Files
ani-web/SPEC.md
T
jozamudi 0fd06f036a Playback: retry flaky upstream, faster proxy fail, cache /stream in browser
- upstream._get: one 300ms retry on transient errors (CF 403/503, 429,
  5xx); 404 stays final. Fixes the 502 cluster that hls.js couldn't
  recover from (fragLoadTimeOut x3 in error log)
- /stream: upstream timeout 12s < hls.js fragLoadingTimeOut (20s), so a
  hung segment 502s first and hls.js retries instead of fatal-timing-out
- /stream responses: Cache-Control public max-age=86400 (VOD + token is
  the exact upstream URL => immutable); re-opens/re-seeks skip the proxy
- /api/stream: drop unused qualities (frontend never read it; saved a
  ~0.2s cold upstream round-trip per episode)
- app.js/style.css: max-age=300 instead of no-store
- test_history_page_and_delete: stale selector (home redesign removed
  the 'view all' link; use 'history ->')
2026-08-30 17:23:57 +00:00

136 lines
6.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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}`
(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). Responses are `Cache-Control: public,
max-age=86400` — VOD playlists/segments are immutable and the token
is the exact upstream URL, so the browser disk cache serves re-opens
and re-seeks without touching the proxy.
- `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`.
- Session hardening: cookies are `HttpOnly`, `SameSite=Lax`, and
`Secure` when `ANIWEB_SECURE=1` (set by `run.sh`); the middleware
re-verifies the account still exists per request, so deleted
accounts' cookies are rejected immediately.
- `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, the native
play button on the video is the way to start.
- 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
.venv/bin/python -m server.users add <you> # one-time account creation
./run.sh # launch/restart, logs to server.log, sets ANIWEB_SECURE=1
```
Use the app through your HTTPS proxy (with `Secure` cookies, plain
remote HTTP can't hold a session). `python tests/run.py` runs the
full suite against a throwaway server on :8399.
## 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.
## 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`.
7. Auth — login screen, then multi-user CLI-created accounts with
per-user history + test suite.