"""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 threading import time from collections import Counter, OrderedDict from datetime import datetime from pathlib import Path 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.staticfiles import StaticFiles from itsdangerous import BadSignature, TimestampSigner from . import db, upstream, users ROOT = Path(__file__).parent.parent app = FastAPI(title="ani-web") # 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": []} def _cdn_probe(): """Background probe: pull 64KB from the CDN every 3 min and log state flips, so server.log shows when a degraded window ends (CDN RECOVERED). Probe target: any valid episode (36019 = JJK S2 E25). ponytail: single fixed episode; if it ever disappears, the probe logs DEGRADED forever — swap the ID, don't build a picker.""" def first(pl, base): l = next(x.strip() for x in pl.splitlines() if x.strip() and not x.startswith("#")) return l if l.startswith("http") else base + l state = None while True: try: m = upstream.master_m3u8(36019, "jpn") if not m: raise RuntimeError("no master m3u8") base = m.rsplit("/", 1)[0] + "/" v = first(upstream._get(m, timeout=10).text, base) seg = first(upstream._get(v, timeout=10).text, base) t = time.time() r = upstream._session().get(seg, headers={"Range": "bytes=0-65535"}, impersonate="chrome", timeout=10) kbps = len(r.content) / 1024 / max(time.time() - t, 0.01) except Exception: kbps = 0.0 now = "OK" if kbps >= 40 else "DEGRADED" if now != state: print(f"[probe] CDN {now} ({kbps:.0f} KB/s)", flush=True) state = now _CDN.update(state=now, kbps=round(kbps), ts=time.time()) _CDN["hist"].append({"t": time.time(), "kbps": round(kbps)}) del _CDN["hist"][:-20] time.sleep(180) 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 # ---------- auth: multi-user, signed cookies ---------- users.migrate_legacy() # file-based legacy import; db.init() absorbs its output db.init() _SIGNER = TimestampSigner(users.get_secret()) _SESSION = "aniweb_session" _SESSION_AGE = 30 * 86400 _PUBLIC = {("/login", "GET"), ("/login", "POST"), ("/api/cdn", "GET")} _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 csrf_header(request, call_next): # ponytail: a cross-site form POST (top-level navigation) does carry the # SameSite=Lax cookie, so