├── .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: 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:. 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:.
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 |
--------------------------------------------------------------------------------