fix(plex): a subtitle track's failed load is heard and retried with a pause, and the poll asks about the right item, position and failure

- The <track>'s `error` never reached `onSubTrackError`: it does not bubble, and React 18 attaches no
  listener of its own to a <track> (measured in Chrome and jsdom — `onError` 0 calls, `onErrorCapture`
  1). Round 1's remount-and-give-up was dead code behind it. The hook now hands the player
  `onErrorCapture`, and the test fires a real non-bubbling `error` at a rendered <track>.
- Once reachable, the retry burned its whole budget within milliseconds of an outage (a remount
  fetches at once) and the give-up outlived turning subtitles off and on. Retries now wait (2 s,
  doubling), running out is reported at once, and picking the track again starts a fresh budget.
- Until the first timeupdate the poll sent `sessionK` — still 0 while the session request was out,
  and the PREVIOUS item's start after a move to the next episode. It now sends where the session was
  asked to start, and the track choice belongs to the item it was made for, so the next item is not
  asked about the old ordinal at the old position.
- A failing poll over a stale `partial` answer was silent: react-query keeps the last good data, the
  track stayed "showable", and the notice only rendered for an unmounted track. The hook now decides
  what the player says (`subNotice`), independently of whether a window is mounted.
This commit is contained in:
2026-09-11 03:18:42 +02:00
parent 7360339039
commit 645d14f45d
3 changed files with 234 additions and 64 deletions
@@ -10,7 +10,7 @@
// 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, renderHook, waitFor } from "@testing-library/react";
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";
@@ -32,14 +32,14 @@ const detail = (): PlexItemDetail =>
markers: [],
}) as unknown as PlexItemDetail;
function mount(qc: QueryClient, { sessionK = 0, abs = 3600 } = {}) {
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: RK,
id,
detail: detail(),
sessionK,
subOffset: 0,
@@ -52,8 +52,9 @@ function mount(qc: QueryClient, { sessionK = 0, abs = 3600 } = {}) {
audioRef: { current: 0 } as MutableRefObject<number | null>,
aoffRef: { current: 0 },
absRef: { current: abs },
loadStartRef: { current: loadStart },
}),
{ wrapper },
{ wrapper, initialProps: { id: RK } },
);
}
@@ -80,7 +81,7 @@ describe("usePlexTracks — the <track> waits for the extraction", () => {
// 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.subFail).toBeNull();
expect(result.current.subNotice).toBe("preparing");
});
it("sends the viewer's position, so the extraction windows around where they are", async () => {
@@ -92,18 +93,35 @@ describe("usePlexTracks — the <track> waits for the extraction", () => {
await waitFor(() => expect(status).toHaveBeenCalledWith(RK, 1, 3600));
});
it("sends the session's start before playback has ticked, not the head of the film", async () => {
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).
// 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: 1198, abs: 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",
@@ -126,7 +144,7 @@ describe("usePlexTracks — the <track> waits for the extraction", () => {
});
const { result } = mount(client());
result.current.setSubOrd(0);
await waitFor(() => expect(result.current.subFail).toBe("bitmap"));
await waitFor(() => expect(result.current.subNotice).toBe("bitmap"));
expect(result.current.subShowable).toBe(false);
});
@@ -138,7 +156,7 @@ describe("usePlexTracks — the <track> waits for the extraction", () => {
});
const { result } = mount(client());
result.current.setSubOrd(0);
await waitFor(() => expect(result.current.subFail).toBe("missing"));
await waitFor(() => expect(result.current.subNotice).toBe("missing"));
});
it("asks nothing at all while subtitles are off", async () => {
@@ -196,7 +214,7 @@ describe("usePlexTracks — when the status poll asks again, and when it stops",
const status = vi.spyOn(api, "plexSubtitleStatus").mockRejectedValue(new Error("offline"));
const { result } = mount(client());
result.current.setSubOrd(0);
await waitFor(() => expect(result.current.subFail).toBe("failed"));
await waitFor(() => expect(result.current.subNotice).toBe("failed"));
expect(status).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(5000);
expect(status).toHaveBeenCalledTimes(1);
@@ -215,29 +233,117 @@ describe("usePlexTracks — when the status poll asks again, and when it stops",
await expect(api.plexSubtitleStatus(RK, 0, 0)).rejects.toThrow();
expect(getError()).toBeNull();
});
});
describe("usePlexTracks — a <track> whose own VTT fetch failed", () => {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
it("is replaced by a fresh element 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.
vi.spyOn(api, "plexSubtitleStatus").mockResolvedValue({ state: "partial", code: null, gen: 5 });
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));
for (let i = 0; i < 2; i++) {
const before = result.current.subTrackKey;
act(() => result.current.onSubTrackError());
await waitFor(() => expect(result.current.subTrackKey).not.toBe(before));
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
});
});
// 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));
}
act(() => result.current.onSubTrackError());
await waitFor(() => expect(result.current.subShowable).toBe(false));
expect(result.current.subFail).toBe("failed");
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();
});
});