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:
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