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>
13 KiB
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_PASSWORDenv var, hashed comparison; can be changed at runtime via/api/change-password, persisted tosettings.json). Supports running behind a reverse-proxy subpath viaSCRIPT_NAME+ScriptNameMiddleware+ProxyFix(currently deployed at domain root, not a subpath, but the capability exists — seenginx/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 viaThreadPoolExecutorwith 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.
- Per-service client functions (
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 fromtemplates/) and were deleted during the redesign.- Templates carry markup only. CSS and JS live in
static/and are linked viaurl_for('static', ...)so they stay correct behind aSCRIPT_NAMEsubpath. 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 matchingstatic/css/*.cssfile, scoped with a structural selector (an adjacent-sibling or:last-childrule tied to the surrounding markup) or a small modifier class — not inline on the element. The only legitimate inlinestyle=left indashboard.htmlare per-request computed values Jinja has to interpolate (a meter'swidth: {{ pct }}%, a colour chosen by threshold) and JS-toggled initial visibility (display:noneon somethingdashboard.jsreveals) — those can't live in a static stylesheet. Everything else should read as a class name, not a value.
- Templates carry markup only. CSS and JS live in
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: 44pxfor minimum touch targets. Only four hues are declared (--accent,--ok,--warn,--danger); every dim/border tint derives from them viacolor-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 stalergba()danger borders that survived two palette changes). Page-specific rules live alongside indashboard.css/login.css/settings.css.dashboard.htmlis ordered by urgency, deliberately mirroring the cache tiers inapp.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
.rowis 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-heach, 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 pairsfriendly_error(human cause) with the raw exception behind a disclosure, passed throughredact_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 loadover 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
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.:
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:
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:
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.