"""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") 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 time.sleep(180) threading.Thread(target=_cdn_probe, daemon=True).start() # ---------- 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")} _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