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
@@ -395,6 +395,17 @@ def test_polls_that_follow_the_playhead_join_one_window(monkeypatch):
assert len(pool.jobs) == 2, "one window + the one full pass"
def test_a_window_for_a_different_stretch_does_not_wait_behind_the_first(monkeypatch):
"""Measured on localdev: after a resume the first poll can still report 0 (the position is only
known once playback ticks), so the first window is cut around the wrong spot. The poll that
follows, at the real position, must get its own window at once — not queue behind that one."""
pool = _ManualPool()
monkeypatch.setattr(subtitles, "_POOL", pool)
subtitles.ensure("98614", 1, at=0.0)
subtitles.ensure("98614", 1, at=1198.0)
assert sorted(a[3] for _, a in pool.jobs if a[3] is not None) == [0.0, 1168.0]
def test_a_queued_window_that_was_covered_meanwhile_reads_nothing(monkeypatch):
"""Every embedded track rides each pass over its file, so by the time a queued window for one
language runs, another language's window may already have published it."""