'⛶ full' in the controls: native requestFullscreen on the video (it fills the viewport and rescales on window resize for free), with the iOS webkitEnterFullscreen fallback where the Fullscreen API doesn't exist. Click again to exit. Live browser test: enter + exit via the button.
ani-web
Web front-end for anime streaming: search, browse, and stream anime in the browser (hianime.at primary; anidb.app and anikototv.to as fallbacks). 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 the active source 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 and season tabs (where the source provides them), plus 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)
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
server/ FastAPI app (app.py), source providers (providers/: hianime,
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)
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
- The upstream sites sit behind Cloudflare; all upstream requests use
curl_cffiChrome TLS impersonation. Sources are ordered byANI_SOURCE(defaulthianime,anidb,anikoto) — a dead one falls through to the next. All upstream-facing code is isolated inserver/upstream.py+server/providers/. - The video CDN sends no CORS headers, so playback goes through the
backend HLS proxy (
/stream/{token}), which rewrites playlist URIs. ANI_SOURCE=anidb .venv/bin/python -m server.upstreamruns a live self-check of the anidb fetchers (search → details → episodes → dub stream). Must be the-mform — plain script execution breaks the providers' relative imports.- A background probe fetches 64KB from the CDN (and checks the origin
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
?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).




