Files
siftlode/backend/tests/test_plex_hls_file_route.py
peter e96a562f14 fix(plex): a segment name ffmpeg never spells is refused; teardown no longer holds the registry lock
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.
2026-10-28 18:48:21 +01:00

359 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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")