Add search language availability badges
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user