import { describe, it, expect } from "vitest"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { subtitleStreamsOf, subtitleTrackLabel, resolveSubtitleTracks, nativeSubtitleTracks, nativeSubtitleArrayIndex, type SubtitleStreamLike, } from "./subtitleTracks"; /** * Subtitle resolution — the list the play request carries. * * TRACES: UR-020 | DR-023 | UT-143, UT-144 * * URLs are resolved into plain strings before they reach the player: the * original markup bound the *Promise* returned by an async function to a * ``, so every track's src stringified to "[object Promise]". */ const SUBS: SubtitleStreamLike[] = [ { index: 2, kind: "subtitle", language: "eng", displayTitle: "English (SRT)", isDefault: true }, { index: 3, kind: "subtitle", language: "fre", displayTitle: "French", isDefault: false }, ]; const STREAMS: SubtitleStreamLike[] = [ { index: 0, kind: "video", language: null, displayTitle: "1080p" }, { index: 1, kind: "audio", language: "eng", displayTitle: "English AAC" }, ...SUBS, ]; const url = (i: number) => `http://jelly.example/Videos/x/Subtitles/${i}/0/subtitles.vtt?api_key=k`; describe("subtitleStreamsOf", () => { it("keeps only subtitle streams, in stream order", () => { expect(subtitleStreamsOf(STREAMS).map((s) => s.index)).toEqual([2, 3]); }); it("tolerates missing media streams", () => { expect(subtitleStreamsOf(null)).toEqual([]); expect(subtitleStreamsOf(undefined)).toEqual([]); }); /** * A subtitle the app cannot draw must not reach the picker. Image-based * tracks (PGS/DVD/DVB) are bitmaps: the only way to show one is for the server * to composite it into the video, which this app deliberately never asks for * (DR-176). Offering it anyway produced the reported symptom's twin — a menu * entry that selects, ticks, and shows nothing. * * The verdict is the backend's (`supportsExternalDelivery`); the codec * vocabulary behind it stays in Rust. * * TRACES: UR-020 | DR-176 | UT-168 */ it("drops subtitles the backend says it cannot deliver as a sidecar", () => { const streams: SubtitleStreamLike[] = [ { index: 2, kind: "subtitle", displayTitle: "English PGS SDH", supportsExternalDelivery: false, }, { index: 3, kind: "subtitle", displayTitle: "English Text SDH", supportsExternalDelivery: true, }, ]; expect(subtitleStreamsOf(streams).map((s) => s.index)).toEqual([3]); }); /** * Only an explicit "no" hides a track. A stream that carries no verdict at all * predates the field (or came from somewhere that does not set it), and * hiding those would silently empty the menu for sources that work today. * * TRACES: UR-020 | DR-176 | UT-168 */ it("keeps subtitles that carry no verdict", () => { const streams: SubtitleStreamLike[] = [ { index: 2, kind: "subtitle", displayTitle: "English" }, { index: 3, kind: "subtitle", displayTitle: "French", supportsExternalDelivery: null }, ]; expect(subtitleStreamsOf(streams).map((s) => s.index)).toEqual([2, 3]); }); /** * The same list feeds the `` children and the native play request, so * an undeliverable track must not even have its URL fetched — that request is * the one that 404s, and the sideloaded track it would produce is the dead * entry all over again. * * TRACES: UR-020 | DR-176 | UT-168 */ it("never resolves a URL for a subtitle it dropped", async () => { const asked: number[] = []; const tracks = await resolveSubtitleTracks( [ { index: 2, kind: "subtitle", displayTitle: "PGS", supportsExternalDelivery: false }, { index: 3, kind: "subtitle", displayTitle: "SRT", supportsExternalDelivery: true }, ], async (index) => { asked.push(index); return url(index); }, ); expect(asked).toEqual([3]); expect(tracks.map((t) => t.streamIndex)).toEqual([3]); }); }); describe("subtitleTrackLabel", () => { it("prefers the display title, then language, then the index", () => { expect(subtitleTrackLabel({ index: 2, displayTitle: "English (SRT)", language: "eng" })).toBe( "English (SRT)", ); expect(subtitleTrackLabel({ index: 2, displayTitle: null, language: "eng" })).toBe("eng"); expect(subtitleTrackLabel({ index: 2 })).toBe("Track 2"); }); }); describe("resolveSubtitleTracks", () => { it("resolves real string URLs — never a Promise — for every subtitle stream", async () => { const tracks = await resolveSubtitleTracks(STREAMS, async (i) => url(i)); expect(tracks).toHaveLength(2); for (const track of tracks) { expect(typeof track.url).toBe("string"); // The exact regression: a Promise bound to src stringifies to this. expect(String(track.url)).not.toContain("[object Promise]"); expect(track.url).toContain("subtitles.vtt"); } // The adapter matches elements by data-stream-index, so the stream // index has to survive resolution. expect(tracks.map((t) => t.streamIndex)).toEqual([2, 3]); expect(tracks.map((t) => t.label)).toEqual(["English (SRT)", "French"]); expect(tracks.map((t) => t.srclang)).toEqual(["eng", "fre"]); expect(tracks[0].isDefault).toBe(true); }); it("drops tracks whose URL cannot be built instead of rendering a dead src", async () => { const tracks = await resolveSubtitleTracks(SUBS, async (i) => { if (i === 2) throw new Error("no repository"); return url(i); }); expect(tracks.map((t) => t.streamIndex)).toEqual([3]); }); it("drops empty and non-string URLs", async () => { const tracks = await resolveSubtitleTracks(SUBS, async (i) => i === 2 ? " " : (undefined as unknown as string), ); expect(tracks).toEqual([]); }); it("returns nothing when there are no subtitle streams", async () => { expect(await resolveSubtitleTracks([STREAMS[0]], async (i) => url(i))).toEqual([]); expect(await resolveSubtitleTracks(null, async (i) => url(i))).toEqual([]); }); }); /** * Subtitles on the Android / ExoPlayer native path. * * TRACES: UR-020 | IR-016, JA-008 | UT-147 * * The bug this guards: VideoPlayer built a fully-resolved subtitle array in * onMount and then never sent it — `commands.playerPlayItem({...})` passed only * streamUrl/title/id/videoCodec/needsTranscoding — so every MediaItem reached * ExoPlayer with zero SubtitleConfigurations and `setSubtitleTrack(n)` logged * "Invalid subtitle track index". * * And the second half: `setSubtitleTrack(n)` indexes ExoPlayer's *text track * groups*, i.e. the position of the sideloaded configuration — not the Jellyfin * stream index. The menu used to pass its own row position, which is a position * in the *unresolved* stream list; the moment one subtitle URL failed to * resolve, the two lists diverged and every track below the gap selected the * wrong subtitle. */ describe("nativeSubtitleTracks", () => { it("maps to the wire shape Rust deserializes and Kotlin parses", async () => { const resolved = await resolveSubtitleTracks(SUBS, async (i) => url(i)); const payload = nativeSubtitleTracks(resolved); expect(payload).toHaveLength(2); // Kotlin reads url/language/label/mime_type; Rust's SubtitleTrack keeps // snake_case for exactly that reason, and so does the generated binding. for (const track of payload) { expect(Object.keys(track).sort()).toEqual( ["index", "label", "language", "mime_type", "url"].sort(), ); expect(track).not.toHaveProperty("mimeType"); expect(track.mime_type).toBe("text/vtt"); } // Jellyfin serves every subtitle stream as WebVTT here, and the stream index // rides along so the UI can keep talking in stream indices. expect(payload.map((t) => t.index)).toEqual([2, 3]); expect(payload[0].url).toContain("subtitles.vtt"); expect(payload[0].language).toBe("eng"); expect(payload[0].label).toBe("English (SRT)"); }); it("preserves stream order, because that order is the selection index", async () => { const resolved = await resolveSubtitleTracks(STREAMS, async (i) => url(i)); expect(nativeSubtitleTracks(resolved).map((t) => t.index)).toEqual( resolved.map((t) => t.streamIndex), ); }); it("has nothing to send when no subtitle URL resolved", async () => { expect(nativeSubtitleTracks(await resolveSubtitleTracks(SUBS, async () => ""))).toEqual([]); expect(nativeSubtitleTracks([])).toEqual([]); }); it("carries a null language/label through rather than inventing one", () => { const payload = nativeSubtitleTracks([ { streamIndex: 5, url: "u.vtt", srclang: "und", label: "Track 5", isDefault: false }, ]); expect(payload[0].language).toBeNull(); expect(payload[0].label).toBe("Track 5"); }); }); describe("nativeSubtitleArrayIndex", () => { it("returns the position in the list that was actually sent, not the stream index", async () => { const resolved = await resolveSubtitleTracks(SUBS, async (i) => url(i)); expect(nativeSubtitleArrayIndex(resolved, 2)).toBe(0); expect(nativeSubtitleArrayIndex(resolved, 3)).toBe(1); }); it("stays aligned when a subtitle URL failed to resolve (the mis-selection bug)", async () => { // Stream 2 has no URL, so it is not among the sideloaded configurations. // The menu's own row for stream 3 is position 1, but ExoPlayer only has one // text track group — position 0. Sending 1 would select nothing. const resolved = await resolveSubtitleTracks(SUBS, async (i) => { if (i === 2) throw new Error("no repository"); return url(i); }); expect(resolved).toHaveLength(1); expect(nativeSubtitleArrayIndex(resolved, 3)).toBe(0); }); it("maps 'Off' to null so the backend disables text instead of selecting track 0", async () => { const resolved = await resolveSubtitleTracks(SUBS, async (i) => url(i)); expect(nativeSubtitleArrayIndex(resolved, null)).toBeNull(); }); it("maps a track that was never sent to null rather than to a wrong position", async () => { const resolved = await resolveSubtitleTracks(SUBS, async (i) => url(i)); expect(nativeSubtitleArrayIndex(resolved, 99)).toBeNull(); expect(nativeSubtitleArrayIndex([], 3)).toBeNull(); }); }); /** * The half of the Android fix that lives in the component: the resolved list has * to actually be handed to `playerPlayItem`, and the index sent to the backend * has to be computed from that same list. * * TRACES: UR-020 | IR-016 | UT-147 */ describe("VideoPlayer -> playerPlayItem (the tracks that were built and thrown away)", () => { const source = readFileSync(resolve(__dirname, "VideoPlayer.svelte"), "utf-8"); /** The playerPlayItem({...}) argument object. */ const playItemCall = (() => { const start = source.indexOf("commands.playerPlayItem("); expect(start).toBeGreaterThan(-1); return source.slice(start, source.indexOf("});", start) + 3); })(); it("sends the subtitle tracks it resolved", () => { expect(playItemCall).toMatch(/\bsubtitles:/); }); it("selects by position in the sent list, not by the menu's row number", () => { expect(source).toContain("nativeSubtitleArrayIndex"); // The old code forwarded the `{#each}` index straight to the backend. expect(source).not.toMatch(/playerSetSubtitleTrack\(\s*arrayIndex\s*\)/); }); });