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
+178
-59
No files matched your search
+53
-3
@@ -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
@@ -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
@@ -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
@@ -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); } }
|
||||
|
||||
Reference in new issue
Block a user