Skip to content

Commit

Permalink
feat: write partial segment and preload hints
Browse files Browse the repository at this point in the history
  • Loading branch information
Wkkkkk committed Feb 11, 2025
1 parent 5d68b75 commit 33a9ed6
Show file tree
Hide file tree
Showing 6 changed files with 237 additions and 61 deletions.
2 changes: 1 addition & 1 deletion m3u8/read_write_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +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",
"media-playlist-low-latency.m3u8",
}

for _, fileName := range files {
Expand Down
5 changes: 3 additions & 2 deletions m3u8/reader.go
Original file line number Diff line number Diff line change
Expand Up @@ -729,7 +729,7 @@ func parseExtXMapParameters(parameters string) (*Map, error) {
for _, attr := range decodeAttributes(parameters) {
switch attr.Key {
case "URI":
m.URI = attr.Val
m.URI = DeQuote(attr.Val)
case "BYTERANGE":
if _, err := fmt.Sscanf(attr.Val, "%d@%d", &m.Limit, &m.Offset); err != nil {
return nil, fmt.Errorf("byterange sub-range length value parsing error: %w", err)
Expand Down Expand Up @@ -850,6 +850,7 @@ func decodeLineOfMediaPlaylist(p *MediaPlaylist, state *decodingState, line stri
p.tail = p.count
err = p.AppendSegment(&seg)
}

// Check err for first or subsequent Append()
if err != nil {
return err
Expand Down Expand Up @@ -939,7 +940,7 @@ func decodeLineOfMediaPlaylist(p *MediaPlaylist, state *decodingState, line stri
return err
}
// if the program date time tag is present, set it on this partial segment
if state.tagProgramDateTime && len(p.PartialSegments) > 0 {
if state.tagProgramDateTime && p.HasPartialSegments() {
partialSegment.ProgramDateTime = state.programDateTime
state.tagProgramDateTime = false
}
Expand Down
14 changes: 7 additions & 7 deletions m3u8/reader_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -873,26 +873,26 @@ func TestDecodeLowLatencyMediaPlaylist(t *testing.T) {
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
is.Equal(pp.TargetDuration, uint(4))
is.True(!pp.Closed) // live playlist
is.Equal(pp.SeqNo, uint64(244))
is.Equal(pp.Count(), uint(6))
is.Equal(pp.PartTargetDuration, float32(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() {
s := pp.Segments[i]
expected := fmt.Sprintf("fileSequence%d.m4s", i+234+1)
expected := fmt.Sprintf("fileSequence%d.m4s", i+244+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")
st, _ := time.Parse(time.RFC3339, "2025-02-10T14:43:10.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)
Expand Down
60 changes: 19 additions & 41 deletions m3u8/sample-playlists/media-playlist-low-latency.m3u8
Original file line number Diff line number Diff line change
@@ -1,53 +1,31 @@
#EXTM3U
#EXT-X-TARGETDURATION:4
#EXT-X-VERSION:6
#EXT-X-PART-INF:PART-TARGET=1.002000
#EXT-X-MEDIA-SEQUENCE:234
#EXT-X-PART-INF:PART-TARGET=1.002
#EXT-X-MEDIA-SEQUENCE:244
#EXT-X-TARGETDURATION:4
#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,
#EXTINF:4.000,
fileSequence245.m4s
#EXT-X-PROGRAM-DATE-TIME:2025-02-10T14:43:10.134Z
#EXTINF:4.00000,
#EXTINF:4.000,
fileSequence246.m4s
#EXTINF:4.00000,
#EXTINF:4.000,
fileSequence247.m4s
#EXTINF:4.00000,
#EXTINF:4.000,
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,
#EXT-X-PART:DURATION=1.000,INDEPENDENT=YES,URI="filePart249.1.m4s"
#EXT-X-PART:DURATION=1.000,INDEPENDENT=YES,URI="filePart249.2.m4s"
#EXT-X-PART:DURATION=1.000,INDEPENDENT=YES,URI="filePart249.3.m4s"
#EXT-X-PART:DURATION=1.000,INDEPENDENT=YES,URI="filePart249.4.m4s"
#EXTINF:4.000,
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,
#EXT-X-PART:DURATION=1.000,INDEPENDENT=YES,URI="filePart250.1.m4s"
#EXT-X-PART:DURATION=1.000,INDEPENDENT=YES,URI="filePart250.2.m4s"
#EXT-X-PART:DURATION=1.000,INDEPENDENT=YES,URI="filePart250.3.m4s"
#EXT-X-PART:DURATION=1.000,INDEPENDENT=YES,URI="filePart250.4.m4s"
#EXTINF:4.000,
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-PART:DURATION=1.000,INDEPENDENT=YES,URI="filePart251.1.m4s"
#EXT-X-PART:DURATION=1.000,INDEPENDENT=YES,URI="filePart251.2.m4s"
#EXT-X-PRELOAD-HINT:TYPE=PART,URI="filePart251.3.m4s"
147 changes: 145 additions & 2 deletions m3u8/writer.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import (
"errors"
"fmt"
"math"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
Expand Down Expand Up @@ -297,6 +299,49 @@ func writeExtXIFrameStreamInf(buf *bytes.Buffer, vnt *Variant) {
buf.WriteRune('\n')
}

func writePartialSegment(buf *bytes.Buffer, ps *PartialSegment) {
if !ps.ProgramDateTime.IsZero() {
buf.WriteString("#EXT-X-PROGRAM-DATE-TIME:")
buf.WriteString(ps.ProgramDateTime.Format(DATETIME))
buf.WriteRune('\n')
}
buf.WriteString("#EXT-X-PART:")
buf.WriteString("DURATION=")
buf.WriteString(strconv.FormatFloat(ps.Duration, 'f', 3, 64))
if ps.Independent {
buf.WriteString(",INDEPENDENT=YES")
}
if ps.Gap {
buf.WriteString(",GAP=YES")
}
if ps.Limit > 0 {
buf.WriteString(",BYTERANGE=")
buf.WriteString(strconv.FormatInt(ps.Limit, 10))
buf.WriteRune('@')
buf.WriteString(strconv.FormatInt(ps.Offset, 10))
}
buf.WriteString(",URI=\"")
buf.WriteString(ps.URI)
buf.WriteRune('"')
buf.WriteRune('\n')
}

func writePreloadHint(buf *bytes.Buffer, ph *PreloadHint) {
buf.WriteString("#EXT-X-PRELOAD-HINT:")
buf.WriteString("TYPE=")
buf.WriteString(ph.Type)
buf.WriteString(",URI=\"")
buf.WriteString(ph.URI)
buf.WriteRune('"')
if ph.Offset > 0 {
buf.WriteString(",BYTERANGE-START=")
buf.WriteString(strconv.FormatInt(ph.Offset, 10))
buf.WriteString(",BYTERANGE-LENGTH=")
buf.WriteString(strconv.FormatInt(ph.Limit, 10))
}
buf.WriteRune('\n')
}

// writeDateRange writes an EXT-X-DATERANGE tag line including \n to the buffer.
func writeDateRange(buf *bytes.Buffer, dr *DateRange) {
buf.WriteString(`#EXT-X-DATERANGE:ID="`)
Expand Down Expand Up @@ -614,8 +659,28 @@ func (p *MediaPlaylist) AppendSegment(seg *MediaSegment) error {
return nil
}

func (p *MediaPlaylist) AppendPartialSegment(ps *PartialSegment) {
func (p *MediaPlaylist) AppendPartial(uri string, duration float64, independent bool) error {
seg := new(PartialSegment)
seg.URI = uri
seg.Duration = duration
seg.Independent = independent
return p.AppendPartialSegment(seg)
}

func (p *MediaPlaylist) AppendPartialSegment(ps *PartialSegment) error {
if p.count == 0 {
return ErrPlaylistEmpty
}
p.PartialSegments = append(p.PartialSegments, ps)

return nil
}

func (p *MediaPlaylist) SetPreloadHint(hintType, uri string) {
preloadHint := new(PreloadHint)
preloadHint.Type = hintType
preloadHint.URI = uri
p.PreloadHints = preloadHint
}

func (p *MediaPlaylist) AppendDefine(d Define) {
Expand Down Expand Up @@ -695,7 +760,7 @@ func (p *MediaPlaylist) Encode() *bytes.Buffer {
}
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.WriteString(strconv.FormatFloat(float64(p.PartTargetDuration), 'f', 3, 64))
p.buf.WriteRune('\n')
}
p.buf.WriteString("#EXT-X-MEDIA-SEQUENCE:")
Expand Down Expand Up @@ -799,6 +864,22 @@ func (p *MediaPlaylist) Encode() *bytes.Buffer {
p.buf.WriteString(seg.ProgramDateTime.Format(DATETIME))
p.buf.WriteRune('\n')
}
// handle completed partial segments
if p.HasPartialSegments() {
fullSegUri := seg.URI
countPartialSeg := 0
for _, ps := range p.PartialSegments {
if IsPartOf(ps.URI, fullSegUri) {
// This partial segment is part of the current full segment
writePartialSegment(&p.buf, ps)
countPartialSeg += 1
}
}
if countPartialSeg > 0 {
// Remove completed partial segments from list
p.PartialSegments = p.PartialSegments[countPartialSeg:]
}
}
if seg.Limit > 0 {
p.buf.WriteString("#EXT-X-BYTERANGE:")
p.buf.WriteString(strconv.FormatInt(seg.Limit, 10))
Expand Down Expand Up @@ -834,6 +915,17 @@ func (p *MediaPlaylist) Encode() *bytes.Buffer {
}
p.buf.WriteRune('\n')
}

// handle uncompleted partial segments
if p.HasPartialSegments() {
for _, ps := range p.PartialSegments {
writePartialSegment(&p.buf, ps)
}
}
if p.PreloadHints != nil {
writePreloadHint(&p.buf, p.PreloadHints)
}

if p.Closed {
p.buf.WriteString("#EXT-X-ENDLIST\n")
}
Expand All @@ -853,6 +945,10 @@ func (p *MediaPlaylist) Count() uint {
return p.count
}

func (p *MediaPlaylist) HasPartialSegments() bool {
return len(p.PartialSegments) > 0
}

// Close sliding playlist and by setting the EXT-X-ENDLIST tag and setting the Closed flag.
func (p *MediaPlaylist) Close() {
if p.buf.Len() > 0 {
Expand Down Expand Up @@ -1085,3 +1181,50 @@ func (p *MediaPlaylist) GetAllSegments() []*MediaSegment {
/*
[Protocol Version Compatibility]: https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis-16#section-8
*/

/// Helper functions

func splitUriBy(uri, sep string) (string, string) {
// split the uri by the last dot
uriParts := strings.Split(uri, sep)
if len(uriParts) < 2 {
return "", ""
}
// get the last part of the uri
lastPart := uriParts[len(uriParts)-1]
// get the rest of the uri
rest := strings.Join(uriParts[:len(uriParts)-1], sep)
return rest, lastPart
}

// find the numbers in the string
// e.g., fileSequence250 -> 250
// filePart250 -> 250
func getSequenceNum(uri string) uint64 {
// find the last number in the uri
re := regexp.MustCompile(`(\d+)$`)
numStr := re.FindString(uri)
if numStr == "" {
return 0
}
num, err := strconv.ParseUint(numStr, 10, 64)
if err != nil {
return 0
}
return num
}

// check if a uri include the other
func IsPartOf(partialSegUri, segUri string) bool {
// check if the extension is the same
if filepath.Ext(partialSegUri) != filepath.Ext(segUri) {
return false
}

// remove the extension
partialSegUri = strings.TrimSuffix(partialSegUri, filepath.Ext(partialSegUri))
partialSegUriPrefix, _ := splitUriBy(partialSegUri, ".")
segUri = strings.TrimSuffix(segUri, filepath.Ext(segUri))

return getSequenceNum(partialSegUriPrefix) == getSequenceNum(segUri)
}
Loading

0 comments on commit 33a9ed6

Please sign in to comment.