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
+218 -5

No files matched your search

+35
View File
@@ -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