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:
1 parent
32f93c0f65
commit
a8426b5922
3 files changed
+80
-15
No files matched your search
@@ -1,3 +1,4 @@
|
|||||||
.venv/
|
.venv/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
data/
|
||||||
@@ -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
@@ -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"));
|
||||||
|
|||||||
Reference in new issue
Block a user