refactor(player): the Plex session machine is a tested engine, one request per item in flight
PlexPlayer's session logic (generation, pending play intent, clock ownership, session offset, reclaim
budget, listener teardown) moves into lib/hlsEngine.ts, a plain class with hls.js behind lib/hlsPort.ts
and its own unit tests.
- A seek, track change or reclaim asked for while a session request is out replaces the queued
target; it is sent once the in-flight answer settles. A burst of 20 presses past the buffer was 20
requests and 20 ffmpeg spawns; it is now 2-3, and plays again ~2 s sooner.
- The player stops its server session when it leaves the item, closes or the page unloads
(POST /api/plex/stream/{rk}/session/{sid}/stop, identity-aware). A left transcode used to run to
the end of the film first.
- A restart asked for after a new session attached but before it played no longer reads the emptied
media as the viewer's pause.
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
"""A player that no longer wants its HLS session stops it, instead of leaving ffmpeg to run on.
|
||||
|
||||
Left to the idle reaper, a transcode ran to the end of the film after the viewer had gone (measured on
|
||||
localdev: ~4 cores for a minute after leaving a resumed 480p film). The player names the session it
|
||||
played; the server stops it only while it is still that viewer's registered session for the item, so
|
||||
a stop that arrives after a seek-restart (or from a second tab) cannot end the newer playback.
|
||||
|
||||
No `db` fixture: the route reads nothing from the database (`current_user` is overridden wholesale).
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.state as state
|
||||
from app.auth import current_user
|
||||
from app.main import app
|
||||
from app.models import User
|
||||
from app.plex import stream as plex_stream
|
||||
|
||||
VIEWER = 41
|
||||
|
||||
|
||||
class _Proc:
|
||||
def __init__(self):
|
||||
self.terminated = False
|
||||
|
||||
def poll(self):
|
||||
return None
|
||||
|
||||
def terminate(self):
|
||||
self.terminated = True
|
||||
|
||||
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
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch, override_dep):
|
||||
monkeypatch.setattr(state, "_configured_cache", True)
|
||||
user = User(id=VIEWER, email="stop@example.com")
|
||||
override_dep(current_user, lambda: user)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _session(tmp_path, sid: str) -> _Session:
|
||||
d = tmp_path / sid
|
||||
d.mkdir()
|
||||
return _Session(sid, d)
|
||||
|
||||
|
||||
def test_the_named_session_is_stopped_and_its_directory_goes(client, tmp_path, sessions):
|
||||
s = _session(tmp_path, "sid-a")
|
||||
sessions[(VIEWER, "m1")] = s
|
||||
r = client.post("/api/plex/stream/m1/session/sid-a/stop", json={})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json() == {"ok": True, "stopped": True}
|
||||
assert s.proc.terminated
|
||||
assert sessions == {}
|
||||
assert not s.dir.exists()
|
||||
|
||||
|
||||
def test_a_session_that_replaced_the_named_one_plays_on(client, tmp_path, sessions):
|
||||
"""The stop of the session a seek-restart replaced arrives after the new one registered."""
|
||||
newer = _session(tmp_path, "sid-b")
|
||||
sessions[(VIEWER, "m1")] = newer
|
||||
r = client.post("/api/plex/stream/m1/session/sid-a/stop", json={})
|
||||
assert r.json() == {"ok": True, "stopped": False}
|
||||
assert not newer.proc.terminated
|
||||
assert sessions == {(VIEWER, "m1"): newer}
|
||||
|
||||
|
||||
def test_another_viewers_session_is_not_this_viewers_to_stop(client, tmp_path, sessions):
|
||||
theirs = _session(tmp_path, "sid-a")
|
||||
sessions[(7, "m1")] = theirs
|
||||
r = client.post("/api/plex/stream/m1/session/sid-a/stop", json={})
|
||||
assert r.json() == {"ok": True, "stopped": False}
|
||||
assert not theirs.proc.terminated
|
||||
|
||||
|
||||
def test_a_stop_with_no_session_left_is_not_an_error(client, sessions):
|
||||
"""The reaper or a cap may have dropped it first; the beacon must not fail loudly for that."""
|
||||
r = client.post("/api/plex/stream/m1/session/sid-a/stop", json={})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json() == {"ok": True, "stopped": False}
|
||||
Reference in New Issue
Block a user