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..03e5041 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,6 +35,18 @@ the user's Unraid home server. Server-side project directory is still named 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. @@ -66,7 +78,12 @@ the user's Unraid home server. Server-side project directory is still named 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. + 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 @@ -96,12 +113,39 @@ the user's Unraid home server. Server-side project directory is still named `--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 the four fastext colour - keys — both in `static/js/service.js`. `nav_items` in `app.py` is the - source of truth for the numbers and is serialised into + 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 ``. + - **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 - all four fastext keys stay visible at every width, reflowing to another + 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 @@ -166,119 +210,21 @@ 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`. +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. -**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 · +`) 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 -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 -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/DESIGN.md b/DESIGN.md index 8934f65..58273d9 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -140,7 +140,7 @@ components: Media Centre is a broadcast information service, not a dashboard. It refuses the bordered-card grid that every self-hosted *arr dashboard ships — including this one, until this redesign. The world is teletext: Ceefax and the American cable guide channel, fused. Eight colours at full saturation on pure black, one blocky monospace at exactly two heights, a character-cell grid, and grouping carried entirely by colour fields and row weight. The visitor types or clicks a page number, reads the machine's state off colour alone, and leaves knowing whether anything needs them. -The density is high and unapologetic. A teletext page was 40 columns; this one gets 92 (`--page-cols: 92ch`) because the screen allows it, but the character grid is the substrate everywhere — padding is measured in `ch`, bars are measured in cells, and nothing floats free of the row rhythm (`--row`, one character row). There is no chrome. The header is a row of the page, not furniture bolted above it, so it scrolls away with everything else. The fastext colour keys sit at the bottom of the page where they always sat, not fixed to the viewport. +The density is high and unapologetic. A teletext page was 40 columns; this one gets 92 (`--page-cols: 92ch`) because the screen allows it, but the character grid is the substrate everywhere — padding is measured in `ch`, bars are measured in cells, and nothing floats free of the row rhythm (`--row`, one character row). There is no chrome. The header is a row of the page, not furniture bolted above it, so it scrolls away with everything else. The fastext keys sit at the top of every page but the index — which closes with them instead, since its own body already is the full nav — never fixed to the viewport. The confirmed anti-reference is the world this replaced and every dashboard that shares its reflexes: the rounded, bordered, drop-shadowed card grid with a neutral dark-grey palette and a soft accent. Nothing here is enclosed. There is not one `border-radius` and not one `box-shadow` in the codebase — the single `text-shadow` is a phosphor bloom, and it is spent deliberately. Emphasis is bought with colour and with doubled height, never with a weight ramp, never with a third type size, never with a container. @@ -181,7 +181,7 @@ Teletext had exactly eight colours, at full saturation, and the product's semant ### Named Rules -**The Locked Eight Rule.** Eight colours exist. Adding a ninth hue, or desaturating one of the eight to make it sit more politely, is a regression rather than a variation. If a new state needs a colour, it takes one of the eight and inherits that colour's meaning. +**The Locked Eight Rule.** Eight colours exist. Adding a ninth hue, or desaturating one of the eight to make it sit more politely, is a regression rather than a variation. If a new state needs a colour, it takes one of the eight and inherits that colour's meaning. **One acknowledged exception:** `--tt-orange` in `base.css`, added for the fastext nav's eighth destination once the locked eight ran out of colours legible as text on black — a one-time, explained break, not a precedent for a second. **The Dither Rule.** A mid-tone is an ordered dither of two of the eight at cell scale — a checkerboard or a repeating gradient stepped at `--dither-size: 2px` or at `--bar-cell-w` — exactly how a 16-colour screen faked a gradient. It is never a ninth colour smuggled into the palette. The two derived greys are the sanctioned exception, and they derive from white by `color-mix`, not by hand. 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..6d831b3 100644 --- a/app.py +++ b/app.py @@ -22,8 +22,8 @@ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) # Docker tab caches stale container IDs, so the footer is the reliable way # to confirm a `docker compose up -d --build` actually took effect. # Both are env-overridable if the deploy pipeline ever injects them. -APP_VERSION = os.environ.get("APP_VERSION", "2.0.0") -APP_VERSION_DATE = os.environ.get("APP_VERSION_DATE", "2026-08-29") +APP_VERSION = os.environ.get("APP_VERSION", "2.3.1") +APP_VERSION_DATE = os.environ.get("APP_VERSION_DATE", "2026-09-05") # Must point at a path that survives `docker compose up --build` — the # container filesystem does not. See the data volume in docker-compose.yml. @@ -287,6 +287,11 @@ _sonarr_cache = {"data": None, "expires": 0} _radarr_cache = {"data": None, "expires": 0} ARR_CACHE_TTL = 5 * 60 # 5 minutes +# A show has to have sat in Sonarr longer than this, untouched, before the +# Admin section will suggest deleting it — keeps something just downloaded +# and not yet gotten to from showing up next to a delete button. +ADMIN_UNWATCHED_MONTHS = 6 + # Tautulli stats cache: top shows/movies/users/history refresh every 2 minutes. # Active streams (get_activity) are always fetched live. _tautulli_stats_cache = {"data": None, "expires": 0} @@ -512,7 +517,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 +532,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 +571,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: @@ -610,6 +617,7 @@ def get_tautulli_summary(): top_movie_list = cached["top_movie_list"] top_played = cached["top_played"] top_users = cached["top_users"] + active_users = cached["active_users"] else: # Top 3 most watched TV shows (30d) tv_data = tautulli_get("get_home_stats", "&stat_id=top_tv&stats_count=3&stats_type=plays&time_range=30") @@ -653,9 +661,36 @@ def get_tautulli_summary(): if len(top_users) >= 3: break + # Active viewers (10d) — everyone with a play in the window, for + # the Watch page's "click a name for their 48h history" list. + # A dedicated call rather than reusing top_played/top_users + # above: those are capped at 3 and tuned for different windows + # (all-time-ish top-3, 30d top-10), neither of which is "who's + # been around lately". + cutoff_date = (datetime.now() - timedelta(days=10)).strftime("%Y-%m-%d") + active_data = tautulli_get("get_history", f"&length=1000&order_column=date&order_dir=desc&after={cutoff_date}") + active_slots = [] + if isinstance(active_data, dict): + active_slots = active_data.get("data", []) + if isinstance(active_slots, dict): + active_slots = active_slots.get("data", []) + active_by_user = {} + for entry in active_slots: + username = entry.get("friendly_name") or entry.get("user", "Unknown") + if username.lower() == "ninja_hippo": + continue + rec = active_by_user.setdefault(username, {"username": username, "plays": 0, "last_watched_ts": 0}) + rec["plays"] += 1 + rec["last_watched_ts"] = max(rec["last_watched_ts"], entry.get("date", 0) or 0) + active_users = sorted(active_by_user.values(), key=lambda x: x["last_watched_ts"], reverse=True) + for u in active_users: + u["last_watched"] = (datetime.fromtimestamp(u["last_watched_ts"]).strftime("%d %b · %H:%M") + if u["last_watched_ts"] else "") + _tautulli_stats_cache = { "data": {"top_tv_list": top_tv_list, "top_movie_list": top_movie_list, - "top_played": top_played, "top_users": top_users}, + "top_played": top_played, "top_users": top_users, + "active_users": active_users}, "expires": now + TAUTULLI_CACHE_TTL, } @@ -706,6 +741,7 @@ def get_tautulli_summary(): "top_movie_list": top_movie_list, "top_played": top_played, "top_users": top_users, + "active_users": active_users, "active_streams": stream_count, "sessions": sessions, } @@ -713,6 +749,140 @@ def get_tautulli_summary(): return {"status": "error", "error": str(e)} +def get_user_watch_history(username, hours=48): + """One viewer's plays in the last `hours`, for the Watch page's + per-user drill-down. Fetched on demand, not cached — this is a click + target, not something every page load needs to pay for.""" + try: + cutoff_ts = time.time() - hours * 3600 + encoded = requests.utils.quote(username) + hist = tautulli_get("get_history", f"&user={encoded}&length=200&order_column=date&order_dir=desc") + slots = [] + if isinstance(hist, dict): + slots = hist.get("data", []) + if isinstance(slots, dict): + slots = slots.get("data", []) + items = [] + for entry in slots: + ts = entry.get("date", 0) or 0 + if ts < cutoff_ts: + continue + try: + percent = int(float(entry.get("percent_complete", 0) or 0)) + except (TypeError, ValueError): + percent = 0 + items.append({ + "title": entry.get("full_title") or entry.get("title", "Unknown"), + "media_type": entry.get("media_type", ""), + "percent": max(0, min(100, percent)), + "when": datetime.fromtimestamp(ts).strftime("%d %b · %H:%M") if ts else "", + }) + # Note: "items" collides with dict.items in Jinja attribute lookup + # (getattr wins over __getitem__), so the template key is "entries". + return {"status": "ok", "entries": items} + except Exception as e: + return {"status": "error", "error": str(e)} + + +def _fmt_bytes(b): + if b >= 1024**3: + return f"{b / 1024**3:.1f} GB" + return f"{b / 1024**2:.0f} MB" + + +def get_admin_unwatched_shows(limit=10, months=ADMIN_UNWATCHED_MONTHS): + """Sonarr shows with episodes on disk that Tautulli has never played, + largest first — the source list for P105's Admin section. + + Excludes anything added to Sonarr within the last `months` months, so a + show that's simply new and hasn't been gotten to yet doesn't turn up + next to a delete button. Matches against Tautulli's full play history + (a large `length`, not the 30/100-play windows the rest of the + dashboard uses) because a recency-limited window could mismark a show + that genuinely was watched — just a while ago — as never-watched, + which matters a lot more here than elsewhere given what's attached to + the wrong answer. Each show also carries whoever's Ombi request + triggered it, best-effort by title match — a show added straight in + Sonarr rather than requested has no requester to find. + """ + try: + series_list = sonarr_series_cached() + except Exception as e: + return {"status": "error", "error": str(e)} + + try: + hist = tautulli_get("get_history", "&length=5000&order_column=date&order_dir=desc") + except Exception as e: + return {"status": "error", "error": str(e)} + + played_titles = set() + slots = hist.get("data", []) if isinstance(hist, dict) else [] + if isinstance(slots, dict): + slots = slots.get("data", []) + for entry in slots: + t = entry.get("grandparent_title") or entry.get("full_title") or entry.get("title", "") + if t: + played_titles.add(t.lower()) + + cutoff = datetime.utcnow() - timedelta(days=30 * months) + + never = [] + for s in series_list: + stats = s.get("statistics") or {} + size = stats.get("sizeOnDisk", 0) or s.get("sizeOnDisk", 0) + if not size: + continue + title = s.get("title", "") + if title.lower() in played_titles: + continue + added = s.get("added", "") or "" + try: + added_dt = datetime.strptime(added[:19], "%Y-%m-%dT%H:%M:%S") + if added_dt > cutoff: + continue # too recently added to fairly call it neglected + except (ValueError, TypeError): + pass # no usable added date — don't let a bad field hide a candidate + never.append({ + "sonarr_id": s.get("id"), + "title": title, + "year": s.get("year", ""), + "size": size, + "ep_count": stats.get("episodeFileCount", 0), + "added": added, + }) + + never.sort(key=lambda x: x["size"], reverse=True) + top = never[:limit] + + # Sonarr doesn't know who asked for a show — Ombi does, via whoever's + # request triggered the grab. Best-effort only: a show added straight + # in Sonarr, without an Ombi request, has no requester to find, and an + # unreachable Ombi shouldn't take down the rest of the section. + requested_by = {} + try: + for show in ombi_get("Request/tv"): + for child in show.get("childRequests", []): + user = child.get("requestedUser", {}) + alias = (user.get("alias") or user.get("userAlias") + or user.get("userName") or "").split("@")[0] + if alias: + requested_by.setdefault(show.get("title", "").lower(), alias) + except Exception: + pass + + for show in top: + show["size_display"] = _fmt_bytes(show["size"]) + show["requested_by"] = requested_by.get(show["title"].lower(), "") + total_bytes = sum(show["size"] for show in top) + + return { + "status": "ok", + "shows": top, + "months": months, + "total_display": _fmt_bytes(total_bytes) if top else "0 MB", + } + + # --------------------------------------------------------------------------- # Ombi # --------------------------------------------------------------------------- @@ -724,6 +894,23 @@ def ombi_get(endpoint): return resp.json() +def ombi_write(method, endpoint, payload): + """Ombi's approve/deny endpoints answer 200 with + {"result": false, "errorMessage": ...} on a logical refusal rather than + an HTTP error, so the body has to be checked and not just the status.""" + headers = {"ApiKey": OMBI_API_KEY, "Content-Type": "application/json"} + resp = requests.request(method, f"{OMBI_URL}/api/v1/{endpoint}", + headers=headers, json=payload, timeout=10) + resp.raise_for_status() + try: + body = resp.json() + except ValueError: + return {} + if isinstance(body, dict) and body.get("result") is False: + raise RuntimeError(body.get("errorMessage") or "Ombi refused the decision") + return body + + def _ombi_status_label(available, approved, denied): if denied: return "Denied" @@ -752,6 +939,7 @@ def get_ombi_summary(): if alias.lower() == "ninja_hippo": continue all_requests.append({ + "id": m.get("id"), "title": m.get("title", "Unknown"), "type": "Movie", "requested_date": m.get("requestedDate", ""), @@ -775,6 +963,9 @@ def get_ombi_summary(): ) denied = child.get("denied", False) all_requests.append({ + # Ombi's tv approve/deny act on the child request, not + # the parent show, so this is the child's id. + "id": child.get("id"), "title": show.get("title", "Unknown"), "type": "TV", "requested_date": child.get("requestedDate", ""), @@ -1253,6 +1444,7 @@ PAGE_TASKS = { "unraid": lambda: get_unraid_stats(), "rt_movies": lambda: get_rt_movies(), "rt_tv": lambda: get_rt_tv(), + "admin_unwatched": lambda: get_admin_unwatched_shows(), } PAGE_TIMEOUT = 5.0 @@ -1301,6 +1493,44 @@ def service_urls(): } +# Every destination, for the top of every page but the index, each +# with its own distinct colour. Order matches nav_items. The four +# with a real keyboard shortcut (r/g/y/c) keep their original colours; +# Requests gets magenta (the same --pending magenta a request already +# shows once approved elsewhere — Requests IS "waiting on a human"); +# Watch gets blue, ~2.4:1 contrast on black versus the 4.5:1 every +# other body text here clears — a known, deliberate exception, kept +# anyway because a swatch-only accent read as unfinished next to six +# fully-coloured neighbours. That's every colour the locked eight +# palette has to give (black's invisible on black); Activity's colour, +# --tt-orange, is a genuine ninth on top of it — a dithered swatch +# alone read as unfinished, and dithering the word itself renders +# illegible, so it's a deliberate, acknowledged break from "eight +# colours, no ninth" rather than a fix that stays inside it. Settings +# alone keeps plain ink — deliberately, as the one page with no live +# status of its own, so it's the one that gets no colour rather than +# an arbitrary leftover. +FULL_FASTEXT = [ + ("k-red", "r", "100", "Index", "/"), + ("k-blue", "", "101", "Watch", "/watch"), + ("k-green", "g", "102", "Library", "/library"), + ("k-yellow", "y", "103", "Discover", "/discover"), + ("k-magenta", "", "104", "Requests", "/requests"), + ("k-cyan", "c", "105", "System", "/system"), + ("k-orange", "", "106", "Activity", "/activity"), + ("", "", "900", "Settings", "/settings"), +] + +# The index's own closing row: just the four letter keys, since the +# index's body already lists every one of the above once. +HOME_FASTEXT = [ + ("k-red", "r", "100", "Index", "/"), + ("k-green", "g", "102", "Library", "/library"), + ("k-yellow", "y", "103", "Discover", "/discover"), + ("k-cyan", "c", "105", "System", "/system"), +] + + @app.context_processor def inject_page_chrome(): """Shell-wide context: the nav, the clock's timezone, service links.""" @@ -1321,15 +1551,12 @@ def inject_page_chrome(): ("activity", "106", "Activity", "/activity"), ("settings", "900", "Settings", "/settings"), ], - # The four coloured keys along the bottom of every page. A page - # that wants different destinations passes its own `fastext` to - # render_template, which wins over this default. - "fastext": [ - ("k-red", "r", "100", "Index", "/"), - ("k-green", "g", "102", "Library", "/library"), - ("k-yellow", "y", "103", "Discover", "/discover"), - ("k-cyan", "c", "105", "System", "/system"), - ], + # Every page, at the top of every page but the index — which + # overrides this with HOME_FASTEXT, its own short closing row, + # since its body already lists all of these once. The four + # letter keys (r/g/y/c) keep their original destinations; the + # rest have no keyboard shortcut, just a click. + "fastext": FULL_FASTEXT, } @@ -1402,6 +1629,86 @@ 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 arr_search(q, limit=12): + """Movies and TV matching q, straight from Radarr's and Sonarr's own + lookup endpoints — the same ones /add-to-radarr and /add-to-sonarr + already use to resolve a title before adding. No Ombi round trip and + no second title-matching path to keep in sync with the Add buttons. + """ + term = q.strip() + if not term: + return {"status": "ok", "results": []} + encoded = requests.utils.quote(term) + results = [] + radarr_ok = sonarr_ok = True + try: + radarr_titles = {t.lower() for t in get_radarr_summary().get("radarr_titles", [])} + for m in radarr_get(f"movie/lookup?term={encoded}")[:limit]: + title = m.get("title", "") + results.append({ + "title": title, + "year": str(m.get("year", "")), + "poster": _poster_url(m.get("images")), + "kind": "Film", + "target": "radarr", + "held": title.lower() in radarr_titles, + }) + except Exception: + radarr_ok = False + try: + sonarr_titles = {t.lower() for t in get_sonarr_summary().get("sonarr_titles", [])} + for s in sonarr_get(f"series/lookup?term={encoded}")[:limit]: + title = s.get("title", "") + results.append({ + "title": title, + "year": str(s.get("year", "")), + "poster": _poster_url(s.get("images")), + "kind": "TV", + "target": "sonarr", + "held": title.lower() in sonarr_titles, + }) + except Exception: + sonarr_ok = False + if not results and not radarr_ok and not sonarr_ok: + return {"status": "error", "error": "Sonarr and Radarr both unreachable", "results": []} + return {"status": "ok", "results": results} + + def get_activity_feed(sabnzbd, ombi, limit=25): """A single chronological feed assembled from what the fetchers return. @@ -1480,7 +1787,7 @@ def home(): others = [s for s in sessions if s is not hero] return render_template( - "home.html", page="home", + "home.html", page="home", fastext=HOME_FASTEXT, sonarr=sonarr, radarr=radarr, sabnzbd=r["sabnzbd"], tautulli=tautulli, prowlarr=r["prowlarr"], ombi=r["ombi"], unraid=r["unraid"], hero=hero, other_sessions=others, @@ -1499,16 +1806,21 @@ def home(): @login_required def watch_page(): r = fetch_services(["tautulli"]) - return render_template("watch.html", page="watch", tautulli=r["tautulli"]) + selected_user = request.args.get("user", "").strip() + user_history = get_user_watch_history(selected_user) if selected_user else None + return render_template("watch.html", page="watch", tautulli=r["tautulli"], + selected_user=selected_user, user_history=user_history) @app.route("/library") @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") @@ -1517,9 +1829,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=arr_search(q) if q else None) @app.route("/requests") @@ -1533,12 +1847,24 @@ def requests_page(): @login_required def system_page(): r = fetch_services(["unraid", "prowlarr", "sonarr", "radarr", - "sabnzbd", "tautulli", "ombi"]) + "sabnzbd", "tautulli", "ombi", "admin_unwatched"]) return render_template("system.html", page="system", unraid=r["unraid"], prowlarr=r["prowlarr"], sonarr=r["sonarr"], radarr=r["radarr"], sabnzbd=r["sabnzbd"], tautulli=r["tautulli"], - ombi=r["ombi"]) + ombi=r["ombi"], admin_unwatched=r["admin_unwatched"]) + + +@app.route("/api/array/refresh") +@login_required +def api_array_refresh(): + """Re-renders just the Array section's markup for P105's refresh + button. Unraid's stats aren't cached (get_unraid_stats hits the + GraphQL API live on every call), so this is a plain re-fetch — the + button exists for the visitor's own peace of mind, not to bypass a + cache. Returns HTML, not JSON: swapped directly into #array-section + rather than re-implementing the table/bar/disk-row markup in JS.""" + return render_template("_array_section.html", unraid=get_unraid_stats()) @app.route("/activity") @@ -1616,6 +1942,40 @@ def api_ombi(): return jsonify(get_ombi_summary()) +@app.route("/api/ombi/decide", methods=["POST"]) +@login_required +def api_ombi_decide(): + """Approve or deny one pending request from P104. + + Ombi splits this four ways — movie/tv x approve/deny — and approve is a + POST while deny is a PUT, so both halves are named explicitly rather + than derived.""" + data = request.get_json(silent=True) or {} + req_id = data.get("id") + kind = (data.get("type") or "").lower() + verdict = (data.get("verdict") or "").lower() + if not req_id: + return jsonify({"error": "No request id"}), 400 + if kind not in ("movie", "tv"): + return jsonify({"error": "Unknown request type"}), 400 + if verdict not in ("approve", "deny"): + return jsonify({"error": "Unknown verdict"}), 400 + try: + if verdict == "approve": + ombi_write("POST", f"Request/{kind}/approve", {"id": req_id}) + else: + ombi_write("PUT", f"Request/{kind}/deny", {"id": req_id, "reason": ""}) + # Without this the 4-hour cache keeps calling the row pending for the + # rest of the afternoon, and the page contradicts the decision just + # made on it. + _ombi_cache["data"] = None + _ombi_cache["expires"] = 0 + return jsonify({"success": True, + "status": "Approved" if verdict == "approve" else "Denied"}) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + @app.route("/api/services") @login_required def api_services(): @@ -1663,57 +2023,15 @@ def api_services(): @app.route("/api/tautulli/never-watched") @login_required def api_never_watched(): - """Movies and shows in Radarr/Sonarr that have never been played in Tautulli.""" - try: - # Get all history titles from Tautulli (last 500 entries) - hist = tautulli_get("get_history", "&length=500&order_column=date&order_dir=desc") - played_titles = set() - if isinstance(hist, dict): - slots = hist.get("data", []) - if isinstance(slots, dict): - slots = slots.get("data", []) - for s in slots: - t = s.get("full_title") or s.get("title") or s.get("parent_title", "") - if t: - played_titles.add(t.lower()) - - never = [] - - # Sonarr series with episodes on disk, never played - try: - series_list = sonarr_get("series") - for s in series_list: - size = s.get("sizeOnDisk", 0) - if size == 0: - continue - title = s.get("title", "") - if title.lower() not in played_titles: - never.append({ - "type": "show", - "title": title, - "year": s.get("year", ""), - "size": size, - "sonarr_id": s.get("id"), - }) - except Exception: - pass - - # Sort by size descending - never.sort(key=lambda x: x["size"], reverse=True) - - # Format size - def fmt(b): - gb = b / (1024**3) - if gb >= 1: - return f"{gb:.1f} GB" - return f"{b / (1024**2):.0f} MB" - - for item in never: - item["size_display"] = fmt(item["size"]) - - return jsonify({"status": "ok", "items": never[:30]}) - except Exception as e: - return jsonify({"status": "error", "error": str(e)}), 500 + """Sonarr shows that have never been played in Tautulli — see the + Admin section on P105 (`get_admin_unwatched_shows`), which this + delegates to rather than duplicating the match/sort logic.""" + months = request.args.get("months", default=ADMIN_UNWATCHED_MONTHS, type=int) + limit = request.args.get("limit", default=30, type=int) + result = get_admin_unwatched_shows(limit=limit, months=months) + if result.get("status") != "ok": + return jsonify(result), 500 + return jsonify(result) @app.route("/api/delete/movie", methods=["POST"]) @@ -1730,6 +2048,8 @@ def api_delete_movie(): headers=headers, timeout=15 ) resp.raise_for_status() + _radarr_cache["data"] = None + _radarr_cache["expires"] = 0 return jsonify({"success": True}) except Exception as e: return jsonify({"error": str(e)}), 500 @@ -1749,6 +2069,8 @@ def api_delete_show(): headers=headers, timeout=15 ) resp.raise_for_status() + _sonarr_cache["data"] = None + _sonarr_cache["expires"] = 0 return jsonify({"success": True}) except Exception as e: return jsonify({"error": str(e)}), 500 @@ -1758,7 +2080,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/base.css b/static/css/base.css index 634ca04..69780b8 100644 --- a/static/css/base.css +++ b/static/css/base.css @@ -61,6 +61,14 @@ --tt-cyan: #00ffff; --tt-white: #ffffff; + /* A deliberate, acknowledged ninth: the fastext nav has eight + destinations and only five of the locked eight read as legible + text on black once red/green/yellow/cyan/magenta/blue are already + spoken for (Activity ran out — a dithered swatch alone read as + unfinished, and dithering the word itself was tried and found + illegible). Not "one of the eight" — used nowhere else. */ + --tt-orange: #ff8000; + /* ── Roles ───────────────────────────────────────────── Each colour means one thing across all nine pages. */ --ink: var(--tt-white); /* body text */ diff --git a/static/css/teletext.css b/static/css/teletext.css index ad36959..28c4a16 100644 --- a/static/css/teletext.css +++ b/static/css/teletext.css @@ -8,6 +8,26 @@ RULER (every bar is --bar-cells wide, no exceptions). ═══════════════════════════════════════════════════════ */ +/* ── Skip-link ────────────────────────────────────────── + Off-screen until it receives focus, then it's a plain field row + like any other — a solid colour ground, no border — sitting above + everything else so tabbing past it reaches the header next. */ +.skip-link { + position: absolute; + top: -999px; + left: 0; + z-index: 1; + padding: 0.5em 1ch; + background: var(--field); + color: var(--ink); + text-transform: uppercase; + letter-spacing: 0.16em; +} +.skip-link:focus { + top: 0; + outline: none; +} + /* ── Header row ───────────────────────────────────────── Teletext's top line, unchanged for forty years: the page number you are on, who is transmitting, and the clock. It @@ -33,7 +53,6 @@ text-transform: uppercase; } .tt-head-clock { - margin-left: auto; color: var(--live); white-space: nowrap; min-width: 0; @@ -45,6 +64,16 @@ letter-spacing: 0.3em; } .tt-entry[hidden] { display: none; } +/* SABnzbd's queue, live on every page — filled in by service.js, empty + (and so invisible) until its first poll returns. Quiet ink when + idle; the live colour only while something is actually moving. */ +.tt-head-dl { + margin-left: auto; + color: var(--ink-dim); + white-space: nowrap; + min-width: 0; +} +.tt-head-dl.live { color: var(--live); } /* ── Page ─────────────────────────────────────────────── One measure for every page in the service. Nothing is ever @@ -158,6 +187,10 @@ a.tt-row:focus-visible .tt-row-sub { color: var(--tt-black); } text-transform: uppercase; min-width: 11ch; } +/* Marks a row that leaves the app (System's Transmission links to each + service's own UI) — everywhere else a .tt-row stays inside Media + Centre, so leaving needs its own quiet signal. */ +.tt-row-ext { color: var(--ink-dim); } .tt-row-body { flex: 1; min-width: 0; } .tt-row-title { overflow-wrap: anywhere; @@ -172,6 +205,13 @@ a.tt-row:focus-visible .tt-row-sub { color: var(--tt-black); } color: var(--live); white-space: nowrap; } +/* The verdicts sit after the status they resolve, so the row reads + state-then-decision. Only pending rows carry one. */ +.tt-row-acts { + flex: 0 0 auto; + display: flex; + gap: 1ch; +} .tt-row-val.ok { color: var(--ok); } .tt-row-val.alert { color: var(--alert); } .tt-row-val.warn { color: var(--voice); } @@ -396,6 +436,24 @@ a.tt-row:focus-visible .tt-row-sub { color: var(--tt-black); } .tt-btn-quiet:hover:not(:disabled) { background: var(--live); color: var(--tt-black); } .tt-btn-sm { min-height: calc(var(--row) * 1.6); width: 100%; } +/* The two verdicts on a pending request (P104). Quiet until armed, then + each reverses into its own meaning rather than the shared red of + .tt-btn.armed — green is the go-ahead, red is the refusal — so the + confirming press says which verdict it is about to send and not merely + that something is live. Both come after .tt-btn.armed to win on + source order at equal specificity. */ +.tt-btn-yes, .tt-btn-no { + background: transparent; + padding: 0 1ch; + letter-spacing: 0.12em; +} +.tt-btn-yes { color: var(--ok); } +.tt-btn-no { color: var(--alert); } +.tt-btn-yes:hover:not(:disabled), +.tt-btn-yes.armed { background: var(--ok); color: var(--tt-black); } +.tt-btn-no:hover:not(:disabled), +.tt-btn-no.armed { background: var(--alert); color: var(--tt-black); } + .tt-input { min-height: var(--tap); padding: 0 1ch; @@ -465,10 +523,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,7 +540,10 @@ a.tt-row:focus-visible .tt-row-sub { color: var(--tt-black); } padding: 0 var(--gutter); gap: 1ch; } -.fastext a { +.fastext-top { + margin: var(--row) auto calc(var(--row) * 1.5); +} +.fastext a, .fastext-here { display: flex; align-items: center; min-width: 0; @@ -491,24 +557,39 @@ a.tt-row:focus-visible .tt-row-sub { color: var(--tt-black); } overflow: hidden; text-overflow: ellipsis; } -/* The key's colour is carried by a solid cell at its head — the - physical coloured button on the remote. */ -.fastext a > span { +/* The key's identity is carried by a cell at its head — the physical + coloured button on the remote. */ +.fastext a > span, .fastext-here > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; } -.fastext a::before { +.fastext a::before, .fastext-here::before { content: ""; flex: 0 0 auto; width: 1.2ch; height: 1em; background: currentColor; } -.fastext .k-red { color: var(--alert); } -.fastext .k-green { color: var(--ok); } -.fastext .k-yellow { color: var(--voice); } -.fastext .k-cyan { color: var(--live); } +/* The page you're already on: present for orientation, same shape as + its neighbours, but not a link — clicking your own destination did + nothing useful, so now it isn't offered as a click at all. */ +.fastext-here { + color: var(--ink-dim); + cursor: default; +} +.fastext .k-red { color: var(--alert); } +.fastext .k-green { color: var(--ok); } +.fastext .k-yellow { color: var(--voice); } +.fastext .k-cyan { color: var(--live); } +.fastext .k-magenta { color: var(--pending); } +/* Pure blue on black is ~2.4:1 contrast — well under the 4.5:1 every + other body text in this app clears — but the word carries the + colour like every other key here, at the visitor's request. */ +.fastext .k-blue { color: var(--field); } +/* --tt-orange is a genuine ninth colour, not one of the locked eight — + see its definition in base.css for why the palette ran out here. */ +.fastext .k-orange { color: var(--tt-orange); } .fastext a:hover, .fastext a:focus-visible { background: currentColor; outline: none; @@ -593,7 +674,18 @@ a.tt-row:focus-visible .tt-row-sub { color: var(--tt-black); } } .tt-row-title { white-space: normal; } .tt-row-val { margin-left: auto; white-space: normal; } + /* Name and status hold line one, the description takes line two, and + the verdicts take a full line of their own — at this width they + cannot share a line with the status without either one shrinking + below the tap target. */ + .tt-row-acts { + flex: 1 0 100%; + order: 4; + justify-content: flex-end; + margin-top: 0.35em; + } .tt-head-name { letter-spacing: 0.12em; } + .tt-head-dl { margin-left: 0; flex-basis: 100%; } .tt-head-clock { margin-left: 0; flex-basis: 100%; } /* The System disk table swaps for its row reflow here — see the diff --git a/static/js/service.js b/static/js/service.js index d95f0b2..7110945 100644 --- a/static/js/service.js +++ b/static/js/service.js @@ -109,7 +109,7 @@ function disarm() { if (!armed) return; armed.classList.remove('armed'); - armed.textContent = armed.dataset.label || '+ ADD'; + if (armed.dataset.label) armed.textContent = armed.dataset.label; armed = null; } window.armAdd = function (btn) { @@ -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 { @@ -152,22 +152,195 @@ btn.textContent = 'CONFIRM?'; armed = btn; }; + /* ── Request verdicts (P104) ────────────────────────── + Approve and Deny share the two-tap arming above — and share its + `armed` slot, so arming one disarms the other rather than leaving + two live verdicts on the same row. An approval starts a download + and a denial is somebody's answer; neither wants a stray tap. + + The page keeps its own count honest after a decision. Leaving the + Pending figure at its server-rendered value would have the page + contradicting a row the visitor just resolved. */ + function settlePending() { + var fig = document.getElementById('req-pending'); + var meta = document.getElementById('req-pending-meta'); + var field = document.getElementById('req-field'); + if (!fig) return; + var n = parseInt(fig.textContent, 10); + if (isNaN(n) || n <= 0) return; + n -= 1; + fig.textContent = n; + if (meta) meta.textContent = n; + if (n === 0) { + // Nothing waits on a human any more, so the page stops being heavy. + fig.classList.remove('pending'); + fig.classList.add('ok'); + if (field) field.classList.remove('warn'); + } + } + + window.armDecision = function (btn) { + if (btn.disabled) return; + if (armed && armed !== btn) disarm(); + + if (armed === btn) { + var acts = btn.parentNode; + var row = btn.closest('.tt-row'); + var verdict = btn.dataset.verdict; + var sibs = acts.querySelectorAll('button'); + var i; + for (i = 0; i < sibs.length; i++) { sibs[i].disabled = true; } + btn.classList.remove('armed'); + btn.textContent = 'SENDING'; + armed = null; + + fetch(BASE + '/api/ombi/decide', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: btn.dataset.id, + type: btn.dataset.type, + verdict: verdict + }) + }) + .then(function (r) { return r.json(); }) + .then(function (d) { + if (d && d.success) { + var val = row && row.querySelector('.tt-row-val'); + if (val) { + val.className = 'tt-row-val ' + (verdict === 'approve' ? 'ok' : 'alert'); + val.textContent = (d.status || verdict).toUpperCase(); + } + // The row is settled: the verdicts have nothing left to offer. + if (acts.parentNode) acts.parentNode.removeChild(acts); + settlePending(); + } else { + for (var j = 0; j < sibs.length; j++) { sibs[j].disabled = false; } + btn.textContent = 'FAILED'; + } + }) + .catch(function () { + for (var k = 0; k < sibs.length; k++) { sibs[k].disabled = false; } + btn.textContent = 'FAILED'; + }); + return; + } + + // Captured once, from the server-rendered text — a retry after a + // FAILED must still disarm back to "Approve", not to "FAILED". + if (!btn.dataset.label) btn.dataset.label = btn.textContent.trim(); + btn.classList.add('armed'); + btn.textContent = 'CONFIRM?'; + armed = btn; + }; + + /* ── Admin: delete unwatched (P105) ─────────────────── + Removing a series from Sonarr with its files is permanent, + so it shares the same two-tap arming as everything else here + rather than a native confirm(). Each row carries its own byte + count, so the reclaimable total in the field meta can be + recomputed client-side after a delete instead of going stale + until the next page load. */ + function fmtBytes(b) { + if (b >= 1073741824) return (b / 1073741824).toFixed(1) + ' GB'; + return (b / 1048576).toFixed(0) + ' MB'; + } + + function settleAdmin() { + var list = document.getElementById('admin-rows'); + var meta = document.getElementById('admin-meta'); + var rows = list ? list.querySelectorAll('.tt-row') : []; + var total = 0; + for (var i = 0; i < rows.length; i++) total += parseInt(rows[i].dataset.bytes, 10) || 0; + if (meta) meta.textContent = rows.length + ' unwatched · ' + fmtBytes(total) + ' reclaimable'; + if (rows.length === 0 && list) { + var empty = document.createElement('p'); + empty.className = 'tt-empty'; + empty.id = 'admin-empty'; + empty.textContent = 'Nothing left to reclaim.'; + list.parentNode.replaceChild(empty, list); + } + } + + window.armDelete = function (btn) { + if (btn.disabled) return; + if (armed && armed !== btn) disarm(); + + if (armed === btn) { + var row = btn.closest('.tt-row'); + btn.disabled = true; + btn.classList.remove('armed'); + btn.textContent = 'DELETING'; + armed = null; + + fetch(BASE + '/api/delete/show', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sonarr_id: btn.dataset.id }) + }) + .then(function (r) { return r.json(); }) + .then(function (d) { + if (d && d.success) { + if (row && row.parentNode) row.parentNode.removeChild(row); + settleAdmin(); + } else { + btn.disabled = false; + btn.classList.add('armed'); + btn.textContent = 'FAILED'; + } + }) + .catch(function () { + btn.disabled = false; + btn.classList.add('armed'); + btn.textContent = 'FAILED'; + }); + return; + } + + btn.dataset.label = btn.textContent.trim(); + btn.classList.add('armed'); + btn.textContent = 'CONFIRM?'; + armed = btn; + }; + document.addEventListener('click', function (e) { if (armed && !armed.contains(e.target)) disarm(); }); + /* ── Array refresh (P105) ───────────────────────────── + Unraid's stats are fetched live already, never cached server-side + (see get_unraid_stats), so this button doesn't bypass a cache — + it exists so the section can be re-fetched without a full page + reload, for the visitor's own peace of mind that it's current. */ + window.refreshArray = function (btn) { + if (btn.disabled) return; + btn.disabled = true; + btn.textContent = 'REFRESHING'; + fetch(BASE + '/api/array/refresh') + .then(function (r) { return r.text(); }) + .then(function (html) { + var section = document.getElementById('array-section'); + if (section) section.innerHTML = html; + }) + .catch(function () { + btn.disabled = false; + btn.textContent = 'FAILED'; + }); + }; + /* ── Live figures ───────────────────────────────────── The queue and the active streams are the only two numbers that must be accurate to the second, so they are the only two that poll. Everything else is cached server-side and - arrives with the page. */ + arrives with the page. The queue also drives the header's + download row, present on every page, so it polls + unconditionally rather than only where #sab-queue exists. */ function setText(id, value) { var el = document.getElementById(id); if (el) el.textContent = value; } function pollSab() { - if (!document.getElementById('sab-queue')) return; fetch(BASE + '/api/sabnzbd') .then(function (r) { return r.json(); }) .then(function (d) { @@ -175,6 +348,14 @@ setText('sab-queue', d.queue_count); setText('sab-speed', d.speed || '0'); setText('sab-remaining', d.sizeleft || '—'); + var dl = document.getElementById('head-dl'); + if (dl) { + var active = d.queue_count > 0; + dl.textContent = active + ? d.queue_count + ' IN QUEUE · ' + (d.speed || '0') + : '0 IN QUEUE'; + dl.classList.toggle('live', active); + } }) .catch(function () { /* offline is the dev baseline, not an error */ }); } @@ -190,10 +371,8 @@ .catch(function () { /* as above */ }); } - if (document.getElementById('sab-queue')) { - pollSab(); - setInterval(pollSab, 5000); - } + pollSab(); + setInterval(pollSab, 5000); if (document.getElementById('tt-streams')) { pollTautulli(); setInterval(pollTautulli, 15000); diff --git a/templates/_array_section.html b/templates/_array_section.html new file mode 100644 index 0000000..6e7d164 --- /dev/null +++ b/templates/_array_section.html @@ -0,0 +1,100 @@ +{% from "_macros.html" import service_error, bar, disk_state, disk_row %} + +{#- ── Array ───────────────────────────────────────────────────── + Its own partial (rather than inline in system.html) so the + refresh button can re-fetch and swap in just this section via + `/api/array/refresh` without a full page reload — Unraid's stats + aren't cached (see get_unraid_stats), so a stale reading here + only ever means "haven't reloaded the page since", and the button + exists purely for the peace-of-mind of confirming that in place. -#} +{% if unraid.status != 'ok' %} +
+ Array + unreachable + + +
+ {{ service_error('Unraid', unraid.error) }} +{% else %} + {%- set apct = unraid.array_used_pct | default(0) | float -%} +
+ Array + + {{ unraid.array_state | default('') }} + {%- if unraid.parities %} · parity + {%- for p in unraid.parities %} {{ p.temp if p.temp is not none else '—' }}°C{% endfor %} + {%- endif %} + + +
+ +
+
+
{{ apct | round | int }}%
+
Used
+
+
+
{{ unraid.array_free | default('—') }}
+
Free
+
+
+
{{ unraid.array_total | default('—') }}
+
Total
+
+
+
{{ unraid.disk_count | default(0) }}
+
Disks
+
+
+ + {% if unraid.disks %} + {#- Desktop: a real table — columns line up, a new metric is a new + column. Hidden below 560px, where six columns stop fitting and + a horizontal scroll would hide the bar and the temperature, + the two readings that matter most on this page. -#} +
+ + + + + + + + + + + + + {% for dk in unraid.disks %} + + + + + + + + + {% endfor %} + {% for ck in unraid.caches | default([]) %} + + + + + + + + + {% endfor %} + +
DiskDeviceSizeFreeUsageTemp
{{ dk.name }}{{ dk.device | default('—') }}{{ dk.total | default('—') }}{{ dk.free | default('—') }}{{ bar(dk.used_pct | default(0)) }} {{ dk.used_pct | default(0) | round | int }}%{{ disk_state(dk) }}
{{ ck.name }}{{ ck.device | default('—') }}{{ ck.total | default('—') }}{{ ck.free | default('—') }}{{ bar(ck.used_pct | default(0)) }} {{ ck.used_pct | default(0) | round | int }}%{{ disk_state(ck) }}
+
+ + {#- Phone (≤560px): the .tt-row-derived reflow — name, the shared + 20-cell bar and the temperature on one line, device/size/free + beneath. Hidden above 560px in favour of the table. -#} +
+ {% for dk in unraid.disks %}{{ disk_row(dk) }}{% endfor %} + {% for ck in unraid.caches | default([]) %}{{ disk_row(ck) }}{% endfor %} +
+ {% endif %} +{% endif %} diff --git a/templates/_shell.html b/templates/_shell.html index 6970434..50c21c3 100644 --- a/templates/_shell.html +++ b/templates/_shell.html @@ -17,6 +17,12 @@ +{#- Skip-link: every page but the index now opens with a full nav + block before any real content — a keyboard or screen-reader visitor + would otherwise tab through all eight destinations on every single + visit. Off-screen until focused, then it's the first thing tabbing + into the page reaches. -#} +