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/README.md b/README.md index 6c86e9a8..a2c6634a 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 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 ----------------- diff --git a/example/example.go b/example/example.go index 3590fde0..074efff4 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.CustomDecoder{ + &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..c077d222 --- /dev/null +++ b/example/template/custom-playlist-tag-template.go @@ -0,0 +1,47 @@ +package template + +import ( + "bytes" + "fmt" + "strconv" + + "github.com/grafov/m3u8" +) + +// #CUSTOM-PLAYLIST-TAG: + +// Implements both CustomTag and CustomDecoder interfaces +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 +} + +// This is a playlist tag example +func (tag *CustomPlaylistTag) SegmentTag() bool { + return false +} + +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() +} diff --git a/example/template/custom-segment-tag-template.go b/example/template/custom-segment-tag-template.go new file mode 100644 index 00000000..bfa52c76 --- /dev/null +++ b/example/template/custom-segment-tag-template.go @@ -0,0 +1,75 @@ +package template + +import ( + "bytes" + "errors" + + "github.com/grafov/m3u8" +) + +// #CUSTOM-SEGMENT-TAG: + +// Implements both CustomTag and CustomDecoder interfaces +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 +} + +// This is a playlist tag example +func (tag *CustomSegmentTag) SegmentTag() bool { + return true +} + +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() +} 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 diff --git a/reader.go b/reader.go index e8240698..86134762 100644 --- a/reader.go +++ b/reader.go @@ -48,6 +48,18 @@ 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 { + p.Custom = make(map[string]CustomTag) + } + + p.customDecoders = customDecoders + + return p +} + // Parse master playlist. Internal function. func (p *MasterPlaylist) decode(buf *bytes.Buffer, strict bool) error { var eof bool @@ -90,6 +102,18 @@ 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 { + p.Custom = make(map[string]CustomTag) + } + + p.customDecoders = customDecoders + + return p +} + func (p *MediaPlaylist) decode(buf *bytes.Buffer, strict bool) error { var eof bool var line string @@ -123,7 +147,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 +158,30 @@ 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) +} + +// 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: + 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, customDecoders) + 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, customDecoders []CustomDecoder) (Playlist, ListType, error) { var eof bool var line string var master *MasterPlaylist @@ -156,6 +198,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 customDecoders != nil { + media = media.WithCustomDecoders(customDecoders).(*MediaPlaylist) + master = master.WithCustomDecoders(customDecoders).(*MasterPlaylist) + state.custom = make(map[string]CustomTag) + } + for !eof { if line, err = buf.ReadString('\n'); err == io.EOF { eof = true @@ -202,6 +251,12 @@ func decode(buf *bytes.Buffer, strict bool) (Playlist, ListType, error) { 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) +} + func decodeParamsLine(line string) map[string]string { out := make(map[string]string) for _, kv := range reKeyValue.FindAllStringSubmatch(line, -1) { @@ -217,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 @@ -369,8 +439,8 @@ 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, "#"): + // comments are ignored } return err } @@ -380,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.SegmentTag() { + 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 @@ -463,6 +554,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 +815,8 @@ 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, "#"): + // comments are ignored } return err } diff --git a/reader_test.go b/reader_test.go index 8fd73e93..09293f0b 100644 --- a/reader_test.go +++ b/reader_test.go @@ -12,6 +12,7 @@ package m3u8 import ( "bufio" "bytes" + "errors" "fmt" "os" "reflect" @@ -150,7 +151,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) } } @@ -594,6 +595,245 @@ func TestDecodeMediaPlaylistWithDiscontinuitySeq(t *testing.T) { } } +func TestDecodeMasterPlaylistWithCustomTags(t *testing.T) { + cases := []struct { + src string + customDecoders []CustomDecoder + expectedError error + expectedPlaylistTags []string + }{ + { + src: "sample-playlists/master-playlist-with-custom-tags.m3u8", + customDecoders: nil, + expectedError: nil, + expectedPlaylistTags: nil, + }, + { + src: "sample-playlists/master-playlist-with-custom-tags.m3u8", + customDecoders: []CustomDecoder{ + &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", + customDecoders: []CustomDecoder{ + &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.customDecoders) + + 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 + customDecoders []CustomDecoder + expectedError error + expectedPlaylistTags []string + expectedSegmentTags []*struct { + index int + names []string + } + }{ + { + src: "sample-playlists/media-playlist-with-custom-tags.m3u8", + customDecoders: nil, + expectedError: nil, + expectedPlaylistTags: nil, + expectedSegmentTags: nil, + }, + { + src: "sample-playlists/media-playlist-with-custom-tags.m3u8", + customDecoders: []CustomDecoder{ + &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", + customDecoders: []CustomDecoder{ + &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.customDecoders) + + 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 new file mode 100644 index 00000000..e31416a3 --- /dev/null +++ b/sample-playlists/master-playlist-with-custom-tags.m3u8 @@ -0,0 +1,14 @@ +#EXTM3U +#EXT-X-VERSION:3 +#JUST A COMMENT +#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..66c963b0 --- /dev/null +++ b/sample-playlists/media-playlist-with-custom-tags.m3u8 @@ -0,0 +1,18 @@ +#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 +#CUSTOM-SEGMENT-TAG-B +#EXTINF:10.0, +movieA.ts +#EXTINF:10.0, +movieB.ts diff --git a/structure.go b/structure.go index 1cb1bb85..ea0d5c85 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 + customDecoders []CustomDecoder } /* @@ -148,6 +150,8 @@ type MasterPlaylist struct { buf bytes.Buffer ver uint8 independentSegments bool + Custom map[string]CustomTag + customDecoders []CustomDecoder } // 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,6 +274,33 @@ type Playlist interface { Encode() *bytes.Buffer Decode(bytes.Buffer, bool) error DecodeFrom(reader io.Reader, strict bool) error + WithCustomDecoders([]CustomDecoder) Playlist + String() string +} + +// 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) + // 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 } @@ -285,6 +317,7 @@ type decodingState struct { tagProgramDateTime bool tagKey bool tagMap bool + tagCustom bool programDateTime time.Time limit int64 offset int64 @@ -295,4 +328,5 @@ type decodingState struct { xkey *Key xmap *Map scte *SCTE + custom map[string]CustomTag } diff --git a/structure_test.go b/structure_test.go index 31b99d8f..4ab7d36a 100644 --- a/structure_test.go +++ b/structure_test.go @@ -10,6 +10,7 @@ package m3u8 import ( + "bytes" "testing" ) @@ -24,3 +25,38 @@ 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 { + if t.encodedString == "" { + return nil + } + + buf := new(bytes.Buffer) + + buf.WriteString(t.encodedString) + + return buf +} + +func (t *MockCustomTag) String() string { + return t.encodedString +} + +func (t *MockCustomTag) SegmentTag() bool { + return t.segment +} diff --git a/writer.go b/writer.go index ffea44a3..ae736067 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 != nil { + p.buf.WriteString(customBuf.String()) + p.buf.WriteRune('\n') + } + } + } + var altsWritten map[string]bool = make(map[string]bool) for _, pl := range p.Variants { @@ -263,6 +273,15 @@ 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) + } + + p.Custom[tag.TagName()] = tag +} + // Version returns the current playlist version number func (p *MasterPlaylist) Version() uint8 { return p.ver @@ -384,6 +403,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 != nil { + 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 +666,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 != nil { + p.buf.WriteString(customBuf.String()) + p.buf.WriteRune('\n') + } + } + } + p.buf.WriteString("#EXTINF:") if str, ok := durationCache[seg.Duration]; ok { p.buf.WriteString(str) @@ -802,6 +843,32 @@ 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) + } + + 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") + } + + 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..bfa2a798 100644 --- a/writer_test.go +++ b/writer_test.go @@ -378,6 +378,65 @@ 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) + + 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) + } + + customSTag := &MockCustomTag{ + name: "#CustomSTag", + encodedString: "#CustomSTag", + } + e = p.SetCustomSegmentTag(customSTag) + if e != nil { + 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 { + if !strings.Contains(encoded, expected) { + 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 // Add two segments to media playlist // Encode structures to HLS @@ -855,6 +914,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