Skip to content

Commit

Permalink
feat: EXT-X-SESSIONDATA support
Browse files Browse the repository at this point in the history
  • Loading branch information
tobbee committed Jan 14, 2025
1 parent 287b30d commit 5d01b83
Show file tree
Hide file tree
Showing 7 changed files with 140 additions and 4 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- MediaPlaylist.SCTE35Syntax() method
- SCTE35Syntax has String() method
- EXT-X-DEFINE support in both master and media playlists
- EXT-X-SESSION-DATA support

### Changed

Expand Down
54 changes: 51 additions & 3 deletions m3u8/read_write_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -296,11 +296,57 @@ func TestReadWriteExtXIFrameStreamInf(t *testing.T) {
}
}

func TestReadWriteSessionData(t *testing.T) {
is := is.New(t)
cases := []struct {
desc string
line string
error bool
}{
{
desc: "minimal",
line: `#EXT-X-SESSION-DATA:DATA-ID="com.example.lyrics",VALUE="example",LANGUAGE="en"`,
error: false,
},
{
desc: "bad tag",
line: `#EXT-X-SESSION-DAT:DATA-ID="com.example.lyrics",VALUE="example",LANGUAGE="en"`,
error: true,
},
{
desc: "raw uri",
line: `#EXT-X-SESSION-DATA:DATA-ID="co.l",URI="dataURI",FORMAT=RAW,LANGUAGE="en"`,
error: false,
},
{
desc: "bad format",
line: `#EXT-X-SESSION-DATA:DATA-ID="co.l",URI="dataURI",FORMAT=raw,LANGUAGE="en"`,
error: true,
},
}
for _, c := range cases {
t.Run(c.desc, func(t *testing.T) {
sd, err := parseSessionData(c.line)
if c.error {
is.Equal(err != nil, true) // must return an error
return
}
is.NoErr(err)
out := bytes.Buffer{}
writeSessionData(&out, sd)
outStr := trimLineEnd(out.String())
is.Equal(c.line, outStr) // EXT-X-SESSION-DATA line must match
})
}

}

// TestReadWriteMediaPlaylist tests reading and writing media playlists from sample-playlists
// Looks at verbatim match, so the order of tags and attributes must match.
func TestReadWritePlaylists(t *testing.T) {
is := is.New(t)
files := []string{
"master-with-sessiondata.m3u8",
"master-with-closed-captions.m3u8",
"media-playlist-with-program-date-time.m3u8",
"master-groups-and-iframe.m3u8",
Expand All @@ -314,12 +360,14 @@ func TestReadWritePlaylists(t *testing.T) {
p, _, err := DecodeFrom(bufio.NewReader(f), true)
is.NoErr(err) // decode playlist should succeed
f.Close()
out := trimLineEnd(p.String())
got := trimLineEnd(p.String())
// os.WriteFile("out.m3u8", []byte(out), 0644)
inData, err := os.ReadFile("sample-playlists/" + fileName)
is.NoErr(err) // read file should succeed
inStr := trimLineEnd(strings.Replace(string(inData), "\r\n", "\n", -1))
is.Equal(inStr, out) // output must match input
want := trimLineEnd(strings.Replace(string(inData), "\r\n", "\n", -1))
if got != want {
t.Errorf("got:\n%s\nwant:\n%s", got, want)
}
})
}
}
35 changes: 35 additions & 0 deletions m3u8/reader.go
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,12 @@ func decodeLineOfMasterPlaylist(p *MasterPlaylist, state *decodingState, line st
if err != nil {
return err
}
case strings.HasPrefix(line, "#EXT-X-SESSION-DATA:"):
sd, err := parseSessionData(line)
if err != nil {
return err
}
p.SessionDatas = append(p.SessionDatas, sd)
}
return err
}
Expand Down Expand Up @@ -605,6 +611,35 @@ func parseDateRange(line string) (*DateRange, error) {
return &dr, nil
}

func parseSessionData(line string) (*SessionData, error) {
sd := SessionData{
Format: "JSON",
}
if !strings.HasPrefix(line, "#EXT-X-SESSION-DATA:") {
return nil, fmt.Errorf("invalid EXT-X-SESSION-DATA line: %q", line)
}
for _, attr := range decodeAttributes(line[len("EXT-X-SESSION_-DATA:"):]) {
switch attr.Key {
case "DATA-ID":
sd.DataId = DeQuote(attr.Val)
case "VALUE":
sd.Value = DeQuote(attr.Val)
case "URI":
sd.URI = DeQuote(attr.Val)
case "FORMAT":
switch attr.Val {
case "JSON", "RAW":
sd.Format = attr.Val
default:
return nil, fmt.Errorf("invalid FORMAT: %s", attr.Val)
}
case "LANGUAGE":
sd.Language = DeQuote(attr.Val)
}
}
return &sd, nil
}

// DeQuote removes quotes from a string.
func DeQuote(s string) string {
if len(s) < 2 {
Expand Down
13 changes: 13 additions & 0 deletions m3u8/reader_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,19 @@ func TestDecodeMasterWithHLSV7(t *testing.T) {
}
}

func TestReadBadSessionData(t *testing.T) {
is := is.New(t)
pl := `#EXTM3U
#EXT-X-VERSION:7
#EXT-X-SESSION-DATA:DATA-ID="com.example.title",VALUE="This is an example title",FORMAT=bad
#EXT-X-INF:BANDWIDTH=1280000
video.m3u8
`
p := NewMasterPlaylist()
err := p.DecodeFrom(bufio.NewReader(bytes.NewBufferString(pl)), true)
is.True(err != nil) // must return an error
}

/****************************
* Begin Test MediaPlaylist *
****************************/
Expand Down
5 changes: 5 additions & 0 deletions m3u8/sample-playlists/master-with-sessiondata.m3u8
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#EXTM3U
#EXT-X-VERSION:7
#EXT-X-SESSION-DATA:DATA-ID="com.example.movie.title",VALUE="good",LANGUAGE="en"
#EXT-X-STREAM-INF:BANDWIDTH=600000
chunklist-b300000.m3u8
13 changes: 12 additions & 1 deletion m3u8/structure.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ type MasterPlaylist struct {
Variants []*Variant // Variants is a list of media playlists
Args string // optional query placed after URI (URI?Args)
Defines []Define // EXT-X-DEFINE tags
SessionDatas []*SessionData // EXT-X-SESSION-DATA tags
buf bytes.Buffer // buffer used for encoding and caching playlist
ver uint8 // protocol version of the playlist, 3 or higher
independentSegments bool // Global tag for EXT-X-INDEPENDENT-SEGMENTS
Expand Down Expand Up @@ -321,16 +322,26 @@ const (
QUERYPARAM
)

// The EXT-X-DEFINE tag provides a Playlist variable definition or declaration.
// Define represents an EXT-X-DEFINE tag and provides a Playlist variable definition or declaration.
type Define struct {
Name string // Specifies the Variable Name.
Type DefineType // name-VALUE pair, QUERYPARAM or IMPORT.
Value string // Only used if type is VALUE.
}

// SessionData represents an EXT-X-SESSION-DATA tag.
type SessionData struct {
DataId string // DATA-ID is a mandatory quoted-string
Value string // VALUE is a quoted-string
URI string // URI is a quoted-string
Format string // FORMAT is enumerated string. Values are JSON and RAW (default is JSON)
Language string // LANGUAGE is a quoted-string containing an [RFC5646] language tag
}

/*
[scte67]: https://webstore.ansi.org/standards/scte/ansiscte672017
[hls-spec]: https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis-16
[ISO/IEC 8601:2004]:http://www.iso.org/iso/catalogue_detail?csnumber=40874
[Protocol Version Compatibility]: https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis-16#section-8
[RFC5646]: https://datatracker.ietf.org/doc/html/rfc5646
*/
23 changes: 23 additions & 0 deletions m3u8/writer.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ func (p *MasterPlaylist) Encode() *bytes.Buffer {
}
}

for _, sd := range p.SessionDatas {
writeSessionData(&p.buf, sd)
}

// Write any custom master tags
if p.Custom != nil {
for _, v := range p.Custom {
Expand Down Expand Up @@ -338,6 +342,25 @@ func writeDateRange(buf *bytes.Buffer, dr *DateRange) {
buf.WriteRune('\n')
}

func writeSessionData(buf *bytes.Buffer, sd *SessionData) {
buf.WriteString("#EXT-X-SESSION-DATA:DATA-ID=\"")
buf.WriteString(sd.DataId)
buf.WriteRune('"')
if sd.Value != "" {
writeQuoted(buf, "VALUE", sd.Value)
}
if sd.URI != "" {
writeQuoted(buf, "URI", sd.URI)
}
if sd.Format != "JSON" {
writeUnQuoted(buf, "FORMAT", sd.Format)
}
if sd.Language != "" {
writeQuoted(buf, "LANGUAGE", sd.Language)
}
buf.WriteRune('\n')
}

// writeQuoted writes a quoted key-value pair to the buffer preceded by a comma.
func writeQuoted(buf *bytes.Buffer, key, value string) {
buf.WriteRune(',')
Expand Down

0 comments on commit 5d01b83

Please sign in to comment.