server-persisted watch history with position + resume

- POST/GET /api/history: JSON file in data/history.json (50 entries
  kept, top 5 shown on home), upsert per (anime, episode)
- watch position saved every ~10s of playback + on pause/unload
- resume: opening an anime seeks to the saved position unless
  >=95% through (then starts fresh); position shown on home items
- drops localStorage; server is the source of truth (verified:
  fresh watch -> new session resumes at saved pos -> home lists it)
This commit is contained in:
jozamudi committed 2026-08-19 15:28:00 +00:00
1 parent 32f93c0f65
commit a8426b5922
3 files changed
+80 -15

No files matched your search

+1
View File
@@ -1,3 +1,4 @@
.venv/
__pycache__/
*.pyc
data/
+40 -1
View File
@@ -5,9 +5,12 @@ pointed at /stream/{token}; the proxy fetches upstream (with the same
Cloudflare workaround) and rewrites playlist URIs to go through itself.
"""
import base64
import json
import time
from pathlib import Path
from urllib.parse import urljoin
from fastapi import FastAPI, HTTPException, Query
from fastapi import Body, FastAPI, HTTPException, Query
from fastapi.responses import Response
from fastapi.staticfiles import StaticFiles
@@ -15,6 +18,42 @@ import upstream
app = FastAPI(title="ani-web")
HIST_FILE = Path(__file__).parent / "data" / "history.json"
def _load_hist():
try:
return json.loads(HIST_FILE.read_text())
except (FileNotFoundError, json.JSONDecodeError):
return []
def _save_hist(entries):
HIST_FILE.parent.mkdir(exist_ok=True)
# ponytail: unguarded read-modify-write, fine for a single-user tool
HIST_FILE.write_text(json.dumps(entries, indent=1))
@app.get("/api/history")
def api_history():
return sorted(_load_hist(), key=lambda e: e.get("t", 0), reverse=True)[:5]
@app.post("/api/history")
def api_history_post(entry: dict = Body(...)):
if "ep_id" not in entry:
raise HTTPException(422, "ep_id required")
entry["t"] = time.time()
entries = _load_hist()
for i, e in enumerate(entries):
if e.get("slug_id") == entry.get("slug_id") and e.get("ep_id") == entry.get("ep_id"):
entries[i] = {**e, **entry}
break
else:
entries.append(entry)
_save_hist(sorted(entries, key=lambda e: e.get("t", 0), reverse=True)[:50])
return entry
def _b64(url: str) -> str:
return base64.urlsafe_b64encode(url.encode()).decode()
+39 -14
View File
@@ -30,23 +30,24 @@ function showStreamError(msg) {
if (player) player.append(p); else fail(new Error(msg));
}
// ---------- history ----------
const HKEY = "aniweb.history";
const hist = () => JSON.parse(localStorage.getItem(HKEY) || "[]");
function pushHistory(entry) {
const h = hist().filter(x => !(x.slug_id === entry.slug_id && x.ep_id === entry.ep_id));
h.unshift(entry);
localStorage.setItem(HKEY, JSON.stringify(h.slice(0, 20)));
// ---------- history (server-persisted, incl. watch position) ----------
let history = [];
async function refreshHistory() {
try { history = await api("/api/history"); } catch { /* home just shows empty */ }
}
function postHistory(entry) {
fetch("/api/history", {method: "POST", headers: {"Content-Type": "application/json"},
body: JSON.stringify(entry)}).catch(() => {});
}
const fmtPos = s => { s = Math.floor(s || 0); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; };
function showHome() {
main.innerHTML = "";
const h = hist();
if (!h.length) { main.append(el("p", "muted", "Search for an anime to get started.")); return; }
if (!history.length) { main.append(el("p", "muted", "Search for an anime to get started.")); return; }
const sec = el("section");
sec.append(el("h2", null, "Recently played"));
for (const x of h) {
const b = el("button", "hist-item", `${x.title} — Ep ${x.ep} (${x.lang === "eng" ? "dub" : "sub"})`);
for (const x of history.slice(0, 5)) {
const b = el("button", "hist-item", `${x.title} — Ep ${x.ep} (${x.lang === "eng" ? "dub" : "sub"}) · ${fmtPos(x.pos)}`);
b.onclick = () => location.href = `?anime=${x.slug_id}&season=${x.season_id}&ep=${x.ep_id}&lang=${x.lang}`;
sec.append(b);
}
@@ -128,7 +129,7 @@ async function showAnime(slug_id, opts = {}) {
if (cur) { markActive(cur); play(opts.ep, cur.dataset.no); }
} else {
// default: resume last watched ep of this season, else start at ep 1
const h = hist().find(x => x.slug_id === slug_id && x.season_id === seasonId);
const h = history.find(x => x.slug_id === slug_id && x.season_id === seasonId);
const auto = h ? h.ep_id : +eps.querySelector(".ep")?.dataset.ep;
if (auto) {
const cur = [...eps.children].find(b => +b.dataset.ep === auto);
@@ -154,6 +155,8 @@ function videoEl() {
function stopHls() { if (hls) { hls.destroy(); hls = null; } }
let currentEp = null, lastSavedPos = 0;
async function play(ep_id, ep_no) {
try {
const s = await api(`/api/episode/${ep_id}/stream?lang=${lang}`);
@@ -171,16 +174,37 @@ async function play(ep_id, ep_no) {
});
hls.loadSource("/stream/" + s.token);
hls.attachMedia(v);
currentEp = {ep_id, ep_no, v};
lastSavedPos = 0;
const saved = history.find(x => x.slug_id === detail.slug_id && x.ep_id === ep_id);
if (saved?.pos > 10) {
v.addEventListener("loadedmetadata", () => {
if (saved.pos < v.duration * 0.95) v.currentTime = saved.pos;
}, {once: true});
}
v.onerror = () => showStreamError("media error: " + (v.error?.message || v.error?.code));
v.play().catch(() => showPlayOverlay(v)); // autoplay blocked (no user gesture) -> visible prompt
watchPos(v);
postHistory({title: detail.name, slug_id: detail.slug_id, season_id: seasonId,
ep_id, lang, ep: ep_no, pos: saved?.pos || 0});
} catch (e) {
showStreamError(e.message);
}
pushHistory({title: detail.name, slug_id: detail.slug_id, season_id: seasonId,
ep_id, lang, ep: ep_no});
document.title = `${detail.name} E${ep_no} — ani-web`;
}
function watchPos(v) {
const save = () => {
if (!currentEp || v.currentTime <= 3) return;
lastSavedPos = v.currentTime;
postHistory({title: detail.name, slug_id: detail.slug_id, season_id: seasonId,
ep_id: currentEp.ep_id, lang, ep: currentEp.ep_no, pos: v.currentTime});
};
v.addEventListener("timeupdate", () => { if (v.currentTime - lastSavedPos >= 10) save(); });
v.addEventListener("pause", save);
window.addEventListener("pagehide", save);
}
function showPlayOverlay(v) {
const o = el("button", "play-overlay", "▶ click to play");
o.onclick = () => { v.play(); o.remove(); };
@@ -189,6 +213,7 @@ function showPlayOverlay(v) {
// ---------- boot ----------
(async () => {
await refreshHistory();
const p = new URLSearchParams(location.search);
try {
if (p.get("q")) await showSearch(p.get("q"));