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

|
||||||
because the app is meant to sit behind your proxy on the open
|
|
||||||
internet. Each user gets a fully isolated watch history
|
|
||||||
(`server/data/aniweb.db`, SQLite): nothing is stored in the browser,
|
|
||||||
so a new device picks up your exact watch state after login. Deleting
|
|
||||||
an account (hamburger menu → delete, or `server.users rm`) kills its
|
|
||||||
session cookies immediately and removes its history.
|
|
||||||
|
|
||||||
The history page shows every entry with poster, progress, and a
|
- **Home** — continue-watching hero (poster, position, Resume) plus a
|
||||||
per-entry delete button:
|
recently-played grid with progress bars.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
### Home: continue watching
|
- **Search** — poster-grid results from the active source; your last 8
|
||||||
|
searches (per user) in a live-filtering dropdown. Results open the
|
||||||
|
detail view (synopsis, season tabs, filler badges).
|
||||||
|
|
||||||
The home screen leads with a **continue watching** hero — the most
|

|
||||||
recently watched episode with its poster, position, and a Resume
|
|
||||||
button — followed by a **recently played** grid with progress bars and
|
|
||||||
relative timestamps.
|
|
||||||
|
|
||||||

|
- **Player** — resume at the saved position, skip intro (MAL timings),
|
||||||
|
dub/sub toggle, playback speed, hide fillers, auto-next with a
|
||||||
|
5-second countdown, ✓ on completed episodes. Playback goes through a
|
||||||
|
backend HLS proxy (the CDN sends no CORS headers); segments are
|
||||||
|
pulled across parallel byte-range lanes so a slow connection doesn't
|
||||||
|
stall.
|
||||||
|
|
||||||
### Search
|

|
||||||
|
|
||||||
The header search box queries the active source and shows results as a
|
- **Status** — the header pill shows one consolidated state — up /
|
||||||
poster grid. Your last 8 searches (per user) appear in a dropdown under
|
slow (KB/s) / down — as a single dot; tap opens a panel with the
|
||||||
the box, filter live as you type, and click to re-run. Results open the
|
per-host breakdown, the ~1h CDN trend (dashed slow-threshold line),
|
||||||
detail view: synopsis and season tabs (where the source provides
|
and origin up/down history. A failure overlay names the CDN when
|
||||||
them), plus filler badges.
|
it's the likely culprit.
|
||||||
|
|
||||||

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

|
|
||||||
|
|
||||||
### Stats
|
|
||||||
|
|
||||||
`?stats` (hamburger menu) — watch minutes, episodes started/completed,
|
|
||||||
active days + best streak, sub/dub split, weekday and per-day heatmaps,
|
|
||||||
top-10 shows, top searches. Derived server-side from the position-event
|
|
||||||
stream (`POST /api/events`), 30–365 day windows.
|
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
server/ FastAPI app (app.py), source providers (providers/: hianime,
|
server/ FastAPI app (app.py), source providers (providers/: hianime,
|
||||||
anidb, anikoto) over shared HTTP + dispatch (upstream.py),
|
anidb, anikoto) over shared HTTP + dispatch (upstream.py)
|
||||||
accounts (users.py). upstream.py + providers/ are the ONLY
|
static/ front-end: index.html, login.html, app.js, hls.min.js (vendored)
|
||||||
Cloudflare-facing code
|
tests/ pytest suite: hermetic default, live browser e2e opt-in
|
||||||
static/ front-end: index.html, login.html, app.js, style.css, hls.min.js (vendored)
|
|
||||||
tests/ pytest suite: conftest.py (throwaway server), HTTP + browser tests
|
|
||||||
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
|
||||||
|
|
||||||
- The upstream sites sit behind Cloudflare; all upstream requests use
|
- `.venv/bin/pytest tests/ -q` — hermetic suite against a throwaway
|
||||||
`curl_cffi` Chrome TLS impersonation. Sources are ordered by
|
server (never touches real data). Live-upstream browser e2e is
|
||||||
`ANI_SOURCE` (default `hianime,anidb,anikoto`) — a dead one falls
|
opt-in: `ANIWEB_LIVE_TESTS=1 .venv/bin/pytest tests/ -q`
|
||||||
through to the next. All upstream-facing code is isolated in
|
(needs playwright chromium).
|
||||||
`server/upstream.py` + `server/providers/`.
|
- `ANI_SOURCE=anidb .venv/bin/python -m server.upstream` — live
|
||||||
- The video CDN sends no CORS headers, so playback goes through the
|
self-check of the anidb fetchers (must be the `-m` form; plain
|
||||||
backend HLS proxy (`/stream/{token}`), which rewrites playlist URIs.
|
script execution breaks the providers' relative imports).
|
||||||
- `ANI_SOURCE=anidb .venv/bin/python -m server.upstream` runs a live
|
- Everything else (Cloudflare/impersonation, HLS proxy, CDN probe,
|
||||||
self-check of the anidb fetchers (search → details → episodes → dub
|
auth hardening, cache-busting): [SPEC.md](SPEC.md).
|
||||||
stream). Must be the `-m` form — plain script execution breaks the
|
|
||||||
providers' relative imports.
|
|
||||||
- A background probe fetches 64KB from the CDN (and checks the origin
|
|
||||||
site) every 3 min and logs state flips to `server.log`; `GET
|
|
||||||
/api/cdn` (public) returns the latest throughput + ~1h history +
|
|
||||||
origin state. Use it to check if "playback broken" is the CDN again.
|
|
||||||
- The front-end is version-pinned: the server injects the git SHA as the
|
|
||||||
`?v=` cache-bust on `app.js`/`style.css` at startup (index.html itself
|
|
||||||
is `no-store`), so a restart is a cache bump — nothing to hand-bump.
|
|
||||||
- Mutations need the `X-Aniweb: 1` header (CSRF: cross-site form POSTs
|
|
||||||
can't set it), `/login` POST included — forced-login is dead.
|
|
||||||
- Login throttling (server/auth.py): per-username 5 fails -> 15 min lock,
|
|
||||||
plus a per-source-IP budget; both sliding windows, in-memory.
|
|
||||||
- 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 test suite against a
|
|
||||||
throwaway server (:8399, temp data dir — never touches the real
|
|
||||||
data). The default suite (API + auth + dispatch tests) is hermetic
|
|
||||||
and deterministic; the Playwright browser tests scrape and play LIVE
|
|
||||||
upstream content, so they are opt-in: `ANIWEB_LIVE_TESTS=1
|
|
||||||
.venv/bin/pytest tests/ -q` (needs playwright chromium installed).
|
|
||||||
@@ -82,6 +82,11 @@ an exception falls through to the next provider, a `None` from
|
|||||||
position-event stream.
|
position-event stream.
|
||||||
- `GET /api/cdn` → CDN + origin health (background 64KB probe every
|
- `GET /api/cdn` → CDN + origin health (background 64KB probe every
|
||||||
3 min, ~1h history) — check whether "playback broken" is the CDN.
|
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 (host-allowlisted against the providers' self-registered
|
upstream URL (host-allowlisted against the providers' self-registered
|
||||||
CDN hosts — anti-SSRF). Rewrites every non-comment line of m3u8
|
CDN hosts — anti-SSRF). Rewrites every non-comment line of m3u8
|
||||||
@@ -124,6 +129,10 @@ across parallel byte-range lanes (single-stream fallback).
|
|||||||
Views: home (continue watching), search, detail+player, full history
|
Views: home (continue watching), search, detail+player, full history
|
||||||
(`?history`), stats (`?stats`); PWA-installable.
|
(`?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
|
||||||
@@ -143,6 +152,10 @@ Use the app through your HTTPS proxy (with `Secure` cookies, plain
|
|||||||
remote HTTP can't hold a session). `.venv/bin/pytest tests/ -q` runs
|
remote HTTP can't hold a session). `.venv/bin/pytest tests/ -q` runs
|
||||||
the hermetic suite against a throwaway server on :8399; the
|
the hermetic suite against a throwaway server on :8399; the
|
||||||
live-upstream browser e2e is opt-in (`ANIWEB_LIVE_TESTS=1`).
|
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)
|
||||||
|
|
||||||
@@ -176,6 +189,5 @@ shipped since this list was written.) 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, `docs/CHANGES-2026-09.md`): provider layer with
|
8. Later (see git log): provider layer with hianime primary, SQLite
|
||||||
hianime primary, SQLite storage, stats/events, 2026-09 security
|
storage, stats/events, 2026-09 security audit batch.
|
||||||
audit batch.
|
|
||||||
@@ -1,625 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
|
|
||||||
version_number="5.0.0"
|
|
||||||
|
|
||||||
# UI
|
|
||||||
|
|
||||||
# shellcheck disable=SC2086
|
|
||||||
# $1 = prompt, $2 = multi select flag (for fzf/rofi), $3 = extra flags by user
|
|
||||||
menu() {
|
|
||||||
case "$menu_program" in
|
|
||||||
fzf) fzf --reverse --cycle --prompt "$1" $2 $3 ;;
|
|
||||||
rofi) rofi -sort -dmenu -i -p "$1" $2 $3 ;;
|
|
||||||
dmenu) dmenu -l 20 -p "$1" $3 ;;
|
|
||||||
*) "$menu_program" $3 "$1" ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
# provide menu. input format is either (ep_list) or (number \t anime_id \t anime_name)
|
|
||||||
nth() {
|
|
||||||
_stdin=$(cat -)
|
|
||||||
[ -z "$_stdin" ] && return 1
|
|
||||||
_line_count="$(printf "%s\n" "$_stdin" | wc -l | tr -d "[:space:]")"
|
|
||||||
[ "$_line_count" -eq 1 ] && printf "%s" "$_stdin" | cut -f 2,3 && return 0
|
|
||||||
_prompt="$1"
|
|
||||||
[ $# -ne 1 ] && _multi_flag="$2"
|
|
||||||
_line=$(printf "%s" "$_stdin" | cut -f 1,3 | tr '\t' ' ' | menu "$_prompt" "$_multi_flag" "$menu_extra_flags" | cut -d " " -f 1)
|
|
||||||
_line_start=$(printf "%s" "$_line" | head -n 1)
|
|
||||||
_line_end=$(printf "%s" "$_line" | tail -n 1)
|
|
||||||
[ -n "$_line" ] || return 1
|
|
||||||
if [ "$_line_start" = "$_line_end" ]; then
|
|
||||||
printf "%s" "$_stdin" | grep -E '^'"${_line}"'($|[[:space:]])' | cut -f 2,3 || return 1
|
|
||||||
else
|
|
||||||
printf "%s" "$_stdin" | sed -n '/^'"${_line_start}"'$/,/^'"${_line_end}$"'/p' || return 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
die() {
|
|
||||||
printf "\33[2K\r\033[1;31m%s\033[0m\n" "$*" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
info() {
|
|
||||||
printf "\33[2K\r\033[1;34m%s\033[0m\n" "$*"
|
|
||||||
}
|
|
||||||
|
|
||||||
help_info() {
|
|
||||||
printf "
|
|
||||||
Usage:
|
|
||||||
%s [options] [query]
|
|
||||||
%s [query] [options]
|
|
||||||
%s [options] [query] [options]
|
|
||||||
|
|
||||||
Options:
|
|
||||||
-c, --continue
|
|
||||||
Continue watching from history
|
|
||||||
-d, --download
|
|
||||||
Download the video instead of playing it
|
|
||||||
-D, --delete
|
|
||||||
Delete history
|
|
||||||
-l, --logview
|
|
||||||
Show logs
|
|
||||||
-s, --syncplay
|
|
||||||
Use Syncplay to watch with friends
|
|
||||||
-S, --select-nth
|
|
||||||
Select nth entry
|
|
||||||
-q, --quality
|
|
||||||
Specify the video quality
|
|
||||||
-v, --vlc
|
|
||||||
Use VLC to play the video
|
|
||||||
-V, --version
|
|
||||||
Show the version of the script
|
|
||||||
-h, --help
|
|
||||||
Show this help message and exit
|
|
||||||
-e, --episode, -r, --range
|
|
||||||
Specify the number of episodes to watch
|
|
||||||
--dub
|
|
||||||
Play dubbed version
|
|
||||||
--rofi
|
|
||||||
Use rofi instead of fzf for the interactive menu
|
|
||||||
--dmenu
|
|
||||||
Use dmenu instead of fzf for the interactive menu
|
|
||||||
--skip
|
|
||||||
Use ani-skip to skip the intro of the episode (mpv only)
|
|
||||||
--no-detach
|
|
||||||
Don't detach the player (useful for in-terminal playback, mpv only)
|
|
||||||
--exit-after-play
|
|
||||||
Exit the player, and return the player exit code (useful for non interactive scenarios)
|
|
||||||
-N, --nextep-countdown
|
|
||||||
Display a countdown to the next episode
|
|
||||||
-U, --update
|
|
||||||
Update the script
|
|
||||||
Some example usages:
|
|
||||||
%s -q 720p banana fish
|
|
||||||
%s --skip -S 2 one piece
|
|
||||||
%s -d -e 2 cyberpunk edgerunners
|
|
||||||
%s --vlc cyberpunk edgerunners -q 1080p -e 4
|
|
||||||
%s blue lock -e 5-6
|
|
||||||
%s -e \"5 6\" blue lock
|
|
||||||
\n" "${0##*/}" "${0##*/}" "${0##*/}" "${0##*/}" "${0##*/}" "${0##*/}" "${0##*/}" "${0##*/}" "${0##*/}"
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
version_info() {
|
|
||||||
printf "%s\n" "$version_number"
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Bookkeeping
|
|
||||||
|
|
||||||
update_script() {
|
|
||||||
info "[branch:$branch] Checking for Update.."
|
|
||||||
update="$($curl_exe --fail-with-body -sL -A "$agent" "https://raw.githubusercontent.com/pystardust/ani-cli/$branch/ani-cli")" || die "[branch:$branch] Connection error: $update"
|
|
||||||
new_version="$(printf "%s" "$update" | sed -nE 's|^version_number="([^"]+)"$|\1|p')"
|
|
||||||
[ -z "$new_version" ] && die "[branch:$branch] Invalid response."
|
|
||||||
printf "%s" "$update" | grep -q "^version_number" || die "[branch:$branch] Invalid response."
|
|
||||||
update="$(printf "%s\n" "$update" | diff -u "$0" -)"
|
|
||||||
if [ -z "$update" ]; then
|
|
||||||
info "[branch:$branch] Script is up to date :) ($version_number)"
|
|
||||||
else
|
|
||||||
if printf "%s\n" "$update" | patch "$0" -; then
|
|
||||||
info "[branch:$branch] Updated: $version_number -> $new_version"
|
|
||||||
else
|
|
||||||
die "[branch:$branch] Failed to update. Read the above line!"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# checks if dependencies are present
|
|
||||||
dep_ch() {
|
|
||||||
command -v "$1" >/dev/null || die "Program $1 not found. Please install it."
|
|
||||||
}
|
|
||||||
|
|
||||||
# checks for deps in order and returns the first available. format is "prog_a,prog_b,prog_c"
|
|
||||||
dep_ch_failover() {
|
|
||||||
[ -z "$1" ] && return 1
|
|
||||||
prog=$(printf "%s" "$1" | cut -d "," -f 1)
|
|
||||||
rest=$(printf "%s" "$1" | cut -s -d "," -f 2-)
|
|
||||||
command -v "$prog" >/dev/null 2>&1 && printf "%s" "$prog" && return 0
|
|
||||||
[ -e "$prog" ] && printf "%s" "$prog" && return 0
|
|
||||||
dep_ch_failover "$rest"
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup() {
|
|
||||||
tput sgr0 # clears colors
|
|
||||||
rm -f "${histfile}.new" # remove temp logfile
|
|
||||||
}
|
|
||||||
|
|
||||||
# SCRAPING
|
|
||||||
|
|
||||||
#shellcheck disable=SC2086
|
|
||||||
anidb_curl() {
|
|
||||||
$curl_exe -sL -A "$agent" --max-time 10 $cipher_flag "$@"
|
|
||||||
}
|
|
||||||
|
|
||||||
# search the query and give results. format is (id \t name)
|
|
||||||
anidb_search() {
|
|
||||||
#shellcheck disable=SC2059
|
|
||||||
_page="$(anidb_curl "$(printf "$search_api" "$1")" | tr '\n' ' ' | sed 's|<a href|\n<a href|g')"
|
|
||||||
if printf "%s" "$_page" | grep -qi "Just a moment"; then
|
|
||||||
[ "$curl_exe" = "curl" ] && die "Blocked by cloudflare. Try installing curl-impersonate"
|
|
||||||
die "Blocked by cloudflare."
|
|
||||||
fi
|
|
||||||
printf "%s" "$_page" | sed -nE 's|.*anime/([a-z0-9-]+-[0-9]+)".*alt="([^"]+)".*|\1\t\2|p' | sed -e "s|'|'|g" -e 's|"|"|g'
|
|
||||||
}
|
|
||||||
|
|
||||||
# extract mal_id and seasons metadata
|
|
||||||
anidb_desc() {
|
|
||||||
#shellcheck disable=SC2059
|
|
||||||
_page="$(anidb_curl "$(printf "$desc_api" "$1")" | tr '\n' ' ' | sed 's|<a href|\n<a href|g')"
|
|
||||||
mal_id="$(printf "%s" "$_page" | sed -nE 's|.*https://myanimelist.net/anime/([0-9]+)/.*|\1|p')"
|
|
||||||
seasons="$(printf "%s" "$_page" | sed -n '/>Seasons</,/>Details</p' | sed -nE 's|.*anime/([^"]+-[0-9]+)"[^>]*title="([^"]+)".*|\1\t\2|p' | sed -e "s|'|'|g")"
|
|
||||||
[ -n "$seasons" ] && seasons_option="\nchange_season"
|
|
||||||
}
|
|
||||||
|
|
||||||
# get the episodes list of the selected anime. format is (id \t ep_no)
|
|
||||||
anidb_episodes() {
|
|
||||||
#shellcheck disable=SC2059
|
|
||||||
_page="$(anidb_curl "$(printf "$episodes_api" "${1##*-}")")"
|
|
||||||
printf "%s" "$_page" | sed 's|},{|}\n{|g' | sed -nE 's|.*"id":([0-9]+).*"number":([0-9]+).*|\1\t\2|p'
|
|
||||||
}
|
|
||||||
|
|
||||||
# extract the individual m3u8 links from response of embed urls
|
|
||||||
anidb_m3u8() {
|
|
||||||
_ep_id=$(printf "%s" "$episode_maps" | sed -nE "s|^([0-9]+)\t${1}$|\1|p")
|
|
||||||
_lang="jpn"
|
|
||||||
[ "$2" = "dub" ] && _lang="eng"
|
|
||||||
#shellcheck disable=SC2059
|
|
||||||
_page="$(anidb_curl "$(printf "$m3u8_api" "${_ep_id}")")"
|
|
||||||
_embed="$(printf "%s" "$_page" | sed 's|},{|}\n{|g' | sed -nE 's|.*'"${_lang}"'.*embed_url":"([^"]+)".*|\1|p' | sed 's|\\/|/|g' | head -n 1)"
|
|
||||||
[ -z "$_embed" ] && return 1
|
|
||||||
_embed_page="$(anidb_curl "$_embed")"
|
|
||||||
_m3u8_master="$(printf "%s" "$_embed_page" | sed -nE "s|.*file: '([^']*)'.*|\1|p" | head -n 1)"
|
|
||||||
anidb_curl "${_m3u8_master}" | sed 's|^#EXT-X-STREAM.*x||g; s|,.*|p|g; /^#/d; $!N; s|\n| >|;/EXT-X-I-FRAME/d'
|
|
||||||
}
|
|
||||||
|
|
||||||
# select the quality according to the setting
|
|
||||||
select_quality() {
|
|
||||||
case "$1" in
|
|
||||||
best) _result=$(printf "%s" "$links" | head -n 1) ;;
|
|
||||||
worst) _result=$(printf "%s" "$links" | grep -E '^[0-9]{3,4}' | tail -n 1) ;;
|
|
||||||
*) _result=$(printf "%s" "$links" | grep -m 1 "$1") ;;
|
|
||||||
esac
|
|
||||||
[ -z "$_result" ] && printf "\33[2K\r\033[1;33m%s\033[0m\n" "$1 not found, defaulting to best" >&2 && _result=$(printf "%s" "$links" | head -n 1)
|
|
||||||
|
|
||||||
video_link=$(printf "%s" "$_result" | cut -d ">" -f 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
# gets embed url, collects direct links and selects one with desired quality into $episode
|
|
||||||
get_video_link() {
|
|
||||||
links=$(anidb_m3u8 "$ep_no" "$mode" | sort -g -r -s)
|
|
||||||
[ -z "$links" ] && die "No sources found for $mode!"
|
|
||||||
info "anidb.app links fetched"
|
|
||||||
select_quality "$quality"
|
|
||||||
# should never trigger but just in case
|
|
||||||
if printf "%s" "$ep_list" | grep -q "^$ep_no$"; then
|
|
||||||
[ -z "$video_link" ] && die "Episode is released, but no valid sources!"
|
|
||||||
else
|
|
||||||
[ -z "$video_link" ] && die "Episode not released!"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# provide countdown for the next episode release both sub and dub
|
|
||||||
time_until_next_ep() {
|
|
||||||
animeschedule="https://animeschedule.net"
|
|
||||||
_query="$(printf "%s" "$*" | tr ' ' '+')"
|
|
||||||
$curl_exe -s -G "$animeschedule/api/v3/anime" --data "q=${_query}" | sed 's|},{"id"|\n|g' | while read -r _entry; do
|
|
||||||
_route=$(printf "%s" "$_entry" | sed -nE 's|.*"route":"([^"]*)",.*|\1|p')
|
|
||||||
_eng_title=$(printf '%s' "$_entry" | sed -nE 's|.*"english":"([^"]*)".*|\1|p')
|
|
||||||
[ -z "$_eng_title" ] && _eng_title=$(printf '%s' "$_entry" | sed -nE 's|.*,"title":"([^"]*)".*|\1|p')
|
|
||||||
[ -z "$_eng_title" ] && _eng_title="N/A"
|
|
||||||
_jpn_title=$(printf '%s' "$_entry" | sed -nE 's|.*"romaji":"([^"]*)".*|\1|p')
|
|
||||||
[ -z "$_jpn_title" ] && _jpn_title="N/A"
|
|
||||||
_status=$(printf '%s' "$_entry" | sed -nE 's|.*"status":"([^"]*)".*|\1|p')
|
|
||||||
[ -z "$_status" ] && _status="Unknown"
|
|
||||||
|
|
||||||
case "$_status" in
|
|
||||||
Ongoing) _color="33" ;;
|
|
||||||
Finished) _color="32" ;;
|
|
||||||
*) _color="36" ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
printf "Eng: %s\n" "$_eng_title"
|
|
||||||
printf "Jpn: %s\n" "$_jpn_title"
|
|
||||||
printf "Status: \033[1;%sm%s\033[0m\n" "$_color" "$_status"
|
|
||||||
|
|
||||||
if [ "$_status" != "Finished" ]; then
|
|
||||||
_page="$($curl_exe -s "$animeschedule/anime/$_route")"
|
|
||||||
_sub_ep=$(printf "%s" "$_page" | sed -nE 's|.*release-time-type-subs"[^>]*><span class="release-time-episode-number">Episode ([0-9]+)</span>.*|\1|p' | head -n 1)
|
|
||||||
_dub_ep=$(printf "%s" "$_page" | sed -nE 's|.*release-time-type-dub"[^>]*><span class="release-time-episode-number">Episode ([0-9]+)</span>.*|\1|p' | head -n 1)
|
|
||||||
_sub_countdown=$(printf "%s" "$_page" | sed -nE 's|.*class="countdown-time" datetime="([^"]*)".*|\1|p' | head -n 1)
|
|
||||||
_dub_countdown=$(printf "%s" "$_page" | sed -nE 's|.*class="countdown-time countdown-time-dub" datetime="([^"]*)".*|\1|p' | head -n 1)
|
|
||||||
|
|
||||||
[ -n "$_sub_ep" ] && [ -n "$_sub_countdown" ] &&
|
|
||||||
printf "Next Sub: Episode %s in \033[1;%sm%s\033[0m\n" "$_sub_ep" "$_color" "$_sub_countdown" || printf "Next Sub: N/A\n"
|
|
||||||
[ -n "$_dub_ep" ] && [ -n "$_dub_countdown" ] &&
|
|
||||||
printf "Next Dub: Episode %s in \033[1;%sm%s\033[0m\n" "$_dub_ep" "$_color" "$_dub_countdown" || printf "Next Dub: N/A\n"
|
|
||||||
fi
|
|
||||||
printf "\n"
|
|
||||||
done
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# HISTORY
|
|
||||||
|
|
||||||
backup_old_hist() {
|
|
||||||
backupfile="${histfile}.v4"
|
|
||||||
while IFS=" " read -r ep_no anime_id anime_title; do
|
|
||||||
case "$anime_id" in
|
|
||||||
*-*) printf "%s\t%s\t%s\n" "$ep_no" "$anime_id" "$anime_title" >>"${histfile}.new" ;;
|
|
||||||
*) printf "%s\t%s\t%s\n" "$ep_no" "$anime_id" "$anime_title" >>"$backupfile" ;;
|
|
||||||
esac
|
|
||||||
done <"$histfile"
|
|
||||||
mv "${histfile}.new" "$histfile"
|
|
||||||
}
|
|
||||||
|
|
||||||
process_hist_entry() {
|
|
||||||
ep_list=$(anidb_episodes "$anime_id" | cut -f 2)
|
|
||||||
ep_no=$(printf "%s" "$ep_list" | sed -n "/^${ep_no}$/{n;p;}") 2>/dev/null
|
|
||||||
[ -n "$ep_no" ] && printf "%s\t%s - episode %s\n" "$anime_id" "$anime_title" "$ep_no"
|
|
||||||
}
|
|
||||||
|
|
||||||
update_history() {
|
|
||||||
if grep -q " ${anime_id} " "$histfile"; then
|
|
||||||
# escape sed special chars in replacement: & and \ and delimiter |
|
|
||||||
safe_title=$(printf "%s" "$anime_title" | sed 's|[&\|]|\\&|g')
|
|
||||||
sed -E "s|^[^ ]+ ${anime_id} [^ ]+$|${ep_no} ${anime_id} ${safe_title}|" "$histfile" >"${histfile}.new"
|
|
||||||
else
|
|
||||||
cp "$histfile" "${histfile}.new"
|
|
||||||
printf "%s\t%s\t%s\n" "$ep_no" "$anime_id" "$anime_title" >>"${histfile}.new"
|
|
||||||
fi
|
|
||||||
mv "${histfile}.new" "$histfile"
|
|
||||||
}
|
|
||||||
|
|
||||||
# PLAYING
|
|
||||||
|
|
||||||
# shellcheck disable=SC2086
|
|
||||||
# $1 = video link, $2 = video name, $3 = extra flags
|
|
||||||
download() {
|
|
||||||
command -v "yt-dlp" >/dev/null && yt-dlp "$1" --no-skip-unavailable-fragments --fragment-retries infinite -N 16 -o "$download_dir/$2.mp4" $3 && return 0
|
|
||||||
ffmpeg -extension_picky 0 -loglevel error -stats -i "$1" -c copy "$download_dir/$2.mp4" $3
|
|
||||||
}
|
|
||||||
|
|
||||||
play_episode() {
|
|
||||||
[ "$log_episode" = 1 ] && [ "$player_function" != "debug" ] && [ "$player_function" != "download" ] && command -v logger >/dev/null && logger -t ani-cli "${anime_title} ${ep_no}"
|
|
||||||
[ "$skip_intro" = 1 ] && skip_flag="$(ani-skip -i "$mal_id" -e "$canon_ep_no")"
|
|
||||||
[ -z "$video_link" ] && get_video_link
|
|
||||||
# wait for the previous player to close (in range selection)
|
|
||||||
wait
|
|
||||||
# shellcheck disable=SC2086
|
|
||||||
case "$player_function" in
|
|
||||||
debug) printf "All links:\n%s\nSelected link:\n%s\n" "$links" "$video_link" ;;
|
|
||||||
android_mpv) nohup am start --user 0 -a android.intent.action.VIEW -d "$video_link" -n is.xyz.mpv/.MPVActivity -e "title" "${anime_title} Episode ${ep_no}" $player_extra_flags >/dev/null 2>&1 & ;;
|
|
||||||
android_vlc) nohup am start --user 0 -a android.intent.action.VIEW -d "$video_link" -n org.videolan.vlc/org.videolan.vlc.gui.video.VideoPlayerActivity -e "title" "${anime_title} Episode ${ep_no}" $player_extra_flags >/dev/null 2>&1 & ;;
|
|
||||||
"$HOME"/.local/share/flatpak/app/io.mpv/Mpv/) flatpak run io.mpv.Mpv $skip_flag $player_extra_flags --force-media-title="${anime_title} Episode ${ep_no}" "$video_link" >/dev/null 2>&1 & ;;
|
|
||||||
*mpv*)
|
|
||||||
if [ "$no_detach" = 0 ]; then
|
|
||||||
nohup $player_function $skip_flag $player_extra_flags --force-media-title="${anime_title} Episode ${ep_no}" "$video_link" >/dev/null 2>&1 &
|
|
||||||
else
|
|
||||||
$player_function $skip_flag $player_extra_flags --force-media-title="${anime_title} Episode ${ep_no}" "$video_link"
|
|
||||||
mpv_exitcode=$?
|
|
||||||
[ "$exit_after_play" = 1 ] && [ -z "$_range" ] && exit "$mpv_exitcode"
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
*iina*)
|
|
||||||
if pgrep -f "IINA" >/dev/null 2>&1; then
|
|
||||||
# omit --keep-running when an IINA instance exists to prevent hanging
|
|
||||||
nohup $player_function $player_extra_flags --no-stdin --mpv-force-media-title="${anime_title} Episode ${ep_no}" "$video_link" >/dev/null 2>&1 &
|
|
||||||
else
|
|
||||||
nohup $player_function $player_extra_flags --no-stdin --keep-running --mpv-force-media-title="${anime_title} Episode ${ep_no}" "$video_link" >/dev/null 2>&1 &
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
*vlc*) nohup $player_function $player_extra_flags --play-and-exit --meta-title="${anime_title} Episode ${ep_no}" "$video_link" >/dev/null 2>&1 & ;;
|
|
||||||
*yncpla*) nohup $player_function $player_extra_flags "$video_link" -- --force-media-title="${anime_title} Episode ${ep_no}" >/dev/null 2>&1 & ;;
|
|
||||||
download) "$player_function" "$video_link" "${anime_title} Episode ${ep_no}" $player_extra_flags ;;
|
|
||||||
catt) nohup catt cast $player_extra_flags "$video_link" >/dev/null 2>&1 & ;;
|
|
||||||
iSH)
|
|
||||||
printf "\033]8;;vlc://%s\a~~~~~~~~~~~~~~~~~~~~\n~ Tap to open VLC ~\n~~~~~~~~~~~~~~~~~~~~\033]8;;\a\n" "$video_link"
|
|
||||||
sleep 5
|
|
||||||
;;
|
|
||||||
*) nohup $player_function $player_extra_flags "$video_link" >/dev/null 2>&1 & ;;
|
|
||||||
esac
|
|
||||||
replay="$video_link"
|
|
||||||
unset video_link
|
|
||||||
update_history
|
|
||||||
[ "$menu_program" != "fzf" ] && wait
|
|
||||||
}
|
|
||||||
|
|
||||||
play() {
|
|
||||||
_start=$(printf "%s" "$ep_no" | grep -Eo '^(-1|[0-9]+(\.[0-9]+)?)')
|
|
||||||
_end=$(printf "%s" "$ep_no" | grep -Eo '(-1|[0-9]+(\.[0-9]+)?)$')
|
|
||||||
[ "$_start" = "-1" ] && ep_no=$(printf "%s" "$ep_list" | tail -n 1) && unset _start
|
|
||||||
[ -z "$_end" ] || [ "$_end" = "$_start" ] && unset _start _end
|
|
||||||
[ "$_end" = "-1" ] && _end=$(printf "%s" "$ep_list" | tail -n 1)
|
|
||||||
_line_count=$(printf "%s\n" "$ep_no" | wc -l | tr -d "[:space:]")
|
|
||||||
if [ "$_line_count" != 1 ] || [ -n "$_start" ]; then
|
|
||||||
[ -z "$_start" ] && _start=$(printf "%s\n" "$ep_no" | head -n 1)
|
|
||||||
[ -z "$_end" ] && _end=$(printf "%s\n" "$ep_no" | tail -n 1)
|
|
||||||
_range=$(printf "%s\n" "$ep_list" | sed -nE "/^${_start}\$/,/^${_end}\$/p")
|
|
||||||
[ -z "$_range" ] && die "Invalid range!"
|
|
||||||
for i in $_range; do
|
|
||||||
tput clear
|
|
||||||
ep_no=$i
|
|
||||||
canon_ep_no="$(printf "%s" "$ep_list" | sed -n "/^${ep_no}$/=")"
|
|
||||||
info "Playing episode $ep_no..."
|
|
||||||
[ "$i" = "$_end" ] && unset _range
|
|
||||||
play_episode
|
|
||||||
done
|
|
||||||
else
|
|
||||||
printf "%s" "$ep_list" | grep -q "^$ep_no$" || die "Invalid episode!"
|
|
||||||
canon_ep_no="$(printf "%s" "$ep_list" | sed -n "/^${ep_no}$/=")"
|
|
||||||
play_episode
|
|
||||||
fi
|
|
||||||
# moves up to stored position and deletes to end
|
|
||||||
[ "$player_function" != "debug" ] && [ "$player_function" != "download" ] && tput rc && tput ed
|
|
||||||
}
|
|
||||||
|
|
||||||
# MAIN
|
|
||||||
|
|
||||||
# setup
|
|
||||||
base_api="https://anidb.app"
|
|
||||||
search_api="${base_api}/browse?q=%s"
|
|
||||||
# search_api_alt="${base_api}/search/suggestions?q=%s"
|
|
||||||
desc_api="${base_api}/anime/%s"
|
|
||||||
episodes_api="${base_api}/api/frontend/anime/%s/episodes"
|
|
||||||
m3u8_api="${base_api}/api/frontend/episode/%s/languages"
|
|
||||||
|
|
||||||
ciphers='ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305'
|
|
||||||
tls13_ciphers='TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256'
|
|
||||||
agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
|
||||||
|
|
||||||
mode="${ANI_CLI_MODE:-sub}"
|
|
||||||
download_dir="${ANI_CLI_DOWNLOAD_DIR:-.}"
|
|
||||||
log_episode="${ANI_CLI_LOG:-1}"
|
|
||||||
quality="${ANI_CLI_QUALITY:-best}"
|
|
||||||
|
|
||||||
curl_exe=$(dep_ch_failover "curl_firefox135,curl_chrome136,curl_chrome116,curl_ff117,curl") || die "Program curl not found"
|
|
||||||
|
|
||||||
case "$(uname -a | cut -d " " -f 1,3-)" in
|
|
||||||
*Darwin*)
|
|
||||||
cipher_flag="--ciphers $ciphers --tls13-ciphers $tls13_ciphers"
|
|
||||||
player_function="${ANI_CLI_PLAYER:-$(dep_ch_failover "iina,/Applications/IINA.app/Contents/MacOS/iina-cli,mpv,vlc")}" || die 'No player found. Looked for iina, mpv and vlc'
|
|
||||||
;; # mac OS
|
|
||||||
*ndroid*) player_function="${ANI_CLI_PLAYER:-android_mpv}" ;; # Android OS (termux)
|
|
||||||
*MINGW* | *WSL2*) player_function="${ANI_CLI_PLAYER:-mpv.exe}" ;; # Windows OS
|
|
||||||
*ish*)
|
|
||||||
player_function="${ANI_CLI_PLAYER:-iSH}"
|
|
||||||
curl_exe=$(dep_ch_failover "curl_safari260_ios,$curl_exe")
|
|
||||||
;; # iOS (iSH)
|
|
||||||
*) player_function="${ANI_CLI_PLAYER:-$(dep_ch_failover "mpv,$HOME/.local/share/flatpak/app/io.mpv/Mpv/,vlc")}" || die 'No player found. Looked for mpv and vlc' ;; # Linux OS
|
|
||||||
esac
|
|
||||||
|
|
||||||
player_extra_flags="${ANI_CLI_PLAYER_FLAGS:-""}"
|
|
||||||
no_detach="${ANI_CLI_NO_DETACH:-0}"
|
|
||||||
exit_after_play="${ANI_CLI_EXIT_AFTER_PLAY:-0}"
|
|
||||||
skip_intro="${ANI_CLI_SKIP_INTRO:-0}"
|
|
||||||
menu_program="${ANI_CLI_MENU:-fzf}"
|
|
||||||
menu_extra_flags="${ANI_CLI_MENU_FLAGS:-""}"
|
|
||||||
|
|
||||||
# not opening from a terminal
|
|
||||||
if [ ! -t 0 ]; then
|
|
||||||
command -v dmenu >/dev/null && menu_program=dmenu
|
|
||||||
command -v rofi >/dev/null && menu_program=rofi
|
|
||||||
fi
|
|
||||||
|
|
||||||
hist_dir="${ANI_CLI_HIST_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/ani-cli}"
|
|
||||||
[ ! -d "$hist_dir" ] && mkdir -p "$hist_dir"
|
|
||||||
histfile="$hist_dir/ani-hsts"
|
|
||||||
[ ! -f "$histfile" ] && : >"$histfile"
|
|
||||||
|
|
||||||
source="${ANI_CLI_DEFAULT_SOURCE:-search}"
|
|
||||||
branch="${ANI_CLI_BRANCH:-master}"
|
|
||||||
|
|
||||||
while [ $# -gt 0 ]; do
|
|
||||||
case "$1" in
|
|
||||||
-v | --vlc)
|
|
||||||
case "$(uname -a | cut -d " " -f 1,3-)" in
|
|
||||||
*ndroid*) player_function="android_vlc" ;;
|
|
||||||
MINGW* | *WSL2*) player_function="vlc.exe" ;;
|
|
||||||
*ish*) player_function="iSH" ;;
|
|
||||||
*) player_function="vlc" ;;
|
|
||||||
esac
|
|
||||||
;;
|
|
||||||
-s | --syncplay)
|
|
||||||
case "$(uname -s)" in
|
|
||||||
Darwin*) player_function="/Applications/Syncplay.app/Contents/MacOS/syncplay" ;;
|
|
||||||
MINGW* | *Msys)
|
|
||||||
export PATH="$PATH":"/c/Program Files (x86)/Syncplay/"
|
|
||||||
player_function="syncplay.exe"
|
|
||||||
;;
|
|
||||||
*) player_function="syncplay" ;;
|
|
||||||
esac
|
|
||||||
;;
|
|
||||||
-q | --quality)
|
|
||||||
[ $# -lt 2 ] && die "missing argument!"
|
|
||||||
quality="$2"
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
-S | --select-nth)
|
|
||||||
[ $# -lt 2 ] && die "missing argument!"
|
|
||||||
index="$2"
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
-c | --continue) source=history ;;
|
|
||||||
-d | --download)
|
|
||||||
player_function=download
|
|
||||||
;;
|
|
||||||
-D | --delete)
|
|
||||||
: >"$histfile"
|
|
||||||
exit 0
|
|
||||||
;;
|
|
||||||
-l | --logview)
|
|
||||||
case "$(uname -s)" in
|
|
||||||
Darwin*) log show --predicate 'process == "logger"' ;;
|
|
||||||
Linux*) journalctl -t ani-cli ;;
|
|
||||||
*) die "Logger not implemented for your platform" ;;
|
|
||||||
esac
|
|
||||||
exit 0
|
|
||||||
;;
|
|
||||||
-V | --version) version_info ;;
|
|
||||||
-h | --help) help_info ;;
|
|
||||||
-e | --episode | -r | --range)
|
|
||||||
[ $# -lt 2 ] && die "missing argument!"
|
|
||||||
ep_no="$2"
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
--dub) mode="dub" ;;
|
|
||||||
--no-detach) no_detach=1 ;;
|
|
||||||
--exit-after-play) exit_after_play=1 && no_detach=1 ;;
|
|
||||||
--rofi) menu_program=rofi ;;
|
|
||||||
--dmenu) menu_program=dmenu ;;
|
|
||||||
--skip) skip_intro=1 ;;
|
|
||||||
-N | --nextep-countdown) source=nextep ;;
|
|
||||||
-U | --update)
|
|
||||||
dep_ch "patch"
|
|
||||||
branch="${2:-$branch}"
|
|
||||||
update_script
|
|
||||||
;;
|
|
||||||
*) query="$(printf "%s" "$query $1" | sed 's|^ ||;s| |+|g')" ;;
|
|
||||||
esac
|
|
||||||
shift
|
|
||||||
done
|
|
||||||
|
|
||||||
[ "$menu_program" = "fzf" ] && menu_multi_flag="-m"
|
|
||||||
[ "$menu_program" = "rofi" ] && menu_multi_flag="-multi-select"
|
|
||||||
|
|
||||||
info "Checking dependencies..."
|
|
||||||
dep_ch "sed"
|
|
||||||
dep_ch "grep"
|
|
||||||
[ "$skip_intro" = 1 ] && dep_ch "ani-skip"
|
|
||||||
dep_ch "$menu_program" # TODO: use dep_ch_failover to iterate through options
|
|
||||||
|
|
||||||
case "$player_function" in
|
|
||||||
debug) ;;
|
|
||||||
download) dep_ch_failover "yt-dlp,ffmpeg" >/dev/null || die 'Neither yt-dlp nor ffmpeg found' ;;
|
|
||||||
android*) printf "\33[2K\rChecking of players on Android is disabled.\n" ;;
|
|
||||||
*iSH*) printf "\33[2K\rChecking of players on iOS is disabled\n" ;;
|
|
||||||
*) dep_ch "$player_function" ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# idk where to put this, so I put it here...
|
|
||||||
trap 'cleanup; exit 1' INT HUP TERM
|
|
||||||
|
|
||||||
# searching
|
|
||||||
case "$source" in
|
|
||||||
history)
|
|
||||||
backup_old_hist
|
|
||||||
anime_list=$(while read -r ep_no anime_id anime_title; do process_hist_entry & done <"$histfile")
|
|
||||||
wait
|
|
||||||
[ -z "$anime_list" ] && die "No unwatched series in history!"
|
|
||||||
[ -z "${index##*[!0-9]*}" ] && anime_id=$(printf "%s" "$anime_list" | nl -w 2 | sed 's/^[[:space:]]//' | nth "Select anime: " | cut -f 1)
|
|
||||||
[ -z "${index##*[!0-9]*}" ] || anime_id=$(printf "%s" "$anime_list" | sed -n "${index}p" | cut -f 1)
|
|
||||||
[ -z "$anime_id" ] && exit 1
|
|
||||||
anime_title=$(printf "%s" "$anime_list" | grep "^$anime_id " | cut -f 2 | sed 's| - episode.*||')
|
|
||||||
anidb_desc "$anime_id" || true
|
|
||||||
episode_maps="$(anidb_episodes "$anime_id")"
|
|
||||||
ep_list="$(printf "%s" "$episode_maps" | cut -f 2)"
|
|
||||||
ep_no=$(printf "%s" "$anime_list" | grep "^$anime_id " | sed -nE 's/.*- episode (.+)$/\1/p')
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
if [ "$menu_program" = "fzf" ]; then
|
|
||||||
while [ -z "$query" ]; do
|
|
||||||
printf "\33[2K\r\033[1;36mSearch anime: \033[0m" && read -r query
|
|
||||||
done
|
|
||||||
else
|
|
||||||
[ -z "$query" ] && query=$(: | menu "Search anime: " "" "$menu_extra_flags")
|
|
||||||
[ -z "$query" ] && exit 1
|
|
||||||
fi
|
|
||||||
# for checking new releases by specifying anime name
|
|
||||||
[ "$source" = "nextep" ] && time_until_next_ep "$query"
|
|
||||||
|
|
||||||
query=$(printf "%s" "$query" | sed 's| |+|g')
|
|
||||||
|
|
||||||
anime_list=$(anidb_search "$query")
|
|
||||||
[ -z "$anime_list" ] && die "No results found!"
|
|
||||||
[ "$index" -eq "$index" ] 2>/dev/null && result=$(printf "%s" "$anime_list" | sed -n "${index}p")
|
|
||||||
[ -z "$index" ] && result=$(printf "%s" "$anime_list" | nl -w 2 | sed 's/^[[:space:]]//' | nth "Select anime: ")
|
|
||||||
[ -z "$result" ] && die "Invalid anime selection"
|
|
||||||
anime_title="$(printf "%s" "$result" | cut -f 2)"
|
|
||||||
anime_id="$(printf "%s" "$result" | cut -f 1)"
|
|
||||||
anidb_desc "$anime_id" || true
|
|
||||||
episode_maps="$(anidb_episodes "$anime_id")"
|
|
||||||
ep_list="$(printf '%s' "$episode_maps" | cut -f 2)"
|
|
||||||
[ -z "$ep_no" ] && ep_no=$(printf "%s" "$ep_list" | nth "Select episode: " "$menu_multi_flag")
|
|
||||||
[ -z "$ep_no" ] && die "Invalid episode selection"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# moves the cursor up one line and clears that line
|
|
||||||
tput cuu1 && tput el
|
|
||||||
# stores the position of cursor
|
|
||||||
tput sc
|
|
||||||
|
|
||||||
# playback & loop
|
|
||||||
play
|
|
||||||
[ "$player_function" = "download" ] || [ "$player_function" = "debug" ] && exit 0
|
|
||||||
|
|
||||||
#shellcheck disable=SC2059
|
|
||||||
while cmd=$(printf "next\nreplay\nprevious\nselect$seasons_option\nchange_quality\nquit" | nth "Playing episode $ep_no of $anime_title... "); do
|
|
||||||
case "$cmd" in
|
|
||||||
next) ep_no=$(printf "%s" "$ep_list" | sed -n "/^${ep_no}$/{n;p;}") 2>/dev/null ;;
|
|
||||||
replay) video_link="$replay" ;;
|
|
||||||
previous) ep_no=$(printf "%s" "$ep_list" | sed -n "/^${ep_no}$/{g;1!p;};h") 2>/dev/null ;;
|
|
||||||
select) ep_no=$(printf "%s" "$ep_list" | nth "Select episode: " "$menu_multi_flag") ;;
|
|
||||||
change_season)
|
|
||||||
new_anime="$(printf "%s" "$seasons" | nl -w 2 | sed 's/^[[:space:]]//' | nth "Select season: ")"
|
|
||||||
[ -z "$new_anime" ] && die "Invalid season selection"
|
|
||||||
anime_title="$(printf "%s" "$new_anime" | cut -f 2)"
|
|
||||||
anime_id="$(printf "%s" "$new_anime" | cut -f 1)"
|
|
||||||
anidb_desc "$anime_id" || true
|
|
||||||
episode_maps="$(anidb_episodes "$anime_id")"
|
|
||||||
ep_list="$(printf '%s' "$episode_maps" | cut -f 2)"
|
|
||||||
ep_no=$(printf "%s" "$ep_list" | nth "Select episode: " "$menu_multi_flag")
|
|
||||||
[ -z "$ep_no" ] && die "Invalid episode selection"
|
|
||||||
;;
|
|
||||||
change_quality)
|
|
||||||
new_quality="$(printf "%s" "$links" | menu "Select Quality: " "" "$menu_extra_flags" | cut -d ">" -f 1)"
|
|
||||||
[ -z "$new_quality" ] && die "No quality selected"
|
|
||||||
select_quality "$new_quality"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
cleanup
|
|
||||||
exit 0
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
[ -z "$ep_no" ] && die "Out of range"
|
|
||||||
play
|
|
||||||
done
|
|
||||||
|
|
||||||
# ani-cli
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License
|
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
#
|
|
||||||
# Project repository: https://github.com/pystardust/ani-cli
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# Supervised replacement for run.sh's nohup (crash -> auto-restart).
|
|
||||||
# Install (then stop the run.sh instance: pkill -f 'uvicorn .*app:app'):
|
|
||||||
# sudo ln -s /home/jozamudi/code/ani-cli-front-end/ani-web.service /etc/systemd/system/
|
|
||||||
# sudo systemctl daemon-reload && sudo systemctl enable --now ani-web
|
|
||||||
[Unit]
|
|
||||||
Description=ani-web (FastAPI anime front-end)
|
|
||||||
After=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
WorkingDirectory=/home/jozamudi/code/ani-cli-front-end
|
|
||||||
Environment=ANIWEB_SECURE=1
|
|
||||||
ExecStart=/home/jozamudi/code/ani-cli-front-end/.venv/bin/python -m uvicorn server.app:app --host 0.0.0.0 --port 8321
|
|
||||||
Restart=always
|
|
||||||
RestartSec=2
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
Binary file not shown.
@@ -1,52 +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.
|
|
||||||
|
|
||||||
## 6. live-upstream e2e gated off by default
|
|
||||||
|
|
||||||
The Playwright tests scrape and play real provider content — they go red
|
|
||||||
whenever a provider rotates or a title is taken down (observed repeatedly).
|
|
||||||
A suite that fails for non-regressions can't gate anything, so the default
|
|
||||||
run is now hermetic only; run the live e2e deliberately with
|
|
||||||
`ANIWEB_LIVE_TESTS=1`.
|
|
||||||
+8
-3
@@ -48,7 +48,10 @@ VERSION = _version()
|
|||||||
_CDN = {"state": "unknown", "kbps": 0.0, "ts": 0.0, "hist": []}
|
_CDN = {"state": "unknown", "kbps": 0.0, "ts": 0.0, "hist": []}
|
||||||
# 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": []}
|
||||||
|
# ponytail: fixed threshold; if the CDN's normal throughput drifts below it
|
||||||
|
# the pill sits on "slow" — tune via ANIWEB_CDN_KBPS, not a percentile.
|
||||||
|
_CDN_THRESHOLD = float(os.environ.get("ANIWEB_CDN_KBPS", "40"))
|
||||||
|
|
||||||
|
|
||||||
def _cdn_probe():
|
def _cdn_probe():
|
||||||
@@ -81,7 +84,7 @@ def _cdn_probe():
|
|||||||
kbps = len(r.content) / 1024 / max(time.time() - t, 0.01)
|
kbps = len(r.content) / 1024 / max(time.time() - t, 0.01)
|
||||||
except Exception:
|
except Exception:
|
||||||
kbps = 0.0
|
kbps = 0.0
|
||||||
now = "OK" if kbps >= 40 else "DEGRADED"
|
now = "OK" if kbps >= _CDN_THRESHOLD else "DEGRADED"
|
||||||
if now != state:
|
if now != state:
|
||||||
print(f"[probe] CDN {now} ({kbps:.0f} KB/s)", flush=True)
|
print(f"[probe] CDN {now} ({kbps:.0f} KB/s)", flush=True)
|
||||||
state = now
|
state = now
|
||||||
@@ -103,6 +106,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 +119,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
|
||||||
|
|||||||
+128
-36
@@ -882,7 +882,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 ----------
|
||||||
@@ -910,46 +920,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 + " 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 + " 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 + " 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 + " KB/s"));
|
||||||
|
const ch = d.hist || [];
|
||||||
|
if (ch.length > 1) {
|
||||||
|
w.append(sparkline(ch.map(s => s.kbps), d.threshold));
|
||||||
|
w.append(el("div", "sp-cap", "CDN, last " + _mins(ch)
|
||||||
|
+ " min (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() {
|
||||||
|
|||||||
+1
-1
@@ -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>
|
||||||
|
|||||||
+17
-6
@@ -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); } }
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""Hermetic browser tests for the header status pill + panel.
|
||||||
|
|
||||||
|
/api/cdn is mocked with page.route — the throwaway server's probe thread
|
||||||
|
runs against LIVE upstream, so its real data must never leak into these
|
||||||
|
assertions. Everything else (login, home) hits the throwaway server, which
|
||||||
|
never touches the real data. Skips if playwright is absent.
|
||||||
|
|
||||||
|
Pill states are CONSOLIDATED (one dot + one word): down (origin dead)
|
||||||
|
> slow (CDN degraded) > up. Per-host detail lives in the tap panel.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
async_playwright = pytest.importorskip("playwright.async_api").async_playwright
|
||||||
|
|
||||||
|
BASE = "http://127.0.0.1:8399"
|
||||||
|
LAUNCH_ARGS = ["--autoplay-policy=no-user-gesture-required"]
|
||||||
|
POLL = 100
|
||||||
|
|
||||||
|
|
||||||
|
def _cdn(state, kbps, origin="OK"):
|
||||||
|
# origin hist: all OK except the last 3 when origin is DOWN
|
||||||
|
ohist = [{"t": 1750000000 - 180 * (10 - i),
|
||||||
|
"state": "DOWN" if (origin == "DOWN" and i >= 8) else "OK"}
|
||||||
|
for i in range(11)]
|
||||||
|
return {
|
||||||
|
"state": state, "kbps": kbps, "ts": 1750000000, "threshold": 40,
|
||||||
|
"hist": [{"t": 1750000000 - 180 * (10 - i), "kbps": 20 + i * 10} for i in range(11)],
|
||||||
|
"origin": {"state": origin, "msg": "http 200", "ts": 1750000000, "hist": ohist},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _open(pw, body):
|
||||||
|
"""logged-in home page with /api/cdn mocked. alice has no history, so
|
||||||
|
home renders without touching the live upstream."""
|
||||||
|
browser = await pw.chromium.launch(args=LAUNCH_ARGS)
|
||||||
|
page = await (await browser.new_context()).new_page()
|
||||||
|
await page.route("**/api/cdn",
|
||||||
|
lambda r: r.fulfill(status=200, body=json.dumps(body),
|
||||||
|
headers={"Content-Type": "application/json"}))
|
||||||
|
await page.goto(BASE + "/")
|
||||||
|
await page.wait_for_selector("input#pw")
|
||||||
|
await page.fill("#user", "alice")
|
||||||
|
await page.fill("#pw", "alicepw12345")
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
await page.wait_for_selector("#q", timeout=30000)
|
||||||
|
return browser, page
|
||||||
|
|
||||||
|
|
||||||
|
def _js_click(page, sel):
|
||||||
|
# JS click: this playwright/chromium combo's actionability check
|
||||||
|
# ("element stable") hangs non-deterministically on header elements
|
||||||
|
return page.evaluate(f"document.querySelector({json.dumps(sel)}).click()")
|
||||||
|
|
||||||
|
|
||||||
|
def test_pill_ok():
|
||||||
|
asyncio.run(_pill_ok())
|
||||||
|
|
||||||
|
|
||||||
|
async def _pill_ok():
|
||||||
|
async with async_playwright() as pw:
|
||||||
|
browser, page = await _open(pw, _cdn("OK", 120))
|
||||||
|
try:
|
||||||
|
await page.wait_for_function(
|
||||||
|
"document.querySelector('#pill .d').className.includes('ok')",
|
||||||
|
timeout=5000, polling=POLL)
|
||||||
|
assert await page.text_content("#pill span") == "up"
|
||||||
|
# tap opens the panel: two sparklines (CDN + origin), the CDN
|
||||||
|
# one carries the dashed threshold line and one point per sample
|
||||||
|
await _js_click(page, "#pill")
|
||||||
|
await page.wait_for_selector("#status-panel .sp-svg polyline")
|
||||||
|
assert await page.locator("#status-panel polyline").count() == 2
|
||||||
|
assert await page.locator("#status-panel .sp-thr").count() == 1
|
||||||
|
pts = await page.get_attribute("#status-panel .sp-svg polyline", "points")
|
||||||
|
assert len(pts.split()) == 11
|
||||||
|
assert "CDN: OK — 120 KB/s" in await page.text_content("#status-panel")
|
||||||
|
# tap again closes; so does a click away
|
||||||
|
await _js_click(page, "#pill")
|
||||||
|
assert not await page.locator("#status-panel").count()
|
||||||
|
await _js_click(page, "#pill")
|
||||||
|
await page.wait_for_selector("#status-panel")
|
||||||
|
await _js_click(page, "body")
|
||||||
|
assert not await page.locator("#status-panel").count()
|
||||||
|
finally:
|
||||||
|
await browser.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_pill_degraded():
|
||||||
|
asyncio.run(_pill_degraded())
|
||||||
|
|
||||||
|
|
||||||
|
async def _pill_degraded():
|
||||||
|
async with async_playwright() as pw:
|
||||||
|
browser, page = await _open(pw, _cdn("DEGRADED", 23))
|
||||||
|
try:
|
||||||
|
await page.wait_for_function(
|
||||||
|
"document.querySelector('#pill .d').className.includes('slow')",
|
||||||
|
timeout=5000, polling=POLL)
|
||||||
|
assert await page.text_content("#pill span") == "slow · 23 KB/s"
|
||||||
|
await _js_click(page, "#pill")
|
||||||
|
await page.wait_for_selector("#status-panel")
|
||||||
|
assert "CDN: DEGRADED — 23 KB/s" in await page.text_content("#status-panel")
|
||||||
|
finally:
|
||||||
|
await browser.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_pill_origin_down():
|
||||||
|
asyncio.run(_pill_origin_down())
|
||||||
|
|
||||||
|
|
||||||
|
async def _pill_origin_down():
|
||||||
|
async with async_playwright() as pw:
|
||||||
|
browser, page = await _open(pw, _cdn("OK", 120, origin="DOWN"))
|
||||||
|
try:
|
||||||
|
# down beats a healthy CDN: one consolidated state
|
||||||
|
await page.wait_for_function(
|
||||||
|
"document.querySelector('#pill .d').className.includes('down')",
|
||||||
|
timeout=5000, polling=POLL)
|
||||||
|
assert await page.text_content("#pill span") == "down"
|
||||||
|
assert "down" in await page.get_attribute("#pill", "class")
|
||||||
|
await _js_click(page, "#pill")
|
||||||
|
await page.wait_for_selector("#status-panel .sp-svg.bad")
|
||||||
|
assert "upstream: DOWN" in await page.text_content("#status-panel")
|
||||||
|
finally:
|
||||||
|
await browser.close()
|
||||||
Reference in new issue
Block a user