// @vitest-environment jsdom // // A subtitle 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 's own // request. The browser gives a 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 }) => ( {children} ); return renderHook( ({ id }: { id: string }) => usePlexTracks({ id, detail: detail(), sessionK, subOffset: 0, subPos: 88, loadSession: async () => {}, patchPrefs: () => {}, videoRef: createRef(), hlsRef: createRef(), multiAudioRef: { current: false }, audioRef: { current: 0 } as MutableRefObject, 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 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 . 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 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 's own // `error` event, which does not bubble — and React 18 attaches no listener of its own to a , // 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(), hlsRef: createRef(), multiAudioRef: { current: false }, audioRef: { current: 0 } as MutableRefObject, aoffRef: { current: 0 }, absRef: { current: 3600 }, loadStartRef: { current: 0 }, }); harness.current = tracks; return ( ); } const harness: { current: ReturnType | null } = { current: null }; function renderTrack(qc: QueryClient) { const view = render( , ); 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 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 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(); }); });