Redesign Media Centre as a teletext broadcast information service

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
This commit is contained in:
Barely Removable 2026-08-29 21:00:45 -07:00
parent 9a277b9031
commit 4b19cbdfb2
25 changed files with 3002 additions and 2343 deletions

6
.gitignore vendored
View file

@ -8,3 +8,9 @@ __pycache__/
# git too so credentials can't be committed by accident.
settings.json
data/
# Impeccable QA/decision-page scratch — screenshots and interview logs from
# a design pass, not project source. .impeccable/design.json (the design
# system sidecar) is tracked deliberately, alongside DESIGN.md.
.impeccable/review/
.impeccable/questions/

268
.impeccable/design.json Normal file
View file

@ -0,0 +1,268 @@
{
"schemaVersion": 2,
"generatedAt": "2026-08-29T00:00:00Z",
"title": "Design System: Media Centre",
"extensions": {
"colorMeta": {
"tt-black": {
"role": "neutral",
"displayName": "Pure Black",
"canonical": "#000000",
"purpose": "The ground, everywhere and unconditionally. There is no second surface colour, because there are no surfaces.",
"tonalRamp": ["#000000", "#0d0d0d", "#1a1a1a", "#262626", "#333333", "#404040", "#4d4d4d", "#595959"]
},
"tt-white": {
"role": "neutral",
"displayName": "Transmission White",
"canonical": "#ffffff",
"purpose": "Body text, row titles, table row headers. The default reading colour.",
"tonalRamp": ["#262626", "#404040", "#595959", "#737373", "#8c8c8c", "#a6a6a6", "#d9d9d9", "#ffffff"]
},
"tt-yellow": {
"role": "primary",
"displayName": "Service Yellow",
"canonical": "#ffff00",
"purpose": "The service speaking in its own voice: mastheads, page numbers, section-band titles, focus reversal, and the warn tier on any threshold-coloured value.",
"tonalRamp": ["#333300", "#4d4d00", "#666600", "#999900", "#cccc00", "#ffff00", "#ffff66", "#ffffcc"]
},
"tt-cyan": {
"role": "primary",
"displayName": "Transmission Cyan",
"canonical": "#00ffff",
"purpose": "Live values and now-showing: the header clock, active figures, links, the currently-playing title. Cyan means this number is moving.",
"tonalRamp": ["#003333", "#004d4d", "#006666", "#009999", "#00cccc", "#00ffff", "#66ffff", "#ccffff"]
},
"tt-green": {
"role": "secondary",
"displayName": "Signal Green",
"canonical": "#00ff00",
"purpose": "Running, healthy, available. ON AIR rows, usage under 75%, healthy progress bars, the confirmation toast.",
"tonalRamp": ["#003300", "#004d00", "#006600", "#009900", "#00cc00", "#00ff00", "#66ff66", "#ccffcc"]
},
"tt-red": {
"role": "secondary",
"displayName": "Off-Air Red",
"canonical": "#ff0000",
"purpose": "Off air, failed, danger. Degraded-service copy, OFF AIR rows, usage at or above 90%, disk temperature at or above 55C, the armed state of a destructive button.",
"tonalRamp": ["#330000", "#4d0000", "#660000", "#990000", "#cc0000", "#ff0000", "#ff6666", "#ffcccc"]
},
"tt-magenta": {
"role": "secondary",
"displayName": "Request Magenta",
"canonical": "#ff00ff",
"purpose": "Waiting on a human. Ombi's pending count and requested events, and nothing else. Its rarity is the point.",
"tonalRamp": ["#330033", "#4d004d", "#660066", "#990099", "#cc00cc", "#ff00ff", "#ff66ff", "#ffccff"]
},
"tt-blue": {
"role": "tertiary",
"displayName": "Field Blue",
"canonical": "#0000ff",
"purpose": "The only colour used as a background rather than as ink. Draws the full-bleed section band and the table-row hover. It never sets text.",
"tonalRamp": ["#000033", "#00004d", "#000066", "#000099", "#0000cc", "#0000ff", "#6666ff", "#ccccff"]
},
"ink-dim": {
"role": "neutral",
"displayName": "Dim Ink",
"canonical": "color-mix(in srgb, #ffffff 58%, #000000)",
"purpose": "The only dimmed value permitted to carry text; at 58% it clears 4.5:1 on black. Secondary row copy, labels, timestamps, the flat trend arrow.",
"tonalRamp": ["#1f1f1f", "#333333", "#4d4d4d", "#666666", "#808080", "#949494", "#b3b3b3", "#d9d9d9"]
},
"ink-dimmer": {
"role": "neutral",
"displayName": "Dither Grey",
"canonical": "color-mix(in srgb, #ffffff 30%, #000000)",
"purpose": "Graphics only. Bar troughs, the mosaic separator, the empty-artwork field, the disabled-button fill, scrollbar thumbs. At 30% it fails text contrast outright.",
"tonalRamp": ["#0d0d0d", "#1a1a1a", "#262626", "#333333", "#404040", "#4d4d4d", "#737373", "#a6a6a6"]
}
},
"typographyMeta": {
"double-height": {
"displayName": "Double Height",
"purpose": "The only size other than body text. Page mastheads, the now-showing title, and the big figures where the number is the content. Set in DotGothic16."
},
"double-height-mobile": {
"displayName": "Double Height (phone)",
"purpose": "Below 760px the double-height row steps to 1.375rem, because a true 30px row eats a phone viewport."
},
"body": {
"displayName": "Body",
"purpose": "Everything else, at one size and one weight, with tabular figures so columns line up on the character grid."
},
"label": {
"displayName": "Label",
"purpose": "Row names, figure captions, buttons, fastext keys, table headers. Letterspacing and case do the work a smaller size would do elsewhere."
},
"field-title": {
"displayName": "Field Title",
"purpose": "The widest tracking in the system (0.22em), reserved for the section colour band and the header row's service name."
},
"input": {
"displayName": "Input",
"purpose": "The one hardcoded pixel size in the codebase. 16px keeps iOS Safari from zooming the page on focus, which would break the character grid mid-interaction."
}
},
"shadows": [],
"shadowPhilosophy": "There are no shadows. The codebase contains zero box-shadow declarations and zero border-radius declarations; the grep returning nothing is the audit test for the whole system. Depth is emissive, not cast: full-saturation colour on pure black reads as lit, a region comes forward by reversing (coloured ground, black text) rather than by lifting, and the single text-shadow in the system is a phosphor bloom at 55% of the element's own colour, spent only on mastheads and the now-showing title.",
"motion": [
{
"name": "tt-transmit",
"value": "tt-in 0.18s steps(2, end) both",
"purpose": "Page transmission. Sections fade in in sequence on load, in stepped rather than smooth opacity, because a teletext page did not appear, it was painted in a few rows at a time."
},
{
"name": "tt-transmit-stagger",
"value": "0.05s per section, capped at 0.35s from the eighth child on",
"purpose": "The delay ramp across .tt-page children that produces the row-by-row paint."
},
{
"name": "toast-slide",
"value": "opacity 0.15s, transform 0.15s",
"purpose": "The only other authored motion: the bottom confirmation strip slides up 100% and fades in."
},
{
"name": "reduced-motion",
"value": "@media (prefers-reduced-motion: reduce) { .tt-page > * { animation: none } * { transition-duration: 0.01ms !important } }",
"purpose": "Both moves are removed outright under reduced motion; content is visible by default, so nothing is lost."
}
],
"breakpoints": [
{ "name": "page", "value": "92ch", "purpose": "The measure. A teletext page was 40 columns; a screen this wide gets double that." },
{ "name": "md", "value": "760px", "purpose": "Double-height steps to 1.375rem, fastext goes to two columns, the bar ruler narrows from 20 cells to 14, the gutter goes to 1rem." },
{ "name": "sm", "value": "560px", "purpose": "A list row wraps its description to a full-width third line; the header clock takes its own line; search stacks." }
]
},
"components": [
{
"name": "Primary Button",
"kind": "button",
"refersTo": "button-primary",
"description": "A reversed row of cells. The entire button vocabulary: no fill variants, no radius, no border.",
"html": "<button class=\"ds-btn\">Search</button>",
"css": ".ds-btn { display: inline-flex; align-items: center; justify-content: center; min-height: 44px; padding: 0 1.5ch; background: var(--voice, #ffff00); color: var(--tt-black, #000); border: none; border-radius: 0; font-family: var(--font, 'Chivo Mono', ui-monospace, Menlo, monospace); font-size: var(--fs, 0.9375rem); line-height: 1.45; letter-spacing: 0.16em; text-transform: uppercase; cursor: pointer; } .ds-btn:hover { background: var(--live, #00ffff); } .ds-btn:focus-visible { outline: none; background: var(--live, #00ffff); color: var(--tt-black, #000); } .ds-btn:active { background: var(--ok, #00ff00); } .ds-btn:disabled { background: var(--ink-dimmer, #4d4d4d); color: var(--ink-dim, #949494); cursor: default; }"
},
{
"name": "Armed Button (two-tap confirm)",
"kind": "button",
"refersTo": "button-primary-armed",
"description": "A destructive action arms on first press and states its own stage rather than opening a dialog. Red armed, green when done.",
"html": "<button class=\"ds-btn-armed\">CONFIRM?</button> <button class=\"ds-btn-armed ds-done\" disabled>ADDED</button>",
"css": ".ds-btn-armed { display: inline-flex; align-items: center; justify-content: center; min-height: 44px; padding: 0 1.5ch; background: var(--alert, #ff0000); color: var(--tt-white, #fff); border: none; border-radius: 0; font-family: var(--font, 'Chivo Mono', ui-monospace, Menlo, monospace); font-size: var(--fs, 0.9375rem); line-height: 1.45; letter-spacing: 0.16em; text-transform: uppercase; cursor: pointer; } .ds-btn-armed:hover { background: var(--live, #00ffff); color: var(--tt-black, #000); } .ds-btn-armed:focus-visible { outline: none; background: var(--voice, #ffff00); color: var(--tt-black, #000); } .ds-btn-armed.ds-done { background: transparent; color: var(--ok, #00ff00); cursor: default; }"
},
{
"name": "Quiet Button",
"kind": "button",
"refersTo": "button-quiet",
"description": "The secondary action. Transparent until touched, then it reverses like everything else.",
"html": "<button class=\"ds-btn-quiet\">Save Sonarr</button>",
"css": ".ds-btn-quiet { display: inline-flex; align-items: center; justify-content: center; min-height: 44px; padding: 0 1ch; background: transparent; color: var(--live, #00ffff); border: none; border-radius: 0; font-family: var(--font, 'Chivo Mono', ui-monospace, Menlo, monospace); font-size: var(--fs, 0.9375rem); line-height: 1.45; letter-spacing: 0.16em; text-transform: uppercase; cursor: pointer; } .ds-btn-quiet:hover { background: var(--live, #00ffff); color: var(--tt-black, #000); } .ds-btn-quiet:focus-visible { outline: none; background: var(--voice, #ffff00); color: var(--tt-black, #000); }"
},
{
"name": "Text Field",
"kind": "input",
"refersTo": "input-field",
"description": "No border, no box. The field is marked by an underscore run pinned to its bottom edge, which turns yellow on focus. 16px type stops iOS zooming on focus.",
"html": "<label class=\"ds-label\" for=\"ds-q\">Search the library</label><input class=\"ds-input\" id=\"ds-q\" type=\"search\" placeholder=\"TITLE\">",
"css": ".ds-label { display: block; color: var(--ink-dim, #949494); font-family: var(--font, 'Chivo Mono', ui-monospace, Menlo, monospace); font-size: var(--fs, 0.9375rem); line-height: 1.45; letter-spacing: 0.16em; text-transform: uppercase; } .ds-input { display: block; width: 100%; min-height: 44px; padding: 0 1ch; background-color: var(--tt-black, #000); color: var(--voice, #ffff00); border: none; border-radius: 0; font-family: var(--font, 'Chivo Mono', ui-monospace, Menlo, monospace); font-size: 16px; outline: none; background-image: repeating-linear-gradient(90deg, var(--ink-dimmer, #4d4d4d) 0 0.6em, transparent 0.6em 0.9em); background-size: 100% 2px; background-repeat: no-repeat; background-position: 0 100%; caret-color: var(--voice, #ffff00); } .ds-input::placeholder { color: var(--ink-dim, #949494); } .ds-input:focus, .ds-input:focus-visible { background-image: repeating-linear-gradient(90deg, var(--voice, #ffff00) 0 0.6em, transparent 0.6em 0.9em); background-size: 100% 2px; background-repeat: no-repeat; background-position: 0 100%; }"
},
{
"name": "Colour Field Band",
"kind": "card",
"refersTo": "field-band",
"description": "A section header as a full-bleed band of colour with the title reversed out of it. This replaces every card header and every 1px border. Goes yellow when something wants attention, red when a region is down.",
"html": "<div class=\"ds-field\"><span>Now Showing</span><span class=\"ds-field-meta\">2 active</span></div><div class=\"ds-field ds-warn\"><span>Requests</span><span class=\"ds-field-meta\">3 of 41</span></div><div class=\"ds-field ds-crit\"><span>Array</span><span class=\"ds-field-meta\">unreachable</span></div>",
"css": ".ds-field { display: flex; align-items: baseline; gap: 1ch; margin: 0 0 0.5rem; padding: 0 1ch; min-height: 2.175rem; background: var(--field, #0000ff); color: var(--voice, #ffff00); border-radius: 0; font-family: var(--font, 'Chivo Mono', ui-monospace, Menlo, monospace); font-size: var(--fs, 0.9375rem); line-height: 1.45; letter-spacing: 0.22em; text-transform: uppercase; } .ds-field-meta { margin-left: auto; color: var(--tt-white, #fff); letter-spacing: 0.06em; } .ds-field.ds-warn { background: var(--voice, #ffff00); color: var(--tt-black, #000); } .ds-field.ds-warn .ds-field-meta { color: var(--tt-black, #000); } .ds-field.ds-crit { background: var(--alert, #ff0000); color: var(--tt-white, #fff); }"
},
{
"name": "Index Row",
"kind": "nav",
"refersTo": "index-row",
"description": "The signature row: page number, name, description, live value in its status colour. On P100 these are the navigation and the status page at once. The whole row reverses on hover and focus.",
"html": "<a class=\"ds-row\" href=\"#\"><span class=\"ds-row-num\">105</span><span class=\"ds-row-name\">System</span><span class=\"ds-row-body\"><span class=\"ds-row-sub\">Array, disks and indexers</span></span><span class=\"ds-row-val ds-ok\">62% FULL</span></a>",
"css": ".ds-row { display: flex; align-items: center; gap: 1.5ch; min-height: 44px; padding: 0 1ch; background: transparent; color: var(--tt-white, #fff); border-radius: 0; text-decoration: none; font-family: var(--font, 'Chivo Mono', ui-monospace, Menlo, monospace); font-size: var(--fs, 0.9375rem); line-height: 1.45; font-variant-numeric: tabular-nums; } .ds-row-num { flex: 0 0 auto; color: var(--voice, #ffff00); letter-spacing: 0.08em; } .ds-row-name { flex: 0 0 auto; min-width: 11ch; letter-spacing: 0.16em; text-transform: uppercase; } .ds-row-body { flex: 1; min-width: 0; } .ds-row-sub { color: var(--ink-dim, #949494); } .ds-row-val { flex: 0 0 auto; margin-left: auto; color: var(--live, #00ffff); white-space: nowrap; } .ds-row-val.ds-ok { color: var(--ok, #00ff00); } .ds-row-val.ds-alert { color: var(--alert, #ff0000); } .ds-row-val.ds-pending { color: var(--pending, #ff00ff); } .ds-row:hover, .ds-row:focus-visible { outline: none; background: var(--voice, #ffff00); color: var(--tt-black, #000); } .ds-row:hover .ds-row-num, .ds-row:hover .ds-row-val, .ds-row:hover .ds-row-sub, .ds-row:focus-visible .ds-row-num, .ds-row:focus-visible .ds-row-val, .ds-row:focus-visible .ds-row-sub { color: var(--tt-black, #000); }"
},
{
"name": "The One Ruler (bar)",
"kind": "custom",
"refersTo": "index-row",
"description": "Every bar, meter and percentage in the service is this, at one shared width, filled in whole mosaic cells. 60% on the array and 60% on an indexer are the same width and compare directly.",
"html": "<span class=\"ds-bar ds-bar-ok\" role=\"img\" aria-label=\"62 percent\"><i style=\"width:60%\"></i></span> <span class=\"ds-bar ds-bar-alert\" role=\"img\" aria-label=\"95 percent\"><i style=\"width:95%\"></i></span>",
"css": ".ds-bar { display: inline-block; width: min(calc(var(--bar-cells, 20) * var(--bar-cell-w, 0.62em)), 100%); height: 0.8em; flex: 0 0 auto; vertical-align: -0.05em; border-radius: 0; font-size: var(--fs, 0.9375rem); background-image: repeating-linear-gradient(90deg, var(--ink-dimmer, #4d4d4d) 0 calc(var(--bar-cell-w, 0.62em) - 2px), transparent calc(var(--bar-cell-w, 0.62em) - 2px) var(--bar-cell-w, 0.62em)); } .ds-bar > i { display: block; height: 100%; background-image: repeating-linear-gradient(90deg, currentColor 0 calc(var(--bar-cell-w, 0.62em) - 2px), transparent calc(var(--bar-cell-w, 0.62em) - 2px) var(--bar-cell-w, 0.62em)); } .ds-bar-ok { color: var(--ok, #00ff00); } .ds-bar-warn { color: var(--voice, #ffff00); } .ds-bar-alert { color: var(--alert, #ff0000); }"
},
{
"name": "Fastext Keys",
"kind": "nav",
"refersTo": "index-row",
"description": "The four coloured keys along the bottom of every page. Real navigation, not decoration: click one, or press its initial (r/g/y/c). Deliberately not fixed to the viewport, because it is the last row of the page.",
"html": "<nav class=\"ds-fastext\" aria-label=\"Fastext\"><a class=\"ds-k-red\" href=\"#\"><span>100 Index</span></a><a class=\"ds-k-green\" href=\"#\"><span>102 Library</span></a><a class=\"ds-k-yellow\" href=\"#\"><span>103 Discover</span></a><a class=\"ds-k-cyan\" href=\"#\"><span>105 System</span></a></nav>",
"css": ".ds-fastext { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1ch; font-family: var(--font, 'Chivo Mono', ui-monospace, Menlo, monospace); font-size: var(--fs, 0.9375rem); line-height: 1.45; } .ds-fastext a { display: flex; align-items: center; gap: 1ch; min-width: 0; min-height: 44px; padding: 0 1ch; border-radius: 0; text-decoration: none; letter-spacing: 0.16em; text-transform: uppercase; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .ds-fastext a > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; color: var(--tt-white, #fff); } .ds-fastext a::before { content: \"\"; flex: 0 0 auto; width: 1.2ch; height: 1em; background: currentColor; } .ds-k-red { color: var(--alert, #ff0000); } .ds-k-green { color: var(--ok, #00ff00); } .ds-k-yellow { color: var(--voice, #ffff00); } .ds-k-cyan { color: var(--live, #00ffff); } .ds-fastext a:hover, .ds-fastext a:focus-visible { outline: none; background: currentColor; } .ds-fastext a:hover > span, .ds-fastext a:focus-visible > span { color: var(--tt-black, #000); }"
},
{
"name": "Poster Tile",
"kind": "card",
"refersTo": "index-row",
"description": "Artwork is the only thing in this world not made of characters, so it sits as a flat rectangle on the grid with its label below. No frame, no rounding, no shadow. A missing poster is a dithered field, never a placeholder illustration.",
"html": "<div class=\"ds-tiles\"><div class=\"ds-tile\"><span class=\"ds-tile-art ds-tile-empty\" aria-hidden=\"true\"></span><span class=\"ds-tile-t\">The Thing</span><span class=\"ds-tile-s\"><span class=\"ds-tile-badge\">92%</span> 1982</span><span class=\"ds-tile-p\"><button class=\"ds-tile-btn\">+ Add</button></span></div></div>",
"css": ".ds-tiles { display: grid; gap: 1.359rem 2ch; grid-template-columns: repeat(auto-fill, minmax(min(12.5ch, 100%), 1fr)); font-family: var(--font, 'Chivo Mono', ui-monospace, Menlo, monospace); font-size: var(--fs, 0.9375rem); line-height: 1.45; } .ds-tile { display: flex; flex-direction: column; min-width: 0; } .ds-tile-art { display: block; width: 100%; aspect-ratio: 2 / 3; object-fit: cover; border-radius: 0; background: var(--ink-dimmer, #4d4d4d); } .ds-tile-empty { background-image: repeating-linear-gradient(45deg, var(--ink-dimmer, #4d4d4d) 0 1px, var(--tt-black, #000) 1px 4px); } .ds-tile-t { margin-top: 0.4em; color: var(--tt-white, #fff); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .ds-tile-s { color: var(--ink-dim, #949494); } .ds-tile-badge { color: var(--voice, #ffff00); } .ds-tile-p { margin-top: 0.35em; } .ds-tile-btn { display: inline-flex; align-items: center; justify-content: center; width: 100%; min-height: 2.175rem; padding: 0 1.5ch; background: var(--voice, #ffff00); color: var(--tt-black, #000); border: none; border-radius: 0; font: inherit; letter-spacing: 0.16em; text-transform: uppercase; cursor: pointer; } .ds-tile-btn:hover { background: var(--live, #00ffff); } .ds-tile-btn:focus-visible { outline: none; background: var(--live, #00ffff); }"
},
{
"name": "Degraded Service",
"kind": "custom",
"refersTo": "field-band-crit",
"description": "Teletext's failure was a page that did not arrive, said in red where the content should have been. No panel, no left-border accent, no icon. The raw exception hides behind a character-drawn disclosure marker.",
"html": "<div class=\"ds-err\" role=\"alert\"><div class=\"ds-err-cause\">Tautulli did not answer. Check that the service is running.</div><details><summary>Technical detail</summary><pre>ConnectionRefusedError: [Errno 61] Connection refused</pre></details></div>",
"css": ".ds-err { padding: 0 1ch; color: var(--alert, #ff0000); font-family: var(--font, 'Chivo Mono', ui-monospace, Menlo, monospace); font-size: var(--fs, 0.9375rem); line-height: 1.45; } .ds-err details { margin-top: 0.3em; } .ds-err summary { display: flex; align-items: center; min-height: 2.175rem; color: var(--ink-dim, #949494); cursor: pointer; list-style: none; letter-spacing: 0.16em; text-transform: uppercase; } .ds-err summary::-webkit-details-marker { display: none; } .ds-err summary::before { content: \"\\25B8\\00a0\"; } .ds-err details[open] summary::before { content: \"\\25BE\\00a0\"; } .ds-err summary:hover { color: var(--tt-white, #fff); } .ds-err summary:focus-visible { outline: none; background: var(--voice, #ffff00); color: var(--tt-black, #000); } .ds-err pre { margin: 0.3em 0 0; padding: 0.5em 1ch; background: var(--ink-dimmer, #4d4d4d); color: var(--tt-white, #fff); font: inherit; white-space: pre-wrap; word-break: break-word; }"
}
],
"narrative": {
"northStar": "Page 100",
"overview": "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.\n\nThe 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.\n\nThe 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.",
"keyCharacteristics": [
"Eight locked colours at full saturation on pure black; a ninth tone is a dither, never a new colour",
"Zero enclosure: no border, no box, no radius, no shadow, anywhere",
"Exactly two type sizes (15px and 30px), one weight, two blocky monospace faces",
"Every bar in the service shares one 20-cell ruler, so any two percentages compare by eye",
"Grouping by full-bleed colour band, indentation and row weight",
"The page number is the navigation: type three digits anywhere and the service jumps",
"Pages differ in stock — a page holding critical state is set heavier than a quiet one"
],
"rules": [
{ "name": "The Locked Eight Rule", "body": "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.", "section": "colors" },
{ "name": "The Dither Rule", "body": "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.", "section": "colors" },
{ "name": "The One Meaning Rule", "body": "Each colour means one thing across all nine pages. Cyan is never decorative; magenta never appears outside a pending request; red never marks emphasis, only failure. A reader who learns the palette on P100 can read P105 without looking at a label.", "section": "colors" },
{ "name": "The Text Contrast Rule", "body": "--ink-dim is the floor for anything a person reads. --ink-dimmer draws graphics and never sets type.", "section": "colors" },
{ "name": "The Two Heights Rule", "body": "Emphasis comes from colour and from doubling the height. It never comes from a weight ramp and never from a third size. Chivo Mono 600 is loaded but unused; reaching for it is the first sign the hierarchy is being solved the wrong way.", "section": "typography" },
{ "name": "The Bloom Rule", "body": "Phosphor bloom (text-shadow: 0 0 0.4em currentColor@55%) is opt-in via .bloom and is spent only where the world is speaking loudly — page mastheads and the now-showing title. Bloom under a dense table of figures costs more legibility than it buys character, so the double-height figures on System and Library carry none.", "section": "typography" },
{ "name": "The Uppercase-Is-A-Role Rule", "body": "Uppercase plus tracking marks a label, a key or a band title. Content — a show name, a username, a raw error — is never uppercased for effect.", "section": "typography" },
{ "name": "The One Ruler Rule", "body": "Every bar, meter and percentage in the service shares one scale, --bar-cells, and is drawn by the bar() macro and nowhere else. 60% on the array and 60% on an indexer are the same physical width and compare directly. Restyling a bar per context is precisely what the ruler exists to prevent.", "section": "layout" },
{ "name": "The Page Number Is The Navigation Rule", "body": "Every page owns a three-digit number (100 Index, 101 Watch, 102 Library, 103 Discover, 104 Requests, 105 System, 106 Activity, 900 Settings, 000 Sign In). That number is the header's first element, the row label on the index, the browser title prefix, and what a visitor types to jump. There is no separate nav bar, and adding one would be a regression.", "section": "layout" },
{ "name": "The Index Is The Status Page Rule", "body": "On P100 every destination row carries its own live figure in its own status colour, so choosing where to go and reading how things are is one act. A navigation row without a reading is an incomplete row.", "section": "layout" },
{ "name": "The No Enclosure Rule", "body": "Nothing is ever surrounded by a border or a box. Grouping is a coloured field row, indentation and weight. This is the exact reflex the previous design was built on and the main thing this world refuses — an added card, panel, outline, divider rule or hairline is a regression, not a refinement.", "section": "elevation" },
{ "name": "The Reversed Cell Rule", "body": "Focus and hover are a reversed cell, the way a teletext menu row selected itself: outline: none, background set to the state colour, text to black. A focus ring is an enclosure and is therefore banned; the reversal is what replaces it, and it must be present on every interactive element so keyboard focus stays visible.", "section": "elevation" },
{ "name": "The Two Sanctioned Lines Rule", "body": "Exactly two horizontal lines exist, and both are characters in the grid rather than borders on a box: the mosaic separator (.tt-sep, a repeating block-graphic run in dither grey) and the underscore run beneath an input, which is how a teletext form showed a field and which turns yellow on focus. Neither may be repurposed as a divider between arbitrary regions.", "section": "elevation" }
],
"dos": [
"Do take every colour from the locked eight (#000000 #ff0000 #00ff00 #ffff00 #0000ff #ff00ff #00ffff #ffffff) at full saturation, and keep each one's single meaning: yellow speaks, cyan is live, green is healthy, red is off air, magenta wants a human, blue is the only background.",
"Do group with a full-bleed colour band, indentation and row weight. A .tt-field opens a region; the next one closes it.",
"Do draw every bar, meter and percentage through the bar() macro so the whole service shares one ruler.",
"Do set a page carrying critical state heavier than a quiet one — .tt-field.warn when something wants attention, .tt-field.crit when a region is down. Uniform texture across the pages is a failure.",
"Do reverse the cell for hover and focus (state colour ground, black text), and make sure every interactive element has it.",
"Do make a tone that isn't one of the eight by dithering at cell scale, using --ink-dim for anything readable and --ink-dimmer for graphics only.",
"Do give a new page a three-digit number, register it in nav_items, and put that number in the header, the browser title and its row on P100.",
"Do keep spacing on the grid: 1ch horizontal, multiples of --row vertical, 44px minimum on anything tappable.",
"Do keep inputs at the 16px literal, so iOS does not zoom the page on focus.",
"Do draw a trend arrow only where the backend genuinely holds a comparison. Today that is one place — SABnzbd's current speed against its stored 7-day average — because no history exists for disk usage or temperature. An invented trend is worse than no trend; extend the rule by adding history, not by adding arrows."
],
"donts": [
"Don't introduce a ninth colour, a tint, or a desaturated version of one of the eight.",
"Don't enclose anything. No card, no panel, no border, no border-radius, no outline ring, no hairline divider, no box-shadow. The audit test is a grep: border-radius and box-shadow must return zero hits across static/ and templates/.",
"Don't add a third type size or a second font weight. If something needs emphasis, colour it or double its height.",
"Don't put bloom on body copy, on tables of figures, or on anything that is not a masthead or the now-showing title.",
"Don't restyle a bar for one context, or give a card its own scale — that breaks the one ruler and with it every cross-page comparison.",
"Don't set text in --ink-dimmer; it fails contrast on black and has had to be corrected once already.",
"Don't fix the fastext strip, the header or anything else to the viewport. Everything scrolls, because everything is a row of the page.",
"Don't solve overflow with an inner scroll area, a truncated list, or content hidden behind a toggle. Give the content the page's full measure, or lay it out in more columns. The one sanctioned scroller is .tt-table-scroll, and only to keep a wide table from scrolling the whole page sideways.",
"Don't reach for a glyph icon set. Trend and disclosure marks are characters (▲ ▼ ▬ ▸ ▾); artwork is the only non-character content in the world.",
"Don't animate anything beyond the page-transmission stagger and the toast. A teletext page painted in a few rows at a time and then held still."
]
}
}

129
CLAUDE.md
View file

@ -3,8 +3,13 @@
Flask dashboard aggregating status from the user's self-hosted media stack
— Sonarr, Radarr, SABnzbd, Tautulli, Prowlarr, Ombi — plus Unraid array
stats and a Rotten Tomatoes "what's popular that I don't have yet" feed.
Branded "Media Centre" — a single neutral, professional dark theme (the
earlier five novelty themes were removed). Runs as one Docker container on
Branded "Media Centre". Since v2.0.0 (2026-08-29) it presents as a
**broadcast information service** in the teletext tradition: numbered
pages you reach by typing the number, status carried by colour, and no
borders or cards anywhere. This replaced — not refined — the earlier
neutral dark card theme, which had itself replaced five novelty themes.
See DESIGN.md for the visual system and the `static/css/base.css` header
for the five disciplines it is held to. Runs as one Docker container on
the user's Unraid home server. Server-side project directory is still named
`greendale`; the container/repo are named `arr-summary`.
@ -33,66 +38,72 @@ the user's Unraid home server. Server-side project directory is still named
- Rotten Tomatoes / IMDB are scraped directly (`requests` +
`BeautifulSoup`, JSON-LD parsing), not via an official API — brittle
to upstream HTML changes by nature.
- **`templates/`** — Jinja2 templates (`dashboard.html`, `login.html`,
`settings.html`). Stale duplicates of these once sat at the repo root;
they were never rendered (Flask loads from `templates/`) and were deleted
during the redesign.
- **`templates/`** — Jinja2 templates. `_shell.html` is the root layout
every page extends; `_macros.html` holds the shared fragments. Pages are
`home.html` (P100), `watch.html` (101), `library.html` (102),
`discover.html` (103), `requests.html` (104), `system.html` (105),
`activity.html` (106), `settings.html` (900), plus `login.html`, which
stands alone. The old single-page `dashboard.html` was deleted in the
v2.0.0 redesign along with `dashboard.css`, `login.css`, `settings.css`
and `dashboard.js`.
- Templates carry markup only. CSS and JS live in `static/` and are
linked via `url_for('static', ...)` so they stay correct behind a
`SCRIPT_NAME` subpath. Server-rendered config (timezone, script root)
reaches JS through `<body data-*>`, never string-interpolated into a
script block.
`SCRIPT_NAME` subpath. Server-rendered config (timezone, script root,
the page-number map) reaches JS through `<body data-*>`, never
string-interpolated into a script block.
- **No `style="..."` attributes for anything static.** A one-off tweak
(button height, a margin, a font size) belongs in the matching
`static/css/*.css` file, scoped with a structural selector (an
adjacent-sibling or `:last-child` rule tied to the surrounding markup)
or a small modifier class — not inline on the element. The only
legitimate inline `style=` left in `dashboard.html` are per-request
computed values Jinja has to interpolate (a meter's `width: {{ pct
}}%`, a colour chosen by threshold) and JS-toggled initial visibility
(`display:none` on something `dashboard.js` reveals) — those can't
live in a static stylesheet. Everything else should read as a class
name, not a value.
- **`static/css/base.css`** — the single source of truth for the design
tokens, shared by all three templates: surfaces, a 6-step `--fs-*` type
scale, semantic colours, and `--tap: 44px` for minimum touch targets.
Only four hues are declared (`--accent`, `--ok`, `--warn`, `--danger`);
every dim/border tint derives from them via `color-mix`. Reshading the
app should be a four-line edit here — never hardcode a colour literal in
a page stylesheet, which is exactly how the palette drifted before
(a favicon with its own baked-in hex, and stale `rgba()` danger borders
that survived two palette changes). Page-specific rules live alongside
in `dashboard.css` / `login.css` / `settings.css`.
- `dashboard.html` is ordered by urgency, deliberately mirroring the cache
tiers in `app.py`: a "Right now" strip (live streams, download queue,
array usage) sits above the fold, with library, discovery, requests and
watch insights below it.
- **Cards in a row must earn their height — never leave dead space.** A
`.row` is a CSS grid, so every card stretches to the tallest one in that
row. A card whose content outgrows its neighbours doesn't just get tall,
it hollows out everything beside it. This has bitten once already: the
per-disk Storage list stacked ten disks at `--row-h` each, turning one
third of the "Right now" strip into a ~950px tower and leaving ~1500px of
empty card across Now playing and Downloads.
- Before adding a repeating list to a card in a multi-column row, check
what the row's other cards hold at their **quietest** — one stream, an
idle queue, zero pending requests — and keep the tallest card within
roughly 1.5× of that.
- When the content genuinely won't fit, the fix is **more width**: give
the card its own full-width row (`grid-template-columns: 1fr`) and lay
its list out in columns inside, or use more columns within the existing
card. Ten items down one narrow column is three tidy rows of tiles once
the card spans the page.
- The fix is **never** an inner scroll area, a truncated list, or content
hidden behind a toggle the user has to find — see the "nothing clipped
or hidden" rule the header pills already follow.
- Where a short card still can't be avoided, centre its content
vertically rather than reaching for `align-items: start`; equal-height
cards are a settled preference.
- Degraded services render through the `service_error()` macro, which
pairs `friendly_error` (human cause) with the raw exception behind a
disclosure, passed through `redact_secrets` — SABnzbd and Tautulli put
their API key in the request URL, so it appears in raw tracebacks.
belongs in `static/css/teletext.css`, scoped with a structural selector
or a small modifier class. The only legitimate inline `style=` is a
per-request computed value Jinja has to interpolate — currently just
the `bar()` macro's fill width.
- `_shell.html`'s first `<body>` child is an HTML comment holding the
**direction contract** for the visual world (thesis, own-world, story,
first viewport, form, seed key). Leave it in place; it is what a later
session audits the design against.
- **`static/css/base.css`** — tokens and reset. Its header comment states
the **five disciplines** the visual world is held to; read them before
changing anything visual, and treat breaking one as a regression rather
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.
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
world most refuses — the previous design was built on bordered cards.
3. **One ruler.** Every bar and percentage shares one scale
(`--bar-cells`) and is drawn only by the `bar()` macro in
`_macros.html`, so 60% on the array and 60% on an indexer are the same
width and compare directly. Never restyle a bar per section.
4. **Every reading carries its direction.** A value shows where it is
heading. Only drawn where the backend genuinely holds a comparison —
today that is SABnzbd speed against its stored 7-day average, and
nowhere else. Do not invent a trend for data with no history.
5. **Pages differ in stock.** A page holding critical state is set
heavier than a quiet one (`.tt-field.warn` / `.tt-field.crit`).
Uniform texture across all the pages is a failure.
- Colour is **semantic, never decorative**: yellow is the service's own
voice, cyan is a live value, green is healthy, red is off air, magenta
means a human must decide (pending requests only), blue is a field
ground. Reshading means editing the eight tokens here, nothing
downstream.
- Type is **one face at exactly two heights** — `--fs` (15px) and `--fs2`
(30px, double height). Adding a third size breaks the system. The 16px
literal on inputs is deliberate: it prevents iOS zoom-on-focus.
- `--ink-dim` (58% white) is the **only** dimmed token allowed to carry
text; it clears 4.5:1 on black. `--ink-dimmer` (30%) is for graphics
only — bar troughs, the dither field, separators. Setting text in
`--ink-dimmer` fails contrast, which is exactly the bug the redesign's
first inspection round caught.
- **Navigation is the page number.** Typing any three digits anywhere
jumps to that page, and `r`/`g`/`y`/`c` fire the four fastext colour
keys — both in `static/js/service.js`. `nav_items` in `app.py` is the
source of truth for the numbers and is serialised into
`<body data-pages>`.
- **Nothing is clipped, hidden, or sticky.** All seven page numbers and
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
(build image locally → tarball → rsync + `docker load` over SSH) that
predates the Forgejo pipeline below. Still present, not removed, but the

349
DESIGN.md Normal file
View file

@ -0,0 +1,349 @@
---
name: Media Centre
description: A broadcast information service for a self-hosted media stack — teletext's locked eight colours on pure black, no borders, no boxes, no radius.
colors:
tt-black: "#000000"
tt-red: "#ff0000"
tt-green: "#00ff00"
tt-yellow: "#ffff00"
tt-blue: "#0000ff"
tt-magenta: "#ff00ff"
tt-cyan: "#00ffff"
tt-white: "#ffffff"
ink-dim: "#949494"
ink-dimmer: "#4d4d4d"
typography:
double-height:
fontFamily: "DotGothic16, 'Chivo Mono', ui-monospace, Menlo, monospace"
fontSize: "1.875rem"
fontWeight: 400
lineHeight: 1.15
letterSpacing: "0.02em"
double-height-mobile:
fontFamily: "DotGothic16, 'Chivo Mono', ui-monospace, Menlo, monospace"
fontSize: "1.375rem"
fontWeight: 400
lineHeight: 1.15
letterSpacing: "0.02em"
body:
fontFamily: "'Chivo Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"
fontSize: "0.9375rem"
fontWeight: 400
lineHeight: 1.45
letterSpacing: "normal"
fontFeature: "tabular-nums"
label:
fontFamily: "'Chivo Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"
fontSize: "0.9375rem"
fontWeight: 400
lineHeight: 1.45
letterSpacing: "0.16em"
textTransform: "uppercase"
field-title:
fontFamily: "'Chivo Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"
fontSize: "0.9375rem"
fontWeight: 400
lineHeight: 1.45
letterSpacing: "0.22em"
textTransform: "uppercase"
input:
fontFamily: "'Chivo Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"
fontSize: "16px"
fontWeight: 400
lineHeight: 1.45
letterSpacing: "normal"
rounded:
none: "0"
spacing:
cell: "1ch"
row: "1.359rem"
gutter: "1.5rem"
gutter-mobile: "1rem"
tap: "44px"
measure: "92ch"
components:
button-primary:
backgroundColor: "{colors.tt-yellow}"
textColor: "{colors.tt-black}"
typography: "{typography.label}"
rounded: "{rounded.none}"
padding: "0 1.5ch"
height: "{spacing.tap}"
button-primary-hover:
backgroundColor: "{colors.tt-cyan}"
textColor: "{colors.tt-black}"
button-primary-armed:
backgroundColor: "{colors.tt-red}"
textColor: "{colors.tt-white}"
button-primary-done:
backgroundColor: "transparent"
textColor: "{colors.tt-green}"
button-primary-disabled:
backgroundColor: "{colors.ink-dimmer}"
textColor: "{colors.ink-dim}"
button-quiet:
backgroundColor: "transparent"
textColor: "{colors.tt-cyan}"
typography: "{typography.label}"
rounded: "{rounded.none}"
padding: "0 1ch"
height: "{spacing.tap}"
button-quiet-hover:
backgroundColor: "{colors.tt-cyan}"
textColor: "{colors.tt-black}"
input-field:
backgroundColor: "{colors.tt-black}"
textColor: "{colors.tt-yellow}"
typography: "{typography.input}"
rounded: "{rounded.none}"
padding: "0 1ch"
height: "{spacing.tap}"
field-band:
backgroundColor: "{colors.tt-blue}"
textColor: "{colors.tt-yellow}"
typography: "{typography.field-title}"
rounded: "{rounded.none}"
padding: "0 1ch"
height: "2.175rem"
field-band-warn:
backgroundColor: "{colors.tt-yellow}"
textColor: "{colors.tt-black}"
field-band-crit:
backgroundColor: "{colors.tt-red}"
textColor: "{colors.tt-white}"
index-row:
backgroundColor: "transparent"
textColor: "{colors.tt-white}"
typography: "{typography.body}"
rounded: "{rounded.none}"
padding: "0 1ch"
height: "{spacing.tap}"
index-row-hover:
backgroundColor: "{colors.tt-yellow}"
textColor: "{colors.tt-black}"
toast:
backgroundColor: "{colors.tt-green}"
textColor: "{colors.tt-black}"
typography: "{typography.label}"
rounded: "{rounded.none}"
padding: "0.6em 1.5rem"
toast-error:
backgroundColor: "{colors.tt-red}"
textColor: "{colors.tt-white}"
---
# Design System: Media Centre
## Overview
**Creative North Star: "Page 100"**
Media Centre is a broadcast information service, not a dashboard. It refuses the bordered-card grid that every self-hosted *arr dashboard ships — including this one, until this redesign. The world is teletext: Ceefax and the American cable guide channel, fused. Eight colours at full saturation on pure black, one blocky monospace at exactly two heights, a character-cell grid, and grouping carried entirely by colour fields and row weight. The visitor types or clicks a page number, reads the machine's state off colour alone, and leaves knowing whether anything needs them.
The density is high and unapologetic. A teletext page was 40 columns; this one gets 92 (`--page-cols: 92ch`) because the screen allows it, but the character grid is the substrate everywhere — padding is measured in `ch`, bars are measured in cells, and nothing floats free of the row rhythm (`--row`, one character row). There is no chrome. The header is a row of the page, not furniture bolted above it, so it scrolls away with everything else. The fastext colour keys sit at the bottom of the page where they always sat, not fixed to the viewport.
The 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.
**Key Characteristics:**
- Eight locked colours at full saturation on pure black; a ninth tone is a dither, never a new colour
- Zero enclosure: no border, no box, no radius, no shadow, anywhere
- Exactly two type sizes (15px and 30px), one weight, two blocky monospace faces
- Every bar in the service shares one 20-cell ruler, so any two percentages compare by eye
- Grouping by full-bleed colour band, indentation and row weight
- The page number is the navigation: type three digits anywhere and the service jumps
- Pages differ in stock — a page holding critical state is set heavier than a quiet one
## Colors
Teletext had exactly eight colours, at full saturation, and the product's semantics were already the same semantics: green runs, yellow works, red is off air. The palette is locked; softening a value is a regression, because the emissive-on-black scene is what full saturation is for.
### Primary
- **Service Yellow** (`#ffff00`, `--voice`): the service speaking in its own voice. Mastheads, page numbers, the row number on every index line, section-band titles, the caret and focus reversal. It is also the "warn" tier on any threshold-coloured value. The loudest colour on the page, and the one the visitor's eye is trained to follow to a heading.
- **Transmission Cyan** (`#00ffff`, `--live`): live values and now-showing. The header clock, active figures, links, the currently-playing title at double height. Cyan means *this number is moving*.
### Secondary
- **Signal Green** (`#00ff00`, `--ok`): running, healthy, available. Service ON AIR, array usage under 75%, progress bars on a healthy stream, the confirmation toast.
- **Off-Air Red** (`#ff0000`, `--alert`): off air, failed, danger. Degraded-service copy, OFF AIR rows, usage at or above 90%, disk temperature at or above 55°C, the armed state of a destructive button.
- **Request Magenta** (`#ff00ff`, `--pending`): waiting on a human. Ombi's pending count and requested events, and nothing else. Its rarity is the point — magenta on the page means someone is owed a decision.
### Tertiary
- **Field Blue** (`#0000ff`, `--field`): the only colour in the system used as a background rather than as ink. It draws the full-bleed section band and the table-row hover. It never sets text.
### Neutral
- **Transmission White** (`#ffffff`, `--ink`): body text, row titles, table row headers. The default reading colour.
- **Pure Black** (`#000000`): the ground, everywhere, unconditionally — `html`, `body`, `theme-color`, scrollbar tracks, input fills. There is no second surface colour, because there are no surfaces.
- **Dim Ink** (`#949494`, `--ink-dim`, white at 58%): the *only* dimmed value permitted to carry text. At 58% it clears 4.5:1 on black. Secondary row copy, labels, timestamps, the flat trend arrow.
- **Dither Grey** (`#4d4d4d`, `--ink-dimmer`, white at 30%): graphics only — bar troughs, the mosaic separator, the empty-artwork field, the disabled-button fill, scrollbar thumbs. At 30% it fails text contrast outright, and text set in it has already had to be fixed once.
### Named Rules
**The Locked Eight Rule.** Eight colours exist. Adding a ninth hue, or desaturating one of the eight to make it sit more politely, is a regression rather than a variation. If a new state needs a colour, it takes one of the eight and inherits that colour's meaning.
**The Dither Rule.** A mid-tone is an ordered dither of two of the eight at cell scale — a checkerboard or a repeating gradient stepped at `--dither-size: 2px` or at `--bar-cell-w` — exactly how a 16-colour screen faked a gradient. It is never a ninth colour smuggled into the palette. The two derived greys are the sanctioned exception, and they derive from white by `color-mix`, not by hand.
**The One Meaning Rule.** Each colour means one thing across all nine pages. Cyan is never decorative; magenta never appears outside a pending request; red never marks emphasis, only failure. A reader who learns the palette on P100 can read P105 without looking at a label.
**The Text Contrast Rule.** `--ink-dim` is the floor for anything a person reads. `--ink-dimmer` draws graphics and never sets type.
## Typography
**Display Font:** DotGothic16 (with Chivo Mono, `ui-monospace`, Menlo fallback)
**Body Font:** Chivo Mono (with `ui-monospace`, SFMono-Regular, Menlo, Consolas fallback)
**Character:** Two blocky monospaces doing one job between them. Chivo Mono sets the entire service at one size and one weight — even table headers are explicitly `font-weight: 400` — with `tabular-nums` on so columns of figures line up on the character grid without a table cell holding them. DotGothic16 carries every double-height row: a pixel-derived face whose letterforms read as broadcast rather than as terminal, and the reason a masthead feels transmitted rather than typeset. Anti-aliasing is left on but `text-rendering: optimizeSpeed` keeps the letterforms crisp rather than blending them toward tones the palette does not contain.
### Hierarchy
- **Double height** (DotGothic16, 400, `1.875rem` / 30px, line-height 1.15, `0.02em`): the only size other than body text. Page mastheads, the now-showing title, and the big figures on System, Library and Requests where the number *is* the content. Steps down to `1.375rem` below 760px, because a true 30px double-height row eats a phone viewport.
- **Body** (Chivo Mono, 400, `0.9375rem` / 15px, line-height 1.45, tabular figures): everything else. Row titles, table cells, meta lines, error copy, hints. There is no third size and no second weight.
- **Label** (Chivo Mono, 400, 15px, `0.16em`, uppercase): row names, figure captions, buttons, fastext keys, table headers, disclosure summaries. Letterspacing and case do the work a smaller size or a lighter weight would do in a scale-based system.
- **Field title** (Chivo Mono, 400, 15px, `0.22em`, uppercase): the section band's own text, and the header row's service name. The widest tracking in the system, reserved for the two places that announce a region.
- **Input** (Chivo Mono, 400, `16px` literal): the one hardcoded pixel size in the codebase, and it is deliberate — anything under 16px makes iOS Safari zoom the page on focus, which would break the character grid mid-interaction.
### Named Rules
**The Two Heights Rule.** Emphasis comes from colour and from doubling the height. It never comes from a weight ramp and never from a third size. Chivo Mono 600 is loaded but unused; reaching for it is the first sign the hierarchy is being solved the wrong way.
**The Bloom Rule.** Phosphor bloom (`text-shadow: 0 0 0.4em currentColor@55%`) is opt-in via `.bloom` and is spent only where the world is speaking loudly — page mastheads and the now-showing title. Bloom under a dense table of figures costs more legibility than it buys character, so the double-height figures on System and Library carry none.
**The Uppercase-Is-A-Role Rule.** Uppercase plus tracking marks a label, a key or a band title. Content — a show name, a username, a raw error — is never uppercased for effect.
## Layout
One measure for the whole service: `min(92ch, 100%)`, centred, with a `1.5rem` gutter that narrows to `1rem` below 760px. The page itself is the only thing ever centred; everything inside it is flush left against the character grid.
The vertical rhythm is the character row: `--row` is `--fs × --lh` (≈21.75px), and every margin in the system is a multiple or simple fraction of it (`--row`, `calc(--row * 0.75)`, `calc(--row * 1.5)`, `calc(--row * 2)`). Horizontal rhythm is measured in character cells: block padding is `1ch`, gaps between row parts are `1ch`–`1.5ch`, figure columns are `4ch` apart, tile columns `2ch`. Interactive rows and controls are held to `--tap: 44px` minimum height.
The page is a single column of stacked regions, in the order: masthead → colour-field band → content block → band → block. The only grids in the system are the tile grid (`repeat(auto-fill, minmax(min(12.5ch, 100%), 1fr))`), the fastext key strip (4 columns, 2 on a phone), and the settings key panel (`minmax(min(30ch, 100%), 1fr)`). There is no card grid, and no multi-column region whose columns have to be equalised — the failure mode that dominated the previous design cannot occur here.
Responsive behaviour is minimal by construction, because a character-cell grid narrows on its own. Three real moves: at 760px the double-height row drops to `1.375rem`, the fastext strip goes to two columns rather than shrinking a key below a tap target, and the shared bar ruler narrows from 20 cells to 14. At 560px a list row wraps — its description drops to a full-width third line under the number, name and value, rather than the row growing wider than the screen — and the header clock takes its own line.
### Named Rules
**The One Ruler Rule.** Every bar, meter and percentage in the service shares one scale, `--bar-cells`, and is drawn by the `bar()` macro and nowhere else. 60% on the array and 60% on an indexer are the same physical width and compare directly. Restyling a bar per context is precisely what the ruler exists to prevent.
**The Page Number Is The Navigation Rule.** Every page owns a three-digit number (100 Index, 101 Watch, 102 Library, 103 Discover, 104 Requests, 105 System, 106 Activity, 900 Settings, 000 Sign In). That number is the header's first element, the row label on the index, the browser title prefix, and what a visitor types to jump. There is no separate nav bar, and adding one would be a regression.
**The Index Is The Status Page Rule.** On P100 every destination row carries its own live figure in its own status colour, so choosing where to go and reading how things are is one act. A navigation row without a reading is an incomplete row.
## Elevation & Depth
**There are no shadows.** The codebase contains zero `box-shadow` declarations and zero `border-radius` declarations — this is checkable with a single grep, and it is the audit test for the whole system. Nothing is lifted, nothing is layered, nothing casts.
Depth is conveyed three ways, all of them native to a phosphor screen rather than to paper. First, **emission**: full-saturation colour on pure black reads as lit, and brightness alone separates a live cyan figure from dim-grey secondary copy. Second, **reversal**: a region comes forward by inverting — a coloured band with black or yellow text reversed out of it is the only "raised" gesture the system has, and it is how section headers, hover, focus, buttons and the toast all announce themselves. Third, **bloom**: the single `text-shadow` in the system is a phosphor halo at 55% of the element's own colour, applied only to mastheads and the now-showing title, so the loudest rows glow slightly the way a CRT let bright phosphor spill.
### Named Rules
**The No Enclosure Rule.** Nothing is ever surrounded by a border or a box. Grouping is a coloured field row, indentation and weight. This is the exact reflex the previous design was built on and the main thing this world refuses — an added card, panel, outline, divider rule or hairline is a regression, not a refinement.
**The Reversed Cell Rule.** Focus and hover are a reversed cell, the way a teletext menu row selected itself: `outline: none`, background set to the state colour, text to black. A focus ring is an enclosure and is therefore banned; the reversal is what replaces it, and it must be present on every interactive element so keyboard focus stays visible.
**The Two Sanctioned Lines Rule.** Exactly two horizontal lines exist, and both are characters in the grid rather than borders on a box: the mosaic separator (`.tt-sep`, a repeating block-graphic run in dither grey) and the underscore run beneath an input, which is how a teletext form showed a field and which turns yellow on focus. Neither may be repurposed as a divider between arbitrary regions.
## Shapes
The corner language is zero radius, everywhere, with no scale — `rounded.none: "0"` is the entire ramp, and there is no step above it to reach for. Every rectangle in the system is a hard-cornered cell: buttons, inputs, colour bands, poster tiles, bar segments, the fastext key swatch, the toast.
Form comes from the character grid rather than from silhouette. A poster tile is a flat `2/3` rectangle with `object-fit: cover`, sitting directly on the black with its label below — no frame, no rounding, no shadow. When artwork fails to load, the tile shows a 45° dithered field rather than a fake poster or a placeholder icon. The bar is a run of discrete mosaic cells, not a continuous track: both the trough and the fill are repeating gradients stepped at `--bar-cell-w` with a 2px gap, so a bar always reads as *n* whole blocks lit out of 20. The fastext key carries its colour as a solid `1.2ch × 1em` cell at its head — the physical coloured button on the remote.
Iconography is drawn from characters, never from an icon set: `▲ ▼ ▬` for trend, `▸ ▾` for a disclosure marker. There are no glyph icons and no SVG furniture apart from the favicon.
## Components
### Buttons
- **Shape:** hard-cornered cell (`0` radius), `border: none`, minimum `44px` tall, `0 1.5ch` padding, uppercase at `0.16em`.
- **Primary:** a reversed row of cells — solid Service Yellow ground, black text. That is the entire fill vocabulary; there is no outlined or filled-vs-tonal distinction.
- **Hover:** ground swaps to Transmission Cyan, text stays black. **Focus:** the global reversed-cell treatment (yellow ground, black text, no ring).
- **Armed:** a destructive action arms on first press — ground goes Off-Air Red, white text, label becomes `CONFIRM?`. **Sending:** label becomes `SENDING`, disabled. **Done:** ground drops to transparent, text Signal Green, label `ADDED`. **Disabled:** dither-grey ground, dim-ink text.
- **Quiet:** transparent ground, cyan text, `0 1ch`; inverts to a cyan ground with black text on hover. Used for the secondary save actions on Settings.
- **Small:** `.tt-btn-sm` drops the height to `calc(--row * 1.6)` and goes full-width inside a tile.
### Inputs / Fields
- **Style:** black ground, Service Yellow text, no border and no box. The field is marked by a 2px underscore run pinned to the bottom edge — a repeating block-graphic gradient in dither grey. `44px` minimum height, `16px` type (the iOS anti-zoom literal).
- **Focus:** the underscore run turns Service Yellow. Nothing else changes; there is no ring, no glow, no border colour shift.
- **Placeholder:** dim ink. **Caret:** Service Yellow. **Select:** the same field with `appearance: none` and black-on-yellow options.
- **Label:** sits above the field in dim ink, uppercase at `0.16em`. **Hint:** dim ink directly under. **Inline error:** Off-Air Red with a reserved `min-height: 1em` so the layout does not jump when it appears.
### Colour Field Band (section header)
- **Style:** a full-bleed band of Field Blue with the section title reversed out in Service Yellow, uppercase at `0.22em`, `calc(--row * 1.6)` tall, `0 1ch` padding. Optional right-aligned meta in white at `0.06em`.
- **This is what replaces every card header and every 1px border in the previous design.** A region begins at a band and ends where the next one starts.
- **Stock variants:** `.warn` turns the band Service Yellow with black text; `.crit` turns it Off-Air Red with white text. This is how a page carrying critical state is set heavier than a quiet one.
### Index / List Row
- **Style:** a flush-left row at `44px` minimum height with four optional parts in fixed order — page number (yellow, `0.08em`), name (uppercase, `0.16em`, `11ch` minimum so names column up), body (title plus dim-ink subtitle, ellipsised), and a right-flushed value.
- **Value tones:** cyan by default, with `.ok` green, `.alert` red, `.warn` yellow, `.pending` magenta, `.off` dim ink.
- **Hover / focus (links only):** the whole row reverses to a Service Yellow ground with every child forced to black — number, value and subtitle included.
- **Mobile:** below 560px the body drops to its own full-width line beneath the number, name and value.
### Tables
- **Style:** no rules, no zebra, no borders, no cell backgrounds. Columns are held by alignment and the monospace grid alone. `0.35em 1ch` cell padding; numeric columns right-aligned with `2ch` trailing space.
- **Head:** Service Yellow, uppercase, `0.16em`, `font-weight: 400` like everything else. **Row header:** white. **Cells:** dim ink, with `.val` cells in cyan.
- **Hover:** the row reverses to a Field Blue ground with white text. **Overflow:** the table scrolls inside `.tt-table-scroll` rather than the page scrolling sideways.
### Tiles
- **Style:** a flat `2/3` poster on the black grid, `object-fit: cover`, dither-grey ground while loading. Title below in white (single line, ellipsised), subtitle in dim ink with an optional yellow badge (the score, the age) leading it. Optional full-width small button underneath.
- **Empty state:** a 45° dithered field, never a placeholder illustration or icon.
- **Grid:** `repeat(auto-fill, minmax(min(12.5ch, 100%), 1fr))` at `--row` × `2ch` gaps.
### Navigation (Fastext)
- **Style:** four coloured keys as the last row of the page — red, green, yellow, cyan — each a `44px` row with a solid `1.2ch` colour cell at its head and its destination in white, uppercase at `0.16em`. Two columns below 760px.
- **Deliberately not fixed to the viewport.** It is the last row of the page, where it always was.
- **Hover / focus:** the key fills with its own colour and the label goes black.
- **Behaviour:** clicking a key or pressing its initial (`r`, `g`, `y`, `c`) navigates. Each page may name its own four destinations, so the keys always point somewhere useful from where you are.
### Degraded Service
- **Style:** the failure state is a page that did not arrive, announced in Off-Air Red on the row where the content should have been. No panel, no left-border accent, no icon, no illustration.
- A friendly human cause in red, with the raw exception behind a `▸ TECHNICAL DETAIL` disclosure (dim-ink summary, `44px`-class tap height, native marker suppressed). The raw block sits on a dither-grey ground in white at body size and wraps rather than scrolling.
- Where the failure is structural to a whole region, the region's colour band goes `.crit` above it.
### Toast
- **Style:** a full-width reversed strip pinned to the bottom edge — Signal Green ground, black uppercase text at `0.16em`; Off-Air Red with white text on failure. Slides up 100% and fades in over 150ms. Not a floating card, and not rounded.
### Signature: The Page-Number Jump
The service's defining interaction. Typing any three digits anywhere on any page jumps to that page, exactly as a teletext remote did — no field to focus, no command palette to open. The digits paint into the header as you type them (`P1__` → `P10_` → `P100`) so the interaction shows its own state instead of happening invisibly. An unknown number sits on `P123 ?` for 1.2s and clears; a half-typed number times out after 2s; `Escape` clears immediately. It stands down entirely while the visitor is in an input, textarea, select or contenteditable, and while a modifier key is held.
### Signature: The One Ruler
Every percentage in the service is drawn by one macro at one width — 20 cells (14 on a phone), filled in whole cells, rounded so a bar never ends mid-character. Colour is chosen by threshold unless a tone is passed explicitly: green under 75%, yellow from 75%, red from 90%. Because array usage, disk usage, stream progress and indexer grab-share all render on the same ruler, any two of them can be compared by eye across pages.
## Do's and Don'ts
### Do:
- **Do** take every colour from the locked eight (`#000000 #ff0000 #00ff00 #ffff00 #0000ff #ff00ff #00ffff #ffffff`) at full saturation, and keep each one's single meaning: yellow speaks, cyan is live, green is healthy, red is off air, magenta wants a human, blue is the only background.
- **Do** group with a full-bleed colour band, indentation and row weight. A `.tt-field` opens a region; the next one closes it.
- **Do** draw every bar, meter and percentage through the `bar()` macro so the whole service shares one ruler.
- **Do** set a page carrying critical state heavier than a quiet one — `.tt-field.warn` when something wants attention, `.tt-field.crit` when a region is down. Uniform texture across the pages is a failure.
- **Do** reverse the cell for hover and focus (state colour ground, black text), and make sure every interactive element has it.
- **Do** make a tone that isn't one of the eight by dithering at cell scale, using `--ink-dim` for anything readable and `--ink-dimmer` for graphics only.
- **Do** give a new page a three-digit number, register it in `nav_items`, and put that number in the header, the browser title and its row on P100.
- **Do** keep spacing on the grid: `1ch` horizontal, multiples of `--row` vertical, `44px` minimum on anything tappable.
- **Do** keep inputs at the `16px` literal, so iOS does not zoom the page on focus.
- **Do** draw a trend arrow only where the backend genuinely holds a comparison. Today that is one place — SABnzbd's current speed against its stored 7-day average — because no history exists for disk usage or temperature. An invented trend is worse than no trend; extend the rule by adding history, not by adding arrows.
### Don't:
- **Don't** introduce a ninth colour, a tint, or a desaturated version of one of the eight.
- **Don't** enclose anything. No card, no panel, no `border`, no `border-radius`, no `outline` ring, no hairline divider, no `box-shadow`. The audit test is a grep: `border-radius` and `box-shadow` must return zero hits across `static/` and `templates/`.
- **Don't** add a third type size or a second font weight. If something needs emphasis, colour it or double its height.
- **Don't** put bloom on body copy, on tables of figures, or on anything that is not a masthead or the now-showing title.
- **Don't** restyle a bar for one context, or give a card its own scale — that breaks the one ruler and with it every cross-page comparison.
- **Don't** set text in `--ink-dimmer`; it fails contrast on black and has had to be corrected once already.
- **Don't** fix the fastext strip, the header or anything else to the viewport. Everything scrolls, because everything is a row of the page.
- **Don't** solve overflow with an inner scroll area, a truncated list, or content hidden behind a toggle. Give the content the page's full measure, or lay it out in more columns. The one sanctioned scroller is `.tt-table-scroll`, and only to keep a wide table from scrolling the whole page sideways.
- **Don't** reach for a glyph icon set. Trend and disclosure marks are characters (`▲ ▼ ▬ ▸ ▾`); artwork is the only non-character content in the world.
- **Don't** animate anything beyond the page-transmission stagger and the toast. A teletext page painted in a few rows at a time and then held still.

134
PRODUCT.md Normal file
View file

@ -0,0 +1,134 @@
# Product
<!-- impeccable:product-schema 1 -->
## Platform
web
## Users
Single primary user: the self-hoster who administers the Sonarr/Radarr/
SABnzbd/Tautulli/Prowlarr/Ombi stack and the Unraid server it runs on. They
are both the audience and the operator — checking on the stack is also how
they'd notice something needs fixing. Multi-user login support
(`manage_users.py`) exists for credential hygiene / future-proofing, not
because other people actively use the dashboard day to day.
Used mostly on a desktop/tablet-class screen at home, not primarily on a
phone over cellular — though the 5-second parallel-fetch deadline and
per-service error degradation mean it still has to stay usable and fast on
a phone away from home when needed.
## Product Purpose
A single glanceable page (well, now a small set of focused pages) that
answers "is my media stack healthy and what's new" without opening six
different service UIs. It exists because Sonarr/Radarr/SABnzbd/Tautulli/
Prowlarr/Ombi each have their own web UI with their own login, and none of
them show "what's playing right now + what just finished downloading + is
the array full" in one glance.
Success is a page that loads fast (parallel fetch, 5s wall-clock cap),
degrades gracefully per-card when a service is offline rather than failing
the whole page, and surfaces the handful of numbers that actually change
day to day (active streams, queue, disk usage, new library adds, what's
popular that isn't owned yet).
## Positioning
Not a general-purpose *arr dashboard project (like Organizr or Homarr) —
this is a bespoke, single-tenant tool built around exactly this user's
stack, their Unraid box, and their own judgment calls about what's worth
surfacing (e.g. Rotten Tomatoes "popular but not owned yet" filtered
against the actual library, not just a generic trending feed). It trades
general configurability for being exactly right for one specific setup.
## Operating Context
Runs as a single Docker container (`arr-summary`) on the user's Unraid
home server, deployed via Forgejo push-to-deploy. Fronted by Nginx Proxy
Manager over HTTPS (DNS-01/Porkbun certs — port 80 is blocked by the ISP,
so this is not optional). Session-based login (single shared password or
per-user accounts) gates the whole app; there's no public/unauthenticated
view.
**In-progress restructure (branch `meridian-marquee`):** the app is
splitting from one long single-page dashboard into focused pages — Home
("marquee": what's playing, one line of system state, what's new),
Watch, Library, Discover, Requests, System, Activity — each answering one
question well rather than one long scroll trying to show everything.
This also resolves cases where a content-heavy section (e.g. the per-disk
storage list) would otherwise force awkward row heights on a shared
"Right now" strip. `_shell.html` and `_macros.html` are new shared-layout
scaffolding for this split; `dashboard.html` is the outgoing single-page
template being superseded by `home.html` + the per-page templates.
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 —
narrower per-page payloads, same parallel-fetch/caching discipline
described in CLAUDE.md.
## Capabilities and Constraints
- Aggregates: Sonarr, Radarr, SABnzbd, Tautulli, Prowlarr, Ombi, Unraid
array stats, and scraped Rotten Tomatoes/IMDB "popular, not yet owned"
data.
- Per-service REST clients with independent, tuned cache TTLs; active
streams and the SABnzbd queue are always live, never cached.
- Every dev-machine service call fails by design (dev laptop isn't on the
home network) — per-card error states are the expected local baseline,
not a bug.
- Can add missing titles directly to Radarr/Sonarr from the Discover
flow (`/add-to-radarr`, `/add-to-sonarr`, lookup endpoints).
- RT/IMDB data comes from HTML scraping, not an official API — brittle
to upstream markup changes by nature; this is a known, accepted
tradeoff, not a defect to "fix" by finding an API.
- Reverse-proxy-subpath capable (`SCRIPT_NAME`) though currently deployed
at domain root.
## Brand Commitments
Branded **"Media Centre"**. One committed visual identity, never a
themeable shell — that has held across two redesigns and the five
original novelty themes before them.
As of the 2026-08-29 redesign (v2.0.0) that identity is a **broadcast
information service** in the teletext tradition. The visual system is
DESIGN.md's business, not this file's; what belongs here is that pages
are now numbered and reachable by typing their number, which makes the
page-number model part of how the product is *navigated*, not merely how
it looks — and that the earlier neutral card-based dark theme was
deliberately replaced rather than refined.
Server-side project directory is `greendale`; container/repo are
`arr-summary` — internal naming detail, not user-facing.
## Evidence on Hand
No real service data is reachable from the dev environment (see
Operating Context / CLAUDE.md's "expect every service to be offline on
the dev machine" note) — every card's real, non-error rendering must be
checked either on the deployed Unraid box or by monkey-patching a
fetcher locally per the CLAUDE.md recipe. Do not fabricate sample
Sonarr/Radarr/Tautulli/etc. data as if it were representative; use
realistic-shaped placeholders only when a card must be visually
evaluated offline, and say so.
## Product Principles
1. **Never let one slow/offline service take down the page.** Parallel
fetch with a hard deadline and per-card `service_error()` degradation
is non-negotiable — a single point of failure defeats the purpose.
2. **Show what changes, cache what doesn't.** Cache TTLs are tuned per
service to how often that data actually moves; live-only data (active
streams, download queue) stays live.
3. **One focused question per page, not one page trying to answer all
of them.** The meridian-marquee split exists to keep each page's
"quietest state" reasonable, not to add navigation for its own sake.
4. **Built for this one stack, not a general product.** Configurability
that would matter for other people's setups is explicitly out of
scope.
5. **A committed visual identity, not a themeable shell.** "Media
Centre"'s dark theme is the one look; don't reintroduce per-user or
per-page theme switching.

430
app.py
View file

@ -22,8 +22,8 @@ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# Docker tab caches stale container IDs, so the footer is the reliable way
# to confirm a `docker compose up -d --build` actually took effect.
# Both are env-overridable if the deploy pipeline ever injects them.
APP_VERSION = os.environ.get("APP_VERSION", "1.6.3")
APP_VERSION_DATE = os.environ.get("APP_VERSION_DATE", "2026-08-27")
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.
@ -376,15 +376,24 @@ def sonarr_post(endpoint, payload):
return resp.json()
def get_sonarr_summary():
def sonarr_series_cached():
"""The full series list, behind the 5-minute cache.
Split out of get_sonarr_summary so the library and home pages can reuse
the same cached payload instead of each paying for the fetch.
"""
global _sonarr_cache
try:
now = time.time()
if _sonarr_cache["data"] is not None and now < _sonarr_cache["expires"]:
series = _sonarr_cache["data"]
else:
return _sonarr_cache["data"]
series = sonarr_get("series")
_sonarr_cache = {"data": series, "expires": now + ARR_CACHE_TTL}
return series
def get_sonarr_summary():
try:
series = sonarr_series_cached()
wanted = sonarr_get("wanted/missing?pageSize=1")
total_series = len(series)
monitored = sum(1 for s in series if s.get("monitored"))
@ -419,15 +428,20 @@ def radarr_post(endpoint, payload):
return resp.json()
def get_radarr_summary():
def radarr_movies_cached():
"""The full movie list, behind the 5-minute cache. See sonarr_series_cached."""
global _radarr_cache
try:
now = time.time()
if _radarr_cache["data"] is not None and now < _radarr_cache["expires"]:
movies = _radarr_cache["data"]
else:
return _radarr_cache["data"]
movies = radarr_get("movie")
_radarr_cache = {"data": movies, "expires": now + ARR_CACHE_TTL}
return movies
def get_radarr_summary():
try:
movies = radarr_movies_cached()
total_movies = len(movies)
monitored = sum(1 for m in movies if m.get("monitored"))
downloaded = sum(1 for m in movies if m.get("hasFile"))
@ -499,12 +513,25 @@ def _get_sab_history_cached():
speeds.append(size_bytes / duration)
avg_bytes_per_sec = sum(speeds) / len(speeds) if speeds else 0
avg_speed = _format_speed(avg_bytes_per_sec / 1024)
# 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.
newest = sorted(recent, key=lambda x: x.get("completed", 0), reverse=True)[:20]
recent_items = [{
"name": s_.get("name", "Unknown"),
"category": s_.get("category", ""),
"size": s_.get("size", ""),
"completed": int(s_.get("completed", 0) or 0),
} for s_ in newest]
result = {"week_size": week_size, "day_size": day_size,
"completed_7d": completed_7d, "avg_speed": avg_speed}
"completed_7d": completed_7d, "avg_speed": avg_speed,
"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": "—"}
return {"week_size": "—", "day_size": "—", "completed_7d": "—",
"avg_speed": "—", "recent_items": []}
def get_sabnzbd_summary():
@ -538,6 +565,7 @@ def get_sabnzbd_summary():
"week_size": hist["week_size"],
"day_size": hist["day_size"],
"avg_speed": hist["avg_speed"],
"recent_items": hist.get("recent_items", []),
}
except Exception as e:
return {"status": "error", "error": str(e)}
@ -554,6 +582,23 @@ def tautulli_get(cmd, extra=""):
return resp.json().get("response", {}).get("data", {})
def _ms_to_hm(ms):
"""Milliseconds to a compact '1h 42m' / '38m' label."""
if not ms or ms < 0:
return ""
mins = int(ms // 60000)
h, m = divmod(mins, 60)
return f"{h}h {m:02d}m" if h else f"{m}m"
def _ends_at(remaining_ms):
"""Wall-clock time this stream finishes, as HH:MM. Server-local, which is
the same timezone the header clock renders in."""
if not remaining_ms or remaining_ms < 0:
return ""
return (datetime.now() + timedelta(milliseconds=remaining_ms)).strftime("%H:%M")
def get_tautulli_summary():
global _tautulli_stats_cache
try:
@ -620,11 +665,34 @@ def get_tautulli_summary():
raw_sessions = activity.get("sessions", []) if isinstance(activity, dict) else []
sessions = []
for s in raw_sessions:
try:
progress = int(float(s.get("progress_percent", 0) or 0))
except (TypeError, ValueError):
progress = 0
try:
duration_ms = int(float(s.get("duration", 0) or 0))
offset_ms = int(float(s.get("view_offset", 0) or 0))
except (TypeError, ValueError):
duration_ms = offset_ms = 0
decision = (s.get("transcode_decision") or "").lower()
sessions.append({
"user": s.get("friendly_name") or s.get("user", "Unknown"),
"title": s.get("full_title") or s.get("title", "Unknown"),
"media_type": s.get("media_type", ""),
"state": s.get("state", ""),
# Everything below comes from the same get_activity payload.
"progress": max(0, min(100, progress)),
"player": s.get("player", ""),
"quality": s.get("video_full_resolution") or s.get("quality_profile", ""),
"decision": decision,
"year": s.get("year", ""),
"duration_ms": duration_ms,
"offset_ms": offset_ms,
"elapsed": _ms_to_hm(offset_ms),
"remaining": _ms_to_hm(max(0, duration_ms - offset_ms)),
"ends_at": _ends_at(duration_ms - offset_ms),
"art": s.get("art", ""),
"thumb": s.get("thumb", ""),
})
top_tv = top_tv_list[0] if top_tv_list else None
@ -1109,14 +1177,14 @@ def login():
session.permanent = True
# Always redirect to dashboard — avoids bare-path redirect issues
# when running behind a reverse proxy subpath (e.g. /media/)
return redirect(url_for("dashboard"))
return redirect(url_for("home"))
# Deliberately doesn't say which half was wrong.
error = ("Incorrect username or password." if multi_user
else "Incorrect password. Try again.")
# If nothing is configured at all, auto-login (dev mode)
if not multi_user and not get_active_password():
session["authenticated"] = True
return redirect(url_for("dashboard"))
return redirect(url_for("home"))
return render_template("login.html", error=error, multi_user=multi_user)
@ -1167,64 +1235,62 @@ def api_rt_descriptions():
return jsonify({"descriptions": result, "pending": still_pending})
@app.route("/")
@login_required
def dashboard():
# Fetch all service data in parallel with a hard 5-second wall-clock deadline.
# Any service that hasn't responded in time returns a graceful error dict so
# the page always renders quickly — critical for remote/cellular access.
DASHBOARD_TIMEOUT = 5.0
tasks = {
"sonarr": get_sonarr_summary,
"radarr": get_radarr_summary,
"sabnzbd": get_sabnzbd_summary,
"tautulli": get_tautulli_summary,
"prowlarr": get_prowlarr_summary,
"ombi": get_ombi_summary,
"unraid": get_unraid_stats,
"rt_movies": get_rt_movies,
"rt_tv": get_rt_tv,
# ---------------------------------------------------------------------------
# Page plumbing
#
# Each page fetches only the services it actually renders. The old single
# dashboard route fetched all nine every time; now /system never pays for the
# Rotten Tomatoes scrape and /requests never pays for the 4.4 MB Radarr list.
# ---------------------------------------------------------------------------
PAGE_TASKS = {
"sonarr": lambda: get_sonarr_summary(),
"radarr": lambda: get_radarr_summary(),
"sabnzbd": lambda: get_sabnzbd_summary(),
"tautulli": lambda: get_tautulli_summary(),
"prowlarr": lambda: get_prowlarr_summary(),
"ombi": lambda: get_ombi_summary(),
"unraid": lambda: get_unraid_stats(),
"rt_movies": lambda: get_rt_movies(),
"rt_tv": lambda: get_rt_tv(),
}
PAGE_TIMEOUT = 5.0
def fetch_services(names, timeout=PAGE_TIMEOUT):
"""Fetch the named services in parallel under a hard wall-clock deadline.
Anything that misses the deadline degrades to an error dict rather than
blocking the page — the behaviour the single dashboard route always had,
lifted out so every page gets it.
"""
tasks = {n: PAGE_TASKS[n] for n in names}
results = {}
if not tasks:
return results
with ThreadPoolExecutor(max_workers=len(tasks)) as ex:
futures = {ex.submit(fn): name for name, fn in tasks.items()}
deadline = time.time() + DASHBOARD_TIMEOUT
for future in as_completed(futures, timeout=DASHBOARD_TIMEOUT):
deadline = time.time() + timeout
try:
for future in as_completed(futures, timeout=timeout):
name = futures[future]
try:
results[name] = future.result(timeout=max(0.1, deadline - time.time()))
except Exception as e:
results[name] = {"status": "error", "error": str(e)}
# Fill in any tasks that didn't complete within the deadline
except Exception:
pass # as_completed timed out; the fill-in below covers the rest
for name in tasks:
if name not in results:
results[name] = {"status": "error", "error": "timeout"}
return results
sonarr = results["sonarr"]
radarr = results["radarr"]
sabnzbd = results["sabnzbd"]
tautulli = results["tautulli"]
prowlarr = results["prowlarr"]
ombi = results["ombi"]
unraid = results["unraid"]
sonarr_titles = set(sonarr.get("sonarr_titles", []))
radarr_titles = set(radarr.get("radarr_titles", []))
_rt_movies_raw = results["rt_movies"]
_rt_tv_raw = results["rt_tv"]
rt_movies = [m for m in _rt_movies_raw if not m.get("error") and m.get("title", "").lower() not in radarr_titles][:10]
if not rt_movies and _rt_movies_raw:
rt_movies = _rt_movies_raw
rt_tv = [s for s in _rt_tv_raw if not s.get("error") and s.get("title", "").lower() not in sonarr_titles][:10]
if not rt_tv and _rt_tv_raw:
rt_tv = _rt_tv_raw
s = load_settings()
timezone = s.get("timezone", "America/New_York")
# Public-facing URLs for card header links (via NPM reverse proxy)
arr_host_public = f"https://hippofam.com"
service_urls = {
def service_urls():
"""Public URLs for the 'open in <service>' links, via the reverse proxy."""
arr_host_public = "https://hippofam.com"
return {
"sonarr": f"{arr_host_public}/sonarr",
"radarr": f"{arr_host_public}/radarr",
"sabnzbd": f"{arr_host_public}/sab",
@ -1233,17 +1299,251 @@ def dashboard():
"ombi": f"{arr_host_public}/ombi",
"unraid": f"https://{UNRAID_HOST}",
}
@app.context_processor
def inject_page_chrome():
"""Shell-wide context: the nav, the clock's timezone, service links."""
s = load_settings()
return {
"timezone": s.get("timezone", "America/New_York"),
"service_urls": service_urls(),
# (key, page number, name, href). The page number is the
# navigation: it shows in the header, it's what the visitor
# types to jump, and it's the row label on the index.
"nav_items": [
("home", "100", "Index", "/"),
("watch", "101", "Watch", "/watch"),
("library", "102", "Library", "/library"),
("discover", "103", "Discover", "/discover"),
("requests", "104", "Requests", "/requests"),
("system", "105", "System", "/system"),
("activity", "106", "Activity", "/activity"),
("settings", "900", "Settings", "/settings"),
],
# The four coloured keys along the bottom of every page. A page
# that wants different destinations passes its own `fastext` to
# render_template, which wins over this default.
"fastext": [
("k-red", "r", "100", "Index", "/"),
("k-green", "g", "102", "Library", "/library"),
("k-yellow", "y", "103", "Discover", "/discover"),
("k-cyan", "c", "105", "System", "/system"),
],
}
# ---------------------------------------------------------------------------
# Derived views over data the fetchers already cache
# ---------------------------------------------------------------------------
def _poster_url(images):
for img in images or []:
if img.get("coverType") == "poster":
return img.get("remoteUrl") or img.get("url") or ""
return ""
def _ago(iso):
"""'2024-08-27T10:00:00Z' -> '2 days'. Empty string if unparseable."""
if not iso:
return ""
try:
dt = datetime.strptime(iso[:19], "%Y-%m-%dT%H:%M:%S")
except (ValueError, TypeError):
return ""
days = (datetime.utcnow() - dt).days
if days <= 0:
return "today"
if days == 1:
return "1 day"
if days < 7:
return f"{days} days"
weeks = days // 7
return "1 wk" if weeks == 1 else f"{weeks} wks"
def get_recently_added(limit=6):
"""Newest titles across both libraries.
Derived from the same cached Sonarr/Radarr lists the summaries already
hold — a sort over the `added` field those payloads carry, not a new
API call. Poster URLs come from the same records.
"""
items = []
try:
for sr in sonarr_series_cached():
items.append({
"title": sr.get("title", "Unknown"),
"sub": f"{sr.get('statistics', {}).get('episodeFileCount', 0)} eps",
"kind": "TV",
"added": sr.get("added", "") or "",
"poster": _poster_url(sr.get("images")),
})
except Exception:
pass
try:
for mv in radarr_movies_cached():
if not mv.get("hasFile"):
continue
items.append({
"title": mv.get("title", "Unknown"),
"sub": str(mv.get("year", "")) or "Film",
"kind": "Film",
"added": mv.get("added", "") or "",
"poster": _poster_url(mv.get("images")),
})
except Exception:
pass
items.sort(key=lambda x: x["added"], reverse=True)
items = items[:limit]
for it in items:
it["ago"] = _ago(it["added"])
return items
def get_activity_feed(sabnzbd, ombi, limit=25):
"""A single chronological feed assembled from what the fetchers return.
Three real event sources, no new calls: SABnzbd completions, Ombi
requests, and library additions. Anything without a usable timestamp is
dropped rather than guessed at.
"""
events = []
for it in (sabnzbd or {}).get("recent_items", []):
ts = it.get("completed", 0)
if not ts:
continue
events.append({
"ts": ts, "kind": "grabbed", "label": "Grabbed",
"title": it.get("name", "Unknown"),
"detail": " · ".join(x for x in [it.get("size", ""), it.get("category", "")] if x),
})
for r in (ombi or {}).get("recent", []):
iso = r.get("requested_date", "")
try:
ts = datetime.strptime(iso[:19], "%Y-%m-%dT%H:%M:%S").timestamp()
except (ValueError, TypeError):
continue
events.append({
"ts": ts, "kind": "requested", "label": "Requested",
"title": r.get("title", "Unknown"),
"detail": f"{r.get('user', '')} · {r.get('status', '')}",
})
for it in get_recently_added(limit=12):
iso = it.get("added", "")
try:
ts = datetime.strptime(iso[:19], "%Y-%m-%dT%H:%M:%S").timestamp()
except (ValueError, TypeError):
continue
events.append({
"ts": ts, "kind": "added", "label": "Added",
"title": it.get("title", "Unknown"),
"detail": f"{it.get('kind', '')} · {it.get('sub', '')}",
})
events.sort(key=lambda e: e["ts"], reverse=True)
for e in events:
e["when"] = datetime.fromtimestamp(e["ts"]).strftime("%d %b · %H:%M")
return events[:limit]
def _filter_rt(raw, owned_titles, limit=10):
"""Drop anything already in the library; fall back to the unfiltered list
if that leaves nothing (better a stale suggestion than an empty card)."""
filtered = [x for x in raw if not x.get("error")
and x.get("title", "").lower() not in owned_titles][:limit]
return filtered or raw
# ---------------------------------------------------------------------------
# Pages
# ---------------------------------------------------------------------------
@app.route("/")
@login_required
def home():
"""Marquee: what's playing, one line of system state, then what's new."""
r = fetch_services(["sonarr", "radarr", "sabnzbd", "tautulli",
"prowlarr", "ombi", "unraid", "rt_movies", "rt_tv"])
sonarr, radarr = r["sonarr"], r["radarr"]
radarr_titles = set(radarr.get("radarr_titles", []))
sonarr_titles = set(sonarr.get("sonarr_titles", []))
tautulli = r["tautulli"]
sessions = tautulli.get("sessions", []) if tautulli.get("status") == "ok" else []
# The hero shows the furthest-along stream; the rest sit beside it.
hero = max(sessions, key=lambda s: s.get("progress", 0)) if sessions else None
others = [s for s in sessions if s is not hero]
return render_template(
"dashboard.html",
sonarr=sonarr, radarr=radarr, sabnzbd=sabnzbd, tautulli=tautulli,
prowlarr=prowlarr, ombi=ombi, unraid=unraid,
rt_movies=rt_movies, rt_tv=rt_tv,
sonarr_titles=sonarr_titles, radarr_titles=radarr_titles,
timezone=timezone,
service_urls=service_urls,
"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,
recently_added=get_recently_added(6),
rt_movies=_filter_rt(r["rt_movies"], radarr_titles, 6),
rt_tv=_filter_rt(r["rt_tv"], sonarr_titles, 6),
)
@app.route("/watch")
@login_required
def watch_page():
r = fetch_services(["tautulli"])
return render_template("watch.html", page="watch", tautulli=r["tautulli"])
@app.route("/library")
@login_required
def library_page():
r = fetch_services(["sonarr", "radarr"])
return render_template("library.html", page="library",
sonarr=r["sonarr"], radarr=r["radarr"],
recently_added=get_recently_added(12))
@app.route("/discover")
@login_required
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", []))
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))
@app.route("/requests")
@login_required
def requests_page():
r = fetch_services(["ombi"])
return render_template("requests.html", page="requests", ombi=r["ombi"])
@app.route("/system")
@login_required
def system_page():
r = fetch_services(["unraid", "prowlarr", "sonarr", "radarr",
"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"])
@app.route("/activity")
@login_required
def activity_page():
r = fetch_services(["sabnzbd", "ombi"])
return render_template("activity.html", page="activity",
events=get_activity_feed(r["sabnzbd"], r["ombi"]))
@app.route("/api/sabnzbd")
@login_required
def api_sabnzbd():

View file

@ -1,75 +1,151 @@
/* ═══════════════════════════════════════════════════════
Media Centre — shared design tokens + base reset.
MEDIA CENTRE — "Page 100"
Single source of truth for the palette. Loaded by
dashboard.html, login.html and settings.html; page-specific
rules live in dashboard.css / login.css / settings.css.
A broadcast information service, not a dashboard. The world
is teletext (Ceefax, and the cable guide channel that did the
same job in America): a locked eight-colour palette on pure
black, one blocky monospace at exactly two heights, a strict
character-cell grid, and grouping carried by colour fields and
row weight — never by a border, never by a box.
To reshade the whole app, change the four hues below —
every dim/border variant is derived from them via color-mix.
Five disciplines this world is held to. Breaking one is a
regression, not a variation:
1. NO IN-BETWEEN TONE. There are eight colours. A mid-grey is
an ordered dither of two of them (--dither-*), never a
ninth colour smuggled into the palette.
2. NO ENCLOSURE. Nothing is ever surrounded by a border or a
box. Grouping is a coloured field row, indentation, and
weight. This is the exact reflex the old design was built
on and the main thing this world refuses.
3. ONE RULER. Every bar, meter and percentage on every page
shares one scale (--bar-cells), so 60% on the array and
60% on an indexer are the same width and compare directly.
4. EVERY READING CARRIES ITS DIRECTION. A value shows where
it is heading, so a disk becoming a problem is visible
before it is one.
5. PAGES DIFFER IN STOCK. A page holding critical state is
set heavier than a quiet one. Uniform texture across seven
pages is a failure.
═══════════════════════════════════════════════════════ */
:root {
/* ── Surfaces ── */
--bg: #141416;
--surface: #1a1a1d;
--surface-2: #202024;
--border: #2c2c31;
--border-lt: #38383f;
/* ── The locked eight ──────────────────────────────────
Teletext had exactly these, at full saturation, and the
product's semantics were already the same semantics:
green runs, yellow works, red is off air. Do not soften
them; the emissive-on-black scene is what they are for. */
--tt-black: #000000;
--tt-red: #ff0000;
--tt-green: #00ff00;
--tt-yellow: #ffff00;
--tt-blue: #0000ff;
--tt-magenta: #ff00ff;
--tt-cyan: #00ffff;
--tt-white: #ffffff;
/* ── Text (all ≥ 4.5:1 on every surface above) ── */
--text: #edecf1;
--text-2: #a8a7b0;
--text-3: #7c7b84;
/* ── Roles ─────────────────────────────────────────────
Each colour means one thing across all nine pages. */
--ink: var(--tt-white); /* body text */
--voice: var(--tt-yellow); /* the service speaking: mastheads, page numbers */
--live: var(--tt-cyan); /* live values, now showing */
--ok: var(--tt-green); /* running, healthy, available */
--alert: var(--tt-red); /* off air, failed, danger */
--pending: var(--tt-magenta); /* requested, waiting on a human */
--field: var(--tt-blue); /* the only "background" colour — header fields */
/* ── Accent + semantics (the four source hues) ── */
--accent: #7c86ff;
--ok: #68b98a;
--warn: #d0a75a;
--danger: #dc5b52;
/* ── Dither ────────────────────────────────────────────
The only legal way to make a tone that isn't one of the
eight. A checkerboard of the colour against black at cell
scale, exactly how a 16-colour screen faked a gradient.
Used for inactive bar cells and secondary text grounds. */
--dither-size: 2px;
/* --ink-dim is the ONLY dimmed value allowed to carry text: at 58% it
clears 4.5:1 on black. --ink-dimmer is for graphics only — bar
troughs, the dither field, the separator — because at 30% it does
not, and secondary text set in it was failing contrast outright. */
--ink-dim: color-mix(in srgb, var(--tt-white) 58%, var(--tt-black));
--ink-dimmer: color-mix(in srgb, var(--tt-white) 30%, var(--tt-black));
/* ── Derived tints — never hardcode these as literals ── */
--accent-dim: color-mix(in srgb, var(--accent) 14%, transparent);
--ok-dim: color-mix(in srgb, var(--ok) 14%, transparent);
--warn-dim: color-mix(in srgb, var(--warn) 14%, transparent);
--danger-dim: color-mix(in srgb, var(--danger) 14%, transparent);
--danger-border: color-mix(in srgb, var(--danger) 35%, transparent);
/* ── Type: one face, exactly two heights ───────────────
Teletext's entire scale. Emphasis comes from colour and
from doubling the height, never from a weight ramp and
never from a third size. */
--fs: 0.9375rem; /* 15px — the single body size */
--fs2: 1.875rem; /* 30px — double height, the only other size */
--lh: 1.45;
--row: calc(var(--fs) * var(--lh)); /* one character row */
--cell: 1ch;
/* ── Type scale (6 steps, no one-offs) ── */
--fs-xs: 0.75rem; /* 12px — smallest permitted */
--fs-sm: 0.8125rem; /* 13px */
--fs-base: 0.9375rem; /* 15px */
--fs-lg: 1.125rem; /* 18px */
--fs-xl: 1.5rem; /* 24px */
--fs-2xl: 2rem; /* 32px */
--font: 'Chivo Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--font-dh: 'DotGothic16', 'Chivo Mono', ui-monospace, Menlo, monospace;
/* ── Spacing / shape ── */
--gap: 1rem;
--gap-lg: 1.5rem;
--radius: 10px;
--radius-sm: 7px;
--tap: 44px; /* minimum touch target */
--row-h: 4.25rem; /* paired Requests / Indexers row height */
/* ── Measure ───────────────────────────────────────────
A teletext page was 40 columns. A screen this wide gets
double that, so density survives without the grid dying. */
--page-cols: 92ch;
--gutter: 1.5rem;
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
/* ── The one ruler (discipline 3) ──────────────────────
Every bar on every page is this many cells wide, so any
two percentages anywhere in the service are comparable. */
--bar-cells: 20;
--bar-cell-w: 0.62em;
--tap: 44px;
}
*, *::before, *::after { box-sizing: border-box; }
html { background: var(--tt-black); }
body {
margin: 0;
background: var(--bg);
color: var(--text);
background: var(--tt-black);
color: var(--ink);
font-family: var(--font);
font-size: var(--fs-base);
line-height: 1.5;
font-size: var(--fs);
line-height: var(--lh);
font-variant-numeric: tabular-nums;
/* Teletext never anti-aliased into a soft grey edge. Keep the
letterforms crisp rather than letting the browser blend them
toward tones the palette does not contain. */
-webkit-font-smoothing: antialiased;
text-rendering: optimizeSpeed;
}
a { color: var(--accent); }
/* Phosphor bloom. Applied only where the world is speaking loudly —
double-height rows and status glyphs — because bloom under a dense
table of figures costs more legibility than it buys character. */
.bloom {
text-shadow: 0 0 0.4em color-mix(in srgb, currentColor 55%, transparent);
}
a { color: var(--live); text-decoration: none; }
a:hover { color: var(--tt-black); background: var(--live); }
/* Focus is a reversed cell, the way a teletext menu row selected
itself. No outline ring — a ring is an enclosure. */
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 4px;
outline: none;
background: var(--voice);
color: var(--tt-black);
}
::selection { background: var(--live); color: var(--tt-black); }
/* Browser furniture, themed to the palette rather than left at the
defaults that belong to no design system. */
* { scrollbar-color: var(--ink-dimmer) var(--tt-black); scrollbar-width: thin; }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: var(--tt-black); }
::-webkit-scrollbar-thumb { background: var(--ink-dimmer); }
::-webkit-scrollbar-thumb:hover { background: var(--ink-dim); }
::-webkit-scrollbar-corner { background: var(--tt-black); }
input { caret-color: var(--voice); }
.visually-hidden {
position: absolute; width: 1px; height: 1px;
padding: 0; margin: -1px; overflow: hidden;
clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

View file

@ -1,775 +0,0 @@
/* ── Header ─────────────────────────────────────────── */
/* Identity on the left, clock + settings on the right, one row at every
width. The service pills used to occupy a second full-width row here;
they now live in the Status section at the top of <main>, which is
where someone looks for them. Scrolls away with the page — a status
bar isn't worth permanent screen real estate. */
header {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 0.75rem 1.5rem;
display: flex;
align-items: center;
gap: 0.75rem var(--gap);
}
.brand {
display: flex;
align-items: center;
gap: 0.65rem;
min-width: 0;
flex: 0 0 auto;
}
.brand svg { flex-shrink: 0; display: block; }
.brand h1 {
margin: 0;
font-size: var(--fs-lg);
font-weight: 600;
letter-spacing: -0.01em;
white-space: nowrap;
}
/* Service status pills — the Status section's only content, left-aligned
with every other section's content. All six share the row equally and
shrink together, so the strip is always exactly one row: never two,
never a scroller. Every service stays visible at every width; what
gives way as space tightens is detail *within* a pill, in this order:
the stat drops below 1024px, then the name shortens below 560px. The
dot and the pill itself never go, so no service is ever hidden. */
.svc-strip {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: nowrap;
min-width: 0;
}
/* Full --tap height now they're content rather than header furniture. */
.svc-pill {
flex: 1 1 0; /* equal share of the row, all shrink together */
min-width: 0; /* ...which flex items won't do without this */
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
padding: 0.35rem 0.9rem;
min-height: var(--tap);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 100px;
font-size: var(--fs-xs);
color: var(--text-2);
text-decoration: none;
white-space: nowrap;
overflow: hidden;
transition: border-color 0.2s, color 0.2s;
}
.svc-pill:hover { border-color: var(--border-lt); color: var(--text); }
.svc-pill.down { color: var(--danger); border-color: var(--danger-border); }
.svc-name {
font-weight: 600;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.svc-stat {
color: var(--text-3);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.svc-pill.down .svc-stat { color: var(--danger); }
@media (max-width: 1024px) {
/* Six stats stop fitting here. The dot already carries up/down, so the
stat is the first thing that can go without losing any status. */
.svc-stat { display: none; }
}
@media (max-width: 620px) {
/* Tighten the furniture so the names keep their characters longer. */
.svc-strip { gap: 0.3rem; }
.svc-pill { padding: 0.35rem 0.5rem; gap: 0.3rem; }
}
@media (max-width: 560px) {
/* Six full names in one row leaves ~23px each — two characters and an
ellipsis, which identifies nothing. Swap in the short label instead.
The full name stays in the DOM (font-size: 0 — invisible but still
announced) and on the pill's title attribute. */
.svc-strip { gap: 0.25rem; }
.svc-pill { padding: 0.35rem 0.35rem; gap: 0.25rem; }
.svc-name { font-size: 0; }
.svc-name::after {
content: attr(data-short);
font-size: var(--fs-xs);
}
}
.status-light {
width: 7px; height: 7px; border-radius: 50%;
flex-shrink: 0; background: var(--text-3);
}
.status-light.up { background: var(--ok); box-shadow: 0 0 0 3px var(--ok-dim); }
.status-light.down { background: var(--danger); box-shadow: 0 0 0 3px var(--danger-dim); }
.status-light.warn { background: var(--warn); box-shadow: 0 0 0 3px var(--warn-dim); }
.header-right {
display: flex;
align-items: center;
gap: 0.75rem;
margin-left: auto;
flex: 0 0 auto;
}
.header-clock {
font-variant-numeric: tabular-nums;
font-size: var(--fs-sm);
color: var(--text-2);
white-space: nowrap;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
min-width: var(--tap);
min-height: var(--tap);
padding: 0 0.85rem;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-2);
/* The button was always a full --tap target, but the cog glyph inside it
was set at --fs-sm (13px) and read as a speck in a 44px box. The glyph
is the affordance, so size it to the button. */
font-size: var(--fs-xl);
line-height: 1;
font-family: inherit;
text-decoration: none;
white-space: nowrap;
cursor: pointer;
transition: color 0.2s, border-color 0.2s, background 0.2s;
}
.icon-btn:hover { color: var(--text); border-color: var(--border-lt); background: var(--surface); }
/* ── Layout ─────────────────────────────────────────── */
main {
padding: var(--gap-lg);
max-width: 1360px;
margin: 0 auto;
}
.section {
margin-bottom: 2rem;
}
.section > h2 {
margin: 0 0 0.85rem;
font-size: var(--fs-xs);
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--text-3);
}
.row {
display: grid;
gap: var(--gap);
}
.row.cols-3 { grid-template-columns: repeat(3, 1fr); }
.row.cols-2 { grid-template-columns: repeat(2, 1fr); }
/* A section with two stacked rows (Watch insights) needs the same gap
between them as the grid uses between columns. */
.row + .row { margin-top: var(--gap); }
/* ── Card ───────────────────────────────────────────── */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
display: flex;
flex-direction: column;
min-width: 0;
}
.card-header {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.85rem 1.1rem;
border-bottom: 1px solid var(--border);
}
.card-header h3 {
margin: 0;
font-size: var(--fs-sm);
font-weight: 600;
color: var(--text);
}
.card-header h3 a { color: inherit; text-decoration: none; }
.card-header h3 a:hover { color: var(--accent); }
.card-meta {
margin-left: auto;
font-size: var(--fs-xs);
color: var(--text-3);
white-space: nowrap;
}
.card-body { padding: 1.1rem; flex: 1; }
.card-body > :first-child { margin-top: 0; }
/* Requests can grow tall when expanded; the Indexers card beside it should
keep its natural height and sit at the top rather than stretching into a
mostly-empty box with its content floating in the middle. */
.row.align-start { align-items: start; }
/* ── "Right now" hero strip ─────────────────────────── */
.hero .card { border-color: var(--border-lt); }
.hero-figure {
font-size: var(--fs-2xl);
font-weight: 600;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
line-height: 1.1;
}
.hero-sub {
font-size: var(--fs-sm);
color: var(--text-3);
margin-top: 0.3rem;
}
/* Now-playing list */
.stream {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.55rem 0;
border-top: 1px solid var(--border);
}
.stream:first-child { border-top: none; padding-top: 0; }
.stream-icon { font-size: var(--fs-lg); flex-shrink: 0; }
.stream-info { min-width: 0; flex: 1; }
.stream-title {
font-size: var(--fs-base); font-weight: 600;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.stream-user { font-size: var(--fs-sm); color: var(--text-3); }
.live-dot {
width: 7px; height: 7px; border-radius: 50%;
background: var(--ok); flex-shrink: 0;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } }
@media (prefers-reduced-motion: reduce) {
.live-dot { animation: none; }
* { transition-duration: 0.01ms !important; }
}
/* ── Meter (storage / progress) ─────────────────────── */
.meter {
height: 7px;
background: var(--surface-2);
border-radius: 4px;
overflow: hidden;
margin-top: 0.65rem;
}
.meter-fill {
height: 100%;
border-radius: 4px;
background: var(--ok);
transition: width 0.5s ease;
}
.meter-fill.warn { background: var(--warn); }
.meter-fill.danger { background: var(--danger); }
.meter-legend {
display: flex;
justify-content: space-between;
font-size: var(--fs-xs);
color: var(--text-3);
margin-top: 0.45rem;
}
/* ── Stats (semantic definition list) ───────────────── */
dl.stats {
display: grid;
gap: 0.6rem;
margin: 0;
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
}
/* Only the Downloads card stacks stats below the hero figure/sub block —
give that pairing breathing room without affecting stats blocks that
open a card (Sonarr/Radarr put dl.stats right after the search bar). */
.card-body > [aria-live="polite"] + dl.stats { margin-top: 0.9rem; }
dl.stats > div {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 0.65rem 0.75rem;
min-width: 0;
}
dl.stats dt {
font-size: var(--fs-xs);
color: var(--text-3);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
dl.stats dd {
margin: 0.2rem 0 0;
font-size: var(--fs-lg);
font-weight: 600;
font-variant-numeric: tabular-nums;
}
dd.is-warn { color: var(--warn); }
dd.is-danger { color: var(--danger); }
dd.is-ok { color: var(--ok); }
dd.is-accent { color: var(--accent); }
/* ── Buttons ────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: var(--tap);
padding: 0 1rem;
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: var(--accent);
color: var(--bg);
font-family: inherit;
font-size: var(--fs-sm);
font-weight: 600;
cursor: pointer;
white-space: nowrap;
transition: opacity 0.15s, background 0.15s;
}
.btn:hover:not(:disabled) { opacity: 0.88; }
.btn:disabled { opacity: 0.5; cursor: default; }
.btn-quiet {
background: var(--surface-2);
border-color: var(--border);
color: var(--text-2);
}
.btn-quiet:hover:not(:disabled) { color: var(--text); border-color: var(--border-lt); }
/* Compact add button — still meets 44px on touch (see media query) */
.btn-add {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 32px;
padding: 0 0.7rem;
border: 1px solid var(--border-lt);
border-radius: var(--radius-sm);
background: var(--surface-2);
color: var(--text);
font-family: inherit;
font-size: var(--fs-xs);
font-weight: 600;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.btn-add:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
/* Armed = awaiting the confirming second tap */
.btn-add.armed {
background: var(--warn);
border-color: var(--warn);
color: var(--bg);
}
.btn-add.done {
background: var(--ok-dim);
border-color: transparent;
color: var(--ok);
cursor: default;
}
.btn-add:disabled { cursor: default; }
/* ── Search ─────────────────────────────────────────── */
.search-bar { display: flex; gap: 0.5rem; margin-bottom: 0.85rem; }
.search-bar input {
flex: 1;
min-width: 0;
min-height: var(--tap);
padding: 0 0.85rem;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font-family: inherit;
font-size: var(--fs-sm);
}
.search-bar input::placeholder { color: var(--text-3); }
.search-bar input:focus { border-color: var(--accent); outline: none; }
.search-result {
display: none;
align-items: center;
gap: 0.75rem;
padding: 0.7rem 0.85rem;
margin-bottom: 0.85rem;
background: var(--accent-dim);
border: 1px solid rgba(122,162,247,0.3);
border-radius: var(--radius-sm);
}
.search-result.visible { display: flex; }
.search-result-info { min-width: 0; flex: 1; }
.search-result-title {
font-size: var(--fs-sm); font-weight: 600;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.search-result-meta { font-size: var(--fs-xs); color: var(--text-3); }
.search-msg {
font-size: var(--fs-sm);
color: var(--text-2);
padding: 0.5rem 0;
}
.search-msg.error { color: var(--danger); }
/* ── Discover (RT) list ─────────────────────────────── */
.rt-list { display: flex; flex-direction: column; gap: 0.5rem; }
.rt-item {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.6rem;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
transition: border-color 0.2s, opacity 0.3s, transform 0.3s;
}
.rt-item:hover { border-color: var(--border-lt); }
.rt-item.removing { opacity: 0; transform: scale(0.97); pointer-events: none; }
.rt-item.is-hidden { display: none; }
.rt-rank {
font-size: var(--fs-xs);
color: var(--text-3);
font-variant-numeric: tabular-nums;
width: 1.2rem;
text-align: right;
flex-shrink: 0;
}
.rt-poster {
width: 38px; height: 56px;
object-fit: cover;
border-radius: 4px;
background: var(--surface);
flex-shrink: 0;
}
.rt-info { min-width: 0; flex: 1; }
.rt-title {
font-size: var(--fs-sm);
font-weight: 600;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.rt-year { color: var(--text-3); font-weight: 400; }
.rt-meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
font-size: var(--fs-xs);
margin-top: 0.15rem;
}
.score.certified { color: var(--ok); }
.score.critics { color: var(--warn); }
.score.audience { color: var(--text-3); }
/* Description is inline + clamped — readable on touch, no hover required */
.rt-desc {
font-size: var(--fs-xs);
color: var(--text-3);
margin-top: 0.25rem;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.rt-desc:empty { display: none; }
.show-more {
margin-top: 0.6rem;
width: 100%;
min-height: 38px;
}
/* Sits below the request pills, which use --row-h — match it so the
button reads as one more row rather than a shorter tacked-on control. */
.show-more.show-more-row { min-height: var(--row-h); }
/* ── Indexers ───────────────────────────────────────── */
/* One indexer per line, built to the same rhythm as the Ombi request rows
beside it — same padding, gap, radius and two-line body, so the two cards
read as one pair rather than two unrelated layouts. */
.indexer-grid { display: flex; flex-direction: column; gap: 0.5rem; }
.indexer-item {
display: flex;
align-items: center;
gap: 0.7rem;
min-height: var(--row-h);
padding: 0.6rem 0.7rem;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
min-width: 0;
}
.indexer-item.down { border-color: var(--danger-border); }
.indexer-item .status-light { width: 9px; height: 9px; flex-shrink: 0; }
.indexer-info { min-width: 0; flex: 1; }
.indexer-name {
font-size: var(--fs-sm); font-weight: 600;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.indexer-sub { font-size: var(--fs-xs); color: var(--text-3); }
.indexer-sub .proto { text-transform: capitalize; }
.indexer-sub:empty::before { content: "Indexer"; }
/* Share of grabs, scaled to the busiest indexer (see dashboard.html).
The margin is trimmed so a bar-carrying row still fits inside --row-h
and stays flush with the request rows beside it. */
.share-bar {
margin-top: 0.3rem;
height: 4px;
border-radius: 100px;
background: var(--surface);
overflow: hidden;
}
.share-bar span {
display: block;
height: 100%;
border-radius: 100px;
background: var(--accent);
}
.indexer-item.down .share-bar span { background: var(--text-3); }
.share-pct {
font-size: var(--fs-sm);
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--text-2);
flex-shrink: 0;
}
/* ── Requests (Ombi) ────────────────────────────────── */
/* Request rows are the same object as an indexer row — same geometry and
the same dot/title/sub/badge anatomy — so the two cards read across as
one pair. --row-h keeps them aligned line-for-line; the indexer rows
reach it naturally once their share bar is in. */
.req-list { display: flex; flex-direction: column; gap: 0.5rem; }
.req-item.is-hidden { display: none; }
.req-item {
display: flex;
align-items: center;
gap: 0.7rem;
min-height: var(--row-h);
padding: 0.6rem 0.7rem;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.req-item .status-light { width: 9px; height: 9px; flex-shrink: 0; }
.req-type { text-transform: capitalize; }
.badge {
font-size: var(--fs-xs);
font-weight: 600;
padding: 0.2rem 0.5rem;
border-radius: 100px;
white-space: nowrap;
flex-shrink: 0;
}
.badge.movie { background: var(--accent-dim); color: var(--accent); }
.badge.tv { background: var(--ok-dim); color: var(--ok); }
.badge.pending { background: var(--warn-dim); color: var(--warn); }
.badge.available, .badge.approved { background: var(--ok-dim); color: var(--ok); }
.badge.denied { background: var(--danger-dim); color: var(--danger); }
.badge.idle { background: var(--surface); color: var(--text-3); }
.badge.downloading { background: var(--accent-dim); color: var(--accent); }
.badge.online { background: var(--ok-dim); color: var(--ok); }
.badge.offline { background: var(--danger-dim); color: var(--danger); }
.badge.paused { background: var(--warn-dim); color: var(--warn); }
.req-info { min-width: 0; flex: 1; }
.req-title {
font-size: var(--fs-sm); font-weight: 600;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.req-sub { font-size: var(--fs-xs); color: var(--text-3); }
/* ── Storage — per-disk table ──────────────────────────── */
/* Storage owns a full-width row of its own, so the per-disk breakdown is a
real table rather than a stack of cards: columns line up, digits are
tabular, and a new metric is a new column instead of a redesign. Stacking
these as rows in a third-of-a-page column is what hollowed out the two
cards beside it — see the dead-space rule in CLAUDE.md. */
/* Storage's header meta is a sentence, not a badge. .card-meta is nowrap so
short status words never break; this one has to wrap or it pushes the whole
page sideways on a phone. */
.card-meta.card-meta-wrap { white-space: normal; text-align: right; }
.array-meter.meter { margin-top: 0; margin-bottom: 1.1rem; }
.table-scroll { overflow-x: auto; }
.disk-table {
width: 100%;
border-collapse: collapse;
font-variant-numeric: tabular-nums;
}
.disk-table th, .disk-table td { padding: 0.45rem 0.6rem; text-align: left; }
.disk-table th:first-child, .disk-table td:first-child { padding-left: 0; }
.disk-table th:last-child, .disk-table td:last-child { padding-right: 0; }
.disk-table thead th {
font-size: var(--fs-xs);
font-weight: 500;
color: var(--text-3);
white-space: nowrap;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border);
}
.disk-table tbody th {
font-size: var(--fs-sm);
font-weight: 600;
color: var(--text);
white-space: nowrap;
}
.disk-table tbody td {
font-size: var(--fs-sm);
color: var(--text-2);
border-bottom: 1px solid var(--border);
}
.disk-table tbody th { border-bottom: 1px solid var(--border); }
.disk-table tbody tr:last-child th,
.disk-table tbody tr:last-child td { border-bottom: none; }
.disk-table .num { text-align: right; white-space: nowrap; }
/* The bar and its percentage share one cell, so the bar flexes with the
table while the number stays a fixed, right-aligned column of digits. */
.disk-table .usage { width: 32%; min-width: 140px; }
.usage-cell { display: flex; align-items: center; gap: 0.6rem; }
.usage-cell .meter { margin-top: 0; flex: 1; height: 5px; }
.usage-pct {
font-size: var(--fs-xs);
color: var(--text-3);
width: 2.6em;
text-align: right;
flex-shrink: 0;
}
/* Still used by the disk_state() macro when a disk reports no temperature. */
.disk-null { color: var(--text-3); }
/* Parity temp in the Storage header — keep the label and its badge together
so they never wrap apart from each other. */
.meta-parity {
display: inline-flex;
align-items: center;
gap: 0.35rem;
white-space: nowrap;
}
@media (max-width: 720px) {
/* Below this the six columns stop fitting; the table scrolls in its own
container rather than squeezing the sizes into unreadable wraps. */
.table-scroll .disk-table { min-width: 540px; }
}
.badge.temp-ok { background: var(--ok-dim); color: var(--ok); }
.badge.temp-warn { background: var(--warn-dim); color: var(--warn); }
.badge.temp-danger { background: var(--danger-dim); color: var(--danger); }
/* ── Insights top lists ─────────────────────────────── */
.top-list { display: flex; flex-direction: column; gap: 0.4rem; }
.top-item {
display: flex;
align-items: center;
gap: 0.65rem;
min-height: 3.25rem;
padding: 0.5rem 0.65rem;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.top-rank {
font-size: var(--fs-xs);
color: var(--text-3);
font-variant-numeric: tabular-nums;
width: 1.1rem;
flex-shrink: 0;
}
.top-info { min-width: 0; flex: 1; }
.top-name {
font-size: var(--fs-sm);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.top-sub { font-size: var(--fs-xs); color: var(--text-3); }
.top-plays {
font-size: var(--fs-xs);
color: var(--text-2);
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.empty { color: var(--text-3); font-size: var(--fs-sm); }
/* Storage card's usage note trails the meter with no room to spare below it. */
.meter-legend + .empty { margin-bottom: 0; }
/* ── Errors ─────────────────────────────────────────── */
.error {
background: var(--surface-2);
border: 1px solid var(--border);
border-left: 3px solid var(--danger);
border-radius: var(--radius-sm);
padding: 0.8rem 0.9rem;
color: var(--text-2);
font-size: var(--fs-sm);
}
.error-detail { margin-top: 0.5rem; }
.error-detail summary {
cursor: pointer;
font-size: var(--fs-xs);
color: var(--text-3);
list-style: none;
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.3rem 0;
}
.error-detail summary::-webkit-details-marker { display: none; }
.error-detail summary::before { content: "▸"; }
.error-detail[open] summary::before { content: "▾"; }
.error-detail summary:hover { color: var(--text-2); }
.error-detail pre {
margin: 0.4rem 0 0;
padding: 0.6rem 0.7rem;
background: rgba(0,0,0,0.3);
border-radius: 6px;
font-family: var(--mono);
font-size: var(--fs-xs);
line-height: 1.5;
color: var(--text-3);
white-space: pre-wrap;
word-break: break-word;
overflow-x: auto;
}
footer {
padding: 1.5rem;
text-align: center;
font-size: var(--fs-xs);
color: var(--text-3);
border-top: 1px solid var(--border);
}
/* Build identity — dim, so it reads as metadata rather than content. */
footer .version {
display: block;
margin-top: 0.3rem;
font-family: var(--mono);
font-variant-numeric: tabular-nums;
opacity: 0.75;
}
.visually-hidden {
position: absolute; width: 1px; height: 1px;
padding: 0; margin: -1px; overflow: hidden;
clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
/* ── Responsive ─────────────────────────────────────── */
@media (max-width: 1100px) {
.row.cols-3 { grid-template-columns: 1fr 1fr; }
.row.cols-3 > :first-child { grid-column: 1 / -1; }
}
@media (max-width: 820px) {
.row.cols-2, .row.cols-3 { grid-template-columns: 1fr; }
.row.cols-3 > :first-child { grid-column: auto; }
}
@media (max-width: 700px) {
header { padding: 0.7rem 1rem; gap: 0.6rem; }
.brand { flex: 1 1 auto; }
.brand h1 { font-size: var(--fs-base); }
.header-clock { display: none; }
main { padding: 1rem; }
.card-body { padding: 0.9rem; }
.card-header { padding: 0.75rem 0.9rem; }
.search-bar { flex-direction: column; }
.search-bar .btn { width: 100%; }
/* Full touch targets once there's no mouse */
.btn-add { min-height: var(--tap); padding: 0 0.9rem; }
.search-bar input { font-size: 16px; } /* prevent iOS zoom on focus */
}

View file

@ -1,86 +0,0 @@
/* Login page — tokens and base reset come from base.css */
/* Centre the card in the viewport (overrides base.css body). */
body {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.wrap { width: 100%; max-width: 360px; }
.brand {
display: flex;
align-items: center;
justify-content: center;
gap: 0.6rem;
margin-bottom: 1.75rem;
}
.brand h1 {
margin: 0;
font-size: var(--fs-lg);
font-weight: 600;
letter-spacing: -0.01em;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.5rem;
}
label {
display: block;
font-size: var(--fs-xs);
font-weight: 600;
color: var(--text-2);
margin-bottom: 0.4rem;
}
input {
width: 100%;
min-height: var(--tap);
padding: 0 0.85rem;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font-family: inherit;
font-size: 16px; /* prevents iOS zoom on focus */
outline: none;
transition: border-color 0.2s;
}
input:focus { border-color: var(--accent); }
input::placeholder { color: var(--text-3); }
/* Second field in a stacked pair (username above password) needs the gap
the single-field form never had. */
label.stacked { margin-top: 1rem; }
.error-msg {
background: var(--surface-2);
border: 1px solid var(--border);
border-left: 3px solid var(--danger);
border-radius: var(--radius-sm);
padding: 0.7rem 0.85rem;
color: var(--text-2);
font-size: var(--fs-sm);
margin-bottom: 1rem;
}
button {
width: 100%;
min-height: var(--tap);
margin-top: 1rem;
background: var(--accent);
color: var(--bg);
border: none;
border-radius: var(--radius-sm);
font-family: inherit;
font-size: var(--fs-sm);
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
}
button:hover { opacity: 0.88; }

View file

@ -1,138 +0,0 @@
/* Settings page — tokens and base reset come from base.css */
header {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 0.75rem 1.5rem;
display: flex;
align-items: center;
gap: 0.65rem;
position: sticky;
top: 0;
z-index: 100;
}
header h1 {
margin: 0;
font-size: var(--fs-lg);
font-weight: 600;
letter-spacing: -0.01em;
}
.back-btn {
margin-left: auto;
display: inline-flex;
align-items: center;
min-height: var(--tap);
padding: 0 0.9rem;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-2);
font-size: var(--fs-sm);
text-decoration: none;
white-space: nowrap;
transition: color 0.2s, border-color 0.2s;
}
.back-btn:hover { color: var(--text); border-color: var(--border-lt); }
main { padding: 1.5rem; max-width: 900px; margin: 0 auto; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: var(--gap);
}
.full { grid-column: 1 / -1; }
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.card-header {
padding: 0.85rem 1.1rem;
border-bottom: 1px solid var(--border);
}
.card-header h2 {
margin: 0;
font-size: var(--fs-sm);
font-weight: 600;
}
.card-body { padding: 1.1rem; }
.field { margin-bottom: 0.9rem; }
label {
display: block;
font-size: var(--fs-xs);
font-weight: 600;
color: var(--text-2);
margin-bottom: 0.35rem;
}
input, select {
width: 100%;
min-height: var(--tap);
padding: 0 0.8rem;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font-family: inherit;
font-size: 16px; /* prevents iOS zoom on focus */
outline: none;
transition: border-color 0.2s;
}
input:focus, select:focus { border-color: var(--accent); }
input::placeholder { color: var(--text-3); }
.hint {
font-size: var(--fs-xs);
color: var(--text-3);
margin-top: 0.35rem;
}
button.save {
width: 100%;
min-height: var(--tap);
background: var(--accent);
color: var(--bg);
border: none;
border-radius: var(--radius-sm);
font-family: inherit;
font-size: var(--fs-sm);
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
}
button.save:hover:not(:disabled) { opacity: 0.88; }
button.save:disabled { background: var(--border); color: var(--text-3); cursor: default; }
.error-inline {
font-size: var(--fs-xs);
color: var(--danger);
display: none;
margin-top: 0.35rem;
}
.toast {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
background: var(--surface);
border: 1px solid var(--ok);
color: var(--ok);
padding: 0.7rem 1.1rem;
border-radius: var(--radius-sm);
font-size: var(--fs-sm);
font-weight: 500;
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
max-width: calc(100vw - 3rem);
}
.toast.error { border-color: var(--danger); color: var(--danger); }
.toast.show { opacity: 1; }
@media (max-width: 600px) {
header { padding: 0.7rem 1rem; }
main { padding: 1rem; }
.toast { left: 1rem; right: 1rem; bottom: 1rem; }
}

625
static/css/teletext.css Normal file
View file

@ -0,0 +1,625 @@
/* ═══════════════════════════════════════════════════════
Page 100 — the service.
Every rule here answers to one of the five disciplines in
base.css. The two that get broken by reflex, and so get
guarded hardest: NO ENCLOSURE (there is not a single
border-radius or box-drawing border in this file) and ONE
RULER (every bar is --bar-cells wide, no exceptions).
═══════════════════════════════════════════════════════ */
/* ── Header row ─────────────────────────────────────────
Teletext's top line, unchanged for forty years: the page
number you are on, who is transmitting, and the clock. It
is a row of the page, not furniture bolted above it, so it
scrolls away like everything else. */
.tt-head {
max-width: min(var(--page-cols), 100%);
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 0 1ch;
padding: var(--row) var(--gutter) 0;
margin: 0 auto;
}
.tt-head-num {
font-family: var(--font-dh);
color: var(--voice);
letter-spacing: 0.08em;
}
.tt-head-name {
color: var(--ink);
letter-spacing: 0.22em;
text-transform: uppercase;
}
.tt-head-clock {
margin-left: auto;
color: var(--live);
white-space: nowrap;
min-width: 0;
}
/* The page-number entry indicator. Hidden until the visitor starts
typing digits, then fills in place — the signature interaction. */
.tt-entry {
color: var(--voice);
letter-spacing: 0.3em;
}
.tt-entry[hidden] { display: none; }
/* ── Page ───────────────────────────────────────────────
One measure for every page in the service. Nothing is ever
centred except the page itself. */
.tt-page {
max-width: min(var(--page-cols), 100%);
margin: 0 auto;
padding: 0 var(--gutter) calc(var(--row) * 2);
}
/* The page masthead: double height in the service's own colour. */
.tt-masthead { color: var(--voice); margin-bottom: calc(var(--row) * 0.75); }
/* A rows block that follows another block rather than opening one. */
.tt-rows-after { margin-top: var(--row); }
/* Double-height. The only size other than body text, and the
only place bloom is allowed. */
.tt-dh {
font-family: var(--font-dh);
font-size: var(--fs2);
line-height: 1.15;
letter-spacing: 0.02em;
margin: 0;
}
/* ── Field row (discipline 2: grouping without enclosure) ──
A section is announced by a full-bleed band of colour with
the title reversed out of it. This is what replaces every
card header and every 1px border in the old design. */
.tt-field {
display: flex;
align-items: baseline;
gap: 1ch;
margin: calc(var(--row) * 1.5) 0 var(--row);
padding: 0 1ch;
background: var(--field);
color: var(--voice);
letter-spacing: 0.22em;
text-transform: uppercase;
min-height: calc(var(--row) * 1.6);
}
.tt-field-meta {
margin-left: auto;
color: var(--tt-white);
letter-spacing: 0.06em;
text-transform: uppercase;
}
/* A page carrying critical state gets a heavier field than a quiet
one (discipline 5) — the band goes red and doubles in height. */
.tt-field.crit { background: var(--alert); color: var(--tt-white); }
.tt-field.warn { background: var(--voice); color: var(--tt-black); }
.tt-field.warn .tt-field-meta { color: var(--tt-black); }
/* ── Mosaic separator ───────────────────────────────────
Teletext's block graphics doing the job a rule would do in a
world that allowed rules. It is content in the grid, not a
border on a box. */
.tt-sep {
height: 0.4em;
margin: var(--row) 0;
background-image: repeating-linear-gradient(
90deg,
var(--ink-dimmer) 0 0.6em,
transparent 0.6em 0.9em
);
}
/* ── Index / list rows ──────────────────────────────────
The index page IS the status page: every destination row
carries its own live figure in its own colour, so reading the
menu and reading the state are the same act. */
.tt-rows { display: flex; flex-direction: column; }
.tt-row {
display: flex;
align-items: center;
gap: 1.5ch;
min-height: var(--tap);
padding: 0 1ch;
color: var(--ink);
text-decoration: none;
}
a.tt-row:hover, a.tt-row:focus-visible {
background: var(--voice);
color: var(--tt-black);
outline: none;
}
a.tt-row:hover .tt-row-num,
a.tt-row:hover .tt-row-val,
a.tt-row:hover .tt-row-sub,
a.tt-row:focus-visible .tt-row-num,
a.tt-row:focus-visible .tt-row-val,
a.tt-row:focus-visible .tt-row-sub { color: var(--tt-black); }
.tt-row-num {
color: var(--voice);
flex: 0 0 auto;
letter-spacing: 0.08em;
}
.tt-row-name {
flex: 0 0 auto;
letter-spacing: 0.16em;
text-transform: uppercase;
min-width: 11ch;
}
.tt-row-body { flex: 1; min-width: 0; }
.tt-row-title {
overflow-wrap: anywhere;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tt-row-sub { color: var(--ink-dim); }
.tt-row-val {
flex: 0 0 auto;
margin-left: auto;
color: var(--live);
white-space: nowrap;
}
.tt-row-val.ok { color: var(--ok); }
.tt-row-val.alert { color: var(--alert); }
.tt-row-val.warn { color: var(--voice); }
.tt-row-val.pending { color: var(--pending); }
.tt-row-val.off { color: var(--ink-dim); }
/* ── The one ruler (discipline 3) ───────────────────────
Every bar in the service is exactly --bar-cells cells wide and
fills in whole cells. Two percentages anywhere in the app are
therefore directly comparable by eye. Never restyle this per
card; that is precisely what it exists to prevent. */
.tt-bar {
display: inline-block;
width: min(calc(var(--bar-cells) * var(--bar-cell-w)), 100%);
height: 0.8em;
flex: 0 0 auto;
vertical-align: -0.05em;
background-image: repeating-linear-gradient(
90deg,
var(--ink-dimmer) 0 calc(var(--bar-cell-w) - 2px),
transparent calc(var(--bar-cell-w) - 2px) var(--bar-cell-w)
);
}
.tt-bar > i {
display: block;
height: 100%;
background-image: repeating-linear-gradient(
90deg,
currentColor 0 calc(var(--bar-cell-w) - 2px),
transparent calc(var(--bar-cell-w) - 2px) var(--bar-cell-w)
);
}
.tt-bar.ok { color: var(--ok); }
.tt-bar.warn { color: var(--voice); }
.tt-bar.alert { color: var(--alert); }
/* ── Trend (discipline 4) ───────────────────────────────
Only rendered where the backend actually carries a comparison
(current speed against the 7-day average, a temperature
against its threshold). Where there is no history there is no
arrow — an invented trend is worse than no trend. */
.tt-trend { flex: 0 0 auto; letter-spacing: 0.05em; }
.tt-trend.up { color: var(--ok); }
.tt-trend.down { color: var(--voice); }
.tt-trend.flat { color: var(--ink-dim); }
/* ── Figures ────────────────────────────────────────────
A labelled number, set at double height because on this page
the number is the content. */
.tt-figs {
display: flex;
flex-wrap: wrap;
gap: 0 4ch;
margin: var(--row) 0;
padding: 0 1ch;
}
.tt-fig { min-width: 12ch; }
.tt-fig-v { color: var(--live); }
.tt-fig-v.ok { color: var(--ok); }
.tt-fig-v.alert { color: var(--alert); }
.tt-fig-v.warn { color: var(--voice); }
.tt-fig-v.pending { color: var(--pending); }
.tt-fig-l {
color: var(--ink-dim);
letter-spacing: 0.16em;
text-transform: uppercase;
}
/* ── Now showing ────────────────────────────────────────
The lower two-thirds of the index. Double height, cyan, with
the progress as a run of mosaic cells. */
.tt-now { margin: var(--row) 0 0; padding: 0 1ch; }
.tt-now-title { color: var(--live); margin-bottom: calc(var(--row) * 0.4); }
.tt-now-meta { color: var(--ink); }
.tt-now-line {
display: flex;
align-items: center;
gap: 1.5ch;
margin-top: calc(var(--row) * 0.5);
color: var(--live);
}
.tt-now-pct { color: var(--live); flex: 0 0 auto; }
/* ── Table ──────────────────────────────────────────────
No rules, no zebra, no borders. Columns are held by alignment
and the monospace grid; the head row is a colour field. */
.tt-table-scroll { overflow-x: auto; }
.tt-table {
width: 100%;
border-collapse: collapse;
white-space: nowrap;
}
.tt-table th, .tt-table td {
padding: 0.35em 1ch 0.35em 0;
text-align: left;
font-weight: 400;
}
.tt-table thead th {
color: var(--voice);
letter-spacing: 0.16em;
text-transform: uppercase;
}
.tt-table tbody th { color: var(--ink); }
.tt-table td { color: var(--ink-dim); }
.tt-table .num { text-align: right; padding-right: 2ch; }
.tt-table .val { color: var(--live); }
.tt-table tbody tr:hover th,
.tt-table tbody tr:hover td { background: var(--field); color: var(--tt-white); }
/* ── Tiles (library / discover) ─────────────────────────
Artwork is the one thing in this world that is not made of
characters, so it is treated as an inserted transmission: a
flat rectangle on the grid with its label in a colour field
beneath. No rounding, no shadow, no border. */
.tt-tiles {
display: grid;
gap: var(--row) 2ch;
grid-template-columns: repeat(auto-fill, minmax(min(12.5ch, 100%), 1fr));
padding: 0 1ch;
}
.tt-tile { display: flex; flex-direction: column; min-width: 0; }
.tt-tile-art {
display: block;
width: 100%;
aspect-ratio: 2 / 3;
object-fit: cover;
background: var(--ink-dimmer);
}
/* No artwork came back. A dithered field, not a fake poster. */
.tt-tile-art-empty {
background-image: repeating-linear-gradient(
45deg,
var(--ink-dimmer) 0 1px,
var(--tt-black) 1px calc(var(--dither-size) * 2)
);
}
.tt-tile-t {
margin-top: 0.4em;
color: var(--ink);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tt-tile-s { color: var(--ink-dim); }
.tt-tile-badge { color: var(--voice); }
.tt-tile-p { margin-top: 0.35em; }
/* ── Controls ───────────────────────────────────────────
A teletext control is a reversed row of cells. That is the
entire button vocabulary: no fill, no radius, no border. */
.tt-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: var(--tap);
padding: 0 1.5ch;
background: var(--voice);
color: var(--tt-black);
border: none;
font-family: inherit;
font-size: inherit;
letter-spacing: 0.16em;
text-transform: uppercase;
cursor: pointer;
}
.tt-btn:hover:not(:disabled) { background: var(--live); }
.tt-btn:disabled { background: var(--ink-dimmer); color: var(--ink-dim); cursor: default; }
.tt-btn.armed { background: var(--alert); color: var(--tt-white); }
.tt-btn.done { background: transparent; color: var(--ok); cursor: default; }
.tt-btn-quiet {
background: transparent;
color: var(--live);
padding: 0 1ch;
}
.tt-btn-quiet:hover:not(:disabled) { background: var(--live); color: var(--tt-black); }
.tt-btn-sm { min-height: calc(var(--row) * 1.6); width: 100%; }
.tt-input {
min-height: var(--tap);
padding: 0 1ch;
background: var(--tt-black);
color: var(--voice);
border: none;
/* The one place a line is allowed: an underscore run, which is
how a teletext form showed a field. It is a character row, not
an enclosure. */
background-image: repeating-linear-gradient(
90deg,
var(--ink-dimmer) 0 0.6em,
transparent 0.6em 0.9em
);
background-size: 100% 2px;
background-repeat: no-repeat;
background-position: 0 100%;
font-family: inherit;
font-size: 16px; /* stops iOS zooming the page on focus */
outline: none;
}
.tt-input::placeholder { color: var(--ink-dim); }
.tt-input:focus {
background-image: repeating-linear-gradient(
90deg,
var(--voice) 0 0.6em,
transparent 0.6em 0.9em
);
background-size: 100% 2px;
background-repeat: no-repeat;
background-position: 0 100%;
}
/* ── Degraded service ───────────────────────────────────
Teletext's own failure state was a page that simply did not
arrive, announced in red on the row where it should have been.
No panel, no left-border accent, no icon. */
.tt-err { padding: 0 1ch; color: var(--alert); }
.tt-err-cause { color: var(--alert); }
.tt-err details { margin-top: 0.3em; }
.tt-err summary {
color: var(--ink-dim);
cursor: pointer;
list-style: none;
letter-spacing: 0.16em;
text-transform: uppercase;
min-height: calc(var(--row) * 1.6);
display: flex;
align-items: center;
}
.tt-err summary::-webkit-details-marker { display: none; }
.tt-err summary::before { content: "\25B8\00a0"; }
.tt-err details[open] summary::before { content: "\25BE\00a0"; }
.tt-err summary:hover { color: var(--ink); }
.tt-err pre {
margin: 0.3em 0 0;
padding: 0.5em 1ch;
background: var(--ink-dimmer);
color: var(--ink);
font-family: inherit;
font-size: inherit;
white-space: pre-wrap;
word-break: break-word;
}
.tt-empty { padding: 0 1ch; color: var(--ink-dim); }
/* ── Fastext ────────────────────────────────────────────
The four coloured keys along the bottom of every teletext
page. Real navigation, not decoration: click one, or press
its initial. Deliberately NOT fixed to the viewport — it is
the last row of the page, where it always was. */
.fastext {
display: grid;
grid-template-columns: repeat(4, 1fr);
max-width: min(var(--page-cols), 100%);
margin: calc(var(--row) * 2) auto 0;
padding: 0 var(--gutter);
gap: 1ch;
}
.fastext a {
display: flex;
align-items: center;
min-width: 0;
gap: 1ch;
min-height: var(--tap);
padding: 0 1ch;
color: var(--ink);
letter-spacing: 0.16em;
text-transform: uppercase;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* The key's colour is carried by a solid cell at its head — the
physical coloured button on the remote. */
.fastext a > span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.fastext a::before {
content: "";
flex: 0 0 auto;
width: 1.2ch;
height: 1em;
background: currentColor;
}
.fastext .k-red { color: var(--alert); }
.fastext .k-green { color: var(--ok); }
.fastext .k-yellow { color: var(--voice); }
.fastext .k-cyan { color: var(--live); }
.fastext a:hover, .fastext a:focus-visible {
background: currentColor;
outline: none;
}
.fastext a:hover span, .fastext a:focus-visible span { color: var(--tt-black); }
/* ── Footer ───────────────────────────────────────────── */
.tt-foot {
max-width: min(var(--page-cols), 100%);
margin: var(--row) auto 0;
padding: 0 var(--gutter) var(--row);
display: flex;
gap: 2ch;
color: var(--ink-dim);
}
.tt-foot .v { margin-left: auto; }
/* ── Motion ─────────────────────────────────────────────
The one authored moment: a teletext page did not appear, it
was transmitted, painting in a few rows at a time. Sections
arrive in sequence on load. Nothing else in this world moves.
Content is visible by default; the animation only delays an
already-rendered row's opacity. */
@keyframes tt-in {
from { opacity: 0; }
to { opacity: 1; }
}
.tt-page > * {
animation: tt-in 0.18s steps(2, end) both;
}
.tt-page > :nth-child(1) { animation-delay: 0.00s; }
.tt-page > :nth-child(2) { animation-delay: 0.05s; }
.tt-page > :nth-child(3) { animation-delay: 0.10s; }
.tt-page > :nth-child(4) { animation-delay: 0.15s; }
.tt-page > :nth-child(5) { animation-delay: 0.20s; }
.tt-page > :nth-child(6) { animation-delay: 0.25s; }
.tt-page > :nth-child(7) { animation-delay: 0.30s; }
.tt-page > :nth-child(n+8) { animation-delay: 0.35s; }
@media (prefers-reduced-motion: reduce) {
.tt-page > * { animation: none; }
* { transition-duration: 0.01ms !important; }
}
/* ── Responsive ─────────────────────────────────────────
The grid is character-based, so it survives narrowing on its
own. Two real moves: the double-height row stops being double
on a phone (it would eat the viewport), and fastext stacks to
two columns rather than shrinking below a tap target. */
@media (max-width: 760px) {
/* --bar-cells stays 20 at every width, on purpose: the bar() macro in
_macros.html hardcodes the same 20 so it can quantise a percentage to
whole cells server-side. Two constants that have to agree is exactly
the kind of drift discipline 3 (One Ruler) exists to prevent — an
earlier version of this file set 14 here, which desynced the CSS
width from the markup's own cell math and could land a fill mid-cell
on a phone. --bar-cell-w is in ch, so the ruler still shrinks with
the font; it just does it as 20 smaller cells, never a different
cell count. */
:root {
--gutter: 1rem;
}
.tt-dh { font-size: 1.375rem; }
.fastext { grid-template-columns: repeat(2, 1fr); gap: 0; }
.tt-row-name { min-width: 9ch; }
.tt-figs { gap: 0 2.5ch; }
}
@media (max-width: 560px) {
/* Below this a row cannot hold number, name, description and value on
one line. The description drops to its own line under them rather
than the row growing wider than the screen. */
.tt-row {
gap: 0 1ch;
flex-wrap: wrap;
padding-top: 0.35em;
padding-bottom: 0.35em;
}
.tt-row-name { min-width: 0; }
.tt-row-body {
flex: 1 0 100%;
order: 3;
}
.tt-row-title { white-space: normal; }
.tt-row-val { margin-left: auto; white-space: normal; }
.tt-head-name { letter-spacing: 0.12em; }
.tt-head-clock { margin-left: 0; flex-basis: 100%; }
}
/* ── Search ─────────────────────────────────────────────
A field and a reversed key. The underscore run under the
input is the only line in this world, and it is a character
row rather than a box around anything. */
.tt-search {
display: flex;
gap: 1ch;
align-items: stretch;
margin: var(--row) 0;
padding: 0 1ch;
}
.tt-search .tt-input { flex: 1; min-width: 0; }
@media (max-width: 560px) {
.tt-search { flex-direction: column; }
}
/* ── Forms ──────────────────────────────────────────────
A label above its field, the field marked by an underscore
run. No box, no group border — the colour field header above
each block is what says where one section ends. */
.tt-form {
display: flex;
flex-direction: column;
gap: var(--row);
align-items: flex-start;
margin: var(--row) 0 calc(var(--row) * 1.5);
padding: 0 1ch;
max-width: 52ch;
}
.tt-field-row { width: 100%; }
.tt-label {
display: block;
color: var(--ink-dim);
letter-spacing: 0.16em;
text-transform: uppercase;
}
.tt-form .tt-input { width: 100%; }
.tt-select {
appearance: none;
-webkit-appearance: none;
color: var(--voice);
cursor: pointer;
}
.tt-select option { background: var(--tt-black); color: var(--voice); }
.tt-hint { margin: 0.2em 0 0; color: var(--ink-dim); }
.tt-inline-err { color: var(--alert); min-height: 1em; }
.tt-keys {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(30ch, 100%), 1fr));
gap: 0 3ch;
}
.tt-keys .tt-form { max-width: none; }
/* ── Toast ──────────────────────────────────────────────
Teletext announced itself in the header row, so a confirmation
lands as a reversed strip rather than a floating card. */
.tt-toast {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: 0.6em var(--gutter);
background: var(--ok);
color: var(--tt-black);
letter-spacing: 0.16em;
text-transform: uppercase;
opacity: 0;
transform: translateY(100%);
transition: opacity 0.15s, transform 0.15s;
pointer-events: none;
}
.tt-toast.show { opacity: 1; transform: translateY(0); }
.tt-toast.error { background: var(--alert); color: var(--tt-white); }
/* ── Sign-in ────────────────────────────────────────────
The one page with no navigation, so it centres its single
block rather than opening the full page measure. */
.tt-login .tt-page { max-width: 52ch; margin: 0 auto; }
.tt-login .tt-head { max-width: 52ch; }
.tt-login .tt-foot { max-width: 52ch; }

View file

@ -1,406 +0,0 @@
/* Server-rendered config, read off <body data-*> (see dashboard.html).
URL_BASE carries the reverse-proxy prefix so /api/* fetches still
resolve when the app is mounted on a subpath via SCRIPT_NAME. */
const URL_BASE = document.body.dataset.urlBase || "";
const TIMEZONE = document.body.dataset.timezone || "UTC";
let sonarrSearchResult = null;
let radarrSearchResult = null;
/* ── Clock ── */
function updateClock() {
const el = document.getElementById('header-clock');
if (!el) return;
try {
el.textContent = new Date().toLocaleTimeString('en-US', {
timeZone: TIMEZONE, hour: '2-digit', minute: '2-digit'
});
} catch (e) {
el.textContent = new Date().toLocaleTimeString();
}
}
updateClock();
setInterval(updateClock, 1000);
/* ── Discover: show more / show less ── */
function toggleMore(btn, listId) {
const list = document.getElementById(listId);
if (!list) return;
const items = Array.from(list.children);
const hidden = items.filter(el => el.classList.contains('is-hidden'));
if (hidden.length) {
hidden.forEach(el => el.classList.remove('is-hidden'));
btn.textContent = 'Show less';
btn.setAttribute('aria-expanded', 'true');
} else {
items.forEach((el, i) => {
if (i >= 5) el.classList.add('is-hidden');
});
const n = items.length - 5;
btn.textContent = 'Show ' + n + ' more';
btn.setAttribute('aria-expanded', 'false');
}
}
/* ── Add flow: first tap arms, second tap commits ──
Adding queues a real download, so a single stray tap shouldn't do it. */
let armedBtn = null;
let armedTimer = null;
function disarm() {
if (armedBtn) {
armedBtn.classList.remove('armed');
armedBtn.textContent = '+ Add';
armedBtn = null;
}
clearTimeout(armedTimer);
}
function armAdd(btn) {
if (armedBtn === btn) { disarm(); return doAdd(btn); }
disarm();
armedBtn = btn;
btn.classList.add('armed');
btn.textContent = 'Confirm?';
armedTimer = setTimeout(disarm, 4000);
}
function removeRtItem(btn) {
const item = btn.closest('.rt-item');
if (!item) return;
item.classList.add('removing');
setTimeout(() => {
const list = item.parentElement;
item.remove();
if (list) {
let n = 0;
list.querySelectorAll('.rt-item').forEach(el => {
el.querySelector('.rt-rank').textContent = ++n;
});
}
}, 320);
}
async function doAdd(btn) {
const title = btn.dataset.title;
const target = btn.dataset.target; // 'sonarr' | 'radarr'
btn.disabled = true;
btn.textContent = 'Adding…';
try {
const resp = await fetch(URL_BASE + '/add-to-' + target, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ title })
});
const data = await resp.json();
if ((resp.ok && data.success) || resp.status === 409) {
removeRtItem(btn);
} else {
btn.disabled = false;
btn.textContent = '+ Add';
alert('Failed to add "' + title + '": ' + (data.error || 'Unknown error'));
}
} catch (e) {
btn.disabled = false;
btn.textContent = '+ Add';
alert('Network error: ' + e.message);
}
}
// A tap anywhere else cancels a pending confirm.
document.addEventListener('click', e => {
if (armedBtn && !e.target.closest('.btn-add')) disarm();
});
/* ── SABnzbd live polling (never cached server-side) ── */
const sabSpeed = document.getElementById('sab-speed');
const sabQueue = document.getElementById('sab-queue');
const sabRemaining = document.getElementById('sab-remaining');
const sabBadge = document.getElementById('sab-badge');
const sabTimeleft = document.getElementById('sab-timeleft');
const sabTimeleftRow = document.getElementById('sab-timeleft-row');
async function pollSabnzbd() {
try {
const resp = await fetch(URL_BASE + '/api/sabnzbd', { cache: 'no-store', credentials: 'include' });
if (!resp.ok) return;
const d = await resp.json();
if (d.status !== 'ok') return;
if (sabSpeed) sabSpeed.textContent = d.speed;
if (sabQueue) sabQueue.textContent = d.queue_count;
if (sabRemaining) sabRemaining.textContent = d.sizeleft;
if (sabBadge) {
const s = (d.sab_status || '').toLowerCase();
sabBadge.className = 'badge ' + (s === 'downloading' ? 'downloading' : s === 'paused' ? 'paused' : 'idle');
sabBadge.textContent = d.sab_status;
}
const active = (d.sab_status || '').toLowerCase() === 'downloading'
&& d.timeleft && d.timeleft !== '0:00:00';
if (sabTimeleftRow) sabTimeleftRow.style.display = active ? 'inline' : 'none';
if (sabTimeleft) sabTimeleft.textContent = d.timeleft;
const pill = document.getElementById('svc-stat-sabnzbd');
if (pill) pill.textContent = d.speed;
} catch (e) { /* transient network error — try again next tick */ }
}
pollSabnzbd();
setInterval(pollSabnzbd, 3000);
/* ── RT descriptions arrive asynchronously from IMDB ── */
async function loadRtDescriptions() {
try {
const resp = await fetch(URL_BASE + '/api/rt/descriptions', { credentials: 'include' });
if (!resp.ok) return;
const d = await resp.json();
for (const [title, desc] of Object.entries(d.descriptions || {})) {
const item = document.querySelector(`.rt-item[data-rt-title="${CSS.escape(title)}"]`);
if (!item || !desc) continue;
const el = item.querySelector('.rt-desc');
if (!el || el.textContent.trim()) continue; // already filled
el.textContent = desc.length > 180 ? desc.slice(0, 180) + '…' : desc;
}
if (d.pending) setTimeout(loadRtDescriptions, 4000);
} catch (e) { /* silently skip */ }
}
setTimeout(loadRtDescriptions, 1500);
/* ── Prowlarr indexer polling ── */
function updateServicePill(name, isUp) {
const pill = document.getElementById('svc-' + name);
const light = document.getElementById('svc-light-' + name);
if (pill) { pill.classList.toggle('up', isUp); pill.classList.toggle('down', !isUp); }
if (light) { light.classList.toggle('up', isUp); light.classList.toggle('down', !isUp); }
}
async function pollProwlarr() {
try {
const resp = await fetch(URL_BASE + '/api/prowlarr', { credentials: 'include' });
if (!resp.ok) return;
const d = await resp.json();
if (d.status !== 'ok') return;
const upEl = document.getElementById('prowlarr-up-count');
const totEl = document.getElementById('prowlarr-total');
if (upEl) upEl.textContent = d.up_count;
if (totEl) totEl.textContent = d.total;
d.indexers.forEach(idx => {
const row = document.getElementById('idx-' + idx.id);
const light = document.getElementById('idx-light-' + idx.id);
if (row) { row.classList.toggle('up', idx.up); row.classList.toggle('down', !idx.up); }
if (light) { light.classList.toggle('up', idx.up); light.classList.toggle('down', !idx.up); }
const badge = document.getElementById('idx-badge-' + idx.id);
if (badge) {
badge.classList.toggle('online', idx.up);
badge.classList.toggle('offline', !idx.up);
badge.textContent = idx.up ? 'Online' : 'Offline';
}
});
updateServicePill('prowlarr', d.up_count === d.total && d.total > 0);
const stat = document.getElementById('svc-stat-prowlarr');
if (stat) stat.textContent = d.up_count + '/' + d.total + ' up';
} catch (e) { /* silently skip */ }
}
pollProwlarr();
setInterval(pollProwlarr, 30000);
/* ── Header service lights ── */
async function pollServices() {
try {
const resp = await fetch(URL_BASE + '/api/services', { credentials: 'include' });
if (!resp.ok) return;
const d = await resp.json();
for (const [name, isUp] of Object.entries(d)) updateServicePill(name, isUp);
} catch (e) { /* silently skip */ }
}
pollServices();
setInterval(pollServices, 60000);
/* ── Tautulli live streams ── */
async function pollTautulli() {
try {
const resp = await fetch(URL_BASE + '/api/tautulli', { credentials: 'include' });
if (!resp.ok) return;
const d = await resp.json();
if (d.status !== 'ok') return;
const count = d.stream_count;
const sessions = d.sessions || [];
const stat = document.getElementById('svc-stat-tautulli');
if (stat) stat.textContent = count > 0 ? count + ' streaming' : '0 streams';
const countEl = document.getElementById('tautulli-count');
if (countEl) countEl.textContent = count + ' active';
const body = document.getElementById('now-playing');
if (!body) return;
if (sessions.length) {
body.innerHTML = sessions.map(s => {
const icon = s.media_type === 'movie' ? '🎬' : '📺';
const esc = t => String(t).replace(/[&<>"']/g, c =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
return `<div class="stream">
<span class="live-dot"></span>
<span class="stream-icon" aria-hidden="true">${icon}</span>
<span class="stream-info">
<span class="stream-title">${esc(s.title)}</span>
<span class="stream-user">${esc(s.user)}</span>
</span>
</div>`;
}).join('');
} else {
body.innerHTML = '<p class="empty">Nothing streaming right now.</p>';
}
} catch (e) { /* silently skip */ }
}
pollTautulli();
setInterval(pollTautulli, 15000);
/* ── Clear search results when the field is emptied ── */
document.getElementById('sonarr-search-input').addEventListener('input', function () {
if (!this.value.trim()) {
document.getElementById('sonarr-search-result').classList.remove('visible');
document.getElementById('sonarr-search-msg').style.display = 'none';
sonarrSearchResult = null;
}
});
document.getElementById('radarr-search-input').addEventListener('input', function () {
if (!this.value.trim()) {
document.getElementById('radarr-search-result').classList.remove('visible');
document.getElementById('radarr-search-msg').style.display = 'none';
radarrSearchResult = null;
}
});
/* ── Sonarr search ── */
async function searchSonarr() {
const input = document.getElementById('sonarr-search-input');
const btn = document.getElementById('sonarr-search-btn');
const resultEl = document.getElementById('sonarr-search-result');
const msgEl = document.getElementById('sonarr-search-msg');
const title = input.value.trim();
if (!title) return;
btn.disabled = true; btn.textContent = 'Searching…';
resultEl.classList.remove('visible');
msgEl.style.display = 'none';
sonarrSearchResult = null;
try {
const resp = await fetch(URL_BASE + '/api/sonarr/lookup?q=' + encodeURIComponent(title), { credentials: 'include' });
if (resp.redirected || !resp.headers.get('content-type')?.includes('application/json')) {
msgEl.textContent = 'Session expired — please refresh the page.';
msgEl.className = 'search-msg error'; msgEl.style.display = 'block';
btn.disabled = false; btn.textContent = 'Search'; return;
}
const d = await resp.json();
if (!resp.ok || d.error) {
msgEl.textContent = d.error || 'Search failed.';
msgEl.className = 'search-msg error'; msgEl.style.display = 'block';
} else if (!d.title) {
msgEl.textContent = 'No results found.';
msgEl.className = 'search-msg'; msgEl.style.display = 'block';
} else {
sonarrSearchResult = d;
document.getElementById('sonarr-result-title').textContent = d.title;
document.getElementById('sonarr-result-meta').textContent =
(d.year || '') + (d.network ? ' · ' + d.network : '') + (d.status ? ' · ' + d.status : '');
const addBtn = document.getElementById('sonarr-add-btn');
if (d.exists) {
addBtn.textContent = '✓ In library'; addBtn.disabled = true; addBtn.className = 'btn-add done';
} else {
addBtn.textContent = '+ Add'; addBtn.disabled = false; addBtn.className = 'btn-add';
}
resultEl.classList.add('visible');
}
} catch (e) {
msgEl.textContent = 'Search failed: ' + e.message;
msgEl.className = 'search-msg error'; msgEl.style.display = 'block';
}
btn.disabled = false; btn.textContent = 'Search';
}
async function confirmAddSonarr() {
if (!sonarrSearchResult) return;
const addBtn = document.getElementById('sonarr-add-btn');
addBtn.disabled = true; addBtn.textContent = 'Adding…';
try {
const resp = await fetch(URL_BASE + '/add-to-sonarr', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
credentials: 'include', body: JSON.stringify({ title: sonarrSearchResult.title })
});
const d = await resp.json();
if ((resp.ok && d.success) || resp.status === 409) {
addBtn.textContent = '✓ Added'; addBtn.className = 'btn-add done';
} else {
addBtn.disabled = false; addBtn.textContent = '+ Add';
alert('Failed: ' + (d.error || 'Unknown error'));
}
} catch (e) { addBtn.disabled = false; addBtn.textContent = '+ Add'; }
}
/* ── Radarr search ── */
async function searchRadarr() {
const input = document.getElementById('radarr-search-input');
const btn = document.getElementById('radarr-search-btn');
const resultEl = document.getElementById('radarr-search-result');
const msgEl = document.getElementById('radarr-search-msg');
const title = input.value.trim();
if (!title) return;
btn.disabled = true; btn.textContent = 'Searching…';
resultEl.classList.remove('visible');
msgEl.style.display = 'none';
radarrSearchResult = null;
try {
const resp = await fetch(URL_BASE + '/api/radarr/lookup?q=' + encodeURIComponent(title), { credentials: 'include' });
if (resp.redirected || !resp.headers.get('content-type')?.includes('application/json')) {
msgEl.textContent = 'Session expired — please refresh the page.';
msgEl.className = 'search-msg error'; msgEl.style.display = 'block';
btn.disabled = false; btn.textContent = 'Search'; return;
}
const d = await resp.json();
if (!resp.ok || d.error) {
msgEl.textContent = d.error || 'Search failed.';
msgEl.className = 'search-msg error'; msgEl.style.display = 'block';
} else if (!d.title) {
msgEl.textContent = 'No results found.';
msgEl.className = 'search-msg'; msgEl.style.display = 'block';
} else {
radarrSearchResult = d;
document.getElementById('radarr-result-title').textContent = d.title;
document.getElementById('radarr-result-meta').textContent =
(d.year || '') + (d.studio ? ' · ' + d.studio : '') + (d.runtime ? ' · ' + d.runtime + ' min' : '');
const addBtn = document.getElementById('radarr-add-btn');
if (d.exists) {
addBtn.textContent = '✓ In library'; addBtn.disabled = true; addBtn.className = 'btn-add done';
} else {
addBtn.textContent = '+ Add'; addBtn.disabled = false; addBtn.className = 'btn-add';
}
resultEl.classList.add('visible');
}
} catch (e) {
msgEl.textContent = 'Search failed: ' + e.message;
msgEl.className = 'search-msg error'; msgEl.style.display = 'block';
}
btn.disabled = false; btn.textContent = 'Search';
}
async function confirmAddRadarr() {
if (!radarrSearchResult) return;
const addBtn = document.getElementById('radarr-add-btn');
addBtn.disabled = true; addBtn.textContent = 'Adding…';
try {
const resp = await fetch(URL_BASE + '/add-to-radarr', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
credentials: 'include', body: JSON.stringify({ title: radarrSearchResult.title })
});
const d = await resp.json();
if ((resp.ok && d.success) || resp.status === 409) {
addBtn.textContent = '✓ Added'; addBtn.className = 'btn-add done';
} else {
addBtn.disabled = false; addBtn.textContent = '+ Add';
alert('Failed: ' + (d.error || 'Unknown error'));
}
} catch (e) { addBtn.disabled = false; addBtn.textContent = '+ Add'; }
}

201
static/js/service.js Normal file
View file

@ -0,0 +1,201 @@
/* ═══════════════════════════════════════════════════════
Page 100 — service behaviour.
Three things: the header clock, the page-number jump (the
signature interaction), and the fastext colour keys. Plus
guarded live polling for the two figures that must be
current-second accurate.
Every element lookup is guarded. Nine pages share this file
and none of them carry all of its targets.
═══════════════════════════════════════════════════════ */
(function () {
'use strict';
var body = document.body;
var BASE = body.dataset.urlBase || '';
var TZ = body.dataset.timezone || 'UTC';
/* ── Clock ────────────────────────────────────────────
Teletext's header carried the transmission clock to the
second. It is the one thing on the page that is always
moving, and it is how you know the page is live. */
var clockEl = document.getElementById('head-clock');
function tick() {
if (!clockEl) return;
var now = new Date();
var d = new Intl.DateTimeFormat('en-GB', {
timeZone: TZ, weekday: 'short', day: '2-digit', month: 'short'
}).format(now);
var t = new Intl.DateTimeFormat('en-GB', {
timeZone: TZ, hour: '2-digit', minute: '2-digit',
second: '2-digit', hour12: false
}).format(now);
clockEl.textContent = d.toUpperCase() + ' ' + t;
}
tick();
setInterval(tick, 1000);
/* ── Page-number jump ─────────────────────────────────
Type three digits anywhere and the service goes to that
page, exactly as a teletext remote did. The digits fill in
the header as you type them, so the interaction shows its
own state instead of happening invisibly. */
var pages = {};
try {
(JSON.parse(body.dataset.pages || '[]')).forEach(function (p) {
pages[p[1]] = p[3]; // "100" -> "/"
});
} catch (e) { /* no nav data: the jump simply does nothing */ }
var entryEl = document.getElementById('page-entry');
var buf = '';
var clearTimer = null;
function paintEntry() {
if (!entryEl) return;
if (!buf) { entryEl.hidden = true; entryEl.textContent = ''; return; }
entryEl.hidden = false;
entryEl.textContent = 'P' + buf + '_'.repeat(3 - buf.length);
}
function resetEntry() {
buf = '';
paintEntry();
}
function typing(e) {
var t = e.target;
return t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' ||
t.tagName === 'SELECT' || t.isContentEditable);
}
document.addEventListener('keydown', function (e) {
if (typing(e) || e.metaKey || e.ctrlKey || e.altKey) return;
if (e.key === 'Escape') { resetEntry(); return; }
if (e.key >= '0' && e.key <= '9') {
buf += e.key;
paintEntry();
clearTimeout(clearTimer);
if (buf.length === 3) {
var dest = pages[buf];
if (dest) { window.location.href = BASE + dest; }
else {
// No such page. Teletext just sat on the number and waited.
if (entryEl) entryEl.textContent = 'P' + buf + ' ?';
clearTimer = setTimeout(resetEntry, 1200);
}
buf = '';
return;
}
// A half-typed number times out rather than lingering.
clearTimer = setTimeout(resetEntry, 2000);
return;
}
/* Fastext keys: r / g / y / c, the coloured buttons. */
var k = e.key.toLowerCase();
if ('rgyc'.indexOf(k) !== -1) {
var link = document.querySelector('.fastext a[data-key="' + k + '"]');
if (link) { window.location.href = link.href; }
}
});
/* ── Two-tap add ──────────────────────────────────────
Adding to the library is destructive enough to want a
confirming second press. The button states its own stage
rather than opening a dialog. */
var armed = null;
function disarm() {
if (!armed) return;
armed.classList.remove('armed');
armed.textContent = armed.dataset.label || '+ ADD';
armed = null;
}
window.armAdd = function (btn) {
if (btn.disabled) return;
if (armed && armed !== btn) disarm();
if (armed === btn) {
var title = btn.dataset.title;
var target = btn.dataset.target;
btn.disabled = true;
btn.classList.remove('armed');
btn.textContent = 'SENDING';
fetch(BASE + '/add-to-' + target, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: title })
})
.then(function (r) { return r.json(); })
.then(function (d) {
if (d && d.ok) {
btn.classList.add('done');
btn.textContent = 'ADDED';
} else {
btn.classList.add('armed');
btn.disabled = false;
btn.textContent = 'FAILED';
}
})
.catch(function () {
btn.disabled = false;
btn.classList.add('armed');
btn.textContent = 'FAILED';
});
armed = null;
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();
});
/* ── 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. */
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) {
if (!d || d.status !== 'ok') return;
setText('sab-queue', d.queue_count);
setText('sab-speed', d.speed || '0');
setText('sab-remaining', d.sizeleft || '—');
})
.catch(function () { /* offline is the dev baseline, not an error */ });
}
function pollTautulli() {
if (!document.getElementById('tt-streams')) return;
fetch(BASE + '/api/tautulli')
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d || d.status !== 'ok') return;
setText('tt-streams', d.active_streams);
})
.catch(function () { /* as above */ });
}
if (document.getElementById('sab-queue')) {
pollSab();
setInterval(pollSab, 5000);
}
if (document.getElementById('tt-streams')) {
pollTautulli();
setInterval(pollTautulli, 15000);
}
})();

109
templates/_macros.html Normal file
View file

@ -0,0 +1,109 @@
{#- ═══════════════════════════════════════════════════════
Shared fragments, in the service's own vocabulary.
Every one of these obeys the two disciplines that get broken
by reflex: nothing is enclosed in a border or a box, and every
bar is drawn at the one shared width so any two percentages in
the service compare directly.
═══════════════════════════════════════════════════════ -#}
{#- A degraded service. Teletext's failure was a page that did not
arrive, said in red where the content should have been — not a
panel with an icon and a left border. -#}
{% macro service_error(name, raw) %}
<div class="tt-err" role="alert">
<div class="tt-err-cause">{{ raw | friendly_error(name) }}</div>
{% if raw %}
<details>
<summary>Technical detail</summary>
<pre>{{ raw | redact_secrets }}</pre>
</details>
{% endif %}
</div>
{% endmacro %}
{#- THE ONE RULER. Every bar in the service is drawn here and
nowhere else, at --bar-cells wide, filled in whole cells. Pass a
percentage; the macro rounds it to cells so a bar never ends
mid-character. `tone` picks the colour by threshold. -#}
{% macro bar(pct, tone='') %}
{%- set p = [[pct | float(0), 0] | max, 100] | min -%}
{%- set cells = 20 -%}
{%- set filled = ((p / 100) * cells) | round(0, 'common') | int -%}
{%- set cls = tone if tone else ('alert' if p >= 90 else ('warn' if p >= 75 else 'ok')) -%}
<span class="tt-bar {{ cls }}" role="img"
aria-label="{{ p | round | int }} percent"><i style="width: {{ (filled / cells * 100) | round(2) }}%"></i></span>
{% endmacro %}
{#- Direction of travel. Only ever called where the backend really
carries a comparison — never to decorate a number that has no
history behind it. -#}
{% macro trend(now, reference, unit='') %}
{%- if now is not none and reference is not none and reference > 0 -%}
{%- set delta = ((now - reference) / reference) * 100 -%}
{%- if delta > 12 -%}
<span class="tt-trend up" title="Above the 7-day average">&#9650;</span>
{%- elif delta < -12 -%}
<span class="tt-trend down" title="Below the 7-day average">&#9660;</span>
{%- else -%}
<span class="tt-trend flat" title="At the 7-day average">&#9644;</span>
{%- endif -%}
{%- endif -%}
{% endmacro %}
{#- Disk temperature / spin state. A spun-down disk reports no usable
temperature, so Standby replaces the reading rather than showing a
stale or zero degree value. Colour carries the state; there is no
badge shape to put it in. -#}
{% macro disk_state(dk) %}
{%- if dk.spinning == false -%}
<span class="tt-row-val off">STANDBY</span>
{%- elif dk.temp is not none -%}
<span class="tt-row-val {% if dk.temp >= 55 %}alert{% elif dk.temp >= 45 %}warn{% else %}ok{% endif %}">{{ dk.temp }}&deg;C</span>
{%- else -%}
<span class="tt-row-val off">&mdash;</span>
{%- endif -%}
{% endmacro %}
{#- A title tile. Artwork is the only thing in this world not made of
characters, so it sits as a flat rectangle on the grid with its
label below — no frame, no rounding, no shadow. -#}
{% macro tile(title, sub, poster, badge='', action='', target='') %}
<div class="tt-tile">
{% if poster %}
<img class="tt-tile-art" src="{{ poster }}" alt="" loading="lazy">
{% else %}
<span class="tt-tile-art tt-tile-art-empty" aria-hidden="true"></span>
{% endif %}
<span class="tt-tile-t">{{ title }}</span>
{% if sub or badge %}
<span class="tt-tile-s">
{%- if badge %}<span class="tt-tile-badge">{{ badge }}</span>{% endif %}
{%- if badge and sub %} {% endif %}{{ sub }}</span>
{% endif %}
{% if action %}
<span class="tt-tile-p">
<button class="tt-btn tt-btn-sm" data-title="{{ title }}" data-target="{{ target }}"
onclick="armAdd(this)">{{ action }}</button>
</span>
{% endif %}
</div>
{% endmacro %}
{#- A list row: number, name, body, value. The index rows and every
listing in the service share this one shape. -#}
{% macro row(name, title, sub='', value='', tone='') %}
<div class="tt-row">
{% if name %}<span class="tt-row-name">{{ name }}</span>{% endif %}
<span class="tt-row-body">
<span class="tt-row-title">{{ title }}</span>
{% if sub %}<span class="tt-row-sub">{{ sub }}</span>{% endif %}
</span>
{% if value %}<span class="tt-row-val {{ tone }}">{{ value }}</span>{% endif %}
</div>
{% endmacro %}

69
templates/_shell.html Normal file
View file

@ -0,0 +1,69 @@
{%- set here = (nav_items | selectattr('0', 'equalto', page) | list | first) or ('home', '100', 'Index', '/') -%}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}P{{ here[1] }} Media Centre{% endblock %}</title>
<meta name="theme-color" content="#000000">
<link rel="icon" href="{{ url_for('static', filename='favicon.svg') }}">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Chivo+Mono:wght@400;600&family=DotGothic16&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='css/base.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/teletext.css') }}">
{% block css %}{% endblock %}
</head>
<body data-timezone="{{ timezone }}" data-url-base="{{ request.script_root }}"
data-pages='{{ nav_items | tojson }}'>
<!--
THESIS: Media Centre is a broadcast information service, not a dashboard. It
refuses the bordered-card grid every self-hosted dashboard ships, this one
included until now.
OWN-WORLD: Teletext's locked eight colours at full saturation on pure black,
phosphor bloom on double-height rows only, one blocky monospace at exactly two
heights, a character-cell grid, and grouping carried by colour fields and row
weight — never a border, never a box, never a radius.
STORY: The visitor types or clicks a page number, reads the machine's state off
colour alone, and leaves knowing whether anything needs them.
FIRST VIEWPORT: P100. Double-height yellow masthead left, live clock right;
seven numbered service rows each carrying a live figure in its status colour, so
the index is the status page; NOW SHOWING fills the lower two-thirds in
double-height cyan with progress as whole mosaic cells; fastext colour keys last.
FORM: Ceefax teletext fused with the US cable guide channel; candidate 3 of 7;
seed aab4510e.
FINISH: unreviewed and undocumented is unfinished; this build ends with the
finish review, the verdict, DESIGN.md, and every shipping raster carrying its
provenance.
-->
<header class="tt-head">
<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>
<span class="tt-head-clock" id="head-clock"></span>
</header>
<main class="tt-page">
{% block content %}{% endblock %}
</main>
{#- Fastext: the four coloured keys. Each page names its own four, so the
keys always point somewhere useful from where you are. -#}
<nav class="fastext" aria-label="Fastext">
{% 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>
<span class="v">v{{ app_version }} &middot; {{ app_version_date }}</span>
</footer>
<script src="{{ url_for('static', filename='js/service.js') }}"></script>
{% block js %}{% endblock %}
</body>
</html>

27
templates/activity.html Normal file
View file

@ -0,0 +1,27 @@
{% extends "_shell.html" %}
{% block title %}P106 Activity{% endblock %}
{% block content %}
<h1 class="tt-dh bloom tt-masthead">ACTIVITY</h1>
<p class="tt-empty">Everything the stack has done lately, newest first.</p>
{% if events %}
<div class="tt-field"><span>Log</span><span class="tt-field-meta">{{ events | length }} events</span></div>
<div class="tt-rows">
{% for e in events %}
<div class="tt-row">
{#- The event kind is the row's colour: grabbed is a completed
transfer, requested wants a human, added is new stock. -#}
<span class="tt-row-name tt-row-val {% if e.kind == 'grabbed' %}ok{% elif e.kind == 'requested' %}pending{% else %}{% endif %}">{{ e.label }}</span>
<span class="tt-row-body">
<span class="tt-row-title">{{ e.title }}</span>
{% if e.detail %}<span class="tt-row-sub">{{ e.detail }}</span>{% endif %}
</span>
<span class="tt-row-val off">{{ e.when | default('') }}</span>
</div>
{% endfor %}
</div>
{% else %}
<p class="tt-empty">Nothing on record. Either the stack has been quiet or its sources are unreachable.</p>
{% endif %}
{% endblock %}

View file

@ -1,618 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Media Centre</title>
<link rel="icon" href="{{ url_for('static', filename='favicon.svg') }}">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='css/base.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/dashboard.css') }}">
</head>
{#- Degraded service: human-readable cause, raw exception behind a
disclosure with any API key redacted. -#}
{% macro service_error(name, raw) %}
<div class="error" role="alert">
<div>{{ raw | friendly_error(name) }}</div>
{% if raw %}
<details class="error-detail">
<summary>Technical details</summary>
<pre>{{ raw | redact_secrets }}</pre>
</details>
{% endif %}
</div>
{% endmacro %}
{#- Disk temperature / spin state, as one badge. A spun-down disk reports no
usable temperature, so Standby replaces the reading rather than showing
a stale or zero degree value. -#}
{% macro disk_state(dk) %}
{%- if dk.spinning == false -%}
<span class="badge idle">Standby</span>
{%- elif dk.temp is not none -%}
<span class="badge temp-{% if dk.temp >= 55 %}danger{% elif dk.temp >= 45 %}warn{% else %}ok{% endif %}">{{ dk.temp }}&deg;C</span>
{%- else -%}
<span class="disk-null">&mdash;</span>
{%- endif -%}
{% endmacro %}
{#- One service status pill in the Status strip. `short` is swapped in for
`label` on phones, where six full names can't share one row without
ellipsising down to two characters — see the .svc-name rules. The full
name stays in the DOM for screen readers either way. -#}
{% macro svc_pill(key, label, short, url, up, stat) %}
<a class="svc-pill {{ 'up' if up else 'down' }}" id="svc-{{ key }}"
href="{{ url }}" target="_blank" rel="noopener" title="{{ label }}">
<span class="status-light {{ 'up' if up else 'down' }}" id="svc-light-{{ key }}"></span>
<span class="svc-name" data-short="{{ short }}">{{ label }}</span>
<span class="svc-stat" id="svc-stat-{{ key }}">{{ stat }}</span>
</a>
{% endmacro %}
<body data-timezone="{{ timezone }}" data-url-base="{{ request.script_root }}">
<header>
<div class="brand">
<svg width="30" height="30" viewBox="0 0 100 100" aria-hidden="true">
<rect width="100" height="100" rx="22" fill="var(--accent)"/>
<path d="M40 32 L72 50 L40 68 Z" fill="var(--bg)"/>
</svg>
<h1>Media Centre</h1>
</div>
<div class="header-right">
<div class="header-clock" id="header-clock">—</div>
<a href="{{ url_for('settings_page') }}" class="icon-btn" aria-label="Settings">
<span aria-hidden="true">⚙</span>
</a>
</div>
</header>
<main>
<!-- ══ Status — is everything reachable? ══ -->
<section class="section" aria-labelledby="h-status">
<h2 id="h-status">Status</h2>
{# Every service stays visible at every width — these wrap onto more
rows rather than scrolling or truncating. A hidden pill is a service
whose status you can't see. #}
<nav class="svc-strip" aria-label="Service status">
{{ svc_pill('sonarr', 'Sonarr', 'Son', service_urls.sonarr, sonarr.status == 'ok',
(sonarr.total_series ~ ' series') if sonarr.status == 'ok' else 'offline') }}
{{ svc_pill('radarr', 'Radarr', 'Rad', service_urls.radarr, radarr.status == 'ok',
(radarr.total_movies ~ ' movies') if radarr.status == 'ok' else 'offline') }}
{{ svc_pill('prowlarr', 'Prowlarr', 'Prow', service_urls.prowlarr, prowlarr.status == 'ok',
(prowlarr.up_count ~ '/' ~ prowlarr.total ~ ' up') if prowlarr.status == 'ok' else 'offline') }}
{{ svc_pill('sabnzbd', 'SABnzbd', 'SAB', service_urls.sabnzbd, sabnzbd.status == 'ok',
sabnzbd.speed if sabnzbd.status == 'ok' else 'offline') }}
{{ svc_pill('tautulli', 'Tautulli', 'Taut', service_urls.tautulli, tautulli.status == 'ok',
((tautulli.active_streams ~ ' streaming') if tautulli.active_streams > 0 else '0 streams') if tautulli.status == 'ok' else 'offline') }}
{{ svc_pill('ombi', 'Ombi', 'Ombi', service_urls.ombi, ombi.status == 'ok',
(ombi.total_pending ~ ' pending') if ombi.status == 'ok' else 'offline') }}
</nav>
</section>
<!-- ══ Right now — the time-sensitive row, above the fold ══ -->
<section class="section hero" aria-labelledby="h-now">
<h2 id="h-now">Right now</h2>
{# Two cards here, not three. Storage carries a per-disk table that no
third-of-a-page column can hold without towering over these two and
hollowing them out — it gets its own full-width row below. #}
<div class="row cols-2">
<!-- Now playing -->
<div class="card">
<div class="card-header">
<h3><a href="{{ service_urls.tautulli }}" target="_blank" rel="noopener">Now playing</a></h3>
<span class="card-meta" id="tautulli-count">
{%- if tautulli.status == 'ok' -%}
{{ tautulli.active_streams }} active
{%- endif -%}
</span>
</div>
<div class="card-body">
{% if tautulli.status == 'ok' %}
<div id="now-playing" aria-live="polite">
{% if tautulli.sessions %}
{% for s in tautulli.sessions %}
<div class="stream">
<span class="live-dot"></span>
<span class="stream-icon" aria-hidden="true">{{ '🎬' if s.media_type == 'movie' else '📺' }}</span>
<span class="stream-info">
<span class="stream-title">{{ s.title }}</span>
<span class="stream-user">{{ s.user }}</span>
</span>
</div>
{% endfor %}
{% else %}
<p class="empty">Nothing streaming right now.</p>
{% endif %}
</div>
{% else %}
{{ service_error("Tautulli", tautulli.error) }}
{% endif %}
</div>
</div>
<!-- Downloads -->
<div class="card">
<div class="card-header">
<h3><a href="{{ service_urls.sabnzbd }}" target="_blank" rel="noopener">Downloads</a></h3>
{% if sabnzbd.status == 'ok' %}
{% set st = sabnzbd.sab_status | lower %}
<span class="card-meta">
<span id="sab-badge" class="badge {{ 'downloading' if st == 'downloading' else ('paused' if st == 'paused' else 'idle') }}">{{ sabnzbd.sab_status }}</span>
</span>
{% endif %}
</div>
<div class="card-body">
{% if sabnzbd.status == 'ok' %}
<div aria-live="polite">
<div class="hero-figure" id="sab-speed">{{ sabnzbd.speed }}</div>
<div class="hero-sub">
<span id="sab-queue">{{ sabnzbd.queue_count }}</span> queued ·
<span id="sab-remaining">{{ sabnzbd.sizeleft }}</span> left
<span id="sab-timeleft-row" style="display:none;"> · ETA <span id="sab-timeleft"></span></span>
</div>
</div>
<dl class="stats">
<div><dt>Avg speed (7d)</dt><dd>{{ sabnzbd.avg_speed }}</dd></div>
<div><dt>Grabbed (7d)</dt><dd>{{ sabnzbd.completed_7d }}</dd></div>
<div><dt>Volume (7d)</dt><dd>{{ sabnzbd.week_size }}</dd></div>
</dl>
{% else %}
{{ service_error("SABnzbd", sabnzbd.error) }}
{% endif %}
</div>
</div>
</div>
{# Storage spans the page so its per-disk table has room to be a table.
Ten disks down a narrow column is a tower; across the full width it
is ten short rows. #}
<div class="row">
<div class="card">
<div class="card-header">
<h3><a href="{{ service_urls.unraid }}" target="_blank" rel="noopener">Storage</a></h3>
{% if unraid.status == 'ok' %}
<span class="card-meta card-meta-wrap">
{{ unraid.array_used_pct }}% used · {{ unraid.array_free }} free of {{ unraid.array_total }}
{%- if unraid.disk_count %} · {{ unraid.disk_count }} disk{{ 's' if unraid.disk_count != 1 }}{% endif %}
{%- if unraid.array_state %} · {{ unraid.array_state | capitalize }}{% endif %}
{#- Parity has no table row of its own (no filesystem to measure),
so its temperature rides here — the one thing worth watching. -#}
{%- if unraid.parities %} · <span class="meta-parity">Parity
{%- for dk in unraid.parities %} {{ disk_state(dk) }}{% endfor %}</span>
{%- endif %}
</span>
{% endif %}
</div>
<div class="card-body">
{% if unraid.status == 'ok' %}
{% if unraid.disks or unraid.caches %}
{# Array total leads, then the disks that make it up. #}
{% set pct = unraid.array_used_pct %}
<div class="meter array-meter">
<div class="meter-fill {% if pct > 85 %}danger{% elif pct > 70 %}warn{% endif %}"
style="width: {{ pct }}%;"
role="progressbar" aria-valuenow="{{ pct }}" aria-valuemin="0" aria-valuemax="100"
aria-label="Array usage"></div>
</div>
<div class="table-scroll">
<table class="disk-table">
<thead>
<tr>
<th scope="col">Disk</th>
<th scope="col" class="num">Used</th>
<th scope="col" class="num">Size</th>
<th scope="col" class="num">Free</th>
<th scope="col" class="usage">Usage</th>
<th scope="col" class="num">Temp</th>
</tr>
</thead>
<tbody>
{% for dk in unraid.disks + unraid.caches %}
{% set dpct = dk.used_pct %}
<tr>
<th scope="row" title="{{ dk.device }}">{{ dk.name }}</th>
<td class="num">{{ dk.used }}</td>
<td class="num">{{ dk.total }}</td>
<td class="num">{{ dk.free }}</td>
<td class="usage">
<div class="usage-cell">
<div class="meter">
<div class="meter-fill {% if dpct > 85 %}danger{% elif dpct > 70 %}warn{% endif %}"
style="width: {{ dpct }}%;"
role="progressbar" aria-valuenow="{{ dpct }}" aria-valuemin="0" aria-valuemax="100"
aria-label="{{ dk.name }} usage"></div>
</div>
<span class="usage-pct">{{ dpct }}%</span>
</div>
</td>
<td class="num">{{ disk_state(dk) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
{# Fallback: container-visible disk usage, no per-disk detail. #}
{% set pct = unraid.array_used_pct %}
<div class="hero-figure"
style="color: {% if pct > 85 %}var(--danger){% elif pct > 70 %}var(--warn){% else %}var(--text){% endif %};">
{{ pct }}%
</div>
<div class="hero-sub">{{ unraid.array_free }} free of {{ unraid.array_total }}</div>
<div class="meter">
<div class="meter-fill {% if pct > 85 %}danger{% elif pct > 70 %}warn{% endif %}"
style="width: {{ pct }}%;"
role="progressbar" aria-valuenow="{{ pct }}" aria-valuemin="0" aria-valuemax="100"
aria-label="Array usage"></div>
</div>
<div class="meter-legend"><span>{{ unraid.array_used }} used</span></div>
{% if unraid.source == "local" %}
<p class="empty">Container disk usage ({{ unraid.mount }}). Set <code>UNRAID_API_KEY</code> for full array stats.</p>
{% endif %}
{% endif %}
{% else %}
{{ service_error("Unraid", unraid.error) }}
{% endif %}
</div>
</div>
</div>
</section>
<!-- ══ Library ══ -->
<section class="section" aria-labelledby="h-lib">
<h2 id="h-lib">Library</h2>
<div class="row cols-2">
<!-- Sonarr -->
<div class="card">
<div class="card-header">
<h3><a href="{{ service_urls.sonarr }}" target="_blank" rel="noopener">TV — Sonarr</a></h3>
</div>
<div class="card-body">
<div class="search-bar">
<label class="visually-hidden" for="sonarr-search-input">Search for a TV show</label>
<input type="text" id="sonarr-search-input" placeholder="Search and add a TV show…"
onkeydown="if(event.key==='Enter') searchSonarr()">
<button class="btn" id="sonarr-search-btn" onclick="searchSonarr()">Search</button>
</div>
<div id="sonarr-search-result" class="search-result">
<div class="search-result-info">
<div class="search-result-title" id="sonarr-result-title"></div>
<div class="search-result-meta" id="sonarr-result-meta"></div>
</div>
<button class="btn-add" id="sonarr-add-btn" onclick="confirmAddSonarr()">+ Add</button>
</div>
<div id="sonarr-search-msg" class="search-msg" style="display:none;" role="status"></div>
{% if sonarr.status == 'ok' %}
<dl class="stats">
<div><dt>Series</dt><dd>{{ sonarr.total_series }}</dd></div>
<div><dt>Missing episodes</dt><dd class="{{ 'is-warn' if sonarr.missing_episodes else '' }}">{{ sonarr.missing_episodes }}</dd></div>
</dl>
{% else %}
{{ service_error("Sonarr", sonarr.error) }}
{% endif %}
</div>
</div>
<!-- Radarr -->
<div class="card">
<div class="card-header">
<h3><a href="{{ service_urls.radarr }}" target="_blank" rel="noopener">Movies — Radarr</a></h3>
</div>
<div class="card-body">
<div class="search-bar">
<label class="visually-hidden" for="radarr-search-input">Search for a movie</label>
<input type="text" id="radarr-search-input" placeholder="Search and add a movie…"
onkeydown="if(event.key==='Enter') searchRadarr()">
<button class="btn" id="radarr-search-btn" onclick="searchRadarr()">Search</button>
</div>
<div id="radarr-search-result" class="search-result">
<div class="search-result-info">
<div class="search-result-title" id="radarr-result-title"></div>
<div class="search-result-meta" id="radarr-result-meta"></div>
</div>
<button class="btn-add" id="radarr-add-btn" onclick="confirmAddRadarr()">+ Add</button>
</div>
<div id="radarr-search-msg" class="search-msg" style="display:none;" role="status"></div>
{% if radarr.status == 'ok' %}
<dl class="stats">
<div><dt>Movies</dt><dd>{{ radarr.total_movies }}</dd></div>
<div><dt>Missing</dt><dd class="{{ 'is-warn' if radarr.missing_movies else '' }}">{{ radarr.missing_movies }}</dd></div>
</dl>
{% else %}
{{ service_error("Radarr", radarr.error) }}
{% endif %}
</div>
</div>
</div>
</section>
<!-- ══ Discover ══ -->
<section class="section" aria-labelledby="h-disc">
<h2 id="h-disc">Discover — not in your library yet</h2>
<div class="row cols-2">
<!-- Popular TV -->
<div class="card">
<div class="card-header"><h3>Popular TV on Rotten Tomatoes</h3></div>
<div class="card-body">
{% if rt_tv and rt_tv[0] is defined and rt_tv[0].error is defined %}
<p class="empty">Couldn't load TV recommendations.</p>
{% elif rt_tv %}
<div class="rt-list" id="rt-tv-list">
{% for show in rt_tv %}
<div class="rt-item {{ 'is-hidden' if loop.index > 5 }}" data-rt-title="{{ show.title }}">
<span class="rt-rank">{{ loop.index }}</span>
{% if show.poster %}<img class="rt-poster" src="{{ show.poster }}" alt="" loading="lazy">
{% else %}<span class="rt-poster"></span>{% endif %}
<div class="rt-info">
<div class="rt-title">{{ show.title }}{% if show.year %} <span class="rt-year">({{ show.year }})</span>{% endif %}</div>
<div class="rt-meta">
{% if show.certified %}<span class="score certified">Certified Fresh</span>
{% elif show.critics_score %}<span class="score critics">{{ show.critics_score }}% critics</span>{% endif %}
{% if show.audience_score %}<span class="score audience">{{ show.audience_score }} audience</span>{% endif %}
</div>
<p class="rt-desc">{{ show.description[:180] ~ '…' if show.description and show.description|length > 180 else (show.description or '') }}</p>
</div>
<button class="btn-add" data-title="{{ show.title }}" data-target="sonarr" onclick="armAdd(this)">+ Add</button>
</div>
{% endfor %}
</div>
{% if rt_tv | length > 5 %}
<button class="btn btn-quiet show-more" onclick="toggleMore(this, 'rt-tv-list')"
aria-expanded="false">Show {{ rt_tv | length - 5 }} more</button>
{% endif %}
{% else %}
<p class="empty">No TV recommendations available.</p>
{% endif %}
</div>
</div>
<!-- Top box office -->
<div class="card">
<div class="card-header"><h3>Top box office on Rotten Tomatoes</h3></div>
<div class="card-body">
{% if rt_movies and rt_movies[0] is defined and rt_movies[0].error is defined %}
<p class="empty">Couldn't load movie recommendations.</p>
{% elif rt_movies %}
<div class="rt-list" id="rt-movie-list">
{% for movie in rt_movies %}
<div class="rt-item {{ 'is-hidden' if loop.index > 5 }}" data-rt-title="{{ movie.title }}">
<span class="rt-rank">{{ loop.index }}</span>
{% if movie.poster %}<img class="rt-poster" src="{{ movie.poster }}" alt="" loading="lazy">
{% else %}<span class="rt-poster"></span>{% endif %}
<div class="rt-info">
<div class="rt-title">{{ movie.title }}{% if movie.year %} <span class="rt-year">({{ movie.year }})</span>{% endif %}</div>
<div class="rt-meta">
{% if movie.certified %}<span class="score certified">Certified Fresh</span>
{% elif movie.critics_score %}<span class="score critics">{{ movie.critics_score }}% critics</span>{% endif %}
{% if movie.audience_score %}<span class="score audience">{{ movie.audience_score }} audience</span>{% endif %}
</div>
<p class="rt-desc">{{ movie.description[:180] ~ '…' if movie.description and movie.description|length > 180 else (movie.description or '') }}</p>
</div>
<button class="btn-add" data-title="{{ movie.title }}" data-target="radarr" onclick="armAdd(this)">+ Add</button>
</div>
{% endfor %}
</div>
{% if rt_movies | length > 5 %}
<button class="btn btn-quiet show-more" onclick="toggleMore(this, 'rt-movie-list')"
aria-expanded="false">Show {{ rt_movies | length - 5 }} more</button>
{% endif %}
{% else %}
<p class="empty">No movie recommendations available.</p>
{% endif %}
</div>
</div>
</div>
</section>
<!-- ══ Requests + Indexers ══ -->
<section class="section" aria-labelledby="h-ops">
<h2 id="h-ops">Requests &amp; indexers</h2>
<div class="row cols-2 align-start">
<!-- Ombi -->
<div class="card">
<div class="card-header">
<h3><a href="{{ service_urls.ombi }}" target="_blank" rel="noopener">Requests — Ombi</a></h3>
{% if ombi.status == 'ok' %}
<span class="card-meta">{{ ombi.total_pending }} pending of {{ ombi.total_requests }}</span>
{% endif %}
</div>
<div class="card-body">
{% if ombi.status == 'ok' %}
{% if ombi.recent %}
<div class="req-list" id="req-list">
{% for req in ombi.recent %}
{# Same anatomy as an indexer row: status dot, name over a
dimmed detail line, status badge on the right. #}
{% set st = req.status | lower %}
<div class="req-item {{ 'is-hidden' if loop.index > 5 }}">
<span class="status-light {{ 'up' if st in ('available', 'approved')
else ('down' if st == 'denied' else 'warn') }}"></span>
<div class="req-info">
<div class="req-title">{{ req.title }}</div>
<div class="req-sub"><span class="req-type">{{ req.type }}</span> · {{ req.user }} · {{ req.date_display }}</div>
</div>
<span class="badge {{ st }}">{{ req.status }}</span>
</div>
{% endfor %}
</div>
{% if ombi.recent | length > 5 %}
<button class="btn btn-quiet show-more show-more-row" onclick="toggleMore(this, 'req-list')"
aria-expanded="false">Show {{ ombi.recent | length - 5 }} more</button>
{% endif %}
{% else %}
<p class="empty">No recent requests.</p>
{% endif %}
{% else %}
{{ service_error("Ombi", ombi.error) }}
{% endif %}
</div>
</div>
<!-- Prowlarr -->
<div class="card" id="prowlarr-card">
<div class="card-header">
<h3><a href="{{ service_urls.prowlarr }}" target="_blank" rel="noopener">Indexers — Prowlarr</a></h3>
{% if prowlarr.status == 'ok' %}
<span class="card-meta">
<span id="prowlarr-up-count">{{ prowlarr.up_count }}</span>/<span id="prowlarr-total">{{ prowlarr.total }}</span> online
{%- if prowlarr.has_stats %} · share of {{ prowlarr.share_metric }}, {{ prowlarr.stats_days }}d{% endif %}
</span>
{% endif %}
</div>
<div class="card-body" id="prowlarr-body">
{% if prowlarr.status == 'ok' %}
{# Bars are scaled against the busiest indexer, not against 100%,
so a spread of single-digit shares is still readable. The label
beside each bar stays the true percentage. #}
{% set top_share = prowlarr.indexers[0].share if prowlarr.indexers else 0 %}
<div class="indexer-grid" id="indexer-grid">
{% for idx in prowlarr.indexers %}
{% set share = idx.share %}
<div class="indexer-item {{ 'up' if idx.up else 'down' }}" id="idx-{{ idx.id }}">
<span class="status-light {{ 'up' if idx.up else 'down' }}" id="idx-light-{{ idx.id }}"></span>
<div class="indexer-info">
<div class="indexer-name" title="{{ idx.name }}">{{ idx.name }}</div>
<div class="indexer-sub">
<span class="proto">{{ idx.protocol }}</span>{% if prowlarr.has_stats %} · {{ '{:,}'.format(idx.grabs) }} grabs · {{ '{:,}'.format(idx.queries) }} queries{% endif %}
</div>
{% if prowlarr.has_stats %}
<div class="share-bar" role="img"
aria-label="{{ share }}% of {{ prowlarr.share_metric }} in the last {{ prowlarr.stats_days }} days">
<span style="width: {{ (share / top_share * 100) | round(1) if top_share else 0 }}%"></span>
</div>
{% endif %}
</div>
{% if prowlarr.has_stats %}
<span class="share-pct">{{ share }}%</span>
{% endif %}
<span class="badge {{ 'online' if idx.up else 'offline' }}"
id="idx-badge-{{ idx.id }}">{{ 'Online' if idx.up else 'Offline' }}</span>
</div>
{% endfor %}
</div>
{% else %}
{{ service_error("Prowlarr", prowlarr.error) }}
{% endif %}
</div>
</div>
</div>
</section>
<!-- ══ Insights ══ -->
{% if tautulli.status == 'ok' %}
<section class="section" aria-labelledby="h-ins">
<h2 id="h-ins">Watch insights — last 30 days</h2>
<div class="row cols-2">
<div class="card">
<div class="card-header"><h3>Most played</h3></div>
<div class="card-body">
{% if tautulli.top_played %}
<div class="top-list">
{% for item in tautulli.top_played %}
<div class="top-item">
<span class="top-rank">{{ loop.index }}</span>
<div class="top-info">
<div class="top-name">{{ item.title }}</div>
<div class="top-sub">{{ item.user }}</div>
</div>
{% set mt = item.media_type | lower %}
<span class="badge {{ 'tv' if mt == 'episode' else 'movie' }}">{{ 'TV' if mt == 'episode' else ('Movie' if mt == 'movie' else mt or '?') }}</span>
<span class="top-plays">{{ item.plays }}×</span>
</div>
{% endfor %}
</div>
{% else %}
<p class="empty">No play history yet.</p>
{% endif %}
</div>
</div>
<div class="card">
<div class="card-header"><h3>Top viewers</h3></div>
<div class="card-body">
{% if tautulli.top_users %}
<div class="top-list">
{% for u in tautulli.top_users %}
<div class="top-item">
<span class="top-rank">{{ loop.index }}</span>
<div class="top-info">
<div class="top-name">{{ u.username }}</div>
</div>
<span class="top-plays">{{ u.plays }} plays</span>
</div>
{% endfor %}
</div>
{% else %}
<p class="empty">No viewer data yet.</p>
{% endif %}
</div>
</div>
</div>
{% if tautulli.top_tv_list or tautulli.top_movie_list %}
<div class="row cols-2">
<div class="card">
<div class="card-header"><h3>Top TV</h3></div>
<div class="card-body">
{% if tautulli.top_tv_list %}
<div class="top-list">
{% for item in tautulli.top_tv_list %}
<div class="top-item">
<span class="top-rank">{{ loop.index }}</span>
<div class="top-info"><div class="top-name">{{ item.title }}</div></div>
<span class="top-plays">{{ item.plays }} plays</span>
</div>
{% endfor %}
</div>
{% else %}<p class="empty">No data yet.</p>{% endif %}
</div>
</div>
<div class="card">
<div class="card-header"><h3>Top movies</h3></div>
<div class="card-body">
{% if tautulli.top_movie_list %}
<div class="top-list">
{% for item in tautulli.top_movie_list %}
<div class="top-item">
<span class="top-rank">{{ loop.index }}</span>
<div class="top-info"><div class="top-name">{{ item.title }}</div></div>
<span class="top-plays">{{ item.plays }} plays</span>
</div>
{% endfor %}
</div>
{% else %}<p class="empty">No data yet.</p>{% endif %}
</div>
</div>
</div>
{% endif %}
</section>
{% endif %}
</main>
<footer>
Media Centre
<span class="version">v{{ app_version }} · {{ app_version_date }}</span>
</footer>
<script src="{{ url_for('static', filename='js/dashboard.js') }}"></script>
</body>
</html>

36
templates/discover.html Normal file
View file

@ -0,0 +1,36 @@
{% extends "_shell.html" %}
{% 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 held filtered out.</p>
<div class="tt-field">
<span>Films</span>
<span class="tt-field-meta">{{ rt_movies | length }} not held</span>
</div>
{% if rt_movies %}
<div class="tt-tiles">
{% for m in rt_movies %}
{{ tile(m.title, m.year, m.poster, m.score, '+ Add', 'radarr') }}
{% endfor %}
</div>
{% else %}
<p class="tt-empty">Nothing to suggest &mdash; 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 held</span>
</div>
{% if rt_tv %}
<div class="tt-tiles">
{% for s in rt_tv %}
{{ tile(s.title, s.year, s.poster, s.score, '+ Add', 'sonarr') }}
{% endfor %}
</div>
{% else %}
<p class="tt-empty">Nothing to suggest &mdash; the feed is unreachable or you already hold it all.</p>
{% endif %}
{% endblock %}

144
templates/home.html Normal file
View file

@ -0,0 +1,144 @@
{% extends "_shell.html" %}
{% from "_macros.html" import service_error, bar, tile %}
{% block title %}P100 Media Centre{% endblock %}
{% block content %}
{#- The masthead. Double height, the service's own colour, and the one
place on the page bloom is allowed. -#}
<h1 class="tt-dh bloom tt-masthead">MEDIA CENTRE</h1>
{#- ── The index IS the status page ──────────────────────────────
Every destination row carries its own live figure in its own
colour, so choosing where to go and reading how things are are
the same act. This is the whole thesis in one block. -#}
<div class="tt-rows">
<a class="tt-row" href="{{ request.script_root }}/watch">
<span class="tt-row-num">101</span>
<span class="tt-row-name">Watch</span>
<span class="tt-row-body"><span class="tt-row-sub">Who is streaming right now</span></span>
{% if tautulli.status == 'ok' %}
<span class="tt-row-val {{ 'ok' if tautulli.active_streams else 'off' }}" id="tt-streams-row">
<span id="tt-streams">{{ tautulli.active_streams }}</span> STREAMING</span>
{% else %}
<span class="tt-row-val alert">OFF AIR</span>
{% endif %}
</a>
<a class="tt-row" href="{{ request.script_root }}/library">
<span class="tt-row-num">102</span>
<span class="tt-row-name">Library</span>
<span class="tt-row-body"><span class="tt-row-sub">Everything already held</span></span>
{% if sonarr.status == 'ok' or radarr.status == 'ok' %}
<span class="tt-row-val">{{ sonarr.total_series | default(0) }} SHOWS &nbsp;{{ radarr.total_movies | default(0) }} FILMS</span>
{% else %}
<span class="tt-row-val alert">OFF AIR</span>
{% endif %}
</a>
<a class="tt-row" href="{{ request.script_root }}/discover">
<span class="tt-row-num">103</span>
<span class="tt-row-name">Discover</span>
<span class="tt-row-body"><span class="tt-row-sub">Popular, and not yet held</span></span>
<span class="tt-row-val">{{ (rt_movies | length) + (rt_tv | length) }} SUGGESTED</span>
</a>
<a class="tt-row" href="{{ request.script_root }}/requests">
<span class="tt-row-num">104</span>
<span class="tt-row-name">Requests</span>
<span class="tt-row-body"><span class="tt-row-sub">Waiting on a decision</span></span>
{% if ombi.status == 'ok' %}
<span class="tt-row-val {{ 'pending' if ombi.total_pending else 'off' }}">{{ ombi.total_pending | default(0) }} PENDING</span>
{% else %}
<span class="tt-row-val alert">OFF AIR</span>
{% endif %}
</a>
<a class="tt-row" href="{{ request.script_root }}/system">
<span class="tt-row-num">105</span>
<span class="tt-row-name">System</span>
<span class="tt-row-body"><span class="tt-row-sub">Array, disks and indexers</span></span>
{% if unraid.status == 'ok' %}
{%- set pct = unraid.array_used_pct | default(0) | float -%}
<span class="tt-row-val {{ 'alert' if pct >= 90 else ('warn' if pct >= 75 else 'ok') }}">{{ pct | round | int }}% FULL</span>
{% else %}
<span class="tt-row-val alert">OFF AIR</span>
{% endif %}
</a>
<a class="tt-row" href="{{ request.script_root }}/activity">
<span class="tt-row-num">106</span>
<span class="tt-row-name">Activity</span>
<span class="tt-row-body"><span class="tt-row-sub">What the stack has been doing</span></span>
{% if sabnzbd.status == 'ok' %}
<span class="tt-row-val {{ '' if sabnzbd.queue_count else 'off' }}"><span id="sab-queue">{{ sabnzbd.queue_count }}</span> IN QUEUE</span>
{% else %}
<span class="tt-row-val alert">OFF AIR</span>
{% endif %}
</a>
<a class="tt-row" href="{{ request.script_root }}/settings">
<span class="tt-row-num">900</span>
<span class="tt-row-name">Settings</span>
<span class="tt-row-body"><span class="tt-row-sub">Keys, timezone and password</span></span>
</a>
</div>
<div class="tt-sep" aria-hidden="true"></div>
{#- ── Now showing ───────────────────────────────────────────────
The lower two-thirds. The furthest-along stream is set at double
height in the live colour, with its progress as whole mosaic
cells on the service's one ruler. -#}
<div class="tt-field">
<span>Now Showing</span>
{% if tautulli.status == 'ok' %}
<span class="tt-field-meta">{{ tautulli.active_streams }} active</span>
{% endif %}
</div>
{% if tautulli.status != 'ok' %}
{{ service_error('Tautulli', tautulli.error) }}
{% elif hero %}
<div class="tt-now">
<div class="tt-dh bloom tt-now-title">{{ hero.title }}</div>
<div class="tt-now-meta">
{{ hero.user or hero.username }}
{%- if hero.player %} &middot; {{ hero.player }}{% endif %}
{%- if hero.quality %} &middot; {{ hero.quality }}{% endif %}
{%- if hero.state %} &middot; {{ hero.state | upper }}{% endif %}
</div>
<div class="tt-now-line">
{{ bar(hero.progress | default(0), 'ok') }}
<span class="tt-now-pct">{{ hero.progress | default(0) | round | int }}%</span>
{% if hero.remaining %}<span class="tt-row-sub">{{ hero.remaining }} LEFT</span>{% endif %}
</div>
</div>
{% if other_sessions %}
<div class="tt-rows tt-rows-after">
{% for s in other_sessions %}
<div class="tt-row">
<span class="tt-row-body">
<span class="tt-row-title">{{ s.title }}</span>
<span class="tt-row-sub">{{ s.user or s.username }}{% if s.player %} &middot; {{ s.player }}{% endif %}</span>
</span>
{{ bar(s.progress | default(0), 'ok') }}
<span class="tt-row-val">{{ s.progress | default(0) | round | int }}%</span>
</div>
{% endfor %}
</div>
{% endif %}
{% else %}
<p class="tt-empty">Nothing streaming. The service is quiet.</p>
{% endif %}
{#- ── Just added ──────────────────────────────────────────────── -#}
{% if recently_added %}
<div class="tt-field"><span>Just Added</span></div>
<div class="tt-tiles">
{% for it in recently_added %}
{{ tile(it.title, it.sub, it.poster, it.ago) }}
{% endfor %}
</div>
{% endif %}
{% endblock %}

66
templates/library.html Normal file
View file

@ -0,0 +1,66 @@
{% extends "_shell.html" %}
{% from "_macros.html" import service_error, tile %}
{% block title %}P102 Library{% endblock %}
{% block content %}
<h1 class="tt-dh bloom tt-masthead">LIBRARY</h1>
{#- Search sits at the top because arriving here with a title in mind is
the common case. It posts to the same lookup the old page used. -#}
<form class="tt-search" action="{{ request.script_root }}/library" method="get" role="search">
<label class="visually-hidden" for="q">Search the library</label>
<input class="tt-input" type="search" id="q" name="q" value="{{ request.args.get('q', '') }}"
placeholder="TITLE" autocomplete="off" spellcheck="false">
<button class="tt-btn" type="submit">Search</button>
</form>
<div class="tt-field">
<span>Holdings</span>
<span class="tt-field-meta">
{%- if sonarr.status == 'ok' %}{{ sonarr.total_series }} shows{% endif -%}
{%- if sonarr.status == 'ok' and radarr.status == 'ok' %} &middot; {% endif -%}
{%- if radarr.status == 'ok' %}{{ radarr.total_movies }} films{% endif -%}
</span>
</div>
<div class="tt-figs">
{% if sonarr.status == 'ok' %}
<div class="tt-fig">
<div class="tt-dh tt-fig-v">{{ sonarr.total_series }}</div>
<div class="tt-fig-l">Shows</div>
</div>
<div class="tt-fig">
<div class="tt-dh tt-fig-v {{ 'alert' if sonarr.missing_episodes else 'ok' }}">{{ sonarr.missing_episodes }}</div>
<div class="tt-fig-l">Missing eps</div>
</div>
{% endif %}
{% if radarr.status == 'ok' %}
<div class="tt-fig">
<div class="tt-dh tt-fig-v">{{ radarr.total_movies }}</div>
<div class="tt-fig-l">Films</div>
</div>
<div class="tt-fig">
<div class="tt-dh tt-fig-v {{ 'alert' if radarr.missing_movies else 'ok' }}">{{ radarr.missing_movies }}</div>
<div class="tt-fig-l">Missing films</div>
</div>
{% endif %}
</div>
{% if sonarr.status != 'ok' %}
<div class="tt-field crit"><span>Sonarr</span><span class="tt-field-meta">off air</span></div>
{{ service_error('Sonarr', sonarr.error) }}
{% endif %}
{% if radarr.status != 'ok' %}
<div class="tt-field crit"><span>Radarr</span><span class="tt-field-meta">off air</span></div>
{{ service_error('Radarr', radarr.error) }}
{% endif %}
{% if recently_added %}
<div class="tt-field"><span>Newest In</span></div>
<div class="tt-tiles">
{% for it in recently_added %}
{{ tile(it.title, it.sub, it.poster, it.ago) }}
{% endfor %}
</div>
{% endif %}
{% endblock %}

View file

@ -3,50 +3,59 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sign in — Media Centre</title>
<title>Media Centre</title>
<meta name="theme-color" content="#000000">
<link rel="icon" href="{{ url_for('static', filename='favicon.svg') }}">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Chivo+Mono:wght@400;600&family=DotGothic16&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='css/base.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/login.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/teletext.css') }}">
</head>
<body>
<body class="tt-login">
<div class="wrap">
{#- The service's start-up page: the header row it always carries, the
masthead, and the one thing it wants before it will transmit. -#}
<header class="tt-head">
<span class="tt-head-num">P000</span>
<span class="tt-head-name">Sign In</span>
</header>
<div class="brand">
<svg width="30" height="30" viewBox="0 0 100 100" aria-hidden="true">
<rect width="100" height="100" rx="22" fill="var(--accent)"/>
<path d="M40 32 L72 50 L40 68 Z" fill="var(--bg)"/>
</svg>
<h1>Media Centre</h1>
</div>
<main class="tt-page">
<h1 class="tt-dh bloom tt-masthead">MEDIA CENTRE</h1>
<div class="tt-field"><span>Access</span></div>
<div class="card">
{% if error %}
<div class="error-msg" role="alert">{{ error }}</div>
<div class="tt-err" role="alert"><div class="tt-err-cause">{{ error }}</div></div>
{% endif %}
<form method="POST" action="{{ url_for('login', next=request.args.get('next', '')) }}">
<form class="tt-form" method="POST" action="{{ url_for('login', next=request.args.get('next', '')) }}">
{# The username field only appears once accounts exist in
settings.json. A single-password install never sees a box it
would have to leave blank. #}
{% if multi_user %}
<label for="username">Username</label>
<input type="text" id="username" name="username"
placeholder="Enter username" autofocus autocomplete="username"
<div class="tt-field-row">
<label class="tt-label" for="username">Username</label>
<input class="tt-input" type="text" id="username" name="username"
autofocus autocomplete="username"
autocapitalize="none" autocorrect="off" spellcheck="false">
</div>
{% endif %}
<label for="password"{% if multi_user %} class="stacked"{% endif %}>Password</label>
<input type="password" id="password" name="password"
placeholder="Enter password"{% if not multi_user %} autofocus{% endif %}
<div class="tt-field-row">
<label class="tt-label" for="password">Password</label>
<input class="tt-input" type="password" id="password" name="password"
{% if not multi_user %}autofocus{% endif %}
autocomplete="current-password">
<button type="submit">Sign in</button>
</div>
<button class="tt-btn" type="submit">Sign In</button>
</form>
</div>
</main>
</div>
<footer class="tt-foot">
<span>MEDIA CENTRE</span>
<span class="v">v{{ app_version }} &middot; {{ app_version_date }}</span>
</footer>
</body>
</html>

49
templates/requests.html Normal file
View file

@ -0,0 +1,49 @@
{% extends "_shell.html" %}
{% from "_macros.html" import service_error %}
{% block title %}P104 Requests{% endblock %}
{% block content %}
<h1 class="tt-dh bloom tt-masthead">REQUESTS</h1>
{% if ombi.status != 'ok' %}
<div class="tt-field crit"><span>Ombi</span><span class="tt-field-meta">off air</span></div>
{{ service_error('Ombi', ombi.error) }}
{% else %}
{#- Pending requests are the only thing here that wants a human, so a
page holding any is set heavier than one holding none (discipline 5). -#}
<div class="tt-field {{ 'warn' if ombi.total_pending else '' }}">
<span>Awaiting A Decision</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' }}">{{ ombi.total_pending }}</div>
<div class="tt-fig-l">Pending</div>
</div>
<div class="tt-fig">
<div class="tt-dh tt-fig-v">{{ ombi.total_requests }}</div>
<div class="tt-fig-l">All time</div>
</div>
</div>
{% if ombi.recent %}
<div class="tt-field"><span>Latest</span></div>
<div class="tt-rows">
{% for r in ombi.recent %}
<div class="tt-row">
<span class="tt-row-name">{{ (r.type or '') | upper }}</span>
<span class="tt-row-body">
<span class="tt-row-title">{{ r.title }}</span>
<span class="tt-row-sub">{{ r.user }}{% if r.requested_date %} &middot; {{ r.requested_date[:10] }}{% endif %}</span>
</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>
</div>
{% endfor %}
</div>
{% else %}
<p class="tt-empty">No requests on record.</p>
{% endif %}
{% endif %}
{% endblock %}

View file

@ -1,37 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Settings — Media Centre</title>
<link rel="icon" href="{{ url_for('static', filename='favicon.svg') }}">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='css/base.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}">
</head>
<body data-url-base="{{ request.script_root }}">
{% extends "_shell.html" %}
{% block title %}P900 Settings{% endblock %}
<header>
<svg width="30" height="30" viewBox="0 0 100 100" aria-hidden="true">
<rect width="100" height="100" rx="22" fill="var(--accent)"/>
<path d="M40 32 L72 50 L40 68 Z" fill="var(--bg)"/>
</svg>
<h1>Settings</h1>
<a href="{{ url_for('dashboard') }}" class="back-btn">← Dashboard</a>
</header>
{% block content %}
<h1 class="tt-dh bloom tt-masthead">SETTINGS</h1>
<p class="tt-empty">Keys saved here override the values in <code>.env</code>. Both need a container restart to take effect.</p>
<main>
<div class="grid">
<!-- General -->
<div class="card full">
<div class="card-header"><h2>General</h2></div>
<div class="card-body">
<div class="field">
<label for="timezone">Timezone</label>
<select id="timezone">
{#- ── General ────────────────────────────────────────────────── -#}
<div class="tt-field"><span>General</span></div>
<div class="tt-form">
<div class="tt-field-row">
<label class="tt-label" for="timezone">Timezone</label>
<select class="tt-input tt-select" id="timezone">
{% set tzs = [
'America/New_York','America/Chicago','America/Denver','America/Los_Angeles',
'America/Toronto','America/Vancouver','America/Phoenix','America/Halifax',
@ -45,63 +24,57 @@
{% endfor %}
</select>
</div>
<div class="field">
<label for="arr_host">Host IP (ARR_HOST)</label>
<input type="text" id="arr_host" value="{{ arr_host }}" placeholder="e.g. 192.168.86.33">
<p class="hint">LAN address of the machine running Sonarr, Radarr, and the rest.</p>
</div>
<button class="save" onclick="saveGeneral()">Save general</button>
<div class="tt-field-row">
<label class="tt-label" for="arr_host">Host IP (ARR_HOST)</label>
<input class="tt-input" type="text" id="arr_host" value="{{ arr_host }}" placeholder="192.168.86.33">
<p class="tt-hint">LAN address of the machine running Sonarr, Radarr and the rest.</p>
</div>
<button class="tt-btn" onclick="saveGeneral()">Save General</button>
</div>
<!-- Change password -->
<div class="card full">
<div class="card-header"><h2>Change password</h2></div>
<div class="card-body">
<div class="field">
<label for="pw_current">Current password</label>
<input type="password" id="pw_current" autocomplete="current-password">
{#- ── Password ───────────────────────────────────────────────── -#}
<div class="tt-field"><span>Change Password</span></div>
<div class="tt-form">
<div class="tt-field-row">
<label class="tt-label" for="pw_current">Current password</label>
<input class="tt-input" type="password" id="pw_current" autocomplete="current-password">
</div>
<div class="field">
<label for="pw_new">New password</label>
<input type="password" id="pw_new" placeholder="At least 6 characters" autocomplete="new-password">
<div class="tt-field-row">
<label class="tt-label" for="pw_new">New password</label>
<input class="tt-input" type="password" id="pw_new" placeholder="At least 6 characters" autocomplete="new-password">
</div>
<div class="field">
<label for="pw_confirm">Confirm new password</label>
<input type="password" id="pw_confirm" autocomplete="new-password">
<div class="error-inline" id="pw-error" role="alert"></div>
</div>
<button class="save" onclick="changePassword()">Update password</button>
<div class="tt-field-row">
<label class="tt-label" for="pw_confirm">Confirm new password</label>
<input class="tt-input" type="password" id="pw_confirm" autocomplete="new-password">
<div class="tt-inline-err" id="pw-error" role="alert"></div>
</div>
<button class="tt-btn" onclick="changePassword()">Update Password</button>
</div>
<!-- Service API keys -->
{#- ── Keys ───────────────────────────────────────────────────── -#}
<div class="tt-field"><span>Service Keys</span></div>
<div class="tt-keys">
{% for svc, label in [
('sonarr','Sonarr'), ('radarr','Radarr'), ('sabnzbd','SABnzbd'),
('tautulli','Tautulli'), ('prowlarr','Prowlarr'), ('ombi','Ombi')
] %}
<div class="card">
<div class="card-header"><h2>{{ label }}</h2></div>
<div class="card-body">
<div class="field">
<label for="{{ svc }}_api_key">API key</label>
<input type="password" id="{{ svc }}_api_key"
<div class="tt-form">
<div class="tt-field-row">
<label class="tt-label" for="{{ svc }}_api_key">{{ label }} API key</label>
<input class="tt-input" type="password" id="{{ svc }}_api_key"
value="{{ {'sonarr': sonarr_api_key, 'radarr': radarr_api_key,
'sabnzbd': sabnzbd_api_key, 'tautulli': tautulli_api_key,
'prowlarr': prowlarr_api_key, 'ombi': ombi_api_key}[svc] }}"
autocomplete="off">
</div>
<button class="save" onclick="saveService('{{ svc }}')">Save</button>
</div>
<button class="tt-btn tt-btn-quiet" onclick="saveService('{{ svc }}')">Save {{ label }}</button>
</div>
{% endfor %}
</div>
</main>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<div class="tt-toast" id="toast" role="status" aria-live="polite"></div>
{% endblock %}
{% block js %}
<script src="{{ url_for('static', filename='js/settings.js') }}"></script>
</body>
</html>
{% endblock %}

151
templates/system.html Normal file
View file

@ -0,0 +1,151 @@
{% extends "_shell.html" %}
{% from "_macros.html" import service_error, bar, disk_state, trend %}
{% block title %}P105 System{% endblock %}
{% block content %}
<h1 class="tt-dh bloom tt-masthead">SYSTEM</h1>
{#- ── 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. -#}
<div class="tt-field"><span>Transmission</span></div>
<div class="tt-rows">
{% for name, svc in [('Sonarr', sonarr), ('Radarr', radarr), ('SABnzbd', sabnzbd),
('Tautulli', tautulli), ('Prowlarr', prowlarr), ('Ombi', ombi)] %}
<div class="tt-row">
<span class="tt-row-name">{{ name }}</span>
<span class="tt-row-body"></span>
<span class="tt-row-val {{ 'ok' if svc.status == 'ok' else 'alert' }}">
{{- 'ON AIR' if svc.status == 'ok' else 'OFF AIR' -}}
</span>
</div>
{% endfor %}
</div>
{#- ── Array ───────────────────────────────────────────────────── -#}
{% if unraid.status != 'ok' %}
<div class="tt-field crit"><span>Array</span><span class="tt-field-meta">unreachable</span></div>
{{ service_error('Unraid', unraid.error) }}
{% else %}
{%- set apct = unraid.array_used_pct | default(0) | float -%}
<div class="tt-field {% if apct >= 90 %}crit{% elif apct >= 75 %}warn{% endif %}">
<span>Array</span>
<span class="tt-field-meta">
{{ unraid.array_state | default('') }}
{%- if unraid.parities %} &middot; parity
{%- for p in unraid.parities %} {{ p.temp if p.temp is not none else '—' }}&deg;C{% endfor %}
{%- endif %}
</span>
</div>
<div class="tt-figs">
<div class="tt-fig">
<div class="tt-dh tt-fig-v {{ 'alert' if apct >= 90 else ('warn' if apct >= 75 else 'ok') }}">{{ apct | round | int }}%</div>
<div class="tt-fig-l">Used</div>
</div>
<div class="tt-fig">
<div class="tt-dh tt-fig-v">{{ unraid.array_free | default('—') }}</div>
<div class="tt-fig-l">Free</div>
</div>
<div class="tt-fig">
<div class="tt-dh tt-fig-v">{{ unraid.array_total | default('—') }}</div>
<div class="tt-fig-l">Total</div>
</div>
<div class="tt-fig">
<div class="tt-dh tt-fig-v">{{ unraid.disk_count | default(0) }}</div>
<div class="tt-fig-l">Disks</div>
</div>
</div>
{% if unraid.disks %}
<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>
{% endif %}
{% endif %}
{#- ── Downloads ─────────────────────────────────────────────────
The one place the service holds real history, so the one place a
trend arrow is honest: current speed against the 7-day average. -#}
{% if sabnzbd.status == 'ok' %}
<div class="tt-field"><span>Downloads</span><span class="tt-field-meta">{{ sabnzbd.sab_status | default('') }}</span></div>
<div class="tt-figs">
<div class="tt-fig">
<div class="tt-dh tt-fig-v"><span id="sab-queue">{{ sabnzbd.queue_count }}</span></div>
<div class="tt-fig-l">In queue</div>
</div>
<div class="tt-fig">
<div class="tt-dh tt-fig-v"><span id="sab-speed">{{ sabnzbd.speed | default('0') }}</span> {{ trend(sabnzbd.speed_kbps, sabnzbd.avg_speed) }}</div>
<div class="tt-fig-l">Speed vs 7-day avg</div>
</div>
<div class="tt-fig">
<div class="tt-dh tt-fig-v"><span id="sab-remaining">{{ sabnzbd.sizeleft | default('—') }}</span></div>
<div class="tt-fig-l">Remaining</div>
</div>
<div class="tt-fig">
<div class="tt-dh tt-fig-v">{{ sabnzbd.week_size | default('—') }}</div>
<div class="tt-fig-l">This week</div>
</div>
</div>
{% else %}
<div class="tt-field crit"><span>Downloads</span><span class="tt-field-meta">off air</span></div>
{{ service_error('SABnzbd', sabnzbd.error) }}
{% endif %}
{#- ── Indexers ────────────────────────────────────────────────── -#}
{% if prowlarr.status == 'ok' %}
<div class="tt-field">
<span>Indexers</span>
<span class="tt-field-meta">{{ prowlarr.up_count }} of {{ prowlarr.total }} up</span>
</div>
<div class="tt-rows">
{% for idx in prowlarr.indexers %}
<div class="tt-row">
<span class="tt-row-name">{{ idx.protocol | default('') }}</span>
<span class="tt-row-body">
<span class="tt-row-title">{{ idx.name }}</span>
{% if prowlarr.has_stats %}<span class="tt-row-sub">{{ idx.grabs | default(0) }} grabs &middot; {{ idx.queries | default(0) }} queries</span>{% endif %}
</span>
{% if prowlarr.has_stats %}{{ bar(idx.grab_share | default(0), 'ok' if idx.up else 'alert') }}{% endif %}
<span class="tt-row-val {{ 'ok' if idx.up else 'alert' }}">{{ 'UP' if idx.up else 'DOWN' }}</span>
</div>
{% endfor %}
</div>
{% else %}
<div class="tt-field crit"><span>Indexers</span><span class="tt-field-meta">off air</span></div>
{{ service_error('Prowlarr', prowlarr.error) }}
{% endif %}
{% endblock %}

79
templates/watch.html Normal file
View file

@ -0,0 +1,79 @@
{% extends "_shell.html" %}
{% from "_macros.html" import service_error, bar %}
{% block title %}P101 Watch{% endblock %}
{% block content %}
<h1 class="tt-dh bloom tt-masthead">WATCH</h1>
{% if tautulli.status != 'ok' %}
<div class="tt-field crit"><span>Tautulli</span><span class="tt-field-meta">off air</span></div>
{{ service_error('Tautulli', tautulli.error) }}
{% else %}
{%- set sessions = tautulli.sessions or [] -%}
<div class="tt-field {{ 'warn' if sessions else '' }}">
<span>Streaming Now</span>
<span class="tt-field-meta">{{ tautulli.active_streams }} active</span>
</div>
{% if sessions %}
<div class="tt-rows">
{% for s in sessions %}
<div class="tt-row">
<span class="tt-row-name">{{ (s.media_type or 'play') | upper }}</span>
<span class="tt-row-body">
<span class="tt-row-title">{{ s.title }}</span>
<span class="tt-row-sub">
{{ s.user or s.username }}
{%- if s.player %} &middot; {{ s.player }}{% endif %}
{%- if s.quality %} &middot; {{ s.quality }}{% endif %}
{%- if s.decision %} &middot; {{ s.decision | upper }}{% endif %}
{%- if s.ends_at %} &middot; ENDS {{ s.ends_at }}{% endif %}
</span>
</span>
{{ bar(s.progress | default(0), 'ok') }}
<span class="tt-row-val">{{ s.progress | default(0) | round | int }}%</span>
</div>
{% endfor %}
</div>
{% else %}
<p class="tt-empty">Nothing streaming. The service is quiet.</p>
{% endif %}
<div class="tt-field"><span>Most Played</span><span class="tt-field-meta">last 30 days</span></div>
<div class="tt-rows">
{% for t in (tautulli.top_tv_list or [])[:5] %}
<div class="tt-row">
<span class="tt-row-num">{{ loop.index }}</span>
<span class="tt-row-name">TV</span>
<span class="tt-row-body"><span class="tt-row-title">{{ t.title }}</span></span>
<span class="tt-row-val">{{ t.plays }} PLAYS</span>
</div>
{% endfor %}
{% for t in (tautulli.top_movie_list or [])[:5] %}
<div class="tt-row">
<span class="tt-row-num">{{ loop.index }}</span>
<span class="tt-row-name">Film</span>
<span class="tt-row-body"><span class="tt-row-title">{{ t.title }}</span></span>
<span class="tt-row-val">{{ t.plays }} PLAYS</span>
</div>
{% endfor %}
{% if not tautulli.top_tv_list and not tautulli.top_movie_list %}
<p class="tt-empty">No play history yet.</p>
{% endif %}
</div>
{% if tautulli.top_users %}
<div class="tt-field"><span>Who Is Watching</span></div>
<div class="tt-rows">
{% for u in tautulli.top_users[:5] %}
<div class="tt-row">
<span class="tt-row-num">{{ loop.index }}</span>
<span class="tt-row-body"><span class="tt-row-title">{{ u.username or u.user }}</span></span>
<span class="tt-row-val">{{ u.plays }} PLAYS</span>
</div>
{% endfor %}
</div>
{% endif %}
{% endif %}
{% endblock %}