From d1f55d5a17dc2958ce6e83915293ea6db38be42c Mon Sep 17 00:00:00 2001 From: Daniel Wu Date: Thu, 28 Mar 2024 14:40:13 +0800 Subject: [PATCH] support #EXTINF tag with attributes --- reader.go | 57 +++++++++++++-- reader_test.go | 29 ++++++-- .../media-playlist-with-attributes.m3u8 | 6 ++ structure.go | 69 ++++++++++--------- writer.go | 17 +++++ writer_test.go | 22 ++++++ 6 files changed, 157 insertions(+), 43 deletions(-) create mode 100644 sample-playlists/media-playlist-with-attributes.m3u8 diff --git a/reader.go b/reader.go index caf021bc..66dde6f2 100644 --- a/reader.go +++ b/reader.go @@ -20,14 +20,15 @@ import ( "strconv" "strings" "time" + "unicode" ) var reKeyValue = regexp.MustCompile(`([a-zA-Z0-9_-]+)=("[^"]+"|[^",]+)`) // TimeParse allows globally apply and/or override Time Parser function. // Available variants: -// * FullTimeParse - implements full featured ISO/IEC 8601:2004 -// * StrictTimeParse - implements only RFC3339 Nanoseconds format +// - FullTimeParse - implements full featured ISO/IEC 8601:2004 +// - StrictTimeParse - implements only RFC3339 Nanoseconds format var TimeParse func(value string) (time.Time, error) = FullTimeParse // Decode parses a master playlist passed from the buffer. If `strict` @@ -482,18 +483,59 @@ func decodeLineOfMediaPlaylist(p *MediaPlaylist, wv *WV, state *decodingState, l } sepIndex = len(line) } - duration := line[8:sepIndex] + durationRe := regexp.MustCompile(`^(-?\d+\.?\d*)`) + if len(durationRe.FindStringSubmatch(line[8:sepIndex])) != 2 { + return errors.New("Duration parsing error: duration not found") + } + duration := durationRe.FindStringSubmatch(line[8:sepIndex])[1] if len(duration) > 0 { if state.duration, err = strconv.ParseFloat(duration, 64); strict && err != nil { return fmt.Errorf("Duration parsing error: %s", err) } } + if len(line) > sepIndex { state.title = line[sepIndex+1:] } + + if strict { + durationLength := len(durationRe.FindStringSubmatch(line[8:sepIndex])[1]) + titleLength := len(line[sepIndex+1:]) + if len(line) > durationLength+titleLength+9 { + state.tagAttribute = true + lastQuote := rune(0) + formatter := func(c rune) bool { + switch { + case c == lastQuote: + lastQuote = rune(0) + return false + case lastQuote != rune(0): + return false + case unicode.In(c, unicode.Quotation_Mark): + lastQuote = c + return false + default: + return unicode.IsSpace(c) + } + } + attributes := strings.FieldsFunc(line[9+durationLength:sepIndex], formatter) + state.attribute = make(map[string]string) + if len(attributes) <= 0 { + return errors.New("EXTINF attributes parsing error: attributes not found") + } + for _, attribute := range attributes { + state.attribute[strings.Split(attribute, "=")[0]] = strings.Trim(strings.Split(attribute, "=")[1], "\"") + } + } + } + case !strings.HasPrefix(line, "#"): if state.tagInf { - err := p.Append(line, state.duration, state.title) + if state.tagAttribute { + err = p.AppendWithAttributes(line, state.duration, state.title, state.attribute) + } else { + err = p.Append(line, state.duration, state.title) + } if err == ErrPlaylistFull { // Extend playlist by doubling size, reset internal state, try again. // If the second Append fails, the if err block will handle it. @@ -502,7 +544,11 @@ func decodeLineOfMediaPlaylist(p *MediaPlaylist, wv *WV, state *decodingState, l p.Segments = append(p.Segments, make([]*MediaSegment, p.Count())...) p.capacity = uint(len(p.Segments)) p.tail = p.count - err = p.Append(line, state.duration, state.title) + if state.tagAttribute { + err = p.AppendWithAttributes(line, state.duration, state.title, state.attribute) + } else { + err = p.Append(line, state.duration, state.title) + } } // Check err for first or subsequent Append() if err != nil { @@ -561,6 +607,7 @@ func decodeLineOfMediaPlaylist(p *MediaPlaylist, wv *WV, state *decodingState, l state.custom = make(map[string]CustomTag) state.tagCustom = false } + // start tag first case line == "#EXTM3U": state.m3u = true diff --git a/reader_test.go b/reader_test.go index ead4e55c..c1849e39 100644 --- a/reader_test.go +++ b/reader_test.go @@ -1,11 +1,11 @@ /* - Playlist parsing tests. +Playlist parsing tests. - Copyright 2013-2019 The Project Developers. - See the AUTHORS and LICENSE files at the top-level directory of this distribution - and at https://github.com/grafov/m3u8/ +Copyright 2013-2019 The Project Developers. +See the AUTHORS and LICENSE files at the top-level directory of this distribution +and at https://github.com/grafov/m3u8/ - ॐ तारे तुत्तारे तुरे स्व +ॐ तारे तुत्तारे तुरे स्व */ package m3u8 @@ -972,6 +972,25 @@ func TestDecodeMediaPlaylistStartTime(t *testing.T) { } } +func TestDecodeMediaPlaylistAttributes(t *testing.T) { + f, err := os.Open("sample-playlists/media-playlist-with-attributes.m3u8") + if err != nil { + t.Fatal(err) + } + p, listType, err := DecodeFrom(bufio.NewReader(f), true) + if err != nil { + t.Fatal(err) + } + pp := p.(*MediaPlaylist) + CheckType(t, pp) + if listType != MEDIA { + t.Error("Sample not recognized as media playlist.") + } + if _, ok := pp.Segments[0].Attritube["group-title"]; !ok { + t.Error("Sample attributes not unpacked") + } +} + func TestDecodeMediaPlaylistWithCueOutCueIn(t *testing.T) { f, err := os.Open("sample-playlists/media-playlist-with-cue-out-in-without-oatcls.m3u8") if err != nil { diff --git a/sample-playlists/media-playlist-with-attributes.m3u8 b/sample-playlists/media-playlist-with-attributes.m3u8 new file mode 100644 index 00000000..c1f7e5e2 --- /dev/null +++ b/sample-playlists/media-playlist-with-attributes.m3u8 @@ -0,0 +1,6 @@ +#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-MEDIA-SEQUENCE:0 +#EXT-X-TARGETDURATION:10 +#EXTINF:-1 tvg-id="id0" tvg-name="Channel Name 0" tvg-logo="logo0.png" group-title="Channel Group 0",Channel 0 +channel0.ts \ No newline at end of file diff --git a/structure.go b/structure.go index eb5d01a2..108cb23b 100644 --- a/structure.go +++ b/structure.go @@ -85,26 +85,26 @@ const ( // // Simple Media Playlist file sample: // -// #EXTM3U -// #EXT-X-VERSION:3 -// #EXT-X-TARGETDURATION:5220 -// #EXTINF:5219.2, -// http://media.example.com/entire.ts -// #EXT-X-ENDLIST +// #EXTM3U +// #EXT-X-VERSION:3 +// #EXT-X-TARGETDURATION:5220 +// #EXTINF:5219.2, +// http://media.example.com/entire.ts +// #EXT-X-ENDLIST // // Sample of Sliding Window Media Playlist, using HTTPS: // -// #EXTM3U -// #EXT-X-VERSION:3 -// #EXT-X-TARGETDURATION:8 -// #EXT-X-MEDIA-SEQUENCE:2680 +// #EXTM3U +// #EXT-X-VERSION:3 +// #EXT-X-TARGETDURATION:8 +// #EXT-X-MEDIA-SEQUENCE:2680 // -// #EXTINF:7.975, -// https://priv.example.com/fileSequence2680.ts -// #EXTINF:7.941, -// https://priv.example.com/fileSequence2681.ts -// #EXTINF:7.975, -// https://priv.example.com/fileSequence2682.ts +// #EXTINF:7.975, +// https://priv.example.com/fileSequence2680.ts +// #EXTINF:7.941, +// https://priv.example.com/fileSequence2681.ts +// #EXTINF:7.975, +// https://priv.example.com/fileSequence2682.ts type MediaPlaylist struct { TargetDuration float64 SeqNo uint64 // EXT-X-MEDIA-SEQUENCE @@ -136,15 +136,15 @@ type MediaPlaylist struct { // combines media playlists for multiple bitrates. URI lines in the // playlist identify media playlists. Sample of Master Playlist file: // -// #EXTM3U -// #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1280000 -// http://example.com/low.m3u8 -// #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=2560000 -// http://example.com/mid.m3u8 -// #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=7680000 -// http://example.com/hi.m3u8 -// #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=65000,CODECS="mp4a.40.5" -// http://example.com/audio-only.m3u8 +// #EXTM3U +// #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1280000 +// http://example.com/low.m3u8 +// #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=2560000 +// http://example.com/mid.m3u8 +// #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=7680000 +// http://example.com/hi.m3u8 +// #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=65000,CODECS="mp4a.40.5" +// http://example.com/audio-only.m3u8 type MasterPlaylist struct { Variants []*Variant Args string // optional arguments placed after URI (URI?Args) @@ -206,14 +206,15 @@ type MediaSegment struct { SeqId uint64 Title string // optional second parameter for EXTINF tag URI string - Duration float64 // first parameter for EXTINF tag; duration must be integers if protocol version is less than 3 but we are always keep them float - Limit int64 // EXT-X-BYTERANGE is length in bytes for the file under URI - Offset int64 // EXT-X-BYTERANGE [@o] is offset from the start of the file under URI - Key *Key // EXT-X-KEY displayed before the segment and means changing of encryption key (in theory each segment may have own key) - Map *Map // EXT-X-MAP displayed before the segment - Discontinuity bool // EXT-X-DISCONTINUITY indicates an encoding discontinuity between the media segment that follows it and the one that preceded it (i.e. file format, number and type of tracks, encoding parameters, encoding sequence, timestamp sequence) - SCTE *SCTE // SCTE-35 used for Ad signaling in HLS - ProgramDateTime time.Time // EXT-X-PROGRAM-DATE-TIME tag associates the first sample of a media segment with an absolute date and/or time + Duration float64 // first parameter for EXTINF tag; duration must be integers if protocol version is less than 3 but we are always keep them float + Attritube map[string]string // unpack from EXTINF tag; usually exist in IPTV media list + Limit int64 // EXT-X-BYTERANGE is length in bytes for the file under URI + Offset int64 // EXT-X-BYTERANGE [@o] is offset from the start of the file under URI + Key *Key // EXT-X-KEY displayed before the segment and means changing of encryption key (in theory each segment may have own key) + Map *Map // EXT-X-MAP displayed before the segment + Discontinuity bool // EXT-X-DISCONTINUITY indicates an encoding discontinuity between the media segment that follows it and the one that preceded it (i.e. file format, number and type of tracks, encoding parameters, encoding sequence, timestamp sequence) + SCTE *SCTE // SCTE-35 used for Ad signaling in HLS + ProgramDateTime time.Time // EXT-X-PROGRAM-DATE-TIME tag associates the first sample of a media segment with an absolute date and/or time Custom map[string]CustomTag } @@ -314,6 +315,7 @@ type decodingState struct { tagWV bool tagStreamInf bool tagInf bool + tagAttribute bool tagSCTE35 bool tagRange bool tagDiscontinuity bool @@ -325,6 +327,7 @@ type decodingState struct { limit int64 offset int64 duration float64 + attribute map[string]string title string variant *Variant alternatives []*Alternative diff --git a/writer.go b/writer.go index 0e32ea3a..7d14f572 100644 --- a/writer.go +++ b/writer.go @@ -359,6 +359,17 @@ func (p *MediaPlaylist) Append(uri string, duration float64, title string) error return p.AppendSegment(seg) } +// AppendWithAttributes append general chunk to the tail of chunk slice for a media playlist with attributes. +// This operation does reset playlist cache. +func (p *MediaPlaylist) AppendWithAttributes(uri string, duration float64, title string, attributes map[string]string) error { + seg := new(MediaSegment) + seg.URI = uri + seg.Duration = duration + seg.Title = title + seg.Attritube = attributes + return p.AppendSegment(seg) +} + // AppendSegment appends a MediaSegment to the tail of chunk slice for // a media playlist. This operation does reset playlist cache. func (p *MediaPlaylist) AppendSegment(seg *MediaSegment) error { @@ -695,6 +706,12 @@ func (p *MediaPlaylist) Encode() *bytes.Buffer { } p.buf.WriteString(durationCache[seg.Duration]) } + if len(seg.Attritube) > 0 { + for key, value := range seg.Attritube { + p.buf.WriteString(" ") + p.buf.WriteString(fmt.Sprintf("%s=\"%s\"", key, value)) + } + } p.buf.WriteRune(',') p.buf.WriteString(seg.Title) p.buf.WriteRune('\n') diff --git a/writer_test.go b/writer_test.go index 8bdaeebd..c4453fee 100644 --- a/writer_test.go +++ b/writer_test.go @@ -378,6 +378,28 @@ func TestEncodeMediaPlaylistWithDefaultMap(t *testing.T) { } } +// Create new media playlist +// Add media attributes +// Add segment with attributes +func TestEncodeMediaPlaylistWithAttributes(t *testing.T) { + p, e := NewMediaPlaylist(1, 1) + if e != nil { + t.Fatalf("Create media playlist failed: %s", e) + } + + attribute := make(map[string]string) + attribute["tvg-id"] = "id0" + attribute["tvg-name"] = "Channel Name 0" + attribute["tvg-logo"] = "logo0.png" + attribute["group-title"] = "Channel Group 0" + + e = p.AppendWithAttributes("channel0.ts", -1, "Channel 0", attribute) + if e != nil { + t.Fatalf("Add 1st segment to a media playlist failed: %s", e) + } + //fmt.Println(p.Encode().String()) +} + // Create new media playlist // Add custom playlist tag // Add segment with custom tag