diff --git a/pyproject.toml b/pyproject.toml index 5a4db39..34356b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,7 +57,7 @@ dependencies = [ "chardet>=5.2.0,<6", "curl-cffi>=0.7.0b4,<0.14", "pyplayready>=0.6.3,<0.7", - "httpx>=0.28.1,<0.29", + "httpx[http2]>=0.28.1,<0.29", "cryptography>=45.0.0,<47", "subby", "aiohttp>=3.13.3,<4", diff --git a/unshackle/core/drm/clearkey.py b/unshackle/core/drm/clearkey.py index 089fa71..e06c2bc 100644 --- a/unshackle/core/drm/clearkey.py +++ b/unshackle/core/drm/clearkey.py @@ -11,6 +11,7 @@ from Cryptodome.Util.Padding import unpad from curl_cffi.requests import Session as CurlSession from m3u8.model import Key from requests import Session +import httpx class ClearKey: @@ -70,8 +71,8 @@ class ClearKey: """ if not isinstance(m3u_key, Key): raise ValueError(f"Provided M3U Key is in an unexpected type {m3u_key!r}") - if not isinstance(session, (Session, CurlSession, type(None))): - raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not a {type(session)}") + if not isinstance(session, (Session, CurlSession, httpx.Client, type(None))): + raise TypeError(f"Expected session to be a {Session} or {CurlSession} or {httpx.Client}, not a {type(session)}") if not m3u_key.method.startswith("AES"): raise ValueError(f"Provided M3U Key is not an AES Clear Key, {m3u_key.method}") diff --git a/unshackle/core/manifests/dash.py b/unshackle/core/manifests/dash.py index 9585ee0..892aa45 100644 --- a/unshackle/core/manifests/dash.py +++ b/unshackle/core/manifests/dash.py @@ -15,6 +15,7 @@ from urllib.parse import urljoin, urlparse from uuid import UUID from zlib import crc32 +import httpx import requests from curl_cffi.requests import Session as CurlSession from langcodes import Language, tag_is_valid @@ -50,7 +51,7 @@ class DASH: self.url = url @classmethod - def from_url(cls, url: str, session: Optional[Union[Session, CurlSession]] = None, **args: Any) -> DASH: + def from_url(cls, url: str, session: Optional[Union[Session, CurlSession, httpx.Client]] = None, **args: Any) -> DASH: if not url: raise requests.URLRequired("DASH manifest URL must be provided for relative path computations.") if not isinstance(url, str): @@ -58,16 +59,15 @@ class DASH: if not session: session = Session() - elif not isinstance(session, (Session, CurlSession)): - raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {session!r}") - - res = session.get(url, **args) + elif not isinstance(session, (Session, CurlSession, httpx.Client)): + raise TypeError(f"Expected session to be a {Session} or {CurlSession} or {httpx.Client}, not {session!r}") + try: + res = session.get(url, **args) + res.raise_for_status() + except Exception as e: + raise RuntimeError("Failed to request the MPD document.") from e if res.url != url: url = res.url - - if not res.ok: - raise requests.ConnectionError("Failed to request the MPD document.", response=res) - return DASH.from_text(res.text, url) @classmethod @@ -261,8 +261,8 @@ class DASH: ): if not session: session = Session() - elif not isinstance(session, (Session, CurlSession)): - raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {session!r}") + elif not isinstance(session, (Session, CurlSession, httpx.Client)): + raise TypeError(f"Expected session to be a {Session} or {CurlSession} or {httpx.Client}, not {session!r}") if proxy: session.proxies.update({"all": proxy}) diff --git a/unshackle/core/manifests/hls.py b/unshackle/core/manifests/hls.py index ca7dc5d..8c10047 100644 --- a/unshackle/core/manifests/hls.py +++ b/unshackle/core/manifests/hls.py @@ -15,6 +15,7 @@ from urllib.parse import urljoin from uuid import UUID from zlib import crc32 +import httpx import m3u8 import requests from curl_cffi.requests import Response as CurlResponse @@ -38,7 +39,7 @@ from unshackle.core.utilities import get_debug_logger, get_extension, is_close_m class HLS: - def __init__(self, manifest: M3U8, session: Optional[Union[Session, CurlSession]] = None): + def __init__(self, manifest: M3U8, session: Optional[Union[Session, CurlSession, httpx.Client]] = None): if not manifest: raise ValueError("HLS manifest must be provided.") if not isinstance(manifest, M3U8): @@ -50,7 +51,7 @@ class HLS: self.session = session or Session() @classmethod - def from_url(cls, url: str, session: Optional[Union[Session, CurlSession]] = None, **args: Any) -> HLS: + def from_url(cls, url: str, session: Optional[Union[Session, CurlSession, httpx.Client]] = None, **args: Any) -> HLS: if not url: raise requests.URLRequired("HLS manifest URL must be provided.") if not isinstance(url, str): @@ -58,23 +59,15 @@ class HLS: if not session: session = Session() - elif not isinstance(session, (Session, CurlSession)): - raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {session!r}") - - res = session.get(url, **args) - - # Handle requests and curl_cffi response objects - if isinstance(res, requests.Response): - if not res.ok: - raise requests.ConnectionError("Failed to request the M3U(8) document.", response=res) - content = res.text - elif isinstance(res, CurlResponse): - if not res.ok: - raise requests.ConnectionError("Failed to request the M3U(8) document.", response=res) - content = res.text - else: - raise TypeError(f"Expected response to be a requests.Response or curl_cffi.Response, not {type(res)}") + elif not isinstance(session, (Session, CurlSession, httpx.Client)): + raise TypeError(f"Expected session to be a {Session} or {CurlSession} or {httpx.Client}, not {session!r}") + try: + res = session.get(url, **args) + res.raise_for_status() + except Exception as e: + raise RuntimeError("Failed to request the M3U(8) document.") from e + content = res.text master = m3u8.loads(content, uri=url) return cls(master, session) @@ -265,7 +258,7 @@ class HLS: save_path: Path, save_dir: Path, progress: partial, - session: Optional[Union[Session, CurlSession]] = None, + session: Optional[Union[Session, CurlSession, httpx.Client]] = None, proxy: Optional[str] = None, max_workers: Optional[int] = None, license_widevine: Optional[Callable] = None, @@ -274,8 +267,8 @@ class HLS: ) -> None: if not session: session = Session() - elif not isinstance(session, (Session, CurlSession)): - raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {session!r}") + elif not isinstance(session, (Session, CurlSession, httpx.Client)): + raise TypeError(f"Expected session to be a {Session} or {CurlSession} or {httpx.Client}, not {session!r}") if proxy: # Handle proxies differently based on session type @@ -848,7 +841,7 @@ class HLS: @staticmethod def parse_session_data_keys( - manifest: M3U8, session: Optional[Union[Session, CurlSession]] = None + manifest: M3U8, session: Optional[Union[Session, CurlSession, httpx.Client]] = None ) -> list[m3u8.model.Key]: """Parse `com.apple.hls.keys` session data and return Key objects.""" keys: list[m3u8.model.Key] = [] @@ -923,7 +916,7 @@ class HLS: def get_track_kid_from_init( master: M3U8, track: AnyTrack, - session: Union[Session, CurlSession], + session: Union[Session, CurlSession, httpx.Client], ) -> Optional[UUID]: """ Extract the track's Key ID from its init segment (EXT-X-MAP). @@ -990,7 +983,7 @@ class HLS: @staticmethod def get_drm( key: Union[m3u8.model.SessionKey, m3u8.model.Key], - session: Optional[Union[Session, CurlSession]] = None, + session: Optional[Union[Session, CurlSession, httpx.Client]] = None, ) -> DRM_T: """ Convert HLS EXT-X-KEY data to an initialized DRM object. @@ -1002,8 +995,8 @@ class HLS: Raises a NotImplementedError if the key system is not supported. """ - if not isinstance(session, (Session, CurlSession, type(None))): - raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {type(session)}") + if not isinstance(session, (Session, CurlSession, httpx.Client, type(None))): + raise TypeError(f"Expected session to be a {Session} or {CurlSession} or {httpx.Client}, not {type(session)}") if not session: session = Session() diff --git a/unshackle/core/manifests/ism.py b/unshackle/core/manifests/ism.py index b047f5e..74d4aa2 100644 --- a/unshackle/core/manifests/ism.py +++ b/unshackle/core/manifests/ism.py @@ -9,6 +9,7 @@ from functools import partial from pathlib import Path from typing import Any, Callable, Optional, Union +import httpx import requests from curl_cffi.requests import Session as CurlSession from langcodes import Language, tag_is_valid @@ -35,13 +36,13 @@ class ISM: self.url = url @classmethod - def from_url(cls, url: str, session: Optional[Union[Session, CurlSession]] = None, **kwargs: Any) -> "ISM": + def from_url(cls, url: str, session: Optional[Union[Session, CurlSession, httpx.Client]] = None, **kwargs: Any) -> "ISM": if not url: raise requests.URLRequired("ISM manifest URL must be provided") if not session: session = Session() - elif not isinstance(session, (Session, CurlSession)): - raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {session!r}") + elif not isinstance(session, (Session, CurlSession, httpx.Client)): + raise TypeError(f"Expected session to be a {Session} or {CurlSession} or {httpx.Client}, not {session!r}") res = session.get(url, **kwargs) if res.url != url: url = res.url diff --git a/unshackle/core/manifests/m3u8.py b/unshackle/core/manifests/m3u8.py index 761d73c..477d776 100644 --- a/unshackle/core/manifests/m3u8.py +++ b/unshackle/core/manifests/m3u8.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import Optional, Union +import httpx import m3u8 from curl_cffi.requests import Session as CurlSession from requests import Session @@ -16,7 +17,7 @@ def parse( master: m3u8.M3U8, language: str, *, - session: Optional[Union[Session, CurlSession]] = None, + session: Optional[Union[Session, CurlSession, httpx.Client]] = None, ) -> Tracks: """Parse a variant playlist to ``Tracks`` with basic information, defer DRM loading.""" tracks = HLS(master, session=session).to_tracks(language) diff --git a/unshackle/core/tracks/track.py b/unshackle/core/tracks/track.py index b9e3720..1e392ee 100644 --- a/unshackle/core/tracks/track.py +++ b/unshackle/core/tracks/track.py @@ -13,6 +13,7 @@ from typing import Any, Callable, Iterable, Optional, Union from uuid import UUID from zlib import crc32 +import httpx from curl_cffi.requests import Session as CurlSession from langcodes import Language from requests import Session @@ -613,8 +614,8 @@ class Track: raise TypeError(f"Expected url to be a {str}, not {type(url)}") if not isinstance(byte_range, (str, type(None))): raise TypeError(f"Expected byte_range to be a {str}, not {type(byte_range)}") - if not isinstance(session, (Session, CurlSession, type(None))): - raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {type(session)}") + if not isinstance(session, (Session, CurlSession, httpx.Client, type(None))): + raise TypeError(f"Expected session to be a {Session} or {CurlSession} or {httpx.Client}, not {type(session)}") if not url: if self.descriptor != self.Descriptor.URL: diff --git a/uv.lock b/uv.lock index b6e94c6..19e56b5 100644 --- a/uv.lock +++ b/uv.lock @@ -608,6 +608,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "h2" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, +] + +[[package]] +name = "hpack" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -636,6 +658,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[package.optional-dependencies] +http2 = [ + { name = "h2" }, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + [[package]] name = "identify" version = "2.6.16" @@ -1642,7 +1678,7 @@ dependencies = [ { name = "curl-cffi" }, { name = "filelock" }, { name = "fonttools" }, - { name = "httpx" }, + { name = "httpx", extra = ["http2"] }, { name = "jsonpickle" }, { name = "langcodes" }, { name = "language-data" }, @@ -1700,7 +1736,7 @@ requires-dist = [ { name = "curl-cffi", specifier = ">=0.7.0b4,<0.14" }, { name = "filelock", specifier = ">=3.20.3,<4" }, { name = "fonttools", specifier = ">=4.60.2,<5" }, - { name = "httpx", specifier = ">=0.28.1,<0.29" }, + { name = "httpx", extras = ["http2"], specifier = ">=0.28.1,<0.29" }, { name = "jsonpickle", specifier = ">=3.0.4,<5" }, { name = "langcodes", specifier = ">=3.4.0,<4" }, { name = "language-data", specifier = ">=1.4.0" },