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.
146 lines
5.1 KiB
Python
146 lines
5.1 KiB
Python
"""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"
|