From 2f373b9bbad6910138d5c2f5c44d7f6b1fb79ea2 Mon Sep 17 00:00:00 2001 From: Josue Zamudio Date: Mon, 24 Aug 2026 17:09:27 +0000 Subject: [PATCH] Add per-user stats: play/error events + /api/stats + ?stats page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - server: 'play' event on successful stream fetch, 'error' on 404 stream failures; new POST /api/events for client-reported playback errors (only type=error accepted, fields allowlisted + length-capped) - server: GET /api/stats?days=N — watch time from position-event pos deltas (<=90s cap: bigger jumps are seeks), episodes started/completed (>=95% of dur), days active, weekday histogram, top anime/searches, sub/dub split, error count - frontend: ?stats page (cards, weekday bars, most-watched, top searches) + 'stats' link on home; playerFail now reports the error - test: test_stats with synthetic viewing data (deltas, isolation, 422) --- server/app.py | 108 +++++++++++++++++++++++++++++++++++++++++++--- static/app.js | 67 ++++++++++++++++++++++++++++ static/style.css | 13 ++++++ tests/test_api.py | 35 +++++++++++++++ 4 files changed, 218 insertions(+), 5 deletions(-) diff --git a/server/app.py b/server/app.py index 6539632..b6024f2 100644 --- a/server/app.py +++ b/server/app.py @@ -8,9 +8,10 @@ import base64 import json import os import time +from collections import Counter, OrderedDict +from datetime import datetime from pathlib import Path from base64 import urlsafe_b64encode, urlsafe_b64decode -from collections import OrderedDict from urllib.parse import quote, urljoin, urlparse from fastapi import Body, FastAPI, HTTPException, Query, Request @@ -230,6 +231,91 @@ def api_searches_post(item: dict = Body(...), request: Request = None): return {"ok": True} +@app.post("/api/events") +def api_events(evt: dict = Body(...), request: Request = None): + # client-reported events; only 'error' is accepted (playback failures + # happen in the browser, not in the request log) + if evt.get("type") != "error": + raise HTTPException(422, "unknown event type") + c = db.connect() + db.event(c, request.state.user, "error", + {k: evt[k] for k in ("ep_id", "ep", "lang") if k in evt} + | {"detail": str(evt.get("detail", ""))[:200]}) + c.commit() + c.close() + return {"ok": True} + + +@app.get("/api/stats") +def api_stats(request: Request, days: int = Query(30, ge=1, le=365)): + u = request.state.user + since = time.time() - days * 86400 + c = db.connect() + positions = c.execute("SELECT ts, payload FROM events WHERE user=? AND type='position' " + "AND ts>=? ORDER BY ts", (u, since)).fetchall() + plays = [r[0] for r in c.execute("SELECT payload FROM events WHERE user=? " + "AND type='play' AND ts>=?", (u, since))] + searches = [json.loads(r[0]).get("q") for r in c.execute( + "SELECT payload FROM events WHERE user=? AND type='search' AND ts>=? " + "ORDER BY ts", (u, since))] + n_errors = c.execute("SELECT COUNT(*) FROM events WHERE user=? AND type='error' " + "AND ts>=?", (u, since)).fetchone()[0] + days_active = c.execute( + "SELECT COUNT(DISTINCT CAST(ts/86400 AS INTEGER)) FROM events " + "WHERE user=? AND ts>=?", (u, since)).fetchone()[0] + c.close() + + # ponytail: watch time = sum of positive pos deltas <= 90s per (anime, ep). + # position events fire ~10s apart; a bigger jump is a seek/resume, not viewing + eps = {} + watch_sec = [0.0] * 7 # Mon..Sun + for ts, payload in positions: + e = json.loads(payload) + k = (str(e.get("slug_id")), e.get("ep_id")) + s = eps.setdefault(k, {"title": e.get("title"), "pos": None, "max": 0.0, + "dur": 0.0, "lang": e.get("lang"), "secs": 0.0}) + p = e.get("pos") or 0 + if s["pos"] is not None and 0 < p - s["pos"] <= 90: + s["secs"] += p - s["pos"] + watch_sec[datetime.fromtimestamp(ts).weekday()] += p - s["pos"] + s["pos"] = p + s["max"] = max(s["max"], p) + s["dur"] = e.get("dur") or s["dur"] + if e.get("lang"): + s["lang"] = e["lang"] + + per_anime, started, completed = {}, 0, 0 + for (slug, _ep), s in eps.items(): + a = per_anime.setdefault(slug, {"title": s["title"], "minutes": 0.0, + "started": 0, "completed": 0}) + a["minutes"] += s["secs"] / 60 + a["started"] += 1 + started += 1 + if s["dur"] > 0 and s["max"] >= s["dur"] * 0.95: + a["completed"] += 1 + completed += 1 + top = sorted(per_anime.values(), key=lambda a: -a["minutes"])[:10] + for a in top: + a["minutes"] = round(a["minutes"]) + + plays_by_lang = Counter(json.loads(p).get("lang", "jpn") for p in plays) + if not plays_by_lang: # pre-`play`-event data: fall back to position lang + plays_by_lang = Counter(s["lang"] for s in eps.values() if s["lang"]) + + return { + "days": days, + "watch_minutes": round(sum(s["secs"] for s in eps.values()) / 60), + "episodes": {"started": started, "completed": completed}, + "days_active": days_active, + "plays": len(plays), + "errors": n_errors, + "by_weekday": [int(x) for x in watch_sec], # seconds, Mon..Sun + "top_anime": top, + "top_searches": [q for q, _ in Counter(searches).most_common(5) if q], + "sub_dub": {"jpn": plays_by_lang.get("jpn", 0), "eng": plays_by_lang.get("eng", 0)}, + } + + def _b64(url: str) -> str: return base64.urlsafe_b64encode(url.encode()).decode() @@ -276,22 +362,34 @@ def api_episodes(anime_id: int): @app.get("/api/episode/{ep_id}/stream") -def api_stream(ep_id: int, lang: str = "jpn"): +def api_stream(ep_id: int, lang: str = "jpn", request: Request = None): def go(): master = _upstream(upstream.master_m3u8, ep_id, lang) if not master: raise HTTPException(404, f"no {lang} stream for episode {ep_id}") return {"token": _b64(master), "qualities": _upstream(upstream.master_variants, master)} + try: - return go() + out = go() except HTTPException as e: # one-shot retry with a cold cache: recovers rotated/expired # playlist URLs from the TTL cache if e.status_code == 502: upstream.clear_cache() - return go() - raise + out = go() + else: + c = db.connect() + db.event(c, request.state.user, "error", + {"ep_id": ep_id, "lang": lang, "detail": str(e.detail)}) + c.commit() + c.close() + raise + c = db.connect() + db.event(c, request.state.user, "play", {"ep_id": ep_id, "lang": lang}) + c.commit() + c.close() + return out def _rewrite_m3u8(text: str, base: str) -> str: diff --git a/static/app.js b/static/app.js index 5627695..a33ed77 100644 --- a/static/app.js +++ b/static/app.js @@ -63,6 +63,10 @@ function postHistory(entry) { fetch(BASE + "api/history", {method: "POST", headers: {"Content-Type": "application/json", "X-Aniweb": "1"}, body: JSON.stringify(entry)}).catch(() => {}); } +function postEvent(ev) { + fetch(BASE + "api/events", {method: "POST", headers: {"Content-Type": "application/json", "X-Aniweb": "1"}, + body: JSON.stringify(ev)}).catch(() => {}); +} const fmtPos = s => { s = Math.floor(s || 0); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; }; const timeago = t => { if (!t) return ""; @@ -110,6 +114,9 @@ function showHome() { if (history.length > 1) { const head = el("div", "sec-head"); head.append(el("h2", null, "Recently played")); + const st = el("a", null, "stats \u2192"); + st.href = BASE + "?stats"; + head.append(st); if (history.length >= 5) { const va = el("a", null, "view all \u2192"); va.href = BASE + "?history"; @@ -323,6 +330,64 @@ function histCard(x) { return c; } +// ---------- stats (last 30 days, server-computed) ---------- +const fmtMin = m => { m = Math.round(m || 0); + return m >= 60 ? `${Math.floor(m / 60)}h ${m % 60}m` : `${m}m`; }; +const statCard = (label, value) => { + const c = el("div", "card stat"); + c.append(el("div", "stat-value", value), el("div", "muted", label)); + return c; +}; +async function showStats() { + main.innerHTML = ""; + main.append(el("h2", null, "Stats \u2014 last 30 days")); + skel(5); + const d = await api("/api/stats?days=30"); + main.innerHTML = ""; + main.append(el("h2", null, "Stats \u2014 last 30 days")); + const cards = el("div", "grid stats"); + cards.append( + statCard("watch time", fmtMin(d.watch_minutes)), + statCard("episodes finished / started", `${d.episodes.completed} / ${d.episodes.started}`), + statCard("days active", String(d.days_active)), + statCard("sub \u00b7 dub plays", `${d.sub_dub.jpn} \u00b7 ${d.sub_dub.eng}`), + statCard("playback errors", String(d.errors))); + main.append(cards); + const WD = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + const mx = Math.max(1, ...d.by_weekday); + const wk = el("div", "wk"); + d.by_weekday.forEach((s, i) => { + const col = el("div", "wk-col"); + const b = el("div", "wk-bar"); + b.style.height = Math.max(3, Math.round(s / mx * 50)) + "px"; + b.title = fmtMin(s / 60); + col.append(b, el("div", "wk-day", WD[i])); + wk.append(col); + }); + main.append(wk); + if (d.top_anime.length) { + const ta = el("section", null); + ta.append(el("h2", null, "Most watched")); + for (const a of d.top_anime) { + const row = el("div", "stat-row"); + row.append(el("span", "stat-title", a.title), + el("span", "muted", `${fmtMin(a.minutes)} \u00b7 ${a.completed}/${a.started} ep`)); + const b = el("div", "bar small"); + const f = el("div", "bar-fill"); + f.style.width = (a.started ? Math.round(100 * a.completed / a.started) : 0) + "%"; + b.append(f); + row.append(b); + ta.append(row); + } + main.append(ta); + } + if (d.top_searches.length) { + main.append(el("section", null).append( + el("h2", null, "Top searches"), + el("div", "muted", d.top_searches.join(" \u00b7 ")))); + } +} + function markActive(b) { for (const x of document.querySelectorAll(".ep")) x.classList.remove("active"); b.classList.add("active"); @@ -388,6 +453,7 @@ function playerFail(msg) { if (!currentEp) return; playerBroken = true; const { ep_id, ep_no } = currentEp; + postEvent({type: "error", detail: msg, ep_id, ep: ep_no, lang}); playerOverlay("Playback failed: " + msg, () => play(ep_id, ep_no)); } @@ -541,6 +607,7 @@ window.addEventListener("pagehide", savePos); lang: p.get("lang") || "jpn", }); else if (p.has("history")) await showHistory(); + else if (p.has("stats")) await showStats(); else showHome(); } catch (e) { fail(e); } })(); diff --git a/static/style.css b/static/style.css index 66d2640..e3c519d 100644 --- a/static/style.css +++ b/static/style.css @@ -49,6 +49,19 @@ h2 { margin: 0.4rem 0; } .bar.small { height: 4px; margin-top: .4rem; } .grid.mini { grid-template-columns: repeat(auto-fill, minmax(140px, 170px)); } .grid.mini .card { cursor: pointer; } + +/* stats page (?stats) */ +.grid.stats { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); } +.card.stat { padding: .8rem; gap: .2rem; } +.stat-value { font-size: 1.3rem; font-weight: 700; } +.wk { display: flex; gap: .5rem; align-items: flex-end; margin: .4rem 0 .8rem; } +.wk-col { display: flex; flex-direction: column; align-items: center; gap: .2rem; } +.wk-bar { width: 26px; background: var(--acc); border-radius: 4px; } +.wk-day { font-size: .7rem; color: var(--muted); } +.stat-row { display: grid; grid-template-columns: minmax(0, 1fr) auto 90px; gap: .8rem; + align-items: center; margin: .35rem 0; } +.stat-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.stat-row .bar { margin-top: 0; } .mini-info { display: flex; flex-direction: column; padding: .5rem; } .mini-meta { font-size: .75rem; color: var(--muted); margin-top: .1rem; } @media (max-width: 640px) { diff --git a/tests/test_api.py b/tests/test_api.py index d36ad60..c9622eb 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -167,6 +167,41 @@ def test_recent_searches(base=BASE): assert req("POST", "/api/searches", {"q": " "}, jar=a, base=base) == 422 +def test_stats(base=BASE): + import sqlite3, time + jar = login("erin", "erinpw12345", base) + # fresh user: zeroed stats + d = _json("GET", "/api/stats?days=7", jar=jar, base=base) + assert d["watch_minutes"] == 0 and d["episodes"] == {"started": 0, "completed": 0} + # synthetic viewing: 3 position events an hour ago, pos deltas of 80s each + # (160s watched); one search + c = sqlite3.connect(os.path.join(os.environ["ANIWEB_DATA"], "aniweb.db")) + t0 = time.time() - 3600 + e = {"slug_id": "stat-a", "ep_id": 900, "title": "Stat Show", "lang": "jpn", + "season_id": 1, "ep": "1", "dur": 1200} + for i, pos in enumerate((0.0, 80.0, 160.0)): + c.execute("INSERT INTO events (ts, user, type, payload) VALUES (?,?,?,?)", + (t0 + 10 * i, "erin", "position", json.dumps({**e, "pos": pos, "t": t0 + 10 * i}))) + c.execute("INSERT INTO events (ts, user, type, payload) VALUES (?,?,?,?)", + (t0, "erin", "search", json.dumps({"q": "stat anime"}))) + c.commit() + c.close() + d = _json("GET", "/api/stats?days=7", jar=jar, base=base) + assert d["watch_minutes"] == 3, d # round(160/60) + assert d["episodes"] == {"started": 1, "completed": 0} + assert d["days_active"] >= 1 + assert sum(d["by_weekday"]) == 160 # seconds, Mon..Sun + assert d["top_anime"][0]["title"] == "Stat Show" + assert d["top_searches"] == ["stat anime"] + assert d["sub_dub"]["jpn"] >= 1 and d["sub_dub"]["eng"] == 0 + # per-user isolation: alice never watched stat-a + a = login("alice", "alicepw12345", base) + da = _json("GET", "/api/stats?days=7", jar=a, base=base) + assert all(x["title"] != "Stat Show" for x in da["top_anime"]) + # days window is validated + assert req("GET", "/api/stats?days=0", jar=jar, base=base) == 422 + + def test_self_delete(base=BASE): jar = login("dave", "davepw12345", base) # give dave a searches file too: account delete must remove it