Merge PR #78: Add support for httpx clients in service files

This commit is contained in:
Andy
2026-02-09 16:17:40 -07:00
8 changed files with 81 additions and 48 deletions

View File

@@ -57,7 +57,7 @@ dependencies = [
"chardet>=5.2.0,<6", "chardet>=5.2.0,<6",
"curl-cffi>=0.7.0b4,<0.14", "curl-cffi>=0.7.0b4,<0.14",
"pyplayready>=0.6.3,<0.7", "pyplayready>=0.6.3,<0.7",
"httpx>=0.28.1,<0.29", "httpx[http2]>=0.28.1,<0.29",
"cryptography>=45.0.0,<47", "cryptography>=45.0.0,<47",
"subby", "subby",
"aiohttp>=3.13.3,<4", "aiohttp>=3.13.3,<4",

View File

@@ -11,6 +11,7 @@ from Cryptodome.Util.Padding import unpad
from curl_cffi.requests import Session as CurlSession from curl_cffi.requests import Session as CurlSession
from m3u8.model import Key from m3u8.model import Key
from requests import Session from requests import Session
import httpx
class ClearKey: class ClearKey:
@@ -70,8 +71,8 @@ class ClearKey:
""" """
if not isinstance(m3u_key, Key): if not isinstance(m3u_key, Key):
raise ValueError(f"Provided M3U Key is in an unexpected type {m3u_key!r}") raise ValueError(f"Provided M3U Key is in an unexpected type {m3u_key!r}")
if not isinstance(session, (Session, CurlSession, type(None))): if not isinstance(session, (Session, CurlSession, httpx.Client, type(None))):
raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not a {type(session)}") 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"): if not m3u_key.method.startswith("AES"):
raise ValueError(f"Provided M3U Key is not an AES Clear Key, {m3u_key.method}") raise ValueError(f"Provided M3U Key is not an AES Clear Key, {m3u_key.method}")

View File

@@ -15,6 +15,7 @@ from urllib.parse import urljoin, urlparse
from uuid import UUID from uuid import UUID
from zlib import crc32 from zlib import crc32
import httpx
import requests import requests
from curl_cffi.requests import Session as CurlSession from curl_cffi.requests import Session as CurlSession
from langcodes import Language, tag_is_valid from langcodes import Language, tag_is_valid
@@ -50,7 +51,7 @@ class DASH:
self.url = url self.url = url
@classmethod @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: if not url:
raise requests.URLRequired("DASH manifest URL must be provided for relative path computations.") raise requests.URLRequired("DASH manifest URL must be provided for relative path computations.")
if not isinstance(url, str): if not isinstance(url, str):
@@ -58,16 +59,15 @@ class DASH:
if not session: if not session:
session = Session() session = Session()
elif not isinstance(session, (Session, CurlSession)): elif not isinstance(session, (Session, CurlSession, httpx.Client)):
raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {session!r}") raise TypeError(f"Expected session to be a {Session} or {CurlSession} or {httpx.Client}, not {session!r}")
try:
res = session.get(url, **args) 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: if res.url != url:
url = res.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) return DASH.from_text(res.text, url)
@classmethod @classmethod
@@ -261,8 +261,8 @@ class DASH:
): ):
if not session: if not session:
session = Session() session = Session()
elif not isinstance(session, (Session, CurlSession)): elif not isinstance(session, (Session, CurlSession, httpx.Client)):
raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {session!r}") raise TypeError(f"Expected session to be a {Session} or {CurlSession} or {httpx.Client}, not {session!r}")
if proxy: if proxy:
session.proxies.update({"all": proxy}) session.proxies.update({"all": proxy})

View File

@@ -15,6 +15,7 @@ from urllib.parse import urljoin
from uuid import UUID from uuid import UUID
from zlib import crc32 from zlib import crc32
import httpx
import m3u8 import m3u8
import requests import requests
from curl_cffi.requests import Response as CurlResponse 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: 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: if not manifest:
raise ValueError("HLS manifest must be provided.") raise ValueError("HLS manifest must be provided.")
if not isinstance(manifest, M3U8): if not isinstance(manifest, M3U8):
@@ -50,7 +51,7 @@ class HLS:
self.session = session or Session() self.session = session or Session()
@classmethod @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: if not url:
raise requests.URLRequired("HLS manifest URL must be provided.") raise requests.URLRequired("HLS manifest URL must be provided.")
if not isinstance(url, str): if not isinstance(url, str):
@@ -58,23 +59,15 @@ class HLS:
if not session: if not session:
session = Session() session = Session()
elif not isinstance(session, (Session, CurlSession)): elif not isinstance(session, (Session, CurlSession, httpx.Client)):
raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {session!r}") raise TypeError(f"Expected session to be a {Session} or {CurlSession} or {httpx.Client}, 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)}")
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) master = m3u8.loads(content, uri=url)
return cls(master, session) return cls(master, session)
@@ -265,7 +258,7 @@ class HLS:
save_path: Path, save_path: Path,
save_dir: Path, save_dir: Path,
progress: partial, progress: partial,
session: Optional[Union[Session, CurlSession]] = None, session: Optional[Union[Session, CurlSession, httpx.Client]] = None,
proxy: Optional[str] = None, proxy: Optional[str] = None,
max_workers: Optional[int] = None, max_workers: Optional[int] = None,
license_widevine: Optional[Callable] = None, license_widevine: Optional[Callable] = None,
@@ -274,8 +267,8 @@ class HLS:
) -> None: ) -> None:
if not session: if not session:
session = Session() session = Session()
elif not isinstance(session, (Session, CurlSession)): elif not isinstance(session, (Session, CurlSession, httpx.Client)):
raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {session!r}") raise TypeError(f"Expected session to be a {Session} or {CurlSession} or {httpx.Client}, not {session!r}")
if proxy: if proxy:
# Handle proxies differently based on session type # Handle proxies differently based on session type
@@ -848,7 +841,7 @@ class HLS:
@staticmethod @staticmethod
def parse_session_data_keys( 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]: ) -> list[m3u8.model.Key]:
"""Parse `com.apple.hls.keys` session data and return Key objects.""" """Parse `com.apple.hls.keys` session data and return Key objects."""
keys: list[m3u8.model.Key] = [] keys: list[m3u8.model.Key] = []
@@ -923,7 +916,7 @@ class HLS:
def get_track_kid_from_init( def get_track_kid_from_init(
master: M3U8, master: M3U8,
track: AnyTrack, track: AnyTrack,
session: Union[Session, CurlSession], session: Union[Session, CurlSession, httpx.Client],
) -> Optional[UUID]: ) -> Optional[UUID]:
""" """
Extract the track's Key ID from its init segment (EXT-X-MAP). Extract the track's Key ID from its init segment (EXT-X-MAP).
@@ -990,7 +983,7 @@ class HLS:
@staticmethod @staticmethod
def get_drm( def get_drm(
key: Union[m3u8.model.SessionKey, m3u8.model.Key], key: Union[m3u8.model.SessionKey, m3u8.model.Key],
session: Optional[Union[Session, CurlSession]] = None, session: Optional[Union[Session, CurlSession, httpx.Client]] = None,
) -> DRM_T: ) -> DRM_T:
""" """
Convert HLS EXT-X-KEY data to an initialized DRM object. 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. Raises a NotImplementedError if the key system is not supported.
""" """
if not isinstance(session, (Session, CurlSession, type(None))): if not isinstance(session, (Session, CurlSession, httpx.Client, type(None))):
raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {type(session)}") raise TypeError(f"Expected session to be a {Session} or {CurlSession} or {httpx.Client}, not {type(session)}")
if not session: if not session:
session = Session() session = Session()

View File

@@ -9,6 +9,7 @@ from functools import partial
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Optional, Union from typing import Any, Callable, Optional, Union
import httpx
import requests import requests
from curl_cffi.requests import Session as CurlSession from curl_cffi.requests import Session as CurlSession
from langcodes import Language, tag_is_valid from langcodes import Language, tag_is_valid
@@ -35,13 +36,13 @@ class ISM:
self.url = url self.url = url
@classmethod @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: if not url:
raise requests.URLRequired("ISM manifest URL must be provided") raise requests.URLRequired("ISM manifest URL must be provided")
if not session: if not session:
session = Session() session = Session()
elif not isinstance(session, (Session, CurlSession)): elif not isinstance(session, (Session, CurlSession, httpx.Client)):
raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {session!r}") raise TypeError(f"Expected session to be a {Session} or {CurlSession} or {httpx.Client}, not {session!r}")
res = session.get(url, **kwargs) res = session.get(url, **kwargs)
if res.url != url: if res.url != url:
url = res.url url = res.url

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
from typing import Optional, Union from typing import Optional, Union
import httpx
import m3u8 import m3u8
from curl_cffi.requests import Session as CurlSession from curl_cffi.requests import Session as CurlSession
from requests import Session from requests import Session
@@ -16,7 +17,7 @@ def parse(
master: m3u8.M3U8, master: m3u8.M3U8,
language: str, language: str,
*, *,
session: Optional[Union[Session, CurlSession]] = None, session: Optional[Union[Session, CurlSession, httpx.Client]] = None,
) -> Tracks: ) -> Tracks:
"""Parse a variant playlist to ``Tracks`` with basic information, defer DRM loading.""" """Parse a variant playlist to ``Tracks`` with basic information, defer DRM loading."""
tracks = HLS(master, session=session).to_tracks(language) tracks = HLS(master, session=session).to_tracks(language)

View File

@@ -13,6 +13,7 @@ from typing import Any, Callable, Iterable, Optional, Union
from uuid import UUID from uuid import UUID
from zlib import crc32 from zlib import crc32
import httpx
from curl_cffi.requests import Session as CurlSession from curl_cffi.requests import Session as CurlSession
from langcodes import Language from langcodes import Language
from requests import Session from requests import Session
@@ -613,8 +614,8 @@ class Track:
raise TypeError(f"Expected url to be a {str}, not {type(url)}") raise TypeError(f"Expected url to be a {str}, not {type(url)}")
if not isinstance(byte_range, (str, type(None))): if not isinstance(byte_range, (str, type(None))):
raise TypeError(f"Expected byte_range to be a {str}, not {type(byte_range)}") raise TypeError(f"Expected byte_range to be a {str}, not {type(byte_range)}")
if not isinstance(session, (Session, CurlSession, type(None))): if not isinstance(session, (Session, CurlSession, httpx.Client, type(None))):
raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {type(session)}") raise TypeError(f"Expected session to be a {Session} or {CurlSession} or {httpx.Client}, not {type(session)}")
if not url: if not url:
if self.descriptor != self.Descriptor.URL: if self.descriptor != self.Descriptor.URL:

40
uv.lock generated
View File

@@ -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" }, { 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]] [[package]]
name = "httpcore" name = "httpcore"
version = "1.0.9" 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" }, { 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]] [[package]]
name = "identify" name = "identify"
version = "2.6.16" version = "2.6.16"
@@ -1642,7 +1678,7 @@ dependencies = [
{ name = "curl-cffi" }, { name = "curl-cffi" },
{ name = "filelock" }, { name = "filelock" },
{ name = "fonttools" }, { name = "fonttools" },
{ name = "httpx" }, { name = "httpx", extra = ["http2"] },
{ name = "jsonpickle" }, { name = "jsonpickle" },
{ name = "langcodes" }, { name = "langcodes" },
{ name = "language-data" }, { name = "language-data" },
@@ -1700,7 +1736,7 @@ requires-dist = [
{ name = "curl-cffi", specifier = ">=0.7.0b4,<0.14" }, { name = "curl-cffi", specifier = ">=0.7.0b4,<0.14" },
{ name = "filelock", specifier = ">=3.20.3,<4" }, { name = "filelock", specifier = ">=3.20.3,<4" },
{ name = "fonttools", specifier = ">=4.60.2,<5" }, { 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 = "jsonpickle", specifier = ">=3.0.4,<5" },
{ name = "langcodes", specifier = ">=3.4.0,<4" }, { name = "langcodes", specifier = ">=3.4.0,<4" },
{ name = "language-data", specifier = ">=1.4.0" }, { name = "language-data", specifier = ">=1.4.0" },