From 37e7aafdf5e9b0c0ff41c6771cc538961d64aea7 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Tue, 8 Sep 2026 04:08:09 +0200 Subject: [PATCH] Auto-select local sidecar subtitles on playback start. Co-authored-by: Cursor --- .../vonas/mediacluster/tv/data/ApiClient.kt | 24 +++ .../vonas/mediacluster/tv/ui/PlayerScreen.kt | 21 ++ .../vonas/mediacluster/tv/data/ApiClient.kt | 24 +++ .../vonas/mediacluster/tv/ui/PlayerScreen.kt | 21 ++ apps/master-api/src/downloads/sidecars.ts | 49 +++++ apps/master-api/src/stremio/routes.ts | 198 ++++++++++++++++-- apps/master-api/src/viewer/service.ts | 35 ++++ 7 files changed, 354 insertions(+), 18 deletions(-) diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt index 4db5559..7645f23 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt @@ -116,10 +116,23 @@ class ApiClient( val body = JSONObject().put("mediaFileId", mediaFileId).toString().toRequestBody(jsonMedia) val req = authed("$baseUrl/api/v1/client/play").post(body).build() parseObject(execute(req)) { o -> + val def = o.optJSONObject("defaultSubtitle") PlayResult( streamUrl = o.getString("streamUrl"), sessionId = o.optString("sessionId"), mediaFileId = o.optString("mediaFileId"), + defaultSubtitle = def?.let { s -> + val b64 = s.optString("contentBase64").ifBlank { null } ?: return@let null + DefaultSubtitle( + fileId = s.optInt("fileId"), + source = s.optString("source").ifBlank { "local" }, + language = s.optString("language").ifBlank { "nl" }, + fileName = s.optString("fileName").ifBlank { "local.srt" }, + format = s.optString("format").ifBlank { "srt" }, + contentBase64 = b64, + label = s.optString("label").ifBlank { "Lokaal" }, + ) + }, ) } } @@ -567,6 +580,17 @@ data class PlayResult( val streamUrl: String, val sessionId: String, val mediaFileId: String, + val defaultSubtitle: DefaultSubtitle? = null, +) + +data class DefaultSubtitle( + val fileId: Int, + val source: String, + val language: String, + val fileName: String, + val format: String, + val contentBase64: String, + val label: String, ) data class SubtitleHit( diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt index 251b81c..2720311 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt @@ -212,6 +212,27 @@ fun PlayerScreen( player.setMediaItem(ExoMediaItem.fromUri(play.streamUrl)) player.prepare() if (startMs > 5_000) player.seekTo(startMs) + play.defaultSubtitle?.let { sub -> + val dir = File(context.cacheDir, "subs").also { it.mkdirs() } + val ext = when (sub.format.lowercase()) { + "vtt" -> "vtt" + "ass", "ssa" -> "ass" + else -> "srt" + } + val file = File(dir, "auto-local-${sub.fileId}.$ext") + file.writeBytes(Base64.decode(sub.contentBase64, Base64.DEFAULT)) + val mime = when (ext) { + "vtt" -> MimeTypes.TEXT_VTT + "ass" -> MimeTypes.TEXT_SSA + else -> MimeTypes.APPLICATION_SUBRIP + } + player.applySideloadedSubtitle( + uri = android.net.Uri.fromFile(file), + mimeType = mime, + language = sub.language.ifBlank { "nl" }, + label = sub.label.ifBlank { "Lokaal" }, + ) + } player.play() } catch (e: Exception) { error = e.message ?: "Afspelen mislukt" 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 64c767c..8e85bdd 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 @@ -116,10 +116,23 @@ class ApiClient( val body = JSONObject().put("mediaFileId", mediaFileId).toString().toRequestBody(jsonMedia) val req = authed("$baseUrl/api/v1/client/play").post(body).build() parseObject(execute(req)) { o -> + val def = o.optJSONObject("defaultSubtitle") PlayResult( streamUrl = o.getString("streamUrl"), sessionId = o.optString("sessionId"), mediaFileId = o.optString("mediaFileId"), + defaultSubtitle = def?.let { s -> + val b64 = s.optString("contentBase64").ifBlank { null } ?: return@let null + DefaultSubtitle( + fileId = s.optInt("fileId"), + source = s.optString("source").ifBlank { "local" }, + language = s.optString("language").ifBlank { "nl" }, + fileName = s.optString("fileName").ifBlank { "local.srt" }, + format = s.optString("format").ifBlank { "srt" }, + contentBase64 = b64, + label = s.optString("label").ifBlank { "Lokaal" }, + ) + }, ) } } @@ -538,6 +551,17 @@ data class PlayResult( val streamUrl: String, val sessionId: String, val mediaFileId: String, + val defaultSubtitle: DefaultSubtitle? = null, +) + +data class DefaultSubtitle( + val fileId: Int, + val source: String, + val language: String, + val fileName: String, + val format: String, + val contentBase64: String, + val label: String, ) data class SubtitleHit( 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 6905e97..e08c80e 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 @@ -209,6 +209,27 @@ fun PlayerScreen( player.setMediaItem(ExoMediaItem.fromUri(play.streamUrl)) player.prepare() if (startMs > 5_000) player.seekTo(startMs) + play.defaultSubtitle?.let { sub -> + val dir = File(context.cacheDir, "subs").also { it.mkdirs() } + val ext = when (sub.format.lowercase()) { + "vtt" -> "vtt" + "ass", "ssa" -> "ass" + else -> "srt" + } + val file = File(dir, "auto-local-${sub.fileId}.$ext") + file.writeBytes(Base64.decode(sub.contentBase64, Base64.DEFAULT)) + val mime = when (ext) { + "vtt" -> MimeTypes.TEXT_VTT + "ass" -> MimeTypes.TEXT_SSA + else -> MimeTypes.APPLICATION_SUBRIP + } + player.applySideloadedSubtitle( + uri = android.net.Uri.fromFile(file), + mimeType = mime, + language = sub.language.ifBlank { "nl" }, + label = sub.label.ifBlank { "Lokaal" }, + ) + } player.play() } catch (e: Exception) { error = e.message ?: "Afspelen mislukt" diff --git a/apps/master-api/src/downloads/sidecars.ts b/apps/master-api/src/downloads/sidecars.ts index 2410b3e..1d6c5e7 100644 --- a/apps/master-api/src/downloads/sidecars.ts +++ b/apps/master-api/src/downloads/sidecars.ts @@ -135,3 +135,52 @@ export async function discoverSidecarSubtitles(mediaFileId: string): Promise h.source === "local"); + const pool = local.length ? local : hits; + return ( + pool.find((h) => isDutchSubtitleLang(h.language)) || + pool[0] || + null + ); +} + +/** Discover sidecars and return matching library_subtitles rows (for Stremio URLs). */ +export async function listLibrarySubtitlesForMediaFile(mediaFileId: string): Promise< + Array<{ id: string; language: string; format: string; fileName: string | null }> +> { + const hits = await discoverSidecarSubtitles(mediaFileId); + if (!hits.length) return []; + const names = [...new Set(hits.map((h) => h.fileName).filter(Boolean))]; + if (!names.length) return []; + const rows = await prisma.librarySubtitle.findMany({ + where: { fileName: { in: names } }, + orderBy: { updatedAt: "desc" }, + take: 16, + }); + const nameSet = new Set(names); + return [...rows] + .sort((a, b) => { + const aLocal = a.fileName && nameSet.has(a.fileName) ? 1 : 0; + const bLocal = b.fileName && nameSet.has(b.fileName) ? 1 : 0; + if (aLocal !== bLocal) return bLocal - aLocal; + const aNl = isDutchSubtitleLang(a.language) ? 1 : 0; + const bNl = isDutchSubtitleLang(b.language) ? 1 : 0; + if (aNl !== bNl) return bNl - aNl; + return 0; + }) + .map((r) => ({ + id: r.id, + language: r.language, + format: r.format, + fileName: r.fileName, + })); +} diff --git a/apps/master-api/src/stremio/routes.ts b/apps/master-api/src/stremio/routes.ts index 6c06bf3..dd657cc 100644 --- a/apps/master-api/src/stremio/routes.ts +++ b/apps/master-api/src/stremio/routes.ts @@ -78,7 +78,7 @@ async function buildManifest(tokenId: string | null, config: Config) { ).slice(-4); return { id: "com.mediacluster.library", - version: `1.7.${shelfPatch}`, + version: `1.8.${shelfPatch}`, name: "VMC", description: "Privé bibliotheek + Live TV / IPTV (Xtream) met EPG", catalogs: [...BASE_CATALOGS, ...shelfCatalogs, ...iptv.catalogs], @@ -94,6 +94,11 @@ async function buildManifest(tokenId: string | null, config: Config) { types: [...types], idPrefixes: ["mc", "sth", "tt", IPTV_PREFIX], }, + { + name: "subtitles", + types: ["movie", "series"], + idPrefixes: ["mc", "sth", "tt"], + }, ], types: [...types], idPrefixes: ["mc", "sth", "tt", IPTV_PREFIX], @@ -286,6 +291,21 @@ export async function registerStremioRoutes(app: FastifyInstance, config: Config return { streams }; }); + // Stremio subtitles resource (auto-select via Stremio preferred language). + app.get("/stremio/:token/subtitles/:type/*", async (request, reply) => { + const { token, type } = request.params as { token: string; type: string; "*": string }; + const auth = await resolveAddonToken(token); + if (!auth) { + return reply.status(401).send({ error: "Invalid addon token" }); + } + if (type !== "movie" && type !== "series") { + return reply.status(404).send({ error: "Not found" }); + } + const rawId = paramId((request.params as { "*": string })["*"] ?? ""); + const subtitles = await getStremioSubtitles(type, rawId, auth, config.PUBLIC_URL, request.log); + return { subtitles }; + }); + app.get("/stremio/:token/subtitles/:subtitleId", async (request, reply) => { const { token, subtitleId } = request.params as { token: string; subtitleId: string }; const auth = await resolveAddonToken(token); @@ -980,22 +1000,7 @@ async function getStreams( log?.warn?.({ err }, "sidecar subtitle discover failed"); } - const librarySubs = await prisma.librarySubtitle.findMany({ - where: { - OR: [ - { movieId: movie.id }, - ...(movie.tmdbId != null ? [{ tmdbId: movie.tmdbId }] : []), - ...(movie.imdbId ? [{ imdbId: movie.imdbId }] : []), - ], - }, - orderBy: { createdAt: "desc" }, - take: 8, - }); - const stremioSubs = librarySubs.map((s) => ({ - id: s.id, - url: `${base}/stremio/${auth.rawToken}/subtitles/${s.id}.${s.format || "srt"}`, - lang: s.language === "nl" || s.language.startsWith("nl") ? "nld" : s.language, - })); + const stremioSubs = await buildStremioSubsForMovie(movie, base, auth.rawToken); const streams = []; for (const file of pool) { @@ -1006,11 +1011,13 @@ async function getStreams( { mediaFileId: file.id, streamUrl }, "stream URL (lazy session on open)" ); + const fileSubs = await buildStremioSubsForMediaFile(file.id, base, auth.rawToken); + const subs = fileSubs.length ? fileSubs : stremioSubs; streams.push({ name: playback.formatStreamName(file), title: playback.formatStreamTitle(file, file.node.name), url: streamUrl, - ...(stremioSubs.length ? { subtitles: stremioSubs } : {}), + ...(subs.length ? { subtitles: subs } : {}), behaviorHints: { notWebReady: true, }, @@ -1106,10 +1113,12 @@ async function getStreams( { mediaFileId: file.id, streamUrl }, "stream URL (lazy session on open)" ); + const subs = await buildStremioSubsForMediaFile(file.id, base, auth.rawToken); streams.push({ name: playback.formatStreamName(file), title: playback.formatStreamTitle(file, file.node.name), url: streamUrl, + ...(subs.length ? { subtitles: subs } : {}), behaviorHints: { notWebReady: true, }, @@ -1129,3 +1138,156 @@ async function getStreams( return []; } + +type StremioSub = { id: string; url: string; lang: string }; + +function toStremioLang(language: string): string { + const l = (language || "nl").toLowerCase(); + if (l === "nl" || l.startsWith("nl") || l === "nld" || l === "dut") return "nld"; + if (l === "en" || l === "eng") return "eng"; + return l.length === 3 ? l : l.slice(0, 3); +} + +function mapLibraryToStremioSubs( + rows: Array<{ id: string; language: string; format: string; fileName?: string | null }>, + base: string, + rawToken: string +): StremioSub[] { + return rows.map((s) => ({ + id: s.id, + url: `${base}/stremio/${rawToken}/subtitles/${s.id}.${s.format || "srt"}`, + lang: toStremioLang(s.language), + })); +} + +async function buildStremioSubsForMediaFile( + mediaFileId: string, + base: string, + rawToken: string +): Promise { + try { + const { listLibrarySubtitlesForMediaFile } = await import("../downloads/sidecars"); + const rows = await listLibrarySubtitlesForMediaFile(mediaFileId); + return mapLibraryToStremioSubs(rows, base, rawToken); + } catch { + return []; + } +} + +async function buildStremioSubsForMovie( + movie: { id: string; tmdbId: number | null; imdbId: string | null }, + base: string, + rawToken: string +): Promise { + const { isDutchSubtitleLang } = await import("../downloads/sidecars"); + const librarySubs = await prisma.librarySubtitle.findMany({ + where: { + OR: [ + { movieId: movie.id }, + ...(movie.tmdbId != null ? [{ tmdbId: movie.tmdbId }] : []), + ...(movie.imdbId ? [{ imdbId: movie.imdbId }] : []), + ], + }, + orderBy: { createdAt: "desc" }, + take: 12, + }); + librarySubs.sort((a, b) => { + const aNl = isDutchSubtitleLang(a.language) ? 1 : 0; + const bNl = isDutchSubtitleLang(b.language) ? 1 : 0; + return bNl - aNl; + }); + return mapLibraryToStremioSubs(librarySubs, base, rawToken); +} + +async function getStremioSubtitles( + type: string, + id: string, + auth: { rawToken: string }, + publicUrl: string, + log?: { warn: (obj: unknown, msg?: string) => void } +): Promise { + const base = publicUrl.replace(/\/+$/, ""); + try { + if (type === "movie") { + const localUuid = unwrapLocalId(id, "movie"); + const isImdb = /^tt\d+$/i.test(id); + const movie = await prisma.movie.findFirst({ + where: { + OR: [ + ...(isImdb ? [{ imdbId: id }] : []), + { id: localUuid }, + { id }, + ], + }, + include: { + mediaFiles: { + where: { available: true }, + include: { node: true }, + take: 8, + }, + }, + }); + if (!movie) return []; + const online = movie.mediaFiles.filter( + (f) => + f.node.status === "ONLINE" && + !f.node.revoked && + nodeConnectionManager.isOnline(f.nodeId) + ); + for (const f of online.slice(0, 4)) { + const subs = await buildStremioSubsForMediaFile(f.id, base, auth.rawToken); + if (subs.length) return subs; + } + return buildStremioSubsForMovie(movie, base, auth.rawToken); + } + + if (type === "series") { + const match = id.match(/^(.+):(\d+):(\d+)$/); + if (!match) return []; + const [, seriesKey, seasonStr, episodeStr] = match; + const seasonNumber = parseInt(seasonStr, 10); + const episodeNumber = parseInt(episodeStr, 10); + const isImdb = /^tt\d+$/i.test(seriesKey); + const series = await prisma.series.findFirst({ + where: { + OR: [ + ...(isImdb ? [{ imdbId: seriesKey }] : []), + { id: unwrapLocalId(seriesKey, "series") }, + { id: seriesKey }, + ], + }, + }); + if (!series) return []; + const episode = await prisma.episode.findUnique({ + where: { + seriesId_seasonNumber_episodeNumber: { + seriesId: series.id, + seasonNumber, + episodeNumber, + }, + }, + include: { + mediaFiles: { + where: { available: true }, + include: { node: true }, + take: 6, + }, + }, + }); + if (!episode) return []; + const online = episode.mediaFiles.filter( + (f) => + f.node.status === "ONLINE" && + !f.node.revoked && + nodeConnectionManager.isOnline(f.nodeId) + ); + for (const f of online.slice(0, 4)) { + const subs = await buildStremioSubsForMediaFile(f.id, base, auth.rawToken); + if (subs.length) return subs; + } + } + } catch (err) { + log?.warn?.({ err, type, id }, "stremio subtitles resource failed"); + } + return []; +} diff --git a/apps/master-api/src/viewer/service.ts b/apps/master-api/src/viewer/service.ts index 80246ca..547aa49 100644 --- a/apps/master-api/src/viewer/service.ts +++ b/apps/master-api/src/viewer/service.ts @@ -1545,10 +1545,45 @@ export class ViewerService { async play(mediaFileId: string, viewerId: string) { const session = await this.playback.createSession(mediaFileId, viewerId, undefined); + + // Local sidecar next to the video → push to client so it can auto-select (no OS search needed). + let defaultSubtitle: { + fileId: number; + source: "local"; + language: string; + fileName: string; + format: string; + contentBase64: string; + label: string; + } | null = null; + try { + const { discoverSidecarSubtitles, getLocalSubtitleCache, pickPreferredLocalHit } = + await import("../downloads/sidecars"); + const hits = await discoverSidecarSubtitles(mediaFileId); + const preferred = pickPreferredLocalHit(hits); + if (preferred) { + const cached = getLocalSubtitleCache(preferred.fileId); + if (cached?.contentBase64) { + defaultSubtitle = { + fileId: preferred.fileId, + source: "local", + language: cached.language || preferred.language || "nl", + fileName: cached.fileName || preferred.fileName, + format: cached.format || "srt", + contentBase64: cached.contentBase64, + label: preferred.release || `Lokaal · ${cached.fileName}`, + }; + } + } + } catch (err) { + console.warn("[viewer] defaultSubtitle:", err instanceof Error ? err.message : err); + } + return { streamUrl: session.streamUrl, sessionId: session.sessionId, mediaFileId: session.mediaFileId, + defaultSubtitle, }; }