Backfill watchlist alternative titles lazily
This commit is contained in:
@@ -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,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.
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user