fix(plex): subtitles — a retried full pass resumes, a stalled track is polled slowly

A full pass that ran out of time kept what it reached, but its retry an hour
later started again at 0, so a file too big to read inside the extraction
timeout never completed. The pass now starts where every embedded track's
coverage from the head ends (less the window pre-roll, so a cue across the
join is read whole). The seek needs -avoid_negative_ts on this read as on a
window, measured on a real file: without it the cue running at the start
shifts the whole output.

A partial track whose full pass had failed never reached a settled state, so
the player polled it every 2 s for the rest of the film. The status now says
whether the track is still growing, and a stalled one is polled every 15 s —
still inside the 60 s lookahead that reads the next window before an edge.
This commit is contained in:
2026-09-19 07:07:53 +02:00
parent e6b76e156a
commit c60359bfed
5 changed files with 126 additions and 5 deletions
+2
View File
@@ -678,6 +678,8 @@ export const plexApi = {
state: "ready" | "partial" | "preparing" | "unavailable" | "failed";
code: string | null;
gen: number;
/** On `partial`/`preparing`: whether the track still fills in on its own. */
growing?: boolean;
}> =>
// `quiet`: the player reports a failed poll inline, so no global error dialog on top of it.
req(
@@ -208,6 +208,22 @@ describe("usePlexTracks — when the status poll asks again, and when it stops",
expect(status).toHaveBeenCalledTimes(1);
});
it("slows down on a partial track that will not grow on its own, and keeps asking", async () => {
// The full pass failed and is backed off for an hour: `partial` never turns `ready` by itself,
// and the 2 s cadence ran for the rest of the film. It still carries the position — reading
// ahead of an edge depends on it — so it slows rather than stops.
const status = vi
.spyOn(api, "plexSubtitleStatus")
.mockResolvedValue({ state: "partial", code: null, gen: 4, growing: false });
const { result } = mount(client());
result.current.setSubOrd(0);
await waitFor(() => expect(status).toHaveBeenCalledTimes(1));
await vi.advanceTimersByTimeAsync(10_000);
expect(status).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(6000);
expect(status).toHaveBeenCalledTimes(2);
});
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.
+7 -1
View File
@@ -484,6 +484,11 @@ const SUB_POLL_ERROR_MS = 10_000;
// 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;
// …and on a `partial` track that is no longer `growing` (its full pass failed and is backed off for
// an hour): it will not fill in on its own, so the 2 s cadence ran for the rest of the film. The poll
// still carries the position — it is what reads the next window ahead of an edge, which the server
// starts within LOOKAHEAD_S (60 s) of it — so it slows down rather than stops.
const SUB_POLL_STALLED_MS = 15_000;
const SUB_TRACK_RETRIES = 2;
const SUB_TRACK_RETRY_MS = 2_000;
@@ -684,7 +689,8 @@ export function usePlexTracks(opts: {
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;
if (st === "partial") return q.state.data?.growing === false ? SUB_POLL_STALLED_MS : 2000;
return 1000;
},
staleTime: 0,
retry: false,