streaming

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

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:
Am3u8/lex.go | 274+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Am3u8/lex_test.go | 29+++++++++++++++++++++++++++++
Am3u8/m3u8.go | 231+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Am3u8/parse.go | 349+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Am3u8/parse_test.go | 33+++++++++++++++++++++++++++++++++
Am3u8/segment.go | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Am3u8/testdata/bbb.m3u8 | 133+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Am3u8/testdata/master-with-alternatives.m3u8 | 18++++++++++++++++++
Am3u8/testdata/master.m3u8 | 12++++++++++++
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