From 46af0ff47d6b933e3c2977843758da5560f02f0c Mon Sep 17 00:00:00 2001 From: Dymas Date: Mon, 25 May 2026 09:24:57 +0200 Subject: [PATCH] Use AniDB for watchlist alternative titles --- CHANGELOG.md | 5 + README.md | 4 +- VERSION | 2 +- app.py | 263 +++++++++------------------------------------- app_support.py | 3 +- test_app.py | 42 ++++---- watchlist_page.py | 4 +- 7 files changed, 79 insertions(+), 244 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8cdc0b..7277a61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.38.0 - 2026-05-25 + +- Switched watchlist `Alternative titles` from AniList lookups to AniDB title-dump matching and now keep only English alternate titles. +- Reused the cached AniDB title metadata already present in the app, which makes alternate-title matching more predictable for watchlist refreshes. + ## 0.37.0 - 2026-05-25 - Added an `Alternative titles` toggle to every watchlist card so AniList title variants can be expanded inline. diff --git a/README.md b/README.md index f1ed9a8..cbf653c 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.37.0` +Current version: `0.38.0` ## Project Layout @@ -220,7 +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. +9. `Alternative titles` expands English alternate titles from AniDB when the watchlist refresh can match the show to an AniDB entry. Status behavior: diff --git a/VERSION b/VERSION index 0f1a7df..ca75280 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.37.0 +0.38.0 diff --git a/app.py b/app.py index 6f22cf3..041cb6a 100644 --- a/app.py +++ b/app.py @@ -25,7 +25,6 @@ from app_support import ( ALLANIME_API, ALLANIME_REFERER, ALLMANGA_SITE_BASE, - ANILIST_API, ANIDB_ANIME_PAGE, ANIDB_TITLES_CACHE, ANIDB_TITLES_PATH, @@ -231,123 +230,6 @@ 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 []: @@ -382,83 +264,32 @@ def decode_alternative_titles(payload): 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 extract_anidb_alternative_titles(aid, primary_title=""): + normalized_aid = str(aid or "").strip() + if not normalized_aid: + return [] + primary_key = normalize_title_key(primary_title) or str(primary_title or "").strip().casefold() + results = [] + seen = set() + type_labels = { + "official": "English", + "main": "English", + "short": "English", + "syn": "English", + } + for entry in get_cached_anidb_titles(): + if str(entry.get("aid") or "").strip() != normalized_aid: + continue + if str(entry.get("lang") or "").strip().lower() != "en": + continue + text = str(entry.get("title") 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) + label = type_labels.get(str(entry.get("type") or "").strip().lower(), "English") + results.append({"label": label, "value": text}) + return results def parse_nonnegative_int(value, default=0): @@ -1612,22 +1443,27 @@ class WatchlistStore: ) except Exception: schedule = None - anilist = None + anidb_match = None try: - anilist = resolve_anilist_media( - snapshot["title"] or existing["title"], - media_id=existing.get("anilist_id"), - timeout=request_timeout, - ) + if existing.get("anidb_aid"): + anidb_match = { + "aid": str(existing.get("anidb_aid")).strip(), + "title": str(existing.get("anidb_title") or snapshot["title"] or existing["title"]).strip(), + } + else: + anidb_match = resolve_anidb_aid(snapshot["title"] or existing["title"]) except Exception: - anilist = None + anidb_match = 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"]) + alternative_titles = extract_anidb_alternative_titles( + (anidb_match or {}).get("aid"), + primary_title=snapshot["title"] or existing["title"], + ) payload = ( snapshot["title"] or existing["title"], "updated", @@ -1647,15 +1483,12 @@ 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"), + existing.get("anilist_id"), + existing.get("anilist_title"), + existing.get("anilist_site_url"), + encode_alternative_titles(alternative_titles) if anidb_match is not None else existing.get("alternative_titles_json"), + (anidb_match or {}).get("aid") or existing.get("anidb_aid"), + (anidb_match or {}).get("title") or existing.get("anidb_title"), normalized_show_id, ) except Exception as exc: @@ -1681,6 +1514,8 @@ class WatchlistStore: existing.get("anilist_title"), existing.get("anilist_site_url"), existing.get("alternative_titles_json"), + existing.get("anidb_aid"), + existing.get("anidb_title"), normalized_show_id, ) @@ -1707,7 +1542,9 @@ class WatchlistStore: anilist_id = ?, anilist_title = ?, anilist_site_url = ?, - alternative_titles_json = ? + alternative_titles_json = ?, + anidb_aid = ?, + anidb_title = ? WHERE show_id = ? """, payload, diff --git a/app_support.py b/app_support.py index 5118710..70f65f3 100644 --- a/app_support.py +++ b/app_support.py @@ -19,14 +19,13 @@ 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.37.0" +VERSION = "0.38.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 4bd7dbf..d4605f2 100644 --- a/test_app.py +++ b/test_app.py @@ -621,7 +621,7 @@ class WatchlistRefreshJobTests(unittest.TestCase): self.assertIn("restart", status["message"].lower()) -class WatchlistAniListTests(unittest.TestCase): +class WatchlistAlternativeTitleTests(unittest.TestCase): def setUp(self): APP.reset_runtime(wait=True) APP.initialize_runtime(start_workers=False) @@ -629,16 +629,13 @@ class WatchlistAniListTests(unittest.TestCase): with self.watchlist._connect() as conn: conn.execute("DELETE FROM watchlist") - def test_watchlist_schema_includes_anilist_columns(self): + def test_watchlist_schema_includes_alternative_title_column(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): + def test_refresh_caches_english_anidb_alternative_titles(self): self.watchlist.add({"show_id": "show-1", "title": "Attack on Titan", "category": "watching"}) with mock.patch.object( @@ -651,29 +648,26 @@ class WatchlistAniListTests(unittest.TestCase): 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"], - }, + "resolve_anidb_aid", + return_value={"aid": "16498", "title": "Attack on Titan"}, + ), mock.patch.object( + APP, + "get_cached_anidb_titles", + return_value=[ + {"aid": "16498", "title": "Attack on Titan", "type": "official", "lang": "en", "normalized_title": "attack on titan"}, + {"aid": "16498", "title": "AoT", "type": "syn", "lang": "en", "normalized_title": "aot"}, + {"aid": "16498", "title": "Shingeki no Kyojin", "type": "official", "lang": "x-jat", "normalized_title": "shingeki no kyojin"}, + {"aid": "16498", "title": "Attack Titan", "type": "short", "lang": "en", "normalized_title": "attack titan"}, + ], ): 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["anidb_aid"], "16498") self.assertEqual( item["alternative_titles"], [ - {"label": "Romaji", "value": "Shingeki no Kyojin"}, - {"label": "Native", "value": "進撃の巨人"}, - {"label": "Synonym", "value": "AoT"}, + {"label": "English", "value": "AoT"}, + {"label": "English", "value": "Attack Titan"}, ], ) self.assertTrue(item["has_alternative_titles"]) @@ -1841,7 +1835,7 @@ class TemplateHelperTests(unittest.TestCase): 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) + self.assertIn("AniDB English 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) diff --git a/watchlist_page.py b/watchlist_page.py index d2e1354..13d472b 100644 --- a/watchlist_page.py +++ b/watchlist_page.py @@ -1118,9 +1118,9 @@ WATCHLIST_HTML = r""" pill.append(label, value); altTitlesList.appendChild(pill); } - altTitlesEmpty.textContent = "Fetched from AniList."; + altTitlesEmpty.textContent = "Fetched from AniDB English titles."; } else { - altTitlesEmpty.textContent = "AniList alternative titles were not available for this show."; + altTitlesEmpty.textContent = "AniDB English alternative titles were not available for this show."; } altTitlesToggle.addEventListener("click", () => { const open = altTitlesPanel.classList.toggle("open");