From e2a23f90db66b68ae1e7593ee50e4af78d100242 Mon Sep 17 00:00:00 2001 From: Vishal Shah Date: Thu, 21 Nov 2019 16:07:13 -0700 Subject: [PATCH 01/26] Fix: MediaPlaylist with discontinuities should not ignore maps --- .../media-playlist-with-discos-and-maps.m3u8 | 19 ++++++++ .../media-playlist-with-maps.m3u8 | 11 +++++ writer.go | 14 ++++++ writer_test.go | 43 ++++++++++++++++++- 4 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 sample-playlists/media-playlist-with-discos-and-maps.m3u8 create mode 100644 sample-playlists/media-playlist-with-maps.m3u8 diff --git a/sample-playlists/media-playlist-with-discos-and-maps.m3u8 b/sample-playlists/media-playlist-with-discos-and-maps.m3u8 new file mode 100644 index 00000000..19941549 --- /dev/null +++ b/sample-playlists/media-playlist-with-discos-and-maps.m3u8 @@ -0,0 +1,19 @@ +#EXTM3U +#EXT-X-VERSION:5 +#EXT-X-MAP:URI="https://example.com/content-init.mp4",BYTERANGE=1024000@1048576 +#EXT-X-MEDIA-SEQUENCE:0 +#EXT-X-TARGETDURATION:6 +#EXTINF:5.000, +test01.mp4 +#EXT-X-DISCONTINUITY +#EXT-X-MAP:URI="https://segmentencoded.com/ad-init.mp4",BYTERANGE=1024000@1048576 +#EXTINF:6.000, +test02.mp4 +#EXTINF:6.000, +test03.mp4 +#EXT-X-DISCONTINUITY +#EXT-X-MAP:URI="https://segmentencoded.com/content-init.mp4",BYTERANGE=1024000@1048576 +#EXTINF:6.000, +test04.mp4 +#EXTINF:6.000, +test05.mp4 diff --git a/sample-playlists/media-playlist-with-maps.m3u8 b/sample-playlists/media-playlist-with-maps.m3u8 new file mode 100644 index 00000000..64a924b5 --- /dev/null +++ b/sample-playlists/media-playlist-with-maps.m3u8 @@ -0,0 +1,11 @@ +#EXTM3U +#EXT-X-VERSION:5 +#EXT-X-MAP:URI="https://example.com/content-init.mp4",BYTERANGE=1024000@1048576 +#EXT-X-MEDIA-SEQUENCE:0 +#EXT-X-TARGETDURATION:6 +#EXTINF:6.000, +test01.mp4 +#EXTINF:6.000, +test02.mp4 +#EXTINF:6.000, +test03.mp4 diff --git a/writer.go b/writer.go index ffea44a3..3273035b 100644 --- a/writer.go +++ b/writer.go @@ -624,6 +624,20 @@ func (p *MediaPlaylist) Encode() *bytes.Buffer { } p.buf.WriteRune('\n') } + // add if default map exists and playlist has discontinuities + if p.Map != nil && seg.Discontinuity && seg.Map != nil { + p.buf.WriteString("#EXT-X-MAP:") + p.buf.WriteString("URI=\"") + p.buf.WriteString(seg.Map.URI) + p.buf.WriteRune('"') + if seg.Map.Limit > 0 { + p.buf.WriteString(",BYTERANGE=") + p.buf.WriteString(strconv.FormatInt(seg.Map.Limit, 10)) + p.buf.WriteRune('@') + p.buf.WriteString(strconv.FormatInt(seg.Map.Offset, 10)) + } + p.buf.WriteRune('\n') + } if !seg.ProgramDateTime.IsZero() { p.buf.WriteString("#EXT-X-PROGRAM-DATE-TIME:") p.buf.WriteString(seg.ProgramDateTime.Format(DATETIME)) diff --git a/writer_test.go b/writer_test.go index 76db9899..be8d2fb1 100644 --- a/writer_test.go +++ b/writer_test.go @@ -365,7 +365,7 @@ func TestEncodeMediaPlaylistWithDefaultMap(t *testing.T) { if e != nil { t.Errorf("Set map to segment failed: %s", e) } - + //fmt.Println(p.Encode().String()) encoded := p.String() expected := `EXT-X-MAP:URI="https://example.com",BYTERANGE=1024000@1048576` if !strings.Contains(encoded, expected) { @@ -378,6 +378,47 @@ func TestEncodeMediaPlaylistWithDefaultMap(t *testing.T) { } } +// Create new playlist +// Set default map and Add segment to media playlist +// Set discontinuity and Add segments +// Set map on segment +func TestEncodeMediaPlaylistWithDiscontinuityAndDefaultMap(t *testing.T) { + p, e := NewMediaPlaylist(3, 5) + if e != nil { + t.Fatalf("Create media playlist failed: %s", e) + } + p.SetDefaultMap("https://example.com", 1000*1024, 1024*1024) + + e = p.Append("test01.ts", 5.0, "") + if e != nil { + t.Errorf("Add 1st segment to a media playlist failed: %s", e) + } + if e = p.Append("test02.ts", 6.0, ""); e != nil { + t.Errorf("Add 2nd segment to a media playlist failed: %s", e) + } + if e = p.SetDiscontinuity(); e != nil { + t.Error("Can't set discontinuity tag") + } + e = p.SetMap("https://segmentencoded.com", 1000*1024, 1024*1024) + if e != nil { + t.Errorf("Set map to segment failed: %s", e) + } + + if e = p.Append("test03.ts", 6.0, ""); e != nil { + t.Errorf("Add 3nd segment to a media playlist failed: %s", e) + } + encoded := p.String() + //fmt.Println(p.Encode().String()) + expectDefaultMap := `EXT-X-MAP:URI="https://example.com",BYTERANGE=1024000@1048576` + if !strings.Contains(encoded, expectDefaultMap) { + t.Fatalf("Media playlist did not contain: %s\nMedia Playlist:\n%v", expectDefaultMap, encoded) + } + expectSegmentMap := `EXT-X-MAP:URI="https://segmentencoded.com",BYTERANGE=1024000@1048576` + if !strings.Contains(encoded, expectSegmentMap) { + t.Fatalf("Media playlist did not contain: %s\nMedia Playlist:\n%v", expectSegmentMap, encoded) + } +} + // Create new media playlist // Add two segments to media playlist // Encode structures to HLS From 3cf5695c34f790d070468737a89a4ea0bda49476 Mon Sep 17 00:00:00 2001 From: Vishal Shah Date: Mon, 25 Nov 2019 11:52:40 -0700 Subject: [PATCH 02/26] add appendSegment tests for MediaPlaylist --- ...playlist-with-discontinuity-and-maps.m3u8} | 2 +- writer_test.go | 41 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) rename sample-playlists/{media-playlist-with-discos-and-maps.m3u8 => media-playlist-with-discontinuity-and-maps.m3u8} (82%) diff --git a/sample-playlists/media-playlist-with-discos-and-maps.m3u8 b/sample-playlists/media-playlist-with-discontinuity-and-maps.m3u8 similarity index 82% rename from sample-playlists/media-playlist-with-discos-and-maps.m3u8 rename to sample-playlists/media-playlist-with-discontinuity-and-maps.m3u8 index 19941549..1648b162 100644 --- a/sample-playlists/media-playlist-with-discos-and-maps.m3u8 +++ b/sample-playlists/media-playlist-with-discontinuity-and-maps.m3u8 @@ -12,7 +12,7 @@ test02.mp4 #EXTINF:6.000, test03.mp4 #EXT-X-DISCONTINUITY -#EXT-X-MAP:URI="https://segmentencoded.com/content-init.mp4",BYTERANGE=1024000@1048576 +#EXT-X-MAP:URI="https://example.com/content-init.mp4",BYTERANGE=1024000@1048576 #EXTINF:6.000, test04.mp4 #EXTINF:6.000, diff --git a/writer_test.go b/writer_test.go index be8d2fb1..420ca96d 100644 --- a/writer_test.go +++ b/writer_test.go @@ -419,6 +419,47 @@ func TestEncodeMediaPlaylistWithDiscontinuityAndDefaultMap(t *testing.T) { } } +// Create new playlist +// Set default map and Add segment to media playlist +// Set discontinuity and Add segment with AppendSegment +// Set map on segment +// NOTE: same test as TestEncodeMediaPlaylistWithDiscontinuityAndDefaultMap , just covers different methods +func TestEncodeMediaPlaylistWithDiscontinuityAndDefaultMapWithAppendSegment(t *testing.T) { + p, e := NewMediaPlaylist(3, 5) + if e != nil { + t.Fatalf("Create media playlist failed: %s", e) + } + p.SetDefaultMap("https://example.com", 1000*1024, 1024*1024) + + e = p.Append("test01.ts", 5.0, "") + if e != nil { + t.Errorf("Add 1st segment to a media playlist failed: %s", e) + } + if e = p.Append("test02.ts", 5.0, ""); e != nil { + t.Errorf("Add 2nd segment to a media playlist failed: %s", e) + } + seg3 := MediaSegment{ + URI: "test-03.ts", + Duration: 6.0, + Discontinuity: true, + Map: &Map{URI: "https://segmentencoded.com", Limit: 1000 * 1024, Offset: 1024 * 1024}, + } + if e = p.AppendSegment(&seg3); e != nil { + t.Error("can't append segment") + } + encoded := p.String() + //fmt.Println(p.Encode().String()) + expectDefaultMap := `EXT-X-MAP:URI="https://example.com",BYTERANGE=1024000@1048576` + if !strings.Contains(encoded, expectDefaultMap) { + t.Fatalf("Media playlist did not contain: %s\nMedia Playlist:\n%v", expectDefaultMap, encoded) + } + expectSegmentMap := `EXT-X-MAP:URI="https://segmentencoded.com",BYTERANGE=1024000@1048576` + if !strings.Contains(encoded, expectSegmentMap) { + t.Fatalf("Media playlist did not contain: %s\nMedia Playlist:\n%v", expectSegmentMap, encoded) + } + +} + // Create new media playlist // Add two segments to media playlist // Encode structures to HLS From 3d9e843af94efb6d6a1a52b32cda78843ad58402 Mon Sep 17 00:00:00 2001 From: Robert Peck Date: Wed, 7 Oct 2020 13:07:17 -0700 Subject: [PATCH 03/26] Try vanity import to work w/ travis --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index fa38729e..998c0449 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,4 +1,5 @@ language: go +go_import_path: github.com/grafov/m3u8 # Versions of go that are explicitly supported. go: From 98975fe29d938af7a6a55a5f1e78dc3339bba9d6 Mon Sep 17 00:00:00 2001 From: Robert Peck Date: Wed, 7 Oct 2020 13:32:16 -0700 Subject: [PATCH 04/26] remove coveralls from fork --- .travis.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 998c0449..427560c7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,10 +11,8 @@ go: # Required for coverage. before_install: - go get golang.org/x/tools/cmd/cover - - go get github.com/mattn/goveralls script: - go build -a -v ./... - diff <(gofmt -d .) <("") - go test -v -covermode=count -coverprofile=coverage.out - - $GOPATH/bin/goveralls -coverprofile=coverage.out -service=travis-ci From 8abd012a213559b1464f72ee977bfdd21d25c1d2 Mon Sep 17 00:00:00 2001 From: Robert Peck Date: Wed, 7 Oct 2020 13:41:16 -0700 Subject: [PATCH 05/26] With support for CHANNELS --- reader.go | 2 + reader_test.go | 37 +++++ sample-playlists/master-adv-example-hevc.m3u8 | 152 ++++++++++++++++++ structure.go | 1 + writer.go | 5 + writer_test.go | 16 ++ 6 files changed, 213 insertions(+) create mode 100644 sample-playlists/master-adv-example-hevc.m3u8 diff --git a/reader.go b/reader.go index b19324eb..2ca68be2 100644 --- a/reader.go +++ b/reader.go @@ -327,6 +327,8 @@ func decodeLineOfMasterPlaylist(p *MasterPlaylist, state *decodingState, line st alt.Characteristics = v case "SUBTITLES": alt.Subtitles = v + case "CHANNELS": + alt.Channels = v case "URI": alt.URI = v } diff --git a/reader_test.go b/reader_test.go index 8d60b16c..691854d5 100644 --- a/reader_test.go +++ b/reader_test.go @@ -288,6 +288,43 @@ func TestDecodeMasterWithHLSV7(t *testing.T) { } } +func TestDecodeMasterPlaylistWithChapters(t *testing.T) { + f, err := os.Open("sample-playlists/master-adv-example-hevc.m3u8") + if err != nil { + t.Fatal(err) + } + p := NewMasterPlaylist() + err = p.DecodeFrom(bufio.NewReader(f), false) + if err != nil { + t.Fatal(err) + } + var audio []*Alternative + for _, v := range p.Variants { + for _, a := range v.Alternatives { + if a.Type == "AUDIO" { + audio = append(audio, a) + } + } + } + if len(audio) != 3 { + t.Fatalf("expected 6 audio renditions got=%d", len(audio)) + } + for _, a := range audio { + var expectedChannels string + switch a.GroupId { + case "a1": + expectedChannels = "2" + case "a2", "a3": + expectedChannels = "6" + default: + t.Fatalf("unexpected audio GROUP-ID=%q", a.GroupId) + } + if a.Channels != expectedChannels { + t.Fatalf("incorrect channels attriute expected=%q got=%q GROUP-ID=%q", expectedChannels, a.Channels, a.GroupId) + } + } +} + /**************************** * Begin Test MediaPlaylist * ****************************/ diff --git a/sample-playlists/master-adv-example-hevc.m3u8 b/sample-playlists/master-adv-example-hevc.m3u8 new file mode 100644 index 00000000..fed8ece5 --- /dev/null +++ b/sample-playlists/master-adv-example-hevc.m3u8 @@ -0,0 +1,152 @@ +#EXTM3U +#EXT-X-VERSION:6 +#EXT-X-INDEPENDENT-SEGMENTS + + +#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="a1",NAME="English",LANGUAGE="en-US",AUTOSELECT=YES,DEFAULT=YES,CHANNELS="2",URI="a1/prog_index.m3u8" +#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="a2",NAME="English",LANGUAGE="en-US",AUTOSELECT=YES,DEFAULT=YES,CHANNELS="6",URI="a2/prog_index.m3u8" +#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="a3",NAME="English",LANGUAGE="en-US",AUTOSELECT=YES,DEFAULT=YES,CHANNELS="6",URI="a3/prog_index.m3u8" + + +#EXT-X-MEDIA:TYPE=CLOSED-CAPTIONS,GROUP-ID="cc",LANGUAGE="en",NAME="English",DEFAULT=YES,AUTOSELECT=YES,INSTREAM-ID="CC1" + + +#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="sub1",LANGUAGE="en",NAME="English",AUTOSELECT=YES,DEFAULT=YES,FORCED=NO,URI="s1/en/prog_index.m3u8" + + +#EXT-X-I-FRAME-STREAM-INF:AVERAGE-BANDWIDTH=928091,BANDWIDTH=1015727,CODECS="avc1.640028",RESOLUTION=1920x1080,URI="tp5/iframe_index.m3u8" +#EXT-X-I-FRAME-STREAM-INF:AVERAGE-BANDWIDTH=731514,BANDWIDTH=760174,CODECS="avc1.64001f",RESOLUTION=1280x720,URI="tp4/iframe_index.m3u8" +#EXT-X-I-FRAME-STREAM-INF:AVERAGE-BANDWIDTH=509153,BANDWIDTH=520162,CODECS="avc1.64001f",RESOLUTION=960x540,URI="tp3/iframe_index.m3u8" +#EXT-X-I-FRAME-STREAM-INF:AVERAGE-BANDWIDTH=176942,BANDWIDTH=186651,CODECS="avc1.64001f",RESOLUTION=640x360,URI="tp2/iframe_index.m3u8" +#EXT-X-I-FRAME-STREAM-INF:AVERAGE-BANDWIDTH=90796,BANDWIDTH=95410,CODECS="avc1.64001f",RESOLUTION=480x270,URI="tp1/iframe_index.m3u8" + + +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=2190673,BANDWIDTH=2523597,CODECS="avc1.640020,mp4a.40.2",RESOLUTION=960x540,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a1",SUBTITLES="sub1" +v5/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=8052613,BANDWIDTH=9873268,CODECS="avc1.64002a,mp4a.40.2",RESOLUTION=1920x1080,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a1",SUBTITLES="sub1" +v9/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=6133114,BANDWIDTH=7318337,CODECS="avc1.64002a,mp4a.40.2",RESOLUTION=1920x1080,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a1",SUBTITLES="sub1" +v8/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=4681537,BANDWIDTH=5421720,CODECS="avc1.64002a,mp4a.40.2",RESOLUTION=1920x1080,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a1",SUBTITLES="sub1" +v7/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=3183969,BANDWIDTH=3611257,CODECS="avc1.640020,mp4a.40.2",RESOLUTION=1280x720,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a1",SUBTITLES="sub1" +v6/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=1277747,BANDWIDTH=1475903,CODECS="avc1.64001f,mp4a.40.2",RESOLUTION=768x432,FRAME-RATE=30.000,CLOSED-CAPTIONS="cc",AUDIO="a1",SUBTITLES="sub1" +v4/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=890848,BANDWIDTH=1017705,CODECS="avc1.64001f,mp4a.40.2",RESOLUTION=640x360,FRAME-RATE=30.000,CLOSED-CAPTIONS="cc",AUDIO="a1",SUBTITLES="sub1" +v3/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=533420,BANDWIDTH=582820,CODECS="avc1.64001f,mp4a.40.2",RESOLUTION=480x270,FRAME-RATE=30.000,CLOSED-CAPTIONS="cc",AUDIO="a1",SUBTITLES="sub1" +v2/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=303898,BANDWIDTH=339404,CODECS="avc1.64001f,mp4a.40.2",RESOLUTION=416x234,FRAME-RATE=30.000,CLOSED-CAPTIONS="cc",AUDIO="a1",SUBTITLES="sub1" +v1/prog_index.m3u8 + + +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=2413172,BANDWIDTH=2746096,CODECS="avc1.640020,ac-3",RESOLUTION=960x540,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a2",SUBTITLES="sub1" +v5/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=8275112,BANDWIDTH=10095767,CODECS="avc1.64002a,ac-3",RESOLUTION=1920x1080,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a2",SUBTITLES="sub1" +v9/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=6355613,BANDWIDTH=7540836,CODECS="avc1.64002a,ac-3",RESOLUTION=1920x1080,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a2",SUBTITLES="sub1" +v8/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=4904036,BANDWIDTH=5644219,CODECS="avc1.64002a,ac-3",RESOLUTION=1920x1080,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a2",SUBTITLES="sub1" +v7/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=3406468,BANDWIDTH=3833756,CODECS="avc1.640020,ac-3",RESOLUTION=1280x720,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a2",SUBTITLES="sub1" +v6/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=1500246,BANDWIDTH=1698402,CODECS="avc1.64001f,ac-3",RESOLUTION=768x432,FRAME-RATE=30.000,CLOSED-CAPTIONS="cc",AUDIO="a2",SUBTITLES="sub1" +v4/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=1113347,BANDWIDTH=1240204,CODECS="avc1.64001f,ac-3",RESOLUTION=640x360,FRAME-RATE=30.000,CLOSED-CAPTIONS="cc",AUDIO="a2",SUBTITLES="sub1" +v3/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=755919,BANDWIDTH=805319,CODECS="avc1.64001f,ac-3",RESOLUTION=480x270,FRAME-RATE=30.000,CLOSED-CAPTIONS="cc",AUDIO="a2",SUBTITLES="sub1" +v2/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=526397,BANDWIDTH=561903,CODECS="avc1.64001f,ac-3",RESOLUTION=416x234,FRAME-RATE=30.000,CLOSED-CAPTIONS="cc",AUDIO="a2",SUBTITLES="sub1" +v1/prog_index.m3u8 + + +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=2221172,BANDWIDTH=2554096,CODECS="avc1.640020,ec-3",RESOLUTION=960x540,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a3",SUBTITLES="sub1" +v5/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=8083112,BANDWIDTH=9903767,CODECS="avc1.64002a,ec-3",RESOLUTION=1920x1080,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a3",SUBTITLES="sub1" +v9/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=6163613,BANDWIDTH=7348836,CODECS="avc1.64002a,ec-3",RESOLUTION=1920x1080,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a3",SUBTITLES="sub1" +v8/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=4712036,BANDWIDTH=5452219,CODECS="avc1.64002a,ec-3",RESOLUTION=1920x1080,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a3",SUBTITLES="sub1" +v7/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=3214468,BANDWIDTH=3641756,CODECS="avc1.640020,ec-3",RESOLUTION=1280x720,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a3",SUBTITLES="sub1" +v6/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=1308246,BANDWIDTH=1506402,CODECS="avc1.64001f,ec-3",RESOLUTION=768x432,FRAME-RATE=30.000,CLOSED-CAPTIONS="cc",AUDIO="a3",SUBTITLES="sub1" +v4/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=921347,BANDWIDTH=1048204,CODECS="avc1.64001f,ec-3",RESOLUTION=640x360,FRAME-RATE=30.000,CLOSED-CAPTIONS="cc",AUDIO="a3",SUBTITLES="sub1" +v3/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=563919,BANDWIDTH=613319,CODECS="avc1.64001f,ec-3",RESOLUTION=480x270,FRAME-RATE=30.000,CLOSED-CAPTIONS="cc",AUDIO="a3",SUBTITLES="sub1" +v2/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=334397,BANDWIDTH=369903,CODECS="avc1.64001f,ec-3",RESOLUTION=416x234,FRAME-RATE=30.000,CLOSED-CAPTIONS="cc",AUDIO="a3",SUBTITLES="sub1" +v1/prog_index.m3u8 + + +#EXT-X-I-FRAME-STREAM-INF:AVERAGE-BANDWIDTH=287207,BANDWIDTH=328352,CODECS="hvc1.2.4.L123.B0",RESOLUTION=1920x1080,URI="tp10/iframe_index.m3u8" +#EXT-X-I-FRAME-STREAM-INF:AVERAGE-BANDWIDTH=216605,BANDWIDTH=226274,CODECS="hvc1.2.4.L123.B0",RESOLUTION=1280x720,URI="tp9/iframe_index.m3u8" +#EXT-X-I-FRAME-STREAM-INF:AVERAGE-BANDWIDTH=154000,BANDWIDTH=159037,CODECS="hvc1.2.4.L123.B0",RESOLUTION=960x540,URI="tp8/iframe_index.m3u8" +#EXT-X-I-FRAME-STREAM-INF:AVERAGE-BANDWIDTH=90882,BANDWIDTH=92800,CODECS="hvc1.2.4.L123.B0",RESOLUTION=640x360,URI="tp7/iframe_index.m3u8" +#EXT-X-I-FRAME-STREAM-INF:AVERAGE-BANDWIDTH=50569,BANDWIDTH=51760,CODECS="hvc1.2.4.L123.B0",RESOLUTION=480x270,URI="tp6/iframe_index.m3u8" + + +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=1966314,BANDWIDTH=2164328,CODECS="hvc1.2.4.L123.B0,mp4a.40.2",RESOLUTION=960x540,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a1",SUBTITLES="sub1" +v14/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=6105163,BANDWIDTH=6664228,CODECS="hvc1.2.4.L123.B0,mp4a.40.2",RESOLUTION=1920x1080,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a1",SUBTITLES="sub1" +v18/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=4801073,BANDWIDTH=5427899,CODECS="hvc1.2.4.L123.B0,mp4a.40.2",RESOLUTION=1920x1080,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a1",SUBTITLES="sub1" +v17/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=3441312,BANDWIDTH=4079770,CODECS="hvc1.2.4.L123.B0,mp4a.40.2",RESOLUTION=1920x1080,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a1",SUBTITLES="sub1" +v16/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=2635933,BANDWIDTH=2764701,CODECS="hvc1.2.4.L123.B0,mp4a.40.2",RESOLUTION=1280x720,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a1",SUBTITLES="sub1" +v15/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=1138612,BANDWIDTH=1226255,CODECS="hvc1.2.4.L123.B0,mp4a.40.2",RESOLUTION=768x432,FRAME-RATE=30.000,CLOSED-CAPTIONS="cc",AUDIO="a1",SUBTITLES="sub1" +v13/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=829339,BANDWIDTH=901770,CODECS="hvc1.2.4.L123.B0,mp4a.40.2",RESOLUTION=640x360,FRAME-RATE=30.000,CLOSED-CAPTIONS="cc",AUDIO="a1",SUBTITLES="sub1" +v12/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=522229,BANDWIDTH=548927,CODECS="hvc1.2.4.L123.B0,mp4a.40.2",RESOLUTION=480x270,FRAME-RATE=30.000,CLOSED-CAPTIONS="cc",AUDIO="a1",SUBTITLES="sub1" +v11/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=314941,BANDWIDTH=340713,CODECS="hvc1.2.4.L123.B0,mp4a.40.2",RESOLUTION=416x234,FRAME-RATE=30.000,CLOSED-CAPTIONS="cc",AUDIO="a1",SUBTITLES="sub1" +v10/prog_index.m3u8 + + +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=2188813,BANDWIDTH=2386827,CODECS="hvc1.2.4.L123.B0,ac-3",RESOLUTION=960x540,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a2",SUBTITLES="sub1" +v14/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=6327662,BANDWIDTH=6886727,CODECS="hvc1.2.4.L123.B0,ac-3",RESOLUTION=1920x1080,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a2",SUBTITLES="sub1" +v18/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=5023572,BANDWIDTH=5650398,CODECS="hvc1.2.4.L123.B0,ac-3",RESOLUTION=1920x1080,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a2",SUBTITLES="sub1" +v17/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=3663811,BANDWIDTH=4302269,CODECS="hvc1.2.4.L123.B0,ac-3",RESOLUTION=1920x1080,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a2",SUBTITLES="sub1" +v16/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=2858432,BANDWIDTH=2987200,CODECS="hvc1.2.4.L123.B0,ac-3",RESOLUTION=1280x720,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a2",SUBTITLES="sub1" +v15/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=1361111,BANDWIDTH=1448754,CODECS="hvc1.2.4.L123.B0,ac-3",RESOLUTION=768x432,FRAME-RATE=30.000,CLOSED-CAPTIONS="cc",AUDIO="a2",SUBTITLES="sub1" +v13/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=1051838,BANDWIDTH=1124269,CODECS="hvc1.2.4.L123.B0,ac-3",RESOLUTION=640x360,FRAME-RATE=30.000,CLOSED-CAPTIONS="cc",AUDIO="a2",SUBTITLES="sub1" +v12/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=744728,BANDWIDTH=771426,CODECS="hvc1.2.4.L123.B0,ac-3",RESOLUTION=480x270,FRAME-RATE=30.000,CLOSED-CAPTIONS="cc",AUDIO="a2",SUBTITLES="sub1" +v11/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=537440,BANDWIDTH=563212,CODECS="hvc1.2.4.L123.B0,ac-3",RESOLUTION=416x234,FRAME-RATE=30.000,CLOSED-CAPTIONS="cc",AUDIO="a2",SUBTITLES="sub1" +v10/prog_index.m3u8 + + +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=1996813,BANDWIDTH=2194827,CODECS="hvc1.2.4.L123.B0,ec-3",RESOLUTION=960x540,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a3",SUBTITLES="sub1" +v14/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=6135662,BANDWIDTH=6694727,CODECS="hvc1.2.4.L123.B0,ec-3",RESOLUTION=1920x1080,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a3",SUBTITLES="sub1" +v18/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=4831572,BANDWIDTH=5458398,CODECS="hvc1.2.4.L123.B0,ec-3",RESOLUTION=1920x1080,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a3",SUBTITLES="sub1" +v17/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=3471811,BANDWIDTH=4110269,CODECS="hvc1.2.4.L123.B0,ec-3",RESOLUTION=1920x1080,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a3",SUBTITLES="sub1" +v16/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=2666432,BANDWIDTH=2795200,CODECS="hvc1.2.4.L123.B0,ec-3",RESOLUTION=1280x720,FRAME-RATE=60.000,CLOSED-CAPTIONS="cc",AUDIO="a3",SUBTITLES="sub1" +v15/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=1169111,BANDWIDTH=1256754,CODECS="hvc1.2.4.L123.B0,ec-3",RESOLUTION=768x432,FRAME-RATE=30.000,CLOSED-CAPTIONS="cc",AUDIO="a3",SUBTITLES="sub1" +v13/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=859838,BANDWIDTH=932269,CODECS="hvc1.2.4.L123.B0,ec-3",RESOLUTION=640x360,FRAME-RATE=30.000,CLOSED-CAPTIONS="cc",AUDIO="a3",SUBTITLES="sub1" +v12/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=552728,BANDWIDTH=579426,CODECS="hvc1.2.4.L123.B0,ec-3",RESOLUTION=480x270,FRAME-RATE=30.000,CLOSED-CAPTIONS="cc",AUDIO="a3",SUBTITLES="sub1" +v11/prog_index.m3u8 +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=345440,BANDWIDTH=371212,CODECS="hvc1.2.4.L123.B0,ec-3",RESOLUTION=416x234,FRAME-RATE=30.000,CLOSED-CAPTIONS="cc",AUDIO="a3",SUBTITLES="sub1" +v10/prog_index.m3u8 + + + + diff --git a/structure.go b/structure.go index eb5d01a2..4ae31a46 100644 --- a/structure.go +++ b/structure.go @@ -197,6 +197,7 @@ type Alternative struct { Forced string Characteristics string Subtitles string + Channels string // Keeping as a string due to the quotes used in the m3u8 file } // MediaSegment structure represents a media segment included in a diff --git a/writer.go b/writer.go index 746130d7..407e0828 100644 --- a/writer.go +++ b/writer.go @@ -150,6 +150,11 @@ func (p *MasterPlaylist) Encode() *bytes.Buffer { p.buf.WriteString(alt.Subtitles) p.buf.WriteRune('"') } + if alt.Channels != "" { + p.buf.WriteString(",CHANNELS=\"") + p.buf.WriteString(alt.Channels) + p.buf.WriteRune('"') + } if alt.URI != "" { p.buf.WriteString(",URI=\"") p.buf.WriteString(alt.URI) diff --git a/writer_test.go b/writer_test.go index 2057ac27..76fe9e25 100644 --- a/writer_test.go +++ b/writer_test.go @@ -882,6 +882,22 @@ func TestNewMasterPlaylistWithAlternatives(t *testing.T) { } } +func TestNewMasterPlaylistWithAudioChannelAlternatives(t *testing.T) { + const expected = `#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="a1",DEFAULT=NO,CHANNELS="2` + var ( + m = NewMasterPlaylist() + audioAlt = &Alternative{GroupId: "a1", Type: "AUDIO", Channels: "2"} + p, err = NewMediaPlaylist(1, 1) + ) + if err != nil { + t.Fatalf("Create media playlist failed: %s", err) + } + m.Append("chunklist1.m3u8", p, VariantParams{Alternatives: []*Alternative{audioAlt}}) + if !strings.Contains(m.String(), expected) { + t.Fatalf("Master playist did not contain: %s\nMasterPlaylist:\n%s", expected, m) + } +} + // Create new master playlist supporting CLOSED-CAPTIONS=NONE func TestNewMasterPlaylistWithClosedCaptionEqNone(t *testing.T) { m := NewMasterPlaylist() From 6485cdb5d328ef1a9ed0744c50d710f28711c2f7 Mon Sep 17 00:00:00 2001 From: Bobby Peck Date: Wed, 7 Oct 2020 16:18:31 -0700 Subject: [PATCH 06/26] Upstream merge (#3) * support custom tag decode/encode * add unit tests * go fmt * add go.mod * add writer tests, breakup custom interface * update readme * add comments to exports * parse custom tags first * update CustomDecoder.Segment to CustomDecoder.SegmentTag * Add badge of godoc.org for the awesome-go * Fix formatting with gofmt Also copyrights updated. Refs to issue #151. * Fix comments by golint recommendations Refs to issue #151. * Simplify with gofmt Refs to issue #151. * Fix comments accordingly with golint advices Refs to issue #151. * Integrate with deepsource.io It bring us more code quality analyzers. Refs #151. * Add deepsource.io badge Refs #151. * Add awesome-go badge Refs to issue #151. * Try vanity import to work w/ travis * remove coveralls from fork Co-authored-by: Matthew Neil Co-authored-by: Lei Gao Co-authored-by: Alexander I.Grafov --- .deepsource.toml | 16 ++ .travis.yml | 3 +- AUTHORS | 3 +- README.md | 37 +-- doc.go | 68 ++--- example/example.go | 12 +- .../template/custom-playlist-tag-template.go | 52 ++++ .../template/custom-segment-tag-template.go | 79 ++++++ go.mod | 3 + reader.go | 118 ++++++++- reader_test.go | 244 +++++++++++++++++- .../master-playlist-with-custom-tags.m3u8 | 14 + .../media-playlist-with-custom-tags.m3u8 | 18 ++ structure.go | 157 ++++++----- structure_test.go | 36 +++ writer.go | 170 ++++++++---- writer_test.go | 77 +++++- 17 files changed, 935 insertions(+), 172 deletions(-) create mode 100644 .deepsource.toml create mode 100644 example/template/custom-playlist-tag-template.go create mode 100644 example/template/custom-segment-tag-template.go create mode 100644 go.mod create mode 100644 sample-playlists/master-playlist-with-custom-tags.m3u8 create mode 100644 sample-playlists/media-playlist-with-custom-tags.m3u8 diff --git a/.deepsource.toml b/.deepsource.toml new file mode 100644 index 00000000..f5332a56 --- /dev/null +++ b/.deepsource.toml @@ -0,0 +1,16 @@ +version = 1 + +test_patterns = [ + "*_test.go" +] + +exclude_patterns = [ + "vendor/**" +] + +[[analyzers]] +name = "go" +enabled = true + + [analyzers.meta] + import_path = "github.com/grafov/m3u8" diff --git a/.travis.yml b/.travis.yml index fa38729e..427560c7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,4 +1,5 @@ language: go +go_import_path: github.com/grafov/m3u8 # Versions of go that are explicitly supported. go: @@ -10,10 +11,8 @@ go: # Required for coverage. before_install: - go get golang.org/x/tools/cmd/cover - - go get github.com/mattn/goveralls script: - go build -a -v ./... - diff <(gofmt -d .) <("") - go test -v -covermode=count -coverprofile=coverage.out - - $GOPATH/bin/goveralls -coverprofile=coverage.out -service=travis-ci diff --git a/AUTHORS b/AUTHORS index 87fb76a4..c873bb91 100644 --- a/AUTHORS +++ b/AUTHORS @@ -18,6 +18,7 @@ to the project. They listed below in an alphabetical order: - Vishal Kumar Tuniki - Yevgen Flerko - Zac Shenker - +- Matthew Neil [mjneil](https://github.com/mjneil) + If you want to be added to this list (or removed for any reason) just open an issue about it. diff --git a/README.md b/README.md index 6c86e9a8..3884c597 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -M3U8 +M3U8 [![](https://awesome.re/mentioned-badge.svg)](https://github.com/avelino/awesome-go#video) ==== This is the most complete opensource library for parsing and generating of M3U8 playlists @@ -15,7 +15,7 @@ ways to play HLS or handle playlists over HTTP. So library features are: * Encryption keys support for use with DRM systems like [Verimatrix](http://verimatrix.com) etc. * Support for non standard [Google Widevine](http://www.widevine.com) tags. -The library covered by BSD 3-clause license. See [LICENSE](LICENSE) for the full text. +The library covered by BSD 3-clause license. See [LICENSE](LICENSE) for the full text. Versions 0.8 and below was covered by GPL v3. License was changed from the version 0.9 and upper. See the list of the library authors at [AUTHORS](AUTHORS) file. @@ -27,7 +27,7 @@ Install or get releases from https://github.com/grafov/m3u8/releases -Documentation [![Go Walker](http://gowalker.org/api/v1/badge)](http://gowalker.org/github.com/grafov/m3u8) +Documentation [![Go Walker](http://gowalker.org/api/v1/badge)](http://gowalker.org/github.com/grafov/m3u8) [![GoDoc](https://godoc.org/github.com/grafov/m3u8?status.svg)](https://godoc.org/github.com/grafov/m3u8) ------------- Package online documentation (examples included) available at: @@ -81,6 +81,11 @@ You may use API methods to fill structures or create them manually to generate p fmt.Println(p.Encode().String()) ``` +Custom Tags +----------- + +M3U8 supports parsing and writing of custom tags. You must implement both the `CustomTag` and `CustomDecoder` interface for each custom tag that may be encountered in the playlist. Look at the template files in `example/template/` for examples on parsing custom playlist and segment tags. + Library structure ----------------- @@ -111,29 +116,21 @@ Also the library used in opensource software so you may look at these apps for u * [HLS utils](https://github.com/archsh/hls-utils) * [M3U8 reader](https://github.com/jeongmin/m3u8-reader) -M3U8 parsing/generation in other languages ------------------------------------------- - -* https://github.com/globocom/m3u8 in Python -* https://github.com/zencoder/m3uzi in Ruby -* https://github.com/Jeanvf/M3U8Paser in Objective C -* https://github.com/tedconf/node-m3u8 in Javascript -* http://sourceforge.net/projects/m3u8parser/ in Java -* https://github.com/karlll/erlm3u8 in Erlang - Project status [![Go Report Card](https://goreportcard.com/badge/grafov/m3u8)](https://goreportcard.com/report/grafov/m3u8) -------------- [![Build Status](https://travis-ci.org/grafov/m3u8.png?branch=master)](https://travis-ci.org/grafov/m3u8) [![Build Status](https://cloud.drone.io/api/badges/grafov/m3u8/status.svg)](https://cloud.drone.io/grafov/m3u8) [![Coverage Status](https://coveralls.io/repos/github/grafov/m3u8/badge.svg?branch=master)](https://coveralls.io/github/grafov/m3u8?branch=master) +[![DeepSource](https://static.deepsource.io/deepsource-badge-light.svg)](https://deepsource.io/gh/grafov/m3u8/?ref=repository-badge) + +Code coverage: https://gocover.io/github.com/grafov/m3u8 + Project maintainers: * Lei Gao @leikao * Bradley Falzon @bradleyfalzon * Alexander Grafov @grafov -State of code coverage: https://gocover.io/github.com/grafov/m3u8 - Roadmap ------- @@ -141,3 +138,13 @@ To version 1.0: * Support all M3U8 tags up to latest version of specs. * Code coverage by unit tests up to 90% + +FYI M3U8 parsing/generation in other languages +------------------------------------------ + +* https://github.com/globocom/m3u8 in Python +* https://github.com/zencoder/m3uzi in Ruby +* https://github.com/Jeanvf/M3U8Paser in Objective C +* https://github.com/tedconf/node-m3u8 in Javascript +* http://sourceforge.net/projects/m3u8parser/ in Java +* https://github.com/karlll/erlm3u8 in Erlang diff --git a/doc.go b/doc.go index 775024ed..eb270395 100644 --- a/doc.go +++ b/doc.go @@ -1,8 +1,12 @@ -/* Package M3U8 is parser & generator library for Apple HLS. +// Package m3u8 is parser & generator library for Apple HLS. -This is a most complete opensource library for parsing and generating of M3U8 playlists used in HTTP Live Streaming (Apple HLS) for internet video translations. +/* This is a most complete opensource library for parsing and + generating of M3U8 playlists used in HTTP Live Streaming (Apple + HLS) for internet video translations. -M3U8 is simple text format and parsing library for it must be simple too. It did not offer ways to play HLS or handle playlists over HTTP. Library features are: +M3U8 is simple text format and parsing library for it must be simple +too. It did not offer ways to play HLS or handle playlists over +HTTP. Library features are: * Support HLS specs up to version 5 of the protocol. * Parsing and generation of master-playlists and media-playlists. @@ -11,46 +15,50 @@ M3U8 is simple text format and parsing library for it must be simple too. It did * Encryption keys support for usage with DRM systems like Verimatrix (http://verimatrix.com) etc. * Support for non standard Google Widevine (http://www.widevine.com) tags. -Library coded accordingly with IETF draft http://tools.ietf.org/html/draft-pantos-http-live-streaming +Library coded accordingly with IETF draft +http://tools.ietf.org/html/draft-pantos-http-live-streaming -Examples of usage may be found in *_test.go files of a package. Also see below some simple examples. +Examples of usage may be found in *_test.go files of a package. Also +see below some simple examples. -Create simple media playlist with sliding window of 3 segments and maximum of 50 segments. +Create simple media playlist with sliding window of 3 segments and +maximum of 50 segments. - p, e := NewMediaPlaylist(3, 50) - if e != nil { - panic(fmt.Sprintf("Create media playlist failed: %s", e)) - } - for i := 0; i < 5; i++ { - e = p.Add(fmt.Sprintf("test%d.ts", i), 5.0) - if e != nil { - panic(fmt.Sprintf("Add segment #%d to a media playlist failed: %s", i, e)) - } - } - fmt.Println(p.Encode(true).String()) + p, e := NewMediaPlaylist(3, 50) + if e != nil { + panic(fmt.Sprintf("Create media playlist failed: %s", e)) + } + for i := 0; i < 5; i++ { + e = p.Add(fmt.Sprintf("test%d.ts", i), 5.0) + if e != nil { + panic(fmt.Sprintf("Add segment #%d to a media playlist failed: %s", i, e)) + } + } + fmt.Println(p.Encode(true).String()) -We add 5 testX.ts segments to playlist then encode it to M3U8 format and convert to string. +We add 5 testX.ts segments to playlist then encode it to M3U8 format +and convert to string. Next example shows parsing of master playlist: - f, err := os.Open("sample-playlists/master.m3u8") - if err != nil { - fmt.Println(err) - } - p := NewMasterPlaylist() - err = p.DecodeFrom(bufio.NewReader(f), false) - if err != nil { - fmt.Println(err) - } + f, err := os.Open("sample-playlists/master.m3u8") + if err != nil { + fmt.Println(err) + } + p := NewMasterPlaylist() + err = p.DecodeFrom(bufio.NewReader(f), false) + if err != nil { + fmt.Println(err) + } - fmt.Printf("Playlist object: %+v\n", p) + fmt.Printf("Playlist object: %+v\n", p) We are open playlist from the file and parse it as master playlist. - */ + package m3u8 -// Copyright 2013-2017 The Project Developers. +// Copyright 2013-2019 The Project Developers. // See the AUTHORS and LICENSE files at the top-level directory of this distribution // and at https://github.com/grafov/m3u8/ diff --git a/example/example.go b/example/example.go index 3590fde0..074efff4 100644 --- a/example/example.go +++ b/example/example.go @@ -7,6 +7,7 @@ import ( "path" "github.com/grafov/m3u8" + "github.com/grafov/m3u8/example/template" ) func main() { @@ -14,12 +15,19 @@ func main() { if GOPATH == "" { panic("$GOPATH is empty") } - m3u8File := "github.com/grafov/m3u8/sample-playlists/media-playlist-with-byterange.m3u8" + + m3u8File := "github.com/grafov/m3u8/sample-playlists/media-playlist-with-custom-tags.m3u8" f, err := os.Open(path.Join(GOPATH, "src", m3u8File)) if err != nil { panic(err) } - p, listType, err := m3u8.DecodeFrom(bufio.NewReader(f), true) + + customTags := []m3u8.CustomDecoder{ + &template.CustomPlaylistTag{}, + &template.CustomSegmentTag{}, + } + + p, listType, err := m3u8.DecodeWith(bufio.NewReader(f), true, customTags) if err != nil { panic(err) } diff --git a/example/template/custom-playlist-tag-template.go b/example/template/custom-playlist-tag-template.go new file mode 100644 index 00000000..e1769b99 --- /dev/null +++ b/example/template/custom-playlist-tag-template.go @@ -0,0 +1,52 @@ +package template + +import ( + "bytes" + "fmt" + "strconv" + + "github.com/grafov/m3u8" +) + +// #CUSTOM-PLAYLIST-TAG: + +// CustomPlaylistTag implements both CustomTag and CustomDecoder +// interfaces. +type CustomPlaylistTag struct { + Number int +} + +// TagName should return the full indentifier including the leading +// '#' and trailing ':' if the tag also contains a value or attribute +// list. +func (tag *CustomPlaylistTag) TagName() string { + return "#CUSTOM-PLAYLIST-TAG:" +} + +// Decode decodes the input line. The line will be the entire matched +// line, including the identifier +func (tag *CustomPlaylistTag) Decode(line string) (m3u8.CustomTag, error) { + _, err := fmt.Sscanf(line, "#CUSTOM-PLAYLIST-TAG:%d", &tag.Number) + + return tag, err +} + +// SegmentTag is a playlist tag example. +func (tag *CustomPlaylistTag) SegmentTag() bool { + return false +} + +// Encode formats the structure to the text result. +func (tag *CustomPlaylistTag) Encode() *bytes.Buffer { + buf := new(bytes.Buffer) + + buf.WriteString(tag.TagName()) + buf.WriteString(strconv.Itoa(tag.Number)) + + return buf +} + +// String implements Stringer interface. +func (tag *CustomPlaylistTag) String() string { + return tag.Encode().String() +} diff --git a/example/template/custom-segment-tag-template.go b/example/template/custom-segment-tag-template.go new file mode 100644 index 00000000..abe6ef49 --- /dev/null +++ b/example/template/custom-segment-tag-template.go @@ -0,0 +1,79 @@ +package template + +import ( + "bytes" + "errors" + + "github.com/grafov/m3u8" +) + +// #CUSTOM-SEGMENT-TAG: + +// CustomSegmentTag implements both CustomTag and CustomDecoder +// interfaces. +type CustomSegmentTag struct { + Name string + Jedi bool +} + +// TagName should return the full indentifier including the leading '#' and trailing ':' +// if the tag also contains a value or attribute list +func (tag *CustomSegmentTag) TagName() string { + return "#CUSTOM-SEGMENT-TAG:" +} + +// Decode decodes the input string to the internal structure. The line +// will be the entire matched line, including the identifier. +func (tag *CustomSegmentTag) Decode(line string) (m3u8.CustomTag, error) { + var err error + + // Since this is a Segment tag, we want to create a new tag every time it is decoded + // as there can be one for each segment with + newTag := new(CustomSegmentTag) + + for k, v := range m3u8.DecodeAttributeList(line[20:]) { + switch k { + case "NAME": + newTag.Name = v + case "JEDI": + if v == "YES" { + newTag.Jedi = true + } else if v == "NO" { + newTag.Jedi = false + } else { + err = errors.New("Valid strings for JEDI attribute are YES and NO.") + } + } + } + + return newTag, err +} + +// SegmentTag is a playlist tag example. +func (tag *CustomSegmentTag) SegmentTag() bool { + return true +} + +// Encode encodes the structure to the text result. +func (tag *CustomSegmentTag) Encode() *bytes.Buffer { + buf := new(bytes.Buffer) + + if tag.Name != "" { + buf.WriteString(tag.TagName()) + buf.WriteString("NAME=\"") + buf.WriteString(tag.Name) + buf.WriteString("\",JEDI=") + if tag.Jedi { + buf.WriteString("YES") + } else { + buf.WriteString("NO") + } + } + + return buf +} + +// String implements Stringer interface. +func (tag *CustomSegmentTag) String() string { + return tag.Encode().String() +} diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..0e2a4148 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/grafov/m3u8 + +go 1.12 diff --git a/reader.go b/reader.go index e8240698..b19324eb 100644 --- a/reader.go +++ b/reader.go @@ -4,7 +4,7 @@ package m3u8 Part of M3U8 parser & generator library. This file defines functions related to playlist parsing. - Copyright 2013-2017 The Project Developers. + Copyright 2013-2019 The Project Developers. See the AUTHORS and LICENSE files at the top-level directory of this distribution and at https://github.com/grafov/m3u8/ @@ -24,9 +24,9 @@ import ( var reKeyValue = regexp.MustCompile(`([a-zA-Z0-9_-]+)=("[^"]+"|[^",]+)`) -// Allow globally apply and/or override Time Parser function. +// TimeParse allows globally apply and/or override Time Parser function. // Available variants: -// * FullTimeParse - implements full featured ISO/IEC 8601:2004 +// * FullTimeParse - implements full featured ISO/IEC 8601:2004 // * StrictTimeParse - implements only RFC3339 Nanoseconds format var TimeParse func(value string) (time.Time, error) = FullTimeParse @@ -48,6 +48,18 @@ func (p *MasterPlaylist) DecodeFrom(reader io.Reader, strict bool) error { return p.decode(buf, strict) } +// WithCustomDecoders adds custom tag decoders to the master playlist for decoding +func (p *MasterPlaylist) WithCustomDecoders(customDecoders []CustomDecoder) Playlist { + // Create the map if it doesn't already exist + if p.Custom == nil { + p.Custom = make(map[string]CustomTag) + } + + p.customDecoders = customDecoders + + return p +} + // Parse master playlist. Internal function. func (p *MasterPlaylist) decode(buf *bytes.Buffer, strict bool) error { var eof bool @@ -90,6 +102,18 @@ func (p *MediaPlaylist) DecodeFrom(reader io.Reader, strict bool) error { return p.decode(buf, strict) } +// WithCustomDecoders adds custom tag decoders to the media playlist for decoding +func (p *MediaPlaylist) WithCustomDecoders(customDecoders []CustomDecoder) Playlist { + // Create the map if it doesn't already exist + if p.Custom == nil { + p.Custom = make(map[string]CustomTag) + } + + p.customDecoders = customDecoders + + return p +} + func (p *MediaPlaylist) decode(buf *bytes.Buffer, strict bool) error { var eof bool var line string @@ -123,7 +147,7 @@ func (p *MediaPlaylist) decode(buf *bytes.Buffer, strict bool) error { // Decode detects type of playlist and decodes it. It accepts bytes // buffer as input. func Decode(data bytes.Buffer, strict bool) (Playlist, ListType, error) { - return decode(&data, strict) + return decode(&data, strict, nil) } // DecodeFrom detects type of playlist and decodes it. It accepts data @@ -134,12 +158,30 @@ func DecodeFrom(reader io.Reader, strict bool) (Playlist, ListType, error) { if err != nil { return nil, 0, err } - return decode(buf, strict) + return decode(buf, strict, nil) +} + +// DecodeWith detects the type of playlist and decodes it. It accepts either bytes.Buffer +// or io.Reader as input. Any custom decoders provided will be used during decoding. +func DecodeWith(input interface{}, strict bool, customDecoders []CustomDecoder) (Playlist, ListType, error) { + switch v := input.(type) { + case bytes.Buffer: + return decode(&v, strict, customDecoders) + case io.Reader: + buf := new(bytes.Buffer) + _, err := buf.ReadFrom(v) + if err != nil { + return nil, 0, err + } + return decode(buf, strict, customDecoders) + default: + return nil, 0, errors.New("input must be bytes.Buffer or io.Reader type") + } } // Detect playlist type and decode it. May be used as decoder for both // master and media playlists. -func decode(buf *bytes.Buffer, strict bool) (Playlist, ListType, error) { +func decode(buf *bytes.Buffer, strict bool, customDecoders []CustomDecoder) (Playlist, ListType, error) { var eof bool var line string var master *MasterPlaylist @@ -156,6 +198,13 @@ func decode(buf *bytes.Buffer, strict bool) (Playlist, ListType, error) { return nil, 0, fmt.Errorf("Create media playlist failed: %s", err) } + // If we have custom tags to parse + if customDecoders != nil { + media = media.WithCustomDecoders(customDecoders).(*MediaPlaylist) + master = master.WithCustomDecoders(customDecoders).(*MasterPlaylist) + state.custom = make(map[string]CustomTag) + } + for !eof { if line, err = buf.ReadString('\n'); err == io.EOF { eof = true @@ -202,6 +251,12 @@ func decode(buf *bytes.Buffer, strict bool) (Playlist, ListType, error) { return nil, state.listType, errors.New("Can't detect playlist type") } +// DecodeAttributeList turns an attribute list into a key, value map. You should trim +// any characters not part of the attribute list, such as the tag and ':'. +func DecodeAttributeList(line string) map[string]string { + return decodeParamsLine(line) +} + func decodeParamsLine(line string) map[string]string { out := make(map[string]string) for _, kv := range reKeyValue.FindAllStringSubmatch(line, -1) { @@ -217,6 +272,21 @@ func decodeLineOfMasterPlaylist(p *MasterPlaylist, state *decodingState, line st line = strings.TrimSpace(line) + // check for custom tags first to allow custom parsing of existing tags + if p.Custom != nil { + for _, v := range p.customDecoders { + if strings.HasPrefix(line, v.TagName()) { + t, err := v.Decode(line) + + if strict && err != nil { + return err + } + + p.Custom[t.TagName()] = t + } + } + } + switch { case line == "#EXTM3U": // start tag first state.m3u = true @@ -369,8 +439,8 @@ func decodeLineOfMasterPlaylist(p *MasterPlaylist, state *decodingState, line st state.variant.HDCPLevel = v } } - case strings.HasPrefix(line, "#"): // unknown tags treated as comments - return err + case strings.HasPrefix(line, "#"): + // comments are ignored } return err } @@ -380,6 +450,27 @@ func decodeLineOfMediaPlaylist(p *MediaPlaylist, wv *WV, state *decodingState, l var err error line = strings.TrimSpace(line) + + // check for custom tags first to allow custom parsing of existing tags + if p.Custom != nil { + for _, v := range p.customDecoders { + if strings.HasPrefix(line, v.TagName()) { + t, err := v.Decode(line) + + if strict && err != nil { + return err + } + + if v.SegmentTag() { + state.tagCustom = true + state.custom[v.TagName()] = t + } else { + p.Custom[v.TagName()] = t + } + } + } + } + switch { case !state.tagInf && strings.HasPrefix(line, "#EXTINF:"): state.tagInf = true @@ -463,6 +554,13 @@ func decodeLineOfMediaPlaylist(p *MediaPlaylist, wv *WV, state *decodingState, l } state.tagMap = false } + + // if segment custom tag appeared before EXTINF then it links to this segment + if state.tagCustom { + p.Segments[p.last()].Custom = state.custom + state.custom = make(map[string]CustomTag) + state.tagCustom = false + } // start tag first case line == "#EXTM3U": state.m3u = true @@ -717,8 +815,8 @@ func decodeLineOfMediaPlaylist(p *MediaPlaylist, wv *WV, state *decodingState, l if err == nil { state.tagWV = true } - case strings.HasPrefix(line, "#"): // unknown tags treated as comments - return err + case strings.HasPrefix(line, "#"): + // comments are ignored } return err } diff --git a/reader_test.go b/reader_test.go index 8fd73e93..8d60b16c 100644 --- a/reader_test.go +++ b/reader_test.go @@ -1,7 +1,7 @@ /* Playlist parsing tests. - Copyright 2013-2017 The Project Developers. + Copyright 2013-2019 The Project Developers. See the AUTHORS and LICENSE files at the top-level directory of this distribution and at https://github.com/grafov/m3u8/ @@ -12,6 +12,7 @@ package m3u8 import ( "bufio" "bytes" + "errors" "fmt" "os" "reflect" @@ -150,7 +151,7 @@ func TestDecodeMediaPlaylistByteRange(t *testing.T) { {URI: "video.ts", Duration: 10, Limit: 69864, SeqId: 2}, } for i, seg := range p.Segments { - if *seg != *expected[i] { + if !reflect.DeepEqual(*seg, *expected[i]) { t.Errorf("exp: %+v\ngot: %+v", expected[i], seg) } } @@ -429,7 +430,7 @@ func TestDecodeMasterPlaylistWithAutodetection(t *testing.T) { mp := m.(*MasterPlaylist) // fmt.Printf(">%+v\n", mp) // for _, v := range mp.Variants { - // fmt.Printf(">>%+v +v\n", v) + // fmt.Printf(">>%+v +v\n", v) // } //fmt.Println("Type below must be MasterPlaylist:") CheckType(t, mp) @@ -594,6 +595,239 @@ func TestDecodeMediaPlaylistWithDiscontinuitySeq(t *testing.T) { } } +func TestDecodeMasterPlaylistWithCustomTags(t *testing.T) { + cases := []struct { + src string + customDecoders []CustomDecoder + expectedError error + expectedPlaylistTags []string + }{ + { + src: "sample-playlists/master-playlist-with-custom-tags.m3u8", + customDecoders: nil, + expectedError: nil, + expectedPlaylistTags: nil, + }, + { + src: "sample-playlists/master-playlist-with-custom-tags.m3u8", + customDecoders: []CustomDecoder{ + &MockCustomTag{ + name: "#CUSTOM-PLAYLIST-TAG:", + err: errors.New("Error decoding tag"), + segment: false, + encodedString: "#CUSTOM-PLAYLIST-TAG:42", + }, + }, + expectedError: errors.New("Error decoding tag"), + expectedPlaylistTags: nil, + }, + { + src: "sample-playlists/master-playlist-with-custom-tags.m3u8", + customDecoders: []CustomDecoder{ + &MockCustomTag{ + name: "#CUSTOM-PLAYLIST-TAG:", + err: nil, + segment: false, + encodedString: "#CUSTOM-PLAYLIST-TAG:42", + }, + }, + expectedError: nil, + expectedPlaylistTags: []string{ + "#CUSTOM-PLAYLIST-TAG:", + }, + }, + } + + for _, testCase := range cases { + f, err := os.Open(testCase.src) + + if err != nil { + t.Fatal(err) + } + + p, listType, err := DecodeWith(bufio.NewReader(f), true, testCase.customDecoders) + + if !reflect.DeepEqual(err, testCase.expectedError) { + t.Fatal(err) + } + + if testCase.expectedError != nil { + // No need to make other assertions if we were expecting an error + continue + } + + pp := p.(*MasterPlaylist) + + CheckType(t, pp) + + if listType != MASTER { + t.Error("Sample not recognized as master playlist.") + } + + if len(pp.Custom) != len(testCase.expectedPlaylistTags) { + t.Errorf("Did not parse expected number of custom tags. Got: %d Expected: %d", len(pp.Custom), len(testCase.expectedPlaylistTags)) + } else { + // we have the same count, lets confirm its the right tags + for _, expectedTag := range testCase.expectedPlaylistTags { + if _, ok := pp.Custom[expectedTag]; !ok { + t.Errorf("Did not parse custom tag %s", expectedTag) + } + } + } + } +} + +func TestDecodeMediaPlaylistWithCustomTags(t *testing.T) { + cases := []struct { + src string + customDecoders []CustomDecoder + expectedError error + expectedPlaylistTags []string + expectedSegmentTags []*struct { + index int + names []string + } + }{ + { + src: "sample-playlists/media-playlist-with-custom-tags.m3u8", + customDecoders: nil, + expectedError: nil, + expectedPlaylistTags: nil, + expectedSegmentTags: nil, + }, + { + src: "sample-playlists/media-playlist-with-custom-tags.m3u8", + customDecoders: []CustomDecoder{ + &MockCustomTag{ + name: "#CUSTOM-PLAYLIST-TAG:", + err: errors.New("Error decoding tag"), + segment: false, + encodedString: "#CUSTOM-PLAYLIST-TAG:42", + }, + }, + expectedError: errors.New("Error decoding tag"), + expectedPlaylistTags: nil, + expectedSegmentTags: nil, + }, + { + src: "sample-playlists/media-playlist-with-custom-tags.m3u8", + customDecoders: []CustomDecoder{ + &MockCustomTag{ + name: "#CUSTOM-PLAYLIST-TAG:", + err: nil, + segment: false, + encodedString: "#CUSTOM-PLAYLIST-TAG:42", + }, + &MockCustomTag{ + name: "#CUSTOM-SEGMENT-TAG:", + err: nil, + segment: true, + encodedString: "#CUSTOM-SEGMENT-TAG:NAME=\"Yoda\",JEDI=YES", + }, + &MockCustomTag{ + name: "#CUSTOM-SEGMENT-TAG-B", + err: nil, + segment: true, + encodedString: "#CUSTOM-SEGMENT-TAG-B", + }, + }, + expectedError: nil, + expectedPlaylistTags: []string{ + "#CUSTOM-PLAYLIST-TAG:", + }, + expectedSegmentTags: []*struct { + index int + names []string + }{ + {1, []string{"#CUSTOM-SEGMENT-TAG:"}}, + {2, []string{"#CUSTOM-SEGMENT-TAG:", "#CUSTOM-SEGMENT-TAG-B"}}, + }, + }, + } + + for _, testCase := range cases { + f, err := os.Open(testCase.src) + + if err != nil { + t.Fatal(err) + } + + p, listType, err := DecodeWith(bufio.NewReader(f), true, testCase.customDecoders) + + if !reflect.DeepEqual(err, testCase.expectedError) { + t.Fatal(err) + } + + if testCase.expectedError != nil { + // No need to make other assertions if we were expecting an error + continue + } + + pp := p.(*MediaPlaylist) + + CheckType(t, pp) + + if listType != MEDIA { + t.Error("Sample not recognized as master playlist.") + } + + if len(pp.Custom) != len(testCase.expectedPlaylistTags) { + t.Errorf("Did not parse expected number of custom tags. Got: %d Expected: %d", len(pp.Custom), len(testCase.expectedPlaylistTags)) + } else { + // we have the same count, lets confirm its the right tags + for _, expectedTag := range testCase.expectedPlaylistTags { + if _, ok := pp.Custom[expectedTag]; !ok { + t.Errorf("Did not parse custom tag %s", expectedTag) + } + } + } + + var expectedSegmentTag *struct { + index int + names []string + } + + expectedIndex := 0 + + for i := 0; i < int(pp.Count()); i++ { + seg := pp.Segments[i] + if expectedIndex != len(testCase.expectedSegmentTags) { + expectedSegmentTag = testCase.expectedSegmentTags[expectedIndex] + } else { + // we are at the end of the expectedSegmentTags list, the rest of the segments + // should have no custom tags + expectedSegmentTag = nil + } + + if expectedSegmentTag == nil || expectedSegmentTag.index != i { + if len(seg.Custom) != 0 { + t.Errorf("Did not parse expected number of custom tags on Segment %d. Got: %d Expected: %d", i, len(seg.Custom), 0) + } + continue + } + + // We are now checking the segment corresponding to exepectedSegmentTag + // increase our expectedIndex for next iteration + expectedIndex++ + + if len(expectedSegmentTag.names) != len(seg.Custom) { + t.Errorf("Did not parse expected number of custom tags on Segment %d. Got: %d Expected: %d", i, len(seg.Custom), len(expectedSegmentTag.names)) + } else { + // we have the same count, lets confirm its the right tags + for _, expectedTag := range expectedSegmentTag.names { + if _, ok := seg.Custom[expectedTag]; !ok { + t.Errorf("Did not parse customTag %s on Segment %d", expectedTag, i) + } + } + } + } + + if expectedIndex != len(testCase.expectedSegmentTags) { + t.Errorf("Did not parse custom tags on all expected segments. Parsed Segments: %d Expected: %d", expectedIndex, len(testCase.expectedSegmentTags)) + } + } +} + /*************************** * Code parsing examples * ***************************/ @@ -623,7 +857,7 @@ func ExampleMediaPlaylist_DurationAsInt() { } func TestMediaPlaylistWithSCTE35Tag(t *testing.T) { - test_cases := []struct { + cases := []struct { playlistLocation string expectedSCTEIndex int expectedSCTECue string @@ -645,7 +879,7 @@ func TestMediaPlaylistWithSCTE35Tag(t *testing.T) { 0, }, } - for _, c := range test_cases { + for _, c := range cases { f, _ := os.Open(c.playlistLocation) playlist, _, _ := DecodeFrom(bufio.NewReader(f), true) mediaPlaylist := playlist.(*MediaPlaylist) diff --git a/sample-playlists/master-playlist-with-custom-tags.m3u8 b/sample-playlists/master-playlist-with-custom-tags.m3u8 new file mode 100644 index 00000000..e31416a3 --- /dev/null +++ b/sample-playlists/master-playlist-with-custom-tags.m3u8 @@ -0,0 +1,14 @@ +#EXTM3U +#EXT-X-VERSION:3 +#JUST A COMMENT +#CUSTOM-PLAYLIST-TAG:42 +#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 diff --git a/sample-playlists/media-playlist-with-custom-tags.m3u8 b/sample-playlists/media-playlist-with-custom-tags.m3u8 new file mode 100644 index 00000000..66c963b0 --- /dev/null +++ b/sample-playlists/media-playlist-with-custom-tags.m3u8 @@ -0,0 +1,18 @@ +#EXTM3U +#EXT-X-TARGETDURATION:10 +#CUSTOM-PLAYLIST-TAG:42 +#EXT-X-VERSION:3 +#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 diff --git a/structure.go b/structure.go index 1cb1bb85..eb5d01a2 100644 --- a/structure.go +++ b/structure.go @@ -31,10 +31,15 @@ const ( o The EXT-X-MEDIA tag. o The AUDIO and VIDEO attributes of the EXT-X-STREAM-INF tag. */ - minver = uint8(3) - DATETIME = time.RFC3339Nano // Format for EXT-X-PROGRAM-DATE-TIME defined in section 3.4.5 + minver = uint8(3) + + // DATETIME represents format of the timestamps in encoded + // playlists. Format for EXT-X-PROGRAM-DATE-TIME defined in + // section 3.4.5 + DATETIME = time.RFC3339Nano ) +// ListType is type of the playlist. type ListType uint const ( @@ -43,7 +48,7 @@ const ( MEDIA ) -// for EXT-X-PLAYLIST-TYPE tag +// MediaType is the type for EXT-X-PLAYLIST-TYPE tag type MediaType uint const ( @@ -73,34 +78,33 @@ const ( SCTE35Cue_End // SCTE35Cue_End indicates an in cue point ) -/* - This structure represents a single bitrate playlist aka media playlist. - It related to both a simple media playlists and a sliding window media playlists. - URI lines in the Playlist point to media segments. - - Simple Media Playlist file sample: - - #EXTM3U - #EXT-X-VERSION:3 - #EXT-X-TARGETDURATION:5220 - #EXTINF:5219.2, - http://media.example.com/entire.ts - #EXT-X-ENDLIST - - Sample of Sliding Window Media Playlist, using HTTPS: - - #EXTM3U - #EXT-X-VERSION:3 - #EXT-X-TARGETDURATION:8 - #EXT-X-MEDIA-SEQUENCE:2680 - - #EXTINF:7.975, - https://priv.example.com/fileSequence2680.ts - #EXTINF:7.941, - https://priv.example.com/fileSequence2681.ts - #EXTINF:7.975, - https://priv.example.com/fileSequence2682.ts -*/ +// MediaPlaylist structure represents a single bitrate playlist aka +// media playlist. It related to both a simple media playlists and a +// sliding window media playlists. URI lines in the Playlist point to +// media segments. +// +// Simple Media Playlist file sample: +// +// #EXTM3U +// #EXT-X-VERSION:3 +// #EXT-X-TARGETDURATION:5220 +// #EXTINF:5219.2, +// http://media.example.com/entire.ts +// #EXT-X-ENDLIST +// +// Sample of Sliding Window Media Playlist, using HTTPS: +// +// #EXTM3U +// #EXT-X-VERSION:3 +// #EXT-X-TARGETDURATION:8 +// #EXT-X-MEDIA-SEQUENCE:2680 +// +// #EXTINF:7.975, +// https://priv.example.com/fileSequence2680.ts +// #EXTINF:7.941, +// https://priv.example.com/fileSequence2681.ts +// #EXTINF:7.975, +// https://priv.example.com/fileSequence2682.ts type MediaPlaylist struct { TargetDuration float64 SeqNo uint64 // EXT-X-MEDIA-SEQUENCE @@ -124,23 +128,23 @@ type MediaPlaylist struct { Key *Key // EXT-X-KEY is optional encryption key displayed before any segments (default key for the playlist) Map *Map // EXT-X-MAP is optional tag specifies how to obtain the Media Initialization Section (default map for the playlist) WV *WV // Widevine related tags outside of M3U8 specs + Custom map[string]CustomTag + customDecoders []CustomDecoder } -/* - This structure represents a master playlist which combines media playlists for multiple bitrates. - URI lines in the playlist identify media playlists. - Sample of Master Playlist file: - - #EXTM3U - #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1280000 - http://example.com/low.m3u8 - #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=2560000 - http://example.com/mid.m3u8 - #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=7680000 - http://example.com/hi.m3u8 - #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=65000,CODECS="mp4a.40.5" - http://example.com/audio-only.m3u8 -*/ +// MasterPlaylist structure represents a master playlist which +// combines media playlists for multiple bitrates. URI lines in the +// playlist identify media playlists. Sample of Master Playlist file: +// +// #EXTM3U +// #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1280000 +// http://example.com/low.m3u8 +// #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=2560000 +// http://example.com/mid.m3u8 +// #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=7680000 +// http://example.com/hi.m3u8 +// #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=65000,CODECS="mp4a.40.5" +// http://example.com/audio-only.m3u8 type MasterPlaylist struct { Variants []*Variant Args string // optional arguments placed after URI (URI?Args) @@ -148,18 +152,21 @@ type MasterPlaylist struct { buf bytes.Buffer ver uint8 independentSegments bool + Custom map[string]CustomTag + customDecoders []CustomDecoder } -// This structure represents variants for master playlist. -// Variants included in a master playlist and point to media playlists. +// Variant structure represents variants for master playlist. +// Variants included in a master playlist and point to media +// playlists. type Variant struct { URI string Chunklist *MediaPlaylist VariantParams } -// This structure represents additional parameters for a variant -// used in EXT-X-STREAM-INF and EXT-X-I-FRAME-STREAM-INF +// VariantParams structure represents additional parameters for a +// variant used in EXT-X-STREAM-INF and EXT-X-I-FRAME-STREAM-INF type VariantParams struct { ProgramId uint32 Bandwidth uint32 @@ -178,7 +185,7 @@ type VariantParams struct { Alternatives []*Alternative // EXT-X-MEDIA } -// This structure represents EXT-X-MEDIA tag in variants. +// Alternative structure represents EXT-X-MEDIA tag in variants. type Alternative struct { GroupId string URI string @@ -192,9 +199,9 @@ type Alternative struct { Subtitles string } -// This structure represents a media segment included in a media playlist. -// Media segment may be encrypted. -// Widevine supports own tags for encryption metadata. +// MediaSegment structure represents a media segment included in a +// media playlist. Media segment may be encrypted. Widevine supports +// own tags for encryption metadata. type MediaSegment struct { SeqId uint64 Title string // optional second parameter for EXTINF tag @@ -207,6 +214,7 @@ type MediaSegment struct { Discontinuity bool // EXT-X-DISCONTINUITY indicates an encoding discontinuity between the media segment that follows it and the one that preceded it (i.e. file format, number and type of tracks, encoding parameters, encoding sequence, timestamp sequence) SCTE *SCTE // SCTE-35 used for Ad signaling in HLS ProgramDateTime time.Time // EXT-X-PROGRAM-DATE-TIME tag associates the first sample of a media segment with an absolute date and/or time + Custom map[string]CustomTag } // SCTE holds custom, non EXT-X-DATERANGE, SCTE-35 tags @@ -219,7 +227,7 @@ type SCTE struct { Elapsed float64 } -// This structure represents information about stream encryption. +// Key structure represents information about stream encryption. // // Realizes EXT-X-KEY tag. type Key struct { @@ -230,11 +238,11 @@ type Key struct { Keyformatversions string } -// This structure represents specifies how to obtain the Media +// Map structure represents specifies how to obtain the Media // Initialization Section required to parse the applicable // Media Segments. - -// It applies to every Media Segment that appears after it in the +// +// It applied to every Media Segment that appears after it in the // Playlist until the next EXT-X-MAP tag or until the end of the // playlist. // @@ -245,7 +253,7 @@ type Map struct { Offset int64 // [@o] is offset from the start of the file under URI } -// This structure represents metadata for Google Widevine playlists. +// WV structure represents metadata for Google Widevine playlists. // This format not described in IETF draft but provied by Widevine Live Packager as // additional tags with #WV-prefix. type WV struct { @@ -264,11 +272,38 @@ type WV struct { VideoSAR string } -// Interface applied to various playlist types. +// Playlist interface applied to various playlist types. type Playlist interface { Encode() *bytes.Buffer Decode(bytes.Buffer, bool) error DecodeFrom(reader io.Reader, strict bool) error + WithCustomDecoders([]CustomDecoder) Playlist + String() string +} + +// CustomDecoder interface for decoding custom and unsupported tags +type CustomDecoder interface { + // TagName should return the full indentifier including the leading '#' as well as the + // trailing ':' if the tag also contains a value or attribute list + TagName() string + // Decode parses a line from the playlist and returns the CustomTag representation + Decode(line string) (CustomTag, error) + // SegmentTag should return true if this CustomDecoder should apply per segment. + // Should returns false if it a MediaPlaylist header tag. + // This value is ignored for MasterPlaylists. + SegmentTag() bool +} + +// CustomTag interface for encoding custom and unsupported tags +type CustomTag interface { + // TagName should return the full indentifier including the leading '#' as well as the + // trailing ':' if the tag also contains a value or attribute list + TagName() string + // Encode should return the complete tag string as a *bytes.Buffer. This will + // be used by Playlist.Decode to write the tag to the m3u8. + // Return nil to not write anything to the m3u8. + Encode() *bytes.Buffer + // String should return the encoded tag as a string. String() string } @@ -285,6 +320,7 @@ type decodingState struct { tagProgramDateTime bool tagKey bool tagMap bool + tagCustom bool programDateTime time.Time limit int64 offset int64 @@ -295,4 +331,5 @@ type decodingState struct { xkey *Key xmap *Map scte *SCTE + custom map[string]CustomTag } diff --git a/structure_test.go b/structure_test.go index 31b99d8f..4ab7d36a 100644 --- a/structure_test.go +++ b/structure_test.go @@ -10,6 +10,7 @@ package m3u8 import ( + "bytes" "testing" ) @@ -24,3 +25,38 @@ func TestNewMediaPlaylist(t *testing.T) { t.Fatalf("Create media playlist failed: %s", e) } } + +type MockCustomTag struct { + name string + err error + segment bool + encodedString string +} + +func (t *MockCustomTag) TagName() string { + return t.name +} + +func (t *MockCustomTag) Decode(line string) (CustomTag, error) { + return t, t.err +} + +func (t *MockCustomTag) Encode() *bytes.Buffer { + if t.encodedString == "" { + return nil + } + + buf := new(bytes.Buffer) + + buf.WriteString(t.encodedString) + + return buf +} + +func (t *MockCustomTag) String() string { + return t.encodedString +} + +func (t *MockCustomTag) SegmentTag() bool { + return t.segment +} diff --git a/writer.go b/writer.go index 3273035b..746130d7 100644 --- a/writer.go +++ b/writer.go @@ -4,7 +4,7 @@ package m3u8 Part of M3U8 parser & generator library. This file defines functions related to playlist generation. - Copyright 2013-2017 The Project Developers. + Copyright 2013-2019 The Project Developers. See the AUTHORS and LICENSE files at the top-level directory of this distribution and at https://github.com/grafov/m3u8/ @@ -21,9 +21,8 @@ import ( "time" ) -var ( - ErrPlaylistFull = errors.New("playlist is full") -) +// ErrPlaylistFull declares the playlist error. +var ErrPlaylistFull = errors.New("playlist is full") // Set version of the playlist accordingly with section 7 func version(ver *uint8, newver uint8) { @@ -36,16 +35,16 @@ func strver(ver uint8) string { return strconv.FormatUint(uint64(ver), 10) } -// Create new empty master playlist. -// Master playlist consists of variants. +// NewMasterPlaylist creates a new empty master playlist. Master +// playlist consists of variants. func NewMasterPlaylist() *MasterPlaylist { p := new(MasterPlaylist) p.ver = minver return p } -// Append variant to master playlist. -// This operation does reset playlist cache. +// Append appends a variant to master playlist. This operation does +// reset playlist cache. func (p *MasterPlaylist) Append(uri string, chunklist *MediaPlaylist, params VariantParams) { v := new(Variant) v.URI = uri @@ -65,11 +64,12 @@ func (p *MasterPlaylist) Append(uri string, chunklist *MediaPlaylist, params Var p.buf.Reset() } +// ResetCache resetes the playlist' cache. func (p *MasterPlaylist) ResetCache() { p.buf.Reset() } -// Generate output in M3U8 format. +// Encode generates the output in M3U8 format. func (p *MasterPlaylist) Encode() *bytes.Buffer { if p.buf.Len() > 0 { return &p.buf @@ -83,7 +83,17 @@ func (p *MasterPlaylist) Encode() *bytes.Buffer { p.buf.WriteString("#EXT-X-INDEPENDENT-SEGMENTS\n") } - var altsWritten map[string]bool = make(map[string]bool) + // Write any custom master tags + if p.Custom != nil { + for _, v := range p.Custom { + if customBuf := v.Encode(); customBuf != nil { + p.buf.WriteString(customBuf.String()) + p.buf.WriteRune('\n') + } + } + } + + var altsWritten = make(map[string]bool) for _, pl := range p.Variants { if pl.Alternatives != nil { @@ -263,6 +273,15 @@ func (p *MasterPlaylist) Encode() *bytes.Buffer { return &p.buf } +// SetCustomTag sets the provided tag on the master playlist for its TagName +func (p *MasterPlaylist) SetCustomTag(tag CustomTag) { + if p.Custom == nil { + p.Custom = make(map[string]CustomTag) + } + + p.Custom[tag.TagName()] = tag +} + // Version returns the current playlist version number func (p *MasterPlaylist) Version() uint8 { return p.ver @@ -286,15 +305,15 @@ func (p *MasterPlaylist) SetIndependentSegments(b bool) { p.independentSegments = b } -// For compatibility with Stringer interface -// For example fmt.Printf("%s", sampleMediaList) will encode -// playist and print its string representation. +// String here for compatibility with Stringer interface. For example +// fmt.Printf("%s", sampleMediaList) will encode playist and print its +// string representation. func (p *MasterPlaylist) String() string { return p.Encode().String() } -// Creates new media playlist structure. -// Winsize defines how much items will displayed on playlist generation. +// NewMediaPlaylist creates a new media playlist structure. Winsize +// defines how much items will displayed on playlist generation. // Capacity is total size of a playlist. func NewMediaPlaylist(winsize uint, capacity uint) (*MediaPlaylist, error) { p := new(MediaPlaylist) @@ -340,8 +359,8 @@ func (p *MediaPlaylist) Append(uri string, duration float64, title string) error return p.AppendSegment(seg) } -// AppendSegment appends a MediaSegment to the tail of chunk slice for a media playlist. -// This operation does reset playlist cache. +// AppendSegment appends a MediaSegment to the tail of chunk slice for +// a media playlist. This operation does reset playlist cache. func (p *MediaPlaylist) AppendSegment(seg *MediaSegment) error { if p.head == p.tail && p.count > 0 { return ErrPlaylistFull @@ -360,9 +379,10 @@ func (p *MediaPlaylist) AppendSegment(seg *MediaSegment) error { return nil } -// Combines two operations: firstly 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 playlists. -// This operation does reset cache. +// Slide combines two operations: firstly 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 +// playlists. This operation does reset cache. func (p *MediaPlaylist) Slide(uri string, duration float64, title string) { if !p.Closed && p.count >= p.winsize { p.Remove() @@ -370,12 +390,14 @@ func (p *MediaPlaylist) Slide(uri string, duration float64, title string) { p.Append(uri, duration, title) } -// Reset playlist cache. Next called Encode() will regenerate playlist from the chunk slice. +// ResetCache resets playlist cache. Next called Encode() will +// regenerate playlist from the chunk slice. func (p *MediaPlaylist) ResetCache() { p.buf.Reset() } -// Generate output in M3U8 format. Marshal `winsize` elements from bottom of the `segments` queue. +// Encode generates output in M3U8 format. Marshal `winsize` elements +// from bottom of the `segments` queue. func (p *MediaPlaylist) Encode() *bytes.Buffer { if p.buf.Len() > 0 { return &p.buf @@ -384,6 +406,17 @@ func (p *MediaPlaylist) Encode() *bytes.Buffer { p.buf.WriteString("#EXTM3U\n#EXT-X-VERSION:") p.buf.WriteString(strver(p.ver)) p.buf.WriteRune('\n') + + // Write any custom master tags + if p.Custom != nil { + for _, v := range p.Custom { + if customBuf := v.Encode(); customBuf != nil { + p.buf.WriteString(customBuf.String()) + p.buf.WriteRune('\n') + } + } + } + // default key (workaround for Widevine) if p.Key != nil { p.buf.WriteString("#EXT-X-KEY:") @@ -650,6 +683,17 @@ func (p *MediaPlaylist) Encode() *bytes.Buffer { p.buf.WriteString(strconv.FormatInt(seg.Offset, 10)) p.buf.WriteRune('\n') } + + // Add Custom Segment Tags here + if seg.Custom != nil { + for _, v := range seg.Custom { + if customBuf := v.Encode(); customBuf != nil { + p.buf.WriteString(customBuf.String()) + p.buf.WriteRune('\n') + } + } + } + p.buf.WriteString("#EXTINF:") if str, ok := durationCache[seg.Duration]; ok { p.buf.WriteString(str) @@ -679,14 +723,14 @@ func (p *MediaPlaylist) Encode() *bytes.Buffer { return &p.buf } -// For compatibility with Stringer interface -// For example fmt.Printf("%s", sampleMediaList) will encode -// playist and print its string representation. +// String here for compatibility with Stringer interface For example +// fmt.Printf("%s", sampleMediaList) will encode playist and print its +// string representation. func (p *MediaPlaylist) String() string { return p.Encode().String() } -// TargetDuration will be int on Encode +// DurationAsInt represents the duration as the integer in encoded playlist. func (p *MediaPlaylist) DurationAsInt(yes bool) { if yes { // duration must be integers if protocol version is less than 3 @@ -695,7 +739,8 @@ func (p *MediaPlaylist) DurationAsInt(yes bool) { p.durationAsInt = yes } -// Count tells us the number of items that are currently in the media playlist +// Count tells us the number of items that are currently in the media +// playlist. func (p *MediaPlaylist) Count() uint { return p.count } @@ -708,9 +753,9 @@ func (p *MediaPlaylist) Close() { p.Closed = true } -// Set encryption key appeared once in header of the playlist (pointer to MediaPlaylist.Key). -// It useful when keys not changed during playback. -// Set tag for the whole list. +// SetDefaultKey sets encryption key appeared once in header of the +// playlist (pointer to MediaPlaylist.Key). It useful when keys not +// changed during playback. Set tag for the whole list. func (p *MediaPlaylist) SetDefaultKey(method, uri, iv, keyformat, keyformatversions string) error { // A Media Playlist MUST indicate a EXT-X-VERSION of 5 or higher if it // contains: @@ -723,21 +768,23 @@ func (p *MediaPlaylist) SetDefaultKey(method, uri, iv, keyformat, keyformatversi return nil } -// Set default Media Initialization Section values for playlist (pointer to MediaPlaylist.Map). -// Set EXT-X-MAP tag for the whole playlist. +// SetDefaultMap sets default Media Initialization Section values for +// playlist (pointer to MediaPlaylist.Map). Set EXT-X-MAP tag for the +// whole playlist. func (p *MediaPlaylist) SetDefaultMap(uri string, limit, offset int64) { version(&p.ver, 5) // due section 4 p.Map = &Map{uri, limit, offset} } -// Mark medialist as consists of only I-frames (Intra frames). -// Set tag for the whole list. +// SetIframeOnly marks medialist as consists of only I-frames (Intra +// frames). Set tag for the whole list. func (p *MediaPlaylist) SetIframeOnly() { version(&p.ver, 4) // due section 4.3.3 p.Iframe = true } -// Set encryption key for the current segment of media playlist (pointer to Segment.Key) +// SetKey sets encryption key for the current segment of media playlist +// (pointer to Segment.Key). func (p *MediaPlaylist) SetKey(method, uri, iv, keyformat, keyformatversions string) error { if p.count == 0 { return errors.New("playlist is empty") @@ -754,7 +801,8 @@ func (p *MediaPlaylist) SetKey(method, uri, iv, keyformat, keyformatversions str return nil } -// Set map for the current segment of media playlist (pointer to Segment.Map) +// SetMap sets map for the current segment of media playlist (pointer +// to Segment.Map). func (p *MediaPlaylist) SetMap(uri string, limit, offset int64) error { if p.count == 0 { return errors.New("playlist is empty") @@ -764,7 +812,8 @@ func (p *MediaPlaylist) SetMap(uri string, limit, offset int64) error { return nil } -// Set limit and offset for the current media segment (EXT-X-BYTERANGE support for protocol version 4). +// SetRange sets limit and offset for the current media segment +// (EXT-X-BYTERANGE support for protocol version 4). func (p *MediaPlaylist) SetRange(limit, offset int64) error { if p.count == 0 { return errors.New("playlist is empty") @@ -791,10 +840,11 @@ func (p *MediaPlaylist) SetSCTE35(scte35 *SCTE) error { return nil } -// Set discontinuity flag for the current media segment. -// EXT-X-DISCONTINUITY indicates an encoding discontinuity between the media segment -// that follows it and the one that preceded it (i.e. file format, number and type of tracks, -// encoding parameters, encoding sequence, timestamp sequence). +// SetDiscontinuity sets discontinuity flag for the current media +// segment. EXT-X-DISCONTINUITY indicates an encoding discontinuity +// between the media segment that follows it and the one that preceded +// it (i.e. file format, number and type of tracks, encoding +// parameters, encoding sequence, timestamp sequence). func (p *MediaPlaylist) SetDiscontinuity() error { if p.count == 0 { return errors.New("playlist is empty") @@ -803,11 +853,11 @@ func (p *MediaPlaylist) SetDiscontinuity() error { return nil } -// Set program date and time for the current media segment. -// EXT-X-PROGRAM-DATE-TIME tag associates the first sample of a -// media segment with an absolute date and/or time. It applies only -// to the current media segment. -// Date/time format is YYYY-MM-DDThh:mm:ssZ (ISO8601) and includes time zone. +// SetProgramDateTime sets program date and time for the current media +// segment. EXT-X-PROGRAM-DATE-TIME tag associates the first sample of +// a media segment with an absolute date and/or time. It applies only +// to the current media segment. Date/time format is +// YYYY-MM-DDThh:mm:ssZ (ISO8601) and includes time zone. func (p *MediaPlaylist) SetProgramDateTime(value time.Time) error { if p.count == 0 { return errors.New("playlist is empty") @@ -816,6 +866,34 @@ func (p *MediaPlaylist) SetProgramDateTime(value time.Time) error { return nil } +// SetCustomTag sets the provided tag on the media playlist for its +// TagName. +func (p *MediaPlaylist) SetCustomTag(tag CustomTag) { + if p.Custom == nil { + p.Custom = make(map[string]CustomTag) + } + + p.Custom[tag.TagName()] = tag +} + +// SetCustomSegmentTag sets the provided tag on the current media +// segment for its TagName. +func (p *MediaPlaylist) SetCustomSegmentTag(tag CustomTag) error { + if p.count == 0 { + return errors.New("playlist is empty") + } + + last := p.Segments[p.last()] + + if last.Custom == nil { + last.Custom = make(map[string]CustomTag) + } + + last.Custom[tag.TagName()] = tag + + return nil +} + // Version returns the current playlist version number func (p *MediaPlaylist) Version() uint8 { return p.ver diff --git a/writer_test.go b/writer_test.go index 420ca96d..2057ac27 100644 --- a/writer_test.go +++ b/writer_test.go @@ -1,7 +1,7 @@ /* Package m3u8. Playlist generation tests. - Copyright 2013-2017 The Project Developers. + Copyright 2013-2019 The Project Developers. See the AUTHORS and LICENSE files at the top-level directory of this distribution and at https://github.com/grafov/m3u8/ @@ -460,6 +460,65 @@ func TestEncodeMediaPlaylistWithDiscontinuityAndDefaultMapWithAppendSegment(t *t } +// Create new media playlist +// Add custom playlist tag +// Add segment with custom tag +func TestEncodeMediaPlaylistWithCustomTags(t *testing.T) { + p, e := NewMediaPlaylist(1, 1) + if e != nil { + t.Fatalf("Create media playlist failed: %s", e) + } + + customPTag := &MockCustomTag{ + name: "#CustomPTag", + encodedString: "#CustomPTag", + } + p.SetCustomTag(customPTag) + + customEmptyPTag := &MockCustomTag{ + name: "#CustomEmptyPTag", + encodedString: "", + } + p.SetCustomTag(customEmptyPTag) + + e = p.Append("test01.ts", 5.0, "") + if e != nil { + t.Fatalf("Add 1st segment to a media playlist failed: %s", e) + } + + customSTag := &MockCustomTag{ + name: "#CustomSTag", + encodedString: "#CustomSTag", + } + e = p.SetCustomSegmentTag(customSTag) + if e != nil { + t.Fatalf("Set CustomTag to segment failed: %s", e) + } + + customEmptySTag := &MockCustomTag{ + name: "#CustomEmptySTag", + encodedString: "", + } + e = p.SetCustomSegmentTag(customEmptySTag) + if e != nil { + t.Fatalf("Set CustomTag to segment failed: %s", e) + } + + encoded := p.String() + expectedStrings := []string{"#CustomPTag", "#CustomSTag"} + for _, expected := range expectedStrings { + if !strings.Contains(encoded, expected) { + t.Fatalf("Media playlist does not contain custom tag: %s\nMedia Playlist:\n%v", expected, encoded) + } + } + unexpectedStrings := []string{"#CustomEmptyPTag", "#CustomEmptySTag"} + for _, unexpected := range unexpectedStrings { + if strings.Contains(encoded, unexpected) { + t.Fatalf("Media playlist contains unexpected custom tag: %s\nMedia Playlist:\n%v", unexpected, encoded) + } + } +} + // Create new media playlist // Add two segments to media playlist // Encode structures to HLS @@ -937,6 +996,22 @@ func TestEncodeMasterPlaylistWithStreamInfName(t *testing.T) { } } +func TestEncodeMasterPlaylistWithCustomTags(t *testing.T) { + m := NewMasterPlaylist() + customMTag := &MockCustomTag{ + name: "#CustomMTag", + encodedString: "#CustomMTag", + } + m.SetCustomTag(customMTag) + + encoded := m.String() + expected := "#CustomMTag" + + if !strings.Contains(encoded, expected) { + t.Fatalf("Master playlist does not contain cusomt tag: %s\n Master Playlist:\n%v", expected, encoded) + } +} + func TestMasterVersion(t *testing.T) { m := NewMasterPlaylist() m.ver = 5 From d3f557ee3777314a54101805c2840e5cec597692 Mon Sep 17 00:00:00 2001 From: hikeh Date: Tue, 30 Nov 2021 16:10:33 +0000 Subject: [PATCH 07/26] add id to m3u8 key --- reader.go | 2 +- structure.go | 1 + writer.go | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/reader.go b/reader.go index 2ca68be2..56271f17 100644 --- a/reader.go +++ b/reader.go @@ -538,7 +538,7 @@ func decodeLineOfMediaPlaylist(p *MediaPlaylist, wv *WV, state *decodingState, l } // If EXT-X-KEY appeared before reference to segment (EXTINF) then it linked to this segment if state.tagKey { - p.Segments[p.last()].Key = &Key{state.xkey.Method, state.xkey.URI, state.xkey.IV, state.xkey.Keyformat, state.xkey.Keyformatversions} + p.Segments[p.last()].Key = &Key{Method: state.xkey.Method, URI: state.xkey.URI, IV: state.xkey.IV, Keyformat: state.xkey.Keyformat, Keyformatversions: state.xkey.Keyformatversions} // First EXT-X-KEY may appeared in the header of the playlist and linked to first segment // but for convenient playlist generation it also linked as default playlist key if p.Key == nil { diff --git a/structure.go b/structure.go index 4ae31a46..2d0a80f7 100644 --- a/structure.go +++ b/structure.go @@ -237,6 +237,7 @@ type Key struct { IV string Keyformat string Keyformatversions string + ID string } // Map structure represents specifies how to obtain the Media diff --git a/writer.go b/writer.go index 407e0828..cd64b3f8 100644 --- a/writer.go +++ b/writer.go @@ -768,7 +768,7 @@ func (p *MediaPlaylist) SetDefaultKey(method, uri, iv, keyformat, keyformatversi if keyformat != "" || keyformatversions != "" { version(&p.ver, 5) } - p.Key = &Key{method, uri, iv, keyformat, keyformatversions} + p.Key = &Key{Method: method, URI: uri, IV: iv, Keyformat: keyformat, Keyformatversions: keyformatversions} return nil } @@ -802,7 +802,7 @@ func (p *MediaPlaylist) SetKey(method, uri, iv, keyformat, keyformatversions str version(&p.ver, 5) } - p.Segments[p.last()].Key = &Key{method, uri, iv, keyformat, keyformatversions} + p.Segments[p.last()].Key = &Key{Method: method, URI: uri, IV: iv, Keyformat: keyformat, Keyformatversions: keyformatversions} return nil } From adb506af588b72c7fe2a3832cb5d4268ad8900e3 Mon Sep 17 00:00:00 2001 From: hikeh Date: Tue, 30 Nov 2021 17:22:38 +0000 Subject: [PATCH 08/26] add id to m3u8 key usages --- reader.go | 2 +- writer.go | 8 ++++---- writer_test.go | 10 +++++----- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/reader.go b/reader.go index 56271f17..c0ddf256 100644 --- a/reader.go +++ b/reader.go @@ -538,7 +538,7 @@ func decodeLineOfMediaPlaylist(p *MediaPlaylist, wv *WV, state *decodingState, l } // If EXT-X-KEY appeared before reference to segment (EXTINF) then it linked to this segment if state.tagKey { - p.Segments[p.last()].Key = &Key{Method: state.xkey.Method, URI: state.xkey.URI, IV: state.xkey.IV, Keyformat: state.xkey.Keyformat, Keyformatversions: state.xkey.Keyformatversions} + p.Segments[p.last()].Key = &Key{Method: state.xkey.Method, URI: state.xkey.URI, IV: state.xkey.IV, Keyformat: state.xkey.Keyformat, Keyformatversions: state.xkey.Keyformatversions, ID: state.xkey.ID} // First EXT-X-KEY may appeared in the header of the playlist and linked to first segment // but for convenient playlist generation it also linked as default playlist key if p.Key == nil { diff --git a/writer.go b/writer.go index cd64b3f8..c99a8adb 100644 --- a/writer.go +++ b/writer.go @@ -761,14 +761,14 @@ func (p *MediaPlaylist) Close() { // SetDefaultKey sets encryption key appeared once in header of the // playlist (pointer to MediaPlaylist.Key). It useful when keys not // changed during playback. Set tag for the whole list. -func (p *MediaPlaylist) SetDefaultKey(method, uri, iv, keyformat, keyformatversions string) error { +func (p *MediaPlaylist) SetDefaultKey(method, uri, iv, keyformat, keyformatversions, id string) error { // A Media Playlist MUST indicate a EXT-X-VERSION of 5 or higher if it // contains: // - The KEYFORMAT and KEYFORMATVERSIONS attributes of the EXT-X-KEY tag. if keyformat != "" || keyformatversions != "" { version(&p.ver, 5) } - p.Key = &Key{Method: method, URI: uri, IV: iv, Keyformat: keyformat, Keyformatversions: keyformatversions} + p.Key = &Key{Method: method, URI: uri, IV: iv, Keyformat: keyformat, Keyformatversions: keyformatversions, ID: id} return nil } @@ -790,7 +790,7 @@ func (p *MediaPlaylist) SetIframeOnly() { // SetKey sets encryption key for the current segment of media playlist // (pointer to Segment.Key). -func (p *MediaPlaylist) SetKey(method, uri, iv, keyformat, keyformatversions string) error { +func (p *MediaPlaylist) SetKey(method, uri, iv, keyformat, keyformatversions, id string) error { if p.count == 0 { return errors.New("playlist is empty") } @@ -802,7 +802,7 @@ func (p *MediaPlaylist) SetKey(method, uri, iv, keyformat, keyformatversions str version(&p.ver, 5) } - p.Segments[p.last()].Key = &Key{Method: method, URI: uri, IV: iv, Keyformat: keyformat, Keyformatversions: keyformatversions} + p.Segments[p.last()].Key = &Key{Method: method, URI: uri, IV: iv, Keyformat: keyformat, Keyformatversions: keyformatversions, ID: id} return nil } diff --git a/writer_test.go b/writer_test.go index 76fe9e25..0d5ffc11 100644 --- a/writer_test.go +++ b/writer_test.go @@ -268,7 +268,7 @@ func TestSetKeyForMediaPlaylist(t *testing.T) { if e = p.Append("test01.ts", 5.0, ""); e != nil { t.Errorf("Add 1st segment to a media playlist failed: %s", e) } - if e := p.SetKey("AES-128", "https://example.com", "iv", test.KeyFormat, test.KeyFormatVersions); e != nil { + if e := p.SetKey("AES-128", "https://example.com", "iv", test.KeyFormat, test.KeyFormatVersions, "id"); e != nil { t.Errorf("Set key to a media playlist failed: %s", e) } if p.ver != test.ExpectVersion { @@ -297,7 +297,7 @@ func TestSetDefaultKeyForMediaPlaylist(t *testing.T) { if e != nil { t.Fatalf("Create media playlist failed: %s", e) } - if e := p.SetDefaultKey("AES-128", "https://example.com", "iv", test.KeyFormat, test.KeyFormatVersions); e != nil { + if e := p.SetDefaultKey("AES-128", "https://example.com", "iv", test.KeyFormat, test.KeyFormatVersions, "id"); e != nil { t.Errorf("Set key to a media playlist failed: %s", e) } if p.ver != test.ExpectVersion { @@ -569,7 +569,7 @@ func TestEncryptionKeysInMediaPlaylist(t *testing.T) { Keyformatversions: "1", } _ = p.Append(uri+".ts", 4, "") - _ = p.SetKey(expected.Method, expected.URI, expected.IV, expected.Keyformat, expected.Keyformatversions) + _ = p.SetKey(expected.Method, expected.URI, expected.IV, expected.Keyformat, expected.Keyformatversions, "id") if p.Segments[i].Key == nil { t.Fatalf("Key was not set on segment %v", i) @@ -586,9 +586,9 @@ func TestEncryptionKeyMethodNoneInMediaPlaylist(t *testing.T) { t.Fatalf("Create media playlist failed: %s", e) } p.Append("segment-1.ts", 4, "") - p.SetKey("AES-128", "key-uri", "iv", "identity", "1") + p.SetKey("AES-128", "key-uri", "iv", "identity", "1", "id") p.Append("segment-2.ts", 4, "") - p.SetKey("NONE", "", "", "", "") + p.SetKey("NONE", "", "", "", "", "id") expected := `#EXT-X-KEY:METHOD=NONE #EXTINF:4.000, segment-2.ts` From 41f3477632c61b5c29be2a25822ebf0bd5fe1574 Mon Sep 17 00:00:00 2001 From: hikeh Date: Tue, 30 Nov 2021 17:41:05 +0000 Subject: [PATCH 09/26] add id to test --- writer_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/writer_test.go b/writer_test.go index 0d5ffc11..74787a26 100644 --- a/writer_test.go +++ b/writer_test.go @@ -567,6 +567,7 @@ func TestEncryptionKeysInMediaPlaylist(t *testing.T) { IV: fmt.Sprintf("%d", i), Keyformat: "identity", Keyformatversions: "1", + ID: "id", } _ = p.Append(uri+".ts", 4, "") _ = p.SetKey(expected.Method, expected.URI, expected.IV, expected.Keyformat, expected.Keyformatversions, "id") From f39fba4a141dae5d7a8a0f17f62814577f8732cb Mon Sep 17 00:00:00 2001 From: hikeh Date: Tue, 30 Nov 2021 18:43:15 +0000 Subject: [PATCH 10/26] update yaml --- .travis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 427560c7..c92218b5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,16 +1,16 @@ language: go -go_import_path: github.com/grafov/m3u8 # Versions of go that are explicitly supported. go: - 1.6.3 - 1.7.3 - 1.8.x + - 1.17x - tip # Required for coverage. before_install: - - go get golang.org/x/tools/cmd/cover + - go install golang.org/x/tools/...@latest script: - go build -a -v ./... From dc41e139fb810fe24c7dd0ad24c237fff651ac58 Mon Sep 17 00:00:00 2001 From: hikeh Date: Tue, 30 Nov 2021 19:11:20 +0000 Subject: [PATCH 11/26] revert update version --- .travis.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index c92218b5..426bf3cb 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,7 +5,6 @@ go: - 1.6.3 - 1.7.3 - 1.8.x - - 1.17x - tip # Required for coverage. From 8cabadb6ba5e969419dac9d11117728c3c086935 Mon Sep 17 00:00:00 2001 From: hikeh Date: Wed, 1 Dec 2021 10:02:11 +0000 Subject: [PATCH 12/26] update Key usages of ID --- reader.go | 2 ++ writer.go | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/reader.go b/reader.go index c0ddf256..09164295 100644 --- a/reader.go +++ b/reader.go @@ -634,6 +634,8 @@ func decodeLineOfMediaPlaylist(p *MediaPlaylist, wv *WV, state *decodingState, l state.xkey.Keyformat = v case "KEYFORMATVERSIONS": state.xkey.Keyformatversions = v + case "ID": + state.xkey.ID = v } } state.tagKey = true diff --git a/writer.go b/writer.go index c99a8adb..01fe3e78 100644 --- a/writer.go +++ b/writer.go @@ -445,6 +445,11 @@ func (p *MediaPlaylist) Encode() *bytes.Buffer { p.buf.WriteString(p.Key.Keyformatversions) p.buf.WriteRune('"') } + if p.Key.ID != "" { + p.buf.WriteString(",ID=\"") + p.buf.WriteString(p.Key.ID) + p.buf.WriteRune('"') + } } p.buf.WriteRune('\n') } @@ -642,6 +647,11 @@ func (p *MediaPlaylist) Encode() *bytes.Buffer { p.buf.WriteString(seg.Key.Keyformatversions) p.buf.WriteRune('"') } + if seg.Key.ID != "" { + p.buf.WriteString(",ID=\"") + p.buf.WriteString(seg.Key.ID) + p.buf.WriteRune('"') + } } p.buf.WriteRune('\n') } From 7acba66c0eed0674e656d18a43d8c77e1100f27e Mon Sep 17 00:00:00 2001 From: hikeh Date: Wed, 1 Dec 2021 11:43:47 +0000 Subject: [PATCH 13/26] rename ID to KeyId --- reader.go | 6 +++--- structure.go | 2 +- writer.go | 16 ++++++++-------- writer_test.go | 2 +- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/reader.go b/reader.go index 09164295..07df5bc3 100644 --- a/reader.go +++ b/reader.go @@ -538,7 +538,7 @@ func decodeLineOfMediaPlaylist(p *MediaPlaylist, wv *WV, state *decodingState, l } // If EXT-X-KEY appeared before reference to segment (EXTINF) then it linked to this segment if state.tagKey { - p.Segments[p.last()].Key = &Key{Method: state.xkey.Method, URI: state.xkey.URI, IV: state.xkey.IV, Keyformat: state.xkey.Keyformat, Keyformatversions: state.xkey.Keyformatversions, ID: state.xkey.ID} + p.Segments[p.last()].Key = &Key{Method: state.xkey.Method, URI: state.xkey.URI, IV: state.xkey.IV, Keyformat: state.xkey.Keyformat, Keyformatversions: state.xkey.Keyformatversions, KeyID: state.xkey.KeyID} // First EXT-X-KEY may appeared in the header of the playlist and linked to first segment // but for convenient playlist generation it also linked as default playlist key if p.Key == nil { @@ -634,8 +634,8 @@ func decodeLineOfMediaPlaylist(p *MediaPlaylist, wv *WV, state *decodingState, l state.xkey.Keyformat = v case "KEYFORMATVERSIONS": state.xkey.Keyformatversions = v - case "ID": - state.xkey.ID = v + case "KEYID": + state.xkey.KeyID = v } } state.tagKey = true diff --git a/structure.go b/structure.go index 2d0a80f7..0c0bb225 100644 --- a/structure.go +++ b/structure.go @@ -237,7 +237,7 @@ type Key struct { IV string Keyformat string Keyformatversions string - ID string + KeyID string } // Map structure represents specifies how to obtain the Media diff --git a/writer.go b/writer.go index 01fe3e78..18ac9515 100644 --- a/writer.go +++ b/writer.go @@ -445,9 +445,9 @@ func (p *MediaPlaylist) Encode() *bytes.Buffer { p.buf.WriteString(p.Key.Keyformatversions) p.buf.WriteRune('"') } - if p.Key.ID != "" { - p.buf.WriteString(",ID=\"") - p.buf.WriteString(p.Key.ID) + if p.Key.KeyID != "" { + p.buf.WriteString(",KEYID=\"") + p.buf.WriteString(p.Key.KeyID) p.buf.WriteRune('"') } } @@ -647,9 +647,9 @@ func (p *MediaPlaylist) Encode() *bytes.Buffer { p.buf.WriteString(seg.Key.Keyformatversions) p.buf.WriteRune('"') } - if seg.Key.ID != "" { - p.buf.WriteString(",ID=\"") - p.buf.WriteString(seg.Key.ID) + if seg.Key.KeyID != "" { + p.buf.WriteString(",KEYID=\"") + p.buf.WriteString(seg.Key.KeyID) p.buf.WriteRune('"') } } @@ -778,7 +778,7 @@ func (p *MediaPlaylist) SetDefaultKey(method, uri, iv, keyformat, keyformatversi if keyformat != "" || keyformatversions != "" { version(&p.ver, 5) } - p.Key = &Key{Method: method, URI: uri, IV: iv, Keyformat: keyformat, Keyformatversions: keyformatversions, ID: id} + p.Key = &Key{Method: method, URI: uri, IV: iv, Keyformat: keyformat, Keyformatversions: keyformatversions, KeyID: id} return nil } @@ -812,7 +812,7 @@ func (p *MediaPlaylist) SetKey(method, uri, iv, keyformat, keyformatversions, id version(&p.ver, 5) } - p.Segments[p.last()].Key = &Key{Method: method, URI: uri, IV: iv, Keyformat: keyformat, Keyformatversions: keyformatversions, ID: id} + p.Segments[p.last()].Key = &Key{Method: method, URI: uri, IV: iv, Keyformat: keyformat, Keyformatversions: keyformatversions, KeyID: id} return nil } diff --git a/writer_test.go b/writer_test.go index 74787a26..e2c2f75a 100644 --- a/writer_test.go +++ b/writer_test.go @@ -567,7 +567,7 @@ func TestEncryptionKeysInMediaPlaylist(t *testing.T) { IV: fmt.Sprintf("%d", i), Keyformat: "identity", Keyformatversions: "1", - ID: "id", + KeyID: "id", } _ = p.Append(uri+".ts", 4, "") _ = p.SetKey(expected.Method, expected.URI, expected.IV, expected.Keyformat, expected.Keyformatversions, "id") From 43ab5e1565939f3f13abfb19d0370ca6f015ee00 Mon Sep 17 00:00:00 2001 From: hikeh Date: Wed, 1 Dec 2021 12:06:30 +0000 Subject: [PATCH 14/26] revert yaml change --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index 426bf3cb..f840a410 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,4 +1,5 @@ language: go + - go_import_path: github.com/grafov/m3u8 # Versions of go that are explicitly supported. go: From 412c7ef22001d041c6dd46e47dc81ffd16d7db0c Mon Sep 17 00:00:00 2001 From: hikeh <89456069+hikeh@users.noreply.github.com> Date: Wed, 1 Dec 2021 12:11:31 +0000 Subject: [PATCH 15/26] Update .travis.yml --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index f840a410..7067370a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,5 @@ language: go - - go_import_path: github.com/grafov/m3u8 +- go_import_path: github.com/grafov/m3u8 # Versions of go that are explicitly supported. go: From 420f0c37511a6c94e7f2a47f6ad5fcd29631fab1 Mon Sep 17 00:00:00 2001 From: hikeh Date: Wed, 1 Dec 2021 12:13:31 +0000 Subject: [PATCH 16/26] remove space --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index f840a410..7067370a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,5 @@ language: go - - go_import_path: github.com/grafov/m3u8 +- go_import_path: github.com/grafov/m3u8 # Versions of go that are explicitly supported. go: From c3edfc85484595390baa225091ad3ad2c83b2592 Mon Sep 17 00:00:00 2001 From: hikeh <89456069+hikeh@users.noreply.github.com> Date: Wed, 1 Dec 2021 12:15:22 +0000 Subject: [PATCH 17/26] Update .travis.yml --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 7067370a..aa5c2399 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,5 @@ language: go -- go_import_path: github.com/grafov/m3u8 +go_import_path: github.com/grafov/m3u8 # Versions of go that are explicitly supported. go: From 07b7ccaec0c5d102aee9649a41c81a438e3f2fc2 Mon Sep 17 00:00:00 2001 From: Fernando Partida Date: Fri, 17 Dec 2021 11:37:03 -0600 Subject: [PATCH 18/26] Add fields to support the creation of Image Playlists for HLS Roku Thumbnails --- structure.go | 2 ++ writer.go | 11 +++++++++++ 2 files changed, 13 insertions(+) diff --git a/structure.go b/structure.go index 0c0bb225..4565406e 100644 --- a/structure.go +++ b/structure.go @@ -130,6 +130,7 @@ type MediaPlaylist struct { WV *WV // Widevine related tags outside of M3U8 specs Custom map[string]CustomTag customDecoders []CustomDecoder + Images bool // EXT-X-IMAGES-ONLY } // MasterPlaylist structure represents a master playlist which @@ -216,6 +217,7 @@ type MediaSegment struct { SCTE *SCTE // SCTE-35 used for Ad signaling in HLS ProgramDateTime time.Time // EXT-X-PROGRAM-DATE-TIME tag associates the first sample of a media segment with an absolute date and/or time Custom map[string]CustomTag + CustomSubTag CustomTag // Adds custom tag under the media segment } // SCTE holds custom, non EXT-X-DATERANGE, SCTE-35 tags diff --git a/writer.go b/writer.go index 18ac9515..38521562 100644 --- a/writer.go +++ b/writer.go @@ -498,6 +498,10 @@ func (p *MediaPlaylist) Encode() *bytes.Buffer { if p.Iframe { p.buf.WriteString("#EXT-X-I-FRAMES-ONLY\n") } + // Tag used to create Images playlist for DELIVER-2169 + if p.Images { + p.buf.WriteString("#EXT-X-IMAGES-ONLY\n") + } // Widevine tags if p.WV != nil { if p.WV.AudioChannels != 0 { @@ -725,6 +729,13 @@ func (p *MediaPlaylist) Encode() *bytes.Buffer { p.buf.WriteRune(',') p.buf.WriteString(seg.Title) p.buf.WriteRune('\n') + // Adds custom tag under #EXTINF + if seg.CustomSubTag != nil { + if customBuf := seg.CustomSubTag.Encode(); customBuf != nil { + p.buf.WriteString(customBuf.String()) + p.buf.WriteRune('\n') + } + } p.buf.WriteString(seg.URI) if p.Args != "" { p.buf.WriteRune('?') From 90ba96069432e9607cb14040b377dc88f5ebd541 Mon Sep 17 00:00:00 2001 From: Fernando Partida Date: Tue, 28 Dec 2021 11:29:04 -0600 Subject: [PATCH 19/26] Add EXT-X-IMAGE-STREAM-INF for HLS Roku Thumbnails --- structure.go | 1 + writer.go | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/structure.go b/structure.go index 4565406e..170d9eea 100644 --- a/structure.go +++ b/structure.go @@ -180,6 +180,7 @@ type VariantParams struct { Captions string // EXT-X-STREAM-INF only Name string // EXT-X-STREAM-INF only (non standard Wowza/JWPlayer extension to name the variant/quality in UA) Iframe bool // EXT-X-I-FRAME-STREAM-INF + ImageStream bool // EXT-X-IMAGE-STREAM-INF VideoRange string HDCPLevel string FrameRate float64 // EXT-X-STREAM-INF diff --git a/writer.go b/writer.go index 38521562..40d2d3a9 100644 --- a/writer.go +++ b/writer.go @@ -200,6 +200,24 @@ func (p *MasterPlaylist) Encode() *bytes.Buffer { p.buf.WriteRune('"') } p.buf.WriteRune('\n') + } else if pl.ImageStream { + p.buf.WriteString("#EXT-X-IMAGE-STREAM-INF:BANDWIDTH=") + p.buf.WriteString(strconv.FormatUint(uint64(pl.Bandwidth), 10)) + if pl.Resolution != "" { + p.buf.WriteString(",RESOLUTION=") // Resolution should not be quoted + p.buf.WriteString(pl.Resolution) + } + if pl.Codecs != "" { + p.buf.WriteString(",CODECS=\"") + p.buf.WriteString(pl.Codecs) + p.buf.WriteRune('"') + } + if pl.URI != "" { + p.buf.WriteString(",URI=\"") + p.buf.WriteString(pl.URI) + p.buf.WriteRune('"') + } + p.buf.WriteRune('\n') } else { p.buf.WriteString("#EXT-X-STREAM-INF:PROGRAM-ID=") p.buf.WriteString(strconv.FormatUint(uint64(pl.ProgramId), 10)) From d9e25223d668a02352fa1c93be619c572bceeb26 Mon Sep 17 00:00:00 2001 From: Fernando Partida Date: Wed, 29 Dec 2021 16:44:07 -0600 Subject: [PATCH 20/26] Add unit tests for new tags --- writer_test.go | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/writer_test.go b/writer_test.go index e2c2f75a..32f07e09 100644 --- a/writer_test.go +++ b/writer_test.go @@ -519,6 +519,48 @@ func TestEncodeMediaPlaylistWithCustomTags(t *testing.T) { } } +func TestEncodeMediaPlaylistWithCustomSubTag(t *testing.T) { + p, e := NewMediaPlaylist(1, 1) + if e != nil { + t.Fatalf("Create media playlist failed: %s", e) + } + + customPTag := &MockCustomTag{ + name: "#CustomPTag", + encodedString: "#CustomPTag", + } + + e = p.AppendSegment(&MediaSegment{ + Title: "", + URI: "test01.ts", + Duration: 5.0, + CustomSubTag: customPTag, + }) + if e != nil { + t.Fatalf("Add 1st segment to a media playlist failed: %s", e) + } + + encoded := p.String() + expected := "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-MEDIA-SEQUENCE:0\n#EXT-X-TARGETDURATION:5\n#EXTINF:5.000,\n#CustomPTag\ntest01.ts\n" + if encoded != expected { + t.Fatalf("Media playlist: %s\n is not equal to Media Playlist:\n%v", expected, encoded) + } +} + +func TestEncodeMediaPlaylistWithImagesOnly(t *testing.T) { + p, e := NewMediaPlaylist(1, 1) + if e != nil { + t.Fatalf("Create media playlist failed: %s", e) + } + p.Images = true + + encoded := p.String() + expected := "#EXT-X-IMAGES-ONLY" + if !strings.Contains(encoded, expected) { + t.Fatalf("Media playlist does not contain tag: %s\nMedia Playlist:\n%v", expected, encoded) + } +} + // Create new media playlist // Add two segments to media playlist // Encode structures to HLS @@ -1029,6 +1071,22 @@ func TestEncodeMasterPlaylistWithCustomTags(t *testing.T) { } } +func TestEncodeMasterPlaylistWithImagePlaylist(t *testing.T) { + m := NewMasterPlaylist() + + vp := VariantParams{ + ImageStream: true, + } + + m.Append("test.jpg", &MediaPlaylist{}, vp) + + encoded := m.String() + expected := "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-IMAGE-STREAM-INF:BANDWIDTH=0,URI=\"test.jpg\"\n" + if encoded != expected { + t.Fatalf("Master playlist: %s\n is not equal to Master Playlist:\n%v", expected, encoded) + } +} + func TestMasterVersion(t *testing.T) { m := NewMasterPlaylist() m.ver = 5 From 2374fe0dd98e655b233cd853ff62d2a20054afbc Mon Sep 17 00:00:00 2001 From: "whitesource-for-github-com[bot]" <50673670+whitesource-for-github-com[bot]@users.noreply.github.com> Date: Tue, 8 Feb 2022 16:03:52 +0000 Subject: [PATCH 21/26] Add .whitesource configuration file (#6) Co-authored-by: whitesource-for-github-com[bot] <50673670+whitesource-for-github-com[bot]@users.noreply.github.com> --- .whitesource | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .whitesource diff --git a/.whitesource b/.whitesource new file mode 100644 index 00000000..11023c1f --- /dev/null +++ b/.whitesource @@ -0,0 +1,3 @@ +{ + "settingsInheritedFrom": "zencoder/whitesource-config@main" +} \ No newline at end of file From 18d14540b385063f5f9836386815cf6cf01f28a0 Mon Sep 17 00:00:00 2001 From: Humberto Atondo <88110879+hatondo-bcov@users.noreply.github.com> Date: Tue, 15 Feb 2022 05:05:04 -0600 Subject: [PATCH 22/26] Support for EXT-X-CUE-OUT and EXT-X-CUE-IN tags (#8) * Support for EXT-X-CUE-OUT and EXT-X-CUE-IN tags * Support empty segments and unit tests * Remove old unit test * Error handling --- reader.go | 18 +++- reader_test.go | 27 ++++++ ...aylist-with-cue-scte35-empty-segments.m3u8 | 16 ++++ .../media-playlist-with-cue-scte35.m3u8 | 20 +++++ structure.go | 14 ++-- writer.go | 73 ++++++++++------ writer_test.go | 84 +++++++++++++++++++ 7 files changed, 219 insertions(+), 33 deletions(-) create mode 100644 sample-playlists/media-playlist-with-cue-scte35-empty-segments.m3u8 create mode 100644 sample-playlists/media-playlist-with-cue-scte35.m3u8 diff --git a/reader.go b/reader.go index 07df5bc3..f2aa6117 100644 --- a/reader.go +++ b/reader.go @@ -697,10 +697,22 @@ func decodeLineOfMediaPlaylist(p *MediaPlaylist, wv *WV, state *decodingState, l // EXT-OATCLS-SCTE35 contains the SCTE35 tag, EXT-X-CUE-OUT contains duration state.scte.Time, _ = strconv.ParseFloat(line[15:], 64) state.scte.CueType = SCTE35Cue_Start + case !state.tagSCTE35 && strings.HasPrefix(line, "#EXT-X-CUE-OUT:"): + state.tagSCTE35 = true + state.listType = MEDIA + state.scte = new(SCTE) + state.scte.Syntax = SCTE35_CUE + state.scte.Time, _ = strconv.ParseFloat(line[15:], 64) + state.scte.CueType = SCTE35Cue_Start case !state.tagSCTE35 && strings.HasPrefix(line, "#EXT-X-CUE-OUT-CONT:"): + // Since different SCTE35 tags can look similar when reading them, we + // take the SCTE35Syntax of the previous segment. This will give + // the SCTESyntax of the starting cue out. + scteSyntax := p.Segments[p.tail-1].SCTE.Syntax + state.tagSCTE35 = true state.scte = new(SCTE) - state.scte.Syntax = SCTE35_OATCLS + state.scte.Syntax = scteSyntax state.scte.CueType = SCTE35Cue_Mid for attribute, value := range decodeParamsLine(line[20:]) { switch attribute { @@ -713,9 +725,11 @@ func decodeLineOfMediaPlaylist(p *MediaPlaylist, wv *WV, state *decodingState, l } } case !state.tagSCTE35 && line == "#EXT-X-CUE-IN": + // Same as EXT-X-CUE-OUT-CONT + scteSyntax := p.Segments[p.tail-1].SCTE.Syntax state.tagSCTE35 = true state.scte = new(SCTE) - state.scte.Syntax = SCTE35_OATCLS + state.scte.Syntax = scteSyntax state.scte.CueType = SCTE35Cue_End case !state.tagDiscontinuity && strings.HasPrefix(line, "#EXT-X-DISCONTINUITY"): state.tagDiscontinuity = true diff --git a/reader_test.go b/reader_test.go index 691854d5..7bc23fbd 100644 --- a/reader_test.go +++ b/reader_test.go @@ -14,6 +14,7 @@ import ( "bytes" "errors" "fmt" + "io/ioutil" "os" "reflect" "testing" @@ -601,6 +602,32 @@ func TestMediaPlaylistWithOATCLSSCTE35Tag(t *testing.T) { } } +func TestMediaPlaylistWithCueSCTE35Tag(t *testing.T) { + f, err := ioutil.ReadFile("sample-playlists/media-playlist-with-cue-scte35.m3u8") + if err != nil { + t.Fatal(err) + } + p, _, err := DecodeFrom(bytes.NewReader(f), true) + if err != nil { + t.Fatal(err) + } + pp := p.(*MediaPlaylist) + + expect := map[int]*SCTE{ + 2: {Syntax: SCTE35_CUE, CueType: SCTE35Cue_Start, Time: 18}, + 3: {Syntax: SCTE35_CUE, CueType: SCTE35Cue_Mid, Time: 6.0, Elapsed: 6.0}, + 4: {Syntax: SCTE35_CUE, CueType: SCTE35Cue_Mid, Time: 6.0, Elapsed: 12.0}, + 5: {Syntax: SCTE35_CUE, CueType: SCTE35Cue_End}, + } + for i := 0; i < int(pp.Count()); i++ { + if !reflect.DeepEqual(pp.Segments[i].SCTE, expect[i]) { + t.Errorf("CUE SCTE35 segment %v (uri: %v)\ngot: %#v\nexp: %#v", + i, pp.Segments[i].URI, pp.Segments[i].SCTE, expect[i], + ) + } + } +} + func TestDecodeMediaPlaylistWithDiscontinuitySeq(t *testing.T) { f, err := os.Open("sample-playlists/media-playlist-with-discontinuity-seq.m3u8") if err != nil { diff --git a/sample-playlists/media-playlist-with-cue-scte35-empty-segments.m3u8 b/sample-playlists/media-playlist-with-cue-scte35-empty-segments.m3u8 new file mode 100644 index 00000000..47da587c --- /dev/null +++ b/sample-playlists/media-playlist-with-cue-scte35-empty-segments.m3u8 @@ -0,0 +1,16 @@ +#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-MEDIA-SEQUENCE:0 +#EXT-X-TARGETDURATION:6 +#EXTINF:6.000, +segment1 +#EXTINF:6.000, +segment2 +#EXT-X-CUE-OUT:12 +#EXT-X-CUE-IN +#EXTINF:6.000, +segment3 +#EXTINF:6.000, +segment4 +#EXTINF:6.000, +segment5 diff --git a/sample-playlists/media-playlist-with-cue-scte35.m3u8 b/sample-playlists/media-playlist-with-cue-scte35.m3u8 new file mode 100644 index 00000000..4b7a9134 --- /dev/null +++ b/sample-playlists/media-playlist-with-cue-scte35.m3u8 @@ -0,0 +1,20 @@ +#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-MEDIA-SEQUENCE:0 +#EXT-X-TARGETDURATION:6 +#EXTINF:6.000, +segment1 +#EXTINF:6.000, +segment2 +#EXT-X-CUE-OUT:18 +#EXTINF:6.000, +segment3 +#EXT-X-CUE-OUT-CONT:ElapsedTime=6,Duration=6 +#EXTINF:6.000, +segment4 +#EXT-X-CUE-OUT-CONT:ElapsedTime=12,Duration=6 +#EXTINF:6.000, +segment5 +#EXT-X-CUE-IN +#EXTINF:6.000, +segment7 diff --git a/structure.go b/structure.go index 170d9eea..7de29b0d 100644 --- a/structure.go +++ b/structure.go @@ -66,6 +66,7 @@ const ( // SCTE35_67_2014 will be the default due to backwards compatibility reasons. SCTE35_67_2014 SCTE35Syntax = iota // SCTE35_67_2014 defined in http://www.scte.org/documents/pdf/standards/SCTE%2067%202014.pdf SCTE35_OATCLS // SCTE35_OATCLS is a non-standard but common format + SCTE35_CUE // SCTE35_CUE is a non-standard but the most common format among ad providers ) // SCTE35CueType defines the type of cue point, used by readers and writers to @@ -223,12 +224,13 @@ type MediaSegment struct { // SCTE holds custom, non EXT-X-DATERANGE, SCTE-35 tags type SCTE struct { - Syntax SCTE35Syntax // Syntax defines the format of the SCTE-35 cue tag - CueType SCTE35CueType // CueType defines whether the cue is a start, mid, end (if applicable) - Cue string - ID string - Time float64 - Elapsed float64 + Syntax SCTE35Syntax // Syntax defines the format of the SCTE-35 cue tag + CueType SCTE35CueType // CueType defines whether the cue is a start, mid, end (if applicable) + Cue string + ID string + Time float64 + Elapsed float64 + EmptySegment bool } // Key structure represents information about stream encryption. diff --git a/writer.go b/writer.go index 40d2d3a9..043a308d 100644 --- a/writer.go +++ b/writer.go @@ -644,6 +644,23 @@ func (p *MediaPlaylist) Encode() *bytes.Buffer { p.buf.WriteString("#EXT-X-CUE-IN") p.buf.WriteRune('\n') } + case SCTE35_CUE: + switch seg.SCTE.CueType { + case SCTE35Cue_Start: + p.buf.WriteString("#EXT-X-CUE-OUT:") + p.buf.WriteString(strconv.FormatFloat(seg.SCTE.Time, 'f', -1, 64)) + p.buf.WriteRune('\n') + case SCTE35Cue_Mid: + p.buf.WriteString("#EXT-X-CUE-OUT-CONT:") + p.buf.WriteString("ElapsedTime=") + p.buf.WriteString(strconv.FormatFloat(seg.SCTE.Elapsed, 'f', -1, 64)) + p.buf.WriteString(",Duration=") + p.buf.WriteString(strconv.FormatFloat(seg.SCTE.Time, 'f', -1, 64)) + p.buf.WriteRune('\n') + case SCTE35Cue_End: + p.buf.WriteString("#EXT-X-CUE-IN") + p.buf.WriteRune('\n') + } } } // check for key change @@ -731,35 +748,41 @@ func (p *MediaPlaylist) Encode() *bytes.Buffer { } } - p.buf.WriteString("#EXTINF:") - if str, ok := durationCache[seg.Duration]; ok { - p.buf.WriteString(str) - } else { - if p.durationAsInt { - // Old Android players has problems with non integer Duration. - durationCache[seg.Duration] = strconv.FormatInt(int64(math.Ceil(seg.Duration)), 10) + if (seg.SCTE == nil) || (seg.SCTE != nil && !seg.SCTE.EmptySegment) { + // There might be cases when we want to insert ad markers so that + // third party ad providers can manipulate the manifest. In these cases + // there is no need to add anything between the ad markers. + p.buf.WriteString("#EXTINF:") + if str, ok := durationCache[seg.Duration]; ok { + p.buf.WriteString(str) } else { - // Wowza Mediaserver and some others prefer floats. - durationCache[seg.Duration] = strconv.FormatFloat(seg.Duration, 'f', 3, 32) + if p.durationAsInt { + // Old Android players has problems with non integer Duration. + durationCache[seg.Duration] = strconv.FormatInt(int64(math.Ceil(seg.Duration)), 10) + } else { + // Wowza Mediaserver and some others prefer floats. + durationCache[seg.Duration] = strconv.FormatFloat(seg.Duration, 'f', 3, 32) + } + p.buf.WriteString(durationCache[seg.Duration]) } - p.buf.WriteString(durationCache[seg.Duration]) - } - p.buf.WriteRune(',') - p.buf.WriteString(seg.Title) - p.buf.WriteRune('\n') - // Adds custom tag under #EXTINF - if seg.CustomSubTag != nil { - if customBuf := seg.CustomSubTag.Encode(); customBuf != nil { - p.buf.WriteString(customBuf.String()) - p.buf.WriteRune('\n') + p.buf.WriteRune(',') + p.buf.WriteString(seg.Title) + p.buf.WriteRune('\n') + + // Adds custom tag under #EXTINF + if seg.CustomSubTag != nil { + if customBuf := seg.CustomSubTag.Encode(); customBuf != nil { + p.buf.WriteString(customBuf.String()) + p.buf.WriteRune('\n') + } } + p.buf.WriteString(seg.URI) + if p.Args != "" { + p.buf.WriteRune('?') + p.buf.WriteString(p.Args) + } + p.buf.WriteRune('\n') } - p.buf.WriteString(seg.URI) - if p.Args != "" { - p.buf.WriteRune('?') - p.buf.WriteString(p.Args) - } - p.buf.WriteRune('\n') } if p.Closed { p.buf.WriteString("#EXT-X-ENDLIST\n") diff --git a/writer_test.go b/writer_test.go index 32f07e09..846f01c5 100644 --- a/writer_test.go +++ b/writer_test.go @@ -245,6 +245,90 @@ func TestSetSCTEForMediaPlaylist(t *testing.T) { } } +func TestBuildManifestWithSCTE35Tags(t *testing.T) { + tests := []struct { + name string + segments []*MediaSegment + expectedPath string + }{ + { + name: "SCTE35_CUE Success", + segments: []*MediaSegment{ + {URI: "segment1", Duration: 6.0}, + {URI: "segment2", Duration: 6.0}, + {URI: "segment3", Duration: 6.0, SCTE: &SCTE{Syntax: SCTE35_CUE, CueType: SCTE35Cue_Start, Time: 18}}, + {URI: "segment4", Duration: 6.0, SCTE: &SCTE{Syntax: SCTE35_CUE, CueType: SCTE35Cue_Mid, Time: 6.0, Elapsed: 6.0}}, + {URI: "segment5", Duration: 6.0, SCTE: &SCTE{Syntax: SCTE35_CUE, CueType: SCTE35Cue_Mid, Time: 6.0, Elapsed: 12.0}}, + {URI: "segment6", SCTE: &SCTE{Syntax: SCTE35_CUE, CueType: SCTE35Cue_End, EmptySegment: true}}, + {URI: "segment7", Duration: 6.0}, + }, + expectedPath: "sample-playlists/media-playlist-with-cue-scte35.m3u8", + }, + { + name: "SCTE35_CUE Success with empty segments", + segments: []*MediaSegment{ + {URI: "segment1", Duration: 6.0}, + {URI: "segment2", Duration: 6.0}, + {SCTE: &SCTE{Syntax: SCTE35_CUE, CueType: SCTE35Cue_Start, Time: 12.0, EmptySegment: true}}, + {SCTE: &SCTE{Syntax: SCTE35_CUE, CueType: SCTE35Cue_End, EmptySegment: true}}, + {URI: "segment3", Duration: 6.0}, + {URI: "segment4", Duration: 6.0}, + {URI: "segment5", Duration: 6.0}, + }, + expectedPath: "sample-playlists/media-playlist-with-cue-scte35-empty-segments.m3u8", + }, + } + + for _, test := range tests { + size := uint(len(test.segments)) + p, e := NewMediaPlaylist(size, size) + if e != nil { + t.Fatalf("Create media playlist failed: %s", e) + } + + // Build manifest + for _, segment := range test.segments { + err := p.AppendSegment(segment) + if err != nil { + t.Fatalf("Falied to append media segment") + } + } + + expected, err := ioutil.ReadFile(test.expectedPath) + if err != nil { + t.Fatal(err) + } + + if p.String() != string(expected) { + t.Errorf("Manifests don't match") + } + } +} + +func TestExampleMediaPlaylist_Segments_SCTE35_CUE(t *testing.T) { + tests := []struct { + expectedPath string + }{ + {expectedPath: "sample-playlists/media-playlist-with-cue-scte35.m3u8"}, + } + + for _, test := range tests { + f, err := ioutil.ReadFile(test.expectedPath) + if err != nil { + t.Fatal(err) + } + p, _, err := DecodeFrom(bytes.NewReader(f), true) + if err != nil { + t.Fatal(err) + } + pp := p.(*MediaPlaylist) + + if pp.String() != string(f) { + t.Errorf("Manifests don't match") + } + } +} + // Create new media playlist // Add segment to media playlist // Set encryption key From 5af99e3142784a789fc749d224f8a5869dba11a3 Mon Sep 17 00:00:00 2001 From: Matthew Neil <2829052+mjneil@users.noreply.github.com> Date: Mon, 8 Jan 2024 09:28:31 -0500 Subject: [PATCH 23/26] ext-x-map byterange attribute is quoted string --- reader.go | 4 +- reader_test.go | 10 ++-- ...-playlist-with-discontinuity-and-maps.m3u8 | 6 +-- .../media-playlist-with-maps.m3u8 | 2 +- structure.go | 50 +++++++++---------- structure_test.go | 10 ++-- writer.go | 9 ++-- writer_test.go | 24 ++++----- 8 files changed, 59 insertions(+), 56 deletions(-) diff --git a/reader.go b/reader.go index f2aa6117..05bb995a 100644 --- a/reader.go +++ b/reader.go @@ -26,8 +26,8 @@ var reKeyValue = regexp.MustCompile(`([a-zA-Z0-9_-]+)=("[^"]+"|[^",]+)`) // TimeParse allows globally apply and/or override Time Parser function. // Available variants: -// * FullTimeParse - implements full featured ISO/IEC 8601:2004 -// * StrictTimeParse - implements only RFC3339 Nanoseconds format +// - FullTimeParse - implements full featured ISO/IEC 8601:2004 +// - StrictTimeParse - implements only RFC3339 Nanoseconds format var TimeParse func(value string) (time.Time, error) = FullTimeParse // Decode parses a master playlist passed from the buffer. If `strict` diff --git a/reader_test.go b/reader_test.go index 7bc23fbd..b5be81f4 100644 --- a/reader_test.go +++ b/reader_test.go @@ -1,11 +1,11 @@ /* - Playlist parsing tests. +Playlist parsing tests. - Copyright 2013-2019 The Project Developers. - See the AUTHORS and LICENSE files at the top-level directory of this distribution - and at https://github.com/grafov/m3u8/ +Copyright 2013-2019 The Project Developers. +See the AUTHORS and LICENSE files at the top-level directory of this distribution +and at https://github.com/grafov/m3u8/ - ॐ तारे तुत्तारे तुरे स्व +ॐ तारे तुत्तारे तुरे स्व */ package m3u8 diff --git a/sample-playlists/media-playlist-with-discontinuity-and-maps.m3u8 b/sample-playlists/media-playlist-with-discontinuity-and-maps.m3u8 index 1648b162..88e64a62 100644 --- a/sample-playlists/media-playlist-with-discontinuity-and-maps.m3u8 +++ b/sample-playlists/media-playlist-with-discontinuity-and-maps.m3u8 @@ -1,18 +1,18 @@ #EXTM3U #EXT-X-VERSION:5 -#EXT-X-MAP:URI="https://example.com/content-init.mp4",BYTERANGE=1024000@1048576 +#EXT-X-MAP:URI="https://example.com/content-init.mp4",BYTERANGE="1024000@1048576" #EXT-X-MEDIA-SEQUENCE:0 #EXT-X-TARGETDURATION:6 #EXTINF:5.000, test01.mp4 #EXT-X-DISCONTINUITY -#EXT-X-MAP:URI="https://segmentencoded.com/ad-init.mp4",BYTERANGE=1024000@1048576 +#EXT-X-MAP:URI="https://segmentencoded.com/ad-init.mp4",BYTERANGE="1024000@1048576" #EXTINF:6.000, test02.mp4 #EXTINF:6.000, test03.mp4 #EXT-X-DISCONTINUITY -#EXT-X-MAP:URI="https://example.com/content-init.mp4",BYTERANGE=1024000@1048576 +#EXT-X-MAP:URI="https://example.com/content-init.mp4",BYTERANGE="1024000@1048576" #EXTINF:6.000, test04.mp4 #EXTINF:6.000, diff --git a/sample-playlists/media-playlist-with-maps.m3u8 b/sample-playlists/media-playlist-with-maps.m3u8 index 64a924b5..9197a619 100644 --- a/sample-playlists/media-playlist-with-maps.m3u8 +++ b/sample-playlists/media-playlist-with-maps.m3u8 @@ -1,6 +1,6 @@ #EXTM3U #EXT-X-VERSION:5 -#EXT-X-MAP:URI="https://example.com/content-init.mp4",BYTERANGE=1024000@1048576 +#EXT-X-MAP:URI="https://example.com/content-init.mp4",BYTERANGE="1024000@1048576" #EXT-X-MEDIA-SEQUENCE:0 #EXT-X-TARGETDURATION:6 #EXTINF:6.000, diff --git a/structure.go b/structure.go index 7de29b0d..75afdd9f 100644 --- a/structure.go +++ b/structure.go @@ -86,26 +86,26 @@ const ( // // Simple Media Playlist file sample: // -// #EXTM3U -// #EXT-X-VERSION:3 -// #EXT-X-TARGETDURATION:5220 -// #EXTINF:5219.2, -// http://media.example.com/entire.ts -// #EXT-X-ENDLIST +// #EXTM3U +// #EXT-X-VERSION:3 +// #EXT-X-TARGETDURATION:5220 +// #EXTINF:5219.2, +// http://media.example.com/entire.ts +// #EXT-X-ENDLIST // // Sample of Sliding Window Media Playlist, using HTTPS: // -// #EXTM3U -// #EXT-X-VERSION:3 -// #EXT-X-TARGETDURATION:8 -// #EXT-X-MEDIA-SEQUENCE:2680 +// #EXTM3U +// #EXT-X-VERSION:3 +// #EXT-X-TARGETDURATION:8 +// #EXT-X-MEDIA-SEQUENCE:2680 // -// #EXTINF:7.975, -// https://priv.example.com/fileSequence2680.ts -// #EXTINF:7.941, -// https://priv.example.com/fileSequence2681.ts -// #EXTINF:7.975, -// https://priv.example.com/fileSequence2682.ts +// #EXTINF:7.975, +// https://priv.example.com/fileSequence2680.ts +// #EXTINF:7.941, +// https://priv.example.com/fileSequence2681.ts +// #EXTINF:7.975, +// https://priv.example.com/fileSequence2682.ts type MediaPlaylist struct { TargetDuration float64 SeqNo uint64 // EXT-X-MEDIA-SEQUENCE @@ -138,15 +138,15 @@ type MediaPlaylist struct { // combines media playlists for multiple bitrates. URI lines in the // playlist identify media playlists. Sample of Master Playlist file: // -// #EXTM3U -// #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1280000 -// http://example.com/low.m3u8 -// #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=2560000 -// http://example.com/mid.m3u8 -// #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=7680000 -// http://example.com/hi.m3u8 -// #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=65000,CODECS="mp4a.40.5" -// http://example.com/audio-only.m3u8 +// #EXTM3U +// #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1280000 +// http://example.com/low.m3u8 +// #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=2560000 +// http://example.com/mid.m3u8 +// #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=7680000 +// http://example.com/hi.m3u8 +// #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=65000,CODECS="mp4a.40.5" +// http://example.com/audio-only.m3u8 type MasterPlaylist struct { Variants []*Variant Args string // optional arguments placed after URI (URI?Args) diff --git a/structure_test.go b/structure_test.go index 4ab7d36a..319176db 100644 --- a/structure_test.go +++ b/structure_test.go @@ -1,11 +1,11 @@ /* - Playlist structures tests. +Playlist structures tests. - Copyright 2013-2017 The Project Developers. - See the AUTHORS and LICENSE files at the top-level directory of this distribution - and at https://github.com/grafov/m3u8/ +Copyright 2013-2017 The Project Developers. +See the AUTHORS and LICENSE files at the top-level directory of this distribution +and at https://github.com/grafov/m3u8/ - ॐ तारे तुत्तारे तुरे स्व +ॐ तारे तुत्तारे तुरे स्व */ package m3u8 diff --git a/writer.go b/writer.go index 043a308d..4e06c005 100644 --- a/writer.go +++ b/writer.go @@ -477,10 +477,11 @@ func (p *MediaPlaylist) Encode() *bytes.Buffer { p.buf.WriteString(p.Map.URI) p.buf.WriteRune('"') if p.Map.Limit > 0 { - p.buf.WriteString(",BYTERANGE=") + p.buf.WriteString(",BYTERANGE=\"") p.buf.WriteString(strconv.FormatInt(p.Map.Limit, 10)) p.buf.WriteRune('@') p.buf.WriteString(strconv.FormatInt(p.Map.Offset, 10)) + p.buf.WriteRune('"') } p.buf.WriteRune('\n') } @@ -704,10 +705,11 @@ func (p *MediaPlaylist) Encode() *bytes.Buffer { p.buf.WriteString(seg.Map.URI) p.buf.WriteRune('"') if seg.Map.Limit > 0 { - p.buf.WriteString(",BYTERANGE=") + p.buf.WriteString(",BYTERANGE=\"") p.buf.WriteString(strconv.FormatInt(seg.Map.Limit, 10)) p.buf.WriteRune('@') p.buf.WriteString(strconv.FormatInt(seg.Map.Offset, 10)) + p.buf.WriteRune('"') } p.buf.WriteRune('\n') } @@ -718,10 +720,11 @@ func (p *MediaPlaylist) Encode() *bytes.Buffer { p.buf.WriteString(seg.Map.URI) p.buf.WriteRune('"') if seg.Map.Limit > 0 { - p.buf.WriteString(",BYTERANGE=") + p.buf.WriteString(",BYTERANGE=\"") p.buf.WriteString(strconv.FormatInt(seg.Map.Limit, 10)) p.buf.WriteRune('@') p.buf.WriteString(strconv.FormatInt(seg.Map.Offset, 10)) + p.buf.WriteRune('"') } p.buf.WriteRune('\n') } diff --git a/writer_test.go b/writer_test.go index 846f01c5..b72e6124 100644 --- a/writer_test.go +++ b/writer_test.go @@ -1,11 +1,11 @@ /* - Package m3u8. Playlist generation tests. +Package m3u8. Playlist generation tests. - Copyright 2013-2019 The Project Developers. - See the AUTHORS and LICENSE files at the top-level directory of this distribution - and at https://github.com/grafov/m3u8/ +Copyright 2013-2019 The Project Developers. +See the AUTHORS and LICENSE files at the top-level directory of this distribution +and at https://github.com/grafov/m3u8/ - ॐ तारे तुत्तारे तुरे स्व +ॐ तारे तुत्तारे तुरे स्व */ package m3u8 @@ -399,7 +399,7 @@ func TestSetDefaultMapForMediaPlaylist(t *testing.T) { } p.SetDefaultMap("https://example.com", 1000*1024, 1024*1024) - expected := `EXT-X-MAP:URI="https://example.com",BYTERANGE=1024000@1048576` + expected := `EXT-X-MAP:URI="https://example.com",BYTERANGE="1024000@1048576"` if !strings.Contains(p.String(), expected) { t.Fatalf("Media playlist did not contain: %s\nMedia Playlist:\n%v", expected, p.String()) } @@ -422,7 +422,7 @@ func TestSetMapForMediaPlaylist(t *testing.T) { t.Errorf("Set map to a media playlist failed: %s", e) } - expected := `EXT-X-MAP:URI="https://example.com",BYTERANGE=1024000@1048576 + expected := `EXT-X-MAP:URI="https://example.com",BYTERANGE="1024000@1048576" #EXTINF:5.000, test01.ts` if !strings.Contains(p.String(), expected) { @@ -451,7 +451,7 @@ func TestEncodeMediaPlaylistWithDefaultMap(t *testing.T) { } //fmt.Println(p.Encode().String()) encoded := p.String() - expected := `EXT-X-MAP:URI="https://example.com",BYTERANGE=1024000@1048576` + expected := `EXT-X-MAP:URI="https://example.com",BYTERANGE="1024000@1048576"` if !strings.Contains(encoded, expected) { t.Fatalf("Media playlist did not contain: %s\nMedia Playlist:\n%v", expected, encoded) } @@ -493,11 +493,11 @@ func TestEncodeMediaPlaylistWithDiscontinuityAndDefaultMap(t *testing.T) { } encoded := p.String() //fmt.Println(p.Encode().String()) - expectDefaultMap := `EXT-X-MAP:URI="https://example.com",BYTERANGE=1024000@1048576` + expectDefaultMap := `EXT-X-MAP:URI="https://example.com",BYTERANGE="1024000@1048576"` if !strings.Contains(encoded, expectDefaultMap) { t.Fatalf("Media playlist did not contain: %s\nMedia Playlist:\n%v", expectDefaultMap, encoded) } - expectSegmentMap := `EXT-X-MAP:URI="https://segmentencoded.com",BYTERANGE=1024000@1048576` + expectSegmentMap := `EXT-X-MAP:URI="https://segmentencoded.com",BYTERANGE="1024000@1048576"` if !strings.Contains(encoded, expectSegmentMap) { t.Fatalf("Media playlist did not contain: %s\nMedia Playlist:\n%v", expectSegmentMap, encoded) } @@ -533,11 +533,11 @@ func TestEncodeMediaPlaylistWithDiscontinuityAndDefaultMapWithAppendSegment(t *t } encoded := p.String() //fmt.Println(p.Encode().String()) - expectDefaultMap := `EXT-X-MAP:URI="https://example.com",BYTERANGE=1024000@1048576` + expectDefaultMap := `EXT-X-MAP:URI="https://example.com",BYTERANGE="1024000@1048576"` if !strings.Contains(encoded, expectDefaultMap) { t.Fatalf("Media playlist did not contain: %s\nMedia Playlist:\n%v", expectDefaultMap, encoded) } - expectSegmentMap := `EXT-X-MAP:URI="https://segmentencoded.com",BYTERANGE=1024000@1048576` + expectSegmentMap := `EXT-X-MAP:URI="https://segmentencoded.com",BYTERANGE="1024000@1048576"` if !strings.Contains(encoded, expectSegmentMap) { t.Fatalf("Media playlist did not contain: %s\nMedia Playlist:\n%v", expectSegmentMap, encoded) } From d20ac766bd12ef4bcfe5c16a07173f3394d80b51 Mon Sep 17 00:00:00 2001 From: Matthew Neil <2829052+mjneil@users.noreply.github.com> Date: Fri, 26 Apr 2024 11:16:28 -0400 Subject: [PATCH 24/26] add BeforeKey to map --- structure.go | 7 ++- writer.go | 175 +++++++++++++++++++++++---------------------------- 2 files changed, 81 insertions(+), 101 deletions(-) diff --git a/structure.go b/structure.go index 75afdd9f..2be93aac 100644 --- a/structure.go +++ b/structure.go @@ -255,9 +255,10 @@ type Key struct { // // Realizes EXT-MAP tag. type Map struct { - URI string - Limit int64 // is length in bytes for the file under URI - Offset int64 // [@o] is offset from the start of the file under URI + URI string + Limit int64 // is length in bytes for the file under URI + Offset int64 // [@o] is offset from the start of the file under URI + BeforeKey bool // If true, the EXT-X-MAP tag will be written before the EXT-X-KEY tag } // WV structure represents metadata for Google Widevine playlists. diff --git a/writer.go b/writer.go index 4e06c005..caa7f431 100644 --- a/writer.go +++ b/writer.go @@ -440,50 +440,18 @@ func (p *MediaPlaylist) Encode() *bytes.Buffer { } } + encodeDefaultMap := p.Map != nil // default key (workaround for Widevine) if p.Key != nil { - p.buf.WriteString("#EXT-X-KEY:") - p.buf.WriteString("METHOD=") - p.buf.WriteString(p.Key.Method) - if p.Key.Method != "NONE" { - p.buf.WriteString(",URI=\"") - p.buf.WriteString(p.Key.URI) - p.buf.WriteRune('"') - if p.Key.IV != "" { - p.buf.WriteString(",IV=") - p.buf.WriteString(p.Key.IV) - } - if p.Key.Keyformat != "" { - p.buf.WriteString(",KEYFORMAT=\"") - p.buf.WriteString(p.Key.Keyformat) - p.buf.WriteRune('"') - } - if p.Key.Keyformatversions != "" { - p.buf.WriteString(",KEYFORMATVERSIONS=\"") - p.buf.WriteString(p.Key.Keyformatversions) - p.buf.WriteRune('"') - } - if p.Key.KeyID != "" { - p.buf.WriteString(",KEYID=\"") - p.buf.WriteString(p.Key.KeyID) - p.buf.WriteRune('"') - } + if encodeDefaultMap && p.Map.BeforeKey { + p.encodeMap(p.Map) + // set to false so we don't encode it twice + encodeDefaultMap = false } - p.buf.WriteRune('\n') + p.encodeKey(p.Key) } - if p.Map != nil { - p.buf.WriteString("#EXT-X-MAP:") - p.buf.WriteString("URI=\"") - p.buf.WriteString(p.Map.URI) - p.buf.WriteRune('"') - if p.Map.Limit > 0 { - p.buf.WriteString(",BYTERANGE=\"") - p.buf.WriteString(strconv.FormatInt(p.Map.Limit, 10)) - p.buf.WriteRune('@') - p.buf.WriteString(strconv.FormatInt(p.Map.Offset, 10)) - p.buf.WriteRune('"') - } - p.buf.WriteRune('\n') + if encodeDefaultMap { + p.encodeMap(p.Map) } if p.MediaType > 0 { p.buf.WriteString("#EXT-X-PLAYLIST-TYPE:") @@ -664,70 +632,27 @@ func (p *MediaPlaylist) Encode() *bytes.Buffer { } } } - // check for key change - if seg.Key != nil && p.Key != seg.Key { - p.buf.WriteString("#EXT-X-KEY:") - p.buf.WriteString("METHOD=") - p.buf.WriteString(seg.Key.Method) - if seg.Key.Method != "NONE" { - p.buf.WriteString(",URI=\"") - p.buf.WriteString(seg.Key.URI) - p.buf.WriteRune('"') - if seg.Key.IV != "" { - p.buf.WriteString(",IV=") - p.buf.WriteString(seg.Key.IV) - } - if seg.Key.Keyformat != "" { - p.buf.WriteString(",KEYFORMAT=\"") - p.buf.WriteString(seg.Key.Keyformat) - p.buf.WriteRune('"') - } - if seg.Key.Keyformatversions != "" { - p.buf.WriteString(",KEYFORMATVERSIONS=\"") - p.buf.WriteString(seg.Key.Keyformatversions) - p.buf.WriteRune('"') - } - if seg.Key.KeyID != "" { - p.buf.WriteString(",KEYID=\"") - p.buf.WriteString(seg.Key.KeyID) - p.buf.WriteRune('"') - } - } - p.buf.WriteRune('\n') - } if seg.Discontinuity { p.buf.WriteString("#EXT-X-DISCONTINUITY\n") } - // ignore segment Map if default playlist Map is present - if p.Map == nil && seg.Map != nil { - p.buf.WriteString("#EXT-X-MAP:") - p.buf.WriteString("URI=\"") - p.buf.WriteString(seg.Map.URI) - p.buf.WriteRune('"') - if seg.Map.Limit > 0 { - p.buf.WriteString(",BYTERANGE=\"") - p.buf.WriteString(strconv.FormatInt(seg.Map.Limit, 10)) - p.buf.WriteRune('@') - p.buf.WriteString(strconv.FormatInt(seg.Map.Offset, 10)) - p.buf.WriteRune('"') + + // Encode segment map if non-nil and there is no default or this segment is a discontinuity + encodeSegMap := seg.Map != nil && (p.Map == nil || seg.Discontinuity) + + // check for key change + if seg.Key != nil && p.Key != seg.Key { + if encodeSegMap && seg.Map.BeforeKey { + p.encodeMap(seg.Map) + // set to false so we don't encode it twice + encodeSegMap = false } - p.buf.WriteRune('\n') + p.encodeKey(seg.Key) } - // add if default map exists and playlist has discontinuities - if p.Map != nil && seg.Discontinuity && seg.Map != nil { - p.buf.WriteString("#EXT-X-MAP:") - p.buf.WriteString("URI=\"") - p.buf.WriteString(seg.Map.URI) - p.buf.WriteRune('"') - if seg.Map.Limit > 0 { - p.buf.WriteString(",BYTERANGE=\"") - p.buf.WriteString(strconv.FormatInt(seg.Map.Limit, 10)) - p.buf.WriteRune('@') - p.buf.WriteString(strconv.FormatInt(seg.Map.Offset, 10)) - p.buf.WriteRune('"') - } - p.buf.WriteRune('\n') + + if encodeSegMap { + p.encodeMap(seg.Map) } + if !seg.ProgramDateTime.IsZero() { p.buf.WriteString("#EXT-X-PROGRAM-DATE-TIME:") p.buf.WriteString(seg.ProgramDateTime.Format(DATETIME)) @@ -793,6 +718,52 @@ func (p *MediaPlaylist) Encode() *bytes.Buffer { return &p.buf } +func (p *MediaPlaylist) encodeKey(k *Key) { + p.buf.WriteString("#EXT-X-KEY:") + p.buf.WriteString("METHOD=") + p.buf.WriteString(k.Method) + if k.Method != "NONE" { + p.buf.WriteString(",URI=\"") + p.buf.WriteString(k.URI) + p.buf.WriteRune('"') + if k.IV != "" { + p.buf.WriteString(",IV=") + p.buf.WriteString(k.IV) + } + if k.Keyformat != "" { + p.buf.WriteString(",KEYFORMAT=\"") + p.buf.WriteString(k.Keyformat) + p.buf.WriteRune('"') + } + if k.Keyformatversions != "" { + p.buf.WriteString(",KEYFORMATVERSIONS=\"") + p.buf.WriteString(k.Keyformatversions) + p.buf.WriteRune('"') + } + if k.KeyID != "" { + p.buf.WriteString(",KEYID=\"") + p.buf.WriteString(k.KeyID) + p.buf.WriteRune('"') + } + } + p.buf.WriteRune('\n') +} + +func (p *MediaPlaylist) encodeMap(m *Map) { + p.buf.WriteString("#EXT-X-MAP:") + p.buf.WriteString("URI=\"") + p.buf.WriteString(m.URI) + p.buf.WriteRune('"') + if m.Limit > 0 { + p.buf.WriteString(",BYTERANGE=\"") + p.buf.WriteString(strconv.FormatInt(m.Limit, 10)) + p.buf.WriteRune('@') + p.buf.WriteString(strconv.FormatInt(m.Offset, 10)) + p.buf.WriteRune('"') + } + p.buf.WriteRune('\n') +} + // String here for compatibility with Stringer interface For example // fmt.Printf("%s", sampleMediaList) will encode playist and print its // string representation. @@ -846,6 +817,14 @@ func (p *MediaPlaylist) SetDefaultMap(uri string, limit, offset int64) { p.Map = &Map{uri, limit, offset} } +// SetDefaultMapBeforeKey sets default Media Initialization Section values for +// playlist (pointer to MediaPlaylist.Map). Set EXT-X-MAP tag for the +// whole playlist and have it written before the playlist's default EXT-X-KEY tag. +func (p *MediaPlaylist) SetDefaultMapBeforeKey(uri string, limit, offset int64) { + version(&p.ver, 5) // due section 4 + p.Map = &Map{uri, limit, offset, true} +} + // SetIframeOnly marks medialist as consists of only I-frames (Intra // frames). Set tag for the whole list. func (p *MediaPlaylist) SetIframeOnly() { From ad67f0a44fc8b404f1a897475fc0ce178c21335b Mon Sep 17 00:00:00 2001 From: Matthew Neil <2829052+mjneil@users.noreply.github.com> Date: Fri, 26 Apr 2024 11:56:09 -0400 Subject: [PATCH 25/26] fix build --- reader.go | 2 +- writer.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/reader.go b/reader.go index 05bb995a..dfc8cfec 100644 --- a/reader.go +++ b/reader.go @@ -548,7 +548,7 @@ func decodeLineOfMediaPlaylist(p *MediaPlaylist, wv *WV, state *decodingState, l } // If EXT-X-MAP appeared before reference to segment (EXTINF) then it linked to this segment if state.tagMap { - p.Segments[p.last()].Map = &Map{state.xmap.URI, state.xmap.Limit, state.xmap.Offset} + p.Segments[p.last()].Map = &Map{state.xmap.URI, state.xmap.Limit, state.xmap.Offset, false} // First EXT-X-MAP may appeared in the header of the playlist and linked to first segment // but for convenient playlist generation it also linked as default playlist map if p.Map == nil { diff --git a/writer.go b/writer.go index caa7f431..ff72d8e8 100644 --- a/writer.go +++ b/writer.go @@ -814,7 +814,7 @@ func (p *MediaPlaylist) SetDefaultKey(method, uri, iv, keyformat, keyformatversi // whole playlist. func (p *MediaPlaylist) SetDefaultMap(uri string, limit, offset int64) { version(&p.ver, 5) // due section 4 - p.Map = &Map{uri, limit, offset} + p.Map = &Map{uri, limit, offset, false} } // SetDefaultMapBeforeKey sets default Media Initialization Section values for @@ -857,7 +857,7 @@ func (p *MediaPlaylist) SetMap(uri string, limit, offset int64) error { return errors.New("playlist is empty") } version(&p.ver, 5) // due section 4 - p.Segments[p.last()].Map = &Map{uri, limit, offset} + p.Segments[p.last()].Map = &Map{uri, limit, offset, false} return nil } From f9ca25e61f940db2449fe45c9acfec169cebfde6 Mon Sep 17 00:00:00 2001 From: Matthew Neil <2829052+mjneil@users.noreply.github.com> Date: Fri, 26 Apr 2024 15:21:43 -0400 Subject: [PATCH 26/26] add unit test --- writer_test.go | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/writer_test.go b/writer_test.go index b72e6124..869fced0 100644 --- a/writer_test.go +++ b/writer_test.go @@ -398,8 +398,29 @@ func TestSetDefaultMapForMediaPlaylist(t *testing.T) { t.Fatalf("Create media playlist failed: %s", e) } p.SetDefaultMap("https://example.com", 1000*1024, 1024*1024) + p.SetDefaultKey("NONE", "", "", "", "", "") - expected := `EXT-X-MAP:URI="https://example.com",BYTERANGE="1024000@1048576"` + expected := ` +#EXT-X-KEY:METHOD=NONE +#EXT-X-MAP:URI="https://example.com",BYTERANGE="1024000@1048576" +` + if !strings.Contains(p.String(), expected) { + t.Fatalf("Media playlist did not contain: %s\nMedia Playlist:\n%v", expected, p.String()) + } +} + +func TestSetDefaultMapBeforeKeyForMediaPlaylist(t *testing.T) { + p, e := NewMediaPlaylist(3, 5) + if e != nil { + t.Fatalf("Create media playlist failed: %s", e) + } + p.SetDefaultMapBeforeKey("https://example.com", 1000*1024, 1024*1024) + p.SetDefaultKey("NONE", "", "", "", "", "") + + expected := ` +#EXT-X-MAP:URI="https://example.com",BYTERANGE="1024000@1048576" +#EXT-X-KEY:METHOD=NONE +` if !strings.Contains(p.String(), expected) { t.Fatalf("Media playlist did not contain: %s\nMedia Playlist:\n%v", expected, p.String()) }