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 -19
View File
@@ -323,6 +323,10 @@ def item_progress(
`_should_push_resume`). `final` (sent on pause / seek / pagehide / unmount) pushes immediately and
resets the throttle window, so Plex sees a settled position the instant the user pauses or seeks."""
it = item_or_404(db, rating_key)
# An open player checkpoints every 10 s whether or not it is playing, so this is the signal that a
# PAUSED viewer still wants their HLS session — the one thing `last_access` could not see, since
# it moved only on a file fetch and a paused player fetches nothing (see stream.reap_idle).
plex_stream.touch_session(str(rating_key))
position = max(0, int(body.position_seconds))
duration = int(body.duration_seconds)
final = body.final
@@ -561,7 +565,6 @@ def stream_session(
HEVC/VP9/AV1/…) take the same HLS path but with the video re-encoded to H.264 (VAAPI or libx264).
Selecting a non-default audio track (`audio` = stream ordinal) forces the HLS path so the chosen audio
can be muxed in. Subtitles are independent WebVTT tracks (GET /subtitle) — no session restart."""
plex_stream.reap_idle() # opportunistic cleanup of idle sessions on each new playback
it = item_or_404(db, rating_key)
transcode = it.playable == "transcode"
# `multi` (item has ≥2 audio tracks) forces the HLS path so ALL audio tracks ship as renditions and
@@ -585,7 +588,9 @@ def stream_session(
return {
"mode": "hls",
"start_s": s.media_start_s,
"url": f"/api/plex/stream/{it.rating_key}/hls/{s.entry}",
# The session id is part of the path (not a query param) so the playlist's RELATIVE segment
# names resolve back into the same session — see `stream_hls_file` for why it has to.
"url": f"/api/plex/stream/{it.rating_key}/hls/{s.id}/{s.entry}",
"transcode": s.transcode,
"hw": s.hw,
"tonemap": s.tonemap,
@@ -624,35 +629,39 @@ _HLS_CT = {
}
@router.get("/stream/{rating_key}/hls/{filename}")
@router.get("/stream/{rating_key}/hls/{session_id}/{filename}")
def stream_hls_file(
rating_key: str,
session_id: str,
filename: str,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> Response:
"""Serve a file (playlist / segment / subtitle) from the current HLS session directory. The
playlist references its segments/subtitles by relative name, all resolving back here."""
"""Serve a file (playlist / segment / subtitle) from ONE HLS session's directory. The playlist
references its segments/subtitles by relative name, all resolving back here.
The session id in the path is what makes "back here" mean the same session. Sessions are keyed by
rating_key alone and a seek-restart or a second viewer of the same item replaces the registered
one, while the DIRECTORY is per (key, offset, tag) — so resolving by rating_key handed the old
viewer the new session's `seg_12.ts`, produced from a different offset under `-copyts`: wrong
bytes with a 200, which the player has no way to notice (deep-review round 5). A request for a
session that is no longer the registered one is a 404, and the player reclaims at the playhead.
That reclaim is also why a request with no live session is no longer auto-started here: the
auto-start could only ever begin at 0, silently throwing away the viewer's position."""
# Filename must be a plain hls artifact — no path separators, only the expected extensions.
ext = "." + filename.rsplit(".", 1)[-1] if "." in filename else ""
if ext not in _HLS_CT or "/" in filename or "\\" in filename or ".." in filename:
raise HTTPException(status_code=404, detail="Not found")
s = plex_stream.current_session(str(rating_key))
if s is None:
# No session yet (e.g. reaped): the ENTRY playlist may auto-start one at 0 — master.m3u8 for a
# multi-audio item, index.m3u8 for a single-audio remux item.
it = item_or_404(db, rating_key)
if filename == "master.m3u8":
s = plex_stream.start_session(db, it, 0.0, multi=True)
elif filename == "index.m3u8" and it.playable == "remux":
s = plex_stream.start_session(db, it, 0.0)
else:
raise HTTPException(status_code=404, detail="No active playback session")
if s is None:
raise HTTPException(status_code=404, detail="Media file not found on disk")
if s is None or s.id != session_id:
raise HTTPException(status_code=404, detail="No active playback session")
path = s.dir / filename
timeout = 30 if ext == ".m3u8" else 20
if not plex_stream.wait_for(path, timeout):
# `proc` so a session whose ffmpeg has already exited answers at once instead of polling out the
# whole timeout: this is a SYNC route on the ~40-slot threadpool, holding a pooled DB connection
# with it, so every such wait is a worker (and a connection) parked for 20 s — an ordinary seek
# past the produced region was enough to do it (round 5).
if not plex_stream.wait_for(path, timeout, proc=s.proc):
raise HTTPException(status_code=404, detail="Not available yet")
return Response(
content=path.read_bytes(),