Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: handle EXT-X-DEFINE #22

Merged
merged 3 commits into from
Jan 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions m3u8/reader.go
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,34 @@ func decodeLineOfMasterPlaylist(p *MasterPlaylist, state *decodingState, line st
state.variant = variant
state.variant.Iframe = true
p.Variants = append(p.Variants, state.variant)
case strings.HasPrefix(line, "#EXT-X-DEFINE:"): // Define tag
var (
name string
value string
defineType DefineType
)

switch {
case strings.HasPrefix(line, "#EXT-X-DEFINE:NAME="):
defineType = VALUE
_, err = fmt.Sscanf(line, "#EXT-X-DEFINE:NAME=%q,VALUE=%q", &name, &value)
case strings.HasPrefix(line, "#EXT-X-DEFINE:QUERYPARAM="):
defineType = QUERYPARAM
_, err = fmt.Sscanf(line, "#EXT-X-DEFINE:QUERYPARAM=%q", &name)
case strings.HasPrefix(line, "#EXT-X-DEFINE:IMPORT="):
return fmt.Errorf("EXT-X-DEFINE IMPORT not allowed in master playlist")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good to report an error on this.

(Some linting tools want error messages always start with a lower-case letter, but here it makes sense to use upper case).

default:
return fmt.Errorf("unknown EXT-X-DEFINE format: %s", line)
}

if err != nil {
return fmt.Errorf("error parsing EXT-X-DEFINE: %w", err)
}

err = p.AppendDefine(Define{name, defineType, value})
if err != nil {
return err
}
}
return err
}
Expand Down Expand Up @@ -734,6 +762,32 @@ func decodeLineOfMediaPlaylist(p *MediaPlaylist, state *decodingState, line stri
if _, err = fmt.Sscanf(line, "#EXT-X-MEDIA-SEQUENCE:%d", &p.SeqNo); strict && err != nil {
return err
}
case strings.HasPrefix(line, "#EXT-X-DEFINE:"): // Define tag
var (
name string
value string
defineType DefineType
)

switch {
case strings.HasPrefix(line, "#EXT-X-DEFINE:NAME="):
defineType = VALUE
_, err = fmt.Sscanf(line, "#EXT-X-DEFINE:NAME=%q,VALUE=%q", &name, &value)
case strings.HasPrefix(line, "#EXT-X-DEFINE:QUERYPARAM="):
defineType = QUERYPARAM
_, err = fmt.Sscanf(line, "#EXT-X-DEFINE:QUERYPARAM=%q", &name)
case strings.HasPrefix(line, "#EXT-X-DEFINE:IMPORT="):
defineType = IMPORT
_, err = fmt.Sscanf(line, "#EXT-X-DEFINE:IMPORT=%q", &name)
default:
return fmt.Errorf("unknown EXT-X-DEFINE format: %s", line)
}

if err != nil {
return fmt.Errorf("error parsing EXT-X-DEFINE: %w", err)
}

p.AppendDefine(Define{name, defineType, value})
case strings.HasPrefix(line, "#EXT-X-PLAYLIST-TYPE:"):
state.listType = MEDIA
var playlistType string
Expand Down
49 changes: 49 additions & 0 deletions m3u8/reader_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -942,6 +942,55 @@ func TestDanglingScte35DateRange(t *testing.T) {
is.Equal(ErrDanglingSCTE35DateRange, err) // must return ErrDanglingSCTE35DateRange
}

func TestDecodeMasterPlaylistWithDefines(t *testing.T) {
is := is.New(t)
f, err := os.Open("sample-playlists/master-with-defines.m3u8")
is.NoErr(err) // must open file
p := NewMasterPlaylist()
err = p.DecodeFrom(bufio.NewReader(f), false)
is.NoErr(err) // must decode playlist
// check parsed values
is.Equal(len(p.Defines), 2) // must be 2 defines

is.Equal(p.Defines[0].Name, "Define1")
is.Equal(p.Defines[0].Type, VALUE)
is.Equal(p.Defines[0].Value, "Value1")
is.Equal(p.Defines[1].Name, "Define2")
is.Equal(p.Defines[1].Type, QUERYPARAM)
is.Equal(p.Defines[1].Value, "")
}
func TestDecodeMasterPlaylistWithInvalidDefines(t *testing.T) {
is := is.New(t)
f, err := os.Open("sample-playlists/master-with-defines-invalid.m3u8")
is.NoErr(err) // must open file
p := NewMasterPlaylist()
err = p.DecodeFrom(bufio.NewReader(f), false)
is.NoErr(err) // must decode playlist
// check parsed values
is.Equal(len(p.Defines), 0) // must be 0 defines
}
func TestDecodeMediaPlaylistWithDefines(t *testing.T) {
is := is.New(t)
f, err := os.Open("sample-playlists/media-playlist-with-defines.m3u8")
is.NoErr(err) // must open file
p, err := NewMediaPlaylist(1, 1)
is.NoErr(err) // must create playlist
err = p.DecodeFrom(bufio.NewReader(f), false)
is.NoErr(err) // must decode playlist
// check parsed values
is.Equal(len(p.Defines), 3) // must be 3 defines

is.Equal(p.Defines[0].Name, "Define1")
is.Equal(p.Defines[0].Type, VALUE)
is.Equal(p.Defines[0].Value, "Value1")
is.Equal(p.Defines[1].Name, "Define2")
is.Equal(p.Defines[1].Type, QUERYPARAM)
is.Equal(p.Defines[1].Value, "")
is.Equal(p.Defines[2].Name, "Define3")
is.Equal(p.Defines[2].Type, IMPORT)
is.Equal(p.Defines[2].Value, "")
}

/***************************
* Code parsing examples *
***************************/
Expand Down
13 changes: 13 additions & 0 deletions m3u8/sample-playlists/master-with-defines-invalid.m3u8
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#EXTM3U
#EXT-X-VERSION:6
#EXT-X-DEFINE:IMPORT="Define1"
#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 m3u8/sample-playlists/master-with-defines.m3u8
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#EXTM3U
#EXT-X-VERSION:6
#EXT-X-DEFINE:NAME="Define1",VALUE="Value1"
#EXT-X-DEFINE:QUERYPARAM="Define2"
#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
20 changes: 20 additions & 0 deletions m3u8/sample-playlists/media-playlist-with-defines.m3u8
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#EXTM3U
#EXT-X-TARGETDURATION:10
#EXT-X-VERSION:6
#EXT-X-DEFINE:NAME="Define1",VALUE="Value1"
#EXT-X-DEFINE:QUERYPARAM="Define2"
#EXT-X-DEFINE:IMPORT="Define3"
#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
18 changes: 18 additions & 0 deletions m3u8/structure.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ type MediaPlaylist struct {
SeqNo uint64 // EXT-X-MEDIA-SEQUENCE
Segments []*MediaSegment // List of segments in the playlist. Output may be limited by winsize.
Args string // optional query placed after URIs (URI?Args)
Defines []Define // EXT-X-DEFINE tags
Iframe bool // EXT-X-I-FRAMES-ONLY
Closed bool // is this VOD/EVENT (closed) or Live (sliding) playlist?
MediaType MediaType // EXT-X-PLAYLIST-TYPE (EVENT, VOD or empty)
Expand All @@ -140,6 +141,7 @@ type MediaPlaylist struct {
ver uint8 // protocol version of the playlist, 3 or higher
targetDurLocked bool // target duration is locked and cannot be changed
independentSegments bool // Global tag for EXT-X-INDEPENDENT-SEGMENTS

}

// MasterPlaylist represents a master (multivariant) playlist which
Expand All @@ -148,6 +150,7 @@ type MediaPlaylist struct {
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
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 @@ -310,6 +313,21 @@ type Attribute struct {
Val string // Value including quotes if a quoted string, and 0x if hexadecimal value
}

type DefineType uint

const (
VALUE DefineType = iota
IMPORT
QUERYPARAM
)

// The EXT-X-DEFINE tag 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.
}

/*
[scte67]: https://webstore.ansi.org/standards/scte/ansiscte672017
[hls-spec]: https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis-16
Expand Down
52 changes: 52 additions & 0 deletions m3u8/writer.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@ func (p *MasterPlaylist) Append(uri string, chunklist *MediaPlaylist, params Var
p.buf.Reset()
}

func (p *MasterPlaylist) AppendDefine(d Define) error {
if d.Type == IMPORT {
return errors.New("IMPORT not allowed in master playlist")
}
p.Defines = append(p.Defines, d)
return nil
}

// ResetCache resets the playlist's cache (its buffer).
func (p *MasterPlaylist) ResetCache() {
p.buf.Reset()
Expand All @@ -71,6 +79,24 @@ func (p *MasterPlaylist) Encode() *bytes.Buffer {
if p.IndependentSegments() {
p.buf.WriteString("#EXT-X-INDEPENDENT-SEGMENTS\n")
}
if len(p.Defines) > 0 {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should skip this check, since the range function will handle the zero-length case.

for _, d := range p.Defines {
p.buf.WriteString("#EXT-X-DEFINE:")
switch d.Type {
case VALUE:
p.buf.WriteString("NAME=\"")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two things to consider:

  1. Use backticks notation to avoid escaping "
  2. Introduce functions to write VALUE, QUERYPARAM, and IMPORT that can be used for both master and media playlist and also make the write function more compact.

p.buf.WriteString(d.Name)
p.buf.WriteString("\",VALUE=\"")
p.buf.WriteString(d.Value)
p.buf.WriteString("\"")
case QUERYPARAM:
p.buf.WriteString("QUERYPARAM=\"")
p.buf.WriteString(d.Name)
p.buf.WriteString("\"")
}
p.buf.WriteRune('\n')
}
}

// Write any custom master tags
if p.Custom != nil {
Expand Down Expand Up @@ -494,6 +520,10 @@ func (p *MediaPlaylist) AppendSegment(seg *MediaSegment) error {
return nil
}

func (p *MediaPlaylist) AppendDefine(d Define) {
p.Defines = append(p.Defines, d)
}

// Slide combines two operations: first it removes one chunk from
// the head of chunk slice and move pointer to next chunk. Secondly it
// appends one chunk to the tail of chunk slice. Useful for sliding
Expand Down Expand Up @@ -571,6 +601,28 @@ func (p *MediaPlaylist) Encode() *bytes.Buffer {
}
p.buf.WriteRune('\n')
}
if len(p.Defines) > 0 {
for _, d := range p.Defines {
p.buf.WriteString("#EXT-X-DEFINE:")
switch d.Type {
case VALUE:
p.buf.WriteString("NAME=\"")
p.buf.WriteString(d.Name)
p.buf.WriteString("\",VALUE=\"")
p.buf.WriteString(d.Value)
p.buf.WriteString("\"")
case IMPORT:
p.buf.WriteString("IMPORT=\"")
p.buf.WriteString(d.Name)
p.buf.WriteString("\"")
case QUERYPARAM:
p.buf.WriteString("QUERYPARAM=\"")
p.buf.WriteString(d.Name)
p.buf.WriteString("\"")
}
p.buf.WriteRune('\n')
}
}
// default MAP before any segment
if p.Map != nil {
p.buf.WriteString("#EXT-X-MAP:")
Expand Down
28 changes: 28 additions & 0 deletions m3u8/writer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -813,6 +813,34 @@ func TestCalculateTargetDuration(t *testing.T) {
}
}

// Create new master and media playlist
// Add define to playlists
func TestAppendDefine(t *testing.T) {
is := is.New(t)
tests := []struct {
define Define
Expected string
}{
{Define{Name: "Define1", Type: VALUE, Value: "Value1"}, `#EXT-X-DEFINE:NAME="Define1",VALUE="Value1"` + "\n"},
{Define{Name: "Define2", Type: IMPORT}, `#EXT-X-DEFINE:IMPORT="Define2"` + "\n"},
{Define{Name: "Define3", Type: QUERYPARAM}, `#EXT-X-DEFINE:QUERYPARAM="Define3"` + "\n"},
}

for _, test := range tests {
p := NewMasterPlaylist()
e := p.AppendDefine(test.define)
if test.define.Type != IMPORT {
is.NoErr(e)
is.True(strings.Contains(p.String(), test.Expected))
}

mp, e := NewMediaPlaylist(1, 1)
is.NoErr(e) // Create media playlist should be successful
mp.AppendDefine(test.define)
is.True(strings.Contains(mp.String(), test.Expected))
}
}

/******************************
* Code generation examples *
******************************/
Expand Down
Loading