mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-07 04:53:01 +01:00
fix(subtitles): resolve duplicate-language and burned-in tracks by identity
Group-ordinal within the same-identity set (a preceding different-language track no longer makes the first duplicate unreachable), exclude server-burned (Encode) streams from the embedded ordinal and signal them as burnedIn, match languages across ISO 639-1/639-2 B-T/IETF variants, and sort the menu by the raw IsExternal flag so the order stays stable between direct play and transcode. Adds getExternalSubtitleUrl as the single source of truth for the external-sub URL, and short-circuits disable/burnedIn before enumerating the track list.
This commit is contained in:
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, string> = {
|
||||
// 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,
|
||||
|
||||
Reference in New Issue
Block a user