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}%`}));
|
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"));
|
||||||
|
|||||||
@@ -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_
|
||||||
@@ -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()
|
||||||
Reference in new issue
Block a user