move server code into server/, update docs

- app.py + upstream.py -> server/ (relative import, __init__.py)
- static/ resolved relative to repo root, works from any CWD
- watch history migrated to server/data/history.json
- README.md: quick start, features, layout, dev notes
- SPEC.md: marked implemented, updated endpoints/run/deviations
- ignore *.tar.gz (local curl-impersonate download at repo root)
This commit is contained in:
jozamudi committed 2026-08-19 15:33:31 +00:00
1 parent a27ff5c931
commit 2c203990bc
7 files changed
+101 -37

No files matched your search

+2 -1
View File
@@ -1,5 +1,6 @@
.venv/
__pycache__/
*.pyc
data/
*.tar.gz
server/data/
server.log
+49
View File
@@ -0,0 +1,49 @@
# ani-web
Web front-end for the [ani-cli](https://github.com/pystardust/ani-cli)
workflow: search, browse, and stream anime in the browser via anidb.app.
## Quick start
```sh
uv venv .venv
uv pip install --python .venv/bin/python -r requirements.txt
./run.sh
```
Open <http://localhost:8321>. `run.sh` restarts cleanly if the server
is already running; logs go to `server.log`.
## Features
- Search with poster grid, detail view with synopsis + season tabs
- HLS playback (vendored hls.js), browser ABR across 1080/720/360p
- Dub/sub toggle
- Autoplays the first episode (or resumes the last watched one); if the
browser blocks autoplay, a "click to play" overlay appears
- Server-persisted watch history (`server/data/history.json`): watch
position saved every ~10s, resume where you left off, top-5 "recently
played" on the home page — shared across browsers and devices
## Layout
```
server/ FastAPI app (app.py) + anidb.app fetchers (upstream.py)
upstream.py is the ONLY Cloudflare-facing code
static/ front-end: index.html, app.js, style.css, hls.min.js (vendored)
run.sh launch/restart the server
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 video CDN sends no CORS headers, so playback goes through the
backend HLS proxy (`/stream/{token}`), which rewrites playlist URIs.
- `python server/upstream.py` runs a self-check of the fetchers
(search → details → episodes → dub stream for Chainsaw Man).
- Episode titles are not available: anidb's episode API only returns
`{id, number, number2, filler}`. Adding titles needs a free
MyAnimeList API key.
+45 -32
View File
@@ -3,6 +3,12 @@
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 |
@@ -37,47 +43,54 @@ Two moving parts, no database, no framework on the frontend.
## Backend
Python 3.12, deps: `fastapi`, `uvicorn`, `curl_cffi`.
`server/app.py` + `server/upstream.py`. Python 3.12, deps:
`fastapi`, `uvicorn`, `curl_cffi`.
- `GET /api/search?q=` → `[{id, slug, name, poster}]` (parse step 1)
- `GET /api/anime/{slug_id}` → `{name, synopsis, mal_id, seasons:[{id,name}]}`
- `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}/languages` → `[{code, name, embed_url}]` (step 4)
- `GET /stream/{token}` → proxied HLS. Token = base64(nats) of the
upstream URL. Rewrites `#EXT-X-...` bodies: every relative/absolute
URL in the playlist is re-pointed at `/stream/...`. Serves master,
variants, and `.ts` segments through the same endpoint (bytes
proxied, no transcoding).
- Upstream cache: embed tokens expire — cache embed→m3u8 mapping for
~10 min; cache nothing else (search is cheap).
- `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` → watch history with
position, persisted to `server/data/history.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).
## Frontend
Single `index.html` + `app.js` + `style.css`. hls.js from CDN.
`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, season tabs) → episode list (number + filler badge,
sub/dub toggle) → `<video>` with hls.js pointed at
`/stream/{token}`; quality follows the master playlist (auto,
hls.js handles ABR).
(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).
- History: last 20 `{title, season, episode}` in `localStorage`
(mirrors ani-cli's `-c` continue feature).
- No login, no accounts, no build step.
- 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, a
"click to play" overlay appears.
- 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 fastapi uvicorn curl_cffi
.venv/bin/python -m uvicorn app:app --host 0.0.0.0 --port 8321
```sh
uv venv .venv && uv pip install --python .venv/bin/python -r requirements.txt
./run.sh # launch/restart, logs to server.log
```
Open `http://localhost:8321`.
Open `http://localhost:8321` (bound to 0.0.0.0 for LAN access).
## Out of scope (v1)
Downloads (`-d`), next-episode countdown (`-N`), syncplay, intro skip,
ani-schedule integration, auth, multi-user. Add when someone asks.
ani-schedule integration, auth, multi-user, episode titles (not in
anidb's API — needs a MyAnimeList API key). Add when someone asks.
## Risks
@@ -89,12 +102,12 @@ ani-schedule integration, auth, multi-user. Add when someone asks.
- **ToS** — anidb.app is a third-party scraper site; personal use,
no redistribution, bind to LAN only by default.
## Milestones
## Milestones (all done)
1. **Spike (done)** — curl_cffi passes Cloudflare; all 7 steps verified.
2. **upstream.py** — the 6 fetch functions above, one assert-based
self-check (`python upstream.py` prints a chainsaw-man episode m3u8).
3. **API** — FastAPI routes over upstream.py.
4. **HLS proxy** — `/stream/{token}` with playlist rewriting.
5. **UI** — search → detail → episodes → player.
6. Polish — history, sub/dub toggle, error states.
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`.
+2 -2
View File
@@ -2,6 +2,6 @@
# launch (or restart) the ani-web server
cd "$(dirname "$0")" || exit 1
[ -x .venv/bin/python ] || { echo "no .venv — run: uv venv .venv && uv pip install --python .venv/bin/python -r requirements.txt"; exit 1; }
pkill -f "[u]vicorn app:app" 2>/dev/null; sleep 1
nohup .venv/bin/python -m uvicorn app:app --host 0.0.0.0 --port 8321 > server.log 2>&1 &
pkill -f "uvicorn .*app:app" 2>/dev/null; sleep 1
nohup .venv/bin/python -m uvicorn server.app:app --host 0.0.0.0 --port 8321 > server.log 2>&1 &
echo "ani-web on http://localhost:8321 (pid $!)"
View File
Whitespace-only changes.
+3 -2
View File
@@ -14,8 +14,9 @@ from fastapi import Body, FastAPI, HTTPException, Query
from fastapi.responses import Response
from fastapi.staticfiles import StaticFiles
import upstream
from . import upstream
ROOT = Path(__file__).parent.parent
app = FastAPI(title="ani-web")
HIST_FILE = Path(__file__).parent / "data" / "history.json"
@@ -125,4 +126,4 @@ def stream(token: str):
return Response(body, media_type=r.headers.get("content-type", "application/octet-stream"))
app.mount("/", StaticFiles(directory="static", html=True), name="static")
app.mount("/", StaticFiles(directory=ROOT / "static", html=True), name="static")
View File
File renamed without changes.