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:
jozamudi committed 2026-09-03 16:33:51 +00:00
1 parent 3f715434f0
commit ff8311c1a8
4 files changed
+178 -59

No files matched your search

+53 -3
View File
@@ -7,6 +7,7 @@ Cloudflare workaround) and rewrites playlist URIs to go through itself.
import base64
import json
import os
import subprocess
import threading
import time
from collections import Counter, OrderedDict
@@ -16,7 +17,7 @@ from base64 import urlsafe_b64encode, urlsafe_b64decode
from urllib.parse import quote, urljoin, urlparse
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 itsdangerous import BadSignature, TimestampSigner
@@ -25,8 +26,28 @@ from . import db, upstream, users
ROOT = Path(__file__).parent.parent
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
_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():
@@ -62,16 +83,34 @@ def _cdn_probe():
_CDN.update(state=now, kbps=round(kbps), ts=time.time())
_CDN["hist"].append({"t": time.time(), "kbps": round(kbps)})
del _CDN["hist"][:-20]
_origin_probe()
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()
@app.get("/api/cdn")
async def cdn_status():
"""CDN health (read-only, public): latest probe + ~1h of history."""
return _CDN
"""CDN + origin health (read-only, public). Top-level fields are the CDN
probe (existing shape); .origin is the anidb.app pages/API probe."""
return {**_CDN, "origin": _ORIGIN}
# ---------- auth: multi-user, signed cookies ----------
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)
@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")
+99 -43
View File
@@ -1,6 +1,8 @@
// 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
// stale-cache phone is detectable server-side
// JSV = this file's cache-bust version (the server injects the git SHA into
// 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
// prefixing reverse proxy (e.g. https://host/ani/)
const BASE = (() => {
@@ -31,6 +33,16 @@ function el(tag, cls, text) {
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) {
main.innerHTML = "";
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 c = el("a", "card" + (hold ? " hold" : ""));
c.href = histHref(x);
if (x.poster) {
const i = el("img"); i.src = x.poster; i.alt = "";
c.append(i);
}
c.append(poster(x.poster, x.title));
let meta = `E${x.ep} \u00b7 ${x.lang === "eng" ? "dub" : "sub"}`;
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`;
@@ -233,31 +242,53 @@ function saveSearch(q) {
body: JSON.stringify({q})}).catch(() => {});
}
async function showSearch(q) {
saveSearch(q);
function renderSearchResults(q, results, announce = true) {
document.title = q + " \u2014 ani-web";
skel(8);
const results = await api(`/api/search?q=${encodeURIComponent(q)}`);
main.innerHTML = "";
if (!results.length) {
const p = el("p", "muted", "No results.");
main.append(p);
recentChips(p);
focusMain();
return;
} else {
main.append(el("p", "muted", `${results.length} result${results.length > 1 ? "s" : ""}`));
const grid = el("div", "grid");
for (const r of results) {
const card = el("a", "card");
card.href = `?anime=${r.slug_id}`;
card.append(poster(r.poster, r.name), el("span", "card-name", r.name));
grid.append(card);
}
main.append(grid);
}
main.append(el("p", "muted", `${results.length} result${results.length > 1 ? "s" : ""}`));
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);
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 ----------
@@ -349,6 +380,7 @@ async function showAnime(slug_id, opts = {}) {
const jump = el("input", "spd");
jump.type = "number"; jump.min = "1"; jump.inputMode = "numeric";
jump.placeholder = "E#"; jump.title = "jump to episode";
jump.ariaLabel = "jump to episode";
jump.style.width = "4.5rem";
jump.onchange = () => {
const t = epsList.find(e => e.number == +jump.value);
@@ -450,10 +482,7 @@ function histCard(x) {
const hold = histClass(x) === "hold";
const c = el("div", "card hist" + (hold ? " hold" : ""));
c.onclick = () => location.href = histHref(x);
if (x.poster) {
const i = el("img"); i.src = x.poster; i.alt = "";
c.append(i);
}
c.append(poster(x.poster, x.title));
const nm = el("a", "card-name", x.title);
nm.href = histHref(x);
const info = el("div", "mini-info");
@@ -529,10 +558,7 @@ function topAnime(d, n = 0) {
ta.append(el("h2", null, "Most watched"));
for (const a of list) {
const row = el("div", "stat-anime");
if (a.poster) {
const i = el("img"); i.src = a.poster; i.alt = "";
row.append(i);
} else row.append(el("div", "sa-ph"));
row.append(poster(a.poster, a.title));
const t1 = el("div", "sa-top");
t1.append(el("span", "sa-title", a.title), el("span", "muted", animeMeta(a)));
row.append(t1,
@@ -792,16 +818,44 @@ function bufferGauge() {
bufTimer = setInterval(tick, 250);
}
function cdnChip() {
// corner badge: live CDN throughput from /api/cdn (dot: green=OK, amber=degraded)
const p = document.getElementById("player");
let c = p.querySelector(".pl-cdn");
if (!c) { c = el("div", "pl-cdn"); c.append(el("i")); p.append(c); }
function statusPill() {
// header badge: upstream site + CDN status from /api/cdn. Lives in the
// (sticky) header so it's visible on the home screen — if the upstream is
// down you never reach the player, so that's where the status must live.
const p = document.getElementById("pill");
if (!p) return;
api("/api/cdn").then(d => {
c.className = "pl-cdn " + (d.state === "OK" ? "ok" : d.state === "DEGRADED" ? "degraded" : "");
c.lastChild.textContent = d.kbps + " KB/s";
c.title = "CDN " + d.state + " — " + d.kbps + " KB/s";
}).catch(() => { c.className = "pl-cdn"; c.lastChild.textContent = "CDN ?"; });
const o = d.origin.state === "OK" ? "ok" : d.origin.state === "DOWN" ? "down" : "";
const c = d.state === "OK" ? "ok" : d.state === "DEGRADED" ? "degraded" : "";
p.className = "pill" + (o === "down" ? " odown" : "");
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) ----------
@@ -876,7 +930,7 @@ async function play(ep_id, ep_no) {
document.getElementById("player").replaceChildren(v);
playerOverlay("Loading\u2026", null, null, true);
bufferGauge();
cdnChip();
statusPill();
// DECISIVE PROBE (iPhone debugging): report exactly which branch we're
// about to take + the browser's real capabilities.
try {
@@ -966,7 +1020,7 @@ async function play(ep_id, ep_no) {
v.addEventListener("waiting", () => {
if (currentEp && !playerBroken && !v.ended) {
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);
@@ -1061,6 +1115,7 @@ window.addEventListener("pagehide", savePos);
const me = await api("/api/me");
document.getElementById("me").textContent = "hi, " + me.user;
initRecent();
initLive();
const lo = document.getElementById("logout");
lo.onclick = async () => {
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";
};
} catch { /* not logged in: api() already redirected to /login */ }
statusPill();
await refreshHistory();
const p = new URLSearchParams(location.search);
try {
+12 -7
View File
@@ -3,17 +3,22 @@
<head>
<meta charset="utf-8">
<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>
<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>
<body>
<header>
<a class="logo" href="/">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" autocapitalize="off" spellcheck="false">
<input id="q" placeholder="search anime…" aria-label="search anime" autocomplete="off" autocapitalize="off" spellcheck="false">
</form>
<span id="pill" class="pill"><i class="o"></i><i class="c"></i><span></span></span>
<span id="me" class="muted"></span>
<div id="menubox" style="display:none">
<button id="menu" class="tiny" aria-label="menu" aria-haspopup="true">☰</button>
@@ -26,10 +31,10 @@
</header>
<main id="main"><p class="muted">Search for an anime to get started.</p></main>
<div id="toasts" aria-live="polite"></div>
<!-- ponytail: bump ?v= whenever app.js/style.css change — they are cached
max-age=300 in browser AND possibly in your HTTPS proxy, and a stale
copy is exactly how users keep seeing an old UI after a deploy -->
<!-- Versioning: ?v={{VERSION}} is replaced by the server (server/app.py index
route) with the git SHA at startup — a correct cache key per commit.
No hand-bumping; JSV in app.js reads the same value from its own URL. -->
<script src="hls.min.js"></script>
<script src="app.js?v=14"></script>
<script src="app.js?v={{VERSION}}"></script>
</body>
</html>
+14 -6
View File
@@ -36,6 +36,10 @@ h2 { margin: 0.4rem 0; }
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; }
/* 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); }
/* 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; }
.card.hist img { max-height: 150px; }
header #me { display: none; }
header .pill span { display: none; }
}
.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); }
.pl-buffer i { display: block; height: 100%; width: 0;
background: linear-gradient(90deg, var(--acc), #fbbf24); transition: width .25s ease; }
.pl-cdn { position: absolute; top: 10px; right: 10px; z-index: 2;
display: flex; align-items: center; gap: .4rem;
/* header status pill: upstream site dot + CDN dot + short label */
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;
background: rgba(0, 0, 0, .55); color: #fff; pointer-events: none; }
.pl-cdn i { width: 8px; height: 8px; flex: none; border-radius: 50%; background: #6b7280; }
.pl-cdn.ok i { background: #22c55e; }
.pl-cdn.degraded i { background: #f59e0b; }
border: 1px solid var(--bd); background: var(--card); color: var(--fg);
cursor: help; }
header .pill i { width: 8px; height: 8px; flex: none; border-radius: 50%; background: #6b7280; }
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%);
display: flex; flex-direction: column; gap: .4rem; z-index: 9; }
@keyframes toast-in { from { opacity: 0; transform: translateY(.4rem); } }