Compare commits
22
Commits
b70ef403a9
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d12ff79d29 | ||
|
|
e3ef51d141 | ||
|
|
1154203569 | ||
|
|
0cd79dd701 | ||
|
|
731f44b99c | ||
|
|
5f7aa010f1 | ||
|
|
778bf2bfc1 | ||
|
|
63a677f028 | ||
|
|
de0bf8b156 | ||
|
|
66a0bb16e6 | ||
|
|
2fb3743b3b | ||
|
|
816e945018 | ||
|
|
cea04a20f6 | ||
|
|
379f591180 | ||
|
|
0f6bd0a52a | ||
|
|
4aca78cab8 | ||
|
|
cbfb2f96c8 | ||
|
|
d02cb017d3 | ||
|
|
882263d276 | ||
|
|
3e5c7062ab | ||
|
|
c5c4dff851 | ||
|
|
7f531fc309 |
No files matched your search
@@ -7,3 +7,4 @@ server.log
|
||||
server.pid
|
||||
pi-session-*.html
|
||||
.pytest_cache/
|
||||
server-*.log
|
||||
@@ -1,160 +0,0 @@
|
||||
# Plan — UI/UX polish bundle (items 1–4 + 7)
|
||||
|
||||
**STATUS: complete** — every item below shipped (see git log, c2461f8 and
|
||||
friends). Kept as the planning record; the "deferred" list at the bottom
|
||||
is stale (favicon, a11y pass, autoplay-next were all done afterwards).
|
||||
|
||||
The agreed bundle from the polish list:
|
||||
|
||||
1. **Loading states** — skeleton cards + player spinner
|
||||
2. **Player failure + buffering overlay**
|
||||
3. **Toasts / confirmations**
|
||||
4. **Back/breadcrumb nav + logo fix**
|
||||
7. **Full history page + delete entries**
|
||||
|
||||
Out of scope (deferred): favicon, search-box clear/count, synopsis clamp, a11y pass,
|
||||
resume toast, sub fallback, autoplay-next, light theme.
|
||||
|
||||
## Current state (grounding)
|
||||
|
||||
- SPA with three views: `?q=` search, `?anime=&season=&ep=&lang=` detail+player
|
||||
(player is inline on the detail page), no-param = home.
|
||||
- No loading feedback anywhere: `main` is blank during fetches; the player is a
|
||||
black box until the first frame.
|
||||
- Stream errors render as a `<p class="error">` **below** the video — easy to
|
||||
miss, no retry.
|
||||
- No in-app way back to home (browser back only). **The logo links to
|
||||
hardcoded `/` — breaks behind the subpath reverse proxy** (everything else
|
||||
uses `BASE`).
|
||||
- History: `GET /api/history` returns only the top 5; no delete endpoint;
|
||||
no UI to remove an entry (only nuke the whole account).
|
||||
|
||||
## Item 1 — loading states
|
||||
|
||||
- `app.js`:
|
||||
- `skel(n)` helper → shimmering `.card.skel` grid in `main`.
|
||||
- `showSearch`: render `skel(8)` before `await api()`.
|
||||
- `showAnime`: `skel()` before the detail fetch; "loading episodes…"
|
||||
placeholder in `#eps` until the episode list resolves.
|
||||
- Player: `.pl-overlay` (shared with item 2) shows "Loading…" right after
|
||||
the video element is attached; hidden on `playing`, "Buffering…" on
|
||||
`waiting` (guarded so it never clobbers the failure overlay).
|
||||
- `style.css`: `@keyframes shimmer`, `.skel`, `.pl-overlay`
|
||||
(absolute inset-0 over the video — `.player` is already `position:relative`).
|
||||
|
||||
**Accept:** no blank `main` during a search; skeleton visible; player shows
|
||||
Loading → (Buffering) → hidden once the first frame plays.
|
||||
|
||||
## Item 2 — player failure + retry
|
||||
|
||||
- `app.js`:
|
||||
- `playerFail(msg)` → overlay on the video: message + **Retry** button.
|
||||
- hls.js `fatal` error → `stopHls()` + `playerFail(details + HTTP code)`.
|
||||
- `video.onerror` → `playerFail("media error")`.
|
||||
- Retry re-runs `play(ep_id, ep_no)` — fresh stream fetch; the server-side
|
||||
cold-cache retry already recovers rotated playlist URLs.
|
||||
- `showStreamError` stays only for pre-play fetch errors (e.g. 404 "no
|
||||
eng stream").
|
||||
- `showPlayOverlay` (autoplay-blocked prompt) hides the loading overlay first.
|
||||
|
||||
**Accept:** headless: abort `**/stream/**` → detail page auto-plays → overlay
|
||||
with Retry appears; unroute → click Retry → `video.readyState >= 4`.
|
||||
|
||||
## Item 3 — toasts / confirmations
|
||||
|
||||
- `index.html`: `<div id="toasts"></div>`.
|
||||
- `app.js`: `toast(msg)` → `.toast`, auto-removed after 2.6 s.
|
||||
- Used for history-entry removal (stays on the page).
|
||||
- Logout / delete account navigate away, so they use one-shot banners
|
||||
instead: `login?signedout=1` / `login?deleted=1`; `login.html` shows a
|
||||
fixed `.banner` ("Signed out." / "Account deleted.") when present.
|
||||
- `style.css`: `.toast` (fixed bottom-center, fade-in), login `.banner`.
|
||||
|
||||
**Accept:** entry removal → toast appears then disappears; logout → banner on
|
||||
the login page; account delete → banner.
|
||||
|
||||
## Item 4 — back nav + logo fix
|
||||
|
||||
- `index.html`: drop the inline `onclick` from the logo (keep `href` fallback).
|
||||
- `app.js` boot: `logo.href = BASE` (BASE ends in `/` → home, clears query) —
|
||||
same proxy-safe mechanism as all other navigation.
|
||||
- Detail view: `← home` crumb link in `.detail-head`, before the title.
|
||||
- `Esc` closes the hamburger menu.
|
||||
|
||||
**Accept:** from `?anime=…`, logo click → home hero visible; crumb works.
|
||||
|
||||
## Item 7 — full history page + delete entries
|
||||
|
||||
- `server/app.py`:
|
||||
- `GET /api/history?all=1` → full list (no top-5 cap), same no-store header.
|
||||
- `DELETE /api/history?slug_id=…&ep_id=…` → removes all entries with
|
||||
`str(slug_id) ==` and `str(ep_id) ==` (loose match on purpose: legacy rows
|
||||
store the same ep as int *and* string — one click clears both ghost rows);
|
||||
returns `{"removed": n}`.
|
||||
- `app.js`:
|
||||
- Boot: `?history` → `showHistory()`: skeleton → grid of cards (poster,
|
||||
title, `E# · dub/sub · time-ago`, progress bar when `dur` known) each with
|
||||
a `×` delete button (top-right; stopPropagation). Cards are clickable
|
||||
divs (valid HTML — a button inside `<a>` is not).
|
||||
- Delete → `DELETE` fetch → `toast("Removed from history")` → re-render.
|
||||
- Home: when the top-5 cap is reached, "Recently played" gets a
|
||||
`view all →` link to `?history`.
|
||||
- `style.css`: `.del` (absolute top-right circle, danger on hover),
|
||||
`.sec-head` (flex row for section title + link), `.card.hist`.
|
||||
|
||||
**Accept:** API: `?all=1` returns everything, DELETE removes only the matching
|
||||
entry and is idempotent. Browser: `?history` lists entries; `×` removes one
|
||||
card and shows a toast.
|
||||
|
||||
## New tests
|
||||
|
||||
- `tests/test_api.py::test_history_all_and_delete` (alice): POST 2 entries
|
||||
(one int `ep_id`, one string) → `?all=1` sees both → plain GET still caps at
|
||||
5 → DELETE the int row via string query param (loose match) → `removed ≥ 1`,
|
||||
row gone, other row untouched → second DELETE → `removed == 0`.
|
||||
- `tests/test_browser.py::test_history_page_and_delete` (new seeded user
|
||||
**erin**, self-contained entries via urllib): `?history` → 2 `.card.hist`
|
||||
cards → click `.del` (force) → 1 card + `.toast` visible.
|
||||
- `tests/test_browser.py::test_stream_failure_retry` (admin): `page.route`
|
||||
aborts `**/stream/**` → search + open detail (auto-play fails) → wait for
|
||||
`.pl-overlay button` (Retry) → `page.unroute` → click Retry → video plays
|
||||
(`readyState >= 4`, 120 s timeout for cold upstream).
|
||||
- `tests/run.py`: seed erin; register both new browser tests.
|
||||
|
||||
All waits use explicit `polling=` (headless rAF stall) and `force=True`
|
||||
clicks, per the established Chromium 151 quirk.
|
||||
|
||||
## Verification
|
||||
|
||||
- `.venv/bin/python tests/run.py` → all green (11 existing + 3 new).
|
||||
- Ad-hoc Playwright screenshots: home, history page, failure overlay, mobile
|
||||
390 px — eyeballed.
|
||||
- `git diff` reviewed before committing (no stray rewrites).
|
||||
|
||||
## Execution order
|
||||
|
||||
1. Backend: `app.py` history endpoints + API test
|
||||
2. `app.js`: items 1, 2, 3, 4, 7
|
||||
3. `index.html` + `login.html`
|
||||
4. `style.css`
|
||||
5. Browser tests + `run.py`
|
||||
6. Full suite + screenshots
|
||||
7. Restart production, commit (includes this plan)
|
||||
|
||||
## Outcome
|
||||
|
||||
Shipped. Full suite green (10 API + 4 browser tests).
|
||||
|
||||
Bugs found while implementing/testing (fixed):
|
||||
|
||||
- `?history` is a valueless param — `p.get("history")` is `""` (falsy);
|
||||
boot routing had to use `p.has("history")`.
|
||||
- Delete button click bubbled to the card's `onclick` → navigated to
|
||||
`?anime=<slug>`; fixed with `stopPropagation()`.
|
||||
- After a fatal stream error, `v.play()` rejects (no media) and
|
||||
`showPlayOverlay` clobbered the failure overlay with "click to play";
|
||||
guarded by `playerBroken`.
|
||||
- Test route pattern `**/stream/**` also matched the token endpoint
|
||||
`/api/episode/{id}/stream` — narrowed to `**/stream/*`.
|
||||
- Skeleton `.card.skel` matched the tests' `.card` selector — tests now
|
||||
use `a.card`.
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
@@ -19,12 +19,8 @@ uv pip install --python .venv/bin/python -r requirements.txt
|
||||
Open it through your HTTPS proxy (or <http://localhost:8321> from the
|
||||
host itself — browsers treat localhost as trusted). `run.sh` restarts
|
||||
cleanly if the server is already running; logs go to `server.log`.
|
||||
With `ANIWEB_SECURE=1` (set by `run.sh`), the session cookie is
|
||||
`Secure`, so remote plain-HTTP access can't hold a session — only the
|
||||
HTTPS proxy works. A legacy `credentials.json` password, if any,
|
||||
is migrated to user `admin` on first start.
|
||||
|
||||
Account management (no self-signup by design):
|
||||
Accounts are CLI-created only (no self-signup):
|
||||
|
||||
```sh
|
||||
.venv/bin/python -m server.users add <name> # add user (prompted password)
|
||||
@@ -32,116 +28,69 @@ Account management (no self-signup by design):
|
||||
.venv/bin/python -m server.users list # list users
|
||||
```
|
||||
|
||||
Security: passwords are PBKDF2-SHA256 (210k iters, per-user salts) in
|
||||
`server/data/aniweb.db` (SQLite, 0600). Sessions are signed 30-day `HttpOnly`
|
||||
`SameSite=Lax` cookies; `run.sh` sets `ANIWEB_SECURE=1` so cookies are
|
||||
`Secure` and only work through your HTTPS proxy. Accounts deleted via
|
||||
the app or CLI are rejected immediately, even with an old cookie.
|
||||
Passwords are PBKDF2-SHA256 in `server/data/aniweb.db` (SQLite, 0600);
|
||||
sessions are signed 30-day `HttpOnly` `SameSite=Lax` cookies, `Secure`
|
||||
via `run.sh`, so remote plain-HTTP can't hold a session — the HTTPS
|
||||
proxy is the way in. See [SPEC.md](SPEC.md) for the rest.
|
||||
|
||||
## Features
|
||||
|
||||
### Accounts & watch history
|
||||
- **History** — server-side and per-user: a new device picks up your
|
||||
exact watch state after login. Entries show poster, progress, and a
|
||||
per-entry delete.
|
||||
|
||||
Accounts are created from the CLI only — there is no signup screen,
|
||||
because the app is meant to sit behind your proxy on the open
|
||||
internet. Each user gets a fully isolated watch history
|
||||
(`server/data/aniweb.db`, SQLite): nothing is stored in the browser,
|
||||
so a new device picks up your exact watch state after login. Deleting
|
||||
an account (hamburger menu → delete, or `server.users rm`) kills its
|
||||
session cookies immediately and removes its history.
|
||||

|
||||
|
||||
The history page shows every entry with poster, progress, and a
|
||||
per-entry delete button:
|
||||
- **Home** — continue-watching hero (poster, position, Resume) plus a
|
||||
recently-played grid with progress bars.
|
||||
|
||||

|
||||

|
||||
|
||||
### Home: continue watching
|
||||
- **Search** — poster-grid results from the active source; your last 8
|
||||
searches (per user) in a live-filtering dropdown. Results open the
|
||||
detail view (synopsis, season tabs, filler badges).
|
||||
|
||||
The home screen leads with a **continue watching** hero — the most
|
||||
recently watched episode with its poster, position, and a Resume
|
||||
button — followed by a **recently played** grid with progress bars and
|
||||
relative timestamps.
|
||||

|
||||
|
||||

|
||||
- **Player** — resume at the saved position, skip intro (MAL timings),
|
||||
dub/sub toggle, playback speed, hide fillers, auto-next with a
|
||||
5-second countdown, ✓ on completed episodes. Playback goes through a
|
||||
backend HLS proxy (the CDN sends no CORS headers); segments are
|
||||
pulled across parallel byte-range lanes so a slow connection doesn't
|
||||
stall.
|
||||
|
||||
### 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.
|
||||
- **Status** — the header pill shows one consolidated state — up /
|
||||
slow (KB/s) / down — as a single dot; tap opens a panel with the
|
||||
per-host breakdown, the ~1h CDN trend (dashed slow-threshold line),
|
||||
and origin up/down history. A failure overlay names the CDN when
|
||||
it's the likely culprit.
|
||||
|
||||

|
||||
|
||||
### Player
|
||||
|
||||
Playback is HLS through a backend proxy (`/stream/{token}`) — the
|
||||
video CDN sends no CORS headers, so the browser can't fetch it
|
||||
directly. The CDN serves a single quality (no ABR); the proxy pulls
|
||||
each segment across 16 parallel byte-range lanes (falling back to a
|
||||
single stream if the CDN refuses ranges), so a slow connection doesn't
|
||||
stall playback.
|
||||
|
||||
- **Resume** — autoplay on open, resumes at the saved position; the
|
||||
position is saved every ~10s
|
||||
- **Skip intro** — MAL/Jikan intro timings (30-day cache) drive a
|
||||
"skip intro ⏭" pill while the intro window is on screen
|
||||
- **Episode names** — MAL titles (via keyless Jikan) in the episode
|
||||
tooltips and the "now playing" line
|
||||
- **Buffering UI** — spinner overlay, a buffer-ahead gauge under the
|
||||
video, and a CDN chip (live KB/s from `/api/cdn`, amber when the CDN
|
||||
is degraded)
|
||||
- **Dub/sub toggle**, **playback speed** (0.5–2×, remembered), and a
|
||||
**hide fillers** switch (fillers are badged and skipped by
|
||||
prev/next/auto-next)
|
||||
- **Auto-next** — when an episode ends, a 5-second countdown offers the
|
||||
next episode; Skip jumps straight to it, ✕ cancels
|
||||
- **Watched state** — completed episodes are dimmed with a ✓, the next
|
||||
unwatched one is highlighted
|
||||
- **Failure overlay** with a Retry button if a stream can't be played,
|
||||
plus loading/buffering indicators
|
||||
- **Fast repeat navigation** — upstream data is TTL-cached and HLS
|
||||
playlists/segments are LRU-cached, so re-opening a show or re-seeking
|
||||
is near-instant (the first load still pays the upstream round-trips)
|
||||
|
||||

|
||||
- **Stats** — `?stats`: watch minutes, episodes, streaks, sub/dub
|
||||
split, weekday/per-day heatmaps, top-10 shows, top searches
|
||||
(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
|
||||
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
|
||||
server/ FastAPI app (app.py), source providers (providers/: hianime,
|
||||
anidb, anikoto) over shared HTTP + dispatch (upstream.py)
|
||||
static/ front-end: index.html, login.html, app.js, hls.min.js (vendored)
|
||||
tests/ pytest suite: hermetic default, live browser e2e opt-in
|
||||
docs/img/ README screenshots
|
||||
run.sh launch/restart the server (sets ANIWEB_SECURE=1)
|
||||
SPEC.md design + the verified anidb.app data flow
|
||||
SPEC.md design, verified anidb.app data flow, security + runbook notes
|
||||
```
|
||||
|
||||
## Dev notes
|
||||
## Dev
|
||||
|
||||
- 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.
|
||||
- `.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.
|
||||
- The front-end is version-pinned: bump `?v=N` on `app.js`/`style.css`
|
||||
in `index.html` on every static change — phones/proxies otherwise
|
||||
serve stale JS (index.html itself is `no-store`).
|
||||
- Playback failures self-report: the client posts a step timeline +
|
||||
capability fingerprint to `/api/events` (see the `events` table),
|
||||
so a broken browser is diagnosable from the server.
|
||||
- `.venv/bin/pytest tests/ -q` runs the full test suite against a
|
||||
throwaway server (:8399, temp data dir — never touches the real
|
||||
data). The API tests use stdlib only; the browser tests need
|
||||
playwright (`uv pip install playwright && playwright install
|
||||
chromium`) and are skipped if absent.
|
||||
- Episode titles are not available: anidb's episode API only returns
|
||||
`{id, number, number2, filler}`. Adding titles needs a free
|
||||
MyAnimeList API key.
|
||||
- `.venv/bin/pytest tests/ -q` — hermetic suite against a throwaway
|
||||
server (never touches real data). Live-upstream browser e2e is
|
||||
opt-in: `ANIWEB_LIVE_TESTS=1 .venv/bin/pytest tests/ -q`
|
||||
(needs playwright chromium).
|
||||
- `ANI_SOURCE=anidb .venv/bin/python -m server.upstream` — live
|
||||
self-check of the anidb fetchers (must be the `-m` form; plain
|
||||
script execution breaks the providers' relative imports).
|
||||
- Everything else (Cloudflare/impersonation, HLS proxy, CDN probe,
|
||||
auth hardening, cache-busting): [SPEC.md](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,75 @@ 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.
|
||||
`DEGRADED` = below `ANIWEB_CDN_KBPS` (default 40). The header pill is
|
||||
the single UI surface: one consolidated state (down > slow > up, one
|
||||
dot), tap opens the panel (per-host state + KB/s, CDN sparkline with
|
||||
the dashed threshold line, origin up/down sparkline of `origin.hist`);
|
||||
the playback failure overlay appends CDN context when degraded/down.
|
||||
- `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 +120,19 @@ 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) → `<video>` with hls.js pointed at
|
||||
`/stream/{token}`; single quality — the proxy fetches each segment
|
||||
across parallel byte-range lanes (single-stream fallback).
|
||||
|
||||
Views: home (continue watching), search, detail+player, full history
|
||||
(`?history`), stats (`?stats`); PWA-installable.
|
||||
|
||||
Cache-busting: the server injects the git SHA as `?v=` on
|
||||
`app.js`/`style.css` at startup (index.html itself is `no-store`), so
|
||||
a restart is a cache bump — nothing to hand-bump.
|
||||
|
||||
- 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
|
||||
@@ -103,22 +149,32 @@ uv venv .venv && uv pip install --python .venv/bin/python -r requirements.txt
|
||||
```
|
||||
|
||||
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.
|
||||
remote HTTP can't hold a session). `.venv/bin/pytest tests/ -q` runs
|
||||
the hermetic suite against a throwaway server on :8399; the
|
||||
live-upstream browser e2e is opt-in (`ANIWEB_LIVE_TESTS=1`).
|
||||
`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).
|
||||
|
||||
## 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.
|
||||
Downloads (`-d`), syncplay, ani-schedule integration. (Next-episode
|
||||
countdown, intro skip, and episode titles — via keyless Jikan —
|
||||
shipped since this list was written.) 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.
|
||||
all upstream access is isolated in `upstream.py` + `providers/`,
|
||||
and the ordered multi-source fallback turns one dead site into a
|
||||
degraded result set, not an outage.
|
||||
- **Provider layout change** → parsers break. For anidb the 4 API
|
||||
endpoints (steps 3–6) are clean JSON; only steps 1, 2, 5 are
|
||||
HTML/JS scraping. hianime/anikoto are fully scraped (higher rotation
|
||||
risk), so they self-register CDN hosts and re-roll servers until one
|
||||
serves.
|
||||
- **ToS** — anidb.app is a third-party scraper site; personal use,
|
||||
no redistribution, bind to LAN only by default.
|
||||
|
||||
@@ -133,3 +189,5 @@ a MyAnimeList API key). Add when someone asks.
|
||||
states, autoplay default, `run.sh`.
|
||||
7. Auth — login screen, then multi-user CLI-created accounts with
|
||||
per-user history + test suite.
|
||||
8. Later (see git log): provider layer with hianime primary, SQLite
|
||||
storage, stats/events, 2026-09 security audit batch.
|
||||
@@ -1,625 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
version_number="5.0.0"
|
||||
|
||||
# UI
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
# $1 = prompt, $2 = multi select flag (for fzf/rofi), $3 = extra flags by user
|
||||
menu() {
|
||||
case "$menu_program" in
|
||||
fzf) fzf --reverse --cycle --prompt "$1" $2 $3 ;;
|
||||
rofi) rofi -sort -dmenu -i -p "$1" $2 $3 ;;
|
||||
dmenu) dmenu -l 20 -p "$1" $3 ;;
|
||||
*) "$menu_program" $3 "$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# provide menu. input format is either (ep_list) or (number \t anime_id \t anime_name)
|
||||
nth() {
|
||||
_stdin=$(cat -)
|
||||
[ -z "$_stdin" ] && return 1
|
||||
_line_count="$(printf "%s\n" "$_stdin" | wc -l | tr -d "[:space:]")"
|
||||
[ "$_line_count" -eq 1 ] && printf "%s" "$_stdin" | cut -f 2,3 && return 0
|
||||
_prompt="$1"
|
||||
[ $# -ne 1 ] && _multi_flag="$2"
|
||||
_line=$(printf "%s" "$_stdin" | cut -f 1,3 | tr '\t' ' ' | menu "$_prompt" "$_multi_flag" "$menu_extra_flags" | cut -d " " -f 1)
|
||||
_line_start=$(printf "%s" "$_line" | head -n 1)
|
||||
_line_end=$(printf "%s" "$_line" | tail -n 1)
|
||||
[ -n "$_line" ] || return 1
|
||||
if [ "$_line_start" = "$_line_end" ]; then
|
||||
printf "%s" "$_stdin" | grep -E '^'"${_line}"'($|[[:space:]])' | cut -f 2,3 || return 1
|
||||
else
|
||||
printf "%s" "$_stdin" | sed -n '/^'"${_line_start}"'$/,/^'"${_line_end}$"'/p' || return 1
|
||||
fi
|
||||
}
|
||||
|
||||
die() {
|
||||
printf "\33[2K\r\033[1;31m%s\033[0m\n" "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
info() {
|
||||
printf "\33[2K\r\033[1;34m%s\033[0m\n" "$*"
|
||||
}
|
||||
|
||||
help_info() {
|
||||
printf "
|
||||
Usage:
|
||||
%s [options] [query]
|
||||
%s [query] [options]
|
||||
%s [options] [query] [options]
|
||||
|
||||
Options:
|
||||
-c, --continue
|
||||
Continue watching from history
|
||||
-d, --download
|
||||
Download the video instead of playing it
|
||||
-D, --delete
|
||||
Delete history
|
||||
-l, --logview
|
||||
Show logs
|
||||
-s, --syncplay
|
||||
Use Syncplay to watch with friends
|
||||
-S, --select-nth
|
||||
Select nth entry
|
||||
-q, --quality
|
||||
Specify the video quality
|
||||
-v, --vlc
|
||||
Use VLC to play the video
|
||||
-V, --version
|
||||
Show the version of the script
|
||||
-h, --help
|
||||
Show this help message and exit
|
||||
-e, --episode, -r, --range
|
||||
Specify the number of episodes to watch
|
||||
--dub
|
||||
Play dubbed version
|
||||
--rofi
|
||||
Use rofi instead of fzf for the interactive menu
|
||||
--dmenu
|
||||
Use dmenu instead of fzf for the interactive menu
|
||||
--skip
|
||||
Use ani-skip to skip the intro of the episode (mpv only)
|
||||
--no-detach
|
||||
Don't detach the player (useful for in-terminal playback, mpv only)
|
||||
--exit-after-play
|
||||
Exit the player, and return the player exit code (useful for non interactive scenarios)
|
||||
-N, --nextep-countdown
|
||||
Display a countdown to the next episode
|
||||
-U, --update
|
||||
Update the script
|
||||
Some example usages:
|
||||
%s -q 720p banana fish
|
||||
%s --skip -S 2 one piece
|
||||
%s -d -e 2 cyberpunk edgerunners
|
||||
%s --vlc cyberpunk edgerunners -q 1080p -e 4
|
||||
%s blue lock -e 5-6
|
||||
%s -e \"5 6\" blue lock
|
||||
\n" "${0##*/}" "${0##*/}" "${0##*/}" "${0##*/}" "${0##*/}" "${0##*/}" "${0##*/}" "${0##*/}" "${0##*/}"
|
||||
exit 0
|
||||
}
|
||||
|
||||
version_info() {
|
||||
printf "%s\n" "$version_number"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Bookkeeping
|
||||
|
||||
update_script() {
|
||||
info "[branch:$branch] Checking for Update.."
|
||||
update="$($curl_exe --fail-with-body -sL -A "$agent" "https://raw.githubusercontent.com/pystardust/ani-cli/$branch/ani-cli")" || die "[branch:$branch] Connection error: $update"
|
||||
new_version="$(printf "%s" "$update" | sed -nE 's|^version_number="([^"]+)"$|\1|p')"
|
||||
[ -z "$new_version" ] && die "[branch:$branch] Invalid response."
|
||||
printf "%s" "$update" | grep -q "^version_number" || die "[branch:$branch] Invalid response."
|
||||
update="$(printf "%s\n" "$update" | diff -u "$0" -)"
|
||||
if [ -z "$update" ]; then
|
||||
info "[branch:$branch] Script is up to date :) ($version_number)"
|
||||
else
|
||||
if printf "%s\n" "$update" | patch "$0" -; then
|
||||
info "[branch:$branch] Updated: $version_number -> $new_version"
|
||||
else
|
||||
die "[branch:$branch] Failed to update. Read the above line!"
|
||||
fi
|
||||
fi
|
||||
exit 0
|
||||
}
|
||||
|
||||
# checks if dependencies are present
|
||||
dep_ch() {
|
||||
command -v "$1" >/dev/null || die "Program $1 not found. Please install it."
|
||||
}
|
||||
|
||||
# checks for deps in order and returns the first available. format is "prog_a,prog_b,prog_c"
|
||||
dep_ch_failover() {
|
||||
[ -z "$1" ] && return 1
|
||||
prog=$(printf "%s" "$1" | cut -d "," -f 1)
|
||||
rest=$(printf "%s" "$1" | cut -s -d "," -f 2-)
|
||||
command -v "$prog" >/dev/null 2>&1 && printf "%s" "$prog" && return 0
|
||||
[ -e "$prog" ] && printf "%s" "$prog" && return 0
|
||||
dep_ch_failover "$rest"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
tput sgr0 # clears colors
|
||||
rm -f "${histfile}.new" # remove temp logfile
|
||||
}
|
||||
|
||||
# SCRAPING
|
||||
|
||||
#shellcheck disable=SC2086
|
||||
anidb_curl() {
|
||||
$curl_exe -sL -A "$agent" --max-time 10 $cipher_flag "$@"
|
||||
}
|
||||
|
||||
# search the query and give results. format is (id \t name)
|
||||
anidb_search() {
|
||||
#shellcheck disable=SC2059
|
||||
_page="$(anidb_curl "$(printf "$search_api" "$1")" | tr '\n' ' ' | sed 's|<a href|\n<a href|g')"
|
||||
if printf "%s" "$_page" | grep -qi "Just a moment"; then
|
||||
[ "$curl_exe" = "curl" ] && die "Blocked by cloudflare. Try installing curl-impersonate"
|
||||
die "Blocked by cloudflare."
|
||||
fi
|
||||
printf "%s" "$_page" | sed -nE 's|.*anime/([a-z0-9-]+-[0-9]+)".*alt="([^"]+)".*|\1\t\2|p' | sed -e "s|'|'|g" -e 's|"|"|g'
|
||||
}
|
||||
|
||||
# extract mal_id and seasons metadata
|
||||
anidb_desc() {
|
||||
#shellcheck disable=SC2059
|
||||
_page="$(anidb_curl "$(printf "$desc_api" "$1")" | tr '\n' ' ' | sed 's|<a href|\n<a href|g')"
|
||||
mal_id="$(printf "%s" "$_page" | sed -nE 's|.*https://myanimelist.net/anime/([0-9]+)/.*|\1|p')"
|
||||
seasons="$(printf "%s" "$_page" | sed -n '/>Seasons</,/>Details</p' | sed -nE 's|.*anime/([^"]+-[0-9]+)"[^>]*title="([^"]+)".*|\1\t\2|p' | sed -e "s|'|'|g")"
|
||||
[ -n "$seasons" ] && seasons_option="\nchange_season"
|
||||
}
|
||||
|
||||
# get the episodes list of the selected anime. format is (id \t ep_no)
|
||||
anidb_episodes() {
|
||||
#shellcheck disable=SC2059
|
||||
_page="$(anidb_curl "$(printf "$episodes_api" "${1##*-}")")"
|
||||
printf "%s" "$_page" | sed 's|},{|}\n{|g' | sed -nE 's|.*"id":([0-9]+).*"number":([0-9]+).*|\1\t\2|p'
|
||||
}
|
||||
|
||||
# extract the individual m3u8 links from response of embed urls
|
||||
anidb_m3u8() {
|
||||
_ep_id=$(printf "%s" "$episode_maps" | sed -nE "s|^([0-9]+)\t${1}$|\1|p")
|
||||
_lang="jpn"
|
||||
[ "$2" = "dub" ] && _lang="eng"
|
||||
#shellcheck disable=SC2059
|
||||
_page="$(anidb_curl "$(printf "$m3u8_api" "${_ep_id}")")"
|
||||
_embed="$(printf "%s" "$_page" | sed 's|},{|}\n{|g' | sed -nE 's|.*'"${_lang}"'.*embed_url":"([^"]+)".*|\1|p' | sed 's|\\/|/|g' | head -n 1)"
|
||||
[ -z "$_embed" ] && return 1
|
||||
_embed_page="$(anidb_curl "$_embed")"
|
||||
_m3u8_master="$(printf "%s" "$_embed_page" | sed -nE "s|.*file: '([^']*)'.*|\1|p" | head -n 1)"
|
||||
anidb_curl "${_m3u8_master}" | sed 's|^#EXT-X-STREAM.*x||g; s|,.*|p|g; /^#/d; $!N; s|\n| >|;/EXT-X-I-FRAME/d'
|
||||
}
|
||||
|
||||
# select the quality according to the setting
|
||||
select_quality() {
|
||||
case "$1" in
|
||||
best) _result=$(printf "%s" "$links" | head -n 1) ;;
|
||||
worst) _result=$(printf "%s" "$links" | grep -E '^[0-9]{3,4}' | tail -n 1) ;;
|
||||
*) _result=$(printf "%s" "$links" | grep -m 1 "$1") ;;
|
||||
esac
|
||||
[ -z "$_result" ] && printf "\33[2K\r\033[1;33m%s\033[0m\n" "$1 not found, defaulting to best" >&2 && _result=$(printf "%s" "$links" | head -n 1)
|
||||
|
||||
video_link=$(printf "%s" "$_result" | cut -d ">" -f 2)
|
||||
}
|
||||
|
||||
# gets embed url, collects direct links and selects one with desired quality into $episode
|
||||
get_video_link() {
|
||||
links=$(anidb_m3u8 "$ep_no" "$mode" | sort -g -r -s)
|
||||
[ -z "$links" ] && die "No sources found for $mode!"
|
||||
info "anidb.app links fetched"
|
||||
select_quality "$quality"
|
||||
# should never trigger but just in case
|
||||
if printf "%s" "$ep_list" | grep -q "^$ep_no$"; then
|
||||
[ -z "$video_link" ] && die "Episode is released, but no valid sources!"
|
||||
else
|
||||
[ -z "$video_link" ] && die "Episode not released!"
|
||||
fi
|
||||
}
|
||||
|
||||
# provide countdown for the next episode release both sub and dub
|
||||
time_until_next_ep() {
|
||||
animeschedule="https://animeschedule.net"
|
||||
_query="$(printf "%s" "$*" | tr ' ' '+')"
|
||||
$curl_exe -s -G "$animeschedule/api/v3/anime" --data "q=${_query}" | sed 's|},{"id"|\n|g' | while read -r _entry; do
|
||||
_route=$(printf "%s" "$_entry" | sed -nE 's|.*"route":"([^"]*)",.*|\1|p')
|
||||
_eng_title=$(printf '%s' "$_entry" | sed -nE 's|.*"english":"([^"]*)".*|\1|p')
|
||||
[ -z "$_eng_title" ] && _eng_title=$(printf '%s' "$_entry" | sed -nE 's|.*,"title":"([^"]*)".*|\1|p')
|
||||
[ -z "$_eng_title" ] && _eng_title="N/A"
|
||||
_jpn_title=$(printf '%s' "$_entry" | sed -nE 's|.*"romaji":"([^"]*)".*|\1|p')
|
||||
[ -z "$_jpn_title" ] && _jpn_title="N/A"
|
||||
_status=$(printf '%s' "$_entry" | sed -nE 's|.*"status":"([^"]*)".*|\1|p')
|
||||
[ -z "$_status" ] && _status="Unknown"
|
||||
|
||||
case "$_status" in
|
||||
Ongoing) _color="33" ;;
|
||||
Finished) _color="32" ;;
|
||||
*) _color="36" ;;
|
||||
esac
|
||||
|
||||
printf "Eng: %s\n" "$_eng_title"
|
||||
printf "Jpn: %s\n" "$_jpn_title"
|
||||
printf "Status: \033[1;%sm%s\033[0m\n" "$_color" "$_status"
|
||||
|
||||
if [ "$_status" != "Finished" ]; then
|
||||
_page="$($curl_exe -s "$animeschedule/anime/$_route")"
|
||||
_sub_ep=$(printf "%s" "$_page" | sed -nE 's|.*release-time-type-subs"[^>]*><span class="release-time-episode-number">Episode ([0-9]+)</span>.*|\1|p' | head -n 1)
|
||||
_dub_ep=$(printf "%s" "$_page" | sed -nE 's|.*release-time-type-dub"[^>]*><span class="release-time-episode-number">Episode ([0-9]+)</span>.*|\1|p' | head -n 1)
|
||||
_sub_countdown=$(printf "%s" "$_page" | sed -nE 's|.*class="countdown-time" datetime="([^"]*)".*|\1|p' | head -n 1)
|
||||
_dub_countdown=$(printf "%s" "$_page" | sed -nE 's|.*class="countdown-time countdown-time-dub" datetime="([^"]*)".*|\1|p' | head -n 1)
|
||||
|
||||
[ -n "$_sub_ep" ] && [ -n "$_sub_countdown" ] &&
|
||||
printf "Next Sub: Episode %s in \033[1;%sm%s\033[0m\n" "$_sub_ep" "$_color" "$_sub_countdown" || printf "Next Sub: N/A\n"
|
||||
[ -n "$_dub_ep" ] && [ -n "$_dub_countdown" ] &&
|
||||
printf "Next Dub: Episode %s in \033[1;%sm%s\033[0m\n" "$_dub_ep" "$_color" "$_dub_countdown" || printf "Next Dub: N/A\n"
|
||||
fi
|
||||
printf "\n"
|
||||
done
|
||||
exit 0
|
||||
}
|
||||
|
||||
# HISTORY
|
||||
|
||||
backup_old_hist() {
|
||||
backupfile="${histfile}.v4"
|
||||
while IFS=" " read -r ep_no anime_id anime_title; do
|
||||
case "$anime_id" in
|
||||
*-*) printf "%s\t%s\t%s\n" "$ep_no" "$anime_id" "$anime_title" >>"${histfile}.new" ;;
|
||||
*) printf "%s\t%s\t%s\n" "$ep_no" "$anime_id" "$anime_title" >>"$backupfile" ;;
|
||||
esac
|
||||
done <"$histfile"
|
||||
mv "${histfile}.new" "$histfile"
|
||||
}
|
||||
|
||||
process_hist_entry() {
|
||||
ep_list=$(anidb_episodes "$anime_id" | cut -f 2)
|
||||
ep_no=$(printf "%s" "$ep_list" | sed -n "/^${ep_no}$/{n;p;}") 2>/dev/null
|
||||
[ -n "$ep_no" ] && printf "%s\t%s - episode %s\n" "$anime_id" "$anime_title" "$ep_no"
|
||||
}
|
||||
|
||||
update_history() {
|
||||
if grep -q " ${anime_id} " "$histfile"; then
|
||||
# escape sed special chars in replacement: & and \ and delimiter |
|
||||
safe_title=$(printf "%s" "$anime_title" | sed 's|[&\|]|\\&|g')
|
||||
sed -E "s|^[^ ]+ ${anime_id} [^ ]+$|${ep_no} ${anime_id} ${safe_title}|" "$histfile" >"${histfile}.new"
|
||||
else
|
||||
cp "$histfile" "${histfile}.new"
|
||||
printf "%s\t%s\t%s\n" "$ep_no" "$anime_id" "$anime_title" >>"${histfile}.new"
|
||||
fi
|
||||
mv "${histfile}.new" "$histfile"
|
||||
}
|
||||
|
||||
# PLAYING
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
# $1 = video link, $2 = video name, $3 = extra flags
|
||||
download() {
|
||||
command -v "yt-dlp" >/dev/null && yt-dlp "$1" --no-skip-unavailable-fragments --fragment-retries infinite -N 16 -o "$download_dir/$2.mp4" $3 && return 0
|
||||
ffmpeg -extension_picky 0 -loglevel error -stats -i "$1" -c copy "$download_dir/$2.mp4" $3
|
||||
}
|
||||
|
||||
play_episode() {
|
||||
[ "$log_episode" = 1 ] && [ "$player_function" != "debug" ] && [ "$player_function" != "download" ] && command -v logger >/dev/null && logger -t ani-cli "${anime_title} ${ep_no}"
|
||||
[ "$skip_intro" = 1 ] && skip_flag="$(ani-skip -i "$mal_id" -e "$canon_ep_no")"
|
||||
[ -z "$video_link" ] && get_video_link
|
||||
# wait for the previous player to close (in range selection)
|
||||
wait
|
||||
# shellcheck disable=SC2086
|
||||
case "$player_function" in
|
||||
debug) printf "All links:\n%s\nSelected link:\n%s\n" "$links" "$video_link" ;;
|
||||
android_mpv) nohup am start --user 0 -a android.intent.action.VIEW -d "$video_link" -n is.xyz.mpv/.MPVActivity -e "title" "${anime_title} Episode ${ep_no}" $player_extra_flags >/dev/null 2>&1 & ;;
|
||||
android_vlc) nohup am start --user 0 -a android.intent.action.VIEW -d "$video_link" -n org.videolan.vlc/org.videolan.vlc.gui.video.VideoPlayerActivity -e "title" "${anime_title} Episode ${ep_no}" $player_extra_flags >/dev/null 2>&1 & ;;
|
||||
"$HOME"/.local/share/flatpak/app/io.mpv/Mpv/) flatpak run io.mpv.Mpv $skip_flag $player_extra_flags --force-media-title="${anime_title} Episode ${ep_no}" "$video_link" >/dev/null 2>&1 & ;;
|
||||
*mpv*)
|
||||
if [ "$no_detach" = 0 ]; then
|
||||
nohup $player_function $skip_flag $player_extra_flags --force-media-title="${anime_title} Episode ${ep_no}" "$video_link" >/dev/null 2>&1 &
|
||||
else
|
||||
$player_function $skip_flag $player_extra_flags --force-media-title="${anime_title} Episode ${ep_no}" "$video_link"
|
||||
mpv_exitcode=$?
|
||||
[ "$exit_after_play" = 1 ] && [ -z "$_range" ] && exit "$mpv_exitcode"
|
||||
fi
|
||||
;;
|
||||
*iina*)
|
||||
if pgrep -f "IINA" >/dev/null 2>&1; then
|
||||
# omit --keep-running when an IINA instance exists to prevent hanging
|
||||
nohup $player_function $player_extra_flags --no-stdin --mpv-force-media-title="${anime_title} Episode ${ep_no}" "$video_link" >/dev/null 2>&1 &
|
||||
else
|
||||
nohup $player_function $player_extra_flags --no-stdin --keep-running --mpv-force-media-title="${anime_title} Episode ${ep_no}" "$video_link" >/dev/null 2>&1 &
|
||||
fi
|
||||
;;
|
||||
*vlc*) nohup $player_function $player_extra_flags --play-and-exit --meta-title="${anime_title} Episode ${ep_no}" "$video_link" >/dev/null 2>&1 & ;;
|
||||
*yncpla*) nohup $player_function $player_extra_flags "$video_link" -- --force-media-title="${anime_title} Episode ${ep_no}" >/dev/null 2>&1 & ;;
|
||||
download) "$player_function" "$video_link" "${anime_title} Episode ${ep_no}" $player_extra_flags ;;
|
||||
catt) nohup catt cast $player_extra_flags "$video_link" >/dev/null 2>&1 & ;;
|
||||
iSH)
|
||||
printf "\033]8;;vlc://%s\a~~~~~~~~~~~~~~~~~~~~\n~ Tap to open VLC ~\n~~~~~~~~~~~~~~~~~~~~\033]8;;\a\n" "$video_link"
|
||||
sleep 5
|
||||
;;
|
||||
*) nohup $player_function $player_extra_flags "$video_link" >/dev/null 2>&1 & ;;
|
||||
esac
|
||||
replay="$video_link"
|
||||
unset video_link
|
||||
update_history
|
||||
[ "$menu_program" != "fzf" ] && wait
|
||||
}
|
||||
|
||||
play() {
|
||||
_start=$(printf "%s" "$ep_no" | grep -Eo '^(-1|[0-9]+(\.[0-9]+)?)')
|
||||
_end=$(printf "%s" "$ep_no" | grep -Eo '(-1|[0-9]+(\.[0-9]+)?)$')
|
||||
[ "$_start" = "-1" ] && ep_no=$(printf "%s" "$ep_list" | tail -n 1) && unset _start
|
||||
[ -z "$_end" ] || [ "$_end" = "$_start" ] && unset _start _end
|
||||
[ "$_end" = "-1" ] && _end=$(printf "%s" "$ep_list" | tail -n 1)
|
||||
_line_count=$(printf "%s\n" "$ep_no" | wc -l | tr -d "[:space:]")
|
||||
if [ "$_line_count" != 1 ] || [ -n "$_start" ]; then
|
||||
[ -z "$_start" ] && _start=$(printf "%s\n" "$ep_no" | head -n 1)
|
||||
[ -z "$_end" ] && _end=$(printf "%s\n" "$ep_no" | tail -n 1)
|
||||
_range=$(printf "%s\n" "$ep_list" | sed -nE "/^${_start}\$/,/^${_end}\$/p")
|
||||
[ -z "$_range" ] && die "Invalid range!"
|
||||
for i in $_range; do
|
||||
tput clear
|
||||
ep_no=$i
|
||||
canon_ep_no="$(printf "%s" "$ep_list" | sed -n "/^${ep_no}$/=")"
|
||||
info "Playing episode $ep_no..."
|
||||
[ "$i" = "$_end" ] && unset _range
|
||||
play_episode
|
||||
done
|
||||
else
|
||||
printf "%s" "$ep_list" | grep -q "^$ep_no$" || die "Invalid episode!"
|
||||
canon_ep_no="$(printf "%s" "$ep_list" | sed -n "/^${ep_no}$/=")"
|
||||
play_episode
|
||||
fi
|
||||
# moves up to stored position and deletes to end
|
||||
[ "$player_function" != "debug" ] && [ "$player_function" != "download" ] && tput rc && tput ed
|
||||
}
|
||||
|
||||
# MAIN
|
||||
|
||||
# setup
|
||||
base_api="https://anidb.app"
|
||||
search_api="${base_api}/browse?q=%s"
|
||||
# search_api_alt="${base_api}/search/suggestions?q=%s"
|
||||
desc_api="${base_api}/anime/%s"
|
||||
episodes_api="${base_api}/api/frontend/anime/%s/episodes"
|
||||
m3u8_api="${base_api}/api/frontend/episode/%s/languages"
|
||||
|
||||
ciphers='ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305'
|
||||
tls13_ciphers='TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256'
|
||||
agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
||||
|
||||
mode="${ANI_CLI_MODE:-sub}"
|
||||
download_dir="${ANI_CLI_DOWNLOAD_DIR:-.}"
|
||||
log_episode="${ANI_CLI_LOG:-1}"
|
||||
quality="${ANI_CLI_QUALITY:-best}"
|
||||
|
||||
curl_exe=$(dep_ch_failover "curl_firefox135,curl_chrome136,curl_chrome116,curl_ff117,curl") || die "Program curl not found"
|
||||
|
||||
case "$(uname -a | cut -d " " -f 1,3-)" in
|
||||
*Darwin*)
|
||||
cipher_flag="--ciphers $ciphers --tls13-ciphers $tls13_ciphers"
|
||||
player_function="${ANI_CLI_PLAYER:-$(dep_ch_failover "iina,/Applications/IINA.app/Contents/MacOS/iina-cli,mpv,vlc")}" || die 'No player found. Looked for iina, mpv and vlc'
|
||||
;; # mac OS
|
||||
*ndroid*) player_function="${ANI_CLI_PLAYER:-android_mpv}" ;; # Android OS (termux)
|
||||
*MINGW* | *WSL2*) player_function="${ANI_CLI_PLAYER:-mpv.exe}" ;; # Windows OS
|
||||
*ish*)
|
||||
player_function="${ANI_CLI_PLAYER:-iSH}"
|
||||
curl_exe=$(dep_ch_failover "curl_safari260_ios,$curl_exe")
|
||||
;; # iOS (iSH)
|
||||
*) player_function="${ANI_CLI_PLAYER:-$(dep_ch_failover "mpv,$HOME/.local/share/flatpak/app/io.mpv/Mpv/,vlc")}" || die 'No player found. Looked for mpv and vlc' ;; # Linux OS
|
||||
esac
|
||||
|
||||
player_extra_flags="${ANI_CLI_PLAYER_FLAGS:-""}"
|
||||
no_detach="${ANI_CLI_NO_DETACH:-0}"
|
||||
exit_after_play="${ANI_CLI_EXIT_AFTER_PLAY:-0}"
|
||||
skip_intro="${ANI_CLI_SKIP_INTRO:-0}"
|
||||
menu_program="${ANI_CLI_MENU:-fzf}"
|
||||
menu_extra_flags="${ANI_CLI_MENU_FLAGS:-""}"
|
||||
|
||||
# not opening from a terminal
|
||||
if [ ! -t 0 ]; then
|
||||
command -v dmenu >/dev/null && menu_program=dmenu
|
||||
command -v rofi >/dev/null && menu_program=rofi
|
||||
fi
|
||||
|
||||
hist_dir="${ANI_CLI_HIST_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/ani-cli}"
|
||||
[ ! -d "$hist_dir" ] && mkdir -p "$hist_dir"
|
||||
histfile="$hist_dir/ani-hsts"
|
||||
[ ! -f "$histfile" ] && : >"$histfile"
|
||||
|
||||
source="${ANI_CLI_DEFAULT_SOURCE:-search}"
|
||||
branch="${ANI_CLI_BRANCH:-master}"
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-v | --vlc)
|
||||
case "$(uname -a | cut -d " " -f 1,3-)" in
|
||||
*ndroid*) player_function="android_vlc" ;;
|
||||
MINGW* | *WSL2*) player_function="vlc.exe" ;;
|
||||
*ish*) player_function="iSH" ;;
|
||||
*) player_function="vlc" ;;
|
||||
esac
|
||||
;;
|
||||
-s | --syncplay)
|
||||
case "$(uname -s)" in
|
||||
Darwin*) player_function="/Applications/Syncplay.app/Contents/MacOS/syncplay" ;;
|
||||
MINGW* | *Msys)
|
||||
export PATH="$PATH":"/c/Program Files (x86)/Syncplay/"
|
||||
player_function="syncplay.exe"
|
||||
;;
|
||||
*) player_function="syncplay" ;;
|
||||
esac
|
||||
;;
|
||||
-q | --quality)
|
||||
[ $# -lt 2 ] && die "missing argument!"
|
||||
quality="$2"
|
||||
shift
|
||||
;;
|
||||
-S | --select-nth)
|
||||
[ $# -lt 2 ] && die "missing argument!"
|
||||
index="$2"
|
||||
shift
|
||||
;;
|
||||
-c | --continue) source=history ;;
|
||||
-d | --download)
|
||||
player_function=download
|
||||
;;
|
||||
-D | --delete)
|
||||
: >"$histfile"
|
||||
exit 0
|
||||
;;
|
||||
-l | --logview)
|
||||
case "$(uname -s)" in
|
||||
Darwin*) log show --predicate 'process == "logger"' ;;
|
||||
Linux*) journalctl -t ani-cli ;;
|
||||
*) die "Logger not implemented for your platform" ;;
|
||||
esac
|
||||
exit 0
|
||||
;;
|
||||
-V | --version) version_info ;;
|
||||
-h | --help) help_info ;;
|
||||
-e | --episode | -r | --range)
|
||||
[ $# -lt 2 ] && die "missing argument!"
|
||||
ep_no="$2"
|
||||
shift
|
||||
;;
|
||||
--dub) mode="dub" ;;
|
||||
--no-detach) no_detach=1 ;;
|
||||
--exit-after-play) exit_after_play=1 && no_detach=1 ;;
|
||||
--rofi) menu_program=rofi ;;
|
||||
--dmenu) menu_program=dmenu ;;
|
||||
--skip) skip_intro=1 ;;
|
||||
-N | --nextep-countdown) source=nextep ;;
|
||||
-U | --update)
|
||||
dep_ch "patch"
|
||||
branch="${2:-$branch}"
|
||||
update_script
|
||||
;;
|
||||
*) query="$(printf "%s" "$query $1" | sed 's|^ ||;s| |+|g')" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
[ "$menu_program" = "fzf" ] && menu_multi_flag="-m"
|
||||
[ "$menu_program" = "rofi" ] && menu_multi_flag="-multi-select"
|
||||
|
||||
info "Checking dependencies..."
|
||||
dep_ch "sed"
|
||||
dep_ch "grep"
|
||||
[ "$skip_intro" = 1 ] && dep_ch "ani-skip"
|
||||
dep_ch "$menu_program" # TODO: use dep_ch_failover to iterate through options
|
||||
|
||||
case "$player_function" in
|
||||
debug) ;;
|
||||
download) dep_ch_failover "yt-dlp,ffmpeg" >/dev/null || die 'Neither yt-dlp nor ffmpeg found' ;;
|
||||
android*) printf "\33[2K\rChecking of players on Android is disabled.\n" ;;
|
||||
*iSH*) printf "\33[2K\rChecking of players on iOS is disabled\n" ;;
|
||||
*) dep_ch "$player_function" ;;
|
||||
esac
|
||||
|
||||
# idk where to put this, so I put it here...
|
||||
trap 'cleanup; exit 1' INT HUP TERM
|
||||
|
||||
# searching
|
||||
case "$source" in
|
||||
history)
|
||||
backup_old_hist
|
||||
anime_list=$(while read -r ep_no anime_id anime_title; do process_hist_entry & done <"$histfile")
|
||||
wait
|
||||
[ -z "$anime_list" ] && die "No unwatched series in history!"
|
||||
[ -z "${index##*[!0-9]*}" ] && anime_id=$(printf "%s" "$anime_list" | nl -w 2 | sed 's/^[[:space:]]//' | nth "Select anime: " | cut -f 1)
|
||||
[ -z "${index##*[!0-9]*}" ] || anime_id=$(printf "%s" "$anime_list" | sed -n "${index}p" | cut -f 1)
|
||||
[ -z "$anime_id" ] && exit 1
|
||||
anime_title=$(printf "%s" "$anime_list" | grep "^$anime_id " | cut -f 2 | sed 's| - episode.*||')
|
||||
anidb_desc "$anime_id" || true
|
||||
episode_maps="$(anidb_episodes "$anime_id")"
|
||||
ep_list="$(printf "%s" "$episode_maps" | cut -f 2)"
|
||||
ep_no=$(printf "%s" "$anime_list" | grep "^$anime_id " | sed -nE 's/.*- episode (.+)$/\1/p')
|
||||
;;
|
||||
*)
|
||||
if [ "$menu_program" = "fzf" ]; then
|
||||
while [ -z "$query" ]; do
|
||||
printf "\33[2K\r\033[1;36mSearch anime: \033[0m" && read -r query
|
||||
done
|
||||
else
|
||||
[ -z "$query" ] && query=$(: | menu "Search anime: " "" "$menu_extra_flags")
|
||||
[ -z "$query" ] && exit 1
|
||||
fi
|
||||
# for checking new releases by specifying anime name
|
||||
[ "$source" = "nextep" ] && time_until_next_ep "$query"
|
||||
|
||||
query=$(printf "%s" "$query" | sed 's| |+|g')
|
||||
|
||||
anime_list=$(anidb_search "$query")
|
||||
[ -z "$anime_list" ] && die "No results found!"
|
||||
[ "$index" -eq "$index" ] 2>/dev/null && result=$(printf "%s" "$anime_list" | sed -n "${index}p")
|
||||
[ -z "$index" ] && result=$(printf "%s" "$anime_list" | nl -w 2 | sed 's/^[[:space:]]//' | nth "Select anime: ")
|
||||
[ -z "$result" ] && die "Invalid anime selection"
|
||||
anime_title="$(printf "%s" "$result" | cut -f 2)"
|
||||
anime_id="$(printf "%s" "$result" | cut -f 1)"
|
||||
anidb_desc "$anime_id" || true
|
||||
episode_maps="$(anidb_episodes "$anime_id")"
|
||||
ep_list="$(printf '%s' "$episode_maps" | cut -f 2)"
|
||||
[ -z "$ep_no" ] && ep_no=$(printf "%s" "$ep_list" | nth "Select episode: " "$menu_multi_flag")
|
||||
[ -z "$ep_no" ] && die "Invalid episode selection"
|
||||
;;
|
||||
esac
|
||||
|
||||
# moves the cursor up one line and clears that line
|
||||
tput cuu1 && tput el
|
||||
# stores the position of cursor
|
||||
tput sc
|
||||
|
||||
# playback & loop
|
||||
play
|
||||
[ "$player_function" = "download" ] || [ "$player_function" = "debug" ] && exit 0
|
||||
|
||||
#shellcheck disable=SC2059
|
||||
while cmd=$(printf "next\nreplay\nprevious\nselect$seasons_option\nchange_quality\nquit" | nth "Playing episode $ep_no of $anime_title... "); do
|
||||
case "$cmd" in
|
||||
next) ep_no=$(printf "%s" "$ep_list" | sed -n "/^${ep_no}$/{n;p;}") 2>/dev/null ;;
|
||||
replay) video_link="$replay" ;;
|
||||
previous) ep_no=$(printf "%s" "$ep_list" | sed -n "/^${ep_no}$/{g;1!p;};h") 2>/dev/null ;;
|
||||
select) ep_no=$(printf "%s" "$ep_list" | nth "Select episode: " "$menu_multi_flag") ;;
|
||||
change_season)
|
||||
new_anime="$(printf "%s" "$seasons" | nl -w 2 | sed 's/^[[:space:]]//' | nth "Select season: ")"
|
||||
[ -z "$new_anime" ] && die "Invalid season selection"
|
||||
anime_title="$(printf "%s" "$new_anime" | cut -f 2)"
|
||||
anime_id="$(printf "%s" "$new_anime" | cut -f 1)"
|
||||
anidb_desc "$anime_id" || true
|
||||
episode_maps="$(anidb_episodes "$anime_id")"
|
||||
ep_list="$(printf '%s' "$episode_maps" | cut -f 2)"
|
||||
ep_no=$(printf "%s" "$ep_list" | nth "Select episode: " "$menu_multi_flag")
|
||||
[ -z "$ep_no" ] && die "Invalid episode selection"
|
||||
;;
|
||||
change_quality)
|
||||
new_quality="$(printf "%s" "$links" | menu "Select Quality: " "" "$menu_extra_flags" | cut -d ">" -f 1)"
|
||||
[ -z "$new_quality" ] && die "No quality selected"
|
||||
select_quality "$new_quality"
|
||||
;;
|
||||
*)
|
||||
cleanup
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
[ -z "$ep_no" ] && die "Out of range"
|
||||
play
|
||||
done
|
||||
|
||||
# ani-cli
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# Project repository: https://github.com/pystardust/ani-cli
|
||||
@@ -1,17 +0,0 @@
|
||||
# Supervised replacement for run.sh's nohup (crash -> auto-restart).
|
||||
# Install (then stop the run.sh instance: pkill -f 'uvicorn .*app:app'):
|
||||
# sudo ln -s /home/jozamudi/code/ani-cli-front-end/ani-web.service /etc/systemd/system/
|
||||
# sudo systemctl daemon-reload && sudo systemctl enable --now ani-web
|
||||
[Unit]
|
||||
Description=ani-web (FastAPI anime front-end)
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
WorkingDirectory=/home/jozamudi/code/ani-cli-front-end
|
||||
Environment=ANIWEB_SECURE=1
|
||||
ExecStart=/home/jozamudi/code/ani-cli-front-end/.venv/bin/python -m uvicorn server.app:app --host 0.0.0.0 --port 8321
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Binary file not shown.
@@ -1,44 +0,0 @@
|
||||
# Code-audit fix batch (2026-09)
|
||||
|
||||
Findings from the 2026-09-15 code audit, in shipping order. The systemd
|
||||
cutover was explicitly deferred — the process stays on run.sh's nohup
|
||||
model; only the restart mechanism's blast radius is fixed here.
|
||||
|
||||
## 1. login handler blocks the event loop (HIGH)
|
||||
|
||||
`login` is `async def`, so the anti-brute-force `time.sleep(0.5)` inside
|
||||
the lockout check runs ON the event loop: every wrong password stalls
|
||||
every in-flight request (all streams included) on the single worker.
|
||||
Fix: make the handler a plain `def` (uvicorn runs it on the threadpool);
|
||||
move the lockout logic into a side-effect-free `server/auth.py` so it is
|
||||
unit-testable without booting the app. Regression test measures
|
||||
`/api/cdn` latency while a wrong-password login is mid-sleep.
|
||||
|
||||
## 2. run.sh global pkill (HIGH)
|
||||
|
||||
`pkill -f "uvicorn .*app:app"` also matches the test server
|
||||
(`uvicorn server.app:app --host 127.0.0.1 --port 8399`): an ani-web
|
||||
restart kills running test sessions (and vice versa). Fix: PID file
|
||||
(`server.pid`) becomes the primary kill path; any pattern fallback is
|
||||
port-scoped to the real instance.
|
||||
|
||||
## 3. lockout is per-username only (MEDIUM)
|
||||
|
||||
Spreading failures across usernames bypasses the per-username lockout
|
||||
entirely (and anyone can lock a known account for 15 min). Add a
|
||||
per-IP failure budget (`ANIWEB_IP_FAILS` / window) on top of the
|
||||
per-username lockout. Client IP = `request.client.host`; the reverse
|
||||
proxy's `X-Forwarded-For` is consulted only when `ANIWEB_TRUST_XFF=1`
|
||||
(set by run.sh), so a spoofed header can't widen or narrow the gate.
|
||||
|
||||
## 4. login is exempt from the CSRF header check (MEDIUM)
|
||||
|
||||
The custom-header check that makes cross-site form POSTs fail excludes
|
||||
`/login`. The exemption is load-bearing only by accident (JSON body
|
||||
content-type); login becomes covered by the same check as every other
|
||||
mutation, and login.html sends the header.
|
||||
|
||||
## 5. dependencies unpinned (MEDIUM)
|
||||
|
||||
`curl_cffi` version churn = impersonation churn = silent upstream 403
|
||||
waves. requirements.txt becomes a full freeze; bump deliberately.
|
||||
+10
-6
@@ -1,8 +1,12 @@
|
||||
fastapi
|
||||
uvicorn
|
||||
curl_cffi
|
||||
itsdangerous
|
||||
# exact pins on purpose: curl_cffi behavior = impersonation behavior; an
|
||||
# unpinned bump is a silent upstream-403 wave. Bump one at a time, re-run
|
||||
# the upstream self-check (.venv/bin/python server/upstream.py) per bump.
|
||||
fastapi==0.141.1
|
||||
uvicorn==0.52.4
|
||||
curl_cffi==0.16.0
|
||||
itsdangerous==2.2.0
|
||||
pycryptodome==3.23.0
|
||||
|
||||
# tests
|
||||
pytest
|
||||
playwright
|
||||
pytest==9.1.1
|
||||
playwright==1.62.0
|
||||
@@ -25,8 +25,12 @@ fi
|
||||
|
||||
# ANIWEB_SECURE=1: session cookie is Secure — only usable via the HTTPS
|
||||
# proxy (plain http://<this machine>:8321 can no longer hold a session).
|
||||
# ANIWEB_TRUST_XFF=1: behind that same local proxy, so X-Forwarded-For may
|
||||
# be trusted for the per-IP login budget (server/auth.py).
|
||||
ANIWEB_SECURE=1 ANIWEB_TRUST_XFF=1 nohup .venv/bin/python -m uvicorn server.app:app --host 0.0.0.0 --port 8321 --timeout-graceful-shutdown 3 > server.log 2>&1 &
|
||||
# ANIWEB_TRUST_XFF=1: prod traffic arrives via the local HTTPS proxy, so the
|
||||
# per-IP login budget keys on X-Forwarded-For; without the flag auth.py uses
|
||||
# the socket peer only (the header is spoofable on a direct connection).
|
||||
# ANI_SOURCE: animekai leads while MegaPlay's CDN 403s our egress IP (its
|
||||
# 1anime servers use an independent CDN); the hianime/anidb/anikoto chain
|
||||
# stays as fallback. Restore "hianime,anidb,anikoto" once the block lifts.
|
||||
ANIWEB_SECURE=1 ANIWEB_TRUST_XFF=1 ANI_SOURCE=animekai,hianime,anidb,anikoto nohup .venv/bin/python -m uvicorn server.app:app --host 0.0.0.0 --port 8321 --timeout-graceful-shutdown 3 > server.log 2>&1 &
|
||||
echo $! > "$PIDF"
|
||||
echo "ani-web on http://localhost:8321 (pid $(cat "$PIDF")) — use the HTTPS proxy to log in"
|
||||
+88
-59
@@ -22,7 +22,7 @@ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Redirect
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from itsdangerous import BadSignature, TimestampSigner
|
||||
|
||||
from . import db, upstream, users
|
||||
from . import auth, db, upstream, users
|
||||
|
||||
ROOT = Path(__file__).parent.parent
|
||||
app = FastAPI(title="ani-web")
|
||||
@@ -44,52 +44,59 @@ def _version():
|
||||
|
||||
VERSION = _version()
|
||||
|
||||
# latest probe result + last 20 samples (~1h at 3-min spacing), for GET /api/cdn
|
||||
# An episode counts as "watched" once you're this far through it (fraction of
|
||||
# duration). One knob for client + server: injected into index.html below so
|
||||
# app.js uses the same value as these stats. Change here only.
|
||||
DONE_RATIO = 0.95
|
||||
|
||||
# latest probe result + last 20 samples, for GET /api/cdn
|
||||
_CDN = {"state": "unknown", "kbps": 0.0, "ts": 0.0, "hist": []}
|
||||
# ponytail: probe only when someone is around to care — skip the 64 KB video
|
||||
# download while the app has been idle (no stream requested in 15 min), and
|
||||
# back off to 30 min while DEGRADED (re-hitting a blocked CDN every 3 min is
|
||||
# just churn). api_stream() bumps _LAST_PLAY.
|
||||
_LAST_PLAY = 0.0
|
||||
_IDLE_SECS = 900
|
||||
_DEGRADED_RETRY = 1800
|
||||
# origin (primary provider pages/API) health — separate host, separate failure
|
||||
# mode (observed 2026-09-03: CDN fine while origin 503'd "Under Maintenance")
|
||||
_ORIGIN = {"state": "unknown", "msg": "", "ts": 0.0}
|
||||
_ORIGIN = {"state": "unknown", "msg": "", "ts": 0.0, "hist": []}
|
||||
# kept for the /api/cdn response shape (the panel shows it only when a rate
|
||||
# was actually measured; the probe now checks reachability, not throughput)
|
||||
_CDN_THRESHOLD = float(os.environ.get("ANIWEB_CDN_KBPS", "40"))
|
||||
|
||||
|
||||
def _cdn_probe():
|
||||
"""Background probe: pull 64KB from the CDN every 3 min and log state
|
||||
flips, so server.log shows when a degraded window ends (CDN RECOVERED).
|
||||
"""Background probe: fetch ONE small playlist and log state flips, so
|
||||
server.log shows when a degraded window ends (CDN RECOVERED). No video
|
||||
is downloaded just to light a status dot. Skipped
|
||||
while the app is idle and backed off while DEGRADED (see _LAST_PLAY).
|
||||
Probe target: ep_id 1 = One Piece E1 on the primary provider.
|
||||
ponytail: single fixed episode; if it ever disappears, the probe logs
|
||||
DEGRADED forever — swap the ID, don't build a picker."""
|
||||
def first(pl, base):
|
||||
l = next(x.strip() for x in pl.splitlines()
|
||||
if x.strip() and not x.startswith("#"))
|
||||
return l if l.startswith("http") else base + l
|
||||
|
||||
state = None
|
||||
while True:
|
||||
if time.time() - _LAST_PLAY > _IDLE_SECS:
|
||||
time.sleep(60) # nobody's watching: don't burn upstream bandwidth
|
||||
continue
|
||||
try:
|
||||
m = upstream.master_m3u8(1, "jpn")
|
||||
if not m:
|
||||
raise RuntimeError("no master m3u8")
|
||||
# hianime's CDN 403s without the embed-origin Referer (anidb needs none)
|
||||
hdrs = upstream.stream_meta(m) or {}
|
||||
base = m.rsplit("/", 1)[0] + "/"
|
||||
v = first(upstream._get(m, timeout=10, headers=hdrs).text, base)
|
||||
# hianime's variant playlists use URIs relative to THEIR OWN path
|
||||
vbase = v.rsplit("/", 1)[0] + "/"
|
||||
seg = first(upstream._get(v, timeout=10, headers=hdrs).text, vbase)
|
||||
t = time.time()
|
||||
r = upstream._session().get(seg, headers={**hdrs, "Range": "bytes=0-65535"},
|
||||
impersonate="chrome", timeout=10)
|
||||
kbps = len(r.content) / 1024 / max(time.time() - t, 0.01)
|
||||
upstream._get(m, timeout=10, headers=hdrs)
|
||||
now = "OK"
|
||||
except Exception:
|
||||
kbps = 0.0
|
||||
now = "OK" if kbps >= 40 else "DEGRADED"
|
||||
now = "DEGRADED"
|
||||
if now != state:
|
||||
print(f"[probe] CDN {now} ({kbps:.0f} KB/s)", flush=True)
|
||||
print(f"[probe] CDN {now}", flush=True)
|
||||
state = now
|
||||
_CDN.update(state=now, kbps=round(kbps), ts=time.time())
|
||||
_CDN["hist"].append({"t": time.time(), "kbps": round(kbps)})
|
||||
_CDN.update(state=now, kbps=0, ts=time.time())
|
||||
_CDN["hist"].append({"t": time.time(), "kbps": 0})
|
||||
del _CDN["hist"][:-20]
|
||||
_origin_probe()
|
||||
time.sleep(180)
|
||||
time.sleep(_DEGRADED_RETRY if now == "DEGRADED" else 180)
|
||||
|
||||
|
||||
def _origin_probe():
|
||||
@@ -103,6 +110,8 @@ def _origin_probe():
|
||||
state, msg = "DOWN", str(e)
|
||||
prev = _ORIGIN["state"]
|
||||
_ORIGIN.update(state=state, msg=msg, ts=time.time())
|
||||
_ORIGIN["hist"].append({"t": time.time(), "state": state})
|
||||
del _ORIGIN["hist"][:-20]
|
||||
if state != prev:
|
||||
print(f"[probe] ORIGIN {state} ({msg})", flush=True)
|
||||
|
||||
@@ -114,7 +123,7 @@ threading.Thread(target=_cdn_probe, daemon=True).start()
|
||||
async def cdn_status():
|
||||
"""CDN + origin health (read-only, public). Top-level fields are the CDN
|
||||
probe (existing shape); .origin is the anidb.app pages/API probe."""
|
||||
return {**_CDN, "origin": _ORIGIN}
|
||||
return {**_CDN, "threshold": _CDN_THRESHOLD, "origin": _ORIGIN}
|
||||
|
||||
# ---------- auth: multi-user, signed cookies ----------
|
||||
users.migrate_legacy() # file-based legacy import; db.init() absorbs its output
|
||||
@@ -125,9 +134,9 @@ _SESSION_AGE = 30 * 86400
|
||||
_PUBLIC = {("/login", "GET"), ("/login", "POST"), ("/api/cdn", "GET")}
|
||||
_SECURE = os.environ.get("ANIWEB_SECURE") == "1" # set when served over HTTPS
|
||||
|
||||
# ponytail: in-memory lockout per username, single process — 5 fails -> 15 min
|
||||
# ponytail: in-memory lockout per username, single process — 5 fails -> 15 min
|
||||
_lockout: dict = {}
|
||||
# Login throttling lives in server/auth.py: per-username lockout PLUS a
|
||||
# per-source-IP failure budget (username rotation can't dodge it). The
|
||||
# bucket logic is unit-tested there (tests/test_auth.py), without the app.
|
||||
|
||||
|
||||
def _session_user(request) -> str | None:
|
||||
@@ -138,28 +147,20 @@ def _session_user(request) -> str | None:
|
||||
return payload.decode().split(":", 1)[1] if payload.startswith(b"user:") else None
|
||||
|
||||
|
||||
def _lock(user: str, pw: str) -> str:
|
||||
lo = _lockout.setdefault(user, {"fails": 0, "until": 0.0})
|
||||
if time.time() < lo["until"]:
|
||||
return "locked"
|
||||
if users.verify(user, pw):
|
||||
lo.update(fails=0, until=0.0)
|
||||
return "ok"
|
||||
lo["fails"] += 1
|
||||
if lo["fails"] >= 5:
|
||||
lo.update(fails=0, until=time.time() + 900)
|
||||
time.sleep(0.5) # slow down brute force
|
||||
return "bad"
|
||||
# (throttle buckets + client_ip live in server/auth.py — see auth.locked /
|
||||
# auth.record; the old per-username-only _lock dict was the audit finding)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def csrf_header(request, call_next):
|
||||
# ponytail: a cross-site form POST (top-level navigation) does carry the
|
||||
# SameSite=Lax cookie, so <form action=.../account/delete> from a
|
||||
# malicious page would work; forms can't set a custom header, the SPA
|
||||
# always sends it. Runs *after* require_auth (registered earlier), so
|
||||
# unauthenticated requests still get 302/401 first.
|
||||
if request.method in ("POST", "DELETE") and request.url.path != "/login":
|
||||
# malicious page would work; forms can't set a custom header, the SPA and
|
||||
# the login page both send one. /login is covered too: login needs no
|
||||
# cookie, so an exempt /login let a cross-site forced-login POST swap a
|
||||
# visitor's session onto the attacker's account. Runs *after* require_auth
|
||||
# (registered earlier), so unauthenticated GETs still 302/401 first.
|
||||
if request.method in ("POST", "DELETE"):
|
||||
if request.headers.get("x-aniweb") != "1":
|
||||
return JSONResponse({"detail": "forbidden"}, status_code=403)
|
||||
return await call_next(request)
|
||||
@@ -194,20 +195,23 @@ def login_page():
|
||||
|
||||
|
||||
@app.post("/login")
|
||||
def login(payload: dict = Body(...)):
|
||||
# SYNC def on purpose: _lock's 0.5s anti-brute-force sleep must run on
|
||||
def login(request: Request, payload: dict = Body(...)):
|
||||
# SYNC def on purpose: the 0.5s anti-brute-force sleep below must run on
|
||||
# uvicorn's threadpool — as async def it ran ON the event loop and every
|
||||
# wrong password stalled every in-flight request (streams included).
|
||||
u = payload.get("username", "").lower()
|
||||
if not u:
|
||||
return JSONResponse({"detail": "username required"}, status_code=400)
|
||||
state = _lock(u, payload.get("password", ""))
|
||||
if state == "ok":
|
||||
ip = auth.client_ip(request)
|
||||
if auth.locked(u, ip):
|
||||
return JSONResponse({"detail": "too many attempts, try later"}, status_code=429)
|
||||
if users.verify(u, payload.get("password", "")):
|
||||
auth.clear(u)
|
||||
resp = JSONResponse({"ok": True, "user": u})
|
||||
_session_cookie(resp, u)
|
||||
return resp
|
||||
if state == "locked":
|
||||
return JSONResponse({"detail": "too many attempts, try later"}, status_code=429)
|
||||
auth.record(u, ip)
|
||||
time.sleep(0.5) # slow down brute force
|
||||
return JSONResponse({"detail": "wrong username or password"}, status_code=401)
|
||||
|
||||
|
||||
@@ -455,7 +459,7 @@ def api_stats(request: Request, days: int = Query(30, ge=1, le=365),
|
||||
if s["last_ts"] > a["last_seen"]:
|
||||
a["last_seen"] = s["last_ts"]
|
||||
a["cur_ep"] = s["last_ep"]
|
||||
if s["dur"] > 0 and s["max"] >= s["dur"] * 0.95:
|
||||
if s["dur"] > 0 and s["max"] >= s["dur"] * DONE_RATIO:
|
||||
a["completed"] += 1
|
||||
completed += 1
|
||||
now = time.time()
|
||||
@@ -554,11 +558,16 @@ def api_episodes(anime_id: int):
|
||||
|
||||
@app.get("/api/episode/{ep_id}/stream")
|
||||
def api_stream(ep_id: int, lang: str = "jpn", request: Request = None):
|
||||
global _LAST_PLAY
|
||||
_LAST_PLAY = time.time() # wakes the CDN probe: a viewer is here
|
||||
def go():
|
||||
master = _upstream(upstream.master_m3u8, ep_id, lang)
|
||||
if not master:
|
||||
raise HTTPException(404, f"no {lang} stream for episode {ep_id}")
|
||||
out = {"token": _b64(master)}
|
||||
# the player kind is a property of the URL, not the provider: .m3u8
|
||||
# -> hls.js, anything else -> native <video> (app.js branches on this)
|
||||
out["hls"] = master.endswith(".m3u8")
|
||||
sub = upstream.subtitles(ep_id) # external .vtt (hianime); anidb bakes subs in
|
||||
if sub:
|
||||
out["sub"] = _b64(sub)
|
||||
@@ -609,9 +618,11 @@ def _cache_put(url, ct, body):
|
||||
_STREAM_CACHE.popitem(last=False)
|
||||
|
||||
|
||||
def _seg_stream(r, url, ct):
|
||||
def _seg_stream(r, url, ct, cache=True):
|
||||
"""Forward the CDN's bytes as they arrive; keep the LRU warm for
|
||||
completed downloads (a truncated one would poison the cache)."""
|
||||
completed downloads (a truncated one would poison the cache). cache=False
|
||||
for ranged file chunks — a partial body under the full-URL key would
|
||||
poison the LRU."""
|
||||
import time
|
||||
t0, t_first, buf, complete = time.time(), None, [], False
|
||||
try:
|
||||
@@ -623,7 +634,7 @@ def _seg_stream(r, url, ct):
|
||||
complete = True
|
||||
finally:
|
||||
r.close()
|
||||
if complete and buf:
|
||||
if complete and buf and cache:
|
||||
_cache_put(url, ct, b"".join(buf))
|
||||
# observability: a slow segment is the fragLoadTimeOut culprit. Log
|
||||
# only the slow ones so normal playback stays quiet. TTFB>3s or
|
||||
@@ -653,7 +664,7 @@ def _stream_playlist(url, meta=None):
|
||||
|
||||
|
||||
@app.get("/stream/{token}")
|
||||
def stream(token: str):
|
||||
def stream(token: str, request: Request = None):
|
||||
url = _url(token)
|
||||
p = urlparse(url)
|
||||
# ponytail: token is plain base64, so allowlist the host — otherwise any
|
||||
@@ -671,6 +682,19 @@ def stream(token: str):
|
||||
return Response(hit[1], media_type=hit[0], headers=_STREAM_CACHE_HDRS)
|
||||
if url.endswith(".m3u8"):
|
||||
return _stream_playlist(url, meta)
|
||||
# progressive file (1anime's direct MP4): the browser seeks with Range —
|
||||
# forward it, stream the chunk back, forward 206/Content-Range, never
|
||||
# cache (partial bodies under the full-URL key would poison the LRU)
|
||||
rng = request.headers.get("range") if request else None
|
||||
if rng:
|
||||
r = _upstream(upstream.get_stream, url, timeout=30,
|
||||
headers={**(meta or {}), "Range": rng})
|
||||
ct = r.headers.get("content-type", "video/mp4")
|
||||
return StreamingResponse(
|
||||
_seg_stream(r, url, ct, cache=False),
|
||||
status_code=r.status_code, media_type=ct,
|
||||
headers={k: v for k, v in r.headers.items()
|
||||
if k.lower() in ("content-range", "accept-ranges")})
|
||||
# segment miss: try parallel byte-range lanes first (4 concurrent fetches
|
||||
# beat per-connection CDN throttling); ANY failure falls back to the
|
||||
# single streaming path, so this can never be worse than before
|
||||
@@ -692,7 +716,10 @@ def stream(token: str):
|
||||
name = url.rsplit("/", 1)[-1][:40]
|
||||
print(f"[stream-slow] {name} parallel total={time.time()-t0:.1f}s "
|
||||
f"bytes={len(body)}", flush=True)
|
||||
_cache_put(url, ct, body)
|
||||
# ponytail: 8MB cap — the LRU is for playlists/segments; a multi-hundred-MB
|
||||
# progressive file (1anime MP4) would evict everything and pin RAM
|
||||
if len(body) < 8 * 1024 * 1024:
|
||||
_cache_put(url, ct, body)
|
||||
return Response(body, media_type=ct, headers=_STREAM_CACHE_HDRS)
|
||||
|
||||
|
||||
@@ -700,8 +727,10 @@ def stream(token: str):
|
||||
@app.get("/index.html", include_in_schema=False)
|
||||
def index():
|
||||
# index.html is served (not mounted) so ?v={{VERSION}} becomes the git
|
||||
# SHA at startup; other statics go through the mount below
|
||||
return HTMLResponse(_INDEX_HTML.replace("{{VERSION}}", VERSION))
|
||||
# SHA and {{DONE_RATIO}} the watched threshold at startup; other statics
|
||||
# go through the mount below
|
||||
return HTMLResponse(_INDEX_HTML.replace("{{VERSION}}", VERSION)
|
||||
.replace("{{DONE_RATIO}}", repr(DONE_RATIO)))
|
||||
|
||||
|
||||
_INDEX_HTML = (ROOT / "static" / "index.html").read_text()
|
||||
|
||||
+53
-52
@@ -1,68 +1,69 @@
|
||||
"""auth.py — login throttling: per-username lockout + per-IP failure budget.
|
||||
"""auth.py — login throttling: per-username lockout + per-source-IP budget.
|
||||
|
||||
Pure logic, no app imports, so it unit-tests without booting the app
|
||||
(tests/test_auth.py). The 0.5s wrong-password sleep is exactly why app.py's
|
||||
login handler must stay a sync def: on the event loop it would stall every
|
||||
stream in flight; on the threadpool it only holds that one request.
|
||||
Lives outside app.py so it is unit-testable without booting the app
|
||||
(tests/test_auth.py). Both buckets are in-memory timestamp lists — the app
|
||||
is single-process by design, so no store is needed; every read prunes
|
||||
timestamps older than the window (sliding, not fixed).
|
||||
|
||||
Two in-memory layers (single process — same assumption as everything else):
|
||||
* per-username: 5 fails -> 15 min lockout for that name (was: the only
|
||||
layer, so spraying fails across many names bypassed it entirely)
|
||||
* per-IP: IP_FAILS fails inside IP_WINDOW -> that IP is refused for the
|
||||
rest of the window. Defaults are deliberately loose (15/300s): they
|
||||
exist to stop username spraying, not to punish typos.
|
||||
- per-username: FAILS_USER timestamps inside the window -> that account is
|
||||
locked, right or wrong password, until the newest fail ages out.
|
||||
- per-source-IP: IP_FAILS fails from one source across ANY usernames ->
|
||||
every login from that IP bounces. This is what username-rotation sweeps
|
||||
cannot step around.
|
||||
|
||||
Client IP is request.client.host. X-Forwarded-For is consulted ONLY when
|
||||
ANIWEB_TRUST_XFF=1 (run.sh sets it, because production traffic arrives via
|
||||
the local HTTPS proxy); a directly-connected client can spoof the header,
|
||||
so it is never a silent trust — without the flag client.host rules even if
|
||||
XFF is present.
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
|
||||
from . import users
|
||||
FAILS_USER = 5 # prod default
|
||||
WINDOW = float(os.environ.get("ANIWEB_LOCK_WINDOW", "900")) # seconds
|
||||
IP_FAILS = int(os.environ.get("ANIWEB_IP_FAILS", "20")) # prod default
|
||||
TRUST_XFF = os.environ.get("ANIWEB_TRUST_XFF") == "1"
|
||||
|
||||
LOCK_FAILS = 5
|
||||
LOCK_SECONDS = 900
|
||||
IP_FAILS = int(os.environ.get("ANIWEB_IP_FAILS", "15"))
|
||||
IP_WINDOW = float(os.environ.get("ANIWEB_IP_WINDOW", "300"))
|
||||
|
||||
_lockout: dict = {} # user -> {"fails": int, "until": float}
|
||||
_ip_fails: dict = {} # ip -> [failure ts] (newest last, capped)
|
||||
_user_fails: dict = {} # username -> [fail timestamps, newest last]
|
||||
_ip_fails: dict = {} # client ip -> [fail timestamps, newest last]
|
||||
|
||||
|
||||
def client_ip(request) -> str:
|
||||
"""Client IP. X-Forwarded-For is trusted ONLY when ANIWEB_TRUST_XFF=1
|
||||
(run.sh sets it because production is always behind the local HTTPS
|
||||
proxy); unconditional trust would let any client forge the header and
|
||||
dodge the per-IP budget."""
|
||||
if os.environ.get("ANIWEB_TRUST_XFF") == "1":
|
||||
xff = request.headers.get("x-forwarded-for", "")
|
||||
if xff:
|
||||
return xff.split(",", 1)[0].strip()
|
||||
return request.client.host if request.client else "?"
|
||||
"""Source of a login attempt: socket peer, or the proxy-reported client
|
||||
when ANIWEB_TRUST_XFF=1 (see module docstring — spoofable, hence gated)."""
|
||||
if TRUST_XFF:
|
||||
first = (request.headers.get("x-forwarded-for") or "").split(",", 1)[0].strip()
|
||||
if first:
|
||||
return first
|
||||
return request.client.host if request.client is not None else "?"
|
||||
|
||||
|
||||
def ip_fail(ip: str) -> None:
|
||||
_ip_fails[ip] = (_ip_fails.get(ip, []) + [time.time()])[-50:]
|
||||
def _fresh(ts_list, now: float):
|
||||
return [t for t in (ts_list or ()) if now - t < WINDOW]
|
||||
|
||||
|
||||
def ip_locked(ip: str) -> bool:
|
||||
now = time.time()
|
||||
ts = [t for t in _ip_fails.get(ip, ()) if now - t < IP_WINDOW]
|
||||
if ts:
|
||||
_ip_fails[ip] = ts
|
||||
return len(ts) >= IP_FAILS
|
||||
def locked(user: str, ip: str, now: float | None = None) -> bool:
|
||||
"""True when either bucket is over budget — caller must not verify."""
|
||||
now = time.time() if now is None else now
|
||||
return (len(_fresh(_user_fails.get(user), now)) >= FAILS_USER
|
||||
or len(_fresh(_ip_fails.get(ip), now)) >= IP_FAILS)
|
||||
|
||||
|
||||
def lock(user: str, pw: str, ip: str) -> str:
|
||||
"""-> "ok" | "bad" | "locked" (per-username lockout or per-IP budget)"""
|
||||
if ip_locked(ip):
|
||||
return "locked"
|
||||
lo = _lockout.setdefault(user, {"fails": 0, "until": 0.0})
|
||||
if time.time() < lo["until"]:
|
||||
return "locked"
|
||||
if users.verify(user, pw):
|
||||
lo.update(fails=0, until=0.0)
|
||||
return "ok"
|
||||
lo["fails"] += 1
|
||||
if lo["fails"] >= LOCK_FAILS:
|
||||
lo.update(fails=0, until=time.time() + LOCK_SECONDS)
|
||||
time.sleep(0.5) # slow down brute force (sync handler -> threadpool)
|
||||
ip_fail(ip)
|
||||
return "bad"
|
||||
def record(user: str, ip: str, now: float | None = None) -> None:
|
||||
"""Count one failed attempt against both buckets (pruning stale fails)."""
|
||||
now = time.time() if now is None else now
|
||||
_user_fails[user] = _fresh(_user_fails.get(user), now) + [now]
|
||||
_ip_fails[ip] = _fresh(_ip_fails.get(ip), now) + [now]
|
||||
|
||||
|
||||
def clear(user: str) -> None:
|
||||
"""A correct password resets only that username's count; the IP bucket
|
||||
keeps aging on its own (one success must not un-lock a sweep)."""
|
||||
_user_fails.pop(user, None)
|
||||
|
||||
|
||||
def reset() -> None:
|
||||
"""Test hook: drop all buckets."""
|
||||
_user_fails.clear()
|
||||
_ip_fails.clear()
|
||||
+3
-3
@@ -7,9 +7,9 @@ reconnect cost is zero. WAL: SQLite serializes writers itself, so the
|
||||
lost-update races of the JSON read-modify-write era are gone.
|
||||
|
||||
One-time import: a table that is empty while its legacy JSON still exists
|
||||
gets absorbed (users.json, history/*.json, searches/*.json). The JSON files
|
||||
are left in place as a last-known-good snapshot; the empty-table guard makes
|
||||
a second import impossible. Legacy ghost rows (same episode stored with int
|
||||
gets absorbed (users.json, history/*.json, searches/*.json); the files are
|
||||
deletable once the tables are populated (the empty-table guard makes a
|
||||
second import impossible). Legacy ghost rows (same episode stored with int
|
||||
AND str ep_id) are deduped on import: newest t per (user, slug, ep).
|
||||
"""
|
||||
import json
|
||||
|
||||
+13
-12
@@ -10,7 +10,7 @@ times until a CDN domain actually serves us.
|
||||
"""
|
||||
import re
|
||||
|
||||
from ..upstream import _cached, _get
|
||||
from ..upstream import _cached, _get, megaplay_sources
|
||||
|
||||
BASE = "https://anikototv.to"
|
||||
XHR = {"X-Requested-With": "XMLHttpRequest"}
|
||||
@@ -100,8 +100,8 @@ def _player_file(embed_url):
|
||||
if not m:
|
||||
return None
|
||||
host = re.match(r'https?://([^/]+)/', embed_url).group(1)
|
||||
d = _get(f"https://{host}/stream/getSourcesNew?id={m.group(1)}",
|
||||
headers={"Referer": embed_url, "X-Requested-With": "XMLHttpRequest"}).json()
|
||||
d = megaplay_sources(_get(f"https://{host}/stream/getSourcesNew?id={m.group(1)}",
|
||||
headers={"Referer": embed_url, "X-Requested-With": "XMLHttpRequest"}).json())
|
||||
return (d.get("sources") or {}).get("file")
|
||||
|
||||
|
||||
@@ -117,15 +117,16 @@ def _master_m3u8(ep_id, lang="eng"):
|
||||
url = (d.get("result") or {}).get("url")
|
||||
if not url:
|
||||
continue
|
||||
for _ in range(2): # re-roll: CDN domains rotate, most 403 our IP
|
||||
try:
|
||||
f = _player_file(url)
|
||||
if not f:
|
||||
break
|
||||
_get(f, timeout=10) # prove this CDN serves us before committing
|
||||
return f
|
||||
except Exception as ex:
|
||||
errs.append(str(ex)[:60])
|
||||
# ponytail: one attempt per server — the enc payload is deterministic,
|
||||
# re-rolling would just spam the provider
|
||||
try:
|
||||
f = _player_file(url)
|
||||
if not f:
|
||||
break
|
||||
_get(f, timeout=10) # prove this CDN serves us before committing
|
||||
return f
|
||||
except Exception as ex:
|
||||
errs.append(str(ex)[:60])
|
||||
raise RuntimeError("anikoto: no working server: " + "; ".join(errs[:3]))
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
"""providers/animekai.py — animekai.be source (hianime-family CMS, own ID space).
|
||||
|
||||
Each episode lists one or more servers, allocated per-episode across TWO
|
||||
backends, handled by separate _backend_* functions (add a third backend =
|
||||
add one branch in _master_m3u8):
|
||||
|
||||
- my.1anime.site -> direct progressive MP4 (<source> on the embed page).
|
||||
Independent CDN; its file host demands a Referer (403 without).
|
||||
- megaplay.buzz -> MegaPlay player, shared with hianime via
|
||||
upstream.megaplay_master. Same CDN family.
|
||||
|
||||
master_m3u8() prefers 1anime first (independent of the MegaPlay CDN), then
|
||||
megaplay. The returned "master" is just "the URL the player fetches":
|
||||
.m3u8 -> hls.js, anything else -> native <video> through the /stream
|
||||
proxy's Range path (app.py decides by suffix — providers stay agnostic).
|
||||
|
||||
Politeness: every endpoint rides the shared _cached TTL cache (search
|
||||
300s, details 3600s, episodes 300s — airing shows gain eps, sources
|
||||
3600s, master 3600s), so repeated UI requests cost the provider one fetch.
|
||||
"""
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from ..upstream import _cached, _get, megaplay_master
|
||||
|
||||
BASE = "https://animekai.be"
|
||||
RETRIES = {"sub": 300, "details": 3600, "episodes": 300, "sources": 3600, "master": 3600}
|
||||
|
||||
# ponytail: process-local slug<->id registry. details(slug) always precedes
|
||||
# episodes(anime_id) in the app flow (search -> details -> episodes), so an
|
||||
# id reaching episodes() was minted by this process's details(). A restart
|
||||
# orphans in-flight ids (user re-navigates) — same failure mode as any
|
||||
# source switch.
|
||||
_REG, _BYID = {}, {}
|
||||
_N = 0
|
||||
|
||||
# host -> Referer its file CDN demands; {} entry = allowed, no headers.
|
||||
# stream_meta() non-None is the /stream proxy's allowlist.
|
||||
STREAM_HOSTS = {}
|
||||
|
||||
|
||||
def _search(q):
|
||||
html = _get(f"{BASE}/browse", params={"keyword": q, "sort": "most_viewed"}, timeout=20).text
|
||||
out, seen = [], set()
|
||||
for m in re.finditer(
|
||||
r'<a class="title" href="[^"]*/watch/([^"?/]+)"[^>]*title="([^"]*)"', html):
|
||||
slug, name = m.group(1), m.group(2)
|
||||
if slug in seen:
|
||||
continue
|
||||
seen.add(slug)
|
||||
img = re.search(r'<img[^>]+src="([^"]+)"', html[max(0, m.start() - 400):m.end() + 200])
|
||||
out.append({"id": 0, "slug_id": slug, "name": name,
|
||||
"poster": img.group(1) if img else None})
|
||||
return out
|
||||
|
||||
|
||||
def _details(slug):
|
||||
t = _get(f"{BASE}/watch/{slug}", timeout=20).text
|
||||
global _N
|
||||
if slug not in _REG:
|
||||
_N += 1
|
||||
_REG[slug] = _N
|
||||
_BYID[_N] = slug
|
||||
title = re.search(r'property="og:title" content="([^"]+)"', t)
|
||||
name = re.sub(r'\s*Episode\s*\d+.*$', "", title.group(1)) if title else None
|
||||
name = re.sub(r'\s*[-|–]\s*AnimeKai\s*$', "", name or "").strip() or None
|
||||
post = re.search(r'property="og:image" content="([^"]+)"', t)
|
||||
syn = re.search(r'<div class="desc text-expand"[^>]*>([\s\S]*?)</div>', t)
|
||||
synopsis = re.sub(r"<[^>]+>", "", syn.group(1)).strip() if syn else None
|
||||
return {"id": _REG[slug], "name": name, "synopsis": synopsis,
|
||||
"mal_id": None,
|
||||
"poster": post.group(1) if post else None,
|
||||
"seasons": []}
|
||||
|
||||
|
||||
def _episodes(anime_id):
|
||||
slug = _BYID.get(anime_id)
|
||||
if slug is None:
|
||||
raise RuntimeError(f"animekai: unknown anime id {anime_id} (stale after restart?)")
|
||||
t = _get(f"{BASE}/watch/{slug}", timeout=20).text
|
||||
out, seen = [], set()
|
||||
for num in re.findall(rf'/watch/{re.escape(slug)}/ep-(\d+)"', t):
|
||||
n = int(num)
|
||||
if n in seen:
|
||||
continue
|
||||
seen.add(n)
|
||||
out.append({"id": anime_id * 1000 + n, "number": n, "filler": False})
|
||||
out.sort(key=lambda e: (isinstance(e["number"], str), e["number"]))
|
||||
return out
|
||||
|
||||
|
||||
def _sources(slug, num):
|
||||
d = _get(f"{BASE}/watch/{slug}/ep/{num}/sources", timeout=20).json()
|
||||
return d.get("sources") or {}
|
||||
|
||||
|
||||
_sources_cached = lambda slug, num: _cached(
|
||||
f"animekai:sources:{slug}:{num}", RETRIES["sources"], _sources, slug, num)
|
||||
|
||||
|
||||
def _languages(ep_id):
|
||||
anime_id, num = divmod(ep_id, 1000)
|
||||
slug = _BYID.get(anime_id)
|
||||
if slug is None:
|
||||
return []
|
||||
s = _sources_cached(slug, num)
|
||||
out = []
|
||||
if s.get("sub"):
|
||||
out.append({"code": "jpn", "name": "Sub", "embed_url": "sub"})
|
||||
if s.get("dub"):
|
||||
out.append({"code": "eng", "name": "Dub", "embed_url": "dub"})
|
||||
return out
|
||||
|
||||
|
||||
def _backend_oneanime(source_url):
|
||||
"""1anime embed -> direct MP4 url, or None. The file CDN 403s without a
|
||||
Referer, so the proof fetch doubles as the referer discovery."""
|
||||
page = _get(source_url, headers={"Referer": BASE + "/"}, timeout=15).text
|
||||
m = re.search(r'<source src="([^"]+)"', page)
|
||||
if not m:
|
||||
return None
|
||||
url = m.group(1)
|
||||
up = urlparse(source_url)
|
||||
host = urlparse(url).netloc
|
||||
# prove it serves us AND learn the referer the file host demands
|
||||
for refr in (f"{up.scheme}://{up.netloc}/", f"https://{host}/", None):
|
||||
try:
|
||||
_get(url, headers={"Range": "bytes=0-1023", **({"Referer": refr} if refr else {})},
|
||||
timeout=10)
|
||||
STREAM_HOSTS[host] = {"Referer": refr} if refr else {}
|
||||
return url
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _backend_megaplay(source_url):
|
||||
"""MegaPlay embed -> master m3u8 url, or None (shared chain)."""
|
||||
master, _subs, refr = megaplay_master(source_url, BASE + "/")
|
||||
if not master:
|
||||
return None
|
||||
host = urlparse(master).netloc
|
||||
STREAM_HOSTS[host] = {"Referer": refr} if refr else {}
|
||||
return master
|
||||
|
||||
|
||||
_BACKENDS = (("1anime", _backend_oneanime), ("megaplay", _backend_megaplay))
|
||||
|
||||
|
||||
def _master_m3u8(ep_id, lang="eng"):
|
||||
anime_id, num = divmod(ep_id, 1000)
|
||||
slug = _BYID.get(anime_id)
|
||||
if slug is None:
|
||||
raise RuntimeError(f"animekai: unknown anime id {anime_id} (stale after restart?)")
|
||||
s = _sources_cached(slug, num)
|
||||
want = "sub" if lang in ("jpn", "sub") else "dub"
|
||||
# 1anime first: independent CDN, so it rides through MegaPlay outages
|
||||
for mode in ((want, "sub") if want == "dub" else (want,)):
|
||||
servers = s.get(mode) or []
|
||||
if not servers:
|
||||
continue
|
||||
for _name, url in ((x.get("server_name"), x.get("source_url")) for x in servers):
|
||||
for _bname, backend in _BACKENDS:
|
||||
if url and _bname in url:
|
||||
try:
|
||||
out = backend(url)
|
||||
except Exception:
|
||||
out = None
|
||||
if out:
|
||||
return out
|
||||
# distinguish "no servers listed" (legit None = no dub) from "listed but
|
||||
# dead" (raise -> ANI_SOURCE fallback), same semantics as hianime
|
||||
if s.get(want) or (want == "dub" and s.get("sub")):
|
||||
raise RuntimeError(f"animekai: ep {num} servers listed but none resolved")
|
||||
return None
|
||||
|
||||
|
||||
# Provider interface. Cache keys namespaced "animekai:"; TTLs keep the
|
||||
# provider from seeing the same request twice (see module docstring).
|
||||
search = lambda q: _cached(f"animekai:search:{q.lower()}", RETRIES["sub"], _search, q)
|
||||
details = lambda s: _cached(f"animekai:details:{s}", RETRIES["details"], _details, s)
|
||||
episodes = lambda i: _cached(f"animekai:episodes:{i}", RETRIES["episodes"], _episodes, i)
|
||||
languages = lambda e: _cached(f"animekai:languages:{e}", RETRIES["sources"], _languages, e)
|
||||
master_m3u8 = lambda e, l="eng": _cached(f"animekai:master:{e}:{l}", RETRIES["master"], _master_m3u8, e, l)
|
||||
|
||||
|
||||
def stream_meta(url):
|
||||
"""{Referer} (or {}) for our own file hosts, else None — non-None
|
||||
doubles as the /stream allowlist."""
|
||||
return STREAM_HOSTS.get(urlparse(url).netloc)
|
||||
|
||||
|
||||
def subtitles(ep_id):
|
||||
# 1anime MP4s carry no separate subs; megaplay subs are in the tracks
|
||||
# the player bakes in — nothing external to hand the frontend
|
||||
return None
|
||||
+17
-31
@@ -13,7 +13,7 @@ import json
|
||||
import re
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
from ..upstream import _cached, _get
|
||||
from ..upstream import _cached, _get, megaplay_master
|
||||
|
||||
BASE = "https://hianime.at"
|
||||
KEY = b"otaku-embed-v1"
|
||||
@@ -88,9 +88,8 @@ def _embed_payload(embed):
|
||||
"""embed url -> (master_m3u8, [{label, src} subs], referer_or_None).
|
||||
|
||||
Two player chains have appeared: zokoanime (window.__P blob,
|
||||
base64+XOR) and megaplay (page needs the hianime referer; data-id ->
|
||||
getSourcesNew; CDN domains rotate and most 403 us, so re-roll until
|
||||
one serves)."""
|
||||
base64+XOR — hianime-specific) and megaplay (shared: upstream.
|
||||
megaplay_master, used by animekai too)."""
|
||||
page = _get(embed, timeout=15).text
|
||||
blob = re.search(r'window\.__P="([^"]+)"', page)
|
||||
if blob:
|
||||
@@ -98,29 +97,7 @@ def _embed_payload(embed):
|
||||
subs = [{"label": (x or {}).get("label", ""), "src": (x or {}).get("src")}
|
||||
for x in j.get("subtitles") or []]
|
||||
return j.get("src"), subs, re.match(r'https?://[^/]+', embed).group(0) + "/"
|
||||
# megaplay: without the hianime referer the page is an error stub with no data-id
|
||||
if not re.search(r'data-id="\d+"', page):
|
||||
page = _get(embed, headers={"Referer": BASE + "/"}, timeout=15).text
|
||||
mid = re.search(r'data-id="(\d+)"', page)
|
||||
if not mid:
|
||||
return None, [], None
|
||||
host = re.match(r'https?://([^/]+)', embed).group(1)
|
||||
for _ in range(5):
|
||||
d = _get(f"https://{host}/stream/getSourcesNew?id={mid.group(1)}",
|
||||
headers={"Referer": embed, "X-Requested-With": "XMLHttpRequest"},
|
||||
timeout=15).json()
|
||||
src = (d.get("sources") or {}).get("file")
|
||||
if not src:
|
||||
continue
|
||||
subs = [{"label": (x or {}).get("label", ""), "src": (x or {}).get("file")}
|
||||
for x in d.get("tracks") or []]
|
||||
for cand, refr in (({"Referer": embed}, embed), ({}, None)):
|
||||
try:
|
||||
_get(src, headers=cand, timeout=10)
|
||||
return src, subs, refr
|
||||
except Exception:
|
||||
continue
|
||||
return None, [], None
|
||||
return megaplay_master(embed, BASE + "/")
|
||||
|
||||
|
||||
def _master_m3u8(ep_id, lang="eng"):
|
||||
@@ -129,13 +106,18 @@ def _master_m3u8(ep_id, lang="eng"):
|
||||
want = "sub" if lang in ("jpn", "sub") else "dub"
|
||||
# HD-1 has rotated between player chains (zokoanime blob, megaplay);
|
||||
# try every listed server of the wanted type until one resolves
|
||||
errs = []
|
||||
for typ in (want, "sub") if want == "dub" else (want,):
|
||||
for _name, h in re.findall(
|
||||
for name, h in re.findall(
|
||||
rf'data-type="{typ}"[\s\S]*?data-server-name="([^"]+)"'
|
||||
rf'[\s\S]*?data-hash="([^"]+)"', t):
|
||||
embed = base64.b64decode(h).decode()
|
||||
master, subs, refr = _embed_payload(embed)
|
||||
try:
|
||||
master, subs, refr = _embed_payload(base64.b64decode(h).decode())
|
||||
except Exception as e:
|
||||
errs.append(f"{name}: {e}")
|
||||
continue
|
||||
if not master:
|
||||
errs.append(f"{name}: no source")
|
||||
continue
|
||||
REFERER = refr
|
||||
# prove the CDN serves us and register the hosts so the /stream proxy
|
||||
@@ -154,7 +136,11 @@ def _master_m3u8(ep_id, lang="eng"):
|
||||
if pick and pick.get("src"):
|
||||
SUBS[ep_id] = pick["src"]
|
||||
return master
|
||||
return None
|
||||
if not errs:
|
||||
return None # no servers listed at all: a legitimate "no dub"
|
||||
# listed but unresolvable (403s, stale player format) is a provider fault,
|
||||
# not "no dub" — raise so the ANI_SOURCE fallback chain tries the next source
|
||||
raise RuntimeError("hianime: no working server: " + "; ".join(errs[:3]))
|
||||
|
||||
|
||||
# Provider interface. Cache keys are namespaced with "hianime:".
|
||||
|
||||
@@ -17,12 +17,15 @@ Shared HTTP helpers (_get/get_stream/fetch_parallel, Chrome TLS
|
||||
impersonation, TTL cache) live here so every provider gets them for free;
|
||||
they work on any URL, not just anidb's.
|
||||
"""
|
||||
import base64
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
|
||||
from Crypto.Cipher import AES
|
||||
from curl_cffi import requests
|
||||
|
||||
H = dict(impersonate="chrome", timeout=20)
|
||||
@@ -55,6 +58,59 @@ def _cached(key, ttl, fn, *a, **kw):
|
||||
def clear_cache():
|
||||
_CACHE.clear()
|
||||
|
||||
# MegaPlay's getSourcesNew hid the stream URL in an AES-256-CBC "enc" field.
|
||||
# Key/IV are static and hardcoded in their public player JS (newclient.min.js)
|
||||
# — anti-scrape theater, not access control. ponytail: breaks if they rotate
|
||||
# the key; upgrade path = re-read newclient.min.js (or let it fail: both
|
||||
# shapes are tolerated below, so the old code path still works if reverted).
|
||||
_MP_KEY = b"i?LMTAx0Q6,:}50U".ljust(32, b"\x00")
|
||||
_MP_IV = b"W0;27ToaUpl_P%'c"
|
||||
|
||||
def megaplay_sources(d):
|
||||
"""getSourcesNew response -> same dict with sources["file"] filled.
|
||||
Old players return it plaintext; current ones base64url+AES-wrap it in
|
||||
"enc" (decrypted payload is {"file": ...})."""
|
||||
if not (d.get("sources") or {}).get("file") and d.get("enc"):
|
||||
raw = base64.urlsafe_b64decode(d["enc"] + "=" * (-len(d["enc"]) % 4))
|
||||
pt = AES.new(_MP_KEY, AES.MODE_CBC, _MP_IV).decrypt(raw)
|
||||
j = json.loads(pt[:-pt[-1]]) # strip PKCS7
|
||||
if j.get("file"):
|
||||
d = {**d, "sources": {"file": j["file"]}}
|
||||
return d
|
||||
|
||||
|
||||
def megaplay_master(embed, referer):
|
||||
"""MegaPlay embed url -> (master_url, [{label, src} subs], referer_or_None).
|
||||
|
||||
Shared by every provider fronting the MegaPlay player (hianime,
|
||||
animekai): embed page (referer-gated) -> data-id -> getSourcesNew
|
||||
(megaplay_sources) -> prove the CDN serves us, remembering which
|
||||
Referer variant worked. referer = the front site's home (the embed
|
||||
page 403s a stub without it). One attempt: the enc payload is
|
||||
deterministic per data-id, re-rolling would just spam the provider.
|
||||
The caller registers the returned master's hosts in its own
|
||||
/stream allowlist."""
|
||||
page = _get(embed, headers={"Referer": referer}, timeout=15).text
|
||||
mid = re.search(r'data-id="(\d+)"', page)
|
||||
if not mid:
|
||||
return None, [], None
|
||||
host = re.match(r'https?://([^/]+)', embed).group(1)
|
||||
d = megaplay_sources(_get(f"https://{host}/stream/getSourcesNew?id={mid.group(1)}",
|
||||
headers={"Referer": embed, "X-Requested-With": "XMLHttpRequest"},
|
||||
timeout=15).json())
|
||||
src = (d.get("sources") or {}).get("file")
|
||||
if not src:
|
||||
return None, [], None
|
||||
subs = [{"label": (x or {}).get("label", ""), "src": (x or {}).get("file")}
|
||||
for x in d.get("tracks") or []]
|
||||
for cand, refr in (({"Referer": embed}, embed), ({}, None)):
|
||||
try:
|
||||
_get(src, headers=cand, timeout=10)
|
||||
return src, subs, refr
|
||||
except Exception:
|
||||
continue
|
||||
return None, [], None
|
||||
|
||||
|
||||
def _get(url, **kw):
|
||||
# ponytail: one retry (300ms) on transient upstream errors (Cloudflare
|
||||
|
||||
+237
-46
@@ -10,6 +10,9 @@ const BASE = (() => {
|
||||
return p.endsWith("/") ? p : p.slice(0, p.lastIndexOf("/") + 1);
|
||||
})();
|
||||
const main = document.getElementById("main");
|
||||
// Watched threshold (fraction of duration) — injected inline by the server
|
||||
// from DONE_RATIO in server/app.py; fallback matches that default.
|
||||
const DONE_RATIO = Number(window.DONE_RATIO ?? 0.95);
|
||||
let hls = null, detail = null, lang = "jpn", seasonId = null;
|
||||
|
||||
async function api(path) {
|
||||
@@ -127,7 +130,53 @@ const bar = (x, cls) => {
|
||||
b.append(Object.assign(el("div", "bar-fill"), {style: `width:${p}%`}));
|
||||
return b;
|
||||
};
|
||||
const histHref = x => `?anime=${x.slug_id}&season=${x.season_id}&ep=${x.ep_id}&lang=${x.lang}`;
|
||||
function histHref(x) { return `?anime=${x.slug_id}&season=${x.season_id}&ep=${x.ep_id}&lang=${x.lang}`; }
|
||||
|
||||
// ---------- stale-entry remap (provider switch) ----------
|
||||
// History rows written while anidb was the source carry anidb slugs/ids. Under
|
||||
// hianime-first dispatch, /watch/{anidb-slug} often 200s as a DIFFERENT anime
|
||||
// (id 2552 = Burning Kabaddi) while the anidb ep id silently dies (not in that
|
||||
// anime's list) — the row then links to the wrong show. Validate every row
|
||||
// against the details endpoint; mismatch or dead slug -> remap board.
|
||||
const _norm = s => String(s || "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||
const _match = (a, b) => {
|
||||
const x = _norm(a), y = _norm(b);
|
||||
return !!(x && y && (x === y || x.includes(y) || y.includes(x)));
|
||||
};
|
||||
const _remap = {};
|
||||
async function checkEntry(x) {
|
||||
if (x._remapped) return true;
|
||||
if (_remap[x.slug_id] !== undefined) { x._remapped = _remap[x.slug_id]; return !_remap[x.slug_id]; }
|
||||
let d;
|
||||
try { d = await api(`/api/anime/${x.slug_id}`); } catch { d = null; }
|
||||
const ok = !!(d && d.name && _match(d.name, x.title));
|
||||
_remap[x.slug_id] = !ok;
|
||||
x._remapped = !ok;
|
||||
return ok;
|
||||
}
|
||||
function remapCard(x) {
|
||||
const c = el("a", "card hist remap");
|
||||
c.append(poster(x.poster, x.title),
|
||||
el("div", "card-name", `"${x.title}" \u2014 not found on the current source`));
|
||||
const row = el("div", "chips");
|
||||
const go = el("button", "tiny", "find it \u2192");
|
||||
go.onclick = e => { e.preventDefault(); e.stopPropagation(); location.href = "?q=" + encodeURIComponent(x.title || ""); };
|
||||
const del = el("button", "del", "\u00d7");
|
||||
del.title = "remove from history";
|
||||
del.onclick = async e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
await fetch(BASE + "api/history?slug_id=" + encodeURIComponent(x.slug_id) +
|
||||
"&ep_id=" + encodeURIComponent(x.ep_id), {method: "DELETE", headers: {"X-Aniweb": "1"}});
|
||||
c.remove();
|
||||
const i = history.indexOf(x);
|
||||
if (i >= 0) history.splice(i, 1);
|
||||
toast("Removed from history", {label: "Undo", fn: () => postHistory(x)});
|
||||
};
|
||||
row.append(go, del);
|
||||
c.append(row);
|
||||
return c;
|
||||
}
|
||||
// one entry per anime (latest row wins), newest first
|
||||
const byShow = () => {
|
||||
const m = new Map();
|
||||
@@ -144,7 +193,7 @@ const homeCard = x => {
|
||||
c.append(poster(x.poster, x.title));
|
||||
let meta = `E${x.ep} \u00b7 ${x.lang === "eng" ? "dub" : "sub"}`;
|
||||
if (done) meta += " \u00b7 \u2713";
|
||||
else if (x.dur > 0 && x.pos < x.dur * 0.95) meta += ` \u00b7 ${fmtMin((x.dur - x.pos) / 60)} left`;
|
||||
else if (x.dur > 0 && x.pos < x.dur * DONE_RATIO) meta += ` \u00b7 ${fmtMin((x.dur - x.pos) / 60)} left`;
|
||||
else if (hold) meta += ` \u00b7 ${Math.round(Date.now() / 1000 / 86400 - x.t / 86400)}d ago`;
|
||||
const info = el("div", "mini-info");
|
||||
info.append(el("div", "card-name", x.title), el("div", "mini-meta", meta));
|
||||
@@ -189,6 +238,8 @@ function showHome() {
|
||||
return;
|
||||
}
|
||||
const hero = history[0];
|
||||
main.append(remapBoard());
|
||||
if (hero && hero._remapped) return;
|
||||
// 1. continue-watching hero
|
||||
const sec = el("section", "hero-sec");
|
||||
const card = el("a", "hero");
|
||||
@@ -202,11 +253,11 @@ function showHome() {
|
||||
body.append(el("div", "hero-kicker", "continue watching"),
|
||||
el("h2", null, hero.title),
|
||||
el("div", "muted", `Ep ${hero.ep} · ${hero.lang === "eng" ? "dub" : "sub"} · ${fmtPos(hero.pos)} in` +
|
||||
(hero.dur > 0 && hero.pos < hero.dur * 0.95 ? ` · ${fmtMin((hero.dur - hero.pos) / 60)} left` : "") +
|
||||
(hero.dur > 0 && hero.pos < hero.dur * DONE_RATIO ? ` · ${fmtMin((hero.dur - hero.pos) / 60)} left` : "") +
|
||||
` · ${timeago(hero.t)}`));
|
||||
const p = bar(hero);
|
||||
if (p) body.append(p);
|
||||
const heroDone = hero.dur > 0 && hero.pos >= hero.dur * 0.95;
|
||||
const heroDone = hero.dur > 0 && hero.pos >= hero.dur * DONE_RATIO;
|
||||
const go = el("button", "btn resume", heroDone ? "\u25b6 Next ep" : "\u25b6 Resume");
|
||||
go.onclick = () => location.href = histHref(hero);
|
||||
right.append(body, go);
|
||||
@@ -398,6 +449,22 @@ async function showAnime(slug_id, opts = {}) {
|
||||
anB.classList.toggle("on", autoNext);
|
||||
};
|
||||
const ctl = [langBtn, spd, prevB, nextB, fillB, jump, anB];
|
||||
const fsB = el("button", "tiny", "\u26f6 full");
|
||||
fsB.title = "fullscreen";
|
||||
fsB.onclick = () => {
|
||||
const v = document.querySelector("#player video");
|
||||
if (!v) return;
|
||||
if (document.fullscreenElement || document.webkitFullscreenElement) {
|
||||
(document.exitFullscreen || document.webkitExitFullscreen).call(document);
|
||||
return;
|
||||
}
|
||||
// native Fullscreen API: the video fills the window (and scales with it);
|
||||
// iOS Safari has no element fullscreen, so fall back to its in-player one
|
||||
if (v.requestFullscreen) v.requestFullscreen();
|
||||
else if (v.webkitEnterFullscreen) v.webkitEnterFullscreen();
|
||||
else if (v.webkitRequestFullscreen) v.webkitRequestFullscreen();
|
||||
};
|
||||
ctl.push(fsB);
|
||||
if (document.pictureInPictureEnabled) {
|
||||
const pipB = el("button", "tiny", "\u29c9 PiP");
|
||||
pipB.title = "picture-in-picture";
|
||||
@@ -439,7 +506,7 @@ async function showAnime(slug_id, opts = {}) {
|
||||
for (const x of await api("/api/history?all=1")) {
|
||||
if (x.slug_id !== slug_id || x.season_id !== seasonId) continue;
|
||||
started = true;
|
||||
if (x.dur > 0 && x.pos >= x.dur * 0.95) watchDone.add(String(x.ep_id));
|
||||
if (x.dur > 0 && x.pos >= x.dur * DONE_RATIO) watchDone.add(String(x.ep_id));
|
||||
else if (x.dur > 0) epProg.set(String(x.ep_id), Math.min(100, Math.round(100 * x.pos / x.dur)));
|
||||
}
|
||||
} catch {}
|
||||
@@ -465,8 +532,24 @@ async function showAnime(slug_id, opts = {}) {
|
||||
function langLabel() { return lang === "eng" ? "▶ dub" : "▶ sub"; }
|
||||
|
||||
// ---------- history page (full list + delete + client-side filter) ----------
|
||||
const histClass = x => x.dur > 0 && x.pos >= x.dur * 0.95 ? "done"
|
||||
const histClass = x => x.dur > 0 && x.pos >= x.dur * DONE_RATIO ? "done"
|
||||
: (x.t && Date.now() / 1000 - x.t > 7 * 86400 ? "hold" : "watching");
|
||||
function remapBoard() {
|
||||
// home: one stale card per stale anime (latest row), above the hero, so a
|
||||
// switched-source user sees the remap CTA before the wrong-show hero
|
||||
const m = new Map();
|
||||
for (const x of history) if (x._remapped && (!m.has(x.slug_id) || x.t > m.get(x.slug_id).t)) m.set(x.slug_id, x);
|
||||
const out = [];
|
||||
if (m.size) {
|
||||
out.push(el("div", "sec-head").append(
|
||||
el("h2", null, "Moved or renamed on the source \u2014 pick the right one"),
|
||||
link("history \u2192", "?history")));
|
||||
const grid = el("div", "grid mini");
|
||||
for (const x of m.values()) grid.append(remapCard(x));
|
||||
out.push(grid);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
async function showHistory() {
|
||||
document.title = "History \u2014 ani-web";
|
||||
skel(10);
|
||||
@@ -474,6 +557,7 @@ async function showHistory() {
|
||||
main.innerHTML = "";
|
||||
main.append(el("h2", null, "History"));
|
||||
if (!items.length) { main.append(el("p", "muted", "Nothing here.")); focusMain(); return; }
|
||||
await Promise.all(items.map(checkEntry));
|
||||
main.append(...histBoard(items));
|
||||
focusMain();
|
||||
}
|
||||
@@ -576,12 +660,14 @@ function topAnime(d, n = 0) {
|
||||
return ta;
|
||||
}
|
||||
function histBoard(items, limit = 0) {
|
||||
const good = items.filter(x => !x._remapped);
|
||||
const stale = items.filter(x => x._remapped);
|
||||
const labels = {all: "all", watching: "watching", done: "completed", hold: "on hold"};
|
||||
let active = "all";
|
||||
const chips = el("div", "chips");
|
||||
const grid = el("div", "grid");
|
||||
const draw = () => {
|
||||
let f = items.filter(x => active === "all" || histClass(x) === active);
|
||||
let f = good.filter(x => active === "all" || histClass(x) === active);
|
||||
if (limit) f = f.slice(0, limit);
|
||||
grid.replaceChildren(...f.map(histCard));
|
||||
for (const c of chips.children) c.classList.toggle("on", c.dataset.f === active);
|
||||
@@ -593,7 +679,15 @@ function histBoard(items, limit = 0) {
|
||||
chips.append(b);
|
||||
}
|
||||
draw();
|
||||
return [chips, grid];
|
||||
const out = [chips, grid];
|
||||
if (stale.length) {
|
||||
out.push(el("div", "sec-head").append(
|
||||
el("h2", null, "Moved or renamed on the source \u2014 pick the right one")));
|
||||
const sgrid = el("div", "grid mini");
|
||||
for (const x of stale) sgrid.append(remapCard(x));
|
||||
out.push(sgrid);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function heatMap(byDay, days) {
|
||||
@@ -791,7 +885,17 @@ function playerFail(msg) {
|
||||
savePos(lastSeenPos);
|
||||
const { ep_id, ep_no } = currentEp;
|
||||
postEvent({type: "error", detail: msg, ep_id, ep: ep_no, lang, ua: navigator.userAgent});
|
||||
playerOverlay("Playback failed: " + msg, () => play(ep_id, ep_no));
|
||||
playerOverlay("Playback failed: " + msg + cdnContext(), () => play(ep_id, ep_no));
|
||||
}
|
||||
|
||||
function cdnContext() {
|
||||
// one-line "why" on the failure overlay: playback failing while the CDN
|
||||
// or upstream is bad is probably the network, not the browser
|
||||
if (!_status) return "";
|
||||
const bits = [];
|
||||
if (_status.state !== "OK") bits.push("CDN " + _status.state + " (" + _status.kbps + " KB/s)");
|
||||
if (_status.origin.state !== "OK") bits.push("upstream " + _status.origin.state);
|
||||
return bits.length ? " · " + bits.join(", ") : "";
|
||||
}
|
||||
|
||||
// ---------- buffering visuals: buffer-ahead gauge + CDN chip ----------
|
||||
@@ -819,46 +923,128 @@ function bufferGauge() {
|
||||
bufTimer = setInterval(tick, 250);
|
||||
}
|
||||
|
||||
// ---------- status pill + panel: the ONE CDN/upstream status surface ----------
|
||||
// The header pill (dots + short label) is visible on every screen — if the
|
||||
// upstream is down you never reach the player, so status lives in the
|
||||
// (sticky) header. Tap expands a panel with detail + the ~1h trend from
|
||||
// /api/cdn's hist. The buffer gauge under the video is CLIENT buffer state,
|
||||
// deliberately not mixed in here.
|
||||
let _status = null;
|
||||
|
||||
function statusPill() {
|
||||
// header badge: upstream site + CDN status from /api/cdn. Lives in the
|
||||
// (sticky) header so it's visible on the home screen — if the upstream is
|
||||
// down you never reach the player, so that's where the status must live.
|
||||
api("/api/cdn").then(d => {
|
||||
_status = d;
|
||||
renderPill(d);
|
||||
if (document.getElementById("status-panel")) renderStatusPanel(d);
|
||||
}).catch(() => {
|
||||
const p = document.getElementById("pill");
|
||||
if (p) p.querySelector("span").textContent = "?";
|
||||
});
|
||||
}
|
||||
|
||||
function renderPill(d) {
|
||||
// ONE dot + one word: the at-a-glance question is "can I watch?", which is
|
||||
// one state — down (origin dead) > slow (CDN degraded) > up. Per-host
|
||||
// detail belongs in the tap panel, not here.
|
||||
const p = document.getElementById("pill");
|
||||
if (!p) return;
|
||||
api("/api/cdn").then(d => {
|
||||
const o = d.origin.state === "OK" ? "ok" : d.origin.state === "DOWN" ? "down" : "";
|
||||
const c = d.state === "OK" ? "ok" : d.state === "DEGRADED" ? "degraded" : "";
|
||||
p.className = "pill" + (o === "down" ? " odown" : "");
|
||||
const od = p.querySelector(".o"), cd = p.querySelector(".c");
|
||||
od.className = "o " + o;
|
||||
cd.className = "c " + c;
|
||||
p.querySelector("span").textContent =
|
||||
o === "down" ? "upstream down" : c === "degraded" ? "cdn slow" : "up";
|
||||
// per-dot hover tooltips (desktop) + full aria label (a11y / screen readers)
|
||||
od.title = "upstream site (anidb.app pages + API): " + d.origin.state
|
||||
+ (d.origin.msg ? " — " + d.origin.msg : "");
|
||||
cd.title = "video CDN (hls.anidb.app) throughput: " + d.state
|
||||
+ " — " + d.kbps + " KB/s";
|
||||
p.title = ""; // dot-level titles carry the detail; pill-level would shadow them
|
||||
p.setAttribute("aria-label", "upstream site " + d.origin.state
|
||||
+ (d.origin.msg ? ": " + d.origin.msg : "")
|
||||
+ "; CDN " + d.state + ", " + d.kbps + " KB/s");
|
||||
// tap/click = tooltip for touch screens (mobile has no hover);
|
||||
// read p._data at click time so the toast reflects the LATEST status,
|
||||
// not the one captured when the handler was first bound
|
||||
if (!p._bound) {
|
||||
p._bound = 1;
|
||||
p.onclick = e => {
|
||||
e.stopPropagation();
|
||||
const d = p._data;
|
||||
toast("upstream site: " + d.origin.state + (d.origin.msg ? " (" + d.origin.msg + ")" : "")
|
||||
+ " · CDN: " + d.state + " (" + d.kbps + " KB/s)");
|
||||
};
|
||||
}
|
||||
p._data = d;
|
||||
}).catch(() => { p.querySelector("span").textContent = "?"; });
|
||||
const known = (d.origin.state === "OK" || d.origin.state === "DOWN")
|
||||
&& (d.state === "OK" || d.state === "DEGRADED");
|
||||
const down = d.origin.state === "DOWN";
|
||||
const slow = !down && d.state === "DEGRADED";
|
||||
const st = !known ? "" : down ? "down" : slow ? "slow" : "ok";
|
||||
p.className = "pill" + (st === "down" ? " down" : "");
|
||||
p.querySelector(".d").className = "d" + (st ? " " + st : "");
|
||||
p.querySelector("span").textContent =
|
||||
!st ? "…" : st === "down" ? "down"
|
||||
: st === "slow" ? "slow" + (d.kbps ? " · " + d.kbps + " KB/s" : "") : "up";
|
||||
p.title = "upstream site: " + d.origin.state + (d.origin.msg ? " — " + d.origin.msg : "")
|
||||
+ " · video CDN: " + d.state + (d.kbps ? " — " + d.kbps + " KB/s" : "");
|
||||
p.setAttribute("aria-label", "status: " + (!st ? "checking"
|
||||
: st === "down" ? "down — upstream " + d.origin.state
|
||||
: st === "slow" ? "slow — CDN " + d.state + (d.kbps ? ", " + d.kbps + " KB/s" : "")
|
||||
: "up"));
|
||||
}
|
||||
|
||||
function toggleStatusPanel() {
|
||||
const open = document.getElementById("status-panel");
|
||||
if (open) { open.remove(); return; }
|
||||
const w = el("div", "status-panel");
|
||||
w.id = "status-panel";
|
||||
document.getElementById("pill").append(w);
|
||||
if (_status) renderStatusPanel(_status);
|
||||
else { w.append(el("div", "sp-row", "fetching…")); statusPill(); }
|
||||
}
|
||||
|
||||
function renderStatusPanel(d) {
|
||||
const w = document.getElementById("status-panel");
|
||||
if (!w) return;
|
||||
w.replaceChildren();
|
||||
w.append(el("div", "sp-row", "upstream: " + d.origin.state
|
||||
+ (d.origin.msg ? " (" + d.origin.msg + ")" : "")));
|
||||
w.append(el("div", "sp-row", "CDN: " + d.state + (d.kbps ? " — " + d.kbps + " KB/s" : "")));
|
||||
const ch = d.hist || [];
|
||||
if (ch.length > 1) {
|
||||
w.append(sparkline(ch.map(s => s.kbps), d.kbps ? d.threshold : null));
|
||||
w.append(el("div", "sp-cap", "CDN, last " + _mins(ch) + " min"
|
||||
+ (d.kbps ? " (dashed = slow threshold " + Math.round(d.threshold) + " KB/s)" : "")));
|
||||
}
|
||||
const oh = d.origin.hist || [];
|
||||
if (oh.length > 1) {
|
||||
const up = oh[oh.length - 1].state === "OK";
|
||||
w.append(sparkline(oh.map(s => (s.state === "OK" ? 1 : 0)), null, true, up));
|
||||
w.append(el("div", "sp-cap", "upstream, last " + _mins(oh) + " min (flat bottom = down)"));
|
||||
}
|
||||
const age = Math.max(0, Math.round((Date.now() / 1000 - d.ts) / 60));
|
||||
w.append(el("div", "sp-cap", "updated " + age + " min ago"));
|
||||
}
|
||||
|
||||
function _mins(h) { return Math.round((h[h.length - 1].t - h[0].t) / 60); }
|
||||
|
||||
function sparkline(values, threshold, binary = false, healthy = true) {
|
||||
// ~20 samples (3-min spacing) -> one inline-SVG polyline, scaled so a
|
||||
// dashed threshold line (when given) always fits. binary = up(1)/down(0)
|
||||
// step line for the origin probe. No lib: it's a single polyline.
|
||||
const W = 120, H = 28, PAD = 2;
|
||||
const max = Math.max(Math.max(...values), threshold || 0, binary ? 1 : 0) || 1;
|
||||
const NS = "http://www.w3.org/2000/svg";
|
||||
const svg = document.createElementNS(NS, "svg");
|
||||
svg.setAttribute("viewBox", "0 0 " + W + " " + H);
|
||||
svg.setAttribute("class", "sp-svg" + (binary && !healthy ? " bad" : ""));
|
||||
svg.setAttribute("role", "img");
|
||||
svg.setAttribute("aria-label", binary ? "upstream up/down, last hour"
|
||||
: "CDN throughput trend, last hour");
|
||||
if (threshold) {
|
||||
const ty = Math.max(PAD, H - PAD - Math.min(threshold / max, 1) * (H - 2 * PAD));
|
||||
const l = document.createElementNS(NS, "line");
|
||||
l.setAttribute("x1", 0); l.setAttribute("x2", W);
|
||||
l.setAttribute("y1", ty.toFixed(1)); l.setAttribute("y2", ty.toFixed(1));
|
||||
l.setAttribute("class", "sp-thr");
|
||||
svg.append(l);
|
||||
}
|
||||
const pts = [];
|
||||
values.forEach((v, i) => {
|
||||
const x0 = (i / (values.length - 1)) * W;
|
||||
const y = H - PAD - Math.min(v / max, 1) * (H - 2 * PAD);
|
||||
pts.push(x0.toFixed(1) + "," + y.toFixed(1));
|
||||
if (binary) pts.push(((i + 1) / (values.length - 1) * W).toFixed(1) + "," + y.toFixed(1));
|
||||
});
|
||||
const pl = document.createElementNS(NS, "polyline");
|
||||
pl.setAttribute("points", pts.join(" "));
|
||||
svg.append(pl);
|
||||
return svg;
|
||||
}
|
||||
|
||||
// pill = tap-to-toggle (mobile has no hover); re-tap or click-away closes.
|
||||
// 60s poll while visible: the probe runs every 3 min and this endpoint is
|
||||
// in-memory + local, so a fresh render is free.
|
||||
document.getElementById("pill").onclick = e => { e.stopPropagation(); toggleStatusPanel(); };
|
||||
document.addEventListener("click", () => {
|
||||
const w = document.getElementById("status-panel");
|
||||
if (w) w.remove();
|
||||
});
|
||||
setInterval(() => { if (document.visibilityState === "visible") statusPill(); }, 60000);
|
||||
|
||||
// ---------- auto-next on episode end (5s countdown) ----------
|
||||
let nextTimer = null;
|
||||
function onEnded() {
|
||||
@@ -952,7 +1138,11 @@ async function play(ep_id, ep_no) {
|
||||
branch: (!window.Hls || !Hls.isSupported()) ? (nativeOK !== "none" && nativeOK !== "" ? "native" : "none-throw") : "hlsjs",
|
||||
hls_lib: typeof window.Hls, mse, native_hls: nativeOK, ua: navigator.userAgent});
|
||||
} catch {}
|
||||
if (!window.Hls || !Hls.isSupported()) {
|
||||
if (s.hls === false) {
|
||||
// progressive file (1anime's direct MP4): native <video>; the /stream
|
||||
// proxy forwards the browser's Range requests, so seeking works
|
||||
v.src = "/stream/" + s.token;
|
||||
} else if (!window.Hls || !Hls.isSupported()) {
|
||||
// No MSE (e.g. iOS < 17): fall back to the browser's NATIVE HLS player.
|
||||
// iOS Safari plays standard m3u8 in <video> directly — same proxied URL,
|
||||
// same-origin, so no CORS needed.
|
||||
@@ -1032,7 +1222,7 @@ async function play(ep_id, ep_no) {
|
||||
}
|
||||
if (saved?.pos > 10) {
|
||||
v.addEventListener("loadedmetadata", () => {
|
||||
if (saved.pos < v.duration * 0.95) v.currentTime = saved.pos;
|
||||
if (saved.pos < v.duration * DONE_RATIO) v.currentTime = saved.pos;
|
||||
}, {once: true});
|
||||
}
|
||||
v.onerror = () => playerFail("media error");
|
||||
@@ -1169,6 +1359,7 @@ window.addEventListener("pagehide", () => savePos(0, true)); // keepalive: a pl
|
||||
} catch { /* not logged in: api() already redirected to /login */ }
|
||||
statusPill();
|
||||
await refreshHistory();
|
||||
await Promise.all(history.map(checkEntry));
|
||||
const p = new URLSearchParams(location.search);
|
||||
try {
|
||||
if (p.get("q")) await showSearch(p.get("q"));
|
||||
|
||||
+4
-1
@@ -18,7 +18,7 @@
|
||||
<form onsubmit="const q=document.getElementById('q').value.trim(); if(q) location='?q='+encodeURIComponent(q); return false">
|
||||
<input id="q" placeholder="search anime…" aria-label="search anime" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
</form>
|
||||
<span id="pill" class="pill"><i class="o"></i><i class="c"></i><span></span></span>
|
||||
<span id="pill" class="pill"><i class="d"></i><span></span></span>
|
||||
<span id="me" class="muted"></span>
|
||||
<div id="menubox" style="display:none">
|
||||
<button id="menu" class="tiny" aria-label="menu" aria-haspopup="true">☰</button>
|
||||
@@ -35,6 +35,9 @@
|
||||
route) with the git SHA at startup — a correct cache key per commit.
|
||||
No hand-bumping; JSV in app.js reads the same value from its own URL. -->
|
||||
<script src="hls.min.js"></script>
|
||||
<!-- Watched threshold: server injects its DONE_RATIO source constant here;
|
||||
app.js reads it as DONE_RATIO. Server uses the same value for its stats. -->
|
||||
<script>window.DONE_RATIO = {{DONE_RATIO}};</script>
|
||||
<script src="app.js?v={{VERSION}}"></script>
|
||||
</body>
|
||||
</html>
|
||||
+1
-1
@@ -49,7 +49,7 @@ const next = new URLSearchParams(location.search).get("next");
|
||||
async function login() {
|
||||
$("err").textContent = "";
|
||||
const r = await fetch(BASE + "login", {method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
headers: {"Content-Type": "application/json", "X-Aniweb": "1"},
|
||||
body: JSON.stringify({username: $("user").value, password: $("pw").value})});
|
||||
if (r.ok) {
|
||||
location.href = next ? BASE + next.replace(/^\/+/, "") : BASE;
|
||||
|
||||
+17
-6
@@ -163,16 +163,27 @@ button.tiny.on { border-color: var(--acc); color: var(--acc); }
|
||||
background: rgba(255, 255, 255, .1); }
|
||||
.pl-buffer i { display: block; height: 100%; width: 0;
|
||||
background: linear-gradient(90deg, var(--acc), #fbbf24); transition: width .25s ease; }
|
||||
/* header status pill: upstream site dot + CDN dot + short label */
|
||||
/* header status pill: upstream site dot + CDN dot + short label; tap = panel */
|
||||
header .pill { display: flex; align-items: center; gap: .4rem; flex: none;
|
||||
position: relative; /* anchors the status panel */
|
||||
font: 600 .72rem/1 system-ui, sans-serif; padding: .3rem .55rem; border-radius: 999px;
|
||||
border: 1px solid var(--bd); background: var(--card); color: var(--fg);
|
||||
cursor: help; }
|
||||
header .pill i { width: 8px; height: 8px; flex: none; border-radius: 50%; background: #6b7280; }
|
||||
header .pill .o.ok, header .pill .c.ok { background: #22c55e; }
|
||||
header .pill .c.degraded { background: #f59e0b; }
|
||||
header .pill .o.down { background: #ef4444; }
|
||||
header .pill.odown { border-color: #7f1d1d; }
|
||||
.status-panel { position: absolute; top: 100%; right: 0; z-index: 6; margin-top: .35rem;
|
||||
padding: .5rem .65rem; border-radius: 8px; border: 1px solid var(--bd);
|
||||
background: var(--card); color: var(--fg); font-size: .78rem; min-width: 170px;
|
||||
display: flex; flex-direction: column; gap: .3rem; box-shadow: 0 4px 16px rgba(0,0,0,.4); }
|
||||
.sp-row { font-variant-numeric: tabular-nums; }
|
||||
.sp-cap { color: var(--muted); font-size: .68rem; }
|
||||
.sp-svg { width: 100%; height: 28px; color: var(--acc); }
|
||||
.sp-svg.bad { color: #ef4444; } /* origin currently down */
|
||||
.sp-svg polyline { fill: none; stroke: currentColor; stroke-width: 1.5; }
|
||||
.sp-thr { stroke: #f59e0b; stroke-width: 1; stroke-dasharray: 3 3; }
|
||||
header .pill .d { width: 8px; height: 8px; flex: none; border-radius: 50%; background: #6b7280; }
|
||||
header .pill .d.ok { background: #22c55e; }
|
||||
header .pill .d.slow { background: #f59e0b; }
|
||||
header .pill .d.down { background: #ef4444; }
|
||||
header .pill.down { border-color: #7f1d1d; }
|
||||
#toasts { position: fixed; bottom: 1rem; left: 50%; transform: translateX(-50%);
|
||||
display: flex; flex-direction: column; gap: .4rem; z-index: 9; }
|
||||
@keyframes toast-in { from { opacity: 0; transform: translateY(.4rem); } }
|
||||
|
||||
+4
-4
@@ -53,10 +53,10 @@ def server():
|
||||
|
||||
data = Path(tempfile.mkdtemp(prefix="aniweb-test-"))
|
||||
os.environ["ANIWEB_DATA"] = str(data)
|
||||
# the per-IP login budget must never trip during the suite (all tests
|
||||
# share 127.0.0.1); per-username lockout stays at production defaults
|
||||
os.environ.setdefault("ANIWEB_IP_FAILS", "1000")
|
||||
os.environ.setdefault("ANIWEB_IP_WINDOW", "300")
|
||||
# test-tuned throttle: small IP budget + short window so the per-IP test
|
||||
# can both trip the gate and watch it expire inside one test run
|
||||
os.environ["ANIWEB_IP_FAILS"] = "10"
|
||||
os.environ["ANIWEB_LOCK_WINDOW"] = "8"
|
||||
sys.path.insert(0, str(ROOT))
|
||||
from server import db, users # noqa: E402
|
||||
db.init() # schema before users.add / seeding
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""test_animekai.py — animekai provider + MegaPlay decrypt + /stream Range path.
|
||||
|
||||
All offline (monkeypatched network) so the suite never touches animekai.be,
|
||||
MegaPlay, or any CDN. Live smoke tests live in test_animekai_live.py and
|
||||
are skipped unless ANI_LIVE=1.
|
||||
|
||||
Run: .venv/bin/pytest tests/test_animekai.py -q
|
||||
"""
|
||||
import base64
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
# Live 1anime-style host the /stream allowlist tests will register
|
||||
HOST = "my.1anime.site"
|
||||
VIDEO = f"https://{HOST}/stream/69f9d2aef757c305714fdc246e06d6bc"
|
||||
|
||||
|
||||
def test_animekai_provider_imports():
|
||||
import server.providers.animekai as a # noqa: F401
|
||||
for fn in ("search", "details", "episodes", "languages", "master_m3u8"):
|
||||
assert callable(getattr(a, fn)), fn
|
||||
assert callable(a.stream_meta)
|
||||
assert callable(a.subtitles)
|
||||
|
||||
|
||||
def test_animekai_stream_meta_allowlist():
|
||||
import server.providers.animekai as a
|
||||
assert a.stream_meta("https://other.example/x.m3u8") is None
|
||||
a.STREAM_HOSTS[HOST] = {"Referer": "https://" + HOST + "/"}
|
||||
assert a.stream_meta(VIDEO) == {"Referer": "https://" + HOST + "/"}
|
||||
a.STREAM_HOSTS.clear()
|
||||
|
||||
|
||||
# --- MegaPlay payload: old plaintext shape, new AES "enc" shape ------------
|
||||
# enc sample: real getSourcesNew response captured 2026-09-29 (deterministic
|
||||
# per data-id; key/IV static in the player's public JS — see upstream._MP_KEY)
|
||||
ENC = ("wdeBruh3qqn_i5wUNnyaPcXqidp1UWP84FfPHzGyKXA2hDZBfMCmZ4FLvs7_pQuH549Ept"
|
||||
"ax8UOjAJyRIZfrRhUGUKy9OBeGh2yB_-m_JLAlLnWTLzYZC3_C5I4ltveYoiaU66Do9Rg"
|
||||
"I9bCetmk_o87-sd66brXnWV1MbjhLnjw")
|
||||
|
||||
|
||||
def test_megaplay_sources_old_shape_passthrough():
|
||||
from server.upstream import megaplay_sources
|
||||
d = {"sources": {"file": "https://cdn.x/master.m3u8"}, "tracks": []}
|
||||
out = megaplay_sources(d)
|
||||
assert out["sources"]["file"] == "https://cdn.x/master.m3u8"
|
||||
|
||||
|
||||
def test_megaplay_sources_new_shape_decrypts():
|
||||
from server.upstream import megaplay_sources
|
||||
out = megaplay_sources({"enc": ENC, "tracks": [{"file": "a.vtt"}]})
|
||||
f = out["sources"]["file"]
|
||||
assert f.startswith("https://") and f.endswith("master.m3u8"), f
|
||||
assert out["tracks"] == [{"file": "a.vtt"}] # outer fields untouched
|
||||
|
||||
|
||||
def test_megaplay_sources_no_sources_unchanged():
|
||||
from server.upstream import megaplay_sources
|
||||
assert megaplay_sources({}) == {}
|
||||
|
||||
|
||||
def test_megaplay_sources_garbage_enc_raises():
|
||||
from server.upstream import megaplay_sources
|
||||
# corrupt payload must raise (provider -> fallback chain), not return None
|
||||
with pytest.raises(Exception):
|
||||
megaplay_sources({"enc": "AAAA" * 10})
|
||||
|
||||
|
||||
# --- /stream Range pass-through (progressive MP4), offline -----------------
|
||||
|
||||
class _FakeStream:
|
||||
"""Minimal stand-in for a streaming curl_cffi response (206 + chunk)."""
|
||||
status_code = 206
|
||||
headers = {"content-type": "video/mp4",
|
||||
"content-range": f"bytes 0-1023/{1024 * 1024}",
|
||||
"accept-ranges": "bytes"}
|
||||
|
||||
def iter_content(self, chunk_size=1):
|
||||
yield b"x" * 1024
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def app_mod(monkeypatch):
|
||||
monkeypatch.setenv("ANI_SOURCE", "animekai")
|
||||
import server.upstream as up
|
||||
monkeypatch.setattr(up, "get_stream",
|
||||
lambda url, timeout=12, headers=None: _FakeStream())
|
||||
import server.app as m
|
||||
return m
|
||||
|
||||
|
||||
def _drain(it):
|
||||
import asyncio
|
||||
async def _go():
|
||||
return b"".join([c async for c in it])
|
||||
return asyncio.run(_go())
|
||||
|
||||
|
||||
def _call(app_mod, token, range_hdr):
|
||||
"""Invoke the /stream handler in-process (no TestClient: it wants httpx).
|
||||
Returns (status, headers_dict, body_bytes)."""
|
||||
req = types.SimpleNamespace(headers={"range": range_hdr} if range_hdr else {})
|
||||
resp = app_mod.stream(token, request=req)
|
||||
body = _drain(resp.body_iterator) if getattr(resp, "body_iterator", None) is not None else resp.body
|
||||
return resp.status_code, dict(resp.headers), body
|
||||
|
||||
|
||||
def test_stream_range_forwards_206_and_chunk(app_mod):
|
||||
import server.providers.animekai as a
|
||||
a.STREAM_HOSTS[HOST] = {"Referer": "https://" + HOST + "/"}
|
||||
token = base64.urlsafe_b64encode(VIDEO.encode()).decode()
|
||||
status, hdrs, body = _call(app_mod, token, "bytes=0-1023")
|
||||
assert status == 206, status
|
||||
assert hdrs.get("content-range") == f"bytes 0-1023/{1024 * 1024}"
|
||||
assert len(body) == 1024
|
||||
|
||||
|
||||
def test_stream_range_not_cached(app_mod):
|
||||
import server.providers.animekai as a
|
||||
a.STREAM_HOSTS[HOST] = {"Referer": "https://" + HOST + "/"}
|
||||
token = base64.urlsafe_b64encode(VIDEO.encode()).decode()
|
||||
_call(app_mod, token, "bytes=0-1023")
|
||||
assert VIDEO not in app_mod._STREAM_CACHE, "ranged chunk poisoned the LRU"
|
||||
@@ -0,0 +1,103 @@
|
||||
"""test_animekai_live.py — LIVE smoke tests for the animekai provider.
|
||||
|
||||
Skipped by default so the suite never touches animekai.be (politeness:
|
||||
the provider must not be spammed by CI or local test runs). Enable with:
|
||||
|
||||
ANI_LIVE=1 .venv/bin/pytest tests/test_animekai_live.py -q -s
|
||||
|
||||
Every call rides the provider's _cached TTL cache, so one run is a
|
||||
handful of requests, not a crawl.
|
||||
"""
|
||||
import base64
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
live = pytest.mark.skipif(
|
||||
os.environ.get("ANI_LIVE") != "1",
|
||||
reason="live upstream test: run with ANI_LIVE=1")
|
||||
|
||||
|
||||
@live
|
||||
def test_search_shape():
|
||||
from server.providers import animekai as a
|
||||
r = a.search("one piece")
|
||||
assert r and all(set(x) >= {"id", "slug_id", "name"} for x in r), r[:2]
|
||||
print("\nsearch:", [(x["name"], x["slug_id"]) for x in r[:3]])
|
||||
|
||||
|
||||
@live
|
||||
def test_details_episodes_languages():
|
||||
from server.providers import animekai as a
|
||||
r = a.search("one piece")
|
||||
top = next(x for x in r if x["name"].lower().startswith("one piece"))
|
||||
d = a.details(top["slug_id"])
|
||||
print("\ndetails:", {k: (str(v)[:60] if v else v) for k, v in d.items()
|
||||
if k != "seasons"})
|
||||
assert d["name"] and d["id"], d
|
||||
eps = a.episodes(d["id"])
|
||||
assert eps and eps[0]["number"] == 1, eps[:2]
|
||||
print("episodes:", len(eps), "first:", eps[0])
|
||||
langs = a.languages(eps[0]["id"])
|
||||
print("langs ep1:", langs)
|
||||
assert langs, "ep1 should have at least sub"
|
||||
|
||||
|
||||
@live
|
||||
def test_master_resolves_and_stream_proxy_serves_range(monkeypatch):
|
||||
"""Full path: provider resolves a stream, and the in-process /stream
|
||||
proxy serves it with Range (206 + chunk). server.app import is safe
|
||||
here: the CDN probe is idle-gated (no upstream fetch until a play)."""
|
||||
# stream_meta's allowlist walks the ANI_SOURCE chain; point it at
|
||||
# animekai (as run.sh does) so the resolved host is permitted
|
||||
monkeypatch.setenv("ANI_SOURCE", "animekai")
|
||||
from server.providers import animekai as a
|
||||
# Sparks of Tomorrow's early eps were observed on 1anime servers
|
||||
# (independent CDN); the browse sort is most_viewed, so pick the title
|
||||
# by name, not position, then scan its first episodes for a resolver.
|
||||
r = a.search("sparks of tomorrow")
|
||||
top = next((x for x in r if "sparks" in x["name"].lower()), r[0])
|
||||
d = a.details(top["slug_id"])
|
||||
eps = a.episodes(d["id"])
|
||||
got = None
|
||||
for e in eps[:5]:
|
||||
for lang in ("jpn", "eng"):
|
||||
try:
|
||||
got = a.master_m3u8(e["id"], lang)
|
||||
except Exception as ex:
|
||||
print(f"\nep{e['number']} {lang}: {ex}")
|
||||
if got:
|
||||
break
|
||||
if got:
|
||||
break
|
||||
assert got, "no server resolved for ep1-3 (both backends dead?)"
|
||||
print("\nresolved:", got)
|
||||
|
||||
# host must be allowlisted (that IS the /stream permit)
|
||||
from urllib.parse import urlparse
|
||||
meta = a.stream_meta(got)
|
||||
assert meta is not None, f"provider did not register host for {got}"
|
||||
|
||||
# in-process /stream Range check (direct handler call, no server spawn)
|
||||
import asyncio
|
||||
import types
|
||||
import server.app as app_mod
|
||||
token = base64.urlsafe_b64encode(got.encode()).decode()
|
||||
rng = None if got.endswith(".m3u8") else "bytes=0-1023"
|
||||
req = types.SimpleNamespace(headers={"range": rng} if rng else {})
|
||||
resp = app_mod.stream(token, request=req)
|
||||
|
||||
async def _drain(it):
|
||||
return b"".join([c async for c in it])
|
||||
body = (asyncio.run(_drain(resp.body_iterator)) if getattr(resp, "body_iterator", None) is not None
|
||||
else resp.body)
|
||||
if got.endswith(".m3u8"):
|
||||
assert resp.status_code == 200 and b"#EXTM3U" in body
|
||||
else:
|
||||
assert resp.status_code == 206 and len(body) == 1024, resp.status_code
|
||||
print("proxy:", resp.status_code, len(body), "b")
|
||||
+65
-3
@@ -297,7 +297,8 @@ def test_self_delete(base=BASE):
|
||||
|
||||
def test_csrf_header_required(base=BASE):
|
||||
# mutation routes without the X-Aniweb header -> 403 (a cross-site form
|
||||
# POST can't set it; the SPA always sends it). /login is exempt.
|
||||
# POST can't set it; the SPA always sends it). /login is covered too now
|
||||
# (see test_login_requires_custom_header — forced-login was the hole).
|
||||
jar = login("admin", "adminpw12345", base)
|
||||
r = urllib.request.Request(base + "/logout", method="POST")
|
||||
try:
|
||||
@@ -320,15 +321,54 @@ def test_stream_token_host_allowlist(base=BASE):
|
||||
assert req("GET", f"/stream/{bad2}", jar=jar, base=base) == 400
|
||||
|
||||
|
||||
def test_login_requires_custom_header(base=BASE):
|
||||
# forced-login CSRF: /login was the one POST route exempt from the custom
|
||||
# header check, and login needs no cookie — so any cross-site <form>
|
||||
# POST could silently sign a visiting browser into the attacker's account.
|
||||
# /login now demands the header like every other mutation; a cross-site
|
||||
# form cannot set it, so the whole attack class dies at the 403.
|
||||
r = urllib.request.Request(base + "/login", method="POST",
|
||||
data=json.dumps({"username": "admin",
|
||||
"password": "adminpw12345"}).encode(),
|
||||
headers={"Content-Type": "application/json"})
|
||||
try:
|
||||
urllib.request.build_opener().open(r)
|
||||
raise AssertionError("POST /login without X-Aniweb must be 403")
|
||||
except urllib.error.HTTPError as e:
|
||||
assert e.code == 403, e.code
|
||||
|
||||
|
||||
def test_lockout_per_user(base=BASE):
|
||||
# a throwaway name: the lockout is per-username and in-memory, and bob is
|
||||
# also used by the browser tests (a locked-out bob breaks their logins)
|
||||
# a throwaway name: the buckets are in-memory and per-username, and bob
|
||||
# is also used by the browser tests (a locked-out bob breaks their logins)
|
||||
codes = [req("POST", "/login", {"username": "lockout-ghost", "password": "bad"}, base=base)
|
||||
for _ in range(6)]
|
||||
assert codes[:5] == [401] * 5 and codes[5] == 429, codes
|
||||
assert req("POST", "/login", {"username": "admin", "password": "adminpw12345"}, base=base) == 200
|
||||
|
||||
|
||||
def test_lockout_per_ip_and_expiry(base=BASE):
|
||||
# per-username lockout is bypassable by rotating usernames; the per-IP
|
||||
# budget (conftest: ANIWEB_IP_FAILS=10, window 8s) is not. Every name
|
||||
# below is fresh, so a 429 can only come from the IP bucket.
|
||||
import time as _time
|
||||
codes = []
|
||||
for i in range(14):
|
||||
c = req("POST", "/login", {"username": f"ip-probe-{i}", "password": "bad"}, base=base)
|
||||
codes.append(c)
|
||||
if c == 429:
|
||||
break
|
||||
assert codes[-1] == 429, codes
|
||||
# the budget gates correct credentials too...
|
||||
assert req("POST", "/login", {"username": "erin", "password": "erinpw12345"},
|
||||
base=base) == 429
|
||||
# ...and it is a sliding window: once it passes, login works again
|
||||
_time.sleep(8.6)
|
||||
jar = CJ()
|
||||
assert req("POST", "/login", {"username": "erin", "password": "erinpw12345"},
|
||||
jar, base=base) == 200
|
||||
|
||||
|
||||
# --- helpers ---
|
||||
|
||||
import os # noqa: E402
|
||||
@@ -466,3 +506,25 @@ def test_fetch_parallel_stitches_range_lanes():
|
||||
finally:
|
||||
u._session = real
|
||||
u._get = real_get
|
||||
|
||||
|
||||
def test_stale_entries_surfaced_as_remappable(base=BASE):
|
||||
# provider switch left anidb-era rows: their slug 200s on hianime as a
|
||||
# DIFFERENT anime (id 2552 = Burning Kabaddi). The client flags the
|
||||
# mismatch; the API contract is unchanged — the entry is deletable by
|
||||
# slug+ep while it sits on the remap board.
|
||||
jar = login("bob", "bobpw12345", base)
|
||||
stale = {"title": "Jujutsu Kaisen", "slug_id": "jujutsu-kaisen-2552",
|
||||
"season_id": 2552, "ep_id": 35996, "lang": "jpn", "ep": 1, "pos": 42}
|
||||
good = {"title": "Seed Anime", "slug_id": "seed-1", "season_id": 1,
|
||||
"ep_id": 1, "lang": "eng", "ep": 1, "pos": 10}
|
||||
assert req("POST", "/api/history", stale, jar=jar, base=base) == 200
|
||||
assert req("POST", "/api/history", good, jar=jar, base=base) == 200
|
||||
all_ = _json("GET", "/api/history?all=1", jar=jar, base=base)
|
||||
got = [x for x in all_ if x["slug_id"] == "jujutsu-kaisen-2552"]
|
||||
assert got and got[0]["ep_id"] == 35996 and got[0]["pos"] == 42, got
|
||||
removed = _json("DELETE", "/api/history?slug_id=jujutsu-kaisen-2552&ep_id=35996",
|
||||
jar=jar, base=base)
|
||||
assert removed["removed"] >= 1, removed
|
||||
all_ = _json("GET", "/api/history?all=1", jar=jar, base=base)
|
||||
assert not [x for x in all_ if x["slug_id"] == "jujutsu-kaisen-2552"], all_
|
||||
+49
-40
@@ -1,52 +1,61 @@
|
||||
"""test_auth.py — pure unit tests for server/auth.py (no server, no HTTP).
|
||||
"""test_auth.py — server/auth.py unit tests (no server, no network).
|
||||
|
||||
The per-IP budget and the XFF trust rule live here; the per-username
|
||||
lockout's HTTP behaviour stays covered in test_api.py.
|
||||
Bucket logic in isolation: per-username lockout, per-source-IP budget,
|
||||
sliding-window expiry, and the spoof-gating of X-Forwarded-For. The
|
||||
module reads its env knobs at import; inside the pytest process they stay
|
||||
at defaults (5 / 20 / 900s) — the tests read auth.FAILS_USER/IP_FAILS/
|
||||
WINDOW rather than hardcoding, so they hold either way.
|
||||
"""
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from server import auth # noqa: E402
|
||||
|
||||
|
||||
def _req(headers=None, host="203.0.113.7"):
|
||||
return SimpleNamespace(headers=dict(headers or {}),
|
||||
client=SimpleNamespace(host=host))
|
||||
class _Peer:
|
||||
host = "203.0.113.9"
|
||||
|
||||
|
||||
def test_ip_budget_counts_prunes_isolates(monkeypatch):
|
||||
from server import auth
|
||||
monkeypatch.setattr(auth, "IP_FAILS", 3)
|
||||
monkeypatch.setattr(auth, "IP_WINDOW", 60.0)
|
||||
auth._ip_fails.clear()
|
||||
assert not auth.ip_locked("203.0.113.1")
|
||||
for _ in range(3):
|
||||
auth.ip_fail("203.0.113.1")
|
||||
assert auth.ip_locked("203.0.113.1")
|
||||
assert not auth.ip_locked("203.0.113.2") # per-IP isolation
|
||||
auth._ip_fails["203.0.113.1"] = [time.time() - 10_000] # stale -> pruned
|
||||
assert not auth.ip_locked("203.0.113.1")
|
||||
class _Req:
|
||||
def __init__(self, headers=None):
|
||||
self.headers = headers or {}
|
||||
self.client = _Peer()
|
||||
|
||||
|
||||
def test_client_ip_xff_trust_rule(monkeypatch):
|
||||
from server import auth
|
||||
r = _req({"x-forwarded-for": "9.9.9.9"})
|
||||
monkeypatch.delenv("ANIWEB_TRUST_XFF", raising=False)
|
||||
assert auth.client_ip(r) == "203.0.113.7" # header NOT trusted by default
|
||||
monkeypatch.setenv("ANIWEB_TRUST_XFF", "1")
|
||||
assert auth.client_ip(r) == "9.9.9.9" # honored only when explicitly on
|
||||
assert auth.client_ip(_req()) == "203.0.113.7" # no header -> socket peer
|
||||
def test_username_lockout_and_sliding_expiry():
|
||||
auth.reset()
|
||||
t = 1_000_000.0
|
||||
for _ in range(auth.FAILS_USER):
|
||||
auth.record("locked-guy", "198.51.100.7", now=t)
|
||||
assert auth.locked("locked-guy", "198.51.100.7", now=t)
|
||||
# window is sliding: once every fail ages out, the name is free again
|
||||
assert not auth.locked("locked-guy", "198.51.100.7", now=t + auth.WINDOW + 1)
|
||||
# a locked username does not gate other usernames from another ip
|
||||
assert not auth.locked("fresh-guy", "198.51.100.8", now=t)
|
||||
# a correct password clears only that username's bucket
|
||||
auth.clear("locked-guy")
|
||||
assert not auth.locked("locked-guy", "198.51.100.9", now=t)
|
||||
auth.reset()
|
||||
|
||||
|
||||
def test_lock_locked_user_never_reaches_verify(monkeypatch):
|
||||
from server import auth
|
||||
monkeypatch.setattr(auth, "IP_FAILS", 1000)
|
||||
auth._lockout["ghost"] = {"fails": 0, "until": time.time() + 900}
|
||||
assert auth.lock("ghost", "x", "203.0.113.3") == "locked"
|
||||
def test_ip_budget_survives_username_rotation():
|
||||
auth.reset()
|
||||
t = 2_000_000.0
|
||||
for i in range(auth.IP_FAILS): # one fresh username per fail...
|
||||
auth.record(f"sweep-{i}", "198.51.100.9", now=t)
|
||||
assert auth.locked("brand-new-user", "198.51.100.9", now=t) # ...still blocked
|
||||
assert not auth.locked("brand-new-user", "198.51.100.10", now=t) # other ip fine
|
||||
assert not auth.locked("brand-new-user", "198.51.100.9", now=t + auth.WINDOW + 1)
|
||||
auth.reset()
|
||||
|
||||
|
||||
def test_lock_wrong_password_is_bad_and_records_ip_failure(monkeypatch):
|
||||
from server import auth
|
||||
monkeypatch.setattr(auth, "IP_FAILS", 1000) # keep the IP gate out of the way
|
||||
auth._ip_fails.clear()
|
||||
auth._lockout.clear() # module state is shared across tests
|
||||
assert auth.lock("ghost", "wrong123456", "203.0.113.4") == "bad" # ghost: no user
|
||||
assert len(auth._ip_fails.get("203.0.113.4", [])) == 1
|
||||
def test_client_ip_trust_gating():
|
||||
# XFF is only consulted when the trust flag is on (it is off here — the
|
||||
# flag is for the prod proxy path, not the test process)
|
||||
r = _Req({"x-forwarded-for": "5.6.7.8, 10.0.0.1"})
|
||||
assert auth.TRUST_XFF is False
|
||||
assert auth.client_ip(r) == "203.0.113.9" # spoofed header ignored
|
||||
assert auth.client_ip(_Req()) == "203.0.113.9"
|
||||
+80
-3
@@ -2,15 +2,24 @@
|
||||
|
||||
Run via pytest (see conftest.py for the throwaway server). Covers what
|
||||
HTTP tests can't: the login UI, the user chip, and real video playback
|
||||
behind auth. Skipped if playwright is not installed.
|
||||
behind auth.
|
||||
|
||||
These tests scrape and play LIVE upstream content, so they fail whenever a
|
||||
provider is mid-rotation or a title is taken down — that is not a code
|
||||
regression. They therefore stay OFF by default (the default suite must be
|
||||
deterministic); run them deliberately with ANIWEB_LIVE_TESTS=1.
|
||||
"""
|
||||
import asyncio
|
||||
import http.cookiejar
|
||||
import json
|
||||
import os
|
||||
import urllib.request
|
||||
|
||||
import pytest
|
||||
|
||||
if os.environ.get("ANIWEB_LIVE_TESTS") != "1":
|
||||
pytest.skip("live-upstream e2e — run with ANIWEB_LIVE_TESTS=1", allow_module_level=True)
|
||||
|
||||
async_playwright = pytest.importorskip("playwright.async_api").async_playwright
|
||||
|
||||
BASE = "http://127.0.0.1:8399"
|
||||
@@ -114,7 +123,7 @@ async def _history_page_and_delete():
|
||||
op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
||||
op.open(urllib.request.Request(BASE + "/login",
|
||||
data=json.dumps({"username": "erin", "password": "erinpw12345"}).encode(),
|
||||
headers={"Content-Type": "application/json"}, method="POST"))
|
||||
headers={"Content-Type": "application/json", "X-Aniweb": "1"}, method="POST"))
|
||||
for n in range(1, 6): # 5 entries: makes the home "history →" link render
|
||||
e = {"title": f"Hist {n}", "slug_id": f"hist-{n}", "season_id": n, "ep_id": n,
|
||||
"lang": "eng", "ep": n, "pos": 5, "dur": 600}
|
||||
@@ -212,7 +221,7 @@ async def _recent_searches():
|
||||
op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
||||
op.open(urllib.request.Request(BASE + "/login",
|
||||
data=json.dumps({"username": "alice", "password": "alicepw12345"}).encode(),
|
||||
headers={"Content-Type": "application/json"}, method="POST"))
|
||||
headers={"Content-Type": "application/json", "X-Aniweb": "1"}, method="POST"))
|
||||
op.open(urllib.request.Request(BASE + "/api/searches",
|
||||
data=json.dumps({"q": "chainsaw"}).encode(),
|
||||
headers={"Content-Type": "application/json", "X-Aniweb": "1"}, method="POST"))
|
||||
@@ -441,3 +450,71 @@ async def _autoplay_blocked():
|
||||
timeout=120000, polling=250)
|
||||
assert not errors, errors
|
||||
await browser.close()
|
||||
|
||||
|
||||
def test_stale_history_remap_board():
|
||||
asyncio.run(_stale_history_remap_board())
|
||||
|
||||
|
||||
async def _stale_history_remap_board():
|
||||
# provider switch (anidb -> hianime) left rows whose anidb slug 200s on
|
||||
# hianime as a DIFFERENT anime (jujutsu-kaisen-2552 = Burning Kabaddi).
|
||||
# The history page must flag it for remap instead of linking to that show.
|
||||
jar = http.cookiejar.CookieJar()
|
||||
op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
||||
op.open(urllib.request.Request(BASE + "/login",
|
||||
data=json.dumps({"username": "erin", "password": "erinpw12345"}).encode(),
|
||||
headers={"Content-Type": "application/json", "X-Aniweb": "1"}, method="POST"))
|
||||
op.open(urllib.request.Request(BASE + "/api/history",
|
||||
data=json.dumps({"title": "Jujutsu Kaisen", "slug_id": "jujutsu-kaisen-2552",
|
||||
"season_id": 2552, "ep_id": 35996, "lang": "jpn", "ep": 1,
|
||||
"pos": 42}).encode(),
|
||||
headers={"Content-Type": "application/json", "X-Aniweb": "1"}, method="POST"))
|
||||
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(args=LAUNCH_ARGS)
|
||||
page = await (await browser.new_context()).new_page()
|
||||
errors = []
|
||||
page.on("pageerror", lambda e: errors.append(str(e)))
|
||||
await _login(page, "erin", "erinpw12345")
|
||||
await page.goto(BASE + "/?history")
|
||||
await page.wait_for_function(
|
||||
"document.querySelector('a.card.remap')", timeout=60000, polling=POLL)
|
||||
stale = page.locator("a.card.remap", has_text="Jujutsu Kaisen")
|
||||
assert "not found" in await stale.inner_text()
|
||||
# the card must not link to the wrong-slug detail page; find-it goes
|
||||
# to the title's search instead
|
||||
assert not await stale.get_attribute("href")
|
||||
await stale.locator("button:has-text('find it')").click(force=True)
|
||||
await page.wait_for_function(
|
||||
"location.search.includes('q=')", timeout=15000, polling=POLL)
|
||||
assert not errors, errors
|
||||
await browser.close()
|
||||
|
||||
|
||||
def test_fullscreen_toggle():
|
||||
asyncio.run(_fullscreen_toggle())
|
||||
|
||||
|
||||
async def _fullscreen_toggle():
|
||||
# the player controls get a fullscreen button; in headless chromium the
|
||||
# native API works, so exercise enter + exit.
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(args=LAUNCH_ARGS)
|
||||
page = await (await browser.new_context()).new_page()
|
||||
errors = []
|
||||
page.on("pageerror", lambda e: errors.append(str(e)))
|
||||
await _login(page, "admin", "adminpw12345")
|
||||
await page.goto(BASE + "/?anime=chainsaw-man-922")
|
||||
btn = page.locator("button:has-text('full')")
|
||||
await btn.wait_for(timeout=30000)
|
||||
assert await page.evaluate(
|
||||
"() => typeof document.querySelector('#player video').requestFullscreen === 'function'")
|
||||
await btn.click(force=True)
|
||||
await page.wait_for_function(
|
||||
"() => !!document.fullscreenElement", timeout=15000, polling=POLL)
|
||||
await btn.click(force=True)
|
||||
await page.wait_for_function(
|
||||
"() => !document.fullscreenElement", timeout=15000, polling=POLL)
|
||||
assert not errors, errors
|
||||
await browser.close()
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Hermetic browser tests for the header status pill + panel.
|
||||
|
||||
/api/cdn is mocked with page.route — the throwaway server's probe thread
|
||||
runs against LIVE upstream, so its real data must never leak into these
|
||||
assertions. Everything else (login, home) hits the throwaway server, which
|
||||
never touches the real data. Skips if playwright is absent.
|
||||
|
||||
Pill states are CONSOLIDATED (one dot + one word): down (origin dead)
|
||||
> slow (CDN degraded) > up. Per-host detail lives in the tap panel.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
async_playwright = pytest.importorskip("playwright.async_api").async_playwright
|
||||
|
||||
BASE = "http://127.0.0.1:8399"
|
||||
LAUNCH_ARGS = ["--autoplay-policy=no-user-gesture-required"]
|
||||
POLL = 100
|
||||
|
||||
|
||||
def _cdn(state, kbps, origin="OK"):
|
||||
# origin hist: all OK except the last 3 when origin is DOWN
|
||||
ohist = [{"t": 1750000000 - 180 * (10 - i),
|
||||
"state": "DOWN" if (origin == "DOWN" and i >= 8) else "OK"}
|
||||
for i in range(11)]
|
||||
return {
|
||||
"state": state, "kbps": kbps, "ts": 1750000000, "threshold": 40,
|
||||
"hist": [{"t": 1750000000 - 180 * (10 - i), "kbps": 20 + i * 10} for i in range(11)],
|
||||
"origin": {"state": origin, "msg": "http 200", "ts": 1750000000, "hist": ohist},
|
||||
}
|
||||
|
||||
|
||||
async def _open(pw, body):
|
||||
"""logged-in home page with /api/cdn mocked. alice has no history, so
|
||||
home renders without touching the live upstream."""
|
||||
browser = await pw.chromium.launch(args=LAUNCH_ARGS)
|
||||
page = await (await browser.new_context()).new_page()
|
||||
await page.route("**/api/cdn",
|
||||
lambda r: r.fulfill(status=200, body=json.dumps(body),
|
||||
headers={"Content-Type": "application/json"}))
|
||||
await page.goto(BASE + "/")
|
||||
await page.wait_for_selector("input#pw")
|
||||
await page.fill("#user", "alice")
|
||||
await page.fill("#pw", "alicepw12345")
|
||||
await page.keyboard.press("Enter")
|
||||
await page.wait_for_selector("#q", timeout=30000)
|
||||
return browser, page
|
||||
|
||||
|
||||
def _js_click(page, sel):
|
||||
# JS click: this playwright/chromium combo's actionability check
|
||||
# ("element stable") hangs non-deterministically on header elements
|
||||
return page.evaluate(f"document.querySelector({json.dumps(sel)}).click()")
|
||||
|
||||
|
||||
def test_pill_ok():
|
||||
asyncio.run(_pill_ok())
|
||||
|
||||
|
||||
async def _pill_ok():
|
||||
async with async_playwright() as pw:
|
||||
browser, page = await _open(pw, _cdn("OK", 120))
|
||||
try:
|
||||
await page.wait_for_function(
|
||||
"document.querySelector('#pill .d').className.includes('ok')",
|
||||
timeout=5000, polling=POLL)
|
||||
assert await page.text_content("#pill span") == "up"
|
||||
# tap opens the panel: two sparklines (CDN + origin), the CDN
|
||||
# one carries the dashed threshold line and one point per sample
|
||||
await _js_click(page, "#pill")
|
||||
await page.wait_for_selector("#status-panel .sp-svg polyline")
|
||||
assert await page.locator("#status-panel polyline").count() == 2
|
||||
assert await page.locator("#status-panel .sp-thr").count() == 1
|
||||
pts = await page.get_attribute("#status-panel .sp-svg polyline", "points")
|
||||
assert len(pts.split()) == 11
|
||||
assert "CDN: OK — 120 KB/s" in await page.text_content("#status-panel")
|
||||
# tap again closes; so does a click away
|
||||
await _js_click(page, "#pill")
|
||||
assert not await page.locator("#status-panel").count()
|
||||
await _js_click(page, "#pill")
|
||||
await page.wait_for_selector("#status-panel")
|
||||
await _js_click(page, "body")
|
||||
assert not await page.locator("#status-panel").count()
|
||||
finally:
|
||||
await browser.close()
|
||||
|
||||
|
||||
def test_pill_degraded():
|
||||
asyncio.run(_pill_degraded())
|
||||
|
||||
|
||||
async def _pill_degraded():
|
||||
async with async_playwright() as pw:
|
||||
browser, page = await _open(pw, _cdn("DEGRADED", 23))
|
||||
try:
|
||||
await page.wait_for_function(
|
||||
"document.querySelector('#pill .d').className.includes('slow')",
|
||||
timeout=5000, polling=POLL)
|
||||
assert await page.text_content("#pill span") == "slow · 23 KB/s"
|
||||
await _js_click(page, "#pill")
|
||||
await page.wait_for_selector("#status-panel")
|
||||
assert "CDN: DEGRADED — 23 KB/s" in await page.text_content("#status-panel")
|
||||
finally:
|
||||
await browser.close()
|
||||
|
||||
|
||||
def test_pill_origin_down():
|
||||
asyncio.run(_pill_origin_down())
|
||||
|
||||
|
||||
async def _pill_origin_down():
|
||||
async with async_playwright() as pw:
|
||||
browser, page = await _open(pw, _cdn("OK", 120, origin="DOWN"))
|
||||
try:
|
||||
# down beats a healthy CDN: one consolidated state
|
||||
await page.wait_for_function(
|
||||
"document.querySelector('#pill .d').className.includes('down')",
|
||||
timeout=5000, polling=POLL)
|
||||
assert await page.text_content("#pill span") == "down"
|
||||
assert "down" in await page.get_attribute("#pill", "class")
|
||||
await _js_click(page, "#pill")
|
||||
await page.wait_for_selector("#status-panel .sp-svg.bad")
|
||||
assert "upstream: DOWN" in await page.text_content("#status-panel")
|
||||
finally:
|
||||
await browser.close()
|
||||
Reference in new issue
Block a user