From baf529863138c8b30e6332270156a6ecc3aab7e1 Mon Sep 17 00:00:00 2001 From: Dymas Date: Mon, 7 Sep 2026 21:30:16 +0200 Subject: [PATCH] Prefer curl impersonation for protected media --- CHANGELOG.md | 5 ++++ Dockerfile | 6 ++++ README.md | 6 ++-- VERSION | 2 +- provider_downloader.py | 62 ++++++++++++++++++++++++++++++++++-------- test_app.py | 24 ++++++++++++++++ 6 files changed, 90 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 924b301..f302065 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.52.26 - 2026-09-07 + +- Added curl-impersonate browser wrapper binaries to the Docker image so protected HLS segment requests can use browser-like TLS fingerprints. +- Changed curl media fallback to prefer discovered `curl_chrome*`, `curl_firefox*`, `curl_safari*`, and `curl_edge*` wrappers without overriding their built-in browser headers. + ## 0.52.25 - 2026-09-07 - Added a curl-backed media fetch fallback for native HLS segment requests that receive `HTTP 403` from Python's HTTP client. diff --git a/Dockerfile b/Dockerfile index 53bf408..5a1d97b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,10 @@ +FROM lexiforest/curl-impersonate:v2.2.2 AS curl_impersonate + FROM python:3.12-slim-bookworm ENV DEBIAN_FRONTEND=noninteractive \ PATH=/usr/local/bin:/usr/bin:/bin \ + LD_LIBRARY_PATH=/usr/local/lib \ KAIZOKU_DOWNLOAD_DIR=/downloads \ KAIZOKU_HOST=0.0.0.0 \ KAIZOKU_PORT=8421 \ @@ -21,6 +24,9 @@ RUN apt-get update \ util-linux \ && rm -rf /var/lib/apt/lists/* +COPY --from=curl_impersonate /usr/local/bin/ /usr/local/bin/ +COPY --from=curl_impersonate /usr/local/lib/ /usr/local/lib/ + RUN mkdir -p /app COPY package.json package-lock.json /app/ diff --git a/README.md b/README.md index 4d75a09..924aac7 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ Useful environment variables: - `KAIZOKU_REMOTE_PATH_ROOTS=/downloads,/jellyfin/tv,/jellyfin/movies` to limit remote filesystem browsing; comma-separated and platform path separators are accepted. - `KAIZOKU_MODE=sub`, `KAIZOKU_QUALITY=best`, and `KAIZOKU_DEBUG=1` for runtime defaults and diagnostics. - `KAIZOKU_SEGMENT_RETRIES=8`, `KAIZOKU_SEGMENT_RETRY_DELAY=1.25`, `KAIZOKU_SEGMENT_RETRY_MAX_DELAY=15`, and `KAIZOKU_SEGMENT_DOWNLOAD_DELAY=0` to tune retry/backoff and optional pacing behavior for native HLS segment downloads. -- `KAIZOKU_MEDIA_HTTP_CLIENT=auto` to let protected HLS segment fetches fall back from Python HTTP to `curl` after `HTTP 403`; use `curl` or `urllib` to force one client, and `KAIZOKU_CURL_BIN=/path/to/curl` to prefer a specific curl-compatible binary. +- `KAIZOKU_MEDIA_HTTP_CLIENT=auto` to let protected HLS segment fetches fall back from Python HTTP to `curl` or curl-impersonate after `HTTP 403`; use `curl` or `urllib` to force one client, and `KAIZOKU_CURL_BIN=/path/to/curl_chrome142` to prefer a specific curl-compatible binary. ## Docker @@ -85,7 +85,7 @@ The compose file builds the local checkout and runs Kaizoku on port `8421` by de Use `/jellyfin/tv` for the Jellyfin TV directory and `/jellyfin/movies` for the Jellyfin movie directory inside the Config page. Override `JELLYFIN_TV_DIR` and `JELLYFIN_MOVIE_DIR` to point those container paths at your real host Jellyfin library folders. -The Docker image installs Python, Node.js, npm, `curl`, `ffmpeg`, `openssl`, and `util-linux` for optional UID/GID switching, then runs `npm ci --omit=dev` for the provider bridge. Downloads go through Kaizoku's provider bridge and `provider_downloader.py`. +The Docker image installs Python, Node.js, npm, `curl`, curl-impersonate browser wrappers, `ffmpeg`, `openssl`, and `util-linux` for optional UID/GID switching, then runs `npm ci --omit=dev` for the provider bridge. Downloads go through Kaizoku's provider bridge and `provider_downloader.py`. Useful Compose overrides: @@ -101,7 +101,7 @@ Keep `./.kaizoku` mounted for production instances. That directory contains the ## Download Flow -Kaizoku stores provider-backed show IDs as `provider:id`, for example `anikoto:some-show-slug`. Queue jobs resolve the episode source through `providers/bridge.js`, then `provider_downloader.py` downloads the media with `ffmpeg` into a staging directory. If a provider returns multiple servers for the requested sub or dub mode, Kaizoku tries the same-mode server sources in quality order before falling back to another provider. It does not use sub sources for dub downloads, or dub sources for sub downloads. If a provider returns a master HLS playlist, Kaizoku selects the highest-bandwidth variant before starting `ffmpeg`. When a media playlist uses extensionless, SnapCDN, or disguised CDN segments, Kaizoku skips direct ffmpeg and uses a StrawVerse-style segment downloader; if HLS preflight fails, it stays on that native path instead of falling through to ffmpeg. The native downloader fetches the media playlist, downloads and concatenates segments itself with browser-like media headers and the provider referer, can fall back to `curl` after protected CDN `HTTP 403` responses, strips short PNG wrappers when present, retries temporary HTTP failures such as `429 Too Many Requests`, optionally paces requests with `KAIZOKU_SEGMENT_DOWNLOAD_DELAY`, then remuxes the local transport stream to MP4. Direct ffmpeg attempts also have a timeout guard so stalled HLS inputs can fall back cleanly. Each episode is written as a temporary `.mp4.part` file and moved into the downloads library after that episode succeeds, so already-finished episodes from a larger batch survive if a later episode fails. Retrying that failed queue job requests only the remaining episodes while keeping the original episode range for display and watchlist sync. Finalization preserves episode numbers from staged `SxxEyy` or `Episode yy` filenames before applying configured season/episode offsets. +Kaizoku stores provider-backed show IDs as `provider:id`, for example `anikoto:some-show-slug`. Queue jobs resolve the episode source through `providers/bridge.js`, then `provider_downloader.py` downloads the media with `ffmpeg` into a staging directory. If a provider returns multiple servers for the requested sub or dub mode, Kaizoku tries the same-mode server sources in quality order before falling back to another provider. It does not use sub sources for dub downloads, or dub sources for sub downloads. If a provider returns a master HLS playlist, Kaizoku selects the highest-bandwidth variant before starting `ffmpeg`. When a media playlist uses extensionless, SnapCDN, or disguised CDN segments, Kaizoku skips direct ffmpeg and uses a StrawVerse-style segment downloader; if HLS preflight fails, it stays on that native path instead of falling through to ffmpeg. The native downloader fetches the media playlist, downloads and concatenates segments itself with browser-like media headers and the provider referer, can fall back to curl-impersonate browser wrappers after protected CDN `HTTP 403` responses, strips short PNG wrappers when present, retries temporary HTTP failures such as `429 Too Many Requests`, optionally paces requests with `KAIZOKU_SEGMENT_DOWNLOAD_DELAY`, then remuxes the local transport stream to MP4. Direct ffmpeg attempts also have a timeout guard so stalled HLS inputs can fall back cleanly. Each episode is written as a temporary `.mp4.part` file and moved into the downloads library after that episode succeeds, so already-finished episodes from a larger batch survive if a later episode fails. Retrying that failed queue job requests only the remaining episodes while keeping the original episode range for display and watchlist sync. Finalization preserves episode numbers from staged `SxxEyy` or `Episode yy` filenames before applying configured season/episode offsets. If the primary provider cannot list, resolve, or download a requested episode, Kaizoku searches the same title on the remaining providers and tries the matching episode there. Existing finalization code moves staged files into the configured library layout, preserving data already present in production download folders. diff --git a/VERSION b/VERSION index 303662b..f1b52b2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.52.25 +0.52.26 diff --git a/provider_downloader.py b/provider_downloader.py index 3baaeed..9df19c1 100755 --- a/provider_downloader.py +++ b/provider_downloader.py @@ -215,32 +215,72 @@ def request_headers(headers=None, referer=None): def curl_candidates(): + def candidate_score(name): + text = Path(str(name or "")).name + numbers = [int(value) for value in re.findall(r"\d+", text)] + version = numbers[0] if numbers else 0 + family_score = 4 if text.startswith("curl_chrome") else 3 if text.startswith("curl_firefox") else 2 if text.startswith("curl_safari") else 1 + return (family_score, version, text) + configured = str(os.environ.get("KAIZOKU_CURL_BIN") or "").strip() - names = [ - configured, - "curl_chrome120", - "curl_chrome116", - "curl_chrome110", - "curl-impersonate", - "curl", - ] + names = [configured] + wrapper_prefixes = ("curl_chrome", "curl_firefox", "curl_safari", "curl_edge") + path_dirs = [Path(part) for part in os.environ.get("PATH", "").split(os.pathsep) if part] + discovered = [] + for directory in path_dirs: + try: + for path in directory.iterdir(): + if path.name.startswith(wrapper_prefixes) and os.access(path, os.X_OK): + discovered.append(path.name) + except OSError: + continue + discovered.sort(key=candidate_score, reverse=True) + names.extend(discovered) + names.extend( + [ + "curl_chrome142", + "curl_chrome136", + "curl_chrome133a", + "curl_chrome133", + "curl_chrome131", + "curl_chrome124", + "curl_chrome123", + "curl_chrome120", + "curl_chrome116", + "curl_chrome110", + "curl_firefox135", + "curl_firefox133", + "curl-impersonate", + "curl", + ] + ) candidates = [] for name in names: - if not name or name in candidates: + if not name: continue resolved = shutil.which(name) - if resolved: + if resolved and resolved not in candidates: candidates.append(resolved) return candidates +def curl_uses_browser_wrapper(curl_bin): + return Path(str(curl_bin or "")).name.startswith(("curl_chrome", "curl_firefox", "curl_safari", "curl_edge")) + + +def curl_request_headers(curl_bin, headers=None): + if not curl_uses_browser_wrapper(curl_bin): + return request_headers(headers) + return {key: value for key, value in (headers or {}).items() if value} + + def curl_fetch_bytes(url, headers=None, timeout=30): candidates = curl_candidates() if not candidates: raise RuntimeError("curl is not available for media fetch fallback.") - clean_headers = request_headers(headers) last_error = None for curl_bin in candidates: + clean_headers = curl_request_headers(curl_bin, headers) cmd = [ curl_bin, "--location", diff --git a/test_app.py b/test_app.py index d4e1da4..f47fcd6 100644 --- a/test_app.py +++ b/test_app.py @@ -4297,6 +4297,9 @@ class TemplateHelperTests(unittest.TestCase): self.assertEqual(lock["packages"]["node_modules/cheerio"]["version"], "1.0.0") self.assertIn("COPY package.json package-lock.json /app/", dockerfile) self.assertIn("npm ci --omit=dev", dockerfile) + self.assertIn("FROM lexiforest/curl-impersonate:v2.2.2 AS curl_impersonate", dockerfile) + self.assertIn("COPY --from=curl_impersonate /usr/local/bin/ /usr/local/bin/", dockerfile) + self.assertIn("LD_LIBRARY_PATH=/usr/local/lib", dockerfile) def test_docker_compose_exposes_jellyfin_browse_roots(self): compose = (ROOT / "docker-compose.yaml").read_text(encoding="utf-8") @@ -4541,6 +4544,27 @@ seg-3.ts self.assertIn("--header", run.call_args.args[0]) self.assertIn("Referer: https://player.example/", run.call_args.args[0]) + def test_curl_request_headers_preserve_browser_wrapper_defaults(self): + headers = provider_downloader.curl_request_headers( + "/usr/local/bin/curl_chrome142", + {"Referer": "https://player.example/"}, + ) + + self.assertEqual(headers, {"Referer": "https://player.example/"}) + + def test_curl_candidates_prefer_new_browser_wrappers(self): + def fake_which(name): + if name in {"curl_chrome142", "curl_chrome120", "curl"}: + return f"/usr/local/bin/{name}" + return None + + with mock.patch.object(provider_downloader.shutil, "which", side_effect=fake_which), mock.patch.object( + provider_downloader.os, "access", return_value=False + ): + candidates = provider_downloader.curl_candidates() + + self.assertEqual(candidates[:3], ["/usr/local/bin/curl_chrome142", "/usr/local/bin/curl_chrome120", "/usr/local/bin/curl"]) + def test_fetch_bytes_retries_forbidden_media_with_curl_fallback(self): error = urllib.error.HTTPError( "https://cdn.example/segment.ts",