Bound time-to-first-byte so a first play actually plays
Playback failed in Jellyfin the day after deployment, for uncached videos only. The handler blocked on the full download-and-mux before sending anything at all -- not even response headers -- so a 46-minute upload sat silent for 79 seconds and the client gave up. The proxy counted it a success, which is why /healthz and doctor both looked fine. Yesterday's "DirectPlay verified" only ever ran against videos already pulled during testing, so the first-play path was never exercised. The output is already a fragmented MP4, so it is readable while being written; a finished file only buys a correct duration and working seeks. Cap the wait at --first-byte-grace (12s, explicit in the unit) and stream whatever has not muxed by then. Measured: 156s -> 12.0s TTFB on a 66-minute upload, with ranges on a complete file unchanged. The cost is a first play with no seek bar when the grace is missed. Every later play of that video is perfect. Also fixes a hang found while testing this: the streaming loop waited on "finished and good" rather than "finished", so a producer that died after writing some bytes held the connection for the full 45s stall timeout and then dropped it. The stale yt-dlp warning pointed at youtube-automate's venv, the tree we are decommissioning; it now names ytstream's. /healthz reports the serving mode and grace. Eight new proxy tests, 353 passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+102
-40
@@ -26,15 +26,25 @@ Safety rails, because a Jellyfin library scan can ask for every episode at once:
|
||||
hour; a refresh storm hits the cap in seconds.
|
||||
--cache-gb tmpfs budget; least-recently-used complete files are evicted.
|
||||
|
||||
Two serving modes, as in the PoC:
|
||||
Serving modes. The output is a fragmented MP4, so it is readable while it is
|
||||
still being written; the only thing a finished file buys is a correct duration
|
||||
and working seeks, because ffmpeg patches the real duration into the moov on
|
||||
close and ignores both mvhd.duration and an injected mehd box before then.
|
||||
|
||||
default wait for the mux to finish, then serve. Correct duration, ranges
|
||||
and seeking. Costs time-to-first-byte (~60x realtime pull, so a
|
||||
46-minute video is ready in about 50s).
|
||||
--growing serve while writing. Low TTFB, but a probe of a partially written
|
||||
fragmented MP4 reports only the duration written so far. ffmpeg
|
||||
ignores both mvhd.duration and an injected mehd box, so this
|
||||
cannot be fixed in the container.
|
||||
default wait for the mux, but only for --first-byte-grace seconds,
|
||||
then serve what exists. Short videos finish inside the
|
||||
grace and keep the exact duration and ranges; long ones
|
||||
start immediately and regain both when the mux lands.
|
||||
--first-byte-grace raise it above --wait-timeout for the old strict behaviour
|
||||
(finished file or 504), which is only safe for a client
|
||||
that will wait minutes for its first byte.
|
||||
--growing grace of zero: serve as soon as any byte exists.
|
||||
|
||||
Why the grace exists at all, measured 2026-08-13 on this machine: the wait is
|
||||
~3s to extract plus roughly duration/50..110 to pull and mux, so a 46-minute
|
||||
upload took 79 seconds during which the handler sent nothing -- not even
|
||||
response headers. Jellyfin reported "playback error" on the first play of every
|
||||
long video while already-cached ones played perfectly.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -63,6 +73,15 @@ POT_ARGS = "youtubepot-bgutilhttp:base_url=http://127.0.0.1:4416"
|
||||
CLIENT_ARGS = "youtube:player_client=default"
|
||||
MAX_HEIGHT = 720
|
||||
STALL_TIMEOUT = 45.0
|
||||
# How long a request may block waiting for a *complete* mux before it gives up and
|
||||
# streams the partial file instead. This is a cap on time-to-first-byte, not a
|
||||
# prediction: measured cold on 2026-08-13, completion ran from 10s (8-minute
|
||||
# upload) to 156s (66-minute upload), and it does not follow the duration closely
|
||||
# because YouTube's per-format throttling varies and one transient failure plus a
|
||||
# retry costs ~5s of extraction before any byte is pulled. So do not tune this
|
||||
# expecting to cover "videos up to N minutes" -- it catches the quick cases,
|
||||
# absorbs a single retry, and bounds the bad case at something a player tolerates.
|
||||
FIRST_BYTE_GRACE = 12.0
|
||||
VIDEO_ID_RE = re.compile(r"^[A-Za-z0-9_-]{11}$")
|
||||
|
||||
_log_lock = threading.Lock()
|
||||
@@ -308,6 +327,9 @@ class Manager:
|
||||
self.cache_bytes = cache_bytes
|
||||
self.no_fetch = no_fetch
|
||||
self.growing = growing
|
||||
# Reported by /healthz so the serving mode is visible without reading the
|
||||
# unit file. Set by main(); the handler holds the value it actually uses.
|
||||
self.first_byte_grace = 0.0 if growing else FIRST_BYTE_GRACE
|
||||
self.lock = threading.Lock()
|
||||
self.sessions = {}
|
||||
self.counters = {"requests": 0, "started": 0, "reused": 0,
|
||||
@@ -438,7 +460,8 @@ class Manager:
|
||||
def status(self):
|
||||
with self.lock:
|
||||
return {
|
||||
"mode": "growing" if self.growing else "wait-for-complete",
|
||||
"mode": "growing" if self.growing else "wait-then-stream",
|
||||
"first_byte_grace_s": self.first_byte_grace,
|
||||
"no_fetch": self.no_fetch,
|
||||
"max_pipelines": self.max_pipelines,
|
||||
"max_starts": self.max_starts,
|
||||
@@ -465,7 +488,7 @@ class Manager:
|
||||
# HTTP
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def make_handler(mgr, wait_timeout):
|
||||
def make_handler(mgr, wait_timeout, first_byte_grace=FIRST_BYTE_GRACE):
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
@@ -478,11 +501,18 @@ def make_handler(mgr, wait_timeout):
|
||||
def _size(self, path):
|
||||
return os.path.getsize(path) if os.path.exists(path) else 0
|
||||
|
||||
def _wait_for_bytes(self, path, offset, complete):
|
||||
def _wait_for_bytes(self, path, offset, finished):
|
||||
"""Block until the file grows past `offset`, or nothing more is coming.
|
||||
|
||||
`finished` must mean "the producer has stopped", not "the producer
|
||||
succeeded" -- a run that died after writing some bytes never grows
|
||||
again, and waiting on success would hang here for STALL_TIMEOUT and
|
||||
then drop the connection.
|
||||
"""
|
||||
deadline = time.monotonic() + STALL_TIMEOUT
|
||||
while True:
|
||||
size = self._size(path)
|
||||
if size > offset or complete():
|
||||
if size > offset or finished():
|
||||
return size
|
||||
if time.monotonic() > deadline:
|
||||
raise TimeoutError
|
||||
@@ -551,29 +581,41 @@ def make_handler(mgr, wait_timeout):
|
||||
self._fail(503, refusal, retry_after=30)
|
||||
return
|
||||
|
||||
if not mgr.growing:
|
||||
# Correct duration and working seeks require a finished file.
|
||||
if not sess.final.is_set():
|
||||
access(f" -> waiting for {video_id} to finish muxing")
|
||||
if not sess.final.wait(wait_timeout):
|
||||
self._fail(504, "mux did not finish in time")
|
||||
return
|
||||
if sess.failed:
|
||||
access(f" -> 502 producer failed: {sess.failed[:120]}")
|
||||
self._fail(502, f"producer failed: {sess.failed[:200]}")
|
||||
# A finished file is worth a short wait -- it is the only way to get a
|
||||
# correct duration and working seeks -- but the wait is proportional
|
||||
# to the video's length, and until it ends this handler sends nothing
|
||||
# at all, not even response headers. Bound it. Whatever has not muxed
|
||||
# inside the grace gets served while it is still being written, which
|
||||
# is possible because the container is fragmented.
|
||||
grace = 0.0 if mgr.growing else min(first_byte_grace, wait_timeout)
|
||||
strict = grace >= wait_timeout
|
||||
if grace > 0 and not sess.final.is_set():
|
||||
access(f" -> waiting up to {grace:.0f}s for {video_id} to mux")
|
||||
sess.final.wait(grace)
|
||||
|
||||
if not sess.final.is_set():
|
||||
if strict:
|
||||
self._fail(504, "mux did not finish in time")
|
||||
return
|
||||
else:
|
||||
# Growing mode still needs the first bytes to exist. Retries
|
||||
# happen underneath while size is still 0, so wait on both.
|
||||
# Serving a partial file still needs its first bytes to exist: a
|
||||
# retry underneath can leave the size at 0 for a while, so wait
|
||||
# on both the size and the terminal signal.
|
||||
access(f" -> serving {video_id} while it is still muxing")
|
||||
deadline = time.monotonic() + STALL_TIMEOUT
|
||||
while sess.size() == 0 and not sess.final.is_set():
|
||||
if time.monotonic() > deadline:
|
||||
self._fail(504, "producer wrote nothing")
|
||||
return
|
||||
time.sleep(0.1)
|
||||
if sess.failed and sess.size() == 0:
|
||||
self._fail(502, f"producer failed: {sess.failed[:200]}")
|
||||
return
|
||||
|
||||
# `failed` is only ever set together with `final`, so this covers both
|
||||
# paths. Strict callers asked for a good file and get an error
|
||||
# instead; otherwise a partial file is better than nothing, and a
|
||||
# failure with no bytes at all is still an error.
|
||||
if sess.failed and (strict or sess.size() == 0):
|
||||
access(f" -> 502 producer failed: {sess.failed[:120]}")
|
||||
self._fail(502, f"producer failed: {sess.failed[:200]}")
|
||||
return
|
||||
|
||||
with mgr.lock:
|
||||
sess.readers += 1
|
||||
@@ -602,12 +644,16 @@ def make_handler(mgr, wait_timeout):
|
||||
|
||||
def _serve(self, sess, head_only):
|
||||
path = sess.out_path
|
||||
complete = lambda: sess.complete # noqa: E731
|
||||
# Two different questions, and conflating them hangs the connection:
|
||||
# `finished` is "no more bytes are coming", which is what every wait
|
||||
# below must test; `sess.complete` additionally means the file is
|
||||
# good, which is what ranges require.
|
||||
finished = lambda: sess.final.is_set() # noqa: E731
|
||||
|
||||
# Ranges are only honoured once the file is complete; while it is
|
||||
# still growing there is no reliable time-to-byte mapping into a
|
||||
# fragmented MP4, so we present a non-seekable stream instead.
|
||||
chunked = not complete()
|
||||
chunked = not sess.complete
|
||||
start, end, is_range = 0, None, False
|
||||
if not chunked:
|
||||
spec = self._parse_range(self.headers.get("Range"),
|
||||
@@ -624,7 +670,7 @@ def make_handler(mgr, wait_timeout):
|
||||
is_range = True
|
||||
|
||||
try:
|
||||
self._wait_for_bytes(path, start, complete)
|
||||
self._wait_for_bytes(path, start, finished)
|
||||
except TimeoutError:
|
||||
self._fail(504, "producer stalled")
|
||||
return
|
||||
@@ -672,10 +718,10 @@ def make_handler(mgr, wait_timeout):
|
||||
if remaining is not None:
|
||||
remaining -= len(buf)
|
||||
continue
|
||||
if complete() and sent >= self._size(path):
|
||||
if finished() and sent >= self._size(path):
|
||||
break
|
||||
try:
|
||||
self._wait_for_bytes(path, sent, complete)
|
||||
self._wait_for_bytes(path, sent, finished)
|
||||
except TimeoutError:
|
||||
break
|
||||
if chunked:
|
||||
@@ -698,7 +744,12 @@ def main():
|
||||
ap.add_argument("--cache-gb", type=float, default=8.0)
|
||||
ap.add_argument("--max-pipelines", type=int, default=2)
|
||||
ap.add_argument("--wait-timeout", type=float, default=300.0,
|
||||
help="how long a request may block waiting for the mux")
|
||||
help="hard cap on blocking for the mux; only reachable when "
|
||||
"--first-byte-grace is raised to meet it")
|
||||
ap.add_argument("--first-byte-grace", type=float, default=FIRST_BYTE_GRACE,
|
||||
help=f"seconds to wait for a complete mux before streaming "
|
||||
f"the partial file instead (default {FIRST_BYTE_GRACE:g}). "
|
||||
f"Raise to --wait-timeout for finished-file-or-504")
|
||||
ap.add_argument("--max-starts", type=int, default=20,
|
||||
help="max cold starts per window; bounds a runaway library "
|
||||
"refresh (default 20)")
|
||||
@@ -711,7 +762,8 @@ def main():
|
||||
help="never start a pipeline; log and 503. Use for a first "
|
||||
"library scan to detect probing with no YouTube traffic")
|
||||
ap.add_argument("--growing", action="store_true",
|
||||
help="serve while still writing (low TTFB, wrong duration)")
|
||||
help="never wait: serve as soon as a byte exists, which is "
|
||||
"--first-byte-grace 0")
|
||||
ap.add_argument("--access-log", default=None)
|
||||
args = ap.parse_args()
|
||||
|
||||
@@ -722,8 +774,8 @@ def main():
|
||||
version = ver.stdout.strip()
|
||||
if version < "2025":
|
||||
log(f"WARNING: yt-dlp {version} looks far too old, and a POT plugin is "
|
||||
f"required. Expected the automation venv "
|
||||
f"(/var/lib/youtube-automate/venv/bin) on PATH.")
|
||||
f"required. Expected ytstream's venv (/var/lib/ytstream/venv/bin) "
|
||||
f"first on PATH -- the unit pins it, a manual run must too.")
|
||||
else:
|
||||
log(f"yt-dlp {version}")
|
||||
|
||||
@@ -735,14 +787,24 @@ def main():
|
||||
int(args.cache_gb * 2**30), args.no_fetch, args.growing,
|
||||
args.max_retries, args.max_starts, args.starts_window)
|
||||
|
||||
grace = 0.0 if args.growing else min(args.first_byte_grace, args.wait_timeout)
|
||||
mgr.first_byte_grace = grace
|
||||
|
||||
if args.no_fetch:
|
||||
log("NO-FETCH MODE: every /watch request will be logged and refused. "
|
||||
"No YouTube traffic will be generated.")
|
||||
if args.growing:
|
||||
log("growing mode: probes will see a partial duration")
|
||||
if grace <= 0:
|
||||
log("growing mode: a first play seeks badly and shows no duration until "
|
||||
"the mux lands")
|
||||
elif grace >= args.wait_timeout:
|
||||
log(f"strict mode: a request blocks up to {grace:g}s for a complete mux "
|
||||
f"and 504s otherwise -- long videos will fail to start")
|
||||
else:
|
||||
log(f"waiting up to {grace:g}s for a complete mux, then streaming the "
|
||||
f"partial file")
|
||||
|
||||
srv = ThreadingHTTPServer((args.host, args.port),
|
||||
make_handler(mgr, args.wait_timeout))
|
||||
make_handler(mgr, args.wait_timeout, grace))
|
||||
log(f"listening on http://{args.host}:{args.port} "
|
||||
f"(/watch/<video_id>, /healthz)")
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user