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
+28 -12
View File
@@ -21,6 +21,7 @@ CPU-only prod host; only P3's full transcode is CPU-heavy.
import logging
import os
import re
import secrets
import shutil
import subprocess
import threading
@@ -99,6 +100,14 @@ class HlsSession:
hw: bool = False,
tonemap: bool = False,
):
# Unguessable per-session id, carried in the URLs the client is handed. Sessions are keyed
# by rating_key alone, so a seek-restart or a SECOND viewer of the same item replaces the
# registered one — and the old viewer's next request for `seg_12.ts`, resolved by rating_key,
# was answered 200 with the NEW session's twelfth segment, produced from a different offset
# under `-copyts`. Wrong bytes, not an error, so the client's reclaim never fired on it
# (deep-review round 5). A request naming a session that is no longer registered is a 404,
# which the player recovers from by restarting at the playhead.
self.id = secrets.token_urlsafe(9)
self.key = key
self.dir = directory
self.proc = proc
@@ -826,16 +835,19 @@ _FINISHED_IDLE_S = 30
def reap_idle(finished_idle_s: float = _FINISHED_IDLE_S) -> int:
"""Kill sessions nobody has fetched from for `_SESSION_IDLE_S` — or, once their ffmpeg has
finished, for `finished_idle_s`.
"""Kill sessions nobody has said they still want for `_SESSION_IDLE_S` — or, once their ffmpeg
has finished, for `finished_idle_s`.
The short rule is only safe where a NEW session is being requested (the default, POST /session):
a finished session is also what a PAUSED viewer has. A remux runs far faster than realtime, so
A finished session is also what a PAUSED viewer has: a remux runs far faster than realtime, so
ffmpeg is done a minute or two in and writes ENDLIST; hls.js then stops asking for the playlist,
and with its forward buffer full a paused player fetches nothing at all. The periodic reaper
therefore uses `reap_abandoned` — with the 30 s rule, a one-minute pause killed the session and
playback died ~30 s after resuming (deep-review round 4). A viewer paused for longer than
`_SESSION_IDLE_S` still loses it; the player restarts one at the playhead when a fetch 404s."""
and with its forward buffer full a paused player fetches nothing at all. With the 30 s rule that
killed a one-minute pause, and playback died ~30 s after resuming (deep-review round 4).
What makes the short rule safe again is that fetching is no longer the only signal: an open
player checkpoints its position every 10 s, and that route calls `touch_session`, so a viewer who
is merely paused says so far more often than this rule asks (round 5). Round 4's blanket
`reap_abandoned` bought the same safety by keeping every finished session for ten minutes — each
one a whole segmented copy of the film on a volume shared with the download center."""
now = time.time()
dropped = 0
with _lock:
@@ -849,7 +861,11 @@ def reap_idle(finished_idle_s: float = _FINISHED_IDLE_S) -> int:
return dropped
def reap_abandoned() -> int:
"""The periodic reaper's pass: only what nobody has fetched from for `_SESSION_IDLE_S`, finished
or not (see reap_idle for why a finished session is not yet an abandoned one)."""
return reap_idle(finished_idle_s=_SESSION_IDLE_S)
def touch_session(key: str) -> None:
"""Say a viewer is still there for `key`, without fetching anything. `last_access` used to move
only on an HLS file fetch, which a paused player does not do — so the only way to keep a paused
viewer's session alive was to keep EVERY abandoned one just as long."""
with _lock:
s = _sessions.get(key)
if s is not None:
s.last_access = time.time()