Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
12 kB · 341 lines
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342package livehls
import ( "bytes" "fmt" "strings" "testing" "time"
"stream.place/streamplace/pkg/muxl")
func initEvent() *muxl.MuxlEvent { return &muxl.MuxlEvent{ Type: "init", Data: []byte("INIT"), // 4 bytes — stands in for ftyp+moov TrackInits: map[string][]byte{"1": []byte("VINIT"), "2": []byte("AINIT")}, Catalog: &muxl.MuxlCatalog{ Video: &muxl.MuxlCatalogVideo{Renditions: map[string]muxl.MuxlVideoConfig{ "v": {Codec: "avc1.640028", Container: muxl.MuxlContainer{Kind: "cmaf", Timescale: 90000, TrackID: 1}, CodedWidth: 1280, CodedHeight: 720}, }}, Audio: &muxl.MuxlCatalogAudio{Renditions: map[string]muxl.MuxlAudioConfig{ "a": {Codec: "mp4a.40.2", Container: muxl.MuxlContainer{Kind: "cmaf", Timescale: 48000, TrackID: 2}, SampleRate: 48000, NumberOfChannels: 2}, }}, }, }}
func segEvent(v, a []byte) *muxl.MuxlEvent { return &muxl.MuxlEvent{ Type: "segment", Tracks: map[string][]byte{"1": v, "2": a}, Durations: map[string]uint64{"1": 90000, "2": 48000}, // 1s each SampleCounts: map[string]uint32{"1": 30, "2": 50}, }}
func segURI(seq uint64) string { return fmt.Sprintf("seg%d.m4s", seq) }
func TestWriterStoresSegmentsInMemory(t *testing.T) { w := NewWriter() if err := w.Observe(initEvent()); err != nil { t.Fatal(err) } v1 := bytes.Repeat([]byte{0xAA}, 100) a1 := bytes.Repeat([]byte{0xBB}, 40) v2 := bytes.Repeat([]byte{0xCC}, 110) a2 := bytes.Repeat([]byte{0xDD}, 42) if err := w.Observe(segEvent(v1, a1)); err != nil { t.Fatal(err) } if err := w.Observe(segEvent(v2, a2)); err != nil { t.Fatal(err) }
// Track 1 (video): two segments, media sequences 0 and 1. tr1 := w.Track("1") if tr1.Type != "video" || tr1.Width != 1280 || tr1.Height != 720 { t.Errorf("track1 config wrong: %+v", tr1) } if len(tr1.Segments) != 2 { t.Fatalf("track1 should have 2 segments, got %d", len(tr1.Segments)) } if tr1.Segments[0].Seq != 0 || tr1.Segments[0].Size() != 100 || tr1.Segments[0].DurationTicks != 90000 || tr1.Segments[0].SampleCount != 30 { t.Errorf("track1 seg0 wrong: %+v", tr1.Segments[0]) } if tr1.Segments[1].Seq != 1 || tr1.Segments[1].Size() != 110 { t.Errorf("track1 seg1 wrong: %+v", tr1.Segments[1]) } // Segment bytes are served verbatim from memory, addressed by media seq. if !bytes.Equal(w.SegmentData("1", 0), v1) || !bytes.Equal(w.SegmentData("1", 1), v2) { t.Errorf("track1 SegmentData did not return the verbatim segment bytes") } if string(w.InitSegment("1")) != "VINIT" { t.Errorf("track1 init = %q, want VINIT", w.InitSegment("1")) }
// Track 2 (audio). tr2 := w.Track("2") if tr2.Type != "audio" || tr2.SampleRate != 48000 || tr2.Channels != 2 { t.Errorf("track2 config wrong: %+v", tr2) } if !bytes.Equal(w.SegmentData("2", 0), a1) || !bytes.Equal(w.SegmentData("2", 1), a2) { t.Errorf("track2 SegmentData did not return the verbatim segment bytes") }}
func TestMediaPlaylistLiveThenFinalize(t *testing.T) { w := NewWriter() _ = w.Observe(initEvent()) _ = w.Observe(segEvent(bytes.Repeat([]byte{1}, 100), bytes.Repeat([]byte{2}, 40)))
pl := w.MediaPlaylist("1", "init1.mp4", segURI) for _, want := range []string{ `#EXT-X-MAP:URI="init1.mp4"`, "#EXT-X-MEDIA-SEQUENCE:0", "#EXTINF:1.000000,", "seg0.m4s", } { if !strings.Contains(pl, want) { t.Errorf("live playlist missing %q:\n%s", want, pl) } } if strings.Contains(pl, "#EXT-X-ENDLIST") { t.Error("a live playlist must not carry EXT-X-ENDLIST") }
w.Finalize() pl = w.MediaPlaylist("1", "init1.mp4", segURI) if !strings.Contains(pl, "#EXT-X-ENDLIST") || !strings.Contains(pl, "#EXT-X-PLAYLIST-TYPE:VOD") { t.Errorf("finalized playlist must be a VOD playlist with ENDLIST:\n%s", pl) }}
func TestSlidingWindowEvictsAndAdvancesMediaSequence(t *testing.T) { w := NewWriter(WithWindow(2)) _ = w.Observe(initEvent()) for i := 0; i < 4; i++ { _ = w.Observe(segEvent([]byte{byte(i)}, []byte{byte(i)})) } tr := w.Track("1") if len(tr.Segments) != 2 { t.Fatalf("window=2 should advertise 2 segments, got %d", len(tr.Segments)) } // 4 emitted, 2 advertised → media sequences 2 and 3 are listed. if tr.Segments[0].Seq != 2 || tr.Segments[1].Seq != 3 { t.Errorf("expected advertised seqs [2,3], got [%d,%d]", tr.Segments[0].Seq, tr.Segments[1].Seq) } pl := w.MediaPlaylist("1", "i", segURI) if !strings.Contains(pl, "#EXT-X-MEDIA-SEQUENCE:2") || strings.Contains(pl, "seg1.m4s") { t.Errorf("expected the playlist to start at seq 2 and not list seq 1:\n%s", pl) } // A segment that has left the playlist is still served for a while (a // player a little behind asks for it), up to a playlist's worth of grace. if w.SegmentData("1", 0) == nil || w.SegmentData("1", 1) == nil { t.Errorf("segments just off the playlist must still be served") } if w.SegmentData("1", 3) == nil { t.Errorf("segment 3 should still be retained") } for i := 4; i < 8; i++ { _ = w.Observe(segEvent([]byte{byte(i)}, []byte{byte(i)})) } if w.SegmentData("1", 0) != nil || w.SegmentData("1", 3) != nil { t.Errorf("beyond the grace (2×window) segments are dropped") } if w.SegmentData("1", 4) == nil { t.Errorf("segment 4 is within the grace and still served") }}
// Signed segments shorter than the minimum fragment are joined into one// fragment, advertised only once it spans the minimum; the join is a byte// append and the fragment carries the summed timing.func TestMinFragmentJoinsTinySegments(t *testing.T) { frame := func(b byte) *muxl.MuxlEvent { ev := segEvent([]byte{b}, []byte{b}) ev.Durations = map[string]uint64{"1": 3000, "2": 1600} // one frame at 30fps ev.SampleCounts = map[string]uint32{"1": 1, "2": 1} return ev } w := NewWriter(WithWindow(12), WithMinFragment(500*time.Millisecond)) _ = w.Observe(initEvent()) _ = w.Observe(segEvent([]byte("A"), []byte("a"))) // 1s: a fragment on its own for i := 0; i < 6; i++ { _ = w.Observe(frame(byte('0' + i))) // 0.2s so far: open, not advertised } if got := len(w.Track("1").Segments); got != 1 { t.Fatalf("an open fragment must not be advertised: want 1 listed, got %d", got) } if w.SegmentData("1", 1) != nil { t.Errorf("an open fragment must not be served") } _ = w.Observe(segEvent([]byte("B"), []byte("b"))) // joins: 1.2s, closes tr := w.Track("1") if len(tr.Segments) != 2 { t.Fatalf("want 2 fragments advertised, got %d", len(tr.Segments)) } f := tr.Segments[1] if f.Seq != 1 || f.SampleCount != 36 || f.DurationTicks != 6*3000+90000 { t.Errorf("fragment timing wrong: seq=%d samples=%d ticks=%d", f.Seq, f.SampleCount, f.DurationTicks) } if got := string(w.SegmentData("1", 1)); got != "012345B" { t.Errorf("fragment bytes are the pieces in order, got %q", got) } pl := w.MediaPlaylist("1", "i", segURI) if !strings.Contains(pl, "#EXTINF:1.200000,\nseg1.m4s") || !strings.Contains(pl, "#EXT-X-TARGETDURATION:2") { t.Errorf("playlist should carry the joined fragment:\n%s", pl) } // A stream that ends mid-fragment publishes what it has. _ = w.Observe(frame('x')) w.Finalize() if n := len(w.Track("1").Segments); n != 3 { t.Errorf("finalize closes the open fragment: want 3, got %d", n) } if got := string(w.SegmentData("1", 2)); got != "x" { t.Errorf("closed tail fragment served, got %q", got) }}
func TestRetentionEvictsStaleSegments(t *testing.T) { w := NewWriter(WithWindow(12), WithRetention(30*time.Second)) clk := time.Unix(1000, 0) w.now = func() time.Time { return clk }
if err := w.Observe(initEvent()); err != nil { t.Fatal(err) } _ = w.Observe(segEvent([]byte{1}, []byte{1})) // seq 0 @ t=1000 clk = clk.Add(20 * time.Second) _ = w.Observe(segEvent([]byte{2}, []byte{2})) // seq 1 @ t=1020
// t=1035, cutoff t=1005: seg0 (t=1000) ages out, seg1 (t=1020) survives. clk = time.Unix(1035, 0) if w.Empty() { t.Fatal("window with a fresh segment must not be empty") } tr := w.Track("1") if len(tr.Segments) != 1 || tr.Segments[0].Seq != 1 { t.Fatalf("want only seg1 retained, got %d segments", len(tr.Segments)) } if w.SegmentData("1", 0) != nil { t.Error("aged-out seg0 must not be served") }
// t=1060: both past retention (a stalled/ended stream) → window empties, so // a retrying player stops replaying the stale tail. clk = time.Unix(1060, 0) if !w.Empty() { t.Error("all segments past retention → window must be empty") } if pl := w.MediaPlaylist("1", "i", segURI); strings.Contains(pl, "seg1.m4s") { t.Errorf("aged-out segment must not appear in the playlist:\n%s", pl) }}
func TestMasterPlaylist(t *testing.T) { w := NewWriter() _ = w.Observe(initEvent()) _ = w.Observe(segEvent(bytes.Repeat([]byte{1}, 100), bytes.Repeat([]byte{2}, 40)))
m := w.MasterPlaylist(func(tid string) string { return "track" + tid + ".m3u8" }) for _, want := range []string{ "#EXT-X-MEDIA:TYPE=AUDIO", `GROUP-ID="audio"`, "#EXT-X-STREAM-INF:", "RESOLUTION=1280x720", "track1.m3u8", } { if !strings.Contains(m, want) { t.Errorf("master playlist missing %q:\n%s", want, m) } }}
func TestPrimaryAudioTrackID(t *testing.T) { w := NewWriter() _ = w.Observe(initEvent()) _ = w.Observe(segEvent(bytes.Repeat([]byte{1}, 100), bytes.Repeat([]byte{2}, 40)))
got := w.PrimaryAudioTrackID() if got != "2" { t.Errorf("PrimaryAudioTrackID() = %q, want %q", got, "2") }}
// A run of tiny segments (an encoder keyframing every scene cut) must not// evict whole seconds of media out of a count window: with a duration floor// the window grows past the count until it again spans that much media.func TestMinDurationKeepsWindowFromShrinking(t *testing.T) { short := func(frames uint64) *muxl.MuxlEvent { ev := segEvent([]byte("v"), []byte("a")) ev.Durations = map[string]uint64{"1": 3000 * frames, "2": 1600 * frames} // 1 frame at 30fps ev.SampleCounts = map[string]uint32{"1": uint32(frames), "2": uint32(frames)} return ev } w := NewWriter(WithWindow(4), WithMinDuration(3*time.Second)) _ = w.Observe(initEvent()) for i := 0; i < 4; i++ { _ = w.Observe(segEvent([]byte("v"), []byte("a"))) // 4s of 1s segments } for i := 0; i < 6; i++ { _ = w.Observe(short(1)) // six one-frame segments } tr := w.Track("1") // Count alone would keep the last 4 (four frames, 0.13s of media). The // floor keeps enough whole-second segments for 3s: 3 of them + 6 frames. if len(tr.Segments) != 9 { t.Fatalf("expected 9 segments retained (3 full + 6 frames), got %d", len(tr.Segments)) } if tr.Segments[0].Seq != 1 { t.Errorf("expected the window to start at seq 1, got %d", tr.Segments[0].Seq) } total := 0.0 for _, s := range tr.Segments { total += s.seconds(tr.Timescale) } if total < 3 { t.Errorf("window spans %.2fs, want at least 3s", total) } // Back to full-length segments: the count window applies again. for i := 0; i < 6; i++ { _ = w.Observe(segEvent([]byte("v"), []byte("a"))) } if n := len(w.Track("1").Segments); n != 4 { t.Errorf("expected the count window (4) once whole segments return, got %d", n) } // Without a timescale the floor can't be measured and count applies. w2 := NewWriter(WithWindow(2), WithMinDuration(time.Hour)) for i := 0; i < 5; i++ { _ = w2.Observe(short(1)) } if n := len(w2.Track("1").Segments); n != 2 { t.Errorf("no timescale: expected count window 2, got %d", n) }}
func TestVideoTracks(t *testing.T) { w := NewWriter() ev := initEvent() ev.TrackInits["100"] = []byte("RINIT") ev.Catalog.Video.Renditions["r"] = muxl.MuxlVideoConfig{Codec: "avc1.64001e", Container: muxl.MuxlContainer{Kind: "cmaf", Timescale: 90000, TrackID: 100}, CodedWidth: 640, CodedHeight: 360} if err := w.Observe(ev); err != nil { t.Fatal(err) } seg := segEvent([]byte{1}, []byte{2}) seg.Tracks["100"] = []byte{3} seg.Durations["100"] = 90000 seg.SampleCounts["100"] = 30 if err := w.Observe(seg); err != nil { t.Fatal(err) } got := w.VideoTracks() if len(got) != 2 { t.Fatalf("want 2 video tracks, got %+v", got) } if got[0].ID != "1" || got[0].Height != 720 || got[1].ID != "100" || got[1].Height != 360 || got[1].Codec != "avc1.64001e" { t.Fatalf("unexpected tracks %+v", got) }}