arr-summary/CLAUDE.md
Barely Removable 8c6992035f Add per-user logins alongside the shared password
The dashboard had one shared password and no concept of a user. This
adds real accounts: a username field on the login page, per-user
credentials, and the signed-in user recorded on the session, which is
the groundwork for per-user permissions later.

Credentials are salted pbkdf2 hashes in settings.json's `users` map.
Three deliberate choices there:

- settings.json rather than an env var, because a pbkdf2 hash contains
  '$' and docker compose interpolates that when it reads .env. It's also
  already the bind-mounted file that survives `up -d --build`.
- pbkdf2 rather than Werkzeug's default scrypt, which needs a hashlib
  built against OpenSSL — the macOS system Python has no hashlib.scrypt
  and would raise at hash time.
- load_users() re-reads the file per check, so adding a user or changing
  a password is live in every gunicorn worker with no restart. The old
  _runtime_password was per-worker and only ever half-applied.

Multi-user activates only once an account exists. Until then the
original LOGIN_PASSWORD path is used unchanged and the username field is
hidden, so deploying this can't lock the existing install out.
manage_users.py prompts for passwords via getpass — never echoed, never
a CLI argument — and only ever writes hashes; it refuses to remove the
last account, which would silently drop the dashboard back to the shared
password.

Two fixes carried along: login no longer requires the LOGIN_PASSWORD env
var specifically, so a settings.json-only password works instead of
locking you out; and the legacy comparison now uses hmac.compare_digest,
which the old docstring claimed but '==' didn't provide.

Also gitignores settings.json and data/ — they hold API keys and now
password hashes, were excluded from the deploy rsync but not from git.

Verified: legacy fallback still signs in with no accounts configured; a
9-case credential matrix (right/wrong user x right/wrong password, plus
the retired shared password); per-user change-password scoped to the
signed-in account and live without restart; and the add/passwd/remove
guards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 13:03:21 -07:00

281 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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" — a single neutral, professional dark theme (the
earlier five novelty themes were removed). 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 (`dashboard.html`, `login.html`,
`settings.html`). Stale duplicates of these once sat at the repo root;
they were never rendered (Flask loads from `templates/`) and were deleted
during the redesign.
- 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)
reaches JS through `<body data-*>`, never string-interpolated into a
script block.
- **No `style="..."` attributes for anything static.** A one-off tweak
(button height, a margin, a font size) belongs in the matching
`static/css/*.css` file, scoped with a structural selector (an
adjacent-sibling or `:last-child` rule tied to the surrounding markup)
or a small modifier class — not inline on the element. The only
legitimate inline `style=` left in `dashboard.html` are per-request
computed values Jinja has to interpolate (a meter's `width: {{ pct
}}%`, a colour chosen by threshold) and JS-toggled initial visibility
(`display:none` on something `dashboard.js` reveals) — those can't
live in a static stylesheet. Everything else should read as a class
name, not a value.
- **`static/css/base.css`** — the single source of truth for the design
tokens, shared by all three templates: surfaces, a 6-step `--fs-*` type
scale, semantic colours, and `--tap: 44px` for minimum touch targets.
Only four hues are declared (`--accent`, `--ok`, `--warn`, `--danger`);
every dim/border tint derives from them via `color-mix`. Reshading the
app should be a four-line edit here — never hardcode a colour literal in
a page stylesheet, which is exactly how the palette drifted before
(a favicon with its own baked-in hex, and stale `rgba()` danger borders
that survived two palette changes). Page-specific rules live alongside
in `dashboard.css` / `login.css` / `settings.css`.
- `dashboard.html` is ordered by urgency, deliberately mirroring the cache
tiers in `app.py`: a "Right now" strip (live streams, download queue,
array usage) sits above the fold, with library, discovery, requests and
watch insights below it.
- **Cards in a row must earn their height — never leave dead space.** A
`.row` is a CSS grid, so every card stretches to the tallest one in that
row. A card whose content outgrows its neighbours doesn't just get tall,
it hollows out everything beside it. This has bitten once already: the
per-disk Storage list stacked ten disks at `--row-h` each, turning one
third of the "Right now" strip into a ~950px tower and leaving ~1500px of
empty card across Now playing and Downloads.
- Before adding a repeating list to a card in a multi-column row, check
what the row's other cards hold at their **quietest** — one stream, an
idle queue, zero pending requests — and keep the tallest card within
roughly 1.5× of that.
- When the content genuinely won't fit, the fix is **more width**: give
the card its own full-width row (`grid-template-columns: 1fr`) and lay
its list out in columns inside, or use more columns within the existing
card. Ten items down one narrow column is three tidy rows of tiles once
the card spans the page.
- The fix is **never** an inner scroll area, a truncated list, or content
hidden behind a toggle the user has to find — see the "nothing clipped
or hidden" rule the header pills already follow.
- Where a short card still can't be avoided, centre its content
vertically rather than reaching for `align-items: start`; equal-height
cards are a settled preference.
- Degraded services render through the `service_error()` macro, which
pairs `friendly_error` (human cause) with the raw exception behind a
disclosure, passed through `redact_secrets` — SABnzbd and Tautulli put
their API key in the request URL, so it appears in raw tracebacks.
- **`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.