fix(plex): a name this session cannot write fails at once, and the layout has one spelling

Round 6 turned `artifact_may_arrive` into a positive rule, but keyed it to the HLS vocabulary in
general rather than to what THIS session produces: the playlist regex accepted master/index/stream_*
for every session and the `seq == 0` branch accepted any variant token unconditionally. 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 — so each of those was read as "an HLS name, so wait and see" and held a threadpool
worker, plus its pooled DB connection, for the full timeout. That is verbatim the round-5 outage the
route's own comment claims it prevents.

Measured through the real route against a live single-audio session, before: master.m3u8 404 after
30.0s, stream_q7z.m3u8 404 after 30.0s, seg_a0_0.ts / seg_zzzz_0.ts / seg_0_0.ts 404 after 20.1s
each. After, on the running localdev API: 404 in 44-316ms for all of them, while index.m3u8,
seg_0.ts and seg_1.ts still answer 200 — nothing is over-refused.

The session now carries the rendition names its ffmpeg was told to write, and `entry` is derived from
them instead of passed. And "one place" now means the argv BUILDS its patterns from the same
functions the parser reads back (`segment_name("%d", "%v")`, `variant_playlist_name("%v")`): the
layout was still spelled out independently in six places, with `_VIDEO_VARIANT` read by no consumer,
which is the drift that caused round 6's HIGH. A test asserts the argv's own patterns are what those
functions produce, and that every name they expand to is one the guard accepts.

The load-bearing MEASURED comment cited ffmpeg 8.1.1 — the Windows host's. The binary that writes
these files is the API container's 7.1.5; re-measured there, the conclusion holds.
This commit is contained in:
2026-09-12 16:21:37 +02:00
parent 3eb0f41962
commit 71d7646bf1
3 changed files with 189 additions and 45 deletions
+87 -28
View File
@@ -26,6 +26,7 @@ import shutil
import subprocess import subprocess
import threading import threading
import time import time
from collections.abc import Sequence
from pathlib import Path from pathlib import Path
from sqlalchemy.orm import Session as DbSession from sqlalchemy.orm import Session as DbSession
@@ -99,7 +100,7 @@ class HlsSession:
directory: Path, directory: Path,
proc: subprocess.Popen, proc: subprocess.Popen,
start_s: float, start_s: float,
entry: str, variants: "Sequence[str]",
transcode: bool = False, transcode: bool = False,
hw: bool = False, hw: bool = False,
tonemap: bool = False, tonemap: bool = False,
@@ -126,7 +127,13 @@ class HlsSession:
# subtitle cue-shift both key off the true content position — no drift. Provisional = X until # subtitle cue-shift both key off the true content position — no drift. Provisional = X until
# the first segment is probed. # the first segment is probed.
self.media_start_s = start_s self.media_start_s = start_s
self.entry = entry # the playlist filename hls.js should load (master.m3u8 with subs, else index.m3u8) # The rendition names this session's ffmpeg was told to write — () for a single-audio
# session. Held on the session because it is what `artifact_may_arrive` needs to tell a name
# this session CAN produce from one belonging to the other argv branch (see it).
self.variants = tuple(variants)
# The playlist filename hls.js should load: the master of a multi-audio session, else the
# single index. Derived, not passed, so it cannot disagree with the argv.
self.entry = _MASTER_PL if self.variants else _INDEX_PL
self.last_access = time.time() self.last_access = time.time()
@@ -509,7 +516,8 @@ def _ffmpeg_cmd(
# switches audio CLIENT-SIDE on the same timeline (no session restart, no drift) — the premium # switches audio CLIENT-SIDE on the same timeline (no session restart, no drift) — the premium
# audio-switch path. Produces master.m3u8 + one stream_%v.m3u8 and seg_%v_*.ts per variant, # audio-switch path. Produces master.m3u8 + one stream_%v.m3u8 and seg_%v_*.ts per variant,
# where %v is the video rendition's index ("0") and each audio rendition's `name:` (a0, a1, # where %v is the video rendition's index ("0") and each audio rendition's `name:` (a0, a1,
# …) — see `_SEG_NAME_RE`, which is the one place that layout is written down. # …). Those names are spelled by `segment_name` / `variant_playlist_name` below, which the
# guard and the warm-up read back — see them for the measurement behind the ordering.
args += ["-map", "0:v:0"] args += ["-map", "0:v:0"]
for i in range(len(audio_langs)): for i in range(len(audio_langs)):
args += ["-map", f"{ai}:a:{i}?"] args += ["-map", f"{ai}:a:{i}?"]
@@ -523,14 +531,16 @@ def _ffmpeg_cmd(
if i == 0: if i == 0:
entry += ",default:yes" entry += ",default:yes"
var.append(entry) var.append(entry)
# The name patterns come from the vocabulary functions, with ffmpeg's own placeholders passed
# through them — so the producer and the parser cannot spell the layout differently (round 7).
args += _HLS_TAIL + [ args += _HLS_TAIL + [
"-master_pl_name", "-master_pl_name",
"master.m3u8", _MASTER_PL,
"-var_stream_map", "-var_stream_map",
" ".join(var), " ".join(var),
"-hls_segment_filename", "-hls_segment_filename",
str(out_dir / "seg_%v_%d.ts"), str(out_dir / segment_name("%d", "%v")),
str(out_dir / "stream_%v.m3u8"), str(out_dir / variant_playlist_name("%v")),
] ]
else: else:
ao = audio_ord if audio_ord is not None else 0 ao = audio_ord if audio_ord is not None else 0
@@ -538,8 +548,8 @@ def _ffmpeg_cmd(
args += [*venc, *_aenc_opts(loudnorm)] args += [*venc, *_aenc_opts(loudnorm)]
args += _HLS_TAIL + [ args += _HLS_TAIL + [
"-hls_segment_filename", "-hls_segment_filename",
str(out_dir / "seg_%d.ts"), str(out_dir / segment_name("%d")),
str(out_dir / "index.m3u8"), str(out_dir / _INDEX_PL),
] ]
return args return args
@@ -627,6 +637,9 @@ def start_session(
start_s = max(0.0, float(start_s)) start_s = max(0.0, float(start_s))
audio_langs = _probe_audio_langs(src) if multi else [] audio_langs = _probe_audio_langs(src) if multi else []
is_multi = len(audio_langs) > 1 # only worth a master playlist when there really are ≥2 tracks is_multi = len(audio_langs) > 1 # only worth a master playlist when there really are ≥2 tracks
# The rendition names the argv below will produce — one source for the argv, the warm-up's
# segment names, the entry playlist and the artifact guard.
variants = session_variants(audio_langs if is_multi else None)
# Loudness normalisation changes the AUDIO THE SEGMENTS CONTAIN, so it belongs in the directory # Loudness normalisation changes the AUDIO THE SEGMENTS CONTAIN, so it belongs in the directory
# name beside the other parameters that do (offset, audio track, A/V offset): a session left over # name beside the other parameters that do (offset, audio track, A/V offset): a session left over
# from before the admin flipped the knob must not be mistaken for one that honours it. Appended to # from before the admin flipped the knob must not be mistaken for one that honours it. Appended to
@@ -636,7 +649,7 @@ def start_session(
directory = _HLS_ROOT / f"{key}_{int(start_s)}_{tag}_{aoff:+.2f}" directory = _HLS_ROOT / f"{key}_{int(start_s)}_{tag}_{aoff:+.2f}"
def _seg(i: int) -> Path: def _seg(i: int) -> Path:
return directory / segment_name(i, is_multi) return directory / segment_name(i, _VIDEO_VARIANT if variants else None)
def _spawn( def _spawn(
hw: bool, tonemap: bool = False, *, replace_only: "HlsSession | None" = None hw: bool, tonemap: bool = False, *, replace_only: "HlsSession | None" = None
@@ -691,7 +704,7 @@ def start_session(
directory, directory,
proc, proc,
start_s, start_s,
"master.m3u8" if is_multi else "index.m3u8", variants,
transcode=transcode, transcode=transcode,
hw=hw, hw=hw,
tonemap=tonemap, tonemap=tonemap,
@@ -763,27 +776,64 @@ def current_session(key: str) -> HlsSession | None:
return s return s
# The names ffmpeg writes into a session directory, in ONE place. Getting this wrong is invisible — # The names ffmpeg writes into a session directory, in ONE place — and "one place" means the argv
# every path that reads a name falls back to "wait and see" — so it is a constant plus a matching # BUILDS its patterns from these same functions (`segment_name("%d", "%v")`), not that a comment
# parser, not three comments that can drift (they did: deep-review round 6 found the regex, `_seg` # says so. Getting this wrong is invisible in both directions: a name we failed to anticipate used
# and the comment above `_ffmpeg_cmd` all disagreeing). # to fall back to "wait and see" (round 6's HIGH), and now that the rule is positive it falls back
# to a 404 instead — so the producer and the parser have to be literally the same spelling.
# #
# MEASURED (ffmpeg 8.1.1, this repo's own argv): `-hls_segment_filename seg_%v_%d.ts` substitutes # MEASURED (ffmpeg 7.1.5 in the API container — the binary that actually writes these files — and
# `%v` with the VARIANT and `%d` with the SEQUENCE — variant FIRST. A dual-audio session writes # re-checked on 8.1.1): `-hls_segment_filename seg_%v_%d.ts` substitutes `%v` with the VARIANT and
# seg_0_0.ts… (video, the unnamed variant) plus seg_a0_0.ts… / seg_a1_0.ts… (the `name:aN` audio # `%d` with the SEQUENCE — variant FIRST. A dual-audio session writes seg_0_0.ts… (video, the
# renditions), and the playlists beside them are master.m3u8 + stream_0/a0/a1.m3u8. Single-audio # unnamed variant) plus seg_a0_0.ts… / seg_a1_0.ts… (the `name:aN` audio renditions), and the
# uses `seg_%d.ts` + index.m3u8, with no variant field at all. # playlists beside them are master.m3u8 + stream_0/a0/a1.m3u8. Single-audio uses `seg_%d.ts` +
# index.m3u8, with no variant field at all.
#
# The two argv branches are DISJOINT vocabularies, which is why the guard below is keyed to the
# SESSION and not to HLS in general: a single-audio session can never write `master.m3u8`,
# `stream_*.m3u8` or any `seg_<variant>_<n>.ts`, and a multi-audio one can never write `index.m3u8`
# or `seg_<n>.ts`. Accepting the union meant a hopeless name of the other shape was waited out for
# the full playlist/segment timeout — the threadpool parking this guard exists to end (round 7).
_MASTER_PL = "master.m3u8"
_INDEX_PL = "index.m3u8"
_SEG_NAME_RE = re.compile(r"^seg_(?:([0-9a-z]{1,4})_)?(\d{1,9})\.ts$") _SEG_NAME_RE = re.compile(r"^seg_(?:([0-9a-z]{1,4})_)?(\d{1,9})\.ts$")
_PLAYLIST_NAME_RE = re.compile(r"^(?:master|index|stream_[0-9a-z]{1,4})\.m3u8$")
def segment_name(seq: int, is_multi: bool) -> str: def session_variants(audio_langs: "Sequence[str | None] | None") -> tuple[str, ...]:
"""The file ffmpeg writes for the `seq`-th VIDEO segment of a session.""" """The `-var_stream_map` rendition names a session will write, or () for a single-audio one.
return f"seg_{_VIDEO_VARIANT}_{seq}.ts" if is_multi else f"seg_{seq}.ts"
The video rendition is left unnamed in the map, so ffmpeg falls back to its index
(`_VIDEO_VARIANT`); each audio rendition is named `aN` in track order."""
if not audio_langs:
return ()
return (_VIDEO_VARIANT, *(f"a{i}" for i in range(len(audio_langs))))
def artifact_may_arrive(directory: Path, filename: str) -> bool: def segment_name(seq: "int | str", variant: "str | None" = None) -> str:
"""Whether waiting for `filename` can possibly end in a file. """The file ffmpeg writes for the `seq`-th segment of `variant`.
`variant=None` is a single-audio session, whose argv carries no `%v` field at all. `seq` and
`variant` are typed loosely because `_ffmpeg_cmd` builds its own `-hls_segment_filename` pattern
through this function, passing ffmpeg's `"%d"` / `"%v"` placeholders — that is what keeps the
producer and the parser from drifting, rather than a comment asking the next editor not to."""
return f"seg_{variant}_{seq}.ts" if variant else f"seg_{seq}.ts"
def variant_playlist_name(variant: str) -> str:
"""The sub-playlist ffmpeg writes for one rendition of a multi-audio session."""
return f"stream_{variant}.m3u8"
def playlist_names(variants: "Sequence[str]") -> frozenset[str]:
"""Every playlist name this session's argv can write — the master plus one per rendition, or
just `index.m3u8` for a single-audio session."""
if not variants:
return frozenset({_INDEX_PL})
return frozenset({_MASTER_PL, *(variant_playlist_name(v) for v in variants)})
def artifact_may_arrive(s: "HlsSession", filename: str) -> bool:
"""Whether waiting for `filename` can possibly end in a file OF THIS SESSION.
Positive rule, not a blocklist: a session produces segments and playlists and NOTHING else, so Positive rule, not a blocklist: a session produces segments and playlists and NOTHING else, so
any other name is refused at once. ffmpeg also produces segments strictly in order, so a segment any other name is refused at once. ffmpeg also produces segments strictly in order, so a segment
@@ -791,23 +841,32 @@ def artifact_may_arrive(directory: Path, filename: str) -> bool:
and no amount of waiting will conjure it. Playlists are the one thing that must still be waited and no amount of waiting will conjure it. Playlists are the one thing that must still be waited
for: they are rewritten continuously and have no ordering to read. for: they are rewritten continuously and have no ordering to read.
Keyed to the SESSION's own vocabulary, not to HLS's: the two argv branches are disjoint, so a
name belonging to the other shape is as hopeless here as a random string. Reading those as "an
HLS name, so wait and see" left the whole class reachable — measured through the real route
against a live single-audio session, `master.m3u8` and `stream_q7z.m3u8` were 404s after 30.0 s
and `seg_a0_0.ts` / `seg_zzzz_0.ts` after 20.1 s each (deep-review round 7).
Saying no at once matters because the route that waits is a SYNC one: it holds a thread off the Saying no at once matters because the route that waits is a SYNC one: it holds a thread off the
~40-slot pool, and a pooled DB connection with it, for the whole timeout. An ordinary seek past ~40-slot pool, and a pooled DB connection with it, for the whole timeout. An ordinary seek past
the produced region was enough to park one for 20 s, and forty concurrent requests for a name the produced region was enough to park one for 20 s, and forty concurrent requests for a name
that will never exist parked the whole API (deep-review round 5). Round 5's guard read only that will never exist parked the whole API (deep-review round 5). Round 5's guard read only
single-audio segment names, so both halves of that were still fully reachable: every multi-audio single-audio segment names, so both halves of that were still fully reachable: every multi-audio
name fell through, and so did any `.m3u8`/`.vtt` name at all.""" name fell through, and so did any `.m3u8`/`.vtt` name at all."""
if _PLAYLIST_NAME_RE.match(filename): if filename in playlist_names(s.variants):
return True return True
m = _SEG_NAME_RE.match(filename) m = _SEG_NAME_RE.match(filename)
if m is None: if m is None:
return False return False
variant, seq = m.group(1), int(m.group(2)) variant, seq = m.group(1), int(m.group(2))
# The variant field must be one THIS session writes: present and known for a multi-audio
# session, absent for a single-audio one.
if variant not in (s.variants or (None,)):
return False
if seq == 0: if seq == 0:
return True return True
prev = f"seg_{variant}_{seq - 1}.ts" if variant else f"seg_{seq - 1}.ts"
try: try:
return (directory / prev).exists() return (s.dir / segment_name(seq - 1, variant)).exists()
except OSError: except OSError:
return False return False
+7 -5
View File
@@ -673,12 +673,14 @@ def stream_hls_file(
if s is None or s.id != session_id: if s is None or s.id != session_id:
raise HTTPException(status_code=404, detail="No active playback session") raise HTTPException(status_code=404, detail="No active playback session")
path = s.dir / filename path = s.dir / filename
# Only a name this session can actually produce is worth waiting for: the playlists, and a # Only a name THIS session can actually produce is worth waiting for: its own playlists, and a
# segment whose predecessor is already on disk (ffmpeg writes them in order, so if it is not # segment of one of its own renditions whose predecessor is already on disk (ffmpeg writes them
# there this one is not next). Answering now instead of waiting it out is what keeps a seek past # in order, so if it is not there this one is not next). The session is passed, not just its
# the produced region — or a deliberate flood of hopeless names — from parking threadpool # directory, because the single- and multi-audio argv branches spell their names differently and
# neither can produce the other's. Answering now instead of waiting it out is what keeps a seek
# past the produced region — or a deliberate flood of hopeless names — from parking threadpool
# workers and their DB connections (see `artifact_may_arrive`). # workers and their DB connections (see `artifact_may_arrive`).
if not plex_stream.artifact_may_arrive(s.dir, filename): if not plex_stream.artifact_may_arrive(s, filename):
raise HTTPException(status_code=404, detail="Not available yet") raise HTTPException(status_code=404, detail="Not available yet")
timeout = 30 if ext == ".m3u8" else 20 timeout = 30 if ext == ".m3u8" else 20
# `proc` so a session whose ffmpeg has already exited answers at once instead of polling out the # `proc` so a session whose ffmpeg has already exited answers at once instead of polling out the
+95 -12
View File
@@ -39,11 +39,16 @@ class _Proc:
class _Session: class _Session:
def __init__(self, session_id: str, directory, running: bool = False): def __init__(self, session_id: str, directory, running: bool = False, audio_langs=None):
self.id = session_id self.id = session_id
self.dir = directory self.dir = directory
self.proc = _Proc(running) self.proc = _Proc(running)
self.last_access = time.time() 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 @pytest.fixture
@@ -145,7 +150,7 @@ def test_a_multi_audio_segment_beyond_the_region_fails_at_once_too(
d.mkdir() d.mkdir()
(d / "seg_0_0.ts").write_bytes(b"x") (d / "seg_0_0.ts").write_bytes(b"x")
(d / "seg_a0_0.ts").write_bytes(b"x") (d / "seg_a0_0.ts").write_bytes(b"x")
sessions["m1"] = _Session("sid-a", d, running=True) sessions["m1"] = _Session("sid-a", d, running=True, audio_langs=["hu", "en"])
started = time.monotonic() started = time.monotonic()
r = client.get(f"/api/plex/stream/m1/hls/sid-a/{name}") r = client.get(f"/api/plex/stream/m1/hls/sid-a/{name}")
elapsed = time.monotonic() - started elapsed = time.monotonic() - started
@@ -161,7 +166,7 @@ def test_a_multi_audio_segment_whose_predecessor_exists_is_served(client, tmp_pa
d.mkdir() d.mkdir()
(d / "seg_a0_0.ts").write_bytes(b"x") (d / "seg_a0_0.ts").write_bytes(b"x")
(d / "seg_a0_1.ts").write_bytes(b"audio") (d / "seg_a0_1.ts").write_bytes(b"audio")
sessions["m1"] = _Session("sid-a", d, running=True) sessions["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") r = client.get("/api/plex/stream/m1/hls/sid-a/seg_a0_1.ts")
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
assert r.content == b"audio" assert r.content == b"audio"
@@ -227,13 +232,91 @@ def test_a_dead_producer_is_not_waited_out(client, tmp_path, sessions):
assert elapsed < 2.0, f"waited {elapsed:.1f}s on a producer that had already exited" assert elapsed < 2.0, f"waited {elapsed:.1f}s on a producer that had already exited"
@pytest.mark.parametrize("is_multi", [False, True]) @pytest.mark.parametrize("audio_langs", [None, ["hu", "en"]])
def test_the_name_ffmpeg_is_asked_for_is_a_name_the_guard_understands(tmp_path, is_multi): 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, and """One vocabulary, enforced. `segment_name` is what the warm-up and the PTS probe wait for, what
`artifact_may_arrive` is what the route parses — they were three separate statements of the the argv's own `-hls_segment_filename` pattern is built from, and what `artifact_may_arrive`
layout and two of them were wrong. Pin them to each other instead.""" parses back — they were three separate statements of the layout and two of them were wrong. Pin
name = plex_stream.segment_name(3, is_multi) them to each other instead."""
prev = plex_stream.segment_name(2, is_multi) sess = _Session("sid", tmp_path, audio_langs=audio_langs)
assert not plex_stream.artifact_may_arrive(tmp_path, name) 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") (tmp_path / prev).write_bytes(b"x")
assert plex_stream.artifact_may_arrive(tmp_path, name) 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["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"])
# 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["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")