fix(plex): an HLS artifact names the session it belongs to, and a paused viewer says so

Deep-review round 5, over round 4's own fixes.

- Sessions are keyed by rating_key alone while a session's DIRECTORY is per (key, offset, tag), and
  the artifact URL carried no session identity — so once a seek-restart or a second viewer of the
  same item took the registry slot, the old viewer's request for `seg_12.ts` was answered 200 with
  the new session's twelfth segment: bytes from a different `-ss` offset under `-copyts`, on a
  timeline the client is not on. Wrong content with a success status is the one failure a player
  cannot notice, and round 4's reclaim-on-404 could never fire on it. The id is in the path now (so
  the playlist's relative names resolve back into the same session), a mismatch is a 404, and the
  player reclaims at the playhead. The entry playlist's auto-start is gone with it: it could only
  ever begin at 0, silently discarding the viewer's position.

- Round 4 exempted paused viewers from the reaper by keeping EVERY finished session for the full
  ten-minute idle window — one whole segmented copy of a film per item, on the volume the download
  center shares. The signal it needed already existed: an open player checkpoints its position every
  10 s whether or not it is playing, so that route now touches the session and the periodic pass is
  back on the short rule.

- POST /session's opportunistic reap was never scoped to the key being started: it walked the whole
  registry with the 30 s finished rule, so pressing play on ANY item — even a direct-play one, the
  call ran before that early return — killed another viewer's paused session. It is gone; the
  periodic pass owns reaping.

- A segment that can never arrive no longer polls out the full 20 s: the producer's handle is passed
  to `wait_for`, so a finished ffmpeg answers at once. This is a sync route, so each such wait parked
  a threadpool worker AND a pooled DB connection, and an ordinary seek past the produced region was
  enough to do it.

- The DataError handler's re-raise branch — the half that keeps a genuine DataError a 500 — is
  pinned, so simplifying it to an unconditional 422 cannot pass in silence.

Six regression tests (two route-level, in the DB lane), each mutation-checked red on the assertion
it is named after. pytest 1124, vitest 999.
This commit is contained in:
2026-09-12 03:57:52 +02:00
parent 8a83bc0e9f
commit 47af4d5c05
7 changed files with 348 additions and 51 deletions
+21
View File
@@ -51,3 +51,24 @@ def test_a_nul_in_a_request_string_is_a_422_not_a_500(db, make_user, as_user):
assert r.status_code == 422, r.text
assert r.json()["detail"] == {"code": "invalid_input", "reason": "nul"}
assert client.get("/api/plex/playlists?contains_group=1,%00").status_code == 422
def test_any_other_data_error_is_still_a_500():
"""The other half of the app-wide handler, and the half a refactor can delete in silence: it is
registered for EVERY DataError on every route and decides on a substring of the driver's message,
so only a NUL may become a 422. Without this, simplifying it to an unconditional 422 — it reads
like dead defensive code — would answer a numeric overflow or an over-long ingested title with
"your input was bad", losing the traceback that names the real fault (deep-review round 5)."""
import asyncio
import pytest
from sqlalchemy.exc import DataError
from app.main import _nul_in_text
overflow = DataError("SELECT 1", {}, Exception("numeric field overflow"))
with pytest.raises(DataError):
asyncio.run(_nul_in_text(None, overflow)) # re-raised: it stays the 500 it always was
nul = DataError("SELECT 1", {}, Exception("invalid byte sequence: NUL (0x00)"))
assert asyncio.run(_nul_in_text(None, nul)).status_code == 422