unarr/internal/engine/stream_growing_test.go
Deivid Soto 3fcfaaf234 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.
2026-06-10 22:37:02 +02:00

222 lines
7.1 KiB
Go

package engine
import (
"io"
"net/http"
"net/http/httptest"
"testing"
)
// fakeGrowing is a GrowingSource backed by a fixed byte slice. When final is
// true it behaves like a completed remux (ReadAt returns io.EOF at the end);
// est overrides the advertised estimate (0 = use len(data)).
type fakeGrowing struct {
data []byte
final bool
est int64
}
func (f *fakeGrowing) ReadAt(p []byte, off int64) (int, error) {
if off < 0 || off >= int64(len(f.data)) {
return 0, io.EOF
}
n := copy(p, f.data[off:])
if int(off)+n >= len(f.data) {
return n, io.EOF
}
return n, nil
}
func (f *fakeGrowing) Size() int64 { return int64(len(f.data)) }
func (f *fakeGrowing) Final() bool { return f.final }
func (f *fakeGrowing) EstimatedSize() int64 {
if f.est > 0 {
return f.est
}
return int64(len(f.data))
}
func (f *fakeGrowing) FileName() string { return "movie.mp4" }
func (f *fakeGrowing) Close() error { return nil }
func TestParseByteRange(t *testing.T) {
cases := []struct {
in string
start, end int64
}{
{"", 0, -1},
{"bytes=0-", 0, -1},
{"bytes=100-", 100, -1},
{"bytes=5-9", 5, 9},
{"bytes=0-0", 0, 0},
{"bytes=10-19,40-49", 10, 19}, // first range only
{"bytes=-500", 0, -1}, // suffix unsupported → open from 0
{"garbage", 0, -1},
{"bytes=", 0, -1},
}
for _, c := range cases {
s, e := parseByteRange(c.in)
if s != c.start || e != c.end {
t.Errorf("parseByteRange(%q) = (%d,%d), want (%d,%d)", c.in, s, e, c.start, c.end)
}
}
}
func TestServeGrowing_FinalFullRequest(t *testing.T) {
data := []byte("0123456789abcdef")
src := &fakeGrowing{data: data, final: true}
ss := &StreamServer{}
req := httptest.NewRequest(http.MethodGet, "/stream", nil)
rec := httptest.NewRecorder()
ss.serveGrowing(rec, req, src)
res := rec.Result()
if res.StatusCode != http.StatusPartialContent {
t.Fatalf("status = %d, want 206", res.StatusCode)
}
if got := res.Header.Get("Content-Range"); got != "bytes 0-15/16" {
t.Errorf("Content-Range = %q, want bytes 0-15/16", got)
}
if got := res.Header.Get("Accept-Ranges"); got != "bytes" {
t.Errorf("Accept-Ranges = %q, want bytes", got)
}
if got := res.Header.Get("Content-Type"); got != "video/mp4" {
t.Errorf("Content-Type = %q, want video/mp4", got)
}
// Final + open-ended → exact Content-Length.
if got := res.Header.Get("Content-Length"); got != "16" {
t.Errorf("Content-Length = %q, want 16", got)
}
if body := rec.Body.String(); body != string(data) {
t.Errorf("body = %q, want %q", body, string(data))
}
}
func TestServeGrowing_OffsetRange(t *testing.T) {
data := []byte("0123456789abcdef")
src := &fakeGrowing{data: data, final: true}
ss := &StreamServer{}
req := httptest.NewRequest(http.MethodGet, "/stream", nil)
req.Header.Set("Range", "bytes=10-")
rec := httptest.NewRecorder()
ss.serveGrowing(rec, req, src)
res := rec.Result()
if res.StatusCode != http.StatusPartialContent {
t.Fatalf("status = %d, want 206", res.StatusCode)
}
if got := res.Header.Get("Content-Range"); got != "bytes 10-15/16" {
t.Errorf("Content-Range = %q, want bytes 10-15/16", got)
}
if body := rec.Body.String(); body != "abcdef" {
t.Errorf("body = %q, want abcdef", body)
}
}
func TestServeGrowing_BoundedRange(t *testing.T) {
data := []byte("0123456789abcdef")
src := &fakeGrowing{data: data, final: true}
ss := &StreamServer{}
req := httptest.NewRequest(http.MethodGet, "/stream", nil)
req.Header.Set("Range", "bytes=5-9")
rec := httptest.NewRecorder()
ss.serveGrowing(rec, req, src)
res := rec.Result()
if res.StatusCode != http.StatusPartialContent {
t.Fatalf("status = %d, want 206", res.StatusCode)
}
if got := res.Header.Get("Content-Range"); got != "bytes 5-9/16" {
t.Errorf("Content-Range = %q, want bytes 5-9/16", got)
}
if body := rec.Body.String(); body != "56789" {
t.Errorf("body = %q, want 56789 (exactly the requested 5 bytes)", body)
}
}
func TestServeGrowing_EstimateUsedWhileNotFinal(t *testing.T) {
// Not final: only 8 bytes produced, estimate says 100. We advertise the
// estimate as the total — iOS/WebKit refuses to play a <video src> whose
// "bytes=0-1" probe comes back without a concrete instance length, so "/*"
// (unknown total) is not an option. The estimate need not be byte-exact;
// 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}
ss := &StreamServer{}
req := httptest.NewRequest(http.MethodGet, "/stream", nil)
rec := httptest.NewRecorder()
ss.serveGrowing(rec, req, src)
res := rec.Result()
if res.StatusCode != http.StatusPartialContent {
t.Fatalf("status = %d, want 206", res.StatusCode)
}
if got := res.Header.Get("Content-Range"); got != "bytes 0-99/100" {
t.Errorf("Content-Range = %q, want bytes 0-99/100 (estimate)", got)
}
// Not final → no exact Content-Length (chunked) so we never promise bytes
// a still-running remux might not produce.
if got := res.Header.Get("Content-Length"); got != "" {
t.Errorf("Content-Length = %q, want empty (chunked) while not final", got)
}
if body := rec.Body.String(); body != "01234567" {
t.Errorf("body = %q, want 01234567 (bytes produced so far)", body)
}
}
func TestServeGrowing_HeadProbe(t *testing.T) {
// HEAD: advertise the total (estimate while growing) so iOS gets the size
// it needs from its probe.
src := &fakeGrowing{data: make([]byte, 0), final: false, est: 4242}
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", got)
}
if rec.Body.Len() != 0 {
t.Errorf("HEAD body = %d bytes, want 0", rec.Body.Len())
}
}
func TestServeGrowing_ProbeRangeCarriesTotal(t *testing.T) {
// The iOS "bytes=0-1" probe MUST come back with a concrete instance length
// (bytes 0-1/<total>), or WebKit bails and re-bootstraps the session.
src := &fakeGrowing{data: []byte("0123456789"), final: false, est: 6685677633}
ss := &StreamServer{}
req := httptest.NewRequest(http.MethodGet, "/stream", nil)
req.Header.Set("Range", "bytes=0-1")
rec := httptest.NewRecorder()
ss.serveGrowing(rec, req, src)
if got := rec.Result().Header.Get("Content-Range"); got != "bytes 0-1/6685677633" {
t.Errorf("Content-Range = %q, want bytes 0-1/6685677633 (concrete total for iOS)", got)
}
if body := rec.Body.String(); body != "01" {
t.Errorf("body = %q, want 01 (the 2 probed bytes)", body)
}
}
func TestServeGrowing_RangeBeyondTotal(t *testing.T) {
src := &fakeGrowing{data: []byte("0123456789"), final: true}
ss := &StreamServer{}
req := httptest.NewRequest(http.MethodGet, "/stream", nil)
req.Header.Set("Range", "bytes=999-")
rec := httptest.NewRecorder()
ss.serveGrowing(rec, req, src)
if rec.Result().StatusCode != http.StatusRequestedRangeNotSatisfiable {
t.Errorf("status = %d, want 416", rec.Result().StatusCode)
}
}