From 78cee28128e7eb927de0fa5381bb3c7f5bdcadc7 Mon Sep 17 00:00:00 2001 From: mhdzumair Date: Sat, 7 Dec 2024 08:05:10 +0530 Subject: [PATCH] Add Support for Python 3.9 (downgrade support) --- mediaflow_proxy/configs.py | 4 +-- mediaflow_proxy/drm/decrypter.py | 42 +++++++++++++-------------- mediaflow_proxy/extractors/livetv.py | 4 +-- mediaflow_proxy/schemas.py | 36 +++++++++++++---------- mediaflow_proxy/utils/cache_utils.py | 2 +- mediaflow_proxy/utils/crypto_utils.py | 3 +- mediaflow_proxy/utils/http_utils.py | 14 ++++----- mediaflow_proxy/utils/mpd_utils.py | 28 +++++++++--------- poetry.lock | 5 ++-- pyproject.toml | 3 +- 10 files changed, 72 insertions(+), 69 deletions(-) diff --git a/mediaflow_proxy/configs.py b/mediaflow_proxy/configs.py index 061ff36..83eb95e 100644 --- a/mediaflow_proxy/configs.py +++ b/mediaflow_proxy/configs.py @@ -1,4 +1,4 @@ -from typing import Dict, Optional +from typing import Dict, Optional, Union import httpx from pydantic import BaseModel, Field @@ -26,7 +26,7 @@ class TransportConfig(BaseSettings): def get_mounts( self, async_http: bool = True - ) -> Dict[str, Optional[httpx.HTTPTransport | httpx.AsyncHTTPTransport]]: + ) -> Dict[str, Optional[Union[httpx.HTTPTransport, httpx.AsyncHTTPTransport]]]: """ Get a dictionary of httpx mount points to transport instances. """ diff --git a/mediaflow_proxy/drm/decrypter.py b/mediaflow_proxy/drm/decrypter.py index d91320a..a6514a9 100644 --- a/mediaflow_proxy/drm/decrypter.py +++ b/mediaflow_proxy/drm/decrypter.py @@ -1,6 +1,7 @@ import argparse import struct import sys +from typing import Optional, Union from Crypto.Cipher import AES from collections import namedtuple @@ -17,14 +18,14 @@ class MP4Atom: __slots__ = ("atom_type", "size", "data") - def __init__(self, atom_type: bytes, size: int, data: memoryview | bytearray): + def __init__(self, atom_type: bytes, size: int, data: Union[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. + data (Union[memoryview, bytearray]): The data contained in the atom. """ self.atom_type = atom_type self.size = size @@ -58,12 +59,12 @@ class MP4Parser: self.data = data self.position = 0 - def read_atom(self) -> MP4Atom | None: + def read_atom(self) -> Optional[MP4Atom]: """ Reads the next atom from the data. Returns: - MP4Atom | None: MP4Atom object or None if no more atoms are available. + Optional[MP4Atom]: MP4Atom object or None if no more atoms are available. """ pos = self.position if pos + 8 > len(self.data): @@ -103,7 +104,7 @@ class MP4Parser: self.position = original_position return atoms - def _read_atom_at(self, pos: int, end: int) -> MP4Atom | None: + def _read_atom_at(self, pos: int, end: int) -> Optional[MP4Atom]: if pos + 8 > end: return None @@ -169,7 +170,7 @@ class MP4Decrypter: Attributes: key_map (dict[bytes, bytes]): Mapping of track IDs to decryption keys. - current_key (bytes | None): Current decryption key. + current_key (Optional[bytes]): 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. @@ -230,17 +231,16 @@ class MP4Decrypter: 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 + if atom_type == b"moov": + return self._process_moov(atom) + elif atom_type == b"moof": + return self._process_moof(atom) + elif atom_type == b"sidx": + return self._process_sidx(atom) + elif atom_type == b"mdat": + return self._decrypt_mdat(atom) + else: + return atom def _process_moov(self, moov: MP4Atom) -> MP4Atom: """ @@ -428,7 +428,7 @@ class MP4Decrypter: @staticmethod def _process_sample( sample: memoryview, sample_info: CENCSampleAuxiliaryDataFormat, key: bytes - ) -> memoryview | bytearray | bytes: + ) -> Union[memoryview, bytearray, bytes]: """ Processes and decrypts a sample using the provided sample information and decryption key. This includes handling sub-sample encryption if present. @@ -439,7 +439,7 @@ class MP4Decrypter: key (bytes): The decryption key. Returns: - memoryview | bytearray | bytes: The decrypted sample. + Union[memoryview, bytearray, bytes]: The decrypted sample. """ if not sample_info.is_encrypted: return sample @@ -701,7 +701,7 @@ class MP4Decrypter: 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: + def _extract_codec_format(self, sinf: MP4Atom) -> Optional[bytes]: """ Extracts the codec format from the 'sinf' (Protection Scheme Information) atom. This includes information about the original format of the protected content. @@ -710,7 +710,7 @@ class MP4Decrypter: sinf (MP4Atom): The 'sinf' atom to extract from. Returns: - bytes | None: The codec format or None if not found. + Optional[bytes]: The codec format or None if not found. """ parser = MP4Parser(sinf.data) for atom in iter(parser.read_atom, None): diff --git a/mediaflow_proxy/extractors/livetv.py b/mediaflow_proxy/extractors/livetv.py index 8106aed..fbe0c93 100644 --- a/mediaflow_proxy/extractors/livetv.py +++ b/mediaflow_proxy/extractors/livetv.py @@ -1,5 +1,5 @@ import re -from typing import Dict, Tuple +from typing import Dict, Tuple, Optional from urllib.parse import urljoin, urlparse, unquote from httpx import Response @@ -92,7 +92,7 @@ class LiveTVExtractor(BaseExtractor): except Exception as e: raise ExtractorError(f"Extraction failed: {str(e)}") - async def _extract_player_api_base(self, html_content: str) -> Tuple[str | None, str | None]: + async def _extract_player_api_base(self, html_content: str) -> Tuple[Optional[str], Optional[str]]: """Extract player API base URL and method.""" admin_ajax_pattern = r'"player_api"\s*:\s*"([^"]+)".*?"play_method"\s*:\s*"([^"]+)"' match = re.search(admin_ajax_pattern, html_content) diff --git a/mediaflow_proxy/schemas.py b/mediaflow_proxy/schemas.py index 2df187f..db8d9f6 100644 --- a/mediaflow_proxy/schemas.py +++ b/mediaflow_proxy/schemas.py @@ -1,24 +1,28 @@ -from typing import Literal, Dict, Any +from typing import Literal, Dict, Any, Optional from pydantic import BaseModel, Field, IPvAnyAddress, ConfigDict class GenerateUrlRequest(BaseModel): mediaflow_proxy_url: str = Field(..., description="The base URL for the mediaflow proxy.") - endpoint: str | None = Field(None, description="The specific endpoint to be appended to the base URL.") - destination_url: str | None = Field(None, description="The destination URL to which the request will be proxied.") - query_params: dict | None = Field( + endpoint: Optional[str] = Field(None, description="The specific endpoint to be appended to the base URL.") + destination_url: Optional[str] = Field( + None, description="The destination URL to which the request will be proxied." + ) + query_params: Optional[dict] = Field( default_factory=dict, description="Query parameters to be included in the request." ) - request_headers: dict | None = Field(default_factory=dict, description="Headers to be included in the request.") - response_headers: dict | None = Field(default_factory=dict, description="Headers to be included in the response.") - expiration: int | None = Field( + request_headers: Optional[dict] = Field(default_factory=dict, description="Headers to be included in the request.") + response_headers: Optional[dict] = Field( + default_factory=dict, description="Headers to be included in the response." + ) + expiration: Optional[int] = Field( None, description="Expiration time for the URL in seconds. If not provided, the URL will not expire." ) - api_password: str | None = Field( + api_password: Optional[str] = Field( None, description="API password for encryption. If not provided, the URL will only be encoded." ) - ip: IPvAnyAddress | None = Field(None, description="The IP address to restrict the URL to.") + ip: Optional[IPvAnyAddress] = Field(None, description="The IP address to restrict the URL to.") class GenericParams(BaseModel): @@ -27,7 +31,7 @@ class GenericParams(BaseModel): class HLSManifestParams(GenericParams): destination: str = Field(..., description="The URL of the HLS manifest.", alias="d") - key_url: str | None = Field( + key_url: Optional[str] = Field( None, description="The HLS Key URL to replace the original key URL. Defaults to None. (Useful for bypassing some sneaky protection)", ) @@ -39,23 +43,23 @@ class ProxyStreamParams(GenericParams): class MPDManifestParams(GenericParams): destination: str = Field(..., description="The URL of the MPD manifest.", alias="d") - key_id: str | None = Field(None, description="The DRM key ID (optional).") - key: str | None = Field(None, description="The DRM key (optional).") + key_id: Optional[str] = Field(None, description="The DRM key ID (optional).") + key: Optional[str] = Field(None, description="The DRM key (optional).") class MPDPlaylistParams(GenericParams): destination: str = Field(..., description="The URL of the MPD manifest.", alias="d") profile_id: str = Field(..., description="The profile ID to generate the playlist for.") - key_id: str | None = Field(None, description="The DRM key ID (optional).") - key: str | None = Field(None, description="The DRM key (optional).") + key_id: Optional[str] = Field(None, description="The DRM key ID (optional).") + key: Optional[str] = Field(None, description="The DRM key (optional).") class MPDSegmentParams(GenericParams): init_url: str = Field(..., description="The URL of the initialization segment.") segment_url: str = Field(..., description="The URL of the media segment.") mime_type: str = Field(..., description="The MIME type of the segment.") - key_id: str | None = Field(None, description="The DRM key ID (optional).") - key: str | None = Field(None, description="The DRM key (optional).") + key_id: Optional[str] = Field(None, description="The DRM key ID (optional).") + key: Optional[str] = Field(None, description="The DRM key (optional).") class ExtractorURLParams(GenericParams): diff --git a/mediaflow_proxy/utils/cache_utils.py b/mediaflow_proxy/utils/cache_utils.py index 5d815b8..999c55e 100644 --- a/mediaflow_proxy/utils/cache_utils.py +++ b/mediaflow_proxy/utils/cache_utils.py @@ -341,7 +341,7 @@ async def get_cached_mpd( mpd_url: str, headers: dict, parse_drm: bool, - parse_segment_profile_id: str | None = None, + parse_segment_profile_id: Optional[str] = None, ) -> dict: """Get MPD from cache or download and parse it.""" # Try cache first diff --git a/mediaflow_proxy/utils/crypto_utils.py b/mediaflow_proxy/utils/crypto_utils.py index 1e5bb40..b20e1dd 100644 --- a/mediaflow_proxy/utils/crypto_utils.py +++ b/mediaflow_proxy/utils/crypto_utils.py @@ -3,6 +3,7 @@ import json import logging import time import traceback +from typing import Optional from urllib.parse import urlencode from Crypto.Cipher import AES @@ -89,7 +90,7 @@ class EncryptionMiddleware(BaseHTTPMiddleware): return response @staticmethod - def get_client_ip(request: Request) -> str | None: + def get_client_ip(request: Request) -> Optional[str]: """ Extract the client's real IP address from the request headers or fallback to the client host. """ diff --git a/mediaflow_proxy/utils/http_utils.py b/mediaflow_proxy/utils/http_utils.py index 6b5c743..1a594e2 100644 --- a/mediaflow_proxy/utils/http_utils.py +++ b/mediaflow_proxy/utils/http_utils.py @@ -250,11 +250,11 @@ async def request_with_retry(method: str, url: str, headers: dict, **kwargs) -> 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, - response_headers: dict | None = None, + endpoint: typing.Optional[str] = None, + destination_url: typing.Optional[str] = None, + query_params: typing.Optional[dict] = None, + request_headers: typing.Optional[dict] = None, + response_headers: typing.Optional[dict] = None, encryption_handler: EncryptionHandler = None, expiration: int = None, ip: str = None, @@ -416,10 +416,6 @@ class EnhancedStreamingResponse(Response): async def wrap(func: typing.Callable[[], typing.Awaitable[None]]) -> None: try: await func() - except ExceptionGroup as e: - if not any(isinstance(exc, anyio.get_cancelled_exc_class()) for exc in e.exceptions): - logger.exception("Error in streaming task") - raise except Exception as e: if not isinstance(e, anyio.get_cancelled_exc_class()): logger.exception("Error in streaming task") diff --git a/mediaflow_proxy/utils/mpd_utils.py b/mediaflow_proxy/utils/mpd_utils.py index d900758..5603694 100644 --- a/mediaflow_proxy/utils/mpd_utils.py +++ b/mediaflow_proxy/utils/mpd_utils.py @@ -2,7 +2,7 @@ import logging import math import re from datetime import datetime, timedelta, timezone -from typing import List, Dict +from typing import List, Dict, Optional, Union from urllib.parse import urljoin import xmltodict @@ -10,12 +10,12 @@ import xmltodict logger = logging.getLogger(__name__) -def parse_mpd(mpd_content: str | bytes) -> dict: +def parse_mpd(mpd_content: Union[str, bytes]) -> dict: """ Parses the MPD content into a dictionary. Args: - mpd_content (str | bytes): The MPD content to parse. + mpd_content (Union[str, bytes]): The MPD content to parse. Returns: dict: The parsed MPD content as a dictionary. @@ -24,7 +24,7 @@ def parse_mpd(mpd_content: str | bytes) -> dict: def parse_mpd_dict( - mpd_dict: dict, mpd_url: str, parse_drm: bool = True, parse_segment_profile_id: str | None = None + mpd_dict: dict, mpd_url: str, parse_drm: bool = True, parse_segment_profile_id: Optional[str] = None ) -> dict: """ Parses the MPD dictionary and extracts relevant information. @@ -122,7 +122,7 @@ def extract_drm_info(periods: List[Dict], mpd_url: str) -> Dict: drm_info = {"isDrmProtected": False} for period in periods: - adaptation_sets: list[dict] | dict = period.get("AdaptationSet", []) + adaptation_sets: Union[list[dict], dict] = period.get("AdaptationSet", []) if not isinstance(adaptation_sets, list): adaptation_sets = [adaptation_sets] @@ -131,7 +131,7 @@ def extract_drm_info(periods: List[Dict], mpd_url: str) -> Dict: process_content_protection(adaptation_set.get("ContentProtection", []), drm_info) # Check ContentProtection inside each Representation - representations: list[dict] | dict = adaptation_set.get("Representation", []) + representations: Union[list[dict], dict] = adaptation_set.get("Representation", []) if not isinstance(representations, list): representations = [representations] @@ -145,12 +145,12 @@ def extract_drm_info(periods: List[Dict], mpd_url: str) -> Dict: return drm_info -def process_content_protection(content_protection: list[dict] | dict, drm_info: dict): +def process_content_protection(content_protection: Union[list[dict], dict], drm_info: dict): """ Processes the ContentProtection elements to extract DRM information. Args: - content_protection (list[dict] | dict): The ContentProtection elements. + content_protection (Union[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. @@ -197,8 +197,8 @@ def parse_representation( adaptation: dict, source: str, media_presentation_duration: str, - parse_segment_profile_id: str | None, -) -> dict | None: + parse_segment_profile_id: Optional[str], +) -> Optional[dict]: """ Parses a representation and extracts profile information. @@ -211,7 +211,7 @@ def parse_representation( 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. + Optional[dict]: 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" @@ -252,7 +252,7 @@ def parse_representation( return profile -def _get_key(adaptation: dict, representation: dict, key: str) -> str | None: +def _get_key(adaptation: dict, representation: dict, key: str) -> Optional[str]: """ Retrieves a key from the representation or adaptation set. @@ -262,7 +262,7 @@ def _get_key(adaptation: dict, representation: dict, key: str) -> str | None: key (str): The key to retrieve. Returns: - str | None: The value of the key or None if not found. + Optional[str]: The value of the key or None if not found. """ return representation.get(key, adaptation.get(key, None)) @@ -454,7 +454,7 @@ def generate_vod_segments(profile: dict, duration: int, timescale: int, start_nu 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: +def create_segment_data(segment: Dict, item: dict, profile: dict, source: str, timescale: Optional[int] = None) -> Dict: """ Creates segment data based on the segment information. This includes the segment URL and metadata. diff --git a/poetry.lock b/poetry.lock index 7589c16..dbe63a8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -634,6 +634,7 @@ files = [ [package.dependencies] anyio = ">=3.4.0,<5" +typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""} [package.extras] full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7)", "pyyaml"] @@ -870,5 +871,5 @@ cffi = ["cffi (>=1.11)"] [metadata] lock-version = "2.0" -python-versions = ">=3.10" -content-hash = "aed582c8fad0d90ed52a4d1965ea0e97a1ca138f6648f8e7abe8f530a1351274" +python-versions = ">=3.9" +content-hash = "34e26a9555c523a586448f1dc66e6898c5b02efcb3c6ad07e85d73381aa9f024" diff --git a/pyproject.toml b/pyproject.toml index 6253520..8ec3ebd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ classifiers = [ "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -22,7 +23,7 @@ include = ["LICENSE", "README.md", "mediaflow_proxy/static/*"] [tool.poetry.dependencies] -python = ">=3.10" +python = ">=3.9" fastapi = "0.115.6" httpx = {extras = ["socks", "zstd"], version = "^0.28.1"} tenacity = "^9.0.0"