arr-summary/CLAUDE.md
Barely Removable 5dd49c505c
All checks were successful
Deploy / deploy (push) Successful in 23s
Give every fastext destination a distinct mark; add skip-link, fix aria-label
Colours: "if the intent is to have them all different colors than
lets have them all different colors." The locked eight only give five
safe as full text (blue fails contrast, black's invisible on black),
so the other three get their own treatments instead of sharing plain
ink: Watch gets blue as a swatch-only accent (the button cap's colour,
not the label), Activity gets an ordered-dither swatch (the other
legal way to mark something without a ninth colour), and Settings
alone keeps plain ink deliberately, as the one page with no live
status of its own.

Accessibility (items 1 and 3 from the design review, item 2 skipped
per request — the header's download figure stays live and unmuted):
- Added a skip-link, off-screen until focused, jumping past the
  8-item top nav straight to each page's #main-content.
- Renamed the nav's aria-label from "Fastext" (meaningless without
  the visual teletext context) to "Page navigation".
2026-08-30 09:24:52 -07:00

208 lines
12 KiB
Markdown

# Arr Summary (Greendale)
Flask dashboard aggregating status from the user's self-hosted media stack
— Sonarr, Radarr, SABnzbd, Tautulli, Prowlarr, Ombi — plus Unraid array
stats and a Rotten Tomatoes "what's popular that I don't have yet" feed.
Branded "Media Centre". Since v2.0.0 (2026-08-29) it presents as a
**broadcast information service** in the teletext tradition: numbered
pages you reach by typing the number, status carried by colour, and no
borders or cards anywhere. This replaced — not refined — the earlier
neutral dark card theme, which had itself replaced five novelty themes.
See DESIGN.md for the visual system and the `static/css/base.css` header
for the five disciplines it is held to. Runs as one Docker container on
the user's Unraid home server. Server-side project directory is still named
`greendale`; the container/repo are named `arr-summary`.
## Architecture
- **`app.py`** — single Flask app, session-based single-shared-password
auth (`LOGIN_PASSWORD` env var, hashed comparison; can be changed at
runtime via `/api/change-password`, persisted to `settings.json`).
Supports running behind a reverse-proxy subpath via `SCRIPT_NAME` +
`ScriptNameMiddleware` + `ProxyFix` (currently deployed at domain root,
not a subpath, but the capability exists — see `nginx/` configs for
both).
- Per-service client functions (`sonarr_get`, `radarr_get`,
`sabnzbd_get`, `tautulli_get`, `prowlarr_get`, `ombi_get`) each call
that service's REST API directly with its API key from env vars.
- The dashboard route (`/`) fetches all services **in parallel** via
`ThreadPoolExecutor` with a hard 5-second wall-clock deadline — any
slow/unreachable service degrades to an error dict rather than
blocking the whole page. This matters for remote/cellular access.
- Aggressive per-service caching with different TTLs tuned to how often
each actually changes: Sonarr/Radarr library lists 5 min (the biggest
latency cost — full Radarr list is ~4.4MB), Tautulli stats 2 min,
SABnzbd history 10 min, Ombi requests 4 hours, Rotten Tomatoes scrapes
4 hours. Active streams (Tautulli) and the SABnzbd queue are always
fetched live, never cached — those need to be current-second accurate.
- Rotten Tomatoes / IMDB are scraped directly (`requests` +
`BeautifulSoup`, JSON-LD parsing), not via an official API — brittle
to upstream HTML changes by nature.
- **`templates/`** — Jinja2 templates. `_shell.html` is the root layout
every page extends; `_macros.html` holds the shared fragments. Pages are
`home.html` (P100), `watch.html` (101), `library.html` (102),
`discover.html` (103), `requests.html` (104), `system.html` (105),
`activity.html` (106), `settings.html` (900), plus `login.html`, which
stands alone. The old single-page `dashboard.html` was deleted in the
v2.0.0 redesign along with `dashboard.css`, `login.css`, `settings.css`
and `dashboard.js`.
- Templates carry markup only. CSS and JS live in `static/` and are
linked via `url_for('static', ...)` so they stay correct behind a
`SCRIPT_NAME` subpath. Server-rendered config (timezone, script root,
the page-number map) reaches JS through `<body data-*>`, never
string-interpolated into a script block.
- **No `style="..."` attributes for anything static.** A one-off tweak
belongs in `static/css/teletext.css`, scoped with a structural selector
or a small modifier class. The only legitimate inline `style=` is a
per-request computed value Jinja has to interpolate — currently just
the `bar()` macro's fill width.
- `_shell.html`'s first `<body>` child is an HTML comment holding the
**direction contract** for the visual world (thesis, own-world, story,
first viewport, form, seed key). Leave it in place; it is what a later
session audits the design against.
- **`static/css/base.css`** — tokens and reset. Its header comment states
the **five disciplines** the visual world is held to; read them before
changing anything visual, and treat breaking one as a regression rather
than a variation:
1. **No in-between tone.** There are eight colours (the teletext
primaries). A mid-grey is an ordered dither of two of them, never a
ninth colour.
2. **No enclosure.** Nothing is surrounded by a border or a box. There is
zero `border-radius` in the codebase. Grouping is a coloured field row
(`.tt-field`), indentation, and weight. This is the single thing the
world most refuses — the previous design was built on bordered cards.
3. **One ruler.** Every bar and percentage shares one scale
(`--bar-cells`) and is drawn only by the `bar()` macro in
`_macros.html`, so 60% on the array and 60% on an indexer are the same
width and compare directly. Never restyle a bar per section.
4. **Every reading carries its direction.** A value shows where it is
heading. Only drawn where the backend genuinely holds a comparison —
today that is SABnzbd speed against its stored 7-day average, and
nowhere else. Do not invent a trend for data with no history.
5. **Pages differ in stock.** A page holding critical state is set
heavier than a quiet one (`.tt-field.warn` / `.tt-field.crit`).
Uniform texture across all the pages is a failure.
- Colour is **semantic, never decorative**: yellow is the service's own
voice, cyan is a live value, green is healthy, red is off air, magenta
means a human must decide (pending requests only), blue is a field
ground. Reshading means editing the eight tokens here, nothing
downstream.
- Type is **one face at exactly two heights** — `--fs` (15px) and `--fs2`
(30px, double height). Adding a third size breaks the system. The 16px
literal on inputs is deliberate: it prevents iOS zoom-on-focus.
- `--ink-dim` (58% white) is the **only** dimmed token allowed to carry
text; it clears 4.5:1 on black. `--ink-dimmer` (30%) is for graphics
only — bar troughs, the dither field, separators. Setting text in
`--ink-dimmer` fails contrast, which is exactly the bug the redesign's
first inspection round caught.
- **Navigation is the page number.** Typing any three digits anywhere
jumps to that page, and `r`/`g`/`y`/`c` fire four of the fastext
entries — both in `static/js/service.js`. `nav_items` in `app.py` is
the source of truth for the numbers and is serialised into
`<body data-pages>`.
- **Fastext leads everywhere but the index, and closes the index.**
`FULL_FASTEXT` in `app.py` lists every destination and renders right
under the header on every page but the index — those pages carry no
other nav, so it needs no scroll to reach. The index overrides it
with `HOME_FASTEXT`, the original four-entry row, as its own closing
line, since its body already lists every destination once. Every
destination gets its own distinct mark — the locked eight colours
only give five that read as legible text on black, so getting to
eight took three treatments: five flat colours as text+swatch
(Index/Library/Discover/System keep their `r`/`g`/`y`/`c` shortcut;
Requests gets magenta/`--pending` with no shortcut — a deliberate
fit, since Requests already is "waiting on a human" everywhere else
magenta appears); Watch gets blue as a swatch-only accent (`.k-blue`
— blue fails contrast as body text, so only the small cell carries
it, not the row's own label); Activity gets an ordered-dither swatch
(`.k-dither`, same mechanism as `--dither-mid`) rather than a flat
colour. Settings alone keeps plain ink with no swatch treatment —
deliberately, as the one page with no live status of its own.
Whichever entry matches the current page renders as a
`.fastext-here` span instead of a link — a page linking to itself
did nothing, so it isn't offered as a click.
- **Nothing is clipped, hidden, or sticky.** All seven page numbers and
every fastext entry stay visible at every width, reflowing to another
row rather than scrolling, truncating, or folding into a menu. No
header is sticky.
- **`export-to-unraid.sh`** — a pre-existing standalone deploy script
(build image locally → tarball → rsync + `docker load` over SSH) that
predates the Forgejo pipeline below. Still present, not removed, but the
Forgejo pipeline is now the primary deploy path going forward.
- **`nginx/`** — reference nginx location-block snippets for subdomain vs.
subpath reverse-proxy setups; not actually consumed by the app at
runtime (NPM handles proxying in practice), kept as documentation/config
reference.
## Local development
```bash
pip install -r requirements.txt
python3 app.py
```
Runs on port 5000. Needs the various `*_API_KEY` and `*_HOST`/`ARR_HOST`
env vars (see `.env.example`) to reach real services — without them,
dashboard cards degrade to "error" status individually rather than
crashing the page.
**Expect every service to be offline on the dev machine, and expect
`/api/*` polls to 500.** The dev laptop is not on the network with the
media stack, so Sonarr/Radarr/SABnzbd/Tautulli/Prowlarr/Ombi all refuse
the connection: every card renders its `service_error()` state, the
header pills all read "offline", and the browser console fills with
`500` responses from the live-poll endpoints (`/api/sabnzbd` and
`/api/tautulli` poll on a timer, so they repeat every few seconds).
**This is the normal local baseline, not a regression** — don't go
debugging it, and don't "fix" it. If you need to check whether a 500 is
genuinely yours, stash the working tree and confirm the same response on
a clean checkout.
Because of that, the only reliable way to exercise a card's real
(non-error) rendering locally is to monkey-patch its fetcher and run the
app on a spare port, e.g.:
```bash
LOGIN_PASSWORD=test python3 -c "
import app as a
a.get_unraid_stats = lambda: {'status': 'ok', 'source': 'api', ...}
a.app.run(host='127.0.0.1', port=5099)"
```
Two gotchas when doing this: Jinja's `auto_reload` follows `app.debug`,
which is off, so **template edits need a server restart** — a browser
reload alone will silently serve the cached template. And the login
password comes from `LOGIN_PASSWORD`, so set it explicitly rather than
hunting for the real one.
The listen port comes from `PORT` (default `5000`, matching `EXPOSE`,
`gunicorn.conf.py` and the `${HOST_PORT}:5000` compose mapping — change
all four together or none). Set it when running locally: macOS
`ControlCenter` already holds port 5000, so `python3 app.py` with no
`PORT` will fail to bind on the dev laptop. `.claude/launch.json` sets
`autoPort` so a dev harness can assign a free one, but note that on this
machine the preview sandbox can't read files under `~/Desktop`
("Operation not permitted"), so launching by name fails and running the
app from a shell on an explicit `PORT` is the working path.
## Deployment
See the `deploy-arr-summary` skill for the Forgejo push-to-deploy
pipeline, manual container restart steps, deploy verification, and the
Nginx Proxy Manager / TLS setup and its Cox-port-80/DNS-01 constraint.
## Logins
See the `manage-accounts` skill for the multi-user vs. legacy
single-shared-password login modes and the `manage_users.py` CLI.
**Never put a real password in the repo** — not in `app.py`, not in
`settings.json` committed to git, not in a fixture. `.env` and `data/`
are gitignored and excluded from the deploy rsync precisely so secrets
have somewhere to live; everything else here is pushed to Forgejo.
A key saved through the Settings page overrides the corresponding
`.env` value at startup (see the block after the `*_API_KEY` reads in
`app.py`); `.env` remains the source of truth for anything never
overridden there. Both paths need a container restart to take effect,
since the keys are read once at import.