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
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_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
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
usersinsettings.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_passwordpath, 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.