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:
2026-09-11 18:04:30 +02:00
parent f7faa19ee2
commit 4f37c41021
7 changed files with 453 additions and 98 deletions
@@ -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