commit e4dde86d85190e154c5716c6c8cf7aaa89aca058 Author: Josue Zamudio Date: Wed Aug 19 15:06:43 2026 +0000 ani-web: FastAPI + HLS proxy over anidb.app, vanilla UI - upstream.py: Cloudflare-proof fetchers (curl_cffi), self-checking - app.py: /api/* routes + /stream/{token} HLS proxy (CDN has no CORS) - static/: search -> detail/seasons/episodes -> hls.js player, history - verified end-to-end: search chainsaw man -> ep1 dub 1080p segment diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..77ac754 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.venv/ +__pycache__/ +*.pyc diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..ef27a7a --- /dev/null +++ b/SPEC.md @@ -0,0 +1,100 @@ +# ani-web — Web Front-End for ani-cli + +Browse, search, and stream anime from the browser, using the same +anidb.app data source that ani-cli scrapes. + +## Verified data flow (spiked 2026-07, all 200 OK with Chrome TLS impersonation) + +| Step | Request | Yields | +|---|---|---| +| 1. Search | `GET anidb.app/browse?q={query}` | HTML cards: `href=".../anime/{slug}-{id}"`, `title="{name}"`, poster `img src` | +| 2. Details | `GET anidb.app/anime/{slug}-{id}` | MAL id, synopsis, related seasons (same-card links) | +| 3. Episodes | `GET anidb.app/api/frontend/anime/{id}/episodes` | JSON `{episodes: [{id, number, number2, filler}]}` | +| 4. Languages | `GET anidb.app/api/frontend/episode/{ep_id}/languages` | JSON `{languages: [{code: "jpn"\|"eng", name, embed_url}]}` — `jpn`=sub, `eng`=dub | +| 5. Embed page | `GET {embed_url}` | JS blob containing `file: ''` | +| 6. Master playlist | `GET {master m3u8}` (hls.anidb.app) | variants: 1080p / 720p / 360p | +| 7. Variants + segments | `GET ...m3u8`, `.ts` | streamable HLS — **NO CORS headers** | + +Key constraint: anidb.app sits behind Cloudflare (plain curl → 403). +All upstream requests must use TLS-fingerprint impersonation +(verified: `curl_cffi` `impersonate="chrome"` passes the challenge). +Since the CDN sends no CORS headers, the browser cannot fetch playlists +or segments directly — **all HLS traffic must go through a backend proxy**. + +## Architecture + +``` +Browser (vanilla JS + hls.js) + │ /api/* JSON /stream/* proxied HLS + ▼ +Python backend (FastAPI, uvicorn) + │ curl_cffi (Chrome impersonation) + ▼ +anidb.app + hls.anidb.app +``` + +Two moving parts, no database, no framework on the frontend. + +## Backend + +Python 3.12, deps: `fastapi`, `uvicorn`, `curl_cffi`. + +- `GET /api/search?q=` → `[{id, slug, name, poster}]` (parse step 1) +- `GET /api/anime/{slug_id}` → `{name, synopsis, mal_id, seasons:[{id,name}]}` +- `GET /api/anime/{id}/episodes` → `[{id, number, filler}]` (step 3, passthrough) +- `GET /api/episode/{ep_id}/languages` → `[{code, name, embed_url}]` (step 4) +- `GET /stream/{token}` → proxied HLS. Token = base64(nats) of the + upstream URL. Rewrites `#EXT-X-...` bodies: every relative/absolute + URL in the playlist is re-pointed at `/stream/...`. Serves master, + variants, and `.ts` segments through the same endpoint (bytes + proxied, no transcoding). +- Upstream cache: embed tokens expire — cache embed→m3u8 mapping for + ~10 min; cache nothing else (search is cheap). + +## Frontend + +Single `index.html` + `app.js` + `style.css`. hls.js from CDN. + +Flow: search box → results grid (poster + title, click) → detail view +(synopsis, season tabs) → episode list (number + filler badge, +sub/dub toggle) → `