Add AniList alternative titles to watchlist
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+2
-1
@@ -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"
|
||||
|
||||
+64
@@ -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)
|
||||
|
||||
@@ -501,6 +501,52 @@ WATCHLIST_HTML = r"""<!doctype html>
|
||||
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"""<!doctype html>
|
||||
<span class="count-pill${item.dub_complete ? " complete" : ""}">Dub <strong>${formatModeProgress(item.dub_count, item.expected_count)}</strong></span>
|
||||
</div>
|
||||
<div class="status-row"></div>
|
||||
<div class="alt-titles">
|
||||
<button class="ghost alt-titles-toggle" type="button" aria-expanded="false">Alternative titles</button>
|
||||
<div class="alt-titles-panel">
|
||||
<div class="alt-titles-list"></div>
|
||||
<div class="muted alt-titles-empty"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="category-editor">
|
||||
<label>Category
|
||||
<select class="category-select">${categoryOptions(item.category)}</select>
|
||||
@@ -1049,6 +1102,30 @@ WATCHLIST_HTML = r"""<!doctype html>
|
||||
if (item.dub_complete) {
|
||||
appendStatusTag(statusRow, "Dub ready", "complete");
|
||||
}
|
||||
const altTitlesToggle = card.querySelector(".alt-titles-toggle");
|
||||
const altTitlesPanel = card.querySelector(".alt-titles-panel");
|
||||
const altTitlesList = card.querySelector(".alt-titles-list");
|
||||
const altTitlesEmpty = card.querySelector(".alt-titles-empty");
|
||||
const alternativeTitles = Array.isArray(item.alternative_titles) ? item.alternative_titles : [];
|
||||
if (alternativeTitles.length) {
|
||||
for (const title of alternativeTitles) {
|
||||
const pill = document.createElement("span");
|
||||
pill.className = "alt-title-pill";
|
||||
const label = document.createElement("strong");
|
||||
label.textContent = title.label;
|
||||
const value = document.createElement("span");
|
||||
value.textContent = title.value;
|
||||
pill.append(label, value);
|
||||
altTitlesList.appendChild(pill);
|
||||
}
|
||||
altTitlesEmpty.textContent = "Fetched from AniList.";
|
||||
} else {
|
||||
altTitlesEmpty.textContent = "AniList alternative titles were not available for this show.";
|
||||
}
|
||||
altTitlesToggle.addEventListener("click", () => {
|
||||
const open = altTitlesPanel.classList.toggle("open");
|
||||
altTitlesToggle.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
});
|
||||
card.querySelector(".card-date").textContent = item.status_message
|
||||
? `Last checked: ${checked}\n${item.status_message}`
|
||||
: `Last checked: ${checked}`;
|
||||
|
||||
Reference in New Issue
Block a user