Files
ani-web/static/app.js
T
jozamudi aae5857dba harden: stream-token host allowlist (SSRF), CSRF header on mutating routes, atomic JSON writes
- /stream/{token}: tokens are plain base64; allowlist https://hls.anidb.app
  so a session can't use the proxy for arbitrary URLs (new test)
- X-Aniweb:1 required on POST/DELETE except /login — SameSite=Lax is
  still sent on cross-site form POSTs, which could hit /account/delete
  (new test; SPA + tests send the header)
- tmp+os.replace writes for users/history/searches: a crash mid-write
  could corrupt the JSON -> load() returned {} -> silent data loss
- resume positions now read from full history (?all=1), not top-5
- pagehide bound once; savePos reads currentEp (no stale closures)
- account delete also removes the searches file; episodes TTL 5m
- ani-web.service: supervised restart (not installed; see header)
- gitignore pi-session dumps + .pytest_cache
2026-08-23 23:22:09 +00:00

537 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// app.js — ani-web frontend: search -> detail -> play, vanilla JS + hls.js
// BASE = this page's directory, so the app works at / or behind a
// prefixing reverse proxy (e.g. https://host/ani/)
const BASE = (() => {
const p = location.pathname;
return p.endsWith("/") ? p : p.slice(0, p.lastIndexOf("/") + 1);
})();
const main = document.getElementById("main");
let hls = null, detail = null, lang = "jpn", seasonId = null;
async function api(path) {
const r = await fetch(BASE + path.replace(/^\//, ""), {headers: {"X-Aniweb": "1"}});
if (r.status === 401) {
location.href = BASE + "login?next=" + encodeURIComponent(location.pathname + location.search);
throw new Error("not authenticated");
}
if (!r.ok) {
let msg = r.statusText;
try { msg = (await r.json()).detail || msg; } catch {}
throw new Error(msg);
}
return r.json();
}
function el(tag, cls, text) {
const e = document.createElement(tag);
if (cls) e.className = cls;
if (text != null) e.textContent = text;
return e;
}
function fail(err) {
main.innerHTML = "";
main.append(el("p", "error", "Error: " + err.message));
}
function skel(n = 8) {
main.innerHTML = "";
const g = el("div", "grid");
for (let i = 0; i < n; i++) g.append(el("div", "card skel"));
main.append(g);
}
function toast(msg) {
const t = el("div", "toast", msg);
document.getElementById("toasts").append(t);
setTimeout(() => t.remove(), 2600);
}
function showStreamError(msg) {
const p = el("p", "error", "Stream error: " + msg);
const player = document.getElementById("player");
if (player) player.append(p); else fail(new Error(msg));
}
// ---------- history (server-persisted, incl. watch position) ----------
let history = [];
async function refreshHistory() {
// ?all=1: resume positions live beyond the top-5 cap
try { history = await api("/api/history?all=1"); } catch { /* home just shows empty */ }
}
function postHistory(entry) {
fetch(BASE + "api/history", {method: "POST", headers: {"Content-Type": "application/json", "X-Aniweb": "1"},
body: JSON.stringify(entry)}).catch(() => {});
}
const fmtPos = s => { s = Math.floor(s || 0); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; };
const timeago = t => {
if (!t) return "";
const d = Math.max(0, Date.now() / 1000 - t);
if (d < 90) return "just now";
if (d < 3600) return `${Math.round(d / 60)}m ago`;
if (d < 86400) return `${Math.round(d / 3600)}h ago`;
return `${Math.round(d / 86400)}d ago`;
};
const prog = x => x.dur > 0 ? Math.min(100, Math.round(100 * x.pos / x.dur)) : null;
const bar = (x, cls) => {
const p = prog(x);
if (p == null) return null;
const b = el("div", "bar" + (cls ? " " + cls : ""));
b.append(Object.assign(el("div", "bar-fill"), {style: `width:${p}%`}));
return b;
};
const histHref = x => `?anime=${x.slug_id}&season=${x.season_id}&ep=${x.ep_id}&lang=${x.lang}`;
function showHome() {
main.innerHTML = "";
if (!history.length) { main.append(el("p", "muted", "Search for an anime to get started.")); return; }
const hero = history[0];
// 1. continue-watching hero
const sec = el("section", "hero-sec");
const card = el("a", "hero");
card.href = histHref(hero);
if (hero.poster) {
const img = el("img"); img.src = hero.poster; img.alt = "";
card.append(img);
}
const right = el("div", "right");
const body = el("div", "hero-body");
body.append(el("div", "hero-kicker", "continue watching"),
el("h2", null, hero.title),
el("div", "muted", `Ep ${hero.ep} · ${hero.lang === "eng" ? "dub" : "sub"} · ${fmtPos(hero.pos)} in · ${timeago(hero.t)}`));
const p = bar(hero);
if (p) body.append(p);
const go = el("button", "btn resume", "▶ Resume");
go.onclick = () => location.href = histHref(hero);
right.append(body, go);
sec.append(card, right);
main.append(sec);
// 2-4. the rest as mini poster cards with progress + time-ago
if (history.length > 1) {
const head = el("div", "sec-head");
head.append(el("h2", null, "Recently played"));
if (history.length >= 5) {
const va = el("a", null, "view all \u2192");
va.href = BASE + "?history";
head.append(va);
}
main.append(head);
const grid = el("div", "grid mini");
for (const x of history.slice(1, 6)) {
const c = el("a", "card");
c.href = histHref(x);
if (x.poster) {
const i = el("img"); i.src = x.poster; i.alt = "";
c.append(i);
}
const info = el("div", "mini-info");
info.append(el("div", "card-name", x.title));
const meta = el("div", "mini-meta");
meta.textContent = `E${x.ep} · ${x.lang === "eng" ? "dub" : "sub"} · ${timeago(x.t)}`;
info.append(meta);
const pb = bar(x, "small");
if (pb) info.append(pb);
c.append(info);
grid.append(c);
}
main.append(grid);
}
}
// ---------- search ----------
function saveSearch(q) {
fetch(BASE + "api/searches", {method: "POST", headers: {"Content-Type": "application/json", "X-Aniweb": "1"},
body: JSON.stringify({q})}).catch(() => {});
}
async function showSearch(q) {
saveSearch(q);
skel(8);
const results = await api(`/api/search?q=${encodeURIComponent(q)}`);
main.innerHTML = "";
if (!results.length) { main.append(el("p", "muted", "No results.")); return; }
const grid = el("div", "grid");
for (const r of results) {
const card = el("a", "card");
card.href = `?anime=${r.slug_id}`;
const img = el("img", null, r.name);
img.src = r.poster || "";
card.append(img, el("span", "card-name", r.name));
grid.append(card);
}
main.append(grid);
}
// ---------- recent searches dropdown ----------
function initRecent() {
const q = document.getElementById("q");
const box = el("div", "recent");
q.closest("form").append(box);
let items = [];
const render = () => {
const v = q.value.trim().toLowerCase();
const list = items.filter(s => !v || s.toLowerCase().includes(v)).slice(0, 8);
box.replaceChildren();
if (!list.length) { box.classList.remove("open"); return; }
for (const s of list) {
const b = el("button", null, s);
b.onclick = () => { q.value = s; location.href = "?q=" + encodeURIComponent(s); };
box.append(b);
}
box.classList.add("open");
};
const hide = () => box.classList.remove("open");
q.addEventListener("focus", () => api("/api/searches").then(r => { items = r; render(); }).catch(hide));
q.addEventListener("input", render);
document.addEventListener("click", e => { if (!e.target.closest("header form")) hide(); });
document.addEventListener("keydown", e => { if (e.key === "Escape") hide(); });
}
// ---------- detail + player ----------
async function showAnime(slug_id, opts = {}) {
skel();
detail = await api(`/api/anime/${slug_id}`);
lang = opts.lang || "jpn";
seasonId = opts.season || detail.id;
detail.slug_id = slug_id;
main.innerHTML = "";
const head = el("section", "detail-head");
const crumb = el("a", "crumb", "\u2190 home");
crumb.href = BASE;
head.append(crumb, el("h2", null, detail.name));
main.append(head);
const player = el("div", "player");
player.id = "player";
player.append(videoEl());
main.append(player);
const controls = el("div", "controls");
const langBtn = el("button", null, langLabel());
langBtn.onclick = () => {
lang = lang === "eng" ? "jpn" : "eng";
langBtn.textContent = langLabel();
const cur = main.querySelector(".ep.active");
if (cur) play(cur.dataset.ep, cur.dataset.no);
};
const spd = el("select", "spd");
for (const r of [0.5, 0.75, 1, 1.25, 1.5, 2]) spd.append(new Option(r + "×", r));
spd.value = localStorage.getItem("aniweb_spd") || "1";
spd.title = "playback speed";
spd.onchange = () => {
localStorage.setItem("aniweb_spd", spd.value);
const v = document.querySelector("#player video");
if (v) v.playbackRate = +spd.value;
};
const prevB = el("button", "tiny", "◀ prev");
const nextB = el("button", "tiny", "next ▶");
prevB.onclick = () => stepEp(-1);
nextB.onclick = () => stepEp(1);
const fillB = el("button", "tiny", "hide fillers");
fillB.onclick = () => {
hideFiller = !hideFiller;
fillB.textContent = hideFiller ? "show fillers" : "hide fillers";
fillB.classList.toggle("on", hideFiller);
renderEps();
};
controls.append(langBtn, spd, prevB, nextB, fillB,
el("span", "muted", " — click an episode to play"));
main.append(controls);
const tabs = el("div", "tabs");
for (const s of detail.seasons) {
const t = el("button", "tab" + (s.id === seasonId ? " active" : ""), s.name);
t.dataset.id = s.id;
t.onclick = () => {
for (const x of tabs.children) x.classList.toggle("active", +x.dataset.id === s.id);
location.href = `?anime=${s.slug_id}&season=${s.id}&lang=${lang}`;
};
tabs.append(t);
}
main.append(tabs);
const eps = el("div", "eps");
eps.id = "eps";
eps.append(el("p", "muted", "loading episodes\u2026"));
main.append(eps);
// watched state: dim completed eps, highlight the first unwatched one
// (ponytail: "first unwatched in list order" is wrong if you skip around;
// good enough for sequential viewing)
epsList = await api(`/api/anime/${seasonId}/episodes`);
watchDone = new Set();
let started = false;
try {
for (const x of await api("/api/history?all=1")) {
if (x.slug_id !== slug_id || x.season_id !== seasonId) continue;
started = true;
if (x.dur > 0 && x.pos >= x.dur * 0.95) watchDone.add(String(x.ep_id));
}
} catch {}
watchNext = started ? (epsList.find(e => !watchDone.has(String(e.id))) || {}).id : null;
renderEps();
const h = history.find(x => x.slug_id === slug_id && x.season_id === seasonId);
const start = opts.ep || (h ? h.ep_id : null) || epsList[0]?.id;
if (start) {
const cur = [...eps.children].find(b => +b.dataset.ep === start);
if (cur) { markActive(cur); play(start, cur.dataset.no); }
}
if (detail.synopsis) main.append(el("p", "synopsis bottom", detail.synopsis));
}
function langLabel() { return lang === "eng" ? "▶ dub" : "▶ sub"; }
// ---------- history page (full list + delete) ----------
async function showHistory() {
skel(10);
const items = await api("/api/history?all=1");
main.innerHTML = "";
main.append(el("h2", null, "History"));
if (!items.length) { main.append(el("p", "muted", "Nothing here.")); return; }
const grid = el("div", "grid");
for (const x of items) grid.append(histCard(x));
main.append(grid);
}
function histCard(x) {
const c = el("div", "card hist");
c.onclick = () => location.href = histHref(x);
if (x.poster) {
const i = el("img"); i.src = x.poster; i.alt = "";
c.append(i);
}
const info = el("div", "mini-info");
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)}`));
const pb = bar(x, "small");
if (pb) info.append(pb);
c.append(info);
const d = el("button", "del", "\u00d7");
d.title = "remove from history";
d.onclick = async e => {
e.preventDefault();
e.stopPropagation();
await fetch(BASE + "api/history?slug_id=" + encodeURIComponent(x.slug_id) +
"&ep_id=" + encodeURIComponent(x.ep_id),
{method: "DELETE", headers: {"X-Aniweb": "1"}});
toast("Removed from history");
await showHistory();
};
c.append(d);
return c;
}
function markActive(b) {
for (const x of document.querySelectorAll(".ep")) x.classList.remove("active");
b.classList.add("active");
}
let epsList = [], hideFiller = false;
let watchDone = new Set(), watchNext = null;
function renderEps() {
const eps = document.getElementById("eps");
if (!eps) return;
eps.innerHTML = "";
for (const e of epsList) {
if (hideFiller && e.filler) continue;
const b = el("button", "ep" + (e.filler ? " filler" : ""));
const done = watchDone.has(String(e.id));
b.textContent = done ? "E" + e.number + " \u2713" : "E" + e.number;
if (done) b.classList.add("done");
if (watchNext != null && String(watchNext) === String(e.id)) b.classList.add("nextup");
if (e.filler) b.append(el("span", "badge", " filler"));
b.dataset.ep = e.id; b.dataset.no = e.number;
b.onclick = () => { markActive(b); play(e.id, e.number); };
eps.append(b);
}
}
function stepEp(d) {
const vis = epsList.filter(e => !(hideFiller && e.filler));
if (!vis.length || !currentEp) return;
const i = vis.findIndex(e => String(e.id) === String(currentEp.ep_id));
const n = vis[i + d];
if (!n) return;
const b = [...document.querySelectorAll(".ep")].find(x => +x.dataset.ep === n.id);
if (b) { markActive(b); play(n.id, n.number); }
}
function videoEl() {
const v = document.createElement("video");
v.controls = true; v.autoplay = true;
return v;
}
function stopHls() { if (hls) { hls.destroy(); hls = null; } }
let currentEp = null, lastSavedPos = 0, playerBroken = false;
function playerOverlay(text, onRetry) {
const p = document.getElementById("player");
let o = p.querySelector(".pl-overlay");
if (!o) { o = el("div", "pl-overlay"); p.append(o); }
o.replaceChildren(el("span", null, text));
if (onRetry) {
const b = el("button", "btn", "Retry");
b.onclick = onRetry;
o.append(b);
}
}
function playerHideOverlay() {
const o = document.querySelector(".pl-overlay");
if (o) o.remove();
}
function playerFail(msg) {
if (!currentEp) return;
playerBroken = true;
const { ep_id, ep_no } = currentEp;
playerOverlay("Playback failed: " + msg, () => play(ep_id, ep_no));
}
// ---------- auto-next on episode end (5s countdown) ----------
let nextTimer = null;
function onEnded() {
if (!currentEp || playerBroken) return;
const vis = epsList.filter(e => !(hideFiller && e.filler));
const i = vis.findIndex(e => String(e.id) === String(currentEp.ep_id));
const nxt = vis[i + 1];
if (!nxt) { playerHideOverlay(); toast("No more episodes"); return; }
nextCountdown(nxt);
}
function nextCountdown(nxt) {
const p = document.getElementById("player");
let o = p.querySelector(".pl-overlay");
if (!o) { o = el("div", "pl-overlay"); p.append(o); }
let n = 5;
const done = () => {
if (nextTimer) { clearTimeout(nextTimer); nextTimer = null; }
const b = [...document.querySelectorAll(".ep")].find(x => +x.dataset.ep === nxt.id);
if (b) markActive(b);
play(nxt.id, nxt.number);
};
const cancel = () => {
if (nextTimer) { clearTimeout(nextTimer); nextTimer = null; }
playerHideOverlay();
};
const draw = () => {
o.replaceChildren(el("span", null, `Next: E${nxt.number} in ${n}\u2026`));
const s = el("button", "btn", "Skip");
s.onclick = done;
const x = el("button", "cnt-x", "\u2715");
x.title = "cancel auto-next";
x.onclick = cancel;
o.append(s, x);
if (n <= 0) { done(); return; }
n--;
nextTimer = setTimeout(draw, 1000);
};
draw();
}
async function play(ep_id, ep_no) {
playerBroken = false;
try {
const s = await api(`/api/episode/${ep_id}/stream?lang=${lang}`);
stopHls();
const v = videoEl();
v.playbackRate = +(localStorage.getItem("aniweb_spd") || 1);
document.getElementById("player").replaceChildren(v);
playerOverlay("Loading\u2026");
if (!window.Hls || !Hls.isSupported()) throw new Error("HLS not supported in this browser");
hls = new Hls();
hls.on(Hls.Events.ERROR, (_, data) => {
if (data.fatal) {
stopHls();
playerFail(data.details + (data.response ? ` (HTTP ${data.response.code})` : ""));
}
});
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 = () => playerFail("media error");
v.addEventListener("playing", playerHideOverlay);
v.addEventListener("waiting", () => {
if (currentEp && !playerBroken && !v.ended) playerOverlay("Buffering\u2026");
});
v.addEventListener("ended", onEnded);
v.play().catch(() => {}); // autoplay blocked -> user uses the native play button
watchPos(v);
postHistory({...meta(v), 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);
}
document.title = `${detail.name} E${ep_no} — ani-web`;
}
// extra fields to persist with every history entry (progress + poster)
const meta = v => ({...(Number.isFinite(v.duration) && v.duration > 0 ? {dur: v.duration} : {}),
poster: detail ? (detail.poster || null) : null});
// one savePos covers all video elements: it reads currentEp, so stale
// closures from earlier plays can't write the wrong episode's position
function savePos() {
if (!currentEp || currentEp.v.currentTime <= 3) return;
lastSavedPos = currentEp.v.currentTime;
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});
}
function watchPos(v) {
v.addEventListener("timeupdate", () => { if (v.currentTime - lastSavedPos >= 10) savePos(); });
v.addEventListener("pause", savePos);
// pagehide is bound once at boot (per-play listeners accumulated and fired
// with stale video elements)
}
window.addEventListener("pagehide", savePos);
// ---------- boot ----------
(async () => {
document.querySelector(".logo").href = BASE;
try {
const me = await api("/api/me");
document.getElementById("me").textContent = "hi, " + me.user;
initRecent();
const lo = document.getElementById("logout");
lo.onclick = async () => {
await fetch(BASE + "logout", {method: "POST", headers: {"X-Aniweb": "1"}}); location.href = BASE + "login?signedout=1";
};
const mb = document.getElementById("menubox");
mb.style.display = "";
document.getElementById("menu").onclick = e => {
e.stopPropagation(); mb.classList.toggle("open");
};
document.addEventListener("click", () => mb.classList.remove("open"));
document.addEventListener("keydown", e => { if (e.key === "Escape") mb.classList.remove("open"); });
const da = document.getElementById("delacct");
da.onclick = async () => {
if (!confirm("Delete your account and watch history?")) return;
await fetch(BASE + "account/delete", {method: "POST", headers: {"X-Aniweb": "1"}}); location.href = BASE + "login?deleted=1";
};
} catch { /* not logged in: api() already redirected to /login */ }
await refreshHistory();
const p = new URLSearchParams(location.search);
try {
if (p.get("q")) await showSearch(p.get("q"));
else if (p.get("anime")) await showAnime(p.get("anime"), {
season: p.get("season") ? +p.get("season") : null,
ep: p.get("ep") ? +p.get("ep") : null,
lang: p.get("lang") || "jpn",
});
else if (p.has("history")) await showHistory();
else showHome();
} catch (e) { fail(e); }
})();