Files
ani-web/SPEC.md
T

6.0 KiB
Raw Blame History

ani-web — Web Front-End for ani-cli

Browse, search, and stream anime from the browser, using the same anidb.app data source that ani-cli scrapes.

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), and 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)

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) variants: 1080p / 720p / 360p
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)
   │  curl_cffi (Chrome impersonation)
   ▼
anidb.app + hls.anidb.app

Two moving parts, no database, no framework on the frontend.

Backend

server/app.py + server/upstream.py. Python 3.12, deps: fastapi, uvicorn, curl_cffi, itsdangerous.

  • 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/episode/{ep_id}/stream?lang=eng|jpn → {token, qualities} (steps 5+6; token is base64url of the master m3u8 URL)
  • GET /api/history / POST /api/history → per-user watch history with position, persisted to server/data/history/<user>.json (50 kept, top 5 returned; upsert per anime+episode).
  • GET /stream/{token} → proxied HLS. Token = base64url of the upstream URL. Rewrites every non-comment line of m3u8 bodies to point back at /stream/; proxies master, variants, and segments (bytes, no transcoding).
  • GET /login / POST /login / POST /logout → multi-user auth: PBKDF2-SHA256 passwords (server/users.py; users.json 0600), signed 30-day cookie carrying the username, 5-fail/15-min in-memory lockout per user. Accounts are CLI-created only (python -m server.users add|rm|list) — no self-signup. One-time migration: legacy single-password credentials.json → user admin; history.json → history/admin.json.
  • 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 under the episode grid, season tabs) → episode grid (number

  • filler badge, sub/dub toggle) → <video> with hls.js pointed at /stream/{token}; quality follows the master playlist (auto ABR).
  • 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). python tests/run.py runs the full suite against a throwaway server on :8399.

Out of scope (v1)

Downloads (-d), next-episode countdown (-N), syncplay, intro skip, ani-schedule integration, episode titles (not in anidb's API — needs a MyAnimeList API key). 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 one module (upstream.py).
  • anidb.app layout change → parsers break. The 4 API endpoints (steps 3–6) are clean JSON; only steps 1, 2, 5 are HTML/JS scraping.
  • 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.