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:
@@ -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