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

Support for low-latency media playlist #33

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 2 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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
module github.com/Eyevinn/hls-m3u8

go 1.16
go 1.22

require github.com/matryer/is v1.4.1
1 change: 1 addition & 0 deletions m3u8/read_write_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,7 @@ func TestReadWritePlaylists(t *testing.T) {
"media-playlist-with-multiple-dateranges.m3u8",
"media-playlist-with-start-time.m3u8",
"master-with-independent-segments.m3u8",
// "media-playlist-low-latency.m3u8",
}

for _, fileName := range files {
Expand Down
76 changes: 76 additions & 0 deletions m3u8/reader.go
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,53 @@ func parseDefine(line string) (Define, error) {
return d, nil
}

func parsePartialSegment(parameters string) (*PartialSegment, error) {
ps := PartialSegment{}
var err error
for _, attr := range decodeAttributes(parameters) {
switch attr.Key {
case "URI":
ps.URI = DeQuote(attr.Val)
case "DURATION":
if ps.Duration, err = strconv.ParseFloat(attr.Val, 64); err != nil {
return nil, fmt.Errorf("duration parsing error: %w", err)
}
case "INDEPENDENT":
ps.Independent = attr.Val == "YES"
case "BYTERANGE":
if _, err := fmt.Sscanf(attr.Val, "%d@%d", &ps.Limit, &ps.Offset); err != nil {
return nil, fmt.Errorf("byterange sub-range length value parsing error: %w", err)
}
}
}
return &ps, nil
}

func parsePreloadHint(parameters string) (*PreloadHint, error) {
ph := PreloadHint{}
for _, attr := range decodeAttributes(parameters) {
switch attr.Key {
case "TYPE":
ph.Type = attr.Val
case "URI":
ph.URI = DeQuote(attr.Val)
case "BYTERANGE-START":
start, err := strconv.ParseInt(attr.Val, 10, 64)
if err != nil {
return nil, fmt.Errorf("start parsing error: %w", err)
}
ph.Offset = start
case "BYTERANGE-LENGTH":
length, err := strconv.ParseInt(attr.Val, 10, 64)
if err != nil {
return nil, fmt.Errorf("start parsing error: %w", err)
}
ph.Limit = length
}
}
return &ph, nil
}

func parseSessionData(line string) (*SessionData, error) {
sd := SessionData{
Format: "JSON",
Expand Down Expand Up @@ -859,6 +906,11 @@ func decodeLineOfMediaPlaylist(p *MediaPlaylist, state *decodingState, line stri
state.custom = make(CustomMap)
state.tagCustom = false
}
// all partial segment which appeared before the segment should be marked as completed
if state.tagPartialSegment {
// Mark all partial segments as completed
state.tagPartialSegment = false
}
// start tag first
case line == "#EXTM3U":
state.m3u = true
Expand All @@ -874,6 +926,30 @@ func decodeLineOfMediaPlaylist(p *MediaPlaylist, state *decodingState, line stri
if _, err = fmt.Sscanf(line, "#EXT-X-TARGETDURATION:%d", &p.TargetDuration); strict && err != nil {
return err
}
case strings.HasPrefix(line, "#EXT-X-PART-INF:PART-TARGET="):
state.listType = MEDIA
if _, err = fmt.Sscanf(line, "#EXT-X-PART-INF:PART-TARGET=%f", &p.PartTargetDuration); strict && err != nil {
return err
}
case strings.HasPrefix(line, "#EXT-X-PART:"):
state.listType = MEDIA
state.tagPartialSegment = true
partialSegment, err := parsePartialSegment(line[12:])
if err != nil {
return err
}
// if the program date time tag is present, set it on this partial segment
if state.tagProgramDateTime && len(p.PartialSegments) > 0 {
partialSegment.ProgramDateTime = state.programDateTime
state.tagProgramDateTime = false
}
p.AppendPartialSegment(partialSegment)
case strings.HasPrefix(line, "#EXT-X-PRELOAD-HINT:"):
preloadHint, err := parsePreloadHint(line[20:])
if err != nil {
return fmt.Errorf("error parsing EXT-X-PRELOAD-HINT: %w", err)
}
p.PreloadHints = preloadHint
case strings.HasPrefix(line, "#EXT-X-MEDIA-SEQUENCE:"):
state.listType = MEDIA
if _, err = fmt.Sscanf(line, "#EXT-X-MEDIA-SEQUENCE:%d", &p.SeqNo); strict && err != nil {
Expand Down
58 changes: 58 additions & 0 deletions m3u8/reader_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"fmt"
"os"
"reflect"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -862,6 +863,63 @@
}
}

func TestDecodeLowLatencyMediaPlaylist(t *testing.T) {
is := is.New(t)
f, err := os.Open("sample-playlists/media-playlist-low-latency.m3u8")
is.NoErr(err) // must open file
p, listType, err := DecodeFrom(bufio.NewReader(f), true)
is.NoErr(err) // must decode playlist
pp := p.(*MediaPlaylist)
CheckType(t, pp)
is.Equal(listType, MEDIA) // must be media playlist
// check parsed values
is.Equal(pp.TargetDuration, uint(4)) // target duration must be 15
is.True(!pp.Closed) // live playlist
is.Equal(pp.SeqNo, uint64(234)) // sequence number must be 0
is.Equal(pp.Count(), uint(16)) // segment count must be 15
is.Equal(pp.PartTargetDuration, float32(1.002000)) // part target duration must be 1.002000

// segment names should be in the following format fileSequence%d.m4s
// starting from fileSequence235.m4s
t.Logf("First Segment is %s", pp.Segments[0].URI)

for i := range pp.Count() {

Check failure on line 886 in m3u8/reader_test.go

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 1.16)

cannot range over pp.Count() (type uint)
s := pp.Segments[i]
expected := fmt.Sprintf("fileSequence%d.m4s", i+234+1)
if s.URI != expected {
t.Errorf("Segment name mismatch: %s != %s", s.URI, expected)
}
}

// The ProgramDateTime of the 2nd segment should be: 2025-02-10T14:42:30.134Z
st, _ := time.Parse(time.RFC3339, "2025-02-10T14:42:30.134+00:00")
if !pp.Segments[1].ProgramDateTime.Equal(st) {
t.Errorf("The program date time of the 1st segment should be: %v, actual value: %v",
st, pp.Segments[1].ProgramDateTime)
}

is.Equal(len(pp.PartialSegments), int(10)) // partial segment count must be 10

for _, ps := range pp.PartialSegments {
// The partial segments should have a duration of 1 second
is.Equal(ps.Duration, float64(1.0))
is.True(ps.Independent)
// partial segment names should be in the following format filePart%d.%d.m4s
is.True(strings.HasPrefix(ps.URI, "filePart"))
}

// The ProgramDateTime of the 8st partial segment should be: 2025-02-10T14:43:30.134Z
st, _ = time.Parse(time.RFC3339, "2025-02-10T14:43:30.134+00:00")
if !pp.PartialSegments[8].ProgramDateTime.Equal(st) {
t.Errorf("The program date time of the 8st partial segment should be: %v, actual value: %v",
st, pp.PartialSegments[8].ProgramDateTime)
}

// Preload Hints
is.Equal(pp.PreloadHints.Type, "PART")
is.Equal(pp.PreloadHints.URI, "filePart251.3.m4s")
}

func TestDecodeMediaPlaylistWithProgramDateTime(t *testing.T) {
is := is.New(t)
f, err := os.Open("sample-playlists/media-playlist-with-program-date-time.m3u8")
Expand Down
53 changes: 53 additions & 0 deletions m3u8/sample-playlists/media-playlist-low-latency.m3u8
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
#EXTM3U
#EXT-X-TARGETDURATION:4
#EXT-X-VERSION:6
#EXT-X-PART-INF:PART-TARGET=1.002000
#EXT-X-MEDIA-SEQUENCE:234
#EXT-X-MAP:URI="fileSequence0.mp4"
#EXTINF:4.00000,
fileSequence235.m4s
#EXT-X-PROGRAM-DATE-TIME:2025-02-10T14:42:30.134Z
#EXTINF:4.00000,
fileSequence236.m4s
#EXTINF:4.00000,
fileSequence237.m4s
#EXTINF:4.00000,
fileSequence238.m4s
#EXTINF:4.00000,
fileSequence239.m4s
#EXTINF:4.00000,
fileSequence240.m4s
#EXT-X-PROGRAM-DATE-TIME:2025-02-10T14:42:50.134Z
#EXTINF:4.00000,
fileSequence241.m4s
#EXTINF:4.00000,
fileSequence242.m4s
#EXTINF:4.00000,
fileSequence243.m4s
#EXTINF:4.00000,
fileSequence244.m4s
#EXTINF:4.00000,
fileSequence245.m4s
#EXT-X-PROGRAM-DATE-TIME:2025-02-10T14:43:10.134Z
#EXTINF:4.00000,
fileSequence246.m4s
#EXTINF:4.00000,
fileSequence247.m4s
#EXTINF:4.00000,
fileSequence248.m4s
#EXT-X-PART:DURATION=1.00000,INDEPENDENT=YES,URI="filePart249.1.m4s"
#EXT-X-PART:DURATION=1.00000,INDEPENDENT=YES,URI="filePart249.2.m4s"
#EXT-X-PART:DURATION=1.00000,INDEPENDENT=YES,URI="filePart249.3.m4s"
#EXT-X-PART:DURATION=1.00000,INDEPENDENT=YES,URI="filePart249.4.m4s"
#EXTINF:4.00000,
fileSequence249.m4s
#EXT-X-PART:DURATION=1.00000,INDEPENDENT=YES,URI="filePart250.1.m4s"
#EXT-X-PART:DURATION=1.00000,INDEPENDENT=YES,URI="filePart250.2.m4s"
#EXT-X-PART:DURATION=1.00000,INDEPENDENT=YES,URI="filePart250.3.m4s"
#EXT-X-PART:DURATION=1.00000,INDEPENDENT=YES,URI="filePart250.4.m4s"
#EXTINF:4.00000,
fileSequence250.m4s
#EXT-X-PROGRAM-DATE-TIME:2025-02-10T14:43:30.134Z
#EXT-X-PART:DURATION=1.00000,INDEPENDENT=YES,URI="filePart251.1.m4s"
#EXT-X-PART:DURATION=1.00000,INDEPENDENT=YES,URI="filePart251.2.m4s"
#EXT-X-PRELOAD-HINT:TYPE=PART,URI="filePart251.3.m4s"
76 changes: 48 additions & 28 deletions m3u8/structure.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,34 +114,36 @@
// It is used for both VOD, EVENT and sliding window live media playlists with window size.
// URI lines in the Playlist point to media segments.
type MediaPlaylist struct {
TargetDuration uint // TargetDuration is max media segment duration. Rounding depends on version.
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)
DiscontinuitySeq uint64 // EXT-X-DISCONTINUITY-SEQUENCE
StartTime float64 // EXT-X-START:TIME-OFFSET=<n> (positive or negative)
StartTimePrecise bool // EXT-X-START:PRECISE=YES
Key *Key // EXT-X-KEY is initial key tag for encrypted segments
Map *Map // EXT-X-MAP provides a Media Initialization Section. Segments can redefine.
DateRanges []*DateRange // EXT-X-DATERANGE tags not associated with SCTE-35
AllowCache *bool // EXT-X-ALLOW-CACHE tag YES/NO, removed in version 7
Custom CustomMap // Custom-provided tags for encoding
customDecoders []CustomDecoder // customDecoders provides custom tags for decoding
winsize uint // max number of segments encoded sliding playlist, set to 0 for VOD and EVENT
capacity uint // total capacity of slice used for the playlist
head uint // head of FIFO, we add segments to head
tail uint // tail of FIFO, we remove segments from tail
count uint // number of segments added to the playlist
buf bytes.Buffer // buffer used for encoding and caching playlist output
scte35Syntax SCTE35Syntax // SCTE-35 syntax used in the playlist
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

TargetDuration uint // TargetDuration is max media segment duration. Rounding depends on version.
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)
DiscontinuitySeq uint64 // EXT-X-DISCONTINUITY-SEQUENCE
StartTime float64 // EXT-X-START:TIME-OFFSET=<n> (positive or negative)
StartTimePrecise bool // EXT-X-START:PRECISE=YES
Key *Key // EXT-X-KEY is initial key tag for encrypted segments
Map *Map // EXT-X-MAP provides a Media Initialization Section. Segments can redefine.
DateRanges []*DateRange // EXT-X-DATERANGE tags not associated with SCTE-35
AllowCache *bool // EXT-X-ALLOW-CACHE tag YES/NO, removed in version 7
Custom CustomMap // Custom-provided tags for encoding
customDecoders []CustomDecoder // customDecoders provides custom tags for decoding
winsize uint // max number of segments encoded sliding playlist, set to 0 for VOD and EVENT
capacity uint // total capacity of slice used for the playlist
head uint // head of FIFO, we add segments to head
tail uint // tail of FIFO, we remove segments from tail
count uint // number of segments added to the playlist
buf bytes.Buffer // buffer used for encoding and caching playlist output
scte35Syntax SCTE35Syntax // SCTE-35 syntax used in the playlist
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
PartTargetDuration float32 // EXT-X-PART-INF:PART-TARGET
PartialSegments []*PartialSegment // List of partial segments in the playlist.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Should we make the PartialSegment part of the Segment instead?

I think that makes sense, although old segments should not keep that data.

PreloadHints *PreloadHint // EXT-X-PRELOAD-HINT tags
}

// MasterPlaylist represents a master (multivariant) playlist which
Expand Down Expand Up @@ -235,6 +237,23 @@
Custom CustomMap // Custom holds custom tags
}

type PartialSegment struct {
URI string // EXT-X-PART:URI
Duration float64 // EXT-X-PART:DURATION
Independent bool // EXT-X-PART:INDEPENDENT
ProgramDateTime time.Time // EXT-X-PROGRAM-DATE-TIME
Offset int64 // EXT-X-PART:BYTERANGE [@o] is offset from the start of the file under URI.
Limit int64 // EXT-X-PART:BYTERANGE <n> is length in bytes for the file under URI.
Gap bool // EXT-X-PART:GAP enumerated-string ("YES" if the Partial Segment is not available)
}

type PreloadHint struct {
Type string // #EXT-X-PRELOAD-HINT:TYPE Enumerated-string ("PART" -> Partial Segment; "MAP" -> Media Initialization Section)

Check failure on line 251 in m3u8/structure.go

View workflow job for this annotation

GitHub Actions / lint

The line is 130 characters long, which exceeds the maximum of 120 characters. (lll)
URI string // #EXT-X-PRELOAD-HINT:URI
Offset int64 // #EXT-X-PRELOAD-HINT:BYTERANGE-START
Limit int64 // #EXT-X-PRELOAD-HINT:BYTERANGE-LENGTH
}

// SCTE holds custom SCTE-35 tags.
type SCTE struct {
Syntax SCTE35Syntax // Syntax defines the format of the SCTE-35 cue tag
Expand Down Expand Up @@ -291,6 +310,7 @@
tagProgramDateTime bool
tagKey bool
tagCustom bool
tagPartialSegment bool
programDateTime time.Time
limit int64
offset int64
Expand Down
9 changes: 9 additions & 0 deletions m3u8/writer.go
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,10 @@ func (p *MediaPlaylist) AppendSegment(seg *MediaSegment) error {
return nil
}

func (p *MediaPlaylist) AppendPartialSegment(ps *PartialSegment) {
p.PartialSegments = append(p.PartialSegments, ps)
}

func (p *MediaPlaylist) AppendDefine(d Define) {
p.Defines = append(p.Defines, d)
}
Expand Down Expand Up @@ -689,6 +693,11 @@ func (p *MediaPlaylist) Encode() *bytes.Buffer {
p.buf.WriteString("VOD\n")
}
}
if p.PartTargetDuration > 0 {
p.buf.WriteString("#EXT-X-PART-INF:PART-TARGET=")
p.buf.WriteString(strconv.FormatFloat(float64(p.PartTargetDuration), 'f', 6, 64))
p.buf.WriteRune('\n')
}
p.buf.WriteString("#EXT-X-MEDIA-SEQUENCE:")
p.buf.WriteString(strconv.FormatUint(p.SeqNo, 10))
p.buf.WriteRune('\n')
Expand Down
19 changes: 19 additions & 0 deletions m3u8/writer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,25 @@ test01.ts
is.Equal(out, expected) // Encode media playlist does not match expected
}

func TestEncodeLowLatencyMediaPlaylist(t *testing.T) {
is := is.New(t)
p, e := NewMediaPlaylist(3, 5)
is.NoErr(e) // Create media playlist should be successful
p.PartTargetDuration = 1.002
e = p.Append("test01.ts", 5.0, "")
is.NoErr(e) // Add 1st segment to a media playlist should be successful
expected := `#EXTM3U
#EXT-X-VERSION:3
#EXT-X-PART-INF:PART-TARGET=1.002000
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-TARGETDURATION:5
#EXTINF:5.000,
test01.ts
`
out := p.String()
is.Equal(out, expected) // Encode media playlist does not match expected
}

// Create new media playlist
// Add 10 segments to media playlist
// Test iterating over segments
Expand Down
Loading