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:
1 parent
202bfa41c3
commit
2f373b9bba
4 files changed
+217
-4
No files matched your search
+102
-4
@@ -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:
|
||||||
|
|||||||
@@ -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); }
|
||||||
})();
|
})();
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user