├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── dash ├── decode.go ├── decode_test.go ├── doc.go ├── encode.go ├── encode_test.go ├── structure.go ├── testdata │ ├── dynamic.mpd │ ├── encrypted.mpd │ ├── eventmessage.mpd │ ├── multipleperiods.mpd │ ├── static.mpd │ ├── static2.mpd │ └── trickplay.mpd ├── types.go ├── util.go └── util_test.go ├── hls ├── decode-util.go ├── decode-util_test.go ├── decode.go ├── doc.go ├── encode-util.go ├── encode-util_test.go ├── encode.go ├── integration_test.go ├── source │ ├── http.go │ └── http_test.go ├── testdata │ ├── apple-ios5-macOS10_7.m3u8 │ ├── apple-ios6-tvOS9.m3u8 │ ├── master │ │ ├── fixture-v7-media-service-fail.m3u8 │ │ └── fixture-v7.m3u8 │ ├── masterp.m3u8 │ ├── media │ │ ├── fixture-v3-fail.m3u8 │ │ ├── fixture-v3.m3u8 │ │ ├── fixture-v4-byterange-fail.m3u8 │ │ ├── fixture-v4-iframes-fail.m3u8 │ │ ├── fixture-v4.m3u8 │ │ ├── fixture-v5-keyformat-fail.m3u8 │ │ ├── fixture-v5-map-fail.m3u8 │ │ ├── fixture-v5.m3u8 │ │ ├── fixture-v6-map-no-iframes-fail.m3u8 │ │ └── fixture-v6.m3u8 │ └── mediap.m3u8 ├── types-master.go ├── types-media.go ├── types-segment.go └── types.go ├── manifest.go └── types.go /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | *.exe 21 | *.test 22 | *.prof 23 | 24 | #Ignore VIM 25 | .lvimrc 26 | *.go.swp 27 | 28 | #Ignore Mac 29 | .DS_Store 30 | 31 | #Ignore coverage profiles 32 | *.coverprofile 33 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | go: 3 | - 1.7.x 4 | - 1.8.x 5 | script: go test -race -v ./... 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2016 REDspace Inc. 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Manifest 2 | 3 | Manifest is a library for parsing and creating video manifest files. 4 | 5 | ## Usage 6 | 7 | See [godoc](https://godoc.org/github.com/ingest/manifest). 8 | 9 | ### Features 10 | 11 | * Complete HLS compliance upto version 7, defined in the _April 4 2016_ [specification](https://tools.ietf.org/html/draft-pantos-http-live-streaming-19) 12 | 13 | ### In-progress 14 | 15 | * DASH is currently not running in production, follow along and help us guide the creation! 16 | 17 | ## Motivation 18 | 19 | Ingest as a organization makes use of lots of open-source software. We initially worked and used [grafov/m3u8](https://github.com/grafov/m3u8) for parsing HLS media playlists but found that we quickly were outpacing the scope of the library. As our roadmap grew and required future HLS version support, and other manifest formats such as DASH, we felt that it would be best to move the development in-house. -------------------------------------------------------------------------------- /dash/decode.go: -------------------------------------------------------------------------------- 1 | package dash 2 | 3 | import ( 4 | "encoding/xml" 5 | "io" 6 | ) 7 | 8 | //Parse decodes a MPD file into an MPD element. 9 | //It validates MPD according to specs and returns an error if validation fails. 10 | func (m *MPD) Parse(reader io.Reader) error { 11 | err := xml.NewDecoder(reader).Decode(&m) 12 | if err != nil { 13 | return err 14 | } 15 | 16 | return m.validate() 17 | } 18 | -------------------------------------------------------------------------------- /dash/decode_test.go: -------------------------------------------------------------------------------- 1 | package dash 2 | 3 | import ( 4 | "bufio" 5 | "os" 6 | "reflect" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | func TestStaticParse(t *testing.T) { 12 | f, err := os.Open("./testdata/static.mpd") 13 | if err != nil { 14 | t.Fatal(err) 15 | } 16 | defer f.Close() 17 | 18 | mpd := &MPD{} 19 | if err := mpd.Parse(bufio.NewReader(f)); err != nil { 20 | t.Fatal(err) 21 | } 22 | 23 | if len(mpd.Periods) != 3 { 24 | t.Errorf("Expecting 3 Period elements, but got %d", len(mpd.Periods)) 25 | } 26 | 27 | if len(mpd.Periods[0].AdaptationSets) != 2 { 28 | t.Errorf("Expecting 2 AdaptationSet element on first Period, but got %d", len(mpd.Periods[0].AdaptationSets)) 29 | } 30 | } 31 | 32 | func TestDynamicParse(t *testing.T) { 33 | f, err := os.Open("./testdata/dynamic.mpd") 34 | if err != nil { 35 | t.Fatal(err) 36 | } 37 | defer f.Close() 38 | 39 | mpd := &MPD{} 40 | 41 | if err := mpd.Parse(bufio.NewReader(f)); err != nil { 42 | t.Fatal(err) 43 | } 44 | 45 | pt, _ := time.Parse(time.RFC3339Nano, "2013-08-10T22:03:00Z") 46 | if !reflect.DeepEqual(mpd.PublishTime.Time, pt) { 47 | t.Errorf("Expected PublishTime %v, but got %v", pt, mpd.PublishTime) 48 | } 49 | d, _ := time.ParseDuration("0h10m54.00s") 50 | if !reflect.DeepEqual(mpd.MediaPresDuration.Duration, d) { 51 | t.Errorf("Expecting MediaPresDuration to be %v, but got %v", d, mpd.MediaPresDuration) 52 | } 53 | 54 | if len(mpd.Periods) != 1 { 55 | t.Errorf("Expecting 1 Period element, but got %d", len(mpd.Periods)) 56 | } 57 | 58 | if len(mpd.Periods[0].AdaptationSets) != 2 { 59 | t.Errorf("Expecting 2 AdaptationSets, but got %d", len(mpd.Periods[0].AdaptationSets)) 60 | } 61 | 62 | if len(mpd.Periods[0].AdaptationSets[0].Representations) != 3 { 63 | t.Errorf("Expecting 3 Representations of AdaptationSets[0], but got %d", len(mpd.Periods[0].AdaptationSets[0].Representations)) 64 | } 65 | 66 | if len(mpd.Periods[0].AdaptationSets[1].Representations) != 1 { 67 | t.Errorf("Expecting 3 Representations of AdaptationSets[1], but got %d", len(mpd.Periods[0].AdaptationSets[1].Representations)) 68 | } 69 | 70 | if len(mpd.Periods[0].AdaptationSets[1].Representations[0].AudioChannelConfig) != 1 { 71 | t.Errorf("Expecting 1 AudioChannelConfig, but got %d", len(mpd.Periods[0].AdaptationSets[1].Representations[0].AudioChannelConfig)) 72 | } 73 | } 74 | 75 | func TestEventMessage(t *testing.T) { 76 | f, err := os.Open("./testdata/eventmessage.mpd") 77 | if err != nil { 78 | t.Fatal(err) 79 | } 80 | defer f.Close() 81 | 82 | mpd := &MPD{} 83 | if err := mpd.Parse(bufio.NewReader(f)); err != nil { 84 | t.Fatal(err) 85 | } 86 | event := mpd.Periods[0].EventStream[0] 87 | if event.SchemeIDURI != "urn:uuid:XYZY" { 88 | t.Errorf("Expecting SchemeIdURI urn:uuid:XYZY, but got %s", event.SchemeIDURI) 89 | } 90 | for i, e := range event.Event { 91 | if e.Message == "" { 92 | t.Errorf("Expecting Message to not be empty.") 93 | } 94 | if e.Duration != 10000 { 95 | t.Errorf("Expecting Duration to be 10000, but got %d", e.Duration) 96 | } 97 | if e.ID != i { 98 | t.Errorf("Expecting ID to be %d, but got %d", i, e.ID) 99 | } 100 | } 101 | 102 | rep := mpd.Periods[0].AdaptationSets[1].Representations[0] 103 | for i, ie := range rep.InbandEventStream { 104 | if ie.SchemeIDURI == "" { 105 | t.Errorf("Expecting %d SchemeIdURI to be set.", i) 106 | } 107 | if ie.Value == "" { 108 | t.Errorf("Expecting %d Value to be set.", i) 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /dash/doc.go: -------------------------------------------------------------------------------- 1 | //Package dash implements the Manifest interface to encode/parse MPEG DASH MPD files. 2 | // 3 | // 4 | //Usage: 5 | // import ( 6 | // "bufio" 7 | // "bytes" 8 | // "io/ioutil" 9 | // "os" 10 | // "time" 11 | // 12 | // "github.com/ingest/manifest/dash" 13 | // ) 14 | // 15 | // func main() { 16 | // //initiate a new MPD with profile and minBufferTime 17 | // mpd := dash.NewMPD("urn:mpeg:dash:profile:isoff-live:2011", time.Second*2) 18 | // period := &dash.Period{ 19 | // AdaptationSets: dash.AdaptationSets{ 20 | // &dash.AdaptationSet{SegmentAlignment: true, 21 | // MaxWidth: 1280, 22 | // MaxHeight: 720, 23 | // MaxFrameRate: "24", 24 | // Representations: dash.Representations{&dash.Representation{ 25 | // ID: "1", MimeType: "video/mp4", Codecs: "avc1.4d01f", Width: 1280, Height: 720, 26 | // FrameRate: "24", StartWithSAP: 1, Bandwidth: 980104, 27 | // SegmentTemplate: &dash.SegmentTemplate{ 28 | // Timescale: 12288, 29 | // Duration: 24576, 30 | // Media: "video_$Number$.mp4"}}, 31 | // }}, 32 | // &dash.AdaptationSet{SegmentAlignment: true, 33 | // Representations: dash.Representations{&dash.Representation{ 34 | // ID: "1", MimeType: "audio/mp4", Codecs: "mp4a.40.29", AudioSamplingRate: "48000", 35 | // StartWithSAP: 1, Bandwidth: 33434, 36 | // AudioChannelConfig: []*dash.Descriptor{ 37 | // &dash.Descriptor{SchemeIDURI: "audio_channel_configuration:2011", Value: "2"}}, 38 | // SegmentTemplate: &dash.SegmentTemplate{ 39 | // Timescale: 48000, 40 | // Duration: 94175, 41 | // Media: "audio_$Number$.mp4", 42 | // InitializationAttr: "BBB_32k_init.mp4"}}, 43 | // }}, 44 | // }} 45 | // mpd.Periods = append(mpd.Periods, period) 46 | // 47 | // reader, err := mpd.Encode() 48 | // if err != nil { 49 | // panic(err) 50 | // } 51 | // 52 | // buf := new(bytes.Buffer) 53 | // buf.ReadFrom(reader) 54 | // 55 | // if err := ioutil.WriteFile("./output.mpd", buf.Bytes(), 0666); err != nil { 56 | // panic(err) 57 | // } 58 | // 59 | // f , err := os.Open("./output.mpd") 60 | // if err != nil { 61 | // panic(err) 62 | // } 63 | // defer f.Close() 64 | // 65 | // newMPD := &dash.MPD{} 66 | // if err := newMPD.Parse(bufio.NewReader(f)); err != nil { 67 | // panic(err) 68 | // } 69 | // //manipulate playlist. Ex: add encryption 70 | // cp := dash.NewContentProtection("mp4.urn.test", "cenc", "", "", "") 71 | // cp2 := dash.NewContentProtection("mp4.urn.test", "cenc", "1234", "psshhashstring", "") 72 | // cp3 := dash.NewContentProtection("playready.uri", "MSPR 2.0", "", "", "msprhashstring") 73 | // cp4 := dash.NewContentProtection("playready.uri", "MSPR 2.0", "", "", "") 74 | // cp4.SetTrackEncryptionBox(8, "16bytekeyidentifier") 75 | // newMPD.Periods[0].AdaptationSets[0].CENCContentProtections = 76 | // append(newMPD.Periods[0].AdaptationSets[0].CENCContentProtections, cp, cp2, cp3, cp4) 77 | // 78 | // newReader, err := newMPD.Encode() 79 | // if err != nil { 80 | // panic(err) 81 | // } 82 | // 83 | // newBuf := new(bytes.Buffer) 84 | // newBuf.ReadFrom(newReader) 85 | // 86 | // if err := ioutil.WriteFile("./newOutput.mpd", newBuf.Bytes(), 0666); err != nil { 87 | // panic(err) 88 | // } 89 | // } 90 | package dash 91 | -------------------------------------------------------------------------------- /dash/encode.go: -------------------------------------------------------------------------------- 1 | package dash 2 | 3 | import ( 4 | "bytes" 5 | "encoding/xml" 6 | "io" 7 | 8 | "github.com/ingest/manifest" 9 | ) 10 | 11 | //Encode marshals an MPD structure into an MPD XML structure. 12 | func (m *MPD) Encode() (io.Reader, error) { 13 | //Validates MPD structure according to specs before encoding 14 | if err := m.validate(); err != nil { 15 | return nil, err 16 | } 17 | 18 | output, err := xml.MarshalIndent(m, "", " ") 19 | if err != nil { 20 | return nil, err 21 | } 22 | 23 | buf := manifest.NewBufWrapper() 24 | buf.WriteString(xml.Header) 25 | buf.Write(output) 26 | 27 | return bytes.NewReader(buf.Buf.Bytes()), buf.Err 28 | } 29 | -------------------------------------------------------------------------------- /dash/encode_test.go: -------------------------------------------------------------------------------- 1 | package dash 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "os" 7 | "reflect" 8 | "testing" 9 | "time" 10 | ) 11 | 12 | func getDefaultMPD() *MPD { 13 | mpd := NewMPD("urn:mpeg:dash:profile:isoff-live:2011", time.Second*2) 14 | period := &Period{ 15 | AdaptationSets: AdaptationSets{ 16 | &AdaptationSet{ 17 | MaxWidth: 1280, 18 | MaxHeight: 720, 19 | Representations: Representations{&Representation{ 20 | ID: "1", MimeType: "video/mp4", Codecs: "avc1.4d01f", Width: 1280, Height: 720, 21 | FrameRate: "24", Bandwidth: 980104, 22 | SegmentTemplate: &SegmentTemplate{ 23 | Timescale: 12288, 24 | Duration: 24576, 25 | Media: "video_$Number$.mp4"}}, 26 | }, 27 | }, 28 | }} 29 | mpd.Periods = append(mpd.Periods, period) 30 | return mpd 31 | } 32 | 33 | func TestEncode(t *testing.T) { 34 | tests := []struct { 35 | Case string 36 | File string 37 | }{ 38 | {"Static MPD", "./testdata/static.mpd"}, 39 | {"Static2 MPD", "./testdata/static2.mpd"}, 40 | {"Dynamic MPD", "./testdata/dynamic.mpd"}, 41 | {"Encrypted MPD", "./testdata/encrypted.mpd"}, 42 | {"Event Message MPD", "./testdata/eventmessage.mpd"}, 43 | {"Multiple Periods MPD", "./testdata/multipleperiods.mpd"}, 44 | {"Trick Play MPD", "./testdata/trickplay.mpd"}, 45 | } 46 | 47 | for _, tt := range tests { 48 | f, err := os.Open(tt.File) 49 | if err != nil { 50 | t.Fatal(err) 51 | } 52 | defer f.Close() 53 | //Parse from file 54 | m := &MPD{} 55 | if err = m.Parse(bufio.NewReader(f)); err != nil { 56 | t.Fatalf("%s - %s", tt.Case, err) 57 | } 58 | 59 | //Encode from m struct 60 | o, err := m.Encode() 61 | if err != nil { 62 | t.Fatalf("%s - %s", tt.Case, err) 63 | } 64 | 65 | //Parse from previous encoded result into new struct 66 | newM := &MPD{} 67 | newM.Parse(o) 68 | 69 | //Both structs must be the same 70 | if !reflect.DeepEqual(m, newM) { 71 | t.Errorf("Case: %s - Expected newM:\n %v \n but got: \n%v", tt.Case, m, newM) 72 | } 73 | } 74 | } 75 | 76 | func TestContentProtection(t *testing.T) { 77 | mpd := getDefaultMPD() 78 | 79 | cp := NewContentProtection("test.uri", "cenc", "somehash", "", "") 80 | mpd.Periods[0].AdaptationSets[0].CENCContentProtections = append(mpd.Periods[0].AdaptationSets[0].CENCContentProtections, cp) 81 | 82 | o, err := mpd.Encode() 83 | if err != nil { 84 | t.Fatal(err) 85 | } 86 | 87 | expect := ` 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | ` 98 | 99 | buf := new(bytes.Buffer) 100 | buf.ReadFrom(o) 101 | 102 | if expect != buf.String() { 103 | t.Errorf("Expecting:\n%s \n but got \n%s", expect, buf.String()) 104 | } 105 | } 106 | 107 | func TestContentProtectionPlayready(t *testing.T) { 108 | mpd := getDefaultMPD() 109 | 110 | cp := NewContentProtection("test.uri", "cenc", "somehash", "psshhash", "msprhash") 111 | mpd.Periods[0].AdaptationSets[0].CENCContentProtections = append(mpd.Periods[0].AdaptationSets[0].CENCContentProtections, cp) 112 | 113 | o, err := mpd.Encode() 114 | if err != nil { 115 | t.Fatal(err) 116 | } 117 | 118 | expect := ` 119 | 120 | 121 | 122 | 123 | psshhash 124 | msprhash 125 | 126 | 127 | 128 | 129 | 130 | 131 | ` 132 | 133 | buf := new(bytes.Buffer) 134 | buf.ReadFrom(o) 135 | 136 | if expect != buf.String() { 137 | t.Errorf("Expecting:\n%s \n but got \n%s", expect, buf.String()) 138 | } 139 | } 140 | 141 | func TestContentProtectionTrackEncryption(t *testing.T) { 142 | mpd := getDefaultMPD() 143 | 144 | cp := NewContentProtection("test.uri", "cenc", "", "", "") 145 | cp.SetTrackEncryptionBox(8, "kidhexacode") 146 | mpd.Periods[0].AdaptationSets[0].CENCContentProtections = append(mpd.Periods[0].AdaptationSets[0].CENCContentProtections, cp) 147 | 148 | o, err := mpd.Encode() 149 | if err != nil { 150 | t.Fatal(err) 151 | } 152 | 153 | expect := ` 154 | 155 | 156 | 157 | 158 | 1 159 | 8 160 | kidhexacode 161 | 162 | 163 | 164 | 165 | 166 | 167 | ` 168 | 169 | buf := new(bytes.Buffer) 170 | buf.ReadFrom(o) 171 | 172 | if expect != buf.String() { 173 | t.Errorf("Expecting:\n%s \n but got \n%s", expect, buf.String()) 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /dash/structure.go: -------------------------------------------------------------------------------- 1 | package dash 2 | 3 | import "encoding/xml" 4 | 5 | const ( 6 | dashNS = "urn:mpeg:dash:schema:mpd:2011" 7 | cencNS = "urn:mpeg:cenc:2013" 8 | msprNS = "urn:microsoft:playready" 9 | ) 10 | 11 | //MPD represents a Media Presentation Description. 12 | type MPD struct { 13 | XMLNS string `xml:"xmlns,attr,omitempty"` 14 | SchemaLocation string `xml:"http://www.w3.org/2001/XMLSchema-instance schemaLocation,attr,omitempty"` 15 | ID string `xml:"id,attr,omitempty"` //Optional. 16 | Profiles string `xml:"profiles,attr,omitempty"` //Required 17 | Type string `xml:"type,attr,omitempty"` //Optional. Default:"static". Possible Values: static, dynamic 18 | PublishTime *CustomTime `xml:"publishTime,attr,omitempty"` //Must be present for type "dynamic". 19 | AvStartTime *CustomTime `xml:"availabilityStartTime,attr,omitempty"` //Must be present for type "dynamic". In UTC 20 | AvEndTime *CustomTime `xml:"availabilityEndTime,attr,omitempty"` //Optional 21 | MediaPresDuration *CustomDuration `xml:"mediaPresentationDuration,attr,omitempty"` //Optional. Shall be present if MinUpdatePeriod and Period.Duration aren't set. 22 | MinUpdatePeriod *CustomDuration `xml:"minimumUpdatePeriod,attr,omitempty"` //Optional. Must not be present for type "static". Specifies the frequency in which clients must check for updates. 23 | MinBufferTime *CustomDuration `xml:"minBufferTime,attr,omitempty"` //Required. 24 | TimeShiftBuffer *CustomDuration `xml:"timeShiftBufferDepth,attr,omitempty"` //Optional for type "dynamic". If type "static", value is undefined. 25 | SuggestedPresDelay *CustomDuration `xml:"suggestedPresentationDelay,attr,omitempty"` //Optional for type "dynamic". If type "static", value is undefined. 26 | MaxSegmentDuration *CustomDuration `xml:"maxSegmentDuration,attr,omitempty"` //Optional. 27 | MaxSubsegmentDuration *CustomDuration `xml:"maxSubsegmentDuration,attr,omitempty"` //Optional. 28 | ProgramInformation []*ProgramInformation `xml:"ProgramInformation,omitempty"` 29 | BaseURL []*BaseURL `xml:"BaseURL,omitempty"` 30 | Location []string `xml:"Location,omitempty"` 31 | Metrics []*Metrics `xml:"Metrics,omitempty"` 32 | Periods Periods `xml:"Period,omitempty"` 33 | } 34 | 35 | //ProgramInformation specifies descriptive information about the program 36 | type ProgramInformation struct { 37 | Lang string `xml:"lang,attr,omitempty"` //Optional 38 | MoreInformationURL string `xml:"moreInformationURL,attr,omitempty"` //Optional 39 | Title string `xml:"Title,omitempty"` //Optional 40 | Source string `xml:"Source,omitempty"` //Optional 41 | Copyright string `xml:"Copyright,omitempty"` //Optional 42 | } 43 | 44 | //BaseURL can be used for reference resolution and alternative URL selection. 45 | type BaseURL struct { 46 | URL string `xml:",innerxml"` 47 | ServiceLocation string `xml:"serviceLocation,attr,omitempty"` //Optional 48 | ByteRange string `xml:"byteRange,attr,omitempty"` // 49 | AvTimeOffset float64 `xml:"availabilityTimeOffset,attr,omitempty"` 50 | AvTimeComplete bool `xml:"availabilityTimeComplete,attr,omitempty"` 51 | } 52 | 53 | //Metrics ... 54 | type Metrics struct { 55 | Metrics string `xml:"metrics,attr,omitempty"` //Required 56 | Range []*Range `xml:"Range,omitempty"` //Optional 57 | Reporting []*Descriptor `xml:"Reporting,omitempty"` //Required 58 | } 59 | 60 | //Range ... 61 | type Range struct { 62 | StartTime float64 `xml:"starttime,attr,omitempty"` 63 | Duration float64 `xml:"duration,attr,omitempty"` 64 | } 65 | 66 | //Descriptor ... 67 | type Descriptor struct { 68 | SchemeIDURI string `xml:"schemeIdUri,attr,omitempty"` 69 | Value string `xml:"value,attr,omitempty"` 70 | ID string `xml:"id,attr,omitempty"` 71 | } 72 | 73 | //Period represents a media content period. 74 | type Period struct { 75 | XlinkHref string `xml:"http://www.w3.org/1999/xlink href,attr,omitempty"` //Optional 76 | XlinkActuate string `xml:"http://www.w3.org/1999/xlink actuate,attr,omitempty"` //Optional. Possible Values: onDemand, onRequest 77 | ID string `xml:"id,attr,omitempty"` //Optional. Must be unique. If type "dynamic", id must be present and not updated. 78 | Start *CustomDuration `xml:"start,attr,omitempty"` //Optional. Used as anchor to determine the start of each Media Segment. 79 | Duration *CustomDuration `xml:"duration,attr,omitempty"` //Optional. Determine the Start time of next Period. 80 | BitstreamSwitching bool `xml:"bitstreamSwitching,attr,omitempty"` //Optional. Default: false. If 'true', means that every AdaptationSet.BitstreamSwitching is set to 'true'. TODO: check if there's 'false' on AdaptationSet 81 | BaseURL []*BaseURL `xml:"BaseURL,omitempty"` //Optional 82 | SegmentBase *SegmentBase `xml:"SegmentBase,omitempty"` //Optional. Default Segment Base information. Overidden by AdaptationSet.SegmentBase and Representation.SegmentBase 83 | SegmentList *SegmentList `xml:"SegmentList,omitempty"` 84 | SegmentTemplate *SegmentTemplate `xml:"SegmentTemplate,omitempty"` 85 | AssetIdentifier *Descriptor `xml:"AssetIdentifier,omitempty"` 86 | EventStream []*EventStream `xml:"EventStream,omitempty"` 87 | AdaptationSets `xml:"AdaptationSet,omitempty"` 88 | Subsets `xml:"Subset,omitempty"` 89 | } 90 | 91 | //EventStream represents a sequence of related events. 92 | type EventStream struct { 93 | XlinkHref string `xml:"http://www.w3.org/1999/xlink href,attr,omitempty"` 94 | XlinkActuate string `xml:"http://www.w3.org/1999/xlink actuate,attr,omitempty"` 95 | SchemeIDURI string `xml:"schemeIdUri,attr,omitempty"` 96 | Value string `xml:"value,attr,omitempty"` 97 | Timescale int `xml:"timescale,attr,omitempty"` 98 | Event []*Event `xml:"Event,omitempty"` 99 | } 100 | 101 | //Event represents aperiodic sparse media-time related auxiliary information to DASH 102 | //client or an application. 103 | type Event struct { 104 | Message string `xml:",innerxml"` 105 | PresTime int64 `xml:"presentationTime,attr,omitempty"` 106 | Duration int64 `xml:"duration,attr,omitempty"` 107 | ID int `xml:"id,attr,omitempty"` 108 | } 109 | 110 | //URLType ... 111 | type URLType struct { 112 | SourceURL string `xml:"sourceURL,attr,omitempty"` 113 | Range string `xml:"range,attr,omitempty"` 114 | } 115 | 116 | //SegmentBase represents a media file played by a DASH client 117 | type SegmentBase struct { 118 | Timescale int `xml:"timescale,attr,omitempty"` //Optional. If not present, it must be set to 1. 119 | PresTimeOffset int64 `xml:"presentationTimeOffset,attr,omitempty"` //Optional. 120 | TimeShiftBuffer *CustomDuration `xml:"timeShiftBufferDepth,attr,omitempty"` //Optional. 121 | IndexRange string `xml:"indexRange,attr,omitempty"` //Optional. ByteRange that contains the Segment Index in all Segments of the Representation. 122 | IndexRangeExact bool `xml:"indexRangeExact,attr,omitempty"` //Default: false. Must not be present if IndexRange isn't present. 123 | AvTimeOffset float64 `xml:"availabilityTimeOffset,attr,omitempty"` //Optional. 124 | AvTmeComplete bool `xml:"availabilityTimeComplete,attr,omitempty"` //Optional. 125 | Initialization *URLType `xml:"Initialization,omitempty"` 126 | RepresentationIndex *URLType `xml:"RepresentationIndex,omitempty"` 127 | } 128 | 129 | //SegmentList contains a list of SegmentURL elements. 130 | type SegmentList struct { 131 | XlinkHref string `xml:"http://www.w3.org/1999/xlink href,attr,omitempty"` 132 | XlinkActuate string `xml:"http://www.w3.org/1999/xlink actuate,attr,omitempty"` 133 | Timescale int `xml:"timescale,attr,omitempty"` //Optional. . If not present, it must be set to 1. 134 | PresTimeOffset int64 `xml:"presentationTimeOffset,attr,omitempty"` //Optional. 135 | TimeShiftBuffer *CustomDuration `xml:"timeShiftBufferDepth,attr,omitempty"` //Optional. 136 | IndexRange string `xml:"indexRange,attr,omitempty"` //Optional. ByteRange that contains the Segment Index in all Segments of the Representation. 137 | IndexRangeExact bool `xml:"indexRangeExact,attr,omitempty"` //Default: false. Must not be present if IndexRange isn't present. 138 | AvTimeOffset float64 `xml:"availabilityTimeOffset,attr,omitempty"` //Optional. 139 | AvTmeComplete bool `xml:"availabilityTimeComplete,attr,omitempty"` //Optional. 140 | Duration int `xml:"duration,attr,omitempty"` 141 | StartNumber int `xml:"startNumber,attr,omitempty"` 142 | Initialization *URLType `xml:"Initialization,omitempty"` 143 | RepresentationIndex *URLType `xml:"RepresentationIndex,omitempty"` 144 | SegmentTimeline *SegmentTimeline `xml:"SegmentTimeline,omitempty"` 145 | BitstreamSwitching *URLType `xml:"BitstreamSwitching,omitempty"` 146 | SegmentURLs []*SegmentURL `xml:"SegmentURL,omitempty"` 147 | } 148 | 149 | //SegmentURL may contain the Media Segment URL. 150 | type SegmentURL struct { 151 | Media string `xml:"media,attr,omitempty"` //Optional. Combined with MediaRange, specifies HTTP-URL for Media Segment. If not present, MediaRange must be present and it's combined with BaseURL 152 | MediaRange string `xml:"mediaRange,attr,omitempty"` //Optional. If not present, Media Segment is the entire resource in Media 153 | Index string `xml:"index,attr,omitempty"` //Optional. 154 | IndexRange string `xml:"indexRange,attr,omitempty"` 155 | } 156 | 157 | //SegmentTemplate specifies identifiers that are substituted by dynamic values assigned 158 | //to Segments, to create a list of Segments. 159 | type SegmentTemplate struct { 160 | Timescale int `xml:"timescale,attr,omitempty"` //Optional. If not present, it must be set to 1. 161 | PresTimeOffset int64 `xml:"presentationTimeOffset,attr,omitempty"` //Optional. 162 | TimeShiftBuffer *CustomDuration `xml:"timeShiftBufferDepth,attr,omitempty"` //Optional. 163 | IndexRange string `xml:"indexRange,attr,omitempty"` //Optional. ByteRange that contains the Segment Index in all Segments of the Representation. 164 | IndexRangeExact bool `xml:"indexRangeExact,attr,omitempty"` //Default: false. Must not be present if IndexRange isn't present. 165 | AvTimeOffset float64 `xml:"availabilityTimeOffset,attr,omitempty"` //Optional. 166 | AvTmeComplete bool `xml:"availabilityTimeComplete,attr,omitempty"` //Optional. 167 | Duration int `xml:"duration,attr,omitempty"` 168 | StartNumber int `xml:"startNumber,attr,omitempty"` 169 | Media string `xml:"media,attr,omitempty"` //Optional. Template to create Media Segment List 170 | Index string `xml:"index,attr,omitempty"` //Optional. Template to create the Index Segment List. If neither $Number% nor %Time% is included, it provides the URL to a Representation Index 171 | InitializationAttr string `xml:"initialization,attr,omitempty"` //Optional. Template to create Initialization Segment. $Number% and %Time% must not be included. 172 | BitstreamSwitchingAttr string `xml:"bitstreamSwitching,attr,omitempty"` //Optional. Template to create Bitstream Switching Segment. $Number% and %Time% must not be included. 173 | Initialization *URLType `xml:"Initialization,omitempty"` 174 | RepresentationIndex *URLType `xml:"RepresentationIndex,omitempty"` 175 | SegmentTimeline *SegmentTimeline `xml:"SegmentTimeline,omitempty"` 176 | BitstreamSwitching *URLType `xml:"BitstreamSwitching,omitempty"` 177 | } 178 | 179 | //SegmentTimeline represents the earliest presentation time and duration for each Segment in the Representation. 180 | //It contains a list of S elements, each describing a sequence of continguous segments of identical MPD duration. 181 | //The order of the S elements must match the numbering order (time) of the corresponding Media Segments. 182 | type SegmentTimeline struct { 183 | Segments Segments `xml:"S"` //Must have at least 1 S element. 184 | } 185 | 186 | //S is contained in a SegmentTimeline tag. 187 | type S struct { 188 | T int `xml:"t,attr"` //Optional. Specifies MPD start time, in timescale units. Relative to the befinning of the Period. 189 | D int `xml:"d,attr"` //Required. Segment duration int timescale units. Must not exceed the value of MPD.MaxSegmentDuration. 190 | R int `xml:"r,attr"` //Default: 0. Specifies repeat count of number of following continguous segments with same duration as D. 191 | } 192 | 193 | //Subset restricts the combination of active AdaptationSets where an active 194 | //Adaptation Set is one for which the DASH client is presenting at least one of the 195 | //contained Representation. No subset should contain all the Adaptaion Sets. 196 | type Subset struct { 197 | Contains CustomInt `xml:"contains,attr"` //Required. Whitespace separated list. 198 | ID string `xml:"id,attr,omitempty"` 199 | } 200 | 201 | //AdaptationSet represents a set of versions of one or more media streams. 202 | type AdaptationSet struct { 203 | XlinkHref string `xml:"http://www.w3.org/1999/xlink href,attr,omitempty"` 204 | XlinkActuate string `xml:"http://www.w3.org/1999/xlink actuate,attr,omitempty"` //Possible Values: 'onLoad', 'onRequest'. Default: onRequest. 205 | ID int `xml:"id,attr,omitempty"` 206 | Group int `xml:"group,attr,omitempty"` 207 | Lang string `xml:"lang,attr,omitempty"` 208 | ContentType string `xml:"contentType,attr,omitempty"` 209 | Par string `xml:"par,attr,omitempty"` //Optional. Picture Aspect Ratio. TODO:check specs for validation (regex) 210 | MinBandwidth int `xml:"minBandwidth,attr,omitempty"` 211 | MaxBandwidth int `xml:"maxBandwidth,attr,omitempty"` 212 | MinWidth int `xml:"minWidth,attr,omitempty"` 213 | MaxWidth int `xml:"maxWidth,attr,omitempty"` 214 | MinHeight int `xml:"minHeight,attr,omitempty"` 215 | MaxHeight int `xml:"maxHeight,attr,omitempty"` 216 | MinFrameRate string `xml:"minFrameRate,attr,omitempty"` //TODO:Check specs for validation (regex) 217 | MaxFrameRate string `xml:"maxFrameRate,attr,omitempty"` 218 | SegmentAlignment bool `xml:"segmentAlignment,attr,omitempty"` //Default: false. TODO: check specs for validation. Accepts 0,1 or true,false 219 | BitstreamSwitching bool `xml:"bitstreamSwitching,attr,omitempty"` //TODO: check specs for validation. Accepts 0,1 or true,false 220 | SubsegmentAlignment bool `xml:"subsegmentAlignment,attr,omitempty"` //Default: false. TODO: check specs for validation 221 | SubsegmentStartsWithSAP int `xml:"subsegmentStartsWithSap,attr,omitempty"` //Default: 0. TODO: check specs for validation 222 | Profiles string `xml:"profiles,attr,omitempty"` 223 | Width int `xml:"width,attr,omitempty"` 224 | Height int `xml:"height,attr,omitempty"` 225 | Sar string `xml:"sar,attr,omitempty"` //RatioType 226 | FrameRate string `xml:"frameRate,attr,omitempty"` //FrameRateType 227 | AudioSamplingRate string `xml:"audioSamplingRate,attr,omitempty"` 228 | MimeType string `xml:"mimeType,attr,omitempty"` 229 | SegmentProfiles string `xml:"segmentProfiles,attr,omitempty"` 230 | Codecs string `xml:"codecs,attr,omitempty"` 231 | MaxSAPPeriod float64 `xml:"maximumSAPPeriod,attr,omitempty"` //seconds 232 | StartWithSAP int `xml:"startWithSAP,attr,omitempty"` //SAPType 233 | MaxPlayoutRate float64 `xml:"maxPlayoutRate,attr,omitempty"` 234 | CodingDependency bool `xml:"codingDependency,attr,omitempty"` 235 | ScanType string `xml:"scanType,attr,omitempty"` //VideoScanType 236 | CENCContentProtections CENCContentProtections `xml:"ContentProtection,omitempty"` 237 | FramePacking []*Descriptor `xml:"FramePacking,omitempty"` 238 | AudioChannelConfig []*Descriptor `xml:"AudioChannelConfiguration,omitempty"` 239 | EssentialProperty []*Descriptor `xml:"EssentialProperty,omitempty"` 240 | SupplementalProperty []*Descriptor `xml:"SupplementalProperty,omitempty"` 241 | InbandEventStream []*Descriptor `xml:"InbandEventStream,omitempty"` 242 | Accessibility []*Descriptor `xml:"Accessibility,omitempty"` 243 | Role []*Descriptor `xml:"Role,omitempty"` 244 | Rating []*Descriptor `xml:"Rating,omitempty"` 245 | ViewPoint []*Descriptor `xml:"Viewpoint,omitempty"` 246 | ContentComponent []*ContentComponent `xml:"ContentComponent,omitempty"` 247 | BaseURL []*BaseURL `xml:"BaseURL,omitempty"` 248 | SegmentBase *SegmentBase `xml:"SegmentBase,omitempty"` 249 | SegmentList *SegmentList `xml:"SegmentList,omitempty"` 250 | SegmentTemplate *SegmentTemplate `xml:"SegmentTemplate,omitempty"` 251 | Representations Representations `xml:"Representation,omitempty"` 252 | } 253 | 254 | //ContentProtection represents the root ContentProtection element. 255 | type ContentProtection struct { 256 | XMLName xml.Name `xml:"ContentProtection"` 257 | XMLNsCenc string `xml:"xmlns:cenc,attr,omitempty"` 258 | XMLNsMspr string `xml:"xmlns:mspr,attr,omitempty"` 259 | SchemeIDURI string `xml:"schemeIdUri,attr,omitempty"` 260 | Value string `xml:"value,attr,omitempty"` 261 | DefaultKID string `xml:"cenc:default_KID,attr,omitempty"` 262 | } 263 | 264 | //CENCContentProtection represents the full ContentProtection element. 265 | // 266 | //Note for Playready encryption: the elements defined in the “mspr” namespace for the 267 | //first edition of Common Encryption (mspr:IsEncrypted, mspr:IV_size, and mspr:kid), 268 | //are deprecated and functionally replaced by cenc:default_KID specified in the second 269 | //edition of Common Encryption [CENC]. 270 | //The IV_size and IsEncrypted fields in the Track Encryption Box (‘tenc’) are used during decryption, 271 | //but are not needed in MPD ContentProtection Descriptor elements. 272 | type CENCContentProtection struct { 273 | ContentProtection 274 | Pssh *Pssh 275 | Pro *Pro 276 | IsEncrypted string `xml:"mspr:IsEncrypted,omitempty"` 277 | IVSize int `xml:"mspr:IV_size,omitempty"` 278 | KID string `xml:"mspr:kid,omitempty"` 279 | } 280 | 281 | //Pssh (Protection System Specific Header) represents the optional cenc:pssh element 282 | //that can be used by all DRM ContentProtection Descriptors for improved interoperability. 283 | type Pssh struct { 284 | XMLName xml.Name `xml:"cenc:pssh,omitempty"` 285 | Value string `xml:",innerxml"` 286 | } 287 | 288 | //Pro represents a Playready Header Object. 289 | type Pro struct { 290 | XMLName xml.Name `xml:"mspr:pro,omitempty"` 291 | Value string `xml:",innerxml"` 292 | } 293 | 294 | //ContentComponent describes the properties of each media content component in an 295 | //Adaptation Set. If only one media content component is present, it can be described directly 296 | //in the Adaptation Set. 297 | type ContentComponent struct { 298 | ID *int `xml:"id,attr,omitempty"` 299 | Lang string `xml:"lang,attr,omitempty"` 300 | ContentType string `xml:"contentType,attr,omitempty"` 301 | Par string `xml:"par,attr,omitempty"` 302 | Accessibility []*Descriptor `xml:"Accessibility,omitempty"` 303 | Role []*Descriptor `xml:"Role,omitempty"` 304 | Rating []*Descriptor `xml:"Rating,omitempty"` 305 | ViewPoint []*Descriptor `xml:"Viewpoint,omitempty"` 306 | } 307 | 308 | //Representation represents a deliverable encoded version of one or more media components. 309 | type Representation struct { 310 | ID string `xml:"id,attr"` //Required. It must not contain whitespace characters. 311 | Bandwidth int64 `xml:"bandwidth,attr"` //Required. 312 | QualityRanking int `xml:"qualityRanking,attr,omitempty"` 313 | DependencyID string `xml:"dependencyId,attr,omitempty"` //Whitespace separated list of int 314 | MediaStreamsStructureID string `xml:"mediaStreamsStructureId,attr,omitempty"` //Whitespace separated list of int 315 | Profiles string `xml:"profiles,attr,omitempty"` 316 | Width int `xml:"width,attr,omitempty"` 317 | Height int `xml:"height,attr,omitempty"` 318 | Sar string `xml:"sar,attr,omitempty"` //RatioType 319 | FrameRate string `xml:"frameRate,attr,omitempty"` //FrameRateType 320 | AudioSamplingRate string `xml:"audioSamplingRate,attr,omitempty"` 321 | MimeType string `xml:"mimeType,attr,omitempty"` 322 | SegmentProfiles string `xml:"segmentProfiles,attr,omitempty"` 323 | Codecs string `xml:"codecs,attr,omitempty"` 324 | MaxSAPPeriod float64 `xml:"maximumSAPPeriod,attr,omitempty"` 325 | StartWithSAP int `xml:"startWithSAP,attr,omitempty"` //SAPType 326 | MaxPlayoutRate float64 `xml:"maxPlayoutRate,attr,omitempty"` 327 | CodingDependency bool `xml:"codingDependency,attr,omitempty"` 328 | ScanType string `xml:"scanType,attr,omitempty"` //VideoScanType 329 | CENCContentProtections CENCContentProtections `xml:"ContentProtection,omitempty"` 330 | FramePacking []*Descriptor `xml:"FramePacking,omitempty"` 331 | AudioChannelConfig []*Descriptor `xml:"AudioChannelConfiguration,omitempty"` 332 | EssentialProperty []*Descriptor `xml:"EssentialProperty,omitempty"` 333 | SupplementalProperty []*Descriptor `xml:"SupplementalProperty,omitempty"` 334 | InbandEventStream []*Descriptor `xml:"InbandEventStream,omitempty"` 335 | BaseURL []*BaseURL `xml:"BaseURL,omitempty"` 336 | SubRepresentation []*SubRepresentation `xml:"SubRepresentation,omitempty"` 337 | SegmentBase *SegmentBase `xml:"SegmentBase,omitempty"` 338 | SegmentList *SegmentList `xml:"SegmentList,omitempty"` 339 | SegmentTemplate *SegmentTemplate `xml:"SegmentTemplate,omitempty"` 340 | } 341 | 342 | //SubRepresentation describes properties of one or several media content components 343 | //that are embedded in the Representation. 344 | type SubRepresentation struct { 345 | Level *int `xml:"level,attr,omitempty"` 346 | DependencyLevel CustomInt `xml:"dependencyLevel,attr,omitempty"` //Whitespace separated list of int 347 | Bandwidth int `xml:"bandwidth,attr,omitempty"` 348 | ContentComponent string `xml:"contentComponent,attr,omitempty"` //Whitespace separated list of string 349 | Profiles string `xml:"profiles,attr,omitempty"` 350 | Width int `xml:"width,attr,omitempty"` 351 | Height int `xml:"height,attr,omitempty"` 352 | Sar string `xml:"sar,attr,omitempty"` //RatioType 353 | FrameRate string `xml:"frameRate,attr,omitempty"` //FrameRateType 354 | AudioSamplingRate string `xml:"audioSamplingRate,attr,omitempty"` 355 | MimeType string `xml:"mimeType,attr,omitempty"` 356 | SegmentProfiles string `xml:"segmentProfiles,attr,omitempty"` 357 | Codecs string `xml:"codecs,attr,omitempty"` 358 | MaxSAPPeriod float64 `xml:"maximumSAPPeriod,attr,omitempty"` 359 | StartWithSAP int `xml:"startWithSAP,attr,omitempty"` //SAPType 360 | MaxPlayoutRate float64 `xml:"maxPlayoutRate,attr,omitempty"` 361 | CodingDependency bool `xml:"codingDependency,attr,omitempty"` 362 | ScanType string `xml:"scanType,attr,omitempty"` //VideoScanType 363 | CENCContentProtections CENCContentProtections `xml:"ContentProtection,omitempty"` 364 | FramePacking []*Descriptor `xml:"FramePacking,omitempty"` 365 | AudioChannelConfig []*Descriptor `xml:"AudioChannelConfiguration,omitempty"` 366 | EssentialProperty []*Descriptor `xml:"EssentialProperty,omitempty"` 367 | SupplementalProperty []*Descriptor `xml:"SupplementalProperty,omitempty"` 368 | InbandEventStream []*Descriptor `xml:"InbandEventStream,omitempty"` 369 | } 370 | -------------------------------------------------------------------------------- /dash/testdata/dynamic.mpd: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | /home/elkhatib/Documents/dash264/TestCases/1b/qualcomm/2_ED_5Sec_MainProf/MultiRate.mpd generated by GPAC 5 | 6 | http://54.72.87.160/stattodyn/statodyn.php?type=seg&pt=1376172180&avail_start=1468430543&tsbd=120&mup=597&orig_url=http://dash.edgesuite.net/dash264/TestCases/1b/qualcomm/2/ 7 | http://54.72.87.160/stattodyn/statodyn.php?type=mpd&avail_start=1468430543&pt=1376172180&tsbd=120&mup=597&origmpd=http://dash.edgesuite.net/dash264/TestCases/1b/qualcomm/2/MultiRate.mpd 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /dash/testdata/encrypted.mpd: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /dash/testdata/eventmessage.mpd: -------------------------------------------------------------------------------- 1 | 2 | 6 | http://cdn1.example.com/ 7 | http://cdn2.example.com/ 8 | 9 | 10 | + 1 800 10101010 11 | + 1 800 10101011 12 | + 1 800 10101012 13 | + 1 800 10101013 14 | 15 | 16 | 18 | video/ 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /dash/testdata/multipleperiods.mpd: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /dash/testdata/static.mpd: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /dash/testdata/static2.mpd: -------------------------------------------------------------------------------- 1 | 2 | 5 | http://www.example.com/ 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /dash/testdata/trickplay.mpd: -------------------------------------------------------------------------------- 1 | 2 | 5 | http://cdn1.example.com/ 6 | http://cdn2.example.com/ 7 | 8 | 10 | 11 | 12 | 13 | video-512k.mp4 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /dash/types.go: -------------------------------------------------------------------------------- 1 | package dash 2 | 3 | import ( 4 | "encoding/xml" 5 | "errors" 6 | "fmt" 7 | "strconv" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | //Periods ... 13 | type Periods []*Period 14 | 15 | //Segments is a string of S elements that implements Sort interface 16 | type Segments []*S 17 | 18 | func (s Segments) Len() int { 19 | return len(s) 20 | } 21 | func (s Segments) Swap(i, j int) { 22 | s[i], s[j] = s[j], s[i] 23 | } 24 | func (s Segments) Less(i, j int) bool { 25 | return s[i].T < s[j].T 26 | } 27 | 28 | //Subsets ... 29 | type Subsets []*Subset 30 | 31 | //AdaptationSets ... 32 | type AdaptationSets []*AdaptationSet 33 | 34 | //Representations ... 35 | type Representations []*Representation 36 | 37 | //CENCContentProtections ... 38 | type CENCContentProtections []*CENCContentProtection 39 | 40 | //CustomTime is a custom type of time.Time that implements XML marshaller and unmarshaller 41 | type CustomTime struct { 42 | time.Time 43 | } 44 | 45 | //UnmarshalXMLAttr implementes UnmarshalerAttr interface for CustomTime 46 | func (c *CustomTime) UnmarshalXMLAttr(attr xml.Attr) (err error) { 47 | c.Time, err = time.Parse(time.RFC3339Nano, attr.Value) 48 | return 49 | } 50 | 51 | //MarshalXMLAttr implementes MarshalerAttr interface for CustomTime 52 | func (c *CustomTime) MarshalXMLAttr(name xml.Name) (xml.Attr, error) { 53 | attr := xml.Attr{Name: name} 54 | attr.Value = c.Time.Format(time.RFC3339Nano) 55 | if attr.Value == "" { 56 | return attr, errors.New("Unable to format CustomTime") 57 | } 58 | return attr, nil 59 | } 60 | 61 | //CustomDuration is a custom type of time.Duration that implements XML marshaller and unmarshaller 62 | type CustomDuration struct { 63 | time.Duration 64 | } 65 | 66 | //UnmarshalXMLAttr implementes UnmarshalerAttr interface for CustomDuration 67 | func (c *CustomDuration) UnmarshalXMLAttr(attr xml.Attr) (err error) { 68 | //Removes 'PT' from attribute value before parsing duration 69 | c.Duration, err = time.ParseDuration(strings.ToLower(attr.Value[2:len(attr.Value)])) 70 | return 71 | } 72 | 73 | //MarshalXMLAttr implementes MarshalerAttr interface for CustomDuration 74 | func (c *CustomDuration) MarshalXMLAttr(name xml.Name) (xml.Attr, error) { 75 | attr := xml.Attr{Name: name} 76 | attr.Value = fmt.Sprintf("PT%s", strings.ToUpper(c.Duration.String())) 77 | return attr, nil 78 | } 79 | 80 | //CustomInt is a custom type for UIntVectorType that implements XML marshaller and unmarshaller 81 | type CustomInt struct { 82 | Value []int 83 | } 84 | 85 | //UnmarshalXMLAttr implementes UnmarshalerAttr interface for CustomInt 86 | func (c *CustomInt) UnmarshalXMLAttr(attr xml.Attr) (err error) { 87 | var ss []string 88 | var i int64 89 | ss = strings.Split(attr.Value, " ") 90 | for _, s := range ss { 91 | i, err = strconv.ParseInt(s, 10, 8) 92 | if err != nil { 93 | return 94 | } 95 | c.Value = append(c.Value, int(i)) 96 | } 97 | return 98 | } 99 | 100 | //MarshalXMLAttr implementes MarshalerAttr interface for CustomInt 101 | func (c *CustomInt) MarshalXMLAttr(name xml.Name) (xml.Attr, error) { 102 | var ci []string 103 | var attr xml.Attr 104 | if len(c.Value) > 0 { 105 | for _, i := range c.Value { 106 | ci = append(ci, strconv.Itoa(i)) 107 | } 108 | attr = xml.Attr{Name: name, Value: strings.Join(ci, " ")} 109 | } 110 | return attr, nil 111 | } 112 | -------------------------------------------------------------------------------- /dash/util.go: -------------------------------------------------------------------------------- 1 | package dash 2 | 3 | import ( 4 | "encoding/xml" 5 | "errors" 6 | "fmt" 7 | "sort" 8 | "strings" 9 | "time" 10 | 11 | "github.com/ingest/manifest" 12 | ) 13 | 14 | //NewMPD initiates a MPD struct with the minimum required attributes 15 | func NewMPD(profile string, minBufferTime time.Duration) *MPD { 16 | return &MPD{XMLNS: dashNS, 17 | Type: "static", 18 | Profiles: profile, 19 | MinBufferTime: &CustomDuration{Duration: minBufferTime}, 20 | } 21 | } 22 | 23 | //NewContentProtection sets ContentProtection element with the appropriate 24 | //namespaces. 25 | func NewContentProtection(schemeIDUri string, 26 | value string, 27 | defaultKID string, 28 | pssh string, 29 | pro string) *CENCContentProtection { 30 | cp := &CENCContentProtection{ 31 | ContentProtection: ContentProtection{ 32 | SchemeIDURI: schemeIDUri, 33 | Value: value, 34 | }, 35 | } 36 | if defaultKID != "" { 37 | cp.ContentProtection.XMLNsCenc = cencNS 38 | cp.ContentProtection.DefaultKID = defaultKID 39 | } 40 | if pssh != "" { 41 | cp.ContentProtection.XMLNsCenc = cencNS 42 | cp.Pssh = &Pssh{ 43 | XMLName: xml.Name{Local: "pssh", Space: "cenc"}, 44 | Value: pssh, 45 | } 46 | } 47 | if pro != "" { 48 | cp.ContentProtection.XMLNsMspr = msprNS 49 | cp.Pro = &Pro{ 50 | XMLName: xml.Name{Local: "pro", Space: "mspr"}, 51 | Value: pro, 52 | } 53 | } 54 | 55 | return cp 56 | } 57 | 58 | //SetTrackEncryptionBox sets PlayReady's Track Encryption Box fields (tenc). 59 | func (c *CENCContentProtection) SetTrackEncryptionBox(ivSize int, kid string) { 60 | c.ContentProtection.XMLNsMspr = msprNS 61 | c.IsEncrypted = "1" 62 | c.IVSize = ivSize 63 | c.KID = kid 64 | } 65 | 66 | //AddSegment adds a Segment to a SegmentTimeline and sorts it. 67 | func (st *SegmentTimeline) AddSegment(t, d, r int) { 68 | if st == nil { 69 | return 70 | } 71 | s := &S{T: t, 72 | D: d, 73 | R: r, 74 | } 75 | st.Segments = append(st.Segments, s) 76 | sort.Sort(st.Segments) 77 | } 78 | 79 | func (m *MPD) validate() error { 80 | buf := manifest.NewBufWrapper() 81 | m.validateMPD(buf) 82 | if buf.Buf != nil && buf.Buf.String() != "" { 83 | return errors.New(buf.Buf.String()) 84 | } 85 | return buf.Err 86 | } 87 | 88 | func (m *MPD) validateMPD(buf *manifest.BufWrapper) { 89 | if m != nil { 90 | //validate attributes 91 | if m.Profiles == "" { 92 | buf.WriteString("MPD field Profiles is required.\n") 93 | } 94 | 95 | if !strings.EqualFold(m.Type, "static") && !strings.EqualFold(m.Type, "dynamic") { 96 | buf.WriteString("Possible values for MPD field Type are 'static' and 'dynamic'.\n") 97 | } 98 | 99 | if strings.EqualFold(m.Type, "dynamic") { 100 | if m.AvStartTime == nil { 101 | buf.WriteString("MPD field AvStartTime must be present when Type = 'dynamic'.\n") 102 | } 103 | if m.PublishTime == nil { 104 | buf.WriteString("MPD field PublishTime must be present when Type = 'dynamic'\n") 105 | } 106 | } else { 107 | if m.MinUpdatePeriod != nil { 108 | buf.WriteString("MPD field MinUpdatePeriod must not be present when Type = 'static'\n") 109 | } 110 | } 111 | 112 | if m.MinBufferTime == nil || m.MinBufferTime.Duration == 0 { 113 | buf.WriteString("MPD field MinBufferTime is required.\n") 114 | } 115 | 116 | if m.Metrics != nil { 117 | for _, metric := range m.Metrics { 118 | metric.validate(buf) 119 | } 120 | } 121 | //validate Period 122 | if m.Periods != nil { 123 | for _, period := range m.Periods { 124 | period.validate(buf, m.Type) 125 | } 126 | } else { 127 | buf.WriteString("MPD must have at least one Period element.\n") 128 | } 129 | } 130 | } 131 | 132 | func (p *Period) validate(buf *manifest.BufWrapper, mpdType string) { 133 | //validate XlinkActuate 134 | validateXlinkActuate(buf, "Period", p.XlinkActuate) 135 | 136 | if strings.EqualFold(mpdType, "dynamic") && p.ID == "" { 137 | buf.WriteString("Period must have ID when Type = 'dynamic'.\n") 138 | } 139 | if p.AssetIdentifier != nil { 140 | p.AssetIdentifier.validate(buf, "AssetIdentifier") 141 | } 142 | if p.SegmentBase != nil { 143 | p.SegmentBase.validate(buf) 144 | } 145 | if p.SegmentList != nil { 146 | p.SegmentList.validate(buf) 147 | } 148 | //validate AdaptationSets 149 | if p.AdaptationSets != nil { 150 | for _, adaptationSet := range p.AdaptationSets { 151 | if p.BitstreamSwitching && !adaptationSet.BitstreamSwitching { 152 | buf.WriteString("Period element with field BitstreamSwitching = 'true' cannot contain AdaptaionSet with BitstreamSwitching = 'false'.\n") 153 | } 154 | adaptationSet.validate(buf) 155 | } 156 | } 157 | //check if only one out of SegmentBase, SegmentList and SegmentTemplate is present 158 | if !validateSegmentPresence(p.SegmentBase, p.SegmentTemplate, p.SegmentList) { 159 | buf.WriteString("At most one of the three, SegmentBase, SegmentTemplate and SegmentList shall be present in a Period element.\n") 160 | } 161 | } 162 | 163 | func (s *SegmentList) validate(buf *manifest.BufWrapper) { 164 | validateXlinkActuate(buf, "SegmentList", s.XlinkActuate) 165 | } 166 | 167 | func (a *AdaptationSet) validate(buf *manifest.BufWrapper) { 168 | validateScanType(buf, "AdaptationSet", a.ScanType) 169 | 170 | if a.Accessibility != nil { 171 | for _, acess := range a.Accessibility { 172 | acess.validate(buf, "Accessibility") 173 | } 174 | } 175 | if a.AudioChannelConfig != nil { 176 | for _, acc := range a.AudioChannelConfig { 177 | acc.validate(buf, "AudioChannelConfig") 178 | } 179 | } 180 | if a.EssentialProperty != nil { 181 | for _, ep := range a.EssentialProperty { 182 | ep.validate(buf, "EssentialProperty") 183 | } 184 | } 185 | if a.FramePacking != nil { 186 | for _, fp := range a.FramePacking { 187 | fp.validate(buf, "FramePacking") 188 | } 189 | } 190 | if a.InbandEventStream != nil { 191 | for _, ies := range a.InbandEventStream { 192 | ies.validate(buf, "InbandEventStream") 193 | } 194 | } 195 | if a.Rating != nil { 196 | for _, r := range a.Rating { 197 | r.validate(buf, "Rating") 198 | } 199 | } 200 | if a.Role != nil { 201 | for _, ro := range a.Role { 202 | ro.validate(buf, "Role") 203 | } 204 | } 205 | if a.SupplementalProperty != nil { 206 | for _, sp := range a.SupplementalProperty { 207 | sp.validate(buf, "SupplementalProperty") 208 | } 209 | } 210 | if a.ViewPoint != nil { 211 | for _, v := range a.ViewPoint { 212 | v.validate(buf, "Viewpoint") 213 | } 214 | } 215 | if a.Representations != nil { 216 | for _, re := range a.Representations { 217 | re.validate(buf) 218 | } 219 | } 220 | 221 | if a.SegmentBase != nil { 222 | a.SegmentBase.validate(buf) 223 | } 224 | //check if only one out of SegmentBase, SegmentList and SegmentTemplate is present 225 | if !validateSegmentPresence(a.SegmentBase, a.SegmentTemplate, a.SegmentList) { 226 | buf.WriteString("At most one of the three, SegmentBase, SegmentTemplate and SegmentList shall be present in AdaptationSet element.\n") 227 | } 228 | } 229 | 230 | func (r *Representation) validate(buf *manifest.BufWrapper) { 231 | if r.ID == "" { 232 | buf.WriteString("Representation field ID is required.\n") 233 | } else if strings.Index(r.ID, " ") != -1 { 234 | buf.WriteString("Representation field ID must not contain whitespace character.\n") 235 | } 236 | if r.Bandwidth == 0 { 237 | buf.WriteString("Representation field Bandwidth is required.\n") 238 | } 239 | if r.AudioChannelConfig != nil { 240 | for _, acc := range r.AudioChannelConfig { 241 | acc.validate(buf, "AudioChannelConfig") 242 | } 243 | } 244 | if r.EssentialProperty != nil { 245 | for _, ep := range r.EssentialProperty { 246 | ep.validate(buf, "EssentialProperty") 247 | } 248 | } 249 | if r.FramePacking != nil { 250 | for _, fp := range r.FramePacking { 251 | fp.validate(buf, "FramePacking") 252 | } 253 | } 254 | if r.InbandEventStream != nil { 255 | for _, ies := range r.InbandEventStream { 256 | ies.validate(buf, "InbandEventStream") 257 | } 258 | } 259 | if r.SupplementalProperty != nil { 260 | for _, sp := range r.SupplementalProperty { 261 | sp.validate(buf, "SupplementalProperty") 262 | } 263 | } 264 | 265 | validateScanType(buf, "Representation", r.ScanType) 266 | 267 | r.SegmentBase.validate(buf) 268 | 269 | if r.SubRepresentation != nil { 270 | for _, sr := range r.SubRepresentation { 271 | sr.validate(buf) 272 | } 273 | } 274 | //check if only one out of SegmentBase, SegmentList and SegmentTemplate is present 275 | if !validateSegmentPresence(r.SegmentBase, r.SegmentTemplate, r.SegmentList) { 276 | buf.WriteString("At most one of the three, SegmentBase, SegmentTemplate and SegmentList shall be present in Representation element.\n") 277 | } 278 | } 279 | 280 | func (s *SubRepresentation) validate(buf *manifest.BufWrapper) { 281 | if s != nil { 282 | if s.Level != nil && s.Bandwidth == 0 { 283 | buf.WriteString("SubRepresentation field Bandwidth is required when Level is present.\n") 284 | } 285 | validateScanType(buf, "SubRepresentation", s.ScanType) 286 | } 287 | } 288 | 289 | func (s *SegmentBase) validate(buf *manifest.BufWrapper) { 290 | if s != nil { 291 | if s.IndexRange == "" && s.IndexRangeExact { 292 | buf.WriteString("SegmentBase element field IndexRangeExact must not be present if IndexRange isn't specified.\n") 293 | } 294 | } 295 | } 296 | 297 | func (m *Metrics) validate(buf *manifest.BufWrapper) { 298 | if m != nil { 299 | if m.Metrics == "" { 300 | buf.WriteString("Metrics field Metrics is required.\n") 301 | } 302 | 303 | if m.Reporting != nil { 304 | for _, r := range m.Reporting { 305 | r.validate(buf, "Reporting") 306 | } 307 | } else { 308 | buf.WriteString("Metrics must have at least one Reporting element.\n") 309 | } 310 | } 311 | } 312 | 313 | func (d *Descriptor) validate(buf *manifest.BufWrapper, element string) { 314 | if d.SchemeIDURI == "" { 315 | buf.WriteString(fmt.Sprintf("%s field SchemeIdURI is required.\n", element)) 316 | } 317 | } 318 | 319 | func validateXlinkActuate(buf *manifest.BufWrapper, element string, xlinkActuate string) { 320 | if xlinkActuate != "" && xlinkActuate != "onLoad" && xlinkActuate != "onRequest" { 321 | buf.WriteString(fmt.Sprintf("%s field XlinkActuate accepts values 'onRequest' and 'onLoad'.\n", element)) 322 | } 323 | } 324 | 325 | func validateScanType(buf *manifest.BufWrapper, element string, scanType string) { 326 | if scanType != "" && scanType != "progressive" && scanType != "interlaced" && scanType != "unknown" { 327 | buf.WriteString(fmt.Sprintf("%s field scanType accepts values 'progressive', 'interlaced' and 'unknown'.\n", 328 | element)) 329 | } 330 | } 331 | 332 | //checks if only one out of SegmentBase, SegmentList and SegmentTemplate is present 333 | func validateSegmentPresence(sb *SegmentBase, st *SegmentTemplate, sl *SegmentList) bool { 334 | var segment int 335 | if sb != nil { 336 | segment++ 337 | } 338 | if st != nil { 339 | segment++ 340 | } 341 | if sl != nil { 342 | segment++ 343 | } 344 | return segment <= 1 345 | } 346 | -------------------------------------------------------------------------------- /dash/util_test.go: -------------------------------------------------------------------------------- 1 | package dash 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | "time" 7 | 8 | "github.com/ingest/manifest" 9 | ) 10 | 11 | func TestNoPeriodError(t *testing.T) { 12 | mpd := NewMPD("profile", time.Second*5) 13 | _, err := mpd.Encode() 14 | if err.Error() != "MPD must have at least one Period element.\n" { 15 | t.Fatalf("Expecting Period requirement error, but got %s", err.Error()) 16 | } 17 | } 18 | 19 | func TestError(t *testing.T) { 20 | mpd := NewMPD("", time.Second*0) 21 | period := &Period{ 22 | ID: "1", 23 | } 24 | mpd.Periods = append(mpd.Periods, period) 25 | _, err := mpd.Encode() 26 | if !strings.Contains(err.Error(), "MPD field Profiles is required") { 27 | t.Errorf("Expecting Profiles requirement error, but got %s", err.Error()) 28 | } 29 | if !strings.Contains(err.Error(), "MPD field MinBufferTime is required") { 30 | t.Errorf("Expecting MinBufferTime requirement error, but got %s", err.Error()) 31 | } 32 | } 33 | 34 | func TestRepresentation(t *testing.T) { 35 | rep := Representation{ 36 | SegmentBase: &SegmentBase{Timescale: 1, IndexRangeExact: true}, 37 | SegmentList: &SegmentList{Timescale: 2}, 38 | } 39 | buf := manifest.NewBufWrapper() 40 | rep.validate(buf) 41 | if !strings.Contains(buf.Buf.String(), "Representation field ID is required") { 42 | t.Error("Expecting 'ID is required' error") 43 | } 44 | if !strings.Contains(buf.Buf.String(), "Representation field Bandwidth is required") { 45 | t.Error("Expecting 'Bandwidth is required' error") 46 | } 47 | if !strings.Contains(buf.Buf.String(), "IndexRangeExact must not be present") { 48 | t.Error("Expecting 'IndexRangeExact must not be present' error") 49 | } 50 | if !strings.Contains(buf.Buf.String(), "At most one of the three") { 51 | t.Error("Expecting 'At most one of the three, SegmentBase, SegmentTemplate and SegmentList' error") 52 | } 53 | } 54 | 55 | func TestDescriptor(t *testing.T) { 56 | test := Descriptor{Value: "test"} 57 | buf := manifest.NewBufWrapper() 58 | test.validate(buf, "Test") 59 | if !strings.Contains(buf.Buf.String(), "Test field SchemeIdURI is required") { 60 | t.Error("Expecting 'SchemeIdURI is required' error") 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /hls/decode-util.go: -------------------------------------------------------------------------------- 1 | package hls 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strconv" 7 | "strings" 8 | "time" 9 | ) 10 | 11 | func decodeVariant(line string, isIframe bool) (*Variant, error) { 12 | var err error 13 | vMap := splitParams(line) 14 | variant := &Variant{} 15 | 16 | for k, v := range vMap { 17 | switch k { 18 | case "BANDWIDTH": 19 | if variant.Bandwidth, err = strconv.ParseInt(v, 10, 64); err != nil { 20 | return nil, err 21 | } 22 | case "AVERAGE-BANDWIDTH": 23 | if variant.AvgBandwidth, err = strconv.ParseInt(v, 10, 64); err != nil { 24 | return nil, err 25 | } 26 | case "CODECS": 27 | variant.Codecs = v 28 | case "RESOLUTION": 29 | variant.Resolution = v 30 | case "FRAME-RATE": 31 | if variant.FrameRate, err = strconv.ParseFloat(v, 64); err != nil { 32 | return nil, err 33 | } 34 | case "AUDIO": 35 | variant.Audio = v 36 | case "VIDEO": 37 | variant.Video = v 38 | case "SUBTITLES": 39 | variant.Subtitles = v 40 | case "CLOSED-CAPTIONS": 41 | variant.ClosedCaptions = v 42 | case "URI": 43 | variant.URI = v 44 | } 45 | 46 | if err != nil { 47 | return nil, err 48 | } 49 | } 50 | 51 | variant.IsIframe = isIframe 52 | return variant, err 53 | } 54 | 55 | func decodeRendition(line string) *Rendition { 56 | rMap := splitParams(line) 57 | 58 | rendition := &Rendition{} 59 | for k, v := range rMap { 60 | switch k { 61 | case "TYPE": 62 | if isValidType(strings.ToUpper(v)) { 63 | rendition.Type = v 64 | } 65 | case "URI": 66 | rendition.URI = v 67 | case "GROUP-ID": 68 | rendition.GroupID = v 69 | case "LANGUAGE": 70 | rendition.Language = v 71 | case "ASSOC-LANGUAGE": 72 | rendition.AssocLanguage = v 73 | case "NAME": 74 | rendition.Name = v 75 | case "DEFAULT": 76 | if strings.EqualFold(v, boolYes) { 77 | rendition.Default = true 78 | } 79 | case "AUTOSELECT": 80 | if strings.EqualFold(v, boolYes) { 81 | rendition.AutoSelect = true 82 | } 83 | case "FORCED": 84 | if strings.EqualFold(v, boolYes) { 85 | rendition.Forced = true 86 | } 87 | case "INSTREAM-ID": 88 | if isValidInstreamID(strings.ToUpper(v)) { 89 | rendition.InstreamID = v 90 | } 91 | case "CHARACTERISTICS": 92 | rendition.Characteristics = v 93 | } 94 | } 95 | return rendition 96 | } 97 | 98 | func decodeSessionData(line string) *SessionData { 99 | sdMap := splitParams(line) 100 | sd := &SessionData{} 101 | for k, v := range sdMap { 102 | switch k { 103 | case "DATA-ID": 104 | sd.DataID = v 105 | case "VALUE": 106 | sd.Value = v 107 | case "URI": 108 | sd.URI = v 109 | case "LANGUAGE": 110 | sd.Language = v 111 | } 112 | } 113 | return sd 114 | } 115 | 116 | func decodeInf(line string) (*Inf, error) { 117 | var err error 118 | i := &Inf{} 119 | index := strings.Index(line, ",") 120 | if index == -1 { 121 | return nil, fmt.Errorf("no comma was found when decoding #EXTINF: %s", line) 122 | } 123 | if i.Duration, err = strconv.ParseFloat(line[0:index], 64); err != nil { 124 | return nil, err 125 | } 126 | i.Title = line[index+1 : len(line)] 127 | return i, err 128 | } 129 | 130 | func decodeDateRange(line string) (*DateRange, error) { 131 | var err error 132 | var d float64 133 | var pd float64 134 | drMap := splitParams(line) 135 | 136 | dr := &DateRange{} 137 | for k, v := range drMap { 138 | switch { 139 | case k == "ID": 140 | dr.ID = v 141 | case k == "CLASS": 142 | dr.Class = v 143 | case k == "START-DATE": 144 | dr.StartDate, err = decodeDateTime(v) 145 | case k == "END-DATE": 146 | dr.EndDate, err = decodeDateTime(v) 147 | case k == "DURATION": 148 | if d, err = strconv.ParseFloat(v, 64); err == nil { 149 | dr.Duration = &d 150 | } else { 151 | return nil, err 152 | } 153 | case k == "PLANNED-DURATION": 154 | if pd, err = strconv.ParseFloat(v, 64); err == nil { 155 | dr.PlannedDuration = &pd 156 | } else { 157 | return nil, err 158 | } 159 | case strings.HasPrefix(k, "X-"): 160 | dr.XClientAttribute = append(dr.XClientAttribute, fmt.Sprintf("%s=%s", k, v)) 161 | case strings.HasPrefix(k, "SCTE35"): 162 | dr.SCTE35 = decodeSCTE(k, v) 163 | case k == "END-ON-NEXT" && strings.EqualFold(v, boolYes): 164 | dr.EndOnNext = true 165 | } 166 | } 167 | 168 | return dr, err 169 | } 170 | 171 | func decodeSCTE(att string, value string) *SCTE35 { 172 | if strings.HasSuffix(att, "IN") { 173 | return &SCTE35{Type: "IN", Value: value} 174 | } else if strings.HasSuffix(att, "OUT") { 175 | return &SCTE35{Type: "OUT", Value: value} 176 | } else if strings.HasSuffix(att, "CMD") { 177 | return &SCTE35{Type: "CMD", Value: value} 178 | } 179 | return nil 180 | } 181 | 182 | func decodeDateTime(line string) (time.Time, error) { 183 | line = strings.Trim(line, "\"") 184 | t, err := time.Parse(time.RFC3339Nano, line) 185 | return t, err 186 | } 187 | 188 | func decodeMap(line string) (*Map, error) { 189 | mMap := splitParams(line) 190 | var err error 191 | m := &Map{} 192 | for k, v := range mMap { 193 | switch k { 194 | case "URI": 195 | m.URI = v 196 | case "BYTERANGE": 197 | m.Byterange, err = decodeByterange(v) 198 | } 199 | } 200 | return m, err 201 | } 202 | 203 | func decodeByterange(value string) (*Byterange, error) { 204 | params := strings.Split(value, "@") 205 | l, err := strconv.ParseInt(params[0], 10, 64) 206 | if err != nil { 207 | return nil, err 208 | } 209 | b := &Byterange{Length: l} 210 | if len(params) == 2 { 211 | o, err := strconv.ParseInt(params[1], 10, 64) 212 | if err != nil { 213 | return b, err 214 | } 215 | b.Offset = &o 216 | } 217 | return b, nil 218 | } 219 | 220 | func decodeKey(line string, isSession bool) *Key { 221 | keyMap := splitParams(line) 222 | 223 | key := Key{IsSession: isSession} 224 | for k, v := range keyMap { 225 | switch k { 226 | case "METHOD": 227 | key.Method = v 228 | case "URI": 229 | key.URI = v 230 | case "IV": 231 | key.IV = v 232 | case "KEYFORMAT": 233 | key.Keyformat = v 234 | case "KEYFORMATVERSIONS": 235 | key.Keyformatversions = v 236 | } 237 | } 238 | return &key 239 | } 240 | 241 | func decodeStartPoint(line string) (*StartPoint, error) { 242 | spMap := splitParams(line) 243 | var err error 244 | sp := &StartPoint{} 245 | for k, v := range spMap { 246 | switch k { 247 | case "TIME-OFFSET": 248 | if sp.TimeOffset, err = strconv.ParseFloat(v, 64); err != nil { 249 | return nil, err 250 | } 251 | case "PRECISE": 252 | if strings.EqualFold(v, boolYes) { 253 | sp.Precise = true 254 | } 255 | } 256 | } 257 | return sp, err 258 | } 259 | 260 | //splitParams receives the comma-separated list of attributes and maps attribute-value pairs 261 | func splitParams(line string) map[string]string { 262 | //regex to recognize att=val format and split on comma, unless comma is inside quotes 263 | re := regexp.MustCompile(`([a-zA-Z\d_-]+)=("[^"]+"|[^",]+)`) 264 | m := make(map[string]string) 265 | for _, kv := range re.FindAllStringSubmatch(line, -1) { 266 | k, v := kv[1], kv[2] 267 | m[strings.ToUpper(k)] = strings.Trim(v, "\"") 268 | } 269 | return m 270 | } 271 | 272 | //stringsIndex wraps string.Index and sets index = 0 if not found 273 | func stringsIndex(line string, char string) int { 274 | index := strings.Index(line, char) 275 | if index == -1 { 276 | index = 0 277 | } 278 | return index 279 | } 280 | -------------------------------------------------------------------------------- /hls/decode-util_test.go: -------------------------------------------------------------------------------- 1 | package hls 2 | 3 | import ( 4 | "bufio" 5 | "os" 6 | "path/filepath" 7 | "reflect" 8 | "testing" 9 | "time" 10 | ) 11 | 12 | func TestProtocolCompat(t *testing.T) { 13 | tests := []struct { 14 | expectErr bool 15 | mediaType string 16 | file string 17 | }{ 18 | { 19 | expectErr: true, 20 | mediaType: "media", 21 | file: "fixture-v3-fail.m3u8", 22 | }, 23 | { 24 | expectErr: false, 25 | mediaType: "media", 26 | file: "fixture-v3.m3u8", 27 | }, 28 | { 29 | expectErr: true, 30 | mediaType: "media", 31 | file: "fixture-v4-byterange-fail.m3u8", 32 | }, 33 | { 34 | expectErr: true, 35 | mediaType: "media", 36 | file: "fixture-v4-iframes-fail.m3u8", 37 | }, 38 | { 39 | expectErr: false, 40 | mediaType: "media", 41 | file: "fixture-v4.m3u8", 42 | }, 43 | { 44 | expectErr: true, 45 | mediaType: "media", 46 | file: "fixture-v5-keyformat-fail.m3u8", 47 | }, 48 | { 49 | expectErr: true, 50 | mediaType: "media", 51 | file: "fixture-v5-map-fail.m3u8", 52 | }, 53 | { 54 | expectErr: false, 55 | mediaType: "media", 56 | file: "fixture-v5.m3u8", 57 | }, 58 | { 59 | expectErr: true, 60 | mediaType: "media", 61 | file: "fixture-v6-map-no-iframes-fail.m3u8", 62 | }, 63 | { 64 | expectErr: false, 65 | mediaType: "media", 66 | file: "fixture-v6.m3u8", 67 | }, 68 | { 69 | expectErr: true, 70 | mediaType: "master", 71 | file: "fixture-v7-media-service-fail.m3u8", 72 | }, 73 | { 74 | expectErr: false, 75 | mediaType: "master", 76 | file: "fixture-v7.m3u8", 77 | }, 78 | } 79 | 80 | for _, tt := range tests { 81 | t.Run(tt.file, func(t *testing.T) { 82 | f, err := os.Open(filepath.Join("./testdata", tt.mediaType, tt.file)) 83 | if err != nil { 84 | t.Fatal(err) 85 | } 86 | 87 | switch tt.mediaType { 88 | case "media": 89 | p := NewMediaPlaylist(0) 90 | if err := p.Parse(f); (err != nil) != tt.expectErr { 91 | t.Errorf("expected (%t) err: %v", tt.expectErr, err) 92 | } 93 | case "master": 94 | p := NewMasterPlaylist(0) 95 | if err := p.Parse(f); (err != nil) != tt.expectErr { 96 | t.Errorf("expected (%t) err: %v", tt.expectErr, err) 97 | } 98 | } 99 | 100 | }) 101 | } 102 | } 103 | 104 | func TestReadMasterPlaylistFile(t *testing.T) { 105 | f, err := os.Open("./testdata/masterp.m3u8") 106 | if err != nil { 107 | t.Fatal(err) 108 | } 109 | p := &MasterPlaylist{} 110 | err = p.Parse(bufio.NewReader(f)) 111 | if err != nil { 112 | t.Fatalf("expected no error, got: %v", err) 113 | } 114 | 115 | if len(p.SessionData) != 2 { 116 | t.Errorf("Expected SessionData len 2, but got %d", len(p.SessionData)) 117 | } 118 | 119 | if len(p.Variants) != 14 { 120 | t.Errorf("Expected Variants len 14, but got %d", len(p.Variants)) 121 | } 122 | 123 | if len(p.Renditions) != 5 { 124 | t.Errorf("Expected Renditions len 5, but got %d", len(p.Renditions)) 125 | } 126 | 127 | k := &Key{ 128 | IsSession: true, 129 | Method: "SAMPLE-AES", 130 | IV: "0x29fd9eba3735966ddfca572e51e68ff2", 131 | URI: "com.keyuri.example", 132 | Keyformat: "com.apple.streamingkeydelivery", 133 | Keyformatversions: "1", 134 | } 135 | 136 | if p.SessionKeys != nil { 137 | if !k.Equal(p.SessionKeys[0]) { 138 | t.Errorf("Expected SessionKeys to be %v, but got %v", k, p.SessionKeys[0]) 139 | } 140 | } 141 | } 142 | 143 | func TestReadMediaPlaylistFile(t *testing.T) { 144 | f, err := os.Open("./testdata/mediap.m3u8") 145 | if err != nil { 146 | t.Fatal(err) 147 | } 148 | p := &MediaPlaylist{} 149 | p.Parse(bufio.NewReader(f)) 150 | if p.TargetDuration != 10 { 151 | t.Errorf("Expected TargetDuration 10, but got %d", p.TargetDuration) 152 | } 153 | 154 | if p.StartPoint.TimeOffset != 8.345 { 155 | t.Errorf("Expected StartPoint to be 8.345, but got %v", p.StartPoint.TimeOffset) 156 | } 157 | if p.Segments != nil { 158 | if len(p.Segments) != 6 { 159 | t.Errorf("Expected len Segments 6, but got %d", len(p.Segments)) 160 | } 161 | sd, _ := time.Parse(time.RFC3339Nano, "2010-02-19T14:54:23.031+08:00") 162 | dr := &DateRange{ID: "6FFF00", StartDate: sd, SCTE35: &SCTE35{Type: "OUT", Value: "0xFC002F0000000000FF0"}} 163 | if !reflect.DeepEqual(p.Segments[0].DateRange, dr) { 164 | t.Errorf("Expected DateRange to be %v, but got %v", dr, p.Segments[0].DateRange) 165 | } 166 | c := p.MediaSequence 167 | for i := range p.Segments { 168 | if p.Segments[i].ID != c { 169 | t.Errorf("Expected Segments %d ID to be %d, but got %d", i, c, p.Segments[i].ID) 170 | } 171 | c++ 172 | } 173 | } 174 | 175 | } 176 | 177 | func TestReadMediaPlaylist(t *testing.T) { 178 | offset := int64(700) 179 | duration := float64(200) 180 | pt, _ := time.Parse(time.RFC3339Nano, "2016-06-22T15:33:52.199039986Z") 181 | seg := &Segment{ 182 | URI: "segment.com", 183 | Inf: &Inf{ 184 | Duration: 9.052, 185 | }, 186 | Byterange: &Byterange{Length: 6000, Offset: &offset}, 187 | Keys: []*Key{&Key{Method: "sample-aes", URI: "keyuri"}, &Key{Method: "sample-aes", URI: "secondkeyuri"}}, 188 | Map: &Map{URI: "mapuri"}, 189 | DateRange: &DateRange{ID: "TEST", 190 | StartDate: pt, 191 | EndDate: pt.Add(1 * time.Hour), 192 | SCTE35: &SCTE35{Type: "IN", Value: "bla"}, 193 | XClientAttribute: []string{"X-THIS-TAG=TEST", "X-THIS-OTHER-TAG=TESTING"}}, 194 | } 195 | 196 | seg2 := &Segment{ 197 | URI: "segment2.com", 198 | Inf: &Inf{ 199 | Duration: 8.052, 200 | Title: "seg title", 201 | }, 202 | Byterange: &Byterange{Length: 4000}, 203 | Keys: []*Key{&Key{Method: "sample-aes", URI: "keyuri"}}, 204 | Map: &Map{URI: "map2"}, 205 | DateRange: &DateRange{ID: "test", StartDate: pt, Duration: &duration}, 206 | } 207 | 208 | seg3 := &Segment{ 209 | URI: "segment3.com", 210 | Inf: &Inf{ 211 | Duration: 9.500, 212 | }, 213 | ProgramDateTime: time.Now(), 214 | Discontinuity: true, 215 | Map: &Map{URI: "map2"}, 216 | } 217 | 218 | p := NewMediaPlaylist(7) 219 | p.Segments = append(p.Segments, seg, seg2, seg3) 220 | p.DiscontinuitySequence = 2 221 | p.TargetDuration = 10 222 | p.EndList = true 223 | p.MediaSequence = 1 224 | p.StartPoint = &StartPoint{TimeOffset: 10.543} 225 | buf, err := p.Encode() 226 | 227 | newP := NewMediaPlaylist(0) 228 | err = newP.Parse(buf) 229 | if err != nil { 230 | t.Fatalf("expected no error, got: %v", err) 231 | } 232 | 233 | if newP.Version != 7 { 234 | t.Errorf("expected version to be 7, got %d", newP.Version) 235 | } 236 | 237 | if newP.TargetDuration != p.TargetDuration { 238 | t.Errorf("Expected TargetDuration to be %d, but got %d", p.TargetDuration, newP.TargetDuration) 239 | } 240 | 241 | if newP.DiscontinuitySequence != p.DiscontinuitySequence { 242 | t.Errorf("Expected DiscontinuitySequence to be %d, but got %d", p.DiscontinuitySequence, newP.DiscontinuitySequence) 243 | } 244 | 245 | if !reflect.DeepEqual(newP.StartPoint, p.StartPoint) { 246 | t.Errorf("Expected StartPoint to be %v, but got %v", p.StartPoint, newP.StartPoint) 247 | } 248 | 249 | for i, s := range p.Segments { 250 | if !reflect.DeepEqual(s.Inf, newP.Segments[i].Inf) { 251 | t.Errorf("Expected %d Segment Inf to be %v, but got %v", i, s.Inf, newP.Segments[i].Inf) 252 | } 253 | if s.URI != newP.Segments[i].URI { 254 | t.Errorf("Expected URI to be %s, but got %s", s.URI, newP.Segments[i].URI) 255 | } 256 | if !s.Map.Equal(newP.Segments[i].Map) { 257 | t.Errorf("Expected %d Segment Map to be %v, but got %v", i, s.Map, newP.Segments[i].Map) 258 | } 259 | // if s.DateRange != nil && !reflect.DeepEqual(s.DateRange, newP.Segments[i].DateRange) { 260 | // t.Errorf("Expected %d Segment DateRange to be %v, but got %v", i, s.DateRange, newP.Segments[i].DateRange) 261 | // } 262 | } 263 | } 264 | -------------------------------------------------------------------------------- /hls/decode.go: -------------------------------------------------------------------------------- 1 | package hls 2 | 3 | import ( 4 | "io" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/ingest/manifest" 9 | ) 10 | 11 | type masterPlaylistParseState struct { 12 | eof bool 13 | streamInfLastTag bool 14 | variant *Variant 15 | } 16 | 17 | //Parse reads a Master Playlist file and converts it to a MasterPlaylist object 18 | func (p *MasterPlaylist) Parse(reader io.Reader) error { 19 | buf := manifest.NewBufWrapper() 20 | 21 | // Populate buffer into memory, we could change this to a tokenizer with a line-by-line scanner? 22 | if _, err := buf.ReadFrom(reader); err != nil { 23 | return err 24 | } 25 | 26 | s := masterPlaylistParseState{ 27 | variant: &Variant{masterPlaylist: p}, 28 | } 29 | 30 | // Raw line 31 | var line string 32 | 33 | // Runs until io.EOF, reads line-by-line from buffer and decode into an object 34 | for !s.eof { 35 | line = buf.ReadString('\n') 36 | if buf.Err == io.EOF { 37 | buf.Err = nil 38 | s.eof = true 39 | } 40 | 41 | if buf.Err != nil { 42 | return buf.Err 43 | } 44 | 45 | line = strings.TrimSpace(line) 46 | size := len(line) 47 | //if empty line, skip 48 | if size <= 1 { 49 | continue 50 | } 51 | 52 | if line[0] == '#' { 53 | s.streamInfLastTag = false 54 | index := stringsIndex(line, ":") 55 | switch { 56 | case line == "#EXTM3U": 57 | p.M3U = true 58 | 59 | case line[0:index] == "#EXT-X-VERSION": 60 | p.Version, buf.Err = strconv.Atoi(line[index+1 : size]) 61 | 62 | case line[0:index] == "#EXT-X-START": 63 | p.StartPoint, buf.Err = decodeStartPoint(line[index+1 : size]) 64 | 65 | case line == "#EXT-X-INDEPENDENT-SEGMENTS": 66 | p.IndependentSegments = true 67 | 68 | case line[0:index] == "#EXT-X-SESSION-KEY": 69 | key := decodeKey(line[index+1:size], true) 70 | key.masterPlaylist = p 71 | p.SessionKeys = append(p.SessionKeys, key) 72 | 73 | case line[0:index] == "#EXT-X-SESSION-DATA": 74 | data := decodeSessionData(line[index+1 : size]) 75 | data.masterPlaylist = p 76 | p.SessionData = append(p.SessionData, data) 77 | 78 | case line[0:index] == "#EXT-X-MEDIA": 79 | r := decodeRendition(line[index+1 : size]) 80 | r.masterPlaylist = p 81 | p.Renditions = append(p.Renditions, r) 82 | 83 | case line[0:index] == "#EXT-X-STREAM-INF": 84 | s.variant, buf.Err = decodeVariant(line[index+1:size], false) 85 | s.variant.masterPlaylist = p 86 | s.streamInfLastTag = true 87 | 88 | //Case line is EXT-X-I-FRAME-STREAM-INF, it means it's the end of a variant 89 | //append variant to MasterPlaylist and restart variables 90 | case line[0:index] == "#EXT-X-I-FRAME-STREAM-INF": 91 | variant, err := decodeVariant(line[index+1:size], true) 92 | if err != nil { 93 | buf.Err = err 94 | continue // shouldn't include a partially decoded iframe playlist 95 | } 96 | variant.masterPlaylist = p 97 | 98 | p.Variants = append(p.Variants, variant) 99 | } 100 | //Case line doesn't start with '#', check if last tag was EXT-X-STREAM-INF. 101 | //Which means this line is variant URI 102 | //Append variant to MasterPlaylist and restart variables 103 | } else if s.streamInfLastTag { 104 | s.variant.URI = line 105 | p.Variants = append(p.Variants, s.variant) 106 | // Reset state 107 | s.variant = &Variant{masterPlaylist: p} 108 | s.streamInfLastTag = false 109 | } 110 | 111 | } 112 | 113 | if buf.Err != nil { 114 | return buf.Err 115 | } 116 | 117 | // Check master playlist compatibility 118 | if err := p.checkCompatibility(); err != nil { 119 | return err 120 | } 121 | 122 | return nil 123 | } 124 | 125 | // Holds the state while parsing a media playlist 126 | type mediaPlaylistParseState struct { 127 | eof bool 128 | previousMap *Map 129 | previousKey *Key 130 | segmentSequence int 131 | } 132 | 133 | //Parse reads a Media Playlist file and convert it to MediaPlaylist object 134 | func (p *MediaPlaylist) Parse(reader io.Reader) error { 135 | buf := manifest.NewBufWrapper() 136 | 137 | // Populate buffer into memory, we could change this to a tokenizer with a line-by-line scanner? 138 | if _, err := buf.ReadFrom(reader); err != nil { 139 | return err 140 | } 141 | 142 | s := mediaPlaylistParseState{} 143 | segment := &Segment{ 144 | mediaPlaylist: p, 145 | } 146 | 147 | //Until EOF, read every line and decode into an object 148 | var line string 149 | for !s.eof { 150 | line = buf.ReadString('\n') 151 | if buf.Err == io.EOF { 152 | buf.Err = nil 153 | s.eof = true 154 | } 155 | 156 | if buf.Err != nil { 157 | return buf.Err 158 | } 159 | 160 | line = strings.TrimSpace(line) 161 | size := len(line) 162 | //if empty line, skip 163 | if size <= 1 { 164 | continue 165 | } 166 | 167 | index := stringsIndex(line, ":") 168 | 169 | switch { 170 | case line[0:index] == "#EXT-X-VERSION": 171 | p.Version, buf.Err = strconv.Atoi(line[index+1 : size]) 172 | case line[0:index] == "#EXT-X-TARGETDURATION": 173 | p.TargetDuration, buf.Err = strconv.Atoi(line[index+1 : size]) 174 | case line[0:index] == "#EXT-X-MEDIA-SEQUENCE": 175 | p.MediaSequence, buf.Err = strconv.Atoi(line[index+1 : size]) 176 | //case MediaSequence is present, first sequence number = MediaSequence 177 | s.segmentSequence = p.MediaSequence 178 | case line[0:index] == "#EXT-X-DISCONTINUITY-SEQUENCE": 179 | p.DiscontinuitySequence, buf.Err = strconv.Atoi(line[index+1 : size]) 180 | case line == "#EXT-X-I-FRAMES-ONLY": 181 | p.IFramesOnly = true 182 | case line[0:index] == "#EXT-X-ALLOW-CACHE": 183 | if line[index+1:size] == boolYes { 184 | p.AllowCache = true 185 | } 186 | case line == "#EXT-X-INDEPENDENT-SEGMENTS": 187 | p.IndependentSegments = true 188 | case line[0:index] == "#EXT-X-PLAYLIST-TYPE": 189 | if strings.EqualFold(line[index+1:size], "VOD") || strings.EqualFold(line[index+1:size], "EVENT") { 190 | p.Type = line[index+1 : size] 191 | } 192 | case line == "#EXT-X-ENDLIST": 193 | p.EndList = true 194 | case line[0:index] == "#EXT-X-START": 195 | p.StartPoint, buf.Err = decodeStartPoint(line[index+1 : size]) 196 | 197 | // Cases below this point refers to tags that effect segments, when we reach a line with no leading #, we've reached the end of a segment definition. 198 | case line[0:index] == "#EXT-X-KEY": 199 | key := decodeKey(line[index+1:size], false) 200 | key.mediaPlaylist = p 201 | s.previousKey = key // we store this key for future reference because every segment between EXT-X-KEYs should use this key for decryption 202 | segment.Keys = append(segment.Keys, key) 203 | case line[0:index] == "#EXT-X-MAP": 204 | s.previousMap, buf.Err = decodeMap(line[index+1 : size]) 205 | s.previousMap.mediaPlaylist = p 206 | segment.Map = s.previousMap 207 | case line[0:index] == "#EXT-X-PROGRAM-DATE-TIME": 208 | segment.ProgramDateTime, buf.Err = decodeDateTime(line[index+1 : size]) 209 | case line[0:index] == "#EXT-X-DATERANGE": 210 | segment.DateRange, buf.Err = decodeDateRange(line[index+1 : size]) 211 | case line[0:index] == "#EXT-X-BYTERANGE": 212 | segment.Byterange, buf.Err = decodeByterange(line[index+1 : size]) 213 | case line[0:index] == "#EXTINF": 214 | segment.Inf, buf.Err = decodeInf(line[index+1 : size]) 215 | case !strings.HasPrefix(line, "#"): 216 | segment.URI = line 217 | segment.ID = s.segmentSequence 218 | 219 | // a previous EXT-X-KEY applies to this segment 220 | if len(segment.Keys) == 0 && s.previousKey != nil && s.previousKey.URI != "" { 221 | segment.Keys = append(segment.Keys, s.previousKey) 222 | } 223 | 224 | // a previous EXT-X-MAP applies to this segment 225 | if segment.Map == nil && s.previousMap != nil { 226 | segment.Map = s.previousMap 227 | } 228 | 229 | p.Segments = append(p.Segments, segment) 230 | 231 | // Reset segment 232 | segment = &Segment{mediaPlaylist: p} 233 | s.segmentSequence++ 234 | } 235 | } 236 | 237 | if buf.Err != nil { 238 | return buf.Err 239 | } 240 | 241 | // Check media playlist compatibility 242 | if err := p.checkCompatibility(nil); err != nil { 243 | return err 244 | } 245 | 246 | for _, segment := range p.Segments { 247 | if err := p.checkCompatibility(segment); err != nil { 248 | return err 249 | } 250 | } 251 | 252 | return nil 253 | } 254 | -------------------------------------------------------------------------------- /hls/doc.go: -------------------------------------------------------------------------------- 1 | //Package hls implements the Manifest interface of package m3u8 to encode/parse 2 | //playlists used in HTTP Live Streaming. Comments explaining type attributes are 3 | //related to HLS Spec 'MUST' and 'MUST NOT' recommendations, and should be considered 4 | //when creating your MediaPlaylist and MasterPlaylist objects for encoding. 5 | // 6 | //Example usage: 7 | // 8 | //Encoding Manifest 9 | // import "github.com/ingest/manifest/hls" 10 | // 11 | // func main(){ 12 | // //Will start a MediaPlaylist object for hls version 7 13 | // p := hls.NewMediaPlaylist(7) 14 | // p.TargetDuration = 10 15 | // p.EndList = true 16 | // segment := &hls.Segment{ 17 | // URI: "segmenturi.ts", 18 | // Inf: &hls.Inf{Duration: 9.052}, 19 | // Byterange: &hls.Byterange{Length: 400}, 20 | // } 21 | // p.Segments = append(p.Segments, segment) 22 | // reader, err := p.Encode() 23 | // if err!=nil{ 24 | // //handle error 25 | // } 26 | // 27 | // buf := new(bytes.Buffer) 28 | // buf.ReadFrom(reader) 29 | // 30 | // if err := ioutil.WriteFile("path/to/file", buf.Bytes(), 0666); err != nil { 31 | // //handle error 32 | // } 33 | // } 34 | // 35 | // 36 | // 37 | //Decoding Manifest 38 | // import "github.com/ingest/manifest/hls" 39 | // 40 | // func main(){ 41 | // f, err := os.Open("path/to/file.m3u8") 42 | // if err != nil { 43 | // //handle error 44 | // } 45 | // defer f.Close() 46 | // 47 | // playlist := &hls.MasterPlaylist{} 48 | // if err = playlist.Parse(bufio.NewReader(f)); err!=io.EOF{ 49 | // //handle error 50 | // } 51 | // //manipulate playlist 52 | // } 53 | // 54 | package hls 55 | -------------------------------------------------------------------------------- /hls/encode-util.go: -------------------------------------------------------------------------------- 1 | package hls 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strconv" 7 | "strings" 8 | "time" 9 | 10 | "github.com/ingest/manifest" 11 | ) 12 | 13 | // NewMediaPlaylist returns an instance of a MediaPlaylist with a set version 14 | func NewMediaPlaylist(version int) *MediaPlaylist { 15 | return &MediaPlaylist{ 16 | Version: version, 17 | } 18 | } 19 | 20 | // WithVariant supplies the data which was processed from the master playlist. 21 | func (p *MediaPlaylist) WithVariant(v *Variant) *MediaPlaylist { 22 | p.Variant = v 23 | return p 24 | } 25 | 26 | // NewMasterPlaylist returns an instance of a MasterPlaylist with a set version 27 | func NewMasterPlaylist(version int) *MasterPlaylist { 28 | return &MasterPlaylist{Version: version} 29 | } 30 | 31 | func backwardsCompatibilityError(version int, tag string) error { 32 | return fmt.Errorf("Backwards compatibility error on tag %s with version %d", tag, version) 33 | } 34 | 35 | func attributeNotSetError(tag string, attribute string) error { 36 | return fmt.Errorf("%s attribute %s must be set", tag, attribute) 37 | } 38 | 39 | //writeHeader sets the initial tags for both Media and Master Playlists files 40 | func writeHeader(version int, buf *manifest.BufWrapper) { 41 | if version > 0 { 42 | buf.WriteString("#EXTM3U\n#EXT-X-VERSION:") 43 | buf.WriteString(strconv.Itoa(version)) 44 | buf.WriteRune('\n') 45 | return 46 | } 47 | buf.Err = attributeNotSetError("Playlist", "Version") 48 | } 49 | 50 | //writeIndependentSegment sets the #EXT-X-INDEPENDENT-SEGMENTS tag on Media and Master Playlist file 51 | func writeIndependentSegment(isIndSeg bool, buf *manifest.BufWrapper) { 52 | if isIndSeg { 53 | buf.WriteString("#EXT-X-INDEPENDENT-SEGMENTS\n") 54 | } 55 | } 56 | 57 | //writeStartPoint sets the #EXT-X-START tag on Media and Master Playlist file 58 | func writeStartPoint(sp *StartPoint, buf *manifest.BufWrapper) { 59 | if sp != nil { 60 | buf.WriteString(fmt.Sprintf("#EXT-X-START:TIME-OFFSET=%s", strconv.FormatFloat(sp.TimeOffset, 'f', 3, 32))) 61 | buf.WriteValidString(sp.Precise, ",PRECISE=YES") 62 | buf.WriteRune('\n') 63 | } 64 | } 65 | 66 | //writeSessionData sets the EXT-X-SESSION-DATA tag on Master Playlist file 67 | func (s *SessionData) writeSessionData(buf *manifest.BufWrapper) { 68 | if s != nil { 69 | if !buf.WriteValidString(s.DataID, fmt.Sprintf("#EXT-X-SESSION-DATA:DATA-ID=\"%s\"", s.DataID)) { 70 | buf.Err = attributeNotSetError("EXT-X-SESSION-DATA", "DATA-ID") 71 | return 72 | } 73 | 74 | if s.Value != "" && s.URI != "" { 75 | buf.Err = errors.New("EXT-X-SESSION-DATA must have attributes URI or VALUE, not both") 76 | return 77 | } else if s.Value != "" || s.URI != "" { 78 | buf.WriteValidString(s.Value, fmt.Sprintf(",VALUE=\"%s\"", s.Value)) 79 | buf.WriteValidString(s.URI, fmt.Sprintf(",URI=\"%s\"", s.URI)) 80 | } else { 81 | buf.Err = errors.New("EXT-X-SESSION-DATA must have either URI or VALUE attributes set") 82 | return 83 | } 84 | 85 | buf.WriteValidString(s.Language, fmt.Sprintf(",LANGUAGE=\"%s\"", s.Language)) 86 | buf.WriteRune('\n') 87 | } 88 | } 89 | 90 | //writeXMedia sets the EXT-X-MEDIA tag on Master Playlist file 91 | func (r *Rendition) writeXMedia(buf *manifest.BufWrapper) { 92 | if r != nil { 93 | 94 | if !isValidType(strings.ToUpper(r.Type)) || !buf.WriteValidString(r.Type, fmt.Sprintf("#EXT-X-MEDIA:TYPE=%s", r.Type)) { 95 | buf.Err = attributeNotSetError("EXT-X-MEDIA", "TYPE") 96 | return 97 | } 98 | if !buf.WriteValidString(r.GroupID, fmt.Sprintf(",GROUP-ID=\"%s\"", r.GroupID)) { 99 | buf.Err = attributeNotSetError("EXT-X-MEDIA", "GROUP-ID") 100 | return 101 | } 102 | if !buf.WriteValidString(r.Name, fmt.Sprintf(",NAME=\"%s\"", r.Name)) { 103 | buf.Err = attributeNotSetError("EXT-X-MEDIA", "NAME") 104 | return 105 | } 106 | buf.WriteValidString(r.Language, fmt.Sprintf(",LANGUAGE=\"%s\"", r.Language)) 107 | buf.WriteValidString(r.AssocLanguage, fmt.Sprintf(",ASSOC-LANGUAGE=\"%s\"", r.AssocLanguage)) 108 | buf.WriteValidString(r.Default, ",DEFAULT=YES") 109 | if r.Forced && strings.EqualFold(r.Type, sub) { 110 | buf.WriteValidString(r.Forced, ",FORCED=YES") 111 | } 112 | if strings.EqualFold(r.Type, cc) && isValidInstreamID(strings.ToUpper(r.InstreamID)) { 113 | buf.WriteValidString(r.InstreamID, fmt.Sprintf(",INSTREAM-ID=\"%s\"", r.InstreamID)) 114 | } 115 | buf.WriteValidString(r.Characteristics, fmt.Sprintf(",CHARACTERISTICS=\"%s\"", r.Characteristics)) 116 | 117 | //URI is required for SUBTITLES and MUST NOT be present for CLOSED-CAPTIONS, other types URI is optinal 118 | if strings.EqualFold(r.Type, sub) { 119 | if !buf.WriteValidString(r.URI, fmt.Sprintf(",URI=\"%s\"", r.URI)) { 120 | buf.Err = attributeNotSetError("EXT-X-MEDIA", "URI for SUBTITLES") 121 | return 122 | } 123 | } else if !strings.EqualFold(r.Type, cc) { 124 | buf.WriteValidString(r.URI, fmt.Sprintf(",URI=\"%s\"", r.URI)) 125 | } 126 | 127 | buf.WriteRune('\n') 128 | } 129 | } 130 | 131 | //isValidType checks rendition Type is supported value (AUDIO, VIDEO, CLOSED-CAPTIONS or SUBTITLES) 132 | func isValidType(t string) bool { 133 | return t == aud || t == vid || t == cc || t == sub 134 | } 135 | 136 | //isValidInstreamID checks rendition InstreamID is supported value 137 | func isValidInstreamID(instream string) bool { 138 | return instream == "CC1" || instream == "CC2" || instream == "CC3" || instream == "CC4" || strings.HasPrefix(instream, "SERVICE") 139 | } 140 | 141 | //writeStreamInf sets the EXT-X-STREAM-INF or EXT-X-I-FRAME-STREAM-INF tag on Master Playlist file 142 | func (v *Variant) writeStreamInf(version int, buf *manifest.BufWrapper) { 143 | if v != nil { 144 | if v.IsIframe { 145 | buf.WriteString("#EXT-X-I-FRAME-STREAM-INF:") 146 | } else { 147 | buf.WriteString("#EXT-X-STREAM-INF:") 148 | } 149 | 150 | if !buf.WriteValidString(v.Bandwidth, fmt.Sprintf("BANDWIDTH=%s", strconv.FormatInt(v.Bandwidth, 10))) { 151 | buf.Err = attributeNotSetError("Variant", "BANDWIDTH") 152 | return 153 | } 154 | if version < 6 && v.ProgramID > 0 { 155 | buf.WriteValidString(v.ProgramID, fmt.Sprintf(",PROGRAM-ID=%s", strconv.FormatInt(v.ProgramID, 10))) 156 | } 157 | buf.WriteValidString(v.AvgBandwidth, fmt.Sprintf(",AVERAGE-BANDWIDTH=%s", strconv.FormatInt(v.AvgBandwidth, 10))) 158 | buf.WriteValidString(v.Codecs, fmt.Sprintf(",CODECS=\"%s\"", v.Codecs)) 159 | buf.WriteValidString(v.Resolution, fmt.Sprintf(",RESOLUTION=%s", v.Resolution)) 160 | buf.WriteValidString(v.FrameRate, fmt.Sprintf(",FRAME-RATE=%s", strconv.FormatFloat(v.FrameRate, 'f', 3, 32))) 161 | buf.WriteValidString(v.Video, fmt.Sprintf(",VIDEO=\"%s\"", v.Video)) 162 | //If is not IFrame tag, adds AUDIO, SUBTITLES and CLOSED-CAPTIONS params 163 | if !v.IsIframe { 164 | buf.WriteValidString(v.Audio, fmt.Sprintf(",AUDIO=\"%s\"", v.Audio)) 165 | buf.WriteValidString(v.Subtitles, fmt.Sprintf(",SUBTITLES=\"%s\"", v.Subtitles)) 166 | buf.WriteValidString(v.ClosedCaptions, fmt.Sprintf(",CLOSED-CAPTIONS=\"%s\"", v.ClosedCaptions)) 167 | //If not IFrame, URI is in its own line 168 | buf.WriteString(fmt.Sprintf("\n%s\n", v.URI)) 169 | } else { 170 | //If Iframe, URI is a param 171 | buf.WriteValidString(v.URI, fmt.Sprintf(",URI=\"%s\"\n", v.URI)) 172 | } 173 | } 174 | } 175 | 176 | func (p *MediaPlaylist) writeTargetDuration(buf *manifest.BufWrapper) { 177 | if !buf.WriteValidString(p.TargetDuration, fmt.Sprintf("#EXT-X-TARGETDURATION:%s\n", strconv.Itoa(p.TargetDuration))) { 178 | buf.Err = attributeNotSetError("EXT-X-TARGETDURATION", "") 179 | } 180 | } 181 | 182 | func (p *MediaPlaylist) writeMediaSequence(buf *manifest.BufWrapper) { 183 | if p.MediaSequence > 0 { 184 | buf.WriteString(fmt.Sprintf("#EXT-X-MEDIA-SEQUENCE:%s\n", strconv.Itoa(p.MediaSequence))) 185 | } 186 | } 187 | 188 | func (p *MediaPlaylist) writeDiscontinuitySequence(buf *manifest.BufWrapper) { 189 | if p.DiscontinuitySequence > 0 { 190 | buf.WriteString(fmt.Sprintf("#EXT-X-DISCONTINUITY-SEQUENCE:%s\n", strconv.Itoa(p.DiscontinuitySequence))) 191 | } 192 | } 193 | 194 | func (p *MediaPlaylist) writeAllowCache(buf *manifest.BufWrapper) { 195 | if p.Version < 7 && p.AllowCache { 196 | buf.WriteString("#EXT-X-ALLOW-CACHE:YES\n") 197 | } 198 | } 199 | 200 | func (p *MediaPlaylist) writePlaylistType(buf *manifest.BufWrapper) { 201 | if p.Type != "" { 202 | buf.WriteString(fmt.Sprintf("#EXT-X-PLAYLIST-TYPE:%s\n", p.Type)) 203 | } 204 | } 205 | 206 | func (p *MediaPlaylist) writeIFramesOnly(buf *manifest.BufWrapper) { 207 | if p.IFramesOnly { 208 | if p.Version < 4 { 209 | buf.Err = backwardsCompatibilityError(p.Version, "#EXT-X-I-FRAMES-ONLY") 210 | return 211 | } 212 | buf.WriteString("#EXT-X-I-FRAMES-ONLY\n") 213 | } 214 | } 215 | 216 | func (s *Segment) writeSegmentTags(buf *manifest.BufWrapper, previousSegment *Segment, version int) { 217 | if s != nil { 218 | for _, key := range s.Keys { 219 | 220 | found := false 221 | // If the previous segment we printed contains the same key, we shouldn't output it again 222 | if previousSegment != nil { 223 | for _, oldKey := range previousSegment.Keys { 224 | if key == oldKey { 225 | found = true 226 | break 227 | } 228 | } 229 | } 230 | 231 | if !found { 232 | key.writeKey(buf) 233 | } 234 | 235 | if buf.Err != nil { 236 | return 237 | } 238 | } 239 | 240 | if previousSegment == nil || previousSegment.Map == nil || (previousSegment.Map != s.Map) { 241 | s.Map.writeMap(buf) 242 | } 243 | 244 | if buf.Err != nil { 245 | return 246 | } 247 | 248 | if !s.ProgramDateTime.IsZero() { 249 | buf.WriteString(fmt.Sprintf("#EXT-X-PROGRAM-DATE-TIME:%s\n", s.ProgramDateTime.Format(time.RFC3339Nano))) 250 | } 251 | buf.WriteValidString(s.Discontinuity, "#EXT-X-DISCONTINUITY\n") 252 | 253 | s.DateRange.writeDateRange(buf) 254 | if buf.Err != nil { 255 | return 256 | } 257 | 258 | if s.Inf == nil { 259 | buf.Err = attributeNotSetError("EXTINF", "DURATION") 260 | return 261 | } 262 | 263 | if version < 3 { 264 | var duration int 265 | if s.Inf.Duration < 0.5 { 266 | duration = 0 267 | } 268 | // s.Inf.Duration is always > 0, so no need to use math.Abs or math.Copysign on the + 0.5 269 | duration = int(s.Inf.Duration + 0.5) 270 | buf.WriteString(fmt.Sprintf("#EXTINF:%d,%s\n", duration, s.Inf.Title)) 271 | } else { 272 | buf.WriteString(fmt.Sprintf("#EXTINF:%s,%s\n", strconv.FormatFloat(s.Inf.Duration, 'f', 3, 32), s.Inf.Title)) 273 | } 274 | 275 | if s.Byterange != nil { 276 | buf.WriteString(fmt.Sprintf("#EXT-X-BYTERANGE:%s", strconv.FormatInt(s.Byterange.Length, 10))) 277 | if s.Byterange.Offset != nil { 278 | buf.WriteString("@" + strconv.FormatInt(*s.Byterange.Offset, 10)) 279 | } 280 | buf.WriteRune('\n') 281 | } 282 | 283 | if s.URI != "" { 284 | buf.WriteString(s.URI) 285 | buf.WriteRune('\n') 286 | } else { 287 | buf.Err = attributeNotSetError("Segment", "URI") 288 | return 289 | } 290 | } 291 | } 292 | 293 | func (k *Key) writeKey(buf *manifest.BufWrapper) { 294 | if k != nil { 295 | if k.IsSession { 296 | buf.WriteString("#EXT-X-SESSION-KEY:") 297 | } else { 298 | buf.WriteString("#EXT-X-KEY:") 299 | } 300 | 301 | if !isValidMethod(k.IsSession, strings.ToUpper(k.Method)) || 302 | !buf.WriteValidString(k.Method, fmt.Sprintf("METHOD=%s", strings.ToUpper(k.Method))) { 303 | buf.Err = attributeNotSetError("KEY", "METHOD") 304 | return 305 | } 306 | if k.URI != "" && strings.ToUpper(k.Method) != none { 307 | buf.WriteValidString(k.URI, fmt.Sprintf(",URI=\"%s\"", k.URI)) 308 | } else { 309 | buf.Err = attributeNotSetError("EXT-X-KEY", "URI") 310 | return 311 | } 312 | buf.WriteValidString(k.IV, fmt.Sprintf(",IV=%s", k.IV)) 313 | buf.WriteValidString(k.Keyformat, fmt.Sprintf(",KEYFORMAT=\"%s\"", k.Keyformat)) 314 | buf.WriteValidString(k.Keyformatversions, fmt.Sprintf(",KEYFORMATVERSIONS=\"%s\"", k.Keyformatversions)) 315 | buf.WriteRune('\n') 316 | } 317 | } 318 | 319 | //isValidMethod checks Key Method value is supported. Session Key Method can't be NONE 320 | func isValidMethod(isSession bool, method string) bool { 321 | return (method == aes || method == sample) || (!isSession && method == none) 322 | } 323 | 324 | func (m *Map) writeMap(buf *manifest.BufWrapper) { 325 | if m != nil { 326 | if !buf.WriteValidString(m.URI, fmt.Sprintf("#EXT-X-MAP:URI=\"%s\"", m.URI)) { 327 | buf.Err = attributeNotSetError("EXT-X-MAP", "URI") 328 | return 329 | } 330 | if m.Byterange != nil { 331 | if m.Byterange.Offset == nil { 332 | o := int64(0) 333 | m.Byterange.Offset = &o 334 | } 335 | buf.WriteString(fmt.Sprintf(",BYTERANGE=\"%s@%s\"", 336 | strconv.FormatInt(m.Byterange.Length, 10), 337 | strconv.FormatInt(*m.Byterange.Offset, 10))) 338 | } 339 | buf.WriteRune('\n') 340 | } 341 | } 342 | 343 | func (d *DateRange) writeDateRange(buf *manifest.BufWrapper) { 344 | if d != nil { 345 | if !buf.WriteValidString(d.ID, fmt.Sprintf("#EXT-X-DATERANGE:ID=%s", d.ID)) { 346 | buf.Err = attributeNotSetError("EXT-X-DATERANGE", "ID") 347 | return 348 | } 349 | buf.WriteValidString(d.Class, fmt.Sprintf(",CLASS=\"%s\"", d.Class)) 350 | if !d.StartDate.IsZero() { 351 | buf.WriteString(fmt.Sprintf(",START-DATE=\"%s\"", d.StartDate.Format(time.RFC3339Nano))) 352 | } else { 353 | buf.Err = attributeNotSetError("EXT-X-DATERANGE", "START-DATE") 354 | return 355 | } 356 | if !d.EndDate.IsZero() { 357 | if d.EndDate.Before(d.StartDate) { 358 | buf.Err = errors.New("DateRange attribute EndDate must be equal or later than StartDate") 359 | return 360 | } 361 | buf.WriteString(fmt.Sprintf(",END-DATE=\"%s\"", d.EndDate.Format(time.RFC3339Nano))) 362 | } 363 | if d.Duration != nil && *d.Duration >= float64(0) { 364 | buf.WriteString(fmt.Sprintf(",DURATION=%s", strconv.FormatFloat(*d.Duration, 'f', 3, 32))) 365 | } 366 | if d.PlannedDuration != nil && *d.PlannedDuration >= float64(0) { 367 | buf.WriteString(fmt.Sprintf(",PLANNED-DURATION=%s", strconv.FormatFloat(*d.PlannedDuration, 'f', 3, 32))) 368 | } 369 | if len(d.XClientAttribute) > 0 { 370 | for _, customTag := range d.XClientAttribute { 371 | if !strings.HasPrefix(strings.ToUpper(customTag), "X-") { 372 | buf.Err = errors.New("EXT-X-DATERANGE client-defined attributes must start with X-") 373 | return 374 | } 375 | buf.WriteString(",") 376 | buf.WriteString(strings.ToUpper(customTag)) 377 | } 378 | } 379 | 380 | d.SCTE35.writeSCTE(buf) 381 | 382 | if buf.WriteValidString(d.EndOnNext, ",END-ON-NEXT=YES") { 383 | if d.Class == "" { 384 | buf.Err = errors.New("EXT-X-DATERANGE tag must have a CLASS attribute when END-ON-NEXT attribue is present") 385 | return 386 | } 387 | if d.Duration != nil || !d.EndDate.IsZero() { 388 | buf.Err = errors.New("EXT-X-DATERANGE tag must not have DURATION or END-DATE attributes when END-ON-NEXT attribute is present") 389 | return 390 | } 391 | } 392 | buf.WriteRune('\n') 393 | } 394 | } 395 | 396 | func (s *SCTE35) writeSCTE(buf *manifest.BufWrapper) { 397 | if s != nil { 398 | t := strings.ToUpper(s.Type) 399 | if t == "IN" || t == "OUT" || t == "CMD" { 400 | if !buf.WriteValidString(s.Value, fmt.Sprintf(",SCTE35-%s=%s", t, s.Value)) { 401 | buf.Err = attributeNotSetError("SCTE35", "Value") 402 | } 403 | return 404 | } 405 | buf.Err = errors.New("SCTE35 type must be IN, OUT or CMD") 406 | } 407 | } 408 | 409 | func (p *MediaPlaylist) writeEndList(buf *manifest.BufWrapper) { 410 | if p.EndList { 411 | buf.WriteString("#EXT-X-ENDLIST\n") 412 | } 413 | } 414 | 415 | //checkCompatibility checks backwards compatibility issues according to the Media Playlist version 416 | func (p *MediaPlaylist) checkCompatibility(s *Segment) error { 417 | if s != nil { 418 | if s.Inf != nil && p.Version < 3 { 419 | if s.Inf.Duration != float64(int64(s.Inf.Duration)) { 420 | return backwardsCompatibilityError(p.Version, "#EXTINF") 421 | } 422 | } 423 | 424 | if s.Byterange != nil && p.Version < 4 { 425 | return backwardsCompatibilityError(p.Version, "#EXT-X-BYTERANGE") 426 | } 427 | 428 | for _, key := range s.Keys { 429 | if key.IV != "" && p.Version < 2 { 430 | return backwardsCompatibilityError(p.Version, "#EXT-X-KEY") 431 | } 432 | 433 | if (key.Keyformat != "" || key.Keyformatversions != "") && p.Version < 5 { 434 | return backwardsCompatibilityError(p.Version, "#EXT-X-KEY") 435 | } 436 | } 437 | 438 | if s.Map != nil { 439 | if p.Version < 5 || (!p.IFramesOnly && p.Version < 6) { 440 | return backwardsCompatibilityError(p.Version, "#EXT-X-MAP") 441 | } 442 | } 443 | } else { 444 | if p.IFramesOnly && p.Version < 4 { 445 | return backwardsCompatibilityError(p.Version, "#EXT-X-I-FRAMES-ONLY") 446 | } 447 | } 448 | 449 | return nil 450 | } 451 | 452 | func (p *MasterPlaylist) checkCompatibility() error { 453 | switch { 454 | case p.Version < 7: 455 | for _, rendition := range p.Renditions { 456 | if rendition.Type == cc { 457 | if strings.HasPrefix(rendition.InstreamID, "SERVICE") { 458 | return backwardsCompatibilityError(p.Version, "#EXT-X-MEDIA") 459 | } 460 | } 461 | } 462 | } 463 | 464 | return nil 465 | } 466 | 467 | //TODO:(sliding window) - MediaPlaylist constructor receiving sliding window size. In the case of sliding window playlist, we 468 | //must not include a EXT-X-PLAYLIST-TYPE tag since EVENT and VOD don't support removing segments from the manifest. 469 | //TODO:(sliding window/live streaming) - Public method to add segment to a MediaPlaylist. This method would need helper methods, to check 470 | //sliding window size and remove segment when necessary. If playlist type is EVENT, only adds without removing. 471 | //Also helper methods to update MediaSequence and DiscontinuitySequence values 472 | //TODO:(sliding window/live streaming) - Public method to insert EXT-X-ENDLIST tag when EVENT or sliding window playlist reaches its end 473 | //TODO:(sliding window) - Figure out a way to control tags like KEY, MAP etc, that can be applicable to following segments. 474 | //What to do when that segment is removed (tags would in theory be removed with it)? Add methods to slide these tags along with the window. 475 | -------------------------------------------------------------------------------- /hls/encode-util_test.go: -------------------------------------------------------------------------------- 1 | package hls 2 | 3 | import ( 4 | "bytes" 5 | "sort" 6 | "strings" 7 | "testing" 8 | "time" 9 | 10 | "github.com/ingest/manifest" 11 | ) 12 | 13 | func TestWriteXMedia(t *testing.T) { 14 | rendition := &Rendition{ 15 | Type: "VIDEO", 16 | GroupID: "TestID", 17 | Name: "Test", 18 | Language: "English", 19 | Default: true, 20 | URI: "http://test.com", 21 | } 22 | 23 | buf := manifest.NewBufWrapper() 24 | 25 | rendition.writeXMedia(buf) 26 | if buf.Err != nil { 27 | t.Errorf("Expected err to be nil, but got %s", buf.Err.Error()) 28 | } 29 | } 30 | 31 | func TestWriteXMediaTypeError(t *testing.T) { 32 | rendition := &Rendition{ 33 | GroupID: "TestID", 34 | } 35 | 36 | buf := manifest.NewBufWrapper() 37 | 38 | rendition.writeXMedia(buf) 39 | if buf.Err.Error() != attributeNotSetError("EXT-X-MEDIA", "TYPE").Error() { 40 | t.Errorf("Expected err to be %s, but got %s", attributeNotSetError("EXT-X-MEDIA", "TYPE"), buf.Err.Error()) 41 | } 42 | } 43 | 44 | func TestWriteXMediaGroupError(t *testing.T) { 45 | rendition := &Rendition{ 46 | Type: "AUDIO", 47 | } 48 | 49 | buf := manifest.NewBufWrapper() 50 | 51 | rendition.writeXMedia(buf) 52 | if buf.Err.Error() != attributeNotSetError("EXT-X-MEDIA", "GROUP-ID").Error() { 53 | t.Errorf("Expected err to be %s, but got %s", attributeNotSetError("EXT-X-MEDIA", "GROUP-ID"), buf.Err.Error()) 54 | } 55 | } 56 | 57 | func TestWriteXMediaInvalid(t *testing.T) { 58 | rendition := &Rendition{ 59 | Type: "CLOSED-CAPTIONS", 60 | GroupID: "TestID", 61 | InstreamID: "CC3", 62 | Name: "Test", 63 | } 64 | 65 | buf := manifest.NewBufWrapper() 66 | 67 | rendition.writeXMedia(buf) 68 | if buf.Err != nil { 69 | t.Errorf("Expected err to be nil") 70 | } 71 | 72 | rendition.URI = "test" 73 | buf = manifest.NewBufWrapper() 74 | rendition.writeXMedia(buf) 75 | if strings.Contains(buf.Buf.String(), "URI") { 76 | t.Error("Expected buf to not contain URI") 77 | } 78 | 79 | rendition.Type = "SUBTITLES" 80 | rendition.URI = "" 81 | buf = manifest.NewBufWrapper() 82 | rendition.writeXMedia(buf) 83 | if buf.Err.Error() != attributeNotSetError("EXT-X-MEDIA", "URI for SUBTITLES").Error() { 84 | t.Errorf("Exptected err to be %s, but got %s", attributeNotSetError("EXT-X-MEDIA", "URI for SUBTITLES").Error(), buf.Err.Error()) 85 | } 86 | } 87 | 88 | func TestWriteStreamInf(t *testing.T) { 89 | variant := &Variant{ 90 | IsIframe: true, 91 | URI: "http://test.com", 92 | Bandwidth: 234000, 93 | Resolution: "230x400", 94 | } 95 | 96 | buf := manifest.NewBufWrapper() 97 | variant.writeStreamInf(7, buf) 98 | 99 | if buf.Err != nil { 100 | t.Fatalf("Expected err to be nil, but got %s", buf.Err.Error()) 101 | } 102 | 103 | if !strings.Contains(buf.Buf.String(), "#EXT-X-I-FRAME-STREAM-INF") { 104 | t.Error("Expected buf to contain #EXT-X-I-FRAME-STREAM-INF") 105 | } 106 | 107 | if strings.Contains(buf.Buf.String(), "#EXT-X-STREAM-INF") { 108 | t.Error("Expected buf to not contain #EXT-X-STREAM-INF") 109 | } 110 | } 111 | 112 | func TestGenerateMasterPlaylist(t *testing.T) { 113 | rend := &Rendition{ 114 | Type: "VIDEO", 115 | GroupID: "TestID", 116 | Name: "Test", 117 | Language: "English", 118 | Default: true, 119 | URI: "http://test.com", 120 | } 121 | rend2 := &Rendition{ 122 | Type: "AUDIO", 123 | GroupID: "Testing", 124 | Name: "Another test", 125 | Language: "English", 126 | Default: false, 127 | } 128 | rend3 := &Rendition{ 129 | Type: "VIDEO", 130 | GroupID: "Test", 131 | Name: "Bla", 132 | Language: "Portuguese", 133 | } 134 | 135 | variant := &Variant{ 136 | IsIframe: false, 137 | URI: "http://test.com", 138 | Bandwidth: 234000, 139 | Resolution: "230x400", 140 | Codecs: "These codescs", 141 | } 142 | 143 | variant2 := &Variant{ 144 | IsIframe: false, 145 | URI: "thistest.com", 146 | Bandwidth: 145000, 147 | } 148 | 149 | p := NewMasterPlaylist(5) 150 | p.Variants = append(p.Variants, variant, variant2) 151 | p.Renditions = append(p.Renditions, rend, rend2, rend3) 152 | p.SessionData = []*SessionData{&SessionData{DataID: "test", Value: "this is the session data"}} 153 | p.SessionKeys = []*Key{&Key{IsSession: true, Method: "sample-aes", URI: "keyuri"}} 154 | p.IndependentSegments = true 155 | buf, err := p.Encode() 156 | 157 | if err != nil { 158 | t.Fatalf("Expected err to be nil, but got %s", err.Error()) 159 | } 160 | 161 | b := new(bytes.Buffer) 162 | b.ReadFrom(buf) 163 | 164 | if !strings.Contains(b.String(), "#EXT-X-SESSION-DATA") { 165 | t.Error("Expected buf to contain #EXT-X-SESSION-DATA") 166 | } 167 | 168 | if !strings.Contains(b.String(), "#EXT-X-SESSION-KEY") { 169 | t.Error("Expected buf to contain #EXT-X-SESSION-KEY") 170 | } 171 | 172 | if !strings.Contains(b.String(), "#EXT-X-INDEPENDENT-SEGMENTS") { 173 | t.Error("Expected buf to contain #EXT-X-INDEPENDENT-SEGMENTS") 174 | } 175 | } 176 | 177 | func TestGenerateMediaPlaylist(t *testing.T) { 178 | offset := int64(700) 179 | 180 | seg := &Segment{ 181 | URI: "segment.com", 182 | Inf: &Inf{Duration: 9.052}, 183 | Byterange: &Byterange{Length: 6000, Offset: &offset}, 184 | Keys: []*Key{&Key{Method: "sample-aes", URI: "keyuri"}}, 185 | Map: &Map{URI: "mapuri"}, 186 | DateRange: &DateRange{ID: "test", 187 | StartDate: time.Now(), 188 | EndDate: time.Now().Add(1 * time.Hour), 189 | SCTE35: &SCTE35{Type: "in", Value: "blablabla"}, 190 | XClientAttribute: []string{"X-THIS-TAG=TEST", "X-THIS-OTHER-TAG=TESTING"}}, 191 | } 192 | 193 | p := NewMediaPlaylist(7) 194 | p.Segments = append(p.Segments, seg) 195 | p.TargetDuration = 10 196 | p.EndList = true 197 | p.MediaSequence = 1 198 | p.StartPoint = &StartPoint{TimeOffset: 10.543} 199 | 200 | buf, err := p.Encode() 201 | if err != nil { 202 | t.Fatalf("Expected err to be nil, but got %s", err.Error()) 203 | } 204 | 205 | b := new(bytes.Buffer) 206 | b.ReadFrom(buf) 207 | if !strings.Contains(b.String(), "#EXT-X-TARGETDURATION:10") { 208 | t.Error("Expected buf to contain #EXT-X-TARGETDURATION") 209 | } 210 | 211 | if !strings.Contains(b.String(), "#EXT-X-BYTERANGE:6000@700") { 212 | t.Error("Expected buf to contain #EXT-X-BYTERANGE") 213 | } 214 | 215 | if !strings.Contains(b.String(), "#EXT-X-ENDLIST") { 216 | t.Error("Expected buf to contain #EXT-X-ENDLIST") 217 | } 218 | 219 | if !strings.Contains(b.String(), "#EXT-X-MEDIA-SEQUENCE:1") { 220 | t.Error("Expected buf to contain #EXT-X-MEDIA-SEQUENCE") 221 | } 222 | 223 | if !strings.Contains(b.String(), "#EXT-X-START:TIME-OFFSET=10.543") { 224 | t.Error("Expected buf to contain #EXT-X-START") 225 | } 226 | 227 | if strings.Contains(b.String(), "#EXT-X-I-FRAMES-ONLY") { 228 | t.Error("Expected buf to not contain #EXT-X-I-FRAMES-ONLY") 229 | } 230 | 231 | p.Segments[0].Inf.Duration = 0 232 | p.StartPoint.TimeOffset = 0 233 | 234 | buf, err = p.Encode() 235 | if err != nil { 236 | t.Fatalf("Expected err to be nil, but got %s", err.Error()) 237 | } 238 | 239 | b = new(bytes.Buffer) 240 | b.ReadFrom(buf) 241 | 242 | if !strings.Contains(b.String(), "#EXT-X-START:TIME-OFFSET=0.000") { 243 | t.Error("Expected buf to contain #EXT-X-START") 244 | } 245 | 246 | if !strings.Contains(b.String(), "#EXTINF:0.000,") { 247 | t.Error("Expected buf to contain #EXT-X-START") 248 | } 249 | } 250 | 251 | func TestDateRange(t *testing.T) { 252 | buf := manifest.NewBufWrapper() 253 | d := &DateRange{} 254 | d.writeDateRange(buf) 255 | if buf.Err.Error() != attributeNotSetError("EXT-X-DATERANGE", "ID").Error() { 256 | t.Errorf("Expected err to be %s, but got %s", attributeNotSetError("EXT-X-DATERANGE", "ID"), buf.Err) 257 | } 258 | 259 | buf = manifest.NewBufWrapper() 260 | d.ID = "test" 261 | d.writeDateRange(buf) 262 | if buf.Err.Error() != attributeNotSetError("EXT-X-DATERANGE", "START-DATE").Error() { 263 | t.Errorf("Expected err to be %s, but got %s", attributeNotSetError("EXT-X-DATERANGE", "START-DATE"), buf.Err) 264 | } 265 | 266 | buf = manifest.NewBufWrapper() 267 | d.StartDate = time.Now() 268 | d.EndOnNext = true 269 | d.writeDateRange(buf) 270 | if buf.Err == nil { 271 | t.Error("EndOnNext without Class should return error") 272 | } 273 | 274 | buf = manifest.NewBufWrapper() 275 | d.EndDate = time.Now().Add(-1 * time.Hour) 276 | d.EndOnNext = false 277 | d.writeDateRange(buf) 278 | if buf.Err == nil { 279 | t.Error("EndDate before StartDate should return error") 280 | } 281 | } 282 | 283 | func TestMap(t *testing.T) { 284 | buf := manifest.NewBufWrapper() 285 | m := &Map{ 286 | Byterange: &Byterange{ 287 | Length: 100, 288 | }, 289 | } 290 | m.writeMap(buf) 291 | if buf.Err.Error() != attributeNotSetError("EXT-X-MAP", "URI").Error() { 292 | t.Fatalf("Expected err to be %s, but got %s", attributeNotSetError("EXT-X-MAP", "URI").Error(), buf.Err.Error()) 293 | } 294 | 295 | buf = manifest.NewBufWrapper() 296 | m.URI = "test" 297 | m.writeMap(buf) 298 | if buf.Err != nil { 299 | t.Error("Expected err to be nil") 300 | } 301 | 302 | if !strings.Contains(buf.Buf.String(), "#EXT-X-MAP:URI=\"test\",BYTERANGE=\"100@0\"") { 303 | t.Error("Expected buf to contain #EXT-X-MAP") 304 | } 305 | } 306 | 307 | func TestCompatibilityCheck(t *testing.T) { 308 | p := NewMediaPlaylist(4) 309 | s := &Segment{ 310 | Keys: []*Key{&Key{Method: "sample-aes", URI: "keyuri", Keyformat: "com.apple.streamingkeydelivery", Keyformatversions: "1"}}, 311 | } 312 | 313 | err := p.checkCompatibility(s) 314 | if err.Error() != backwardsCompatibilityError(p.Version, "#EXT-X-KEY").Error() { 315 | t.Errorf("Error should be %s, but got %s", backwardsCompatibilityError(p.Version, "#EXT-X-KEY"), err) 316 | } 317 | 318 | p = NewMediaPlaylist(5) 319 | err = p.checkCompatibility(s) 320 | if err != nil { 321 | t.Errorf("Expected err to be nil, but got %s", err) 322 | } 323 | 324 | s = &Segment{ 325 | Map: &Map{ 326 | URI: "test", 327 | }, 328 | } 329 | 330 | err = p.checkCompatibility(s) 331 | if err.Error() != backwardsCompatibilityError(p.Version, "#EXT-X-MAP").Error() { 332 | t.Errorf("Error should be %s, but got %s", backwardsCompatibilityError(p.Version, "#EXT-X-MAP"), err) 333 | } 334 | 335 | p = NewMediaPlaylist(3) 336 | s = &Segment{ 337 | Byterange: &Byterange{Length: 100}, 338 | } 339 | 340 | err = p.checkCompatibility(s) 341 | if err.Error() != backwardsCompatibilityError(p.Version, "#EXT-X-BYTERANGE").Error() { 342 | t.Errorf("Error should be %s, but got %s", backwardsCompatibilityError(p.Version, "#EXT-X-BYTERANGE").Error(), err.Error()) 343 | } 344 | } 345 | 346 | func TestSortSegments(t *testing.T) { 347 | s := &Segment{ 348 | ID: 1, 349 | URI: "firstsegment", 350 | } 351 | s2 := &Segment{ 352 | ID: 2, 353 | URI: "secondsegment", 354 | } 355 | s3 := &Segment{ 356 | ID: 3, 357 | URI: "thirdsegment", 358 | } 359 | var segs Segments 360 | segs = append(segs, s3, s, s2) 361 | sort.Sort(segs) 362 | for i := range segs { 363 | if segs[i].ID != i+1 { 364 | t.Errorf("Expected seg %d ID to be %d, but got %d", i, i+1, segs[i].ID) 365 | } 366 | } 367 | } 368 | -------------------------------------------------------------------------------- /hls/encode.go: -------------------------------------------------------------------------------- 1 | package hls 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "io" 7 | "sort" 8 | 9 | "github.com/ingest/manifest" 10 | ) 11 | 12 | //Encode writes a Master Playlist file 13 | func (p *MasterPlaylist) Encode() (io.Reader, error) { 14 | if err := p.checkCompatibility(); err != nil { 15 | return nil, err 16 | } 17 | 18 | buf := manifest.NewBufWrapper() 19 | 20 | //Write header tags 21 | writeHeader(p.Version, buf) 22 | if buf.Err != nil { 23 | return nil, buf.Err 24 | } 25 | 26 | //Write Session Data tags if enabled 27 | if p.SessionData != nil { 28 | for _, sd := range p.SessionData { 29 | sd.writeSessionData(buf) 30 | if buf.Err != nil { 31 | return nil, buf.Err 32 | } 33 | } 34 | } 35 | //write session keys tags if enabled 36 | if p.SessionKeys != nil { 37 | for _, sk := range p.SessionKeys { 38 | sk.writeKey(buf) 39 | if buf.Err != nil { 40 | return nil, buf.Err 41 | } 42 | } 43 | } 44 | 45 | //Write Independent Segments tag if enabled 46 | writeIndependentSegment(p.IndependentSegments, buf) 47 | 48 | //write Start tag if enabled 49 | writeStartPoint(p.StartPoint, buf) 50 | if buf.Err != nil { 51 | return nil, buf.Err 52 | } 53 | 54 | // For every Rendition, write #EXT-X-MEDIA tags 55 | if p.Renditions != nil { 56 | for _, rendition := range p.Renditions { 57 | rendition.writeXMedia(buf) 58 | if buf.Err != nil { 59 | return nil, buf.Err 60 | } 61 | } 62 | } 63 | 64 | //For every Variant, write #EXT-X-STREAM-INF and #EXT-X-I-FRAME-STREAM-INF tags 65 | if p.Variants != nil { 66 | for _, variant := range p.Variants { 67 | variant.writeStreamInf(p.Version, buf) 68 | if buf.Err != nil { 69 | return nil, buf.Err 70 | } 71 | } 72 | } 73 | 74 | return bytes.NewReader(buf.Buf.Bytes()), buf.Err 75 | } 76 | 77 | //Encode writes a Media Playlist file 78 | func (p *MediaPlaylist) Encode() (io.Reader, error) { 79 | buf := manifest.NewBufWrapper() 80 | 81 | //write header tags 82 | writeHeader(p.Version, buf) 83 | if buf.Err != nil { 84 | return nil, buf.Err 85 | } 86 | //write Target Duration tag 87 | p.writeTargetDuration(buf) 88 | if buf.Err != nil { 89 | return nil, buf.Err 90 | } 91 | //write Media Sequence tag if enabled 92 | p.writeMediaSequence(buf) 93 | //write Independent Segment tag if enabled 94 | writeIndependentSegment(p.IndependentSegments, buf) 95 | //write Start tag if enabled 96 | writeStartPoint(p.StartPoint, buf) 97 | //write Discontinuity Sequence tag if enabled 98 | p.writeDiscontinuitySequence(buf) 99 | //write Playlist Type tag if enabled 100 | p.writePlaylistType(buf) 101 | //write Allow Cache tag if enabled 102 | p.writeAllowCache(buf) 103 | //write I-Frames Only if enabled 104 | p.writeIFramesOnly(buf) 105 | if buf.Err != nil { 106 | return nil, buf.Err 107 | } 108 | 109 | //write segment tags 110 | if p.Segments != nil { 111 | sort.Sort(p.Segments) 112 | var prev *Segment 113 | for _, segment := range p.Segments { 114 | if err := p.checkCompatibility(segment); err != nil { 115 | return nil, err 116 | } 117 | segment.writeSegmentTags(buf, prev, p.Version) 118 | if buf.Err != nil { 119 | return nil, buf.Err 120 | } 121 | prev = segment 122 | } 123 | } else { 124 | return nil, errors.New("MediaPlaylist must have at least one Segment") 125 | } 126 | //write End List tag if enabled 127 | p.writeEndList(buf) 128 | 129 | return bytes.NewReader(buf.Buf.Bytes()), buf.Err 130 | } 131 | -------------------------------------------------------------------------------- /hls/integration_test.go: -------------------------------------------------------------------------------- 1 | package hls_test 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "log" 7 | "os" 8 | "testing" 9 | 10 | "github.com/ingest/manifest/hls" 11 | ) 12 | 13 | const chunkSize = 5120 14 | 15 | func equal(r1, r2 io.Reader) bool { 16 | // Compare 17 | for { 18 | b1 := make([]byte, chunkSize) 19 | _, err1 := r1.Read(b1) 20 | 21 | b2 := make([]byte, chunkSize) 22 | _, err2 := r2.Read(b2) 23 | 24 | if err1 != nil || err2 != nil { 25 | if err1 == io.EOF && err2 == io.EOF { 26 | return bytes.Equal(b1, b2) 27 | } else if err1 == io.EOF || err2 == io.EOF { 28 | return false 29 | } else { 30 | log.Fatal(err1, err2) 31 | } 32 | } 33 | 34 | if !bytes.Equal(b1, b2) { 35 | return false 36 | } 37 | } 38 | } 39 | 40 | func TestIdempotentDecodeEncodeCycle(t *testing.T) { 41 | tests := []struct { 42 | file string 43 | }{ 44 | { 45 | file: "apple-ios5-macOS10_7.m3u8", 46 | }, 47 | { 48 | file: "apple-ios6-tvOS9.m3u8", 49 | }, 50 | } 51 | 52 | for _, tt := range tests { 53 | t.Run(tt.file, func(t *testing.T) { 54 | f, e := os.Open("./testdata/" + tt.file) 55 | if e != nil { 56 | t.Fatal(e) 57 | } 58 | defer f.Close() 59 | 60 | p := hls.NewMasterPlaylist(0) 61 | if err := p.Parse(f); err != nil && err != io.EOF { 62 | t.Fatal(err) 63 | } 64 | 65 | if _, err := f.Seek(0, 0); err != nil { 66 | t.Fatal(err) 67 | } 68 | 69 | output, err := p.Encode() 70 | if err != nil { 71 | t.Fatal(err) 72 | } 73 | 74 | if !equal(f, output) { 75 | t.Fatal("parse/decode not idempotent") 76 | } 77 | }) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /hls/source/http.go: -------------------------------------------------------------------------------- 1 | package source 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/http" 7 | 8 | "github.com/ingest/manifest/hls" 9 | ) 10 | 11 | type httpSource struct { 12 | Client *http.Client 13 | } 14 | 15 | // HTTP returns a source interface that fetches content using HTTP 16 | func HTTP(c *http.Client) hls.Source { 17 | if c == nil { 18 | c = http.DefaultClient 19 | } 20 | 21 | return &httpSource{ 22 | Client: c, 23 | } 24 | } 25 | 26 | // Master will download, and attempt to parse the document at the URI into a HLS master playlist. 27 | // It must be a HTTP accessible address using the provided http.Client. 28 | func (s *httpSource) Master(ctx context.Context, uri string) (*hls.MasterPlaylist, error) { 29 | master := hls.NewMasterPlaylist(0) 30 | master.URI = uri 31 | 32 | req, err := master.Request() 33 | if err != nil { 34 | return nil, err 35 | } 36 | req = req.WithContext(ctx) 37 | 38 | res, err := s.Client.Do(req) 39 | if err != nil { 40 | return nil, err 41 | } 42 | defer res.Body.Close() 43 | 44 | if err := master.Parse(res.Body); err != nil { 45 | return nil, err 46 | } 47 | return master, nil 48 | } 49 | 50 | // Media will download, and attempt to parse the HLS media playlist from the given variant that was parsed from a master playlist. 51 | // It must be a HTTP accessible address using the provided http.Client. 52 | func (s *httpSource) Media(ctx context.Context, variant *hls.Variant) (*hls.MediaPlaylist, error) { 53 | req, err := variant.Request() 54 | if err != nil { 55 | return nil, err 56 | } 57 | req = req.WithContext(ctx) 58 | 59 | res, err := s.Client.Do(req) 60 | if err != nil { 61 | return nil, err 62 | } 63 | defer res.Body.Close() 64 | 65 | media := hls.NewMediaPlaylist(0).WithVariant(variant) 66 | if err := media.Parse(res.Body); err != nil { 67 | return nil, err 68 | } 69 | 70 | return media, nil 71 | } 72 | 73 | // Resource will download, and return the http.Response.Body for further parsing for whatever the structure might be. 74 | // Some examples of a resource might be the actual media segment, or session decryption key. 75 | func (s *httpSource) Resource(ctx context.Context, uri string) (io.ReadCloser, error) { 76 | req, err := http.NewRequest("GET", uri, nil) 77 | if err != nil { 78 | return nil, err 79 | } 80 | req = req.WithContext(ctx) 81 | 82 | res, err := s.Client.Do(req) 83 | if err != nil { 84 | return nil, err 85 | } 86 | 87 | return res.Body, nil 88 | } 89 | -------------------------------------------------------------------------------- /hls/source/http_test.go: -------------------------------------------------------------------------------- 1 | package source_test 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/ingest/manifest/hls/source" 8 | ) 9 | 10 | // HTTP implements the source interface for HTTP for retrieving HLS data. 11 | // By default uses the http.DefaultClient if a nil pointer is passed. 12 | func ExampleHTTP() { 13 | ctx := context.Background() 14 | src := source.HTTP(http.DefaultClient) 15 | 16 | // Fetching master and media playlists 17 | master, _ := src.Master(ctx, "https://example.com/hls-master.m3u8") 18 | media, _ := src.Media(ctx, master.Variants[0]) 19 | 20 | // Fetching Session Key, could be used to decrypt segment below 21 | sURL, _ := master.SessionKeys[0].AbsoluteURL() 22 | sessionKey, _ := src.Resource(ctx, sURL) 23 | sessionKey.Close() 24 | 25 | // Fetch segment content 26 | sURL, _ = media.Segments[0].AbsoluteURL() 27 | segment, _ := src.Resource(ctx, sURL) 28 | segment.Close() 29 | } 30 | -------------------------------------------------------------------------------- /hls/testdata/apple-ios5-macOS10_7.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-VERSION:1 3 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="bipbop_audio",NAME="BipBop Audio 1",LANGUAGE="eng",DEFAULT=YES 4 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="bipbop_audio",NAME="BipBop Audio 2",LANGUAGE="eng",URI="alternate_audio_aac_sinewave/prog_index.m3u8" 5 | #EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="English",LANGUAGE="en",DEFAULT=YES,CHARACTERISTICS="public.accessibility.transcribes-spoken-dialog, public.accessibility.describes-music-and-sound",URI="subtitles/eng/prog_index.m3u8" 6 | #EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="English (Forced)",LANGUAGE="en",FORCED=YES,URI="subtitles/eng_forced/prog_index.m3u8" 7 | #EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="Français",LANGUAGE="fr",CHARACTERISTICS="public.accessibility.transcribes-spoken-dialog, public.accessibility.describes-music-and-sound",URI="subtitles/fra/prog_index.m3u8" 8 | #EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="Français (Forced)",LANGUAGE="fr",FORCED=YES,URI="subtitles/fra_forced/prog_index.m3u8" 9 | #EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="Español",LANGUAGE="es",CHARACTERISTICS="public.accessibility.transcribes-spoken-dialog, public.accessibility.describes-music-and-sound",URI="subtitles/spa/prog_index.m3u8" 10 | #EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="Español (Forced)",LANGUAGE="es",FORCED=YES,URI="subtitles/spa_forced/prog_index.m3u8" 11 | #EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="日本語",LANGUAGE="ja",CHARACTERISTICS="public.accessibility.transcribes-spoken-dialog, public.accessibility.describes-music-and-sound",URI="subtitles/jpn/prog_index.m3u8" 12 | #EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="日本語 (Forced)",LANGUAGE="ja",FORCED=YES,URI="subtitles/jpn_forced/prog_index.m3u8" 13 | #EXT-X-STREAM-INF:BANDWIDTH=263851,CODECS="mp4a.40.2, avc1.4d400d",RESOLUTION=416x234,AUDIO="bipbop_audio",SUBTITLES="subs" 14 | gear1/prog_index.m3u8 15 | #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=28451,CODECS="avc1.4d400d",URI="gear1/iframe_index.m3u8" 16 | #EXT-X-STREAM-INF:BANDWIDTH=577610,CODECS="mp4a.40.2, avc1.4d401e",RESOLUTION=640x360,AUDIO="bipbop_audio",SUBTITLES="subs" 17 | gear2/prog_index.m3u8 18 | #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=181534,CODECS="avc1.4d401e",URI="gear2/iframe_index.m3u8" 19 | #EXT-X-STREAM-INF:BANDWIDTH=915905,CODECS="mp4a.40.2, avc1.4d401f",RESOLUTION=960x540,AUDIO="bipbop_audio",SUBTITLES="subs" 20 | gear3/prog_index.m3u8 21 | #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=297056,CODECS="avc1.4d401f",URI="gear3/iframe_index.m3u8" 22 | #EXT-X-STREAM-INF:BANDWIDTH=1030138,CODECS="mp4a.40.2, avc1.4d401f",RESOLUTION=1280x720,AUDIO="bipbop_audio",SUBTITLES="subs" 23 | gear4/prog_index.m3u8 24 | #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=339492,CODECS="avc1.4d401f",URI="gear4/iframe_index.m3u8" 25 | #EXT-X-STREAM-INF:BANDWIDTH=1924009,CODECS="mp4a.40.2, avc1.4d401f",RESOLUTION=1920x1080,AUDIO="bipbop_audio",SUBTITLES="subs" 26 | gear5/prog_index.m3u8 27 | #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=669554,CODECS="avc1.4d401f",URI="gear5/iframe_index.m3u8" 28 | #EXT-X-STREAM-INF:BANDWIDTH=41457,CODECS="mp4a.40.2",AUDIO="bipbop_audio",SUBTITLES="subs" 29 | gear0/prog_index.m3u8 30 | -------------------------------------------------------------------------------- /hls/testdata/apple-ios6-tvOS9.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-VERSION:6 3 | #EXT-X-INDEPENDENT-SEGMENTS 4 | #EXT-X-STREAM-INF:BANDWIDTH=2227464,AVERAGE-BANDWIDTH=2218327,CODECS="avc1.640020,mp4a.40.2",RESOLUTION=960x540,FRAME-RATE=60.000,AUDIO="aud1",SUBTITLES="sub1",CLOSED-CAPTIONS="cc1" 5 | v5/prog_index.m3u8 6 | #EXT-X-STREAM-INF:BANDWIDTH=8178040,AVERAGE-BANDWIDTH=8144656,CODECS="avc1.64002a,mp4a.40.2",RESOLUTION=1920x1080,FRAME-RATE=60.000,AUDIO="aud1",SUBTITLES="sub1",CLOSED-CAPTIONS="cc1" 7 | v9/prog_index.m3u8 8 | #EXT-X-STREAM-INF:BANDWIDTH=6453202,AVERAGE-BANDWIDTH=6307144,CODECS="avc1.64002a,mp4a.40.2",RESOLUTION=1920x1080,FRAME-RATE=60.000,AUDIO="aud1",SUBTITLES="sub1",CLOSED-CAPTIONS="cc1" 9 | v8/prog_index.m3u8 10 | #EXT-X-STREAM-INF:BANDWIDTH=5054232,AVERAGE-BANDWIDTH=4775338,CODECS="avc1.64002a,mp4a.40.2",RESOLUTION=1920x1080,FRAME-RATE=60.000,AUDIO="aud1",SUBTITLES="sub1",CLOSED-CAPTIONS="cc1" 11 | v7/prog_index.m3u8 12 | #EXT-X-STREAM-INF:BANDWIDTH=3289288,AVERAGE-BANDWIDTH=3240596,CODECS="avc1.640020,mp4a.40.2",RESOLUTION=1280x720,FRAME-RATE=60.000,AUDIO="aud1",SUBTITLES="sub1",CLOSED-CAPTIONS="cc1" 13 | v6/prog_index.m3u8 14 | #EXT-X-STREAM-INF:BANDWIDTH=1296989,AVERAGE-BANDWIDTH=1292926,CODECS="avc1.64001e,mp4a.40.2",RESOLUTION=768x432,FRAME-RATE=30.000,AUDIO="aud1",SUBTITLES="sub1",CLOSED-CAPTIONS="cc1" 15 | v4/prog_index.m3u8 16 | #EXT-X-STREAM-INF:BANDWIDTH=922242,AVERAGE-BANDWIDTH=914722,CODECS="avc1.64001e,mp4a.40.2",RESOLUTION=640x360,FRAME-RATE=30.000,AUDIO="aud1",SUBTITLES="sub1",CLOSED-CAPTIONS="cc1" 17 | v3/prog_index.m3u8 18 | #EXT-X-STREAM-INF:BANDWIDTH=553010,AVERAGE-BANDWIDTH=541239,CODECS="avc1.640015,mp4a.40.2",RESOLUTION=480x270,FRAME-RATE=30.000,AUDIO="aud1",SUBTITLES="sub1",CLOSED-CAPTIONS="cc1" 19 | v2/prog_index.m3u8 20 | #EXT-X-STREAM-INF:BANDWIDTH=2448841,AVERAGE-BANDWIDTH=2439704,CODECS="avc1.640020,ac-3",RESOLUTION=960x540,FRAME-RATE=60.000,AUDIO="aud2",SUBTITLES="sub1",CLOSED-CAPTIONS="cc1" 21 | v5/prog_index.m3u8 22 | #EXT-X-STREAM-INF:BANDWIDTH=8399417,AVERAGE-BANDWIDTH=8366033,CODECS="avc1.64002a,ac-3",RESOLUTION=1920x1080,FRAME-RATE=60.000,AUDIO="aud2",SUBTITLES="sub1",CLOSED-CAPTIONS="cc1" 23 | v9/prog_index.m3u8 24 | #EXT-X-STREAM-INF:BANDWIDTH=6674579,AVERAGE-BANDWIDTH=6528521,CODECS="avc1.64002a,ac-3",RESOLUTION=1920x1080,FRAME-RATE=60.000,AUDIO="aud2",SUBTITLES="sub1",CLOSED-CAPTIONS="cc1" 25 | v8/prog_index.m3u8 26 | #EXT-X-STREAM-INF:BANDWIDTH=5275609,AVERAGE-BANDWIDTH=4996715,CODECS="avc1.64002a,ac-3",RESOLUTION=1920x1080,FRAME-RATE=60.000,AUDIO="aud2",SUBTITLES="sub1",CLOSED-CAPTIONS="cc1" 27 | v7/prog_index.m3u8 28 | #EXT-X-STREAM-INF:BANDWIDTH=3510665,AVERAGE-BANDWIDTH=3461973,CODECS="avc1.640020,ac-3",RESOLUTION=1280x720,FRAME-RATE=60.000,AUDIO="aud2",SUBTITLES="sub1",CLOSED-CAPTIONS="cc1" 29 | v6/prog_index.m3u8 30 | #EXT-X-STREAM-INF:BANDWIDTH=1518366,AVERAGE-BANDWIDTH=1514303,CODECS="avc1.64001e,ac-3",RESOLUTION=768x432,FRAME-RATE=30.000,AUDIO="aud2",SUBTITLES="sub1",CLOSED-CAPTIONS="cc1" 31 | v4/prog_index.m3u8 32 | #EXT-X-STREAM-INF:BANDWIDTH=1143619,AVERAGE-BANDWIDTH=1136099,CODECS="avc1.64001e,ac-3",RESOLUTION=640x360,FRAME-RATE=30.000,AUDIO="aud2",SUBTITLES="sub1",CLOSED-CAPTIONS="cc1" 33 | v3/prog_index.m3u8 34 | #EXT-X-STREAM-INF:BANDWIDTH=774387,AVERAGE-BANDWIDTH=762616,CODECS="avc1.640015,ac-3",RESOLUTION=480x270,FRAME-RATE=30.000,AUDIO="aud2",SUBTITLES="sub1",CLOSED-CAPTIONS="cc1" 35 | v2/prog_index.m3u8 36 | #EXT-X-STREAM-INF:BANDWIDTH=2256841,AVERAGE-BANDWIDTH=2247704,CODECS="avc1.640020,ec-3",RESOLUTION=960x540,FRAME-RATE=60.000,AUDIO="aud3",SUBTITLES="sub1",CLOSED-CAPTIONS="cc1" 37 | v5/prog_index.m3u8 38 | #EXT-X-STREAM-INF:BANDWIDTH=8207417,AVERAGE-BANDWIDTH=8174033,CODECS="avc1.64002a,ec-3",RESOLUTION=1920x1080,FRAME-RATE=60.000,AUDIO="aud3",SUBTITLES="sub1",CLOSED-CAPTIONS="cc1" 39 | v9/prog_index.m3u8 40 | #EXT-X-STREAM-INF:BANDWIDTH=6482579,AVERAGE-BANDWIDTH=6336521,CODECS="avc1.64002a,ec-3",RESOLUTION=1920x1080,FRAME-RATE=60.000,AUDIO="aud3",SUBTITLES="sub1",CLOSED-CAPTIONS="cc1" 41 | v8/prog_index.m3u8 42 | #EXT-X-STREAM-INF:BANDWIDTH=5083609,AVERAGE-BANDWIDTH=4804715,CODECS="avc1.64002a,ec-3",RESOLUTION=1920x1080,FRAME-RATE=60.000,AUDIO="aud3",SUBTITLES="sub1",CLOSED-CAPTIONS="cc1" 43 | v7/prog_index.m3u8 44 | #EXT-X-STREAM-INF:BANDWIDTH=3318665,AVERAGE-BANDWIDTH=3269973,CODECS="avc1.640020,ec-3",RESOLUTION=1280x720,FRAME-RATE=60.000,AUDIO="aud3",SUBTITLES="sub1",CLOSED-CAPTIONS="cc1" 45 | v6/prog_index.m3u8 46 | #EXT-X-STREAM-INF:BANDWIDTH=1326366,AVERAGE-BANDWIDTH=1322303,CODECS="avc1.64001e,ec-3",RESOLUTION=768x432,FRAME-RATE=30.000,AUDIO="aud3",SUBTITLES="sub1",CLOSED-CAPTIONS="cc1" 47 | v4/prog_index.m3u8 48 | #EXT-X-STREAM-INF:BANDWIDTH=951619,AVERAGE-BANDWIDTH=944099,CODECS="avc1.64001e,ec-3",RESOLUTION=640x360,FRAME-RATE=30.000,AUDIO="aud3",SUBTITLES="sub1",CLOSED-CAPTIONS="cc1" 49 | v3/prog_index.m3u8 50 | #EXT-X-STREAM-INF:BANDWIDTH=582387,AVERAGE-BANDWIDTH=570616,CODECS="avc1.640015,ec-3",RESOLUTION=480x270,FRAME-RATE=30.000,AUDIO="aud3",SUBTITLES="sub1",CLOSED-CAPTIONS="cc1" 51 | v2/prog_index.m3u8 52 | #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=186522,AVERAGE-BANDWIDTH=182077,CODECS="avc1.64002a",RESOLUTION=1920x1080,URI="v7/iframe_index.m3u8" 53 | #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=133856,AVERAGE-BANDWIDTH=129936,CODECS="avc1.640020",RESOLUTION=1280x720,URI="v6/iframe_index.m3u8" 54 | #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=98136,AVERAGE-BANDWIDTH=94286,CODECS="avc1.640020",RESOLUTION=960x540,URI="v5/iframe_index.m3u8" 55 | #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=76704,AVERAGE-BANDWIDTH=74767,CODECS="avc1.64001e",RESOLUTION=768x432,URI="v4/iframe_index.m3u8" 56 | #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=64078,AVERAGE-BANDWIDTH=62251,CODECS="avc1.64001e",RESOLUTION=640x360,URI="v3/iframe_index.m3u8" 57 | #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=38728,AVERAGE-BANDWIDTH=37866,CODECS="avc1.640015",RESOLUTION=480x270,URI="v2/iframe_index.m3u8" 58 | -------------------------------------------------------------------------------- /hls/testdata/master/fixture-v7-media-service-fail.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-VERSION:1 3 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="bipbop_audio",NAME="BipBop Audio 1",LANGUAGE="eng",DEFAULT=YES 4 | #EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="English",LANGUAGE="en",DEFAULT=YES,CHARACTERISTICS="public.accessibility.transcribes-spoken-dialog, public.accessibility.describes-music-and-sound",URI="subtitles/eng/prog_index.m3u8" 5 | #EXT-X-MEDIA:TYPE=CLOSED-CAPTIONS,GROUP-ID="s5",LANGUAGE="en",NAME="English",AUTOSELECT=YES,DEFAULT=YES,INSTREAM-ID="SERVICE5" 6 | #EXT-X-STREAM-INF:BANDWIDTH=263851,CODECS="mp4a.40.2, avc1.4d400d",RESOLUTION=416x234,AUDIO="bipbop_audio",SUBTITLES="subs",CLOSED-CAPTIONS="s5" 7 | gear1/prog_index.m3u8 -------------------------------------------------------------------------------- /hls/testdata/master/fixture-v7.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-VERSION:7 3 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="bipbop_audio",NAME="BipBop Audio 1",LANGUAGE="eng",DEFAULT=YES 4 | #EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="English",LANGUAGE="en",DEFAULT=YES,CHARACTERISTICS="public.accessibility.transcribes-spoken-dialog, public.accessibility.describes-music-and-sound",URI="subtitles/eng/prog_index.m3u8" 5 | #EXT-X-MEDIA:TYPE=CLOSED-CAPTIONS,GROUP-ID="s5",LANGUAGE="en",NAME="English",AUTOSELECT=YES,DEFAULT=YES,INSTREAM-ID="SERVICE5" 6 | #EXT-X-STREAM-INF:BANDWIDTH=263851,CODECS="mp4a.40.2, avc1.4d400d",RESOLUTION=416x234,AUDIO="bipbop_audio",SUBTITLES="subs",CLOSED-CAPTIONS="s5" 7 | gear1/prog_index.m3u8 -------------------------------------------------------------------------------- /hls/testdata/masterp.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-VERSION:6 3 | #EXT-X-INDEPENDENT-SEGMENTS 4 | #EXT-X-SESSION-KEY:METHOD="SAMPLE-AES",IV=0x29fd9eba3735966ddfca572e51e68ff2,URI="com.keyuri.example",KEYFORMAT="com.apple.streamingkeydelivery",KEYFORMATVERSIONS="1" 5 | #EXT-X-SESSION-DATA:DATA-ID="com.example.title",LANGUAGE="en",VALUE="This is an example" 6 | #EXT-X-SESSION-DATA:DATA-ID="com.example.lyrics",URI="lyrics.json" 7 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud1",LANGUAGE="eng",NAME="English",AUTOSELECT=YES,DEFAULT=YES,URI="a1/prog_index.m3u8" 8 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud2",LANGUAGE="eng",NAME="English",AUTOSELECT=YES,DEFAULT=YES,URI="a2/prog_index.m3u8" 9 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud3",LANGUAGE="eng",NAME="English",AUTOSELECT=YES,DEFAULT=YES,URI="a3/prog_index.m3u8" 10 | #EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="sub1",NAME="English",LANGUAGE="eng",DEFAULT=YES,AUTOSELECT=YES,FORCED=NO,URI="s1/eng/prog_index.m3u8" 11 | #EXT-X-MEDIA:TYPE=CLOSED-CAPTIONS,GROUP-ID="cc1",NAME="English",LANGUAGE="eng",DEFAULT=YES,AUTOSELECT=YES,INSTREAM-ID="CC1" 12 | #EXT-X-I-FRAME-STREAM-INF:AVERAGE-BANDWIDTH=163198,BANDWIDTH=166942,CODECS="avc1.64002a",RESOLUTION=1920x1080,URI="v6/iframe_index.m3u8" 13 | #EXT-X-I-FRAME-STREAM-INF:AVERAGE-BANDWIDTH=131314,BANDWIDTH=139041,CODECS="avc1.640020",RESOLUTION=1280x720,URI="v5/iframe_index.m3u8" 14 | #EXT-X-I-FRAME-STREAM-INF:AVERAGE-BANDWIDTH=100233,BANDWIDTH=101724,CODECS="avc1.640020",RESOLUTION=960x540,URI="v4/iframe_index.m3u8" 15 | #EXT-X-I-FRAME-STREAM-INF:AVERAGE-BANDWIDTH=81002,BANDWIDTH=84112,CODECS="avc1.64001e",RESOLUTION=768x432,URI="v3/iframe_index.m3u8" 16 | #EXT-X-I-FRAME-STREAM-INF:AVERAGE-BANDWIDTH=64987,BANDWIDTH=65835,CODECS="avc1.64001e",RESOLUTION=640x360,URI="v2/iframe_index.m3u8" 17 | #EXT-X-I-FRAME-STREAM-INF:AVERAGE-BANDWIDTH=41547,BANDWIDTH=42106,CODECS="avc1.640015",RESOLUTION=480x270,URI="v1/iframe_index.m3u8" 18 | #EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=2165224,BANDWIDTH=2215219,CODECS="avc1.640020,mp4a.40.2",RESOLUTION=960x540,FRAME-RATE=59.940,CLOSED-CAPTIONS="cc1",AUDIO="aud1",SUBTITLES="sub1" 19 | v4/prog_index.m3u8 20 | #EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=7962844,BANDWIDTH=7976430,CODECS="avc1.64002a,mp4a.40.2",RESOLUTION=1920x1080,FRAME-RATE=59.940,CLOSED-CAPTIONS="cc1",AUDIO="aud1",SUBTITLES="sub1" 21 | v8/prog_index.m3u8 22 | #EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=6165024,BANDWIDTH=6181885,CODECS="avc1.64002a,mp4a.40.2",RESOLUTION=1920x1080,FRAME-RATE=59.940,CLOSED-CAPTIONS="cc1",AUDIO="aud1",SUBTITLES="sub1" 23 | v7/prog_index.m3u8 24 | #EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=4664459,BANDWIDTH=4682666,CODECS="avc1.64002a,mp4a.40.2",RESOLUTION=1920x1080,FRAME-RATE=59.940,CLOSED-CAPTIONS="cc1",AUDIO="aud1",SUBTITLES="sub1" 25 | v6/prog_index.m3u8 26 | #EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=3164759,BANDWIDTH=3170746,CODECS="avc1.640020,mp4a.40.2",RESOLUTION=1280x720,FRAME-RATE=59.940,CLOSED-CAPTIONS="cc1",AUDIO="aud1",SUBTITLES="sub1" 27 | v5/prog_index.m3u8 28 | #EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=1262552,BANDWIDTH=1276223,CODECS="avc1.64001e,mp4a.40.2",RESOLUTION=768x432,FRAME-RATE=29.970,CLOSED-CAPTIONS="cc1",AUDIO="aud1",SUBTITLES="sub1" 29 | v3/prog_index.m3u8 30 | #EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=893243,BANDWIDTH=904744,CODECS="avc1.64001e,mp4a.40.2",RESOLUTION=640x360,FRAME-RATE=29.970,CLOSED-CAPTIONS="cc1",AUDIO="aud1",SUBTITLES="sub1" 31 | v2/prog_index.m3u8 32 | #EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=527673,BANDWIDTH=538201,CODECS="avc1.640015,mp4a.40.2",RESOLUTION=480x270,FRAME-RATE=29.970,CLOSED-CAPTIONS="cc1",AUDIO="aud1",SUBTITLES="sub1" 33 | v1/prog_index.m3u8 -------------------------------------------------------------------------------- /hls/testdata/media/fixture-v3-fail.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-VERSION:2 3 | #EXTINF:9.052, 4 | https://play-dev.ingest.io/v_6500k0.ts -------------------------------------------------------------------------------- /hls/testdata/media/fixture-v3.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-VERSION:3 3 | #EXTINF:9.052, 4 | https://play-dev.ingest.io/v_6500k0.ts -------------------------------------------------------------------------------- /hls/testdata/media/fixture-v4-byterange-fail.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-VERSION:3 3 | #EXTINF:9.052, 4 | #EXT-X-BYTERANGE:999624@376 5 | https://play-dev.ingest.io/v_6500k0.ts -------------------------------------------------------------------------------- /hls/testdata/media/fixture-v4-iframes-fail.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-VERSION:3 3 | #EXT-X-I-FRAMES-ONLY 4 | #EXTINF:9.052, 5 | #EXT-X-BYTERANGE:999624@376 6 | https://play-dev.ingest.io/v_6500k0.ts -------------------------------------------------------------------------------- /hls/testdata/media/fixture-v4.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-VERSION:4 3 | #EXT-X-I-FRAMES-ONLY 4 | #EXTINF:9.052, 5 | #EXT-X-BYTERANGE:999624@376 6 | https://play-dev.ingest.io/v_6500k0.ts -------------------------------------------------------------------------------- /hls/testdata/media/fixture-v5-keyformat-fail.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-VERSION:4 3 | #EXT-X-KEY:METHOD="SAMPLE-AES",IV=0x29fd9eba3735966ddfca572e51e68ff2,URI="com.keyuri.example",KEYFORMAT="com.apple.streamingkeydelivery",KEYFORMATVERSIONS="1" 4 | #EXTINF:9.052, 5 | #EXT-X-BYTERANGE:999624@376 6 | https://play-dev.ingest.io/v_6500k0.ts -------------------------------------------------------------------------------- /hls/testdata/media/fixture-v5-map-fail.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-VERSION:4 3 | #EXT-X-I-FRAMES-ONLY 4 | #EXT-X-MAP:URI="mapuri.for.test",BYTERANGE=6000@200 5 | #EXTINF:9.052, 6 | #EXT-X-BYTERANGE:999624@376 7 | https://play-dev.ingest.io/v_6500k0.ts -------------------------------------------------------------------------------- /hls/testdata/media/fixture-v5.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-VERSION:5 3 | #EXT-X-I-FRAMES-ONLY 4 | #EXT-X-KEY:METHOD="SAMPLE-AES",IV=0x29fd9eba3735966ddfca572e51e68ff2,URI="com.keyuri.example",KEYFORMAT="com.apple.streamingkeydelivery",KEYFORMATVERSIONS="1" 5 | #EXT-X-MAP:URI="mapuri.for.test",BYTERANGE=6000@200 6 | #EXTINF:9.052, 7 | #EXT-X-BYTERANGE:999624@376 8 | https://play-dev.ingest.io/v_6500k0.ts -------------------------------------------------------------------------------- /hls/testdata/media/fixture-v6-map-no-iframes-fail.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-VERSION:5 3 | #EXT-X-KEY:METHOD="SAMPLE-AES",IV=0x29fd9eba3735966ddfca572e51e68ff2,URI="com.keyuri.example",KEYFORMAT="com.apple.streamingkeydelivery",KEYFORMATVERSIONS="1" 4 | #EXT-X-MAP:URI="mapuri.for.test",BYTERANGE=6000@200 5 | #EXTINF:9.052, 6 | #EXT-X-BYTERANGE:999624@376 7 | https://play-dev.ingest.io/v_6500k0.ts -------------------------------------------------------------------------------- /hls/testdata/media/fixture-v6.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-VERSION:6 3 | #EXT-X-KEY:METHOD="SAMPLE-AES",IV=0x29fd9eba3735966ddfca572e51e68ff2,URI="com.keyuri.example",KEYFORMAT="com.apple.streamingkeydelivery",KEYFORMATVERSIONS="1" 4 | #EXT-X-MAP:URI="mapuri.for.test",BYTERANGE=6000@200 5 | #EXTINF:9.052, 6 | #EXT-X-BYTERANGE:999624@376 7 | https://play-dev.ingest.io/v_6500k0.ts -------------------------------------------------------------------------------- /hls/testdata/mediap.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-VERSION:7 3 | #EXT-X-MEDIA-SEQUENCE:1 4 | #EXT-X-TARGETDURATION:10 5 | #EXT-X-START:TIME-OFFSET=8.345 6 | #EXT-X-KEY:METHOD=AES-128,URI="https://keyuri.com",IV=0x29fd9eba3735966ddfca572e51e68ff2,KEYFORMAT=0xc1cfbbacc147912fce3adfd86b259b59 7 | #EXT-X-MAP:URI="mapuri.for.test",BYTERANGE=6000@200 8 | #EXT-X-DATERANGE:ID="6FFF00",START-DATE="2010-02-19T14:54:23.031+08:00",SCTE35-OUT=0xFC002F0000000000FF0 9 | #EXTINF:9.052, 10 | #EXT-X-BYTERANGE:999624@376 11 | https://play-dev.ingest.io/v_6500k0.ts 12 | #EXT-X-KEY:METHOD=AES-128,URI="https://play-dev.ingest.io/key/fc454946-1761-4952-989f-0f94b44cf987{{q}}",IV=0xc1cfbbacc147912fce3adfd86b259b59 13 | #EXT-X-KEY:METHOD=AES-128,URI="https://fc454946-1761-4952-989f-0f94b44cf987{{q}}",IV=0xc1cfbbacc147912fce3adfd86b259b59 14 | #EXTINF:9.009, 15 | #EXT-X-BYTERANGE:999624@376 16 | https://play-dev.ingest.io/v_6500k1.ts 17 | #EXT-X-KEY:METHOD=AES-128,URI="https://play-dev.ingest.io/key/fc454946-1761-4952-989f-0f94b44cf987{{q}}",IV=0x931dd8faee9cd2329c557e4b0bebb191 18 | #EXTINF:9.009, 19 | #EXT-X-BYTERANGE:999624@376 20 | https://play-dev.ingest.io/v_6500k2.ts 21 | #EXT-X-KEY:METHOD=AES-128,URI="https://play-dev.ingest.io/key/fc454946-1761-4952-989f-0f94b44cf987{{q}}",IV=0xdcb68f58b786ae63bddfd2f3970282a6 22 | #EXT-X-MAP:URI="mapuri.just.for.testing",BYTERANGE=2000@0 23 | #EXTINF:8.976, 24 | #EXT-X-BYTERANGE:999624@376 25 | https://play-dev.ingest.io/v_6500k3.ts 26 | #EXT-X-KEY:METHOD=AES-128,URI="https://play-dev.ingest.io/key/fc454946-1761-4952-989f-0f94b44cf987{{q}}",IV=0x017e07d9e59ad7dd239f23f8fed0daf4 27 | #EXTINF:9.009, 28 | #EXT-X-BYTERANGE:999624@376 29 | https://play-dev.ingest.io/v_6500k4.ts 30 | #EXT-X-KEY:METHOD=AES-128,URI="https://play-dev.ingest.io/key/fc454946-1761-4952-989f-0f94b44cf987{{q}}",IV=0xbf60660fafde45060f24f4c8ca8309ee 31 | #EXTINF:9.009, 32 | #EXT-X-BYTERANGE:999624@376 33 | https://play-dev.ingest.io/v_6500k5.ts -------------------------------------------------------------------------------- /hls/types-master.go: -------------------------------------------------------------------------------- 1 | package hls 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | //MasterPlaylist represents a Master Playlist and its tags 9 | type MasterPlaylist struct { 10 | URI string // Location of the master playlist 11 | M3U bool // Represents tag #EXTM3U. Indicates if present. MUST be present. 12 | Version int // Represents tag #EXT-X-VERSION. MUST be present. 13 | Variants []*Variant // Represents the #EXT-X-I-FRAME-STREAM-INF and #EXT-X-STREAM-INF playlists 14 | Renditions []*Rendition // Represents the #EXT-X-MEDIA tags 15 | SessionData []*SessionData 16 | SessionKeys []*Key 17 | IndependentSegments bool // Represents tag #EXT-X-INDEPENDENT-SEGMENTS. Applies to every Media Segment of every Media Playlist referenced. V6 or higher. 18 | StartPoint *StartPoint 19 | } 20 | 21 | // Request creates a new http request ready to retrieve the segment 22 | func (m *MasterPlaylist) Request() (*http.Request, error) { 23 | req, err := http.NewRequest("GET", m.URI, nil) 24 | if err != nil { 25 | return req, fmt.Errorf("failed to construct request: %v", err) 26 | } 27 | 28 | return req, nil 29 | } 30 | 31 | //Rendition represents the tag #EXT-X-MEDIA 32 | // 33 | //Relates Media Playlists with alternative renditions of the same content. 34 | //Eg. Audio only playlists containing English, French and Spanish renditions of the same content. 35 | //One or more X-MEDIA tags with same GroupID and Type sets a group of renditions and MUST meet the following constraints: 36 | // -Tags in the same group MUST have different Name att. 37 | // -MUST NOT have more than one member with a Default att of YES 38 | // -All members whose AutoSelect att is YES MUST have Language att with unique values 39 | // 40 | type Rendition struct { 41 | Type string //Possible Values: AUDIO, VIDEO, SUBTITLES, CLOSED-CAPTIONS. Required. 42 | URI string //URI containing the media playlist. If type is CLOSED-CAPTIONS, URI MUST NOT be present. 43 | GroupID string //Required. 44 | Language string //Optional. Identifies the primary language used in the rendition. Must be one of the standard tags RFC5646 45 | AssocLanguage string //Optional. Language tag RFC5646 46 | Name string //Required. Description of the rendition. SHOULD be written in the same language as Language 47 | Default bool //Possible Values: YES, NO. Optional. Defines if rendition should be played by client if user doesn't choose a rendition. Default: NO 48 | AutoSelect bool //Possible Values: YES, NO. Optional. Client MAY choose this rendition if user doesn't choose one. if present, MUST be YES if Default=YES. Default: NO. 49 | Forced bool //Possible Values: YES, NO. Optional. MUST NOT be present unless Type is SUBTITLES. Default: NO. 50 | InstreamID string //Specifies a rendition within the Media Playlist. MUST NOT be present unless Type is CLOSED-CAPTIONS. Possible Values: CC1, CC2, CC3, CC4, or SERVICEn where n is int between 1 - 63 51 | Characteristics string //Optional. One or more Uniform Type Indentifiers separated by comma. Each UTI indicates an individual characteristic of the Rendition. 52 | 53 | masterPlaylist *MasterPlaylist // MasterPlaylist is included to be used internally for resolving relative resource locations 54 | } 55 | 56 | // Request creates a new http request ready to retrieve the segment 57 | func (r *Rendition) Request() (*http.Request, error) { 58 | uri, err := r.AbsoluteURL() 59 | if err != nil { 60 | return nil, fmt.Errorf("failed building resource url: %v", err) 61 | } 62 | 63 | req, err := http.NewRequest("GET", uri, nil) 64 | if err != nil { 65 | return req, fmt.Errorf("failed to construct request: %v", err) 66 | } 67 | 68 | return req, nil 69 | } 70 | 71 | // AbsoluteURL will resolve the rendition URI to a absolute path, given it is a URL. 72 | func (r *Rendition) AbsoluteURL() (string, error) { 73 | return resolveURLReference(r.masterPlaylist.URI, r.URI) 74 | } 75 | 76 | // Variant represents the tag #EXT-X-STREAM-INF: and tag #EXT-X-I-FRAME-STREAM-INF. 77 | // #EXT-X-STREAM-INF specifies a Variant Stream, which is one of the ren which can be combined to play the presentation. 78 | // A URI line following the tag indicates the Media Playlist carrying a rendition of the Variant Stream and it MUST be present. 79 | // 80 | // #EXT-X-I-FRAME-STREAM-INF identifies Media Playlist file containing the I-frames of a multimedia presentation. 81 | // It supports the same parameters as EXT-X-STREAM-INF except Audio, Subtitles and ClosedCaptions. 82 | type Variant struct { 83 | IsIframe bool //Identifies if #EXT-X-STREAM-INF or #EXT-X-I-FRAME-STREAM-INF 84 | URI string //If #EXT-X-STREAM-INF, URI line MUST follow the tag. If #EXT-X-I-FRAME-STREAM-INF, URI MUST appear as an attribute of the tag. 85 | ProgramID int64 //Removed on Version 6 86 | Bandwidth int64 //Required. Peak segment bit rate. 87 | AvgBandwidth int64 //Optional. Average segment bit rate of the Variant Stream. 88 | Codecs string //Optional. Comma-separated list of formats. Valid formats are the ones specified in RFC6381. SHOULD be present. 89 | Resolution string //Optional. Optimal pixel resolution. 90 | FrameRate float64 //Optional. Maximum frame rate. Optional. SHOULD be included if any video exceeds 30 frames per second. 91 | Audio string //Optional. Indicates the set of audio renditions that SHOULD be used. MUST match GroupID value of an EXT-X-MEDIA tag whose Type is AUDIO. 92 | Video string //Optional. Indicates the set of video renditions that SHOULD be used. MUST match GroupID value of an EXT-X-MEDIA tag whose Type is VIDEO. 93 | Subtitles string //Optional. Indicates the set of subtitle renditions that SHOULD be used. MUST match GroupID value of an EXT-X-MEDIA tag whose Type is SUBTITLES. 94 | ClosedCaptions string //Optional. Indicates the set of closed-caption renditions that SHOULD be used. Can be quoted-string or NONE. 95 | // If NONE, all EXT-X-STREAM-INF MUST have this attribute as NONE. If quoted-string, MUST match GroupID value of an EXT-X-MEDIA tag whose Type is CLOSED-CAPTIONS. 96 | 97 | masterPlaylist *MasterPlaylist // MasterPlaylist is included to be used internally for resolving relative resource locations 98 | } 99 | 100 | // Request creates a new http request ready to retrieve the segment 101 | func (v *Variant) Request() (*http.Request, error) { 102 | uri, err := v.AbsoluteURL() 103 | if err != nil { 104 | return nil, fmt.Errorf("failed building resource url: %v", err) 105 | } 106 | 107 | req, err := http.NewRequest("GET", uri, nil) 108 | if err != nil { 109 | return req, fmt.Errorf("failed to construct request: %v", err) 110 | } 111 | return req, nil 112 | } 113 | 114 | // AbsoluteURL will resolve the variant URI to a absolute path, given it is a URL. 115 | func (v *Variant) AbsoluteURL() (string, error) { 116 | return resolveURLReference(v.masterPlaylist.URI, v.URI) 117 | } 118 | 119 | // SessionData represents tag #EXT-X-SESSION-DATA. 120 | // Master Playlist MAY contain more than one tag with the same DataID but the Language MUST be different. 121 | // Introduced in HLSv7. 122 | type SessionData struct { 123 | DataID string //Required. SHOULD conform with a reverse DNS naming convention. 124 | Value string //Required IF URI is not present. Contains the session data 125 | URI string //Required IF Value is not present. Resource with the session data 126 | Language string //Optional. RFC5646 language tag that identifies the language of the data 127 | 128 | masterPlaylist *MasterPlaylist // MasterPlaylist is included to be used internally for resolving relative resource locations 129 | } 130 | 131 | // Request creates a new http request ready to retrieve the segment 132 | func (s *SessionData) Request() (*http.Request, error) { 133 | uri, err := s.AbsoluteURL() 134 | if err != nil { 135 | return nil, fmt.Errorf("failed building resource url: %v", err) 136 | } 137 | 138 | req, err := http.NewRequest("GET", uri, nil) 139 | if err != nil { 140 | return req, fmt.Errorf("failed to construct request: %v", err) 141 | } 142 | return req, nil 143 | } 144 | 145 | // AbsoluteURL will resolve the SessionData URI to a absolute path, given it is a URL. 146 | func (s *SessionData) AbsoluteURL() (string, error) { 147 | return resolveURLReference(s.masterPlaylist.URI, s.URI) 148 | } 149 | 150 | // StartPoint represents tag #EXT-X-START. 151 | // Indicates preferred point at which to start playing a Playlist. 152 | type StartPoint struct { 153 | TimeOffset float64 //Required. If positive, time offset from the beginning of the Playlist. If negative, time offset from the end of the last segment of the playlist 154 | Precise bool //Possible Values: YES or NO. 155 | } 156 | -------------------------------------------------------------------------------- /hls/types-media.go: -------------------------------------------------------------------------------- 1 | package hls 2 | 3 | //MediaPlaylist represents a Media Playlist and its tags. 4 | // 5 | //TODO:(sliding window) - add field for sliding window to represent either the max amount of segments 6 | //or the max duration of a window (TBD). Also would be useful to add variable to track the current first and last sequence numbers 7 | //as a helper to adding and removing segments and tracking MediaSequence, DiscontinuitySequence etc 8 | // 9 | type MediaPlaylist struct { 10 | *Variant // Variant is embedded, contains information on how the master playlist represented this media playlist. 11 | Version int // Version is required, is written #EXT-X-VERSION: . 12 | Segments Segments // Segments are represented by #EXT-INF\n . 13 | TargetDuration int // TargetDuration is required, is written #EXT-X-TARGETDURATION: . MUST BE >= largest EXT-INF duration 14 | MediaSequence int //Represents tag #EXT-X-MEDIA-SEQUENCE. Number of the first media sequence in the playlist. 15 | DiscontinuitySequence int //Represents tag #EXT-X-DISCONTINUITY-SEQUENCE. If present, MUST appear before the first Media Segment. MUST appear before any EXT-X-DISCONTINUITY Media Segment tag. 16 | EndList bool //Represents tag #EXT-X-ENDLIST. Indicates no more media segments will be added to the playlist. 17 | Type string //Possible Values: EVENT or VOD. Represents tag #EXT-X-PLAYLIST-TYPE. If EVENT - segments can only be added to the end of playlist. If VOD - playlist cannot change. If segments need to be removed from playlist, this tag MUST NOT be present 18 | IFramesOnly bool //Represents tag #EXT-X-I-FRAMES-ONLY. If present, segments MUST begin with either a Media Initialization Section or have a EXT-X-MAP tag. 19 | AllowCache bool //Possible Values: YES or NO. Represents tag #EXT-X-ALLOW-CACHE. Versions 3 - 6 only. 20 | IndependentSegments bool //Represents tag #EXT-X-INDEPENDENT-SEGMENTS. Applies to every Media Segment in the playlist. 21 | StartPoint *StartPoint //Represents tag #EXT-X-START 22 | } 23 | -------------------------------------------------------------------------------- /hls/types-segment.go: -------------------------------------------------------------------------------- 1 | package hls 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "time" 7 | ) 8 | 9 | //Segment represents the Media Segment and its tags 10 | type Segment struct { 11 | ID int //Sequence number 12 | URI string 13 | Inf *Inf //Required. 14 | Byterange *Byterange 15 | Discontinuity bool //Represents tag #EXT-X-DISCONTINUITY. MUST be present if there's change in file format; number, type and identifiers of tracks or timestamp sequence 16 | Keys []*Key 17 | Map *Map 18 | ProgramDateTime time.Time //Represents tag #EXT-X-PROGRAM-DATE-TIME 19 | DateRange *DateRange 20 | 21 | mediaPlaylist *MediaPlaylist // MediaPlaylist is included to be used internally for resolving relative resource locations 22 | } 23 | 24 | // Request creates a new http request ready to send to retrieve the segment 25 | func (s *Segment) Request() (*http.Request, error) { 26 | uri, err := s.AbsoluteURL() 27 | if err != nil { 28 | return nil, fmt.Errorf("failed building resource url: %v", err) 29 | } 30 | 31 | req, err := http.NewRequest("GET", uri, nil) 32 | if err != nil { 33 | return req, fmt.Errorf("failed to construct request: %v", err) 34 | } 35 | return req, nil 36 | } 37 | 38 | // AbsoluteURL will resolve the segment URI to a absolute path, given it is a relative URL. 39 | func (s *Segment) AbsoluteURL() (string, error) { 40 | return resolveURLReference(s.mediaPlaylist.URI, s.URI) 41 | } 42 | 43 | // Segments implements golang/sort interface to sort a Segment slice by Segment ID 44 | type Segments []*Segment 45 | 46 | func (s Segments) Len() int { 47 | return len(s) 48 | } 49 | func (s Segments) Swap(i, j int) { 50 | s[i], s[j] = s[j], s[i] 51 | } 52 | func (s Segments) Less(i, j int) bool { 53 | return s[i].ID < s[j].ID 54 | } 55 | 56 | // Inf represents tag 57 | // #EXTINF: ,[] 58 | type Inf struct { 59 | Duration float64 60 | Title string 61 | } 62 | 63 | // Byterange represents tag #EXT-X-BYTERANGE. 64 | // Introduced in HLSv4. 65 | // Format: length[@offset]. 66 | type Byterange struct { 67 | Length int64 68 | Offset *int64 69 | } 70 | 71 | // Equal determines if the two byterange objects are equal and contain the same values 72 | func (b *Byterange) Equal(other *Byterange) bool { 73 | if b != nil && other != nil { 74 | if b.Length != other.Length { 75 | return false 76 | } 77 | 78 | if b.Offset != nil && other.Offset != nil { 79 | return *b.Offset == *other.Offset 80 | } 81 | } 82 | 83 | return b == other 84 | } 85 | 86 | // Key represents tags #EXT-X-KEY:<attribute=value> and #EXT-X-SESSION-KEY. Specifies how to decrypt an encrypted media segment. 87 | // #EXT-X-SESSION-KEY is exclusively a Master Playlist tag (HLS V7) and it SHOULD be used if multiple Variant Streams use the same encryption keys. 88 | // TODO(jstackhouse): Split SESSION-KEY into it's own type as it's got different validation properties, and is part of the master playlist, not media playlist. 89 | type Key struct { 90 | IsSession bool //Identifies if #EXT-X-KEY or #EXT-X-SESSION-KEY. If #EXT-X-SESSION-KEY, Method MUST NOT be NONE. 91 | Method string //Required. Possible Values: NONE, AES-128, SAMPLE-AES. If NONE, other attributes MUST NOT be present. 92 | URI string //Required unless the method is NONE. Specifies how to get the key for the encryption method. 93 | IV string //Optional. Hexadecimal that specifies a 128-bit int Initialization Vector to be used with the key. 94 | Keyformat string //Optional. Specifies how the key is represented in the resource. V5 or higher 95 | Keyformatversions string //Optional. Indicates which Keyformat versions this instance complies with. Default value is 1. V5 or higher 96 | 97 | masterPlaylist *MasterPlaylist // MasterPlaylist is included to be used internally for resolving relative resource locations for Session keys 98 | mediaPlaylist *MediaPlaylist // MediaPlaylist is included to be used internally for resolving relative resource locations 99 | } 100 | 101 | // Request creates a new http request ready to retrieve the segment 102 | func (k *Key) Request() (*http.Request, error) { 103 | uri, err := k.AbsoluteURL() 104 | if err != nil { 105 | return nil, fmt.Errorf("failed building resource url: %v", err) 106 | } 107 | 108 | req, err := http.NewRequest("GET", uri, nil) 109 | if err != nil { 110 | return req, fmt.Errorf("failed to construct request: %v", err) 111 | } 112 | return req, nil 113 | } 114 | 115 | // AbsoluteURL will resolve the Key URI to a absolute path, given it is a URL. 116 | func (k *Key) AbsoluteURL() (string, error) { 117 | var uri string 118 | var err error 119 | 120 | if k.IsSession { 121 | uri, err = resolveURLReference(k.masterPlaylist.URI, k.URI) 122 | } else { 123 | uri, err = resolveURLReference(k.mediaPlaylist.URI, k.URI) 124 | } 125 | 126 | return uri, err 127 | } 128 | 129 | // Equal checks whether all public fields are equal in a Key with the exception of the IV field. 130 | func (k *Key) Equal(other *Key) bool { 131 | if k != nil && other != nil { 132 | if k.IsSession != other.IsSession { 133 | return false 134 | } 135 | 136 | if k.Keyformat != other.Keyformat { 137 | return false 138 | } 139 | 140 | if k.Keyformatversions != other.Keyformatversions { 141 | return false 142 | } 143 | 144 | if k.Method != other.Method { 145 | return false 146 | } 147 | 148 | if k.URI != other.URI { 149 | return false 150 | } 151 | 152 | return true 153 | } 154 | 155 | // are they both nil 156 | return k == other 157 | } 158 | 159 | //Map represents tag #EXT-X-MAP:<attribute=value>. Specifies how to get the Media Initialization Section 160 | type Map struct { 161 | URI string //Required. 162 | Byterange *Byterange //Optional. Indicates the byte range into the URI resource containing the Media Initialization Section. 163 | 164 | mediaPlaylist *MediaPlaylist // MediaPlaylist is included to be used internally for resolving relative resource locations 165 | } 166 | 167 | // Equal determines if the two maps are equal, does not check private fields for equality so this does not guarantee that two maps will act identically. 168 | // Works on nil structures, if both m and other are nil, they are considered equal. 169 | func (m *Map) Equal(other *Map) bool { 170 | if m != nil && other != nil { 171 | return m.URI == other.URI && m.Byterange.Equal(other.Byterange) 172 | } 173 | 174 | return m == other 175 | } 176 | 177 | // Request creates a new http request ready to retrieve the segment 178 | func (m *Map) Request() (*http.Request, error) { 179 | uri, err := m.AbsoluteURL() 180 | if err != nil { 181 | return nil, fmt.Errorf("failed building resource url: %v", err) 182 | } 183 | 184 | req, err := http.NewRequest("GET", uri, nil) 185 | if err != nil { 186 | return req, fmt.Errorf("failed to construct request: %v", err) 187 | } 188 | return req, nil 189 | } 190 | 191 | // AbsoluteURL will resolve the EXT-X-MAP URI to a absolute path, given it is a URL. 192 | func (m *Map) AbsoluteURL() (string, error) { 193 | return resolveURLReference(m.mediaPlaylist.URI, m.URI) 194 | } 195 | 196 | //DateRange represents tag #EXT-X-DATERANGE:<attribute=value>. 197 | // 198 | //If present, playlist MUST also contain at least one EXT-X-PROGRAM-DATE-TIME tag. 199 | //Tags with the same Class MUST NOT indicate ranges that overlap. 200 | type DateRange struct { 201 | ID string //Required. If more than one tag with same ID exists, att values MUST be the same. 202 | Class string //Optional. Specifies some set of attributes and their associated value semantics. 203 | StartDate time.Time //Required. 204 | EndDate time.Time //Optional. 205 | Duration *float64 //Optional. If both EndDate and Duration present, check EndDate equal to Duration + StartDate 206 | PlannedDuration *float64 //Optional. Expected duration. 207 | XClientAttribute []string //Optional. Namespace reserved for client-defined att. eg. X-COM-EXAMPLE="example". 208 | EndOnNext bool //Optional. Possible Value: YES. Indicates the end of the current date range is equal to the start date of the following range of the samePROGRAM-DATE-TIME class. 209 | SCTE35 *SCTE35 210 | } 211 | 212 | //SCTE35 represents a DateRange attribute SCTE35-OUT, SCTE35-IN or SCTE35-CMD 213 | type SCTE35 struct { 214 | Type string //Possible Values: IN, OUT, CMD 215 | Value string //big-endian binary representation of the splice_info_section(), expressed as a hexadecimal-sequence. 216 | } 217 | -------------------------------------------------------------------------------- /hls/types.go: -------------------------------------------------------------------------------- 1 | package hls 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net/url" 8 | ) 9 | 10 | const ( 11 | sub = "SUBTITLES" 12 | aud = "AUDIO" 13 | vid = "VIDEO" 14 | cc = "CLOSED-CAPTIONS" 15 | aes = "AES-128" 16 | none = "NONE" 17 | sample = "SAMPLE-AES" 18 | boolYes = "YES" 19 | boolNo = "NO" 20 | ) 21 | 22 | // Source represents how you can fetch the components of a HLS manifest from different locations 23 | type Source interface { 24 | Master(ctx context.Context, uri string) (*MasterPlaylist, error) 25 | Media(ctx context.Context, variant *Variant) (*MediaPlaylist, error) 26 | Resource(ctx context.Context, uri string) (io.ReadCloser, error) 27 | } 28 | 29 | func resolveURLReference(base, sub string) (string, error) { 30 | ref, err := url.Parse(sub) 31 | if err != nil { 32 | return "", fmt.Errorf("failed to parse subresource uri: %v", err) 33 | } 34 | if ref.IsAbs() { 35 | return ref.String(), nil 36 | } 37 | 38 | baseURL, err := url.Parse(base) 39 | if err != nil { 40 | return "", err 41 | } 42 | return baseURL.ResolveReference(ref).String(), nil 43 | } 44 | -------------------------------------------------------------------------------- /manifest.go: -------------------------------------------------------------------------------- 1 | //Package manifest holds the main interface for Manifest encode/parse. 2 | package manifest 3 | 4 | import "io" 5 | 6 | // Parser is the interface by which we convert the textual format into a Go based structure format 7 | type Parser interface { 8 | Parse(reader io.Reader) error 9 | } 10 | 11 | // Encoder is the interface by which we convert our Go based structured format into the textual representation 12 | type Encoder interface { 13 | Encode() (io.Reader, error) 14 | } 15 | -------------------------------------------------------------------------------- /types.go: -------------------------------------------------------------------------------- 1 | package manifest 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | ) 7 | 8 | //BufWrapper is a wrapper type for bytes.Buffer. 9 | type BufWrapper struct { 10 | Buf *bytes.Buffer 11 | Err error 12 | } 13 | 14 | //NewBufWrapper returns an instance of BufWrapper. 15 | func NewBufWrapper() *BufWrapper { 16 | return &BufWrapper{ 17 | Buf: new(bytes.Buffer), 18 | Err: nil, 19 | } 20 | } 21 | 22 | //WriteValidString receives an interface and performs a buffer.Write if data is set. 23 | //It returns true if value is set, and false if it isn't. 24 | func (b *BufWrapper) WriteValidString(data interface{}, write string) bool { 25 | if b.Err != nil { 26 | return false 27 | } 28 | switch data.(type) { 29 | case string: 30 | if data.(string) != "" { 31 | _, b.Err = b.Buf.WriteString(write) 32 | return true 33 | } 34 | case float64: 35 | if data.(float64) > float64(0) { 36 | _, b.Err = b.Buf.WriteString(write) 37 | return true 38 | } 39 | case int64: 40 | if data.(int64) > 0 { 41 | _, b.Err = b.Buf.WriteString(write) 42 | return true 43 | } 44 | case int: 45 | if data.(int) > 0 { 46 | _, b.Err = b.Buf.WriteString(write) 47 | return true 48 | } 49 | case bool: 50 | if data.(bool) { 51 | _, b.Err = b.Buf.WriteString(write) 52 | return true 53 | } 54 | } 55 | 56 | return false 57 | } 58 | 59 | //WriteString wraps buffer.WriteString 60 | func (b *BufWrapper) WriteString(s string) { 61 | if b.Err != nil { 62 | return 63 | } 64 | _, b.Err = b.Buf.WriteString(s) 65 | } 66 | 67 | //WriteRune wraps buffer.WriteRune 68 | func (b *BufWrapper) WriteRune(r rune) { 69 | if b.Err != nil { 70 | return 71 | } 72 | _, b.Err = b.Buf.WriteRune(r) 73 | } 74 | 75 | //Write wraps buffer.Write 76 | func (b *BufWrapper) Write(p []byte) { 77 | if b.Err != nil { 78 | return 79 | } 80 | _, b.Err = b.Buf.Write(p) 81 | } 82 | 83 | // ReadFrom wraps buffer.ReadFrom 84 | func (b *BufWrapper) ReadFrom(r io.Reader) (int64, error) { 85 | if b.Err != nil { 86 | return 0, b.Err 87 | } 88 | 89 | var count int64 90 | count, b.Err = b.Buf.ReadFrom(r) 91 | return count, b.Err 92 | } 93 | 94 | // ReadString wraps buffer.ReadString 95 | func (b *BufWrapper) ReadString(delim byte) (line string) { 96 | if b.Err != nil { 97 | return 98 | } 99 | 100 | line, b.Err = b.Buf.ReadString(delim) 101 | return 102 | } 103 | --------------------------------------------------------------------------------