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:
+87
-28
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user