The previous commit was assembled from a tree read before 13264e22 landed,
so committing it reverted that commit's changes: the image-based subtitle
filtering in device_profile/types, subtitleTracks and its tests, the
regenerated bindings, and the VideoPlayer menu wiring.
Nothing was lost — the working tree held both changes throughout. This
restores those files to the merged state, leaving both the subtitle fix and
the play-session fix in place.
TRACES: UR-020, UR-004 | DR-176 | UT-168
366 lines
15 KiB
TypeScript
366 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*\)/);
|
|
});
|
|
});
|