Fix System page crash, retire "held" copy, and slim CLAUDE.md
All checks were successful
Deploy / deploy (push) Successful in 27s

- 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
This commit is contained in:
Barely Removable 2026-08-30 08:05:49 -07:00
parent bb3e9af056
commit 08491c0c9b
9 changed files with 153 additions and 133 deletions

View file

@ -21,9 +21,7 @@
"Bash(expect:*)", "Bash(expect:*)",
"Bash(bash export-to-unraid.sh root@192.168.86.33)", "Bash(bash export-to-unraid.sh root@192.168.86.33)",
"Bash(scp:*)", "Bash(scp:*)",
"Bash(rsync:*)", "Bash(rsync:*)"
"Bash(/Users/matt/Desktop/summary/arr-summary/gunicorn.conf.py:*)",
"Bash(__NEW_LINE_58e4516f9721fb50__ ssh -o StrictHostKeyChecking=no -o IdentitiesOnly=yes root@192.168.86.33 \"cd /mnt/user/appdata/greendale && docker compose build --no-cache 2>&1 | tail -3 && docker compose up -d 2>&1\")"
] ]
} }
} }

View file

@ -0,0 +1,83 @@
---
name: deploy-arr-summary
description: Use when deploying Arr Summary (Media Centre), restarting the arr-summary container on Unraid, confirming a deploy landed, or troubleshooting the Nginx Proxy Manager reverse proxy / TLS certs for hippofam.com. Covers the Forgejo push-to-deploy pipeline, manual container restarts, and the DNS-01/Cox port-80 constraints.
---
## 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:
```bash
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:
```bash
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.

View file

@ -0,0 +1,39 @@
---
name: manage-accounts
description: Use when adding, removing, or changing dashboard login accounts for Arr Summary (Media Centre), or explaining the difference between its multi-user and legacy single-shared-password login modes.
---
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 `users` in `settings.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_password` path, 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:
```bash
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.

115
CLAUDE.md
View file

@ -166,119 +166,14 @@ app from a shell on an explicit `PORT` is the working path.
## Deployment ## Deployment
**Target:** Unraid server, container path `/mnt/user/appdata/greendale` See the `deploy-arr-summary` skill for the Forgejo push-to-deploy
(note the directory name differs from the container/repo name), built from pipeline, manual container restart steps, deploy verification, and the
`Dockerfile`/`docker-compose.yml` in this repo. Container name Nginx Proxy Manager / TLS setup and its Cox-port-80/DNS-01 constraint.
`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:
```bash
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:
```bash
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 ## Logins
Two modes, chosen by whether `settings.json` holds a non-empty `users` map: See the `manage-accounts` skill for the multi-user vs. legacy
single-shared-password login modes and the `manage_users.py` CLI.
- **Multi-user** (once any account exists): username + password, each
password stored as a salted pbkdf2 hash under `users` in `settings.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_password` path, 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:
```bash
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 **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/` `settings.json` committed to git, not in a fixture. `.env` and `data/`

View file

@ -53,16 +53,19 @@ so this is not optional). Session-based login (single shared password or
per-user accounts) gates the whole app; there's no public/unauthenticated per-user accounts) gates the whole app; there's no public/unauthenticated
view. view.
**In-progress restructure (branch `meridian-marquee`):** the app is **Multi-page restructure (shipped 2026-08-29, `meridian-marquee` merged
splitting from one long single-page dashboard into focused pages — Home to `main`):** the app split from one long single-page dashboard into
("marquee": what's playing, one line of system state, what's new), focused pages — Home/P100, Watch, Library, Discover, Requests, System,
Watch, Library, Discover, Requests, System, Activity — each answering one Activity — each answering one question well rather than one long scroll
question well rather than one long scroll trying to show everything. trying to show everything. This also resolved cases where a content-heavy
This also resolves cases where a content-heavy section (e.g. the per-disk section (e.g. the per-disk storage list) would otherwise force awkward
storage list) would otherwise force awkward row heights on a shared row heights on a shared "Right now" strip. `_shell.html` and
"Right now" strip. `_shell.html` and `_macros.html` are new shared-layout `_macros.html` are the shared-layout scaffolding; `dashboard.html` (the
scaffolding for this split; `dashboard.html` is the outgoing single-page old single-page template) was deleted, superseded by `home.html` + the
template being superseded by `home.html` + the per-page templates. per-page templates. Landed together with the visual redesign in
Brand Commitments below — see DESIGN.md and
`static/css/base.css`'s header comment for the visual system's own
invariants.
Each page fetches only the services it needs (e.g. `/watch` fetches only Each page fetches only the services it needs (e.g. `/watch` fetches only
Tautulli) rather than the old single route's all-services-at-once fetch — Tautulli) rather than the old single route's all-services-at-once fetch —

8
app.py
View file

@ -512,7 +512,8 @@ def _get_sab_history_cached():
if duration > 0 and size_bytes > 0: if duration > 0 and size_bytes > 0:
speeds.append(size_bytes / duration) speeds.append(size_bytes / duration)
avg_bytes_per_sec = sum(speeds) / len(speeds) if speeds else 0 avg_bytes_per_sec = sum(speeds) / len(speeds) if speeds else 0
avg_speed = _format_speed(avg_bytes_per_sec / 1024) avg_speed_kbps = avg_bytes_per_sec / 1024
avg_speed = _format_speed(avg_speed_kbps)
# Keep the newest completed items too — "Landed today" on the home # Keep the newest completed items too — "Landed today" on the home
# page and the activity feed both read these. Same request as before; # page and the activity feed both read these. Same request as before;
# previously only the aggregates survived. # previously only the aggregates survived.
@ -526,12 +527,12 @@ def _get_sab_history_cached():
result = {"week_size": week_size, "day_size": day_size, result = {"week_size": week_size, "day_size": day_size,
"completed_7d": completed_7d, "avg_speed": avg_speed, "completed_7d": completed_7d, "avg_speed": avg_speed,
"recent_items": recent_items} "avg_speed_kbps": avg_speed_kbps, "recent_items": recent_items}
_sab_history_cache = {"data": result, "expires": now + 600} _sab_history_cache = {"data": result, "expires": now + 600}
return result return result
except Exception: except Exception:
return {"week_size": "—", "day_size": "—", "completed_7d": "—", return {"week_size": "—", "day_size": "—", "completed_7d": "—",
"avg_speed": "—", "recent_items": []} "avg_speed": "—", "avg_speed_kbps": None, "recent_items": []}
def get_sabnzbd_summary(): def get_sabnzbd_summary():
@ -565,6 +566,7 @@ def get_sabnzbd_summary():
"week_size": hist["week_size"], "week_size": hist["week_size"],
"day_size": hist["day_size"], "day_size": hist["day_size"],
"avg_speed": hist["avg_speed"], "avg_speed": hist["avg_speed"],
"avg_speed_kbps": hist["avg_speed_kbps"],
"recent_items": hist.get("recent_items", []), "recent_items": hist.get("recent_items", []),
} }
except Exception as e: except Exception as e:

View file

@ -4,11 +4,11 @@
{% block content %} {% block content %}
<h1 class="tt-dh bloom tt-masthead">DISCOVER</h1> <h1 class="tt-dh bloom tt-masthead">DISCOVER</h1>
<p class="tt-empty">Popular right now, with anything already held filtered out.</p> <p class="tt-empty">Popular right now, with anything already in library filtered out.</p>
<div class="tt-field"> <div class="tt-field">
<span>Films</span> <span>Films</span>
<span class="tt-field-meta">{{ rt_movies | length }} not held</span> <span class="tt-field-meta">{{ rt_movies | length }} not in library</span>
</div> </div>
{% if rt_movies %} {% if rt_movies %}
<div class="tt-tiles"> <div class="tt-tiles">
@ -17,12 +17,12 @@
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
<p class="tt-empty">Nothing to suggest &mdash; the feed is unreachable or you already hold it all.</p> <p class="tt-empty">Nothing to suggest &mdash; the feed is unreachable or it's all already in your library.</p>
{% endif %} {% endif %}
<div class="tt-field"> <div class="tt-field">
<span>Television</span> <span>Television</span>
<span class="tt-field-meta">{{ rt_tv | length }} not held</span> <span class="tt-field-meta">{{ rt_tv | length }} not in library</span>
</div> </div>
{% if rt_tv %} {% if rt_tv %}
<div class="tt-tiles"> <div class="tt-tiles">

View file

@ -27,7 +27,7 @@
<a class="tt-row" href="{{ request.script_root }}/library"> <a class="tt-row" href="{{ request.script_root }}/library">
<span class="tt-row-num">102</span> <span class="tt-row-num">102</span>
<span class="tt-row-name">Library</span> <span class="tt-row-name">Library</span>
<span class="tt-row-body"><span class="tt-row-sub">Everything already held</span></span> <span class="tt-row-body"><span class="tt-row-sub">Everything in library</span></span>
{% if sonarr.status == 'ok' or radarr.status == 'ok' %} {% if sonarr.status == 'ok' or radarr.status == 'ok' %}
<span class="tt-row-val">{{ sonarr.total_series | default(0) }} SHOWS &nbsp;{{ radarr.total_movies | default(0) }} FILMS</span> <span class="tt-row-val">{{ sonarr.total_series | default(0) }} SHOWS &nbsp;{{ radarr.total_movies | default(0) }} FILMS</span>
{% else %} {% else %}
@ -38,7 +38,7 @@
<a class="tt-row" href="{{ request.script_root }}/discover"> <a class="tt-row" href="{{ request.script_root }}/discover">
<span class="tt-row-num">103</span> <span class="tt-row-num">103</span>
<span class="tt-row-name">Discover</span> <span class="tt-row-name">Discover</span>
<span class="tt-row-body"><span class="tt-row-sub">Popular, and not yet held</span></span> <span class="tt-row-body"><span class="tt-row-sub">Popular, and not yet in library</span></span>
<span class="tt-row-val">{{ (rt_movies | length) + (rt_tv | length) }} SUGGESTED</span> <span class="tt-row-val">{{ (rt_movies | length) + (rt_tv | length) }} SUGGESTED</span>
</a> </a>

View file

@ -120,7 +120,7 @@
<div class="tt-fig-l">In queue</div> <div class="tt-fig-l">In queue</div>
</div> </div>
<div class="tt-fig"> <div class="tt-fig">
<div class="tt-dh tt-fig-v"><span id="sab-speed">{{ sabnzbd.speed | default('0') }}</span> {{ trend(sabnzbd.speed_kbps, sabnzbd.avg_speed) }}</div> <div class="tt-dh tt-fig-v"><span id="sab-speed">{{ sabnzbd.speed | default('0') }}</span> {{ trend(sabnzbd.speed_kbps, sabnzbd.avg_speed_kbps) }}</div>
<div class="tt-fig-l">Speed vs 7-day avg</div> <div class="tt-fig-l">Speed vs 7-day avg</div>
</div> </div>
<div class="tt-fig"> <div class="tt-fig">