diff --git a/.gitignore b/.gitignore index 77ac754..c8ce53e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .venv/ __pycache__/ *.pyc +data/ diff --git a/app.py b/app.py index 62f7f82..0767fe7 100644 --- a/app.py +++ b/app.py @@ -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() diff --git a/static/app.js b/static/app.js index 20e5b66..e9290d8 100644 --- a/static/app.js +++ b/static/app.js @@ -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"));