diff --git a/m3u8/lex.go b/m3u8/lex.go index bced687..db1a4f3 100644 --- a/m3u8/lex.go +++ b/m3u8/lex.go @@ -243,6 +243,9 @@ func lexAttrs(l *lexer) stateFn { func lexAttrValue(l *lexer) stateFn { r := l.next() + if r == '@' { + r = l.next() + } switch r { case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.', ':': return lexNumber(l) @@ -295,7 +298,6 @@ Loop: return lexAttrs(l) } - func lexQString(l *lexer) stateFn { for { r := l.next() diff --git a/m3u8/m3u8.go b/m3u8/m3u8.go index 1b880fa..854fc16 100644 --- a/m3u8/m3u8.go +++ b/m3u8/m3u8.go @@ -34,6 +34,7 @@ type Playlist struct { // Master playlist Media []Rendition Variants []Variant + Iframes []IFrameInfo SessionData []SessionData SessionKey *Key } diff --git a/m3u8/master.go b/m3u8/master.go index fc25b92..83b71a9 100644 --- a/m3u8/master.go +++ b/m3u8/master.go @@ -182,9 +182,13 @@ type Variant struct { Video string Subtitles string ClosedCaptions string // May be NoClosedCaptions to explicitly signal no rendition. + + // Outdate Vesrsion up to 6 + ProgramID *int } -func (v Variant) String() string { +// variantAttributes return list of marshaled attributes +func variantAttributes(v Variant) []string { var attrs []string attrs = append(attrs, fmt.Sprintf("BANDWIDTH=%d", v.Bandwidth)) if v.AverageBandwidth > 0 { @@ -214,6 +218,14 @@ func (v Variant) String() string { if v.ClosedCaptions != "" && v.ClosedCaptions != NoClosedCaptions { attrs = append(attrs, fmt.Sprintf("CLOSED-CAPTIONS=%q", v.ClosedCaptions)) } + if v.ProgramID != nil { + attrs = append(attrs, fmt.Sprintf("PROGRAM-ID=%d", *v.ProgramID)) + } + return attrs +} + +func (v Variant) String() string { + attrs := variantAttributes(v) return fmt.Sprintf("%s:%s\n%s", tagVariant, strings.Join(attrs, ","), v.URI) } @@ -250,6 +262,14 @@ func (l HDCPLevel) String() string { // - ClosedCaptions type IFrameInfo Variant +func (iv IFrameInfo) String() string { + attrs := variantAttributes(Variant(iv)) + if iv.URI != "" { + attrs = append(attrs, fmt.Sprintf("URI=%q", iv.URI)) + } + return fmt.Sprintf("%s:%s\n", tagIFrame, strings.Join(attrs, ",")) +} + // SessionData represents the EXT-X-SESSION-DATA tag. type SessionData struct { ID string // This attribute is REQUIRED diff --git a/m3u8/parse.go b/m3u8/parse.go index 5a872d7..f0d608d 100644 --- a/m3u8/parse.go +++ b/m3u8/parse.go @@ -21,6 +21,7 @@ const ( tagEndList = "#EXT-X-ENDLIST" // RFC 8216, 4.4.3.4 tagIndependentSegments = "#EXT-X-INDEPENDENT-SEGMENTS" // RFC 8216, 4.3.5.1 tagSessionData = "#EXT-X-SESSION-DATA" // RFC 8216, 4.3.4.4 + tagIFrame = "#EXT-X-I-FRAME-STREAM-INF" // RFC 8216, 4.3.4.3 ) func Decode(rd io.Reader) (*Playlist, error) { @@ -66,6 +67,14 @@ func Decode(rd io.Reader) (*Playlist, error) { return p, fmt.Errorf("parse variant: %w", err) } p.Variants = append(p.Variants, *variant) + + case tagIFrame: + variant, err := parseVariant(lex.items) + if err != nil { + return p, fmt.Errorf("parse i-frame variant: %w", err) + } + p.Iframes = append(p.Iframes, IFrameInfo(*variant)) + case tagRendition: rend, err := parseRendition(lex.items) if err != nil { @@ -88,7 +97,7 @@ func Decode(rd io.Reader) (*Playlist, error) { } p.TargetDuration = dur - case tagSegmentDuration, tagByteRange, tagKey: + case tagSegmentDuration, tagByteRange, tagKey, tagMap: segment, err := parseSegment(lex.items, it) if err != nil { return p, fmt.Errorf("parse segment: %w", err) @@ -122,7 +131,12 @@ func parseVariant(items chan item) (*Variant, error) { switch it.typ { case itemError: return nil, errors.New(it.val) - case itemComma, itemNewline: + case itemComma: + continue + case itemNewline: + if v.URI != "" { + return &v, nil + } continue case itemURL: v.URI = it.val @@ -139,9 +153,23 @@ func parseVariant(items chan item) (*Variant, error) { } switch attr.val { - case "PROGRAM-ID", "NAME": + case "PROGRAM-ID": // parsing PROGRAM-ID attribute unsupported; removed in HLS version 6 // NAME is non-standard, should be set in Rendition. + it = <-items + if it.typ != itemNumber { + return nil, fmt.Errorf("parse program-id: unexpected %s", it) + } + n, err := strconv.Atoi(it.val) + if err != nil { + return nil, fmt.Errorf("parse program-id: %w", err) + } + v.ProgramID = &n + + case "NAME": + // just skip + <-items + case "BANDWIDTH", "AVERAGE-BANDWIDTH": it = <-items if it.typ != itemNumber { @@ -205,7 +233,15 @@ func parseVariant(items chan item) (*Variant, error) { if it.typ != itemString { return nil, fmt.Errorf("parse closed-captions: unexpected %s", it) } + v.ClosedCaptions = strings.Trim(it.val, `"`) + case "URI": + it = <-items + if it.typ != itemString { + return nil, fmt.Errorf("parse URI: unexpected %s", it) + } + v.URI = strings.Trim(it.val, `"`) + default: return nil, fmt.Errorf("unknown attribute %s", attr.val) } diff --git a/m3u8/segment.go b/m3u8/segment.go index cc0f04e..217d057 100644 --- a/m3u8/segment.go +++ b/m3u8/segment.go @@ -77,10 +77,10 @@ func parseSegment(items chan item, leading item) (*Segment, error) { it = <-segItems seg.Title = it.val - case tagByteRange: it = <-segItems - r, err := parseByteRange(it.val) + bbValue := strings.Trim(it.val, `"`) + r, err := parseByteRange(bbValue) if err != nil { return nil, fmt.Errorf("parse byte range: %w", err) } @@ -219,6 +219,8 @@ func parseMap(items chan item) (Map, error) { switch it.typ { case itemError: return mmap, errors.New(it.val) + case itemComma: + continue case itemNewline: return mmap, nil } @@ -236,7 +238,8 @@ func parseMap(items chan item) (Map, error) { case "URI": mmap.URI = strings.Trim(it.val, `"`) case "BYTERANGE": - r, err := parseByteRange(it.val) + bbValue := strings.Trim(it.val, `"`) + r, err := parseByteRange(bbValue) if err != nil { return Map{}, fmt.Errorf("parse byte range: %w", err) } @@ -300,7 +303,7 @@ func (seg *Segment) MarshalText() ([]byte, error) { // we do .03f for the same precision as test-streams.mux.dev. durTag := fmt.Sprintf("%s:%.03f", tagSegmentDuration, float32(us)/1e6) if seg.Title != "" { - durTag += ","+seg.Title + durTag += "," + seg.Title } tags = append(tags, durTag) tags = append(tags, seg.URI) diff --git a/m3u8/write.go b/m3u8/write.go index 384a796..09b1efd 100644 --- a/m3u8/write.go +++ b/m3u8/write.go @@ -42,6 +42,12 @@ func Encode(w io.Writer, p *Playlist) error { } } + for i, v := range p.Iframes { + if _, err := writeIframeVariant(w, &v); err != nil { + return fmt.Errorf("write i-frame variant %d: %w", i, err) + } + } + for i, sd := range p.SessionData { if _, err := writeSessionData(w, sd); err != nil { return fmt.Errorf("write session data %d: %w", i, err) @@ -64,6 +70,16 @@ func writeVariant(w io.Writer, v *Variant) (n int, err error) { return fmt.Fprintln(w, v) } +func writeIframeVariant(w io.Writer, v *IFrameInfo) (n int, err error) { + if v.Bandwidth <= 0 { + return 0, fmt.Errorf("invalid bandwidth %d: must be larger than zero", v.Bandwidth) + } + if v.URI == "" { + return 0, fmt.Errorf("empty URI") + } + return fmt.Fprintln(w, v) +} + func writeDateRange(w io.Writer, dr *DateRange) error { if dr.ID == "" { return fmt.Errorf("empty ID") diff --git a/m3u8/write_test.go b/m3u8/write_test.go index 076f775..0f65ae5 100644 --- a/m3u8/write_test.go +++ b/m3u8/write_test.go @@ -62,3 +62,40 @@ small.m3u8`, }) } } + +func TestWrite(t *testing.T) { + var cases = []struct { + name string + v IFrameInfo + want string + valid bool + }{ + { + "simple", + IFrameInfo{ + URI: "0_hd_hls/0_hd_1000_IFRAME.m3u8", + Bandwidth: 41666, + }, + `#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=41666,URI="0_hd_hls/0_hd_1000_IFRAME.m3u8"`, + true, + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + buf := &bytes.Buffer{} + _, err := writeIframeVariant(buf, &tt.v) + if err != nil && tt.valid { + t.Fatalf("non-nil error for valid i-frame variant %v: %v", tt.v, err) + } else if !tt.valid && err == nil { + t.Fatalf("nil error for invalid i-frame variant %v: %v", tt.v, err) + } + got := strings.TrimSpace(buf.String()) // trim newline + if got != tt.want { + t.Errorf("unexpected i-frame variant text") + t.Logf("got: %s", got) + t.Logf("want: %s", tt.want) + } + }) + } +}