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.
337 lines
16 KiB
Python
337 lines
16 KiB
Python
"""An HLS artifact request names the session it belongs to, and gets nothing else.
|
|
|
|
Sessions are keyed by rating_key ALONE, while a session's directory is per (key, offset, tag). So a
|
|
seek-restart — or a second viewer of the same item — replaces the registered session, and the old
|
|
viewer's next request for `seg_12.ts`, resolved by rating_key, was answered 200 with the NEW
|
|
session's twelfth segment: bytes produced from a different `-ss` offset under `-copyts`, i.e. a
|
|
timeline the client is not on. Wrong content with a success status is the one failure a player cannot
|
|
notice, and the reclaim-on-404 added in round 4 could never fire on it (deep-review round 5).
|
|
|
|
No `db` fixture: the route reads nothing from the database (its own `Depends(get_db)` went with the
|
|
removed auto-start, and `current_user` is overridden wholesale), so these must not sit in the DB
|
|
lane — there they SKIP on a machine with no Postgres while pytest prints green (round 6).
|
|
"""
|
|
|
|
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
|
|
|
|
|
|
class _Proc:
|
|
def __init__(self, running: bool = False):
|
|
self.running = running
|
|
|
|
def poll(self):
|
|
return None if self.running else 0
|
|
|
|
def terminate(self):
|
|
pass
|
|
|
|
def wait(self, timeout=None):
|
|
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
|
|
self.dir = directory
|
|
self.proc = _Proc(running)
|
|
self.last_access = time.time()
|
|
# The real session derives both of these from the argv it spawned; the guard reads
|
|
# `variants` to tell a name this session can produce from one only the OTHER argv branch
|
|
# writes, so a stand-in without it would test a shape that cannot exist.
|
|
self.variants = plex_stream.session_variants(audio_langs)
|
|
self.entry = "master.m3u8" if self.variants else "index.m3u8"
|
|
|
|
|
|
@pytest.fixture
|
|
def sessions(monkeypatch):
|
|
live: dict = {}
|
|
monkeypatch.setattr(plex_stream, "_sessions", live)
|
|
return live
|
|
|
|
|
|
@pytest.fixture
|
|
def client(monkeypatch):
|
|
monkeypatch.setattr(state, "_configured_cache", True)
|
|
user = User(id=VIEWER, email="hls@example.com")
|
|
app.dependency_overrides[current_user] = lambda: user
|
|
try:
|
|
yield TestClient(app)
|
|
finally:
|
|
app.dependency_overrides.pop(current_user, None)
|
|
|
|
|
|
def test_a_segment_is_served_from_the_session_the_url_names(client, tmp_path, sessions):
|
|
d = tmp_path / "m1_0_0_+0.00"
|
|
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[(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
|
|
):
|
|
"""Viewer A is at 0:00; viewer B seeks to 25:00 and their session takes the registry slot. A's
|
|
next segment must fail honestly — the player reclaims at the playhead — not arrive as B's."""
|
|
theirs = tmp_path / "m1_1500_0_+0.00"
|
|
theirs.mkdir()
|
|
(theirs / "seg_11.ts").write_bytes(b"")
|
|
(theirs / "seg_12.ts").write_bytes(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
|
|
|
|
|
|
def test_a_playlist_with_no_live_session_is_a_404_not_a_silent_restart_at_zero(
|
|
client, monkeypatch, sessions
|
|
):
|
|
"""The entry playlist used to auto-start a session when none was registered — always at 0.0,
|
|
silently throwing away the viewer's position. A 404 sends the player through its own reclaim,
|
|
which restarts at the playhead."""
|
|
started: list[float] = []
|
|
monkeypatch.setattr(
|
|
plex_stream,
|
|
"start_session",
|
|
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
|
|
assert started == []
|
|
|
|
|
|
def test_a_segment_that_will_never_arrive_fails_at_once(client, tmp_path, sessions):
|
|
"""`stream_hls_file` is a SYNC route: it runs on the ~40-slot threadpool and holds a pooled DB
|
|
connection while it waits. It used to poll out the whole 20 s for a name that could never appear
|
|
— an ordinary seek past the produced region was enough to park a worker and a connection, and
|
|
forty hopeless names parked the API (deep-review round 5). ffmpeg writes segments in order, so a
|
|
segment whose predecessor is absent is not the next one, and that is knowable at once — even
|
|
while ffmpeg is still running, which is the case `proc` alone cannot shorten."""
|
|
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[(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
|
|
assert r.status_code == 404, r.text
|
|
# The wait is 20 s; anything in that neighbourhood means the producer's exit was not consulted.
|
|
assert elapsed < 2.0, f"waited {elapsed:.1f}s for a segment that can never arrive"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"name",
|
|
[
|
|
"seg_0_9000.ts", # the VIDEO rendition of a multi-audio session: variant 0, segment 9000
|
|
"seg_a0_9000.ts", # the first audio rendition: `name:a0` from -var_stream_map
|
|
],
|
|
)
|
|
def test_a_multi_audio_segment_beyond_the_region_fails_at_once_too(
|
|
client, tmp_path, sessions, name
|
|
):
|
|
"""The round-5 guard read `seg_<seq>_<variant>.ts`, but ffmpeg substitutes `%v` FIRST — so the
|
|
real names are `seg_<variant>_<seq>.ts` and every multi-audio request fell through to the full
|
|
20 s wait: the video rendition because its variant "0" parsed as segment 0, the audio renditions
|
|
(`a0`, `a1`) because they did not parse at all. Dual-audio is the common shape in this library,
|
|
so the guard was inert exactly where it was needed (round 6)."""
|
|
d = tmp_path / "m1_0_multi_+0.00"
|
|
d.mkdir()
|
|
(d / "seg_0_0.ts").write_bytes(b"x")
|
|
(d / "seg_a0_0.ts").write_bytes(b"x")
|
|
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
|
|
assert r.status_code == 404, r.text
|
|
assert elapsed < 2.0, f"waited {elapsed:.1f}s for {name}"
|
|
|
|
|
|
def test_a_multi_audio_segment_whose_predecessor_exists_is_served(client, tmp_path, sessions):
|
|
"""The other half of the same rule: reading the names correctly must not start refusing real
|
|
ones. `seg_a0_1.ts` follows `seg_a0_0.ts` — the previous SEGMENT of that rendition, not the
|
|
previous variant."""
|
|
d = tmp_path / "m1_0_multi_+0.00"
|
|
d.mkdir()
|
|
(d / "seg_a0_0.ts").write_bytes(b"x")
|
|
(d / "seg_a0_1.ts").write_bytes(b"audio")
|
|
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"
|
|
|
|
|
|
def test_a_name_the_session_cannot_produce_fails_at_once(client, tmp_path, sessions):
|
|
"""The guard was a blocklist: anything that was not a single-audio segment name was waved
|
|
through to the timeout, so `GET …/anything.m3u8` still parked a worker for the full 30 s — and
|
|
the route comment claimed a flood of hopeless names was what it prevented. A session produces
|
|
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[(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
|
|
assert r.status_code == 404, r.text
|
|
assert elapsed < 2.0, f"waited {elapsed:.1f}s for a playlist this session never writes"
|
|
|
|
|
|
@pytest.mark.parametrize("digits", [100, 5000])
|
|
def test_an_absurd_segment_index_is_a_404_not_a_500(client, tmp_path, sessions, digits):
|
|
"""`int()` on an unbounded digit run raises ValueError past 4300 digits, and only OSError was
|
|
caught — so a long enough name reached the client as a 500 with a traceback. Same bug class the
|
|
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[(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
|
|
|
|
|
|
def test_a_session_torn_down_during_the_read_is_a_404_not_a_500(
|
|
client, monkeypatch, tmp_path, sessions
|
|
):
|
|
"""`_kill` rmtrees the whole directory and nothing holds `_lock` across the wait and the read,
|
|
so the file can vanish in between — on every seek-restart, since `loadSession` awaits the new
|
|
session before destroying the old hls instance. The player's recovery is keyed on the 404
|
|
STATUS, so a 500 here skipped the reclaim and ended playback with "couldn't play"."""
|
|
d = tmp_path / "m1_0_0_+0.00"
|
|
d.mkdir()
|
|
(d / "seg_11.ts").write_bytes(b"")
|
|
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")
|
|
assert r.status_code == 404, r.text
|
|
|
|
|
|
def test_a_dead_producer_is_not_waited_out(client, tmp_path, sessions):
|
|
"""The route passes `proc` to `wait_for`. Without it a session whose ffmpeg has already exited
|
|
— a finished remux, a VAAPI init refusal — polls the whole 20 s for a segment that will never
|
|
be written, holding a threadpool worker and its pooled DB connection. The predecessor IS on
|
|
disk here, so `artifact_may_arrive` says yes and only `proc` can end the wait."""
|
|
d = tmp_path / "m1_0_0_+0.00"
|
|
d.mkdir()
|
|
(d / "seg_5.ts").write_bytes(b"x")
|
|
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
|
|
assert r.status_code == 404, r.text
|
|
assert elapsed < 2.0, f"waited {elapsed:.1f}s on a producer that had already exited"
|
|
|
|
|
|
@pytest.mark.parametrize("audio_langs", [None, ["hu", "en"]])
|
|
def test_the_name_ffmpeg_is_asked_for_is_a_name_the_guard_understands(tmp_path, audio_langs):
|
|
"""One vocabulary, enforced. `segment_name` is what the warm-up and the PTS probe wait for, what
|
|
the argv's own `-hls_segment_filename` pattern is built from, and what `artifact_may_arrive`
|
|
parses back — they were three separate statements of the layout and two of them were wrong. Pin
|
|
them to each other instead."""
|
|
sess = _Session("sid", tmp_path, audio_langs=audio_langs)
|
|
variant = sess.variants[0] if sess.variants else None
|
|
name = plex_stream.segment_name(3, variant)
|
|
prev = plex_stream.segment_name(2, variant)
|
|
assert not plex_stream.artifact_may_arrive(sess, name)
|
|
(tmp_path / prev).write_bytes(b"x")
|
|
assert plex_stream.artifact_may_arrive(sess, name)
|
|
|
|
|
|
def test_the_argv_asks_for_exactly_the_names_the_guard_accepts(tmp_path):
|
|
"""The producer and the parser, pinned to each other through the argv itself.
|
|
|
|
Round 6's HIGH was a guard that read `seg_%v_%d.ts` sequence-first, so it was inert on every
|
|
multi-audio item, and a warm-up that waited 8 s for a name ffmpeg never writes. The fix was to
|
|
give the layout one spelling — but a comment saying so is what had just failed, so assert that
|
|
the `-hls_segment_filename` pattern in the real argv is literally what `segment_name` produces,
|
|
and that every playlist the argv names is one `playlist_names` accepts."""
|
|
for audio_langs in (None, ["hu", "en", "de"]):
|
|
args = plex_stream._ffmpeg_cmd(
|
|
tmp_path / "in.mkv", 0.0, tmp_path, 0, 0.0, audio_langs, loudnorm=False
|
|
)
|
|
variants = plex_stream.session_variants(audio_langs)
|
|
sess = _Session("sid", tmp_path, audio_langs=audio_langs)
|
|
# The two patterns in the argv are the vocabulary's own spelling, `%d`/`%v` and all.
|
|
seg_pattern = args[args.index("-hls_segment_filename") + 1]
|
|
pl_pattern = args[-1].rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
|
|
if variants:
|
|
assert seg_pattern.endswith(plex_stream.segment_name("%d", "%v")), seg_pattern
|
|
assert pl_pattern == plex_stream.variant_playlist_name("%v"), pl_pattern
|
|
assert args[args.index("-master_pl_name") + 1] == plex_stream._MASTER_PL
|
|
else:
|
|
assert seg_pattern.endswith(plex_stream.segment_name("%d")), seg_pattern
|
|
assert pl_pattern == plex_stream._INDEX_PL, pl_pattern
|
|
assert "-master_pl_name" not in args
|
|
|
|
# And every name those patterns can actually expand to is one the guard lets through — the
|
|
# entry playlist hls.js is handed included, since a session whose own manifest is refused
|
|
# cannot start at all.
|
|
for v in variants or (None,):
|
|
assert plex_stream.artifact_may_arrive(sess, plex_stream.segment_name(0, v))
|
|
if v is not None:
|
|
assert plex_stream.artifact_may_arrive(sess, plex_stream.variant_playlist_name(v))
|
|
assert plex_stream.artifact_may_arrive(sess, sess.entry)
|
|
|
|
|
|
def test_a_name_only_the_other_argv_branch_writes_is_refused_at_once(tmp_path, client, sessions):
|
|
"""The guard is keyed to THIS session's vocabulary, not to HLS in general.
|
|
|
|
The two argv branches are disjoint: a single-audio session is given `seg_%d.ts` + `index.m3u8`
|
|
and no `%v` at all, so `master.m3u8`, `stream_*.m3u8` and every `seg_<variant>_<n>.ts` are names
|
|
it can never write — and vice versa. Accepting the union read them as "an HLS name, so wait and
|
|
see", and each one then held a threadpool worker and its pooled DB connection for the full
|
|
timeout: measured through this route against a live single-audio session, `master.m3u8` took
|
|
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[(VIEWER, "m1")] = _Session("sid-a", single, running=True)
|
|
multi = tmp_path / "m2_0_multi_+0.00"
|
|
multi.mkdir()
|
|
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.
|
|
for key, sid, names in (
|
|
("m1", "sid-a", ["master.m3u8", "stream_0.m3u8", "seg_0_0.ts", "seg_zzzz_0.ts"]),
|
|
("m2", "sid-b", ["index.m3u8", "seg_0.ts", "seg_a9_0.ts", "stream_zz.m3u8"]),
|
|
):
|
|
for name in names:
|
|
started = time.monotonic()
|
|
r = client.get(f"/api/plex/stream/{key}/hls/{sid}/{name}")
|
|
elapsed = time.monotonic() - started
|
|
assert r.status_code == 404, f"{name}: {r.text}"
|
|
assert elapsed < 2.0, (
|
|
f"{name}: waited {elapsed:.1f}s for a name this session cannot write"
|
|
)
|
|
|
|
# And the control: the names each session CAN write are still waited for, not refused.
|
|
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")
|