arr-summary/templates/dashboard.html
Barely Removable 18c7719cd7
All checks were successful
Deploy / deploy (push) Successful in 23s
Extract CSS/JS to static files, wrap header pills, add version footer
The palette lived in a :root block duplicated across all three
templates, so every reshade meant three edits — and drifted twice in
practice (the favicon hardcoded its own hex; stale danger-border rgba
literals survived two palette changes). Move tokens to a single
static/css/base.css, derive every dim/border tint from the four source
hues with color-mix, and point the favicon at one static/favicon.svg.

Also splits the 1558-line dashboard.html into template + static assets:
662 lines of CSS and 396 of JS were re-sent uninlined on every load
with no browser caching, which costs real time on the remote/cellular
access this app is built for. Templates drop to 501/42/107 lines.
Server-rendered config (timezone, script_root) now arrives via
<body data-*>; wiring URL_BASE to request.script_root also fixes /api/*
fetches for a future subpath mount, which the hardcoded "" would break.

Header service pills now wrap instead of scrolling — they were clipped
at narrow widths, and a hidden pill is a service whose status you
cannot see. Verified 0 clipped and no page overflow at 320/375/700/1280.

Footer gains a version and date so the running build is identifiable
without trusting the Unraid Docker tab's cached container IDs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 14:27:52 -07:00

504 lines
23 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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 %}
{#- One service status pill in the header strip. -#}
{% macro svc_pill(key, label, url, up, stat) %}
<a class="svc-pill {{ 'up' if up else 'down' }}" id="svc-{{ key }}"
href="{{ url }}" target="_blank" rel="noopener">
<span class="status-light {{ 'up' if up else 'down' }}" id="svc-light-{{ key }}"></span>
<span class="svc-name">{{ 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>
<nav class="svc-strip" aria-label="Service status">
{{ svc_pill('sonarr', 'Sonarr', service_urls.sonarr, sonarr.status == 'ok',
(sonarr.total_series ~ ' series') if sonarr.status == 'ok' else 'offline') }}
{{ svc_pill('radarr', 'Radarr', service_urls.radarr, radarr.status == 'ok',
(radarr.total_movies ~ ' movies') if radarr.status == 'ok' else 'offline') }}
{{ svc_pill('prowlarr', 'Prowlarr', service_urls.prowlarr, prowlarr.status == 'ok',
(prowlarr.up_count ~ '/' ~ prowlarr.total ~ ' up') if prowlarr.status == 'ok' else 'offline') }}
{{ svc_pill('sabnzbd', 'SABnzbd', service_urls.sabnzbd, sabnzbd.status == 'ok',
sabnzbd.speed if sabnzbd.status == 'ok' else 'offline') }}
{{ svc_pill('tautulli', 'Tautulli', 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', service_urls.ombi, ombi.status == 'ok',
(ombi.total_pending ~ ' pending') if ombi.status == 'ok' else 'offline') }}
</nav>
<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>
<!-- ══ Right now — the time-sensitive row, above the fold ══ -->
<section class="section hero" aria-labelledby="h-now">
<h2 id="h-now">Right now</h2>
<div class="row cols-3">
<!-- 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" style="margin-top:0.9rem;">
<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>
<!-- Storage -->
<div class="card">
<div class="card-header">
<h3><a href="{{ service_urls.unraid }}" target="_blank" rel="noopener">Storage</a></h3>
{% if unraid.status == 'ok' and unraid.array_state %}
<span class="card-meta">{{ unraid.array_state | capitalize }}</span>
{% endif %}
</div>
<div class="card-body">
{% if unraid.status == 'ok' %}
{% set pct = unraid.array_used_pct %}
<div class="hero-figure {% if pct > 85 %}is-danger{% elif pct > 70 %}is-warn{% endif %}"
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>
{% if unraid.disk_count %}<span>{{ unraid.disk_count }} disk{{ 's' if unraid.disk_count != 1 }}</span>{% endif %}
</div>
{% if unraid.source == "local" %}
<p class="empty" style="margin-bottom:0;">Container disk usage ({{ unraid.mount }}). Set <code>UNRAID_API_KEY</code> for full array stats.</p>
{% 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">
<!-- 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 %}
<div class="req-item {{ 'is-hidden' if loop.index > 5 }}">
<span class="badge {{ 'movie' if req.type == 'Movie' else 'tv' }}">{{ req.type }}</span>
<div class="req-info">
<div class="req-title">{{ req.title }}</div>
<div class="req-sub">{{ req.user }} · {{ req.date_display }}</div>
</div>
<span class="badge {{ req.status | lower }}">{{ req.status }}</span>
</div>
{% endfor %}
</div>
{% if ombi.recent | length > 5 %}
<button class="btn btn-quiet show-more" 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
</span>
{% endif %}
</div>
<div class="card-body" id="prowlarr-body">
{% if prowlarr.status == 'ok' %}
<div class="indexer-grid" id="indexer-grid">
{% for idx in prowlarr.indexers %}
<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>
<span class="indexer-name" title="{{ idx.name }}">{{ idx.name }}</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" style="margin-top: var(--gap);">
<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>