# 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. **Status: implemented and running.** Deviations from the original draft: hls.js is vendored locally (CDN was a silent failure point), history is server-side with watch-position resume (was: localStorage, last 20), and the front-end lives in `static/` with the server code in `server/`. See README.md. ## 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 `server/app.py` + `server/upstream.py`. Python 3.12, deps: `fastapi`, `uvicorn`, `curl_cffi`, `itsdangerous`. - `GET /api/search?q=` → `[{id, slug_id, name, poster}]` (parse step 1) - `GET /api/anime/{slug_id}` → `{id, name, synopsis, mal_id, seasons:[{slug_id,name,id}]}` - `GET /api/anime/{id}/episodes` → `[{id, number, filler}]` (step 3, passthrough) - `GET /api/episode/{ep_id}/stream?lang=eng|jpn` → `{token, qualities}` (steps 5+6; token is base64url of the master m3u8 URL) - `GET /api/history` / `POST /api/history` → **per-user** watch history with position, persisted to `server/data/history/.json` (50 kept, top 5 returned; upsert per anime+episode). - `GET /stream/{token}` → proxied HLS. Token = base64url of the upstream URL. Rewrites every non-comment line of m3u8 bodies to point back at `/stream/`; proxies master, variants, and segments (bytes, no transcoding). - `GET /login` / `POST /login` / `POST /logout` → multi-user auth: PBKDF2-SHA256 passwords (`server/users.py`; `users.json` 0600), signed 30-day cookie carrying the username, 5-fail/15-min in-memory lockout **per user**. Accounts are CLI-created only (`python -m server.users add|rm|list`) — no self-signup. One-time migration: legacy single-password `credentials.json` → user `admin`; `history.json` → `history/admin.json`. - `GET /api/me`, `POST /account/delete` → current user / self-delete (account + history file). ## Frontend `static/`: single `index.html` + `app.js` + `style.css`. hls.js is **vendored** (`static/hls.min.js`) — a CDN dependency silently produced black screens. Flow: search box → results grid (poster + title, click) → detail view (synopsis under the episode grid, season tabs) → episode grid (number + filler badge, sub/dub toggle) → `