fix(plex): the subtitle poll backs off on errors, a failed <track> is replaced, and a resume asks for the right window
Deep-review R2, plus one defect found while verifying it on localdev. - The status poll re-armed every second after a FAILED request: `data` stays empty on an error, so it read as "still preparing" for as long as the player was open — and the request was not quiet, so a persistent 500 re-opened the global error dialog every second. It now backs off to 10 s (a blip still heals on its own) and is quiet; the player already shows the failure inline. - A <track> whose own VTT fetch failed never fetched again: with the server's answer unchanged its key was unchanged, so React kept the dead element — silently blank subtitles. The key (now built in the hook, where it is tested) carries a failed-load count; after two remounts the track is reported as unavailable instead. - Found in UAT: until the first timeupdate `abs` is 0, so after a resume every poll said at=0 and the first window was cut around the head of the film (a resume at 20:00 polled at=0 for as long as it stayed paused). The poll reports the session's start until playback ticks. And the window's dedup identity is the track plus a window-length bucket of its start — keyed on the track alone (R1), the right window queued behind that wrong one. The poll's start/stop rules finally have tests (every earlier one resolved a single fixed answer). Verified on localdev against Westworld S1E6 resumed at 20:00: the polls carried at=1197, one subtitle line on screen at 21:02, the poll stopped at `ready`; the real file's window (56 cues) + full pass (608) merged to 608 — the old merge would have served 664. 11 mutations, all red.
This commit is contained in:
@@ -349,6 +349,11 @@ export function useAutoSkip(opts: {
|
||||
* fold happens here rather than multiplying near-identical translations. */
|
||||
export type SubFailReason = "bitmap" | "missing" | "failed";
|
||||
|
||||
// The subtitle status poll's cadence after a FAILED request (see usePlexTracks), and how many times a
|
||||
// <track> whose own VTT fetch failed is remounted before the track is reported as unavailable.
|
||||
const SUB_POLL_ERROR_MS = 10_000;
|
||||
const SUB_TRACK_RETRIES = 2;
|
||||
|
||||
function subFailReason(code: string | null | undefined): SubFailReason {
|
||||
if (code === "bitmap") return "bitmap";
|
||||
if (code === "no_item" || code === "no_track" || code === "no_file" || code === "not_fetchable")
|
||||
@@ -394,6 +399,11 @@ export function usePlexTracks(opts: {
|
||||
/** The cached VTT's generation — it grows as the background extraction widens the track, so it
|
||||
* belongs in the <track> key/URL or the browser keeps serving the first, short window. */
|
||||
subGen: number;
|
||||
/** The <track>'s React key. Everything that must produce a FRESH element — and so a fresh VTT
|
||||
* fetch — is in it: the item and track, the shifted offset (a seek-restart or a debounced offset
|
||||
* change), the extraction generation (a widened track replaces the first short window), and the
|
||||
* failed-load count (a <track> whose fetch failed is replaced rather than left dead). */
|
||||
subTrackKey: string;
|
||||
onSubTrackError: () => void;
|
||||
} {
|
||||
const { id, detail, sessionK, subOffset, subPos, loadSession, patchPrefs } = opts;
|
||||
@@ -486,14 +496,28 @@ export function usePlexTracks(opts: {
|
||||
// around the playhead first, and this poll is what tells us when there is something to mount.
|
||||
const subStatusQ = useQuery({
|
||||
queryKey: qk.plexSubtitle(id, subOrd ?? -1),
|
||||
queryFn: () => api.plexSubtitleStatus(id, subOrd as number, absRef.current ?? 0),
|
||||
// The position is only known once playback has ticked: until the first timeupdate `abs` is 0
|
||||
// even though a resumed session starts minutes in — and every poll said `at=0`, so the first
|
||||
// window was cut around the head of the film instead of where the viewer is (measured on
|
||||
// localdev: a resume at 20:00 polled `at=0` for as long as it stayed paused). The session's own
|
||||
// start is the best answer until then.
|
||||
queryFn: () =>
|
||||
api.plexSubtitleStatus(id, subOrd as number, absRef.current > 0 ? absRef.current : sessionK),
|
||||
enabled: subOrd != null,
|
||||
// Keep polling until the track is COMPLETE, not just showable: each poll carries the current
|
||||
// position, so seeking past the extracted window is what asks for the next one. A settled
|
||||
// answer — the whole track, or a reason it can never be shown — stops the poll; without that
|
||||
// stop, a bitmap track would poll for the length of the film AND keep re-arming the extraction
|
||||
// every time the server's short failure memo expired.
|
||||
//
|
||||
// A FAILED request is neither: it says nothing about the track. It must not keep the 1 s cadence
|
||||
// (it used to — `data` stays empty on an error, so every failure read as "still preparing" and
|
||||
// re-armed a second later, for as long as the player was open), but it must not end the poll
|
||||
// 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).
|
||||
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;
|
||||
return st === "partial" ? 2000 : 1000;
|
||||
@@ -502,20 +526,32 @@ export function usePlexTracks(opts: {
|
||||
retry: false,
|
||||
});
|
||||
const subStatus = subOrd == null ? null : (subStatusQ.data ?? null);
|
||||
// A <track> whose own VTT fetch failed never fetches again, and re-asking the status cannot fix
|
||||
// that on its own: while the answer (and its `gen`) is unchanged the key is unchanged, so React
|
||||
// keeps the dead element and the subtitles stay silently blank. So a failed load bumps a counter
|
||||
// that is part of the key — a fresh element, a fresh fetch — and after SUB_TRACK_RETRIES the track
|
||||
// is reported as unavailable instead of left blank. The count belongs to one track generation: a
|
||||
// different track, item or `gen` starts from zero.
|
||||
const loadKey = `${id}:${subOrd}:${subStatus?.gen ?? 0}`;
|
||||
const [loadFails, setLoadFails] = useState({ key: "", n: 0 });
|
||||
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.
|
||||
const subShowable = subStatus?.state === "ready" || subStatus?.state === "partial";
|
||||
const subShowable =
|
||||
(subStatus?.state === "ready" || subStatus?.state === "partial") && !subGaveUp;
|
||||
const subFail: SubFailReason | null =
|
||||
subStatusQ.isError && subOrd != null
|
||||
(subStatusQ.isError || subGaveUp) && subOrd != null
|
||||
? "failed"
|
||||
: subStatus?.state === "failed"
|
||||
? subFailReason(subStatus.code)
|
||||
: null;
|
||||
// A <track> that errors anyway (the cached VTT was evicted between the poll and the fetch) goes
|
||||
// back to polling rather than staying silently blank.
|
||||
const onSubTrackError = useCallback(() => {
|
||||
setLoadFails((f) => ({ key: loadKey, n: (f.key === loadKey ? f.n : 0) + 1 }));
|
||||
// …and ask again: if the cached VTT was evicted between the poll and the fetch, the answer drops
|
||||
// back to `preparing` and the poll takes over until it has been re-extracted.
|
||||
if (subOrd != null) qc.invalidateQueries({ queryKey: qk.plexSubtitle(id, subOrd) });
|
||||
}, [qc, id, subOrd]);
|
||||
}, [qc, id, subOrd, loadKey]);
|
||||
|
||||
// Text subtitles offered in the picker; image subs (PGS/VobSub) can't be shown as text.
|
||||
const textSubs = (detail?.subtitle_streams ?? []).filter((s) => s.text);
|
||||
@@ -586,6 +622,7 @@ export function usePlexTracks(opts: {
|
||||
subShowable,
|
||||
subFail,
|
||||
subGen: subStatus?.gen ?? 0,
|
||||
subTrackKey: `${id}-${subOrd}-${subTrackOffset.toFixed(2)}-${subStatus?.gen ?? 0}-${subRetry}`,
|
||||
onSubTrackError,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user