All checks were successful
Deploy / deploy (push) Successful in 24s
- Discover's search now hits Radarr's/Sonarr's own lookup endpoints directly (the same ones /add-to-radarr and /add-to-sonarr already use), instead of Ombi — one fewer round trip, and a result always resolves the same way its own Add button will. The Ombi-based ombi_search/_tmdb_poster helpers are gone. - The top nav on every page but the index now lists all eight destinations (FULL_FASTEXT) instead of the original four; the index keeps the original four-entry row as its own closing line (HOME_FASTEXT), since its body already lists every destination once. - System page's Transmission rows are now links to each service's own reverse-proxied instance (service_urls, already injected into every template but unused until now).
195 lines
11 KiB
Markdown
195 lines
11 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.
|
|
- 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 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. Only
|
|
those original four (Index/Library/Discover/System) carry an `r`/
|
|
`g`/`y`/`c` keyboard shortcut; the rest are click-only.
|
|
- **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.
|
|
|
|
## 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.
|