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.
359 lines
18 KiB
Python
359 lines
18 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 client's reclaim-on-404 could never fire on it.
|
||
|
||
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.
|
||
"""
|
||
|
||
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, override_dep):
|
||
monkeypatch.setattr(state, "_configured_cache", True)
|
||
user = User(id=VIEWER, email="hls@example.com")
|
||
override_dep(current_user, lambda: user)
|
||
return TestClient(app)
|
||
|
||
|
||
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. 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
|
||
):
|
||
"""An earlier 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."""
|
||
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(
|
||
("name", "audio_langs"),
|
||
[
|
||
("seg_00.ts", None), # `int()` reads it as segment 0, which needs no predecessor
|
||
("seg_007.ts", None), # …as segment 7, whose predecessor seg_6.ts is on disk
|
||
("seg_٧.ts", None), # an Arabic-Indic 7: `\d` and `int()` both take it
|
||
("seg_a0_07.ts", ["hu", "en"]),
|
||
("seg_0_00.ts", ["hu", "en"]),
|
||
],
|
||
)
|
||
def test_a_segment_name_ffmpeg_never_spells_is_refused(tmp_path, name, audio_langs):
|
||
"""ffmpeg writes `seg_%d.ts`: no padding, ASCII digits. A name that only PARSES to a sequence the
|
||
session has reached is still a file that will never appear, and saying yes to it parked the
|
||
request for the full 20 s wait — the parking this guard exists to end."""
|
||
d = tmp_path / "m1_0_0_+0.00"
|
||
d.mkdir()
|
||
for v in plex_stream.session_variants(audio_langs) or (None,):
|
||
(d / plex_stream.segment_name(6, v)).write_bytes(b"x")
|
||
sess = _Session("sid-a", d, running=True, audio_langs=audio_langs)
|
||
assert not plex_stream.artifact_may_arrive(sess, name)
|
||
# The canonical spelling of the same next segment is still accepted.
|
||
v = (plex_stream.session_variants(audio_langs) or (None,))[0]
|
||
assert plex_stream.artifact_may_arrive(sess, plex_stream.segment_name(7, v))
|
||
|
||
|
||
@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
|
||
):
|
||
"""Teardown moves the whole directory aside 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.
|
||
|
||
The bug 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."""
|
||
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")
|