fix(plex): playback sessions per viewer, bounded in total; a fair share of subtitle passes

HLS sessions were keyed by the item, so a second account opening the same
film killed the first one's session; the displaced player reclaimed at its
playhead and evicted the other in turn. The registry is now keyed by
(viewer, rating_key), segment requests resolve by both, and the session
directory carries the viewer (the orphan sweep still recognises the older
shapes).

Only transcodes counted toward a cap, so remuxes — each a whole segmented
copy of the film on the scratch volume — were bounded by the library size.
Three bounds now apply after every start, each dropping the least recently
used: two sessions per account, the transcode cap, and a new admin knob,
plex_max_sessions (default 8), over every session. A superseded session
request returns at once, since it waits on the killed ffmpeg, so an account
cannot park more request workers than it may hold sessions.

The subtitle extractor's full-pass slots were global and minutes long each;
one account opening subtitles on film after film could hold them all. An
account may now hold three of the six, and still gets windows past that.
This commit is contained in:
2026-09-19 07:16:17 +02:00
parent 6f9ba428d5
commit dc4e8b9fcd
13 changed files with 278 additions and 102 deletions
+32 -18
View File
@@ -38,6 +38,9 @@ class _Proc:
return 0
VIEWER = 41 # the id of the account the test client is signed in as
class _Session:
def __init__(self, session_id: str, directory, running: bool = False, audio_langs=None):
self.id = session_id
@@ -61,7 +64,7 @@ def sessions(monkeypatch):
@pytest.fixture
def client(monkeypatch):
monkeypatch.setattr(state, "_configured_cache", True)
user = User(email="hls@example.com")
user = User(id=VIEWER, email="hls@example.com")
app.dependency_overrides[current_user] = lambda: user
try:
yield TestClient(app)
@@ -74,12 +77,23 @@ def test_a_segment_is_served_from_the_session_the_url_names(client, tmp_path, se
d.mkdir()
(d / "seg_11.ts").write_bytes(b"") # its predecessor, so seg_12 is the next one
(d / "seg_12.ts").write_bytes(b"mine")
sessions["m1"] = _Session("sid-a", d)
sessions[(VIEWER, "m1")] = _Session("sid-a", d)
r = client.get("/api/plex/stream/m1/hls/sid-a/seg_12.ts")
assert r.status_code == 200, r.text
assert r.content == b"mine"
def test_another_viewers_session_of_the_same_item_is_not_this_viewers(client, tmp_path, sessions):
"""Sessions are per viewer: a second account playing the same film has its own, and knowing its
id does not make it yours — resolution is by (viewer, item) before the id is compared."""
d = tmp_path / "m1_u7_0_0_+0.00"
d.mkdir()
(d / "index.m3u8").write_bytes(b"#EXTM3U")
sessions[(7, "m1")] = _Session("sid-a", d)
r = client.get("/api/plex/stream/m1/hls/sid-a/index.m3u8")
assert r.status_code == 404, r.text
def test_a_segment_of_a_replaced_session_is_a_404_not_the_new_sessions_bytes(
client, tmp_path, sessions
):
@@ -89,7 +103,7 @@ def test_a_segment_of_a_replaced_session_is_a_404_not_the_new_sessions_bytes(
theirs.mkdir()
(theirs / "seg_11.ts").write_bytes(b"")
(theirs / "seg_12.ts").write_bytes(b"theirs")
sessions["m1"] = _Session("sid-b", theirs)
sessions[(VIEWER, "m1")] = _Session("sid-b", theirs)
r = client.get("/api/plex/stream/m1/hls/sid-a/seg_12.ts")
assert r.status_code == 404, r.text
assert b"theirs" not in r.content
@@ -105,7 +119,7 @@ def test_a_playlist_with_no_live_session_is_a_404_not_a_silent_restart_at_zero(
monkeypatch.setattr(
plex_stream,
"start_session",
lambda *a, **k: started.append(a[2] if len(a) > 2 else 0.0),
lambda *a, **k: started.append(a[3] if len(a) > 3 else 0.0),
)
r = client.get("/api/plex/stream/m1/hls/sid-gone/index.m3u8")
assert r.status_code == 404, r.text
@@ -122,7 +136,7 @@ def test_a_segment_that_will_never_arrive_fails_at_once(client, tmp_path, sessio
d = tmp_path / "m1_0_0_+0.00"
d.mkdir()
(d / "seg_0.ts").write_bytes(b"x") # the session is producing; seg_99999 is simply not next
sessions["m1"] = _Session("sid-a", d, running=True)
sessions[(VIEWER, "m1")] = _Session("sid-a", d, running=True)
started = time.monotonic()
r = client.get("/api/plex/stream/m1/hls/sid-a/seg_99999.ts")
elapsed = time.monotonic() - started
@@ -150,7 +164,7 @@ def test_a_multi_audio_segment_beyond_the_region_fails_at_once_too(
d.mkdir()
(d / "seg_0_0.ts").write_bytes(b"x")
(d / "seg_a0_0.ts").write_bytes(b"x")
sessions["m1"] = _Session("sid-a", d, running=True, audio_langs=["hu", "en"])
sessions[(VIEWER, "m1")] = _Session("sid-a", d, running=True, audio_langs=["hu", "en"])
started = time.monotonic()
r = client.get(f"/api/plex/stream/m1/hls/sid-a/{name}")
elapsed = time.monotonic() - started
@@ -166,7 +180,7 @@ def test_a_multi_audio_segment_whose_predecessor_exists_is_served(client, tmp_pa
d.mkdir()
(d / "seg_a0_0.ts").write_bytes(b"x")
(d / "seg_a0_1.ts").write_bytes(b"audio")
sessions["m1"] = _Session("sid-a", d, running=True, audio_langs=["hu", "en"])
sessions[(VIEWER, "m1")] = _Session("sid-a", d, running=True, audio_langs=["hu", "en"])
r = client.get("/api/plex/stream/m1/hls/sid-a/seg_a0_1.ts")
assert r.status_code == 200, r.text
assert r.content == b"audio"
@@ -179,7 +193,7 @@ def test_a_name_the_session_cannot_produce_fails_at_once(client, tmp_path, sessi
its playlists and its segments and nothing else, so any other name is answerable now."""
d = tmp_path / "m1_0_0_+0.00"
d.mkdir()
sessions["m1"] = _Session("sid-a", d, running=True)
sessions[(VIEWER, "m1")] = _Session("sid-a", d, running=True)
started = time.monotonic()
r = client.get("/api/plex/stream/m1/hls/sid-a/nope.m3u8")
elapsed = time.monotonic() - started
@@ -194,7 +208,7 @@ def test_an_absurd_segment_index_is_a_404_not_a_500(client, tmp_path, sessions,
subtitle routes were bounded for; the answer is the same 404 any other unproducible name gets."""
d = tmp_path / "m1_0_0_+0.00"
d.mkdir()
sessions["m1"] = _Session("sid-a", d, running=True)
sessions[(VIEWER, "m1")] = _Session("sid-a", d, running=True)
r = client.get(f"/api/plex/stream/m1/hls/sid-a/seg_{'9' * digits}.ts")
assert r.status_code == 404, r.text
@@ -209,7 +223,7 @@ def test_a_session_torn_down_during_the_read_is_a_404_not_a_500(
d = tmp_path / "m1_0_0_+0.00"
d.mkdir()
(d / "seg_11.ts").write_bytes(b"")
sessions["m1"] = _Session("sid-a", d)
sessions[(VIEWER, "m1")] = _Session("sid-a", d)
# The file existed when the wait checked, and is gone by the time the route reads it.
monkeypatch.setattr(plex_stream, "wait_for", lambda *a, **k: True)
r = client.get("/api/plex/stream/m1/hls/sid-a/seg_12.ts")
@@ -224,7 +238,7 @@ def test_a_dead_producer_is_not_waited_out(client, tmp_path, sessions):
d = tmp_path / "m1_0_0_+0.00"
d.mkdir()
(d / "seg_5.ts").write_bytes(b"x")
sessions["m1"] = _Session("sid-a", d, running=False)
sessions[(VIEWER, "m1")] = _Session("sid-a", d, running=False)
started = time.monotonic()
r = client.get("/api/plex/stream/m1/hls/sid-a/seg_6.ts")
elapsed = time.monotonic() - started
@@ -294,10 +308,10 @@ def test_a_name_only_the_other_argv_branch_writes_is_refused_at_once(tmp_path, c
30.0 s and `seg_a0_0.ts` 20.1 s to answer 404 (deep-review round 7)."""
single = tmp_path / "m1_0_0_+0.00"
single.mkdir()
sessions["m1"] = _Session("sid-a", single, running=True)
sessions[(VIEWER, "m1")] = _Session("sid-a", single, running=True)
multi = tmp_path / "m2_0_multi_+0.00"
multi.mkdir()
sessions["m2"] = _Session("sid-b", multi, running=True, audio_langs=["hu", "en"])
sessions[(VIEWER, "m2")] = _Session("sid-b", multi, running=True, audio_langs=["hu", "en"])
# Each of these belongs to the other shape (or to no shape at all) and must be refused without
# a wait — the assertion is the ELAPSED time as much as the status.
@@ -315,8 +329,8 @@ def test_a_name_only_the_other_argv_branch_writes_is_refused_at_once(tmp_path, c
)
# And the control: the names each session CAN write are still waited for, not refused.
assert plex_stream.artifact_may_arrive(sessions["m1"], "index.m3u8")
assert plex_stream.artifact_may_arrive(sessions["m1"], "seg_0.ts")
assert plex_stream.artifact_may_arrive(sessions["m2"], "master.m3u8")
assert plex_stream.artifact_may_arrive(sessions["m2"], "stream_a1.m3u8")
assert plex_stream.artifact_may_arrive(sessions["m2"], "seg_a1_0.ts")
assert plex_stream.artifact_may_arrive(sessions[(VIEWER, "m1")], "index.m3u8")
assert plex_stream.artifact_may_arrive(sessions[(VIEWER, "m1")], "seg_0.ts")
assert plex_stream.artifact_may_arrive(sessions[(VIEWER, "m2")], "master.m3u8")
assert plex_stream.artifact_may_arrive(sessions[(VIEWER, "m2")], "stream_a1.m3u8")
assert plex_stream.artifact_may_arrive(sessions[(VIEWER, "m2")], "seg_a1_0.ts")