fix(plex): the subtitle poll backs off on errors, a failed <track> is replaced, and a resume asks for the right window
Deep-review R2, plus one defect found while verifying it on localdev. - The status poll re-armed every second after a FAILED request: `data` stays empty on an error, so it read as "still preparing" for as long as the player was open — and the request was not quiet, so a persistent 500 re-opened the global error dialog every second. It now backs off to 10 s (a blip still heals on its own) and is quiet; the player already shows the failure inline. - A <track> whose own VTT fetch failed never fetched again: with the server's answer unchanged its key was unchanged, so React kept the dead element — silently blank subtitles. The key (now built in the hook, where it is tested) carries a failed-load count; after two remounts the track is reported as unavailable instead. - Found in UAT: until the first timeupdate `abs` is 0, so after a resume every poll said at=0 and the first window was cut around the head of the film (a resume at 20:00 polled at=0 for as long as it stayed paused). The poll reports the session's start until playback ticks. And the window's dedup identity is the track plus a window-length bucket of its start — keyed on the track alone (R1), the right window queued behind that wrong one. The poll's start/stop rules finally have tests (every earlier one resolved a single fixed answer). Verified on localdev against Westworld S1E6 resumed at 20:00: the polls carried at=1197, one subtitle line on screen at 21:02, the poll stopped at `ready`; the real file's window (56 cues) + full pass (608) merged to 608 — the old merge would have served 664. 11 mutations, all red.
This commit is contained in:
@@ -9,12 +9,13 @@
|
||||
// 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 } from "vitest";
|
||||
import { cleanup, renderHook, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import { act, cleanup, 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";
|
||||
@@ -31,7 +32,7 @@ const detail = (): PlexItemDetail =>
|
||||
markers: [],
|
||||
}) as unknown as PlexItemDetail;
|
||||
|
||||
function mount(qc: QueryClient) {
|
||||
function mount(qc: QueryClient, { sessionK = 0, abs = 3600 } = {}) {
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
);
|
||||
@@ -40,7 +41,7 @@ function mount(qc: QueryClient) {
|
||||
usePlexTracks({
|
||||
id: RK,
|
||||
detail: detail(),
|
||||
sessionK: 0,
|
||||
sessionK,
|
||||
subOffset: 0,
|
||||
subPos: 88,
|
||||
loadSession: async () => {},
|
||||
@@ -50,7 +51,7 @@ function mount(qc: QueryClient) {
|
||||
multiAudioRef: { current: false },
|
||||
audioRef: { current: 0 } as MutableRefObject<number | null>,
|
||||
aoffRef: { current: 0 },
|
||||
absRef: { current: 3600 },
|
||||
absRef: { current: abs },
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
@@ -91,6 +92,18 @@ 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 () => {
|
||||
// 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).
|
||||
const status = vi
|
||||
.spyOn(api, "plexSubtitleStatus")
|
||||
.mockResolvedValue({ state: "preparing", code: null, gen: 0 });
|
||||
const { result } = mount(client(), { sessionK: 1198, abs: 0 });
|
||||
result.current.setSubOrd(1);
|
||||
await waitFor(() => expect(status).toHaveBeenCalledWith(RK, 1, 1198));
|
||||
});
|
||||
|
||||
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",
|
||||
@@ -138,3 +151,93 @@ describe("usePlexTracks — the <track> waits for the extraction", () => {
|
||||
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("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.subFail).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();
|
||||
});
|
||||
});
|
||||
|
||||
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 });
|
||||
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);
|
||||
}
|
||||
act(() => result.current.onSubTrackError());
|
||||
await waitFor(() => expect(result.current.subShowable).toBe(false));
|
||||
expect(result.current.subFail).toBe("failed");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user