diff --git a/README.md b/README.md index 929f8ca..99dad95 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # 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. +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. ![login](docs/img/login.png) @@ -66,10 +66,11 @@ 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. +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. ![search](docs/img/search.png) @@ -106,11 +107,20 @@ stall playback. ![detail](docs/img/detail.png) +### 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), anidb.app fetchers (upstream.py), - accounts (users.py). upstream.py is the ONLY Cloudflare-facing code +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 @@ -120,17 +130,21 @@ SPEC.md design + the verified anidb.app data flow ## Dev notes -- anidb.app is behind Cloudflare; all upstream requests use - `curl_cffi` Chrome TLS impersonation. If that ever stops working, - everything is isolated in `server/upstream.py`. +- The upstream sites sit behind Cloudflare; all upstream requests use + `curl_cffi` Chrome TLS impersonation. Sources are ordered by + `ANI_SOURCE` (default `hianime,anidb,anikoto`) — a dead one falls + through to the next. All upstream-facing code is isolated in + `server/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. -- `.venv/bin/python server/upstream.py` runs 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. +- `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. +- 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 on `app.js`/`style.css` at startup (index.html itself is `no-store`), so a restart is a cache bump — nothing to hand-bump. @@ -147,6 +161,3 @@ SPEC.md design + the verified anidb.app data flow 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. diff --git a/SPEC.md b/SPEC.md index df24037..4aa3261 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,16 +1,20 @@ # 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. +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), and the front-end lives in `static/` with the server code in -`server/`. See README.md. +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` | @@ -33,41 +37,70 @@ or segments directly — **all HLS traffic must go through a backend proxy**. Browser (vanilla JS + hls.js) │ /api/* JSON /stream/* proxied HLS ▼ -Python backend (FastAPI, uvicorn) +Python backend (FastAPI, uvicorn, SQLite) │ curl_cffi (Chrome impersonation) ▼ -anidb.app + hls.anidb.app +hianime.at / anidb.app / anikototv.to + their CDNs ``` -Two moving parts, no database, no framework on the frontend. +Two moving parts, SQLite (users/history/searches/events), no framework +on the frontend. ## Backend -`server/app.py` + `server/upstream.py`. Python 3.12, deps: -`fastapi`, `uvicorn`, `curl_cffi`, `itsdangerous`. +`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/episode/{ep_id}/stream?lang=eng|jpn` → `{token}` - (steps 5+6; token is base64url of the master m3u8 URL) -- `GET /api/history` / `POST /api/history` → **per-user** watch +- `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). + `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. - `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). 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. + 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**. 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`. + 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 @@ -82,11 +115,15 @@ Two moving parts, no database, no framework on the frontend. produced black screens. Flow: search box → results grid (poster + title, click) → detail view -(synopsis under the episode grid, season tabs) → episode grid (number +(synopsis and season tabs where the source provides them, under the +episode grid) → episode grid (number + filler badge, sub/dub toggle) → `