9.3 KiB
ani-web — Web Front-End for ani-cli
Browse, search, and stream anime from the browser. Sources are pluggable
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
draft: hls.js is vendored locally (CDN was a silent failure point),
history is server-side with watch-position resume (was: localStorage,
last 20), storage is SQLite (was: JSON files), 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)
The anidb.app flow — one of the three providers (hianime.at is primary;
see server/providers/hianime.py for its flow).
| 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) |
single rendition (v1) — no ABR ladders |
| 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, SQLite)
│ curl_cffi (Chrome impersonation)
▼
hianime.at / anidb.app / anikototv.to + their CDNs
Two moving parts, SQLite (users/history/searches/events), no framework on the frontend.
Backend
server/app.py (routes), server/upstream.py (shared HTTP, TTL
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/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/intros/{mal_id}→ per-episode MAL intro timings + episode titles (keyless Jikan, 30-day cache; empty list when unavailable, 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;subis 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 toserver/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 upstream URL (host-allowlisted against the providers' self-registered CDN hosts — anti-SSRF). Rewrites every non-comment line of m3u8 bodies to point back at/stream/; proxies master, variants, and segments (bytes, no transcoding), segments across parallel byte-range lanes with single-stream fallback. Responses areCache-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; SQLiteaniweb.db, 0600), signed 30-day cookie carrying the username, 5-fail/15-min in-memory lockout per user plus a per-source-IP failure budget (username rotation can't dodge it; XFF trusted only behind the proxy). All mutations — includingPOST /login— require theX-Aniweb: 1header (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-passwordcredentials.json→ useradmin.- Session hardening: cookies are
HttpOnly,SameSite=Lax, andSecurewhenANIWEB_SECURE=1(set byrun.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 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/stream/{token}; single quality — the proxy fetches each segment across parallel byte-range lanes (single-stream fallback).
Views: home (continue watching), search, detail+player, full history
(?history), stats (?stats); PWA-installable.
Cache-busting: the server injects the git SHA as ?v= on
app.js/style.css at startup (index.html itself is no-store), so
a restart is a cache bump — nothing to hand-bump.
- 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/historyevery ~10s + on pause/unload; home page lists the 5 most recent entries with timecode.
Run
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). .venv/bin/pytest tests/ -q runs
the hermetic suite against a throwaway server on :8399; the
live-upstream browser e2e is opt-in (ANIWEB_LIVE_TESTS=1).
ANI_SOURCE=anidb .venv/bin/python -m server.upstream runs a live
self-check of the anidb fetchers (search → details → episodes → dub
stream); must be the -m form (plain script execution breaks the
providers' relative imports).
Out of scope (v1)
Downloads (-d), syncplay, ani-schedule integration. (Next-episode
countdown, intro skip, and episode titles — via keyless Jikan —
shipped since this list was written.) Add when someone asks.
Risks
- Cloudflare policy change → upstream breaks. Mitigation: swap
impersonate="chrome"for"safari"/binary curl-impersonate; all upstream access is isolated inupstream.py+providers/, and the ordered multi-source fallback turns one dead site into a 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, no redistribution, bind to LAN only by default.
Milestones (all done)
- Spike — curl_cffi passes Cloudflare; all 7 steps verified.
server/upstream.py— the 6 fetch functions, assert self-check.- API — FastAPI routes over upstream.py.
- HLS proxy —
/stream/{token}with playlist rewriting. - UI — search → detail → episodes → player.
- Polish — history with position + resume, sub/dub toggle, error
states, autoplay default,
run.sh. - Auth — login screen, then multi-user CLI-created accounts with per-user history + test suite.
- Later (see git log,
docs/CHANGES-2026-09.md): provider layer with hianime primary, SQLite storage, stats/events, 2026-09 security audit batch.