"""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 time from pathlib import Path from urllib.parse import quote, urljoin from fastapi import Body, FastAPI, HTTPException, Query from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response from fastapi.staticfiles import StaticFiles from itsdangerous import BadSignature, TimestampSigner from . import credentials, upstream ROOT = Path(__file__).parent.parent app = FastAPI(title="ani-web") # ---------- auth: single user, signed cookie ---------- _SIGNER = TimestampSigner(credentials.load()["secret"]) # exits with instructions if unset _SESSION = "aniweb_session" _SESSION_AGE = 30 * 86400 _PUBLIC = {("/login", "GET"), ("/login", "POST")} # ponytail: in-memory lockout, single process — 5 fails lock for 15 min _lockout = {"fails": 0, "until": 0.0} def _auth_ok(request) -> bool: try: return _SIGNER.unsign(request.cookies.get(_SESSION, ""), max_age=_SESSION_AGE) == b"ok" except BadSignature: return False @app.middleware("http") async def require_auth(request, call_next): if (request.url.path, request.method) in _PUBLIC or _auth_ok(request): return await call_next(request) 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) @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(...)): if time.time() < _lockout["until"]: return JSONResponse({"detail": "too many attempts, try later"}, status_code=429) if credentials.check(payload.get("password", "")): _lockout.update(fails=0, until=0.0) resp = JSONResponse({"ok": True}) resp.set_cookie(_SESSION, _SIGNER.sign(b"ok").decode(), max_age=_SESSION_AGE, httponly=True, samesite="lax") return resp _lockout["fails"] += 1 if _lockout["fails"] >= 5: _lockout.update(fails=0, until=time.time() + 900) time.sleep(0.5) # slow down brute force return JSONResponse({"detail": "wrong password"}, status_code=401) @app.post("/logout") def logout(): resp = JSONResponse({"ok": True}) resp.delete_cookie(_SESSION) return resp HIST_FILE = Path(__file__).parent / "data" / "history.json" def _load_hist(): try: return json.loads(HIST_FILE.read_text()) except (FileNotFoundError, json.JSONDecodeError): return [] def _save_hist(entries): HIST_FILE.parent.mkdir(exist_ok=True) # ponytail: unguarded read-modify-write, fine for a single-user tool HIST_FILE.write_text(json.dumps(entries, indent=1)) @app.get("/api/history") def api_history(): return sorted(_load_hist(), key=lambda e: e.get("t", 0), reverse=True)[:5] @app.post("/api/history") def api_history_post(entry: dict = Body(...)): if "ep_id" not in entry: raise HTTPException(422, "ep_id required") entry["t"] = time.time() entries = _load_hist() 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(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 (segments should cache) r = await call_next(request) if 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"): 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)} 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" @app.get("/stream/{token}") def stream(token: str): # ponytail: buffers whole response in memory; .ts segments are small url = _url(token) 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() return Response(body, media_type=r.headers.get("content-type", "application/octet-stream")) app.mount("/", StaticFiles(directory=ROOT / "static", html=True), name="static")