Files
ani-web/SPEC.md
jozamudi 5f7aa010f1 status: consolidate the pill to one dot + one word; upgrade the panel
- pill: two host dots -> ONE consolidated state (down > slow > up) —
  the at-a-glance question is 'can I watch?', which is one state;
  per-host detail belongs in the tap panel
- panel: dashed slow-threshold line in the CDN sparkline (drawn at the
  server's real threshold, not a client copy) + origin up/down step
  sparkline so 'how long has the origin been down' is visible
- server: origin probe keeps a 20-sample hist like the CDN probe;
  DEGRADED threshold is now ANIWEB_CDN_KBPS (default 40) and is
  returned in /api/cdn so the UI draws the line at the live value
- tests: pill assertions switched to consolidated states; fixtures
  carry origin hist + threshold; assert both sparklines + threshold line
- docs: README + SPEC synced; preview on :8322 restarted (pid 44382)
2026-09-21 00:10:10 +00:00

9.6 KiB
Raw Permalink Blame History

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; 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 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. DEGRADED = below ANIWEB_CDN_KBPS (default 40). The header pill is the single UI surface: one consolidated state (down > slow > up, one dot), tap opens the panel (per-host state + KB/s, CDN sparkline with the dashed threshold line, origin up/down sparkline of origin.hist); the playback failure overlay appends CDN context when degraded/down.
  • 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 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; SQLite aniweb.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 — including POST /login — require the 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 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 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/history every ~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 in upstream.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)

  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.
  8. Later (see git log): provider layer with hianime primary, SQLite storage, stats/events, 2026-09 security audit batch.