diff --git a/CHANGELOG.md b/CHANGELOG.md index eba9165..d8cdc0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.37.0 - 2026-05-25 + +- Added an `Alternative titles` toggle to every watchlist card so AniList title variants can be expanded inline. +- Cached AniList title matches during watchlist refreshes and stored Romaji, English, Native, and synonym titles in SQLite with a migration-safe schema update for existing installs. + ## 0.36.3 - 2026-05-24 - Changed the Queue page to stop re-rendering the full job list on every poll and refresh only visible non-terminal job cards in place. diff --git a/README.md b/README.md index 871249a..f1ed9a8 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Small local web UI for a system-wide `ani-cli` install. -Current version: `0.36.3` +Current version: `0.37.0` ## Project Layout @@ -220,6 +220,7 @@ Main behavior: 6. Each card has an inline category selector. 7. Summary cards show total tracked shows plus `sub` and `dub` counts. 8. Pagination shows 30 cards per page. +9. `Alternative titles` expands AniList Romaji, English, Native, and synonym titles when AniList can match the show. Status behavior: diff --git a/VERSION b/VERSION index 1d3b40e..0f1a7df 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.36.3 +0.37.0 diff --git a/app.py b/app.py index 82a51e6..6f22cf3 100644 --- a/app.py +++ b/app.py @@ -25,6 +25,7 @@ from app_support import ( ALLANIME_API, ALLANIME_REFERER, ALLMANGA_SITE_BASE, + ANILIST_API, ANIDB_ANIME_PAGE, ANIDB_TITLES_CACHE, ANIDB_TITLES_PATH, @@ -230,6 +231,236 @@ def animeschedule_request(path, params=None, timeout=25): raise RuntimeError("AnimeSchedule returned unreadable JSON") from exc +def anilist_request(query, variables, timeout=25): + payload = json.dumps({"variables": variables, "query": query}).encode("utf-8") + request = urllib.request.Request( + ANILIST_API, + data=payload, + headers={ + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": AGENT, + }, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + raw = response.read().decode("utf-8") + except urllib.error.URLError as exc: + raise RuntimeError(f"Could not reach AniList: {exc}") from exc + except TimeoutError as exc: + raise RuntimeError("AniList request timed out") from exc + + try: + data = json.loads(raw) + except json.JSONDecodeError as exc: + raise RuntimeError("AniList returned unreadable JSON") from exc + + if data.get("errors"): + message = str(((data.get("errors") or [{}])[0]).get("message") or "AniList returned an error") + raise RuntimeError(message) + return data.get("data", {}) + + +def normalize_anilist_entry(node): + if not isinstance(node, dict): + return None + media_id = node.get("id") + if media_id in {None, ""}: + return None + title = node.get("title") if isinstance(node.get("title"), dict) else {} + synonyms = node.get("synonyms") if isinstance(node.get("synonyms"), list) else [] + return { + "id": int(media_id), + "title": { + "romaji": str(title.get("romaji") or "").strip(), + "english": str(title.get("english") or "").strip(), + "native": str(title.get("native") or "").strip(), + "userPreferred": str(title.get("userPreferred") or "").strip(), + }, + "synonyms": [str(value or "").strip() for value in synonyms if str(value or "").strip()], + "siteUrl": str(node.get("siteUrl") or "").strip(), + } + + +def anilist_title_candidates(entry): + if not isinstance(entry, dict): + return [] + values = [] + title = entry.get("title") if isinstance(entry.get("title"), dict) else {} + for key in ("romaji", "english", "native", "userPreferred"): + text = str(title.get(key) or "").strip() + if text: + values.append(text) + for value in entry.get("synonyms") or []: + text = str(value or "").strip() + if text: + values.append(text) + deduped = [] + seen = set() + for value in values: + normalized = normalize_title_key(value) + if normalized and normalized not in seen: + seen.add(normalized) + deduped.append(value) + return deduped + + +def score_anilist_match(query_variants, entry): + best = None + for candidate in anilist_title_candidates(entry): + normalized = normalize_title_key(candidate) + if not normalized: + continue + score = None + if normalized in query_variants: + score = 110 + else: + for variant in query_variants: + if variant and (normalized in variant or variant in normalized): + score = max(score or 0, 80 - abs(len(normalized) - len(variant))) + if score is not None: + best = max(best or score, score) + return best + + +def extract_anilist_alternative_titles(entry, primary_title=""): + if not isinstance(entry, dict): + return [] + primary_key = normalize_title_key(primary_title) or str(primary_title or "").strip().casefold() + results = [] + seen = set() + title = entry.get("title") if isinstance(entry.get("title"), dict) else {} + for key, label in (("romaji", "Romaji"), ("english", "English"), ("native", "Native")): + text = str(title.get(key) or "").strip() + normalized = normalize_title_key(text) or text.casefold() + if not normalized or normalized == primary_key or normalized in seen: + continue + seen.add(normalized) + results.append({"label": label, "value": text}) + for synonym in entry.get("synonyms") or []: + text = str(synonym or "").strip() + normalized = normalize_title_key(text) or text.casefold() + if not normalized or normalized == primary_key or normalized in seen: + continue + seen.add(normalized) + results.append({"label": "Synonym", "value": text}) + return results + + +def encode_alternative_titles(alternative_titles): + normalized = [] + for item in alternative_titles or []: + if not isinstance(item, dict): + continue + label = str(item.get("label") or "").strip() + value = str(item.get("value") or "").strip() + if label and value: + normalized.append({"label": label, "value": value}) + return json.dumps(normalized, ensure_ascii=True) + + +def decode_alternative_titles(payload): + if not payload: + return [] + try: + raw = json.loads(payload) + except (TypeError, ValueError, json.JSONDecodeError): + return [] + normalized = [] + seen = set() + for item in raw if isinstance(raw, list) else []: + if not isinstance(item, dict): + continue + label = str(item.get("label") or "").strip() + value = str(item.get("value") or "").strip() + normalized_key = (label.lower(), normalize_title_key(value) or value.casefold()) + if not label or not value or not normalized_key[1] or normalized_key in seen: + continue + seen.add(normalized_key) + normalized.append({"label": label, "value": value}) + return normalized + + +def fetch_anilist_media_by_id(media_id, timeout=25): + data = anilist_request( + """ + query ($id: Int) { + Media(id: $id, type: ANIME) { + id + siteUrl + title { + romaji + english + native + userPreferred + } + synonyms + } + } + """, + {"id": int(media_id)}, + timeout=timeout, + ) + entry = normalize_anilist_entry(data.get("Media")) + if not entry: + raise RuntimeError("AniList media response was missing required fields") + return entry + + +def resolve_anilist_media(title, media_id=None, timeout=25): + stored_media_id = str(media_id or "").strip() + if stored_media_id: + try: + return fetch_anilist_media_by_id(int(stored_media_id), timeout=timeout) + except Exception: + debug_log("anilist.media_refresh_failed", media_id=stored_media_id, title=title) + + query_variants = title_match_variants(title) + if not query_variants: + raise RuntimeError("Missing title for AniList lookup") + + queries = title_lookup_queries(title) + best = None + for query in queries: + data = anilist_request( + """ + query ($search: String, $page: Int, $perPage: Int) { + Page(page: $page, perPage: $perPage) { + media(search: $search, type: ANIME) { + id + siteUrl + title { + romaji + english + native + userPreferred + } + synonyms + } + } + } + """, + {"search": query, "page": 1, "perPage": 8}, + timeout=timeout, + ) + for node in ((data.get("Page") or {}).get("media") or []): + entry = normalize_anilist_entry(node) + if not entry: + continue + score = score_anilist_match(query_variants, entry) + if score is None: + continue + if best is None or score > best["score"]: + best = {"entry": entry, "score": score} + if best is not None: + break + + if best is None: + raise RuntimeError("AniList lookup did not find a matching anime") + return best["entry"] + + def parse_nonnegative_int(value, default=0): try: number = int(value) @@ -1028,6 +1259,10 @@ class WatchlistStore: dub_latest_episode TEXT, animeschedule_route TEXT, animeschedule_title TEXT, + anilist_id INTEGER, + anilist_title TEXT, + anilist_site_url TEXT, + alternative_titles_json TEXT, anidb_aid TEXT, anidb_title TEXT, thumbnail_path TEXT, @@ -1056,6 +1291,14 @@ class WatchlistStore: conn.execute("ALTER TABLE watchlist ADD COLUMN animeschedule_route TEXT") if "animeschedule_title" not in columns: conn.execute("ALTER TABLE watchlist ADD COLUMN animeschedule_title TEXT") + if "anilist_id" not in columns: + conn.execute("ALTER TABLE watchlist ADD COLUMN anilist_id INTEGER") + if "anilist_title" not in columns: + conn.execute("ALTER TABLE watchlist ADD COLUMN anilist_title TEXT") + if "anilist_site_url" not in columns: + conn.execute("ALTER TABLE watchlist ADD COLUMN anilist_site_url TEXT") + if "alternative_titles_json" not in columns: + conn.execute("ALTER TABLE watchlist ADD COLUMN alternative_titles_json TEXT") conn.execute( "UPDATE watchlist SET category = 'watching' WHERE category IS NULL OR TRIM(category) = ''" ) @@ -1084,6 +1327,8 @@ class WatchlistStore: thumb_path = thumbnail_file_path(item.get("thumbnail_path")) item["thumbnail_ready"] = bool(thumb_path and thumb_path.exists()) item["thumbnail_url"] = cover_route(item.get("show_id")) + item["alternative_titles"] = decode_alternative_titles(item.get("alternative_titles_json")) + item["has_alternative_titles"] = bool(item["alternative_titles"]) if item.get("animeschedule_route"): title = str(item.get("animeschedule_title") or item.get("title") or "").strip() or "unknown title" item["thumbnail_debug"] = f"AnimeSchedule: {item['animeschedule_route']} - {title}" @@ -1103,9 +1348,11 @@ class WatchlistStore: show_id, title, category, downloaded, status, status_message, created_at, updated_at, last_checked, sub_count, dub_count, expected_count, airing_status, sub_latest_episode, dub_latest_episode, - animeschedule_route, animeschedule_title, anidb_aid, anidb_title, + animeschedule_route, animeschedule_title, + anilist_id, anilist_title, anilist_site_url, alternative_titles_json, + anidb_aid, anidb_title, thumbnail_path, thumbnail_checked_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(show_id) DO UPDATE SET title = excluded.title, category = excluded.category, @@ -1122,6 +1369,10 @@ class WatchlistStore: dub_latest_episode = excluded.dub_latest_episode, animeschedule_route = excluded.animeschedule_route, animeschedule_title = excluded.animeschedule_title, + anilist_id = excluded.anilist_id, + anilist_title = excluded.anilist_title, + anilist_site_url = excluded.anilist_site_url, + alternative_titles_json = excluded.alternative_titles_json, anidb_aid = excluded.anidb_aid, anidb_title = excluded.anidb_title, thumbnail_path = excluded.thumbnail_path, @@ -1145,6 +1396,10 @@ class WatchlistStore: item.get("dub_latest_episode"), item.get("animeschedule_route"), item.get("animeschedule_title"), + item.get("anilist_id"), + item.get("anilist_title"), + item.get("anilist_site_url"), + item.get("alternative_titles_json"), item.get("anidb_aid"), item.get("anidb_title"), item.get("thumbnail_path"), @@ -1190,6 +1445,10 @@ class WatchlistStore: "dub_latest_episode": None, "animeschedule_route": None, "animeschedule_title": None, + "anilist_id": None, + "anilist_title": None, + "anilist_site_url": None, + "alternative_titles_json": None, "anidb_aid": None, "anidb_title": None, "thumbnail_path": None, @@ -1315,6 +1574,10 @@ class WatchlistStore: "dub_latest_episode": None, "animeschedule_route": None, "animeschedule_title": None, + "anilist_id": None, + "anilist_title": None, + "anilist_site_url": None, + "alternative_titles_json": None, "anidb_aid": None, "anidb_title": None, "thumbnail_path": None, @@ -1349,12 +1612,22 @@ class WatchlistStore: ) except Exception: schedule = None + anilist = None + try: + anilist = resolve_anilist_media( + snapshot["title"] or existing["title"], + media_id=existing.get("anilist_id"), + timeout=request_timeout, + ) + except Exception: + anilist = None if self.refresh_stop_event.is_set(): return None if not self.has_show(normalized_show_id): return None expected_count = parse_nonnegative_int((schedule or {}).get("episodes")) or int(existing.get("expected_count") or 0) airing_status = normalize_animeschedule_status((schedule or {}).get("status") or existing.get("airing_status")) + alternative_titles = extract_anilist_alternative_titles(anilist, primary_title=snapshot["title"] or existing["title"]) payload = ( snapshot["title"] or existing["title"], "updated", @@ -1374,6 +1647,15 @@ class WatchlistStore: dub_episodes[-1] if dub_episodes else None, (schedule or {}).get("route") or existing.get("animeschedule_route"), (schedule or {}).get("title") or existing.get("animeschedule_title"), + (anilist or {}).get("id") or existing.get("anilist_id"), + ( + ((anilist or {}).get("title") or {}).get("userPreferred") + or ((anilist or {}).get("title") or {}).get("romaji") + or ((anilist or {}).get("title") or {}).get("english") + or existing.get("anilist_title") + ), + (anilist or {}).get("siteUrl") or existing.get("anilist_site_url"), + encode_alternative_titles(alternative_titles) if anilist is not None else existing.get("alternative_titles_json"), normalized_show_id, ) except Exception as exc: @@ -1395,6 +1677,10 @@ class WatchlistStore: existing.get("dub_latest_episode"), existing.get("animeschedule_route"), existing.get("animeschedule_title"), + existing.get("anilist_id"), + existing.get("anilist_title"), + existing.get("anilist_site_url"), + existing.get("alternative_titles_json"), normalized_show_id, ) @@ -1417,7 +1703,11 @@ class WatchlistStore: sub_latest_episode = ?, dub_latest_episode = ?, animeschedule_route = ?, - animeschedule_title = ? + animeschedule_title = ?, + anilist_id = ?, + anilist_title = ?, + anilist_site_url = ?, + alternative_titles_json = ? WHERE show_id = ? """, payload, @@ -1535,6 +1825,10 @@ class WatchlistStore: "dub_latest_episode": None, "animeschedule_route": None, "animeschedule_title": None, + "anilist_id": None, + "anilist_title": None, + "anilist_site_url": None, + "alternative_titles_json": None, "anidb_aid": None, "anidb_title": None, "thumbnail_path": None, diff --git a/app_support.py b/app_support.py index bebb7a7..5118710 100644 --- a/app_support.py +++ b/app_support.py @@ -19,13 +19,14 @@ from uuid import uuid4 PROJECT_ROOT = Path(__file__).resolve().parent ANI_CLI = os.environ.get("ANI_CLI_BIN") or shutil.which("ani-cli") or "ani-cli" APP_NAME = "ani-cli-web" -VERSION = "0.36.3" +VERSION = "0.37.0" ALLANIME_BASE = "allanime.day" ALLANIME_API = f"https://api.{ALLANIME_BASE}" ALLANIME_REFERER = "https://allmanga.to" ALLMANGA_SITE_BASE = "https://allmanga.to" ANIMESCHEDULE_API = "https://animeschedule.net/api/v3" ANIMESCHEDULE_IMAGE_BASE = "https://img.animeschedule.net/production/assets/public/img/" +ANILIST_API = "https://graphql.anilist.co" ANIDB_TITLES_URL = "https://anidb.net/api/anime-titles.xml.gz" ANIDB_ANIME_PAGE = "https://anidb.net/anime/{aid}" AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0" diff --git a/test_app.py b/test_app.py index 4790da0..4bd7dbf 100644 --- a/test_app.py +++ b/test_app.py @@ -621,6 +621,64 @@ class WatchlistRefreshJobTests(unittest.TestCase): self.assertIn("restart", status["message"].lower()) +class WatchlistAniListTests(unittest.TestCase): + def setUp(self): + APP.reset_runtime(wait=True) + APP.initialize_runtime(start_workers=False) + self.watchlist = APP.WATCHLIST + with self.watchlist._connect() as conn: + conn.execute("DELETE FROM watchlist") + + def test_watchlist_schema_includes_anilist_columns(self): + with self.watchlist._connect() as conn: + columns = {row["name"] for row in conn.execute("PRAGMA table_info(watchlist)").fetchall()} + + self.assertIn("anilist_id", columns) + self.assertIn("anilist_title", columns) + self.assertIn("anilist_site_url", columns) + self.assertIn("alternative_titles_json", columns) + + def test_refresh_caches_anilist_alternative_titles(self): + self.watchlist.add({"show_id": "show-1", "title": "Attack on Titan", "category": "watching"}) + + with mock.patch.object( + APP, + "fetch_show_episode_snapshot", + return_value={"show_id": "show-1", "title": "Attack on Titan", "episodes": {"sub": ["1", "2"], "dub": ["1"]}}, + ), mock.patch.object( + APP, + "resolve_animeschedule_anime", + return_value={"route": "attack-on-titan", "title": "Attack on Titan", "episodes": 2, "status": "Finished"}, + ), mock.patch.object( + APP, + "resolve_anilist_media", + return_value={ + "id": 16498, + "siteUrl": "https://anilist.co/anime/16498", + "title": { + "romaji": "Shingeki no Kyojin", + "english": "Attack on Titan", + "native": "進撃の巨人", + "userPreferred": "Attack on Titan", + }, + "synonyms": ["AoT"], + }, + ): + item = self.watchlist.refresh("show-1", include_thumbnail=False) + + self.assertEqual(item["anilist_id"], 16498) + self.assertEqual(item["anilist_site_url"], "https://anilist.co/anime/16498") + self.assertEqual( + item["alternative_titles"], + [ + {"label": "Romaji", "value": "Shingeki no Kyojin"}, + {"label": "Native", "value": "進撃の巨人"}, + {"label": "Synonym", "value": "AoT"}, + ], + ) + self.assertTrue(item["has_alternative_titles"]) + + class ThumbnailEventRecoveryTests(unittest.TestCase): def test_ensure_thumbnails_skips_show_removed_during_batch(self): store = object.__new__(APP.WatchlistStore) @@ -1779,6 +1837,12 @@ class TemplateHelperTests(unittest.TestCase): self.assertIn('downloadWatchlistItem(item, "sub"', APP.WATCHLIST_HTML) self.assertIn('downloadWatchlistItem(item, "dub"', APP.WATCHLIST_HTML) + def test_watchlist_page_renders_alternative_titles_toggle(self): + self.assertIn("Alternative titles", APP.WATCHLIST_HTML) + self.assertIn('class="ghost alt-titles-toggle"', APP.WATCHLIST_HTML) + self.assertIn('altTitlesPanel.classList.toggle("open")', APP.WATCHLIST_HTML) + self.assertIn("AniList alternative titles were not available for this show.", APP.WATCHLIST_HTML) + def test_watchlist_thumbnail_warmup_skips_repeated_attempts(self): self.assertIn("thumbnailWarmupPending: new Set()", APP.WATCHLIST_HTML) self.assertIn("function thumbnailWarmupToken(item)", APP.WATCHLIST_HTML) diff --git a/watchlist_page.py b/watchlist_page.py index 18a3807..d2e1354 100644 --- a/watchlist_page.py +++ b/watchlist_page.py @@ -501,6 +501,52 @@ WATCHLIST_HTML = r""" line-height: 1.35; white-space: pre-line; } + .alt-titles { + display: grid; + gap: 8px; + } + .alt-titles-toggle { + width: 100%; + min-height: 34px; + padding: 0 10px; + font-size: 12px; + font-weight: 700; + justify-content: center; + } + .alt-titles-panel { + display: none; + gap: 8px; + border: 1px solid var(--line); + border-radius: 14px; + background: rgba(255, 255, 255, 0.04); + padding: 10px; + } + .alt-titles-panel.open { + display: grid; + } + .alt-titles-list { + display: flex; + flex-wrap: wrap; + gap: 6px; + } + .alt-title-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 9px; + border-radius: 999px; + border: 1px solid var(--line); + background: rgba(157, 123, 255, 0.08); + font-size: 11px; + line-height: 1.3; + color: var(--text); + } + .alt-title-pill strong { + color: var(--muted); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.06em; + } .category-editor { display: grid; gap: 6px; @@ -1008,6 +1054,13 @@ WATCHLIST_HTML = r""" Dub ${formatModeProgress(item.dub_count, item.expected_count)}
+
+ +
+
+
+
+