diff --git a/CHANGELOG.md b/CHANGELOG.md index e11688c..80824c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.52.29 - 2026-09-07 + +- Added retry and exponential backoff for temporary HLS segment network failures, including DNS resolution, connection, and socket timeout errors. + ## 0.52.28 - 2026-09-07 - Fixed provider source fallback so playlist-resolution exceptions such as `HTTP 502` fail only the current server and the downloader continues with the remaining same-mode servers. diff --git a/README.md b/README.md index 861b478..f36d9d1 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ Useful environment variables: - `KAIZOKU_DOWNLOAD_DIR=/downloads` for the default library output path. - `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_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. Retries cover temporary HTTP responses, DNS failures, connection errors, and socket timeouts. - `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. Playlist, segment, and encryption-key requests preserve provider CDN session cookies for the duration of the download process. ## Docker diff --git a/VERSION b/VERSION index e7bb636..819b47c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.52.28 +0.52.29 diff --git a/provider_downloader.py b/provider_downloader.py index 617453e..b100dc5 100755 --- a/provider_downloader.py +++ b/provider_downloader.py @@ -420,6 +420,20 @@ def fetch_bytes_with_retries(url, headers=None, timeout=30, retries=None, retry_ ) if delay: time.sleep(delay) + except (urllib.error.URLError, TimeoutError, ConnectionError) as exc: + if attempt >= attempts: + raise + delay = base_delay * (2 ** attempt) + if max_delay: + delay = min(delay, max_delay) + print( + f"{retry_label} had a temporary network failure; retrying in {delay:.1f}s " + f"({attempt + 1}/{attempts}): {exc}", + file=sys.stderr, + flush=True, + ) + if delay: + time.sleep(delay) def segment_download_delay_seconds(): diff --git a/test_app.py b/test_app.py index 316d589..71740aa 100644 --- a/test_app.py +++ b/test_app.py @@ -4758,6 +4758,39 @@ https://shard-102.snapcdn.top/anime/show/episode/seg-f1-00083.css self.assertEqual(fetch_bytes.call_count, 2) sleep.assert_called_once() + def test_fetch_bytes_with_retries_recovers_from_temporary_dns_failure(self): + error = urllib.error.URLError(OSError(-3, "Temporary failure in name resolution")) + + with mock.patch.object( + provider_downloader, + "fetch_bytes", + side_effect=[error, b"video"], + ) as fetch_bytes, mock.patch.object(provider_downloader.time, "sleep") as sleep: + data = provider_downloader.fetch_bytes_with_retries( + "https://cdn.example/video/raw-segment", + retries=1, + retry_label="segment", + ) + + self.assertEqual(data, b"video") + self.assertEqual(fetch_bytes.call_count, 2) + sleep.assert_called_once() + + def test_fetch_bytes_with_retries_raises_dns_failure_after_limit(self): + error = urllib.error.URLError(OSError(-3, "Temporary failure in name resolution")) + + with mock.patch.object(provider_downloader, "fetch_bytes", side_effect=error), mock.patch.object( + provider_downloader.time, "sleep" + ) as sleep: + with self.assertRaises(urllib.error.URLError): + provider_downloader.fetch_bytes_with_retries( + "https://cdn.example/video/raw-segment", + retries=1, + retry_label="segment", + ) + + sleep.assert_called_once() + def test_provider_stream_candidates_returns_resolved_sources(self): with mock.patch.object( provider_downloader,