mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-07 04:53:01 +01:00
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.
618 lines
20 KiB
TypeScript
618 lines
20 KiB
TypeScript
/**
|
|
* Subtitle utilities: resolve a Jellyfin subtitle stream to the right track in
|
|
* the *player's real track list* by identity — never by positional counting.
|
|
*
|
|
* Why: Jellyfin renumbers MediaStreams (externals first); the player enumerates
|
|
* embedded-from-container first and externals (`sub-add`) last; and a library that
|
|
* hides embedded subs drops them from MediaStreams while the player still demuxes
|
|
* them from the file. Positional Index→id mapping therefore mis-selects (e.g.
|
|
* picking Spanish shows English). See {@link resolveSubtitleTrack}.
|
|
*
|
|
* Image-based subtitles (PGS, VOBSUB) during transcoding are burned into the video
|
|
* and absent from the player's track list.
|
|
*/
|
|
|
|
import type {
|
|
MediaSourceInfo,
|
|
MediaStream,
|
|
SubtitleDeliveryMethod,
|
|
} from "@jellyfin/sdk/lib/generated-client";
|
|
|
|
// "External" is the value of SubtitleDeliveryMethod.External. Compared as a typed
|
|
// 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`
|
|
* flag before a device-specific delivery method is assigned).
|
|
*
|
|
* Deliberately NOT keyed on `DeliveryUrl`: an Hls-delivered sub also carries a
|
|
* `DeliveryUrl` but lives inside the player's track list (not `sub-add`-ed), so
|
|
* it must resolve through the embedded path. Keeping this in lockstep with the
|
|
* load sites (which only `sub-add` `DeliveryMethod === External`) and with the
|
|
* menu comparator below avoids a sub being sorted as embedded yet resolved as
|
|
* external (→ `notFound`).
|
|
*/
|
|
export const isExternalSubtitle = (sub: MediaStream): boolean =>
|
|
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
|
|
* `itemHelper.sortTracks`: in-container tracks first then external, and within
|
|
* each group forced first, then default, then `Index` ascending. Callers prepend
|
|
* their own "None/Off" entry separately.
|
|
*
|
|
* 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 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(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);
|
|
|
|
/**
|
|
* Identity of a subtitle track as reported by the *player's real track list*
|
|
* (mpv `track-list`, or a Cast media-track list). Player-agnostic on purpose so
|
|
* the same resolver can drive the mpv player today and the Chromecast backend later.
|
|
*/
|
|
export type PlayerSubtitleTrack = {
|
|
/** Player-side id used to actually select the track (mpv `sid`, cast trackId). */
|
|
id: number;
|
|
/** True if loaded from a separate file (mpv `external`). */
|
|
external?: boolean;
|
|
/** For external tracks: the exact URL/path it was loaded from (mpv `external-filename`). */
|
|
externalFilename?: string;
|
|
language?: string;
|
|
title?: string;
|
|
codec?: string;
|
|
};
|
|
|
|
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. */
|
|
const normalizeUrl = (url: string): string => {
|
|
let u = url;
|
|
try {
|
|
u = decodeURIComponent(u);
|
|
} catch {
|
|
// not decodable — compare raw
|
|
}
|
|
return u.replace(/^file:\/\//, "");
|
|
};
|
|
|
|
const externalFilenameMatches = (
|
|
trackFilename: string | undefined,
|
|
expectedUrl: string | undefined,
|
|
): boolean => {
|
|
if (!trackFilename || !expectedUrl) return false;
|
|
const a = normalizeUrl(trackFilename);
|
|
const b = normalizeUrl(expectedUrl);
|
|
return a === b || a.endsWith(b) || b.endsWith(a);
|
|
};
|
|
|
|
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 (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;
|
|
}
|
|
// No language on one side — fall back to a title match.
|
|
if (!track.language || !stream.Language) return eq(track.title, stream.Title);
|
|
return false;
|
|
};
|
|
|
|
/**
|
|
* Resolve the player track id for a given Jellyfin subtitle index by matching
|
|
* against the player's REAL track list (identity), never by positional counting.
|
|
*
|
|
* Why identity, not position: Jellyfin renumbers `MediaStreams` (externals first)
|
|
* while the player enumerates embedded-from-container first and externals
|
|
* (`sub-add`) last; and when a library hides embedded subs they vanish from
|
|
* `MediaStreams` but still physically exist in the file the player demuxes.
|
|
* Positional Index→id mapping therefore mis-selects (e.g. picking Spanish shows
|
|
* English — issues #954/#1690/#618/#1467/#976/#1451).
|
|
*
|
|
* Strategy:
|
|
* - disabled (-1/undefined) → `disable`
|
|
* - external Jellyfin sub → match the player track by `externalFilename`
|
|
* (exact identity, immune to hidden-embedded shifts); fall back to the
|
|
* ordinal among *loadable* externals (Swiftfin: externals are the list tail).
|
|
* - embedded Jellyfin sub → match by language/title among non-external tracks;
|
|
* fall back to the embedded ordinal (container order aligns on both sides).
|
|
*
|
|
* Player-agnostic: pass any player's track list + a URL builder, so the mpv
|
|
* player and (later) the Chromecast backend share one source of truth.
|
|
*/
|
|
export const resolveSubtitleTrack = (params: {
|
|
subtitleStreams: MediaStream[] | undefined;
|
|
jellyfinSubtitleIndex: number | undefined;
|
|
playerTracks: PlayerSubtitleTrack[];
|
|
/** Build the exact URL/path an external Jellyfin sub was loaded into the player with. */
|
|
getExpectedExternalUrl?: (sub: MediaStream) => string | undefined;
|
|
}): SubtitleSelection => {
|
|
const { jellyfinSubtitleIndex, playerTracks, getExpectedExternalUrl } =
|
|
params;
|
|
const subtitleStreams = params.subtitleStreams ?? [];
|
|
|
|
if (jellyfinSubtitleIndex === undefined || jellyfinSubtitleIndex === -1) {
|
|
return { kind: "disable" };
|
|
}
|
|
|
|
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);
|
|
|
|
// 1) Exact identity by external filename — robust against hidden-embedded offset.
|
|
const expectedUrl = getExpectedExternalUrl?.(target);
|
|
const byName = playerExternals.find((t) =>
|
|
externalFilenameMatches(t.externalFilename, expectedUrl),
|
|
);
|
|
if (byName) return { kind: "select", trackId: byName.id };
|
|
|
|
// 2) Fallback: externals are appended in MediaStreams order → ordinal among
|
|
// *loadable* externals (those actually added to the player) stays in lockstep
|
|
// with the player's external list, skipping ones with no DeliveryUrl (#1763).
|
|
const externalStreams = subtitleStreams.filter(isExternalSubtitle);
|
|
const loadableExternals = getExpectedExternalUrl
|
|
? externalStreams.filter((s) => getExpectedExternalUrl(s))
|
|
: externalStreams;
|
|
const ordinal = loadableExternals.findIndex(
|
|
(s) => s.Index === jellyfinSubtitleIndex,
|
|
);
|
|
if (ordinal >= 0 && ordinal < playerExternals.length) {
|
|
return { kind: "select", trackId: playerExternals[ordinal].id };
|
|
}
|
|
return { kind: "notFound" };
|
|
}
|
|
|
|
// 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).
|
|
const identityMatches = playerEmbedded.filter((t) =>
|
|
embeddedIdentityMatches(t, target),
|
|
);
|
|
if (identityMatches.length === 1) {
|
|
return { kind: "select", trackId: identityMatches[0].id };
|
|
}
|
|
|
|
// 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 (ordinal >= 0 && ordinal < playerEmbedded.length) {
|
|
return { kind: "select", trackId: playerEmbedded[ordinal].id };
|
|
}
|
|
return { kind: "notFound" };
|
|
};
|
|
|
|
/**
|
|
* A subtitle track as reported by a concrete player's track-list API
|
|
* (mpv `getSubtitleTracks`, or a Cast track list). `lang` mirrors mpv's field name.
|
|
*/
|
|
export type PlayerSubtitleTrackRaw = {
|
|
id: number;
|
|
lang?: string;
|
|
title?: string;
|
|
codec?: string;
|
|
external?: boolean;
|
|
externalFilename?: string;
|
|
};
|
|
|
|
/**
|
|
* Minimal player surface needed to select a subtitle. Satisfied structurally by
|
|
* the mpv player ref and (later) implementable by the Chromecast backend.
|
|
*/
|
|
export interface SubtitleSelectablePlayer {
|
|
getSubtitleTracks: () => Promise<PlayerSubtitleTrackRaw[] | null | undefined>;
|
|
setSubtitleTrack: (trackId: number) => unknown;
|
|
disableSubtitles: () => unknown;
|
|
}
|
|
|
|
/**
|
|
* Read the player's real track list, resolve the Jellyfin subtitle index by
|
|
* identity ({@link resolveSubtitleTrack}) and apply the result. Single entry point
|
|
* for both the mobile controls and the player screen, so selection stays
|
|
* consistent everywhere. Returns the resolution for callers that want to react.
|
|
*/
|
|
export const applyMpvSubtitleSelection = async (
|
|
player: SubtitleSelectablePlayer | null | undefined,
|
|
params: {
|
|
subtitleStreams: MediaStream[] | undefined;
|
|
jellyfinSubtitleIndex: number;
|
|
/** Build the exact URL/path an external sub was loaded into the player with. */
|
|
getExpectedExternalUrl?: (sub: MediaStream) => string | undefined;
|
|
},
|
|
): Promise<SubtitleSelection> => {
|
|
if (!player) return { kind: "notFound" };
|
|
|
|
// Called fire-and-forget (`void applyMpvSubtitleSelection(...)`), so any native
|
|
// 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,
|
|
jellyfinSubtitleIndex: params.jellyfinSubtitleIndex,
|
|
playerTracks: tracks.map((t) => ({
|
|
id: t.id,
|
|
external: t.external,
|
|
externalFilename: t.externalFilename,
|
|
language: t.lang,
|
|
title: t.title,
|
|
codec: t.codec,
|
|
})),
|
|
getExpectedExternalUrl: params.getExpectedExternalUrl,
|
|
});
|
|
|
|
if (selection.kind === "select") {
|
|
await player.setSubtitleTrack(selection.trackId);
|
|
} else if (selection.kind === "disable") {
|
|
await player.disableSubtitles();
|
|
}
|
|
// notFound → leave current selection (e.g. image subs burned in while transcoding)
|
|
return selection;
|
|
} catch {
|
|
return { kind: "notFound" };
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Calculate the MPV track ID for a given Jellyfin audio index.
|
|
*
|
|
* For direct play: Audio tracks map to their position in the file (1-based).
|
|
* For transcoding: Only ONE audio track exists in the HLS stream (the selected one),
|
|
* so we should return 1 or undefined to use the default track.
|
|
*
|
|
* MPV track IDs are 1-based.
|
|
*
|
|
* @param mediaSource - The media source containing audio streams
|
|
* @param jellyfinAudioIndex - The Jellyfin server-side audio index
|
|
* @param isTranscoding - Whether the stream is being transcoded
|
|
* @returns MPV track ID (1-based), or undefined if not found
|
|
*/
|
|
export const getMpvAudioId = (
|
|
mediaSource: MediaSourceInfo | null | undefined,
|
|
jellyfinAudioIndex: number | undefined,
|
|
isTranscoding: boolean,
|
|
): number | undefined => {
|
|
if (jellyfinAudioIndex === undefined) {
|
|
return undefined;
|
|
}
|
|
|
|
// When transcoding, Jellyfin only includes the selected audio track in the HLS stream.
|
|
// So there's only 1 audio track - no need to specify an ID.
|
|
if (isTranscoding) {
|
|
return undefined;
|
|
}
|
|
|
|
const allAudio =
|
|
mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || [];
|
|
|
|
// Find position in audio list (1-based for MPV)
|
|
const position = allAudio.findIndex((a) => a.Index === jellyfinAudioIndex);
|
|
return position >= 0 ? position + 1 : undefined;
|
|
};
|