arr-summary/templates/dashboard.html
Barely Removable 909a0b568c
All checks were successful
Deploy / deploy (push) Successful in 26s
Show per-indexer grab share and rebuild the Indexers card as a list
The Indexers card sat beside Requests as a stretched, vertically centred
grid of pills — mostly dead space once the Ombi list was expanded, and it
said nothing about which indexer was actually doing the work.

- Fetch Prowlarr's indexerstats over a rolling 30-day window and merge
  per-indexer queries/grabs into the summary. Cached 10 min on success and
  never on failure, so a stats outage can't blank the column after it
  recovers or take the up/down list down with it.
- Rank indexers busiest-first by grab share (query share as fallback);
  queries mostly count enabled indexers, grabs count who delivered.
- One indexer per line, matched to the Ombi request rows: same padding,
  gap, radius and two-line body, plus a share bar scaled to the busiest
  indexer and an Online/Offline badge the 30s poll keeps current.
- Let that row size cards to their own content instead of stretching.

Bump APP_VERSION to 1.2.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 22:28:39 -07:00

526 lines
24 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 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 %}
<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
{%- 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" 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>