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.
409 lines
18 KiB
TypeScript
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();
|
|
});
|
|
});
|