diff --git a/AUTHORS b/AUTHORS index d3bbdd8a..e49692b8 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1,4 +1,4 @@ -There are many persons contribute their code include small patches +There are many persons contribute their code (include small patches) to the project. They listed below in an alphabetical order: - Alexander (Axel) I.Grafov @@ -7,12 +7,14 @@ to the project. They listed below in an alphabetical order: - Bradley Falzon - Denys Smirnov - Fabrizio (Misto) Milo +- Hori Ryota - Jamie Stackhouse - Julian Cooper - Kz26 - Makombo - Scott Kidder +- Vishal Kumar Tuniki - Zac Shenker If you want to be added to this list (or removed for any reason) -just open issue about it. +just open an issue about it. diff --git a/M3U8.md b/M3U8.md index 5b6a6cbe..61d990a0 100644 --- a/M3U8.md +++ b/M3U8.md @@ -5,7 +5,7 @@ This doc explaines M3U8 tag occurence in different versions of HLS protocol and their status in Go library. - Copyleft 2013-2015 Alexander I.Grafov aka Axel + Copyleft 2013-2016 library authors (see AUTHORS file in the package) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by @@ -27,7 +27,7 @@ M3U8 tags cheatsheet ==================== The table above describes tags of M3U8, their occurence in playlists of different types and their support status -in the go-library. Current stable version of the library is 0.2. +in the go-library. Legend for playlist types: @@ -104,4 +104,4 @@ http://tools.ietf.org/html/draft-pantos-http-live-streaming * Version 4 of the HLS protocol described in draft07-draft08. * Version 5 of the HLS protocol described in draft09-draft11. * Version 6 of the HLS protocol described in draft12-draft13. -* Version 7 of the HLS protocol described in draft14-draft17. +* Version 7 of the HLS protocol described in draft14-draft19. diff --git a/README.md b/README.md index d74632dc..4f21b3e3 100644 --- a/README.md +++ b/README.md @@ -44,16 +44,16 @@ Parse playlist: if err != nil { panic(err) } - p, listType, err := DecodeFrom(bufio.NewReader(f), true) + p, listType, err := m3u8.DecodeFrom(bufio.NewReader(f), true) if err != nil { panic(err) } switch listType { - case MEDIA: - mediapl := p.(*MediaPlaylist) + case m3u8.MEDIA: + mediapl := p.(*m3u8.MediaPlaylist) fmt.Printf("%+v\n", mediapl) - case MASTER: - masterpl := p.(*MasterPlaylist) + case m3u8.MASTER: + masterpl := p.(*m3u8.MasterPlaylist) fmt.Printf("%+v\n", masterpl) } ``` @@ -65,7 +65,7 @@ See ``structure.go`` or full documentation (link below). You may use API methods to fill structures or create them manually to generate playlists. Example of media playlist generation: ```go - p, e := NewMediaPlaylist(3, 10) // with window of size 3 and capacity 10 + p, e := m3u8.NewMediaPlaylist(3, 10) // with window of size 3 and capacity 10 if e != nil { panic(fmt.Sprintf("Creating of media playlist failed: %s", e)) } @@ -99,12 +99,13 @@ Related links Library usage ------------- -This library successfully used in streaming software developed for my employer and tested with -generating of VOD and Live streams and parsing of Widevine Live streams. Also library usage noted -in opensource software: +This library was successfully used in streaming software developed for company where I worked several +years ago. It was tested then in generating of VOD and Live streams and parsing of Widevine Live streams. +Also the library used in opensource software so you may look at these apps for usage examples: -* [Stream Surfer](http://streamsurfer.org) monitoring software. -* [gohls](https://github.com/kz26/gohls) — HLS downloader. +* [HLS downloader](https://github.com/kz26/gohls) +* [Another HLS downloader](https://github.com/Makombo/hlsdownloader) +* [HLS utils](https://github.com/archsh/hls-utils) M3U8 parsing/generation in other languages ------------------------------------------ @@ -116,30 +117,30 @@ M3U8 parsing/generation in other languages * http://sourceforge.net/projects/m3u8parser/ in Java * https://github.com/karlll/erlm3u8 in Erlang -Project status [![Is maintained?](http://stillmaintained.com/grafov/m3u8.png)](http://stillmaintained.com/grafov/m3u8) ---------------- +Project status [![Go Report Card](https://goreportcard.com/badge/grafov/m3u8)](https://goreportcard.com/report/grafov/m3u8) +-------------- -In development. +[![Build Status](https://travis-ci.org/grafov/m3u8.png?branch=master)](https://travis-ci.org/grafov/m3u8) [![Build Status](https://drone.io/github.com/grafov/m3u8/status.png)](https://drone.io/github.com/grafov/m3u8/latest) -[![Build Status](https://travis-ci.org/grafov/m3u8.png?branch=master)](https://travis-ci.org/grafov/m3u8) for last commit from `master` or `draft` branches. +Project maintainers: -[![Build Status](https://drone.io/github.com/grafov/m3u8/status.png)](https://drone.io/github.com/grafov/m3u8/latest) for `master` branch. +* Bradley Falzon @bradleyfalzon +* Alexander Grafov @grafov Development rules: -* Feature changes first applied to `draft` branch then after minimal testing it will merge with `master` branch. -* Bugfixes or minor doc changes applied to `master` branch and then merged to `draft`. -* Code in `draft` branch may be in an inconsistent state. * After complete testing and one week usage with my prober for HLS [Stream Surfer](http://streamsurfer.org) it may be released as new library version. * Each new API call or M3U8 tag accompanied by at least with one unit test till new release (this rule will be apply after v1.0). * Versioning scheme follows http://semver.org rules (but versions till v1.0 not support bacward compatibility, see release notes carefully). Project dashboard: https://waffle.io/grafov/m3u8 [![Stories in Ready](https://badge.waffle.io/grafov/m3u8.png?label=ready&title=Ready)](https://waffle.io/grafov/m3u8) +State of code coverage: https://gocover.io/github.com/grafov/m3u8 + Roadmap ------- To version 1.0: * Support all M3U8 tags up to latest version of specs. -* Code coverage by unit tests more than 90% +* Code coverage by unit tests up to 90% diff --git a/TODO.org b/TODO.org deleted file mode 100644 index f9694b81..00000000 --- a/TODO.org +++ /dev/null @@ -1,42 +0,0 @@ --*- mode:org -*- -* Roadmap -** Realize support for all HLS tags up to version 6 of the protocol :v1.0: -** Improve validation for all tags :v1.0: -** Cover each API call and tag with own unit test :v2.0: -** Show detailed examples in documentation :v2.0: -* Unclosed bugs -** [#A] Decode with type detection now implement less features than explicit type decode method. Need refactoring. -** [#A] Mediaplaylist Encode displays winsize+1 chunks, must be winsize. -** DONE [#B] Show target duration as nearest greater integer. -** Set minimal version to 1 -* Planned features -** DONE [#A] Support EXT-X-PROGRAM-DATE-TIME -** DONE [#A] Support for EXT-X-PLAYLIST-TYPE :proto3: -** DONE [#A] Support for EXT-X-I-FRAMES-ONLY :proto4: -** DONE [#A] Support for EXT-X-I-FRAME-STREAM-INF :proto4: -** DONE [#A] Support for EXT-X-MAP :proto5: -** TODO Add samples from iOS Devs Lib - https://developer.apple.com/library/ios/technotes/tn2288/_index.html -** [#A] Support for EXT-X-START :proto6: -** [#A] Support for EXT-X-DISCONTINUITY-SEQUENCE :proto6: -** [#B] Autoexpand option for playlist buffers -** [#B] CR/LF protection: characters within a quoted-string MUST NOT include linefeed (0xA), carriage-return (0xD) or double quote (0x22). :draft12:validation: -** [#B] EXT-X-BYTERANGE MUST NOT appear in a Master Playlist :draft12:validation: -** [#B] The EXT-X-TARGETDURATION tag MUST NOT appear in a Master Playlist. :draft12:validation: -** [#B] The sequence number MUST NOT decrease. :draft12:validation: -** [#B] The EXT-X-STREAM-INF tag MUST NOT appear in a Media Playlist. :validation:draft12: -** [#B] The EXT-X-PLAYLIST-TYPE tag MUST NOT appear in a Master Playlist. :draft12:validation: -** [#B] A Live Playlist MUST NOT contain the EXT-X-PLAYLIST-TYPE tag. :validation:draft12: -** [#B] If o is not present, a previous media segment MUST appear in the Playlist file and MUST be a sub-range of the same media resource. :validation:draft12: -** [#B] Write more detailed test for Widevine specific parameters. -** [#B] Test cache logic for media playlists. -** [#B] Count EXT-X-MEDIA-SEQUENCE precense — MUST be present only once. -** [#B] Support ASSOC-LANGUAGE parameter for EXT-X-MEDIA :rfc5646:draft12: -** [#C] Keep extra data (any commenta out of HLS specs) in a special parameter and print it untouched with Encode. -** [#C] Add examples of playlists with #EXT-X-KEY -** [#C] Cover each API call with unit test -** [#C] Cover each tag with unit test -* Planned for M3U8 utils -** Merge media playlists -** Rename segments by mask in a playlist - diff --git a/doc.go b/doc.go index 4b98fbd3..733749c6 100644 --- a/doc.go +++ b/doc.go @@ -57,7 +57,7 @@ Next example shows parsing of master playlist: fmt.Println(err) } p := NewMasterPlaylist() - err = p.Decode(bufio.NewReader(f), false) + err = p.DecodeFrom(bufio.NewReader(f), false) if err != nil { fmt.Println(err) } diff --git a/example/example.go b/example/example.go new file mode 100644 index 00000000..1a5af928 --- /dev/null +++ b/example/example.go @@ -0,0 +1,33 @@ +package main + +import ( + "bufio" + "fmt" + "os" + + "github.com/grafov/m3u8" +) + +func main() { + GOPATH := os.Getenv("GOPATH") + if GOPATH == "" { + panic("$GOPATH is empty") + } + this := "github.com/grafov/m3u8" + f, err := os.Open(GOPATH + "/src/" + this + "/sample-playlists/media-playlist-with-byterange.m3u8") + if err != nil { + panic(err) + } + p, listType, err := m3u8.DecodeFrom(bufio.NewReader(f), true) + if err != nil { + panic(err) + } + switch listType { + case m3u8.MEDIA: + mediapl := p.(*m3u8.MediaPlaylist) + fmt.Printf("%+v\n", mediapl) + case m3u8.MASTER: + masterpl := p.(*m3u8.MasterPlaylist) + fmt.Printf("%+v\n", masterpl) + } +} diff --git a/reader.go b/reader.go index 2afcb3fe..d5d1a6da 100644 --- a/reader.go +++ b/reader.go @@ -72,7 +72,7 @@ func (p *MasterPlaylist) decode(buf *bytes.Buffer, strict bool) error { } } if strict && !state.m3u { - return errors.New("#EXT3MU absent") + return errors.New("#EXTM3U absent") } return nil } @@ -119,7 +119,7 @@ func (p *MediaPlaylist) decode(buf *bytes.Buffer, strict bool) error { p.WV = wv } if strict && !state.m3u { - return errors.New("#EXT3MU absent") + return errors.New("#EXTM3U absent") } return nil } @@ -187,7 +187,7 @@ func decode(buf *bytes.Buffer, strict bool) (Playlist, ListType, error) { } if strict && !state.m3u { - return nil, listType, errors.New("#EXT3MU absent") + return nil, listType, errors.New("#EXTM3U absent") } switch state.listType { @@ -212,8 +212,6 @@ func decodeParamsLine(line string) map[string]string { // Parse one line of master playlist. func decodeLineOfMasterPlaylist(p *MasterPlaylist, state *decodingState, line string, strict bool) error { - var alt *Alternative - var alternatives []*Alternative var err error line = strings.TrimSpace(line) @@ -228,9 +226,8 @@ func decodeLineOfMasterPlaylist(p *MasterPlaylist, state *decodingState, line st return err } case strings.HasPrefix(line, "#EXT-X-MEDIA:"): + var alt Alternative state.listType = MASTER - alt = new(Alternative) - alternatives = append(alternatives, alt) for k, v := range decodeParamsLine(line[13:]) { switch k { case "TYPE": @@ -261,13 +258,14 @@ func decodeLineOfMasterPlaylist(p *MasterPlaylist, state *decodingState, line st alt.URI = v } } + state.alternatives = append(state.alternatives, &alt) case !state.tagStreamInf && strings.HasPrefix(line, "#EXT-X-STREAM-INF:"): state.tagStreamInf = true state.listType = MASTER state.variant = new(Variant) - if len(alternatives) > 0 { - state.variant.Alternatives = alternatives - alternatives = nil + if len(state.alternatives) > 0 { + state.variant.Alternatives = state.alternatives + state.alternatives = nil } p.Variants = append(p.Variants, state.variant) for k, v := range decodeParamsLine(line[18:]) { @@ -305,14 +303,13 @@ func decodeLineOfMasterPlaylist(p *MasterPlaylist, state *decodingState, line st case state.tagStreamInf && !strings.HasPrefix(line, "#"): state.tagStreamInf = false state.variant.URI = line - case !state.tagIframeStreamInf && strings.HasPrefix(line, "#EXT-X-I-FRAME-STREAM-INF:"): - state.tagIframeStreamInf = true + case strings.HasPrefix(line, "#EXT-X-I-FRAME-STREAM-INF:"): state.listType = MASTER state.variant = new(Variant) state.variant.Iframe = true - if len(alternatives) > 0 { - state.variant.Alternatives = alternatives - alternatives = nil + if len(state.alternatives) > 0 { + state.variant.Alternatives = state.alternatives + state.alternatives = nil } p.Variants = append(p.Variants, state.variant) for k, v := range decodeParamsLine(line[26:]) { @@ -351,11 +348,76 @@ func decodeLineOfMasterPlaylist(p *MasterPlaylist, state *decodingState, line st // Parse one line of media playlist. func decodeLineOfMediaPlaylist(p *MediaPlaylist, wv *WV, state *decodingState, line string, strict bool) error { - var title string var err error line = strings.TrimSpace(line) switch { + case !state.tagInf && strings.HasPrefix(line, "#EXTINF:"): + state.tagInf = true + state.listType = MEDIA + sepIndex := strings.Index(line, ",") + if sepIndex == -1 { + break + } + duration := line[8:sepIndex] + if len(duration) > 0 { + if state.duration, err = strconv.ParseFloat(duration, 64); strict && err != nil { + return fmt.Errorf("Duration parsing error: %s", err) + } + } + if len(line) > sepIndex { + state.title = line[sepIndex+1:] + } + case !strings.HasPrefix(line, "#"): + if state.tagInf { + p.Append(line, state.duration, state.title) + state.tagInf = false + } + if state.tagRange { + if err = p.SetRange(state.limit, state.offset); strict && err != nil { + return err + } + state.tagRange = false + } + if state.tagSCTE35 { + state.tagSCTE35 = false + scte := *state.scte + if err = p.SetSCTE(scte.Cue, scte.ID, scte.Time); strict && err != nil { + return err + } + } + if state.tagDiscontinuity { + state.tagDiscontinuity = false + if err = p.SetDiscontinuity(); strict && err != nil { + return err + } + } + if state.tagProgramDateTime { + state.tagProgramDateTime = false + if err = p.SetProgramDateTime(state.programDateTime); strict && err != nil { + return err + } + } + // 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} + // 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 { + p.Key = state.xkey + } + state.tagKey = false + } + // 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} + // 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 { + p.Map = state.xmap + } + state.tagMap = false + } // start tag first case line == "#EXTM3U": state.m3u = true @@ -434,6 +496,7 @@ func decodeLineOfMediaPlaylist(p *MediaPlaylist, wv *WV, state *decodingState, l case !state.tagRange && strings.HasPrefix(line, "#EXT-X-BYTERANGE:"): state.tagRange = true state.listType = MEDIA + state.offset = 0 params := strings.SplitN(line[17:], "@", 2) if state.limit, err = strconv.ParseInt(params[0], 10, 64); strict && err != nil { return fmt.Errorf("Byterange sub-range length value parsing error: %s", err) @@ -443,66 +506,26 @@ func decodeLineOfMediaPlaylist(p *MediaPlaylist, wv *WV, state *decodingState, l return fmt.Errorf("Byterange sub-range offset value parsing error: %s", err) } } - case !state.tagInf && strings.HasPrefix(line, "#EXTINF:"): - state.tagInf = true + case !state.tagSCTE35 && strings.HasPrefix(line, "#EXT-SCTE35:"): + state.tagSCTE35 = true state.listType = MEDIA - params := strings.SplitN(line[8:], ",", 2) - if len(params) > 0 { - if state.duration, err = strconv.ParseFloat(params[0], 64); strict && err != nil { - return fmt.Errorf("Duration parsing error: %s", err) + state.scte = new(SCTE) + for attribute, value := range decodeParamsLine(line[12:]) { + switch attribute { + case "CUE": + state.scte.Cue = value + case "ID": + state.scte.ID = value + case "TIME": + state.scte.Time, _ = strconv.ParseFloat(value, 64) } } - if len(params) > 1 { - title = params[1] - } case !state.tagDiscontinuity && strings.HasPrefix(line, "#EXT-X-DISCONTINUITY"): state.tagDiscontinuity = true state.listType = MEDIA case strings.HasPrefix(line, "#EXT-X-I-FRAMES-ONLY"): state.listType = MEDIA p.Iframe = true - case !strings.HasPrefix(line, "#"): - if state.tagInf { - p.Append(line, state.duration, title) - state.tagInf = false - } else if state.tagRange { - if err = p.SetRange(state.limit, state.offset); strict && err != nil { - return err - } - state.tagRange = false - } - if state.tagDiscontinuity { - state.tagDiscontinuity = false - if err = p.SetDiscontinuity(); strict && err != nil { - return err - } - } - if state.tagProgramDateTime { - state.tagProgramDateTime = false - if err = p.SetProgramDateTime(state.programDateTime); strict && err != nil { - return err - } - } - // If EXT-X-KEY appeared before reference to segment (EXTINF) then it linked to this segment - if state.tagKey { - p.Segments[(p.tail-1)%p.capacity].Key = &Key{state.xkey.Method, state.xkey.URI, state.xkey.IV, state.xkey.Keyformat, 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 { - p.Key = state.xkey - } - state.tagKey = false - } - // If EXT-X-MAP appeared before reference to segment (EXTINF) then it linked to this segment - if state.tagMap { - p.Segments[(p.tail-1)%p.capacity].Map = &Map{state.xmap.URI, state.xmap.Limit, state.xmap.Offset} - // 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 { - p.Map = state.xmap - } - state.tagMap = false - } case strings.HasPrefix(line, "#WV-AUDIO-CHANNELS"): state.listType = MEDIA if _, err = fmt.Sscanf(line, "#WV-AUDIO-CHANNELS %d", &wv.AudioChannels); strict && err != nil { diff --git a/reader_test.go b/reader_test.go index 9dfaf3bd..b73bda17 100644 --- a/reader_test.go +++ b/reader_test.go @@ -25,6 +25,7 @@ import ( "bufio" "fmt" "os" + "reflect" "testing" ) @@ -89,11 +90,25 @@ func TestDecodeMasterPlaylistWithAlternatives(t *testing.T) { if p.ver != 3 { t.Errorf("Version of parsed playlist = %d (must = 3)", p.ver) } - // if len(p.Variants) != 5 { - // t.Fatal("Not all variants in master playlist parsed.") - // } + if len(p.Variants) != 4 { + t.Fatal("not all variants in master playlist parsed") + } // TODO check other values - //fmt.Println(p.Encode().String()) + for i, v := range p.Variants { + if i == 0 && len(v.Alternatives) != 3 { + t.Fatalf("not all alternatives from #EXT-X-MEDIA parsed (has %d but should be 3", len(v.Alternatives)) + } + if i == 1 && len(v.Alternatives) != 3 { + t.Fatalf("not all alternatives from #EXT-X-MEDIA parsed (has %d but should be 3", len(v.Alternatives)) + } + if i == 2 && len(v.Alternatives) != 3 { + t.Fatalf("not all alternatives from #EXT-X-MEDIA parsed (has %d but should be 3", len(v.Alternatives)) + } + if i == 3 && len(v.Alternatives) > 0 { + t.Fatal("should not be alternatives for this variant") + } + } + // fmt.Println(p.Encode().String()) } // Decode a master playlist with Name tag in EXT-X-STREAM-INF @@ -114,6 +129,50 @@ func TestDecodeMasterPlaylistWithStreamInfName(t *testing.T) { } } +func TestDecodeMediaPlaylistByteRange(t *testing.T) { + f, _ := os.Open("sample-playlists/media-playlist-with-byterange.m3u8") + p, _ := NewMediaPlaylist(3, 3) + _ = p.DecodeFrom(bufio.NewReader(f), true) + expected := []*MediaSegment{ + {URI: "video.ts", Duration: 10, Limit: 75232}, + {URI: "video.ts", Duration: 10, Limit: 82112, Offset: 752321}, + {URI: "video.ts", Duration: 10, Limit: 69864}, + } + for i, seg := range p.Segments { + if *seg != *expected[i] { + t.Errorf("exp: %+v\ngot: %+v", expected[i], seg) + } + } +} + +// Decode a master playlist with i-frame-stream-inf +func TestDecodeMasterPlaylistWithIFrameStreamInf(t *testing.T) { + f, err := os.Open("sample-playlists/master-with-i-frame-stream-inf.m3u8") + if err != nil { + t.Fatal(err) + } + p := NewMasterPlaylist() + err = p.DecodeFrom(bufio.NewReader(f), false) + if err != nil { + t.Fatal(err) + } + expected := map[int]*Variant{ + 86000: {URI: "low/iframe.m3u8", VariantParams: VariantParams{Bandwidth: 86000, ProgramId: 1, Codecs: "c1", Resolution: "1x1", Video: "1", Iframe: true}}, + 150000: {URI: "mid/iframe.m3u8", VariantParams: VariantParams{Bandwidth: 150000, ProgramId: 1, Codecs: "c2", Resolution: "2x2", Video: "2", Iframe: true}}, + 550000: {URI: "hi/iframe.m3u8", VariantParams: VariantParams{Bandwidth: 550000, ProgramId: 1, Codecs: "c2", Resolution: "2x2", Video: "2", Iframe: true}}, + } + for _, variant := range p.Variants { + for k, expect := range expected { + if reflect.DeepEqual(variant, expect) { + delete(expected, k) + } + } + } + for _, expect := range expected { + t.Errorf("not found:%+v", expect) + } +} + func TestDecodeMediaPlaylist(t *testing.T) { f, err := os.Open("sample-playlists/wowza-vod-chunklist.m3u8") if err != nil { @@ -138,6 +197,15 @@ func TestDecodeMediaPlaylist(t *testing.T) { if !p.Closed { t.Error("This is a closed (VOD) playlist but Close field = false") } + titles := []string{"Title 1", "Title 2", ""} + for i, s := range p.Segments { + if i > len(titles)-1 { + break + } + if s.Title != titles[i] { + t.Errorf("Segment %v's title = %v (must = %q)", i, s.Title, titles[i]) + } + } // TODO check other values… //fmt.Println(p.Encode().String()), stream.Name} } @@ -231,7 +299,7 @@ func ExampleMediaPlaylist_DurationAsInt() { // Output: // #EXTM3U // #EXT-X-VERSION:3 - // #EXT-X-MEDIA-SEQUENCE:1 + // #EXT-X-MEDIA-SEQUENCE:0 // #EXT-X-TARGETDURATION:10 // #EXTINF:10, // ad0.ts @@ -243,3 +311,84 @@ func ExampleMediaPlaylist_DurationAsInt() { // #EXTINF:10, // movieB.ts } + +func TestMediaPlaylistWithSCTE35Tag(t *testing.T) { + test_cases := []struct { + playlistLocation string + expectedSCTEIndex int + expectedSCTECue string + expectedSCTEID string + expectedSCTETime float64 + }{ + { + "sample-playlists/media-playlist-with-scte35.m3u8", + 2, + "/DAIAAAAAAAAAAAQAAZ/I0VniQAQAgBDVUVJQAAAAH+cAAAAAA==", + "123", + 123.12, + }, + { + "sample-playlists/media-playlist-with-scte35-1.m3u8", + 1, + "/DAIAAAAAAAAAAAQAAZ/I0VniQAQAgBDVUVJQAA", + "", + 0, + }, + } + for _, c := range test_cases { + f, _ := os.Open(c.playlistLocation) + playlist, _, _ := DecodeFrom(bufio.NewReader(f), true) + mediaPlaylist := playlist.(*MediaPlaylist) + for index, item := range mediaPlaylist.Segments { + if item == nil { + break + } + if index != c.expectedSCTEIndex && item.SCTE != nil { + t.Error("Not expecting SCTE information on this segment") + } else if index == c.expectedSCTEIndex && item.SCTE == nil { + t.Error("Expecting SCTE information on this segment") + } else if index == c.expectedSCTEIndex && item.SCTE != nil { + if (*item.SCTE).Cue != c.expectedSCTECue { + t.Error("Expected ", c.expectedSCTECue, " got ", (*item.SCTE).Cue) + } else if (*item.SCTE).ID != c.expectedSCTEID { + t.Error("Expected ", c.expectedSCTEID, " got ", (*item.SCTE).ID) + } else if (*item.SCTE).Time != c.expectedSCTETime { + t.Error("Expected ", c.expectedSCTETime, " got ", (*item.SCTE).Time) + } + } + } + } +} + +/**************** + * Benchmarks * + ****************/ + +func BenchmarkDecodeMasterPlaylist(b *testing.B) { + for i := 0; i < b.N; i++ { + f, err := os.Open("sample-playlists/master.m3u8") + if err != nil { + b.Fatal(err) + } + p := NewMasterPlaylist() + if err := p.DecodeFrom(bufio.NewReader(f), false); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkDecodeMediaPlaylist(b *testing.B) { + for i := 0; i < b.N; i++ { + f, err := os.Open("sample-playlists/wowza-vod-chunklist.m3u8") + if err != nil { + b.Fatal(err) + } + p, err := NewMediaPlaylist(50000, 50000) + if err != nil { + b.Fatalf("Create media playlist failed: %s", err) + } + if err = p.DecodeFrom(bufio.NewReader(f), true); err != nil { + b.Fatal(err) + } + } +} diff --git a/sample-playlists/master-with-i-frame-stream-inf.m3u8 b/sample-playlists/master-with-i-frame-stream-inf.m3u8 new file mode 100644 index 00000000..73c89d66 --- /dev/null +++ b/sample-playlists/master-with-i-frame-stream-inf.m3u8 @@ -0,0 +1,13 @@ +#EXTM3U +#EXT-X-STREAM-INF:BANDWIDTH=1280000 +low/audio-video.m3u8 +#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=86000,URI="low/iframe.m3u8",PROGRAM-ID=1,CODECS="c1",RESOLUTION="1x1",VIDEO="1" +#EXT-X-STREAM-INF:BANDWIDTH=2560000 +mid/audio-video.m3u8 +#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=150000,URI="mid/iframe.m3u8",PROGRAM-ID=1,CODECS="c2",RESOLUTION="2x2",VIDEO="2" +#EXT-X-STREAM-INF:BANDWIDTH=7680000 +hi/audio-video.m3u8 +#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=550000,URI="hi/iframe.m3u8",PROGRAM-ID=1,CODECS="c2",RESOLUTION="2x2",VIDEO="2" +#EXT-X-STREAM-INF:BANDWIDTH=65000,CODECS="mp4a.40.5" +audio-only.m3u8 +#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH="INVALIDBW",URI="hi/iframe.m3u8",PROGRAM-ID=1,CODECS="c2",RESOLUTION="2x2",VIDEO="2" diff --git a/sample-playlists/media-playlist-with-byterange.m3u8 b/sample-playlists/media-playlist-with-byterange.m3u8 new file mode 100644 index 00000000..743901dc --- /dev/null +++ b/sample-playlists/media-playlist-with-byterange.m3u8 @@ -0,0 +1,13 @@ +#EXTM3U +#EXT-X-TARGETDURATION:10 +#EXT-X-VERSION:4 +#EXT-X-MEDIA-SEQUENCE:0 +#EXTINF:10.0, +#EXT-X-BYTERANGE:75232@0 +video.ts +#EXT-X-BYTERANGE:82112@752321 +#EXTINF:10.0, +video.ts +#EXTINF:10.0, +#EXT-X-BYTERANGE:69864 +video.ts diff --git a/sample-playlists/media-playlist-with-scte35-1.m3u8 b/sample-playlists/media-playlist-with-scte35-1.m3u8 new file mode 100644 index 00000000..fea864c5 --- /dev/null +++ b/sample-playlists/media-playlist-with-scte35-1.m3u8 @@ -0,0 +1,11 @@ +#EXTM3U +#EXT-X-TARGETDURATION:10 +#EXT-X-VERSION:3 +#EXT-X-MEDIA-SEQUENCE:0 +#EXTINF:10.000, +media0.ts +#EXT-SCTE35: CUE="/DAIAAAAAAAAAAAQAAZ/I0VniQAQAgBDVUVJQAA" +#EXTINF:10.000, +media1.ts +#EXTINF:10.000, +media2.ts diff --git a/sample-playlists/media-playlist-with-scte35.m3u8 b/sample-playlists/media-playlist-with-scte35.m3u8 new file mode 100644 index 00000000..f47c907d --- /dev/null +++ b/sample-playlists/media-playlist-with-scte35.m3u8 @@ -0,0 +1,11 @@ +#EXTM3U +#EXT-X-TARGETDURATION:10 +#EXT-X-VERSION:3 +#EXT-X-MEDIA-SEQUENCE:0 +#EXTINF:10.000, +media0.ts +#EXTINF:10.000, +media1.ts +#EXT-SCTE35: CUE="/DAIAAAAAAAAAAAQAAZ/I0VniQAQAgBDVUVJQAAAAH+cAAAAAA==", ID="123", TIME=123.12 +#EXTINF:10.000, +media2.ts diff --git a/sample-playlists/wowza-vod-chunklist.m3u8 b/sample-playlists/wowza-vod-chunklist.m3u8 index f63db071..f739522d 100644 --- a/sample-playlists/wowza-vod-chunklist.m3u8 +++ b/sample-playlists/wowza-vod-chunklist.m3u8 @@ -2,9 +2,9 @@ #EXT-X-VERSION:3 #EXT-X-TARGETDURATION:12 #EXT-X-MEDIA-SEQUENCE:1 -#EXTINF:12.0, +#EXTINF:12.0,Title 1 media-b2000000_1.ts?wowzasessionid=2029972411 -#EXTINF:12.0, +#EXTINF:12.0,Title 2 media-b2000000_2.ts?wowzasessionid=2029972411 #EXTINF:12.0, media-b2000000_3.ts?wowzasessionid=2029972411 diff --git a/structure.go b/structure.go index afa3aa9c..8a8392be 100644 --- a/structure.go +++ b/structure.go @@ -154,11 +154,11 @@ type VariantParams struct { Resolution string Audio string // EXT-X-STREAM-INF only Video string - Subtitles string // EXT-X-STREAM-INF only - 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 - Alternatives []*Alternative + Subtitles string // EXT-X-STREAM-INF only + 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 + Alternatives []*Alternative // EXT-X-MEDIA } // This structure represents EXT-X-MEDIA tag in variants. @@ -188,9 +188,16 @@ type MediaSegment struct { Key *Key // EXT-X-KEY displayed before the segment and means changing of encryption key (in theory each segment may have own key) Map *Map // EXT-X-MAP displayed before the segment 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 // EXT-SCTE35 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 } +type SCTE struct { + Cue string + ID string + Time float64 +} + // This structure represents information about stream encryption. // // Realizes EXT-X-KEY tag. @@ -250,8 +257,8 @@ type decodingState struct { m3u bool tagWV bool tagStreamInf bool - tagIframeStreamInf bool tagInf bool + tagSCTE35 bool tagRange bool tagDiscontinuity bool tagProgramDateTime bool @@ -261,7 +268,10 @@ type decodingState struct { limit int64 offset int64 duration float64 + title string variant *Variant + alternatives []*Alternative xkey *Key xmap *Map + scte *SCTE } diff --git a/writer.go b/writer.go index a4973db2..bd63bbfb 100644 --- a/writer.go +++ b/writer.go @@ -33,6 +33,10 @@ import ( "time" ) +var ( + ErrPlaylistFull = errors.New("playlist is full") +) + // Set version of the playlist accordingly with section 7 func version(ver *uint8, newver uint8) { if *ver < newver { @@ -228,6 +232,17 @@ func (p *MasterPlaylist) Encode() *bytes.Buffer { return &p.buf } +// Version returns the current playlist version number +func (p *MasterPlaylist) Version() uint8 { + return p.ver +} + +// SetVersion sets the playlist version number, note the version maybe changed +// automatically by other Set methods. +func (p *MasterPlaylist) SetVersion(ver uint8) { + p.ver = ver +} + // For compatibility with Stringer interface // For example fmt.Printf("%s", sampleMediaList) will encode // playist and print its string representation. @@ -250,6 +265,14 @@ func NewMediaPlaylist(winsize uint, capacity uint) (*MediaPlaylist, error) { return p, nil } +// last returns the previously written segment's index +func (p *MediaPlaylist) last() uint { + if p.tail == 0 { + return p.capacity - 1 + } + return p.tail - 1 +} + // Remove current segment from the head of chunk slice form a media playlist. Useful for sliding playlists. // This operation does reset playlist cache. func (p *MediaPlaylist) Remove() (err error) { @@ -268,18 +291,24 @@ func (p *MediaPlaylist) Remove() (err error) { // Append general chunk to the tail of chunk slice for a media playlist. // This operation does reset playlist cache. func (p *MediaPlaylist) Append(uri string, duration float64, title string) error { - if p.head == p.tail && p.count > 0 { - return errors.New("playlist is full") - } seg := new(MediaSegment) seg.URI = uri seg.Duration = duration seg.Title = title + return p.AppendSegment(seg) +} + +// 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 + } p.Segments[p.tail] = seg p.tail = (p.tail + 1) % p.capacity p.count++ - if p.TargetDuration < duration { - p.TargetDuration = math.Ceil(duration) + if p.TargetDuration < seg.Duration { + p.TargetDuration = math.Ceil(seg.Duration) } p.buf.Reset() return nil @@ -304,15 +333,10 @@ func (p *MediaPlaylist) ResetCache() { // Generate output in M3U8 format. Marshal `winsize` elements from bottom of the `segments` queue. func (p *MediaPlaylist) Encode() *bytes.Buffer { - var seg *MediaSegment - if p.buf.Len() > 0 { return &p.buf } - if p.SeqNo == 0 { - p.SeqNo = 1 - } p.buf.WriteString("#EXTM3U\n#EXT-X-VERSION:") p.buf.WriteString(strver(p.ver)) p.buf.WriteRune('\n') @@ -441,6 +465,11 @@ func (p *MediaPlaylist) Encode() *bytes.Buffer { } } + var ( + seg *MediaSegment + durationCache = make(map[float64]string) + ) + head := p.head count := p.count for i := uint(0); (i < p.winsize || p.winsize == 0) && count > 0; count-- { @@ -452,6 +481,22 @@ func (p *MediaPlaylist) Encode() *bytes.Buffer { if p.winsize > 0 { // skip for VOD playlists, where winsize = 0 i++ } + if seg.SCTE != nil { + p.buf.WriteString("#EXT-SCTE35:") + p.buf.WriteString("CUE=\"") + p.buf.WriteString(seg.SCTE.Cue) + p.buf.WriteRune('"') + if seg.SCTE.ID != "" { + p.buf.WriteString(",ID=\"") + p.buf.WriteString(seg.SCTE.ID) + p.buf.WriteRune('"') + } + if seg.SCTE.Time != 0 { + p.buf.WriteString(",TIME=") + p.buf.WriteString(strconv.FormatFloat(seg.SCTE.Time, 'f', -1, 64)) + } + p.buf.WriteRune('\n') + } // check for key change if seg.Key != nil && p.Key != seg.Key { p.buf.WriteString("#EXT-X-KEY:") @@ -492,22 +537,27 @@ func (p *MediaPlaylist) Encode() *bytes.Buffer { p.buf.WriteRune('\n') } p.buf.WriteString("#EXTINF:") - if p.durationAsInt { - // Old Android players has problems with non integer Duration. - p.buf.WriteString(strconv.FormatInt(int64(math.Ceil(seg.Duration)), 10)) + if str, ok := durationCache[seg.Duration]; ok { + p.buf.WriteString(str) } else { - // Wowza Mediaserver and some others prefer floats. - p.buf.WriteString(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.WriteRune(',') p.buf.WriteString(seg.Title) - p.buf.WriteString("\n") + p.buf.WriteRune('\n') p.buf.WriteString(seg.URI) if p.Args != "" { p.buf.WriteRune('?') p.buf.WriteString(p.Args) } - p.buf.WriteString("\n") + p.buf.WriteRune('\n') } if p.Closed { p.buf.WriteString("#EXT-X-ENDLIST\n") @@ -551,7 +601,7 @@ func (p *MediaPlaylist) SetDefaultKey(method, uri, iv, keyformat, keyformatversi // 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 != "" { + if keyformat != "" || keyformatversions != "" { version(&p.ver, 5) } p.Key = &Key{method, uri, iv, keyformat, keyformatversions} @@ -583,11 +633,11 @@ func (p *MediaPlaylist) SetKey(method, uri, iv, keyformat, keyformatversions str // 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 != "" { + if keyformat != "" || keyformatversions != "" { version(&p.ver, 5) } - p.Segments[(p.tail-1)%p.capacity].Key = &Key{method, uri, iv, keyformat, keyformatversions} + p.Segments[p.last()].Key = &Key{method, uri, iv, keyformat, keyformatversions} return nil } @@ -597,7 +647,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.tail-1)%p.capacity].Map = &Map{uri, limit, offset} + p.Segments[p.last()].Map = &Map{uri, limit, offset} return nil } @@ -607,8 +657,16 @@ func (p *MediaPlaylist) SetRange(limit, offset int64) error { return errors.New("playlist is empty") } version(&p.ver, 4) // due section 3.4.1 - p.Segments[(p.tail-1)%p.capacity].Limit = limit - p.Segments[(p.tail-1)%p.capacity].Offset = offset + p.Segments[p.last()].Limit = limit + p.Segments[p.last()].Offset = offset + return nil +} + +func (p *MediaPlaylist) SetSCTE(cue string, id string, time float64) error { + if p.count == 0 { + return errors.New("playlist is empty") + } + p.Segments[p.last()].SCTE = &SCTE{cue, id, time} return nil } @@ -620,7 +678,7 @@ func (p *MediaPlaylist) SetDiscontinuity() error { if p.count == 0 { return errors.New("playlist is empty") } - p.Segments[(p.tail-1)%p.capacity].Discontinuity = true + p.Segments[p.last()].Discontinuity = true return nil } @@ -633,6 +691,17 @@ func (p *MediaPlaylist) SetProgramDateTime(value time.Time) error { if p.count == 0 { return errors.New("playlist is empty") } - p.Segments[(p.tail-1)%p.capacity].ProgramDateTime = value + p.Segments[p.last()].ProgramDateTime = value return nil } + +// Version returns the current playlist version number +func (p *MediaPlaylist) Version() uint8 { + return p.ver +} + +// SetVersion sets the playlist version number, note the version maybe changed +// automatically by other Set methods. +func (p *MediaPlaylist) SetVersion(ver uint8) { + p.ver = ver +} diff --git a/writer_test.go b/writer_test.go index 07935b5e..be124dc9 100644 --- a/writer_test.go +++ b/writer_test.go @@ -22,7 +22,9 @@ package m3u8 import ( + "bufio" "fmt" + "os" "strings" "testing" "time" @@ -47,6 +49,20 @@ func TestCreateMediaPlaylistWithWrongSize(t *testing.T) { } } +// Tests the last method on media playlist +func TestLastSegmentMediaPlaylist(t *testing.T) { + p, _ := NewMediaPlaylist(5, 5) + if p.last() != 4 { + t.Errorf("last is %v, expected: 4", p.last()) + } + for i := uint(0); i < 5; i++ { + _ = p.Append("uri.ts", 4, "") + if p.last() != i { + t.Errorf("last is: %v, expected: %v", p.last(), i) + } + } +} + // Create new media playlist // Add two segments to media playlist func TestAddSegmentToMediaPlaylist(t *testing.T) { @@ -54,14 +70,38 @@ func TestAddSegmentToMediaPlaylist(t *testing.T) { if e != nil { t.Fatalf("Create media playlist failed: %s", e) } - e = p.Append("test01.ts", 5.0, "") + e = p.Append("test01.ts", 10.0, "title") if e != nil { t.Errorf("Add 1st segment to a media playlist failed: %s", e) } - e = p.Append("test02.ts", 6.0, "") + if p.Segments[0].URI != "test01.ts" { + t.Errorf("Expected: test01.ts, got: %v", p.Segments[0].URI) + } + if p.Segments[0].Duration != 10 { + t.Errorf("Expected: 10, got: %v", p.Segments[0].Duration) + } + if p.Segments[0].Title != "title" { + t.Errorf("Expected: title, got: %v", p.Segments[0].Title) + } +} + +func TestAppendSegmentToMediaPlaylist(t *testing.T) { + p, _ := NewMediaPlaylist(2, 2) + e := p.AppendSegment(&MediaSegment{Duration: 10}) + if e != nil { + t.Errorf("Add 1st segment to a media playlist failed: %s", e) + } + if p.TargetDuration != 10 { + t.Errorf("Failed to increase TargetDuration, expected: 10, got: %v", p.TargetDuration) + } + e = p.AppendSegment(&MediaSegment{Duration: 10}) if e != nil { t.Errorf("Add 2nd segment to a media playlist failed: %s", e) } + e = p.AppendSegment(&MediaSegment{Duration: 10}) + if e != ErrPlaylistFull { + t.Errorf("Add 3rd expected full error, got: %s", e) + } } // Create new media playlist @@ -155,21 +195,69 @@ func TestOverAddSegmentsToMediaPlaylist(t *testing.T) { } } +// Create new media playlist +// Add segment to media playlist +// Set SCTE +func TestSetSCTEForMediaPlaylist(t *testing.T) { + tests := []struct { + Cue string + ID string + Time float64 + Expected string + }{ + {"CueData1", "", 0, `#EXT-SCTE35:CUE="CueData1"` + "\n"}, + {"CueData2", "ID2", 0, `#EXT-SCTE35:CUE="CueData2",ID="ID2"` + "\n"}, + {"CueData3", "ID3", 3.141, `#EXT-SCTE35:CUE="CueData3",ID="ID3",TIME=3.141` + "\n"}, + {"CueData4", "", 3.1, `#EXT-SCTE35:CUE="CueData4",TIME=3.1` + "\n"}, + {"CueData5", "", 3.0, `#EXT-SCTE35:CUE="CueData5",TIME=3` + "\n"}, + } + + for _, test := range tests { + p, e := NewMediaPlaylist(1, 1) + if e != nil { + t.Fatalf("Create media playlist failed: %s", e) + } + 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.SetSCTE(test.Cue, test.ID, test.Time); e != nil { + t.Errorf("SetSCTE to a media playlist failed: %s", e) + } + if !strings.Contains(p.String(), test.Expected) { + t.Errorf("Test %+v did not contain: %q, playlist: %v", test, test.Expected, p.String()) + } + } +} + // Create new media playlist // Add segment to media playlist // Set encryption key func TestSetKeyForMediaPlaylist(t *testing.T) { - p, e := NewMediaPlaylist(3, 5) - if e != nil { - t.Fatalf("Create media playlist failed: %s", e) - } - e = p.Append("test01.ts", 5.0, "") - if e != nil { - t.Errorf("Add 1st segment to a media playlist failed: %s", e) - } - e = p.SetKey("AES-128", "https://example.com", "iv", "format", "vers") - if e != nil { - t.Errorf("Set key to a media playlist failed: %s", e) + tests := []struct { + KeyFormat string + KeyFormatVersions string + ExpectVersion uint8 + }{ + {"", "", 3}, + {"Format", "", 5}, + {"", "Version", 5}, + {"Format", "Version", 5}, + } + + for _, test := range tests { + p, e := NewMediaPlaylist(3, 5) + if e != nil { + t.Fatalf("Create media playlist failed: %s", e) + } + 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 { + t.Errorf("Set key to a media playlist failed: %s", e) + } + if p.ver != test.ExpectVersion { + t.Errorf("Set key playlist version: %v, expected: %v", p.ver, test.ExpectVersion) + } } } @@ -177,27 +265,29 @@ func TestSetKeyForMediaPlaylist(t *testing.T) { // Add segment to media playlist // Set encryption key func TestSetDefaultKeyForMediaPlaylist(t *testing.T) { - p, e := NewMediaPlaylist(3, 5) - if e != nil { - t.Fatalf("Create media playlist failed: %s", e) - } - e = p.SetDefaultKey("AES-128", "https://example.com", "iv", "", "") - if e != nil { - t.Errorf("Set default key to a media playlist failed: %s", e) - } - if p.ver != 3 { - t.Errorf("SetDefaultKey to a media playlist changed version unnecessarily") - } - - // Test that using V5 features updates EXT-X-VERSION - e = p.SetDefaultKey("AES-128", "https://example.com", "iv", "format", "vers") - if e != nil { - t.Errorf("Set key to a media playlist failed: %s", e) - } - if p.ver != 5 { - t.Errorf("SetDefaultKey did not update version") + tests := []struct { + KeyFormat string + KeyFormatVersions string + ExpectVersion uint8 + }{ + {"", "", 3}, + {"Format", "", 5}, + {"", "Version", 5}, + {"Format", "Version", 5}, + } + + for _, test := range tests { + p, e := NewMediaPlaylist(3, 5) + 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 { + t.Errorf("Set key to a media playlist failed: %s", e) + } + if p.ver != test.ExpectVersion { + t.Errorf("Set key playlist version: %v, expected: %v", p.ver, test.ExpectVersion) + } } - } // Create new media playlist @@ -252,48 +342,31 @@ func TestLoopSegmentsOfMediaPlaylist(t *testing.T) { //fmt.Println(p.Encode().String()) } -// Create new media playlist with capacity 30 -// Add 10 segments to media playlist -// Add encryption key -// Add another 10 segments to media playlist -// Add new encryption key -// Add another 10 segments to media playlist -// Iterate over segments +// Create new media playlist with capacity 5 +// Add 5 segments and 5 unique keys +// Test correct keys set on correct segments func TestEncryptionKeysInMediaPlaylist(t *testing.T) { - // Create new media playlist with capacity 30 - p, e := NewMediaPlaylist(5, 15) - if e != nil { - t.Fatalf("Create media playlist failed: %s", e) - } - // Add 10 segments to media playlist - for i := 0; i < 5; i++ { - e = p.Append(fmt.Sprintf("test0-%d.ts", i), 6.0, "") - if e != nil { - t.Errorf("Add segment #%d to a media playlist failed: %s", i, e) + p, _ := NewMediaPlaylist(5, 5) + // Add 5 segments and set custom encryption key + for i := uint(0); i < 5; i++ { + uri := fmt.Sprintf("uri-%d", i) + expected := &Key{ + Method: "AES-128", + URI: uri, + IV: fmt.Sprintf("%d", i), + Keyformat: "identity", + Keyformatversions: "1", } - } - // Add encryption key - p.SetKey("AES-128", "https://example.com/", "0X00000000000000000000000000000000", "key-format1", "version x.x") - // Add 10 segments to media playlist - for i := 0; i < 5; i++ { - e = p.Append(fmt.Sprintf("test1-%d.ts", i), 6.0, "") - if e != nil { - t.Errorf("Add segment #%d to a media playlist failed: %s", i, e) + _ = p.Append(uri+".ts", 4, "") + _ = p.SetKey(expected.Method, expected.URI, expected.IV, expected.Keyformat, expected.Keyformatversions) + + if p.Segments[i].Key == nil { + t.Fatalf("Key was not set on segment %v", i) } - } - // Add encryption key - p.SetKey("AES-128", "https://example.com/", "0X00000000000000000000000000000001", "key-format2", "version x.x") - // Add 10 segments to media playlist - for i := 0; i < 5; i++ { - e = p.Append(fmt.Sprintf("test2-%d.ts", i), 6.0, "") - if e != nil { - t.Errorf("Add segment #%d to a media playlist failed: %s", i, e) + if *p.Segments[i].Key != *expected { + t.Errorf("Key %+v does not match expected %+v", p.Segments[i].Key, expected) } } - for i := 0; i < 3; i++ { - //fmt.Printf("Iteration %d:\n%s\n", i, p.Encode().String()) - p.Remove() - } } // Create new media playlist @@ -363,6 +436,23 @@ func TestClosedMediaPlaylist(t *testing.T) { p.Close() } +func TestMediaVersion(t *testing.T) { + m, _ := NewMediaPlaylist(3, 3) + m.ver = 5 + if m.Version() != m.ver { + t.Errorf("Expected version: %v, got: %v", m.ver, m.Version()) + } +} + +func TestMediaSetVersion(t *testing.T) { + m, _ := NewMediaPlaylist(3, 3) + m.ver = 3 + m.SetVersion(5) + if m.ver != 5 { + t.Errorf("Expected version: %v, got: %v", 5, m.ver) + } +} + // Create new master playlist without params // Add media playlist func TestNewMasterPlaylist(t *testing.T) { @@ -492,6 +582,23 @@ func TestEncodeMasterPlaylistWithStreamInfName(t *testing.T) { } } +func TestMasterVersion(t *testing.T) { + m := NewMasterPlaylist() + m.ver = 5 + if m.Version() != m.ver { + t.Errorf("Expected version: %v, got: %v", m.ver, m.Version()) + } +} + +func TestMasterSetVersion(t *testing.T) { + m := NewMasterPlaylist() + m.ver = 3 + m.SetVersion(5) + if m.ver != 5 { + t.Errorf("Expected version: %v, got: %v", 5, m.ver) + } +} + /****************************** * Code generation examples * ******************************/ @@ -507,7 +614,7 @@ func ExampleMediaPlaylist_String() { // Output: // #EXTM3U // #EXT-X-VERSION:3 - // #EXT-X-MEDIA-SEQUENCE:1 + // #EXT-X-MEDIA-SEQUENCE:0 // #EXT-X-TARGETDURATION:6 // #EXTINF:5.000, // test01.ts @@ -533,3 +640,40 @@ func ExampleMasterPlaylist_String() { // #EXT-X-STREAM-INF:PROGRAM-ID=123,BANDWIDTH=1500000,RESOLUTION=576x480 // chunklist2.m3u8 } + +/**************** + * Benchmarks * + ****************/ + +func BenchmarkEncodeMasterPlaylist(b *testing.B) { + f, err := os.Open("sample-playlists/master.m3u8") + if err != nil { + b.Fatal(err) + } + p := NewMasterPlaylist() + if err := p.DecodeFrom(bufio.NewReader(f), true); err != nil { + b.Fatal(err) + } + for i := 0; i < b.N; i++ { + p.ResetCache() + _ = p.Encode() // disregard output + } +} + +func BenchmarkEncodeMediaPlaylist(b *testing.B) { + f, err := os.Open("sample-playlists/wowza-vod-chunklist.m3u8") + if err != nil { + b.Fatal(err) + } + p, err := NewMediaPlaylist(50000, 50000) + if err != nil { + b.Fatalf("Create media playlist failed: %s", err) + } + if err = p.DecodeFrom(bufio.NewReader(f), true); err != nil { + b.Fatal(err) + } + for i := 0; i < b.N; i++ { + p.ResetCache() + _ = p.Encode() // disregard output + } +}