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

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="card">
{% if error %}
<div class="error-msg" role="alert">{{ error }}</div>
{% endif %}
<div class="tt-field"><span>Access</span></div>
<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"
{% if error %}
<div class="tt-err" role="alert"><div class="tt-err-cause">{{ error }}</div></div>
{% endif %}
<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 %}
<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">
{% 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>
{% 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>
</form>
</div>
</div>
<button class="tt-btn" type="submit">Sign In</button>
</form>
</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,107 +1,80 @@
<!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>
<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">
{% set tzs = [
'America/New_York','America/Chicago','America/Denver','America/Los_Angeles',
'America/Toronto','America/Vancouver','America/Phoenix','America/Halifax',
'Europe/London','Europe/Paris','Europe/Berlin','Europe/Amsterdam',
'Europe/Stockholm','Europe/Rome','Europe/Madrid','Europe/Zurich',
'Australia/Sydney','Australia/Melbourne','Australia/Brisbane','Australia/Perth',
'Pacific/Auckland','Asia/Tokyo','Asia/Singapore','Asia/Dubai','Asia/Kolkata'
] %}
{% for tz in tzs %}
<option value="{{ tz }}" {% if tz == timezone %}selected{% endif %}>{{ tz }}</option>
{% 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>
</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">
</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>
<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>
</div>
<!-- Service API 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"
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>
</div>
{% endfor %}
{% 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>
{#- ── 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',
'Europe/London','Europe/Paris','Europe/Berlin','Europe/Amsterdam',
'Europe/Stockholm','Europe/Rome','Europe/Madrid','Europe/Zurich',
'Australia/Sydney','Australia/Melbourne','Australia/Brisbane','Australia/Perth',
'Pacific/Auckland','Asia/Tokyo','Asia/Singapore','Asia/Dubai','Asia/Kolkata'
] %}
{% for tz in tzs %}
<option value="{{ tz }}" {% if tz == timezone %}selected{% endif %}>{{ tz }}</option>
{% endfor %}
</select>
</div>
</main>
<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>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
{#- ── 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="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="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>
{#- ── 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="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="tt-btn tt-btn-quiet" onclick="saveService('{{ svc }}')">Save {{ label }}</button>
</div>
{% endfor %}
</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 %}