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;