streaming

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

commit 77cd9c1576a8929f7aed8cc9f81a8efca01130fd
parent 7c2872aa194394d29015373350eb6bafec4ab2ec
Author: Oliver Lowe <o@olowe.co>
Date:   Fri, 17 May 2024 12:59:56 +1000

m3u8: implement parsing, byte ranges, endlist

With tests and new documentation for the corresponding struct fields.

Diffstat:
Mm3u8/lex.go | 7++++++-
Mm3u8/lex_test.go | 2+-
Mm3u8/m3u8.go | 23+++++++++++++++--------
Mm3u8/parse.go | 25++++++++++++++++++++++++-
Mm3u8/parse_test.go | 28+++++++++++++++++++++++++++-
Mm3u8/segment.go | 16+++++++++++++++-
6 files changed, 88 insertions(+), 13 deletions(-)

diff --git a/m3u8/lex.go b/m3u8/lex.go @@ -1,4 +1,4 @@ -package m3u82 +package m3u8 import ( "bufio" @@ -217,6 +217,8 @@ func lexAttrs(l *lexer) stateFn { return lexAttrs(l) case r == '.': return lexAttrValue(l) + case r == '@': + return lexAttrValue(l) default: return l.errorf("illegal character %q in attribute name", r) } @@ -230,6 +232,9 @@ func lexAttrValue(l *lexer) stateFn { return lexNumber(l) case '"': return lexQString(l) + case '@': + // we're lexing a byte range, e.g. 69@420 + return lexRawString(l) } if isTagNameChar(r) { return lexRawString(l) diff --git a/m3u8/lex_test.go b/m3u8/lex_test.go @@ -1,4 +1,4 @@ -package m3u82 +package m3u8 import ( "os" diff --git a/m3u8/m3u8.go b/m3u8/m3u8.go @@ -1,6 +1,6 @@ // Package m3u8 implements reading and writing of m3u8 playlists // used in HTTP Live Streaming (HLS) as specified in RFC 8216. -package m3u82 +package m3u8 import ( "fmt" @@ -33,15 +33,22 @@ type Playlist struct { } type Segment struct { - URI string + URI string // Duration of this specific segment from the #EXTINF tag. - Duration time.Duration - Range ByteRange + Duration time.Duration + // Indicates this segment holds a subset of the segment point to by URI. + // Range is the length of the subsegment from from the #EXT-X-BYTERANGE tag. + Range ByteRange + // If true, the preceding segment and the following segment + // are discontinuous. For example, this segment is part of a + // commercial break. Discontinuity bool - Key *Key - Map *Map - DateTime time.Time - DateRange *DateRange + // Holds information on how to decrypt this segment. + // If nil, the segment is not encrypted. + Key *Key + Map *Map + DateTime time.Time + DateRange *DateRange } // A Key specifies how to decrypt encrypted playlist segments. diff --git a/m3u8/parse.go b/m3u8/parse.go @@ -1,4 +1,4 @@ -package m3u82 +package m3u8 import ( "errors" @@ -16,6 +16,7 @@ const ( tagRendition = "#EXT-X-MEDIA" tagPlaylistType = "#EXT-X-PLAYLIST-TYPE" // RFC 8216, 4.4.3.5 tagTargetDuration = "#EXT-X-TARGETDURATION" // RFC 8216, 4.4.3.1 + tagEndList = "#EXT-X-ENDLIST" // RFC 8216, 4.4.3.4 ) func ParsePlaylist(rd io.Reader) (*Playlist, error) { @@ -77,6 +78,8 @@ func ParsePlaylist(rd io.Reader) (*Playlist, error) { return p, fmt.Errorf("parse segment: %w", err) } p.Segments = append(p.Segments, *segment) + case tagEndList: + p.End = true } } } @@ -347,3 +350,23 @@ func parseTargetDuration(it item) (time.Duration, error) { } return time.Duration(i) * time.Second, nil } + +func parseByteRange(s string) (ByteRange, error) { + offset, until, found := strings.Cut(s, "@") + if !found { + n, err := strconv.Atoi(offset) + if err != nil { + return ByteRange{}, err + } + return ByteRange{n, 0}, nil + } + n, err := strconv.Atoi(offset) + if err != nil { + return ByteRange{}, err + } + nn, err := strconv.Atoi(until) + if err != nil { + return ByteRange{}, err + } + return ByteRange{n, nn}, nil +} diff --git a/m3u8/parse_test.go b/m3u8/parse_test.go @@ -1,4 +1,4 @@ -package m3u82 +package m3u8 import ( "fmt" @@ -31,3 +31,29 @@ func TestParseDuration(t *testing.T) { t.Errorf("parseSegmentDuration(%s) = %s, want %s", it, dur, want) } } + +func TestParseByteRange(t *testing.T) { + var tests = []struct { + in string + want ByteRange + valid bool + }{ + {"27@46", ByteRange{27, 46}, true}, + {"69", ByteRange{69}, true}, + {"732@", ByteRange{0, 0}, false}, + {"@", ByteRange{0, 0}, false}, + } + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + r, err := parseByteRange(tt.in) + if err != nil && tt.valid { + t.Fatalf("parseByteRange(%s): %v", tt.in, err) + } else if err == nil && !tt.valid { + t.Fatalf("parseByteRange(%s): nil error on invalid byte range", tt.in) + } + if r != tt.want { + t.Errorf("parseByteRange(%s) = %v, want %v", tt.in, r, tt.want) + } + }) + } +} diff --git a/m3u8/segment.go b/m3u8/segment.go @@ -1,4 +1,4 @@ -package m3u82 +package m3u8 import ( "errors" @@ -54,8 +54,22 @@ func parseSegment(items chan item, leading item) (*Segment, error) { return nil, fmt.Errorf("parse segment duration: %w", err) } seg.Duration = dur + case tagByteRange: + it = <-items + if it.typ != itemString { + return nil, fmt.Errorf("parse byte range: got %s, want item type string", it) + } + r, err := parseByteRange(it.val) + if err != nil { + return nil, fmt.Errorf("parse byte range: %w", err) + } + seg.Range = r case tagDiscontinuity: seg.Discontinuity = true + case tagKey: + return nil, fmt.Errorf("parsing %s unsupported", it) + default: + return nil, fmt.Errorf("parsing %s unsupported", it) } } }