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/ .venv/
__pycache__/ __pycache__/
*.pyc *.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. Cloudflare workaround) and rewrites playlist URIs to go through itself.
""" """
import base64 import base64
import json
import time
from pathlib import Path
from urllib.parse import urljoin 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.responses import Response
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
@@ -15,6 +18,42 @@ import upstream
app = FastAPI(title="ani-web") 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: def _b64(url: str) -> str:
return base64.urlsafe_b64encode(url.encode()).decode() 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)); if (player) player.append(p); else fail(new Error(msg));
} }
// ---------- history ---------- // ---------- history (server-persisted, incl. watch position) ----------
const HKEY = "aniweb.history"; let history = [];
const hist = () => JSON.parse(localStorage.getItem(HKEY) || "[]"); async function refreshHistory() {
function pushHistory(entry) { try { history = await api("/api/history"); } catch { /* home just shows empty */ }
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)));
} }
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() { function showHome() {
main.innerHTML = ""; main.innerHTML = "";
const h = hist(); if (!history.length) { main.append(el("p", "muted", "Search for an anime to get started.")); return; }
if (!h.length) { main.append(el("p", "muted", "Search for an anime to get started.")); return; }
const sec = el("section"); const sec = el("section");
sec.append(el("h2", null, "Recently played")); sec.append(el("h2", null, "Recently played"));
for (const x of h) { for (const x of history.slice(0, 5)) {
const b = el("button", "hist-item", `${x.title} — Ep ${x.ep} (${x.lang === "eng" ? "dub" : "sub"})`); 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}`; b.onclick = () => location.href = `?anime=${x.slug_id}&season=${x.season_id}&ep=${x.ep_id}&lang=${x.lang}`;
sec.append(b); sec.append(b);
} }
@@ -128,7 +129,7 @@ async function showAnime(slug_id, opts = {}) {
if (cur) { markActive(cur); play(opts.ep, cur.dataset.no); } if (cur) { markActive(cur); play(opts.ep, cur.dataset.no); }
} else { } else {
// default: resume last watched ep of this season, else start at ep 1 // 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; const auto = h ? h.ep_id : +eps.querySelector(".ep")?.dataset.ep;
if (auto) { if (auto) {
const cur = [...eps.children].find(b => +b.dataset.ep === 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; } } function stopHls() { if (hls) { hls.destroy(); hls = null; } }
let currentEp = null, lastSavedPos = 0;
async function play(ep_id, ep_no) { async function play(ep_id, ep_no) {
try { try {
const s = await api(`/api/episode/${ep_id}/stream?lang=${lang}`); 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.loadSource("/stream/" + s.token);
hls.attachMedia(v); 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.onerror = () => showStreamError("media error: " + (v.error?.message || v.error?.code));
v.play().catch(() => showPlayOverlay(v)); // autoplay blocked (no user gesture) -> visible prompt 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) { } catch (e) {
showStreamError(e.message); 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`; 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) { function showPlayOverlay(v) {
const o = el("button", "play-overlay", "▶ click to play"); const o = el("button", "play-overlay", "▶ click to play");
o.onclick = () => { v.play(); o.remove(); }; o.onclick = () => { v.play(); o.remove(); };
@@ -189,6 +213,7 @@ function showPlayOverlay(v) {
// ---------- boot ---------- // ---------- boot ----------
(async () => { (async () => {
await refreshHistory();
const p = new URLSearchParams(location.search); const p = new URLSearchParams(location.search);
try { try {
if (p.get("q")) await showSearch(p.get("q")); if (p.get("q")) await showSearch(p.get("q"));