ani-web: FastAPI + HLS proxy over anidb.app, vanilla UI
- upstream.py: Cloudflare-proof fetchers (curl_cffi), self-checking
- app.py: /api/* routes + /stream/{token} HLS proxy (CDN has no CORS)
- static/: search -> detail/seasons/episodes -> hls.js player, history
- verified end-to-end: search chainsaw man -> ep1 dub 1080p segment
This commit is contained in:
commit
e4dde86d85
10 files changed
+1151
No files matched your search
+167
@@ -0,0 +1,167 @@
|
||||
// app.js — ani-web frontend: search -> detail -> play, vanilla JS + hls.js
|
||||
const main = document.getElementById("main");
|
||||
let hls = null, detail = null, lang = "eng", seasonId = null;
|
||||
|
||||
async function api(path) {
|
||||
const r = await fetch(path);
|
||||
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));
|
||||
}
|
||||
|
||||
// ---------- 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)));
|
||||
}
|
||||
|
||||
function showHome() {
|
||||
main.innerHTML = "";
|
||||
const h = hist();
|
||||
if (!h.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"})`);
|
||||
b.onclick = () => location.href = `?anime=${x.slug_id}&season=${x.season_id}&ep=${x.ep_id}&lang=${x.lang}`;
|
||||
sec.append(b);
|
||||
}
|
||||
main.append(sec);
|
||||
}
|
||||
|
||||
// ---------- search ----------
|
||||
async function showSearch(q) {
|
||||
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);
|
||||
}
|
||||
|
||||
// ---------- detail + player ----------
|
||||
async function showAnime(slug_id, opts = {}) {
|
||||
detail = await api(`/api/anime/${slug_id}`);
|
||||
lang = opts.lang || "eng";
|
||||
seasonId = opts.season || detail.id;
|
||||
detail.slug_id = slug_id;
|
||||
main.innerHTML = "";
|
||||
|
||||
const head = el("section", "detail-head");
|
||||
head.append(el("h2", null, detail.name), el("p", "synopsis", detail.synopsis || ""));
|
||||
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());
|
||||
const setLang = l => { lang = l; langBtn.textContent = langLabel(); };
|
||||
langBtn.onclick = () => {
|
||||
setLang(lang === "eng" ? "jpn" : "eng");
|
||||
const cur = main.querySelector(".ep.active");
|
||||
if (cur) play(cur.dataset.ep, cur.dataset.no);
|
||||
};
|
||||
controls.append(langBtn, 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";
|
||||
main.append(eps);
|
||||
|
||||
const list = await api(`/api/anime/${seasonId}/episodes`);
|
||||
for (const e of list) {
|
||||
const b = el("button", "ep" + (e.filler ? " filler" : ""));
|
||||
b.textContent = "E" + e.number;
|
||||
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);
|
||||
}
|
||||
if (opts.ep) {
|
||||
const cur = [...eps.children].find(b => +b.dataset.ep === opts.ep);
|
||||
if (cur) { markActive(cur); play(opts.ep, cur.dataset.no); }
|
||||
}
|
||||
}
|
||||
|
||||
function langLabel() { return lang === "eng" ? "▶ dub" : "▶ sub"; }
|
||||
|
||||
function markActive(b) {
|
||||
for (const x of document.querySelectorAll(".ep")) x.classList.remove("active");
|
||||
b.classList.add("active");
|
||||
}
|
||||
|
||||
function videoEl() {
|
||||
const v = document.createElement("video");
|
||||
v.controls = true; v.autoplay = true;
|
||||
return v;
|
||||
}
|
||||
|
||||
function stopHls() { if (hls) { hls.destroy(); hls = null; } }
|
||||
|
||||
async function play(ep_id, ep_no) {
|
||||
const s = await api(`/api/episode/${ep_id}/stream?lang=${lang}`);
|
||||
stopHls();
|
||||
const v = videoEl();
|
||||
document.getElementById("player").replaceChildren(v);
|
||||
if (!Hls.isSupported()) throw new Error("HLS not supported in this browser");
|
||||
hls = new Hls();
|
||||
hls.loadSource("/stream/" + s.token);
|
||||
hls.attachMedia(v);
|
||||
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`;
|
||||
}
|
||||
|
||||
// ---------- boot ----------
|
||||
(async () => {
|
||||
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") || "eng",
|
||||
});
|
||||
else showHome();
|
||||
} catch (e) { fail(e); }
|
||||
})();
|
||||
@@ -0,0 +1,20 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>ani-web</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<a class="logo" href="/" onclick="location.replace('/');return false">ani-web</a>
|
||||
<form onsubmit="const q=document.getElementById('q').value.trim(); if(q) location='?q='+encodeURIComponent(q); return false">
|
||||
<input id="q" placeholder="search anime…" autocomplete="off">
|
||||
</form>
|
||||
</header>
|
||||
<main id="main"><p class="muted">Search for an anime to get started.</p></main>
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/hls.min.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,41 @@
|
||||
:root { --bg: #0e0f13; --card: #171821; --bd: #262833; --fg: #e8e9f0; --muted: #8a8d9c; --acc: #f97316; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.5 system-ui, sans-serif; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
header { display: flex; gap: 1rem; align-items: center; padding: 0.8rem 1.2rem;
|
||||
border-bottom: 1px solid var(--bd); position: sticky; top: 0; background: var(--bg); z-index: 1; }
|
||||
.logo { font-weight: 700; font-size: 1.2rem; color: var(--acc); }
|
||||
header form { flex: 1; max-width: 420px; }
|
||||
#q { width: 100%; padding: 0.5rem 0.8rem; border-radius: 8px; border: 1px solid var(--bd);
|
||||
background: var(--card); color: var(--fg); }
|
||||
main { padding: 1.2rem; max-width: 1100px; margin: 0 auto; }
|
||||
.muted { color: var(--muted); }
|
||||
.error { color: #f87171; }
|
||||
h2 { margin: 0.4rem 0; }
|
||||
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 1rem; }
|
||||
.card { background: var(--card); border: 1px solid var(--bd); border-radius: 10px; overflow: hidden;
|
||||
display: flex; flex-direction: column; }
|
||||
.card:hover { border-color: var(--acc); }
|
||||
.card img { width: 100%; aspect-ratio: 2/3; object-fit: cover; background: #000; display: block; }
|
||||
.card-name { padding: 0.5rem; font-size: 0.85rem; color: var(--muted); }
|
||||
|
||||
.detail-head { margin-bottom: 1rem; }
|
||||
.synopsis { color: var(--muted); max-width: 70ch; margin: 0.3rem 0; }
|
||||
|
||||
.player video { width: 100%; max-height: 70vh; background: #000; border-radius: 10px; aspect-ratio: 16/9; }
|
||||
.controls { display: flex; align-items: center; gap: 0.4rem; margin: 0.6rem 0; }
|
||||
button { background: var(--card); color: var(--fg); border: 1px solid var(--bd);
|
||||
border-radius: 8px; padding: 0.4rem 0.7rem; cursor: pointer; }
|
||||
button:hover { border-color: var(--acc); }
|
||||
|
||||
.tabs { display: flex; gap: 0.5rem; flex-wrap: wrap; margin: 0.6rem 0; }
|
||||
.tab.active { border-color: var(--acc); color: var(--acc); }
|
||||
|
||||
.eps { display: grid; grid-template-columns: repeat(auto-fill, minmax(64px, 1fr)); gap: 0.4rem; }
|
||||
.ep { position: relative; font-size: 0.9rem; }
|
||||
.ep.active { border-color: var(--acc); color: var(--acc); }
|
||||
.ep.filler { opacity: 0.55; }
|
||||
.badge { font-size: 0.6rem; color: var(--muted); }
|
||||
|
||||
.hist-item { display: block; width: 100%; text-align: left; margin: 0.3rem 0; padding: 0.6rem 0.8rem; }
|
||||
Reference in new issue
Block a user