streaming

Media streaming and broadcast systems in Go
Log | Files | Refs | README | LICENSE

commit 95f3dab6a9cc691761cc0b2ce737533ea17f02fb
parent baaea663292b3165b7b67c663676c2f8798c7ce5
Author: Oliver Lowe <o@olowe.co>
Date:   Tue, 28 May 2024 16:39:44 +1000

m3u8: factor, test segment encoding separately

To make it easier to add new test cases. The Encode() function was
getting pretty big, too.

Diffstat:
Mm3u8/m3u8.go | 2++
Mm3u8/segment.go | 37+++++++++++++++++++++++++++++++++++++
Am3u8/segment_test.go | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mm3u8/write.go | 28+++-------------------------
Dm3u8/write_test.go | 34----------------------------------
5 files changed, 137 insertions(+), 59 deletions(-)

diff --git a/m3u8/m3u8.go b/m3u8/m3u8.go @@ -74,6 +74,8 @@ type Key struct { IV [16]byte } +const defaultKeyFormat string = "identity" + func (k Key) String() string { var attrs []string attrs = append(attrs, fmt.Sprintf("METHOD=%s", k.Method)) diff --git a/m3u8/segment.go b/m3u8/segment.go @@ -3,6 +3,7 @@ package m3u8 import ( "errors" "fmt" + "io" "strconv" "strings" "time" @@ -117,3 +118,39 @@ func parseSegmentDuration(it item) (time.Duration, error) { microseconds := seconds * 1e6 return time.Duration(microseconds) * time.Microsecond, nil } + +func writeSegments(w io.Writer, segments []Segment) (n int, err error) { + for _, seg := range segments { + if seg.URI == "" { + return 0, fmt.Errorf("empty URI") + } + if seg.Duration == 0 { + return 0, fmt.Errorf("zero duration") + } + if seg.Discontinuity { + fmt.Fprintln(w, tagDiscontinuity) + } + if seg.DateRange != nil { + if err := writeDateRange(w, seg.DateRange); err != nil { + return 0, fmt.Errorf("write date range: %w", err) + } + } + if seg.Range != [2]int{0, 0} { + fmt.Fprintf(w, "%s:%s\n", tagByteRange, seg.Range) + } + if seg.Key != nil { + fmt.Fprintf(w, "%s:%s\n", tagKey, seg.Key) + } + if seg.Map != nil { + writeMap(w, *seg.Map) + } + if !seg.DateTime.IsZero() { + fmt.Fprintf(w, "%s:%s\n", tagDateTime, seg.DateTime.Format(RFC3339Milli)) + } + us := seg.Duration / time.Microsecond + // we do .03f for the same precision as test-streams.mux.dev. + fmt.Fprintf(w, "%s:%.03f\n", tagSegmentDuration, float32(us)/1e6) + fmt.Fprintln(w, seg.URI) + } + return 0, nil +} diff --git a/m3u8/segment_test.go b/m3u8/segment_test.go @@ -0,0 +1,95 @@ +package m3u8 + +import ( + "bytes" + "encoding/binary" + "io" + "testing" + "time" +) + +func TestWriteSegments(t *testing.T) { + var cases = []struct { + name string + seg Segment + out string + }{ + { + "duration", + Segment{Duration: 10 * time.Second, URI: "bunny.ts"}, + "#EXTINF:10.000\nbunny.ts\n", + }, + { + "duration milliseconds", + Segment{URI: "something.ts", Duration: 9967 * time.Millisecond}, + "#EXTINF:9.967\nsomething.ts\n", + }, + { + "discontinuity with URI", + Segment{ + Duration: 30 * time.Second, + Discontinuity: true, + URI: "adbreak.ts", + }, + "#EXT-X-DISCONTINUITY\n#EXTINF:30.000\nadbreak.ts\n", + }, + { + "byte range", + Segment{ + Duration: 2 * time.Second, + URI: "vid.ts", + Range: ByteRange{69, 420}, + }, + "#EXT-X-BYTERANGE:69@420\n#EXTINF:2.000\nvid.ts\n", + }, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + buf := &bytes.Buffer{} + if _, err := writeSegments(buf, []Segment{tt.seg}); err != nil { + t.Fatal(err) + } + if buf.String() != tt.out { + t.Errorf("segment text does not match expected") + t.Log("got:", buf.String()) + t.Log("want:", tt.out) + } + }) + } +} + +func TestWriteBadSegments(t *testing.T) { + var cases = []struct { + name string + seg Segment + }{ + {"empty", Segment{}}, + {"no duration", Segment{URI: "video.ts"}}, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + if _, err := writeSegments(io.Discard, []Segment{tt.seg}); err == nil { + t.Fatalf("nil error encoding invalid segment") + } + }) + } +} + +func TestWriteKey(t *testing.T) { + var iv [16]byte + binary.LittleEndian.PutUint64(iv[:8], 10000) + binary.LittleEndian.PutUint64(iv[8:], 98765432) + k := Key{ + Method: EncryptMethodAES128, + URI: "magic.key", + IV: iv, + Format: defaultKeyFormat, + FormatVersions: []uint32{1, 2, 5}, + } + want := `METHOD=AES-128,URI="magic.key",IV=0x1027000000000000780ae30500000000,KEYFORMAT="identity",KEYFORMATVERSIONS="1/2/5"` + if k.String() != want { + t.Errorf("unexpected segment key text") + t.Log("got:", k.String()) + t.Log("want:", want) + } +} diff --git a/m3u8/write.go b/m3u8/write.go @@ -25,31 +25,9 @@ func Encode(w io.Writer, p *Playlist) error { fmt.Fprintf(w, "%s:%d\n", tagTargetDuration, p.TargetDuration/time.Second) } fmt.Fprintf(w, "%s:%d\n", tagMediaSequence, p.Sequence) - for _, seg := range p.Segments { - if seg.Discontinuity { - fmt.Fprintln(w, tagDiscontinuity) - } - if seg.DateRange != nil { - if err := writeDateRange(w, seg.DateRange); err != nil { - return fmt.Errorf("write date range: %w", err) - } - } - if seg.Range != [2]int{0, 0} { - fmt.Fprintf(w, "%s:%s\n", tagByteRange, seg.Range) - } - if seg.Key != nil { - fmt.Fprintf(w, "%s:%s\n", tagKey, seg.Key) - } - if seg.Map != nil { - writeMap(w, *seg.Map) - } - if !seg.DateTime.IsZero() { - fmt.Fprintf(w, "%s:%s\n", tagDateTime, seg.DateTime.Format(RFC3339Milli)) - } - us := seg.Duration / time.Microsecond - // we do .03f for the same precision as test-streams.mux.dev. - fmt.Fprintf(w, "%s:%.03f\n", tagSegmentDuration, float32(us)/1e6) - fmt.Fprintln(w, seg.URI) + + if _, err := writeSegments(w, p.Segments); err != nil { + return fmt.Errorf("write segments: %w", err) } for _, r := range p.Media { diff --git a/m3u8/write_test.go b/m3u8/write_test.go @@ -1,34 +0,0 @@ -package m3u8 - -import ( - "bufio" - "bytes" - "testing" - "time" -) - -func TestEncodeSegDuration(t *testing.T) { - plist := &Playlist{ - Version: 7, - Segments: []Segment{{Duration: 9967 * time.Millisecond}}, - } - buf := &bytes.Buffer{} - if err := Encode(buf, plist); err != nil { - t.Fatal(err) - } - sc := bufio.NewScanner(buf) - var linenum = 1 - var found bool - want := "#EXTINF:9.967" - for sc.Scan() { - t.Log(sc.Text()) - if sc.Text() == want { - found = true - return - } - linenum++ - } - if !found { - t.Errorf("no matching segment duration %s", want) - } -}