"""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. 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" does not mean "abandoned". An earlier fix 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. Its successor replaced the blanket retention with the signal that already existed: a player parked mid-item checkpoints its position every 10 s, paused or not, and that route says "still here" (it stops once the item is finished, where reaping is correct). 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 User from app.plex.models import PlexItem, PlexLibrary from app.plex import paths as plex_paths from app.plex import stream class _Configured: def __enter__(self): return None def __exit__(self, *exc): return False def test_the_periodic_pass_is_the_one_that_runs_and_it_uses_the_short_rule(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) # `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): 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.key = name 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_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( 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_idle() == 2 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[(1, "12345")] = s stream.touch_session(1, "12345") assert stream.reap_idle() == 0 assert (1, "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, override_dep): override_dep(get_db, db.info["request"]) override_dep(current_user, lambda: user) return TestClient(app) def test_a_paused_viewers_checkpoint_keeps_their_session_alive( db, monkeypatch, tmp_path, sessions, override_dep ): """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[(user.id, "paused1")] = s resp = _client(db, user, override_dep).post( f"/api/plex/item/{it.rating_key}/progress", json={"position_seconds": 600, "duration_seconds": 3000, "final": False}, ) assert resp.status_code == 200, resp.text assert stream.reap_idle() == 0, "the checkpoint did not say the viewer is still there" assert (user.id, "paused1") in sessions and not s.proc.killed def test_starting_a_session_leaves_another_viewers_paused_one_alone( db, monkeypatch, tmp_path, sessions, override_dep ): """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 the retention fix set out to protect.""" 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[(999, "someone-else")] = paused def fake_start(db_, item, user_id, 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) resp = _client(db, user, override_dep).post(f"/api/plex/stream/{started.rating_key}/session") assert resp.status_code == 200, resp.text assert (999, "someone-else") in sessions and not paused.proc.killed