Two fixes to the Storage card.
Unraid's GraphQL reports fsSize/fsFree in KB, not bytes — the schema is
explicit about it ("(KB) Total Size of the FS"). We were passing those
straight to fmt_bytes as if they were bytes, so every figure came out
1024x too small and picked the unit below the right one: a 13 TB disk
rendered as "13.0 GB" and a 64.8 TB array as "64.8 GB". _fs_bytes() now
does the conversion at the point of reading, so everything downstream
still deals in bytes. The shutil fallback already returns real bytes and
deliberately doesn't go through it.
Parity is gone from the table and is no longer requested at all. It
mirrors the array rather than holding a filesystem — the schema notes
fsSize is "Not present on Parity type drive" — so it could only ever
render as a name, three em-dashes and a temperature, which earned little
for a whole row. Worth knowing: its temperature is no longer surfaced
anywhere, so a hot parity drive won't show up here.
Verified against the raw KB shape the API actually returns: an array of
3.7/11.2/13.0 TB disks now totals 27.9 TB rather than 27.9 GB, the sub-TB
cache still correctly reads in GB, and the local fallback still reports
this machine's real 228.3 GB rather than a 1024x-inflated figure.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
613 lines
28 KiB
HTML
613 lines
28 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 %}
|
||
{# 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>
|