Try alternate provider episode sources

This commit is contained in:
Dymas
2026-09-05 09:27:21 +02:00
parent 5718e5fe91
commit a29affd9be
6 changed files with 182 additions and 52 deletions
+5
View File
@@ -1,5 +1,10 @@
# Changelog # Changelog
## 0.52.21 - 2026-09-05
- Changed provider downloads to try alternate same-mode server sources for an episode before falling back to another provider.
- Stopped falling back from missing dub sources to sub sources during provider resolution.
## 0.52.20 - 2026-09-01 ## 0.52.20 - 2026-09-01
- Fixed retrying a failed multi-episode provider download so already finalized episodes are skipped and only the remaining episodes are requested again. - Fixed retrying a failed multi-episode provider download so already finalized episodes are skipped and only the remaining episodes are requested again.
+1 -1
View File
@@ -100,7 +100,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 a master HLS playlist, Kaizoku selects the highest-bandwidth variant before starting `ffmpeg`. When a media playlist uses extensionless 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`, 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 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`, 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.20 0.52.21
+29 -4
View File
@@ -673,6 +673,14 @@ def provider_episode_candidates(primary_provider, primary_show_id, title, wanted
print(f"Fallback skipped {provider}: {exc}") print(f"Fallback skipped {provider}: {exc}")
def provider_stream_candidates(provider, episode_id, mode, quality):
payload = bridge("resolve-all", provider, episode_id, mode, quality)
sources = payload.get("sources") or []
if isinstance(sources, list):
return [source for source in sources if isinstance(source, dict) and source.get("url")]
return []
def main(): def main():
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--provider", default="anikoto") parser.add_argument("--provider", default="anikoto")
@@ -728,10 +736,19 @@ def main():
percent=0, percent=0,
) )
print(f"Resolving episode {number} on {active_provider} ({args.mode}, {args.quality})...", flush=True) print(f"Resolving episode {number} on {active_provider} ({args.mode}, {args.quality})...", flush=True)
stream = bridge("resolve", active_provider, active_ep_id, args.mode, args.quality) streams = provider_stream_candidates(active_provider, active_ep_id, args.mode, args.quality)
if not streams:
last_error = RuntimeError(f"No playable {args.mode} sources on {active_provider}")
print(str(last_error), file=sys.stderr)
continue
for stream_index, stream in enumerate(streams, start=1):
source_name = stream.get("server") or stream.get("quality") or f"source {stream_index}"
emit_progress( emit_progress(
phase="download", phase="download",
message=f"Downloading episode {number} from {active_provider} {stream.get('quality') or 'auto'}.", message=(
f"Downloading episode {number} from {active_provider} "
f"{source_name} ({stream_index}/{len(streams)})."
),
episode=number, episode=number,
episode_index=episode_index, episode_index=episode_index,
episode_total=len(wanted), episode_total=len(wanted),
@@ -739,7 +756,11 @@ def main():
quality=stream.get("quality") or "auto", quality=stream.get("quality") or "auto",
percent=0, percent=0,
) )
print(f"Downloading episode {number} from {active_provider} {stream.get('quality') or 'auto'}...", flush=True) print(
f"Downloading episode {number} from {active_provider} "
f"{source_name} ({stream_index}/{len(streams)})...",
flush=True,
)
code = download_episode( code = download_episode(
stream, stream,
target, target,
@@ -748,7 +769,9 @@ def main():
episode_total=len(wanted), episode_total=len(wanted),
) )
if code != 0: if code != 0:
last_error = RuntimeError(f"ffmpeg failed on {active_provider} with exit code {code}") last_error = RuntimeError(
f"Source {source_name} on {active_provider} failed with exit code {code}"
)
print(str(last_error), file=sys.stderr) print(str(last_error), file=sys.stderr)
continue continue
try: try:
@@ -767,6 +790,8 @@ def main():
print(f"Saved: {target.name}", flush=True) print(f"Saved: {target.name}", flush=True)
downloaded = True downloaded = True
break break
if downloaded:
break
except Exception as exc: except Exception as exc:
last_error = exc last_error = exc
print(f"Provider {active_provider} failed for episode {number}: {exc}", file=sys.stderr) print(f"Provider {active_provider} failed for episode {number}: {exc}", file=sys.stderr)
+39 -11
View File
@@ -76,20 +76,39 @@ function qualityScore(source, wanted) {
return -Math.abs(target - value); return -Math.abs(target - value);
} }
function sourceMatchesMode(source, mode) {
const requested = String(mode || "sub").toLowerCase();
const type = String(source?.type || source?.lang || requested).toLowerCase();
if (requested === "dub") return type === "dub";
if (requested === "sub") return type === "sub" || type === "softsub" || type === "hsub" || type === "hardsub";
return type === requested;
}
async function resolve(provider, episodeId, mode, quality) { async function resolve(provider, episodeId, mode, quality) {
let sourcesPayload = await provider.fetchEpisodeSources(episodeId, mode); const candidates = await resolveAll(provider, episodeId, mode, quality);
let sources = Array.isArray(sourcesPayload?.sources) ? sourcesPayload.sources : []; if (candidates.length) return candidates[0];
if (!sources.length && mode === "dub") { throw new Error("Could not resolve a playable source.");
sourcesPayload = await provider.fetchEpisodeSources(episodeId, "sub"); }
sources = Array.isArray(sourcesPayload?.sources) ? sourcesPayload.sources : [];
} async function resolveAll(provider, episodeId, mode, quality) {
const sourcesPayload = await provider.fetchEpisodeSources(episodeId, mode);
const sources = Array.isArray(sourcesPayload?.sources) ? sourcesPayload.sources : [];
if (!sources.length) throw new Error("No episode sources were returned."); if (!sources.length) throw new Error("No episode sources were returned.");
sources.sort((a, b) => qualityScore(b, quality) - qualityScore(a, quality)); sources.sort((a, b) => qualityScore(b, quality) - qualityScore(a, quality));
const resolvedSources = [];
for (const source of sources) { for (const source of sources) {
const resolved = source.isUnresolved && provider.processServer let resolved = null;
try {
resolved = source.isUnresolved && provider.processServer
? await provider.processServer(source.rawServer || source) ? await provider.processServer(source.rawServer || source)
: source; : source;
} catch (err) {
process.stderr.write(`Source ${source.name || source.quality || "server"} failed to resolve: ${err?.message || err}\n`);
continue;
}
if (resolved?.url) { if (resolved?.url) {
const sourceType = resolved.type || resolved.lang || source.type || source.lang || mode;
if (!sourceMatchesMode({ type: sourceType }, mode)) continue;
const headers = Object.assign({}, resolved.headers || {}); const headers = Object.assign({}, resolved.headers || {});
try { try {
const host = new URL(resolved.url).hostname; const host = new URL(resolved.url).hostname;
@@ -98,17 +117,18 @@ async function resolve(provider, episodeId, mode, quality) {
} }
} catch (_) {} } catch (_) {}
if (!headers.Referer && fallbackReferer) headers.Referer = fallbackReferer; if (!headers.Referer && fallbackReferer) headers.Referer = fallbackReferer;
return { resolvedSources.push({
url: resolved.url, url: resolved.url,
isM3U8: Boolean(resolved.isM3U8 || String(resolved.url).includes(".m3u8")), isM3U8: Boolean(resolved.isM3U8 || String(resolved.url).includes(".m3u8")),
quality: resolved.quality || source.quality || source.name || "auto", quality: resolved.quality || source.quality || source.name || "auto",
type: resolved.type || source.type || mode, type: sourceType,
headers, headers,
subtitles: resolved.subtitles || sourcesPayload.subtitles || [], subtitles: resolved.subtitles || sourcesPayload.subtitles || [],
}; server: source.name || resolved.name || resolved.quality || "server",
});
} }
} }
throw new Error("Could not resolve a playable source."); return resolvedSources;
} }
async function main() { async function main() {
@@ -166,6 +186,14 @@ async function main() {
return; return;
} }
if (command === "resolve-all") {
const mode = rest[1] || "sub";
const quality = rest[2] || "best";
const data = await resolveAll(provider, id, mode, quality);
console.log(JSON.stringify({ sources: data }));
return;
}
fail(`Unknown command: ${command}`); fail(`Unknown command: ${command}`);
} }
+72
View File
@@ -4561,6 +4561,78 @@ seg-3.ts
self.assertEqual(fetch_bytes.call_count, 2) self.assertEqual(fetch_bytes.call_count, 2)
sleep.assert_called_once() sleep.assert_called_once()
def test_provider_stream_candidates_returns_resolved_sources(self):
with mock.patch.object(
provider_downloader,
"bridge",
return_value={
"sources": [
{"url": "https://cdn.example/one.m3u8", "server": "HD-1"},
{"server": "broken"},
{"url": "https://cdn.example/two.m3u8", "server": "HD-2"},
]
},
):
sources = provider_downloader.provider_stream_candidates("anikoto", "ep1", "dub", "best")
self.assertEqual([source["server"] for source in sources], ["HD-1", "HD-2"])
def test_provider_downloader_tries_next_same_provider_source_after_failure(self):
calls = []
def fake_bridge(command, provider, *args):
if command == "info":
return {"title": "Retry Source Show"}
if command == "episodes":
return {"episodes": [{"number": 1, "id": "ep1"}]}
if command == "resolve-all":
self.assertEqual(args[1], "dub")
return {
"sources": [
{"url": "https://cdn.example/first.m3u8", "server": "HD-1", "type": "dub"},
{"url": "https://cdn.example/second.m3u8", "server": "HD-2", "type": "dub"},
]
}
raise AssertionError(f"unexpected bridge command {command}")
def fake_download(stream, target, episode_number=None, episode_index=None, episode_total=None):
calls.append(stream["server"])
return 1 if stream["server"] == "HD-1" else 0
argv = [
"provider_downloader.py",
"--provider",
"anikoto",
"--show-id",
"anikoto:show-1",
"--title",
"Retry Source Show",
"--episodes",
"1",
"--mode",
"dub",
"--quality",
"best",
"--output-dir",
"/tmp/kaizoku-provider-test",
]
with mock.patch.object(provider_downloader, "bridge", side_effect=fake_bridge), mock.patch.object(
provider_downloader, "download_episode", side_effect=fake_download
), mock.patch.object(provider_downloader, "download_subtitles", return_value=[]), mock.patch.object(
provider_downloader.sys, "argv", argv
):
with self.assertRaises(SystemExit) as raised:
provider_downloader.main()
self.assertEqual(raised.exception.code, 0)
self.assertEqual(calls, ["HD-1", "HD-2"])
def test_provider_bridge_resolve_does_not_fallback_from_dub_to_sub(self):
bridge_js = (ROOT / "providers" / "bridge.js").read_text(encoding="utf-8")
self.assertNotIn('mode === "dub"', bridge_js)
self.assertNotIn('fetchEpisodeSources(episodeId, "sub")', bridge_js)
class DockerfilePackagingTests(unittest.TestCase): class DockerfilePackagingTests(unittest.TestCase):
def test_provider_updates_module_is_copied_into_image(self): def test_provider_updates_module_is_copied_into_image(self):