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:
+28
-12
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user