fix(api): a paused viewer keeps its HLS session, a refused subtitle publish backs off, and a NUL is a 422 everywhere

Deep-review round 4, backend half.

- The periodic HLS reaper (round 3) applied the "finished and unfetched for 30 s" rule that only a
  NEW session request may apply. A remux finishes long before the film, and a paused player with a
  full buffer fetches nothing, so a one-minute pause killed the session and playback died ~30 s after
  resuming. The periodic pass is `reap_abandoned` now (the long idle limit, finished or not).
- A subtitle `publish` that could not keep its output (full / read-only cache volume) returned
  quietly, so every poll queued the window and the whole-container pass again. It reports failure
  and the pass is backed off like a failed read; a cached VTT that cannot be READ is answered
  `unavailable` (`cache_unreadable`) instead of scheduling reads that can never land.
- The subtitle routes bound `rating_key` / `sub_ord` (they become a cache file name: a 250-digit
  one was ENAMETOOLONG, which pathlib's exists() re-raises -> a 500), and the cache helpers no longer
  throw on such a name.
- One app-wide handler turns psycopg's "text fields cannot contain NUL" DataError into a 422: round 3
  guarded only the by-rating_key lookup, and `contains=`, `contains_group=`, reorder/bulk key lists
  and the collection search still 500'd.
- The hand-written `db.get(...) is None -> 404 "Unknown ..."` siblings use `get_or_404` with a
  `what` (channel / video / user), so the Hungarian UI no longer shows English for a gone row.
This commit is contained in:
2026-09-11 23:08:11 +02:00
parent 86971c574d
commit 31dc4184f5
14 changed files with 278 additions and 36 deletions
@@ -1002,3 +1002,75 @@ def test_a_nul_rating_key_names_no_row():
from app.plex.lookups import by_rating_key
assert by_rating_key(None, PlexItem, "12\x0034") is None # type: ignore[arg-type]
# --- the fourth deep-review round ---------------------------------------------------------------
def test_a_pass_whose_output_could_not_be_kept_is_backed_off_like_a_failed_one(monkeypatch):
"""`publish` refusing (a full or read-only cache volume) returned quietly, so `_run` recorded
nothing and every 1 s poll queued the window AND the whole-container pass again — the file read
end to end over and over, the player on "preparing" for good."""
_stub_item(monkeypatch)
pool = _manual_pool(monkeypatch)
monkeypatch.setattr(subtitles, "cache_write", lambda key, vtt: False)
assert subtitles.ensure("98614", 3, at=600.0)["state"] == "preparing"
assert [a[3] for _, a in pool.jobs] == [570.0, None]
pool.run_all()
after = subtitles.ensure("98614", 3, at=600.0)
assert (after["state"], after["code"]) == ("unavailable", "extract_failed")
for _ in range(3):
subtitles.ensure("98614", 3, at=600.0)
assert pool.jobs == [], "the same reads were queued again"
def test_a_sidecar_that_could_not_be_kept_is_recorded_as_failed(monkeypatch):
_stub_item(monkeypatch)
pool = _manual_pool(monkeypatch)
monkeypatch.setattr(subtitles, "cache_write", lambda key, vtt: False)
subtitles.ensure("98614", 0, at=600.0)
pool.run_all()
subtitles.ensure("98614", 0, at=600.0)
assert pool.jobs == [], "a refused sidecar publish was queued again"
def test_an_unreadable_cached_track_is_said_once_and_not_rescheduled(monkeypatch):
"""A VTT on disk that cannot be read: `publish` will not merge into it and a job whose sidecar
covers the position skips as done, so scheduling could only ever leave the player on
"preparing"."""
pool = _manual_pool(monkeypatch)
key = subtitles.cache_key("98614", 0)
subtitles.publish(key, _vtt(_cue("16:20.000", "16:22.000", "x")), (970.0, 1300.0), False)
monkeypatch.setattr(subtitles, "cache_read", lambda k: None) # an I/O error on the read
st = subtitles.ensure("98614", 0, at=1000.0)
assert (st["state"], st["code"], st["gen"]) == ("unavailable", "cache_unreadable", 0)
assert pool.jobs == []
def test_an_absurd_rating_key_or_ordinal_is_refused_not_a_500(client, monkeypatch):
"""They become a cache FILE name: a 250-digit key outgrew the OS name limit, and pathlib's
`exists()` re-raises that (ENAMETOOLONG) instead of answering False — a 500 on every poll."""
pool = _manual_pool(monkeypatch)
long_key = "9" * 250
for url in (
f"/api/plex/subtitle/{long_key}/0/status",
f"/api/plex/subtitle/98614/{long_key}/status",
f"/api/plex/subtitle/{long_key}/0",
):
assert client.get(url).status_code == 422, url
assert pool.jobs == []
# …and the cache helpers themselves no longer throw on such a name.
assert subtitles.read_meta(subtitles.cache_key(long_key, 0)) == ([], False)
def test_a_read_ahead_window_that_failed_is_not_queued_again(monkeypatch):
"""Near a covered edge the probe moves just past it, and `_schedule` has no failed-span check of
its own: without the guard, every poll re-queued the same failing window read."""
pool = _manual_pool(monkeypatch)
subtitles._FULL_FAILED["98614"] = time.monotonic()
key = subtitles.cache_key("98614", 0)
subtitles.publish(key, _vtt(_cue("16:20.000", "16:22.000", "x")), (970.0, 1300.0), False)
subtitles._window_fail(key, (1271.0, 1601.0))
for at in (1250.0, 1252.0, 1254.0):
assert subtitles.ensure("98614", 0, at=at)["state"] == "partial"
assert pool.jobs == [], "the failed read-ahead window was queued again"