Status pill + git-SHA asset versioning
- _origin_probe(): 3-min check of anidb.app pages/API; OK/DOWN + reason
in GET /api/cdn and server.log ([probe] ORIGIN ...)
- header pill: upstream-site dot (red = down) + CDN dot + label;
per-dot hover tooltips, tap -> toast legend (mobile has no hover)
- ?v={{VERSION}} injected from 'git describe --always --dirty' at
startup; JSV reads it from its own script URL - replaces hand-bumped
numbers (JSV/?v drift) and gives play-probes the running commit
This commit is contained in:
1 parent
3f715434f0
commit
ff8311c1a8
4 files changed
+171
-52
No files matched your search
+53
-3
@@ -7,6 +7,7 @@ Cloudflare workaround) and rewrites playlist URIs to go through itself.
|
|||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from collections import Counter, OrderedDict
|
from collections import Counter, OrderedDict
|
||||||
@@ -16,7 +17,7 @@ from base64 import urlsafe_b64encode, urlsafe_b64decode
|
|||||||
from urllib.parse import quote, urljoin, urlparse
|
from urllib.parse import quote, urljoin, urlparse
|
||||||
|
|
||||||
from fastapi import Body, FastAPI, HTTPException, Query, Request
|
from fastapi import Body, FastAPI, HTTPException, Query, Request
|
||||||
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response, StreamingResponse
|
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response, StreamingResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from itsdangerous import BadSignature, TimestampSigner
|
from itsdangerous import BadSignature, TimestampSigner
|
||||||
|
|
||||||
@@ -25,8 +26,28 @@ from . import db, upstream, users
|
|||||||
ROOT = Path(__file__).parent.parent
|
ROOT = Path(__file__).parent.parent
|
||||||
app = FastAPI(title="ani-web")
|
app = FastAPI(title="ani-web")
|
||||||
|
|
||||||
|
def _version():
|
||||||
|
"""Cache-bust token: git short SHA (+ -dirty). The content of a commit
|
||||||
|
never changes, so ?v=<sha> is a correct cache key — this replaces the
|
||||||
|
hand-bumped ?v= number (forgetting a bump = users stuck on stale JS).
|
||||||
|
Every deploy restarts the process (run.sh), so the read is fresh."""
|
||||||
|
try:
|
||||||
|
v = subprocess.run(["git", "describe", "--always", "--dirty"], cwd=ROOT,
|
||||||
|
capture_output=True, text=True, timeout=5).stdout.strip()
|
||||||
|
if v:
|
||||||
|
return v
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return "dev"
|
||||||
|
|
||||||
|
|
||||||
|
VERSION = _version()
|
||||||
|
|
||||||
# latest probe result + last 20 samples (~1h at 3-min spacing), for GET /api/cdn
|
# latest probe result + last 20 samples (~1h at 3-min spacing), for GET /api/cdn
|
||||||
_CDN = {"state": "unknown", "kbps": 0.0, "ts": 0.0, "hist": []}
|
_CDN = {"state": "unknown", "kbps": 0.0, "ts": 0.0, "hist": []}
|
||||||
|
# origin (anidb.app pages/API) health — separate host, separate failure mode
|
||||||
|
# (observed 2026-09-03: CDN fine while origin 503'd "Under Maintenance")
|
||||||
|
_ORIGIN = {"state": "unknown", "msg": "", "ts": 0.0}
|
||||||
|
|
||||||
|
|
||||||
def _cdn_probe():
|
def _cdn_probe():
|
||||||
@@ -62,16 +83,34 @@ def _cdn_probe():
|
|||||||
_CDN.update(state=now, kbps=round(kbps), ts=time.time())
|
_CDN.update(state=now, kbps=round(kbps), ts=time.time())
|
||||||
_CDN["hist"].append({"t": time.time(), "kbps": round(kbps)})
|
_CDN["hist"].append({"t": time.time(), "kbps": round(kbps)})
|
||||||
del _CDN["hist"][:-20]
|
del _CDN["hist"][:-20]
|
||||||
|
_origin_probe()
|
||||||
time.sleep(180)
|
time.sleep(180)
|
||||||
|
|
||||||
|
|
||||||
|
def _origin_probe():
|
||||||
|
"""Hit the same kind of page users load (details page) every 3 min and
|
||||||
|
track OK/DOWN — this is the host that 503s 'Under Maintenance'.
|
||||||
|
ponytail: one URL, no history; the log line + ts tell the when."""
|
||||||
|
try:
|
||||||
|
r = upstream._get("https://anidb.app/anime/jujutsu-kaisen-season-2-2554",
|
||||||
|
timeout=10)
|
||||||
|
state, msg = "OK", f"http {r.status_code}"
|
||||||
|
except Exception as e:
|
||||||
|
state, msg = "DOWN", str(e)
|
||||||
|
prev = _ORIGIN["state"]
|
||||||
|
_ORIGIN.update(state=state, msg=msg, ts=time.time())
|
||||||
|
if state != prev:
|
||||||
|
print(f"[probe] ORIGIN {state} ({msg})", flush=True)
|
||||||
|
|
||||||
|
|
||||||
threading.Thread(target=_cdn_probe, daemon=True).start()
|
threading.Thread(target=_cdn_probe, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/cdn")
|
@app.get("/api/cdn")
|
||||||
async def cdn_status():
|
async def cdn_status():
|
||||||
"""CDN health (read-only, public): latest probe + ~1h of history."""
|
"""CDN + origin health (read-only, public). Top-level fields are the CDN
|
||||||
return _CDN
|
probe (existing shape); .origin is the anidb.app pages/API probe."""
|
||||||
|
return {**_CDN, "origin": _ORIGIN}
|
||||||
|
|
||||||
# ---------- auth: multi-user, signed cookies ----------
|
# ---------- auth: multi-user, signed cookies ----------
|
||||||
users.migrate_legacy() # file-based legacy import; db.init() absorbs its output
|
users.migrate_legacy() # file-based legacy import; db.init() absorbs its output
|
||||||
@@ -621,4 +660,15 @@ def stream(token: str):
|
|||||||
return Response(body, media_type=ct, headers=_STREAM_CACHE_HDRS)
|
return Response(body, media_type=ct, headers=_STREAM_CACHE_HDRS)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", include_in_schema=False)
|
||||||
|
@app.get("/index.html", include_in_schema=False)
|
||||||
|
def index():
|
||||||
|
# index.html is served (not mounted) so ?v={{VERSION}} becomes the git
|
||||||
|
# SHA at startup; other statics go through the mount below
|
||||||
|
return HTMLResponse(_INDEX_HTML.replace("{{VERSION}}", VERSION))
|
||||||
|
|
||||||
|
|
||||||
|
_INDEX_HTML = (ROOT / "static" / "index.html").read_text()
|
||||||
|
|
||||||
|
|
||||||
app.mount("/", StaticFiles(directory=ROOT / "static", html=True), name="static")
|
app.mount("/", StaticFiles(directory=ROOT / "static", html=True), name="static")
|
||||||
+92
-36
@@ -1,6 +1,8 @@
|
|||||||
// app.js — ani-web frontend: search -> detail -> play, vanilla JS + hls.js
|
// app.js — ani-web frontend: search -> detail -> play, vanilla JS + hls.js
|
||||||
const JSV = 14; // bump with every app.js change; play-probe reports it so a
|
// JSV = this file's cache-bust version (the server injects the git SHA into
|
||||||
// stale-cache phone is detectable server-side
|
// its ?v= URL), reported by play-probes so a stale-cache phone is
|
||||||
|
// detectable server-side. No hand-bumping.
|
||||||
|
const JSV = ((document.currentScript && document.currentScript.src).match(/[?&]v=([^&]+)/) || [])[1] || "unknown";
|
||||||
// BASE = this page's directory, so the app works at / or behind a
|
// BASE = this page's directory, so the app works at / or behind a
|
||||||
// prefixing reverse proxy (e.g. https://host/ani/)
|
// prefixing reverse proxy (e.g. https://host/ani/)
|
||||||
const BASE = (() => {
|
const BASE = (() => {
|
||||||
@@ -31,6 +33,16 @@ function el(tag, cls, text) {
|
|||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// lazy poster img with a broken-image fallback (a dead upstream poster URL
|
||||||
|
// must not leave a broken-image icon in an otherwise fine card)
|
||||||
|
function poster(src, alt) {
|
||||||
|
const i = document.createElement("img");
|
||||||
|
i.alt = alt || "";
|
||||||
|
if (src) { i.src = src; i.loading = "lazy"; i.decoding = "async"; }
|
||||||
|
i.onerror = () => i.replaceWith(el("div", "ph-img"));
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
|
||||||
function fail(err) {
|
function fail(err) {
|
||||||
main.innerHTML = "";
|
main.innerHTML = "";
|
||||||
main.append(el("p", "error", "Error: " + err.message));
|
main.append(el("p", "error", "Error: " + err.message));
|
||||||
@@ -129,10 +141,7 @@ const homeCard = x => {
|
|||||||
const done = histClass(x) === "done", hold = histClass(x) === "hold";
|
const done = histClass(x) === "done", hold = histClass(x) === "hold";
|
||||||
const c = el("a", "card" + (hold ? " hold" : ""));
|
const c = el("a", "card" + (hold ? " hold" : ""));
|
||||||
c.href = histHref(x);
|
c.href = histHref(x);
|
||||||
if (x.poster) {
|
c.append(poster(x.poster, x.title));
|
||||||
const i = el("img"); i.src = x.poster; i.alt = "";
|
|
||||||
c.append(i);
|
|
||||||
}
|
|
||||||
let meta = `E${x.ep} \u00b7 ${x.lang === "eng" ? "dub" : "sub"}`;
|
let meta = `E${x.ep} \u00b7 ${x.lang === "eng" ? "dub" : "sub"}`;
|
||||||
if (done) meta += " \u00b7 \u2713";
|
if (done) meta += " \u00b7 \u2713";
|
||||||
else if (x.dur > 0 && x.pos < x.dur * 0.95) meta += ` \u00b7 ${fmtMin((x.dur - x.pos) / 60)} left`;
|
else if (x.dur > 0 && x.pos < x.dur * 0.95) meta += ` \u00b7 ${fmtMin((x.dur - x.pos) / 60)} left`;
|
||||||
@@ -233,31 +242,53 @@ function saveSearch(q) {
|
|||||||
body: JSON.stringify({q})}).catch(() => {});
|
body: JSON.stringify({q})}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function showSearch(q) {
|
function renderSearchResults(q, results, announce = true) {
|
||||||
saveSearch(q);
|
|
||||||
document.title = q + " \u2014 ani-web";
|
document.title = q + " \u2014 ani-web";
|
||||||
skel(8);
|
|
||||||
const results = await api(`/api/search?q=${encodeURIComponent(q)}`);
|
|
||||||
main.innerHTML = "";
|
main.innerHTML = "";
|
||||||
if (!results.length) {
|
if (!results.length) {
|
||||||
const p = el("p", "muted", "No results.");
|
const p = el("p", "muted", "No results.");
|
||||||
main.append(p);
|
main.append(p);
|
||||||
recentChips(p);
|
recentChips(p);
|
||||||
focusMain();
|
} else {
|
||||||
return;
|
|
||||||
}
|
|
||||||
main.append(el("p", "muted", `${results.length} result${results.length > 1 ? "s" : ""}`));
|
main.append(el("p", "muted", `${results.length} result${results.length > 1 ? "s" : ""}`));
|
||||||
const grid = el("div", "grid");
|
const grid = el("div", "grid");
|
||||||
for (const r of results) {
|
for (const r of results) {
|
||||||
const card = el("a", "card");
|
const card = el("a", "card");
|
||||||
card.href = `?anime=${r.slug_id}`;
|
card.href = `?anime=${r.slug_id}`;
|
||||||
const img = el("img", null, r.name);
|
card.append(poster(r.poster, r.name), el("span", "card-name", r.name));
|
||||||
img.src = r.poster || "";
|
|
||||||
card.append(img, el("span", "card-name", r.name));
|
|
||||||
grid.append(card);
|
grid.append(card);
|
||||||
}
|
}
|
||||||
main.append(grid);
|
main.append(grid);
|
||||||
focusMain();
|
}
|
||||||
|
if (announce) focusMain(); // focus-steal breaks Enter-to-submit mid-keystroke
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showSearch(q) {
|
||||||
|
saveSearch(q);
|
||||||
|
skel(8);
|
||||||
|
renderSearchResults(q, await api(`/api/search?q=${encodeURIComponent(q)}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// search-as-you-type: debounced /api/search rendered in place (Enter still
|
||||||
|
// commits to ?q= so results are shareable/reloadable)
|
||||||
|
let liveTok = 0;
|
||||||
|
function initLive() {
|
||||||
|
let t = null;
|
||||||
|
const q = document.getElementById("q");
|
||||||
|
q.addEventListener("input", () => {
|
||||||
|
const v = q.value.trim();
|
||||||
|
clearTimeout(t);
|
||||||
|
if (v.length < 2) return;
|
||||||
|
t = setTimeout(async () => {
|
||||||
|
const tok = ++liveTok;
|
||||||
|
try {
|
||||||
|
const results = await api(`/api/search?q=${encodeURIComponent(v)}`);
|
||||||
|
if (tok !== liveTok) return; // superseded by a newer keystroke
|
||||||
|
saveSearch(v);
|
||||||
|
renderSearchResults(v, results, false); // keep focus in #q so Enter still submits
|
||||||
|
} catch { /* failed or stale — Enter still works */ }
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- recent searches dropdown ----------
|
// ---------- recent searches dropdown ----------
|
||||||
@@ -349,6 +380,7 @@ async function showAnime(slug_id, opts = {}) {
|
|||||||
const jump = el("input", "spd");
|
const jump = el("input", "spd");
|
||||||
jump.type = "number"; jump.min = "1"; jump.inputMode = "numeric";
|
jump.type = "number"; jump.min = "1"; jump.inputMode = "numeric";
|
||||||
jump.placeholder = "E#"; jump.title = "jump to episode";
|
jump.placeholder = "E#"; jump.title = "jump to episode";
|
||||||
|
jump.ariaLabel = "jump to episode";
|
||||||
jump.style.width = "4.5rem";
|
jump.style.width = "4.5rem";
|
||||||
jump.onchange = () => {
|
jump.onchange = () => {
|
||||||
const t = epsList.find(e => e.number == +jump.value);
|
const t = epsList.find(e => e.number == +jump.value);
|
||||||
@@ -450,10 +482,7 @@ function histCard(x) {
|
|||||||
const hold = histClass(x) === "hold";
|
const hold = histClass(x) === "hold";
|
||||||
const c = el("div", "card hist" + (hold ? " hold" : ""));
|
const c = el("div", "card hist" + (hold ? " hold" : ""));
|
||||||
c.onclick = () => location.href = histHref(x);
|
c.onclick = () => location.href = histHref(x);
|
||||||
if (x.poster) {
|
c.append(poster(x.poster, x.title));
|
||||||
const i = el("img"); i.src = x.poster; i.alt = "";
|
|
||||||
c.append(i);
|
|
||||||
}
|
|
||||||
const nm = el("a", "card-name", x.title);
|
const nm = el("a", "card-name", x.title);
|
||||||
nm.href = histHref(x);
|
nm.href = histHref(x);
|
||||||
const info = el("div", "mini-info");
|
const info = el("div", "mini-info");
|
||||||
@@ -529,10 +558,7 @@ function topAnime(d, n = 0) {
|
|||||||
ta.append(el("h2", null, "Most watched"));
|
ta.append(el("h2", null, "Most watched"));
|
||||||
for (const a of list) {
|
for (const a of list) {
|
||||||
const row = el("div", "stat-anime");
|
const row = el("div", "stat-anime");
|
||||||
if (a.poster) {
|
row.append(poster(a.poster, a.title));
|
||||||
const i = el("img"); i.src = a.poster; i.alt = "";
|
|
||||||
row.append(i);
|
|
||||||
} else row.append(el("div", "sa-ph"));
|
|
||||||
const t1 = el("div", "sa-top");
|
const t1 = el("div", "sa-top");
|
||||||
t1.append(el("span", "sa-title", a.title), el("span", "muted", animeMeta(a)));
|
t1.append(el("span", "sa-title", a.title), el("span", "muted", animeMeta(a)));
|
||||||
row.append(t1,
|
row.append(t1,
|
||||||
@@ -792,16 +818,44 @@ function bufferGauge() {
|
|||||||
bufTimer = setInterval(tick, 250);
|
bufTimer = setInterval(tick, 250);
|
||||||
}
|
}
|
||||||
|
|
||||||
function cdnChip() {
|
function statusPill() {
|
||||||
// corner badge: live CDN throughput from /api/cdn (dot: green=OK, amber=degraded)
|
// header badge: upstream site + CDN status from /api/cdn. Lives in the
|
||||||
const p = document.getElementById("player");
|
// (sticky) header so it's visible on the home screen — if the upstream is
|
||||||
let c = p.querySelector(".pl-cdn");
|
// down you never reach the player, so that's where the status must live.
|
||||||
if (!c) { c = el("div", "pl-cdn"); c.append(el("i")); p.append(c); }
|
const p = document.getElementById("pill");
|
||||||
|
if (!p) return;
|
||||||
api("/api/cdn").then(d => {
|
api("/api/cdn").then(d => {
|
||||||
c.className = "pl-cdn " + (d.state === "OK" ? "ok" : d.state === "DEGRADED" ? "degraded" : "");
|
const o = d.origin.state === "OK" ? "ok" : d.origin.state === "DOWN" ? "down" : "";
|
||||||
c.lastChild.textContent = d.kbps + " KB/s";
|
const c = d.state === "OK" ? "ok" : d.state === "DEGRADED" ? "degraded" : "";
|
||||||
c.title = "CDN " + d.state + " — " + d.kbps + " KB/s";
|
p.className = "pill" + (o === "down" ? " odown" : "");
|
||||||
}).catch(() => { c.className = "pl-cdn"; c.lastChild.textContent = "CDN ?"; });
|
const od = p.querySelector(".o"), cd = p.querySelector(".c");
|
||||||
|
od.className = "o " + o;
|
||||||
|
cd.className = "c " + c;
|
||||||
|
p.querySelector("span").textContent =
|
||||||
|
o === "down" ? "upstream down" : c === "degraded" ? "cdn slow" : "up";
|
||||||
|
// per-dot hover tooltips (desktop) + full aria label (a11y / screen readers)
|
||||||
|
od.title = "upstream site (anidb.app pages + API): " + d.origin.state
|
||||||
|
+ (d.origin.msg ? " — " + d.origin.msg : "");
|
||||||
|
cd.title = "video CDN (hls.anidb.app) throughput: " + d.state
|
||||||
|
+ " — " + d.kbps + " KB/s";
|
||||||
|
p.title = ""; // dot-level titles carry the detail; pill-level would shadow them
|
||||||
|
p.setAttribute("aria-label", "upstream site " + d.origin.state
|
||||||
|
+ (d.origin.msg ? ": " + d.origin.msg : "")
|
||||||
|
+ "; CDN " + d.state + ", " + d.kbps + " KB/s");
|
||||||
|
// tap/click = tooltip for touch screens (mobile has no hover);
|
||||||
|
// read p._data at click time so the toast reflects the LATEST status,
|
||||||
|
// not the one captured when the handler was first bound
|
||||||
|
if (!p._bound) {
|
||||||
|
p._bound = 1;
|
||||||
|
p.onclick = e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const d = p._data;
|
||||||
|
toast("upstream site: " + d.origin.state + (d.origin.msg ? " (" + d.origin.msg + ")" : "")
|
||||||
|
+ " · CDN: " + d.state + " (" + d.kbps + " KB/s)");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
p._data = d;
|
||||||
|
}).catch(() => { p.querySelector("span").textContent = "?"; });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- auto-next on episode end (5s countdown) ----------
|
// ---------- auto-next on episode end (5s countdown) ----------
|
||||||
@@ -876,7 +930,7 @@ async function play(ep_id, ep_no) {
|
|||||||
document.getElementById("player").replaceChildren(v);
|
document.getElementById("player").replaceChildren(v);
|
||||||
playerOverlay("Loading\u2026", null, null, true);
|
playerOverlay("Loading\u2026", null, null, true);
|
||||||
bufferGauge();
|
bufferGauge();
|
||||||
cdnChip();
|
statusPill();
|
||||||
// DECISIVE PROBE (iPhone debugging): report exactly which branch we're
|
// DECISIVE PROBE (iPhone debugging): report exactly which branch we're
|
||||||
// about to take + the browser's real capabilities.
|
// about to take + the browser's real capabilities.
|
||||||
try {
|
try {
|
||||||
@@ -966,7 +1020,7 @@ async function play(ep_id, ep_no) {
|
|||||||
v.addEventListener("waiting", () => {
|
v.addEventListener("waiting", () => {
|
||||||
if (currentEp && !playerBroken && !v.ended) {
|
if (currentEp && !playerBroken && !v.ended) {
|
||||||
playerOverlay("Buffering\u2026", null, null, true);
|
playerOverlay("Buffering\u2026", null, null, true);
|
||||||
cdnChip(); // refresh CDN readout exactly when it matters
|
statusPill(); // refresh status readout exactly when it matters
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
v.addEventListener("ended", onEnded);
|
v.addEventListener("ended", onEnded);
|
||||||
@@ -1061,6 +1115,7 @@ window.addEventListener("pagehide", savePos);
|
|||||||
const me = await api("/api/me");
|
const me = await api("/api/me");
|
||||||
document.getElementById("me").textContent = "hi, " + me.user;
|
document.getElementById("me").textContent = "hi, " + me.user;
|
||||||
initRecent();
|
initRecent();
|
||||||
|
initLive();
|
||||||
const lo = document.getElementById("logout");
|
const lo = document.getElementById("logout");
|
||||||
lo.onclick = async () => {
|
lo.onclick = async () => {
|
||||||
await fetch(BASE + "logout", {method: "POST", headers: {"X-Aniweb": "1"}}); location.href = BASE + "login?signedout=1";
|
await fetch(BASE + "logout", {method: "POST", headers: {"X-Aniweb": "1"}}); location.href = BASE + "login?signedout=1";
|
||||||
@@ -1082,6 +1137,7 @@ window.addEventListener("pagehide", savePos);
|
|||||||
await fetch(BASE + "account/delete", {method: "POST", headers: {"X-Aniweb": "1"}}); location.href = BASE + "login?deleted=1";
|
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 */ }
|
} catch { /* not logged in: api() already redirected to /login */ }
|
||||||
|
statusPill();
|
||||||
await refreshHistory();
|
await refreshHistory();
|
||||||
const p = new URLSearchParams(location.search);
|
const p = new URLSearchParams(location.search);
|
||||||
try {
|
try {
|
||||||
|
|||||||
+12
-7
@@ -3,17 +3,22 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="theme-color" content="#0e0f13">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<title>ani-web</title>
|
<title>ani-web</title>
|
||||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%23f97316'/%3E%3Ctext x='32' y='45' font-family='sans-serif' font-size='42' font-weight='700' text-anchor='middle' fill='%230e0f13'%3Ea%3C/text%3E%3C/svg%3E">
|
<link rel="icon" href="icon.svg">
|
||||||
|
<link rel="manifest" href="manifest.webmanifest">
|
||||||
|
|
||||||
<link rel="stylesheet" href="style.css?v=14">
|
<link rel="stylesheet" href="style.css?v={{VERSION}}">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<a class="logo" href="/">ani-web</a>
|
<a class="logo" href="/">ani-web</a>
|
||||||
<form onsubmit="const q=document.getElementById('q').value.trim(); if(q) location='?q='+encodeURIComponent(q); return false">
|
<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" autocapitalize="off" spellcheck="false">
|
<input id="q" placeholder="search anime…" aria-label="search anime" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||||
</form>
|
</form>
|
||||||
|
<span id="pill" class="pill"><i class="o"></i><i class="c"></i><span></span></span>
|
||||||
<span id="me" class="muted"></span>
|
<span id="me" class="muted"></span>
|
||||||
<div id="menubox" style="display:none">
|
<div id="menubox" style="display:none">
|
||||||
<button id="menu" class="tiny" aria-label="menu" aria-haspopup="true">☰</button>
|
<button id="menu" class="tiny" aria-label="menu" aria-haspopup="true">☰</button>
|
||||||
@@ -26,10 +31,10 @@
|
|||||||
</header>
|
</header>
|
||||||
<main id="main"><p class="muted">Search for an anime to get started.</p></main>
|
<main id="main"><p class="muted">Search for an anime to get started.</p></main>
|
||||||
<div id="toasts" aria-live="polite"></div>
|
<div id="toasts" aria-live="polite"></div>
|
||||||
<!-- ponytail: bump ?v= whenever app.js/style.css change — they are cached
|
<!-- Versioning: ?v={{VERSION}} is replaced by the server (server/app.py index
|
||||||
max-age=300 in browser AND possibly in your HTTPS proxy, and a stale
|
route) with the git SHA at startup — a correct cache key per commit.
|
||||||
copy is exactly how users keep seeing an old UI after a deploy -->
|
No hand-bumping; JSV in app.js reads the same value from its own URL. -->
|
||||||
<script src="hls.min.js"></script>
|
<script src="hls.min.js"></script>
|
||||||
<script src="app.js?v=14"></script>
|
<script src="app.js?v={{VERSION}}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+14
-6
@@ -36,6 +36,10 @@ h2 { margin: 0.4rem 0; }
|
|||||||
display: flex; flex-direction: column; }
|
display: flex; flex-direction: column; }
|
||||||
.card:hover { border-color: var(--acc); }
|
.card:hover { border-color: var(--acc); }
|
||||||
.card img { width: 100%; aspect-ratio: 2/3; object-fit: cover; background: #000; display: block; }
|
.card img { width: 100%; aspect-ratio: 2/3; object-fit: cover; background: #000; display: block; }
|
||||||
|
/* broken-poster fallback: a dead upstream URL must not leave a broken-image
|
||||||
|
icon in an otherwise fine card */
|
||||||
|
.ph-img { aspect-ratio: 2/3; background: var(--bd); }
|
||||||
|
.stat-anime .ph-img { width: 44px; border-radius: 6px; grid-row: span 2; }
|
||||||
.card-name { padding: 0.5rem; font-size: 0.85rem; color: var(--muted); }
|
.card-name { padding: 0.5rem; font-size: 0.85rem; color: var(--muted); }
|
||||||
|
|
||||||
/* home: continue-watching hero + mini history cards */
|
/* home: continue-watching hero + mini history cards */
|
||||||
@@ -107,6 +111,7 @@ h2 { margin: 0.4rem 0; }
|
|||||||
.hero-body h2 { white-space: normal; font-size: 1.05rem; }
|
.hero-body h2 { white-space: normal; font-size: 1.05rem; }
|
||||||
.card.hist img { max-height: 150px; }
|
.card.hist img { max-height: 150px; }
|
||||||
header #me { display: none; }
|
header #me { display: none; }
|
||||||
|
header .pill span { display: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-head { margin-bottom: 1rem; }
|
.detail-head { margin-bottom: 1rem; }
|
||||||
@@ -158,13 +163,16 @@ button.tiny.on { border-color: var(--acc); color: var(--acc); }
|
|||||||
background: rgba(255, 255, 255, .1); }
|
background: rgba(255, 255, 255, .1); }
|
||||||
.pl-buffer i { display: block; height: 100%; width: 0;
|
.pl-buffer i { display: block; height: 100%; width: 0;
|
||||||
background: linear-gradient(90deg, var(--acc), #fbbf24); transition: width .25s ease; }
|
background: linear-gradient(90deg, var(--acc), #fbbf24); transition: width .25s ease; }
|
||||||
.pl-cdn { position: absolute; top: 10px; right: 10px; z-index: 2;
|
/* header status pill: upstream site dot + CDN dot + short label */
|
||||||
display: flex; align-items: center; gap: .4rem;
|
header .pill { display: flex; align-items: center; gap: .4rem; flex: none;
|
||||||
font: 600 .72rem/1 system-ui, sans-serif; padding: .3rem .55rem; border-radius: 999px;
|
font: 600 .72rem/1 system-ui, sans-serif; padding: .3rem .55rem; border-radius: 999px;
|
||||||
background: rgba(0, 0, 0, .55); color: #fff; pointer-events: none; }
|
border: 1px solid var(--bd); background: var(--card); color: var(--fg);
|
||||||
.pl-cdn i { width: 8px; height: 8px; flex: none; border-radius: 50%; background: #6b7280; }
|
cursor: help; }
|
||||||
.pl-cdn.ok i { background: #22c55e; }
|
header .pill i { width: 8px; height: 8px; flex: none; border-radius: 50%; background: #6b7280; }
|
||||||
.pl-cdn.degraded i { background: #f59e0b; }
|
header .pill .o.ok, header .pill .c.ok { background: #22c55e; }
|
||||||
|
header .pill .c.degraded { background: #f59e0b; }
|
||||||
|
header .pill .o.down { background: #ef4444; }
|
||||||
|
header .pill.odown { border-color: #7f1d1d; }
|
||||||
#toasts { position: fixed; bottom: 1rem; left: 50%; transform: translateX(-50%);
|
#toasts { position: fixed; bottom: 1rem; left: 50%; transform: translateX(-50%);
|
||||||
display: flex; flex-direction: column; gap: .4rem; z-index: 9; }
|
display: flex; flex-direction: column; gap: .4rem; z-index: 9; }
|
||||||
@keyframes toast-in { from { opacity: 0; transform: translateY(.4rem); } }
|
@keyframes toast-in { from { opacity: 0; transform: translateY(.4rem); } }
|
||||||
|
|||||||
Reference in new issue
Block a user