├── requirements.txt ├── MANIFEST.in ├── tests ├── playlists │ ├── simple-playlist.m3u8 │ └── relative-playlist.m3u8 ├── invalid_versioned_playlists.py ├── test_invalid_versioned_playlists.py ├── m3u8server.py ├── test_strict_validations.py ├── test_http_client.py ├── test_version_matching_rules.py ├── test_loader.py ├── test_variant_m3u8.py ├── test_parser.py └── playlists.py ├── .gitignore ├── .editorconfig ├── requirements-dev.txt ├── .github └── workflows │ ├── ruff.yml │ └── main.yml ├── runtests ├── setup.py ├── LICENSE ├── m3u8 ├── version_matching.py ├── httpclient.py ├── mixins.py ├── protocol.py ├── __init__.py ├── version_matching_rules.py └── parser.py └── README.md /requirements.txt: -------------------------------------------------------------------------------- 1 | backports-datetime-fromisoformat; python_version < '3.11' 2 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include requirements.txt 2 | include LICENSE 3 | include README.md 4 | -------------------------------------------------------------------------------- /tests/playlists/simple-playlist.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-TARGETDURATION:5220 3 | #EXTINF:5220, 4 | http://media.example.com/entire.ts 5 | #EXT-X-ENDLIST 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.egg-info 3 | tests/server.stdout 4 | dist/ 5 | build/ 6 | bin/ 7 | include/ 8 | lib/ 9 | lib64/ 10 | local/ 11 | .coverage 12 | .cache 13 | .python-version 14 | .idea/ 15 | .vscode/ 16 | venv/ 17 | pyvenv.cfg 18 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://editorconfig.org 2 | root = true 3 | 4 | [*.py] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 4 8 | end_of_line = lf 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | [Makefile] 13 | indent_style = tab 14 | indent_size = 4 15 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | bottle 3 | pytest 4 | # pytest-cov 2.6.0 has increased the version requirement 5 | # for the coverage package from >=3.7.1 to >=4.4, 6 | # which is in conflict with the version requirement 7 | # defined by the python-coveralls package for coverage==4.0.3 8 | pytest-cov>=2.4.0,<2.6 9 | -------------------------------------------------------------------------------- /.github/workflows/ruff.yml: -------------------------------------------------------------------------------- 1 | name: Ruff 2 | run-name: Ruff 3 | 4 | on: [ push, pull_request ] 5 | 6 | jobs: 7 | ruff: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: astral-sh/ruff-action@v1 12 | 13 | ruff_format: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: astral-sh/ruff-action@v1 18 | with: 19 | args: format --check --diff 20 | -------------------------------------------------------------------------------- /tests/playlists/relative-playlist.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-TARGETDURATION:5220 3 | 4 | #EXT-X-KEY:METHOD=AES-128,URI="../key.bin", IV=0X10ef8f758ca555115584bb5b3c687f52 5 | 6 | #EXT-X-SESSION-KEY:METHOD=AES-128,URI="../key.bin", IV=0X10ef8f758ca555115584bb5b3c687f52 7 | 8 | 9 | #EXTINF:5220, 10 | /entire1.ts 11 | #EXTINF:5220, 12 | ../entire2.ts 13 | #EXTINF:5220, 14 | ../../entire3.ts 15 | #EXTINF:5220, 16 | entire4.ts 17 | #EXTINF:5220, 18 | ./entire5.ts 19 | #EXTINF:5220, 20 | .//entire6.ts 21 | #EXT-X-ENDLIST 22 | -------------------------------------------------------------------------------- /runtests: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | test_server_stdout=tests/server.stdout 4 | 5 | function install_deps { 6 | pip install -r requirements-dev.txt 7 | } 8 | 9 | function start_server { 10 | rm -f ${test_server_stdout} 11 | python tests/m3u8server.py >${test_server_stdout} 2>&1 & 12 | } 13 | 14 | function stop_server { 15 | pkill -9 -f m3u8server.py 16 | echo "Test server stdout on ${test_server_stdout}" 17 | } 18 | 19 | function run { 20 | PYTHONPATH=. py.test -vv --cov-report term-missing --cov m3u8 tests/ 21 | } 22 | 23 | function main { 24 | install_deps 25 | start_server 26 | run 27 | retval=$? 28 | stop_server 29 | return "$retval" 30 | } 31 | 32 | if [ -z "$1" ]; then 33 | main 34 | else 35 | "$@" 36 | fi 37 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from os.path import abspath, dirname, exists, join 2 | 3 | from setuptools import setup 4 | 5 | long_description = None 6 | if exists("README.md"): 7 | with open("README.md") as file: 8 | long_description = file.read() 9 | 10 | install_reqs = [ 11 | req for req in open(abspath(join(dirname(__file__), "requirements.txt"))) 12 | ] 13 | 14 | setup( 15 | name="m3u8", 16 | author="Globo.com", 17 | version="6.0.0", 18 | license="MIT", 19 | zip_safe=False, 20 | include_package_data=True, 21 | install_requires=install_reqs, 22 | packages=["m3u8"], 23 | url="https://github.com/globocom/m3u8", 24 | description="Python m3u8 parser", 25 | long_description=long_description, 26 | long_description_content_type="text/markdown", 27 | python_requires=">=3.9", 28 | ) 29 | -------------------------------------------------------------------------------- /tests/invalid_versioned_playlists.py: -------------------------------------------------------------------------------- 1 | # Should have at least version 2 if you have IV in EXT-X-KEY. 2 | M3U8_RULE_IV = """ 3 | #EXTM3U 4 | #EXT-X-VERSION: 1 5 | #EXT-X-KEY: METHOD=AES-128, IV=0x123456789ABCDEF0123456789ABCDEF0, URI="https://example.com/key.bin" 6 | #EXT-X-TARGETDURATION: 10 7 | #EXTINF: 10.0, 8 | https://example.com/segment1.ts 9 | """ 10 | 11 | # Should have at least version 3 if you have floating point EXTINF duration values. 12 | M3U8_RULE_FLOATING_POINT = """ 13 | #EXTM3U 14 | #EXT-X-VERSION: 2 15 | #EXT-X-TARGETDURATION: 10 16 | #EXTINF: 10.5, 17 | https://example.com/segment1.ts 18 | """ 19 | 20 | # Should have at least version 4 if you have EXT-X-BYTERANGE or EXT-X-IFRAME-ONLY. 21 | M3U8_RULE_BYTE_RANGE = """ 22 | #EXTM3U 23 | #EXT-X-VERSION: 3 24 | #EXT-X-BYTERANGE: 200000@1000 25 | #EXT-X-TARGETDURATION: 10 26 | #EXTINF: 10.0, 27 | https://example.com/segment1.ts 28 | """ 29 | -------------------------------------------------------------------------------- /tests/test_invalid_versioned_playlists.py: -------------------------------------------------------------------------------- 1 | import invalid_versioned_playlists 2 | import pytest 3 | 4 | import m3u8 5 | 6 | 7 | def test_should_fail_if_iv_in_EXT_X_KEY_and_version_less_than_2(): 8 | with pytest.raises(Exception) as exc_info: 9 | m3u8.parse(invalid_versioned_playlists.M3U8_RULE_IV, strict=True) 10 | 11 | assert "Change the protocol version to 2 or higher." in str(exc_info.value) 12 | 13 | 14 | def test_should_fail_if_floating_point_EXTINF_and_version_less_than_3(): 15 | with pytest.raises(Exception) as exc_info: 16 | m3u8.parse(invalid_versioned_playlists.M3U8_RULE_FLOATING_POINT, strict=True) 17 | 18 | assert "Change the protocol version to 3 or higher." in str(exc_info.value) 19 | 20 | 21 | def test_should_fail_if_EXT_X_BYTERANGE_or_EXT_X_I_FRAMES_ONLY_and_version_less_than_4(): 22 | with pytest.raises(Exception) as exc_info: 23 | m3u8.parse(invalid_versioned_playlists.M3U8_RULE_BYTE_RANGE, strict=True) 24 | 25 | assert "Change the protocol version to 4 or higher." in str(exc_info.value) 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | m3u8 is licensed under the MIT License: 2 | 3 | The MIT License 4 | 5 | Copyright (c) 2012 globo.com webmedia@corp.globo.com 6 | 7 | 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: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 10 | 11 | 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. 12 | -------------------------------------------------------------------------------- /m3u8/version_matching.py: -------------------------------------------------------------------------------- 1 | from m3u8 import protocol 2 | from m3u8.version_matching_rules import VersionMatchingError, available_rules 3 | 4 | 5 | def get_version(file_lines: list[str]): 6 | for line in file_lines: 7 | if line.startswith(protocol.ext_x_version): 8 | version = line.split(":")[1] 9 | return float(version) 10 | 11 | return None 12 | 13 | 14 | def valid_in_all_rules( 15 | line_number: int, line: str, version: float 16 | ) -> list[VersionMatchingError]: 17 | errors = [] 18 | for rule in available_rules: 19 | validator = rule(version, line_number, line) 20 | 21 | if not validator.validate(): 22 | errors.append(validator.get_error()) 23 | 24 | return errors 25 | 26 | 27 | def validate(file_lines: list[str]) -> list[VersionMatchingError]: 28 | found_version = get_version(file_lines) 29 | if found_version is None: 30 | return [] 31 | 32 | errors = [] 33 | for number, line in enumerate(file_lines): 34 | errors_in_line = valid_in_all_rules(number, line, found_version) 35 | errors.extend(errors_in_line) 36 | 37 | return errors 38 | -------------------------------------------------------------------------------- /tests/m3u8server.py: -------------------------------------------------------------------------------- 1 | # Copyright 2014 Globo.com Player authors. All rights reserved. 2 | # Use of this source code is governed by a MIT License 3 | # license that can be found in the LICENSE file. 4 | 5 | # Test server to deliver stubed M3U8s 6 | 7 | from os.path import dirname, abspath, join 8 | 9 | from bottle import route, run, response, redirect 10 | import bottle 11 | import time 12 | 13 | playlists = abspath(join(dirname(__file__), "playlists")) 14 | 15 | 16 | @route("/path/to/redirect_me") 17 | def redirect_route(): 18 | redirect("/simple.m3u8") 19 | 20 | 21 | @route("/simple.m3u8") 22 | def simple(): 23 | response.set_header("Content-Type", "application/vnd.apple.mpegurl") 24 | return m3u8_file("simple-playlist.m3u8") 25 | 26 | 27 | @route("/timeout_simple.m3u8") 28 | def timeout_simple(): 29 | time.sleep(5) 30 | response.set_header("Content-Type", "application/vnd.apple.mpegurl") 31 | return m3u8_file("simple-playlist.m3u8") 32 | 33 | 34 | @route("/path/to/relative-playlist.m3u8") 35 | def relative_playlist(): 36 | response.set_header("Content-Type", "application/vnd.apple.mpegurl") 37 | return m3u8_file("relative-playlist.m3u8") 38 | 39 | 40 | def m3u8_file(filename): 41 | with open(join(playlists, filename)) as fileobj: 42 | return fileobj.read().strip() 43 | 44 | 45 | bottle.debug = True 46 | run(host="localhost", port=8112) 47 | -------------------------------------------------------------------------------- /m3u8/httpclient.py: -------------------------------------------------------------------------------- 1 | import gzip 2 | import ssl 3 | import urllib.request 4 | from urllib.parse import urljoin 5 | 6 | 7 | class DefaultHTTPClient: 8 | def __init__(self, proxies=None): 9 | self.proxies = proxies 10 | 11 | def download(self, uri, timeout=None, headers={}, verify_ssl=True): 12 | proxy_handler = urllib.request.ProxyHandler(self.proxies) 13 | https_handler = HTTPSHandler(verify_ssl=verify_ssl) 14 | opener = urllib.request.build_opener(proxy_handler, https_handler) 15 | opener.addheaders = headers.items() 16 | resource = opener.open(uri, timeout=timeout) 17 | base_uri = urljoin(resource.geturl(), ".") 18 | 19 | if resource.info().get("Content-Encoding") == "gzip": 20 | content = gzip.decompress(resource.read()).decode( 21 | resource.headers.get_content_charset(failobj="utf-8") 22 | ) 23 | else: 24 | content = resource.read().decode( 25 | resource.headers.get_content_charset(failobj="utf-8") 26 | ) 27 | return content, base_uri 28 | 29 | 30 | class HTTPSHandler: 31 | def __new__(self, verify_ssl=True): 32 | context = ssl.create_default_context() 33 | if not verify_ssl: 34 | context.check_hostname = False 35 | context.verify_mode = ssl.CERT_NONE 36 | return urllib.request.HTTPSHandler(context=context) 37 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: CI 4 | 5 | # Controls when the action will run. 6 | on: 7 | # Triggers the workflow on push or pull request events but only for the master branch 8 | push: 9 | branches: [ master ] 10 | pull_request: 11 | branches: [ master ] 12 | 13 | # Allows you to run this workflow manually from the Actions tab 14 | workflow_dispatch: 15 | 16 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 17 | jobs: 18 | # This workflow contains a single job called "build" 19 | build: 20 | # The type of runner that the job will run on 21 | runs-on: ubuntu-latest 22 | strategy: 23 | # You can use PyPy versions in python-version. 24 | # For example, pypy2 and pypy3 25 | matrix: 26 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 27 | 28 | # Steps represent a sequence of tasks that will be executed as part of the job 29 | steps: 30 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 31 | - uses: actions/checkout@v4 32 | - name: Set up Python ${{ matrix.python-version }} 33 | uses: actions/setup-python@v5 34 | with: 35 | python-version: ${{ matrix.python-version }} 36 | 37 | # Runs a single command using the runners shell 38 | - name: Run all tests 39 | run: ./runtests 40 | 41 | -------------------------------------------------------------------------------- /tests/test_strict_validations.py: -------------------------------------------------------------------------------- 1 | # Copyright 2014 Globo.com Player authors. All rights reserved. 2 | # Use of this source code is governed by a MIT License 3 | # license that can be found in the LICENSE file. 4 | 5 | import invalid_versioned_playlists 6 | import pytest 7 | 8 | import m3u8 9 | import m3u8.version_matching_rules 10 | 11 | 12 | @pytest.mark.xfail 13 | def test_should_fail_if_first_line_not_EXTM3U(): 14 | assert 0 15 | 16 | 17 | @pytest.mark.xfail 18 | def test_should_fail_if_expected_ts_segment_line_is_not_valid(): 19 | assert 0 20 | 21 | 22 | @pytest.mark.xfail 23 | def test_should_fail_if_EXT_X_MEDIA_SEQUENCE_is_diffent_from_sequence_number_of_first_uri(): 24 | assert 0 25 | 26 | 27 | @pytest.mark.xfail 28 | def test_should_fail_if_more_than_one_EXT_X_MEDIA_SEQUENCE(): 29 | assert 0 30 | 31 | 32 | @pytest.mark.xfail 33 | def test_should_fail_if_EXT_X_MEDIA_SEQUENCE_is_not_a_number(): 34 | assert 0 35 | 36 | 37 | def test_should_validate_supported_EXT_X_VERSION(): 38 | with pytest.raises( 39 | Exception, 40 | ): 41 | m3u8.parse(invalid_versioned_playlists.M3U8_RULE_IV, strict=True) 42 | 43 | 44 | @pytest.mark.xfail 45 | def test_should_fail_if_any_EXTINF_duration_is_greater_than_TARGET_DURATION(): 46 | assert 0 47 | 48 | 49 | @pytest.mark.xfail 50 | def test_should_fail_if_TARGET_DURATION_not_found(): 51 | assert 0 52 | 53 | 54 | @pytest.mark.xfail 55 | def test_should_fail_if_invalid_m3u8_url_after_EXT_X_STREAM_INF(): 56 | assert 0 57 | -------------------------------------------------------------------------------- /m3u8/mixins.py: -------------------------------------------------------------------------------- 1 | from os.path import dirname 2 | from urllib.parse import urljoin, urlsplit 3 | 4 | 5 | class BasePathMixin: 6 | @property 7 | def absolute_uri(self): 8 | if self.uri is None: 9 | return None 10 | 11 | ret = urljoin(self.base_uri, self.uri) 12 | if self.base_uri: 13 | base_uri_parts = urlsplit(self.base_uri) 14 | if (not base_uri_parts.scheme) and (not base_uri_parts.netloc): 15 | return ret 16 | 17 | if not urlsplit(ret).scheme: 18 | raise ValueError("There can not be `absolute_uri` with no `base_uri` set") 19 | 20 | return ret 21 | 22 | @property 23 | def base_path(self): 24 | if self.uri is None: 25 | return None 26 | return dirname(self.get_path_from_uri()) 27 | 28 | def get_path_from_uri(self): 29 | """Some URIs have a slash in the query string.""" 30 | return self.uri.split("?")[0] 31 | 32 | @base_path.setter 33 | def base_path(self, newbase_path): 34 | if self.uri is not None: 35 | if not self.base_path: 36 | self.uri = f"{newbase_path}/{self.uri}" 37 | else: 38 | self.uri = self.uri.replace(self.base_path, newbase_path) 39 | 40 | 41 | class GroupedBasePathMixin: 42 | def _set_base_uri(self, new_base_uri): 43 | for item in self: 44 | item.base_uri = new_base_uri 45 | 46 | base_uri = property(None, _set_base_uri) 47 | 48 | def _set_base_path(self, newbase_path): 49 | for item in self: 50 | item.base_path = newbase_path 51 | 52 | base_path = property(None, _set_base_path) 53 | -------------------------------------------------------------------------------- /m3u8/protocol.py: -------------------------------------------------------------------------------- 1 | # Copyright 2014 Globo.com Player authors. All rights reserved. 2 | # Use of this source code is governed by a MIT License 3 | # license that can be found in the LICENSE file. 4 | 5 | ext_m3u = "#EXTM3U" 6 | ext_x_targetduration = "#EXT-X-TARGETDURATION" 7 | ext_x_media_sequence = "#EXT-X-MEDIA-SEQUENCE" 8 | ext_x_discontinuity_sequence = "#EXT-X-DISCONTINUITY-SEQUENCE" 9 | ext_x_program_date_time = "#EXT-X-PROGRAM-DATE-TIME" 10 | ext_x_media = "#EXT-X-MEDIA" 11 | ext_x_playlist_type = "#EXT-X-PLAYLIST-TYPE" 12 | ext_x_key = "#EXT-X-KEY" 13 | ext_x_stream_inf = "#EXT-X-STREAM-INF" 14 | ext_x_version = "#EXT-X-VERSION" 15 | ext_x_allow_cache = "#EXT-X-ALLOW-CACHE" 16 | ext_x_endlist = "#EXT-X-ENDLIST" 17 | extinf = "#EXTINF" 18 | ext_i_frames_only = "#EXT-X-I-FRAMES-ONLY" 19 | ext_x_asset = "#EXT-X-ASSET" 20 | ext_x_bitrate = "#EXT-X-BITRATE" 21 | ext_x_byterange = "#EXT-X-BYTERANGE" 22 | ext_x_i_frame_stream_inf = "#EXT-X-I-FRAME-STREAM-INF" 23 | ext_x_discontinuity = "#EXT-X-DISCONTINUITY" 24 | ext_x_cue_out = "#EXT-X-CUE-OUT" 25 | ext_x_cue_out_cont = "#EXT-X-CUE-OUT-CONT" 26 | ext_x_cue_in = "#EXT-X-CUE-IN" 27 | ext_x_cue_span = "#EXT-X-CUE-SPAN" 28 | ext_oatcls_scte35 = "#EXT-OATCLS-SCTE35" 29 | ext_is_independent_segments = "#EXT-X-INDEPENDENT-SEGMENTS" 30 | ext_x_map = "#EXT-X-MAP" 31 | ext_x_start = "#EXT-X-START" 32 | ext_x_server_control = "#EXT-X-SERVER-CONTROL" 33 | ext_x_part_inf = "#EXT-X-PART-INF" 34 | ext_x_part = "#EXT-X-PART" 35 | ext_x_rendition_report = "#EXT-X-RENDITION-REPORT" 36 | ext_x_skip = "#EXT-X-SKIP" 37 | ext_x_session_data = "#EXT-X-SESSION-DATA" 38 | ext_x_session_key = "#EXT-X-SESSION-KEY" 39 | ext_x_preload_hint = "#EXT-X-PRELOAD-HINT" 40 | ext_x_daterange = "#EXT-X-DATERANGE" 41 | ext_x_gap = "#EXT-X-GAP" 42 | ext_x_content_steering = "#EXT-X-CONTENT-STEERING" 43 | ext_x_image_stream_inf = "#EXT-X-IMAGE-STREAM-INF" 44 | ext_x_images_only = "#EXT-X-IMAGES-ONLY" 45 | ext_x_tiles = "#EXT-X-TILES" 46 | -------------------------------------------------------------------------------- /tests/test_http_client.py: -------------------------------------------------------------------------------- 1 | import gzip 2 | import unittest 3 | from http.client import HTTPResponse 4 | from unittest.mock import Mock, patch 5 | 6 | from m3u8.httpclient import DefaultHTTPClient 7 | 8 | 9 | class MockHeaders: 10 | def __init__(self, encoding=None): 11 | self.encoding = encoding 12 | 13 | def get_content_charset(self, failobj="utf-8"): 14 | return self.encoding or failobj 15 | 16 | 17 | class TestDefaultHTTPClient(unittest.TestCase): 18 | @patch("urllib.request.OpenerDirector.open") 19 | def test_download_normal_content(self, mock_open): 20 | client = DefaultHTTPClient() 21 | mock_response = Mock(spec=HTTPResponse) 22 | mock_response.read.return_value = b"playlist content" 23 | mock_response.info.return_value = {} 24 | mock_response.geturl.return_value = "http://example.com/index.m3u8" 25 | mock_response.headers = MockHeaders() 26 | mock_open.return_value = mock_response 27 | 28 | content, base_uri = client.download("http://example.com/index.m3u8") 29 | 30 | self.assertEqual(content, "playlist content") 31 | self.assertEqual(base_uri, "http://example.com/") 32 | 33 | @patch("urllib.request.OpenerDirector.open") 34 | def test_download_gzipped_content(self, mock_open): 35 | client = DefaultHTTPClient() 36 | original_content = "playlist gzipped content" 37 | gzipped_content = gzip.compress(original_content.encode("utf-8")) 38 | mock_response = Mock(spec=HTTPResponse) 39 | mock_response.read.return_value = gzipped_content 40 | mock_response.info.return_value = {"Content-Encoding": "gzip"} 41 | mock_response.geturl.return_value = "http://example.com/index.m3u8" 42 | mock_response.headers = MockHeaders("utf-8") 43 | mock_open.return_value = mock_response 44 | 45 | content, base_uri = client.download("http://example.com/index.m3u8") 46 | 47 | self.assertEqual(content, original_content) 48 | self.assertEqual(base_uri, "http://example.com/") 49 | 50 | @patch("urllib.request.OpenerDirector.open") 51 | def test_download_with_proxy(self, mock_open): 52 | client = DefaultHTTPClient(proxies={"http": "http://proxy.example.com"}) 53 | mock_response = Mock(spec=HTTPResponse) 54 | mock_response.read.return_value = b"playlist proxied content" 55 | mock_response.info.return_value = {} 56 | mock_response.geturl.return_value = "http://example.com/index.m3u8" 57 | mock_response.headers = MockHeaders() 58 | mock_open.return_value = mock_response 59 | 60 | content, base_uri = client.download("http://example.com/index.m3u8") 61 | 62 | self.assertEqual(content, "playlist proxied content") 63 | self.assertEqual(base_uri, "http://example.com/") 64 | -------------------------------------------------------------------------------- /m3u8/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2014 Globo.com Player authors. All rights reserved. 2 | # Use of this source code is governed by a MIT License 3 | # license that can be found in the LICENSE file. 4 | 5 | import os 6 | from urllib.parse import urljoin, urlsplit 7 | 8 | from m3u8.httpclient import DefaultHTTPClient 9 | from m3u8.model import ( 10 | M3U8, 11 | ContentSteering, 12 | DateRange, 13 | DateRangeList, 14 | IFramePlaylist, 15 | ImagePlaylist, 16 | Key, 17 | Media, 18 | MediaList, 19 | PartialSegment, 20 | PartialSegmentList, 21 | PartInformation, 22 | Playlist, 23 | PlaylistList, 24 | PreloadHint, 25 | RenditionReport, 26 | RenditionReportList, 27 | Segment, 28 | SegmentList, 29 | ServerControl, 30 | Skip, 31 | Start, 32 | Tiles, 33 | ) 34 | from m3u8.parser import ParseError, parse 35 | 36 | __all__ = ( 37 | "M3U8", 38 | "Segment", 39 | "SegmentList", 40 | "PartialSegment", 41 | "PartialSegmentList", 42 | "Key", 43 | "Playlist", 44 | "IFramePlaylist", 45 | "Media", 46 | "MediaList", 47 | "PlaylistList", 48 | "Start", 49 | "RenditionReport", 50 | "RenditionReportList", 51 | "ServerControl", 52 | "Skip", 53 | "PartInformation", 54 | "PreloadHint", 55 | "DateRange", 56 | "DateRangeList", 57 | "ContentSteering", 58 | "ImagePlaylist", 59 | "Tiles", 60 | "loads", 61 | "load", 62 | "parse", 63 | "ParseError", 64 | ) 65 | 66 | 67 | def loads(content, uri=None, custom_tags_parser=None): 68 | """ 69 | Given a string with a m3u8 content, returns a M3U8 object. 70 | Optionally parses a uri to set a correct base_uri on the M3U8 object. 71 | Raises ValueError if invalid content 72 | """ 73 | 74 | if uri is None: 75 | return M3U8(content, custom_tags_parser=custom_tags_parser) 76 | else: 77 | base_uri = urljoin(uri, ".") 78 | return M3U8(content, base_uri=base_uri, custom_tags_parser=custom_tags_parser) 79 | 80 | 81 | def load( 82 | uri, 83 | timeout=None, 84 | headers={}, 85 | custom_tags_parser=None, 86 | http_client=DefaultHTTPClient(), 87 | verify_ssl=True, 88 | ): 89 | """ 90 | Retrieves the content from a given URI and returns a M3U8 object. 91 | Raises ValueError if invalid content or IOError if request fails. 92 | """ 93 | base_uri_parts = urlsplit(uri) 94 | if base_uri_parts.scheme and base_uri_parts.netloc: 95 | content, base_uri = http_client.download(uri, timeout, headers, verify_ssl) 96 | return M3U8(content, base_uri=base_uri, custom_tags_parser=custom_tags_parser) 97 | else: 98 | return _load_from_file(uri, custom_tags_parser) 99 | 100 | 101 | def _load_from_file(uri, custom_tags_parser=None): 102 | with open(uri, encoding="utf8") as fileobj: 103 | raw_content = fileobj.read().strip() 104 | base_uri = os.path.dirname(uri) 105 | return M3U8(raw_content, base_uri=base_uri, custom_tags_parser=custom_tags_parser) 106 | -------------------------------------------------------------------------------- /m3u8/version_matching_rules.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from m3u8 import protocol 4 | 5 | 6 | @dataclass 7 | class VersionMatchingError(Exception): 8 | line_number: int 9 | line: str 10 | how_to_fix: str = "Please fix the version matching error." 11 | description: str = "There is a version matching error in the file." 12 | 13 | def __str__(self): 14 | return ( 15 | "Version matching error found in the file when parsing in strict mode.\n" 16 | f"Line {self.line_number}: {self.description}\n" 17 | f"Line content: {self.line}\n" 18 | f"How to fix: {self.how_to_fix}" 19 | "\n" 20 | ) 21 | 22 | 23 | class VersionMatchRuleBase: 24 | description: str = "" 25 | how_to_fix: str = "" 26 | version: float 27 | line_number: int 28 | line: str 29 | 30 | def __init__(self, version: float, line_number: int, line: str) -> None: 31 | self.version = version 32 | self.line_number = line_number 33 | self.line = line 34 | 35 | def validate(self): 36 | raise NotImplementedError 37 | 38 | def get_error(self): 39 | return VersionMatchingError( 40 | line_number=self.line_number, 41 | line=self.line, 42 | description=self.description, 43 | how_to_fix=self.how_to_fix, 44 | ) 45 | 46 | 47 | class ValidIVInEXTXKEY(VersionMatchRuleBase): 48 | description = ( 49 | "You must use at least protocol version 2 if you have IV in EXT-X-KEY." 50 | ) 51 | how_to_fix = "Change the protocol version to 2 or higher." 52 | 53 | def validate(self): 54 | if protocol.ext_x_key not in self.line: 55 | return True 56 | 57 | if "IV" in self.line: 58 | return self.version >= 2 59 | 60 | return True 61 | 62 | 63 | class ValidFloatingPointEXTINF(VersionMatchRuleBase): 64 | description = "You must use at least protocol version 3 if you have floating point EXTINF duration values." 65 | how_to_fix = "Change the protocol version to 3 or higher." 66 | 67 | def validate(self): 68 | if protocol.extinf not in self.line: 69 | return True 70 | 71 | chunks = self.line.replace(protocol.extinf + ":", "").split(",", 1) 72 | duration = chunks[0] 73 | 74 | def is_number(value: str): 75 | try: 76 | float(value) 77 | return True 78 | except ValueError: 79 | return False 80 | 81 | def is_floating_number(value: str): 82 | return is_number(value) and "." in value 83 | 84 | if is_floating_number(duration): 85 | return self.version >= 3 86 | 87 | return is_number(duration) 88 | 89 | 90 | class ValidEXTXBYTERANGEOrEXTXIFRAMESONLY(VersionMatchRuleBase): 91 | description = "You must use at least protocol version 4 if you have EXT-X-BYTERANGE or EXT-X-IFRAME-ONLY." 92 | how_to_fix = "Change the protocol version to 4 or higher." 93 | 94 | def validate(self): 95 | if ( 96 | protocol.ext_x_byterange not in self.line 97 | and protocol.ext_i_frames_only not in self.line 98 | ): 99 | return True 100 | 101 | return self.version >= 4 102 | 103 | 104 | available_rules: list[type[VersionMatchRuleBase]] = [ 105 | ValidIVInEXTXKEY, 106 | ValidFloatingPointEXTINF, 107 | ValidEXTXBYTERANGEOrEXTXIFRAMESONLY, 108 | ] 109 | -------------------------------------------------------------------------------- /tests/test_version_matching_rules.py: -------------------------------------------------------------------------------- 1 | from m3u8.version_matching_rules import ( 2 | ValidEXTXBYTERANGEOrEXTXIFRAMESONLY, 3 | ValidFloatingPointEXTINF, 4 | ValidIVInEXTXKEY, 5 | ) 6 | 7 | 8 | def test_invalid_iv_in_EXT_X_KEY(): 9 | validator = ValidIVInEXTXKEY( 10 | version=1, 11 | line_number=1, 12 | line="#EXT-X-KEY: METHOD=AES-128, IV=0x123456789ABCDEF0123456789ABCDEF0, URI=https://example.com/key.bin", 13 | ) 14 | 15 | assert not validator.validate() 16 | 17 | 18 | def test_valid_iv_in_EXT_X_KEY(): 19 | examples = [ 20 | { 21 | "line": "#EXT-X-KEY: METHOD=AES-128, URI=https://example.com/key.bin", 22 | "version": 1, 23 | "expected": True, 24 | }, 25 | { 26 | "line": "#EXT-X-KEY: METHOD=AES-128, IV=0x123456789ABCDEF0123456789ABCDEF0, URI=https://example.com/key.bin", 27 | "version": 2, 28 | "expected": True, 29 | }, 30 | { 31 | "line": "#EXT-X-KEY: METHOD=AES-128, IV=0x123456789ABCDEF0123456789ABCDEF0, URI=https://example.com/key.bin", 32 | "version": 3, 33 | "expected": True, 34 | }, 35 | # Invalid case 36 | { 37 | "line": "#EXT-X-KEY: METHOD=AES-128, IV=0x123456789ABCDEF0123456789ABCDEF0, URI=https://example.com/key.bin", 38 | "version": 1, 39 | "expected": False, 40 | }, 41 | ] 42 | 43 | for example in examples: 44 | validator = ValidIVInEXTXKEY( 45 | version=example["version"], 46 | line_number=1, 47 | line=example["line"], 48 | ) 49 | assert validator.validate() == example["expected"] 50 | 51 | 52 | def test_invalid_floating_point_EXTINF(): 53 | examples = [ 54 | { 55 | "line": "#EXTINF: 10.5,", 56 | "version": 2, 57 | }, 58 | { 59 | "line": "#EXTINF: A,", 60 | "version": 3, 61 | }, 62 | ] 63 | 64 | for example in examples: 65 | validator = ValidFloatingPointEXTINF( 66 | version=example["version"], 67 | line_number=1, 68 | line=example["line"], 69 | ) 70 | assert not validator.validate() 71 | 72 | 73 | def test_valid_floating_point_EXTINF(): 74 | examples = [ 75 | { 76 | "line": "#EXTINF: 10,", 77 | "version": 2, 78 | }, 79 | { 80 | "line": "#EXTINF: 10.5,", 81 | "version": 3, 82 | }, 83 | { 84 | "line": "#EXTINF: 10.5,", 85 | "version": 4, 86 | }, 87 | ] 88 | 89 | for example in examples: 90 | validator = ValidFloatingPointEXTINF( 91 | version=example["version"], 92 | line_number=1, 93 | line=example["line"], 94 | ) 95 | assert validator.validate() 96 | 97 | 98 | def test_invalid_EXT_X_BYTERANGE_or_EXT_X_I_FRAMES_ONLY(): 99 | examples = [ 100 | { 101 | "line": "#EXT-X-BYTERANGE: 200000@1000", 102 | "version": 3, 103 | }, 104 | { 105 | "line": "#EXT-X-I-FRAMES-ONLY", 106 | "version": 3, 107 | }, 108 | ] 109 | 110 | for example in examples: 111 | validator = ValidEXTXBYTERANGEOrEXTXIFRAMESONLY( 112 | version=example["version"], 113 | line_number=1, 114 | line=example["line"], 115 | ) 116 | assert not validator.validate() 117 | 118 | 119 | def test_valid_EXT_X_BYTERANGE_or_EXT_X_I_FRAMES_ONLY(): 120 | examples = [ 121 | { 122 | "line": "#EXT-X-BYTERANGE: 200000@1000", 123 | "version": 4, 124 | }, 125 | { 126 | "line": "#EXT-X-I-FRAMES-ONLY", 127 | "version": 4, 128 | }, 129 | { 130 | "line": "#EXT-X-BYTERANGE: 200000@1000", 131 | "version": 5, 132 | }, 133 | { 134 | "line": "#EXT-X-I-FRAMES-ONLY", 135 | "version": 5, 136 | }, 137 | ] 138 | 139 | for example in examples: 140 | validator = ValidEXTXBYTERANGEOrEXTXIFRAMESONLY( 141 | version=example["version"], 142 | line_number=1, 143 | line=example["line"], 144 | ) 145 | assert validator.validate() 146 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![image](https://github.com/globocom/m3u8/actions/workflows/main.yml/badge.svg) [![image](https://badge.fury.io/py/m3u8.svg)](https://badge.fury.io/py/m3u8) 2 | 3 | # m3u8 4 | 5 | Python [m3u8](https://tools.ietf.org/html/rfc8216) parser. 6 | 7 | # Documentation 8 | 9 | ## Loading a playlist 10 | 11 | To load a playlist into an object from uri, file path or directly from 12 | string, use the `load/loads` functions: 13 | 14 | ```python 15 | import m3u8 16 | 17 | playlist = m3u8.load('http://videoserver.com/playlist.m3u8') # this could also be an absolute filename 18 | print(playlist.segments) 19 | print(playlist.target_duration) 20 | 21 | # if you already have the content as string, use 22 | 23 | playlist = m3u8.loads('#EXTM3U8 ... etc ... ') 24 | ``` 25 | 26 | ## Dumping a playlist 27 | 28 | To dump a playlist from an object to the console or a file, use the 29 | `dump/dumps` functions: 30 | 31 | ``` python 32 | import m3u8 33 | 34 | playlist = m3u8.load('http://videoserver.com/playlist.m3u8') 35 | print(playlist.dumps()) 36 | 37 | # if you want to write a file from its content 38 | 39 | playlist.dump('playlist.m3u8') 40 | ``` 41 | 42 | # Supported tags 43 | 44 | - [\#EXT-X-TARGETDURATION](https://tools.ietf.org/html/rfc8216#section-4.3.3.1) 45 | - [\#EXT-X-MEDIA-SEQUENCE](https://tools.ietf.org/html/rfc8216#section-4.3.3.2) 46 | - [\#EXT-X-DISCONTINUITY-SEQUENCE](https://tools.ietf.org/html/rfc8216#section-4.3.3.3) 47 | - [\#EXT-X-PROGRAM-DATE-TIME](https://tools.ietf.org/html/rfc8216#section-4.3.2.6) 48 | - [\#EXT-X-MEDIA](https://tools.ietf.org/html/rfc8216#section-4.3.4.1) 49 | - [\#EXT-X-PLAYLIST-TYPE](https://tools.ietf.org/html/rfc8216#section-4.3.3.5) 50 | - [\#EXT-X-KEY](https://tools.ietf.org/html/rfc8216#section-4.3.2.4) 51 | - [\#EXT-X-STREAM-INF](https://tools.ietf.org/html/rfc8216#section-4.3.4.2) 52 | - [\#EXT-X-VERSION](https://tools.ietf.org/html/rfc8216#section-4.3.1.2) 53 | - [\#EXT-X-ALLOW-CACHE](https://datatracker.ietf.org/doc/html/draft-pantos-http-live-streaming-07#section-3.3.6) 54 | - [\#EXT-X-ENDLIST](https://tools.ietf.org/html/rfc8216#section-4.3.3.4) 55 | - [\#EXTINF](https://tools.ietf.org/html/rfc8216#section-4.3.2.1) 56 | - [\#EXT-X-I-FRAMES-ONLY](https://tools.ietf.org/html/rfc8216#section-4.3.3.6) 57 | - [\#EXT-X-BITRATE](https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis#section-4.4.4.8) 58 | - [\#EXT-X-BYTERANGE](https://tools.ietf.org/html/rfc8216#section-4.3.2.2) 59 | - [\#EXT-X-I-FRAME-STREAM-INF](https://tools.ietf.org/html/rfc8216#section-4.3.4.3) 60 | - [\#EXT-X-IMAGES-ONLY](https://github.com/image-media-playlist/spec/blob/master/image_media_playlist_v0_4.pdf) 61 | - [\#EXT-X-IMAGE-STREAM-INF](https://github.com/image-media-playlist/spec/blob/master/image_media_playlist_v0_4.pdf) 62 | - [\#EXT-X-TILES](https://github.com/image-media-playlist/spec/blob/master/image_media_playlist_v0_4.pdf) 63 | - [\#EXT-X-DISCONTINUITY](https://tools.ietf.org/html/rfc8216#section-4.3.2.3) 64 | - \#EXT-X-CUE-OUT 65 | - \#EXT-X-CUE-OUT-CONT 66 | - \#EXT-X-CUE-IN 67 | - \#EXT-X-CUE-SPAN 68 | - \#EXT-OATCLS-SCTE35 69 | - [\#EXT-X-INDEPENDENT-SEGMENTS](https://tools.ietf.org/html/rfc8216#section-4.3.5.1) 70 | - [\#EXT-X-MAP](https://tools.ietf.org/html/rfc8216#section-4.3.2.5) 71 | - [\#EXT-X-START](https://tools.ietf.org/html/rfc8216#section-4.3.5.2) 72 | - [\#EXT-X-SERVER-CONTROL](https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis#section-4.4.3.8) 73 | - [\#EXT-X-PART-INF](https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis#section-4.4.3.7) 74 | - [\#EXT-X-PART](https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis#section-4.4.4.9) 75 | - [\#EXT-X-RENDITION-REPORT](https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis#section-4.4.5.4) 76 | - [\#EXT-X-SKIP](https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis#section-4.4.5.2) 77 | - [\#EXT-X-SESSION-DATA](https://tools.ietf.org/html/rfc8216#section-4.3.4.4) 78 | - [\#EXT-X-PRELOAD-HINT](https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis-09#section-4.4.5.3) 79 | - [\#EXT-X-SESSION-KEY](https://tools.ietf.org/html/rfc8216#section-4.3.4.5) 80 | - [\#EXT-X-DATERANGE](https://tools.ietf.org/html/rfc8216#section-4.3.2.7) 81 | - [\#EXT-X-GAP](https://tools.ietf.org/html/draft-pantos-hls-rfc8216bis-05#section-4.4.2.7) 82 | - [\#EXT-X-CONTENT-STEERING](https://tools.ietf.org/html/draft-pantos-hls-rfc8216bis-10#section-4.4.6.64) 83 | 84 | # Frequently Asked Questions 85 | 86 | - [FAQ](https://github.com/globocom/m3u8/wiki/FAQ) 87 | 88 | # Running Tests 89 | 90 | ``` bash 91 | $ ./runtests 92 | ``` 93 | 94 | # Contributing 95 | 96 | All contributions are welcome, but we will merge a pull request if, and 97 | only if, it 98 | 99 | - Has tests 100 | - Follows the code conventions 101 | 102 | If you plan to implement a new feature or something that will take more 103 | than a few minutes, please open an issue to make sure we don't work on 104 | the same thing. 105 | -------------------------------------------------------------------------------- /tests/test_loader.py: -------------------------------------------------------------------------------- 1 | # Copyright 2014 Globo.com Player authors. All rights reserved. 2 | # Use of this source code is governed by a MIT License 3 | # license that can be found in the LICENSE file. 4 | 5 | import os 6 | import socket 7 | import urllib.parse 8 | import unittest.mock 9 | 10 | import pytest 11 | 12 | import m3u8 13 | import playlists 14 | 15 | 16 | def test_loads_should_create_object_from_string(): 17 | obj = m3u8.loads(playlists.SIMPLE_PLAYLIST) 18 | assert isinstance(obj, m3u8.M3U8) 19 | assert 5220 == obj.target_duration 20 | assert "http://media.example.com/entire.ts" == obj.segments[0].uri 21 | 22 | 23 | def test_load_should_create_object_from_file(): 24 | obj = m3u8.load(playlists.SIMPLE_PLAYLIST_FILENAME) 25 | assert isinstance(obj, m3u8.M3U8) 26 | assert 5220 == obj.target_duration 27 | assert "http://media.example.com/entire.ts" == obj.segments[0].uri 28 | 29 | 30 | def test_load_should_create_object_from_uri(): 31 | obj = m3u8.load(playlists.SIMPLE_PLAYLIST_URI) 32 | assert isinstance(obj, m3u8.M3U8) 33 | assert 5220 == obj.target_duration 34 | assert "http://media.example.com/entire.ts" == obj.segments[0].uri 35 | 36 | 37 | def test_load_should_remember_redirect(): 38 | obj = m3u8.load(playlists.REDIRECT_PLAYLIST_URI) 39 | urlparsed = urllib.parse.urlparse(playlists.SIMPLE_PLAYLIST_URI) 40 | assert urlparsed.scheme + "://" + urlparsed.netloc + "/" == obj.base_uri 41 | 42 | 43 | def test_load_should_create_object_from_file_with_relative_segments(): 44 | base_uri = os.path.dirname(playlists.RELATIVE_PLAYLIST_FILENAME) 45 | obj = m3u8.load(playlists.RELATIVE_PLAYLIST_FILENAME) 46 | expected_key_abspath = "%s/key.bin" % os.path.dirname(base_uri) 47 | expected_key_path = "../key.bin" 48 | expected_ts1_abspath = "/entire1.ts" 49 | expected_ts1_path = "/entire1.ts" 50 | expected_ts2_abspath = "%s/entire2.ts" % os.path.dirname(base_uri) 51 | expected_ts2_path = "../entire2.ts" 52 | expected_ts3_abspath = "%s/entire3.ts" % os.path.dirname(os.path.dirname(base_uri)) 53 | expected_ts3_path = "../../entire3.ts" 54 | expected_ts4_abspath = "%s/entire4.ts" % base_uri 55 | expected_ts4_path = "entire4.ts" 56 | expected_ts5_abspath = "%s/entire5.ts" % base_uri 57 | expected_ts5_path = "./entire5.ts" 58 | expected_ts6_abspath = "%s/entire6.ts" % base_uri 59 | expected_ts6_path = ".//entire6.ts" 60 | 61 | assert isinstance(obj, m3u8.M3U8) 62 | assert expected_key_path == obj.keys[0].uri 63 | assert expected_key_abspath == obj.keys[0].absolute_uri 64 | assert expected_ts1_path == obj.segments[0].uri 65 | assert expected_ts1_abspath == obj.segments[0].absolute_uri 66 | assert expected_ts2_path == obj.segments[1].uri 67 | assert expected_ts2_abspath == obj.segments[1].absolute_uri 68 | assert expected_ts3_path == obj.segments[2].uri 69 | assert expected_ts3_abspath == obj.segments[2].absolute_uri 70 | assert expected_ts4_path == obj.segments[3].uri 71 | assert expected_ts4_abspath == obj.segments[3].absolute_uri 72 | assert expected_ts5_path == obj.segments[4].uri 73 | assert expected_ts5_abspath == obj.segments[4].absolute_uri 74 | assert expected_ts6_path == obj.segments[5].uri 75 | assert expected_ts6_abspath == obj.segments[5].absolute_uri 76 | 77 | 78 | def test_load_should_create_object_from_uri_with_relative_segments(): 79 | obj = m3u8.load(playlists.RELATIVE_PLAYLIST_URI) 80 | urlparsed = urllib.parse.urlparse(playlists.RELATIVE_PLAYLIST_URI) 81 | base_uri = os.path.normpath(urlparsed.path + "/..") 82 | prefix = urlparsed.scheme + "://" + urlparsed.netloc 83 | expected_key_abspath = "{}{}key.bin".format( 84 | prefix, 85 | os.path.normpath(base_uri + "/..") + "/", 86 | ) 87 | expected_key_path = "../key.bin" 88 | expected_ts1_abspath = "{}{}entire1.ts".format(prefix, "/") 89 | expected_ts1_path = "/entire1.ts" 90 | expected_ts2_abspath = "{}{}entire2.ts".format( 91 | prefix, 92 | os.path.normpath(base_uri + "/..") + "/", 93 | ) 94 | expected_ts2_path = "../entire2.ts" 95 | expected_ts3_abspath = "{}{}entire3.ts".format( 96 | prefix, 97 | os.path.normpath(base_uri + "/../.."), 98 | ) 99 | expected_ts3_path = "../../entire3.ts" 100 | expected_ts4_abspath = "{}{}entire4.ts".format(prefix, base_uri + "/") 101 | expected_ts4_path = "entire4.ts" 102 | expected_ts5_abspath = "{}{}entire5.ts".format(prefix, base_uri + "/") 103 | expected_ts5_path = "./entire5.ts" 104 | expected_ts6_abspath = "{}{}entire6.ts".format(prefix, base_uri + "/") 105 | expected_ts6_path = ".//entire6.ts" 106 | 107 | assert isinstance(obj, m3u8.M3U8) 108 | assert expected_key_path == obj.keys[0].uri 109 | assert expected_key_abspath == obj.keys[0].absolute_uri 110 | assert expected_ts1_path == obj.segments[0].uri 111 | assert expected_ts1_abspath == obj.segments[0].absolute_uri 112 | assert expected_ts2_path == obj.segments[1].uri 113 | assert expected_ts2_abspath == obj.segments[1].absolute_uri 114 | assert expected_ts3_path == obj.segments[2].uri 115 | assert expected_ts3_abspath == obj.segments[2].absolute_uri 116 | assert expected_ts4_path == obj.segments[3].uri 117 | assert expected_ts4_abspath == obj.segments[3].absolute_uri 118 | assert expected_ts5_path == obj.segments[4].uri 119 | assert expected_ts5_abspath == obj.segments[4].absolute_uri 120 | assert expected_ts6_path == obj.segments[5].uri 121 | assert expected_ts6_abspath == obj.segments[5].absolute_uri 122 | 123 | 124 | def test_there_should_not_be_absolute_uris_with_loads(): 125 | with open(playlists.RELATIVE_PLAYLIST_FILENAME) as f: 126 | content = f.read() 127 | obj = m3u8.loads(content) 128 | with pytest.raises(ValueError) as e: 129 | obj.keys[0].absolute_uri 130 | assert str(e.value) == "There can not be `absolute_uri` with no `base_uri` set" 131 | 132 | 133 | def test_absolute_uri_should_handle_empty_base_uri_path(): 134 | key = m3u8.model.Key(method="AES", uri="/key.bin", base_uri="http://example.com") 135 | assert "http://example.com/key.bin" == key.absolute_uri 136 | 137 | 138 | def test_presence_of_base_uri_if_provided_when_loading_from_string(): 139 | with open(playlists.RELATIVE_PLAYLIST_FILENAME) as f: 140 | content = f.read() 141 | obj = m3u8.loads(content, uri="http://media.example.com/path/playlist.m3u8") 142 | assert obj.base_uri == "http://media.example.com/path/" 143 | 144 | 145 | def test_raise_timeout_exception_if_timeout_happens_when_loading_from_uri(): 146 | try: 147 | m3u8.load(playlists.TIMEOUT_SIMPLE_PLAYLIST_URI, timeout=1) 148 | except socket.timeout: 149 | assert True 150 | else: 151 | assert False 152 | 153 | 154 | def test_windows_paths(): 155 | file_path = "C:\\HLS Video\test.m3u8" 156 | with unittest.mock.patch("builtins.open") as mock_open: 157 | mock_open.return_value.__enter__.return_value.read.return_value = ( 158 | playlists.WINDOWS_PLAYLIST 159 | ) 160 | obj = m3u8.load(file_path) 161 | assert obj.segments[0].uri == "C:\\HLS Video\\test1.ts" 162 | assert obj.segments[0].absolute_uri == "C:\\HLS Video\\test1.ts" 163 | -------------------------------------------------------------------------------- /tests/test_variant_m3u8.py: -------------------------------------------------------------------------------- 1 | # Copyright 2014 Globo.com Player authors. All rights reserved. 2 | # Use of this source code is governed by a MIT License 3 | # license that can be found in the LICENSE file. 4 | 5 | import playlists 6 | 7 | import m3u8 8 | 9 | 10 | def test_create_a_variant_m3u8_with_two_playlists(): 11 | variant_m3u8 = m3u8.M3U8() 12 | 13 | subtitles = m3u8.Media( 14 | "english_sub.m3u8", 15 | "SUBTITLES", 16 | "subs", 17 | "en", 18 | "English", 19 | "YES", 20 | "YES", 21 | "NO", 22 | None, 23 | ) 24 | variant_m3u8.add_media(subtitles) 25 | 26 | low_playlist = m3u8.Playlist( 27 | "http://example.com/low.m3u8", 28 | stream_info={ 29 | "bandwidth": 1280000, 30 | "program_id": 1, 31 | "closed_captions": "NONE", 32 | "subtitles": "subs", 33 | }, 34 | media=[subtitles], 35 | base_uri=None, 36 | ) 37 | high_playlist = m3u8.Playlist( 38 | "http://example.com/high.m3u8", 39 | stream_info={"bandwidth": 3000000, "program_id": 1, "subtitles": "subs"}, 40 | media=[subtitles], 41 | base_uri=None, 42 | ) 43 | 44 | variant_m3u8.add_playlist(low_playlist) 45 | variant_m3u8.add_playlist(high_playlist) 46 | 47 | expected_content = """\ 48 | #EXTM3U 49 | #EXT-X-MEDIA:URI="english_sub.m3u8",TYPE=SUBTITLES,GROUP-ID="subs",LANGUAGE="en",NAME="English",DEFAULT=YES,AUTOSELECT=YES,FORCED=NO 50 | #EXT-X-STREAM-INF:PROGRAM-ID=1,CLOSED-CAPTIONS=NONE,BANDWIDTH=1280000,SUBTITLES="subs" 51 | http://example.com/low.m3u8 52 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=3000000,SUBTITLES="subs" 53 | http://example.com/high.m3u8 54 | """ 55 | assert expected_content == variant_m3u8.dumps() 56 | 57 | 58 | def test_create_a_variant_m3u8_with_two_playlists_and_two_iframe_playlists(): 59 | variant_m3u8 = m3u8.M3U8() 60 | 61 | subtitles = m3u8.Media( 62 | "english_sub.m3u8", 63 | "SUBTITLES", 64 | "subs", 65 | "en", 66 | "English", 67 | "YES", 68 | "YES", 69 | "NO", 70 | None, 71 | ) 72 | variant_m3u8.add_media(subtitles) 73 | 74 | low_playlist = m3u8.Playlist( 75 | uri="video-800k.m3u8", 76 | stream_info={ 77 | "bandwidth": 800000, 78 | "program_id": 1, 79 | "resolution": "624x352", 80 | "codecs": "avc1.4d001f, mp4a.40.5", 81 | "subtitles": "subs", 82 | }, 83 | media=[subtitles], 84 | base_uri="http://example.com/", 85 | ) 86 | high_playlist = m3u8.Playlist( 87 | uri="video-1200k.m3u8", 88 | stream_info={ 89 | "bandwidth": 1200000, 90 | "program_id": 1, 91 | "codecs": "avc1.4d001f, mp4a.40.5", 92 | "subtitles": "subs", 93 | }, 94 | media=[subtitles], 95 | base_uri="http://example.com/", 96 | ) 97 | low_iframe_playlist = m3u8.IFramePlaylist( 98 | uri="video-800k-iframes.m3u8", 99 | iframe_stream_info={ 100 | "bandwidth": 151288, 101 | "program_id": 1, 102 | "closed_captions": None, 103 | "resolution": "624x352", 104 | "codecs": "avc1.4d001f", 105 | }, 106 | base_uri="http://example.com/", 107 | ) 108 | high_iframe_playlist = m3u8.IFramePlaylist( 109 | uri="video-1200k-iframes.m3u8", 110 | iframe_stream_info={"bandwidth": 193350, "codecs": "avc1.4d001f"}, 111 | base_uri="http://example.com/", 112 | ) 113 | 114 | variant_m3u8.add_playlist(low_playlist) 115 | variant_m3u8.add_playlist(high_playlist) 116 | variant_m3u8.add_iframe_playlist(low_iframe_playlist) 117 | variant_m3u8.add_iframe_playlist(high_iframe_playlist) 118 | 119 | expected_content = """\ 120 | #EXTM3U 121 | #EXT-X-MEDIA:URI="english_sub.m3u8",TYPE=SUBTITLES,GROUP-ID="subs",\ 122 | LANGUAGE="en",NAME="English",DEFAULT=YES,AUTOSELECT=YES,FORCED=NO 123 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=800000,RESOLUTION=624x352,\ 124 | CODECS="avc1.4d001f, mp4a.40.5",SUBTITLES="subs" 125 | video-800k.m3u8 126 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1200000,\ 127 | CODECS="avc1.4d001f, mp4a.40.5",SUBTITLES="subs" 128 | video-1200k.m3u8 129 | #EXT-X-I-FRAME-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=151288,RESOLUTION=624x352,\ 130 | CODECS="avc1.4d001f",URI="video-800k-iframes.m3u8" 131 | #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=193350,\ 132 | CODECS="avc1.4d001f",URI="video-1200k-iframes.m3u8" 133 | """ 134 | assert expected_content == variant_m3u8.dumps() 135 | 136 | 137 | def test_variant_playlist_with_average_bandwidth(): 138 | variant_m3u8 = m3u8.M3U8() 139 | 140 | low_playlist = m3u8.Playlist( 141 | "http://example.com/low.m3u8", 142 | stream_info={ 143 | "bandwidth": 1280000, 144 | "average_bandwidth": 1257891, 145 | "program_id": 1, 146 | "subtitles": "subs", 147 | }, 148 | media=[], 149 | base_uri=None, 150 | ) 151 | high_playlist = m3u8.Playlist( 152 | "http://example.com/high.m3u8", 153 | stream_info={ 154 | "bandwidth": 3000000, 155 | "average_bandwidth": 2857123, 156 | "program_id": 1, 157 | "subtitles": "subs", 158 | }, 159 | media=[], 160 | base_uri=None, 161 | ) 162 | 163 | variant_m3u8.add_playlist(low_playlist) 164 | variant_m3u8.add_playlist(high_playlist) 165 | 166 | expected_content = """\ 167 | #EXTM3U 168 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1280000,AVERAGE-BANDWIDTH=1257891 169 | http://example.com/low.m3u8 170 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=3000000,AVERAGE-BANDWIDTH=2857123 171 | http://example.com/high.m3u8 172 | """ 173 | assert expected_content == variant_m3u8.dumps() 174 | 175 | 176 | def test_variant_playlist_with_video_range(): 177 | variant_m3u8 = m3u8.M3U8() 178 | 179 | sdr_playlist = m3u8.Playlist( 180 | "http://example.com/sdr.m3u8", 181 | stream_info={"bandwidth": 1280000, "video_range": "SDR", "program_id": 1}, 182 | media=[], 183 | base_uri=None, 184 | ) 185 | hdr_playlist = m3u8.Playlist( 186 | "http://example.com/hdr.m3u8", 187 | stream_info={"bandwidth": 3000000, "video_range": "PQ", "program_id": 1}, 188 | media=[], 189 | base_uri=None, 190 | ) 191 | 192 | variant_m3u8.add_playlist(sdr_playlist) 193 | variant_m3u8.add_playlist(hdr_playlist) 194 | 195 | expected_content = """\ 196 | #EXTM3U 197 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1280000,VIDEO-RANGE=SDR 198 | http://example.com/sdr.m3u8 199 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=3000000,VIDEO-RANGE=PQ 200 | http://example.com/hdr.m3u8 201 | """ 202 | assert expected_content == variant_m3u8.dumps() 203 | 204 | 205 | def test_variant_playlist_with_hdcp_level(): 206 | variant_m3u8 = m3u8.M3U8() 207 | 208 | none_playlist = m3u8.Playlist( 209 | "http://example.com/none.m3u8", 210 | stream_info={"bandwidth": 1280000, "hdcp_level": "NONE", "program_id": 1}, 211 | media=[], 212 | base_uri=None, 213 | ) 214 | type0_playlist = m3u8.Playlist( 215 | "http://example.com/type0.m3u8", 216 | stream_info={"bandwidth": 3000000, "hdcp_level": "TYPE-0", "program_id": 1}, 217 | media=[], 218 | base_uri=None, 219 | ) 220 | type1_playlist = m3u8.Playlist( 221 | "http://example.com/type1.m3u8", 222 | stream_info={"bandwidth": 4000000, "hdcp_level": "TYPE-1", "program_id": 1}, 223 | media=[], 224 | base_uri=None, 225 | ) 226 | 227 | variant_m3u8.add_playlist(none_playlist) 228 | variant_m3u8.add_playlist(type0_playlist) 229 | variant_m3u8.add_playlist(type1_playlist) 230 | 231 | expected_content = """\ 232 | #EXTM3U 233 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1280000,HDCP-LEVEL=NONE 234 | http://example.com/none.m3u8 235 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=3000000,HDCP-LEVEL=TYPE-0 236 | http://example.com/type0.m3u8 237 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=4000000,HDCP-LEVEL=TYPE-1 238 | http://example.com/type1.m3u8 239 | """ 240 | assert expected_content == variant_m3u8.dumps() 241 | 242 | 243 | def test_variant_playlist_with_multiple_media(): 244 | variant_m3u8 = m3u8.loads(playlists.MULTI_MEDIA_PLAYLIST) 245 | assert variant_m3u8.dumps() == playlists.MULTI_MEDIA_PLAYLIST 246 | 247 | 248 | def test_create_a_variant_m3u8_with_iframe_with_average_bandwidth_playlists(): 249 | variant_m3u8 = m3u8.M3U8() 250 | 251 | subtitles = m3u8.Media( 252 | "english_sub.m3u8", 253 | "SUBTITLES", 254 | "subs", 255 | "en", 256 | "English", 257 | "YES", 258 | "YES", 259 | "NO", 260 | None, 261 | ) 262 | variant_m3u8.add_media(subtitles) 263 | 264 | low_playlist = m3u8.Playlist( 265 | uri="video-800k.m3u8", 266 | stream_info={ 267 | "bandwidth": 800000, 268 | "average_bandwidth": 555000, 269 | "resolution": "624x352", 270 | "codecs": "avc1.4d001f, mp4a.40.5", 271 | "subtitles": "subs", 272 | }, 273 | media=[subtitles], 274 | base_uri="http://example.com/", 275 | ) 276 | low_iframe_playlist = m3u8.IFramePlaylist( 277 | uri="video-800k-iframes.m3u8", 278 | iframe_stream_info={ 279 | "bandwidth": 151288, 280 | "average_bandwidth": 111000, 281 | "resolution": "624x352", 282 | "codecs": "avc1.4d001f", 283 | }, 284 | base_uri="http://example.com/", 285 | ) 286 | 287 | variant_m3u8.add_playlist(low_playlist) 288 | variant_m3u8.add_iframe_playlist(low_iframe_playlist) 289 | 290 | expected_content = """\ 291 | #EXTM3U 292 | #EXT-X-MEDIA:URI="english_sub.m3u8",TYPE=SUBTITLES,GROUP-ID="subs",\ 293 | LANGUAGE="en",NAME="English",DEFAULT=YES,AUTOSELECT=YES,FORCED=NO 294 | #EXT-X-STREAM-INF:BANDWIDTH=800000,AVERAGE-BANDWIDTH=555000,\ 295 | RESOLUTION=624x352,CODECS="avc1.4d001f, mp4a.40.5",SUBTITLES="subs" 296 | video-800k.m3u8 297 | #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=151288,\ 298 | AVERAGE-BANDWIDTH=111000,RESOLUTION=624x352,CODECS="avc1.4d001f",\ 299 | URI="video-800k-iframes.m3u8" 300 | """ 301 | assert expected_content == variant_m3u8.dumps() 302 | 303 | 304 | def test_create_a_variant_m3u8_with_iframe_with_video_range_playlists(): 305 | variant_m3u8 = m3u8.M3U8() 306 | 307 | for vrange in ["SDR", "PQ", "HLG"]: 308 | playlist = m3u8.Playlist( 309 | uri="video-%s.m3u8" % vrange, 310 | stream_info={"bandwidth": 3000000, "video_range": vrange}, 311 | media=[], 312 | base_uri="http://example.com/%s" % vrange, 313 | ) 314 | iframe_playlist = m3u8.IFramePlaylist( 315 | uri="video-%s-iframes.m3u8" % vrange, 316 | iframe_stream_info={"bandwidth": 3000000, "video_range": vrange}, 317 | base_uri="http://example.com/%s" % vrange, 318 | ) 319 | 320 | variant_m3u8.add_playlist(playlist) 321 | variant_m3u8.add_iframe_playlist(iframe_playlist) 322 | 323 | expected_content = """\ 324 | #EXTM3U 325 | #EXT-X-STREAM-INF:BANDWIDTH=3000000,VIDEO-RANGE=SDR 326 | video-SDR.m3u8 327 | #EXT-X-STREAM-INF:BANDWIDTH=3000000,VIDEO-RANGE=PQ 328 | video-PQ.m3u8 329 | #EXT-X-STREAM-INF:BANDWIDTH=3000000,VIDEO-RANGE=HLG 330 | video-HLG.m3u8 331 | #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=3000000,VIDEO-RANGE=SDR,URI="video-SDR-iframes.m3u8" 332 | #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=3000000,VIDEO-RANGE=PQ,URI="video-PQ-iframes.m3u8" 333 | #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=3000000,VIDEO-RANGE=HLG,URI="video-HLG-iframes.m3u8" 334 | """ 335 | assert expected_content == variant_m3u8.dumps() 336 | 337 | 338 | def test_create_a_variant_m3u8_with_iframe_with_hdcp_level_playlists(): 339 | variant_m3u8 = m3u8.M3U8() 340 | 341 | for hdcplv in ["NONE", "TYPE-0", "TYPE-1"]: 342 | playlist = m3u8.Playlist( 343 | uri="video-%s.m3u8" % hdcplv, 344 | stream_info={"bandwidth": 3000000, "hdcp_level": hdcplv}, 345 | media=[], 346 | base_uri="http://example.com/%s" % hdcplv, 347 | ) 348 | iframe_playlist = m3u8.IFramePlaylist( 349 | uri="video-%s-iframes.m3u8" % hdcplv, 350 | iframe_stream_info={"bandwidth": 3000000, "hdcp_level": hdcplv}, 351 | base_uri="http://example.com/%s" % hdcplv, 352 | ) 353 | 354 | variant_m3u8.add_playlist(playlist) 355 | variant_m3u8.add_iframe_playlist(iframe_playlist) 356 | 357 | expected_content = """\ 358 | #EXTM3U 359 | #EXT-X-STREAM-INF:BANDWIDTH=3000000,HDCP-LEVEL=NONE 360 | video-NONE.m3u8 361 | #EXT-X-STREAM-INF:BANDWIDTH=3000000,HDCP-LEVEL=TYPE-0 362 | video-TYPE-0.m3u8 363 | #EXT-X-STREAM-INF:BANDWIDTH=3000000,HDCP-LEVEL=TYPE-1 364 | video-TYPE-1.m3u8 365 | #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=3000000,HDCP-LEVEL=NONE,URI="video-NONE-iframes.m3u8" 366 | #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=3000000,HDCP-LEVEL=TYPE-0,URI="video-TYPE-0-iframes.m3u8" 367 | #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=3000000,HDCP-LEVEL=TYPE-1,URI="video-TYPE-1-iframes.m3u8" 368 | """ 369 | assert expected_content == variant_m3u8.dumps() 370 | 371 | 372 | def test_create_a_variant_m3u8_with_two_playlists_and_two_image_playlists(): 373 | variant_m3u8 = m3u8.M3U8() 374 | 375 | subtitles = m3u8.Media( 376 | "english_sub.m3u8", 377 | "SUBTITLES", 378 | "subs", 379 | "en", 380 | "English", 381 | "YES", 382 | "YES", 383 | "NO", 384 | None, 385 | ) 386 | variant_m3u8.add_media(subtitles) 387 | 388 | low_playlist = m3u8.Playlist( 389 | uri="video-800k.m3u8", 390 | stream_info={ 391 | "bandwidth": 800000, 392 | "program_id": 1, 393 | "resolution": "624x352", 394 | "codecs": "avc1.4d001f, mp4a.40.5", 395 | "subtitles": "subs", 396 | }, 397 | media=[subtitles], 398 | base_uri="http://example.com/", 399 | ) 400 | high_playlist = m3u8.Playlist( 401 | uri="video-1200k.m3u8", 402 | stream_info={ 403 | "bandwidth": 1200000, 404 | "program_id": 1, 405 | "codecs": "avc1.4d001f, mp4a.40.5", 406 | "subtitles": "subs", 407 | }, 408 | media=[subtitles], 409 | base_uri="http://example.com/", 410 | ) 411 | low_image_playlist = m3u8.ImagePlaylist( 412 | uri="thumbnails-sd.m3u8", 413 | image_stream_info={ 414 | "bandwidth": 151288, 415 | "resolution": "320x160", 416 | "codecs": "jpeg", 417 | }, 418 | base_uri="http://example.com/", 419 | ) 420 | high_image_playlist = m3u8.ImagePlaylist( 421 | uri="thumbnails-hd.m3u8", 422 | image_stream_info={ 423 | "bandwidth": 193350, 424 | "resolution": "640x320", 425 | "codecs": "jpeg", 426 | }, 427 | base_uri="http://example.com/", 428 | ) 429 | 430 | variant_m3u8.add_playlist(low_playlist) 431 | variant_m3u8.add_playlist(high_playlist) 432 | variant_m3u8.add_image_playlist(low_image_playlist) 433 | variant_m3u8.add_image_playlist(high_image_playlist) 434 | 435 | expected_content = """\ 436 | #EXTM3U 437 | #EXT-X-MEDIA:URI="english_sub.m3u8",TYPE=SUBTITLES,GROUP-ID="subs",\ 438 | LANGUAGE="en",NAME="English",DEFAULT=YES,AUTOSELECT=YES,FORCED=NO 439 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=800000,RESOLUTION=624x352,\ 440 | CODECS="avc1.4d001f, mp4a.40.5",SUBTITLES="subs" 441 | video-800k.m3u8 442 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1200000,\ 443 | CODECS="avc1.4d001f, mp4a.40.5",SUBTITLES="subs" 444 | video-1200k.m3u8 445 | #EXT-X-IMAGE-STREAM-INF:BANDWIDTH=151288,RESOLUTION=320x160,\ 446 | CODECS="jpeg",URI="thumbnails-sd.m3u8" 447 | #EXT-X-IMAGE-STREAM-INF:BANDWIDTH=193350,RESOLUTION=640x320,\ 448 | CODECS="jpeg",URI="thumbnails-hd.m3u8" 449 | """ 450 | assert expected_content == variant_m3u8.dumps() 451 | -------------------------------------------------------------------------------- /m3u8/parser.py: -------------------------------------------------------------------------------- 1 | # Copyright 2014 Globo.com Player authors. All rights reserved. 2 | # Use of this source code is governed by a MIT License 3 | # license that can be found in the LICENSE file. 4 | 5 | import itertools 6 | import re 7 | from datetime import datetime, timedelta 8 | 9 | try: 10 | from backports.datetime_fromisoformat import MonkeyPatch 11 | 12 | MonkeyPatch.patch_fromisoformat() 13 | except ImportError: 14 | pass 15 | 16 | 17 | from m3u8 import protocol, version_matching 18 | 19 | """ 20 | http://tools.ietf.org/html/draft-pantos-http-live-streaming-08#section-3.2 21 | http://stackoverflow.com/questions/2785755/how-to-split-but-ignore-separators-in-quoted-strings-in-python 22 | """ 23 | ATTRIBUTELISTPATTERN = re.compile(r"""((?:[^,"']|"[^"]*"|'[^']*')+)""") 24 | 25 | 26 | def cast_date_time(value): 27 | return datetime.fromisoformat(value) 28 | 29 | 30 | def format_date_time(value, **kwargs): 31 | return value.isoformat(**kwargs) 32 | 33 | 34 | class ParseError(Exception): 35 | def __init__(self, lineno, line): 36 | self.lineno = lineno 37 | self.line = line 38 | 39 | def __str__(self): 40 | return "Syntax error in manifest on line %d: %s" % (self.lineno, self.line) 41 | 42 | 43 | def parse(content, strict=False, custom_tags_parser=None): 44 | """ 45 | Given a M3U8 playlist content returns a dictionary with all data found 46 | """ 47 | data = { 48 | "media_sequence": 0, 49 | "is_variant": False, 50 | "is_endlist": False, 51 | "is_i_frames_only": False, 52 | "is_independent_segments": False, 53 | "is_images_only": False, 54 | "playlist_type": None, 55 | "playlists": [], 56 | "segments": [], 57 | "iframe_playlists": [], 58 | "image_playlists": [], 59 | "tiles": [], 60 | "media": [], 61 | "keys": [], 62 | "rendition_reports": [], 63 | "skip": {}, 64 | "part_inf": {}, 65 | "session_data": [], 66 | "session_keys": [], 67 | "segment_map": [], 68 | } 69 | 70 | state = { 71 | "expect_segment": False, 72 | "expect_playlist": False, 73 | "current_key": None, 74 | "current_segment_map": None, 75 | } 76 | 77 | lines = string_to_lines(content) 78 | if strict: 79 | found_errors = version_matching.validate(lines) 80 | 81 | if len(found_errors) > 0: 82 | raise Exception(found_errors) 83 | 84 | for lineno, line in enumerate(lines, 1): 85 | line = line.strip() 86 | parse_kwargs = { 87 | "line": line, 88 | "lineno": lineno, 89 | "data": data, 90 | "state": state, 91 | "strict": strict, 92 | } 93 | 94 | # Call custom parser if needed 95 | if line.startswith("#") and callable(custom_tags_parser): 96 | go_to_next_line = custom_tags_parser(line, lineno, data, state) 97 | 98 | # Do not try to parse other standard tags on this line if custom_tags_parser 99 | # function returns `True` 100 | if go_to_next_line: 101 | continue 102 | 103 | if line.startswith(protocol.ext_x_byterange): 104 | _parse_byterange(**parse_kwargs) 105 | continue 106 | 107 | elif line.startswith(protocol.ext_x_bitrate): 108 | _parse_bitrate(**parse_kwargs) 109 | 110 | elif line.startswith(protocol.ext_x_targetduration): 111 | _parse_targetduration(**parse_kwargs) 112 | 113 | elif line.startswith(protocol.ext_x_media_sequence): 114 | _parse_media_sequence(**parse_kwargs) 115 | 116 | elif line.startswith(protocol.ext_x_discontinuity_sequence): 117 | _parse_discontinuity_sequence(**parse_kwargs) 118 | 119 | elif line.startswith(protocol.ext_x_program_date_time): 120 | _parse_program_date_time(**parse_kwargs) 121 | 122 | elif line.startswith(protocol.ext_x_discontinuity): 123 | _parse_discontinuity(**parse_kwargs) 124 | 125 | elif line.startswith(protocol.ext_x_cue_out_cont): 126 | _parse_cueout_cont(**parse_kwargs) 127 | 128 | elif line.startswith(protocol.ext_x_cue_out): 129 | _parse_cueout(**parse_kwargs) 130 | 131 | elif line.startswith(f"{protocol.ext_oatcls_scte35}:"): 132 | _parse_oatcls_scte35(**parse_kwargs) 133 | 134 | elif line.startswith(f"{protocol.ext_x_asset}:"): 135 | _parse_asset(**parse_kwargs) 136 | 137 | elif line.startswith(protocol.ext_x_cue_in): 138 | _parse_cue_in(**parse_kwargs) 139 | 140 | elif line.startswith(protocol.ext_x_cue_span): 141 | _parse_cue_span(**parse_kwargs) 142 | 143 | elif line.startswith(protocol.ext_x_version): 144 | _parse_version(**parse_kwargs) 145 | 146 | elif line.startswith(protocol.ext_x_allow_cache): 147 | _parse_allow_cache(**parse_kwargs) 148 | 149 | elif line.startswith(protocol.ext_x_key): 150 | _parse_key(**parse_kwargs) 151 | 152 | elif line.startswith(protocol.extinf): 153 | _parse_extinf(**parse_kwargs) 154 | 155 | elif line.startswith(protocol.ext_x_stream_inf): 156 | _parse_stream_inf(**parse_kwargs) 157 | 158 | elif line.startswith(protocol.ext_x_i_frame_stream_inf): 159 | _parse_i_frame_stream_inf(**parse_kwargs) 160 | 161 | elif line.startswith(protocol.ext_x_media): 162 | _parse_media(**parse_kwargs) 163 | 164 | elif line.startswith(protocol.ext_x_playlist_type): 165 | _parse_playlist_type(**parse_kwargs) 166 | 167 | elif line.startswith(protocol.ext_i_frames_only): 168 | _parse_i_frames_only(**parse_kwargs) 169 | 170 | elif line.startswith(protocol.ext_is_independent_segments): 171 | _parse_is_independent_segments(**parse_kwargs) 172 | 173 | elif line.startswith(protocol.ext_x_endlist): 174 | _parse_endlist(**parse_kwargs) 175 | 176 | elif line.startswith(protocol.ext_x_map): 177 | _parse_x_map(**parse_kwargs) 178 | 179 | elif line.startswith(protocol.ext_x_start): 180 | _parse_start(**parse_kwargs) 181 | 182 | elif line.startswith(protocol.ext_x_server_control): 183 | _parse_server_control(**parse_kwargs) 184 | 185 | elif line.startswith(protocol.ext_x_part_inf): 186 | _parse_part_inf(**parse_kwargs) 187 | 188 | elif line.startswith(protocol.ext_x_rendition_report): 189 | _parse_rendition_report(**parse_kwargs) 190 | 191 | elif line.startswith(protocol.ext_x_part): 192 | _parse_part(**parse_kwargs) 193 | 194 | elif line.startswith(protocol.ext_x_skip): 195 | _parse_skip(**parse_kwargs) 196 | 197 | elif line.startswith(protocol.ext_x_session_data): 198 | _parse_session_data(**parse_kwargs) 199 | 200 | elif line.startswith(protocol.ext_x_session_key): 201 | _parse_session_key(**parse_kwargs) 202 | 203 | elif line.startswith(protocol.ext_x_preload_hint): 204 | _parse_preload_hint(**parse_kwargs) 205 | 206 | elif line.startswith(protocol.ext_x_daterange): 207 | _parse_daterange(**parse_kwargs) 208 | 209 | elif line.startswith(protocol.ext_x_gap): 210 | _parse_gap(**parse_kwargs) 211 | 212 | elif line.startswith(protocol.ext_x_content_steering): 213 | _parse_content_steering(**parse_kwargs) 214 | 215 | elif line.startswith(protocol.ext_x_image_stream_inf): 216 | _parse_image_stream_inf(**parse_kwargs) 217 | 218 | elif line.startswith(protocol.ext_x_images_only): 219 | _parse_is_images_only(**parse_kwargs) 220 | elif line.startswith(protocol.ext_x_tiles): 221 | _parse_tiles(**parse_kwargs) 222 | 223 | # #EXTM3U should be present. 224 | elif line.startswith(protocol.ext_m3u): 225 | pass 226 | 227 | # Blank lines are ignored. 228 | elif line.strip() == "": 229 | pass 230 | 231 | # Lines that don't start with # are either segments or playlists. 232 | elif (not line.startswith("#")) and (state["expect_segment"]): 233 | _parse_ts_chunk(**parse_kwargs) 234 | 235 | elif (not line.startswith("#")) and (state["expect_playlist"]): 236 | _parse_variant_playlist(**parse_kwargs) 237 | 238 | # Lines that haven't been recognized by any of the parsers above are illegal 239 | # in strict mode. 240 | elif strict: 241 | raise ParseError(lineno, line) 242 | 243 | # Handle remaining partial segments. 244 | if "segment" in state: 245 | data["segments"].append(state.pop("segment")) 246 | 247 | return data 248 | 249 | 250 | def _parse_key(line, data, state, **kwargs): 251 | params = ATTRIBUTELISTPATTERN.split(line.replace(protocol.ext_x_key + ":", ""))[ 252 | 1::2 253 | ] 254 | key = {} 255 | for param in params: 256 | name, value = param.split("=", 1) 257 | key[normalize_attribute(name)] = remove_quotes(value) 258 | 259 | state["current_key"] = key 260 | if key not in data["keys"]: 261 | data["keys"].append(key) 262 | 263 | 264 | def _parse_extinf(line, state, lineno, strict, **kwargs): 265 | chunks = line.replace(protocol.extinf + ":", "").split(",", 1) 266 | if len(chunks) == 2: 267 | duration, title = chunks 268 | elif len(chunks) == 1: 269 | if strict: 270 | raise ParseError(lineno, line) 271 | else: 272 | duration = chunks[0] 273 | title = "" 274 | if "segment" not in state: 275 | state["segment"] = {} 276 | state["segment"]["duration"] = float(duration) 277 | state["segment"]["title"] = title 278 | state["expect_segment"] = True 279 | 280 | 281 | def _parse_ts_chunk(line, data, state, **kwargs): 282 | segment = state.pop("segment") 283 | if state.get("program_date_time"): 284 | segment["program_date_time"] = state.pop("program_date_time") 285 | if state.get("current_program_date_time"): 286 | segment["current_program_date_time"] = state["current_program_date_time"] 287 | state["current_program_date_time"] += timedelta(seconds=segment["duration"]) 288 | segment["uri"] = line 289 | segment["cue_in"] = state.pop("cue_in", False) 290 | segment["cue_out"] = state.pop("cue_out", False) 291 | segment["cue_out_start"] = state.pop("cue_out_start", False) 292 | segment["cue_out_explicitly_duration"] = state.pop( 293 | "cue_out_explicitly_duration", False 294 | ) 295 | 296 | scte_op = state.get if segment["cue_out"] else state.pop 297 | segment["scte35"] = scte_op("current_cue_out_scte35", None) 298 | segment["oatcls_scte35"] = scte_op("current_cue_out_oatcls_scte35", None) 299 | segment["scte35_duration"] = scte_op("current_cue_out_duration", None) 300 | segment["scte35_elapsedtime"] = scte_op("current_cue_out_elapsedtime", None) 301 | segment["asset_metadata"] = scte_op("asset_metadata", None) 302 | 303 | segment["discontinuity"] = state.pop("discontinuity", False) 304 | if state.get("current_key"): 305 | segment["key"] = state["current_key"] 306 | else: 307 | # For unencrypted segments, the initial key would be None 308 | if None not in data["keys"]: 309 | data["keys"].append(None) 310 | if state.get("current_segment_map"): 311 | segment["init_section"] = state["current_segment_map"] 312 | segment["dateranges"] = state.pop("dateranges", None) 313 | segment["gap_tag"] = state.pop("gap", None) 314 | data["segments"].append(segment) 315 | state["expect_segment"] = False 316 | 317 | 318 | def _parse_attribute_list(prefix, line, attribute_parser, default_parser=None): 319 | params = ATTRIBUTELISTPATTERN.split(line.replace(prefix + ":", ""))[1::2] 320 | 321 | attributes = {} 322 | if not line.startswith(prefix + ":"): 323 | return attributes 324 | 325 | for param in params: 326 | param_parts = param.split("=", 1) 327 | if len(param_parts) == 1: 328 | name = "" 329 | value = param_parts[0] 330 | else: 331 | name, value = param_parts 332 | 333 | name = normalize_attribute(name) 334 | if name in attribute_parser: 335 | value = attribute_parser[name](value) 336 | elif default_parser is not None: 337 | value = default_parser(value) 338 | 339 | attributes[name] = value 340 | 341 | return attributes 342 | 343 | 344 | def _parse_stream_inf(line, data, state, **kwargs): 345 | state["expect_playlist"] = True 346 | data["is_variant"] = True 347 | data["media_sequence"] = None 348 | attribute_parser = remove_quotes_parser( 349 | "codecs", 350 | "audio", 351 | "video", 352 | "video_range", 353 | "subtitles", 354 | "pathway_id", 355 | "stable_variant_id", 356 | ) 357 | attribute_parser["program_id"] = int 358 | attribute_parser["bandwidth"] = lambda x: int(float(x)) 359 | attribute_parser["average_bandwidth"] = int 360 | attribute_parser["frame_rate"] = float 361 | attribute_parser["hdcp_level"] = str 362 | state["stream_info"] = _parse_attribute_list( 363 | protocol.ext_x_stream_inf, line, attribute_parser 364 | ) 365 | 366 | 367 | def _parse_i_frame_stream_inf(line, data, **kwargs): 368 | attribute_parser = remove_quotes_parser( 369 | "codecs", "uri", "pathway_id", "stable_variant_id" 370 | ) 371 | attribute_parser["program_id"] = int 372 | attribute_parser["bandwidth"] = int 373 | attribute_parser["average_bandwidth"] = int 374 | attribute_parser["hdcp_level"] = str 375 | iframe_stream_info = _parse_attribute_list( 376 | protocol.ext_x_i_frame_stream_inf, line, attribute_parser 377 | ) 378 | iframe_playlist = { 379 | "uri": iframe_stream_info.pop("uri"), 380 | "iframe_stream_info": iframe_stream_info, 381 | } 382 | 383 | data["iframe_playlists"].append(iframe_playlist) 384 | 385 | 386 | def _parse_image_stream_inf(line, data, **kwargs): 387 | attribute_parser = remove_quotes_parser( 388 | "codecs", "uri", "pathway_id", "stable_variant_id" 389 | ) 390 | attribute_parser["program_id"] = int 391 | attribute_parser["bandwidth"] = int 392 | attribute_parser["average_bandwidth"] = int 393 | attribute_parser["resolution"] = str 394 | image_stream_info = _parse_attribute_list( 395 | protocol.ext_x_image_stream_inf, line, attribute_parser 396 | ) 397 | image_playlist = { 398 | "uri": image_stream_info.pop("uri"), 399 | "image_stream_info": image_stream_info, 400 | } 401 | 402 | data["image_playlists"].append(image_playlist) 403 | 404 | 405 | def _parse_is_images_only(line, data, **kwargs): 406 | data["is_images_only"] = True 407 | 408 | 409 | def _parse_tiles(line, data, state, **kwargs): 410 | attribute_parser = remove_quotes_parser("uri") 411 | attribute_parser["resolution"] = str 412 | attribute_parser["layout"] = str 413 | attribute_parser["duration"] = float 414 | tiles_info = _parse_attribute_list(protocol.ext_x_tiles, line, attribute_parser) 415 | data["tiles"].append(tiles_info) 416 | 417 | 418 | def _parse_media(line, data, **kwargs): 419 | quoted = remove_quotes_parser( 420 | "uri", 421 | "group_id", 422 | "language", 423 | "assoc_language", 424 | "name", 425 | "instream_id", 426 | "characteristics", 427 | "channels", 428 | "stable_rendition_id", 429 | "thumbnails", 430 | "image", 431 | ) 432 | media = _parse_attribute_list(protocol.ext_x_media, line, quoted) 433 | data["media"].append(media) 434 | 435 | 436 | def _parse_variant_playlist(line, data, state, **kwargs): 437 | playlist = {"uri": line, "stream_info": state.pop("stream_info")} 438 | data["playlists"].append(playlist) 439 | state["expect_playlist"] = False 440 | 441 | 442 | def _parse_bitrate(state, **kwargs): 443 | if "segment" not in state: 444 | state["segment"] = {} 445 | state["segment"]["bitrate"] = _parse_simple_parameter(cast_to=int, **kwargs) 446 | 447 | 448 | def _parse_byterange(line, state, **kwargs): 449 | if "segment" not in state: 450 | state["segment"] = {} 451 | state["segment"]["byterange"] = line.replace(protocol.ext_x_byterange + ":", "") 452 | state["expect_segment"] = True 453 | 454 | 455 | def _parse_targetduration(**parse_kwargs): 456 | return _parse_simple_parameter(cast_to=int, **parse_kwargs) 457 | 458 | 459 | def _parse_media_sequence(**parse_kwargs): 460 | return _parse_simple_parameter(cast_to=int, **parse_kwargs) 461 | 462 | 463 | def _parse_discontinuity_sequence(**parse_kwargs): 464 | return _parse_simple_parameter(cast_to=int, **parse_kwargs) 465 | 466 | 467 | def _parse_program_date_time(line, state, data, **parse_kwargs): 468 | _, program_date_time = _parse_simple_parameter_raw_value( 469 | line, cast_to=cast_date_time, **parse_kwargs 470 | ) 471 | if not data.get("program_date_time"): 472 | data["program_date_time"] = program_date_time 473 | state["current_program_date_time"] = program_date_time 474 | state["program_date_time"] = program_date_time 475 | 476 | 477 | def _parse_discontinuity(state, **parse_kwargs): 478 | state["discontinuity"] = True 479 | 480 | 481 | def _parse_cue_in(state, **parse_kwargs): 482 | state["cue_in"] = True 483 | 484 | 485 | def _parse_cue_span(state, **parse_kwargs): 486 | state["cue_out"] = True 487 | 488 | 489 | def _parse_version(**parse_kwargs): 490 | return _parse_simple_parameter(cast_to=int, **parse_kwargs) 491 | 492 | 493 | def _parse_allow_cache(**parse_kwargs): 494 | return _parse_simple_parameter(cast_to=str, **parse_kwargs) 495 | 496 | 497 | def _parse_playlist_type(line, data, **kwargs): 498 | return _parse_simple_parameter(line, data) 499 | 500 | 501 | def _parse_x_map(line, data, state, **kwargs): 502 | quoted_parser = remove_quotes_parser("uri", "byterange") 503 | segment_map_info = _parse_attribute_list(protocol.ext_x_map, line, quoted_parser) 504 | state["current_segment_map"] = segment_map_info 505 | data["segment_map"].append(segment_map_info) 506 | 507 | 508 | def _parse_start(line, data, **kwargs): 509 | attribute_parser = {"time_offset": lambda x: float(x)} 510 | start_info = _parse_attribute_list(protocol.ext_x_start, line, attribute_parser) 511 | data["start"] = start_info 512 | 513 | 514 | def _parse_gap(state, **kwargs): 515 | state["gap"] = True 516 | 517 | 518 | def _parse_simple_parameter_raw_value(line, cast_to=str, normalize=False, **kwargs): 519 | param, value = line.split(":", 1) 520 | param = normalize_attribute(param.replace("#EXT-X-", "")) 521 | if normalize: 522 | value = value.strip().lower() 523 | return param, cast_to(value) 524 | 525 | 526 | def _parse_and_set_simple_parameter_raw_value( 527 | line, data, cast_to=str, normalize=False, **kwargs 528 | ): 529 | param, value = _parse_simple_parameter_raw_value(line, cast_to, normalize) 530 | data[param] = value 531 | return data[param] 532 | 533 | 534 | def _parse_simple_parameter(line, data, cast_to=str, **kwargs): 535 | return _parse_and_set_simple_parameter_raw_value(line, data, cast_to, True) 536 | 537 | 538 | def _parse_i_frames_only(data, **kwargs): 539 | data["is_i_frames_only"] = True 540 | 541 | 542 | def _parse_is_independent_segments(data, **kwargs): 543 | data["is_independent_segments"] = True 544 | 545 | 546 | def _parse_endlist(data, **kwargs): 547 | data["is_endlist"] = True 548 | 549 | 550 | def _parse_cueout_cont(line, state, **kwargs): 551 | state["cue_out"] = True 552 | 553 | elements = line.split(":", 1) 554 | if len(elements) != 2: 555 | return 556 | 557 | # EXT-X-CUE-OUT-CONT:ElapsedTime=10,Duration=60,SCTE35=... style 558 | cue_info = _parse_attribute_list( 559 | protocol.ext_x_cue_out_cont, 560 | line, 561 | remove_quotes_parser("duration", "elapsedtime", "scte35"), 562 | ) 563 | 564 | # EXT-X-CUE-OUT-CONT:2.436/120 style 565 | progress = cue_info.get("") 566 | if progress: 567 | progress_parts = progress.split("/", 1) 568 | if len(progress_parts) == 1: 569 | state["current_cue_out_duration"] = progress_parts[0] 570 | else: 571 | state["current_cue_out_elapsedtime"] = progress_parts[0] 572 | state["current_cue_out_duration"] = progress_parts[1] 573 | 574 | duration = cue_info.get("duration") 575 | if duration: 576 | state["current_cue_out_duration"] = duration 577 | 578 | scte35 = cue_info.get("scte35") 579 | if duration: 580 | state["current_cue_out_scte35"] = scte35 581 | 582 | elapsedtime = cue_info.get("elapsedtime") 583 | if elapsedtime: 584 | state["current_cue_out_elapsedtime"] = elapsedtime 585 | 586 | 587 | def _parse_cueout(line, state, **kwargs): 588 | state["cue_out_start"] = True 589 | state["cue_out"] = True 590 | if "DURATION" in line.upper(): 591 | state["cue_out_explicitly_duration"] = True 592 | 593 | elements = line.split(":", 1) 594 | if len(elements) != 2: 595 | return 596 | 597 | cue_info = _parse_attribute_list( 598 | protocol.ext_x_cue_out, 599 | line, 600 | remove_quotes_parser("cue"), 601 | ) 602 | cue_out_scte35 = cue_info.get("cue") 603 | cue_out_duration = cue_info.get("duration") or cue_info.get("") 604 | 605 | current_cue_out_scte35 = state.get("current_cue_out_scte35") 606 | state["current_cue_out_scte35"] = cue_out_scte35 or current_cue_out_scte35 607 | state["current_cue_out_duration"] = cue_out_duration 608 | 609 | 610 | def _parse_server_control(line, data, **kwargs): 611 | attribute_parser = { 612 | "can_block_reload": str, 613 | "hold_back": lambda x: float(x), 614 | "part_hold_back": lambda x: float(x), 615 | "can_skip_until": lambda x: float(x), 616 | "can_skip_dateranges": str, 617 | } 618 | 619 | data["server_control"] = _parse_attribute_list( 620 | protocol.ext_x_server_control, line, attribute_parser 621 | ) 622 | 623 | 624 | def _parse_part_inf(line, data, **kwargs): 625 | attribute_parser = {"part_target": lambda x: float(x)} 626 | 627 | data["part_inf"] = _parse_attribute_list( 628 | protocol.ext_x_part_inf, line, attribute_parser 629 | ) 630 | 631 | 632 | def _parse_rendition_report(line, data, **kwargs): 633 | attribute_parser = remove_quotes_parser("uri") 634 | attribute_parser["last_msn"] = int 635 | attribute_parser["last_part"] = int 636 | 637 | rendition_report = _parse_attribute_list( 638 | protocol.ext_x_rendition_report, line, attribute_parser 639 | ) 640 | 641 | data["rendition_reports"].append(rendition_report) 642 | 643 | 644 | def _parse_part(line, state, **kwargs): 645 | attribute_parser = remove_quotes_parser("uri") 646 | attribute_parser["duration"] = lambda x: float(x) 647 | attribute_parser["independent"] = str 648 | attribute_parser["gap"] = str 649 | attribute_parser["byterange"] = str 650 | 651 | part = _parse_attribute_list(protocol.ext_x_part, line, attribute_parser) 652 | 653 | # this should always be true according to spec 654 | if state.get("current_program_date_time"): 655 | part["program_date_time"] = state["current_program_date_time"] 656 | state["current_program_date_time"] += timedelta(seconds=part["duration"]) 657 | 658 | part["dateranges"] = state.pop("dateranges", None) 659 | part["gap_tag"] = state.pop("gap", None) 660 | 661 | if "segment" not in state: 662 | state["segment"] = {} 663 | segment = state["segment"] 664 | if "parts" not in segment: 665 | segment["parts"] = [] 666 | 667 | segment["parts"].append(part) 668 | 669 | 670 | def _parse_skip(line, data, **parse_kwargs): 671 | attribute_parser = remove_quotes_parser("recently_removed_dateranges") 672 | attribute_parser["skipped_segments"] = int 673 | 674 | data["skip"] = _parse_attribute_list(protocol.ext_x_skip, line, attribute_parser) 675 | 676 | 677 | def _parse_session_data(line, data, **kwargs): 678 | quoted = remove_quotes_parser("data_id", "value", "uri", "language") 679 | session_data = _parse_attribute_list(protocol.ext_x_session_data, line, quoted) 680 | data["session_data"].append(session_data) 681 | 682 | 683 | def _parse_session_key(line, data, **kwargs): 684 | params = ATTRIBUTELISTPATTERN.split( 685 | line.replace(protocol.ext_x_session_key + ":", "") 686 | )[1::2] 687 | key = {} 688 | for param in params: 689 | name, value = param.split("=", 1) 690 | key[normalize_attribute(name)] = remove_quotes(value) 691 | data["session_keys"].append(key) 692 | 693 | 694 | def _parse_preload_hint(line, data, **kwargs): 695 | attribute_parser = remove_quotes_parser("uri") 696 | attribute_parser["type"] = str 697 | attribute_parser["byterange_start"] = int 698 | attribute_parser["byterange_length"] = int 699 | 700 | data["preload_hint"] = _parse_attribute_list( 701 | protocol.ext_x_preload_hint, line, attribute_parser 702 | ) 703 | 704 | 705 | def _parse_daterange(line, state, **kwargs): 706 | attribute_parser = remove_quotes_parser("id", "class", "start_date", "end_date") 707 | attribute_parser["duration"] = float 708 | attribute_parser["planned_duration"] = float 709 | attribute_parser["end_on_next"] = str 710 | attribute_parser["scte35_cmd"] = str 711 | attribute_parser["scte35_out"] = str 712 | attribute_parser["scte35_in"] = str 713 | 714 | parsed = _parse_attribute_list(protocol.ext_x_daterange, line, attribute_parser) 715 | 716 | if "dateranges" not in state: 717 | state["dateranges"] = [] 718 | 719 | state["dateranges"].append(parsed) 720 | 721 | 722 | def _parse_content_steering(line, data, **kwargs): 723 | attribute_parser = remove_quotes_parser("server_uri", "pathway_id") 724 | 725 | data["content_steering"] = _parse_attribute_list( 726 | protocol.ext_x_content_steering, line, attribute_parser 727 | ) 728 | 729 | 730 | def _parse_oatcls_scte35(line, state, **kwargs): 731 | scte35_cue = line.split(":", 1)[1] 732 | state["current_cue_out_oatcls_scte35"] = scte35_cue 733 | state["current_cue_out_scte35"] = scte35_cue 734 | 735 | 736 | def _parse_asset(line, state, **kwargs): 737 | # EXT-X-ASSET attribute values may or may not be quoted, and need to be URL-encoded. 738 | # They are preserved as-is here to prevent loss of information. 739 | state["asset_metadata"] = _parse_attribute_list( 740 | protocol.ext_x_asset, line, {}, default_parser=str 741 | ) 742 | 743 | 744 | def string_to_lines(string): 745 | return string.strip().splitlines() 746 | 747 | 748 | def remove_quotes_parser(*attrs): 749 | return dict(zip(attrs, itertools.repeat(remove_quotes))) 750 | 751 | 752 | def remove_quotes(string): 753 | """ 754 | Remove quotes from string. 755 | 756 | Ex.: 757 | "foo" -> foo 758 | 'foo' -> foo 759 | 'foo -> 'foo 760 | 761 | """ 762 | quotes = ('"', "'") 763 | if string.startswith(quotes) and string.endswith(quotes): 764 | return string[1:-1] 765 | return string 766 | 767 | 768 | def normalize_attribute(attribute): 769 | return attribute.replace("-", "_").lower().strip() 770 | 771 | 772 | def get_segment_custom_value(state, key, default=None): 773 | """ 774 | Helper function for getting custom values for Segment 775 | Are useful with custom_tags_parser 776 | """ 777 | if "segment" not in state: 778 | return default 779 | if "custom_parser_values" not in state["segment"]: 780 | return default 781 | return state["segment"]["custom_parser_values"].get(key, default) 782 | 783 | 784 | def save_segment_custom_value(state, key, value): 785 | """ 786 | Helper function for saving custom values for Segment 787 | Are useful with custom_tags_parser 788 | """ 789 | if "segment" not in state: 790 | state["segment"] = {} 791 | 792 | if "custom_parser_values" not in state["segment"]: 793 | state["segment"]["custom_parser_values"] = {} 794 | 795 | state["segment"]["custom_parser_values"][key] = value 796 | -------------------------------------------------------------------------------- /tests/test_parser.py: -------------------------------------------------------------------------------- 1 | # Copyright 2014 Globo.com Player authors. All rights reserved. 2 | # Use of this source code is governed by a MIT License 3 | # license that can be found in the LICENSE file. 4 | import re 5 | 6 | import playlists 7 | import pytest 8 | 9 | import m3u8 10 | from m3u8.parser import ( 11 | ParseError, 12 | _parse_simple_parameter_raw_value, 13 | cast_date_time, 14 | get_segment_custom_value, 15 | save_segment_custom_value, 16 | ) 17 | 18 | 19 | def test_should_parse_simple_playlist_from_string(): 20 | data = m3u8.parse(playlists.SIMPLE_PLAYLIST) 21 | assert 5220 == data["targetduration"] 22 | assert 0 == data["media_sequence"] 23 | assert ["http://media.example.com/entire.ts"] == [ 24 | c["uri"] for c in data["segments"] 25 | ] 26 | assert [5220] == [c["duration"] for c in data["segments"]] 27 | 28 | 29 | def test_should_parse_non_integer_duration_from_playlist_string(): 30 | data = m3u8.parse(playlists.PLAYLIST_WITH_NON_INTEGER_DURATION) 31 | assert 5221 == data["targetduration"] 32 | assert [5220.5] == [c["duration"] for c in data["segments"]] 33 | 34 | 35 | def test_should_parse_comma_in_title(): 36 | data = m3u8.parse(playlists.SIMPLE_PLAYLIST_TITLE_COMMA) 37 | assert ["Title with a comma, end"] == [c["title"] for c in data["segments"]] 38 | 39 | 40 | def test_should_parse_simple_playlist_from_string_with_different_linebreaks(): 41 | data = m3u8.parse(playlists.SIMPLE_PLAYLIST.replace("\n", "\r\n")) 42 | assert 5220 == data["targetduration"] 43 | assert ["http://media.example.com/entire.ts"] == [ 44 | c["uri"] for c in data["segments"] 45 | ] 46 | assert [5220] == [c["duration"] for c in data["segments"]] 47 | 48 | 49 | def test_should_parse_sliding_window_playlist_from_string(): 50 | data = m3u8.parse(playlists.SLIDING_WINDOW_PLAYLIST) 51 | assert 8 == data["targetduration"] 52 | assert 2680 == data["media_sequence"] 53 | assert [ 54 | "https://priv.example.com/fileSequence2680.ts", 55 | "https://priv.example.com/fileSequence2681.ts", 56 | "https://priv.example.com/fileSequence2682.ts", 57 | ] == [c["uri"] for c in data["segments"]] 58 | assert [8, 8, 8] == [c["duration"] for c in data["segments"]] 59 | 60 | 61 | def test_should_parse_playlist_with_encrypted_segments_from_string(): 62 | data = m3u8.parse(playlists.PLAYLIST_WITH_ENCRYPTED_SEGMENTS) 63 | assert 7794 == data["media_sequence"] 64 | assert 15 == data["targetduration"] 65 | assert "AES-128" == data["keys"][0]["method"] 66 | assert "https://priv.example.com/key.php?r=52" == data["keys"][0]["uri"] 67 | assert [ 68 | "http://media.example.com/fileSequence52-1.ts", 69 | "http://media.example.com/fileSequence52-2.ts", 70 | "http://media.example.com/fileSequence52-3.ts", 71 | ] == [c["uri"] for c in data["segments"]] 72 | assert [15, 15, 15] == [c["duration"] for c in data["segments"]] 73 | 74 | 75 | def test_should_load_playlist_with_iv_from_string(): 76 | data = m3u8.parse(playlists.PLAYLIST_WITH_ENCRYPTED_SEGMENTS_AND_IV) 77 | assert "/hls-key/key.bin" == data["keys"][0]["uri"] 78 | assert "AES-128" == data["keys"][0]["method"] 79 | assert "0X10ef8f758ca555115584bb5b3c687f52" == data["keys"][0]["iv"] 80 | 81 | 82 | def test_should_add_key_attribute_to_segment_from_playlist(): 83 | data = m3u8.parse( 84 | playlists.PLAYLIST_WITH_ENCRYPTED_SEGMENTS_AND_IV_WITH_MULTIPLE_KEYS 85 | ) 86 | first_segment_key = data["segments"][0]["key"] 87 | assert "/hls-key/key.bin" == first_segment_key["uri"] 88 | assert "AES-128" == first_segment_key["method"] 89 | assert "0X10ef8f758ca555115584bb5b3c687f52" == first_segment_key["iv"] 90 | last_segment_key = data["segments"][-1]["key"] 91 | assert "/hls-key/key2.bin" == last_segment_key["uri"] 92 | assert "AES-128" == last_segment_key["method"] 93 | assert "0Xcafe8f758ca555115584bb5b3c687f52" == last_segment_key["iv"] 94 | 95 | 96 | def test_should_add_non_key_for_multiple_keys_unencrypted_and_encrypted(): 97 | data = m3u8.parse(playlists.PLAYLIST_WITH_MULTIPLE_KEYS_UNENCRYPTED_AND_ENCRYPTED) 98 | # First two segments have no Key, so it's not in the dictionary 99 | assert "key" not in data["segments"][0] 100 | assert "key" not in data["segments"][1] 101 | third_segment_key = data["segments"][2]["key"] 102 | assert "/hls-key/key.bin" == third_segment_key["uri"] 103 | assert "AES-128" == third_segment_key["method"] 104 | assert "0X10ef8f758ca555115584bb5b3c687f52" == third_segment_key["iv"] 105 | last_segment_key = data["segments"][-1]["key"] 106 | assert "/hls-key/key2.bin" == last_segment_key["uri"] 107 | assert "AES-128" == last_segment_key["method"] 108 | assert "0Xcafe8f758ca555115584bb5b3c687f52" == last_segment_key["iv"] 109 | 110 | 111 | def test_should_handle_key_method_none_and_no_uri_attr(): 112 | data = m3u8.parse( 113 | playlists.PLAYLIST_WITH_MULTIPLE_KEYS_UNENCRYPTED_AND_ENCRYPTED_NONE_AND_NO_URI_ATTR 114 | ) 115 | assert "key" not in data["segments"][0] 116 | assert "key" not in data["segments"][1] 117 | third_segment_key = data["segments"][2]["key"] 118 | assert "/hls-key/key.bin" == third_segment_key["uri"] 119 | assert "AES-128" == third_segment_key["method"] 120 | assert "0X10ef8f758ca555115584bb5b3c687f52" == third_segment_key["iv"] 121 | assert "NONE" == data["segments"][6]["key"]["method"] 122 | 123 | 124 | def test_should_parse_playlist_with_session_encrypted_segments_from_string(): 125 | data = m3u8.parse(playlists.PLAYLIST_WITH_SESSION_ENCRYPTED_SEGMENTS) 126 | assert 7794 == data["media_sequence"] 127 | assert 15 == data["targetduration"] 128 | assert "AES-128" == data["session_keys"][0]["method"] 129 | assert "https://priv.example.com/key.php?r=52" == data["session_keys"][0]["uri"] 130 | assert [ 131 | "http://media.example.com/fileSequence52-1.ts", 132 | "http://media.example.com/fileSequence52-2.ts", 133 | "http://media.example.com/fileSequence52-3.ts", 134 | ] == [c["uri"] for c in data["segments"]] 135 | assert [15, 15, 15] == [c["duration"] for c in data["segments"]] 136 | 137 | 138 | def test_should_load_playlist_with_session_iv_from_string(): 139 | data = m3u8.parse(playlists.PLAYLIST_WITH_SESSION_ENCRYPTED_SEGMENTS_AND_IV) 140 | assert "/hls-key/key.bin" == data["session_keys"][0]["uri"] 141 | assert "AES-128" == data["session_keys"][0]["method"] 142 | assert "0X10ef8f758ca555115584bb5b3c687f52" == data["session_keys"][0]["iv"] 143 | 144 | 145 | def test_should_parse_quoted_title_from_playlist(): 146 | data = m3u8.parse(playlists.SIMPLE_PLAYLIST_WITH_QUOTED_TITLE) 147 | assert 1 == len(data["segments"]) 148 | assert 5220 == data["segments"][0]["duration"] 149 | assert '"A sample title"' == data["segments"][0]["title"] 150 | assert "http://media.example.com/entire.ts" == data["segments"][0]["uri"] 151 | 152 | 153 | def test_should_parse_unquoted_title_from_playlist(): 154 | data = m3u8.parse(playlists.SIMPLE_PLAYLIST_WITH_UNQUOTED_TITLE) 155 | assert 1 == len(data["segments"]) 156 | assert 5220 == data["segments"][0]["duration"] 157 | assert "A sample unquoted title" == data["segments"][0]["title"] 158 | assert "http://media.example.com/entire.ts" == data["segments"][0]["uri"] 159 | 160 | 161 | def test_should_parse_variant_playlist(): 162 | data = m3u8.parse(playlists.VARIANT_PLAYLIST) 163 | playlists_list = list(data["playlists"]) 164 | 165 | assert True is data["is_variant"] 166 | assert None is data["media_sequence"] 167 | assert 4 == len(playlists_list) 168 | 169 | assert "http://example.com/low.m3u8" == playlists_list[0]["uri"] 170 | assert 1 == playlists_list[0]["stream_info"]["program_id"] 171 | assert 1280000 == playlists_list[0]["stream_info"]["bandwidth"] 172 | 173 | assert "http://example.com/audio-only.m3u8" == playlists_list[-1]["uri"] 174 | assert 1 == playlists_list[-1]["stream_info"]["program_id"] 175 | assert 65000 == playlists_list[-1]["stream_info"]["bandwidth"] 176 | assert "mp4a.40.5,avc1.42801e" == playlists_list[-1]["stream_info"]["codecs"] 177 | 178 | 179 | def test_should_parse_variant_playlist_with_cc_subtitles_and_audio(): 180 | data = m3u8.parse(playlists.VARIANT_PLAYLIST_WITH_CC_SUBS_AND_AUDIO) 181 | playlists_list = list(data["playlists"]) 182 | 183 | assert True is data["is_variant"] 184 | assert None is data["media_sequence"] 185 | assert 2 == len(playlists_list) 186 | 187 | assert "http://example.com/with-cc-hi.m3u8" == playlists_list[0]["uri"] 188 | assert 1 == playlists_list[0]["stream_info"]["program_id"] 189 | assert 7680000 == playlists_list[0]["stream_info"]["bandwidth"] 190 | assert '"cc"' == playlists_list[0]["stream_info"]["closed_captions"] 191 | assert "sub" == playlists_list[0]["stream_info"]["subtitles"] 192 | assert "aud" == playlists_list[0]["stream_info"]["audio"] 193 | 194 | assert "http://example.com/with-cc-low.m3u8" == playlists_list[-1]["uri"] 195 | assert 1 == playlists_list[-1]["stream_info"]["program_id"] 196 | assert 65000 == playlists_list[-1]["stream_info"]["bandwidth"] 197 | assert '"cc"' == playlists_list[-1]["stream_info"]["closed_captions"] 198 | assert "sub" == playlists_list[-1]["stream_info"]["subtitles"] 199 | assert "aud" == playlists_list[-1]["stream_info"]["audio"] 200 | 201 | 202 | def test_should_parse_variant_playlist_with_none_cc_and_audio(): 203 | data = m3u8.parse(playlists.VARIANT_PLAYLIST_WITH_NONE_CC_AND_AUDIO) 204 | playlists_list = list(data["playlists"]) 205 | 206 | assert "NONE" == playlists_list[0]["stream_info"]["closed_captions"] 207 | assert "NONE" == playlists_list[-1]["stream_info"]["closed_captions"] 208 | 209 | 210 | def test_should_parse_variant_playlist_with_average_bandwidth(): 211 | data = m3u8.parse(playlists.VARIANT_PLAYLIST_WITH_AVERAGE_BANDWIDTH) 212 | playlists_list = list(data["playlists"]) 213 | assert 1280000 == playlists_list[0]["stream_info"]["bandwidth"] 214 | assert 1252345 == playlists_list[0]["stream_info"]["average_bandwidth"] 215 | assert 2560000 == playlists_list[1]["stream_info"]["bandwidth"] 216 | assert 2466570 == playlists_list[1]["stream_info"]["average_bandwidth"] 217 | assert 7680000 == playlists_list[2]["stream_info"]["bandwidth"] 218 | assert 7560423 == playlists_list[2]["stream_info"]["average_bandwidth"] 219 | assert 65000 == playlists_list[3]["stream_info"]["bandwidth"] 220 | assert 63005 == playlists_list[3]["stream_info"]["average_bandwidth"] 221 | 222 | 223 | def test_should_parse_variant_playlist_with_video_range(): 224 | data = m3u8.parse(playlists.VARIANT_PLAYLIST_WITH_VIDEO_RANGE) 225 | playlists_list = list(data["playlists"]) 226 | assert "SDR" == playlists_list[0]["stream_info"]["video_range"] 227 | assert "PQ" == playlists_list[1]["stream_info"]["video_range"] 228 | 229 | 230 | def test_should_parse_variant_playlist_with_hdcp_level(): 231 | data = m3u8.parse(playlists.VARIANT_PLAYLIST_WITH_HDCP_LEVEL) 232 | playlists_list = list(data["playlists"]) 233 | assert "NONE" == playlists_list[0]["stream_info"]["hdcp_level"] 234 | assert "TYPE-0" == playlists_list[1]["stream_info"]["hdcp_level"] 235 | assert "TYPE-1" == playlists_list[2]["stream_info"]["hdcp_level"] 236 | 237 | 238 | # This is actually not according to specification but as for example Twitch.tv 239 | # is producing master playlists that have bandwidth as floats (issue 72) 240 | # this tests that this situation does not break the parser and will just 241 | # truncate to a decimal-integer according to specification 242 | def test_should_parse_variant_playlist_with_bandwidth_as_float(): 243 | data = m3u8.parse(playlists.VARIANT_PLAYLIST_WITH_BANDWIDTH_FLOAT) 244 | playlists_list = list(data["playlists"]) 245 | assert 1280000 == playlists_list[0]["stream_info"]["bandwidth"] 246 | assert 2560000 == playlists_list[1]["stream_info"]["bandwidth"] 247 | assert 7680000 == playlists_list[2]["stream_info"]["bandwidth"] 248 | assert 65000 == playlists_list[3]["stream_info"]["bandwidth"] 249 | 250 | 251 | def test_should_parse_variant_playlist_with_iframe_playlists(): 252 | data = m3u8.parse(playlists.VARIANT_PLAYLIST_WITH_IFRAME_PLAYLISTS) 253 | iframe_playlists = list(data["iframe_playlists"]) 254 | 255 | assert True is data["is_variant"] 256 | 257 | assert 4 == len(iframe_playlists) 258 | 259 | assert 1 == iframe_playlists[0]["iframe_stream_info"]["program_id"] 260 | assert 151288 == iframe_playlists[0]["iframe_stream_info"]["bandwidth"] 261 | assert "624x352" == iframe_playlists[0]["iframe_stream_info"]["resolution"] 262 | assert "avc1.4d001f" == iframe_playlists[0]["iframe_stream_info"]["codecs"] 263 | assert "video-800k-iframes.m3u8" == iframe_playlists[0]["uri"] 264 | 265 | assert 38775 == iframe_playlists[-1]["iframe_stream_info"]["bandwidth"] 266 | assert "avc1.4d001f" == (iframe_playlists[-1]["iframe_stream_info"]["codecs"]) 267 | assert "video-150k-iframes.m3u8" == iframe_playlists[-1]["uri"] 268 | 269 | 270 | def test_should_parse_variant_playlist_with_alt_iframe_playlists_layout(): 271 | data = m3u8.parse(playlists.VARIANT_PLAYLIST_WITH_ALT_IFRAME_PLAYLISTS_LAYOUT) 272 | iframe_playlists = list(data["iframe_playlists"]) 273 | 274 | assert True is data["is_variant"] 275 | 276 | assert 4 == len(iframe_playlists) 277 | 278 | assert 1 == iframe_playlists[0]["iframe_stream_info"]["program_id"] 279 | assert 151288 == iframe_playlists[0]["iframe_stream_info"]["bandwidth"] 280 | assert "624x352" == iframe_playlists[0]["iframe_stream_info"]["resolution"] 281 | assert "avc1.4d001f" == iframe_playlists[0]["iframe_stream_info"]["codecs"] 282 | assert "video-800k-iframes.m3u8" == iframe_playlists[0]["uri"] 283 | 284 | assert 38775 == iframe_playlists[-1]["iframe_stream_info"]["bandwidth"] 285 | assert "avc1.4d001f" == (iframe_playlists[-1]["iframe_stream_info"]["codecs"]) 286 | assert "video-150k-iframes.m3u8" == iframe_playlists[-1]["uri"] 287 | 288 | 289 | def test_should_parse_iframe_playlist(): 290 | data = m3u8.parse(playlists.IFRAME_PLAYLIST) 291 | 292 | assert True is data["is_i_frames_only"] 293 | assert 4.12 == data["segments"][0]["duration"] 294 | assert "9400@376" == data["segments"][0]["byterange"] 295 | assert "segment1.ts" == data["segments"][0]["uri"] 296 | 297 | 298 | def test_should_parse_variant_playlist_with_image_playlists(): 299 | data = m3u8.parse(playlists.VARIANT_PLAYLIST_WITH_IMAGE_PLAYLISTS) 300 | image_playlists = list(data["image_playlists"]) 301 | 302 | assert True is data["is_variant"] 303 | assert 2 == len(image_playlists) 304 | assert "320x180" == image_playlists[0]["image_stream_info"]["resolution"] 305 | assert "jpeg" == image_playlists[0]["image_stream_info"]["codecs"] 306 | assert "5x2_320x180/320x180-5x2.m3u8" == image_playlists[0]["uri"] 307 | assert "640x360" == image_playlists[1]["image_stream_info"]["resolution"] 308 | assert "jpeg" == image_playlists[1]["image_stream_info"]["codecs"] 309 | assert "5x2_640x360/640x360-5x2.m3u8" == image_playlists[1]["uri"] 310 | 311 | 312 | def test_should_parse_vod_image_playlist(): 313 | data = m3u8.parse(playlists.VOD_IMAGE_PLAYLIST) 314 | 315 | assert True is data["is_images_only"] 316 | assert 8 == len(data["tiles"]) 317 | assert "preroll-ad-1.jpg" == data["segments"][0]["uri"] 318 | assert "640x360" == data["tiles"][0]["resolution"] 319 | assert "5x2" == data["tiles"][0]["layout"] 320 | assert 6.006 == data["tiles"][0]["duration"] 321 | assert "byterange" not in data["tiles"][0] 322 | 323 | 324 | def test_should_parse_vod_image_playlist2(): 325 | data = m3u8.parse(playlists.VOD_IMAGE_PLAYLIST2) 326 | 327 | assert True is data["is_images_only"] 328 | assert "640x360" == data["tiles"][0]["resolution"] 329 | assert "4x3" == data["tiles"][0]["layout"] 330 | assert 2.002 == data["tiles"][0]["duration"] 331 | assert 6 == len(data["tiles"]) 332 | assert "promo_1.jpg" == data["segments"][0]["uri"] 333 | 334 | 335 | def test_should_parse_live_image_playlist(): 336 | data = m3u8.parse(playlists.LIVE_IMAGE_PLAYLIST) 337 | 338 | assert True is data["is_images_only"] 339 | assert 10 == len(data["segments"]) 340 | assert "content-123.jpg" == data["segments"][0]["uri"] 341 | assert "content-124.jpg" == data["segments"][1]["uri"] 342 | assert "content-125.jpg" == data["segments"][2]["uri"] 343 | assert "missing-midroll.jpg" == data["segments"][3]["uri"] 344 | assert "missing-midroll.jpg" == data["segments"][4]["uri"] 345 | assert "missing-midroll.jpg" == data["segments"][5]["uri"] 346 | assert "content-128.jpg" == data["segments"][6]["uri"] 347 | assert "content-129.jpg" == data["segments"][7]["uri"] 348 | assert "content-130.jpg" == data["segments"][8]["uri"] 349 | assert "content-131.jpg" == data["segments"][9]["uri"] 350 | 351 | 352 | def test_should_parse_playlist_using_byteranges(): 353 | data = m3u8.parse(playlists.PLAYLIST_USING_BYTERANGES) 354 | 355 | assert False is data["is_i_frames_only"] 356 | assert 10 == data["segments"][0]["duration"] 357 | assert "76242@0" == data["segments"][0]["byterange"] 358 | assert "segment.ts" == data["segments"][0]["uri"] 359 | 360 | 361 | def test_should_parse_endlist_playlist(): 362 | data = m3u8.parse(playlists.SIMPLE_PLAYLIST) 363 | assert True is data["is_endlist"] 364 | 365 | data = m3u8.parse(playlists.SLIDING_WINDOW_PLAYLIST) 366 | assert False is data["is_endlist"] 367 | 368 | 369 | def test_should_parse_ALLOW_CACHE(): 370 | data = m3u8.parse(playlists.PLAYLIST_WITH_ENCRYPTED_SEGMENTS_AND_IV) 371 | assert "no" == data["allow_cache"] 372 | 373 | 374 | def test_should_parse_VERSION(): 375 | data = m3u8.parse(playlists.PLAYLIST_WITH_ENCRYPTED_SEGMENTS_AND_IV) 376 | assert 2 == data["version"] 377 | 378 | 379 | def test_should_parse_program_date_time_from_playlist(): 380 | data = m3u8.parse(playlists.SIMPLE_PLAYLIST_WITH_PROGRAM_DATE_TIME) 381 | assert cast_date_time("2014-08-13T13:36:33+00:00") == data["program_date_time"] 382 | 383 | 384 | def test_should_parse_scte35_from_playlist(): 385 | data = m3u8.parse(playlists.CUE_OUT_ELEMENTAL_PLAYLIST) 386 | 387 | # cue_out should be maintained from [EXT-X-CUE-OUT, EXT-X-CUE-IN) 388 | actual_cue_status = [s["cue_out"] for s in data["segments"]] 389 | expected_cue_status = [ 390 | False, 391 | False, 392 | False, 393 | True, # EXT-X-CUE-OUT 394 | True, 395 | True, 396 | True, 397 | True, 398 | True, 399 | False, # EXT-X-CUE-IN 400 | False, 401 | ] 402 | assert actual_cue_status == expected_cue_status 403 | 404 | # scte35 should be maintained from [EXT-X-CUE-OUT, EXT-X-CUE-IN] 405 | cue = "/DAlAAAAAAAAAP/wFAUAAAABf+//wpiQkv4ARKogAAEBAQAAQ6sodg==" 406 | actual_scte35 = [s["scte35"] for s in data["segments"]] 407 | expected_scte35 = [None, None, None, cue, cue, cue, cue, cue, cue, cue, None] 408 | assert actual_scte35 == expected_scte35 409 | 410 | # oatcls_scte35 should be maintained from [EXT-X-CUE-OUT, EXT-X-CUE-IN] 411 | actual_oatcls_scte35 = [s["oatcls_scte35"] for s in data["segments"]] 412 | expected_oatcls_scte35 = [None, None, None, cue, cue, cue, cue, cue, cue, cue, None] 413 | assert actual_oatcls_scte35 == expected_oatcls_scte35 414 | 415 | # durations should be maintained from from [EXT-X-CUE-OUT, EXT-X-CUE-IN] 416 | actual_scte35_duration = [s["scte35_duration"] for s in data["segments"]] 417 | expected_scte35_duration = [ 418 | None, 419 | None, 420 | None, 421 | "50.000", 422 | "50", 423 | "50", 424 | "50", 425 | "50", 426 | "50", 427 | "50", 428 | None, 429 | ] 430 | assert actual_scte35_duration == expected_scte35_duration 431 | 432 | 433 | def test_should_parse_envivio_cue_playlist(): 434 | data = m3u8.parse(playlists.CUE_OUT_ENVIVIO_PLAYLIST) 435 | assert data["segments"][3]["scte35"] 436 | assert data["segments"][3]["cue_out"] 437 | assert ( 438 | "/DAlAAAENOOQAP/wFAUBAABrf+//N25XDf4B9p/gAAEBAQAAxKni9A==" 439 | == data["segments"][3]["scte35"] 440 | ) 441 | assert "366" == data["segments"][3]["scte35_duration"] 442 | assert data["segments"][4]["cue_out"] 443 | assert ( 444 | "/DAlAAAENOOQAP/wFAUBAABrf+//N25XDf4B9p/gAAEBAQAAxKni9A==" 445 | == data["segments"][4]["scte35"] 446 | ) 447 | assert ( 448 | "/DAlAAAENOOQAP/wFAUBAABrf+//N25XDf4B9p/gAAEBAQAAxKni9A==" 449 | == data["segments"][5]["scte35"] 450 | ) 451 | 452 | 453 | def test_should_parse_no_duration_cue_playlist(): 454 | data = m3u8.parse(playlists.CUE_OUT_NO_DURATION_PLAYLIST) 455 | assert data["segments"][0]["cue_out_start"] 456 | assert data["segments"][2]["cue_in"] 457 | 458 | 459 | def test_parse_simple_playlist_messy(): 460 | data = m3u8.parse(playlists.SIMPLE_PLAYLIST_MESSY) 461 | assert 5220 == data["targetduration"] 462 | assert 0 == data["media_sequence"] 463 | assert ["http://media.example.com/entire.ts"] == [ 464 | c["uri"] for c in data["segments"] 465 | ] 466 | assert [5220] == [c["duration"] for c in data["segments"]] 467 | 468 | 469 | def test_parse_simple_playlist_messy_strict(): 470 | with pytest.raises(ParseError) as catch: 471 | m3u8.parse(playlists.SIMPLE_PLAYLIST_MESSY, strict=True) 472 | assert str(catch.value) == "Syntax error in manifest on line 5: JUNK" 473 | 474 | 475 | def test_commaless_extinf(): 476 | data = m3u8.parse(playlists.SIMPLE_PLAYLIST_COMMALESS_EXTINF) 477 | assert 5220 == data["targetduration"] 478 | assert 0 == data["media_sequence"] 479 | assert ["http://media.example.com/entire.ts"] == [ 480 | c["uri"] for c in data["segments"] 481 | ] 482 | assert [5220] == [c["duration"] for c in data["segments"]] 483 | 484 | 485 | def test_commaless_extinf_strict(): 486 | with pytest.raises(ParseError) as e: 487 | m3u8.parse(playlists.SIMPLE_PLAYLIST_COMMALESS_EXTINF, strict=True) 488 | assert str(e.value) == "Syntax error in manifest on line 3: #EXTINF:5220" 489 | 490 | 491 | def test_should_parse_segment_map_uri(): 492 | data = m3u8.parse(playlists.MAP_URI_PLAYLIST) 493 | assert data["segment_map"][0]["uri"] == "fileSequence0.mp4" 494 | 495 | 496 | def test_should_parse_segment_map_uri_with_byterange(): 497 | data = m3u8.parse(playlists.MAP_URI_PLAYLIST_WITH_BYTERANGE) 498 | assert data["segment_map"][0]["uri"] == "main.mp4" 499 | 500 | 501 | def test_should_parse_multiple_map_attributes(): 502 | data = m3u8.parse(playlists.MULTIPLE_MAP_URI_PLAYLIST) 503 | 504 | assert data["segments"][0]["init_section"]["uri"] == "init1.mp4" 505 | assert data["segments"][1]["init_section"]["uri"] == "init1.mp4" 506 | assert data["segments"][2]["init_section"]["uri"] == "init3.mp4" 507 | 508 | 509 | def test_should_parse_empty_uri_with_base_path(): 510 | data = m3u8.M3U8( 511 | playlists.MEDIA_WITHOUT_URI_PLAYLIST, base_path="base_path", base_uri="base_uri" 512 | ) 513 | media = data.media[0] 514 | assert media.uri is None 515 | assert media.base_path is None 516 | assert "base_uri/" == media.base_uri 517 | 518 | 519 | def test_should_parse_audio_channels(): 520 | data = m3u8.M3U8( 521 | playlists.MEDIA_WITHOUT_URI_PLAYLIST, base_path="base_path", base_uri="base_uri" 522 | ) 523 | media = data.media[0] 524 | assert media.channels == "2" 525 | 526 | 527 | def test_should_parse_start_with_negative_time_offset(): 528 | data = m3u8.parse(playlists.SIMPLE_PLAYLIST_WITH_START_NEGATIVE_OFFSET) 529 | assert data["start"]["time_offset"] == -2.0 530 | assert not hasattr(data["start"], "precise") 531 | 532 | 533 | def test_should_parse_start_with_precise(): 534 | data = m3u8.parse(playlists.SIMPLE_PLAYLIST_WITH_START_PRECISE) 535 | assert data["start"]["time_offset"] == 10.5 536 | assert data["start"]["precise"] == "YES" 537 | 538 | 539 | def test_should_parse_session_data(): 540 | data = m3u8.parse(playlists.SESSION_DATA_PLAYLIST) 541 | assert data["session_data"][0]["data_id"] == "com.example.value" 542 | assert data["session_data"][0]["value"] == "example" 543 | assert data["session_data"][0]["language"] == "en" 544 | 545 | 546 | def test_should_parse_multiple_session_data(): 547 | data = m3u8.parse(playlists.MULTIPLE_SESSION_DATA_PLAYLIST) 548 | 549 | assert len(data["session_data"]) == 4 550 | 551 | assert data["session_data"][0]["data_id"] == "com.example.value" 552 | assert data["session_data"][0]["value"] == "example" 553 | assert data["session_data"][0]["language"] == "en" 554 | 555 | assert data["session_data"][1]["data_id"] == "com.example.value" 556 | assert data["session_data"][1]["value"] == "example" 557 | assert data["session_data"][1]["language"] == "ru" 558 | 559 | assert data["session_data"][2]["data_id"] == "com.example.value" 560 | assert data["session_data"][2]["value"] == "example" 561 | assert data["session_data"][2]["language"] == "de" 562 | 563 | assert data["session_data"][3]["data_id"] == "com.example.title" 564 | assert data["session_data"][3]["uri"] == "title.json" 565 | 566 | 567 | def test_simple_playlist_with_discontinuity_sequence(): 568 | data = m3u8.parse(playlists.SIMPLE_PLAYLIST_WITH_DISCONTINUITY_SEQUENCE) 569 | assert data["discontinuity_sequence"] == 123 570 | 571 | 572 | def test_simple_playlist_with_custom_tags(): 573 | def get_movie(line, lineno, data, segment): 574 | if line.startswith("#EXT-X-MOVIE"): 575 | custom_tag = line.split(":") 576 | if len(custom_tag) == 2: 577 | data["movie"] = custom_tag[1].strip() 578 | return True 579 | 580 | data = m3u8.parse( 581 | playlists.SIMPLE_PLAYLIST_WITH_CUSTOM_TAGS, 582 | strict=False, 583 | custom_tags_parser=get_movie, 584 | ) 585 | assert data["movie"] == "million dollar baby" 586 | assert 5220 == data["targetduration"] 587 | assert 0 == data["media_sequence"] 588 | assert ["http://media.example.com/entire.ts"] == [ 589 | c["uri"] for c in data["segments"] 590 | ] 591 | assert [5220] == [c["duration"] for c in data["segments"]] 592 | 593 | 594 | def test_iptv_playlist_with_custom_tags(): 595 | def parse_iptv_attributes(line, lineno, data, state): 596 | # Customize parsing #EXTINF 597 | if line.startswith("#EXTINF"): 598 | chunks = line.replace("#EXTINF" + ":", "").split(",", 1) 599 | if len(chunks) == 2: 600 | duration_and_props, title = chunks 601 | elif len(chunks) == 1: 602 | duration_and_props = chunks[0] 603 | title = "" 604 | 605 | additional_props = {} 606 | chunks = duration_and_props.strip().split(" ", 1) 607 | if len(chunks) == 2: 608 | duration, raw_props = chunks 609 | matched_props = re.finditer(r'([\w\-]+)="([^"]*)"', raw_props) 610 | for match in matched_props: 611 | additional_props[match.group(1)] = match.group(2) 612 | else: 613 | duration = duration_and_props 614 | 615 | if "segment" not in state: 616 | state["segment"] = {} 617 | state["segment"]["duration"] = float(duration) 618 | state["segment"]["title"] = title 619 | 620 | save_segment_custom_value(state, "extinf_props", additional_props) 621 | 622 | state["expect_segment"] = True 623 | return True 624 | 625 | # Parse #EXTGRP 626 | if line.startswith("#EXTGRP"): 627 | _, value = _parse_simple_parameter_raw_value(line, str) 628 | save_segment_custom_value(state, "extgrp", value) 629 | state["expect_segment"] = True 630 | return True 631 | 632 | # Parse #EXTVLCOPT 633 | if line.startswith("#EXTVLCOPT"): 634 | _, value = _parse_simple_parameter_raw_value(line, str) 635 | 636 | existing_opts = get_segment_custom_value(state, "vlcopt", []) 637 | existing_opts.append(value) 638 | save_segment_custom_value(state, "vlcopt", existing_opts) 639 | 640 | state["expect_segment"] = True 641 | return True 642 | 643 | data = m3u8.parse( 644 | playlists.IPTV_PLAYLIST_WITH_CUSTOM_TAGS, 645 | strict=False, 646 | custom_tags_parser=parse_iptv_attributes, 647 | ) 648 | 649 | assert ["Channel1"] == [c["title"] for c in data["segments"]] 650 | assert ( 651 | data["segments"][0]["uri"] 652 | == "http://str00.iptv.domain/7331/mpegts?token=longtokenhere" 653 | ) 654 | assert ( 655 | data["segments"][0]["custom_parser_values"]["extinf_props"]["tvg-id"] 656 | == "channel1" 657 | ) 658 | assert ( 659 | data["segments"][0]["custom_parser_values"]["extinf_props"]["group-title"] 660 | == "Group1" 661 | ) 662 | assert ( 663 | data["segments"][0]["custom_parser_values"]["extinf_props"]["catchup-days"] 664 | == "7" 665 | ) 666 | assert ( 667 | data["segments"][0]["custom_parser_values"]["extinf_props"]["catchup-type"] 668 | == "flussonic" 669 | ) 670 | assert data["segments"][0]["custom_parser_values"]["extgrp"] == "ExtGroup1" 671 | assert data["segments"][0]["custom_parser_values"]["vlcopt"] == [ 672 | "video-filter=invert", 673 | "param2=value2", 674 | ] 675 | 676 | 677 | def test_tag_after_extinf(): 678 | parsed_playlist = m3u8.loads(playlists.IPTV_PLAYLIST_WITH_EARLY_EXTINF) 679 | actual = parsed_playlist.segments[0].uri 680 | expected = "http://str00.iptv.domain/7331/mpegts?token=longtokenhere" 681 | assert actual == expected 682 | 683 | 684 | def test_master_playlist_with_frame_rate(): 685 | data = m3u8.parse(playlists.VARIANT_PLAYLIST_WITH_FRAME_RATE) 686 | playlists_list = list(data["playlists"]) 687 | assert 25 == playlists_list[0]["stream_info"]["frame_rate"] 688 | assert 50 == playlists_list[1]["stream_info"]["frame_rate"] 689 | assert 60 == playlists_list[2]["stream_info"]["frame_rate"] 690 | assert 12.5 == playlists_list[3]["stream_info"]["frame_rate"] 691 | 692 | 693 | def test_master_playlist_with_unrounded_frame_rate(): 694 | data = m3u8.parse(playlists.VARIANT_PLAYLIST_WITH_ROUNDABLE_FRAME_RATE) 695 | playlists_list = list(data["playlists"]) 696 | assert 12.54321 == playlists_list[0]["stream_info"]["frame_rate"] 697 | 698 | 699 | def test_low_latency_playlist(): 700 | data = m3u8.parse(playlists.LOW_LATENCY_DELTA_UPDATE_PLAYLIST) 701 | assert data["server_control"]["can_block_reload"] == "YES" 702 | assert data["server_control"]["can_skip_until"] == 12.0 703 | assert data["server_control"]["part_hold_back"] == 1.0 704 | assert data["part_inf"]["part_target"] == 0.33334 705 | assert data["skip"]["skipped_segments"] == 3 706 | assert len(data["segments"][2]["parts"]) == 12 707 | assert data["segments"][2]["parts"][0]["duration"] == 0.33334 708 | assert data["segments"][2]["parts"][0]["uri"] == "filePart271.0.ts" 709 | assert len(data["rendition_reports"]) == 2 710 | assert data["rendition_reports"][0]["uri"] == "../1M/waitForMSN.php" 711 | assert data["rendition_reports"][0]["last_msn"] == 273 712 | assert data["rendition_reports"][0]["last_part"] == 3 713 | 714 | 715 | def test_low_latency_with_preload_and_byteranges_playlist(): 716 | data = m3u8.parse(playlists.LOW_LATENCY_WITH_PRELOAD_AND_BYTERANGES_PLAYLIST) 717 | assert data["segments"][1]["parts"][2]["byterange"] == "18000@43000" 718 | assert data["preload_hint"]["type"] == "PART" 719 | assert data["preload_hint"]["uri"] == "fs271.mp4" 720 | assert data["preload_hint"]["byterange_start"] == 61000 721 | assert data["preload_hint"]["byterange_length"] == 20000 722 | 723 | 724 | def test_negative_media_sequence(): 725 | data = m3u8.parse(playlists.PLAYLIST_WITH_NEGATIVE_MEDIA_SEQUENCE) 726 | assert data["media_sequence"] == -2680 727 | 728 | 729 | def test_daterange_simple(): 730 | data = m3u8.parse(playlists.DATERANGE_SIMPLE_PLAYLIST) 731 | 732 | assert data["segments"][0]["dateranges"][0]["id"] == "ad3" 733 | assert data["segments"][0]["dateranges"][0]["start_date"] == "2016-06-13T11:15:00Z" 734 | assert data["segments"][0]["dateranges"][0]["duration"] == 20 735 | assert data["segments"][0]["dateranges"][0]["x_ad_id"] == '"1234"' 736 | assert ( 737 | data["segments"][0]["dateranges"][0]["x_ad_url"] 738 | == '"http://ads.example.com/beacon3"' 739 | ) 740 | 741 | 742 | def test_date_range_with_scte_out_and_in(): 743 | data = m3u8.parse(playlists.DATERANGE_SCTE35_OUT_AND_IN_PLAYLIST) 744 | 745 | assert data["segments"][0]["dateranges"][0]["id"] == "splice-6FFFFFF0" 746 | assert data["segments"][0]["dateranges"][0]["planned_duration"] == 59.993 747 | assert ( 748 | data["segments"][0]["dateranges"][0]["scte35_out"] 749 | == "0xFC002F0000000000FF000014056FFFFFF000E011622DCAFF000052636200000000000A0008029896F50000008700000000" 750 | ) 751 | 752 | assert data["segments"][6]["dateranges"][0]["id"] == "splice-6FFFFFF0" 753 | assert data["segments"][6]["dateranges"][0]["duration"] == 59.993 754 | assert ( 755 | data["segments"][6]["dateranges"][0]["scte35_in"] 756 | == "0xFC002A0000000000FF00000F056FFFFFF000401162802E6100000000000A0008029896F50000008700000000" 757 | ) 758 | 759 | 760 | def test_date_range_in_parts(): 761 | data = m3u8.parse(playlists.DATERANGE_IN_PART_PLAYLIST) 762 | 763 | assert data["segments"][0]["parts"][2]["dateranges"][0]["id"] == "test_id" 764 | assert ( 765 | data["segments"][0]["parts"][2]["dateranges"][0]["start_date"] 766 | == "2020-03-10T07:48:02Z" 767 | ) 768 | assert data["segments"][0]["parts"][2]["dateranges"][0]["class"] == "test_class" 769 | assert data["segments"][0]["parts"][2]["dateranges"][0]["end_on_next"] == "YES" 770 | 771 | 772 | def test_gap(): 773 | data = m3u8.parse(playlists.GAP_PLAYLIST) 774 | 775 | assert data["segments"][0]["gap_tag"] is None 776 | assert data["segments"][1]["gap_tag"] is True 777 | assert data["segments"][2]["gap_tag"] is True 778 | assert data["segments"][3]["gap_tag"] is None 779 | 780 | 781 | def test_gap_in_parts(): 782 | data = m3u8.parse(playlists.GAP_IN_PARTS_PLAYLIST) 783 | 784 | assert data["segments"][0]["parts"][0]["gap_tag"] is None 785 | assert data["segments"][0]["parts"][0].get("gap", None) is None 786 | assert data["segments"][0]["parts"][1]["gap_tag"] is None 787 | assert data["segments"][0]["parts"][1]["gap"] == "YES" 788 | assert data["segments"][0]["parts"][2]["gap_tag"] is True 789 | assert data["segments"][0]["parts"][2].get("gap", None) is None 790 | 791 | 792 | def test_should_parse_variant_playlist_with_iframe_with_average_bandwidth(): 793 | data = m3u8.parse(playlists.VARIANT_PLAYLIST_WITH_IFRAME_AVERAGE_BANDWIDTH) 794 | iframe_playlists = list(data["iframe_playlists"]) 795 | 796 | assert True is data["is_variant"] 797 | 798 | assert 4 == len(iframe_playlists) 799 | 800 | assert 151288 == iframe_playlists[0]["iframe_stream_info"]["bandwidth"] 801 | # Check for absence of average_bandwidth if not given in the playlist 802 | assert "average_bandwidth" not in iframe_playlists[0]["iframe_stream_info"] 803 | assert "624x352" == iframe_playlists[0]["iframe_stream_info"]["resolution"] 804 | assert "avc1.4d001f" == iframe_playlists[0]["iframe_stream_info"]["codecs"] 805 | assert "video-800k-iframes.m3u8" == iframe_playlists[0]["uri"] 806 | 807 | assert 38775 == iframe_playlists[-1]["iframe_stream_info"]["bandwidth"] 808 | assert "avc1.4d001f" == (iframe_playlists[-1]["iframe_stream_info"]["codecs"]) 809 | assert "video-150k-iframes.m3u8" == iframe_playlists[-1]["uri"] 810 | assert 155000 == iframe_playlists[1]["iframe_stream_info"]["average_bandwidth"] 811 | assert 65000 == iframe_playlists[2]["iframe_stream_info"]["average_bandwidth"] 812 | assert 30000 == iframe_playlists[3]["iframe_stream_info"]["average_bandwidth"] 813 | 814 | 815 | def test_should_parse_variant_playlist_with_iframe_with_video_range(): 816 | data = m3u8.parse(playlists.VARIANT_PLAYLIST_WITH_IFRAME_VIDEO_RANGE) 817 | iframe_playlists = list(data["iframe_playlists"]) 818 | 819 | assert True is data["is_variant"] 820 | 821 | assert 4 == len(iframe_playlists) 822 | 823 | assert "http://example.com/sdr-iframes.m3u8" == iframe_playlists[0]["uri"] 824 | assert "SDR" == iframe_playlists[0]["iframe_stream_info"]["video_range"] 825 | assert "http://example.com/hdr-pq-iframes.m3u8" == iframe_playlists[1]["uri"] 826 | assert "PQ" == iframe_playlists[1]["iframe_stream_info"]["video_range"] 827 | assert "http://example.com/hdr-hlg-iframes.m3u8" == iframe_playlists[2]["uri"] 828 | assert "HLG" == iframe_playlists[2]["iframe_stream_info"]["video_range"] 829 | assert "http://example.com/unknown-iframes.m3u8" == iframe_playlists[3]["uri"] 830 | assert "video_range" not in iframe_playlists[3]["iframe_stream_info"] 831 | 832 | 833 | def test_should_parse_variant_playlist_with_iframe_with_hdcp_level(): 834 | data = m3u8.parse(playlists.VARIANT_PLAYLIST_WITH_IFRAME_HDCP_LEVEL) 835 | iframe_playlists = list(data["iframe_playlists"]) 836 | 837 | assert True is data["is_variant"] 838 | 839 | assert 4 == len(iframe_playlists) 840 | 841 | assert "http://example.com/none-iframes.m3u8" == iframe_playlists[0]["uri"] 842 | assert "NONE" == iframe_playlists[0]["iframe_stream_info"]["hdcp_level"] 843 | assert "http://example.com/type0-iframes.m3u8" == iframe_playlists[1]["uri"] 844 | assert "TYPE-0" == iframe_playlists[1]["iframe_stream_info"]["hdcp_level"] 845 | assert "http://example.com/type1-iframes.m3u8" == iframe_playlists[2]["uri"] 846 | assert "TYPE-1" == iframe_playlists[2]["iframe_stream_info"]["hdcp_level"] 847 | assert "http://example.com/unknown-iframes.m3u8" == iframe_playlists[3]["uri"] 848 | assert "hdcp_level" not in iframe_playlists[3]["iframe_stream_info"] 849 | 850 | 851 | def test_delta_playlist_daterange_skipping(): 852 | data = m3u8.parse(playlists.DELTA_UPDATE_SKIP_DATERANGES_PLAYLIST) 853 | assert data["skip"]["recently_removed_dateranges"] == "1" 854 | assert data["server_control"]["can_skip_dateranges"] == "YES" 855 | 856 | 857 | def test_bitrate(): 858 | data = m3u8.parse(playlists.BITRATE_PLAYLIST) 859 | assert data["segments"][0]["bitrate"] == 1674 860 | assert data["segments"][1]["bitrate"] == 1625 861 | 862 | 863 | def test_content_steering(): 864 | data = m3u8.parse(playlists.CONTENT_STEERING_PLAYLIST) 865 | assert data["content_steering"]["server_uri"] == "/steering?video=00012" 866 | assert data["content_steering"]["pathway_id"] == "CDN-A" 867 | assert data["playlists"][0]["stream_info"]["pathway_id"] == "CDN-A" 868 | assert data["playlists"][1]["stream_info"]["pathway_id"] == "CDN-A" 869 | assert data["playlists"][2]["stream_info"]["pathway_id"] == "CDN-B" 870 | assert data["playlists"][3]["stream_info"]["pathway_id"] == "CDN-B" 871 | 872 | 873 | def test_cue_in_pops_scte35_data_and_duration(): 874 | data = m3u8.parse(playlists.CUE_OUT_ELEMENTAL_PLAYLIST) 875 | assert data["segments"][9]["cue_in"] is True 876 | assert ( 877 | data["segments"][9]["scte35"] 878 | == "/DAlAAAAAAAAAP/wFAUAAAABf+//wpiQkv4ARKogAAEBAQAAQ6sodg==" 879 | ) 880 | assert data["segments"][9]["scte35_duration"] == "50" 881 | assert data["segments"][10]["cue_in"] is False 882 | assert data["segments"][10]["scte35"] is None 883 | assert data["segments"][10]["scte35_duration"] is None 884 | 885 | 886 | def test_playlist_with_stable_variant_id(): 887 | data = m3u8.parse(playlists.VARIANT_PLAYLIST_WITH_STABLE_VARIANT_ID) 888 | assert ( 889 | data["playlists"][0]["stream_info"]["stable_variant_id"] 890 | == "eb9c6e4de930b36d9a67fbd38a30b39f865d98f4a203d2140bbf71fd58ad764e" 891 | ) 892 | 893 | 894 | def test_iframe_with_stable_variant_id(): 895 | data = m3u8.parse(playlists.VARIANT_PLAYLIST_WITH_IFRAME_STABLE_VARIANT_ID) 896 | assert ( 897 | data["iframe_playlists"][0]["iframe_stream_info"]["stable_variant_id"] 898 | == "415901312adff69b967a0644a54f8d00dc14004f36bc8293737e6b4251f60f3f" 899 | ) 900 | 901 | 902 | def test_media_with_stable_rendition_id(): 903 | data = m3u8.parse(playlists.VARIANT_PLAYLIST_WITH_STABLE_RENDITION_ID) 904 | assert ( 905 | data["media"][0]["stable_rendition_id"] 906 | == "a8213e27c12a158ea8660e0fe8bdcac6072ca26d984e7e8603652bc61fdceffa" 907 | ) 908 | 909 | 910 | def test_req_video_layout(): 911 | data = m3u8.parse(playlists.VARIANT_PLAYLIST_WITH_REQ_VIDEO_LAYOUT) 912 | assert data["playlists"][0]["stream_info"]["req_video_layout"] == '"CH-STEREO"' 913 | -------------------------------------------------------------------------------- /tests/playlists.py: -------------------------------------------------------------------------------- 1 | # Copyright 2014 Globo.com Player authors. All rights reserved. 2 | # Use of this source code is governed by a MIT License 3 | # license that can be found in the LICENSE file. 4 | 5 | from os.path import abspath, dirname, join 6 | 7 | TEST_HOST = "http://localhost:8112" 8 | 9 | SIMPLE_PLAYLIST = """ 10 | #EXTM3U 11 | #EXT-X-TARGETDURATION:5220 12 | #EXTINF:5220, 13 | http://media.example.com/entire.ts 14 | #EXT-X-ENDLIST 15 | """ 16 | 17 | SIMPLE_PLAYLIST_WITH_ZERO_DURATION = """ 18 | #EXTM3U 19 | #EXT-X-TARGETDURATION:5220 20 | #EXTINF:0, 21 | http://media.example.com/entire1.ts 22 | #EXTINF:5220, 23 | http://media.example.com/entire2.ts 24 | #EXT-X-ENDLIST 25 | """ 26 | 27 | SIMPLE_PLAYLIST_WITH_VERY_SHORT_DURATION = """ 28 | #EXTM3U 29 | #EXT-X-TARGETDURATION:5220 30 | #EXTINF:5220, 31 | http://media.example.com/entire1.ts 32 | #EXTINF:5218.5, 33 | http://media.example.com/entire2.ts 34 | #EXTINF:0.000011, 35 | http://media.example.com/entire3.ts 36 | #EXT-X-ENDLIST 37 | """ 38 | 39 | SIMPLE_PLAYLIST_WITH_START_NEGATIVE_OFFSET = """ 40 | #EXTM3U 41 | #EXT-X-TARGETDURATION:5220 42 | #EXT-X-START:TIME-OFFSET=-2.0 43 | #EXTINF:5220, 44 | http://media.example.com/entire.ts 45 | #EXT-X-ENDLIST 46 | """ 47 | 48 | SIMPLE_PLAYLIST_WITH_START_PRECISE = """ 49 | #EXTM3U 50 | #EXT-X-TARGETDURATION:5220 51 | #EXT-X-START:TIME-OFFSET=10.5,PRECISE=YES 52 | #EXTINF:5220, 53 | http://media.example.com/entire.ts 54 | #EXT-X-ENDLIST 55 | """ 56 | 57 | SIMPLE_PLAYLIST_FILENAME = abspath( 58 | join(dirname(__file__), "playlists/simple-playlist.m3u8") 59 | ) 60 | 61 | SIMPLE_PLAYLIST_URI = TEST_HOST + "/simple.m3u8" 62 | TIMEOUT_SIMPLE_PLAYLIST_URI = TEST_HOST + "/timeout_simple.m3u8" 63 | REDIRECT_PLAYLIST_URI = TEST_HOST + "/path/to/redirect_me" 64 | 65 | 66 | PLAYLIST_WITH_NON_INTEGER_DURATION = """ 67 | #EXTM3U 68 | #EXT-X-TARGETDURATION:5221 69 | #EXTINF:5220.5, 70 | http://media.example.com/entire.ts 71 | """ 72 | 73 | SLIDING_WINDOW_PLAYLIST = """ 74 | #EXTM3U 75 | #EXT-X-TARGETDURATION:8 76 | #EXT-X-MEDIA-SEQUENCE:2680 77 | 78 | #EXTINF:8, 79 | https://priv.example.com/fileSequence2680.ts 80 | #EXTINF:8, 81 | https://priv.example.com/fileSequence2681.ts 82 | #EXTINF:8, 83 | https://priv.example.com/fileSequence2682.ts 84 | """ 85 | 86 | PLAYLIST_WITH_ENCRYPTED_SEGMENTS = """ 87 | #EXTM3U 88 | #EXT-X-MEDIA-SEQUENCE:7794 89 | #EXT-X-TARGETDURATION:15 90 | 91 | #EXT-X-KEY:METHOD=AES-128,URI="https://priv.example.com/key.php?r=52" 92 | 93 | #EXTINF:15, 94 | http://media.example.com/fileSequence52-1.ts 95 | #EXTINF:15, 96 | http://media.example.com/fileSequence52-2.ts 97 | #EXTINF:15, 98 | http://media.example.com/fileSequence52-3.ts 99 | """ 100 | 101 | PLAYLIST_WITH_TAG_MEDIA_READY = """#EXTM3U 102 | #EXT-X-VERSION:3 103 | #EXT-X-TARGETDURATION:6 104 | #EXT-X-MEDIA-SEQUENCE:1 105 | #EXT-X-MEDIA-READY:7f659f6f09bce196d7 106 | #EXT-X-KEY:METHOD=AES-128,URI="[KEY]",IV=[IV] 107 | #EXTINF:6.0, 108 | https://cdn.example.com/vod/hash:XXX/file.mp4/media-1.ts 109 | #EXTINF:6.28, 110 | https://cdn.example.com/vod/hash:XXX/file.mp4/media-2.ts 111 | """ 112 | 113 | PLAYLIST_WITH_SESSION_ENCRYPTED_SEGMENTS = """ 114 | #EXTM3U 115 | #EXT-X-MEDIA-SEQUENCE:7794 116 | #EXT-X-TARGETDURATION:15 117 | 118 | #EXT-X-SESSION-KEY:METHOD=AES-128,URI="https://priv.example.com/key.php?r=52" 119 | 120 | #EXTINF:15, 121 | http://media.example.com/fileSequence52-1.ts 122 | #EXTINF:15, 123 | http://media.example.com/fileSequence52-2.ts 124 | #EXTINF:15, 125 | http://media.example.com/fileSequence52-3.ts 126 | """ 127 | 128 | VARIANT_PLAYLIST = """ 129 | #EXTM3U 130 | #EXT-X-STREAM-INF:PROGRAM-ID=1, BANDWIDTH=1280000 131 | http://example.com/low.m3u8 132 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=2560000 133 | http://example.com/mid.m3u8 134 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=7680000 135 | http://example.com/hi.m3u8 136 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=65000,CODECS="mp4a.40.5,avc1.42801e" 137 | http://example.com/audio-only.m3u8 138 | """ 139 | 140 | VARIANT_PLAYLIST_WITH_CC_SUBS_AND_AUDIO = """ 141 | #EXTM3U 142 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=7680000,CLOSED-CAPTIONS="cc",SUBTITLES="sub",AUDIO="aud" 143 | http://example.com/with-cc-hi.m3u8 144 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=65000,CLOSED-CAPTIONS="cc",SUBTITLES="sub",AUDIO="aud" 145 | http://example.com/with-cc-low.m3u8 146 | """ 147 | 148 | VARIANT_PLAYLIST_WITH_NONE_CC_AND_AUDIO = """ 149 | #EXTM3U 150 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=7680000,CLOSED-CAPTIONS=NONE,SUBTITLES="sub",AUDIO="aud" 151 | http://example.com/with-cc-hi.m3u8 152 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=65000,CLOSED-CAPTIONS=NONE,SUBTITLES="sub",AUDIO="aud" 153 | http://example.com/with-cc-low.m3u8 154 | """ 155 | 156 | VARIANT_PLAYLIST_WITH_VIDEO_CC_SUBS_AND_AUDIO = """ 157 | #EXTM3U 158 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=7680000,CLOSED-CAPTIONS="cc",SUBTITLES="sub",AUDIO="aud",VIDEO="vid" 159 | http://example.com/with-everything-hi.m3u8 160 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=65000,CLOSED-CAPTIONS="cc",SUBTITLES="sub",AUDIO="aud",VIDEO="vid" 161 | http://example.com/with-everything-low.m3u8 162 | """ 163 | 164 | VARIANT_PLAYLIST_WITH_AVERAGE_BANDWIDTH = """ 165 | #EXTM3U 166 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1280000,AVERAGE-BANDWIDTH=1252345 167 | http://example.com/low.m3u8 168 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=2560000,AVERAGE-BANDWIDTH=2466570 169 | http://example.com/mid.m3u8 170 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=7680000,AVERAGE-BANDWIDTH=7560423 171 | http://example.com/hi.m3u8 172 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=65000,AVERAGE-BANDWIDTH=63005,CODECS="mp4a.40.5,avc1.42801e" 173 | http://example.com/audio-only.m3u8 174 | """ 175 | 176 | VARIANT_PLAYLIST_WITH_VIDEO_RANGE = """ 177 | #EXTM3U 178 | #EXT-X-STREAM-INF:PROGRAM-ID=1,VIDEO-RANGE=SDR 179 | http://example.com/sdr.m3u8 180 | #EXT-X-STREAM-INF:PROGRAM-ID=1,VIDEO-RANGE=PQ 181 | http://example.com/hdr.m3u8 182 | """ 183 | 184 | VARIANT_PLAYLIST_WITH_HDCP_LEVEL = """ 185 | #EXTM3U 186 | #EXT-X-STREAM-INF:PROGRAM-ID=1,HDCP-LEVEL=NONE 187 | http://example.com/none.m3u8 188 | #EXT-X-STREAM-INF:PROGRAM-ID=1,HDCP-LEVEL=TYPE-0 189 | http://example.com/type0.m3u8 190 | #EXT-X-STREAM-INF:PROGRAM-ID=1,HDCP-LEVEL=TYPE-1 191 | http://example.com/type1.m3u8 192 | """ 193 | 194 | VARIANT_PLAYLIST_WITH_BANDWIDTH_FLOAT = """ 195 | #EXTM3U 196 | #EXT-X-STREAM-INF:PROGRAM-ID=1, BANDWIDTH=1280000.0 197 | http://example.com/low.m3u8 198 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=2560000.4 199 | http://example.com/mid.m3u8 200 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=7680000.6 201 | http://example.com/hi.m3u8 202 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=65000,CODECS="mp4a.40.5,avc1.42801e" 203 | http://example.com/audio-only.m3u8 204 | """ 205 | 206 | VARIANT_PLAYLIST_WITH_IFRAME_PLAYLISTS = """ 207 | #EXTM3U 208 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=800000,RESOLUTION=624x352,CODECS="avc1.4d001f, mp4a.40.5" 209 | video-800k.m3u8 210 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1200000,CODECS="avc1.4d001f, mp4a.40.5" 211 | video-1200k.m3u8 212 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=400000,CODECS="avc1.4d001f, mp4a.40.5" 213 | video-400k.m3u8 214 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=150000,CODECS="avc1.4d001f, mp4a.40.5" 215 | video-150k.m3u8 216 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=64000,CODECS="mp4a.40.5" 217 | video-64k.m3u8 218 | #EXT-X-I-FRAME-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=151288,RESOLUTION=624x352,CODECS="avc1.4d001f",URI="video-800k-iframes.m3u8" 219 | #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=193350,CODECS="avc1.4d001f",URI="video-1200k-iframes.m3u8" 220 | #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=83598,CODECS="avc1.4d001f",URI="video-400k-iframes.m3u8" 221 | #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=38775,CODECS="avc1.4d001f",URI="video-150k-iframes.m3u8" 222 | """ 223 | 224 | VARIANT_PLAYLIST_WITH_ALT_IFRAME_PLAYLISTS_LAYOUT = """ 225 | #EXTM3U 226 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=800000,RESOLUTION=624x352,CODECS="avc1.4d001f, mp4a.40.5" 227 | video-800k.m3u8 228 | #EXT-X-I-FRAME-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=151288,RESOLUTION=624x352,CODECS="avc1.4d001f",URI="video-800k-iframes.m3u8" 229 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1200000,CODECS="avc1.4d001f, mp4a.40.5" 230 | video-1200k.m3u8 231 | #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=193350,CODECS="avc1.4d001f",URI="video-1200k-iframes.m3u8" 232 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=400000,CODECS="avc1.4d001f, mp4a.40.5" 233 | video-400k.m3u8 234 | #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=83598,CODECS="avc1.4d001f",URI="video-400k-iframes.m3u8" 235 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=150000,CODECS="avc1.4d001f, mp4a.40.5" 236 | video-150k.m3u8 237 | #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=38775,CODECS="avc1.4d001f",URI="video-150k-iframes.m3u8" 238 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=64000,CODECS="mp4a.40.5" 239 | video-64k.m3u8 240 | """ 241 | 242 | VARIANT_PLAYLIST_WITH_REQ_VIDEO_LAYOUT = """ 243 | #EXTM3U 244 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=7680000,CLOSED-CAPTIONS="cc",SUBTITLES="sub",AUDIO="aud",VIDEO="vid",REQ-VIDEO-LAYOUT="CH-STEREO" 245 | http://example.com/with-everything-hi.m3u8 246 | """ 247 | 248 | IFRAME_PLAYLIST = """ 249 | #EXTM3U 250 | #EXT-X-VERSION:4 251 | #EXT-X-TARGETDURATION:10 252 | #EXT-X-PLAYLIST-TYPE:VOD 253 | #EXT-X-I-FRAMES-ONLY 254 | #EXTINF:4.12, 255 | #EXT-X-BYTERANGE:9400@376 256 | segment1.ts 257 | #EXTINF:3.56, 258 | #EXT-X-BYTERANGE:7144@47000 259 | segment1.ts 260 | #EXTINF:3.82, 261 | #EXT-X-BYTERANGE:10340@1880 262 | segment2.ts 263 | #EXT-X-ENDLIST 264 | """ 265 | 266 | # reversing byterange and extinf from IFRAME. 267 | IFRAME_PLAYLIST2 = """ 268 | #EXTM3U 269 | #EXT-X-VERSION:4 270 | #EXT-X-TARGETDURATION:10 271 | #EXT-X-PLAYLIST-TYPE:VOD 272 | #EXT-X-I-FRAMES-ONLY 273 | #EXT-X-BYTERANGE:9400@376 274 | #EXTINF:4.12, 275 | segment1.ts 276 | #EXT-X-BYTERANGE:7144@47000 277 | #EXTINF:3.56, 278 | segment1.ts 279 | #EXT-X-BYTERANGE:10340@1880 280 | #EXTINF:3.82, 281 | segment2.ts 282 | #EXT-X-ENDLIST 283 | """ 284 | 285 | PLAYLIST_USING_BYTERANGES = """ 286 | #EXTM3U 287 | #EXT-X-VERSION:4 288 | #EXT-X-TARGETDURATION:11 289 | #EXTINF:10, 290 | #EXT-X-BYTERANGE:76242@0 291 | segment.ts 292 | #EXTINF:10, 293 | #EXT-X-BYTERANGE:83442@762421 294 | segment.ts 295 | #EXTINF:10, 296 | #EXT-X-BYTERANGE:69864@834421 297 | segment.ts 298 | #EXT-X-ENDLIST 299 | """ 300 | 301 | PLAYLIST_WITH_ENCRYPTED_SEGMENTS_AND_IV = """ 302 | #EXTM3U 303 | #EXT-X-MEDIA-SEQUENCE:82400 304 | #EXT-X-ALLOW-CACHE:NO 305 | #EXT-X-VERSION:2 306 | #EXT-X-KEY:METHOD=AES-128,URI="/hls-key/key.bin", IV=0X10ef8f758ca555115584bb5b3c687f52 307 | #EXT-X-TARGETDURATION:8 308 | #EXTINF:8, 309 | ../../../../hls/streamNum82400.ts 310 | #EXTINF:8, 311 | ../../../../hls/streamNum82401.ts 312 | #EXTINF:8, 313 | ../../../../hls/streamNum82402.ts 314 | #EXTINF:8, 315 | ../../../../hls/streamNum82403.ts 316 | #EXTINF:8, 317 | ../../../../hls/streamNum82404.ts 318 | #EXTINF:8, 319 | ../../../../hls/streamNum82405.ts 320 | """ 321 | 322 | PLAYLIST_WITH_SESSION_ENCRYPTED_SEGMENTS_AND_IV = """ 323 | #EXTM3U 324 | #EXT-X-MEDIA-SEQUENCE:82400 325 | #EXT-X-ALLOW-CACHE:NO 326 | #EXT-X-VERSION:2 327 | #EXT-X-SESSION-KEY:METHOD=AES-128,URI="/hls-key/key.bin", IV=0X10ef8f758ca555115584bb5b3c687f52 328 | #EXT-X-TARGETDURATION:8 329 | #EXTINF:8, 330 | ../../../../hls/streamNum82400.ts 331 | #EXTINF:8, 332 | ../../../../hls/streamNum82401.ts 333 | #EXTINF:8, 334 | ../../../../hls/streamNum82402.ts 335 | #EXTINF:8, 336 | ../../../../hls/streamNum82403.ts 337 | #EXTINF:8, 338 | ../../../../hls/streamNum82404.ts 339 | #EXTINF:8, 340 | ../../../../hls/streamNum82405.ts 341 | """ 342 | 343 | PLAYLIST_WITH_ENCRYPTED_SEGMENTS_AND_IV_SORTED = """ 344 | #EXTM3U 345 | #EXT-X-MEDIA-SEQUENCE:82400 346 | #EXT-X-ALLOW-CACHE:NO 347 | #EXT-X-VERSION:2 348 | #EXT-X-TARGETDURATION:8 349 | #EXT-X-KEY:METHOD=AES-128,URI="/hls-key/key.bin", IV=0X10ef8f758ca555115584bb5b3c687f52 350 | #EXTINF:8, 351 | ../../../../hls/streamNum82400.ts 352 | #EXTINF:8, 353 | ../../../../hls/streamNum82401.ts 354 | #EXTINF:8, 355 | ../../../../hls/streamNum82402.ts 356 | #EXTINF:8, 357 | ../../../../hls/streamNum82403.ts 358 | #EXTINF:8, 359 | ../../../../hls/streamNum82404.ts 360 | #EXTINF:8, 361 | ../../../../hls/streamNum82405.ts 362 | """ 363 | 364 | PLAYLIST_WITH_SESSION_ENCRYPTED_SEGMENTS_AND_IV_SORTED = """ 365 | #EXTM3U 366 | #EXT-X-MEDIA-SEQUENCE:82400 367 | #EXT-X-ALLOW-CACHE:NO 368 | #EXT-X-VERSION:2 369 | #EXT-X-TARGETDURATION:8 370 | #EXT-X-SESSION-KEY:METHOD=AES-128,URI="/hls-key/key.bin", IV=0X10ef8f758ca555115584bb5b3c687f52 371 | #EXTINF:8, 372 | ../../../../hls/streamNum82400.ts 373 | #EXTINF:8, 374 | ../../../../hls/streamNum82401.ts 375 | #EXTINF:8, 376 | ../../../../hls/streamNum82402.ts 377 | #EXTINF:8, 378 | ../../../../hls/streamNum82403.ts 379 | #EXTINF:8, 380 | ../../../../hls/streamNum82404.ts 381 | #EXTINF:8, 382 | ../../../../hls/streamNum82405.ts 383 | """ 384 | 385 | PLAYLIST_WITH_ENCRYPTED_SEGMENTS_AND_IV_WITH_MULTIPLE_KEYS = """ 386 | #EXTM3U 387 | #EXT-X-MEDIA-SEQUENCE:82400 388 | #EXT-X-ALLOW-CACHE:NO 389 | #EXT-X-VERSION:2 390 | #EXT-X-KEY:METHOD=AES-128,URI="/hls-key/key.bin",IV=0X10ef8f758ca555115584bb5b3c687f52 391 | #EXT-X-TARGETDURATION:8 392 | #EXTINF:8, 393 | ../../../../hls/streamNum82400.ts 394 | #EXTINF:8, 395 | ../../../../hls/streamNum82401.ts 396 | #EXTINF:8, 397 | ../../../../hls/streamNum82402.ts 398 | #EXTINF:8, 399 | ../../../../hls/streamNum82403.ts 400 | #EXT-X-KEY:METHOD=AES-128,URI="/hls-key/key2.bin",IV=0Xcafe8f758ca555115584bb5b3c687f52 401 | #EXTINF:8, 402 | ../../../../hls/streamNum82404.ts 403 | #EXTINF:8, 404 | ../../../../hls/streamNum82405.ts 405 | """ 406 | 407 | PLAYLIST_WITH_ENCRYPTED_SEGMENTS_AND_IV_WITH_MULTIPLE_KEYS_SORTED = """ 408 | #EXTM3U 409 | #EXT-X-MEDIA-SEQUENCE:82400 410 | #EXT-X-ALLOW-CACHE:NO 411 | #EXT-X-VERSION:2 412 | #EXT-X-TARGETDURATION:8 413 | #EXT-X-KEY:METHOD=AES-128,URI="/hls-key/key.bin",IV=0X10ef8f758ca555115584bb5b3c687f52 414 | #EXTINF:8, 415 | ../../../../hls/streamNum82400.ts 416 | #EXTINF:8, 417 | ../../../../hls/streamNum82401.ts 418 | #EXTINF:8, 419 | ../../../../hls/streamNum82402.ts 420 | #EXTINF:8, 421 | ../../../../hls/streamNum82403.ts 422 | #EXT-X-KEY:METHOD=AES-128,URI="/hls-key/key2.bin",IV=0Xcafe8f758ca555115584bb5b3c687f52 423 | #EXTINF:8, 424 | ../../../../hls/streamNum82404.ts 425 | #EXTINF:8, 426 | ../../../../hls/streamNum82405.ts 427 | """ 428 | 429 | PLAYLIST_WITH_MULTIPLE_KEYS_UNENCRYPTED_AND_ENCRYPTED = """ 430 | #EXTM3U 431 | #EXT-X-MEDIA-SEQUENCE:82400 432 | #EXT-X-ALLOW-CACHE:NO 433 | #EXT-X-VERSION:2 434 | #EXT-X-TARGETDURATION:8 435 | #EXTINF:8, 436 | ../../../../hls/streamNum82400.ts 437 | #EXTINF:8, 438 | ../../../../hls/streamNum82401.ts 439 | #EXT-X-KEY:METHOD=AES-128,URI="/hls-key/key.bin",IV=0X10ef8f758ca555115584bb5b3c687f52 440 | #EXTINF:8, 441 | ../../../../hls/streamNum82400.ts 442 | #EXTINF:8, 443 | ../../../../hls/streamNum82401.ts 444 | #EXTINF:8, 445 | ../../../../hls/streamNum82402.ts 446 | #EXTINF:8, 447 | ../../../../hls/streamNum82403.ts 448 | #EXT-X-KEY:METHOD=AES-128,URI="/hls-key/key2.bin",IV=0Xcafe8f758ca555115584bb5b3c687f52 449 | #EXTINF:8, 450 | ../../../../hls/streamNum82404.ts 451 | #EXTINF:8, 452 | ../../../../hls/streamNum82405.ts 453 | """ 454 | 455 | PLAYLIST_WITH_MULTIPLE_KEYS_UNENCRYPTED_AND_ENCRYPTED_UPDATED = """ 456 | #EXTM3U 457 | #EXT-X-MEDIA-SEQUENCE:82400 458 | #EXT-X-ALLOW-CACHE:NO 459 | #EXT-X-VERSION:2 460 | #EXT-X-TARGETDURATION:8 461 | #EXT-X-KEY:METHOD=AES-128,URI="/hls-key/key0.bin",IV=0Xcafe8f758ca555115584bb5b3c687f52 462 | #EXTINF:8, 463 | ../../../../hls/streamNum82400.ts 464 | #EXTINF:8, 465 | ../../../../hls/streamNum82401.ts 466 | #EXT-X-KEY:METHOD=AES-128,URI="/hls-key/key.bin",IV=0X10ef8f758ca555115584bb5b3c687f52 467 | #EXTINF:8, 468 | ../../../../hls/streamNum82400.ts 469 | #EXTINF:8, 470 | ../../../../hls/streamNum82401.ts 471 | #EXTINF:8, 472 | ../../../../hls/streamNum82402.ts 473 | #EXTINF:8, 474 | ../../../../hls/streamNum82403.ts 475 | #EXT-X-KEY:METHOD=AES-128,URI="/hls-key/key2.bin",IV=0Xcafe8f758ca555115584bb5b3c687f52 476 | #EXTINF:8, 477 | ../../../../hls/streamNum82404.ts 478 | #EXTINF:8, 479 | ../../../../hls/streamNum82405.ts 480 | """ 481 | 482 | PLAYLIST_WITH_MULTIPLE_KEYS_UNENCRYPTED_AND_ENCRYPTED_NONE = """ 483 | #EXTM3U 484 | #EXT-X-MEDIA-SEQUENCE:82400 485 | #EXT-X-ALLOW-CACHE:NO 486 | #EXT-X-VERSION:2 487 | #EXT-X-TARGETDURATION:8 488 | #EXTINF:8, 489 | ../../../../hls/streamNum82400.ts 490 | #EXTINF:8, 491 | ../../../../hls/streamNum82401.ts 492 | #EXT-X-KEY:METHOD=AES-128,URI="/hls-key/key.bin",IV=0X10ef8f758ca555115584bb5b3c687f52 493 | #EXTINF:8, 494 | ../../../../hls/streamNum82400.ts 495 | #EXTINF:8, 496 | ../../../../hls/streamNum82401.ts 497 | #EXTINF:8, 498 | ../../../../hls/streamNum82402.ts 499 | #EXTINF:8, 500 | ../../../../hls/streamNum82403.ts 501 | #EXT-X-KEY:METHOD=NONE,URI="" 502 | #EXTINF:8, 503 | ../../../../hls/streamNum82404.ts 504 | #EXTINF:8, 505 | ../../../../hls/streamNum82405.ts 506 | #EXT-X-KEY:METHOD=AES-128,URI="/hls-key/key2.bin",IV=0Xcafe8f758ca555115584bb5b3c687f52 507 | #EXTINF:8, 508 | ../../../../hls/streamNum82404.ts 509 | #EXTINF:8, 510 | ../../../../hls/streamNum82405.ts 511 | 512 | """ 513 | 514 | PLAYLIST_WITH_MULTIPLE_KEYS_UNENCRYPTED_AND_ENCRYPTED_NONE_AND_NO_URI_ATTR = """ 515 | #EXTM3U 516 | #EXT-X-MEDIA-SEQUENCE:82400 517 | #EXT-X-ALLOW-CACHE:NO 518 | #EXT-X-VERSION:2 519 | #EXT-X-TARGETDURATION:8 520 | #EXTINF:8, 521 | ../../../../hls/streamNum82400.ts 522 | #EXTINF:8, 523 | ../../../../hls/streamNum82401.ts 524 | #EXT-X-KEY:METHOD=AES-128,URI="/hls-key/key.bin",IV=0X10ef8f758ca555115584bb5b3c687f52 525 | #EXTINF:8, 526 | ../../../../hls/streamNum82400.ts 527 | #EXTINF:8, 528 | ../../../../hls/streamNum82401.ts 529 | #EXTINF:8, 530 | ../../../../hls/streamNum82402.ts 531 | #EXTINF:8, 532 | ../../../../hls/streamNum82403.ts 533 | #EXT-X-KEY:METHOD=NONE 534 | #EXTINF:8, 535 | ../../../../hls/streamNum82404.ts 536 | #EXTINF:8, 537 | ../../../../hls/streamNum82405.ts 538 | #EXT-X-KEY:METHOD=AES-128,URI="/hls-key/key2.bin",IV=0Xcafe8f758ca555115584bb5b3c687f52 539 | #EXTINF:8, 540 | ../../../../hls/streamNum82404.ts 541 | #EXTINF:8, 542 | ../../../../hls/streamNum82405.ts 543 | 544 | """ 545 | 546 | PLAYLIST_WITH_KEYFORMAT_AND_KEYFORMATVERSIONS = """#EXTM3U 547 | #EXT-X-VERSION:5 548 | #EXT-X-TARGETDURATION:8 549 | #EXT-X-KEY:METHOD=SAMPLE-AES,URI="skd://someuri",KEYFORMAT="com.apple.streamingkeydelivery",KEYFORMATVERSIONS="1" 550 | #EXTINF:8, 551 | segment.ts 552 | """ 553 | 554 | SIMPLE_PLAYLIST_WITH_QUOTED_TITLE = """ 555 | #EXTM3U 556 | #EXT-X-TARGETDURATION:5220 557 | #EXTINF:5220,"A sample title" 558 | http://media.example.com/entire.ts 559 | #EXT-X-ENDLIST 560 | """ 561 | 562 | SIMPLE_PLAYLIST_WITH_UNQUOTED_TITLE = """ 563 | #EXTM3U 564 | #EXT-X-TARGETDURATION:5220 565 | #EXTINF:5220,A sample unquoted title 566 | http://media.example.com/entire.ts 567 | #EXT-X-ENDLIST 568 | """ 569 | 570 | SIMPLE_PLAYLIST_WITH_RESOLUTION = """ 571 | #EXTM3U 572 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=445000,RESOLUTION=512x288,CODECS="avc1.77.30, mp4a.40.5" 573 | index_0_av.m3u8?e=b471643725c47acd 574 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=45000,CODECS="mp4a.40.5" 575 | index_0_a.m3u8?e=b471643725c47acd 576 | """ 577 | 578 | SIMPLE_PLAYLIST_WITH_VOD_PLAYLIST_TYPE = """ 579 | #EXTM3U 580 | #EXT-X-PLAYLIST-TYPE:VOD 581 | #EXTINF:180.00000, 582 | some_video.ts 583 | #EXT-X-ENDLIST 584 | """ 585 | 586 | SIMPLE_PLAYLIST_WITH_INDEPENDENT_SEGMENTS = """ 587 | #EXTM3U 588 | #EXT-X-INDEPENDENT-SEGMENTS 589 | #EXTINF:180.00000, 590 | some_video.ts 591 | #EXT-X-ENDLIST 592 | """ 593 | 594 | SIMPLE_PLAYLIST_WITH_EVENT_PLAYLIST_TYPE = """ 595 | #EXTM3U 596 | #EXT-X-PLAYLIST-TYPE:EVENT 597 | #EXTINF:180.00000, 598 | some_video.ts 599 | #EXT-X-ENDLIST 600 | """ 601 | 602 | SIMPLE_PLAYLIST_WITH_PROGRAM_DATE_TIME = """ 603 | #EXTM3U 604 | #EXT-X-MEDIA-SEQUENCE:50116 605 | #EXT-X-TARGETDURATION:3 606 | #EXT-X-PROGRAM-DATE-TIME:2014-08-13T13:36:33+00:00 607 | #EXTINF:3, 608 | g_50116.ts 609 | #EXTINF:3, 610 | g_50117.ts 611 | #EXTINF:3, 612 | g_50118.ts 613 | #EXTINF:3, 614 | g_50119.ts 615 | #EXTINF:3, 616 | g_50120.ts 617 | #EXTINF:3, 618 | g_50121.ts 619 | #EXTINF:3, 620 | g_50122.ts 621 | #EXTINF:3, 622 | g_50123.ts 623 | 624 | """ 625 | 626 | # The playlist fails if parsed as strict, but otherwise passes 627 | SIMPLE_PLAYLIST_MESSY = """ 628 | #EXTM3U 629 | #EXT-X-TARGETDURATION:5220 630 | #EXTINF:5220, 631 | http://media.example.com/entire.ts 632 | JUNK 633 | #EXT-X-ENDLIST 634 | """ 635 | 636 | # The playlist fails if parsed as strict, but otherwise passes 637 | SIMPLE_PLAYLIST_TITLE_COMMA = """ 638 | #EXTM3U 639 | #EXTINF:5220,Title with a comma, end 640 | http://media.example.com/entire.ts 641 | #EXT-X-ENDLIST 642 | """ 643 | 644 | # Playlist with EXTINF record not ending with comma 645 | SIMPLE_PLAYLIST_COMMALESS_EXTINF = """ 646 | #EXTM3U 647 | #EXT-X-TARGETDURATION:5220 648 | #EXTINF:5220 649 | http://media.example.com/entire.ts 650 | #EXT-X-ENDLIST 651 | """ 652 | 653 | DISCONTINUITY_PLAYLIST_WITH_PROGRAM_DATE_TIME = """ 654 | #EXTM3U 655 | #EXT-X-MEDIA-SEQUENCE:50116 656 | #EXT-X-TARGETDURATION:3 657 | #EXT-X-PROGRAM-DATE-TIME:2014-08-13T13:36:33.000+00:00 658 | #EXTINF:3, 659 | g_50116.ts 660 | #EXTINF:3, 661 | g_50117.ts 662 | #EXTINF:3, 663 | g_50118.ts 664 | #EXTINF:3, 665 | g_50119.ts 666 | #EXTINF:3, 667 | g_50120.ts 668 | #EXT-X-DISCONTINUITY 669 | #EXT-X-PROGRAM-DATE-TIME:2014-08-13T13:36:55.000+00:00 670 | #EXTINF:3, 671 | g_50121.ts 672 | #EXTINF:3, 673 | g_50122.ts 674 | #EXTINF:3, 675 | g_50123.ts 676 | 677 | """ 678 | 679 | PLAYLIST_WITH_PROGRAM_DATE_TIME_WITHOUT_DISCONTINUITY = """ 680 | #EXTM3U 681 | #EXT-X-VERSION:3 682 | #EXT-X-TARGETDURATION:6 683 | #EXT-X-PLAYLIST-TYPE:EVENT 684 | #EXT-X-MEDIA-SEQUENCE:50 685 | #EXT-X-PROGRAM-DATE-TIME:2019-06-10T00:05:00.000Z 686 | #EXTINF:6.000, 687 | manifest_1_50.ts?m=1559946393 688 | #EXT-X-PROGRAM-DATE-TIME:2019-06-10T00:05:06.000Z 689 | #EXTINF:6.000, 690 | manifest_1_51.ts?m=1559946393 691 | #EXT-X-PROGRAM-DATE-TIME:2019-06-10T00:05:12.000Z 692 | #EXTINF:6.000, 693 | manifest_1_52.ts?m=1559946393 694 | #EXT-X-ENDLIST 695 | """ 696 | 697 | CUE_OUT_PLAYLIST = """ 698 | #EXTM3U 699 | #EXT-X-TARGETDURATION:10 700 | #EXT-X-MEDIA-SEQUENCE:143474331 701 | #EXT-X-VERSION:3 702 | #EXTINF:10, 703 | #EXT-X-PROGRAM-DATE-TIME:2015-06-18T23:22:10Z 704 | 1432451707508/ts/71737/sequence143474338.ts 705 | #EXT-X-CUE-OUT-CONT 706 | #EXTINF:10, 707 | #EXT-X-PROGRAM-DATE-TIME:2015-06-18T23:22:20Z 708 | 1432451707508/ts/71737/sequence143474339.ts 709 | #EXT-X-CUE-OUT-CONT 710 | #EXTINF:10, 711 | #EXT-X-PROGRAM-DATE-TIME:2015-06-18T23:22:30Z 712 | 1432451707508/ts/71737/sequence143474340.ts 713 | #EXT-OATCLS-SCTE35:/DA5AAAAAAAA/wCABQb+aDhDgAAjAhdDVUVJQAAAV3+fCAgAAAAAIxDjqDUCAAAIQ1VFSQAAAABSV+PX 714 | #EXT-X-CUE-IN 715 | #EXTINF:10, 716 | #EXT-X-PROGRAM-DATE-TIME:2015-06-18T23:22:40Z 717 | 1432451707508/ts/71737/sequence143474341.ts 718 | """ 719 | 720 | CUE_OUT_ELEMENTAL_PLAYLIST = """ 721 | #EXTM3U 722 | #EXT-X-VERSION:3 723 | #EXT-X-TARGETDURATION:10 724 | #EXT-X-MEDIA-SEQUENCE:47224 725 | #EXTINF:10.000, 726 | master2500_47224.ts 727 | #EXTINF:10.000, 728 | master2500_47225.ts 729 | #EXTINF:2.040, 730 | master2500_47226.ts 731 | #EXT-OATCLS-SCTE35:/DAlAAAAAAAAAP/wFAUAAAABf+//wpiQkv4ARKogAAEBAQAAQ6sodg== 732 | #EXT-X-ASSET:GENRE=CV,CAID=12345678,EPISODE="Episode%20Name%20Date",SEASON="Season%20Name%20and%20Number",SERIES="Series%2520Name" 733 | #EXT-X-CUE-OUT:50.000 734 | #EXTINF:7.960, 735 | master2500_47227.ts 736 | #EXT-X-CUE-OUT-CONT:ElapsedTime=7.960,Duration=50,SCTE35=/DAlAAAAAAAAAP/wFAUAAAABf+//wpiQkv4ARKogAAEBAQAAQ6sodg== 737 | #EXTINF:10.000, 738 | master2500_47228.ts 739 | #EXT-X-CUE-OUT-CONT:ElapsedTime=17.960,Duration=50,SCTE35=/DAlAAAAAAAAAP/wFAUAAAABf+//wpiQkv4ARKogAAEBAQAAQ6sodg== 740 | #EXTINF:10.000, 741 | master2500_47229.ts 742 | #EXT-X-CUE-OUT-CONT:ElapsedTime=27.960,Duration=50,SCTE35=/DAlAAAAAAAAAP/wFAUAAAABf+//wpiQkv4ARKogAAEBAQAAQ6sodg== 743 | #EXTINF:10.000, 744 | master2500_47230.ts 745 | #EXT-X-CUE-OUT-CONT:ElapsedTime=37.960,Duration=50,SCTE35=/DAlAAAAAAAAAP/wFAUAAAABf+//wpiQkv4ARKogAAEBAQAAQ6sodg== 746 | #EXTINF:10.000, 747 | master2500_47231.ts 748 | #EXT-X-CUE-OUT-CONT:ElapsedTime=47.960,Duration=50,SCTE35=/DAlAAAAAAAAAP/wFAUAAAABf+//wpiQkv4ARKogAAEBAQAAQ6sodg== 749 | #EXTINF:2.040, 750 | master2500_47232.ts 751 | #EXT-X-CUE-IN 752 | #EXTINF:7.960, 753 | master2500_47233.ts 754 | #EXTINF:7.960, 755 | master2500_47234.ts 756 | """ 757 | 758 | OATCLS_ELEMENTAL_PLAYLIST = """ 759 | #EXTM3U 760 | #EXT-X-VERSION:3 761 | #EXT-X-TARGETDURATION:8 762 | #EXT-X-MEDIA-SEQUENCE:266918 763 | #EXTINF:6.00600, 764 | playlist_192k_266918.ts 765 | #EXTINF:4.80480, 766 | playlist_192k_266919.ts 767 | #EXT-OATCLS-SCTE35:/DAqAAAAAyiYAP/wBQb/FuaKGAAUAhJDVUVJAAAFp3+/EQMCRgIMAQF7Ny4D 768 | #EXTINF:1.20120, 769 | playlist_192k_266920.ts 770 | #EXTINF:6.00600, 771 | playlist_192k_266921.ts 772 | #EXTINF:6.00600, 773 | playlist_192k_266922.ts 774 | """ 775 | 776 | CUE_OUT_CONT_ALT_PLAYLIST = """ 777 | #EXTM3U 778 | #EXT-X-VERSION:3 779 | #EXT-X-TARGETDURATION:7 780 | #EXT-X-MEDIA-SEQUENCE:19980226 781 | #EXT-X-DISCONTINUITY-SEQUENCE:1 782 | #EXT-X-CUE-OUT:119.987 783 | #EXTINF:2.000, 784 | segment_19980226.ts 785 | #EXT-X-CUE-OUT-CONT:2/120 786 | #EXTINF:6.000, 787 | segment_19980227.ts 788 | #EXT-X-CUE-OUT-CONT:8/120.0 789 | #EXTINF:6.001, 790 | segment_19980228.ts 791 | #EXT-X-CUE-OUT-CONT:14.001/120.0 792 | #EXTINF:6.001, 793 | segment_19980229.ts 794 | """ 795 | 796 | CUE_OUT_MEDIACONVERT_PLAYLIST = """\ 797 | #EXTM3U 798 | #EXT-X-VERSION:3 799 | #EXT-X-TARGETDURATION:11 800 | #EXT-X-MEDIA-SEQUENCE:1 801 | #EXT-X-PLAYLIST-TYPE:VOD 802 | #EXTINF:10, 803 | segment_00001.ts 804 | #EXT-X-CUE-OUT:4,SpliceType=VOD_DAI,Action=REPLACE, PAID=example.com/2024073010700,Acds=BA 805 | #EXTINF:10, 806 | segment_00002.ts 807 | #EXT-X-CUE-OUT-CONT:10/4, SpliceType=VOD_DAI,Action=REPLACE,PAID=example.com/2024073010700,Acds=BA 808 | #EXTINF:10, 809 | segment_00003.ts 810 | #EXTINF:10, 811 | segment_00004.ts 812 | #EXT-X-CUE-IN:4,SpliceType=VOD_DAI 813 | #EXTINF:0, 814 | segment_00005.ts 815 | #EXTINF:10, 816 | segment_00006.ts 817 | #EXT-X-ENDLIST 818 | """ 819 | 820 | 821 | CUE_OUT_ENVIVIO_PLAYLIST = """ 822 | #EXTM3U 823 | #EXT-X-VERSION:3 824 | #EXT-X-TARGETDURATION:11 825 | #EXT-X-MEDIA-SEQUENCE:399703 826 | #EXTINF:10.0000, 827 | 20160914T080055-master804-199/1703.ts 828 | #EXTINF:10.0000, 829 | 20160914T080055-master804-199/1704.ts 830 | #EXTINF:5.1200, 831 | 20160914T080055-master804-199/1705.ts 832 | #EXT-X-CUE-OUT:DURATION=366,ID=16777323,CUE="/DAlAAAENOOQAP/wFAUBAABrf+//N25XDf4B9p/gAAEBAQAAxKni9A==" 833 | #EXTINF:10.0000, 834 | 20160914T080055-master804-199/1706.ts 835 | #EXT-X-CUE-SPAN:TIMEFROMSIGNAL=PT10S,ID=16777323 836 | #EXTINF:10.0000, 837 | 20160914T080055-master804-199/1707.ts 838 | #EXT-X-CUE-SPAN:TIMEFROMSIGNAL=PT20S,ID=16777323 839 | #EXTINF:10.0000, 840 | 20160914T080055-master804-199/1708.ts 841 | #EXT-X-CUE-SPAN:TIMEFROMSIGNAL=PT30S,ID=16777323 842 | #EXTINF:10.0000, 843 | 20160914T080055-master804-199/1709.ts 844 | #EXT-X-CUE-IN:ID=16777323 845 | #EXTINF:10.0000, 846 | 20160914T080055-master804-199/1710.ts 847 | """ 848 | 849 | CUE_OUT_INVALID_PLAYLIST = """#EXTM3U 850 | #EXT-X-TARGETDURATION:6 851 | #EXT-X-CUE-OUT:INVALID 852 | #EXTINF:5.76, no desc 853 | 0.aac 854 | #EXT-X-CUE-OUT-CONT 855 | #EXTINF:5.76 856 | 1.aac 857 | """ 858 | 859 | CUE_OUT_NO_DURATION_PLAYLIST = """#EXTM3U 860 | #EXT-X-TARGETDURATION:6 861 | #EXT-X-CUE-OUT 862 | #EXTINF:5.76, 863 | 0.aac 864 | #EXTINF:5.76, 865 | 1.aac 866 | #EXT-X-CUE-IN 867 | #EXTINF:5.76, 868 | 2.aac 869 | """ 870 | 871 | CUE_OUT_WITH_DURATION_PLAYLIST = """#EXTM3U 872 | #EXT-X-TARGETDURATION:6 873 | #EXT-X-CUE-OUT:11.52 874 | #EXTINF:5.76, 875 | 0.aac 876 | #EXTINF:5.76, 877 | 1.aac 878 | #EXT-X-CUE-IN 879 | #EXTINF:5.76, 880 | 2.aac 881 | """ 882 | 883 | CUE_OUT_WITH_EXPLICIT_DURATION_PLAYLIST = """#EXTM3U 884 | #EXT-X-TARGETDURATION:6 885 | #EXT-X-CUE-OUT:DURATION=11.52 886 | #EXTINF:5.76, 887 | 0.aac 888 | #EXTINF:5.76, 889 | 1.aac 890 | #EXT-X-CUE-IN 891 | #EXTINF:5.76, 892 | 2.aac 893 | """ 894 | 895 | CUE_OUT_WITH_DURATION_KEY_PLAYLIST = """#EXTM3U 896 | #EXT-X-TARGETDURATION:6 897 | #EXT-X-CUE-OUT:DURATION=11.52 898 | #EXTINF:5.76, 899 | 0.aac 900 | #EXTINF:5.76, 901 | 1.aac 902 | #EXT-X-CUE-IN 903 | #EXTINF:5.76, 904 | 2.aac 905 | """ 906 | 907 | MULTI_MEDIA_PLAYLIST = """#EXTM3U 908 | #EXT-X-VERSION:3 909 | #EXT-X-MEDIA:URI="chinese/ed.ttml",TYPE=SUBTITLES,GROUP-ID="subs",LANGUAGE="zho",NAME="Chinese",AUTOSELECT=YES,FORCED=NO 910 | #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" 911 | #EXT-X-MEDIA:TYPE=CLOSED-CAPTIONS,GROUP-ID="cc",LANGUAGE="sp",NAME="CC2",AUTOSELECT=YES,INSTREAM-ID="CC2" 912 | #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 913 | #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 914 | #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 915 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=2962000,RESOLUTION=1280x720,CODECS="avc1.66.30",AUDIO="aac",SUBTITLES="subs" 916 | 1280/chunklist_w370587926_b2962000_vo_slen_t64TWFpbg==.m3u8 917 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1427000,RESOLUTION=768x432,CODECS="avc1.66.30",AUDIO="aac",SUBTITLES="subs" 918 | 768/chunklist_w370587926_b1427000_vo_slen_t64TWFpbg==.m3u8 919 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=688000,RESOLUTION=448x252,CODECS="avc1.66.30",AUDIO="aac",SUBTITLES="subs" 920 | 448/chunklist_w370587926_b688000_vo_slen_t64TWFpbg==.m3u8 921 | """ 922 | 923 | MAP_URI_PLAYLIST = """#EXTM3U 924 | #EXT-X-TARGETDURATION:2 925 | #EXT-X-VERSION:7 926 | #EXT-X-MEDIA-SEQUENCE:1 927 | #EXT-X-PLAYLIST-TYPE:VOD 928 | #EXT-X-INDEPENDENT-SEGMENTS 929 | #EXT-X-MAP:URI="fileSequence0.mp4" 930 | """ 931 | 932 | MAP_URI_PLAYLIST_WITH_BYTERANGE = """#EXTM3U 933 | #EXT-X-TARGETDURATION:2 934 | #EXT-X-VERSION:7 935 | #EXT-X-MEDIA-SEQUENCE:1 936 | #EXT-X-PLAYLIST-TYPE:VOD 937 | #EXT-X-INDEPENDENT-SEGMENTS 938 | #EXT-X-MAP:URI="main.mp4",BYTERANGE="812@0" 939 | #EXTINF:1, 940 | segment_link1.mp4 941 | #EXT-X-MAP:URI="main2.mp4",BYTERANGE="912@0" 942 | #EXTINF:1, 943 | segment_link2.mp4 944 | """ 945 | 946 | MULTIPLE_MAP_URI_PLAYLIST = """#EXTM3U 947 | #EXT-X-TARGETDURATION:6 948 | #EXT-X-VERSION:7 949 | #EXT-X-MEDIA-SEQUENCE:1 950 | #EXT-X-PLAYLIST-TYPE:VOD 951 | #EXT-X-INDEPENDENT-SEGMENTS 952 | #EXT-X-KEY:URI="key.bin",METHOD=AES-128 953 | #EXT-X-MAP:URI="init1.mp4" 954 | #EXTINF:5, 955 | segment1.mp4 956 | #EXTINF:5, 957 | segment2.mp4 958 | #EXT-X-MAP:URI="init3.mp4" 959 | #EXTINF:5, 960 | segment3.mp4 961 | """ 962 | 963 | MEDIA_WITHOUT_URI_PLAYLIST = """#EXTM3U 964 | #EXT-X-VERSION:4 965 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio-aacl-312",NAME="English",LANGUAGE="en",AUTOSELECT=YES,DEFAULT=YES,CHANNELS="2" 966 | #EXT-X-STREAM-INF:BANDWIDTH=364000,AVERAGE-BANDWIDTH=331000,CODECS="mp4a.40.2",AUDIO="audio-aacl-312",SUBTITLES="textstream" 967 | ch001-audio_312640_eng=312000.m3u8 968 | """ 969 | 970 | SIMPLE_PLAYLIST_WITH_DISCONTINUITY_SEQUENCE = """#EXTM3U 971 | #EXT-X-TARGETDURATION:5220 972 | #EXT-X-DISCONTINUITY-SEQUENCE:123 973 | #EXTINF:5220, 974 | http://media.example.com/entire.ts 975 | #EXT-X-ENDLIST 976 | """ 977 | 978 | SIMPLE_PLAYLIST_WITH_CUSTOM_TAGS = """#EXTM3U 979 | #EXT-X-MOVIE: million dollar baby 980 | #EXT-X-TARGETDURATION:5220 981 | #EXTINF:5220, 982 | http://media.example.com/entire.ts 983 | #EXT-X-ENDLIST 984 | """ 985 | 986 | IPTV_PLAYLIST_WITH_CUSTOM_TAGS = """#EXTM3U 987 | #EXTVLCOPT:video-filter=invert 988 | #EXTGRP:ExtGroup1 989 | #EXTINF:-1 timeshift="0" catchup-days="7" catchup-type="flussonic" tvg-id="channel1" group-title="Group1",Channel1 990 | #EXTVLCOPT:param2=value2 991 | http://str00.iptv.domain/7331/mpegts?token=longtokenhere 992 | """ 993 | 994 | IPTV_PLAYLIST_WITH_EARLY_EXTINF = """#EXTM3U 995 | #EXTVLCOPT:video-filter=invert 996 | #EXTGRP:ExtGroup1 997 | #EXTINF:0,Info 998 | #EXTVLCOPT:param2=value2 999 | http://str00.iptv.domain/7331/mpegts?token=longtokenhere 1000 | """ 1001 | 1002 | LOW_LATENCY_PART_PLAYLIST = """\ 1003 | #EXTM3U 1004 | #EXT-X-TARGETDURATION:4 1005 | #EXT-X-VERSION:6 1006 | #EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=1.0,CAN-SKIP-UNTIL=24.0 1007 | #EXT-X-PART-INF:PART-TARGET=0.33334 1008 | #EXT-X-MEDIA-SEQUENCE:264 1009 | #EXT-X-PROGRAM-DATE-TIME:2019-02-14T02:13:28.106Z 1010 | #EXT-X-MAP:URI="init.mp4" 1011 | #EXTINF:4.00008, 1012 | fileSequence264.mp4 1013 | #EXTINF:4.00008, 1014 | fileSequence265.mp4 1015 | #EXTINF:4.00008, 1016 | fileSequence266.mp4 1017 | #EXTINF:4.00008, 1018 | fileSequence267.mp4 1019 | #EXTINF:4.00008, 1020 | fileSequence268.mp4 1021 | #EXTINF:4.00008, 1022 | fileSequence269.mp4 1023 | #EXTINF:4.00008, 1024 | fileSequence270.mp4 1025 | #EXT-X-PART:DURATION=0.33334,URI="filePart271.0.mp4" 1026 | #EXT-X-PART:DURATION=0.33334,URI="filePart271.1.mp4" 1027 | #EXT-X-PART:DURATION=0.33334,URI="filePart271.2.mp4" 1028 | #EXT-X-PART:DURATION=0.33334,URI="filePart271.3.mp4" 1029 | #EXT-X-PART:DURATION=0.33334,URI="filePart271.4.mp4",INDEPENDENT=YES 1030 | #EXT-X-PART:DURATION=0.33334,URI="filePart271.5.mp4" 1031 | #EXT-X-PART:DURATION=0.33334,URI="filePart271.6.mp4" 1032 | #EXT-X-PART:DURATION=0.33334,URI="filePart271.7.mp4" 1033 | #EXT-X-PART:DURATION=0.33334,URI="filePart271.8.mp4",INDEPENDENT=YES 1034 | #EXT-X-PART:DURATION=0.33334,URI="filePart271.9.mp4" 1035 | #EXT-X-PART:DURATION=0.33334,URI="filePart271.10.mp4" 1036 | #EXT-X-PART:DURATION=0.33334,URI="filePart271.11.mp4" 1037 | #EXTINF:4.00008, 1038 | fileSequence271.mp4 1039 | #EXT-X-PROGRAM-DATE-TIME:2019-02-14T02:14:00.106Z 1040 | #EXT-X-PART:DURATION=0.33334,URI="filePart272.a.mp4" 1041 | #EXT-X-PART:DURATION=0.33334,URI="filePart272.b.mp4" 1042 | #EXT-X-PART:DURATION=0.33334,URI="filePart272.c.mp4" 1043 | #EXT-X-PART:DURATION=0.33334,URI="filePart272.d.mp4" 1044 | #EXT-X-PART:DURATION=0.33334,URI="filePart272.e.mp4" 1045 | #EXT-X-PART:DURATION=0.33334,URI="filePart272.f.mp4",INDEPENDENT=YES 1046 | #EXT-X-PART:DURATION=0.33334,URI="filePart272.g.mp4" 1047 | #EXT-X-PART:DURATION=0.33334,URI="filePart272.h.mp4" 1048 | #EXT-X-PART:DURATION=0.33334,URI="filePart272.i.mp4" 1049 | #EXT-X-PART:DURATION=0.33334,URI="filePart272.j.mp4" 1050 | #EXT-X-PART:DURATION=0.33334,URI="filePart272.k.mp4" 1051 | #EXT-X-PART:DURATION=0.33334,URI="filePart272.l.mp4" 1052 | #EXTINF:4.00008, 1053 | fileSequence272.mp4 1054 | #EXT-X-PART:DURATION=0.33334,URI="filePart273.0.mp4",INDEPENDENT=YES 1055 | #EXT-X-PART:DURATION=0.33334,URI="filePart273.1.mp4" 1056 | #EXT-X-PART:DURATION=0.33334,URI="filePart273.2.mp4" 1057 | #EXT-X-PRELOAD-HINT:TYPE=PART,URI="filePart273.3.mp4" 1058 | 1059 | #EXT-X-RENDITION-REPORT:URI="../1M/waitForMSN.php",LAST-MSN=273,LAST-PART=2 1060 | #EXT-X-RENDITION-REPORT:URI="../4M/waitForMSN.php",LAST-MSN=273,LAST-PART=1 1061 | """ 1062 | 1063 | LOW_LATENCY_DELTA_UPDATE_PLAYLIST = """#EXTM3U 1064 | # 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 1065 | #EXT-X-TARGETDURATION:4 1066 | #EXT-X-VERSION:9 1067 | #EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=1.0,CAN-SKIP-UNTIL=12.0 1068 | #EXT-X-PART-INF:PART-TARGET=0.33334 1069 | #EXT-X-MEDIA-SEQUENCE:266 1070 | #EXT-X-SKIP:SKIPPED-SEGMENTS=3 1071 | #EXTINF:4.00008, 1072 | fileSequence269.ts 1073 | #EXTINF:4.00008, 1074 | fileSequence270.ts 1075 | #EXT-X-PART:DURATION=0.33334,URI="filePart271.0.ts" 1076 | #EXT-X-PART:DURATION=0.33334,URI="filePart271.1.ts" 1077 | #EXT-X-PART:DURATION=0.33334,URI="filePart271.2.ts" 1078 | #EXT-X-PART:DURATION=0.33334,URI="filePart271.3.ts" 1079 | #EXT-X-PART:DURATION=0.33334,URI="filePart271.4.ts",INDEPENDENT=YES 1080 | #EXT-X-PART:DURATION=0.33334,URI="filePart271.5.ts" 1081 | #EXT-X-PART:DURATION=0.33334,URI="filePart271.6.ts" 1082 | #EXT-X-PART:DURATION=0.33334,URI="filePart271.7.ts" 1083 | #EXT-X-PART:DURATION=0.33334,URI="filePart271.8.ts",INDEPENDENT=YES 1084 | #EXT-X-PART:DURATION=0.33334,URI="filePart271.9.ts" 1085 | #EXT-X-PART:DURATION=0.33334,URI="filePart271.10.ts" 1086 | #EXT-X-PART:DURATION=0.33334,URI="filePart271.11.ts" 1087 | #EXTINF:4.00008, 1088 | fileSequence271.ts 1089 | #EXT-X-PROGRAM-DATE-TIME:2019-02-14T02:14:00.106Z 1090 | #EXT-X-PART:DURATION=0.33334,URI="filePart272.a.ts" 1091 | #EXT-X-PART:DURATION=0.33334,URI="filePart272.b.ts" 1092 | #EXT-X-PART:DURATION=0.33334,URI="filePart272.c.ts" 1093 | #EXT-X-PART:DURATION=0.33334,URI="filePart272.d.ts" 1094 | #EXT-X-PART:DURATION=0.33334,URI="filePart272.e.ts" 1095 | #EXT-X-PART:DURATION=0.33334,URI="filePart272.f.ts",INDEPENDENT=YES 1096 | #EXT-X-PART:DURATION=0.33334,URI="filePart272.g.ts" 1097 | #EXT-X-PART:DURATION=0.33334,URI="filePart272.h.ts" 1098 | #EXT-X-PART:DURATION=0.33334,URI="filePart272.i.ts" 1099 | #EXT-X-PART:DURATION=0.33334,URI="filePart272.j.ts" 1100 | #EXT-X-PART:DURATION=0.33334,URI="filePart272.k.ts" 1101 | #EXT-X-PART:DURATION=0.33334,URI="filePart272.l.ts" 1102 | #EXTINF:4.00008, 1103 | fileSequence272.ts 1104 | #EXT-X-PART:DURATION=0.33334,URI="filePart273.0.ts",INDEPENDENT=YES 1105 | #EXT-X-PART:DURATION=0.33334,URI="filePart273.1.ts" 1106 | #EXT-X-PART:DURATION=0.33334,URI="filePart273.2.ts" 1107 | #EXT-X-PART:DURATION=0.33334,URI="filePart273.3.ts" 1108 | #EXT-X-PRELOAD-HINT:TYPE=PART,URI="filePart273.4.ts" 1109 | 1110 | #EXT-X-RENDITION-REPORT:URI="../1M/waitForMSN.php",LAST-MSN=273,LAST-PART=3 1111 | #EXT-X-RENDITION-REPORT:URI="../4M/waitForMSN.php",LAST-MSN=273,LAST-PART=3 1112 | """ 1113 | 1114 | LOW_LATENCY_OMITTED_ATTRIBUTES = """ 1115 | #EXTM3U 1116 | #EXT-X-VERSION:7 1117 | #EXT-X-TARGETDURATION:2 1118 | #EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=2.171 1119 | #EXT-X-PART-INF:PART-TARGET=1.034 1120 | #EXT-X-MAP:URI="init_data.m4s" 1121 | #EXT-X-MEDIA-SEQUENCE:6342 1122 | #EXT-X-PROGRAM-DATE-TIME:2024-09-24T15:31:57.350+00:00 1123 | #EXTINF:2, 1124 | chunk_6342.m4s 1125 | #EXT-X-PROGRAM-DATE-TIME:2024-09-24T15:31:59.350+00:00 1126 | #EXTINF:2, 1127 | chunk_6343.m4s 1128 | #EXT-X-PROGRAM-DATE-TIME:2024-09-24T15:32:01.350+00:00 1129 | #EXTINF:2, 1130 | chunk_6344.m4s 1131 | #EXT-X-PROGRAM-DATE-TIME:2024-09-24T15:32:03.350+00:00 1132 | #EXTINF:2, 1133 | chunk_6345.m4s 1134 | #EXT-X-PROGRAM-DATE-TIME:2024-09-24T15:32:05.350+00:00 1135 | #EXTINF:2, 1136 | chunk_6346.m4s 1137 | #EXT-X-PROGRAM-DATE-TIME:2024-09-24T15:32:07.350+00:00 1138 | #EXTINF:2, 1139 | chunk_6347.m4s 1140 | #EXT-X-PROGRAM-DATE-TIME:2024-09-24T15:32:09.350+00:00 1141 | #EXTINF:2, 1142 | chunk_6348.m4s 1143 | #EXT-X-PROGRAM-DATE-TIME:2024-09-24T15:32:11.350+00:00 1144 | #EXT-X-PART:DURATION=1,URI="chunk_6349.0.m4s",INDEPENDENT=YES 1145 | #EXT-X-PART:DURATION=1,URI="chunk_6349.1.m4s" 1146 | #EXTINF:2, 1147 | chunk_6349.m4s 1148 | #EXT-X-PROGRAM-DATE-TIME:2024-09-24T15:32:13.350+00:00 1149 | #EXT-X-PART:DURATION=1,URI="chunk_6350.0.m4s",INDEPENDENT=YES 1150 | #EXT-X-PART:DURATION=1,URI="chunk_6350.1.m4s" 1151 | #EXTINF:2, 1152 | chunk_6350.m4s 1153 | #EXT-X-PROGRAM-DATE-TIME:2024-09-24T15:32:15.350+00:00 1154 | #EXT-X-PART:DURATION=1,URI="chunk_6351.0.m4s?skid=default&signature=NjZmYzFjODBfYzY3NGRlODc4Zjk1MjM1OGNmMmE3NjhiM2E2NTUyNGI1Y2JiYzMyZDU5YTFjNTQzODI2MjI5ZTllZmNhMDZmNQ==&zone=1",INDEPENDENT=YES 1155 | #EXT-X-PART:DURATION=1,URI="chunk_6351.1.m4s?skid=default&signature=NjZmYzFjODBfMDcwMjA0OTZlMTE3Y2RiN2VjOGY2YjE2MDE2NTAwZThlN2Q3NjUyZTAzM2YxZTZlZmFlZTg1ZThmZWEyZmQ4Ng==&zone=1" 1156 | #EXTINF:2, 1157 | chunk_6350.m4s 1158 | #EXT-X-PRELOAD-HINT:TYPE=PART,URI="chunk_6352.0.m4s?skid=default&signature=NjZmYzFjODBfMzkyZmNiOWNjNmY5N2EwN2QwNTU3YTA3M2Q0ZTRlMWU2YjliZDMyM2Y0MTRmYTY5OTdhODIyMmIwY2QwOWY1NQ==&zone=1" 1159 | #EXT-X-RENDITION-REPORT:URI="rendition_1.m3u8" 1160 | #EXT-X-RENDITION-REPORT:URI="rendition_2.m3u8" 1161 | #EXT-X-RENDITION-REPORT:URI="rendition_3.m3u8" 1162 | #EXT-X-RENDITION-REPORT:URI="rendition_4.m3u8" 1163 | #EXT-X-RENDITION-REPORT:URI="rendition_5.m3u8" 1164 | """ 1165 | 1166 | LOW_LATENCY_WITH_PRELOAD_AND_BYTERANGES_PLAYLIST = """ 1167 | #EXTM3U 1168 | #EXTINF:4.08, 1169 | fs270.mp4 1170 | #EXT-X-PART:DURATION=1.02,URI="fs271.mp4",BYTERANGE=20000@0 1171 | #EXT-X-PART:DURATION=1.02,URI="fs271.mp4",BYTERANGE=23000@20000 1172 | #EXT-X-PART:DURATION=1.02,URI="fs271.mp4",BYTERANGE=18000@43000 1173 | #EXT-X-PRELOAD-HINT:TYPE=PART,URI="fs271.mp4",BYTERANGE-START=61000,BYTERANGE-LENGTH=20000 1174 | """ 1175 | 1176 | RELATIVE_PLAYLIST_FILENAME = abspath( 1177 | join(dirname(__file__), "playlists/relative-playlist.m3u8") 1178 | ) 1179 | 1180 | RELATIVE_PLAYLIST_URI = TEST_HOST + "/path/to/relative-playlist.m3u8" 1181 | 1182 | CUE_OUT_PLAYLIST_FILENAME = abspath(join(dirname(__file__), "playlists/cue_out.m3u8")) 1183 | 1184 | CUE_OUT_PLAYLIST_URI = TEST_HOST + "/path/to/cue_out.m3u8" 1185 | 1186 | VARIANT_PLAYLIST_WITH_FRAME_RATE = """ 1187 | #EXTM3U 1188 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1280000,FRAME-RATE=25 1189 | http://example.com/low.m3u8 1190 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=2560000,FRAME-RATE=50 1191 | http://example.com/mid.m3u8 1192 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=7680000,FRAME-RATE=60 1193 | http://example.com/hi.m3u8 1194 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=65000,FRAME-RATE=12.5,CODECS="mp4a.40.5,avc1.42801e" 1195 | http://example.com/audio-only.m3u8 1196 | """ 1197 | 1198 | VARIANT_PLAYLIST_WITH_ROUNDABLE_FRAME_RATE = """ 1199 | #EXTM3U 1200 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=65000,FRAME-RATE=12.54321,CODECS="mp4a.40.5,avc1.42801e" 1201 | http://example.com/audio-only.m3u8 1202 | """ 1203 | 1204 | VARIANT_PLAYLIST_WITH_ROUNDED_FRAME_RATE = """ 1205 | #EXTM3U 1206 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=65000,FRAME-RATE=12.543,CODECS="mp4a.40.5,avc1.42801e" 1207 | http://example.com/audio-only.m3u8 1208 | """ 1209 | 1210 | SESSION_DATA_PLAYLIST = """#EXTM3U 1211 | #EXT-X-VERSION:4 1212 | #EXT-X-SESSION-DATA:DATA-ID="com.example.value",VALUE="example",LANGUAGE="en" 1213 | """ 1214 | 1215 | MULTIPLE_SESSION_DATA_PLAYLIST = """#EXTM3U 1216 | #EXT-X-VERSION:4 1217 | #EXT-X-SESSION-DATA:DATA-ID="com.example.value",VALUE="example",LANGUAGE="en" 1218 | #EXT-X-SESSION-DATA:DATA-ID="com.example.value",VALUE="example",LANGUAGE="ru" 1219 | #EXT-X-SESSION-DATA:DATA-ID="com.example.value",VALUE="example",LANGUAGE="de" 1220 | #EXT-X-SESSION-DATA:DATA-ID="com.example.title",URI="title.json" 1221 | """ 1222 | 1223 | VERSION_PLAYLIST = """#EXTM3U 1224 | #EXT-X-VERSION:4 1225 | """ 1226 | 1227 | PLAYLIST_WITH_NEGATIVE_MEDIA_SEQUENCE = """ 1228 | #EXTM3U 1229 | #EXT-X-TARGETDURATION:5220 1230 | #EXT-X-MEDIA-SEQUENCE:-2680 1231 | #EXTINF:5220, 1232 | http://media.example.com/entire.ts 1233 | #EXT-X-ENDLIST 1234 | """ 1235 | 1236 | DATERANGE_SIMPLE_PLAYLIST = """ 1237 | #EXTM3U 1238 | #EXT-X-PROGRAM-DATE-TIME:2016-06-13T11:15:15Z 1239 | #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" 1240 | #EXTINF:10, 1241 | ad3.1.ts 1242 | #EXTINF:10, 1243 | ad3.2.ts 1244 | """ 1245 | 1246 | DATERANGE_SCTE35_OUT_AND_IN_PLAYLIST = """ 1247 | #EXTM3U 1248 | # adapted from https://tools.ietf.org/html/rfc8216#section-8.10 1249 | #EXT-X-PROGRAM-DATE-TIME:2014-03-05T11:15:00Z 1250 | #EXT-X-DATERANGE:ID="splice-6FFFFFF0",START-DATE="2014-03-05T11:15:00Z",PLANNED-DURATION=59.993,SCTE35-OUT=0xFC002F0000000000FF000014056FFFFFF000E011622DCAFF000052636200000000000A0008029896F50000008700000000 1251 | #EXTINF:10, 1252 | ad3.1.ts 1253 | #EXTINF:10, 1254 | ad3.2.ts 1255 | #EXTINF:10, 1256 | ad3.3.ts 1257 | #EXTINF:10, 1258 | ad3.4.ts 1259 | #EXTINF:10, 1260 | ad3.5.ts 1261 | #EXTINF:10, 1262 | ad3.6.ts 1263 | #EXT-X-DATERANGE:ID="splice-6FFFFFF0",DURATION=59.993,SCTE35-IN=0xFC002A0000000000FF00000F056FFFFFF000401162802E6100000000000A0008029896F50000008700000000 1264 | #EXTINF:10, 1265 | prog.1.ts 1266 | """ 1267 | 1268 | DATERANGE_ENDDATE_SCTECMD_PLAYLIST = """ 1269 | #EXTM3U 1270 | #EXT-X-PROGRAM-DATE-TIME:2020-03-11T10:51:00Z 1271 | #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 1272 | #EXTINF:10, 1273 | prog.1.ts 1274 | """ 1275 | 1276 | DATERANGE_IN_PART_PLAYLIST = """ 1277 | #EXTM3U 1278 | #EXT-X-PROGRAM-DATE-TIME:2020-03-10T07:48:00Z 1279 | #EXT-X-PART:DURATION=1,URI="filePart271.a.ts" 1280 | #EXT-X-PART:DURATION=1,URI="filePart271.b.ts" 1281 | #EXT-X-DATERANGE:ID="test_id",START-DATE="2020-03-10T07:48:02Z",CLASS="test_class",END-ON-NEXT=YES 1282 | #EXT-X-PART:DURATION=1,URI="filePart271.c.ts" 1283 | """ 1284 | 1285 | GAP_PLAYLIST = """ 1286 | #EXTM3U 1287 | #EXT-X-MEDIA-SEQUENCE:14 1288 | #EXT-X-VERSION:7 1289 | #EXT-X-TARGETDURATION:10 1290 | #EXTINF:9.84317, 1291 | fileSequence14.ts 1292 | #EXTINF:8.75875, 1293 | #EXT-X-GAP 1294 | missing-Sequence15.ts 1295 | #EXTINF:9.88487, 1296 | #EXT-X-GAP 1297 | missing-Sequence16.ts 1298 | #EXTINF:9.09242, 1299 | fileSequence17.ts 1300 | """ 1301 | 1302 | GAP_IN_PARTS_PLAYLIST = """ 1303 | #EXTM3U 1304 | #EXT-X-PART:DURATION=1,URI="filePart271.a.ts" 1305 | #EXT-X-PART:DURATION=1,URI="filePart271.b.ts",GAP=YES 1306 | #EXT-X-GAP 1307 | #EXT-X-PART:DURATION=1,URI="filePart271.c.ts" 1308 | """ 1309 | 1310 | PLAYLIST_WITH_SLASH_IN_QUERY_STRING = """ 1311 | #EXTM3U 1312 | #EXT-X-VERSION:3 1313 | #EXT-X-TARGETDURATION:5 1314 | #EXT-X-MEDIA-SEQUENCE:10599 1315 | #EXT-X-PROGRAM-DATE-TIME:2020-08-05T13:51:49.000+00:00 1316 | #EXTINF:5.0000, 1317 | testvideo-1596635509-4769390994-a0e3087c.ts?hdntl=exp=1596678764~acl=/*~data=hdntl~hmac=12345& 1318 | #EXTINF:5.0000, 1319 | testvideo-1596635514-4769840994-a0e00878.ts?hdntl=exp=1596678764~acl=/*~data=hdntl~hmac=12345& 1320 | #EXTINF:5.0000, 1321 | testvideo-1596635519-4770290994-a0e5087d.ts?hdntl=exp=1596678764~acl=/*~data=hdntl~hmac=12345& 1322 | #EXTINF:5.0000, 1323 | """ 1324 | 1325 | VARIANT_PLAYLIST_WITH_IFRAME_AVERAGE_BANDWIDTH = """ 1326 | #EXTM3U 1327 | #EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=624x352,CODECS="avc1.4d001f, mp4a.40.5" 1328 | video-800k.m3u8 1329 | #EXT-X-STREAM-INF:BANDWIDTH=1200000,CODECS="avc1.4d001f, mp4a.40.5" 1330 | video-1200k.m3u8 1331 | #EXT-X-STREAM-INF:BANDWIDTH=400000,CODECS="avc1.4d001f, mp4a.40.5" 1332 | video-400k.m3u8 1333 | #EXT-X-STREAM-INF:BANDWIDTH=150000,CODECS="avc1.4d001f, mp4a.40.5" 1334 | video-150k.m3u8 1335 | #EXT-X-STREAM-INF:BANDWIDTH=64000,CODECS="mp4a.40.5" 1336 | video-64k.m3u8 1337 | #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=151288,RESOLUTION=624x352,CODECS="avc1.4d001f",URI="video-800k-iframes.m3u8" 1338 | #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=193350,AVERAGE_BANDWIDTH=155000,CODECS="avc1.4d001f",URI="video-1200k-iframes.m3u8" 1339 | #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=83598,AVERAGE_BANDWIDTH=65000,CODECS="avc1.4d001f",URI="video-400k-iframes.m3u8" 1340 | #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=38775,AVERAGE_BANDWIDTH=30000,CODECS="avc1.4d001f",URI="video-150k-iframes.m3u8" 1341 | """ 1342 | 1343 | VARIANT_PLAYLIST_WITH_IFRAME_VIDEO_RANGE = """ 1344 | #EXTM3U 1345 | #EXT-X-STREAM-INF:PROGRAM-ID=1,VIDEO-RANGE=SDR 1346 | http://example.com/sdr.m3u8 1347 | #EXT-X-STREAM-INF:PROGRAM-ID=1,VIDEO-RANGE=PQ 1348 | http://example.com/hdr-pq.m3u8 1349 | #EXT-X-STREAM-INF:PROGRAM-ID=1,VIDEO-RANGE=HLG 1350 | http://example.com/hdr-hlg.m3u8 1351 | #EXT-X-I-FRAME-STREAM-INF:VIDEO_RANGE=SDR,URI="http://example.com/sdr-iframes.m3u8" 1352 | #EXT-X-I-FRAME-STREAM-INF:VIDEO_RANGE=PQ,URI="http://example.com/hdr-pq-iframes.m3u8" 1353 | #EXT-X-I-FRAME-STREAM-INF:VIDEO_RANGE=HLG,URI="http://example.com/hdr-hlg-iframes.m3u8" 1354 | #EXT-X-I-FRAME-STREAM-INF:URI="http://example.com/unknown-iframes.m3u8" 1355 | """ 1356 | 1357 | VARIANT_PLAYLIST_WITH_IFRAME_HDCP_LEVEL = """ 1358 | #EXTM3U 1359 | #EXT-X-STREAM-INF:PROGRAM-ID=1,HDCP-LEVEL=NONE 1360 | http://example.com/none.m3u8 1361 | #EXT-X-STREAM-INF:PROGRAM-ID=1,HDCP-LEVEL=TYPE-0 1362 | http://example.com/type0.m3u8 1363 | #EXT-X-STREAM-INF:PROGRAM-ID=1,HDCP-LEVEL=TYPE-1 1364 | http://example.com/type1.m3u8 1365 | #EXT-X-I-FRAME-STREAM-INF:HDCP-LEVEL=NONE,URI="http://example.com/none-iframes.m3u8" 1366 | #EXT-X-I-FRAME-STREAM-INF:HDCP-LEVEL=TYPE-0,URI="http://example.com/type0-iframes.m3u8" 1367 | #EXT-X-I-FRAME-STREAM-INF:HDCP-LEVEL=TYPE-1,URI="http://example.com/type1-iframes.m3u8" 1368 | #EXT-X-I-FRAME-STREAM-INF:URI="http://example.com/unknown-iframes.m3u8" 1369 | """ 1370 | 1371 | DELTA_UPDATE_SKIP_DATERANGES_PLAYLIST = """#EXTM3U 1372 | #EXT-X-VERSION:10 1373 | #EXT-X-TARGETDURATION:6 1374 | #EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=36,CAN-SKIP-DATERANGES=YES 1375 | #EXT-X-MEDIA-SEQUENCE:1 1376 | #EXT-X-MAP:URI="init.mp4" 1377 | #EXT-X-SKIP:SKIPPED-SEGMENTS=16,RECENTLY-REMOVED-DATERANGES="1" 1378 | #EXTINF:4.00000, 1379 | segment16.mp4 1380 | #EXTINF:4.00000, 1381 | segment17.mp4 1382 | #EXTINF:4.00000, 1383 | segment18.mp4 1384 | #EXTINF:4.00000, 1385 | segment19.mp4 1386 | #EXTINF:4.00000, 1387 | segment20.mp4 1388 | #EXTINF:4.00000, 1389 | segment21.mp4 1390 | #EXT-X-DATERANGE:ID="P" 1391 | #EXT-X-DATERANGE:ID="Q" 1392 | """ 1393 | 1394 | BITRATE_PLAYLIST = """ 1395 | #EXTM3U 1396 | #EXT-X-VERSION:3 1397 | #EXT-X-INDEPENDENT-SEGMENTS 1398 | #EXT-X-TARGETDURATION:10 1399 | #EXT-X-MEDIA-SEQUENCE:55119 1400 | #EXT-X-PROGRAM-DATE-TIME:2020-07-21T08:14:29.379Z 1401 | #EXT-X-BITRATE:1674 1402 | #EXTINF:9.600, 1403 | test1.ts 1404 | #EXT-X-BITRATE:1625 1405 | #EXTINF:9.600, 1406 | test2.ts 1407 | """ 1408 | 1409 | CONTENT_STEERING_PLAYLIST = """ 1410 | #EXTM3U 1411 | #EXT-X-CONTENT-STEERING:SERVER-URI="/steering?video=00012",PATHWAY-ID="CDN-A" 1412 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="A",NAME="English",DEFAULT=YES,URI="eng.m3u8",LANGUAGE="en" 1413 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="B",NAME="ENGLISH",DEFAULT=YES,URI="https://b.example.com/content/videos/video12/eng.m3u8",LANGUAGE="en" 1414 | #EXT-X-STREAM-INF:BANDWIDTH=1280000,AUDIO="A",PATHWAY-ID="CDN-A" 1415 | low/video.m3u8 1416 | #EXT-X-STREAM-INF:BANDWIDTH=7680000,AUDIO="A",PATHWAY-ID="CDN-A" 1417 | hi/video.m3u8 1418 | #EXT-X-STREAM-INF:BANDWIDTH=1280000,AUDIO="B",PATHWAY-ID="CDN-B" 1419 | https://backup.example.com/content/videos/video12/low/video.m3u8 1420 | #EXT-X-STREAM-INF:BANDWIDTH=7680000,AUDIO="B",PATHWAY-ID="CDN-B" 1421 | https://backup.example.com/content/videos/video12/hi/video.m3u8 1422 | #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=193350,CODECS="avc1.4d001f",URI="video-1200k-iframes.m3u8",PATHWAY-ID="CDN-A" 1423 | #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=193350,CODECS="avc1.4d001f",URI="https://backup.example.com/content/videos/video12/video-1200k-iframes.m3u8",PATHWAY-ID="CDN-B" 1424 | """ 1425 | 1426 | VARIANT_PLAYLIST_WITH_STABLE_VARIANT_ID = """ 1427 | #EXT-X-STREAM-INF:BANDWIDTH=1280000,STABLE-VARIANT-ID="eb9c6e4de930b36d9a67fbd38a30b39f865d98f4a203d2140bbf71fd58ad764e" 1428 | http://example.com/type0.m3u8 1429 | """ 1430 | 1431 | VARIANT_PLAYLIST_WITH_IFRAME_STABLE_VARIANT_ID = """ 1432 | #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=128000,STABLE-VARIANT-ID="415901312adff69b967a0644a54f8d00dc14004f36bc8293737e6b4251f60f3f",URI="http://example.com/type0-iframes.m3u8" 1433 | """ 1434 | 1435 | VARIANT_PLAYLIST_WITH_STABLE_RENDITION_ID = """ 1436 | #EXT-X-MEDIA:TYPE=AUDIO,NAME="audio-aac-eng",STABLE-RENDITION-ID="a8213e27c12a158ea8660e0fe8bdcac6072ca26d984e7e8603652bc61fdceffa",URI="http://example.com/eng.m3u8" 1437 | """ 1438 | 1439 | VARIANT_PLAYLIST_WITH_IMAGE_PLAYLISTS = """ 1440 | #EXTM3U 1441 | #EXT-X-VERSION:3 1442 | #EXT-X-INDEPENDENT-SEGMENTS 1443 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=464000,RESOLUTION=640x360,CODECS="avc1.77.30, mp4a.40.2" 1444 | index_0_av/new_index_0_av.m3u8 1445 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=764000,RESOLUTION=640x360,CODECS="avc1.77.30, mp4a.40.2" 1446 | index_1_av/new_index_1_av.m3u8 1447 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1062000,RESOLUTION=640x360,CODECS="avc1.77.30, mp4a.40.2" 1448 | index_2_av/new_index_2_av.m3u8 1449 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1563000,RESOLUTION=960x540,CODECS="avc1.77.30, mp4a.40.2" 1450 | index_3_av/new_index_3_av.m3u8 1451 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=64000,CODECS="mp4a.40.2" 1452 | index_0_a/new_index_0_a.m3u8S 1453 | #EXT-X-IMAGE-STREAM-INF:BANDWIDTH=16460,RESOLUTION=320x180,CODECS="jpeg",URI="5x2_320x180/320x180-5x2.m3u8" 1454 | #EXT-X-IMAGE-STREAM-INF:BANDWIDTH=32920,RESOLUTION=640x360,CODECS="jpeg",URI="5x2_640x360/640x360-5x2.m3u8" 1455 | """ 1456 | 1457 | VOD_IMAGE_PLAYLIST = """ 1458 | #EXTM3U 1459 | #EXT-X-VERSION:7 1460 | #EXT-X-TARGETDURATION:6 1461 | #EXT-X-MEDIA-SEQUENCE:0 1462 | #EXT-X-PLAYLIST-TYPE:VOD 1463 | #EXT-X-IMAGES-ONLY 1464 | #EXTINF:6.006, 1465 | preroll-ad-1.jpg 1466 | #EXTINF:6.006, 1467 | preroll-ad-2.jpg 1468 | #EXTINF:3.003, 1469 | preroll-ad-3.jpg 1470 | #EXT-X-DISCONTINUITY 1471 | #EXTINF:60.06, 1472 | #EXT-X-TILES:RESOLUTION=640x360,LAYOUT=5x2,DURATION=6.006 1473 | content-0.jpg 1474 | #EXTINF:60.06, 1475 | #EXT-X-TILES:RESOLUTION=640x360,LAYOUT=5x2,DURATION=6.006 1476 | content-1.jpg 1477 | #EXTINF:60.06, 1478 | #EXT-X-TILES:RESOLUTION=640x360,LAYOUT=5x2,DURATION=6.006 1479 | content-2.jpg 1480 | #EXTINF:60.06, 1481 | #EXT-X-TILES:RESOLUTION=640x360,LAYOUT=5x2,DURATION=6.006 1482 | content-3.jpg 1483 | #EXTINF:54.054, 1484 | #EXT-X-TILES:RESOLUTION=640x360,LAYOUT=5x2,DURATION=6.006 1485 | content-4.jpg 1486 | #EXT-X-DISCONTINUITY 1487 | #EXTINF:6.006, 1488 | midroll-ad-1.jpg 1489 | #EXTINF:6.006, 1490 | midroll-ad-2.jpg 1491 | #EXTINF:3.003, 1492 | midroll-ad-3.jpg 1493 | #EXT-X-DISCONTINUITY 1494 | #EXTINF:60.06, 1495 | #EXT-X-TILES:RESOLUTION=640x360,LAYOUT=5x2,DURATION=6.006 1496 | content-5.jpg 1497 | #EXTINF:60.06, 1498 | #EXT-X-TILES:RESOLUTION=640x360,LAYOUT=5x2,DURATION=6.006 1499 | content-6.jpg 1500 | #EXTINF:60.06, 1501 | #EXT-X-TILES:RESOLUTION=640x360,LAYOUT=5x2,DURATION=6.006 1502 | content-7.jpg 1503 | #EXT-X-ENDLIST 1504 | """ 1505 | 1506 | VOD_IMAGE_PLAYLIST2 = """ 1507 | #EXTM3U 1508 | #EXT-X-TARGETDURATION:6 1509 | #EXT-X-VERSION:7 1510 | #EXT-X-MEDIA-SEQUENCE:0 1511 | #EXT-X-PLAYLIST-TYPE:VOD 1512 | #EXT-X-IMAGES-ONLY 1513 | #EXTINF:6.006, 1514 | promo_1.jpg 1515 | #EXTINF:6.006, 1516 | promo_2.jpg 1517 | #EXTINF:3.003, 1518 | promo_3.jpg 1519 | #EXT-X-DISCONTINUITY 1520 | #EXTINF:24.024, 1521 | #EXT-X-TILES:RESOLUTION=640x360,LAYOUT=4x3,DURATION=2.002 1522 | movie_001.jpg 1523 | #EXTINF:24.024, 1524 | #EXT-X-TILES:RESOLUTION=640x360,LAYOUT=4x3,DURATION=2.002 1525 | movie_002.jpg 1526 | #EXTINF:24.024, 1527 | #EXT-X-TILES:RESOLUTION=640x360,LAYOUT=4x3,DURATION=2.002 1528 | movie_003.jpg 1529 | #EXTINF:24.024, 1530 | #EXT-X-TILES:RESOLUTION=640x360,LAYOUT=4x3,DURATION=2.002 1531 | movie_275.jpg 1532 | #EXT-X-DISCONTINUITY 1533 | #EXTINF:24.024, 1534 | #EXT-X-TILES:RESOLUTION=640x360,LAYOUT=4x3,DURATION=2.002 1535 | credits_2_0.jpg 1536 | #EXTINF:6.006, 1537 | #EXT-X-TILES:RESOLUTION=640x360,LAYOUT=4x3,DURATION=2.002 1538 | credits_2_1.jpg 1539 | #EXT-X-ENDLIST 1540 | """ 1541 | 1542 | LIVE_IMAGE_PLAYLIST = """ 1543 | #EXTM3U 1544 | #EXT-X-TARGETDURATION:6 1545 | #EXT-X-VERSION:7 1546 | #EXT-X-MEDIA-SEQUENCE:127228 1547 | #EXT-X-IMAGES-ONLY 1548 | #EXT-X-DISCONTINUITY-SEQUENCE:5 1549 | #EXT-X-PROGRAM-DATE-TIME:2019-04-17T19:28:12.046Z 1550 | #EXTINF:6.006, 1551 | content-123.jpg 1552 | #EXTINF:6.006, 1553 | content-124.jpg 1554 | #EXTINF:6.006, 1555 | content-125.jpg 1556 | #EXT-X-DISCONTINUITY 1557 | #EXT-X-PROGRAM-DATE-TIME:2019-04-17T19:28:30.064Z 1558 | #EXT-X-GAP 1559 | #EXTINF:6.006, 1560 | missing-midroll.jpg 1561 | #EXT-X-GAP 1562 | #EXTINF:6.006, 1563 | missing-midroll.jpg 1564 | #EXT-X-GAP 1565 | #EXTINF:3.003, 1566 | missing-midroll.jpg 1567 | #EXT-X-DISCONTINUITY 1568 | #EXT-X-PROGRAM-DATE-TIME:2019-04-17T19:28:45.079Z 1569 | #EXTINF:6.006, 1570 | content-128.jpg 1571 | #EXTINF:6.006, 1572 | content-129.jpg 1573 | #EXTINF:6.006, 1574 | content-130.jpg 1575 | #EXTINF:6.006, 1576 | content-131.jpg 1577 | """ 1578 | 1579 | WINDOWS_PLAYLIST = r"""\ 1580 | #EXTM3U 1581 | #EXT-X-VERSION:3 1582 | #EXT-X-INDEPENDENT-SEGMENTS 1583 | #EXT-X-TARGETDURATION:10 1584 | #EXT-X-MEDIA-SEQUENCE:55119 1585 | #EXT-X-PROGRAM-DATE-TIME:2024-10-11T09:53:30.001Z 1586 | #EXTINF:9.600, 1587 | C:\HLS Video\test1.ts 1588 | """ 1589 | 1590 | del abspath, dirname, join 1591 | --------------------------------------------------------------------------------