Compare commits
3
Commits
8aa490adf8
...
2ff4e390c4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ff4e390c4 | ||
|
|
bead7da60a | ||
|
|
5b0f4043ed |
@@ -1,17 +1,5 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 0.37.2 - 2026-05-23
|
|
||||||
|
|
||||||
- Added lazy watchlist alt-title backfill for visible cards, so older entries can fetch and show the new `Alternative titles` button without requiring a manual refresh first.
|
|
||||||
|
|
||||||
## 0.37.1 - 2026-05-23
|
|
||||||
|
|
||||||
- Fixed AllManga alternative title parsing to read the visible `span.altnames` markup and changed watchlist cards to reveal those titles only when the new `Alternative titles` button is pressed.
|
|
||||||
|
|
||||||
## 0.37.0 - 2026-05-23
|
|
||||||
|
|
||||||
- Added watchlist support for alternative English and synonym titles pulled from the AllManga show page and shown directly on each watchlist card.
|
|
||||||
|
|
||||||
## 0.36.1 - 2026-05-23
|
## 0.36.1 - 2026-05-23
|
||||||
|
|
||||||
- Replaced the first-visit remote-access 403 dead end with a browser sign-in page and persistent auth cookie, so remote users no longer need to know the `?token=...` bootstrap URL format up front.
|
- Replaced the first-visit remote-access 403 dead end with a browser sign-in page and persistent auth cookie, so remote users no longer need to know the `?token=...` bootstrap URL format up front.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Small local web UI for a system-wide `ani-cli` install.
|
Small local web UI for a system-wide `ani-cli` install.
|
||||||
|
|
||||||
Current version: `0.37.2`
|
Current version: `0.36.1`
|
||||||
|
|
||||||
## Project Layout
|
## Project Layout
|
||||||
|
|
||||||
@@ -238,13 +238,6 @@ Thumbnail behavior:
|
|||||||
|
|
||||||
- Thumbnails are served from the local `/api/watchlist/thumb/...` route.
|
- Thumbnails are served from the local `/api/watchlist/thumb/...` route.
|
||||||
- Cached thumbnails are reused.
|
- Cached thumbnails are reused.
|
||||||
|
|
||||||
Title behavior:
|
|
||||||
|
|
||||||
- Watchlist refreshes also try to pull alternative English and synonym titles from the AllManga show page.
|
|
||||||
- Watchlist cards show an `Alternative titles` button when names are available.
|
|
||||||
- Pressing that button expands the stored alternative titles on demand.
|
|
||||||
- Visible watchlist cards also try to backfill missing alternative titles automatically, so older entries do not need a manual refresh just to get the button.
|
|
||||||
- Missing covers are warmed in small batches.
|
- Missing covers are warmed in small batches.
|
||||||
- Failed lookups back off before retrying.
|
- Failed lookups back off before retrying.
|
||||||
- You can force `Reload` or use `Upload` when no thumbnail exists.
|
- You can force `Reload` or use `Upload` when no thumbnail exists.
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import base64
|
import base64
|
||||||
import gzip
|
import gzip
|
||||||
import html
|
|
||||||
import json
|
import json
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
@@ -147,105 +146,6 @@ def anime_source_url(show_id, title):
|
|||||||
return f"{ALLMANGA_SITE_BASE}/bangumi/{quote(normalized_show_id, safe='')}"
|
return f"{ALLMANGA_SITE_BASE}/bangumi/{quote(normalized_show_id, safe='')}"
|
||||||
|
|
||||||
|
|
||||||
def unique_title_values(values, *exclude_values):
|
|
||||||
seen = {
|
|
||||||
normalize_title_key(value)
|
|
||||||
for value in exclude_values
|
|
||||||
if normalize_title_key(value)
|
|
||||||
}
|
|
||||||
deduped = []
|
|
||||||
for value in values:
|
|
||||||
text = str(value or "").strip()
|
|
||||||
normalized = normalize_title_key(text)
|
|
||||||
if not text or not normalized or normalized in seen:
|
|
||||||
continue
|
|
||||||
seen.add(normalized)
|
|
||||||
deduped.append(text)
|
|
||||||
return deduped
|
|
||||||
|
|
||||||
|
|
||||||
def parse_allmanga_alternative_titles(html_text, primary_title=""):
|
|
||||||
text = str(html_text or "")
|
|
||||||
primary = str(primary_title or "").strip()
|
|
||||||
candidates = []
|
|
||||||
preferred_english = ""
|
|
||||||
|
|
||||||
def add_candidate(value, english_preferred=False):
|
|
||||||
nonlocal preferred_english
|
|
||||||
cleaned = html.unescape(str(value or "")).replace('\\"', '"').strip()
|
|
||||||
if not cleaned:
|
|
||||||
return
|
|
||||||
candidates.append(cleaned)
|
|
||||||
if english_preferred and not preferred_english:
|
|
||||||
preferred_english = cleaned
|
|
||||||
|
|
||||||
quoted_key_patterns = (
|
|
||||||
(r'"english(?:Title|Name)?"\s*:\s*"([^"]+)"', True),
|
|
||||||
(r'"(?:altEnglish|alternativeEnglish(?:Title|Name)?)"\s*:\s*"([^"]+)"', True),
|
|
||||||
(r'"romaji(?:Title|Name)?"\s*:\s*"([^"]+)"', False),
|
|
||||||
(r'"native(?:Title|Name)?"\s*:\s*"([^"]+)"', False),
|
|
||||||
)
|
|
||||||
for pattern, english_preferred in quoted_key_patterns:
|
|
||||||
for match in re.findall(pattern, text, flags=re.IGNORECASE):
|
|
||||||
add_candidate(match, english_preferred=english_preferred)
|
|
||||||
|
|
||||||
array_key_patterns = (
|
|
||||||
(r'"synonyms"\s*:\s*\[(.*?)\]', False),
|
|
||||||
(r'"alternative(?:Names|Titles)?"\s*:\s*\[(.*?)\]', False),
|
|
||||||
(r'"alt(?:Titles|Names)?"\s*:\s*\[(.*?)\]', False),
|
|
||||||
)
|
|
||||||
for pattern, english_preferred in array_key_patterns:
|
|
||||||
for body in re.findall(pattern, text, flags=re.IGNORECASE | re.DOTALL):
|
|
||||||
for match in re.findall(r'"([^"]+)"', body):
|
|
||||||
add_candidate(match, english_preferred=english_preferred)
|
|
||||||
|
|
||||||
for match in re.findall(
|
|
||||||
r'<span[^>]*class="[^"]*\baltnames\b[^"]*"[^>]*>\s*(.*?)\s*</span>',
|
|
||||||
text,
|
|
||||||
flags=re.IGNORECASE | re.DOTALL,
|
|
||||||
):
|
|
||||||
cleaned = re.sub(r"<[^>]+>", " ", match)
|
|
||||||
cleaned = cleaned.replace(" ", " ")
|
|
||||||
cleaned = re.sub(r"^[\s\u25cf\u26aa\u2022]+", "", cleaned)
|
|
||||||
add_candidate(cleaned, english_preferred=not preferred_english)
|
|
||||||
|
|
||||||
deduped = unique_title_values(candidates, primary)
|
|
||||||
english_title = ""
|
|
||||||
for candidate in unique_title_values([preferred_english], primary):
|
|
||||||
english_title = candidate
|
|
||||||
break
|
|
||||||
if not english_title and deduped:
|
|
||||||
english_title = deduped[0]
|
|
||||||
return {
|
|
||||||
"english_title": english_title,
|
|
||||||
"alt_titles": deduped,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_allmanga_alternative_titles(show_id, title="", timeout=25):
|
|
||||||
url = anime_source_url(show_id, title)
|
|
||||||
request = urllib.request.Request(
|
|
||||||
url,
|
|
||||||
headers={
|
|
||||||
"Accept": "text/html,application/xhtml+xml",
|
|
||||||
"Referer": ALLANIME_REFERER,
|
|
||||||
"User-Agent": AGENT,
|
|
||||||
},
|
|
||||||
method="GET",
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
||||||
raw = response.read().decode("utf-8", errors="replace")
|
|
||||||
except urllib.error.URLError as exc:
|
|
||||||
raise RuntimeError(f"Could not reach AllManga page: {exc}") from exc
|
|
||||||
except TimeoutError as exc:
|
|
||||||
raise RuntimeError("AllManga page request timed out") from exc
|
|
||||||
parsed = parse_allmanga_alternative_titles(raw, primary_title=title)
|
|
||||||
if not parsed["alt_titles"] and not parsed["english_title"]:
|
|
||||||
raise RuntimeError("AllManga page did not expose any alternative titles")
|
|
||||||
return parsed
|
|
||||||
|
|
||||||
|
|
||||||
def infer_image_extension(url, content_type):
|
def infer_image_extension(url, content_type):
|
||||||
normalized_type = str(content_type or "").split(";", 1)[0].strip().lower()
|
normalized_type = str(content_type or "").split(";", 1)[0].strip().lower()
|
||||||
extension = mimetypes.guess_extension(normalized_type, strict=False)
|
extension = mimetypes.guess_extension(normalized_type, strict=False)
|
||||||
@@ -1128,8 +1028,6 @@ class WatchlistStore:
|
|||||||
dub_latest_episode TEXT,
|
dub_latest_episode TEXT,
|
||||||
animeschedule_route TEXT,
|
animeschedule_route TEXT,
|
||||||
animeschedule_title TEXT,
|
animeschedule_title TEXT,
|
||||||
english_title TEXT,
|
|
||||||
alt_titles_json TEXT NOT NULL DEFAULT '[]',
|
|
||||||
anidb_aid TEXT,
|
anidb_aid TEXT,
|
||||||
anidb_title TEXT,
|
anidb_title TEXT,
|
||||||
thumbnail_path TEXT,
|
thumbnail_path TEXT,
|
||||||
@@ -1158,10 +1056,6 @@ class WatchlistStore:
|
|||||||
conn.execute("ALTER TABLE watchlist ADD COLUMN animeschedule_route TEXT")
|
conn.execute("ALTER TABLE watchlist ADD COLUMN animeschedule_route TEXT")
|
||||||
if "animeschedule_title" not in columns:
|
if "animeschedule_title" not in columns:
|
||||||
conn.execute("ALTER TABLE watchlist ADD COLUMN animeschedule_title TEXT")
|
conn.execute("ALTER TABLE watchlist ADD COLUMN animeschedule_title TEXT")
|
||||||
if "english_title" not in columns:
|
|
||||||
conn.execute("ALTER TABLE watchlist ADD COLUMN english_title TEXT")
|
|
||||||
if "alt_titles_json" not in columns:
|
|
||||||
conn.execute("ALTER TABLE watchlist ADD COLUMN alt_titles_json TEXT NOT NULL DEFAULT '[]'")
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE watchlist SET category = 'watching' WHERE category IS NULL OR TRIM(category) = ''"
|
"UPDATE watchlist SET category = 'watching' WHERE category IS NULL OR TRIM(category) = ''"
|
||||||
)
|
)
|
||||||
@@ -1183,22 +1077,6 @@ class WatchlistStore:
|
|||||||
item["dub_progress"] = format_episode_progress(item["dub_count"], item["expected_count"])
|
item["dub_progress"] = format_episode_progress(item["dub_count"], item["expected_count"])
|
||||||
item["sub_complete"] = mode_is_complete(item["sub_count"], item["expected_count"], item["airing_status"])
|
item["sub_complete"] = mode_is_complete(item["sub_count"], item["expected_count"], item["airing_status"])
|
||||||
item["dub_complete"] = mode_is_complete(item["dub_count"], item["expected_count"], item["airing_status"])
|
item["dub_complete"] = mode_is_complete(item["dub_count"], item["expected_count"], item["airing_status"])
|
||||||
try:
|
|
||||||
alt_titles = json.loads(item.get("alt_titles_json") or "[]")
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
alt_titles = []
|
|
||||||
if not isinstance(alt_titles, list):
|
|
||||||
alt_titles = []
|
|
||||||
item["alt_titles"] = unique_title_values(alt_titles, item.get("title"))
|
|
||||||
item["english_title"] = next(iter(unique_title_values([item.get("english_title")], item.get("title"))), "")
|
|
||||||
display_alt_titles = []
|
|
||||||
if item["english_title"]:
|
|
||||||
display_alt_titles.append(item["english_title"])
|
|
||||||
for candidate in item["alt_titles"]:
|
|
||||||
if normalize_title_key(candidate) == normalize_title_key(item["english_title"]):
|
|
||||||
continue
|
|
||||||
display_alt_titles.append(candidate)
|
|
||||||
item["display_alt_titles"] = display_alt_titles[:3]
|
|
||||||
item["finished_badge"] = (
|
item["finished_badge"] = (
|
||||||
"downloaded" if item["category"] == "finished" and item["downloaded"] else "manual" if item["category"] == "finished" else ""
|
"downloaded" if item["category"] == "finished" and item["downloaded"] else "manual" if item["category"] == "finished" else ""
|
||||||
)
|
)
|
||||||
@@ -1219,19 +1097,15 @@ class WatchlistStore:
|
|||||||
return item
|
return item
|
||||||
|
|
||||||
def _upsert_conn(self, conn, item):
|
def _upsert_conn(self, conn, item):
|
||||||
raw_alt_titles = item.get("alt_titles") if isinstance(item.get("alt_titles"), list) else []
|
|
||||||
if not raw_alt_titles and isinstance(item.get("alt_titles_json"), list):
|
|
||||||
raw_alt_titles = item.get("alt_titles_json")
|
|
||||||
normalized_alt_titles = unique_title_values(raw_alt_titles, item.get("title"))
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO watchlist (
|
INSERT INTO watchlist (
|
||||||
show_id, title, category, downloaded, status, status_message, created_at, updated_at,
|
show_id, title, category, downloaded, status, status_message, created_at, updated_at,
|
||||||
last_checked, sub_count, dub_count, expected_count, airing_status,
|
last_checked, sub_count, dub_count, expected_count, airing_status,
|
||||||
sub_latest_episode, dub_latest_episode,
|
sub_latest_episode, dub_latest_episode,
|
||||||
animeschedule_route, animeschedule_title, english_title, alt_titles_json, anidb_aid, anidb_title,
|
animeschedule_route, animeschedule_title, anidb_aid, anidb_title,
|
||||||
thumbnail_path, thumbnail_checked_at
|
thumbnail_path, thumbnail_checked_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(show_id) DO UPDATE SET
|
ON CONFLICT(show_id) DO UPDATE SET
|
||||||
title = excluded.title,
|
title = excluded.title,
|
||||||
category = excluded.category,
|
category = excluded.category,
|
||||||
@@ -1248,8 +1122,6 @@ class WatchlistStore:
|
|||||||
dub_latest_episode = excluded.dub_latest_episode,
|
dub_latest_episode = excluded.dub_latest_episode,
|
||||||
animeschedule_route = excluded.animeschedule_route,
|
animeschedule_route = excluded.animeschedule_route,
|
||||||
animeschedule_title = excluded.animeschedule_title,
|
animeschedule_title = excluded.animeschedule_title,
|
||||||
english_title = excluded.english_title,
|
|
||||||
alt_titles_json = excluded.alt_titles_json,
|
|
||||||
anidb_aid = excluded.anidb_aid,
|
anidb_aid = excluded.anidb_aid,
|
||||||
anidb_title = excluded.anidb_title,
|
anidb_title = excluded.anidb_title,
|
||||||
thumbnail_path = excluded.thumbnail_path,
|
thumbnail_path = excluded.thumbnail_path,
|
||||||
@@ -1273,8 +1145,6 @@ class WatchlistStore:
|
|||||||
item.get("dub_latest_episode"),
|
item.get("dub_latest_episode"),
|
||||||
item.get("animeschedule_route"),
|
item.get("animeschedule_route"),
|
||||||
item.get("animeschedule_title"),
|
item.get("animeschedule_title"),
|
||||||
item.get("english_title"),
|
|
||||||
json.dumps(normalized_alt_titles),
|
|
||||||
item.get("anidb_aid"),
|
item.get("anidb_aid"),
|
||||||
item.get("anidb_title"),
|
item.get("anidb_title"),
|
||||||
item.get("thumbnail_path"),
|
item.get("thumbnail_path"),
|
||||||
@@ -1320,8 +1190,6 @@ class WatchlistStore:
|
|||||||
"dub_latest_episode": None,
|
"dub_latest_episode": None,
|
||||||
"animeschedule_route": None,
|
"animeschedule_route": None,
|
||||||
"animeschedule_title": None,
|
"animeschedule_title": None,
|
||||||
"english_title": None,
|
|
||||||
"alt_titles": [],
|
|
||||||
"anidb_aid": None,
|
"anidb_aid": None,
|
||||||
"anidb_title": None,
|
"anidb_title": None,
|
||||||
"thumbnail_path": None,
|
"thumbnail_path": None,
|
||||||
@@ -1447,8 +1315,6 @@ class WatchlistStore:
|
|||||||
"dub_latest_episode": None,
|
"dub_latest_episode": None,
|
||||||
"animeschedule_route": None,
|
"animeschedule_route": None,
|
||||||
"animeschedule_title": None,
|
"animeschedule_title": None,
|
||||||
"english_title": None,
|
|
||||||
"alt_titles": [],
|
|
||||||
"anidb_aid": None,
|
"anidb_aid": None,
|
||||||
"anidb_title": None,
|
"anidb_title": None,
|
||||||
"thumbnail_path": None,
|
"thumbnail_path": None,
|
||||||
@@ -1475,10 +1341,6 @@ class WatchlistStore:
|
|||||||
sub_episodes = snapshot["episodes"]["sub"]
|
sub_episodes = snapshot["episodes"]["sub"]
|
||||||
dub_episodes = snapshot["episodes"]["dub"]
|
dub_episodes = snapshot["episodes"]["dub"]
|
||||||
schedule = None
|
schedule = None
|
||||||
allmanga_titles = {
|
|
||||||
"english_title": str(existing.get("english_title") or "").strip(),
|
|
||||||
"alt_titles": list(existing.get("alt_titles") or []),
|
|
||||||
}
|
|
||||||
try:
|
try:
|
||||||
schedule = resolve_animeschedule_anime(
|
schedule = resolve_animeschedule_anime(
|
||||||
snapshot["title"] or existing["title"],
|
snapshot["title"] or existing["title"],
|
||||||
@@ -1487,14 +1349,6 @@ class WatchlistStore:
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
schedule = None
|
schedule = None
|
||||||
try:
|
|
||||||
allmanga_titles = fetch_allmanga_alternative_titles(
|
|
||||||
normalized_show_id,
|
|
||||||
snapshot["title"] or existing["title"],
|
|
||||||
timeout=request_timeout,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
debug_log("watchlist.alt_titles.lookup_failed", show_id=normalized_show_id, error=exc)
|
|
||||||
if self.refresh_stop_event.is_set():
|
if self.refresh_stop_event.is_set():
|
||||||
return None
|
return None
|
||||||
if not self.has_show(normalized_show_id):
|
if not self.has_show(normalized_show_id):
|
||||||
@@ -1520,8 +1374,6 @@ class WatchlistStore:
|
|||||||
dub_episodes[-1] if dub_episodes else None,
|
dub_episodes[-1] if dub_episodes else None,
|
||||||
(schedule or {}).get("route") or existing.get("animeschedule_route"),
|
(schedule or {}).get("route") or existing.get("animeschedule_route"),
|
||||||
(schedule or {}).get("title") or existing.get("animeschedule_title"),
|
(schedule or {}).get("title") or existing.get("animeschedule_title"),
|
||||||
allmanga_titles.get("english_title") or existing.get("english_title"),
|
|
||||||
json.dumps(unique_title_values(allmanga_titles.get("alt_titles") or [], snapshot["title"] or existing["title"])),
|
|
||||||
normalized_show_id,
|
normalized_show_id,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -1543,8 +1395,6 @@ class WatchlistStore:
|
|||||||
existing.get("dub_latest_episode"),
|
existing.get("dub_latest_episode"),
|
||||||
existing.get("animeschedule_route"),
|
existing.get("animeschedule_route"),
|
||||||
existing.get("animeschedule_title"),
|
existing.get("animeschedule_title"),
|
||||||
existing.get("english_title"),
|
|
||||||
existing.get("alt_titles_json") or "[]",
|
|
||||||
normalized_show_id,
|
normalized_show_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1567,9 +1417,7 @@ class WatchlistStore:
|
|||||||
sub_latest_episode = ?,
|
sub_latest_episode = ?,
|
||||||
dub_latest_episode = ?,
|
dub_latest_episode = ?,
|
||||||
animeschedule_route = ?,
|
animeschedule_route = ?,
|
||||||
animeschedule_title = ?,
|
animeschedule_title = ?
|
||||||
english_title = ?,
|
|
||||||
alt_titles_json = ?
|
|
||||||
WHERE show_id = ?
|
WHERE show_id = ?
|
||||||
""",
|
""",
|
||||||
payload,
|
payload,
|
||||||
@@ -1875,54 +1723,6 @@ class WatchlistStore:
|
|||||||
continue
|
continue
|
||||||
return {"items": updated}
|
return {"items": updated}
|
||||||
|
|
||||||
def ensure_alt_titles(self, show_id, force=False):
|
|
||||||
item = self.get(show_id)
|
|
||||||
if not force and (item.get("alt_titles") or item.get("english_title")):
|
|
||||||
return item
|
|
||||||
parsed = fetch_allmanga_alternative_titles(item["show_id"], item.get("title"), timeout=self._refresh_request_timeout())
|
|
||||||
updated_at = now_iso()
|
|
||||||
alt_titles_json = json.dumps(unique_title_values(parsed.get("alt_titles") or [], item.get("title")))
|
|
||||||
with self.lock, self._connect() as conn:
|
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
UPDATE watchlist
|
|
||||||
SET english_title = ?, alt_titles_json = ?, updated_at = ?
|
|
||||||
WHERE show_id = ?
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
parsed.get("english_title") or None,
|
|
||||||
alt_titles_json,
|
|
||||||
updated_at,
|
|
||||||
item["show_id"],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return self.get(show_id)
|
|
||||||
|
|
||||||
def ensure_alt_titles_many(self, show_ids, limit=6, force=False):
|
|
||||||
normalized = []
|
|
||||||
for show_id in show_ids:
|
|
||||||
text = str(show_id or "").strip()
|
|
||||||
if text and text not in normalized:
|
|
||||||
normalized.append(text)
|
|
||||||
updated = []
|
|
||||||
for show_id in normalized[: max(1, min(int(limit or 6), 10))]:
|
|
||||||
try:
|
|
||||||
item = self.get(show_id)
|
|
||||||
except KeyError:
|
|
||||||
continue
|
|
||||||
if not force and (item.get("alt_titles") or item.get("english_title")):
|
|
||||||
updated.append(item)
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
updated.append(self.ensure_alt_titles(show_id, force=force))
|
|
||||||
except Exception as exc:
|
|
||||||
debug_log("watchlist.alt_titles.ensure_failed", show_id=show_id, error=exc)
|
|
||||||
try:
|
|
||||||
updated.append(self.get(show_id))
|
|
||||||
except KeyError:
|
|
||||||
continue
|
|
||||||
return {"items": updated}
|
|
||||||
|
|
||||||
|
|
||||||
def add_to_watchlist(payload):
|
def add_to_watchlist(payload):
|
||||||
ensure_runtime()
|
ensure_runtime()
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@ from uuid import uuid4
|
|||||||
PROJECT_ROOT = Path(__file__).resolve().parent
|
PROJECT_ROOT = Path(__file__).resolve().parent
|
||||||
ANI_CLI = os.environ.get("ANI_CLI_BIN") or shutil.which("ani-cli") or "ani-cli"
|
ANI_CLI = os.environ.get("ANI_CLI_BIN") or shutil.which("ani-cli") or "ani-cli"
|
||||||
APP_NAME = "ani-cli-web"
|
APP_NAME = "ani-cli-web"
|
||||||
VERSION = "0.37.2"
|
VERSION = "0.36.1"
|
||||||
ALLANIME_BASE = "allanime.day"
|
ALLANIME_BASE = "allanime.day"
|
||||||
ALLANIME_API = f"https://api.{ALLANIME_BASE}"
|
ALLANIME_API = f"https://api.{ALLANIME_BASE}"
|
||||||
ALLANIME_REFERER = "https://allmanga.to"
|
ALLANIME_REFERER = "https://allmanga.to"
|
||||||
|
|||||||
@@ -499,16 +499,6 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
force=Handler.optional_bool_field(self, payload, "force", default=False),
|
force=Handler.optional_bool_field(self, payload, "force", default=False),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
elif parsed.path == "/api/watchlist/ensure-alt-titles":
|
|
||||||
runtime = Handler._runtime(self)
|
|
||||||
payload = Handler.require_json_object(self, self.body_json())
|
|
||||||
self.json(
|
|
||||||
runtime["watchlist"].ensure_alt_titles_many(
|
|
||||||
Handler.optional_list_field(self, payload, "show_ids") or [],
|
|
||||||
limit=payload.get("limit", 6),
|
|
||||||
force=Handler.optional_bool_field(self, payload, "force", default=False),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
elif parsed.path == "/api/watchlist/update-status":
|
elif parsed.path == "/api/watchlist/update-status":
|
||||||
Handler._runtime(self)
|
Handler._runtime(self)
|
||||||
payload = Handler.require_fields(self, self.body_json(), "show_id")
|
payload = Handler.require_fields(self, self.body_json(), "show_id")
|
||||||
|
|||||||
-56
@@ -849,33 +849,6 @@ class WatchlistCompletionTests(unittest.TestCase):
|
|||||||
self.assertIn("Sub ready.", item["status_message"])
|
self.assertIn("Sub ready.", item["status_message"])
|
||||||
self.assertIn("Dub ready.", item["status_message"])
|
self.assertIn("Dub ready.", item["status_message"])
|
||||||
|
|
||||||
def test_refresh_persists_allmanga_alternative_titles(self):
|
|
||||||
self.seed_watchlist_item()
|
|
||||||
snapshot = {
|
|
||||||
"show_id": "show-1",
|
|
||||||
"title": "Example Show",
|
|
||||||
"episodes": {"sub": ["1"], "dub": []},
|
|
||||||
}
|
|
||||||
with mock.patch.object(APP, "fetch_show_episode_snapshot", return_value=snapshot), mock.patch.object(
|
|
||||||
APP, "resolve_animeschedule_anime", return_value=None
|
|
||||||
), mock.patch.object(
|
|
||||||
APP, "fetch_allmanga_alternative_titles", return_value={"english_title": "Example Show English", "alt_titles": ["Example Show English", "Sample Alias"]}
|
|
||||||
), mock.patch.object(APP.WatchlistStore, "ensure_thumbnail", return_value=None):
|
|
||||||
item = APP.WATCHLIST.refresh("show-1")
|
|
||||||
|
|
||||||
self.assertEqual(item["english_title"], "Example Show English")
|
|
||||||
self.assertEqual(item["display_alt_titles"], ["Example Show English", "Sample Alias"])
|
|
||||||
|
|
||||||
def test_ensure_alt_titles_backfills_existing_item(self):
|
|
||||||
self.seed_watchlist_item()
|
|
||||||
with mock.patch.object(
|
|
||||||
APP, "fetch_allmanga_alternative_titles", return_value={"english_title": "Example Show English", "alt_titles": ["Example Show English", "Sample Alias"]}
|
|
||||||
):
|
|
||||||
item = APP.WATCHLIST.ensure_alt_titles("show-1")
|
|
||||||
|
|
||||||
self.assertEqual(item["english_title"], "Example Show English")
|
|
||||||
self.assertEqual(item["display_alt_titles"], ["Example Show English", "Sample Alias"])
|
|
||||||
|
|
||||||
def test_list_filters_by_category_and_reports_category_counts(self):
|
def test_list_filters_by_category_and_reports_category_counts(self):
|
||||||
self.seed_watchlist_item(show_id="show-1", title="Watching Show", category="watching", status="queued")
|
self.seed_watchlist_item(show_id="show-1", title="Watching Show", category="watching", status="queued")
|
||||||
self.seed_watchlist_item(show_id="show-2", title="Finished Show", category="finished", downloaded=True)
|
self.seed_watchlist_item(show_id="show-2", title="Finished Show", category="finished", downloaded=True)
|
||||||
@@ -1671,15 +1644,6 @@ class HandlerRouteTests(unittest.TestCase):
|
|||||||
self.assertEqual(handler.json_payload, payload)
|
self.assertEqual(handler.json_payload, payload)
|
||||||
self.assertEqual(handler.json_status, HTTPStatus.CREATED)
|
self.assertEqual(handler.json_status, HTTPStatus.CREATED)
|
||||||
|
|
||||||
def test_watchlist_ensure_alt_titles_post_returns_items(self):
|
|
||||||
body = b'{"show_ids":["show-1"],"limit":1}'
|
|
||||||
handler = DummyHandler("/api/watchlist/ensure-alt-titles", body=body, content_length=len(body))
|
|
||||||
payload = {"items": [{"show_id": "show-1", "display_alt_titles": ["Example Show English"]}]}
|
|
||||||
with mock.patch.object(APP.WATCHLIST, "ensure_alt_titles_many", return_value=payload):
|
|
||||||
APP.Handler.do_POST(handler)
|
|
||||||
self.assertEqual(handler.json_payload, payload)
|
|
||||||
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
|
||||||
|
|
||||||
def test_generic_handler_exception_skips_traceback_when_debug_disabled(self):
|
def test_generic_handler_exception_skips_traceback_when_debug_disabled(self):
|
||||||
handler = DummyHandler("/api/config")
|
handler = DummyHandler("/api/config")
|
||||||
with mock.patch.object(http_handler, "debug_enabled", return_value=False), mock.patch.object(
|
with mock.patch.object(http_handler, "debug_enabled", return_value=False), mock.patch.object(
|
||||||
@@ -1764,32 +1728,12 @@ class TemplateHelperTests(unittest.TestCase):
|
|||||||
self.assertIn('api("/api/watchlist/download-all"', APP.WATCHLIST_HTML)
|
self.assertIn('api("/api/watchlist/download-all"', APP.WATCHLIST_HTML)
|
||||||
self.assertIn("Enter sub or dub", APP.WATCHLIST_HTML)
|
self.assertIn("Enter sub or dub", APP.WATCHLIST_HTML)
|
||||||
|
|
||||||
def test_watchlist_page_shows_alternative_titles(self):
|
|
||||||
self.assertIn('class="ghost alt-toggle"', APP.WATCHLIST_HTML)
|
|
||||||
self.assertIn('class="alt-titles"', APP.WATCHLIST_HTML)
|
|
||||||
self.assertIn('Alternative titles', APP.WATCHLIST_HTML)
|
|
||||||
self.assertIn('altTitlesNode.classList.toggle("visible")', APP.WATCHLIST_HTML)
|
|
||||||
self.assertIn('api("/api/watchlist/ensure-alt-titles"', APP.WATCHLIST_HTML)
|
|
||||||
self.assertIn("altTitlePending: new Set()", APP.WATCHLIST_HTML)
|
|
||||||
|
|
||||||
def test_watchlist_thumbnail_warmup_skips_repeated_attempts(self):
|
def test_watchlist_thumbnail_warmup_skips_repeated_attempts(self):
|
||||||
self.assertIn("thumbnailWarmupPending: new Set()", APP.WATCHLIST_HTML)
|
self.assertIn("thumbnailWarmupPending: new Set()", APP.WATCHLIST_HTML)
|
||||||
self.assertIn("function thumbnailWarmupToken(item)", APP.WATCHLIST_HTML)
|
self.assertIn("function thumbnailWarmupToken(item)", APP.WATCHLIST_HTML)
|
||||||
self.assertIn("if (state.thumbnailWarmupTokens[item.show_id] === token) continue;", APP.WATCHLIST_HTML)
|
self.assertIn("if (state.thumbnailWarmupTokens[item.show_id] === token) continue;", APP.WATCHLIST_HTML)
|
||||||
self.assertIn("state.thumbnailWarmupPending.delete(showId);", APP.WATCHLIST_HTML)
|
self.assertIn("state.thumbnailWarmupPending.delete(showId);", APP.WATCHLIST_HTML)
|
||||||
|
|
||||||
def test_parse_allmanga_alternative_titles_extracts_english_and_synonyms(self):
|
|
||||||
html_text = """
|
|
||||||
<div class="show-more-content card-text p-0" style="">
|
|
||||||
<span class="mr-1 altnames"> ⚪ Example Show English </span>
|
|
||||||
<span class="mr-1 altnames"> ⚪ Sample Alias </span>
|
|
||||||
<span class="mr-1 altnames"> ⚪ Example Show </span>
|
|
||||||
</div>
|
|
||||||
"""
|
|
||||||
parsed = APP.parse_allmanga_alternative_titles(html_text, primary_title="Example Show")
|
|
||||||
self.assertEqual(parsed["english_title"], "Example Show English")
|
|
||||||
self.assertEqual(parsed["alt_titles"], ["Example Show English", "Sample Alias"])
|
|
||||||
|
|
||||||
|
|
||||||
class AniDbCacheTests(unittest.TestCase):
|
class AniDbCacheTests(unittest.TestCase):
|
||||||
def test_get_cached_anidb_titles_reuses_cached_entries(self):
|
def test_get_cached_anidb_titles_reuses_cached_entries(self):
|
||||||
|
|||||||
+1
-101
@@ -413,24 +413,6 @@ WATCHLIST_HTML = r"""<!doctype html>
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.alt-toggle {
|
|
||||||
min-height: 30px;
|
|
||||||
padding: 0 10px;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.alt-titles {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.35;
|
|
||||||
margin-top: 8px;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.alt-titles.visible {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
.card-title a {
|
.card-title a {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -674,8 +656,6 @@ WATCHLIST_HTML = r"""<!doctype html>
|
|||||||
watchlistPerPage: 30,
|
watchlistPerPage: 30,
|
||||||
queuedCount: 0,
|
queuedCount: 0,
|
||||||
watchlistCategory: "watching",
|
watchlistCategory: "watching",
|
||||||
altTitleTokens: {},
|
|
||||||
altTitlePending: new Set(),
|
|
||||||
thumbnailNonce: Date.now(),
|
thumbnailNonce: Date.now(),
|
||||||
thumbnailWarmupTokens: {},
|
thumbnailWarmupTokens: {},
|
||||||
thumbnailWarmupPending: new Set(),
|
thumbnailWarmupPending: new Set(),
|
||||||
@@ -865,44 +845,6 @@ WATCHLIST_HTML = r"""<!doctype html>
|
|||||||
.find((card) => card.dataset.showId === showId) || null;
|
.find((card) => card.dataset.showId === showId) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function altTitleToken(item) {
|
|
||||||
return [
|
|
||||||
item.english_title || "",
|
|
||||||
...(item.display_alt_titles || []),
|
|
||||||
].join("|");
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyAltTitles(card, item) {
|
|
||||||
if (!card) return;
|
|
||||||
const altTitlesNode = card.querySelector(".alt-titles");
|
|
||||||
const oldToggle = card.querySelector(".alt-toggle");
|
|
||||||
if (!altTitlesNode || !oldToggle) return;
|
|
||||||
const altTitles = item.display_alt_titles || [];
|
|
||||||
const wasVisible = altTitlesNode.classList.contains("visible");
|
|
||||||
altTitlesNode.classList.remove("visible");
|
|
||||||
altTitlesNode.textContent = "";
|
|
||||||
altTitlesNode.hidden = true;
|
|
||||||
const newToggle = oldToggle.cloneNode(true);
|
|
||||||
oldToggle.replaceWith(newToggle);
|
|
||||||
newToggle.hidden = true;
|
|
||||||
newToggle.textContent = "Alternative titles";
|
|
||||||
newToggle.setAttribute("aria-expanded", "false");
|
|
||||||
if (!altTitles.length) return;
|
|
||||||
altTitlesNode.hidden = false;
|
|
||||||
altTitlesNode.textContent = altTitles.join(" · ");
|
|
||||||
newToggle.hidden = false;
|
|
||||||
if (wasVisible) {
|
|
||||||
altTitlesNode.classList.add("visible");
|
|
||||||
newToggle.textContent = "Hide alternative titles";
|
|
||||||
newToggle.setAttribute("aria-expanded", "true");
|
|
||||||
}
|
|
||||||
newToggle.addEventListener("click", () => {
|
|
||||||
const visible = altTitlesNode.classList.toggle("visible");
|
|
||||||
newToggle.setAttribute("aria-expanded", visible ? "true" : "false");
|
|
||||||
newToggle.textContent = visible ? "Hide alternative titles" : "Alternative titles";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function thumbnailWarmupToken(item) {
|
function thumbnailWarmupToken(item) {
|
||||||
return [
|
return [
|
||||||
item.thumbnail_ready ? "1" : "0",
|
item.thumbnail_ready ? "1" : "0",
|
||||||
@@ -949,42 +891,6 @@ WATCHLIST_HTML = r"""<!doctype html>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hydrateAltTitlesOnce(items) {
|
|
||||||
const missing = [];
|
|
||||||
for (const item of items) {
|
|
||||||
const token = altTitleToken(item);
|
|
||||||
if ((item.display_alt_titles || []).length) {
|
|
||||||
state.altTitlePending.delete(item.show_id);
|
|
||||||
state.altTitleTokens[item.show_id] = token;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (state.altTitlePending.has(item.show_id)) continue;
|
|
||||||
if (state.altTitleTokens[item.show_id] === token) continue;
|
|
||||||
state.altTitlePending.add(item.show_id);
|
|
||||||
state.altTitleTokens[item.show_id] = token;
|
|
||||||
missing.push(item.show_id);
|
|
||||||
if (missing.length >= 6) break;
|
|
||||||
}
|
|
||||||
if (!missing.length) return;
|
|
||||||
try {
|
|
||||||
const data = await api("/api/watchlist/ensure-alt-titles", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ show_ids: missing, limit: 6, force: false })
|
|
||||||
});
|
|
||||||
for (const item of data.items || []) {
|
|
||||||
state.altTitleTokens[item.show_id] = altTitleToken(item);
|
|
||||||
const card = findCardByShowId(item.show_id);
|
|
||||||
if (!card) continue;
|
|
||||||
applyAltTitles(card, item);
|
|
||||||
}
|
|
||||||
} catch (_error) {
|
|
||||||
} finally {
|
|
||||||
for (const showId of missing) {
|
|
||||||
state.altTitlePending.delete(showId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function reloadWatchlistThumbnail(showId, title) {
|
async function reloadWatchlistThumbnail(showId, title) {
|
||||||
const card = findCardByShowId(showId);
|
const card = findCardByShowId(showId);
|
||||||
if (card) setThumbnailReloading(card, true);
|
if (card) setThumbnailReloading(card, true);
|
||||||
@@ -1085,11 +991,7 @@ WATCHLIST_HTML = r"""<!doctype html>
|
|||||||
<input class="thumb-upload-input" type="file" accept="image/*" hidden>
|
<input class="thumb-upload-input" type="file" accept="image/*" hidden>
|
||||||
</div>
|
</div>
|
||||||
<div class="title-row">
|
<div class="title-row">
|
||||||
<div>
|
<div class="card-title"></div>
|
||||||
<div class="card-title"></div>
|
|
||||||
<button class="ghost alt-toggle" type="button" hidden>Alternative titles</button>
|
|
||||||
<div class="alt-titles" hidden></div>
|
|
||||||
</div>
|
|
||||||
<span class="finish-check ${item.finished_badge || ""}" ${item.finished_badge ? "" : "hidden"}>${item.finished_badge ? "✓" : ""}</span>
|
<span class="finish-check ${item.finished_badge || ""}" ${item.finished_badge ? "" : "hidden"}>${item.finished_badge ? "✓" : ""}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="count-row">
|
<div class="count-row">
|
||||||
@@ -1117,7 +1019,6 @@ WATCHLIST_HTML = r"""<!doctype html>
|
|||||||
titleLink.rel = "noopener noreferrer";
|
titleLink.rel = "noopener noreferrer";
|
||||||
titleLink.textContent = item.title;
|
titleLink.textContent = item.title;
|
||||||
titleNode.replaceChildren(titleLink);
|
titleNode.replaceChildren(titleLink);
|
||||||
applyAltTitles(card, item);
|
|
||||||
const finishCheck = card.querySelector(".finish-check");
|
const finishCheck = card.querySelector(".finish-check");
|
||||||
if (finishCheck && item.finished_badge) {
|
if (finishCheck && item.finished_badge) {
|
||||||
finishCheck.title = item.finished_badge === "downloaded"
|
finishCheck.title = item.finished_badge === "downloaded"
|
||||||
@@ -1176,7 +1077,6 @@ WATCHLIST_HTML = r"""<!doctype html>
|
|||||||
applyThumbnail(card, item, false);
|
applyThumbnail(card, item, false);
|
||||||
}
|
}
|
||||||
renderPager();
|
renderPager();
|
||||||
window.setTimeout(() => hydrateAltTitlesOnce(items), 120);
|
|
||||||
window.setTimeout(() => hydrateThumbnailsOnce(items), 150);
|
window.setTimeout(() => hydrateThumbnailsOnce(items), 150);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user