arr-summary/CLAUDE.md
Barely Removable 08c71ba68d
All checks were successful
Deploy / deploy (push) Successful in 23s
Give Storage its own row and rebuild the disk list as a table
Ten disks stacked at --row-h each turned one third of the "Right now"
strip into a ~950px tower. The row is a grid, so Now playing and
Downloads stretched to match and held roughly 1500px of empty card
between them.

The information needed width, not a shorter list. "Right now" drops to
two columns and Storage moves to a full-width row beneath it, where the
same ten disks are a six-column table — disk, used, size, free, usage
bar, temp — at ~270px total. Nothing is hidden and nothing scrolls
except the table itself, which gets its own overflow container below
720px rather than wrapping the sizes into mush.

Also: the array total moves to a bar above the table and the summary
into the card header, so the hero figure isn't repeating what every row
already says. The header summary is a sentence rather than a badge, so
it needs .card-meta-wrap to opt out of the global nowrap — without it
that one string pushed the whole page sideways on a phone.

Local-fallback (no per-disk data) and error states keep the previous
hero-figure treatment. CLAUDE.md now carries the dead-space rule this
came from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 06:52:34 -07:00

200 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.
## 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.