Release MediaFlow Proxy

This commit is contained in:
mhdzumair
2024-08-24 23:38:20 +05:30
commit 1ebbb74761
22 changed files with 3451 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
.env
.idea/
*.service
# Ignore all files under drm folder
mediaflow_proxy/drm/*
# Unignore specific files
!mediaflow_proxy/drm/__init__.py
!mediaflow_proxy/drm/decrypter.py
+2
View File
@@ -0,0 +1,2 @@
github: [mhdzumair]
+8
View File
@@ -0,0 +1,8 @@
version: 2
updates:
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
commit-message:
prefix: "dependabot"
+40
View File
@@ -0,0 +1,40 @@
name: MediaFlow Proxy CI/CD
on:
release:
types: [ created ]
jobs:
mediaflow_proxy_docker_build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
id: docker_build
uses: docker/build-push-action@v5
with:
context: .
file: ./deployment/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: |
mhdzumair/mediaflow-proxy:v${{ github.ref_name }}
mhdzumair/mediaflow-proxy:latest
- name: Image digest
run: echo ${{ steps.docker_build.outputs.digest }}
+140
View File
@@ -0,0 +1,140 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# 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/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
.idea/
*.service
# Ignore all files under drm folder
mediaflow_proxy/drm/*
# Unignore specific files
!mediaflow_proxy/drm/__init__.py
!mediaflow_proxy/drm/decrypter.py
+38
View File
@@ -0,0 +1,38 @@
FROM python:3.12-slim
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE="1"
ENV PYTHONUNBUFFERED="1"
ENV PORT="8888"
# Set work directory
WORKDIR /mediaflow_proxy
# Create a non-root user
RUN useradd -m mediaflow_proxy
RUN chown -R mediaflow_proxy:mediaflow_proxy /mediaflow_proxy
# Set up the PATH to include the user's local bin
ENV PATH="/home/mediaflow_proxy/.local/bin:$PATH"
# Switch to non-root user
USER mediaflow_proxy
# Install Poetry
RUN pip install --user --no-cache-dir poetry
# Copy only requirements to cache them in docker layer
COPY --chown=mediaflow_proxy:mediaflow_proxy pyproject.toml poetry.lock* /mediaflow_proxy/
# Project initialization:
RUN poetry config virtualenvs.in-project true \
&& poetry install --no-interaction --no-ansi --no-dev
# Copy project files
COPY --chown=mediaflow_proxy:mediaflow_proxy . /mediaflow_proxy
# Expose the port the app runs on
EXPOSE 8888
# Activate virtual environment and run the application with Gunicorn
CMD ["poetry", "run", "gunicorn", "mediaflow_proxy.main:app", "-w", "4", "-k", "uvicorn.workers.UvicornWorker", "--bind", "0.0.0.0:8888", "--timeout", "120", "--max-requests", "500", "--max-requests-jitter", "200"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) [2024] [Mohamed Zumair]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+186
View File
@@ -0,0 +1,186 @@
# MediaFlow Proxy
MediaFlow Proxy is a powerful and flexible solution for proxifying various types of media streams. It supports HTTP(S) links, HLS (M3U8) streams, and MPEG-DASH streams, including DRM-protected content. This proxy can convert MPEG-DASH DRM-protected streams to decrypted HLS live streams in real-time, making it one of the fastest live decrypter servers available.
## Features
- Convert MPEG-DASH streams (DRM-protected and non-protected) to HLS
- Support for Clear Key DRM-protected MPD DASH streams
- Support for non-DRM protected DASH live and VOD streams
- Proxy HTTP/HTTPS links with custom headers
- Proxy and modify HLS (M3U8) streams in real-time with custom headers and key URL modifications for bypassing some sneaky restrictions.
- Retrieve public IP address of the MediaFlow Proxy server for use with Debrid services
- Support for HTTP/HTTPS/SOCKS5 proxy forwarding
- Protect against unauthorized access and network bandwidth abuses
## Installation
### Using Docker from Docker Hub (Recommended)
1. Pull & Run the Docker image:
```
docker run -p 8888:8888 -e API_PASSWORD=your_password mhdzumair/mediaflow-proxy
```
### Using Poetry
1. Clone the repository:
```
git clone https://github.com/mhdzumair/mediaflow-proxy.git
cd mediaflow-proxy
```
2. Install dependencies using Poetry:
```
poetry install
```
3. Set the `API_PASSWORD` environment variable in `.env`:
```
echo "API_PASSWORD=your_password" > .env
```
4. Run the FastAPI server:
```
poetry run uvicorn mediaflow_proxy.main:app --host 0.0.0.0 --port 8888
```
### Build and Run Docker Image Locally
1. Build the Docker image:
```
docker build -t mediaflow-proxy .
```
2. Run the Docker container:
```
docker run -p 8888:8888 -e API_PASSWORD=your_password mediaflow-proxy
```
## Configuration
Set the following environment variables:
- `API_PASSWORD`: Required. Protects against unauthorized access and API network abuses.
- `PROXY_URL`: Optional. HTTP/HTTPS/SOCKS5 proxy URL for forwarding network requests.
- `MPD_LIVE_STREAM_DELAY`: Optional. Delay in seconds for live DASH streams. This is useful to prevent buffering issues with live streams. Default is `30` seconds.
## Usage
### Endpoints
1. `/proxy/hls`: Proxify HLS streams
2. `/proxy/stream`: Proxy generic http video streams
3. `/proxy/mpd/manifest`: Process MPD manifests
4. `/proxy/mpd/playlist`: Generate HLS playlists from MPD
5. `/proxy/mpd/segment`: Process and decrypt media segments
6. `/proxy/ip`: Get the public IP address of the MediaFlow Proxy server
Once the server is running, for more details on the available endpoints and their parameters, visit the Swagger UI at `http://localhost:8888/docs`.
### Examples
#### Proxy HTTPS Stream
```bash
mpv "http://localhost:8888/proxy/stream?d=https://jsoncompare.org/LearningContainer/SampleFiles/Video/MP4/sample-mp4-file.mp4&api_password=your_password"
```
#### Proxy HLS Stream with Headers
```bash
mpv "http://localhost:8888/proxy/hls?d=https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_fmp4/master.m3u8&h_referer=https://apple.com/&h_origin=https://apple.com&h_user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36&api_password=your_password"
```
#### Live DASH Stream (Non-DRM Protected)
```bash
mpv -v "http://localhost:8888/proxy/mpd/manifest?d=https://livesim.dashif.org/livesim/chunkdur_1/ato_7/testpic4_8s/Manifest.mpd&api_password=your_password"
```
#### VOD DASH Stream (DRM Protected)
```bash
mpv -v "http://localhost:8888/proxy/mpd/manifest?d=https://media.axprod.net/TestVectors/v7-MultiDRM-SingleKey/Manifest_1080p_ClearKey.mpd&key_id=nrQFDeRLSAKTLifXUIPiZg&key=FmY0xnWCPCNaSpRG-tUuTQ&api_password=your_password"
```
Note: The `key` and `key_id` parameters are automatically processed if they're not in the correct format.
### URL Encoding
For players like VLC that require properly encoded URLs, use the `encode_mediaflow_proxy_url` function:
```python
from mediaflow_proxy.utils.http_utils import encode_mediaflow_proxy_url
encoded_url = encode_mediaflow_proxy_url(
"http://127.0.0.1:8888",
"/proxy/mpd/manifest",
"https://media.axprod.net/TestVectors/v7-MultiDRM-SingleKey/Manifest_1080p_ClearKey.mpd",
{
"key_id": "nrQFDeRLSAKTLifXUIPiZg",
"key": "FmY0xnWCPCNaSpRG-tUuTQ",
"api_password": "your_password"
}
)
print(encoded_url)
```
This will output a properly encoded URL that can be used with players like VLC.
```bash
vlc "http://127.0.0.1:8888/proxy/mpd/manifest?key_id=nrQFDeRLSAKTLifXUIPiZg&key=FmY0xnWCPCNaSpRG-tUuTQ&api_password=dedsec&d=https%3A%2F%2Fmedia.axprod.net%2FTestVectors%2Fv7-MultiDRM-SingleKey%2FManifest_1080p_ClearKey.mpd"
```
### Using MediaFlow Proxy with Debrid Services and Stremio Addons
MediaFlow Proxy can be particularly useful when working with Debrid services (like Real-Debrid, AllDebrid) and Stremio addons. The `/proxy/ip` endpoint allows you to retrieve the public IP address of the MediaFlow Proxy server, which is crucial for routing Debrid streams correctly.
When a Stremio addon needs to create a video URL for a Debrid service, it typically needs to provide the user's public IP address. However, when routing the Debrid stream through MediaFlow Proxy, you should use the IP address of the MediaFlow Proxy server instead.
Here's how to utilize MediaFlow Proxy in this scenario:
1. If MediaFlow Proxy is accessible over the internet:
- Use the `/proxy/ip` endpoint to get the MediaFlow Proxy server's public IP.
- Use this IP when creating Debrid service URLs in your Stremio addon.
2. If MediaFlow Proxy is set up locally:
- Stremio addons can directly use the client's IP address.
## Future Development
- Add support for Widevine and PlayReady decryption
## Acknowledgements and Inspirations
MediaFlow Proxy was developed with inspiration from various projects and resources:
- [Stremio Server](https://github.com/Stremio/stremio-server) for HLS Proxify implementation, which inspired our HLS M3u8 Manifest parsing and redirection proxify support.
- [Comet Debrid proxy](https://github.com/g0ldyy/comet) for the idea of proxifying HTTPS video streams.
- [mp4decrypt](https://www.bento4.com/developers/dash/encryption_and_drm/), [mp4box](https://wiki.gpac.io/xmlformats/Common-Encryption/), and [devine](https://github.com/devine-dl/devine) for insights on parsing MPD and decrypting Clear Key DRM protected content.
- Test URLs were sourced from:
- [OTTVerse MPEG-DASH MPD Examples](https://ottverse.com/free-mpeg-dash-mpd-manifest-example-test-urls/)
- [OTTVerse HLS M3U8 Examples](https://ottverse.com/free-hls-m3u8-test-urls/)
- [Bitmovin Stream Test](https://bitmovin.com/demos/stream-test)
- [Bitmovin DRM Demo](https://bitmovin.com/demos/drm)
- [DASH-IF Reference Player](http://reference.dashif.org/dash.js/nightly/samples/)
- [HLS Protocol RFC](https://www.rfc-editor.org/rfc/rfc8216) for understanding the HLS protocol specifications.
- Claude 3.5 Sonnet for code assistance and brainstorming.
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
## License
[MIT License](LICENSE)
## Disclaimer
This project is for educational purposes only. The developers of MediaFlow Proxy are not responsible for any misuse of this software. Please ensure that you have the necessary permissions to access and use the media streams you are proxying.
View File
+14
View File
@@ -0,0 +1,14 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
api_password: str # The password for accessing the API endpoints.
proxy_url: str | None = None # The URL of the proxy server to route requests through.
mpd_live_stream_delay: int = 30 # The delay in seconds for live MPD streams.
class Config:
env_file = ".env"
extra = "ignore"
settings = Settings()
+11
View File
@@ -0,0 +1,11 @@
import os
import tempfile
async def create_temp_file(suffix: str, content: bytes = None, prefix: str = None) -> tempfile.NamedTemporaryFile:
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=suffix, prefix=prefix)
temp_file.delete_file = lambda: os.unlink(temp_file.name)
if content:
temp_file.write(content)
temp_file.close()
return temp_file
+778
View File
@@ -0,0 +1,778 @@
import argparse
import struct
import sys
from Crypto.Cipher import AES
from collections import namedtuple
import array
CENCSampleAuxiliaryDataFormat = namedtuple("CENCSampleAuxiliaryDataFormat", ["is_encrypted", "iv", "sub_samples"])
class MP4Atom:
"""
Represents an MP4 atom, which is a basic unit of data in an MP4 file.
Each atom contains a header (size and type) and data.
"""
__slots__ = ("atom_type", "size", "data")
def __init__(self, atom_type: bytes, size: int, data: memoryview | bytearray):
"""
Initializes an MP4Atom instance.
Args:
atom_type (bytes): The type of the atom.
size (int): The size of the atom.
data (memoryview | bytearray): The data contained in the atom.
"""
self.atom_type = atom_type
self.size = size
self.data = data
def __repr__(self):
return f"<MP4Atom type={self.atom_type}, size={self.size}>"
def pack(self):
"""
Packs the atom into binary data.
Returns:
bytes: Packed binary data with size, type, and data.
"""
return struct.pack(">I", self.size) + self.atom_type + self.data
class MP4Parser:
"""
Parses MP4 data to extract atoms and their structure.
"""
def __init__(self, data: memoryview):
"""
Initializes an MP4Parser instance.
Args:
data (memoryview): The binary data of the MP4 file.
"""
self.data = data
self.position = 0
def read_atom(self) -> MP4Atom | None:
"""
Reads the next atom from the data.
Returns:
MP4Atom | None: MP4Atom object or None if no more atoms are available.
"""
pos = self.position
if pos + 8 > len(self.data):
return None
size, atom_type = struct.unpack_from(">I4s", self.data, pos)
pos += 8
if size == 1:
if pos + 8 > len(self.data):
return None
size = struct.unpack_from(">Q", self.data, pos)[0]
pos += 8
if size < 8 or pos + size - 8 > len(self.data):
return None
atom_data = self.data[pos : pos + size - 8]
self.position = pos + size - 8
return MP4Atom(atom_type, size, atom_data)
def list_atoms(self) -> list[MP4Atom]:
"""
Lists all atoms in the data.
Returns:
list[MP4Atom]: List of MP4Atom objects.
"""
atoms = []
original_position = self.position
self.position = 0
while self.position + 8 <= len(self.data):
atom = self.read_atom()
if not atom:
break
atoms.append(atom)
self.position = original_position
return atoms
def _read_atom_at(self, pos: int, end: int) -> MP4Atom | None:
if pos + 8 > end:
return None
size, atom_type = struct.unpack_from(">I4s", self.data, pos)
pos += 8
if size == 1:
if pos + 8 > end:
return None
size = struct.unpack_from(">Q", self.data, pos)[0]
pos += 8
if size < 8 or pos + size - 8 > end:
return None
atom_data = self.data[pos : pos + size - 8]
return MP4Atom(atom_type, size, atom_data)
def print_atoms_structure(self, indent: int = 0):
"""
Prints the structure of all atoms in the data.
Args:
indent (int): The indentation level for printing.
"""
pos = 0
end = len(self.data)
while pos + 8 <= end:
atom = self._read_atom_at(pos, end)
if not atom:
break
self.print_single_atom_structure(atom, pos, indent)
pos += atom.size
def print_single_atom_structure(self, atom: MP4Atom, parent_position: int, indent: int):
"""
Prints the structure of a single atom.
Args:
atom (MP4Atom): The atom to print.
parent_position (int): The position of the parent atom.
indent (int): The indentation level for printing.
"""
try:
atom_type = atom.atom_type.decode("utf-8")
except UnicodeDecodeError:
atom_type = repr(atom.atom_type)
print(" " * indent + f"Type: {atom_type}, Size: {atom.size}")
child_pos = 0
child_end = len(atom.data)
while child_pos + 8 <= child_end:
child_atom = self._read_atom_at(parent_position + 8 + child_pos, parent_position + 8 + child_end)
if not child_atom:
break
self.print_single_atom_structure(child_atom, parent_position, indent + 2)
child_pos += child_atom.size
class MP4Decrypter:
"""
Class to handle the decryption of CENC encrypted MP4 segments.
Attributes:
key_map (dict[bytes, bytes]): Mapping of track IDs to decryption keys.
current_key (bytes | None): Current decryption key.
trun_sample_sizes (array.array): Array of sample sizes from the 'trun' box.
current_sample_info (list): List of sample information from the 'senc' box.
encryption_overhead (int): Total size of encryption-related boxes.
"""
def __init__(self, key_map: dict[bytes, bytes]):
"""
Initializes the MP4Decrypter with a key map.
Args:
key_map (dict[bytes, bytes]): Mapping of track IDs to decryption keys.
"""
self.key_map = key_map
self.current_key = None
self.trun_sample_sizes = array.array("I")
self.current_sample_info = []
self.encryption_overhead = 0
def decrypt_segment(self, combined_segment: bytes) -> bytes:
"""
Decrypts a combined MP4 segment.
Args:
combined_segment (bytes): Combined initialization and media segment.
Returns:
bytes: Decrypted segment content.
"""
data = memoryview(combined_segment)
parser = MP4Parser(data)
atoms = parser.list_atoms()
atom_process_order = [b"moov", b"moof", b"sidx", b"mdat"]
processed_atoms = {}
for atom_type in atom_process_order:
if atom := next((a for a in atoms if a.atom_type == atom_type), None):
processed_atoms[atom_type] = self._process_atom(atom_type, atom)
result = bytearray()
for atom in atoms:
if atom.atom_type in processed_atoms:
processed_atom = processed_atoms[atom.atom_type]
result.extend(processed_atom.pack())
else:
result.extend(atom.pack())
return bytes(result)
def _process_atom(self, atom_type: bytes, atom: MP4Atom) -> MP4Atom:
"""
Processes an MP4 atom based on its type.
Args:
atom_type (bytes): Type of the atom.
atom (MP4Atom): The atom to process.
Returns:
MP4Atom: Processed atom.
"""
match atom_type:
case b"moov":
return self._process_moov(atom)
case b"moof":
return self._process_moof(atom)
case b"sidx":
return self._process_sidx(atom)
case b"mdat":
return self._decrypt_mdat(atom)
case _:
return atom
def _process_moov(self, moov: MP4Atom) -> MP4Atom:
"""
Processes the 'moov' (Movie) atom, which contains metadata about the entire presentation.
This includes information about tracks, media data, and other movie-level metadata.
Args:
moov (MP4Atom): The 'moov' atom to process.
Returns:
MP4Atom: Processed 'moov' atom with updated track information.
"""
parser = MP4Parser(moov.data)
new_moov_data = bytearray()
for atom in iter(parser.read_atom, None):
if atom.atom_type == b"trak":
new_trak = self._process_trak(atom)
new_moov_data.extend(new_trak.pack())
elif atom.atom_type != b"pssh":
# Skip PSSH boxes as they are not needed in the decrypted output
new_moov_data.extend(atom.pack())
return MP4Atom(b"moov", len(new_moov_data) + 8, new_moov_data)
def _process_moof(self, moof: MP4Atom) -> MP4Atom:
"""
Processes the 'moov' (Movie) atom, which contains metadata about the entire presentation.
This includes information about tracks, media data, and other movie-level metadata.
Args:
moov (MP4Atom): The 'moov' atom to process.
Returns:
MP4Atom: Processed 'moov' atom with updated track information.
"""
parser = MP4Parser(moof.data)
new_moof_data = bytearray()
for atom in iter(parser.read_atom, None):
if atom.atom_type == b"traf":
new_traf = self._process_traf(atom)
new_moof_data.extend(new_traf.pack())
else:
new_moof_data.extend(atom.pack())
return MP4Atom(b"moof", len(new_moof_data) + 8, new_moof_data)
def _process_traf(self, traf: MP4Atom) -> MP4Atom:
"""
Processes the 'traf' (Track Fragment) atom, which contains information about a track fragment.
This includes sample information, sample encryption data, and other track-level metadata.
Args:
traf (MP4Atom): The 'traf' atom to process.
Returns:
MP4Atom: Processed 'traf' atom with updated sample information.
"""
parser = MP4Parser(traf.data)
new_traf_data = bytearray()
tfhd = None
sample_count = 0
sample_info = []
atoms = parser.list_atoms()
# calculate encryption_overhead earlier to avoid dependency on trun
self.encryption_overhead = sum(a.size for a in atoms if a.atom_type in {b"senc", b"saiz", b"saio"})
for atom in atoms:
if atom.atom_type == b"tfhd":
tfhd = atom
new_traf_data.extend(atom.pack())
elif atom.atom_type == b"trun":
sample_count = self._process_trun(atom)
new_trun = self._modify_trun(atom)
new_traf_data.extend(new_trun.pack())
elif atom.atom_type == b"senc":
# Parse senc but don't include it in the new decrypted traf data and similarly don't include saiz and saio
sample_info = self._parse_senc(atom, sample_count)
elif atom.atom_type not in {b"saiz", b"saio"}:
new_traf_data.extend(atom.pack())
if tfhd:
tfhd_track_id = struct.unpack_from(">I", tfhd.data, 4)[0]
self.current_key = self._get_key_for_track(tfhd_track_id)
self.current_sample_info = sample_info
return MP4Atom(b"traf", len(new_traf_data) + 8, new_traf_data)
def _decrypt_mdat(self, mdat: MP4Atom) -> MP4Atom:
"""
Decrypts the 'mdat' (Media Data) atom, which contains the actual media data (audio, video, etc.).
The decryption is performed using the current decryption key and sample information.
Args:
mdat (MP4Atom): The 'mdat' atom to decrypt.
Returns:
MP4Atom: Decrypted 'mdat' atom with decrypted media data.
"""
if not self.current_key or not self.current_sample_info:
return mdat # Return original mdat if we don't have decryption info
decrypted_samples = bytearray()
mdat_data = mdat.data
position = 0
for i, info in enumerate(self.current_sample_info):
if position >= len(mdat_data):
break # No more data to process
sample_size = self.trun_sample_sizes[i] if i < len(self.trun_sample_sizes) else len(mdat_data) - position
sample = mdat_data[position : position + sample_size]
position += sample_size
decrypted_sample = self._process_sample(sample, info, self.current_key)
decrypted_samples.extend(decrypted_sample)
return MP4Atom(b"mdat", len(decrypted_samples) + 8, decrypted_samples)
def _parse_senc(self, senc: MP4Atom, sample_count: int) -> list[CENCSampleAuxiliaryDataFormat]:
"""
Parses the 'senc' (Sample Encryption) atom, which contains encryption information for samples.
This includes initialization vectors (IVs) and sub-sample encryption data.
Args:
senc (MP4Atom): The 'senc' atom to parse.
sample_count (int): The number of samples.
Returns:
list[CENCSampleAuxiliaryDataFormat]: List of sample auxiliary data formats with encryption information.
"""
data = memoryview(senc.data)
version_flags = struct.unpack_from(">I", data, 0)[0]
version, flags = version_flags >> 24, version_flags & 0xFFFFFF
position = 4
if version == 0:
sample_count = struct.unpack_from(">I", data, position)[0]
position += 4
sample_info = []
for _ in range(sample_count):
if position + 8 > len(data):
break
iv = data[position : position + 8].tobytes()
position += 8
sub_samples = []
if flags & 0x000002 and position + 2 <= len(data): # Check if subsample information is present
subsample_count = struct.unpack_from(">H", data, position)[0]
position += 2
for _ in range(subsample_count):
if position + 6 <= len(data):
clear_bytes, encrypted_bytes = struct.unpack_from(">HI", data, position)
position += 6
sub_samples.append((clear_bytes, encrypted_bytes))
else:
break
sample_info.append(CENCSampleAuxiliaryDataFormat(True, iv, sub_samples))
return sample_info
def _get_key_for_track(self, track_id: int) -> bytes:
"""
Retrieves the decryption key for a given track ID from the key map.
Args:
track_id (int): The track ID.
Returns:
bytes: The decryption key for the specified track ID.
"""
if len(self.key_map) == 1:
return next(iter(self.key_map.values()))
key = self.key_map.get(track_id.pack(4, "big"))
if not key:
raise ValueError(f"No key found for track ID {track_id}")
return key
@staticmethod
def _process_sample(
sample: memoryview, sample_info: CENCSampleAuxiliaryDataFormat, key: bytes
) -> memoryview | bytearray | bytes:
"""
Processes and decrypts a sample using the provided sample information and decryption key.
This includes handling sub-sample encryption if present.
Args:
sample (memoryview): The sample data.
sample_info (CENCSampleAuxiliaryDataFormat): The sample auxiliary data format with encryption information.
key (bytes): The decryption key.
Returns:
memoryview | bytearray | bytes: The decrypted sample.
"""
if not sample_info.is_encrypted:
return sample
# pad IV to 16 bytes
iv = sample_info.iv + b"\x00" * (16 - len(sample_info.iv))
cipher = AES.new(key, AES.MODE_CTR, initial_value=iv, nonce=b"")
if not sample_info.sub_samples:
# If there are no sub_samples, decrypt the entire sample
return cipher.decrypt(sample)
result = bytearray()
offset = 0
for clear_bytes, encrypted_bytes in sample_info.sub_samples:
result.extend(sample[offset : offset + clear_bytes])
offset += clear_bytes
result.extend(cipher.decrypt(sample[offset : offset + encrypted_bytes]))
offset += encrypted_bytes
# If there's any remaining data, treat it as encrypted
if offset < len(sample):
result.extend(cipher.decrypt(sample[offset:]))
return result
def _process_trun(self, trun: MP4Atom) -> int:
"""
Processes the 'trun' (Track Fragment Run) atom, which contains information about the samples in a track fragment.
This includes sample sizes, durations, flags, and composition time offsets.
Args:
trun (MP4Atom): The 'trun' atom to process.
Returns:
int: The number of samples in the 'trun' atom.
"""
trun_flags, sample_count = struct.unpack_from(">II", trun.data, 0)
data_offset = 8
if trun_flags & 0x000001:
data_offset += 4
if trun_flags & 0x000004:
data_offset += 4
self.trun_sample_sizes = array.array("I")
for _ in range(sample_count):
if trun_flags & 0x000100: # sample-duration-present flag
data_offset += 4
if trun_flags & 0x000200: # sample-size-present flag
sample_size = struct.unpack_from(">I", trun.data, data_offset)[0]
self.trun_sample_sizes.append(sample_size)
data_offset += 4
else:
self.trun_sample_sizes.append(0) # Using 0 instead of None for uniformity in the array
if trun_flags & 0x000400: # sample-flags-present flag
data_offset += 4
if trun_flags & 0x000800: # sample-composition-time-offsets-present flag
data_offset += 4
return sample_count
def _modify_trun(self, trun: MP4Atom) -> MP4Atom:
"""
Modifies the 'trun' (Track Fragment Run) atom to update the data offset.
This is necessary to account for the encryption overhead.
Args:
trun (MP4Atom): The 'trun' atom to modify.
Returns:
MP4Atom: Modified 'trun' atom with updated data offset.
"""
trun_data = bytearray(trun.data)
current_flags = struct.unpack_from(">I", trun_data, 0)[0] & 0xFFFFFF
# If the data-offset-present flag is set, update the data offset to account for encryption overhead
if current_flags & 0x000001:
current_data_offset = struct.unpack_from(">i", trun_data, 8)[0]
struct.pack_into(">i", trun_data, 8, current_data_offset - self.encryption_overhead)
return MP4Atom(b"trun", len(trun_data) + 8, trun_data)
def _process_sidx(self, sidx: MP4Atom) -> MP4Atom:
"""
Processes the 'sidx' (Segment Index) atom, which contains indexing information for media segments.
This includes references to media segments and their durations.
Args:
sidx (MP4Atom): The 'sidx' atom to process.
Returns:
MP4Atom: Processed 'sidx' atom with updated segment references.
"""
sidx_data = bytearray(sidx.data)
current_size = struct.unpack_from(">I", sidx_data, 32)[0]
reference_type = current_size >> 31
current_referenced_size = current_size & 0x7FFFFFFF
# Remove encryption overhead from referenced size
new_referenced_size = current_referenced_size - self.encryption_overhead
new_size = (reference_type << 31) | new_referenced_size
struct.pack_into(">I", sidx_data, 32, new_size)
return MP4Atom(b"sidx", len(sidx_data) + 8, sidx_data)
def _process_trak(self, trak: MP4Atom) -> MP4Atom:
"""
Processes the 'trak' (Track) atom, which contains information about a single track in the movie.
This includes track header, media information, and other track-level metadata.
Args:
trak (MP4Atom): The 'trak' atom to process.
Returns:
MP4Atom: Processed 'trak' atom with updated track information.
"""
parser = MP4Parser(trak.data)
new_trak_data = bytearray()
for atom in iter(parser.read_atom, None):
if atom.atom_type == b"mdia":
new_mdia = self._process_mdia(atom)
new_trak_data.extend(new_mdia.pack())
else:
new_trak_data.extend(atom.pack())
return MP4Atom(b"trak", len(new_trak_data) + 8, new_trak_data)
def _process_mdia(self, mdia: MP4Atom) -> MP4Atom:
"""
Processes the 'mdia' (Media) atom, which contains media information for a track.
This includes media header, handler reference, and media information container.
Args:
mdia (MP4Atom): The 'mdia' atom to process.
Returns:
MP4Atom: Processed 'mdia' atom with updated media information.
"""
parser = MP4Parser(mdia.data)
new_mdia_data = bytearray()
for atom in iter(parser.read_atom, None):
if atom.atom_type == b"minf":
new_minf = self._process_minf(atom)
new_mdia_data.extend(new_minf.pack())
else:
new_mdia_data.extend(atom.pack())
return MP4Atom(b"mdia", len(new_mdia_data) + 8, new_mdia_data)
def _process_minf(self, minf: MP4Atom) -> MP4Atom:
"""
Processes the 'minf' (Media Information) atom, which contains information about the media data in a track.
This includes data information, sample table, and other media-level metadata.
Args:
minf (MP4Atom): The 'minf' atom to process.
Returns:
MP4Atom: Processed 'minf' atom with updated media information.
"""
parser = MP4Parser(minf.data)
new_minf_data = bytearray()
for atom in iter(parser.read_atom, None):
if atom.atom_type == b"stbl":
new_stbl = self._process_stbl(atom)
new_minf_data.extend(new_stbl.pack())
else:
new_minf_data.extend(atom.pack())
return MP4Atom(b"minf", len(new_minf_data) + 8, new_minf_data)
def _process_stbl(self, stbl: MP4Atom) -> MP4Atom:
"""
Processes the 'stbl' (Sample Table) atom, which contains information about the samples in a track.
This includes sample descriptions, sample sizes, sample times, and other sample-level metadata.
Args:
stbl (MP4Atom): The 'stbl' atom to process.
Returns:
MP4Atom: Processed 'stbl' atom with updated sample information.
"""
parser = MP4Parser(stbl.data)
new_stbl_data = bytearray()
for atom in iter(parser.read_atom, None):
if atom.atom_type == b"stsd":
new_stsd = self._process_stsd(atom)
new_stbl_data.extend(new_stsd.pack())
else:
new_stbl_data.extend(atom.pack())
return MP4Atom(b"stbl", len(new_stbl_data) + 8, new_stbl_data)
def _process_stsd(self, stsd: MP4Atom) -> MP4Atom:
"""
Processes the 'stsd' (Sample Description) atom, which contains descriptions of the sample entries in a track.
This includes codec information, sample entry details, and other sample description metadata.
Args:
stsd (MP4Atom): The 'stsd' atom to process.
Returns:
MP4Atom: Processed 'stsd' atom with updated sample descriptions.
"""
parser = MP4Parser(stsd.data)
entry_count = struct.unpack_from(">I", parser.data, 4)[0]
new_stsd_data = bytearray(stsd.data[:8])
parser.position = 8 # Move past version_flags and entry_count
for _ in range(entry_count):
sample_entry = parser.read_atom()
if not sample_entry:
break
processed_entry = self._process_sample_entry(sample_entry)
new_stsd_data.extend(processed_entry.pack())
return MP4Atom(b"stsd", len(new_stsd_data) + 8, new_stsd_data)
def _process_sample_entry(self, entry: MP4Atom) -> MP4Atom:
"""
Processes a sample entry atom, which contains information about a specific type of sample.
This includes codec-specific information and other sample entry details.
Args:
entry (MP4Atom): The sample entry atom to process.
Returns:
MP4Atom: Processed sample entry atom with updated information.
"""
# Determine the size of fixed fields based on sample entry type
if entry.atom_type in {b"mp4a", b"enca"}:
fixed_size = 28 # 8 bytes for size, type and reserved, 20 bytes for fixed fields in Audio Sample Entry.
elif entry.atom_type in {b"mp4v", b"encv", b"avc1", b"hev1", b"hvc1"}:
fixed_size = 78 # 8 bytes for size, type and reserved, 70 bytes for fixed fields in Video Sample Entry.
else:
fixed_size = 16 # 8 bytes for size, type and reserved, 8 bytes for fixed fields in other Sample Entries.
new_entry_data = bytearray(entry.data[:fixed_size])
parser = MP4Parser(entry.data[fixed_size:])
codec_format = None
for atom in iter(parser.read_atom, None):
if atom.atom_type in {b"sinf", b"schi", b"tenc", b"schm"}:
if atom.atom_type == b"sinf":
codec_format = self._extract_codec_format(atom)
continue # Skip encryption-related atoms
new_entry_data.extend(atom.pack())
# Replace the atom type with the extracted codec format
new_type = codec_format if codec_format else entry.atom_type
return MP4Atom(new_type, len(new_entry_data) + 8, new_entry_data)
def _extract_codec_format(self, sinf: MP4Atom) -> bytes | None:
"""
Extracts the codec format from the 'sinf' (Protection Scheme Information) atom.
This includes information about the original format of the protected content.
Args:
sinf (MP4Atom): The 'sinf' atom to extract from.
Returns:
bytes | None: The codec format or None if not found.
"""
parser = MP4Parser(sinf.data)
for atom in iter(parser.read_atom, None):
if atom.atom_type == b"frma":
return atom.data
return None
def decrypt_segment(init_segment: bytes, segment_content: bytes, key_id: str, key: str) -> bytes:
"""
Decrypts a CENC encrypted MP4 segment.
Args:
init_segment (bytes): Initialization segment data.
segment_content (bytes): Encrypted segment content.
key_id (str): Key ID in hexadecimal format.
key (str): Key in hexadecimal format.
"""
key_map = {bytes.fromhex(key_id): bytes.fromhex(key)}
decrypter = MP4Decrypter(key_map)
decrypted_content = decrypter.decrypt_segment(init_segment + segment_content)
return decrypted_content
def cli():
"""
Command line interface for decrypting a CENC encrypted MP4 segment.
"""
init_segment = b""
if args.init and args.segment:
with open(args.init, "rb") as f:
init_segment = f.read()
with open(args.segment, "rb") as f:
segment_content = f.read()
elif args.combined_segment:
with open(args.combined_segment, "rb") as f:
segment_content = f.read()
else:
print("Usage: python mp4decrypt.py --help")
sys.exit(1)
try:
decrypted_segment = decrypt_segment(init_segment, segment_content, args.key_id, args.key)
print(f"Decrypted content size is {len(decrypted_segment)} bytes")
with open(args.output, "wb") as f:
f.write(decrypted_segment)
print(f"Decrypted segment written to {args.output}")
except Exception as e:
print(f"Error: {e}")
sys.exit(1)
if __name__ == "__main__":
arg_parser = argparse.ArgumentParser(description="Decrypts a MP4 init and media segment using CENC encryption.")
arg_parser.add_argument("--init", help="Path to the init segment file", required=False)
arg_parser.add_argument("--segment", help="Path to the media segment file", required=False)
arg_parser.add_argument(
"--combined_segment", help="Path to the combined init and media segment file", required=False
)
arg_parser.add_argument("--key_id", help="Key ID in hexadecimal format", required=True)
arg_parser.add_argument("--key", help="Key in hexadecimal format", required=True)
arg_parser.add_argument("--output", help="Path to the output file", required=True)
args = arg_parser.parse_args()
cli()
+268
View File
@@ -0,0 +1,268 @@
import base64
import logging
from ipaddress import ip_address
import httpx
from fastapi import Request, Response, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import HttpUrl
from starlette.background import BackgroundTask
from .configs import settings
from .mpd_processor import process_manifest, process_playlist, process_segment
from .utils.cache_utils import get_cached_mpd, get_cached_init_segment
from .utils.http_utils import Streamer, DownloadError, download_file_with_retry, request_with_retry
from .utils.m3u8_processor import M3U8Processor
from .utils.mpd_utils import pad_base64
logger = logging.getLogger(__name__)
async def handle_hls_stream_proxy(request: Request, destination: str, headers: dict, key_url: HttpUrl = None):
"""
Handles the HLS stream proxy request, fetching and processing the m3u8 playlist or streaming the content.
Args:
request (Request): The incoming HTTP request.
destination (str): The destination URL to fetch the content from.
headers (dict): The headers to include in the request.
key_url (str, optional): The HLS Key URL to replace the original key URL. Defaults to None.
Returns:
Response: The HTTP response with the processed m3u8 playlist or streamed content.
"""
try:
if destination.endswith((".m3u", ".m3u8")) or "mpegurl" in headers.get("accept", "").lower():
return await fetch_and_process_m3u8(destination, headers, request, key_url)
return await handle_stream_request(request.method, destination, headers)
except httpx.HTTPStatusError as e:
logger.error(f"HTTP error while fetching m3u8: {e}")
return Response(status_code=e.response.status_code, content=str(e))
except Exception as e:
logger.exception(f"Error in live_stream_proxy: {str(e)}")
return Response(status_code=500, content=f"Internal server error: {str(e)}")
async def proxy_stream(method: str, video_url: str, headers: dict):
"""
Proxies the stream request to the given video URL.
Args:
method (str): The HTTP method (e.g., GET, HEAD).
video_url (str): The URL of the video to stream.
headers (dict): The headers to include in the request.
Returns:
Response: The HTTP response with the streamed content.
"""
return await handle_stream_request(method, video_url, headers)
async def handle_stream_request(method: str, video_url: str, headers: dict):
"""
Handles the stream request, fetching the content from the video URL and streaming it.
Args:
method (str): The HTTP method (e.g., GET, HEAD).
video_url (str): The URL of the video to stream.
headers (dict): The headers to include in the request.
Returns:
Response: The HTTP response with the streamed content.
"""
client = httpx.AsyncClient(
follow_redirects=True,
timeout=httpx.Timeout(30.0),
limits=httpx.Limits(max_keepalive_connections=10, max_connections=20),
proxy=settings.proxy_url,
)
streamer = Streamer(client)
try:
response = await streamer.head(video_url, headers)
if method == "HEAD":
await streamer.close()
return Response(headers=response.headers, status_code=response.status_code)
else:
return StreamingResponse(
streamer.stream_content(video_url, headers),
headers=response.headers,
background=BackgroundTask(streamer.close),
)
except httpx.HTTPStatusError as e:
logger.error(f"Upstream service error while handling {method} request: {e}")
await client.aclose()
return Response(status_code=e.response.status_code, content=f"Upstream service error: {e}")
except DownloadError as e:
logger.error(f"Error downloading {video_url}: {e}")
return Response(status_code=502, content=str(e))
except Exception as e:
logger.error(f"Internal server error while handling {method} request: {e}")
await client.aclose()
return Response(status_code=502, content=f"Internal server error: {e}")
async def fetch_and_process_m3u8(url: str, headers: dict, request: Request, key_url: HttpUrl = None):
"""
Fetches and processes the m3u8 playlist, converting it to an HLS playlist.
Args:
url (str): The URL of the m3u8 playlist.
headers (dict): The headers to include in the request.
request (Request): The incoming HTTP request.
key_url (HttpUrl, optional): The HLS Key URL to replace the original key URL. Defaults to None.
Returns:
Response: The HTTP response with the processed m3u8 playlist.
"""
async with httpx.AsyncClient(
follow_redirects=True,
timeout=httpx.Timeout(30.0),
limits=httpx.Limits(max_keepalive_connections=10, max_connections=20),
proxy=settings.proxy_url,
) as client:
try:
streamer = Streamer(client)
content = await streamer.get_text(url, headers)
processor = M3U8Processor(request, HttpUrl)
processed_content = await processor.process_m3u8(content, str(streamer.response.url))
return Response(
content=processed_content,
media_type="application/vnd.apple.mpegurl",
headers={
"Content-Disposition": "inline",
"Accept-Ranges": "none",
},
)
except httpx.HTTPStatusError as e:
logger.error(f"HTTP error while fetching m3u8: {e}")
return Response(status_code=e.response.status_code, content=str(e))
except DownloadError as e:
logger.error(f"Error downloading m3u8: {url}")
return Response(status_code=502, content=str(e))
except Exception as e:
logger.exception(f"Unexpected error while processing m3u8: {e}")
return Response(status_code=502, content=str(e))
async def handle_drm_key_data(key_id, key, drm_info):
"""
Handles the DRM key data, retrieving the key ID and key from the DRM info if not provided.
Args:
key_id (str): The DRM key ID.
key (str): The DRM key.
drm_info (dict): The DRM information from the MPD manifest.
Returns:
tuple: The key ID and key.
"""
if drm_info and not drm_info.get("isDrmProtected"):
return None, None
if not key_id or not key:
if "keyId" in drm_info and "key" in drm_info:
key_id = drm_info["keyId"]
key = drm_info["key"]
elif "laUrl" in drm_info and "keyId" in drm_info:
raise HTTPException(status_code=400, detail="LA URL is not supported yet")
else:
raise HTTPException(
status_code=400, detail="Unable to determine key_id and key, and they were not provided"
)
return key_id, key
async def get_manifest(request: Request, mpd_url: str, headers: dict, key_id: str = None, key: str = None):
"""
Retrieves and processes the MPD manifest, converting it to an HLS manifest.
Args:
request (Request): The incoming HTTP request.
mpd_url (str): The URL of the MPD manifest.
headers (dict): The headers to include in the request.
key_id (str, optional): The DRM key ID. Defaults to None.
key (str, optional): The DRM key. Defaults to None.
Returns:
Response: The HTTP response with the HLS manifest.
"""
try:
mpd_dict = await get_cached_mpd(mpd_url, headers=headers, parse_drm=not key_id and not key)
except DownloadError as e:
raise HTTPException(status_code=e.status_code, detail=f"Failed to download MPD: {e.message}")
drm_info = mpd_dict.get("drmInfo", {})
if drm_info and not drm_info.get("isDrmProtected"):
# For non-DRM protected MPD, we still create an HLS manifest
return await process_manifest(request, mpd_dict, None, None)
key_id, key = await handle_drm_key_data(key_id, key, drm_info)
# check if the provided key_id and key are valid
if key_id and len(key_id) != 32:
key_id = base64.urlsafe_b64decode(pad_base64(key_id)).hex()
if key and len(key) != 32:
key = base64.urlsafe_b64decode(pad_base64(key)).hex()
return await process_manifest(request, mpd_dict, key_id, key)
async def get_playlist(
request: Request, mpd_url: str, profile_id: str, headers: dict, key_id: str = None, key: str = None
):
"""
Retrieves and processes the MPD manifest, converting it to an HLS playlist for a specific profile.
Args:
request (Request): The incoming HTTP request.
mpd_url (str): The URL of the MPD manifest.
profile_id (str): The profile ID to generate the playlist for.
headers (dict): The headers to include in the request.
key_id (str, optional): The DRM key ID. Defaults to None.
key (str, optional): The DRM key. Defaults to None.
Returns:
Response: The HTTP response with the HLS playlist.
"""
mpd_dict = await get_cached_mpd(
mpd_url, headers=headers, parse_drm=not key_id and not key, parse_segment_profile_id=profile_id
)
return await process_playlist(request, mpd_dict, profile_id)
async def get_segment(
init_url: str, segment_url: str, mimetype: str, headers: dict, key_id: str = None, key: str = None
):
"""
Retrieves and processes a media segment, decrypting it if necessary.
Args:
init_url (str): The URL of the initialization segment.
segment_url (str): The URL of the media segment.
mimetype (str): The MIME type of the segment.
headers (dict): The headers to include in the request.
key_id (str, optional): The DRM key ID. Defaults to None.
key (str, optional): The DRM key. Defaults to None.
Returns:
Response: The HTTP response with the processed segment.
"""
try:
init_content = await get_cached_init_segment(init_url, headers)
segment_content = await download_file_with_retry(segment_url, headers)
except DownloadError as e:
raise HTTPException(status_code=e.status_code, detail=f"Failed to download segment: {e.message}")
return await process_segment(init_content, segment_content, mimetype, key_id, key)
async def get_public_ip():
"""
Retrieves the public IP address of the MediaFlow proxy.
Returns:
Response: The HTTP response with the public IP address.
"""
ip_address_data = await request_with_retry("GET", "https://api.ipify.org?format=json", {})
return ip_address_data.json()
+182
View File
@@ -0,0 +1,182 @@
import logging
from fastapi import FastAPI, Request, Depends, Security, HTTPException
from fastapi.security import APIKeyQuery
from pydantic import HttpUrl
from mediaflow_proxy.configs import settings
from .handlers import handle_hls_stream_proxy, proxy_stream, get_manifest, get_playlist, get_segment, get_public_ip
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
app = FastAPI()
api_key_query = APIKeyQuery(name="api_password", auto_error=False)
async def verify_api_key(api_key: str = Security(api_key_query)):
"""
Verifies the API key for the request.
Args:
api_key (str): The API key to validate.
Raises:
HTTPException: If the API key is invalid.
"""
if api_key != settings.api_password:
raise HTTPException(status_code=403, detail="Could not validate credentials")
def get_proxy_headers(request: Request) -> dict:
"""
Extracts proxy headers from the request query parameters.
Args:
request (Request): The incoming HTTP request.
Returns:
dict: A dictionary of proxy headers.
"""
return {k[2:]: v for k, v in request.query_params.items() if k.startswith("h_")}
@app.head("/proxy/hls")
@app.get("/proxy/hls")
async def hls_stream_proxy(
request: Request,
d: HttpUrl,
headers: dict = Depends(get_proxy_headers),
key_url: HttpUrl | None = None,
_: str = Depends(verify_api_key),
):
"""
Proxify HLS stream requests, fetching and processing the m3u8 playlist or streaming the content.
Args:
request (Request): The incoming HTTP request.
d (HttpUrl): The destination URL to fetch the content from.
key_url (HttpUrl, optional): The HLS Key URL to replace the original key URL. Defaults to None. (Useful for bypassing some sneaky protection)
headers (dict): The headers to include in the request.
_ (str): The API key to validate.
Returns:
Response: The HTTP response with the processed m3u8 playlist or streamed content.
"""
destination = str(d)
return await handle_hls_stream_proxy(request, destination, headers, key_url)
@app.head("/proxy/stream")
@app.get("/proxy/stream")
async def proxy_stream_endpoint(
request: Request, d: HttpUrl, headers: dict = Depends(get_proxy_headers), _: str = Depends(verify_api_key)
):
"""
Proxies stream requests to the given video URL.
Args:
request (Request): The incoming HTTP request.
d (HttpUrl): The URL of the video to stream.
headers (dict): The headers to include in the request.
_: str: The API key to validate.
Returns:
Response: The HTTP response with the streamed content.
"""
headers.update({"range": headers.get("range", "bytes=0-")})
return await proxy_stream(request.method, str(d), headers)
@app.get("/proxy/mpd/manifest")
async def manifest_endpoint(
request: Request,
d: HttpUrl,
headers: dict = Depends(get_proxy_headers),
key_id: str = None,
key: str = None,
_: str = Depends(verify_api_key),
):
"""
Retrieves and processes the MPD manifest, converting it to an HLS manifest.
Args:
request (Request): The incoming HTTP request.
d (HttpUrl): The URL of the MPD manifest.
headers (dict): The headers to include in the request.
key_id (str, optional): The DRM key ID. Defaults to None.
key (str, optional): The DRM key. Defaults to None.
_: str: The API key to validate.
Returns:
Response: The HTTP response with the HLS manifest.
"""
return await get_manifest(request, str(d), headers, key_id, key)
@app.get("/proxy/mpd/playlist")
async def playlist_endpoint(
request: Request,
d: HttpUrl,
profile_id: str,
headers: dict = Depends(get_proxy_headers),
key_id: str = None,
key: str = None,
_: str = Depends(verify_api_key),
):
"""
Retrieves and processes the MPD manifest, converting it to an HLS playlist for a specific profile.
Args:
request (Request): The incoming HTTP request.
d (HttpUrl): The URL of the MPD manifest.
profile_id (str): The profile ID to generate the playlist for.
headers (dict): The headers to include in the request.
key_id (str, optional): The DRM key ID. Defaults to None.
key (str, optional): The DRM key. Defaults to None.
_: str: The API key to validate.
Returns:
Response: The HTTP response with the HLS playlist.
"""
return await get_playlist(request, str(d), profile_id, headers, key_id, key)
@app.get("/proxy/mpd/segment")
async def segment_endpoint(
init_url: HttpUrl,
segment_url: HttpUrl,
mime_type: str,
headers: dict = Depends(get_proxy_headers),
key_id: str = None,
key: str = None,
_: str = Depends(verify_api_key),
):
"""
Retrieves and processes a media segment, decrypting it if necessary.
Args:
init_url (HttpUrl): The URL of the initialization segment.
segment_url (HttpUrl): The URL of the media segment.
mime_type (str): The MIME type of the segment.
headers (dict): The headers to include in the request.
key_id (str, optional): The DRM key ID. Defaults to None.
key (str, optional): The DRM key. Defaults to None.
_: str: The API key to validate.
Returns:
Response: The HTTP response with the processed segment.
"""
return await get_segment(str(init_url), str(segment_url), mime_type, headers, key_id, key)
@app.get("/proxy/ip")
async def get_mediaflow_proxy_public_ip(_: str = Depends(verify_api_key)):
"""
Retrieves the public IP address of the MediaFlow proxy server.
Args:
_: str: The API key to validate.
Returns:
Response: The HTTP response with the public IP address in the form of a JSON object. {"ip": "xxx.xxx.xxx.xxx"}
"""
return await get_public_ip()
+202
View File
@@ -0,0 +1,202 @@
import logging
import math
import time
from datetime import datetime, timezone, timedelta
from fastapi import Request, Response, HTTPException
from mediaflow_proxy.configs import settings
from mediaflow_proxy.drm.decrypter import decrypt_segment
from mediaflow_proxy.utils.http_utils import encode_mediaflow_proxy_url
logger = logging.getLogger(__name__)
async def process_manifest(request: Request, mpd_dict: dict, key_id: str = None, key: str = None) -> Response:
"""
Processes the MPD manifest and converts it to an HLS manifest.
Args:
request (Request): The incoming HTTP request.
mpd_dict (dict): The MPD manifest data.
key_id (str, optional): The DRM key ID. Defaults to None.
key (str, optional): The DRM key. Defaults to None.
Returns:
Response: The HLS manifest as an HTTP response.
"""
hls_content = build_hls(mpd_dict, request, key_id, key)
return Response(content=hls_content, media_type="application/vnd.apple.mpegurl")
async def process_playlist(request: Request, mpd_dict: dict, profile_id: str) -> Response:
"""
Processes the MPD manifest and converts it to an HLS playlist for a specific profile.
Args:
request (Request): The incoming HTTP request.
mpd_dict (dict): The MPD manifest data.
profile_id (str): The profile ID to generate the playlist for.
Returns:
Response: The HLS playlist as an HTTP response.
Raises:
HTTPException: If the profile is not found in the MPD manifest.
"""
matching_profiles = [p for p in mpd_dict["profiles"] if p["id"] == profile_id]
if not matching_profiles:
raise HTTPException(status_code=404, detail="Profile not found")
hls_content = build_hls_playlist(mpd_dict, matching_profiles, request)
return Response(content=hls_content, media_type="application/vnd.apple.mpegurl")
async def process_segment(
init_content: bytes,
segment_content: bytes,
mimetype: str,
key_id: str = None,
key: str = None,
) -> Response:
"""
Processes and decrypts a media segment.
Args:
init_content (bytes): The initialization segment content.
segment_content (bytes): The media segment content.
mimetype (str): The MIME type of the segment.
key_id (str, optional): The DRM key ID. Defaults to None.
key (str, optional): The DRM key. Defaults to None.
Returns:
Response: The decrypted segment as an HTTP response.
"""
if key_id and key:
# For DRM protected content
now = time.time()
decrypted_content = decrypt_segment(init_content, segment_content, key_id, key)
logger.info(f"Decryption of {mimetype} segment took {time.time() - now:.4f} seconds")
else:
# For non-DRM protected content, we just concatenate init and segment content
decrypted_content = init_content + segment_content
return Response(content=decrypted_content, media_type=mimetype)
def build_hls(mpd_dict: dict, request: Request, key_id: str = None, key: str = None) -> str:
"""
Builds an HLS manifest from the MPD manifest.
Args:
mpd_dict (dict): The MPD manifest data.
request (Request): The incoming HTTP request.
key_id (str, optional): The DRM key ID. Defaults to None.
key (str, optional): The DRM key. Defaults to None.
Returns:
str: The HLS manifest as a string.
"""
hls = ["#EXTM3U", "#EXT-X-VERSION:6"]
query_params = dict(request.query_params)
video_profiles = {}
audio_profiles = {}
for profile in mpd_dict["profiles"]:
query_params.update({"profile_id": profile["id"], "key_id": key_id or "", "key": key or ""})
playlist_url = encode_mediaflow_proxy_url(
str(request.url_for("playlist_endpoint")),
query_params=query_params,
)
if "video" in profile["mimeType"]:
video_profiles[profile["id"]] = (profile, playlist_url)
elif "audio" in profile["mimeType"]:
audio_profiles[profile["id"]] = (profile, playlist_url)
# Add audio streams
for i, (profile, playlist_url) in enumerate(audio_profiles.values()):
is_default = "YES" if i == 0 else "NO" # Set the first audio track as default
hls.append(
f'#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="{profile["id"]}",DEFAULT={is_default},AUTOSELECT={is_default},LANGUAGE="{profile.get("lang", "und")}",URI="{playlist_url}"'
)
# Add video streams
for profile, playlist_url in video_profiles.values():
hls.append(
f'#EXT-X-STREAM-INF:BANDWIDTH={profile["bandwidth"]},RESOLUTION={profile["width"]}x{profile["height"]},CODECS="{profile["codecs"]}",FRAME-RATE={profile["frameRate"]},AUDIO="audio"'
)
hls.append(playlist_url)
return "\n".join(hls)
def build_hls_playlist(mpd_dict: dict, profiles: list[dict], request: Request) -> str:
"""
Builds an HLS playlist from the MPD manifest for specific profiles.
Args:
mpd_dict (dict): The MPD manifest data.
profiles (list[dict]): The profiles to include in the playlist.
request (Request): The incoming HTTP request.
Returns:
str: The HLS playlist as a string.
"""
hls = ["#EXTM3U", "#EXT-X-VERSION:6"]
added_segments = 0
current_time = datetime.now(timezone.utc)
live_stream_delay = timedelta(seconds=settings.mpd_live_stream_delay)
target_end_time = current_time - live_stream_delay
for index, profile in enumerate(profiles):
segments = profile["segments"]
if not segments:
logger.warning(f"No segments found for profile {profile['id']}")
continue
# Add headers for only the first profile
if index == 0:
sequence = segments[0]["number"]
extinf_values = [f["extinf"] for f in segments if "extinf" in f]
target_duration = math.ceil(max(extinf_values)) if extinf_values else 3
hls.extend(
[
f"#EXT-X-TARGETDURATION:{target_duration}",
f"#EXT-X-MEDIA-SEQUENCE:{sequence}",
]
)
if mpd_dict["isLive"]:
hls.append("#EXT-X-PLAYLIST-TYPE:EVENT")
else:
hls.append("#EXT-X-PLAYLIST-TYPE:VOD")
init_url = profile["initUrl"]
query_params = dict(request.query_params)
query_params.pop("profile_id", None)
query_params.pop("d", None)
for segment in segments:
if mpd_dict["isLive"]:
if segment["end_time"] > target_end_time:
continue
hls.append(f"#EXT-X-PROGRAM-DATE-TIME:{segment['program_date_time']}")
hls.append(f'#EXTINF:{segment["extinf"]:.3f},')
query_params.update(
{"init_url": init_url, "segment_url": segment["media"], "mime_type": profile["mimeType"]}
)
hls.append(
encode_mediaflow_proxy_url(
str(request.url_for("segment_endpoint")),
query_params=query_params,
)
)
added_segments += 1
if not mpd_dict["isLive"]:
hls.append("#EXT-X-ENDLIST")
logger.info(f"Added {added_segments} segments to HLS playlist")
return "\n".join(hls)
View File
+58
View File
@@ -0,0 +1,58 @@
import datetime
import logging
from cachetools import TTLCache
from .http_utils import download_file_with_retry
from .mpd_utils import parse_mpd, parse_mpd_dict
logger = logging.getLogger(__name__)
# cache dictionary
mpd_cache = TTLCache(maxsize=100, ttl=300) # 5 minutes default TTL
init_segment_cache = TTLCache(maxsize=100, ttl=3600) # 1 hour default TTL
async def get_cached_mpd(
mpd_url: str, headers: dict, parse_drm: bool, parse_segment_profile_id: str | None = None
) -> dict:
"""
Retrieves and caches the MPD manifest, parsing it if not already cached.
Args:
mpd_url (str): The URL of the MPD manifest.
headers (dict): The headers to include in the request.
parse_drm (bool): Whether to parse DRM information.
parse_segment_profile_id (str, optional): The profile ID to parse segments for. Defaults to None.
Returns:
dict: The parsed MPD manifest data.
"""
current_time = datetime.datetime.now(datetime.UTC)
if mpd_url in mpd_cache and mpd_cache[mpd_url]["expires"] > current_time:
logger.info(f"Using cached MPD for {mpd_url}")
return parse_mpd_dict(mpd_cache[mpd_url]["mpd"], mpd_url, parse_drm, parse_segment_profile_id)
mpd_dict = parse_mpd(await download_file_with_retry(mpd_url, headers))
parsed_mpd_dict = parse_mpd_dict(mpd_dict, mpd_url, parse_drm, parse_segment_profile_id)
current_time = datetime.datetime.now(datetime.UTC)
expiration_time = current_time + datetime.timedelta(seconds=parsed_mpd_dict.get("minimumUpdatePeriod", 300))
mpd_cache[mpd_url] = {"mpd": mpd_dict, "expires": expiration_time}
return parsed_mpd_dict
async def get_cached_init_segment(init_url: str, headers: dict) -> bytes:
"""
Retrieves and caches the initialization segment.
Args:
init_url (str): The URL of the initialization segment.
headers (dict): The headers to include in the request.
Returns:
bytes: The initialization segment content.
"""
if init_url not in init_segment_cache:
init_content = await download_file_with_retry(init_url, headers)
init_segment_cache[init_url] = init_content
return init_segment_cache[init_url]
+220
View File
@@ -0,0 +1,220 @@
import logging
from urllib import parse
import httpx
import tenacity
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
from mediaflow_proxy.configs import settings
logger = logging.getLogger(__name__)
class DownloadError(Exception):
def __init__(self, status_code, message):
self.status_code = status_code
self.message = message
super().__init__(message)
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=4, max=10),
retry=retry_if_exception_type(DownloadError),
)
async def fetch_with_retry(client, method, url, headers, follow_redirects=True, **kwargs):
"""
Fetches a URL with retry logic.
Args:
client (httpx.AsyncClient): The HTTP client to use for the request.
method (str): The HTTP method to use (e.g., GET, POST).
url (str): The URL to fetch.
headers (dict): The headers to include in the request.
follow_redirects (bool, optional): Whether to follow redirects. Defaults to True.
**kwargs: Additional arguments to pass to the request.
Returns:
httpx.Response: The HTTP response.
Raises:
DownloadError: If the request fails after retries.
"""
try:
response = await client.request(method, url, headers=headers, follow_redirects=follow_redirects, **kwargs)
response.raise_for_status()
return response
except httpx.TimeoutException:
logger.warning(f"Timeout while downloading {url}")
raise DownloadError(409, f"Timeout while downloading {url}")
except httpx.HTTPStatusError as e:
logger.error(f"HTTP error {e.response.status_code} while downloading {url}")
# if e.response.status_code == 404:
# logger.error(f"Segment Resource not found: {url}")
# raise e
raise DownloadError(e.response.status_code, f"HTTP error {e.response.status_code} while downloading {url}")
except Exception as e:
logger.error(f"Error downloading {url}: {e}")
raise
class Streamer:
def __init__(self, client):
"""
Initializes the Streamer with an HTTP client.
Args:
client (httpx.AsyncClient): The HTTP client to use for streaming.
"""
self.client = client
self.response = None
async def stream_content(self, url: str, headers: dict):
"""
Streams content from a URL.
Args:
url (str): The URL to stream content from.
headers (dict): The headers to include in the request.
Yields:
bytes: Chunks of the streamed content.
"""
async with self.client.stream("GET", url, headers=headers, follow_redirects=True) as self.response:
self.response.raise_for_status()
async for chunk in self.response.aiter_bytes():
yield chunk
async def head(self, url: str, headers: dict):
"""
Sends a HEAD request to a URL.
Args:
url (str): The URL to send the HEAD request to.
headers (dict): The headers to include in the request.
Returns:
httpx.Response: The HTTP response.
"""
try:
self.response = await fetch_with_retry(self.client, "HEAD", url, headers)
except tenacity.RetryError as e:
raise e.last_attempt.result()
return self.response
async def get_text(self, url: str, headers: dict):
"""
Sends a GET request to a URL and returns the response text.
Args:
url (str): The URL to send the GET request to.
headers (dict): The headers to include in the request.
Returns:
str: The response text.
"""
try:
self.response = await fetch_with_retry(self.client, "GET", url, headers)
except tenacity.RetryError as e:
raise e.last_attempt.result()
return self.response.text
async def close(self):
"""
Closes the HTTP client and response.
"""
if self.response:
await self.response.aclose()
await self.client.aclose()
async def download_file_with_retry(url: str, headers: dict, timeout: float = 10.0):
"""
Downloads a file with retry logic.
Args:
url (str): The URL of the file to download.
headers (dict): The headers to include in the request.
timeout (float, optional): The request timeout. Defaults to 10.0.
Returns:
bytes: The downloaded file content.
Raises:
DownloadError: If the download fails after retries.
"""
async with httpx.AsyncClient(follow_redirects=True, timeout=timeout, proxy=settings.proxy_url) as client:
try:
response = await fetch_with_retry(client, "GET", url, headers)
return response.content
except DownloadError as e:
logger.error(f"Failed to download file: {e}")
raise e
except tenacity.RetryError as e:
raise DownloadError(502, f"Failed to download file: {e.last_attempt.result()}")
async def request_with_retry(method: str, url: str, headers: dict, timeout: float = 10.0, **kwargs):
"""
Sends an HTTP request with retry logic.
Args:
method (str): The HTTP method to use (e.g., GET, POST).
url (str): The URL to send the request to.
headers (dict): The headers to include in the request.
timeout (float, optional): The request timeout. Defaults to 10.0.
**kwargs: Additional arguments to pass to the request.
Returns:
httpx.Response: The HTTP response.
Raises:
DownloadError: If the request fails after retries.
"""
async with httpx.AsyncClient(follow_redirects=True, timeout=timeout, proxy=settings.proxy_url) as client:
try:
response = await fetch_with_retry(client, method, url, headers, **kwargs)
return response
except DownloadError as e:
logger.error(f"Failed to download file: {e}")
raise
def encode_mediaflow_proxy_url(
mediaflow_proxy_url: str,
endpoint: str | None = None,
destination_url: str | None = None,
query_params: dict | None = None,
request_headers: dict | None = None,
) -> str:
"""
Encodes a MediaFlow proxy URL with query parameters and headers.
Args:
mediaflow_proxy_url (str): The base MediaFlow proxy URL.
endpoint (str, optional): The endpoint to append to the base URL. Defaults to None.
destination_url (str, optional): The destination URL to include in the query parameters. Defaults to None.
query_params (dict, optional): Additional query parameters to include. Defaults to None.
request_headers (dict, optional): Headers to include as query parameters. Defaults to None.
Returns:
str: The encoded MediaFlow proxy URL.
"""
query_params = query_params or {}
if destination_url is not None:
query_params["d"] = destination_url
# Add headers if provided
if request_headers:
query_params.update(
{key if key.startswith("h_") else f"h_{key}": value for key, value in request_headers.items()}
)
# Encode the query parameters
encoded_params = parse.urlencode(query_params, quote_via=parse.quote)
# Construct the full URL
if endpoint is None:
return f"{mediaflow_proxy_url}?{encoded_params}"
base_url = parse.urljoin(mediaflow_proxy_url, endpoint)
return f"{base_url}?{encoded_params}"
+83
View File
@@ -0,0 +1,83 @@
import re
from urllib import parse
from pydantic import HttpUrl
from mediaflow_proxy.utils.http_utils import encode_mediaflow_proxy_url
class M3U8Processor:
def __init__(self, request, key_url: HttpUrl = None):
"""
Initializes the M3U8Processor with the request and URL prefix.
Args:
request (Request): The incoming HTTP request.
key_url (HttpUrl, optional): The URL of the key server. Defaults to None.
"""
self.request = request
self.key_url = key_url
async def process_m3u8(self, content: str, base_url: str) -> str:
"""
Processes the m3u8 content, proxying URLs and handling key lines.
Args:
content (str): The m3u8 content to process.
base_url (str): The base URL to resolve relative URLs.
Returns:
str: The processed m3u8 content.
"""
lines = content.splitlines()
processed_lines = []
for line in lines:
if "URI=" in line:
processed_lines.append(await self.process_key_line(line, base_url))
elif not line.startswith("#") and line.strip():
processed_lines.append(await self.proxy_url(line, base_url))
else:
processed_lines.append(line)
return "\n".join(processed_lines)
async def process_key_line(self, line: str, base_url: str) -> str:
"""
Processes a key line in the m3u8 content, proxying the URI.
Args:
line (str): The key line to process.
base_url (str): The base URL to resolve relative URLs.
Returns:
str: The processed key line.
"""
uri_match = re.search(r'URI="([^"]+)"', line)
if uri_match:
original_uri = uri_match.group(1)
uri = parse.urlparse(original_uri)
if self.key_url:
uri.scheme = self.key_url.scheme
uri.netloc = self.key_url.netloc
new_uri = await self.proxy_url(str(uri), base_url)
line = line.replace(f'URI="{original_uri}"', f'URI="{new_uri}"')
return line
async def proxy_url(self, url: str, base_url: str) -> str:
"""
Proxies a URL, encoding it with the MediaFlow proxy URL.
Args:
url (str): The URL to proxy.
base_url (str): The base URL to resolve relative URLs.
Returns:
str: The proxied URL.
"""
full_url = parse.urljoin(base_url, url)
return encode_mediaflow_proxy_url(
str(self.request.url_for("hls_stream_proxy")),
"",
full_url,
query_params=dict(self.request.query_params),
)
+555
View File
@@ -0,0 +1,555 @@
import logging
import math
import re
from datetime import datetime, timedelta, timezone
from typing import List, Dict
from urllib.parse import urljoin
import xmltodict
logger = logging.getLogger(__name__)
def parse_mpd(mpd_content: str) -> dict:
"""
Parses the MPD content into a dictionary.
Args:
mpd_content (str): The MPD content as a string.
Returns:
dict: The parsed MPD content as a dictionary.
"""
return xmltodict.parse(mpd_content)
def parse_mpd_dict(
mpd_dict: dict, mpd_url: str, parse_drm: bool = True, parse_segment_profile_id: str | None = None
) -> dict:
"""
Parses the MPD dictionary and extracts relevant information.
Args:
mpd_dict (dict): The MPD content as a dictionary.
mpd_url (str): The URL of the MPD manifest.
parse_drm (bool, optional): Whether to parse DRM information. Defaults to True.
parse_segment_profile_id (str, optional): The profile ID to parse segments for. Defaults to None.
Returns:
dict: The parsed MPD information including profiles and DRM info.
This function processes the MPD dictionary to extract profiles, DRM information, and other relevant data.
It handles both live and static MPD manifests.
"""
profiles = []
parsed_dict = {}
source = "/".join(mpd_url.split("/")[:-1])
is_live = mpd_dict["MPD"].get("@type", "static").lower() == "dynamic"
parsed_dict["isLive"] = is_live
media_presentation_duration = mpd_dict["MPD"].get("@mediaPresentationDuration")
# Parse additional MPD attributes for live streams
if is_live:
parsed_dict["minimumUpdatePeriod"] = parse_duration(mpd_dict["MPD"].get("@minimumUpdatePeriod", "PT0S"))
parsed_dict["timeShiftBufferDepth"] = parse_duration(mpd_dict["MPD"].get("@timeShiftBufferDepth", "PT2M"))
parsed_dict["availabilityStartTime"] = datetime.fromisoformat(
mpd_dict["MPD"]["@availabilityStartTime"].replace("Z", "+00:00")
)
parsed_dict["publishTime"] = datetime.fromisoformat(
mpd_dict["MPD"].get("@publishTime", "").replace("Z", "+00:00")
)
periods = mpd_dict["MPD"]["Period"]
periods = periods if isinstance(periods, list) else [periods]
for period in periods:
parsed_dict["PeriodStart"] = parse_duration(period.get("@start", "PT0S"))
for adaptation in period["AdaptationSet"]:
representations = adaptation["Representation"]
representations = representations if isinstance(representations, list) else [representations]
for representation in representations:
profile = parse_representation(
parsed_dict,
representation,
adaptation,
source,
media_presentation_duration,
parse_segment_profile_id,
)
if profile:
profiles.append(profile)
parsed_dict["profiles"] = profiles
if parse_drm:
drm_info = extract_drm_info(periods, mpd_url)
else:
drm_info = {}
parsed_dict["drmInfo"] = drm_info
return parsed_dict
def pad_base64(encoded_key_id):
"""
Pads a base64 encoded key ID to make its length a multiple of 4.
Args:
encoded_key_id (str): The base64 encoded key ID.
Returns:
str: The padded base64 encoded key ID.
"""
return encoded_key_id + "=" * (4 - len(encoded_key_id) % 4)
def extract_drm_info(periods: List[Dict], mpd_url: str) -> Dict:
"""
Extracts DRM information from the MPD periods.
Args:
periods (List[Dict]): The list of periods in the MPD.
mpd_url (str): The URL of the MPD manifest.
Returns:
Dict: The extracted DRM information.
This function processes the ContentProtection elements in the MPD to extract DRM system information,
such as ClearKey, Widevine, and PlayReady.
"""
drm_info = {"isDrmProtected": False}
for period in periods:
adaptation_sets: list[dict] | dict = period.get("AdaptationSet", [])
if not isinstance(adaptation_sets, list):
adaptation_sets = [adaptation_sets]
for adaptation_set in adaptation_sets:
# Check ContentProtection in AdaptationSet
process_content_protection(adaptation_set.get("ContentProtection", []), drm_info)
# Check ContentProtection inside each Representation
representations: list[dict] | dict = adaptation_set.get("Representation", [])
if not isinstance(representations, list):
representations = [representations]
for representation in representations:
process_content_protection(representation.get("ContentProtection", []), drm_info)
# If we have a license acquisition URL, make sure it's absolute
if "laUrl" in drm_info and not drm_info["laUrl"].startswith(("http://", "https://")):
drm_info["laUrl"] = urljoin(mpd_url, drm_info["laUrl"])
return drm_info
def process_content_protection(content_protection: list[dict] | dict, drm_info: dict):
"""
Processes the ContentProtection elements to extract DRM information.
Args:
content_protection (list[dict] | dict): The ContentProtection elements.
drm_info (dict): The dictionary to store DRM information.
This function updates the drm_info dictionary with DRM system information found in the ContentProtection elements.
"""
if not isinstance(content_protection, list):
content_protection = [content_protection]
for protection in content_protection:
drm_info["isDrmProtected"] = True
scheme_id_uri = protection.get("@schemeIdUri", "").lower()
if "clearkey" in scheme_id_uri:
drm_info["drmSystem"] = "clearkey"
if "clearkey:Laurl" in protection:
la_url = protection["clearkey:Laurl"].get("#text")
if la_url and "laUrl" not in drm_info:
drm_info["laUrl"] = la_url
elif "widevine" in scheme_id_uri or "edef8ba9-79d6-4ace-a3c8-27dcd51d21ed" in scheme_id_uri:
drm_info["drmSystem"] = "widevine"
pssh = protection.get("cenc:pssh", {}).get("#text")
if pssh:
drm_info["pssh"] = pssh
elif "playready" in scheme_id_uri or "9a04f079-9840-4286-ab92-e65be0885f95" in scheme_id_uri:
drm_info["drmSystem"] = "playready"
if "@cenc:default_KID" in protection:
key_id = protection["@cenc:default_KID"].replace("-", "")
if "keyId" not in drm_info:
drm_info["keyId"] = key_id
if "ms:laurl" in protection:
la_url = protection["ms:laurl"].get("@licenseUrl")
if la_url and "laUrl" not in drm_info:
drm_info["laUrl"] = la_url
return drm_info
def parse_representation(
parsed_dict: dict,
representation: dict,
adaptation: dict,
source: str,
media_presentation_duration: str,
parse_segment_profile_id: str | None,
) -> dict | None:
"""
Parses a representation and extracts profile information.
Args:
parsed_dict (dict): The parsed MPD data.
representation (dict): The representation data.
adaptation (dict): The adaptation set data.
source (str): The source URL.
media_presentation_duration (str): The media presentation duration.
parse_segment_profile_id (str, optional): The profile ID to parse segments for. Defaults to None.
Returns:
dict | None: The parsed profile information or None if not applicable.
"""
mime_type = _get_key(adaptation, representation, "@mimeType") or (
"video/mp4" if "avc" in representation["@codecs"] else "audio/mp4"
)
if "video" not in mime_type and "audio" not in mime_type:
return None
profile = {
"id": representation.get("@id") or adaptation.get("@id"),
"mimeType": mime_type,
"lang": representation.get("@lang") or adaptation.get("@lang"),
"codecs": representation.get("@codecs") or adaptation.get("@codecs"),
"bandwidth": int(representation.get("@bandwidth") or adaptation.get("@bandwidth")),
"startWithSAP": (_get_key(adaptation, representation, "@startWithSAP") or "1") == "1",
"mediaPresentationDuration": media_presentation_duration,
}
if "audio" in profile["mimeType"]:
profile["audioSamplingRate"] = representation.get("@audioSamplingRate") or adaptation.get("@audioSamplingRate")
profile["channels"] = representation.get("AudioChannelConfiguration", {}).get("@value", "2")
else:
profile["width"] = int(representation["@width"])
profile["height"] = int(representation["@height"])
frame_rate = representation.get("@frameRate") or adaptation.get("@maxFrameRate") or "30000/1001"
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")
if parse_segment_profile_id is None or profile["id"] != parse_segment_profile_id:
return profile
item = adaptation.get("SegmentTemplate") or representation.get("SegmentTemplate")
if item:
profile["segments"] = parse_segment_template(parsed_dict, item, profile, source)
else:
profile["segments"] = parse_segment_base(representation, source)
return profile
def _get_key(adaptation: dict, representation: dict, key: str) -> str | None:
"""
Retrieves a key from the representation or adaptation set.
Args:
adaptation (dict): The adaptation set data.
representation (dict): The representation data.
key (str): The key to retrieve.
Returns:
str | None: The value of the key or None if not found.
"""
return representation.get(key, adaptation.get(key, None))
def parse_segment_template(parsed_dict: dict, item: dict, profile: dict, source: str) -> List[Dict]:
"""
Parses a segment template and extracts segment information.
Args:
parsed_dict (dict): The parsed MPD data.
item (dict): The segment template data.
profile (dict): The profile information.
source (str): The source URL.
Returns:
List[Dict]: The list of parsed segments.
"""
segments = []
timescale = int(item.get("@timescale", 1))
# Initialization
if "@initialization" in item:
media = item["@initialization"]
media = media.replace("$RepresentationID$", profile["id"])
media = media.replace("$Bandwidth$", str(profile["bandwidth"]))
if not media.startswith("http"):
media = f"{source}/{media}"
profile["initUrl"] = media
# Segments
if "SegmentTimeline" in item:
segments.extend(parse_segment_timeline(parsed_dict, item, profile, source, timescale))
elif "@duration" in item:
segments.extend(parse_segment_duration(parsed_dict, item, profile, source, timescale))
return segments
def parse_segment_timeline(parsed_dict: dict, item: dict, profile: dict, source: str, timescale: int) -> List[Dict]:
"""
Parses a segment timeline and extracts segment information.
Args:
parsed_dict (dict): The parsed MPD data.
item (dict): The segment timeline data.
profile (dict): The profile information.
source (str): The source URL.
timescale (int): The timescale for the segments.
Returns:
List[Dict]: The list of parsed segments.
"""
timelines = item["SegmentTimeline"]["S"]
timelines = timelines if isinstance(timelines, list) else [timelines]
period_start = parsed_dict["availabilityStartTime"] + timedelta(seconds=parsed_dict.get("PeriodStart", 0))
presentation_time_offset = int(item.get("@presentationTimeOffset", 0))
start_number = int(item.get("@startNumber", 1))
segments = [
create_segment_data(timeline, item, profile, source, timescale)
for timeline in preprocess_timeline(timelines, start_number, period_start, presentation_time_offset, timescale)
]
return segments
def preprocess_timeline(
timelines: List[Dict], start_number: int, period_start: datetime, presentation_time_offset: int, timescale: int
) -> List[Dict]:
"""
Preprocesses the segment timeline data.
Args:
timelines (List[Dict]): The list of timeline segments.
start_number (int): The starting segment number.
period_start (datetime): The start time of the period.
presentation_time_offset (int): The presentation time offset.
timescale (int): The timescale for the segments.
Returns:
List[Dict]: The list of preprocessed timeline segments.
"""
processed_data = []
current_time = 0
for timeline in timelines:
repeat = int(timeline.get("@r", 0))
duration = int(timeline["@d"])
start_time = int(timeline.get("@t", current_time))
for _ in range(repeat + 1):
segment_start_time = period_start + timedelta(seconds=(start_time - presentation_time_offset) / timescale)
segment_end_time = segment_start_time + timedelta(seconds=duration / timescale)
processed_data.append(
{
"number": start_number,
"start_time": segment_start_time,
"end_time": segment_end_time,
"duration": duration,
"time": start_time,
}
)
start_time += duration
start_number += 1
current_time = start_time
return processed_data
def parse_segment_duration(parsed_dict: dict, item: dict, profile: dict, source: str, timescale: int) -> List[Dict]:
"""
Parses segment duration and extracts segment information.
This is used for static or live MPD manifests.
Args:
parsed_dict (dict): The parsed MPD data.
item (dict): The segment duration data.
profile (dict): The profile information.
source (str): The source URL.
timescale (int): The timescale for the segments.
Returns:
List[Dict]: The list of parsed segments.
"""
duration = int(item["@duration"])
start_number = int(item.get("@startNumber", 1))
segment_duration_sec = duration / timescale
if parsed_dict["isLive"]:
segments = generate_live_segments(parsed_dict, segment_duration_sec, start_number)
else:
segments = generate_vod_segments(profile, duration, timescale, start_number)
return [create_segment_data(seg, item, profile, source, timescale) for seg in segments]
def generate_live_segments(parsed_dict: dict, segment_duration_sec: float, start_number: int) -> List[Dict]:
"""
Generates live segments based on the segment duration and start number.
This is used for live MPD manifests.
Args:
parsed_dict (dict): The parsed MPD data.
segment_duration_sec (float): The segment duration in seconds.
start_number (int): The starting segment number.
Returns:
List[Dict]: The list of generated live segments.
"""
time_shift_buffer_depth = timedelta(seconds=parsed_dict.get("timeShiftBufferDepth", 60))
segment_count = math.ceil(time_shift_buffer_depth.total_seconds() / segment_duration_sec)
current_time = datetime.now(tz=timezone.utc)
earliest_segment_number = max(
start_number
+ math.floor((current_time - parsed_dict["availabilityStartTime"]).total_seconds() / segment_duration_sec)
- segment_count,
start_number,
)
return [
{
"number": number,
"start_time": parsed_dict["availabilityStartTime"]
+ timedelta(seconds=(number - start_number) * segment_duration_sec),
"duration": segment_duration_sec,
}
for number in range(earliest_segment_number, earliest_segment_number + segment_count)
]
def generate_vod_segments(profile: dict, duration: int, timescale: int, start_number: int) -> List[Dict]:
"""
Generates VOD segments based on the segment duration and start number.
This is used for static MPD manifests.
Args:
profile (dict): The profile information.
duration (int): The segment duration.
timescale (int): The timescale for the segments.
start_number (int): The starting segment number.
Returns:
List[Dict]: The list of generated VOD segments.
"""
total_duration = profile.get("mediaPresentationDuration") or 0
if isinstance(total_duration, str):
total_duration = parse_duration(total_duration)
segment_count = math.ceil(total_duration * timescale / duration)
return [{"number": start_number + i, "duration": duration / timescale} for i in range(segment_count)]
def create_segment_data(segment: Dict, item: dict, profile: dict, source: str, timescale: int | None = None) -> Dict:
"""
Creates segment data based on the segment information. This includes the segment URL and metadata.
Args:
segment (Dict): The segment information.
item (dict): The segment template data.
profile (dict): The profile information.
source (str): The source URL.
timescale (int, optional): The timescale for the segments. Defaults to None.
Returns:
Dict: The created segment data.
"""
media_template = item["@media"]
media = media_template.replace("$RepresentationID$", profile["id"])
media = media.replace("$Number%04d$", f"{segment['number']:04d}")
media = media.replace("$Number$", str(segment["number"]))
media = media.replace("$Bandwidth$", str(profile["bandwidth"]))
if "time" in segment and timescale is not None:
media = media.replace("$Time$", str(int(segment["time"] * timescale)))
if not media.startswith("http"):
media = f"{source}/{media}"
segment_data = {
"type": "segment",
"media": media,
"number": segment["number"],
}
if "start_time" in segment and "end_time" in segment:
segment_data.update(
{
"start_time": segment["start_time"],
"end_time": segment["end_time"],
"extinf": (segment["end_time"] - segment["start_time"]).total_seconds(),
"program_date_time": segment["start_time"].isoformat() + "Z",
}
)
elif "start_time" in segment and "duration" in segment:
duration = segment["duration"]
segment_data.update(
{
"start_time": segment["start_time"],
"end_time": segment["start_time"] + timedelta(seconds=duration),
"extinf": duration,
"program_date_time": segment["start_time"].isoformat() + "Z",
}
)
elif "duration" in segment:
segment_data["extinf"] = segment["duration"]
return segment_data
def parse_segment_base(representation: dict, source: str) -> List[Dict]:
"""
Parses segment base information and extracts segment data. This is used for single-segment representations.
Args:
representation (dict): The representation data.
source (str): The source URL.
Returns:
List[Dict]: The list of parsed segments.
"""
segment = representation["SegmentBase"]
start, end = map(int, segment["@indexRange"].split("-"))
if "Initialization" in segment:
start, _ = map(int, segment["Initialization"]["@range"].split("-"))
return [
{
"type": "segment",
"range": f"{start}-{end}",
"media": f"{source}/{representation['BaseURL']}",
}
]
def parse_duration(duration_str: str) -> float:
"""
Parses a duration ISO 8601 string into seconds.
Args:
duration_str (str): The duration string to parse.
Returns:
float: The parsed duration in seconds.
"""
pattern = re.compile(r"P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?T?(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?")
match = pattern.match(duration_str)
if not match:
raise ValueError(f"Invalid duration format: {duration_str}")
years, months, days, hours, minutes, seconds = [float(g) if g else 0 for g in match.groups()]
return years * 365 * 24 * 3600 + months * 30 * 24 * 3600 + days * 24 * 3600 + hours * 3600 + minutes * 60 + seconds
Generated
+578
View File
@@ -0,0 +1,578 @@
# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand.
[[package]]
name = "annotated-types"
version = "0.7.0"
description = "Reusable constraint types to use with typing.Annotated"
optional = false
python-versions = ">=3.8"
files = [
{file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"},
{file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"},
]
[[package]]
name = "anyio"
version = "4.4.0"
description = "High level compatibility layer for multiple asynchronous event loop implementations"
optional = false
python-versions = ">=3.8"
files = [
{file = "anyio-4.4.0-py3-none-any.whl", hash = "sha256:c1b2d8f46a8a812513012e1107cb0e68c17159a7a594208005a57dc776e1bdc7"},
{file = "anyio-4.4.0.tar.gz", hash = "sha256:5aadc6a1bbb7cdb0bede386cac5e2940f5e2ff3aa20277e991cf028e0585ce94"},
]
[package.dependencies]
idna = ">=2.8"
sniffio = ">=1.1"
[package.extras]
doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"]
test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"]
trio = ["trio (>=0.23)"]
[[package]]
name = "black"
version = "24.8.0"
description = "The uncompromising code formatter."
optional = false
python-versions = ">=3.8"
files = [
{file = "black-24.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:09cdeb74d494ec023ded657f7092ba518e8cf78fa8386155e4a03fdcc44679e6"},
{file = "black-24.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:81c6742da39f33b08e791da38410f32e27d632260e599df7245cccee2064afeb"},
{file = "black-24.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:707a1ca89221bc8a1a64fb5e15ef39cd755633daa672a9db7498d1c19de66a42"},
{file = "black-24.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:d6417535d99c37cee4091a2f24eb2b6d5ec42b144d50f1f2e436d9fe1916fe1a"},
{file = "black-24.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fb6e2c0b86bbd43dee042e48059c9ad7830abd5c94b0bc518c0eeec57c3eddc1"},
{file = "black-24.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:837fd281f1908d0076844bc2b801ad2d369c78c45cf800cad7b61686051041af"},
{file = "black-24.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62e8730977f0b77998029da7971fa896ceefa2c4c4933fcd593fa599ecbf97a4"},
{file = "black-24.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:72901b4913cbac8972ad911dc4098d5753704d1f3c56e44ae8dce99eecb0e3af"},
{file = "black-24.8.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:7c046c1d1eeb7aea9335da62472481d3bbf3fd986e093cffd35f4385c94ae368"},
{file = "black-24.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:649f6d84ccbae73ab767e206772cc2d7a393a001070a4c814a546afd0d423aed"},
{file = "black-24.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b59b250fdba5f9a9cd9d0ece6e6d993d91ce877d121d161e4698af3eb9c1018"},
{file = "black-24.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:6e55d30d44bed36593c3163b9bc63bf58b3b30e4611e4d88a0c3c239930ed5b2"},
{file = "black-24.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:505289f17ceda596658ae81b61ebbe2d9b25aa78067035184ed0a9d855d18afd"},
{file = "black-24.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b19c9ad992c7883ad84c9b22aaa73562a16b819c1d8db7a1a1a49fb7ec13c7d2"},
{file = "black-24.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f13f7f386f86f8121d76599114bb8c17b69d962137fc70efe56137727c7047e"},
{file = "black-24.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:f490dbd59680d809ca31efdae20e634f3fae27fba3ce0ba3208333b713bc3920"},
{file = "black-24.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:eab4dd44ce80dea27dc69db40dab62d4ca96112f87996bca68cd75639aeb2e4c"},
{file = "black-24.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3c4285573d4897a7610054af5a890bde7c65cb466040c5f0c8b732812d7f0e5e"},
{file = "black-24.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e84e33b37be070ba135176c123ae52a51f82306def9f7d063ee302ecab2cf47"},
{file = "black-24.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:73bbf84ed136e45d451a260c6b73ed674652f90a2b3211d6a35e78054563a9bb"},
{file = "black-24.8.0-py3-none-any.whl", hash = "sha256:972085c618ee94f402da1af548a4f218c754ea7e5dc70acb168bfaca4c2542ed"},
{file = "black-24.8.0.tar.gz", hash = "sha256:2500945420b6784c38b9ee885af039f5e7471ef284ab03fa35ecdde4688cd83f"},
]
[package.dependencies]
click = ">=8.0.0"
mypy-extensions = ">=0.4.3"
packaging = ">=22.0"
pathspec = ">=0.9.0"
platformdirs = ">=2"
[package.extras]
colorama = ["colorama (>=0.4.3)"]
d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"]
jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
uvloop = ["uvloop (>=0.15.2)"]
[[package]]
name = "cachetools"
version = "5.5.0"
description = "Extensible memoizing collections and decorators"
optional = false
python-versions = ">=3.7"
files = [
{file = "cachetools-5.5.0-py3-none-any.whl", hash = "sha256:02134e8439cdc2ffb62023ce1debca2944c3f289d66bb17ead3ab3dede74b292"},
{file = "cachetools-5.5.0.tar.gz", hash = "sha256:2cc24fb4cbe39633fb7badd9db9ca6295d766d9c2995f245725a46715d050f2a"},
]
[[package]]
name = "certifi"
version = "2024.7.4"
description = "Python package for providing Mozilla's CA Bundle."
optional = false
python-versions = ">=3.6"
files = [
{file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"},
{file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"},
]
[[package]]
name = "click"
version = "8.1.7"
description = "Composable command line interface toolkit"
optional = false
python-versions = ">=3.7"
files = [
{file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"},
{file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"},
]
[package.dependencies]
colorama = {version = "*", markers = "platform_system == \"Windows\""}
[[package]]
name = "colorama"
version = "0.4.6"
description = "Cross-platform colored terminal text."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
[[package]]
name = "fastapi"
version = "0.112.1"
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
optional = false
python-versions = ">=3.8"
files = [
{file = "fastapi-0.112.1-py3-none-any.whl", hash = "sha256:bcbd45817fc2a1cd5da09af66815b84ec0d3d634eb173d1ab468ae3103e183e4"},
{file = "fastapi-0.112.1.tar.gz", hash = "sha256:b2537146f8c23389a7faa8b03d0bd38d4986e6983874557d95eed2acc46448ef"},
]
[package.dependencies]
pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0"
starlette = ">=0.37.2,<0.39.0"
typing-extensions = ">=4.8.0"
[package.extras]
all = ["email_validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.7)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"]
standard = ["email_validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "jinja2 (>=2.11.2)", "python-multipart (>=0.0.7)", "uvicorn[standard] (>=0.12.0)"]
[[package]]
name = "gunicorn"
version = "23.0.0"
description = "WSGI HTTP Server for UNIX"
optional = false
python-versions = ">=3.7"
files = [
{file = "gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d"},
{file = "gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec"},
]
[package.dependencies]
packaging = "*"
[package.extras]
eventlet = ["eventlet (>=0.24.1,!=0.36.0)"]
gevent = ["gevent (>=1.4.0)"]
setproctitle = ["setproctitle"]
testing = ["coverage", "eventlet", "gevent", "pytest", "pytest-cov"]
tornado = ["tornado (>=0.2)"]
[[package]]
name = "h11"
version = "0.14.0"
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
optional = false
python-versions = ">=3.7"
files = [
{file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"},
{file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"},
]
[[package]]
name = "httpcore"
version = "1.0.5"
description = "A minimal low-level HTTP client."
optional = false
python-versions = ">=3.8"
files = [
{file = "httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5"},
{file = "httpcore-1.0.5.tar.gz", hash = "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61"},
]
[package.dependencies]
certifi = "*"
h11 = ">=0.13,<0.15"
[package.extras]
asyncio = ["anyio (>=4.0,<5.0)"]
http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"]
trio = ["trio (>=0.22.0,<0.26.0)"]
[[package]]
name = "httpx"
version = "0.27.0"
description = "The next generation HTTP client."
optional = false
python-versions = ">=3.8"
files = [
{file = "httpx-0.27.0-py3-none-any.whl", hash = "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5"},
{file = "httpx-0.27.0.tar.gz", hash = "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5"},
]
[package.dependencies]
anyio = "*"
certifi = "*"
httpcore = "==1.*"
idna = "*"
sniffio = "*"
socksio = {version = "==1.*", optional = true, markers = "extra == \"socks\""}
[package.extras]
brotli = ["brotli", "brotlicffi"]
cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"]
[[package]]
name = "idna"
version = "3.8"
description = "Internationalized Domain Names in Applications (IDNA)"
optional = false
python-versions = ">=3.6"
files = [
{file = "idna-3.8-py3-none-any.whl", hash = "sha256:050b4e5baadcd44d760cedbd2b8e639f2ff89bbc7a5730fcc662954303377aac"},
{file = "idna-3.8.tar.gz", hash = "sha256:d838c2c0ed6fced7693d5e8ab8e734d5f8fda53a039c0164afb0b82e771e3603"},
]
[[package]]
name = "mypy-extensions"
version = "1.0.0"
description = "Type system extensions for programs checked with the mypy type checker."
optional = false
python-versions = ">=3.5"
files = [
{file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"},
{file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"},
]
[[package]]
name = "packaging"
version = "24.1"
description = "Core utilities for Python packages"
optional = false
python-versions = ">=3.8"
files = [
{file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"},
{file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"},
]
[[package]]
name = "pathspec"
version = "0.12.1"
description = "Utility library for gitignore style pattern matching of file paths."
optional = false
python-versions = ">=3.8"
files = [
{file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"},
{file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"},
]
[[package]]
name = "platformdirs"
version = "4.2.2"
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
optional = false
python-versions = ">=3.8"
files = [
{file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"},
{file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"},
]
[package.extras]
docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"]
test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"]
type = ["mypy (>=1.8)"]
[[package]]
name = "pycryptodome"
version = "3.20.0"
description = "Cryptographic library for Python"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
files = [
{file = "pycryptodome-3.20.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:f0e6d631bae3f231d3634f91ae4da7a960f7ff87f2865b2d2b831af1dfb04e9a"},
{file = "pycryptodome-3.20.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:baee115a9ba6c5d2709a1e88ffe62b73ecc044852a925dcb67713a288c4ec70f"},
{file = "pycryptodome-3.20.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:417a276aaa9cb3be91f9014e9d18d10e840a7a9b9a9be64a42f553c5b50b4d1d"},
{file = "pycryptodome-3.20.0-cp27-cp27m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a1250b7ea809f752b68e3e6f3fd946b5939a52eaeea18c73bdab53e9ba3c2dd"},
{file = "pycryptodome-3.20.0-cp27-cp27m-musllinux_1_1_aarch64.whl", hash = "sha256:d5954acfe9e00bc83ed9f5cb082ed22c592fbbef86dc48b907238be64ead5c33"},
{file = "pycryptodome-3.20.0-cp27-cp27m-win32.whl", hash = "sha256:06d6de87c19f967f03b4cf9b34e538ef46e99a337e9a61a77dbe44b2cbcf0690"},
{file = "pycryptodome-3.20.0-cp27-cp27m-win_amd64.whl", hash = "sha256:ec0bb1188c1d13426039af8ffcb4dbe3aad1d7680c35a62d8eaf2a529b5d3d4f"},
{file = "pycryptodome-3.20.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:5601c934c498cd267640b57569e73793cb9a83506f7c73a8ec57a516f5b0b091"},
{file = "pycryptodome-3.20.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:d29daa681517f4bc318cd8a23af87e1f2a7bad2fe361e8aa29c77d652a065de4"},
{file = "pycryptodome-3.20.0-cp27-cp27mu-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3427d9e5310af6680678f4cce149f54e0bb4af60101c7f2c16fdf878b39ccccc"},
{file = "pycryptodome-3.20.0-cp27-cp27mu-musllinux_1_1_aarch64.whl", hash = "sha256:3cd3ef3aee1079ae44afaeee13393cf68b1058f70576b11439483e34f93cf818"},
{file = "pycryptodome-3.20.0-cp35-abi3-macosx_10_9_universal2.whl", hash = "sha256:ac1c7c0624a862f2e53438a15c9259d1655325fc2ec4392e66dc46cdae24d044"},
{file = "pycryptodome-3.20.0-cp35-abi3-macosx_10_9_x86_64.whl", hash = "sha256:76658f0d942051d12a9bd08ca1b6b34fd762a8ee4240984f7c06ddfb55eaf15a"},
{file = "pycryptodome-3.20.0-cp35-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f35d6cee81fa145333137009d9c8ba90951d7d77b67c79cbe5f03c7eb74d8fe2"},
{file = "pycryptodome-3.20.0-cp35-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76cb39afede7055127e35a444c1c041d2e8d2f1f9c121ecef573757ba4cd2c3c"},
{file = "pycryptodome-3.20.0-cp35-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49a4c4dc60b78ec41d2afa392491d788c2e06edf48580fbfb0dd0f828af49d25"},
{file = "pycryptodome-3.20.0-cp35-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:fb3b87461fa35afa19c971b0a2b7456a7b1db7b4eba9a8424666104925b78128"},
{file = "pycryptodome-3.20.0-cp35-abi3-musllinux_1_1_i686.whl", hash = "sha256:acc2614e2e5346a4a4eab6e199203034924313626f9620b7b4b38e9ad74b7e0c"},
{file = "pycryptodome-3.20.0-cp35-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:210ba1b647837bfc42dd5a813cdecb5b86193ae11a3f5d972b9a0ae2c7e9e4b4"},
{file = "pycryptodome-3.20.0-cp35-abi3-win32.whl", hash = "sha256:8d6b98d0d83d21fb757a182d52940d028564efe8147baa9ce0f38d057104ae72"},
{file = "pycryptodome-3.20.0-cp35-abi3-win_amd64.whl", hash = "sha256:9b3ae153c89a480a0ec402e23db8d8d84a3833b65fa4b15b81b83be9d637aab9"},
{file = "pycryptodome-3.20.0-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:4401564ebf37dfde45d096974c7a159b52eeabd9969135f0426907db367a652a"},
{file = "pycryptodome-3.20.0-pp27-pypy_73-win32.whl", hash = "sha256:ec1f93feb3bb93380ab0ebf8b859e8e5678c0f010d2d78367cf6bc30bfeb148e"},
{file = "pycryptodome-3.20.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:acae12b9ede49f38eb0ef76fdec2df2e94aad85ae46ec85be3648a57f0a7db04"},
{file = "pycryptodome-3.20.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f47888542a0633baff535a04726948e876bf1ed880fddb7c10a736fa99146ab3"},
{file = "pycryptodome-3.20.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e0e4a987d38cfc2e71b4a1b591bae4891eeabe5fa0f56154f576e26287bfdea"},
{file = "pycryptodome-3.20.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c18b381553638414b38705f07d1ef0a7cf301bc78a5f9bc17a957eb19446834b"},
{file = "pycryptodome-3.20.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a60fedd2b37b4cb11ccb5d0399efe26db9e0dd149016c1cc6c8161974ceac2d6"},
{file = "pycryptodome-3.20.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:405002eafad114a2f9a930f5db65feef7b53c4784495dd8758069b89baf68eab"},
{file = "pycryptodome-3.20.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2ab6ab0cb755154ad14e507d1df72de9897e99fd2d4922851a276ccc14f4f1a5"},
{file = "pycryptodome-3.20.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:acf6e43fa75aca2d33e93409f2dafe386fe051818ee79ee8a3e21de9caa2ac9e"},
{file = "pycryptodome-3.20.0.tar.gz", hash = "sha256:09609209ed7de61c2b560cc5c8c4fbf892f8b15b1faf7e4cbffac97db1fffda7"},
]
[[package]]
name = "pydantic"
version = "2.8.2"
description = "Data validation using Python type hints"
optional = false
python-versions = ">=3.8"
files = [
{file = "pydantic-2.8.2-py3-none-any.whl", hash = "sha256:73ee9fddd406dc318b885c7a2eab8a6472b68b8fb5ba8150949fc3db939f23c8"},
{file = "pydantic-2.8.2.tar.gz", hash = "sha256:6f62c13d067b0755ad1c21a34bdd06c0c12625a22b0fc09c6b149816604f7c2a"},
]
[package.dependencies]
annotated-types = ">=0.4.0"
pydantic-core = "2.20.1"
typing-extensions = [
{version = ">=4.12.2", markers = "python_version >= \"3.13\""},
{version = ">=4.6.1", markers = "python_version < \"3.13\""},
]
[package.extras]
email = ["email-validator (>=2.0.0)"]
[[package]]
name = "pydantic-core"
version = "2.20.1"
description = "Core functionality for Pydantic validation and serialization"
optional = false
python-versions = ">=3.8"
files = [
{file = "pydantic_core-2.20.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3acae97ffd19bf091c72df4d726d552c473f3576409b2a7ca36b2f535ffff4a3"},
{file = "pydantic_core-2.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:41f4c96227a67a013e7de5ff8f20fb496ce573893b7f4f2707d065907bffdbd6"},
{file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f239eb799a2081495ea659d8d4a43a8f42cd1fe9ff2e7e436295c38a10c286a"},
{file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53e431da3fc53360db73eedf6f7124d1076e1b4ee4276b36fb25514544ceb4a3"},
{file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1f62b2413c3a0e846c3b838b2ecd6c7a19ec6793b2a522745b0869e37ab5bc1"},
{file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d41e6daee2813ecceea8eda38062d69e280b39df793f5a942fa515b8ed67953"},
{file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d482efec8b7dc6bfaedc0f166b2ce349df0011f5d2f1f25537ced4cfc34fd98"},
{file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e93e1a4b4b33daed65d781a57a522ff153dcf748dee70b40c7258c5861e1768a"},
{file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e7c4ea22b6739b162c9ecaaa41d718dfad48a244909fe7ef4b54c0b530effc5a"},
{file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4f2790949cf385d985a31984907fecb3896999329103df4e4983a4a41e13e840"},
{file = "pydantic_core-2.20.1-cp310-none-win32.whl", hash = "sha256:5e999ba8dd90e93d57410c5e67ebb67ffcaadcea0ad973240fdfd3a135506250"},
{file = "pydantic_core-2.20.1-cp310-none-win_amd64.whl", hash = "sha256:512ecfbefef6dac7bc5eaaf46177b2de58cdf7acac8793fe033b24ece0b9566c"},
{file = "pydantic_core-2.20.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d2a8fa9d6d6f891f3deec72f5cc668e6f66b188ab14bb1ab52422fe8e644f312"},
{file = "pydantic_core-2.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:175873691124f3d0da55aeea1d90660a6ea7a3cfea137c38afa0a5ffabe37b88"},
{file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37eee5b638f0e0dcd18d21f59b679686bbd18917b87db0193ae36f9c23c355fc"},
{file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25e9185e2d06c16ee438ed39bf62935ec436474a6ac4f9358524220f1b236e43"},
{file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:150906b40ff188a3260cbee25380e7494ee85048584998c1e66df0c7a11c17a6"},
{file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ad4aeb3e9a97286573c03df758fc7627aecdd02f1da04516a86dc159bf70121"},
{file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3f3ed29cd9f978c604708511a1f9c2fdcb6c38b9aae36a51905b8811ee5cbf1"},
{file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b0dae11d8f5ded51699c74d9548dcc5938e0804cc8298ec0aa0da95c21fff57b"},
{file = "pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:faa6b09ee09433b87992fb5a2859efd1c264ddc37280d2dd5db502126d0e7f27"},
{file = "pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9dc1b507c12eb0481d071f3c1808f0529ad41dc415d0ca11f7ebfc666e66a18b"},
{file = "pydantic_core-2.20.1-cp311-none-win32.whl", hash = "sha256:fa2fddcb7107e0d1808086ca306dcade7df60a13a6c347a7acf1ec139aa6789a"},
{file = "pydantic_core-2.20.1-cp311-none-win_amd64.whl", hash = "sha256:40a783fb7ee353c50bd3853e626f15677ea527ae556429453685ae32280c19c2"},
{file = "pydantic_core-2.20.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:595ba5be69b35777474fa07f80fc260ea71255656191adb22a8c53aba4479231"},
{file = "pydantic_core-2.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a4f55095ad087474999ee28d3398bae183a66be4823f753cd7d67dd0153427c9"},
{file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9aa05d09ecf4c75157197f27cdc9cfaeb7c5f15021c6373932bf3e124af029f"},
{file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e97fdf088d4b31ff4ba35db26d9cc472ac7ef4a2ff2badeabf8d727b3377fc52"},
{file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc633a9fe1eb87e250b5c57d389cf28998e4292336926b0b6cdaee353f89a237"},
{file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d573faf8eb7e6b1cbbcb4f5b247c60ca8be39fe2c674495df0eb4318303137fe"},
{file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26dc97754b57d2fd00ac2b24dfa341abffc380b823211994c4efac7f13b9e90e"},
{file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:33499e85e739a4b60c9dac710c20a08dc73cb3240c9a0e22325e671b27b70d24"},
{file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bebb4d6715c814597f85297c332297c6ce81e29436125ca59d1159b07f423eb1"},
{file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:516d9227919612425c8ef1c9b869bbbee249bc91912c8aaffb66116c0b447ebd"},
{file = "pydantic_core-2.20.1-cp312-none-win32.whl", hash = "sha256:469f29f9093c9d834432034d33f5fe45699e664f12a13bf38c04967ce233d688"},
{file = "pydantic_core-2.20.1-cp312-none-win_amd64.whl", hash = "sha256:035ede2e16da7281041f0e626459bcae33ed998cca6a0a007a5ebb73414ac72d"},
{file = "pydantic_core-2.20.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:0827505a5c87e8aa285dc31e9ec7f4a17c81a813d45f70b1d9164e03a813a686"},
{file = "pydantic_core-2.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19c0fa39fa154e7e0b7f82f88ef85faa2a4c23cc65aae2f5aea625e3c13c735a"},
{file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa223cd1e36b642092c326d694d8bf59b71ddddc94cdb752bbbb1c5c91d833b"},
{file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c336a6d235522a62fef872c6295a42ecb0c4e1d0f1a3e500fe949415761b8a19"},
{file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7eb6a0587eded33aeefea9f916899d42b1799b7b14b8f8ff2753c0ac1741edac"},
{file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:70c8daf4faca8da5a6d655f9af86faf6ec2e1768f4b8b9d0226c02f3d6209703"},
{file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9fa4c9bf273ca41f940bceb86922a7667cd5bf90e95dbb157cbb8441008482c"},
{file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:11b71d67b4725e7e2a9f6e9c0ac1239bbc0c48cce3dc59f98635efc57d6dac83"},
{file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:270755f15174fb983890c49881e93f8f1b80f0b5e3a3cc1394a255706cabd203"},
{file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c81131869240e3e568916ef4c307f8b99583efaa60a8112ef27a366eefba8ef0"},
{file = "pydantic_core-2.20.1-cp313-none-win32.whl", hash = "sha256:b91ced227c41aa29c672814f50dbb05ec93536abf8f43cd14ec9521ea09afe4e"},
{file = "pydantic_core-2.20.1-cp313-none-win_amd64.whl", hash = "sha256:65db0f2eefcaad1a3950f498aabb4875c8890438bc80b19362cf633b87a8ab20"},
{file = "pydantic_core-2.20.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:4745f4ac52cc6686390c40eaa01d48b18997cb130833154801a442323cc78f91"},
{file = "pydantic_core-2.20.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a8ad4c766d3f33ba8fd692f9aa297c9058970530a32c728a2c4bfd2616d3358b"},
{file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41e81317dd6a0127cabce83c0c9c3fbecceae981c8391e6f1dec88a77c8a569a"},
{file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04024d270cf63f586ad41fff13fde4311c4fc13ea74676962c876d9577bcc78f"},
{file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eaad4ff2de1c3823fddf82f41121bdf453d922e9a238642b1dedb33c4e4f98ad"},
{file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26ab812fa0c845df815e506be30337e2df27e88399b985d0bb4e3ecfe72df31c"},
{file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c5ebac750d9d5f2706654c638c041635c385596caf68f81342011ddfa1e5598"},
{file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2aafc5a503855ea5885559eae883978c9b6d8c8993d67766ee73d82e841300dd"},
{file = "pydantic_core-2.20.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:4868f6bd7c9d98904b748a2653031fc9c2f85b6237009d475b1008bfaeb0a5aa"},
{file = "pydantic_core-2.20.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:aa2f457b4af386254372dfa78a2eda2563680d982422641a85f271c859df1987"},
{file = "pydantic_core-2.20.1-cp38-none-win32.whl", hash = "sha256:225b67a1f6d602de0ce7f6c1c3ae89a4aa25d3de9be857999e9124f15dab486a"},
{file = "pydantic_core-2.20.1-cp38-none-win_amd64.whl", hash = "sha256:6b507132dcfc0dea440cce23ee2182c0ce7aba7054576efc65634f080dbe9434"},
{file = "pydantic_core-2.20.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:b03f7941783b4c4a26051846dea594628b38f6940a2fdc0df00b221aed39314c"},
{file = "pydantic_core-2.20.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1eedfeb6089ed3fad42e81a67755846ad4dcc14d73698c120a82e4ccf0f1f9f6"},
{file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:635fee4e041ab9c479e31edda27fcf966ea9614fff1317e280d99eb3e5ab6fe2"},
{file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:77bf3ac639c1ff567ae3b47f8d4cc3dc20f9966a2a6dd2311dcc055d3d04fb8a"},
{file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ed1b0132f24beeec5a78b67d9388656d03e6a7c837394f99257e2d55b461611"},
{file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6514f963b023aeee506678a1cf821fe31159b925c4b76fe2afa94cc70b3222b"},
{file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10d4204d8ca33146e761c79f83cc861df20e7ae9f6487ca290a97702daf56006"},
{file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2d036c7187b9422ae5b262badb87a20a49eb6c5238b2004e96d4da1231badef1"},
{file = "pydantic_core-2.20.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9ebfef07dbe1d93efb94b4700f2d278494e9162565a54f124c404a5656d7ff09"},
{file = "pydantic_core-2.20.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:6b9d9bb600328a1ce523ab4f454859e9d439150abb0906c5a1983c146580ebab"},
{file = "pydantic_core-2.20.1-cp39-none-win32.whl", hash = "sha256:784c1214cb6dd1e3b15dd8b91b9a53852aed16671cc3fbe4786f4f1db07089e2"},
{file = "pydantic_core-2.20.1-cp39-none-win_amd64.whl", hash = "sha256:d2fe69c5434391727efa54b47a1e7986bb0186e72a41b203df8f5b0a19a4f669"},
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a45f84b09ac9c3d35dfcf6a27fd0634d30d183205230a0ebe8373a0e8cfa0906"},
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d02a72df14dfdbaf228424573a07af10637bd490f0901cee872c4f434a735b94"},
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2b27e6af28f07e2f195552b37d7d66b150adbaa39a6d327766ffd695799780f"},
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:084659fac3c83fd674596612aeff6041a18402f1e1bc19ca39e417d554468482"},
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:242b8feb3c493ab78be289c034a1f659e8826e2233786e36f2893a950a719bb6"},
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:38cf1c40a921d05c5edc61a785c0ddb4bed67827069f535d794ce6bcded919fc"},
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e0bbdd76ce9aa5d4209d65f2b27fc6e5ef1312ae6c5333c26db3f5ade53a1e99"},
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:254ec27fdb5b1ee60684f91683be95e5133c994cc54e86a0b0963afa25c8f8a6"},
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:407653af5617f0757261ae249d3fba09504d7a71ab36ac057c938572d1bc9331"},
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:c693e916709c2465b02ca0ad7b387c4f8423d1db7b4649c551f27a529181c5ad"},
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b5ff4911aea936a47d9376fd3ab17e970cc543d1b68921886e7f64bd28308d1"},
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:177f55a886d74f1808763976ac4efd29b7ed15c69f4d838bbd74d9d09cf6fa86"},
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:964faa8a861d2664f0c7ab0c181af0bea66098b1919439815ca8803ef136fc4e"},
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:4dd484681c15e6b9a977c785a345d3e378d72678fd5f1f3c0509608da24f2ac0"},
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f6d6cff3538391e8486a431569b77921adfcdef14eb18fbf19b7c0a5294d4e6a"},
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a6d511cc297ff0883bc3708b465ff82d7560193169a8b93260f74ecb0a5e08a7"},
{file = "pydantic_core-2.20.1.tar.gz", hash = "sha256:26ca695eeee5f9f1aeeb211ffc12f10bcb6f71e2989988fda61dabd65db878d4"},
]
[package.dependencies]
typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0"
[[package]]
name = "pydantic-settings"
version = "2.4.0"
description = "Settings management using Pydantic"
optional = false
python-versions = ">=3.8"
files = [
{file = "pydantic_settings-2.4.0-py3-none-any.whl", hash = "sha256:bb6849dc067f1687574c12a639e231f3a6feeed0a12d710c1382045c5db1c315"},
{file = "pydantic_settings-2.4.0.tar.gz", hash = "sha256:ed81c3a0f46392b4d7c0a565c05884e6e54b3456e6f0fe4d8814981172dc9a88"},
]
[package.dependencies]
pydantic = ">=2.7.0"
python-dotenv = ">=0.21.0"
[package.extras]
azure-key-vault = ["azure-identity (>=1.16.0)", "azure-keyvault-secrets (>=4.8.0)"]
toml = ["tomli (>=2.0.1)"]
yaml = ["pyyaml (>=6.0.1)"]
[[package]]
name = "python-dotenv"
version = "1.0.1"
description = "Read key-value pairs from a .env file and set them as environment variables"
optional = false
python-versions = ">=3.8"
files = [
{file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"},
{file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"},
]
[package.extras]
cli = ["click (>=5.0)"]
[[package]]
name = "sniffio"
version = "1.3.1"
description = "Sniff out which async library your code is running under"
optional = false
python-versions = ">=3.7"
files = [
{file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"},
{file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
]
[[package]]
name = "socksio"
version = "1.0.0"
description = "Sans-I/O implementation of SOCKS4, SOCKS4A, and SOCKS5."
optional = false
python-versions = ">=3.6"
files = [
{file = "socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3"},
{file = "socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac"},
]
[[package]]
name = "starlette"
version = "0.38.2"
description = "The little ASGI library that shines."
optional = false
python-versions = ">=3.8"
files = [
{file = "starlette-0.38.2-py3-none-any.whl", hash = "sha256:4ec6a59df6bbafdab5f567754481657f7ed90dc9d69b0c9ff017907dd54faeff"},
{file = "starlette-0.38.2.tar.gz", hash = "sha256:c7c0441065252160993a1a37cf2a73bb64d271b17303e0b0c1eb7191cfb12d75"},
]
[package.dependencies]
anyio = ">=3.4.0,<5"
[package.extras]
full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7)", "pyyaml"]
[[package]]
name = "tenacity"
version = "9.0.0"
description = "Retry code until it succeeds"
optional = false
python-versions = ">=3.8"
files = [
{file = "tenacity-9.0.0-py3-none-any.whl", hash = "sha256:93de0c98785b27fcf659856aa9f54bfbd399e29969b0621bc7f762bd441b4539"},
{file = "tenacity-9.0.0.tar.gz", hash = "sha256:807f37ca97d62aa361264d497b0e31e92b8027044942bfa756160d908320d73b"},
]
[package.extras]
doc = ["reno", "sphinx"]
test = ["pytest", "tornado (>=4.5)", "typeguard"]
[[package]]
name = "typing-extensions"
version = "4.12.2"
description = "Backported and Experimental Type Hints for Python 3.8+"
optional = false
python-versions = ">=3.8"
files = [
{file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
{file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
]
[[package]]
name = "uvicorn"
version = "0.30.6"
description = "The lightning-fast ASGI server."
optional = false
python-versions = ">=3.8"
files = [
{file = "uvicorn-0.30.6-py3-none-any.whl", hash = "sha256:65fd46fe3fda5bdc1b03b94eb634923ff18cd35b2f084813ea79d1f103f711b5"},
{file = "uvicorn-0.30.6.tar.gz", hash = "sha256:4b15decdda1e72be08209e860a1e10e92439ad5b97cf44cc945fcbee66fc5788"},
]
[package.dependencies]
click = ">=7.0"
h11 = ">=0.8"
[package.extras]
standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"]
[[package]]
name = "xmltodict"
version = "0.13.0"
description = "Makes working with XML feel like you are working with JSON"
optional = false
python-versions = ">=3.4"
files = [
{file = "xmltodict-0.13.0-py2.py3-none-any.whl", hash = "sha256:aa89e8fd76320154a40d19a0df04a4695fb9dc5ba977cbb68ab3e4eb225e7852"},
{file = "xmltodict-0.13.0.tar.gz", hash = "sha256:341595a488e3e01a85a9d8911d8912fd922ede5fecc4dce437eb4b6c8d037e56"},
]
[metadata]
lock-version = "2.0"
python-versions = "^3.12"
content-hash = "8cfbb5ac5e9e2098578646c06fbc895ae04531d66c9985635f06ee2b787e3c75"
+29
View File
@@ -0,0 +1,29 @@
[tool.poetry]
name = "mediaflow proxy"
version = "1.0.0"
description = "A high-performance proxy server for streaming media, supporting HTTP(S), HLS, and MPEG-DASH with real-time DRM decryption."
authors = ["mhdzumair <mhdzumair@gmail.com>"]
readme = "README.md"
[tool.poetry.dependencies]
python = "^3.12"
fastapi = "^0.112.0"
httpx = {extras = ["socks"], version = "^0.27.0"}
tenacity = "^9.0.0"
xmltodict = "^0.13.0"
cachetools = "^5.4.0"
pydantic-settings = "^2.4.0"
gunicorn = "^23.0.0"
pycryptodome = "^3.20.0"
uvicorn = "^0.30.6"
[tool.poetry.group.dev.dependencies]
black = "^24.8.0"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.black]
line-length = 120