arr-summary/CLAUDE.md
Barely Removable 4b19cbdfb2 Redesign Media Centre as a teletext broadcast information service
Replaces the neutral dark card-based theme with "Page 100": teletext's
locked eight colours at full saturation on pure black, numbered pages
reachable by typing the number (or the r/g/y/c fastext keys), and no
borders, boxes, or radius anywhere — grouping is carried by full-bleed
colour fields and row weight instead.

Chosen via the impeccable skill's replacement-world flow: seven visual
candidates derived from the audience's own world, a mandatory roll
assigned Ceefax/US-cable-guide teletext, six catalog challengers weighed
against it, and the user locked the assigned direction on the decision
page (seed aab4510e).

- New templates for all nine pages (home/watch/library/discover/
  requests/system/activity/login/settings) behind a shared _shell.html
  carrying the direction contract and _macros.html for the row/bar/tile
  vocabulary; deletes the old single-page dashboard.html and its
  dedicated stylesheets/script.
- static/css/base.css + teletext.css: the locked palette, a two-height
  type scale (Chivo Mono / DotGothic16), and the five disciplines the
  system is held to (no in-between tone, no enclosure, one shared bar
  ruler, trend only where real history exists, pages weighted by state).
- static/js/service.js: header clock, page-number jump, fastext keys,
  two-tap arm/confirm add, guarded live polling — replaces dashboard.js.
- app.py: nav_items now carry page numbers; fastext defaults added to
  the page-chrome context processor; version bumped to 2.0.0.
- DESIGN.md + .impeccable/design.json regenerated from the built world
  by the documenter pass; PRODUCT.md and CLAUDE.md updated to describe
  the new system in place of the retired one.
- Fixed a live bug found along the way: settings.html called
  url_for('dashboard'), an endpoint removed earlier in the branch, which
  would have 500'd the Settings page.
- Fixed a One-Ruler violation the finish review's recapture request
  surfaced: --bar-cells disagreed with the bar() macro's hardcoded cell
  count below the 760px breakpoint, so a fill could land mid-cell on a
  phone. Removed the mobile override; the ruler is now 20 cells at every
  width, matching the macro unconditionally.

Passed two inspection rounds (desktop + true-390px mobile, via an
iframe harness since macOS Chrome clamps --window-size) and a finish
review; QA screenshots and interview logs are gitignored as scratch,
not shipped in the repo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBw1QodG8ZMYHrGeQfqyDS
2026-08-29 21:00:45 -07:00

292 lines
16 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 the four fastext colour
keys — 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>`.
- **Nothing is clipped, hidden, or sticky.** All seven page numbers and
all four fastext keys 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
**Target:** Unraid server, container path `/mnt/user/appdata/greendale`
(note the directory name differs from the container/repo name), built from
`Dockerfile`/`docker-compose.yml` in this repo. Container name
`arr-summary`, port `5055` (overridable via `HOST_PORT`) → container port
`5000`.
**Deploy mechanism: Forgejo push-to-deploy**, same pattern as the other
three projects on this box — see `card-grader`'s `CLAUDE.md` for the full
pipeline mechanics. Push to `main` on the `forgejo` remote
(`https://git.hippofam.com/ninja_hippo/arr-summary.git`) triggers
`.forgejo/workflows/deploy.yml`, which syncs into
`/mnt/user/appdata/greendale` (excluding `.git`, `.env`, `data/`) and runs
`docker compose up -d --build`.
**Restarting the container manually: use Compose, not the Unraid Docker
UI.** Every deploy recreates the container with a new Docker ID (same
name, `arr-summary`). Unraid's Docker tab caches the old ID, so hitting
Restart there after a deploy throws "No such container" — harmless, but
confusing. From the Unraid terminal:
```bash
cd /mnt/user/appdata/greendale && docker compose restart
```
`docker compose up -d --force-recreate` for a guaranteed-clean recreate;
`docker ps --filter name=arr-summary` to check real state regardless of
what the Unraid UI shows.
**Confirming a deploy actually landed:** the dashboard footer renders
`v<APP_VERSION> · <APP_VERSION_DATE>` from constants near the top of
`app.py` (both env-overridable, injected into every template by the
`inject_version` context processor). Bump them when cutting a deploy —
loading the page is then the quickest honest check that the new build is
live, without trusting the Unraid Docker tab's cached container IDs.
## Reverse proxy (Nginx Proxy Manager)
Two NPM proxy hosts front this app and `git.hippofam.com`, both using
Let's Encrypt certs issued via **DNS-01 through Porkbun** — required,
not optional: **Cox blocks inbound port 80** on this connection, so
HTTP-01 challenges (and plain `http://` requests generally) can never
work here. `http://hippofam.com` will always time out from outside the
LAN; only `https://` resolves. Both proxy hosts have Force SSL on.
`hippofam.com` → `http://192.168.86.33:5055` (the Unraid box, this
container's port). Verify the destination IP/port here before assuming
an outage is DNS or app-level — it has drifted to the wrong host before.
On 2026-08-26 the apex host's own cert (issued Aug 2025) went dark: NPM's
UI still showed it "In Use" with a valid, unexpired date, but nginx
presented no certificate at all for `hippofam.com` (TLS alert 112,
"unrecognized name") — the on-disk cert files had rotted independently
of the DB record NPM's UI reads from. `git.hippofam.com`, on a newer
cert from the same account, was unaffected. Fixed by pointing the proxy
host at the `*.hippofam.com` + `hippofam.com` wildcard cert already
issued and sitting unused, which forced NPM to regenerate the nginx
config with known-good cert files. If `hippofam.com` ever throws a TLS
handshake error again with NPM's UI reporting all green, suspect this
same on-disk/DB drift before anything else — check with:
```bash
openssl s_client -connect <public-ip>:443 -servername hippofam.com </dev/null
```
A clean run shows `subject=/CN=...` and `Verify return code: 0`; the
broken state shows `no peer certificate available`.
**Persistent state, never touched by deploys:** `.env` holds every API key
and `LOGIN_PASSWORD` — this project was already doing this correctly
before the Forgejo migration (compose file uses `${VAR}` substitution from
`.env`, no hardcoded secrets), unlike `n64-tracker` which needed fixing.
Runtime settings written by the Settings page (API-key overrides,
timezone, changed login password) live in `settings.json` at
`SETTINGS_FILE`, which the compose file points at `/data/settings.json`
on the `./data` bind mount — i.e. `/mnt/user/appdata/greendale/data`.
That mount is load-bearing: without it the file sits on the container
filesystem, and every `up -d --build` recreates the container and
silently discards it. `data/` is likewise excluded from the deploy
rsync, which runs `--delete` and would otherwise remove it for not
being in the repo.
## Logins
Two modes, chosen by whether `settings.json` holds a non-empty `users` map:
- **Multi-user** (once any account exists): username + password, each
password stored as a salted pbkdf2 hash under `users` in `settings.json`.
`verify_credentials()` is the only entry point; `load_users()` re-reads
the file per check, so adding a user or changing a password is live
immediately in every gunicorn worker — no restart, unlike the API keys.
- **Legacy single shared password** (no accounts yet): the original
`LOGIN_PASSWORD` / `settings.json.login_password` path, username ignored.
This fallback exists so deploying multi-user support can't lock an
existing install out; the login page hides the username field entirely
until accounts exist.
Manage accounts with `manage_users.py`, which prompts for passwords
(never echoed, never a CLI argument, so they stay out of shell history)
and only ever writes hashes:
```bash
docker exec -it arr-summary python3 manage_users.py list
docker exec -it arr-summary python3 manage_users.py add <username>
docker exec -it arr-summary python3 manage_users.py passwd <username>
docker exec -it arr-summary python3 manage_users.py remove <username>
```
**Adding the first account switches the whole dashboard to per-user
logins and the shared password stops working** — verify you can sign in
as that user before closing the shell. `remove` refuses to delete the
last account for the same reason.
Users live in `settings.json` rather than an env var deliberately: a
pbkdf2 hash contains `$`, which docker compose interpolates when reading
`.env`. `PASSWORD_HASH_METHOD` is pinned to pbkdf2 rather than Werkzeug's
default scrypt, which needs a hashlib built against OpenSSL and is absent
on the macOS system Python.
**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.