fix(stream): no anunciar un total falso mientras el remux crece (loop de re-seek)
serveGrowing anunciaba en Content-Range total = EstimatedSize() = el tamaño del MKV fuente mientras ffmpeg aún corría. Pero el fMP4 resultante no mide eso (el audio re-encodea a AAC y la fragmentación cambian el byte count), así que el <video> nativo mapeaba su timeline sobre una longitud falsa, pedía offsets que no cuadraban, re-seekeaba y reabría la conexión cientos de veces por segundo (el loop de reproducción remux). Mientras crece (!Final) la longitud real es DESCONOCIDA: ahora se sirve Content-Range "bytes start-end/*" (RFC 7233 §4.2) sin Content-Length, y el cliente lee secuencial en vez de re-seekear. Cuando ffmpeg termina, el tamaño real se conoce y se anuncia como antes. El 416 y el Content-Length del HEAD solo cuando el total es real (final).
This commit is contained in:
parent
9ab0763f8a
commit
5f2d1cdc70
2 changed files with 66 additions and 20 deletions
|
|
@ -135,9 +135,12 @@ func TestServeGrowing_BoundedRange(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServeGrowing_EstimateUsedWhileNotFinal(t *testing.T) {
|
func TestServeGrowing_UnknownTotalWhileNotFinal(t *testing.T) {
|
||||||
// Not final: only 8 bytes produced, but estimate says 100. The advertised
|
// Not final: only 8 bytes produced, estimate says 100. The instance length
|
||||||
// total is the estimate (scrubber timeline); body is what exists so far.
|
// is genuinely unknown while the remux grows, so we advertise "/*" (RFC 7233
|
||||||
|
// §4.2) instead of a total the native player would map its timeline onto and
|
||||||
|
// re-seek against (the playback loop). The estimate is only an upper-bound
|
||||||
|
// hint for `end`; 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{}
|
||||||
|
|
||||||
|
|
@ -149,8 +152,8 @@ func TestServeGrowing_EstimateUsedWhileNotFinal(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/100" {
|
if got := res.Header.Get("Content-Range"); got != "bytes 0-99/*" {
|
||||||
t.Errorf("Content-Range = %q, want bytes 0-99/100 (estimate)", got)
|
t.Errorf("Content-Range = %q, want bytes 0-99/* (unknown total)", 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.
|
||||||
|
|
@ -163,6 +166,8 @@ func TestServeGrowing_EstimateUsedWhileNotFinal(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
|
||||||
|
// (advertising the estimate is the bug this fix removes).
|
||||||
src := &fakeGrowing{data: make([]byte, 0), final: false, est: 4242}
|
src := &fakeGrowing{data: make([]byte, 0), final: false, est: 4242}
|
||||||
ss := &StreamServer{}
|
ss := &StreamServer{}
|
||||||
|
|
||||||
|
|
@ -174,14 +179,32 @@ 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 != "4242" {
|
if got := res.Header.Get("Content-Length"); got != "" {
|
||||||
t.Errorf("HEAD Content-Length = %q, want 4242", got)
|
t.Errorf("HEAD Content-Length = %q, want empty (unknown total while growing)", 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) {
|
||||||
|
// HEAD once final: the true total IS known, so advertise it.
|
||||||
|
src := &fakeGrowing{data: make([]byte, 4242), final: true}
|
||||||
|
ss := &StreamServer{}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodHead, "/stream", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
ss.serveGrowing(rec, req, src)
|
||||||
|
|
||||||
|
res := rec.Result()
|
||||||
|
if res.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("HEAD status = %d, want 200", res.StatusCode)
|
||||||
|
}
|
||||||
|
if got := res.Header.Get("Content-Length"); got != "4242" {
|
||||||
|
t.Errorf("HEAD Content-Length = %q, want 4242 (final size known)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestServeGrowing_RangeBeyondTotal(t *testing.T) {
|
func TestServeGrowing_RangeBeyondTotal(t *testing.T) {
|
||||||
src := &fakeGrowing{data: []byte("0123456789"), final: true}
|
src := &fakeGrowing{data: []byte("0123456789"), final: true}
|
||||||
ss := &StreamServer{}
|
ss := &StreamServer{}
|
||||||
|
|
|
||||||
|
|
@ -1467,25 +1467,38 @@ 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()))
|
||||||
|
|
||||||
// Total to advertise: exact when ffmpeg has exited, else the estimate.
|
// The instance length is KNOWN only once ffmpeg has exited. While the remux
|
||||||
total := src.EstimatedSize()
|
// is still growing, the final size is genuinely unknown — the source MKV
|
||||||
if src.Final() {
|
// size is NOT it (the audio re-encode to AAC + fMP4 fragmentation change the
|
||||||
total = src.Size()
|
// byte count). Advertising that wrong total made the native <video> map its
|
||||||
|
// timeline onto a bogus length, request byte offsets that didn't line up,
|
||||||
|
// re-seek, and reopen the connection hundreds of times a second (the remux
|
||||||
|
// playback loop). Per RFC 7233 §4.2 we now send "/*" (unknown total) while
|
||||||
|
// growing, so the player streams sequentially instead of re-seeking against
|
||||||
|
// a fake size. `end` uses the estimate only as an upper-bound hint.
|
||||||
|
final := src.Final()
|
||||||
|
total := src.Size()
|
||||||
|
if !final {
|
||||||
|
total = src.EstimatedSize()
|
||||||
}
|
}
|
||||||
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"))
|
||||||
if total > 0 && start >= total {
|
// A 416 is only sound against a KNOWN total. While growing we can't say a
|
||||||
// Range beyond what we expect to produce — let the browser recover.
|
// start is unsatisfiable (more bytes are still coming), so only guard when
|
||||||
|
// 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 {
|
||||||
if total > 0 {
|
// Only promise a length we actually know (final). While growing, omit it.
|
||||||
|
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)
|
||||||
|
|
@ -1496,14 +1509,24 @@ func (ss *StreamServer) serveGrowing(w http.ResponseWriter, r *http.Request, src
|
||||||
if explicitEnd >= 0 && explicitEnd < end {
|
if explicitEnd >= 0 && explicitEnd < end {
|
||||||
end = explicitEnd
|
end = explicitEnd
|
||||||
}
|
}
|
||||||
|
if 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
|
// Exact Content-Length only when final (true size known) so we never
|
||||||
// we never promise bytes a still-running remux might not produce.
|
// promise bytes a still-running remux might not produce.
|
||||||
if src.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)
|
||||||
|
|
||||||
buf := make([]byte, 256*1024)
|
buf := make([]byte, 256*1024)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue