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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user