pax_global_header00006660000000000000000000000064137707531300014520gustar00rootroot0000000000000052 comment=2ea68776c15449e969bbdc621655f3238d5580a8 m3u8-0.8.0/000077500000000000000000000000001377075313000123215ustar00rootroot00000000000000m3u8-0.8.0/.coveralls.yml000066400000000000000000000000561377075313000151150ustar00rootroot00000000000000repo_token: YiOOP7xuzmxWqSWpUQh9xJlTFFD0DTV2g m3u8-0.8.0/.editorconfig000066400000000000000000000003371377075313000150010ustar00rootroot00000000000000# http://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-0.8.0/.gitignore000066400000000000000000000001641377075313000143120ustar00rootroot00000000000000*.pyc *.egg-info tests/server.stdout dist/ build/ bin/ include/ lib/ local/ .coverage .cache .python-version .idea/ m3u8-0.8.0/.travis.yml000066400000000000000000000002611377075313000144310ustar00rootroot00000000000000language: python sudo: false python: - "3.5" - "3.6" - "3.6-dev" - "3.7" - "3.7-dev" - "3.8" - "3.8-dev" - "nightly" script: ./runtests after_success: coveralls m3u8-0.8.0/LICENSE000066400000000000000000000021571377075313000133330ustar00rootroot00000000000000m3u8 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-0.8.0/MANIFEST.in000066400000000000000000000000731377075313000140570ustar00rootroot00000000000000include requirements.txt include LICENSE include README.rstm3u8-0.8.0/README.rst000066400000000000000000000262171377075313000140200ustar00rootroot00000000000000.. image:: https://travis-ci.org/globocom/m3u8.svg :target: https://travis-ci.org/globocom/m3u8 .. image:: https://coveralls.io/repos/globocom/m3u8/badge.png?branch=master :target: https://coveralls.io/r/globocom/m3u8?branch=master .. image:: https://badge.fury.io/py/m3u8.svg :target: https://badge.fury.io/py/m3u8 m3u8 ==== Python `m3u8`_ 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: .. code-block:: 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: .. code-block:: 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`_ * `#EXT-X-MEDIA-SEQUENCE`_ * `#EXT-X-DISCONTINUITY-SEQUENCE`_ * `#EXT-X-PROGRAM-DATE-TIME`_ * `#EXT-X-MEDIA`_ * `#EXT-X-PLAYLIST-TYPE`_ * `#EXT-X-KEY`_ * `#EXT-X-STREAM-INF`_ * `#EXT-X-VERSION`_ * #EXT-X-ALLOW-CACHE * `#EXT-X-ENDLIST`_ * `#EXTINF`_ * `#EXT-X-I-FRAMES-ONLY`_ * `#EXT-X-BYTERANGE`_ * `#EXT-X-I-FRAME-STREAM-INF`_ * `#EXT-X-DISCONTINUITY`_ * #EXT-X-CUE-OUT * #EXT-X-CUE-OUT-CONT * #EXT-X-CUE-IN * #EXT-X-CUE-SPAN * #EXT-OATCLS-SCTE35 * `#EXT-X-INDEPENDENT-SEGMENTS`_ * `#EXT-X-MAP`_ * `#EXT-X-START`_ * #EXT-X-SERVER-CONTROL * #EXT-X-PART-INF * #EXT-X-PART * #EXT-X-RENDITION-REPORT * #EXT-X-SKIP * `#EXT-X-SESSION-DATA`_ * `#EXT-X-DATERANGE`_ * `#EXT-X-GAP`_ Encryption keys --------------- The segments may be or not encrypted. The ``keys`` attribute list will be a list with all the different keys as described with `#EXT-X-KEY`_: Each key has the next properties: - ``method``: ex.: "AES-128" - ``uri``: the key uri, ex.: "http://videoserver.com/key.bin" - ``iv``: the initialization vector, if available. Otherwise ``None``. If no ``#EXT-X-KEY`` is found, the ``keys`` list will have a unique element ``None``. Multiple keys are supported. If unencrypted and encrypted segments are mixed in the M3U8 file, then the list will contain a ``None`` element, with one or more keys afterwards. To traverse the list of keys available: .. code-block:: python import m3u8 m3u8_obj = m3u8.loads('#EXTM3U8 ... etc ...') len(m3u8_obj.keys) => returns the number of keys available in the list (normally 1) for key in m3u8_obj.keys: if key: # First one could be None key.uri key.method key.iv Getting segments encrypted with one key --------------------------------------- There are cases where listing segments for a given key is important. It's possible to retrieve the list of segments encrypted with one key via ``by_key`` method in the ``segments`` list. Example of getting the segments with no encryption: .. code-block:: python import m3u8 m3u8_obj = m3u8.loads('#EXTM3U8 ... etc ...') segmk1 = m3u8_obj.segments.by_key(None) # Get the list of segments encrypted using last key segm = m3u8_obj.segments.by_key( m3u8_obj.keys[-1] ) With this method, is now possible also to change the key from some of the segments programmatically: .. code-block:: python import m3u8 m3u8_obj = m3u8.loads('#EXTM3U8 ... etc ...') # Create a new Key and replace it new_key = m3u8.Key("AES-128", "/encrypted/newkey.bin", None, iv="0xf123ad23f22e441098aa87ee") for segment in m3u8_obj.segments.by_key( m3u8_obj.keys[-1] ): segment.key = new_key # Remember to sync the key from the list as well m3u8_obj.keys[-1] = new_key Variant playlists (variable bitrates) ------------------------------------- A playlist can have a list to other playlist files, this is used to represent multiple bitrates videos, and it's called `variant streams`_. See an `example here`_. .. code-block:: python variant_m3u8 = m3u8.loads('#EXTM3U8 ... contains a variant stream ...') variant_m3u8.is_variant # in this case will be True for playlist in variant_m3u8.playlists: playlist.uri playlist.stream_info.bandwidth the playlist object used in the for loop above has a few attributes: - ``uri``: the url to the stream - ``stream_info``: a ``StreamInfo`` object (actually a namedtuple) with all the attributes available to `#EXT-X-STREAM-INF`_ - ``media``: a list of related ``Media`` objects with all the attributes available to `#EXT-X-MEDIA`_ - ``playlist_type``: the type of the playlist, which can be one of `VOD`_ (video on demand) or `EVENT`_ **NOTE: the following attributes are not implemented yet**, follow `issue 4`_ for updates - ``alternative_audios``: its an empty list, unless it's a playlist with `Alternative audio`_, in this case it's a list with ``Media`` objects with all the attributes available to `#EXT-X-MEDIA`_ - ``alternative_videos``: same as ``alternative_audios`` A variant playlist can also have links to `I-frame playlists`_, which are used to specify where the I-frames are in a video. See `Apple's documentation`_ on this for more information. These I-frame playlists can be accessed in a similar way to regular playlists. .. code-block:: python variant_m3u8 = m3u8.loads('#EXTM3U ... contains a variant stream ...') for iframe_playlist in variant_m3u8.iframe_playlists: iframe_playlist.uri iframe_playlist.iframe_stream_info.bandwidth The iframe_playlist object used in the for loop above has a few attributes: - ``uri``: the url to the I-frame playlist - ``base_uri``: the base uri of the variant playlist (if given) - ``iframe_stream_info``: a ``StreamInfo`` object (same as a regular playlist) Custom tags ----------- Quoting the documentation:: Lines that start with the character '#' are either comments or tags. Tags begin with #EXT. They are case-sensitive. All other lines that begin with '#' are comments and SHOULD be ignored. This library ignores all the non standard tags by default. If you want them to be collected while loading the file content, you need to pass a function to the `load/loads` functions, following the example below: .. code-block:: python import m3u8 def get_movie(line, data, lineno): if line.startswith('#MOVIE-NAME:'): custom_tag = line.split(':') data['movie'] = custom_tag[1].strip() m3u8_obj = m3u8.load('http://videoserver.com/playlist.m3u8', custom_tags_parser=get_movie) print(m3u8_obj.data['movie']) # million dollar baby Using different HTTP clients ---------------------------- If you don't want to use urllib to download playlists, having more control on how objects are fetched over the internet, you can use your own client. `requests` is a well known Python HTTP library and it can be used with `m3u8`: .. code-block:: python import m3u8 import requests class RequestsClient(): def download(self, uri, timeout=None, headers={}, verify_ssl=True): o = requests.get(uri, timeout=timeout, headers=headers) return o.text, o.url playlist = m3u8.load('http://videoserver.com/playlist.m3u8', http_client=RequestsClient()) print(playlist.dumps()) The advantage of using a custom HTTP client is to refine SSL verification, proxies, performance, flexibility, etc. Playlists behind proxies ------------------------ In case you need to use a proxy but can't use a system wide proxy (HTTP/HTTPS proxy environment variables), you can pass your HTTP/HTTPS proxies as a dict to the load function. .. code-block:: python import m3u8 proxies = { 'http': 'http://10.10.1.10:3128', 'https': 'http://10.10.1.10:1080', } http_client = m3u8.httpclient.DefaultHTTPClient(proxies) playlist = m3u8.load('http://videoserver.com/playlist.m3u8', http_client=http_client) # this could also be an absolute filename print(playlist.dumps()) It works with the default client only. Custom HTTP clients must implement this feature. Running Tests ============= .. code-block:: bash $ ./runtests Contributing ============ All contribution is 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: https://tools.ietf.org/html/rfc8216 .. _#EXT-X-VERSION: https://tools.ietf.org/html/rfc8216#section-4.3.1.2 .. _#EXTINF: https://tools.ietf.org/html/rfc8216#section-4.3.2.1 .. _#EXT-X-BYTERANGE: https://tools.ietf.org/html/rfc8216#section-4.3.2.2 .. _#EXT-X-DISCONTINUITY: https://tools.ietf.org/html/rfc8216#section-4.3.2.3 .. _#EXT-X-KEY: https://tools.ietf.org/html/rfc8216#section-4.3.2.4 .. _#EXT-X-MAP: https://tools.ietf.org/html/rfc8216#section-4.3.2.5 .. _#EXT-X-PROGRAM-DATE-TIME: https://tools.ietf.org/html/rfc8216#section-4.3.2.6 .. _#EXT-X-DATERANGE: https://tools.ietf.org/html/rfc8216#section-4.3.2.7 .. _#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-ENDLIST: https://tools.ietf.org/html/rfc8216#section-4.3.3.4 .. _#EXT-X-PLAYLIST-TYPE: https://tools.ietf.org/html/rfc8216#section-4.3.3.5 .. _#EXT-X-I-FRAMES-ONLY: https://tools.ietf.org/html/rfc8216#section-4.3.3.6 .. _#EXT-X-MEDIA: https://tools.ietf.org/html/rfc8216#section-4.3.4.1 .. _#EXT-X-STREAM-INF: https://tools.ietf.org/html/rfc8216#section-4.3.4.2 .. _#EXT-X-I-FRAME-STREAM-INF: https://tools.ietf.org/html/rfc8216#section-4.3.4.3 .. _#EXT-X-SESSION-DATA: https://tools.ietf.org/html/rfc8216#section-4.3.4.4 .. _#EXT-X-INDEPENDENT-SEGMENTS: https://tools.ietf.org/html/rfc8216#section-4.3.5.1 .. _#EXT-X-START: https://tools.ietf.org/html/rfc8216#section-4.3.5.2 .. _#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 .. _issue 1: https://github.com/globocom/m3u8/issues/1 .. _variant streams: https://tools.ietf.org/html/rfc8216#section-6.2.4 .. _example here: http://tools.ietf.org/html/draft-pantos-http-live-streaming-08#section-8.5 .. _issue 4: https://github.com/globocom/m3u8/issues/4 .. _I-frame playlists: https://tools.ietf.org/html/rfc8216#section-4.3.4.3 .. _Apple's documentation: https://developer.apple.com/library/ios/technotes/tn2288/_index.html#//apple_ref/doc/uid/DTS40012238-CH1-I_FRAME_PLAYLIST .. _Alternative audio: http://tools.ietf.org/html/draft-pantos-http-live-streaming-08#section-8.7 .. _VOD: https://developer.apple.com/library/mac/technotes/tn2288/_index.html#//apple_ref/doc/uid/DTS40012238-CH1-TNTAG2 .. _EVENT: https://developer.apple.com/library/mac/technotes/tn2288/_index.html#//apple_ref/doc/uid/DTS40012238-CH1-EVENT_PLAYLIST m3u8-0.8.0/m3u8/000077500000000000000000000000001377075313000131155ustar00rootroot00000000000000m3u8-0.8.0/m3u8/__init__.py000066400000000000000000000045161377075313000152340ustar00rootroot00000000000000# coding: utf-8 # 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 sys import os from m3u8.httpclient import DefaultHTTPClient, _parsed_url from m3u8.model import (M3U8, Segment, SegmentList, PartialSegment, PartialSegmentList, Key, Playlist, IFramePlaylist, Media, MediaList, PlaylistList, Start, RenditionReport, RenditionReportList, ServerControl, Skip, PartInformation, PreloadHint, DateRange, DateRangeList) from m3u8.parser import parse, is_url, ParseError __all__ = ('M3U8', 'Segment', 'SegmentList', 'PartialSegment', 'PartialSegmentList', 'Key', 'Playlist', 'IFramePlaylist', 'Media', 'MediaList', 'PlaylistList', 'Start', 'RenditionReport', 'RenditionReportList', 'ServerControl', 'Skip', 'PartInformation', 'PreloadHint' 'DateRange', 'DateRangeList', '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 = _parsed_url(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 is_url(uri): 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-0.8.0/m3u8/httpclient.py000066400000000000000000000024521377075313000156500ustar00rootroot00000000000000import posixpath import ssl import sys import urllib from urllib.error import HTTPError from urllib.parse import urlparse, urljoin import urllib.request def _parsed_url(url): parsed_url = urlparse(url) prefix = parsed_url.scheme + '://' + parsed_url.netloc base_path = posixpath.normpath(parsed_url.path + '/..') return urljoin(prefix, base_path) 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 = _parsed_url(resource.geturl()) 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-0.8.0/m3u8/mixins.py000066400000000000000000000031361377075313000150010ustar00rootroot00000000000000 import os from m3u8.parser import is_url try: import urlparse as url_parser except ImportError: import urllib.parse as url_parser def _urijoin(base_uri, path): if is_url(base_uri): return url_parser.urljoin(base_uri, path) else: return os.path.normpath(os.path.join(base_uri, path.strip('/'))) class BasePathMixin(object): @property def absolute_uri(self): if self.uri is None: return None if is_url(self.uri): return self.uri else: if self.base_uri is None: raise ValueError('There can not be `absolute_uri` with no `base_uri` set') return _urijoin(self.base_uri, self.uri) @property def base_path(self): if self.uri is None: return None return os.path.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 = "%s/%s" % (newbase_path, self.uri) else: self.uri = self.uri.replace(self.base_path, newbase_path) class GroupedBasePathMixin(object): 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-0.8.0/m3u8/model.py000066400000000000000000001263241377075313000145770ustar00rootroot00000000000000# coding: utf-8 # 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 import errno import math from m3u8.protocol import ext_x_start, ext_x_key, ext_x_session_key, ext_x_map from m3u8.parser import parse, format_date_time from m3u8.mixins import BasePathMixin, GroupedBasePathMixin class MalformedPlaylistError(Exception): pass class M3U8(object): ''' 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 ''' 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') ) 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.segments = SegmentList([ Segment(base_uri=self.base_uri, keyobject=find_key(segment.get('key', {}), self.keys), **segment) for segment in self.data.get('segments', []) ]) #self.keys = get_uniques([ segment.key for segment in self.segments ]) for attr, param in self.simple_attributes: setattr(self, attr, self.data.get(param)) 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.segment_map = self.data.get('segment_map') 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) 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 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 @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.rendition_reports.base_path = self._base_path if self.preload_hint: self.preload_hint.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_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): ''' Returns the current m3u8 as a string. You could also use unicode() or str() ''' output = ['#EXTM3U'] if self.is_independent_segments: output.append('#EXT-X-INDEPENDENT-SEGMENTS') if self.media_sequence: output.append('#EXT-X-MEDIA-SEQUENCE:' + str(self.media_sequence)) if self.discontinuity_sequence: output.append('#EXT-X-DISCONTINUITY-SEQUENCE:{}'.format( number_to_string(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.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.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.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(str(self.segments)) 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): basename = os.path.dirname(filename) try: if basename: os.makedirs(basename) except OSError as error: if error.errno != errno.EEXIST: raise 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_start` Returns a boolean indicating if a EXT-X-CUE-OUT tag exists `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_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 `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 preceed the segment `gap_tag` GAP tag indicates that a Media Segment is missing ''' def __init__(self, uri=None, base_uri=None, program_date_time=None, current_program_date_time=None, duration=None, title=None, byterange=None, cue_out=False, cue_out_start=False, cue_in=False, discontinuity=False, key=None, scte35=None, scte35_duration=None, keyobject=None, parts=None, init_section=None, dateranges=None, gap_tag=None): self.uri = uri self.duration = duration self.title = title self._base_uri = base_uri 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 = cue_out self.cue_in = cue_in self.scte35 = scte35 self.scte35_duration = scte35_duration 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 # Key(base_uri=base_uri, **key) if key else None def add_part(self, part): self.parts.append(part) def dumps(self, last_segment): 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 last_segment and self.init_section != last_segment.init_section: if not self.init_section: raise MalformedPlaylistError( "init section can't be None if previous is not None") output.append(str(self.init_section)) output.append('\n') else: if self.init_section and last_segment is None: 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)) if len(self.dateranges): output.append(str(self.dateranges)) output.append('\n') if self.cue_out_start: output.append('#EXT-X-CUE-OUT{}\n'.format( (':' + self.scte35_duration) if self.scte35_duration else '')) elif self.cue_out: output.append('#EXT-X-CUE-OUT-CONT\n') if self.cue_in: output.append('#EXT-X-CUE-IN\n') if self.parts: output.append(str(self.parts)) output.append('\n') if self.uri: if self.duration is not None: output.append('#EXTINF:%s,' % number_to_string(self.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.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(Segment, self).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 __str__(self): output = [] last_segment = None for segment in self: output.append(segment.dumps(last_segment)) last_segment = segment return '\n'.join(output) @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 preceed 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=' + 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 != 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') ) 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('%s="%s"' % (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 ) 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)) return '#EXT-X-I-FRAME-STREAM-INF:' + ','.join(iframe_stream_inf) class StreamInfo(object): 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 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") 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) 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` 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, 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.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)) 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(object): 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=' + number_to_string(self.last_msn)) if self.last_part is not None: report.append('LAST-PART=' + number_to_string( 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(object): 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(object): 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' % number_to_string( 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(object): 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('%s=%s' % ( denormalize_attribute(attr), number_to_string(self[attr]) )) return ('#EXT-X-PRELOAD-HINT:' + ','.join(hint)) def __str__(self): return self.dumps() class SessionData(object): 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(object): 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('%s=%s' % ( denormalize_attribute(attr), value )) return '#EXT-X-DATERANGE:' + ','.join(daterange) 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-0.8.0/m3u8/parser.py000066400000000000000000000435431377075313000147740ustar00rootroot00000000000000# coding: utf-8 # 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 iso8601 import datetime import itertools import re from m3u8 import protocol ''' 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 iso8601.parse_date(value) def format_date_time(value): return value.isoformat() 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, 'playlist_type': None, 'playlists': [], 'segments': [], 'iframe_playlists': [], 'media': [], 'keys': [], 'rendition_reports': [], 'skip': {}, 'part_inf': {}, 'session_data': [], 'session_keys': [], } state = { 'expect_segment': False, 'expect_playlist': False, 'current_key': None, 'current_segment_map': None, } lineno = 0 for line in string_to_lines(content): lineno += 1 line = line.strip() if line.startswith(protocol.ext_x_byterange): _parse_byterange(line, state) state['expect_segment'] = True elif line.startswith(protocol.ext_x_targetduration): _parse_simple_parameter(line, data, float) elif line.startswith(protocol.ext_x_media_sequence): _parse_simple_parameter(line, data, int) elif line.startswith(protocol.ext_x_discontinuity_sequence): _parse_simple_parameter(line, data, int) elif line.startswith(protocol.ext_x_program_date_time): _, program_date_time = _parse_simple_parameter_raw_value(line, cast_date_time) 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 elif line.startswith(protocol.ext_x_discontinuity): state['discontinuity'] = True elif line.startswith(protocol.ext_x_cue_out_cont): _parse_cueout_cont(line, state) state['cue_out'] = True elif line.startswith(protocol.ext_x_cue_out): _parse_cueout(line, state, string_to_lines(content)[lineno - 2]) state['cue_out_start'] = True state['cue_out'] = True elif line.startswith(protocol.ext_x_cue_in): state['cue_in'] = True elif line.startswith(protocol.ext_x_cue_span): state['cue_out'] = True elif line.startswith(protocol.ext_x_version): _parse_simple_parameter(line, data, int) elif line.startswith(protocol.ext_x_allow_cache): _parse_simple_parameter(line, data) elif line.startswith(protocol.ext_x_key): key = _parse_key(line) state['current_key'] = key if key not in data['keys']: data['keys'].append(key) elif line.startswith(protocol.extinf): _parse_extinf(line, data, state, lineno, strict) state['expect_segment'] = True elif line.startswith(protocol.ext_x_stream_inf): state['expect_playlist'] = True _parse_stream_inf(line, data, state) elif line.startswith(protocol.ext_x_i_frame_stream_inf): _parse_i_frame_stream_inf(line, data) elif line.startswith(protocol.ext_x_media): _parse_media(line, data, state) elif line.startswith(protocol.ext_x_playlist_type): _parse_simple_parameter(line, data) elif line.startswith(protocol.ext_i_frames_only): data['is_i_frames_only'] = True elif line.startswith(protocol.ext_is_independent_segments): data['is_independent_segments'] = True elif line.startswith(protocol.ext_x_endlist): data['is_endlist'] = True elif line.startswith(protocol.ext_x_map): quoted_parser = remove_quotes_parser('uri') segment_map_info = _parse_attribute_list(protocol.ext_x_map, line, quoted_parser) state['current_segment_map'] = segment_map_info # left for backward compatibility data['segment_map'] = segment_map_info elif line.startswith(protocol.ext_x_start): attribute_parser = { "time_offset": lambda x: float(x) } start_info = _parse_attribute_list(protocol.ext_x_start, line, attribute_parser) data['start'] = start_info elif line.startswith(protocol.ext_x_server_control): _parse_server_control(line, data, state) elif line.startswith(protocol.ext_x_part_inf): _parse_part_inf(line, data, state) elif line.startswith(protocol.ext_x_rendition_report): _parse_rendition_report(line, data, state) elif line.startswith(protocol.ext_x_part): _parse_part(line, data, state) elif line.startswith(protocol.ext_x_skip): _parse_skip(line, data, state) elif line.startswith(protocol.ext_x_session_data): _parse_session_data(line, data, state) elif line.startswith(protocol.ext_x_session_key): _parse_session_key(line, data, state) elif line.startswith(protocol.ext_x_preload_hint): _parse_preload_hint(line, data, state) elif line.startswith(protocol.ext_x_daterange): _parse_daterange(line, data, state) elif line.startswith(protocol.ext_x_gap): state['gap'] = True # Comments and whitespace elif line.startswith('#'): if callable(custom_tags_parser): custom_tags_parser(line, data, lineno) elif line.strip() == '': # blank lines are legal pass elif state['expect_segment']: _parse_ts_chunk(line, data, state) state['expect_segment'] = False elif state['expect_playlist']: _parse_variant_playlist(line, data, state) state['expect_playlist'] = False elif strict: raise ParseError(lineno, line) # there could be remaining partial segments if 'segment' in state: data['segments'].append(state.pop('segment')) return data def _parse_key(line): 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) return key def _parse_extinf(line, data, state, lineno, strict): 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 def _parse_ts_chunk(line, data, state): 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'] += datetime.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) if state.get('current_cue_out_scte35'): segment['scte35'] = state['current_cue_out_scte35'] if state.get('current_cue_out_duration'): segment['scte35_duration'] = state['current_cue_out_duration'] 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) def _parse_attribute_list(prefix, line, atribute_parser): params = ATTRIBUTELISTPATTERN.split(line.replace(prefix + ':', ''))[1::2] attributes = {} for param in params: name, value = param.split('=', 1) name = normalize_attribute(name) if name in atribute_parser: value = atribute_parser[name](value) attributes[name] = value return attributes def _parse_stream_inf(line, data, state): data['is_variant'] = True data['media_sequence'] = None atribute_parser = remove_quotes_parser('codecs', 'audio', 'video', 'subtitles', 'closed_captions') atribute_parser["program_id"] = int atribute_parser["bandwidth"] = lambda x: int(float(x)) atribute_parser["average_bandwidth"] = int atribute_parser["frame_rate"] = float atribute_parser["video_range"] = str atribute_parser["hdcp_level"] = str state['stream_info'] = _parse_attribute_list(protocol.ext_x_stream_inf, line, atribute_parser) def _parse_i_frame_stream_inf(line, data): atribute_parser = remove_quotes_parser('codecs', 'uri') atribute_parser["program_id"] = int atribute_parser["bandwidth"] = int atribute_parser["average_bandwidth"] = int atribute_parser["video_range"] = str atribute_parser["hdcp_level"] = str iframe_stream_info = _parse_attribute_list(protocol.ext_x_i_frame_stream_inf, line, atribute_parser) iframe_playlist = {'uri': iframe_stream_info.pop('uri'), 'iframe_stream_info': iframe_stream_info} data['iframe_playlists'].append(iframe_playlist) def _parse_media(line, data, state): quoted = remove_quotes_parser('uri', 'group_id', 'language', 'assoc_language', 'name', 'instream_id', 'characteristics', 'channels') media = _parse_attribute_list(protocol.ext_x_media, line, quoted) data['media'].append(media) def _parse_variant_playlist(line, data, state): playlist = {'uri': line, 'stream_info': state.pop('stream_info')} data['playlists'].append(playlist) def _parse_byterange(line, state): if 'segment' not in state: state['segment'] = {} state['segment']['byterange'] = line.replace(protocol.ext_x_byterange + ':', '') def _parse_simple_parameter_raw_value(line, cast_to=str, normalize=False): 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): 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): return _parse_and_set_simple_parameter_raw_value(line, data, cast_to, True) def _parse_cueout_cont(line, state): param, value = line.split(':', 1) res = re.match('.*Duration=(.*),SCTE35=(.*)$', value) if res: state['current_cue_out_duration'] = res.group(1) state['current_cue_out_scte35'] = res.group(2) def _cueout_no_duration(line): # this needs to be called first since line.split in all other # parsers will throw a ValueError if passed just this tag if line == protocol.ext_x_cue_out: return (None, None) def _cueout_elemental(line, state, prevline): param, value = line.split(':', 1) res = re.match('.*EXT-OATCLS-SCTE35:(.*)$', prevline) if res: return (res.group(1), value) else: return None def _cueout_envivio(line, state, prevline): param, value = line.split(':', 1) res = re.match('.*DURATION=(.*),.*,CUE="(.*)"', value) if res: return (res.group(2), res.group(1)) else: return None def _cueout_duration(line): # this needs to be called after _cueout_elemental # as it would capture those cues incompletely # This was added seperately rather than modifying "simple" param, value = line.split(':', 1) res = re.match(r'DURATION=(.*)', value) if res: return (None, res.group(1)) def _cueout_simple(line): # this needs to be called after _cueout_elemental # as it would capture those cues incompletely param, value = line.split(':', 1) res = re.match(r'^(\d+(?:\.\d)?\d*)$', value) if res: return (None, res.group(1)) def _parse_cueout(line, state, prevline): _cueout_state = (_cueout_no_duration(line) or _cueout_elemental(line, state, prevline) or _cueout_envivio(line, state, prevline) or _cueout_duration(line) or _cueout_simple(line)) if _cueout_state: state['current_cue_out_scte35'] = _cueout_state[0] state['current_cue_out_duration'] = _cueout_state[1] def _parse_server_control(line, data, state): 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, state): 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, state): 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, data, state): 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'] += datetime.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, state): 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, state): 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, state): 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, state): 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, date, state): 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 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 is_url(uri): return uri.startswith(('https://', 'http://')) m3u8-0.8.0/m3u8/protocol.py000066400000000000000000000027431377075313000153360ustar00rootroot00000000000000# coding: utf-8 # 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_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_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_x_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" m3u8-0.8.0/requirements-dev.txt000066400000000000000000000004521377075313000163620ustar00rootroot00000000000000-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 python-coveralls m3u8-0.8.0/requirements.txt000066400000000000000000000000101377075313000155740ustar00rootroot00000000000000iso8601 m3u8-0.8.0/runtests000077500000000000000000000012061377075313000141350ustar00rootroot00000000000000#!/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 { ps ax | grep m3u8server.py | grep -v grep | cut -d ' ' -f 1 | xargs kill 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-0.8.0/setup.py000066400000000000000000000012631377075313000140350ustar00rootroot00000000000000from os.path import dirname, abspath, join, exists from setuptools import setup long_description = None if exists("README.rst"): with open("README.rst") 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', author_email='videos3@corp.globo.com', version="0.8.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, python_requires='>=3.5' ) m3u8-0.8.0/tests/000077500000000000000000000000001377075313000134635ustar00rootroot00000000000000m3u8-0.8.0/tests/m3u8server.py000066400000000000000000000022131377075313000160560ustar00rootroot00000000000000# coding: utf-8 # 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 simple(): 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 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 simple(): 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-0.8.0/tests/playlists.py000077500000000000000000001017601377075313000160710ustar00rootroot00000000000000# coding: utf-8 # 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 dirname, abspath, 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:5220.5 #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_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_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 ''' 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+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+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:CAID=0x000000002310E3A8,ElapsedTime=161,Duration=181 #EXTINF:10, #EXT-X-PROGRAM-DATE-TIME:2015-06-18T23:22:20Z 1432451707508/ts/71737/sequence143474339.ts #EXT-X-CUE-OUT-CONT:CAID=0x000000002310E3A8,ElapsedTime=171,Duration=181 #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-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 ''' 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 ''' 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_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_link.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 ''' 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" ''' del abspath, dirname, join m3u8-0.8.0/tests/playlists/000077500000000000000000000000001377075313000155075ustar00rootroot00000000000000m3u8-0.8.0/tests/playlists/relative-playlist.m3u8000066400000000000000000000005201377075313000216740ustar00rootroot00000000000000#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 #EXT-X-ENDLIST m3u8-0.8.0/tests/playlists/simple-playlist.m3u8000066400000000000000000000001431377075313000213530ustar00rootroot00000000000000#EXTM3U #EXT-X-TARGETDURATION:5220 #EXTINF:5220, http://media.example.com/entire.ts #EXT-X-ENDLIST m3u8-0.8.0/tests/test_loader.py000066400000000000000000000122311377075313000163410ustar00rootroot00000000000000# coding: utf-8 # 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 try: import urlparse as url_parser except ImportError: import urllib.parse as url_parser 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 = url_parser.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 = '%s/entire1.ts' % base_uri 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' 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 def test_load_should_create_object_from_uri_with_relative_segments(): obj = m3u8.load(playlists.RELATIVE_PLAYLIST_URI) urlparsed = url_parser.urlparse(playlists.RELATIVE_PLAYLIST_URI) base_uri = os.path.normpath(urlparsed.path + '/..') prefix = urlparsed.scheme + '://' + urlparsed.netloc expected_key_abspath = '%s%s/key.bin' % (prefix, os.path.normpath(base_uri + '/..')) expected_key_path = '../key.bin' expected_ts1_abspath = '%s/entire1.ts' % (prefix) expected_ts1_path = '/entire1.ts' expected_ts2_abspath = '%s%sentire2.ts' % (prefix, os.path.normpath(base_uri + '/..') + '/') expected_ts2_path = '../entire2.ts' expected_ts3_abspath = '%s%sentire3.ts' % (prefix, os.path.normpath(base_uri + '/../..')) expected_ts3_path = '../../entire3.ts' expected_ts4_abspath = '%s%sentire4.ts' % (prefix, base_uri + '/') expected_ts4_path = 'entire4.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 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: obj = m3u8.load(playlists.TIMEOUT_SIMPLE_PLAYLIST_URI, timeout=1) except: assert True else: assert False m3u8-0.8.0/tests/test_model.py000077500000000000000000001334201377075313000162020ustar00rootroot00000000000000# coding: utf-8 # 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 pytest import sys from m3u8.protocol import ext_x_start, ext_x_part, ext_x_preload_hint import m3u8 import playlists from m3u8.model import Segment, Key, Media, MediaList, RenditionReport, PartialSegment, denormalize_attribute, find_key, SessionData, PreloadHint, DateRange class UTC(datetime.tzinfo): """tzinfo class used for backwards compatibility reasons. Extracted from the official documentation. Ref: https://docs.python.org/2/library/datetime.html#datetime.tzinfo.fromutc """ def utcoffset(self, dt): return datetime.timedelta(0) def tzname(self, dt): return 'UTC' def dst(self, dt): return datetime.timedelta(0) utc = 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 == False assert segments[5].discontinuity == True assert segments[6].discontinuity == False def test_segment_cue_out_attribute(): obj = m3u8.M3U8(playlists.CUE_OUT_PLAYLIST) segments = obj.segments assert segments[1].cue_out == True assert segments[2].cue_out == True assert segments[3].cue_out == False def test_segment_cue_out_start_attribute(): obj = m3u8.M3U8(playlists.CUE_OUT_WITH_DURATION_PLAYLIST) assert obj.segments[0].cue_out_start == True def test_segment_cue_in_attribute(): obj = m3u8.M3U8(playlists.CUE_OUT_WITH_DURATION_PLAYLIST) assert obj.segments[2].cue_in == 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_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_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 == True assert segments[9].cue_out == False assert segments[4].scte35 == '/DAlAAAAAAAAAP/wFAUAAAABf+//wpiQkv4ARKogAAEBAQAAQ6sodg==' def test_segment_envivio_scte35_attribute(): obj = m3u8.M3U8(playlists.CUE_OUT_ENVIVIO_PLAYLIST) segments = obj.segments assert segments[3].cue_out == True assert segments[4].scte35 == '/DAlAAAENOOQAP/wFAUBAABrf+//N25XDf4B9p/gAAEBAQAAxKni9A==' assert segments[5].scte35 == '/DAlAAAENOOQAP/wFAUBAABrf+//N25XDf4B9p/gAAEBAQAAxKni9A==' assert segments[7].cue_out == False def test_segment_unknown_scte35_attribute(): obj = m3u8.M3U8(playlists.CUE_OUT_INVALID_PLAYLIST) assert obj.segments[0].scte35 == None assert obj.segments[0].scte35_duration == None def test_segment_cue_out_no_duration(): obj = m3u8.M3U8(playlists.CUE_OUT_NO_DURATION_PLAYLIST) assert obj.segments[0].cue_out_start == True assert obj.segments[2].cue_in == True def test_keys_on_clear_playlist(): obj = m3u8.M3U8(playlists.SIMPLE_PLAYLIST) assert len(obj.keys) == 1 assert obj.keys[0] == 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 == 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 == obj.session_keys[0].iv 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 == 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 == 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 == obj.playlists[0].stream_info.closed_captions assert None == obj.playlists[0].stream_info.codecs assert None == 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 == obj.playlists[0].media[0].language assert 'High' == obj.playlists[0].media[0].name assert None == obj.playlists[0].media[0].default assert None == obj.playlists[0].media[0].autoselect assert None == obj.playlists[0].media[0].forced assert None == 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 == obj.playlists[1].stream_info.closed_captions assert 'mp4a.40.5' == obj.playlists[1].stream_info.codecs assert None == 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 == 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 == obj.playlists[1].media[0].forced assert None == 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 == obj.playlists[0].stream_info.codecs assert None == 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 == 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 == obj.iframe_playlists[1].iframe_stream_info.program_id assert '120000' == obj.iframe_playlists[1].iframe_stream_info.bandwidth assert None == 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 == 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 == 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, monkeypatch): def raiseOSError(*args): raise OSError monkeypatch.setattr(os, "makedirs", raiseOSError) raised = False try: obj = m3u8.M3U8(playlists.SIMPLE_PLAYLIST) obj.dump(str(tmpdir.join('subdir1', 'playlist.m3u8'))) except OSError as e: raised = True finally: assert raised 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+00:00" in obj.dumps().strip() 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() 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() 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+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+00:00" in output assert "#EXT-X-PROGRAM-DATE-TIME:2019-06-10T00:05:06+00:00" in output assert "#EXT-X-PROGRAM-DATE-TIME:2019-06-10T00:05:12+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(): obj = m3u8.M3U8(playlists.MULTIPLE_MAP_URI_PLAYLIST) output = obj.dump('/tmp/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 # update later 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() == 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 '/any/path/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 '/any/where/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['uri'] == "fileSequence0.mp4" def test_segment_map_uri_attribute_with_byterange(): obj = m3u8.M3U8(playlists.MAP_URI_PLAYLIST_WITH_BYTERANGE) assert obj.segment_map['uri'] == "main.mp4" 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() @pytest.mark.skipif(sys.version_info >= (3,), reason="unicode not available in v3") def test_m3u8_unicode_method(): obj = m3u8.M3U8(playlists.SIMPLE_PLAYLIST) result = unicode(obj).strip() expected = playlists.SIMPLE_PLAYLIST.strip() assert result == expected 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 as e: 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 == 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 # 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-0.8.0/tests/test_parser.py000066400000000000000000000652261377075313000164030ustar00rootroot00000000000000# coding: utf-8 # 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 m3u8 import playlists import pytest from m3u8.parser import cast_date_time, ParseError 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 5220.5 == 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 == data['is_variant'] assert None == 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 == data['is_variant'] assert None == 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_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 == 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 == 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 == 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_playlist_using_byteranges(): data = m3u8.parse(playlists.PLAYLIST_USING_BYTERANGES) assert False == 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 == data['is_endlist'] data = m3u8.parse(playlists.SLIDING_WINDOW_PLAYLIST) assert False == 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) assert not data['segments'][2]['cue_out'] assert data['segments'][3]['scte35'] assert data['segments'][3]['cue_out'] assert '/DAlAAAAAAAAAP/wFAUAAAABf+//wpiQkv4ARKogAAEBAQAAQ6sodg==' == data['segments'][4]['scte35'] assert '50' == data['segments'][4]['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']['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']['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, data, lineno): custom_tag = line.split(':') if len(custom_tag) == 2: data['movie'] = custom_tag[1].strip() 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_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'] == True assert data['segments'][2]['gap_tag'] == 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'] == 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 == 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 == 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 == 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" m3u8-0.8.0/tests/test_strict_validations.py000066400000000000000000000020701377075313000210000ustar00rootroot00000000000000# coding: utf-8 # 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 pytest @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 @pytest.mark.xfail def test_should_validate_supported_EXT_X_VERSION(): assert 0 @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-0.8.0/tests/test_variant_m3u8.py000066400000000000000000000267051377075313000174260ustar00rootroot00000000000000# coding: utf-8 # 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 m3u8, playlists 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()