From d5bc7e8031faad54a297a9648f7465a3f3853540 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 3 Nov 2026 18:35:33 +0100 Subject: [PATCH] feat(plex): every HLS session reap logs its rule and idle time --- backend/app/plex/stream.py | 8 ++++++++ backend/tests/test_hls_reaper.py | 24 ++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/backend/app/plex/stream.py b/backend/app/plex/stream.py index 7c4ec00a..50dcf8a0 100644 --- a/backend/app/plex/stream.py +++ b/backend/app/plex/stream.py @@ -1185,6 +1185,7 @@ def reap_idle(finished_idle_s: float = _FINISHED_IDLE_S) -> int: one a whole segmented copy of the film on a volume shared with the download center.""" now = time.time() doomed: list[tuple[HlsSession, Path | None]] = [] + reaped: list[tuple[str, str, int]] = [] with _lock: for reg, s in list(_sessions.items()): done = s.proc.poll() is not None @@ -1192,6 +1193,13 @@ def reap_idle(finished_idle_s: float = _FINISHED_IDLE_S) -> int: if idle > _SESSION_IDLE_S or (done and idle > finished_idle_s): _sessions.pop(reg, None) doomed.append(_detach(s)) + reaped.append((s.key, "finished_idle" if done else "idle", int(idle))) + # The viewer of a session wrongly taken sees only a 404 later, so this line is the one record of why. + for key, reason, idle_s in reaped: + log.info( + "an idle session was reaped", + extra=logs.event("plex.stream.session_reaped", key=key, reason=reason, idle_s=idle_s), + ) _finish(doomed) return len(doomed) diff --git a/backend/tests/test_hls_reaper.py b/backend/tests/test_hls_reaper.py index 844f5499..fb1ffe2b 100644 --- a/backend/tests/test_hls_reaper.py +++ b/backend/tests/test_hls_reaper.py @@ -116,6 +116,30 @@ def test_the_periodic_pass_still_reaps_what_nobody_has_touched_for_the_idle_limi assert sessions == {} +def test_every_reap_says_which_rule_took_the_session(tmp_path, sessions, caplog): + """A session that vanishes leaves the viewer a 404 and nothing else: with no line here, a live + stall could only ever be blamed on the reaper by inference.""" + 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 + ) + sessions["3"] = _Session(tmp_path, "kept", finished=False, idle_s=5) + with caplog.at_level("INFO"): + assert stream.reap_idle() == 2 + reaped = { + r.attrs["key"]: r.attrs + for r in caplog.records + if getattr(r, "event", "") == "plex.stream.session_reaped" + } + assert set(reaped) == {"gone_done", "gone_live"} + assert reaped["gone_done"]["reason"] == "finished_idle" + assert reaped["gone_live"]["reason"] == "idle" + assert reaped["gone_done"]["idle_s"] == pytest.approx(stream._SESSION_IDLE_S - 5, abs=2) + assert reaped["gone_live"]["idle_s"] == pytest.approx(stream._SESSION_IDLE_S + 5, abs=2) + + 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