fix(plex): a subtitle failure about one position no longer ends the track, and windows are read ahead and never wait behind a full pass
Deep-review round 3 of bug hunt 2026-09. - `failed` is now final only for a track that can never be text (bitmap, no item/track/file). A Plex hiccup or a stretch ffmpeg could not read answers `unavailable`: said, polled again every 5 s, and the <track> stays mounted when cues exist elsewhere (gen > 0). Re-picking the selected track asks the server again at once. - Within LOOKAHEAD_S of a covered stretch's end the next window is read ahead, instead of only once the playhead has fallen off it (subtitles dropped out at every edge while the full pass was backed off or slower than a window lasts). - Windows and full passes run on separate pools with separate in-flight caps, so a window never queues behind (or is refused by) minute-long full passes — the pool comment promised that and nothing enforced it. - A pass that times out is TERMinated and keeps what it reached (published as partial) instead of being killed and discarded. - Lapsed failure memos are swept on every write (they were only pruned when the same key was read again, so polling fresh keys grew the process without bound). - `publish` no longer merges into a sidecar with no VTT behind it, or claims a region whose VTT write failed, or overwrites an unreadable track with one window. - A NUL in a rating_key names no row (it was a DataError, a 500, on every Plex route) and no longer 500s the status poll. Tests: 16 new, each mutation-checked (18 mutations, all red on the test that names them).
This commit is contained in:
@@ -249,6 +249,49 @@ describe("usePlexTracks — when the status poll asks again, and when it stops",
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user