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:
@@ -116,10 +116,10 @@ def test_the_periodic_pass_still_reaps_what_nobody_has_touched_for_the_idle_limi
|
||||
|
||||
def test_touching_a_session_keeps_it_through_the_short_rule(tmp_path, sessions):
|
||||
s = _Session(tmp_path, "paused", finished=True, idle_s=90)
|
||||
sessions["12345"] = s
|
||||
stream.touch_session("12345")
|
||||
sessions[(1, "12345")] = s
|
||||
stream.touch_session(1, "12345")
|
||||
assert stream.reap_idle() == 0
|
||||
assert "12345" in sessions and s.dir.exists() and not s.proc.killed
|
||||
assert (1, "12345") in sessions and s.dir.exists() and not s.proc.killed
|
||||
|
||||
|
||||
# --- the two call sites, driven through the real routes (DB lane) -------------------------------
|
||||
@@ -157,7 +157,7 @@ def test_a_paused_viewers_checkpoint_keeps_their_session_alive(db, monkeypatch,
|
||||
db.add(user)
|
||||
db.commit()
|
||||
s = _Session(tmp_path, "paused", finished=True, idle_s=90)
|
||||
sessions["paused1"] = s
|
||||
sessions[(user.id, "paused1")] = s
|
||||
try:
|
||||
resp = _client(db, user).post(
|
||||
f"/api/plex/item/{it.rating_key}/progress",
|
||||
@@ -167,7 +167,7 @@ def test_a_paused_viewers_checkpoint_keeps_their_session_alive(db, monkeypatch,
|
||||
app.dependency_overrides.clear()
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert stream.reap_idle() == 0, "the checkpoint did not say the viewer is still there"
|
||||
assert "paused1" in sessions and not s.proc.killed
|
||||
assert (user.id, "paused1") in sessions and not s.proc.killed
|
||||
|
||||
|
||||
def test_starting_a_session_leaves_another_viewers_paused_one_alone(
|
||||
@@ -183,9 +183,9 @@ def test_starting_a_session_leaves_another_viewers_paused_one_alone(
|
||||
db.add(user)
|
||||
db.commit()
|
||||
paused = _Session(tmp_path, "paused", finished=True, idle_s=90)
|
||||
sessions["someone-else"] = paused
|
||||
sessions[(999, "someone-else")] = paused
|
||||
|
||||
def fake_start(db_, item, start, audio=None, aoff=0.0, multi=False, transcode=False):
|
||||
def fake_start(db_, item, user_id, start, audio=None, aoff=0.0, multi=False, transcode=False):
|
||||
return SimpleNamespace(
|
||||
id="sid",
|
||||
media_start_s=start,
|
||||
@@ -201,4 +201,4 @@ def test_starting_a_session_leaves_another_viewers_paused_one_alone(
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert "someone-else" in sessions and not paused.proc.killed
|
||||
assert (999, "someone-else") in sessions and not paused.proc.killed
|
||||
|
||||
@@ -68,7 +68,7 @@ def test_a_first_ever_finish_inserts_and_counts_once(db, make_user):
|
||||
def test_the_finish_supersedes_a_resume_push_still_in_flight(db, as_user, make_user, monkeypatch):
|
||||
user = make_user()
|
||||
item = _item(db)
|
||||
monkeypatch.setattr(playback.plex_stream, "touch_session", lambda key: None)
|
||||
monkeypatch.setattr(playback.plex_stream, "touch_session", lambda uid, key: None)
|
||||
earlier = datetime.now(timezone.utc) - timedelta(seconds=5)
|
||||
# A resume push was scheduled a moment ago and has not reached Plex yet.
|
||||
assert playback._should_push_resume(user.id, item.id, earlier, final=True)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -55,7 +55,7 @@ def _post(db, user, monkeypatch, rating_key, *, captured):
|
||||
monkeypatch.setattr(plex_stream, "reap_idle", lambda: None)
|
||||
monkeypatch.setattr(plex_paths, "local_media_path", lambda db_, fp: "/local/a.mkv")
|
||||
|
||||
def fake_start(db_, it, start, audio, aoff, multi, transcode=False):
|
||||
def fake_start(db_, it, user_id, start, audio, aoff, multi, transcode=False):
|
||||
captured["transcode"] = transcode
|
||||
return SimpleNamespace(
|
||||
id="sid",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -690,8 +690,8 @@ def test_a_scrubbing_viewer_cannot_queue_a_window_per_stop(monkeypatch):
|
||||
|
||||
def test_nothing_more_is_queued_while_the_extractor_is_saturated(monkeypatch):
|
||||
pool = _manual_pool(monkeypatch)
|
||||
for rk in range(20):
|
||||
subtitles.ensure(str(rk), 0, at=0.0)
|
||||
for rk in range(20): # twenty viewers, so no one account's share is what stops the passes
|
||||
subtitles.ensure(str(rk), 0, at=0.0, viewer=rk)
|
||||
windows = [a for _, a in pool.jobs if a[3] is not None]
|
||||
assert len(windows) == subtitles._MAX_WINDOWS_INFLIGHT
|
||||
assert len(pool.jobs) - len(windows) == subtitles._MAX_FULL_INFLIGHT
|
||||
@@ -971,6 +971,27 @@ def test_a_window_is_not_refused_while_full_passes_hold_their_slots(monkeypatch)
|
||||
assert full.jobs == [], "past its bound no full pass is queued"
|
||||
|
||||
|
||||
def test_one_account_cannot_hold_every_full_pass_slot(monkeypatch):
|
||||
"""The full-pass slots are global and minutes long each: one account opening subtitles on film
|
||||
after film held them all, and nobody else's track ever completed. Past its share an account
|
||||
still gets windows — what makes a track watchable now — and the next account still gets a pass."""
|
||||
win, full = _ManualPool(), _ManualPool()
|
||||
monkeypatch.setattr(subtitles, "_WIN_POOL", win)
|
||||
monkeypatch.setattr(subtitles, "_FULL_POOL", full)
|
||||
monkeypatch.setattr(subtitles, "_extract_and_publish", lambda *a: None)
|
||||
for rk in range(subtitles._MAX_FULL_PER_USER + 1):
|
||||
subtitles.ensure(str(1000 + rk), 1, at=600.0, viewer=7)
|
||||
assert len(full.jobs) == subtitles._MAX_FULL_PER_USER
|
||||
assert len(win.jobs) == subtitles._MAX_FULL_PER_USER + 1, (
|
||||
"the account over its share still gets windows"
|
||||
)
|
||||
subtitles.ensure("2000", 1, at=600.0, viewer=8)
|
||||
assert len(full.jobs) == subtitles._MAX_FULL_PER_USER + 1, "another account is not shut out"
|
||||
full.run_all() # the account's passes end: its share is free again
|
||||
subtitles.ensure("3000", 1, at=600.0, viewer=7)
|
||||
assert any(a[1] == "3000" and a[3] is None for _, a in full.jobs)
|
||||
|
||||
|
||||
def test_a_poll_after_shutdown_queues_nothing(monkeypatch):
|
||||
pool = _manual_pool(monkeypatch)
|
||||
subtitles.shutdown()
|
||||
|
||||
Reference in New Issue
Block a user