Files
ani-web/server/app.py
T
jozamudi 19e878800c perf: keep-alive upstream sessions, TTL cache on upstream data, /stream LRU, cache hls.min.js
- shared thread-local curl_cffi sessions (no TLS handshake per call)
- in-memory TTL cache: search 5min, details/episodes/languages/
  playlist-URLs 1h; api_stream retries once with a cold cache on 502
  (recovers rotated playlist URLs)
- /stream LRU (24 entries) serves re-seeks and playlist re-fetches from memory
- hls.min.js: Cache-Control public max-age=30d (was no-store -> 500KB
  re-download per page load on mobile)

Measured (click->play chain, cold -> warm):
  search    0.45s -> 0.00s
  detail    0.46s -> 0.00s
  episodes  0.40s -> 0.00s
  stream    1.06s -> 0.00s
  m3u8 x2   0.64s -> 0.00s
  segment   0.20s -> 0.00s
2026-08-19 17:46:17 +00:00

261 lines
8.6 KiB
Python

"""app.py — ani-web: FastAPI over upstream.py + HLS proxy + static UI.
Browser playback: hls.anidb.app sends no CORS headers, so the player is
pointed at /stream/{token}; the proxy fetches upstream (with the same
Cloudflare workaround) and rewrites playlist URIs to go through itself.
"""
import base64
import json
import os
import time
from pathlib import Path
from base64 import urlsafe_b64encode, urlsafe_b64decode
from collections import OrderedDict
from urllib.parse import quote, urljoin
from fastapi import Body, FastAPI, HTTPException, Query, Request
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response
from fastapi.staticfiles import StaticFiles
from itsdangerous import BadSignature, TimestampSigner
from . import upstream, users
ROOT = Path(__file__).parent.parent
app = FastAPI(title="ani-web")
# ---------- auth: multi-user, signed cookies ----------
users.migrate_legacy()
_SIGNER = TimestampSigner(users.get_secret())
_SESSION = "aniweb_session"
_SESSION_AGE = 30 * 86400
_PUBLIC = {("/login", "GET"), ("/login", "POST")}
_SECURE = os.environ.get("ANIWEB_SECURE") == "1" # set when served over HTTPS
# ponytail: in-memory lockout per username, single process — 5 fails -> 15 min
_lockout: dict = {}
def _session_user(request) -> str | None:
try:
payload = _SIGNER.unsign(request.cookies.get(_SESSION, ""), max_age=_SESSION_AGE)
except BadSignature:
return None
return payload.decode().split(":", 1)[1] if payload.startswith(b"user:") else None
def _lock(user: str, pw: str) -> str:
lo = _lockout.setdefault(user, {"fails": 0, "until": 0.0})
if time.time() < lo["until"]:
return "locked"
if users.verify(user, pw):
lo.update(fails=0, until=0.0)
return "ok"
lo["fails"] += 1
if lo["fails"] >= 5:
lo.update(fails=0, until=time.time() + 900)
time.sleep(0.5) # slow down brute force
return "bad"
@app.middleware("http")
async def require_auth(request, call_next):
if (request.url.path, request.method) in _PUBLIC:
return await call_next(request)
user = _session_user(request)
# stateless cookie -> re-verify the account still exists, so
# deleted users' cookies die immediately
if user is not None and user not in users.load():
user = None
if not user:
if request.url.path.startswith("/api/"):
return JSONResponse({"detail": "not authenticated"}, status_code=401)
next_ = request.url.path + (f"?{request.url.query}" if request.url.query else "")
return RedirectResponse(f"/login?next={quote(next_, safe='')}", status_code=302)
request.state.user = user
return await call_next(request)
def _session_cookie(resp, user: str):
resp.set_cookie(_SESSION, _SIGNER.sign(b"user:" + user.encode()).decode(),
max_age=_SESSION_AGE, httponly=True, samesite="lax", secure=_SECURE)
@app.get("/login", include_in_schema=False)
def login_page():
return FileResponse(ROOT / "static" / "login.html")
@app.post("/login")
async def login(payload: dict = Body(...)):
u = payload.get("username", "").lower()
if not u:
return JSONResponse({"detail": "username required"}, status_code=400)
state = _lock(u, payload.get("password", ""))
if state == "ok":
resp = JSONResponse({"ok": True, "user": u})
_session_cookie(resp, u)
return resp
if state == "locked":
return JSONResponse({"detail": "too many attempts, try later"}, status_code=429)
return JSONResponse({"detail": "wrong username or password"}, status_code=401)
@app.get("/api/me", include_in_schema=False)
def api_me(request: Request):
return {"user": request.state.user}
@app.post("/account/delete")
def delete_account(request: Request):
users.remove(request.state.user)
resp = JSONResponse({"ok": True})
resp.delete_cookie(_SESSION, secure=_SECURE)
return resp
@app.post("/logout")
def logout():
resp = JSONResponse({"ok": True})
resp.delete_cookie(_SESSION, secure=_SECURE)
return resp
HIST_DIR = Path(os.environ.get("ANIWEB_DATA", Path(__file__).parent / "data")) / "history"
def _load_hist(user: str):
try:
return json.loads((HIST_DIR / f"{user}.json").read_text())
except (FileNotFoundError, json.JSONDecodeError):
return []
def _save_hist(user: str, entries):
HIST_DIR.mkdir(exist_ok=True)
# ponytail: unguarded read-modify-write per file, fine at this scale
(HIST_DIR / f"{user}.json").write_text(json.dumps(entries, indent=1))
@app.get("/api/history")
def api_history(request: Request):
# no-store: this JSON mutates while a tab is open (position saves);
# a stale copy has caused "poster gone" confusion before
return JSONResponse(
sorted(_load_hist(request.state.user), key=lambda e: e.get("t", 0), reverse=True)[:5],
headers={"Cache-Control": "no-store"})
@app.post("/api/history")
def api_history_post(entry: dict = Body(...), request: Request = None):
if "ep_id" not in entry:
raise HTTPException(422, "ep_id required")
entry["t"] = time.time()
u = request.state.user
entries = _load_hist(u)
for i, e in enumerate(entries):
if e.get("slug_id") == entry.get("slug_id") and e.get("ep_id") == entry.get("ep_id"):
entries[i] = {**e, **entry}
break
else:
entries.append(entry)
_save_hist(u, sorted(entries, key=lambda e: e.get("t", 0), reverse=True)[:50])
return entry
def _b64(url: str) -> str:
return base64.urlsafe_b64encode(url.encode()).decode()
def _url(token: str) -> str:
return base64.urlsafe_b64decode(token.encode()).decode()
def _upstream(fn, *a, **kw):
try:
return fn(*a, **kw)
except HTTPException:
raise
except Exception as e:
raise HTTPException(502, f"upstream error: {e}")
@app.middleware("http")
async def no_cache(request, call_next):
# ponytail: no-store on everything except /stream (immutable HLS
# data) and the vendored hls.min.js (immutable, 500KB — re-downloading
# it on every page load hurt on mobile)
r = await call_next(request)
if request.url.path == "/hls.min.js":
r.headers["Cache-Control"] = "public, max-age=2592000"
elif not request.url.path.startswith("/stream"):
r.headers["Cache-Control"] = "no-store"
return r
@app.get("/api/search")
def api_search(q: str = Query(min_length=1)):
return _upstream(upstream.search, q)
@app.get("/api/anime/{slug_id}")
def api_anime(slug_id: str):
return _upstream(upstream.details, slug_id)
@app.get("/api/anime/{anime_id}/episodes")
def api_episodes(anime_id: int):
return _upstream(upstream.episodes, anime_id)
@app.get("/api/episode/{ep_id}/stream")
def api_stream(ep_id: int, lang: str = "eng"):
def go():
master = _upstream(upstream.master_m3u8, ep_id, lang)
if not master:
raise HTTPException(404, f"no {lang} stream for episode {ep_id}")
return {"token": _b64(master),
"qualities": _upstream(upstream.master_variants, master)}
try:
return go()
except HTTPException as e:
# one-shot retry with a cold cache: recovers rotated/expired
# playlist URLs from the TTL cache
if e.status_code == 502:
upstream.clear_cache()
return go()
raise
def _rewrite_m3u8(text: str, base: str) -> str:
# ponytail: rewrites plain URIs only; URI-list attributes in
# #EXT-X-* tags (e.g. SUBTITLES) are ignored — fine for video-only playlists
return "\n".join(
line if line.startswith("#") or not line
else "/stream/" + _b64(urljoin(base, line))
for line in text.splitlines()) + "\n"
_STREAM_CACHE = OrderedDict() # ponytail: LRU of proxied HLS data (immutable);
_STREAM_MAX = 24 # segments ~1-2MB -> <= ~48MB
@app.get("/stream/{token}")
def stream(token: str):
# ponytail: buffers whole response in memory; .ts segments are small
url = _url(token)
hit = _STREAM_CACHE.get(url)
if hit:
_STREAM_CACHE.move_to_end(url)
return Response(hit[1], media_type=hit[0])
r = _upstream(upstream._get, url)
body = r.content
if url.endswith(".m3u8") or "mpegurl" in r.headers.get("content-type", ""):
body = _rewrite_m3u8(body.decode(), url).encode()
_STREAM_CACHE[url] = (r.headers.get("content-type", "application/octet-stream"), body)
while len(_STREAM_CACHE) > _STREAM_MAX:
_STREAM_CACHE.popitem(last=False)
return Response(body, media_type=r.headers.get("content-type", "application/octet-stream"))
app.mount("/", StaticFiles(directory=ROOT / "static", html=True), name="static")