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
+78 -17
View File
@@ -108,7 +108,7 @@ class TestSweepOrphans:
def test_a_live_sessions_directory_is_never_deleted(self, hls_root):
# Registered session, but with an OLD directory — only the live check can save it.
d = _session_dir(hls_root, "12345_0_multi_+0.00", age_s=3600)
stream._sessions["k"] = _FakeSession(d)
stream._sessions[(1, "k")] = _FakeSession(d)
assert stream.sweep_orphans(older_than=_cutoff(hls_root)) == 0
assert d.exists()
@@ -179,10 +179,20 @@ class _CapturingProc:
def __init__(self, *args, **kwargs):
_CapturingProc.calls.append((args, kwargs))
self.killed = False
def poll(self):
return None
def terminate(self):
self.killed = True
def wait(self, timeout=None):
return 0
def kill(self):
self.killed = True
def _venc(cmd):
"""The video codec ffmpeg was told to use (the value after the first `-c:v`)."""
@@ -495,7 +505,7 @@ class TestColdStartWarmup:
return True
monkeypatch.setattr(stream, "wait_for", spy)
s = stream.start_session(db=None, item=_FakeItem(), start_s=0.0)
s = stream.start_session(db=None, user_id=7, item=_FakeItem(), start_s=0.0)
assert s is not None
# seg_0 (unconditional now) + the warm-up run up to _WARMUP_SEGMENTS.
assert waited == [f"seg_{i}.ts" for i in range(stream._WARMUP_SEGMENTS)]
@@ -508,7 +518,7 @@ class TestColdStartWarmup:
return path.name != "seg_1.ts" # seg_1 never appears → warm-up gives up there
monkeypatch.setattr(stream, "wait_for", spy)
s = stream.start_session(db=None, item=_FakeItem(), start_s=0.0)
s = stream.start_session(db=None, user_id=7, item=_FakeItem(), start_s=0.0)
assert s is not None
assert waited == ["seg_0.ts", "seg_1.ts"] # never asked for seg_2
@@ -522,7 +532,7 @@ class TestColdStartWarmup:
return False
monkeypatch.setattr(stream, "wait_for", spy)
s = stream.start_session(db=None, item=_FakeItem(), start_s=0.0)
s = stream.start_session(db=None, user_id=7, item=_FakeItem(), start_s=0.0)
assert s is not None
assert waited == ["seg_0.ts"]
@@ -548,6 +558,7 @@ class TestTranscodeStartSession:
"plex_transcode_crf": 18,
"plex_transcode_max_height": 480,
"plex_max_transcodes": 4,
"plex_max_sessions": 8,
}[k],
)
monkeypatch.setattr(
@@ -573,7 +584,7 @@ class TestTranscodeStartSession:
stream, "_select_encode", lambda db, item, transcode, src: (False, False)
)
s = stream.start_session(
db=None, item=_FakeItem(codec_video="mpeg4"), start_s=0.0, transcode=True
db=None, user_id=7, item=_FakeItem(codec_video="mpeg4"), start_s=0.0, transcode=True
)
assert s is not None and s.transcode is True and s.hw is False
(cmd, *_), kwargs = _CapturingProc.calls[0]
@@ -590,7 +601,7 @@ class TestTranscodeStartSession:
stream, "_select_encode", lambda db, item, transcode, src: (True, False)
)
s = stream.start_session(
db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
db=None, user_id=7, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
)
assert s is not None and s.transcode is True and s.hw is True and s.tonemap is False
assert len(_CapturingProc.calls) == 1 # first segment arrived — no HW→SW fallback respawn
@@ -609,7 +620,7 @@ class TestTranscodeStartSession:
# tonemap=True on the session, still on the GPU (hw=True) and never niced.
monkeypatch.setattr(stream, "_select_encode", lambda db, item, transcode, src: (True, True))
s = stream.start_session(
db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
db=None, user_id=7, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
)
assert s is not None and s.transcode is True and s.hw is True and s.tonemap is True
(cmd, *_), kwargs = _CapturingProc.calls[0]
@@ -635,7 +646,7 @@ class TestTranscodeStartSession:
monkeypatch.setattr(stream, "wait_for", flaky_wait)
s = stream.start_session(
db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
db=None, user_id=7, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
)
assert (
s is not None and s.transcode is True and s.hw is False
@@ -666,7 +677,7 @@ class TestTranscodeStartSession:
monkeypatch.setattr(stream, "wait_for", flaky_wait)
s = stream.start_session(
db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
db=None, user_id=7, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
)
assert s is not None and s.transcode is True and s.hw is False and s.tonemap is False
assert len(_CapturingProc.calls) == 2 # the HW tonemap attempt, then the SW respawn
@@ -699,17 +710,17 @@ class TestTranscodeStartSession:
def wait_then_supersede(path, timeout=20.0, proc=None):
# Simulate the concurrent replacement happening during our out-of-lock wait, then report the
# (now-killed) HW proc produced nothing.
stream._sessions["12345"] = newer
stream._sessions[(7, "12345")] = newer
return False
monkeypatch.setattr(stream, "wait_for", wait_then_supersede)
s = stream.start_session(
db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
db=None, user_id=7, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
)
# No SW respawn — only the initial HW _spawn ran; the newer session was left intact.
assert len(_CapturingProc.calls) == 1
assert s is newer # yielded the currently-registered session, did not clobber it
assert stream._sessions["12345"] is newer
assert stream._sessions[(7, "12345")] is newer
def test_a_cap_culled_hw_session_does_not_respawn_and_is_not_reported_missing(
self, capturing_env, monkeypatch
@@ -727,16 +738,16 @@ class TestTranscodeStartSession:
def wait_then_cull(path, timeout=20.0, proc=None):
captured["s"] = stream._sessions[
"12345"
(7, "12345")
] # the HW session the initial _spawn just registered
del stream._sessions[
"12345"
(7, "12345")
] # simulate _enforce_cap dropping the LRU under cap pressure
return False
monkeypatch.setattr(stream, "wait_for", wait_then_cull)
s = stream.start_session(
db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
db=None, user_id=7, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
)
assert (
len(_CapturingProc.calls) == 1
@@ -752,7 +763,9 @@ class TestTranscodeStartSession:
monkeypatch.setattr(
stream, "_select_encode", lambda db, item, transcode, src: (False, False)
)
s = stream.start_session(db=None, item=_FakeItem(), start_s=0.0) # transcode defaults False
s = stream.start_session(
db=None, user_id=7, item=_FakeItem(), start_s=0.0
) # transcode defaults False
assert s is not None and s.transcode is False
(cmd, *_), kwargs = _CapturingProc.calls[0]
assert _venc(cmd) == "copy"
@@ -896,7 +909,7 @@ class TestAudioLoudnorm:
)
if multi:
monkeypatch.setattr(stream, "_probe_audio_langs", lambda src: ["hun", "eng"])
s = stream.start_session(db=None, item=_FakeItem(), start_s=0.0, multi=multi)
s = stream.start_session(db=None, user_id=7, item=_FakeItem(), start_s=0.0, multi=multi)
assert s is not None
(cmd, *_), _ = _CapturingProc.calls[0]
return s, cmd
@@ -945,3 +958,51 @@ class TestAudioLoudnorm:
# sweep no longer recognises is one that never gets cleaned up.
s, _ = self._spawn(monkeypatch, on=on)
assert _SESSION_DIR_RE.match(s.dir.name)
class TestSessionsPerViewer:
"""HLS sessions were keyed by the ITEM, so a second account opening the same film killed the
first one's session; each then reclaimed and evicted the other. And only transcodes counted
toward a cap, so remuxes — each a whole segmented copy of the film on disk — were unbounded."""
capturing_env = TestTranscodeStartSession.capturing_env
@staticmethod
def _start(monkeypatch, user_id, rating_key="12345"):
monkeypatch.setattr(
stream, "_select_encode", lambda db, item, transcode, src: (False, False)
)
return stream.start_session(
db=None, user_id=user_id, item=_FakeItem(rating_key=rating_key), start_s=0.0
)
def test_two_viewers_of_one_film_each_keep_their_own_session(self, capturing_env, monkeypatch):
monkeypatch.setattr(stream, "_sessions", {})
a = self._start(monkeypatch, user_id=1)
b = self._start(monkeypatch, user_id=2)
assert stream._sessions == {(1, "12345"): a, (2, "12345"): b}
assert not a.proc.killed
assert a.dir != b.dir and _SESSION_DIR_RE.match(a.dir.name)
def test_one_account_holds_at_most_two_sessions(self, capturing_env, monkeypatch):
monkeypatch.setattr(stream, "_sessions", {})
first = self._start(monkeypatch, user_id=1, rating_key="1")
first.last_access -= 60 # the least recently used of the three
self._start(monkeypatch, user_id=1, rating_key="2")
self._start(monkeypatch, user_id=1, rating_key="3")
assert sorted(stream._sessions) == [(1, "2"), (1, "3")]
assert first.proc.killed
def test_remux_sessions_count_toward_the_overall_ceiling(self, capturing_env, monkeypatch):
monkeypatch.setattr(stream, "_sessions", {})
monkeypatch.setattr(
stream.sysconfig,
"get_int",
lambda db, k: {"plex_max_transcodes": 4, "plex_max_sessions": 3}[k],
)
oldest = self._start(monkeypatch, user_id=1)
oldest.last_access -= 60
for uid in (2, 3, 4):
self._start(monkeypatch, user_id=uid)
assert len(stream._sessions) == 3 and (1, "12345") not in stream._sessions
assert oldest.proc.killed and not oldest.transcode