Never serve a partial file: it makes Jellyfin transcode

The 12s first-byte grace was the wrong trade and a real play found it
within the hour. A 2-hour upload took 3 minutes to start, played 6
seconds, and stalled. Jellyfin had run ffmpeg with -probesize 1G against
the growing stream and then transcoded to HLS with libx264.

The cause is the container. A fragmented MP4 with empty_moov has no
duration in its header, so the only way to get one is to sum every
fragment -- probing a growing file reads all of it. Jellyfin cannot
establish duration, codec or bitrate, so it abandons direct play and
transcodes a stream it also cannot seek. It was targeting 4.83 Mbps
against a source measured at 3.29: re-encoding a stream that already fit,
because it could not measure it.

The same video once complete reports SupportsDirectPlay with the exact
runtime and bitrate.

So FIRST_BYTE_GRACE defaults to infinite again, with --wait-timeout raised
to 600s for a 2-hour upload. A cold long video is slow to start, which is
accepted: the fetch outlives the request so a retry is instant, and a
retryable stall beats a transcode that wastes a gigabyte and cannot work.
Both failure modes are recorded at the constant in the order measured so
the 12s cap is not reintroduced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-08-13 11:32:12 +01:00
parent c12837cbb9
commit 22d8828080
10 changed files with 514 additions and 46 deletions
+74
View File
@@ -1272,3 +1272,77 @@ he subscribes to something and nothing happens, which is the feature not working
Reversible per channel with `ytstream unsubscribe`.
`approve --all`: **108 added, 0 failed, 70 s.**
## 22. Serving a partial file to Jellyfin is worse than making it wait — 2026-08-13
§19 capped the wait for a complete mux at 12s and streamed the partial file after
that, to stop long videos failing to start. That was the wrong trade, and a real
play found it within the hour: a 2-hour upload took **3 minutes to start, played 6
seconds, and stalled permanently**.
### What Jellyfin actually did
```
ffmpeg -analyzeduration 200M -probesize 1G -i http://127.0.0.1:8099/watch/JYZYnkXMxdU
-codec:v:0 libx264 -preset veryfast -maxrate 4830438 ... -f hls
```
It **transcoded**, after dragging up to a gigabyte through the proxy to probe. The
Jellyfin log closes it out: `Playback stopped ... Stopped at "6016" ms`.
### Why — and it is the container, not the client
A fragmented MP4 written with `empty_moov` **has no duration in its header**. The
only way to obtain one is to sum every fragment, so probing a *growing* file means
reading all of it — hence `-probesize 1G`. Jellyfin therefore cannot establish
duration, codec or bitrate, stops trusting direct play, and transcodes a stream it
also cannot seek.
The same video once complete, via `PlaybackInfo`:
| | |
|---|---|
| `SupportsDirectPlay` | **True** |
| `RunTimeTicks` | 1278 s (database says 1279) |
| `Bitrate` | 3,290,533 |
| Streams | h264 720p 3.16 Mbps + aac 128 kbps |
The transcode had been targeting `-maxrate 4830438` — **4.83 Mbps, above the
source's 3.29**. Jellyfin was re-encoding a stream that already fitted, purely
because it could not measure it. ffmpeg patches the real duration into the moov on
close, which is what makes the complete file cheap to probe and safe to direct-play.
### The decision
`FIRST_BYTE_GRACE` now defaults to **infinite** — wait for the complete mux,
bounded by `--wait-timeout` (raised to 600s to cover a 2-hour, 2.4 GB upload). A
cold long video is slow to start, and that is accepted deliberately:
* the fetch outlives the request, so a **retry is instant**;
* the failure is a stall the user can retry, not a runaway transcode that wastes a
gigabyte of transfer and cannot succeed.
Both failure modes are now recorded at the constant, in the order they were
measured, so nobody re-tries the 12s cap and rediscovers this. `/healthz` reports
`mode: wait-for-complete` and `first_byte_grace_s: null` — `null` rather than a
number because `json.dumps` renders an infinite float as `Infinity`, which is not
valid JSON.
### Still unresolved
**A cold long video does not start on the first press.** The grace was the wrong
fix for it, and the right one is not in yet. The options, none free:
1. **Pre-warm** the newest episode per channel after each run — turns the common
case ("watch the latest") instant, at the cost of fetching videos nobody asked
for, which is the premise the whole design rejects. Bounded, though: 119 videos.
2. **Faster pull.** `yt-dlp -o -` uses a single connection; measured 4.3 MB/s on a
525 MB upload. If concurrent ranged fetches work on these formats, a 2-minute
wait could become 20 seconds and the problem mostly disappears.
3. **Give Jellyfin the metadata up front** so it never probes: duration, codec and
bitrate are all known before a byte is fetched. `<streamdetails>` in the NFO was
measured not to affect scan probing, but its effect on `PlaybackInfo`
specifically has not been tested — and that is the one that decides transcoding.
Option 2 is the one to measure first: it is the only one that costs nothing and
helps every case.