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/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
+ )