All checks were successful
Deploy / deploy (push) Successful in 55s
Every TV request now needs a decision, so P104 offers the two verdicts
inline on the rows actually waiting rather than sending the visitor to
Ombi's own UI.
Ombi is now the one service the app writes to. Three details the new
ombi_write() helper and /api/ombi/decide route have to get right:
approve is a POST but deny is a PUT; a TV decision acts on the child
request id, not the show, so get_ombi_summary() carries child["id"] for
TV rows and m["id"] for movies; and Ombi answers 200 with
{"result": false, ...} on a logical refusal, so the body is checked
rather than the status code. A successful decision clears _ombi_cache —
the 4-hour TTL would otherwise keep calling the row pending for the
rest of the afternoon.
The buttons reuse the existing two-tap arming and share its armed slot,
so arming one disarms the other: an approval starts a download and a
denial is somebody's answer, neither wants a stray tap. They reverse
into green and red rather than the shared red of .tt-btn.armed, so the
confirming press states which verdict it is about to send. After a
decision the row's status flips in place and the Pending figure and its
"N of M" both settle; at zero the page drops its warn weight, since
nothing waits on a human any more (discipline 5). Below 560px the
verdicts take a full line of their own rather than shrinking below the
tap target.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YNc3gd3GRPudFwgPXNEm8A
238 lines
14 KiB
Markdown
238 lines
14 KiB
Markdown
# 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.
|
|
- Ombi is the one service the app **writes** to: P104's Approve/Deny
|
|
buttons post to `/api/ombi/decide`, which fans out to Ombi's four
|
|
write endpoints via `ombi_write()`. Three things there are easy to
|
|
get wrong: approve is a `POST` but deny is a `PUT`; a TV decision
|
|
acts on the **child request** id, not the show, which is why
|
|
`get_ombi_summary()` carries `child["id"]` for a TV row and
|
|
`m["id"]` for a movie; and Ombi answers `200` with
|
|
`{"result": false, "errorMessage": ...}` on a logical refusal
|
|
("already approved"), so `ombi_write()` checks the body rather than
|
|
trusting the status code. A successful decision clears
|
|
`_ombi_cache` — the 4-hour TTL would otherwise keep calling the row
|
|
pending for the rest of the afternoon.
|
|
- 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 — except `--tt-orange`, the one deliberate, acknowledged
|
|
break in `base.css`, added when the fastext nav ran out of legible
|
|
colours for its eighth destination (Activity) and dithering the
|
|
word itself proved illegible. Treat a *second* ninth colour showing
|
|
up as the regression this discipline warns about; this one is a
|
|
one-time, explained exception, not a precedent.
|
|
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 four of the fastext
|
|
entries — 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>`.
|
|
- **Fastext leads everywhere but the index, and closes the index.**
|
|
`FULL_FASTEXT` in `app.py` lists 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
|
|
with `HOME_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 flat colour, text and swatch
|
|
both: Index/Library/Discover/System keep their `r`/`g`/`y`/`c`
|
|
shortcut; Requests gets magenta/`--pending` with 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 its fully-coloured neighbours.
|
|
Activity gets `--tt-orange` (`.k-orange`) — a genuine ninth colour,
|
|
the one destination that breaks "eight colours, no ninth"
|
|
outright, defined and explained in `base.css` right after the
|
|
locked eight. It's there because the palette ran out at six
|
|
(dithering a swatch alone read as unfinished, and dithering the
|
|
word itself renders illegible) and the visitor wanted every
|
|
destination to look different, not merely as different as eight
|
|
colours allow. Settings alone keeps plain ink — deliberately, as
|
|
the one page with no live status of its own. Whichever entry
|
|
matches the current page renders as a `.fastext-here` span 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 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.
|
|
|
|
**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.:
|
|
|
|
```bash
|
|
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.
|
|
|
|
**Every commit should be matched with a version change.** Bump
|
|
`APP_VERSION` / `APP_VERSION_DATE` near the top of `app.py` as part of
|
|
the same commit. This is what makes the footer (`v<APP_VERSION> ·
|
|
<APP_VERSION_DATE>`) a trustworthy signal that a given deploy actually
|
|
landed — a commit without a version bump can't be distinguished from
|
|
"not deployed yet" by looking at the live site.
|
|
|
|
## 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.
|