"""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: entries = json.loads((HIST_DIR / f"{user}.json").read_text()) except (FileNotFoundError, json.JSONDecodeError): return [] # ponytail: dedup legacy ghost rows — the same episode was stored with ep_id # as int AND string; keep the newest per (slug_id, str(ep_id)) best = {} for e in entries: k = (str(e.get("slug_id")), str(e.get("ep_id"))) if e.get("t", 0) >= best.get(k, {"t": 0})["t"]: best[k] = e return sorted(best.values(), key=lambda e: e.get("t", 0), reverse=True) 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. # ?all=1: full list for the /history page (no top-5 cap) entries = sorted(_load_hist(request.state.user), key=lambda e: e.get("t", 0), reverse=True) if request.query_params.get("all") != "1": entries = entries[:5] return JSONResponse(entries, headers={"Cache-Control": "no-store"}) @app.delete("/api/history") def api_history_delete(request: Request, slug_id: str, ep_id: str): # loose str() match on purpose: legacy rows store the same ep_id as int # AND as string — one delete clears both ghost rows entries = _load_hist(request.state.user) keep = [e for e in entries if not (str(e.get("slug_id")) == slug_id and str(e.get("ep_id")) == ep_id)] _save_hist(request.state.user, keep) return {"removed": len(entries) - len(keep)} @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") # normalize: ghost rows were born when ep_id arrived as str against an # int row (or vice versa) — int() makes the stored type stable try: entry["ep_id"] = int(entry["ep_id"]) except (TypeError, ValueError): raise HTTPException(422, "ep_id must be an integer") entry["t"] = time.time() u = request.state.user entries = _load_hist(u) for i, e in enumerate(entries): # loose str() match: merge onto a legacy ghost row of the other type if e.get("slug_id") == entry.get("slug_id") and str(e.get("ep_id")) == str(entry["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 SEARCH_DIR = Path(os.environ.get("ANIWEB_DATA", Path(__file__).parent / "data")) / "searches" def _load_searches(user: str): try: return json.loads((SEARCH_DIR / f"{user}.json").read_text()) except (FileNotFoundError, json.JSONDecodeError): return [] def _save_searches(user: str, items): SEARCH_DIR.mkdir(exist_ok=True) (SEARCH_DIR / f"{user}.json").write_text(json.dumps(items, indent=1)) @app.get("/api/searches") def api_searches(request: Request): return JSONResponse(_load_searches(request.state.user), headers={"Cache-Control": "no-store"}) @app.post("/api/searches") def api_searches_post(item: dict = Body(...), request: Request = None): q = str(item.get("q", "")).strip()[:60] if not q: raise HTTPException(422, "q required") u = request.state.user # dedupe case-insensitively, newest first, cap 8 items = [x for x in _load_searches(u) if x.lower() != q.lower()] items.insert(0, q) _save_searches(u, items[:8]) return {"ok": True} 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 = "jpn"): 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")