arr-summary/templates/dashboard.html
Barely Removable 2e1f8efa33
All checks were successful
Deploy / deploy (push) Successful in 2m6s
Fix unreachable mobile Settings link and raw error tracebacks
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>
2026-08-25 23:59:07 -07:00

2046 lines
81 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{% 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' %}
📋 &nbsp;Monday morning meeting: checking downloads, missing episodes, and who took the last of the coffee.
{% elif theme == 'parks' %}
📋 &nbsp;Today's agenda: checking what's missing, what's downloading, and making Pawnee great again.
{% elif theme == 'potter' %}
📋 &nbsp;Today's scroll: checking what spells are missing, what's being conjured, and what deserves a watch.
{% elif theme == 'starwars' %}
📋 &nbsp;Rebel briefing: checking what's missing, what's downloading, and what the Force recommends watching.
{% else %}
📋 &nbsp;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 %}&nbsp;·&nbsp; {{ unraid.disk_count }} disk{{ 's' if unraid.disk_count != 1 }}{% endif %}
{% if unraid.os_version %}&nbsp;·&nbsp; 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 }} &nbsp;·&nbsp; {{ 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." &nbsp;&nbsp; Michael Scott &nbsp;|&nbsp; Dunder Mifflin Media Centre
{% elif theme == 'parks' %}
"If you believe it, you can achieve it." &nbsp;&nbsp; Leslie Knope &nbsp;|&nbsp; Pawnee Parks Media Centre
{% elif theme == 'potter' %}
"It does not do to dwell on dreams and forget to live." &nbsp;&nbsp; Albus Dumbledore &nbsp;|&nbsp; Hogwarts Media Vault
{% elif theme == 'starwars' %}
"Do. Or do not. There is no try." &nbsp;&nbsp; Yoda &nbsp;|&nbsp; Rebel Alliance Media Archive
{% else %}
"I discovered at a young age that I have... a gift." &nbsp;&nbsp; Jeff Winger &nbsp;|&nbsp; 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>