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:
parent
9a277b9031
commit
4b19cbdfb2
25 changed files with 3002 additions and 2343 deletions
109
templates/_macros.html
Normal file
109
templates/_macros.html
Normal 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">▲</span>
|
||||
{%- elif delta < -12 -%}
|
||||
<span class="tt-trend down" title="Below the 7-day average">▼</span>
|
||||
{%- else -%}
|
||||
<span class="tt-trend flat" title="At the 7-day average">▬</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 }}°C</span>
|
||||
{%- else -%}
|
||||
<span class="tt-row-val off">—</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
69
templates/_shell.html
Normal 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 }} · {{ 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
27
templates/activity.html
Normal 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 %}
|
||||
|
|
@ -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 }}°C</span>
|
||||
{%- else -%}
|
||||
<span class="disk-null">—</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 & 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
36
templates/discover.html
Normal 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 — 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 — the feed is unreachable or you already hold it all.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
144
templates/home.html
Normal file
144
templates/home.html
Normal 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 {{ 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 %} · {{ hero.player }}{% endif %}
|
||||
{%- if hero.quality %} · {{ hero.quality }}{% endif %}
|
||||
{%- if hero.state %} · {{ 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 %} · {{ 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
66
templates/library.html
Normal 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' %} · {% 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 %}
|
||||
|
|
@ -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 }} · {{ app_version_date }}</span>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
49
templates/requests.html
Normal file
49
templates/requests.html
Normal 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 %} · {{ 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 %}
|
||||
|
|
@ -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
151
templates/system.html
Normal 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 %} · parity
|
||||
{%- for p in unraid.parities %} {{ p.temp if p.temp is not none else '—' }}°C{% endfor %}
|
||||
{%- endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="tt-figs">
|
||||
<div class="tt-fig">
|
||||
<div class="tt-dh tt-fig-v {{ 'alert' if apct >= 90 else ('warn' if apct >= 75 else 'ok') }}">{{ apct | round | int }}%</div>
|
||||
<div class="tt-fig-l">Used</div>
|
||||
</div>
|
||||
<div class="tt-fig">
|
||||
<div class="tt-dh tt-fig-v">{{ unraid.array_free | default('—') }}</div>
|
||||
<div class="tt-fig-l">Free</div>
|
||||
</div>
|
||||
<div class="tt-fig">
|
||||
<div class="tt-dh tt-fig-v">{{ unraid.array_total | default('—') }}</div>
|
||||
<div class="tt-fig-l">Total</div>
|
||||
</div>
|
||||
<div class="tt-fig">
|
||||
<div class="tt-dh tt-fig-v">{{ unraid.disk_count | default(0) }}</div>
|
||||
<div class="tt-fig-l">Disks</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if unraid.disks %}
|
||||
<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 · {{ 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
79
templates/watch.html
Normal 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 %} · {{ s.player }}{% endif %}
|
||||
{%- if s.quality %} · {{ s.quality }}{% endif %}
|
||||
{%- if s.decision %} · {{ s.decision | upper }}{% endif %}
|
||||
{%- if s.ends_at %} · 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 %}
|
||||
Loading…
Add table
Add a link
Reference in a new issue