storage: JSON files -> SQLite (users/history/searches/events)
- one-time import from the legacy JSON (left in place as snapshot); ghost rows (int vs str ep_id) deduped on import, unrepresentable after - history POST is now an upsert: the (user, slug_id, ep_id) PK is the merge key, so the unguarded read-modify-write (lost updates) and the corruption-swallow (JSONDecodeError -> [] -> fresh overwrite) are gone - searches: NOCASE PK upsert + cap 8 - events table + position/search logging (watch-time trajectory), 90-day retention pruned at boot - account delete cascades all four tables - 22/22 tests green; old JSON files untouched
This commit is contained in:
1 parent
2c0ee8eae3
commit
202bfa41c3
5 files changed
+222
-105
No files matched your search
+48
-56
@@ -18,13 +18,14 @@ from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Resp
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from itsdangerous import BadSignature, TimestampSigner
|
||||
|
||||
from . import upstream, users
|
||||
from . import db, upstream, users
|
||||
|
||||
ROOT = Path(__file__).parent.parent
|
||||
app = FastAPI(title="ani-web")
|
||||
|
||||
# ---------- auth: multi-user, signed cookies ----------
|
||||
users.migrate_legacy()
|
||||
users.migrate_legacy() # file-based legacy import; db.init() absorbs its output
|
||||
db.init()
|
||||
_SIGNER = TimestampSigner(users.get_secret())
|
||||
_SESSION = "aniweb_session"
|
||||
_SESSION_AGE = 30 * 86400
|
||||
@@ -133,29 +134,12 @@ def logout():
|
||||
return resp
|
||||
|
||||
|
||||
HIST_DIR = Path(os.environ.get("ANIWEB_DATA", Path(__file__).parent / "data")) / "history"
|
||||
|
||||
|
||||
def _load_hist(user: str):
|
||||
try:
|
||||
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):
|
||||
HIST_DIR.mkdir(exist_ok=True)
|
||||
# ponytail: unguarded read-modify-write per file, fine at this scale;
|
||||
# atomic_write keeps a crash/race from corrupting the file
|
||||
users.atomic_write(HIST_DIR / f"{user}.json", json.dumps(entries, indent=1))
|
||||
c = db.connect()
|
||||
rows = c.execute("SELECT payload FROM history WHERE user=? ORDER BY t DESC",
|
||||
(user,)).fetchall()
|
||||
c.close()
|
||||
return [json.loads(r[0]) for r in rows]
|
||||
|
||||
|
||||
@app.get("/api/history")
|
||||
@@ -171,13 +155,18 @@ def api_history(request: Request):
|
||||
|
||||
@app.delete("/api/history")
|
||||
def api_history_delete(request: Request, slug_id: str, ep_id: str):
|
||||
# loose str() match on purpose: legacy rows store the same ep_id as int
|
||||
# AND as string — one delete clears both ghost rows
|
||||
entries = _load_hist(request.state.user)
|
||||
keep = [e for e in entries
|
||||
if not (str(e.get("slug_id")) == slug_id and str(e.get("ep_id")) == ep_id)]
|
||||
_save_hist(request.state.user, keep)
|
||||
return {"removed": len(entries) - len(keep)}
|
||||
# ep_id is a stable INTEGER in the schema (ghost rows died in the import),
|
||||
# so a strict match suffices
|
||||
try:
|
||||
ep = int(ep_id)
|
||||
except ValueError:
|
||||
return {"removed": 0}
|
||||
c = db.connect()
|
||||
cur = c.execute("DELETE FROM history WHERE user=? AND slug_id=? AND ep_id=?",
|
||||
(request.state.user, slug_id, ep))
|
||||
c.commit()
|
||||
c.close()
|
||||
return {"removed": cur.rowcount}
|
||||
|
||||
|
||||
@app.post("/api/history")
|
||||
@@ -192,31 +181,27 @@ def api_history_post(entry: dict = Body(...), request: Request = None):
|
||||
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):
|
||||
# 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:
|
||||
entries.append(entry)
|
||||
_save_hist(u, sorted(entries, key=lambda e: e.get("t", 0), reverse=True)[:50])
|
||||
c = db.connect()
|
||||
# upsert: the (user, slug_id, ep_id) PK *is* the merge key, so no
|
||||
# read-modify-write (and no lost-update race between the read and write)
|
||||
c.execute("INSERT INTO history (user, slug_id, ep_id, t, payload) VALUES (?,?,?,?,?) "
|
||||
"ON CONFLICT(user, slug_id, ep_id) DO UPDATE SET "
|
||||
"t=excluded.t, payload=excluded.payload",
|
||||
(u, str(entry.get("slug_id")), entry["ep_id"], entry["t"], json.dumps(entry)))
|
||||
# cap: newest 50 per user
|
||||
c.execute("DELETE FROM history WHERE user=? AND rowid NOT IN "
|
||||
"(SELECT rowid FROM history WHERE user=? ORDER BY t DESC LIMIT 50)", (u, u))
|
||||
db.event(c, u, "position", entry) # metrics: watch-time trajectory
|
||||
c.commit()
|
||||
c.close()
|
||||
return entry
|
||||
|
||||
|
||||
SEARCH_DIR = Path(os.environ.get("ANIWEB_DATA", Path(__file__).parent / "data")) / "searches"
|
||||
|
||||
|
||||
def _load_searches(user: str):
|
||||
try:
|
||||
return json.loads((SEARCH_DIR / f"{user}.json").read_text())
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return []
|
||||
|
||||
|
||||
def _save_searches(user: str, items):
|
||||
SEARCH_DIR.mkdir(exist_ok=True)
|
||||
users.atomic_write(SEARCH_DIR / f"{user}.json", json.dumps(items, indent=1))
|
||||
c = db.connect()
|
||||
rows = c.execute("SELECT q FROM searches WHERE user=? ORDER BY t DESC", (user,)).fetchall()
|
||||
c.close()
|
||||
return [r[0] for r in rows]
|
||||
|
||||
|
||||
@app.get("/api/searches")
|
||||
@@ -231,10 +216,17 @@ def api_searches_post(item: dict = Body(...), request: Request = None):
|
||||
if not q:
|
||||
raise HTTPException(422, "q required")
|
||||
u = request.state.user
|
||||
# dedupe case-insensitively, newest first, cap 8
|
||||
items = [x for x in _load_searches(u) if x.lower() != q.lower()]
|
||||
items.insert(0, q)
|
||||
_save_searches(u, items[:8])
|
||||
# NOCASE PK: re-posting a seen query (any casing) conflicts on the row,
|
||||
# so the upsert rewrites q to the newest casing, then caps at 8
|
||||
c = db.connect()
|
||||
c.execute("INSERT INTO searches (user, q, t) VALUES (?,?,?) "
|
||||
"ON CONFLICT(user, q) DO UPDATE SET q=excluded.q, t=excluded.t",
|
||||
(u, q, time.time()))
|
||||
c.execute("DELETE FROM searches WHERE user=? AND rowid NOT IN "
|
||||
"(SELECT rowid FROM searches WHERE user=? ORDER BY t DESC LIMIT 8)", (u, u))
|
||||
db.event(c, u, "search", {"q": q})
|
||||
c.commit()
|
||||
c.close()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user