Refine watchlist search and provider fallback

This commit is contained in:
Codex
2026-08-09 12:03:08 +02:00
parent 34e3625f87
commit bc7270d7c1
7 changed files with 158 additions and 80 deletions
+6
View File
@@ -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`.
+2 -1
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
0.51.0
0.51.1
+130 -7
View File
@@ -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'}...")
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:
print(f"ffmpeg failed for episode {number} with exit code {code}", file=sys.stderr)
exit_code = code
break
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
raise SystemExit(exit_code)
+1 -1
View File
@@ -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");
+11 -2
View File
@@ -541,6 +541,15 @@ INDEX_HTML = r"""<!doctype html>
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"""<!doctype html>
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;
-61
View File
@@ -705,40 +705,6 @@ WATCHLIST_HTML = r"""<!doctype html>
"Watchlist",
) + render_page_links("watchlist") + r"""
<section class="settings">
<div class="toolbar">
<div>
<h2>Add manually</h2>
<p class="muted">Track a show directly when you already know its AllAnime ID.</p>
</div>
</div>
<form id="watchlist-form" class="stack" onsubmit="event.preventDefault(); addAnimeToWatchlist();">
<label>Anime title
<input type="text" id="watchlist-title" placeholder="Anime Title" required>
</label>
<label>Show ID
<input type="text" id="watchlist-show-id" placeholder="Show ID" required>
</label>
<label>Category
<select id="watchlist-category">
<option value="watching">Watching</option>
<option value="planned">Planned</option>
<option value="finished">Finished</option>
<option value="dropped">Dropped</option>
</select>
</label>
<label>Media type
<select id="watchlist-media-type">
<option value="tv">TV</option>
<option value="movie">Movie</option>
</select>
</label>
<div class="field-hint">Tip: you can also add entries directly from the Search page.</div>
<button class="primary" type="submit">Add to watchlist</button>
</form>
</section>
<section class="settings">
<h2>How it works</h2>
<div class="stack muted">
@@ -1541,33 +1507,6 @@ WATCHLIST_HTML = r"""<!doctype html>
}
}
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", {