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:
1 parent
0f6bd0a52a
commit
379f591180
3 files changed
+141
-3
No files matched your search
+79
-3
@@ -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"));
|
||||
|
||||
@@ -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_
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user