Auto-select local sidecar subtitles on playback start.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
c3755b48a5
commit
37e7aafdf5
7 changed files with 354 additions and 18 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -135,3 +135,52 @@ export async function discoverSidecarSubtitles(mediaFileId: string): Promise<Sub
|
|||
}
|
||||
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,
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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 [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue