Stats UI: per-anime progress rows, status chips, on-hold, streak, detail line
- server: /api/stats top_anime rows enriched — poster, total eps, cur_ep, last_seen, status (finished/ongoing/hold heuristic), pace (ep/day), sub/dub watch-time split, rewatches (backward jump >60s); new on_hold + best_streak top-level keys; optional ?slug= filter (json_extract) - frontend: 'Most watched' rows = poster + meta + status chip + completion bar (ongoing -> hold -> finished order); stat cards swap in on-hold and best streak, errors demoted to a one-liner; anime detail page gets a progress line (E12/24 · 4h 30m · 2.1 ep/day · status); history cards dim + label 'on hold' after 7 days; savePos now includes total eps - css: .chip, .stat-anime rows, .card.hold, .detail-prog; drop dead .stat-row - test: test_stats asserts the new fields + slug filter
This commit is contained in:
1 parent
2f373b9bba
commit
1be3f4f307
4 files changed
+155
-41
No files matched your search
+68
-16
@@ -247,12 +247,18 @@ def api_events(evt: dict = Body(...), request: Request = None):
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/stats")
|
@app.get("/api/stats")
|
||||||
def api_stats(request: Request, days: int = Query(30, ge=1, le=365)):
|
def api_stats(request: Request, days: int = Query(30, ge=1, le=365),
|
||||||
|
slug: str = Query(None)):
|
||||||
u = request.state.user
|
u = request.state.user
|
||||||
since = time.time() - days * 86400
|
since = time.time() - days * 86400
|
||||||
c = db.connect()
|
c = db.connect()
|
||||||
positions = c.execute("SELECT ts, payload FROM events WHERE user=? AND type='position' "
|
if slug:
|
||||||
"AND ts>=? ORDER BY ts", (u, since)).fetchall()
|
positions = c.execute("SELECT ts, payload FROM events WHERE user=? AND type='position' "
|
||||||
|
"AND ts>=? AND json_extract(payload, '$.slug_id')=? ORDER BY ts",
|
||||||
|
(u, since, slug)).fetchall()
|
||||||
|
else:
|
||||||
|
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=? "
|
plays = [r[0] for r in c.execute("SELECT payload FROM events WHERE user=? "
|
||||||
"AND type='play' AND ts>=?", (u, since))]
|
"AND type='play' AND ts>=?", (u, since))]
|
||||||
searches = [json.loads(r[0]).get("q") for r in c.execute(
|
searches = [json.loads(r[0]).get("q") for r in c.execute(
|
||||||
@@ -260,43 +266,87 @@ def api_stats(request: Request, days: int = Query(30, ge=1, le=365)):
|
|||||||
"ORDER BY ts", (u, since))]
|
"ORDER BY ts", (u, since))]
|
||||||
n_errors = c.execute("SELECT COUNT(*) FROM events WHERE user=? AND type='error' "
|
n_errors = c.execute("SELECT COUNT(*) FROM events WHERE user=? AND type='error' "
|
||||||
"AND ts>=?", (u, since)).fetchone()[0]
|
"AND ts>=?", (u, since)).fetchone()[0]
|
||||||
days_active = c.execute(
|
day_set = [r[0] for r in c.execute(
|
||||||
"SELECT COUNT(DISTINCT CAST(ts/86400 AS INTEGER)) FROM events "
|
"SELECT DISTINCT CAST(ts/86400 AS INTEGER) FROM events WHERE user=? AND ts>=? "
|
||||||
"WHERE user=? AND ts>=?", (u, since)).fetchone()[0]
|
"ORDER BY 1", (u, since))]
|
||||||
c.close()
|
c.close()
|
||||||
|
days_active = len(day_set)
|
||||||
|
best = streak = 0 # max consecutive-day run (streak)
|
||||||
|
for i, d in enumerate(day_set):
|
||||||
|
streak = streak + 1 if i and d == day_set[i - 1] + 1 else 1
|
||||||
|
best = max(best, streak)
|
||||||
|
|
||||||
# ponytail: watch time = sum of positive pos deltas <= 90s per (anime, ep).
|
# 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
|
# position events fire ~10s apart; a bigger jump is a seek/resume, not
|
||||||
|
# viewing; a backward jump >60s within an ep counts as a rewatch
|
||||||
eps = {}
|
eps = {}
|
||||||
watch_sec = [0.0] * 7 # Mon..Sun
|
watch_sec = [0.0] * 7 # Mon..Sun
|
||||||
for ts, payload in positions:
|
for ts, payload in positions:
|
||||||
e = json.loads(payload)
|
e = json.loads(payload)
|
||||||
k = (str(e.get("slug_id")), e.get("ep_id"))
|
k = (str(e.get("slug_id")), e.get("season_id"), e.get("ep_id"))
|
||||||
s = eps.setdefault(k, {"title": e.get("title"), "pos": None, "max": 0.0,
|
s = eps.setdefault(k, {"title": e.get("title"), "poster": e.get("poster"),
|
||||||
"dur": 0.0, "lang": e.get("lang"), "secs": 0.0})
|
"total": e.get("total"), "last_ts": 0.0, "last_ep": None,
|
||||||
|
"pos": None, "max": 0.0, "dur": 0.0, "lang": e.get("lang"),
|
||||||
|
"secs": 0.0, "rewinds": 0, "jpn": 0.0, "eng": 0.0,
|
||||||
|
"days": set()})
|
||||||
p = e.get("pos") or 0
|
p = e.get("pos") or 0
|
||||||
|
s["days"].add(ts // 86400)
|
||||||
|
s["last_ts"] = ts
|
||||||
|
if e.get("ep") is not None:
|
||||||
|
s["last_ep"] = e["ep"]
|
||||||
|
if e.get("poster"):
|
||||||
|
s["poster"] = e["poster"]
|
||||||
|
if e.get("total"):
|
||||||
|
s["total"] = e["total"]
|
||||||
if s["pos"] is not None and 0 < p - s["pos"] <= 90:
|
if s["pos"] is not None and 0 < p - s["pos"] <= 90:
|
||||||
s["secs"] += p - s["pos"]
|
s["secs"] += p - s["pos"]
|
||||||
|
s["eng" if e.get("lang") == "eng" else "jpn"] += p - s["pos"]
|
||||||
watch_sec[datetime.fromtimestamp(ts).weekday()] += p - s["pos"]
|
watch_sec[datetime.fromtimestamp(ts).weekday()] += p - s["pos"]
|
||||||
|
elif s["pos"] is not None and p < s["pos"] - 60:
|
||||||
|
s["rewinds"] += 1
|
||||||
s["pos"] = p
|
s["pos"] = p
|
||||||
s["max"] = max(s["max"], p)
|
s["max"] = max(s["max"], p)
|
||||||
s["dur"] = e.get("dur") or s["dur"]
|
s["dur"] = e.get("dur") or s["dur"]
|
||||||
if e.get("lang"):
|
if e.get("lang"):
|
||||||
s["lang"] = e["lang"]
|
s["lang"] = e["lang"]
|
||||||
|
|
||||||
per_anime, started, completed = {}, 0, 0
|
per_anime, started, completed, on_hold = {}, 0, 0, 0
|
||||||
for (slug, _ep), s in eps.items():
|
for (slug, _season, _ep), s in eps.items():
|
||||||
a = per_anime.setdefault(slug, {"title": s["title"], "minutes": 0.0,
|
a = per_anime.setdefault(slug, {"title": s["title"], "poster": s["poster"],
|
||||||
"started": 0, "completed": 0})
|
"total": s["total"], "last_seen": 0.0,
|
||||||
|
"cur_ep": s["last_ep"], "minutes": 0.0,
|
||||||
|
"started": 0, "completed": 0, "rewatches": 0,
|
||||||
|
"sub": 0.0, "dub": 0.0, "days": set()})
|
||||||
a["minutes"] += s["secs"] / 60
|
a["minutes"] += s["secs"] / 60
|
||||||
a["started"] += 1
|
a["started"] += 1
|
||||||
|
a["rewatches"] += s["rewinds"]
|
||||||
|
a["sub"] += s["jpn"]
|
||||||
|
a["dub"] += s["eng"]
|
||||||
|
a["days"] |= s["days"]
|
||||||
|
a["poster"] = a["poster"] or s["poster"]
|
||||||
|
a["total"] = a["total"] or s["total"]
|
||||||
|
a["title"] = a["title"] or s["title"]
|
||||||
started += 1
|
started += 1
|
||||||
|
if s["last_ts"] > a["last_seen"]:
|
||||||
|
a["last_seen"] = s["last_ts"]
|
||||||
|
a["cur_ep"] = s["last_ep"]
|
||||||
if s["dur"] > 0 and s["max"] >= s["dur"] * 0.95:
|
if s["dur"] > 0 and s["max"] >= s["dur"] * 0.95:
|
||||||
a["completed"] += 1
|
a["completed"] += 1
|
||||||
completed += 1
|
completed += 1
|
||||||
top = sorted(per_anime.values(), key=lambda a: -a["minutes"])[:10]
|
now = time.time()
|
||||||
for a in top:
|
for a in per_anime.values():
|
||||||
|
a["pace"] = round(a["started"] / len(a["days"]), 1)
|
||||||
|
# ponytail: finished = every started ep completed and (total unknown,
|
||||||
|
# or all eps done) — heuristic, fine for a v1 chip
|
||||||
|
fin = a["completed"] == a["started"] and (a["total"] is None
|
||||||
|
or a["completed"] >= a["total"])
|
||||||
|
a["status"] = ("finished" if fin else
|
||||||
|
"ongoing" if now - a["last_seen"] < 7 * 86400 else "hold")
|
||||||
|
if a["status"] == "hold":
|
||||||
|
on_hold += 1
|
||||||
a["minutes"] = round(a["minutes"])
|
a["minutes"] = round(a["minutes"])
|
||||||
|
a["sub"], a["dub"] = int(a["sub"]), int(a["dub"])
|
||||||
|
top = sorted(per_anime.values(), key=lambda a: -a["minutes"])[:10]
|
||||||
|
|
||||||
plays_by_lang = Counter(json.loads(p).get("lang", "jpn") for p in plays)
|
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
|
if not plays_by_lang: # pre-`play`-event data: fall back to position lang
|
||||||
@@ -307,6 +357,8 @@ def api_stats(request: Request, days: int = Query(30, ge=1, le=365)):
|
|||||||
"watch_minutes": round(sum(s["secs"] for s in eps.values()) / 60),
|
"watch_minutes": round(sum(s["secs"] for s in eps.values()) / 60),
|
||||||
"episodes": {"started": started, "completed": completed},
|
"episodes": {"started": started, "completed": completed},
|
||||||
"days_active": days_active,
|
"days_active": days_active,
|
||||||
|
"best_streak": best,
|
||||||
|
"on_hold": on_hold,
|
||||||
"plays": len(plays),
|
"plays": len(plays),
|
||||||
"errors": n_errors,
|
"errors": n_errors,
|
||||||
"by_weekday": [int(x) for x in watch_sec], # seconds, Mon..Sun
|
"by_weekday": [int(x) for x in watch_sec], # seconds, Mon..Sun
|
||||||
|
|||||||
+48
-11
@@ -207,6 +207,11 @@ async function showAnime(slug_id, opts = {}) {
|
|||||||
const crumb = el("a", "crumb", "\u2190 home");
|
const crumb = el("a", "crumb", "\u2190 home");
|
||||||
crumb.href = BASE;
|
crumb.href = BASE;
|
||||||
head.append(crumb, el("h2", null, detail.name));
|
head.append(crumb, el("h2", null, detail.name));
|
||||||
|
const progLine = el("div", "muted detail-prog");
|
||||||
|
head.append(progLine);
|
||||||
|
api(`/api/stats?days=30&slug=${encodeURIComponent(slug_id)}`)
|
||||||
|
.then(d => { if (d.top_anime[0]) progLine.textContent = animeLine(d.top_anime[0]); })
|
||||||
|
.catch(() => {});
|
||||||
main.append(head);
|
main.append(head);
|
||||||
|
|
||||||
const player = el("div", "player");
|
const player = el("div", "player");
|
||||||
@@ -303,7 +308,8 @@ async function showHistory() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function histCard(x) {
|
function histCard(x) {
|
||||||
const c = el("div", "card hist");
|
const hold = x.t && Date.now() / 1000 - x.t > 7 * 86400;
|
||||||
|
const c = el("div", "card hist" + (hold ? " hold" : ""));
|
||||||
c.onclick = () => location.href = histHref(x);
|
c.onclick = () => location.href = histHref(x);
|
||||||
if (x.poster) {
|
if (x.poster) {
|
||||||
const i = el("img"); i.src = x.poster; i.alt = "";
|
const i = el("img"); i.src = x.poster; i.alt = "";
|
||||||
@@ -311,7 +317,8 @@ function histCard(x) {
|
|||||||
}
|
}
|
||||||
const info = el("div", "mini-info");
|
const info = el("div", "mini-info");
|
||||||
info.append(el("div", "card-name", x.title),
|
info.append(el("div", "card-name", x.title),
|
||||||
el("div", "mini-meta", `E${x.ep} \u00b7 ${x.lang === "eng" ? "dub" : "sub"} \u00b7 ${timeago(x.t)}`));
|
el("div", "mini-meta", `E${x.ep} \u00b7 ${x.lang === "eng" ? "dub" : "sub"} \u00b7 ${timeago(x.t)}` +
|
||||||
|
(hold ? " \u00b7 on hold" : "")));
|
||||||
const pb = bar(x, "small");
|
const pb = bar(x, "small");
|
||||||
if (pb) info.append(pb);
|
if (pb) info.append(pb);
|
||||||
c.append(info);
|
c.append(info);
|
||||||
@@ -338,6 +345,21 @@ const statCard = (label, value) => {
|
|||||||
c.append(el("div", "stat-value", value), el("div", "muted", label));
|
c.append(el("div", "stat-value", value), el("div", "muted", label));
|
||||||
return c;
|
return c;
|
||||||
};
|
};
|
||||||
|
const holdDays = a => Math.max(1, Math.round((Date.now() / 1000 - a.last_seen) / 86400));
|
||||||
|
const statusLabel = a => a.status === "finished" ? "\u2713 finished"
|
||||||
|
: a.status === "ongoing" ? "\u25b6 ongoing" : `\u23f8 on hold \u00b7 ${holdDays(a)}d`;
|
||||||
|
const animeMeta = a => {
|
||||||
|
const bits = [`E${a.cur_ep}${a.total ? "/" + a.total : ""}`, fmtMin(a.minutes)];
|
||||||
|
if (a.pace > 0) bits.push(`${a.pace} ep/day`);
|
||||||
|
const tot = (a.sub || 0) + (a.dub || 0);
|
||||||
|
if (tot) {
|
||||||
|
const pct = Math.round(100 * (a.sub || 0) / tot);
|
||||||
|
bits.push(pct >= 90 ? "sub" : pct <= 10 ? "dub" : `sub ${pct}%`);
|
||||||
|
}
|
||||||
|
if (a.rewatches > 0) bits.push(`\u21ba ${a.rewatches} rewatch${a.rewatches > 1 ? "s" : ""}`);
|
||||||
|
return bits.join(" \u00b7 ");
|
||||||
|
};
|
||||||
|
const animeLine = a => `${animeMeta(a)} \u00b7 ${statusLabel(a)}`;
|
||||||
async function showStats() {
|
async function showStats() {
|
||||||
main.innerHTML = "";
|
main.innerHTML = "";
|
||||||
main.append(el("h2", null, "Stats \u2014 last 30 days"));
|
main.append(el("h2", null, "Stats \u2014 last 30 days"));
|
||||||
@@ -350,9 +372,11 @@ async function showStats() {
|
|||||||
statCard("watch time", fmtMin(d.watch_minutes)),
|
statCard("watch time", fmtMin(d.watch_minutes)),
|
||||||
statCard("episodes finished / started", `${d.episodes.completed} / ${d.episodes.started}`),
|
statCard("episodes finished / started", `${d.episodes.completed} / ${d.episodes.started}`),
|
||||||
statCard("days active", String(d.days_active)),
|
statCard("days active", String(d.days_active)),
|
||||||
statCard("sub \u00b7 dub plays", `${d.sub_dub.jpn} \u00b7 ${d.sub_dub.eng}`),
|
statCard("on hold", `${d.on_hold} anime`),
|
||||||
statCard("playback errors", String(d.errors)));
|
statCard("best streak", `${d.best_streak} days`),
|
||||||
|
statCard("sub \u00b7 dub plays", `${d.sub_dub.jpn} \u00b7 ${d.sub_dub.eng}`));
|
||||||
main.append(cards);
|
main.append(cards);
|
||||||
|
if (d.errors > 0) main.append(el("p", "muted", `${d.errors} playback error${d.errors > 1 ? "s" : ""} in the last ${d.days} days`));
|
||||||
const WD = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
const WD = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||||
const mx = Math.max(1, ...d.by_weekday);
|
const mx = Math.max(1, ...d.by_weekday);
|
||||||
const wk = el("div", "wk");
|
const wk = el("div", "wk");
|
||||||
@@ -365,16 +389,28 @@ async function showStats() {
|
|||||||
wk.append(col);
|
wk.append(col);
|
||||||
});
|
});
|
||||||
main.append(wk);
|
main.append(wk);
|
||||||
if (d.top_anime.length) {
|
const order = {ongoing: 0, hold: 1, finished: 2};
|
||||||
|
const list = [...d.top_anime].sort((x, y) =>
|
||||||
|
order[x.status] - order[y.status] || y.minutes - x.minutes);
|
||||||
|
if (list.length) {
|
||||||
const ta = el("section", null);
|
const ta = el("section", null);
|
||||||
ta.append(el("h2", null, "Most watched"));
|
ta.append(el("h2", null, "Most watched"));
|
||||||
for (const a of d.top_anime) {
|
for (const a of list) {
|
||||||
const row = el("div", "stat-row");
|
const row = el("div", "stat-anime");
|
||||||
row.append(el("span", "stat-title", a.title),
|
if (a.poster) {
|
||||||
el("span", "muted", `${fmtMin(a.minutes)} \u00b7 ${a.completed}/${a.started} ep`));
|
const i = el("img"); i.src = a.poster; i.alt = "";
|
||||||
|
row.append(i);
|
||||||
|
} else row.append(el("div", "sa-ph"));
|
||||||
|
const t1 = el("div", "sa-top");
|
||||||
|
t1.append(el("span", "sa-title", a.title), el("span", "muted", animeMeta(a)));
|
||||||
|
row.append(t1,
|
||||||
|
el("span", "chip" + (a.status === "finished" ? " fin" : a.status === "ongoing" ? " on" : ""),
|
||||||
|
statusLabel(a)));
|
||||||
const b = el("div", "bar small");
|
const b = el("div", "bar small");
|
||||||
const f = el("div", "bar-fill");
|
const f = el("div", "bar-fill");
|
||||||
f.style.width = (a.started ? Math.round(100 * a.completed / a.started) : 0) + "%";
|
const pct = a.total ? Math.min(100, Math.round(100 * a.completed / a.total))
|
||||||
|
: (a.started ? Math.round(100 * a.completed / a.started) : 0);
|
||||||
|
f.style.width = pct + "%";
|
||||||
b.append(f);
|
b.append(f);
|
||||||
row.append(b);
|
row.append(b);
|
||||||
ta.append(row);
|
ta.append(row);
|
||||||
@@ -561,7 +597,8 @@ function savePos() {
|
|||||||
if (!currentEp || currentEp.v.currentTime <= 3) return;
|
if (!currentEp || currentEp.v.currentTime <= 3) return;
|
||||||
lastSavedPos = currentEp.v.currentTime;
|
lastSavedPos = currentEp.v.currentTime;
|
||||||
postHistory({...meta(currentEp.v), title: detail.name, slug_id: detail.slug_id, season_id: seasonId,
|
postHistory({...meta(currentEp.v), title: detail.name, slug_id: detail.slug_id, season_id: seasonId,
|
||||||
ep_id: currentEp.ep_id, lang, ep: currentEp.ep_no, pos: currentEp.v.currentTime});
|
ep_id: currentEp.ep_id, lang, ep: currentEp.ep_no, pos: currentEp.v.currentTime,
|
||||||
|
...(epsList.length ? {total: epsList.length} : {})});
|
||||||
}
|
}
|
||||||
|
|
||||||
function watchPos(v) {
|
function watchPos(v) {
|
||||||
|
|||||||
+14
-4
@@ -58,10 +58,20 @@ h2 { margin: 0.4rem 0; }
|
|||||||
.wk-col { display: flex; flex-direction: column; align-items: center; gap: .2rem; }
|
.wk-col { display: flex; flex-direction: column; align-items: center; gap: .2rem; }
|
||||||
.wk-bar { width: 26px; background: var(--acc); border-radius: 4px; }
|
.wk-bar { width: 26px; background: var(--acc); border-radius: 4px; }
|
||||||
.wk-day { font-size: .7rem; color: var(--muted); }
|
.wk-day { font-size: .7rem; color: var(--muted); }
|
||||||
.stat-row { display: grid; grid-template-columns: minmax(0, 1fr) auto 90px; gap: .8rem;
|
.chip { font-size: .7rem; padding: .1rem .5rem; border-radius: 99px; border: 1px solid var(--bd);
|
||||||
align-items: center; margin: .35rem 0; }
|
color: var(--muted); white-space: nowrap; }
|
||||||
.stat-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.chip.on { color: var(--acc); border-color: var(--acc); }
|
||||||
.stat-row .bar { margin-top: 0; }
|
.chip.fin { background: var(--acc); border-color: var(--acc); color: #140b02; font-weight: 600; }
|
||||||
|
.stat-anime { display: grid; grid-template-columns: 44px minmax(0, 1fr) auto; gap: .3rem .8rem;
|
||||||
|
align-items: center; padding: .55rem; background: var(--card); border: 1px solid var(--bd);
|
||||||
|
border-radius: 10px; margin: .55rem 0; }
|
||||||
|
.stat-anime img, .sa-ph { grid-row: span 2; width: 44px; aspect-ratio: 2/3; object-fit: cover;
|
||||||
|
border-radius: 6px; background: var(--bd); }
|
||||||
|
.sa-top { display: flex; gap: .8rem; align-items: baseline; min-width: 0; }
|
||||||
|
.sa-title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.stat-anime .bar { grid-column: 2 / 4; margin-top: 0; }
|
||||||
|
.card.hold { opacity: .55; }
|
||||||
|
.detail-prog { margin-top: .1rem; font-size: .85rem; }
|
||||||
.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) {
|
||||||
|
|||||||
+25
-10
@@ -173,27 +173,42 @@ def test_stats(base=BASE):
|
|||||||
# fresh user: zeroed stats
|
# fresh user: zeroed stats
|
||||||
d = _json("GET", "/api/stats?days=7", jar=jar, base=base)
|
d = _json("GET", "/api/stats?days=7", jar=jar, base=base)
|
||||||
assert d["watch_minutes"] == 0 and d["episodes"] == {"started": 0, "completed": 0}
|
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
|
# synthetic viewing: ep 1 = 3 position events, pos deltas of 80s each
|
||||||
# (160s watched); one search
|
# (160s watched); ep 2 watched to 1190s of a 1200s dur (completed);
|
||||||
|
# one search. All an hour ago, jpn.
|
||||||
c = sqlite3.connect(os.path.join(os.environ["ANIWEB_DATA"], "aniweb.db"))
|
c = sqlite3.connect(os.path.join(os.environ["ANIWEB_DATA"], "aniweb.db"))
|
||||||
t0 = time.time() - 3600
|
t0 = time.time() - 3600
|
||||||
e = {"slug_id": "stat-a", "ep_id": 900, "title": "Stat Show", "lang": "jpn",
|
e = {"slug_id": "stat-a", "title": "Stat Show", "lang": "jpn",
|
||||||
"season_id": 1, "ep": "1", "dur": 1200}
|
"season_id": 1, "dur": 1200, "total": 24, "poster": "https://p/1.jpg"}
|
||||||
for i, pos in enumerate((0.0, 80.0, 160.0)):
|
for i, pos in enumerate((0.0, 80.0, 160.0)):
|
||||||
c.execute("INSERT INTO events (ts, user, type, payload) VALUES (?,?,?,?)",
|
c.execute("INSERT INTO events (ts, user, type, payload) VALUES (?,?,?,?)",
|
||||||
(t0 + 10 * i, "erin", "position", json.dumps({**e, "pos": pos, "t": t0 + 10 * i})))
|
(t0 + 10 * i, "erin", "position",
|
||||||
|
json.dumps({**e, "ep_id": 900, "ep": "1", "pos": pos, "t": t0 + 10 * i})))
|
||||||
|
for i, pos in enumerate((1100.0, 1190.0)):
|
||||||
|
c.execute("INSERT INTO events (ts, user, type, payload) VALUES (?,?,?,?)",
|
||||||
|
(t0 + 30 + 10 * i, "erin", "position",
|
||||||
|
json.dumps({**e, "ep_id": 901, "ep": "2", "pos": pos, "t": t0 + 30 + 10 * i})))
|
||||||
c.execute("INSERT INTO events (ts, user, type, payload) VALUES (?,?,?,?)",
|
c.execute("INSERT INTO events (ts, user, type, payload) VALUES (?,?,?,?)",
|
||||||
(t0, "erin", "search", json.dumps({"q": "stat anime"})))
|
(t0, "erin", "search", json.dumps({"q": "stat anime"})))
|
||||||
c.commit()
|
c.commit()
|
||||||
c.close()
|
c.close()
|
||||||
d = _json("GET", "/api/stats?days=7", jar=jar, base=base)
|
d = _json("GET", "/api/stats?days=7", jar=jar, base=base)
|
||||||
assert d["watch_minutes"] == 3, d # round(160/60)
|
assert d["watch_minutes"] == 4, d # round((160+90)/60)
|
||||||
assert d["episodes"] == {"started": 1, "completed": 0}
|
assert d["episodes"] == {"started": 2, "completed": 1}
|
||||||
assert d["days_active"] >= 1
|
assert d["days_active"] == 1 and d["best_streak"] == 1
|
||||||
assert sum(d["by_weekday"]) == 160 # seconds, Mon..Sun
|
assert d["on_hold"] == 0
|
||||||
assert d["top_anime"][0]["title"] == "Stat Show"
|
assert sum(d["by_weekday"]) == 250 # seconds, Mon..Sun
|
||||||
|
a0 = d["top_anime"][0]
|
||||||
|
assert a0["title"] == "Stat Show" and a0["status"] == "ongoing", a0
|
||||||
|
assert a0["cur_ep"] == "2" and a0["total"] == 24 and a0["poster"] == "https://p/1.jpg"
|
||||||
|
assert a0["sub"] == 250 and a0["dub"] == 0 and a0["pace"] == 2.0 and a0["rewatches"] == 0
|
||||||
assert d["top_searches"] == ["stat anime"]
|
assert d["top_searches"] == ["stat anime"]
|
||||||
assert d["sub_dub"]["jpn"] >= 1 and d["sub_dub"]["eng"] == 0
|
assert d["sub_dub"]["jpn"] >= 1 and d["sub_dub"]["eng"] == 0
|
||||||
|
# slug filter scopes to one anime
|
||||||
|
ds = _json("GET", "/api/stats?days=7&slug=stat-a", jar=jar, base=base)
|
||||||
|
assert len(ds["top_anime"]) == 1 and ds["watch_minutes"] == 4
|
||||||
|
dn = _json("GET", "/api/stats?days=7&slug=none", jar=jar, base=base)
|
||||||
|
assert dn["top_anime"] == [] and dn["watch_minutes"] == 0
|
||||||
# per-user isolation: alice never watched stat-a
|
# per-user isolation: alice never watched stat-a
|
||||||
a = login("alice", "alicepw12345", base)
|
a = login("alice", "alicepw12345", base)
|
||||||
da = _json("GET", "/api/stats?days=7", jar=a, base=base)
|
da = _json("GET", "/api/stats?days=7", jar=a, base=base)
|
||||||
|
|||||||
Reference in new issue
Block a user