All checks were successful
Deploy / deploy (push) Successful in 24s
The footer version is the honest check that a deploy landed, so cut 1.4.0 for the Storage table. Also record something that has now cost two sessions: the dev machine isn't on the media stack's network, so every service is offline and the live-poll endpoints 500 on a timer. That's the normal local baseline, not a regression. Notes the monkey-patch pattern for exercising a card's real rendering, plus the two traps that go with it — Jinja's auto_reload follows app.debug (off), so template edits need a restart rather than a browser reload, and LOGIN_PASSWORD has to be set explicitly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
229 lines
13 KiB
Markdown
229 lines
13 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.
|
||
|
||
## 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.
|
||
|
||
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.
|