Prefer curl impersonation for protected media

This commit is contained in:
Dymas
2026-09-07 21:30:16 +02:00
parent 5f75fef4de
commit baf5298631
6 changed files with 90 additions and 15 deletions
+5
View File
@@ -1,5 +1,10 @@
# Changelog # 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 ## 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. - Added a curl-backed media fetch fallback for native HLS segment requests that receive `HTTP 403` from Python's HTTP client.
+6
View File
@@ -1,7 +1,10 @@
FROM lexiforest/curl-impersonate:v2.2.2 AS curl_impersonate
FROM python:3.12-slim-bookworm FROM python:3.12-slim-bookworm
ENV DEBIAN_FRONTEND=noninteractive \ ENV DEBIAN_FRONTEND=noninteractive \
PATH=/usr/local/bin:/usr/bin:/bin \ PATH=/usr/local/bin:/usr/bin:/bin \
LD_LIBRARY_PATH=/usr/local/lib \
KAIZOKU_DOWNLOAD_DIR=/downloads \ KAIZOKU_DOWNLOAD_DIR=/downloads \
KAIZOKU_HOST=0.0.0.0 \ KAIZOKU_HOST=0.0.0.0 \
KAIZOKU_PORT=8421 \ KAIZOKU_PORT=8421 \
@@ -21,6 +24,9 @@ RUN apt-get update \
util-linux \ util-linux \
&& rm -rf /var/lib/apt/lists/* && 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 RUN mkdir -p /app
COPY package.json package-lock.json /app/ COPY package.json package-lock.json /app/
+3 -3
View File
@@ -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_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_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_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 ## 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. 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: Useful Compose overrides:
@@ -101,7 +101,7 @@ Keep `./.kaizoku` mounted for production instances. That directory contains the
## Download Flow ## 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. 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.
+1 -1
View File
@@ -1 +1 @@
0.52.25 0.52.26
+45 -5
View File
@@ -215,32 +215,72 @@ def request_headers(headers=None, referer=None):
def curl_candidates(): 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() configured = str(os.environ.get("KAIZOKU_CURL_BIN") or "").strip()
names = [ names = [configured]
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_chrome120",
"curl_chrome116", "curl_chrome116",
"curl_chrome110", "curl_chrome110",
"curl_firefox135",
"curl_firefox133",
"curl-impersonate", "curl-impersonate",
"curl", "curl",
] ]
)
candidates = [] candidates = []
for name in names: for name in names:
if not name or name in candidates: if not name:
continue continue
resolved = shutil.which(name) resolved = shutil.which(name)
if resolved: if resolved and resolved not in candidates:
candidates.append(resolved) candidates.append(resolved)
return candidates 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): def curl_fetch_bytes(url, headers=None, timeout=30):
candidates = curl_candidates() candidates = curl_candidates()
if not candidates: if not candidates:
raise RuntimeError("curl is not available for media fetch fallback.") raise RuntimeError("curl is not available for media fetch fallback.")
clean_headers = request_headers(headers)
last_error = None last_error = None
for curl_bin in candidates: for curl_bin in candidates:
clean_headers = curl_request_headers(curl_bin, headers)
cmd = [ cmd = [
curl_bin, curl_bin,
"--location", "--location",
+24
View File
@@ -4297,6 +4297,9 @@ class TemplateHelperTests(unittest.TestCase):
self.assertEqual(lock["packages"]["node_modules/cheerio"]["version"], "1.0.0") self.assertEqual(lock["packages"]["node_modules/cheerio"]["version"], "1.0.0")
self.assertIn("COPY package.json package-lock.json /app/", dockerfile) self.assertIn("COPY package.json package-lock.json /app/", dockerfile)
self.assertIn("npm ci --omit=dev", 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): def test_docker_compose_exposes_jellyfin_browse_roots(self):
compose = (ROOT / "docker-compose.yaml").read_text(encoding="utf-8") 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("--header", run.call_args.args[0])
self.assertIn("Referer: https://player.example/", 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): def test_fetch_bytes_retries_forbidden_media_with_curl_fallback(self):
error = urllib.error.HTTPError( error = urllib.error.HTTPError(
"https://cdn.example/segment.ts", "https://cdn.example/segment.ts",