# 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". Since v2.0.0 (2026-08-29) it presents as a **broadcast information service** in the teletext tradition: numbered pages you reach by typing the number, status carried by colour, and no borders or cards anywhere. This replaced — not refined — the earlier neutral dark card theme, which had itself replaced five novelty themes. See DESIGN.md for the visual system and the `static/css/base.css` header for the five disciplines it is held to. 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. `_shell.html` is the root layout every page extends; `_macros.html` holds the shared fragments. Pages are `home.html` (P100), `watch.html` (101), `library.html` (102), `discover.html` (103), `requests.html` (104), `system.html` (105), `activity.html` (106), `settings.html` (900), plus `login.html`, which stands alone. The old single-page `dashboard.html` was deleted in the v2.0.0 redesign along with `dashboard.css`, `login.css`, `settings.css` and `dashboard.js`. - 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, the page-number map) reaches JS through `
`, never string-interpolated into a script block. - **No `style="..."` attributes for anything static.** A one-off tweak belongs in `static/css/teletext.css`, scoped with a structural selector or a small modifier class. The only legitimate inline `style=` is a per-request computed value Jinja has to interpolate — currently just the `bar()` macro's fill width. - `_shell.html`'s first `` child is an HTML comment holding the **direction contract** for the visual world (thesis, own-world, story, first viewport, form, seed key). Leave it in place; it is what a later session audits the design against. - **`static/css/base.css`** — tokens and reset. Its header comment states the **five disciplines** the visual world is held to; read them before changing anything visual, and treat breaking one as a regression rather than a variation: 1. **No in-between tone.** There are eight colours (the teletext primaries). A mid-grey is an ordered dither of two of them, never a ninth colour. 2. **No enclosure.** Nothing is surrounded by a border or a box. There is zero `border-radius` in the codebase. Grouping is a coloured field row (`.tt-field`), indentation, and weight. This is the single thing the world most refuses — the previous design was built on bordered cards. 3. **One ruler.** Every bar and percentage shares one scale (`--bar-cells`) and is drawn only by the `bar()` macro in `_macros.html`, so 60% on the array and 60% on an indexer are the same width and compare directly. Never restyle a bar per section. 4. **Every reading carries its direction.** A value shows where it is heading. Only drawn where the backend genuinely holds a comparison — today that is SABnzbd speed against its stored 7-day average, and nowhere else. Do not invent a trend for data with no history. 5. **Pages differ in stock.** A page holding critical state is set heavier than a quiet one (`.tt-field.warn` / `.tt-field.crit`). Uniform texture across all the pages is a failure. - Colour is **semantic, never decorative**: yellow is the service's own voice, cyan is a live value, green is healthy, red is off air, magenta means a human must decide (pending requests only), blue is a field ground. Reshading means editing the eight tokens here, nothing downstream. - Type is **one face at exactly two heights** — `--fs` (15px) and `--fs2` (30px, double height). Adding a third size breaks the system. The 16px literal on inputs is deliberate: it prevents iOS zoom-on-focus. - `--ink-dim` (58% white) is the **only** dimmed token allowed to carry text; it clears 4.5:1 on black. `--ink-dimmer` (30%) is for graphics only — bar troughs, the dither field, separators. Setting text in `--ink-dimmer` fails contrast, which is exactly the bug the redesign's first inspection round caught. - **Navigation is the page number.** Typing any three digits anywhere jumps to that page, and `r`/`g`/`y`/`c` fire the four fastext colour keys — both in `static/js/service.js`. `nav_items` in `app.py` is the source of truth for the numbers and is serialised into ``. - **Nothing is clipped, hidden, or sticky.** All seven page numbers and all four fastext keys stay visible at every width, reflowing to another row rather than scrolling, truncating, or folding into a menu. No header is sticky. - **`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