mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-06-15 05:32:04 +00:00
feat(session): replace curl_cffi with rnet for TLS-fingerprinted HTTP
Replace CurlSession (curl_cffi) with RnetSession powered by rnet (Rust/BoringSSL). Benchmarks show 3.5x faster segmented downloads (1.06 GB/s vs 304 MB/s) and 16% faster single-file downloads with near-zero TLS fingerprinting overhead. - Add RnetSession wrapper with requests-compatible API (headers, cookies, proxies, retry logic, prepared requests) - Add RnetResponse wrapper normalizing rnet quirks (status_code as int, text as property, bytes-to-str headers, iter_content re-chunking) - Replace CurlSession isinstance checks across manifests, tracks, DRM - Update downloader with rnet native streaming path and byte-based progress tracking for accurate Rich progress bars - Add speed display column to Rich progress bar (DASH/HLS/URL prefix) - Add rnet dependency, services use exact preset names (e.g. OkHttp4_12)
This commit is contained in:
@@ -36,7 +36,6 @@ dependencies = [
|
|||||||
"jsonpickle>=3.0.4,<5",
|
"jsonpickle>=3.0.4,<5",
|
||||||
"langcodes>=3.4.0,<4",
|
"langcodes>=3.4.0,<4",
|
||||||
"lxml>=5.2.1,<7",
|
"lxml>=5.2.1,<7",
|
||||||
"pproxy>=2.7.9,<3",
|
|
||||||
"protobuf>=4.25.3,<7",
|
"protobuf>=4.25.3,<7",
|
||||||
"pycaption>=2.2.6,<3",
|
"pycaption>=2.2.6,<3",
|
||||||
"pycryptodomex>=3.20.0,<4",
|
"pycryptodomex>=3.20.0,<4",
|
||||||
@@ -55,9 +54,7 @@ dependencies = [
|
|||||||
"Unidecode>=1.3.8,<2",
|
"Unidecode>=1.3.8,<2",
|
||||||
"urllib3>=2.6.3,<3",
|
"urllib3>=2.6.3,<3",
|
||||||
"chardet>=5.2.0,<6",
|
"chardet>=5.2.0,<6",
|
||||||
"curl-cffi>=0.7.0b4,<0.14",
|
|
||||||
"pyplayready>=0.8.3,<0.9",
|
"pyplayready>=0.8.3,<0.9",
|
||||||
"httpx>=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",
|
||||||
@@ -68,6 +65,7 @@ dependencies = [
|
|||||||
"language-data>=1.4.0",
|
"language-data>=1.4.0",
|
||||||
"wasmtime>=41.0.0",
|
"wasmtime>=41.0.0",
|
||||||
"animeapi-py>=0.6.0",
|
"animeapi-py>=0.6.0",
|
||||||
|
"rnet>=2.4.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
|
|||||||
@@ -2190,6 +2190,8 @@ class dl:
|
|||||||
BarColumn(),
|
BarColumn(),
|
||||||
"•",
|
"•",
|
||||||
TimeRemainingColumn(compact=True, elapsed_when_finished=True),
|
TimeRemainingColumn(compact=True, elapsed_when_finished=True),
|
||||||
|
"•",
|
||||||
|
TextColumn("{task.fields[downloaded]}"),
|
||||||
console=console,
|
console=console,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2215,7 +2217,7 @@ class dl:
|
|||||||
|
|
||||||
def enqueue_mux_tasks(task_description: str, base_tracks: Tracks) -> None:
|
def enqueue_mux_tasks(task_description: str, base_tracks: Tracks) -> None:
|
||||||
if merge_audio or not base_tracks.audio:
|
if merge_audio or not base_tracks.audio:
|
||||||
task_id = progress.add_task(f"{task_description}...", total=None, start=False)
|
task_id = progress.add_task(f"{task_description}...", total=None, start=False, downloaded="")
|
||||||
multiplex_tasks.append((task_id, base_tracks, None))
|
multiplex_tasks.append((task_id, base_tracks, None))
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -2228,7 +2230,7 @@ class dl:
|
|||||||
if audio_codec:
|
if audio_codec:
|
||||||
description = f"{task_description} {audio_codec.name}"
|
description = f"{task_description} {audio_codec.name}"
|
||||||
|
|
||||||
task_id = progress.add_task(f"{description}...", total=None, start=False)
|
task_id = progress.add_task(f"{description}...", total=None, start=False, downloaded="")
|
||||||
task_tracks = clone_tracks_for_audio(base_tracks, codec_audio_tracks)
|
task_tracks = clone_tracks_for_audio(base_tracks, codec_audio_tracks)
|
||||||
multiplex_tasks.append((task_id, task_tracks, audio_codec))
|
multiplex_tasks.append((task_id, task_tracks, audio_codec))
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from collections import deque
|
|
||||||
from concurrent.futures import FIRST_COMPLETED, wait
|
from concurrent.futures import FIRST_COMPLETED, wait
|
||||||
from concurrent.futures.thread import ThreadPoolExecutor
|
from concurrent.futures.thread import ThreadPoolExecutor
|
||||||
from http.cookiejar import CookieJar
|
from http.cookiejar import CookieJar
|
||||||
@@ -39,19 +38,24 @@ def _is_requests_session(session: Any) -> bool:
|
|||||||
return isinstance(session, Session)
|
return isinstance(session, Session)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_rnet_session(session: Any) -> bool:
|
||||||
|
"""Check if the session is an RnetSession (uses resp.stream())."""
|
||||||
|
from unshackle.core.session import RnetSession
|
||||||
|
return isinstance(session, RnetSession)
|
||||||
|
|
||||||
|
|
||||||
def download(
|
def download(
|
||||||
url: str,
|
url: str,
|
||||||
save_path: Path,
|
save_path: Path,
|
||||||
session: Optional[Any] = None,
|
session: Optional[Any] = None,
|
||||||
segmented: bool = False,
|
segmented: bool = False,
|
||||||
_speed_tracker: Optional[dict] = None,
|
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> Generator[dict[str, Any], None, None]:
|
) -> Generator[dict[str, Any], None, None]:
|
||||||
"""
|
"""
|
||||||
Download a file with optimized I/O.
|
Download a file with optimized I/O.
|
||||||
|
|
||||||
Supports both requests.Session and curl_cffi CurlSession for TLS fingerprinting.
|
Supports both requests.Session and RnetSession for TLS fingerprinting.
|
||||||
Uses raw socket reads for requests.Session (30-35% faster) and iter_content for CurlSession.
|
Uses raw socket reads for requests.Session and native rnet streaming for RnetSession.
|
||||||
|
|
||||||
Yields the following download status updates while chunks are downloading:
|
Yields the following download status updates while chunks are downloading:
|
||||||
|
|
||||||
@@ -65,19 +69,15 @@ def download(
|
|||||||
url: Web URL of a file to download.
|
url: Web URL of a file to download.
|
||||||
save_path: The path to save the file to. If the save path's directory does not
|
save_path: The path to save the file to. If the save path's directory does not
|
||||||
exist then it will be made automatically.
|
exist then it will be made automatically.
|
||||||
session: A requests.Session or curl_cffi CurlSession to make HTTP requests with.
|
session: A requests.Session or RnetSession to make HTTP requests with.
|
||||||
CurlSession preserves TLS fingerprinting for services that need it.
|
RnetSession preserves TLS fingerprinting for services that need it.
|
||||||
segmented: If downloads are segments or parts of one bigger file.
|
segmented: If downloads are segments or parts of one bigger file.
|
||||||
_speed_tracker: Shared speed tracking state for this download batch (per-call, not global).
|
|
||||||
kwargs: Any extra keyword arguments to pass to the session.get() call. Use this
|
kwargs: Any extra keyword arguments to pass to the session.get() call. Use this
|
||||||
for one-time request changes like a header, cookie, or proxy. For example,
|
for one-time request changes like a header, cookie, or proxy. For example,
|
||||||
to request Byte-ranges use e.g., `headers={"Range": "bytes=0-128"}`.
|
to request Byte-ranges use e.g., `headers={"Range": "bytes=0-128"}`.
|
||||||
"""
|
"""
|
||||||
session = session or Session()
|
session = session or Session()
|
||||||
|
|
||||||
if _speed_tracker is None:
|
|
||||||
_speed_tracker = {"sizes": deque(), "last_refresh": time.time()}
|
|
||||||
|
|
||||||
save_dir = save_path.parent
|
save_dir = save_path.parent
|
||||||
control_file = save_path.with_name(f"{save_path.name}.!dev")
|
control_file = save_path.with_name(f"{save_path.name}.!dev")
|
||||||
|
|
||||||
@@ -100,22 +100,26 @@ def download(
|
|||||||
last_speed_refresh = _time()
|
last_speed_refresh = _time()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
use_rnet = _is_rnet_session(session)
|
||||||
stream = session.get(url, stream=True, **kwargs)
|
stream = session.get(url, stream=True, **kwargs)
|
||||||
stream.raise_for_status()
|
stream.raise_for_status()
|
||||||
|
|
||||||
# Determine content length and adaptive chunk size
|
# Determine content length and adaptive chunk size
|
||||||
try:
|
if use_rnet:
|
||||||
content_length = int(stream.headers.get("Content-Length", "0"))
|
content_length = stream.content_length or 0
|
||||||
if stream.headers.get("Content-Encoding", "").lower() in ["gzip", "deflate", "br"]:
|
else:
|
||||||
|
try:
|
||||||
|
content_length = int(stream.headers.get("Content-Length", "0"))
|
||||||
|
if stream.headers.get("Content-Encoding", "").lower() in ["gzip", "deflate", "br"]:
|
||||||
|
content_length = 0
|
||||||
|
except ValueError:
|
||||||
content_length = 0
|
content_length = 0
|
||||||
except ValueError:
|
|
||||||
content_length = 0
|
|
||||||
|
|
||||||
chunk_size = _adaptive_chunk_size(content_length)
|
chunk_size = _adaptive_chunk_size(content_length)
|
||||||
|
|
||||||
if not segmented:
|
if not segmented:
|
||||||
if content_length > 0:
|
if content_length > 0:
|
||||||
yield dict(total=math.ceil(content_length / chunk_size))
|
yield dict(total=content_length)
|
||||||
else:
|
else:
|
||||||
yield dict(total=None)
|
yield dict(total=None)
|
||||||
|
|
||||||
@@ -128,8 +132,12 @@ def download(
|
|||||||
# Cache f.write for hot loop
|
# Cache f.write for hot loop
|
||||||
_write = f.write
|
_write = f.write
|
||||||
|
|
||||||
# Build chunk iterator — raw reads for requests.Session, iter_content for CurlSession
|
# Build chunk iterator based on session type
|
||||||
if use_raw:
|
if use_rnet:
|
||||||
|
# rnet: native Rust streaming — 3.5x faster than curl_cffi (benchmarked)
|
||||||
|
chunks = stream.stream()
|
||||||
|
elif use_raw:
|
||||||
|
# requests.Session: raw socket read — 30-35% faster than iter_content
|
||||||
stream.raw.decode_content = False
|
stream.raw.decode_content = False
|
||||||
_read = stream.raw.read
|
_read = stream.raw.read
|
||||||
|
|
||||||
@@ -143,6 +151,7 @@ def download(
|
|||||||
|
|
||||||
chunks = _chunks()
|
chunks = _chunks()
|
||||||
else:
|
else:
|
||||||
|
# Fallback: iter_content
|
||||||
def _chunks_iter() -> Generator[bytes, None, None]:
|
def _chunks_iter() -> Generator[bytes, None, None]:
|
||||||
yield from stream.iter_content(chunk_size=chunk_size)
|
yield from stream.iter_content(chunk_size=chunk_size)
|
||||||
stream.close()
|
stream.close()
|
||||||
@@ -151,22 +160,31 @@ def download(
|
|||||||
|
|
||||||
# Unified write + progress loop
|
# Unified write + progress loop
|
||||||
_data_accumulated = 0
|
_data_accumulated = 0
|
||||||
|
_bytes_since_yield = 0
|
||||||
for chunk in chunks:
|
for chunk in chunks:
|
||||||
|
if DOWNLOAD_CANCELLED.is_set():
|
||||||
|
break
|
||||||
_write(chunk)
|
_write(chunk)
|
||||||
download_size = len(chunk)
|
download_size = len(chunk)
|
||||||
written += download_size
|
written += download_size
|
||||||
|
|
||||||
if not segmented:
|
if not segmented:
|
||||||
yield dict(advance=1)
|
_bytes_since_yield += download_size
|
||||||
|
_data_accumulated += download_size
|
||||||
now = _time()
|
now = _time()
|
||||||
time_since = now - last_speed_refresh
|
time_since = now - last_speed_refresh
|
||||||
_data_accumulated += download_size
|
if time_since > PROGRESS_WINDOW:
|
||||||
if time_since > PROGRESS_WINDOW or download_size < chunk_size:
|
yield dict(advance=_bytes_since_yield)
|
||||||
|
_bytes_since_yield = 0
|
||||||
download_speed = math.ceil(_data_accumulated / (time_since or 1))
|
download_speed = math.ceil(_data_accumulated / (time_since or 1))
|
||||||
yield dict(downloaded=f"{filesize.decimal(download_speed)}/s")
|
yield dict(downloaded=f"{filesize.decimal(download_speed)}/s")
|
||||||
last_speed_refresh = now
|
last_speed_refresh = now
|
||||||
_data_accumulated = 0
|
_data_accumulated = 0
|
||||||
|
|
||||||
|
# Flush any remaining bytes
|
||||||
|
if not segmented and _bytes_since_yield > 0:
|
||||||
|
yield dict(advance=_bytes_since_yield)
|
||||||
|
|
||||||
# Truncate to actual written size in case pre-allocation overshot
|
# Truncate to actual written size in case pre-allocation overshot
|
||||||
if content_length > 0 and written != content_length:
|
if content_length > 0 and written != content_length:
|
||||||
f.truncate(written)
|
f.truncate(written)
|
||||||
@@ -178,21 +196,6 @@ def download(
|
|||||||
|
|
||||||
if segmented:
|
if segmented:
|
||||||
yield dict(advance=1)
|
yield dict(advance=1)
|
||||||
now = _time()
|
|
||||||
sizes = _speed_tracker["sizes"]
|
|
||||||
if written:
|
|
||||||
sizes.append((now, written))
|
|
||||||
cutoff = now - SPEED_ROLLING_WINDOW
|
|
||||||
while sizes and sizes[0][0] < cutoff:
|
|
||||||
sizes.popleft()
|
|
||||||
time_since = now - _speed_tracker["last_refresh"]
|
|
||||||
if sizes and time_since > PROGRESS_WINDOW:
|
|
||||||
window_start = sizes[0][0]
|
|
||||||
window_duration = now - window_start
|
|
||||||
data_size = sum(size for _, size in sizes)
|
|
||||||
download_speed = math.ceil(data_size / (window_duration or 1))
|
|
||||||
yield dict(downloaded=f"{filesize.decimal(download_speed)}/s")
|
|
||||||
_speed_tracker["last_refresh"] = now
|
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
save_path.unlink(missing_ok=True)
|
save_path.unlink(missing_ok=True)
|
||||||
@@ -217,7 +220,7 @@ def requests(
|
|||||||
"""
|
"""
|
||||||
Download files with optimized I/O and adaptive chunk sizing.
|
Download files with optimized I/O and adaptive chunk sizing.
|
||||||
|
|
||||||
Supports both requests.Session and curl_cffi CurlSession. When a CurlSession is
|
Supports both requests.Session and RnetSession. When a RnetSession is
|
||||||
provided (e.g. from a service's get_session()), TLS fingerprinting is preserved
|
provided (e.g. from a service's get_session()), TLS fingerprinting is preserved
|
||||||
on all segment downloads.
|
on all segment downloads.
|
||||||
|
|
||||||
@@ -245,7 +248,7 @@ def requests(
|
|||||||
proxy: An optional proxy URI to route connections through for all downloads.
|
proxy: An optional proxy URI to route connections through for all downloads.
|
||||||
max_workers: The maximum amount of threads to use for downloads. Defaults to
|
max_workers: The maximum amount of threads to use for downloads. Defaults to
|
||||||
min(12,(cpu_count+4)).
|
min(12,(cpu_count+4)).
|
||||||
session: An optional requests.Session or curl_cffi CurlSession to use. If provided,
|
session: An optional requests.Session or RnetSession to use. If provided,
|
||||||
it will be used directly (preserving TLS fingerprinting). If None, a new
|
it will be used directly (preserving TLS fingerprinting). If None, a new
|
||||||
requests.Session with HTTPAdapter connection pooling will be created.
|
requests.Session with HTTPAdapter connection pooling will be created.
|
||||||
"""
|
"""
|
||||||
@@ -293,7 +296,7 @@ def requests(
|
|||||||
]
|
]
|
||||||
|
|
||||||
# Use provided session or create a new optimized requests.Session
|
# Use provided session or create a new optimized requests.Session
|
||||||
# When a session is provided (e.g., service's CurlSession), don't mutate headers/cookies/proxy —
|
# When a session is provided (e.g., service's RnetSession), don't mutate headers/cookies/proxy —
|
||||||
# they're already set and the session may be shared across tracks.
|
# they're already set and the session may be shared across tracks.
|
||||||
if session is None:
|
if session is None:
|
||||||
session = Session()
|
session = Session()
|
||||||
@@ -331,93 +334,142 @@ def requests(
|
|||||||
)
|
)
|
||||||
|
|
||||||
segmented_batch = len(urls) > 1
|
segmented_batch = len(urls) > 1
|
||||||
if segmented_batch:
|
|
||||||
yield dict(total=len(urls))
|
|
||||||
|
|
||||||
# Per-call speed tracker — shared across threads within this call only
|
# Fast path: single URL — no thread pool overhead
|
||||||
speed_tracker: dict[str, Any] = {"sizes": deque(), "last_refresh": time.time()}
|
if len(urls) == 1:
|
||||||
|
try:
|
||||||
try:
|
|
||||||
# Fast path: single URL — no thread pool overhead
|
|
||||||
if len(urls) == 1:
|
|
||||||
yield from download(
|
yield from download(
|
||||||
session=session,
|
session=session,
|
||||||
segmented=segmented_batch,
|
segmented=segmented_batch,
|
||||||
_speed_tracker=speed_tracker,
|
|
||||||
**urls[0],
|
**urls[0],
|
||||||
)
|
)
|
||||||
else:
|
except KeyboardInterrupt:
|
||||||
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
DOWNLOAD_CANCELLED.set()
|
||||||
event_queue: Queue[dict[str, Any]] = Queue()
|
yield dict(downloaded="[yellow]CANCELLED")
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
# Segmented download with thread pool
|
||||||
|
# Speed is tracked here on the main thread, not in workers
|
||||||
|
total_bytes = 0
|
||||||
|
start_time = time.time()
|
||||||
|
last_speed_report = start_time
|
||||||
|
|
||||||
def _download_worker(url_item: dict[str, Any]) -> None:
|
pool = ThreadPoolExecutor(max_workers=max_workers)
|
||||||
for event in download(
|
event_queue: Queue[dict[str, Any]] = Queue()
|
||||||
session=session,
|
|
||||||
segmented=segmented_batch,
|
|
||||||
_speed_tracker=speed_tracker,
|
|
||||||
**url_item,
|
|
||||||
):
|
|
||||||
event_queue.put(event)
|
|
||||||
|
|
||||||
futures = [pool.submit(_download_worker, url) for url in urls]
|
def _download_worker(url_item: dict[str, Any]) -> None:
|
||||||
pending = set(futures)
|
for event in download(
|
||||||
|
session=session,
|
||||||
|
segmented=segmented_batch,
|
||||||
|
**url_item,
|
||||||
|
):
|
||||||
|
event_queue.put(event)
|
||||||
|
|
||||||
while pending:
|
futures = [pool.submit(_download_worker, url) for url in urls]
|
||||||
# Drain queued progress updates for responsive UI
|
pending = set(futures)
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
yield event_queue.get_nowait()
|
|
||||||
except Empty:
|
|
||||||
break
|
|
||||||
|
|
||||||
# Wait efficiently for next future completion (OS condition variable)
|
pending_advance = 0
|
||||||
completed, pending = wait(pending, timeout=0.1, return_when=FIRST_COMPLETED)
|
|
||||||
for future in completed:
|
|
||||||
exc = future.exception()
|
|
||||||
if isinstance(exc, KeyboardInterrupt):
|
|
||||||
DOWNLOAD_CANCELLED.set()
|
|
||||||
yield dict(downloaded="[yellow]CANCELLING")
|
|
||||||
pool.shutdown(wait=True, cancel_futures=True)
|
|
||||||
yield dict(downloaded="[yellow]CANCELLED")
|
|
||||||
raise exc
|
|
||||||
elif exc:
|
|
||||||
DOWNLOAD_CANCELLED.set()
|
|
||||||
yield dict(downloaded="[red]FAILING")
|
|
||||||
pool.shutdown(wait=True, cancel_futures=True)
|
|
||||||
yield dict(downloaded="[red]FAILED")
|
|
||||||
if debug_logger:
|
|
||||||
debug_logger.log(
|
|
||||||
level="ERROR",
|
|
||||||
operation="downloader_failed",
|
|
||||||
message=f"Download failed: {exc}",
|
|
||||||
error=exc,
|
|
||||||
context={
|
|
||||||
"url_count": len(urls),
|
|
||||||
"output_dir": str(output_dir),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
raise exc
|
|
||||||
|
|
||||||
# Drain any remaining events from workers that just finished
|
try:
|
||||||
|
while pending:
|
||||||
|
# Drain queued events — batch advances, track bytes for speed
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
yield event_queue.get_nowait()
|
event = event_queue.get_nowait()
|
||||||
except Empty:
|
except Empty:
|
||||||
break
|
break
|
||||||
|
# Accumulate advance events for batched yield
|
||||||
|
advance = event.get("advance")
|
||||||
|
if advance:
|
||||||
|
pending_advance += advance
|
||||||
|
continue
|
||||||
|
# Track bytes from completed segments for speed calculation
|
||||||
|
written = event.get("written")
|
||||||
|
if written:
|
||||||
|
total_bytes += written
|
||||||
|
# Pass through other events (file_downloaded, total, etc.)
|
||||||
|
yield event
|
||||||
|
|
||||||
if debug_logger:
|
# Yield batched advances every drain cycle for responsive progress bar
|
||||||
debug_logger.log(
|
if pending_advance > 0:
|
||||||
level="DEBUG",
|
yield dict(advance=pending_advance)
|
||||||
operation="downloader_complete",
|
pending_advance = 0
|
||||||
message="Download completed successfully",
|
|
||||||
context={
|
# Yield speed every 0.5s (throttled to avoid spamming Rich)
|
||||||
"url_count": len(urls),
|
now = time.time()
|
||||||
"output_dir": str(output_dir),
|
if now - last_speed_report > 0.5 and total_bytes > 0:
|
||||||
"filename": filename,
|
elapsed = now - start_time
|
||||||
},
|
if elapsed > 0:
|
||||||
)
|
download_speed = math.ceil(total_bytes / elapsed)
|
||||||
finally:
|
yield dict(downloaded=f"{filesize.decimal(download_speed)}/s")
|
||||||
speed_tracker["sizes"].clear()
|
last_speed_report = now
|
||||||
|
|
||||||
|
# Wait efficiently for next future completion (OS condition variable)
|
||||||
|
completed, pending = wait(pending, timeout=0.1, return_when=FIRST_COMPLETED)
|
||||||
|
for future in completed:
|
||||||
|
exc = future.exception()
|
||||||
|
if isinstance(exc, KeyboardInterrupt):
|
||||||
|
raise KeyboardInterrupt()
|
||||||
|
elif exc:
|
||||||
|
DOWNLOAD_CANCELLED.set()
|
||||||
|
yield dict(downloaded="[red]FAILING")
|
||||||
|
pool.shutdown(wait=False, cancel_futures=True)
|
||||||
|
yield dict(downloaded="[red]FAILED")
|
||||||
|
if debug_logger:
|
||||||
|
debug_logger.log(
|
||||||
|
level="ERROR",
|
||||||
|
operation="downloader_failed",
|
||||||
|
message=f"Download failed: {exc}",
|
||||||
|
error=exc,
|
||||||
|
context={
|
||||||
|
"url_count": len(urls),
|
||||||
|
"output_dir": str(output_dir),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
raise exc
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
DOWNLOAD_CANCELLED.set()
|
||||||
|
yield dict(downloaded="[yellow]CANCELLING")
|
||||||
|
pool.shutdown(wait=False, cancel_futures=True)
|
||||||
|
yield dict(downloaded="[yellow]CANCELLED")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
pool.shutdown(wait=False, cancel_futures=True)
|
||||||
|
|
||||||
|
# Drain remaining events
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
event = event_queue.get_nowait()
|
||||||
|
except Empty:
|
||||||
|
break
|
||||||
|
advance = event.get("advance")
|
||||||
|
if advance:
|
||||||
|
pending_advance += advance
|
||||||
|
continue
|
||||||
|
written = event.get("written")
|
||||||
|
if written:
|
||||||
|
total_bytes += written
|
||||||
|
yield event
|
||||||
|
|
||||||
|
# Flush remaining advances and final speed
|
||||||
|
if pending_advance > 0:
|
||||||
|
yield dict(advance=pending_advance)
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
if elapsed > 0 and total_bytes > 0:
|
||||||
|
download_speed = math.ceil(total_bytes / elapsed)
|
||||||
|
yield dict(downloaded=f"{filesize.decimal(download_speed)}/s")
|
||||||
|
|
||||||
|
if debug_logger:
|
||||||
|
debug_logger.log(
|
||||||
|
level="DEBUG",
|
||||||
|
operation="downloader_complete",
|
||||||
|
message="Download completed successfully",
|
||||||
|
context={
|
||||||
|
"url_count": len(urls),
|
||||||
|
"output_dir": str(output_dir),
|
||||||
|
"filename": filename,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
__all__ = ("requests",)
|
__all__ = ("requests",)
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ from urllib.parse import urljoin
|
|||||||
|
|
||||||
from Cryptodome.Cipher import AES
|
from Cryptodome.Cipher import AES
|
||||||
from Cryptodome.Util.Padding import unpad
|
from Cryptodome.Util.Padding import unpad
|
||||||
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
|
||||||
|
|
||||||
|
from unshackle.core.session import RnetSession
|
||||||
|
|
||||||
|
|
||||||
class ClearKey:
|
class ClearKey:
|
||||||
"""AES Clear Key DRM System."""
|
"""AES Clear Key DRM System."""
|
||||||
@@ -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, RnetSession, 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 {RnetSession}, 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}")
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ from uuid import UUID
|
|||||||
from zlib import crc32
|
from zlib import crc32
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from curl_cffi.requests import Session as CurlSession
|
|
||||||
from langcodes import Language, tag_is_valid
|
from langcodes import Language, tag_is_valid
|
||||||
from lxml.etree import Element, ElementTree
|
from lxml.etree import Element, ElementTree
|
||||||
from pyplayready.system.pssh import PSSH as PR_PSSH
|
from pyplayready.system.pssh import PSSH as PR_PSSH
|
||||||
@@ -28,6 +27,7 @@ from unshackle.core.cdm.detect import is_playready_cdm
|
|||||||
from unshackle.core.constants import DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY, AnyTrack
|
from unshackle.core.constants import DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY, AnyTrack
|
||||||
from unshackle.core.drm import DRM_T, PlayReady, Widevine
|
from unshackle.core.drm import DRM_T, PlayReady, Widevine
|
||||||
from unshackle.core.events import events
|
from unshackle.core.events import events
|
||||||
|
from unshackle.core.session import RnetSession
|
||||||
from unshackle.core.tracks import Audio, Subtitle, Tracks, Video
|
from unshackle.core.tracks import Audio, Subtitle, Tracks, Video
|
||||||
from unshackle.core.utilities import get_debug_logger, is_close_match, try_ensure_utf8
|
from unshackle.core.utilities import get_debug_logger, is_close_match, try_ensure_utf8
|
||||||
from unshackle.core.utils.xml import load_xml
|
from unshackle.core.utils.xml import load_xml
|
||||||
@@ -49,7 +49,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, RnetSession]] = 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):
|
||||||
@@ -57,8 +57,8 @@ class DASH:
|
|||||||
|
|
||||||
if not session:
|
if not session:
|
||||||
session = Session()
|
session = Session()
|
||||||
elif not isinstance(session, (Session, CurlSession)):
|
elif not isinstance(session, (Session, RnetSession)):
|
||||||
raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {session!r}")
|
raise TypeError(f"Expected session to be a {Session} or {RnetSession}, not {session!r}")
|
||||||
|
|
||||||
res = session.get(url, **args)
|
res = session.get(url, **args)
|
||||||
if res.url != url:
|
if res.url != url:
|
||||||
@@ -264,8 +264,8 @@ class DASH:
|
|||||||
):
|
):
|
||||||
if not session:
|
if not session:
|
||||||
session = Session()
|
session = Session()
|
||||||
elif not isinstance(session, (Session, CurlSession)):
|
elif not isinstance(session, (Session, RnetSession)):
|
||||||
raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {session!r}")
|
raise TypeError(f"Expected session to be a {Session} or {RnetSession}, not {session!r}")
|
||||||
|
|
||||||
if proxy:
|
if proxy:
|
||||||
session.proxies.update({"all": proxy})
|
session.proxies.update({"all": proxy})
|
||||||
@@ -589,7 +589,7 @@ class DASH:
|
|||||||
manifest: ElementTree,
|
manifest: ElementTree,
|
||||||
track: AnyTrack,
|
track: AnyTrack,
|
||||||
track_url: str,
|
track_url: str,
|
||||||
session: Union[Session, CurlSession],
|
session: Union[Session, RnetSession],
|
||||||
) -> tuple[
|
) -> tuple[
|
||||||
Optional[bytes],
|
Optional[bytes],
|
||||||
list[tuple[str, Optional[str]]],
|
list[tuple[str, Optional[str]]],
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ from zlib import crc32
|
|||||||
|
|
||||||
import m3u8
|
import m3u8
|
||||||
import requests
|
import requests
|
||||||
from curl_cffi.requests import Response as CurlResponse
|
|
||||||
from curl_cffi.requests import Session as CurlSession
|
|
||||||
from langcodes import Language, tag_is_valid
|
from langcodes import Language, tag_is_valid
|
||||||
from m3u8 import M3U8
|
from m3u8 import M3U8
|
||||||
from pyplayready.cdm import Cdm as PlayReadyCdm
|
from pyplayready.cdm import Cdm as PlayReadyCdm
|
||||||
@@ -32,12 +30,13 @@ from unshackle.core.cdm.detect import is_playready_cdm, is_widevine_cdm
|
|||||||
from unshackle.core.constants import DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY, AnyTrack
|
from unshackle.core.constants import DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY, AnyTrack
|
||||||
from unshackle.core.drm import DRM_T, ClearKey, MonaLisa, PlayReady, Widevine
|
from unshackle.core.drm import DRM_T, ClearKey, MonaLisa, PlayReady, Widevine
|
||||||
from unshackle.core.events import events
|
from unshackle.core.events import events
|
||||||
|
from unshackle.core.session import RnetResponse, RnetSession
|
||||||
from unshackle.core.tracks import Audio, Subtitle, Tracks, Video
|
from unshackle.core.tracks import Audio, Subtitle, Tracks, Video
|
||||||
from unshackle.core.utilities import get_debug_logger, get_extension, is_close_match, try_ensure_utf8
|
from unshackle.core.utilities import get_debug_logger, get_extension, is_close_match, try_ensure_utf8
|
||||||
|
|
||||||
|
|
||||||
class HLS:
|
class HLS:
|
||||||
def __init__(self, manifest: M3U8, session: Optional[Union[Session, CurlSession]] = None):
|
def __init__(self, manifest: M3U8, session: Optional[Union[Session, RnetSession]] = 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):
|
||||||
@@ -49,7 +48,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, RnetSession]] = 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):
|
||||||
@@ -57,22 +56,22 @@ class HLS:
|
|||||||
|
|
||||||
if not session:
|
if not session:
|
||||||
session = Session()
|
session = Session()
|
||||||
elif not isinstance(session, (Session, CurlSession)):
|
elif not isinstance(session, (Session, RnetSession)):
|
||||||
raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {session!r}")
|
raise TypeError(f"Expected session to be a {Session} or {RnetSession}, not {session!r}")
|
||||||
|
|
||||||
res = session.get(url, **args)
|
res = session.get(url, **args)
|
||||||
|
|
||||||
# Handle requests and curl_cffi response objects
|
# Handle requests and rnet response objects
|
||||||
if isinstance(res, requests.Response):
|
if isinstance(res, requests.Response):
|
||||||
if not res.ok:
|
if not res.ok:
|
||||||
raise requests.ConnectionError("Failed to request the M3U(8) document.", response=res)
|
raise requests.ConnectionError("Failed to request the M3U(8) document.", response=res)
|
||||||
content = res.text
|
content = res.text
|
||||||
elif isinstance(res, CurlResponse):
|
elif isinstance(res, RnetResponse):
|
||||||
if not res.ok:
|
if not res.ok:
|
||||||
raise requests.ConnectionError("Failed to request the M3U(8) document.", response=res)
|
raise requests.ConnectionError("Failed to request the M3U(8) document.", response=res)
|
||||||
content = res.text
|
content = res.text
|
||||||
else:
|
else:
|
||||||
raise TypeError(f"Expected response to be a requests.Response or curl_cffi.Response, not {type(res)}")
|
raise TypeError(f"Expected response to be a requests.Response or rnet.Response, not {type(res)}")
|
||||||
|
|
||||||
master = m3u8.loads(content, uri=url)
|
master = m3u8.loads(content, uri=url)
|
||||||
|
|
||||||
@@ -281,7 +280,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, RnetSession]] = 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,
|
||||||
@@ -290,8 +289,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, RnetSession)):
|
||||||
raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {session!r}")
|
raise TypeError(f"Expected session to be a {Session} or {RnetSession}, not {session!r}")
|
||||||
|
|
||||||
if proxy:
|
if proxy:
|
||||||
# Handle proxies differently based on session type
|
# Handle proxies differently based on session type
|
||||||
@@ -305,14 +304,14 @@ class HLS:
|
|||||||
else:
|
else:
|
||||||
# Get the playlist text and handle both session types
|
# Get the playlist text and handle both session types
|
||||||
response = session.get(track.url)
|
response = session.get(track.url)
|
||||||
if isinstance(response, requests.Response) or isinstance(response, CurlResponse):
|
if isinstance(response, requests.Response) or isinstance(response, RnetResponse):
|
||||||
if not response.ok:
|
if not response.ok:
|
||||||
log.error(f"Failed to request the invariant M3U8 playlist: {response.status_code}")
|
log.error(f"Failed to request the invariant M3U8 playlist: {response.status_code}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
playlist_text = response.text
|
playlist_text = response.text
|
||||||
else:
|
else:
|
||||||
raise TypeError(
|
raise TypeError(
|
||||||
f"Expected response to be a requests.Response or curl_cffi.Response, not {type(response)}"
|
f"Expected response to be a requests.Response or rnet.Response, not {type(response)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
master = m3u8.loads(playlist_text, uri=track.url)
|
master = m3u8.loads(playlist_text, uri=track.url)
|
||||||
@@ -613,12 +612,12 @@ class HLS:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Check response based on session type
|
# Check response based on session type
|
||||||
if isinstance(res, requests.Response) or isinstance(res, CurlResponse):
|
if isinstance(res, requests.Response) or isinstance(res, RnetResponse):
|
||||||
res.raise_for_status()
|
res.raise_for_status()
|
||||||
init_content = res.content
|
init_content = res.content
|
||||||
else:
|
else:
|
||||||
raise TypeError(
|
raise TypeError(
|
||||||
f"Expected response to be requests.Response or curl_cffi.Response, not {type(res)}"
|
f"Expected response to be requests.Response or rnet.Response, not {type(res)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
map_data = (segment.init_section, init_content)
|
map_data = (segment.init_section, init_content)
|
||||||
@@ -832,7 +831,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, RnetSession]] = 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] = []
|
||||||
@@ -907,7 +906,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, RnetSession],
|
||||||
) -> 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).
|
||||||
@@ -974,7 +973,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, RnetSession]] = 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.
|
||||||
@@ -986,8 +985,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, RnetSession, 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 {RnetSession}, not {type(session)}")
|
||||||
if not session:
|
if not session:
|
||||||
session = Session()
|
session = Session()
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ from pathlib import Path
|
|||||||
from typing import Any, Callable, Optional, Union
|
from typing import Any, Callable, Optional, Union
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from curl_cffi.requests import Session as CurlSession
|
|
||||||
from langcodes import Language, tag_is_valid
|
from langcodes import Language, tag_is_valid
|
||||||
from lxml.etree import Element
|
from lxml.etree import Element
|
||||||
from pyplayready.system.pssh import PSSH as PR_PSSH
|
from pyplayready.system.pssh import PSSH as PR_PSSH
|
||||||
@@ -19,6 +18,7 @@ from requests import Session
|
|||||||
from unshackle.core.constants import DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY, AnyTrack
|
from unshackle.core.constants import DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY, AnyTrack
|
||||||
from unshackle.core.drm import DRM_T, PlayReady, Widevine
|
from unshackle.core.drm import DRM_T, PlayReady, Widevine
|
||||||
from unshackle.core.events import events
|
from unshackle.core.events import events
|
||||||
|
from unshackle.core.session import RnetSession
|
||||||
from unshackle.core.tracks import Audio, Subtitle, Track, Tracks, Video
|
from unshackle.core.tracks import Audio, Subtitle, Track, Tracks, Video
|
||||||
from unshackle.core.utilities import get_debug_logger, try_ensure_utf8
|
from unshackle.core.utilities import get_debug_logger, try_ensure_utf8
|
||||||
from unshackle.core.utils.xml import load_xml
|
from unshackle.core.utils.xml import load_xml
|
||||||
@@ -34,13 +34,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, RnetSession]] = 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, RnetSession)):
|
||||||
raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {session!r}")
|
raise TypeError(f"Expected session to be a {Session} or {RnetSession}, 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
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ from __future__ import annotations
|
|||||||
from typing import Optional, Union
|
from typing import Optional, Union
|
||||||
|
|
||||||
import m3u8
|
import m3u8
|
||||||
from curl_cffi.requests import Session as CurlSession
|
|
||||||
from requests import Session
|
from requests import Session
|
||||||
|
|
||||||
from unshackle.core.manifests.hls import HLS
|
from unshackle.core.manifests.hls import HLS
|
||||||
|
from unshackle.core.session import RnetSession
|
||||||
from unshackle.core.tracks import Tracks
|
from unshackle.core.tracks import Tracks
|
||||||
|
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ def parse(
|
|||||||
master: m3u8.M3U8,
|
master: m3u8.M3U8,
|
||||||
language: str,
|
language: str,
|
||||||
*,
|
*,
|
||||||
session: Optional[Union[Session, CurlSession]] = None,
|
session: Optional[Union[Session, RnetSession]] = 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)
|
||||||
|
|||||||
@@ -1,96 +1,452 @@
|
|||||||
"""Session utilities for creating HTTP sessions with different backends."""
|
"""Session utilities for creating HTTP sessions with TLS fingerprinting via rnet (Rust/BoringSSL)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import http
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
import time
|
import time
|
||||||
import warnings
|
from collections.abc import Iterator, MutableMapping
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from email.utils import parsedate_to_datetime
|
from email.utils import parsedate_to_datetime
|
||||||
from typing import Any
|
from http.cookiejar import CookieJar
|
||||||
from urllib.parse import urlparse
|
from typing import Any, Optional
|
||||||
|
from urllib.parse import urlencode, urlparse, urlunparse
|
||||||
|
|
||||||
from curl_cffi.requests import Response, Session, exceptions
|
import rnet
|
||||||
|
from requests import HTTPError, Request
|
||||||
|
from requests.structures import CaseInsensitiveDict
|
||||||
|
|
||||||
from unshackle.core.config import config
|
from unshackle.core.config import config
|
||||||
|
|
||||||
# Globally suppress curl_cffi HTTPS proxy warnings since some proxy providers
|
# ---------------------------------------------------------------------------
|
||||||
# (like NordVPN) require HTTPS URLs but curl_cffi expects HTTP format
|
# Impersonate preset mapping — rnet uses named presets (no custom JA3/Akamai)
|
||||||
warnings.filterwarnings(
|
# ---------------------------------------------------------------------------
|
||||||
"ignore", message="Make sure you are using https over https proxy.*", category=RuntimeWarning, module="curl_cffi.*"
|
|
||||||
)
|
|
||||||
|
|
||||||
FINGERPRINT_PRESETS = {
|
DEFAULT_IMPERSONATE = rnet.Impersonate.Chrome131
|
||||||
"okhttp4": {
|
|
||||||
"ja3": (
|
|
||||||
"771," # TLS 1.2
|
def _resolve_impersonate(browser: str) -> rnet.Impersonate:
|
||||||
"4865-4866-4867-49195-49196-52393-49199-49200-52392-49171-49172-156-157-47-53," # Ciphers
|
"""Resolve a browser string to an rnet.Impersonate preset.
|
||||||
"0-23-65281-10-11-35-16-5-13-51-45-43," # Extensions
|
|
||||||
"29-23-24," # Named groups (x25519, secp256r1, secp384r1)
|
Accepts exact rnet preset names (e.g. "Chrome131", "OkHttp4_12", "Edge101").
|
||||||
"0" # EC point formats
|
See https://github.com/0x676e67/rnet for the full list of available presets.
|
||||||
),
|
"""
|
||||||
"akamai": "4:16777216|16711681|0|m,p,a,s",
|
preset = getattr(rnet.Impersonate, browser, None)
|
||||||
"description": "OkHttp 3.x/4.x (BoringSSL TLS stack)",
|
if preset is not None:
|
||||||
},
|
return preset
|
||||||
"okhttp5": {
|
raise ValueError(
|
||||||
"ja3": (
|
f"Unknown impersonate preset: {browser!r}. "
|
||||||
"771," # TLS 1.2
|
f"Use exact rnet preset names like 'Chrome131', 'OkHttp4_12', 'Edge101'. "
|
||||||
"4865-4866-4867-49195-49199-49196-49200-52393-52392-49171-49172-156-157-47-53," # Ciphers
|
f"See rnet.Impersonate for all available presets."
|
||||||
"0-23-65281-10-11-35-16-5-13-51-45-43," # Extensions
|
)
|
||||||
"29-23-24," # Named groups (x25519, secp256r1, secp384r1)
|
|
||||||
"0" # EC point formats
|
# Map string method names to rnet.Method enum
|
||||||
),
|
_METHOD_MAP: dict[str, rnet.Method] = {
|
||||||
"akamai": "4:16777216|16711681|0|m,p,a,s",
|
"GET": rnet.Method.GET,
|
||||||
"description": "OkHttp 5.x (BoringSSL TLS stack)",
|
"POST": rnet.Method.POST,
|
||||||
},
|
"PUT": rnet.Method.PUT,
|
||||||
"shield_okhttp": {
|
"DELETE": rnet.Method.DELETE,
|
||||||
"ja3": (
|
"HEAD": rnet.Method.HEAD,
|
||||||
"771," # TLS 1.2
|
"OPTIONS": rnet.Method.OPTIONS,
|
||||||
"4865-4866-4867-49195-49199-49196-49200-52393-52392-49171-49172-156-157-47-53," # Ciphers (OkHttp 4.11)
|
"PATCH": rnet.Method.PATCH,
|
||||||
"0-23-65281-10-11-35-16-5-13-51-45-43-21," # Extensions (incl padding ext 21)
|
"TRACE": rnet.Method.TRACE,
|
||||||
"29-23-24," # Named groups (x25519, secp256r1, secp384r1)
|
|
||||||
"0" # EC point formats
|
|
||||||
),
|
|
||||||
"akamai": "4:16777216|16711681|0|m,p,a,s",
|
|
||||||
"description": "NVIDIA SHIELD Android TV OkHttp 4.11 (captured JA3)",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class MaxRetriesError(exceptions.RequestException):
|
# ---------------------------------------------------------------------------
|
||||||
def __init__(self, message, cause=None):
|
# Response headers adapter — bytes → str
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class RnetResponseHeaders(MutableMapping):
|
||||||
|
"""Read-only str-based view over rnet's bytes-based HeaderMap."""
|
||||||
|
|
||||||
|
def __init__(self, header_map: Any) -> None:
|
||||||
|
self._map = header_map
|
||||||
|
|
||||||
|
def _decode(self, val: Any) -> str:
|
||||||
|
return val.decode("utf-8", errors="replace") if isinstance(val, (bytes, bytearray)) else str(val)
|
||||||
|
|
||||||
|
def __getitem__(self, key: str) -> str:
|
||||||
|
val = self._map[key]
|
||||||
|
return self._decode(val)
|
||||||
|
|
||||||
|
def __setitem__(self, key: str, value: str) -> None:
|
||||||
|
raise TypeError("Response headers are read-only")
|
||||||
|
|
||||||
|
def __delitem__(self, key: str) -> None:
|
||||||
|
raise TypeError("Response headers are read-only")
|
||||||
|
|
||||||
|
def __contains__(self, key: object) -> bool:
|
||||||
|
if not isinstance(key, str):
|
||||||
|
return False
|
||||||
|
return self._map.contains_key(key)
|
||||||
|
|
||||||
|
def __iter__(self) -> Iterator[str]:
|
||||||
|
seen: set[str] = set()
|
||||||
|
for k, _ in self._map.items():
|
||||||
|
dk = self._decode(k)
|
||||||
|
if dk not in seen:
|
||||||
|
seen.add(dk)
|
||||||
|
yield dk
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return self._map.keys_len()
|
||||||
|
|
||||||
|
def get(self, key: str, default: Optional[str] = None) -> Optional[str]:
|
||||||
|
val = self._map.get(key)
|
||||||
|
if val is None:
|
||||||
|
return default
|
||||||
|
return self._decode(val)
|
||||||
|
|
||||||
|
def items(self) -> list[tuple[str, str]]:
|
||||||
|
return [(self._decode(k), self._decode(v)) for k, v in self._map.items()]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Response wrapper — requests-compatible interface
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class RnetResponse:
|
||||||
|
"""Wraps rnet.BlockingResponse with a requests-compatible API."""
|
||||||
|
|
||||||
|
def __init__(self, resp: Any) -> None:
|
||||||
|
self._resp = resp
|
||||||
|
self._headers: Optional[RnetResponseHeaders] = None
|
||||||
|
self._content: Optional[bytes] = None
|
||||||
|
self._text: Optional[str] = None
|
||||||
|
self._streamed = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status_code(self) -> int:
|
||||||
|
return int(str(self._resp.status_code))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ok(self) -> bool:
|
||||||
|
return self._resp.ok
|
||||||
|
|
||||||
|
@property
|
||||||
|
def headers(self) -> RnetResponseHeaders:
|
||||||
|
if self._headers is None:
|
||||||
|
self._headers = RnetResponseHeaders(self._resp.headers)
|
||||||
|
return self._headers
|
||||||
|
|
||||||
|
@property
|
||||||
|
def url(self) -> str:
|
||||||
|
return str(self._resp.url)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def content_length(self) -> Optional[int]:
|
||||||
|
return self._resp.content_length
|
||||||
|
|
||||||
|
@property
|
||||||
|
def content(self) -> bytes:
|
||||||
|
if self._content is None:
|
||||||
|
self._content = self._resp.bytes()
|
||||||
|
return self._content
|
||||||
|
|
||||||
|
@property
|
||||||
|
def text(self) -> str:
|
||||||
|
if self._text is None:
|
||||||
|
encoding = self._resp.encoding or "utf-8"
|
||||||
|
self._text = self.content.decode(encoding, errors="replace")
|
||||||
|
return self._text
|
||||||
|
|
||||||
|
@property
|
||||||
|
def reason(self) -> str:
|
||||||
|
try:
|
||||||
|
return http.HTTPStatus(self.status_code).phrase
|
||||||
|
except ValueError:
|
||||||
|
return "Unknown"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cookies(self) -> Any:
|
||||||
|
return self._resp.cookies
|
||||||
|
|
||||||
|
def json(self, **kwargs: Any) -> Any:
|
||||||
|
import json as _json
|
||||||
|
return _json.loads(self.content)
|
||||||
|
|
||||||
|
def raise_for_status(self) -> None:
|
||||||
|
if not self.ok:
|
||||||
|
raise HTTPError(
|
||||||
|
f"{self.status_code} {self.reason}: {self.url}",
|
||||||
|
response=self,
|
||||||
|
)
|
||||||
|
|
||||||
|
def iter_content(self, chunk_size: Optional[int] = None) -> Iterator[bytes]:
|
||||||
|
"""Re-chunk rnet's variable-size stream into fixed-size pieces."""
|
||||||
|
self._streamed = True
|
||||||
|
if chunk_size is None or chunk_size <= 0:
|
||||||
|
yield from self._resp.stream()
|
||||||
|
return
|
||||||
|
|
||||||
|
buf = bytearray()
|
||||||
|
for chunk in self._resp.stream():
|
||||||
|
buf.extend(chunk)
|
||||||
|
while len(buf) >= chunk_size:
|
||||||
|
yield bytes(buf[:chunk_size])
|
||||||
|
buf = buf[chunk_size:]
|
||||||
|
if buf:
|
||||||
|
yield bytes(buf)
|
||||||
|
|
||||||
|
def stream(self) -> Iterator[bytes]:
|
||||||
|
"""Direct pass-through of rnet's native stream iterator."""
|
||||||
|
self._streamed = True
|
||||||
|
yield from self._resp.stream()
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
try:
|
||||||
|
self._resp.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Session headers adapter — persists via client.update()
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class RnetSessionHeaders(CaseInsensitiveDict):
|
||||||
|
"""Dict-like headers that persist to the rnet client via update()."""
|
||||||
|
|
||||||
|
def __init__(self, client: Any) -> None:
|
||||||
|
self._client = client
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def _sync(self) -> None:
|
||||||
|
"""Push current headers to the rnet client."""
|
||||||
|
if hasattr(self, "_store") and self._store:
|
||||||
|
self._client.update(headers={k: v for k, v in self.items()})
|
||||||
|
|
||||||
|
def __setitem__(self, key: str, value: str) -> None:
|
||||||
|
super().__setitem__(key, value)
|
||||||
|
self._sync()
|
||||||
|
|
||||||
|
def update(self, __m: Any = None, **kwargs: Any) -> None:
|
||||||
|
if __m:
|
||||||
|
if hasattr(__m, "items"):
|
||||||
|
for k, v in __m.items():
|
||||||
|
super().__setitem__(k, v)
|
||||||
|
else:
|
||||||
|
for k, v in __m:
|
||||||
|
super().__setitem__(k, v)
|
||||||
|
for k, v in kwargs.items():
|
||||||
|
super().__setitem__(k, v)
|
||||||
|
self._sync()
|
||||||
|
|
||||||
|
def pop(self, key: str, *args: Any) -> Any:
|
||||||
|
result = super().pop(key, *args)
|
||||||
|
# rnet doesn't support removing individual headers, but we track locally
|
||||||
|
# and always send the full set on next update
|
||||||
|
return result
|
||||||
|
|
||||||
|
def __delitem__(self, key: str) -> None:
|
||||||
|
super().__delitem__(key)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Session cookies adapter
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class RnetCookieAdapter(MutableMapping):
|
||||||
|
"""Cookie adapter that bridges requests-style cookie access to rnet."""
|
||||||
|
|
||||||
|
def __init__(self, client: Any) -> None:
|
||||||
|
self._client = client
|
||||||
|
self._cookies: dict[str, dict[str, str]] = {} # {domain: {name: value}}
|
||||||
|
self._flat: dict[str, str] = {} # flat name→value for simple access
|
||||||
|
|
||||||
|
def update(self, other: Any = None, **kwargs: Any) -> None:
|
||||||
|
if other is None:
|
||||||
|
other = {}
|
||||||
|
if isinstance(other, CookieJar):
|
||||||
|
for cookie in other:
|
||||||
|
domain = cookie.domain or ""
|
||||||
|
name = cookie.name
|
||||||
|
value = cookie.value or ""
|
||||||
|
self._flat[name] = value
|
||||||
|
self._cookies.setdefault(domain, {})[name] = value
|
||||||
|
try:
|
||||||
|
url = f"https://{domain.lstrip('.')}" if domain else "https://localhost"
|
||||||
|
self._client.set_cookie(url, rnet.Cookie(name, value))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
elif isinstance(other, dict):
|
||||||
|
for name, value in other.items():
|
||||||
|
self._flat[name] = value
|
||||||
|
self._client.set_cookie("https://localhost", rnet.Cookie(name, str(value)))
|
||||||
|
self._flat.update(other)
|
||||||
|
elif hasattr(other, "items"):
|
||||||
|
for name, value in other.items():
|
||||||
|
self._flat[name] = str(value)
|
||||||
|
self._client.set_cookie("https://localhost", rnet.Cookie(name, str(value)))
|
||||||
|
|
||||||
|
for name, value in kwargs.items():
|
||||||
|
self._flat[name] = value
|
||||||
|
self._client.set_cookie("https://localhost", rnet.Cookie(name, value))
|
||||||
|
|
||||||
|
def get(self, name: str, default: Optional[str] = None, domain: Optional[str] = None,
|
||||||
|
path: Optional[str] = None) -> Optional[str]:
|
||||||
|
if domain and domain in self._cookies:
|
||||||
|
return self._cookies[domain].get(name, default)
|
||||||
|
return self._flat.get(name, default)
|
||||||
|
|
||||||
|
def set(self, name: str, value: str, domain: str = "localhost") -> None:
|
||||||
|
self._flat[name] = value
|
||||||
|
self._cookies.setdefault(domain, {})[name] = value
|
||||||
|
url = f"https://{domain.lstrip('.')}"
|
||||||
|
self._client.set_cookie(url, rnet.Cookie(name, value))
|
||||||
|
|
||||||
|
def __getitem__(self, name: str) -> str:
|
||||||
|
return self._flat[name]
|
||||||
|
|
||||||
|
def __setitem__(self, name: str, value: str) -> None:
|
||||||
|
self.set(name, value)
|
||||||
|
|
||||||
|
def __delitem__(self, name: str) -> None:
|
||||||
|
self._flat.pop(name, None)
|
||||||
|
for domain_cookies in self._cookies.values():
|
||||||
|
domain_cookies.pop(name, None)
|
||||||
|
|
||||||
|
def __contains__(self, name: object) -> bool:
|
||||||
|
return name in self._flat
|
||||||
|
|
||||||
|
def __iter__(self) -> Iterator:
|
||||||
|
return iter(self._flat)
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return len(self._flat)
|
||||||
|
|
||||||
|
def __bool__(self) -> bool:
|
||||||
|
return bool(self._flat)
|
||||||
|
|
||||||
|
def items(self) -> list[tuple[str, str]]:
|
||||||
|
return list(self._flat.items())
|
||||||
|
|
||||||
|
def keys(self) -> list[str]:
|
||||||
|
return list(self._flat.keys())
|
||||||
|
|
||||||
|
def values(self) -> list[str]:
|
||||||
|
return list(self._flat.values())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Session proxy adapter
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class RnetProxyDict(dict):
|
||||||
|
"""Dict-like proxy config that syncs to the rnet client."""
|
||||||
|
|
||||||
|
def __init__(self, client: Any) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._client = client
|
||||||
|
|
||||||
|
def _sync(self) -> None:
|
||||||
|
proxy = self.get("all") or self.get("https") or self.get("http")
|
||||||
|
if proxy:
|
||||||
|
self._client.update(proxy=proxy)
|
||||||
|
|
||||||
|
def update(self, __m: Any = None, **kwargs: Any) -> None:
|
||||||
|
super().update(__m or {}, **kwargs)
|
||||||
|
self._sync()
|
||||||
|
|
||||||
|
def __setitem__(self, key: str, value: str) -> None:
|
||||||
|
super().__setitem__(key, value)
|
||||||
|
self._sync()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Exceptions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class MaxRetriesError(Exception):
|
||||||
|
def __init__(self, message: str, cause: Optional[Exception] = None) -> None:
|
||||||
super().__init__(message)
|
super().__init__(message)
|
||||||
self.__cause__ = cause
|
self.__cause__ = cause
|
||||||
|
|
||||||
|
|
||||||
class CurlSession(Session):
|
# ---------------------------------------------------------------------------
|
||||||
|
# RnetSession — main session class
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class RnetSession:
|
||||||
|
"""
|
||||||
|
TLS-fingerprinted HTTP session powered by rnet (Rust/BoringSSL).
|
||||||
|
|
||||||
|
Drop-in replacement for CurlSession with requests-compatible API.
|
||||||
|
Supports browser impersonation (Chrome, Firefox, Edge, Safari, OkHttp),
|
||||||
|
retry with exponential backoff, cookie persistence, and proxy support.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
max_retries: int = 5,
|
max_retries: int = 5,
|
||||||
backoff_factor: float = 0.2,
|
backoff_factor: float = 0.2,
|
||||||
max_backoff: float = 60.0,
|
max_backoff: float = 60.0,
|
||||||
status_forcelist: list[int] | None = None,
|
status_forcelist: Optional[list[int]] = None,
|
||||||
allowed_methods: set[str] | None = None,
|
allowed_methods: Optional[set[str]] = None,
|
||||||
catch_exceptions: tuple[type[Exception], ...] | None = None,
|
catch_exceptions: Optional[tuple[type[Exception], ...]] = None,
|
||||||
**session_kwargs: Any,
|
**session_kwargs: Any,
|
||||||
):
|
) -> None:
|
||||||
super().__init__(**session_kwargs)
|
# Extract retry config before passing to rnet
|
||||||
|
|
||||||
self.max_retries = max_retries
|
self.max_retries = max_retries
|
||||||
self.backoff_factor = backoff_factor
|
self.backoff_factor = backoff_factor
|
||||||
self.max_backoff = max_backoff
|
self.max_backoff = max_backoff
|
||||||
self.status_forcelist = status_forcelist or [429, 500, 502, 503, 504]
|
self.status_forcelist = status_forcelist or [429, 500, 502, 503, 504]
|
||||||
self.allowed_methods = allowed_methods or {"GET", "POST", "HEAD", "OPTIONS", "PUT", "DELETE", "TRACE"}
|
self.allowed_methods = allowed_methods or {"GET", "POST", "HEAD", "OPTIONS", "PUT", "DELETE", "TRACE"}
|
||||||
self.catch_exceptions = catch_exceptions or (
|
self.catch_exceptions = catch_exceptions or (
|
||||||
exceptions.ConnectionError,
|
rnet.ConnectionError,
|
||||||
exceptions.ProxyError,
|
rnet.TimeoutError,
|
||||||
exceptions.SSLError,
|
rnet.RequestError,
|
||||||
exceptions.Timeout,
|
|
||||||
)
|
)
|
||||||
self.log = logging.getLogger(self.__class__.__name__)
|
self.log = logging.getLogger(self.__class__.__name__)
|
||||||
|
|
||||||
def get_sleep_time(self, response: Response | None, attempt: int) -> float | None:
|
# Extract rnet-compatible kwargs
|
||||||
|
client_kwargs: dict[str, Any] = {}
|
||||||
|
for key in ("impersonate", "timeout", "proxy", "verify", "redirect"):
|
||||||
|
if key in session_kwargs:
|
||||||
|
client_kwargs[key] = session_kwargs.pop(key)
|
||||||
|
|
||||||
|
# Always enable cookie store
|
||||||
|
client_kwargs["cookie_store"] = True
|
||||||
|
|
||||||
|
# Handle verify=False
|
||||||
|
self.verify: bool = client_kwargs.pop("verify", True)
|
||||||
|
if not self.verify:
|
||||||
|
client_kwargs["danger_accept_invalid_certs"] = True
|
||||||
|
|
||||||
|
self._client = rnet.BlockingClient(**client_kwargs)
|
||||||
|
|
||||||
|
# Set up attribute adapters
|
||||||
|
self.headers = RnetSessionHeaders(self._client)
|
||||||
|
self.cookies = RnetCookieAdapter(self._client)
|
||||||
|
self.proxies = RnetProxyDict(self._client)
|
||||||
|
|
||||||
|
# Handle initial headers/cookies/proxies from kwargs
|
||||||
|
if "headers" in session_kwargs:
|
||||||
|
self.headers.update(session_kwargs.pop("headers"))
|
||||||
|
if "cookies" in session_kwargs:
|
||||||
|
self.cookies.update(session_kwargs.pop("cookies"))
|
||||||
|
if "proxies" in session_kwargs:
|
||||||
|
self.proxies.update(session_kwargs.pop("proxies"))
|
||||||
|
|
||||||
|
def _build_url(self, url: str, params: Optional[dict] = None) -> str:
|
||||||
|
"""URL-encode params dict into the URL (rnet ignores params kwarg)."""
|
||||||
|
if not params:
|
||||||
|
return url
|
||||||
|
parsed = urlparse(url)
|
||||||
|
separator = "&" if parsed.query else ""
|
||||||
|
query = parsed.query + separator + urlencode(params, doseq=True) if parsed.query else urlencode(params, doseq=True)
|
||||||
|
return urlunparse(parsed._replace(query=query))
|
||||||
|
|
||||||
|
def get_sleep_time(self, response: Optional[RnetResponse], attempt: int) -> Optional[float]:
|
||||||
if response:
|
if response:
|
||||||
retry_after = response.headers.get("Retry-After")
|
retry_after = response.headers.get("Retry-After")
|
||||||
if retry_after:
|
if retry_after:
|
||||||
@@ -108,19 +464,42 @@ class CurlSession(Session):
|
|||||||
sleep_time = backoff_value + random.uniform(-jitter, jitter)
|
sleep_time = backoff_value + random.uniform(-jitter, jitter)
|
||||||
return min(sleep_time, self.max_backoff)
|
return min(sleep_time, self.max_backoff)
|
||||||
|
|
||||||
def request(self, method: str, url: str, **kwargs: Any) -> Response:
|
def request(self, method: str, url: str, **kwargs: Any) -> RnetResponse:
|
||||||
if method.upper() not in self.allowed_methods:
|
method_upper = method.upper() if isinstance(method, str) else str(method).upper()
|
||||||
return super().request(method, url, **kwargs)
|
|
||||||
|
|
||||||
last_exception = None
|
# Build URL with params
|
||||||
response = None
|
url = self._build_url(url, kwargs.pop("params", None))
|
||||||
|
|
||||||
|
# Default allow_redirects=True
|
||||||
|
kwargs.setdefault("allow_redirects", True)
|
||||||
|
|
||||||
|
# Pass verify setting
|
||||||
|
if not self.verify:
|
||||||
|
kwargs.setdefault("verify", False)
|
||||||
|
|
||||||
|
# Remove kwargs rnet doesn't understand
|
||||||
|
kwargs.pop("stream", None) # rnet responses are always lazy
|
||||||
|
|
||||||
|
# Resolve method enum
|
||||||
|
rnet_method = _METHOD_MAP.get(method_upper)
|
||||||
|
if rnet_method is None:
|
||||||
|
raise ValueError(f"Unsupported HTTP method: {method}")
|
||||||
|
|
||||||
|
# Skip retry for non-allowed methods
|
||||||
|
if method_upper not in self.allowed_methods:
|
||||||
|
raw_resp = self._client.request(rnet_method, url, **kwargs)
|
||||||
|
return RnetResponse(raw_resp)
|
||||||
|
|
||||||
|
last_exception: Optional[Exception] = None
|
||||||
|
response: Optional[RnetResponse] = None
|
||||||
|
|
||||||
for attempt in range(self.max_retries + 1):
|
for attempt in range(self.max_retries + 1):
|
||||||
try:
|
try:
|
||||||
response = super().request(method, url, **kwargs)
|
raw_resp = self._client.request(rnet_method, url, **kwargs)
|
||||||
|
response = RnetResponse(raw_resp)
|
||||||
if response.status_code not in self.status_forcelist:
|
if response.status_code not in self.status_forcelist:
|
||||||
return response
|
return response
|
||||||
last_exception = exceptions.HTTPError(f"Received status code: {response.status_code}")
|
last_exception = HTTPError(f"Received status code: {response.status_code}")
|
||||||
self.log.warning(
|
self.log.warning(
|
||||||
f"{response.status_code} {response.reason}({urlparse(url).path}). Retrying... "
|
f"{response.status_code} {response.reason}({urlparse(url).path}). Retrying... "
|
||||||
f"({attempt + 1}/{self.max_retries})"
|
f"({attempt + 1}/{self.max_retries})"
|
||||||
@@ -142,120 +521,100 @@ class CurlSession(Session):
|
|||||||
|
|
||||||
raise MaxRetriesError(f"Max retries exceeded for {method} {url}", cause=last_exception)
|
raise MaxRetriesError(f"Max retries exceeded for {method} {url}", cause=last_exception)
|
||||||
|
|
||||||
|
def get(self, url: str, **kwargs: Any) -> RnetResponse:
|
||||||
|
return self.request("GET", url, **kwargs)
|
||||||
|
|
||||||
|
def post(self, url: str, **kwargs: Any) -> RnetResponse:
|
||||||
|
return self.request("POST", url, **kwargs)
|
||||||
|
|
||||||
|
def put(self, url: str, **kwargs: Any) -> RnetResponse:
|
||||||
|
return self.request("PUT", url, **kwargs)
|
||||||
|
|
||||||
|
def delete(self, url: str, **kwargs: Any) -> RnetResponse:
|
||||||
|
return self.request("DELETE", url, **kwargs)
|
||||||
|
|
||||||
|
def head(self, url: str, **kwargs: Any) -> RnetResponse:
|
||||||
|
return self.request("HEAD", url, **kwargs)
|
||||||
|
|
||||||
|
def options(self, url: str, **kwargs: Any) -> RnetResponse:
|
||||||
|
return self.request("OPTIONS", url, **kwargs)
|
||||||
|
|
||||||
|
def patch(self, url: str, **kwargs: Any) -> RnetResponse:
|
||||||
|
return self.request("PATCH", url, **kwargs)
|
||||||
|
|
||||||
|
def prepare_request(self, req: Request) -> Request:
|
||||||
|
"""Compatibility shim for services using prepared requests."""
|
||||||
|
# Merge session headers into request headers
|
||||||
|
if req.headers:
|
||||||
|
merged = dict(self.headers)
|
||||||
|
merged.update(req.headers)
|
||||||
|
req.headers = merged
|
||||||
|
else:
|
||||||
|
req.headers = dict(self.headers)
|
||||||
|
return req
|
||||||
|
|
||||||
|
def send(self, req: Request, **kwargs: Any) -> RnetResponse:
|
||||||
|
"""Compatibility shim for services using prepared requests."""
|
||||||
|
method = req.method or "GET"
|
||||||
|
url = req.url or ""
|
||||||
|
|
||||||
|
send_kwargs: dict[str, Any] = {}
|
||||||
|
if req.headers:
|
||||||
|
send_kwargs["headers"] = dict(req.headers)
|
||||||
|
if req.body:
|
||||||
|
send_kwargs["data"] = req.body
|
||||||
|
if req.json:
|
||||||
|
send_kwargs["json"] = req.json
|
||||||
|
|
||||||
|
send_kwargs.update(kwargs)
|
||||||
|
return self.request(method, url, **send_kwargs)
|
||||||
|
|
||||||
|
def mount(self, prefix: str, adapter: Any) -> None:
|
||||||
|
"""No-op — rnet handles TLS and connection pooling natively."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""No-op — rnet manages its own resources."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# session() factory
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def session(
|
def session(
|
||||||
browser: str | None = None,
|
browser: Optional[str] = None,
|
||||||
ja3: str | None = None,
|
**kwargs: Any,
|
||||||
akamai: str | None = None,
|
) -> RnetSession:
|
||||||
extra_fp: dict | None = None,
|
|
||||||
**kwargs,
|
|
||||||
) -> CurlSession:
|
|
||||||
"""
|
"""
|
||||||
Create a curl_cffi session that impersonates a browser or custom TLS/HTTP fingerprint.
|
Create an rnet session with TLS fingerprinting (browser/app impersonation).
|
||||||
|
|
||||||
This is a full replacement for requests.Session with browser impersonation
|
|
||||||
and anti-bot capabilities. The session uses curl-impersonate under the hood
|
|
||||||
to mimic real browser behavior.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
browser: Browser to impersonate (e.g. "chrome124", "firefox", "safari") OR
|
browser: Exact rnet.Impersonate preset name. Examples:
|
||||||
fingerprint preset name (e.g. "okhttp4").
|
"Chrome131", "OkHttp4_12", "Edge101", "Firefox135",
|
||||||
Uses the configured default from curl_impersonate.browser if not specified.
|
"Safari18", "OkHttp5", "Opera118"
|
||||||
Available presets: okhttp4, okhttp5
|
Uses the configured default from config if not specified.
|
||||||
See https://github.com/lexiforest/curl_cffi#sessions for browser options.
|
See rnet.Impersonate for all available presets.
|
||||||
ja3: Custom JA3 TLS fingerprint string (format: "SSLVersion,Ciphers,Extensions,Curves,PointFormats").
|
**kwargs: Additional arguments passed to RnetSession constructor.
|
||||||
When provided, curl_cffi will use this exact TLS fingerprint instead of the browser's default.
|
|
||||||
See https://curl-cffi.readthedocs.io/en/latest/impersonate/customize.html
|
|
||||||
akamai: Custom Akamai HTTP/2 fingerprint string (format: "SETTINGS|WINDOW_UPDATE|PRIORITY|PSEUDO_HEADERS").
|
|
||||||
When provided, curl_cffi will use this exact HTTP/2 fingerprint instead of the browser's default.
|
|
||||||
See https://curl-cffi.readthedocs.io/en/latest/impersonate/customize.html
|
|
||||||
extra_fp: Additional fingerprint parameters dict for advanced customization.
|
|
||||||
See https://curl-cffi.readthedocs.io/en/latest/impersonate/customize.html
|
|
||||||
**kwargs: Additional arguments passed to CurlSession constructor:
|
|
||||||
- headers: Additional headers (dict)
|
|
||||||
- cookies: Cookie jar or dict
|
|
||||||
- auth: HTTP basic auth tuple (username, password)
|
|
||||||
- proxies: Proxy configuration dict
|
|
||||||
- verify: SSL certificate verification (bool, default True)
|
|
||||||
- timeout: Request timeout in seconds (float or tuple)
|
|
||||||
- allow_redirects: Follow redirects (bool, default True)
|
|
||||||
- max_redirects: Maximum redirect count (int)
|
|
||||||
- cert: Client certificate (str or tuple)
|
|
||||||
|
|
||||||
Extra arguments for retry handler:
|
|
||||||
- max_retries: Maximum number of retries (int, default 5)
|
|
||||||
- backoff_factor: Backoff factor (float, default 0.2)
|
|
||||||
- max_backoff: Maximum backoff time (float, default 60.0)
|
|
||||||
- status_forcelist: List of status codes to force retry (list, default [429, 500, 502, 503, 504])
|
|
||||||
- allowed_methods: List of allowed HTTP methods (set, default {"GET", "POST", "HEAD", "OPTIONS", "PUT", "DELETE", "TRACE"})
|
|
||||||
- catch_exceptions: List of exceptions to catch (tuple, default (exceptions.ConnectionError, exceptions.ProxyError, exceptions.SSLError, exceptions.Timeout))
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
curl_cffi.requests.Session configured with browser impersonation or custom fingerprints,
|
RnetSession configured with browser impersonation and retry behavior.
|
||||||
common headers, and equivalent retry behavior to requests.Session.
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
# Standard browser impersonation
|
session() # Default browser from config
|
||||||
from unshackle.core.session import session
|
session("OkHttp4_12") # OkHttp 4.12 fingerprint
|
||||||
|
session("Chrome131") # Chrome 131
|
||||||
class MyService(Service):
|
session("Edge101", max_retries=3) # Edge 101 with custom retry
|
||||||
@staticmethod
|
|
||||||
def get_session():
|
|
||||||
return session() # Uses config default browser
|
|
||||||
|
|
||||||
# Use OkHttp 4.x preset for Android TV
|
|
||||||
class AndroidService(Service):
|
|
||||||
@staticmethod
|
|
||||||
def get_session():
|
|
||||||
return session("okhttp4")
|
|
||||||
|
|
||||||
# Custom fingerprint (manual)
|
|
||||||
class CustomService(Service):
|
|
||||||
@staticmethod
|
|
||||||
def get_session():
|
|
||||||
return session(
|
|
||||||
ja3="771,4865-4866-4867-49195...",
|
|
||||||
akamai="1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p",
|
|
||||||
)
|
|
||||||
|
|
||||||
# With retry configuration
|
|
||||||
class MyService(Service):
|
|
||||||
@staticmethod
|
|
||||||
def get_session():
|
|
||||||
return session(
|
|
||||||
"okhttp4",
|
|
||||||
max_retries=5,
|
|
||||||
status_forcelist=[429, 500],
|
|
||||||
allowed_methods={"GET", "HEAD", "OPTIONS"},
|
|
||||||
)
|
|
||||||
"""
|
"""
|
||||||
|
if browser is None:
|
||||||
|
browser = config.curl_impersonate.get("browser", "Chrome131")
|
||||||
|
|
||||||
if browser and browser in FINGERPRINT_PRESETS:
|
impersonate = _resolve_impersonate(browser)
|
||||||
preset = FINGERPRINT_PRESETS[browser]
|
|
||||||
if ja3 is None:
|
|
||||||
ja3 = preset.get("ja3")
|
|
||||||
if akamai is None:
|
|
||||||
akamai = preset.get("akamai")
|
|
||||||
if extra_fp is None:
|
|
||||||
extra_fp = preset.get("extra_fp")
|
|
||||||
browser = None
|
|
||||||
|
|
||||||
if browser is None and ja3 is None and akamai is None:
|
session_kwargs: dict[str, Any] = {"impersonate": impersonate}
|
||||||
browser = config.curl_impersonate.get("browser", "chrome")
|
session_kwargs.update(kwargs)
|
||||||
|
|
||||||
session_config = {}
|
session_obj = RnetSession(**session_kwargs)
|
||||||
if browser:
|
|
||||||
session_config["impersonate"] = browser
|
|
||||||
|
|
||||||
if ja3:
|
|
||||||
session_config["ja3"] = ja3
|
|
||||||
if akamai:
|
|
||||||
session_config["akamai"] = akamai
|
|
||||||
if extra_fp:
|
|
||||||
session_config["extra_fp"] = extra_fp
|
|
||||||
|
|
||||||
session_config.update(kwargs)
|
|
||||||
|
|
||||||
session_obj = CurlSession(**session_config)
|
|
||||||
session_obj.headers.update(config.headers)
|
session_obj.headers.update(config.headers)
|
||||||
return session_obj
|
return session_obj
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ from typing import Any, Callable, Iterable, Optional, Union
|
|||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from zlib import crc32
|
from zlib import crc32
|
||||||
|
|
||||||
from curl_cffi.requests import Session as CurlSession
|
|
||||||
from langcodes import Language
|
from langcodes import Language
|
||||||
from requests import Session
|
from requests import Session
|
||||||
|
|
||||||
@@ -24,6 +23,7 @@ from unshackle.core.constants import DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY
|
|||||||
from unshackle.core.downloaders import requests
|
from unshackle.core.downloaders import requests
|
||||||
from unshackle.core.drm import DRM_T, PlayReady, Widevine
|
from unshackle.core.drm import DRM_T, PlayReady, Widevine
|
||||||
from unshackle.core.events import events
|
from unshackle.core.events import events
|
||||||
|
from unshackle.core.session import RnetSession
|
||||||
from unshackle.core.utilities import get_boxes, try_ensure_utf8
|
from unshackle.core.utilities import get_boxes, try_ensure_utf8
|
||||||
from unshackle.core.utils.subprocess import ffprobe
|
from unshackle.core.utils.subprocess import ffprobe
|
||||||
|
|
||||||
@@ -326,6 +326,9 @@ class Track:
|
|||||||
):
|
):
|
||||||
file_downloaded = status_update.get("file_downloaded")
|
file_downloaded = status_update.get("file_downloaded")
|
||||||
if not file_downloaded:
|
if not file_downloaded:
|
||||||
|
downloaded = status_update.get("downloaded")
|
||||||
|
if downloaded and downloaded.endswith("/s"):
|
||||||
|
status_update["downloaded"] = f"URL {downloaded}"
|
||||||
progress(**status_update)
|
progress(**status_update)
|
||||||
|
|
||||||
# see https://github.com/devine-dl/devine/issues/71
|
# see https://github.com/devine-dl/devine/issues/71
|
||||||
@@ -584,8 +587,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, RnetSession, 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 {RnetSession}, not {type(session)}")
|
||||||
|
|
||||||
if not url:
|
if not url:
|
||||||
if self.descriptor != self.Descriptor.URL:
|
if self.descriptor != self.Descriptor.URL:
|
||||||
@@ -623,10 +626,11 @@ class Track:
|
|||||||
init_data = res.content
|
init_data = res.content
|
||||||
else:
|
else:
|
||||||
init_data = None
|
init_data = None
|
||||||
with session.get(url, stream=True) as s:
|
s = session.get(url, stream=True)
|
||||||
for chunk in s.iter_content(content_length):
|
for chunk in s.iter_content(content_length):
|
||||||
init_data = chunk
|
init_data = chunk
|
||||||
break
|
break
|
||||||
|
s.close()
|
||||||
if not init_data:
|
if not init_data:
|
||||||
raise ValueError(f"Failed to read {content_length} bytes from the track URI.")
|
raise ValueError(f"Failed to read {content_length} bytes from the track URI.")
|
||||||
|
|
||||||
|
|||||||
147
uv.lock
generated
147
uv.lock
generated
@@ -123,20 +123,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/ba/98/6775d71cf7d88d554e8394f5ce5cda90041c99fdf1b2b60af02001e8c790/animeapi_py-3.8.1-py3-none-any.whl", hash = "sha256:c29f6e633d17bb613f459aa6514c0baab7ae325881f8a109eb6e4b3be5c22827", size = 26983, upload-time = "2026-02-25T15:29:16.685Z" },
|
{ url = "https://files.pythonhosted.org/packages/ba/98/6775d71cf7d88d554e8394f5ce5cda90041c99fdf1b2b60af02001e8c790/animeapi_py-3.8.1-py3-none-any.whl", hash = "sha256:c29f6e633d17bb613f459aa6514c0baab7ae325881f8a109eb6e4b3be5c22827", size = 26983, upload-time = "2026-02-25T15:29:16.685Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "anyio"
|
|
||||||
version = "4.12.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
|
||||||
{ name = "idna" },
|
|
||||||
{ name = "typing-extensions" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "appdirs"
|
name = "appdirs"
|
||||||
version = "1.4.4"
|
version = "1.4.4"
|
||||||
@@ -442,27 +428,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/a7/ec/bb273b7208c606890dc36540fe667d06ce840a6f62f9fae7e658fcdc90fb/cssutils-2.11.1-py3-none-any.whl", hash = "sha256:a67bfdfdff4f3867fab43698ec4897c1a828eca5973f4073321b3bccaf1199b1", size = 385747, upload-time = "2024-06-04T15:51:37.499Z" },
|
{ url = "https://files.pythonhosted.org/packages/a7/ec/bb273b7208c606890dc36540fe667d06ce840a6f62f9fae7e658fcdc90fb/cssutils-2.11.1-py3-none-any.whl", hash = "sha256:a67bfdfdff4f3867fab43698ec4897c1a828eca5973f4073321b3bccaf1199b1", size = 385747, upload-time = "2024-06-04T15:51:37.499Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "curl-cffi"
|
|
||||||
version = "0.13.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "certifi" },
|
|
||||||
{ name = "cffi" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/4e/3d/f39ca1f8fdf14408888e7c25e15eed63eac5f47926e206fb93300d28378c/curl_cffi-0.13.0.tar.gz", hash = "sha256:62ecd90a382bd5023750e3606e0aa7cb1a3a8ba41c14270b8e5e149ebf72c5ca", size = 151303, upload-time = "2025-08-06T13:05:42.988Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/19/d1/acabfd460f1de26cad882e5ef344d9adde1507034528cb6f5698a2e6a2f1/curl_cffi-0.13.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:434cadbe8df2f08b2fc2c16dff2779fb40b984af99c06aa700af898e185bb9db", size = 5686337, upload-time = "2025-08-06T13:05:28.985Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2c/1c/cdb4fb2d16a0e9de068e0e5bc02094e105ce58a687ff30b4c6f88e25a057/curl_cffi-0.13.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:59afa877a9ae09efa04646a7d068eeea48915a95d9add0a29854e7781679fcd7", size = 2994613, upload-time = "2025-08-06T13:05:31.027Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/04/3e/fdf617c1ec18c3038b77065d484d7517bb30f8fb8847224eb1f601a4e8bc/curl_cffi-0.13.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d06ed389e45a7ca97b17c275dbedd3d6524560270e675c720e93a2018a766076", size = 7931353, upload-time = "2025-08-06T13:05:32.273Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3d/10/6f30c05d251cf03ddc2b9fd19880f3cab8c193255e733444a2df03b18944/curl_cffi-0.13.0-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b4e0de45ab3b7a835c72bd53640c2347415111b43421b5c7a1a0b18deae2e541", size = 7486378, upload-time = "2025-08-06T13:05:33.672Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/77/81/5bdb7dd0d669a817397b2e92193559bf66c3807f5848a48ad10cf02bf6c7/curl_cffi-0.13.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8eb4083371bbb94e9470d782de235fb5268bf43520de020c9e5e6be8f395443f", size = 8328585, upload-time = "2025-08-06T13:05:35.28Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ce/c1/df5c6b4cfad41c08442e0f727e449f4fb5a05f8aa564d1acac29062e9e8e/curl_cffi-0.13.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:28911b526e8cd4aa0e5e38401bfe6887e8093907272f1f67ca22e6beb2933a51", size = 8739831, upload-time = "2025-08-06T13:05:37.078Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1a/91/6dd1910a212f2e8eafe57877bcf97748eb24849e1511a266687546066b8a/curl_cffi-0.13.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6d433ffcb455ab01dd0d7bde47109083aa38b59863aa183d29c668ae4c96bf8e", size = 8711908, upload-time = "2025-08-06T13:05:38.741Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6d/e4/15a253f9b4bf8d008c31e176c162d2704a7e0c5e24d35942f759df107b68/curl_cffi-0.13.0-cp39-abi3-win_amd64.whl", hash = "sha256:66a6b75ce971de9af64f1b6812e275f60b88880577bac47ef1fa19694fa21cd3", size = 1614510, upload-time = "2025-08-06T13:05:40.451Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f9/0f/9c5275f17ad6ff5be70edb8e0120fdc184a658c9577ca426d4230f654beb/curl_cffi-0.13.0-cp39-abi3-win_arm64.whl", hash = "sha256:d438a3b45244e874794bc4081dc1e356d2bb926dcc7021e5a8fef2e2105ef1d8", size = 1365753, upload-time = "2025-08-06T13:05:41.879Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dacite"
|
name = "dacite"
|
||||||
version = "1.9.2"
|
version = "1.9.2"
|
||||||
@@ -490,18 +455,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/e8/35/4a113189f7138035a21bd255d30dc7bffc77c942c93b7948d2eac2e22429/ECPy-1.2.5-py3-none-any.whl", hash = "sha256:559c92e42406d9d1a6b2b8fc26e6ad7bc985f33903b72f426a56cb1073a25ce3", size = 43075, upload-time = "2020-10-26T11:56:13.613Z" },
|
{ url = "https://files.pythonhosted.org/packages/e8/35/4a113189f7138035a21bd255d30dc7bffc77c942c93b7948d2eac2e22429/ECPy-1.2.5-py3-none-any.whl", hash = "sha256:559c92e42406d9d1a6b2b8fc26e6ad7bc985f33903b72f426a56cb1073a25ce3", size = 43075, upload-time = "2020-10-26T11:56:13.613Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "exceptiongroup"
|
|
||||||
version = "1.3.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "typing-extensions" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastjsonschema"
|
name = "fastjsonschema"
|
||||||
version = "2.19.1"
|
version = "2.19.1"
|
||||||
@@ -622,43 +575,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/ab/6e/81d47999aebc1b155f81eca4477a616a70f238a2549848c38983f3c22a82/ftfy-6.3.1-py3-none-any.whl", hash = "sha256:7c70eb532015cd2f9adb53f101fb6c7945988d023a085d127d1573dc49dd0083", size = 44821, upload-time = "2024-10-26T00:50:33.425Z" },
|
{ url = "https://files.pythonhosted.org/packages/ab/6e/81d47999aebc1b155f81eca4477a616a70f238a2549848c38983f3c22a82/ftfy-6.3.1-py3-none-any.whl", hash = "sha256:7c70eb532015cd2f9adb53f101fb6c7945988d023a085d127d1573dc49dd0083", size = 44821, upload-time = "2024-10-26T00:50:33.425Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "h11"
|
|
||||||
version = "0.16.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
|
||||||
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 = "httpcore"
|
|
||||||
version = "1.0.9"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "certifi" },
|
|
||||||
{ name = "h11" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "httpx"
|
|
||||||
version = "0.28.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "anyio" },
|
|
||||||
{ name = "certifi" },
|
|
||||||
{ name = "httpcore" },
|
|
||||||
{ name = "idna" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
|
||||||
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]]
|
[[package]]
|
||||||
name = "identify"
|
name = "identify"
|
||||||
version = "2.6.16"
|
version = "2.6.16"
|
||||||
@@ -1046,14 +962,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" },
|
{ url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pproxy"
|
|
||||||
version = "2.7.9"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d4/c6/673a10a729061d2594b85aedd7dd2e470db4d54b12d4f95a306353bb2967/pproxy-2.7.9-py3-none-any.whl", hash = "sha256:a073d02616a47c43e1d20a547918c307dbda598c6d53869b165025f3cfe58e80", size = 42842, upload-time = "2024-01-16T11:33:35.286Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pre-commit"
|
name = "pre-commit"
|
||||||
version = "4.5.1"
|
version = "4.5.1"
|
||||||
@@ -1434,6 +1342,53 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/52/d2/d2ffaecbfff0c057b5824a82b57b709b1c5b2966c970e4c5d6e1d8109b21/rlaphoenix.m3u8-3.4.0-py3-none-any.whl", hash = "sha256:cd2c22195c747d52c63189d4bd5f664e1fc5ea202f5a7396b7336581f26a2838", size = 24767, upload-time = "2023-03-09T21:37:38.326Z" },
|
{ url = "https://files.pythonhosted.org/packages/52/d2/d2ffaecbfff0c057b5824a82b57b709b1c5b2966c970e4c5d6e1d8109b21/rlaphoenix.m3u8-3.4.0-py3-none-any.whl", hash = "sha256:cd2c22195c747d52c63189d4bd5f664e1fc5ea202f5a7396b7336581f26a2838", size = 24767, upload-time = "2023-03-09T21:37:38.326Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rnet"
|
||||||
|
version = "2.4.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/10/bc/e5e4395e67803405900b98d503a23c1125432a5a73d2c311dd2ebe11b7fc/rnet-2.4.2.tar.gz", hash = "sha256:9fc9ea17a7afea799e10670f0c1da939f500c440760aeefe42209644ffef5bf5", size = 515573, upload-time = "2025-08-02T23:26:27.795Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/5e/09b4fcb92611b6c51db2b7abb0a126aa87a76350e1da783ea35e3c9711af/rnet-2.4.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:9e5c8e485396dc86cdd39bf036747866f9ccf1c462ed660c65df4fea57b7d8b7", size = 3703136, upload-time = "2025-08-02T23:25:24.945Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/0e/40b06dec2a172e2136d0c731880f5932b4383da470dc0ccf17f3fdd196da/rnet-2.4.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b092c70d4943d914272c58bc17e2382054c3180828564f378411cdfebc752f7a", size = 3429794, upload-time = "2025-08-02T23:25:12.382Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/68/31/4e51497c8722379c79b054bb6d98e0273f42248de948f7dbc3c4dcde88cb/rnet-2.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f881c1334d8f65b8c3c54eacccc487b21ea778762dc40e20d94ee8f841a2bb9", size = 3661754, upload-time = "2025-08-02T23:24:59.127Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/c3/9b43dde7c6b505eae0d0c23133b612b07d9221f0423fac55abbda78d5bdb/rnet-2.4.2-cp310-cp310-manylinux_2_34_aarch64.whl", hash = "sha256:ad5d2af6097493a84f9ef006f709fa4a3d42957f38aa84dd6283f8856e94e773", size = 3609141, upload-time = "2025-08-02T23:24:20.516Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/37/37e5a0b9eb1c4a782c399443c5d498b24a2d40baa86842afd1588f4b4508/rnet-2.4.2-cp310-cp310-manylinux_2_34_armv7l.whl", hash = "sha256:5cdaf7a141a045cae13961b206406ccc34d8b9f3bac9d5e44bd26f14c33ca657", size = 3424711, upload-time = "2025-08-02T23:24:34.339Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/6c/aef21e909707d0bbfd347a843fefbed2fd50255c7a99ff4251fce82e2362/rnet-2.4.2-cp310-cp310-manylinux_2_34_i686.whl", hash = "sha256:df33b9f4e5e2bdc21aba4189628a6827d950718f863904c5ee3f43a40c60089a", size = 3686201, upload-time = "2025-08-02T23:24:46.31Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/c5/5ed5ee58cba531681e73099e619c2d36e8453e28764c71682a32c373b30c/rnet-2.4.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a76a4976e065ff2af0fbfa13ea14e2b2f449ba6ea708125029d54738e3c638cf", size = 3957076, upload-time = "2025-08-02T23:25:37.751Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/88/2ac698c25fe8c7a108d0bf7b76afa0049d9f4c1ae7162542434970936a00/rnet-2.4.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1030ca77af54bfee5739d4bb34f403329b154cdbab4bcd2feeb20fab22955359", size = 3919451, upload-time = "2025-08-02T23:25:49.469Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/1b/129029ba55eeb1daa58ab7e88a06f2a95b8246b207fbe8bbc04f9f23d2cd/rnet-2.4.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fc5dd872523a4b5f21ea7092fd9440a0677f609e3b971c60673b4dbd984745a9", size = 4005497, upload-time = "2025-08-02T23:26:02.816Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/a2/4df4e00e1f3b04c902ab494147140fea308d139c5f7697aedcf949d8f225/rnet-2.4.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:080d04ceaf7be30505d11360b33d5d43668b557a7b86de3c548882d1de19bc4f", size = 4166618, upload-time = "2025-08-02T23:26:15.245Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/93/cbf1d634d17b220bb7ba52fd38afd98101a010bf5c873af5815eba6e601d/rnet-2.4.2-cp310-cp310-win32.whl", hash = "sha256:9e8f79f055630780e1334255b1167b30b99989e31a87e10295e143240eb519d5", size = 3207306, upload-time = "2025-08-02T23:26:53.616Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/36/dd76e90d1fea4688f64cb6263244500fea6b1c8f979bb1651f132515a617/rnet-2.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:f4e891d603c6fe4f28857b161d6ee10975633a5ee1867050962aef3954cf3e1a", size = 3561188, upload-time = "2025-08-02T23:26:41.141Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/08/beb3c97573688b23f081d35f6280db9438c3a32ec7dc6ba8479107f8d913/rnet-2.4.2-cp310-cp310-win_arm64.whl", hash = "sha256:372e9a7764f6947a8484774827829e291a8f299b80f93cf9318483c60b1c1921", size = 3202587, upload-time = "2025-08-02T23:26:29.299Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/b3/7cbd1daf6cf3a5eb56615128e5a9fb5f3fda6457d511791766c39cc71203/rnet-2.4.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0106d7b43ea92a02458eea5e5c76ac67ff978f5715293c836164c4a05a7eb890", size = 3703182, upload-time = "2025-08-02T23:25:26.218Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/2f/4bd07edd1785445b95e717ad93c5845b18e8d4df578e1c62c11c77a9aea4/rnet-2.4.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:14074800998098403540b9b624e78f7dd811605ac0f1a6081a12ad5e6e1fd1ac", size = 3429858, upload-time = "2025-08-02T23:25:13.59Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/4e/d71e2c30526c54ace931f95c5134cb474aaa9f3142e4e11f651bb1ec7b27/rnet-2.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:573c637accaf7f3c7aa6d2241224ed577444c00e8af4e631b243b3cae765c502", size = 3661678, upload-time = "2025-08-02T23:25:00.677Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/27/a33ac1b61d29015e832ff960b274929288b6901cca3cf415e1f6a0aec1ed/rnet-2.4.2-cp311-cp311-manylinux_2_34_aarch64.whl", hash = "sha256:48fca3430dc4d90c920c08474a0db0ec3e6465226a08345b10b6cc58c8b0c23e", size = 3609069, upload-time = "2025-08-02T23:24:22.862Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/f5/628153c9228e7430650e11c1f40cdb53a1a23592d98c39aafb534217278e/rnet-2.4.2-cp311-cp311-manylinux_2_34_armv7l.whl", hash = "sha256:7bf06f481297304d426cd7c6b36babc3859ae242cde276f038f6f51cff7fd4de", size = 3424456, upload-time = "2025-08-02T23:24:35.592Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5e/c9/c6444cfa9c935ef2b9a273470812b8555cec262dbab7f20325fd67a27c1d/rnet-2.4.2-cp311-cp311-manylinux_2_34_i686.whl", hash = "sha256:ca351af5ccb531d308eeb7ae3dcbfba038a14d4897e22139d76f8cd88eed649f", size = 3686160, upload-time = "2025-08-02T23:24:47.5Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/6a/d7c48b8400b30c1931a800c79b429692758ef349b1a210bb9f499f199687/rnet-2.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:129eab0183ca50fd5f57b24b3b4387a5edca727e4004c1debcb5c23ecba6c128", size = 3957128, upload-time = "2025-08-02T23:25:39Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/cf/ddabfa4299dbeefae488a54e95684c0c68c00b5d3cff3b8212d1adf2b206/rnet-2.4.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:4a0449fae24b95b29f8a3f433f7866fc6c94d9ee37d2a5d94b7154eb436ee448", size = 3919406, upload-time = "2025-08-02T23:25:50.78Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/fe/e92e5dacfc97041cbf335c10e0a45b7ac71e0d30c51e0a0dc51d35d1ce0b/rnet-2.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b8a17765351c3f75ce725ee4d946a255c1b3920462252edffd737e81ce996fc7", size = 4005641, upload-time = "2025-08-02T23:26:04.192Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/a6/156f5801328adc4296f6686e27f69ea22cc0c17d1f108759caa53bcedeb5/rnet-2.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e73fb0e89965ed31b22644e221bdd928ebcb6b3f8ce75da4f083cb92baef844f", size = 4166702, upload-time = "2025-08-02T23:26:16.539Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/e6/42a36a76238e10b157e1265be38f2fc66eeb4eaa5b9b3dfdcd4b581e2e6f/rnet-2.4.2-cp311-cp311-win32.whl", hash = "sha256:f3296e85f3f8da7165d8b7df5633f8443b1f2597215646e8e090d1affaa3d1b0", size = 3207638, upload-time = "2025-08-02T23:26:54.904Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/6e/92d99f03522cddffb4d00dbac4b63daafbd7966a915ec689bb713da45d3e/rnet-2.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:847529308ab9cf59f0d4ac5a9d1fe051894a26aadf3b8f8b20a862302587725f", size = 3561173, upload-time = "2025-08-02T23:26:42.451Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/bc/4a4d19425adf6a62459da608988b4de0f43c71d252cf0b15517cdb46649e/rnet-2.4.2-cp311-cp311-win_arm64.whl", hash = "sha256:b2eb935265a0771f9b323f2980455b5478550919d18572d523ac2cb5f328e7f7", size = 3202633, upload-time = "2025-08-02T23:26:30.611Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/22/434a9aa0228a4fa2abe48b04d36214f5cbe08af45afdb833ac7cc02cd913/rnet-2.4.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b853a9809588a569011142b9bae142ad982387640edcfd38fba4337b044900ae", size = 3694856, upload-time = "2025-08-02T23:25:27.818Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/ca/b49c2dce89381b7697ccb771a6850eea13934ef1eb37a8ef2ba27d925643/rnet-2.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:da547d7be92261ead4bc0ce23e30823c760d638055fd301da18c6521ec245fc8", size = 3420543, upload-time = "2025-08-02T23:25:14.783Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/cb/7c5979932069c9f40651d4aca487bfe639a94098eb123d7ec466f7f7730d/rnet-2.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc5bf17b3ed46455fe70a784dff0ccc7beaf54984d554536e644b6d1dacea63e", size = 3658152, upload-time = "2025-08-02T23:25:01.876Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/2f/83b754b1383a8cf6e696cb547e0ec4d47ba58dc838b16341be6f1af0ede6/rnet-2.4.2-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:3fe4531b0bffc26d10e3baec2f3d0deb59fb8ff157c56b985d9bd2d6060b2715", size = 3602597, upload-time = "2025-08-02T23:24:24.421Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/91/4a/3012990ec2f309baf41f70929bac0f166db3a7ce5a6bca1143ba6e9b4610/rnet-2.4.2-cp312-cp312-manylinux_2_34_armv7l.whl", hash = "sha256:c51cc5648efdc97bb17d88aab30f0596924766dc137109865bff72539141a81e", size = 3418020, upload-time = "2025-08-02T23:24:36.835Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/5b/6780b490a7d9dfc76c17c68f84b9d5cfb602bdc5db4ca5774930e7b7933e/rnet-2.4.2-cp312-cp312-manylinux_2_34_i686.whl", hash = "sha256:b13ee78075389050ae537d9c6957d8de820d0c7f3c7053dfc3e103e0538890a7", size = 3679644, upload-time = "2025-08-02T23:24:49.07Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/d4/a092274c9513d67f802cc6f3472068f6cbf30652d00d4b5c29617c20479d/rnet-2.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1d2e21ed40fcaead89a9f711354f92d5d2690e621a0a6f37edf6d655d1994d58", size = 3953384, upload-time = "2025-08-02T23:25:40.244Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b6/5f/e4660f38921f41ab2199228d173d6f5d881f391c7b686695dd383fd41693/rnet-2.4.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:8c5672ff8cdb9042a275187badd28279f979e20dcf175da22fce666af7b1b273", size = 3913721, upload-time = "2025-08-02T23:25:52.214Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/44/89/1e81dd97c9ab45bfed871b5cb7fec50893f1a6be6bfd2c237cf3b902cf63/rnet-2.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:82037372e94fd7bc999ccac1b971da1a0f15a469979777c11dd225fddb249de1", size = 4001858, upload-time = "2025-08-02T23:26:05.506Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/5c/475c7c9bff6e94a7e5d457e8de2b5786a1f1a7488ad48b29cafddbb530bf/rnet-2.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:582f71311cb42db9a396bb95f39577fc4ac5e94d6de17696d2900a05814e5ac6", size = 4162005, upload-time = "2025-08-02T23:26:17.797Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/36/c4bdcdcdd9682fcb1fe01a371e8c25bff949bd719fd78021112538951bd3/rnet-2.4.2-cp312-cp312-win32.whl", hash = "sha256:355b849b67b131fbeffb7b5ee9a4057d3b4f576c1c63a59698a49f86c3a0bc80", size = 3200189, upload-time = "2025-08-02T23:26:56.208Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/cd/cb6f11f33e0a7d567b980c2b7e19f5f0e827a9ea33c53c2de350ef23f121/rnet-2.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:d47a3fb6339e62b06cabeed8dc4aab28050cadc02a8dcbf56b688fb1ca2c7171", size = 3560606, upload-time = "2025-08-02T23:26:43.797Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/70/5ded6c684343fd1a59e5b9ed4ffc7ec783d080bac32ba98c503d363914c0/rnet-2.4.2-cp312-cp312-win_arm64.whl", hash = "sha256:71d3f845b0f44d2353133ac8ee3bea39d00a6766356aa0d1f545b739380d0bea", size = 3199395, upload-time = "2025-08-02T23:26:31.975Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ruamel-yaml"
|
name = "ruamel-yaml"
|
||||||
version = "0.18.17"
|
version = "0.18.17"
|
||||||
@@ -1663,15 +1618,12 @@ dependencies = [
|
|||||||
{ name = "construct" },
|
{ name = "construct" },
|
||||||
{ name = "crccheck" },
|
{ name = "crccheck" },
|
||||||
{ name = "cryptography" },
|
{ name = "cryptography" },
|
||||||
{ name = "curl-cffi" },
|
|
||||||
{ name = "filelock" },
|
{ name = "filelock" },
|
||||||
{ name = "fonttools" },
|
{ name = "fonttools" },
|
||||||
{ name = "httpx" },
|
|
||||||
{ name = "jsonpickle" },
|
{ name = "jsonpickle" },
|
||||||
{ name = "langcodes" },
|
{ name = "langcodes" },
|
||||||
{ name = "language-data" },
|
{ name = "language-data" },
|
||||||
{ name = "lxml" },
|
{ name = "lxml" },
|
||||||
{ name = "pproxy" },
|
|
||||||
{ name = "protobuf" },
|
{ name = "protobuf" },
|
||||||
{ name = "pycaption" },
|
{ name = "pycaption" },
|
||||||
{ name = "pycountry" },
|
{ name = "pycountry" },
|
||||||
@@ -1688,6 +1640,7 @@ dependencies = [
|
|||||||
{ name = "requests", extra = ["socks"] },
|
{ name = "requests", extra = ["socks"] },
|
||||||
{ name = "rich" },
|
{ name = "rich" },
|
||||||
{ name = "rlaphoenix-m3u8" },
|
{ name = "rlaphoenix-m3u8" },
|
||||||
|
{ name = "rnet" },
|
||||||
{ name = "ruamel-yaml" },
|
{ name = "ruamel-yaml" },
|
||||||
{ name = "sortedcontainers" },
|
{ name = "sortedcontainers" },
|
||||||
{ name = "subby" },
|
{ name = "subby" },
|
||||||
@@ -1722,15 +1675,12 @@ requires-dist = [
|
|||||||
{ name = "construct", specifier = ">=2.8.8,<3" },
|
{ name = "construct", specifier = ">=2.8.8,<3" },
|
||||||
{ name = "crccheck", specifier = ">=1.3.0,<2" },
|
{ name = "crccheck", specifier = ">=1.3.0,<2" },
|
||||||
{ name = "cryptography", specifier = ">=45.0.0,<47" },
|
{ name = "cryptography", specifier = ">=45.0.0,<47" },
|
||||||
{ 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 = "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" },
|
||||||
{ name = "lxml", specifier = ">=5.2.1,<7" },
|
{ name = "lxml", specifier = ">=5.2.1,<7" },
|
||||||
{ name = "pproxy", specifier = ">=2.7.9,<3" },
|
|
||||||
{ name = "protobuf", specifier = ">=4.25.3,<7" },
|
{ name = "protobuf", specifier = ">=4.25.3,<7" },
|
||||||
{ name = "pycaption", specifier = ">=2.2.6,<3" },
|
{ name = "pycaption", specifier = ">=2.2.6,<3" },
|
||||||
{ name = "pycountry", specifier = ">=24.6.1" },
|
{ name = "pycountry", specifier = ">=24.6.1" },
|
||||||
@@ -1747,6 +1697,7 @@ requires-dist = [
|
|||||||
{ name = "requests", extras = ["socks"], specifier = ">=2.32.5,<3" },
|
{ name = "requests", extras = ["socks"], specifier = ">=2.32.5,<3" },
|
||||||
{ name = "rich", specifier = ">=13.7.1,<15" },
|
{ name = "rich", specifier = ">=13.7.1,<15" },
|
||||||
{ name = "rlaphoenix-m3u8", specifier = ">=3.4.0,<4" },
|
{ name = "rlaphoenix-m3u8", specifier = ">=3.4.0,<4" },
|
||||||
|
{ name = "rnet", specifier = ">=2.4.2" },
|
||||||
{ name = "ruamel-yaml", specifier = ">=0.18.6,<0.19" },
|
{ name = "ruamel-yaml", specifier = ">=0.18.6,<0.19" },
|
||||||
{ name = "sortedcontainers", specifier = ">=2.4.0,<3" },
|
{ name = "sortedcontainers", specifier = ">=2.4.0,<3" },
|
||||||
{ name = "subby", git = "https://github.com/vevv/subby.git?rev=1ea6a52028c5bea8177c8abc91716d74e4d097e1" },
|
{ name = "subby", git = "https://github.com/vevv/subby.git?rev=1ea6a52028c5bea8177c8abc91716d74e4d097e1" },
|
||||||
|
|||||||
Reference in New Issue
Block a user