From d39ee838ff76bf359aeebc705ba10ff39673f9cf Mon Sep 17 00:00:00 2001 From: Josue Zamudio Date: Sun, 23 Aug 2026 21:29:01 +0000 Subject: [PATCH] api: dedup ghost history rows (int vs string ep_id); normalize ep_id on write --- server/app.py | 19 +++++++++++++++++-- tests/test_api.py | 17 +++++++++++++++-- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/server/app.py b/server/app.py index 238d038..05af4fe 100644 --- a/server/app.py +++ b/server/app.py @@ -125,9 +125,17 @@ HIST_DIR = Path(os.environ.get("ANIWEB_DATA", Path(__file__).parent / "data")) / def _load_hist(user: str): try: - return json.loads((HIST_DIR / f"{user}.json").read_text()) + entries = json.loads((HIST_DIR / f"{user}.json").read_text()) except (FileNotFoundError, json.JSONDecodeError): return [] + # ponytail: dedup legacy ghost rows — the same episode was stored with ep_id + # as int AND string; keep the newest per (slug_id, str(ep_id)) + best = {} + for e in entries: + k = (str(e.get("slug_id")), str(e.get("ep_id"))) + if e.get("t", 0) >= best.get(k, {"t": 0})["t"]: + best[k] = e + return sorted(best.values(), key=lambda e: e.get("t", 0), reverse=True) def _save_hist(user: str, entries): @@ -162,11 +170,18 @@ def api_history_delete(request: Request, slug_id: str, ep_id: str): def api_history_post(entry: dict = Body(...), request: Request = None): if "ep_id" not in entry: raise HTTPException(422, "ep_id required") + # normalize: ghost rows were born when ep_id arrived as str against an + # int row (or vice versa) — int() makes the stored type stable + try: + entry["ep_id"] = int(entry["ep_id"]) + except (TypeError, ValueError): + raise HTTPException(422, "ep_id must be an integer") entry["t"] = time.time() u = request.state.user entries = _load_hist(u) for i, e in enumerate(entries): - if e.get("slug_id") == entry.get("slug_id") and e.get("ep_id") == entry.get("ep_id"): + # loose str() match: merge onto a legacy ghost row of the other type + if e.get("slug_id") == entry.get("slug_id") and str(e.get("ep_id")) == str(entry["ep_id"]): entries[i] = {**e, **entry} break else: diff --git a/tests/test_api.py b/tests/test_api.py index c9d9396..aa50409 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -111,8 +111,9 @@ def test_history_per_user(base=BASE): assert req("POST", "/api/history", entry, jar=a, base=base) == 200 got_a = _json("GET", "/api/history", jar=a, base=base) got_b = _json("GET", "/api/history", jar=b, base=base) - assert any(e["ep_id"] == "111" for e in got_a) - assert all(e.get("ep_id") != "111" for e in got_b), "leak: admin entry in alice's history" + # ep_id is normalized to int on write (ghost-row fix) + assert any(e["ep_id"] == 111 for e in got_a) + assert all(e.get("ep_id") != 111 for e in got_b), "leak: admin entry in alice's history" def test_history_all_and_delete(base=BASE): @@ -138,6 +139,18 @@ def test_history_all_and_delete(base=BASE): jar=jar, base=base)["removed"] == 0 +def test_history_no_ghost_duplicates(base=BASE): + """same episode posted with int and string ep_id -> exactly one row""" + jar = login("alice", "alicepw12345", base) + for ep in (301, "301"): + assert req("POST", "/api/history", + {"ep_id": ep, "slug_id": "ghost-a", "title": "Ghost", "season_id": 1, + "ep": "1", "lang": "eng", "pos": 5}, jar=jar, base=base) == 200 + rows = [e for e in _json("GET", "/api/history?all=1", jar=jar, base=base) + if e["slug_id"] == "ghost-a"] + assert len(rows) == 1 and rows[0]["ep_id"] == 301, rows + + def test_recent_searches(base=BASE): a = login("alice", "alicepw12345", base) assert req("POST", "/api/searches", {"q": "naruto"}, jar=a, base=base) == 200