# 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 `
`, 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