diff --git a/utils/jellyfin/subtitleUtils.test.ts b/utils/jellyfin/subtitleUtils.test.ts index 53fc061b..62dbf93d 100644 --- a/utils/jellyfin/subtitleUtils.test.ts +++ b/utils/jellyfin/subtitleUtils.test.ts @@ -4,7 +4,9 @@ import type { SubtitleDeliveryMethod, } from "@jellyfin/sdk/lib/generated-client"; import { + applyMpvSubtitleSelection, compareTracksForMenu, + getExternalSubtitleUrl, isExternalSubtitle, type PlayerSubtitleTrack, resolveSubtitleTrack, @@ -13,6 +15,8 @@ import { // String-enum values as typed literals — avoids a runtime SDK import (see subtitleUtils.ts). const External = "External" as SubtitleDeliveryMethod; const Embed = "Embed" as SubtitleDeliveryMethod; +const Encode = "Encode" as SubtitleDeliveryMethod; +const Hls = "Hls" as SubtitleDeliveryMethod; // --- fixtures -------------------------------------------------------------- @@ -64,6 +68,22 @@ describe("isExternalSubtitle", () => { false, ); }); + + test("a sidecar re-delivered by the server (Hls/Encode) is NOT external", () => { + // IsExternal only wins while no device-specific delivery method is assigned; + // once the server picks Hls (inside the stream) or Encode (burned), the + // track is not a sub-added sidecar and must not use the external path. + expect( + isExternalSubtitle( + sub({ Index: 0, IsExternal: true, DeliveryMethod: Hls }), + ), + ).toBe(false); + expect( + isExternalSubtitle( + sub({ Index: 0, IsExternal: true, DeliveryMethod: Encode }), + ), + ).toBe(false); + }); }); describe("resolveSubtitleTrack — disable / notFound", () => { @@ -219,3 +239,247 @@ describe("compareTracksForMenu — jellyfin-web order", () => { ); }); }); + +describe("resolveSubtitleTrack — same-identity group ordinal (duplicate languages)", () => { + // [jpn, eng, eng] with no titles: the eng group starts at embedded position 1, + // so the group ordinal (not the global one) must drive the pick. + const streams = [ + emb(0, { Language: "jpn" }), + emb(1, { Language: "eng" }), + emb(2, { Language: "eng" }), + ]; + const playerTracks = [ + track({ id: 1, language: "jpn" }), + track({ id: 2, language: "eng" }), + track({ id: 3, language: "eng" }), + ]; + + test("first eng selects the first matching player track", () => { + expect(resolve(streams, 1, playerTracks)).toEqual({ + kind: "select", + trackId: 2, + }); + }); + + test("second eng selects the second matching player track", () => { + expect(resolve(streams, 2, playerTracks)).toEqual({ + kind: "select", + trackId: 3, + }); + }); +}); + +describe("resolveSubtitleTrack — server-burned (Encode) streams", () => { + const burned = emb(2, { + DeliveryMethod: Encode, + IsTextSubtitleStream: false, + Codec: "pgssub", + }); + + test("selecting a burned-in sub returns burnedIn (a stream refresh, not a track)", () => { + expect(resolve([burned, emb(3)], 2, [track({ id: 1 })])).toEqual({ + kind: "burnedIn", + }); + }); + + test("burned-in streams do not shift the embedded ordinal", () => { + // Player only demuxes the two untagged text subs; the burned PGS is pixels. + const streams = [burned, emb(3), emb(4)]; + const playerTracks = [track({ id: 1 }), track({ id: 2 })]; + expect(resolve(streams, 3, playerTracks)).toEqual({ + kind: "select", + trackId: 1, + }); + expect(resolve([burned, emb(3)], 3, [track({ id: 1 })])).toEqual({ + kind: "select", + trackId: 1, + }); + }); +}); + +describe("resolveSubtitleTrack — Hls-delivered sidecar goes through the embedded path", () => { + test("resolves by identity against the player's in-stream track", () => { + const hlsSidecar = sub({ + Index: 0, + IsExternal: true, + DeliveryMethod: Hls, + DeliveryUrl: "/videos/x/subs/0.vtt", + Language: "fre", + }); + const r = resolve([hlsSidecar, emb(1, { Language: "eng" })], 0, [ + track({ id: 1, language: "fre" }), + track({ id: 2, language: "eng" }), + ]); + expect(r).toEqual({ kind: "select", trackId: 1 }); + }); +}); + +describe("applyMpvSubtitleSelection — short-circuits", () => { + test("disable (-1) never reads the player track list", async () => { + let enumerated = false; + let disabled = false; + const r = await applyMpvSubtitleSelection( + { + getSubtitleTracks: async () => { + enumerated = true; + return []; + }, + setSubtitleTrack: () => {}, + disableSubtitles: () => { + disabled = true; + }, + }, + { subtitleStreams: [], jellyfinSubtitleIndex: -1 }, + ); + expect(r).toEqual({ kind: "disable" }); + expect(disabled).toBe(true); + expect(enumerated).toBe(false); + }); + + test("burned-in target returns without touching the player", async () => { + let enumerated = false; + const r = await applyMpvSubtitleSelection( + { + getSubtitleTracks: async () => { + enumerated = true; + return []; + }, + setSubtitleTrack: () => {}, + disableSubtitles: () => {}, + }, + { + subtitleStreams: [ + emb(2, { DeliveryMethod: Encode, IsTextSubtitleStream: false }), + ], + jellyfinSubtitleIndex: 2, + }, + ); + expect(r).toEqual({ kind: "burnedIn" }); + expect(enumerated).toBe(false); + }); +}); + +describe("getExternalSubtitleUrl — server contract (MediaInfoHelper)", () => { + test("server-relative DeliveryUrl gets the basePath prefix", () => { + expect( + getExternalSubtitleUrl(ext(0, { DeliveryUrl: "/sub/0.srt" }), { + offline: false, + basePath: "http://srv", + }), + ).toBe("http://srv/sub/0.srt"); + }); + + test("IsExternalUrl means DeliveryUrl is already absolute — no prefix", () => { + expect( + getExternalSubtitleUrl( + ext(0, { + DeliveryUrl: "https://cdn.example/sub.srt", + IsExternalUrl: true, + }), + { offline: false, basePath: "http://srv" }, + ), + ).toBe("https://cdn.example/sub.srt"); + }); + + test("offline returns the stored local path as-is", () => { + expect( + getExternalSubtitleUrl(ext(0, { DeliveryUrl: "file:///subs/0.srt" }), { + offline: true, + basePath: "http://srv", + }), + ).toBe("file:///subs/0.srt"); + }); + + test("no DeliveryUrl or no basePath online → undefined", () => { + expect( + getExternalSubtitleUrl(sub({ Index: 0, IsExternal: true }), { + offline: false, + basePath: "http://srv", + }), + ).toBeUndefined(); + expect( + getExternalSubtitleUrl(ext(0), { offline: false, basePath: undefined }), + ).toBeUndefined(); + }); +}); + +describe("resolveSubtitleTrack — language tag variants (ISO 639-1 / 639-2 B-T / IETF)", () => { + test("Jellyfin 639-2/B matches mpv 639-2/T and 639-1 tags", () => { + // Server says "ger" (639-2/B); muxers can surface "deu" (/T) or "de" (639-1). + const streams = [emb(0, { Language: "ger" }), emb(1, { Language: "fre" })]; + const playerT = [ + track({ id: 1, language: "deu" }), + track({ id: 2, language: "fra" }), + ]; + expect(resolve(streams, 0, playerT)).toEqual({ + kind: "select", + trackId: 1, + }); + expect(resolve(streams, 1, playerT)).toEqual({ + kind: "select", + trackId: 2, + }); + + const player1 = [ + track({ id: 1, language: "de" }), + track({ id: 2, language: "fr" }), + ]; + expect(resolve(streams, 0, player1)).toEqual({ + kind: "select", + trackId: 1, + }); + }); + + test("IETF tags reduce to their primary subtag", () => { + const streams = [emb(0, { Language: "eng" }), emb(1, { Language: "spa" })]; + const player = [ + track({ id: 1, language: "en-US" }), + track({ id: 2, language: "es-419" }), + ]; + expect(resolve(streams, 0, player)).toEqual({ kind: "select", trackId: 1 }); + expect(resolve(streams, 1, player)).toEqual({ kind: "select", trackId: 2 }); + }); + + test("different languages still never match ('ger' vs 'gre')", () => { + const streams = [emb(0, { Language: "ger" })]; + const player = [ + track({ id: 1, language: "gre" }), + track({ id: 2, language: "ger" }), + ]; + expect(resolve(streams, 0, player)).toEqual({ kind: "select", trackId: 2 }); + }); +}); + +describe("compareTracksForMenu — stable order across play methods (8 Mile live find)", () => { + test("transcode re-delivery (SRT→External, PGS→Encode) must not reshuffle the menu", () => { + // In-file order: srt(1) srt(2) pgs(3) pgs(4), plus a real sidecar ext(0). + const directPlay = [ + ext(0, { Language: "eng" }), + emb(1, { Language: "fre" }), + emb(2, { Language: "eng" }), + emb(3, { Language: "fre", IsTextSubtitleStream: false }), + emb(4, { Language: "eng", IsTextSubtitleStream: false }), + ]; + // Same file while transcoding: server re-delivers embedded text as External + // (extracted) and burns image subs (Encode). IsExternal stays a FILE property. + const transcoding = [ + ext(0, { Language: "eng" }), + emb(1, { Language: "fre", DeliveryMethod: External, DeliveryUrl: "/x1" }), + emb(2, { Language: "eng", DeliveryMethod: External, DeliveryUrl: "/x2" }), + emb(3, { + Language: "fre", + IsTextSubtitleStream: false, + DeliveryMethod: Encode, + }), + emb(4, { + Language: "eng", + IsTextSubtitleStream: false, + DeliveryMethod: Encode, + }), + ]; + const order = (streams: MediaStream[]) => + [...streams].sort(compareTracksForMenu).map((s) => s.Index); + expect(order(directPlay)).toEqual([1, 2, 3, 4, 0]); + expect(order(transcoding)).toEqual(order(directPlay)); + }); +}); diff --git a/utils/jellyfin/subtitleUtils.ts b/utils/jellyfin/subtitleUtils.ts index bce3ffc2..7905d93c 100644 --- a/utils/jellyfin/subtitleUtils.ts +++ b/utils/jellyfin/subtitleUtils.ts @@ -22,11 +22,20 @@ import type { // literal so this util needs no *runtime* import of the SDK barrel — which pulls in // the axios-dependent `/api` modules and breaks unit tests under `bun test`. const EXTERNAL_DELIVERY = "External" as SubtitleDeliveryMethod; +const ENCODE_DELIVERY = "Encode" as SubtitleDeliveryMethod; /** Check if subtitle is image-based (PGS, VOBSUB, etc.) */ export const isImageBasedSubtitle = (sub: MediaStream): boolean => sub.IsTextSubtitleStream === false; +/** + * Burned into the video by the server (`DeliveryMethod === Encode`, e.g. image + * subs while transcoding, or sidecar formats no profile can deliver). Never a + * selectable player track — switching to/away requires a stream refresh. + */ +export const isBurnedInSubtitle = (sub: MediaStream): boolean => + sub.DeliveryMethod === ENCODE_DELIVERY; + /** * A Jellyfin subtitle stream is "external" when the server delivers it as a * sub-added sidecar — i.e. `DeliveryMethod === External` (or the `IsExternal` @@ -40,7 +49,28 @@ export const isImageBasedSubtitle = (sub: MediaStream): boolean => * external (→ `notFound`). */ export const isExternalSubtitle = (sub: MediaStream): boolean => - sub.DeliveryMethod === EXTERNAL_DELIVERY || sub.IsExternal === true; + sub.DeliveryMethod === EXTERNAL_DELIVERY || + (sub.DeliveryMethod == null && sub.IsExternal === true); + +/** + * The exact URL/path an external sub is (or would be) loaded into the player + * with. Single source of truth for BOTH the load site (`videoSource` + * externalSubtitles) and identity matching (`getExpectedExternalUrl`) — the + * filename match only works while the two stay byte-identical. + * + * Server contract (MediaInfoHelper.SetDeviceSpecificSubtitleInfo): DeliveryUrl + * is server-relative (`/Videos/...`, may carry `?ApiKey=`) UNLESS + * `IsExternalUrl` is set — then it is already an absolute URL and must not be + * prefixed. Offline: DeliveryUrl holds the local file path. + */ +export const getExternalSubtitleUrl = ( + sub: MediaStream, + opts: { offline: boolean; basePath?: string | null }, +): string | undefined => { + if (!sub.DeliveryUrl) return undefined; + if (opts.offline || sub.IsExternalUrl) return sub.DeliveryUrl; + return opts.basePath ? `${opts.basePath}${sub.DeliveryUrl}` : undefined; +}; /** * Order subtitle MediaStreams for the selection menu exactly like jellyfin-web's @@ -50,11 +80,15 @@ export const isExternalSubtitle = (sub: MediaStream): boolean => * * The Jellyfin server inserts external (sidecar) streams at the FRONT of * `MediaStreams` (low indices), so raw Index order shows externals first — this - * comparator flips that to match web (externals last). Uses {@link isExternalSubtitle} - * (not the raw `IsExternal` flag) so ordering and resolution agree. + * comparator flips that to match web (externals last). Uses the raw `IsExternal` + * flag exactly like web's `sortTracks` (itemHelper.js): it is a property of the + * FILE, so the menu order stays identical between direct play and transcode — + * delivery-based grouping would reshuffle entries when the server re-delivers + * extracted text subs as External and burns image subs (Encode). Ordering is + * purely cosmetic; selection resolves by `Index` identity regardless. */ export const compareTracksForMenu = (a: MediaStream, b: MediaStream): number => - Number(isExternalSubtitle(a)) - Number(isExternalSubtitle(b)) || + Number(a.IsExternal ?? false) - Number(b.IsExternal ?? false) || Number(b.IsForced ?? false) - Number(a.IsForced ?? false) || Number(b.IsDefault ?? false) - Number(a.IsDefault ?? false) || (a.Index ?? 0) - (b.Index ?? 0); @@ -79,6 +113,8 @@ export type PlayerSubtitleTrack = { export type SubtitleSelection = | { kind: "select"; trackId: number } | { kind: "disable" } + /** Target is server-burned (Encode) — only a stream refresh can show/hide it. */ + | { kind: "burnedIn" } | { kind: "notFound" }; /** Decode percent-encoding and strip a leading `file://` scheme for tolerant comparison. */ @@ -105,12 +141,237 @@ const externalFilenameMatches = ( const eq = (a?: string | null, b?: string | null): boolean => !!a && !!b && a.toLowerCase() === b.toLowerCase(); +/** + * ISO 639-1 (2-letter) and ISO 639-2/T tags mapped to their ISO 639-2/B form, + * so tags from different muxers/servers compare equal ("de"/"deu"/"ger" → "ger"). + * Jellyfin normalizes probe results to 639-2/B, but mkv `LanguageIETF` tags + * ("en-US") and some muxers' 639-1 or /T tags leak through to mpv's `lang`. + */ +const LANG_CANONICAL: Record = { + // ISO 639-1 → 639-2/B + aa: "aar", + ab: "abk", + ae: "ave", + af: "afr", + ak: "aka", + am: "amh", + an: "arg", + ar: "ara", + as: "asm", + av: "ava", + ay: "aym", + az: "aze", + ba: "bak", + be: "bel", + bg: "bul", + bh: "bih", + bi: "bis", + bm: "bam", + bn: "ben", + bo: "tib", + br: "bre", + bs: "bos", + ca: "cat", + ce: "che", + ch: "cha", + co: "cos", + cr: "cre", + cs: "cze", + cu: "chu", + cv: "chv", + cy: "wel", + da: "dan", + de: "ger", + dv: "div", + dz: "dzo", + ee: "ewe", + el: "gre", + en: "eng", + eo: "epo", + es: "spa", + et: "est", + eu: "baq", + fa: "per", + ff: "ful", + fi: "fin", + fj: "fij", + fo: "fao", + fr: "fre", + fy: "fry", + ga: "gle", + gd: "gla", + gl: "glg", + gn: "grn", + gu: "guj", + gv: "glv", + ha: "hau", + he: "heb", + hi: "hin", + ho: "hmo", + hr: "hrv", + ht: "hat", + hu: "hun", + hy: "arm", + hz: "her", + ia: "ina", + id: "ind", + ie: "ile", + ig: "ibo", + ii: "iii", + ik: "ipk", + io: "ido", + is: "ice", + it: "ita", + iu: "iku", + ja: "jpn", + jv: "jav", + ka: "geo", + kg: "kon", + ki: "kik", + kj: "kua", + kk: "kaz", + kl: "kal", + km: "khm", + kn: "kan", + ko: "kor", + kr: "kau", + ks: "kas", + ku: "kur", + kv: "kom", + kw: "cor", + ky: "kir", + la: "lat", + lb: "ltz", + lg: "lug", + li: "lim", + ln: "lin", + lo: "lao", + lt: "lit", + lu: "lub", + lv: "lav", + mg: "mlg", + mh: "mah", + mi: "mao", + mk: "mac", + ml: "mal", + mn: "mon", + mr: "mar", + ms: "may", + mt: "mlt", + my: "bur", + na: "nau", + nb: "nob", + nd: "nde", + ne: "nep", + ng: "ndo", + nl: "dut", + nn: "nno", + no: "nor", + nr: "nbl", + nv: "nav", + ny: "nya", + oc: "oci", + oj: "oji", + om: "orm", + or: "ori", + os: "oss", + pa: "pan", + pi: "pli", + pl: "pol", + ps: "pus", + pt: "por", + qu: "que", + rm: "roh", + rn: "run", + ro: "rum", + ru: "rus", + rw: "kin", + sa: "san", + sc: "srd", + sd: "snd", + se: "sme", + sg: "sag", + si: "sin", + sk: "slo", + sl: "slv", + sm: "smo", + sn: "sna", + so: "som", + sq: "alb", + sr: "srp", + ss: "ssw", + st: "sot", + su: "sun", + sv: "swe", + sw: "swa", + ta: "tam", + te: "tel", + tg: "tgk", + th: "tha", + ti: "tir", + tk: "tuk", + tl: "tgl", + tn: "tsn", + to: "ton", + tr: "tur", + ts: "tso", + tt: "tat", + tw: "twi", + ty: "tah", + ug: "uig", + uk: "ukr", + ur: "urd", + uz: "uzb", + ve: "ven", + vi: "vie", + vo: "vol", + wa: "wln", + wo: "wol", + xh: "xho", + yi: "yid", + yo: "yor", + za: "zha", + zh: "chi", + zu: "zul", + // ISO 639-2/T → /B (the 20 languages with two distinct 3-letter codes) + bod: "tib", + ces: "cze", + cym: "wel", + deu: "ger", + ell: "gre", + eus: "baq", + fas: "per", + fra: "fre", + hye: "arm", + isl: "ice", + kat: "geo", + mkd: "mac", + mri: "mao", + msa: "may", + mya: "bur", + nld: "dut", + ron: "rum", + slk: "slo", + sqi: "alb", + zho: "chi", +}; + +/** Canonicalize a language tag: lowercase, primary subtag ("en-US" → "en"), 639-2/B. */ +const canonicalLang = (raw: string): string => { + const primary = raw.trim().toLowerCase().split("-")[0]; + return LANG_CANONICAL[primary] ?? primary; +}; + +/** Language-tag comparison across ISO 639-1 / 639-2 B/T / IETF variants. */ +const langEq = (a?: string | null, b?: string | null): boolean => + !!a && !!b && canonicalLang(a) === canonicalLang(b); + /** Match an embedded player track to a Jellyfin stream by language/title (codec-agnostic). */ const embeddedIdentityMatches = ( track: PlayerSubtitleTrack, stream: MediaStream, ): boolean => { - if (eq(track.language, stream.Language)) { + if (langEq(track.language, stream.Language)) { // When both carry a title it must agree; otherwise language alone is enough. if (track.title && stream.Title) return eq(track.title, stream.Title); return true; @@ -160,6 +421,10 @@ export const resolveSubtitleTrack = (params: { const target = subtitleStreams.find((s) => s.Index === jellyfinSubtitleIndex); if (!target) return { kind: "notFound" }; + // Server-burned subs are pixels, not tracks — signal the caller to refresh + // the stream instead of hunting for a track that cannot exist. + if (isBurnedInSubtitle(target)) return { kind: "burnedIn" }; + if (isExternalSubtitle(target)) { const playerExternals = playerTracks.filter((t) => t.external === true); @@ -186,8 +451,12 @@ export const resolveSubtitleTrack = (params: { return { kind: "notFound" }; } - // Embedded / in-container subtitle. - const embeddedStreams = subtitleStreams.filter((s) => !isExternalSubtitle(s)); + // Embedded / in-container subtitle. Burned-in (Encode) streams are excluded: + // they are baked into the video and never appear in the player's track list, + // so counting them would shift every ordinal below. + const embeddedStreams = subtitleStreams.filter( + (s) => !isExternalSubtitle(s) && !isBurnedInSubtitle(s), + ); const playerEmbedded = playerTracks.filter((t) => t.external !== true); // 1) Identity by language/title (unique match wins). @@ -198,15 +467,28 @@ export const resolveSubtitleTrack = (params: { return { kind: "select", trackId: identityMatches[0].id }; } - // 2) Fallback: embedded order is container order on both sides → ordinal. + // 2) Multiple same-identity tracks: ordinal within the same-identity GROUP — + // the k-th matching stream corresponds to the k-th matching player track + // (container order is preserved on both sides, filter preserves order). + // The group ordinal, not the global one: with [jpn, eng, eng] the first + // eng is global position 1 but group position 0. + if (identityMatches.length > 1) { + const groupStreams = embeddedStreams.filter((s) => + identityMatches.some((t) => embeddedIdentityMatches(t, s)), + ); + const groupOrdinal = groupStreams.findIndex( + (s) => s.Index === jellyfinSubtitleIndex, + ); + if (groupOrdinal >= 0) { + const idx = Math.min(groupOrdinal, identityMatches.length - 1); + return { kind: "select", trackId: identityMatches[idx].id }; + } + } + + // 3) Fallback: embedded order is container order on both sides → ordinal. const ordinal = embeddedStreams.findIndex( (s) => s.Index === jellyfinSubtitleIndex, ); - if (identityMatches.length > 1 && ordinal >= 0) { - // Multiple same-language tracks: pick by position among the matches. - const idx = Math.min(ordinal, identityMatches.length - 1); - return { kind: "select", trackId: identityMatches[idx].id }; - } if (ordinal >= 0 && ordinal < playerEmbedded.length) { return { kind: "select", trackId: playerEmbedded[ordinal].id }; } @@ -257,6 +539,19 @@ export const applyMpvSubtitleSelection = async ( // rejection from getSubtitleTracks/setSubtitleTrack/disableSubtitles must be // swallowed here instead of escaping as an unhandled promise rejection. try { + // Short-circuit the outcomes that don't need the player's track list, so + // the common subtitles-off case skips a full native enumeration. + if (params.jellyfinSubtitleIndex === -1) { + await player.disableSubtitles(); + return { kind: "disable" }; + } + const burnTarget = params.subtitleStreams?.find( + (s) => s.Index === params.jellyfinSubtitleIndex, + ); + if (burnTarget && isBurnedInSubtitle(burnTarget)) { + return { kind: "burnedIn" }; + } + const tracks = (await player.getSubtitleTracks()) ?? []; const selection = resolveSubtitleTrack({ subtitleStreams: params.subtitleStreams,