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:
2026-09-11 01:08:27 +02:00
parent cf7ec5e9ec
commit d2ba65e383
6 changed files with 179 additions and 21 deletions
+9 -6
View File
@@ -439,14 +439,17 @@ def _schedule(rating_key: str, sub_ord: int, at: float, want_window: bool) -> No
full pass that makes it complete. Each is deduplicated by identity, so a 1 s status poll (or a
second viewer) joins the running job instead of starting another ffmpeg.
A window's identity is its TRACK, never its position. The poll sends the live playhead, so a
position-keyed window minted a new identity every second and queued one more read of the file per
poll until the first window landed. One window per track is in flight at a time; a seek past it
is picked up by the next poll after it publishes. The full pass is per ITEM, because one read
fans out to every embedded text track of the file."""
A window's identity is its track plus WHICH STRETCH of the film it is for — never the exact
position. The poll sends the live playhead, so a position-keyed window minted a new identity every
second and queued one more read of the file per poll until the first window landed; bucketing the
start by the window's own length makes a playhead creeping along join the job already running. It
must not be the track alone either: then a window cut around the wrong spot (the first poll after
a resume can still report 0) made the right one wait for it. The full pass is per ITEM, because
one read fans out to every embedded text track of the file."""
jobs: list[tuple[tuple, float | None]] = []
if want_window:
jobs.append((("win", rating_key, sub_ord), max(0.0, at - WINDOW_PRE_S)))
w_start = max(0.0, at - WINDOW_PRE_S)
jobs.append((("win", rating_key, sub_ord, int(w_start // WINDOW_LEN_S)), w_start))
jobs.append((("full", rating_key), None))
for ident, window_start in jobs:
with _LOCK: