Add per-user stats: play/error events + /api/stats + ?stats page

- 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)
This commit is contained in:
jozamudi committed 2026-08-24 17:09:27 +00:00
1 parent 202bfa41c3
commit 2f373b9bba
4 files changed
+217 -4

No files matched your search

+102 -4
View File
@@ -8,9 +8,10 @@ import base64
import json import json
import os import os
import time import time
from collections import Counter, OrderedDict
from datetime import datetime
from pathlib import Path from pathlib import Path
from base64 import urlsafe_b64encode, urlsafe_b64decode from base64 import urlsafe_b64encode, urlsafe_b64decode
from collections import OrderedDict
from urllib.parse import quote, urljoin, urlparse from urllib.parse import quote, urljoin, urlparse
from fastapi import Body, FastAPI, HTTPException, Query, Request 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} 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: def _b64(url: str) -> str:
return base64.urlsafe_b64encode(url.encode()).decode() return base64.urlsafe_b64encode(url.encode()).decode()
@@ -276,22 +362,34 @@ def api_episodes(anime_id: int):
@app.get("/api/episode/{ep_id}/stream") @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(): def go():
master = _upstream(upstream.master_m3u8, ep_id, lang) master = _upstream(upstream.master_m3u8, ep_id, lang)
if not master: if not master:
raise HTTPException(404, f"no {lang} stream for episode {ep_id}") raise HTTPException(404, f"no {lang} stream for episode {ep_id}")
return {"token": _b64(master), return {"token": _b64(master),
"qualities": _upstream(upstream.master_variants, master)} "qualities": _upstream(upstream.master_variants, master)}
try: try:
return go() out = go()
except HTTPException as e: except HTTPException as e:
# one-shot retry with a cold cache: recovers rotated/expired # one-shot retry with a cold cache: recovers rotated/expired
# playlist URLs from the TTL cache # playlist URLs from the TTL cache
if e.status_code == 502: if e.status_code == 502:
upstream.clear_cache() upstream.clear_cache()
return go() 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 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: def _rewrite_m3u8(text: str, base: str) -> str:
+67
View File
@@ -63,6 +63,10 @@ function postHistory(entry) {
fetch(BASE + "api/history", {method: "POST", headers: {"Content-Type": "application/json", "X-Aniweb": "1"}, fetch(BASE + "api/history", {method: "POST", headers: {"Content-Type": "application/json", "X-Aniweb": "1"},
body: JSON.stringify(entry)}).catch(() => {}); 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 fmtPos = s => { s = Math.floor(s || 0); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; };
const timeago = t => { const timeago = t => {
if (!t) return ""; if (!t) return "";
@@ -110,6 +114,9 @@ function showHome() {
if (history.length > 1) { if (history.length > 1) {
const head = el("div", "sec-head"); const head = el("div", "sec-head");
head.append(el("h2", null, "Recently played")); 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) { if (history.length >= 5) {
const va = el("a", null, "view all \u2192"); const va = el("a", null, "view all \u2192");
va.href = BASE + "?history"; va.href = BASE + "?history";
@@ -323,6 +330,64 @@ function histCard(x) {
return c; 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) { function markActive(b) {
for (const x of document.querySelectorAll(".ep")) x.classList.remove("active"); for (const x of document.querySelectorAll(".ep")) x.classList.remove("active");
b.classList.add("active"); b.classList.add("active");
@@ -388,6 +453,7 @@ function playerFail(msg) {
if (!currentEp) return; if (!currentEp) return;
playerBroken = true; playerBroken = true;
const { ep_id, ep_no } = currentEp; 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)); playerOverlay("Playback failed: " + msg, () => play(ep_id, ep_no));
} }
@@ -541,6 +607,7 @@ window.addEventListener("pagehide", savePos);
lang: p.get("lang") || "jpn", lang: p.get("lang") || "jpn",
}); });
else if (p.has("history")) await showHistory(); else if (p.has("history")) await showHistory();
else if (p.has("stats")) await showStats();
else showHome(); else showHome();
} catch (e) { fail(e); } } catch (e) { fail(e); }
})(); })();
+13
View File
@@ -49,6 +49,19 @@ h2 { margin: 0.4rem 0; }
.bar.small { height: 4px; margin-top: .4rem; } .bar.small { height: 4px; margin-top: .4rem; }
.grid.mini { grid-template-columns: repeat(auto-fill, minmax(140px, 170px)); } .grid.mini { grid-template-columns: repeat(auto-fill, minmax(140px, 170px)); }
.grid.mini .card { cursor: pointer; } .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-info { display: flex; flex-direction: column; padding: .5rem; }
.mini-meta { font-size: .75rem; color: var(--muted); margin-top: .1rem; } .mini-meta { font-size: .75rem; color: var(--muted); margin-top: .1rem; }
@media (max-width: 640px) { @media (max-width: 640px) {
+35
View File
@@ -167,6 +167,41 @@ def test_recent_searches(base=BASE):
assert req("POST", "/api/searches", {"q": " "}, jar=a, base=base) == 422 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): def test_self_delete(base=BASE):
jar = login("dave", "davepw12345", base) jar = login("dave", "davepw12345", base)
# give dave a searches file too: account delete must remove it # give dave a searches file too: account delete must remove it