The artifact guard parsed the sequence with int(), so a zero-padded or non-ASCII-digit name read as a segment the session had reached and parked the sync route for the full 20 s wait. It now also requires the canonical spelling segment_name() produces. Stopping, replacing, culling or reaping a session waited up to 3 s for ffmpeg and deleted a whole segmented film under _lock, which every segment request and checkpoint takes. Teardown now signals the process and moves its directory aside under the lock, and waits/deletes after it. A new session on the same path still waits for the dying ffmpeg first (it writes by path), and the orphan sweep recognises a moved-aside directory left by a crash.
103 lines
3.2 KiB
Python
103 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.key = "m1"
|
|
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}
|