Skip to content

Commit

Permalink
[Release] Merge develop into main (#16)
Browse files Browse the repository at this point in the history
* [SSAI-500] Allow Segment Insertion into MediaPlaylist (#11)

* Allow Segment Insertion into MediaPlaylist

* [SSAI-702] Support EXT-X-SESSION-DATA (#15)

* [SSAI-702] Support EXT-X-SESSION-DATA

* add comment

* SessionData struct uses pointer instead

* enforce spec on strict read + test

* spec compliant write

* remove comments

---------

Co-authored-by: Patrick Su <[email protected]>
  • Loading branch information
azombor and PatrickSUDO authored Jan 24, 2025
1 parent 054b353 commit 72380e0
Show file tree
Hide file tree
Showing 8 changed files with 238 additions and 16 deletions.
49 changes: 38 additions & 11 deletions reader.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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)
}
}
}
Expand Down Expand Up @@ -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:"):
Expand Down Expand Up @@ -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
}
Expand Down
80 changes: 80 additions & 0 deletions reader_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions sample-playlists/master-with-session-data-with-uri-and-value.m3u8
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions sample-playlists/master-with-session-data.m3u8
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions structure.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
37 changes: 37 additions & 0 deletions writer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
38 changes: 33 additions & 5 deletions writer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import (
"bufio"
"bytes"
"fmt"
"io/ioutil"
"os"
"reflect"
"strings"
Expand Down Expand Up @@ -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)
}
Expand All @@ -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")
}
}()
Expand Down Expand Up @@ -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()
Expand All @@ -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) {
Expand All @@ -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())
Expand Down

0 comments on commit 72380e0

Please sign in to comment.