fix(stream): iOS exige total concreto en el Content-Range del remux

iOS/WebKit abre todo <video src> con una sonda "bytes=0-1" y se niega a
reproducir si el 206 no trae una longitud concreta en Content-Range —
"/*" (total desconocido, el fix anterior del loop de re-seek) le hacía
abortar y re-bootstrapear la sesión sin parar.

Vuelve a anunciar siempre un total numérico (exacto si ffmpeg terminó, el
estimado mientras crece). El loop de re-seek real no era el total
anunciado sino el init segment malformado, ya arreglado con +delay_moov
en buildFFmpegArgs. Test nuevo: la sonda 0-1 debe llevar total concreto.
This commit is contained in:
Deivid Soto 2026-06-10 22:37:02 +02:00
parent b3487a22e8
commit 3fcfaaf234
2 changed files with 45 additions and 54 deletions

View file

@ -135,12 +135,13 @@ func TestServeGrowing_BoundedRange(t *testing.T) {
} }
} }
func TestServeGrowing_UnknownTotalWhileNotFinal(t *testing.T) { func TestServeGrowing_EstimateUsedWhileNotFinal(t *testing.T) {
// Not final: only 8 bytes produced, estimate says 100. The instance length // Not final: only 8 bytes produced, estimate says 100. We advertise the
// is genuinely unknown while the remux grows, so we advertise "/*" (RFC 7233 // estimate as the total — iOS/WebKit refuses to play a <video src> whose
// §4.2) instead of a total the native player would map its timeline onto and // "bytes=0-1" probe comes back without a concrete instance length, so "/*"
// re-seek against (the playback loop). The estimate is only an upper-bound // (unknown total) is not an option. The estimate need not be byte-exact;
// hint for `end`; body is what exists so far. // the real re-seek loop was the malformed init segment (fixed by
// +delay_moov), not the advertised total. Body is what exists so far.
src := &fakeGrowing{data: []byte("01234567"), final: false, est: 100} src := &fakeGrowing{data: []byte("01234567"), final: false, est: 100}
ss := &StreamServer{} ss := &StreamServer{}
@ -152,8 +153,8 @@ func TestServeGrowing_UnknownTotalWhileNotFinal(t *testing.T) {
if res.StatusCode != http.StatusPartialContent { if res.StatusCode != http.StatusPartialContent {
t.Fatalf("status = %d, want 206", res.StatusCode) t.Fatalf("status = %d, want 206", res.StatusCode)
} }
if got := res.Header.Get("Content-Range"); got != "bytes 0-99/*" { if got := res.Header.Get("Content-Range"); got != "bytes 0-99/100" {
t.Errorf("Content-Range = %q, want bytes 0-99/* (unknown total)", got) t.Errorf("Content-Range = %q, want bytes 0-99/100 (estimate)", got)
} }
// Not final → no exact Content-Length (chunked) so we never promise bytes // Not final → no exact Content-Length (chunked) so we never promise bytes
// a still-running remux might not produce. // a still-running remux might not produce.
@ -166,8 +167,8 @@ func TestServeGrowing_UnknownTotalWhileNotFinal(t *testing.T) {
} }
func TestServeGrowing_HeadProbe(t *testing.T) { func TestServeGrowing_HeadProbe(t *testing.T) {
// HEAD while growing: total is unknown, so no Content-Length is promised // HEAD: advertise the total (estimate while growing) so iOS gets the size
// (advertising the estimate is the bug this fix removes). // it needs from its probe.
src := &fakeGrowing{data: make([]byte, 0), final: false, est: 4242} src := &fakeGrowing{data: make([]byte, 0), final: false, est: 4242}
ss := &StreamServer{} ss := &StreamServer{}
@ -179,29 +180,30 @@ func TestServeGrowing_HeadProbe(t *testing.T) {
if res.StatusCode != http.StatusOK { if res.StatusCode != http.StatusOK {
t.Fatalf("HEAD status = %d, want 200", res.StatusCode) t.Fatalf("HEAD status = %d, want 200", res.StatusCode)
} }
if got := res.Header.Get("Content-Length"); got != "" { if got := res.Header.Get("Content-Length"); got != "4242" {
t.Errorf("HEAD Content-Length = %q, want empty (unknown total while growing)", got) t.Errorf("HEAD Content-Length = %q, want 4242", got)
} }
if rec.Body.Len() != 0 { if rec.Body.Len() != 0 {
t.Errorf("HEAD body = %d bytes, want 0", rec.Body.Len()) t.Errorf("HEAD body = %d bytes, want 0", rec.Body.Len())
} }
} }
func TestServeGrowing_HeadProbeFinal(t *testing.T) { func TestServeGrowing_ProbeRangeCarriesTotal(t *testing.T) {
// HEAD once final: the true total IS known, so advertise it. // The iOS "bytes=0-1" probe MUST come back with a concrete instance length
src := &fakeGrowing{data: make([]byte, 4242), final: true} // (bytes 0-1/<total>), or WebKit bails and re-bootstraps the session.
src := &fakeGrowing{data: []byte("0123456789"), final: false, est: 6685677633}
ss := &StreamServer{} ss := &StreamServer{}
req := httptest.NewRequest(http.MethodHead, "/stream", nil) req := httptest.NewRequest(http.MethodGet, "/stream", nil)
req.Header.Set("Range", "bytes=0-1")
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
ss.serveGrowing(rec, req, src) ss.serveGrowing(rec, req, src)
res := rec.Result() if got := rec.Result().Header.Get("Content-Range"); got != "bytes 0-1/6685677633" {
if res.StatusCode != http.StatusOK { t.Errorf("Content-Range = %q, want bytes 0-1/6685677633 (concrete total for iOS)", got)
t.Fatalf("HEAD status = %d, want 200", res.StatusCode)
} }
if got := res.Header.Get("Content-Length"); got != "4242" { if body := rec.Body.String(); body != "01" {
t.Errorf("HEAD Content-Length = %q, want 4242 (final size known)", got) t.Errorf("body = %q, want 01 (the 2 probed bytes)", body)
} }
} }

View file

@ -1477,38 +1477,34 @@ func (ss *StreamServer) serveGrowing(w http.ResponseWriter, r *http.Request, src
w.Header().Set("Content-Type", "video/mp4") w.Header().Set("Content-Type", "video/mp4")
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", src.FileName())) w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", src.FileName()))
// The instance length is KNOWN only once ffmpeg has exited. While the remux // Total to advertise. iOS/WebKit opens every <video src> with a tiny
// is still growing, the final size is genuinely unknown — the source MKV // "bytes=0-1" probe and REFUSES to play unless that 206 carries a concrete
// size is NOT it (the audio re-encode to AAC + fMP4 fragmentation change the // instance length in Content-Range (bytes 0-1/<total>); "/*" (unknown total)
// byte count). Advertising that wrong total made the native <video> map its // makes it bail and re-bootstrap the session forever. So we always advertise
// timeline onto a bogus length, request byte offsets that didn't line up, // a numeric total: the exact size once ffmpeg has exited, the estimate while
// re-seek, and reopen the connection hundreds of times a second (the remux // still growing. The estimate isn't the byte-exact final size (the audio
// playback loop). Per RFC 7233 §4.2 we now send "/*" (unknown total) while // re-encode + fMP4 fragmentation shift it), but that's fine — the real
// growing, so the player streams sequentially instead of re-seeking against // re-seek loop on a growing source was the malformed init segment, fixed by
// a fake size. `end` uses the estimate only as an upper-bound hint. // the encoder's +delay_moov (see buildFFmpegArgs), NOT the advertised total.
final := src.Final() final := src.Final()
total := src.Size() total := src.EstimatedSize()
if !final { if final {
total = src.EstimatedSize() total = src.Size()
} }
if total <= 0 { if total <= 0 {
total = src.Size() total = src.Size()
} }
start, explicitEnd := parseByteRange(r.Header.Get("Range")) start, explicitEnd := parseByteRange(r.Header.Get("Range"))
// A 416 is only sound against a KNOWN total. While growing we can't say a if total > 0 && start >= total {
// start is unsatisfiable (more bytes are still coming), so only guard when // Range beyond what we expect to produce — let the browser recover.
// final.
if final && total > 0 && start >= total {
// Range beyond the real end — let the browser recover.
w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", total)) w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", total))
http.Error(w, "range not satisfiable", http.StatusRequestedRangeNotSatisfiable) http.Error(w, "range not satisfiable", http.StatusRequestedRangeNotSatisfiable)
return return
} }
if r.Method == http.MethodHead { if r.Method == http.MethodHead {
// Only promise a length we actually know (final). While growing, omit it. if total > 0 {
if final && total > 0 {
w.Header().Set("Content-Length", strconv.FormatInt(total, 10)) w.Header().Set("Content-Length", strconv.FormatInt(total, 10))
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
@ -1522,20 +1518,13 @@ func (ss *StreamServer) serveGrowing(w http.ResponseWriter, r *http.Request, src
if end < start { if end < start {
end = start end = start
} }
if final { if total > 0 {
if total > 0 { w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, total))
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, total)) }
} // Exact Content-Length only when the source is final (true size known) so we
// Exact Content-Length only when final (true size known) so we never // never promise bytes a still-running remux might not produce.
// promise bytes a still-running remux might not produce. if final && explicitEnd < 0 {
if explicitEnd < 0 { w.Header().Set("Content-Length", strconv.FormatInt(src.Size()-start, 10))
w.Header().Set("Content-Length", strconv.FormatInt(src.Size()-start, 10))
}
} else {
// Growing: honest "unknown total" so the player doesn't re-seek against
// a wrong size. No Content-Length (chunked) — bytes flow as ffmpeg makes
// them and the read loop below blocks at the live edge.
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/*", start, end))
} }
w.WriteHeader(http.StatusPartialContent) w.WriteHeader(http.StatusPartialContent)