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
+218
-5
No files matched your search
@@ -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); }
|
||||
})();
|
||||
Reference in new issue
Block a user