Compare commits

...
22 Commits
Author SHA1 Message Date
jozamudi d12ff79d29 Add animekai source with 1anime direct-MP4 backend (independent CDN)
animekai.be fronted two server backends per episode: megaplay.buzz
(same player/CDN family as hianime — 403s our egress IP) and
my.1anime.site (direct progressive MP4, independent CDN — verified
serving our IP at ~2.4MB/s). This is the rescue path around the
MegaPlay Cloudflare block, per the ani-cli #1945/#1918 work.

- providers/animekai.py: standard interface, per-episode backend
  dispatch (1anime first, then megaplay), same listed-but-dead ->
  raise semantics as hianime. All endpoints ride the shared TTL cache
  (no re-fetching on repeat UI hits).
- upstream.py: megaplay_master() moved out of hianime — the MegaPlay
  chain (embed -> data-id -> getSourcesNew -> enc decrypt -> CDN proof)
  is now shared by every provider fronting that player.
- app.py: /stream forwards Range for progressive files (206 +
  Content-Range passthrough, partial bodies never LRU-cached, 8MB cap
  on the parallel path so a 250MB MP4 can't pin RAM). api_stream
  reports hls=true/false from the URL — the player kind is a property
  of the URL, so future stream types slot in without app changes.
- app.js: hls===false -> native <video> (existing Range-seeking),
  hls.js path untouched.
- run.sh: ANI_SOURCE=animekai,hianime,anidb,anikoto (revert to the old
  chain when the MegaPlay block lifts).
- tests: test_animekai.py (offline: interface, enc decrypt, /stream
  Range via monkeypatched network) + test_animekai_live.py (opt-in
  ANI_LIVE=1 end-to-end, skipped by default so the suite never touches
  animekai.be).
2026-09-29 18:35:22 +00:00
jozamudi e3ef51d141 Stop spamming upstream: drop video download from probe, kill re-rolls
- CDN probe no longer downloads 64KB of video (or even a variant
  playlist): one small master-playlist fetch proves reachability.
  State stays OK/DEGRADED, kbps is no longer measured, and the pill/
  panel only show a rate when one exists.
- hianime: 5x getSourcesNew re-roll -> 1 attempt. The enc payload is
  deterministic per data-id (observed identical across calls), so
  re-rolling only hammered the provider.
- anikoto: 2x re-roll -> 1 attempt per server, same reason.

Kept: idle-gated + 30-min degraded backoff from the previous commit,
the single _get retry (protects real user playback from CF blips),
and the origin page check (the 'down' signal). Net upstream traffic is
now what an actual viewer would generate, nothing more.
2026-09-29 17:33:54 +00:00
jozamudi 1154203569 CDN probe: skip while idle, back off while degraded
The 3-min 64KB video download ran on a timer regardless of whether
anyone was using the app — wasteful upstream bandwidth, and pure churn
while the CDN 403s our IP. Now: no fetch while no stream has been
requested in 15 min (api_stream bumps _LAST_PLAY), and 30-min retry
while DEGRADED. Pill state goes stale while idle, which is fine — the
next viewer's own request is the true signal, and the probe resumes
within a minute of it.
2026-09-29 17:23:18 +00:00
jozamudi 0cd79dd701 Decode MegaPlay's encrypted getSourcesNew payload
MegaPlay (behind both hianime and anikoto) stopped returning
sources.file in plaintext and now ships the stream URL in an
AES-256-CBC 'enc' field. Key/IV are static, hardcoded in their public
player JS (newclient.min.js) — anti-scrape obscurity, not access
control. Shared megaplay_sources() in upstream.py tolerates both
shapes, so a revert upstream breaks nothing.

Also: hianime master_m3u8 now raises when servers are listed but none
resolve (403s, stale format) instead of returning None — a listed-but
dead server is a provider fault, not 'no dub', and the old None
silently short-circuited the ANI_SOURCE fallback chain.
2026-09-29 17:17:33 +00:00
jozamudi 731f44b99c watched: single DONE_RATIO source constant replaces the seven hardcoded 0.95 literals
server/app.py DONE_RATIO is injected into index.html like {{VERSION}}, app.js
reads it as window.DONE_RATIO (fallback = same default). Change the threshold
in exactly one place now.
2026-09-21 01:41:44 +00:00
jozamudi 5f7aa010f1 status: consolidate the pill to one dot + one word; upgrade the panel
- pill: two host dots -> ONE consolidated state (down > slow > up) —
  the at-a-glance question is 'can I watch?', which is one state;
  per-host detail belongs in the tap panel
- panel: dashed slow-threshold line in the CDN sparkline (drawn at the
  server's real threshold, not a client copy) + origin up/down step
  sparkline so 'how long has the origin been down' is visible
- server: origin probe keeps a 20-sample hist like the CDN probe;
  DEGRADED threshold is now ANIWEB_CDN_KBPS (default 40) and is
  returned in /api/cdn so the UI draws the line at the live value
- tests: pill assertions switched to consolidated states; fixtures
  carry origin hist + threshold; assert both sparklines + threshold line
- docs: README + SPEC synced; preview on :8322 restarted (pid 44382)
2026-09-21 00:10:10 +00:00
jozamudi 778bf2bfc1 status: consolidate CDN/upstream status into the header pill + tap panel
- pill label now shows KB/s when degraded ('cdn 23 KB/s'); tap opens a
  panel (replaces the transient toast) with origin/CDN state, KB/s,
  a sparkline of the ~1h /api/cdn hist, and last-updated age
- 60s poll while the page is visible (was: load/play/failure only)
- playback failure overlay appends CDN context when degraded/down
- buffer gauge + spinner untouched: client buffer state, not CDN state
- server unchanged: /api/cdn already returned everything
- tests: 3 hermetic browser tests (mocked /api/cdn via page.route —
  the throwaway server's probe runs live upstream, so its data is
  mocked out of the assertions); 37 passed
- docs: README + SPEC synced
2026-09-20 23:53:47 +00:00
jozamudi 63a677f028 remove docs/CHANGES-2026-09.md: one-off audit-batch record, every fix already in the git log with explanatory commits; drop its mention from SPEC milestones 2026-09-20 23:40:26 +00:00
jozamudi de0bf8b156 remove PLAN.md: one-shot planning record for a shipped bundle; its content is superseded by git log + docs/CHANGES-2026-09.md 2026-09-20 23:37:20 +00:00
jozamudi 66a0bb16e6 remove ani-web.service: no code or docs reference the systemd unit 2026-09-20 23:36:28 +00:00
jozamudi 2fb3743b3b remove unused files: 3.6MB curl-impersonate tarball (covered by *.tar.gz gitignore, nothing references it) and the vendored ani-cli v5.0.0 script (upstream's, not used by this project) 2026-09-20 23:36:24 +00:00
jozamudi 816e945018 docs: slim README to what it uniquely owns (run, features, dev commands); implementation detail already lived in SPEC.md — moved the two stragglers (anidb self-check, cache-busting) over 2026-09-20 23:34:32 +00:00
jozamudi cea04a20f6 player: fullscreen button (scales with the window)
'⛶ full' in the controls: native requestFullscreen on the video (it fills
the viewport and rescales on window resize for free), with the iOS
webkitEnterFullscreen fallback where the Fullscreen API doesn't exist.
Click again to exit. Live browser test: enter + exit via the button.
2026-09-19 00:11:45 +00:00
jozamudi 379f591180 history: flag rows whose slug no longer means the same anime
Provider switch (anidb -> hianime-first) left anidb-era rows whose slugs
200 on hianime as a DIFFERENT anime: /watch/jujutsu-kaisen-2552 is now
Burning Kabaddi (the trailing number is a hianime id), and the stored
anidb ep id doesn't even exist in that anime's list — the row rendered
as watched JJK but linked to the wrong show.

Client-side validation (no data migration): every history row's slug is
checked against GET /api/anime/{slug} once per slug; title mismatch (or
a dead slug) marks the entry stale. Stale rows:
- home: one card per stale anime above the hero + 'history ->' link
- history page: separate 'Moved or renamed on the source' section
Each card: title, 'find it ->' (search by stored title) and x (remove,
same endpoint as regular cards). Current rows are untouched: a hianime
slug whose details name matches the stored title passes.

Tests: API contract for a stale row (deletable while on the board);
live browser test (gated) with the real jujutsu-kaisen-2552 row:
remap card renders, card doesn't link to the wrong slug, find-it
lands on the title's search.
2026-09-18 23:25:35 +00:00
jozamudi 0f6bd0a52a docs: sync README + SPEC with the shipped reality
Both docs still described the pre-provider-layer single-source app:

- README: anidb-only framing, missing stats page, 'upstream.py is the
  ONLY Cloudflare-facing code', and a self-check command that does not
  work (plain script execution breaks the providers' relative imports;
  the asserts are anidb-specific under the hianime-first default) —
  now documents ANI_SOURCE=anidb + the -m form. Dropped the stale
  'episode titles are not available' note (Jikan titles shipped).
- SPEC: 'no database' -> SQLite, architecture -> 3 providers, full
  endpoint list (intros/searches/events/stats/cdn, DELETE history,
  ?all=1, {sub}), auth hardening (per-IP budget, X-Aniweb on /login),
  tests/run.py -> pytest + ANIWEB_LIVE_TESTS gating, out-of-scope list
  (countdown/intro-skip/titles shipped), risks updated for the
  fallback layer, milestone 8 pointer.
2026-09-16 21:14:26 +00:00
jozamudi 4aca78cab8 docs(db): legacy JSON files are deletable once tables are populated: the on-disk copies of users/history/searches were verified superseded by SQLite and removed; the docstring claimed they were kept as a snapshot 2026-09-16 21:14:26 +00:00
jozamudi cbfb2f96c8 pin exact dependency versions: curl_cffi impersonation churn must never update silently 2026-09-16 02:41:25 +00:00
jozamudi d02cb017d3 gate live-upstream browser e2e behind ANIWEB_LIVE_TESTS=1: provider rotation/takedowns were turning the suite red for non-regressions; default suite is hermetic (33 tests) again 2026-09-16 02:41:25 +00:00
jozamudi 882263d276 login POST now requires the X-Aniweb header: cross-site forced-login (attacker-form session swap) dies at the 403; login.html sends the header like the SPA 2026-09-16 02:41:25 +00:00
jozamudi 3e5c7062ab login: per-source-IP failure budget (auth.py) — username rotation no longer dodges the lockout; sliding window, XFF trusted only behind the proxy (ANIWEB_TRUST_XFF); lockout logic now unit-testable (tests/test_auth.py) 2026-09-16 02:31:23 +00:00
jozamudi c5c4dff851 Pin dependencies (full env freeze): curl_cffi/curl-impersonate churn breaks impersonation silently 2026-09-16 02:06:56 +00:00
jozamudi 7f531fc309 CSRF header check now covers /login (exemption removed); login.html sends X-Aniweb 2026-09-16 02:02:09 +00:00
28 changed files with 1390 additions and 1235 deletions

No files matched your search

+1
View File
@@ -7,3 +7,4 @@ server.log
server.pid server.pid
pi-session-*.html pi-session-*.html
.pytest_cache/ .pytest_cache/
server-*.log
-160
View File
@@ -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`.
+47 -98
View File
@@ -1,9 +1,9 @@
# ani-web # ani-web
Web front-end for anime streaming: search, browse, and stream anime in 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 the browser (hianime.at primary; anidb.app and anikototv.to as
HLS playback proxied through the backend. Tuned for phone as much as fallbacks). Multi-user, with per-user watch history and HLS playback
desktop. proxied through the backend. Tuned for phone as much as desktop.
![login](docs/img/login.png) ![login](docs/img/login.png)
@@ -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 Open it through your HTTPS proxy (or <http://localhost:8321> from the
host itself — browsers treat localhost as trusted). `run.sh` restarts host itself — browsers treat localhost as trusted). `run.sh` restarts
cleanly if the server is already running; logs go to `server.log`. 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 ```sh
.venv/bin/python -m server.users add <name> # add user (prompted password) .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 .venv/bin/python -m server.users list # list users
``` ```
Security: passwords are PBKDF2-SHA256 (210k iters, per-user salts) in Passwords are PBKDF2-SHA256 in `server/data/aniweb.db` (SQLite, 0600);
`server/data/aniweb.db` (SQLite, 0600). Sessions are signed 30-day `HttpOnly` sessions are signed 30-day `HttpOnly` `SameSite=Lax` cookies, `Secure`
`SameSite=Lax` cookies; `run.sh` sets `ANIWEB_SECURE=1` so cookies are via `run.sh`, so remote plain-HTTP can't hold a session — the HTTPS
`Secure` and only work through your HTTPS proxy. Accounts deleted via proxy is the way in. See [SPEC.md](SPEC.md) for the rest.
the app or CLI are rejected immediately, even with an old cookie.
## Features ## 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
Accounts are created from the CLI only — there is no signup screen, per-entry delete.
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:
![history](docs/img/history.png) ![history](docs/img/history.png)
### Home: continue watching - **Home** — continue-watching hero (poster, position, Resume) plus a
recently-played grid with progress bars.
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.
![home](docs/img/home.png) ![home](docs/img/home.png)
### Search - **Search** — poster-grid results from the active source; your last 8
searches (per user) in a live-filtering dropdown. Results open the
The header search box queries anidb.app and shows results as a poster detail view (synopsis, season tabs, filler badges).
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.
![search](docs/img/search.png) ![search](docs/img/search.png)
### Player - **Player** — resume at the saved position, skip intro (MAL timings),
dub/sub toggle, playback speed, hide fillers, auto-next with a
Playback is HLS through a backend proxy (`/stream/{token}`) — the 5-second countdown, ✓ on completed episodes. Playback goes through a
video CDN sends no CORS headers, so the browser can't fetch it backend HLS proxy (the CDN sends no CORS headers); segments are
directly. The CDN serves a single quality (no ABR); the proxy pulls pulled across parallel byte-range lanes so a slow connection doesn't
each segment across 16 parallel byte-range lanes (falling back to a stall.
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)
![detail](docs/img/detail.png) ![detail](docs/img/detail.png)
- **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.
- **Stats** — `?stats`: watch minutes, episodes, streaks, sub/dub
split, weekday/per-day heatmaps, top-10 shows, top searches
(30–365 day windows).
## Layout ## Layout
``` ```
server/ FastAPI app (app.py), anidb.app fetchers (upstream.py), server/ FastAPI app (app.py), source providers (providers/: hianime,
accounts (users.py). upstream.py is the ONLY Cloudflare-facing code anidb, anikoto) over shared HTTP + dispatch (upstream.py)
static/ front-end: index.html, login.html, app.js, style.css, hls.min.js (vendored) static/ front-end: index.html, login.html, app.js, hls.min.js (vendored)
tests/ pytest suite: conftest.py (throwaway server), HTTP + browser tests tests/ pytest suite: hermetic default, live browser e2e opt-in
docs/img/ README screenshots docs/img/ README screenshots
run.sh launch/restart the server (sets ANIWEB_SECURE=1) 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 - `.venv/bin/pytest tests/ -q` — hermetic suite against a throwaway
`curl_cffi` Chrome TLS impersonation. If that ever stops working, server (never touches real data). Live-upstream browser e2e is
everything is isolated in `server/upstream.py`. opt-in: `ANIWEB_LIVE_TESTS=1 .venv/bin/pytest tests/ -q`
- The video CDN sends no CORS headers, so playback goes through the (needs playwright chromium).
backend HLS proxy (`/stream/{token}`), which rewrites playlist URIs. - `ANI_SOURCE=anidb .venv/bin/python -m server.upstream` — live
- `.venv/bin/python server/upstream.py` runs a self-check of the self-check of the anidb fetchers (must be the `-m` form; plain
fetchers (search → details → episodes → dub stream for Chainsaw Man). script execution breaks the providers' relative imports).
- A background probe fetches 64KB from the CDN every 3 min and logs - Everything else (Cloudflare/impersonation, HLS proxy, CDN probe,
state flips to `server.log`; `GET /api/cdn` (public) returns the auth hardening, cache-busting): [SPEC.md](SPEC.md).
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.
+90 -32
View File
@@ -1,16 +1,20 @@
# ani-web — Web Front-End for ani-cli # ani-web — Web Front-End for ani-cli
Browse, search, and stream anime from the browser, using the same Browse, search, and stream anime from the browser. Sources are pluggable
anidb.app data source that ani-cli scrapes. 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 **Status: implemented and running.** Deviations from the original
draft: hls.js is vendored locally (CDN was a silent failure point), draft: hls.js is vendored locally (CDN was a silent failure point),
history is server-side with watch-position resume (was: localStorage, history is server-side with watch-position resume (was: localStorage,
last 20), and the front-end lives in `static/` with the server code in last 20), storage is SQLite (was: JSON files), the front-end lives in
`server/`. See README.md. `static/` with the server code in `server/`. See README.md.
## Verified data flow (spiked 2026-07, all 200 OK with Chrome TLS impersonation) ## Verified data flow (spiked 2026-07, all 200 OK with Chrome TLS impersonation)
The anidb.app flow — one of the three providers (hianime.at is primary;
see `server/providers/hianime.py` for its flow).
| Step | Request | Yields | | Step | Request | Yields |
|---|---|---| |---|---|---|
| 1. Search | `GET anidb.app/browse?q={query}` | HTML cards: `href=".../anime/{slug}-{id}"`, `title="{name}"`, poster `img src` | | 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) Browser (vanilla JS + hls.js)
│ /api/* JSON /stream/* proxied HLS │ /api/* JSON /stream/* proxied HLS
▼ ▼
Python backend (FastAPI, uvicorn) Python backend (FastAPI, uvicorn, SQLite)
│ curl_cffi (Chrome impersonation) │ 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 ## Backend
`server/app.py` + `server/upstream.py`. Python 3.12, deps: `server/app.py` (routes), `server/upstream.py` (shared HTTP, TTL
`fastapi`, `uvicorn`, `curl_cffi`, `itsdangerous`. 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/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/{slug_id}` → `{id, name, synopsis, mal_id, seasons:[{slug_id,name,id}]}`
- `GET /api/anime/{id}/episodes` → `[{id, number, filler}]` (step 3, passthrough) - `GET /api/anime/{id}/episodes` → `[{id, number, filler}]` (step 3, passthrough)
- `GET /api/episode/{ep_id}/stream?lang=eng|jpn` → `{token}` - `GET /api/intros/{mal_id}` → per-episode MAL intro timings + episode
(steps 5+6; token is base64url of the master m3u8 URL) titles (keyless Jikan, 30-day cache; empty list when unavailable,
- `GET /api/history` / `POST /api/history` → **per-user** watch 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 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 - `GET /stream/{token}` → proxied HLS. Token = base64url of the
upstream URL. Rewrites every non-comment line of m3u8 bodies to upstream URL (host-allowlisted against the providers' self-registered
point back at `/stream/`; proxies master, variants, and segments CDN hosts — anti-SSRF). Rewrites every non-comment line of m3u8
(bytes, no transcoding). Responses are `Cache-Control: public, bodies to point back at `/stream/`; proxies master, variants, and
max-age=86400` — VOD playlists/segments are immutable and the token segments (bytes, no transcoding), segments across parallel byte-range
is the exact upstream URL, so the browser disk cache serves re-opens lanes with single-stream fallback. Responses are `Cache-Control:
and re-seeks without touching the proxy. 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: - `GET /login` / `POST /login` / `POST /logout` → multi-user auth:
PBKDF2-SHA256 passwords (`server/users.py`; SQLite `aniweb.db`, 0600), PBKDF2-SHA256 passwords (`server/users.py`; SQLite `aniweb.db`, 0600),
signed 30-day cookie carrying the username, 5-fail/15-min signed 30-day cookie carrying the username, 5-fail/15-min
in-memory lockout **per user**. Accounts are CLI-created only in-memory lockout **per user** plus a per-source-IP failure budget
(`python -m server.users add|rm|list`) — no self-signup. One-time (username rotation can't dodge it; XFF trusted only behind the
migration: legacy single-password `credentials.json` → user proxy). All mutations — including `POST /login` — require the
`admin`; `history.json` → `history/admin.json`. `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 - Session hardening: cookies are `HttpOnly`, `SameSite=Lax`, and
`Secure` when `ANIWEB_SECURE=1` (set by `run.sh`); the middleware `Secure` when `ANIWEB_SECURE=1` (set by `run.sh`); the middleware
re-verifies the account still exists per request, so deleted 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. produced black screens.
Flow: search box → results grid (poster + title, click) → detail view 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 + filler badge, sub/dub toggle) → `<video>` with hls.js pointed at
`/stream/{token}`; single quality — the proxy fetches each segment `/stream/{token}`; single quality — the proxy fetches each segment
across parallel byte-range lanes (single-stream fallback). 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 - Default play: opening an anime resumes the last watched episode of
the season (seeking to the saved position unless ≥95% through), the season (seeking to the saved position unless ≥95% through),
else autoplays episode 1. If the browser blocks autoplay, the native 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 Use the app through your HTTPS proxy (with `Secure` cookies, plain
remote HTTP can't hold a session). `python tests/run.py` runs the remote HTTP can't hold a session). `.venv/bin/pytest tests/ -q` runs
full suite against a throwaway server on :8399. 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) ## Out of scope (v1)
Downloads (`-d`), next-episode countdown (`-N`), syncplay, intro skip, Downloads (`-d`), syncplay, ani-schedule integration. (Next-episode
ani-schedule integration, episode titles (not in anidb's API — needs countdown, intro skip, and episode titles — via keyless Jikan —
a MyAnimeList API key). Add when someone asks. shipped since this list was written.) Add when someone asks.
## Risks ## Risks
- **Cloudflare policy change** → upstream breaks. Mitigation: swap - **Cloudflare policy change** → upstream breaks. Mitigation: swap
`impersonate="chrome"` for `"safari"`/binary curl-impersonate; `impersonate="chrome"` for `"safari"`/binary curl-impersonate;
all upstream access is isolated in one module (`upstream.py`). all upstream access is isolated in `upstream.py` + `providers/`,
- **anidb.app layout change** → parsers break. The 4 API endpoints and the ordered multi-source fallback turns one dead site into a
(steps 3–6) are clean JSON; only steps 1, 2, 5 are HTML/JS scraping. 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, - **ToS** — anidb.app is a third-party scraper site; personal use,
no redistribution, bind to LAN only by default. no redistribution, bind to LAN only by default.
@@ -133,3 +189,5 @@ a MyAnimeList API key). Add when someone asks.
states, autoplay default, `run.sh`. states, autoplay default, `run.sh`.
7. Auth — login screen, then multi-user CLI-created accounts with 7. Auth — login screen, then multi-user CLI-created accounts with
per-user history + test suite. per-user history + test suite.
8. Later (see git log): provider layer with hianime primary, SQLite
storage, stats/events, 2026-09 security audit batch.
-625
View File
@@ -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|&#039;|'|g" -e 's|&quot;|"|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|&#039;|'|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
-17
View File
@@ -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.
-44
View File
@@ -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
View File
@@ -1,8 +1,12 @@
fastapi # exact pins on purpose: curl_cffi behavior = impersonation behavior; an
uvicorn # unpinned bump is a silent upstream-403 wave. Bump one at a time, re-run
curl_cffi # the upstream self-check (.venv/bin/python server/upstream.py) per bump.
itsdangerous fastapi==0.141.1
uvicorn==0.52.4
curl_cffi==0.16.0
itsdangerous==2.2.0
pycryptodome==3.23.0
# tests # tests
pytest pytest==9.1.1
playwright playwright==1.62.0
+7 -3
View File
@@ -25,8 +25,12 @@ fi
# ANIWEB_SECURE=1: session cookie is Secure — only usable via the HTTPS # ANIWEB_SECURE=1: session cookie is Secure — only usable via the HTTPS
# proxy (plain http://<this machine>:8321 can no longer hold a session). # 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 # ANIWEB_TRUST_XFF=1: prod traffic arrives via the local HTTPS proxy, so the
# be trusted for the per-IP login budget (server/auth.py). # per-IP login budget keys on X-Forwarded-For; without the flag auth.py uses
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 & # 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 $! > "$PIDF"
echo "ani-web on http://localhost:8321 (pid $(cat "$PIDF")) — use the HTTPS proxy to log in" echo "ani-web on http://localhost:8321 (pid $(cat "$PIDF")) — use the HTTPS proxy to log in"
+87 -58
View File
@@ -22,7 +22,7 @@ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Redirect
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from itsdangerous import BadSignature, TimestampSigner from itsdangerous import BadSignature, TimestampSigner
from . import db, upstream, users from . import auth, db, upstream, users
ROOT = Path(__file__).parent.parent ROOT = Path(__file__).parent.parent
app = FastAPI(title="ani-web") app = FastAPI(title="ani-web")
@@ -44,52 +44,59 @@ def _version():
VERSION = _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": []} _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 # origin (primary provider pages/API) health — separate host, separate failure
# mode (observed 2026-09-03: CDN fine while origin 503'd "Under Maintenance") # 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(): def _cdn_probe():
"""Background probe: pull 64KB from the CDN every 3 min and log state """Background probe: fetch ONE small playlist and log state flips, so
flips, so server.log shows when a degraded window ends (CDN RECOVERED). 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. Probe target: ep_id 1 = One Piece E1 on the primary provider.
ponytail: single fixed episode; if it ever disappears, the probe logs ponytail: single fixed episode; if it ever disappears, the probe logs
DEGRADED forever — swap the ID, don't build a picker.""" 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 state = None
while True: while True:
if time.time() - _LAST_PLAY > _IDLE_SECS:
time.sleep(60) # nobody's watching: don't burn upstream bandwidth
continue
try: try:
m = upstream.master_m3u8(1, "jpn") m = upstream.master_m3u8(1, "jpn")
if not m: if not m:
raise RuntimeError("no master m3u8") raise RuntimeError("no master m3u8")
# hianime's CDN 403s without the embed-origin Referer (anidb needs none) # hianime's CDN 403s without the embed-origin Referer (anidb needs none)
hdrs = upstream.stream_meta(m) or {} hdrs = upstream.stream_meta(m) or {}
base = m.rsplit("/", 1)[0] + "/" upstream._get(m, timeout=10, headers=hdrs)
v = first(upstream._get(m, timeout=10, headers=hdrs).text, base) now = "OK"
# 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)
except Exception: except Exception:
kbps = 0.0 now = "DEGRADED"
now = "OK" if kbps >= 40 else "DEGRADED"
if now != state: if now != state:
print(f"[probe] CDN {now} ({kbps:.0f} KB/s)", flush=True) print(f"[probe] CDN {now}", flush=True)
state = now state = now
_CDN.update(state=now, kbps=round(kbps), ts=time.time()) _CDN.update(state=now, kbps=0, ts=time.time())
_CDN["hist"].append({"t": time.time(), "kbps": round(kbps)}) _CDN["hist"].append({"t": time.time(), "kbps": 0})
del _CDN["hist"][:-20] del _CDN["hist"][:-20]
_origin_probe() _origin_probe()
time.sleep(180) time.sleep(_DEGRADED_RETRY if now == "DEGRADED" else 180)
def _origin_probe(): def _origin_probe():
@@ -103,6 +110,8 @@ def _origin_probe():
state, msg = "DOWN", str(e) state, msg = "DOWN", str(e)
prev = _ORIGIN["state"] prev = _ORIGIN["state"]
_ORIGIN.update(state=state, msg=msg, ts=time.time()) _ORIGIN.update(state=state, msg=msg, ts=time.time())
_ORIGIN["hist"].append({"t": time.time(), "state": state})
del _ORIGIN["hist"][:-20]
if state != prev: if state != prev:
print(f"[probe] ORIGIN {state} ({msg})", flush=True) print(f"[probe] ORIGIN {state} ({msg})", flush=True)
@@ -114,7 +123,7 @@ threading.Thread(target=_cdn_probe, daemon=True).start()
async def cdn_status(): async def cdn_status():
"""CDN + origin health (read-only, public). Top-level fields are the CDN """CDN + origin health (read-only, public). Top-level fields are the CDN
probe (existing shape); .origin is the anidb.app pages/API probe.""" 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 ---------- # ---------- auth: multi-user, signed cookies ----------
users.migrate_legacy() # file-based legacy import; db.init() absorbs its output 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")} _PUBLIC = {("/login", "GET"), ("/login", "POST"), ("/api/cdn", "GET")}
_SECURE = os.environ.get("ANIWEB_SECURE") == "1" # set when served over HTTPS _SECURE = os.environ.get("ANIWEB_SECURE") == "1" # set when served over HTTPS
# ponytail: in-memory lockout per username, single process — 5 fails -> 15 min # Login throttling lives in server/auth.py: per-username lockout PLUS a
# ponytail: in-memory lockout per username, single process — 5 fails -> 15 min # per-source-IP failure budget (username rotation can't dodge it). The
_lockout: dict = {} # bucket logic is unit-tested there (tests/test_auth.py), without the app.
def _session_user(request) -> str | None: 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 return payload.decode().split(":", 1)[1] if payload.startswith(b"user:") else None
def _lock(user: str, pw: str) -> str: # (throttle buckets + client_ip live in server/auth.py — see auth.locked /
lo = _lockout.setdefault(user, {"fails": 0, "until": 0.0}) # auth.record; the old per-username-only _lock dict was the audit finding)
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"
@app.middleware("http") @app.middleware("http")
async def csrf_header(request, call_next): async def csrf_header(request, call_next):
# ponytail: a cross-site form POST (top-level navigation) does carry the # ponytail: a cross-site form POST (top-level navigation) does carry the
# SameSite=Lax cookie, so <form action=.../account/delete> from a # SameSite=Lax cookie, so <form action=.../account/delete> from a
# malicious page would work; forms can't set a custom header, the SPA # malicious page would work; forms can't set a custom header, the SPA and
# always sends it. Runs *after* require_auth (registered earlier), so # the login page both send one. /login is covered too: login needs no
# unauthenticated requests still get 302/401 first. # cookie, so an exempt /login let a cross-site forced-login POST swap a
if request.method in ("POST", "DELETE") and request.url.path != "/login": # 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": if request.headers.get("x-aniweb") != "1":
return JSONResponse({"detail": "forbidden"}, status_code=403) return JSONResponse({"detail": "forbidden"}, status_code=403)
return await call_next(request) return await call_next(request)
@@ -194,20 +195,23 @@ def login_page():
@app.post("/login") @app.post("/login")
def login(payload: dict = Body(...)): def login(request: Request, payload: dict = Body(...)):
# SYNC def on purpose: _lock's 0.5s anti-brute-force sleep must run on # 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 # uvicorn's threadpool — as async def it ran ON the event loop and every
# wrong password stalled every in-flight request (streams included). # wrong password stalled every in-flight request (streams included).
u = payload.get("username", "").lower() u = payload.get("username", "").lower()
if not u: if not u:
return JSONResponse({"detail": "username required"}, status_code=400) return JSONResponse({"detail": "username required"}, status_code=400)
state = _lock(u, payload.get("password", "")) ip = auth.client_ip(request)
if state == "ok": 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}) resp = JSONResponse({"ok": True, "user": u})
_session_cookie(resp, u) _session_cookie(resp, u)
return resp return resp
if state == "locked": auth.record(u, ip)
return JSONResponse({"detail": "too many attempts, try later"}, status_code=429) time.sleep(0.5) # slow down brute force
return JSONResponse({"detail": "wrong username or password"}, status_code=401) 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"]: if s["last_ts"] > a["last_seen"]:
a["last_seen"] = s["last_ts"] a["last_seen"] = s["last_ts"]
a["cur_ep"] = s["last_ep"] 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 a["completed"] += 1
completed += 1 completed += 1
now = time.time() now = time.time()
@@ -554,11 +558,16 @@ def api_episodes(anime_id: int):
@app.get("/api/episode/{ep_id}/stream") @app.get("/api/episode/{ep_id}/stream")
def api_stream(ep_id: int, lang: str = "jpn", request: Request = None): 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(): def go():
master = _upstream(upstream.master_m3u8, ep_id, lang) master = _upstream(upstream.master_m3u8, ep_id, lang)
if not master: if not master:
raise HTTPException(404, f"no {lang} stream for episode {ep_id}") raise HTTPException(404, f"no {lang} stream for episode {ep_id}")
out = {"token": _b64(master)} 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 sub = upstream.subtitles(ep_id) # external .vtt (hianime); anidb bakes subs in
if sub: if sub:
out["sub"] = _b64(sub) out["sub"] = _b64(sub)
@@ -609,9 +618,11 @@ def _cache_put(url, ct, body):
_STREAM_CACHE.popitem(last=False) _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 """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 import time
t0, t_first, buf, complete = time.time(), None, [], False t0, t_first, buf, complete = time.time(), None, [], False
try: try:
@@ -623,7 +634,7 @@ def _seg_stream(r, url, ct):
complete = True complete = True
finally: finally:
r.close() r.close()
if complete and buf: if complete and buf and cache:
_cache_put(url, ct, b"".join(buf)) _cache_put(url, ct, b"".join(buf))
# observability: a slow segment is the fragLoadTimeOut culprit. Log # observability: a slow segment is the fragLoadTimeOut culprit. Log
# only the slow ones so normal playback stays quiet. TTFB>3s or # 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}") @app.get("/stream/{token}")
def stream(token: str): def stream(token: str, request: Request = None):
url = _url(token) url = _url(token)
p = urlparse(url) p = urlparse(url)
# ponytail: token is plain base64, so allowlist the host — otherwise any # 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) return Response(hit[1], media_type=hit[0], headers=_STREAM_CACHE_HDRS)
if url.endswith(".m3u8"): if url.endswith(".m3u8"):
return _stream_playlist(url, meta) 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 # segment miss: try parallel byte-range lanes first (4 concurrent fetches
# beat per-connection CDN throttling); ANY failure falls back to the # beat per-connection CDN throttling); ANY failure falls back to the
# single streaming path, so this can never be worse than before # single streaming path, so this can never be worse than before
@@ -692,6 +716,9 @@ def stream(token: str):
name = url.rsplit("/", 1)[-1][:40] name = url.rsplit("/", 1)[-1][:40]
print(f"[stream-slow] {name} parallel total={time.time()-t0:.1f}s " print(f"[stream-slow] {name} parallel total={time.time()-t0:.1f}s "
f"bytes={len(body)}", flush=True) f"bytes={len(body)}", flush=True)
# 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) _cache_put(url, ct, body)
return Response(body, media_type=ct, headers=_STREAM_CACHE_HDRS) 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) @app.get("/index.html", include_in_schema=False)
def index(): def index():
# index.html is served (not mounted) so ?v={{VERSION}} becomes the git # index.html is served (not mounted) so ?v={{VERSION}} becomes the git
# SHA at startup; other statics go through the mount below # SHA and {{DONE_RATIO}} the watched threshold at startup; other statics
return HTMLResponse(_INDEX_HTML.replace("{{VERSION}}", VERSION)) # 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() _INDEX_HTML = (ROOT / "static" / "index.html").read_text()
+53 -52
View File
@@ -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 Lives outside app.py so it is unit-testable without booting the app
(tests/test_auth.py). The 0.5s wrong-password sleep is exactly why app.py's (tests/test_auth.py). Both buckets are in-memory timestamp lists — the app
login handler must stay a sync def: on the event loop it would stall every is single-process by design, so no store is needed; every read prunes
stream in flight; on the threadpool it only holds that one request. timestamps older than the window (sliding, not fixed).
Two in-memory layers (single process — same assumption as everything else): - per-username: FAILS_USER timestamps inside the window -> that account is
* per-username: 5 fails -> 15 min lockout for that name (was: the only locked, right or wrong password, until the newest fail ages out.
layer, so spraying fails across many names bypassed it entirely) - per-source-IP: IP_FAILS fails from one source across ANY usernames ->
* per-IP: IP_FAILS fails inside IP_WINDOW -> that IP is refused for the every login from that IP bounces. This is what username-rotation sweeps
rest of the window. Defaults are deliberately loose (15/300s): they cannot step around.
exist to stop username spraying, not to punish typos.
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 os
import time 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 _user_fails: dict = {} # username -> [fail timestamps, newest last]
LOCK_SECONDS = 900 _ip_fails: dict = {} # client ip -> [fail timestamps, newest last]
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)
def client_ip(request) -> str: def client_ip(request) -> str:
"""Client IP. X-Forwarded-For is trusted ONLY when ANIWEB_TRUST_XFF=1 """Source of a login attempt: socket peer, or the proxy-reported client
(run.sh sets it because production is always behind the local HTTPS when ANIWEB_TRUST_XFF=1 (see module docstring — spoofable, hence gated)."""
proxy); unconditional trust would let any client forge the header and if TRUST_XFF:
dodge the per-IP budget.""" first = (request.headers.get("x-forwarded-for") or "").split(",", 1)[0].strip()
if os.environ.get("ANIWEB_TRUST_XFF") == "1": if first:
xff = request.headers.get("x-forwarded-for", "") return first
if xff: return request.client.host if request.client is not None else "?"
return xff.split(",", 1)[0].strip()
return request.client.host if request.client else "?"
def ip_fail(ip: str) -> None: def _fresh(ts_list, now: float):
_ip_fails[ip] = (_ip_fails.get(ip, []) + [time.time()])[-50:] return [t for t in (ts_list or ()) if now - t < WINDOW]
def ip_locked(ip: str) -> bool: def locked(user: str, ip: str, now: float | None = None) -> bool:
now = time.time() """True when either bucket is over budget — caller must not verify."""
ts = [t for t in _ip_fails.get(ip, ()) if now - t < IP_WINDOW] now = time.time() if now is None else now
if ts: return (len(_fresh(_user_fails.get(user), now)) >= FAILS_USER
_ip_fails[ip] = ts or len(_fresh(_ip_fails.get(ip), now)) >= IP_FAILS)
return len(ts) >= IP_FAILS
def lock(user: str, pw: str, ip: str) -> str: def record(user: str, ip: str, now: float | None = None) -> None:
"""-> "ok" | "bad" | "locked" (per-username lockout or per-IP budget)""" """Count one failed attempt against both buckets (pruning stale fails)."""
if ip_locked(ip): now = time.time() if now is None else now
return "locked" _user_fails[user] = _fresh(_user_fails.get(user), now) + [now]
lo = _lockout.setdefault(user, {"fails": 0, "until": 0.0}) _ip_fails[ip] = _fresh(_ip_fails.get(ip), now) + [now]
if time.time() < lo["until"]:
return "locked"
if users.verify(user, pw): def clear(user: str) -> None:
lo.update(fails=0, until=0.0) """A correct password resets only that username's count; the IP bucket
return "ok" keeps aging on its own (one success must not un-lock a sweep)."""
lo["fails"] += 1 _user_fails.pop(user, None)
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) def reset() -> None:
ip_fail(ip) """Test hook: drop all buckets."""
return "bad" _user_fails.clear()
_ip_fails.clear()
+3 -3
View File
@@ -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. 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 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 gets absorbed (users.json, history/*.json, searches/*.json); the files are
are left in place as a last-known-good snapshot; the empty-table guard makes deletable once the tables are populated (the empty-table guard makes a
a second import impossible. Legacy ghost rows (same episode stored with int 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). AND str ep_id) are deduped on import: newest t per (user, slug, ep).
""" """
import json import json
+5 -4
View File
@@ -10,7 +10,7 @@ times until a CDN domain actually serves us.
""" """
import re import re
from ..upstream import _cached, _get from ..upstream import _cached, _get, megaplay_sources
BASE = "https://anikototv.to" BASE = "https://anikototv.to"
XHR = {"X-Requested-With": "XMLHttpRequest"} XHR = {"X-Requested-With": "XMLHttpRequest"}
@@ -100,8 +100,8 @@ def _player_file(embed_url):
if not m: if not m:
return None return None
host = re.match(r'https?://([^/]+)/', embed_url).group(1) host = re.match(r'https?://([^/]+)/', embed_url).group(1)
d = _get(f"https://{host}/stream/getSourcesNew?id={m.group(1)}", d = megaplay_sources(_get(f"https://{host}/stream/getSourcesNew?id={m.group(1)}",
headers={"Referer": embed_url, "X-Requested-With": "XMLHttpRequest"}).json() headers={"Referer": embed_url, "X-Requested-With": "XMLHttpRequest"}).json())
return (d.get("sources") or {}).get("file") return (d.get("sources") or {}).get("file")
@@ -117,7 +117,8 @@ def _master_m3u8(ep_id, lang="eng"):
url = (d.get("result") or {}).get("url") url = (d.get("result") or {}).get("url")
if not url: if not url:
continue continue
for _ in range(2): # re-roll: CDN domains rotate, most 403 our IP # ponytail: one attempt per server — the enc payload is deterministic,
# re-rolling would just spam the provider
try: try:
f = _player_file(url) f = _player_file(url)
if not f: if not f:
+196
View File
@@ -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
View File
@@ -13,7 +13,7 @@ import json
import re import re
from urllib.parse import urljoin, urlparse from urllib.parse import urljoin, urlparse
from ..upstream import _cached, _get from ..upstream import _cached, _get, megaplay_master
BASE = "https://hianime.at" BASE = "https://hianime.at"
KEY = b"otaku-embed-v1" KEY = b"otaku-embed-v1"
@@ -88,9 +88,8 @@ def _embed_payload(embed):
"""embed url -> (master_m3u8, [{label, src} subs], referer_or_None). """embed url -> (master_m3u8, [{label, src} subs], referer_or_None).
Two player chains have appeared: zokoanime (window.__P blob, Two player chains have appeared: zokoanime (window.__P blob,
base64+XOR) and megaplay (page needs the hianime referer; data-id -> base64+XOR — hianime-specific) and megaplay (shared: upstream.
getSourcesNew; CDN domains rotate and most 403 us, so re-roll until megaplay_master, used by animekai too)."""
one serves)."""
page = _get(embed, timeout=15).text page = _get(embed, timeout=15).text
blob = re.search(r'window\.__P="([^"]+)"', page) blob = re.search(r'window\.__P="([^"]+)"', page)
if blob: if blob:
@@ -98,29 +97,7 @@ def _embed_payload(embed):
subs = [{"label": (x or {}).get("label", ""), "src": (x or {}).get("src")} subs = [{"label": (x or {}).get("label", ""), "src": (x or {}).get("src")}
for x in j.get("subtitles") or []] for x in j.get("subtitles") or []]
return j.get("src"), subs, re.match(r'https?://[^/]+', embed).group(0) + "/" 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 return megaplay_master(embed, BASE + "/")
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
def _master_m3u8(ep_id, lang="eng"): 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" want = "sub" if lang in ("jpn", "sub") else "dub"
# HD-1 has rotated between player chains (zokoanime blob, megaplay); # HD-1 has rotated between player chains (zokoanime blob, megaplay);
# try every listed server of the wanted type until one resolves # try every listed server of the wanted type until one resolves
errs = []
for typ in (want, "sub") if want == "dub" else (want,): 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'data-type="{typ}"[\s\S]*?data-server-name="([^"]+)"'
rf'[\s\S]*?data-hash="([^"]+)"', t): rf'[\s\S]*?data-hash="([^"]+)"', t):
embed = base64.b64decode(h).decode() try:
master, subs, refr = _embed_payload(embed) master, subs, refr = _embed_payload(base64.b64decode(h).decode())
except Exception as e:
errs.append(f"{name}: {e}")
continue
if not master: if not master:
errs.append(f"{name}: no source")
continue continue
REFERER = refr REFERER = refr
# prove the CDN serves us and register the hosts so the /stream proxy # 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"): if pick and pick.get("src"):
SUBS[ep_id] = pick["src"] SUBS[ep_id] = pick["src"]
return master 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:". # Provider interface. Cache keys are namespaced with "hianime:".
+56
View File
@@ -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; impersonation, TTL cache) live here so every provider gets them for free;
they work on any URL, not just anidb's. they work on any URL, not just anidb's.
""" """
import base64
import importlib import importlib
import json
import os import os
import re import re
import threading import threading
import time import time
from Crypto.Cipher import AES
from curl_cffi import requests from curl_cffi import requests
H = dict(impersonate="chrome", timeout=20) H = dict(impersonate="chrome", timeout=20)
@@ -55,6 +58,59 @@ def _cached(key, ttl, fn, *a, **kw):
def clear_cache(): def clear_cache():
_CACHE.clear() _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): def _get(url, **kw):
# ponytail: one retry (300ms) on transient upstream errors (Cloudflare # ponytail: one retry (300ms) on transient upstream errors (Cloudflare
+235 -44
View File
@@ -10,6 +10,9 @@ const BASE = (() => {
return p.endsWith("/") ? p : p.slice(0, p.lastIndexOf("/") + 1); return p.endsWith("/") ? p : p.slice(0, p.lastIndexOf("/") + 1);
})(); })();
const main = document.getElementById("main"); 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; let hls = null, detail = null, lang = "jpn", seasonId = null;
async function api(path) { async function api(path) {
@@ -127,7 +130,53 @@ const bar = (x, cls) => {
b.append(Object.assign(el("div", "bar-fill"), {style: `width:${p}%`})); b.append(Object.assign(el("div", "bar-fill"), {style: `width:${p}%`}));
return b; 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 // one entry per anime (latest row wins), newest first
const byShow = () => { const byShow = () => {
const m = new Map(); const m = new Map();
@@ -144,7 +193,7 @@ const homeCard = x => {
c.append(poster(x.poster, x.title)); c.append(poster(x.poster, x.title));
let meta = `E${x.ep} \u00b7 ${x.lang === "eng" ? "dub" : "sub"}`; let meta = `E${x.ep} \u00b7 ${x.lang === "eng" ? "dub" : "sub"}`;
if (done) meta += " \u00b7 \u2713"; 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`; else if (hold) meta += ` \u00b7 ${Math.round(Date.now() / 1000 / 86400 - x.t / 86400)}d ago`;
const info = el("div", "mini-info"); const info = el("div", "mini-info");
info.append(el("div", "card-name", x.title), el("div", "mini-meta", meta)); info.append(el("div", "card-name", x.title), el("div", "mini-meta", meta));
@@ -189,6 +238,8 @@ function showHome() {
return; return;
} }
const hero = history[0]; const hero = history[0];
main.append(remapBoard());
if (hero && hero._remapped) return;
// 1. continue-watching hero // 1. continue-watching hero
const sec = el("section", "hero-sec"); const sec = el("section", "hero-sec");
const card = el("a", "hero"); const card = el("a", "hero");
@@ -202,11 +253,11 @@ function showHome() {
body.append(el("div", "hero-kicker", "continue watching"), body.append(el("div", "hero-kicker", "continue watching"),
el("h2", null, hero.title), el("h2", null, hero.title),
el("div", "muted", `Ep ${hero.ep} · ${hero.lang === "eng" ? "dub" : "sub"} · ${fmtPos(hero.pos)} in` + 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)}`)); ` · ${timeago(hero.t)}`));
const p = bar(hero); const p = bar(hero);
if (p) body.append(p); 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"); const go = el("button", "btn resume", heroDone ? "\u25b6 Next ep" : "\u25b6 Resume");
go.onclick = () => location.href = histHref(hero); go.onclick = () => location.href = histHref(hero);
right.append(body, go); right.append(body, go);
@@ -398,6 +449,22 @@ async function showAnime(slug_id, opts = {}) {
anB.classList.toggle("on", autoNext); anB.classList.toggle("on", autoNext);
}; };
const ctl = [langBtn, spd, prevB, nextB, fillB, jump, anB]; 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) { if (document.pictureInPictureEnabled) {
const pipB = el("button", "tiny", "\u29c9 PiP"); const pipB = el("button", "tiny", "\u29c9 PiP");
pipB.title = "picture-in-picture"; 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")) { for (const x of await api("/api/history?all=1")) {
if (x.slug_id !== slug_id || x.season_id !== seasonId) continue; if (x.slug_id !== slug_id || x.season_id !== seasonId) continue;
started = true; 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))); else if (x.dur > 0) epProg.set(String(x.ep_id), Math.min(100, Math.round(100 * x.pos / x.dur)));
} }
} catch {} } catch {}
@@ -465,8 +532,24 @@ async function showAnime(slug_id, opts = {}) {
function langLabel() { return lang === "eng" ? "▶ dub" : "▶ sub"; } function langLabel() { return lang === "eng" ? "▶ dub" : "▶ sub"; }
// ---------- history page (full list + delete + client-side filter) ---------- // ---------- 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"); : (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() { async function showHistory() {
document.title = "History \u2014 ani-web"; document.title = "History \u2014 ani-web";
skel(10); skel(10);
@@ -474,6 +557,7 @@ async function showHistory() {
main.innerHTML = ""; main.innerHTML = "";
main.append(el("h2", null, "History")); main.append(el("h2", null, "History"));
if (!items.length) { main.append(el("p", "muted", "Nothing here.")); focusMain(); return; } if (!items.length) { main.append(el("p", "muted", "Nothing here.")); focusMain(); return; }
await Promise.all(items.map(checkEntry));
main.append(...histBoard(items)); main.append(...histBoard(items));
focusMain(); focusMain();
} }
@@ -576,12 +660,14 @@ function topAnime(d, n = 0) {
return ta; return ta;
} }
function histBoard(items, limit = 0) { 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"}; const labels = {all: "all", watching: "watching", done: "completed", hold: "on hold"};
let active = "all"; let active = "all";
const chips = el("div", "chips"); const chips = el("div", "chips");
const grid = el("div", "grid"); const grid = el("div", "grid");
const draw = () => { 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); if (limit) f = f.slice(0, limit);
grid.replaceChildren(...f.map(histCard)); grid.replaceChildren(...f.map(histCard));
for (const c of chips.children) c.classList.toggle("on", c.dataset.f === active); 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); chips.append(b);
} }
draw(); 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) { function heatMap(byDay, days) {
@@ -791,7 +885,17 @@ function playerFail(msg) {
savePos(lastSeenPos); savePos(lastSeenPos);
const { ep_id, ep_no } = currentEp; const { ep_id, ep_no } = currentEp;
postEvent({type: "error", detail: msg, ep_id, ep: ep_no, lang, ua: navigator.userAgent}); 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 ---------- // ---------- buffering visuals: buffer-ahead gauge + CDN chip ----------
@@ -819,46 +923,128 @@ function bufferGauge() {
bufTimer = setInterval(tick, 250); 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() { function statusPill() {
// header badge: upstream site + CDN status from /api/cdn. Lives in the api("/api/cdn").then(d => {
// (sticky) header so it's visible on the home screen — if the upstream is _status = d;
// down you never reach the player, so that's where the status must live. 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"); const p = document.getElementById("pill");
if (!p) return; if (!p) return;
api("/api/cdn").then(d => { const known = (d.origin.state === "OK" || d.origin.state === "DOWN")
const o = d.origin.state === "OK" ? "ok" : d.origin.state === "DOWN" ? "down" : ""; && (d.state === "OK" || d.state === "DEGRADED");
const c = d.state === "OK" ? "ok" : d.state === "DEGRADED" ? "degraded" : ""; const down = d.origin.state === "DOWN";
p.className = "pill" + (o === "down" ? " odown" : ""); const slow = !down && d.state === "DEGRADED";
const od = p.querySelector(".o"), cd = p.querySelector(".c"); const st = !known ? "" : down ? "down" : slow ? "slow" : "ok";
od.className = "o " + o; p.className = "pill" + (st === "down" ? " down" : "");
cd.className = "c " + c; p.querySelector(".d").className = "d" + (st ? " " + st : "");
p.querySelector("span").textContent = p.querySelector("span").textContent =
o === "down" ? "upstream down" : c === "degraded" ? "cdn slow" : "up"; !st ? "…" : st === "down" ? "down"
// per-dot hover tooltips (desktop) + full aria label (a11y / screen readers) : st === "slow" ? "slow" + (d.kbps ? " · " + d.kbps + " KB/s" : "") : "up";
od.title = "upstream site (anidb.app pages + API): " + d.origin.state p.title = "upstream site: " + d.origin.state + (d.origin.msg ? " — " + d.origin.msg : "")
+ (d.origin.msg ? " — " + d.origin.msg : ""); + " · video CDN: " + d.state + (d.kbps ? " — " + d.kbps + " KB/s" : "");
cd.title = "video CDN (hls.anidb.app) throughput: " + d.state p.setAttribute("aria-label", "status: " + (!st ? "checking"
+ " — " + d.kbps + " KB/s"; : st === "down" ? "down — upstream " + d.origin.state
p.title = ""; // dot-level titles carry the detail; pill-level would shadow them : st === "slow" ? "slow — CDN " + d.state + (d.kbps ? ", " + d.kbps + " KB/s" : "")
p.setAttribute("aria-label", "upstream site " + d.origin.state : "up"));
+ (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 = "?"; }); 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) ---------- // ---------- auto-next on episode end (5s countdown) ----------
let nextTimer = null; let nextTimer = null;
function onEnded() { 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", branch: (!window.Hls || !Hls.isSupported()) ? (nativeOK !== "none" && nativeOK !== "" ? "native" : "none-throw") : "hlsjs",
hls_lib: typeof window.Hls, mse, native_hls: nativeOK, ua: navigator.userAgent}); hls_lib: typeof window.Hls, mse, native_hls: nativeOK, ua: navigator.userAgent});
} catch {} } 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. // 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, // iOS Safari plays standard m3u8 in <video> directly — same proxied URL,
// same-origin, so no CORS needed. // same-origin, so no CORS needed.
@@ -1032,7 +1222,7 @@ async function play(ep_id, ep_no) {
} }
if (saved?.pos > 10) { if (saved?.pos > 10) {
v.addEventListener("loadedmetadata", () => { 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}); }, {once: true});
} }
v.onerror = () => playerFail("media error"); 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 */ } } catch { /* not logged in: api() already redirected to /login */ }
statusPill(); statusPill();
await refreshHistory(); await refreshHistory();
await Promise.all(history.map(checkEntry));
const p = new URLSearchParams(location.search); const p = new URLSearchParams(location.search);
try { try {
if (p.get("q")) await showSearch(p.get("q")); if (p.get("q")) await showSearch(p.get("q"));
+4 -1
View File
@@ -18,7 +18,7 @@
<form onsubmit="const q=document.getElementById('q').value.trim(); if(q) location='?q='+encodeURIComponent(q); return false"> <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"> <input id="q" placeholder="search anime…" aria-label="search anime" autocomplete="off" autocapitalize="off" spellcheck="false">
</form> </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> <span id="me" class="muted"></span>
<div id="menubox" style="display:none"> <div id="menubox" style="display:none">
<button id="menu" class="tiny" aria-label="menu" aria-haspopup="true">☰</button> <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. 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. --> No hand-bumping; JSV in app.js reads the same value from its own URL. -->
<script src="hls.min.js"></script> <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> <script src="app.js?v={{VERSION}}"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -49,7 +49,7 @@ const next = new URLSearchParams(location.search).get("next");
async function login() { async function login() {
$("err").textContent = ""; $("err").textContent = "";
const r = await fetch(BASE + "login", {method: "POST", 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})}); body: JSON.stringify({username: $("user").value, password: $("pw").value})});
if (r.ok) { if (r.ok) {
location.href = next ? BASE + next.replace(/^\/+/, "") : BASE; location.href = next ? BASE + next.replace(/^\/+/, "") : BASE;
+17 -6
View File
@@ -163,16 +163,27 @@ button.tiny.on { border-color: var(--acc); color: var(--acc); }
background: rgba(255, 255, 255, .1); } background: rgba(255, 255, 255, .1); }
.pl-buffer i { display: block; height: 100%; width: 0; .pl-buffer i { display: block; height: 100%; width: 0;
background: linear-gradient(90deg, var(--acc), #fbbf24); transition: width .25s ease; } 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; 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; 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); border: 1px solid var(--bd); background: var(--card); color: var(--fg);
cursor: help; } cursor: help; }
header .pill i { width: 8px; height: 8px; flex: none; border-radius: 50%; background: #6b7280; } .status-panel { position: absolute; top: 100%; right: 0; z-index: 6; margin-top: .35rem;
header .pill .o.ok, header .pill .c.ok { background: #22c55e; } padding: .5rem .65rem; border-radius: 8px; border: 1px solid var(--bd);
header .pill .c.degraded { background: #f59e0b; } background: var(--card); color: var(--fg); font-size: .78rem; min-width: 170px;
header .pill .o.down { background: #ef4444; } display: flex; flex-direction: column; gap: .3rem; box-shadow: 0 4px 16px rgba(0,0,0,.4); }
header .pill.odown { border-color: #7f1d1d; } .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%); #toasts { position: fixed; bottom: 1rem; left: 50%; transform: translateX(-50%);
display: flex; flex-direction: column; gap: .4rem; z-index: 9; } display: flex; flex-direction: column; gap: .4rem; z-index: 9; }
@keyframes toast-in { from { opacity: 0; transform: translateY(.4rem); } } @keyframes toast-in { from { opacity: 0; transform: translateY(.4rem); } }
+4 -4
View File
@@ -53,10 +53,10 @@ def server():
data = Path(tempfile.mkdtemp(prefix="aniweb-test-")) data = Path(tempfile.mkdtemp(prefix="aniweb-test-"))
os.environ["ANIWEB_DATA"] = str(data) os.environ["ANIWEB_DATA"] = str(data)
# the per-IP login budget must never trip during the suite (all tests # test-tuned throttle: small IP budget + short window so the per-IP test
# share 127.0.0.1); per-username lockout stays at production defaults # can both trip the gate and watch it expire inside one test run
os.environ.setdefault("ANIWEB_IP_FAILS", "1000") os.environ["ANIWEB_IP_FAILS"] = "10"
os.environ.setdefault("ANIWEB_IP_WINDOW", "300") os.environ["ANIWEB_LOCK_WINDOW"] = "8"
sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT))
from server import db, users # noqa: E402 from server import db, users # noqa: E402
db.init() # schema before users.add / seeding db.init() # schema before users.add / seeding
+133
View File
@@ -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"
+103
View File
@@ -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
View File
@@ -297,7 +297,8 @@ def test_self_delete(base=BASE):
def test_csrf_header_required(base=BASE): def test_csrf_header_required(base=BASE):
# mutation routes without the X-Aniweb header -> 403 (a cross-site form # 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) jar = login("admin", "adminpw12345", base)
r = urllib.request.Request(base + "/logout", method="POST") r = urllib.request.Request(base + "/logout", method="POST")
try: try:
@@ -320,15 +321,54 @@ def test_stream_token_host_allowlist(base=BASE):
assert req("GET", f"/stream/{bad2}", jar=jar, base=base) == 400 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): def test_lockout_per_user(base=BASE):
# a throwaway name: the lockout is per-username and in-memory, and bob is # a throwaway name: the buckets are in-memory and per-username, and bob
# also used by the browser tests (a locked-out bob breaks their logins) # 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) codes = [req("POST", "/login", {"username": "lockout-ghost", "password": "bad"}, base=base)
for _ in range(6)] for _ in range(6)]
assert codes[:5] == [401] * 5 and codes[5] == 429, codes assert codes[:5] == [401] * 5 and codes[5] == 429, codes
assert req("POST", "/login", {"username": "admin", "password": "adminpw12345"}, base=base) == 200 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 --- # --- helpers ---
import os # noqa: E402 import os # noqa: E402
@@ -466,3 +506,25 @@ def test_fetch_parallel_stitches_range_lanes():
finally: finally:
u._session = real u._session = real
u._get = real_get 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
View File
@@ -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 Bucket logic in isolation: per-username lockout, per-source-IP budget,
lockout's HTTP behaviour stays covered in test_api.py. 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 import sys
from types import SimpleNamespace 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"): class _Peer:
return SimpleNamespace(headers=dict(headers or {}), host = "203.0.113.9"
client=SimpleNamespace(host=host))
def test_ip_budget_counts_prunes_isolates(monkeypatch): class _Req:
from server import auth def __init__(self, headers=None):
monkeypatch.setattr(auth, "IP_FAILS", 3) self.headers = headers or {}
monkeypatch.setattr(auth, "IP_WINDOW", 60.0) self.client = _Peer()
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")
def test_client_ip_xff_trust_rule(monkeypatch): def test_username_lockout_and_sliding_expiry():
from server import auth auth.reset()
r = _req({"x-forwarded-for": "9.9.9.9"}) t = 1_000_000.0
monkeypatch.delenv("ANIWEB_TRUST_XFF", raising=False) for _ in range(auth.FAILS_USER):
assert auth.client_ip(r) == "203.0.113.7" # header NOT trusted by default auth.record("locked-guy", "198.51.100.7", now=t)
monkeypatch.setenv("ANIWEB_TRUST_XFF", "1") assert auth.locked("locked-guy", "198.51.100.7", now=t)
assert auth.client_ip(r) == "9.9.9.9" # honored only when explicitly on # window is sliding: once every fail ages out, the name is free again
assert auth.client_ip(_req()) == "203.0.113.7" # no header -> socket peer 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): def test_ip_budget_survives_username_rotation():
from server import auth auth.reset()
monkeypatch.setattr(auth, "IP_FAILS", 1000) t = 2_000_000.0
auth._lockout["ghost"] = {"fails": 0, "until": time.time() + 900} for i in range(auth.IP_FAILS): # one fresh username per fail...
assert auth.lock("ghost", "x", "203.0.113.3") == "locked" 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): def test_client_ip_trust_gating():
from server import auth # XFF is only consulted when the trust flag is on (it is off here — the
monkeypatch.setattr(auth, "IP_FAILS", 1000) # keep the IP gate out of the way # flag is for the prod proxy path, not the test process)
auth._ip_fails.clear() r = _Req({"x-forwarded-for": "5.6.7.8, 10.0.0.1"})
auth._lockout.clear() # module state is shared across tests assert auth.TRUST_XFF is False
assert auth.lock("ghost", "wrong123456", "203.0.113.4") == "bad" # ghost: no user assert auth.client_ip(r) == "203.0.113.9" # spoofed header ignored
assert len(auth._ip_fails.get("203.0.113.4", [])) == 1 assert auth.client_ip(_Req()) == "203.0.113.9"
+80 -3
View File
@@ -2,15 +2,24 @@
Run via pytest (see conftest.py for the throwaway server). Covers what 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 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 asyncio
import http.cookiejar import http.cookiejar
import json import json
import os
import urllib.request import urllib.request
import pytest 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 async_playwright = pytest.importorskip("playwright.async_api").async_playwright
BASE = "http://127.0.0.1:8399" 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 = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
op.open(urllib.request.Request(BASE + "/login", op.open(urllib.request.Request(BASE + "/login",
data=json.dumps({"username": "erin", "password": "erinpw12345"}).encode(), 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 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, e = {"title": f"Hist {n}", "slug_id": f"hist-{n}", "season_id": n, "ep_id": n,
"lang": "eng", "ep": n, "pos": 5, "dur": 600} "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 = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
op.open(urllib.request.Request(BASE + "/login", op.open(urllib.request.Request(BASE + "/login",
data=json.dumps({"username": "alice", "password": "alicepw12345"}).encode(), 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", op.open(urllib.request.Request(BASE + "/api/searches",
data=json.dumps({"q": "chainsaw"}).encode(), data=json.dumps({"q": "chainsaw"}).encode(),
headers={"Content-Type": "application/json", "X-Aniweb": "1"}, method="POST")) headers={"Content-Type": "application/json", "X-Aniweb": "1"}, method="POST"))
@@ -441,3 +450,71 @@ async def _autoplay_blocked():
timeout=120000, polling=250) timeout=120000, polling=250)
assert not errors, errors assert not errors, errors
await browser.close() 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()
+127
View File
@@ -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()