Deep-review round 4, backend half. - The periodic HLS reaper (round 3) applied the "finished and unfetched for 30 s" rule that only a NEW session request may apply. A remux finishes long before the film, and a paused player with a full buffer fetches nothing, so a one-minute pause killed the session and playback died ~30 s after resuming. The periodic pass is `reap_abandoned` now (the long idle limit, finished or not). - A subtitle `publish` that could not keep its output (full / read-only cache volume) returned quietly, so every poll queued the window and the whole-container pass again. It reports failure and the pass is backed off like a failed read; a cached VTT that cannot be READ is answered `unavailable` (`cache_unreadable`) instead of scheduling reads that can never land. - The subtitle routes bound `rating_key` / `sub_ord` (they become a cache file name: a 250-digit one was ENAMETOOLONG, which pathlib's exists() re-raises -> a 500), and the cache helpers no longer throw on such a name. - One app-wide handler turns psycopg's "text fields cannot contain NUL" DataError into a 422: round 3 guarded only the by-rating_key lookup, and `contains=`, `contains_group=`, reorder/bulk key lists and the collection search still 500'd. - The hand-written `db.get(...) is None -> 404 "Unknown ..."` siblings use `get_or_404` with a `what` (channel / video / user), so the Hungarian UI no longer shows English for a gone row.
107 lines
3.4 KiB
Python
107 lines
3.4 KiB
Python
"""Idle HLS sessions are reaped on a timer, not only when the next session is requested.
|
|
|
|
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.
|
|
|
|
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".
|
|
"""
|
|
|
|
import asyncio
|
|
import time
|
|
|
|
import pytest
|
|
|
|
import app.main as app_main
|
|
from app.main import app
|
|
from app.plex import stream
|
|
|
|
|
|
class _Configured:
|
|
def __enter__(self):
|
|
return None
|
|
|
|
def __exit__(self, *exc):
|
|
return False
|
|
|
|
|
|
def test_idle_sessions_are_reaped_while_nobody_starts_a_new_one(monkeypatch):
|
|
async def _no_sweep():
|
|
return None
|
|
|
|
reaped: list[int] = []
|
|
monkeypatch.setattr(app_main, "SessionLocal", _Configured)
|
|
monkeypatch.setattr(app_main.state, "is_configured", lambda db: True)
|
|
monkeypatch.setattr(app_main, "_sweep_hls_orphans", _no_sweep)
|
|
monkeypatch.setattr(app_main, "start_scheduler", lambda: None)
|
|
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)
|
|
|
|
async def _cycle():
|
|
async with app_main.lifespan(app):
|
|
await asyncio.sleep(0.2)
|
|
|
|
asyncio.run(_cycle())
|
|
assert len(reaped) >= 2, "no periodic reap ran while the app was up"
|
|
|
|
|
|
class _Proc:
|
|
def __init__(self, finished: bool):
|
|
self.finished = finished
|
|
self.killed = False
|
|
|
|
def poll(self):
|
|
return 0 if self.finished else None
|
|
|
|
def terminate(self):
|
|
self.killed = True
|
|
|
|
def wait(self, timeout=None):
|
|
return 0
|
|
|
|
|
|
class _Session:
|
|
def __init__(self, tmp_path, name: str, finished: bool, idle_s: float):
|
|
self.dir = tmp_path / name
|
|
self.dir.mkdir()
|
|
self.proc = _Proc(finished)
|
|
self.last_access = time.time() - idle_s
|
|
|
|
|
|
@pytest.fixture
|
|
def sessions(monkeypatch):
|
|
live: dict = {}
|
|
monkeypatch.setattr(stream, "_sessions", live)
|
|
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_still_reaps_what_nobody_has_touched_for_the_idle_limit(
|
|
tmp_path, sessions
|
|
):
|
|
sessions["1"] = _Session(
|
|
tmp_path, "gone_done", finished=True, idle_s=stream._SESSION_IDLE_S + 5
|
|
)
|
|
sessions["2"] = _Session(
|
|
tmp_path, "gone_live", finished=False, idle_s=stream._SESSION_IDLE_S + 5
|
|
)
|
|
assert stream.reap_abandoned() == 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 == {}
|