- system.html crashed with a 500 whenever SABnzbd history loaded: the trend() macro compared the formatted "avg_speed" string (e.g. "900 KB/s") against a number. Added a real numeric avg_speed_kbps field and pointed the macro at that instead. - Replaced "held"/"not held" wording with "in library"/"not in library" on Home and Discover. - Moved CLAUDE.md's Deployment/reverse-proxy and account-management sections into two on-demand skills (deploy-arr-summary, manage-accounts) so they only load when actually relevant, with a one-line pointer left in their place. - Updated PRODUCT.md's restructure note now that meridian-marquee has merged to main. - Dropped two stale/broken entries from settings.local.json's permission allowlist (a rule from a different machine/path, and one with a corrupted token). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NBw1QodG8ZMYHrGeQfqyDS
10 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_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._shell.htmlis the root layout every page extends;_macros.htmlholds the shared fragments. Pages arehome.html(P100),watch.html(101),library.html(102),discover.html(103),requests.html(104),system.html(105),activity.html(106),settings.html(900), pluslogin.html, which stands alone. The old single-pagedashboard.htmlwas deleted in the v2.0.0 redesign along withdashboard.css,login.css,settings.cssanddashboard.js.- 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, 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 instatic/css/teletext.css, scoped with a structural selector or a small modifier class. The only legitimate inlinestyle=is a per-request computed value Jinja has to interpolate — currently just thebar()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.
- Templates carry markup only. CSS and JS live in
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:- 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.
- No enclosure. Nothing is surrounded by a border or a box. There is
zero
border-radiusin 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. - One ruler. Every bar and percentage shares one scale
(
--bar-cells) and is drawn only by thebar()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. - 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.
- 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-dimmerfails 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/cfire the four fastext colour keys — both instatic/js/service.js.nav_itemsinapp.pyis 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 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.
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
See the deploy-arr-summary skill for the Forgejo push-to-deploy
pipeline, manual container restart steps, deploy verification, and the
Nginx Proxy Manager / TLS setup and its Cox-port-80/DNS-01 constraint.
Logins
See the manage-accounts skill for the multi-user vs. legacy
single-shared-password login modes and the manage_users.py CLI.
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.