diff --git a/reader.go b/reader.go index 8346b9b3..e72e93e4 100644 --- a/reader.go +++ b/reader.go @@ -195,7 +195,7 @@ func decode(buf *bytes.Buffer, strict bool, customDecoders []CustomDecoder) (Pla master = NewMasterPlaylist() media, err = NewMediaPlaylist(8, 1024) // Winsize for VoD will become 0, capacity auto extends if err != nil { - return nil, 0, fmt.Errorf("Create media playlist failed: %s", err) + return nil, 0, fmt.Errorf("create media playlist failed: %s", err) } // If we have custom tags to parse @@ -248,7 +248,7 @@ func decode(buf *bytes.Buffer, strict bool, customDecoders []CustomDecoder) (Pla } return media, MEDIA, nil } - return nil, state.listType, errors.New("Can't detect playlist type") + return nil, state.listType, errors.New("can't detect playlist type") } // DecodeAttributeList turns an attribute list into a key, value map. You should trim @@ -290,6 +290,37 @@ func decodeLineOfMasterPlaylist(p *MasterPlaylist, state *decodingState, line st switch { case line == "#EXTM3U": // start tag first state.m3u = true + case strings.HasPrefix(line, "#EXT-X-SESSION-DATA:"): // session data tag + state.listType = MASTER + sessionData := new(SessionData) + for k, v := range decodeParamsLine(line[20:]) { + switch k { + case "DATA-ID": + sessionData.DataID = v + case "VALUE": + sessionData.Value = v + case "URI": + sessionData.URI = v + case "LANGUAGE": + sessionData.Language = v + } + } + // EXT-X-SESSION-DATA tag MUST contain either a VALUE or URI attribute, but not both. + if (sessionData.Value == "" && sessionData.URI == "") || (sessionData.Value != "" && sessionData.URI != "") { + if strict { + return errors.New("either VALUE or URI must be present, but not both") + } + } + // A Playlist MUST NOT contain more than one EXT-X-SESSION-DATA tag with the + // same DATA-ID attribute and the same LANGUAGE attribute. + for _, sd := range p.SessionData { + if sd.DataID == sessionData.DataID && sd.Language == sessionData.Language { + if strict { + return errors.New("duplicate EXT-X-SESSION-DATA tag with the same DATA-ID and LANGUAGE") + } + } + } + p.SessionData = append(p.SessionData, sessionData) case strings.HasPrefix(line, "#EXT-X-VERSION:"): // version tag state.listType = MASTER _, err = fmt.Sscanf(line, "#EXT-X-VERSION:%d", &p.ver) @@ -443,8 +474,6 @@ func decodeLineOfMasterPlaylist(p *MasterPlaylist, state *decodingState, line st state.variant.HDCPLevel = v } } - case strings.HasPrefix(line, "#"): - // comments are ignored } return err } @@ -489,7 +518,7 @@ func decodeLineOfMediaPlaylist(p *MediaPlaylist, wv *WV, state *decodingState, l duration := line[8:sepIndex] if len(duration) > 0 { if state.duration, err = strconv.ParseFloat(duration, 64); strict && err != nil { - return fmt.Errorf("Duration parsing error: %s", err) + return fmt.Errorf("duration parsing error: %s", err) } } if len(line) > sepIndex { @@ -626,7 +655,7 @@ func decodeLineOfMediaPlaylist(p *MediaPlaylist, wv *WV, state *decodingState, l case "TIME-OFFSET": st, err := strconv.ParseFloat(v, 64) if err != nil { - return fmt.Errorf("Invalid TIME-OFFSET: %s: %v", v, err) + return fmt.Errorf("invalid TIME-OFFSET: %s: %v", v, err) } p.StartTime = st case "PRECISE": @@ -660,7 +689,7 @@ func decodeLineOfMediaPlaylist(p *MediaPlaylist, wv *WV, state *decodingState, l state.xmap.URI = v case "BYTERANGE": if _, err = fmt.Sscanf(v, "%d@%d", &state.xmap.Limit, &state.xmap.Offset); strict && err != nil { - return fmt.Errorf("Byterange sub-range length value parsing error: %s", err) + return fmt.Errorf("byterange sub-range length value parsing error: %s", err) } } } @@ -727,11 +756,11 @@ func decodeLineOfMediaPlaylist(p *MediaPlaylist, wv *WV, state *decodingState, l state.offset = 0 params := strings.SplitN(line[17:], "@", 2) if state.limit, err = strconv.ParseInt(params[0], 10, 64); strict && err != nil { - return fmt.Errorf("Byterange sub-range length value parsing error: %s", err) + return fmt.Errorf("byterange sub-range length value parsing error: %s", err) } if len(params) > 1 { if state.offset, err = strconv.ParseInt(params[1], 10, 64); strict && err != nil { - return fmt.Errorf("Byterange sub-range offset value parsing error: %s", err) + return fmt.Errorf("byterange sub-range offset value parsing error: %s", err) } } case !state.tagSCTE35 && strings.HasPrefix(line, "#EXT-SCTE35:"): @@ -893,8 +922,6 @@ func decodeLineOfMediaPlaylist(p *MediaPlaylist, wv *WV, state *decodingState, l if err == nil { state.tagWV = true } - case strings.HasPrefix(line, "#"): - // comments are ignored } return err } diff --git a/reader_test.go b/reader_test.go index 5ae19b6d..294dc699 100644 --- a/reader_test.go +++ b/reader_test.go @@ -41,6 +41,86 @@ func TestDecodeMasterPlaylist(t *testing.T) { // fmt.Println(p.Encode().String()) } +func TestDecodeMasterPlaylist_WithSessionData(t *testing.T) { + f, err := os.Open("sample-playlists/master-with-session-data.m3u8") + if err != nil { + t.Fatal(err) + } + p := NewMasterPlaylist() + err = p.DecodeFrom(bufio.NewReader(f), false) + if err != nil { + t.Fatal(err) + } + // check parsed values + if p.ver != 3 { + t.Errorf("Version of parsed playlist = %d (must = 3)", p.ver) + } + if len(p.Variants) != 5 { + t.Error("Not all variants in master playlist parsed.") + } + + if len(p.SessionData) < 1 { + t.Error("Session data has not been parsed.") + } + + // check specific session data values + expectedSessionData := []SessionData{ + { + DataID: "com.example.movie.title", + Value: "Example Movie", + Language: "en", + }, + { + DataID: "com.example.movie.description", + URI: "http://example.com/description.json", + }, + } + + for i, expected := range expectedSessionData { + if i >= len(p.SessionData) { + t.Errorf("Missing session data entry: %+v", expected) + continue + } + actual := p.SessionData[i] + if actual.DataID != expected.DataID { + t.Errorf("SessionData[%d].DataID = %s, want %s", i, actual.DataID, expected.DataID) + } + if actual.Value != expected.Value { + t.Errorf("SessionData[%d].Value = %s, want %s", i, actual.Value, expected.Value) + } + if actual.URI != expected.URI { + t.Errorf("SessionData[%d].URI = %s, want %s", i, actual.URI, expected.URI) + } + if actual.Language != expected.Language { + t.Errorf("SessionData[%d].Language = %s, want %s", i, actual.Language, expected.Language) + } + } +} + +func TestDecodeMasterPlaylist_WithBothValueAndURI(t *testing.T) { + f, err := os.Open("sample-playlists/master-with-session-data-with-uri-and-value.m3u8") + if err != nil { + t.Fatal(err) + } + p := NewMasterPlaylist() + err = p.DecodeFrom(bufio.NewReader(f), true) + if err == nil { + t.Error("Expected error when strict mode is enabled.") + } +} + +func TestDecodeMasterPlaylist_WithDuplicateDataIDAndLanguage(t *testing.T) { + f, err := os.Open("sample-playlists/master-with-session-data-with-duplicate-data-id-and-language.m3u8") + if err != nil { + t.Fatal(err) + } + p := NewMasterPlaylist() + err = p.DecodeFrom(bufio.NewReader(f), true) + if err == nil { + t.Error("Expected error when strict mode is enabled.") + } +} + func TestDecodeMasterPlaylistWithMultipleCodecs(t *testing.T) { f, err := os.Open("sample-playlists/master-with-multiple-codecs.m3u8") if err != nil { diff --git a/sample-playlists/master-with-session-data-with-duplicate-data-id-and-language.m3u8 b/sample-playlists/master-with-session-data-with-duplicate-data-id-and-language.m3u8 new file mode 100644 index 00000000..804bd019 --- /dev/null +++ b/sample-playlists/master-with-session-data-with-duplicate-data-id-and-language.m3u8 @@ -0,0 +1,14 @@ +#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-SESSION-DATA:DATA-ID="com.example.movie.title",VALUE="Example Movie",LANGUAGE="en" +#EXT-X-SESSION-DATA:DATA-ID="com.example.movie.title",VALUE="Example Movie",LANGUAGE="en" +#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/master-with-session-data-with-uri-and-value.m3u8 b/sample-playlists/master-with-session-data-with-uri-and-value.m3u8 new file mode 100644 index 00000000..df9d3dbe --- /dev/null +++ b/sample-playlists/master-with-session-data-with-uri-and-value.m3u8 @@ -0,0 +1,13 @@ +#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-SESSION-DATA:DATA-ID="com.example.movie.title",VALUE="Example Movie",LANGUAGE="en",URI="http://example.com/description.json" +#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/master-with-session-data.m3u8 b/sample-playlists/master-with-session-data.m3u8 new file mode 100644 index 00000000..86eba35b --- /dev/null +++ b/sample-playlists/master-with-session-data.m3u8 @@ -0,0 +1,14 @@ +#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-SESSION-DATA:DATA-ID="com.example.movie.title",VALUE="Example Movie",LANGUAGE="en" +#EXT-X-SESSION-DATA:DATA-ID="com.example.movie.description",URI="http://example.com/description.json" +#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/structure.go b/structure.go index e9028afc..a9e432ce 100644 --- a/structure.go +++ b/structure.go @@ -147,6 +147,7 @@ type MediaPlaylist struct { // http://example.com/audio-only.m3u8 type MasterPlaylist struct { Variants []*Variant + SessionData []*SessionData Args string // optional arguments placed after URI (URI?Args) CypherVersion string // non-standard tag for Widevine (see also WV struct) buf bytes.Buffer @@ -165,6 +166,14 @@ type Variant struct { VariantParams } +// SessionData structure represents EXT-X-SESSION-DATA tag in the master playlist. +type SessionData struct { + DataID string + Value string + URI string + Language string +} + // VariantParams structure represents additional parameters for a // variant used in EXT-X-STREAM-INF and EXT-X-I-FRAME-STREAM-INF type VariantParams struct { diff --git a/writer.go b/writer.go index cca0df39..72419a03 100644 --- a/writer.go +++ b/writer.go @@ -96,6 +96,43 @@ func (p *MasterPlaylist) Encode() *bytes.Buffer { } } + var languageWritten = make(map[string]bool) + if p.SessionData != nil { + for _, sessionData := range p.SessionData { + // make sure that the same data id and language is not written twice + if sessionData.Language != "" { + languageWrittenKey := strings.ToLower(sessionData.DataID) + "-" + strings.ToLower(sessionData.Language) + if languageWritten[languageWrittenKey] { + continue + } + languageWritten[languageWrittenKey] = true + } + + p.buf.WriteString("#EXT-X-SESSION-DATA:") + p.buf.WriteString("DATA-ID=\"") + p.buf.WriteString(sessionData.DataID) + p.buf.WriteRune('"') + if sessionData.Value != "" { + p.buf.WriteString(",VALUE=\"") + p.buf.WriteString(sessionData.Value) + p.buf.WriteRune('"') + } + // Each EXT-X-SESSION-DATA tag MUST contain either a VALUE or URI attribute, but not both. + // In case both are present, default to writing only the VALUE attribute. + if sessionData.URI != "" && sessionData.Value == "" { + p.buf.WriteString(",URI=\"") + p.buf.WriteString(sessionData.URI) + p.buf.WriteRune('"') + } + if sessionData.Language != "" { + p.buf.WriteString(",LANGUAGE=\"") + p.buf.WriteString(sessionData.Language) + p.buf.WriteRune('"') + } + p.buf.WriteRune('\n') + } + } + var altsWritten = make(map[string]bool) for _, pl := range p.Variants { diff --git a/writer_test.go b/writer_test.go index cdf3b5ef..c7f5ea36 100644 --- a/writer_test.go +++ b/writer_test.go @@ -13,7 +13,6 @@ import ( "bufio" "bytes" "fmt" - "io/ioutil" "os" "reflect" "strings" @@ -587,7 +586,7 @@ func TestClosedMediaPlaylist(t *testing.T) { // Create new media playlist as sliding playlist. func TestLargeMediaPlaylistWithParallel(t *testing.T) { testCount := 10 - expect, err := ioutil.ReadFile("sample-playlists/media-playlist-large.m3u8") + expect, err := os.ReadFile("sample-playlists/media-playlist-large.m3u8") if err != nil { t.Fatal(err) } @@ -609,7 +608,7 @@ func TestLargeMediaPlaylistWithParallel(t *testing.T) { } actual := p.Encode().Bytes() // disregard output - if bytes.Compare(expect, actual) != 0 { + if !bytes.Equal(expect, actual) { t.Fatal("not matched") } }() @@ -802,6 +801,35 @@ func TestNewMasterPlaylistWithAlternatives(t *testing.T) { } } +func TestNewMasterPlaylistWithSessionData(t *testing.T) { + m := NewMasterPlaylist() + m.SessionData = []*SessionData{ + { + DataID: "com.example.movie.title", + Value: "Example Movie", + Language: "en", + }, + { + DataID: "com.example.movie.description", + URI: "http://example.com/description.json", + }, + } + + var buf bytes.Buffer + m.buf = buf + + output := m.Encode().String() + expected := `#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-SESSION-DATA:DATA-ID="com.example.movie.title",VALUE="Example Movie",LANGUAGE="en" +#EXT-X-SESSION-DATA:DATA-ID="com.example.movie.description",URI="http://example.com/description.json" +` + + if output != expected { + t.Errorf("expected:\n%s\ngot:\n%s", expected, output) + } +} + // Create new master playlist supporting CLOSED-CAPTIONS=NONE func TestNewMasterPlaylistWithClosedCaptionEqNone(t *testing.T) { m := NewMasterPlaylist() @@ -819,7 +847,7 @@ func TestNewMasterPlaylistWithClosedCaptionEqNone(t *testing.T) { if err != nil { t.Fatalf("Create media playlist failed: %s", err) } - m.Append(fmt.Sprintf("eng_rendition_rendition.m3u8"), p, *vp) + m.Append("eng_rendition_rendition.m3u8", p, *vp) expected := "CLOSED-CAPTIONS=NONE" if !strings.Contains(m.String(), expected) { @@ -828,7 +856,7 @@ func TestNewMasterPlaylistWithClosedCaptionEqNone(t *testing.T) { // quotes need to be include if not eq NONE vp.Captions = "CC1" m2 := NewMasterPlaylist() - m2.Append(fmt.Sprintf("eng_rendition_rendition.m3u8"), p, *vp) + m2.Append("eng_rendition_rendition.m3u8", p, *vp) expected = `CLOSED-CAPTIONS="CC1"` if !strings.Contains(m2.String(), expected) { t.Fatalf("Master playlist did not contain: %s\nMaster Playlist:\n%v", expected, m2.String())