commit 3e2200b9e76d35b13e385535acdd87583441afbe from: Oliver Lowe date: Sat May 17 07:32:49 2025 UTC m3u8: parse EXT-X-PROGRAM-DATE-TIME tag commit - 9d0a9c380ce962f56c141d6db6fdacd7a41bf831 commit + 3e2200b9e76d35b13e385535acdd87583441afbe blob - 72a79eb6300b633779d9890cb85f70b5f0cb7bc8 blob + 0e22fc020003e7247af625a953ad3f112aa4c8f8 --- m3u8/lex.go +++ 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) blob - cad16cdf5b3e2c9aeaa5df7f69207b3151bc066a blob + fb10f2118acd9cb9ba8899c4e2834c6b92a94123 --- m3u8/m3u8.go +++ 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 } blob - 0d706d6f50a48b0bc89983ee9fa72a8a9c18b968 blob + 3789c873abda6c7313d8e49d44f5bce71970f434 --- m3u8/segment.go +++ m3u8/segment.go @@ -45,6 +45,12 @@ func parseSegment(items chan item, leading item) (*Seg 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) (*Seg 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) + } + 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) } - 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) - } + mmap.ByteRange = r + default: + return Map{}, fmt.Errorf("unexpected attribute %q", it.val) } } return Map{}, fmt.Errorf("unexpected end of tag") blob - c9c777d8c62b46c04c954a12fc84294e87fd250b blob + 06f9851ab3e4f2273de6dc4d044dc7b4c2a95a2b --- m3u8/segment_test.go +++ 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) { blob - 79e1a52b3948e69c1df6200c4fcc9cb9165588a3 blob + 8050818b9274cc31ecf069dce7f58d61b7a07272 --- m3u8/testdata/discontinuities.m3u8 +++ 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