Compare commits

..
Author SHA1 Message Date
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
13 changed files with 341 additions and 1014 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`.
+45 -112
View File
@@ -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, ![history](docs/img/history.png)
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.
![history](docs/img/history.png) ![home](docs/img/home.png)
### 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 ![search](docs/img/search.png)
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) - **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 ![detail](docs/img/detail.png)
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.
![search](docs/img/search.png) - **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)
![detail](docs/img/detail.png)
### 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).
+15 -3
View File
@@ -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.
-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.
-52
View File
@@ -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
View File
@@ -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
+127 -35
View File
@@ -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
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>
+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); } }
+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()