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:
@@ -1,21 +1,35 @@
|
||||
"""Idle HLS sessions are reaped on a timer, not only when the next session is requested.
|
||||
"""Idle HLS sessions are reaped on a timer, and a viewer who is merely PAUSED keeps theirs.
|
||||
|
||||
plex/stream.py promises "a periodic reaper kills idle ones", but `reap_idle` ran only inside
|
||||
POST /session: after the last playback, an abandoned session (its ffmpeg still working through the
|
||||
film, its directory a whole segmented copy of it) lived until somebody pressed play again.
|
||||
film, its directory a whole segmented copy of it) lived until somebody pressed play again
|
||||
(deep-review round 4).
|
||||
|
||||
And it must not take a PAUSED viewer's session: a remux finishes long before the film does, and a
|
||||
paused player with a full buffer fetches nothing — so "finished and unfetched for 30 s" (the rule a
|
||||
new session request applies) is not "abandoned".
|
||||
paused player with a full buffer fetches nothing, so "finished and unfetched for 30 s" does not mean
|
||||
"abandoned". Round 4 bought that safety by keeping EVERY finished session for the full ten-minute
|
||||
idle window — one segmented copy of a film per item, on the volume the download center shares. Round
|
||||
5 replaced the blanket retention with the signal that already existed: an open player checkpoints its
|
||||
position every 10 s whether or not it is playing, and that route says "still here".
|
||||
|
||||
Both call sites are pinned here, because the bug each time was that only one of them moved: the
|
||||
periodic pass, and the fact that starting a session no longer sweeps OTHER viewers' sessions at all.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.main as app_main
|
||||
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 paths as plex_paths
|
||||
from app.plex import stream
|
||||
|
||||
|
||||
@@ -27,7 +41,7 @@ class _Configured:
|
||||
return False
|
||||
|
||||
|
||||
def test_idle_sessions_are_reaped_while_nobody_starts_a_new_one(monkeypatch):
|
||||
def test_the_periodic_pass_is_the_one_that_runs_and_it_uses_the_short_rule(monkeypatch):
|
||||
async def _no_sweep():
|
||||
return None
|
||||
|
||||
@@ -39,7 +53,9 @@ def test_idle_sessions_are_reaped_while_nobody_starts_a_new_one(monkeypatch):
|
||||
monkeypatch.setattr(app_main, "shutdown_scheduler", lambda: None)
|
||||
monkeypatch.setattr(app_main.plex_subtitles, "shutdown", lambda: None)
|
||||
monkeypatch.setattr(app_main, "_HLS_REAP_EVERY_S", 0.01)
|
||||
monkeypatch.setattr(app_main.plex_stream, "reap_abandoned", lambda: reaped.append(1) or 0)
|
||||
# `reap_idle`, i.e. the SHORT finished rule: a finished session nobody has said they still want
|
||||
# is a whole copy of a film sitting on the download volume.
|
||||
monkeypatch.setattr(app_main.plex_stream, "reap_idle", lambda: reaped.append(1) or 0)
|
||||
|
||||
async def _cycle():
|
||||
async with app_main.lifespan(app):
|
||||
@@ -79,12 +95,10 @@ def sessions(monkeypatch):
|
||||
return live
|
||||
|
||||
|
||||
def test_the_periodic_pass_keeps_a_paused_viewers_finished_session(tmp_path, sessions):
|
||||
# A remux that finished a minute ago, its viewer paused with a full buffer since.
|
||||
s = _Session(tmp_path, "paused", finished=True, idle_s=90)
|
||||
sessions["12345"] = s
|
||||
assert stream.reap_abandoned() == 0
|
||||
assert "12345" in sessions and s.dir.exists() and not s.proc.killed
|
||||
def test_the_periodic_pass_reaps_a_finished_session_nobody_has_claimed(tmp_path, sessions):
|
||||
sessions["12345"] = _Session(tmp_path, "done", finished=True, idle_s=45)
|
||||
assert stream.reap_idle() == 1
|
||||
assert sessions == {}
|
||||
|
||||
|
||||
def test_the_periodic_pass_still_reaps_what_nobody_has_touched_for_the_idle_limit(
|
||||
@@ -96,11 +110,95 @@ def test_the_periodic_pass_still_reaps_what_nobody_has_touched_for_the_idle_limi
|
||||
sessions["2"] = _Session(
|
||||
tmp_path, "gone_live", finished=False, idle_s=stream._SESSION_IDLE_S + 5
|
||||
)
|
||||
assert stream.reap_abandoned() == 2
|
||||
assert stream.reap_idle() == 2
|
||||
assert sessions == {}
|
||||
|
||||
|
||||
def test_a_new_session_request_still_reaps_finished_sessions_quickly(tmp_path, sessions):
|
||||
sessions["12345"] = _Session(tmp_path, "done", finished=True, idle_s=45)
|
||||
assert stream.reap_idle() == 1
|
||||
assert sessions == {}
|
||||
def test_touching_a_session_keeps_it_through_the_short_rule(tmp_path, sessions):
|
||||
s = _Session(tmp_path, "paused", finished=True, idle_s=90)
|
||||
sessions["12345"] = s
|
||||
stream.touch_session("12345")
|
||||
assert stream.reap_idle() == 0
|
||||
assert "12345" in sessions and s.dir.exists() and not s.proc.killed
|
||||
|
||||
|
||||
# --- the two call sites, driven through the real routes (DB lane) -------------------------------
|
||||
|
||||
|
||||
def _library_item(db, rating_key: str, *, playable: str = "remux"):
|
||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
it = PlexItem(
|
||||
rating_key=rating_key,
|
||||
library_id=lib.id,
|
||||
kind="movie",
|
||||
title="A Movie",
|
||||
file_path="/media/a.mkv",
|
||||
playable=playable,
|
||||
)
|
||||
db.add(it)
|
||||
db.commit()
|
||||
return it
|
||||
|
||||
|
||||
def _client(db, user):
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[current_user] = lambda: user
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_a_paused_viewers_checkpoint_keeps_their_session_alive(db, monkeypatch, tmp_path, sessions):
|
||||
"""The whole reason the short rule is safe again: a paused player fetches nothing, so
|
||||
`last_access` never moved on its own — but it does keep checkpointing its position."""
|
||||
monkeypatch.setattr(state, "_configured_cache", True)
|
||||
it = _library_item(db, "paused1")
|
||||
user = User(email="paused@example.com")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
s = _Session(tmp_path, "paused", finished=True, idle_s=90)
|
||||
sessions["paused1"] = s
|
||||
try:
|
||||
resp = _client(db, user).post(
|
||||
f"/api/plex/item/{it.rating_key}/progress",
|
||||
json={"position_seconds": 600, "duration_seconds": 3000, "final": False},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert stream.reap_idle() == 0, "the checkpoint did not say the viewer is still there"
|
||||
assert "paused1" in sessions and not s.proc.killed
|
||||
|
||||
|
||||
def test_starting_a_session_leaves_another_viewers_paused_one_alone(
|
||||
db, monkeypatch, tmp_path, sessions
|
||||
):
|
||||
"""The opportunistic sweep this route used to run was not scoped to the key being started: it
|
||||
walked the WHOLE registry with the 30 s finished rule, so pressing play on any item killed a
|
||||
paused viewer's session — the very case round 4 set out to protect (found in round 5)."""
|
||||
monkeypatch.setattr(state, "_configured_cache", True)
|
||||
monkeypatch.setattr(plex_paths, "local_media_path", lambda db_, fp: "/local/a.mkv")
|
||||
started = _library_item(db, "starting")
|
||||
user = User(email="starter@example.com")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
paused = _Session(tmp_path, "paused", finished=True, idle_s=90)
|
||||
sessions["someone-else"] = paused
|
||||
|
||||
def fake_start(db_, item, start, audio=None, aoff=0.0, multi=False, transcode=False):
|
||||
return SimpleNamespace(
|
||||
id="sid",
|
||||
media_start_s=start,
|
||||
entry="index.m3u8",
|
||||
transcode=transcode,
|
||||
hw=False,
|
||||
tonemap=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(stream, "start_session", fake_start)
|
||||
try:
|
||||
resp = _client(db, user).post(f"/api/plex/stream/{started.rating_key}/session")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert "someone-else" in sessions and not paused.proc.killed
|
||||
|
||||
@@ -51,3 +51,24 @@ def test_a_nul_in_a_request_string_is_a_422_not_a_500(db, make_user, as_user):
|
||||
assert r.status_code == 422, r.text
|
||||
assert r.json()["detail"] == {"code": "invalid_input", "reason": "nul"}
|
||||
assert client.get("/api/plex/playlists?contains_group=1,%00").status_code == 422
|
||||
|
||||
|
||||
def test_any_other_data_error_is_still_a_500():
|
||||
"""The other half of the app-wide handler, and the half a refactor can delete in silence: it is
|
||||
registered for EVERY DataError on every route and decides on a substring of the driver's message,
|
||||
so only a NUL may become a 422. Without this, simplifying it to an unconditional 422 — it reads
|
||||
like dead defensive code — would answer a numeric overflow or an over-long ingested title with
|
||||
"your input was bad", losing the traceback that names the real fault (deep-review round 5)."""
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.exc import DataError
|
||||
|
||||
from app.main import _nul_in_text
|
||||
|
||||
overflow = DataError("SELECT 1", {}, Exception("numeric field overflow"))
|
||||
with pytest.raises(DataError):
|
||||
asyncio.run(_nul_in_text(None, overflow)) # re-raised: it stays the 500 it always was
|
||||
|
||||
nul = DataError("SELECT 1", {}, Exception("invalid byte sequence: NUL (0x00)"))
|
||||
assert asyncio.run(_nul_in_text(None, nul)).status_code == 422
|
||||
|
||||
@@ -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"
|
||||
@@ -58,7 +58,12 @@ def _post(db, user, monkeypatch, rating_key, *, captured):
|
||||
def fake_start(db_, it, start, audio, aoff, multi, transcode=False):
|
||||
captured["transcode"] = transcode
|
||||
return SimpleNamespace(
|
||||
media_start_s=start, entry="index.m3u8", transcode=transcode, hw=False, tonemap=False
|
||||
id="sid",
|
||||
media_start_s=start,
|
||||
entry="index.m3u8",
|
||||
transcode=transcode,
|
||||
hw=False,
|
||||
tonemap=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(plex_stream, "start_session", fake_start)
|
||||
|
||||
Reference in New Issue
Block a user