Add search language availability badges

This commit is contained in:
Codex
2026-08-09 12:08:10 +02:00
parent bc7270d7c1
commit f79cbd9b7d
5 changed files with 63 additions and 11 deletions
+5
View File
@@ -1,5 +1,10 @@
# Changelog
## 0.51.2 - 2026-08-09
- Changed Search to inspect both subbed and dubbed episode availability for each result.
- Added `SUB` and `DUB` language badges to Search result cards when those episode versions are present.
## 0.51.1 - 2026-08-09
- Removed the manual add form from the Watchlist page so entries are added through Search.
+1 -1
View File
@@ -4,7 +4,7 @@ Kaizoku is a local web app for searching, tracking, and downloading anime from A
## Features
- Search anime through the configured provider: `anikoto`, `anineko`, or `pahe`.
- Search anime through the configured provider and tag results with available `SUB` and `DUB` episode languages.
- 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`.
+1 -1
View File
@@ -1 +1 @@
0.51.1
0.51.2
+43 -1
View File
@@ -879,6 +879,43 @@ def cache_thumbnail_image(show_id, image_url):
return final_path.name
def episode_language_summary(show_id, provider):
summary = {
"sub": False,
"dub": False,
"sub_count": 0,
"dub_count": 0,
"total": 0,
}
try:
rows = provider_bridge.episodes(show_id, provider=provider)
except Exception as exc:
debug_log("search.episode_language_summary_failed", show_id=show_id, provider=provider, error=exc)
return summary
sub_values = set()
dub_values = set()
all_values = set()
for row in rows:
value = str(row.get("number") or "").strip()
if value:
all_values.add(value)
langs = {str(lang or "").strip().lower() for lang in row.get("langs") or []}
if not langs:
langs.add("sub")
if "hsub" in langs:
langs.add("sub")
if "sub" in langs and value:
sub_values.add(value)
if "dub" in langs and value:
dub_values.add(value)
summary["sub"] = bool(sub_values)
summary["dub"] = bool(dub_values)
summary["sub_count"] = len(sub_values)
summary["dub_count"] = len(dub_values)
summary["total"] = len(all_values)
return summary
def search_anime(query, mode):
runtime = ensure_runtime()
provider = str((runtime.get("config") or {}).get("provider") or "anikoto").strip().lower()
@@ -890,13 +927,18 @@ def search_anime(query, mode):
show_id = str(show.get("id") or "").strip()
if not show_id or not title:
continue
language_summary = episode_language_summary(show_id, show.get("provider") or provider)
alternative_titles = extract_anidb_alternative_titles_for_title(title, primary_title=title)
index = len(results) + 1
results.append(
{
"id": show_id,
"title": title,
"episodes": "",
"episodes": language_summary["total"],
"has_sub": language_summary["sub"],
"has_dub": language_summary["dub"],
"sub_episodes": language_summary["sub_count"],
"dub_episodes": language_summary["dub_count"],
"index": index,
"query": query,
"mode": mode,
+13 -8
View File
@@ -423,7 +423,7 @@ INDEX_HTML = r"""<!doctype html>
<label for="search-query">Anime Title / Keyword</label>
<input type="text" id="search-query" placeholder="e.g., Attack on Titan">
<label for="search-mode">Mode</label>
<label for="search-mode">Episode mode</label>
<select id="search-mode">
<option value="sub">Subtitled (Sub)</option>
<option value="dub">Dubbed (Dub)</option>
@@ -569,10 +569,7 @@ INDEX_HTML = r"""<!doctype html>
</div>
<div class="result-title"></div>
<div class="result-subtitle"></div>
<div class="result-meta">
<span class="result-pill"></span>
<span class="result-pill"></span>
</div>
<div class="result-meta"></div>
`;
btn.querySelector(".result-title").textContent = item.title;
const subtitle = btn.querySelector(".result-subtitle");
@@ -582,9 +579,17 @@ INDEX_HTML = r"""<!doctype html>
subtitle.textContent = alternativeTitle ? `Also known as: ${alternativeTitle}` : "";
subtitle.hidden = !alternativeTitle;
btn.querySelector(".cover-fallback").textContent = coverInitials(item.title);
const pills = btn.querySelectorAll(".result-pill");
pills[0].textContent = `Source: ${providerLabel(item.provider)}`;
pills[1].textContent = item.episodes ? `${item.episodes} episodes` : `Result ${item.index}`;
const meta = btn.querySelector(".result-meta");
const addPill = (text) => {
const pill = document.createElement("span");
pill.className = "result-pill";
pill.textContent = text;
meta.appendChild(pill);
};
addPill(`Source: ${providerLabel(item.provider)}`);
if (item.has_sub) addPill("SUB");
if (item.has_dub) addPill("DUB");
addPill(item.episodes ? `${item.episodes} episodes` : `Result ${item.index}`);
const img = btn.querySelector("img");
const tile = btn.querySelector(".cover-tile");
img.alt = item.title;