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.
102 lines
3.2 KiB
Python
102 lines
3.2 KiB
Python
"""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}
|