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/
|
.venv/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
data/
|
*.tar.gz
|
||||||
|
server/data/
|
||||||
server.log
|
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
|
Browse, search, and stream anime from the browser, using the same
|
||||||
anidb.app data source that ani-cli scrapes.
|
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)
|
## Verified data flow (spiked 2026-07, all 200 OK with Chrome TLS impersonation)
|
||||||
|
|
||||||
| Step | Request | Yields |
|
| Step | Request | Yields |
|
||||||
@@ -37,47 +43,54 @@ Two moving parts, no database, no framework on the frontend.
|
|||||||
|
|
||||||
## Backend
|
## 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/search?q=` → `[{id, slug_id, name, poster}]` (parse step 1)
|
||||||
- `GET /api/anime/{slug_id}` → `{name, synopsis, mal_id, seasons:[{id,name}]}`
|
- `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/anime/{id}/episodes` → `[{id, number, filler}]` (step 3, passthrough)
|
||||||
- `GET /api/episode/{ep_id}/languages` → `[{code, name, embed_url}]` (step 4)
|
- `GET /api/episode/{ep_id}/stream?lang=eng|jpn` → `{token, qualities}`
|
||||||
- `GET /stream/{token}` → proxied HLS. Token = base64(nats) of the
|
(steps 5+6; token is base64url of the master m3u8 URL)
|
||||||
upstream URL. Rewrites `#EXT-X-...` bodies: every relative/absolute
|
- `GET /api/history` / `POST /api/history` → watch history with
|
||||||
URL in the playlist is re-pointed at `/stream/...`. Serves master,
|
position, persisted to `server/data/history.json` (50 kept, top 5
|
||||||
variants, and `.ts` segments through the same endpoint (bytes
|
returned; upsert per anime+episode)
|
||||||
proxied, no transcoding).
|
- `GET /stream/{token}` → proxied HLS. Token = base64url of the
|
||||||
- Upstream cache: embed tokens expire — cache embed→m3u8 mapping for
|
upstream URL. Rewrites every non-comment line of m3u8 bodies to
|
||||||
~10 min; cache nothing else (search is cheap).
|
point back at `/stream/`; proxies master, variants, and segments
|
||||||
|
(bytes, no transcoding).
|
||||||
|
|
||||||
## Frontend
|
## 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
|
Flow: search box → results grid (poster + title, click) → detail view
|
||||||
(synopsis, season tabs) → episode list (number + filler badge,
|
(synopsis under the episode grid, season tabs) → episode grid (number
|
||||||
sub/dub toggle) → `<video>` with hls.js pointed at
|
+ filler badge, sub/dub toggle) → `<video>` with hls.js pointed at
|
||||||
`/stream/{token}`; quality follows the master playlist (auto,
|
`/stream/{token}`; quality follows the master playlist (auto ABR).
|
||||||
hls.js handles ABR).
|
|
||||||
|
|
||||||
- History: last 20 `{title, season, episode}` in `localStorage`
|
- Default play: opening an anime resumes the last watched episode of
|
||||||
(mirrors ani-cli's `-c` continue feature).
|
the season (seeking to the saved position unless ≥95% through),
|
||||||
- No login, no accounts, no build step.
|
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
|
## Run
|
||||||
|
|
||||||
```
|
```sh
|
||||||
uv venv .venv && uv pip install --python .venv/bin/python fastapi uvicorn curl_cffi
|
uv venv .venv && uv pip install --python .venv/bin/python -r requirements.txt
|
||||||
.venv/bin/python -m uvicorn app:app --host 0.0.0.0 --port 8321
|
./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)
|
## Out of scope (v1)
|
||||||
|
|
||||||
Downloads (`-d`), next-episode countdown (`-N`), syncplay, intro skip,
|
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
|
## 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,
|
- **ToS** — anidb.app is a third-party scraper site; personal use,
|
||||||
no redistribution, bind to LAN only by default.
|
no redistribution, bind to LAN only by default.
|
||||||
|
|
||||||
## Milestones
|
## Milestones (all done)
|
||||||
|
|
||||||
1. **Spike (done)** — curl_cffi passes Cloudflare; all 7 steps verified.
|
1. Spike — curl_cffi passes Cloudflare; all 7 steps verified.
|
||||||
2. **upstream.py** — the 6 fetch functions above, one assert-based
|
2. `server/upstream.py` — the 6 fetch functions, assert self-check.
|
||||||
self-check (`python upstream.py` prints a chainsaw-man episode m3u8).
|
3. API — FastAPI routes over upstream.py.
|
||||||
3. **API** — FastAPI routes over upstream.py.
|
4. HLS proxy — `/stream/{token}` with playlist rewriting.
|
||||||
4. **HLS proxy** — `/stream/{token}` with playlist rewriting.
|
5. UI — search → detail → episodes → player.
|
||||||
5. **UI** — search → detail → episodes → player.
|
6. Polish — history with position + resume, sub/dub toggle, error
|
||||||
6. Polish — history, sub/dub toggle, error states.
|
states, autoplay default, `run.sh`.
|
||||||
@@ -2,6 +2,6 @@
|
|||||||
# launch (or restart) the ani-web server
|
# launch (or restart) the ani-web server
|
||||||
cd "$(dirname "$0")" || exit 1
|
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; }
|
[ -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
|
pkill -f "uvicorn .*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 &
|
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 $!)"
|
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.responses import Response
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
import upstream
|
from . import upstream
|
||||||
|
|
||||||
|
ROOT = Path(__file__).parent.parent
|
||||||
app = FastAPI(title="ani-web")
|
app = FastAPI(title="ani-web")
|
||||||
|
|
||||||
HIST_FILE = Path(__file__).parent / "data" / "history.json"
|
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"))
|
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