From bc7270d7c17dd9165ee3b3afab970264e37e53ad Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 9 Aug 2026 12:03:08 +0200 Subject: [PATCH] Refine watchlist search and provider fallback --- CHANGELOG.md | 6 ++ README.md | 3 +- VERSION | 2 +- provider_downloader.py | 151 +++++++++++++++++++++++++++++++++++++---- providers/bridge.js | 2 +- search_page.py | 13 +++- watchlist_page.py | 61 ----------------- 7 files changed, 158 insertions(+), 80 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35f6bb1..4837aa5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.51.1 - 2026-08-09 + +- Removed the manual add form from the Watchlist page so entries are added through Search. +- Made Search result provider badges explicit with `Source: Anikoto`, `Source: AniNeko`, or `Source: AnimePahe`. +- Added cross-provider download fallback so a missing or failing episode on the primary provider is tried against the remaining providers before the queue job fails. + ## 0.51.0 - 2026-08-09 - Renamed the app surface to Kaizoku and switched the default state folder to `.kaizoku`. diff --git a/README.md b/README.md index ea8417a..839edf3 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ Kaizoku is a local web app for searching, tracking, and downloading anime from A - Search anime through the configured provider: `anikoto`, `anineko`, or `pahe`. - Queue single episodes or batches in subbed or dubbed mode. +- Fall back across the other configured providers when an episode or stream cannot be resolved on the primary provider. - Save files with Jellyfin-friendly layout: `TV/Series Name/Season 01/Series Name - S01E01.mp4`. - Save English external subtitles when the provider exposes usable subtitle tracks. - Manage watchlists for `Watching`, `Planned`, `Finished`, and `Dropped`. @@ -67,7 +68,7 @@ The Docker image installs Python, Node.js, npm, and `ffmpeg`, then runs `npm ins ## 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. Existing finalization code moves staged files into the configured library layout, preserving data already present in production download folders. +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 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. ## Data Safety diff --git a/VERSION b/VERSION index c5d4cee..1e0c609 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.51.0 +0.51.1 diff --git a/provider_downloader.py b/provider_downloader.py index 494dc2d..3b0780b 100755 --- a/provider_downloader.py +++ b/provider_downloader.py @@ -23,6 +23,10 @@ def clean_component(value, default="Anime"): return text.strip(". ") or default +def normalize_title(value): + return re.sub(r"[^a-z0-9]+", " ", str(value or "").lower()).strip() + + def parse_show_id(value, default_provider="anikoto"): text = str(value or "").strip() if ":" in text: @@ -44,6 +48,52 @@ def bridge(command, provider, *args): raise RuntimeError(f"Provider bridge returned invalid JSON: {exc}") from exc +def provider_order(primary): + ordered = [] + primary = str(primary or "").strip().lower() + if primary in PROVIDERS: + ordered.append(primary) + for provider in PROVIDERS: + if provider not in ordered: + ordered.append(provider) + return ordered + + +def find_provider_show(provider, title, original_provider, original_show_id): + if provider == original_provider: + return original_show_id + query = str(title or "").strip() + if not query: + return "" + payload = bridge("search", provider, query, 1) + results = payload.get("results") or [] + if not results: + return "" + target = normalize_title(query) + scored = [] + for item in results: + candidate = normalize_title(item.get("title")) + if not candidate: + continue + if candidate == target: + score = 100 + elif target and (candidate in target or target in candidate): + score = 75 - abs(len(candidate) - len(target)) + else: + shared = set(target.split()) & set(candidate.split()) + score = len(shared) * 5 + scored.append((score, item)) + if not scored: + return "" + scored.sort(key=lambda row: row[0], reverse=True) + if scored[0][0] <= 0: + return "" + provider_id = str(scored[0][1].get("provider_id") or scored[0][1].get("id") or "").strip() + if ":" in provider_id: + provider_id = provider_id.split(":", 1)[1].strip() + return provider_id + + def episode_values(spec, episodes): values = [] by_number = {str(ep.get("number")).rstrip("0").rstrip("."): ep for ep in episodes} @@ -71,6 +121,47 @@ def episode_values(spec, episodes): return values +def requested_episode_numbers(spec, episodes): + text = str(spec or "").strip() + if not text: + return [episode.get("number") for episode in episodes or [] if episode.get("number") not in (None, "")] + numbers = [] + for part in re.split(r"[\s,]+", text): + if not part: + continue + if "-" in part: + start, end = part.split("-", 1) + try: + left = int(float(start)) + right = int(float(end)) + except ValueError: + continue + step = 1 if right >= left else -1 + numbers.extend(range(left, right + step, step)) + continue + if re.match(r"^\d+(?:\.\d+)?$", part): + numbers.append(float(part) if "." in part else int(part)) + return numbers + + +def wanted_episodes(spec, episodes): + by_number = {str(ep.get("number")).rstrip("0").rstrip("."): ep for ep in episodes or []} + wanted = [] + for number in requested_episode_numbers(spec, episodes): + key = str(number).rstrip("0").rstrip(".") + wanted.append(by_number.get(key) or {"number": number, "id": ""}) + return wanted + + +def episode_by_number(number, episodes): + wanted = str(number).rstrip("0").rstrip(".") + for episode in episodes or []: + current = str(episode.get("number")).rstrip("0").rstrip(".") + if current == wanted: + return episode + return None + + def ffmpeg_headers(headers): pairs = [] for key, value in (headers or {}).items(): @@ -108,6 +199,24 @@ def download_episode(stream, target): return subprocess.call(cmd) +def provider_episode_candidates(primary_provider, primary_show_id, title, wanted_episode): + number = wanted_episode.get("number") + for provider in provider_order(primary_provider): + try: + provider_show_id = find_provider_show(provider, title, primary_provider, primary_show_id) + if not provider_show_id: + print(f"Fallback skipped {provider}: no matching title.") + continue + episodes = bridge("episodes", provider, provider_show_id).get("episodes") or [] + episode = episode_by_number(number, episodes) + if not episode: + print(f"Fallback skipped {provider}: episode {number} is not listed.") + continue + yield provider, provider_show_id, episode + except Exception as exc: + print(f"Fallback skipped {provider}: {exc}") + + def main(): parser = argparse.ArgumentParser() parser.add_argument("--provider", default="anikoto") @@ -122,7 +231,7 @@ def main(): provider, provider_show_id = parse_show_id(args.show_id, args.provider) info = bridge("info", provider, provider_show_id) episodes = bridge("episodes", provider, provider_show_id).get("episodes") or [] - wanted = episode_values(args.episodes, episodes) + wanted = wanted_episodes(args.episodes, episodes) if not wanted: raise SystemExit(f"No matching episodes for spec {args.episodes!r}.") @@ -140,20 +249,34 @@ def main(): padded = f"{int(float(number)):02d}" if str(number).replace(".", "", 1).isdigit() else str(number) basename = f"{series_title} - S01E{padded}" target = output_dir / f"{basename}.mp4" - print(f"Resolving episode {number} ({args.mode}, {args.quality})...") - stream = bridge("resolve", provider, ep_id, args.mode, args.quality) - print(f"Downloading episode {number} from {stream.get('quality') or 'auto'}...") - code = download_episode(stream, target) - if code != 0: - print(f"ffmpeg failed for episode {number} with exit code {code}", file=sys.stderr) - exit_code = code + last_error = None + downloaded = False + for active_provider, _active_show_id, active_ep in provider_episode_candidates(provider, provider_show_id, args.title or info.get("title"), ep): + try: + active_ep_id = active_ep.get("id") + print(f"Resolving episode {number} on {active_provider} ({args.mode}, {args.quality})...") + stream = bridge("resolve", active_provider, active_ep_id, args.mode, args.quality) + print(f"Downloading episode {number} from {active_provider} {stream.get('quality') or 'auto'}...") + code = download_episode(stream, target) + if code != 0: + last_error = RuntimeError(f"ffmpeg failed on {active_provider} with exit code {code}") + print(str(last_error), file=sys.stderr) + continue + try: + for subtitle_path in download_subtitles(stream.get("subtitles") or [], target): + print(f"Saved subtitle: {subtitle_path.name}") + except Exception as exc: + print(f"Subtitle download skipped: {exc}") + print(f"Saved: {target.name}") + downloaded = True + break + except Exception as exc: + last_error = exc + print(f"Provider {active_provider} failed for episode {number}: {exc}", file=sys.stderr) + if not downloaded: + print(f"Episode {number} failed on all providers: {last_error}", file=sys.stderr) + exit_code = 1 break - try: - for subtitle_path in download_subtitles(stream.get("subtitles") or [], target): - print(f"Saved subtitle: {subtitle_path.name}") - except Exception as exc: - print(f"Subtitle download skipped: {exc}") - print(f"Saved: {target.name}") raise SystemExit(exit_code) diff --git a/providers/bridge.js b/providers/bridge.js index 332d3a5..f4f3e69 100755 --- a/providers/bridge.js +++ b/providers/bridge.js @@ -77,7 +77,7 @@ function qualityScore(source, wanted) { } async function resolve(provider, episodeId, mode, quality) { - const sourcesPayload = await provider.fetchEpisodeSources(episodeId, mode); + let sourcesPayload = await provider.fetchEpisodeSources(episodeId, mode); let sources = Array.isArray(sourcesPayload?.sources) ? sourcesPayload.sources : []; if (!sources.length && mode === "dub") { sourcesPayload = await provider.fetchEpisodeSources(episodeId, "sub"); diff --git a/search_page.py b/search_page.py index 3069a68..31b51c5 100644 --- a/search_page.py +++ b/search_page.py @@ -541,6 +541,15 @@ INDEX_HTML = r""" return `/api/search/thumb?title=${encodeURIComponent(title || "")}`; } + function providerLabel(provider) { + const labels = { + anikoto: "Anikoto", + anineko: "AniNeko", + pahe: "AnimePahe" + }; + return labels[String(provider || "").toLowerCase()] || "Provider"; + } + function renderResults(results) { const el = $("results"); el.innerHTML = ""; @@ -574,8 +583,8 @@ INDEX_HTML = r""" subtitle.hidden = !alternativeTitle; btn.querySelector(".cover-fallback").textContent = coverInitials(item.title); const pills = btn.querySelectorAll(".result-pill"); - pills[0].textContent = item.episodes ? `${item.episodes} episodes` : (item.provider || "provider"); - pills[1].textContent = `Result ${item.index}`; + pills[0].textContent = `Source: ${providerLabel(item.provider)}`; + pills[1].textContent = item.episodes ? `${item.episodes} episodes` : `Result ${item.index}`; const img = btn.querySelector("img"); const tile = btn.querySelector(".cover-tile"); img.alt = item.title; diff --git a/watchlist_page.py b/watchlist_page.py index 2feaa03..c1ab7f5 100644 --- a/watchlist_page.py +++ b/watchlist_page.py @@ -705,40 +705,6 @@ WATCHLIST_HTML = r""" "Watchlist", ) + render_page_links("watchlist") + r""" -
-
-
-

Add manually

-

Track a show directly when you already know its AllAnime ID.

-
-
- -
- - - - -
Tip: you can also add entries directly from the Search page.
- -
-
-

How it works

@@ -1541,33 +1507,6 @@ WATCHLIST_HTML = r""" } } - async function addAnimeToWatchlist() { - const showId = $("watchlist-show-id").value.trim(); - const title = $("watchlist-title").value.trim(); - if (!showId || !title) { - setNotice("Fill in the watchlist title and show ID."); - return; - } - try { - const data = await api("/api/watchlist", { - method: "POST", - body: JSON.stringify({ - show_id: showId, - title, - category: $("watchlist-category").value, - media_type: $("watchlist-media-type").value - }) - }); - $("watchlist-form").reset(); - $("watchlist-category").value = "watching"; - $("watchlist-media-type").value = "tv"; - setNotice(data.message || "Added to watchlist."); - await fetchWatchlist(); - } catch (error) { - setNotice(error.message); - } - } - async function refreshWatchlistItem(showId) { try { const data = await api("/api/watchlist/update-status", {