history: flag rows whose slug no longer means the same anime

Provider switch (anidb -> hianime-first) left anidb-era rows whose slugs
200 on hianime as a DIFFERENT anime: /watch/jujutsu-kaisen-2552 is now
Burning Kabaddi (the trailing number is a hianime id), and the stored
anidb ep id doesn't even exist in that anime's list — the row rendered
as watched JJK but linked to the wrong show.

Client-side validation (no data migration): every history row's slug is
checked against GET /api/anime/{slug} once per slug; title mismatch (or
a dead slug) marks the entry stale. Stale rows:
- home: one card per stale anime above the hero + 'history ->' link
- history page: separate 'Moved or renamed on the source' section
Each card: title, 'find it ->' (search by stored title) and x (remove,
same endpoint as regular cards). Current rows are untouched: a hianime
slug whose details name matches the stored title passes.

Tests: API contract for a stale row (deletable while on the board);
live browser test (gated) with the real jujutsu-kaisen-2552 row:
remap card renders, card doesn't link to the wrong slug, find-it
lands on the title's search.
This commit is contained in:
jozamudi committed 2026-09-18 23:25:35 +00:00
1 parent 0f6bd0a52a
commit 379f591180
3 files changed
+141 -3

No files matched your search

+79 -3
View File
@@ -127,7 +127,53 @@ const bar = (x, cls) => {
b.append(Object.assign(el("div", "bar-fill"), {style: `width:${p}%`})); b.append(Object.assign(el("div", "bar-fill"), {style: `width:${p}%`}));
return b; return b;
}; };
const histHref = x => `?anime=${x.slug_id}&season=${x.season_id}&ep=${x.ep_id}&lang=${x.lang}`; function histHref(x) { return `?anime=${x.slug_id}&season=${x.season_id}&ep=${x.ep_id}&lang=${x.lang}`; }
// ---------- stale-entry remap (provider switch) ----------
// History rows written while anidb was the source carry anidb slugs/ids. Under
// hianime-first dispatch, /watch/{anidb-slug} often 200s as a DIFFERENT anime
// (id 2552 = Burning Kabaddi) while the anidb ep id silently dies (not in that
// anime's list) — the row then links to the wrong show. Validate every row
// against the details endpoint; mismatch or dead slug -> remap board.
const _norm = s => String(s || "").toLowerCase().replace(/[^a-z0-9]/g, "");
const _match = (a, b) => {
const x = _norm(a), y = _norm(b);
return !!(x && y && (x === y || x.includes(y) || y.includes(x)));
};
const _remap = {};
async function checkEntry(x) {
if (x._remapped) return true;
if (_remap[x.slug_id] !== undefined) { x._remapped = _remap[x.slug_id]; return !_remap[x.slug_id]; }
let d;
try { d = await api(`/api/anime/${x.slug_id}`); } catch { d = null; }
const ok = !!(d && d.name && _match(d.name, x.title));
_remap[x.slug_id] = !ok;
x._remapped = !ok;
return ok;
}
function remapCard(x) {
const c = el("a", "card hist remap");
c.append(poster(x.poster, x.title),
el("div", "card-name", `"${x.title}" \u2014 not found on the current source`));
const row = el("div", "chips");
const go = el("button", "tiny", "find it \u2192");
go.onclick = e => { e.preventDefault(); e.stopPropagation(); location.href = "?q=" + encodeURIComponent(x.title || ""); };
const del = el("button", "del", "\u00d7");
del.title = "remove from history";
del.onclick = async e => {
e.preventDefault();
e.stopPropagation();
await fetch(BASE + "api/history?slug_id=" + encodeURIComponent(x.slug_id) +
"&ep_id=" + encodeURIComponent(x.ep_id), {method: "DELETE", headers: {"X-Aniweb": "1"}});
c.remove();
const i = history.indexOf(x);
if (i >= 0) history.splice(i, 1);
toast("Removed from history", {label: "Undo", fn: () => postHistory(x)});
};
row.append(go, del);
c.append(row);
return c;
}
// one entry per anime (latest row wins), newest first // one entry per anime (latest row wins), newest first
const byShow = () => { const byShow = () => {
const m = new Map(); const m = new Map();
@@ -189,6 +235,8 @@ function showHome() {
return; return;
} }
const hero = history[0]; const hero = history[0];
main.append(remapBoard());
if (hero && hero._remapped) return;
// 1. continue-watching hero // 1. continue-watching hero
const sec = el("section", "hero-sec"); const sec = el("section", "hero-sec");
const card = el("a", "hero"); const card = el("a", "hero");
@@ -467,6 +515,22 @@ function langLabel() { return lang === "eng" ? "▶ dub" : "▶ sub"; }
// ---------- history page (full list + delete + client-side filter) ---------- // ---------- history page (full list + delete + client-side filter) ----------
const histClass = x => x.dur > 0 && x.pos >= x.dur * 0.95 ? "done" const histClass = x => x.dur > 0 && x.pos >= x.dur * 0.95 ? "done"
: (x.t && Date.now() / 1000 - x.t > 7 * 86400 ? "hold" : "watching"); : (x.t && Date.now() / 1000 - x.t > 7 * 86400 ? "hold" : "watching");
function remapBoard() {
// home: one stale card per stale anime (latest row), above the hero, so a
// switched-source user sees the remap CTA before the wrong-show hero
const m = new Map();
for (const x of history) if (x._remapped && (!m.has(x.slug_id) || x.t > m.get(x.slug_id).t)) m.set(x.slug_id, x);
const out = [];
if (m.size) {
out.push(el("div", "sec-head").append(
el("h2", null, "Moved or renamed on the source \u2014 pick the right one"),
link("history \u2192", "?history")));
const grid = el("div", "grid mini");
for (const x of m.values()) grid.append(remapCard(x));
out.push(grid);
}
return out;
}
async function showHistory() { async function showHistory() {
document.title = "History \u2014 ani-web"; document.title = "History \u2014 ani-web";
skel(10); skel(10);
@@ -474,6 +538,7 @@ async function showHistory() {
main.innerHTML = ""; main.innerHTML = "";
main.append(el("h2", null, "History")); main.append(el("h2", null, "History"));
if (!items.length) { main.append(el("p", "muted", "Nothing here.")); focusMain(); return; } if (!items.length) { main.append(el("p", "muted", "Nothing here.")); focusMain(); return; }
await Promise.all(items.map(checkEntry));
main.append(...histBoard(items)); main.append(...histBoard(items));
focusMain(); focusMain();
} }
@@ -576,12 +641,14 @@ function topAnime(d, n = 0) {
return ta; return ta;
} }
function histBoard(items, limit = 0) { function histBoard(items, limit = 0) {
const good = items.filter(x => !x._remapped);
const stale = items.filter(x => x._remapped);
const labels = {all: "all", watching: "watching", done: "completed", hold: "on hold"}; const labels = {all: "all", watching: "watching", done: "completed", hold: "on hold"};
let active = "all"; let active = "all";
const chips = el("div", "chips"); const chips = el("div", "chips");
const grid = el("div", "grid"); const grid = el("div", "grid");
const draw = () => { const draw = () => {
let f = items.filter(x => active === "all" || histClass(x) === active); let f = good.filter(x => active === "all" || histClass(x) === active);
if (limit) f = f.slice(0, limit); if (limit) f = f.slice(0, limit);
grid.replaceChildren(...f.map(histCard)); grid.replaceChildren(...f.map(histCard));
for (const c of chips.children) c.classList.toggle("on", c.dataset.f === active); for (const c of chips.children) c.classList.toggle("on", c.dataset.f === active);
@@ -593,7 +660,15 @@ function histBoard(items, limit = 0) {
chips.append(b); chips.append(b);
} }
draw(); draw();
return [chips, grid]; const out = [chips, grid];
if (stale.length) {
out.push(el("div", "sec-head").append(
el("h2", null, "Moved or renamed on the source \u2014 pick the right one")));
const sgrid = el("div", "grid mini");
for (const x of stale) sgrid.append(remapCard(x));
out.push(sgrid);
}
return out;
} }
function heatMap(byDay, days) { function heatMap(byDay, days) {
@@ -1169,6 +1244,7 @@ window.addEventListener("pagehide", () => savePos(0, true)); // keepalive: a pl
} catch { /* not logged in: api() already redirected to /login */ } } catch { /* not logged in: api() already redirected to /login */ }
statusPill(); statusPill();
await refreshHistory(); await refreshHistory();
await Promise.all(history.map(checkEntry));
const p = new URLSearchParams(location.search); const p = new URLSearchParams(location.search);
try { try {
if (p.get("q")) await showSearch(p.get("q")); if (p.get("q")) await showSearch(p.get("q"));
+22
View File
@@ -506,3 +506,25 @@ def test_fetch_parallel_stitches_range_lanes():
finally: finally:
u._session = real u._session = real
u._get = real_get u._get = real_get
def test_stale_entries_surfaced_as_remappable(base=BASE):
# provider switch left anidb-era rows: their slug 200s on hianime as a
# DIFFERENT anime (id 2552 = Burning Kabaddi). The client flags the
# mismatch; the API contract is unchanged — the entry is deletable by
# slug+ep while it sits on the remap board.
jar = login("bob", "bobpw12345", base)
stale = {"title": "Jujutsu Kaisen", "slug_id": "jujutsu-kaisen-2552",
"season_id": 2552, "ep_id": 35996, "lang": "jpn", "ep": 1, "pos": 42}
good = {"title": "Seed Anime", "slug_id": "seed-1", "season_id": 1,
"ep_id": 1, "lang": "eng", "ep": 1, "pos": 10}
assert req("POST", "/api/history", stale, jar=jar, base=base) == 200
assert req("POST", "/api/history", good, jar=jar, base=base) == 200
all_ = _json("GET", "/api/history?all=1", jar=jar, base=base)
got = [x for x in all_ if x["slug_id"] == "jujutsu-kaisen-2552"]
assert got and got[0]["ep_id"] == 35996 and got[0]["pos"] == 42, got
removed = _json("DELETE", "/api/history?slug_id=jujutsu-kaisen-2552&ep_id=35996",
jar=jar, base=base)
assert removed["removed"] >= 1, removed
all_ = _json("GET", "/api/history?all=1", jar=jar, base=base)
assert not [x for x in all_ if x["slug_id"] == "jujutsu-kaisen-2552"], all_
+40
View File
@@ -450,3 +450,43 @@ async def _autoplay_blocked():
timeout=120000, polling=250) timeout=120000, polling=250)
assert not errors, errors assert not errors, errors
await browser.close() await browser.close()
def test_stale_history_remap_board():
asyncio.run(_stale_history_remap_board())
async def _stale_history_remap_board():
# provider switch (anidb -> hianime) left rows whose anidb slug 200s on
# hianime as a DIFFERENT anime (jujutsu-kaisen-2552 = Burning Kabaddi).
# The history page must flag it for remap instead of linking to that show.
jar = http.cookiejar.CookieJar()
op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
op.open(urllib.request.Request(BASE + "/login",
data=json.dumps({"username": "erin", "password": "erinpw12345"}).encode(),
headers={"Content-Type": "application/json", "X-Aniweb": "1"}, method="POST"))
op.open(urllib.request.Request(BASE + "/api/history",
data=json.dumps({"title": "Jujutsu Kaisen", "slug_id": "jujutsu-kaisen-2552",
"season_id": 2552, "ep_id": 35996, "lang": "jpn", "ep": 1,
"pos": 42}).encode(),
headers={"Content-Type": "application/json", "X-Aniweb": "1"}, method="POST"))
async with async_playwright() as p:
browser = await p.chromium.launch(args=LAUNCH_ARGS)
page = await (await browser.new_context()).new_page()
errors = []
page.on("pageerror", lambda e: errors.append(str(e)))
await _login(page, "erin", "erinpw12345")
await page.goto(BASE + "/?history")
await page.wait_for_function(
"document.querySelector('a.card.remap')", timeout=60000, polling=POLL)
stale = page.locator("a.card.remap", has_text="Jujutsu Kaisen")
assert "not found" in await stale.inner_text()
# the card must not link to the wrong-slug detail page; find-it goes
# to the title's search instead
assert not await stale.get_attribute("href")
await stale.locator("button:has-text('find it')").click(force=True)
await page.wait_for_function(
"location.search.includes('q=')", timeout=15000, polling=POLL)
assert not errors, errors
await browser.close()