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>
281 lines
15 KiB
Markdown
281 lines
15 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" — 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.
|