Auto-select local sidecar subtitles on playback start.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-09-08 04:08:09 +02:00
parent c3755b48a5
commit 37e7aafdf5
7 changed files with 354 additions and 18 deletions

View file

@ -116,10 +116,23 @@ class ApiClient(
val body = JSONObject().put("mediaFileId", mediaFileId).toString().toRequestBody(jsonMedia) val body = JSONObject().put("mediaFileId", mediaFileId).toString().toRequestBody(jsonMedia)
val req = authed("$baseUrl/api/v1/client/play").post(body).build() val req = authed("$baseUrl/api/v1/client/play").post(body).build()
parseObject(execute(req)) { o -> parseObject(execute(req)) { o ->
val def = o.optJSONObject("defaultSubtitle")
PlayResult( PlayResult(
streamUrl = o.getString("streamUrl"), streamUrl = o.getString("streamUrl"),
sessionId = o.optString("sessionId"), sessionId = o.optString("sessionId"),
mediaFileId = o.optString("mediaFileId"), 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 streamUrl: String,
val sessionId: String, val sessionId: String,
val mediaFileId: 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( data class SubtitleHit(

View file

@ -212,6 +212,27 @@ fun PlayerScreen(
player.setMediaItem(ExoMediaItem.fromUri(play.streamUrl)) player.setMediaItem(ExoMediaItem.fromUri(play.streamUrl))
player.prepare() player.prepare()
if (startMs > 5_000) player.seekTo(startMs) 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() player.play()
} catch (e: Exception) { } catch (e: Exception) {
error = e.message ?: "Afspelen mislukt" error = e.message ?: "Afspelen mislukt"

View file

@ -116,10 +116,23 @@ class ApiClient(
val body = JSONObject().put("mediaFileId", mediaFileId).toString().toRequestBody(jsonMedia) val body = JSONObject().put("mediaFileId", mediaFileId).toString().toRequestBody(jsonMedia)
val req = authed("$baseUrl/api/v1/client/play").post(body).build() val req = authed("$baseUrl/api/v1/client/play").post(body).build()
parseObject(execute(req)) { o -> parseObject(execute(req)) { o ->
val def = o.optJSONObject("defaultSubtitle")
PlayResult( PlayResult(
streamUrl = o.getString("streamUrl"), streamUrl = o.getString("streamUrl"),
sessionId = o.optString("sessionId"), sessionId = o.optString("sessionId"),
mediaFileId = o.optString("mediaFileId"), 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 streamUrl: String,
val sessionId: String, val sessionId: String,
val mediaFileId: 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( data class SubtitleHit(

View file

@ -209,6 +209,27 @@ fun PlayerScreen(
player.setMediaItem(ExoMediaItem.fromUri(play.streamUrl)) player.setMediaItem(ExoMediaItem.fromUri(play.streamUrl))
player.prepare() player.prepare()
if (startMs > 5_000) player.seekTo(startMs) 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() player.play()
} catch (e: Exception) { } catch (e: Exception) {
error = e.message ?: "Afspelen mislukt" error = e.message ?: "Afspelen mislukt"

View file

@ -135,3 +135,52 @@ export async function discoverSidecarSubtitles(mediaFileId: string): Promise<Sub
} }
return hits; return hits;
} }
export function isDutchSubtitleLang(lang: string | null | undefined): boolean {
const l = (lang || "").trim().toLowerCase();
return l === "nl" || l === "nld" || l === "dut" || l.startsWith("nl");
}
/** Prefer Dutch local sidecars, then any local, then other. */
export function pickPreferredLocalHit(hits: SubtitleHit[]): SubtitleHit | null {
if (!hits.length) return null;
const local = hits.filter((h) => 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,
}));
}

View file

@ -78,7 +78,7 @@ async function buildManifest(tokenId: string | null, config: Config) {
).slice(-4); ).slice(-4);
return { return {
id: "com.mediacluster.library", id: "com.mediacluster.library",
version: `1.7.${shelfPatch}`, version: `1.8.${shelfPatch}`,
name: "VMC", name: "VMC",
description: "Privé bibliotheek + Live TV / IPTV (Xtream) met EPG", description: "Privé bibliotheek + Live TV / IPTV (Xtream) met EPG",
catalogs: [...BASE_CATALOGS, ...shelfCatalogs, ...iptv.catalogs], catalogs: [...BASE_CATALOGS, ...shelfCatalogs, ...iptv.catalogs],
@ -94,6 +94,11 @@ async function buildManifest(tokenId: string | null, config: Config) {
types: [...types], types: [...types],
idPrefixes: ["mc", "sth", "tt", IPTV_PREFIX], idPrefixes: ["mc", "sth", "tt", IPTV_PREFIX],
}, },
{
name: "subtitles",
types: ["movie", "series"],
idPrefixes: ["mc", "sth", "tt"],
},
], ],
types: [...types], types: [...types],
idPrefixes: ["mc", "sth", "tt", IPTV_PREFIX], idPrefixes: ["mc", "sth", "tt", IPTV_PREFIX],
@ -286,6 +291,21 @@ export async function registerStremioRoutes(app: FastifyInstance, config: Config
return { streams }; 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) => { app.get("/stremio/:token/subtitles/:subtitleId", async (request, reply) => {
const { token, subtitleId } = request.params as { token: string; subtitleId: string }; const { token, subtitleId } = request.params as { token: string; subtitleId: string };
const auth = await resolveAddonToken(token); const auth = await resolveAddonToken(token);
@ -980,22 +1000,7 @@ async function getStreams(
log?.warn?.({ err }, "sidecar subtitle discover failed"); log?.warn?.({ err }, "sidecar subtitle discover failed");
} }
const librarySubs = await prisma.librarySubtitle.findMany({ const stremioSubs = await buildStremioSubsForMovie(movie, base, auth.rawToken);
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 streams = []; const streams = [];
for (const file of pool) { for (const file of pool) {
@ -1006,11 +1011,13 @@ async function getStreams(
{ mediaFileId: file.id, streamUrl }, { mediaFileId: file.id, streamUrl },
"stream URL (lazy session on open)" "stream URL (lazy session on open)"
); );
const fileSubs = await buildStremioSubsForMediaFile(file.id, base, auth.rawToken);
const subs = fileSubs.length ? fileSubs : stremioSubs;
streams.push({ streams.push({
name: playback.formatStreamName(file), name: playback.formatStreamName(file),
title: playback.formatStreamTitle(file, file.node.name), title: playback.formatStreamTitle(file, file.node.name),
url: streamUrl, url: streamUrl,
...(stremioSubs.length ? { subtitles: stremioSubs } : {}), ...(subs.length ? { subtitles: subs } : {}),
behaviorHints: { behaviorHints: {
notWebReady: true, notWebReady: true,
}, },
@ -1106,10 +1113,12 @@ async function getStreams(
{ mediaFileId: file.id, streamUrl }, { mediaFileId: file.id, streamUrl },
"stream URL (lazy session on open)" "stream URL (lazy session on open)"
); );
const subs = await buildStremioSubsForMediaFile(file.id, base, auth.rawToken);
streams.push({ streams.push({
name: playback.formatStreamName(file), name: playback.formatStreamName(file),
title: playback.formatStreamTitle(file, file.node.name), title: playback.formatStreamTitle(file, file.node.name),
url: streamUrl, url: streamUrl,
...(subs.length ? { subtitles: subs } : {}),
behaviorHints: { behaviorHints: {
notWebReady: true, notWebReady: true,
}, },
@ -1129,3 +1138,156 @@ async function getStreams(
return []; 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<StremioSub[]> {
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<StremioSub[]> {
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<StremioSub[]> {
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 [];
}

View file

@ -1545,10 +1545,45 @@ export class ViewerService {
async play(mediaFileId: string, viewerId: string) { async play(mediaFileId: string, viewerId: string) {
const session = await this.playback.createSession(mediaFileId, viewerId, undefined); 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 { return {
streamUrl: session.streamUrl, streamUrl: session.streamUrl,
sessionId: session.sessionId, sessionId: session.sessionId,
mediaFileId: session.mediaFileId, mediaFileId: session.mediaFileId,
defaultSubtitle,
}; };
} }