docs: sync README + SPEC with the shipped reality
Both docs still described the pre-provider-layer single-source app:
- README: anidb-only framing, missing stats page, 'upstream.py is the
ONLY Cloudflare-facing code', and a self-check command that does not
work (plain script execution breaks the providers' relative imports;
the asserts are anidb-specific under the hianime-first default) —
now documents ANI_SOURCE=anidb + the -m form. Dropped the stale
'episode titles are not available' note (Jikan titles shipped).
- SPEC: 'no database' -> SQLite, architecture -> 3 providers, full
endpoint list (intros/searches/events/stats/cdn, DELETE history,
?all=1, {sub}), auth hardening (per-IP budget, X-Aniweb on /login),
tests/run.py -> pytest + ANIWEB_LIVE_TESTS gating, out-of-scope list
(countdown/intro-skip/titles shipped), risks updated for the
fallback layer, milestone 8 pointer.
This commit is contained in:
1 parent
4aca78cab8
commit
0f6bd0a52a
2 files changed
+110
-53
No files matched your search
@@ -1,9 +1,9 @@
|
|||||||
# ani-web
|
# ani-web
|
||||||
|
|
||||||
Web front-end for anime streaming: search, browse, and stream anime in
|
Web front-end for anime streaming: search, browse, and stream anime in
|
||||||
the browser via anidb.app. Multi-user, with per-user watch history and
|
the browser (hianime.at primary; anidb.app and anikototv.to as
|
||||||
HLS playback proxied through the backend. Tuned for phone as much as
|
fallbacks). Multi-user, with per-user watch history and HLS playback
|
||||||
desktop.
|
proxied through the backend. Tuned for phone as much as desktop.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -66,10 +66,11 @@ relative timestamps.
|
|||||||
|
|
||||||
### Search
|
### Search
|
||||||
|
|
||||||
The header search box queries anidb.app and shows results as a poster
|
The header search box queries the active source and shows results as a
|
||||||
grid. Your last 8 searches (per user) appear in a dropdown under the
|
poster grid. Your last 8 searches (per user) appear in a dropdown under
|
||||||
box, filter live as you type, and click to re-run. Results open the
|
the box, filter live as you type, and click to re-run. Results open the
|
||||||
detail view: synopsis, stats, season tabs, and filler badges.
|
detail view: synopsis and season tabs (where the source provides
|
||||||
|
them), plus filler badges.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -106,11 +107,20 @@ stall playback.
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
### Stats
|
||||||
|
|
||||||
|
`?stats` (hamburger menu) — watch minutes, episodes started/completed,
|
||||||
|
active days + best streak, sub/dub split, weekday and per-day heatmaps,
|
||||||
|
top-10 shows, top searches. Derived server-side from the position-event
|
||||||
|
stream (`POST /api/events`), 30–365 day windows.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
server/ FastAPI app (app.py), anidb.app fetchers (upstream.py),
|
server/ FastAPI app (app.py), source providers (providers/: hianime,
|
||||||
accounts (users.py). upstream.py is the ONLY Cloudflare-facing code
|
anidb, anikoto) over shared HTTP + dispatch (upstream.py),
|
||||||
|
accounts (users.py). upstream.py + providers/ are the ONLY
|
||||||
|
Cloudflare-facing code
|
||||||
static/ front-end: index.html, login.html, app.js, style.css, hls.min.js (vendored)
|
static/ front-end: index.html, login.html, app.js, style.css, hls.min.js (vendored)
|
||||||
tests/ pytest suite: conftest.py (throwaway server), HTTP + browser tests
|
tests/ pytest suite: conftest.py (throwaway server), HTTP + browser tests
|
||||||
docs/img/ README screenshots
|
docs/img/ README screenshots
|
||||||
@@ -120,17 +130,21 @@ SPEC.md design + the verified anidb.app data flow
|
|||||||
|
|
||||||
## Dev notes
|
## Dev notes
|
||||||
|
|
||||||
- anidb.app is behind Cloudflare; all upstream requests use
|
- The upstream sites sit behind Cloudflare; all upstream requests use
|
||||||
`curl_cffi` Chrome TLS impersonation. If that ever stops working,
|
`curl_cffi` Chrome TLS impersonation. Sources are ordered by
|
||||||
everything is isolated in `server/upstream.py`.
|
`ANI_SOURCE` (default `hianime,anidb,anikoto`) — a dead one falls
|
||||||
|
through to the next. All upstream-facing code is isolated in
|
||||||
|
`server/upstream.py` + `server/providers/`.
|
||||||
- The video CDN sends no CORS headers, so playback goes through the
|
- The video CDN sends no CORS headers, so playback goes through the
|
||||||
backend HLS proxy (`/stream/{token}`), which rewrites playlist URIs.
|
backend HLS proxy (`/stream/{token}`), which rewrites playlist URIs.
|
||||||
- `.venv/bin/python server/upstream.py` runs a self-check of the
|
- `ANI_SOURCE=anidb .venv/bin/python -m server.upstream` runs a live
|
||||||
fetchers (search → details → episodes → dub stream for Chainsaw Man).
|
self-check of the anidb fetchers (search → details → episodes → dub
|
||||||
- A background probe fetches 64KB from the CDN every 3 min and logs
|
stream). Must be the `-m` form — plain script execution breaks the
|
||||||
state flips to `server.log`; `GET /api/cdn` (public) returns the
|
providers' relative imports.
|
||||||
latest throughput + ~1h history. Use it to check if "playback
|
- A background probe fetches 64KB from the CDN (and checks the origin
|
||||||
broken" is the CDN again.
|
site) every 3 min and logs state flips to `server.log`; `GET
|
||||||
|
/api/cdn` (public) returns the latest throughput + ~1h history +
|
||||||
|
origin state. Use it to check if "playback broken" is the CDN again.
|
||||||
- The front-end is version-pinned: the server injects the git SHA as the
|
- The front-end is version-pinned: the server injects the git SHA as the
|
||||||
`?v=` cache-bust on `app.js`/`style.css` at startup (index.html itself
|
`?v=` cache-bust on `app.js`/`style.css` at startup (index.html itself
|
||||||
is `no-store`), so a restart is a cache bump — nothing to hand-bump.
|
is `no-store`), so a restart is a cache bump — nothing to hand-bump.
|
||||||
@@ -147,6 +161,3 @@ SPEC.md design + the verified anidb.app data flow
|
|||||||
and deterministic; the Playwright browser tests scrape and play LIVE
|
and deterministic; the Playwright browser tests scrape and play LIVE
|
||||||
upstream content, so they are opt-in: `ANIWEB_LIVE_TESTS=1
|
upstream content, so they are opt-in: `ANIWEB_LIVE_TESTS=1
|
||||||
.venv/bin/pytest tests/ -q` (needs playwright chromium installed).
|
.venv/bin/pytest tests/ -q` (needs playwright chromium installed).
|
||||||
- Episode titles are not available: anidb's episode API only returns
|
|
||||||
`{id, number, number2, filler}`. Adding titles needs a free
|
|
||||||
MyAnimeList API key.
|
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
# ani-web — Web Front-End for ani-cli
|
# ani-web — Web Front-End for ani-cli
|
||||||
|
|
||||||
Browse, search, and stream anime from the browser, using the same
|
Browse, search, and stream anime from the browser. Sources are pluggable
|
||||||
anidb.app data source that ani-cli scrapes.
|
providers (`server/providers/`): hianime.at primary, anidb.app (the
|
||||||
|
original ani-cli source) and anikototv.to as fallbacks.
|
||||||
|
|
||||||
**Status: implemented and running.** Deviations from the original
|
**Status: implemented and running.** Deviations from the original
|
||||||
draft: hls.js is vendored locally (CDN was a silent failure point),
|
draft: hls.js is vendored locally (CDN was a silent failure point),
|
||||||
history is server-side with watch-position resume (was: localStorage,
|
history is server-side with watch-position resume (was: localStorage,
|
||||||
last 20), and the front-end lives in `static/` with the server code in
|
last 20), storage is SQLite (was: JSON files), the front-end lives in
|
||||||
`server/`. See README.md.
|
`static/` with the server code in `server/`. See README.md.
|
||||||
|
|
||||||
## Verified data flow (spiked 2026-07, all 200 OK with Chrome TLS impersonation)
|
## Verified data flow (spiked 2026-07, all 200 OK with Chrome TLS impersonation)
|
||||||
|
|
||||||
|
The anidb.app flow — one of the three providers (hianime.at is primary;
|
||||||
|
see `server/providers/hianime.py` for its flow).
|
||||||
|
|
||||||
| Step | Request | Yields |
|
| Step | Request | Yields |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 1. Search | `GET anidb.app/browse?q={query}` | HTML cards: `href=".../anime/{slug}-{id}"`, `title="{name}"`, poster `img src` |
|
| 1. Search | `GET anidb.app/browse?q={query}` | HTML cards: `href=".../anime/{slug}-{id}"`, `title="{name}"`, poster `img src` |
|
||||||
@@ -33,41 +37,70 @@ or segments directly — **all HLS traffic must go through a backend proxy**.
|
|||||||
Browser (vanilla JS + hls.js)
|
Browser (vanilla JS + hls.js)
|
||||||
│ /api/* JSON /stream/* proxied HLS
|
│ /api/* JSON /stream/* proxied HLS
|
||||||
▼
|
▼
|
||||||
Python backend (FastAPI, uvicorn)
|
Python backend (FastAPI, uvicorn, SQLite)
|
||||||
│ curl_cffi (Chrome impersonation)
|
│ curl_cffi (Chrome impersonation)
|
||||||
▼
|
▼
|
||||||
anidb.app + hls.anidb.app
|
hianime.at / anidb.app / anikototv.to + their CDNs
|
||||||
```
|
```
|
||||||
|
|
||||||
Two moving parts, no database, no framework on the frontend.
|
Two moving parts, SQLite (users/history/searches/events), no framework
|
||||||
|
on the frontend.
|
||||||
|
|
||||||
## Backend
|
## Backend
|
||||||
|
|
||||||
`server/app.py` + `server/upstream.py`. Python 3.12, deps:
|
`server/app.py` (routes), `server/upstream.py` (shared HTTP, TTL
|
||||||
`fastapi`, `uvicorn`, `curl_cffi`, `itsdangerous`.
|
cache, provider dispatch), `server/providers/` (hianime, anidb,
|
||||||
|
anikoto), `server/db.py` (SQLite), `server/auth.py` (login throttling),
|
||||||
|
`server/users.py` (accounts). Python 3.12, deps: `fastapi`, `uvicorn`,
|
||||||
|
`curl_cffi`, `itsdangerous`.
|
||||||
|
|
||||||
|
Source dispatch: `ANI_SOURCE=hianime,anidb,anikoto` — ordered fallback;
|
||||||
|
an exception falls through to the next provider, a `None` from
|
||||||
|
`master_m3u8` ("no dub") is final.
|
||||||
|
|
||||||
- `GET /api/search?q=` → `[{id, slug_id, name, poster}]` (parse step 1)
|
- `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/{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/anime/{id}/episodes` → `[{id, number, filler}]` (step 3, passthrough)
|
||||||
- `GET /api/episode/{ep_id}/stream?lang=eng|jpn` → `{token}`
|
- `GET /api/intros/{mal_id}` → per-episode MAL intro timings + episode
|
||||||
(steps 5+6; token is base64url of the master m3u8 URL)
|
titles (keyless Jikan, 30-day cache; empty list when unavailable,
|
||||||
- `GET /api/history` / `POST /api/history` → **per-user** watch
|
never an error that can touch the player)
|
||||||
|
- `GET /api/episode/{ep_id}/stream?lang=eng|jpn` → `{token, sub?}`
|
||||||
|
(steps 5+6; token is base64url of the master m3u8 URL; `sub` is the
|
||||||
|
external .vtt URL when the provider serves subs separately)
|
||||||
|
- `GET /api/history?all=1` / `POST /api/history` /
|
||||||
|
`DELETE /api/history?slug_id=&ep_id=` → **per-user** watch
|
||||||
history with position, persisted to
|
history with position, persisted to
|
||||||
`server/data/aniweb.db` (SQLite; upsert per anime+episode).
|
`server/data/aniweb.db` (SQLite; upsert per anime+episode, newest 50).
|
||||||
|
- `GET /api/searches` / `POST /api/searches` → per-user recent
|
||||||
|
searches (newest 8, case-insensitive).
|
||||||
|
- `POST /api/events` → client self-diagnostics (playback failures,
|
||||||
|
step timelines, capability probes) + the position stream the stats
|
||||||
|
are derived from (90-day retention).
|
||||||
|
- `GET /api/stats?days=&slug=` → watch minutes, episodes
|
||||||
|
started/completed, active days + best streak, weekday/per-day
|
||||||
|
heatmaps, top-10 shows, sub/dub split — derived from the
|
||||||
|
position-event stream.
|
||||||
|
- `GET /api/cdn` → CDN + origin health (background 64KB probe every
|
||||||
|
3 min, ~1h history) — check whether "playback broken" is the CDN.
|
||||||
- `GET /stream/{token}` → proxied HLS. Token = base64url of the
|
- `GET /stream/{token}` → proxied HLS. Token = base64url of the
|
||||||
upstream URL. Rewrites every non-comment line of m3u8 bodies to
|
upstream URL (host-allowlisted against the providers' self-registered
|
||||||
point back at `/stream/`; proxies master, variants, and segments
|
CDN hosts — anti-SSRF). Rewrites every non-comment line of m3u8
|
||||||
(bytes, no transcoding). Responses are `Cache-Control: public,
|
bodies to point back at `/stream/`; proxies master, variants, and
|
||||||
max-age=86400` — VOD playlists/segments are immutable and the token
|
segments (bytes, no transcoding), segments across parallel byte-range
|
||||||
is the exact upstream URL, so the browser disk cache serves re-opens
|
lanes with single-stream fallback. Responses are `Cache-Control:
|
||||||
and re-seeks without touching the proxy.
|
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:
|
- `GET /login` / `POST /login` / `POST /logout` → multi-user auth:
|
||||||
PBKDF2-SHA256 passwords (`server/users.py`; SQLite `aniweb.db`, 0600),
|
PBKDF2-SHA256 passwords (`server/users.py`; SQLite `aniweb.db`, 0600),
|
||||||
signed 30-day cookie carrying the username, 5-fail/15-min
|
signed 30-day cookie carrying the username, 5-fail/15-min
|
||||||
in-memory lockout **per user**. Accounts are CLI-created only
|
in-memory lockout **per user** plus a per-source-IP failure budget
|
||||||
(`python -m server.users add|rm|list`) — no self-signup. One-time
|
(username rotation can't dodge it; XFF trusted only behind the
|
||||||
migration: legacy single-password `credentials.json` → user
|
proxy). All mutations — including `POST /login` — require the
|
||||||
`admin`; `history.json` → `history/admin.json`.
|
`X-Aniweb: 1` header (cross-site form POSTs can't set it). Accounts
|
||||||
|
are CLI-created only (`python -m server.users add|rm|list`) — no
|
||||||
|
self-signup. One-time migration (done): legacy single-password
|
||||||
|
`credentials.json` → user `admin`.
|
||||||
- Session hardening: cookies are `HttpOnly`, `SameSite=Lax`, and
|
- Session hardening: cookies are `HttpOnly`, `SameSite=Lax`, and
|
||||||
`Secure` when `ANIWEB_SECURE=1` (set by `run.sh`); the middleware
|
`Secure` when `ANIWEB_SECURE=1` (set by `run.sh`); the middleware
|
||||||
re-verifies the account still exists per request, so deleted
|
re-verifies the account still exists per request, so deleted
|
||||||
@@ -82,11 +115,15 @@ Two moving parts, no database, no framework on the frontend.
|
|||||||
produced black screens.
|
produced black screens.
|
||||||
|
|
||||||
Flow: search box → results grid (poster + title, click) → detail view
|
Flow: search box → results grid (poster + title, click) → detail view
|
||||||
(synopsis under the episode grid, season tabs) → episode grid (number
|
(synopsis and season tabs where the source provides them, under the
|
||||||
|
episode grid) → episode grid (number
|
||||||
+ filler badge, sub/dub toggle) → `<video>` with hls.js pointed at
|
+ filler badge, sub/dub toggle) → `<video>` with hls.js pointed at
|
||||||
`/stream/{token}`; single quality — the proxy fetches each segment
|
`/stream/{token}`; single quality — the proxy fetches each segment
|
||||||
across parallel byte-range lanes (single-stream fallback).
|
across parallel byte-range lanes (single-stream fallback).
|
||||||
|
|
||||||
|
Views: home (continue watching), search, detail+player, full history
|
||||||
|
(`?history`), stats (`?stats`); PWA-installable.
|
||||||
|
|
||||||
- Default play: opening an anime resumes the last watched episode of
|
- Default play: opening an anime resumes the last watched episode of
|
||||||
the season (seeking to the saved position unless ≥95% through),
|
the season (seeking to the saved position unless ≥95% through),
|
||||||
else autoplays episode 1. If the browser blocks autoplay, the native
|
else autoplays episode 1. If the browser blocks autoplay, the native
|
||||||
@@ -103,22 +140,28 @@ uv venv .venv && uv pip install --python .venv/bin/python -r requirements.txt
|
|||||||
```
|
```
|
||||||
|
|
||||||
Use the app through your HTTPS proxy (with `Secure` cookies, plain
|
Use the app through your HTTPS proxy (with `Secure` cookies, plain
|
||||||
remote HTTP can't hold a session). `python tests/run.py` runs the
|
remote HTTP can't hold a session). `.venv/bin/pytest tests/ -q` runs
|
||||||
full suite against a throwaway server on :8399.
|
the hermetic suite against a throwaway server on :8399; the
|
||||||
|
live-upstream browser e2e is opt-in (`ANIWEB_LIVE_TESTS=1`).
|
||||||
|
|
||||||
## Out of scope (v1)
|
## Out of scope (v1)
|
||||||
|
|
||||||
Downloads (`-d`), next-episode countdown (`-N`), syncplay, intro skip,
|
Downloads (`-d`), syncplay, ani-schedule integration. (Next-episode
|
||||||
ani-schedule integration, episode titles (not in anidb's API — needs
|
countdown, intro skip, and episode titles — via keyless Jikan —
|
||||||
a MyAnimeList API key). Add when someone asks.
|
shipped since this list was written.) Add when someone asks.
|
||||||
|
|
||||||
## Risks
|
## Risks
|
||||||
|
|
||||||
- **Cloudflare policy change** → upstream breaks. Mitigation: swap
|
- **Cloudflare policy change** → upstream breaks. Mitigation: swap
|
||||||
`impersonate="chrome"` for `"safari"`/binary curl-impersonate;
|
`impersonate="chrome"` for `"safari"`/binary curl-impersonate;
|
||||||
all upstream access is isolated in one module (`upstream.py`).
|
all upstream access is isolated in `upstream.py` + `providers/`,
|
||||||
- **anidb.app layout change** → parsers break. The 4 API endpoints
|
and the ordered multi-source fallback turns one dead site into a
|
||||||
(steps 3–6) are clean JSON; only steps 1, 2, 5 are HTML/JS scraping.
|
degraded result set, not an outage.
|
||||||
|
- **Provider layout change** → parsers break. For anidb the 4 API
|
||||||
|
endpoints (steps 3–6) are clean JSON; only steps 1, 2, 5 are
|
||||||
|
HTML/JS scraping. hianime/anikoto are fully scraped (higher rotation
|
||||||
|
risk), so they self-register CDN hosts and re-roll servers until one
|
||||||
|
serves.
|
||||||
- **ToS** — anidb.app is a third-party scraper site; personal use,
|
- **ToS** — anidb.app is a third-party scraper site; personal use,
|
||||||
no redistribution, bind to LAN only by default.
|
no redistribution, bind to LAN only by default.
|
||||||
|
|
||||||
@@ -133,3 +176,6 @@ a MyAnimeList API key). Add when someone asks.
|
|||||||
states, autoplay default, `run.sh`.
|
states, autoplay default, `run.sh`.
|
||||||
7. Auth — login screen, then multi-user CLI-created accounts with
|
7. Auth — login screen, then multi-user CLI-created accounts with
|
||||||
per-user history + test suite.
|
per-user history + test suite.
|
||||||
|
8. Later (see git log, `docs/CHANGES-2026-09.md`): provider layer with
|
||||||
|
hianime primary, SQLite storage, stats/events, 2026-09 security
|
||||||
|
audit batch.
|
||||||
Reference in new issue
Block a user