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:
1 parent
a27ff5c931
commit
2c203990bc
7 files changed
+101
-37
No files matched your search
+2
-1
@@ -1,5 +1,6 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
data/
|
||||
*.tar.gz
|
||||
server/data/
|
||||
server.log
|
||||
@@ -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.
|
||||
@@ -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,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 $!)"
|
||||
Whitespace-only changes.
@@ -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")
|
||||
File renamed without changes.
Reference in new issue
Block a user