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:
@@ -662,13 +662,15 @@ export const plexApi = {
|
||||
// Whether a subtitle track can be shown yet. Extracting one out of a big MKV costs a full read of
|
||||
// the container (measured: 154 s for a 17 GB film), so the server does it in the background — around
|
||||
// `at`, the viewer's position, first — and the player polls this instead of mounting a <track> that
|
||||
// would sit empty for minutes. `partial` = a window around `at` is ready and still growing.
|
||||
// would sit empty for minutes. `partial` = a window around `at` is ready and still growing;
|
||||
// `unavailable` = nothing for `at` right now (`code` says why) but ask again — cues elsewhere, when
|
||||
// `gen` > 0, are still there; `failed` = the track can never be shown (the only final answer).
|
||||
plexSubtitleStatus: (
|
||||
id: string,
|
||||
ord: number,
|
||||
at = 0,
|
||||
): Promise<{
|
||||
state: "ready" | "partial" | "preparing" | "failed";
|
||||
state: "ready" | "partial" | "preparing" | "unavailable" | "failed";
|
||||
code: string | null;
|
||||
gen: number;
|
||||
}> =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -353,6 +353,10 @@ export type SubFailReason = "bitmap" | "missing" | "failed";
|
||||
// <track> whose own VTT fetch failed is remounted before the track is reported as unavailable, and
|
||||
// the first of the (doubling) pauses before each remount.
|
||||
const SUB_POLL_ERROR_MS = 10_000;
|
||||
// …and after an `unavailable` answer: nothing is being read for this position right now (a memo the
|
||||
// server keeps for a minute), so no need for the 1 s cadence — but the poll carries the position, and
|
||||
// moving into a stretch that IS cached should stop saying "couldn't be loaded" within a few seconds.
|
||||
const SUB_POLL_UNAVAILABLE_MS = 5_000;
|
||||
const SUB_TRACK_RETRIES = 2;
|
||||
const SUB_TRACK_RETRY_MS = 2_000;
|
||||
|
||||
@@ -467,14 +471,19 @@ export function usePlexTracks(opts: {
|
||||
(ord: number | null) => {
|
||||
setSubOrd(ord);
|
||||
// Picking a track (the same one included) is the viewer asking for it NOW: a track given up on
|
||||
// after failed loads gets a fresh budget instead of repeating "couldn't be loaded" unasked.
|
||||
// after failed loads gets a fresh budget instead of repeating "couldn't be loaded" unasked —
|
||||
// and the server is asked again at once. Re-picking the highlighted track changes no query key,
|
||||
// so without the refetch that half of "now" did nothing: a poll backing off after a failed
|
||||
// request, or one waiting out an `unavailable`, kept waiting.
|
||||
setLoadFails({ key: "", n: 0 });
|
||||
if (ord != null && ord === subOrd)
|
||||
void qc.refetchQueries({ queryKey: qk.plexSubtitle(id, ord), exact: true });
|
||||
patchPrefs({
|
||||
subLang:
|
||||
ord == null ? "" : (detail?.subtitle_streams.find((s) => s.ord === ord)?.language ?? ""),
|
||||
});
|
||||
},
|
||||
[detail, patchPrefs, setSubOrd],
|
||||
[detail, patchPrefs, setSubOrd, subOrd, qc, id],
|
||||
);
|
||||
// 'A' cycles audio tracks; 'S' cycles subtitle (off → each text sub → off).
|
||||
const cycleAudio = useCallback(() => {
|
||||
@@ -543,10 +552,15 @@ export function usePlexTracks(opts: {
|
||||
// either, or one dropped request would leave "unavailable" on screen for a track that is fine.
|
||||
// So it backs off to SUB_POLL_ERROR_MS; the player shows the failure inline meanwhile, which is
|
||||
// also why the request is `quiet` (no global error dialog re-opening under the film).
|
||||
//
|
||||
// `unavailable` is not settled either — it is the server's "nothing for this position right now"
|
||||
// (a Plex hiccup, a stretch ffmpeg could not read), which it forgets within a minute. Treated as
|
||||
// final it ended the subtitles for the rest of the session, cached stretches included.
|
||||
refetchInterval: (q) => {
|
||||
if (q.state.status === "error") return SUB_POLL_ERROR_MS;
|
||||
const st = q.state.data?.state;
|
||||
if (st === "ready" || st === "failed") return false;
|
||||
if (st === "unavailable") return SUB_POLL_UNAVAILABLE_MS;
|
||||
return st === "partial" ? 2000 : 1000;
|
||||
},
|
||||
staleTime: 0,
|
||||
@@ -563,9 +577,14 @@ export function usePlexTracks(opts: {
|
||||
const subRetry = loadFails.key === loadKey ? loadFails.n : 0;
|
||||
const subGaveUp = subRetry > SUB_TRACK_RETRIES;
|
||||
// Mount the <track> for a `partial` track too: a window around the playhead is exactly what the
|
||||
// viewer needs right now, and the poll widens it underneath them.
|
||||
// viewer needs right now, and the poll widens it underneath them. And for an `unavailable` one that
|
||||
// has cues somewhere (`gen` > 0): nothing shows at a stretch with no cues, while a seek back into a
|
||||
// cached one shows them again — unmounting it blanked those too.
|
||||
const subShowable =
|
||||
(subStatus?.state === "ready" || subStatus?.state === "partial") && !subGaveUp;
|
||||
(subStatus?.state === "ready" ||
|
||||
subStatus?.state === "partial" ||
|
||||
(subStatus?.state === "unavailable" && subStatus.gen > 0)) &&
|
||||
!subGaveUp;
|
||||
// A failing poll is said even while an earlier window stays mounted: react-query keeps the last
|
||||
// good `data` through a failed refetch, so a `partial` answer kept the track "showable" and the
|
||||
// failure had nowhere to appear — blank subtitles past the window, no spinner, no message.
|
||||
@@ -574,7 +593,7 @@ export function usePlexTracks(opts: {
|
||||
? null
|
||||
: subStatusQ.isError || subGaveUp
|
||||
? "failed"
|
||||
: subStatus?.state === "failed"
|
||||
: subStatus?.state === "failed" || subStatus?.state === "unavailable"
|
||||
? subFailReason(subStatus.code)
|
||||
: subShowable
|
||||
? null
|
||||
|
||||
Reference in New Issue
Block a user