arr-summary/CLAUDE.md
Barely Removable 4b19cbdfb2 Redesign Media Centre as a teletext broadcast information service
Replaces the neutral dark card-based theme with "Page 100": teletext's
locked eight colours at full saturation on pure black, numbered pages
reachable by typing the number (or the r/g/y/c fastext keys), and no
borders, boxes, or radius anywhere — grouping is carried by full-bleed
colour fields and row weight instead.

Chosen via the impeccable skill's replacement-world flow: seven visual
candidates derived from the audience's own world, a mandatory roll
assigned Ceefax/US-cable-guide teletext, six catalog challengers weighed
against it, and the user locked the assigned direction on the decision
page (seed aab4510e).

- New templates for all nine pages (home/watch/library/discover/
  requests/system/activity/login/settings) behind a shared _shell.html
  carrying the direction contract and _macros.html for the row/bar/tile
  vocabulary; deletes the old single-page dashboard.html and its
  dedicated stylesheets/script.
- static/css/base.css + teletext.css: the locked palette, a two-height
  type scale (Chivo Mono / DotGothic16), and the five disciplines the
  system is held to (no in-between tone, no enclosure, one shared bar
  ruler, trend only where real history exists, pages weighted by state).
- static/js/service.js: header clock, page-number jump, fastext keys,
  two-tap arm/confirm add, guarded live polling — replaces dashboard.js.
- app.py: nav_items now carry page numbers; fastext defaults added to
  the page-chrome context processor; version bumped to 2.0.0.
- DESIGN.md + .impeccable/design.json regenerated from the built world
  by the documenter pass; PRODUCT.md and CLAUDE.md updated to describe
  the new system in place of the retired one.
- Fixed a live bug found along the way: settings.html called
  url_for('dashboard'), an endpoint removed earlier in the branch, which
  would have 500'd the Settings page.
- Fixed a One-Ruler violation the finish review's recapture request
  surfaced: --bar-cells disagreed with the bar() macro's hardcoded cell
  count below the 760px breakpoint, so a fill could land mid-cell on a
  phone. Removed the mobile override; the ruler is now 20 cells at every
  width, matching the macro unconditionally.

Passed two inspection rounds (desktop + true-390px mobile, via an
iframe harness since macOS Chrome clamps --window-size) and a finish
review; QA screenshots and interview logs are gitignored as scratch,
not shipped in the repo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBw1QodG8ZMYHrGeQfqyDS
2026-08-29 21:00:45 -07:00

16 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". 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 <body data-*>, 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 <body> 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 <body data-pages>.
    • 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

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.

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:

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.

Logins

Two modes, chosen by whether settings.json holds a non-empty users map:

  • Multi-user (once any account exists): username + password, each password stored as a salted pbkdf2 hash under users in settings.json. verify_credentials() is the only entry point; load_users() re-reads the file per check, so adding a user or changing a password is live immediately in every gunicorn worker — no restart, unlike the API keys.
  • Legacy single shared password (no accounts yet): the original LOGIN_PASSWORD / settings.json.login_password path, username ignored. This fallback exists so deploying multi-user support can't lock an existing install out; the login page hides the username field entirely until accounts exist.

Manage accounts with manage_users.py, which prompts for passwords (never echoed, never a CLI argument, so they stay out of shell history) and only ever writes hashes:

docker exec -it arr-summary python3 manage_users.py list
docker exec -it arr-summary python3 manage_users.py add <username>
docker exec -it arr-summary python3 manage_users.py passwd <username>
docker exec -it arr-summary python3 manage_users.py remove <username>

Adding the first account switches the whole dashboard to per-user logins and the shared password stops working — verify you can sign in as that user before closing the shell. remove refuses to delete the last account for the same reason.

Users live in settings.json rather than an env var deliberately: a pbkdf2 hash contains $, which docker compose interpolates when reading .env. PASSWORD_HASH_METHOD is pinned to pbkdf2 rather than Werkzeug's default scrypt, which needs a hashlib built against OpenSSL and is absent on the macOS system Python.

Never put a real password in the repo — not in app.py, not in settings.json committed to git, not in a fixture. .env and data/ are gitignored and excluded from the deploy rsync precisely so secrets have somewhere to live; everything else here is pushed to Forgejo.

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.