Files
siftlode/frontend/src/lib/plexPlayerHooks.subtitle.dom.test.tsx
T
peter c60359bfed fix(plex): subtitles — a retried full pass resumes, a stalled track is polled slowly
A full pass that ran out of time kept what it reached, but its retry an hour
later started again at 0, so a file too big to read inside the extraction
timeout never completed. The pass now starts where every embedded track's
coverage from the head ends (less the window pre-roll, so a cue across the
join is read whole). The seek needs -avoid_negative_ts on this read as on a
window, measured on a real file: without it the cue running at the start
shifts the whole output.

A partial track whose full pass had failed never reached a settled state, so
the player polled it every 2 s for the rest of the film. The status now says
whether the track is still growing, and a stalled one is polled every 15 s —
still inside the 60 s lookahead that reads the next window before an edge.
2026-09-19 07:07:53 +02:00

409 lines
18 KiB
TypeScript

// @vitest-environment jsdom
//
// A subtitle <track> must not be mounted before the server has something to put in it.
//
// Bug hunt 2026-09, item 8: extracting one track out of a big MKV is a full read of the container
// (154 s for a 17 GB film, measured on prod), and it used to happen inside the <track>'s own
// request. The browser gives a <track> no visible state, so the player showed nothing at all —
// which reads as "switching subtitles does nothing", and the reported workaround was to stop/start
// until the cache happened to be warm. The extraction now runs in the background, publishing a
// window around the playhead first, and this hook's poll is what decides when there is something to
// mount and what to say while there isn't.
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { act, cleanup, render, renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createRef, type ReactNode, type MutableRefObject } from "react";
import { usePlexTracks } from "./plexPlayerHooks";
import { api, type PlexItemDetail } from "./api";
import { dismissError, getError } from "./errorDialog";
import type Hls from "hls.js";
const RK = "98614";
const detail = (): PlexItemDetail =>
({
id: RK,
duration_seconds: 8580,
audio_streams: [{ ord: 0, label: "Magyar AC3 5.1", language: "hu", default: true }],
subtitle_streams: [
{ ord: 0, label: "Magyar", language: "hu", text: true },
{ ord: 1, label: "English", language: "en", text: true },
],
markers: [],
}) as unknown as PlexItemDetail;
function mount(qc: QueryClient, { sessionK = 0, abs = 3600, loadStart = 0 } = {}) {
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
return renderHook(
({ id }: { id: string }) =>
usePlexTracks({
id,
detail: detail(),
sessionK,
subOffset: 0,
subPos: 88,
loadSession: async () => {},
patchPrefs: () => {},
videoRef: createRef<HTMLVideoElement>(),
hlsRef: createRef<Hls>(),
multiAudioRef: { current: false },
audioRef: { current: 0 } as MutableRefObject<number | null>,
aoffRef: { current: 0 },
absRef: { current: abs },
loadStartRef: { current: loadStart },
}),
{ wrapper, initialProps: { id: RK } },
);
}
const client = () => new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
describe("usePlexTracks — the <track> waits for the extraction", () => {
// vitest runs with globals off, so testing-library's auto-cleanup never registers: unmount by
// hand or the hook's 250 ms sub-offset debounce fires into a torn-down environment.
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
it("is not showable while the track is still being extracted", async () => {
vi.spyOn(api, "plexSubtitleStatus").mockResolvedValue({
state: "preparing",
code: null,
gen: 0,
});
const { result } = mount(client());
result.current.setSubOrd(0);
await waitFor(() => expect(api.plexSubtitleStatus).toHaveBeenCalled());
await waitFor(() => expect(result.current.selectedSub?.ord).toBe(0));
// The selected track exists and is offered in the picker — it just cannot be rendered yet, and
// the caller must be told so rather than mounting an empty <track>.
expect(result.current.subShowable).toBe(false);
expect(result.current.subNotice).toBe("preparing");
});
it("sends the viewer's position, so the extraction windows around where they are", async () => {
const status = vi
.spyOn(api, "plexSubtitleStatus")
.mockResolvedValue({ state: "preparing", code: null, gen: 0 });
const { result } = mount(client());
result.current.setSubOrd(1);
await waitFor(() => expect(status).toHaveBeenCalledWith(RK, 1, 3600));
});
it("sends where the session was asked to start before playback has ticked, not the head of the film", async () => {
// A resumed session starts minutes in, but `abs` stays 0 until the first timeupdate — and every
// poll used to say at=0, so the first window was cut around the head of the film (measured on
// localdev: a resume at 20:00 polled at=0 for as long as it stayed paused). `sessionK` is no
// substitute: it is still 0 here, because the session request has not answered yet.
const status = vi
.spyOn(api, "plexSubtitleStatus")
.mockResolvedValue({ state: "preparing", code: null, gen: 0 });
const { result } = mount(client(), { sessionK: 0, abs: 0, loadStart: 1198 });
result.current.setSubOrd(1);
await waitFor(() => expect(status).toHaveBeenCalledWith(RK, 1, 1198));
});
it("does not carry the track choice to the next item — that item's detail decides it", async () => {
// Carried over, the old ordinal polled the NEW item (where it may be another language) at the
// OLD item's position, queueing an extraction of the wrong stretch of the next file.
const status = vi
.spyOn(api, "plexSubtitleStatus")
.mockResolvedValue({ state: "preparing", code: null, gen: 0 });
const { result, rerender } = mount(client());
result.current.setSubOrd(1);
await waitFor(() => expect(status).toHaveBeenCalledWith(RK, 1, 3600));
rerender({ id: "NEXT" });
expect(result.current.subOrd).toBeNull();
expect(result.current.subNotice).toBeNull();
await act(async () => {});
expect(status.mock.calls.some(([id]) => id === "NEXT")).toBe(false);
});
it("becomes showable on a PARTIAL track — a window around the playhead is what is needed now", async () => {
vi.spyOn(api, "plexSubtitleStatus").mockResolvedValue({
state: "partial",
code: null,
gen: 1757500000000000000,
});
const { result } = mount(client());
result.current.setSubOrd(0);
await waitFor(() => expect(result.current.subShowable).toBe(true));
// The generation goes into the <track> key/URL: without it the browser would keep serving the
// first, short window from its own cache once the background pass widens the track.
expect(result.current.subGen).toBe(1757500000000000000);
});
it("reports a track that can never be shown, in the bucket the player has a sentence for", async () => {
vi.spyOn(api, "plexSubtitleStatus").mockResolvedValue({
state: "failed",
code: "bitmap",
gen: 0,
});
const { result } = mount(client());
result.current.setSubOrd(0);
await waitFor(() => expect(result.current.subNotice).toBe("bitmap"));
expect(result.current.subShowable).toBe(false);
});
it("folds the server's finer failure codes into 'missing'", async () => {
vi.spyOn(api, "plexSubtitleStatus").mockResolvedValue({
state: "failed",
code: "no_file",
gen: 0,
});
const { result } = mount(client());
result.current.setSubOrd(0);
await waitFor(() => expect(result.current.subNotice).toBe("missing"));
});
it("asks nothing at all while subtitles are off", async () => {
const status = vi
.spyOn(api, "plexSubtitleStatus")
.mockResolvedValue({ state: "ready", code: null, gen: 1 });
const { result } = mount(client());
await waitFor(() => expect(result.current.textSubs).toHaveLength(2));
expect(status).not.toHaveBeenCalled();
expect(result.current.subShowable).toBe(false);
});
});
// The poll's refetch rules ARE the feature: nothing else turns "preparing" into subtitles on screen,
// and nothing else stops a settled track from being asked about for the length of the film. Every
// test above resolves one fixed answer, so none of them ever reached the refetch callback.
describe("usePlexTracks — when the status poll asks again, and when it stops", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
});
it("keeps asking until a window lands, then shows it without the viewer doing anything", async () => {
const status = vi
.spyOn(api, "plexSubtitleStatus")
.mockResolvedValueOnce({ state: "preparing", code: null, gen: 0 })
.mockResolvedValue({ state: "partial", code: null, gen: 7 });
const { result } = mount(client());
result.current.setSubOrd(0);
await waitFor(() => expect(status).toHaveBeenCalledTimes(1));
expect(result.current.subShowable).toBe(false);
await vi.advanceTimersByTimeAsync(1100);
await waitFor(() => expect(result.current.subShowable).toBe(true));
});
it.each([
["the whole track is there", { state: "ready" as const, code: null, gen: 3 }],
["it can never be shown", { state: "failed" as const, code: "bitmap", gen: 0 }],
])("stops asking once the answer is settled — %s", async (_, answer) => {
const status = vi.spyOn(api, "plexSubtitleStatus").mockResolvedValue(answer);
const { result } = mount(client());
result.current.setSubOrd(0);
await waitFor(() => expect(status).toHaveBeenCalledTimes(1));
await vi.advanceTimersByTimeAsync(5000);
expect(status).toHaveBeenCalledTimes(1);
});
it("slows down on a partial track that will not grow on its own, and keeps asking", async () => {
// The full pass failed and is backed off for an hour: `partial` never turns `ready` by itself,
// and the 2 s cadence ran for the rest of the film. It still carries the position — reading
// ahead of an edge depends on it — so it slows rather than stops.
const status = vi
.spyOn(api, "plexSubtitleStatus")
.mockResolvedValue({ state: "partial", code: null, gen: 4, growing: false });
const { result } = mount(client());
result.current.setSubOrd(0);
await waitFor(() => expect(status).toHaveBeenCalledTimes(1));
await vi.advanceTimersByTimeAsync(10_000);
expect(status).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(6000);
expect(status).toHaveBeenCalledTimes(2);
});
it("backs off after a failed request, and still asks again so a blip heals on its own", async () => {
// A failed request leaves `data` empty; it used to read as "still preparing" and re-ask every
// second for as long as the player stayed open.
const status = vi.spyOn(api, "plexSubtitleStatus").mockRejectedValue(new Error("offline"));
const { result } = mount(client());
result.current.setSubOrd(0);
await waitFor(() => expect(result.current.subNotice).toBe("failed"));
expect(status).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(5000);
expect(status).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(6000);
expect(status).toHaveBeenCalledTimes(2);
});
it("does not raise the global error dialog when it fails — the player says so inline", async () => {
dismissError();
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ detail: "boom" }), {
status: 500,
headers: { "content-type": "application/json" },
}),
);
await expect(api.plexSubtitleStatus(RK, 0, 0)).rejects.toThrow();
expect(getError()).toBeNull();
});
it("says a failing poll even while an earlier window is still on screen", async () => {
// react-query keeps the last good answer through a failed refetch, so a `partial` track stayed
// "showable" and the failure had nowhere to appear: past the window, blank subtitles and nothing
// on screen saying why — with the request `quiet`, not even the global dialog.
vi.spyOn(api, "plexSubtitleStatus")
.mockResolvedValueOnce({ state: "partial", code: null, gen: 5 })
.mockRejectedValue(new Error("offline"));
const { result } = mount(client());
result.current.setSubOrd(0);
await waitFor(() => expect(result.current.subShowable).toBe(true));
expect(result.current.subNotice).toBeNull();
await vi.advanceTimersByTimeAsync(2100);
await waitFor(() => expect(result.current.subNotice).toBe("failed"));
expect(result.current.subShowable).toBe(true); // the window it has is still worth showing
});
it("says `unavailable` but keeps asking — and keeps the cues it has on screen", async () => {
// A Plex hiccup, or a stretch ffmpeg could not read, is the server's answer about NOW. Taken as
// final it stopped the poll and unmounted the track: no subtitles for the rest of the session,
// at cached positions too, after the full pass had completed behind it.
const status = vi
.spyOn(api, "plexSubtitleStatus")
.mockResolvedValueOnce({ state: "unavailable", code: "plex_error", gen: 5 })
.mockResolvedValue({ state: "partial", code: null, gen: 6 });
const { result } = mount(client());
result.current.setSubOrd(0);
await waitFor(() => expect(result.current.subNotice).toBe("failed"));
expect(result.current.subShowable).toBe(true);
await vi.advanceTimersByTimeAsync(5100);
expect(status).toHaveBeenCalledTimes(2);
await waitFor(() => expect(result.current.subNotice).toBeNull());
});
it("does not mount an `unavailable` track that has no cues anywhere", async () => {
vi.spyOn(api, "plexSubtitleStatus").mockResolvedValue({
state: "unavailable",
code: "plex_error",
gen: 0,
});
const { result } = mount(client());
result.current.setSubOrd(0);
await waitFor(() => expect(result.current.subNotice).toBe("failed"));
expect(result.current.subShowable).toBe(false);
});
it("re-asks the server at once when the viewer picks the track that is already selected", async () => {
// Same track, same query key: without an explicit refetch "picking it again" did nothing for a
// poll that was backing off after a failed request.
const status = vi.spyOn(api, "plexSubtitleStatus").mockRejectedValue(new Error("offline"));
const { result } = mount(client());
result.current.setSubOrd(0);
await waitFor(() => expect(result.current.subNotice).toBe("failed"));
expect(status).toHaveBeenCalledTimes(1);
status.mockResolvedValue({ state: "partial", code: null, gen: 2 });
act(() => result.current.changeSubtitle(0));
await waitFor(() => expect(status).toHaveBeenCalledTimes(2));
await waitFor(() => expect(result.current.subShowable).toBe(true));
});
});
// Rendered for real, not by calling the hook's callback: the failure arrives as the <track>'s own
// `error` event, which does not bubble — and React 18 attaches no listener of its own to a <track>,
// so the `onError` this used to be wired through was never called in a browser at all.
function TrackHarness() {
const tracks = usePlexTracks({
id: RK,
detail: detail(),
sessionK: 0,
subOffset: 0,
subPos: 88,
loadSession: async () => {},
patchPrefs: () => {},
videoRef: createRef<HTMLVideoElement>(),
hlsRef: createRef<Hls>(),
multiAudioRef: { current: false },
audioRef: { current: 0 } as MutableRefObject<number | null>,
aoffRef: { current: 0 },
absRef: { current: 3600 },
loadStartRef: { current: 0 },
});
harness.current = tracks;
return (
<video>
{tracks.subShowable && (
<track
key={tracks.subTrackKey}
data-key={tracks.subTrackKey}
kind="subtitles"
{...tracks.subTrackListeners}
/>
)}
</video>
);
}
const harness: { current: ReturnType<typeof usePlexTracks> | null } = { current: null };
function renderTrack(qc: QueryClient) {
const view = render(
<QueryClientProvider client={qc}>
<TrackHarness />
</QueryClientProvider>,
);
const track = () => view.container.querySelector("track");
// What the browser does when the VTT fetch fails: a plain, NON-bubbling `error` on the element.
const fail = () => act(() => void track()!.dispatchEvent(new Event("error")));
return { track, fail };
}
describe("usePlexTracks — a <track> whose own VTT fetch failed", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.spyOn(api, "plexSubtitleStatus").mockResolvedValue({ state: "ready", code: null, gen: 5 });
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
});
it("is replaced by a fresh element, after a pause, a couple of times — then reported instead of left blank", async () => {
// A failed <track> never fetches again, and while the server's answer is unchanged its key was
// unchanged too — so React kept the dead element and the subtitles stayed silently blank. The
// pause matters as much: remounting at once spent the whole budget inside a one-second outage.
const { track, fail } = renderTrack(client());
act(() => harness.current!.setSubOrd(0));
await waitFor(() => expect(track()).not.toBeNull());
for (const pause of [2000, 4000]) {
const before = track()!.dataset.key;
fail();
await vi.advanceTimersByTimeAsync(pause - 100);
expect(track()!.dataset.key).toBe(before); // not yet: still waiting out the pause
await vi.advanceTimersByTimeAsync(200);
await waitFor(() => expect(track()!.dataset.key).not.toBe(before));
}
fail();
await waitFor(() => expect(track()).toBeNull());
expect(harness.current!.subNotice).toBe("failed");
});
it("gets a fresh budget when the viewer picks the track again", async () => {
// The give-up belongs to one track generation, and a `ready` track is no longer polled, so no new
// generation would ever come: turning subtitles off and on used to leave "couldn't be loaded".
const { track, fail } = renderTrack(client());
act(() => harness.current!.setSubOrd(0));
await waitFor(() => expect(track()).not.toBeNull());
for (let i = 0; i < 3; i++) {
fail();
await vi.advanceTimersByTimeAsync(4100);
}
await waitFor(() => expect(track()).toBeNull());
act(() => harness.current!.changeSubtitle(null));
act(() => harness.current!.changeSubtitle(0));
await waitFor(() => expect(track()).not.toBeNull());
expect(harness.current!.subNotice).toBeNull();
});
});