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:
@@ -1053,3 +1053,87 @@ automatically after `materialise --all`.
|
||||
| Playback via Jellyfin `PlaybackInfo` | DirectPlay h264 720p + aac, 2049 s runtime |
|
||||
| Episodes with plot / aired after refresh | 251 / 251 |
|
||||
| `doctor` | all fatal checks pass |
|
||||
|
||||
## 19. First play was broken, and how — 2026-08-13
|
||||
|
||||
The day after deployment, playback failed in Jellyfin. The service was healthy the
|
||||
whole time: both units active, `doctor` green, `/healthz` reporting `failed: 0`.
|
||||
|
||||
**Cached videos played; uncached ones did not.** Yesterday's "DirectPlay verified"
|
||||
was measured only on videos already pulled during testing, so the first-play path
|
||||
had never actually been exercised end to end. That is the hole in §18's evidence.
|
||||
|
||||
### The mechanism
|
||||
|
||||
In wait-for-complete mode the handler blocked on `sess.final.wait(wait_timeout)`
|
||||
before sending anything — not the body, not even response headers. The wait is the
|
||||
whole download and mux:
|
||||
|
||||
| upload | cold time to first byte |
|
||||
|---|---|
|
||||
| 8 min | ~10 s |
|
||||
| 6.8 min *(one transient failure + retry)* | >12 s |
|
||||
| 22 min | 47 s |
|
||||
| 46 min | **79 s** ← what the user hit |
|
||||
| 66 min | 156 s |
|
||||
|
||||
No player waits that long, so the socket sat silent until the client gave up. The
|
||||
proxy counted it a success, which is why nothing looked wrong from inside.
|
||||
|
||||
### The fix: bound the wait, then stream
|
||||
|
||||
The output is already a fragmented MP4 (`frag_keyframe+empty_moov`), so it is
|
||||
readable while being written. The only thing a *finished* file buys is a correct
|
||||
duration and working seeks — ffmpeg patches the real duration into the moov on
|
||||
close, and ignores both `mvhd.duration` and an injected `mehd` before then
|
||||
(confirmed: a growing file probes 197 s → 1185 s → 2378 s → 4127 s against a true
|
||||
4128 s).
|
||||
|
||||
So the wait is now capped by `--first-byte-grace` (default 12 s, explicit in the
|
||||
unit). Whatever has not muxed by then is streamed as it is written.
|
||||
|
||||
| | before | after |
|
||||
|---|---|---|
|
||||
| TTFB, 66-min upload cold | 156 s (silent) | **12.0 s** |
|
||||
| TTFB, same video cached | ~0 | ~0 |
|
||||
| Range request on a complete file | 206 + `Content-Range` | unchanged, 1.7 ms |
|
||||
|
||||
`--first-byte-grace` is a **cap, not a prediction**. Completion time ranged 10–156 s
|
||||
and does not track duration closely: YouTube's per-format throttling varies, and one
|
||||
transient failure plus a retry costs ~5 s of extraction before any byte is pulled.
|
||||
Raising it to `--wait-timeout` restores the old finished-file-or-504 behaviour, which
|
||||
is the bug. The unit carries a comment saying so.
|
||||
|
||||
### Cost, stated plainly
|
||||
|
||||
A first play that misses the grace has **no seek bar and no duration** for that
|
||||
watch. The stream is chunked with no `Content-Length`, so the client cannot seek
|
||||
even after the mux lands mid-play; it has to re-request, which happens on the next
|
||||
play. Every subsequent play of that video is perfect and instant. This is a real
|
||||
regression against a *hypothetical* fast first play, and a large improvement over
|
||||
an error.
|
||||
|
||||
### A second bug found while fixing the first
|
||||
|
||||
The streaming loop waited on `sess.complete` (finished **and** good) rather than
|
||||
`sess.final` (finished). A producer that died after writing some bytes therefore
|
||||
never satisfied the wait: the loop sat in `_wait_for_bytes` for the full 45 s
|
||||
`STALL_TIMEOUT` and then dropped the connection. Now split into `finished` for every
|
||||
wait and `sess.complete` only for the ranges decision. Caught by a new test, not by
|
||||
inspection.
|
||||
|
||||
### Also corrected
|
||||
|
||||
The too-old-yt-dlp warning pointed at `/var/lib/youtube-automate/venv/bin` — the tree
|
||||
§12 decommissions. It now names ytstream's venv, and says the unit pins PATH so a
|
||||
manual run has to as well. That warning is what a future debugger reads at 2am.
|
||||
|
||||
The `/healthz` payload gained `mode` (`wait-then-stream` / `growing`) and
|
||||
`first_byte_grace_s`, because the difference between "plays" and "playback error" was
|
||||
invisible without reading the unit file.
|
||||
|
||||
Eight new tests in `test_proxy.py` cover: a slow mux streaming instead of blocking, a
|
||||
fast mux keeping ranges and seeking, `--first-byte-grace` at `--wait-timeout`
|
||||
restoring strict mode, a failed producer with bytes being served but 502 in strict
|
||||
mode, a failed producer with no bytes always 502, `--growing` meaning no wait, and
|
||||
`/healthz` reporting the mode. 353 pass.
|
||||
|
||||
Reference in New Issue
Block a user