Files
jellytau/src/lib/components/player/subtitleTracks.test.ts
T
dtourolle ad48d89dfe chore(format): run prettier over src/ and scripts/
Formatting was configured but never enforced: `bun run format:check`
reported 199 unformatted files and ran in no workflow and in no git hook,
so .prettierrc (printWidth 100, trailing commas) described an intention
rather than the tree.

This is the one-time sweep that makes the check gateable. Whitespace and
token-reflow only -- no behavioural change: `bun run check` reports 0
errors and all 1053 frontend tests pass before and after.

Kept out of every other commit on purpose. A 199-file diff mixed with
real changes is unreviewable, and the next commit turns format:check
into a hard CI gate so this cannot silently accumulate again.
2026-08-21 17:41:44 +02:00

375 lines
15 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import {
subtitleStreamsOf,
subtitleTrackLabel,
resolveSubtitleTracks,
reconcileSelectedSubtitle,
videoCrossOriginMode,
nativeSubtitleTracks,
nativeSubtitleArrayIndex,
type SubtitleStreamLike,
} from "./subtitleTracks";
/**
* Subtitles on the Linux / WebKitGTK HTML5 `<video>` path.
*
* TRACES: UR-020 | DR-023 | UT-143, UT-144
*
* The bug this guards: VideoPlayer rendered no `<track>` children at all (the
* block was commented out "to debug playback issues"), so
* `Html5PlayerAdapter.selectSubtitle()` walked an empty `textTracks` list and
* the subtitle menu was inert on Linux. The reason it had to be disabled is
* visible in the original markup — `src={getSubtitleUrl(track.index)}` bound the
* *Promise* returned by an async function to the attribute, so every track's src
* stringified to "[object Promise]", an unloadable resource hanging off the
* media element.
*
* So the fix has two halves and both are tested here: URLs must be resolved into
* plain strings *before* they reach the markup, and the markup must actually
* render the tracks (with the `data-stream-index` the adapter matches on).
*/
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 `<track>` 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 <track> 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([]);
});
});
describe("reconcileSelectedSubtitle", () => {
it("starts off (null) and keeps 'off' selectable", async () => {
const tracks = await resolveSubtitleTracks(SUBS, async (i) => url(i));
expect(reconcileSelectedSubtitle(tracks, null)).toBeNull();
});
it("keeps a selection that is still renderable", async () => {
const tracks = await resolveSubtitleTracks(SUBS, async (i) => url(i));
expect(reconcileSelectedSubtitle(tracks, 3)).toBe(3);
});
it("falls back to off when the selected track is gone (new item / failed URL)", async () => {
const tracks = await resolveSubtitleTracks(SUBS, async (i) => url(i));
expect(reconcileSelectedSubtitle(tracks, 9)).toBeNull();
expect(reconcileSelectedSubtitle([], 3)).toBeNull();
});
it("never auto-selects the server's default track", async () => {
// The menu opens on "Off" and a <track default> would auto-show, so the UI
// would claim subtitles are off while they are burned over the picture.
const tracks = await resolveSubtitleTracks(SUBS, async (i) => url(i));
expect(tracks[0].isDefault).toBe(true);
expect(reconcileSelectedSubtitle(tracks, null)).toBeNull();
});
});
describe("videoCrossOriginMode", () => {
it("opts into CORS for a server stream that has subtitles", () => {
expect(videoCrossOriginMode("http://jelly.example/Videos/x/master.m3u8", 2)).toBe("anonymous");
expect(videoCrossOriginMode("https://jelly.example/Videos/x/stream.mp4", 1)).toBe("anonymous");
});
it("leaves a local/offline source alone so playback cannot regress", () => {
expect(videoCrossOriginMode("asset://localhost/movie.mkv", 2)).toBeUndefined();
expect(videoCrossOriginMode("file:///home/u/movie.mkv", 2)).toBeUndefined();
});
it("stays out of the way when there is nothing to load", () => {
expect(videoCrossOriginMode("http://jelly.example/x.m3u8", 0)).toBeUndefined();
expect(videoCrossOriginMode("", 0)).toBeUndefined();
});
it("is decided by inputs known at first render, so it cannot flip mid-load", () => {
// Same answer before and after the async URL resolution completes.
const before = videoCrossOriginMode("http://jelly.example/x.m3u8", SUBS.length);
const after = videoCrossOriginMode("http://jelly.example/x.m3u8", SUBS.length);
expect(before).toBe(after);
});
});
/**
* 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();
});
});
describe("VideoPlayer markup (the regression that made the menu inert)", () => {
const source = readFileSync(resolve(__dirname, "VideoPlayer.svelte"), "utf-8");
it("renders <track> elements instead of leaving them commented out", () => {
expect(source).not.toContain("Temporarily disabled to debug playback issues");
expect(source).toMatch(/<track\b/);
expect(source).toContain('kind="subtitles"');
});
/** The rendered element, not a `<track>` mentioned in prose. */
const trackElement = source.slice(source.search(/<track\s/), source.search(/<track\s/) + 400);
it("keeps data-stream-index — Html5PlayerAdapter.selectSubtitle matches on it", () => {
expect(trackElement).toContain("data-stream-index");
});
it("never binds the async getSubtitleUrl() Promise to src", () => {
expect(source).not.toMatch(/src=\{\s*getSubtitleUrl\(/);
});
it("does not mark any track default (a default track auto-shows)", () => {
expect(trackElement).not.toMatch(/\bdefault=/);
});
});
/**
* 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*\)/);
});
});