Compare commits
No commits in common. "main" and "meridian-marquee" have entirely different histories.
main
...
meridian-m
18 changed files with 340 additions and 1199 deletions
|
|
@ -21,7 +21,9 @@
|
|||
"Bash(expect:*)",
|
||||
"Bash(bash export-to-unraid.sh root@192.168.86.33)",
|
||||
"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\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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
174
CLAUDE.md
|
|
@ -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
|
||||
4 hours. Active streams (Tautulli) and the SABnzbd queue are always
|
||||
fetched live, never cached — those need to be current-second accurate.
|
||||
- Ombi is the one service the app **writes** to: P104's Approve/Deny
|
||||
buttons post to `/api/ombi/decide`, which fans out to Ombi's four
|
||||
write endpoints via `ombi_write()`. Three things there are easy to
|
||||
get wrong: approve is a `POST` but deny is a `PUT`; a TV decision
|
||||
acts on the **child request** id, not the show, which is why
|
||||
`get_ombi_summary()` carries `child["id"]` for a TV row and
|
||||
`m["id"]` for a movie; and Ombi answers `200` with
|
||||
`{"result": false, "errorMessage": ...}` on a logical refusal
|
||||
("already approved"), so `ombi_write()` checks the body rather than
|
||||
trusting the status code. A successful decision clears
|
||||
`_ombi_cache` — the 4-hour TTL would otherwise keep calling the row
|
||||
pending for the rest of the afternoon.
|
||||
- Rotten Tomatoes / IMDB are scraped directly (`requests` +
|
||||
`BeautifulSoup`, JSON-LD parsing), not via an official API — brittle
|
||||
to upstream HTML changes by nature.
|
||||
|
|
@ -78,12 +66,7 @@ the user's Unraid home server. Server-side project directory is still named
|
|||
than a variation:
|
||||
1. **No in-between tone.** There are eight colours (the teletext
|
||||
primaries). A mid-grey is an ordered dither of two of them, never a
|
||||
ninth colour — except `--tt-orange`, the one deliberate, acknowledged
|
||||
break in `base.css`, added when the fastext nav ran out of legible
|
||||
colours for its eighth destination (Activity) and dithering the
|
||||
word itself proved illegible. Treat a *second* ninth colour showing
|
||||
up as the regression this discipline warns about; this one is a
|
||||
one-time, explained exception, not a precedent.
|
||||
ninth colour.
|
||||
2. **No enclosure.** Nothing is surrounded by a border or a box. There is
|
||||
zero `border-radius` in the codebase. Grouping is a coloured field row
|
||||
(`.tt-field`), indentation, and weight. This is the single thing the
|
||||
|
|
@ -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
|
||||
first inspection round caught.
|
||||
- **Navigation is the page number.** Typing any three digits anywhere
|
||||
jumps to that page, and `r`/`g`/`y`/`c` fire four of the fastext
|
||||
entries — both in `static/js/service.js`. `nav_items` in `app.py` is
|
||||
the source of truth for the numbers and is serialised into
|
||||
jumps to that page, and `r`/`g`/`y`/`c` fire the four fastext colour
|
||||
keys — both in `static/js/service.js`. `nav_items` in `app.py` is the
|
||||
source of truth for the numbers and is serialised into
|
||||
`<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
|
||||
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
|
||||
header is sticky.
|
||||
- **`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
|
||||
|
||||
See the `deploy-arr-summary` skill for the Forgejo push-to-deploy
|
||||
pipeline, manual container restart steps, deploy verification, and the
|
||||
Nginx Proxy Manager / TLS setup and its Cox-port-80/DNS-01 constraint.
|
||||
**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`.
|
||||
|
||||
**Every commit should be matched with a version change.** Bump
|
||||
`APP_VERSION` / `APP_VERSION_DATE` near the top of `app.py` as part of
|
||||
the same commit. This is what makes the footer (`v<APP_VERSION> ·
|
||||
<APP_VERSION_DATE>`) a trustworthy signal that a given deploy actually
|
||||
landed — a commit without a version bump can't be distinguished from
|
||||
"not deployed yet" by looking at the live site.
|
||||
**Deploy mechanism: Forgejo push-to-deploy**, same pattern as the other
|
||||
three projects on this box — see `card-grader`'s `CLAUDE.md` for the full
|
||||
pipeline mechanics. Push to `main` on the `forgejo` remote
|
||||
(`https://git.hippofam.com/ninja_hippo/arr-summary.git`) triggers
|
||||
`.forgejo/workflows/deploy.yml`, which syncs into
|
||||
`/mnt/user/appdata/greendale` (excluding `.git`, `.env`, `data/`) and runs
|
||||
`docker compose up -d --build`.
|
||||
|
||||
**Restarting the container manually: use Compose, not the Unraid Docker
|
||||
UI.** Every deploy recreates the container with a new Docker ID (same
|
||||
name, `arr-summary`). Unraid's Docker tab caches the old ID, so hitting
|
||||
Restart there after a deploy throws "No such container" — harmless, but
|
||||
confusing. From the Unraid terminal:
|
||||
```bash
|
||||
cd /mnt/user/appdata/greendale && docker compose restart
|
||||
```
|
||||
`docker compose up -d --force-recreate` for a guaranteed-clean recreate;
|
||||
`docker ps --filter name=arr-summary` to check real state regardless of
|
||||
what the Unraid UI shows.
|
||||
|
||||
**Confirming a deploy actually landed:** the dashboard footer renders
|
||||
`v<APP_VERSION> · <APP_VERSION_DATE>` from constants near the top of
|
||||
`app.py` (both env-overridable, injected into every template by the
|
||||
`inject_version` context processor). Bump them when cutting a deploy —
|
||||
loading the page is then the quickest honest check that the new build is
|
||||
live, without trusting the Unraid Docker tab's cached container IDs.
|
||||
|
||||
## Reverse proxy (Nginx Proxy Manager)
|
||||
|
||||
Two NPM proxy hosts front this app and `git.hippofam.com`, both using
|
||||
Let's Encrypt certs issued via **DNS-01 through Porkbun** — required,
|
||||
not optional: **Cox blocks inbound port 80** on this connection, so
|
||||
HTTP-01 challenges (and plain `http://` requests generally) can never
|
||||
work here. `http://hippofam.com` will always time out from outside the
|
||||
LAN; only `https://` resolves. Both proxy hosts have Force SSL on.
|
||||
|
||||
`hippofam.com` → `http://192.168.86.33:5055` (the Unraid box, this
|
||||
container's port). Verify the destination IP/port here before assuming
|
||||
an outage is DNS or app-level — it has drifted to the wrong host before.
|
||||
|
||||
On 2026-08-26 the apex host's own cert (issued Aug 2025) went dark: NPM's
|
||||
UI still showed it "In Use" with a valid, unexpired date, but nginx
|
||||
presented no certificate at all for `hippofam.com` (TLS alert 112,
|
||||
"unrecognized name") — the on-disk cert files had rotted independently
|
||||
of the DB record NPM's UI reads from. `git.hippofam.com`, on a newer
|
||||
cert from the same account, was unaffected. Fixed by pointing the proxy
|
||||
host at the `*.hippofam.com` + `hippofam.com` wildcard cert already
|
||||
issued and sitting unused, which forced NPM to regenerate the nginx
|
||||
config with known-good cert files. If `hippofam.com` ever throws a TLS
|
||||
handshake error again with NPM's UI reporting all green, suspect this
|
||||
same on-disk/DB drift before anything else — check with:
|
||||
```bash
|
||||
openssl s_client -connect <public-ip>:443 -servername hippofam.com </dev/null
|
||||
```
|
||||
A clean run shows `subject=/CN=...` and `Verify return code: 0`; the
|
||||
broken state shows `no peer certificate available`.
|
||||
|
||||
**Persistent state, never touched by deploys:** `.env` holds every API key
|
||||
and `LOGIN_PASSWORD` — this project was already doing this correctly
|
||||
before the Forgejo migration (compose file uses `${VAR}` substitution from
|
||||
`.env`, no hardcoded secrets), unlike `n64-tracker` which needed fixing.
|
||||
Runtime settings written by the Settings page (API-key overrides,
|
||||
timezone, changed login password) live in `settings.json` at
|
||||
`SETTINGS_FILE`, which the compose file points at `/data/settings.json`
|
||||
on the `./data` bind mount — i.e. `/mnt/user/appdata/greendale/data`.
|
||||
That mount is load-bearing: without it the file sits on the container
|
||||
filesystem, and every `up -d --build` recreates the container and
|
||||
silently discards it. `data/` is likewise excluded from the deploy
|
||||
rsync, which runs `--delete` and would otherwise remove it for not
|
||||
being in the repo.
|
||||
|
||||
## Logins
|
||||
|
||||
See the `manage-accounts` skill for the multi-user vs. legacy
|
||||
single-shared-password login modes and the `manage_users.py` CLI.
|
||||
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.
|
||||
|
||||
**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/`
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@ components:
|
|||
|
||||
Media Centre is a broadcast information service, not a dashboard. It refuses the bordered-card grid that every self-hosted *arr dashboard ships — including this one, until this redesign. The world is teletext: Ceefax and the American cable guide channel, fused. Eight colours at full saturation on pure black, one blocky monospace at exactly two heights, a character-cell grid, and grouping carried entirely by colour fields and row weight. The visitor types or clicks a page number, reads the machine's state off colour alone, and leaves knowing whether anything needs them.
|
||||
|
||||
The density is high and unapologetic. A teletext page was 40 columns; this one gets 92 (`--page-cols: 92ch`) because the screen allows it, but the character grid is the substrate everywhere — padding is measured in `ch`, bars are measured in cells, and nothing floats free of the row rhythm (`--row`, one character row). There is no chrome. The header is a row of the page, not furniture bolted above it, so it scrolls away with everything else. The fastext 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.
|
||||
|
||||
|
|
@ -181,7 +181,7 @@ Teletext had exactly eight colours, at full saturation, and the product's semant
|
|||
|
||||
### Named Rules
|
||||
|
||||
**The Locked Eight Rule.** Eight colours exist. Adding a ninth hue, or desaturating one of the eight to make it sit more politely, is a regression rather than a variation. If a new state needs a colour, it takes one of the eight and inherits that colour's meaning. **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.
|
||||
|
||||
|
|
|
|||
23
PRODUCT.md
23
PRODUCT.md
|
|
@ -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
|
||||
view.
|
||||
|
||||
**Multi-page restructure (shipped 2026-08-29, `meridian-marquee` merged
|
||||
to `main`):** the app split from one long single-page dashboard into
|
||||
focused pages — Home/P100, Watch, Library, Discover, Requests, System,
|
||||
Activity — each answering one question well rather than one long scroll
|
||||
trying to show everything. This also resolved cases where a content-heavy
|
||||
section (e.g. the per-disk storage list) would otherwise force awkward
|
||||
row heights on a shared "Right now" strip. `_shell.html` and
|
||||
`_macros.html` are the shared-layout scaffolding; `dashboard.html` (the
|
||||
old single-page template) was deleted, superseded by `home.html` + the
|
||||
per-page templates. Landed together with the visual redesign in
|
||||
Brand Commitments below — see DESIGN.md and
|
||||
`static/css/base.css`'s header comment for the visual system's own
|
||||
invariants.
|
||||
**In-progress restructure (branch `meridian-marquee`):** the app is
|
||||
splitting from one long single-page dashboard into focused pages — Home
|
||||
("marquee": what's playing, one line of system state, what's new),
|
||||
Watch, Library, Discover, Requests, System, Activity — each answering one
|
||||
question well rather than one long scroll trying to show everything.
|
||||
This also resolves cases where a content-heavy section (e.g. the per-disk
|
||||
storage list) would otherwise force awkward row heights on a shared
|
||||
"Right now" strip. `_shell.html` and `_macros.html` are new shared-layout
|
||||
scaffolding for this split; `dashboard.html` is the outgoing single-page
|
||||
template being superseded by `home.html` + the per-page templates.
|
||||
|
||||
Each page fetches only the services it needs (e.g. `/watch` fetches only
|
||||
Tautulli) rather than the old single route's all-services-at-once fetch —
|
||||
|
|
|
|||
468
app.py
468
app.py
|
|
@ -22,8 +22,8 @@ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
|||
# Docker tab caches stale container IDs, so the footer is the reliable way
|
||||
# to confirm a `docker compose up -d --build` actually took effect.
|
||||
# Both are env-overridable if the deploy pipeline ever injects them.
|
||||
APP_VERSION = os.environ.get("APP_VERSION", "2.3.1")
|
||||
APP_VERSION_DATE = os.environ.get("APP_VERSION_DATE", "2026-09-05")
|
||||
APP_VERSION = os.environ.get("APP_VERSION", "2.0.0")
|
||||
APP_VERSION_DATE = os.environ.get("APP_VERSION_DATE", "2026-08-29")
|
||||
|
||||
# Must point at a path that survives `docker compose up --build` — the
|
||||
# container filesystem does not. See the data volume in docker-compose.yml.
|
||||
|
|
@ -287,11 +287,6 @@ _sonarr_cache = {"data": None, "expires": 0}
|
|||
_radarr_cache = {"data": None, "expires": 0}
|
||||
ARR_CACHE_TTL = 5 * 60 # 5 minutes
|
||||
|
||||
# A show has to have sat in Sonarr longer than this, untouched, before the
|
||||
# Admin section will suggest deleting it — keeps something just downloaded
|
||||
# and not yet gotten to from showing up next to a delete button.
|
||||
ADMIN_UNWATCHED_MONTHS = 6
|
||||
|
||||
# Tautulli stats cache: top shows/movies/users/history refresh every 2 minutes.
|
||||
# Active streams (get_activity) are always fetched live.
|
||||
_tautulli_stats_cache = {"data": None, "expires": 0}
|
||||
|
|
@ -517,8 +512,7 @@ def _get_sab_history_cached():
|
|||
if duration > 0 and size_bytes > 0:
|
||||
speeds.append(size_bytes / duration)
|
||||
avg_bytes_per_sec = sum(speeds) / len(speeds) if speeds else 0
|
||||
avg_speed_kbps = avg_bytes_per_sec / 1024
|
||||
avg_speed = _format_speed(avg_speed_kbps)
|
||||
avg_speed = _format_speed(avg_bytes_per_sec / 1024)
|
||||
# Keep the newest completed items too — "Landed today" on the home
|
||||
# page and the activity feed both read these. Same request as before;
|
||||
# previously only the aggregates survived.
|
||||
|
|
@ -532,12 +526,12 @@ def _get_sab_history_cached():
|
|||
|
||||
result = {"week_size": week_size, "day_size": day_size,
|
||||
"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}
|
||||
return result
|
||||
except Exception:
|
||||
return {"week_size": "—", "day_size": "—", "completed_7d": "—",
|
||||
"avg_speed": "—", "avg_speed_kbps": None, "recent_items": []}
|
||||
"avg_speed": "—", "recent_items": []}
|
||||
|
||||
|
||||
def get_sabnzbd_summary():
|
||||
|
|
@ -571,7 +565,6 @@ def get_sabnzbd_summary():
|
|||
"week_size": hist["week_size"],
|
||||
"day_size": hist["day_size"],
|
||||
"avg_speed": hist["avg_speed"],
|
||||
"avg_speed_kbps": hist["avg_speed_kbps"],
|
||||
"recent_items": hist.get("recent_items", []),
|
||||
}
|
||||
except Exception as e:
|
||||
|
|
@ -617,7 +610,6 @@ def get_tautulli_summary():
|
|||
top_movie_list = cached["top_movie_list"]
|
||||
top_played = cached["top_played"]
|
||||
top_users = cached["top_users"]
|
||||
active_users = cached["active_users"]
|
||||
else:
|
||||
# Top 3 most watched TV shows (30d)
|
||||
tv_data = tautulli_get("get_home_stats", "&stat_id=top_tv&stats_count=3&stats_type=plays&time_range=30")
|
||||
|
|
@ -661,36 +653,9 @@ def get_tautulli_summary():
|
|||
if len(top_users) >= 3:
|
||||
break
|
||||
|
||||
# Active viewers (10d) — everyone with a play in the window, for
|
||||
# the Watch page's "click a name for their 48h history" list.
|
||||
# A dedicated call rather than reusing top_played/top_users
|
||||
# above: those are capped at 3 and tuned for different windows
|
||||
# (all-time-ish top-3, 30d top-10), neither of which is "who's
|
||||
# been around lately".
|
||||
cutoff_date = (datetime.now() - timedelta(days=10)).strftime("%Y-%m-%d")
|
||||
active_data = tautulli_get("get_history", f"&length=1000&order_column=date&order_dir=desc&after={cutoff_date}")
|
||||
active_slots = []
|
||||
if isinstance(active_data, dict):
|
||||
active_slots = active_data.get("data", [])
|
||||
if isinstance(active_slots, dict):
|
||||
active_slots = active_slots.get("data", [])
|
||||
active_by_user = {}
|
||||
for entry in active_slots:
|
||||
username = entry.get("friendly_name") or entry.get("user", "Unknown")
|
||||
if username.lower() == "ninja_hippo":
|
||||
continue
|
||||
rec = active_by_user.setdefault(username, {"username": username, "plays": 0, "last_watched_ts": 0})
|
||||
rec["plays"] += 1
|
||||
rec["last_watched_ts"] = max(rec["last_watched_ts"], entry.get("date", 0) or 0)
|
||||
active_users = sorted(active_by_user.values(), key=lambda x: x["last_watched_ts"], reverse=True)
|
||||
for u in active_users:
|
||||
u["last_watched"] = (datetime.fromtimestamp(u["last_watched_ts"]).strftime("%d %b · %H:%M")
|
||||
if u["last_watched_ts"] else "")
|
||||
|
||||
_tautulli_stats_cache = {
|
||||
"data": {"top_tv_list": top_tv_list, "top_movie_list": top_movie_list,
|
||||
"top_played": top_played, "top_users": top_users,
|
||||
"active_users": active_users},
|
||||
"top_played": top_played, "top_users": top_users},
|
||||
"expires": now + TAUTULLI_CACHE_TTL,
|
||||
}
|
||||
|
||||
|
|
@ -741,7 +706,6 @@ def get_tautulli_summary():
|
|||
"top_movie_list": top_movie_list,
|
||||
"top_played": top_played,
|
||||
"top_users": top_users,
|
||||
"active_users": active_users,
|
||||
"active_streams": stream_count,
|
||||
"sessions": sessions,
|
||||
}
|
||||
|
|
@ -749,140 +713,6 @@ def get_tautulli_summary():
|
|||
return {"status": "error", "error": str(e)}
|
||||
|
||||
|
||||
def get_user_watch_history(username, hours=48):
|
||||
"""One viewer's plays in the last `hours`, for the Watch page's
|
||||
per-user drill-down. Fetched on demand, not cached — this is a click
|
||||
target, not something every page load needs to pay for."""
|
||||
try:
|
||||
cutoff_ts = time.time() - hours * 3600
|
||||
encoded = requests.utils.quote(username)
|
||||
hist = tautulli_get("get_history", f"&user={encoded}&length=200&order_column=date&order_dir=desc")
|
||||
slots = []
|
||||
if isinstance(hist, dict):
|
||||
slots = hist.get("data", [])
|
||||
if isinstance(slots, dict):
|
||||
slots = slots.get("data", [])
|
||||
items = []
|
||||
for entry in slots:
|
||||
ts = entry.get("date", 0) or 0
|
||||
if ts < cutoff_ts:
|
||||
continue
|
||||
try:
|
||||
percent = int(float(entry.get("percent_complete", 0) or 0))
|
||||
except (TypeError, ValueError):
|
||||
percent = 0
|
||||
items.append({
|
||||
"title": entry.get("full_title") or entry.get("title", "Unknown"),
|
||||
"media_type": entry.get("media_type", ""),
|
||||
"percent": max(0, min(100, percent)),
|
||||
"when": datetime.fromtimestamp(ts).strftime("%d %b · %H:%M") if ts else "",
|
||||
})
|
||||
# Note: "items" collides with dict.items in Jinja attribute lookup
|
||||
# (getattr wins over __getitem__), so the template key is "entries".
|
||||
return {"status": "ok", "entries": items}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
|
||||
def _fmt_bytes(b):
|
||||
if b >= 1024**3:
|
||||
return f"{b / 1024**3:.1f} GB"
|
||||
return f"{b / 1024**2:.0f} MB"
|
||||
|
||||
|
||||
def get_admin_unwatched_shows(limit=10, months=ADMIN_UNWATCHED_MONTHS):
|
||||
"""Sonarr shows with episodes on disk that Tautulli has never played,
|
||||
largest first — the source list for P105's Admin section.
|
||||
|
||||
Excludes anything added to Sonarr within the last `months` months, so a
|
||||
show that's simply new and hasn't been gotten to yet doesn't turn up
|
||||
next to a delete button. Matches against Tautulli's full play history
|
||||
(a large `length`, not the 30/100-play windows the rest of the
|
||||
dashboard uses) because a recency-limited window could mismark a show
|
||||
that genuinely was watched — just a while ago — as never-watched,
|
||||
which matters a lot more here than elsewhere given what's attached to
|
||||
the wrong answer. Each show also carries whoever's Ombi request
|
||||
triggered it, best-effort by title match — a show added straight in
|
||||
Sonarr rather than requested has no requester to find.
|
||||
"""
|
||||
try:
|
||||
series_list = sonarr_series_cached()
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
try:
|
||||
hist = tautulli_get("get_history", "&length=5000&order_column=date&order_dir=desc")
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
played_titles = set()
|
||||
slots = hist.get("data", []) if isinstance(hist, dict) else []
|
||||
if isinstance(slots, dict):
|
||||
slots = slots.get("data", [])
|
||||
for entry in slots:
|
||||
t = entry.get("grandparent_title") or entry.get("full_title") or entry.get("title", "")
|
||||
if t:
|
||||
played_titles.add(t.lower())
|
||||
|
||||
cutoff = datetime.utcnow() - timedelta(days=30 * months)
|
||||
|
||||
never = []
|
||||
for s in series_list:
|
||||
stats = s.get("statistics") or {}
|
||||
size = stats.get("sizeOnDisk", 0) or s.get("sizeOnDisk", 0)
|
||||
if not size:
|
||||
continue
|
||||
title = s.get("title", "")
|
||||
if title.lower() in played_titles:
|
||||
continue
|
||||
added = s.get("added", "") or ""
|
||||
try:
|
||||
added_dt = datetime.strptime(added[:19], "%Y-%m-%dT%H:%M:%S")
|
||||
if added_dt > cutoff:
|
||||
continue # too recently added to fairly call it neglected
|
||||
except (ValueError, TypeError):
|
||||
pass # no usable added date — don't let a bad field hide a candidate
|
||||
never.append({
|
||||
"sonarr_id": s.get("id"),
|
||||
"title": title,
|
||||
"year": s.get("year", ""),
|
||||
"size": size,
|
||||
"ep_count": stats.get("episodeFileCount", 0),
|
||||
"added": added,
|
||||
})
|
||||
|
||||
never.sort(key=lambda x: x["size"], reverse=True)
|
||||
top = never[:limit]
|
||||
|
||||
# Sonarr doesn't know who asked for a show — Ombi does, via whoever's
|
||||
# request triggered the grab. Best-effort only: a show added straight
|
||||
# in Sonarr, without an Ombi request, has no requester to find, and an
|
||||
# unreachable Ombi shouldn't take down the rest of the section.
|
||||
requested_by = {}
|
||||
try:
|
||||
for show in ombi_get("Request/tv"):
|
||||
for child in show.get("childRequests", []):
|
||||
user = child.get("requestedUser", {})
|
||||
alias = (user.get("alias") or user.get("userAlias")
|
||||
or user.get("userName") or "").split("@")[0]
|
||||
if alias:
|
||||
requested_by.setdefault(show.get("title", "").lower(), alias)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for show in top:
|
||||
show["size_display"] = _fmt_bytes(show["size"])
|
||||
show["requested_by"] = requested_by.get(show["title"].lower(), "")
|
||||
total_bytes = sum(show["size"] for show in top)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"shows": top,
|
||||
"months": months,
|
||||
"total_display": _fmt_bytes(total_bytes) if top else "0 MB",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ombi
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -894,23 +724,6 @@ def ombi_get(endpoint):
|
|||
return resp.json()
|
||||
|
||||
|
||||
def ombi_write(method, endpoint, payload):
|
||||
"""Ombi's approve/deny endpoints answer 200 with
|
||||
{"result": false, "errorMessage": ...} on a logical refusal rather than
|
||||
an HTTP error, so the body has to be checked and not just the status."""
|
||||
headers = {"ApiKey": OMBI_API_KEY, "Content-Type": "application/json"}
|
||||
resp = requests.request(method, f"{OMBI_URL}/api/v1/{endpoint}",
|
||||
headers=headers, json=payload, timeout=10)
|
||||
resp.raise_for_status()
|
||||
try:
|
||||
body = resp.json()
|
||||
except ValueError:
|
||||
return {}
|
||||
if isinstance(body, dict) and body.get("result") is False:
|
||||
raise RuntimeError(body.get("errorMessage") or "Ombi refused the decision")
|
||||
return body
|
||||
|
||||
|
||||
def _ombi_status_label(available, approved, denied):
|
||||
if denied:
|
||||
return "Denied"
|
||||
|
|
@ -939,7 +752,6 @@ def get_ombi_summary():
|
|||
if alias.lower() == "ninja_hippo":
|
||||
continue
|
||||
all_requests.append({
|
||||
"id": m.get("id"),
|
||||
"title": m.get("title", "Unknown"),
|
||||
"type": "Movie",
|
||||
"requested_date": m.get("requestedDate", ""),
|
||||
|
|
@ -963,9 +775,6 @@ def get_ombi_summary():
|
|||
)
|
||||
denied = child.get("denied", False)
|
||||
all_requests.append({
|
||||
# Ombi's tv approve/deny act on the child request, not
|
||||
# the parent show, so this is the child's id.
|
||||
"id": child.get("id"),
|
||||
"title": show.get("title", "Unknown"),
|
||||
"type": "TV",
|
||||
"requested_date": child.get("requestedDate", ""),
|
||||
|
|
@ -1444,7 +1253,6 @@ PAGE_TASKS = {
|
|||
"unraid": lambda: get_unraid_stats(),
|
||||
"rt_movies": lambda: get_rt_movies(),
|
||||
"rt_tv": lambda: get_rt_tv(),
|
||||
"admin_unwatched": lambda: get_admin_unwatched_shows(),
|
||||
}
|
||||
|
||||
PAGE_TIMEOUT = 5.0
|
||||
|
|
@ -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
|
||||
def inject_page_chrome():
|
||||
"""Shell-wide context: the nav, the clock's timezone, service links."""
|
||||
|
|
@ -1551,12 +1321,15 @@ def inject_page_chrome():
|
|||
("activity", "106", "Activity", "/activity"),
|
||||
("settings", "900", "Settings", "/settings"),
|
||||
],
|
||||
# Every page, at the top of every page but the index — which
|
||||
# overrides this with HOME_FASTEXT, its own short closing row,
|
||||
# since its body already lists all of these once. The four
|
||||
# letter keys (r/g/y/c) keep their original destinations; the
|
||||
# rest have no keyboard shortcut, just a click.
|
||||
"fastext": FULL_FASTEXT,
|
||||
# The four coloured keys along the bottom of every page. A page
|
||||
# that wants different destinations passes its own `fastext` to
|
||||
# render_template, which wins over this default.
|
||||
"fastext": [
|
||||
("k-red", "r", "100", "Index", "/"),
|
||||
("k-green", "g", "102", "Library", "/library"),
|
||||
("k-yellow", "y", "103", "Discover", "/discover"),
|
||||
("k-cyan", "c", "105", "System", "/system"),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -1629,86 +1402,6 @@ def get_recently_added(limit=6):
|
|||
return items
|
||||
|
||||
|
||||
def search_library(q, limit=24):
|
||||
"""Titles in Sonarr/Radarr matching q, case-insensitive substring.
|
||||
|
||||
Filters the same cached lists get_recently_added uses — no new fetch.
|
||||
"""
|
||||
needle = q.strip().lower()
|
||||
if not needle:
|
||||
return []
|
||||
items = []
|
||||
try:
|
||||
for sr in sonarr_series_cached():
|
||||
if needle in sr.get("title", "").lower():
|
||||
items.append({
|
||||
"title": sr.get("title", "Unknown"),
|
||||
"kind": "TV",
|
||||
"year": str(sr.get("year", "")),
|
||||
"poster": _poster_url(sr.get("images")),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
for mv in radarr_movies_cached():
|
||||
if needle in mv.get("title", "").lower():
|
||||
items.append({
|
||||
"title": mv.get("title", "Unknown"),
|
||||
"kind": "Film",
|
||||
"year": str(mv.get("year", "")),
|
||||
"poster": _poster_url(mv.get("images")),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
items.sort(key=lambda x: x["title"].lower())
|
||||
return items[:limit]
|
||||
|
||||
|
||||
def arr_search(q, limit=12):
|
||||
"""Movies and TV matching q, straight from Radarr's and Sonarr's own
|
||||
lookup endpoints — the same ones /add-to-radarr and /add-to-sonarr
|
||||
already use to resolve a title before adding. No Ombi round trip and
|
||||
no second title-matching path to keep in sync with the Add buttons.
|
||||
"""
|
||||
term = q.strip()
|
||||
if not term:
|
||||
return {"status": "ok", "results": []}
|
||||
encoded = requests.utils.quote(term)
|
||||
results = []
|
||||
radarr_ok = sonarr_ok = True
|
||||
try:
|
||||
radarr_titles = {t.lower() for t in get_radarr_summary().get("radarr_titles", [])}
|
||||
for m in radarr_get(f"movie/lookup?term={encoded}")[:limit]:
|
||||
title = m.get("title", "")
|
||||
results.append({
|
||||
"title": title,
|
||||
"year": str(m.get("year", "")),
|
||||
"poster": _poster_url(m.get("images")),
|
||||
"kind": "Film",
|
||||
"target": "radarr",
|
||||
"held": title.lower() in radarr_titles,
|
||||
})
|
||||
except Exception:
|
||||
radarr_ok = False
|
||||
try:
|
||||
sonarr_titles = {t.lower() for t in get_sonarr_summary().get("sonarr_titles", [])}
|
||||
for s in sonarr_get(f"series/lookup?term={encoded}")[:limit]:
|
||||
title = s.get("title", "")
|
||||
results.append({
|
||||
"title": title,
|
||||
"year": str(s.get("year", "")),
|
||||
"poster": _poster_url(s.get("images")),
|
||||
"kind": "TV",
|
||||
"target": "sonarr",
|
||||
"held": title.lower() in sonarr_titles,
|
||||
})
|
||||
except Exception:
|
||||
sonarr_ok = False
|
||||
if not results and not radarr_ok and not sonarr_ok:
|
||||
return {"status": "error", "error": "Sonarr and Radarr both unreachable", "results": []}
|
||||
return {"status": "ok", "results": results}
|
||||
|
||||
|
||||
def get_activity_feed(sabnzbd, ombi, limit=25):
|
||||
"""A single chronological feed assembled from what the fetchers return.
|
||||
|
||||
|
|
@ -1787,7 +1480,7 @@ def home():
|
|||
others = [s for s in sessions if s is not hero]
|
||||
|
||||
return render_template(
|
||||
"home.html", page="home", fastext=HOME_FASTEXT,
|
||||
"home.html", page="home",
|
||||
sonarr=sonarr, radarr=radarr, sabnzbd=r["sabnzbd"], tautulli=tautulli,
|
||||
prowlarr=r["prowlarr"], ombi=r["ombi"], unraid=r["unraid"],
|
||||
hero=hero, other_sessions=others,
|
||||
|
|
@ -1806,21 +1499,16 @@ def home():
|
|||
@login_required
|
||||
def watch_page():
|
||||
r = fetch_services(["tautulli"])
|
||||
selected_user = request.args.get("user", "").strip()
|
||||
user_history = get_user_watch_history(selected_user) if selected_user else None
|
||||
return render_template("watch.html", page="watch", tautulli=r["tautulli"],
|
||||
selected_user=selected_user, user_history=user_history)
|
||||
return render_template("watch.html", page="watch", tautulli=r["tautulli"])
|
||||
|
||||
|
||||
@app.route("/library")
|
||||
@login_required
|
||||
def library_page():
|
||||
r = fetch_services(["sonarr", "radarr"])
|
||||
q = request.args.get("q", "").strip()
|
||||
return render_template("library.html", page="library",
|
||||
sonarr=r["sonarr"], radarr=r["radarr"],
|
||||
recently_added=get_recently_added(12),
|
||||
q=q, search_results=search_library(q) if q else None)
|
||||
recently_added=get_recently_added(12))
|
||||
|
||||
|
||||
@app.route("/discover")
|
||||
|
|
@ -1829,11 +1517,9 @@ def discover_page():
|
|||
r = fetch_services(["sonarr", "radarr", "rt_movies", "rt_tv"])
|
||||
sonarr_titles = set(r["sonarr"].get("sonarr_titles", []))
|
||||
radarr_titles = set(r["radarr"].get("radarr_titles", []))
|
||||
q = request.args.get("q", "").strip()
|
||||
return render_template("discover.html", page="discover",
|
||||
rt_movies=_filter_rt(r["rt_movies"], radarr_titles),
|
||||
rt_tv=_filter_rt(r["rt_tv"], sonarr_titles),
|
||||
q=q, search=arr_search(q) if q else None)
|
||||
rt_tv=_filter_rt(r["rt_tv"], sonarr_titles))
|
||||
|
||||
|
||||
@app.route("/requests")
|
||||
|
|
@ -1847,24 +1533,12 @@ def requests_page():
|
|||
@login_required
|
||||
def system_page():
|
||||
r = fetch_services(["unraid", "prowlarr", "sonarr", "radarr",
|
||||
"sabnzbd", "tautulli", "ombi", "admin_unwatched"])
|
||||
"sabnzbd", "tautulli", "ombi"])
|
||||
return render_template("system.html", page="system",
|
||||
unraid=r["unraid"], prowlarr=r["prowlarr"],
|
||||
sonarr=r["sonarr"], radarr=r["radarr"],
|
||||
sabnzbd=r["sabnzbd"], tautulli=r["tautulli"],
|
||||
ombi=r["ombi"], admin_unwatched=r["admin_unwatched"])
|
||||
|
||||
|
||||
@app.route("/api/array/refresh")
|
||||
@login_required
|
||||
def api_array_refresh():
|
||||
"""Re-renders just the Array section's markup for P105's refresh
|
||||
button. Unraid's stats aren't cached (get_unraid_stats hits the
|
||||
GraphQL API live on every call), so this is a plain re-fetch — the
|
||||
button exists for the visitor's own peace of mind, not to bypass a
|
||||
cache. Returns HTML, not JSON: swapped directly into #array-section
|
||||
rather than re-implementing the table/bar/disk-row markup in JS."""
|
||||
return render_template("_array_section.html", unraid=get_unraid_stats())
|
||||
ombi=r["ombi"])
|
||||
|
||||
|
||||
@app.route("/activity")
|
||||
|
|
@ -1942,40 +1616,6 @@ def api_ombi():
|
|||
return jsonify(get_ombi_summary())
|
||||
|
||||
|
||||
@app.route("/api/ombi/decide", methods=["POST"])
|
||||
@login_required
|
||||
def api_ombi_decide():
|
||||
"""Approve or deny one pending request from P104.
|
||||
|
||||
Ombi splits this four ways — movie/tv x approve/deny — and approve is a
|
||||
POST while deny is a PUT, so both halves are named explicitly rather
|
||||
than derived."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
req_id = data.get("id")
|
||||
kind = (data.get("type") or "").lower()
|
||||
verdict = (data.get("verdict") or "").lower()
|
||||
if not req_id:
|
||||
return jsonify({"error": "No request id"}), 400
|
||||
if kind not in ("movie", "tv"):
|
||||
return jsonify({"error": "Unknown request type"}), 400
|
||||
if verdict not in ("approve", "deny"):
|
||||
return jsonify({"error": "Unknown verdict"}), 400
|
||||
try:
|
||||
if verdict == "approve":
|
||||
ombi_write("POST", f"Request/{kind}/approve", {"id": req_id})
|
||||
else:
|
||||
ombi_write("PUT", f"Request/{kind}/deny", {"id": req_id, "reason": ""})
|
||||
# Without this the 4-hour cache keeps calling the row pending for the
|
||||
# rest of the afternoon, and the page contradicts the decision just
|
||||
# made on it.
|
||||
_ombi_cache["data"] = None
|
||||
_ombi_cache["expires"] = 0
|
||||
return jsonify({"success": True,
|
||||
"status": "Approved" if verdict == "approve" else "Denied"})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/services")
|
||||
@login_required
|
||||
def api_services():
|
||||
|
|
@ -2023,15 +1663,57 @@ def api_services():
|
|||
@app.route("/api/tautulli/never-watched")
|
||||
@login_required
|
||||
def api_never_watched():
|
||||
"""Sonarr shows that have never been played in Tautulli — see the
|
||||
Admin section on P105 (`get_admin_unwatched_shows`), which this
|
||||
delegates to rather than duplicating the match/sort logic."""
|
||||
months = request.args.get("months", default=ADMIN_UNWATCHED_MONTHS, type=int)
|
||||
limit = request.args.get("limit", default=30, type=int)
|
||||
result = get_admin_unwatched_shows(limit=limit, months=months)
|
||||
if result.get("status") != "ok":
|
||||
return jsonify(result), 500
|
||||
return jsonify(result)
|
||||
"""Movies and shows in Radarr/Sonarr that have never been played in Tautulli."""
|
||||
try:
|
||||
# Get all history titles from Tautulli (last 500 entries)
|
||||
hist = tautulli_get("get_history", "&length=500&order_column=date&order_dir=desc")
|
||||
played_titles = set()
|
||||
if isinstance(hist, dict):
|
||||
slots = hist.get("data", [])
|
||||
if isinstance(slots, dict):
|
||||
slots = slots.get("data", [])
|
||||
for s in slots:
|
||||
t = s.get("full_title") or s.get("title") or s.get("parent_title", "")
|
||||
if t:
|
||||
played_titles.add(t.lower())
|
||||
|
||||
never = []
|
||||
|
||||
# Sonarr series with episodes on disk, never played
|
||||
try:
|
||||
series_list = sonarr_get("series")
|
||||
for s in series_list:
|
||||
size = s.get("sizeOnDisk", 0)
|
||||
if size == 0:
|
||||
continue
|
||||
title = s.get("title", "")
|
||||
if title.lower() not in played_titles:
|
||||
never.append({
|
||||
"type": "show",
|
||||
"title": title,
|
||||
"year": s.get("year", ""),
|
||||
"size": size,
|
||||
"sonarr_id": s.get("id"),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Sort by size descending
|
||||
never.sort(key=lambda x: x["size"], reverse=True)
|
||||
|
||||
# Format size
|
||||
def fmt(b):
|
||||
gb = b / (1024**3)
|
||||
if gb >= 1:
|
||||
return f"{gb:.1f} GB"
|
||||
return f"{b / (1024**2):.0f} MB"
|
||||
|
||||
for item in never:
|
||||
item["size_display"] = fmt(item["size"])
|
||||
|
||||
return jsonify({"status": "ok", "items": never[:30]})
|
||||
except Exception as e:
|
||||
return jsonify({"status": "error", "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/delete/movie", methods=["POST"])
|
||||
|
|
@ -2048,8 +1730,6 @@ def api_delete_movie():
|
|||
headers=headers, timeout=15
|
||||
)
|
||||
resp.raise_for_status()
|
||||
_radarr_cache["data"] = None
|
||||
_radarr_cache["expires"] = 0
|
||||
return jsonify({"success": True})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
|
@ -2069,8 +1749,6 @@ def api_delete_show():
|
|||
headers=headers, timeout=15
|
||||
)
|
||||
resp.raise_for_status()
|
||||
_sonarr_cache["data"] = None
|
||||
_sonarr_cache["expires"] = 0
|
||||
return jsonify({"success": True})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
|
@ -2080,7 +1758,7 @@ def api_delete_show():
|
|||
@login_required
|
||||
def settings_page():
|
||||
s = load_settings()
|
||||
return render_template("settings.html", page="settings",
|
||||
return render_template("settings.html",
|
||||
timezone=s.get("timezone", "America/New_York"),
|
||||
sonarr_api_key=SONARR_API_KEY,
|
||||
radarr_api_key=RADARR_API_KEY,
|
||||
|
|
|
|||
|
|
@ -61,14 +61,6 @@
|
|||
--tt-cyan: #00ffff;
|
||||
--tt-white: #ffffff;
|
||||
|
||||
/* A deliberate, acknowledged ninth: the fastext nav has eight
|
||||
destinations and only five of the locked eight read as legible
|
||||
text on black once red/green/yellow/cyan/magenta/blue are already
|
||||
spoken for (Activity ran out — a dithered swatch alone read as
|
||||
unfinished, and dithering the word itself was tried and found
|
||||
illegible). Not "one of the eight" — used nowhere else. */
|
||||
--tt-orange: #ff8000;
|
||||
|
||||
/* ── Roles ─────────────────────────────────────────────
|
||||
Each colour means one thing across all nine pages. */
|
||||
--ink: var(--tt-white); /* body text */
|
||||
|
|
|
|||
|
|
@ -8,26 +8,6 @@
|
|||
RULER (every bar is --bar-cells wide, no exceptions).
|
||||
═══════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── Skip-link ──────────────────────────────────────────
|
||||
Off-screen until it receives focus, then it's a plain field row
|
||||
like any other — a solid colour ground, no border — sitting above
|
||||
everything else so tabbing past it reaches the header next. */
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
top: -999px;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
padding: 0.5em 1ch;
|
||||
background: var(--field);
|
||||
color: var(--ink);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
}
|
||||
.skip-link:focus {
|
||||
top: 0;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* ── Header row ─────────────────────────────────────────
|
||||
Teletext's top line, unchanged for forty years: the page
|
||||
number you are on, who is transmitting, and the clock. It
|
||||
|
|
@ -53,6 +33,7 @@
|
|||
text-transform: uppercase;
|
||||
}
|
||||
.tt-head-clock {
|
||||
margin-left: auto;
|
||||
color: var(--live);
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
|
|
@ -64,16 +45,6 @@
|
|||
letter-spacing: 0.3em;
|
||||
}
|
||||
.tt-entry[hidden] { display: none; }
|
||||
/* SABnzbd's queue, live on every page — filled in by service.js, empty
|
||||
(and so invisible) until its first poll returns. Quiet ink when
|
||||
idle; the live colour only while something is actually moving. */
|
||||
.tt-head-dl {
|
||||
margin-left: auto;
|
||||
color: var(--ink-dim);
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
.tt-head-dl.live { color: var(--live); }
|
||||
|
||||
/* ── Page ───────────────────────────────────────────────
|
||||
One measure for every page in the service. Nothing is ever
|
||||
|
|
@ -187,10 +158,6 @@ a.tt-row:focus-visible .tt-row-sub { color: var(--tt-black); }
|
|||
text-transform: uppercase;
|
||||
min-width: 11ch;
|
||||
}
|
||||
/* Marks a row that leaves the app (System's Transmission links to each
|
||||
service's own UI) — everywhere else a .tt-row stays inside Media
|
||||
Centre, so leaving needs its own quiet signal. */
|
||||
.tt-row-ext { color: var(--ink-dim); }
|
||||
.tt-row-body { flex: 1; min-width: 0; }
|
||||
.tt-row-title {
|
||||
overflow-wrap: anywhere;
|
||||
|
|
@ -205,13 +172,6 @@ a.tt-row:focus-visible .tt-row-sub { color: var(--tt-black); }
|
|||
color: var(--live);
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* The verdicts sit after the status they resolve, so the row reads
|
||||
state-then-decision. Only pending rows carry one. */
|
||||
.tt-row-acts {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
gap: 1ch;
|
||||
}
|
||||
.tt-row-val.ok { color: var(--ok); }
|
||||
.tt-row-val.alert { color: var(--alert); }
|
||||
.tt-row-val.warn { color: var(--voice); }
|
||||
|
|
@ -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-sm { min-height: calc(var(--row) * 1.6); width: 100%; }
|
||||
|
||||
/* The two verdicts on a pending request (P104). Quiet until armed, then
|
||||
each reverses into its own meaning rather than the shared red of
|
||||
.tt-btn.armed — green is the go-ahead, red is the refusal — so the
|
||||
confirming press says which verdict it is about to send and not merely
|
||||
that something is live. Both come after .tt-btn.armed to win on
|
||||
source order at equal specificity. */
|
||||
.tt-btn-yes, .tt-btn-no {
|
||||
background: transparent;
|
||||
padding: 0 1ch;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
.tt-btn-yes { color: var(--ok); }
|
||||
.tt-btn-no { color: var(--alert); }
|
||||
.tt-btn-yes:hover:not(:disabled),
|
||||
.tt-btn-yes.armed { background: var(--ok); color: var(--tt-black); }
|
||||
.tt-btn-no:hover:not(:disabled),
|
||||
.tt-btn-no.armed { background: var(--alert); color: var(--tt-black); }
|
||||
|
||||
.tt-input {
|
||||
min-height: var(--tap);
|
||||
padding: 0 1ch;
|
||||
|
|
@ -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); }
|
||||
|
||||
/* ── Fastext ────────────────────────────────────────────
|
||||
The four coloured keys. Real navigation, not decoration:
|
||||
click one, or press its initial. Deliberately NOT fixed to
|
||||
the viewport — it is a row of the page, where it always
|
||||
was, not furniture bolted over it. On the index it closes
|
||||
the page, under Now Showing/Just Added, because the index's
|
||||
own body is already the full nav. Everywhere else — where
|
||||
these four keys are the only nav on the page — it opens the
|
||||
page, right under the header, so it doesn't need a scroll
|
||||
to reach. */
|
||||
The four coloured keys along the bottom of every teletext
|
||||
page. Real navigation, not decoration: click one, or press
|
||||
its initial. Deliberately NOT fixed to the viewport — it is
|
||||
the last row of the page, where it always was. */
|
||||
.fastext {
|
||||
display: grid;
|
||||
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);
|
||||
gap: 1ch;
|
||||
}
|
||||
.fastext-top {
|
||||
margin: var(--row) auto calc(var(--row) * 1.5);
|
||||
}
|
||||
.fastext a, .fastext-here {
|
||||
.fastext a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
|
|
@ -557,39 +491,24 @@ a.tt-row:focus-visible .tt-row-sub { color: var(--tt-black); }
|
|||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
/* The key's identity is carried by a cell at its head — the physical
|
||||
coloured button on the remote. */
|
||||
.fastext a > span, .fastext-here > span {
|
||||
/* The key's colour is carried by a solid cell at its head — the
|
||||
physical coloured button on the remote. */
|
||||
.fastext a > span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.fastext a::before, .fastext-here::before {
|
||||
.fastext a::before {
|
||||
content: "";
|
||||
flex: 0 0 auto;
|
||||
width: 1.2ch;
|
||||
height: 1em;
|
||||
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-green { color: var(--ok); }
|
||||
.fastext .k-yellow { color: var(--voice); }
|
||||
.fastext .k-cyan { color: var(--live); }
|
||||
.fastext .k-magenta { color: var(--pending); }
|
||||
/* Pure blue on black is ~2.4:1 contrast — well under the 4.5:1 every
|
||||
other body text in this app clears — but the word carries the
|
||||
colour like every other key here, at the visitor's request. */
|
||||
.fastext .k-blue { color: var(--field); }
|
||||
/* --tt-orange is a genuine ninth colour, not one of the locked eight —
|
||||
see its definition in base.css for why the palette ran out here. */
|
||||
.fastext .k-orange { color: var(--tt-orange); }
|
||||
.fastext .k-red { color: var(--alert); }
|
||||
.fastext .k-green { color: var(--ok); }
|
||||
.fastext .k-yellow { color: var(--voice); }
|
||||
.fastext .k-cyan { color: var(--live); }
|
||||
.fastext a:hover, .fastext a:focus-visible {
|
||||
background: currentColor;
|
||||
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-val { margin-left: auto; white-space: normal; }
|
||||
/* Name and status hold line one, the description takes line two, and
|
||||
the verdicts take a full line of their own — at this width they
|
||||
cannot share a line with the status without either one shrinking
|
||||
below the tap target. */
|
||||
.tt-row-acts {
|
||||
flex: 1 0 100%;
|
||||
order: 4;
|
||||
justify-content: flex-end;
|
||||
margin-top: 0.35em;
|
||||
}
|
||||
.tt-head-name { letter-spacing: 0.12em; }
|
||||
.tt-head-dl { margin-left: 0; flex-basis: 100%; }
|
||||
.tt-head-clock { margin-left: 0; flex-basis: 100%; }
|
||||
|
||||
/* The System disk table swaps for its row reflow here — see the
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@
|
|||
function disarm() {
|
||||
if (!armed) return;
|
||||
armed.classList.remove('armed');
|
||||
if (armed.dataset.label) armed.textContent = armed.dataset.label;
|
||||
armed.textContent = armed.dataset.label || '+ ADD';
|
||||
armed = null;
|
||||
}
|
||||
window.armAdd = function (btn) {
|
||||
|
|
@ -129,7 +129,7 @@
|
|||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
if (d && d.success) {
|
||||
if (d && d.ok) {
|
||||
btn.classList.add('done');
|
||||
btn.textContent = 'ADDED';
|
||||
} else {
|
||||
|
|
@ -152,195 +152,22 @@
|
|||
btn.textContent = 'CONFIRM?';
|
||||
armed = btn;
|
||||
};
|
||||
/* ── Request verdicts (P104) ──────────────────────────
|
||||
Approve and Deny share the two-tap arming above — and share its
|
||||
`armed` slot, so arming one disarms the other rather than leaving
|
||||
two live verdicts on the same row. An approval starts a download
|
||||
and a denial is somebody's answer; neither wants a stray tap.
|
||||
|
||||
The page keeps its own count honest after a decision. Leaving the
|
||||
Pending figure at its server-rendered value would have the page
|
||||
contradicting a row the visitor just resolved. */
|
||||
function settlePending() {
|
||||
var fig = document.getElementById('req-pending');
|
||||
var meta = document.getElementById('req-pending-meta');
|
||||
var field = document.getElementById('req-field');
|
||||
if (!fig) return;
|
||||
var n = parseInt(fig.textContent, 10);
|
||||
if (isNaN(n) || n <= 0) return;
|
||||
n -= 1;
|
||||
fig.textContent = n;
|
||||
if (meta) meta.textContent = n;
|
||||
if (n === 0) {
|
||||
// Nothing waits on a human any more, so the page stops being heavy.
|
||||
fig.classList.remove('pending');
|
||||
fig.classList.add('ok');
|
||||
if (field) field.classList.remove('warn');
|
||||
}
|
||||
}
|
||||
|
||||
window.armDecision = function (btn) {
|
||||
if (btn.disabled) return;
|
||||
if (armed && armed !== btn) disarm();
|
||||
|
||||
if (armed === btn) {
|
||||
var acts = btn.parentNode;
|
||||
var row = btn.closest('.tt-row');
|
||||
var verdict = btn.dataset.verdict;
|
||||
var sibs = acts.querySelectorAll('button');
|
||||
var i;
|
||||
for (i = 0; i < sibs.length; i++) { sibs[i].disabled = true; }
|
||||
btn.classList.remove('armed');
|
||||
btn.textContent = 'SENDING';
|
||||
armed = null;
|
||||
|
||||
fetch(BASE + '/api/ombi/decide', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: btn.dataset.id,
|
||||
type: btn.dataset.type,
|
||||
verdict: verdict
|
||||
})
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
if (d && d.success) {
|
||||
var val = row && row.querySelector('.tt-row-val');
|
||||
if (val) {
|
||||
val.className = 'tt-row-val ' + (verdict === 'approve' ? 'ok' : 'alert');
|
||||
val.textContent = (d.status || verdict).toUpperCase();
|
||||
}
|
||||
// The row is settled: the verdicts have nothing left to offer.
|
||||
if (acts.parentNode) acts.parentNode.removeChild(acts);
|
||||
settlePending();
|
||||
} else {
|
||||
for (var j = 0; j < sibs.length; j++) { sibs[j].disabled = false; }
|
||||
btn.textContent = 'FAILED';
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
for (var k = 0; k < sibs.length; k++) { sibs[k].disabled = false; }
|
||||
btn.textContent = 'FAILED';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Captured once, from the server-rendered text — a retry after a
|
||||
// FAILED must still disarm back to "Approve", not to "FAILED".
|
||||
if (!btn.dataset.label) btn.dataset.label = btn.textContent.trim();
|
||||
btn.classList.add('armed');
|
||||
btn.textContent = 'CONFIRM?';
|
||||
armed = btn;
|
||||
};
|
||||
|
||||
/* ── Admin: delete unwatched (P105) ───────────────────
|
||||
Removing a series from Sonarr with its files is permanent,
|
||||
so it shares the same two-tap arming as everything else here
|
||||
rather than a native confirm(). Each row carries its own byte
|
||||
count, so the reclaimable total in the field meta can be
|
||||
recomputed client-side after a delete instead of going stale
|
||||
until the next page load. */
|
||||
function fmtBytes(b) {
|
||||
if (b >= 1073741824) return (b / 1073741824).toFixed(1) + ' GB';
|
||||
return (b / 1048576).toFixed(0) + ' MB';
|
||||
}
|
||||
|
||||
function settleAdmin() {
|
||||
var list = document.getElementById('admin-rows');
|
||||
var meta = document.getElementById('admin-meta');
|
||||
var rows = list ? list.querySelectorAll('.tt-row') : [];
|
||||
var total = 0;
|
||||
for (var i = 0; i < rows.length; i++) total += parseInt(rows[i].dataset.bytes, 10) || 0;
|
||||
if (meta) meta.textContent = rows.length + ' unwatched · ' + fmtBytes(total) + ' reclaimable';
|
||||
if (rows.length === 0 && list) {
|
||||
var empty = document.createElement('p');
|
||||
empty.className = 'tt-empty';
|
||||
empty.id = 'admin-empty';
|
||||
empty.textContent = 'Nothing left to reclaim.';
|
||||
list.parentNode.replaceChild(empty, list);
|
||||
}
|
||||
}
|
||||
|
||||
window.armDelete = function (btn) {
|
||||
if (btn.disabled) return;
|
||||
if (armed && armed !== btn) disarm();
|
||||
|
||||
if (armed === btn) {
|
||||
var row = btn.closest('.tt-row');
|
||||
btn.disabled = true;
|
||||
btn.classList.remove('armed');
|
||||
btn.textContent = 'DELETING';
|
||||
armed = null;
|
||||
|
||||
fetch(BASE + '/api/delete/show', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sonarr_id: btn.dataset.id })
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
if (d && d.success) {
|
||||
if (row && row.parentNode) row.parentNode.removeChild(row);
|
||||
settleAdmin();
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
btn.classList.add('armed');
|
||||
btn.textContent = 'FAILED';
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
btn.disabled = false;
|
||||
btn.classList.add('armed');
|
||||
btn.textContent = 'FAILED';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
btn.dataset.label = btn.textContent.trim();
|
||||
btn.classList.add('armed');
|
||||
btn.textContent = 'CONFIRM?';
|
||||
armed = btn;
|
||||
};
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
if (armed && !armed.contains(e.target)) disarm();
|
||||
});
|
||||
|
||||
/* ── Array refresh (P105) ─────────────────────────────
|
||||
Unraid's stats are fetched live already, never cached server-side
|
||||
(see get_unraid_stats), so this button doesn't bypass a cache —
|
||||
it exists so the section can be re-fetched without a full page
|
||||
reload, for the visitor's own peace of mind that it's current. */
|
||||
window.refreshArray = function (btn) {
|
||||
if (btn.disabled) return;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'REFRESHING';
|
||||
fetch(BASE + '/api/array/refresh')
|
||||
.then(function (r) { return r.text(); })
|
||||
.then(function (html) {
|
||||
var section = document.getElementById('array-section');
|
||||
if (section) section.innerHTML = html;
|
||||
})
|
||||
.catch(function () {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'FAILED';
|
||||
});
|
||||
};
|
||||
|
||||
/* ── Live figures ─────────────────────────────────────
|
||||
The queue and the active streams are the only two numbers
|
||||
that must be accurate to the second, so they are the only
|
||||
two that poll. Everything else is cached server-side and
|
||||
arrives with the page. The queue also drives the header's
|
||||
download row, present on every page, so it polls
|
||||
unconditionally rather than only where #sab-queue exists. */
|
||||
arrives with the page. */
|
||||
function setText(id, value) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.textContent = value;
|
||||
}
|
||||
|
||||
function pollSab() {
|
||||
if (!document.getElementById('sab-queue')) return;
|
||||
fetch(BASE + '/api/sabnzbd')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
|
|
@ -348,14 +175,6 @@
|
|||
setText('sab-queue', d.queue_count);
|
||||
setText('sab-speed', d.speed || '0');
|
||||
setText('sab-remaining', d.sizeleft || '—');
|
||||
var dl = document.getElementById('head-dl');
|
||||
if (dl) {
|
||||
var active = d.queue_count > 0;
|
||||
dl.textContent = active
|
||||
? d.queue_count + ' IN QUEUE · ' + (d.speed || '0')
|
||||
: '0 IN QUEUE';
|
||||
dl.classList.toggle('live', active);
|
||||
}
|
||||
})
|
||||
.catch(function () { /* offline is the dev baseline, not an error */ });
|
||||
}
|
||||
|
|
@ -371,8 +190,10 @@
|
|||
.catch(function () { /* as above */ });
|
||||
}
|
||||
|
||||
pollSab();
|
||||
setInterval(pollSab, 5000);
|
||||
if (document.getElementById('sab-queue')) {
|
||||
pollSab();
|
||||
setInterval(pollSab, 5000);
|
||||
}
|
||||
if (document.getElementById('tt-streams')) {
|
||||
pollTautulli();
|
||||
setInterval(pollTautulli, 15000);
|
||||
|
|
|
|||
|
|
@ -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 %} · parity
|
||||
{%- for p in unraid.parities %} {{ p.temp if p.temp is not none else '—' }}°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 %}
|
||||
|
|
@ -17,12 +17,6 @@
|
|||
</head>
|
||||
<body data-timezone="{{ timezone }}" data-url-base="{{ request.script_root }}"
|
||||
data-pages='{{ nav_items | tojson }}'>
|
||||
{#- Skip-link: every page but the index now opens with a full nav
|
||||
block before any real content — a keyboard or screen-reader visitor
|
||||
would otherwise tab through all eight destinations on every single
|
||||
visit. Off-screen until focused, then it's the first thing tabbing
|
||||
into the page reaches. -#}
|
||||
<a class="skip-link" href="#main-content">Skip to content</a>
|
||||
<!--
|
||||
THESIS: Media Centre is a broadcast information service, not a dashboard. It
|
||||
refuses the bordered-card grid every self-hosted dashboard ships, this one
|
||||
|
|
@ -48,40 +42,22 @@ provenance.
|
|||
<span class="tt-head-num">P{{ here[1] }}</span>
|
||||
<span class="tt-head-name">{{ here[2] }}</span>
|
||||
<span class="tt-entry" id="page-entry" hidden aria-live="polite"></span>
|
||||
{#- SABnzbd's queue, on every page — the same live /api/sabnzbd poll
|
||||
Activity and System already use, not a server-rendered fetch, so
|
||||
an unreachable SABnzbd never adds latency to a page that doesn't
|
||||
otherwise need it. Starts blank and fills in within a second. -#}
|
||||
<span class="tt-head-dl" id="head-dl" aria-live="polite"></span>
|
||||
<span class="tt-head-clock" id="head-clock"></span>
|
||||
</header>
|
||||
|
||||
{#- Fastext: the four coloured keys. Each page names its own four, so the
|
||||
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">
|
||||
<main class="tt-page">
|
||||
{% block content %}{% endblock %}
|
||||
</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">
|
||||
<span>MEDIA CENTRE</span>
|
||||
|
|
|
|||
|
|
@ -1,48 +1,14 @@
|
|||
{% extends "_shell.html" %}
|
||||
{% from "_macros.html" import tile, service_error %}
|
||||
{% from "_macros.html" import tile %}
|
||||
{% block title %}P103 Discover{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="tt-dh bloom tt-masthead">DISCOVER</h1>
|
||||
<p class="tt-empty">Popular right now, with anything already in library filtered out.</p>
|
||||
|
||||
{#- Search sits at the top for the same reason it does on Library: a
|
||||
title in mind is the common case. This one hits Radarr's and
|
||||
Sonarr's own lookup directly — the same call /add-to-radarr and
|
||||
/add-to-sonarr already make — so a result here always resolves the
|
||||
same way a click on its own Add button will. -#}
|
||||
<form class="tt-search" action="{{ request.script_root }}/discover" method="get" role="search">
|
||||
<label class="visually-hidden" for="q">Search Sonarr and Radarr for a title to add</label>
|
||||
<input class="tt-input" type="search" id="q" name="q" value="{{ q }}"
|
||||
placeholder="SEARCH TO ADD" autocomplete="off" spellcheck="false">
|
||||
<button class="tt-btn" type="submit">Search</button>
|
||||
</form>
|
||||
|
||||
{% if search %}
|
||||
<div class="tt-field">
|
||||
<span>Results</span>
|
||||
<span class="tt-field-meta">“{{ q }}”</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 “{{ q }}”.</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<p class="tt-empty">Popular right now, with anything already held filtered out.</p>
|
||||
|
||||
<div class="tt-field">
|
||||
<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>
|
||||
{% if rt_movies %}
|
||||
<div class="tt-tiles">
|
||||
|
|
@ -51,12 +17,12 @@
|
|||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="tt-empty">Nothing to suggest — the feed is unreachable or it's all already in your library.</p>
|
||||
<p class="tt-empty">Nothing to suggest — the feed is unreachable or you already hold it all.</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="tt-field">
|
||||
<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>
|
||||
{% if rt_tv %}
|
||||
<div class="tt-tiles">
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@
|
|||
<a class="tt-row" href="{{ request.script_root }}/library">
|
||||
<span class="tt-row-num">102</span>
|
||||
<span class="tt-row-name">Library</span>
|
||||
<span class="tt-row-body"><span class="tt-row-sub">Everything 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' %}
|
||||
<span class="tt-row-val">{{ sonarr.total_series | default(0) }} SHOWS {{ radarr.total_movies | default(0) }} FILMS</span>
|
||||
{% else %}
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
<a class="tt-row" href="{{ request.script_root }}/discover">
|
||||
<span class="tt-row-num">103</span>
|
||||
<span class="tt-row-name">Discover</span>
|
||||
<span class="tt-row-body"><span class="tt-row-sub">Popular, and not yet 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>
|
||||
</a>
|
||||
|
||||
|
|
|
|||
|
|
@ -14,22 +14,6 @@
|
|||
<button class="tt-btn" type="submit">Search</button>
|
||||
</form>
|
||||
|
||||
{% if search_results is not none %}
|
||||
<div class="tt-field">
|
||||
<span>Results</span>
|
||||
<span class="tt-field-meta">{{ search_results | length }} match{{ 'es' if search_results | length != 1 else '' }}</span>
|
||||
</div>
|
||||
{% if search_results %}
|
||||
<div class="tt-tiles">
|
||||
{% for it in search_results %}
|
||||
{{ tile(it.title, it.kind ~ (' · ' ~ it.year if it.year else ''), it.poster) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="tt-empty">No matches for “{{ q }}”.</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<div class="tt-field">
|
||||
<span>Holdings</span>
|
||||
<span class="tt-field-meta">
|
||||
|
|
@ -71,7 +55,7 @@
|
|||
{{ service_error('Radarr', radarr.error) }}
|
||||
{% endif %}
|
||||
|
||||
{% if not q and recently_added %}
|
||||
{% if recently_added %}
|
||||
<div class="tt-field"><span>Newest In</span></div>
|
||||
<div class="tt-tiles">
|
||||
{% for it in recently_added %}
|
||||
|
|
|
|||
|
|
@ -10,17 +10,15 @@
|
|||
{{ service_error('Ombi', ombi.error) }}
|
||||
{% else %}
|
||||
{#- Pending requests are the only thing here that wants a human, so a
|
||||
page holding any is set heavier than one holding none (discipline 5).
|
||||
The ids are what the verdict buttons write back to when a decision
|
||||
empties the queue, so the page stops contradicting itself. -#}
|
||||
<div class="tt-field {{ 'warn' if ombi.total_pending else '' }}" id="req-field">
|
||||
page holding any is set heavier than one holding none (discipline 5). -#}
|
||||
<div class="tt-field {{ 'warn' if ombi.total_pending else '' }}">
|
||||
<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 class="tt-figs">
|
||||
<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>
|
||||
<div class="tt-fig">
|
||||
|
|
@ -41,21 +39,6 @@
|
|||
</span>
|
||||
{%- set st = (r.status or '') | lower -%}
|
||||
<span class="tt-row-val {% if st in ('available', 'approved') %}ok{% elif st == 'denied' %}alert{% elif st == 'pending' %}pending{% else %}off{% endif %}">{{ (r.status or '—') | upper }}</span>
|
||||
{#- Magenta says a human must decide; these are the two things that
|
||||
human can decide, offered only on the rows actually waiting.
|
||||
Both take a confirming second press, the same two-tap the add
|
||||
buttons use — an approval starts a download and a denial is
|
||||
somebody's answer. -#}
|
||||
{% if st == 'pending' and r.id %}
|
||||
<span class="tt-row-acts">
|
||||
<button class="tt-btn tt-btn-yes" data-id="{{ r.id }}" data-type="{{ (r.type or '') | lower }}"
|
||||
data-verdict="approve" onclick="armDecision(this)"
|
||||
aria-label="Approve {{ r.title }}">Approve</button>
|
||||
<button class="tt-btn tt-btn-no" data-id="{{ r.id }}" data-type="{{ (r.type or '') | lower }}"
|
||||
data-verdict="deny" onclick="armDecision(this)"
|
||||
aria-label="Deny {{ r.title }}">Deny</button>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{% 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 content %}
|
||||
|
|
@ -7,25 +7,107 @@
|
|||
|
||||
{#- ── Service transmission status ───────────────────────────────
|
||||
Six services, one row each, colour carrying up or down. This is
|
||||
what the old header pills were, given room to say something. Each
|
||||
row opens that service's own instance, via the reverse proxy. -#}
|
||||
what the old header pills were, given room to say something. -#}
|
||||
<div class="tt-field"><span>Transmission</span></div>
|
||||
<div class="tt-rows">
|
||||
{% for name, key, svc in [('Sonarr', 'sonarr', sonarr), ('Radarr', 'radarr', radarr), ('SABnzbd', 'sabnzbd', sabnzbd),
|
||||
('Tautulli', 'tautulli', tautulli), ('Prowlarr', 'prowlarr', prowlarr), ('Ombi', 'ombi', ombi)] %}
|
||||
<a class="tt-row" href="{{ service_urls[key] }}" target="_blank" rel="noopener">
|
||||
<span class="tt-row-name">{{ name }} <span class="tt-row-ext" aria-hidden="true">↗</span></span>
|
||||
{% for name, svc in [('Sonarr', sonarr), ('Radarr', radarr), ('SABnzbd', sabnzbd),
|
||||
('Tautulli', tautulli), ('Prowlarr', prowlarr), ('Ombi', ombi)] %}
|
||||
<div class="tt-row">
|
||||
<span class="tt-row-name">{{ name }}</span>
|
||||
<span class="tt-row-body"></span>
|
||||
<span class="tt-row-val {{ 'ok' if svc.status == 'ok' else 'alert' }}">
|
||||
{{- 'ON AIR' if svc.status == 'ok' else 'OFF AIR' -}}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div id="array-section">
|
||||
{% include "_array_section.html" %}
|
||||
</div>
|
||||
{#- ── Array ───────────────────────────────────────────────────── -#}
|
||||
{% if unraid.status != 'ok' %}
|
||||
<div class="tt-field crit"><span>Array</span><span class="tt-field-meta">unreachable</span></div>
|
||||
{{ service_error('Unraid', unraid.error) }}
|
||||
{% else %}
|
||||
{%- set apct = unraid.array_used_pct | default(0) | float -%}
|
||||
<div class="tt-field {% if apct >= 90 %}crit{% elif apct >= 75 %}warn{% endif %}">
|
||||
<span>Array</span>
|
||||
<span class="tt-field-meta">
|
||||
{{ unraid.array_state | default('') }}
|
||||
{%- if unraid.parities %} · parity
|
||||
{%- for p in unraid.parities %} {{ p.temp if p.temp is not none else '—' }}°C{% endfor %}
|
||||
{%- endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="tt-figs">
|
||||
<div class="tt-fig">
|
||||
<div class="tt-dh tt-fig-v {{ 'alert' if apct >= 90 else ('warn' if apct >= 75 else 'ok') }}">{{ apct | round | int }}%</div>
|
||||
<div class="tt-fig-l">Used</div>
|
||||
</div>
|
||||
<div class="tt-fig">
|
||||
<div class="tt-dh tt-fig-v">{{ unraid.array_free | default('—') }}</div>
|
||||
<div class="tt-fig-l">Free</div>
|
||||
</div>
|
||||
<div class="tt-fig">
|
||||
<div class="tt-dh tt-fig-v">{{ unraid.array_total | default('—') }}</div>
|
||||
<div class="tt-fig-l">Total</div>
|
||||
</div>
|
||||
<div class="tt-fig">
|
||||
<div class="tt-dh tt-fig-v">{{ unraid.disk_count | default(0) }}</div>
|
||||
<div class="tt-fig-l">Disks</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if unraid.disks %}
|
||||
{#- Desktop: a real table — columns line up, a new metric is a new
|
||||
column. Hidden below 560px, where six columns stop fitting and
|
||||
a horizontal scroll would hide the bar and the temperature,
|
||||
the two readings that matter most on this page. -#}
|
||||
<div class="tt-table-scroll">
|
||||
<table class="tt-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Disk</th>
|
||||
<th scope="col">Device</th>
|
||||
<th scope="col" class="num">Size</th>
|
||||
<th scope="col" class="num">Free</th>
|
||||
<th scope="col">Usage</th>
|
||||
<th scope="col" class="num">Temp</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for dk in unraid.disks %}
|
||||
<tr>
|
||||
<th scope="row">{{ dk.name }}</th>
|
||||
<td>{{ dk.device | default('—') }}</td>
|
||||
<td class="num">{{ dk.total | default('—') }}</td>
|
||||
<td class="num">{{ dk.free | default('—') }}</td>
|
||||
<td>{{ bar(dk.used_pct | default(0)) }} <span class="val">{{ dk.used_pct | default(0) | round | int }}%</span></td>
|
||||
<td class="num">{{ disk_state(dk) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% for ck in unraid.caches | default([]) %}
|
||||
<tr>
|
||||
<th scope="row">{{ ck.name }}</th>
|
||||
<td>{{ ck.device | default('—') }}</td>
|
||||
<td class="num">{{ ck.total | default('—') }}</td>
|
||||
<td class="num">{{ ck.free | default('—') }}</td>
|
||||
<td>{{ bar(ck.used_pct | default(0)) }} <span class="val">{{ ck.used_pct | default(0) | round | int }}%</span></td>
|
||||
<td class="num">{{ disk_state(ck) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{#- Phone (≤560px): the .tt-row-derived reflow — name, the shared
|
||||
20-cell bar and the temperature on one line, device/size/free
|
||||
beneath. Hidden above 560px in favour of the table. -#}
|
||||
<div class="tt-disk-rows">
|
||||
{% for dk in unraid.disks %}{{ disk_row(dk) }}{% endfor %}
|
||||
{% for ck in unraid.caches | default([]) %}{{ disk_row(ck) }}{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{#- ── Downloads ─────────────────────────────────────────────────
|
||||
The one place the service holds real history, so the one place a
|
||||
|
|
@ -38,7 +120,7 @@
|
|||
<div class="tt-fig-l">In queue</div>
|
||||
</div>
|
||||
<div class="tt-fig">
|
||||
<div class="tt-dh tt-fig-v"><span id="sab-speed">{{ sabnzbd.speed | default('0') }}</span> {{ trend(sabnzbd.speed_kbps, sabnzbd.avg_speed_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>
|
||||
<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>
|
||||
{{ service_error('Prowlarr', prowlarr.error) }}
|
||||
{% endif %}
|
||||
|
||||
{#- ── Admin ─────────────────────────────────────────────────────
|
||||
Shows Sonarr is holding, sized, that nobody has ever played in
|
||||
Tautulli and that have sat untouched for longer than the
|
||||
neglect window — largest first, since the point is reclaiming
|
||||
space. Delete removes the series from Sonarr with its episode
|
||||
files and folder; that's permanent, so it shares the site's
|
||||
two-tap arming rather than a native confirm(). -#}
|
||||
{% if admin_unwatched.status != 'ok' %}
|
||||
<div class="tt-field crit"><span>Admin</span><span class="tt-field-meta">off air</span></div>
|
||||
{{ service_error('Sonarr/Tautulli', admin_unwatched.error) }}
|
||||
{% else %}
|
||||
<div class="tt-field">
|
||||
<span>Admin</span>
|
||||
<span class="tt-field-meta" id="admin-meta">
|
||||
{{ admin_unwatched.shows | length }} unwatched · {{ 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 }} · {% endif %}Downloaded {{ show.added[:10] if show.added else 'unknown' }} · {% if show.requested_by %}Requested by {{ show.requested_by }}{% else %}Requester unknown{% endif %} · 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 %}
|
||||
|
|
|
|||
|
|
@ -11,29 +11,6 @@
|
|||
{% else %}
|
||||
{%- set sessions = tautulli.sessions or [] -%}
|
||||
|
||||
{% if selected_user %}
|
||||
<div class="tt-field"><span>History — {{ 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">← All viewers</a></p>
|
||||
{% endif %}
|
||||
|
||||
<div class="tt-field {{ 'warn' if sessions else '' }}">
|
||||
<span>Streaming Now</span>
|
||||
<span class="tt-field-meta">{{ tautulli.active_streams }} active</span>
|
||||
|
|
@ -98,23 +75,5 @@
|
|||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="tt-field"><span>Active Viewers</span><span class="tt-field-meta">last 10 days</span></div>
|
||||
{% if tautulli.active_users %}
|
||||
<div class="tt-rows">
|
||||
{% for u in tautulli.active_users %}
|
||||
<a class="tt-row" href="{{ request.script_root }}/watch?user={{ u.username | urlencode }}">
|
||||
<span class="tt-row-num">{{ loop.index }}</span>
|
||||
<span class="tt-row-body">
|
||||
<span class="tt-row-title">{{ u.username }}</span>
|
||||
<span class="tt-row-sub">Last watched {{ u.last_watched }}</span>
|
||||
</span>
|
||||
<span class="tt-row-val">{{ u.plays }} PLAYS</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="tt-empty">No viewers in the last 10 days.</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue