Backfill watchlist alternative titles lazily

This commit is contained in:
Dymas
2026-05-23 19:09:38 +02:00
parent 0c1b46e395
commit 8aa490adf8
8 changed files with 165 additions and 18 deletions
+4
View File
@@ -1,5 +1,9 @@
# 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 ## 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. - 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.
+2 -1
View File
@@ -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.1` Current version: `0.37.2`
## Project Layout ## Project Layout
@@ -244,6 +244,7 @@ Title behavior:
- Watchlist refreshes also try to pull alternative English and synonym titles from the AllManga show page. - 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. - Watchlist cards show an `Alternative titles` button when names are available.
- Pressing that button expands the stored alternative titles on demand. - 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 -1
View File
@@ -1 +1 @@
0.37.1 0.37.2
+48
View File
@@ -1875,6 +1875,54 @@ 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
View File
@@ -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.1" VERSION = "0.37.2"
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"
+10
View File
@@ -499,6 +499,16 @@ 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")
+21
View File
@@ -866,6 +866,16 @@ class WatchlistCompletionTests(unittest.TestCase):
self.assertEqual(item["english_title"], "Example Show English") self.assertEqual(item["english_title"], "Example Show English")
self.assertEqual(item["display_alt_titles"], ["Example Show English", "Sample Alias"]) 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)
@@ -1661,6 +1671,15 @@ 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(
@@ -1750,6 +1769,8 @@ class TemplateHelperTests(unittest.TestCase):
self.assertIn('class="alt-titles"', APP.WATCHLIST_HTML) self.assertIn('class="alt-titles"', APP.WATCHLIST_HTML)
self.assertIn('Alternative titles', APP.WATCHLIST_HTML) self.assertIn('Alternative titles', APP.WATCHLIST_HTML)
self.assertIn('altTitlesNode.classList.toggle("visible")', 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)
+78 -15
View File
@@ -674,6 +674,8 @@ 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(),
@@ -863,6 +865,44 @@ 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",
@@ -909,6 +949,42 @@ 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);
@@ -1041,21 +1117,7 @@ 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);
const altToggle = card.querySelector(".alt-toggle"); applyAltTitles(card, item);
const altTitlesNode = card.querySelector(".alt-titles");
const altTitles = item.display_alt_titles || [];
if (altTitles.length) {
altToggle.hidden = false;
altTitlesNode.hidden = false;
altTitlesNode.textContent = altTitles.join(" · ");
altToggle.setAttribute("aria-expanded", "false");
altToggle.addEventListener("click", () => {
const visible = altTitlesNode.classList.toggle("visible");
altToggle.setAttribute("aria-expanded", visible ? "true" : "false");
altToggle.textContent = visible ? "Hide alternative titles" : "Alternative titles";
});
altToggle.textContent = "Alternative titles";
}
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"
@@ -1114,6 +1176,7 @@ 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);
} }