From 08491c0c9b4e067f437a14f0fde3e43bc67d1d51 Mon Sep 17 00:00:00 2001 From: Barely Removable Date: Sun, 30 Aug 2026 08:05:49 -0700 Subject: [PATCH 01/18] Fix System page crash, retire "held" copy, and slim CLAUDE.md - 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 Claude-Session: https://claude.ai/code/session_01NBw1QodG8ZMYHrGeQfqyDS --- .claude/settings.local.json | 4 +- .claude/skills/deploy-arr-summary/SKILL.md | 83 +++++++++++++++ .claude/skills/manage-accounts/SKILL.md | 39 +++++++ CLAUDE.md | 115 +-------------------- PRODUCT.md | 23 +++-- app.py | 8 +- templates/discover.html | 8 +- templates/home.html | 4 +- templates/system.html | 2 +- 9 files changed, 153 insertions(+), 133 deletions(-) create mode 100644 .claude/skills/deploy-arr-summary/SKILL.md create mode 100644 .claude/skills/manage-accounts/SKILL.md diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 163a54b..36f0b6c 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -21,9 +21,7 @@ "Bash(expect:*)", "Bash(bash export-to-unraid.sh root@192.168.86.33)", "Bash(scp:*)", - "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\")" + "Bash(rsync:*)" ] } } diff --git a/.claude/skills/deploy-arr-summary/SKILL.md b/.claude/skills/deploy-arr-summary/SKILL.md new file mode 100644 index 0000000..18a9778 --- /dev/null +++ b/.claude/skills/deploy-arr-summary/SKILL.md @@ -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 · ` 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 :443 -servername hippofam.com +docker exec -it arr-summary python3 manage_users.py passwd +docker exec -it arr-summary python3 manage_users.py remove +``` + +**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. diff --git a/CLAUDE.md b/CLAUDE.md index 2c2c4ac..a5b4c8d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -166,119 +166,14 @@ 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: -```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 · ` 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 :443 -servername hippofam.com -docker exec -it arr-summary python3 manage_users.py passwd -docker exec -it arr-summary python3 manage_users.py remove -``` - -**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. +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/` diff --git a/PRODUCT.md b/PRODUCT.md index 60ebd49..b9388d6 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -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 view. -**In-progress restructure (branch `meridian-marquee`):** the app is -splitting from one long single-page dashboard into focused pages — Home -("marquee": what's playing, one line of system state, what's new), -Watch, Library, Discover, Requests, System, Activity — each answering one -question well rather than one long scroll trying to show everything. -This also resolves cases where a content-heavy section (e.g. the per-disk -storage list) would otherwise force awkward row heights on a shared -"Right now" strip. `_shell.html` and `_macros.html` are new shared-layout -scaffolding for this split; `dashboard.html` is the outgoing single-page -template being superseded by `home.html` + the per-page templates. +**Multi-page restructure (shipped 2026-08-29, `meridian-marquee` merged +to `main`):** the app split from one long single-page dashboard into +focused pages — Home/P100, Watch, Library, Discover, Requests, System, +Activity — each answering one question well rather than one long scroll +trying to show everything. This also resolved cases where a content-heavy +section (e.g. the per-disk storage list) would otherwise force awkward +row heights on a shared "Right now" strip. `_shell.html` and +`_macros.html` are the shared-layout scaffolding; `dashboard.html` (the +old single-page template) was deleted, superseded by `home.html` + the +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 Tautulli) rather than the old single route's all-services-at-once fetch — diff --git a/app.py b/app.py index ff2a0b6..37ede9d 100644 --- a/app.py +++ b/app.py @@ -512,7 +512,8 @@ def _get_sab_history_cached(): if duration > 0 and size_bytes > 0: speeds.append(size_bytes / duration) 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 # page and the activity feed both read these. Same request as before; # previously only the aggregates survived. @@ -526,12 +527,12 @@ def _get_sab_history_cached(): result = {"week_size": week_size, "day_size": day_size, "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} return result except Exception: return {"week_size": "—", "day_size": "—", "completed_7d": "—", - "avg_speed": "—", "recent_items": []} + "avg_speed": "—", "avg_speed_kbps": None, "recent_items": []} def get_sabnzbd_summary(): @@ -565,6 +566,7 @@ def get_sabnzbd_summary(): "week_size": hist["week_size"], "day_size": hist["day_size"], "avg_speed": hist["avg_speed"], + "avg_speed_kbps": hist["avg_speed_kbps"], "recent_items": hist.get("recent_items", []), } except Exception as e: diff --git a/templates/discover.html b/templates/discover.html index 43e21f6..bb00f23 100644 --- a/templates/discover.html +++ b/templates/discover.html @@ -4,11 +4,11 @@ {% block content %}

DISCOVER

-

Popular right now, with anything already held filtered out.

+

Popular right now, with anything already in library filtered out.

Films - {{ rt_movies | length }} not held + {{ rt_movies | length }} not in library
{% if rt_movies %}
@@ -17,12 +17,12 @@ {% endfor %}
{% else %} -

Nothing to suggest — the feed is unreachable or you already hold it all.

+

Nothing to suggest — the feed is unreachable or it's all already in your library.

{% endif %}
Television - {{ rt_tv | length }} not held + {{ rt_tv | length }} not in library
{% if rt_tv %}
diff --git a/templates/home.html b/templates/home.html index b5fcefb..9ad36b2 100644 --- a/templates/home.html +++ b/templates/home.html @@ -27,7 +27,7 @@ 102 Library - Everything already held + Everything in library {% if sonarr.status == 'ok' or radarr.status == 'ok' %} {{ sonarr.total_series | default(0) }} SHOWS  {{ radarr.total_movies | default(0) }} FILMS {% else %} @@ -38,7 +38,7 @@ 103 Discover - Popular, and not yet held + Popular, and not yet in library {{ (rt_movies | length) + (rt_tv | length) }} SUGGESTED diff --git a/templates/system.html b/templates/system.html index 1982e7e..ba0f34d 100644 --- a/templates/system.html +++ b/templates/system.html @@ -120,7 +120,7 @@
In queue
-
{{ sabnzbd.speed | default('0') }} {{ trend(sabnzbd.speed_kbps, sabnzbd.avg_speed) }}
+
{{ sabnzbd.speed | default('0') }} {{ trend(sabnzbd.speed_kbps, sabnzbd.avg_speed_kbps) }}
Speed vs 7-day avg
From abaccaaacd03b4e09c384efb86364c49296fb67c Mon Sep 17 00:00:00 2001 From: Barely Removable Date: Sun, 30 Aug 2026 08:26:47 -0700 Subject: [PATCH 02/18] Move fastext nav to page top, fix Settings header, add working search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fastext colour keys now lead every page but the index, since they're the only nav those pages carry; the index keeps them as its closing row since its own body is already the full nav. - Settings page was missing page="settings" in its render_template call, so its header silently fell back to "P100 Index". - Library's search form posted a q param the route never read; it now filters the cached Sonarr/Radarr lists and shows matching tiles. - Discover gained a search bar that queries Ombi (already in front of TMDB/TVDB) and lets you add a result straight to Radarr/Sonarr, showing "Held" instead of a button for anything already in library. - Fixed the Add button's success check, which read d.ok against a response that actually sets d.success — it reported FAILED even when the add worked. --- app.py | 100 ++++++++++++++++++++++++++++++++++++++-- static/css/teletext.css | 16 +++++-- static/js/service.js | 2 +- templates/_shell.html | 21 ++++++--- templates/discover.html | 31 ++++++++++++- templates/library.html | 18 +++++++- 6 files changed, 172 insertions(+), 16 deletions(-) diff --git a/app.py b/app.py index 37ede9d..4ab59b1 100644 --- a/app.py +++ b/app.py @@ -726,6 +726,61 @@ def ombi_get(endpoint): return resp.json() +def _tmdb_poster(path): + """Ombi's movie search returns a bare TMDB path ('/abc123.jpg'), not a + usable URL; TV search returns a full TVDB banner URL already.""" + if not path: + return "" + if path.startswith("http"): + return path + return f"https://image.tmdb.org/t/p/w300{path}" + + +def ombi_search(q, limit=12): + """Movies and TV matching q, via Ombi's own search — it already sits in + front of TheMovieDB/TVDB, so this is one call instead of two more API + keys. Cross-referenced against the cached Sonarr/Radarr title sets so a + title already held shows that instead of an Add button. + """ + term = q.strip() + if not term: + return {"status": "ok", "results": []} + encoded = requests.utils.quote(term) + try: + radarr_titles = {t.lower() for t in get_radarr_summary().get("radarr_titles", [])} + sonarr_titles = {t.lower() for t in get_sonarr_summary().get("sonarr_titles", [])} + results = [] + try: + for m in ombi_get(f"Search/movie/{encoded}")[:limit]: + title = m.get("title", "") + results.append({ + "title": title, + "year": (m.get("releaseDate") or "")[:4], + "poster": _tmdb_poster(m.get("posterPath")), + "kind": "Film", + "target": "radarr", + "held": bool(m.get("available")) or title.lower() in radarr_titles, + }) + except Exception: + pass + try: + for s in ombi_get(f"Search/tv/{encoded}")[:limit]: + title = s.get("title", "") + results.append({ + "title": title, + "year": (s.get("firstAired") or "")[:4], + "poster": s.get("banner") or _tmdb_poster(s.get("posterPath")), + "kind": "TV", + "target": "sonarr", + "held": bool(s.get("available")) or title.lower() in sonarr_titles, + }) + except Exception: + pass + return {"status": "ok", "results": results} + except Exception as e: + return {"status": "error", "error": str(e), "results": []} + + def _ombi_status_label(available, approved, denied): if denied: return "Denied" @@ -1404,6 +1459,41 @@ def get_recently_added(limit=6): return items +def search_library(q, limit=24): + """Titles in Sonarr/Radarr matching q, case-insensitive substring. + + Filters the same cached lists get_recently_added uses — no new fetch. + """ + needle = q.strip().lower() + if not needle: + return [] + items = [] + try: + for sr in sonarr_series_cached(): + if needle in sr.get("title", "").lower(): + items.append({ + "title": sr.get("title", "Unknown"), + "kind": "TV", + "year": str(sr.get("year", "")), + "poster": _poster_url(sr.get("images")), + }) + except Exception: + pass + try: + for mv in radarr_movies_cached(): + if needle in mv.get("title", "").lower(): + items.append({ + "title": mv.get("title", "Unknown"), + "kind": "Film", + "year": str(mv.get("year", "")), + "poster": _poster_url(mv.get("images")), + }) + except Exception: + pass + items.sort(key=lambda x: x["title"].lower()) + return items[:limit] + + def get_activity_feed(sabnzbd, ombi, limit=25): """A single chronological feed assembled from what the fetchers return. @@ -1508,9 +1598,11 @@ def watch_page(): @login_required def library_page(): r = fetch_services(["sonarr", "radarr"]) + q = request.args.get("q", "").strip() return render_template("library.html", page="library", sonarr=r["sonarr"], radarr=r["radarr"], - recently_added=get_recently_added(12)) + recently_added=get_recently_added(12), + q=q, search_results=search_library(q) if q else None) @app.route("/discover") @@ -1519,9 +1611,11 @@ def discover_page(): r = fetch_services(["sonarr", "radarr", "rt_movies", "rt_tv"]) sonarr_titles = set(r["sonarr"].get("sonarr_titles", [])) radarr_titles = set(r["radarr"].get("radarr_titles", [])) + q = request.args.get("q", "").strip() return render_template("discover.html", page="discover", rt_movies=_filter_rt(r["rt_movies"], radarr_titles), - rt_tv=_filter_rt(r["rt_tv"], sonarr_titles)) + rt_tv=_filter_rt(r["rt_tv"], sonarr_titles), + q=q, search=ombi_search(q) if q else None) @app.route("/requests") @@ -1760,7 +1854,7 @@ def api_delete_show(): @login_required def settings_page(): s = load_settings() - return render_template("settings.html", + return render_template("settings.html", page="settings", timezone=s.get("timezone", "America/New_York"), sonarr_api_key=SONARR_API_KEY, radarr_api_key=RADARR_API_KEY, diff --git a/static/css/teletext.css b/static/css/teletext.css index ad36959..69eb404 100644 --- a/static/css/teletext.css +++ b/static/css/teletext.css @@ -465,10 +465,15 @@ a.tt-row:focus-visible .tt-row-sub { color: var(--tt-black); } .tt-empty { padding: 0 1ch; color: var(--ink-dim); } /* ── Fastext ──────────────────────────────────────────── - The four coloured keys along the bottom of every teletext - page. Real navigation, not decoration: click one, or press - its initial. Deliberately NOT fixed to the viewport — it is - the last row of the page, where it always was. */ + The four coloured keys. Real navigation, not decoration: + click one, or press its initial. Deliberately NOT fixed to + the viewport — it is a row of the page, where it always + was, not furniture bolted over it. On the index it closes + the page, under Now Showing/Just Added, because the index's + own body is already the full nav. Everywhere else — where + these four keys are the only nav on the page — it opens the + page, right under the header, so it doesn't need a scroll + to reach. */ .fastext { display: grid; grid-template-columns: repeat(4, 1fr); @@ -477,6 +482,9 @@ a.tt-row:focus-visible .tt-row-sub { color: var(--tt-black); } padding: 0 var(--gutter); gap: 1ch; } +.fastext-top { + margin: var(--row) auto calc(var(--row) * 1.5); +} .fastext a { display: flex; align-items: center; diff --git a/static/js/service.js b/static/js/service.js index d95f0b2..f7e71fc 100644 --- a/static/js/service.js +++ b/static/js/service.js @@ -129,7 +129,7 @@ }) .then(function (r) { return r.json(); }) .then(function (d) { - if (d && d.ok) { + if (d && d.success) { btn.classList.add('done'); btn.textContent = 'ADDED'; } else { diff --git a/templates/_shell.html b/templates/_shell.html index 6970434..2509aa4 100644 --- a/templates/_shell.html +++ b/templates/_shell.html @@ -45,19 +45,28 @@ provenance. -
- {% block content %}{% endblock %} -
- {#- Fastext: the four coloured keys. Each page names its own four, so the - keys always point somewhere useful from where you are. -#} -