diff --git a/CHANGELOG.md b/CHANGELOG.md index cf2d538..67950bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 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. diff --git a/README.md b/README.md index c33bbd1..bdefc29 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.1` +Current version: `0.37.2` ## 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 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. - Failed lookups back off before retrying. - You can force `Reload` or use `Upload` when no thumbnail exists. diff --git a/VERSION b/VERSION index 9b1bb85..8570a3a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.37.1 +0.37.2 diff --git a/app.py b/app.py index 3d869e7..599bc2f 100644 --- a/app.py +++ b/app.py @@ -1875,6 +1875,54 @@ class WatchlistStore: continue 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): ensure_runtime() diff --git a/app_support.py b/app_support.py index ece506c..ed9eef5 100644 --- a/app_support.py +++ b/app_support.py @@ -19,7 +19,7 @@ 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.1" +VERSION = "0.37.2" ALLANIME_BASE = "allanime.day" ALLANIME_API = f"https://api.{ALLANIME_BASE}" ALLANIME_REFERER = "https://allmanga.to" diff --git a/http_handler.py b/http_handler.py index d600c88..d624acf 100644 --- a/http_handler.py +++ b/http_handler.py @@ -499,6 +499,16 @@ class Handler(BaseHTTPRequestHandler): 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": Handler._runtime(self) payload = Handler.require_fields(self, self.body_json(), "show_id") diff --git a/test_app.py b/test_app.py index 58d72fd..2ec4bc5 100644 --- a/test_app.py +++ b/test_app.py @@ -866,6 +866,16 @@ class WatchlistCompletionTests(unittest.TestCase): 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): 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) @@ -1661,6 +1671,15 @@ class HandlerRouteTests(unittest.TestCase): self.assertEqual(handler.json_payload, payload) 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): handler = DummyHandler("/api/config") 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('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): self.assertIn("thumbnailWarmupPending: new Set()", APP.WATCHLIST_HTML) diff --git a/watchlist_page.py b/watchlist_page.py index 73cdccf..da7717a 100644 --- a/watchlist_page.py +++ b/watchlist_page.py @@ -674,6 +674,8 @@ WATCHLIST_HTML = r""" watchlistPerPage: 30, queuedCount: 0, watchlistCategory: "watching", + altTitleTokens: {}, + altTitlePending: new Set(), thumbnailNonce: Date.now(), thumbnailWarmupTokens: {}, thumbnailWarmupPending: new Set(), @@ -863,6 +865,44 @@ WATCHLIST_HTML = r""" .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) { return [ item.thumbnail_ready ? "1" : "0", @@ -909,6 +949,42 @@ WATCHLIST_HTML = r""" } } + 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) { const card = findCardByShowId(showId); if (card) setThumbnailReloading(card, true); @@ -1041,21 +1117,7 @@ WATCHLIST_HTML = r""" titleLink.rel = "noopener noreferrer"; titleLink.textContent = item.title; titleNode.replaceChildren(titleLink); - const altToggle = card.querySelector(".alt-toggle"); - 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"; - } + applyAltTitles(card, item); const finishCheck = card.querySelector(".finish-check"); if (finishCheck && item.finished_badge) { finishCheck.title = item.finished_badge === "downloaded" @@ -1114,6 +1176,7 @@ WATCHLIST_HTML = r""" applyThumbnail(card, item, false); } renderPager(); + window.setTimeout(() => hydrateAltTitlesOnce(items), 120); window.setTimeout(() => hydrateThumbnailsOnce(items), 150); }