diff --git a/static/app.js b/static/app.js index 65d2c37..5b99457 100644 --- a/static/app.js +++ b/static/app.js @@ -127,7 +127,53 @@ const bar = (x, cls) => { b.append(Object.assign(el("div", "bar-fill"), {style: `width:${p}%`})); 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 const byShow = () => { const m = new Map(); @@ -189,6 +235,8 @@ function showHome() { return; } const hero = history[0]; + main.append(remapBoard()); + if (hero && hero._remapped) return; // 1. continue-watching hero const sec = el("section", "hero-sec"); const card = el("a", "hero"); @@ -467,6 +515,22 @@ function langLabel() { return lang === "eng" ? "▶ dub" : "▶ sub"; } // ---------- history page (full list + delete + client-side filter) ---------- const histClass = x => x.dur > 0 && x.pos >= x.dur * 0.95 ? "done" : (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() { document.title = "History \u2014 ani-web"; skel(10); @@ -474,6 +538,7 @@ async function showHistory() { main.innerHTML = ""; main.append(el("h2", null, "History")); if (!items.length) { main.append(el("p", "muted", "Nothing here.")); focusMain(); return; } + await Promise.all(items.map(checkEntry)); main.append(...histBoard(items)); focusMain(); } @@ -576,12 +641,14 @@ function topAnime(d, n = 0) { return ta; } 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"}; let active = "all"; const chips = el("div", "chips"); const grid = el("div", "grid"); 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); grid.replaceChildren(...f.map(histCard)); 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); } 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) { @@ -1169,6 +1244,7 @@ window.addEventListener("pagehide", () => savePos(0, true)); // keepalive: a pl } catch { /* not logged in: api() already redirected to /login */ } statusPill(); await refreshHistory(); + await Promise.all(history.map(checkEntry)); const p = new URLSearchParams(location.search); try { if (p.get("q")) await showSearch(p.get("q")); diff --git a/tests/test_api.py b/tests/test_api.py index deada4c..52bfe35 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -506,3 +506,25 @@ def test_fetch_parallel_stitches_range_lanes(): finally: u._session = real 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_ diff --git a/tests/test_browser.py b/tests/test_browser.py index ccd7e5e..2e0d8e3 100644 --- a/tests/test_browser.py +++ b/tests/test_browser.py @@ -450,3 +450,43 @@ async def _autoplay_blocked(): timeout=120000, polling=250) assert not errors, errors 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()