From 4b3c594609c96322a58f1ee8259d0201f3be7b48 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Mon, 31 Aug 2026 23:37:40 +0200 Subject: [PATCH] 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 --- apps/android-tv/app/build.gradle.kts | 4 +- .../vonas/mediacluster/tv/data/ApiClient.kt | 80 ++++++++ .../mediacluster/tv/playback/PlayerFactory.kt | 106 +++++++++-- .../java/nl/vonas/mediacluster/tv/ui/McNav.kt | 25 ++- .../vonas/mediacluster/tv/ui/PlayerScreen.kt | 176 +++++++++++++++--- apps/master-api/src/app.ts | 4 +- apps/master-api/src/downloads/service.ts | 95 +++++++++- apps/master-api/src/viewer/media-label.ts | 58 ++++++ apps/master-api/src/viewer/routes.ts | 30 ++- apps/master-api/src/viewer/service.ts | 41 +++- 10 files changed, 562 insertions(+), 57 deletions(-) create mode 100644 apps/master-api/src/viewer/media-label.ts diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts index 20bbde7..ceaced4 100644 --- a/apps/android-tv/app/build.gradle.kts +++ b/apps/android-tv/app/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "nl.vonas.mediacluster.tv" minSdk = 24 targetSdk = 35 - versionCode = 10 - versionName = "0.7.3" + versionCode = 11 + versionName = "0.7.4" buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a") diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt index 2c8c05f..55d4f59 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt @@ -193,6 +193,60 @@ class ApiClient( 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) { val req = authed("$baseUrl/api/v1/client/progress/$progressId/dismiss") .post("{}".toRequestBody(jsonMedia)) @@ -284,6 +338,32 @@ data class PlayResult( 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, +) + +data class FetchedSubtitle( + val fileId: Int, + val source: String, + val fileName: String, + val format: String, + val contentBase64: String, +) + data class ShelfPref( val shelfId: String, val name: String, diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt index b59e011..24caa61 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt @@ -5,6 +5,7 @@ import android.os.Handler import androidx.annotation.OptIn import androidx.media3.common.AudioAttributes import androidx.media3.common.C +import androidx.media3.common.MediaItem import androidx.media3.common.MimeTypes import androidx.media3.common.TrackSelectionOverride import androidx.media3.common.Tracks @@ -119,21 +120,77 @@ fun ExoPlayer.listTracks(type: Int): List { if (group.type != type) continue for (ti in 0 until group.length) { val format = group.getTrackFormat(ti) - val mime = format.sampleMimeType ?: "?" - val lang = format.language?.uppercase() ?: "" - val ch = format.channelCount.takeIf { it > 0 }?.let { "${it}ch" } ?: "" - val label = listOfNotNull( - format.label?.takeIf { it.isNotBlank() }, - lang.takeIf { it.isNotBlank() }, - shortMime(mime), - ch.takeIf { it.isNotBlank() }, - ).joinToString(" · ").ifBlank { "Track ${ti + 1}" } + val label = if (type == C.TRACK_TYPE_TEXT) { + formatTextTrackLabel(format, ti) + } else { + formatAudioTrackLabel(format, ti) + } out += PlayerTrack(gi, ti, label, type) } } 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). */ fun ExoPlayer.ensureAudioSelected() { if (currentTracks.hasAudioSelected()) { @@ -170,6 +227,34 @@ fun ExoPlayer.disableTrackType(type: Int) { .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 { MimeTypes.AUDIO_E_AC3 == mime || mime.contains("eac3") || mime.contains("ec-3") -> "E-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" mime.contains("vorbis") -> "Vorbis" mime.startsWith("audio/") -> mime.substringAfter("audio/").uppercase() - mime.startsWith("application/") || mime.startsWith("text/") -> "SUB" - else -> mime.substringAfterLast('/').uppercase() + else -> "" } fun Tracks.hasAudioSelected(): Boolean { diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt index 1d58252..ab9cd60 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt @@ -717,13 +717,14 @@ private fun MovieDetailScreen( if (arr != null) { for (i in 0 until arr.length()) { val f = arr.getJSONObject(i) - val label = buildList { - f.optString("resolution").ifBlank { null }?.let(::add) - f.optString("hdrType").ifBlank { null }?.let(::add) - f.optString("videoCodec").ifBlank { null }?.let(::add) - f.optString("audioCodec").ifBlank { null }?.let(::add) - f.optString("releaseName").ifBlank { null }?.let { if (isEmpty()) add(it.take(42)) } - f.optString("nodeName").ifBlank { null }?.let { if (isEmpty()) add(it) } + val fromApi = f.optCleanString("label") + val label = fromApi ?: buildList { + f.optCleanString("resolution")?.let(::add) + f.optCleanString("hdrType")?.let(::add) + f.optCleanString("videoCodec")?.let(::add) + f.optCleanString("audioCodec")?.let(::add) + f.optCleanString("releaseName")?.let { if (isEmpty()) add(it.take(42)) } + f.optCleanString("nodeName")?.let { if (isEmpty()) add(it) } }.joinToString(" · ").ifBlank { "Versie ${i + 1}" } 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 +} diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt index f21ffd2..e9b968a 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt @@ -51,6 +51,7 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView import androidx.media3.common.C import androidx.media3.common.MediaItem as ExoMediaItem +import androidx.media3.common.MimeTypes import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.ui.AspectRatioFrameLayout @@ -60,9 +61,12 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Surface import androidx.tv.material3.Text import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking 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.applySideloadedSubtitle import nl.vonas.mediacluster.tv.playback.buildTvPlayer import nl.vonas.mediacluster.tv.playback.disableTrackType 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.ui.components.McButton import nl.vonas.mediacluster.tv.ui.theme.McColors +import android.util.Base64 +import androidx.compose.runtime.rememberCoroutineScope +import java.io.File @OptIn(ExperimentalTvMaterial3Api::class) @Composable @@ -87,6 +94,7 @@ fun PlayerScreen( val player = remember { buildTvPlayer(context) } val controlsFocus = remember { FocusRequester() } val catcherFocus = remember { FocusRequester() } + val scope = rememberCoroutineScope() var error by remember { mutableStateOf(null) } var loading by remember { mutableStateOf(true) } @@ -96,7 +104,11 @@ fun PlayerScreen( var durationMs by remember { mutableLongStateOf(0L) } var audioTracks by remember { mutableStateOf>(emptyList()) } var textTracks by remember { mutableStateOf>(emptyList()) } - var menu by remember { mutableStateOf(null) } // audio | text + var menu by remember { mutableStateOf(null) } // audio | text | os + var osHits by remember { mutableStateOf>(emptyList()) } + var osLoading by remember { mutableStateOf(false) } + var osError by remember { mutableStateOf(null) } + var osBusy by remember { mutableStateOf(false) } fun showControls() { controls = true @@ -111,8 +123,76 @@ fun PlayerScreen( 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 { when { + menu == "os" -> menu = "text" menu != null -> menu = null controls -> onBack() else -> showControls() @@ -427,6 +507,7 @@ fun PlayerScreen( if (menu != null) { val isAudio = menu == "audio" + val isOs = menu == "os" val tracks = if (isAudio) audioTracks else textTracks Box( Modifier @@ -437,46 +518,99 @@ fun PlayerScreen( Column( Modifier .fillMaxHeight() - .width(400.dp) + .width(420.dp) .background(Color(0xF20A0A0A)) .padding(horizontal = 28.dp, vertical = 32.dp), verticalArrangement = Arrangement.spacedBy(6.dp), ) { Text( - if (isAudio) "Audio" else "Ondertiteling", + when { + isAudio -> "Audio" + isOs -> "OpenSubtitles" + else -> "Ondertiteling" + }, color = Color.White, fontSize = 22.sp, fontWeight = FontWeight.Bold, ) Spacer(Modifier.height(12.dp)) - if (!isAudio) { - NetflixTrackRow("Uit", selected = false) { - player.disableTrackType(C.TRACK_TYPE_TEXT) - menu = null + if (isOs) { + if (osLoading) { + Text("Zoeken…", color = Color(0xFF8A8A8A), fontSize = 14.sp) } - } - if (tracks.isEmpty()) { - Text("Geen sporen", color = Color(0xFF8A8A8A), fontSize = 14.sp) - } - LazyColumn( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - items(tracks, key = { "${it.groupIndex}-${it.trackIndex}" }) { track -> - NetflixTrackRow(track.label, selected = false) { - player.selectTrack(track) - menu = null + 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) { + NetflixTrackRow("Uit", selected = false) { + player.disableTrackType(C.TRACK_TYPE_TEXT) + menu = null + } + NetflixTrackRow("Zoeken op OpenSubtitles…", selected = false) { + openOpenSubtitles() + } + } + if (tracks.isEmpty() && isAudio) { + Text("Geen sporen", color = Color(0xFF8A8A8A), fontSize = 14.sp) + } + if (tracks.isEmpty() && !isAudio) { + Text("Geen ingebouwde ondertitels", color = Color(0xFF8A8A8A), fontSize = 14.sp) + } + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + items(tracks, key = { "${it.groupIndex}-${it.trackIndex}" }) { track -> + NetflixTrackRow(track.label, selected = false) { + player.selectTrack(track) + menu = null + } + } + } + Spacer(Modifier.height(8.dp)) + NetflixTextButton("Sluiten") { menu = null } } - Spacer(Modifier.height(8.dp)) - NetflixTextButton("Sluiten") { menu = null } } } } } } +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) @Composable private fun NetflixIconButton( diff --git a/apps/master-api/src/app.ts b/apps/master-api/src/app.ts index ccdbdc5..0d0045a 100644 --- a/apps/master-api/src/app.ts +++ b/apps/master-api/src/app.ts @@ -74,9 +74,9 @@ async function main() { await registerStremioRoutes(app, config); await registerAdminRoutes(app, config); await registerInstallRoutes(app, config); - registerDownloadRoutes(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) => { void nodeConnectionManager.handleConnection(socket); diff --git a/apps/master-api/src/downloads/service.ts b/apps/master-api/src/downloads/service.ts index c56e384..87c4b4a 100644 --- a/apps/master-api/src/downloads/service.ts +++ b/apps/master-api/src/downloads/service.ts @@ -86,6 +86,8 @@ export class DownloadService { releaseHint?: string; movieTitle?: string; movieYear?: number | null; + /** Fallback voor OS.org (bijv. serie S01E02) als IMDb ontbreekt of episode-specifiek. */ + query?: string; }): Promise<{ imdbId: string | null; results: SubtitleHit[]; @@ -106,9 +108,10 @@ export class DownloadService { } const languages = opts.languages?.trim() || "nl"; + const query = opts.query?.trim() || undefined; - // Alleen IMDb — free-text query mengt verkeerde films door elkaar - if (!imdbId) { + // Zonder IMDb: alleen OS.org query (als aanwezig) + if (!imdbId && !query) { return { imdbId: null, results: [], configured }; } @@ -120,11 +123,15 @@ export class DownloadService { return [] as SubtitleHit[]; }); - if (comClient) { + if (imdbId && comClient) { tasks.push(safe("com/imdb", comClient.search({ imdbId, languages }))); } - // OpenSubtitles.org = aparte catalogus (rest.opensubtitles.org) - tasks.push(safe("org/imdb", orgClient.search({ imdbId, languages }))); + if (imdbId) { + tasks.push(safe("org/imdb", orgClient.search({ imdbId, languages }))); + } + if (query) { + tasks.push(safe("org/query", orgClient.search({ query, languages }))); + } if (tasks.length === 0) { return { imdbId, results: [], configured }; @@ -141,7 +148,7 @@ export class DownloadService { if (r.language === "nl" || r.language.startsWith("nl") || r.language === "dut") { 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 (hint) { const hay = `${r.release} ${r.fileName}`.toLowerCase(); @@ -158,6 +165,82 @@ export class DownloadService { 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<{ client: SynologyDownloadStation; config: Awaited>; diff --git a/apps/master-api/src/viewer/media-label.ts b/apps/master-api/src/viewer/media-label.ts new file mode 100644 index 0000000..fd373fd --- /dev/null +++ b/apps/master-api/src/viewer/media-label.ts @@ -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}`; +} diff --git a/apps/master-api/src/viewer/routes.ts b/apps/master-api/src/viewer/routes.ts index 221c32f..40de170 100644 --- a/apps/master-api/src/viewer/routes.ts +++ b/apps/master-api/src/viewer/routes.ts @@ -4,10 +4,16 @@ import { requireAdmin } from "../auth/routes"; import { PlaybackService } from "../playback/service"; import type { Config } from "../config"; 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 viewers = new ViewerService(config, playback); + const viewers = new ViewerService(config, playback, downloads); // ——— Admin: kijkers ——— 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) }; }); + + 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); + }); } diff --git a/apps/master-api/src/viewer/service.ts b/apps/master-api/src/viewer/service.ts index f60dee1..38daaa7 100644 --- a/apps/master-api/src/viewer/service.ts +++ b/apps/master-api/src/viewer/service.ts @@ -10,6 +10,9 @@ import { AppError } from "../security/errors"; import { PlaybackService } from "../playback/service"; import type { Config } from "../config"; 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_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; @@ -41,7 +44,8 @@ export function hashLinkCode(raw: string): string { export class ViewerService { constructor( private readonly config: Config, - private readonly playback: PlaybackService + private readonly playback: PlaybackService, + private readonly downloads?: DownloadService ) {} async createViewer(input: { @@ -1052,15 +1056,26 @@ export class ViewerService { backdropUrl: movie.backdropUrl, runtime: movie.runtime, genres: movie.genres, - mediaFiles: movie.mediaFiles.map((f) => ({ + mediaFiles: movie.mediaFiles.map((f, index) => ({ id: f.id, - resolution: f.resolution, - hdrType: f.hdrType, - videoCodec: f.videoCodec, - audioCodec: f.audioCodec, + 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, + hdrType: f.hdrType, + videoCodec: f.videoCodec, + audioCodec: f.audioCodec, + audioChannels: f.audioChannels, + releaseName: f.releaseName, + nodeName: f.node.name, + 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 { const base = (this.config.CLIENT_LINK_BASE_URL || this.config.PUBLIC_URL).replace(/\/+$/, ""); return base;