- server/users.py replaces credentials.py: users.json (0600), PBKDF2, one-time migration of the legacy single password to user admin (history.json -> history/admin.json) - accounts are CLI-managed only: python -m server.users add|rm|list (no self-signup on an internet-exposed app); /signup route removed, login page back to username + password - per-user 30-day signed session cookie (payload carries the username), per-user lockout, GET /api/me, POST /account/delete - per-user watch history: server/data/history/<user>.json; ANIWEB_DATA env override for the data dir - header chip: logged-in user, logout, delete account - tests/: throwaway-server suite (port 8399, temp data dir) — 9 HTTP tests + 2 Playwright browser tests (login UI, real video playback behind auth); runs via python tests/run.py
125 lines
5.6 KiB
Markdown
125 lines
5.6 KiB
Markdown
# 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: '<master m3u8>'` |
|
||
| 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/<user>.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) → `<video>` with hls.js pointed at
|
||
`/stream/{token}`; quality follows the master playlist (auto ABR).
|
||
|
||
- Default play: opening an anime resumes the last watched episode of
|
||
the season (seeking to the saved position unless ≥95% through),
|
||
else autoplays episode 1. If the browser blocks autoplay, a
|
||
"click to play" overlay appears.
|
||
- Watch position saved to `/api/history` every ~10s + on pause/unload;
|
||
home page lists the 5 most recent entries with timecode.
|
||
|
||
## Run
|
||
|
||
```sh
|
||
uv venv .venv && uv pip install --python .venv/bin/python -r requirements.txt
|
||
./run.sh # launch/restart, logs to server.log
|
||
```
|
||
|
||
Open `http://localhost:8321` (bound to 0.0.0.0 for LAN access).
|
||
|
||
## Out of scope (v1)
|
||
|
||
Downloads (`-d`), next-episode countdown (`-N`), syncplay, intro skip,
|
||
ani-schedule integration, episode titles (not in anidb's API — needs
|
||
a MyAnimeList API key). Add when someone asks.
|
||
anidb's API — needs a MyAnimeList API key). Add when someone asks.
|
||
|
||
## Risks
|
||
|
||
- **Cloudflare policy change** → upstream breaks. Mitigation: swap
|
||
`impersonate="chrome"` for `"safari"`/binary curl-impersonate;
|
||
all upstream access is isolated in one module (`upstream.py`).
|
||
- **anidb.app layout change** → parsers break. The 4 API endpoints
|
||
(steps 3–6) are clean JSON; only steps 1, 2, 5 are HTML/JS scraping.
|
||
- **ToS** — anidb.app is a third-party scraper site; personal use,
|
||
no redistribution, bind to LAN only by default.
|
||
|
||
## Milestones (all done)
|
||
|
||
1. Spike — curl_cffi passes Cloudflare; all 7 steps verified.
|
||
2. `server/upstream.py` — the 6 fetch functions, assert self-check.
|
||
3. API — FastAPI routes over upstream.py.
|
||
4. HLS proxy — `/stream/{token}` with playlist rewriting.
|
||
5. UI — search → detail → episodes → player.
|
||
6. Polish — history with position + resume, sub/dub toggle, error
|
||
states, autoplay default, `run.sh`.
|