Swatch-only blue read as unfinished next to the other five fully- coloured entries. The word now carries the colour like every other key, same currentColor mechanism as the rest — accepting blue's ~2.4:1 contrast on black as a known, deliberate exception to the 4.5:1 bar everything else here clears.
12 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 four of the fastext entries — both instatic/js/service.js.nav_itemsinapp.pyis the source of truth for the numbers and is serialised into<body data-pages>. - Fastext leads everywhere but the index, and closes the index.
FULL_FASTEXTinapp.pylists every destination and renders right under the header on every page but the index — those pages carry no other nav, so it needs no scroll to reach. The index overrides it withHOME_FASTEXT, the original four-entry row, as its own closing line, since its body already lists every destination once. Every destination gets its own distinct mark. Six get a flat colour, text and swatch both: Index/Library/Discover/System keep theirr/g/y/cshortcut; Requests gets magenta/--pendingwith no shortcut (a deliberate fit — Requests already is "waiting on a human" everywhere else magenta appears); Watch gets blue (.k-blue) with no shortcut. Blue is a known exception to the app's own 4.5:1 text-contrast bar (it clears ~2.4:1 on black) — kept anyway, at the visitor's request, over the alternative of a swatch-only accent that read as unfinished next to six fully-coloured neighbours. Activity gets an ordered-dither swatch (.k-dither, same mechanism as--dither-mid) instead of a flat colour — the one other legal way to get a distinct mark without a ninth hue. Settings alone keeps plain ink with no swatch treatment — deliberately, as the one page with no live status of its own. Whichever entry matches the current page renders as a.fastext-herespan instead of a link — a page linking to itself did nothing, so it isn't offered as a click. - Nothing is clipped, hidden, or sticky. All seven page numbers and every fastext entry 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.