All checks were successful
Deploy / deploy (push) Successful in 21s
The strip wrapped to two or three rows on narrow screens. It's now nowrap with flex: 1 1 0 pills, so all six share the row equally and shrink together instead of spilling onto a second line. Six pills can't hold full detail in 375px, so detail degrades inside the pill rather than the strip breaking: the stat drops at 1024px (the dot already carries up/down), then below 560px the name swaps to a short label — Son, Rad, Prow, SAB, Taut, Ombi. Without that swap the names ellipsised to ~23px, about two characters, which identifies nothing. The full name stays in the DOM at font-size: 0 so screen readers still announce it, and it's on the pill's title attribute. The dot and the pill itself never go, so no service is ever hidden — which was the point of the old wrapping rule. Verified one row with nothing clipped and no page overflow at 320, 375, 430, 560, 620, 700, 820, 1024, 1100 and 1400px. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
625 lines
29 KiB
HTML
625 lines
29 KiB
HTML
<!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 %}
|
||
</span>
|
||
{% endif %}
|
||
</div>
|
||
<div class="card-body">
|
||
{% if unraid.status == 'ok' %}
|
||
|
||
{% if unraid.disks or unraid.caches or unraid.parities %}
|
||
{# 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 %}
|
||
{% for dk in unraid.parities %}
|
||
{# Parity mirrors the array rather than holding a filesystem,
|
||
so it has no size or usage of its own to report. #}
|
||
<tr class="is-parity">
|
||
<th scope="row" title="{{ dk.device }}">{{ dk.name }}</th>
|
||
<td class="num"><span class="disk-null">—</span></td>
|
||
<td class="num"><span class="disk-null">—</span></td>
|
||
<td class="num"><span class="disk-null">—</span></td>
|
||
<td class="usage"><span class="disk-null">Parity · no filesystem</span></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" style="margin-bottom:0;">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>
|