commit 51f4160e4ecce5868aa3be9f3c269a49566c7158
parent b43d09a3ddfbb8cb409c40c855531f1544b7f71c
Author: Oliver Lowe <o@olowe.co>
Date: Thu, 16 May 2024 16:12:48 +1000
m3u8: add new m3u8 package for HLS
For now, we're only parsing a tiny part of playlists. More to come!
Diffstat:
9 files changed, 1183 insertions(+), 0 deletions(-)
diff --git a/m3u8/lex.go b/m3u8/lex.go
@@ -0,0 +1,274 @@
+package m3u82
+
+import (
+ "bufio"
+ "fmt"
+ "io"
+ "strings"
+ "unicode/utf8"
+)
+
+type item struct {
+ typ itemType
+ val string
+}
+
+func (it item) String() string {
+ if it.typ == itemNewline {
+ return "newline"
+ }
+ return fmt.Sprintf("%s: %s", it.typ, it.val)
+}
+
+type itemType int
+
+const (
+ itemError itemType = iota
+ itemTag
+ itemAttrName
+ itemEquals
+ itemNumber
+ itemString
+ itemComma
+ itemURL
+ itemNewline
+ itemEOF
+)
+
+func (t itemType) String() string {
+ switch t {
+ case itemError:
+ return "error"
+ case itemTag:
+ return "tag"
+ case itemAttrName:
+ return "attribute name"
+ case itemEquals:
+ return "equals"
+ case itemNumber:
+ return "number"
+ case itemString:
+ return "string"
+ case itemComma:
+ return "comma"
+ case itemURL:
+ return "url"
+ case itemNewline:
+ return "newline"
+ case itemEOF:
+ return "EOF"
+ }
+ return "unknown item type"
+}
+
+const tagStart = "#EXT"
+
+// A lexer... TODO
+// The design is described in "Lexical Scanning in Go" by Rob Pike:
+// https://www.youtube.com/watch?v=HxaD_trXwRE
+type lexer struct {
+ sc *bufio.Scanner
+ input string
+ start int
+ pos int
+ width int
+ items chan item
+}
+
+type stateFn func(*lexer) stateFn
+
+func (l *lexer) next() (r rune) {
+ if l.pos >= len(l.input) {
+ return -1
+ }
+ r, l.width = utf8.DecodeRuneInString(l.input[l.pos:])
+ l.pos += l.width
+ return r
+}
+
+// ignore skips the current rune.
+func (l *lexer) ignore() { l.start = l.pos }
+
+// backup steps the lexer back one rune.
+func (l *lexer) backup() { l.pos -= l.width }
+
+func (l *lexer) peek() rune {
+ r := l.next()
+ l.backup()
+ return r
+}
+
+func (l *lexer) errorf(format string, a ...any) stateFn {
+ err := fmt.Sprintf(format, a...)
+ l.items <- item{itemError, err}
+ return nil
+}
+
+func (l *lexer) run() {
+ for state := lexStart; state != nil; {
+ state = state(l)
+ }
+ close(l.items)
+}
+
+func (l *lexer) emit(t itemType) {
+ l.items <- item{t, l.input[l.start:l.pos]}
+ // fmt.Println(item{t, l.input[l.start:l.pos]})
+ l.start = l.pos
+}
+
+func newLexer(r io.Reader) *lexer {
+ return &lexer{
+ sc: bufio.NewScanner(r),
+ items: make(chan item),
+ }
+}
+
+func lexStart(l *lexer) stateFn {
+ for l.sc.Scan() {
+ if l.sc.Text() == "" {
+ continue // ignore blank lines
+ }
+ l.input = l.sc.Text() + "\n"
+ l.pos = 0
+ l.start = 0
+ if strings.HasPrefix(l.input, tagStart) {
+ return lexTag(l)
+ } else if strings.HasPrefix(l.input, "#") {
+ continue // ignore comments
+ }
+ // not a tag, so must be a URL.
+ // emit the URL, then the newline we appended ourselves.
+ l.pos = len(l.sc.Text())
+ l.emit(itemURL)
+ l.emit(itemNewline)
+ }
+ if err := l.sc.Err(); err != nil {
+ panic(err)
+ }
+ return nil
+}
+
+func lexTag(l *lexer) stateFn {
+ r := l.next()
+ if r != '#' {
+ return l.errorf("missing starting #")
+ }
+ return lexTagName(l)
+}
+
+func lexTagName(l *lexer) stateFn {
+ for {
+ r := l.peek()
+ if isTagNameChar(r) {
+ l.next()
+ continue
+ }
+ switch r {
+ case '\n':
+ l.emit(itemTag)
+ l.next()
+ l.emit(itemNewline)
+ return lexStart(l)
+ case ':':
+ l.emit(itemTag)
+ l.next()
+ l.ignore()
+ return lexAttrs(l)
+ }
+ return l.errorf("illegal tag character %q", r)
+ }
+}
+
+func isTagNameChar(r rune) bool {
+ if r >= 'A' && r <= 'Z' {
+ return true
+ }
+ switch r {
+ case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9':
+ return true
+ case '-':
+ return true
+ }
+ return false
+}
+
+func lexAttrs(l *lexer) stateFn {
+ for {
+ switch r := l.peek(); {
+ case isTagNameChar(r):
+ l.next()
+ continue
+ case r == '\n':
+ if len(l.input[l.start:l.pos]) != 0 {
+ l.emit(itemAttrName)
+ }
+ l.next()
+ l.emit(itemNewline)
+ return lexStart(l)
+ case r == '=':
+ l.emit(itemAttrName)
+ l.next()
+ l.emit(itemEquals)
+ return lexAttrValue(l)
+ case r == ',':
+ l.next()
+ l.emit(itemComma)
+ return lexAttrs(l)
+ case r == '.':
+ return lexAttrValue(l)
+ default:
+ return l.errorf("illegal character %q in attribute name", r)
+ }
+ }
+}
+
+func lexAttrValue(l *lexer) stateFn {
+ r := l.next()
+ switch r {
+ case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.':
+ return lexNumber(l)
+ case '"':
+ return lexQString(l)
+ }
+ if isTagNameChar(r) {
+ return lexRawString(l)
+ }
+ return l.errorf("unquoted string starting with illegal character %q", r)
+}
+
+func lexNumber(l *lexer) stateFn {
+ for {
+ switch r := l.peek(); r {
+ case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.':
+ l.next()
+ continue
+ default:
+ l.emit(itemNumber)
+ return lexAttrs(l)
+ }
+ }
+}
+
+func lexQString(l *lexer) stateFn {
+ for {
+ r := l.next()
+ if r == '"' {
+ l.emit(itemString)
+ return lexAttrs(l)
+ } else if r == '\n' {
+ return l.errorf("unterminated quoted string")
+ }
+ }
+}
+
+func lexRawString(l *lexer) stateFn {
+ for {
+ if !isTagNameChar(l.peek()) {
+ break
+ }
+ l.next()
+ }
+ l.emit(itemString)
+ return lexAttrs(l)
+}
diff --git a/m3u8/lex_test.go b/m3u8/lex_test.go
@@ -0,0 +1,29 @@
+package m3u82
+
+import (
+ "os"
+ "path"
+ "testing"
+)
+
+func TestLex(t *testing.T) {
+ files := []string{"testdata/master.m3u8", "testdata/master-with-alternatives.m3u8"}
+ for _, name := range files {
+ fname := path.Base(name)
+ t.Run(fname, func(t *testing.T) {
+ f, err := os.Open(name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.Close()
+ lexer := newLexer(f)
+ go lexer.run()
+ for it := range lexer.items {
+ t.Log(it)
+ if it.typ == itemError {
+ t.Error(it.val)
+ }
+ }
+ })
+ }
+}
diff --git a/m3u8/m3u8.go b/m3u8/m3u8.go
@@ -0,0 +1,231 @@
+// Package m3u8 implements reading and writing of m3u8 playlists
+// used in HTTP Live Streaming (HLS) as specified in RFC 8216.
+package m3u82
+
+import (
+ "fmt"
+ "strconv"
+ "time"
+)
+
+type Playlist struct {
+ Version int
+ Segments []Segment
+
+ // Media playlist
+ // RFC 8216, 4.4.3.1
+ TargetDuration time.Duration
+ Sequence int
+ DiscontinuitySequence int
+ End bool
+ Type PlaylistType
+ IFramesOnly bool
+
+ // Master playlist
+ Media []Rendition
+ Variants []Variant
+ SessionData []SessionData
+ SessionKey *Key
+
+ // Both Media and Master playlists.
+ IndependentSegments bool
+ Start *StartPoint
+}
+
+type Segment struct {
+ URI string
+ // Duration of this specific segment from the #EXTINF tag.
+ Duration time.Duration
+ Range ByteRange
+ Discontinuity bool
+ Key *Key
+ Map *Map
+ DateTime time.Time
+ DateRange *DateRange
+}
+
+// A Key specifies how to decrypt encrypted playlist segments.
+type Key struct {
+ Method EncryptMethod
+ // A URI pointing to instructions on how to obtain the key.
+ URI string
+ Format string
+ // An optional specification of the version of the key format
+ // set in Format. The first value of the slice is the major
+ // version; subsequent values are minor versions.
+ FormatVersions []uint32
+ // IV is a 128-bit unsigned integer holding the key's
+ // initialisation vector.
+ IV [16]byte
+}
+
+type EncryptMethod uint8
+
+const (
+ EncryptMethodNone EncryptMethod = 0 + iota
+ EncryptMethodAES128
+ EncryptMethodSampleAES
+)
+
+func (m EncryptMethod) String() string {
+ switch m {
+ case EncryptMethodNone:
+ return "NONE"
+ case EncryptMethodAES128:
+ return "AES-128"
+ case EncryptMethodSampleAES:
+ return "SAMPLE-AES"
+ }
+ return "invalid"
+}
+
+type Map struct {
+ URI string
+ ByteRange ByteRange
+}
+
+// ByteRange represents...
+// The first entry is an offset, the second...?
+type ByteRange [2]int
+
+func (r ByteRange) String() string {
+ if r[1] == 0 {
+ return strconv.Itoa(r[0])
+ }
+ return fmt.Sprintf("%d@%d", r[0], r[1])
+}
+
+type DateRange struct {
+ ID string
+ Class string
+ Start time.Time
+ End time.Time
+ Duration time.Duration
+ Planned time.Duration
+ // value must be a string, float or hex sequence (int?)
+ Custom map[string]any
+ CueCommand []byte
+ CueIn []byte
+ CueOut []byte
+ EndOnNext bool
+}
+
+type PlaylistType uint8
+
+const (
+ PlaylistEvent PlaylistType = 0 + iota
+ PlaylistVOD
+)
+
+func (t PlaylistType) String() string {
+ switch t {
+ case PlaylistEvent:
+ return "EVENT"
+ case PlaylistVOD:
+ return "VOD"
+ }
+ return "invalid"
+}
+
+type StartPoint struct {
+ Offset float32
+ Precise bool
+}
+
+// Rendition represents a unique rendition as described by a single
+// EXT-X-MEDIA tag.
+type Rendition struct {
+ Type MediaType
+ URI string
+ Group string
+ Language string
+ AssocLanguage string
+ Name string
+ Default bool
+ AutoSelect bool
+ Forced bool
+ InstreamID *CCInfo
+ Characteristics []string
+ Channels []string
+}
+
+type MediaType uint8
+
+const (
+ MediaAudio MediaType = 0 + iota
+ MediaVideo
+ MediaSubtitles
+ MediaClosedCaptions
+)
+
+func (t MediaType) String() string {
+ switch t {
+ case MediaAudio:
+ return "AUDIO"
+ case MediaVideo:
+ return "VIDEO"
+ case MediaSubtitles:
+ return "SUBTITLES"
+ case MediaClosedCaptions:
+ return "CLOSED-CAPTIONS"
+ }
+ return "invalid"
+}
+
+type CCInfo struct {
+ ID int
+ Service bool
+}
+
+func (info *CCInfo) String() string {
+ if info.Service {
+ return fmt.Sprintf("%s%d", "SERVICE", info.ID)
+ }
+ return fmt.Sprintf("%s%d", "CC", info.ID)
+}
+
+const (
+ CharacteristicTranscribesDialog = "public.accessibility.transcribes-spoken-dialog"
+ CharacteristicDescribesMusicAndSound = "public.accessibility.transcribes-spoken-dialog"
+ ChractersticEasyToRead = "public.easy-to-read"
+ CharacteristicDescribesVideo = "public.accessibility.describes-video"
+)
+
+// EXT-X-STREAM-INF 4.3.4.2
+type Variant struct {
+ URI string
+ Bandwidth int
+ AverageBandwidth int
+ Codecs []string
+ Resolution [2]int
+ FrameRate float32
+ HDCP HDCPLevel
+ Audio string
+ Video string
+ Subtitles string
+ ClosedCaptions []string // `NONE` or comma-separated values
+}
+
+type HDCPLevel uint8
+
+const (
+ HDCPNone HDCPLevel = 0 + iota
+ HDCPType0
+ HDCPType1
+)
+
+// IFrameInfo represents the EXT-X-I-FRAME-STREAM-INF tag.
+// It has the same structure as Variant, but the following fields should be unset:
+// - FrameRate
+// - Audio
+// - Subtitles
+// - ClosedCaptions
+type IFrameInfo Variant
+
+// SessionData represents the EXT-X-SESSION-DATA tag.
+type SessionData struct {
+ ID string
+ Value string
+ URI string
+ Language string
+}
diff --git a/m3u8/parse.go b/m3u8/parse.go
@@ -0,0 +1,349 @@
+package m3u82
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "strconv"
+ "strings"
+ "time"
+)
+
+const (
+ tagHead = tagStart + "M3U"
+ tagVersion = "#EXT-X-VERSION"
+ tagVariant = "#EXT-X-STREAM-INF"
+ 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
+)
+
+func ParsePlaylist(rd io.Reader) (*Playlist, error) {
+ lex := newLexer(rd)
+ go lex.run()
+ it := <-lex.items
+ if it.typ == itemError {
+ return nil, errors.New(it.val)
+ }
+ if it.typ != itemTag || it.val != tagHead {
+ return nil, fmt.Errorf("expected head tag, got %q", it.val)
+ }
+ p := &Playlist{}
+ var err error
+ for it := range lex.items {
+ switch it.typ {
+ case itemError:
+ return p, errors.New(it.val)
+ case itemTag:
+ switch it.val {
+ case tagVersion:
+ it = <-lex.items
+ if p.Version != 0 {
+ return p, fmt.Errorf("parse %s: playlist version already specified", it)
+ }
+ p.Version, err = strconv.Atoi(it.val)
+ if err != nil {
+ return p, fmt.Errorf("parse playlist version: %w", err)
+ }
+ case tagVariant:
+ variant, err := parseVariant(lex.items)
+ if err != nil {
+ return p, fmt.Errorf("parse variant: %w", err)
+ }
+ p.Variants = append(p.Variants, *variant)
+ case tagRendition:
+ rend, err := parseRendition(lex.items)
+ if err != nil {
+ return p, fmt.Errorf("parse rendition: %w", err)
+ }
+ p.Media = append(p.Media, *rend)
+ case tagPlaylistType:
+ it = <-lex.items
+ typ, err := parsePlaylistType(it)
+ if err != nil {
+ return p, fmt.Errorf("parse playlist type: %w", err)
+ }
+ p.Type = typ
+ case tagTargetDuration:
+ it = <-lex.items
+ dur, err := parseTargetDuration(it)
+ if err != nil {
+ return p, fmt.Errorf("parse target duration: %w", err)
+ }
+ p.TargetDuration = dur
+ case tagSegmentDuration, tagByteRange:
+ segment, err := parseSegment(lex.items, it)
+ if err != nil {
+ return p, fmt.Errorf("parse segment: %w", err)
+ }
+ p.Segments = append(p.Segments, *segment)
+ }
+ }
+ }
+ return p, nil
+}
+
+func parseVariant(items chan item) (*Variant, error) {
+ var v Variant
+ for it := range items {
+ switch it.typ {
+ case itemAttrName:
+ attr := it
+ it = <-items
+ if it.typ != itemEquals {
+ return nil, fmt.Errorf("missing equals after %s", attr)
+ }
+ switch attr.val {
+ case "BANDWIDTH", "AVERAGE-BANDWIDTH":
+ it = <-items
+ if it.typ != itemNumber {
+ return nil, fmt.Errorf("parse bandwidth attribute: unexpected %s", it)
+ }
+ n, err := strconv.Atoi(it.val)
+ if err != nil {
+ return nil, fmt.Errorf("parse bandwidth: %w", err)
+ }
+ if attr.val == "BANDWIDTH" {
+ v.Bandwidth = n
+ } else {
+ v.AverageBandwidth = n
+ }
+ case "CODECS":
+ it = <-items
+ if it.typ != itemString {
+ return nil, fmt.Errorf("parse codecs attribute: unexpected %s", it)
+ }
+ v.Codecs = strings.Split(it.val, ",")
+ case "RESOLUTION":
+ it = <-items
+ if it.typ != itemString {
+ return nil, fmt.Errorf("parse resolution attribute: unexpected %s", it)
+ }
+ res, err := parseResolution(it.val)
+ if err != nil {
+ return nil, fmt.Errorf("parse resolution: %w", err)
+ }
+ v.Resolution = res
+ case "FRAME-RATE":
+ it = <-items
+ if it.typ != itemNumber {
+ return nil, fmt.Errorf("parse frame rate: unexpected %s", it)
+ }
+ n, err := strconv.ParseFloat(it.val, 32)
+ if err != nil {
+ return nil, fmt.Errorf("parse frame rate: %w", err)
+ }
+ v.FrameRate = float32(n)
+ case "HDCP-LEVEL":
+ it = <-items
+ if it.typ != itemString {
+ return nil, fmt.Errorf("parse HDCP level: unexpected %s", it)
+ }
+ l, err := parseHDCPLevel(it.val)
+ if err != nil {
+ return nil, fmt.Errorf("parse HDCP level: %w", err)
+ }
+ v.HDCP = l
+ case "AUDIO", "VIDEO", "SUBTITLES":
+ name := it.val
+ it = <-items
+ if it.typ != itemString {
+ return nil, fmt.Errorf("parse %s: unexpected %s", name, it)
+ }
+ if name == "AUDIO" {
+ v.Audio = it.val
+ } else if name == "VIDEO" {
+ v.Video = it.val
+ } else if name == "SUBTITLES" {
+ v.Subtitles = it.val
+ }
+ case "CLOSED-CAPTIONS":
+ it = <-items
+ if it.typ != itemString {
+ return nil, fmt.Errorf("parse closed-captions: unexpcted %s", it)
+ }
+ v.ClosedCaptions = strings.Split(it.val, ",")
+ default:
+ return nil, fmt.Errorf("unknown attribute %s", attr.val)
+ }
+ case itemComma:
+ continue
+ case itemURL:
+ v.URI = it.val
+ return &v, nil
+ }
+ }
+ fmt.Println(v)
+ return &v, nil
+}
+
+func parseResolution(s string) (res [2]int, err error) {
+ x, y, found := strings.Cut(s, "x")
+ if !found {
+ return res, fmt.Errorf("missing x seperator")
+ }
+ res[0], err = strconv.Atoi(x)
+ if err != nil {
+ return res, fmt.Errorf("horizontal pixels: %v", err)
+ }
+ res[1], err = strconv.Atoi(y)
+ if err != nil {
+ return res, fmt.Errorf("vertical pixels: %v", err)
+ }
+ return res, nil
+}
+
+func parseHDCPLevel(s string) (HDCPLevel, error) {
+ switch s {
+ case "NONE":
+ return HDCPNone, nil
+ case "TYPE-0":
+ return HDCPType0, nil
+ case "TYPE-1":
+ return HDCPType1, nil
+ }
+ return 0, fmt.Errorf("unknown HDCP level %q", s)
+}
+
+func parseRendition(items chan item) (*Rendition, error) {
+ var rend Rendition
+ var err error
+ for it := range items {
+ if it.typ != itemAttrName {
+ return nil, fmt.Errorf("expected attribute name, got %s", it)
+ }
+ attr := it
+ it = <-items
+ if it.typ != itemEquals {
+ return nil, fmt.Errorf("parse %s: expected =, got %s", attr, it)
+ }
+ it = <-items
+ switch attr.val {
+ case "TYPE":
+ rend.Type, err = parseMediaType(it.val)
+ if err != nil {
+ return nil, fmt.Errorf("parse media type: %w", err)
+ }
+ case "URI":
+ rend.URI = strings.Trim(it.val, `"`)
+ case "GROUP-ID":
+ rend.Group = strings.Trim(it.val, `"`)
+ case "LANGUAGE":
+ rend.Language = strings.Trim(it.val, `"`)
+ case "ASSOC-LANGUAGE":
+ rend.AssocLanguage = strings.Trim(it.val, `"`)
+ case "NAME":
+ rend.Name = strings.Trim(it.val, `"`)
+ case "DEFAULT", "AUTOSELECT", "FORCED":
+ b, err := parseBool(it.val)
+ if err != nil {
+ return nil, fmt.Errorf("parse %s: %w", attr, err)
+ }
+ if attr.val == "DEFAULT" {
+ rend.Default = b
+ } else if attr.val == "AUTOSELECT" {
+ rend.AutoSelect = b
+ } else if attr.val == "FORCED" {
+ rend.Forced = b
+ }
+ case "INSTREAM-ID":
+ rend.InstreamID, err = parseCCInfo(it.val)
+ if err != nil {
+ return nil, fmt.Errorf("parse instream-id: %w", err)
+ }
+ case "CHARACTERISTICS":
+ rend.Characteristics = strings.Split(it.val, ",")
+ case "CHANNELS":
+ rend.Channels = strings.Split(it.val, "/")
+ default:
+ return nil, fmt.Errorf("unknown rendition attribute %s", attr.val)
+ }
+ it = <-items
+ switch it.typ {
+ case itemError:
+ return nil, fmt.Errorf("next attribute: %s", it.val)
+ case itemComma:
+ continue
+ case itemNewline:
+ return &rend, nil
+ default:
+ return nil, fmt.Errorf("next attribute: expected comma or newline, got %s", it)
+ }
+ }
+ return &rend, nil
+}
+
+func parseMediaType(s string) (MediaType, error) {
+ for t := MediaAudio; t <= MediaClosedCaptions; t++ {
+ if t.String() == s {
+ return t, nil
+ }
+ }
+ return 0, fmt.Errorf("unknown media type %s", s)
+}
+
+func parseBool(s string) (bool, error) {
+ if s == "YES" {
+ return true, nil
+ } else if s == "NO" {
+ return false, nil
+ }
+ return false, fmt.Errorf("invalid boolean string %s", s)
+}
+
+// parseCCInfo parses a CCInfo attribute from s as specified in RFC 8216 section 4.4.6.1.
+func parseCCInfo(s string) (*CCInfo, error) {
+ // shortest possible is 3 chars, "CC0", "CC1" etc.
+ if len(s) < 3 {
+ return nil, fmt.Errorf("too short")
+ }
+ if s[:1] == "CC" {
+ // TODO(otl): compare against literals '1', '2', '3', '4' instead of parsing.
+ i, err := strconv.Atoi(string(s[2]))
+ if err != nil {
+ return nil, fmt.Errorf("parse channel number: %w", err)
+ }
+ return &CCInfo{i, false}, nil
+ }
+ // SERVICE00
+ if len(s) < 8 {
+ return nil, fmt.Errorf("invalid keyword %s", s)
+ }
+ if s[:6] != "SERVICE" {
+ return nil, fmt.Errorf("expected keyword %q, got %q", "SERVICE", s[:6])
+ } else if len(s) > 9 {
+ return nil, fmt.Errorf("service too long")
+ }
+ i, err := strconv.Atoi(s[6:])
+ if err != nil {
+ return nil, fmt.Errorf("parse service block number: %w", err)
+ }
+ if i < 1 || i > 63 {
+ return nil, fmt.Errorf("invalid service block number %d", i)
+ }
+ return &CCInfo{i, true}, nil
+}
+
+func parsePlaylistType(it item) (PlaylistType, error) {
+ if it.typ != itemAttrName {
+ return 0, fmt.Errorf("got %s, want item type %s", it, itemString)
+ }
+ switch it.val {
+ case "EVENT":
+ return PlaylistEvent, nil
+ case "VOD":
+ return PlaylistVOD, nil
+ }
+ return 0, fmt.Errorf("illegal playlist type %q", it.val)
+}
+
+func parseTargetDuration(it item) (time.Duration, error) {
+ if it.typ != itemAttrName && it.typ != itemNumber {
+ return 0, fmt.Errorf("got %s: want attribute name or number", it)
+ }
+ i, err := strconv.Atoi(it.val)
+ if err != nil {
+ return 0, err
+ }
+ return time.Duration(i) * time.Second, nil
+}
diff --git a/m3u8/parse_test.go b/m3u8/parse_test.go
@@ -0,0 +1,33 @@
+package m3u82
+
+import (
+ "fmt"
+ "os"
+ "testing"
+ "time"
+)
+
+func TestParse(t *testing.T) {
+ f, err := os.Open("testdata/bbb.m3u8")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.Close()
+ p, err := ParsePlaylist(f)
+ if err != nil {
+ t.Fatal(err)
+ }
+ fmt.Println(p.Segments[0])
+}
+
+func TestParseDuration(t *testing.T) {
+ want := 9967000 * time.Microsecond
+ it := item{typ: itemNumber, val: "9.967"}
+ dur, err := parseSegmentDuration(it)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if dur != want {
+ t.Errorf("parseSegmentDuration(%s) = %s, want %s", it, dur, want)
+ }
+}
diff --git a/m3u8/segment.go b/m3u8/segment.go
@@ -0,0 +1,104 @@
+package m3u82
+
+import (
+ "errors"
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// Media segment tags specified in RFC 8216 section 4.4.4.
+const (
+ tagSegmentDuration = "#EXTINF"
+ tagByteRange = "#EXT-X-BYTERANGE"
+ tagDiscontinuity = "#EXT-X-DISCONTINUITY"
+ tagKey = "#EXT-X-KEY"
+ tagMap = "#EXT-X-MAP"
+ tagDateTime = "#EXT-X-PROGRAM-DATE-TIME"
+ tagGap = "#EXT-X-GAP"
+ tagBitrate = "#EXT-X-BITRATE"
+ tagPart = "#EXT-X-PART"
+)
+
+// parseSegment returns the next segment from items and the leading
+// item which indecated the start of a segment.
+func parseSegment(items chan item, leading item) (*Segment, error) {
+ var seg Segment
+ switch leading.typ {
+ case itemTag:
+ switch leading.val {
+ case tagSegmentDuration:
+ it := <-items
+ dur, err := parseSegmentDuration(it)
+ if err != nil {
+ return nil, fmt.Errorf("parse segment duration: %w", err)
+ }
+ seg.Duration = dur
+ }
+ }
+ for it := range items {
+ if it.typ == itemError {
+ return nil, errors.New(it.val)
+ }
+ switch it.typ {
+ case itemURL:
+ seg.URI = it.val
+ return &seg, nil
+ case itemTag:
+ switch it.val {
+ case tagSegmentDuration:
+ it = <-items
+ dur, err := parseSegmentDuration(it)
+ if err != nil {
+ return nil, fmt.Errorf("parse segment duration: %w", err)
+ }
+ seg.Duration = dur
+ case tagDiscontinuity:
+ seg.Discontinuity = true
+ }
+ }
+ }
+ return nil, fmt.Errorf("no url")
+}
+
+func parseSegmentDuration(it item) (time.Duration, error) {
+ if it.typ != itemAttrName && it.typ != itemNumber {
+ return 0, fmt.Errorf("got %s: want attribute name or number", it)
+ }
+ // Some numbers can be converted straight to ints, e.g.:
+ // 10
+ // 10.000
+ // Others need to be converted from floating point, e.g:
+ // 9.967
+ // Try the easiest paths first.
+ if !strings.Contains(it.val, ".") {
+ i, err := strconv.Atoi(it.val)
+ if err != nil {
+ return 0, err
+ }
+ return time.Duration(i) * time.Second, nil
+ }
+ // 10.000
+ before, after, _ := strings.Cut(it.val, ".")
+ var allZeroes = true
+ for r := range after {
+ if r != '0' {
+ allZeroes = false
+ }
+ }
+ if allZeroes {
+ i, err := strconv.Atoi(before)
+ if err != nil {
+ return 0, err
+ }
+ return time.Duration(i) * time.Second, nil
+ }
+ seconds, err := strconv.ParseFloat(it.val, 32)
+ if err != nil {
+ return 0, err
+ }
+ // precision based on a 90KHz clock.
+ microseconds := seconds * 1e6
+ return time.Duration(microseconds) * time.Microsecond, nil
+}
diff --git a/m3u8/testdata/bbb.m3u8 b/m3u8/testdata/bbb.m3u8
@@ -0,0 +1,133 @@
+#EXTM3U
+#EXT-X-VERSION:3
+#EXT-X-PLAYLIST-TYPE:VOD
+#EXT-X-TARGETDURATION:11
+#EXTINF:10.000,
+url_846/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_847/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_848/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_849/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_850/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_851/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_852/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:9.967,
+url_853/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.033,
+url_854/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_855/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_856/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_857/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_858/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_859/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_860/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_861/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_862/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_863/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_864/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_865/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_866/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_867/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_868/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_869/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_870/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_871/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_872/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_873/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_874/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:9.967,
+url_875/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.033,
+url_876/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_877/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_878/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_879/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_880/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_881/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_882/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_883/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_884/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_885/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_886/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_887/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_888/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:9.967,
+url_889/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.033,
+url_890/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_891/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_892/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_893/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_894/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_895/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_896/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_897/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_898/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_899/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_900/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_901/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_902/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_903/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_904/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_905/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_906/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_907/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:10.000,
+url_908/193039199_mp4_h264_aac_hq_7.ts
+#EXTINF:4.600,
+url_909/193039199_mp4_h264_aac_hq_7.ts
+#EXT-X-ENDLIST
diff --git a/m3u8/testdata/master-with-alternatives.m3u8 b/m3u8/testdata/master-with-alternatives.m3u8
@@ -0,0 +1,18 @@
+#EXTM3U
+#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="low",NAME="Main",DEFAULT=YES,URI="low/main/audio-video.m3u8"
+#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="low",NAME="Centerfield",DEFAULT=NO,URI="low/centerfield/audio-video.m3u8"
+#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="low",NAME="Dugout",DEFAULT=NO,URI="low/dugout/audio-video.m3u8"
+#EXT-X-STREAM-INF:BANDWIDTH=1280000,VIDEO="low"
+low/main/audio-video.m3u8
+#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="mid",NAME="Main",DEFAULT=YES,URI="mid/main/audio-video.m3u8"
+#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="mid",NAME="Centerfield",DEFAULT=NO,URI="mid/centerfield/audio-video.m3u8"
+#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="mid",NAME="Dugout",DEFAULT=NO,URI="mid/dugout/audio-video.m3u8"
+#EXT-X-STREAM-INF:BANDWIDTH=2560000,VIDEO="mid"
+mid/main/audio-video.m3u8
+#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="hi",NAME="Main",DEFAULT=YES,URI="hi/main/audio-video.m3u8"
+#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="hi",NAME="Centerfield",DEFAULT=NO,URI="hi/centerfield/audio-video.m3u8"
+#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="hi",NAME="Dugout",DEFAULT=NO,URI="hi/dugout/audio-video.m3u8"
+#EXT-X-STREAM-INF:BANDWIDTH=7680000,VIDEO="hi"
+hi/main/audio-video.m3u8
+#EXT-X-STREAM-INF:BANDWIDTH=65000,CODECS="mp4a.40.5"
+main/audio-only.m3u8
diff --git a/m3u8/testdata/master.m3u8 b/m3u8/testdata/master.m3u8
@@ -0,0 +1,12 @@
+#EXTM3U
+#EXT-X-VERSION:3
+#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=300000
+chunklist-b300000.m3u8
+#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=600000
+chunklist-b600000.m3u8
+#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=850000
+chunklist-b850000.m3u8
+#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1000000
+chunklist-b1000000.m3u8
+#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1500000
+chunklist-b1500000.m3u8