pax_global_header00006660000000000000000000000064146546541060014525gustar00rootroot0000000000000052 comment=49417d05602fc472b518ec61aa9b327cd00e079d m3u8-6.0.0/000077500000000000000000000000001465465410600123245ustar00rootroot00000000000000m3u8-6.0.0/.editorconfig000066400000000000000000000003401465465410600147760ustar00rootroot00000000000000# https://editorconfig.org root = true [*.py] charset = utf-8 indent_style = space indent_size = 4 end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true [Makefile] indent_style = tab indent_size = 4 m3u8-6.0.0/.github/000077500000000000000000000000001465465410600136645ustar00rootroot00000000000000m3u8-6.0.0/.github/workflows/000077500000000000000000000000001465465410600157215ustar00rootroot00000000000000m3u8-6.0.0/.github/workflows/main.yml000066400000000000000000000023761465465410600174000ustar00rootroot00000000000000# This is a basic workflow to help you get started with Actions name: CI # Controls when the action will run. on: # Triggers the workflow on push or pull request events but only for the master branch push: branches: [ master ] pull_request: branches: [ master ] # Allows you to run this workflow manually from the Actions tab workflow_dispatch: # A workflow run is made up of one or more jobs that can run sequentially or in parallel jobs: # This workflow contains a single job called "build" build: # The type of runner that the job will run on runs-on: ubuntu-latest strategy: # You can use PyPy versions in python-version. # For example, pypy2 and pypy3 matrix: python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] # Steps represent a sequence of tasks that will be executed as part of the job steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} # Runs a single command using the runners shell - name: Run all tests run: ./runtests m3u8-6.0.0/.github/workflows/ruff.yml000066400000000000000000000005511465465410600174070ustar00rootroot00000000000000name: Ruff run-name: Ruff on: [ push, pull_request ] jobs: ruff: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: chartboost/ruff-action@v1 ruff_format: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: chartboost/ruff-action@v1 with: args: format --check --diff m3u8-6.0.0/.gitignore000066400000000000000000000002251465465410600143130ustar00rootroot00000000000000*.pyc *.egg-info tests/server.stdout dist/ build/ bin/ include/ lib/ lib64/ local/ .coverage .cache .python-version .idea/ .vscode/ venv/ pyvenv.cfg m3u8-6.0.0/LICENSE000066400000000000000000000021571465465410600133360ustar00rootroot00000000000000m3u8 is licensed under the MIT License: The MIT License Copyright (c) 2012 globo.com webmedia@corp.globo.com Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. m3u8-6.0.0/MANIFEST.in000066400000000000000000000000731465465410600140620ustar00rootroot00000000000000include requirements.txt include LICENSE include README.md m3u8-6.0.0/README.md000066400000000000000000000107701465465410600136100ustar00rootroot00000000000000![image](https://github.com/globocom/m3u8/actions/workflows/main.yml/badge.svg) [![image](https://badge.fury.io/py/m3u8.svg)](https://badge.fury.io/py/m3u8) # m3u8 Python [m3u8](https://tools.ietf.org/html/rfc8216) parser. # Documentation ## Loading a playlist To load a playlist into an object from uri, file path or directly from string, use the `load/loads` functions: ```python import m3u8 playlist = m3u8.load('http://videoserver.com/playlist.m3u8') # this could also be an absolute filename print(playlist.segments) print(playlist.target_duration) # if you already have the content as string, use playlist = m3u8.loads('#EXTM3U8 ... etc ... ') ``` ## Dumping a playlist To dump a playlist from an object to the console or a file, use the `dump/dumps` functions: ``` python import m3u8 playlist = m3u8.load('http://videoserver.com/playlist.m3u8') print(playlist.dumps()) # if you want to write a file from its content playlist.dump('playlist.m3u8') ``` # Supported tags - [\#EXT-X-TARGETDURATION](https://tools.ietf.org/html/rfc8216#section-4.3.3.1) - [\#EXT-X-MEDIA-SEQUENCE](https://tools.ietf.org/html/rfc8216#section-4.3.3.2) - [\#EXT-X-DISCONTINUITY-SEQUENCE](https://tools.ietf.org/html/rfc8216#section-4.3.3.3) - [\#EXT-X-PROGRAM-DATE-TIME](https://tools.ietf.org/html/rfc8216#section-4.3.2.6) - [\#EXT-X-MEDIA](https://tools.ietf.org/html/rfc8216#section-4.3.4.1) - [\#EXT-X-PLAYLIST-TYPE](https://tools.ietf.org/html/rfc8216#section-4.3.3.5) - [\#EXT-X-KEY](https://tools.ietf.org/html/rfc8216#section-4.3.2.4) - [\#EXT-X-STREAM-INF](https://tools.ietf.org/html/rfc8216#section-4.3.4.2) - [\#EXT-X-VERSION](https://tools.ietf.org/html/rfc8216#section-4.3.1.2) - [\#EXT-X-ALLOW-CACHE](https://datatracker.ietf.org/doc/html/draft-pantos-http-live-streaming-07#section-3.3.6) - [\#EXT-X-ENDLIST](https://tools.ietf.org/html/rfc8216#section-4.3.3.4) - [\#EXTINF](https://tools.ietf.org/html/rfc8216#section-4.3.2.1) - [\#EXT-X-I-FRAMES-ONLY](https://tools.ietf.org/html/rfc8216#section-4.3.3.6) - [\#EXT-X-BITRATE](https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis#section-4.4.4.8) - [\#EXT-X-BYTERANGE](https://tools.ietf.org/html/rfc8216#section-4.3.2.2) - [\#EXT-X-I-FRAME-STREAM-INF](https://tools.ietf.org/html/rfc8216#section-4.3.4.3) - [\#EXT-X-IMAGES-ONLY](https://github.com/image-media-playlist/spec/blob/master/image_media_playlist_v0_4.pdf) - [\#EXT-X-IMAGE-STREAM-INF](https://github.com/image-media-playlist/spec/blob/master/image_media_playlist_v0_4.pdf) - [\#EXT-X-TILES](https://github.com/image-media-playlist/spec/blob/master/image_media_playlist_v0_4.pdf) - [\#EXT-X-DISCONTINUITY](https://tools.ietf.org/html/rfc8216#section-4.3.2.3) - \#EXT-X-CUE-OUT - \#EXT-X-CUE-OUT-CONT - \#EXT-X-CUE-IN - \#EXT-X-CUE-SPAN - \#EXT-OATCLS-SCTE35 - [\#EXT-X-INDEPENDENT-SEGMENTS](https://tools.ietf.org/html/rfc8216#section-4.3.5.1) - [\#EXT-X-MAP](https://tools.ietf.org/html/rfc8216#section-4.3.2.5) - [\#EXT-X-START](https://tools.ietf.org/html/rfc8216#section-4.3.5.2) - [\#EXT-X-SERVER-CONTROL](https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis#section-4.4.3.8) - [\#EXT-X-PART-INF](https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis#section-4.4.3.7) - [\#EXT-X-PART](https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis#section-4.4.4.9) - [\#EXT-X-RENDITION-REPORT](https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis#section-4.4.5.4) - [\#EXT-X-SKIP](https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis#section-4.4.5.2) - [\#EXT-X-SESSION-DATA](https://tools.ietf.org/html/rfc8216#section-4.3.4.4) - [\#EXT-X-PRELOAD-HINT](https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis-09#section-4.4.5.3) - [\#EXT-X-SESSION-KEY](https://tools.ietf.org/html/rfc8216#section-4.3.4.5) - [\#EXT-X-DATERANGE](https://tools.ietf.org/html/rfc8216#section-4.3.2.7) - [\#EXT-X-GAP](https://tools.ietf.org/html/draft-pantos-hls-rfc8216bis-05#section-4.4.2.7) - [\#EXT-X-CONTENT-STEERING](https://tools.ietf.org/html/draft-pantos-hls-rfc8216bis-10#section-4.4.6.64) # Frequently Asked Questions - [FAQ](https://github.com/globocom/m3u8/wiki/FAQ) # Running Tests ``` bash $ ./runtests ``` # Contributing All contributions are welcome, but we will merge a pull request if, and only if, it - Has tests - Follows the code conventions If you plan to implement a new feature or something that will take more than a few minutes, please open an issue to make sure we don't work on the same thing. m3u8-6.0.0/m3u8/000077500000000000000000000000001465465410600131205ustar00rootroot00000000000000m3u8-6.0.0/m3u8/__init__.py000066400000000000000000000047351465465410600152420ustar00rootroot00000000000000# Copyright 2014 Globo.com Player authors. All rights reserved. # Use of this source code is governed by a MIT License # license that can be found in the LICENSE file. import os from urllib.parse import urljoin, urlsplit from m3u8.httpclient import DefaultHTTPClient from m3u8.model import ( M3U8, ContentSteering, DateRange, DateRangeList, IFramePlaylist, ImagePlaylist, Key, Media, MediaList, PartialSegment, PartialSegmentList, PartInformation, Playlist, PlaylistList, PreloadHint, RenditionReport, RenditionReportList, Segment, SegmentList, ServerControl, Skip, Start, Tiles, ) from m3u8.parser import ParseError, parse __all__ = ( "M3U8", "Segment", "SegmentList", "PartialSegment", "PartialSegmentList", "Key", "Playlist", "IFramePlaylist", "Media", "MediaList", "PlaylistList", "Start", "RenditionReport", "RenditionReportList", "ServerControl", "Skip", "PartInformation", "PreloadHint", "DateRange", "DateRangeList", "ContentSteering", "ImagePlaylist", "Tiles", "loads", "load", "parse", "ParseError", ) def loads(content, uri=None, custom_tags_parser=None): """ Given a string with a m3u8 content, returns a M3U8 object. Optionally parses a uri to set a correct base_uri on the M3U8 object. Raises ValueError if invalid content """ if uri is None: return M3U8(content, custom_tags_parser=custom_tags_parser) else: base_uri = urljoin(uri, ".") return M3U8(content, base_uri=base_uri, custom_tags_parser=custom_tags_parser) def load( uri, timeout=None, headers={}, custom_tags_parser=None, http_client=DefaultHTTPClient(), verify_ssl=True, ): """ Retrieves the content from a given URI and returns a M3U8 object. Raises ValueError if invalid content or IOError if request fails. """ if urlsplit(uri).scheme: content, base_uri = http_client.download(uri, timeout, headers, verify_ssl) return M3U8(content, base_uri=base_uri, custom_tags_parser=custom_tags_parser) else: return _load_from_file(uri, custom_tags_parser) def _load_from_file(uri, custom_tags_parser=None): with open(uri, encoding="utf8") as fileobj: raw_content = fileobj.read().strip() base_uri = os.path.dirname(uri) return M3U8(raw_content, base_uri=base_uri, custom_tags_parser=custom_tags_parser) m3u8-6.0.0/m3u8/httpclient.py000066400000000000000000000023661465465410600156570ustar00rootroot00000000000000import gzip import ssl import urllib.request from urllib.parse import urljoin class DefaultHTTPClient: def __init__(self, proxies=None): self.proxies = proxies def download(self, uri, timeout=None, headers={}, verify_ssl=True): proxy_handler = urllib.request.ProxyHandler(self.proxies) https_handler = HTTPSHandler(verify_ssl=verify_ssl) opener = urllib.request.build_opener(proxy_handler, https_handler) opener.addheaders = headers.items() resource = opener.open(uri, timeout=timeout) base_uri = urljoin(resource.geturl(), ".") if resource.info().get("Content-Encoding") == "gzip": content = gzip.decompress(resource.read()).decode( resource.headers.get_content_charset(failobj="utf-8") ) else: content = resource.read().decode( resource.headers.get_content_charset(failobj="utf-8") ) return content, base_uri class HTTPSHandler: def __new__(self, verify_ssl=True): context = ssl.create_default_context() if not verify_ssl: context.check_hostname = False context.verify_mode = ssl.CERT_NONE return urllib.request.HTTPSHandler(context=context) m3u8-6.0.0/m3u8/mixins.py000066400000000000000000000025311465465410600150020ustar00rootroot00000000000000from os.path import dirname from urllib.parse import urljoin, urlsplit class BasePathMixin: @property def absolute_uri(self): if self.uri is None: return None ret = urljoin(self.base_uri, self.uri) if self.base_uri and (not urlsplit(self.base_uri).scheme): return ret if not urlsplit(ret).scheme: raise ValueError("There can not be `absolute_uri` with no `base_uri` set") return ret @property def base_path(self): if self.uri is None: return None return dirname(self.get_path_from_uri()) def get_path_from_uri(self): """Some URIs have a slash in the query string.""" return self.uri.split("?")[0] @base_path.setter def base_path(self, newbase_path): if self.uri is not None: if not self.base_path: self.uri = f"{newbase_path}/{self.uri}" else: self.uri = self.uri.replace(self.base_path, newbase_path) class GroupedBasePathMixin: def _set_base_uri(self, new_base_uri): for item in self: item.base_uri = new_base_uri base_uri = property(None, _set_base_uri) def _set_base_path(self, newbase_path): for item in self: item.base_path = newbase_path base_path = property(None, _set_base_path) m3u8-6.0.0/m3u8/model.py000066400000000000000000001530671465465410600146060ustar00rootroot00000000000000# Copyright 2014 Globo.com Player authors. All rights reserved. # Use of this source code is governed by a MIT License # license that can be found in the LICENSE file. import decimal import os from m3u8.mixins import BasePathMixin, GroupedBasePathMixin from m3u8.parser import format_date_time, parse from m3u8.protocol import ( ext_oatcls_scte35, ext_x_asset, ext_x_key, ext_x_map, ext_x_session_key, ext_x_start, ) class MalformedPlaylistError(Exception): pass class M3U8: """ Represents a single M3U8 playlist. Should be instantiated with the content as string. Parameters: `content` the m3u8 content as string `base_path` all urls (key and segments url) will be updated with this base_path, ex.: base_path = "http://videoserver.com/hls" /foo/bar/key.bin --> http://videoserver.com/hls/key.bin http://vid.com/segment1.ts --> http://videoserver.com/hls/segment1.ts can be passed as parameter or setted as an attribute to ``M3U8`` object. `base_uri` uri the playlist comes from. it is propagated to SegmentList and Key ex.: http://example.com/path/to Attributes: `keys` Returns the list of `Key` objects used to encrypt the segments from m3u8. It covers the whole list of possible situations when encryption either is used or not. 1. No encryption. `keys` list will only contain a `None` element. 2. Encryption enabled for all segments. `keys` list will contain the key used for the segments. 3. No encryption for first element(s), encryption is applied afterwards `keys` list will contain `None` and the key used for the rest of segments. 4. Multiple keys used during the m3u8 manifest. `keys` list will contain the key used for each set of segments. `session_keys` Returns the list of `SessionKey` objects used to encrypt multiple segments from m3u8. `segments` a `SegmentList` object, represents the list of `Segment`s from this playlist `is_variant` Returns true if this M3U8 is a variant playlist, with links to other M3U8s with different bitrates. If true, `playlists` is a list of the playlists available, and `iframe_playlists` is a list of the i-frame playlists available. `is_endlist` Returns true if EXT-X-ENDLIST tag present in M3U8. http://tools.ietf.org/html/draft-pantos-http-live-streaming-07#section-3.3.8 `playlists` If this is a variant playlist (`is_variant` is True), returns a list of Playlist objects `iframe_playlists` If this is a variant playlist (`is_variant` is True), returns a list of IFramePlaylist objects `playlist_type` A lower-case string representing the type of the playlist, which can be one of VOD (video on demand) or EVENT. `media` If this is a variant playlist (`is_variant` is True), returns a list of Media objects `target_duration` Returns the EXT-X-TARGETDURATION as an integer http://tools.ietf.org/html/draft-pantos-http-live-streaming-07#section-3.3.2 `media_sequence` Returns the EXT-X-MEDIA-SEQUENCE as an integer http://tools.ietf.org/html/draft-pantos-http-live-streaming-07#section-3.3.3 `program_date_time` Returns the EXT-X-PROGRAM-DATE-TIME as a string http://tools.ietf.org/html/draft-pantos-http-live-streaming-07#section-3.3.5 `version` Return the EXT-X-VERSION as is `allow_cache` Return the EXT-X-ALLOW-CACHE as is `files` Returns an iterable with all files from playlist, in order. This includes segments and key uri, if present. `base_uri` It is a property (getter and setter) used by SegmentList and Key to have absolute URIs. `is_i_frames_only` Returns true if EXT-X-I-FRAMES-ONLY tag present in M3U8. http://tools.ietf.org/html/draft-pantos-http-live-streaming-07#section-3.3.12 `is_independent_segments` Returns true if EXT-X-INDEPENDENT-SEGMENTS tag present in M3U8. https://tools.ietf.org/html/draft-pantos-http-live-streaming-13#section-3.4.16 `image_playlists` If this is a variant playlist (`is_variant` is True), returns a list of ImagePlaylist objects `is_images_only` Returns true if EXT-X-IMAGES-ONLY tag present in M3U8. https://github.com/image-media-playlist/spec/blob/master/image_media_playlist_v0_4.pdf """ simple_attributes = ( # obj attribute # parser attribute ("is_variant", "is_variant"), ("is_endlist", "is_endlist"), ("is_i_frames_only", "is_i_frames_only"), ("target_duration", "targetduration"), ("media_sequence", "media_sequence"), ("program_date_time", "program_date_time"), ("is_independent_segments", "is_independent_segments"), ("version", "version"), ("allow_cache", "allow_cache"), ("playlist_type", "playlist_type"), ("discontinuity_sequence", "discontinuity_sequence"), ("is_images_only", "is_images_only"), ) def __init__( self, content=None, base_path=None, base_uri=None, strict=False, custom_tags_parser=None, ): if content is not None: self.data = parse(content, strict, custom_tags_parser) else: self.data = {} self._base_uri = base_uri if self._base_uri: if not self._base_uri.endswith("/"): self._base_uri += "/" self._initialize_attributes() self.base_path = base_path def _initialize_attributes(self): self.keys = [ Key(base_uri=self.base_uri, **params) if params else None for params in self.data.get("keys", []) ] self.segment_map = [ InitializationSection(base_uri=self.base_uri, **params) if params else None for params in self.data.get("segment_map", []) ] self.segments = SegmentList( [ Segment( base_uri=self.base_uri, keyobject=find_key(segment.get("key", {}), self.keys), **segment, ) for segment in self.data.get("segments", []) ] ) for attr, param in self.simple_attributes: setattr(self, attr, self.data.get(param)) for i, segment in enumerate(self.segments, self.media_sequence or 0): segment.media_sequence = i self.files = [] for key in self.keys: # Avoid None key, it could be the first one, don't repeat them if key and key.uri not in self.files: self.files.append(key.uri) self.files.extend(self.segments.uri) self.media = MediaList( [ Media(base_uri=self.base_uri, **media) for media in self.data.get("media", []) ] ) self.playlists = PlaylistList( [ Playlist(base_uri=self.base_uri, media=self.media, **playlist) for playlist in self.data.get("playlists", []) ] ) self.iframe_playlists = PlaylistList() for ifr_pl in self.data.get("iframe_playlists", []): self.iframe_playlists.append( IFramePlaylist( base_uri=self.base_uri, uri=ifr_pl["uri"], iframe_stream_info=ifr_pl["iframe_stream_info"], ) ) self.image_playlists = PlaylistList() for img_pl in self.data.get("image_playlists", []): self.image_playlists.append( ImagePlaylist( base_uri=self.base_uri, uri=img_pl["uri"], image_stream_info=img_pl["image_stream_info"], ) ) start = self.data.get("start", None) self.start = start and Start(**start) server_control = self.data.get("server_control", None) self.server_control = server_control and ServerControl(**server_control) part_inf = self.data.get("part_inf", None) self.part_inf = part_inf and PartInformation(**part_inf) skip = self.data.get("skip", None) self.skip = skip and Skip(**skip) self.rendition_reports = RenditionReportList( [ RenditionReport(base_uri=self.base_uri, **rendition_report) for rendition_report in self.data.get("rendition_reports", []) ] ) self.session_data = SessionDataList( [ SessionData(**session_data) for session_data in self.data.get("session_data", []) if "data_id" in session_data ] ) self.session_keys = [ SessionKey(base_uri=self.base_uri, **params) if params else None for params in self.data.get("session_keys", []) ] preload_hint = self.data.get("preload_hint", None) self.preload_hint = preload_hint and PreloadHint( base_uri=self.base_uri, **preload_hint ) content_steering = self.data.get("content_steering", None) self.content_steering = content_steering and ContentSteering( base_uri=self.base_uri, **content_steering ) def __unicode__(self): return self.dumps() @property def base_uri(self): return self._base_uri @base_uri.setter def base_uri(self, new_base_uri): self._base_uri = new_base_uri self.media.base_uri = new_base_uri self.playlists.base_uri = new_base_uri self.iframe_playlists.base_uri = new_base_uri self.segments.base_uri = new_base_uri self.rendition_reports.base_uri = new_base_uri self.image_playlists.base_uri = new_base_uri for key in self.keys: if key: key.base_uri = new_base_uri for key in self.session_keys: if key: key.base_uri = new_base_uri if self.preload_hint: self.preload_hint.base_uri = new_base_uri if self.content_steering: self.content_steering.base_uri = new_base_uri @property def base_path(self): return self._base_path @base_path.setter def base_path(self, newbase_path): self._base_path = newbase_path self._update_base_path() def _update_base_path(self): if self._base_path is None: return for key in self.keys: if key: key.base_path = self._base_path for key in self.session_keys: if key: key.base_path = self._base_path self.media.base_path = self._base_path self.segments.base_path = self._base_path self.playlists.base_path = self._base_path self.iframe_playlists.base_path = self._base_path self.image_playlists.base_path = self._base_path self.rendition_reports.base_path = self._base_path if self.preload_hint: self.preload_hint.base_path = self._base_path if self.content_steering: self.content_steering.base_path = self._base_path def add_playlist(self, playlist): self.is_variant = True self.playlists.append(playlist) def add_iframe_playlist(self, iframe_playlist): if iframe_playlist is not None: self.is_variant = True self.iframe_playlists.append(iframe_playlist) def add_image_playlist(self, image_playlist): if image_playlist is not None: self.is_variant = True self.image_playlists.append(image_playlist) def add_media(self, media): self.media.append(media) def add_segment(self, segment): self.segments.append(segment) def add_rendition_report(self, report): self.rendition_reports.append(report) def dumps(self, timespec="milliseconds", infspec="auto"): """ Returns the current m3u8 as a string. You could also use unicode() or str() """ output = ["#EXTM3U"] if self.content_steering: output.append(str(self.content_steering)) if self.media_sequence: output.append("#EXT-X-MEDIA-SEQUENCE:" + str(self.media_sequence)) if self.discontinuity_sequence: output.append( f"#EXT-X-DISCONTINUITY-SEQUENCE:{self.discontinuity_sequence}" ) if self.allow_cache: output.append("#EXT-X-ALLOW-CACHE:" + self.allow_cache.upper()) if self.version: output.append("#EXT-X-VERSION:" + str(self.version)) if self.is_independent_segments: output.append("#EXT-X-INDEPENDENT-SEGMENTS") if self.target_duration: output.append( "#EXT-X-TARGETDURATION:" + number_to_string(self.target_duration) ) if not (self.playlist_type is None or self.playlist_type == ""): output.append("#EXT-X-PLAYLIST-TYPE:%s" % str(self.playlist_type).upper()) if self.start: output.append(str(self.start)) if self.is_i_frames_only: output.append("#EXT-X-I-FRAMES-ONLY") if self.is_images_only: output.append("#EXT-X-IMAGES-ONLY") if self.server_control: output.append(str(self.server_control)) if self.is_variant: if self.media: output.append(str(self.media)) output.append(str(self.playlists)) if self.iframe_playlists: output.append(str(self.iframe_playlists)) if self.image_playlists: output.append(str(self.image_playlists)) if self.part_inf: output.append(str(self.part_inf)) if self.skip: output.append(str(self.skip)) if self.session_data: output.append(str(self.session_data)) for key in self.session_keys: output.append(str(key)) output.append(self.segments.dumps(timespec, infspec)) if self.preload_hint: output.append(str(self.preload_hint)) if self.rendition_reports: output.append(str(self.rendition_reports)) if self.is_endlist: output.append("#EXT-X-ENDLIST") # ensure that the last line is terminated correctly if output[-1] and not output[-1].endswith("\n"): output.append("") return "\n".join(output) def dump(self, filename): """ Saves the current m3u8 to ``filename`` """ self._create_sub_directories(filename) with open(filename, "w") as fileobj: fileobj.write(self.dumps()) def _create_sub_directories(self, filename): if not os.path.isabs(filename): filename = os.path.join(os.getcwd(), filename) basename = os.path.dirname(filename) if basename: os.makedirs(basename, exist_ok=True) class Segment(BasePathMixin): """ A video segment from a M3U8 playlist `uri` a string with the segment uri `title` title attribute from EXTINF parameter `program_date_time` Returns the EXT-X-PROGRAM-DATE-TIME as a datetime. This field is only set if EXT-X-PROGRAM-DATE-TIME exists for this segment http://tools.ietf.org/html/draft-pantos-http-live-streaming-07#section-3.3.5 `current_program_date_time` Returns a datetime of this segment, either the value of `program_date_time` when EXT-X-PROGRAM-DATE-TIME is set or a calculated value based on previous segments' EXT-X-PROGRAM-DATE-TIME and EXTINF values `discontinuity` Returns a boolean indicating if a EXT-X-DISCONTINUITY tag exists http://tools.ietf.org/html/draft-pantos-http-live-streaming-13#section-3.4.11 `cue_out` Returns a boolean indicating if a EXT-X-CUE-OUT-CONT tag exists Note: for backwards compatibility, this will be True when cue_out_start is True, even though this tag did not exist in the input, and EXT-X-CUE-OUT-CONT will not exist in the output `cue_out_start` Returns a boolean indicating if a EXT-X-CUE-OUT tag exists `cue_out_explicitly_duration` Returns a boolean indicating if a EXT-X-CUE-OUT have the DURATION parameter when parsing `cue_in` Returns a boolean indicating if a EXT-X-CUE-IN tag exists `scte35` Base64 encoded SCTE35 metadata if available `scte35_duration` Planned SCTE35 duration `duration` duration attribute from EXTINF parameter `base_uri` uri the key comes from in URI hierarchy. ex.: http://example.com/path/to `bitrate` bitrate attribute from EXT-X-BITRATE parameter `byterange` byterange attribute from EXT-X-BYTERANGE parameter `key` Key used to encrypt the segment (EXT-X-KEY) `parts` partial segments that make up this segment `dateranges` any dateranges that should precede the segment `gap_tag` GAP tag indicates that a Media Segment is missing `custom_parser_values` Additional values which custom_tags_parser might store per segment """ def __init__( self, uri=None, base_uri=None, program_date_time=None, current_program_date_time=None, duration=None, title=None, bitrate=None, byterange=None, cue_out=False, cue_out_start=False, cue_out_explicitly_duration=False, cue_in=False, discontinuity=False, key=None, scte35=None, oatcls_scte35=None, scte35_duration=None, scte35_elapsedtime=None, asset_metadata=None, keyobject=None, parts=None, init_section=None, dateranges=None, gap_tag=None, media_sequence=None, custom_parser_values=None, ): self.media_sequence = media_sequence self.uri = uri self.duration = duration self.title = title self._base_uri = base_uri self.bitrate = bitrate self.byterange = byterange self.program_date_time = program_date_time self.current_program_date_time = current_program_date_time self.discontinuity = discontinuity self.cue_out_start = cue_out_start self.cue_out_explicitly_duration = cue_out_explicitly_duration self.cue_out = cue_out self.cue_in = cue_in self.scte35 = scte35 self.oatcls_scte35 = oatcls_scte35 self.scte35_duration = scte35_duration self.scte35_elapsedtime = scte35_elapsedtime self.asset_metadata = asset_metadata self.key = keyobject self.parts = PartialSegmentList( [PartialSegment(base_uri=self._base_uri, **partial) for partial in parts] if parts else [] ) if init_section is not None: self.init_section = InitializationSection(self._base_uri, **init_section) else: self.init_section = None self.dateranges = DateRangeList( [DateRange(**daterange) for daterange in dateranges] if dateranges else [] ) self.gap_tag = gap_tag self.custom_parser_values = custom_parser_values or {} def add_part(self, part): self.parts.append(part) def dumps(self, last_segment, timespec="milliseconds", infspec="auto"): output = [] if last_segment and self.key != last_segment.key: output.append(str(self.key)) output.append("\n") else: # The key must be checked anyway now for the first segment if self.key and last_segment is None: output.append(str(self.key)) output.append("\n") if self.init_section: if (not last_segment) or (self.init_section != last_segment.init_section): output.append(str(self.init_section)) output.append("\n") if self.discontinuity: output.append("#EXT-X-DISCONTINUITY\n") if self.program_date_time: output.append( "#EXT-X-PROGRAM-DATE-TIME:%s\n" % format_date_time(self.program_date_time, timespec=timespec) ) if len(self.dateranges): output.append(str(self.dateranges)) output.append("\n") if self.cue_out_start: if self.oatcls_scte35: output.append(f"{ext_oatcls_scte35}:{self.oatcls_scte35}\n") if self.asset_metadata: asset_suffix = [] for metadata_key, metadata_value in self.asset_metadata.items(): asset_suffix.append(f"{metadata_key.upper()}={metadata_value}") output.append(f"{ext_x_asset}:{','.join(asset_suffix)}\n") prefix = ":DURATION=" if self.cue_out_explicitly_duration else ":" cue_info = f"{prefix}{self.scte35_duration}" if self.scte35_duration else "" output.append(f"#EXT-X-CUE-OUT{cue_info}\n") elif self.cue_out: cue_out_cont_suffix = [] if self.scte35_elapsedtime: cue_out_cont_suffix.append(f"ElapsedTime={self.scte35_elapsedtime}") if self.scte35_duration: cue_out_cont_suffix.append(f"Duration={self.scte35_duration}") if self.scte35: cue_out_cont_suffix.append(f"SCTE35={self.scte35}") if cue_out_cont_suffix: cue_out_cont_suffix = ":" + ",".join(cue_out_cont_suffix) else: cue_out_cont_suffix = "" output.append(f"#EXT-X-CUE-OUT-CONT{cue_out_cont_suffix}\n") elif self.cue_in: output.append("#EXT-X-CUE-IN\n") elif self.oatcls_scte35: output.append(f"{ext_oatcls_scte35}:{self.oatcls_scte35}\n") if self.parts: output.append(str(self.parts)) output.append("\n") if self.uri: if self.duration is not None: if infspec == "milliseconds": duration = "{:.3f}".format(self.duration) elif infspec == "microseconds": duration = "{:.6f}".format(self.duration) else: duration = number_to_string(self.duration) output.append("#EXTINF:%s," % duration) if self.title: output.append(self.title) output.append("\n") if self.byterange: output.append("#EXT-X-BYTERANGE:%s\n" % self.byterange) if self.bitrate: output.append("#EXT-X-BITRATE:%d\n" % self.bitrate) if self.gap_tag: output.append("#EXT-X-GAP\n") output.append(self.uri) return "".join(output) def __str__(self): return self.dumps(None) @property def base_path(self): return super().base_path @base_path.setter def base_path(self, newbase_path): super(Segment, self.__class__).base_path.fset(self, newbase_path) self.parts.base_path = newbase_path if self.init_section is not None: self.init_section.base_path = newbase_path @property def base_uri(self): return self._base_uri @base_uri.setter def base_uri(self, newbase_uri): self._base_uri = newbase_uri self.parts.base_uri = newbase_uri if self.init_section is not None: self.init_section.base_uri = newbase_uri class SegmentList(list, GroupedBasePathMixin): def dumps(self, timespec="milliseconds", infspec="auto"): output = [] last_segment = None for segment in self: output.append(segment.dumps(last_segment, timespec, infspec)) last_segment = segment return "\n".join(output) def __str__(self): return self.dumps() @property def uri(self): return [seg.uri for seg in self] def by_key(self, key): return [segment for segment in self if segment.key == key] class PartialSegment(BasePathMixin): """ A partial segment from a M3U8 playlist `uri` a string with the segment uri `program_date_time` Returns the EXT-X-PROGRAM-DATE-TIME as a datetime. This field is only set if EXT-X-PROGRAM-DATE-TIME exists for this segment http://tools.ietf.org/html/draft-pantos-http-live-streaming-07#section-3.3.5 `current_program_date_time` Returns a datetime of this segment, either the value of `program_date_time` when EXT-X-PROGRAM-DATE-TIME is set or a calculated value based on previous segments' EXT-X-PROGRAM-DATE-TIME and EXTINF values `duration` duration attribute from EXTINF parameter `byterange` byterange attribute from EXT-X-BYTERANGE parameter `independent` the Partial Segment contains an independent frame `gap` GAP attribute indicates the Partial Segment is not available `dateranges` any dateranges that should precede the partial segment `gap_tag` GAP tag indicates one or more of the parent Media Segment's Partial Segments have a GAP=YES attribute. This tag should appear immediately after the first EXT-X-PART tag in the Parent Segment with a GAP=YES attribute. """ def __init__( self, base_uri, uri, duration, program_date_time=None, current_program_date_time=None, byterange=None, independent=None, gap=None, dateranges=None, gap_tag=None, ): self.base_uri = base_uri self.uri = uri self.duration = duration self.program_date_time = program_date_time self.current_program_date_time = current_program_date_time self.byterange = byterange self.independent = independent self.gap = gap self.dateranges = DateRangeList( [DateRange(**daterange) for daterange in dateranges] if dateranges else [] ) self.gap_tag = gap_tag def dumps(self, last_segment): output = [] if len(self.dateranges): output.append(str(self.dateranges)) output.append("\n") if self.gap_tag: output.append("#EXT-X-GAP\n") output.append( '#EXT-X-PART:DURATION=%s,URI="%s"' % (number_to_string(self.duration), self.uri) ) if self.independent: output.append(",INDEPENDENT=%s" % self.independent) if self.byterange: output.append(",BYTERANGE=%s" % self.byterange) if self.gap: output.append(",GAP=%s" % self.gap) return "".join(output) def __str__(self): return self.dumps(None) class PartialSegmentList(list, GroupedBasePathMixin): def __str__(self): output = [str(part) for part in self] return "\n".join(output) class Key(BasePathMixin): """ Key used to encrypt the segments in a m3u8 playlist (EXT-X-KEY) `method` is a string. ex.: "AES-128" `uri` is a string. ex:: "https://priv.example.com/key.php?r=52" `base_uri` uri the key comes from in URI hierarchy. ex.: http://example.com/path/to `iv` initialization vector. a string representing a hexadecimal number. ex.: 0X12A """ tag = ext_x_key def __init__( self, method, base_uri, uri=None, iv=None, keyformat=None, keyformatversions=None, **kwargs, ): self.method = method self.uri = uri self.iv = iv self.keyformat = keyformat self.keyformatversions = keyformatversions self.base_uri = base_uri self._extra_params = kwargs def __str__(self): output = [ "METHOD=%s" % self.method, ] if self.uri: output.append('URI="%s"' % self.uri) if self.iv: output.append("IV=%s" % self.iv) if self.keyformat: output.append('KEYFORMAT="%s"' % self.keyformat) if self.keyformatversions: output.append('KEYFORMATVERSIONS="%s"' % self.keyformatversions) return self.tag + ":" + ",".join(output) def __eq__(self, other): if not other: return False return ( self.method == other.method and self.uri == other.uri and self.iv == other.iv and self.base_uri == other.base_uri and self.keyformat == other.keyformat and self.keyformatversions == other.keyformatversions ) def __ne__(self, other): return not self.__eq__(other) class InitializationSection(BasePathMixin): """ Used to obtain Media Initialization Section required to parse the applicable Media Segments (EXT-X-MAP) `uri` is a string. ex:: "https://priv.example.com/key.php?r=52" `byterange` value of BYTERANGE attribute `base_uri` uri the segment comes from in URI hierarchy. ex.: http://example.com/path/to """ tag = ext_x_map def __init__(self, base_uri, uri, byterange=None): self.base_uri = base_uri self.uri = uri self.byterange = byterange def __str__(self): output = [] if self.uri: output.append("URI=" + quoted(self.uri)) if self.byterange: output.append("BYTERANGE=" + quoted(self.byterange)) return "{tag}:{attributes}".format(tag=self.tag, attributes=",".join(output)) def __eq__(self, other): if not other: return False return ( self.uri == other.uri and self.byterange == other.byterange and self.base_uri == other.base_uri ) def __ne__(self, other): return not self.__eq__(other) class SessionKey(Key): tag = ext_x_session_key class Playlist(BasePathMixin): """ Playlist object representing a link to a variant M3U8 with a specific bitrate. Attributes: `stream_info` is a named tuple containing the attributes: `program_id`, `bandwidth`, `average_bandwidth`, `resolution`, `codecs` and `resolution` which is a a tuple (w, h) of integers `media` is a list of related Media entries. More info: http://tools.ietf.org/html/draft-pantos-http-live-streaming-07#section-3.3.10 """ def __init__(self, uri, stream_info, media, base_uri): self.uri = uri self.base_uri = base_uri resolution = stream_info.get("resolution") if resolution is not None: resolution = resolution.strip('"') values = resolution.split("x") resolution_pair = (int(values[0]), int(values[1])) else: resolution_pair = None self.stream_info = StreamInfo( bandwidth=stream_info["bandwidth"], video=stream_info.get("video"), audio=stream_info.get("audio"), subtitles=stream_info.get("subtitles"), closed_captions=stream_info.get("closed_captions"), average_bandwidth=stream_info.get("average_bandwidth"), program_id=stream_info.get("program_id"), resolution=resolution_pair, codecs=stream_info.get("codecs"), frame_rate=stream_info.get("frame_rate"), video_range=stream_info.get("video_range"), hdcp_level=stream_info.get("hdcp_level"), pathway_id=stream_info.get("pathway_id"), stable_variant_id=stream_info.get("stable_variant_id"), req_video_layout=stream_info.get("req_video_layout"), ) self.media = [] for media_type in ("audio", "video", "subtitles"): group_id = stream_info.get(media_type) if not group_id: continue self.media += filter(lambda m: m.group_id == group_id, media) def __str__(self): media_types = [] stream_inf = [str(self.stream_info)] for media in self.media: if media.type in media_types: continue else: media_types += [media.type] media_type = media.type.upper() stream_inf.append(f'{media_type}="{media.group_id}"') return "#EXT-X-STREAM-INF:" + ",".join(stream_inf) + "\n" + self.uri class IFramePlaylist(BasePathMixin): """ IFramePlaylist object representing a link to a variant M3U8 i-frame playlist with a specific bitrate. Attributes: `iframe_stream_info` is a named tuple containing the attributes: `program_id`, `bandwidth`, `average_bandwidth`, `codecs`, `video_range`, `hdcp_level` and `resolution` which is a tuple (w, h) of integers More info: http://tools.ietf.org/html/draft-pantos-http-live-streaming-07#section-3.3.13 """ def __init__(self, base_uri, uri, iframe_stream_info): self.uri = uri self.base_uri = base_uri resolution = iframe_stream_info.get("resolution") if resolution is not None: values = resolution.split("x") resolution_pair = (int(values[0]), int(values[1])) else: resolution_pair = None self.iframe_stream_info = StreamInfo( bandwidth=iframe_stream_info.get("bandwidth"), average_bandwidth=iframe_stream_info.get("average_bandwidth"), video=iframe_stream_info.get("video"), # Audio, subtitles, and closed captions should not exist in # EXT-X-I-FRAME-STREAM-INF, so just hardcode them to None. audio=None, subtitles=None, closed_captions=None, program_id=iframe_stream_info.get("program_id"), resolution=resolution_pair, codecs=iframe_stream_info.get("codecs"), video_range=iframe_stream_info.get("video_range"), hdcp_level=iframe_stream_info.get("hdcp_level"), frame_rate=None, pathway_id=iframe_stream_info.get("pathway_id"), stable_variant_id=iframe_stream_info.get("stable_variant_id"), req_video_layout=None, ) def __str__(self): iframe_stream_inf = [] if self.iframe_stream_info.program_id: iframe_stream_inf.append( "PROGRAM-ID=%d" % self.iframe_stream_info.program_id ) if self.iframe_stream_info.bandwidth: iframe_stream_inf.append("BANDWIDTH=%d" % self.iframe_stream_info.bandwidth) if self.iframe_stream_info.average_bandwidth: iframe_stream_inf.append( "AVERAGE-BANDWIDTH=%d" % self.iframe_stream_info.average_bandwidth ) if self.iframe_stream_info.resolution: res = ( str(self.iframe_stream_info.resolution[0]) + "x" + str(self.iframe_stream_info.resolution[1]) ) iframe_stream_inf.append("RESOLUTION=" + res) if self.iframe_stream_info.codecs: iframe_stream_inf.append("CODECS=" + quoted(self.iframe_stream_info.codecs)) if self.iframe_stream_info.video_range: iframe_stream_inf.append( "VIDEO-RANGE=%s" % self.iframe_stream_info.video_range ) if self.iframe_stream_info.hdcp_level: iframe_stream_inf.append( "HDCP-LEVEL=%s" % self.iframe_stream_info.hdcp_level ) if self.uri: iframe_stream_inf.append("URI=" + quoted(self.uri)) if self.iframe_stream_info.pathway_id: iframe_stream_inf.append( "PATHWAY-ID=" + quoted(self.iframe_stream_info.pathway_id) ) if self.iframe_stream_info.stable_variant_id: iframe_stream_inf.append( "STABLE-VARIANT-ID=" + quoted(self.iframe_stream_info.stable_variant_id) ) return "#EXT-X-I-FRAME-STREAM-INF:" + ",".join(iframe_stream_inf) class StreamInfo: bandwidth = None closed_captions = None average_bandwidth = None program_id = None resolution = None codecs = None audio = None video = None subtitles = None frame_rate = None video_range = None hdcp_level = None pathway_id = None stable_variant_id = None req_video_layout = None def __init__(self, **kwargs): self.bandwidth = kwargs.get("bandwidth") self.closed_captions = kwargs.get("closed_captions") self.average_bandwidth = kwargs.get("average_bandwidth") self.program_id = kwargs.get("program_id") self.resolution = kwargs.get("resolution") self.codecs = kwargs.get("codecs") self.audio = kwargs.get("audio") self.video = kwargs.get("video") self.subtitles = kwargs.get("subtitles") self.frame_rate = kwargs.get("frame_rate") self.video_range = kwargs.get("video_range") self.hdcp_level = kwargs.get("hdcp_level") self.pathway_id = kwargs.get("pathway_id") self.stable_variant_id = kwargs.get("stable_variant_id") self.req_video_layout = kwargs.get("req_video_layout") def __str__(self): stream_inf = [] if self.program_id is not None: stream_inf.append("PROGRAM-ID=%d" % self.program_id) if self.closed_captions is not None: stream_inf.append("CLOSED-CAPTIONS=%s" % self.closed_captions) if self.bandwidth is not None: stream_inf.append("BANDWIDTH=%d" % self.bandwidth) if self.average_bandwidth is not None: stream_inf.append("AVERAGE-BANDWIDTH=%d" % self.average_bandwidth) if self.resolution is not None: res = str(self.resolution[0]) + "x" + str(self.resolution[1]) stream_inf.append("RESOLUTION=" + res) if self.frame_rate is not None: stream_inf.append( "FRAME-RATE=%g" % decimal.Decimal(self.frame_rate).quantize(decimal.Decimal("1.000")) ) if self.codecs is not None: stream_inf.append("CODECS=" + quoted(self.codecs)) if self.video_range is not None: stream_inf.append("VIDEO-RANGE=%s" % self.video_range) if self.hdcp_level is not None: stream_inf.append("HDCP-LEVEL=%s" % self.hdcp_level) if self.pathway_id is not None: stream_inf.append("PATHWAY-ID=" + quoted(self.pathway_id)) if self.stable_variant_id is not None: stream_inf.append("STABLE-VARIANT-ID=" + quoted(self.stable_variant_id)) if self.req_video_layout is not None: stream_inf.append("REQ-VIDEO_LAYOUT=" + quoted(self.req_video_layout)) return ",".join(stream_inf) class Media(BasePathMixin): """ A media object from a M3U8 playlist https://tools.ietf.org/html/draft-pantos-http-live-streaming-16#section-4.3.4.1 `uri` a string with the media uri `type` `group_id` `language` `assoc-language` `name` `default` `autoselect` `forced` `instream_id` `characteristics` `channels` `stable_rendition_id` attributes in the EXT-MEDIA tag `base_uri` uri the media comes from in URI hierarchy. ex.: http://example.com/path/to """ def __init__( self, uri=None, type=None, group_id=None, language=None, name=None, default=None, autoselect=None, forced=None, characteristics=None, channels=None, stable_rendition_id=None, assoc_language=None, instream_id=None, base_uri=None, **extras, ): self.base_uri = base_uri self.uri = uri self.type = type self.group_id = group_id self.language = language self.name = name self.default = default self.autoselect = autoselect self.forced = forced self.assoc_language = assoc_language self.instream_id = instream_id self.characteristics = characteristics self.channels = channels self.stable_rendition_id = stable_rendition_id self.extras = extras def dumps(self): media_out = [] if self.uri: media_out.append("URI=" + quoted(self.uri)) if self.type: media_out.append("TYPE=" + self.type) if self.group_id: media_out.append("GROUP-ID=" + quoted(self.group_id)) if self.language: media_out.append("LANGUAGE=" + quoted(self.language)) if self.assoc_language: media_out.append("ASSOC-LANGUAGE=" + quoted(self.assoc_language)) if self.name: media_out.append("NAME=" + quoted(self.name)) if self.default: media_out.append("DEFAULT=" + self.default) if self.autoselect: media_out.append("AUTOSELECT=" + self.autoselect) if self.forced: media_out.append("FORCED=" + self.forced) if self.instream_id: media_out.append("INSTREAM-ID=" + quoted(self.instream_id)) if self.characteristics: media_out.append("CHARACTERISTICS=" + quoted(self.characteristics)) if self.channels: media_out.append("CHANNELS=" + quoted(self.channels)) if self.stable_rendition_id: media_out.append("STABLE-RENDITION-ID=" + quoted(self.stable_rendition_id)) return "#EXT-X-MEDIA:" + ",".join(media_out) def __str__(self): return self.dumps() class TagList(list): def __str__(self): output = [str(tag) for tag in self] return "\n".join(output) class MediaList(TagList, GroupedBasePathMixin): @property def uri(self): return [media.uri for media in self] class PlaylistList(TagList, GroupedBasePathMixin): pass class SessionDataList(TagList): pass class Start: def __init__(self, time_offset, precise=None): self.time_offset = float(time_offset) self.precise = precise def __str__(self): output = ["TIME-OFFSET=" + str(self.time_offset)] if self.precise and self.precise in ["YES", "NO"]: output.append("PRECISE=" + str(self.precise)) return ext_x_start + ":" + ",".join(output) class RenditionReport(BasePathMixin): def __init__(self, base_uri, uri, last_msn, last_part=None): self.base_uri = base_uri self.uri = uri self.last_msn = last_msn self.last_part = last_part def dumps(self): report = [] report.append("URI=" + quoted(self.uri)) report.append("LAST-MSN=" + str(self.last_msn)) if self.last_part is not None: report.append("LAST-PART=" + str(self.last_part)) return "#EXT-X-RENDITION-REPORT:" + ",".join(report) def __str__(self): return self.dumps() class RenditionReportList(list, GroupedBasePathMixin): def __str__(self): output = [str(report) for report in self] return "\n".join(output) class ServerControl: def __init__( self, can_skip_until=None, can_block_reload=None, hold_back=None, part_hold_back=None, can_skip_dateranges=None, ): self.can_skip_until = can_skip_until self.can_block_reload = can_block_reload self.hold_back = hold_back self.part_hold_back = part_hold_back self.can_skip_dateranges = can_skip_dateranges def __getitem__(self, item): return getattr(self, item) def dumps(self): ctrl = [] if self.can_block_reload: ctrl.append("CAN-BLOCK-RELOAD=%s" % self.can_block_reload) for attr in ["hold_back", "part_hold_back"]: if self[attr]: ctrl.append( "%s=%s" % (denormalize_attribute(attr), number_to_string(self[attr])) ) if self.can_skip_until: ctrl.append("CAN-SKIP-UNTIL=%s" % number_to_string(self.can_skip_until)) if self.can_skip_dateranges: ctrl.append("CAN-SKIP-DATERANGES=%s" % self.can_skip_dateranges) return "#EXT-X-SERVER-CONTROL:" + ",".join(ctrl) def __str__(self): return self.dumps() class Skip: def __init__(self, skipped_segments, recently_removed_dateranges=None): self.skipped_segments = skipped_segments self.recently_removed_dateranges = recently_removed_dateranges def dumps(self): skip = [] skip.append("SKIPPED-SEGMENTS=%s" % self.skipped_segments) if self.recently_removed_dateranges is not None: skip.append( "RECENTLY-REMOVED-DATERANGES=%s" % quoted(self.recently_removed_dateranges) ) return "#EXT-X-SKIP:" + ",".join(skip) def __str__(self): return self.dumps() class PartInformation: def __init__(self, part_target=None): self.part_target = part_target def dumps(self): return "#EXT-X-PART-INF:PART-TARGET=%s" % number_to_string(self.part_target) def __str__(self): return self.dumps() class PreloadHint(BasePathMixin): def __init__( self, type, base_uri, uri, byterange_start=None, byterange_length=None ): self.hint_type = type self.base_uri = base_uri self.uri = uri self.byterange_start = byterange_start self.byterange_length = byterange_length def __getitem__(self, item): return getattr(self, item) def dumps(self): hint = [] hint.append("TYPE=" + self.hint_type) hint.append("URI=" + quoted(self.uri)) for attr in ["byterange_start", "byterange_length"]: if self[attr] is not None: hint.append(f"{denormalize_attribute(attr)}={self[attr]}") return "#EXT-X-PRELOAD-HINT:" + ",".join(hint) def __str__(self): return self.dumps() class SessionData: def __init__(self, data_id, value=None, uri=None, language=None): self.data_id = data_id self.value = value self.uri = uri self.language = language def dumps(self): session_data_out = ["DATA-ID=" + quoted(self.data_id)] if self.value: session_data_out.append("VALUE=" + quoted(self.value)) elif self.uri: session_data_out.append("URI=" + quoted(self.uri)) if self.language: session_data_out.append("LANGUAGE=" + quoted(self.language)) return "#EXT-X-SESSION-DATA:" + ",".join(session_data_out) def __str__(self): return self.dumps() class DateRangeList(TagList): pass class DateRange: def __init__(self, **kwargs): self.id = kwargs["id"] self.start_date = kwargs.get("start_date") self.class_ = kwargs.get("class") self.end_date = kwargs.get("end_date") self.duration = kwargs.get("duration") self.planned_duration = kwargs.get("planned_duration") self.scte35_cmd = kwargs.get("scte35_cmd") self.scte35_out = kwargs.get("scte35_out") self.scte35_in = kwargs.get("scte35_in") self.end_on_next = kwargs.get("end_on_next") self.x_client_attrs = [ (attr, kwargs.get(attr)) for attr in kwargs if attr.startswith("x_") ] def dumps(self): daterange = [] daterange.append("ID=" + quoted(self.id)) # whilst START-DATE is technically REQUIRED by the spec, this is # contradicted by an example in the same document (see # https://tools.ietf.org/html/rfc8216#section-8.10), and also by # real-world implementations, so we make it optional here if self.start_date: daterange.append("START-DATE=" + quoted(self.start_date)) if self.class_: daterange.append("CLASS=" + quoted(self.class_)) if self.end_date: daterange.append("END-DATE=" + quoted(self.end_date)) if self.duration: daterange.append("DURATION=" + number_to_string(self.duration)) if self.planned_duration: daterange.append( "PLANNED-DURATION=" + number_to_string(self.planned_duration) ) if self.scte35_cmd: daterange.append("SCTE35-CMD=" + self.scte35_cmd) if self.scte35_out: daterange.append("SCTE35-OUT=" + self.scte35_out) if self.scte35_in: daterange.append("SCTE35-IN=" + self.scte35_in) if self.end_on_next: daterange.append("END-ON-NEXT=" + self.end_on_next) # client attributes sorted alphabetically output order is predictable for attr, value in sorted(self.x_client_attrs): daterange.append(f"{denormalize_attribute(attr)}={value}") return "#EXT-X-DATERANGE:" + ",".join(daterange) def __str__(self): return self.dumps() class ContentSteering(BasePathMixin): def __init__(self, base_uri, server_uri, pathway_id=None): self.base_uri = base_uri self.uri = server_uri self.pathway_id = pathway_id def dumps(self): steering = [] steering.append("SERVER-URI=" + quoted(self.uri)) if self.pathway_id is not None: steering.append("PATHWAY-ID=" + quoted(self.pathway_id)) return "#EXT-X-CONTENT-STEERING:" + ",".join(steering) def __str__(self): return self.dumps() class ImagePlaylist(BasePathMixin): """ ImagePlaylist object representing a link to a variant M3U8 image playlist with a specific bitrate. Attributes: `image_stream_info` is a named tuple containing the attributes: `bandwidth`, `resolution` which is a tuple (w, h) of integers and `codecs`, More info: https://github.com/image-media-playlist/spec/blob/master/image_media_playlist_v0_4.pdf """ def __init__(self, base_uri, uri, image_stream_info): self.uri = uri self.base_uri = base_uri resolution = image_stream_info.get("resolution") if resolution is not None: values = resolution.split("x") resolution_pair = (int(values[0]), int(values[1])) else: resolution_pair = None self.image_stream_info = StreamInfo( bandwidth=image_stream_info.get("bandwidth"), average_bandwidth=image_stream_info.get("average_bandwidth"), video=image_stream_info.get("video"), # Audio, subtitles, closed captions, video range and hdcp level should not exist in # EXT-X-IMAGE-STREAM-INF, so just hardcode them to None. audio=None, subtitles=None, closed_captions=None, program_id=image_stream_info.get("program_id"), resolution=resolution_pair, codecs=image_stream_info.get("codecs"), video_range=None, hdcp_level=None, frame_rate=None, pathway_id=image_stream_info.get("pathway_id"), stable_variant_id=image_stream_info.get("stable_variant_id"), ) def __str__(self): image_stream_inf = [] if self.image_stream_info.program_id: image_stream_inf.append("PROGRAM-ID=%d" % self.image_stream_info.program_id) if self.image_stream_info.bandwidth: image_stream_inf.append("BANDWIDTH=%d" % self.image_stream_info.bandwidth) if self.image_stream_info.average_bandwidth: image_stream_inf.append( "AVERAGE-BANDWIDTH=%d" % self.image_stream_info.average_bandwidth ) if self.image_stream_info.resolution: res = ( str(self.image_stream_info.resolution[0]) + "x" + str(self.image_stream_info.resolution[1]) ) image_stream_inf.append("RESOLUTION=" + res) if self.image_stream_info.codecs: image_stream_inf.append("CODECS=" + quoted(self.image_stream_info.codecs)) if self.uri: image_stream_inf.append("URI=" + quoted(self.uri)) if self.image_stream_info.pathway_id: image_stream_inf.append( "PATHWAY-ID=" + quoted(self.image_stream_info.pathway_id) ) if self.image_stream_info.stable_variant_id: image_stream_inf.append( "STABLE-VARIANT-ID=" + quoted(self.image_stream_info.stable_variant_id) ) return "#EXT-X-IMAGE-STREAM-INF:" + ",".join(image_stream_inf) class Tiles(BasePathMixin): """ Image tiles from a M3U8 playlist `resolution` resolution attribute from EXT-X-TILES tag `layout` layout attribute from EXT-X-TILES tag `duration` duration attribute from EXT-X-TILES tag """ def __init__(self, resolution, layout, duration): self.resolution = resolution self.layout = layout self.duration = duration def dumps(self): tiles = [] tiles.append("RESOLUTION=" + self.resolution) tiles.append("LAYOUT=" + self.layout) tiles.append("DURATION=" + self.duration) return "#EXT-X-TILES:" + ",".join(tiles) def __str__(self): return self.dumps() def find_key(keydata, keylist): if not keydata: return None for key in keylist: if key: # Check the intersection of keys and values if ( keydata.get("uri", None) == key.uri and keydata.get("method", "NONE") == key.method and keydata.get("iv", None) == key.iv ): return key raise KeyError("No key found for key data") def denormalize_attribute(attribute): return attribute.replace("_", "-").upper() def quoted(string): return '"%s"' % string def number_to_string(number): with decimal.localcontext() as ctx: ctx.prec = 20 # set floating point precision d = decimal.Decimal(str(number)) return str( d.quantize(decimal.Decimal(1)) if d == d.to_integral_value() else d.normalize() ) m3u8-6.0.0/m3u8/parser.py000066400000000000000000000603611465465410600147740ustar00rootroot00000000000000# Copyright 2014 Globo.com Player authors. All rights reserved. # Use of this source code is governed by a MIT License # license that can be found in the LICENSE file. import itertools import re from datetime import datetime, timedelta try: from backports.datetime_fromisoformat import MonkeyPatch MonkeyPatch.patch_fromisoformat() except ImportError: pass from m3u8 import protocol, version_matching """ http://tools.ietf.org/html/draft-pantos-http-live-streaming-08#section-3.2 http://stackoverflow.com/questions/2785755/how-to-split-but-ignore-separators-in-quoted-strings-in-python """ ATTRIBUTELISTPATTERN = re.compile(r"""((?:[^,"']|"[^"]*"|'[^']*')+)""") def cast_date_time(value): return datetime.fromisoformat(value) def format_date_time(value, **kwargs): return value.isoformat(**kwargs) class ParseError(Exception): def __init__(self, lineno, line): self.lineno = lineno self.line = line def __str__(self): return "Syntax error in manifest on line %d: %s" % (self.lineno, self.line) def parse(content, strict=False, custom_tags_parser=None): """ Given a M3U8 playlist content returns a dictionary with all data found """ data = { "media_sequence": 0, "is_variant": False, "is_endlist": False, "is_i_frames_only": False, "is_independent_segments": False, "is_images_only": False, "playlist_type": None, "playlists": [], "segments": [], "iframe_playlists": [], "image_playlists": [], "tiles": [], "media": [], "keys": [], "rendition_reports": [], "skip": {}, "part_inf": {}, "session_data": [], "session_keys": [], "segment_map": [], } state = { "expect_segment": False, "expect_playlist": False, "current_key": None, "current_segment_map": None, } lines = string_to_lines(content) if strict: found_errors = version_matching.validate(lines) if len(found_errors) > 0: raise Exception(found_errors) for lineno, line in enumerate(lines, 1): line = line.strip() parse_kwargs = { "line": line, "lineno": lineno, "data": data, "state": state, "strict": strict, } # Call custom parser if needed if line.startswith("#") and callable(custom_tags_parser): go_to_next_line = custom_tags_parser(line, lineno, data, state) # Do not try to parse other standard tags on this line if custom_tags_parser # function returns `True` if go_to_next_line: continue if line.startswith(protocol.ext_x_byterange): _parse_byterange(**parse_kwargs) continue elif line.startswith(protocol.ext_x_bitrate): _parse_bitrate(**parse_kwargs) elif line.startswith(protocol.ext_x_targetduration): _parse_targetduration(**parse_kwargs) elif line.startswith(protocol.ext_x_media_sequence): _parse_media_sequence(**parse_kwargs) elif line.startswith(protocol.ext_x_discontinuity_sequence): _parse_discontinuity_sequence(**parse_kwargs) elif line.startswith(protocol.ext_x_program_date_time): _parse_program_date_time(**parse_kwargs) elif line.startswith(protocol.ext_x_discontinuity): _parse_discontinuity(**parse_kwargs) elif line.startswith(protocol.ext_x_cue_out_cont): _parse_cueout_cont(**parse_kwargs) elif line.startswith(protocol.ext_x_cue_out): _parse_cueout(**parse_kwargs) elif line.startswith(f"{protocol.ext_oatcls_scte35}:"): _parse_oatcls_scte35(**parse_kwargs) elif line.startswith(f"{protocol.ext_x_asset}:"): _parse_asset(**parse_kwargs) elif line.startswith(protocol.ext_x_cue_in): _parse_cue_in(**parse_kwargs) elif line.startswith(protocol.ext_x_cue_span): _parse_cue_span(**parse_kwargs) elif line.startswith(protocol.ext_x_version): _parse_version(**parse_kwargs) elif line.startswith(protocol.ext_x_allow_cache): _parse_allow_cache(**parse_kwargs) elif line.startswith(protocol.ext_x_key): _parse_key(**parse_kwargs) elif line.startswith(protocol.extinf): _parse_extinf(**parse_kwargs) elif line.startswith(protocol.ext_x_stream_inf): _parse_stream_inf(**parse_kwargs) elif line.startswith(protocol.ext_x_i_frame_stream_inf): _parse_i_frame_stream_inf(**parse_kwargs) elif line.startswith(protocol.ext_x_media): _parse_media(**parse_kwargs) elif line.startswith(protocol.ext_x_playlist_type): _parse_playlist_type(**parse_kwargs) elif line.startswith(protocol.ext_i_frames_only): _parse_i_frames_only(**parse_kwargs) elif line.startswith(protocol.ext_is_independent_segments): _parse_is_independent_segments(**parse_kwargs) elif line.startswith(protocol.ext_x_endlist): _parse_endlist(**parse_kwargs) elif line.startswith(protocol.ext_x_map): _parse_x_map(**parse_kwargs) elif line.startswith(protocol.ext_x_start): _parse_start(**parse_kwargs) elif line.startswith(protocol.ext_x_server_control): _parse_server_control(**parse_kwargs) elif line.startswith(protocol.ext_x_part_inf): _parse_part_inf(**parse_kwargs) elif line.startswith(protocol.ext_x_rendition_report): _parse_rendition_report(**parse_kwargs) elif line.startswith(protocol.ext_x_part): _parse_part(**parse_kwargs) elif line.startswith(protocol.ext_x_skip): _parse_skip(**parse_kwargs) elif line.startswith(protocol.ext_x_session_data): _parse_session_data(**parse_kwargs) elif line.startswith(protocol.ext_x_session_key): _parse_session_key(**parse_kwargs) elif line.startswith(protocol.ext_x_preload_hint): _parse_preload_hint(**parse_kwargs) elif line.startswith(protocol.ext_x_daterange): _parse_daterange(**parse_kwargs) elif line.startswith(protocol.ext_x_gap): _parse_gap(**parse_kwargs) elif line.startswith(protocol.ext_x_content_steering): _parse_content_steering(**parse_kwargs) elif line.startswith(protocol.ext_x_image_stream_inf): _parse_image_stream_inf(**parse_kwargs) elif line.startswith(protocol.ext_x_images_only): _parse_is_images_only(**parse_kwargs) elif line.startswith(protocol.ext_x_tiles): _parse_tiles(**parse_kwargs) # #EXTM3U should be present. elif line.startswith(protocol.ext_m3u): pass # Blank lines are ignored. elif line.strip() == "": pass # Lines that don't start with # are either segments or playlists. elif (not line.startswith("#")) and (state["expect_segment"]): _parse_ts_chunk(**parse_kwargs) elif (not line.startswith("#")) and (state["expect_playlist"]): _parse_variant_playlist(**parse_kwargs) # Lines that haven't been recognized by any of the parsers above are illegal # in strict mode. elif strict: raise ParseError(lineno, line) # Handle remaining partial segments. if "segment" in state: data["segments"].append(state.pop("segment")) return data def _parse_key(line, data, state, **kwargs): params = ATTRIBUTELISTPATTERN.split(line.replace(protocol.ext_x_key + ":", ""))[ 1::2 ] key = {} for param in params: name, value = param.split("=", 1) key[normalize_attribute(name)] = remove_quotes(value) state["current_key"] = key if key not in data["keys"]: data["keys"].append(key) def _parse_extinf(line, state, lineno, strict, **kwargs): chunks = line.replace(protocol.extinf + ":", "").split(",", 1) if len(chunks) == 2: duration, title = chunks elif len(chunks) == 1: if strict: raise ParseError(lineno, line) else: duration = chunks[0] title = "" if "segment" not in state: state["segment"] = {} state["segment"]["duration"] = float(duration) state["segment"]["title"] = title state["expect_segment"] = True def _parse_ts_chunk(line, data, state, **kwargs): segment = state.pop("segment") if state.get("program_date_time"): segment["program_date_time"] = state.pop("program_date_time") if state.get("current_program_date_time"): segment["current_program_date_time"] = state["current_program_date_time"] state["current_program_date_time"] += timedelta(seconds=segment["duration"]) segment["uri"] = line segment["cue_in"] = state.pop("cue_in", False) segment["cue_out"] = state.pop("cue_out", False) segment["cue_out_start"] = state.pop("cue_out_start", False) segment["cue_out_explicitly_duration"] = state.pop( "cue_out_explicitly_duration", False ) scte_op = state.get if segment["cue_out"] else state.pop segment["scte35"] = scte_op("current_cue_out_scte35", None) segment["oatcls_scte35"] = scte_op("current_cue_out_oatcls_scte35", None) segment["scte35_duration"] = scte_op("current_cue_out_duration", None) segment["scte35_elapsedtime"] = scte_op("current_cue_out_elapsedtime", None) segment["asset_metadata"] = scte_op("asset_metadata", None) segment["discontinuity"] = state.pop("discontinuity", False) if state.get("current_key"): segment["key"] = state["current_key"] else: # For unencrypted segments, the initial key would be None if None not in data["keys"]: data["keys"].append(None) if state.get("current_segment_map"): segment["init_section"] = state["current_segment_map"] segment["dateranges"] = state.pop("dateranges", None) segment["gap_tag"] = state.pop("gap", None) data["segments"].append(segment) state["expect_segment"] = False def _parse_attribute_list(prefix, line, attribute_parser, default_parser=None): params = ATTRIBUTELISTPATTERN.split(line.replace(prefix + ":", ""))[1::2] attributes = {} if not line.startswith(prefix + ":"): return attributes for param in params: param_parts = param.split("=", 1) if len(param_parts) == 1: name = "" value = param_parts[0] else: name, value = param_parts name = normalize_attribute(name) if name in attribute_parser: value = attribute_parser[name](value) elif default_parser is not None: value = default_parser(value) attributes[name] = value return attributes def _parse_stream_inf(line, data, state, **kwargs): state["expect_playlist"] = True data["is_variant"] = True data["media_sequence"] = None attribute_parser = remove_quotes_parser( "codecs", "audio", "video", "video_range", "subtitles", "pathway_id", "stable_variant_id", ) attribute_parser["program_id"] = int attribute_parser["bandwidth"] = lambda x: int(float(x)) attribute_parser["average_bandwidth"] = int attribute_parser["frame_rate"] = float attribute_parser["hdcp_level"] = str state["stream_info"] = _parse_attribute_list( protocol.ext_x_stream_inf, line, attribute_parser ) def _parse_i_frame_stream_inf(line, data, **kwargs): attribute_parser = remove_quotes_parser( "codecs", "uri", "pathway_id", "stable_variant_id" ) attribute_parser["program_id"] = int attribute_parser["bandwidth"] = int attribute_parser["average_bandwidth"] = int attribute_parser["hdcp_level"] = str iframe_stream_info = _parse_attribute_list( protocol.ext_x_i_frame_stream_inf, line, attribute_parser ) iframe_playlist = { "uri": iframe_stream_info.pop("uri"), "iframe_stream_info": iframe_stream_info, } data["iframe_playlists"].append(iframe_playlist) def _parse_image_stream_inf(line, data, **kwargs): attribute_parser = remove_quotes_parser( "codecs", "uri", "pathway_id", "stable_variant_id" ) attribute_parser["program_id"] = int attribute_parser["bandwidth"] = int attribute_parser["average_bandwidth"] = int attribute_parser["resolution"] = str image_stream_info = _parse_attribute_list( protocol.ext_x_image_stream_inf, line, attribute_parser ) image_playlist = { "uri": image_stream_info.pop("uri"), "image_stream_info": image_stream_info, } data["image_playlists"].append(image_playlist) def _parse_is_images_only(line, data, **kwargs): data["is_images_only"] = True def _parse_tiles(line, data, state, **kwargs): attribute_parser = remove_quotes_parser("uri") attribute_parser["resolution"] = str attribute_parser["layout"] = str attribute_parser["duration"] = float tiles_info = _parse_attribute_list(protocol.ext_x_tiles, line, attribute_parser) data["tiles"].append(tiles_info) def _parse_media(line, data, **kwargs): quoted = remove_quotes_parser( "uri", "group_id", "language", "assoc_language", "name", "instream_id", "characteristics", "channels", "stable_rendition_id", "thumbnails", "image", ) media = _parse_attribute_list(protocol.ext_x_media, line, quoted) data["media"].append(media) def _parse_variant_playlist(line, data, state, **kwargs): playlist = {"uri": line, "stream_info": state.pop("stream_info")} data["playlists"].append(playlist) state["expect_playlist"] = False def _parse_bitrate(state, **kwargs): if "segment" not in state: state["segment"] = {} state["segment"]["bitrate"] = _parse_simple_parameter(cast_to=int, **kwargs) def _parse_byterange(line, state, **kwargs): if "segment" not in state: state["segment"] = {} state["segment"]["byterange"] = line.replace(protocol.ext_x_byterange + ":", "") state["expect_segment"] = True def _parse_targetduration(**parse_kwargs): return _parse_simple_parameter(cast_to=int, **parse_kwargs) def _parse_media_sequence(**parse_kwargs): return _parse_simple_parameter(cast_to=int, **parse_kwargs) def _parse_discontinuity_sequence(**parse_kwargs): return _parse_simple_parameter(cast_to=int, **parse_kwargs) def _parse_program_date_time(line, state, data, **parse_kwargs): _, program_date_time = _parse_simple_parameter_raw_value( line, cast_to=cast_date_time, **parse_kwargs ) if not data.get("program_date_time"): data["program_date_time"] = program_date_time state["current_program_date_time"] = program_date_time state["program_date_time"] = program_date_time def _parse_discontinuity(state, **parse_kwargs): state["discontinuity"] = True def _parse_cue_in(state, **parse_kwargs): state["cue_in"] = True def _parse_cue_span(state, **parse_kwargs): state["cue_out"] = True def _parse_version(**parse_kwargs): return _parse_simple_parameter(cast_to=int, **parse_kwargs) def _parse_allow_cache(**parse_kwargs): return _parse_simple_parameter(cast_to=str, **parse_kwargs) def _parse_playlist_type(line, data, **kwargs): return _parse_simple_parameter(line, data) def _parse_x_map(line, data, state, **kwargs): quoted_parser = remove_quotes_parser("uri", "byterange") segment_map_info = _parse_attribute_list(protocol.ext_x_map, line, quoted_parser) state["current_segment_map"] = segment_map_info data["segment_map"].append(segment_map_info) def _parse_start(line, data, **kwargs): attribute_parser = {"time_offset": lambda x: float(x)} start_info = _parse_attribute_list(protocol.ext_x_start, line, attribute_parser) data["start"] = start_info def _parse_gap(state, **kwargs): state["gap"] = True def _parse_simple_parameter_raw_value(line, cast_to=str, normalize=False, **kwargs): param, value = line.split(":", 1) param = normalize_attribute(param.replace("#EXT-X-", "")) if normalize: value = value.strip().lower() return param, cast_to(value) def _parse_and_set_simple_parameter_raw_value( line, data, cast_to=str, normalize=False, **kwargs ): param, value = _parse_simple_parameter_raw_value(line, cast_to, normalize) data[param] = value return data[param] def _parse_simple_parameter(line, data, cast_to=str, **kwargs): return _parse_and_set_simple_parameter_raw_value(line, data, cast_to, True) def _parse_i_frames_only(data, **kwargs): data["is_i_frames_only"] = True def _parse_is_independent_segments(data, **kwargs): data["is_independent_segments"] = True def _parse_endlist(data, **kwargs): data["is_endlist"] = True def _parse_cueout_cont(line, state, **kwargs): state["cue_out"] = True elements = line.split(":", 1) if len(elements) != 2: return # EXT-X-CUE-OUT-CONT:ElapsedTime=10,Duration=60,SCTE35=... style cue_info = _parse_attribute_list( protocol.ext_x_cue_out_cont, line, remove_quotes_parser("duration", "elapsedtime", "scte35"), ) # EXT-X-CUE-OUT-CONT:2.436/120 style progress = cue_info.get("") if progress: progress_parts = progress.split("/", 1) if len(progress_parts) == 1: state["current_cue_out_duration"] = progress_parts[0] else: state["current_cue_out_elapsedtime"] = progress_parts[0] state["current_cue_out_duration"] = progress_parts[1] duration = cue_info.get("duration") if duration: state["current_cue_out_duration"] = duration scte35 = cue_info.get("scte35") if duration: state["current_cue_out_scte35"] = scte35 elapsedtime = cue_info.get("elapsedtime") if elapsedtime: state["current_cue_out_elapsedtime"] = elapsedtime def _parse_cueout(line, state, **kwargs): state["cue_out_start"] = True state["cue_out"] = True if "DURATION" in line.upper(): state["cue_out_explicitly_duration"] = True elements = line.split(":", 1) if len(elements) != 2: return cue_info = _parse_attribute_list( protocol.ext_x_cue_out, line, remove_quotes_parser("cue"), ) cue_out_scte35 = cue_info.get("cue") cue_out_duration = cue_info.get("duration") or cue_info.get("") current_cue_out_scte35 = state.get("current_cue_out_scte35") state["current_cue_out_scte35"] = cue_out_scte35 or current_cue_out_scte35 state["current_cue_out_duration"] = cue_out_duration def _parse_server_control(line, data, **kwargs): attribute_parser = { "can_block_reload": str, "hold_back": lambda x: float(x), "part_hold_back": lambda x: float(x), "can_skip_until": lambda x: float(x), "can_skip_dateranges": str, } data["server_control"] = _parse_attribute_list( protocol.ext_x_server_control, line, attribute_parser ) def _parse_part_inf(line, data, **kwargs): attribute_parser = {"part_target": lambda x: float(x)} data["part_inf"] = _parse_attribute_list( protocol.ext_x_part_inf, line, attribute_parser ) def _parse_rendition_report(line, data, **kwargs): attribute_parser = remove_quotes_parser("uri") attribute_parser["last_msn"] = int attribute_parser["last_part"] = int rendition_report = _parse_attribute_list( protocol.ext_x_rendition_report, line, attribute_parser ) data["rendition_reports"].append(rendition_report) def _parse_part(line, state, **kwargs): attribute_parser = remove_quotes_parser("uri") attribute_parser["duration"] = lambda x: float(x) attribute_parser["independent"] = str attribute_parser["gap"] = str attribute_parser["byterange"] = str part = _parse_attribute_list(protocol.ext_x_part, line, attribute_parser) # this should always be true according to spec if state.get("current_program_date_time"): part["program_date_time"] = state["current_program_date_time"] state["current_program_date_time"] += timedelta(seconds=part["duration"]) part["dateranges"] = state.pop("dateranges", None) part["gap_tag"] = state.pop("gap", None) if "segment" not in state: state["segment"] = {} segment = state["segment"] if "parts" not in segment: segment["parts"] = [] segment["parts"].append(part) def _parse_skip(line, data, **parse_kwargs): attribute_parser = remove_quotes_parser("recently_removed_dateranges") attribute_parser["skipped_segments"] = int data["skip"] = _parse_attribute_list(protocol.ext_x_skip, line, attribute_parser) def _parse_session_data(line, data, **kwargs): quoted = remove_quotes_parser("data_id", "value", "uri", "language") session_data = _parse_attribute_list(protocol.ext_x_session_data, line, quoted) data["session_data"].append(session_data) def _parse_session_key(line, data, **kwargs): params = ATTRIBUTELISTPATTERN.split( line.replace(protocol.ext_x_session_key + ":", "") )[1::2] key = {} for param in params: name, value = param.split("=", 1) key[normalize_attribute(name)] = remove_quotes(value) data["session_keys"].append(key) def _parse_preload_hint(line, data, **kwargs): attribute_parser = remove_quotes_parser("uri") attribute_parser["type"] = str attribute_parser["byterange_start"] = int attribute_parser["byterange_length"] = int data["preload_hint"] = _parse_attribute_list( protocol.ext_x_preload_hint, line, attribute_parser ) def _parse_daterange(line, state, **kwargs): attribute_parser = remove_quotes_parser("id", "class", "start_date", "end_date") attribute_parser["duration"] = float attribute_parser["planned_duration"] = float attribute_parser["end_on_next"] = str attribute_parser["scte35_cmd"] = str attribute_parser["scte35_out"] = str attribute_parser["scte35_in"] = str parsed = _parse_attribute_list(protocol.ext_x_daterange, line, attribute_parser) if "dateranges" not in state: state["dateranges"] = [] state["dateranges"].append(parsed) def _parse_content_steering(line, data, **kwargs): attribute_parser = remove_quotes_parser("server_uri", "pathway_id") data["content_steering"] = _parse_attribute_list( protocol.ext_x_content_steering, line, attribute_parser ) def _parse_oatcls_scte35(line, state, **kwargs): scte35_cue = line.split(":", 1)[1] state["current_cue_out_oatcls_scte35"] = scte35_cue state["current_cue_out_scte35"] = scte35_cue def _parse_asset(line, state, **kwargs): # EXT-X-ASSET attribute values may or may not be quoted, and need to be URL-encoded. # They are preserved as-is here to prevent loss of information. state["asset_metadata"] = _parse_attribute_list( protocol.ext_x_asset, line, {}, default_parser=str ) def string_to_lines(string): return string.strip().splitlines() def remove_quotes_parser(*attrs): return dict(zip(attrs, itertools.repeat(remove_quotes))) def remove_quotes(string): """ Remove quotes from string. Ex.: "foo" -> foo 'foo' -> foo 'foo -> 'foo """ quotes = ('"', "'") if string.startswith(quotes) and string.endswith(quotes): return string[1:-1] return string def normalize_attribute(attribute): return attribute.replace("-", "_").lower().strip() def get_segment_custom_value(state, key, default=None): """ Helper function for getting custom values for Segment Are useful with custom_tags_parser """ if "segment" not in state: return default if "custom_parser_values" not in state["segment"]: return default return state["segment"]["custom_parser_values"].get(key, default) def save_segment_custom_value(state, key, value): """ Helper function for saving custom values for Segment Are useful with custom_tags_parser """ if "segment" not in state: state["segment"] = {} if "custom_parser_values" not in state["segment"]: state["segment"]["custom_parser_values"] = {} state["segment"]["custom_parser_values"][key] = value m3u8-6.0.0/m3u8/protocol.py000066400000000000000000000033261465465410600153370ustar00rootroot00000000000000# Copyright 2014 Globo.com Player authors. All rights reserved. # Use of this source code is governed by a MIT License # license that can be found in the LICENSE file. ext_m3u = "#EXTM3U" ext_x_targetduration = "#EXT-X-TARGETDURATION" ext_x_media_sequence = "#EXT-X-MEDIA-SEQUENCE" ext_x_discontinuity_sequence = "#EXT-X-DISCONTINUITY-SEQUENCE" ext_x_program_date_time = "#EXT-X-PROGRAM-DATE-TIME" ext_x_media = "#EXT-X-MEDIA" ext_x_playlist_type = "#EXT-X-PLAYLIST-TYPE" ext_x_key = "#EXT-X-KEY" ext_x_stream_inf = "#EXT-X-STREAM-INF" ext_x_version = "#EXT-X-VERSION" ext_x_allow_cache = "#EXT-X-ALLOW-CACHE" ext_x_endlist = "#EXT-X-ENDLIST" extinf = "#EXTINF" ext_i_frames_only = "#EXT-X-I-FRAMES-ONLY" ext_x_asset = "#EXT-X-ASSET" ext_x_bitrate = "#EXT-X-BITRATE" ext_x_byterange = "#EXT-X-BYTERANGE" ext_x_i_frame_stream_inf = "#EXT-X-I-FRAME-STREAM-INF" ext_x_discontinuity = "#EXT-X-DISCONTINUITY" ext_x_cue_out = "#EXT-X-CUE-OUT" ext_x_cue_out_cont = "#EXT-X-CUE-OUT-CONT" ext_x_cue_in = "#EXT-X-CUE-IN" ext_x_cue_span = "#EXT-X-CUE-SPAN" ext_oatcls_scte35 = "#EXT-OATCLS-SCTE35" ext_is_independent_segments = "#EXT-X-INDEPENDENT-SEGMENTS" ext_x_map = "#EXT-X-MAP" ext_x_start = "#EXT-X-START" ext_x_server_control = "#EXT-X-SERVER-CONTROL" ext_x_part_inf = "#EXT-X-PART-INF" ext_x_part = "#EXT-X-PART" ext_x_rendition_report = "#EXT-X-RENDITION-REPORT" ext_x_skip = "#EXT-X-SKIP" ext_x_session_data = "#EXT-X-SESSION-DATA" ext_x_session_key = "#EXT-X-SESSION-KEY" ext_x_preload_hint = "#EXT-X-PRELOAD-HINT" ext_x_daterange = "#EXT-X-DATERANGE" ext_x_gap = "#EXT-X-GAP" ext_x_content_steering = "#EXT-X-CONTENT-STEERING" ext_x_image_stream_inf = "#EXT-X-IMAGE-STREAM-INF" ext_x_images_only = "#EXT-X-IMAGES-ONLY" ext_x_tiles = "#EXT-X-TILES" m3u8-6.0.0/m3u8/version_matching.py000066400000000000000000000017651465465410600170420ustar00rootroot00000000000000from typing import List from m3u8 import protocol from m3u8.version_matching_rules import VersionMatchingError, available_rules def get_version(file_lines: List[str]): for line in file_lines: if line.startswith(protocol.ext_x_version): version = line.split(":")[1] return float(version) return None def valid_in_all_rules( line_number: int, line: str, version: float ) -> List[VersionMatchingError]: errors = [] for rule in available_rules: validator = rule(version, line_number, line) if not validator.validate(): errors.append(validator.get_error()) return errors def validate(file_lines: List[str]) -> List[VersionMatchingError]: found_version = get_version(file_lines) if found_version is None: return [] errors = [] for number, line in enumerate(file_lines): errors_in_line = valid_in_all_rules(number, line, found_version) errors.extend(errors_in_line) return errors m3u8-6.0.0/m3u8/version_matching_rules.py000066400000000000000000000057361465465410600202560ustar00rootroot00000000000000from dataclasses import dataclass from typing import List, Type from m3u8 import protocol @dataclass class VersionMatchingError(Exception): line_number: int line: str how_to_fix: str = "Please fix the version matching error." description: str = "There is a version matching error in the file." def __str__(self): return ( "Version matching error found in the file when parsing in strict mode.\n" f"Line {self.line_number}: {self.description}\n" f"Line content: {self.line}\n" f"How to fix: {self.how_to_fix}" "\n" ) class VersionMatchRuleBase: description: str = "" how_to_fix: str = "" version: float line_number: int line: str def __init__(self, version: float, line_number: int, line: str) -> None: self.version = version self.line_number = line_number self.line = line def validate(self): raise NotImplementedError def get_error(self): return VersionMatchingError( line_number=self.line_number, line=self.line, description=self.description, how_to_fix=self.how_to_fix, ) class ValidIVInEXTXKEY(VersionMatchRuleBase): description = ( "You must use at least protocol version 2 if you have IV in EXT-X-KEY." ) how_to_fix = "Change the protocol version to 2 or higher." def validate(self): if protocol.ext_x_key not in self.line: return True if "IV" in self.line: return self.version >= 2 return True class ValidFloatingPointEXTINF(VersionMatchRuleBase): description = "You must use at least protocol version 3 if you have floating point EXTINF duration values." how_to_fix = "Change the protocol version to 3 or higher." def validate(self): if protocol.extinf not in self.line: return True chunks = self.line.replace(protocol.extinf + ":", "").split(",", 1) duration = chunks[0] def is_number(value: str): try: float(value) return True except ValueError: return False def is_floating_number(value: str): return is_number(value) and "." in value if is_floating_number(duration): return self.version >= 3 return is_number(duration) class ValidEXTXBYTERANGEOrEXTXIFRAMESONLY(VersionMatchRuleBase): description = "You must use at least protocol version 4 if you have EXT-X-BYTERANGE or EXT-X-IFRAME-ONLY." how_to_fix = "Change the protocol version to 4 or higher." def validate(self): if ( protocol.ext_x_byterange not in self.line and protocol.ext_i_frames_only not in self.line ): return True return self.version >= 4 available_rules: List[Type[VersionMatchRuleBase]] = [ ValidIVInEXTXKEY, ValidFloatingPointEXTINF, ValidEXTXBYTERANGEOrEXTXIFRAMESONLY, ] m3u8-6.0.0/requirements-dev.txt000066400000000000000000000004311465465410600163620ustar00rootroot00000000000000-r requirements.txt bottle pytest # pytest-cov 2.6.0 has increased the version requirement # for the coverage package from >=3.7.1 to >=4.4, # which is in conflict with the version requirement # defined by the python-coveralls package for coverage==4.0.3 pytest-cov>=2.4.0,<2.6 m3u8-6.0.0/requirements.txt000066400000000000000000000000721465465410600156070ustar00rootroot00000000000000backports-datetime-fromisoformat; python_version < '3.11' m3u8-6.0.0/runtests000077500000000000000000000011311465465410600141350ustar00rootroot00000000000000#!/bin/bash test_server_stdout=tests/server.stdout function install_deps { pip install -r requirements-dev.txt } function start_server { rm -f ${test_server_stdout} python tests/m3u8server.py >${test_server_stdout} 2>&1 & } function stop_server { pkill -9 -f m3u8server.py echo "Test server stdout on ${test_server_stdout}" } function run { PYTHONPATH=. py.test -vv --cov-report term-missing --cov m3u8 tests/ } function main { install_deps start_server run retval=$? stop_server return "$retval" } if [ -z "$1" ]; then main else "$@" fi m3u8-6.0.0/setup.py000066400000000000000000000013001465465410600140300ustar00rootroot00000000000000from os.path import abspath, dirname, exists, join from setuptools import setup long_description = None if exists("README.md"): with open("README.md") as file: long_description = file.read() install_reqs = [ req for req in open(abspath(join(dirname(__file__), "requirements.txt"))) ] setup( name="m3u8", author="Globo.com", version="6.0.0", license="MIT", zip_safe=False, include_package_data=True, install_requires=install_reqs, packages=["m3u8"], url="https://github.com/globocom/m3u8", description="Python m3u8 parser", long_description=long_description, long_description_content_type="text/markdown", python_requires=">=3.7", ) m3u8-6.0.0/tests/000077500000000000000000000000001465465410600134665ustar00rootroot00000000000000m3u8-6.0.0/tests/invalid_versioned_playlists.py000066400000000000000000000013541465465410600216530ustar00rootroot00000000000000# Should have at least version 2 if you have IV in EXT-X-KEY. M3U8_RULE_IV = """ #EXTM3U #EXT-X-VERSION: 1 #EXT-X-KEY: METHOD=AES-128, IV=0x123456789ABCDEF0123456789ABCDEF0, URI="https://example.com/key.bin" #EXT-X-TARGETDURATION: 10 #EXTINF: 10.0, https://example.com/segment1.ts """ # Should have at least version 3 if you have floating point EXTINF duration values. M3U8_RULE_FLOATING_POINT = """ #EXTM3U #EXT-X-VERSION: 2 #EXT-X-TARGETDURATION: 10 #EXTINF: 10.5, https://example.com/segment1.ts """ # Should have at least version 4 if you have EXT-X-BYTERANGE or EXT-X-IFRAME-ONLY. M3U8_RULE_BYTE_RANGE = """ #EXTM3U #EXT-X-VERSION: 3 #EXT-X-BYTERANGE: 200000@1000 #EXT-X-TARGETDURATION: 10 #EXTINF: 10.0, https://example.com/segment1.ts """ m3u8-6.0.0/tests/m3u8server.py000066400000000000000000000022351465465410600160650ustar00rootroot00000000000000# Copyright 2014 Globo.com Player authors. All rights reserved. # Use of this source code is governed by a MIT License # license that can be found in the LICENSE file. # Test server to deliver stubed M3U8s from os.path import dirname, abspath, join from bottle import route, run, response, redirect import bottle import time playlists = abspath(join(dirname(__file__), "playlists")) @route("/path/to/redirect_me") def redirect_route(): redirect("/simple.m3u8") @route("/simple.m3u8") def simple(): response.set_header("Content-Type", "application/vnd.apple.mpegurl") return m3u8_file("simple-playlist.m3u8") @route("/timeout_simple.m3u8") def timeout_simple(): time.sleep(5) response.set_header("Content-Type", "application/vnd.apple.mpegurl") return m3u8_file("simple-playlist.m3u8") @route("/path/to/relative-playlist.m3u8") def relative_playlist(): response.set_header("Content-Type", "application/vnd.apple.mpegurl") return m3u8_file("relative-playlist.m3u8") def m3u8_file(filename): with open(join(playlists, filename)) as fileobj: return fileobj.read().strip() bottle.debug = True run(host="localhost", port=8112) m3u8-6.0.0/tests/playlists.py000077500000000000000000001274161465465410600161020ustar00rootroot00000000000000# Copyright 2014 Globo.com Player authors. All rights reserved. # Use of this source code is governed by a MIT License # license that can be found in the LICENSE file. from os.path import abspath, dirname, join TEST_HOST = "http://localhost:8112" SIMPLE_PLAYLIST = """ #EXTM3U #EXT-X-TARGETDURATION:5220 #EXTINF:5220, http://media.example.com/entire.ts #EXT-X-ENDLIST """ SIMPLE_PLAYLIST_WITH_ZERO_DURATION = """ #EXTM3U #EXT-X-TARGETDURATION:5220 #EXTINF:0, http://media.example.com/entire1.ts #EXTINF:5220, http://media.example.com/entire2.ts #EXT-X-ENDLIST """ SIMPLE_PLAYLIST_WITH_VERY_SHORT_DURATION = """ #EXTM3U #EXT-X-TARGETDURATION:5220 #EXTINF:5220, http://media.example.com/entire1.ts #EXTINF:5218.5, http://media.example.com/entire2.ts #EXTINF:0.000011, http://media.example.com/entire3.ts #EXT-X-ENDLIST """ SIMPLE_PLAYLIST_WITH_START_NEGATIVE_OFFSET = """ #EXTM3U #EXT-X-TARGETDURATION:5220 #EXT-X-START:TIME-OFFSET=-2.0 #EXTINF:5220, http://media.example.com/entire.ts #EXT-X-ENDLIST """ SIMPLE_PLAYLIST_WITH_START_PRECISE = """ #EXTM3U #EXT-X-TARGETDURATION:5220 #EXT-X-START:TIME-OFFSET=10.5,PRECISE=YES #EXTINF:5220, http://media.example.com/entire.ts #EXT-X-ENDLIST """ SIMPLE_PLAYLIST_FILENAME = abspath( join(dirname(__file__), "playlists/simple-playlist.m3u8") ) SIMPLE_PLAYLIST_URI = TEST_HOST + "/simple.m3u8" TIMEOUT_SIMPLE_PLAYLIST_URI = TEST_HOST + "/timeout_simple.m3u8" REDIRECT_PLAYLIST_URI = TEST_HOST + "/path/to/redirect_me" PLAYLIST_WITH_NON_INTEGER_DURATION = """ #EXTM3U #EXT-X-TARGETDURATION:5221 #EXTINF:5220.5, http://media.example.com/entire.ts """ SLIDING_WINDOW_PLAYLIST = """ #EXTM3U #EXT-X-TARGETDURATION:8 #EXT-X-MEDIA-SEQUENCE:2680 #EXTINF:8, https://priv.example.com/fileSequence2680.ts #EXTINF:8, https://priv.example.com/fileSequence2681.ts #EXTINF:8, https://priv.example.com/fileSequence2682.ts """ PLAYLIST_WITH_ENCRYPTED_SEGMENTS = """ #EXTM3U #EXT-X-MEDIA-SEQUENCE:7794 #EXT-X-TARGETDURATION:15 #EXT-X-KEY:METHOD=AES-128,URI="https://priv.example.com/key.php?r=52" #EXTINF:15, http://media.example.com/fileSequence52-1.ts #EXTINF:15, http://media.example.com/fileSequence52-2.ts #EXTINF:15, http://media.example.com/fileSequence52-3.ts """ PLAYLIST_WITH_TAG_MEDIA_READY = """#EXTM3U #EXT-X-VERSION:3 #EXT-X-TARGETDURATION:6 #EXT-X-MEDIA-SEQUENCE:1 #EXT-X-MEDIA-READY:7f659f6f09bce196d7 #EXT-X-KEY:METHOD=AES-128,URI="[KEY]",IV=[IV] #EXTINF:6.0, https://cdn.example.com/vod/hash:XXX/file.mp4/media-1.ts #EXTINF:6.28, https://cdn.example.com/vod/hash:XXX/file.mp4/media-2.ts """ PLAYLIST_WITH_SESSION_ENCRYPTED_SEGMENTS = """ #EXTM3U #EXT-X-MEDIA-SEQUENCE:7794 #EXT-X-TARGETDURATION:15 #EXT-X-SESSION-KEY:METHOD=AES-128,URI="https://priv.example.com/key.php?r=52" #EXTINF:15, http://media.example.com/fileSequence52-1.ts #EXTINF:15, http://media.example.com/fileSequence52-2.ts #EXTINF:15, http://media.example.com/fileSequence52-3.ts """ VARIANT_PLAYLIST = """ #EXTM3U #EXT-X-STREAM-INF:PROGRAM-ID=1, BANDWIDTH=1280000 http://example.com/low.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=2560000 http://example.com/mid.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=7680000 http://example.com/hi.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=65000,CODECS="mp4a.40.5,avc1.42801e" http://example.com/audio-only.m3u8 """ VARIANT_PLAYLIST_WITH_CC_SUBS_AND_AUDIO = """ #EXTM3U #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=7680000,CLOSED-CAPTIONS="cc",SUBTITLES="sub",AUDIO="aud" http://example.com/with-cc-hi.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=65000,CLOSED-CAPTIONS="cc",SUBTITLES="sub",AUDIO="aud" http://example.com/with-cc-low.m3u8 """ VARIANT_PLAYLIST_WITH_NONE_CC_AND_AUDIO = """ #EXTM3U #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=7680000,CLOSED-CAPTIONS=NONE,SUBTITLES="sub",AUDIO="aud" http://example.com/with-cc-hi.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=65000,CLOSED-CAPTIONS=NONE,SUBTITLES="sub",AUDIO="aud" http://example.com/with-cc-low.m3u8 """ VARIANT_PLAYLIST_WITH_VIDEO_CC_SUBS_AND_AUDIO = """ #EXTM3U #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=7680000,CLOSED-CAPTIONS="cc",SUBTITLES="sub",AUDIO="aud",VIDEO="vid" http://example.com/with-everything-hi.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=65000,CLOSED-CAPTIONS="cc",SUBTITLES="sub",AUDIO="aud",VIDEO="vid" http://example.com/with-everything-low.m3u8 """ VARIANT_PLAYLIST_WITH_AVERAGE_BANDWIDTH = """ #EXTM3U #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1280000,AVERAGE-BANDWIDTH=1252345 http://example.com/low.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=2560000,AVERAGE-BANDWIDTH=2466570 http://example.com/mid.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=7680000,AVERAGE-BANDWIDTH=7560423 http://example.com/hi.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=65000,AVERAGE-BANDWIDTH=63005,CODECS="mp4a.40.5,avc1.42801e" http://example.com/audio-only.m3u8 """ VARIANT_PLAYLIST_WITH_VIDEO_RANGE = """ #EXTM3U #EXT-X-STREAM-INF:PROGRAM-ID=1,VIDEO-RANGE=SDR http://example.com/sdr.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,VIDEO-RANGE=PQ http://example.com/hdr.m3u8 """ VARIANT_PLAYLIST_WITH_HDCP_LEVEL = """ #EXTM3U #EXT-X-STREAM-INF:PROGRAM-ID=1,HDCP-LEVEL=NONE http://example.com/none.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,HDCP-LEVEL=TYPE-0 http://example.com/type0.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,HDCP-LEVEL=TYPE-1 http://example.com/type1.m3u8 """ VARIANT_PLAYLIST_WITH_BANDWIDTH_FLOAT = """ #EXTM3U #EXT-X-STREAM-INF:PROGRAM-ID=1, BANDWIDTH=1280000.0 http://example.com/low.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=2560000.4 http://example.com/mid.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=7680000.6 http://example.com/hi.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=65000,CODECS="mp4a.40.5,avc1.42801e" http://example.com/audio-only.m3u8 """ VARIANT_PLAYLIST_WITH_IFRAME_PLAYLISTS = """ #EXTM3U #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=800000,RESOLUTION=624x352,CODECS="avc1.4d001f, mp4a.40.5" video-800k.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1200000,CODECS="avc1.4d001f, mp4a.40.5" video-1200k.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=400000,CODECS="avc1.4d001f, mp4a.40.5" video-400k.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=150000,CODECS="avc1.4d001f, mp4a.40.5" video-150k.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=64000,CODECS="mp4a.40.5" video-64k.m3u8 #EXT-X-I-FRAME-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=151288,RESOLUTION=624x352,CODECS="avc1.4d001f",URI="video-800k-iframes.m3u8" #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=193350,CODECS="avc1.4d001f",URI="video-1200k-iframes.m3u8" #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=83598,CODECS="avc1.4d001f",URI="video-400k-iframes.m3u8" #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=38775,CODECS="avc1.4d001f",URI="video-150k-iframes.m3u8" """ VARIANT_PLAYLIST_WITH_ALT_IFRAME_PLAYLISTS_LAYOUT = """ #EXTM3U #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=800000,RESOLUTION=624x352,CODECS="avc1.4d001f, mp4a.40.5" video-800k.m3u8 #EXT-X-I-FRAME-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=151288,RESOLUTION=624x352,CODECS="avc1.4d001f",URI="video-800k-iframes.m3u8" #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1200000,CODECS="avc1.4d001f, mp4a.40.5" video-1200k.m3u8 #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=193350,CODECS="avc1.4d001f",URI="video-1200k-iframes.m3u8" #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=400000,CODECS="avc1.4d001f, mp4a.40.5" video-400k.m3u8 #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=83598,CODECS="avc1.4d001f",URI="video-400k-iframes.m3u8" #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=150000,CODECS="avc1.4d001f, mp4a.40.5" video-150k.m3u8 #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=38775,CODECS="avc1.4d001f",URI="video-150k-iframes.m3u8" #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=64000,CODECS="mp4a.40.5" video-64k.m3u8 """ VARIANT_PLAYLIST_WITH_REQ_VIDEO_LAYOUT = """ #EXTM3U #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=7680000,CLOSED-CAPTIONS="cc",SUBTITLES="sub",AUDIO="aud",VIDEO="vid",REQ-VIDEO-LAYOUT="CH-STEREO" http://example.com/with-everything-hi.m3u8 """ IFRAME_PLAYLIST = """ #EXTM3U #EXT-X-VERSION:4 #EXT-X-TARGETDURATION:10 #EXT-X-PLAYLIST-TYPE:VOD #EXT-X-I-FRAMES-ONLY #EXTINF:4.12, #EXT-X-BYTERANGE:9400@376 segment1.ts #EXTINF:3.56, #EXT-X-BYTERANGE:7144@47000 segment1.ts #EXTINF:3.82, #EXT-X-BYTERANGE:10340@1880 segment2.ts #EXT-X-ENDLIST """ # reversing byterange and extinf from IFRAME. IFRAME_PLAYLIST2 = """ #EXTM3U #EXT-X-VERSION:4 #EXT-X-TARGETDURATION:10 #EXT-X-PLAYLIST-TYPE:VOD #EXT-X-I-FRAMES-ONLY #EXT-X-BYTERANGE:9400@376 #EXTINF:4.12, segment1.ts #EXT-X-BYTERANGE:7144@47000 #EXTINF:3.56, segment1.ts #EXT-X-BYTERANGE:10340@1880 #EXTINF:3.82, segment2.ts #EXT-X-ENDLIST """ PLAYLIST_USING_BYTERANGES = """ #EXTM3U #EXT-X-VERSION:4 #EXT-X-TARGETDURATION:11 #EXTINF:10, #EXT-X-BYTERANGE:76242@0 segment.ts #EXTINF:10, #EXT-X-BYTERANGE:83442@762421 segment.ts #EXTINF:10, #EXT-X-BYTERANGE:69864@834421 segment.ts #EXT-X-ENDLIST """ PLAYLIST_WITH_ENCRYPTED_SEGMENTS_AND_IV = """ #EXTM3U #EXT-X-MEDIA-SEQUENCE:82400 #EXT-X-ALLOW-CACHE:NO #EXT-X-VERSION:2 #EXT-X-KEY:METHOD=AES-128,URI="/hls-key/key.bin", IV=0X10ef8f758ca555115584bb5b3c687f52 #EXT-X-TARGETDURATION:8 #EXTINF:8, ../../../../hls/streamNum82400.ts #EXTINF:8, ../../../../hls/streamNum82401.ts #EXTINF:8, ../../../../hls/streamNum82402.ts #EXTINF:8, ../../../../hls/streamNum82403.ts #EXTINF:8, ../../../../hls/streamNum82404.ts #EXTINF:8, ../../../../hls/streamNum82405.ts """ PLAYLIST_WITH_SESSION_ENCRYPTED_SEGMENTS_AND_IV = """ #EXTM3U #EXT-X-MEDIA-SEQUENCE:82400 #EXT-X-ALLOW-CACHE:NO #EXT-X-VERSION:2 #EXT-X-SESSION-KEY:METHOD=AES-128,URI="/hls-key/key.bin", IV=0X10ef8f758ca555115584bb5b3c687f52 #EXT-X-TARGETDURATION:8 #EXTINF:8, ../../../../hls/streamNum82400.ts #EXTINF:8, ../../../../hls/streamNum82401.ts #EXTINF:8, ../../../../hls/streamNum82402.ts #EXTINF:8, ../../../../hls/streamNum82403.ts #EXTINF:8, ../../../../hls/streamNum82404.ts #EXTINF:8, ../../../../hls/streamNum82405.ts """ PLAYLIST_WITH_ENCRYPTED_SEGMENTS_AND_IV_SORTED = """ #EXTM3U #EXT-X-MEDIA-SEQUENCE:82400 #EXT-X-ALLOW-CACHE:NO #EXT-X-VERSION:2 #EXT-X-TARGETDURATION:8 #EXT-X-KEY:METHOD=AES-128,URI="/hls-key/key.bin", IV=0X10ef8f758ca555115584bb5b3c687f52 #EXTINF:8, ../../../../hls/streamNum82400.ts #EXTINF:8, ../../../../hls/streamNum82401.ts #EXTINF:8, ../../../../hls/streamNum82402.ts #EXTINF:8, ../../../../hls/streamNum82403.ts #EXTINF:8, ../../../../hls/streamNum82404.ts #EXTINF:8, ../../../../hls/streamNum82405.ts """ PLAYLIST_WITH_SESSION_ENCRYPTED_SEGMENTS_AND_IV_SORTED = """ #EXTM3U #EXT-X-MEDIA-SEQUENCE:82400 #EXT-X-ALLOW-CACHE:NO #EXT-X-VERSION:2 #EXT-X-TARGETDURATION:8 #EXT-X-SESSION-KEY:METHOD=AES-128,URI="/hls-key/key.bin", IV=0X10ef8f758ca555115584bb5b3c687f52 #EXTINF:8, ../../../../hls/streamNum82400.ts #EXTINF:8, ../../../../hls/streamNum82401.ts #EXTINF:8, ../../../../hls/streamNum82402.ts #EXTINF:8, ../../../../hls/streamNum82403.ts #EXTINF:8, ../../../../hls/streamNum82404.ts #EXTINF:8, ../../../../hls/streamNum82405.ts """ PLAYLIST_WITH_ENCRYPTED_SEGMENTS_AND_IV_WITH_MULTIPLE_KEYS = """ #EXTM3U #EXT-X-MEDIA-SEQUENCE:82400 #EXT-X-ALLOW-CACHE:NO #EXT-X-VERSION:2 #EXT-X-KEY:METHOD=AES-128,URI="/hls-key/key.bin",IV=0X10ef8f758ca555115584bb5b3c687f52 #EXT-X-TARGETDURATION:8 #EXTINF:8, ../../../../hls/streamNum82400.ts #EXTINF:8, ../../../../hls/streamNum82401.ts #EXTINF:8, ../../../../hls/streamNum82402.ts #EXTINF:8, ../../../../hls/streamNum82403.ts #EXT-X-KEY:METHOD=AES-128,URI="/hls-key/key2.bin",IV=0Xcafe8f758ca555115584bb5b3c687f52 #EXTINF:8, ../../../../hls/streamNum82404.ts #EXTINF:8, ../../../../hls/streamNum82405.ts """ PLAYLIST_WITH_ENCRYPTED_SEGMENTS_AND_IV_WITH_MULTIPLE_KEYS_SORTED = """ #EXTM3U #EXT-X-MEDIA-SEQUENCE:82400 #EXT-X-ALLOW-CACHE:NO #EXT-X-VERSION:2 #EXT-X-TARGETDURATION:8 #EXT-X-KEY:METHOD=AES-128,URI="/hls-key/key.bin",IV=0X10ef8f758ca555115584bb5b3c687f52 #EXTINF:8, ../../../../hls/streamNum82400.ts #EXTINF:8, ../../../../hls/streamNum82401.ts #EXTINF:8, ../../../../hls/streamNum82402.ts #EXTINF:8, ../../../../hls/streamNum82403.ts #EXT-X-KEY:METHOD=AES-128,URI="/hls-key/key2.bin",IV=0Xcafe8f758ca555115584bb5b3c687f52 #EXTINF:8, ../../../../hls/streamNum82404.ts #EXTINF:8, ../../../../hls/streamNum82405.ts """ PLAYLIST_WITH_MULTIPLE_KEYS_UNENCRYPTED_AND_ENCRYPTED = """ #EXTM3U #EXT-X-MEDIA-SEQUENCE:82400 #EXT-X-ALLOW-CACHE:NO #EXT-X-VERSION:2 #EXT-X-TARGETDURATION:8 #EXTINF:8, ../../../../hls/streamNum82400.ts #EXTINF:8, ../../../../hls/streamNum82401.ts #EXT-X-KEY:METHOD=AES-128,URI="/hls-key/key.bin",IV=0X10ef8f758ca555115584bb5b3c687f52 #EXTINF:8, ../../../../hls/streamNum82400.ts #EXTINF:8, ../../../../hls/streamNum82401.ts #EXTINF:8, ../../../../hls/streamNum82402.ts #EXTINF:8, ../../../../hls/streamNum82403.ts #EXT-X-KEY:METHOD=AES-128,URI="/hls-key/key2.bin",IV=0Xcafe8f758ca555115584bb5b3c687f52 #EXTINF:8, ../../../../hls/streamNum82404.ts #EXTINF:8, ../../../../hls/streamNum82405.ts """ PLAYLIST_WITH_MULTIPLE_KEYS_UNENCRYPTED_AND_ENCRYPTED_UPDATED = """ #EXTM3U #EXT-X-MEDIA-SEQUENCE:82400 #EXT-X-ALLOW-CACHE:NO #EXT-X-VERSION:2 #EXT-X-TARGETDURATION:8 #EXT-X-KEY:METHOD=AES-128,URI="/hls-key/key0.bin",IV=0Xcafe8f758ca555115584bb5b3c687f52 #EXTINF:8, ../../../../hls/streamNum82400.ts #EXTINF:8, ../../../../hls/streamNum82401.ts #EXT-X-KEY:METHOD=AES-128,URI="/hls-key/key.bin",IV=0X10ef8f758ca555115584bb5b3c687f52 #EXTINF:8, ../../../../hls/streamNum82400.ts #EXTINF:8, ../../../../hls/streamNum82401.ts #EXTINF:8, ../../../../hls/streamNum82402.ts #EXTINF:8, ../../../../hls/streamNum82403.ts #EXT-X-KEY:METHOD=AES-128,URI="/hls-key/key2.bin",IV=0Xcafe8f758ca555115584bb5b3c687f52 #EXTINF:8, ../../../../hls/streamNum82404.ts #EXTINF:8, ../../../../hls/streamNum82405.ts """ PLAYLIST_WITH_MULTIPLE_KEYS_UNENCRYPTED_AND_ENCRYPTED_NONE = """ #EXTM3U #EXT-X-MEDIA-SEQUENCE:82400 #EXT-X-ALLOW-CACHE:NO #EXT-X-VERSION:2 #EXT-X-TARGETDURATION:8 #EXTINF:8, ../../../../hls/streamNum82400.ts #EXTINF:8, ../../../../hls/streamNum82401.ts #EXT-X-KEY:METHOD=AES-128,URI="/hls-key/key.bin",IV=0X10ef8f758ca555115584bb5b3c687f52 #EXTINF:8, ../../../../hls/streamNum82400.ts #EXTINF:8, ../../../../hls/streamNum82401.ts #EXTINF:8, ../../../../hls/streamNum82402.ts #EXTINF:8, ../../../../hls/streamNum82403.ts #EXT-X-KEY:METHOD=NONE,URI="" #EXTINF:8, ../../../../hls/streamNum82404.ts #EXTINF:8, ../../../../hls/streamNum82405.ts #EXT-X-KEY:METHOD=AES-128,URI="/hls-key/key2.bin",IV=0Xcafe8f758ca555115584bb5b3c687f52 #EXTINF:8, ../../../../hls/streamNum82404.ts #EXTINF:8, ../../../../hls/streamNum82405.ts """ PLAYLIST_WITH_MULTIPLE_KEYS_UNENCRYPTED_AND_ENCRYPTED_NONE_AND_NO_URI_ATTR = """ #EXTM3U #EXT-X-MEDIA-SEQUENCE:82400 #EXT-X-ALLOW-CACHE:NO #EXT-X-VERSION:2 #EXT-X-TARGETDURATION:8 #EXTINF:8, ../../../../hls/streamNum82400.ts #EXTINF:8, ../../../../hls/streamNum82401.ts #EXT-X-KEY:METHOD=AES-128,URI="/hls-key/key.bin",IV=0X10ef8f758ca555115584bb5b3c687f52 #EXTINF:8, ../../../../hls/streamNum82400.ts #EXTINF:8, ../../../../hls/streamNum82401.ts #EXTINF:8, ../../../../hls/streamNum82402.ts #EXTINF:8, ../../../../hls/streamNum82403.ts #EXT-X-KEY:METHOD=NONE #EXTINF:8, ../../../../hls/streamNum82404.ts #EXTINF:8, ../../../../hls/streamNum82405.ts #EXT-X-KEY:METHOD=AES-128,URI="/hls-key/key2.bin",IV=0Xcafe8f758ca555115584bb5b3c687f52 #EXTINF:8, ../../../../hls/streamNum82404.ts #EXTINF:8, ../../../../hls/streamNum82405.ts """ PLAYLIST_WITH_KEYFORMAT_AND_KEYFORMATVERSIONS = """#EXTM3U #EXT-X-VERSION:5 #EXT-X-TARGETDURATION:8 #EXT-X-KEY:METHOD=SAMPLE-AES,URI="skd://someuri",KEYFORMAT="com.apple.streamingkeydelivery",KEYFORMATVERSIONS="1" #EXTINF:8, segment.ts """ SIMPLE_PLAYLIST_WITH_QUOTED_TITLE = """ #EXTM3U #EXT-X-TARGETDURATION:5220 #EXTINF:5220,"A sample title" http://media.example.com/entire.ts #EXT-X-ENDLIST """ SIMPLE_PLAYLIST_WITH_UNQUOTED_TITLE = """ #EXTM3U #EXT-X-TARGETDURATION:5220 #EXTINF:5220,A sample unquoted title http://media.example.com/entire.ts #EXT-X-ENDLIST """ SIMPLE_PLAYLIST_WITH_RESOLUTION = """ #EXTM3U #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=445000,RESOLUTION=512x288,CODECS="avc1.77.30, mp4a.40.5" index_0_av.m3u8?e=b471643725c47acd #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=45000,CODECS="mp4a.40.5" index_0_a.m3u8?e=b471643725c47acd """ SIMPLE_PLAYLIST_WITH_VOD_PLAYLIST_TYPE = """ #EXTM3U #EXT-X-PLAYLIST-TYPE:VOD #EXTINF:180.00000, some_video.ts #EXT-X-ENDLIST """ SIMPLE_PLAYLIST_WITH_INDEPENDENT_SEGMENTS = """ #EXTM3U #EXT-X-INDEPENDENT-SEGMENTS #EXTINF:180.00000, some_video.ts #EXT-X-ENDLIST """ SIMPLE_PLAYLIST_WITH_EVENT_PLAYLIST_TYPE = """ #EXTM3U #EXT-X-PLAYLIST-TYPE:EVENT #EXTINF:180.00000, some_video.ts #EXT-X-ENDLIST """ SIMPLE_PLAYLIST_WITH_PROGRAM_DATE_TIME = """ #EXTM3U #EXT-X-MEDIA-SEQUENCE:50116 #EXT-X-TARGETDURATION:3 #EXT-X-PROGRAM-DATE-TIME:2014-08-13T13:36:33+00:00 #EXTINF:3, g_50116.ts #EXTINF:3, g_50117.ts #EXTINF:3, g_50118.ts #EXTINF:3, g_50119.ts #EXTINF:3, g_50120.ts #EXTINF:3, g_50121.ts #EXTINF:3, g_50122.ts #EXTINF:3, g_50123.ts """ # The playlist fails if parsed as strict, but otherwise passes SIMPLE_PLAYLIST_MESSY = """ #EXTM3U #EXT-X-TARGETDURATION:5220 #EXTINF:5220, http://media.example.com/entire.ts JUNK #EXT-X-ENDLIST """ # The playlist fails if parsed as strict, but otherwise passes SIMPLE_PLAYLIST_TITLE_COMMA = """ #EXTM3U #EXTINF:5220,Title with a comma, end http://media.example.com/entire.ts #EXT-X-ENDLIST """ # Playlist with EXTINF record not ending with comma SIMPLE_PLAYLIST_COMMALESS_EXTINF = """ #EXTM3U #EXT-X-TARGETDURATION:5220 #EXTINF:5220 http://media.example.com/entire.ts #EXT-X-ENDLIST """ DISCONTINUITY_PLAYLIST_WITH_PROGRAM_DATE_TIME = """ #EXTM3U #EXT-X-MEDIA-SEQUENCE:50116 #EXT-X-TARGETDURATION:3 #EXT-X-PROGRAM-DATE-TIME:2014-08-13T13:36:33.000+00:00 #EXTINF:3, g_50116.ts #EXTINF:3, g_50117.ts #EXTINF:3, g_50118.ts #EXTINF:3, g_50119.ts #EXTINF:3, g_50120.ts #EXT-X-DISCONTINUITY #EXT-X-PROGRAM-DATE-TIME:2014-08-13T13:36:55.000+00:00 #EXTINF:3, g_50121.ts #EXTINF:3, g_50122.ts #EXTINF:3, g_50123.ts """ PLAYLIST_WITH_PROGRAM_DATE_TIME_WITHOUT_DISCONTINUITY = """ #EXTM3U #EXT-X-VERSION:3 #EXT-X-TARGETDURATION:6 #EXT-X-PLAYLIST-TYPE:EVENT #EXT-X-MEDIA-SEQUENCE:50 #EXT-X-PROGRAM-DATE-TIME:2019-06-10T00:05:00.000Z #EXTINF:6.000, manifest_1_50.ts?m=1559946393 #EXT-X-PROGRAM-DATE-TIME:2019-06-10T00:05:06.000Z #EXTINF:6.000, manifest_1_51.ts?m=1559946393 #EXT-X-PROGRAM-DATE-TIME:2019-06-10T00:05:12.000Z #EXTINF:6.000, manifest_1_52.ts?m=1559946393 #EXT-X-ENDLIST """ CUE_OUT_PLAYLIST = """ #EXTM3U #EXT-X-TARGETDURATION:10 #EXT-X-MEDIA-SEQUENCE:143474331 #EXT-X-VERSION:3 #EXTINF:10, #EXT-X-PROGRAM-DATE-TIME:2015-06-18T23:22:10Z 1432451707508/ts/71737/sequence143474338.ts #EXT-X-CUE-OUT-CONT #EXTINF:10, #EXT-X-PROGRAM-DATE-TIME:2015-06-18T23:22:20Z 1432451707508/ts/71737/sequence143474339.ts #EXT-X-CUE-OUT-CONT #EXTINF:10, #EXT-X-PROGRAM-DATE-TIME:2015-06-18T23:22:30Z 1432451707508/ts/71737/sequence143474340.ts #EXT-OATCLS-SCTE35:/DA5AAAAAAAA/wCABQb+aDhDgAAjAhdDVUVJQAAAV3+fCAgAAAAAIxDjqDUCAAAIQ1VFSQAAAABSV+PX #EXT-X-CUE-IN #EXTINF:10, #EXT-X-PROGRAM-DATE-TIME:2015-06-18T23:22:40Z 1432451707508/ts/71737/sequence143474341.ts """ CUE_OUT_ELEMENTAL_PLAYLIST = """ #EXTM3U #EXT-X-VERSION:3 #EXT-X-TARGETDURATION:10 #EXT-X-MEDIA-SEQUENCE:47224 #EXTINF:10.000, master2500_47224.ts #EXTINF:10.000, master2500_47225.ts #EXTINF:2.040, master2500_47226.ts #EXT-OATCLS-SCTE35:/DAlAAAAAAAAAP/wFAUAAAABf+//wpiQkv4ARKogAAEBAQAAQ6sodg== #EXT-X-ASSET:GENRE=CV,CAID=12345678,EPISODE="Episode%20Name%20Date",SEASON="Season%20Name%20and%20Number",SERIES="Series%2520Name" #EXT-X-CUE-OUT:50.000 #EXTINF:7.960, master2500_47227.ts #EXT-X-CUE-OUT-CONT:ElapsedTime=7.960,Duration=50,SCTE35=/DAlAAAAAAAAAP/wFAUAAAABf+//wpiQkv4ARKogAAEBAQAAQ6sodg== #EXTINF:10.000, master2500_47228.ts #EXT-X-CUE-OUT-CONT:ElapsedTime=17.960,Duration=50,SCTE35=/DAlAAAAAAAAAP/wFAUAAAABf+//wpiQkv4ARKogAAEBAQAAQ6sodg== #EXTINF:10.000, master2500_47229.ts #EXT-X-CUE-OUT-CONT:ElapsedTime=27.960,Duration=50,SCTE35=/DAlAAAAAAAAAP/wFAUAAAABf+//wpiQkv4ARKogAAEBAQAAQ6sodg== #EXTINF:10.000, master2500_47230.ts #EXT-X-CUE-OUT-CONT:ElapsedTime=37.960,Duration=50,SCTE35=/DAlAAAAAAAAAP/wFAUAAAABf+//wpiQkv4ARKogAAEBAQAAQ6sodg== #EXTINF:10.000, master2500_47231.ts #EXT-X-CUE-OUT-CONT:ElapsedTime=47.960,Duration=50,SCTE35=/DAlAAAAAAAAAP/wFAUAAAABf+//wpiQkv4ARKogAAEBAQAAQ6sodg== #EXTINF:2.040, master2500_47232.ts #EXT-X-CUE-IN #EXTINF:7.960, master2500_47233.ts #EXTINF:7.960, master2500_47234.ts """ OATCLS_ELEMENTAL_PLAYLIST = """ #EXTM3U #EXT-X-VERSION:3 #EXT-X-TARGETDURATION:8 #EXT-X-MEDIA-SEQUENCE:266918 #EXTINF:6.00600, playlist_192k_266918.ts #EXTINF:4.80480, playlist_192k_266919.ts #EXT-OATCLS-SCTE35:/DAqAAAAAyiYAP/wBQb/FuaKGAAUAhJDVUVJAAAFp3+/EQMCRgIMAQF7Ny4D #EXTINF:1.20120, playlist_192k_266920.ts #EXTINF:6.00600, playlist_192k_266921.ts #EXTINF:6.00600, playlist_192k_266922.ts """ CUE_OUT_CONT_ALT_PLAYLIST = """ #EXTM3U #EXT-X-VERSION:3 #EXT-X-TARGETDURATION:7 #EXT-X-MEDIA-SEQUENCE:19980226 #EXT-X-DISCONTINUITY-SEQUENCE:1 #EXT-X-CUE-OUT:119.987 #EXTINF:2.000, segment_19980226.ts #EXT-X-CUE-OUT-CONT:2/120 #EXTINF:6.000, segment_19980227.ts #EXT-X-CUE-OUT-CONT:8/120.0 #EXTINF:6.001, segment_19980228.ts #EXT-X-CUE-OUT-CONT:14.001/120.0 #EXTINF:6.001, segment_19980229.ts """ CUE_OUT_MEDIACONVERT_PLAYLIST = """\ #EXTM3U #EXT-X-VERSION:3 #EXT-X-TARGETDURATION:11 #EXT-X-MEDIA-SEQUENCE:1 #EXT-X-PLAYLIST-TYPE:VOD #EXTINF:10, segment_00001.ts #EXT-X-CUE-OUT:4,SpliceType=VOD_DAI,Action=REPLACE, PAID=example.com/2024073010700,Acds=BA #EXTINF:10, segment_00002.ts #EXT-X-CUE-OUT-CONT:10/4, SpliceType=VOD_DAI,Action=REPLACE,PAID=example.com/2024073010700,Acds=BA #EXTINF:10, segment_00003.ts #EXTINF:10, segment_00004.ts #EXT-X-CUE-IN:4,SpliceType=VOD_DAI #EXTINF:0, segment_00005.ts #EXTINF:10, segment_00006.ts #EXT-X-ENDLIST """ CUE_OUT_ENVIVIO_PLAYLIST = """ #EXTM3U #EXT-X-VERSION:3 #EXT-X-TARGETDURATION:11 #EXT-X-MEDIA-SEQUENCE:399703 #EXTINF:10.0000, 20160914T080055-master804-199/1703.ts #EXTINF:10.0000, 20160914T080055-master804-199/1704.ts #EXTINF:5.1200, 20160914T080055-master804-199/1705.ts #EXT-X-CUE-OUT:DURATION=366,ID=16777323,CUE="/DAlAAAENOOQAP/wFAUBAABrf+//N25XDf4B9p/gAAEBAQAAxKni9A==" #EXTINF:10.0000, 20160914T080055-master804-199/1706.ts #EXT-X-CUE-SPAN:TIMEFROMSIGNAL=PT10S,ID=16777323 #EXTINF:10.0000, 20160914T080055-master804-199/1707.ts #EXT-X-CUE-SPAN:TIMEFROMSIGNAL=PT20S,ID=16777323 #EXTINF:10.0000, 20160914T080055-master804-199/1708.ts #EXT-X-CUE-SPAN:TIMEFROMSIGNAL=PT30S,ID=16777323 #EXTINF:10.0000, 20160914T080055-master804-199/1709.ts #EXT-X-CUE-IN:ID=16777323 #EXTINF:10.0000, 20160914T080055-master804-199/1710.ts """ CUE_OUT_INVALID_PLAYLIST = """#EXTM3U #EXT-X-TARGETDURATION:6 #EXT-X-CUE-OUT:INVALID #EXTINF:5.76, no desc 0.aac #EXT-X-CUE-OUT-CONT #EXTINF:5.76 1.aac """ CUE_OUT_NO_DURATION_PLAYLIST = """#EXTM3U #EXT-X-TARGETDURATION:6 #EXT-X-CUE-OUT #EXTINF:5.76, 0.aac #EXTINF:5.76, 1.aac #EXT-X-CUE-IN #EXTINF:5.76, 2.aac """ CUE_OUT_WITH_DURATION_PLAYLIST = """#EXTM3U #EXT-X-TARGETDURATION:6 #EXT-X-CUE-OUT:11.52 #EXTINF:5.76, 0.aac #EXTINF:5.76, 1.aac #EXT-X-CUE-IN #EXTINF:5.76, 2.aac """ CUE_OUT_WITH_EXPLICIT_DURATION_PLAYLIST = """#EXTM3U #EXT-X-TARGETDURATION:6 #EXT-X-CUE-OUT:DURATION=11.52 #EXTINF:5.76, 0.aac #EXTINF:5.76, 1.aac #EXT-X-CUE-IN #EXTINF:5.76, 2.aac """ CUE_OUT_WITH_DURATION_KEY_PLAYLIST = """#EXTM3U #EXT-X-TARGETDURATION:6 #EXT-X-CUE-OUT:DURATION=11.52 #EXTINF:5.76, 0.aac #EXTINF:5.76, 1.aac #EXT-X-CUE-IN #EXTINF:5.76, 2.aac """ MULTI_MEDIA_PLAYLIST = """#EXTM3U #EXT-X-VERSION:3 #EXT-X-MEDIA:URI="chinese/ed.ttml",TYPE=SUBTITLES,GROUP-ID="subs",LANGUAGE="zho",NAME="Chinese",AUTOSELECT=YES,FORCED=NO #EXT-X-MEDIA:URI="french/ed.ttml",TYPE=SUBTITLES,GROUP-ID="subs",LANGUAGE="fra",ASSOC-LANGUAGE="fra",NAME="French",AUTOSELECT=YES,FORCED=NO,CHARACTERISTICS="public.accessibility.transcribes-spoken-dialog,public.accessibility.describes-music-and-sound" #EXT-X-MEDIA:TYPE=CLOSED-CAPTIONS,GROUP-ID="cc",LANGUAGE="sp",NAME="CC2",AUTOSELECT=YES,INSTREAM-ID="CC2" #EXT-X-MEDIA:URI="en/chunklist_w370587926_b160000_ao_slen_t64RW5nbGlzaA==.m3u8",TYPE=AUDIO,GROUP-ID="aac",LANGUAGE="en",NAME="English",DEFAULT=YES,AUTOSELECT=YES #EXT-X-MEDIA:URI="sp/chunklist_w370587926_b160000_ao_slsp_t64U3BhbmlzaA==.m3u8",TYPE=AUDIO,GROUP-ID="aac",LANGUAGE="sp",NAME="Spanish",DEFAULT=NO,AUTOSELECT=YES #EXT-X-MEDIA:URI="com/chunklist_w370587926_b160000_ao_slen_t64Q29tbWVudGFyeSAoZW5nKQ==.m3u8",TYPE=AUDIO,GROUP-ID="aac",LANGUAGE="en",NAME="Commentary (eng)",DEFAULT=NO,AUTOSELECT=NO #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=2962000,RESOLUTION=1280x720,CODECS="avc1.66.30",AUDIO="aac",SUBTITLES="subs" 1280/chunklist_w370587926_b2962000_vo_slen_t64TWFpbg==.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1427000,RESOLUTION=768x432,CODECS="avc1.66.30",AUDIO="aac",SUBTITLES="subs" 768/chunklist_w370587926_b1427000_vo_slen_t64TWFpbg==.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=688000,RESOLUTION=448x252,CODECS="avc1.66.30",AUDIO="aac",SUBTITLES="subs" 448/chunklist_w370587926_b688000_vo_slen_t64TWFpbg==.m3u8 """ MAP_URI_PLAYLIST = """#EXTM3U #EXT-X-TARGETDURATION:2 #EXT-X-VERSION:7 #EXT-X-MEDIA-SEQUENCE:1 #EXT-X-PLAYLIST-TYPE:VOD #EXT-X-INDEPENDENT-SEGMENTS #EXT-X-MAP:URI="fileSequence0.mp4" """ MAP_URI_PLAYLIST_WITH_BYTERANGE = """#EXTM3U #EXT-X-TARGETDURATION:2 #EXT-X-VERSION:7 #EXT-X-MEDIA-SEQUENCE:1 #EXT-X-PLAYLIST-TYPE:VOD #EXT-X-INDEPENDENT-SEGMENTS #EXT-X-MAP:URI="main.mp4",BYTERANGE="812@0" #EXTINF:1, segment_link1.mp4 #EXT-X-MAP:URI="main2.mp4",BYTERANGE="912@0" #EXTINF:1, segment_link2.mp4 """ MULTIPLE_MAP_URI_PLAYLIST = """#EXTM3U #EXT-X-TARGETDURATION:6 #EXT-X-VERSION:7 #EXT-X-MEDIA-SEQUENCE:1 #EXT-X-PLAYLIST-TYPE:VOD #EXT-X-INDEPENDENT-SEGMENTS #EXT-X-KEY:URI="key.bin",METHOD=AES-128 #EXT-X-MAP:URI="init1.mp4" #EXTINF:5, segment1.mp4 #EXTINF:5, segment2.mp4 #EXT-X-MAP:URI="init3.mp4" #EXTINF:5, segment3.mp4 """ MEDIA_WITHOUT_URI_PLAYLIST = """#EXTM3U #EXT-X-VERSION:4 #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio-aacl-312",NAME="English",LANGUAGE="en",AUTOSELECT=YES,DEFAULT=YES,CHANNELS="2" #EXT-X-STREAM-INF:BANDWIDTH=364000,AVERAGE-BANDWIDTH=331000,CODECS="mp4a.40.2",AUDIO="audio-aacl-312",SUBTITLES="textstream" ch001-audio_312640_eng=312000.m3u8 """ SIMPLE_PLAYLIST_WITH_DISCONTINUITY_SEQUENCE = """#EXTM3U #EXT-X-TARGETDURATION:5220 #EXT-X-DISCONTINUITY-SEQUENCE:123 #EXTINF:5220, http://media.example.com/entire.ts #EXT-X-ENDLIST """ SIMPLE_PLAYLIST_WITH_CUSTOM_TAGS = """#EXTM3U #EXT-X-MOVIE: million dollar baby #EXT-X-TARGETDURATION:5220 #EXTINF:5220, http://media.example.com/entire.ts #EXT-X-ENDLIST """ IPTV_PLAYLIST_WITH_CUSTOM_TAGS = """#EXTM3U #EXTVLCOPT:video-filter=invert #EXTGRP:ExtGroup1 #EXTINF:-1 timeshift="0" catchup-days="7" catchup-type="flussonic" tvg-id="channel1" group-title="Group1",Channel1 #EXTVLCOPT:param2=value2 http://str00.iptv.domain/7331/mpegts?token=longtokenhere """ IPTV_PLAYLIST_WITH_EARLY_EXTINF = """#EXTM3U #EXTVLCOPT:video-filter=invert #EXTGRP:ExtGroup1 #EXTINF:0,Info #EXTVLCOPT:param2=value2 http://str00.iptv.domain/7331/mpegts?token=longtokenhere """ LOW_LATENCY_PART_PLAYLIST = """\ #EXTM3U #EXT-X-TARGETDURATION:4 #EXT-X-VERSION:6 #EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=1.0,CAN-SKIP-UNTIL=24.0 #EXT-X-PART-INF:PART-TARGET=0.33334 #EXT-X-MEDIA-SEQUENCE:264 #EXT-X-PROGRAM-DATE-TIME:2019-02-14T02:13:28.106Z #EXT-X-MAP:URI="init.mp4" #EXTINF:4.00008, fileSequence264.mp4 #EXTINF:4.00008, fileSequence265.mp4 #EXTINF:4.00008, fileSequence266.mp4 #EXTINF:4.00008, fileSequence267.mp4 #EXTINF:4.00008, fileSequence268.mp4 #EXTINF:4.00008, fileSequence269.mp4 #EXTINF:4.00008, fileSequence270.mp4 #EXT-X-PART:DURATION=0.33334,URI="filePart271.0.mp4" #EXT-X-PART:DURATION=0.33334,URI="filePart271.1.mp4" #EXT-X-PART:DURATION=0.33334,URI="filePart271.2.mp4" #EXT-X-PART:DURATION=0.33334,URI="filePart271.3.mp4" #EXT-X-PART:DURATION=0.33334,URI="filePart271.4.mp4",INDEPENDENT=YES #EXT-X-PART:DURATION=0.33334,URI="filePart271.5.mp4" #EXT-X-PART:DURATION=0.33334,URI="filePart271.6.mp4" #EXT-X-PART:DURATION=0.33334,URI="filePart271.7.mp4" #EXT-X-PART:DURATION=0.33334,URI="filePart271.8.mp4",INDEPENDENT=YES #EXT-X-PART:DURATION=0.33334,URI="filePart271.9.mp4" #EXT-X-PART:DURATION=0.33334,URI="filePart271.10.mp4" #EXT-X-PART:DURATION=0.33334,URI="filePart271.11.mp4" #EXTINF:4.00008, fileSequence271.mp4 #EXT-X-PROGRAM-DATE-TIME:2019-02-14T02:14:00.106Z #EXT-X-PART:DURATION=0.33334,URI="filePart272.a.mp4" #EXT-X-PART:DURATION=0.33334,URI="filePart272.b.mp4" #EXT-X-PART:DURATION=0.33334,URI="filePart272.c.mp4" #EXT-X-PART:DURATION=0.33334,URI="filePart272.d.mp4" #EXT-X-PART:DURATION=0.33334,URI="filePart272.e.mp4" #EXT-X-PART:DURATION=0.33334,URI="filePart272.f.mp4",INDEPENDENT=YES #EXT-X-PART:DURATION=0.33334,URI="filePart272.g.mp4" #EXT-X-PART:DURATION=0.33334,URI="filePart272.h.mp4" #EXT-X-PART:DURATION=0.33334,URI="filePart272.i.mp4" #EXT-X-PART:DURATION=0.33334,URI="filePart272.j.mp4" #EXT-X-PART:DURATION=0.33334,URI="filePart272.k.mp4" #EXT-X-PART:DURATION=0.33334,URI="filePart272.l.mp4" #EXTINF:4.00008, fileSequence272.mp4 #EXT-X-PART:DURATION=0.33334,URI="filePart273.0.mp4",INDEPENDENT=YES #EXT-X-PART:DURATION=0.33334,URI="filePart273.1.mp4" #EXT-X-PART:DURATION=0.33334,URI="filePart273.2.mp4" #EXT-X-PRELOAD-HINT:TYPE=PART,URI="filePart273.3.mp4" #EXT-X-RENDITION-REPORT:URI="../1M/waitForMSN.php",LAST-MSN=273,LAST-PART=2 #EXT-X-RENDITION-REPORT:URI="../4M/waitForMSN.php",LAST-MSN=273,LAST-PART=1 """ LOW_LATENCY_DELTA_UPDATE_PLAYLIST = """#EXTM3U # Following the example above, this playlist is a response to: GET https://example.com/2M/waitForMSN.php?_HLS_msn=273&_HLS_part=3&_HLS_report=../1M/waitForMSN.php&_HLS_report=../4M/waitForMSN.php&_HLS_skip=YES #EXT-X-TARGETDURATION:4 #EXT-X-VERSION:9 #EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=1.0,CAN-SKIP-UNTIL=12.0 #EXT-X-PART-INF:PART-TARGET=0.33334 #EXT-X-MEDIA-SEQUENCE:266 #EXT-X-SKIP:SKIPPED-SEGMENTS=3 #EXTINF:4.00008, fileSequence269.ts #EXTINF:4.00008, fileSequence270.ts #EXT-X-PART:DURATION=0.33334,URI="filePart271.0.ts" #EXT-X-PART:DURATION=0.33334,URI="filePart271.1.ts" #EXT-X-PART:DURATION=0.33334,URI="filePart271.2.ts" #EXT-X-PART:DURATION=0.33334,URI="filePart271.3.ts" #EXT-X-PART:DURATION=0.33334,URI="filePart271.4.ts",INDEPENDENT=YES #EXT-X-PART:DURATION=0.33334,URI="filePart271.5.ts" #EXT-X-PART:DURATION=0.33334,URI="filePart271.6.ts" #EXT-X-PART:DURATION=0.33334,URI="filePart271.7.ts" #EXT-X-PART:DURATION=0.33334,URI="filePart271.8.ts",INDEPENDENT=YES #EXT-X-PART:DURATION=0.33334,URI="filePart271.9.ts" #EXT-X-PART:DURATION=0.33334,URI="filePart271.10.ts" #EXT-X-PART:DURATION=0.33334,URI="filePart271.11.ts" #EXTINF:4.00008, fileSequence271.ts #EXT-X-PROGRAM-DATE-TIME:2019-02-14T02:14:00.106Z #EXT-X-PART:DURATION=0.33334,URI="filePart272.a.ts" #EXT-X-PART:DURATION=0.33334,URI="filePart272.b.ts" #EXT-X-PART:DURATION=0.33334,URI="filePart272.c.ts" #EXT-X-PART:DURATION=0.33334,URI="filePart272.d.ts" #EXT-X-PART:DURATION=0.33334,URI="filePart272.e.ts" #EXT-X-PART:DURATION=0.33334,URI="filePart272.f.ts",INDEPENDENT=YES #EXT-X-PART:DURATION=0.33334,URI="filePart272.g.ts" #EXT-X-PART:DURATION=0.33334,URI="filePart272.h.ts" #EXT-X-PART:DURATION=0.33334,URI="filePart272.i.ts" #EXT-X-PART:DURATION=0.33334,URI="filePart272.j.ts" #EXT-X-PART:DURATION=0.33334,URI="filePart272.k.ts" #EXT-X-PART:DURATION=0.33334,URI="filePart272.l.ts" #EXTINF:4.00008, fileSequence272.ts #EXT-X-PART:DURATION=0.33334,URI="filePart273.0.ts",INDEPENDENT=YES #EXT-X-PART:DURATION=0.33334,URI="filePart273.1.ts" #EXT-X-PART:DURATION=0.33334,URI="filePart273.2.ts" #EXT-X-PART:DURATION=0.33334,URI="filePart273.3.ts" #EXT-X-PRELOAD-HINT:TYPE=PART,URI="filePart273.4.ts" #EXT-X-RENDITION-REPORT:URI="../1M/waitForMSN.php",LAST-MSN=273,LAST-PART=3 #EXT-X-RENDITION-REPORT:URI="../4M/waitForMSN.php",LAST-MSN=273,LAST-PART=3 """ LOW_LATENCY_WITH_PRELOAD_AND_BYTERANGES_PLAYLIST = """ #EXTM3U #EXTINF:4.08, fs270.mp4 #EXT-X-PART:DURATION=1.02,URI="fs271.mp4",BYTERANGE=20000@0 #EXT-X-PART:DURATION=1.02,URI="fs271.mp4",BYTERANGE=23000@20000 #EXT-X-PART:DURATION=1.02,URI="fs271.mp4",BYTERANGE=18000@43000 #EXT-X-PRELOAD-HINT:TYPE=PART,URI="fs271.mp4",BYTERANGE-START=61000,BYTERANGE-LENGTH=20000 """ RELATIVE_PLAYLIST_FILENAME = abspath( join(dirname(__file__), "playlists/relative-playlist.m3u8") ) RELATIVE_PLAYLIST_URI = TEST_HOST + "/path/to/relative-playlist.m3u8" CUE_OUT_PLAYLIST_FILENAME = abspath(join(dirname(__file__), "playlists/cue_out.m3u8")) CUE_OUT_PLAYLIST_URI = TEST_HOST + "/path/to/cue_out.m3u8" VARIANT_PLAYLIST_WITH_FRAME_RATE = """ #EXTM3U #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1280000,FRAME-RATE=25 http://example.com/low.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=2560000,FRAME-RATE=50 http://example.com/mid.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=7680000,FRAME-RATE=60 http://example.com/hi.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=65000,FRAME-RATE=12.5,CODECS="mp4a.40.5,avc1.42801e" http://example.com/audio-only.m3u8 """ VARIANT_PLAYLIST_WITH_ROUNDABLE_FRAME_RATE = """ #EXTM3U #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=65000,FRAME-RATE=12.54321,CODECS="mp4a.40.5,avc1.42801e" http://example.com/audio-only.m3u8 """ VARIANT_PLAYLIST_WITH_ROUNDED_FRAME_RATE = """ #EXTM3U #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=65000,FRAME-RATE=12.543,CODECS="mp4a.40.5,avc1.42801e" http://example.com/audio-only.m3u8 """ SESSION_DATA_PLAYLIST = """#EXTM3U #EXT-X-VERSION:4 #EXT-X-SESSION-DATA:DATA-ID="com.example.value",VALUE="example",LANGUAGE="en" """ MULTIPLE_SESSION_DATA_PLAYLIST = """#EXTM3U #EXT-X-VERSION:4 #EXT-X-SESSION-DATA:DATA-ID="com.example.value",VALUE="example",LANGUAGE="en" #EXT-X-SESSION-DATA:DATA-ID="com.example.value",VALUE="example",LANGUAGE="ru" #EXT-X-SESSION-DATA:DATA-ID="com.example.value",VALUE="example",LANGUAGE="de" #EXT-X-SESSION-DATA:DATA-ID="com.example.title",URI="title.json" """ VERSION_PLAYLIST = """#EXTM3U #EXT-X-VERSION:4 """ PLAYLIST_WITH_NEGATIVE_MEDIA_SEQUENCE = """ #EXTM3U #EXT-X-TARGETDURATION:5220 #EXT-X-MEDIA-SEQUENCE:-2680 #EXTINF:5220, http://media.example.com/entire.ts #EXT-X-ENDLIST """ DATERANGE_SIMPLE_PLAYLIST = """ #EXTM3U #EXT-X-PROGRAM-DATE-TIME:2016-06-13T11:15:15Z #EXT-X-DATERANGE:ID="ad3",START-DATE="2016-06-13T11:15:00Z",DURATION=20,X-AD-URL="http://ads.example.com/beacon3",X-AD-ID="1234" #EXTINF:10, ad3.1.ts #EXTINF:10, ad3.2.ts """ DATERANGE_SCTE35_OUT_AND_IN_PLAYLIST = """ #EXTM3U # adapted from https://tools.ietf.org/html/rfc8216#section-8.10 #EXT-X-PROGRAM-DATE-TIME:2014-03-05T11:15:00Z #EXT-X-DATERANGE:ID="splice-6FFFFFF0",START-DATE="2014-03-05T11:15:00Z",PLANNED-DURATION=59.993,SCTE35-OUT=0xFC002F0000000000FF000014056FFFFFF000E011622DCAFF000052636200000000000A0008029896F50000008700000000 #EXTINF:10, ad3.1.ts #EXTINF:10, ad3.2.ts #EXTINF:10, ad3.3.ts #EXTINF:10, ad3.4.ts #EXTINF:10, ad3.5.ts #EXTINF:10, ad3.6.ts #EXT-X-DATERANGE:ID="splice-6FFFFFF0",DURATION=59.993,SCTE35-IN=0xFC002A0000000000FF00000F056FFFFFF000401162802E6100000000000A0008029896F50000008700000000 #EXTINF:10, prog.1.ts """ DATERANGE_ENDDATE_SCTECMD_PLAYLIST = """ #EXTM3U #EXT-X-PROGRAM-DATE-TIME:2020-03-11T10:51:00Z #EXT-X-DATERANGE:ID="test_id",START-DATE="2020-03-11T10:51:00Z",CLASS="test_class",END-DATE="2020-03-11T10:52:00Z",DURATION=60,SCTE35-CMD=0xFCINVALIDSECTION #EXTINF:10, prog.1.ts """ DATERANGE_IN_PART_PLAYLIST = """ #EXTM3U #EXT-X-PROGRAM-DATE-TIME:2020-03-10T07:48:00Z #EXT-X-PART:DURATION=1,URI="filePart271.a.ts" #EXT-X-PART:DURATION=1,URI="filePart271.b.ts" #EXT-X-DATERANGE:ID="test_id",START-DATE="2020-03-10T07:48:02Z",CLASS="test_class",END-ON-NEXT=YES #EXT-X-PART:DURATION=1,URI="filePart271.c.ts" """ GAP_PLAYLIST = """ #EXTM3U #EXT-X-MEDIA-SEQUENCE:14 #EXT-X-VERSION:7 #EXT-X-TARGETDURATION:10 #EXTINF:9.84317, fileSequence14.ts #EXTINF:8.75875, #EXT-X-GAP missing-Sequence15.ts #EXTINF:9.88487, #EXT-X-GAP missing-Sequence16.ts #EXTINF:9.09242, fileSequence17.ts """ GAP_IN_PARTS_PLAYLIST = """ #EXTM3U #EXT-X-PART:DURATION=1,URI="filePart271.a.ts" #EXT-X-PART:DURATION=1,URI="filePart271.b.ts",GAP=YES #EXT-X-GAP #EXT-X-PART:DURATION=1,URI="filePart271.c.ts" """ PLAYLIST_WITH_SLASH_IN_QUERY_STRING = """ #EXTM3U #EXT-X-VERSION:3 #EXT-X-TARGETDURATION:5 #EXT-X-MEDIA-SEQUENCE:10599 #EXT-X-PROGRAM-DATE-TIME:2020-08-05T13:51:49.000+00:00 #EXTINF:5.0000, testvideo-1596635509-4769390994-a0e3087c.ts?hdntl=exp=1596678764~acl=/*~data=hdntl~hmac=12345& #EXTINF:5.0000, testvideo-1596635514-4769840994-a0e00878.ts?hdntl=exp=1596678764~acl=/*~data=hdntl~hmac=12345& #EXTINF:5.0000, testvideo-1596635519-4770290994-a0e5087d.ts?hdntl=exp=1596678764~acl=/*~data=hdntl~hmac=12345& #EXTINF:5.0000, """ VARIANT_PLAYLIST_WITH_IFRAME_AVERAGE_BANDWIDTH = """ #EXTM3U #EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=624x352,CODECS="avc1.4d001f, mp4a.40.5" video-800k.m3u8 #EXT-X-STREAM-INF:BANDWIDTH=1200000,CODECS="avc1.4d001f, mp4a.40.5" video-1200k.m3u8 #EXT-X-STREAM-INF:BANDWIDTH=400000,CODECS="avc1.4d001f, mp4a.40.5" video-400k.m3u8 #EXT-X-STREAM-INF:BANDWIDTH=150000,CODECS="avc1.4d001f, mp4a.40.5" video-150k.m3u8 #EXT-X-STREAM-INF:BANDWIDTH=64000,CODECS="mp4a.40.5" video-64k.m3u8 #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=151288,RESOLUTION=624x352,CODECS="avc1.4d001f",URI="video-800k-iframes.m3u8" #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=193350,AVERAGE_BANDWIDTH=155000,CODECS="avc1.4d001f",URI="video-1200k-iframes.m3u8" #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=83598,AVERAGE_BANDWIDTH=65000,CODECS="avc1.4d001f",URI="video-400k-iframes.m3u8" #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=38775,AVERAGE_BANDWIDTH=30000,CODECS="avc1.4d001f",URI="video-150k-iframes.m3u8" """ VARIANT_PLAYLIST_WITH_IFRAME_VIDEO_RANGE = """ #EXTM3U #EXT-X-STREAM-INF:PROGRAM-ID=1,VIDEO-RANGE=SDR http://example.com/sdr.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,VIDEO-RANGE=PQ http://example.com/hdr-pq.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,VIDEO-RANGE=HLG http://example.com/hdr-hlg.m3u8 #EXT-X-I-FRAME-STREAM-INF:VIDEO_RANGE=SDR,URI="http://example.com/sdr-iframes.m3u8" #EXT-X-I-FRAME-STREAM-INF:VIDEO_RANGE=PQ,URI="http://example.com/hdr-pq-iframes.m3u8" #EXT-X-I-FRAME-STREAM-INF:VIDEO_RANGE=HLG,URI="http://example.com/hdr-hlg-iframes.m3u8" #EXT-X-I-FRAME-STREAM-INF:URI="http://example.com/unknown-iframes.m3u8" """ VARIANT_PLAYLIST_WITH_IFRAME_HDCP_LEVEL = """ #EXTM3U #EXT-X-STREAM-INF:PROGRAM-ID=1,HDCP-LEVEL=NONE http://example.com/none.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,HDCP-LEVEL=TYPE-0 http://example.com/type0.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,HDCP-LEVEL=TYPE-1 http://example.com/type1.m3u8 #EXT-X-I-FRAME-STREAM-INF:HDCP-LEVEL=NONE,URI="http://example.com/none-iframes.m3u8" #EXT-X-I-FRAME-STREAM-INF:HDCP-LEVEL=TYPE-0,URI="http://example.com/type0-iframes.m3u8" #EXT-X-I-FRAME-STREAM-INF:HDCP-LEVEL=TYPE-1,URI="http://example.com/type1-iframes.m3u8" #EXT-X-I-FRAME-STREAM-INF:URI="http://example.com/unknown-iframes.m3u8" """ DELTA_UPDATE_SKIP_DATERANGES_PLAYLIST = """#EXTM3U #EXT-X-VERSION:10 #EXT-X-TARGETDURATION:6 #EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=36,CAN-SKIP-DATERANGES=YES #EXT-X-MEDIA-SEQUENCE:1 #EXT-X-MAP:URI="init.mp4" #EXT-X-SKIP:SKIPPED-SEGMENTS=16,RECENTLY-REMOVED-DATERANGES="1" #EXTINF:4.00000, segment16.mp4 #EXTINF:4.00000, segment17.mp4 #EXTINF:4.00000, segment18.mp4 #EXTINF:4.00000, segment19.mp4 #EXTINF:4.00000, segment20.mp4 #EXTINF:4.00000, segment21.mp4 #EXT-X-DATERANGE:ID="P" #EXT-X-DATERANGE:ID="Q" """ BITRATE_PLAYLIST = """ #EXTM3U #EXT-X-VERSION:3 #EXT-X-INDEPENDENT-SEGMENTS #EXT-X-TARGETDURATION:10 #EXT-X-MEDIA-SEQUENCE:55119 #EXT-X-PROGRAM-DATE-TIME:2020-07-21T08:14:29.379Z #EXT-X-BITRATE:1674 #EXTINF:9.600, test1.ts #EXT-X-BITRATE:1625 #EXTINF:9.600, test2.ts """ CONTENT_STEERING_PLAYLIST = """ #EXTM3U #EXT-X-CONTENT-STEERING:SERVER-URI="/steering?video=00012",PATHWAY-ID="CDN-A" #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="A",NAME="English",DEFAULT=YES,URI="eng.m3u8",LANGUAGE="en" #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="B",NAME="ENGLISH",DEFAULT=YES,URI="https://b.example.com/content/videos/video12/eng.m3u8",LANGUAGE="en" #EXT-X-STREAM-INF:BANDWIDTH=1280000,AUDIO="A",PATHWAY-ID="CDN-A" low/video.m3u8 #EXT-X-STREAM-INF:BANDWIDTH=7680000,AUDIO="A",PATHWAY-ID="CDN-A" hi/video.m3u8 #EXT-X-STREAM-INF:BANDWIDTH=1280000,AUDIO="B",PATHWAY-ID="CDN-B" https://backup.example.com/content/videos/video12/low/video.m3u8 #EXT-X-STREAM-INF:BANDWIDTH=7680000,AUDIO="B",PATHWAY-ID="CDN-B" https://backup.example.com/content/videos/video12/hi/video.m3u8 #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=193350,CODECS="avc1.4d001f",URI="video-1200k-iframes.m3u8",PATHWAY-ID="CDN-A" #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=193350,CODECS="avc1.4d001f",URI="https://backup.example.com/content/videos/video12/video-1200k-iframes.m3u8",PATHWAY-ID="CDN-B" """ VARIANT_PLAYLIST_WITH_STABLE_VARIANT_ID = """ #EXT-X-STREAM-INF:BANDWIDTH=1280000,STABLE-VARIANT-ID="eb9c6e4de930b36d9a67fbd38a30b39f865d98f4a203d2140bbf71fd58ad764e" http://example.com/type0.m3u8 """ VARIANT_PLAYLIST_WITH_IFRAME_STABLE_VARIANT_ID = """ #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=128000,STABLE-VARIANT-ID="415901312adff69b967a0644a54f8d00dc14004f36bc8293737e6b4251f60f3f",URI="http://example.com/type0-iframes.m3u8" """ VARIANT_PLAYLIST_WITH_STABLE_RENDITION_ID = """ #EXT-X-MEDIA:TYPE=AUDIO,NAME="audio-aac-eng",STABLE-RENDITION-ID="a8213e27c12a158ea8660e0fe8bdcac6072ca26d984e7e8603652bc61fdceffa",URI="http://example.com/eng.m3u8" """ VARIANT_PLAYLIST_WITH_IMAGE_PLAYLISTS = """ #EXTM3U #EXT-X-VERSION:3 #EXT-X-INDEPENDENT-SEGMENTS #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=464000,RESOLUTION=640x360,CODECS="avc1.77.30, mp4a.40.2" index_0_av/new_index_0_av.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=764000,RESOLUTION=640x360,CODECS="avc1.77.30, mp4a.40.2" index_1_av/new_index_1_av.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1062000,RESOLUTION=640x360,CODECS="avc1.77.30, mp4a.40.2" index_2_av/new_index_2_av.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1563000,RESOLUTION=960x540,CODECS="avc1.77.30, mp4a.40.2" index_3_av/new_index_3_av.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=64000,CODECS="mp4a.40.2" index_0_a/new_index_0_a.m3u8S #EXT-X-IMAGE-STREAM-INF:BANDWIDTH=16460,RESOLUTION=320x180,CODECS="jpeg",URI="5x2_320x180/320x180-5x2.m3u8" #EXT-X-IMAGE-STREAM-INF:BANDWIDTH=32920,RESOLUTION=640x360,CODECS="jpeg",URI="5x2_640x360/640x360-5x2.m3u8" """ VOD_IMAGE_PLAYLIST = """ #EXTM3U #EXT-X-VERSION:7 #EXT-X-TARGETDURATION:6 #EXT-X-MEDIA-SEQUENCE:0 #EXT-X-PLAYLIST-TYPE:VOD #EXT-X-IMAGES-ONLY #EXTINF:6.006, preroll-ad-1.jpg #EXTINF:6.006, preroll-ad-2.jpg #EXTINF:3.003, preroll-ad-3.jpg #EXT-X-DISCONTINUITY #EXTINF:60.06, #EXT-X-TILES:RESOLUTION=640x360,LAYOUT=5x2,DURATION=6.006 content-0.jpg #EXTINF:60.06, #EXT-X-TILES:RESOLUTION=640x360,LAYOUT=5x2,DURATION=6.006 content-1.jpg #EXTINF:60.06, #EXT-X-TILES:RESOLUTION=640x360,LAYOUT=5x2,DURATION=6.006 content-2.jpg #EXTINF:60.06, #EXT-X-TILES:RESOLUTION=640x360,LAYOUT=5x2,DURATION=6.006 content-3.jpg #EXTINF:54.054, #EXT-X-TILES:RESOLUTION=640x360,LAYOUT=5x2,DURATION=6.006 content-4.jpg #EXT-X-DISCONTINUITY #EXTINF:6.006, midroll-ad-1.jpg #EXTINF:6.006, midroll-ad-2.jpg #EXTINF:3.003, midroll-ad-3.jpg #EXT-X-DISCONTINUITY #EXTINF:60.06, #EXT-X-TILES:RESOLUTION=640x360,LAYOUT=5x2,DURATION=6.006 content-5.jpg #EXTINF:60.06, #EXT-X-TILES:RESOLUTION=640x360,LAYOUT=5x2,DURATION=6.006 content-6.jpg #EXTINF:60.06, #EXT-X-TILES:RESOLUTION=640x360,LAYOUT=5x2,DURATION=6.006 content-7.jpg #EXT-X-ENDLIST """ VOD_IMAGE_PLAYLIST2 = """ #EXTM3U #EXT-X-TARGETDURATION:6 #EXT-X-VERSION:7 #EXT-X-MEDIA-SEQUENCE:0 #EXT-X-PLAYLIST-TYPE:VOD #EXT-X-IMAGES-ONLY #EXTINF:6.006, promo_1.jpg #EXTINF:6.006, promo_2.jpg #EXTINF:3.003, promo_3.jpg #EXT-X-DISCONTINUITY #EXTINF:24.024, #EXT-X-TILES:RESOLUTION=640x360,LAYOUT=4x3,DURATION=2.002 movie_001.jpg #EXTINF:24.024, #EXT-X-TILES:RESOLUTION=640x360,LAYOUT=4x3,DURATION=2.002 movie_002.jpg #EXTINF:24.024, #EXT-X-TILES:RESOLUTION=640x360,LAYOUT=4x3,DURATION=2.002 movie_003.jpg #EXTINF:24.024, #EXT-X-TILES:RESOLUTION=640x360,LAYOUT=4x3,DURATION=2.002 movie_275.jpg #EXT-X-DISCONTINUITY #EXTINF:24.024, #EXT-X-TILES:RESOLUTION=640x360,LAYOUT=4x3,DURATION=2.002 credits_2_0.jpg #EXTINF:6.006, #EXT-X-TILES:RESOLUTION=640x360,LAYOUT=4x3,DURATION=2.002 credits_2_1.jpg #EXT-X-ENDLIST """ LIVE_IMAGE_PLAYLIST = """ #EXTM3U #EXT-X-TARGETDURATION:6 #EXT-X-VERSION:7 #EXT-X-MEDIA-SEQUENCE:127228 #EXT-X-IMAGES-ONLY #EXT-X-DISCONTINUITY-SEQUENCE:5 #EXT-X-PROGRAM-DATE-TIME:2019-04-17T19:28:12.046Z #EXTINF:6.006, content-123.jpg #EXTINF:6.006, content-124.jpg #EXTINF:6.006, content-125.jpg #EXT-X-DISCONTINUITY #EXT-X-PROGRAM-DATE-TIME:2019-04-17T19:28:30.064Z #EXT-X-GAP #EXTINF:6.006, missing-midroll.jpg #EXT-X-GAP #EXTINF:6.006, missing-midroll.jpg #EXT-X-GAP #EXTINF:3.003, missing-midroll.jpg #EXT-X-DISCONTINUITY #EXT-X-PROGRAM-DATE-TIME:2019-04-17T19:28:45.079Z #EXTINF:6.006, content-128.jpg #EXTINF:6.006, content-129.jpg #EXTINF:6.006, content-130.jpg #EXTINF:6.006, content-131.jpg """ del abspath, dirname, join m3u8-6.0.0/tests/playlists/000077500000000000000000000000001465465410600155125ustar00rootroot00000000000000m3u8-6.0.0/tests/playlists/relative-playlist.m3u8000066400000000000000000000006071465465410600217050ustar00rootroot00000000000000#EXTM3U #EXT-X-TARGETDURATION:5220 #EXT-X-KEY:METHOD=AES-128,URI="../key.bin", IV=0X10ef8f758ca555115584bb5b3c687f52 #EXT-X-SESSION-KEY:METHOD=AES-128,URI="../key.bin", IV=0X10ef8f758ca555115584bb5b3c687f52 #EXTINF:5220, /entire1.ts #EXTINF:5220, ../entire2.ts #EXTINF:5220, ../../entire3.ts #EXTINF:5220, entire4.ts #EXTINF:5220, ./entire5.ts #EXTINF:5220, .//entire6.ts #EXT-X-ENDLIST m3u8-6.0.0/tests/playlists/simple-playlist.m3u8000066400000000000000000000001431465465410600213560ustar00rootroot00000000000000#EXTM3U #EXT-X-TARGETDURATION:5220 #EXTINF:5220, http://media.example.com/entire.ts #EXT-X-ENDLIST m3u8-6.0.0/tests/test_http_client.py000066400000000000000000000050101465465410600174100ustar00rootroot00000000000000import gzip import unittest from http.client import HTTPResponse from unittest.mock import Mock, patch from m3u8.httpclient import DefaultHTTPClient class MockHeaders: def __init__(self, encoding=None): self.encoding = encoding def get_content_charset(self, failobj="utf-8"): return self.encoding or failobj class TestDefaultHTTPClient(unittest.TestCase): @patch("urllib.request.OpenerDirector.open") def test_download_normal_content(self, mock_open): client = DefaultHTTPClient() mock_response = Mock(spec=HTTPResponse) mock_response.read.return_value = b"playlist content" mock_response.info.return_value = {} mock_response.geturl.return_value = "http://example.com/index.m3u8" mock_response.headers = MockHeaders() mock_open.return_value = mock_response content, base_uri = client.download("http://example.com/index.m3u8") self.assertEqual(content, "playlist content") self.assertEqual(base_uri, "http://example.com/") @patch("urllib.request.OpenerDirector.open") def test_download_gzipped_content(self, mock_open): client = DefaultHTTPClient() original_content = "playlist gzipped content" gzipped_content = gzip.compress(original_content.encode("utf-8")) mock_response = Mock(spec=HTTPResponse) mock_response.read.return_value = gzipped_content mock_response.info.return_value = {"Content-Encoding": "gzip"} mock_response.geturl.return_value = "http://example.com/index.m3u8" mock_response.headers = MockHeaders("utf-8") mock_open.return_value = mock_response content, base_uri = client.download("http://example.com/index.m3u8") self.assertEqual(content, original_content) self.assertEqual(base_uri, "http://example.com/") @patch("urllib.request.OpenerDirector.open") def test_download_with_proxy(self, mock_open): client = DefaultHTTPClient(proxies={"http": "http://proxy.example.com"}) mock_response = Mock(spec=HTTPResponse) mock_response.read.return_value = b"playlist proxied content" mock_response.info.return_value = {} mock_response.geturl.return_value = "http://example.com/index.m3u8" mock_response.headers = MockHeaders() mock_open.return_value = mock_response content, base_uri = client.download("http://example.com/index.m3u8") self.assertEqual(content, "playlist proxied content") self.assertEqual(base_uri, "http://example.com/") m3u8-6.0.0/tests/test_invalid_versioned_playlists.py000066400000000000000000000016341465465410600227130ustar00rootroot00000000000000import invalid_versioned_playlists import pytest import m3u8 def test_should_fail_if_iv_in_EXT_X_KEY_and_version_less_than_2(): with pytest.raises(Exception) as exc_info: m3u8.parse(invalid_versioned_playlists.M3U8_RULE_IV, strict=True) assert "Change the protocol version to 2 or higher." in str(exc_info.value) def test_should_fail_if_floating_point_EXTINF_and_version_less_than_3(): with pytest.raises(Exception) as exc_info: m3u8.parse(invalid_versioned_playlists.M3U8_RULE_FLOATING_POINT, strict=True) assert "Change the protocol version to 3 or higher." in str(exc_info.value) def test_should_fail_if_EXT_X_BYTERANGE_or_EXT_X_I_FRAMES_ONLY_and_version_less_than_4(): with pytest.raises(Exception) as exc_info: m3u8.parse(invalid_versioned_playlists.M3U8_RULE_BYTE_RANGE, strict=True) assert "Change the protocol version to 4 or higher." in str(exc_info.value) m3u8-6.0.0/tests/test_loader.py000066400000000000000000000140051465465410600163450ustar00rootroot00000000000000# Copyright 2014 Globo.com Player authors. All rights reserved. # Use of this source code is governed by a MIT License # license that can be found in the LICENSE file. import os import socket import urllib.parse import m3u8 import pytest import playlists def test_loads_should_create_object_from_string(): obj = m3u8.loads(playlists.SIMPLE_PLAYLIST) assert isinstance(obj, m3u8.M3U8) assert 5220 == obj.target_duration assert "http://media.example.com/entire.ts" == obj.segments[0].uri def test_load_should_create_object_from_file(): obj = m3u8.load(playlists.SIMPLE_PLAYLIST_FILENAME) assert isinstance(obj, m3u8.M3U8) assert 5220 == obj.target_duration assert "http://media.example.com/entire.ts" == obj.segments[0].uri def test_load_should_create_object_from_uri(): obj = m3u8.load(playlists.SIMPLE_PLAYLIST_URI) assert isinstance(obj, m3u8.M3U8) assert 5220 == obj.target_duration assert "http://media.example.com/entire.ts" == obj.segments[0].uri def test_load_should_remember_redirect(): obj = m3u8.load(playlists.REDIRECT_PLAYLIST_URI) urlparsed = urllib.parse.urlparse(playlists.SIMPLE_PLAYLIST_URI) assert urlparsed.scheme + "://" + urlparsed.netloc + "/" == obj.base_uri def test_load_should_create_object_from_file_with_relative_segments(): base_uri = os.path.dirname(playlists.RELATIVE_PLAYLIST_FILENAME) obj = m3u8.load(playlists.RELATIVE_PLAYLIST_FILENAME) expected_key_abspath = "%s/key.bin" % os.path.dirname(base_uri) expected_key_path = "../key.bin" expected_ts1_abspath = "/entire1.ts" expected_ts1_path = "/entire1.ts" expected_ts2_abspath = "%s/entire2.ts" % os.path.dirname(base_uri) expected_ts2_path = "../entire2.ts" expected_ts3_abspath = "%s/entire3.ts" % os.path.dirname(os.path.dirname(base_uri)) expected_ts3_path = "../../entire3.ts" expected_ts4_abspath = "%s/entire4.ts" % base_uri expected_ts4_path = "entire4.ts" expected_ts5_abspath = "%s/entire5.ts" % base_uri expected_ts5_path = "./entire5.ts" expected_ts6_abspath = "%s/entire6.ts" % base_uri expected_ts6_path = ".//entire6.ts" assert isinstance(obj, m3u8.M3U8) assert expected_key_path == obj.keys[0].uri assert expected_key_abspath == obj.keys[0].absolute_uri assert expected_ts1_path == obj.segments[0].uri assert expected_ts1_abspath == obj.segments[0].absolute_uri assert expected_ts2_path == obj.segments[1].uri assert expected_ts2_abspath == obj.segments[1].absolute_uri assert expected_ts3_path == obj.segments[2].uri assert expected_ts3_abspath == obj.segments[2].absolute_uri assert expected_ts4_path == obj.segments[3].uri assert expected_ts4_abspath == obj.segments[3].absolute_uri assert expected_ts5_path == obj.segments[4].uri assert expected_ts5_abspath == obj.segments[4].absolute_uri assert expected_ts6_path == obj.segments[5].uri assert expected_ts6_abspath == obj.segments[5].absolute_uri def test_load_should_create_object_from_uri_with_relative_segments(): obj = m3u8.load(playlists.RELATIVE_PLAYLIST_URI) urlparsed = urllib.parse.urlparse(playlists.RELATIVE_PLAYLIST_URI) base_uri = os.path.normpath(urlparsed.path + "/..") prefix = urlparsed.scheme + "://" + urlparsed.netloc expected_key_abspath = "{}{}key.bin".format( prefix, os.path.normpath(base_uri + "/..") + "/", ) expected_key_path = "../key.bin" expected_ts1_abspath = "{}{}entire1.ts".format(prefix, "/") expected_ts1_path = "/entire1.ts" expected_ts2_abspath = "{}{}entire2.ts".format( prefix, os.path.normpath(base_uri + "/..") + "/", ) expected_ts2_path = "../entire2.ts" expected_ts3_abspath = "{}{}entire3.ts".format( prefix, os.path.normpath(base_uri + "/../.."), ) expected_ts3_path = "../../entire3.ts" expected_ts4_abspath = "{}{}entire4.ts".format(prefix, base_uri + "/") expected_ts4_path = "entire4.ts" expected_ts5_abspath = "{}{}entire5.ts".format(prefix, base_uri + "/") expected_ts5_path = "./entire5.ts" expected_ts6_abspath = "{}{}entire6.ts".format(prefix, base_uri + "/") expected_ts6_path = ".//entire6.ts" assert isinstance(obj, m3u8.M3U8) assert expected_key_path == obj.keys[0].uri assert expected_key_abspath == obj.keys[0].absolute_uri assert expected_ts1_path == obj.segments[0].uri assert expected_ts1_abspath == obj.segments[0].absolute_uri assert expected_ts2_path == obj.segments[1].uri assert expected_ts2_abspath == obj.segments[1].absolute_uri assert expected_ts3_path == obj.segments[2].uri assert expected_ts3_abspath == obj.segments[2].absolute_uri assert expected_ts4_path == obj.segments[3].uri assert expected_ts4_abspath == obj.segments[3].absolute_uri assert expected_ts5_path == obj.segments[4].uri assert expected_ts5_abspath == obj.segments[4].absolute_uri assert expected_ts6_path == obj.segments[5].uri assert expected_ts6_abspath == obj.segments[5].absolute_uri def test_there_should_not_be_absolute_uris_with_loads(): with open(playlists.RELATIVE_PLAYLIST_FILENAME) as f: content = f.read() obj = m3u8.loads(content) with pytest.raises(ValueError) as e: obj.keys[0].absolute_uri assert str(e.value) == "There can not be `absolute_uri` with no `base_uri` set" def test_absolute_uri_should_handle_empty_base_uri_path(): key = m3u8.model.Key(method="AES", uri="/key.bin", base_uri="http://example.com") assert "http://example.com/key.bin" == key.absolute_uri def test_presence_of_base_uri_if_provided_when_loading_from_string(): with open(playlists.RELATIVE_PLAYLIST_FILENAME) as f: content = f.read() obj = m3u8.loads(content, uri="http://media.example.com/path/playlist.m3u8") assert obj.base_uri == "http://media.example.com/path/" def test_raise_timeout_exception_if_timeout_happens_when_loading_from_uri(): try: m3u8.load(playlists.TIMEOUT_SIMPLE_PLAYLIST_URI, timeout=1) except socket.timeout: assert True else: assert False m3u8-6.0.0/tests/test_model.py000077500000000000000000001532301465465410600162060ustar00rootroot00000000000000# Copyright 2014 Globo.com Player authors. All rights reserved. # Use of this source code is governed by a MIT License # license that can be found in the LICENSE file. # Tests M3U8 class to make sure all attributes and methods use the correct # data returned from parser.parse() import datetime import os import textwrap import playlists import pytest import m3u8 from m3u8.model import ( DateRange, Key, Media, MediaList, PartialSegment, PreloadHint, RenditionReport, Segment, SessionData, denormalize_attribute, find_key, ) from m3u8.protocol import ext_x_part, ext_x_preload_hint, ext_x_start utc = datetime.timezone.utc def test_base_path_playlist_with_slash_in_query_string(): playlist = m3u8.M3U8( playlists.PLAYLIST_WITH_SLASH_IN_QUERY_STRING, base_path="http://testvideo.com/foo", ) assert ( playlist.segments[0].uri == "http://testvideo.com/foo/testvideo-1596635509-4769390994-a0e3087c.ts?hdntl=exp=1596678764~acl=/*~data=hdntl~hmac=12345&" ) def test_target_duration_attribute(): obj = m3u8.M3U8(playlists.SIMPLE_PLAYLIST) mock_parser_data(obj, {"targetduration": "1234567"}) assert "1234567" == obj.target_duration def test_media_sequence_attribute(): obj = m3u8.M3U8(playlists.SIMPLE_PLAYLIST) mock_parser_data(obj, {"media_sequence": 1234567}) assert 1234567 == obj.media_sequence def test_program_date_time_attribute(): obj = m3u8.M3U8(playlists.SIMPLE_PLAYLIST_WITH_PROGRAM_DATE_TIME) assert ( datetime.datetime(2014, 8, 13, 13, 36, 33, tzinfo=utc) == obj.program_date_time ) def test_program_date_time_attribute_for_each_segment(): obj = m3u8.M3U8(playlists.SIMPLE_PLAYLIST_WITH_PROGRAM_DATE_TIME) first_program_date_time = datetime.datetime(2014, 8, 13, 13, 36, 33, tzinfo=utc) # first segment contains both program_date_time and current_program_date_time assert obj.segments[0].program_date_time == first_program_date_time assert obj.segments[0].current_program_date_time == first_program_date_time # other segments contain only current_program_date_time for idx, segment in enumerate(obj.segments[1:]): assert segment.program_date_time is None assert ( segment.current_program_date_time == first_program_date_time + datetime.timedelta(seconds=(idx + 1) * 3) ) def test_program_date_time_attribute_with_discontinuity(): obj = m3u8.M3U8(playlists.DISCONTINUITY_PLAYLIST_WITH_PROGRAM_DATE_TIME) first_program_date_time = datetime.datetime(2014, 8, 13, 13, 36, 33, tzinfo=utc) discontinuity_program_date_time = datetime.datetime( 2014, 8, 13, 13, 36, 55, tzinfo=utc ) segments = obj.segments # first segment has EXT-X-PROGRAM-DATE-TIME assert segments[0].program_date_time == first_program_date_time assert segments[0].current_program_date_time == first_program_date_time # second segment does not have EXT-X-PROGRAM-DATE-TIME assert segments[1].program_date_time is None assert segments[ 1 ].current_program_date_time == first_program_date_time + datetime.timedelta( seconds=3 ) # segment with EXT-X-DISCONTINUITY also has EXT-X-PROGRAM-DATE-TIME assert segments[5].program_date_time == discontinuity_program_date_time assert segments[5].current_program_date_time == discontinuity_program_date_time # subsequent segment does not have EXT-X-PROGRAM-DATE-TIME assert segments[ 6 ].current_program_date_time == discontinuity_program_date_time + datetime.timedelta( seconds=3 ) assert segments[6].program_date_time is None def test_program_date_time_attribute_without_discontinuity(): obj = m3u8.M3U8(playlists.PLAYLIST_WITH_PROGRAM_DATE_TIME_WITHOUT_DISCONTINUITY) first_program_date_time = datetime.datetime(2019, 6, 10, 0, 5, tzinfo=utc) for idx, segment in enumerate(obj.segments): program_date_time = first_program_date_time + datetime.timedelta( seconds=idx * 6 ) assert segment.program_date_time == program_date_time assert segment.current_program_date_time == program_date_time def test_segment_discontinuity_attribute(): obj = m3u8.M3U8(playlists.DISCONTINUITY_PLAYLIST_WITH_PROGRAM_DATE_TIME) segments = obj.segments assert segments[0].discontinuity is False assert segments[5].discontinuity is True assert segments[6].discontinuity is False def test_segment_cue_out_attribute(): obj = m3u8.M3U8(playlists.CUE_OUT_PLAYLIST) segments = obj.segments assert segments[1].cue_out is True assert segments[2].cue_out is True assert segments[3].cue_out is False def test_segment_cue_out_start_attribute(): obj = m3u8.M3U8(playlists.CUE_OUT_WITH_DURATION_PLAYLIST) assert obj.segments[0].cue_out_start is True def test_segment_cue_in_attribute(): obj = m3u8.M3U8(playlists.CUE_OUT_WITH_DURATION_PLAYLIST) assert obj.segments[2].cue_in is True def test_segment_cue_out_cont_dumps(): obj = m3u8.M3U8(playlists.CUE_OUT_PLAYLIST) result = obj.dumps() expected = "#EXT-X-CUE-OUT-CONT\n" assert expected in result def test_segment_cue_out_cont_attributes_dumps(): obj = m3u8.M3U8(playlists.CUE_OUT_ELEMENTAL_PLAYLIST) result = obj.dumps() expected = ( "#EXT-X-CUE-OUT-CONT:" "ElapsedTime=7.960," "Duration=50,SCTE35=/DAlAAAAAAAAAP/wFAUAAAABf+//wpiQkv4ARKogAAEBAQAAQ6sodg==\n" ) assert expected in result def test_segment_oatcls_scte35_cue_out_dumps(): obj = m3u8.M3U8(playlists.CUE_OUT_ELEMENTAL_PLAYLIST) result = obj.dumps() # Check OATCLS-SCTE35 for CUE-OUT lines cue_out_line = ( "#EXT-OATCLS-SCTE35:/DAlAAAAAAAAAP/wFAUAAAABf+//wpiQkv4ARKogAAEBAQAAQ6sodg==\n" ) assert result.count(cue_out_line) == 1 def test_segment_oatcls_scte35_non_cue_out_dumps(): obj = m3u8.M3U8(playlists.OATCLS_ELEMENTAL_PLAYLIST) result = obj.dumps() # Check OATCLS-SCTE35 for non-CUE-OUT lines cue_out_line = "/DAqAAAAAyiYAP/wBQb/FuaKGAAUAhJDVUVJAAAFp3+/EQMCRgIMAQF7Ny4D\n" assert result.count(cue_out_line) == 1 def test_segment_cue_out_start_dumps(): obj = m3u8.M3U8(playlists.CUE_OUT_WITH_DURATION_PLAYLIST) result = obj.dumps() expected = "#EXT-X-CUE-OUT:11.52\n" assert expected in result def test_segment_cue_out_start_explicit_dumps(): obj = m3u8.M3U8(playlists.CUE_OUT_WITH_EXPLICIT_DURATION_PLAYLIST) result = obj.dumps() expected = "#EXT-X-CUE-OUT:DURATION=11.52\n" assert expected in result def test_segment_cue_out_start_no_duration_dumps(): obj = m3u8.M3U8(playlists.CUE_OUT_NO_DURATION_PLAYLIST) result = obj.dumps() expected = "#EXT-X-CUE-OUT\n" assert expected in result def test_segment_cue_out_in_dumps(): obj = m3u8.M3U8(playlists.CUE_OUT_NO_DURATION_PLAYLIST) result = obj.dumps() expected = "#EXT-X-CUE-IN\n" assert expected in result def test_segment_elemental_scte35_attribute(): obj = m3u8.M3U8(playlists.CUE_OUT_ELEMENTAL_PLAYLIST) segments = obj.segments assert segments[4].cue_out is True assert segments[9].cue_out is False assert ( segments[4].scte35 == "/DAlAAAAAAAAAP/wFAUAAAABf+//wpiQkv4ARKogAAEBAQAAQ6sodg==" ) def test_segment_cue_out_cont_alt(): obj = m3u8.M3U8(playlists.CUE_OUT_CONT_ALT_PLAYLIST) segments = obj.segments assert segments[1].scte35_elapsedtime == "2" assert segments[1].scte35_duration == "120" assert segments[2].scte35_elapsedtime == "8" assert segments[2].scte35_duration == "120.0" assert segments[3].scte35_elapsedtime == "14.001" assert segments[3].scte35_duration == "120.0" def test_segment_cue_out_cont_mediaconvert(): obj = m3u8.M3U8(playlists.CUE_OUT_MEDIACONVERT_PLAYLIST) segments = obj.segments assert segments[2].scte35_elapsedtime == "10" assert segments[2].scte35_duration == "4" def test_segment_envivio_scte35_attribute(): obj = m3u8.M3U8(playlists.CUE_OUT_ENVIVIO_PLAYLIST) segments = obj.segments assert segments[3].cue_out is True assert ( segments[4].scte35 == "/DAlAAAENOOQAP/wFAUBAABrf+//N25XDf4B9p/gAAEBAQAAxKni9A==" ) assert ( segments[5].scte35 == "/DAlAAAENOOQAP/wFAUBAABrf+//N25XDf4B9p/gAAEBAQAAxKni9A==" ) assert segments[7].cue_out is False def test_segment_unknown_scte35_attribute(): obj = m3u8.M3U8(playlists.CUE_OUT_INVALID_PLAYLIST) assert obj.segments[0].scte35 is None assert obj.segments[0].scte35_duration == "INVALID" def test_segment_cue_out_no_duration(): obj = m3u8.M3U8(playlists.CUE_OUT_NO_DURATION_PLAYLIST) assert obj.segments[0].cue_out_start is True assert obj.segments[2].cue_in is True def test_segment_asset_metadata_dumps(): obj = m3u8.M3U8(playlists.CUE_OUT_ELEMENTAL_PLAYLIST) result = obj.dumps() # Only insert EXT-X-ASSET at cue out asset_metadata_line = ( '#EXT-X-ASSET:GENRE=CV,CAID=12345678,EPISODE="Episode%20Name%20Date",' 'SEASON="Season%20Name%20and%20Number",SERIES="Series%2520Name"\n' ) assert result.count(asset_metadata_line) == 1 def test_keys_on_clear_playlist(): obj = m3u8.M3U8(playlists.SIMPLE_PLAYLIST) assert len(obj.keys) == 1 assert obj.keys[0] is None def test_keys_on_simple_encrypted_playlist(): obj = m3u8.M3U8(playlists.PLAYLIST_WITH_ENCRYPTED_SEGMENTS) assert len(obj.keys) == 1 assert obj.keys[0].uri == "https://priv.example.com/key.php?r=52" def test_key_attribute(): obj = m3u8.M3U8(playlists.SIMPLE_PLAYLIST) data = {"keys": [{"method": "AES-128", "uri": "/key", "iv": "foobar"}]} mock_parser_data(obj, data) assert "Key" == obj.keys[0].__class__.__name__ assert "AES-128" == obj.keys[0].method assert "/key" == obj.keys[0].uri assert "foobar" == obj.keys[0].iv def test_key_attribute_on_none(): obj = m3u8.M3U8(playlists.SIMPLE_PLAYLIST) mock_parser_data(obj, {}) assert len(obj.keys) == 0 def test_key_attribute_without_initialization_vector(): obj = m3u8.M3U8(playlists.SIMPLE_PLAYLIST) mock_parser_data(obj, {"keys": [{"method": "AES-128", "uri": "/key"}]}) assert "AES-128" == obj.keys[0].method assert "/key" == obj.keys[0].uri assert None is obj.keys[0].iv def test_session_keys_on_clear_playlist(): obj = m3u8.M3U8(playlists.SIMPLE_PLAYLIST) assert len(obj.session_keys) == 0 def test_session_keys_on_simple_encrypted_playlist(): obj = m3u8.M3U8(playlists.PLAYLIST_WITH_SESSION_ENCRYPTED_SEGMENTS) assert len(obj.session_keys) == 1 assert obj.session_keys[0].uri == "https://priv.example.com/key.php?r=52" def test_session_key_attribute(): obj = m3u8.M3U8(playlists.SIMPLE_PLAYLIST) data = {"session_keys": [{"method": "AES-128", "uri": "/key", "iv": "foobar"}]} mock_parser_data(obj, data) assert "SessionKey" == obj.session_keys[0].__class__.__name__ assert "AES-128" == obj.session_keys[0].method assert "/key" == obj.session_keys[0].uri assert "foobar" == obj.session_keys[0].iv def test_session_key_attribute_on_none(): obj = m3u8.M3U8(playlists.SIMPLE_PLAYLIST) mock_parser_data(obj, {}) assert len(obj.session_keys) == 0 def test_session_key_attribute_without_initialization_vector(): obj = m3u8.M3U8(playlists.SIMPLE_PLAYLIST) mock_parser_data(obj, {"session_keys": [{"method": "AES-128", "uri": "/key"}]}) assert "AES-128" == obj.session_keys[0].method assert "/key" == obj.session_keys[0].uri assert None is obj.session_keys[0].iv def test_parse_tag_name_matches_fully(): assert m3u8.M3U8(playlists.PLAYLIST_WITH_TAG_MEDIA_READY) def test_segments_attribute(): obj = m3u8.M3U8(playlists.SIMPLE_PLAYLIST) mock_parser_data( obj, { "segments": [ {"uri": "/foo/bar-1.ts", "title": "First Segment", "duration": 1500}, {"uri": "/foo/bar-2.ts", "title": "Second Segment", "duration": 1600}, ] }, ) assert 2 == len(obj.segments) assert "/foo/bar-1.ts" == obj.segments[0].uri assert "First Segment" == obj.segments[0].title assert 1500 == obj.segments[0].duration def test_segments_attribute_without_title(): obj = m3u8.M3U8(playlists.SIMPLE_PLAYLIST) mock_parser_data(obj, {"segments": [{"uri": "/foo/bar-1.ts", "duration": 1500}]}) assert 1 == len(obj.segments) assert "/foo/bar-1.ts" == obj.segments[0].uri assert 1500 == obj.segments[0].duration assert None is obj.segments[0].title def test_segments_attribute_without_duration(): obj = m3u8.M3U8(playlists.SIMPLE_PLAYLIST) mock_parser_data( obj, {"segments": [{"uri": "/foo/bar-1.ts", "title": "Segment title"}]} ) assert 1 == len(obj.segments) assert "/foo/bar-1.ts" == obj.segments[0].uri assert "Segment title" == obj.segments[0].title assert None is obj.segments[0].duration def test_segments_attribute_with_byterange(): obj = m3u8.M3U8(playlists.SIMPLE_PLAYLIST) mock_parser_data( obj, { "segments": [ { "uri": "/foo/bar-1.ts", "title": "Segment title", "duration": 1500, "byterange": "76242@0", } ] }, ) assert 1 == len(obj.segments) assert "/foo/bar-1.ts" == obj.segments[0].uri assert "Segment title" == obj.segments[0].title assert 1500 == obj.segments[0].duration assert "76242@0" == obj.segments[0].byterange def test_segment_attribute_with_multiple_keys(): obj = m3u8.M3U8( playlists.PLAYLIST_WITH_ENCRYPTED_SEGMENTS_AND_IV_WITH_MULTIPLE_KEYS ) segments = obj.segments assert segments[0].key.uri == "/hls-key/key.bin" assert segments[1].key.uri == "/hls-key/key.bin" assert segments[4].key.uri == "/hls-key/key2.bin" assert segments[5].key.uri == "/hls-key/key2.bin" def test_segment_title_dumps(): obj = m3u8.M3U8(playlists.SIMPLE_PLAYLIST_WITH_QUOTED_TITLE) result = obj.segments[0].dumps(None).strip() expected = '#EXTINF:5220,"A sample title"\nhttp://media.example.com/entire.ts' assert result == expected def test_is_variant_attribute(): obj = m3u8.M3U8(playlists.SIMPLE_PLAYLIST) mock_parser_data(obj, {"is_variant": False}) assert not obj.is_variant mock_parser_data(obj, {"is_variant": True}) assert obj.is_variant def test_is_endlist_attribute(): obj = m3u8.M3U8(playlists.SIMPLE_PLAYLIST) mock_parser_data(obj, {"is_endlist": False}) assert not obj.is_endlist obj = m3u8.M3U8(playlists.SLIDING_WINDOW_PLAYLIST) mock_parser_data(obj, {"is_endlist": True}) assert obj.is_endlist def test_is_i_frames_only_attribute(): obj = m3u8.M3U8(playlists.SIMPLE_PLAYLIST) mock_parser_data(obj, {"is_i_frames_only": False}) assert not obj.is_i_frames_only mock_parser_data(obj, {"is_i_frames_only": True}) assert obj.is_i_frames_only def test_playlists_attribute(): obj = m3u8.M3U8(playlists.SIMPLE_PLAYLIST) data = { "playlists": [ { "uri": "/url/1.m3u8", "stream_info": { "program_id": 1, "bandwidth": 320000, "closed_captions": None, "video": "high", }, }, { "uri": "/url/2.m3u8", "stream_info": { "program_id": 1, "bandwidth": 120000, "closed_captions": None, "codecs": "mp4a.40.5", "video": "low", }, }, ], "media": [ {"type": "VIDEO", "name": "High", "group_id": "high"}, { "type": "VIDEO", "name": "Low", "group_id": "low", "default": "YES", "autoselect": "YES", }, ], } mock_parser_data(obj, data) assert 2 == len(obj.playlists) assert "/url/1.m3u8" == obj.playlists[0].uri assert 1 == obj.playlists[0].stream_info.program_id assert 320000 == obj.playlists[0].stream_info.bandwidth assert None is obj.playlists[0].stream_info.closed_captions assert None is obj.playlists[0].stream_info.codecs assert None is obj.playlists[0].media[0].uri assert "high" == obj.playlists[0].media[0].group_id assert "VIDEO" == obj.playlists[0].media[0].type assert None is obj.playlists[0].media[0].language assert "High" == obj.playlists[0].media[0].name assert None is obj.playlists[0].media[0].default assert None is obj.playlists[0].media[0].autoselect assert None is obj.playlists[0].media[0].forced assert None is obj.playlists[0].media[0].characteristics assert "/url/2.m3u8" == obj.playlists[1].uri assert 1 == obj.playlists[1].stream_info.program_id assert 120000 == obj.playlists[1].stream_info.bandwidth assert None is obj.playlists[1].stream_info.closed_captions assert "mp4a.40.5" == obj.playlists[1].stream_info.codecs assert None is obj.playlists[1].media[0].uri assert "low" == obj.playlists[1].media[0].group_id assert "VIDEO" == obj.playlists[1].media[0].type assert None is obj.playlists[1].media[0].language assert "Low" == obj.playlists[1].media[0].name assert "YES" == obj.playlists[1].media[0].default assert "YES" == obj.playlists[1].media[0].autoselect assert None is obj.playlists[1].media[0].forced assert None is obj.playlists[1].media[0].characteristics assert [] == obj.iframe_playlists def test_playlists_attribute_without_program_id(): obj = m3u8.M3U8(playlists.SIMPLE_PLAYLIST) mock_parser_data( obj, {"playlists": [{"uri": "/url/1.m3u8", "stream_info": {"bandwidth": 320000}}]}, ) assert 1 == len(obj.playlists) assert "/url/1.m3u8" == obj.playlists[0].uri assert 320000 == obj.playlists[0].stream_info.bandwidth assert None is obj.playlists[0].stream_info.codecs assert None is obj.playlists[0].stream_info.program_id def test_playlists_attribute_with_resolution(): obj = m3u8.M3U8(playlists.SIMPLE_PLAYLIST_WITH_RESOLUTION) assert 2 == len(obj.playlists) assert (512, 288) == obj.playlists[0].stream_info.resolution assert None is obj.playlists[1].stream_info.resolution def test_iframe_playlists_attribute(): obj = m3u8.M3U8(playlists.SIMPLE_PLAYLIST) data = { "iframe_playlists": [ { "uri": "/url/1.m3u8", "iframe_stream_info": { "program_id": 1, "bandwidth": 320000, "resolution": "320x180", "codecs": "avc1.4d001f", }, }, { "uri": "/url/2.m3u8", "iframe_stream_info": {"bandwidth": "120000", "codecs": "avc1.4d400d"}, }, ] } mock_parser_data(obj, data) assert 2 == len(obj.iframe_playlists) assert "/url/1.m3u8" == obj.iframe_playlists[0].uri assert 1 == obj.iframe_playlists[0].iframe_stream_info.program_id assert 320000 == obj.iframe_playlists[0].iframe_stream_info.bandwidth assert (320, 180) == obj.iframe_playlists[0].iframe_stream_info.resolution assert "avc1.4d001f" == obj.iframe_playlists[0].iframe_stream_info.codecs assert "/url/2.m3u8" == obj.iframe_playlists[1].uri assert None is obj.iframe_playlists[1].iframe_stream_info.program_id assert "120000" == obj.iframe_playlists[1].iframe_stream_info.bandwidth assert None is obj.iframe_playlists[1].iframe_stream_info.resolution assert "avc1.4d400d" == obj.iframe_playlists[1].iframe_stream_info.codecs def test_version_attribute(): obj = m3u8.M3U8(playlists.SIMPLE_PLAYLIST) mock_parser_data(obj, {"version": 2}) assert 2 == obj.version mock_parser_data(obj, {}) assert None is obj.version def test_version_settable_as_int(): obj = m3u8.loads(playlists.VERSION_PLAYLIST) obj.version = 9 assert "#EXT-X-VERSION:9" in obj.dumps().strip() def test_version_settable_as_string(): obj = m3u8.loads(playlists.VERSION_PLAYLIST) obj.version = "9" assert "#EXT-X-VERSION:9" in obj.dumps().strip() def test_allow_cache_attribute(): obj = m3u8.M3U8(playlists.SIMPLE_PLAYLIST) mock_parser_data(obj, {"allow_cache": "no"}) assert "no" == obj.allow_cache mock_parser_data(obj, {}) assert None is obj.allow_cache def test_files_attribute_should_list_all_files_including_segments_and_key(): obj = m3u8.M3U8(playlists.PLAYLIST_WITH_ENCRYPTED_SEGMENTS) files = [ "https://priv.example.com/key.php?r=52", "http://media.example.com/fileSequence52-1.ts", "http://media.example.com/fileSequence52-2.ts", "http://media.example.com/fileSequence52-3.ts", ] assert files == obj.files def test_vod_playlist_type_should_be_imported_as_a_simple_attribute(): obj = m3u8.M3U8(playlists.SIMPLE_PLAYLIST_WITH_VOD_PLAYLIST_TYPE) assert obj.playlist_type == "vod" def test_event_playlist_type_should_be_imported_as_a_simple_attribute(): obj = m3u8.M3U8(playlists.SIMPLE_PLAYLIST_WITH_EVENT_PLAYLIST_TYPE) assert obj.playlist_type == "event" def test_independent_segments_should_be_true(): obj = m3u8.M3U8(playlists.SIMPLE_PLAYLIST_WITH_INDEPENDENT_SEGMENTS) assert obj.is_independent_segments def test_independent_segments_should_be_false(): obj = m3u8.M3U8(playlists.SIMPLE_PLAYLIST_WITH_EVENT_PLAYLIST_TYPE) assert not obj.is_independent_segments def test_no_playlist_type_leaves_attribute_empty(): obj = m3u8.M3U8(playlists.SIMPLE_PLAYLIST) assert obj.playlist_type is None def test_dump_playlists_with_resolution(): obj = m3u8.M3U8(playlists.SIMPLE_PLAYLIST_WITH_RESOLUTION) expected = playlists.SIMPLE_PLAYLIST_WITH_RESOLUTION.strip().splitlines() assert expected == obj.dumps().strip().splitlines() def test_dump_should_build_file_with_same_content(tmpdir): obj = m3u8.M3U8(playlists.PLAYLIST_WITH_ENCRYPTED_SEGMENTS_AND_IV) expected = playlists.PLAYLIST_WITH_ENCRYPTED_SEGMENTS_AND_IV_SORTED.replace( ", IV", ",IV" ).strip() filename = str(tmpdir.join("playlist.m3u8")) obj.dump(filename) assert_file_content(filename, expected) def test_dump_should_create_sub_directories(tmpdir): obj = m3u8.M3U8(playlists.PLAYLIST_WITH_ENCRYPTED_SEGMENTS_AND_IV) expected = playlists.PLAYLIST_WITH_ENCRYPTED_SEGMENTS_AND_IV_SORTED.replace( ", IV", ",IV" ).strip() filename = str(tmpdir.join("subdir1", "subdir2", "playlist.m3u8")) obj.dump(filename) assert_file_content(filename, expected) def test_dump_should_raise_if_create_sub_directories_fails(tmpdir): # The first subdirectory is read-only subdir_1 = os.path.join(tmpdir, "subdir1") os.mkdir(subdir_1, mode=0o400) # The file is to be stored in a second subdirectory that's underneath the first subdir_2 = os.path.join(subdir_1, "subdir2") file_name = os.path.join(subdir_2, "playlist.m3u8") # When we try to write it, we'll be prevented from creating the second subdirectory with pytest.raises(OSError): m3u8.M3U8(playlists.SIMPLE_PLAYLIST).dump(file_name) def test_create_sub_directories_with_relative_path(tmpdir, monkeypatch): relative_path = os.path.join("relative", "path", "playlist.m3u8") # Use a temporary directory as the current working directory for the test monkeypatch.chdir(tmpdir) obj = m3u8.M3U8(playlists.SIMPLE_PLAYLIST) obj.dump(relative_path) expected_file_path = os.path.join(tmpdir, relative_path) assert os.path.exists(expected_file_path) with open(expected_file_path, "r") as file: assert file.read().strip() == playlists.SIMPLE_PLAYLIST.strip() def test_dump_should_work_for_variant_streams(): obj = m3u8.M3U8(playlists.VARIANT_PLAYLIST) expected = playlists.VARIANT_PLAYLIST.replace(", BANDWIDTH", ",BANDWIDTH").strip() assert expected == obj.dumps().strip() def test_dump_should_work_for_variant_playlists_with_iframe_playlists(): obj = m3u8.M3U8(playlists.VARIANT_PLAYLIST_WITH_IFRAME_PLAYLISTS) expected = playlists.VARIANT_PLAYLIST_WITH_IFRAME_PLAYLISTS.strip() assert expected == obj.dumps().strip() def test_dump_should_work_for_iframe_playlists(): obj = m3u8.M3U8(playlists.IFRAME_PLAYLIST) expected = playlists.IFRAME_PLAYLIST.strip() assert expected == obj.dumps().strip() obj = m3u8.M3U8(playlists.IFRAME_PLAYLIST2) expected = playlists.IFRAME_PLAYLIST.strip() # expected that dump will reverse EXTINF and EXT-X-BYTERANGE, # hence IFRAME_PLAYLIST dump from IFRAME_PLAYLIST2 parse. assert expected == obj.dumps().strip() obj = m3u8.M3U8(playlists.IFRAME_PLAYLIST2) expected = playlists.IFRAME_PLAYLIST.strip() # expected that dump will reverse EXTINF and EXT-X-BYTERANGE, # hence IFRAME_PLAYLIST dump from IFRAME_PLAYLIST2 parse. assert expected == obj.dumps().strip() def test_dump_should_include_program_date_time(): obj = m3u8.M3U8(playlists.SIMPLE_PLAYLIST_WITH_PROGRAM_DATE_TIME) assert ( "EXT-X-PROGRAM-DATE-TIME:2014-08-13T13:36:33.000+00:00" in obj.dumps().strip() ) def test_dump_segment_honors_timespec(): segment = m3u8.M3U8(playlists.SIMPLE_PLAYLIST_WITH_PROGRAM_DATE_TIME).segments[0] segment_text = segment.dumps(None, timespec="microseconds").strip() assert "EXT-X-PROGRAM-DATE-TIME:2014-08-13T13:36:33.000000+00:00" in segment_text def test_dump_honors_timespec(): obj = m3u8.M3U8(playlists.SIMPLE_PLAYLIST_WITH_PROGRAM_DATE_TIME) obj_text = obj.dumps(timespec="microseconds").strip() assert "EXT-X-PROGRAM-DATE-TIME:2014-08-13T13:36:33.000000+00:00" in obj_text def test_dump_should_not_ignore_zero_duration(): obj = m3u8.M3U8(playlists.SIMPLE_PLAYLIST_WITH_ZERO_DURATION) assert "EXTINF:0" in obj.dumps().strip() assert "EXTINF:5220" in obj.dumps().strip() assert "EXTINF:0.000" in obj.dumps(infspec="milliseconds").strip() assert "EXTINF:5220.000" in obj.dumps(infspec="milliseconds").strip() assert "EXTINF:0.000000" in obj.dumps(infspec="microseconds").strip() assert "EXTINF:5220.000000" in obj.dumps(infspec="microseconds").strip() def test_dump_should_use_decimal_floating_point_for_very_short_durations(): obj = m3u8.M3U8(playlists.SIMPLE_PLAYLIST_WITH_VERY_SHORT_DURATION) assert "EXTINF:5220" in obj.dumps().strip() assert "EXTINF:5218.5" in obj.dumps().strip() assert "EXTINF:0.000011" in obj.dumps().strip() assert "EXTINF:5220.000" in obj.dumps(infspec="milliseconds").strip() assert "EXTINF:5218.500" in obj.dumps(infspec="milliseconds").strip() assert "EXTINF:0.000" in obj.dumps(infspec="milliseconds").strip() assert "EXTINF:5220.000000" in obj.dumps(infspec="microseconds").strip() assert "EXTINF:5218.500" in obj.dumps(infspec="microseconds").strip() assert "EXTINF:0.000011" in obj.dumps(infspec="microseconds").strip() def test_dump_should_include_segment_level_program_date_time(): obj = m3u8.M3U8(playlists.DISCONTINUITY_PLAYLIST_WITH_PROGRAM_DATE_TIME) # Tag being expected is in the segment level, not the global one assert ( "#EXT-X-PROGRAM-DATE-TIME:2014-08-13T13:36:55.000+00:00" in obj.dumps().strip() ) def test_dump_should_include_segment_level_program_date_time_without_discontinuity(): obj = m3u8.M3U8(playlists.PLAYLIST_WITH_PROGRAM_DATE_TIME_WITHOUT_DISCONTINUITY) output = obj.dumps().strip() assert "#EXT-X-PROGRAM-DATE-TIME:2019-06-10T00:05:00.000+00:00" in output assert "#EXT-X-PROGRAM-DATE-TIME:2019-06-10T00:05:06.000+00:00" in output assert "#EXT-X-PROGRAM-DATE-TIME:2019-06-10T00:05:12.000+00:00" in output def test_dump_should_include_map_attributes(): obj = m3u8.M3U8(playlists.MAP_URI_PLAYLIST_WITH_BYTERANGE) assert 'EXT-X-MAP:URI="main.mp4",BYTERANGE="812@0"' in obj.dumps().strip() def test_multiple_map_attributes(): obj = m3u8.M3U8(playlists.MULTIPLE_MAP_URI_PLAYLIST) assert obj.segments[0].init_section.uri == "init1.mp4" assert obj.segments[1].init_section.uri == "init1.mp4" assert obj.segments[2].init_section.uri == "init3.mp4" def test_dump_should_include_multiple_map_attributes(tmpdir): obj = m3u8.M3U8(playlists.MULTIPLE_MAP_URI_PLAYLIST) output = obj.dump(str(tmpdir.join("d.m3u8"))) output = obj.dumps().strip() assert output.count('#EXT-X-MAP:URI="init1.mp4"') == 1 assert output.count('#EXT-X-MAP:URI="init3.mp4"') == 1 def test_dump_should_work_for_playlists_using_byteranges(): obj = m3u8.M3U8(playlists.PLAYLIST_USING_BYTERANGES) expected = playlists.PLAYLIST_USING_BYTERANGES.strip() assert expected == obj.dumps().strip() def test_should_dump_with_endlist_tag(): obj = m3u8.M3U8(playlists.SLIDING_WINDOW_PLAYLIST) obj.is_endlist = True assert "#EXT-X-ENDLIST" in obj.dumps().splitlines() def test_should_dump_without_endlist_tag(): obj = m3u8.M3U8(playlists.SIMPLE_PLAYLIST) obj.is_endlist = False expected = playlists.SIMPLE_PLAYLIST.strip().splitlines() expected.remove("#EXT-X-ENDLIST") assert expected == obj.dumps().strip().splitlines() def test_should_dump_multiple_keys(): obj = m3u8.M3U8( playlists.PLAYLIST_WITH_ENCRYPTED_SEGMENTS_AND_IV_WITH_MULTIPLE_KEYS ) expected = playlists.PLAYLIST_WITH_ENCRYPTED_SEGMENTS_AND_IV_WITH_MULTIPLE_KEYS_SORTED.strip() assert expected == obj.dumps().strip() def test_should_dump_unencrypted_encrypted_keys_together(): obj = m3u8.M3U8(playlists.PLAYLIST_WITH_MULTIPLE_KEYS_UNENCRYPTED_AND_ENCRYPTED) expected = playlists.PLAYLIST_WITH_MULTIPLE_KEYS_UNENCRYPTED_AND_ENCRYPTED.strip() assert expected == obj.dumps().strip() def test_should_dump_complex_unencrypted_encrypted_keys(): obj = m3u8.M3U8( playlists.PLAYLIST_WITH_MULTIPLE_KEYS_UNENCRYPTED_AND_ENCRYPTED_NONE ) expected = ( playlists.PLAYLIST_WITH_MULTIPLE_KEYS_UNENCRYPTED_AND_ENCRYPTED_NONE.replace( 'METHOD=NONE,URI=""', "METHOD=NONE" ).strip() ) assert expected == obj.dumps().strip() def test_should_dump_complex_unencrypted_encrypted_keys_no_uri_attr(): obj = m3u8.M3U8( playlists.PLAYLIST_WITH_MULTIPLE_KEYS_UNENCRYPTED_AND_ENCRYPTED_NONE_AND_NO_URI_ATTR ) expected = playlists.PLAYLIST_WITH_MULTIPLE_KEYS_UNENCRYPTED_AND_ENCRYPTED_NONE_AND_NO_URI_ATTR.strip() assert expected == obj.dumps().strip() def test_should_dump_session_data(): obj = m3u8.M3U8(playlists.SESSION_DATA_PLAYLIST) expected = playlists.SESSION_DATA_PLAYLIST.strip() assert expected == obj.dumps().strip() def test_should_dump_multiple_session_data(): obj = m3u8.M3U8(playlists.MULTIPLE_SESSION_DATA_PLAYLIST) expected = playlists.MULTIPLE_SESSION_DATA_PLAYLIST.strip() assert expected == obj.dumps().strip() def test_length_segments_by_key(): obj = m3u8.M3U8(playlists.PLAYLIST_WITH_MULTIPLE_KEYS_UNENCRYPTED_AND_ENCRYPTED) assert len(obj.segments.by_key(obj.keys[0])) == 2 assert len(obj.segments.by_key(obj.keys[1])) == 4 assert len(obj.segments.by_key(obj.keys[2])) == 2 def test_list_segments_by_key(): obj = m3u8.M3U8(playlists.PLAYLIST_WITH_MULTIPLE_KEYS_UNENCRYPTED_AND_ENCRYPTED) # unencrypted segments segments = obj.segments.by_key(None) expected = "../../../../hls/streamNum82400.ts\n../../../../hls/streamNum82401.ts" output = [segment.uri for segment in segments] assert "\n".join(output).strip() == expected.strip() # segments for last key segments = obj.segments.by_key(obj.keys[2]) expected = "../../../../hls/streamNum82404.ts\n../../../../hls/streamNum82405.ts" output = [segment.uri for segment in segments] assert "\n".join(output).strip() == expected.strip() def test_replace_segment_key(): obj = m3u8.M3U8(playlists.PLAYLIST_WITH_MULTIPLE_KEYS_UNENCRYPTED_AND_ENCRYPTED) # Replace unencrypted segments with new key new_key = Key( "AES-128", None, "/hls-key/key0.bin", iv="0Xcafe8f758ca555115584bb5b3c687f52" ) for segment in obj.segments.by_key(None): segment.key = new_key # Check dump expected = ( playlists.PLAYLIST_WITH_MULTIPLE_KEYS_UNENCRYPTED_AND_ENCRYPTED_UPDATED.strip() ) assert obj.dumps().strip() == expected def test_keyformat_and_keyformatversion(): obj = m3u8.M3U8(playlists.PLAYLIST_WITH_KEYFORMAT_AND_KEYFORMATVERSIONS) result = obj.dumps().strip() expected = 'KEYFORMAT="com.apple.streamingkeydelivery",KEYFORMATVERSIONS="1"' assert expected in result def test_should_dump_program_datetime_and_discontinuity(): obj = m3u8.M3U8(playlists.DISCONTINUITY_PLAYLIST_WITH_PROGRAM_DATE_TIME) expected = playlists.DISCONTINUITY_PLAYLIST_WITH_PROGRAM_DATE_TIME.strip() assert expected == obj.dumps().strip() def test_should_normalize_segments_and_key_urls_if_base_path_passed_to_constructor(): base_path = "http://videoserver.com/hls/live" obj = m3u8.M3U8(playlists.PLAYLIST_WITH_ENCRYPTED_SEGMENTS_AND_IV, base_path) assert obj.base_path == base_path expected = ( playlists.PLAYLIST_WITH_ENCRYPTED_SEGMENTS_AND_IV_SORTED.replace(", IV", ",IV") .replace("../../../../hls", base_path) .replace("/hls-key", base_path) .strip() ) assert obj.dumps().strip() == expected def test_should_normalize_session_key_urls_if_base_path_passed_to_constructor(): base_path = "http://videoserver.com/hls/live" obj = m3u8.M3U8( playlists.PLAYLIST_WITH_SESSION_ENCRYPTED_SEGMENTS_AND_IV, base_path ) assert obj.base_path == base_path expected = ( playlists.PLAYLIST_WITH_SESSION_ENCRYPTED_SEGMENTS_AND_IV_SORTED.replace( ", IV", ",IV" ) .replace("../../../../hls", base_path) .replace("/hls-key", base_path) .strip() ) assert obj.dumps().strip() == expected def test_should_normalize_variant_streams_urls_if_base_path_passed_to_constructor(): base_path = "http://videoserver.com/hls/live" obj = m3u8.M3U8(playlists.VARIANT_PLAYLIST, base_path) expected = ( playlists.VARIANT_PLAYLIST.replace(", BANDWIDTH", ",BANDWIDTH") .replace("http://example.com", base_path) .strip() ) assert obj.dumps().strip() == expected def test_should_normalize_segments_and_key_urls_if_base_path_attribute_updated(): base_path = "http://videoserver.com/hls/live" obj = m3u8.M3U8(playlists.PLAYLIST_WITH_ENCRYPTED_SEGMENTS_AND_IV) obj.base_path = base_path expected = ( playlists.PLAYLIST_WITH_ENCRYPTED_SEGMENTS_AND_IV_SORTED.replace(", IV", ",IV") .replace("../../../../hls", base_path) .replace("/hls-key", base_path) .strip() ) assert obj.dumps().strip() == expected def test_playlist_type_dumped_to_appropriate_m3u8_field(): obj = m3u8.M3U8() obj.playlist_type = "vod" result = obj.dumps() expected = "#EXTM3U\n#EXT-X-PLAYLIST-TYPE:VOD\n" assert result == expected def test_empty_playlist_type_is_gracefully_ignored(): obj = m3u8.M3U8() obj.playlist_type = "" result = obj.dumps() expected = "#EXTM3U\n" assert result == expected def test_none_playlist_type_is_gracefully_ignored(): obj = m3u8.M3U8() obj.playlist_type = None result = obj.dumps() expected = "#EXTM3U\n" assert result == expected def test_0_media_sequence_added_to_file(): obj = m3u8.M3U8() obj.media_sequence = 0 result = obj.dumps() expected = "#EXTM3U\n" assert result == expected def test_none_media_sequence_gracefully_ignored(): obj = m3u8.M3U8() obj.media_sequence = None result = obj.dumps() expected = "#EXTM3U\n" assert result == expected def test_0_discontinuity_sequence_added_to_file(): obj = m3u8.M3U8() obj.discontinuity_sequence = 0 result = obj.dumps() expected = "#EXTM3U\n" assert result == expected def test_none_discontinuity_sequence_gracefully_ignored(): obj = m3u8.M3U8() obj.discontinuity_sequence = None result = obj.dumps() expected = "#EXTM3U\n" assert result == expected def test_non_zero_discontinuity_sequence_added_to_file(): obj = m3u8.M3U8() obj.discontinuity_sequence = 1 result = obj.dumps() expected = "#EXT-X-DISCONTINUITY-SEQUENCE:1" assert expected in result def test_should_correctly_update_base_path_if_its_blank(): segment = Segment("entire.ts", "http://1.2/") assert not segment.base_path segment.base_path = "base_path" assert "http://1.2/base_path/entire.ts" == segment.absolute_uri def test_base_path_should_just_return_uri_if_absolute(): segment = Segment("http://1.2/entire.ts", "") assert "http://1.2/entire.ts" == segment.absolute_uri def test_m3u8_should_propagate_base_uri_to_segments(): with open(playlists.RELATIVE_PLAYLIST_FILENAME) as f: content = f.read() obj = m3u8.M3U8(content, base_uri="/any/path") assert "/entire1.ts" == obj.segments[0].uri assert "/entire1.ts" == obj.segments[0].absolute_uri assert "entire4.ts" == obj.segments[3].uri assert "/any/path/entire4.ts" == obj.segments[3].absolute_uri obj.base_uri = "/any/where/" assert "/entire1.ts" == obj.segments[0].uri assert "/entire1.ts" == obj.segments[0].absolute_uri assert "entire4.ts" == obj.segments[3].uri assert "/any/where/entire4.ts" == obj.segments[3].absolute_uri def test_m3u8_should_propagate_base_uri_to_key(): with open(playlists.RELATIVE_PLAYLIST_FILENAME) as f: content = f.read() obj = m3u8.M3U8(content, base_uri="/any/path") assert "../key.bin" == obj.keys[0].uri assert "/any/key.bin" == obj.keys[0].absolute_uri obj.base_uri = "/any/where/" assert "../key.bin" == obj.keys[0].uri assert "/any/key.bin" == obj.keys[0].absolute_uri def test_m3u8_should_propagate_base_uri_to_session_key(): with open(playlists.RELATIVE_PLAYLIST_FILENAME) as f: content = f.read() obj = m3u8.M3U8(content, base_uri="/any/path") assert "../key.bin" == obj.session_keys[0].uri assert "/any/key.bin" == obj.session_keys[0].absolute_uri obj.base_uri = "/any/where/" assert "../key.bin" == obj.session_keys[0].uri assert "/any/key.bin" == obj.session_keys[0].absolute_uri def test_base_path_with_optional_uri_should_do_nothing(): media = Media(type="AUDIO", group_id="audio-group", name="English") assert media.uri is None assert media.base_uri is None media.base_path = "base_path" assert media.absolute_uri is None assert media.base_path is None def test_medialist_uri_method(): langs = ["English", "French", "German"] ml = MediaList() for lang in langs: ml.append( Media( type="AUDIO", group_id="audio-group", name=lang, uri=("/%s.m3u8" % lang) ) ) assert len(ml.uri) == len(langs) assert ml.uri[0] == "/%s.m3u8" % langs[0] assert ml.uri[1] == "/%s.m3u8" % langs[1] assert ml.uri[2] == "/%s.m3u8" % langs[2] def test_segment_map_uri_attribute(): obj = m3u8.M3U8(playlists.MAP_URI_PLAYLIST) assert obj.segment_map[0].uri == "fileSequence0.mp4" def test_segment_map_uri_attribute_with_byterange(): obj = m3u8.M3U8(playlists.MAP_URI_PLAYLIST_WITH_BYTERANGE) assert obj.segment_map[0].uri == "main.mp4" assert obj.segment_map[0].byterange == "812@0" assert obj.segment_map[1].uri == "main2.mp4" assert obj.segment_map[1].byterange == "912@0" def test_start_with_negative_offset(): obj = m3u8.M3U8(playlists.SIMPLE_PLAYLIST_WITH_START_NEGATIVE_OFFSET) assert obj.start.time_offset == -2.0 assert obj.start.precise is None assert ext_x_start + ":TIME-OFFSET=-2.0\n" in obj.dumps() def test_start_with_precise(): obj = m3u8.M3U8(playlists.SIMPLE_PLAYLIST_WITH_START_PRECISE) assert obj.start.time_offset == 10.5 assert obj.start.precise == "YES" assert ext_x_start + ":TIME-OFFSET=10.5,PRECISE=YES\n" in obj.dumps() def test_playlist_stream_info_contains_group_id_refs(): obj = m3u8.M3U8(playlists.VARIANT_PLAYLIST_WITH_VIDEO_CC_SUBS_AND_AUDIO) assert len(obj.playlists) == 2 for pl in obj.playlists: assert pl.stream_info.closed_captions == '"cc"' assert pl.stream_info.subtitles == "sub" assert pl.stream_info.audio == "aud" assert pl.stream_info.video == "vid" def test_should_dump_frame_rate(): obj = m3u8.M3U8(playlists.VARIANT_PLAYLIST_WITH_FRAME_RATE) expected = playlists.VARIANT_PLAYLIST_WITH_FRAME_RATE.strip() assert expected == obj.dumps().strip() def test_should_round_frame_rate(): obj = m3u8.M3U8(playlists.VARIANT_PLAYLIST_WITH_ROUNDABLE_FRAME_RATE) expected = playlists.VARIANT_PLAYLIST_WITH_ROUNDED_FRAME_RATE.strip() assert expected == obj.dumps().strip() def test_add_segment_to_playlist(): obj = m3u8.M3U8() obj.add_segment(Segment("entire.ts", "http://1.2/", duration=1)) def test_segment_str_method(): segment = Segment("entire.ts", "http://1.2/", duration=1) expected = "#EXTINF:1,\nentire.ts" result = str(segment).strip() assert result == expected def test_attribute_denormaliser(): result = denormalize_attribute("test_test") expected = "TEST-TEST" assert result == expected def test_find_key_throws_when_no_match(): threw = False try: find_key( {"method": "AES-128", "iv": 0x12345678, "uri": "http://1.2/"}, [ # deliberately empty ], ) except KeyError: threw = True finally: assert threw def test_ll_playlist(): obj = m3u8.M3U8(playlists.LOW_LATENCY_DELTA_UPDATE_PLAYLIST) obj.base_path = "http://localhost/test_base_path" obj.base_uri = "http://localhost/test_base_uri" assert len(obj.rendition_reports) == 2 assert len(obj.segments[2].parts) == 12 assert ( ext_x_part + ':DURATION=0.33334,URI="http://localhost/test_base_path/filePart271.0.ts"' ) in obj.dumps() assert ( ext_x_preload_hint + ':TYPE=PART,URI="http://localhost/test_base_path/filePart273.4.ts"' ) in obj.dumps() assert obj.preload_hint.base_uri == "http://localhost/test_base_uri" def test_add_rendition_report_to_playlist(): obj = m3u8.M3U8() obj.add_rendition_report( RenditionReport( base_uri=None, uri="../1M/waitForMSN.php", last_msn=273, last_part=0 ) ) obj.base_path = "http://localhost/test" result = obj.dumps() expected = '#EXT-X-RENDITION-REPORT:URI="http://localhost/test/waitForMSN.php",LAST-MSN=273,LAST-PART=0' assert expected in result def test_add_part_to_segment(): obj = Segment(uri="fileSequence271.ts", duration=4.00008) obj.add_part(PartialSegment(None, "filePart271.0.ts", 0.33334)) result = obj.dumps(None) expected = '#EXT-X-PART:DURATION=0.33334,URI="filePart271.0.ts"' assert expected in result def test_partial_segment_gap_and_byterange(): obj = PartialSegment( "", "filePart271.0.ts", 0.33334, byterange="9400@376", gap="YES" ) result = obj.dumps(None) expected = ( '#EXT-X-PART:DURATION=0.33334,URI="filePart271.0.ts",BYTERANGE=9400@376,GAP=YES' ) assert result == expected def test_session_data_with_value(): obj = SessionData("com.example.value", "example", language="en") result = obj.dumps() expected = ( '#EXT-X-SESSION-DATA:DATA-ID="com.example.value",VALUE="example",LANGUAGE="en"' ) assert result == expected def test_session_data_with_uri(): obj = SessionData("com.example.value", uri="example.json", language="en") result = obj.dumps() expected = '#EXT-X-SESSION-DATA:DATA-ID="com.example.value",URI="example.json",LANGUAGE="en"' assert result == expected def test_session_data_cannot_be_created_with_value_and_uri_at_the_same_time(): obj = SessionData( "com.example.value", value="example", uri="example.json", language="en" ) result = obj.dumps() expected = ( '#EXT-X-SESSION-DATA:DATA-ID="com.example.value",VALUE="example",LANGUAGE="en"' ) assert result == expected def test_endswith_newline(): obj = m3u8.loads(playlists.SIMPLE_PLAYLIST) manifest = obj.dumps() assert manifest.endswith("#EXT-X-ENDLIST\n") def test_init_section_base_path_update(): obj = m3u8.M3U8(playlists.MULTIPLE_MAP_URI_PLAYLIST) assert obj.segments[0].init_section.uri == "init1.mp4" obj.base_path = "http://localhost/base_path" obj.base_uri = "http://localhost/base_uri" assert obj.segments[0].init_section.uri == "http://localhost/base_path/init1.mp4" assert obj.segments[0].init_section.base_uri == "http://localhost/base_uri" def test_iframe_playlists_base_path_update(): obj = m3u8.M3U8(playlists.VARIANT_PLAYLIST_WITH_IFRAME_PLAYLISTS) assert obj.iframe_playlists[0].uri == "video-800k-iframes.m3u8" assert obj.iframe_playlists[0].base_uri is None obj.base_path = "http://localhost/base_path" obj.base_uri = "http://localhost/base_uri" assert ( obj.iframe_playlists[0].uri == "http://localhost/base_path/video-800k-iframes.m3u8" ) assert obj.iframe_playlists[0].base_uri == "http://localhost/base_uri" def test_partial_segment_base_path_update(): obj = m3u8.M3U8(playlists.LOW_LATENCY_DELTA_UPDATE_PLAYLIST) obj.base_path = "http://localhost/base_path" obj.base_uri = "http://localhost/base_uri" assert obj.segments[2].parts[0].uri == "http://localhost/base_path/filePart271.0.ts" assert obj.segments[2].parts[0].base_uri == "http://localhost/base_uri" def test_add_preload_hint(): obj = PreloadHint("PART", "", "filePart273.4.ts", 0) result = obj.dumps() expected = '#EXT-X-PRELOAD-HINT:TYPE=PART,URI="filePart273.4.ts",BYTERANGE-START=0' assert result == expected def test_add_daterange(): obj = DateRange( id="testid123", start_date="2020-03-09T17:19:00Z", planned_duration=60, x_test_client_attr='"test-attr"', ) result = obj.dumps() expected = '#EXT-X-DATERANGE:ID="testid123",START-DATE="2020-03-09T17:19:00Z",PLANNED-DURATION=60,X-TEST-CLIENT-ATTR="test-attr"' assert result == expected def test_daterange_simple(): obj = m3u8.M3U8(playlists.DATERANGE_SIMPLE_PLAYLIST) # note that x-s are explicitly alphabetically ordered # when dumped for predictability, so line below is different from input expected = '#EXT-X-DATERANGE:ID="ad3",START-DATE="2016-06-13T11:15:00Z",DURATION=20,X-AD-ID="1234",X-AD-URL="http://ads.example.com/beacon3"' result = obj.dumps() assert expected in result def test_daterange_scte_out_and_in(): obj = m3u8.M3U8(playlists.DATERANGE_SCTE35_OUT_AND_IN_PLAYLIST) result = obj.dumps() daterange_out = '#EXT-X-DATERANGE:ID="splice-6FFFFFF0",START-DATE="2014-03-05T11:15:00Z",PLANNED-DURATION=59.993,SCTE35-OUT=0xFC002F0000000000FF000014056FFFFFF000E011622DCAFF000052636200000000000A0008029896F50000008700000000' daterange_in = '#EXT-X-DATERANGE:ID="splice-6FFFFFF0",DURATION=59.993,SCTE35-IN=0xFC002A0000000000FF00000F056FFFFFF000401162802E6100000000000A0008029896F50000008700000000' assert daterange_out in result assert daterange_in in result def test_daterange_enddate_sctecmd(): obj = m3u8.M3U8(playlists.DATERANGE_ENDDATE_SCTECMD_PLAYLIST) result = obj.dumps() expected = '#EXT-X-DATERANGE:ID="test_id",START-DATE="2020-03-11T10:51:00Z",CLASS="test_class",END-DATE="2020-03-11T10:52:00Z",DURATION=60,SCTE35-CMD=0xFCINVALIDSECTION' assert expected in result def test_daterange_in_parts(): obj = m3u8.M3U8(playlists.DATERANGE_IN_PART_PLAYLIST) result = obj.dumps() expected = '#EXT-X-DATERANGE:ID="test_id",START-DATE="2020-03-10T07:48:02Z",CLASS="test_class",END-ON-NEXT=YES' assert expected in result def test_add_gap(): obj = m3u8.Segment(uri="fileSequence271.ts", duration=4, gap_tag=True) result = str(obj) expected = "#EXTINF:4,\n#EXT-X-GAP\nfileSequence271.ts" assert result == expected def test_gap(): obj = m3u8.M3U8(playlists.GAP_PLAYLIST) result = obj.dumps().strip() expected = playlists.GAP_PLAYLIST.strip() assert result == expected def test_gap_in_parts(): obj = m3u8.M3U8(playlists.GAP_IN_PARTS_PLAYLIST) result = obj.dumps().strip() expected = playlists.GAP_IN_PARTS_PLAYLIST.strip() assert result == expected def test_skip_dateranges(): obj = m3u8.M3U8(playlists.DELTA_UPDATE_SKIP_DATERANGES_PLAYLIST) expected_skip_tag = ( '#EXT-X-SKIP:SKIPPED-SEGMENTS=16,RECENTLY-REMOVED-DATERANGES="1"' ) expected_server_control_tag = ( "#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=36,CAN-SKIP-DATERANGES=YES" ) result = obj.dumps().strip() assert expected_skip_tag in result assert expected_server_control_tag in result def test_add_skip(): obj = m3u8.Skip(skipped_segments=30, recently_removed_dateranges="1\t2") expected = '#EXT-X-SKIP:SKIPPED-SEGMENTS=30,RECENTLY-REMOVED-DATERANGES="1\t2"' result = obj.dumps().strip() assert result == expected def test_content_steering(): obj = m3u8.M3U8(playlists.CONTENT_STEERING_PLAYLIST) expected_content_steering_tag = ( '#EXT-X-CONTENT-STEERING:SERVER-URI="/steering?video=00012",PATHWAY-ID="CDN-A"' ) result = obj.dumps().strip() assert expected_content_steering_tag in result def test_add_content_steering(): obj = m3u8.ContentSteering("", "/steering?video=00012", "CDN-A") expected = ( '#EXT-X-CONTENT-STEERING:SERVER-URI="/steering?video=00012",PATHWAY-ID="CDN-A"' ) result = obj.dumps().strip() assert result == expected def test_content_steering_base_path_update(): obj = m3u8.M3U8(playlists.CONTENT_STEERING_PLAYLIST) obj.base_path = "https://another.example.com/" assert ( '#EXT-X-CONTENT-STEERING:SERVER-URI="https://another.example.com/steering?video=00012",PATHWAY-ID="CDN-A"' in obj.dumps().strip() ) def test_add_content_steering_base_uri_update(): obj = m3u8.M3U8(playlists.CONTENT_STEERING_PLAYLIST) obj.base_uri = "https://yet-another.example.com/" assert ( obj.content_steering.absolute_uri == "https://yet-another.example.com/steering?video=00012" ) def test_dump_should_work_for_variant_playlists_with_image_playlists(): obj = m3u8.M3U8(playlists.VARIANT_PLAYLIST_WITH_IMAGE_PLAYLISTS) expected = playlists.VARIANT_PLAYLIST_WITH_IMAGE_PLAYLISTS.strip() assert expected == obj.dumps().strip() def test_segment_media_sequence(): obj = m3u8.M3U8(playlists.SLIDING_WINDOW_PLAYLIST) assert [s.media_sequence for s in obj.segments] == [2680, 2681, 2682] def test_low_latency_output(): obj = m3u8.M3U8(playlists.LOW_LATENCY_PART_PLAYLIST) actual = obj.dumps() expected = textwrap.dedent( """\ #EXTM3U #EXT-X-MEDIA-SEQUENCE:264 #EXT-X-VERSION:6 #EXT-X-TARGETDURATION:4 #EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=1,CAN-SKIP-UNTIL=24 #EXT-X-PART-INF:PART-TARGET=0.33334 #EXT-X-MAP:URI="init.mp4" #EXT-X-PROGRAM-DATE-TIME:2019-02-14T02:13:28.106+00:00 #EXTINF:4.00008, fileSequence264.mp4 #EXTINF:4.00008, fileSequence265.mp4 #EXTINF:4.00008, fileSequence266.mp4 #EXTINF:4.00008, fileSequence267.mp4 #EXTINF:4.00008, fileSequence268.mp4 #EXTINF:4.00008, fileSequence269.mp4 #EXTINF:4.00008, fileSequence270.mp4 #EXT-X-PART:DURATION=0.33334,URI="filePart271.0.mp4" #EXT-X-PART:DURATION=0.33334,URI="filePart271.1.mp4" #EXT-X-PART:DURATION=0.33334,URI="filePart271.2.mp4" #EXT-X-PART:DURATION=0.33334,URI="filePart271.3.mp4" #EXT-X-PART:DURATION=0.33334,URI="filePart271.4.mp4",INDEPENDENT=YES #EXT-X-PART:DURATION=0.33334,URI="filePart271.5.mp4" #EXT-X-PART:DURATION=0.33334,URI="filePart271.6.mp4" #EXT-X-PART:DURATION=0.33334,URI="filePart271.7.mp4" #EXT-X-PART:DURATION=0.33334,URI="filePart271.8.mp4",INDEPENDENT=YES #EXT-X-PART:DURATION=0.33334,URI="filePart271.9.mp4" #EXT-X-PART:DURATION=0.33334,URI="filePart271.10.mp4" #EXT-X-PART:DURATION=0.33334,URI="filePart271.11.mp4" #EXTINF:4.00008, fileSequence271.mp4 #EXT-X-PROGRAM-DATE-TIME:2019-02-14T02:14:00.106+00:00 #EXT-X-PART:DURATION=0.33334,URI="filePart272.a.mp4" #EXT-X-PART:DURATION=0.33334,URI="filePart272.b.mp4" #EXT-X-PART:DURATION=0.33334,URI="filePart272.c.mp4" #EXT-X-PART:DURATION=0.33334,URI="filePart272.d.mp4" #EXT-X-PART:DURATION=0.33334,URI="filePart272.e.mp4" #EXT-X-PART:DURATION=0.33334,URI="filePart272.f.mp4",INDEPENDENT=YES #EXT-X-PART:DURATION=0.33334,URI="filePart272.g.mp4" #EXT-X-PART:DURATION=0.33334,URI="filePart272.h.mp4" #EXT-X-PART:DURATION=0.33334,URI="filePart272.i.mp4" #EXT-X-PART:DURATION=0.33334,URI="filePart272.j.mp4" #EXT-X-PART:DURATION=0.33334,URI="filePart272.k.mp4" #EXT-X-PART:DURATION=0.33334,URI="filePart272.l.mp4" #EXTINF:4.00008, fileSequence272.mp4 #EXT-X-PART:DURATION=0.33334,URI="filePart273.0.mp4",INDEPENDENT=YES #EXT-X-PART:DURATION=0.33334,URI="filePart273.1.mp4" #EXT-X-PART:DURATION=0.33334,URI="filePart273.2.mp4" #EXT-X-PRELOAD-HINT:TYPE=PART,URI="filePart273.3.mp4" #EXT-X-RENDITION-REPORT:URI="../1M/waitForMSN.php",LAST-MSN=273,LAST-PART=2 #EXT-X-RENDITION-REPORT:URI="../4M/waitForMSN.php",LAST-MSN=273,LAST-PART=1 """ ) assert actual == expected def test_bitrate_settable_as_int(): obj = m3u8.loads(playlists.BITRATE_PLAYLIST) obj.segments[0].bitrate = 9876 assert "#EXT-X-BITRATE:9876" in obj.dumps().strip() # custom asserts def assert_file_content(filename, expected): with open(filename) as fileobj: content = fileobj.read().strip() assert content == expected # helpers def mock_parser_data(m3u8_obj, data): data.setdefault("segments", []) m3u8_obj.data = data m3u8_obj._initialize_attributes() m3u8-6.0.0/tests/test_parser.py000066400000000000000000001101641465465410600163760ustar00rootroot00000000000000# Copyright 2014 Globo.com Player authors. All rights reserved. # Use of this source code is governed by a MIT License # license that can be found in the LICENSE file. import re import playlists import pytest import m3u8 from m3u8.parser import ( ParseError, _parse_simple_parameter_raw_value, cast_date_time, get_segment_custom_value, save_segment_custom_value, ) def test_should_parse_simple_playlist_from_string(): data = m3u8.parse(playlists.SIMPLE_PLAYLIST) assert 5220 == data["targetduration"] assert 0 == data["media_sequence"] assert ["http://media.example.com/entire.ts"] == [ c["uri"] for c in data["segments"] ] assert [5220] == [c["duration"] for c in data["segments"]] def test_should_parse_non_integer_duration_from_playlist_string(): data = m3u8.parse(playlists.PLAYLIST_WITH_NON_INTEGER_DURATION) assert 5221 == data["targetduration"] assert [5220.5] == [c["duration"] for c in data["segments"]] def test_should_parse_comma_in_title(): data = m3u8.parse(playlists.SIMPLE_PLAYLIST_TITLE_COMMA) assert ["Title with a comma, end"] == [c["title"] for c in data["segments"]] def test_should_parse_simple_playlist_from_string_with_different_linebreaks(): data = m3u8.parse(playlists.SIMPLE_PLAYLIST.replace("\n", "\r\n")) assert 5220 == data["targetduration"] assert ["http://media.example.com/entire.ts"] == [ c["uri"] for c in data["segments"] ] assert [5220] == [c["duration"] for c in data["segments"]] def test_should_parse_sliding_window_playlist_from_string(): data = m3u8.parse(playlists.SLIDING_WINDOW_PLAYLIST) assert 8 == data["targetduration"] assert 2680 == data["media_sequence"] assert [ "https://priv.example.com/fileSequence2680.ts", "https://priv.example.com/fileSequence2681.ts", "https://priv.example.com/fileSequence2682.ts", ] == [c["uri"] for c in data["segments"]] assert [8, 8, 8] == [c["duration"] for c in data["segments"]] def test_should_parse_playlist_with_encrypted_segments_from_string(): data = m3u8.parse(playlists.PLAYLIST_WITH_ENCRYPTED_SEGMENTS) assert 7794 == data["media_sequence"] assert 15 == data["targetduration"] assert "AES-128" == data["keys"][0]["method"] assert "https://priv.example.com/key.php?r=52" == data["keys"][0]["uri"] assert [ "http://media.example.com/fileSequence52-1.ts", "http://media.example.com/fileSequence52-2.ts", "http://media.example.com/fileSequence52-3.ts", ] == [c["uri"] for c in data["segments"]] assert [15, 15, 15] == [c["duration"] for c in data["segments"]] def test_should_load_playlist_with_iv_from_string(): data = m3u8.parse(playlists.PLAYLIST_WITH_ENCRYPTED_SEGMENTS_AND_IV) assert "/hls-key/key.bin" == data["keys"][0]["uri"] assert "AES-128" == data["keys"][0]["method"] assert "0X10ef8f758ca555115584bb5b3c687f52" == data["keys"][0]["iv"] def test_should_add_key_attribute_to_segment_from_playlist(): data = m3u8.parse( playlists.PLAYLIST_WITH_ENCRYPTED_SEGMENTS_AND_IV_WITH_MULTIPLE_KEYS ) first_segment_key = data["segments"][0]["key"] assert "/hls-key/key.bin" == first_segment_key["uri"] assert "AES-128" == first_segment_key["method"] assert "0X10ef8f758ca555115584bb5b3c687f52" == first_segment_key["iv"] last_segment_key = data["segments"][-1]["key"] assert "/hls-key/key2.bin" == last_segment_key["uri"] assert "AES-128" == last_segment_key["method"] assert "0Xcafe8f758ca555115584bb5b3c687f52" == last_segment_key["iv"] def test_should_add_non_key_for_multiple_keys_unencrypted_and_encrypted(): data = m3u8.parse(playlists.PLAYLIST_WITH_MULTIPLE_KEYS_UNENCRYPTED_AND_ENCRYPTED) # First two segments have no Key, so it's not in the dictionary assert "key" not in data["segments"][0] assert "key" not in data["segments"][1] third_segment_key = data["segments"][2]["key"] assert "/hls-key/key.bin" == third_segment_key["uri"] assert "AES-128" == third_segment_key["method"] assert "0X10ef8f758ca555115584bb5b3c687f52" == third_segment_key["iv"] last_segment_key = data["segments"][-1]["key"] assert "/hls-key/key2.bin" == last_segment_key["uri"] assert "AES-128" == last_segment_key["method"] assert "0Xcafe8f758ca555115584bb5b3c687f52" == last_segment_key["iv"] def test_should_handle_key_method_none_and_no_uri_attr(): data = m3u8.parse( playlists.PLAYLIST_WITH_MULTIPLE_KEYS_UNENCRYPTED_AND_ENCRYPTED_NONE_AND_NO_URI_ATTR ) assert "key" not in data["segments"][0] assert "key" not in data["segments"][1] third_segment_key = data["segments"][2]["key"] assert "/hls-key/key.bin" == third_segment_key["uri"] assert "AES-128" == third_segment_key["method"] assert "0X10ef8f758ca555115584bb5b3c687f52" == third_segment_key["iv"] assert "NONE" == data["segments"][6]["key"]["method"] def test_should_parse_playlist_with_session_encrypted_segments_from_string(): data = m3u8.parse(playlists.PLAYLIST_WITH_SESSION_ENCRYPTED_SEGMENTS) assert 7794 == data["media_sequence"] assert 15 == data["targetduration"] assert "AES-128" == data["session_keys"][0]["method"] assert "https://priv.example.com/key.php?r=52" == data["session_keys"][0]["uri"] assert [ "http://media.example.com/fileSequence52-1.ts", "http://media.example.com/fileSequence52-2.ts", "http://media.example.com/fileSequence52-3.ts", ] == [c["uri"] for c in data["segments"]] assert [15, 15, 15] == [c["duration"] for c in data["segments"]] def test_should_load_playlist_with_session_iv_from_string(): data = m3u8.parse(playlists.PLAYLIST_WITH_SESSION_ENCRYPTED_SEGMENTS_AND_IV) assert "/hls-key/key.bin" == data["session_keys"][0]["uri"] assert "AES-128" == data["session_keys"][0]["method"] assert "0X10ef8f758ca555115584bb5b3c687f52" == data["session_keys"][0]["iv"] def test_should_parse_quoted_title_from_playlist(): data = m3u8.parse(playlists.SIMPLE_PLAYLIST_WITH_QUOTED_TITLE) assert 1 == len(data["segments"]) assert 5220 == data["segments"][0]["duration"] assert '"A sample title"' == data["segments"][0]["title"] assert "http://media.example.com/entire.ts" == data["segments"][0]["uri"] def test_should_parse_unquoted_title_from_playlist(): data = m3u8.parse(playlists.SIMPLE_PLAYLIST_WITH_UNQUOTED_TITLE) assert 1 == len(data["segments"]) assert 5220 == data["segments"][0]["duration"] assert "A sample unquoted title" == data["segments"][0]["title"] assert "http://media.example.com/entire.ts" == data["segments"][0]["uri"] def test_should_parse_variant_playlist(): data = m3u8.parse(playlists.VARIANT_PLAYLIST) playlists_list = list(data["playlists"]) assert True is data["is_variant"] assert None is data["media_sequence"] assert 4 == len(playlists_list) assert "http://example.com/low.m3u8" == playlists_list[0]["uri"] assert 1 == playlists_list[0]["stream_info"]["program_id"] assert 1280000 == playlists_list[0]["stream_info"]["bandwidth"] assert "http://example.com/audio-only.m3u8" == playlists_list[-1]["uri"] assert 1 == playlists_list[-1]["stream_info"]["program_id"] assert 65000 == playlists_list[-1]["stream_info"]["bandwidth"] assert "mp4a.40.5,avc1.42801e" == playlists_list[-1]["stream_info"]["codecs"] def test_should_parse_variant_playlist_with_cc_subtitles_and_audio(): data = m3u8.parse(playlists.VARIANT_PLAYLIST_WITH_CC_SUBS_AND_AUDIO) playlists_list = list(data["playlists"]) assert True is data["is_variant"] assert None is data["media_sequence"] assert 2 == len(playlists_list) assert "http://example.com/with-cc-hi.m3u8" == playlists_list[0]["uri"] assert 1 == playlists_list[0]["stream_info"]["program_id"] assert 7680000 == playlists_list[0]["stream_info"]["bandwidth"] assert '"cc"' == playlists_list[0]["stream_info"]["closed_captions"] assert "sub" == playlists_list[0]["stream_info"]["subtitles"] assert "aud" == playlists_list[0]["stream_info"]["audio"] assert "http://example.com/with-cc-low.m3u8" == playlists_list[-1]["uri"] assert 1 == playlists_list[-1]["stream_info"]["program_id"] assert 65000 == playlists_list[-1]["stream_info"]["bandwidth"] assert '"cc"' == playlists_list[-1]["stream_info"]["closed_captions"] assert "sub" == playlists_list[-1]["stream_info"]["subtitles"] assert "aud" == playlists_list[-1]["stream_info"]["audio"] def test_should_parse_variant_playlist_with_none_cc_and_audio(): data = m3u8.parse(playlists.VARIANT_PLAYLIST_WITH_NONE_CC_AND_AUDIO) playlists_list = list(data["playlists"]) assert "NONE" == playlists_list[0]["stream_info"]["closed_captions"] assert "NONE" == playlists_list[-1]["stream_info"]["closed_captions"] def test_should_parse_variant_playlist_with_average_bandwidth(): data = m3u8.parse(playlists.VARIANT_PLAYLIST_WITH_AVERAGE_BANDWIDTH) playlists_list = list(data["playlists"]) assert 1280000 == playlists_list[0]["stream_info"]["bandwidth"] assert 1252345 == playlists_list[0]["stream_info"]["average_bandwidth"] assert 2560000 == playlists_list[1]["stream_info"]["bandwidth"] assert 2466570 == playlists_list[1]["stream_info"]["average_bandwidth"] assert 7680000 == playlists_list[2]["stream_info"]["bandwidth"] assert 7560423 == playlists_list[2]["stream_info"]["average_bandwidth"] assert 65000 == playlists_list[3]["stream_info"]["bandwidth"] assert 63005 == playlists_list[3]["stream_info"]["average_bandwidth"] def test_should_parse_variant_playlist_with_video_range(): data = m3u8.parse(playlists.VARIANT_PLAYLIST_WITH_VIDEO_RANGE) playlists_list = list(data["playlists"]) assert "SDR" == playlists_list[0]["stream_info"]["video_range"] assert "PQ" == playlists_list[1]["stream_info"]["video_range"] def test_should_parse_variant_playlist_with_hdcp_level(): data = m3u8.parse(playlists.VARIANT_PLAYLIST_WITH_HDCP_LEVEL) playlists_list = list(data["playlists"]) assert "NONE" == playlists_list[0]["stream_info"]["hdcp_level"] assert "TYPE-0" == playlists_list[1]["stream_info"]["hdcp_level"] assert "TYPE-1" == playlists_list[2]["stream_info"]["hdcp_level"] # This is actually not according to specification but as for example Twitch.tv # is producing master playlists that have bandwidth as floats (issue 72) # this tests that this situation does not break the parser and will just # truncate to a decimal-integer according to specification def test_should_parse_variant_playlist_with_bandwidth_as_float(): data = m3u8.parse(playlists.VARIANT_PLAYLIST_WITH_BANDWIDTH_FLOAT) playlists_list = list(data["playlists"]) assert 1280000 == playlists_list[0]["stream_info"]["bandwidth"] assert 2560000 == playlists_list[1]["stream_info"]["bandwidth"] assert 7680000 == playlists_list[2]["stream_info"]["bandwidth"] assert 65000 == playlists_list[3]["stream_info"]["bandwidth"] def test_should_parse_variant_playlist_with_iframe_playlists(): data = m3u8.parse(playlists.VARIANT_PLAYLIST_WITH_IFRAME_PLAYLISTS) iframe_playlists = list(data["iframe_playlists"]) assert True is data["is_variant"] assert 4 == len(iframe_playlists) assert 1 == iframe_playlists[0]["iframe_stream_info"]["program_id"] assert 151288 == iframe_playlists[0]["iframe_stream_info"]["bandwidth"] assert "624x352" == iframe_playlists[0]["iframe_stream_info"]["resolution"] assert "avc1.4d001f" == iframe_playlists[0]["iframe_stream_info"]["codecs"] assert "video-800k-iframes.m3u8" == iframe_playlists[0]["uri"] assert 38775 == iframe_playlists[-1]["iframe_stream_info"]["bandwidth"] assert "avc1.4d001f" == (iframe_playlists[-1]["iframe_stream_info"]["codecs"]) assert "video-150k-iframes.m3u8" == iframe_playlists[-1]["uri"] def test_should_parse_variant_playlist_with_alt_iframe_playlists_layout(): data = m3u8.parse(playlists.VARIANT_PLAYLIST_WITH_ALT_IFRAME_PLAYLISTS_LAYOUT) iframe_playlists = list(data["iframe_playlists"]) assert True is data["is_variant"] assert 4 == len(iframe_playlists) assert 1 == iframe_playlists[0]["iframe_stream_info"]["program_id"] assert 151288 == iframe_playlists[0]["iframe_stream_info"]["bandwidth"] assert "624x352" == iframe_playlists[0]["iframe_stream_info"]["resolution"] assert "avc1.4d001f" == iframe_playlists[0]["iframe_stream_info"]["codecs"] assert "video-800k-iframes.m3u8" == iframe_playlists[0]["uri"] assert 38775 == iframe_playlists[-1]["iframe_stream_info"]["bandwidth"] assert "avc1.4d001f" == (iframe_playlists[-1]["iframe_stream_info"]["codecs"]) assert "video-150k-iframes.m3u8" == iframe_playlists[-1]["uri"] def test_should_parse_iframe_playlist(): data = m3u8.parse(playlists.IFRAME_PLAYLIST) assert True is data["is_i_frames_only"] assert 4.12 == data["segments"][0]["duration"] assert "9400@376" == data["segments"][0]["byterange"] assert "segment1.ts" == data["segments"][0]["uri"] def test_should_parse_variant_playlist_with_image_playlists(): data = m3u8.parse(playlists.VARIANT_PLAYLIST_WITH_IMAGE_PLAYLISTS) image_playlists = list(data["image_playlists"]) assert True is data["is_variant"] assert 2 == len(image_playlists) assert "320x180" == image_playlists[0]["image_stream_info"]["resolution"] assert "jpeg" == image_playlists[0]["image_stream_info"]["codecs"] assert "5x2_320x180/320x180-5x2.m3u8" == image_playlists[0]["uri"] assert "640x360" == image_playlists[1]["image_stream_info"]["resolution"] assert "jpeg" == image_playlists[1]["image_stream_info"]["codecs"] assert "5x2_640x360/640x360-5x2.m3u8" == image_playlists[1]["uri"] def test_should_parse_vod_image_playlist(): data = m3u8.parse(playlists.VOD_IMAGE_PLAYLIST) assert True is data["is_images_only"] assert 8 == len(data["tiles"]) assert "preroll-ad-1.jpg" == data["segments"][0]["uri"] assert "640x360" == data["tiles"][0]["resolution"] assert "5x2" == data["tiles"][0]["layout"] assert 6.006 == data["tiles"][0]["duration"] assert "byterange" not in data["tiles"][0] def test_should_parse_vod_image_playlist2(): data = m3u8.parse(playlists.VOD_IMAGE_PLAYLIST2) assert True is data["is_images_only"] assert "640x360" == data["tiles"][0]["resolution"] assert "4x3" == data["tiles"][0]["layout"] assert 2.002 == data["tiles"][0]["duration"] assert 6 == len(data["tiles"]) assert "promo_1.jpg" == data["segments"][0]["uri"] def test_should_parse_live_image_playlist(): data = m3u8.parse(playlists.LIVE_IMAGE_PLAYLIST) assert True is data["is_images_only"] assert 10 == len(data["segments"]) assert "content-123.jpg" == data["segments"][0]["uri"] assert "content-124.jpg" == data["segments"][1]["uri"] assert "content-125.jpg" == data["segments"][2]["uri"] assert "missing-midroll.jpg" == data["segments"][3]["uri"] assert "missing-midroll.jpg" == data["segments"][4]["uri"] assert "missing-midroll.jpg" == data["segments"][5]["uri"] assert "content-128.jpg" == data["segments"][6]["uri"] assert "content-129.jpg" == data["segments"][7]["uri"] assert "content-130.jpg" == data["segments"][8]["uri"] assert "content-131.jpg" == data["segments"][9]["uri"] def test_should_parse_playlist_using_byteranges(): data = m3u8.parse(playlists.PLAYLIST_USING_BYTERANGES) assert False is data["is_i_frames_only"] assert 10 == data["segments"][0]["duration"] assert "76242@0" == data["segments"][0]["byterange"] assert "segment.ts" == data["segments"][0]["uri"] def test_should_parse_endlist_playlist(): data = m3u8.parse(playlists.SIMPLE_PLAYLIST) assert True is data["is_endlist"] data = m3u8.parse(playlists.SLIDING_WINDOW_PLAYLIST) assert False is data["is_endlist"] def test_should_parse_ALLOW_CACHE(): data = m3u8.parse(playlists.PLAYLIST_WITH_ENCRYPTED_SEGMENTS_AND_IV) assert "no" == data["allow_cache"] def test_should_parse_VERSION(): data = m3u8.parse(playlists.PLAYLIST_WITH_ENCRYPTED_SEGMENTS_AND_IV) assert 2 == data["version"] def test_should_parse_program_date_time_from_playlist(): data = m3u8.parse(playlists.SIMPLE_PLAYLIST_WITH_PROGRAM_DATE_TIME) assert cast_date_time("2014-08-13T13:36:33+00:00") == data["program_date_time"] def test_should_parse_scte35_from_playlist(): data = m3u8.parse(playlists.CUE_OUT_ELEMENTAL_PLAYLIST) # cue_out should be maintained from [EXT-X-CUE-OUT, EXT-X-CUE-IN) actual_cue_status = [s["cue_out"] for s in data["segments"]] expected_cue_status = [ False, False, False, True, # EXT-X-CUE-OUT True, True, True, True, True, False, # EXT-X-CUE-IN False, ] assert actual_cue_status == expected_cue_status # scte35 should be maintained from [EXT-X-CUE-OUT, EXT-X-CUE-IN] cue = "/DAlAAAAAAAAAP/wFAUAAAABf+//wpiQkv4ARKogAAEBAQAAQ6sodg==" actual_scte35 = [s["scte35"] for s in data["segments"]] expected_scte35 = [None, None, None, cue, cue, cue, cue, cue, cue, cue, None] assert actual_scte35 == expected_scte35 # oatcls_scte35 should be maintained from [EXT-X-CUE-OUT, EXT-X-CUE-IN] actual_oatcls_scte35 = [s["oatcls_scte35"] for s in data["segments"]] expected_oatcls_scte35 = [None, None, None, cue, cue, cue, cue, cue, cue, cue, None] assert actual_oatcls_scte35 == expected_oatcls_scte35 # durations should be maintained from from [EXT-X-CUE-OUT, EXT-X-CUE-IN] actual_scte35_duration = [s["scte35_duration"] for s in data["segments"]] expected_scte35_duration = [ None, None, None, "50.000", "50", "50", "50", "50", "50", "50", None, ] assert actual_scte35_duration == expected_scte35_duration def test_should_parse_envivio_cue_playlist(): data = m3u8.parse(playlists.CUE_OUT_ENVIVIO_PLAYLIST) assert data["segments"][3]["scte35"] assert data["segments"][3]["cue_out"] assert ( "/DAlAAAENOOQAP/wFAUBAABrf+//N25XDf4B9p/gAAEBAQAAxKni9A==" == data["segments"][3]["scte35"] ) assert "366" == data["segments"][3]["scte35_duration"] assert data["segments"][4]["cue_out"] assert ( "/DAlAAAENOOQAP/wFAUBAABrf+//N25XDf4B9p/gAAEBAQAAxKni9A==" == data["segments"][4]["scte35"] ) assert ( "/DAlAAAENOOQAP/wFAUBAABrf+//N25XDf4B9p/gAAEBAQAAxKni9A==" == data["segments"][5]["scte35"] ) def test_should_parse_no_duration_cue_playlist(): data = m3u8.parse(playlists.CUE_OUT_NO_DURATION_PLAYLIST) assert data["segments"][0]["cue_out_start"] assert data["segments"][2]["cue_in"] def test_parse_simple_playlist_messy(): data = m3u8.parse(playlists.SIMPLE_PLAYLIST_MESSY) assert 5220 == data["targetduration"] assert 0 == data["media_sequence"] assert ["http://media.example.com/entire.ts"] == [ c["uri"] for c in data["segments"] ] assert [5220] == [c["duration"] for c in data["segments"]] def test_parse_simple_playlist_messy_strict(): with pytest.raises(ParseError) as catch: m3u8.parse(playlists.SIMPLE_PLAYLIST_MESSY, strict=True) assert str(catch.value) == "Syntax error in manifest on line 5: JUNK" def test_commaless_extinf(): data = m3u8.parse(playlists.SIMPLE_PLAYLIST_COMMALESS_EXTINF) assert 5220 == data["targetduration"] assert 0 == data["media_sequence"] assert ["http://media.example.com/entire.ts"] == [ c["uri"] for c in data["segments"] ] assert [5220] == [c["duration"] for c in data["segments"]] def test_commaless_extinf_strict(): with pytest.raises(ParseError) as e: m3u8.parse(playlists.SIMPLE_PLAYLIST_COMMALESS_EXTINF, strict=True) assert str(e.value) == "Syntax error in manifest on line 3: #EXTINF:5220" def test_should_parse_segment_map_uri(): data = m3u8.parse(playlists.MAP_URI_PLAYLIST) assert data["segment_map"][0]["uri"] == "fileSequence0.mp4" def test_should_parse_segment_map_uri_with_byterange(): data = m3u8.parse(playlists.MAP_URI_PLAYLIST_WITH_BYTERANGE) assert data["segment_map"][0]["uri"] == "main.mp4" def test_should_parse_multiple_map_attributes(): data = m3u8.parse(playlists.MULTIPLE_MAP_URI_PLAYLIST) assert data["segments"][0]["init_section"]["uri"] == "init1.mp4" assert data["segments"][1]["init_section"]["uri"] == "init1.mp4" assert data["segments"][2]["init_section"]["uri"] == "init3.mp4" def test_should_parse_empty_uri_with_base_path(): data = m3u8.M3U8( playlists.MEDIA_WITHOUT_URI_PLAYLIST, base_path="base_path", base_uri="base_uri" ) media = data.media[0] assert media.uri is None assert media.base_path is None assert "base_uri/" == media.base_uri def test_should_parse_audio_channels(): data = m3u8.M3U8( playlists.MEDIA_WITHOUT_URI_PLAYLIST, base_path="base_path", base_uri="base_uri" ) media = data.media[0] assert media.channels == "2" def test_should_parse_start_with_negative_time_offset(): data = m3u8.parse(playlists.SIMPLE_PLAYLIST_WITH_START_NEGATIVE_OFFSET) assert data["start"]["time_offset"] == -2.0 assert not hasattr(data["start"], "precise") def test_should_parse_start_with_precise(): data = m3u8.parse(playlists.SIMPLE_PLAYLIST_WITH_START_PRECISE) assert data["start"]["time_offset"] == 10.5 assert data["start"]["precise"] == "YES" def test_should_parse_session_data(): data = m3u8.parse(playlists.SESSION_DATA_PLAYLIST) assert data["session_data"][0]["data_id"] == "com.example.value" assert data["session_data"][0]["value"] == "example" assert data["session_data"][0]["language"] == "en" def test_should_parse_multiple_session_data(): data = m3u8.parse(playlists.MULTIPLE_SESSION_DATA_PLAYLIST) assert len(data["session_data"]) == 4 assert data["session_data"][0]["data_id"] == "com.example.value" assert data["session_data"][0]["value"] == "example" assert data["session_data"][0]["language"] == "en" assert data["session_data"][1]["data_id"] == "com.example.value" assert data["session_data"][1]["value"] == "example" assert data["session_data"][1]["language"] == "ru" assert data["session_data"][2]["data_id"] == "com.example.value" assert data["session_data"][2]["value"] == "example" assert data["session_data"][2]["language"] == "de" assert data["session_data"][3]["data_id"] == "com.example.title" assert data["session_data"][3]["uri"] == "title.json" def test_simple_playlist_with_discontinuity_sequence(): data = m3u8.parse(playlists.SIMPLE_PLAYLIST_WITH_DISCONTINUITY_SEQUENCE) assert data["discontinuity_sequence"] == 123 def test_simple_playlist_with_custom_tags(): def get_movie(line, lineno, data, segment): if line.startswith("#EXT-X-MOVIE"): custom_tag = line.split(":") if len(custom_tag) == 2: data["movie"] = custom_tag[1].strip() return True data = m3u8.parse( playlists.SIMPLE_PLAYLIST_WITH_CUSTOM_TAGS, strict=False, custom_tags_parser=get_movie, ) assert data["movie"] == "million dollar baby" assert 5220 == data["targetduration"] assert 0 == data["media_sequence"] assert ["http://media.example.com/entire.ts"] == [ c["uri"] for c in data["segments"] ] assert [5220] == [c["duration"] for c in data["segments"]] def test_iptv_playlist_with_custom_tags(): def parse_iptv_attributes(line, lineno, data, state): # Customize parsing #EXTINF if line.startswith("#EXTINF"): chunks = line.replace("#EXTINF" + ":", "").split(",", 1) if len(chunks) == 2: duration_and_props, title = chunks elif len(chunks) == 1: duration_and_props = chunks[0] title = "" additional_props = {} chunks = duration_and_props.strip().split(" ", 1) if len(chunks) == 2: duration, raw_props = chunks matched_props = re.finditer(r'([\w\-]+)="([^"]*)"', raw_props) for match in matched_props: additional_props[match.group(1)] = match.group(2) else: duration = duration_and_props if "segment" not in state: state["segment"] = {} state["segment"]["duration"] = float(duration) state["segment"]["title"] = title save_segment_custom_value(state, "extinf_props", additional_props) state["expect_segment"] = True return True # Parse #EXTGRP if line.startswith("#EXTGRP"): _, value = _parse_simple_parameter_raw_value(line, str) save_segment_custom_value(state, "extgrp", value) state["expect_segment"] = True return True # Parse #EXTVLCOPT if line.startswith("#EXTVLCOPT"): _, value = _parse_simple_parameter_raw_value(line, str) existing_opts = get_segment_custom_value(state, "vlcopt", []) existing_opts.append(value) save_segment_custom_value(state, "vlcopt", existing_opts) state["expect_segment"] = True return True data = m3u8.parse( playlists.IPTV_PLAYLIST_WITH_CUSTOM_TAGS, strict=False, custom_tags_parser=parse_iptv_attributes, ) assert ["Channel1"] == [c["title"] for c in data["segments"]] assert ( data["segments"][0]["uri"] == "http://str00.iptv.domain/7331/mpegts?token=longtokenhere" ) assert ( data["segments"][0]["custom_parser_values"]["extinf_props"]["tvg-id"] == "channel1" ) assert ( data["segments"][0]["custom_parser_values"]["extinf_props"]["group-title"] == "Group1" ) assert ( data["segments"][0]["custom_parser_values"]["extinf_props"]["catchup-days"] == "7" ) assert ( data["segments"][0]["custom_parser_values"]["extinf_props"]["catchup-type"] == "flussonic" ) assert data["segments"][0]["custom_parser_values"]["extgrp"] == "ExtGroup1" assert data["segments"][0]["custom_parser_values"]["vlcopt"] == [ "video-filter=invert", "param2=value2", ] def test_tag_after_extinf(): parsed_playlist = m3u8.loads(playlists.IPTV_PLAYLIST_WITH_EARLY_EXTINF) actual = parsed_playlist.segments[0].uri expected = "http://str00.iptv.domain/7331/mpegts?token=longtokenhere" assert actual == expected def test_master_playlist_with_frame_rate(): data = m3u8.parse(playlists.VARIANT_PLAYLIST_WITH_FRAME_RATE) playlists_list = list(data["playlists"]) assert 25 == playlists_list[0]["stream_info"]["frame_rate"] assert 50 == playlists_list[1]["stream_info"]["frame_rate"] assert 60 == playlists_list[2]["stream_info"]["frame_rate"] assert 12.5 == playlists_list[3]["stream_info"]["frame_rate"] def test_master_playlist_with_unrounded_frame_rate(): data = m3u8.parse(playlists.VARIANT_PLAYLIST_WITH_ROUNDABLE_FRAME_RATE) playlists_list = list(data["playlists"]) assert 12.54321 == playlists_list[0]["stream_info"]["frame_rate"] def test_low_latency_playlist(): data = m3u8.parse(playlists.LOW_LATENCY_DELTA_UPDATE_PLAYLIST) assert data["server_control"]["can_block_reload"] == "YES" assert data["server_control"]["can_skip_until"] == 12.0 assert data["server_control"]["part_hold_back"] == 1.0 assert data["part_inf"]["part_target"] == 0.33334 assert data["skip"]["skipped_segments"] == 3 assert len(data["segments"][2]["parts"]) == 12 assert data["segments"][2]["parts"][0]["duration"] == 0.33334 assert data["segments"][2]["parts"][0]["uri"] == "filePart271.0.ts" assert len(data["rendition_reports"]) == 2 assert data["rendition_reports"][0]["uri"] == "../1M/waitForMSN.php" assert data["rendition_reports"][0]["last_msn"] == 273 assert data["rendition_reports"][0]["last_part"] == 3 def test_low_latency_with_preload_and_byteranges_playlist(): data = m3u8.parse(playlists.LOW_LATENCY_WITH_PRELOAD_AND_BYTERANGES_PLAYLIST) assert data["segments"][1]["parts"][2]["byterange"] == "18000@43000" assert data["preload_hint"]["type"] == "PART" assert data["preload_hint"]["uri"] == "fs271.mp4" assert data["preload_hint"]["byterange_start"] == 61000 assert data["preload_hint"]["byterange_length"] == 20000 def test_negative_media_sequence(): data = m3u8.parse(playlists.PLAYLIST_WITH_NEGATIVE_MEDIA_SEQUENCE) assert data["media_sequence"] == -2680 def test_daterange_simple(): data = m3u8.parse(playlists.DATERANGE_SIMPLE_PLAYLIST) assert data["segments"][0]["dateranges"][0]["id"] == "ad3" assert data["segments"][0]["dateranges"][0]["start_date"] == "2016-06-13T11:15:00Z" assert data["segments"][0]["dateranges"][0]["duration"] == 20 assert data["segments"][0]["dateranges"][0]["x_ad_id"] == '"1234"' assert ( data["segments"][0]["dateranges"][0]["x_ad_url"] == '"http://ads.example.com/beacon3"' ) def test_date_range_with_scte_out_and_in(): data = m3u8.parse(playlists.DATERANGE_SCTE35_OUT_AND_IN_PLAYLIST) assert data["segments"][0]["dateranges"][0]["id"] == "splice-6FFFFFF0" assert data["segments"][0]["dateranges"][0]["planned_duration"] == 59.993 assert ( data["segments"][0]["dateranges"][0]["scte35_out"] == "0xFC002F0000000000FF000014056FFFFFF000E011622DCAFF000052636200000000000A0008029896F50000008700000000" ) assert data["segments"][6]["dateranges"][0]["id"] == "splice-6FFFFFF0" assert data["segments"][6]["dateranges"][0]["duration"] == 59.993 assert ( data["segments"][6]["dateranges"][0]["scte35_in"] == "0xFC002A0000000000FF00000F056FFFFFF000401162802E6100000000000A0008029896F50000008700000000" ) def test_date_range_in_parts(): data = m3u8.parse(playlists.DATERANGE_IN_PART_PLAYLIST) assert data["segments"][0]["parts"][2]["dateranges"][0]["id"] == "test_id" assert ( data["segments"][0]["parts"][2]["dateranges"][0]["start_date"] == "2020-03-10T07:48:02Z" ) assert data["segments"][0]["parts"][2]["dateranges"][0]["class"] == "test_class" assert data["segments"][0]["parts"][2]["dateranges"][0]["end_on_next"] == "YES" def test_gap(): data = m3u8.parse(playlists.GAP_PLAYLIST) assert data["segments"][0]["gap_tag"] is None assert data["segments"][1]["gap_tag"] is True assert data["segments"][2]["gap_tag"] is True assert data["segments"][3]["gap_tag"] is None def test_gap_in_parts(): data = m3u8.parse(playlists.GAP_IN_PARTS_PLAYLIST) assert data["segments"][0]["parts"][0]["gap_tag"] is None assert data["segments"][0]["parts"][0].get("gap", None) is None assert data["segments"][0]["parts"][1]["gap_tag"] is None assert data["segments"][0]["parts"][1]["gap"] == "YES" assert data["segments"][0]["parts"][2]["gap_tag"] is True assert data["segments"][0]["parts"][2].get("gap", None) is None def test_should_parse_variant_playlist_with_iframe_with_average_bandwidth(): data = m3u8.parse(playlists.VARIANT_PLAYLIST_WITH_IFRAME_AVERAGE_BANDWIDTH) iframe_playlists = list(data["iframe_playlists"]) assert True is data["is_variant"] assert 4 == len(iframe_playlists) assert 151288 == iframe_playlists[0]["iframe_stream_info"]["bandwidth"] # Check for absence of average_bandwidth if not given in the playlist assert "average_bandwidth" not in iframe_playlists[0]["iframe_stream_info"] assert "624x352" == iframe_playlists[0]["iframe_stream_info"]["resolution"] assert "avc1.4d001f" == iframe_playlists[0]["iframe_stream_info"]["codecs"] assert "video-800k-iframes.m3u8" == iframe_playlists[0]["uri"] assert 38775 == iframe_playlists[-1]["iframe_stream_info"]["bandwidth"] assert "avc1.4d001f" == (iframe_playlists[-1]["iframe_stream_info"]["codecs"]) assert "video-150k-iframes.m3u8" == iframe_playlists[-1]["uri"] assert 155000 == iframe_playlists[1]["iframe_stream_info"]["average_bandwidth"] assert 65000 == iframe_playlists[2]["iframe_stream_info"]["average_bandwidth"] assert 30000 == iframe_playlists[3]["iframe_stream_info"]["average_bandwidth"] def test_should_parse_variant_playlist_with_iframe_with_video_range(): data = m3u8.parse(playlists.VARIANT_PLAYLIST_WITH_IFRAME_VIDEO_RANGE) iframe_playlists = list(data["iframe_playlists"]) assert True is data["is_variant"] assert 4 == len(iframe_playlists) assert "http://example.com/sdr-iframes.m3u8" == iframe_playlists[0]["uri"] assert "SDR" == iframe_playlists[0]["iframe_stream_info"]["video_range"] assert "http://example.com/hdr-pq-iframes.m3u8" == iframe_playlists[1]["uri"] assert "PQ" == iframe_playlists[1]["iframe_stream_info"]["video_range"] assert "http://example.com/hdr-hlg-iframes.m3u8" == iframe_playlists[2]["uri"] assert "HLG" == iframe_playlists[2]["iframe_stream_info"]["video_range"] assert "http://example.com/unknown-iframes.m3u8" == iframe_playlists[3]["uri"] assert "video_range" not in iframe_playlists[3]["iframe_stream_info"] def test_should_parse_variant_playlist_with_iframe_with_hdcp_level(): data = m3u8.parse(playlists.VARIANT_PLAYLIST_WITH_IFRAME_HDCP_LEVEL) iframe_playlists = list(data["iframe_playlists"]) assert True is data["is_variant"] assert 4 == len(iframe_playlists) assert "http://example.com/none-iframes.m3u8" == iframe_playlists[0]["uri"] assert "NONE" == iframe_playlists[0]["iframe_stream_info"]["hdcp_level"] assert "http://example.com/type0-iframes.m3u8" == iframe_playlists[1]["uri"] assert "TYPE-0" == iframe_playlists[1]["iframe_stream_info"]["hdcp_level"] assert "http://example.com/type1-iframes.m3u8" == iframe_playlists[2]["uri"] assert "TYPE-1" == iframe_playlists[2]["iframe_stream_info"]["hdcp_level"] assert "http://example.com/unknown-iframes.m3u8" == iframe_playlists[3]["uri"] assert "hdcp_level" not in iframe_playlists[3]["iframe_stream_info"] def test_delta_playlist_daterange_skipping(): data = m3u8.parse(playlists.DELTA_UPDATE_SKIP_DATERANGES_PLAYLIST) assert data["skip"]["recently_removed_dateranges"] == "1" assert data["server_control"]["can_skip_dateranges"] == "YES" def test_bitrate(): data = m3u8.parse(playlists.BITRATE_PLAYLIST) assert data["segments"][0]["bitrate"] == 1674 assert data["segments"][1]["bitrate"] == 1625 def test_content_steering(): data = m3u8.parse(playlists.CONTENT_STEERING_PLAYLIST) assert data["content_steering"]["server_uri"] == "/steering?video=00012" assert data["content_steering"]["pathway_id"] == "CDN-A" assert data["playlists"][0]["stream_info"]["pathway_id"] == "CDN-A" assert data["playlists"][1]["stream_info"]["pathway_id"] == "CDN-A" assert data["playlists"][2]["stream_info"]["pathway_id"] == "CDN-B" assert data["playlists"][3]["stream_info"]["pathway_id"] == "CDN-B" def test_cue_in_pops_scte35_data_and_duration(): data = m3u8.parse(playlists.CUE_OUT_ELEMENTAL_PLAYLIST) assert data["segments"][9]["cue_in"] is True assert ( data["segments"][9]["scte35"] == "/DAlAAAAAAAAAP/wFAUAAAABf+//wpiQkv4ARKogAAEBAQAAQ6sodg==" ) assert data["segments"][9]["scte35_duration"] == "50" assert data["segments"][10]["cue_in"] is False assert data["segments"][10]["scte35"] is None assert data["segments"][10]["scte35_duration"] is None def test_playlist_with_stable_variant_id(): data = m3u8.parse(playlists.VARIANT_PLAYLIST_WITH_STABLE_VARIANT_ID) assert ( data["playlists"][0]["stream_info"]["stable_variant_id"] == "eb9c6e4de930b36d9a67fbd38a30b39f865d98f4a203d2140bbf71fd58ad764e" ) def test_iframe_with_stable_variant_id(): data = m3u8.parse(playlists.VARIANT_PLAYLIST_WITH_IFRAME_STABLE_VARIANT_ID) assert ( data["iframe_playlists"][0]["iframe_stream_info"]["stable_variant_id"] == "415901312adff69b967a0644a54f8d00dc14004f36bc8293737e6b4251f60f3f" ) def test_media_with_stable_rendition_id(): data = m3u8.parse(playlists.VARIANT_PLAYLIST_WITH_STABLE_RENDITION_ID) assert ( data["media"][0]["stable_rendition_id"] == "a8213e27c12a158ea8660e0fe8bdcac6072ca26d984e7e8603652bc61fdceffa" ) def test_req_video_layout(): data = m3u8.parse(playlists.VARIANT_PLAYLIST_WITH_REQ_VIDEO_LAYOUT) assert data["playlists"][0]["stream_info"]["req_video_layout"] == '"CH-STEREO"' m3u8-6.0.0/tests/test_strict_validations.py000066400000000000000000000023401465465410600210030ustar00rootroot00000000000000# Copyright 2014 Globo.com Player authors. All rights reserved. # Use of this source code is governed by a MIT License # license that can be found in the LICENSE file. import invalid_versioned_playlists import pytest import m3u8 import m3u8.version_matching_rules @pytest.mark.xfail def test_should_fail_if_first_line_not_EXTM3U(): assert 0 @pytest.mark.xfail def test_should_fail_if_expected_ts_segment_line_is_not_valid(): assert 0 @pytest.mark.xfail def test_should_fail_if_EXT_X_MEDIA_SEQUENCE_is_diffent_from_sequence_number_of_first_uri(): assert 0 @pytest.mark.xfail def test_should_fail_if_more_than_one_EXT_X_MEDIA_SEQUENCE(): assert 0 @pytest.mark.xfail def test_should_fail_if_EXT_X_MEDIA_SEQUENCE_is_not_a_number(): assert 0 def test_should_validate_supported_EXT_X_VERSION(): with pytest.raises( Exception, ): m3u8.parse(invalid_versioned_playlists.M3U8_RULE_IV, strict=True) @pytest.mark.xfail def test_should_fail_if_any_EXTINF_duration_is_greater_than_TARGET_DURATION(): assert 0 @pytest.mark.xfail def test_should_fail_if_TARGET_DURATION_not_found(): assert 0 @pytest.mark.xfail def test_should_fail_if_invalid_m3u8_url_after_EXT_X_STREAM_INF(): assert 0 m3u8-6.0.0/tests/test_variant_m3u8.py000066400000000000000000000332531465465410600174250ustar00rootroot00000000000000# Copyright 2014 Globo.com Player authors. All rights reserved. # Use of this source code is governed by a MIT License # license that can be found in the LICENSE file. import playlists import m3u8 def test_create_a_variant_m3u8_with_two_playlists(): variant_m3u8 = m3u8.M3U8() subtitles = m3u8.Media( "english_sub.m3u8", "SUBTITLES", "subs", "en", "English", "YES", "YES", "NO", None, ) variant_m3u8.add_media(subtitles) low_playlist = m3u8.Playlist( "http://example.com/low.m3u8", stream_info={ "bandwidth": 1280000, "program_id": 1, "closed_captions": "NONE", "subtitles": "subs", }, media=[subtitles], base_uri=None, ) high_playlist = m3u8.Playlist( "http://example.com/high.m3u8", stream_info={"bandwidth": 3000000, "program_id": 1, "subtitles": "subs"}, media=[subtitles], base_uri=None, ) variant_m3u8.add_playlist(low_playlist) variant_m3u8.add_playlist(high_playlist) expected_content = """\ #EXTM3U #EXT-X-MEDIA:URI="english_sub.m3u8",TYPE=SUBTITLES,GROUP-ID="subs",LANGUAGE="en",NAME="English",DEFAULT=YES,AUTOSELECT=YES,FORCED=NO #EXT-X-STREAM-INF:PROGRAM-ID=1,CLOSED-CAPTIONS=NONE,BANDWIDTH=1280000,SUBTITLES="subs" http://example.com/low.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=3000000,SUBTITLES="subs" http://example.com/high.m3u8 """ assert expected_content == variant_m3u8.dumps() def test_create_a_variant_m3u8_with_two_playlists_and_two_iframe_playlists(): variant_m3u8 = m3u8.M3U8() subtitles = m3u8.Media( "english_sub.m3u8", "SUBTITLES", "subs", "en", "English", "YES", "YES", "NO", None, ) variant_m3u8.add_media(subtitles) low_playlist = m3u8.Playlist( uri="video-800k.m3u8", stream_info={ "bandwidth": 800000, "program_id": 1, "resolution": "624x352", "codecs": "avc1.4d001f, mp4a.40.5", "subtitles": "subs", }, media=[subtitles], base_uri="http://example.com/", ) high_playlist = m3u8.Playlist( uri="video-1200k.m3u8", stream_info={ "bandwidth": 1200000, "program_id": 1, "codecs": "avc1.4d001f, mp4a.40.5", "subtitles": "subs", }, media=[subtitles], base_uri="http://example.com/", ) low_iframe_playlist = m3u8.IFramePlaylist( uri="video-800k-iframes.m3u8", iframe_stream_info={ "bandwidth": 151288, "program_id": 1, "closed_captions": None, "resolution": "624x352", "codecs": "avc1.4d001f", }, base_uri="http://example.com/", ) high_iframe_playlist = m3u8.IFramePlaylist( uri="video-1200k-iframes.m3u8", iframe_stream_info={"bandwidth": 193350, "codecs": "avc1.4d001f"}, base_uri="http://example.com/", ) variant_m3u8.add_playlist(low_playlist) variant_m3u8.add_playlist(high_playlist) variant_m3u8.add_iframe_playlist(low_iframe_playlist) variant_m3u8.add_iframe_playlist(high_iframe_playlist) expected_content = """\ #EXTM3U #EXT-X-MEDIA:URI="english_sub.m3u8",TYPE=SUBTITLES,GROUP-ID="subs",\ LANGUAGE="en",NAME="English",DEFAULT=YES,AUTOSELECT=YES,FORCED=NO #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=800000,RESOLUTION=624x352,\ CODECS="avc1.4d001f, mp4a.40.5",SUBTITLES="subs" video-800k.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1200000,\ CODECS="avc1.4d001f, mp4a.40.5",SUBTITLES="subs" video-1200k.m3u8 #EXT-X-I-FRAME-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=151288,RESOLUTION=624x352,\ CODECS="avc1.4d001f",URI="video-800k-iframes.m3u8" #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=193350,\ CODECS="avc1.4d001f",URI="video-1200k-iframes.m3u8" """ assert expected_content == variant_m3u8.dumps() def test_variant_playlist_with_average_bandwidth(): variant_m3u8 = m3u8.M3U8() low_playlist = m3u8.Playlist( "http://example.com/low.m3u8", stream_info={ "bandwidth": 1280000, "average_bandwidth": 1257891, "program_id": 1, "subtitles": "subs", }, media=[], base_uri=None, ) high_playlist = m3u8.Playlist( "http://example.com/high.m3u8", stream_info={ "bandwidth": 3000000, "average_bandwidth": 2857123, "program_id": 1, "subtitles": "subs", }, media=[], base_uri=None, ) variant_m3u8.add_playlist(low_playlist) variant_m3u8.add_playlist(high_playlist) expected_content = """\ #EXTM3U #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1280000,AVERAGE-BANDWIDTH=1257891 http://example.com/low.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=3000000,AVERAGE-BANDWIDTH=2857123 http://example.com/high.m3u8 """ assert expected_content == variant_m3u8.dumps() def test_variant_playlist_with_video_range(): variant_m3u8 = m3u8.M3U8() sdr_playlist = m3u8.Playlist( "http://example.com/sdr.m3u8", stream_info={"bandwidth": 1280000, "video_range": "SDR", "program_id": 1}, media=[], base_uri=None, ) hdr_playlist = m3u8.Playlist( "http://example.com/hdr.m3u8", stream_info={"bandwidth": 3000000, "video_range": "PQ", "program_id": 1}, media=[], base_uri=None, ) variant_m3u8.add_playlist(sdr_playlist) variant_m3u8.add_playlist(hdr_playlist) expected_content = """\ #EXTM3U #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1280000,VIDEO-RANGE=SDR http://example.com/sdr.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=3000000,VIDEO-RANGE=PQ http://example.com/hdr.m3u8 """ assert expected_content == variant_m3u8.dumps() def test_variant_playlist_with_hdcp_level(): variant_m3u8 = m3u8.M3U8() none_playlist = m3u8.Playlist( "http://example.com/none.m3u8", stream_info={"bandwidth": 1280000, "hdcp_level": "NONE", "program_id": 1}, media=[], base_uri=None, ) type0_playlist = m3u8.Playlist( "http://example.com/type0.m3u8", stream_info={"bandwidth": 3000000, "hdcp_level": "TYPE-0", "program_id": 1}, media=[], base_uri=None, ) type1_playlist = m3u8.Playlist( "http://example.com/type1.m3u8", stream_info={"bandwidth": 4000000, "hdcp_level": "TYPE-1", "program_id": 1}, media=[], base_uri=None, ) variant_m3u8.add_playlist(none_playlist) variant_m3u8.add_playlist(type0_playlist) variant_m3u8.add_playlist(type1_playlist) expected_content = """\ #EXTM3U #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1280000,HDCP-LEVEL=NONE http://example.com/none.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=3000000,HDCP-LEVEL=TYPE-0 http://example.com/type0.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=4000000,HDCP-LEVEL=TYPE-1 http://example.com/type1.m3u8 """ assert expected_content == variant_m3u8.dumps() def test_variant_playlist_with_multiple_media(): variant_m3u8 = m3u8.loads(playlists.MULTI_MEDIA_PLAYLIST) assert variant_m3u8.dumps() == playlists.MULTI_MEDIA_PLAYLIST def test_create_a_variant_m3u8_with_iframe_with_average_bandwidth_playlists(): variant_m3u8 = m3u8.M3U8() subtitles = m3u8.Media( "english_sub.m3u8", "SUBTITLES", "subs", "en", "English", "YES", "YES", "NO", None, ) variant_m3u8.add_media(subtitles) low_playlist = m3u8.Playlist( uri="video-800k.m3u8", stream_info={ "bandwidth": 800000, "average_bandwidth": 555000, "resolution": "624x352", "codecs": "avc1.4d001f, mp4a.40.5", "subtitles": "subs", }, media=[subtitles], base_uri="http://example.com/", ) low_iframe_playlist = m3u8.IFramePlaylist( uri="video-800k-iframes.m3u8", iframe_stream_info={ "bandwidth": 151288, "average_bandwidth": 111000, "resolution": "624x352", "codecs": "avc1.4d001f", }, base_uri="http://example.com/", ) variant_m3u8.add_playlist(low_playlist) variant_m3u8.add_iframe_playlist(low_iframe_playlist) expected_content = """\ #EXTM3U #EXT-X-MEDIA:URI="english_sub.m3u8",TYPE=SUBTITLES,GROUP-ID="subs",\ LANGUAGE="en",NAME="English",DEFAULT=YES,AUTOSELECT=YES,FORCED=NO #EXT-X-STREAM-INF:BANDWIDTH=800000,AVERAGE-BANDWIDTH=555000,\ RESOLUTION=624x352,CODECS="avc1.4d001f, mp4a.40.5",SUBTITLES="subs" video-800k.m3u8 #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=151288,\ AVERAGE-BANDWIDTH=111000,RESOLUTION=624x352,CODECS="avc1.4d001f",\ URI="video-800k-iframes.m3u8" """ assert expected_content == variant_m3u8.dumps() def test_create_a_variant_m3u8_with_iframe_with_video_range_playlists(): variant_m3u8 = m3u8.M3U8() for vrange in ["SDR", "PQ", "HLG"]: playlist = m3u8.Playlist( uri="video-%s.m3u8" % vrange, stream_info={"bandwidth": 3000000, "video_range": vrange}, media=[], base_uri="http://example.com/%s" % vrange, ) iframe_playlist = m3u8.IFramePlaylist( uri="video-%s-iframes.m3u8" % vrange, iframe_stream_info={"bandwidth": 3000000, "video_range": vrange}, base_uri="http://example.com/%s" % vrange, ) variant_m3u8.add_playlist(playlist) variant_m3u8.add_iframe_playlist(iframe_playlist) expected_content = """\ #EXTM3U #EXT-X-STREAM-INF:BANDWIDTH=3000000,VIDEO-RANGE=SDR video-SDR.m3u8 #EXT-X-STREAM-INF:BANDWIDTH=3000000,VIDEO-RANGE=PQ video-PQ.m3u8 #EXT-X-STREAM-INF:BANDWIDTH=3000000,VIDEO-RANGE=HLG video-HLG.m3u8 #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=3000000,VIDEO-RANGE=SDR,URI="video-SDR-iframes.m3u8" #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=3000000,VIDEO-RANGE=PQ,URI="video-PQ-iframes.m3u8" #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=3000000,VIDEO-RANGE=HLG,URI="video-HLG-iframes.m3u8" """ assert expected_content == variant_m3u8.dumps() def test_create_a_variant_m3u8_with_iframe_with_hdcp_level_playlists(): variant_m3u8 = m3u8.M3U8() for hdcplv in ["NONE", "TYPE-0", "TYPE-1"]: playlist = m3u8.Playlist( uri="video-%s.m3u8" % hdcplv, stream_info={"bandwidth": 3000000, "hdcp_level": hdcplv}, media=[], base_uri="http://example.com/%s" % hdcplv, ) iframe_playlist = m3u8.IFramePlaylist( uri="video-%s-iframes.m3u8" % hdcplv, iframe_stream_info={"bandwidth": 3000000, "hdcp_level": hdcplv}, base_uri="http://example.com/%s" % hdcplv, ) variant_m3u8.add_playlist(playlist) variant_m3u8.add_iframe_playlist(iframe_playlist) expected_content = """\ #EXTM3U #EXT-X-STREAM-INF:BANDWIDTH=3000000,HDCP-LEVEL=NONE video-NONE.m3u8 #EXT-X-STREAM-INF:BANDWIDTH=3000000,HDCP-LEVEL=TYPE-0 video-TYPE-0.m3u8 #EXT-X-STREAM-INF:BANDWIDTH=3000000,HDCP-LEVEL=TYPE-1 video-TYPE-1.m3u8 #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=3000000,HDCP-LEVEL=NONE,URI="video-NONE-iframes.m3u8" #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=3000000,HDCP-LEVEL=TYPE-0,URI="video-TYPE-0-iframes.m3u8" #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=3000000,HDCP-LEVEL=TYPE-1,URI="video-TYPE-1-iframes.m3u8" """ assert expected_content == variant_m3u8.dumps() def test_create_a_variant_m3u8_with_two_playlists_and_two_image_playlists(): variant_m3u8 = m3u8.M3U8() subtitles = m3u8.Media( "english_sub.m3u8", "SUBTITLES", "subs", "en", "English", "YES", "YES", "NO", None, ) variant_m3u8.add_media(subtitles) low_playlist = m3u8.Playlist( uri="video-800k.m3u8", stream_info={ "bandwidth": 800000, "program_id": 1, "resolution": "624x352", "codecs": "avc1.4d001f, mp4a.40.5", "subtitles": "subs", }, media=[subtitles], base_uri="http://example.com/", ) high_playlist = m3u8.Playlist( uri="video-1200k.m3u8", stream_info={ "bandwidth": 1200000, "program_id": 1, "codecs": "avc1.4d001f, mp4a.40.5", "subtitles": "subs", }, media=[subtitles], base_uri="http://example.com/", ) low_image_playlist = m3u8.ImagePlaylist( uri="thumbnails-sd.m3u8", image_stream_info={ "bandwidth": 151288, "resolution": "320x160", "codecs": "jpeg", }, base_uri="http://example.com/", ) high_image_playlist = m3u8.ImagePlaylist( uri="thumbnails-hd.m3u8", image_stream_info={ "bandwidth": 193350, "resolution": "640x320", "codecs": "jpeg", }, base_uri="http://example.com/", ) variant_m3u8.add_playlist(low_playlist) variant_m3u8.add_playlist(high_playlist) variant_m3u8.add_image_playlist(low_image_playlist) variant_m3u8.add_image_playlist(high_image_playlist) expected_content = """\ #EXTM3U #EXT-X-MEDIA:URI="english_sub.m3u8",TYPE=SUBTITLES,GROUP-ID="subs",\ LANGUAGE="en",NAME="English",DEFAULT=YES,AUTOSELECT=YES,FORCED=NO #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=800000,RESOLUTION=624x352,\ CODECS="avc1.4d001f, mp4a.40.5",SUBTITLES="subs" video-800k.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1200000,\ CODECS="avc1.4d001f, mp4a.40.5",SUBTITLES="subs" video-1200k.m3u8 #EXT-X-IMAGE-STREAM-INF:BANDWIDTH=151288,RESOLUTION=320x160,\ CODECS="jpeg",URI="thumbnails-sd.m3u8" #EXT-X-IMAGE-STREAM-INF:BANDWIDTH=193350,RESOLUTION=640x320,\ CODECS="jpeg",URI="thumbnails-hd.m3u8" """ assert expected_content == variant_m3u8.dumps() m3u8-6.0.0/tests/test_version_matching_rules.py000066400000000000000000000071641465465410600216600ustar00rootroot00000000000000from m3u8.version_matching_rules import ( ValidEXTXBYTERANGEOrEXTXIFRAMESONLY, ValidFloatingPointEXTINF, ValidIVInEXTXKEY, ) def test_invalid_iv_in_EXT_X_KEY(): validator = ValidIVInEXTXKEY( version=1, line_number=1, line="#EXT-X-KEY: METHOD=AES-128, IV=0x123456789ABCDEF0123456789ABCDEF0, URI=https://example.com/key.bin", ) assert not validator.validate() def test_valid_iv_in_EXT_X_KEY(): examples = [ { "line": "#EXT-X-KEY: METHOD=AES-128, URI=https://example.com/key.bin", "version": 1, "expected": True, }, { "line": "#EXT-X-KEY: METHOD=AES-128, IV=0x123456789ABCDEF0123456789ABCDEF0, URI=https://example.com/key.bin", "version": 2, "expected": True, }, { "line": "#EXT-X-KEY: METHOD=AES-128, IV=0x123456789ABCDEF0123456789ABCDEF0, URI=https://example.com/key.bin", "version": 3, "expected": True, }, # Invalid case { "line": "#EXT-X-KEY: METHOD=AES-128, IV=0x123456789ABCDEF0123456789ABCDEF0, URI=https://example.com/key.bin", "version": 1, "expected": False, }, ] for example in examples: validator = ValidIVInEXTXKEY( version=example["version"], line_number=1, line=example["line"], ) assert validator.validate() == example["expected"] def test_invalid_floating_point_EXTINF(): examples = [ { "line": "#EXTINF: 10.5,", "version": 2, }, { "line": "#EXTINF: A,", "version": 3, }, ] for example in examples: validator = ValidFloatingPointEXTINF( version=example["version"], line_number=1, line=example["line"], ) assert not validator.validate() def test_valid_floating_point_EXTINF(): examples = [ { "line": "#EXTINF: 10,", "version": 2, }, { "line": "#EXTINF: 10.5,", "version": 3, }, { "line": "#EXTINF: 10.5,", "version": 4, }, ] for example in examples: validator = ValidFloatingPointEXTINF( version=example["version"], line_number=1, line=example["line"], ) assert validator.validate() def test_invalid_EXT_X_BYTERANGE_or_EXT_X_I_FRAMES_ONLY(): examples = [ { "line": "#EXT-X-BYTERANGE: 200000@1000", "version": 3, }, { "line": "#EXT-X-I-FRAMES-ONLY", "version": 3, }, ] for example in examples: validator = ValidEXTXBYTERANGEOrEXTXIFRAMESONLY( version=example["version"], line_number=1, line=example["line"], ) assert not validator.validate() def test_valid_EXT_X_BYTERANGE_or_EXT_X_I_FRAMES_ONLY(): examples = [ { "line": "#EXT-X-BYTERANGE: 200000@1000", "version": 4, }, { "line": "#EXT-X-I-FRAMES-ONLY", "version": 4, }, { "line": "#EXT-X-BYTERANGE: 200000@1000", "version": 5, }, { "line": "#EXT-X-I-FRAMES-ONLY", "version": 5, }, ] for example in examples: validator = ValidEXTXBYTERANGEOrEXTXIFRAMESONLY( version=example["version"], line_number=1, line=example["line"], ) assert validator.validate()