diff --git a/utils/jellyfin/subtitleUtils.test.ts b/utils/jellyfin/subtitleUtils.test.ts new file mode 100644 index 00000000..dab53326 --- /dev/null +++ b/utils/jellyfin/subtitleUtils.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, test } from "bun:test"; +import type { + MediaStream, + SubtitleDeliveryMethod, +} from "@jellyfin/sdk/lib/generated-client"; +import { + compareTracksForMenu, + isExternalSubtitle, + type PlayerSubtitleTrack, + resolveSubtitleTrack, +} from "./subtitleUtils"; + +// String-enum values as typed literals — avoids a runtime SDK import (see subtitleUtils.ts). +const External = "External" as SubtitleDeliveryMethod; +const Embed = "Embed" as SubtitleDeliveryMethod; + +// --- fixtures -------------------------------------------------------------- + +const sub = (o: Partial & { Index: number }): MediaStream => + ({ Type: "Subtitle", ...o }) as MediaStream; + +const ext = (Index: number, o: Partial = {}): MediaStream => + sub({ + Index, + DeliveryMethod: External, + IsExternal: true, + DeliveryUrl: `/sub/${Index}.srt`, + ...o, + }); + +const emb = (Index: number, o: Partial = {}): MediaStream => + sub({ Index, DeliveryMethod: Embed, ...o }); + +const track = (o: PlayerSubtitleTrack): PlayerSubtitleTrack => o; + +// Mirror direct-player.tsx online URL builder. +const urlBuilder = + (base: string) => + (s: MediaStream): string | undefined => + s.DeliveryUrl ? `${base}${s.DeliveryUrl}` : undefined; + +const resolve = ( + streams: MediaStream[], + index: number | undefined, + player: PlayerSubtitleTrack[], + getExpectedExternalUrl = urlBuilder("http://srv"), +) => + resolveSubtitleTrack({ + subtitleStreams: streams, + jellyfinSubtitleIndex: index, + playerTracks: player, + getExpectedExternalUrl, + }); + +// --- tests ----------------------------------------------------------------- + +describe("isExternalSubtitle", () => { + test("true for External delivery, IsExternal flag, or a DeliveryUrl", () => { + expect(isExternalSubtitle(ext(0))).toBe(true); + expect(isExternalSubtitle(sub({ Index: 1, DeliveryUrl: "/x.srt" }))).toBe( + true, + ); + expect(isExternalSubtitle(emb(2))).toBe(false); + }); +}); + +describe("resolveSubtitleTrack — disable / notFound", () => { + test("index -1 or undefined disables", () => { + expect(resolve([], -1, [])).toEqual({ kind: "disable" }); + expect(resolve([], undefined, [])).toEqual({ kind: "disable" }); + }); + + test("index not present returns notFound", () => { + expect(resolve([emb(0)], 99, [track({ id: 1 })])).toEqual({ + kind: "notFound", + }); + }); +}); + +describe("resolveSubtitleTrack — hidden embedded (#954)", () => { + // Server hides embedded subs: MediaStreams lists only the 3 externals, + // but mpv still demuxes the 3 embedded from the file → externals get ids 4,5,6. + const streams = [ + ext(0, { Language: "por" }), + ext(1, { Language: "eng" }), + ext(2, { Language: "eng", Title: "SDH" }), + ]; + const player = [ + track({ id: 1, external: false, language: "eng", title: "CC" }), + track({ id: 2, external: false, language: "spa" }), + track({ id: 3, external: false, language: "fre" }), + track({ id: 4, external: true, externalFilename: "http://srv/sub/0.srt" }), + track({ id: 5, external: true, externalFilename: "http://srv/sub/1.srt" }), + track({ id: 6, external: true, externalFilename: "http://srv/sub/2.srt" }), + ]; + + test("each external maps to the right player id by filename (not 1,2,3)", () => { + expect(resolve(streams, 0, player)).toEqual({ kind: "select", trackId: 4 }); + expect(resolve(streams, 1, player)).toEqual({ kind: "select", trackId: 5 }); + expect(resolve(streams, 2, player)).toEqual({ kind: "select", trackId: 6 }); + }); + + test("falls back to external ordinal when filenames are unavailable", () => { + const noNames = player.map((t) => + t.external ? { ...t, externalFilename: undefined } : t, + ); + expect(resolve(streams, 1, noNames)).toEqual({ + kind: "select", + trackId: 5, + }); + }); +}); + +describe("resolveSubtitleTrack — external/embed reversal (non-hidden)", () => { + // Jellyfin lists externals first; mpv lists embedded first then externals. + const streams = [ + ext(0, { Language: "eng" }), + emb(1, { Language: "spa" }), + emb(2, { Language: "fre" }), + ]; + const player = [ + track({ id: 1, external: false, language: "spa" }), + track({ id: 2, external: false, language: "fre" }), + track({ id: 3, external: true, externalFilename: "http://srv/sub/0.srt" }), + ]; + + test("external resolves by filename, embedded by language", () => { + expect(resolve(streams, 0, player)).toEqual({ kind: "select", trackId: 3 }); + expect(resolve(streams, 1, player)).toEqual({ kind: "select", trackId: 1 }); + expect(resolve(streams, 2, player)).toEqual({ kind: "select", trackId: 2 }); + }); +}); + +describe("resolveSubtitleTrack — external without DeliveryUrl (#1763 CodeRabbit)", () => { + // Middle external has no DeliveryUrl → never loaded into the player. + const streams = [ + ext(0, { Language: "eng", DeliveryUrl: "/sub/a.srt" }), + sub({ Index: 1, DeliveryMethod: External, IsExternal: true }), + ext(2, { Language: "fre", DeliveryUrl: "/sub/c.srt" }), + ]; + const player = [ + track({ id: 4, external: true, externalFilename: "http://srv/sub/a.srt" }), + track({ id: 5, external: true, externalFilename: "http://srv/sub/c.srt" }), + ]; + + test("loaded externals still map correctly despite the gap", () => { + expect(resolve(streams, 0, player)).toEqual({ kind: "select", trackId: 4 }); + expect(resolve(streams, 2, player)).toEqual({ kind: "select", trackId: 5 }); + }); + + test("selecting the unloaded external returns notFound", () => { + expect(resolve(streams, 1, player)).toEqual({ kind: "notFound" }); + }); +}); + +describe("resolveSubtitleTrack — embedded matching", () => { + test("unique language match wins regardless of order", () => { + const streams = [emb(0, { Language: "eng" }), emb(1, { Language: "jpn" })]; + const player = [ + track({ id: 1, external: false, language: "eng" }), + track({ id: 2, external: false, language: "jpn" }), + ]; + expect(resolve(streams, 1, player)).toEqual({ kind: "select", trackId: 2 }); + }); + + test("same-language tracks disambiguate by ordinal among matches", () => { + const streams = [ + emb(0, { Language: "eng", Title: "Full" }), + emb(1, { Language: "eng", Title: "SDH" }), + ]; + const player = [ + track({ id: 1, external: false, language: "eng", title: "Full" }), + track({ id: 2, external: false, language: "eng", title: "SDH" }), + ]; + expect(resolve(streams, 1, player)).toEqual({ kind: "select", trackId: 2 }); + }); + + test("falls back to embedded ordinal when no language/title info", () => { + const streams = [emb(0), emb(1)]; + const player = [ + track({ id: 1, external: false }), + track({ id: 2, external: false }), + ]; + expect(resolve(streams, 1, player)).toEqual({ kind: "select", trackId: 2 }); + }); +}); + +describe("compareTracksForMenu — jellyfin-web order", () => { + test("externals sort after embedded despite lower Index", () => { + const sorted = [ + ext(0, { Language: "eng" }), + emb(7, { Language: "fra" }), + ].sort(compareTracksForMenu); + expect(sorted.map((s) => s.Index)).toEqual([7, 0]); + }); + + test("forced then default float to the top within a group", () => { + const sorted = [ + emb(2, { Language: "eng" }), + emb(1, { Language: "eng", IsDefault: true }), + emb(0, { Language: "eng", IsForced: true }), + ].sort(compareTracksForMenu); + expect(sorted.map((s) => s.Index)).toEqual([0, 1, 2]); + }); + + test("full Okiku order: embedded first, externals last by Index", () => { + const streams = [ + ext(0, { Language: "eng" }), + ext(1, { Language: "eng" }), + ext(2, { Language: "fra" }), + ext(3, { Language: "fra" }), + emb(7, { Language: "fra", Title: "French" }), + ]; + expect([...streams].sort(compareTracksForMenu).map((s) => s.Index)).toEqual( + [7, 0, 1, 2, 3], + ); + }); +}); diff --git a/utils/jellyfin/subtitleUtils.ts b/utils/jellyfin/subtitleUtils.ts index f20f8521..7cb23e84 100644 --- a/utils/jellyfin/subtitleUtils.ts +++ b/utils/jellyfin/subtitleUtils.ts @@ -1,91 +1,270 @@ /** - * Subtitle utility functions for mapping between Jellyfin and MPV track indices. + * Subtitle utilities: resolve a Jellyfin subtitle stream to the right track in + * the *player's real track list* by identity — never by positional counting. * - * Jellyfin uses server-side indices (e.g., 3, 4, 5 for subtitles in MediaStreams). - * MPV uses its own track IDs starting from 1, only counting tracks loaded into MPV. + * 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 NOT available in MPV's track list. + * and absent from the player's track list. */ -import { - type MediaSourceInfo, - type MediaStream, +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; + /** Check if subtitle is image-based (PGS, VOBSUB, etc.) */ export const isImageBasedSubtitle = (sub: MediaStream): boolean => sub.IsTextSubtitleStream === false; -/** - * Determine if a subtitle will be available in MPV's track list. - * - * A subtitle is in MPV if: - * - Delivery is Embed/Hls/External AND not an image-based sub during transcode - */ -export const isSubtitleInMpv = ( - sub: MediaStream, - isTranscoding: boolean, -): boolean => { - // During transcoding, image-based subs are burned in, not in MPV - if (isTranscoding && isImageBasedSubtitle(sub)) { - return false; - } +/** A Jellyfin subtitle stream is "external" when the server delivers it as a sidecar file. */ +export const isExternalSubtitle = (sub: MediaStream): boolean => + sub.DeliveryMethod === EXTERNAL_DELIVERY || + sub.IsExternal === true || + Boolean(sub.DeliveryUrl); - // Embed/Hls/External methods mean the sub is loaded into MPV - return ( - sub.DeliveryMethod === SubtitleDeliveryMethod.Embed || - sub.DeliveryMethod === SubtitleDeliveryMethod.Hls || - sub.DeliveryMethod === SubtitleDeliveryMethod.External - ); +/** + * Order subtitle/audio 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). + */ +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" } + | { 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(); + +/** 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)) { + // 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; }; /** - * Calculate the MPV track ID for a given Jellyfin subtitle index. + * 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. * - * MPV track IDs are 1-based and only count subtitles that are actually in MPV. - * We iterate through all subtitles, counting only those in MPV, until we find - * the one matching the Jellyfin index. + * 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). * - * @param mediaSource - The media source containing subtitle streams - * @param jellyfinSubtitleIndex - The Jellyfin server-side subtitle index (-1 = disabled) - * @param isTranscoding - Whether the stream is being transcoded - * @returns MPV track ID (1-based), or -1 if disabled, or undefined if not in MPV + * 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 getMpvSubtitleId = ( - mediaSource: MediaSourceInfo | null | undefined, - jellyfinSubtitleIndex: number | undefined, - isTranscoding: boolean, -): number | undefined => { - // -1 or undefined means disabled +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 -1; + return { kind: "disable" }; } - const allSubs = - mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") || []; + const target = subtitleStreams.find((s) => s.Index === jellyfinSubtitleIndex); + if (!target) return { kind: "notFound" }; - // Find the subtitle with the matching Jellyfin index - const targetSub = allSubs.find((s) => s.Index === jellyfinSubtitleIndex); + if (isExternalSubtitle(target)) { + const playerExternals = playerTracks.filter((t) => t.external === true); - // If the target subtitle isn't in MPV (e.g., image-based during transcode), return undefined - if (!targetSub || !isSubtitleInMpv(targetSub, isTranscoding)) { - return undefined; - } + // 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 }; - // Count MPV track position (1-based) - let mpvIndex = 0; - for (const sub of allSubs) { - if (isSubtitleInMpv(sub, isTranscoding)) { - mpvIndex++; - if (sub.Index === jellyfinSubtitleIndex) { - return mpvIndex; - } + // 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" }; } - return undefined; + // Embedded / in-container subtitle. + const embeddedStreams = subtitleStreams.filter((s) => !isExternalSubtitle(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) 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 }; + } + 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; + 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 => { + if (!player) return { kind: "notFound" }; + + 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; }; /**