Commit Diff


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