Compare commits

..

No commits in common. "main" and "meridian-marquee" have entirely different histories.

18 changed files with 340 additions and 1199 deletions

View file

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

View file

@ -1,83 +0,0 @@
---
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

@ -1,39 +0,0 @@
---
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,18 +35,6 @@ the user's Unraid home server. Server-side project directory is still named
SABnzbd history 10 min, Ombi requests 4 hours, Rotten Tomatoes scrapes SABnzbd history 10 min, Ombi requests 4 hours, Rotten Tomatoes scrapes
4 hours. Active streams (Tautulli) and the SABnzbd queue are always 4 hours. Active streams (Tautulli) and the SABnzbd queue are always
fetched live, never cached — those need to be current-second accurate. 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` + - Rotten Tomatoes / IMDB are scraped directly (`requests` +
`BeautifulSoup`, JSON-LD parsing), not via an official API — brittle `BeautifulSoup`, JSON-LD parsing), not via an official API — brittle
to upstream HTML changes by nature. to upstream HTML changes by nature.
@ -78,12 +66,7 @@ the user's Unraid home server. Server-side project directory is still named
than a variation: than a variation:
1. **No in-between tone.** There are eight colours (the teletext 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 primaries). A mid-grey is an ordered dither of two of them, never a
ninth colour — except `--tt-orange`, the one deliberate, acknowledged ninth colour.
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 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 zero `border-radius` in the codebase. Grouping is a coloured field row
(`.tt-field`), indentation, and weight. This is the single thing the (`.tt-field`), indentation, and weight. This is the single thing the
@ -113,39 +96,12 @@ 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 `--ink-dimmer` fails contrast, which is exactly the bug the redesign's
first inspection round caught. first inspection round caught.
- **Navigation is the page number.** Typing any three digits anywhere - **Navigation is the page number.** Typing any three digits anywhere
jumps to that page, and `r`/`g`/`y`/`c` fire four of the fastext jumps to that page, and `r`/`g`/`y`/`c` fire the four fastext colour
entries — both in `static/js/service.js`. `nav_items` in `app.py` is keys — both in `static/js/service.js`. `nav_items` in `app.py` is the
the source of truth for the numbers and is serialised into source of truth for the numbers and is serialised into
`<body data-pages>`. `<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 - **Nothing is clipped, hidden, or sticky.** All seven page numbers and
every fastext entry stay visible at every width, reflowing to another all four fastext keys stay visible at every width, reflowing to another
row rather than scrolling, truncating, or folding into a menu. No row rather than scrolling, truncating, or folding into a menu. No
header is sticky. header is sticky.
- **`export-to-unraid.sh`** — a pre-existing standalone deploy script - **`export-to-unraid.sh`** — a pre-existing standalone deploy script
@ -210,21 +166,119 @@ app from a shell on an explicit `PORT` is the working path.
## Deployment ## Deployment
See the `deploy-arr-summary` skill for the Forgejo push-to-deploy **Target:** Unraid server, container path `/mnt/user/appdata/greendale`
pipeline, manual container restart steps, deploy verification, and the (note the directory name differs from the container/repo name), built from
Nginx Proxy Manager / TLS setup and its Cox-port-80/DNS-01 constraint. `Dockerfile`/`docker-compose.yml` in this repo. Container name
`arr-summary`, port `5055` (overridable via `HOST_PORT`) → container port
`5000`.
**Every commit should be matched with a version change.** Bump **Deploy mechanism: Forgejo push-to-deploy**, same pattern as the other
`APP_VERSION` / `APP_VERSION_DATE` near the top of `app.py` as part of three projects on this box — see `card-grader`'s `CLAUDE.md` for the full
the same commit. This is what makes the footer (`v<APP_VERSION> · pipeline mechanics. Push to `main` on the `forgejo` remote
<APP_VERSION_DATE>`) a trustworthy signal that a given deploy actually (`https://git.hippofam.com/ninja_hippo/arr-summary.git`) triggers
landed — a commit without a version bump can't be distinguished from `.forgejo/workflows/deploy.yml`, which syncs into
"not deployed yet" by looking at the live site. `/mnt/user/appdata/greendale` (excluding `.git`, `.env`, `data/`) and runs
`docker compose up -d --build`.
**Restarting the container manually: use Compose, not the Unraid Docker
UI.** Every deploy recreates the container with a new Docker ID (same
name, `arr-summary`). Unraid's Docker tab caches the old ID, so hitting
Restart there after a deploy throws "No such container" — harmless, but
confusing. From the Unraid terminal:
```bash
cd /mnt/user/appdata/greendale && docker compose restart
```
`docker compose up -d --force-recreate` for a guaranteed-clean recreate;
`docker ps --filter name=arr-summary` to check real state regardless of
what the Unraid UI shows.
**Confirming a deploy actually landed:** the dashboard footer renders
`v<APP_VERSION> · <APP_VERSION_DATE>` from constants near the top of
`app.py` (both env-overridable, injected into every template by the
`inject_version` context processor). Bump them when cutting a deploy —
loading the page is then the quickest honest check that the new build is
live, without trusting the Unraid Docker tab's cached container IDs.
## Reverse proxy (Nginx Proxy Manager)
Two NPM proxy hosts front this app and `git.hippofam.com`, both using
Let's Encrypt certs issued via **DNS-01 through Porkbun** — required,
not optional: **Cox blocks inbound port 80** on this connection, so
HTTP-01 challenges (and plain `http://` requests generally) can never
work here. `http://hippofam.com` will always time out from outside the
LAN; only `https://` resolves. Both proxy hosts have Force SSL on.
`hippofam.com` → `http://192.168.86.33:5055` (the Unraid box, this
container's port). Verify the destination IP/port here before assuming
an outage is DNS or app-level — it has drifted to the wrong host before.
On 2026-08-26 the apex host's own cert (issued Aug 2025) went dark: NPM's
UI still showed it "In Use" with a valid, unexpired date, but nginx
presented no certificate at all for `hippofam.com` (TLS alert 112,
"unrecognized name") — the on-disk cert files had rotted independently
of the DB record NPM's UI reads from. `git.hippofam.com`, on a newer
cert from the same account, was unaffected. Fixed by pointing the proxy
host at the `*.hippofam.com` + `hippofam.com` wildcard cert already
issued and sitting unused, which forced NPM to regenerate the nginx
config with known-good cert files. If `hippofam.com` ever throws a TLS
handshake error again with NPM's UI reporting all green, suspect this
same on-disk/DB drift before anything else — check with:
```bash
openssl s_client -connect <public-ip>:443 -servername hippofam.com </dev/null
```
A clean run shows `subject=/CN=...` and `Verify return code: 0`; the
broken state shows `no peer certificate available`.
**Persistent state, never touched by deploys:** `.env` holds every API key
and `LOGIN_PASSWORD` — this project was already doing this correctly
before the Forgejo migration (compose file uses `${VAR}` substitution from
`.env`, no hardcoded secrets), unlike `n64-tracker` which needed fixing.
Runtime settings written by the Settings page (API-key overrides,
timezone, changed login password) live in `settings.json` at
`SETTINGS_FILE`, which the compose file points at `/data/settings.json`
on the `./data` bind mount — i.e. `/mnt/user/appdata/greendale/data`.
That mount is load-bearing: without it the file sits on the container
filesystem, and every `up -d --build` recreates the container and
silently discards it. `data/` is likewise excluded from the deploy
rsync, which runs `--delete` and would otherwise remove it for not
being in the repo.
## Logins ## Logins
See the `manage-accounts` skill for the multi-user vs. legacy Two modes, chosen by whether `settings.json` holds a non-empty `users` map:
single-shared-password login modes and the `manage_users.py` CLI.
- **Multi-user** (once any account exists): username + password, each
password stored as a salted pbkdf2 hash under `users` in `settings.json`.
`verify_credentials()` is the only entry point; `load_users()` re-reads
the file per check, so adding a user or changing a password is live
immediately in every gunicorn worker — no restart, unlike the API keys.
- **Legacy single shared password** (no accounts yet): the original
`LOGIN_PASSWORD` / `settings.json.login_password` path, username ignored.
This fallback exists so deploying multi-user support can't lock an
existing install out; the login page hides the username field entirely
until accounts exist.
Manage accounts with `manage_users.py`, which prompts for passwords
(never echoed, never a CLI argument, so they stay out of shell history)
and only ever writes hashes:
```bash
docker exec -it arr-summary python3 manage_users.py list
docker exec -it arr-summary python3 manage_users.py add <username>
docker exec -it arr-summary python3 manage_users.py passwd <username>
docker exec -it arr-summary python3 manage_users.py remove <username>
```
**Adding the first account switches the whole dashboard to per-user
logins and the shared password stops working** — verify you can sign in
as that user before closing the shell. `remove` refuses to delete the
last account for the same reason.
Users live in `settings.json` rather than an env var deliberately: a
pbkdf2 hash contains `$`, which docker compose interpolates when reading
`.env`. `PASSWORD_HASH_METHOD` is pinned to pbkdf2 rather than Werkzeug's
default scrypt, which needs a hashlib built against OpenSSL and is absent
on the macOS system Python.
**Never put a real password in the repo** — not in `app.py`, not in **Never put a real password in the repo** — not in `app.py`, not in
`settings.json` committed to git, not in a fixture. `.env` and `data/` `settings.json` committed to git, not in a fixture. `.env` and `data/`

View file

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

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 # Docker tab caches stale container IDs, so the footer is the reliable way
# to confirm a `docker compose up -d --build` actually took effect. # to confirm a `docker compose up -d --build` actually took effect.
# Both are env-overridable if the deploy pipeline ever injects them. # Both are env-overridable if the deploy pipeline ever injects them.
APP_VERSION = os.environ.get("APP_VERSION", "2.3.1") APP_VERSION = os.environ.get("APP_VERSION", "2.0.0")
APP_VERSION_DATE = os.environ.get("APP_VERSION_DATE", "2026-09-05") APP_VERSION_DATE = os.environ.get("APP_VERSION_DATE", "2026-08-29")
# Must point at a path that survives `docker compose up --build` — the # Must point at a path that survives `docker compose up --build` — the
# container filesystem does not. See the data volume in docker-compose.yml. # container filesystem does not. See the data volume in docker-compose.yml.
@ -287,11 +287,6 @@ _sonarr_cache = {"data": None, "expires": 0}
_radarr_cache = {"data": None, "expires": 0} _radarr_cache = {"data": None, "expires": 0}
ARR_CACHE_TTL = 5 * 60 # 5 minutes 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. # Tautulli stats cache: top shows/movies/users/history refresh every 2 minutes.
# Active streams (get_activity) are always fetched live. # Active streams (get_activity) are always fetched live.
_tautulli_stats_cache = {"data": None, "expires": 0} _tautulli_stats_cache = {"data": None, "expires": 0}
@ -517,8 +512,7 @@ def _get_sab_history_cached():
if duration > 0 and size_bytes > 0: if duration > 0 and size_bytes > 0:
speeds.append(size_bytes / duration) speeds.append(size_bytes / duration)
avg_bytes_per_sec = sum(speeds) / len(speeds) if speeds else 0 avg_bytes_per_sec = sum(speeds) / len(speeds) if speeds else 0
avg_speed_kbps = avg_bytes_per_sec / 1024 avg_speed = _format_speed(avg_bytes_per_sec / 1024)
avg_speed = _format_speed(avg_speed_kbps)
# Keep the newest completed items too — "Landed today" on the home # Keep the newest completed items too — "Landed today" on the home
# page and the activity feed both read these. Same request as before; # page and the activity feed both read these. Same request as before;
# previously only the aggregates survived. # previously only the aggregates survived.
@ -532,12 +526,12 @@ def _get_sab_history_cached():
result = {"week_size": week_size, "day_size": day_size, result = {"week_size": week_size, "day_size": day_size,
"completed_7d": completed_7d, "avg_speed": avg_speed, "completed_7d": completed_7d, "avg_speed": avg_speed,
"avg_speed_kbps": avg_speed_kbps, "recent_items": recent_items} "recent_items": recent_items}
_sab_history_cache = {"data": result, "expires": now + 600} _sab_history_cache = {"data": result, "expires": now + 600}
return result return result
except Exception: except Exception:
return {"week_size": "—", "day_size": "—", "completed_7d": "—", return {"week_size": "—", "day_size": "—", "completed_7d": "—",
"avg_speed": "—", "avg_speed_kbps": None, "recent_items": []} "avg_speed": "—", "recent_items": []}
def get_sabnzbd_summary(): def get_sabnzbd_summary():
@ -571,7 +565,6 @@ def get_sabnzbd_summary():
"week_size": hist["week_size"], "week_size": hist["week_size"],
"day_size": hist["day_size"], "day_size": hist["day_size"],
"avg_speed": hist["avg_speed"], "avg_speed": hist["avg_speed"],
"avg_speed_kbps": hist["avg_speed_kbps"],
"recent_items": hist.get("recent_items", []), "recent_items": hist.get("recent_items", []),
} }
except Exception as e: except Exception as e:
@ -617,7 +610,6 @@ def get_tautulli_summary():
top_movie_list = cached["top_movie_list"] top_movie_list = cached["top_movie_list"]
top_played = cached["top_played"] top_played = cached["top_played"]
top_users = cached["top_users"] top_users = cached["top_users"]
active_users = cached["active_users"]
else: else:
# Top 3 most watched TV shows (30d) # 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") tv_data = tautulli_get("get_home_stats", "&stat_id=top_tv&stats_count=3&stats_type=plays&time_range=30")
@ -661,36 +653,9 @@ def get_tautulli_summary():
if len(top_users) >= 3: if len(top_users) >= 3:
break 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 = { _tautulli_stats_cache = {
"data": {"top_tv_list": top_tv_list, "top_movie_list": top_movie_list, "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, "expires": now + TAUTULLI_CACHE_TTL,
} }
@ -741,7 +706,6 @@ def get_tautulli_summary():
"top_movie_list": top_movie_list, "top_movie_list": top_movie_list,
"top_played": top_played, "top_played": top_played,
"top_users": top_users, "top_users": top_users,
"active_users": active_users,
"active_streams": stream_count, "active_streams": stream_count,
"sessions": sessions, "sessions": sessions,
} }
@ -749,140 +713,6 @@ def get_tautulli_summary():
return {"status": "error", "error": str(e)} 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 # Ombi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -894,23 +724,6 @@ def ombi_get(endpoint):
return resp.json() 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): def _ombi_status_label(available, approved, denied):
if denied: if denied:
return "Denied" return "Denied"
@ -939,7 +752,6 @@ def get_ombi_summary():
if alias.lower() == "ninja_hippo": if alias.lower() == "ninja_hippo":
continue continue
all_requests.append({ all_requests.append({
"id": m.get("id"),
"title": m.get("title", "Unknown"), "title": m.get("title", "Unknown"),
"type": "Movie", "type": "Movie",
"requested_date": m.get("requestedDate", ""), "requested_date": m.get("requestedDate", ""),
@ -963,9 +775,6 @@ def get_ombi_summary():
) )
denied = child.get("denied", False) denied = child.get("denied", False)
all_requests.append({ 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"), "title": show.get("title", "Unknown"),
"type": "TV", "type": "TV",
"requested_date": child.get("requestedDate", ""), "requested_date": child.get("requestedDate", ""),
@ -1444,7 +1253,6 @@ PAGE_TASKS = {
"unraid": lambda: get_unraid_stats(), "unraid": lambda: get_unraid_stats(),
"rt_movies": lambda: get_rt_movies(), "rt_movies": lambda: get_rt_movies(),
"rt_tv": lambda: get_rt_tv(), "rt_tv": lambda: get_rt_tv(),
"admin_unwatched": lambda: get_admin_unwatched_shows(),
} }
PAGE_TIMEOUT = 5.0 PAGE_TIMEOUT = 5.0
@ -1493,44 +1301,6 @@ 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 @app.context_processor
def inject_page_chrome(): def inject_page_chrome():
"""Shell-wide context: the nav, the clock's timezone, service links.""" """Shell-wide context: the nav, the clock's timezone, service links."""
@ -1551,12 +1321,15 @@ def inject_page_chrome():
("activity", "106", "Activity", "/activity"), ("activity", "106", "Activity", "/activity"),
("settings", "900", "Settings", "/settings"), ("settings", "900", "Settings", "/settings"),
], ],
# Every page, at the top of every page but the index — which # The four coloured keys along the bottom of every page. A page
# overrides this with HOME_FASTEXT, its own short closing row, # that wants different destinations passes its own `fastext` to
# since its body already lists all of these once. The four # render_template, which wins over this default.
# letter keys (r/g/y/c) keep their original destinations; the "fastext": [
# rest have no keyboard shortcut, just a click. ("k-red", "r", "100", "Index", "/"),
"fastext": FULL_FASTEXT, ("k-green", "g", "102", "Library", "/library"),
("k-yellow", "y", "103", "Discover", "/discover"),
("k-cyan", "c", "105", "System", "/system"),
],
} }
@ -1629,86 +1402,6 @@ def get_recently_added(limit=6):
return items 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): def get_activity_feed(sabnzbd, ombi, limit=25):
"""A single chronological feed assembled from what the fetchers return. """A single chronological feed assembled from what the fetchers return.
@ -1787,7 +1480,7 @@ def home():
others = [s for s in sessions if s is not hero] others = [s for s in sessions if s is not hero]
return render_template( return render_template(
"home.html", page="home", fastext=HOME_FASTEXT, "home.html", page="home",
sonarr=sonarr, radarr=radarr, sabnzbd=r["sabnzbd"], tautulli=tautulli, sonarr=sonarr, radarr=radarr, sabnzbd=r["sabnzbd"], tautulli=tautulli,
prowlarr=r["prowlarr"], ombi=r["ombi"], unraid=r["unraid"], prowlarr=r["prowlarr"], ombi=r["ombi"], unraid=r["unraid"],
hero=hero, other_sessions=others, hero=hero, other_sessions=others,
@ -1806,21 +1499,16 @@ def home():
@login_required @login_required
def watch_page(): def watch_page():
r = fetch_services(["tautulli"]) r = fetch_services(["tautulli"])
selected_user = request.args.get("user", "").strip() return render_template("watch.html", page="watch", tautulli=r["tautulli"])
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") @app.route("/library")
@login_required @login_required
def library_page(): def library_page():
r = fetch_services(["sonarr", "radarr"]) r = fetch_services(["sonarr", "radarr"])
q = request.args.get("q", "").strip()
return render_template("library.html", page="library", return render_template("library.html", page="library",
sonarr=r["sonarr"], radarr=r["radarr"], 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") @app.route("/discover")
@ -1829,11 +1517,9 @@ def discover_page():
r = fetch_services(["sonarr", "radarr", "rt_movies", "rt_tv"]) r = fetch_services(["sonarr", "radarr", "rt_movies", "rt_tv"])
sonarr_titles = set(r["sonarr"].get("sonarr_titles", [])) sonarr_titles = set(r["sonarr"].get("sonarr_titles", []))
radarr_titles = set(r["radarr"].get("radarr_titles", [])) radarr_titles = set(r["radarr"].get("radarr_titles", []))
q = request.args.get("q", "").strip()
return render_template("discover.html", page="discover", return render_template("discover.html", page="discover",
rt_movies=_filter_rt(r["rt_movies"], radarr_titles), 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") @app.route("/requests")
@ -1847,24 +1533,12 @@ def requests_page():
@login_required @login_required
def system_page(): def system_page():
r = fetch_services(["unraid", "prowlarr", "sonarr", "radarr", r = fetch_services(["unraid", "prowlarr", "sonarr", "radarr",
"sabnzbd", "tautulli", "ombi", "admin_unwatched"]) "sabnzbd", "tautulli", "ombi"])
return render_template("system.html", page="system", return render_template("system.html", page="system",
unraid=r["unraid"], prowlarr=r["prowlarr"], unraid=r["unraid"], prowlarr=r["prowlarr"],
sonarr=r["sonarr"], radarr=r["radarr"], sonarr=r["sonarr"], radarr=r["radarr"],
sabnzbd=r["sabnzbd"], tautulli=r["tautulli"], sabnzbd=r["sabnzbd"], tautulli=r["tautulli"],
ombi=r["ombi"], admin_unwatched=r["admin_unwatched"]) ombi=r["ombi"])
@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") @app.route("/activity")
@ -1942,40 +1616,6 @@ def api_ombi():
return jsonify(get_ombi_summary()) 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") @app.route("/api/services")
@login_required @login_required
def api_services(): def api_services():
@ -2023,15 +1663,57 @@ def api_services():
@app.route("/api/tautulli/never-watched") @app.route("/api/tautulli/never-watched")
@login_required @login_required
def api_never_watched(): def api_never_watched():
"""Sonarr shows that have never been played in Tautulli — see the """Movies and shows in Radarr/Sonarr that have never been played in Tautulli."""
Admin section on P105 (`get_admin_unwatched_shows`), which this try:
delegates to rather than duplicating the match/sort logic.""" # Get all history titles from Tautulli (last 500 entries)
months = request.args.get("months", default=ADMIN_UNWATCHED_MONTHS, type=int) hist = tautulli_get("get_history", "&length=500&order_column=date&order_dir=desc")
limit = request.args.get("limit", default=30, type=int) played_titles = set()
result = get_admin_unwatched_shows(limit=limit, months=months) if isinstance(hist, dict):
if result.get("status") != "ok": slots = hist.get("data", [])
return jsonify(result), 500 if isinstance(slots, dict):
return jsonify(result) 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
@app.route("/api/delete/movie", methods=["POST"]) @app.route("/api/delete/movie", methods=["POST"])
@ -2048,8 +1730,6 @@ def api_delete_movie():
headers=headers, timeout=15 headers=headers, timeout=15
) )
resp.raise_for_status() resp.raise_for_status()
_radarr_cache["data"] = None
_radarr_cache["expires"] = 0
return jsonify({"success": True}) return jsonify({"success": True})
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
@ -2069,8 +1749,6 @@ def api_delete_show():
headers=headers, timeout=15 headers=headers, timeout=15
) )
resp.raise_for_status() resp.raise_for_status()
_sonarr_cache["data"] = None
_sonarr_cache["expires"] = 0
return jsonify({"success": True}) return jsonify({"success": True})
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
@ -2080,7 +1758,7 @@ def api_delete_show():
@login_required @login_required
def settings_page(): def settings_page():
s = load_settings() s = load_settings()
return render_template("settings.html", page="settings", return render_template("settings.html",
timezone=s.get("timezone", "America/New_York"), timezone=s.get("timezone", "America/New_York"),
sonarr_api_key=SONARR_API_KEY, sonarr_api_key=SONARR_API_KEY,
radarr_api_key=RADARR_API_KEY, radarr_api_key=RADARR_API_KEY,

View file

@ -61,14 +61,6 @@
--tt-cyan: #00ffff; --tt-cyan: #00ffff;
--tt-white: #ffffff; --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 ───────────────────────────────────────────── /* ── Roles ─────────────────────────────────────────────
Each colour means one thing across all nine pages. */ Each colour means one thing across all nine pages. */
--ink: var(--tt-white); /* body text */ --ink: var(--tt-white); /* body text */

View file

@ -8,26 +8,6 @@
RULER (every bar is --bar-cells wide, no exceptions). 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 ───────────────────────────────────────── /* ── Header row ─────────────────────────────────────────
Teletext's top line, unchanged for forty years: the page Teletext's top line, unchanged for forty years: the page
number you are on, who is transmitting, and the clock. It number you are on, who is transmitting, and the clock. It
@ -53,6 +33,7 @@
text-transform: uppercase; text-transform: uppercase;
} }
.tt-head-clock { .tt-head-clock {
margin-left: auto;
color: var(--live); color: var(--live);
white-space: nowrap; white-space: nowrap;
min-width: 0; min-width: 0;
@ -64,16 +45,6 @@
letter-spacing: 0.3em; letter-spacing: 0.3em;
} }
.tt-entry[hidden] { display: none; } .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 ─────────────────────────────────────────────── /* ── Page ───────────────────────────────────────────────
One measure for every page in the service. Nothing is ever One measure for every page in the service. Nothing is ever
@ -187,10 +158,6 @@ a.tt-row:focus-visible .tt-row-sub { color: var(--tt-black); }
text-transform: uppercase; text-transform: uppercase;
min-width: 11ch; 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-body { flex: 1; min-width: 0; }
.tt-row-title { .tt-row-title {
overflow-wrap: anywhere; overflow-wrap: anywhere;
@ -205,13 +172,6 @@ a.tt-row:focus-visible .tt-row-sub { color: var(--tt-black); }
color: var(--live); color: var(--live);
white-space: nowrap; 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.ok { color: var(--ok); }
.tt-row-val.alert { color: var(--alert); } .tt-row-val.alert { color: var(--alert); }
.tt-row-val.warn { color: var(--voice); } .tt-row-val.warn { color: var(--voice); }
@ -436,24 +396,6 @@ 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-quiet:hover:not(:disabled) { background: var(--live); color: var(--tt-black); }
.tt-btn-sm { min-height: calc(var(--row) * 1.6); width: 100%; } .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 { .tt-input {
min-height: var(--tap); min-height: var(--tap);
padding: 0 1ch; padding: 0 1ch;
@ -523,15 +465,10 @@ a.tt-row:focus-visible .tt-row-sub { color: var(--tt-black); }
.tt-empty { padding: 0 1ch; color: var(--ink-dim); } .tt-empty { padding: 0 1ch; color: var(--ink-dim); }
/* ── Fastext ──────────────────────────────────────────── /* ── Fastext ────────────────────────────────────────────
The four coloured keys. Real navigation, not decoration: The four coloured keys along the bottom of every teletext
click one, or press its initial. Deliberately NOT fixed to page. Real navigation, not decoration: click one, or press
the viewport — it is a row of the page, where it always its initial. Deliberately NOT fixed to the viewport — it is
was, not furniture bolted over it. On the index it closes the last row of the page, where it always was. */
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 { .fastext {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);
@ -540,10 +477,7 @@ a.tt-row:focus-visible .tt-row-sub { color: var(--tt-black); }
padding: 0 var(--gutter); padding: 0 var(--gutter);
gap: 1ch; gap: 1ch;
} }
.fastext-top { .fastext a {
margin: var(--row) auto calc(var(--row) * 1.5);
}
.fastext a, .fastext-here {
display: flex; display: flex;
align-items: center; align-items: center;
min-width: 0; min-width: 0;
@ -557,39 +491,24 @@ a.tt-row:focus-visible .tt-row-sub { color: var(--tt-black); }
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
/* The key's identity is carried by a cell at its head — the physical /* The key's colour is carried by a solid cell at its head — the
coloured button on the remote. */ physical coloured button on the remote. */
.fastext a > span, .fastext-here > span { .fastext a > span {
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.fastext a::before, .fastext-here::before { .fastext a::before {
content: ""; content: "";
flex: 0 0 auto; flex: 0 0 auto;
width: 1.2ch; width: 1.2ch;
height: 1em; height: 1em;
background: currentColor; background: currentColor;
} }
/* 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-red { color: var(--alert); }
.fastext .k-green { color: var(--ok); } .fastext .k-green { color: var(--ok); }
.fastext .k-yellow { color: var(--voice); } .fastext .k-yellow { color: var(--voice); }
.fastext .k-cyan { color: var(--live); } .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 { .fastext a:hover, .fastext a:focus-visible {
background: currentColor; background: currentColor;
outline: none; outline: none;
@ -674,18 +593,7 @@ a.tt-row:focus-visible .tt-row-sub { color: var(--tt-black); }
} }
.tt-row-title { white-space: normal; } .tt-row-title { white-space: normal; }
.tt-row-val { margin-left: auto; 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-name { letter-spacing: 0.12em; }
.tt-head-dl { margin-left: 0; flex-basis: 100%; }
.tt-head-clock { 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 /* The System disk table swaps for its row reflow here — see the

View file

@ -109,7 +109,7 @@
function disarm() { function disarm() {
if (!armed) return; if (!armed) return;
armed.classList.remove('armed'); armed.classList.remove('armed');
if (armed.dataset.label) armed.textContent = armed.dataset.label; armed.textContent = armed.dataset.label || '+ ADD';
armed = null; armed = null;
} }
window.armAdd = function (btn) { window.armAdd = function (btn) {
@ -129,7 +129,7 @@
}) })
.then(function (r) { return r.json(); }) .then(function (r) { return r.json(); })
.then(function (d) { .then(function (d) {
if (d && d.success) { if (d && d.ok) {
btn.classList.add('done'); btn.classList.add('done');
btn.textContent = 'ADDED'; btn.textContent = 'ADDED';
} else { } else {
@ -152,195 +152,22 @@
btn.textContent = 'CONFIRM?'; btn.textContent = 'CONFIRM?';
armed = btn; 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) { document.addEventListener('click', function (e) {
if (armed && !armed.contains(e.target)) disarm(); 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 ───────────────────────────────────── /* ── Live figures ─────────────────────────────────────
The queue and the active streams are the only two numbers The queue and the active streams are the only two numbers
that must be accurate to the second, so they are the only that must be accurate to the second, so they are the only
two that poll. Everything else is cached server-side and two that poll. Everything else is cached server-side and
arrives with the page. The queue also drives the header's arrives with the page. */
download row, present on every page, so it polls
unconditionally rather than only where #sab-queue exists. */
function setText(id, value) { function setText(id, value) {
var el = document.getElementById(id); var el = document.getElementById(id);
if (el) el.textContent = value; if (el) el.textContent = value;
} }
function pollSab() { function pollSab() {
if (!document.getElementById('sab-queue')) return;
fetch(BASE + '/api/sabnzbd') fetch(BASE + '/api/sabnzbd')
.then(function (r) { return r.json(); }) .then(function (r) { return r.json(); })
.then(function (d) { .then(function (d) {
@ -348,14 +175,6 @@
setText('sab-queue', d.queue_count); setText('sab-queue', d.queue_count);
setText('sab-speed', d.speed || '0'); setText('sab-speed', d.speed || '0');
setText('sab-remaining', d.sizeleft || '—'); 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 */ }); .catch(function () { /* offline is the dev baseline, not an error */ });
} }
@ -371,8 +190,10 @@
.catch(function () { /* as above */ }); .catch(function () { /* as above */ });
} }
if (document.getElementById('sab-queue')) {
pollSab(); pollSab();
setInterval(pollSab, 5000); setInterval(pollSab, 5000);
}
if (document.getElementById('tt-streams')) { if (document.getElementById('tt-streams')) {
pollTautulli(); pollTautulli();
setInterval(pollTautulli, 15000); setInterval(pollTautulli, 15000);

View file

@ -1,100 +0,0 @@
{% 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,12 +17,6 @@
</head> </head>
<body data-timezone="{{ timezone }}" data-url-base="{{ request.script_root }}" <body data-timezone="{{ timezone }}" data-url-base="{{ request.script_root }}"
data-pages='{{ nav_items | tojson }}'> 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 THESIS: Media Centre is a broadcast information service, not a dashboard. It
refuses the bordered-card grid every self-hosted dashboard ships, this one refuses the bordered-card grid every self-hosted dashboard ships, this one
@ -48,40 +42,22 @@ provenance.
<span class="tt-head-num">P{{ here[1] }}</span> <span class="tt-head-num">P{{ here[1] }}</span>
<span class="tt-head-name">{{ here[2] }}</span> <span class="tt-head-name">{{ here[2] }}</span>
<span class="tt-entry" id="page-entry" hidden aria-live="polite"></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> <span class="tt-head-clock" id="head-clock"></span>
</header> </header>
{#- Fastext: the four coloured keys. Each page names its own four, so the <main class="tt-page">
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 %} {% block content %}{% endblock %}
</main> </main>
{% if page == 'home' %}{{ fastext_nav }}{% endif %} {#- 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">
{% for cls, key, num, label, href in fastext %}
<a class="{{ cls }}" href="{{ request.script_root }}{{ href }}" data-key="{{ key }}">
<span>{{ num }} {{ label }}</span>
</a>
{% endfor %}
</nav>
<footer class="tt-foot"> <footer class="tt-foot">
<span>MEDIA CENTRE</span> <span>MEDIA CENTRE</span>

View file

@ -1,48 +1,14 @@
{% extends "_shell.html" %} {% extends "_shell.html" %}
{% from "_macros.html" import tile, service_error %} {% from "_macros.html" import tile %}
{% block title %}P103 Discover{% endblock %} {% block title %}P103 Discover{% endblock %}
{% block content %} {% block content %}
<h1 class="tt-dh bloom tt-masthead">DISCOVER</h1> <h1 class="tt-dh bloom tt-masthead">DISCOVER</h1>
<p class="tt-empty">Popular right now, with anything already in library filtered out.</p> <p class="tt-empty">Popular right now, with anything already held 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"> <div class="tt-field">
<span>Films</span> <span>Films</span>
<span class="tt-field-meta">{{ rt_movies | length }} not in library</span> <span class="tt-field-meta">{{ rt_movies | length }} not held</span>
</div> </div>
{% if rt_movies %} {% if rt_movies %}
<div class="tt-tiles"> <div class="tt-tiles">
@ -51,12 +17,12 @@
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
<p class="tt-empty">Nothing to suggest &mdash; the feed is unreachable or it's all already in your library.</p> <p class="tt-empty">Nothing to suggest &mdash; the feed is unreachable or you already hold it all.</p>
{% endif %} {% endif %}
<div class="tt-field"> <div class="tt-field">
<span>Television</span> <span>Television</span>
<span class="tt-field-meta">{{ rt_tv | length }} not in library</span> <span class="tt-field-meta">{{ rt_tv | length }} not held</span>
</div> </div>
{% if rt_tv %} {% if rt_tv %}
<div class="tt-tiles"> <div class="tt-tiles">

View file

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

View file

@ -14,22 +14,6 @@
<button class="tt-btn" type="submit">Search</button> <button class="tt-btn" type="submit">Search</button>
</form> </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"> <div class="tt-field">
<span>Holdings</span> <span>Holdings</span>
<span class="tt-field-meta"> <span class="tt-field-meta">
@ -71,7 +55,7 @@
{{ service_error('Radarr', radarr.error) }} {{ service_error('Radarr', radarr.error) }}
{% endif %} {% endif %}
{% if not q and recently_added %} {% if recently_added %}
<div class="tt-field"><span>Newest In</span></div> <div class="tt-field"><span>Newest In</span></div>
<div class="tt-tiles"> <div class="tt-tiles">
{% for it in recently_added %} {% for it in recently_added %}

View file

@ -10,17 +10,15 @@
{{ service_error('Ombi', ombi.error) }} {{ service_error('Ombi', ombi.error) }}
{% else %} {% else %}
{#- Pending requests are the only thing here that wants a human, so a {#- 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). 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 <div class="tt-field {{ 'warn' if ombi.total_pending else '' }}">
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>Awaiting A Decision</span>
<span class="tt-field-meta"><span id="req-pending-meta">{{ ombi.total_pending }}</span> of {{ ombi.total_requests }}</span> <span class="tt-field-meta">{{ ombi.total_pending }} of {{ ombi.total_requests }}</span>
</div> </div>
<div class="tt-figs"> <div class="tt-figs">
<div class="tt-fig"> <div class="tt-fig">
<div class="tt-dh tt-fig-v {{ 'pending' if ombi.total_pending else 'ok' }}" id="req-pending">{{ ombi.total_pending }}</div> <div class="tt-dh tt-fig-v {{ 'pending' if ombi.total_pending else 'ok' }}">{{ ombi.total_pending }}</div>
<div class="tt-fig-l">Pending</div> <div class="tt-fig-l">Pending</div>
</div> </div>
<div class="tt-fig"> <div class="tt-fig">
@ -41,21 +39,6 @@
</span> </span>
{%- set st = (r.status or '') | lower -%} {%- 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> <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> </div>
{% endfor %} {% endfor %}
</div> </div>

View file

@ -1,5 +1,5 @@
{% extends "_shell.html" %} {% extends "_shell.html" %}
{% from "_macros.html" import service_error, bar, trend %} {% from "_macros.html" import service_error, bar, disk_state, disk_row, trend %}
{% block title %}P105 System{% endblock %} {% block title %}P105 System{% endblock %}
{% block content %} {% block content %}
@ -7,26 +7,108 @@
{#- ── Service transmission status ─────────────────────────────── {#- ── Service transmission status ───────────────────────────────
Six services, one row each, colour carrying up or down. This is Six services, one row each, colour carrying up or down. This is
what the old header pills were, given room to say something. Each what the old header pills were, given room to say something. -#}
row opens that service's own instance, via the reverse proxy. -#}
<div class="tt-field"><span>Transmission</span></div> <div class="tt-field"><span>Transmission</span></div>
<div class="tt-rows"> <div class="tt-rows">
{% for name, key, svc in [('Sonarr', 'sonarr', sonarr), ('Radarr', 'radarr', radarr), ('SABnzbd', 'sabnzbd', sabnzbd), {% for name, svc in [('Sonarr', sonarr), ('Radarr', radarr), ('SABnzbd', sabnzbd),
('Tautulli', 'tautulli', tautulli), ('Prowlarr', 'prowlarr', prowlarr), ('Ombi', 'ombi', ombi)] %} ('Tautulli', tautulli), ('Prowlarr', prowlarr), ('Ombi', ombi)] %}
<a class="tt-row" href="{{ service_urls[key] }}" target="_blank" rel="noopener"> <div class="tt-row">
<span class="tt-row-name">{{ name }} <span class="tt-row-ext" aria-hidden="true">&#8599;</span></span> <span class="tt-row-name">{{ name }}</span>
<span class="tt-row-body"></span> <span class="tt-row-body"></span>
<span class="tt-row-val {{ 'ok' if svc.status == 'ok' else 'alert' }}"> <span class="tt-row-val {{ 'ok' if svc.status == 'ok' else 'alert' }}">
{{- 'ON AIR' if svc.status == 'ok' else 'OFF AIR' -}} {{- 'ON AIR' if svc.status == 'ok' else 'OFF AIR' -}}
</span> </span>
</a> </div>
{% endfor %} {% endfor %}
</div> </div>
<div id="array-section"> {#- ── Array ───────────────────────────────────────────────────── -#}
{% include "_array_section.html" %} {% 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>
<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 %}
{#- ── Downloads ───────────────────────────────────────────────── {#- ── Downloads ─────────────────────────────────────────────────
The one place the service holds real history, so the one place a The one place the service holds real history, so the one place a
trend arrow is honest: current speed against the 7-day average. -#} trend arrow is honest: current speed against the 7-day average. -#}
@ -38,7 +120,7 @@
<div class="tt-fig-l">In queue</div> <div class="tt-fig-l">In queue</div>
</div> </div>
<div class="tt-fig"> <div class="tt-fig">
<div class="tt-dh tt-fig-v"><span id="sab-speed">{{ sabnzbd.speed | default('0') }}</span> {{ trend(sabnzbd.speed_kbps, sabnzbd.avg_speed_kbps) }}</div> <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-fig-l">Speed vs 7-day avg</div> <div class="tt-fig-l">Speed vs 7-day avg</div>
</div> </div>
<div class="tt-fig"> <div class="tt-fig">
@ -78,43 +160,4 @@
<div class="tt-field crit"><span>Indexers</span><span class="tt-field-meta">off air</span></div> <div class="tt-field crit"><span>Indexers</span><span class="tt-field-meta">off air</span></div>
{{ service_error('Prowlarr', prowlarr.error) }} {{ service_error('Prowlarr', prowlarr.error) }}
{% endif %} {% 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 %} {% endblock %}

View file

@ -11,29 +11,6 @@
{% else %} {% else %}
{%- set sessions = tautulli.sessions or [] -%} {%- 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 '' }}"> <div class="tt-field {{ 'warn' if sessions else '' }}">
<span>Streaming Now</span> <span>Streaming Now</span>
<span class="tt-field-meta">{{ tautulli.active_streams }} active</span> <span class="tt-field-meta">{{ tautulli.active_streams }} active</span>
@ -98,23 +75,5 @@
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% 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 %} {% endif %}
{% endblock %} {% endblock %}