feat(downloader): adaptive worker scaling, tail boost, session parity, resume fixes

Opt-in --adaptive-workers (AIMD scaling + tail range-split), request/rnet session parity, and fixes for resume poisoning, truncated-segment acceptance, 416 handling, and byte-range-slice corruption. Tests + docs.
This commit is contained in:
imSp4rky
2026-07-06 11:25:29 -06:00
parent 774c1a4555
commit 2f8492ebaf
16 changed files with 1085 additions and 146 deletions
+1
View File
@@ -171,6 +171,7 @@ Keep only certain track types, or skip certain track types. Attachments are alwa
| Flag | Default | Description |
|---|---|---|
| `--workers` | downloader default | Per-track download threads. |
| `--adaptive-workers` | off | Scale per-track segment workers dynamically (up to the `--workers` cap) based on measured CDN throughput and errors. |
| `--downloads` | `1` | Number of tracks downloaded concurrently. |
| `--no-cache` | off | Bypass the title cache. |
| `--reset-cache` | off | Clear the title cache. |
+1
View File
@@ -462,6 +462,7 @@ Two related flags:
| Flag | Purpose |
| --- | --- |
| `--workers N` | Threads used per track for segment downloads. Default depends on the downloader. |
| `--adaptive-workers` | Opt-in: start with a moderate per-track worker count and ramp it up or back off based on measured CDN throughput and errors, capped at `--workers`. Off by default (fixed worker count). |
| `--downloads N` | Number of tracks downloaded concurrently. Default `1`. |
| `--slow [MIN-MAX]` | Add a delay between titles to look more like a real device. `--slow` alone means 60-120s; `--slow 20-40` sets a custom range. Minimum 20s. |
| `--no-cache` | Bypass the title cache for this download. |
+10 -2
View File
@@ -465,8 +465,16 @@ TLS-fingerprinting and HTTP client settings for the rnet-based session.
| `browser` | str | `"Chrome131"` | Impersonation preset. Must be an exact preset name (e.g. `Chrome131`, `Firefox135`, `Edge101`, `Safari18`, `OkHttp4_12`, `OkHttp5`, `Opera118`); unknown names raise an error. |
| `http1_only` | bool | *(unset)* | Force HTTP/1.1. |
| `http2_only` | bool | *(unset)* | Force HTTP/2. |
| `pool_max_idle_per_host` | int | *(unset)* | Connection-pool tuning. |
| `pool_max_size` | int | *(unset)* | Connection-pool tuning. |
| `connect_timeout` | int | `10` | Connect timeout in seconds. |
| `read_timeout` | int | `30` | Read timeout in seconds (max gap between response chunks). |
| `timeout` | int | *(unset)* | Total request timeout in seconds. |
| `allow_redirects` | bool | *(unset)* | Follow redirects automatically (rnet client default when unset). |
| `pool_idle_timeout` | int | `55` | Seconds an idle pooled connection is kept (default stays under typical ~60s CDN idle kills). |
| `pool_max_idle_per_host` | int | `16` | Max idle connections kept per host. |
| `pool_max_size` | int | *(unset)* | Max total connections in the pool. |
| `tcp_keepalive` | int | `30` | TCP keepalive time in seconds. |
| `tcp_keepalive_interval` | int | *(unset)* | TCP keepalive probe interval in seconds. |
| `tcp_keepalive_retries` | int | *(unset)* | TCP keepalive probe retry count. |
| `tcp_nodelay` | bool | *(unset)* | Disable Nagle's algorithm. |
```yaml
-98
View File
@@ -1,98 +0,0 @@
#!/usr/bin/env python3
"""
Benchmark subtitle conversion backends to (re-)tune the preference ranks in
``unshackle/core/tracks/subtitle_convert.py``.
Runs every backend that can read each input file, converting to a target format (default
SRT), and reports cue count, leaked ASS override tags, and output size — so you can compare
fidelity per (source, target) pair on real files. Read-only: copies inputs to a temp dir.
Usage:
uv run python scripts/bench_subtitle_backends.py <file-or-dir> [<file-or-dir> ...] [--target SRT]
Example:
uv run python scripts/bench_subtitle_backends.py downloads/
"""
from __future__ import annotations
import argparse
import re
import shutil
import tempfile
from pathlib import Path
from unshackle.core.tracks import subtitle_convert as sc
from unshackle.core.tracks.subtitle import Subtitle
Codec = Subtitle.Codec
EXT_TO_CODEC = {
".srt": Codec.SubRip,
".vtt": Codec.WebVTT,
".ass": Codec.SubStationAlphav4,
".ssa": Codec.SubStationAlpha,
".ttml": Codec.TimedTextMarkupLang,
".smi": Codec.SAMI,
".sami": Codec.SAMI,
}
def gather(paths: list[str]) -> list[Path]:
files: list[Path] = []
for p in paths:
path = Path(p)
if path.is_dir():
files.extend(f for f in path.rglob("*") if f.suffix.lower() in EXT_TO_CODEC)
elif path.suffix.lower() in EXT_TO_CODEC:
files.append(path)
return sorted(files)
def metrics(text: str) -> tuple[int, int, int]:
cues = len(re.findall(r"-->", text))
ass_residue = len(re.findall(r"\{\\", text))
return cues, ass_residue, len(text)
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("paths", nargs="+", help="subtitle files or directories")
ap.add_argument("--target", default="SRT", help="target codec value (SRT, VTT, ASS, ...)")
args = ap.parse_args()
target = Codec(args.target.upper())
files = gather(args.paths)
if not files:
print("No subtitle files found.")
return
tmp = Path(tempfile.mkdtemp(prefix="subbench_"))
print(f"{'file':40} {'source':10} {'backend':12} {'ok':3} {'cues':>5} {'resid':>5} {'bytes':>7}")
for f in files:
source = EXT_TO_CODEC[f.suffix.lower()]
if source == target:
continue
for backend in sc.REGISTRY:
if not (backend.is_available() and backend.can_convert(source, target)):
continue
work = tmp / f"{f.stem}.{backend.name}{f.suffix}"
shutil.copy2(f, work)
sub = Subtitle(url="x", language="en", codec=source)
sub.path = work
try:
# Call the backend directly so each row reflects only that backend (no fallback).
out = work.with_suffix(f".{target.value.lower()}")
backend.convert(sub, target, out)
cues, resid, size = metrics(out.read_text("utf8", errors="replace"))
print(
f"{f.name[:40]:40} {source.name[:10]:10} {backend.name:12} {'Y':3} {cues:>5} {resid:>5} {size:>7}"
)
except Exception as e: # noqa: BLE001 - benchmark reports failures, does not raise
print(
f"{f.name[:40]:40} {source.name[:10]:10} {backend.name:12} {'N':3} {'-':>5} {'-':>5} {'-':>7} {type(e).__name__}"
)
if __name__ == "__main__":
main()
+237
View File
@@ -0,0 +1,237 @@
"""Unit tests for the opt-in adaptive worker controller (AIMD hill-climb over segment
concurrency) and the plumbing that carries the flag into the downloader. Pure controller
tests only. No threads, sockets, or real downloads; the clock is supplied explicitly."""
from __future__ import annotations
import inspect
from functools import partial
from pathlib import Path
import pytest
from unshackle.core.downloaders.requests import (TAIL_BOOST_MIN_SEGMENT_SIZE, TAIL_BOOST_PART_SIZE,
AdaptiveWorkerController, _has_range_header, _plan_tail_parts,
_tail_boost_engages, requests)
from unshackle.core.tracks.track import DownloadContext
pytestmark = pytest.mark.unit
TICK = 4.0
WINDOW = 10.0
DT = 0.5
def _fill_window(c: AdaptiveWorkerController, rate: float, now: float) -> float:
"""Record ``WINDOW`` seconds of samples at ``rate`` bytes/sec ending on the 0.5s grid.
Records a sample at ``now`` and every 0.5s up to ``now + WINDOW`` so the oldest kept
sample sits exactly ``WINDOW`` old. That is the condition ``update`` needs to start acting.
"""
c.record_bytes(int(rate * DT), now)
end = now + WINDOW
while now < end - 1e-9:
now += DT
c.record_bytes(int(rate * DT), now)
return now
def _run_tick(c: AdaptiveWorkerController, rate: float, now: float, errors: int = 0) -> tuple[int, float]:
"""Advance one tick recording samples at ``rate``, optionally inject ``errors``, then update."""
end = now + TICK
while now < end - 1e-9:
now += DT
c.record_bytes(int(rate * DT), now)
for _ in range(errors):
c.record_error(now)
return c.update(now), now
def test_starts_at_min_six_cap() -> None:
assert AdaptiveWorkerController(cap=16).target == 6
assert AdaptiveWorkerController(cap=8).target == 6
assert AdaptiveWorkerController(cap=4).target == 4 # min(6, cap)
assert AdaptiveWorkerController(cap=1).target == 2 # clamped up to ADAPTIVE_MIN
def test_no_op_before_full_window() -> None:
c = AdaptiveWorkerController(cap=16, tick=TICK, window=WINDOW)
assert c.update(0.0) == 6 # arms last_tick before any samples are recorded
c.record_bytes(500_000, 0.0)
c.record_bytes(500_000, 5.0)
# a full tick has elapsed, but only 5s of window exists -> hold
assert c.update(5.0) == 6
c.record_bytes(500_000, 8.0)
assert c.update(9.0) == 6
def test_ramps_up_while_speed_improves() -> None:
c = AdaptiveWorkerController(cap=16, tick=TICK, window=WINDOW)
now = 0.0
c.update(now) # arm
rate = 1_000_000.0
now = _fill_window(c, rate, now)
targets = [c.update(now)] # first evaluation: 6 -> 8
for _ in range(6):
rate *= 2 # sustained >10% windowed gain each tick
target, now = _run_tick(c, rate, now)
targets.append(target)
assert targets == sorted(targets) # monotonic non-decreasing
assert targets[-1] == 16 # climbed to the cap
assert max(targets) <= 16
def test_reverts_on_plateau() -> None:
c = AdaptiveWorkerController(cap=16, tick=TICK, window=WINDOW)
now = 0.0
c.update(now)
rate = 1_000_000.0
now = _fill_window(c, rate, now)
assert c.update(now) == 8 # first increase 6 -> 8
# hold the rate flat: <10% gain -> the increase is reverted and held
target, now = _run_tick(c, rate, now)
assert target == 6
def test_halves_and_cooldown_on_error_burst() -> None:
c = AdaptiveWorkerController(cap=16, tick=TICK, window=WINDOW)
now = 0.0
c.update(now)
now = _fill_window(c, 1_000_000.0, now)
for _ in range(3):
c.record_error(now)
assert c.update(now) == 3 # 3 errors -> multiplicative decrease 6 // 2
# cooldown tick holds even with improving throughput
target, now = _run_tick(c, 2_000_000.0, now)
assert target == 3
# cooldown consumed -> climbing resumes
target, now = _run_tick(c, 4_000_000.0, now)
assert target == 5
def test_clamps_to_bounds_and_never_exceeds_cap() -> None:
c = AdaptiveWorkerController(cap=4, tick=TICK, window=WINDOW)
assert c.target == 4 # already at cap (min(6, 4))
now = 0.0
c.update(now)
rate = 1_000_000.0
now = _fill_window(c, rate, now)
seen = [c.update(now)]
for _ in range(10):
rate *= 2
target, now = _run_tick(c, rate, now)
seen.append(target)
assert all(2 <= s <= 4 for s in seen)
assert max(seen) == 4 # never exceeds the cap
def test_error_burst_floors_at_min() -> None:
c = AdaptiveWorkerController(cap=16, start=2, tick=TICK, window=WINDOW)
now = 0.0
c.update(now)
now = _fill_window(c, 1_000_000.0, now)
for _ in range(5):
c.record_error(now)
assert c.update(now) == 2 # max(ADAPTIVE_MIN, 2 // 2)
def test_tail_guard_suppresses_increase() -> None:
# with fewer units of work than the target, the tick must hold instead of probing up
c = AdaptiveWorkerController(cap=16, tick=TICK, window=WINDOW)
now = 0.0
c.update(now) # arm at target 6
now = _fill_window(c, 1_000_000.0, now)
assert c.update(now, 3) == 6 # 3 < target 6 -> holds at 6 rather than climbing to 8
def test_tail_guard_none_path_unchanged() -> None:
# without a tail count, the update still probes upward on the first evaluation
c = AdaptiveWorkerController(cap=16, tick=TICK, window=WINDOW)
now = 0.0
c.update(now)
now = _fill_window(c, 1_000_000.0, now)
assert c.update(now) == 8 # unchanged: first evaluation 6 -> 8
def test_tail_guard_still_halves_on_error_burst() -> None:
# the guard suppresses growth but never blocks an error-burst decrease
c = AdaptiveWorkerController(cap=16, tick=TICK, window=WINDOW)
now = 0.0
c.update(now)
now = _fill_window(c, 1_000_000.0, now)
for _ in range(3):
c.record_error(now)
assert c.update(now, 2) == 3 # guard active (2 < 6) but 3 errors -> 6 // 2
def test_plan_tail_parts_full_coverage_no_gaps() -> None:
size = 20 * 1024 * 1024
parts = _plan_tail_parts(size, spare=8)
assert parts, "expected a split for a large segment with spare capacity"
assert parts[0][0] == 0
assert parts[-1][1] == size - 1
for (_, prev_end), (start, _) in zip(parts, parts[1:]):
assert start == prev_end + 1 # each part begins exactly where the previous one ended
covered = sum(end - start + 1 for start, end in parts)
assert covered == size
def test_plan_tail_parts_respects_spare_cap() -> None:
size = 100 * 1024 * 1024 # would want ~25 parts at 4MB, but spare caps it
parts = _plan_tail_parts(size, spare=3)
assert len(parts) <= 3
assert parts[0][0] == 0 and parts[-1][1] == size - 1
def test_plan_tail_parts_part_size_bounded() -> None:
size = 40 * 1024 * 1024
parts = _plan_tail_parts(size, spare=16)
# never more parts than ~ceil(size / TAIL_BOOST_PART_SIZE)
assert len(parts) <= -(-size // TAIL_BOOST_PART_SIZE)
def test_plan_tail_parts_skips_small_or_no_spare() -> None:
assert _plan_tail_parts(TAIL_BOOST_MIN_SEGMENT_SIZE - 1, spare=8) == [] # too small
assert _plan_tail_parts(20 * 1024 * 1024, spare=1) == [] # not enough spare workers
assert _plan_tail_parts(20 * 1024 * 1024, spare=0) == []
def test_tail_boost_engages_on_idle_capacity() -> None:
# engages at the tail: idle workers outnumber the remaining whole segments
assert _tail_boost_engages(remaining=4, pending=2, target=8) # spare 6 >= 4
assert _tail_boost_engages(remaining=6, pending=6, target=16) # spare 10 >= 6
def test_tail_boost_engages_when_remaining_is_a_full_stride() -> None:
# remaining sits at 6 (one worker stride) while the target has climbed to 16, so 14 workers
# are idle. idle capacity far exceeds the remaining segments, so the boost must engage.
assert _tail_boost_engages(remaining=6, pending=2, target=16)
def test_tail_boost_does_not_engage_early_or_saturated() -> None:
assert not _tail_boost_engages(remaining=54, pending=2, target=6) # early: 54 > spare 4
assert not _tail_boost_engages(remaining=1, pending=8, target=8) # saturated: spare 0
assert not _tail_boost_engages(remaining=0, pending=2, target=8) # nothing left
assert not _tail_boost_engages(remaining=5, pending=10, target=8) # over-subscribed: spare < 0
def test_has_range_header_detects_byte_range_slices() -> None:
# items carrying a Range header are byte-range slices; ranged-parallel must skip them
assert _has_range_header({"url": "u", "headers": {"Range": "bytes=0-99"}})
assert _has_range_header({"url": "u", "headers": {"range": "bytes=0-99"}}) # case-insensitive
assert not _has_range_header({"url": "u", "headers": {"Authorization": "Bearer x"}})
assert not _has_range_header({"url": "u", "headers": {}})
assert not _has_range_header({"url": "u"})
def test_download_context_adaptive_default_false() -> None:
ctx = DownloadContext(save_path=Path("x.mp4"), save_dir=Path("."), progress=partial(lambda **_: None))
assert ctx.adaptive_workers is False
def test_requests_accepts_adaptive_kwarg() -> None:
sig = inspect.signature(requests)
assert "adaptive" in sig.parameters
assert sig.parameters["adaptive"].default is False
+229
View File
@@ -0,0 +1,229 @@
"""Regression tests for downloader resume/completeness robustness.
Each test drives the real ``download()`` generator against a local HTTP server
that injects a specific fault the fault-injection benchmark surfaced:
- truncated segment (clean FIN mid-body) must not finalize as complete
- an interrupted download must resume from the bytes actually written, not a
pre-allocated full size
- an oversized/stale resume that provokes a 416 must restart clean
- byte-range-slice segments (DASH SegmentBase / HLS EXT-X-BYTERANGE style) must
never be range-probed or tail-boosted: that would clobber their Range header
and fetch the whole resource into the segment file
All localhost + small payloads, so they run in the default (not live) suite.
"""
from __future__ import annotations
import importlib
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Optional
import pytest
from requests import Session
# the downloaders package re-exports a `requests` function that shadows this submodule
# attribute, so fetch the real module object from sys.modules via importlib
dl = importlib.import_module("unshackle.core.downloaders.requests")
def _consume(gen) -> list:
return list(gen)
class _FaultServer:
"""Serves a fixed body with per-request fault injection and Range support."""
def __init__(self, body: bytes) -> None:
self.body = body
self.attempts = 0 # GETs seen for the single resource
self.ranges: list[Optional[str]] = [] # Range header per GET, in order
self.truncate_first = 0 # send only half the body + close, for the first N attempts
self._lock = threading.Lock()
server = self
class Handler(BaseHTTPRequestHandler):
def log_message(self, *_a: object) -> None: # silence
pass
def do_GET(self) -> None: # noqa: N802 (stdlib name)
with server._lock:
server.attempts += 1
attempt = server.attempts
rng = self.headers.get("Range")
server.ranges.append(rng)
total = len(server.body)
start = 0
if rng and rng.startswith("bytes="):
start = int(rng.removeprefix("bytes=").split("-", 1)[0])
if start >= total: # unsatisfiable: real CDNs answer 416
self.send_response(416)
self.send_header("Content-Range", f"bytes */{total}")
self.end_headers()
return
chunk = server.body[start:]
partial = attempt <= server.truncate_first
declared = len(chunk) # honest Content-Length...
sent = chunk[: declared // 2] if partial else chunk # ...but under-deliver the body
if rng:
self.send_response(206)
self.send_header("Content-Range", f"bytes {start}-{total - 1}/{total}")
else:
self.send_response(200)
self.send_header("Accept-Ranges", "bytes")
self.send_header("Content-Length", str(declared))
self.end_headers()
self.wfile.write(sent) # short write + return => connection closes (clean FIN)
self._httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
self._thread = threading.Thread(target=self._httpd.serve_forever, daemon=True)
def __enter__(self) -> "_FaultServer":
self._thread.start()
return self
def __exit__(self, *_exc: object) -> None:
self._httpd.shutdown()
self._httpd.server_close()
@property
def url(self) -> str:
host, port = self._httpd.server_address
return f"http://{host}:{port}/file"
@pytest.fixture(autouse=True)
def _fast_retries(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(dl, "RETRY_WAIT", 0)
def test_truncated_segment_is_not_finalized(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""A segment cut short mid-body must raise, never finalize a truncated file."""
monkeypatch.setattr(dl, "MAX_ATTEMPTS", 2)
body = b"A" * 2000
save = tmp_path / "seg.bin"
with _FaultServer(body) as srv:
srv.truncate_first = 99 # every attempt under-delivers
with pytest.raises(Exception):
_consume(dl.download(url=srv.url, save_path=save, session=Session(), segmented=True))
assert not save.exists(), "truncated segment must not be finalized as complete"
def test_interrupted_download_resumes_from_bytes_written(tmp_path: Path) -> None:
"""After a mid-body interruption the retry must Range-resume from the real
byte count (not a pre-allocated full size) and produce the correct file."""
body = b"".join(bytes([i % 256]) for i in range(4000))
save = tmp_path / "file.bin"
with _FaultServer(body) as srv:
srv.truncate_first = 1 # first attempt delivers half then closes; retry completes
_consume(dl.download(url=srv.url, save_path=save, session=Session()))
assert save.read_bytes() == body
# attempt 1 had no Range; attempt 2 must resume partway in, not from 0 or past the end
assert srv.ranges[0] is None
resume_start = int(srv.ranges[1].removeprefix("bytes=").split("-", 1)[0])
assert 0 < resume_start < len(body), f"resume offset {resume_start} not derived from bytes written"
def test_resumes_from_preexisting_dev_partial(tmp_path: Path) -> None:
"""A .!dev left on disk by a killed process (Ctrl+C, outage, crash) must resume on the
next run via a Range request from the partial's size, not restart from zero."""
body = b"".join(bytes([i % 256]) for i in range(4000))
save = tmp_path / "file.bin"
tmp = save.with_name("file.bin.!dev")
tmp.write_bytes(body[:1500]) # correct prefix the interrupted run flushed to disk
with _FaultServer(body) as srv:
_consume(dl.download(url=srv.url, save_path=save, session=Session()))
assert save.read_bytes() == body
assert not tmp.exists()
assert srv.ranges[0] == "bytes=1500-", f"expected resume from 1500, got {srv.ranges[0]}"
def test_byte_range_slice_segments_never_probed_or_boosted(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Adaptive tail boost must skip segments whose per-item headers carry a Range.
Such segments are byte-range slices of one larger resource; the boost probe and
part-mode download would overwrite that Range and pull the entire resource into
the segment file. A server-side barrier clusters the first six completions so the
main loop reliably sees spare workers while the two tail slices are still queued,
the exact window where an unguarded boost would corrupt output.
"""
seg = 2 * 1024 * 1024
nseg = 8
body = b"".join(bytes([i]) * seg for i in range(nseg)) # 16 MiB, distinct byte per slice
barrier_count = [0]
barrier_lock = threading.Lock()
barrier = threading.Event()
class Handler(BaseHTTPRequestHandler):
def log_message(self, *_a: object) -> None:
pass
def do_GET(self) -> None: # noqa: N802 (stdlib name)
rng = self.headers.get("Range") or ""
with barrier_lock:
barrier_count[0] += 1
if barrier_count[0] >= 6:
barrier.set()
barrier.wait(5) # cluster the first six completions; auto-release as a safety net
start, _, end_s = rng.removeprefix("bytes=").partition("-")
start = int(start or 0)
end = int(end_s) if end_s else len(body) - 1
chunk = body[start : end + 1]
self.send_response(206 if rng else 200)
if rng:
self.send_header("Content-Range", f"bytes {start}-{end}/{len(body)}")
self.send_header("Accept-Ranges", "bytes")
self.send_header("Content-Length", str(len(chunk)))
self.end_headers()
self.wfile.write(chunk)
httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
host, port = httpd.server_address
probed: list[str] = []
real_probe = dl._probe_ranged
monkeypatch.setattr(dl, "_probe_ranged", lambda url, session, **kw: probed.append(url) or real_probe(url, session, **kw))
url = f"http://{host}:{port}/media.mp4"
urls = [{"url": url, "headers": {"Range": f"bytes={i * seg}-{(i + 1) * seg - 1}"}} for i in range(nseg)]
try:
_consume(
dl.requests(
urls=urls,
output_dir=tmp_path,
filename="seg_{i:02}{ext}",
session=Session(),
max_workers=16,
adaptive=True,
)
)
finally:
httpd.shutdown()
httpd.server_close()
assert probed == [], "byte-range slice segments must never be range-probed"
for i in range(nseg):
data = (tmp_path / f"seg_{i:02}.mp4").read_bytes()
assert data == body[i * seg : (i + 1) * seg], f"segment {i} corrupted: got {len(data)} bytes"
def test_oversized_resume_416_restarts_clean(tmp_path: Path) -> None:
"""A stale .!dev larger than the resource provokes a 416; the downloader must
discard it and re-download cleanly rather than burn retries or corrupt."""
body = b"Z" * 1500
save = tmp_path / "file.bin"
tmp = save.with_name(f"{save.name}.!dev")
tmp.write_bytes(b"X" * 4096) # poisoned partial, bigger than the server's body
with _FaultServer(body) as srv:
_consume(dl.download(url=srv.url, save_path=save, session=Session()))
assert save.read_bytes() == body
assert srv.ranges[0] is not None and "4096" in srv.ranges[0] # first request over-ranged → 416
+1
View File
@@ -32,6 +32,7 @@ def test_api_error_default_http_status_per_code() -> None:
APIErrorCode.JOB_NOT_FOUND: 404,
APIErrorCode.SESSION_NOT_FOUND: 404,
APIErrorCode.TRACK_NOT_FOUND: 404,
APIErrorCode.CONFLICT: 409,
APIErrorCode.RATE_LIMITED: 429,
APIErrorCode.INTERNAL_ERROR: 500,
APIErrorCode.DOWNLOAD_ERROR: 500,
+12
View File
@@ -665,6 +665,15 @@ class dl:
default=None,
help="Max workers/threads to download with per-track. Default depends on the downloader.",
)
@click.option(
"--adaptive-workers",
is_flag=True,
default=False,
help=(
"Dynamically scale per-track segment workers up to the --workers cap (or the built-in cap "
"when --workers is unset) based on measured CDN throughput and errors. Off by default."
),
)
@click.option("--downloads", type=int, default=1, help="Amount of tracks to download concurrently.")
@click.option(
"-o",
@@ -1201,6 +1210,7 @@ class dl:
no_source: bool,
no_mux: bool,
workers: Optional[int],
adaptive_workers: bool,
downloads: int,
worst: bool,
best_available: bool,
@@ -1741,6 +1751,7 @@ class dl:
),
cdm=self.cdm,
max_workers=workers,
adaptive_workers=adaptive_workers,
progress=progress_call,
)
for song, track, progress_call in music_items
@@ -2850,6 +2861,7 @@ class dl:
),
cdm=self.cdm,
max_workers=workers,
adaptive_workers=adaptive_workers,
progress=tracks_progress_callables[i],
)
# DRM-free and HLS-ClearKey tracks never reach prepare_drm, so export here.
+447 -30
View File
@@ -3,6 +3,7 @@ import os
import statistics
import threading
import time
from collections import deque
from concurrent.futures import FIRST_COMPLETED, wait
from concurrent.futures.thread import ThreadPoolExecutor
from http.cookiejar import CookieJar
@@ -38,6 +39,13 @@ SPEED_ROLLING_WINDOW = 10 # seconds of history to keep for speed calculation
RANGE_PARALLEL_MIN_SIZE = 64 * 1024 * 1024
RANGE_PARALLEL_PART_SIZE = 16 * 1024 * 1024
# Tail-end boost (opt-in, adaptive only): when only a few segments remain and workers
# would otherwise idle, split each remaining segment into intra-segment range parts so
# aggregate throughput holds to the end instead of collapsing to (few)x(per-connection).
TAIL_BOOST_MAX_PER_CYCLE = 4 # cap on segments probed/split per drain cycle so blocking probes can't stall the loop
TAIL_BOOST_MIN_SEGMENT_SIZE = 8 * 1024 * 1024 # don't probe+split segments smaller than this
TAIL_BOOST_PART_SIZE = 4 * 1024 * 1024 # target size per tail part (smaller than the whole-file part size)
def _adaptive_chunk_size(content_length: int) -> int:
"""Pick chunk size based on content length. Benchmarked sweet spot: 512KB-4MB."""
@@ -46,6 +54,140 @@ def _adaptive_chunk_size(content_length: int) -> int:
return min(MAX_CHUNK, max(MIN_CHUNK, content_length // 4))
# Adaptive worker controller (opt-in): AIMD hill-climb over segment concurrency
ADAPTIVE_TICK = 4.0 # seconds between target re-evaluations
ADAPTIVE_START = 6 # initial worker target, clamped to cap
ADAPTIVE_STEP = 2 # additive increase per tick
ADAPTIVE_MIN = 2 # never drop below this many workers
ADAPTIVE_ERROR_BURST = 3 # errors within a tick that trigger multiplicative decrease
ADAPTIVE_PLATEAU_GAIN = 1.10 # min speed ratio to justify keeping an increase
class AdaptiveWorkerController:
"""CDN-aware segment concurrency governor.
Pure and synchronous (no threads or sockets), so the AIMD policy is unit-testable.
``update`` is evaluated once per ``tick`` and only after a full ``window`` of
throughput samples exists: it ramps the worker target up while the CDN keeps
rewarding extra concurrency, reverts the last increase on a plateau, and halves
(with a one-tick cooldown) on an error burst. Target is always clamped to
``[ADAPTIVE_MIN, cap]``.
"""
def __init__(
self, cap: int, start: Optional[int] = None, tick: float = ADAPTIVE_TICK, window: float = SPEED_ROLLING_WINDOW
) -> None:
self.cap = max(ADAPTIVE_MIN, cap)
base = ADAPTIVE_START if start is None else start
self.target = max(ADAPTIVE_MIN, min(base, self.cap))
self.tick = tick
self.window = window
self._samples: deque[tuple[float, int]] = deque()
self._first_sample: Optional[float] = None # first-ever sample time (survives pruning)
self._errors = 0 # errors observed since the last tick evaluation
self._last_tick: Optional[float] = None
self._last_action: Optional[str] = None # "increase" once we have probed upward
self._speed_before_increase = 0.0
self._cooldown = False
def _prune(self, now: float) -> None:
cutoff = now - self.window
while self._samples and self._samples[0][0] < cutoff:
self._samples.popleft()
def record_bytes(self, n: int, now: float) -> None:
if self._first_sample is None:
self._first_sample = now
self._samples.append((now, n))
self._prune(now)
def record_error(self, now: float) -> None:
self._errors += 1
def speed(self, now: float) -> float:
"""Rolling bytes/sec over the window."""
self._prune(now)
if not self._samples:
return 0.0
span = now - self._samples[0][0]
if span <= 0:
return 0.0
return sum(n for _, n in self._samples) / span
def _full_window(self, now: float) -> bool:
# true once we have been observing for at least a full window; measured from the
# first-ever sample so continuous pruning (which keeps span just under window) does
# not perpetually starve the controller of a "full" window in real timing
return self._first_sample is not None and (now - self._first_sample) >= self.window
def update(self, now: float, inflight_plus_remaining: Optional[int] = None) -> int:
"""Evaluate the policy at most once per tick; return the current target.
``inflight_plus_remaining`` (when given) is the count of segments still in flight or
not yet started. If it is below the target there is not enough work to saturate the
target, so the low measured speed reflects starvation rather than a plateau, and the
probe/revert step is skipped this tick. Error-burst decreases still apply. Default ``None`` keeps the
original behaviour for existing callers.
"""
if self._last_tick is None:
self._last_tick = now
return self.target
if now - self._last_tick < self.tick:
return self.target
self._last_tick = now
# hold until a full window of throughput history has accumulated
if not self._full_window(now):
self._errors = 0
return self.target
errors = self._errors
self._errors = 0
speed_now = self.speed(now)
old = self.target
if self._cooldown:
# one tick of quiet after a decrease before probing again
self._cooldown = False
self._last_action = None
return self.target
reason: Optional[str] = None
if errors >= ADAPTIVE_ERROR_BURST:
self.target = max(ADAPTIVE_MIN, self.target // 2)
self._cooldown = True
self._last_action = None
reason = "error_burst"
elif inflight_plus_remaining is not None and inflight_plus_remaining < self.target:
# tail guard: too little work to saturate the target, so a low measured speed
# here reflects starvation rather than a plateau, so hold and skip the probe/revert this tick
pass
elif self._last_action == "increase" and speed_now < ADAPTIVE_PLATEAU_GAIN * self._speed_before_increase:
# last probe upward did not pay off, so revert it and hold
self.target = max(ADAPTIVE_MIN, self.target - ADAPTIVE_STEP)
self._last_action = None
reason = "plateau"
elif self.target < self.cap:
self._speed_before_increase = speed_now
self.target = min(self.cap, self.target + ADAPTIVE_STEP)
self._last_action = "increase"
reason = "increase"
else:
self._last_action = None
self.target = max(ADAPTIVE_MIN, min(self.cap, self.target))
if self.target != old:
debug_logger = get_debug_logger()
if debug_logger:
debug_logger.log(
level="DEBUG",
operation="adaptive_workers",
message=f"worker target {old} -> {self.target}",
context={"old": old, "new": self.target, "speed_bps": round(speed_now), "reason": reason},
)
return self.target
def _is_requests_session(session: Any) -> bool:
"""Check if the session is a standard requests.Session (supports resp.raw)."""
return isinstance(session, Session)
@@ -63,6 +205,8 @@ def _probe_ranged(url: str, session: Any, **kwargs: Any) -> tuple[int, bool]:
rest = {k: v for k, v in kwargs.items() if k != "headers"}
if _is_rnet_session(session):
rest.setdefault("read_timeout", READ_TIMEOUT)
# let download() own segment retries, with no nested session-level retry loop
rest.setdefault("max_retries", 0)
else:
rest.setdefault("timeout", (CONNECT_TIMEOUT, READ_TIMEOUT))
try:
@@ -85,6 +229,48 @@ def _probe_ranged(url: str, session: Any, **kwargs: Any) -> tuple[int, bool]:
pass
def _has_range_header(item: dict[str, Any]) -> bool:
"""True when a URL item's per-request headers already carry a Range.
Such an item is itself a byte-range slice of a larger resource (DASH SegmentBase,
HLS EXT-X-BYTERANGE); probing or part-mode would clobber that Range and fetch the
wrong bytes, so ranged-parallel strategies must skip these items entirely.
"""
return any(k.lower() == "range" for k in (item.get("headers") or {}))
def _plan_tail_parts(size: int, spare: int) -> list[tuple[int, int]]:
"""Plan intra-segment range parts for a tail segment.
Returns inclusive ``[start, end]`` byte ranges covering ``[0, size)`` with no gaps or
overlaps, split into at most ``spare`` parts of ~``TAIL_BOOST_PART_SIZE``. Returns an
empty list when the segment is smaller than ``TAIL_BOOST_MIN_SEGMENT_SIZE`` (not worth a
probe+split) or there are fewer than two spare workers. Pure, with no I/O, so testable.
"""
if size < TAIL_BOOST_MIN_SEGMENT_SIZE or spare < 2:
return []
n_parts = max(1, min(spare, math.ceil(size / TAIL_BOOST_PART_SIZE)))
part_size = math.ceil(size / n_parts)
return [
(s, e)
for i in range(n_parts)
for s, e in [(i * part_size, min(size - 1, (i + 1) * part_size - 1))]
if s <= e
]
def _tail_boost_engages(remaining: int, pending: int, target: int) -> bool:
"""True when idle workers outnumber the remaining whole segments.
That is the tail condition where running one worker per remaining segment would leave
workers idle, so splitting those segments into range parts keeps throughput up. Gating on
idle capacity (not a fixed segment count) is what lets the boost fire despite ``remaining``
draining in strides of the worker target. Pure, so the gate is unit-testable.
"""
spare = target - pending
return remaining > 0 and spare >= 2 and remaining <= spare
def _dispatch_parts(
url: str,
save_path: Path,
@@ -110,6 +296,9 @@ def _dispatch_parts(
f.truncate(total_size)
events: Queue[dict[str, Any]] = Queue()
# local to this ranged download: a failed part must not poison the process-global
# DOWNLOAD_CANCELLED (which would kill the sequential fallback and sibling tracks)
abort = threading.Event()
def _worker(start: int, end: int) -> None:
for ev in download(
@@ -118,6 +307,7 @@ def _dispatch_parts(
session=session,
part_offset=start,
part_end=end,
abort=abort,
**kwargs,
):
events.put(ev)
@@ -158,7 +348,7 @@ def _dispatch_parts(
exc = fut.exception()
if exc:
worker_error = True
DOWNLOAD_CANCELLED.set()
abort.set()
raise exc
advance = 0
@@ -194,6 +384,8 @@ def download(
part_offset: Optional[int] = None,
part_end: Optional[int] = None,
claimed: Optional[Callable[[], bool]] = None,
abort: Optional[threading.Event] = None,
on_retry: Optional[Callable[[Exception], None]] = None,
**kwargs: Any,
) -> Generator[dict[str, Any], None, None]:
"""
@@ -224,6 +416,12 @@ def download(
part_end: Inclusive end byte of the part. Required when `part_offset` is set.
claimed: Optional predicate checked before every attempt; when it returns True
the download stops silently (another worker already delivered this file).
abort: Optional local cancel signal. When set, the download stops like a cancel
(keeps its partial, returns silently, does not raise). Used by ranged-parallel
downloads to abort sibling parts without touching the process-global cancel.
on_retry: Optional callback invoked with the raised exception before each retry
wait (not on cancel or final exhaustion). Used to feed CDN error signals to
the adaptive worker controller. Callback errors are swallowed.
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,
to request Byte-ranges use e.g., `headers={"Range": "bytes=0-128"}`.
@@ -268,6 +466,8 @@ def download(
request_kwargs = dict(kwargs)
if use_rnet:
request_kwargs.setdefault("read_timeout", READ_TIMEOUT)
# own segment retries here, suppressing RnetSession's nested retry loop
request_kwargs.setdefault("max_retries", 0)
else:
request_kwargs.setdefault("timeout", (CONNECT_TIMEOUT, READ_TIMEOUT))
if part_mode:
@@ -280,6 +480,19 @@ def download(
request_kwargs["headers"] = req_headers
stream = session.get(url, stream=True, **request_kwargs)
if (not part_mode) and resume_offset > 0 and stream.status_code == 416:
# our Range started past the end (a stale or oversized .!dev from a prior run).
# discard it and restart clean rather than raise_for_status → burn retries.
try:
stream.close()
except Exception:
pass
tmp_file.unlink(missing_ok=True)
resume_offset = 0
written = 0
continue
stream.raise_for_status()
resumed = (not part_mode) and resume_offset > 0 and stream.status_code == 206
@@ -314,12 +527,14 @@ def download(
else:
file_mode = "ab" if resumed else "wb"
file_buffering = 1_048_576
# non-part downloads write sequentially and must NOT pre-allocate: the resume
# offset is derived from the tmp file's size, so preallocating to content_length
# would make an interrupted write look fully downloaded and poison the resume
# (unsatisfiable Range on retry, silent zero-padded corruption). part_mode still
# pre-truncates in _dispatch_parts because its workers seek to fixed offsets.
with open(save_path if part_mode else tmp_file, file_mode, buffering=file_buffering) as f:
if part_mode:
f.seek(part_offset + written)
elif not resumed and content_length > 0:
f.truncate(content_length)
f.seek(0)
_write = f.write
@@ -334,7 +549,7 @@ def download(
_bytes_since_yield = 0
emit_progress = (not segmented) or part_mode
for chunk in chunks:
if DOWNLOAD_CANCELLED.is_set():
if DOWNLOAD_CANCELLED.is_set() or (abort is not None and abort.is_set()):
break
_write(chunk)
download_size = len(chunk)
@@ -362,18 +577,21 @@ def download(
except Exception:
pass
if not part_mode and not resumed and content_length > 0 and written != content_length:
f.truncate(written)
if DOWNLOAD_CANCELLED.is_set() and (not content_length or written < content_length):
# cancelled mid-stream: keep the partial .!dev for resume, never finalize
aborted = abort is not None and abort.is_set()
if (DOWNLOAD_CANCELLED.is_set() or aborted) and (not content_length or written < content_length):
# cancelled/aborted mid-stream: keep the partial for resume, never finalize,
# and skip the size checks below (a short read here is expected, not an error)
return
if part_mode:
expected = part_end - part_offset + 1
if written < expected:
raise IOError(f"Failed to read part {part_offset}-{part_end}: got {written}/{expected}")
elif not segmented and content_length and written < content_length:
elif content_length and written < content_length:
# applies to segments too: a connection FIN'd mid-body is a clean EOF, so the
# chunk loop ends without raising, so without this check a truncated segment would
# finalize as complete and corrupt the merge. content_length==0 (chunked/gzip)
# is unknown-length and correctly skips the check.
raise IOError(f"Failed to read {content_length} bytes from the track URI.")
if not part_mode:
@@ -382,15 +600,26 @@ def download(
if segmented:
yield dict(advance=1)
break
except Exception:
except Exception as exc:
try:
stream.close()
except Exception:
pass
if DOWNLOAD_CANCELLED.is_set() or attempts == MAX_ATTEMPTS:
if part_mode and not DOWNLOAD_CANCELLED.is_set():
cancelled = DOWNLOAD_CANCELLED.is_set() or (abort is not None and abort.is_set())
if cancelled or attempts == MAX_ATTEMPTS:
# a hedged loser whose file was already delivered must not kill the batch
if claimed is not None and claimed():
return
# cancel/abort is not an error, but a genuine retry-exhaustion is, so surface it
# so the caller fails fast instead of hitting a missing segment at merge time
if not cancelled:
raise
return
if on_retry is not None:
try:
on_retry(exc)
except Exception:
pass
if not part_mode:
resume_offset = tmp_file.stat().st_size if tmp_file.exists() else 0
time.sleep(RETRY_WAIT)
@@ -406,6 +635,7 @@ def requests(
proxy: Optional[str] = None,
max_workers: Optional[int] = None,
session: Optional[Any] = None,
adaptive: bool = False,
) -> Generator[dict[str, Any], None, None]:
"""
Download files with optimized I/O and adaptive chunk sizing.
@@ -441,6 +671,11 @@ def requests(
session: An optional requests.Session or RnetSession to use. If provided,
it will be used directly (preserving TLS fingerprinting). If None, a new
requests.Session with HTTPAdapter connection pooling will be created.
adaptive: Opt-in CDN-aware dynamic segment concurrency for segmented (multi-URL)
downloads. When True the per-track worker count starts moderate and ramps up
or backs off based on measured throughput and CDN errors, capped at
max_workers. When False (default) all segments are submitted upfront using a
fixed worker count, matching the non-adaptive behaviour exactly.
"""
if not urls:
raise ValueError("urls must be provided and not empty")
@@ -522,6 +757,7 @@ def requests(
"max_workers": max_workers,
"has_proxy": bool(proxy),
"session_type": type(session).__name__,
"adaptive": adaptive,
},
)
@@ -531,7 +767,7 @@ def requests(
url_item = urls[0]
try:
ranged_used = False
if max_workers > 1:
if max_workers > 1 and not _has_range_header(url_item):
total_size, supports_ranges = _probe_ranged(url_item["url"], session)
if supports_ranges and total_size >= RANGE_PARALLEL_MIN_SIZE:
try:
@@ -581,6 +817,10 @@ def requests(
seg_durations: list[float] = []
hedged: set[int] = set()
controller = AdaptiveWorkerController(cap=max_workers) if adaptive else None
# feed CDN error signals (429/timeout/reset) from each retrying segment into the controller
on_retry_cb = (lambda _exc: controller.record_error(time.time())) if controller else None
def _download_worker(index: int, url_item: dict[str, Any], hedge: bool = False) -> None:
item = dict(url_item)
save_path = item.pop("save_path")
@@ -596,12 +836,16 @@ def requests(
# the .!dev suffix keeps strays visible to the manifest parsers' cleanup
target = save_path.with_name(f"{save_path.name}.{'h' if hedge else 'p'}.!dev")
if not hedge:
seg_start[index] = time.time()
# under seg_lock: the hedge scan iterates seg_start.items() on the main
# thread, and a first-time key insert during that iteration raises
with seg_lock:
seg_start[index] = time.time()
for event in download(
session=session,
segmented=segmented_batch,
save_path=target,
claimed=lambda: index in seg_done,
on_retry=on_retry_cb,
**item,
):
if "file_downloaded" in event:
@@ -616,13 +860,159 @@ def requests(
event = dict(event, file_downloaded=save_path)
event_queue.put(event)
futures = [pool.submit(_download_worker, i, url) for i, url in enumerate(urls)]
pending = set(futures)
# not-yet-submitted segments; adaptive mode meters submission against the controller
# target, while fixed mode (adaptive=False) submits every segment upfront
remaining: deque[tuple[int, dict[str, Any]]] = deque(enumerate(urls))
pending: set = set()
# tail boost bookkeeping (adaptive only): indices whose parts are in flight vs
# indices probed and left for the normal single-worker path, each decided once
tail_boosted: set[int] = set()
tail_skipped: set[int] = set()
def _submit(count: int, only: Optional[set[int]] = None) -> None:
# only=<set> submits just those indices (used in the tail window to release
# segments the boost declined while holding back boost candidates); with no
# `only`, submits the leading `count` segments in FIFO order.
if count <= 0 or not remaining:
return
picked: list[tuple[int, dict[str, Any]]] = []
for item in remaining:
if count <= 0:
break
if only is not None and item[0] not in only:
continue
pending.add(pool.submit(_download_worker, item[0], item[1]))
picked.append(item)
count -= 1
for item in picked:
remaining.remove(item)
def _tail_part_worker(
index: int,
url: str,
part_target: Path,
save_path: Path,
start: int,
end: int,
size: int,
abort: threading.Event,
parts_left: list[int],
part_lock: threading.Lock,
req_kwargs: dict[str, Any],
) -> None:
try:
# part_mode download writes [start, end] into the pre-truncated target and
# emits only byte-`advance`; swallow those, since tail segments report at segment
# granularity (advance=1) like every other segment, once on finalize below
for _ev in download(
url=url,
save_path=part_target,
session=session,
part_offset=start,
part_end=end,
abort=abort,
on_retry=on_retry_cb,
**req_kwargs,
):
pass
except BaseException:
abort.set() # stop sibling parts of this segment; a real failure fails the batch
raise
with part_lock:
parts_left[0] -= 1
finalize = parts_left[0] == 0
if not finalize:
return
if DOWNLOAD_CANCELLED.is_set() or abort.is_set():
return # cancelled/aborted: keep the partial target for the stray sweep, don't finalize
with seg_lock:
if index in seg_done:
part_target.unlink(missing_ok=True)
return
seg_done.add(index)
seg_durations.append(time.time() - seg_start[index])
os.replace(part_target, save_path)
event_queue.put(dict(file_downloaded=save_path, written=size))
event_queue.put(dict(advance=1))
def _maybe_tail_boost(target: int) -> None:
# Tail boost: near the end, split the few not-yet-started segments across idle
# workers with intra-segment range parts so aggregate throughput doesn't collapse
# to (few segments)x(per-connection speed). Only not-yet-started segments in
# `remaining` are boosted (never mid-flight ones) to avoid entangling the main
# loop; gated behind adaptive, though it could be decoupled from the controller.
# Engage on idle capacity so the boost fires despite `remaining` draining in strides
# of the worker target; probe at most TAIL_BOOST_MAX_PER_CYCLE per cycle so a run of
# blocking range probes can't stall the drain loop.
if DOWNLOAD_CANCELLED.is_set() or not _tail_boost_engages(len(remaining), len(pending), target):
return
boosted = 0
for index, url_item in list(remaining):
if boosted >= TAIL_BOOST_MAX_PER_CYCLE:
break
spare = target - len(pending)
if spare < 2:
break
if index in tail_boosted or index in tail_skipped:
continue
item = dict(url_item)
save_path = item.pop("save_path")
if save_path.exists(): # finished in a previous run; let the normal fast-path handle it
tail_skipped.add(index)
continue
if _has_range_header(item): # byte-range slice; part-mode would clobber its Range
tail_skipped.add(index)
continue
req_kwargs = {k: v for k, v in item.items() if k != "url"}
size, supports_ranges = _probe_ranged(item["url"], session, **req_kwargs)
parts = _plan_tail_parts(size, spare) if supports_ranges else []
if not parts:
tail_skipped.add(index) # no ranges / too small / no capacity -> normal path
continue
try:
remaining.remove((index, url_item))
except ValueError:
tail_skipped.add(index)
continue
# distinct .tp.!dev target so an interrupted boost never collides with the
# normal worker's .p.!dev; the .!dev suffix keeps the stray visible to cleanup
part_target = save_path.with_name(f"{save_path.name}.tp.!dev")
with open(part_target, "wb") as f:
f.truncate(size)
seg_start[index] = time.time()
tail_boosted.add(index)
abort = threading.Event()
parts_left = [len(parts)]
part_lock = threading.Lock()
for start, end in parts:
pending.add(
pool.submit(
_tail_part_worker,
index,
item["url"],
part_target,
save_path,
start,
end,
size,
abort,
parts_left,
part_lock,
req_kwargs,
)
)
boosted += 1
if controller:
_submit(controller.update(time.time()))
else:
_submit(len(remaining))
pending_advance = 0
try:
while pending:
while pending or remaining:
# Drain queued events — batch advances, track bytes for speed
while True:
try:
@@ -638,6 +1028,8 @@ def requests(
written = event.get("written")
if written:
total_bytes += written
if controller:
controller.record_bytes(written, time.time())
# Pass through other events (file_downloaded, total, etc.)
yield event
@@ -646,25 +1038,50 @@ def requests(
yield dict(advance=pending_advance)
pending_advance = 0
# Yield speed every 0.5s (throttled to avoid spamming Rich)
now = time.time()
if now - last_speed_report > 0.5 and total_bytes > 0:
elapsed = now - start_time
if elapsed > 0:
download_speed = math.ceil(total_bytes / elapsed)
yield dict(downloaded=f"{filesize.decimal(download_speed)}/s")
last_speed_report = now
# hedge stuck segments once spare workers exist (pending < max_workers)
if len(pending) < max_workers and seg_durations and not DOWNLOAD_CANCELLED.is_set():
# Adaptive: re-evaluate the worker target and top up in-flight futures
target = max_workers
if controller:
target = controller.update(now, len(pending) + len(remaining))
_maybe_tail_boost(target) # spend spare workers on the tail before topping up
if len(pending) < target:
# In the final stride (unstarted segments <= one worker target), hold back
# boost candidates so idle workers accumulate for a range-split; only
# release segments the boost already declined (too small / no range) so
# nothing stalls. Otherwise top up in FIFO order as usual.
if remaining and len(remaining) <= target:
_submit(target - len(pending), only=tail_skipped)
else:
_submit(target - len(pending))
# Yield speed every 0.5s (throttled to avoid spamming Rich)
if now - last_speed_report > 0.5:
if controller:
rolling = controller.speed(now)
if rolling > 0:
yield dict(downloaded=f"{filesize.decimal(math.ceil(rolling))}/s")
last_speed_report = now
elif total_bytes > 0:
elapsed = now - start_time
if elapsed > 0:
download_speed = math.ceil(total_bytes / elapsed)
yield dict(downloaded=f"{filesize.decimal(download_speed)}/s")
last_speed_report = now
# hedge stuck segments once spare workers exist within the active target
if len(pending) < target and seg_durations and not DOWNLOAD_CANCELLED.is_set():
threshold = max(HEDGE_FACTOR * statistics.median(seg_durations), HEDGE_MIN_WAIT)
with seg_lock:
stuck = [
i
for i, started in seg_start.items()
if i not in seg_done and i not in hedged and now - started > threshold
if i not in seg_done
and i not in hedged
and i not in tail_boosted # boosted segments are already range-split
and now - started > threshold
]
for i in stuck[: max_workers - len(pending)]:
for i in stuck[: target - len(pending)]:
hedged.add(i)
pending.add(pool.submit(_download_worker, i, urls[i], True))
+2
View File
@@ -281,6 +281,7 @@ class DASH:
progress = ctx.progress
proxy = ctx.proxy
max_workers = ctx.max_workers
adaptive = ctx.adaptive_workers
license_widevine = ctx.license_widevine
cdm = ctx.cdm
@@ -458,6 +459,7 @@ class DASH:
proxy=proxy,
max_workers=max_workers,
session=session,
adaptive=adaptive,
)
log_event(
+2
View File
@@ -523,6 +523,7 @@ class HLS:
progress = ctx.progress
proxy = ctx.proxy
max_workers = ctx.max_workers
adaptive = ctx.adaptive_workers
license_widevine = ctx.license_widevine
cdm = ctx.cdm
@@ -672,6 +673,7 @@ class HLS:
proxy=proxy,
max_workers=max_workers,
session=session,
adaptive=adaptive,
)
log_event(
+2
View File
@@ -370,6 +370,7 @@ class ISM:
progress = ctx.progress
proxy = ctx.proxy
max_workers = ctx.max_workers
adaptive = ctx.adaptive_workers
license_widevine = ctx.license_widevine
cdm = ctx.cdm
@@ -411,6 +412,7 @@ class ISM:
proxy=proxy,
max_workers=max_workers,
session=session,
adaptive=adaptive,
)
log_event(
+62 -4
View File
@@ -4,7 +4,7 @@ from collections.abc import Callable, Generator
from dataclasses import dataclass, field
from http.cookiejar import CookieJar
from pathlib import Path
from typing import TYPE_CHECKING, Optional, Union
from typing import TYPE_CHECKING, Any, Optional, Union
from urllib.parse import urlparse, urlunparse
if TYPE_CHECKING:
@@ -25,12 +25,55 @@ from unshackle.core.constants import AnyTrack
from unshackle.core.credential import Credential
from unshackle.core.drm import DRM_T
from unshackle.core.search_result import SearchResult
from unshackle.core.session import (BACKOFF_FACTOR, CONNECT_TIMEOUT, MAX_BACKOFF, MAX_RETRIES, POOL_MAX_SIZE,
READ_TIMEOUT, RETRY_METHODS, STATUS_FORCELIST)
from unshackle.core.title_cacher import TitleCacher, get_account_hash, get_region_from_proxy
from unshackle.core.titles import Title_T, Titles_T, remap_titles
from unshackle.core.tracks import Chapters, Tracks
from unshackle.core.tracks.video import Video
from unshackle.core.utils.ip_info import get_ip_info
# Default (connect, read) timeout for the requests path, mirroring RnetSession's
# connect_timeout / read_timeout construction defaults. A per-request timeout= wins.
DEFAULT_TIMEOUT = (CONNECT_TIMEOUT, READ_TIMEOUT)
class TimeoutSession(requests.Session):
"""requests.Session applying DEFAULT_TIMEOUT when the caller passes none.
requests has no native default timeout; without one a stalled connect or
read hangs forever. RnetSession bounds every request via its client's
connect_timeout/read_timeout, so this mirrors that on the requests path.
A per-request non-None ``timeout=`` wins; an explicit ``timeout=None`` is
still replaced with the default by :class:`TimeoutHTTPAdapter` on the
mounted adapters, so there is no unbounded read, matching RnetSession where
the client-level timeouts always apply. Pass a large timeout instead.
"""
def request(self, *args: Any, **kwargs: Any) -> requests.Response:
kwargs.setdefault("timeout", DEFAULT_TIMEOUT)
return super().request(*args, **kwargs)
class TimeoutHTTPAdapter(HTTPAdapter):
"""HTTPAdapter applying DEFAULT_TIMEOUT when the caller passes none.
Backstops :class:`TimeoutSession` for the ``session.send(prepared)`` path,
which bypasses ``Session.request``; RnetSession bounds those too via its
client, so this keeps parity. A per-request non-None ``timeout=`` wins;
``None`` (unset, or explicitly passed) gets the default, because the adapter
cannot distinguish the two, and rnet has no unbounded mode either.
"""
def __init__(self, *args: Any, timeout: Any = DEFAULT_TIMEOUT, **kwargs: Any) -> None:
self.default_timeout = timeout
super().__init__(*args, **kwargs)
def send(self, request: Any, **kwargs: Any) -> Any:
if kwargs.get("timeout") is None:
kwargs["timeout"] = self.default_timeout
return super().send(request, **kwargs)
@dataclass
class TrackRequest:
@@ -322,12 +365,27 @@ class Service(metaclass=ABCMeta):
from config, cookies, retry handler, and a proxy if available.
:returns: Prepared Python-requests Session
"""
session = requests.Session()
session = TimeoutSession()
session.headers.update(config.headers)
# Retry / pool policy mirrors RnetSession via the shared constants in session.py:
# same total, forcelist, backoff cap, allowed_methods (incl. POST, so license
# requests retry on both paths), and pool size. Accepted divergence: urllib3's
# backoff_jitter adds an absolute 0..N seconds whereas rnet jitters ±10% of the
# backoff, the only intentional difference, not worth hand-rolling a retry loop.
session.mount(
"https://",
HTTPAdapter(
max_retries=Retry(total=5, backoff_factor=0.2, status_forcelist=[429, 500, 502, 503, 504]),
TimeoutHTTPAdapter(
max_retries=Retry(
total=MAX_RETRIES,
backoff_factor=BACKOFF_FACTOR,
backoff_max=MAX_BACKOFF,
backoff_jitter=0.2,
status_forcelist=STATUS_FORCELIST,
allowed_methods=RETRY_METHODS,
respect_retry_after_header=True,
),
pool_connections=POOL_MAX_SIZE,
pool_maxsize=POOL_MAX_SIZE,
pool_block=True,
),
)
+65 -11
View File
@@ -19,6 +19,24 @@ from requests.structures import CaseInsensitiveDict
from unshackle.core.config import config
# ---------------------------------------------------------------------------
# Shared retry / pool / timeout policy: single source of truth
# ---------------------------------------------------------------------------
# rnet's only distinguishing role is TLS fingerprinting; retry, backoff,
# pooling, and timeouts must match the plain-requests path. Both RnetSession
# (below) and Service.get_session import these so the two can't drift.
# The unified downloader keeps its own copies (downloaders/requests.py) by design.
MAX_RETRIES = 5
BACKOFF_FACTOR = 0.2
MAX_BACKOFF = 60.0 # shared backoff cap (rnet max_backoff == requests Retry backoff_max)
STATUS_FORCELIST = [429, 500, 502, 503, 504]
RETRY_METHODS = frozenset({"GET", "POST", "HEAD", "OPTIONS", "PUT", "DELETE", "TRACE"})
POOL_MAX_SIZE = 16 # rnet pool_max_idle_per_host == requests pool_maxsize/pool_connections
CONNECT_TIMEOUT = 10
READ_TIMEOUT = 30
# ---------------------------------------------------------------------------
# Impersonate preset mapping — rnet uses named presets (no custom JA3/Akamai)
# ---------------------------------------------------------------------------
@@ -483,9 +501,9 @@ class RnetSession:
def __init__(
self,
max_retries: int = 5,
backoff_factor: float = 0.2,
max_backoff: float = 60.0,
max_retries: int = MAX_RETRIES,
backoff_factor: float = BACKOFF_FACTOR,
max_backoff: float = MAX_BACKOFF,
status_forcelist: Optional[list[int]] = None,
allowed_methods: Optional[set[str]] = None,
catch_exceptions: Optional[tuple[type[Exception], ...]] = None,
@@ -494,10 +512,11 @@ class RnetSession:
self.max_retries = max_retries
self.backoff_factor = backoff_factor
self.max_backoff = max_backoff
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.status_forcelist = status_forcelist or list(STATUS_FORCELIST)
self.allowed_methods = allowed_methods or set(RETRY_METHODS)
self.catch_exceptions = catch_exceptions or (
rnet.ConnectionError,
rnet.ConnectionResetError, # sibling of ConnectionError; mid-request resets are retryable
rnet.TimeoutError,
rnet.RequestError,
)
@@ -511,9 +530,13 @@ class RnetSession:
"read_timeout",
"proxies",
"verify",
"redirect",
"allow_redirects",
"pool_idle_timeout",
"pool_max_idle_per_host",
"pool_max_size",
"tcp_keepalive",
"tcp_keepalive_interval",
"tcp_keepalive_retries",
"http1_only",
"http2_only",
"tcp_nodelay",
@@ -597,6 +620,12 @@ class RnetSession:
client = self._ensure_client()
method_upper = method.upper() if isinstance(method, str) else str(method).upper()
# Per-request override of the session retry budget (rnet.request() would reject the kwarg).
# max_retries=0 means a single attempt with no session-level retries.
max_retries = kwargs.pop("max_retries", None)
if max_retries is None:
max_retries = self.max_retries
# Build URL with params
url = self._build_url(url, kwargs.pop("params", None))
@@ -637,7 +666,7 @@ class RnetSession:
last_exception: Optional[Exception] = None
response: Optional[RnetResponse] = None
for attempt in range(self.max_retries + 1):
for attempt in range(max_retries + 1):
try:
raw_resp = client.request(rnet_method, url, **kwargs)
response = RnetResponse(raw_resp)
@@ -662,17 +691,17 @@ class RnetSession:
last_exception = HTTPError(f"Received status code: {response.status_code}")
self.log.warning(
f"{response.status_code} {response.reason}({urlparse(url).path}). Retrying... "
f"({attempt + 1}/{self.max_retries})"
f"({attempt + 1}/{max_retries})"
)
except self.catch_exceptions as e:
last_exception = e
response = None
self.log.warning(
f"{e.__class__.__name__}({urlparse(url).path}). Retrying... ({attempt + 1}/{self.max_retries})"
f"{e.__class__.__name__}({urlparse(url).path}). Retrying... ({attempt + 1}/{max_retries})"
)
if attempt < self.max_retries:
if attempt < max_retries:
if sleep_duration := self.get_sleep_time(response, attempt + 1):
if sleep_duration > 0:
time.sleep(sleep_duration)
@@ -774,11 +803,36 @@ def session(
session_kwargs: dict[str, Any] = {"impersonate": impersonate}
# optional rnet client knobs, see docs/NETWORK_CONFIG.md
for key in ("http1_only", "http2_only", "pool_max_idle_per_host", "pool_max_size", "tcp_nodelay"):
for key in (
"http1_only",
"http2_only",
"pool_max_idle_per_host",
"pool_max_size",
"tcp_nodelay",
"connect_timeout",
"read_timeout",
"timeout",
"pool_idle_timeout",
"tcp_keepalive",
"tcp_keepalive_interval",
"tcp_keepalive_retries",
"allow_redirects",
):
if key in config.network:
session_kwargs[key] = config.network[key]
session_kwargs.update(kwargs)
# Connection-pool / timeout defaults applied only when neither config.network nor the caller set them.
# connect_timeout + read_timeout mirror the requests-path default timeout (CONNECT_TIMEOUT, READ_TIMEOUT)
# so an unset config still gets a bounded connect and read like requests; pool_idle_timeout stays under
# the typical ~60s CDN idle kill; pool_max_idle_per_host matches the downloader worker cap to avoid
# HTTP/1.1 connection churn; tcp_keepalive keeps long idle segments warm.
session_kwargs.setdefault("connect_timeout", CONNECT_TIMEOUT)
session_kwargs.setdefault("read_timeout", READ_TIMEOUT)
session_kwargs.setdefault("pool_idle_timeout", 55)
session_kwargs.setdefault("pool_max_idle_per_host", POOL_MAX_SIZE)
session_kwargs.setdefault("tcp_keepalive", 30)
session_obj = RnetSession(**session_kwargs)
session_obj.headers.update(config.headers)
return session_obj
+10 -1
View File
@@ -246,8 +246,17 @@ class Subtitle(Track):
*,
cdm: Optional[object] = None,
no_proxy_download: bool = False,
adaptive_workers: bool = False,
):
super().download(session, prepare_drm, max_workers, progress, cdm=cdm, no_proxy_download=no_proxy_download)
super().download(
session,
prepare_drm,
max_workers,
progress,
cdm=cdm,
no_proxy_download=no_proxy_download,
adaptive_workers=adaptive_workers,
)
if not self.path:
return
+4
View File
@@ -58,6 +58,7 @@ class DownloadContext:
session: Optional[Union[Session, "RnetSession"]] = None
proxy: Optional[str] = None
max_workers: Optional[int] = None
adaptive_workers: bool = False
license_widevine: Optional[Callable] = None
cdm: Optional[object] = None
@@ -232,6 +233,7 @@ class Track:
*,
cdm: Optional[object] = None,
no_proxy_download: bool = False,
adaptive_workers: bool = False,
):
"""Download and optionally Decrypt this Track."""
from unshackle.core.manifests import DASH, HLS, ISM
@@ -293,6 +295,7 @@ class Track:
session=dl_session,
proxy=proxy,
max_workers=max_workers,
adaptive_workers=adaptive_workers,
license_widevine=prepare_drm,
cdm=cdm,
)
@@ -362,6 +365,7 @@ class Track:
proxy=proxy,
max_workers=max_workers,
session=dl_session,
adaptive=adaptive_workers,
):
file_downloaded = status_update.get("file_downloaded")
if not file_downloaded: