All checks were successful
Deploy / deploy (push) Successful in 2m6s
Settings lived inside .header-right, which is hidden below 600px, leaving no way to reach it on mobile. It's now a standalone 44x44px button next to the title. Dashboard error cards showed raw Python/urllib3 exception strings. Added a friendly_error() filter that classifies the failure (timeout, connection refused, DNS, auth, 4xx/5xx, TLS) into plain-English copy, with the raw string still available behind a details disclosure. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2046 lines
81 KiB
HTML
2046 lines
81 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
{% if theme == 'office' %}<title>Dunder Mifflin Media Centre</title>
|
||
{% elif theme == 'parks' %}<title>Pawnee Parks Media Centre</title>
|
||
{% elif theme == 'potter' %}<title>Hogwarts Media Vault</title>
|
||
{% elif theme == 'starwars' %}<title>Rebel Alliance Media Archive</title>
|
||
{% else %}<title>Greendale Media Centre</title>{% endif %}
|
||
{% if theme == 'office' %}<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📎</text></svg>">
|
||
{% elif theme == 'parks' %}<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌳</text></svg>">
|
||
{% elif theme == 'potter' %}<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚡</text></svg>">
|
||
{% elif theme == 'starwars' %}<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌌</text></svg>">
|
||
{% else %}<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}">{% endif %}
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
{% if theme == 'office' %}
|
||
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
|
||
{% elif theme == 'parks' %}
|
||
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700&family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
|
||
{% elif theme == 'potter' %}
|
||
<link href="https://fonts.googleapis.com/css2?family=MedievalSharp&family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
|
||
{% elif theme == 'starwars' %}
|
||
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@700&family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
|
||
{% else %}
|
||
<link href="https://fonts.googleapis.com/css2?family=Special+Elite&family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
|
||
{% endif %}
|
||
<style>
|
||
:root {
|
||
--gold: #e8b84b;
|
||
--gold-dk: #c49a2a;
|
||
--teal: #2a9d8f;
|
||
--teal-lt: #3dbdb0;
|
||
--navy: #1b2a3b;
|
||
--navy-lt: #243447;
|
||
--navy-mid:#1e3050;
|
||
--panel: #192433;
|
||
--border: #2e4460;
|
||
--text: #d8e4f0;
|
||
--muted: #7a93b0;
|
||
--red: #e84b4b;
|
||
--green: #27c98f;
|
||
--brand-font: 'Special Elite', cursive;
|
||
}
|
||
|
||
{% if theme == 'office' %}
|
||
/* ── The Office: Dunder Mifflin blues & greys ── */
|
||
:root {
|
||
--gold: #4a90d9;
|
||
--gold-dk: #357abd;
|
||
--teal: #6c757d;
|
||
--teal-lt: #8e9aa8;
|
||
--navy: #1c2128;
|
||
--navy-lt: #252d38;
|
||
--navy-mid:#1a2232;
|
||
--panel: #1e2530;
|
||
--border: #334155;
|
||
--text: #dce6f0;
|
||
--muted: #7a8fa6;
|
||
--red: #e05252;
|
||
--green: #3aad74;
|
||
--brand-font: 'Libre Baskerville', serif;
|
||
}
|
||
{% elif theme == 'parks' %}
|
||
/* ── Parks & Rec: warm greens & gold ── */
|
||
:root {
|
||
--gold: #5aad4e;
|
||
--gold-dk: #3d8c33;
|
||
--teal: #c8842a;
|
||
--teal-lt: #e09a3a;
|
||
--navy: #1a2213;
|
||
--navy-lt: #222e19;
|
||
--navy-mid:#1d2a16;
|
||
--panel: #1a2615;
|
||
--border: #2d4220;
|
||
--text: #d8edd0;
|
||
--muted: #7a9a72;
|
||
--red: #e84b4b;
|
||
--green: #5aad4e;
|
||
--brand-font: 'Playfair Display', serif;
|
||
}
|
||
{% elif theme == 'potter' %}
|
||
/* ── Harry Potter: Hogwarts deep purples & maroon ── */
|
||
:root {
|
||
--gold: #d4af37;
|
||
--gold-dk: #b8961e;
|
||
--teal: #7b2d8b;
|
||
--teal-lt: #9b3dab;
|
||
--navy: #1a1228;
|
||
--navy-lt: #231934;
|
||
--navy-mid:#1d1530;
|
||
--panel: #1a1228;
|
||
--border: #3d2a5a;
|
||
--text: #e8dff8;
|
||
--muted: #9080b8;
|
||
--red: #8b1a1a;
|
||
--green: #2d6e3a;
|
||
--brand-font: 'MedievalSharp', cursive;
|
||
}
|
||
{% elif theme == 'starwars' %}
|
||
/* ── Star Wars: deep space blacks & gold ── */
|
||
:root {
|
||
--gold: #ffe81f;
|
||
--gold-dk: #d4c000;
|
||
--teal: #4fc3f7;
|
||
--teal-lt: #81d4fa;
|
||
--navy: #080c14;
|
||
--navy-lt: #0f1620;
|
||
--navy-mid:#0a1018;
|
||
--panel: #0d1422;
|
||
--border: #1e2d42;
|
||
--text: #d0e8ff;
|
||
--muted: #607d9e;
|
||
--red: #e84b4b;
|
||
--green: #00e676;
|
||
--brand-font: 'Orbitron', sans-serif;
|
||
}
|
||
{% endif %}
|
||
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
|
||
body {
|
||
font-family: 'Inter', sans-serif;
|
||
background: var(--navy);
|
||
color: var(--text);
|
||
min-height: 100vh;
|
||
padding: 0;
|
||
}
|
||
|
||
/* ── Header ── */
|
||
header {
|
||
background: var(--navy-mid);
|
||
border-bottom: 3px solid var(--gold);
|
||
padding: 0.85rem 1.75rem;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
max-width: 100%;
|
||
}
|
||
.header-left { display: flex; align-items: center; gap: 1rem; }
|
||
.logo-badge {
|
||
background: var(--gold);
|
||
color: var(--navy);
|
||
font-family: var(--brand-font);
|
||
font-size: 1rem;
|
||
font-weight: 700;
|
||
padding: 0.4rem 0.9rem;
|
||
border-radius: 6px;
|
||
letter-spacing: 0.5px;
|
||
white-space: nowrap;
|
||
}
|
||
header h1 {
|
||
font-family: var(--brand-font);
|
||
font-size: 1.2rem;
|
||
color: var(--gold);
|
||
letter-spacing: 1px;
|
||
}
|
||
.header-sub {
|
||
font-size: 0.75rem;
|
||
color: var(--muted);
|
||
margin-top: 0.1rem;
|
||
font-style: italic;
|
||
}
|
||
.header-right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.7rem;
|
||
font-family: var(--brand-font);
|
||
font-size: 0.85rem;
|
||
color: var(--muted);
|
||
text-align: right;
|
||
}
|
||
.header-meta { text-align: right; }
|
||
.header-clock {
|
||
font-size: 1.1rem;
|
||
font-family: var(--brand-font);
|
||
color: var(--gold);
|
||
letter-spacing: 1px;
|
||
}
|
||
.header-room { font-size: 0.7rem; color: var(--muted); margin-top: 0.1rem; }
|
||
.settings-link {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 0.35rem;
|
||
min-width: 44px;
|
||
min-height: 44px;
|
||
padding: 0 0.7rem;
|
||
background: var(--navy);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
color: var(--muted);
|
||
text-decoration: none;
|
||
font-size: 0.78rem;
|
||
white-space: nowrap;
|
||
transition: color 0.2s, border-color 0.2s;
|
||
}
|
||
.settings-link:hover { color: var(--gold); border-color: var(--gold); }
|
||
|
||
/* ── Main layout ── */
|
||
main { padding: 1.5rem 2rem; max-width: 1280px; margin: 0 auto; }
|
||
|
||
/* ── Study group banner ── */
|
||
.study-banner {
|
||
background: var(--navy-mid);
|
||
border: 1px solid var(--border);
|
||
border-left: 4px solid var(--teal);
|
||
border-radius: 8px;
|
||
padding: 0.75rem 1.2rem;
|
||
margin-bottom: 1.75rem;
|
||
font-family: var(--brand-font);
|
||
font-size: 0.9rem;
|
||
color: var(--teal-lt);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.6rem;
|
||
}
|
||
|
||
/* ── 2-col grid ── */
|
||
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1.75rem; margin-bottom: 1.75rem; }
|
||
|
||
/* ── Cards ── */
|
||
.card {
|
||
background: var(--panel);
|
||
border: 1px solid var(--border);
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
}
|
||
.card-header {
|
||
padding: 0.65rem 1rem 0.6rem;
|
||
border-bottom: 1px solid var(--border);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
}
|
||
.card-header .card-icon { font-size: 1rem; }
|
||
.card-header h2 {
|
||
font-size: 0.88rem;
|
||
font-family: var(--brand-font);
|
||
letter-spacing: 0.5px;
|
||
flex: 1;
|
||
}
|
||
.card-header h2 a {
|
||
color: inherit;
|
||
text-decoration: none;
|
||
transition: opacity 0.15s;
|
||
}
|
||
.card-header h2 a:hover { opacity: 0.72; text-decoration: underline; }
|
||
.card.sonarr .card-header { background: linear-gradient(90deg, rgba(42,157,143,0.15), transparent); }
|
||
.card.sonarr .card-header h2 { color: var(--teal-lt); }
|
||
.card.radarr .card-header { background: linear-gradient(90deg, rgba(232,184,75,0.12), transparent); }
|
||
.card.radarr .card-header h2 { color: var(--gold); }
|
||
.card.sabnzbd .card-header { background: linear-gradient(90deg, rgba(100,149,237,0.12), transparent); }
|
||
.card.sabnzbd .card-header h2 { color: #7eb8f7; }
|
||
.card.tautulli .card-header { background: linear-gradient(90deg, rgba(180,100,220,0.12), transparent); }
|
||
.card.tautulli .card-header h2 { color: #c77dff; }
|
||
.card.tautulli .stat::before { background: #c77dff; }
|
||
.card-body { padding: 0.9rem 1rem; }
|
||
|
||
/* ── Stat grids ── */
|
||
.stats { display: grid; grid-template-columns: 1fr 1fr; gap: 0.65rem; margin-bottom: 1.2rem; }
|
||
.sab-stats { display: grid; grid-template-columns: repeat(6, 1fr); gap: 0.65rem; }
|
||
|
||
/* Tautulli */
|
||
.tautulli-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1.25rem; }
|
||
.tautulli-item {
|
||
display: flex; align-items: center; gap: 1rem;
|
||
background: var(--navy-lt);
|
||
border: 1px solid var(--border);
|
||
border-radius: 10px;
|
||
padding: 1rem 1.1rem;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
.tautulli-item::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0; left: 0;
|
||
width: 3px; height: 100%;
|
||
background: #c77dff;
|
||
}
|
||
.tautulli-thumb {
|
||
width: 54px; height: 80px;
|
||
border-radius: 6px;
|
||
object-fit: cover;
|
||
flex-shrink: 0;
|
||
background: var(--navy-mid);
|
||
border: 1px solid var(--border);
|
||
}
|
||
.tautulli-info { flex: 1; min-width: 0; }
|
||
.tautulli-kind {
|
||
font-size: 0.68rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.7px;
|
||
color: #c77dff;
|
||
font-weight: 700;
|
||
margin-bottom: 0.3rem;
|
||
}
|
||
.tautulli-title {
|
||
font-size: 1rem;
|
||
font-weight: 700;
|
||
color: var(--text);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.tautulli-plays {
|
||
margin-top: 0.4rem;
|
||
font-size: 0.8rem;
|
||
color: var(--muted);
|
||
}
|
||
.tautulli-plays strong { color: #c77dff; font-size: 1.1rem; }
|
||
.tautulli-streams {
|
||
display: flex; align-items: center; gap: 0.5rem;
|
||
margin-left: auto;
|
||
font-size: 0.8rem;
|
||
color: var(--muted);
|
||
}
|
||
|
||
/* Tautulli — top played & top users rows */
|
||
.tautulli-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 1.25rem; margin-top: 1.25rem; }
|
||
.top-list { display: flex; flex-direction: column; gap: 0.45rem; }
|
||
.top-list-item {
|
||
display: flex; align-items: center; gap: 0.7rem;
|
||
background: var(--navy-lt);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
padding: 0.6rem 0.85rem;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
.top-list-item::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0; left: 0;
|
||
width: 3px; height: 100%;
|
||
background: #c77dff;
|
||
}
|
||
.top-rank {
|
||
font-size: 0.75rem;
|
||
font-weight: 700;
|
||
color: var(--muted);
|
||
width: 1.2rem;
|
||
text-align: center;
|
||
flex-shrink: 0;
|
||
}
|
||
.top-avatar {
|
||
width: 32px; height: 32px;
|
||
border-radius: 50%;
|
||
object-fit: cover;
|
||
flex-shrink: 0;
|
||
background: var(--navy-mid);
|
||
border: 1px solid var(--border);
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 1rem;
|
||
}
|
||
.top-info { flex: 1; min-width: 0; }
|
||
.top-name {
|
||
font-size: 0.88rem;
|
||
font-weight: 700;
|
||
color: var(--text);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.top-sub {
|
||
font-size: 0.72rem;
|
||
color: var(--muted);
|
||
margin-top: 0.1rem;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.top-plays {
|
||
font-size: 0.82rem;
|
||
font-weight: 700;
|
||
color: #c77dff;
|
||
flex-shrink: 0;
|
||
}
|
||
.media-type-badge {
|
||
font-size: 0.65rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
padding: 0.1rem 0.4rem;
|
||
border-radius: 4px;
|
||
font-weight: 700;
|
||
flex-shrink: 0;
|
||
}
|
||
.media-type-badge.episode { background: rgba(42,157,143,0.2); color: var(--teal-lt); }
|
||
.media-type-badge.movie { background: rgba(232,184,75,0.15); color: var(--gold); }
|
||
.media-type-badge.other { background: rgba(122,147,176,0.15); color: var(--muted); }
|
||
|
||
/* ── Status lights (header + indexers) ── */
|
||
.status-light {
|
||
display: inline-block;
|
||
width: 8px; height: 8px;
|
||
border-radius: 50%;
|
||
flex-shrink: 0;
|
||
}
|
||
.status-light.up { background: var(--green); box-shadow: 0 0 5px rgba(39,201,143,0.7); }
|
||
.status-light.down { background: var(--red); box-shadow: 0 0 5px rgba(232,75,75,0.7); animation: blink 1.4s infinite; }
|
||
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0.3} }
|
||
|
||
/* ── Header service pills ── */
|
||
.header-services {
|
||
display: flex; align-items: center; gap: 0.6rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
.svc-pill {
|
||
display: inline-flex; align-items: center; gap: 0.5rem;
|
||
background: var(--navy);
|
||
border: 1px solid var(--border);
|
||
border-radius: 10px;
|
||
padding: 0.3rem 0.75rem 0.3rem 0.6rem;
|
||
transition: border-color 0.3s;
|
||
}
|
||
.svc-pill-text { display: flex; flex-direction: column; gap: 0; }
|
||
.svc-pill-name {
|
||
font-size: 0.7rem;
|
||
font-weight: 700;
|
||
color: var(--muted);
|
||
letter-spacing: 0.3px;
|
||
line-height: 1.2;
|
||
}
|
||
.svc-pill-stat {
|
||
font-size: 0.68rem;
|
||
color: var(--muted);
|
||
line-height: 1.2;
|
||
white-space: nowrap;
|
||
}
|
||
.svc-pill.up { border-color: rgba(39,201,143,0.4); }
|
||
.svc-pill.up .svc-pill-name { color: var(--text); }
|
||
.svc-pill.up .svc-pill-stat { color: var(--green); font-weight: 600; }
|
||
.svc-pill.down { border-color: rgba(232,75,75,0.4); }
|
||
.svc-pill.down .svc-pill-name { color: #ff8080; }
|
||
.svc-pill.down .svc-pill-stat { color: var(--red); }
|
||
|
||
/* ── Prowlarr card ── */
|
||
.card.prowlarr .card-header { background: linear-gradient(90deg, rgba(255,140,0,0.12), transparent); }
|
||
.card.prowlarr .card-header h2 { color: #ffaa44; }
|
||
.card.prowlarr .stat::before { background: #ffaa44; }
|
||
|
||
.indexer-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(5, 1fr);
|
||
gap: 0.5rem;
|
||
}
|
||
.indexer-item {
|
||
display: flex; align-items: center; gap: 0.55rem;
|
||
background: var(--navy-lt);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
padding: 0.55rem 0.75rem;
|
||
transition: border-color 0.2s;
|
||
}
|
||
.indexer-item.up { border-left: 3px solid var(--green); }
|
||
.indexer-item.down { border-left: 3px solid var(--red); }
|
||
.indexer-name {
|
||
font-size: 0.82rem;
|
||
font-weight: 600;
|
||
color: var(--text);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
flex: 1;
|
||
}
|
||
.prowlarr-summary {
|
||
display: flex; align-items: center; gap: 1rem;
|
||
margin-bottom: 0.85rem;
|
||
font-size: 0.8rem;
|
||
color: var(--muted);
|
||
}
|
||
.prowlarr-summary strong { color: var(--text); font-size: 1rem; }
|
||
|
||
/* ── Ombi card ── */
|
||
.card.ombi .card-header { background: linear-gradient(90deg, rgba(232,75,130,0.12), transparent); }
|
||
.card.ombi .card-header h2 { color: #ff6eb0; }
|
||
|
||
.ombi-meta {
|
||
margin-left: auto;
|
||
font-size: 0.78rem;
|
||
color: var(--muted);
|
||
display: flex; gap: 0.9rem;
|
||
}
|
||
.ombi-meta span strong { font-weight: 700; color: var(--text); }
|
||
|
||
.ombi-list {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 0.45rem;
|
||
}
|
||
@media (max-width: 960px) { .ombi-list { grid-template-columns: 1fr; } }
|
||
.ombi-item {
|
||
display: flex; align-items: center; gap: 0.8rem;
|
||
background: var(--navy-lt);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
padding: 0.6rem 0.9rem;
|
||
position: relative; overflow: hidden;
|
||
}
|
||
.ombi-item::before {
|
||
content: '';
|
||
position: absolute; top: 0; left: 0;
|
||
width: 3px; height: 100%;
|
||
background: #ff6eb0;
|
||
}
|
||
.ombi-type-badge {
|
||
font-size: 0.62rem; font-weight: 700;
|
||
text-transform: uppercase; letter-spacing: 0.5px;
|
||
padding: 0.15rem 0.45rem; border-radius: 4px;
|
||
flex-shrink: 0;
|
||
}
|
||
.ombi-type-badge.movie { background: rgba(232,184,75,0.15); color: var(--gold); }
|
||
.ombi-type-badge.tv { background: rgba(42,157,143,0.2); color: var(--teal-lt); }
|
||
.ombi-info { flex: 1; min-width: 0; }
|
||
.ombi-title {
|
||
font-size: 0.88rem; font-weight: 700; color: var(--text);
|
||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||
}
|
||
.ombi-sub {
|
||
font-size: 0.72rem; color: var(--muted); margin-top: 0.1rem;
|
||
}
|
||
.ombi-status {
|
||
font-size: 0.7rem; font-weight: 700;
|
||
padding: 0.15rem 0.5rem; border-radius: 20px; flex-shrink: 0;
|
||
text-transform: uppercase; letter-spacing: 0.4px;
|
||
}
|
||
.ombi-status.available { background: rgba(39,201,143,0.15); color: var(--green); }
|
||
.ombi-status.approved { background: rgba(126,184,247,0.15); color: #7eb8f7; }
|
||
.ombi-status.pending { background: rgba(232,184,75,0.15); color: var(--gold); }
|
||
.ombi-status.denied { background: rgba(232,75,75,0.15); color: var(--red); }
|
||
|
||
/* ── Tautulli top-3 list ── */
|
||
.tautulli-top3 { display: flex; flex-direction: column; gap: 0.35rem; margin-top: 0.6rem; }
|
||
.tautulli-top3-item {
|
||
display: flex; align-items: center; gap: 0.6rem;
|
||
padding: 0.35rem 0.5rem;
|
||
border-radius: 6px;
|
||
background: rgba(199,125,255,0.06);
|
||
border: 1px solid rgba(199,125,255,0.12);
|
||
}
|
||
.tautulli-top3-rank {
|
||
font-size: 0.7rem; font-weight: 700; color: #c77dff;
|
||
width: 1rem; text-align: center; flex-shrink: 0;
|
||
}
|
||
.tautulli-top3-title {
|
||
flex: 1; font-size: 0.82rem; font-weight: 600; color: var(--text);
|
||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||
}
|
||
.tautulli-top3-plays {
|
||
font-size: 0.75rem; color: var(--muted); flex-shrink: 0;
|
||
}
|
||
.tautulli-top3-plays strong { color: #c77dff; }
|
||
|
||
.stream-dot {
|
||
width: 8px; height: 8px;
|
||
border-radius: 50%;
|
||
background: var(--green);
|
||
animation: pulse 1.5s infinite;
|
||
}
|
||
@keyframes pulse {
|
||
0%, 100% { opacity: 1; transform: scale(1); }
|
||
50% { opacity: 0.5; transform: scale(0.8); }
|
||
}
|
||
|
||
.stat {
|
||
background: var(--navy-lt);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
padding: 0.85rem 1rem;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
.stat::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0; left: 0;
|
||
width: 3px; height: 100%;
|
||
}
|
||
.card.sonarr .stat::before { background: var(--teal); }
|
||
.card.radarr .stat::before { background: var(--gold); }
|
||
.card.sabnzbd .stat::before { background: #7eb8f7; }
|
||
|
||
.stat .label {
|
||
font-size: 0.7rem;
|
||
color: var(--muted);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.6px;
|
||
font-weight: 600;
|
||
}
|
||
.stat .value {
|
||
font-size: 1.35rem;
|
||
font-weight: 700;
|
||
margin-top: 0.2rem;
|
||
color: var(--text);
|
||
}
|
||
.stat .value.speed { color: #7eb8f7; }
|
||
.stat .value.active { color: var(--green); }
|
||
.stat .value.missing { color: var(--red); }
|
||
|
||
/* ── Status badge ── */
|
||
.sab-badge {
|
||
display: inline-flex; align-items: center; gap: 0.3rem;
|
||
padding: 0.2rem 0.65rem;
|
||
border-radius: 20px;
|
||
font-size: 0.7rem;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
.sab-badge::before { content: '●'; font-size: 0.6rem; }
|
||
.sab-badge.idle { background: rgba(122,147,176,0.15); color: var(--muted); }
|
||
.sab-badge.downloading { background: rgba(39,201,143,0.15); color: var(--green); }
|
||
.sab-badge.paused { background: rgba(232,184,75,0.15); color: var(--gold); }
|
||
|
||
/* ── Section heading ── */
|
||
.section-title {
|
||
font-family: var(--brand-font);
|
||
font-size: 0.82rem;
|
||
color: var(--muted);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.8px;
|
||
margin-bottom: 0.7rem;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.4rem;
|
||
}
|
||
.section-title::after {
|
||
content: '';
|
||
flex: 1;
|
||
height: 1px;
|
||
background: var(--border);
|
||
margin-left: 0.4rem;
|
||
}
|
||
|
||
/* ── RT list items ── */
|
||
.rt-list { display: flex; flex-direction: column; gap: 0.45rem; }
|
||
.rt-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
grid-template-rows: repeat(5, auto);
|
||
grid-auto-flow: column;
|
||
gap: 0.45rem;
|
||
}
|
||
@media (max-width: 600px) { .rt-grid { grid-template-columns: 1fr; grid-auto-flow: row; grid-template-rows: none; } }
|
||
|
||
/* ── RT tooltip ── */
|
||
.rt-item { position: relative; }
|
||
.rt-tooltip {
|
||
display: none;
|
||
position: absolute;
|
||
bottom: calc(100% + 8px);
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
width: 240px;
|
||
background: var(--navy-mid);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
padding: 0.7rem 0.85rem;
|
||
font-size: 0.78rem;
|
||
color: var(--text);
|
||
line-height: 1.5;
|
||
z-index: 100;
|
||
box-shadow: 0 6px 24px rgba(0,0,0,0.5);
|
||
pointer-events: none;
|
||
}
|
||
.rt-tooltip::after {
|
||
content: '';
|
||
position: absolute;
|
||
top: 100%;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
border: 6px solid transparent;
|
||
border-top-color: var(--border);
|
||
}
|
||
.rt-item:hover .rt-tooltip { display: block; }
|
||
.rt-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.7rem;
|
||
background: var(--navy-lt);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
padding: 0.55rem 0.75rem;
|
||
transition: border-color 0.15s;
|
||
}
|
||
.rt-item:hover { border-color: var(--teal); }
|
||
.card.radarr .rt-item:hover { border-color: var(--gold-dk); }
|
||
|
||
.rt-rank {
|
||
font-size: 0.7rem;
|
||
color: var(--muted);
|
||
font-weight: 700;
|
||
width: 1.1rem;
|
||
text-align: center;
|
||
flex-shrink: 0;
|
||
}
|
||
.rt-poster {
|
||
width: 34px; height: 50px;
|
||
border-radius: 4px;
|
||
object-fit: cover;
|
||
flex-shrink: 0;
|
||
background: var(--navy-mid);
|
||
border: 1px solid var(--border);
|
||
}
|
||
.rt-info { flex: 1; min-width: 0; }
|
||
.rt-title {
|
||
font-size: 0.85rem;
|
||
font-weight: 600;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
color: var(--text);
|
||
}
|
||
.rt-meta {
|
||
font-size: 0.72rem;
|
||
color: var(--muted);
|
||
margin-top: 0.18rem;
|
||
display: flex;
|
||
gap: 0.45rem;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
}
|
||
.score { display: inline-flex; align-items: center; gap: 0.15rem; font-weight: 600; }
|
||
.score.critics { color: #f4c430; }
|
||
.score.audience { color: #7eb8f7; }
|
||
.score.certified { color: var(--green); }
|
||
|
||
.rt-actions { flex-shrink: 0; }
|
||
|
||
/* ── Buttons ── */
|
||
.btn-add {
|
||
border: none;
|
||
border-radius: 6px;
|
||
padding: 0.3rem 0.65rem;
|
||
font-size: 0.78rem;
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
transition: opacity 0.15s, transform 0.1s;
|
||
font-family: 'Inter', sans-serif;
|
||
}
|
||
.btn-add:active { transform: scale(0.96); }
|
||
.btn-add.radarr-btn { background: var(--gold); color: var(--navy); }
|
||
.btn-add.sonarr-btn { background: var(--teal); color: #fff; }
|
||
.btn-add:hover:not(:disabled) { opacity: 0.85; }
|
||
.btn-add:disabled { background: #2e4460 !important; color: var(--muted) !important; cursor: default; }
|
||
.btn-add.in-arr { background: var(--green) !important; color: #fff !important; cursor: default; }
|
||
.btn-add.adding { background: #2e4460 !important; color: var(--muted) !important; cursor: wait; }
|
||
|
||
/* ── Search bar ── */
|
||
.search-bar {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
margin-bottom: 1rem;
|
||
}
|
||
.search-bar input {
|
||
flex: 1;
|
||
background: var(--navy-lt);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
padding: 0.55rem 0.85rem;
|
||
font-size: 0.88rem;
|
||
color: var(--text);
|
||
font-family: 'Inter', sans-serif;
|
||
outline: none;
|
||
transition: border-color 0.2s;
|
||
}
|
||
.search-bar input:focus { border-color: var(--gold); }
|
||
.search-bar input::placeholder { color: var(--muted); }
|
||
.card.sonarr .search-bar input:focus { border-color: var(--teal); }
|
||
.search-bar button {
|
||
background: var(--teal);
|
||
color: #fff;
|
||
border: none;
|
||
border-radius: 8px;
|
||
padding: 0.55rem 1rem;
|
||
font-size: 0.85rem;
|
||
font-weight: 700;
|
||
font-family: 'Inter', sans-serif;
|
||
cursor: pointer;
|
||
transition: opacity 0.15s;
|
||
white-space: nowrap;
|
||
}
|
||
.card.radarr .search-bar button { background: var(--gold); color: var(--navy); }
|
||
.search-bar button:hover:not(:disabled) { opacity: 0.85; }
|
||
.search-bar button:disabled { background: var(--border); color: var(--muted); cursor: default; }
|
||
.search-result {
|
||
display: none;
|
||
align-items: center;
|
||
gap: 0.7rem;
|
||
background: var(--navy-lt);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
padding: 0.6rem 0.85rem;
|
||
margin-bottom: 1rem;
|
||
}
|
||
.search-result.visible { display: flex; }
|
||
.search-result-info { flex: 1; min-width: 0; }
|
||
.search-result-title {
|
||
font-size: 0.9rem;
|
||
font-weight: 700;
|
||
color: var(--text);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.search-result-meta { font-size: 0.75rem; color: var(--muted); margin-top: 0.1rem; }
|
||
.search-msg { font-size: 0.8rem; color: var(--muted); margin-bottom: 1rem; padding: 0.4rem 0; }
|
||
.search-msg.error { color: var(--red); }
|
||
|
||
/* ── Error ── */
|
||
.error {
|
||
background: rgba(232,75,75,0.1);
|
||
border: 1px solid rgba(232,75,75,0.4);
|
||
border-radius: 8px;
|
||
padding: 0.85rem 1rem;
|
||
color: #ff8080;
|
||
font-size: 0.85rem;
|
||
margin-bottom: 1rem;
|
||
}
|
||
.error-detail { margin-top: 0.5rem; }
|
||
.error-detail summary {
|
||
cursor: pointer;
|
||
font-size: 0.75rem;
|
||
color: var(--muted);
|
||
list-style: none;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.3rem;
|
||
padding: 0.25rem 0;
|
||
}
|
||
.error-detail summary::-webkit-details-marker { display: none; }
|
||
.error-detail summary::before { content: "▸"; font-size: 0.7rem; }
|
||
.error-detail[open] summary::before { content: "▾"; }
|
||
.error-detail summary:hover { color: var(--text); }
|
||
.error-detail pre {
|
||
margin: 0.4rem 0 0;
|
||
padding: 0.6rem 0.7rem;
|
||
background: rgba(0,0,0,0.25);
|
||
border-radius: 6px;
|
||
font-size: 0.7rem;
|
||
line-height: 1.45;
|
||
color: var(--muted);
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
overflow-x: auto;
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||
}
|
||
.rt-error { font-size: 0.8rem; color: #ff8080; padding: 0.4rem 0; }
|
||
|
||
/* ── Footer quote ── */
|
||
footer {
|
||
text-align: center;
|
||
padding: 1.5rem 2rem;
|
||
font-family: var(--brand-font);
|
||
font-size: 0.8rem;
|
||
color: var(--muted);
|
||
border-top: 1px solid var(--border);
|
||
margin-top: 0.5rem;
|
||
}
|
||
|
||
/* ── Responsive ── */
|
||
@media (max-width: 960px) {
|
||
.grid { grid-template-columns: 1fr; }
|
||
.sab-stats { grid-template-columns: repeat(3, 1fr); }
|
||
.tautulli-grid { grid-template-columns: 1fr; }
|
||
.tautulli-lower { grid-template-columns: 1fr; }
|
||
.indexer-grid { grid-template-columns: repeat(3, 1fr); }
|
||
.ombi-list { grid-template-columns: 1fr; }
|
||
}
|
||
@media (max-width: 600px) {
|
||
header {
|
||
padding: 0.9rem 1rem;
|
||
flex-wrap: wrap;
|
||
gap: 0.6rem;
|
||
}
|
||
header h1 { font-size: 1.05rem; }
|
||
.header-sub { display: none; }
|
||
/* Title and Settings share the first row; the pill strip wraps below */
|
||
.header-left { order: 1; min-width: 0; flex: 1 1 auto; gap: 0.6rem; }
|
||
.header-left h1 { overflow-wrap: anywhere; }
|
||
.header-right { order: 2; margin-left: auto; flex: 0 0 auto; }
|
||
.header-services {
|
||
order: 3;
|
||
width: 100%;
|
||
overflow-x: auto;
|
||
flex-wrap: nowrap;
|
||
padding-bottom: 0.2rem;
|
||
-webkit-overflow-scrolling: touch;
|
||
scrollbar-width: none;
|
||
}
|
||
.header-services::-webkit-scrollbar { display: none; }
|
||
main { padding: 1rem; }
|
||
.study-banner { font-size: 0.8rem; padding: 0.6rem 0.9rem; }
|
||
.card-body { padding: 0.85rem 0.9rem; }
|
||
.card-header { padding: 0.75rem 0.9rem; }
|
||
.stats { grid-template-columns: 1fr 1fr; }
|
||
.sab-stats { grid-template-columns: repeat(3, 1fr); }
|
||
.rt-grid { grid-template-columns: 1fr; }
|
||
.indexer-grid { grid-template-columns: repeat(2, 1fr); }
|
||
.tautulli-grid { grid-template-columns: 1fr; }
|
||
.tautulli-lower { grid-template-columns: 1fr; }
|
||
.ombi-list { grid-template-columns: 1fr; }
|
||
.search-bar { flex-direction: column; }
|
||
.search-bar button { width: 100%; }
|
||
footer { padding: 1rem; font-size: 0.72rem; }
|
||
}
|
||
@media (max-width: 400px) {
|
||
.sab-stats { grid-template-columns: 1fr 1fr; }
|
||
.indexer-grid { grid-template-columns: 1fr 1fr; }
|
||
.stat .value { font-size: 1.3rem; }
|
||
.logo-badge { display: none; }
|
||
}
|
||
|
||
/* ── Unraid card ── */
|
||
.card.unraid .card-header { background: linear-gradient(90deg, rgba(255,85,85,0.12), transparent); }
|
||
.card.unraid .card-header h2 { color: #ff6b6b; }
|
||
.card.unraid .stat::before { background: #ff6b6b; }
|
||
.unraid-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.65rem; }
|
||
.disk-bar-wrap {
|
||
margin-top: 0.85rem;
|
||
background: var(--navy-lt);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
padding: 0.75rem 1rem;
|
||
}
|
||
.disk-bar-label {
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
font-size: 0.72rem; color: var(--muted); margin-bottom: 0.45rem;
|
||
}
|
||
.disk-bar-label strong { color: var(--text); }
|
||
.disk-bar-track {
|
||
height: 8px; background: var(--border); border-radius: 4px; overflow: hidden;
|
||
}
|
||
.disk-bar-fill {
|
||
height: 100%; border-radius: 4px;
|
||
background: linear-gradient(90deg, #ff6b6b, #ff4444);
|
||
transition: width 0.5s ease;
|
||
}
|
||
.disk-bar-fill.ok { background: linear-gradient(90deg, var(--teal), var(--teal-lt)); }
|
||
.disk-bar-fill.warn { background: linear-gradient(90deg, var(--gold), #ffaa00); }
|
||
|
||
@media (max-width: 960px) {
|
||
.unraid-stats { grid-template-columns: repeat(3, 1fr); }
|
||
}
|
||
@media (max-width: 600px) {
|
||
.unraid-stats { grid-template-columns: 1fr 1fr; }
|
||
}
|
||
|
||
/* ── RT item fade-out animation ── */
|
||
.rt-item.removing {
|
||
opacity: 0;
|
||
transform: scale(0.95);
|
||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* ── Mobile: ensure nothing overflows ── */
|
||
@media (max-width: 600px) {
|
||
.ombi-meta { display: none; }
|
||
.tautulli-streams { margin-left: auto; }
|
||
.rt-title { font-size: 0.8rem; }
|
||
.rt-meta { font-size: 0.68rem; }
|
||
.svc-pill-stat { display: none; }
|
||
/* Keep the Settings link reachable on mobile — only the clock/room drop out */
|
||
.header-meta { display: none; }
|
||
.settings-label { display: none; }
|
||
.settings-link { padding: 0; }
|
||
.stat .value { font-size: 1.4rem; }
|
||
.search-bar input { font-size: 16px; } /* prevent iOS zoom */
|
||
}
|
||
</style>
|
||
</head>
|
||
{#- Renders a degraded service as human copy, with the raw exception tucked
|
||
behind a disclosure for debugging. -#}
|
||
{% 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 }}</pre>
|
||
</details>
|
||
{% endif %}
|
||
</div>
|
||
{% endmacro %}
|
||
<body>
|
||
|
||
<header>
|
||
<div class="header-left">
|
||
{% if theme == 'office' %}
|
||
<div style="width:48px; height:48px; display:flex; align-items:center; justify-content:center; font-size:2.2rem; filter:drop-shadow(0 1px 4px rgba(0,0,0,0.5));">📎</div>
|
||
{% elif theme == 'parks' %}
|
||
<div style="width:48px; height:48px; display:flex; align-items:center; justify-content:center; font-size:2.2rem; filter:drop-shadow(0 1px 4px rgba(0,0,0,0.5));">🌳</div>
|
||
{% elif theme == 'potter' %}
|
||
<div style="width:48px; height:48px; display:flex; align-items:center; justify-content:center; font-size:2.2rem; filter:drop-shadow(0 1px 4px rgba(0,0,0,0.5));">⚡</div>
|
||
{% elif theme == 'starwars' %}
|
||
<div style="width:48px; height:48px; display:flex; align-items:center; justify-content:center; filter:drop-shadow(0 0 8px rgba(255,232,31,0.6));">
|
||
<svg viewBox="0 0 100 100" width="46" height="46" xmlns="http://www.w3.org/2000/svg">
|
||
<circle cx="50" cy="50" r="48" fill="#1a2a3a" stroke="#ffe81f" stroke-width="2.5"/>
|
||
<line x1="2" y1="50" x2="98" y2="50" stroke="#ffe81f" stroke-width="2"/>
|
||
<circle cx="28" cy="34" r="13" fill="#0d1822" stroke="#ffe81f" stroke-width="1.8"/>
|
||
<circle cx="28" cy="34" r="7" fill="none" stroke="#ffe81f" stroke-width="1.2"/>
|
||
<circle cx="28" cy="34" r="3" fill="#ffe81f" opacity="0.9"/>
|
||
<line x1="2" y1="22" x2="98" y2="22" stroke="#ffe81f" stroke-width="0.7" opacity="0.35"/>
|
||
<line x1="2" y1="72" x2="98" y2="72" stroke="#ffe81f" stroke-width="0.7" opacity="0.35"/>
|
||
<line x1="55" y1="2" x2="55" y2="48" stroke="#ffe81f" stroke-width="0.7" opacity="0.35"/>
|
||
<line x1="75" y1="2" x2="75" y2="48" stroke="#ffe81f" stroke-width="0.7" opacity="0.35"/>
|
||
</svg>
|
||
</div>
|
||
{% else %}
|
||
<img src="{{ url_for('static', filename='logo.png') }}" alt="Greendale" style="width:48px; height:48px; object-fit:contain; filter:drop-shadow(0 1px 4px rgba(0,0,0,0.5));">
|
||
{% endif %}
|
||
<div>
|
||
{% if theme == 'office' %}
|
||
<h1>Dunder Mifflin Media Centre</h1>
|
||
<div class="header-sub">That's what she watched.</div>
|
||
{% elif theme == 'parks' %}
|
||
<h1>Pawnee Parks Media Centre</h1>
|
||
<div class="header-sub">The greatest city in the world.</div>
|
||
{% elif theme == 'potter' %}
|
||
<h1>Hogwarts Media Vault</h1>
|
||
<div class="header-sub">Mischief managed.</div>
|
||
{% elif theme == 'starwars' %}
|
||
<h1>Rebel Alliance Media Archive</h1>
|
||
<div class="header-sub">May the downloads be with you.</div>
|
||
{% else %}
|
||
<h1>Greendale Media Centre</h1>
|
||
<div class="header-sub">Human beings welcome. Robots too.</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
<div class="header-services" id="header-services">
|
||
|
||
<!-- Sonarr -->
|
||
{% set up = sonarr.status == "ok" %}
|
||
<span class="svc-pill {{ 'up' if up else 'down' }}" id="svc-sonarr">
|
||
<span class="status-light {{ 'up' if up else 'down' }}" id="svc-light-sonarr"></span>
|
||
<span class="svc-pill-text">
|
||
<span class="svc-pill-name">Sonarr</span>
|
||
<span class="svc-pill-stat" id="svc-stat-sonarr">
|
||
{%- if up %}{{ sonarr.total_series }} series{% else %}offline{% endif -%}
|
||
</span>
|
||
</span>
|
||
</span>
|
||
|
||
<!-- Radarr -->
|
||
{% set up = radarr.status == "ok" %}
|
||
<span class="svc-pill {{ 'up' if up else 'down' }}" id="svc-radarr">
|
||
<span class="status-light {{ 'up' if up else 'down' }}" id="svc-light-radarr"></span>
|
||
<span class="svc-pill-text">
|
||
<span class="svc-pill-name">Radarr</span>
|
||
<span class="svc-pill-stat" id="svc-stat-radarr">
|
||
{%- if up %}{{ radarr.total_movies }} movies{% else %}offline{% endif -%}
|
||
</span>
|
||
</span>
|
||
</span>
|
||
|
||
<!-- Prowlarr -->
|
||
{% set up = prowlarr.status == "ok" %}
|
||
<span class="svc-pill {{ 'up' if up else 'down' }}" id="svc-prowlarr">
|
||
<span class="status-light {{ 'up' if up else 'down' }}" id="svc-light-prowlarr"></span>
|
||
<span class="svc-pill-text">
|
||
<span class="svc-pill-name">Prowlarr</span>
|
||
<span class="svc-pill-stat" id="svc-stat-prowlarr">
|
||
{%- if up %}{{ prowlarr.up_count }}/{{ prowlarr.total }} online{% else %}offline{% endif -%}
|
||
</span>
|
||
</span>
|
||
</span>
|
||
|
||
<!-- Ombi -->
|
||
{% set up = ombi.status == "ok" %}
|
||
<span class="svc-pill {{ 'up' if up else 'down' }}" id="svc-ombi">
|
||
<span class="status-light {{ 'up' if up else 'down' }}" id="svc-light-ombi"></span>
|
||
<span class="svc-pill-text">
|
||
<span class="svc-pill-name">Ombi</span>
|
||
<span class="svc-pill-stat" id="svc-stat-ombi">
|
||
{%- if up %}{{ ombi.total_pending }} pending{% else %}offline{% endif -%}
|
||
</span>
|
||
</span>
|
||
</span>
|
||
|
||
<!-- SABnzbd -->
|
||
{% set up = sabnzbd.status == "ok" %}
|
||
<span class="svc-pill {{ 'up' if up else 'down' }}" id="svc-sabnzbd"
|
||
style="position:relative; cursor:default;"
|
||
onmouseenter="document.getElementById('sab-popover').style.display='block'"
|
||
onmouseleave="document.getElementById('sab-popover').style.display='none'">
|
||
<span class="status-light {{ 'up' if up else 'down' }}" id="svc-light-sabnzbd"></span>
|
||
<span class="svc-pill-text">
|
||
<span class="svc-pill-name">SABnzbd</span>
|
||
<span class="svc-pill-stat" id="svc-stat-sabnzbd">
|
||
{%- if up %}{{ sabnzbd.speed }}{% else %}offline{% endif -%}
|
||
</span>
|
||
</span>
|
||
<div id="sab-popover" style="display:none; position:absolute; top:calc(100% + 8px); right:0; z-index:200;
|
||
background:var(--navy-mid); border:1px solid var(--border); border-radius:10px;
|
||
padding:0.6rem 0.85rem; min-width:160px; box-shadow:0 8px 24px rgba(0,0,0,0.5);
|
||
font-size:0.72rem; color:var(--muted); white-space:nowrap;">
|
||
🔄 Updated: <span id="sab-popover-time">—</span>
|
||
</div>
|
||
</span>
|
||
|
||
<!-- Tautulli -->
|
||
{% set up = tautulli.status == "ok" %}
|
||
<span class="svc-pill {{ 'up' if up else 'down' }}" id="svc-tautulli" style="position:relative; cursor:default;"
|
||
onmouseenter="document.getElementById('tautulli-popover').style.display='block'"
|
||
onmouseleave="document.getElementById('tautulli-popover').style.display='none'">
|
||
<span class="status-light {{ 'up' if up else 'down' }}" id="svc-light-tautulli"></span>
|
||
<span class="svc-pill-text">
|
||
<span class="svc-pill-name">Tautulli</span>
|
||
<span class="svc-pill-stat" id="svc-stat-tautulli">
|
||
{%- if up -%}
|
||
{%- if tautulli.active_streams > 0 -%}
|
||
{{ tautulli.active_streams }} streaming
|
||
{%- else -%}
|
||
0 streams
|
||
{%- endif -%}
|
||
{%- else -%}offline{%- endif -%}
|
||
</span>
|
||
</span>
|
||
<!-- Stream popover -->
|
||
<div id="tautulli-popover" style="display:none; position:absolute; top:calc(100% + 8px); right:0; z-index:200;
|
||
background:var(--navy-mid); border:1px solid var(--border); border-radius:10px;
|
||
padding:0.6rem 0.85rem; min-width:220px; max-width:300px; box-shadow:0 8px 24px rgba(0,0,0,0.5);">
|
||
{% if up and tautulli.sessions %}
|
||
{% for s in tautulli.sessions %}
|
||
<div style="display:flex; align-items:flex-start; gap:0.5rem; {% if not loop.first %}margin-top:0.55rem; padding-top:0.55rem; border-top:1px solid var(--border);{% endif %}">
|
||
<span style="font-size:0.9rem; flex-shrink:0;">{{ '🎬' if s.media_type == 'movie' else '📺' }}</span>
|
||
<div style="min-width:0;">
|
||
<div style="font-size:0.78rem; font-weight:700; color:var(--text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">{{ s.title }}</div>
|
||
<div style="font-size:0.68rem; color:#c77dff; margin-top:0.1rem;">{{ s.user }}</div>
|
||
</div>
|
||
</div>
|
||
{% endfor %}
|
||
{% else %}
|
||
<div style="font-size:0.78rem; color:var(--muted);">No active streams</div>
|
||
{% endif %}
|
||
</div>
|
||
</span>
|
||
|
||
</div>
|
||
<div class="header-right">
|
||
<div class="header-meta">
|
||
<div id="header-clock" class="header-clock">—</div>
|
||
<div class="header-room">
|
||
{% if theme == 'office' %}Dunder Mifflin HQ
|
||
{% elif theme == 'parks' %}Pawnee City Hall
|
||
{% elif theme == 'potter' %}Hogwarts Great Hall
|
||
{% elif theme == 'starwars' %}Death Star Control Room
|
||
{% else %}Study Room F{% endif %}
|
||
</div>
|
||
</div>
|
||
<a href="{{ url_for('settings_page') }}" class="settings-link" aria-label="Settings">
|
||
<span aria-hidden="true">⚙</span><span class="settings-label">Settings</span>
|
||
</a>
|
||
</div>
|
||
</header>
|
||
|
||
<main>
|
||
|
||
<div class="study-banner">
|
||
{% if theme == 'office' %}
|
||
📋 Monday morning meeting: checking downloads, missing episodes, and who took the last of the coffee.
|
||
{% elif theme == 'parks' %}
|
||
📋 Today's agenda: checking what's missing, what's downloading, and making Pawnee great again.
|
||
{% elif theme == 'potter' %}
|
||
📋 Today's scroll: checking what spells are missing, what's being conjured, and what deserves a watch.
|
||
{% elif theme == 'starwars' %}
|
||
📋 Rebel briefing: checking what's missing, what's downloading, and what the Force recommends watching.
|
||
{% else %}
|
||
📋 Today's agenda: checking what's missing, what's downloading, and what deserves to be watched.
|
||
{% endif %}
|
||
</div>
|
||
|
||
<!-- ── Unraid Stats ── -->
|
||
<div class="card unraid" style="margin-bottom: 1.75rem;">
|
||
<div class="card-header">
|
||
<span class="card-icon">🖥️</span>
|
||
<h2><a href="{{ service_urls.unraid }}" target="_blank" rel="noopener">Unraid</a></h2>
|
||
{% if unraid.status == "ok" %}
|
||
<span style="margin-left:auto; font-size:0.75rem; color:var(--muted);">
|
||
{% if unraid.array_state %}<span style="color:var(--green); font-weight:700;">{{ unraid.array_state | capitalize }}</span>{% endif %}
|
||
{% if unraid.disk_count %} · {{ unraid.disk_count }} disk{{ 's' if unraid.disk_count != 1 }}{% endif %}
|
||
{% if unraid.os_version %} · v{{ unraid.os_version }}{% endif %}
|
||
</span>
|
||
{% endif %}
|
||
</div>
|
||
<div class="card-body">
|
||
{% if unraid.status == "ok" %}
|
||
<div class="unraid-stats">
|
||
<div class="stat">
|
||
<div class="label">Array Total</div>
|
||
<div class="value" style="font-size:1.3rem;">{{ unraid.array_total }}</div>
|
||
</div>
|
||
<div class="stat">
|
||
<div class="label">Array Used</div>
|
||
<div class="value" style="font-size:1.3rem; color:{% if unraid.array_used_pct > 85 %}var(--red){% elif unraid.array_used_pct > 70 %}var(--gold){% else %}var(--text){% endif %};">{{ unraid.array_used }}</div>
|
||
</div>
|
||
<div class="stat">
|
||
<div class="label">Array Free</div>
|
||
<div class="value" style="font-size:1.3rem; color:var(--green);">{{ unraid.array_free }}</div>
|
||
</div>
|
||
</div>
|
||
<!-- Disk usage bar -->
|
||
<div class="disk-bar-wrap">
|
||
<div class="disk-bar-label">
|
||
<span>Array Usage</span>
|
||
<strong>{{ unraid.array_used_pct }}%</strong>
|
||
</div>
|
||
<div class="disk-bar-track">
|
||
<div class="disk-bar-fill {% if unraid.array_used_pct <= 70 %}ok{% elif unraid.array_used_pct <= 85 %}warn{% endif %}"
|
||
style="width: {{ unraid.array_used_pct }}%;"></div>
|
||
</div>
|
||
</div>
|
||
{% if unraid.source == "local" %}
|
||
<div style="margin-top:0.5rem; font-size:0.7rem; color:var(--muted);">
|
||
ⓘ Showing container disk usage ({{ unraid.mount }}). Add <code>UNRAID_API_KEY</code> for full Unraid stats.
|
||
</div>
|
||
{% endif %}
|
||
{% else %}
|
||
{{ service_error("Unraid", unraid.error) }}
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Sonarr ── -->
|
||
<div class="card sonarr" style="margin-bottom: 1.75rem;">
|
||
<div class="card-header">
|
||
<span class="card-icon">📺</span>
|
||
<h2><a href="{{ service_urls.sonarr }}" target="_blank" rel="noopener">Sonarr</a></h2>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="search-bar">
|
||
<input type="text" id="sonarr-search-input" placeholder="Search and add a TV show…" onkeydown="if(event.key==='Enter') searchSonarr()">
|
||
<button 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 sonarr-btn" id="sonarr-add-btn" onclick="confirmAddSonarr()">+ Add</button>
|
||
</div>
|
||
<div id="sonarr-search-msg" class="search-msg" style="display:none;"></div>
|
||
{% if sonarr.status == "ok" %}
|
||
<div class="stats">
|
||
<div class="stat">
|
||
<div class="label">Total Series</div>
|
||
<div class="value">{{ sonarr.total_series }}</div>
|
||
</div>
|
||
<div class="stat">
|
||
<div class="label">Missing Episodes</div>
|
||
<div class="value missing">{{ sonarr.missing_episodes }}</div>
|
||
</div>
|
||
</div>
|
||
{% else %}
|
||
{{ service_error("Sonarr", sonarr.error) }}
|
||
{% endif %}
|
||
|
||
<div class="section-title">🍅 Popular on RT</div>
|
||
{% if rt_tv and rt_tv[0] is defined and rt_tv[0].error is defined %}
|
||
<div class="rt-error">Failed to load TV data: {{ rt_tv[0].error }}</div>
|
||
{% elif rt_tv %}
|
||
<div class="rt-grid">
|
||
{% for show in rt_tv %}
|
||
<div class="rt-item" data-rt-title="{{ show.title }}">
|
||
<div class="rt-tooltip" style="{{ '' if show.description else 'display:none;' }}">
|
||
<strong>{{ show.title }}</strong><br>
|
||
<span class="rt-desc">{{ show.description[:180] if show.description else '' }}{% if show.description and show.description|length > 180 %}…{% endif %}</span>
|
||
</div>
|
||
<div class="rt-rank">{{ loop.index }}</div>
|
||
{% if show.poster %}<img class="rt-poster" src="{{ show.poster }}" alt="" loading="lazy">
|
||
{% else %}<div class="rt-poster"></div>{% endif %}
|
||
<div class="rt-info">
|
||
<div class="rt-title">{{ show.title }}{% if show.year %} <span style="color:var(--muted);font-weight:400">({{ 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 }}%</span>{% endif %}
|
||
{% if show.audience_score %}<span class="score audience">🍿 {{ show.audience_score }}</span>{% endif %}
|
||
</div>
|
||
</div>
|
||
<div class="rt-actions">
|
||
<button class="btn-add sonarr-btn" data-title="{{ show.title }}" onclick="addToSonarr(this)">+ Add</button>
|
||
</div>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
{% else %}
|
||
<div class="rt-error">No TV data available.</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Radarr ── -->
|
||
<div class="card radarr" style="margin-bottom: 1.75rem;">
|
||
<div class="card-header">
|
||
<span class="card-icon">🎬</span>
|
||
<h2><a href="{{ service_urls.radarr }}" target="_blank" rel="noopener">Radarr</a></h2>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="search-bar">
|
||
<input type="text" id="radarr-search-input" placeholder="Search and add a movie…" onkeydown="if(event.key==='Enter') searchRadarr()">
|
||
<button 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 radarr-btn" id="radarr-add-btn" onclick="confirmAddRadarr()">+ Add</button>
|
||
</div>
|
||
<div id="radarr-search-msg" class="search-msg" style="display:none;"></div>
|
||
{% if radarr.status == "ok" %}
|
||
<div class="stats">
|
||
<div class="stat">
|
||
<div class="label">Total Movies</div>
|
||
<div class="value">{{ radarr.total_movies }}</div>
|
||
</div>
|
||
<div class="stat">
|
||
<div class="label">Missing</div>
|
||
<div class="value missing">{{ radarr.missing_movies }}</div>
|
||
</div>
|
||
</div>
|
||
{% else %}
|
||
{{ service_error("Radarr", radarr.error) }}
|
||
{% endif %}
|
||
|
||
<div class="section-title">🍅 Top Box Office on RT</div>
|
||
{% if rt_movies and rt_movies[0] is defined and rt_movies[0].error is defined %}
|
||
<div class="rt-error">Failed to load movie data: {{ rt_movies[0].error }}</div>
|
||
{% elif rt_movies %}
|
||
<div class="rt-grid">
|
||
{% for movie in rt_movies %}
|
||
<div class="rt-item" data-rt-title="{{ movie.title }}">
|
||
<div class="rt-tooltip" style="{{ '' if movie.description else 'display:none;' }}">
|
||
<strong>{{ movie.title }}</strong><br>
|
||
<span class="rt-desc">{{ movie.description[:180] if movie.description else '' }}{% if movie.description and movie.description|length > 180 %}…{% endif %}</span>
|
||
</div>
|
||
<div class="rt-rank">{{ loop.index }}</div>
|
||
{% if movie.poster %}<img class="rt-poster" src="{{ movie.poster }}" alt="" loading="lazy">
|
||
{% else %}<div class="rt-poster"></div>{% endif %}
|
||
<div class="rt-info">
|
||
<div class="rt-title">{{ movie.title }}{% if movie.year %} <span style="color:var(--muted);font-weight:400">({{ 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 }}%</span>{% endif %}
|
||
{% if movie.audience_score %}<span class="score audience">🍿 {{ movie.audience_score }}</span>{% endif %}
|
||
</div>
|
||
</div>
|
||
<div class="rt-actions">
|
||
<button class="btn-add radarr-btn" data-title="{{ movie.title }}" onclick="addToRadarr(this)">+ Add</button>
|
||
</div>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
{% else %}
|
||
<div class="rt-error">No movie data available.</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Prowlarr full-width ── -->
|
||
<div class="card prowlarr" id="prowlarr-card" style="margin-bottom: 1.75rem;">
|
||
<div class="card-header">
|
||
<span class="card-icon">🔍</span>
|
||
<h2><a href="{{ service_urls.prowlarr }}" target="_blank" rel="noopener">Prowlarr</a> — Indexers</h2>
|
||
{% if prowlarr.status == "ok" %}
|
||
<span style="margin-left:auto; font-size:0.78rem; color:var(--muted);">
|
||
<span id="prowlarr-up-count" style="color:var(--green); font-weight:700;">{{ 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>
|
||
|
||
<!-- ── SABnzbd full-width ── -->
|
||
<div class="card sabnzbd">
|
||
<div class="card-header">
|
||
<span class="card-icon">⬇️</span>
|
||
<h2><a href="{{ service_urls.sabnzbd }}" target="_blank" rel="noopener">SABnzbd</a></h2>
|
||
{% if sabnzbd.status == "ok" %}
|
||
{% set s = sabnzbd.sab_status | lower %}
|
||
<span id="sab-badge" class="sab-badge {{ 'downloading' if s == 'downloading' else ('paused' if s == 'paused' else 'idle') }}">
|
||
{{ sabnzbd.sab_status }}
|
||
</span>
|
||
{% endif %}
|
||
</div>
|
||
<div class="card-body">
|
||
{% if sabnzbd.status == "ok" %}
|
||
<div class="sab-stats">
|
||
<div class="stat">
|
||
<div class="label">Current Speed</div>
|
||
<div class="value speed" id="sab-speed">{{ sabnzbd.speed }}</div>
|
||
</div>
|
||
<div class="stat">
|
||
<div class="label">Avg Speed (7d)</div>
|
||
<div class="value">{{ sabnzbd.avg_speed }}</div>
|
||
</div>
|
||
<div class="stat">
|
||
<div class="label">Queue Items</div>
|
||
<div class="value" id="sab-queue">{{ sabnzbd.queue_count }}</div>
|
||
</div>
|
||
<div class="stat">
|
||
<div class="label">Remaining</div>
|
||
<div class="value" id="sab-remaining">{{ sabnzbd.sizeleft }}</div>
|
||
</div>
|
||
<div class="stat">
|
||
<div class="label">Downloads (7d)</div>
|
||
<div class="value active">{{ sabnzbd.completed_7d }}</div>
|
||
</div>
|
||
<div class="stat">
|
||
<div class="label">Downloaded (7d)</div>
|
||
<div class="value">{{ sabnzbd.week_size }}</div>
|
||
</div>
|
||
</div>
|
||
<!-- Time left row when actively downloading -->
|
||
<div id="sab-timeleft-row" style="margin-top:0.6rem; font-size:0.8rem; color:var(--muted); display:none;">
|
||
⏱ ETA: <span id="sab-timeleft"></span>
|
||
</div>
|
||
{% else %}
|
||
{{ service_error("SABnzbd", sabnzbd.error) }}
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Ombi ── -->
|
||
<div class="card ombi" style="margin-top: 1.75rem;">
|
||
<div class="card-header">
|
||
<span class="card-icon">🎟️</span>
|
||
<h2><a href="{{ service_urls.ombi }}" target="_blank" rel="noopener">Ombi</a> — Requests</h2>
|
||
{% if ombi.status == "ok" %}
|
||
<div class="ombi-meta">
|
||
<span><strong>{{ ombi.total_requests }}</strong> total</span>
|
||
<span><strong>{{ ombi.total_pending }}</strong> pending</span>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
<div class="card-body">
|
||
{% if ombi.status == "ok" %}
|
||
{% if ombi.recent %}
|
||
<div class="ombi-list">
|
||
{% for req in ombi.recent %}
|
||
<div class="ombi-item">
|
||
<span class="ombi-type-badge {{ 'movie' if req.type == 'Movie' else 'tv' }}">{{ req.type }}</span>
|
||
<div class="ombi-info">
|
||
<div class="ombi-title">{{ req.title }}</div>
|
||
<div class="ombi-sub">{{ req.user }} · {{ req.date_display }}</div>
|
||
</div>
|
||
<span class="ombi-status {{ req.status | lower }}">{{ req.status }}</span>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
{% else %}
|
||
<div style="color:var(--muted); font-size:0.85rem;">No recent requests.</div>
|
||
{% endif %}
|
||
{% else %}
|
||
{{ service_error("Ombi", ombi.error) }}
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Tautulli ── -->
|
||
<div class="card tautulli" style="margin-top: 1.75rem;">
|
||
<div class="card-header">
|
||
<span class="card-icon">▶️</span>
|
||
<h2><a href="{{ service_urls.tautulli }}" target="_blank" rel="noopener">Tautulli</a></h2>
|
||
{% if tautulli.status == "ok" %}
|
||
<div class="tautulli-streams">
|
||
{% if tautulli.active_streams > 0 %}
|
||
<span class="stream-dot"></span>
|
||
<span>{{ tautulli.active_streams }} active stream{{ 's' if tautulli.active_streams != 1 }}</span>
|
||
{% else %}
|
||
<span style="color:var(--muted); font-size:0.78rem;">No active streams</span>
|
||
{% endif %}
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
<div class="card-body">
|
||
{% if tautulli.status == "ok" %}
|
||
<div class="tautulli-grid">
|
||
|
||
<!-- Top 3 TV -->
|
||
<div class="tautulli-item" style="flex-direction: column; align-items: flex-start; gap: 0.5rem;">
|
||
<div style="display:flex; align-items:center; gap:0.6rem; width:100%;">
|
||
<div style="font-size:1.5rem;">📺</div>
|
||
<div class="tautulli-kind" style="margin-bottom:0;">Top TV — 30d</div>
|
||
</div>
|
||
{% if tautulli.top_tv_list %}
|
||
<div class="tautulli-top3" style="width:100%;">
|
||
{% for item in tautulli.top_tv_list %}
|
||
<div class="tautulli-top3-item">
|
||
<div class="tautulli-top3-rank">{{ loop.index }}</div>
|
||
<div class="tautulli-top3-title">{{ item.title }}</div>
|
||
<div class="tautulli-top3-plays"><strong>{{ item.plays }}</strong> plays</div>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
{% else %}
|
||
<div style="color:var(--muted); font-size:0.82rem;">No data yet</div>
|
||
{% endif %}
|
||
</div>
|
||
|
||
<!-- Top 3 Movies -->
|
||
<div class="tautulli-item" style="flex-direction: column; align-items: flex-start; gap: 0.5rem;">
|
||
<div style="display:flex; align-items:center; gap:0.6rem; width:100%;">
|
||
<div style="font-size:1.5rem;">🎬</div>
|
||
<div class="tautulli-kind" style="margin-bottom:0;">Top Movies — 30d</div>
|
||
</div>
|
||
{% if tautulli.top_movie_list %}
|
||
<div class="tautulli-top3" style="width:100%;">
|
||
{% for item in tautulli.top_movie_list %}
|
||
<div class="tautulli-top3-item">
|
||
<div class="tautulli-top3-rank">{{ loop.index }}</div>
|
||
<div class="tautulli-top3-title">{{ item.title }}</div>
|
||
<div class="tautulli-top3-plays"><strong>{{ item.plays }}</strong> plays</div>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
{% else %}
|
||
<div style="color:var(--muted); font-size:0.82rem;">No data yet</div>
|
||
{% endif %}
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- Top played + Top viewers -->
|
||
<div class="tautulli-lower">
|
||
|
||
<!-- Top 3 Most Played (30d) -->
|
||
<div>
|
||
<div class="section-title">🏆 Top Played (30d)</div>
|
||
<div class="top-list">
|
||
{% if tautulli.top_played %}
|
||
{% for item in tautulli.top_played %}
|
||
<div class="top-list-item">
|
||
<div class="top-rank">{{ loop.index }}</div>
|
||
<div class="top-info">
|
||
<div class="top-name">{{ item.title }}</div>
|
||
<div class="top-sub">by {{ item.user }}</div>
|
||
</div>
|
||
{% set mt = item.media_type | lower %}
|
||
<span class="media-type-badge {{ 'episode' if mt == 'episode' else ('movie' if mt == 'movie' else 'other') }}">
|
||
{{ 'TV' if mt == 'episode' else ('Movie' if mt == 'movie' else mt or '?') }}
|
||
</span>
|
||
<div class="top-plays">{{ item.plays }}×</div>
|
||
</div>
|
||
{% endfor %}
|
||
{% else %}
|
||
<div style="color:var(--muted); font-size:0.82rem; padding: 0.4rem 0;">No play history available.</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Top 3 Viewers (30d, excl. ninja_hippo) -->
|
||
<div>
|
||
<div class="section-title">👤 Top Viewers (30d)</div>
|
||
<div class="top-list">
|
||
{% if tautulli.top_users %}
|
||
{% for u in tautulli.top_users %}
|
||
<div class="top-list-item">
|
||
<div class="top-rank">{{ loop.index }}</div>
|
||
<div class="top-avatar">👤</div>
|
||
<div class="top-info">
|
||
<div class="top-name">{{ u.username }}</div>
|
||
<div class="top-sub">{{ u.plays }} plays</div>
|
||
</div>
|
||
<div class="top-plays">{{ u.plays }}×</div>
|
||
</div>
|
||
{% endfor %}
|
||
{% else %}
|
||
<div style="color:var(--muted); font-size:0.82rem; padding: 0.4rem 0;">No viewer data available.</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
{% else %}
|
||
{{ service_error("Tautulli", tautulli.error) }}
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
|
||
</main>
|
||
|
||
<footer>
|
||
{% if theme == 'office' %}
|
||
"I'm not superstitious, but I am a little stitious." — Michael Scott | Dunder Mifflin Media Centre
|
||
{% elif theme == 'parks' %}
|
||
"If you believe it, you can achieve it." — Leslie Knope | Pawnee Parks Media Centre
|
||
{% elif theme == 'potter' %}
|
||
"It does not do to dwell on dreams and forget to live." — Albus Dumbledore | Hogwarts Media Vault
|
||
{% elif theme == 'starwars' %}
|
||
"Do. Or do not. There is no try." — Yoda | Rebel Alliance Media Archive
|
||
{% else %}
|
||
"I discovered at a young age that I have... a gift." — Jeff Winger | Greendale Human Being Media Centre
|
||
{% endif %}
|
||
</footer>
|
||
|
||
<script>
|
||
// Base URL prefix — empty when running at root
|
||
const URL_BASE = "";
|
||
|
||
// ── Search state (declared early to avoid ReferenceError in event listeners) ──
|
||
let sonarrSearchResult = null;
|
||
let radarrSearchResult = null;
|
||
|
||
// ── Clock ──
|
||
const TIMEZONE = "{{ timezone }}";
|
||
function updateClock() {
|
||
const el = document.getElementById('header-clock');
|
||
if (!el) return;
|
||
try {
|
||
el.textContent = new Date().toLocaleTimeString('en-US', {
|
||
timeZone: TIMEZONE, hour: '2-digit', minute: '2-digit', second: '2-digit'
|
||
});
|
||
} catch(e) {
|
||
el.textContent = new Date().toLocaleTimeString();
|
||
}
|
||
}
|
||
updateClock();
|
||
setInterval(updateClock, 1000);
|
||
|
||
function removeRtItem(btn) {
|
||
const item = btn.closest('.rt-item');
|
||
if (!item) return;
|
||
item.classList.add('removing');
|
||
setTimeout(() => {
|
||
const grid = item.parentElement;
|
||
item.remove();
|
||
// Re-number remaining items
|
||
if (grid) {
|
||
grid.querySelectorAll('.rt-rank').forEach((el, i) => { el.textContent = i + 1; });
|
||
}
|
||
}, 320);
|
||
}
|
||
|
||
async function addToRadarr(btn) {
|
||
const title = btn.dataset.title;
|
||
btn.disabled = true; btn.classList.add('adding'); btn.textContent = 'Adding…';
|
||
try {
|
||
const resp = await fetch(URL_BASE + '/add-to-radarr', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ title }) });
|
||
const data = await resp.json();
|
||
if ((resp.ok && data.success) || resp.status === 409) {
|
||
removeRtItem(btn);
|
||
} else {
|
||
btn.classList.remove('adding'); btn.disabled = false; btn.textContent = '+ Add';
|
||
alert('Failed to add "' + title + '": ' + (data.error || 'Unknown error'));
|
||
}
|
||
} catch (e) { btn.classList.remove('adding'); btn.disabled = false; btn.textContent = '+ Add'; alert('Network error: ' + e.message); }
|
||
}
|
||
|
||
async function addToSonarr(btn) {
|
||
const title = btn.dataset.title;
|
||
btn.disabled = true; btn.classList.add('adding'); btn.textContent = 'Adding…';
|
||
try {
|
||
const resp = await fetch(URL_BASE + '/add-to-sonarr', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ title }) });
|
||
const data = await resp.json();
|
||
if ((resp.ok && data.success) || resp.status === 409) {
|
||
removeRtItem(btn);
|
||
} else {
|
||
btn.classList.remove('adding'); btn.disabled = false; btn.textContent = '+ Add';
|
||
alert('Failed to add "' + title + '": ' + (data.error || 'Unknown error'));
|
||
}
|
||
} catch (e) { btn.classList.remove('adding'); btn.disabled = false; btn.textContent = '+ Add'; alert('Network error: ' + e.message); }
|
||
}
|
||
|
||
// ── SABnzbd real-time polling ──
|
||
const sabSpeed = document.getElementById('sab-speed');
|
||
const sabQueue = document.getElementById('sab-queue');
|
||
const sabRemaining = document.getElementById('sab-remaining');
|
||
const sabBadge = document.getElementById('sab-badge');
|
||
const sabTimeleft = document.getElementById('sab-timeleft');
|
||
const sabTimeleftRow = document.getElementById('sab-timeleft-row');
|
||
|
||
function updateSabBadge(status) {
|
||
if (!sabBadge) return;
|
||
const s = status.toLowerCase();
|
||
sabBadge.className = 'sab-badge ' + (s === 'downloading' ? 'downloading' : s === 'paused' ? 'paused' : 'idle');
|
||
sabBadge.textContent = status;
|
||
}
|
||
|
||
async function pollSabnzbd() {
|
||
try {
|
||
const resp = await fetch(URL_BASE + '/api/sabnzbd', { cache: 'no-store', credentials: 'include' });
|
||
if (!resp.ok) return;
|
||
const d = await resp.json();
|
||
if (d.status !== 'ok') return;
|
||
if (sabSpeed) sabSpeed.textContent = d.speed;
|
||
if (sabQueue) sabQueue.textContent = d.queue_count;
|
||
if (sabRemaining) sabRemaining.textContent = d.sizeleft;
|
||
updateSabBadge(d.sab_status);
|
||
// Show ETA row only when actively downloading with a real timeleft
|
||
const isActive = d.sab_status.toLowerCase() === 'downloading' && d.timeleft && d.timeleft !== '0:00:00';
|
||
if (sabTimeleftRow) sabTimeleftRow.style.display = isActive ? 'block' : 'none';
|
||
if (sabTimeleft) sabTimeleft.textContent = d.timeleft;
|
||
// Update header pill stat
|
||
const sabStat = document.getElementById('svc-stat-sabnzbd');
|
||
if (sabStat) sabStat.textContent = d.speed;
|
||
// Timestamp in hover popover
|
||
const sabPopoverTime = document.getElementById('sab-popover-time');
|
||
if (sabPopoverTime) sabPopoverTime.textContent = new Date().toLocaleTimeString();
|
||
} catch (e) { /* silently skip on network error */ }
|
||
}
|
||
|
||
// Poll every 3 seconds — no guard, always start
|
||
pollSabnzbd();
|
||
setInterval(pollSabnzbd, 3000);
|
||
|
||
// ── RT description lazy-loader ──
|
||
// After page load, poll /api/rt/descriptions and fill in tooltips as IMDB data arrives.
|
||
async function loadRtDescriptions() {
|
||
try {
|
||
const resp = await fetch(URL_BASE + '/api/rt/descriptions', { credentials: 'include' });
|
||
if (!resp.ok) return;
|
||
const d = await resp.json();
|
||
for (const [title, desc] of Object.entries(d.descriptions || {})) {
|
||
const item = document.querySelector(`.rt-item[data-rt-title="${CSS.escape(title)}"]`);
|
||
if (!item || !desc) continue;
|
||
const tooltip = item.querySelector('.rt-tooltip');
|
||
const span = item.querySelector('.rt-desc');
|
||
if (!tooltip || !span || span.textContent.trim()) continue; // already filled
|
||
span.textContent = desc.length > 180 ? desc.slice(0, 180) + '…' : desc;
|
||
tooltip.style.display = '';
|
||
}
|
||
// Keep polling every 4 seconds until all descriptions are in
|
||
if (d.pending) setTimeout(loadRtDescriptions, 4000);
|
||
} catch(e) { /* silently skip */ }
|
||
}
|
||
// Start after a short delay so it doesn't compete with initial page load
|
||
setTimeout(loadRtDescriptions, 1500);
|
||
|
||
// ── Prowlarr real-time polling ──
|
||
async function pollProwlarr() {
|
||
try {
|
||
const resp = await fetch(URL_BASE + '/api/prowlarr', { credentials: 'include' });
|
||
if (!resp.ok) return;
|
||
const d = await resp.json();
|
||
if (d.status !== 'ok') return;
|
||
|
||
// Update up/down count
|
||
const upEl = document.getElementById('prowlarr-up-count');
|
||
const totEl = document.getElementById('prowlarr-total');
|
||
if (upEl) upEl.textContent = d.up_count;
|
||
if (totEl) totEl.textContent = d.total;
|
||
|
||
// Update each indexer light
|
||
d.indexers.forEach(idx => {
|
||
const row = document.getElementById('idx-' + idx.id);
|
||
const light = document.getElementById('idx-light-' + idx.id);
|
||
if (row) {
|
||
row.classList.toggle('up', idx.up);
|
||
row.classList.toggle('down', !idx.up);
|
||
}
|
||
if (light) {
|
||
light.classList.toggle('up', idx.up);
|
||
light.classList.toggle('down', !idx.up);
|
||
}
|
||
});
|
||
|
||
// Update header pill
|
||
updateServicePill('prowlarr', d.up_count === d.total && d.total > 0);
|
||
const prowlarrStat = document.getElementById('svc-stat-prowlarr');
|
||
if (prowlarrStat) prowlarrStat.textContent = d.up_count + '/' + d.total + ' online';
|
||
} catch (e) { /* silently skip */ }
|
||
}
|
||
|
||
// ── Header service lights polling ──
|
||
function updateServicePill(name, isUp) {
|
||
const pill = document.getElementById('svc-' + name);
|
||
const light = document.getElementById('svc-light-' + name);
|
||
if (pill) { pill.classList.toggle('up', isUp); pill.classList.toggle('down', !isUp); }
|
||
if (light) { light.classList.toggle('up', isUp); light.classList.toggle('down', !isUp); }
|
||
}
|
||
|
||
async function pollServices() {
|
||
try {
|
||
const resp = await fetch(URL_BASE + '/api/services', { credentials: 'include' });
|
||
if (!resp.ok) return;
|
||
const d = await resp.json();
|
||
for (const [name, isUp] of Object.entries(d)) {
|
||
updateServicePill(name, isUp);
|
||
}
|
||
} catch (e) { /* silently skip */ }
|
||
}
|
||
|
||
// Prowlarr: poll every 30 seconds (indexer status doesn't change fast)
|
||
pollProwlarr();
|
||
setInterval(pollProwlarr, 30000);
|
||
|
||
// Header service lights: poll every 60 seconds
|
||
pollServices();
|
||
setInterval(pollServices, 60000);
|
||
|
||
// ── Tautulli real-time stream polling ──
|
||
async function pollTautulli() {
|
||
try {
|
||
const resp = await fetch(URL_BASE + '/api/tautulli', { credentials: 'include' });
|
||
if (!resp.ok) return;
|
||
const d = await resp.json();
|
||
if (d.status !== 'ok') return;
|
||
|
||
const count = d.stream_count;
|
||
const sessions = d.sessions || [];
|
||
|
||
// Update header pill stat
|
||
const statEl = document.getElementById('svc-stat-tautulli');
|
||
if (statEl) statEl.textContent = count > 0 ? count + ' streaming' : '0 streams';
|
||
|
||
// Update popover content
|
||
const popover = document.getElementById('tautulli-popover');
|
||
if (popover) {
|
||
if (sessions.length > 0) {
|
||
popover.innerHTML = sessions.map((s, i) => {
|
||
const sep = i > 0 ? 'margin-top:0.55rem; padding-top:0.55rem; border-top:1px solid var(--border);' : '';
|
||
const icon = s.media_type === 'movie' ? '🎬' : '📺';
|
||
return `<div style="display:flex; align-items:flex-start; gap:0.5rem; ${sep}">
|
||
<span style="font-size:0.9rem; flex-shrink:0;">${icon}</span>
|
||
<div style="min-width:0;">
|
||
<div style="font-size:0.78rem; font-weight:700; color:var(--text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${s.title}</div>
|
||
<div style="font-size:0.68rem; color:#c77dff; margin-top:0.1rem;">${s.user}</div>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
} else {
|
||
popover.innerHTML = '<div style="font-size:0.78rem; color:var(--muted);">No active streams</div>';
|
||
}
|
||
}
|
||
} catch (e) { /* silently skip */ }
|
||
}
|
||
|
||
// Poll Tautulli every 15 seconds
|
||
pollTautulli();
|
||
setInterval(pollTautulli, 15000);
|
||
|
||
// ── Search clear on empty input ──
|
||
document.getElementById('sonarr-search-input').addEventListener('input', function() {
|
||
if (!this.value.trim()) {
|
||
document.getElementById('sonarr-search-result').classList.remove('visible');
|
||
document.getElementById('sonarr-search-msg').style.display = 'none';
|
||
sonarrSearchResult = null;
|
||
}
|
||
});
|
||
document.getElementById('radarr-search-input').addEventListener('input', function() {
|
||
if (!this.value.trim()) {
|
||
document.getElementById('radarr-search-result').classList.remove('visible');
|
||
document.getElementById('radarr-search-msg').style.display = 'none';
|
||
radarrSearchResult = null;
|
||
}
|
||
});
|
||
|
||
let pendingDelete = null;
|
||
|
||
function confirmDelete(item, rowId) {
|
||
pendingDelete = { item, rowId };
|
||
document.getElementById('delete-modal-title').textContent = item.title;
|
||
const modal = document.getElementById('delete-modal');
|
||
modal.style.display = 'flex';
|
||
document.getElementById('delete-modal-confirm').onclick = executeDelete;
|
||
}
|
||
|
||
function closeDeleteModal() {
|
||
document.getElementById('delete-modal').style.display = 'none';
|
||
pendingDelete = null;
|
||
}
|
||
|
||
async function executeDelete() {
|
||
if (!pendingDelete) return;
|
||
const { item, rowId } = pendingDelete;
|
||
closeDeleteModal();
|
||
const row = document.getElementById(rowId);
|
||
if (row) row.style.opacity = '0.4';
|
||
try {
|
||
const endpoint = item.type === 'movie' ? '/api/delete/movie' : '/api/delete/show';
|
||
const body = item.type === 'movie' ? { radarr_id: item.radarr_id } : { sonarr_id: item.sonarr_id };
|
||
const resp = await fetch(URL_BASE + endpoint, {
|
||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||
credentials: 'include', body: JSON.stringify(body)
|
||
});
|
||
const d = await resp.json();
|
||
if (resp.ok && d.success) {
|
||
if (row) row.remove();
|
||
} else {
|
||
if (row) row.style.opacity = '1';
|
||
alert('Delete failed: ' + (d.error || 'Unknown error'));
|
||
}
|
||
} catch(e) {
|
||
if (row) row.style.opacity = '1';
|
||
alert('Network error during delete.');
|
||
}
|
||
}
|
||
|
||
// Close modal on backdrop click
|
||
document.getElementById('delete-modal').addEventListener('click', function(e) {
|
||
if (e.target === this) closeDeleteModal();
|
||
});
|
||
|
||
// ── Sonarr search ──
|
||
async function searchSonarr() {
|
||
const input = document.getElementById('sonarr-search-input');
|
||
const btn = document.getElementById('sonarr-search-btn');
|
||
const resultEl = document.getElementById('sonarr-search-result');
|
||
const msgEl = document.getElementById('sonarr-search-msg');
|
||
const title = input.value.trim();
|
||
if (!title) return;
|
||
|
||
btn.disabled = true; btn.textContent = 'Searching…';
|
||
resultEl.classList.remove('visible');
|
||
msgEl.style.display = 'none';
|
||
sonarrSearchResult = null;
|
||
|
||
try {
|
||
const resp = await fetch(URL_BASE + '/api/sonarr/lookup?q=' + encodeURIComponent(title), { credentials: 'include' });
|
||
if (resp.redirected || !resp.headers.get('content-type')?.includes('application/json')) {
|
||
msgEl.textContent = 'Session expired — please refresh the page.';
|
||
msgEl.className = 'search-msg error'; msgEl.style.display = 'block';
|
||
btn.disabled = false; btn.textContent = 'Search'; return;
|
||
}
|
||
const d = await resp.json();
|
||
if (!resp.ok || d.error) {
|
||
msgEl.textContent = d.error || 'Search failed.';
|
||
msgEl.className = 'search-msg error'; msgEl.style.display = 'block';
|
||
} else if (!d.title) {
|
||
msgEl.textContent = 'No results found.';
|
||
msgEl.className = 'search-msg'; msgEl.style.display = 'block';
|
||
} else {
|
||
sonarrSearchResult = d;
|
||
document.getElementById('sonarr-result-title').textContent = d.title;
|
||
document.getElementById('sonarr-result-meta').textContent = (d.year || '') + (d.network ? ' · ' + d.network : '') + (d.status ? ' · ' + d.status : '');
|
||
const addBtn = document.getElementById('sonarr-add-btn');
|
||
if (d.exists) {
|
||
addBtn.textContent = '✓ In Library'; addBtn.disabled = true; addBtn.className = 'btn-add sonarr-btn in-arr';
|
||
} else {
|
||
addBtn.textContent = '+ Add'; addBtn.disabled = false; addBtn.className = 'btn-add sonarr-btn';
|
||
}
|
||
resultEl.classList.add('visible');
|
||
}
|
||
} catch(e) {
|
||
msgEl.textContent = 'Search failed: ' + e.message; msgEl.className = 'search-msg error'; msgEl.style.display = 'block';
|
||
}
|
||
btn.disabled = false; btn.textContent = 'Search';
|
||
}
|
||
|
||
async function confirmAddSonarr() {
|
||
if (!sonarrSearchResult) return;
|
||
const addBtn = document.getElementById('sonarr-add-btn');
|
||
addBtn.disabled = true; addBtn.textContent = 'Adding…';
|
||
try {
|
||
const resp = await fetch(URL_BASE + '/add-to-sonarr', {
|
||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||
credentials: 'include', body: JSON.stringify({ title: sonarrSearchResult.title })
|
||
});
|
||
const d = await resp.json();
|
||
if ((resp.ok && d.success) || resp.status === 409) {
|
||
addBtn.textContent = '✓ Added'; addBtn.className = 'btn-add sonarr-btn in-arr';
|
||
} else {
|
||
addBtn.disabled = false; addBtn.textContent = '+ Add';
|
||
alert('Failed: ' + (d.error || 'Unknown error'));
|
||
}
|
||
} catch(e) { addBtn.disabled = false; addBtn.textContent = '+ Add'; }
|
||
}
|
||
|
||
// ── Radarr search ──
|
||
async function searchRadarr() {
|
||
const input = document.getElementById('radarr-search-input');
|
||
const btn = document.getElementById('radarr-search-btn');
|
||
const resultEl = document.getElementById('radarr-search-result');
|
||
const msgEl = document.getElementById('radarr-search-msg');
|
||
const title = input.value.trim();
|
||
if (!title) return;
|
||
|
||
btn.disabled = true; btn.textContent = 'Searching…';
|
||
resultEl.classList.remove('visible');
|
||
msgEl.style.display = 'none';
|
||
radarrSearchResult = null;
|
||
|
||
try {
|
||
const resp = await fetch(URL_BASE + '/api/radarr/lookup?q=' + encodeURIComponent(title), { credentials: 'include' });
|
||
if (resp.redirected || !resp.headers.get('content-type')?.includes('application/json')) {
|
||
msgEl.textContent = 'Session expired — please refresh the page.';
|
||
msgEl.className = 'search-msg error'; msgEl.style.display = 'block';
|
||
btn.disabled = false; btn.textContent = 'Search'; return;
|
||
}
|
||
const d = await resp.json();
|
||
if (!resp.ok || d.error) {
|
||
msgEl.textContent = d.error || 'Search failed.';
|
||
msgEl.className = 'search-msg error'; msgEl.style.display = 'block';
|
||
} else if (!d.title) {
|
||
msgEl.textContent = 'No results found.';
|
||
msgEl.className = 'search-msg'; msgEl.style.display = 'block';
|
||
} else {
|
||
radarrSearchResult = d;
|
||
document.getElementById('radarr-result-title').textContent = d.title;
|
||
document.getElementById('radarr-result-meta').textContent = (d.year || '') + (d.studio ? ' · ' + d.studio : '') + (d.runtime ? ' · ' + d.runtime + 'min' : '');
|
||
const addBtn = document.getElementById('radarr-add-btn');
|
||
if (d.exists) {
|
||
addBtn.textContent = '✓ In Library'; addBtn.disabled = true; addBtn.className = 'btn-add radarr-btn in-arr';
|
||
} else {
|
||
addBtn.textContent = '+ Add'; addBtn.disabled = false; addBtn.className = 'btn-add radarr-btn';
|
||
}
|
||
resultEl.classList.add('visible');
|
||
}
|
||
} catch(e) {
|
||
msgEl.textContent = 'Search failed: ' + e.message; msgEl.className = 'search-msg error'; msgEl.style.display = 'block';
|
||
}
|
||
btn.disabled = false; btn.textContent = 'Search';
|
||
}
|
||
|
||
async function confirmAddRadarr() {
|
||
if (!radarrSearchResult) return;
|
||
const addBtn = document.getElementById('radarr-add-btn');
|
||
addBtn.disabled = true; addBtn.textContent = 'Adding…';
|
||
try {
|
||
const resp = await fetch(URL_BASE + '/add-to-radarr', {
|
||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||
credentials: 'include', body: JSON.stringify({ title: radarrSearchResult.title })
|
||
});
|
||
const d = await resp.json();
|
||
if ((resp.ok && d.success) || resp.status === 409) {
|
||
addBtn.textContent = '✓ Added'; addBtn.className = 'btn-add radarr-btn in-arr';
|
||
} else {
|
||
addBtn.disabled = false; addBtn.textContent = '+ Add';
|
||
alert('Failed: ' + (d.error || 'Unknown error'));
|
||
}
|
||
} catch(e) { addBtn.disabled = false; addBtn.textContent = '+ Add'; }
|
||
}
|
||
</script>
|
||
|
||
</body>
|
||
</html>
|