Fix version/subtitle labels and add OpenSubtitles search in the TV player.

Clean null metadata in stream labels, show readable subtitle track names, and let viewers pause to pick OpenSubtitles during playback.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-08-31 23:37:40 +02:00
parent 2ce482370b
commit 4b3c594609
10 changed files with 562 additions and 57 deletions

View file

@ -12,8 +12,8 @@ android {
applicationId = "nl.vonas.mediacluster.tv" applicationId = "nl.vonas.mediacluster.tv"
minSdk = 24 minSdk = 24
targetSdk = 35 targetSdk = 35
versionCode = 10 versionCode = 11
versionName = "0.7.3" versionName = "0.7.4"
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
ndk { ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a") abiFilters += listOf("arm64-v8a", "armeabi-v7a")

View file

@ -193,6 +193,60 @@ class ApiClient(
Unit Unit
} }
suspend fun searchSubtitles(mediaFileId: String, languages: String = "nl,en"): SubtitleSearchResult =
withContext(Dispatchers.IO) {
val id = java.net.URLEncoder.encode(mediaFileId, "UTF-8")
val langs = java.net.URLEncoder.encode(languages, "UTF-8")
val req = authed("$baseUrl/api/v1/client/subtitles/search?mediaFileId=$id&languages=$langs")
.get()
.build()
parseObject(execute(req)) { o ->
val arr = o.optJSONArray("results") ?: JSONArray()
val results = buildList {
for (i in 0 until arr.length()) {
val r = arr.getJSONObject(i)
add(
SubtitleHit(
fileId = r.optInt("fileId"),
language = r.optString("language"),
release = r.optString("release").ifBlank { r.optString("fileName") },
fileName = r.optString("fileName"),
downloadCount = r.optInt("downloadCount"),
hearingImpaired = r.optBoolean("hearingImpaired"),
source = r.optString("source").ifBlank { "org" },
uploader = r.optString("uploader").takeIf { it.isNotBlank() && it != "null" },
)
)
}
}
SubtitleSearchResult(
configured = o.optBoolean("configured", true),
imdbId = o.optString("imdbId").takeIf { it.isNotBlank() && it != "null" },
title = o.optString("title").takeIf { it.isNotBlank() && it != "null" },
results = results,
)
}
}
suspend fun fetchSubtitle(fileId: Int, source: String): FetchedSubtitle =
withContext(Dispatchers.IO) {
val body = JSONObject()
.put("fileId", fileId)
.put("source", source)
.toString()
.toRequestBody(jsonMedia)
val req = authed("$baseUrl/api/v1/client/subtitles/fetch").post(body).build()
parseObject(execute(req)) { o ->
FetchedSubtitle(
fileId = o.optInt("fileId", fileId),
source = o.optString("source").ifBlank { source },
fileName = o.optString("fileName").ifBlank { "subtitle.srt" },
format = o.optString("format").ifBlank { "srt" },
contentBase64 = o.getString("contentBase64"),
)
}
}
suspend fun dismissContinue(progressId: String) = withContext(Dispatchers.IO) { suspend fun dismissContinue(progressId: String) = withContext(Dispatchers.IO) {
val req = authed("$baseUrl/api/v1/client/progress/$progressId/dismiss") val req = authed("$baseUrl/api/v1/client/progress/$progressId/dismiss")
.post("{}".toRequestBody(jsonMedia)) .post("{}".toRequestBody(jsonMedia))
@ -284,6 +338,32 @@ data class PlayResult(
val mediaFileId: String, val mediaFileId: String,
) )
data class SubtitleHit(
val fileId: Int,
val language: String,
val release: String,
val fileName: String,
val downloadCount: Int,
val hearingImpaired: Boolean,
val source: String,
val uploader: String? = null,
)
data class SubtitleSearchResult(
val configured: Boolean,
val imdbId: String?,
val title: String?,
val results: List<SubtitleHit>,
)
data class FetchedSubtitle(
val fileId: Int,
val source: String,
val fileName: String,
val format: String,
val contentBase64: String,
)
data class ShelfPref( data class ShelfPref(
val shelfId: String, val shelfId: String,
val name: String, val name: String,

View file

@ -5,6 +5,7 @@ import android.os.Handler
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.media3.common.AudioAttributes import androidx.media3.common.AudioAttributes
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes import androidx.media3.common.MimeTypes
import androidx.media3.common.TrackSelectionOverride import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.Tracks import androidx.media3.common.Tracks
@ -119,21 +120,77 @@ fun ExoPlayer.listTracks(type: Int): List<PlayerTrack> {
if (group.type != type) continue if (group.type != type) continue
for (ti in 0 until group.length) { for (ti in 0 until group.length) {
val format = group.getTrackFormat(ti) val format = group.getTrackFormat(ti)
val mime = format.sampleMimeType ?: "?" val label = if (type == C.TRACK_TYPE_TEXT) {
val lang = format.language?.uppercase() ?: "" formatTextTrackLabel(format, ti)
val ch = format.channelCount.takeIf { it > 0 }?.let { "${it}ch" } ?: "" } else {
val label = listOfNotNull( formatAudioTrackLabel(format, ti)
format.label?.takeIf { it.isNotBlank() }, }
lang.takeIf { it.isNotBlank() },
shortMime(mime),
ch.takeIf { it.isNotBlank() },
).joinToString(" · ").ifBlank { "Track ${ti + 1}" }
out += PlayerTrack(gi, ti, label, type) out += PlayerTrack(gi, ti, label, type)
} }
} }
return out return out
} }
private fun formatAudioTrackLabel(format: androidx.media3.common.Format, index: Int): String {
val mime = format.sampleMimeType ?: ""
val lang = prettyLanguage(format.language)
val ch = format.channelCount.takeIf { it > 0 }?.let { "${it}ch" }
return listOfNotNull(
format.label?.takeIf { it.isNotBlank() && !it.equals("und", true) },
lang,
shortMime(mime).takeIf { it.isNotBlank() },
ch,
).joinToString(" · ").ifBlank { "Audio ${index + 1}" }
}
private fun formatTextTrackLabel(format: androidx.media3.common.Format, index: Int): String {
val lang = prettyLanguage(format.language)
val kind = when {
format.sampleMimeType?.contains("vtt", true) == true -> "VTT"
format.sampleMimeType?.contains("ssa", true) == true ||
format.sampleMimeType?.contains("ass", true) == true -> "ASS"
format.sampleMimeType?.contains("subrip", true) == true ||
format.sampleMimeType?.contains("srt", true) == true -> "SRT"
else -> null
}
val forced = if ((format.selectionFlags and C.SELECTION_FLAG_FORCED) != 0) "Forced" else null
val label = format.label?.takeIf {
it.isNotBlank() &&
!it.equals("und", true) &&
!it.equals("sub", true) &&
!it.equals("subtitle", true)
}
return listOfNotNull(label, lang, kind, forced)
.distinct()
.joinToString(" · ")
.ifBlank { "Ondertitel ${index + 1}" }
}
private fun prettyLanguage(code: String?): String? {
val c = code?.trim()?.lowercase()?.takeIf { it.isNotEmpty() && it != "und" && it != "null" } ?: return null
return when (c) {
"nl", "dut", "nld" -> "Nederlands"
"en", "eng" -> "Engels"
"de", "ger", "deu" -> "Duits"
"fr", "fre", "fra" -> "Frans"
"es", "spa" -> "Spaans"
"it", "ita" -> "Italiaans"
"pt", "por" -> "Portugees"
"pl", "pol" -> "Pools"
"ru", "rus" -> "Russisch"
"ja", "jpn" -> "Japans"
"ko", "kor" -> "Koreaans"
"zh", "chi", "zho", "cmn" -> "Chinees"
"ar", "ara" -> "Arabisch"
"tr", "tur" -> "Turks"
"sv", "swe" -> "Zweeds"
"no", "nor", "nb", "nn" -> "Noors"
"da", "dan" -> "Deens"
"fi", "fin" -> "Fins"
else -> c.uppercase()
}
}
/** Kies een bruikbaar audiospoor als er nog niets speelt (commentary overslaan). */ /** Kies een bruikbaar audiospoor als er nog niets speelt (commentary overslaan). */
fun ExoPlayer.ensureAudioSelected() { fun ExoPlayer.ensureAudioSelected() {
if (currentTracks.hasAudioSelected()) { if (currentTracks.hasAudioSelected()) {
@ -170,6 +227,34 @@ fun ExoPlayer.disableTrackType(type: Int) {
.build() .build()
} }
fun ExoPlayer.applySideloadedSubtitle(
uri: android.net.Uri,
mimeType: String,
language: String,
label: String,
) {
val current = currentMediaItem ?: return
val pos = currentPosition.coerceAtLeast(0)
val wasPlaying = playWhenReady
val sub = MediaItem.SubtitleConfiguration.Builder(uri)
.setMimeType(mimeType)
.setLanguage(language)
.setLabel(label)
.setSelectionFlags(C.SELECTION_FLAG_DEFAULT)
.build()
val next = current.buildUpon()
.setSubtitleConfigurations(listOf(sub))
.build()
setMediaItem(next, /* startPositionMs = */ pos)
trackSelectionParameters = trackSelectionParameters
.buildUpon()
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
.setPreferredTextLanguage(language)
.build()
prepare()
playWhenReady = wasPlaying
}
private fun shortMime(mime: String): String = when { private fun shortMime(mime: String): String = when {
MimeTypes.AUDIO_E_AC3 == mime || mime.contains("eac3") || mime.contains("ec-3") -> "E-AC3" MimeTypes.AUDIO_E_AC3 == mime || mime.contains("eac3") || mime.contains("ec-3") -> "E-AC3"
MimeTypes.AUDIO_AC3 == mime || mime.contains("ac3") -> "AC3" MimeTypes.AUDIO_AC3 == mime || mime.contains("ac3") -> "AC3"
@ -179,8 +264,7 @@ private fun shortMime(mime: String): String = when {
MimeTypes.AUDIO_AAC == mime || mime.contains("mp4a") -> "AAC" MimeTypes.AUDIO_AAC == mime || mime.contains("mp4a") -> "AAC"
mime.contains("vorbis") -> "Vorbis" mime.contains("vorbis") -> "Vorbis"
mime.startsWith("audio/") -> mime.substringAfter("audio/").uppercase() mime.startsWith("audio/") -> mime.substringAfter("audio/").uppercase()
mime.startsWith("application/") || mime.startsWith("text/") -> "SUB" else -> ""
else -> mime.substringAfterLast('/').uppercase()
} }
fun Tracks.hasAudioSelected(): Boolean { fun Tracks.hasAudioSelected(): Boolean {

View file

@ -717,13 +717,14 @@ private fun MovieDetailScreen(
if (arr != null) { if (arr != null) {
for (i in 0 until arr.length()) { for (i in 0 until arr.length()) {
val f = arr.getJSONObject(i) val f = arr.getJSONObject(i)
val label = buildList { val fromApi = f.optCleanString("label")
f.optString("resolution").ifBlank { null }?.let(::add) val label = fromApi ?: buildList {
f.optString("hdrType").ifBlank { null }?.let(::add) f.optCleanString("resolution")?.let(::add)
f.optString("videoCodec").ifBlank { null }?.let(::add) f.optCleanString("hdrType")?.let(::add)
f.optString("audioCodec").ifBlank { null }?.let(::add) f.optCleanString("videoCodec")?.let(::add)
f.optString("releaseName").ifBlank { null }?.let { if (isEmpty()) add(it.take(42)) } f.optCleanString("audioCodec")?.let(::add)
f.optString("nodeName").ifBlank { null }?.let { if (isEmpty()) add(it) } f.optCleanString("releaseName")?.let { if (isEmpty()) add(it.take(42)) }
f.optCleanString("nodeName")?.let { if (isEmpty()) add(it) }
}.joinToString(" · ").ifBlank { "Versie ${i + 1}" } }.joinToString(" · ").ifBlank { "Versie ${i + 1}" }
list += f.getString("id") to label list += f.getString("id") to label
} }
@ -918,3 +919,13 @@ private fun SettingsScreen(
} }
} }
} }
/** Android JSONObject.optString returns the literal "null" for JSON null — filter that. */
private fun org.json.JSONObject.optCleanString(key: String): String? {
if (!has(key) || isNull(key)) return null
val raw = optString(key).trim()
if (raw.isEmpty() || raw.equals("null", ignoreCase = true) || raw.equals("undefined", ignoreCase = true)) {
return null
}
return raw
}

View file

@ -51,6 +51,7 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.MediaItem as ExoMediaItem import androidx.media3.common.MediaItem as ExoMediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackException
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.AspectRatioFrameLayout
@ -60,9 +61,12 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Surface import androidx.tv.material3.Surface
import androidx.tv.material3.Text import androidx.tv.material3.Text
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import nl.vonas.mediacluster.tv.data.ApiClient import nl.vonas.mediacluster.tv.data.ApiClient
import nl.vonas.mediacluster.tv.data.SubtitleHit
import nl.vonas.mediacluster.tv.playback.PlayerTrack import nl.vonas.mediacluster.tv.playback.PlayerTrack
import nl.vonas.mediacluster.tv.playback.applySideloadedSubtitle
import nl.vonas.mediacluster.tv.playback.buildTvPlayer import nl.vonas.mediacluster.tv.playback.buildTvPlayer
import nl.vonas.mediacluster.tv.playback.disableTrackType import nl.vonas.mediacluster.tv.playback.disableTrackType
import nl.vonas.mediacluster.tv.playback.ensureAudioSelected import nl.vonas.mediacluster.tv.playback.ensureAudioSelected
@ -70,6 +74,9 @@ import nl.vonas.mediacluster.tv.playback.listTracks
import nl.vonas.mediacluster.tv.playback.selectTrack import nl.vonas.mediacluster.tv.playback.selectTrack
import nl.vonas.mediacluster.tv.ui.components.McButton import nl.vonas.mediacluster.tv.ui.components.McButton
import nl.vonas.mediacluster.tv.ui.theme.McColors import nl.vonas.mediacluster.tv.ui.theme.McColors
import android.util.Base64
import androidx.compose.runtime.rememberCoroutineScope
import java.io.File
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
@ -87,6 +94,7 @@ fun PlayerScreen(
val player = remember { buildTvPlayer(context) } val player = remember { buildTvPlayer(context) }
val controlsFocus = remember { FocusRequester() } val controlsFocus = remember { FocusRequester() }
val catcherFocus = remember { FocusRequester() } val catcherFocus = remember { FocusRequester() }
val scope = rememberCoroutineScope()
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
var loading by remember { mutableStateOf(true) } var loading by remember { mutableStateOf(true) }
@ -96,7 +104,11 @@ fun PlayerScreen(
var durationMs by remember { mutableLongStateOf(0L) } var durationMs by remember { mutableLongStateOf(0L) }
var audioTracks by remember { mutableStateOf<List<PlayerTrack>>(emptyList()) } var audioTracks by remember { mutableStateOf<List<PlayerTrack>>(emptyList()) }
var textTracks by remember { mutableStateOf<List<PlayerTrack>>(emptyList()) } var textTracks by remember { mutableStateOf<List<PlayerTrack>>(emptyList()) }
var menu by remember { mutableStateOf<String?>(null) } // audio | text var menu by remember { mutableStateOf<String?>(null) } // audio | text | os
var osHits by remember { mutableStateOf<List<SubtitleHit>>(emptyList()) }
var osLoading by remember { mutableStateOf(false) }
var osError by remember { mutableStateOf<String?>(null) }
var osBusy by remember { mutableStateOf(false) }
fun showControls() { fun showControls() {
controls = true controls = true
@ -111,8 +123,76 @@ fun PlayerScreen(
showControls() showControls()
} }
fun openOpenSubtitles() {
player.pause()
menu = "os"
osError = null
osHits = emptyList()
osLoading = true
showControls()
scope.launch {
try {
val res = api.searchSubtitles(mediaFileId)
if (!res.configured) {
osError = "OpenSubtitles staat uit in de admin"
} else if (res.results.isEmpty()) {
osError = "Geen ondertitels gevonden"
}
osHits = res.results
} catch (e: Exception) {
osError = e.message ?: "Zoeken mislukt"
} finally {
osLoading = false
}
}
}
fun pickOpenSubtitle(hit: SubtitleHit) {
if (osBusy) return
osBusy = true
osError = null
scope.launch {
try {
val fetched = api.fetchSubtitle(hit.fileId, hit.source)
val dir = File(context.cacheDir, "subs").also { it.mkdirs() }
val ext = when (fetched.format.lowercase()) {
"vtt" -> "vtt"
"ass", "ssa" -> "ass"
else -> "srt"
}
val file = File(dir, "os-${fetched.source}-${fetched.fileId}.$ext")
file.writeBytes(Base64.decode(fetched.contentBase64, Base64.DEFAULT))
val mime = when (ext) {
"vtt" -> MimeTypes.TEXT_VTT
"ass" -> MimeTypes.TEXT_SSA
else -> MimeTypes.APPLICATION_SUBRIP
}
val lang = hit.language.ifBlank { "nl" }
val label = buildList {
add(langLabel(lang))
hit.release.takeIf { it.isNotBlank() }?.let { add(it.take(40)) }
if (hit.hearingImpaired) add("SDH")
}.joinToString(" · ")
player.applySideloadedSubtitle(
uri = android.net.Uri.fromFile(file),
mimeType = mime,
language = lang,
label = label,
)
menu = null
player.play()
showControls()
} catch (e: Exception) {
osError = e.message ?: "Download mislukt"
} finally {
osBusy = false
}
}
}
BackHandler { BackHandler {
when { when {
menu == "os" -> menu = "text"
menu != null -> menu = null menu != null -> menu = null
controls -> onBack() controls -> onBack()
else -> showControls() else -> showControls()
@ -427,6 +507,7 @@ fun PlayerScreen(
if (menu != null) { if (menu != null) {
val isAudio = menu == "audio" val isAudio = menu == "audio"
val isOs = menu == "os"
val tracks = if (isAudio) audioTracks else textTracks val tracks = if (isAudio) audioTracks else textTracks
Box( Box(
Modifier Modifier
@ -437,27 +518,67 @@ fun PlayerScreen(
Column( Column(
Modifier Modifier
.fillMaxHeight() .fillMaxHeight()
.width(400.dp) .width(420.dp)
.background(Color(0xF20A0A0A)) .background(Color(0xF20A0A0A))
.padding(horizontal = 28.dp, vertical = 32.dp), .padding(horizontal = 28.dp, vertical = 32.dp),
verticalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
Text( Text(
if (isAudio) "Audio" else "Ondertiteling", when {
isAudio -> "Audio"
isOs -> "OpenSubtitles"
else -> "Ondertiteling"
},
color = Color.White, color = Color.White,
fontSize = 22.sp, fontSize = 22.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
if (isOs) {
if (osLoading) {
Text("Zoeken…", color = Color(0xFF8A8A8A), fontSize = 14.sp)
}
if (osError != null) {
Text(osError!!, color = Color(0xFFFF6B6B), fontSize = 14.sp)
}
if (osBusy) {
Text("Bezig met downloaden…", color = Color(0xFF8A8A8A), fontSize = 14.sp)
}
LazyColumn(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(osHits, key = { "${it.source}-${it.fileId}" }) { hit ->
val rowLabel = buildList {
add(langLabel(hit.language))
hit.release.takeIf { it.isNotBlank() }?.let { add(it.take(36)) }
if (hit.hearingImpaired) add("SDH")
add(hit.source.uppercase())
if (hit.downloadCount > 0) add("${hit.downloadCount}×")
}.joinToString(" · ")
NetflixTrackRow(rowLabel, selected = false) {
pickOpenSubtitle(hit)
}
}
}
Spacer(Modifier.height(8.dp))
NetflixTextButton("Terug") { menu = "text" }
} else {
if (!isAudio) { if (!isAudio) {
NetflixTrackRow("Uit", selected = false) { NetflixTrackRow("Uit", selected = false) {
player.disableTrackType(C.TRACK_TYPE_TEXT) player.disableTrackType(C.TRACK_TYPE_TEXT)
menu = null menu = null
} }
NetflixTrackRow("Zoeken op OpenSubtitles…", selected = false) {
openOpenSubtitles()
} }
if (tracks.isEmpty()) { }
if (tracks.isEmpty() && isAudio) {
Text("Geen sporen", color = Color(0xFF8A8A8A), fontSize = 14.sp) Text("Geen sporen", color = Color(0xFF8A8A8A), fontSize = 14.sp)
} }
if (tracks.isEmpty() && !isAudio) {
Text("Geen ingebouwde ondertitels", color = Color(0xFF8A8A8A), fontSize = 14.sp)
}
LazyColumn( LazyColumn(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
@ -475,6 +596,19 @@ fun PlayerScreen(
} }
} }
} }
}
}
private fun langLabel(code: String): String {
val c = code.trim().lowercase()
return when (c) {
"nl", "dut", "nld" -> "Nederlands"
"en", "eng" -> "Engels"
"de", "ger", "deu" -> "Duits"
"fr", "fre", "fra" -> "Frans"
"es", "spa" -> "Spaans"
else -> c.uppercase().ifBlank { "ONB" }
}
} }
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)

View file

@ -74,9 +74,9 @@ async function main() {
await registerStremioRoutes(app, config); await registerStremioRoutes(app, config);
await registerAdminRoutes(app, config); await registerAdminRoutes(app, config);
await registerInstallRoutes(app, config); await registerInstallRoutes(app, config);
registerDownloadRoutes(app, config);
registerSettingsRoutes(app, config); registerSettingsRoutes(app, config);
registerViewerRoutes(app, config); const downloads = registerDownloadRoutes(app, config);
registerViewerRoutes(app, config, downloads);
app.get("/api/v1/node/connect", { websocket: true }, (socket) => { app.get("/api/v1/node/connect", { websocket: true }, (socket) => {
void nodeConnectionManager.handleConnection(socket); void nodeConnectionManager.handleConnection(socket);

View file

@ -86,6 +86,8 @@ export class DownloadService {
releaseHint?: string; releaseHint?: string;
movieTitle?: string; movieTitle?: string;
movieYear?: number | null; movieYear?: number | null;
/** Fallback voor OS.org (bijv. serie S01E02) als IMDb ontbreekt of episode-specifiek. */
query?: string;
}): Promise<{ }): Promise<{
imdbId: string | null; imdbId: string | null;
results: SubtitleHit[]; results: SubtitleHit[];
@ -106,9 +108,10 @@ export class DownloadService {
} }
const languages = opts.languages?.trim() || "nl"; const languages = opts.languages?.trim() || "nl";
const query = opts.query?.trim() || undefined;
// Alleen IMDb — free-text query mengt verkeerde films door elkaar // Zonder IMDb: alleen OS.org query (als aanwezig)
if (!imdbId) { if (!imdbId && !query) {
return { imdbId: null, results: [], configured }; return { imdbId: null, results: [], configured };
} }
@ -120,11 +123,15 @@ export class DownloadService {
return [] as SubtitleHit[]; return [] as SubtitleHit[];
}); });
if (comClient) { if (imdbId && comClient) {
tasks.push(safe("com/imdb", comClient.search({ imdbId, languages }))); tasks.push(safe("com/imdb", comClient.search({ imdbId, languages })));
} }
// OpenSubtitles.org = aparte catalogus (rest.opensubtitles.org) if (imdbId) {
tasks.push(safe("org/imdb", orgClient.search({ imdbId, languages }))); tasks.push(safe("org/imdb", orgClient.search({ imdbId, languages })));
}
if (query) {
tasks.push(safe("org/query", orgClient.search({ query, languages })));
}
if (tasks.length === 0) { if (tasks.length === 0) {
return { imdbId, results: [], configured }; return { imdbId, results: [], configured };
@ -141,7 +148,7 @@ export class DownloadService {
if (r.language === "nl" || r.language.startsWith("nl") || r.language === "dut") { if (r.language === "nl" || r.language.startsWith("nl") || r.language === "dut") {
score += 1_000_000; score += 1_000_000;
} }
if (r.source === "org") score += 8; // .org vaak rijker; niet onder .com laten verdwijnen if (r.source === "org") score += 8;
if (r.source === "com") score += 5; if (r.source === "com") score += 5;
if (hint) { if (hint) {
const hay = `${r.release} ${r.fileName}`.toLowerCase(); const hay = `${r.release} ${r.fileName}`.toLowerCase();
@ -158,6 +165,82 @@ export class DownloadService {
return { imdbId, results: scored.slice(0, 40), configured }; return { imdbId, results: scored.slice(0, 40), configured };
} }
/** TV/client: resolve IMDb (+ episode query) from media file, then search OS.com/OS.org. */
async searchSubtitlesForMediaFile(mediaFileId: string, languages = "nl,en") {
const file = await prisma.mediaFile.findUnique({
where: { id: mediaFileId },
include: {
movie: { select: { imdbId: true, tmdbId: true, title: true, year: true } },
episode: {
select: {
imdbId: true,
tmdbId: true,
title: true,
seasonNumber: true,
episodeNumber: true,
series: { select: { imdbId: true, tmdbId: true, title: true, year: true } },
},
},
},
});
if (!file) throw new AppError("NOT_FOUND", "Mediabestand niet gevonden", 404);
const imdbId =
file.movie?.imdbId ||
file.episode?.imdbId ||
file.episode?.series?.imdbId ||
undefined;
const tmdbId = file.movie?.tmdbId || file.episode?.tmdbId || file.episode?.series?.tmdbId || undefined;
const movieTitle = file.movie?.title || file.episode?.series?.title || undefined;
const movieYear = file.movie?.year ?? file.episode?.series?.year ?? null;
let query: string | undefined;
if (file.episode && !file.episode.imdbId) {
const s = String(file.episode.seasonNumber).padStart(2, "0");
const e = String(file.episode.episodeNumber).padStart(2, "0");
const name = file.episode.series.title;
query = `${name} S${s}E${e}`;
}
const base = await this.searchSubtitles({
imdbId,
tmdbId: tmdbId ?? undefined,
languages,
releaseHint: file.releaseName,
movieTitle,
movieYear,
query,
});
return {
...base,
title: movieTitle || null,
seasonNumber: file.episode?.seasonNumber ?? null,
episodeNumber: file.episode?.episodeNumber ?? null,
};
}
async downloadSubtitleForClient(fileId: number, source: SubtitleSource) {
if (!Number.isFinite(fileId) || fileId <= 0) {
throw new AppError("INVALID_REQUEST", "fileId ongeldig", 400);
}
const src: SubtitleSource = source === "com" ? "com" : "org";
const file = await this.downloadSubtitleFile(fileId, src);
const lower = file.fileName.toLowerCase();
const format = lower.endsWith(".vtt")
? "vtt"
: lower.endsWith(".ass") || lower.endsWith(".ssa")
? "ass"
: "srt";
return {
fileId,
source: src,
fileName: file.fileName,
format,
contentBase64: file.content.toString("base64"),
};
}
private async clientForNode(nodeId: string): Promise<{ private async clientForNode(nodeId: string): Promise<{
client: SynologyDownloadStation; client: SynologyDownloadStation;
config: Awaited<ReturnType<typeof prisma.synologyDsConfig.findUniqueOrThrow>>; config: Awaited<ReturnType<typeof prisma.synologyDsConfig.findUniqueOrThrow>>;

View file

@ -0,0 +1,58 @@
/** Strip JSON/DB junk ("null", empty) from media metadata fields. */
export function cleanMeta(value: string | null | undefined): string | null {
if (value == null) return null;
const t = String(value).trim();
if (!t) return null;
if (/^(null|undefined|none|n\/a|unknown)$/i.test(t)) return null;
return t;
}
function prettyCodec(value: string): string {
const v = value.trim();
const lower = v.toLowerCase();
if (/^h\.?264$/i.test(v) || lower === "avc" || lower === "avc1") return "H.264";
if (/^h\.?265$/i.test(v) || lower === "hevc" || lower === "x265") return "HEVC";
if (lower === "x264") return "x264";
if (/atmos/i.test(v)) return v.replace(/atmos/gi, "Atmos");
if (/truehd/i.test(v)) return v.replace(/truehd/gi, "TrueHD");
if (/dts-?hd/i.test(v)) return "DTS-HD";
if (lower === "eac3" || lower === "e-ac3" || lower === "ec-3") return "E-AC3";
if (lower === "ac3" || lower === "ac-3") return "AC3";
return v;
}
export function formatMediaFileLabel(file: {
resolution?: string | null;
hdrType?: string | null;
videoCodec?: string | null;
audioCodec?: string | null;
audioChannels?: string | null;
releaseName?: string | null;
nodeName?: string | null;
index?: number;
}): string {
const bits: string[] = [];
const resolution = cleanMeta(file.resolution);
const hdr = cleanMeta(file.hdrType);
const video = cleanMeta(file.videoCodec);
const audio = cleanMeta(file.audioCodec);
const channels = cleanMeta(file.audioChannels);
if (resolution) bits.push(resolution);
if (hdr) bits.push(hdr.toUpperCase() === hdr ? hdr : hdr);
if (video) bits.push(prettyCodec(video));
if (audio) bits.push(prettyCodec(audio));
if (channels && !bits.some((b) => b.toLowerCase().includes(channels.toLowerCase()))) {
bits.push(channels);
}
if (bits.length) return bits.join(" · ");
const release = cleanMeta(file.releaseName);
if (release) return release.length > 48 ? `${release.slice(0, 45)}…` : release;
const node = cleanMeta(file.nodeName);
if (node) return node;
return `Versie ${(file.index ?? 0) + 1}`;
}

View file

@ -4,10 +4,16 @@ import { requireAdmin } from "../auth/routes";
import { PlaybackService } from "../playback/service"; import { PlaybackService } from "../playback/service";
import type { Config } from "../config"; import type { Config } from "../config";
import { ViewerService } from "./service"; import { ViewerService } from "./service";
import type { DownloadService } from "../downloads/service";
import type { SubtitleSource } from "../opensubtitles/client";
export function registerViewerRoutes(app: FastifyInstance, config: Config) { export function registerViewerRoutes(
app: FastifyInstance,
config: Config,
downloads?: DownloadService
) {
const playback = new PlaybackService(config); const playback = new PlaybackService(config);
const viewers = new ViewerService(config, playback); const viewers = new ViewerService(config, playback, downloads);
// ——— Admin: kijkers ——— // ——— Admin: kijkers ———
app.get("/api/v1/admin/viewers", { preHandler: requireAdmin }, async () => { app.get("/api/v1/admin/viewers", { preHandler: requireAdmin }, async () => {
@ -202,4 +208,24 @@ export function registerViewerRoutes(app: FastifyInstance, config: Config) {
} }
return { shelves: await viewers.setShelfPrefs(auth.viewerId, body.shelves) }; return { shelves: await viewers.setShelfPrefs(auth.viewerId, body.shelves) };
}); });
app.get("/api/v1/client/subtitles/search", async (request) => {
await viewers.authFromBearer(request.headers.authorization);
const q = request.query as { mediaFileId?: string; languages?: string };
if (!q.mediaFileId?.trim()) {
throw new AppError("INVALID_REQUEST", "mediaFileId verplicht", 400);
}
return viewers.searchSubtitles(q.mediaFileId.trim(), q.languages || "nl,en");
});
app.post("/api/v1/client/subtitles/fetch", async (request) => {
await viewers.authFromBearer(request.headers.authorization);
const body = request.body as { fileId?: number; source?: string };
const fileId = typeof body.fileId === "number" ? body.fileId : parseInt(String(body.fileId || ""), 10);
if (!Number.isFinite(fileId) || fileId <= 0) {
throw new AppError("INVALID_REQUEST", "fileId verplicht", 400);
}
const source: SubtitleSource = body.source === "com" ? "com" : "org";
return viewers.fetchSubtitle(fileId, source);
});
} }

View file

@ -10,6 +10,9 @@ import { AppError } from "../security/errors";
import { PlaybackService } from "../playback/service"; import { PlaybackService } from "../playback/service";
import type { Config } from "../config"; import type { Config } from "../config";
import type { FastifyRequest } from "fastify"; import type { FastifyRequest } from "fastify";
import { cleanMeta, formatMediaFileLabel } from "./media-label";
import type { DownloadService } from "../downloads/service";
import type { SubtitleSource } from "../opensubtitles/client";
const CODE_TTL_MS = 10 * 60 * 1000; const CODE_TTL_MS = 10 * 60 * 1000;
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
@ -41,7 +44,8 @@ export function hashLinkCode(raw: string): string {
export class ViewerService { export class ViewerService {
constructor( constructor(
private readonly config: Config, private readonly config: Config,
private readonly playback: PlaybackService private readonly playback: PlaybackService,
private readonly downloads?: DownloadService
) {} ) {}
async createViewer(input: { async createViewer(input: {
@ -1052,15 +1056,26 @@ export class ViewerService {
backdropUrl: movie.backdropUrl, backdropUrl: movie.backdropUrl,
runtime: movie.runtime, runtime: movie.runtime,
genres: movie.genres, genres: movie.genres,
mediaFiles: movie.mediaFiles.map((f) => ({ mediaFiles: movie.mediaFiles.map((f, index) => ({
id: f.id, id: f.id,
resolution: cleanMeta(f.resolution),
hdrType: cleanMeta(f.hdrType),
videoCodec: cleanMeta(f.videoCodec),
audioCodec: cleanMeta(f.audioCodec),
audioChannels: cleanMeta(f.audioChannels),
releaseName: f.releaseName,
nodeName: f.node.name,
nodeOnline: f.node.status === "ONLINE",
label: formatMediaFileLabel({
resolution: f.resolution, resolution: f.resolution,
hdrType: f.hdrType, hdrType: f.hdrType,
videoCodec: f.videoCodec, videoCodec: f.videoCodec,
audioCodec: f.audioCodec, audioCodec: f.audioCodec,
audioChannels: f.audioChannels,
releaseName: f.releaseName, releaseName: f.releaseName,
nodeName: f.node.name, nodeName: f.node.name,
nodeOnline: f.node.status === "ONLINE", index,
}),
})), })),
}; };
} }
@ -1121,6 +1136,20 @@ export class ViewerService {
}; };
} }
async searchSubtitles(mediaFileId: string, languages = "nl,en") {
if (!this.downloads) {
throw new AppError("NOT_CONFIGURED", "Ondertitelzoeken niet beschikbaar", 503);
}
return this.downloads.searchSubtitlesForMediaFile(mediaFileId, languages);
}
async fetchSubtitle(fileId: number, source: SubtitleSource) {
if (!this.downloads) {
throw new AppError("NOT_CONFIGURED", "Ondertiteldownload niet beschikbaar", 503);
}
return this.downloads.downloadSubtitleForClient(fileId, source);
}
private publicBase(): string { private publicBase(): string {
const base = (this.config.CLIENT_LINK_BASE_URL || this.config.PUBLIC_URL).replace(/\/+$/, ""); const base = (this.config.CLIENT_LINK_BASE_URL || this.config.PUBLIC_URL).replace(/\/+$/, "");
return base; return base;