From 0c051f4015059a46e982ef475ccd042a637059d8 Mon Sep 17 00:00:00 2001 From: Dymas Date: Sat, 5 Sep 2026 22:41:50 +0200 Subject: [PATCH] Keep failed HLS preflight on native path --- CHANGELOG.md | 5 +++++ README.md | 2 +- VERSION | 2 +- provider_downloader.py | 33 +++++++++++++++++++++++++++++++-- test_app.py | 15 +++++++++++++++ 5 files changed, 53 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e12ac3..e14498d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.52.23 - 2026-09-05 + +- Changed HLS preflight failures to stay on the native segment path instead of falling through to direct ffmpeg, preventing SnapCDN playlists from being hammered after a guard failure. +- Reused the already-fetched HLS media playlist for native segment downloads to avoid duplicate playlist requests. + ## 0.52.22 - 2026-09-05 - Routed SnapCDN and disguised HLS media segments through the native segment downloader to avoid ffmpeg hammering provider CDNs into `HTTP 429` failures. diff --git a/README.md b/README.md index 124fb77..dae42ff 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,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: it fetches the media playlist, downloads and concatenates segments itself, 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, 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 2d236ed..38a0310 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.52.22 +0.52.23 diff --git a/provider_downloader.py b/provider_downloader.py index 65b6d8f..2839ffc 100755 --- a/provider_downloader.py +++ b/provider_downloader.py @@ -434,8 +434,20 @@ def decrypt_aes128_segment(data, key, iv_value): return proc.stdout -def download_hls_segments(stream, input_url, target, partial, episode_number=None, episode_index=None, episode_total=None): - input_url, playlist = hls_media_playlist(stream, input_url) +def download_hls_segments( + stream, + input_url, + target, + partial, + episode_number=None, + episode_index=None, + episode_total=None, + playlist_text=None, +): + if playlist_text is None: + input_url, playlist = hls_media_playlist(stream, input_url) + else: + playlist = playlist_text segments = parse_hls_segments(input_url, playlist) if not segments: return 1 @@ -598,12 +610,29 @@ def download_episode(stream, target, episode_number=None, episode_index=None, ep episode_number=episode_number, episode_index=episode_index, episode_total=episode_total, + playlist_text=playlist, ) if code == 0: return 0 return code except Exception as exc: print(f"Native HLS preflight failed: {exc}", file=sys.stderr) + try: + code = download_hls_segments( + stream, + input_url, + target, + partial, + episode_number=episode_number, + episode_index=episode_index, + episode_total=episode_total, + ) + if code == 0: + return 0 + return code + except Exception as segment_exc: + print(f"Segment downloader failed after HLS preflight error: {segment_exc}", file=sys.stderr) + return 1 attempts = [ [ *ffmpeg_base_command(stream, input_url), diff --git a/test_app.py b/test_app.py index 4ae4b4b..2bb1d32 100644 --- a/test_app.py +++ b/test_app.py @@ -4546,6 +4546,21 @@ seg-3.ts ) ) + def test_hls_preflight_failure_does_not_fall_through_to_direct_ffmpeg(self): + with tempfile.TemporaryDirectory() as temp_root: + target = Path(temp_root) / "Episode.mp4" + stream = {"url": "https://cdn.example/master.m3u8", "isM3U8": True, "headers": {}} + with mock.patch.object(provider_downloader, "resolve_hls_input_url", return_value=stream["url"]), mock.patch.object( + provider_downloader, "hls_media_playlist", side_effect=RuntimeError("playlist blocked") + ), mock.patch.object(provider_downloader, "download_hls_segments", return_value=1) as download_segments, mock.patch.object( + provider_downloader, "run_ffmpeg" + ) as run_ffmpeg: + code = provider_downloader.download_episode(stream, target, episode_number=1) + + self.assertEqual(code, 1) + download_segments.assert_called_once() + run_ffmpeg.assert_not_called() + def test_fetch_bytes_with_retries_recovers_from_segment_rate_limit(self): error = urllib.error.HTTPError( "https://cdn.example/video/raw-segment",