streaming

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

commit 3e2200b9e76d35b13e385535acdd87583441afbe
parent 9d0a9c380ce962f56c141d6db6fdacd7a41bf831
Author: Oliver Lowe <o@olowe.co>
Date:   Sat, 17 May 2025 17:31:05 +1000

m3u8: parse EXT-X-PROGRAM-DATE-TIME tag

Diffstat:
Mm3u8/lex.go | 12+++++++++++-
Mm3u8/m3u8.go | 20+++++++++++++++-----
Mm3u8/segment.go | 52++++++++++++++++++++++++++++++++++------------------
Mm3u8/segment_test.go | 3++-
Mm3u8/testdata/discontinuities.m3u8 | 1+
5 files changed, 63 insertions(+), 25 deletions(-)

diff --git a/m3u8/lex.go b/m3u8/lex.go @@ -6,6 +6,7 @@ import ( "io" "os" "strings" + "unicode" "unicode/utf8" ) @@ -229,6 +230,8 @@ func lexAttrs(l *lexer) stateFn { return lexAttrValue(l) case r == '@': return lexAttrValue(l) + case r == ':': + return lexAttrValue(l) case r == '"': l.next() return lexQString(l) @@ -241,7 +244,7 @@ func lexAttrs(l *lexer) stateFn { func lexAttrValue(l *lexer) stateFn { r := l.next() switch r { - case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.': + case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.', ':': return lexNumber(l) case '"': return lexQString(l) @@ -262,6 +265,13 @@ func lexNumber(l *lexer) stateFn { case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.': l.next() continue + case ':', 'T', 'Z': + // could be a RFC 3339 timestamp + l.next() + if !unicode.IsDigit(l.peek()) { + return l.errorf("expected digit after timestamp character %c, got %c", r, l.peek()) + } + return lexRawString(l) default: l.emit(itemNumber) return lexAttrs(l) diff --git a/m3u8/m3u8.go b/m3u8/m3u8.go @@ -40,20 +40,30 @@ type Playlist struct { type Segment struct { URI string - // Duration of this specific segment from the #EXTINF tag. + + // Duration of this specific segment from the EXTINF tag. 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 is the length of the subsegment 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 + // Holds information on how to decrypt this segment. // If nil, the segment is not encrypted. - Key *Key - Map *Map - DateTime time.Time + Key *Key + + Map *Map + + // Associates an absolute time with the start of the segment. + // The EXT-X-PROGRAM-DATE-TIME tag holds this value to + // millisecond accuracy. + DateTime time.Time + DateRange *DateRange } diff --git a/m3u8/segment.go b/m3u8/segment.go @@ -45,6 +45,12 @@ func parseSegment(items chan item, leading item) (*Segment, error) { return nil, fmt.Errorf("parse key: %w", err) } seg.Key = &key + case tagMap: + m, err := parseMap(items) + if err != nil { + return nil, fmt.Errorf("parse map: %w", err) + } + seg.Map = &m default: return nil, fmt.Errorf("parse leading item %s: unsupported", leading) } @@ -94,6 +100,13 @@ func parseSegment(items chan item, leading item) (*Segment, error) { return nil, fmt.Errorf("parse map: %w", err) } seg.Map = &m + case tagDateTime: + it = <-items + t, err := time.Parse(time.RFC3339Nano, it.val) + if err != nil { + return nil, fmt.Errorf("bad date time tag: %w", err) + } + seg.DateTime = t default: return nil, fmt.Errorf("parsing %s unsupported", it) } @@ -205,25 +218,28 @@ func parseMap(items chan item) (Map, error) { return mmap, errors.New(it.val) case itemNewline: return mmap, nil - case itemAttrName: - v := <-items - if v.typ != itemEquals { - return Map{}, fmt.Errorf("expected %q after %s, got %s", "=", it.typ, v) - } - switch it.val { - case "URI": - v = <-items - mmap.URI = strings.Trim(it.val, `"`) - case "BYTERANGE": - v = <-items - r, err := parseByteRange(v.val) - if err != nil { - return Map{}, fmt.Errorf("parse byte range: %w", err) - } - mmap.ByteRange = r - default: - return Map{}, fmt.Errorf("unexpected attribute %q", it.val) + } + if it.typ != itemAttrName { + return Map{}, fmt.Errorf("unexpected %s %q", it.typ, it.val) + } + attr := it.val + it = <-items + if it.typ != itemEquals { + return Map{}, fmt.Errorf("expected %q after %s, got %q", "=", attr, it.val) + } + + it = <-items + switch attr { + case "URI": + mmap.URI = strings.Trim(it.val, `"`) + case "BYTERANGE": + r, err := parseByteRange(it.val) + if err != nil { + return Map{}, fmt.Errorf("parse byte range: %w", err) } + mmap.ByteRange = r + default: + return Map{}, fmt.Errorf("unexpected attribute %q", it.val) } } return Map{}, fmt.Errorf("unexpected end of tag") diff --git a/m3u8/segment_test.go b/m3u8/segment_test.go @@ -115,7 +115,8 @@ func TestParseSegment(t *testing.T) { URI: "key1.json?f=1041&s=0&p=1822767&m=1506045858", IV: [...]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x1B, 0xD0, 0x2F}, }, - URI: "1041_6_1822767.ts?m=1506045858", + DateTime: time.Date(2020, 12, 2, 18, 33, 3, 447e6, time.UTC), + URI: "1041_6_1822767.ts?m=1506045858", } if !reflect.DeepEqual(plist.Segments[0], encrypted) { diff --git a/m3u8/testdata/discontinuities.m3u8 b/m3u8/testdata/discontinuities.m3u8 @@ -4,6 +4,7 @@ #EXT-X-VERSION:3 #EXT-X-PLAYLIST-TYPE:EVENT #EXT-X-KEY:METHOD=AES-128,URI="key1.json?f=1041&s=0&p=1822767&m=1506045858",IV=0x000000000000000000000000001BD02F +#EXT-X-PROGRAM-DATE-TIME:2020-12-02T18:33:03.447Z #EXTINF:10, 1041_6_1822767.ts?m=1506045858 #EXT-X-KEY:METHOD=AES-128,URI="key2.json?f=1041&s=0&p=1822768&m=1506045858",IV=0x000000000000000000000000001BD030