Refine watchlist search and provider fallback
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+137
-14
@@ -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)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -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
@@ -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;
|
||||
|
||||
@@ -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", {
|
||||
|
||||
Reference in New Issue
Block a user