From ff8311c1a8bd84cbf3579bcc6820aee38bb5fd3f Mon Sep 17 00:00:00 2001 From: Josue Zamudio Date: Thu, 3 Sep 2026 16:33:51 +0000 Subject: [PATCH] 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 --- server/app.py | 56 +++++++++++++++++- static/app.js | 142 ++++++++++++++++++++++++++++++++-------------- static/index.html | 19 ++++--- static/style.css | 20 +++++-- 4 files changed, 178 insertions(+), 59 deletions(-) diff --git a/server/app.py b/server/app.py index f88b317..828984f 100644 --- a/server/app.py +++ b/server/app.py @@ -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= 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") diff --git a/static/app.js b/static/app.js index 5665a38..96b4bf1 100644 --- a/static/app.js +++ b/static/app.js @@ -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 { diff --git a/static/index.html b/static/index.html index 23691ba..fb2ce8b 100644 --- a/static/index.html +++ b/static/index.html @@ -3,17 +3,22 @@ + + + ani-web - + + - +
- +
+

Search for an anime to get started.

- + - + diff --git a/static/style.css b/static/style.css index 45a9d3d..5b0d8da 100644 --- a/static/style.css +++ b/static/style.css @@ -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); } }