openm3u8 7.0.0__cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openm3u8/__init__.py +113 -0
- openm3u8/_m3u8_parser.abi3.so +0 -0
- openm3u8/_m3u8_parser.c +3122 -0
- openm3u8/httpclient.py +36 -0
- openm3u8/mixins.py +52 -0
- openm3u8/model.py +1694 -0
- openm3u8/parser.py +786 -0
- openm3u8/protocol.py +47 -0
- openm3u8/version_matching.py +37 -0
- openm3u8/version_matching_rules.py +108 -0
- openm3u8-7.0.0.dist-info/METADATA +122 -0
- openm3u8-7.0.0.dist-info/RECORD +15 -0
- openm3u8-7.0.0.dist-info/WHEEL +6 -0
- openm3u8-7.0.0.dist-info/licenses/LICENSE +13 -0
- openm3u8-7.0.0.dist-info/top_level.txt +1 -0
openm3u8/model.py
ADDED
|
@@ -0,0 +1,1694 @@
|
|
|
1
|
+
# Copyright 2014 Globo.com Player authors. All rights reserved.
|
|
2
|
+
# Modifications Copyright (c) 2026 Wurl.
|
|
3
|
+
# Use of this source code is governed by a MIT License
|
|
4
|
+
# license that can be found in the LICENSE file.
|
|
5
|
+
import decimal
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
from openm3u8.mixins import BasePathMixin, GroupedBasePathMixin
|
|
9
|
+
from openm3u8.parser import parse, format_date_time
|
|
10
|
+
|
|
11
|
+
# Try to import the C extension for faster parsing, fall back to Python
|
|
12
|
+
if os.environ.get("M3U8_NO_C_EXTENSION", "") != "1":
|
|
13
|
+
try:
|
|
14
|
+
from openm3u8._m3u8_parser import parse
|
|
15
|
+
except ImportError:
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
from openm3u8.protocol import (
|
|
19
|
+
ext_oatcls_scte35,
|
|
20
|
+
ext_x_asset,
|
|
21
|
+
ext_x_key,
|
|
22
|
+
ext_x_map,
|
|
23
|
+
ext_x_session_key,
|
|
24
|
+
ext_x_start,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class MalformedPlaylistError(Exception):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class M3U8:
|
|
33
|
+
"""
|
|
34
|
+
Represents a single M3U8 playlist. Should be instantiated with
|
|
35
|
+
the content as string.
|
|
36
|
+
|
|
37
|
+
Parameters:
|
|
38
|
+
|
|
39
|
+
`content`
|
|
40
|
+
the m3u8 content as string
|
|
41
|
+
|
|
42
|
+
`base_path`
|
|
43
|
+
all urls (key and segments url) will be updated with this base_path,
|
|
44
|
+
ex.:
|
|
45
|
+
base_path = "http://videoserver.com/hls"
|
|
46
|
+
|
|
47
|
+
/foo/bar/key.bin --> http://videoserver.com/hls/key.bin
|
|
48
|
+
http://vid.com/segment1.ts --> http://videoserver.com/hls/segment1.ts
|
|
49
|
+
|
|
50
|
+
can be passed as parameter or setted as an attribute to ``M3U8`` object.
|
|
51
|
+
`base_uri`
|
|
52
|
+
uri the playlist comes from. it is propagated to SegmentList and Key
|
|
53
|
+
ex.: http://example.com/path/to
|
|
54
|
+
|
|
55
|
+
Attributes:
|
|
56
|
+
|
|
57
|
+
`keys`
|
|
58
|
+
Returns the list of `Key` objects used to encrypt the segments from m3u8.
|
|
59
|
+
It covers the whole list of possible situations when encryption either is
|
|
60
|
+
used or not.
|
|
61
|
+
|
|
62
|
+
1. No encryption.
|
|
63
|
+
`keys` list will only contain a `None` element.
|
|
64
|
+
|
|
65
|
+
2. Encryption enabled for all segments.
|
|
66
|
+
`keys` list will contain the key used for the segments.
|
|
67
|
+
|
|
68
|
+
3. No encryption for first element(s), encryption is applied afterwards
|
|
69
|
+
`keys` list will contain `None` and the key used for the rest of segments.
|
|
70
|
+
|
|
71
|
+
4. Multiple keys used during the m3u8 manifest.
|
|
72
|
+
`keys` list will contain the key used for each set of segments.
|
|
73
|
+
|
|
74
|
+
`session_keys`
|
|
75
|
+
Returns the list of `SessionKey` objects used to encrypt multiple segments from m3u8.
|
|
76
|
+
|
|
77
|
+
`segments`
|
|
78
|
+
a `SegmentList` object, represents the list of `Segment`s from this playlist
|
|
79
|
+
|
|
80
|
+
`is_variant`
|
|
81
|
+
Returns true if this M3U8 is a variant playlist, with links to
|
|
82
|
+
other M3U8s with different bitrates.
|
|
83
|
+
|
|
84
|
+
If true, `playlists` is a list of the playlists available,
|
|
85
|
+
and `iframe_playlists` is a list of the i-frame playlists available.
|
|
86
|
+
|
|
87
|
+
`is_endlist`
|
|
88
|
+
Returns true if EXT-X-ENDLIST tag present in M3U8.
|
|
89
|
+
http://tools.ietf.org/html/draft-pantos-http-live-streaming-07#section-3.3.8
|
|
90
|
+
|
|
91
|
+
`playlists`
|
|
92
|
+
If this is a variant playlist (`is_variant` is True), returns a list of
|
|
93
|
+
Playlist objects
|
|
94
|
+
|
|
95
|
+
`iframe_playlists`
|
|
96
|
+
If this is a variant playlist (`is_variant` is True), returns a list of
|
|
97
|
+
IFramePlaylist objects
|
|
98
|
+
|
|
99
|
+
`playlist_type`
|
|
100
|
+
A lower-case string representing the type of the playlist, which can be
|
|
101
|
+
one of VOD (video on demand) or EVENT.
|
|
102
|
+
|
|
103
|
+
`media`
|
|
104
|
+
If this is a variant playlist (`is_variant` is True), returns a list of
|
|
105
|
+
Media objects
|
|
106
|
+
|
|
107
|
+
`target_duration`
|
|
108
|
+
Returns the EXT-X-TARGETDURATION as an integer
|
|
109
|
+
http://tools.ietf.org/html/draft-pantos-http-live-streaming-07#section-3.3.2
|
|
110
|
+
|
|
111
|
+
`media_sequence`
|
|
112
|
+
Returns the EXT-X-MEDIA-SEQUENCE as an integer
|
|
113
|
+
http://tools.ietf.org/html/draft-pantos-http-live-streaming-07#section-3.3.3
|
|
114
|
+
|
|
115
|
+
`program_date_time`
|
|
116
|
+
Returns the EXT-X-PROGRAM-DATE-TIME as a string
|
|
117
|
+
http://tools.ietf.org/html/draft-pantos-http-live-streaming-07#section-3.3.5
|
|
118
|
+
|
|
119
|
+
`version`
|
|
120
|
+
Return the EXT-X-VERSION as is
|
|
121
|
+
|
|
122
|
+
`allow_cache`
|
|
123
|
+
Return the EXT-X-ALLOW-CACHE as is
|
|
124
|
+
|
|
125
|
+
`files`
|
|
126
|
+
Returns an iterable with all files from playlist, in order. This includes
|
|
127
|
+
segments and key uri, if present.
|
|
128
|
+
|
|
129
|
+
`base_uri`
|
|
130
|
+
It is a property (getter and setter) used by
|
|
131
|
+
SegmentList and Key to have absolute URIs.
|
|
132
|
+
|
|
133
|
+
`is_i_frames_only`
|
|
134
|
+
Returns true if EXT-X-I-FRAMES-ONLY tag present in M3U8.
|
|
135
|
+
http://tools.ietf.org/html/draft-pantos-http-live-streaming-07#section-3.3.12
|
|
136
|
+
|
|
137
|
+
`is_independent_segments`
|
|
138
|
+
Returns true if EXT-X-INDEPENDENT-SEGMENTS tag present in M3U8.
|
|
139
|
+
https://tools.ietf.org/html/draft-pantos-http-live-streaming-13#section-3.4.16
|
|
140
|
+
|
|
141
|
+
`image_playlists`
|
|
142
|
+
If this is a variant playlist (`is_variant` is True), returns a list of
|
|
143
|
+
ImagePlaylist objects
|
|
144
|
+
|
|
145
|
+
`is_images_only`
|
|
146
|
+
Returns true if EXT-X-IMAGES-ONLY tag present in M3U8.
|
|
147
|
+
https://github.com/image-media-playlist/spec/blob/master/image_media_playlist_v0_4.pdf
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
simple_attributes = (
|
|
151
|
+
# obj attribute # parser attribute
|
|
152
|
+
("is_variant", "is_variant"),
|
|
153
|
+
("is_endlist", "is_endlist"),
|
|
154
|
+
("is_i_frames_only", "is_i_frames_only"),
|
|
155
|
+
("target_duration", "targetduration"),
|
|
156
|
+
("media_sequence", "media_sequence"),
|
|
157
|
+
("program_date_time", "program_date_time"),
|
|
158
|
+
("is_independent_segments", "is_independent_segments"),
|
|
159
|
+
("version", "version"),
|
|
160
|
+
("allow_cache", "allow_cache"),
|
|
161
|
+
("playlist_type", "playlist_type"),
|
|
162
|
+
("discontinuity_sequence", "discontinuity_sequence"),
|
|
163
|
+
("is_images_only", "is_images_only"),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def __init__(
|
|
167
|
+
self,
|
|
168
|
+
content=None,
|
|
169
|
+
base_path=None,
|
|
170
|
+
base_uri=None,
|
|
171
|
+
strict=False,
|
|
172
|
+
custom_tags_parser=None,
|
|
173
|
+
):
|
|
174
|
+
if content is not None:
|
|
175
|
+
self.data = parse(content, strict, custom_tags_parser)
|
|
176
|
+
else:
|
|
177
|
+
self.data = {}
|
|
178
|
+
self._base_uri = base_uri
|
|
179
|
+
if self._base_uri:
|
|
180
|
+
if not self._base_uri.endswith("/"):
|
|
181
|
+
self._base_uri += "/"
|
|
182
|
+
|
|
183
|
+
self._initialize_attributes()
|
|
184
|
+
self.base_path = base_path
|
|
185
|
+
|
|
186
|
+
def _initialize_attributes(self):
|
|
187
|
+
self.keys = [
|
|
188
|
+
Key(base_uri=self.base_uri, **params) if params else None
|
|
189
|
+
for params in self.data.get("keys", [])
|
|
190
|
+
]
|
|
191
|
+
self.segment_map = [
|
|
192
|
+
InitializationSection(base_uri=self.base_uri, **params) if params else None
|
|
193
|
+
for params in self.data.get("segment_map", [])
|
|
194
|
+
]
|
|
195
|
+
self.segments = SegmentList(
|
|
196
|
+
[
|
|
197
|
+
Segment(
|
|
198
|
+
base_uri=self.base_uri,
|
|
199
|
+
keyobject=find_key(segment.get("key", {}), self.keys),
|
|
200
|
+
**segment,
|
|
201
|
+
)
|
|
202
|
+
for segment in self.data.get("segments", [])
|
|
203
|
+
]
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
for attr, param in self.simple_attributes:
|
|
207
|
+
setattr(self, attr, self.data.get(param))
|
|
208
|
+
|
|
209
|
+
for i, segment in enumerate(self.segments, self.media_sequence or 0):
|
|
210
|
+
segment.media_sequence = i
|
|
211
|
+
|
|
212
|
+
self.files = []
|
|
213
|
+
for key in self.keys:
|
|
214
|
+
# Avoid None key, it could be the first one, don't repeat them
|
|
215
|
+
if key and key.uri not in self.files:
|
|
216
|
+
self.files.append(key.uri)
|
|
217
|
+
self.files.extend(self.segments.uri)
|
|
218
|
+
|
|
219
|
+
self.media = MediaList(
|
|
220
|
+
[
|
|
221
|
+
Media(base_uri=self.base_uri, **media)
|
|
222
|
+
for media in self.data.get("media", [])
|
|
223
|
+
]
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
self.playlists = PlaylistList(
|
|
227
|
+
[
|
|
228
|
+
Playlist(base_uri=self.base_uri, media=self.media, **playlist)
|
|
229
|
+
for playlist in self.data.get("playlists", [])
|
|
230
|
+
]
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
self.iframe_playlists = PlaylistList()
|
|
234
|
+
for ifr_pl in self.data.get("iframe_playlists", []):
|
|
235
|
+
self.iframe_playlists.append(
|
|
236
|
+
IFramePlaylist(
|
|
237
|
+
base_uri=self.base_uri,
|
|
238
|
+
uri=ifr_pl["uri"],
|
|
239
|
+
iframe_stream_info=ifr_pl["iframe_stream_info"],
|
|
240
|
+
)
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
self.image_playlists = PlaylistList()
|
|
244
|
+
for img_pl in self.data.get("image_playlists", []):
|
|
245
|
+
self.image_playlists.append(
|
|
246
|
+
ImagePlaylist(
|
|
247
|
+
base_uri=self.base_uri,
|
|
248
|
+
uri=img_pl["uri"],
|
|
249
|
+
image_stream_info=img_pl["image_stream_info"],
|
|
250
|
+
)
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
start = self.data.get("start", None)
|
|
254
|
+
self.start = start and Start(**start)
|
|
255
|
+
|
|
256
|
+
server_control = self.data.get("server_control", None)
|
|
257
|
+
self.server_control = server_control and ServerControl(**server_control)
|
|
258
|
+
|
|
259
|
+
part_inf = self.data.get("part_inf", None)
|
|
260
|
+
self.part_inf = part_inf and PartInformation(**part_inf)
|
|
261
|
+
|
|
262
|
+
skip = self.data.get("skip", None)
|
|
263
|
+
self.skip = skip and Skip(**skip)
|
|
264
|
+
|
|
265
|
+
self.rendition_reports = RenditionReportList(
|
|
266
|
+
[
|
|
267
|
+
RenditionReport(base_uri=self.base_uri, **rendition_report)
|
|
268
|
+
for rendition_report in self.data.get("rendition_reports", [])
|
|
269
|
+
]
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
self.session_data = SessionDataList(
|
|
273
|
+
[
|
|
274
|
+
SessionData(**session_data)
|
|
275
|
+
for session_data in self.data.get("session_data", [])
|
|
276
|
+
if "data_id" in session_data
|
|
277
|
+
]
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
self.session_keys = [
|
|
281
|
+
SessionKey(base_uri=self.base_uri, **params) if params else None
|
|
282
|
+
for params in self.data.get("session_keys", [])
|
|
283
|
+
]
|
|
284
|
+
|
|
285
|
+
preload_hint = self.data.get("preload_hint", None)
|
|
286
|
+
self.preload_hint = preload_hint and PreloadHint(
|
|
287
|
+
base_uri=self.base_uri, **preload_hint
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
content_steering = self.data.get("content_steering", None)
|
|
291
|
+
self.content_steering = content_steering and ContentSteering(
|
|
292
|
+
base_uri=self.base_uri, **content_steering
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
def __unicode__(self):
|
|
296
|
+
return self.dumps()
|
|
297
|
+
|
|
298
|
+
@property
|
|
299
|
+
def base_uri(self):
|
|
300
|
+
return self._base_uri
|
|
301
|
+
|
|
302
|
+
@base_uri.setter
|
|
303
|
+
def base_uri(self, new_base_uri):
|
|
304
|
+
self._base_uri = new_base_uri
|
|
305
|
+
self.media.base_uri = new_base_uri
|
|
306
|
+
self.playlists.base_uri = new_base_uri
|
|
307
|
+
self.iframe_playlists.base_uri = new_base_uri
|
|
308
|
+
self.segments.base_uri = new_base_uri
|
|
309
|
+
self.rendition_reports.base_uri = new_base_uri
|
|
310
|
+
self.image_playlists.base_uri = new_base_uri
|
|
311
|
+
for key in self.keys:
|
|
312
|
+
if key:
|
|
313
|
+
key.base_uri = new_base_uri
|
|
314
|
+
for key in self.session_keys:
|
|
315
|
+
if key:
|
|
316
|
+
key.base_uri = new_base_uri
|
|
317
|
+
if self.preload_hint:
|
|
318
|
+
self.preload_hint.base_uri = new_base_uri
|
|
319
|
+
if self.content_steering:
|
|
320
|
+
self.content_steering.base_uri = new_base_uri
|
|
321
|
+
|
|
322
|
+
@property
|
|
323
|
+
def base_path(self):
|
|
324
|
+
return self._base_path
|
|
325
|
+
|
|
326
|
+
@base_path.setter
|
|
327
|
+
def base_path(self, newbase_path):
|
|
328
|
+
self._base_path = newbase_path
|
|
329
|
+
self._update_base_path()
|
|
330
|
+
|
|
331
|
+
def _update_base_path(self):
|
|
332
|
+
if self._base_path is None:
|
|
333
|
+
return
|
|
334
|
+
for key in self.keys:
|
|
335
|
+
if key:
|
|
336
|
+
key.base_path = self._base_path
|
|
337
|
+
for key in self.session_keys:
|
|
338
|
+
if key:
|
|
339
|
+
key.base_path = self._base_path
|
|
340
|
+
self.media.base_path = self._base_path
|
|
341
|
+
self.segments.base_path = self._base_path
|
|
342
|
+
self.playlists.base_path = self._base_path
|
|
343
|
+
self.iframe_playlists.base_path = self._base_path
|
|
344
|
+
self.image_playlists.base_path = self._base_path
|
|
345
|
+
self.rendition_reports.base_path = self._base_path
|
|
346
|
+
if self.preload_hint:
|
|
347
|
+
self.preload_hint.base_path = self._base_path
|
|
348
|
+
if self.content_steering:
|
|
349
|
+
self.content_steering.base_path = self._base_path
|
|
350
|
+
|
|
351
|
+
def add_playlist(self, playlist):
|
|
352
|
+
self.is_variant = True
|
|
353
|
+
self.playlists.append(playlist)
|
|
354
|
+
|
|
355
|
+
def add_iframe_playlist(self, iframe_playlist):
|
|
356
|
+
if iframe_playlist is not None:
|
|
357
|
+
self.is_variant = True
|
|
358
|
+
self.iframe_playlists.append(iframe_playlist)
|
|
359
|
+
|
|
360
|
+
def add_image_playlist(self, image_playlist):
|
|
361
|
+
if image_playlist is not None:
|
|
362
|
+
self.is_variant = True
|
|
363
|
+
self.image_playlists.append(image_playlist)
|
|
364
|
+
|
|
365
|
+
def add_media(self, media):
|
|
366
|
+
self.media.append(media)
|
|
367
|
+
|
|
368
|
+
def add_segment(self, segment):
|
|
369
|
+
self.segments.append(segment)
|
|
370
|
+
|
|
371
|
+
def add_rendition_report(self, report):
|
|
372
|
+
self.rendition_reports.append(report)
|
|
373
|
+
|
|
374
|
+
def dumps(self, timespec="milliseconds", infspec="auto"):
|
|
375
|
+
"""
|
|
376
|
+
Returns the current m3u8 as a string.
|
|
377
|
+
You could also use unicode(<this obj>) or str(<this obj>)
|
|
378
|
+
"""
|
|
379
|
+
output = ["#EXTM3U"]
|
|
380
|
+
if self.content_steering:
|
|
381
|
+
output.append(str(self.content_steering))
|
|
382
|
+
if self.media_sequence:
|
|
383
|
+
output.append("#EXT-X-MEDIA-SEQUENCE:" + str(self.media_sequence))
|
|
384
|
+
if self.discontinuity_sequence:
|
|
385
|
+
output.append(
|
|
386
|
+
f"#EXT-X-DISCONTINUITY-SEQUENCE:{self.discontinuity_sequence}"
|
|
387
|
+
)
|
|
388
|
+
if self.allow_cache:
|
|
389
|
+
output.append("#EXT-X-ALLOW-CACHE:" + self.allow_cache.upper())
|
|
390
|
+
if self.version:
|
|
391
|
+
output.append("#EXT-X-VERSION:" + str(self.version))
|
|
392
|
+
if self.is_independent_segments:
|
|
393
|
+
output.append("#EXT-X-INDEPENDENT-SEGMENTS")
|
|
394
|
+
if self.target_duration:
|
|
395
|
+
output.append(
|
|
396
|
+
"#EXT-X-TARGETDURATION:" + number_to_string(self.target_duration)
|
|
397
|
+
)
|
|
398
|
+
if not (self.playlist_type is None or self.playlist_type == ""):
|
|
399
|
+
output.append("#EXT-X-PLAYLIST-TYPE:%s" % str(self.playlist_type).upper())
|
|
400
|
+
if self.start:
|
|
401
|
+
output.append(str(self.start))
|
|
402
|
+
if self.is_i_frames_only:
|
|
403
|
+
output.append("#EXT-X-I-FRAMES-ONLY")
|
|
404
|
+
if self.is_images_only:
|
|
405
|
+
output.append("#EXT-X-IMAGES-ONLY")
|
|
406
|
+
if self.server_control:
|
|
407
|
+
output.append(str(self.server_control))
|
|
408
|
+
if self.is_variant:
|
|
409
|
+
if self.media:
|
|
410
|
+
output.append(str(self.media))
|
|
411
|
+
output.append(str(self.playlists))
|
|
412
|
+
if self.iframe_playlists:
|
|
413
|
+
output.append(str(self.iframe_playlists))
|
|
414
|
+
if self.image_playlists:
|
|
415
|
+
output.append(str(self.image_playlists))
|
|
416
|
+
if self.part_inf:
|
|
417
|
+
output.append(str(self.part_inf))
|
|
418
|
+
if self.skip:
|
|
419
|
+
output.append(str(self.skip))
|
|
420
|
+
if self.session_data:
|
|
421
|
+
output.append(str(self.session_data))
|
|
422
|
+
|
|
423
|
+
for key in self.session_keys:
|
|
424
|
+
output.append(str(key))
|
|
425
|
+
|
|
426
|
+
output.append(self.segments.dumps(timespec, infspec))
|
|
427
|
+
|
|
428
|
+
if self.preload_hint:
|
|
429
|
+
output.append(str(self.preload_hint))
|
|
430
|
+
|
|
431
|
+
if self.rendition_reports:
|
|
432
|
+
output.append(str(self.rendition_reports))
|
|
433
|
+
|
|
434
|
+
if self.is_endlist:
|
|
435
|
+
output.append("#EXT-X-ENDLIST")
|
|
436
|
+
|
|
437
|
+
# ensure that the last line is terminated correctly
|
|
438
|
+
if output[-1] and not output[-1].endswith("\n"):
|
|
439
|
+
output.append("")
|
|
440
|
+
|
|
441
|
+
return "\n".join(output)
|
|
442
|
+
|
|
443
|
+
def dump(self, filename):
|
|
444
|
+
"""
|
|
445
|
+
Saves the current m3u8 to ``filename``
|
|
446
|
+
"""
|
|
447
|
+
self._create_sub_directories(filename)
|
|
448
|
+
|
|
449
|
+
with open(filename, "w") as fileobj:
|
|
450
|
+
fileobj.write(self.dumps())
|
|
451
|
+
|
|
452
|
+
def _create_sub_directories(self, filename):
|
|
453
|
+
if not os.path.isabs(filename):
|
|
454
|
+
filename = os.path.join(os.getcwd(), filename)
|
|
455
|
+
|
|
456
|
+
basename = os.path.dirname(filename)
|
|
457
|
+
if basename:
|
|
458
|
+
os.makedirs(basename, exist_ok=True)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
class Segment(BasePathMixin):
|
|
462
|
+
"""
|
|
463
|
+
A video segment from a M3U8 playlist
|
|
464
|
+
|
|
465
|
+
`uri`
|
|
466
|
+
a string with the segment uri
|
|
467
|
+
|
|
468
|
+
`title`
|
|
469
|
+
title attribute from EXTINF parameter
|
|
470
|
+
|
|
471
|
+
`program_date_time`
|
|
472
|
+
Returns the EXT-X-PROGRAM-DATE-TIME as a datetime. This field is only set
|
|
473
|
+
if EXT-X-PROGRAM-DATE-TIME exists for this segment
|
|
474
|
+
http://tools.ietf.org/html/draft-pantos-http-live-streaming-07#section-3.3.5
|
|
475
|
+
|
|
476
|
+
`current_program_date_time`
|
|
477
|
+
Returns a datetime of this segment, either the value of `program_date_time`
|
|
478
|
+
when EXT-X-PROGRAM-DATE-TIME is set or a calculated value based on previous
|
|
479
|
+
segments' EXT-X-PROGRAM-DATE-TIME and EXTINF values
|
|
480
|
+
|
|
481
|
+
`discontinuity`
|
|
482
|
+
Returns a boolean indicating if a EXT-X-DISCONTINUITY tag exists
|
|
483
|
+
http://tools.ietf.org/html/draft-pantos-http-live-streaming-13#section-3.4.11
|
|
484
|
+
|
|
485
|
+
`cue_out`
|
|
486
|
+
Returns a boolean indicating if a EXT-X-CUE-OUT-CONT tag exists
|
|
487
|
+
Note: for backwards compatibility, this will be True when cue_out_start
|
|
488
|
+
is True, even though this tag did not exist in the input, and
|
|
489
|
+
EXT-X-CUE-OUT-CONT will not exist in the output
|
|
490
|
+
|
|
491
|
+
`cue_out_start`
|
|
492
|
+
Returns a boolean indicating if a EXT-X-CUE-OUT tag exists
|
|
493
|
+
|
|
494
|
+
`cue_out_explicitly_duration`
|
|
495
|
+
Returns a boolean indicating if a EXT-X-CUE-OUT have the DURATION parameter when parsing
|
|
496
|
+
|
|
497
|
+
`cue_in`
|
|
498
|
+
Returns a boolean indicating if a EXT-X-CUE-IN tag exists
|
|
499
|
+
|
|
500
|
+
`scte35`
|
|
501
|
+
Base64 encoded SCTE35 metadata if available
|
|
502
|
+
|
|
503
|
+
`scte35_duration`
|
|
504
|
+
Planned SCTE35 duration
|
|
505
|
+
|
|
506
|
+
`duration`
|
|
507
|
+
duration attribute from EXTINF parameter
|
|
508
|
+
|
|
509
|
+
`base_uri`
|
|
510
|
+
uri the key comes from in URI hierarchy. ex.: http://example.com/path/to
|
|
511
|
+
|
|
512
|
+
`bitrate`
|
|
513
|
+
bitrate attribute from EXT-X-BITRATE parameter
|
|
514
|
+
|
|
515
|
+
`byterange`
|
|
516
|
+
byterange attribute from EXT-X-BYTERANGE parameter
|
|
517
|
+
|
|
518
|
+
`key`
|
|
519
|
+
Key used to encrypt the segment (EXT-X-KEY)
|
|
520
|
+
|
|
521
|
+
`parts`
|
|
522
|
+
partial segments that make up this segment
|
|
523
|
+
|
|
524
|
+
`dateranges`
|
|
525
|
+
any dateranges that should precede the segment
|
|
526
|
+
|
|
527
|
+
`gap_tag`
|
|
528
|
+
GAP tag indicates that a Media Segment is missing
|
|
529
|
+
|
|
530
|
+
`blackout`
|
|
531
|
+
indicates program changes
|
|
532
|
+
|
|
533
|
+
`custom_parser_values`
|
|
534
|
+
Additional values which custom_tags_parser might store per segment
|
|
535
|
+
"""
|
|
536
|
+
|
|
537
|
+
def __init__(
|
|
538
|
+
self,
|
|
539
|
+
uri=None,
|
|
540
|
+
base_uri=None,
|
|
541
|
+
program_date_time=None,
|
|
542
|
+
current_program_date_time=None,
|
|
543
|
+
duration=None,
|
|
544
|
+
title=None,
|
|
545
|
+
bitrate=None,
|
|
546
|
+
byterange=None,
|
|
547
|
+
cue_out=False,
|
|
548
|
+
cue_out_start=False,
|
|
549
|
+
cue_out_explicitly_duration=False,
|
|
550
|
+
cue_in=False,
|
|
551
|
+
discontinuity=False,
|
|
552
|
+
key=None,
|
|
553
|
+
scte35=None,
|
|
554
|
+
oatcls_scte35=None,
|
|
555
|
+
scte35_duration=None,
|
|
556
|
+
scte35_elapsedtime=None,
|
|
557
|
+
asset_metadata=None,
|
|
558
|
+
keyobject=None,
|
|
559
|
+
parts=None,
|
|
560
|
+
init_section=None,
|
|
561
|
+
dateranges=None,
|
|
562
|
+
gap_tag=None,
|
|
563
|
+
blackout=None,
|
|
564
|
+
media_sequence=None,
|
|
565
|
+
custom_parser_values=None,
|
|
566
|
+
):
|
|
567
|
+
self.media_sequence = media_sequence
|
|
568
|
+
self.uri = uri
|
|
569
|
+
self.duration = duration
|
|
570
|
+
self.title = title
|
|
571
|
+
self._base_uri = base_uri
|
|
572
|
+
self.bitrate = bitrate
|
|
573
|
+
self.byterange = byterange
|
|
574
|
+
self.program_date_time = program_date_time
|
|
575
|
+
self.current_program_date_time = current_program_date_time
|
|
576
|
+
self.discontinuity = discontinuity
|
|
577
|
+
self.cue_out_start = cue_out_start
|
|
578
|
+
self.cue_out_explicitly_duration = cue_out_explicitly_duration
|
|
579
|
+
self.cue_out = cue_out
|
|
580
|
+
self.cue_in = cue_in
|
|
581
|
+
self.scte35 = scte35
|
|
582
|
+
self.oatcls_scte35 = oatcls_scte35
|
|
583
|
+
self.scte35_duration = scte35_duration
|
|
584
|
+
self.scte35_elapsedtime = scte35_elapsedtime
|
|
585
|
+
self.asset_metadata = asset_metadata
|
|
586
|
+
self.key = keyobject
|
|
587
|
+
self.parts = PartialSegmentList(
|
|
588
|
+
[PartialSegment(base_uri=self._base_uri, **partial) for partial in parts]
|
|
589
|
+
if parts
|
|
590
|
+
else []
|
|
591
|
+
)
|
|
592
|
+
if init_section is not None:
|
|
593
|
+
self.init_section = InitializationSection(self._base_uri, **init_section)
|
|
594
|
+
else:
|
|
595
|
+
self.init_section = None
|
|
596
|
+
self.dateranges = DateRangeList(
|
|
597
|
+
[DateRange(**daterange) for daterange in dateranges] if dateranges else []
|
|
598
|
+
)
|
|
599
|
+
self.gap_tag = gap_tag
|
|
600
|
+
self.blackout = blackout
|
|
601
|
+
self.custom_parser_values = custom_parser_values or {}
|
|
602
|
+
|
|
603
|
+
def add_part(self, part):
|
|
604
|
+
self.parts.append(part)
|
|
605
|
+
|
|
606
|
+
def dumps(self, last_segment, timespec="milliseconds", infspec="auto"):
|
|
607
|
+
output = []
|
|
608
|
+
|
|
609
|
+
if last_segment and self.key != last_segment.key:
|
|
610
|
+
output.append(str(self.key))
|
|
611
|
+
output.append("\n")
|
|
612
|
+
else:
|
|
613
|
+
# The key must be checked anyway now for the first segment
|
|
614
|
+
if self.key and last_segment is None:
|
|
615
|
+
output.append(str(self.key))
|
|
616
|
+
output.append("\n")
|
|
617
|
+
|
|
618
|
+
if self.init_section:
|
|
619
|
+
if (not last_segment) or (self.init_section != last_segment.init_section):
|
|
620
|
+
output.append(str(self.init_section))
|
|
621
|
+
output.append("\n")
|
|
622
|
+
|
|
623
|
+
if self.discontinuity:
|
|
624
|
+
output.append("#EXT-X-DISCONTINUITY\n")
|
|
625
|
+
if self.program_date_time:
|
|
626
|
+
output.append(
|
|
627
|
+
"#EXT-X-PROGRAM-DATE-TIME:%s\n"
|
|
628
|
+
% format_date_time(self.program_date_time, timespec=timespec)
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
if len(self.dateranges):
|
|
632
|
+
output.append(str(self.dateranges))
|
|
633
|
+
output.append("\n")
|
|
634
|
+
|
|
635
|
+
if self.cue_out_start:
|
|
636
|
+
if self.oatcls_scte35:
|
|
637
|
+
output.append(f"{ext_oatcls_scte35}:{self.oatcls_scte35}\n")
|
|
638
|
+
|
|
639
|
+
if self.asset_metadata:
|
|
640
|
+
asset_suffix = []
|
|
641
|
+
for metadata_key, metadata_value in self.asset_metadata.items():
|
|
642
|
+
asset_suffix.append(f"{metadata_key.upper()}={metadata_value}")
|
|
643
|
+
output.append(f"{ext_x_asset}:{','.join(asset_suffix)}\n")
|
|
644
|
+
|
|
645
|
+
prefix = ":DURATION=" if self.cue_out_explicitly_duration else ":"
|
|
646
|
+
cue_info = f"{prefix}{self.scte35_duration}" if self.scte35_duration else ""
|
|
647
|
+
output.append(f"#EXT-X-CUE-OUT{cue_info}\n")
|
|
648
|
+
elif self.cue_out:
|
|
649
|
+
cue_out_cont_suffix = []
|
|
650
|
+
if self.scte35_elapsedtime:
|
|
651
|
+
cue_out_cont_suffix.append(f"ElapsedTime={self.scte35_elapsedtime}")
|
|
652
|
+
if self.scte35_duration:
|
|
653
|
+
cue_out_cont_suffix.append(f"Duration={self.scte35_duration}")
|
|
654
|
+
if self.scte35:
|
|
655
|
+
cue_out_cont_suffix.append(f"SCTE35={self.scte35}")
|
|
656
|
+
|
|
657
|
+
if cue_out_cont_suffix:
|
|
658
|
+
cue_out_cont_suffix = ":" + ",".join(cue_out_cont_suffix)
|
|
659
|
+
else:
|
|
660
|
+
cue_out_cont_suffix = ""
|
|
661
|
+
output.append(f"#EXT-X-CUE-OUT-CONT{cue_out_cont_suffix}\n")
|
|
662
|
+
if self.oatcls_scte35:
|
|
663
|
+
if (not last_segment) or (
|
|
664
|
+
last_segment.oatcls_scte35 != self.oatcls_scte35
|
|
665
|
+
):
|
|
666
|
+
output.append(f"{ext_oatcls_scte35}:{self.oatcls_scte35}\n")
|
|
667
|
+
elif self.cue_in:
|
|
668
|
+
output.append("#EXT-X-CUE-IN\n")
|
|
669
|
+
if self.oatcls_scte35:
|
|
670
|
+
if (not last_segment) or (
|
|
671
|
+
last_segment.oatcls_scte35 != self.oatcls_scte35
|
|
672
|
+
):
|
|
673
|
+
output.append(f"{ext_oatcls_scte35}:{self.oatcls_scte35}\n")
|
|
674
|
+
elif self.oatcls_scte35:
|
|
675
|
+
output.append(f"{ext_oatcls_scte35}:{self.oatcls_scte35}\n")
|
|
676
|
+
|
|
677
|
+
if self.parts:
|
|
678
|
+
output.append(str(self.parts))
|
|
679
|
+
output.append("\n")
|
|
680
|
+
|
|
681
|
+
if self.blackout:
|
|
682
|
+
if self.blackout is True:
|
|
683
|
+
# tag without parameters
|
|
684
|
+
output.append("#EXT-X-BLACKOUT\n")
|
|
685
|
+
else:
|
|
686
|
+
# tag with parameters
|
|
687
|
+
output.append(f"#EXT-X-BLACKOUT:{self.blackout}\n")
|
|
688
|
+
|
|
689
|
+
if self.uri:
|
|
690
|
+
if self.duration is not None:
|
|
691
|
+
if infspec == "milliseconds":
|
|
692
|
+
duration = f"{self.duration:.3f}"
|
|
693
|
+
elif infspec == "microseconds":
|
|
694
|
+
duration = f"{self.duration:.6f}"
|
|
695
|
+
else:
|
|
696
|
+
duration = number_to_string(self.duration)
|
|
697
|
+
output.append("#EXTINF:%s," % duration)
|
|
698
|
+
if self.title:
|
|
699
|
+
output.append(self.title)
|
|
700
|
+
output.append("\n")
|
|
701
|
+
|
|
702
|
+
if self.byterange:
|
|
703
|
+
output.append("#EXT-X-BYTERANGE:%s\n" % self.byterange)
|
|
704
|
+
|
|
705
|
+
if self.bitrate:
|
|
706
|
+
output.append("#EXT-X-BITRATE:%d\n" % self.bitrate)
|
|
707
|
+
|
|
708
|
+
if self.gap_tag:
|
|
709
|
+
output.append("#EXT-X-GAP\n")
|
|
710
|
+
|
|
711
|
+
output.append(self.uri)
|
|
712
|
+
|
|
713
|
+
return "".join(output)
|
|
714
|
+
|
|
715
|
+
def __str__(self):
|
|
716
|
+
return self.dumps(None)
|
|
717
|
+
|
|
718
|
+
@property
|
|
719
|
+
def base_path(self):
|
|
720
|
+
return super().base_path
|
|
721
|
+
|
|
722
|
+
@base_path.setter
|
|
723
|
+
def base_path(self, newbase_path):
|
|
724
|
+
super(Segment, self.__class__).base_path.fset(self, newbase_path)
|
|
725
|
+
self.parts.base_path = newbase_path
|
|
726
|
+
if self.init_section is not None:
|
|
727
|
+
self.init_section.base_path = newbase_path
|
|
728
|
+
|
|
729
|
+
@property
|
|
730
|
+
def base_uri(self):
|
|
731
|
+
return self._base_uri
|
|
732
|
+
|
|
733
|
+
@base_uri.setter
|
|
734
|
+
def base_uri(self, newbase_uri):
|
|
735
|
+
self._base_uri = newbase_uri
|
|
736
|
+
self.parts.base_uri = newbase_uri
|
|
737
|
+
if self.init_section is not None:
|
|
738
|
+
self.init_section.base_uri = newbase_uri
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
class SegmentList(list, GroupedBasePathMixin):
|
|
742
|
+
def dumps(self, timespec="milliseconds", infspec="auto"):
|
|
743
|
+
output = []
|
|
744
|
+
last_segment = None
|
|
745
|
+
for segment in self:
|
|
746
|
+
output.append(segment.dumps(last_segment, timespec, infspec))
|
|
747
|
+
last_segment = segment
|
|
748
|
+
return "\n".join(output)
|
|
749
|
+
|
|
750
|
+
def __str__(self):
|
|
751
|
+
return self.dumps()
|
|
752
|
+
|
|
753
|
+
@property
|
|
754
|
+
def uri(self):
|
|
755
|
+
return [seg.uri for seg in self]
|
|
756
|
+
|
|
757
|
+
def by_key(self, key):
|
|
758
|
+
return [segment for segment in self if segment.key == key]
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
class PartialSegment(BasePathMixin):
|
|
762
|
+
"""
|
|
763
|
+
A partial segment from a M3U8 playlist
|
|
764
|
+
|
|
765
|
+
`uri`
|
|
766
|
+
a string with the segment uri
|
|
767
|
+
|
|
768
|
+
`program_date_time`
|
|
769
|
+
Returns the EXT-X-PROGRAM-DATE-TIME as a datetime. This field is only set
|
|
770
|
+
if EXT-X-PROGRAM-DATE-TIME exists for this segment
|
|
771
|
+
http://tools.ietf.org/html/draft-pantos-http-live-streaming-07#section-3.3.5
|
|
772
|
+
|
|
773
|
+
`current_program_date_time`
|
|
774
|
+
Returns a datetime of this segment, either the value of `program_date_time`
|
|
775
|
+
when EXT-X-PROGRAM-DATE-TIME is set or a calculated value based on previous
|
|
776
|
+
segments' EXT-X-PROGRAM-DATE-TIME and EXTINF values
|
|
777
|
+
|
|
778
|
+
`duration`
|
|
779
|
+
duration attribute from EXTINF parameter
|
|
780
|
+
|
|
781
|
+
`byterange`
|
|
782
|
+
byterange attribute from EXT-X-BYTERANGE parameter
|
|
783
|
+
|
|
784
|
+
`independent`
|
|
785
|
+
the Partial Segment contains an independent frame
|
|
786
|
+
|
|
787
|
+
`gap`
|
|
788
|
+
GAP attribute indicates the Partial Segment is not available
|
|
789
|
+
|
|
790
|
+
`dateranges`
|
|
791
|
+
any dateranges that should precede the partial segment
|
|
792
|
+
|
|
793
|
+
`gap_tag`
|
|
794
|
+
GAP tag indicates one or more of the parent Media Segment's Partial
|
|
795
|
+
Segments have a GAP=YES attribute. This tag should appear immediately
|
|
796
|
+
after the first EXT-X-PART tag in the Parent Segment with a GAP=YES
|
|
797
|
+
attribute.
|
|
798
|
+
"""
|
|
799
|
+
|
|
800
|
+
def __init__(
|
|
801
|
+
self,
|
|
802
|
+
base_uri,
|
|
803
|
+
uri,
|
|
804
|
+
duration,
|
|
805
|
+
program_date_time=None,
|
|
806
|
+
current_program_date_time=None,
|
|
807
|
+
byterange=None,
|
|
808
|
+
independent=None,
|
|
809
|
+
gap=None,
|
|
810
|
+
dateranges=None,
|
|
811
|
+
gap_tag=None,
|
|
812
|
+
):
|
|
813
|
+
self.base_uri = base_uri
|
|
814
|
+
self.uri = uri
|
|
815
|
+
self.duration = duration
|
|
816
|
+
self.program_date_time = program_date_time
|
|
817
|
+
self.current_program_date_time = current_program_date_time
|
|
818
|
+
self.byterange = byterange
|
|
819
|
+
self.independent = independent
|
|
820
|
+
self.gap = gap
|
|
821
|
+
self.dateranges = DateRangeList(
|
|
822
|
+
[DateRange(**daterange) for daterange in dateranges] if dateranges else []
|
|
823
|
+
)
|
|
824
|
+
self.gap_tag = gap_tag
|
|
825
|
+
|
|
826
|
+
def dumps(self, last_segment):
|
|
827
|
+
output = []
|
|
828
|
+
|
|
829
|
+
if len(self.dateranges):
|
|
830
|
+
output.append(str(self.dateranges))
|
|
831
|
+
output.append("\n")
|
|
832
|
+
|
|
833
|
+
if self.gap_tag:
|
|
834
|
+
output.append("#EXT-X-GAP\n")
|
|
835
|
+
|
|
836
|
+
output.append(
|
|
837
|
+
'#EXT-X-PART:DURATION=%s,URI="%s"'
|
|
838
|
+
% (number_to_string(self.duration), self.uri)
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
if self.independent:
|
|
842
|
+
output.append(",INDEPENDENT=%s" % self.independent)
|
|
843
|
+
|
|
844
|
+
if self.byterange:
|
|
845
|
+
output.append(",BYTERANGE=%s" % self.byterange)
|
|
846
|
+
|
|
847
|
+
if self.gap:
|
|
848
|
+
output.append(",GAP=%s" % self.gap)
|
|
849
|
+
|
|
850
|
+
return "".join(output)
|
|
851
|
+
|
|
852
|
+
def __str__(self):
|
|
853
|
+
return self.dumps(None)
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
class PartialSegmentList(list, GroupedBasePathMixin):
|
|
857
|
+
def __str__(self):
|
|
858
|
+
output = [str(part) for part in self]
|
|
859
|
+
return "\n".join(output)
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
class Key(BasePathMixin):
|
|
863
|
+
"""
|
|
864
|
+
Key used to encrypt the segments in a m3u8 playlist (EXT-X-KEY)
|
|
865
|
+
|
|
866
|
+
`method`
|
|
867
|
+
is a string. ex.: "AES-128"
|
|
868
|
+
|
|
869
|
+
`uri`
|
|
870
|
+
is a string. ex:: "https://priv.example.com/key.php?r=52"
|
|
871
|
+
|
|
872
|
+
`base_uri`
|
|
873
|
+
uri the key comes from in URI hierarchy. ex.: http://example.com/path/to
|
|
874
|
+
|
|
875
|
+
`iv`
|
|
876
|
+
initialization vector. a string representing a hexadecimal number. ex.: 0X12A
|
|
877
|
+
|
|
878
|
+
"""
|
|
879
|
+
|
|
880
|
+
tag = ext_x_key
|
|
881
|
+
|
|
882
|
+
def __init__(
|
|
883
|
+
self,
|
|
884
|
+
method,
|
|
885
|
+
base_uri,
|
|
886
|
+
uri=None,
|
|
887
|
+
iv=None,
|
|
888
|
+
keyformat=None,
|
|
889
|
+
keyformatversions=None,
|
|
890
|
+
**kwargs,
|
|
891
|
+
):
|
|
892
|
+
self.method = method
|
|
893
|
+
self.uri = uri
|
|
894
|
+
self.iv = iv
|
|
895
|
+
self.keyformat = keyformat
|
|
896
|
+
self.keyformatversions = keyformatversions
|
|
897
|
+
self.base_uri = base_uri
|
|
898
|
+
self._extra_params = kwargs
|
|
899
|
+
|
|
900
|
+
def __str__(self):
|
|
901
|
+
output = [
|
|
902
|
+
"METHOD=%s" % self.method,
|
|
903
|
+
]
|
|
904
|
+
if self.uri:
|
|
905
|
+
output.append('URI="%s"' % self.uri)
|
|
906
|
+
if self.iv:
|
|
907
|
+
output.append("IV=%s" % self.iv)
|
|
908
|
+
if self.keyformat:
|
|
909
|
+
output.append('KEYFORMAT="%s"' % self.keyformat)
|
|
910
|
+
if self.keyformatversions:
|
|
911
|
+
output.append('KEYFORMATVERSIONS="%s"' % self.keyformatversions)
|
|
912
|
+
|
|
913
|
+
return self.tag + ":" + ",".join(output)
|
|
914
|
+
|
|
915
|
+
def __eq__(self, other):
|
|
916
|
+
if not other:
|
|
917
|
+
return False
|
|
918
|
+
return (
|
|
919
|
+
self.method == other.method
|
|
920
|
+
and self.uri == other.uri
|
|
921
|
+
and self.iv == other.iv
|
|
922
|
+
and self.base_uri == other.base_uri
|
|
923
|
+
and self.keyformat == other.keyformat
|
|
924
|
+
and self.keyformatversions == other.keyformatversions
|
|
925
|
+
)
|
|
926
|
+
|
|
927
|
+
def __ne__(self, other):
|
|
928
|
+
return not self.__eq__(other)
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
class InitializationSection(BasePathMixin):
|
|
932
|
+
"""
|
|
933
|
+
Used to obtain Media Initialization Section required to
|
|
934
|
+
parse the applicable Media Segments (EXT-X-MAP)
|
|
935
|
+
|
|
936
|
+
`uri`
|
|
937
|
+
is a string. ex:: "https://priv.example.com/key.php?r=52"
|
|
938
|
+
|
|
939
|
+
`byterange`
|
|
940
|
+
value of BYTERANGE attribute
|
|
941
|
+
|
|
942
|
+
`base_uri`
|
|
943
|
+
uri the segment comes from in URI hierarchy. ex.: http://example.com/path/to
|
|
944
|
+
"""
|
|
945
|
+
|
|
946
|
+
tag = ext_x_map
|
|
947
|
+
|
|
948
|
+
def __init__(self, base_uri, uri, byterange=None):
|
|
949
|
+
self.base_uri = base_uri
|
|
950
|
+
self.uri = uri
|
|
951
|
+
self.byterange = byterange
|
|
952
|
+
|
|
953
|
+
def __str__(self):
|
|
954
|
+
output = []
|
|
955
|
+
if self.uri:
|
|
956
|
+
output.append("URI=" + quoted(self.uri))
|
|
957
|
+
if self.byterange:
|
|
958
|
+
output.append("BYTERANGE=" + quoted(self.byterange))
|
|
959
|
+
return "{tag}:{attributes}".format(tag=self.tag, attributes=",".join(output))
|
|
960
|
+
|
|
961
|
+
def __eq__(self, other):
|
|
962
|
+
if not other:
|
|
963
|
+
return False
|
|
964
|
+
return (
|
|
965
|
+
self.uri == other.uri
|
|
966
|
+
and self.byterange == other.byterange
|
|
967
|
+
and self.base_uri == other.base_uri
|
|
968
|
+
)
|
|
969
|
+
|
|
970
|
+
def __ne__(self, other):
|
|
971
|
+
return not self.__eq__(other)
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
class SessionKey(Key):
|
|
975
|
+
tag = ext_x_session_key
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
class Playlist(BasePathMixin):
|
|
979
|
+
"""
|
|
980
|
+
Playlist object representing a link to a variant M3U8 with a specific bitrate.
|
|
981
|
+
|
|
982
|
+
Attributes:
|
|
983
|
+
|
|
984
|
+
`stream_info` is a named tuple containing the attributes: `program_id`,
|
|
985
|
+
`bandwidth`, `average_bandwidth`, `resolution`, `codecs` and `resolution`
|
|
986
|
+
which is a a tuple (w, h) of integers
|
|
987
|
+
|
|
988
|
+
`media` is a list of related Media entries.
|
|
989
|
+
|
|
990
|
+
More info: http://tools.ietf.org/html/draft-pantos-http-live-streaming-07#section-3.3.10
|
|
991
|
+
"""
|
|
992
|
+
|
|
993
|
+
def __init__(self, uri, stream_info, media, base_uri):
|
|
994
|
+
self.uri = uri
|
|
995
|
+
self.base_uri = base_uri
|
|
996
|
+
|
|
997
|
+
resolution = stream_info.get("resolution")
|
|
998
|
+
if resolution is not None:
|
|
999
|
+
resolution = resolution.strip('"')
|
|
1000
|
+
values = resolution.split("x")
|
|
1001
|
+
resolution_pair = (int(values[0]), int(values[1]))
|
|
1002
|
+
else:
|
|
1003
|
+
resolution_pair = None
|
|
1004
|
+
|
|
1005
|
+
self.stream_info = StreamInfo(
|
|
1006
|
+
bandwidth=stream_info["bandwidth"],
|
|
1007
|
+
video=stream_info.get("video"),
|
|
1008
|
+
audio=stream_info.get("audio"),
|
|
1009
|
+
subtitles=stream_info.get("subtitles"),
|
|
1010
|
+
closed_captions=stream_info.get("closed_captions"),
|
|
1011
|
+
average_bandwidth=stream_info.get("average_bandwidth"),
|
|
1012
|
+
program_id=stream_info.get("program_id"),
|
|
1013
|
+
resolution=resolution_pair,
|
|
1014
|
+
codecs=stream_info.get("codecs"),
|
|
1015
|
+
frame_rate=stream_info.get("frame_rate"),
|
|
1016
|
+
video_range=stream_info.get("video_range"),
|
|
1017
|
+
hdcp_level=stream_info.get("hdcp_level"),
|
|
1018
|
+
pathway_id=stream_info.get("pathway_id"),
|
|
1019
|
+
stable_variant_id=stream_info.get("stable_variant_id"),
|
|
1020
|
+
req_video_layout=stream_info.get("req_video_layout"),
|
|
1021
|
+
)
|
|
1022
|
+
self.media = []
|
|
1023
|
+
for media_type in ("audio", "video", "subtitles"):
|
|
1024
|
+
group_id = stream_info.get(media_type)
|
|
1025
|
+
if not group_id:
|
|
1026
|
+
continue
|
|
1027
|
+
|
|
1028
|
+
self.media += filter(lambda m: m.group_id == group_id, media)
|
|
1029
|
+
|
|
1030
|
+
def __str__(self):
|
|
1031
|
+
media_types = []
|
|
1032
|
+
stream_inf = [str(self.stream_info)]
|
|
1033
|
+
for media in self.media:
|
|
1034
|
+
if media.type in media_types:
|
|
1035
|
+
continue
|
|
1036
|
+
else:
|
|
1037
|
+
media_types += [media.type]
|
|
1038
|
+
media_type = media.type.upper()
|
|
1039
|
+
stream_inf.append(f'{media_type}="{media.group_id}"')
|
|
1040
|
+
|
|
1041
|
+
return "#EXT-X-STREAM-INF:" + ",".join(stream_inf) + "\n" + self.uri
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
class IFramePlaylist(BasePathMixin):
|
|
1045
|
+
"""
|
|
1046
|
+
IFramePlaylist object representing a link to a
|
|
1047
|
+
variant M3U8 i-frame playlist with a specific bitrate.
|
|
1048
|
+
|
|
1049
|
+
Attributes:
|
|
1050
|
+
|
|
1051
|
+
`iframe_stream_info` is a named tuple containing the attributes:
|
|
1052
|
+
`program_id`, `bandwidth`, `average_bandwidth`, `codecs`, `video_range`,
|
|
1053
|
+
`hdcp_level` and `resolution` which is a tuple (w, h) of integers
|
|
1054
|
+
|
|
1055
|
+
More info: http://tools.ietf.org/html/draft-pantos-http-live-streaming-07#section-3.3.13
|
|
1056
|
+
"""
|
|
1057
|
+
|
|
1058
|
+
def __init__(self, base_uri, uri, iframe_stream_info):
|
|
1059
|
+
self.uri = uri
|
|
1060
|
+
self.base_uri = base_uri
|
|
1061
|
+
|
|
1062
|
+
resolution = iframe_stream_info.get("resolution")
|
|
1063
|
+
if resolution is not None:
|
|
1064
|
+
values = resolution.split("x")
|
|
1065
|
+
resolution_pair = (int(values[0]), int(values[1]))
|
|
1066
|
+
else:
|
|
1067
|
+
resolution_pair = None
|
|
1068
|
+
|
|
1069
|
+
self.iframe_stream_info = StreamInfo(
|
|
1070
|
+
bandwidth=iframe_stream_info.get("bandwidth"),
|
|
1071
|
+
average_bandwidth=iframe_stream_info.get("average_bandwidth"),
|
|
1072
|
+
video=iframe_stream_info.get("video"),
|
|
1073
|
+
# Audio, subtitles, and closed captions should not exist in
|
|
1074
|
+
# EXT-X-I-FRAME-STREAM-INF, so just hardcode them to None.
|
|
1075
|
+
audio=None,
|
|
1076
|
+
subtitles=None,
|
|
1077
|
+
closed_captions=None,
|
|
1078
|
+
program_id=iframe_stream_info.get("program_id"),
|
|
1079
|
+
resolution=resolution_pair,
|
|
1080
|
+
codecs=iframe_stream_info.get("codecs"),
|
|
1081
|
+
video_range=iframe_stream_info.get("video_range"),
|
|
1082
|
+
hdcp_level=iframe_stream_info.get("hdcp_level"),
|
|
1083
|
+
frame_rate=None,
|
|
1084
|
+
pathway_id=iframe_stream_info.get("pathway_id"),
|
|
1085
|
+
stable_variant_id=iframe_stream_info.get("stable_variant_id"),
|
|
1086
|
+
req_video_layout=None,
|
|
1087
|
+
)
|
|
1088
|
+
|
|
1089
|
+
def __str__(self):
|
|
1090
|
+
iframe_stream_inf = []
|
|
1091
|
+
if self.iframe_stream_info.program_id:
|
|
1092
|
+
iframe_stream_inf.append(
|
|
1093
|
+
"PROGRAM-ID=%d" % self.iframe_stream_info.program_id
|
|
1094
|
+
)
|
|
1095
|
+
if self.iframe_stream_info.bandwidth:
|
|
1096
|
+
iframe_stream_inf.append("BANDWIDTH=%d" % self.iframe_stream_info.bandwidth)
|
|
1097
|
+
if self.iframe_stream_info.average_bandwidth:
|
|
1098
|
+
iframe_stream_inf.append(
|
|
1099
|
+
"AVERAGE-BANDWIDTH=%d" % self.iframe_stream_info.average_bandwidth
|
|
1100
|
+
)
|
|
1101
|
+
if self.iframe_stream_info.resolution:
|
|
1102
|
+
res = (
|
|
1103
|
+
str(self.iframe_stream_info.resolution[0])
|
|
1104
|
+
+ "x"
|
|
1105
|
+
+ str(self.iframe_stream_info.resolution[1])
|
|
1106
|
+
)
|
|
1107
|
+
iframe_stream_inf.append("RESOLUTION=" + res)
|
|
1108
|
+
if self.iframe_stream_info.codecs:
|
|
1109
|
+
iframe_stream_inf.append("CODECS=" + quoted(self.iframe_stream_info.codecs))
|
|
1110
|
+
if self.iframe_stream_info.video_range:
|
|
1111
|
+
iframe_stream_inf.append(
|
|
1112
|
+
"VIDEO-RANGE=%s" % self.iframe_stream_info.video_range
|
|
1113
|
+
)
|
|
1114
|
+
if self.iframe_stream_info.hdcp_level:
|
|
1115
|
+
iframe_stream_inf.append(
|
|
1116
|
+
"HDCP-LEVEL=%s" % self.iframe_stream_info.hdcp_level
|
|
1117
|
+
)
|
|
1118
|
+
if self.uri:
|
|
1119
|
+
iframe_stream_inf.append("URI=" + quoted(self.uri))
|
|
1120
|
+
if self.iframe_stream_info.pathway_id:
|
|
1121
|
+
iframe_stream_inf.append(
|
|
1122
|
+
"PATHWAY-ID=" + quoted(self.iframe_stream_info.pathway_id)
|
|
1123
|
+
)
|
|
1124
|
+
if self.iframe_stream_info.stable_variant_id:
|
|
1125
|
+
iframe_stream_inf.append(
|
|
1126
|
+
"STABLE-VARIANT-ID=" + quoted(self.iframe_stream_info.stable_variant_id)
|
|
1127
|
+
)
|
|
1128
|
+
|
|
1129
|
+
return "#EXT-X-I-FRAME-STREAM-INF:" + ",".join(iframe_stream_inf)
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
class StreamInfo:
|
|
1133
|
+
bandwidth = None
|
|
1134
|
+
closed_captions = None
|
|
1135
|
+
average_bandwidth = None
|
|
1136
|
+
program_id = None
|
|
1137
|
+
resolution = None
|
|
1138
|
+
codecs = None
|
|
1139
|
+
audio = None
|
|
1140
|
+
video = None
|
|
1141
|
+
subtitles = None
|
|
1142
|
+
frame_rate = None
|
|
1143
|
+
video_range = None
|
|
1144
|
+
hdcp_level = None
|
|
1145
|
+
pathway_id = None
|
|
1146
|
+
stable_variant_id = None
|
|
1147
|
+
req_video_layout = None
|
|
1148
|
+
|
|
1149
|
+
def __init__(self, **kwargs):
|
|
1150
|
+
self.bandwidth = kwargs.get("bandwidth")
|
|
1151
|
+
self.closed_captions = kwargs.get("closed_captions")
|
|
1152
|
+
self.average_bandwidth = kwargs.get("average_bandwidth")
|
|
1153
|
+
self.program_id = kwargs.get("program_id")
|
|
1154
|
+
self.resolution = kwargs.get("resolution")
|
|
1155
|
+
self.codecs = kwargs.get("codecs")
|
|
1156
|
+
self.audio = kwargs.get("audio")
|
|
1157
|
+
self.video = kwargs.get("video")
|
|
1158
|
+
self.subtitles = kwargs.get("subtitles")
|
|
1159
|
+
self.frame_rate = kwargs.get("frame_rate")
|
|
1160
|
+
self.video_range = kwargs.get("video_range")
|
|
1161
|
+
self.hdcp_level = kwargs.get("hdcp_level")
|
|
1162
|
+
self.pathway_id = kwargs.get("pathway_id")
|
|
1163
|
+
self.stable_variant_id = kwargs.get("stable_variant_id")
|
|
1164
|
+
self.req_video_layout = kwargs.get("req_video_layout")
|
|
1165
|
+
|
|
1166
|
+
def __str__(self):
|
|
1167
|
+
stream_inf = []
|
|
1168
|
+
if self.program_id is not None:
|
|
1169
|
+
stream_inf.append("PROGRAM-ID=%d" % self.program_id)
|
|
1170
|
+
if self.closed_captions is not None:
|
|
1171
|
+
stream_inf.append("CLOSED-CAPTIONS=%s" % self.closed_captions)
|
|
1172
|
+
if self.bandwidth is not None:
|
|
1173
|
+
stream_inf.append("BANDWIDTH=%d" % self.bandwidth)
|
|
1174
|
+
if self.average_bandwidth is not None:
|
|
1175
|
+
stream_inf.append("AVERAGE-BANDWIDTH=%d" % self.average_bandwidth)
|
|
1176
|
+
if self.resolution is not None:
|
|
1177
|
+
res = str(self.resolution[0]) + "x" + str(self.resolution[1])
|
|
1178
|
+
stream_inf.append("RESOLUTION=" + res)
|
|
1179
|
+
if self.frame_rate is not None:
|
|
1180
|
+
stream_inf.append(
|
|
1181
|
+
"FRAME-RATE=%g"
|
|
1182
|
+
% decimal.Decimal(self.frame_rate).quantize(decimal.Decimal("1.000"))
|
|
1183
|
+
)
|
|
1184
|
+
if self.codecs is not None:
|
|
1185
|
+
stream_inf.append("CODECS=" + quoted(self.codecs))
|
|
1186
|
+
if self.video_range is not None:
|
|
1187
|
+
stream_inf.append("VIDEO-RANGE=%s" % self.video_range)
|
|
1188
|
+
if self.hdcp_level is not None:
|
|
1189
|
+
stream_inf.append("HDCP-LEVEL=%s" % self.hdcp_level)
|
|
1190
|
+
if self.pathway_id is not None:
|
|
1191
|
+
stream_inf.append("PATHWAY-ID=" + quoted(self.pathway_id))
|
|
1192
|
+
if self.stable_variant_id is not None:
|
|
1193
|
+
stream_inf.append("STABLE-VARIANT-ID=" + quoted(self.stable_variant_id))
|
|
1194
|
+
if self.req_video_layout is not None:
|
|
1195
|
+
stream_inf.append("REQ-VIDEO_LAYOUT=" + quoted(self.req_video_layout))
|
|
1196
|
+
return ",".join(stream_inf)
|
|
1197
|
+
|
|
1198
|
+
|
|
1199
|
+
class Media(BasePathMixin):
|
|
1200
|
+
"""
|
|
1201
|
+
A media object from a M3U8 playlist
|
|
1202
|
+
https://tools.ietf.org/html/draft-pantos-http-live-streaming-16#section-4.3.4.1
|
|
1203
|
+
|
|
1204
|
+
`uri`
|
|
1205
|
+
a string with the media uri
|
|
1206
|
+
|
|
1207
|
+
`type`
|
|
1208
|
+
`group_id`
|
|
1209
|
+
`language`
|
|
1210
|
+
`assoc-language`
|
|
1211
|
+
`name`
|
|
1212
|
+
`default`
|
|
1213
|
+
`autoselect`
|
|
1214
|
+
`forced`
|
|
1215
|
+
`instream_id`
|
|
1216
|
+
`characteristics`
|
|
1217
|
+
`channels`
|
|
1218
|
+
`stable_rendition_id`
|
|
1219
|
+
attributes in the EXT-MEDIA tag
|
|
1220
|
+
|
|
1221
|
+
`base_uri`
|
|
1222
|
+
uri the media comes from in URI hierarchy. ex.: http://example.com/path/to
|
|
1223
|
+
"""
|
|
1224
|
+
|
|
1225
|
+
def __init__(
|
|
1226
|
+
self,
|
|
1227
|
+
uri=None,
|
|
1228
|
+
type=None,
|
|
1229
|
+
group_id=None,
|
|
1230
|
+
language=None,
|
|
1231
|
+
name=None,
|
|
1232
|
+
default=None,
|
|
1233
|
+
autoselect=None,
|
|
1234
|
+
forced=None,
|
|
1235
|
+
characteristics=None,
|
|
1236
|
+
channels=None,
|
|
1237
|
+
stable_rendition_id=None,
|
|
1238
|
+
assoc_language=None,
|
|
1239
|
+
instream_id=None,
|
|
1240
|
+
base_uri=None,
|
|
1241
|
+
**extras,
|
|
1242
|
+
):
|
|
1243
|
+
self.base_uri = base_uri
|
|
1244
|
+
self.uri = uri
|
|
1245
|
+
self.type = type
|
|
1246
|
+
self.group_id = group_id
|
|
1247
|
+
self.language = language
|
|
1248
|
+
self.name = name
|
|
1249
|
+
self.default = default
|
|
1250
|
+
self.autoselect = autoselect
|
|
1251
|
+
self.forced = forced
|
|
1252
|
+
self.assoc_language = assoc_language
|
|
1253
|
+
self.instream_id = instream_id
|
|
1254
|
+
self.characteristics = characteristics
|
|
1255
|
+
self.channels = channels
|
|
1256
|
+
self.stable_rendition_id = stable_rendition_id
|
|
1257
|
+
self.extras = extras
|
|
1258
|
+
|
|
1259
|
+
def dumps(self):
|
|
1260
|
+
media_out = []
|
|
1261
|
+
|
|
1262
|
+
if self.uri:
|
|
1263
|
+
media_out.append("URI=" + quoted(self.uri))
|
|
1264
|
+
if self.type:
|
|
1265
|
+
media_out.append("TYPE=" + self.type)
|
|
1266
|
+
if self.group_id:
|
|
1267
|
+
media_out.append("GROUP-ID=" + quoted(self.group_id))
|
|
1268
|
+
if self.language:
|
|
1269
|
+
media_out.append("LANGUAGE=" + quoted(self.language))
|
|
1270
|
+
if self.assoc_language:
|
|
1271
|
+
media_out.append("ASSOC-LANGUAGE=" + quoted(self.assoc_language))
|
|
1272
|
+
if self.name:
|
|
1273
|
+
media_out.append("NAME=" + quoted(self.name))
|
|
1274
|
+
if self.default:
|
|
1275
|
+
media_out.append("DEFAULT=" + self.default)
|
|
1276
|
+
if self.autoselect:
|
|
1277
|
+
media_out.append("AUTOSELECT=" + self.autoselect)
|
|
1278
|
+
if self.forced:
|
|
1279
|
+
media_out.append("FORCED=" + self.forced)
|
|
1280
|
+
if self.instream_id:
|
|
1281
|
+
media_out.append("INSTREAM-ID=" + quoted(self.instream_id))
|
|
1282
|
+
if self.characteristics:
|
|
1283
|
+
media_out.append("CHARACTERISTICS=" + quoted(self.characteristics))
|
|
1284
|
+
if self.channels:
|
|
1285
|
+
media_out.append("CHANNELS=" + quoted(self.channels))
|
|
1286
|
+
if self.stable_rendition_id:
|
|
1287
|
+
media_out.append("STABLE-RENDITION-ID=" + quoted(self.stable_rendition_id))
|
|
1288
|
+
|
|
1289
|
+
return "#EXT-X-MEDIA:" + ",".join(media_out)
|
|
1290
|
+
|
|
1291
|
+
def __str__(self):
|
|
1292
|
+
return self.dumps()
|
|
1293
|
+
|
|
1294
|
+
|
|
1295
|
+
class TagList(list):
|
|
1296
|
+
def __str__(self):
|
|
1297
|
+
output = [str(tag) for tag in self]
|
|
1298
|
+
return "\n".join(output)
|
|
1299
|
+
|
|
1300
|
+
|
|
1301
|
+
class MediaList(TagList, GroupedBasePathMixin):
|
|
1302
|
+
@property
|
|
1303
|
+
def uri(self):
|
|
1304
|
+
return [media.uri for media in self]
|
|
1305
|
+
|
|
1306
|
+
|
|
1307
|
+
class PlaylistList(TagList, GroupedBasePathMixin):
|
|
1308
|
+
pass
|
|
1309
|
+
|
|
1310
|
+
|
|
1311
|
+
class SessionDataList(TagList):
|
|
1312
|
+
pass
|
|
1313
|
+
|
|
1314
|
+
|
|
1315
|
+
class Start:
|
|
1316
|
+
def __init__(self, time_offset, precise=None):
|
|
1317
|
+
self.time_offset = float(time_offset)
|
|
1318
|
+
self.precise = precise
|
|
1319
|
+
|
|
1320
|
+
def __str__(self):
|
|
1321
|
+
output = ["TIME-OFFSET=" + str(self.time_offset)]
|
|
1322
|
+
if self.precise and self.precise in ["YES", "NO"]:
|
|
1323
|
+
output.append("PRECISE=" + str(self.precise))
|
|
1324
|
+
|
|
1325
|
+
return ext_x_start + ":" + ",".join(output)
|
|
1326
|
+
|
|
1327
|
+
|
|
1328
|
+
class RenditionReport(BasePathMixin):
|
|
1329
|
+
def __init__(self, base_uri, uri, last_msn=None, last_part=None):
|
|
1330
|
+
self.base_uri = base_uri
|
|
1331
|
+
self.uri = uri
|
|
1332
|
+
self.last_msn = last_msn
|
|
1333
|
+
self.last_part = last_part
|
|
1334
|
+
|
|
1335
|
+
def dumps(self):
|
|
1336
|
+
report = []
|
|
1337
|
+
report.append("URI=" + quoted(self.uri))
|
|
1338
|
+
if self.last_msn is not None:
|
|
1339
|
+
report.append("LAST-MSN=" + str(self.last_msn))
|
|
1340
|
+
if self.last_part is not None:
|
|
1341
|
+
report.append("LAST-PART=" + str(self.last_part))
|
|
1342
|
+
|
|
1343
|
+
return "#EXT-X-RENDITION-REPORT:" + ",".join(report)
|
|
1344
|
+
|
|
1345
|
+
def __str__(self):
|
|
1346
|
+
return self.dumps()
|
|
1347
|
+
|
|
1348
|
+
|
|
1349
|
+
class RenditionReportList(list, GroupedBasePathMixin):
|
|
1350
|
+
def __str__(self):
|
|
1351
|
+
output = [str(report) for report in self]
|
|
1352
|
+
return "\n".join(output)
|
|
1353
|
+
|
|
1354
|
+
|
|
1355
|
+
class ServerControl:
|
|
1356
|
+
def __init__(
|
|
1357
|
+
self,
|
|
1358
|
+
can_skip_until=None,
|
|
1359
|
+
can_block_reload=None,
|
|
1360
|
+
hold_back=None,
|
|
1361
|
+
part_hold_back=None,
|
|
1362
|
+
can_skip_dateranges=None,
|
|
1363
|
+
):
|
|
1364
|
+
self.can_skip_until = can_skip_until
|
|
1365
|
+
self.can_block_reload = can_block_reload
|
|
1366
|
+
self.hold_back = hold_back
|
|
1367
|
+
self.part_hold_back = part_hold_back
|
|
1368
|
+
self.can_skip_dateranges = can_skip_dateranges
|
|
1369
|
+
|
|
1370
|
+
def __getitem__(self, item):
|
|
1371
|
+
return getattr(self, item)
|
|
1372
|
+
|
|
1373
|
+
def dumps(self):
|
|
1374
|
+
ctrl = []
|
|
1375
|
+
if self.can_block_reload:
|
|
1376
|
+
ctrl.append("CAN-BLOCK-RELOAD=%s" % self.can_block_reload)
|
|
1377
|
+
|
|
1378
|
+
for attr in ["hold_back", "part_hold_back"]:
|
|
1379
|
+
if self[attr]:
|
|
1380
|
+
ctrl.append(
|
|
1381
|
+
"%s=%s"
|
|
1382
|
+
% (denormalize_attribute(attr), number_to_string(self[attr]))
|
|
1383
|
+
)
|
|
1384
|
+
|
|
1385
|
+
if self.can_skip_until:
|
|
1386
|
+
ctrl.append("CAN-SKIP-UNTIL=%s" % number_to_string(self.can_skip_until))
|
|
1387
|
+
if self.can_skip_dateranges:
|
|
1388
|
+
ctrl.append("CAN-SKIP-DATERANGES=%s" % self.can_skip_dateranges)
|
|
1389
|
+
|
|
1390
|
+
return "#EXT-X-SERVER-CONTROL:" + ",".join(ctrl)
|
|
1391
|
+
|
|
1392
|
+
def __str__(self):
|
|
1393
|
+
return self.dumps()
|
|
1394
|
+
|
|
1395
|
+
|
|
1396
|
+
class Skip:
|
|
1397
|
+
def __init__(self, skipped_segments, recently_removed_dateranges=None):
|
|
1398
|
+
self.skipped_segments = skipped_segments
|
|
1399
|
+
self.recently_removed_dateranges = recently_removed_dateranges
|
|
1400
|
+
|
|
1401
|
+
def dumps(self):
|
|
1402
|
+
skip = []
|
|
1403
|
+
skip.append("SKIPPED-SEGMENTS=%s" % self.skipped_segments)
|
|
1404
|
+
if self.recently_removed_dateranges is not None:
|
|
1405
|
+
skip.append(
|
|
1406
|
+
"RECENTLY-REMOVED-DATERANGES=%s"
|
|
1407
|
+
% quoted(self.recently_removed_dateranges)
|
|
1408
|
+
)
|
|
1409
|
+
|
|
1410
|
+
return "#EXT-X-SKIP:" + ",".join(skip)
|
|
1411
|
+
|
|
1412
|
+
def __str__(self):
|
|
1413
|
+
return self.dumps()
|
|
1414
|
+
|
|
1415
|
+
|
|
1416
|
+
class PartInformation:
|
|
1417
|
+
def __init__(self, part_target=None):
|
|
1418
|
+
self.part_target = part_target
|
|
1419
|
+
|
|
1420
|
+
def dumps(self):
|
|
1421
|
+
return "#EXT-X-PART-INF:PART-TARGET=%s" % number_to_string(self.part_target)
|
|
1422
|
+
|
|
1423
|
+
def __str__(self):
|
|
1424
|
+
return self.dumps()
|
|
1425
|
+
|
|
1426
|
+
|
|
1427
|
+
class PreloadHint(BasePathMixin):
|
|
1428
|
+
def __init__(
|
|
1429
|
+
self, type, base_uri, uri, byterange_start=None, byterange_length=None
|
|
1430
|
+
):
|
|
1431
|
+
self.hint_type = type
|
|
1432
|
+
self.base_uri = base_uri
|
|
1433
|
+
self.uri = uri
|
|
1434
|
+
self.byterange_start = byterange_start
|
|
1435
|
+
self.byterange_length = byterange_length
|
|
1436
|
+
|
|
1437
|
+
def __getitem__(self, item):
|
|
1438
|
+
return getattr(self, item)
|
|
1439
|
+
|
|
1440
|
+
def dumps(self):
|
|
1441
|
+
hint = []
|
|
1442
|
+
hint.append("TYPE=" + self.hint_type)
|
|
1443
|
+
hint.append("URI=" + quoted(self.uri))
|
|
1444
|
+
|
|
1445
|
+
for attr in ["byterange_start", "byterange_length"]:
|
|
1446
|
+
if self[attr] is not None:
|
|
1447
|
+
hint.append(f"{denormalize_attribute(attr)}={self[attr]}")
|
|
1448
|
+
|
|
1449
|
+
return "#EXT-X-PRELOAD-HINT:" + ",".join(hint)
|
|
1450
|
+
|
|
1451
|
+
def __str__(self):
|
|
1452
|
+
return self.dumps()
|
|
1453
|
+
|
|
1454
|
+
|
|
1455
|
+
class SessionData:
|
|
1456
|
+
def __init__(self, data_id, value=None, uri=None, language=None):
|
|
1457
|
+
self.data_id = data_id
|
|
1458
|
+
self.value = value
|
|
1459
|
+
self.uri = uri
|
|
1460
|
+
self.language = language
|
|
1461
|
+
|
|
1462
|
+
def dumps(self):
|
|
1463
|
+
session_data_out = ["DATA-ID=" + quoted(self.data_id)]
|
|
1464
|
+
|
|
1465
|
+
if self.value:
|
|
1466
|
+
session_data_out.append("VALUE=" + quoted(self.value))
|
|
1467
|
+
elif self.uri:
|
|
1468
|
+
session_data_out.append("URI=" + quoted(self.uri))
|
|
1469
|
+
if self.language:
|
|
1470
|
+
session_data_out.append("LANGUAGE=" + quoted(self.language))
|
|
1471
|
+
|
|
1472
|
+
return "#EXT-X-SESSION-DATA:" + ",".join(session_data_out)
|
|
1473
|
+
|
|
1474
|
+
def __str__(self):
|
|
1475
|
+
return self.dumps()
|
|
1476
|
+
|
|
1477
|
+
|
|
1478
|
+
class DateRangeList(TagList):
|
|
1479
|
+
pass
|
|
1480
|
+
|
|
1481
|
+
|
|
1482
|
+
class DateRange:
|
|
1483
|
+
def __init__(self, **kwargs):
|
|
1484
|
+
self.id = kwargs["id"]
|
|
1485
|
+
self.start_date = kwargs.get("start_date")
|
|
1486
|
+
self.class_ = kwargs.get("class")
|
|
1487
|
+
self.end_date = kwargs.get("end_date")
|
|
1488
|
+
self.duration = kwargs.get("duration")
|
|
1489
|
+
self.planned_duration = kwargs.get("planned_duration")
|
|
1490
|
+
self.scte35_cmd = kwargs.get("scte35_cmd")
|
|
1491
|
+
self.scte35_out = kwargs.get("scte35_out")
|
|
1492
|
+
self.scte35_in = kwargs.get("scte35_in")
|
|
1493
|
+
self.end_on_next = kwargs.get("end_on_next")
|
|
1494
|
+
self.x_client_attrs = [
|
|
1495
|
+
(attr, kwargs.get(attr)) for attr in kwargs if attr.startswith("x_")
|
|
1496
|
+
]
|
|
1497
|
+
|
|
1498
|
+
def dumps(self):
|
|
1499
|
+
daterange = []
|
|
1500
|
+
daterange.append("ID=" + quoted(self.id))
|
|
1501
|
+
|
|
1502
|
+
# whilst START-DATE is technically REQUIRED by the spec, this is
|
|
1503
|
+
# contradicted by an example in the same document (see
|
|
1504
|
+
# https://tools.ietf.org/html/rfc8216#section-8.10), and also by
|
|
1505
|
+
# real-world implementations, so we make it optional here
|
|
1506
|
+
if self.start_date:
|
|
1507
|
+
daterange.append("START-DATE=" + quoted(self.start_date))
|
|
1508
|
+
if self.class_:
|
|
1509
|
+
daterange.append("CLASS=" + quoted(self.class_))
|
|
1510
|
+
if self.end_date:
|
|
1511
|
+
daterange.append("END-DATE=" + quoted(self.end_date))
|
|
1512
|
+
if self.duration:
|
|
1513
|
+
daterange.append("DURATION=" + number_to_string(self.duration))
|
|
1514
|
+
if self.planned_duration:
|
|
1515
|
+
daterange.append(
|
|
1516
|
+
"PLANNED-DURATION=" + number_to_string(self.planned_duration)
|
|
1517
|
+
)
|
|
1518
|
+
if self.scte35_cmd:
|
|
1519
|
+
daterange.append("SCTE35-CMD=" + self.scte35_cmd)
|
|
1520
|
+
if self.scte35_out:
|
|
1521
|
+
daterange.append("SCTE35-OUT=" + self.scte35_out)
|
|
1522
|
+
if self.scte35_in:
|
|
1523
|
+
daterange.append("SCTE35-IN=" + self.scte35_in)
|
|
1524
|
+
if self.end_on_next:
|
|
1525
|
+
daterange.append("END-ON-NEXT=" + self.end_on_next)
|
|
1526
|
+
|
|
1527
|
+
# client attributes sorted alphabetically output order is predictable
|
|
1528
|
+
for attr, value in sorted(self.x_client_attrs):
|
|
1529
|
+
daterange.append(f"{denormalize_attribute(attr)}={value}")
|
|
1530
|
+
|
|
1531
|
+
return "#EXT-X-DATERANGE:" + ",".join(daterange)
|
|
1532
|
+
|
|
1533
|
+
def __str__(self):
|
|
1534
|
+
return self.dumps()
|
|
1535
|
+
|
|
1536
|
+
|
|
1537
|
+
class ContentSteering(BasePathMixin):
|
|
1538
|
+
def __init__(self, base_uri, server_uri, pathway_id=None):
|
|
1539
|
+
self.base_uri = base_uri
|
|
1540
|
+
self.uri = server_uri
|
|
1541
|
+
self.pathway_id = pathway_id
|
|
1542
|
+
|
|
1543
|
+
def dumps(self):
|
|
1544
|
+
steering = []
|
|
1545
|
+
steering.append("SERVER-URI=" + quoted(self.uri))
|
|
1546
|
+
|
|
1547
|
+
if self.pathway_id is not None:
|
|
1548
|
+
steering.append("PATHWAY-ID=" + quoted(self.pathway_id))
|
|
1549
|
+
|
|
1550
|
+
return "#EXT-X-CONTENT-STEERING:" + ",".join(steering)
|
|
1551
|
+
|
|
1552
|
+
def __str__(self):
|
|
1553
|
+
return self.dumps()
|
|
1554
|
+
|
|
1555
|
+
|
|
1556
|
+
class ImagePlaylist(BasePathMixin):
|
|
1557
|
+
"""
|
|
1558
|
+
ImagePlaylist object representing a link to a
|
|
1559
|
+
variant M3U8 image playlist with a specific bitrate.
|
|
1560
|
+
|
|
1561
|
+
Attributes:
|
|
1562
|
+
|
|
1563
|
+
`image_stream_info` is a named tuple containing the attributes:
|
|
1564
|
+
`bandwidth`, `resolution` which is a tuple (w, h) of integers and `codecs`,
|
|
1565
|
+
|
|
1566
|
+
More info: https://github.com/image-media-playlist/spec/blob/master/image_media_playlist_v0_4.pdf
|
|
1567
|
+
"""
|
|
1568
|
+
|
|
1569
|
+
def __init__(self, base_uri, uri, image_stream_info):
|
|
1570
|
+
self.uri = uri
|
|
1571
|
+
self.base_uri = base_uri
|
|
1572
|
+
|
|
1573
|
+
resolution = image_stream_info.get("resolution")
|
|
1574
|
+
if resolution is not None:
|
|
1575
|
+
values = resolution.split("x")
|
|
1576
|
+
resolution_pair = (int(values[0]), int(values[1]))
|
|
1577
|
+
else:
|
|
1578
|
+
resolution_pair = None
|
|
1579
|
+
|
|
1580
|
+
self.image_stream_info = StreamInfo(
|
|
1581
|
+
bandwidth=image_stream_info.get("bandwidth"),
|
|
1582
|
+
average_bandwidth=image_stream_info.get("average_bandwidth"),
|
|
1583
|
+
video=image_stream_info.get("video"),
|
|
1584
|
+
# Audio, subtitles, closed captions, video range and hdcp level should not exist in
|
|
1585
|
+
# EXT-X-IMAGE-STREAM-INF, so just hardcode them to None.
|
|
1586
|
+
audio=None,
|
|
1587
|
+
subtitles=None,
|
|
1588
|
+
closed_captions=None,
|
|
1589
|
+
program_id=image_stream_info.get("program_id"),
|
|
1590
|
+
resolution=resolution_pair,
|
|
1591
|
+
codecs=image_stream_info.get("codecs"),
|
|
1592
|
+
video_range=None,
|
|
1593
|
+
hdcp_level=None,
|
|
1594
|
+
frame_rate=None,
|
|
1595
|
+
pathway_id=image_stream_info.get("pathway_id"),
|
|
1596
|
+
stable_variant_id=image_stream_info.get("stable_variant_id"),
|
|
1597
|
+
)
|
|
1598
|
+
|
|
1599
|
+
def __str__(self):
|
|
1600
|
+
image_stream_inf = []
|
|
1601
|
+
if self.image_stream_info.program_id:
|
|
1602
|
+
image_stream_inf.append("PROGRAM-ID=%d" % self.image_stream_info.program_id)
|
|
1603
|
+
if self.image_stream_info.bandwidth:
|
|
1604
|
+
image_stream_inf.append("BANDWIDTH=%d" % self.image_stream_info.bandwidth)
|
|
1605
|
+
if self.image_stream_info.average_bandwidth:
|
|
1606
|
+
image_stream_inf.append(
|
|
1607
|
+
"AVERAGE-BANDWIDTH=%d" % self.image_stream_info.average_bandwidth
|
|
1608
|
+
)
|
|
1609
|
+
if self.image_stream_info.resolution:
|
|
1610
|
+
res = (
|
|
1611
|
+
str(self.image_stream_info.resolution[0])
|
|
1612
|
+
+ "x"
|
|
1613
|
+
+ str(self.image_stream_info.resolution[1])
|
|
1614
|
+
)
|
|
1615
|
+
image_stream_inf.append("RESOLUTION=" + res)
|
|
1616
|
+
if self.image_stream_info.codecs:
|
|
1617
|
+
image_stream_inf.append("CODECS=" + quoted(self.image_stream_info.codecs))
|
|
1618
|
+
if self.uri:
|
|
1619
|
+
image_stream_inf.append("URI=" + quoted(self.uri))
|
|
1620
|
+
if self.image_stream_info.pathway_id:
|
|
1621
|
+
image_stream_inf.append(
|
|
1622
|
+
"PATHWAY-ID=" + quoted(self.image_stream_info.pathway_id)
|
|
1623
|
+
)
|
|
1624
|
+
if self.image_stream_info.stable_variant_id:
|
|
1625
|
+
image_stream_inf.append(
|
|
1626
|
+
"STABLE-VARIANT-ID=" + quoted(self.image_stream_info.stable_variant_id)
|
|
1627
|
+
)
|
|
1628
|
+
|
|
1629
|
+
return "#EXT-X-IMAGE-STREAM-INF:" + ",".join(image_stream_inf)
|
|
1630
|
+
|
|
1631
|
+
|
|
1632
|
+
class Tiles(BasePathMixin):
|
|
1633
|
+
"""
|
|
1634
|
+
Image tiles from a M3U8 playlist
|
|
1635
|
+
|
|
1636
|
+
`resolution`
|
|
1637
|
+
resolution attribute from EXT-X-TILES tag
|
|
1638
|
+
|
|
1639
|
+
`layout`
|
|
1640
|
+
layout attribute from EXT-X-TILES tag
|
|
1641
|
+
|
|
1642
|
+
`duration`
|
|
1643
|
+
duration attribute from EXT-X-TILES tag
|
|
1644
|
+
"""
|
|
1645
|
+
|
|
1646
|
+
def __init__(self, resolution, layout, duration):
|
|
1647
|
+
self.resolution = resolution
|
|
1648
|
+
self.layout = layout
|
|
1649
|
+
self.duration = duration
|
|
1650
|
+
|
|
1651
|
+
def dumps(self):
|
|
1652
|
+
tiles = []
|
|
1653
|
+
tiles.append("RESOLUTION=" + self.resolution)
|
|
1654
|
+
tiles.append("LAYOUT=" + self.layout)
|
|
1655
|
+
tiles.append("DURATION=" + self.duration)
|
|
1656
|
+
|
|
1657
|
+
return "#EXT-X-TILES:" + ",".join(tiles)
|
|
1658
|
+
|
|
1659
|
+
def __str__(self):
|
|
1660
|
+
return self.dumps()
|
|
1661
|
+
|
|
1662
|
+
|
|
1663
|
+
def find_key(keydata, keylist):
|
|
1664
|
+
if not keydata:
|
|
1665
|
+
return None
|
|
1666
|
+
for key in keylist:
|
|
1667
|
+
if key:
|
|
1668
|
+
# Check the intersection of keys and values
|
|
1669
|
+
if (
|
|
1670
|
+
keydata.get("uri", None) == key.uri
|
|
1671
|
+
and keydata.get("method", "NONE") == key.method
|
|
1672
|
+
and keydata.get("iv", None) == key.iv
|
|
1673
|
+
):
|
|
1674
|
+
return key
|
|
1675
|
+
raise KeyError("No key found for key data")
|
|
1676
|
+
|
|
1677
|
+
|
|
1678
|
+
def denormalize_attribute(attribute):
|
|
1679
|
+
return attribute.replace("_", "-").upper()
|
|
1680
|
+
|
|
1681
|
+
|
|
1682
|
+
def quoted(string):
|
|
1683
|
+
return '"%s"' % string
|
|
1684
|
+
|
|
1685
|
+
|
|
1686
|
+
def number_to_string(number):
|
|
1687
|
+
with decimal.localcontext() as ctx:
|
|
1688
|
+
ctx.prec = 20 # set floating point precision
|
|
1689
|
+
d = decimal.Decimal(str(number))
|
|
1690
|
+
return str(
|
|
1691
|
+
d.quantize(decimal.Decimal(1))
|
|
1692
|
+
if d == d.to_integral_value()
|
|
1693
|
+
else d.normalize()
|
|
1694
|
+
)
|