From 899eb3d5d3b21ccfb729cde9e44423b9003ace9e Mon Sep 17 00:00:00 2001 From: Matthew Neil Date: Fri, 15 Mar 2019 10:37:27 -0400 Subject: [PATCH 1/9] support custom tag decode/encode --- AUTHORS | 3 +- example/example.go | 12 ++- .../template/custom-playlist-tag-template.go | 46 ++++++++ .../template/custom-segment-tag-template.go | 74 +++++++++++++ reader.go | 101 ++++++++++++++++-- reader_test.go | 2 +- .../master-playlist-with-custom-tags.m3u8 | 13 +++ .../media-playlist-with-custom-tags.m3u8 | 17 +++ structure.go | 16 +++ writer.go | 32 ++++++ 10 files changed, 305 insertions(+), 11 deletions(-) create mode 100644 example/template/custom-playlist-tag-template.go create mode 100644 example/template/custom-segment-tag-template.go create mode 100644 sample-playlists/master-playlist-with-custom-tags.m3u8 create mode 100644 sample-playlists/media-playlist-with-custom-tags.m3u8 diff --git a/AUTHORS b/AUTHORS index 87fb76a4..c873bb91 100644 --- a/AUTHORS +++ b/AUTHORS @@ -18,6 +18,7 @@ to the project. They listed below in an alphabetical order: - Vishal Kumar Tuniki - Yevgen Flerko - Zac Shenker - +- Matthew Neil [mjneil](https://github.com/mjneil) + If you want to be added to this list (or removed for any reason) just open an issue about it. diff --git a/example/example.go b/example/example.go index 3590fde0..d1074e34 100644 --- a/example/example.go +++ b/example/example.go @@ -7,6 +7,7 @@ import ( "path" "github.com/grafov/m3u8" + "github.com/grafov/m3u8/example/template" ) func main() { @@ -14,12 +15,19 @@ func main() { if GOPATH == "" { panic("$GOPATH is empty") } - m3u8File := "github.com/grafov/m3u8/sample-playlists/media-playlist-with-byterange.m3u8" + + m3u8File := "github.com/grafov/m3u8/sample-playlists/media-playlist-with-custom-tags.m3u8" f, err := os.Open(path.Join(GOPATH, "src", m3u8File)) if err != nil { panic(err) } - p, listType, err := m3u8.DecodeFrom(bufio.NewReader(f), true) + + customTags := []m3u8.CustomTag{ + &template.CustomPlaylistTag{}, + &template.CustomSegmentTag{}, + } + + p, listType, err := m3u8.DecodeWith(bufio.NewReader(f), true, customTags) if err != nil { panic(err) } diff --git a/example/template/custom-playlist-tag-template.go b/example/template/custom-playlist-tag-template.go new file mode 100644 index 00000000..854367d1 --- /dev/null +++ b/example/template/custom-playlist-tag-template.go @@ -0,0 +1,46 @@ +package template + +import ( + "bytes" + "fmt" + "strconv" + + "github.com/grafov/m3u8" +) + +// #CUSTOM-PLAYLIST-TAG: + +type CustomPlaylistTag struct { + Number int +} + +// TagName() should return the full indentifier including the leading '#' and trailing ':' +// if the tag also contains a value or attribute list +func (tag *CustomPlaylistTag) TagName() string { + return "#CUSTOM-PLAYLIST-TAG:" +} + +// line will be the entire matched line, including the identifier +func (tag *CustomPlaylistTag) Decode(line string) (m3u8.CustomTag, error) { + _, err := fmt.Sscanf(line, "#CUSTOM-PLAYLIST-TAG:%d", &tag.Number) + + return tag, err +} + +func (tag *CustomPlaylistTag) Encode() *bytes.Buffer { + buf := new(bytes.Buffer) + + buf.WriteString(tag.TagName()) + buf.WriteString(strconv.Itoa(tag.Number)) + + return buf +} + +func (tag *CustomPlaylistTag) String() string { + return tag.Encode().String() +} + +// This is a playlist tag example +func (tag *CustomPlaylistTag) Segment() bool { + return false +} diff --git a/example/template/custom-segment-tag-template.go b/example/template/custom-segment-tag-template.go new file mode 100644 index 00000000..c763d3c4 --- /dev/null +++ b/example/template/custom-segment-tag-template.go @@ -0,0 +1,74 @@ +package template + +import ( + "bytes" + "errors" + + "github.com/grafov/m3u8" +) + +// #CUSTOM-SEGMENT-TAG: + +type CustomSegmentTag struct { + Name string + Jedi bool +} + +// TagName() should return the full indentifier including the leading '#' and trailing ':' +// if the tag also contains a value or attribute list +func (tag *CustomSegmentTag) TagName() string { + return "#CUSTOM-SEGMENT-TAG:" +} + +// line will be the entire matched line, including the identifier +func (tag *CustomSegmentTag) Decode(line string) (m3u8.CustomTag, error) { + var err error + + // Since this is a Segment tag, we want to create a new tag every time it is decoded + // as there can be one for each segment with + newTag := new(CustomSegmentTag) + + for k, v := range m3u8.DecodeAttributeList(line[20:]) { + switch k { + case "NAME": + newTag.Name = v + case "JEDI": + if v == "YES" { + newTag.Jedi = true + } else if v == "NO" { + newTag.Jedi = false + } else { + err = errors.New("Valid strings for JEDI attribute are YES and NO.") + } + } + } + + return newTag, err +} + +func (tag *CustomSegmentTag) Encode() *bytes.Buffer { + buf := new(bytes.Buffer) + + if tag.Name != "" { + buf.WriteString(tag.TagName()) + buf.WriteString("NAME=\"") + buf.WriteString(tag.Name) + buf.WriteString("\",JEDI=") + if tag.Jedi { + buf.WriteString("YES") + } else { + buf.WriteString("NO") + } + } + + return buf +} + +func (tag *CustomSegmentTag) String() string { + return tag.Encode().String() +} + +// This is a playlist tag example +func (tag *CustomSegmentTag) Segment() bool { + return true +} diff --git a/reader.go b/reader.go index e8240698..ea75248f 100644 --- a/reader.go +++ b/reader.go @@ -48,6 +48,17 @@ func (p *MasterPlaylist) DecodeFrom(reader io.Reader, strict bool) error { return p.decode(buf, strict) } +func (p *MasterPlaylist) WithCustomTags(customTags []CustomTag) Playlist { + // Create the map if it doesn't already exist + if p.Custom == nil { + p.Custom = make(map[string]CustomTag) + } + + p.customTags = customTags + + return p +} + // Parse master playlist. Internal function. func (p *MasterPlaylist) decode(buf *bytes.Buffer, strict bool) error { var eof bool @@ -90,6 +101,17 @@ func (p *MediaPlaylist) DecodeFrom(reader io.Reader, strict bool) error { return p.decode(buf, strict) } +func (p *MediaPlaylist) WithCustomTags(customTags []CustomTag) Playlist { + // Create the map if it doesn't already exist + if p.Custom == nil { + p.Custom = make(map[string]CustomTag) + } + + p.customTags = customTags + + return p +} + func (p *MediaPlaylist) decode(buf *bytes.Buffer, strict bool) error { var eof bool var line string @@ -123,7 +145,7 @@ func (p *MediaPlaylist) decode(buf *bytes.Buffer, strict bool) error { // Decode detects type of playlist and decodes it. It accepts bytes // buffer as input. func Decode(data bytes.Buffer, strict bool) (Playlist, ListType, error) { - return decode(&data, strict) + return decode(&data, strict, nil) } // DecodeFrom detects type of playlist and decodes it. It accepts data @@ -134,12 +156,28 @@ func DecodeFrom(reader io.Reader, strict bool) (Playlist, ListType, error) { if err != nil { return nil, 0, err } - return decode(buf, strict) + return decode(buf, strict, nil) +} + +func DecodeWith(input interface{}, strict bool, customTags []CustomTag) (Playlist, ListType, error) { + switch v := input.(type) { + case bytes.Buffer: + return decode(&v, strict, customTags) + case io.Reader: + buf := new(bytes.Buffer) + _, err := buf.ReadFrom(v) + if err != nil { + return nil, 0, err + } + return decode(buf, strict, customTags) + default: + return nil, 0, errors.New("input must be bytes.Buffer or io.Reader type") + } } // Detect playlist type and decode it. May be used as decoder for both // master and media playlists. -func decode(buf *bytes.Buffer, strict bool) (Playlist, ListType, error) { +func decode(buf *bytes.Buffer, strict bool, customTags []CustomTag) (Playlist, ListType, error) { var eof bool var line string var master *MasterPlaylist @@ -156,6 +194,13 @@ func decode(buf *bytes.Buffer, strict bool) (Playlist, ListType, error) { return nil, 0, fmt.Errorf("Create media playlist failed: %s", err) } + // If we have custom tags to parse + if customTags != nil { + media = media.WithCustomTags(customTags).(*MediaPlaylist) + master = master.WithCustomTags(customTags).(*MasterPlaylist) + state.custom = make(map[string]CustomTag) + } + for !eof { if line, err = buf.ReadString('\n'); err == io.EOF { eof = true @@ -202,6 +247,10 @@ func decode(buf *bytes.Buffer, strict bool) (Playlist, ListType, error) { return nil, state.listType, errors.New("Can't detect playlist type") } +func DecodeAttributeList(line string) map[string]string { + return decodeParamsLine(line) +} + func decodeParamsLine(line string) map[string]string { out := make(map[string]string) for _, kv := range reKeyValue.FindAllStringSubmatch(line, -1) { @@ -369,8 +418,21 @@ func decodeLineOfMasterPlaylist(p *MasterPlaylist, state *decodingState, line st state.variant.HDCPLevel = v } } - case strings.HasPrefix(line, "#"): // unknown tags treated as comments - return err + case strings.HasPrefix(line, "#"): + // if we have custom tags, check for those here, otherwise comments are ignored + if p.Custom != nil { + for _, v := range p.customTags { + if strings.HasPrefix(line, v.TagName()) { + t, err := v.Decode(line) + + if strict && err != nil { + return err + } + + p.Custom[t.TagName()] = t + } + } + } } return err } @@ -463,6 +525,13 @@ func decodeLineOfMediaPlaylist(p *MediaPlaylist, wv *WV, state *decodingState, l } state.tagMap = false } + + // if segment custom tag appeared before EXTINF then it links to this segment + if state.tagCustom { + p.Segments[p.last()].Custom = state.custom + state.custom = make(map[string]CustomTag) + state.tagCustom = false + } // start tag first case line == "#EXTM3U": state.m3u = true @@ -717,8 +786,26 @@ func decodeLineOfMediaPlaylist(p *MediaPlaylist, wv *WV, state *decodingState, l if err == nil { state.tagWV = true } - case strings.HasPrefix(line, "#"): // unknown tags treated as comments - return err + case strings.HasPrefix(line, "#"): + // if we have custom tags, check for those here, otherwise comments are ignored + if p.Custom != nil { + for _, v := range p.customTags { + if strings.HasPrefix(line, v.TagName()) { + t, err := v.Decode(line) + + if strict && err != nil { + return err + } + + if t.Segment() { + state.tagCustom = true + state.custom[t.TagName()] = t + } else { + p.Custom[t.TagName()] = t + } + } + } + } } return err } diff --git a/reader_test.go b/reader_test.go index 8fd73e93..666ff457 100644 --- a/reader_test.go +++ b/reader_test.go @@ -150,7 +150,7 @@ func TestDecodeMediaPlaylistByteRange(t *testing.T) { {URI: "video.ts", Duration: 10, Limit: 69864, SeqId: 2}, } for i, seg := range p.Segments { - if *seg != *expected[i] { + if !reflect.DeepEqual(*seg, *expected[i]) { t.Errorf("exp: %+v\ngot: %+v", expected[i], seg) } } diff --git a/sample-playlists/master-playlist-with-custom-tags.m3u8 b/sample-playlists/master-playlist-with-custom-tags.m3u8 new file mode 100644 index 00000000..126c6e23 --- /dev/null +++ b/sample-playlists/master-playlist-with-custom-tags.m3u8 @@ -0,0 +1,13 @@ +#EXTM3U +#EXT-X-VERSION:3 +#CUSTOM-PLAYLIST-TAG:42 +#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 diff --git a/sample-playlists/media-playlist-with-custom-tags.m3u8 b/sample-playlists/media-playlist-with-custom-tags.m3u8 new file mode 100644 index 00000000..8f7f4d8d --- /dev/null +++ b/sample-playlists/media-playlist-with-custom-tags.m3u8 @@ -0,0 +1,17 @@ +#EXTM3U +#EXT-X-TARGETDURATION:10 +#CUSTOM-PLAYLIST-TAG:42 +#EXT-X-VERSION:3 +#EXT-X-MEDIA-SEQUENCE:0 +#JUST A COMMENT +#EXTINF:10.0, +ad0.ts +#CUSTOM-SEGMENT-TAG:NAME="Yoda",JEDI=YES +#EXTINF:8.0, +ad1.ts +#EXT-X-DISCONTINUITY +#CUSTOM-SEGMENT-TAG:NAME="JarJar",JEDI=NO +#EXTINF:10.0, +movieA.ts +#EXTINF:10.0, +movieB.ts diff --git a/structure.go b/structure.go index 1cb1bb85..78e56c44 100644 --- a/structure.go +++ b/structure.go @@ -124,6 +124,8 @@ type MediaPlaylist struct { Key *Key // EXT-X-KEY is optional encryption key displayed before any segments (default key for the playlist) Map *Map // EXT-X-MAP is optional tag specifies how to obtain the Media Initialization Section (default map for the playlist) WV *WV // Widevine related tags outside of M3U8 specs + Custom map[string]CustomTag + customTags []CustomTag } /* @@ -148,6 +150,8 @@ type MasterPlaylist struct { buf bytes.Buffer ver uint8 independentSegments bool + Custom map[string]CustomTag + customTags []CustomTag } // This structure represents variants for master playlist. @@ -207,6 +211,7 @@ type MediaSegment struct { 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 } // SCTE holds custom, non EXT-X-DATERANGE, SCTE-35 tags @@ -269,9 +274,18 @@ type Playlist interface { Encode() *bytes.Buffer Decode(bytes.Buffer, bool) error DecodeFrom(reader io.Reader, strict bool) error + WithCustomTags([]CustomTag) Playlist String() string } +type CustomTag interface { + TagName() string + Decode(line string) (CustomTag, error) + Encode() *bytes.Buffer + String() string + Segment() bool +} + // Internal structure for decoding a line of input stream with a list type detection type decodingState struct { listType ListType @@ -285,6 +299,7 @@ type decodingState struct { tagProgramDateTime bool tagKey bool tagMap bool + tagCustom bool programDateTime time.Time limit int64 offset int64 @@ -295,4 +310,5 @@ type decodingState struct { xkey *Key xmap *Map scte *SCTE + custom map[string]CustomTag } diff --git a/writer.go b/writer.go index ffea44a3..99ea14c2 100644 --- a/writer.go +++ b/writer.go @@ -83,6 +83,16 @@ func (p *MasterPlaylist) Encode() *bytes.Buffer { p.buf.WriteString("#EXT-X-INDEPENDENT-SEGMENTS\n") } + // Write any custom master tags + if p.Custom != nil { + for _, v := range p.Custom { + if customBuf := v.Encode(); customBuf.Len() > 0 { + p.buf.WriteString(customBuf.String()) + p.buf.WriteRune('\n') + } + } + } + var altsWritten map[string]bool = make(map[string]bool) for _, pl := range p.Variants { @@ -384,6 +394,17 @@ func (p *MediaPlaylist) Encode() *bytes.Buffer { p.buf.WriteString("#EXTM3U\n#EXT-X-VERSION:") p.buf.WriteString(strver(p.ver)) p.buf.WriteRune('\n') + + // Write any custom master tags + if p.Custom != nil { + for _, v := range p.Custom { + if customBuf := v.Encode(); customBuf.Len() > 0 && !v.Segment() { + p.buf.WriteString(customBuf.String()) + p.buf.WriteRune('\n') + } + } + } + // default key (workaround for Widevine) if p.Key != nil { p.buf.WriteString("#EXT-X-KEY:") @@ -636,6 +657,17 @@ func (p *MediaPlaylist) Encode() *bytes.Buffer { p.buf.WriteString(strconv.FormatInt(seg.Offset, 10)) p.buf.WriteRune('\n') } + + // Add Custom Segment Tags here + if seg.Custom != nil { + for _, v := range seg.Custom { + if customBuf := v.Encode(); customBuf.Len() > 0 && v.Segment() { + p.buf.WriteString(customBuf.String()) + p.buf.WriteRune('\n') + } + } + } + p.buf.WriteString("#EXTINF:") if str, ok := durationCache[seg.Duration]; ok { p.buf.WriteString(str) From ddaef2580fafeb6a55706d85127cc04a68dfda3c Mon Sep 17 00:00:00 2001 From: Matthew Neil Date: Mon, 18 Mar 2019 13:31:25 -0400 Subject: [PATCH 2/9] add unit tests --- README.md | 7 +- reader_test.go | 240 ++++++++++++++++++ .../master-playlist-with-custom-tags.m3u8 | 1 + .../media-playlist-with-custom-tags.m3u8 | 1 + structure_test.go | 32 +++ 5 files changed, 280 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6c86e9a8..52ddfaa6 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ ways to play HLS or handle playlists over HTTP. So library features are: * Encryption keys support for use with DRM systems like [Verimatrix](http://verimatrix.com) etc. * Support for non standard [Google Widevine](http://www.widevine.com) tags. -The library covered by BSD 3-clause license. See [LICENSE](LICENSE) for the full text. +The library covered by BSD 3-clause license. See [LICENSE](LICENSE) for the full text. Versions 0.8 and below was covered by GPL v3. License was changed from the version 0.9 and upper. See the list of the library authors at [AUTHORS](AUTHORS) file. @@ -81,6 +81,11 @@ You may use API methods to fill structures or create them manually to generate p fmt.Println(p.Encode().String()) ``` +Custom Tags +----------- + +M3U8 supports parsing and writing of custom tags. You must implement the `CustomTag` interface for each custom tag that may be encountered in the playlist. Look at the template files in `example/template/` for examples on parsing custom playlist and segment tags. + Library structure ----------------- diff --git a/reader_test.go b/reader_test.go index 666ff457..efda639b 100644 --- a/reader_test.go +++ b/reader_test.go @@ -12,6 +12,7 @@ package m3u8 import ( "bufio" "bytes" + "errors" "fmt" "os" "reflect" @@ -594,6 +595,245 @@ func TestDecodeMediaPlaylistWithDiscontinuitySeq(t *testing.T) { } } +func TestDecodeMasterPlaylistWithCustomTags(t *testing.T) { + cases := []struct { + src string + customTags []CustomTag + expectedError error + expectedPlaylistTags []string + }{ + { + src: "sample-playlists/master-playlist-with-custom-tags.m3u8", + customTags: nil, + expectedError: nil, + expectedPlaylistTags: nil, + }, + { + src: "sample-playlists/master-playlist-with-custom-tags.m3u8", + customTags: []CustomTag{ + &MockCustomTag{ + name: "#CUSTOM-PLAYLIST-TAG:", + err: errors.New("Error decoding tag"), + segment: false, + encodedString: "#CUSTOM-PLAYLIST-TAG:42", + }, + }, + expectedError: errors.New("Error decoding tag"), + expectedPlaylistTags: nil, + }, + { + src: "sample-playlists/master-playlist-with-custom-tags.m3u8", + customTags: []CustomTag{ + &MockCustomTag{ + name: "#CUSTOM-PLAYLIST-TAG:", + err: nil, + segment: false, + encodedString: "#CUSTOM-PLAYLIST-TAG:42", + }, + }, + expectedError: nil, + expectedPlaylistTags: []string{ + "#CUSTOM-PLAYLIST-TAG:", + }, + }, + } + + for _, testCase := range cases { + f, err := os.Open(testCase.src) + + if err != nil { + t.Fatal(err) + } + + p, listType, err := DecodeWith(bufio.NewReader(f), true, testCase.customTags) + + if !reflect.DeepEqual(err, testCase.expectedError) { + t.Fatal(err) + } + + if testCase.expectedError != nil { + // No need to make other assertions if we were expecting an error + continue + } + + pp := p.(*MasterPlaylist) + + CheckType(t, pp) + + if listType != MASTER { + t.Error("Sample not recognized as master playlist.") + } + + if len(pp.Custom) != len(testCase.expectedPlaylistTags) { + t.Errorf("Did not parse expected number of custom tags. Got: %d Expected: %d", len(pp.Custom), len(testCase.expectedPlaylistTags)) + } else { + // we have the same count, lets confirm its the right tags + for _, expectedTag := range testCase.expectedPlaylistTags { + if _, ok := pp.Custom[expectedTag]; !ok { + t.Errorf("Did not parse custom tag %s", expectedTag) + } + } + } + } +} + +func TestDecodeMediaPlaylistWithCustomTags(t *testing.T) { + cases := []struct { + src string + customTags []CustomTag + expectedError error + expectedPlaylistTags []string + expectedSegmentTags []*struct { + index int + names []string + } + }{ + { + src: "sample-playlists/media-playlist-with-custom-tags.m3u8", + customTags: nil, + expectedError: nil, + expectedPlaylistTags: nil, + expectedSegmentTags: nil, + }, + { + src: "sample-playlists/media-playlist-with-custom-tags.m3u8", + customTags: []CustomTag{ + &MockCustomTag{ + name: "#CUSTOM-PLAYLIST-TAG:", + err: errors.New("Error decoding tag"), + segment: false, + encodedString: "#CUSTOM-PLAYLIST-TAG:42", + }, + }, + expectedError: errors.New("Error decoding tag"), + expectedPlaylistTags: nil, + expectedSegmentTags: nil, + }, + { + src: "sample-playlists/media-playlist-with-custom-tags.m3u8", + customTags: []CustomTag{ + &MockCustomTag{ + name: "#CUSTOM-PLAYLIST-TAG:", + err: nil, + segment: false, + encodedString: "#CUSTOM-PLAYLIST-TAG:42", + }, + &MockCustomTag{ + name: "#CUSTOM-SEGMENT-TAG:", + err: nil, + segment: true, + encodedString: "#CUSTOM-SEGMENT-TAG:NAME=\"Yoda\",JEDI=YES", + }, + &MockCustomTag{ + name: "#CUSTOM-SEGMENT-TAG-B", + err: nil, + segment: true, + encodedString: "#CUSTOM-SEGMENT-TAG-B", + }, + }, + expectedError: nil, + expectedPlaylistTags: []string{ + "#CUSTOM-PLAYLIST-TAG:", + }, + expectedSegmentTags: []*struct { + index int + names []string + }{ + &struct { + index int + names []string + }{1, []string{"#CUSTOM-SEGMENT-TAG:"}}, + &struct { + index int + names []string + }{2, []string{"#CUSTOM-SEGMENT-TAG:", "#CUSTOM-SEGMENT-TAG-B"}}, + }, + }, + } + + for _, testCase := range cases { + f, err := os.Open(testCase.src) + + if err != nil { + t.Fatal(err) + } + + p, listType, err := DecodeWith(bufio.NewReader(f), true, testCase.customTags) + + if !reflect.DeepEqual(err, testCase.expectedError) { + t.Fatal(err) + } + + if testCase.expectedError != nil { + // No need to make other assertions if we were expecting an error + continue + } + + pp := p.(*MediaPlaylist) + + CheckType(t, pp) + + if listType != MEDIA { + t.Error("Sample not recognized as master playlist.") + } + + if len(pp.Custom) != len(testCase.expectedPlaylistTags) { + t.Errorf("Did not parse expected number of custom tags. Got: %d Expected: %d", len(pp.Custom), len(testCase.expectedPlaylistTags)) + } else { + // we have the same count, lets confirm its the right tags + for _, expectedTag := range testCase.expectedPlaylistTags { + if _, ok := pp.Custom[expectedTag]; !ok { + t.Errorf("Did not parse custom tag %s", expectedTag) + } + } + } + + var expectedSegmentTag *struct { + index int + names []string + } + + expectedIndex := 0 + + for i := 0; i < int(pp.Count()); i++ { + seg := pp.Segments[i] + if expectedIndex != len(testCase.expectedSegmentTags) { + expectedSegmentTag = testCase.expectedSegmentTags[expectedIndex] + } else { + // we are at the end of the expectedSegmentTags list, the rest of the segments + // should have no custom tags + expectedSegmentTag = nil + } + + if expectedSegmentTag == nil || expectedSegmentTag.index != i { + if len(seg.Custom) != 0 { + t.Errorf("Did not parse expected number of custom tags on Segment %d. Got: %d Expected: %d", i, len(seg.Custom), 0) + } + continue + } + + // We are now checking the segment corresponding to exepectedSegmentTag + // increase our expectedIndex for next iteration + expectedIndex++ + + if len(expectedSegmentTag.names) != len(seg.Custom) { + t.Errorf("Did not parse expected number of custom tags on Segment %d. Got: %d Expected: %d", i, len(seg.Custom), len(expectedSegmentTag.names)) + } else { + // we have the same count, lets confirm its the right tags + for _, expectedTag := range expectedSegmentTag.names { + if _, ok := seg.Custom[expectedTag]; !ok { + t.Errorf("Did not parse customTag %s on Segment %d", expectedTag, i) + } + } + } + } + + if expectedIndex != len(testCase.expectedSegmentTags) { + t.Errorf("Did not parse custom tags on all expected segments. Parsed Segments: %d Expected: %d", expectedIndex, len(testCase.expectedSegmentTags)) + } + } +} + /*************************** * Code parsing examples * ***************************/ diff --git a/sample-playlists/master-playlist-with-custom-tags.m3u8 b/sample-playlists/master-playlist-with-custom-tags.m3u8 index 126c6e23..e31416a3 100644 --- a/sample-playlists/master-playlist-with-custom-tags.m3u8 +++ b/sample-playlists/master-playlist-with-custom-tags.m3u8 @@ -1,5 +1,6 @@ #EXTM3U #EXT-X-VERSION:3 +#JUST A COMMENT #CUSTOM-PLAYLIST-TAG:42 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=300000 chunklist-b300000.m3u8 diff --git a/sample-playlists/media-playlist-with-custom-tags.m3u8 b/sample-playlists/media-playlist-with-custom-tags.m3u8 index 8f7f4d8d..66c963b0 100644 --- a/sample-playlists/media-playlist-with-custom-tags.m3u8 +++ b/sample-playlists/media-playlist-with-custom-tags.m3u8 @@ -11,6 +11,7 @@ ad0.ts ad1.ts #EXT-X-DISCONTINUITY #CUSTOM-SEGMENT-TAG:NAME="JarJar",JEDI=NO +#CUSTOM-SEGMENT-TAG-B #EXTINF:10.0, movieA.ts #EXTINF:10.0, diff --git a/structure_test.go b/structure_test.go index 31b99d8f..757a62e1 100644 --- a/structure_test.go +++ b/structure_test.go @@ -10,6 +10,7 @@ package m3u8 import ( + "bytes" "testing" ) @@ -24,3 +25,34 @@ func TestNewMediaPlaylist(t *testing.T) { t.Fatalf("Create media playlist failed: %s", e) } } + +type MockCustomTag struct { + name string + err error + segment bool + encodedString string +} + +func (t *MockCustomTag) TagName() string { + return t.name +} + +func (t *MockCustomTag) Decode(line string) (CustomTag, error) { + return t, t.err +} + +func (t *MockCustomTag) Encode() *bytes.Buffer { + buf := new(bytes.Buffer) + + buf.WriteString(t.encodedString) + + return buf +} + +func (t *MockCustomTag) String() string { + return t.encodedString +} + +func (t *MockCustomTag) Segment() bool { + return t.segment +} From 372907b7daacb16e7e19b194b93af943175a503c Mon Sep 17 00:00:00 2001 From: Matthew Neil Date: Mon, 18 Mar 2019 14:36:03 -0400 Subject: [PATCH 3/9] go fmt --- .../template/custom-playlist-tag-template.go | 28 +++---- .../template/custom-segment-tag-template.go | 80 +++++++++---------- 2 files changed, 54 insertions(+), 54 deletions(-) diff --git a/example/template/custom-playlist-tag-template.go b/example/template/custom-playlist-tag-template.go index 854367d1..b5c5a62a 100644 --- a/example/template/custom-playlist-tag-template.go +++ b/example/template/custom-playlist-tag-template.go @@ -1,46 +1,46 @@ package template import ( - "bytes" - "fmt" - "strconv" + "bytes" + "fmt" + "strconv" - "github.com/grafov/m3u8" + "github.com/grafov/m3u8" ) // #CUSTOM-PLAYLIST-TAG: type CustomPlaylistTag struct { - Number int + Number int } // TagName() should return the full indentifier including the leading '#' and trailing ':' // if the tag also contains a value or attribute list func (tag *CustomPlaylistTag) TagName() string { - return "#CUSTOM-PLAYLIST-TAG:" + return "#CUSTOM-PLAYLIST-TAG:" } // line will be the entire matched line, including the identifier func (tag *CustomPlaylistTag) Decode(line string) (m3u8.CustomTag, error) { - _, err := fmt.Sscanf(line, "#CUSTOM-PLAYLIST-TAG:%d", &tag.Number) + _, err := fmt.Sscanf(line, "#CUSTOM-PLAYLIST-TAG:%d", &tag.Number) - return tag, err + return tag, err } func (tag *CustomPlaylistTag) Encode() *bytes.Buffer { - buf := new(bytes.Buffer) + buf := new(bytes.Buffer) - buf.WriteString(tag.TagName()) - buf.WriteString(strconv.Itoa(tag.Number)) + buf.WriteString(tag.TagName()) + buf.WriteString(strconv.Itoa(tag.Number)) - return buf + return buf } func (tag *CustomPlaylistTag) String() string { - return tag.Encode().String() + return tag.Encode().String() } // This is a playlist tag example func (tag *CustomPlaylistTag) Segment() bool { - return false + return false } diff --git a/example/template/custom-segment-tag-template.go b/example/template/custom-segment-tag-template.go index c763d3c4..3002e796 100644 --- a/example/template/custom-segment-tag-template.go +++ b/example/template/custom-segment-tag-template.go @@ -1,74 +1,74 @@ package template import ( - "bytes" - "errors" + "bytes" + "errors" - "github.com/grafov/m3u8" + "github.com/grafov/m3u8" ) // #CUSTOM-SEGMENT-TAG: type CustomSegmentTag struct { - Name string - Jedi bool + Name string + Jedi bool } // TagName() should return the full indentifier including the leading '#' and trailing ':' // if the tag also contains a value or attribute list func (tag *CustomSegmentTag) TagName() string { - return "#CUSTOM-SEGMENT-TAG:" + return "#CUSTOM-SEGMENT-TAG:" } // line will be the entire matched line, including the identifier func (tag *CustomSegmentTag) Decode(line string) (m3u8.CustomTag, error) { - var err error + var err error - // Since this is a Segment tag, we want to create a new tag every time it is decoded - // as there can be one for each segment with - newTag := new(CustomSegmentTag) + // Since this is a Segment tag, we want to create a new tag every time it is decoded + // as there can be one for each segment with + newTag := new(CustomSegmentTag) - for k, v := range m3u8.DecodeAttributeList(line[20:]) { - switch k { - case "NAME": - newTag.Name = v - case "JEDI": - if v == "YES" { - newTag.Jedi = true - } else if v == "NO" { - newTag.Jedi = false - } else { - err = errors.New("Valid strings for JEDI attribute are YES and NO.") - } - } - } + for k, v := range m3u8.DecodeAttributeList(line[20:]) { + switch k { + case "NAME": + newTag.Name = v + case "JEDI": + if v == "YES" { + newTag.Jedi = true + } else if v == "NO" { + newTag.Jedi = false + } else { + err = errors.New("Valid strings for JEDI attribute are YES and NO.") + } + } + } - return newTag, err + return newTag, err } func (tag *CustomSegmentTag) Encode() *bytes.Buffer { - buf := new(bytes.Buffer) + buf := new(bytes.Buffer) - if tag.Name != "" { - buf.WriteString(tag.TagName()) - buf.WriteString("NAME=\"") - buf.WriteString(tag.Name) - buf.WriteString("\",JEDI=") - if tag.Jedi { - buf.WriteString("YES") - } else { - buf.WriteString("NO") - } - } + if tag.Name != "" { + buf.WriteString(tag.TagName()) + buf.WriteString("NAME=\"") + buf.WriteString(tag.Name) + buf.WriteString("\",JEDI=") + if tag.Jedi { + buf.WriteString("YES") + } else { + buf.WriteString("NO") + } + } - return buf + return buf } func (tag *CustomSegmentTag) String() string { - return tag.Encode().String() + return tag.Encode().String() } // This is a playlist tag example func (tag *CustomSegmentTag) Segment() bool { - return true + return true } From 2ae4ee8ed39ea83c3d2497f9e1726c7a4319d673 Mon Sep 17 00:00:00 2001 From: Matthew Neil Date: Fri, 19 Apr 2019 15:13:44 -0400 Subject: [PATCH 4/9] add go.mod --- go.mod | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 go.mod diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..0e2a4148 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/grafov/m3u8 + +go 1.12 From 15e2411475a99ea99f271e3d4d590243d003587f Mon Sep 17 00:00:00 2001 From: Matthew Neil Date: Thu, 23 May 2019 14:31:43 -0400 Subject: [PATCH 5/9] add writer tests, breakup custom interface --- example/example.go | 2 +- .../template/custom-playlist-tag-template.go | 11 ++-- .../template/custom-segment-tag-template.go | 11 ++-- reader.go | 32 +++++------ reader_test.go | 20 +++---- structure.go | 12 +++-- writer.go | 36 ++++++++++++- writer_test.go | 54 +++++++++++++++++++ 8 files changed, 135 insertions(+), 43 deletions(-) diff --git a/example/example.go b/example/example.go index d1074e34..074efff4 100644 --- a/example/example.go +++ b/example/example.go @@ -22,7 +22,7 @@ func main() { panic(err) } - customTags := []m3u8.CustomTag{ + customTags := []m3u8.CustomDecoder{ &template.CustomPlaylistTag{}, &template.CustomSegmentTag{}, } diff --git a/example/template/custom-playlist-tag-template.go b/example/template/custom-playlist-tag-template.go index b5c5a62a..1690117a 100644 --- a/example/template/custom-playlist-tag-template.go +++ b/example/template/custom-playlist-tag-template.go @@ -10,6 +10,7 @@ import ( // #CUSTOM-PLAYLIST-TAG: +// Implements both CustomTag and CustomDecoder interfaces type CustomPlaylistTag struct { Number int } @@ -27,6 +28,11 @@ func (tag *CustomPlaylistTag) Decode(line string) (m3u8.CustomTag, error) { return tag, err } +// This is a playlist tag example +func (tag *CustomPlaylistTag) Segment() bool { + return false +} + func (tag *CustomPlaylistTag) Encode() *bytes.Buffer { buf := new(bytes.Buffer) @@ -39,8 +45,3 @@ func (tag *CustomPlaylistTag) Encode() *bytes.Buffer { func (tag *CustomPlaylistTag) String() string { return tag.Encode().String() } - -// This is a playlist tag example -func (tag *CustomPlaylistTag) Segment() bool { - return false -} diff --git a/example/template/custom-segment-tag-template.go b/example/template/custom-segment-tag-template.go index 3002e796..f1855fb9 100644 --- a/example/template/custom-segment-tag-template.go +++ b/example/template/custom-segment-tag-template.go @@ -9,6 +9,7 @@ import ( // #CUSTOM-SEGMENT-TAG: +// Implements both CustomTag and CustomDecoder interfaces type CustomSegmentTag struct { Name string Jedi bool @@ -46,6 +47,11 @@ func (tag *CustomSegmentTag) Decode(line string) (m3u8.CustomTag, error) { return newTag, err } +// This is a playlist tag example +func (tag *CustomSegmentTag) Segment() bool { + return true +} + func (tag *CustomSegmentTag) Encode() *bytes.Buffer { buf := new(bytes.Buffer) @@ -67,8 +73,3 @@ func (tag *CustomSegmentTag) Encode() *bytes.Buffer { func (tag *CustomSegmentTag) String() string { return tag.Encode().String() } - -// This is a playlist tag example -func (tag *CustomSegmentTag) Segment() bool { - return true -} diff --git a/reader.go b/reader.go index ea75248f..d67aa387 100644 --- a/reader.go +++ b/reader.go @@ -48,13 +48,13 @@ func (p *MasterPlaylist) DecodeFrom(reader io.Reader, strict bool) error { return p.decode(buf, strict) } -func (p *MasterPlaylist) WithCustomTags(customTags []CustomTag) Playlist { +func (p *MasterPlaylist) WithCustomDecoders(customDecoders []CustomDecoder) Playlist { // Create the map if it doesn't already exist if p.Custom == nil { p.Custom = make(map[string]CustomTag) } - p.customTags = customTags + p.customDecoders = customDecoders return p } @@ -101,13 +101,13 @@ func (p *MediaPlaylist) DecodeFrom(reader io.Reader, strict bool) error { return p.decode(buf, strict) } -func (p *MediaPlaylist) WithCustomTags(customTags []CustomTag) Playlist { +func (p *MediaPlaylist) WithCustomDecoders(customDecoders []CustomDecoder) Playlist { // Create the map if it doesn't already exist if p.Custom == nil { p.Custom = make(map[string]CustomTag) } - p.customTags = customTags + p.customDecoders = customDecoders return p } @@ -159,17 +159,17 @@ func DecodeFrom(reader io.Reader, strict bool) (Playlist, ListType, error) { return decode(buf, strict, nil) } -func DecodeWith(input interface{}, strict bool, customTags []CustomTag) (Playlist, ListType, error) { +func DecodeWith(input interface{}, strict bool, customDecoders []CustomDecoder) (Playlist, ListType, error) { switch v := input.(type) { case bytes.Buffer: - return decode(&v, strict, customTags) + return decode(&v, strict, customDecoders) case io.Reader: buf := new(bytes.Buffer) _, err := buf.ReadFrom(v) if err != nil { return nil, 0, err } - return decode(buf, strict, customTags) + return decode(buf, strict, customDecoders) default: return nil, 0, errors.New("input must be bytes.Buffer or io.Reader type") } @@ -177,7 +177,7 @@ func DecodeWith(input interface{}, strict bool, customTags []CustomTag) (Playlis // Detect playlist type and decode it. May be used as decoder for both // master and media playlists. -func decode(buf *bytes.Buffer, strict bool, customTags []CustomTag) (Playlist, ListType, error) { +func decode(buf *bytes.Buffer, strict bool, customDecoders []CustomDecoder) (Playlist, ListType, error) { var eof bool var line string var master *MasterPlaylist @@ -195,9 +195,9 @@ func decode(buf *bytes.Buffer, strict bool, customTags []CustomTag) (Playlist, L } // If we have custom tags to parse - if customTags != nil { - media = media.WithCustomTags(customTags).(*MediaPlaylist) - master = master.WithCustomTags(customTags).(*MasterPlaylist) + if customDecoders != nil { + media = media.WithCustomDecoders(customDecoders).(*MediaPlaylist) + master = master.WithCustomDecoders(customDecoders).(*MasterPlaylist) state.custom = make(map[string]CustomTag) } @@ -421,7 +421,7 @@ func decodeLineOfMasterPlaylist(p *MasterPlaylist, state *decodingState, line st case strings.HasPrefix(line, "#"): // if we have custom tags, check for those here, otherwise comments are ignored if p.Custom != nil { - for _, v := range p.customTags { + for _, v := range p.customDecoders { if strings.HasPrefix(line, v.TagName()) { t, err := v.Decode(line) @@ -789,7 +789,7 @@ func decodeLineOfMediaPlaylist(p *MediaPlaylist, wv *WV, state *decodingState, l case strings.HasPrefix(line, "#"): // if we have custom tags, check for those here, otherwise comments are ignored if p.Custom != nil { - for _, v := range p.customTags { + for _, v := range p.customDecoders { if strings.HasPrefix(line, v.TagName()) { t, err := v.Decode(line) @@ -797,11 +797,11 @@ func decodeLineOfMediaPlaylist(p *MediaPlaylist, wv *WV, state *decodingState, l return err } - if t.Segment() { + if v.Segment() { state.tagCustom = true - state.custom[t.TagName()] = t + state.custom[v.TagName()] = t } else { - p.Custom[t.TagName()] = t + p.Custom[v.TagName()] = t } } } diff --git a/reader_test.go b/reader_test.go index efda639b..09293f0b 100644 --- a/reader_test.go +++ b/reader_test.go @@ -598,19 +598,19 @@ func TestDecodeMediaPlaylistWithDiscontinuitySeq(t *testing.T) { func TestDecodeMasterPlaylistWithCustomTags(t *testing.T) { cases := []struct { src string - customTags []CustomTag + customDecoders []CustomDecoder expectedError error expectedPlaylistTags []string }{ { src: "sample-playlists/master-playlist-with-custom-tags.m3u8", - customTags: nil, + customDecoders: nil, expectedError: nil, expectedPlaylistTags: nil, }, { src: "sample-playlists/master-playlist-with-custom-tags.m3u8", - customTags: []CustomTag{ + customDecoders: []CustomDecoder{ &MockCustomTag{ name: "#CUSTOM-PLAYLIST-TAG:", err: errors.New("Error decoding tag"), @@ -623,7 +623,7 @@ func TestDecodeMasterPlaylistWithCustomTags(t *testing.T) { }, { src: "sample-playlists/master-playlist-with-custom-tags.m3u8", - customTags: []CustomTag{ + customDecoders: []CustomDecoder{ &MockCustomTag{ name: "#CUSTOM-PLAYLIST-TAG:", err: nil, @@ -645,7 +645,7 @@ func TestDecodeMasterPlaylistWithCustomTags(t *testing.T) { t.Fatal(err) } - p, listType, err := DecodeWith(bufio.NewReader(f), true, testCase.customTags) + p, listType, err := DecodeWith(bufio.NewReader(f), true, testCase.customDecoders) if !reflect.DeepEqual(err, testCase.expectedError) { t.Fatal(err) @@ -680,7 +680,7 @@ func TestDecodeMasterPlaylistWithCustomTags(t *testing.T) { func TestDecodeMediaPlaylistWithCustomTags(t *testing.T) { cases := []struct { src string - customTags []CustomTag + customDecoders []CustomDecoder expectedError error expectedPlaylistTags []string expectedSegmentTags []*struct { @@ -690,14 +690,14 @@ func TestDecodeMediaPlaylistWithCustomTags(t *testing.T) { }{ { src: "sample-playlists/media-playlist-with-custom-tags.m3u8", - customTags: nil, + customDecoders: nil, expectedError: nil, expectedPlaylistTags: nil, expectedSegmentTags: nil, }, { src: "sample-playlists/media-playlist-with-custom-tags.m3u8", - customTags: []CustomTag{ + customDecoders: []CustomDecoder{ &MockCustomTag{ name: "#CUSTOM-PLAYLIST-TAG:", err: errors.New("Error decoding tag"), @@ -711,7 +711,7 @@ func TestDecodeMediaPlaylistWithCustomTags(t *testing.T) { }, { src: "sample-playlists/media-playlist-with-custom-tags.m3u8", - customTags: []CustomTag{ + customDecoders: []CustomDecoder{ &MockCustomTag{ name: "#CUSTOM-PLAYLIST-TAG:", err: nil, @@ -758,7 +758,7 @@ func TestDecodeMediaPlaylistWithCustomTags(t *testing.T) { t.Fatal(err) } - p, listType, err := DecodeWith(bufio.NewReader(f), true, testCase.customTags) + p, listType, err := DecodeWith(bufio.NewReader(f), true, testCase.customDecoders) if !reflect.DeepEqual(err, testCase.expectedError) { t.Fatal(err) diff --git a/structure.go b/structure.go index 78e56c44..1f0e8e28 100644 --- a/structure.go +++ b/structure.go @@ -125,7 +125,7 @@ type MediaPlaylist struct { Map *Map // EXT-X-MAP is optional tag specifies how to obtain the Media Initialization Section (default map for the playlist) WV *WV // Widevine related tags outside of M3U8 specs Custom map[string]CustomTag - customTags []CustomTag + customDecoders []CustomDecoder } /* @@ -151,7 +151,7 @@ type MasterPlaylist struct { ver uint8 independentSegments bool Custom map[string]CustomTag - customTags []CustomTag + customDecoders []CustomDecoder } // This structure represents variants for master playlist. @@ -274,15 +274,19 @@ type Playlist interface { Encode() *bytes.Buffer Decode(bytes.Buffer, bool) error DecodeFrom(reader io.Reader, strict bool) error - WithCustomTags([]CustomTag) Playlist + WithCustomDecoders([]CustomDecoder) Playlist String() string } type CustomTag interface { TagName() string - Decode(line string) (CustomTag, error) Encode() *bytes.Buffer String() string +} + +type CustomDecoder interface { + TagName() string + Decode(line string) (CustomTag, error) Segment() bool } diff --git a/writer.go b/writer.go index 99ea14c2..e14f02fa 100644 --- a/writer.go +++ b/writer.go @@ -273,6 +273,14 @@ func (p *MasterPlaylist) Encode() *bytes.Buffer { return &p.buf } +func (p *MasterPlaylist) SetCustomTag(tag CustomTag) { + if p.Custom == nil { + p.Custom = make(map[string]CustomTag) + } + + p.Custom[tag.TagName()] = tag +} + // Version returns the current playlist version number func (p *MasterPlaylist) Version() uint8 { return p.ver @@ -398,7 +406,7 @@ func (p *MediaPlaylist) Encode() *bytes.Buffer { // Write any custom master tags if p.Custom != nil { for _, v := range p.Custom { - if customBuf := v.Encode(); customBuf.Len() > 0 && !v.Segment() { + if customBuf := v.Encode(); customBuf.Len() > 0 { p.buf.WriteString(customBuf.String()) p.buf.WriteRune('\n') } @@ -661,7 +669,7 @@ func (p *MediaPlaylist) Encode() *bytes.Buffer { // Add Custom Segment Tags here if seg.Custom != nil { for _, v := range seg.Custom { - if customBuf := v.Encode(); customBuf.Len() > 0 && v.Segment() { + if customBuf := v.Encode(); customBuf.Len() > 0 { p.buf.WriteString(customBuf.String()) p.buf.WriteRune('\n') } @@ -834,6 +842,30 @@ func (p *MediaPlaylist) SetProgramDateTime(value time.Time) error { return nil } +func (p *MediaPlaylist) SetCustomTag(tag CustomTag) { + if p.Custom == nil { + p.Custom = make(map[string]CustomTag) + } + + p.Custom[tag.TagName()] = tag +} + +func (p *MediaPlaylist) SetCustomSegmentTag(tag CustomTag) error { + if p.count == 0 { + return errors.New("playlist is empty") + } + + last := p.Segments[p.last()] + + if last.Custom == nil { + last.Custom = make(map[string]CustomTag) + } + + last.Custom[tag.TagName()] = tag + + return nil +} + // Version returns the current playlist version number func (p *MediaPlaylist) Version() uint8 { return p.ver diff --git a/writer_test.go b/writer_test.go index 76db9899..5c4b61dd 100644 --- a/writer_test.go +++ b/writer_test.go @@ -378,6 +378,44 @@ func TestEncodeMediaPlaylistWithDefaultMap(t *testing.T) { } } +// Create new media playlist +// Add custom playlist tag +// Add segment with custom tag +func TestEncodeMediaPlaylistWithCustomTags(t *testing.T) { + p, e := NewMediaPlaylist(1, 1) + if e != nil { + t.Fatalf("Create media playlist failed: %s", e) + } + + customPTag := &MockCustomTag{ + name: "#CustomPTag", + encodedString: "#CustomPTag", + } + p.SetCustomTag(customPTag) + + e = p.Append("test01.ts", 5.0, "") + if e != nil { + t.Fatalf("Add 1st segment to a media playlist failed: %s", e) + } + + customSTag := &MockCustomTag{ + name: "#CustomSTag", + encodedString: "#CustomSTag", + } + e = p.SetCustomSegmentTag(customSTag) + if e != nil { + t.Fatalf("Set CustomTag to segment failed: %s", e) + } + + encoded := p.String() + expectedStrings := []string{"#CustomPTag", "#CustomSTag"} + for _, expected := range expectedStrings { + if !strings.Contains(encoded, expected) { + t.Fatalf("Media playlist does not contain custom tag: %s\nMedia Playlist:\n%v", expected, encoded) + } + } +} + // Create new media playlist // Add two segments to media playlist // Encode structures to HLS @@ -855,6 +893,22 @@ func TestEncodeMasterPlaylistWithStreamInfName(t *testing.T) { } } +func TestEncodeMasterPlaylistWithCustomTags(t *testing.T) { + m := NewMasterPlaylist() + customMTag := &MockCustomTag{ + name: "#CustomMTag", + encodedString: "#CustomMTag", + } + m.SetCustomTag(customMTag) + + encoded := m.String() + expected := "#CustomMTag" + + if !strings.Contains(encoded, expected) { + t.Fatalf("Master playlist does not contain cusomt tag: %s\n Master Playlist:\n%v", expected, encoded) + } +} + func TestMasterVersion(t *testing.T) { m := NewMasterPlaylist() m.ver = 5 From 13df0e209ed471d6852bf0550e99a11eb7223987 Mon Sep 17 00:00:00 2001 From: Matthew Neil Date: Thu, 23 May 2019 14:33:05 -0400 Subject: [PATCH 6/9] update readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 52ddfaa6..a2c6634a 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ You may use API methods to fill structures or create them manually to generate p Custom Tags ----------- -M3U8 supports parsing and writing of custom tags. You must implement the `CustomTag` interface for each custom tag that may be encountered in the playlist. Look at the template files in `example/template/` for examples on parsing custom playlist and segment tags. +M3U8 supports parsing and writing of custom tags. You must implement both the `CustomTag` and `CustomDecoder` interface for each custom tag that may be encountered in the playlist. Look at the template files in `example/template/` for examples on parsing custom playlist and segment tags. Library structure ----------------- From 23b66b5cd8adb2d3e50a0f4898d6d32cf4d9f15e Mon Sep 17 00:00:00 2001 From: Matthew Neil Date: Fri, 31 May 2019 10:38:20 -0400 Subject: [PATCH 7/9] add comments to exports --- reader.go | 6 ++++++ structure.go | 14 ++++++++------ writer.go | 3 +++ 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/reader.go b/reader.go index d67aa387..40191f9d 100644 --- a/reader.go +++ b/reader.go @@ -48,6 +48,7 @@ func (p *MasterPlaylist) DecodeFrom(reader io.Reader, strict bool) error { return p.decode(buf, strict) } +// WithCustomDecoders adds custom tag decoders to the master playlist for decoding func (p *MasterPlaylist) WithCustomDecoders(customDecoders []CustomDecoder) Playlist { // Create the map if it doesn't already exist if p.Custom == nil { @@ -101,6 +102,7 @@ func (p *MediaPlaylist) DecodeFrom(reader io.Reader, strict bool) error { return p.decode(buf, strict) } +// WithCustomDecoders adds custom tag decoders to the media playlist for decoding func (p *MediaPlaylist) WithCustomDecoders(customDecoders []CustomDecoder) Playlist { // Create the map if it doesn't already exist if p.Custom == nil { @@ -159,6 +161,8 @@ func DecodeFrom(reader io.Reader, strict bool) (Playlist, ListType, error) { return decode(buf, strict, nil) } +// DecodeWith detects the type of playlist and decodes it. It accepts either bytes.Buffer +// or io.Reader as input. Any custom decoders provided will be used during decoding. func DecodeWith(input interface{}, strict bool, customDecoders []CustomDecoder) (Playlist, ListType, error) { switch v := input.(type) { case bytes.Buffer: @@ -247,6 +251,8 @@ func decode(buf *bytes.Buffer, strict bool, customDecoders []CustomDecoder) (Pla return nil, state.listType, errors.New("Can't detect playlist type") } +// DecodeAttributeList turns an attribute list into a key, value map. You should trim +// any characters not part of the attribute list, such as the tag and ':'. func DecodeAttributeList(line string) map[string]string { return decodeParamsLine(line) } diff --git a/structure.go b/structure.go index 1f0e8e28..58e0c783 100644 --- a/structure.go +++ b/structure.go @@ -278,18 +278,20 @@ type Playlist interface { String() string } -type CustomTag interface { - TagName() string - Encode() *bytes.Buffer - String() string -} - +// Interface for decoding custom and unsupported tags type CustomDecoder interface { TagName() string Decode(line string) (CustomTag, error) Segment() bool } +// Interface for encoding custom and unsupported tags +type CustomTag interface { + TagName() string + Encode() *bytes.Buffer + String() string +} + // Internal structure for decoding a line of input stream with a list type detection type decodingState struct { listType ListType diff --git a/writer.go b/writer.go index e14f02fa..8e9577e1 100644 --- a/writer.go +++ b/writer.go @@ -273,6 +273,7 @@ func (p *MasterPlaylist) Encode() *bytes.Buffer { return &p.buf } +// SetCustomTag sets the provided tag on the master playlist for its TagName func (p *MasterPlaylist) SetCustomTag(tag CustomTag) { if p.Custom == nil { p.Custom = make(map[string]CustomTag) @@ -842,6 +843,7 @@ func (p *MediaPlaylist) SetProgramDateTime(value time.Time) error { return nil } +// SetCustomTag sets the provided tag on the media playlist for its TagName func (p *MediaPlaylist) SetCustomTag(tag CustomTag) { if p.Custom == nil { p.Custom = make(map[string]CustomTag) @@ -850,6 +852,7 @@ func (p *MediaPlaylist) SetCustomTag(tag CustomTag) { p.Custom[tag.TagName()] = tag } +// SetCustomTag sets the provided tag on the current media segment for its TagName func (p *MediaPlaylist) SetCustomSegmentTag(tag CustomTag) error { if p.count == 0 { return errors.New("playlist is empty") From e08fbda90da8b3e23fa4580cb1dc12767b214787 Mon Sep 17 00:00:00 2001 From: Matthew Neil Date: Wed, 26 Jun 2019 11:18:46 -0400 Subject: [PATCH 8/9] parse custom tags first --- reader.go | 71 +++++++++++++++++++++++++++++-------------------------- 1 file changed, 38 insertions(+), 33 deletions(-) diff --git a/reader.go b/reader.go index 40191f9d..c82b8079 100644 --- a/reader.go +++ b/reader.go @@ -272,6 +272,21 @@ func decodeLineOfMasterPlaylist(p *MasterPlaylist, state *decodingState, line st line = strings.TrimSpace(line) + // check for custom tags first to allow custom parsing of existing tags + if p.Custom != nil { + for _, v := range p.customDecoders { + if strings.HasPrefix(line, v.TagName()) { + t, err := v.Decode(line) + + if strict && err != nil { + return err + } + + p.Custom[t.TagName()] = t + } + } + } + switch { case line == "#EXTM3U": // start tag first state.m3u = true @@ -425,20 +440,7 @@ func decodeLineOfMasterPlaylist(p *MasterPlaylist, state *decodingState, line st } } case strings.HasPrefix(line, "#"): - // if we have custom tags, check for those here, otherwise comments are ignored - if p.Custom != nil { - for _, v := range p.customDecoders { - if strings.HasPrefix(line, v.TagName()) { - t, err := v.Decode(line) - - if strict && err != nil { - return err - } - - p.Custom[t.TagName()] = t - } - } - } + // comments are ignored } return err } @@ -448,6 +450,27 @@ func decodeLineOfMediaPlaylist(p *MediaPlaylist, wv *WV, state *decodingState, l var err error line = strings.TrimSpace(line) + + // check for custom tags first to allow custom parsing of existing tags + if p.Custom != nil { + for _, v := range p.customDecoders { + if strings.HasPrefix(line, v.TagName()) { + t, err := v.Decode(line) + + if strict && err != nil { + return err + } + + if v.Segment() { + state.tagCustom = true + state.custom[v.TagName()] = t + } else { + p.Custom[v.TagName()] = t + } + } + } + } + switch { case !state.tagInf && strings.HasPrefix(line, "#EXTINF:"): state.tagInf = true @@ -793,25 +816,7 @@ func decodeLineOfMediaPlaylist(p *MediaPlaylist, wv *WV, state *decodingState, l state.tagWV = true } case strings.HasPrefix(line, "#"): - // if we have custom tags, check for those here, otherwise comments are ignored - if p.Custom != nil { - for _, v := range p.customDecoders { - if strings.HasPrefix(line, v.TagName()) { - t, err := v.Decode(line) - - if strict && err != nil { - return err - } - - if v.Segment() { - state.tagCustom = true - state.custom[v.TagName()] = t - } else { - p.Custom[v.TagName()] = t - } - } - } - } + // comments are ignored } return err } From b6f2ecc8d3d81c8c4e68508c3554ef477fd1f339 Mon Sep 17 00:00:00 2001 From: Matthew Neil Date: Tue, 16 Jul 2019 11:27:18 -0400 Subject: [PATCH 9/9] update CustomDecoder.Segment to CustomDecoder.SegmentTag --- .../template/custom-playlist-tag-template.go | 2 +- .../template/custom-segment-tag-template.go | 2 +- reader.go | 2 +- structure.go | 14 ++++++++++++- structure_test.go | 6 +++++- writer.go | 6 +++--- writer_test.go | 21 +++++++++++++++++++ 7 files changed, 45 insertions(+), 8 deletions(-) diff --git a/example/template/custom-playlist-tag-template.go b/example/template/custom-playlist-tag-template.go index 1690117a..c077d222 100644 --- a/example/template/custom-playlist-tag-template.go +++ b/example/template/custom-playlist-tag-template.go @@ -29,7 +29,7 @@ func (tag *CustomPlaylistTag) Decode(line string) (m3u8.CustomTag, error) { } // This is a playlist tag example -func (tag *CustomPlaylistTag) Segment() bool { +func (tag *CustomPlaylistTag) SegmentTag() bool { return false } diff --git a/example/template/custom-segment-tag-template.go b/example/template/custom-segment-tag-template.go index f1855fb9..bfa52c76 100644 --- a/example/template/custom-segment-tag-template.go +++ b/example/template/custom-segment-tag-template.go @@ -48,7 +48,7 @@ func (tag *CustomSegmentTag) Decode(line string) (m3u8.CustomTag, error) { } // This is a playlist tag example -func (tag *CustomSegmentTag) Segment() bool { +func (tag *CustomSegmentTag) SegmentTag() bool { return true } diff --git a/reader.go b/reader.go index c82b8079..86134762 100644 --- a/reader.go +++ b/reader.go @@ -461,7 +461,7 @@ func decodeLineOfMediaPlaylist(p *MediaPlaylist, wv *WV, state *decodingState, l return err } - if v.Segment() { + if v.SegmentTag() { state.tagCustom = true state.custom[v.TagName()] = t } else { diff --git a/structure.go b/structure.go index 58e0c783..ea0d5c85 100644 --- a/structure.go +++ b/structure.go @@ -280,15 +280,27 @@ type Playlist interface { // Interface for decoding custom and unsupported tags type CustomDecoder interface { + // TagName should return the full indentifier including the leading '#' as well as the + // trailing ':' if the tag also contains a value or attribute list TagName() string + // Decode parses a line from the playlist and returns the CustomTag representation Decode(line string) (CustomTag, error) - Segment() bool + // SegmentTag should return true if this CustomDecoder should apply per segment. + // Should returns false if it a MediaPlaylist header tag. + // This value is ignored for MasterPlaylists. + SegmentTag() bool } // Interface for encoding custom and unsupported tags type CustomTag interface { + // TagName should return the full indentifier including the leading '#' as well as the + // trailing ':' if the tag also contains a value or attribute list TagName() string + // Encode should return the complete tag string as a *bytes.Buffer. This will + // be used by Playlist.Decode to write the tag to the m3u8. + // Return nil to not write anything to the m3u8. Encode() *bytes.Buffer + // String should return the encoded tag as a string. String() string } diff --git a/structure_test.go b/structure_test.go index 757a62e1..4ab7d36a 100644 --- a/structure_test.go +++ b/structure_test.go @@ -42,6 +42,10 @@ func (t *MockCustomTag) Decode(line string) (CustomTag, error) { } func (t *MockCustomTag) Encode() *bytes.Buffer { + if t.encodedString == "" { + return nil + } + buf := new(bytes.Buffer) buf.WriteString(t.encodedString) @@ -53,6 +57,6 @@ func (t *MockCustomTag) String() string { return t.encodedString } -func (t *MockCustomTag) Segment() bool { +func (t *MockCustomTag) SegmentTag() bool { return t.segment } diff --git a/writer.go b/writer.go index 8e9577e1..ae736067 100644 --- a/writer.go +++ b/writer.go @@ -86,7 +86,7 @@ func (p *MasterPlaylist) Encode() *bytes.Buffer { // Write any custom master tags if p.Custom != nil { for _, v := range p.Custom { - if customBuf := v.Encode(); customBuf.Len() > 0 { + if customBuf := v.Encode(); customBuf != nil { p.buf.WriteString(customBuf.String()) p.buf.WriteRune('\n') } @@ -407,7 +407,7 @@ func (p *MediaPlaylist) Encode() *bytes.Buffer { // Write any custom master tags if p.Custom != nil { for _, v := range p.Custom { - if customBuf := v.Encode(); customBuf.Len() > 0 { + if customBuf := v.Encode(); customBuf != nil { p.buf.WriteString(customBuf.String()) p.buf.WriteRune('\n') } @@ -670,7 +670,7 @@ func (p *MediaPlaylist) Encode() *bytes.Buffer { // Add Custom Segment Tags here if seg.Custom != nil { for _, v := range seg.Custom { - if customBuf := v.Encode(); customBuf.Len() > 0 { + if customBuf := v.Encode(); customBuf != nil { p.buf.WriteString(customBuf.String()) p.buf.WriteRune('\n') } diff --git a/writer_test.go b/writer_test.go index 5c4b61dd..bfa2a798 100644 --- a/writer_test.go +++ b/writer_test.go @@ -393,6 +393,12 @@ func TestEncodeMediaPlaylistWithCustomTags(t *testing.T) { } p.SetCustomTag(customPTag) + customEmptyPTag := &MockCustomTag{ + name: "#CustomEmptyPTag", + encodedString: "", + } + p.SetCustomTag(customEmptyPTag) + e = p.Append("test01.ts", 5.0, "") if e != nil { t.Fatalf("Add 1st segment to a media playlist failed: %s", e) @@ -407,6 +413,15 @@ func TestEncodeMediaPlaylistWithCustomTags(t *testing.T) { t.Fatalf("Set CustomTag to segment failed: %s", e) } + customEmptySTag := &MockCustomTag{ + name: "#CustomEmptySTag", + encodedString: "", + } + e = p.SetCustomSegmentTag(customEmptySTag) + if e != nil { + t.Fatalf("Set CustomTag to segment failed: %s", e) + } + encoded := p.String() expectedStrings := []string{"#CustomPTag", "#CustomSTag"} for _, expected := range expectedStrings { @@ -414,6 +429,12 @@ func TestEncodeMediaPlaylistWithCustomTags(t *testing.T) { t.Fatalf("Media playlist does not contain custom tag: %s\nMedia Playlist:\n%v", expected, encoded) } } + unexpectedStrings := []string{"#CustomEmptyPTag", "#CustomEmptySTag"} + for _, unexpected := range unexpectedStrings { + if strings.Contains(encoded, unexpected) { + t.Fatalf("Media playlist contains unexpected custom tag: %s\nMedia Playlist:\n%v", unexpected, encoded) + } + } } // Create new media playlist