6.6 KiB
ani-web
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 HLS playback proxied through the backend. Tuned for phone as much as desktop.
Quick start
uv venv .venv
uv pip install --python .venv/bin/python -r requirements.txt
.venv/bin/python -m server.users add <you> # create your account (one-time)
./run.sh
Open it through your HTTPS proxy (or http://localhost:8321 from the
host itself — browsers treat localhost as trusted). run.sh restarts
cleanly if the server is already running; logs go to server.log.
With ANIWEB_SECURE=1 (set by run.sh), the session cookie is
Secure, so remote plain-HTTP access can't hold a session — only the
HTTPS proxy works. A legacy credentials.json password, if any,
is migrated to user admin on first start.
Account management (no self-signup by design):
.venv/bin/python -m server.users add <name> # add user (prompted password)
.venv/bin/python -m server.users rm <name> # remove user + their history
.venv/bin/python -m server.users list # list users
Security: passwords are PBKDF2-SHA256 (210k iters, per-user salts) in
server/data/aniweb.db (SQLite, 0600). Sessions are signed 30-day HttpOnly
SameSite=Lax cookies; run.sh sets ANIWEB_SECURE=1 so cookies are
Secure and only work through your HTTPS proxy. Accounts deleted via
the app or CLI are rejected immediately, even with an old cookie.
Features
Accounts & watch history
Accounts are created from the CLI only — there is no signup screen,
because the app is meant to sit behind your proxy on the open
internet. Each user gets a fully isolated watch history
(server/data/aniweb.db, SQLite): nothing is stored in the browser,
so a new device picks up your exact watch state after login. Deleting
an account (hamburger menu → delete, or server.users rm) kills its
session cookies immediately and removes its history.
The history page shows every entry with poster, progress, and a per-entry delete button:
Home: continue watching
The home screen leads with a continue watching hero — the most recently watched episode with its poster, position, and a Resume button — followed by a recently played grid with progress bars and relative timestamps.
Search
The header search box queries anidb.app and shows results as a poster grid. Your last 8 searches (per user) appear in a dropdown under the box, filter live as you type, and click to re-run. Results open the detail view: synopsis, stats, season tabs, and filler badges.
Player
Playback is HLS through a backend proxy (/stream/{token}) — the
video CDN sends no CORS headers, so the browser can't fetch it
directly. The CDN serves a single quality (no ABR); the proxy pulls
each segment across 16 parallel byte-range lanes (falling back to a
single stream if the CDN refuses ranges), so a slow connection doesn't
stall playback.
- Resume — autoplay on open, resumes at the saved position; the position is saved every ~10s
- Skip intro — MAL/Jikan intro timings (30-day cache) drive a "skip intro ⏭" pill while the intro window is on screen
- Episode names — MAL titles (via keyless Jikan) in the episode tooltips and the "now playing" line
- Buffering UI — spinner overlay, a buffer-ahead gauge under the
video, and a CDN chip (live KB/s from
/api/cdn, amber when the CDN is degraded) - Dub/sub toggle, playback speed (0.5–2×, remembered), and a hide fillers switch (fillers are badged and skipped by prev/next/auto-next)
- Auto-next — when an episode ends, a 5-second countdown offers the next episode; Skip jumps straight to it, ✕ cancels
- Watched state — completed episodes are dimmed with a ✓, the next unwatched one is highlighted
- Failure overlay with a Retry button if a stream can't be played, plus loading/buffering indicators
- Fast repeat navigation — upstream data is TTL-cached and HLS playlists/segments are LRU-cached, so re-opening a show or re-seeking is near-instant (the first load still pays the upstream round-trips)
Layout
server/ FastAPI app (app.py), anidb.app fetchers (upstream.py),
accounts (users.py). upstream.py is the ONLY Cloudflare-facing code
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
docs/img/ README screenshots
run.sh launch/restart the server (sets ANIWEB_SECURE=1)
SPEC.md design + the verified anidb.app data flow
Dev notes
- anidb.app is behind Cloudflare; all upstream requests use
curl_cffiChrome TLS impersonation. If that ever stops working, everything is isolated inserver/upstream.py. - The video CDN sends no CORS headers, so playback goes through the
backend HLS proxy (
/stream/{token}), which rewrites playlist URIs. .venv/bin/python server/upstream.pyruns a self-check of the fetchers (search → details → episodes → dub stream for Chainsaw Man).- A background probe fetches 64KB from the CDN every 3 min and logs
state flips to
server.log;GET /api/cdn(public) returns the latest throughput + ~1h history. 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
?v=cache-bust onapp.js/style.cssat startup (index.html itself isno-store), so a restart is a cache bump — nothing to hand-bump. - Mutations need the
X-Aniweb: 1header (CSRF: cross-site form POSTs can't set it),/loginPOST included — forced-login is dead. - Login throttling (server/auth.py): per-username 5 fails -> 15 min lock, plus a per-source-IP budget; both sliding windows, in-memory.
- Playback failures self-report: the client posts a step timeline +
capability fingerprint to
/api/events(see theeventstable), so a broken browser is diagnosable from the server. .venv/bin/pytest tests/ -qruns the test suite against a throwaway server (:8399, temp data dir — never touches the real data). The default suite (API + auth + dispatch tests) is hermetic and deterministic; the Playwright browser tests scrape and play LIVE upstream content, so they are opt-in:ANIWEB_LIVE_TESTS=1 .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.




