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:
@@ -0,0 +1,145 @@
|
||||
"""An HLS artifact request names the session it belongs to, and gets nothing else.
|
||||
|
||||
Sessions are keyed by rating_key ALONE, while a session's directory is per (key, offset, tag). So a
|
||||
seek-restart — or a second viewer of the same item — replaces the registered session, 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: bytes produced from a different `-ss` offset under `-copyts`, i.e. a
|
||||
timeline the client is not on. Wrong content with a success status is the one failure a player cannot
|
||||
notice, and the reclaim-on-404 added in round 4 could never fire on it (deep-review round 5).
|
||||
|
||||
DB lane (`db` fixture) — skipped when no Postgres is reachable.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.state as state
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import PlexItem, PlexLibrary, User
|
||||
from app.plex import stream as plex_stream
|
||||
|
||||
|
||||
class _Proc:
|
||||
def poll(self):
|
||||
return 0 # finished: `wait_for` may not poll out its whole timeout on a missing file
|
||||
|
||||
def terminate(self):
|
||||
pass
|
||||
|
||||
def wait(self, timeout=None):
|
||||
return 0
|
||||
|
||||
|
||||
class _Session:
|
||||
def __init__(self, session_id: str, directory):
|
||||
self.id = session_id
|
||||
self.dir = directory
|
||||
self.proc = _Proc()
|
||||
self.last_access = time.time()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sessions(monkeypatch):
|
||||
live: dict = {}
|
||||
monkeypatch.setattr(plex_stream, "_sessions", live)
|
||||
return live
|
||||
|
||||
|
||||
def _client(db, monkeypatch):
|
||||
monkeypatch.setattr(state, "_configured_cache", True)
|
||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
db.add(
|
||||
PlexItem(
|
||||
rating_key="m1",
|
||||
library_id=lib.id,
|
||||
kind="movie",
|
||||
title="A Movie",
|
||||
file_path="/media/a.mkv",
|
||||
playable="remux",
|
||||
)
|
||||
)
|
||||
user = User(email="hls@example.com")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[current_user] = lambda: user
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_a_segment_is_served_from_the_session_the_url_names(db, monkeypatch, tmp_path, sessions):
|
||||
d = tmp_path / "m1_0_0_+0.00"
|
||||
d.mkdir()
|
||||
(d / "seg_12.ts").write_bytes(b"mine")
|
||||
sessions["m1"] = _Session("sid-a", d)
|
||||
client = _client(db, monkeypatch)
|
||||
try:
|
||||
r = client.get("/api/plex/stream/m1/hls/sid-a/seg_12.ts")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.content == b"mine"
|
||||
|
||||
|
||||
def test_a_segment_of_a_replaced_session_is_a_404_not_the_new_sessions_bytes(
|
||||
db, monkeypatch, tmp_path, sessions
|
||||
):
|
||||
"""Viewer A is at 0:00; viewer B seeks to 25:00 and their session takes the registry slot. A's
|
||||
next segment must fail honestly — the player reclaims at the playhead — not arrive as B's."""
|
||||
theirs = tmp_path / "m1_1500_0_+0.00"
|
||||
theirs.mkdir()
|
||||
(theirs / "seg_12.ts").write_bytes(b"theirs")
|
||||
sessions["m1"] = _Session("sid-b", theirs)
|
||||
client = _client(db, monkeypatch)
|
||||
try:
|
||||
r = client.get("/api/plex/stream/m1/hls/sid-a/seg_12.ts")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
assert r.status_code == 404, r.text
|
||||
assert b"theirs" not in r.content
|
||||
|
||||
|
||||
def test_a_playlist_with_no_live_session_is_a_404_not_a_silent_restart_at_zero(
|
||||
db, monkeypatch, sessions
|
||||
):
|
||||
"""The entry playlist used to auto-start a session when none was registered — always at 0.0,
|
||||
silently throwing away the viewer's position. A 404 sends the player through its own reclaim,
|
||||
which restarts at the playhead."""
|
||||
started: list[float] = []
|
||||
monkeypatch.setattr(
|
||||
plex_stream,
|
||||
"start_session",
|
||||
lambda *a, **k: started.append(a[2] if len(a) > 2 else 0.0),
|
||||
)
|
||||
client = _client(db, monkeypatch)
|
||||
try:
|
||||
r = client.get("/api/plex/stream/m1/hls/sid-gone/index.m3u8")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
assert r.status_code == 404, r.text
|
||||
assert started == []
|
||||
|
||||
|
||||
def test_a_segment_that_will_never_arrive_fails_at_once(db, monkeypatch, tmp_path, sessions):
|
||||
"""`stream_hls_file` is a SYNC route: it runs on the ~40-slot threadpool and holds a pooled DB
|
||||
connection while it waits. Waiting without the producer's handle polled out the whole 20 s even
|
||||
when that ffmpeg had already exited and no segment could ever appear — one ordinary seek past the
|
||||
produced region parked a worker and a connection for 20 s (deep-review round 5)."""
|
||||
d = tmp_path / "m1_0_0_+0.00"
|
||||
d.mkdir()
|
||||
sessions["m1"] = _Session("sid-a", d) # its _Proc.poll() reports a finished ffmpeg
|
||||
client = _client(db, monkeypatch)
|
||||
started = time.monotonic()
|
||||
try:
|
||||
r = client.get("/api/plex/stream/m1/hls/sid-a/seg_99999.ts")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
elapsed = time.monotonic() - started
|
||||
assert r.status_code == 404, r.text
|
||||
# The wait is 20 s; anything in that neighbourhood means the producer's exit was not consulted.
|
||||
assert elapsed < 2.0, f"waited {elapsed:.1f}s for a segment that can never arrive"
|
||||
Reference in New Issue
Block a user