streaming

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

commit 1c6017bc874091eb87e3509f767454b5f5c5b168
parent 2d91eff9749102b83cc82919f67c016e44096676
Author: Oliver Lowe <o@olowe.co>
Date:   Fri, 31 May 2024 17:41:35 +1000

m3u8: extract, add more validation for writing Variants

Encode() was getting pretty big. We also use append() rather than
continually calling fmt.Fprint to avoid a bunch of error handling.
Validation involves checking that required fields of Variant are set,
and ensuring we round frame rate to the precision specified in RFC
8216.

Resolves https://github.com/untangledco/streaming/issues/9

Diffstat:
Mm3u8/write.go | 75++++++++++++++++++++++++++++++++++++++++++---------------------------------
Am3u8/write_test.go | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 106 insertions(+), 33 deletions(-)

diff --git a/m3u8/write.go b/m3u8/write.go @@ -84,40 +84,10 @@ func Encode(w io.Writer, p *Playlist) error { fmt.Fprintln(w) } - for _, v := range p.Variants { - fmt.Fprint(w, tagVariant+":") - if v.Bandwidth > 0 { - fmt.Fprintf(w, "BANDWIDTH=%d,", v.Bandwidth) + for i, v := range p.Variants { + if _, err := writeVariant(w, &v); err != nil { + return fmt.Errorf("write variant %d: %w", i, err) } - if v.AverageBandwidth > 0 { - fmt.Fprintf(w, "AVERAGE-BANDWIDTH=%d,", v.AverageBandwidth) - } - if len(v.Codecs) > 0 { - fmt.Fprintf(w, "CODECS=%q,", strings.Join(v.Codecs, ",")) - } - if v.Resolution != [2]int{0, 0} { - fmt.Fprintf(w, "RESOLUTION=%dx%d,", v.Resolution[0], v.Resolution[1]) - } - if v.FrameRate > 0 { - fmt.Fprintf(w, "FRAME-RATE=%f,", v.FrameRate) - } - if v.HDCP != HDCPNone { - fmt.Fprintf(w, "HDCP-LEVEL=%s,", v.HDCP) - } - if v.Audio != "" { - fmt.Fprintf(w, "AUDIO=%q,", v.Audio) - } - if v.Video != "" { - fmt.Fprintf(w, "VIDEO=%q,", v.Video) - } - if v.Subtitles != "" { - fmt.Fprintf(w, "SUBTITLES=%q,", v.Subtitles) - } - if v.ClosedCaptions != "" && v.ClosedCaptions != NoClosedCaptions { - fmt.Fprintf(w, "CLOSED-CAPTIONS=%q,", v.ClosedCaptions) - } - fmt.Fprintln(w) - fmt.Fprintln(w, v.URI) } for i, sd := range p.SessionData { @@ -132,6 +102,45 @@ func Encode(w io.Writer, p *Playlist) error { return nil } +func writeVariant(w io.Writer, v *Variant) (n int, err error) { + var attrs []string + if v.Bandwidth <= 0 { + return 0, fmt.Errorf("invalid bandwidth %d: must be larger than zero", v.Bandwidth) + } + attrs = append(attrs, fmt.Sprintf("BANDWIDTH=%d", v.Bandwidth)) + if v.AverageBandwidth > 0 { + attrs = append(attrs, fmt.Sprintf("AVERAGE-BANDWIDTH=%d,", v.AverageBandwidth)) + } + if len(v.Codecs) > 0 { + attrs = append(attrs, fmt.Sprintf("CODECS=%q", strings.Join(v.Codecs, ","))) + } + if v.Resolution != [2]int{0, 0} { + attrs = append(attrs, fmt.Sprintf("RESOLUTION=%dx%d", v.Resolution[0], v.Resolution[1])) + } + if v.FrameRate > 0 { + attrs = append(attrs, fmt.Sprintf("FRAME-RATE=%.03f", v.FrameRate)) + } + if v.HDCP != HDCPNone { + attrs = append(attrs, fmt.Sprintf("HDCP-LEVEL=%s", v.HDCP)) + } + if v.Audio != "" { + attrs = append(attrs, fmt.Sprintf("AUDIO=%q", v.Audio)) + } + if v.Video != "" { + attrs = append(attrs, fmt.Sprintf("VIDEO=%q", v.Video)) + } + if v.Subtitles != "" { + attrs = append(attrs, fmt.Sprintf("SUBTITLES=%q", v.Subtitles)) + } + if v.ClosedCaptions != "" && v.ClosedCaptions != NoClosedCaptions { + attrs = append(attrs, fmt.Sprintf("CLOSED-CAPTIONS=%q", v.ClosedCaptions)) + } + if v.URI == "" { + return 0, fmt.Errorf("empty URI") + } + return fmt.Fprintf(w, "%s:%s\n%s\n", tagVariant, strings.Join(attrs, ","), v.URI) +} + func writeDateRange(w io.Writer, dr *DateRange) error { fmt.Fprint(w, tagDateRange+":") var attrs []string diff --git a/m3u8/write_test.go b/m3u8/write_test.go @@ -0,0 +1,64 @@ +package m3u8 + +import ( + "bytes" + "strings" + "testing" +) + +func TestWriteVariant(t *testing.T) { + var cases = []struct { + name string + v Variant + want string + valid bool + }{ + { + "simple", + Variant{ + URI: "url_0/193039199_mp4_h264_aac_hd_7.m3u8", + Bandwidth: 2149280, + Codecs: []string{"mp4a.40.2", "avc1.64001f"}, + Resolution: [2]int{1280, 720}, + }, + `#EXT-X-STREAM-INF:BANDWIDTH=2149280,CODECS="mp4a.40.2,avc1.64001f",RESOLUTION=1280x720 +url_0/193039199_mp4_h264_aac_hd_7.m3u8`, + true, + }, + { + "rounded frame rate", + Variant{ + URI: "small.m3u8", + Bandwidth: 10000, + FrameRate: 60 / 1.001, // busted NTSC + }, + `#EXT-X-STREAM-INF:BANDWIDTH=10000,FRAME-RATE=59.940 +small.m3u8`, + true, + }, + { + "no bandwidth", + Variant{URI: "url_0/193039199_mp4_h264_aac_hd_7.m3u8"}, + "", + false, + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + buf := &bytes.Buffer{} + _, err := writeVariant(buf, &tt.v) + if err != nil && tt.valid { + t.Fatalf("non-nil error for valid variant %v: %v", tt.v, err) + } else if !tt.valid && err == nil { + t.Fatalf("nil error for invalid variant %v: %v", tt.v, err) + } + got := strings.TrimSpace(buf.String()) // trim newline + if got != tt.want { + t.Errorf("unexpected variant text") + t.Logf("got: %s", got) + t.Logf("want: %s", tt.want) + } + }) + } +}