From b5ec10623052bf02acced7a16451edc7621a3bb0 Mon Sep 17 00:00:00 2001 From: hyugogirubato <65763543+hyugogirubato@users.noreply.github.com> Date: Sat, 3 Jun 2023 15:54:48 +0200 Subject: [PATCH] Release v2.1.0 --- .gitignore | 101 ----------------- .travis.yml | 23 ---- README.md | 90 +++++++++++---- pydash2hls/__init__.py | 4 +- pydash2hls/converter.py | 236 +++++++++++++++------------------------ pydash2hls/exceptions.py | 8 +- setup.py | 52 ++++----- 7 files changed, 194 insertions(+), 320 deletions(-) delete mode 100644 .gitignore delete mode 100644 .travis.yml diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 7bbc71c..0000000 --- a/.gitignore +++ /dev/null @@ -1,101 +0,0 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -env/ -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -*.egg-info/ -.installed.cfg -*.egg - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -.hypothesis/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# pyenv -.python-version - -# celery beat schedule file -celerybeat-schedule - -# SageMath parsed files -*.sage.py - -# dotenv -.env - -# virtualenv -.venv -venv/ -ENV/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 238144d..0000000 --- a/.travis.yml +++ /dev/null @@ -1,23 +0,0 @@ -language: python -matrix: - include: - - python: 3.4 - dist: trusty - sudo: false - - python: 3.5 - dist: trusty - sudo: false - - python: 3.5-dev - dist: trusty - sudo: false - - python: 3.6 - dist: trusty - sudo: false - - python: 3.6-dev - dist: trusty - sudo: false - - python: 3.7 - dist: xenial - sudo: true -install: - - python setup.py -q install diff --git a/README.md b/README.md index 0aaf154..af8bf92 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,77 @@ -# pydash2hls -[![License: GPL v3](https://img.shields.io/badge/License-GPL%20v3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) -[![Release](https://img.shields.io/github/release-date/hyugogirubato/pydash2hls?style=plastic)](https://github.com/hyugogirubato/pydash2hls/releases) -![Total Downloads](https://img.shields.io/github/downloads/hyugogirubato/pydash2hls/total.svg?style=plastic) +# PyDash2HLS -Python library to convert DASH file to HLS. +[![License](https://img.shields.io/github/license/hyugogirubato/PyDash2HLS)](https://github.com/hyugogirubato/PyDash2HLS/blob/master/LICENSE) +[![Release](https://img.shields.io/github/release-date/hyugogirubato/pydash2hls)](https://github.com/hyugogirubato/pydash2hls/releases) +[![Latest Version](https://img.shields.io/pypi/v/pydash2hls)](https://pypi.org/project/pydash2hls) -# Usage +PyDash2HLS is a Python library for converting DASH (Dynamic Adaptive Streaming over HTTP) manifest files to HLS (HTTP +Live Streaming) format. -### Basic Usage +## Installation -```python ->>> from pydash2hls import Converter ->>> converter = Converter.from_local('manifest.mpd') ->>> converter = Converter.from_remote(method='GET', url='https://...') # Recommended option ->>> profiles = converter.profile ->>> hls_content = converter.build_hls(profile_id=profiles[0]['id']) ->>> with open('index.m3u8', mode='w', encoding='utf-8') as f: -... f.write(hls_content) -... f.close() -``` +You can install PyDash2HLS using pip: -# Installation +````shell +pip install pydash2hls +```` + +## Usage + +### Converter Initialization + +To initialize the Converter class, you can use the following methods: + +#### Initialization from a Remote URL + +````python +from pydash2hls import Converter + +# Initialize Converter from a remote URL +url = "http://example.com/manifest.mpd" +converter = Converter.from_remote(url) +```` + +#### Initialization from a Local File + +````python +from pydash2hls import Converter +from pathlib import Path + +# Initialize Converter from a local file +file_path = Path("path/to/manifest.mpd") +converter = Converter.from_local(file_path) +```` + +### Building HLS Manifest + +To build an HLS manifest for a specific profile, you can use the `build_hls()` method: + +````python +# Build HLS manifest for a profile +profile_id = "profile1" +hls_manifest = converter.build_hls(profile_id) +```` + +### Getting Media URLs + +To retrieve a list of media URLs for a specific profile, you can use the `media_urls()` method: + +````python +# Get media URLs for a profile +profile_id = "profile1" +media_urls = converter.media_urls(profile_id) +```` + +### Exceptions + +The following exceptions can be raised by PyDash2HLS: + +- `InvalidPath`: Raised when the file path is invalid. +- `InvalidFileContent`: Raised when the contents of the file are not in DASH format or are incompatible. +- `InvalidProfile`: Raised when the selected profile is invalid. +- `MissingRemoteUrl`: Raised when a remote file URL is required but not provided. + +### License + +This project is licensed under the [GPL v3 License](LICENSE). -To install, you can either clone the repository and run `python setup.py install` diff --git a/pydash2hls/__init__.py b/pydash2hls/__init__.py index d8902b2..dad96ac 100644 --- a/pydash2hls/__init__.py +++ b/pydash2hls/__init__.py @@ -1,3 +1,3 @@ -from pydash2hls.converter import * +from .converter import * -__version__ = "2.0.1" +__version__ = "2.1.0" diff --git a/pydash2hls/converter.py b/pydash2hls/converter.py index d0d3b89..48c5bfe 100644 --- a/pydash2hls/converter.py +++ b/pydash2hls/converter.py @@ -1,204 +1,150 @@ from __future__ import annotations -import os +from pathlib import Path + import requests import xmltodict -from pydash2hls.exceptions import InvalidPath, InvalidFileContent, InvalidProfile, MissingRemoteUrl +from pydash2hls.exceptions import InvalidFileContent, InvalidPath, InvalidProfile, MissingRemoteUrl class Converter: - def __init__(self, mdp_srt, mdp_json, url): + def __init__(self, mdp_srt: str, mdp_dict: dict, url: str = None): self.mdp_srt = mdp_srt - self.mdp_json = mdp_json + self.mdp_dict = mdp_dict self.mdp_url = url - # init self.profile = self._manifest_profile() @classmethod - def from_remote(cls, method, url, **kwargs) -> Converter: - r = requests.request(method=method, url=url, **kwargs) - if not r.ok: - raise InvalidPath("Unable to load remote file.") - mdp_srt = r.content.decode('utf-8') + def from_remote(cls, url: str, **kwargs) -> Converter: + r = requests.request(method=kwargs.get("method", "GET"), url=url, **kwargs) + r.raise_for_status() + mdp_srt = r.text try: - mdp_json = xmltodict.parse(mdp_srt) + mdp_dict = xmltodict.parse(mdp_srt) except Exception as e: raise InvalidFileContent(f"Unable to load file, {e}") - return cls(mdp_srt, mdp_json, url) + return cls(mdp_srt, mdp_dict, url) @classmethod - def from_local(cls, path: str) -> Converter: - if not os.path.exists(path): - raise InvalidPath('Invalid file path.') - with open(path, mode='rb') as f: - mdp_srt = f.read().decode('utf-8') - f.close() + def from_local(cls, path: Path) -> Converter: + if not path.is_file(): + raise InvalidPath("Invalid file path.") + mdp_srt = path.read_text() try: - mdp_json = xmltodict.parse(mdp_srt) + mdp_dict = xmltodict.parse(mdp_srt) except Exception as e: raise InvalidFileContent(f"Unable to load file, {e}") - return cls(mdp_srt, mdp_json, None) + return cls(mdp_srt, mdp_dict) @staticmethod def _get_key(adaptation: dict, representation: dict, key: str) -> str: - value = representation[key] if key in representation else None - if value is None: - value = adaptation[key] if key in adaptation else None - return value + return representation.get(key, adaptation.get(key, None)) def _get_profile(self, profile_id: str) -> dict: - profile = None - for p in self.profile: - if p['id'] == profile_id: - profile = p - break - if profile is None: - raise InvalidProfile(f"Profile does not exist, {profile_id}") - return profile + for profile in self.profile: + if profile["id"] == profile_id: + return profile + raise InvalidProfile(f"Profile does not exist: {profile_id}") def _manifest_profile(self) -> list: - source = None if self.mdp_url is None else '/'.join(self.mdp_url.split('/')[:-1]) + source = None if self.mdp_url is None else "/".join(self.mdp_url.split("/")[:-1]) profiles = [] - for adaptation in self.mdp_json['MPD']['Period']['AdaptationSet']: + for adaptation in self.mdp_dict["MPD"]["Period"]["AdaptationSet"]: drm = {} - if 'ContentProtection' in adaptation: - for protection in adaptation['ContentProtection']: - KEYS = [ - ['@cenc:default_KID', 'kid'], - ['cenc:pssh', 'widevine'], - ['mspr:pro', 'playready'], - ['ms:laurl', 'license']] - - for key in KEYS: - if key[0] in protection: - drm[key[1]] = protection[key[0]] - - if type(adaptation['Representation']) == list: - for representation in adaptation['Representation']: - mime_type = self._get_key(adaptation, representation, '@mimeType') - if mime_type is None: - mime_type = 'video/mp4' if 'avc' in representation['@codecs'] else 'audio/m4a' - start_with_sap = self._get_key(adaptation, representation, '@startWithSAP') - start_with_sap = '1' if start_with_sap is None else start_with_sap + if "ContentProtection" in adaptation: + for protection in adaptation["ContentProtection"]: + keys = { + "kid": "@cenc:default_KID", + "widevine": "cenc:pssh", + "playready": "mspr:pro", + "license": "ms:laurl" + } + + for key, value in keys.items(): + if value in protection: + drm[key] = protection[value] + + if isinstance(adaptation["Representation"], list): + for representation in adaptation["Representation"]: + mime_type = self._get_key(adaptation, representation, "@mimeType") or ( + "video/mp4" if "avc" in representation["@codecs"] else "audio/m4a") + start_with_sap = self._get_key(adaptation, representation, "@startWithSAP") or "1" profile = { - 'id': representation['@id'], - 'mimeType': mime_type, - 'codecs': representation['@codecs'], - 'bandwidth': int(representation['@bandwidth']), - 'startWithSAP': start_with_sap + "id": representation["@id"], + "mimeType": mime_type, + "codecs": representation["@codecs"], + "bandwidth": int(representation["@bandwidth"]), + "startWithSAP": start_with_sap } - if 'audio' in profile['mimeType'] or '@audioSamplingRate' in representation: - profile['audioSamplingRate'] = representation['@audioSamplingRate'] + if "audio" in profile["mimeType"] or "@audioSamplingRate" in representation: + profile["audioSamplingRate"] = representation.get("@audioSamplingRate") else: - profile['width'] = int(representation['@width']) - profile['height'] = int(representation['@height']) - if '@frameRate' in representation: - frame_rate = representation['@frameRate'] - elif '@maxFrameRate ' in adaptation: - frame_rate = adaptation['@maxFrameRate'] - else: - frame_rate = '1/1' - frame_rate = frame_rate if '/' in frame_rate else f"{frame_rate}/1" - profile['frameRate'] = round(int(frame_rate.split('/')[0]) / int(frame_rate.split('/')[1]), 3) - profile['sar'] = representation.get('@sar', '1:1') - - # build urls + profile["width"] = int(representation["@width"]) + profile["height"] = int(representation["@height"]) + frame_rate = representation.get("@frameRate") or adaptation.get("@maxFrameRate") or "1/1" + frame_rate = frame_rate if "/" in frame_rate else f"{frame_rate}/1" + profile["frameRate"] = round(int(frame_rate.split("/")[0]) / int(frame_rate.split("/")[1]), 3) + profile["sar"] = representation.get("@sar", "1:1") + fragments = [] - if 'SegmentTemplate' in adaptation: - # Segment parts + if "SegmentTemplate" in adaptation: position = 0 - number = int(adaptation['SegmentTemplate']['@startNumber'] if '@startNumber' in adaptation['SegmentTemplate'] else 1) - 1 - timescale = int(adaptation['SegmentTemplate']['@timescale']) - timelines = adaptation['SegmentTemplate']['SegmentTimeline']['S'] + number = int(adaptation["SegmentTemplate"].get("@startNumber", 1)) - 1 + timescale = int(adaptation["SegmentTemplate"]["@timescale"]) + timelines = adaptation["SegmentTemplate"]["SegmentTimeline"]["S"] for timeline in timelines: - for i in range(int(timeline['@r']) + 1 if '@r' in timeline else 1): # Fixed segment offset - # init + for _ in range(int(timeline.get("@r", 1))): number += 1 - extinf = int(timelines[position]['@d']) / timescale - media = adaptation['SegmentTemplate']['@media'] - if not media.startswith('http'): + extinf = int(timelines[position]["@d"]) / timescale + media = adaptation["SegmentTemplate"]["@media"] + if not media.startswith("http"): if source is None: - raise MissingRemoteUrl("Remote manifest url required.") + raise MissingRemoteUrl("Remote manifest URL required.") media = f"{source}/{media}" - # build - if "$Number$" in media: - media = media.replace("$Number$", str(number)) - if "$Time$" in media: - time = timelines[position]['@t'] if '@t' in timelines[position] else 0 - time += int(timelines[position]['@d']) - media = media.replace("$Time$", str(time)) - if "$RepresentationID$" in media: - media = media.replace("$RepresentationID$", profile['id']) - if '$Bandwidth$' in media: - media = media.replace('$Bandwidth$', str(profile['bandwidth'])) + media = media.replace("$Number$", str(number)) + time = timelines[position].get("@t", 0) + int(timelines[position]["@d"]) + media = media.replace("$Time$", str(time)) + media = media.replace("$RepresentationID$", profile["id"]) + media = media.replace("$Bandwidth$", str(profile["bandwidth"])) + fragments.append({ - 'range': '0-', - 'extinf': f"{extinf:.3f}", - 'media': media + "range": "0-", + "extinf": f"{extinf:.3f}", + "media": media }) position += 1 else: - # Direct link - segment = representation['SegmentBase']['@indexRange'] - extinf = (int(segment.split('-')[1]) - int(segment.split('-')[0])) / 1000 + segment = representation["SegmentBase"]["@indexRange"] + start, end = map(int, segment.split("-")) + extinf = (end - start) / 1000 fragments.append({ - 'range': segment, - 'extinf': f"{extinf:.3f}", - 'media': f"{source}/{representation['BaseURL']}" + "range": segment, + "extinf": f"{extinf:.3f}", + "media": f"{source}/{representation['BaseURL']}" }) - profile['fragments'] = fragments - profile['drm'] = drm + profile["fragments"] = fragments + profile["drm"] = drm profiles.append(profile) else: - # Other sources pass return profiles def build_hls(self, profile_id: str) -> str: profile = self._get_profile(profile_id) - """ - if profile['fragments'][0]['media'].startswith('https://v.vrv.co/'): - # HLS: VRV.co master m3u8 - hls = ['#EXTM3U'] - KEYS = [ - 'f1-{T}1-x3', # 720 - 'f2-{T}1-x3', # 1080 - 'f3-{T}1-x3', # 480 - 'f4-{T}1-x3', # 360 - 'f5-{T}1-x3', # 240 - 'f6-{T}1-x3' # 80 - ] - source = profile['fragments'][0]['media'] - host = source.split('_,')[0] - cloudflare = source.split('?')[1] - files = source.split('_,')[1].split(',.urlset')[0].split(',') - audio = self._get_profile(KEYS[0].replace('{T}', 'a')) - for i in range(len(files)): - video = self._get_profile(KEYS[i].replace('{T}', 'v')) - hls.append(f'#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH={video["bandwidth"]},RESOLUTION={video["width"]}x{video["height"]},FRAME-RATE={video["frameRate"]},CODECS="{video["codecs"]},{audio["codecs"]}"') - hls.append(f"{host}_{files[i]}/index-v1-a1.m3u8?{cloudflare}") - else: - """ - - # HLS: index m3u8 - hls = ['#EXTM3U', '#EXT-X-TARGETDURATION:4', '#EXT-X-ALLOW-CACHE:YES', '#EXT-X-PLAYLIST-TYPE:VOD'] - if 'licenseUrl' in profile['drm']: - hls.append(f'#EXT-X-KEY:METHOD=SAMPLE-AES,URI="{profile["drm"]["license"]}"') - hls += ['#EXT-X-VERSION:5', '#EXT-X-MEDIA-SEQUENCE:1'] - for fragment in profile['fragments']: - hls.append(f"#EXTINF:{fragment['extinf']},") - hls.append(fragment['media']) - hls.append('#EXT-X-ENDLIST') - return '\n'.join(hls) + hls = ["#EXTM3U", "#EXT-X-TARGETDURATION:4", "#EXT-X-ALLOW-CACHE:YES", "#EXT-X-PLAYLIST-TYPE:VOD"] + licence = profile["drm"].get("license") + if licence: + hls.append(f'#EXT-X-KEY:METHOD=SAMPLE-AES,URI="{licence}"') + hls += ["#EXT-X-VERSION:5", "#EXT-X-MEDIA-SEQUENCE:1"] + hls.extend(f"#EXTINF:{fragment['extinf']},\n{fragment['media']}" for fragment in profile["fragments"]) + hls.append("#EXT-X-ENDLIST") + return "\n".join(hls) def media_urls(self, profile_id: str) -> list: profile = self._get_profile(profile_id) - urls = [] - for fragment in profile['fragments']: - urls.append(fragment['media']) - return urls + return [fragment["media"] for fragment in profile["fragments"]] diff --git a/pydash2hls/exceptions.py b/pydash2hls/exceptions.py index e3fddc0..dc5f63c 100644 --- a/pydash2hls/exceptions.py +++ b/pydash2hls/exceptions.py @@ -3,16 +3,16 @@ class PyDash2HLSException(Exception): class InvalidPath(PyDash2HLSException): - """File path is invalid.""" + """Invalid file path.""" class InvalidFileContent(PyDash2HLSException): - """Contents of the file is not a DASH file or is incompatible.""" + """The contents of the file are not in DASH format or are incompatible.""" class InvalidProfile(PyDash2HLSException): - """Selected profile is invalid.""" + """The selected profile is invalid.""" class MissingRemoteUrl(PyDash2HLSException): - """Remote file url is required.""" + """Remote file URL is missing.""" diff --git a/setup.py b/setup.py index 0e42af8..c20e4cb 100644 --- a/setup.py +++ b/setup.py @@ -1,30 +1,30 @@ -"""Setup module""" - -from setuptools import setup - -with open('README.md', 'r') as fh: - LONG_DESCRIPTION = fh.read() +from setuptools import setup, find_packages setup( - name='pydash2hls', - version='2.0.1', - description='Python library to convert DASH file to HLS.', - long_description=LONG_DESCRIPTION, - long_description_content_type='text/markdown', - url='https://github.com/hyugogirubato/pydash2hls', - author='hyugogirubato', - author_email='hyugogirubato@gmail.com', - license='GNU GPLv3', - packages=['pydash2hls'], - install_requires=['requests', 'xmltodict'], + name="pydash2hls", + version="2.1.0", + author="hyugogirubato", + author_email="hyugogirubato@gmail.com", + description="Python library for converting DASH manifest files to HLS format.", + long_description=open("README.md").read(), + long_description_content_type="text/markdown", + url="https://github.com/hyugogirubato/pydash2hls", + packages=find_packages(), + license="GPL-3.0-only", + keywords=["manifest", "hls", "m3u8", "dash"], classifiers=[ - 'Environment :: Console', - 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', - 'Operating System :: OS Independent', - 'Programming Language :: Python :: 3.4', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Topic :: Utilities' - ] + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Intended Audience :: End Users/Desktop", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Topic :: Utilities" + ], + install_requires=["requests", "xmltodict"], + python_requires=">=3.7" )