Add AniList alternative titles to watchlist

This commit is contained in:
Dymas
2026-05-25 09:10:42 +02:00
parent 643408b19d
commit 64979d5d32
7 changed files with 448 additions and 6 deletions
+297 -3
View File
@@ -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,