Lists anyone who's watched something in the last 10 days; clicking a
name shows their play history for the last 48 hours.
Bump to v2.1.0 per the just-added CLAUDE.md rule that every commit
should be matched with a version change.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017kn4ZaFd6RCnbMewMyMsfe
Requested directly: "can we make activity orange". The locked eight
palette had already run out at six legible text colours (Watch's blue
was the last one, and it's already a contrast exception); a dithered
swatch alone read as unfinished, and dithering the word itself
rendered as illegible static — tried and reverted this session.
--tt-orange (#ff8000) is defined in base.css right after the locked
eight, clearly marked as a deliberate, acknowledged one-off exception
to "eight colours, no ninth" rather than a secretly-smuggled-in tint.
Same flat text+swatch treatment as every other fastext key. Updated
CLAUDE.md's discipline 1 and DESIGN.md's Locked Eight Rule to name the
exception explicitly, so it reads as a documented decision next time
someone audits the palette, not a bug.
Swatch-only blue read as unfinished next to the other five fully-
coloured entries. The word now carries the colour like every other
key, same currentColor mechanism as the rest — accepting blue's
~2.4:1 contrast on black as a known, deliberate exception to the
4.5:1 bar everything else here clears.
Colours: "if the intent is to have them all different colors than
lets have them all different colors." The locked eight only give five
safe as full text (blue fails contrast, black's invisible on black),
so the other three get their own treatments instead of sharing plain
ink: Watch gets blue as a swatch-only accent (the button cap's colour,
not the label), Activity gets an ordered-dither swatch (the other
legal way to mark something without a ninth colour), and Settings
alone keeps plain ink deliberately, as the one page with no live
status of its own.
Accessibility (items 1 and 3 from the design review, item 2 skipped
per request — the header's download figure stays live and unmuted):
- Added a skip-link, off-screen until focused, jumping past the
8-item top nav straight to each page's #main-content.
- Renamed the nav's aria-label from "Fastext" (meaningless without
the visual teletext context) to "Page navigation".
Plain ink for Watch/Requests/Activity/Settings read as flat and
unfinished, not deliberate — user feedback: "the new colors are
awful and basically the same color." Requests gets magenta
(--pending) back: not an arbitrary reuse, it's the same colour a
request already carries once approved elsewhere in the app, and
Requests genuinely is "waiting on a human." That's the real ceiling
though — the locked eight teletext colours leave exactly five legible
as text on black (blue fails contrast, black is invisible on black),
so Watch/Activity/Settings still share plain ink rather than a sixth
colour that doesn't exist in this palette.
From a frontend-design skill review of all pages, after last session's
chrome changes (top nav, all-tabs nav, header download row):
- The top/closing fastext nav now mutes the entry matching the current
page (.fastext-here) instead of rendering a link to the page you're
already on.
- Colour in fastext is reserved for the four entries that still carry
a real keyboard shortcut (r/g/y/c); the other four render in plain
ink instead of reusing k-red/k-green/k-yellow/k-cyan for a
destination they don't semantically mean (e.g. Requests no longer
shows in the app's own off-air red).
- Discover's badge slot meant "critic score" on its RT tiles and
"release year" on its new search tiles at the same time. Search
results (Library and Discover) now fold year/held-state into the
sub-line instead, leaving the badge exclusively for critic score.
- System's Transmission rows now carry a small ↗ glyph, since they
silently changed from in-app navigation to opening each service's
own UI in a new tab with no visual cue.
- SABnzbd polling drops from 5s to 20s on pages that only show the
header's download figure (everywhere but System/Activity, which
keep 5s for their own live figures) — a number idle 95% of the time
doesn't need second-level freshness sitewide.
- DESIGN.md's fastext description, now-current per this session's nav
changes.
- Discover's search now hits Radarr's/Sonarr's own lookup endpoints
directly (the same ones /add-to-radarr and /add-to-sonarr already
use), instead of Ombi — one fewer round trip, and a result always
resolves the same way its own Add button will. The Ombi-based
ombi_search/_tmdb_poster helpers are gone.
- The top nav on every page but the index now lists all eight
destinations (FULL_FASTEXT) instead of the original four; the index
keeps the original four-entry row as its own closing line
(HOME_FASTEXT), since its body already lists every destination once.
- System page's Transmission rows are now links to each service's own
reverse-proxied instance (service_urls, already injected into every
template but unused until now).
- Fastext colour keys now lead every page but the index, since they're
the only nav those pages carry; the index keeps them as its closing
row since its own body is already the full nav.
- Settings page was missing page="settings" in its render_template
call, so its header silently fell back to "P100 Index".
- Library's search form posted a q param the route never read; it now
filters the cached Sonarr/Radarr lists and shows matching tiles.
- Discover gained a search bar that queries Ombi (already in front of
TMDB/TVDB) and lets you add a result straight to Radarr/Sonarr,
showing "Held" instead of a button for anything already in library.
- Fixed the Add button's success check, which read d.ok against a
response that actually sets d.success — it reported FAILED even
when the add worked.
- system.html crashed with a 500 whenever SABnzbd history loaded: the
trend() macro compared the formatted "avg_speed" string (e.g. "900
KB/s") against a number. Added a real numeric avg_speed_kbps field
and pointed the macro at that instead.
- Replaced "held"/"not held" wording with "in library"/"not in
library" on Home and Discover.
- Moved CLAUDE.md's Deployment/reverse-proxy and account-management
sections into two on-demand skills (deploy-arr-summary,
manage-accounts) so they only load when actually relevant, with a
one-line pointer left in their place.
- Updated PRODUCT.md's restructure note now that meridian-marquee has
merged to main.
- Dropped two stale/broken entries from settings.local.json's
permission allowlist (a rule from a different machine/path, and one
with a corrupted token).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBw1QodG8ZMYHrGeQfqyDS
Applies the fix round from the finish-reviewer's disposition on commit
4b19cbd, in one batch:
- System's disk table is unreadable below 560px: horizontal-scrolling a
6-column table hid the usage bar and the temperature, the two
readings that matter most (discipline 3 — one shared ruler — was
unauditable as a result). Added a disk_row() macro and a phone-only
.tt-disk-rows reflow (name + full 20-cell bar + temp on one line,
device/free/total beneath), CSS-toggled against the existing table
at the same breakpoint.
- Contrast failure on the highest-severity state: white text on the
red .tt-field.crit/.tt-toast.error/.tt-btn.armed reversals measured
4.0:1, under the 4.5:1 floor. Switched to black text (5.25:1),
matching the black-on-yellow .warn pattern already in use — and
fixed a second instance of the same bug: .tt-field.crit had no
.tt-field-meta override, unlike .warn.
- Two type families where the direction contract names one blocky
monospace: unified on a single self-hosted face (Space Mono, one
weight, no bold) for both body and the double-height row.
- FIRST VIEWPORT proportion: an unpromised 6-item Just Added grid was
competing with NOW SHOWING for the fold instead of the contract's
"lower two-thirds in double-height cyan." Cut to a fixed 3-item,
fixed-3-column row, which also fixes the orphaned-tile grid defect
as a side effect (a fixed count can't leave a lone tile beside empty
tracks).
- Three flat mid-grey fills contradicted discipline 1 (no in-between
tone): .tt-tile-art, .tt-btn:disabled, and .tt-err pre all painted a
solid --ink-dimmer background. Centralized the dither .tt-tile-art
already used correctly into a shared --dither-mid generator and
applied it to all three.
- Google Fonts dependency on a LAN-only home server: a dropped webfont
request was a silent identity change, not a graceful fallback.
Self-hosted Space Mono under static/fonts/ via @font-face; dropped
the <link> tags from _shell.html and login.html.
- Deliberately deferred: "pages differ in stock" (discipline 5) still
fires on only one band service-wide. Every other finding was a
bounded fix to existing code; this one asks for a new mechanism (an
aggregate status band on P100) rather than a correction, so it's
flagged for a follow-up rather than rushed into this batch.
Recaptured desktop.png, mobile.png, desktop-system.png and
mobile-system.png (the four files the reviewer's findings cited)
through the same true-390px iframe method as the prior round, and
returned them for the fix round's verdict pass.
Known follow-up: DESIGN.md's typography section (written by the
documenter before this batch) still names the old two-family pairing
and needs a re-run now that the font system changed under it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBw1QodG8ZMYHrGeQfqyDS
Replaces the neutral dark card-based theme with "Page 100": teletext's
locked eight colours at full saturation on pure black, numbered pages
reachable by typing the number (or the r/g/y/c fastext keys), and no
borders, boxes, or radius anywhere — grouping is carried by full-bleed
colour fields and row weight instead.
Chosen via the impeccable skill's replacement-world flow: seven visual
candidates derived from the audience's own world, a mandatory roll
assigned Ceefax/US-cable-guide teletext, six catalog challengers weighed
against it, and the user locked the assigned direction on the decision
page (seed aab4510e).
- New templates for all nine pages (home/watch/library/discover/
requests/system/activity/login/settings) behind a shared _shell.html
carrying the direction contract and _macros.html for the row/bar/tile
vocabulary; deletes the old single-page dashboard.html and its
dedicated stylesheets/script.
- static/css/base.css + teletext.css: the locked palette, a two-height
type scale (Chivo Mono / DotGothic16), and the five disciplines the
system is held to (no in-between tone, no enclosure, one shared bar
ruler, trend only where real history exists, pages weighted by state).
- static/js/service.js: header clock, page-number jump, fastext keys,
two-tap arm/confirm add, guarded live polling — replaces dashboard.js.
- app.py: nav_items now carry page numbers; fastext defaults added to
the page-chrome context processor; version bumped to 2.0.0.
- DESIGN.md + .impeccable/design.json regenerated from the built world
by the documenter pass; PRODUCT.md and CLAUDE.md updated to describe
the new system in place of the retired one.
- Fixed a live bug found along the way: settings.html called
url_for('dashboard'), an endpoint removed earlier in the branch, which
would have 500'd the Settings page.
- Fixed a One-Ruler violation the finish review's recapture request
surfaced: --bar-cells disagreed with the bar() macro's hardcoded cell
count below the 760px breakpoint, so a fill could land mid-cell on a
phone. Removed the mobile override; the ruler is now 20 cells at every
width, matching the macro unconditionally.
Passed two inspection rounds (desktop + true-390px mobile, via an
iframe harness since macOS Chrome clamps --window-size) and a finish
review; QA screenshots and interview logs are gitignored as scratch,
not shipped in the repo.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBw1QodG8ZMYHrGeQfqyDS
Parity went back into the GraphQL query, but for its temperature only —
it still gets no table row, since it has no filesystem to measure. The
temp rides in the card header next to the array summary, which was the
one genuinely useful thing the old parity row carried: a parity drive
cooking away is worth seeing. Multiple parity drives each get a badge,
and .meta-parity keeps the label and badge from wrapping apart.
The settings cog was a speck. The button was always a full 44px --tap
target, but the glyph inside it was set at --fs-sm (13px), so the
affordance read as far smaller than the hit area actually was. Now
--fs-xl (24px) with line-height 1 — roughly 55% of the button, which is
the usual proportion for an icon button. .icon-btn has exactly one user
(the cog), so nothing else shifts.
Verified at 1400px and 375px: no page overflow, the cog still measures
44x44, and the parity badge stays visible and intact on both.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two fixes to the Storage card.
Unraid's GraphQL reports fsSize/fsFree in KB, not bytes — the schema is
explicit about it ("(KB) Total Size of the FS"). We were passing those
straight to fmt_bytes as if they were bytes, so every figure came out
1024x too small and picked the unit below the right one: a 13 TB disk
rendered as "13.0 GB" and a 64.8 TB array as "64.8 GB". _fs_bytes() now
does the conversion at the point of reading, so everything downstream
still deals in bytes. The shutil fallback already returns real bytes and
deliberately doesn't go through it.
Parity is gone from the table and is no longer requested at all. It
mirrors the array rather than holding a filesystem — the schema notes
fsSize is "Not present on Parity type drive" — so it could only ever
render as a name, three em-dashes and a temperature, which earned little
for a whole row. Worth knowing: its temperature is no longer surfaced
anywhere, so a hot parity drive won't show up here.
Verified against the raw KB shape the API actually returns: an array of
3.7/11.2/13.0 TB disks now totals 27.9 TB rather than 27.9 GB, the sub-TB
cache still correctly reads in GB, and the local fallback still reports
this machine's real 228.3 GB rather than a 1024x-inflated figure.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
settings.json can still hold the old plaintext login_password after
accounts exist, where it's ignored but sitting in cleartext. Removing it
by hand has a sharp edge: with no accounts AND no login_password, the
login route reads "nothing configured" as dev mode and lets anyone in
without a password, so a tidy-up can quietly open the dashboard.
`manage_users.py retire-shared-password` removes the key only when at
least one account exists, and says so plainly when it won't. It also
warns if LOGIN_PASSWORD is still set in the environment, since that's a
second copy people forget.
Needed delete_setting() in app.py — save_settings() only ever merges, so
it had no way to take a key away.
Verified: refuses with exit 1 when no accounts exist and leaves the file
untouched; removes the key while preserving timezone and users; is
idempotent; warns about the env var; and after removal only the account
password signs in, with the retired shared password and the env value
both rejected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Minor rather than patch — per-user logins are a new capability, even
though existing installs keep the shared password until an account is
added.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dashboard had one shared password and no concept of a user. This
adds real accounts: a username field on the login page, per-user
credentials, and the signed-in user recorded on the session, which is
the groundwork for per-user permissions later.
Credentials are salted pbkdf2 hashes in settings.json's `users` map.
Three deliberate choices there:
- settings.json rather than an env var, because a pbkdf2 hash contains
'$' and docker compose interpolates that when it reads .env. It's also
already the bind-mounted file that survives `up -d --build`.
- pbkdf2 rather than Werkzeug's default scrypt, which needs a hashlib
built against OpenSSL — the macOS system Python has no hashlib.scrypt
and would raise at hash time.
- load_users() re-reads the file per check, so adding a user or changing
a password is live in every gunicorn worker with no restart. The old
_runtime_password was per-worker and only ever half-applied.
Multi-user activates only once an account exists. Until then the
original LOGIN_PASSWORD path is used unchanged and the username field is
hidden, so deploying this can't lock the existing install out.
manage_users.py prompts for passwords via getpass — never echoed, never
a CLI argument — and only ever writes hashes; it refuses to remove the
last account, which would silently drop the dashboard back to the shared
password.
Two fixes carried along: login no longer requires the LOGIN_PASSWORD env
var specifically, so a settings.json-only password works instead of
locking you out; and the legacy comparison now uses hmac.compare_digest,
which the old docstring claimed but '==' didn't provide.
Also gitignores settings.json and data/ — they hold API keys and now
password hashes, were excluded from the deploy rsync but not from git.
Verified: legacy fallback still signs in with no accounts configured; a
9-case credential matrix (right/wrong user x right/wrong password, plus
the retired shared password); per-user change-password scoped to the
signed-in account and live without restart; and the add/passwd/remove
guards.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Covers the one-row status pills (3c93187) and the redundant inline
margin removal (2a4236e). Patch rather than minor — neither changes what
the dashboard shows, only how it lays out at narrow widths.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
app.py's dev server and gunicorn both hardcoded 5000, which is awkward
locally — macOS ControlCenter already holds that port, so the app can't
bind on the dev laptop without editing source.
Both now read PORT, defaulting to 5000 so EXPOSE, the compose
`${HOST_PORT}:5000` mapping and the deployed container are unchanged.
Read at run time rather than import time so a dev harness can assign a
free port.
Also tracks .claude/launch.json with autoPort set. It doesn't actually
launch on this machine — the preview sandbox can't read files under
~/Desktop — so CLAUDE.md notes that running from a shell with an
explicit PORT is the working local path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The footer version is the honest check that a deploy landed, so cut
1.4.0 for the Storage table.
Also record something that has now cost two sessions: the dev machine
isn't on the media stack's network, so every service is offline and the
live-poll endpoints 500 on a timer. That's the normal local baseline,
not a regression. Notes the monkey-patch pattern for exercising a card's
real rendering, plus the two traps that go with it — Jinja's auto_reload
follows app.debug (off), so template edits need a restart rather than a
browser reload, and LOGIN_PASSWORD has to be set explicitly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The GraphQL query only asked for aggregate array fsSize/fsFree, so the
per-disk breakdown added in templates/dashboard.html (commit 5045316)
had no data to render. Now pulls name/device/temp/isSpinning for data
disks, cache disks, and parity disks, and shapes them into the
disks/caches/parities lists the template expects.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The Indexers card sat beside Requests as a stretched, vertically centred
grid of pills — mostly dead space once the Ombi list was expanded, and it
said nothing about which indexer was actually doing the work.
- Fetch Prowlarr's indexerstats over a rolling 30-day window and merge
per-indexer queries/grabs into the summary. Cached 10 min on success and
never on failure, so a stats outage can't blank the column after it
recovers or take the up/down list down with it.
- Rank indexers busiest-first by grab share (query share as fallback);
queries mostly count enabled indexers, grabs count who delivered.
- One indexer per line, matched to the Ombi request rows: same padding,
gap, radius and two-line body, plus a share bar scaled to the busiest
indexer and an Online/Offline badge the 30s poll keeps current.
- Let that row size cards to their own content instead of stretching.
Bump APP_VERSION to 1.2.0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The palette lived in a :root block duplicated across all three
templates, so every reshade meant three edits — and drifted twice in
practice (the favicon hardcoded its own hex; stale danger-border rgba
literals survived two palette changes). Move tokens to a single
static/css/base.css, derive every dim/border tint from the four source
hues with color-mix, and point the favicon at one static/favicon.svg.
Also splits the 1558-line dashboard.html into template + static assets:
662 lines of CSS and 396 of JS were re-sent uninlined on every load
with no browser caching, which costs real time on the remote/cellular
access this app is built for. Templates drop to 501/42/107 lines.
Server-rendered config (timezone, script_root) now arrives via
<body data-*>; wiring URL_BASE to request.script_root also fixes /api/*
fetches for a future subpath mount, which the hardcoded "" would break.
Header service pills now wrap instead of scrolling — they were clipped
at narrow widths, and a hidden pill is a service whose status you
cannot see. Verified 0 clipped and no page overflow at 320/375/700/1280.
Footer gains a version and date so the running build is identifiable
without trusting the Unraid Docker tab's cached container IDs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous commit made Settings-saved API keys override .env, but the
key still never survived: settings.json was written to the container
filesystem, and every deploy runs `up -d --build`, which recreates the
container and discards it. Saving a rotated SABnzbd key and restarting
therefore still fell back to the stale .env value.
SETTINGS_FILE is now configurable and pointed at /data/settings.json on
a ./data bind mount, so runtime settings live on the host. The deploy
rsync also excludes data/ — it runs --delete and would otherwise wipe
the directory for not being in the repo, reintroducing the same bug.
This affected everything the Settings page writes, not just API keys:
timezone and a UI-changed login password were being reverted on every
deploy too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every *_API_KEY was read from the env var once at startup and never
checked against settings.json again, so saving a new key on the
Settings page updated the file but had zero effect on real requests —
reported live as "SABnzbd rejected the API key" right after rotating
and saving a new one there. A saved key now overrides the .env value
at startup, matching what the Settings page's own toast already says
("restart the container to apply").
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replaces the five novelty themes with one professional dark theme built
on a shared token set (surfaces, 6-step type scale, semantic colours,
44px tap target) used across dashboard, login and settings.
Layout now matches the cache tiers in app.py: a "Right now" strip with
live streams, the download queue and array usage sits above the fold,
with library, discovery, requests and watch insights below. Previously
the page opened on rarely-actionable disk stats and buried active
streams under twenty recommendation rows.
Also from the design review:
- Discover lists show 5 with a Show more toggle instead of 10 each
- RT descriptions render inline and clamped, not hover-only tooltips
that touch devices could never reach
- Adding a title is now two-step (tap to arm, tap to confirm) since it
queues a real download
- role=alert on errors, aria-live on polled regions, semantic dl/dt/dd
stats, labelled search inputs
- every text/surface pair verified at >= 5:1 contrast
Drops the dead delete-modal JS, which threw a TypeError on every load,
and the stale root-level template copies that Flask never rendered.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SABnzbd and Tautulli embed their API key in the request URL, so it
was showing up verbatim in the raw exception text under "Technical
details" on the dashboard — visible to anyone with the study-room
password. Added a redact_secrets filter that strips apikey/token/
password query params before the raw string is rendered.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Settings lived inside .header-right, which is hidden below 600px,
leaving no way to reach it on mobile. It's now a standalone 44x44px
button next to the title.
Dashboard error cards showed raw Python/urllib3 exception strings.
Added a friendly_error() filter that classifies the failure (timeout,
connection refused, DNS, auth, 4xx/5xx, TLS) into plain-English copy,
with the raw string still available behind a details disclosure.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>