Compare commits

...
Sign in to create a new pull request.

19 commits

Author SHA1 Message Date
Barely Removable
0de473cdc0 Bump version for the Array refresh button and Admin row detail commits
All checks were successful
Deploy / deploy (push) Successful in 26s
The last three commits (Admin download-date/requester detail, Array
refresh button) landed without a version bump, so the footer couldn't
distinguish "deployed" from "not yet deployed" for them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPJxtE9tPURsaWDZ6rC4Dt
2026-09-05 10:02:13 -07:00
Barely Removable
2dedd4e915 Add a manual refresh button to the Array section on System
Some checks failed
Deploy / deploy (push) Has been cancelled
Unraid's stats were already fetched live on every page load (no
server-side cache), so this doesn't bypass anything — it just lets the
section be re-fetched without a full page reload, for confirming it's
current without waiting on a reload.

Array section moves into its own partial (_array_section.html) so a new
/api/array/refresh route can re-render just that fragment and swap it
into #array-section, rather than duplicating the table/bar/disk-row
markup in JS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPJxtE9tPURsaWDZ6rC4Dt
2026-09-05 10:01:51 -07:00
Barely Removable
106bd16de8 Show who requested each show on Admin rows
All checks were successful
Deploy / deploy (push) Successful in 39s
Sonarr has no concept of a requester, so this cross-references Ombi's
TV request list by title to attribute the show to whoever's request
triggered the grab, falling back to "Requester unknown" for anything
added straight in Sonarr or when Ombi can't be reached.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPJxtE9tPURsaWDZ6rC4Dt
2026-09-05 09:55:02 -07:00
Barely Removable
6d093a25d7 Show download date and watch status on Admin rows
All checks were successful
Deploy / deploy (push) Successful in 33s
Surfaces the download-age cutoff data (already computed for the 6-month
filter) directly on each row, plus an explicit "Never watched" label, so
the never-watched call behind the delete button is visible rather than
just trusted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPJxtE9tPURsaWDZ6rC4Dt
2026-09-05 09:38:53 -07:00
Barely Removable
cad4cd0a84 Add Admin section to System: reclaim space from unwatched shows
All checks were successful
Deploy / deploy (push) Successful in 48s
Cross-references Sonarr's per-series disk usage against Tautulli's full
play history to surface the 10 largest shows nobody has ever watched,
excluding anything added in the last 6 months so new downloads aren't
flagged before they've had a chance to be watched. Delete removes the
series from Sonarr along with its episode files and folder, behind the
site's usual two-tap arm/confirm rather than a native dialog.

Also dedupes the never-wired /api/tautulli/never-watched endpoint onto
the same helper, and clears the Sonarr/Radarr list caches on delete so
a removed title doesn't linger in other cards until the TTL expires.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPJxtE9tPURsaWDZ6rC4Dt
2026-09-05 09:32:45 -07:00
Barely Removable
d38ab1b561 Add Approve/Deny verdicts to pending requests on P104
All checks were successful
Deploy / deploy (push) Successful in 55s
Every TV request now needs a decision, so P104 offers the two verdicts
inline on the rows actually waiting rather than sending the visitor to
Ombi's own UI.

Ombi is now the one service the app writes to. Three details the new
ombi_write() helper and /api/ombi/decide route have to get right:
approve is a POST but deny is a PUT; a TV decision acts on the child
request id, not the show, so get_ombi_summary() carries child["id"] for
TV rows and m["id"] for movies; and Ombi answers 200 with
{"result": false, ...} on a logical refusal, so the body is checked
rather than the status code. A successful decision clears _ombi_cache —
the 4-hour TTL would otherwise keep calling the row pending for the
rest of the afternoon.

The buttons reuse the existing two-tap arming and share its armed slot,
so arming one disarms the other: an approval starts a download and a
denial is somebody's answer, neither wants a stray tap. They reverse
into green and red rather than the shared red of .tt-btn.armed, so the
confirming press states which verdict it is about to send. After a
decision the row's status flips in place and the Pending figure and its
"N of M" both settle; at zero the page drops its warn weight, since
nothing waits on a human any more (discipline 5). Below 560px the
verdicts take a full line of their own rather than shrinking below the
tap target.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YNc3gd3GRPudFwgPXNEm8A
2026-09-02 09:05:53 -07:00
Barely Removable
8115ed4855 Add active-viewer list to Watch with per-user 48h history drill-down
All checks were successful
Deploy / deploy (push) Successful in 28s
Lists anyone who's watched something in the last 10 days; clicking a
name shows their play history for the last 48 hours.

Bump to v2.1.0 per the just-added CLAUDE.md rule that every commit
should be matched with a version change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017kn4ZaFd6RCnbMewMyMsfe
2026-08-31 07:56:18 -07:00
Barely Removable
cf2b5acd3e Document that every commit should bump APP_VERSION
Keeps the footer version string a trustworthy signal that a deploy
actually landed, per deploy-arr-summary verification steps.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017kn4ZaFd6RCnbMewMyMsfe
2026-08-31 07:45:42 -07:00
Barely Removable
517559a460 Give Activity a genuine ninth colour: --tt-orange
All checks were successful
Deploy / deploy (push) Successful in 24s
Requested directly: "can we make activity orange". The locked eight
palette had already run out at six legible text colours (Watch's blue
was the last one, and it's already a contrast exception); a dithered
swatch alone read as unfinished, and dithering the word itself
rendered as illegible static — tried and reverted this session.

--tt-orange (#ff8000) is defined in base.css right after the locked
eight, clearly marked as a deliberate, acknowledged one-off exception
to "eight colours, no ninth" rather than a secretly-smuggled-in tint.
Same flat text+swatch treatment as every other fastext key. Updated
CLAUDE.md's discipline 1 and DESIGN.md's Locked Eight Rule to name the
exception explicitly, so it reads as a documented decision next time
someone audits the palette, not a bug.
2026-08-30 09:52:19 -07:00
Barely Removable
9fba4f210e Colour Watch's fastext label, not just its swatch
All checks were successful
Deploy / deploy (push) Successful in 22s
Swatch-only blue read as unfinished next to the other five fully-
coloured entries. The word now carries the colour like every other
key, same currentColor mechanism as the rest — accepting blue's
~2.4:1 contrast on black as a known, deliberate exception to the
4.5:1 bar everything else here clears.
2026-08-30 09:27:39 -07:00
Barely Removable
5dd49c505c Give every fastext destination a distinct mark; add skip-link, fix aria-label
All checks were successful
Deploy / deploy (push) Successful in 23s
Colours: "if the intent is to have them all different colors than
lets have them all different colors." The locked eight only give five
safe as full text (blue fails contrast, black's invisible on black),
so the other three get their own treatments instead of sharing plain
ink: Watch gets blue as a swatch-only accent (the button cap's colour,
not the label), Activity gets an ordered-dither swatch (the other
legal way to mark something without a ninth colour), and Settings
alone keeps plain ink deliberately, as the one page with no live
status of its own.

Accessibility (items 1 and 3 from the design review, item 2 skipped
per request — the header's download figure stays live and unmuted):
- Added a skip-link, off-screen until focused, jumping past the
  8-item top nav straight to each page's #main-content.
- Renamed the nav's aria-label from "Fastext" (meaningless without
  the visual teletext context) to "Page navigation".
2026-08-30 09:24:52 -07:00
Barely Removable
15936027de Revert SABnzbd poll throttling — keep the header figure live everywhere
All checks were successful
Deploy / deploy (push) Successful in 22s
User wants live-updating, not slower polling off System/Activity.
Back to a flat 5s interval on every page.
2026-08-30 09:18:26 -07:00
Barely Removable
8500fba745 Give Requests its own colour instead of leaving 4 fastext entries plain
All checks were successful
Deploy / deploy (push) Successful in 21s
Plain ink for Watch/Requests/Activity/Settings read as flat and
unfinished, not deliberate — user feedback: "the new colors are
awful and basically the same color." Requests gets magenta
(--pending) back: not an arbitrary reuse, it's the same colour a
request already carries once approved elsewhere in the app, and
Requests genuinely is "waiting on a human." That's the real ceiling
though — the locked eight teletext colours leave exactly five legible
as text on black (blue fails contrast, black is invisible on black),
so Watch/Activity/Settings still share plain ink rather than a sixth
colour that doesn't exist in this palette.
2026-08-30 09:17:37 -07:00
Barely Removable
59beb9490f Fix design-review findings: self-linking nav, colour reuse, badge clash
All checks were successful
Deploy / deploy (push) Successful in 24s
From a frontend-design skill review of all pages, after last session's
chrome changes (top nav, all-tabs nav, header download row):

- The top/closing fastext nav now mutes the entry matching the current
  page (.fastext-here) instead of rendering a link to the page you're
  already on.
- Colour in fastext is reserved for the four entries that still carry
  a real keyboard shortcut (r/g/y/c); the other four render in plain
  ink instead of reusing k-red/k-green/k-yellow/k-cyan for a
  destination they don't semantically mean (e.g. Requests no longer
  shows in the app's own off-air red).
- Discover's badge slot meant "critic score" on its RT tiles and
  "release year" on its new search tiles at the same time. Search
  results (Library and Discover) now fold year/held-state into the
  sub-line instead, leaving the badge exclusively for critic score.
- System's Transmission rows now carry a small ↗ glyph, since they
  silently changed from in-app navigation to opening each service's
  own UI in a new tab with no visual cue.
- SABnzbd polling drops from 5s to 20s on pages that only show the
  header's download figure (everywhere but System/Activity, which
  keep 5s for their own live figures) — a number idle 95% of the time
  doesn't need second-level freshness sitewide.
- DESIGN.md's fastext description, now-current per this session's nav
  changes.
2026-08-30 09:11:59 -07:00
Barely Removable
d672c3f02d Show SABnzbd's queue in the header on every page
All checks were successful
Deploy / deploy (push) Successful in 21s
Adds a live download figure next to the header clock, present on
every page (not just Activity/System). It's populated by the same
/api/sabnzbd poll those pages already use, extended to run
unconditionally and to fill in a new #head-dl span — never a
server-rendered fetch, so an unreachable SABnzbd can't add latency to
pages that don't otherwise touch it. Quiet ink at "0 IN QUEUE" when
idle, live cyan with a speed reading once something's moving.
2026-08-30 08:54:41 -07:00
Barely Removable
cef66e7361 Search Sonarr/Radarr directly on Discover, full top nav, live System links
All checks were successful
Deploy / deploy (push) Successful in 24s
- Discover's search now hits Radarr's/Sonarr's own lookup endpoints
  directly (the same ones /add-to-radarr and /add-to-sonarr already
  use), instead of Ombi — one fewer round trip, and a result always
  resolves the same way its own Add button will. The Ombi-based
  ombi_search/_tmdb_poster helpers are gone.
- The top nav on every page but the index now lists all eight
  destinations (FULL_FASTEXT) instead of the original four; the index
  keeps the original four-entry row as its own closing line
  (HOME_FASTEXT), since its body already lists every destination once.
- System page's Transmission rows are now links to each service's own
  reverse-proxied instance (service_urls, already injected into every
  template but unused until now).
2026-08-30 08:40:52 -07:00
Barely Removable
abaccaaacd Move fastext nav to page top, fix Settings header, add working search
All checks were successful
Deploy / deploy (push) Successful in 23s
- 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.
2026-08-30 08:26:47 -07:00
Barely Removable
08491c0c9b 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
2026-08-30 08:05:49 -07:00
Barely Removable
bb3e9af056 Merge meridian-marquee: teletext redesign of Media Centre (v2.0.0)
All checks were successful
Deploy / deploy (push) Successful in 30s
Replaces the neutral dark card-based dashboard with "Page 100" — a
broadcast information service in the teletext tradition. Full detail
in 4b19cbd (the redesign) and bc7779e (the finish-review fix round).

Shipped with the user's explicit sign-off after two finish-review
rounds; known accepted gaps (fold proportion, two minor regressions,
one deferred discipline, a stale DESIGN.md section) are logged in
project memory rather than left undocumented.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBw1QodG8ZMYHrGeQfqyDS
2026-08-29 21:24:09 -07:00
18 changed files with 1195 additions and 336 deletions

View file

@ -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:*)"
]
}
}

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.

174
CLAUDE.md
View file

@ -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
`<body data-pages>`.
- **Fastext leads everywhere but the index, and closes the index.**
`FULL_FASTEXT` in `app.py` lists every destination and renders right
under the header on every page but the index — those pages carry no
other nav, so it needs no scroll to reach. The index overrides it
with `HOME_FASTEXT`, the original four-entry row, as its own closing
line, since its body already lists every destination once. Every
destination gets its own distinct flat colour, text and swatch
both: Index/Library/Discover/System keep their `r`/`g`/`y`/`c`
shortcut; Requests gets magenta/`--pending` with no shortcut (a
deliberate fit — Requests already is "waiting on a human"
everywhere else magenta appears); Watch gets blue (`.k-blue`) with
no shortcut. Blue is a known exception to the app's own 4.5:1
text-contrast bar (it clears ~2.4:1 on black) — kept anyway, at the
visitor's request, over the alternative of a swatch-only accent
that read as unfinished next to its fully-coloured neighbours.
Activity gets `--tt-orange` (`.k-orange`) — a genuine ninth colour,
the one destination that breaks "eight colours, no ninth"
outright, defined and explained in `base.css` right after the
locked eight. It's there because the palette ran out at six
(dithering a swatch alone read as unfinished, and dithering the
word itself renders illegible) and the visitor wanted every
destination to look different, not merely as different as eight
colours allow. Settings alone keeps plain ink — deliberately, as
the one page with no live status of its own. Whichever entry
matches the current page renders as a `.fastext-here` span instead
of a link — a page linking to itself did nothing, so it isn't
offered as a click.
- **Nothing is clipped, hidden, or sticky.** All seven page numbers and
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<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.
**Every commit should be matched with a version change.** Bump
`APP_VERSION` / `APP_VERSION_DATE` near the top of `app.py` as part of
the same commit. This is what makes the footer (`v<APP_VERSION> ·
<APP_VERSION_DATE>`) a trustworthy signal that a given deploy actually
landed — a commit without a version bump can't be distinguished from
"not deployed yet" by looking at the live site.
## Logins
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.
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/`

View file

@ -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.

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
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 —

468
app.py
View file

@ -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,

View file

@ -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 */

View file

@ -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

View file

@ -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);

View file

@ -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' %}
<div class="tt-field crit">
<span>Array</span>
<span class="tt-field-meta">unreachable
<button class="tt-btn-quiet" type="button" id="array-refresh-btn" onclick="refreshArray(this)" aria-label="Refresh array stats">REFRESH</button>
</span>
</div>
{{ service_error('Unraid', unraid.error) }}
{% else %}
{%- set apct = unraid.array_used_pct | default(0) | float -%}
<div class="tt-field {% if apct >= 90 %}crit{% elif apct >= 75 %}warn{% endif %}">
<span>Array</span>
<span class="tt-field-meta">
{{ unraid.array_state | default('') }}
{%- if unraid.parities %} &middot; parity
{%- for p in unraid.parities %} {{ p.temp if p.temp is not none else '—' }}&deg;C{% endfor %}
{%- endif %}
<button class="tt-btn-quiet" type="button" id="array-refresh-btn" onclick="refreshArray(this)" aria-label="Refresh array stats">REFRESH</button>
</span>
</div>
<div class="tt-figs">
<div class="tt-fig">
<div class="tt-dh tt-fig-v {{ 'alert' if apct >= 90 else ('warn' if apct >= 75 else 'ok') }}">{{ apct | round | int }}%</div>
<div class="tt-fig-l">Used</div>
</div>
<div class="tt-fig">
<div class="tt-dh tt-fig-v">{{ unraid.array_free | default('—') }}</div>
<div class="tt-fig-l">Free</div>
</div>
<div class="tt-fig">
<div class="tt-dh tt-fig-v">{{ unraid.array_total | default('—') }}</div>
<div class="tt-fig-l">Total</div>
</div>
<div class="tt-fig">
<div class="tt-dh tt-fig-v">{{ unraid.disk_count | default(0) }}</div>
<div class="tt-fig-l">Disks</div>
</div>
</div>
{% 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. -#}
<div class="tt-table-scroll">
<table class="tt-table">
<thead>
<tr>
<th scope="col">Disk</th>
<th scope="col">Device</th>
<th scope="col" class="num">Size</th>
<th scope="col" class="num">Free</th>
<th scope="col">Usage</th>
<th scope="col" class="num">Temp</th>
</tr>
</thead>
<tbody>
{% for dk in unraid.disks %}
<tr>
<th scope="row">{{ dk.name }}</th>
<td>{{ dk.device | default('—') }}</td>
<td class="num">{{ dk.total | default('—') }}</td>
<td class="num">{{ dk.free | default('—') }}</td>
<td>{{ bar(dk.used_pct | default(0)) }} <span class="val">{{ dk.used_pct | default(0) | round | int }}%</span></td>
<td class="num">{{ disk_state(dk) }}</td>
</tr>
{% endfor %}
{% for ck in unraid.caches | default([]) %}
<tr>
<th scope="row">{{ ck.name }}</th>
<td>{{ ck.device | default('—') }}</td>
<td class="num">{{ ck.total | default('—') }}</td>
<td class="num">{{ ck.free | default('—') }}</td>
<td>{{ bar(ck.used_pct | default(0)) }} <span class="val">{{ ck.used_pct | default(0) | round | int }}%</span></td>
<td class="num">{{ disk_state(ck) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{#- 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. -#}
<div class="tt-disk-rows">
{% for dk in unraid.disks %}{{ disk_row(dk) }}{% endfor %}
{% for ck in unraid.caches | default([]) %}{{ disk_row(ck) }}{% endfor %}
</div>
{% endif %}
{% endif %}

View file

@ -17,6 +17,12 @@
</head>
<body data-timezone="{{ timezone }}" data-url-base="{{ request.script_root }}"
data-pages='{{ nav_items | tojson }}'>
{#- 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. -#}
<a class="skip-link" href="#main-content">Skip to content</a>
<!--
THESIS: Media Centre is a broadcast information service, not a dashboard. It
refuses the bordered-card grid every self-hosted dashboard ships, this one
@ -42,22 +48,40 @@ provenance.
<span class="tt-head-num">P{{ here[1] }}</span>
<span class="tt-head-name">{{ here[2] }}</span>
<span class="tt-entry" id="page-entry" hidden aria-live="polite"></span>
{#- SABnzbd's queue, on every page — the same live /api/sabnzbd poll
Activity and System already use, not a server-rendered fetch, so
an unreachable SABnzbd never adds latency to a page that doesn't
otherwise need it. Starts blank and fills in within a second. -#}
<span class="tt-head-dl" id="head-dl" aria-live="polite"></span>
<span class="tt-head-clock" id="head-clock"></span>
</header>
<main class="tt-page">
{% block content %}{% endblock %}
</main>
{#- Fastext: the four coloured keys. Each page names its own four, so the
keys always point somewhere useful from where you are. -#}
<nav class="fastext" aria-label="Fastext">
keys always point somewhere useful from where you are. On the index
it stays the closing row, under Now Showing/Just Added, since the
index's own body already is the full nav. Everywhere else it leads,
right under the header, since it's the only nav those pages carry. -#}
{% set fastext_nav %}
<nav class="fastext{{ '' if page == 'home' else ' fastext-top' }}" aria-label="Page navigation">
{% for cls, key, num, label, href in fastext %}
{% if num == here[1] %}
<span class="fastext-here" aria-current="page"><span>{{ num }} {{ label }}</span></span>
{% else %}
<a class="{{ cls }}" href="{{ request.script_root }}{{ href }}" data-key="{{ key }}">
<span>{{ num }} {{ label }}</span>
</a>
{% endif %}
{% endfor %}
</nav>
{% endset %}
{% if page != 'home' %}{{ fastext_nav }}{% endif %}
<main class="tt-page" id="main-content">
{% block content %}{% endblock %}
</main>
{% if page == 'home' %}{{ fastext_nav }}{% endif %}
<footer class="tt-foot">
<span>MEDIA CENTRE</span>

View file

@ -1,14 +1,48 @@
{% extends "_shell.html" %}
{% from "_macros.html" import tile %}
{% from "_macros.html" import tile, service_error %}
{% block title %}P103 Discover{% endblock %}
{% block content %}
<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>
{#- Search sits at the top for the same reason it does on Library: a
title in mind is the common case. This one hits Radarr's and
Sonarr's own lookup directly — the same call /add-to-radarr and
/add-to-sonarr already make — so a result here always resolves the
same way a click on its own Add button will. -#}
<form class="tt-search" action="{{ request.script_root }}/discover" method="get" role="search">
<label class="visually-hidden" for="q">Search Sonarr and Radarr for a title to add</label>
<input class="tt-input" type="search" id="q" name="q" value="{{ q }}"
placeholder="SEARCH TO ADD" autocomplete="off" spellcheck="false">
<button class="tt-btn" type="submit">Search</button>
</form>
{% if search %}
<div class="tt-field">
<span>Results</span>
<span class="tt-field-meta">&ldquo;{{ q }}&rdquo;</span>
</div>
{% if search.status != 'ok' %}
{{ service_error('Sonarr/Radarr', search.error) }}
{% elif search.results %}
<div class="tt-tiles">
{#- No badge here: the badge slot means "critic score" below,
from the RT feed. A search result has no score, so its
year and held-state live in the sub-line instead of
overloading that slot with a second meaning. -#}
{% for it in search.results %}
{{ tile(it.title, it.kind ~ (' · ' ~ it.year if it.year else '') ~ (' · Held' if it.held else ''), it.poster, '', ('' if it.held else '+ Add'), it.target) }}
{% endfor %}
</div>
{% else %}
<p class="tt-empty">No matches for &ldquo;{{ q }}&rdquo;.</p>
{% endif %}
{% endif %}
<div class="tt-field">
<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>
{% if rt_movies %}
<div class="tt-tiles">
@ -17,12 +51,12 @@
{% endfor %}
</div>
{% 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 %}
<div class="tt-field">
<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>
{% if rt_tv %}
<div class="tt-tiles">

View file

@ -27,7 +27,7 @@
<a class="tt-row" href="{{ request.script_root }}/library">
<span class="tt-row-num">102</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' %}
<span class="tt-row-val">{{ sonarr.total_series | default(0) }} SHOWS &nbsp;{{ radarr.total_movies | default(0) }} FILMS</span>
{% else %}
@ -38,7 +38,7 @@
<a class="tt-row" href="{{ request.script_root }}/discover">
<span class="tt-row-num">103</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>
</a>

View file

@ -14,6 +14,22 @@
<button class="tt-btn" type="submit">Search</button>
</form>
{% if search_results is not none %}
<div class="tt-field">
<span>Results</span>
<span class="tt-field-meta">{{ search_results | length }} match{{ 'es' if search_results | length != 1 else '' }}</span>
</div>
{% if search_results %}
<div class="tt-tiles">
{% for it in search_results %}
{{ tile(it.title, it.kind ~ (' · ' ~ it.year if it.year else ''), it.poster) }}
{% endfor %}
</div>
{% else %}
<p class="tt-empty">No matches for &ldquo;{{ q }}&rdquo;.</p>
{% endif %}
{% endif %}
<div class="tt-field">
<span>Holdings</span>
<span class="tt-field-meta">
@ -55,7 +71,7 @@
{{ service_error('Radarr', radarr.error) }}
{% endif %}
{% if recently_added %}
{% if not q and recently_added %}
<div class="tt-field"><span>Newest In</span></div>
<div class="tt-tiles">
{% for it in recently_added %}

View file

@ -10,15 +10,17 @@
{{ service_error('Ombi', ombi.error) }}
{% else %}
{#- Pending requests are the only thing here that wants a human, so a
page holding any is set heavier than one holding none (discipline 5). -#}
<div class="tt-field {{ 'warn' if ombi.total_pending else '' }}">
page holding any is set heavier than one holding none (discipline 5).
The ids are what the verdict buttons write back to when a decision
empties the queue, so the page stops contradicting itself. -#}
<div class="tt-field {{ 'warn' if ombi.total_pending else '' }}" id="req-field">
<span>Awaiting A Decision</span>
<span class="tt-field-meta">{{ ombi.total_pending }} of {{ ombi.total_requests }}</span>
<span class="tt-field-meta"><span id="req-pending-meta">{{ ombi.total_pending }}</span> of {{ ombi.total_requests }}</span>
</div>
<div class="tt-figs">
<div class="tt-fig">
<div class="tt-dh tt-fig-v {{ 'pending' if ombi.total_pending else 'ok' }}">{{ ombi.total_pending }}</div>
<div class="tt-dh tt-fig-v {{ 'pending' if ombi.total_pending else 'ok' }}" id="req-pending">{{ ombi.total_pending }}</div>
<div class="tt-fig-l">Pending</div>
</div>
<div class="tt-fig">
@ -39,6 +41,21 @@
</span>
{%- set st = (r.status or '') | lower -%}
<span class="tt-row-val {% if st in ('available', 'approved') %}ok{% elif st == 'denied' %}alert{% elif st == 'pending' %}pending{% else %}off{% endif %}">{{ (r.status or '—') | upper }}</span>
{#- Magenta says a human must decide; these are the two things that
human can decide, offered only on the rows actually waiting.
Both take a confirming second press, the same two-tap the add
buttons use — an approval starts a download and a denial is
somebody's answer. -#}
{% if st == 'pending' and r.id %}
<span class="tt-row-acts">
<button class="tt-btn tt-btn-yes" data-id="{{ r.id }}" data-type="{{ (r.type or '') | lower }}"
data-verdict="approve" onclick="armDecision(this)"
aria-label="Approve {{ r.title }}">Approve</button>
<button class="tt-btn tt-btn-no" data-id="{{ r.id }}" data-type="{{ (r.type or '') | lower }}"
data-verdict="deny" onclick="armDecision(this)"
aria-label="Deny {{ r.title }}">Deny</button>
</span>
{% endif %}
</div>
{% endfor %}
</div>

View file

@ -1,5 +1,5 @@
{% extends "_shell.html" %}
{% from "_macros.html" import service_error, bar, disk_state, disk_row, trend %}
{% from "_macros.html" import service_error, bar, trend %}
{% block title %}P105 System{% endblock %}
{% block content %}
@ -7,107 +7,25 @@
{#- ── Service transmission status ───────────────────────────────
Six services, one row each, colour carrying up or down. This is
what the old header pills were, given room to say something. -#}
what the old header pills were, given room to say something. Each
row opens that service's own instance, via the reverse proxy. -#}
<div class="tt-field"><span>Transmission</span></div>
<div class="tt-rows">
{% for name, svc in [('Sonarr', sonarr), ('Radarr', radarr), ('SABnzbd', sabnzbd),
('Tautulli', tautulli), ('Prowlarr', prowlarr), ('Ombi', ombi)] %}
<div class="tt-row">
<span class="tt-row-name">{{ name }}</span>
{% for name, key, svc in [('Sonarr', 'sonarr', sonarr), ('Radarr', 'radarr', radarr), ('SABnzbd', 'sabnzbd', sabnzbd),
('Tautulli', 'tautulli', tautulli), ('Prowlarr', 'prowlarr', prowlarr), ('Ombi', 'ombi', ombi)] %}
<a class="tt-row" href="{{ service_urls[key] }}" target="_blank" rel="noopener">
<span class="tt-row-name">{{ name }} <span class="tt-row-ext" aria-hidden="true">&#8599;</span></span>
<span class="tt-row-body"></span>
<span class="tt-row-val {{ 'ok' if svc.status == 'ok' else 'alert' }}">
{{- 'ON AIR' if svc.status == 'ok' else 'OFF AIR' -}}
</span>
</div>
</a>
{% endfor %}
</div>
{#- ── Array ───────────────────────────────────────────────────── -#}
{% if unraid.status != 'ok' %}
<div class="tt-field crit"><span>Array</span><span class="tt-field-meta">unreachable</span></div>
{{ service_error('Unraid', unraid.error) }}
{% else %}
{%- set apct = unraid.array_used_pct | default(0) | float -%}
<div class="tt-field {% if apct >= 90 %}crit{% elif apct >= 75 %}warn{% endif %}">
<span>Array</span>
<span class="tt-field-meta">
{{ unraid.array_state | default('') }}
{%- if unraid.parities %} &middot; parity
{%- for p in unraid.parities %} {{ p.temp if p.temp is not none else '—' }}&deg;C{% endfor %}
{%- endif %}
</span>
</div>
<div class="tt-figs">
<div class="tt-fig">
<div class="tt-dh tt-fig-v {{ 'alert' if apct >= 90 else ('warn' if apct >= 75 else 'ok') }}">{{ apct | round | int }}%</div>
<div class="tt-fig-l">Used</div>
</div>
<div class="tt-fig">
<div class="tt-dh tt-fig-v">{{ unraid.array_free | default('—') }}</div>
<div class="tt-fig-l">Free</div>
</div>
<div class="tt-fig">
<div class="tt-dh tt-fig-v">{{ unraid.array_total | default('—') }}</div>
<div class="tt-fig-l">Total</div>
</div>
<div class="tt-fig">
<div class="tt-dh tt-fig-v">{{ unraid.disk_count | default(0) }}</div>
<div class="tt-fig-l">Disks</div>
</div>
</div>
{% 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. -#}
<div class="tt-table-scroll">
<table class="tt-table">
<thead>
<tr>
<th scope="col">Disk</th>
<th scope="col">Device</th>
<th scope="col" class="num">Size</th>
<th scope="col" class="num">Free</th>
<th scope="col">Usage</th>
<th scope="col" class="num">Temp</th>
</tr>
</thead>
<tbody>
{% for dk in unraid.disks %}
<tr>
<th scope="row">{{ dk.name }}</th>
<td>{{ dk.device | default('—') }}</td>
<td class="num">{{ dk.total | default('—') }}</td>
<td class="num">{{ dk.free | default('—') }}</td>
<td>{{ bar(dk.used_pct | default(0)) }} <span class="val">{{ dk.used_pct | default(0) | round | int }}%</span></td>
<td class="num">{{ disk_state(dk) }}</td>
</tr>
{% endfor %}
{% for ck in unraid.caches | default([]) %}
<tr>
<th scope="row">{{ ck.name }}</th>
<td>{{ ck.device | default('—') }}</td>
<td class="num">{{ ck.total | default('—') }}</td>
<td class="num">{{ ck.free | default('—') }}</td>
<td>{{ bar(ck.used_pct | default(0)) }} <span class="val">{{ ck.used_pct | default(0) | round | int }}%</span></td>
<td class="num">{{ disk_state(ck) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{#- 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. -#}
<div class="tt-disk-rows">
{% for dk in unraid.disks %}{{ disk_row(dk) }}{% endfor %}
{% for ck in unraid.caches | default([]) %}{{ disk_row(ck) }}{% endfor %}
</div>
{% endif %}
{% endif %}
<div id="array-section">
{% include "_array_section.html" %}
</div>
{#- ── Downloads ─────────────────────────────────────────────────
The one place the service holds real history, so the one place a
@ -120,7 +38,7 @@
<div class="tt-fig-l">In queue</div>
</div>
<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>
<div class="tt-fig">
@ -160,4 +78,43 @@
<div class="tt-field crit"><span>Indexers</span><span class="tt-field-meta">off air</span></div>
{{ service_error('Prowlarr', prowlarr.error) }}
{% endif %}
{#- ── Admin ─────────────────────────────────────────────────────
Shows Sonarr is holding, sized, that nobody has ever played in
Tautulli and that have sat untouched for longer than the
neglect window — largest first, since the point is reclaiming
space. Delete removes the series from Sonarr with its episode
files and folder; that's permanent, so it shares the site's
two-tap arming rather than a native confirm(). -#}
{% if admin_unwatched.status != 'ok' %}
<div class="tt-field crit"><span>Admin</span><span class="tt-field-meta">off air</span></div>
{{ service_error('Sonarr/Tautulli', admin_unwatched.error) }}
{% else %}
<div class="tt-field">
<span>Admin</span>
<span class="tt-field-meta" id="admin-meta">
{{ admin_unwatched.shows | length }} unwatched &middot; {{ admin_unwatched.total_display }} reclaimable
</span>
</div>
{% if admin_unwatched.shows %}
<div class="tt-rows" id="admin-rows">
{% for show in admin_unwatched.shows %}
<div class="tt-row" data-bytes="{{ show.size }}">
<span class="tt-row-name">{{ show.ep_count }} EP</span>
<span class="tt-row-body">
<span class="tt-row-title">{{ show.title }}</span>
<span class="tt-row-sub">{% if show.year %}{{ show.year }} &middot; {% endif %}Downloaded {{ show.added[:10] if show.added else 'unknown' }} &middot; {% if show.requested_by %}Requested by {{ show.requested_by }}{% else %}Requester unknown{% endif %} &middot; Never watched</span>
</span>
<span class="tt-row-val">{{ show.size_display }}</span>
<span class="tt-row-acts">
<button class="tt-btn tt-btn-no tt-btn-sm" data-id="{{ show.sonarr_id }}"
onclick="armDelete(this)" aria-label="Delete {{ show.title }}">Delete</button>
</span>
</div>
{% endfor %}
</div>
{% else %}
<p class="tt-empty" id="admin-empty">Nothing unwatched and older than {{ admin_unwatched.months }} months is taking up meaningful space.</p>
{% endif %}
{% endif %}
{% endblock %}

View file

@ -11,6 +11,29 @@
{% else %}
{%- set sessions = tautulli.sessions or [] -%}
{% if selected_user %}
<div class="tt-field"><span>History &mdash; {{ selected_user }}</span><span class="tt-field-meta">last 48 hours</span></div>
{% if user_history.status != 'ok' %}
{{ service_error('Tautulli', user_history.error) }}
{% elif user_history.entries %}
<div class="tt-rows">
{% for it in user_history.entries %}
<div class="tt-row">
<span class="tt-row-name">{{ (it.media_type or 'play') | upper }}</span>
<span class="tt-row-body">
<span class="tt-row-title">{{ it.title }}</span>
<span class="tt-row-sub">{{ it.when }}</span>
</span>
<span class="tt-row-val">{{ it.percent }}%</span>
</div>
{% endfor %}
</div>
{% else %}
<p class="tt-empty">Nothing played in the last 48 hours.</p>
{% endif %}
<p class="tt-empty"><a href="{{ request.script_root }}/watch">&larr; All viewers</a></p>
{% endif %}
<div class="tt-field {{ 'warn' if sessions else '' }}">
<span>Streaming Now</span>
<span class="tt-field-meta">{{ tautulli.active_streams }} active</span>
@ -75,5 +98,23 @@
{% endfor %}
</div>
{% endif %}
<div class="tt-field"><span>Active Viewers</span><span class="tt-field-meta">last 10 days</span></div>
{% if tautulli.active_users %}
<div class="tt-rows">
{% for u in tautulli.active_users %}
<a class="tt-row" href="{{ request.script_root }}/watch?user={{ u.username | urlencode }}">
<span class="tt-row-num">{{ loop.index }}</span>
<span class="tt-row-body">
<span class="tt-row-title">{{ u.username }}</span>
<span class="tt-row-sub">Last watched {{ u.last_watched }}</span>
</span>
<span class="tt-row-val">{{ u.plays }} PLAYS</span>
</a>
{% endfor %}
</div>
{% else %}
<p class="tt-empty">No viewers in the last 10 days.</p>
{% endif %}
{% endif %}
{% endblock %}