arr-summary/templates/dashboard.html
Barely Removable ecf3209abd
All checks were successful
Deploy / deploy (push) Successful in 21s
Enlarge Prowlarr indexer pills
Bigger padding, font size, and status dot, plus a wider minimum column,
so the indexer grid reads less cramped next to the Ombi request list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 14:01:17 -07:00

1557 lines
60 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Media Centre</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='%237c86ff'/><path d='M40 32 L72 50 L40 68 Z' fill='%23141416'/></svg>">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
/* ═══════════════════════════════════════════════════════
Media Centre — single theme, neutral + professional
═══════════════════════════════════════════════════════ */
:root {
/* ── Surfaces ── */
--bg: #141416;
--surface: #1a1a1d;
--surface-2: #202024;
--border: #2c2c31;
--border-lt: #38383f;
/* ── Text (all ≥ 4.5:1 on every surface above) ── */
--text: #edecf1;
--text-2: #a8a7b0;
--text-3: #7c7b84;
/* ── Accent + semantics ── */
--accent: #7c86ff;
--accent-dim: rgba(124,134,255,0.14);
--ok: #68b98a;
--ok-dim: rgba(104,185,138,0.14);
--warn: #d0a75a;
--warn-dim: rgba(208,167,90,0.14);
--danger: #dc5b52;
--danger-dim: rgba(220,91,82,0.14);
/* ── Type scale (6 steps, no one-offs) ── */
--fs-xs: 0.75rem; /* 12px — smallest permitted */
--fs-sm: 0.8125rem; /* 13px */
--fs-base: 0.9375rem; /* 15px */
--fs-lg: 1.125rem; /* 18px */
--fs-xl: 1.5rem; /* 24px */
--fs-2xl: 2rem; /* 32px */
/* ── Spacing / shape ── */
--gap: 1rem;
--gap-lg: 1.5rem;
--radius: 10px;
--radius-sm: 7px;
--tap: 44px; /* minimum touch target */
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: var(--font);
font-size: var(--fs-base);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
a { color: var(--accent); }
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 4px;
}
/* ── Header ─────────────────────────────────────────── */
header {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 0.75rem 1.5rem;
display: flex;
align-items: center;
gap: var(--gap);
flex-wrap: wrap;
position: sticky;
top: 0;
z-index: 100;
}
.brand {
display: flex;
align-items: center;
gap: 0.65rem;
min-width: 0;
flex: 0 0 auto;
}
.brand svg { flex-shrink: 0; display: block; }
.brand h1 {
margin: 0;
font-size: var(--fs-lg);
font-weight: 600;
letter-spacing: -0.01em;
white-space: nowrap;
}
/* Service status pills */
.svc-strip {
display: flex;
align-items: center;
gap: 0.4rem;
flex: 1 1 auto;
justify-content: center;
min-width: 0;
overflow-x: auto;
scrollbar-width: none;
}
.svc-strip::-webkit-scrollbar { display: none; }
.svc-pill {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.35rem 0.7rem;
min-height: 34px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 100px;
font-size: var(--fs-xs);
color: var(--text-2);
text-decoration: none;
white-space: nowrap;
transition: border-color 0.2s, color 0.2s;
}
.svc-pill:hover { border-color: var(--border-lt); color: var(--text); }
.svc-pill.down { color: var(--danger); border-color: rgba(220,91,82,0.35); }
.svc-name { font-weight: 600; }
.svc-stat { color: var(--text-3); }
.svc-pill.down .svc-stat { color: var(--danger); }
.status-light {
width: 7px; height: 7px; border-radius: 50%;
flex-shrink: 0; background: var(--text-3);
}
.status-light.up { background: var(--ok); box-shadow: 0 0 0 3px var(--ok-dim); }
.status-light.down { background: var(--danger); box-shadow: 0 0 0 3px var(--danger-dim); }
.header-right {
display: flex;
align-items: center;
gap: 0.75rem;
margin-left: auto;
flex: 0 0 auto;
}
.header-clock {
font-variant-numeric: tabular-nums;
font-size: var(--fs-sm);
color: var(--text-2);
white-space: nowrap;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
min-width: var(--tap);
min-height: var(--tap);
padding: 0 0.85rem;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-2);
font-size: var(--fs-sm);
font-family: inherit;
text-decoration: none;
white-space: nowrap;
cursor: pointer;
transition: color 0.2s, border-color 0.2s, background 0.2s;
}
.icon-btn:hover { color: var(--text); border-color: var(--border-lt); background: var(--surface); }
/* ── Layout ─────────────────────────────────────────── */
main {
padding: var(--gap-lg);
max-width: 1360px;
margin: 0 auto;
}
.section {
margin-bottom: 2rem;
}
.section > h2 {
margin: 0 0 0.85rem;
font-size: var(--fs-xs);
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--text-3);
}
.row {
display: grid;
gap: var(--gap);
}
.row.cols-3 { grid-template-columns: repeat(3, 1fr); }
.row.cols-2 { grid-template-columns: repeat(2, 1fr); }
.row.align-top { align-items: start; }
/* ── Card ───────────────────────────────────────────── */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
display: flex;
flex-direction: column;
min-width: 0;
}
.card-header {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.85rem 1.1rem;
border-bottom: 1px solid var(--border);
}
.card-header h3 {
margin: 0;
font-size: var(--fs-sm);
font-weight: 600;
color: var(--text);
}
.card-header h3 a { color: inherit; text-decoration: none; }
.card-header h3 a:hover { color: var(--accent); }
.card-meta {
margin-left: auto;
font-size: var(--fs-xs);
color: var(--text-3);
white-space: nowrap;
}
.card-body { padding: 1.1rem; flex: 1; }
.card-body > :first-child { margin-top: 0; }
/* ── "Right now" hero strip ─────────────────────────── */
.hero .card { border-color: var(--border-lt); }
.hero-figure {
font-size: var(--fs-2xl);
font-weight: 600;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
line-height: 1.1;
}
.hero-sub {
font-size: var(--fs-sm);
color: var(--text-3);
margin-top: 0.3rem;
}
/* Now-playing list */
.stream {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.55rem 0;
border-top: 1px solid var(--border);
}
.stream:first-child { border-top: none; padding-top: 0; }
.stream-icon { font-size: var(--fs-base); flex-shrink: 0; }
.stream-info { min-width: 0; flex: 1; }
.stream-title {
font-size: var(--fs-sm); font-weight: 600;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.stream-user { font-size: var(--fs-xs); color: var(--text-3); }
.live-dot {
width: 7px; height: 7px; border-radius: 50%;
background: var(--ok); flex-shrink: 0;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } }
@media (prefers-reduced-motion: reduce) {
.live-dot { animation: none; }
* { transition-duration: 0.01ms !important; }
}
/* ── Meter (storage / progress) ─────────────────────── */
.meter {
height: 7px;
background: var(--surface-2);
border-radius: 4px;
overflow: hidden;
margin-top: 0.65rem;
}
.meter-fill {
height: 100%;
border-radius: 4px;
background: var(--ok);
transition: width 0.5s ease;
}
.meter-fill.warn { background: var(--warn); }
.meter-fill.danger { background: var(--danger); }
.meter-legend {
display: flex;
justify-content: space-between;
font-size: var(--fs-xs);
color: var(--text-3);
margin-top: 0.45rem;
}
/* ── Stats (semantic definition list) ───────────────── */
dl.stats {
display: grid;
gap: 0.6rem;
margin: 0;
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
}
dl.stats > div {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 0.65rem 0.75rem;
min-width: 0;
}
dl.stats dt {
font-size: var(--fs-xs);
color: var(--text-3);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
dl.stats dd {
margin: 0.2rem 0 0;
font-size: var(--fs-lg);
font-weight: 600;
font-variant-numeric: tabular-nums;
}
dd.is-warn { color: var(--warn); }
dd.is-danger { color: var(--danger); }
dd.is-ok { color: var(--ok); }
dd.is-accent { color: var(--accent); }
/* ── Buttons ────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: var(--tap);
padding: 0 1rem;
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: var(--accent);
color: var(--bg);
font-family: inherit;
font-size: var(--fs-sm);
font-weight: 600;
cursor: pointer;
white-space: nowrap;
transition: opacity 0.15s, background 0.15s;
}
.btn:hover:not(:disabled) { opacity: 0.88; }
.btn:disabled { opacity: 0.5; cursor: default; }
.btn-quiet {
background: var(--surface-2);
border-color: var(--border);
color: var(--text-2);
}
.btn-quiet:hover:not(:disabled) { color: var(--text); border-color: var(--border-lt); }
/* Compact add button — still meets 44px on touch (see media query) */
.btn-add {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 32px;
padding: 0 0.7rem;
border: 1px solid var(--border-lt);
border-radius: var(--radius-sm);
background: var(--surface-2);
color: var(--text);
font-family: inherit;
font-size: var(--fs-xs);
font-weight: 600;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.btn-add:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
/* Armed = awaiting the confirming second tap */
.btn-add.armed {
background: var(--warn);
border-color: var(--warn);
color: var(--bg);
}
.btn-add.done {
background: var(--ok-dim);
border-color: transparent;
color: var(--ok);
cursor: default;
}
.btn-add:disabled { cursor: default; }
/* ── Search ─────────────────────────────────────────── */
.search-bar { display: flex; gap: 0.5rem; margin-bottom: 0.85rem; }
.search-bar input {
flex: 1;
min-width: 0;
min-height: var(--tap);
padding: 0 0.85rem;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font-family: inherit;
font-size: var(--fs-sm);
}
.search-bar input::placeholder { color: var(--text-3); }
.search-bar input:focus { border-color: var(--accent); outline: none; }
.search-result {
display: none;
align-items: center;
gap: 0.75rem;
padding: 0.7rem 0.85rem;
margin-bottom: 0.85rem;
background: var(--accent-dim);
border: 1px solid rgba(122,162,247,0.3);
border-radius: var(--radius-sm);
}
.search-result.visible { display: flex; }
.search-result-info { min-width: 0; flex: 1; }
.search-result-title {
font-size: var(--fs-sm); font-weight: 600;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.search-result-meta { font-size: var(--fs-xs); color: var(--text-3); }
.search-msg {
font-size: var(--fs-sm);
color: var(--text-2);
padding: 0.5rem 0;
}
.search-msg.error { color: var(--danger); }
/* ── Discover (RT) list ─────────────────────────────── */
.rt-list { display: flex; flex-direction: column; gap: 0.5rem; }
.rt-item {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.6rem;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
transition: border-color 0.2s, opacity 0.3s, transform 0.3s;
}
.rt-item:hover { border-color: var(--border-lt); }
.rt-item.removing { opacity: 0; transform: scale(0.97); pointer-events: none; }
.rt-item.is-hidden { display: none; }
.rt-rank {
font-size: var(--fs-xs);
color: var(--text-3);
font-variant-numeric: tabular-nums;
width: 1.2rem;
text-align: right;
flex-shrink: 0;
}
.rt-poster {
width: 38px; height: 56px;
object-fit: cover;
border-radius: 4px;
background: var(--surface);
flex-shrink: 0;
}
.rt-info { min-width: 0; flex: 1; }
.rt-title {
font-size: var(--fs-sm);
font-weight: 600;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.rt-year { color: var(--text-3); font-weight: 400; }
.rt-meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
font-size: var(--fs-xs);
margin-top: 0.15rem;
}
.score.certified { color: var(--ok); }
.score.critics { color: var(--warn); }
.score.audience { color: var(--text-3); }
/* Description is inline + clamped — readable on touch, no hover required */
.rt-desc {
font-size: var(--fs-xs);
color: var(--text-3);
margin-top: 0.25rem;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.rt-desc:empty { display: none; }
.show-more {
margin-top: 0.6rem;
width: 100%;
min-height: 38px;
}
/* ── Indexers ───────────────────────────────────────── */
.indexer-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 0.65rem;
}
.indexer-item {
display: flex;
align-items: center;
gap: 0.65rem;
min-height: var(--tap);
padding: 0.75rem 0.9rem;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: var(--fs-base);
min-width: 0;
}
.indexer-item.down { border-color: rgba(220,91,82,0.3); }
.indexer-item .status-light { width: 9px; height: 9px; }
.indexer-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text-2); }
/* ── Requests (Ombi) ────────────────────────────────── */
.req-list { display: flex; flex-direction: column; gap: 0.5rem; }
.req-item.is-hidden { display: none; }
.req-item {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.6rem 0.7rem;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.badge {
font-size: var(--fs-xs);
font-weight: 600;
padding: 0.2rem 0.5rem;
border-radius: 100px;
white-space: nowrap;
flex-shrink: 0;
}
.badge.movie { background: var(--accent-dim); color: var(--accent); }
.badge.tv { background: var(--ok-dim); color: var(--ok); }
.badge.pending { background: var(--warn-dim); color: var(--warn); }
.badge.available, .badge.approved { background: var(--ok-dim); color: var(--ok); }
.badge.denied { background: var(--danger-dim); color: var(--danger); }
.badge.idle { background: var(--surface); color: var(--text-3); }
.badge.downloading { background: var(--accent-dim); color: var(--accent); }
.badge.paused { background: var(--warn-dim); color: var(--warn); }
.req-info { min-width: 0; flex: 1; }
.req-title {
font-size: var(--fs-sm); font-weight: 600;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.req-sub { font-size: var(--fs-xs); color: var(--text-3); }
/* ── Insights top lists ─────────────────────────────── */
.top-list { display: flex; flex-direction: column; gap: 0.4rem; }
.top-item {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.5rem 0.65rem;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.top-rank {
font-size: var(--fs-xs);
color: var(--text-3);
font-variant-numeric: tabular-nums;
width: 1.1rem;
flex-shrink: 0;
}
.top-info { min-width: 0; flex: 1; }
.top-name {
font-size: var(--fs-sm);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.top-sub { font-size: var(--fs-xs); color: var(--text-3); }
.top-plays {
font-size: var(--fs-xs);
color: var(--text-2);
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.empty { color: var(--text-3); font-size: var(--fs-sm); }
/* ── Errors ─────────────────────────────────────────── */
.error {
background: var(--surface-2);
border: 1px solid var(--border);
border-left: 3px solid var(--danger);
border-radius: var(--radius-sm);
padding: 0.8rem 0.9rem;
color: var(--text-2);
font-size: var(--fs-sm);
}
.error-detail { margin-top: 0.5rem; }
.error-detail summary {
cursor: pointer;
font-size: var(--fs-xs);
color: var(--text-3);
list-style: none;
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.3rem 0;
}
.error-detail summary::-webkit-details-marker { display: none; }
.error-detail summary::before { content: "▸"; }
.error-detail[open] summary::before { content: "▾"; }
.error-detail summary:hover { color: var(--text-2); }
.error-detail pre {
margin: 0.4rem 0 0;
padding: 0.6rem 0.7rem;
background: rgba(0,0,0,0.3);
border-radius: 6px;
font-family: var(--mono);
font-size: var(--fs-xs);
line-height: 1.5;
color: var(--text-3);
white-space: pre-wrap;
word-break: break-word;
overflow-x: auto;
}
footer {
padding: 1.5rem;
text-align: center;
font-size: var(--fs-xs);
color: var(--text-3);
border-top: 1px solid var(--border);
}
.visually-hidden {
position: absolute; width: 1px; height: 1px;
padding: 0; margin: -1px; overflow: hidden;
clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
/* ── Responsive ─────────────────────────────────────── */
@media (max-width: 1100px) {
.row.cols-3 { grid-template-columns: 1fr 1fr; }
.row.cols-3 > :first-child { grid-column: 1 / -1; }
}
@media (max-width: 820px) {
.row.cols-2, .row.cols-3 { grid-template-columns: 1fr; }
.row.cols-3 > :first-child { grid-column: auto; }
}
@media (max-width: 700px) {
header { padding: 0.7rem 1rem; gap: 0.6rem; }
.brand { order: 1; flex: 1 1 auto; min-width: 0; }
.brand h1 { font-size: var(--fs-base); }
.header-right { order: 2; }
.header-clock { display: none; }
.svc-strip { order: 3; width: 100%; flex: 1 0 100%; justify-content: flex-start; }
main { padding: 1rem; }
.card-body { padding: 0.9rem; }
.card-header { padding: 0.75rem 0.9rem; }
.search-bar { flex-direction: column; }
.search-bar .btn { width: 100%; }
/* Full touch targets once there's no mouse */
.btn-add { min-height: var(--tap); padding: 0 0.9rem; }
.search-bar input { font-size: 16px; } /* prevent iOS zoom on focus */
}
</style>
</head>
{#- Degraded service: human-readable cause, raw exception behind a
disclosure with any API key redacted. -#}
{% macro service_error(name, raw) %}
<div class="error" role="alert">
<div>{{ raw | friendly_error(name) }}</div>
{% if raw %}
<details class="error-detail">
<summary>Technical details</summary>
<pre>{{ raw | redact_secrets }}</pre>
</details>
{% endif %}
</div>
{% endmacro %}
{#- One service status pill in the header strip. -#}
{% macro svc_pill(key, label, url, up, stat) %}
<a class="svc-pill {{ 'up' if up else 'down' }}" id="svc-{{ key }}"
href="{{ url }}" target="_blank" rel="noopener">
<span class="status-light {{ 'up' if up else 'down' }}" id="svc-light-{{ key }}"></span>
<span class="svc-name">{{ label }}</span>
<span class="svc-stat" id="svc-stat-{{ key }}">{{ stat }}</span>
</a>
{% endmacro %}
<body>
<header>
<div class="brand">
<svg width="30" height="30" viewBox="0 0 100 100" aria-hidden="true">
<rect width="100" height="100" rx="22" fill="var(--accent)"/>
<path d="M40 32 L72 50 L40 68 Z" fill="var(--bg)"/>
</svg>
<h1>Media Centre</h1>
</div>
<nav class="svc-strip" aria-label="Service status">
{{ svc_pill('sonarr', 'Sonarr', service_urls.sonarr, sonarr.status == 'ok',
(sonarr.total_series ~ ' series') if sonarr.status == 'ok' else 'offline') }}
{{ svc_pill('radarr', 'Radarr', service_urls.radarr, radarr.status == 'ok',
(radarr.total_movies ~ ' movies') if radarr.status == 'ok' else 'offline') }}
{{ svc_pill('prowlarr', 'Prowlarr', service_urls.prowlarr, prowlarr.status == 'ok',
(prowlarr.up_count ~ '/' ~ prowlarr.total ~ ' up') if prowlarr.status == 'ok' else 'offline') }}
{{ svc_pill('sabnzbd', 'SABnzbd', service_urls.sabnzbd, sabnzbd.status == 'ok',
sabnzbd.speed if sabnzbd.status == 'ok' else 'offline') }}
{{ svc_pill('tautulli', 'Tautulli', service_urls.tautulli, tautulli.status == 'ok',
((tautulli.active_streams ~ ' streaming') if tautulli.active_streams > 0 else '0 streams') if tautulli.status == 'ok' else 'offline') }}
{{ svc_pill('ombi', 'Ombi', service_urls.ombi, ombi.status == 'ok',
(ombi.total_pending ~ ' pending') if ombi.status == 'ok' else 'offline') }}
</nav>
<div class="header-right">
<div class="header-clock" id="header-clock">—</div>
<a href="{{ url_for('settings_page') }}" class="icon-btn" aria-label="Settings">
<span aria-hidden="true">⚙</span>
</a>
</div>
</header>
<main>
<!-- ══ Right now — the time-sensitive row, above the fold ══ -->
<section class="section hero" aria-labelledby="h-now">
<h2 id="h-now">Right now</h2>
<div class="row cols-3">
<!-- Now playing -->
<div class="card">
<div class="card-header">
<h3><a href="{{ service_urls.tautulli }}" target="_blank" rel="noopener">Now playing</a></h3>
<span class="card-meta" id="tautulli-count">
{%- if tautulli.status == 'ok' -%}
{{ tautulli.active_streams }} active
{%- endif -%}
</span>
</div>
<div class="card-body">
{% if tautulli.status == 'ok' %}
<div id="now-playing" aria-live="polite">
{% if tautulli.sessions %}
{% for s in tautulli.sessions %}
<div class="stream">
<span class="live-dot"></span>
<span class="stream-icon" aria-hidden="true">{{ '🎬' if s.media_type == 'movie' else '📺' }}</span>
<span class="stream-info">
<span class="stream-title">{{ s.title }}</span>
<span class="stream-user">{{ s.user }}</span>
</span>
</div>
{% endfor %}
{% else %}
<p class="empty">Nothing streaming right now.</p>
{% endif %}
</div>
{% else %}
{{ service_error("Tautulli", tautulli.error) }}
{% endif %}
</div>
</div>
<!-- Downloads -->
<div class="card">
<div class="card-header">
<h3><a href="{{ service_urls.sabnzbd }}" target="_blank" rel="noopener">Downloads</a></h3>
{% if sabnzbd.status == 'ok' %}
{% set st = sabnzbd.sab_status | lower %}
<span class="card-meta">
<span id="sab-badge" class="badge {{ 'downloading' if st == 'downloading' else ('paused' if st == 'paused' else 'idle') }}">{{ sabnzbd.sab_status }}</span>
</span>
{% endif %}
</div>
<div class="card-body">
{% if sabnzbd.status == 'ok' %}
<div aria-live="polite">
<div class="hero-figure" id="sab-speed">{{ sabnzbd.speed }}</div>
<div class="hero-sub">
<span id="sab-queue">{{ sabnzbd.queue_count }}</span> queued ·
<span id="sab-remaining">{{ sabnzbd.sizeleft }}</span> left
<span id="sab-timeleft-row" style="display:none;"> · ETA <span id="sab-timeleft"></span></span>
</div>
</div>
<dl class="stats" style="margin-top:0.9rem;">
<div><dt>Avg speed (7d)</dt><dd>{{ sabnzbd.avg_speed }}</dd></div>
<div><dt>Grabbed (7d)</dt><dd>{{ sabnzbd.completed_7d }}</dd></div>
<div><dt>Volume (7d)</dt><dd>{{ sabnzbd.week_size }}</dd></div>
</dl>
{% else %}
{{ service_error("SABnzbd", sabnzbd.error) }}
{% endif %}
</div>
</div>
<!-- Storage -->
<div class="card">
<div class="card-header">
<h3><a href="{{ service_urls.unraid }}" target="_blank" rel="noopener">Storage</a></h3>
{% if unraid.status == 'ok' and unraid.array_state %}
<span class="card-meta">{{ unraid.array_state | capitalize }}</span>
{% endif %}
</div>
<div class="card-body">
{% if unraid.status == 'ok' %}
{% set pct = unraid.array_used_pct %}
<div class="hero-figure {% if pct > 85 %}is-danger{% elif pct > 70 %}is-warn{% endif %}"
style="color: {% if pct > 85 %}var(--danger){% elif pct > 70 %}var(--warn){% else %}var(--text){% endif %};">
{{ pct }}%
</div>
<div class="hero-sub">{{ unraid.array_free }} free of {{ unraid.array_total }}</div>
<div class="meter">
<div class="meter-fill {% if pct > 85 %}danger{% elif pct > 70 %}warn{% endif %}"
style="width: {{ pct }}%;"
role="progressbar" aria-valuenow="{{ pct }}" aria-valuemin="0" aria-valuemax="100"
aria-label="Array usage"></div>
</div>
<div class="meter-legend">
<span>{{ unraid.array_used }} used</span>
{% if unraid.disk_count %}<span>{{ unraid.disk_count }} disk{{ 's' if unraid.disk_count != 1 }}</span>{% endif %}
</div>
{% if unraid.source == "local" %}
<p class="empty" style="margin-bottom:0;">Container disk usage ({{ unraid.mount }}). Set <code>UNRAID_API_KEY</code> for full array stats.</p>
{% endif %}
{% else %}
{{ service_error("Unraid", unraid.error) }}
{% endif %}
</div>
</div>
</div>
</section>
<!-- ══ Library ══ -->
<section class="section" aria-labelledby="h-lib">
<h2 id="h-lib">Library</h2>
<div class="row cols-2">
<!-- Sonarr -->
<div class="card">
<div class="card-header">
<h3><a href="{{ service_urls.sonarr }}" target="_blank" rel="noopener">TV — Sonarr</a></h3>
</div>
<div class="card-body">
<div class="search-bar">
<label class="visually-hidden" for="sonarr-search-input">Search for a TV show</label>
<input type="text" id="sonarr-search-input" placeholder="Search and add a TV show…"
onkeydown="if(event.key==='Enter') searchSonarr()">
<button class="btn" id="sonarr-search-btn" onclick="searchSonarr()">Search</button>
</div>
<div id="sonarr-search-result" class="search-result">
<div class="search-result-info">
<div class="search-result-title" id="sonarr-result-title"></div>
<div class="search-result-meta" id="sonarr-result-meta"></div>
</div>
<button class="btn-add" id="sonarr-add-btn" onclick="confirmAddSonarr()">+ Add</button>
</div>
<div id="sonarr-search-msg" class="search-msg" style="display:none;" role="status"></div>
{% if sonarr.status == 'ok' %}
<dl class="stats">
<div><dt>Series</dt><dd>{{ sonarr.total_series }}</dd></div>
<div><dt>Missing episodes</dt><dd class="{{ 'is-warn' if sonarr.missing_episodes else '' }}">{{ sonarr.missing_episodes }}</dd></div>
</dl>
{% else %}
{{ service_error("Sonarr", sonarr.error) }}
{% endif %}
</div>
</div>
<!-- Radarr -->
<div class="card">
<div class="card-header">
<h3><a href="{{ service_urls.radarr }}" target="_blank" rel="noopener">Movies — Radarr</a></h3>
</div>
<div class="card-body">
<div class="search-bar">
<label class="visually-hidden" for="radarr-search-input">Search for a movie</label>
<input type="text" id="radarr-search-input" placeholder="Search and add a movie…"
onkeydown="if(event.key==='Enter') searchRadarr()">
<button class="btn" id="radarr-search-btn" onclick="searchRadarr()">Search</button>
</div>
<div id="radarr-search-result" class="search-result">
<div class="search-result-info">
<div class="search-result-title" id="radarr-result-title"></div>
<div class="search-result-meta" id="radarr-result-meta"></div>
</div>
<button class="btn-add" id="radarr-add-btn" onclick="confirmAddRadarr()">+ Add</button>
</div>
<div id="radarr-search-msg" class="search-msg" style="display:none;" role="status"></div>
{% if radarr.status == 'ok' %}
<dl class="stats">
<div><dt>Movies</dt><dd>{{ radarr.total_movies }}</dd></div>
<div><dt>Missing</dt><dd class="{{ 'is-warn' if radarr.missing_movies else '' }}">{{ radarr.missing_movies }}</dd></div>
</dl>
{% else %}
{{ service_error("Radarr", radarr.error) }}
{% endif %}
</div>
</div>
</div>
</section>
<!-- ══ Discover ══ -->
<section class="section" aria-labelledby="h-disc">
<h2 id="h-disc">Discover — not in your library yet</h2>
<div class="row cols-2">
<!-- Popular TV -->
<div class="card">
<div class="card-header"><h3>Popular TV on Rotten Tomatoes</h3></div>
<div class="card-body">
{% if rt_tv and rt_tv[0] is defined and rt_tv[0].error is defined %}
<p class="empty">Couldn't load TV recommendations.</p>
{% elif rt_tv %}
<div class="rt-list" id="rt-tv-list">
{% for show in rt_tv %}
<div class="rt-item {{ 'is-hidden' if loop.index > 5 }}" data-rt-title="{{ show.title }}">
<span class="rt-rank">{{ loop.index }}</span>
{% if show.poster %}<img class="rt-poster" src="{{ show.poster }}" alt="" loading="lazy">
{% else %}<span class="rt-poster"></span>{% endif %}
<div class="rt-info">
<div class="rt-title">{{ show.title }}{% if show.year %} <span class="rt-year">({{ show.year }})</span>{% endif %}</div>
<div class="rt-meta">
{% if show.certified %}<span class="score certified">Certified Fresh</span>
{% elif show.critics_score %}<span class="score critics">{{ show.critics_score }}% critics</span>{% endif %}
{% if show.audience_score %}<span class="score audience">{{ show.audience_score }} audience</span>{% endif %}
</div>
<p class="rt-desc">{{ show.description[:180] ~ '…' if show.description and show.description|length > 180 else (show.description or '') }}</p>
</div>
<button class="btn-add" data-title="{{ show.title }}" data-target="sonarr" onclick="armAdd(this)">+ Add</button>
</div>
{% endfor %}
</div>
{% if rt_tv | length > 5 %}
<button class="btn btn-quiet show-more" onclick="toggleMore(this, 'rt-tv-list')"
aria-expanded="false">Show {{ rt_tv | length - 5 }} more</button>
{% endif %}
{% else %}
<p class="empty">No TV recommendations available.</p>
{% endif %}
</div>
</div>
<!-- Top box office -->
<div class="card">
<div class="card-header"><h3>Top box office on Rotten Tomatoes</h3></div>
<div class="card-body">
{% if rt_movies and rt_movies[0] is defined and rt_movies[0].error is defined %}
<p class="empty">Couldn't load movie recommendations.</p>
{% elif rt_movies %}
<div class="rt-list" id="rt-movie-list">
{% for movie in rt_movies %}
<div class="rt-item {{ 'is-hidden' if loop.index > 5 }}" data-rt-title="{{ movie.title }}">
<span class="rt-rank">{{ loop.index }}</span>
{% if movie.poster %}<img class="rt-poster" src="{{ movie.poster }}" alt="" loading="lazy">
{% else %}<span class="rt-poster"></span>{% endif %}
<div class="rt-info">
<div class="rt-title">{{ movie.title }}{% if movie.year %} <span class="rt-year">({{ movie.year }})</span>{% endif %}</div>
<div class="rt-meta">
{% if movie.certified %}<span class="score certified">Certified Fresh</span>
{% elif movie.critics_score %}<span class="score critics">{{ movie.critics_score }}% critics</span>{% endif %}
{% if movie.audience_score %}<span class="score audience">{{ movie.audience_score }} audience</span>{% endif %}
</div>
<p class="rt-desc">{{ movie.description[:180] ~ '…' if movie.description and movie.description|length > 180 else (movie.description or '') }}</p>
</div>
<button class="btn-add" data-title="{{ movie.title }}" data-target="radarr" onclick="armAdd(this)">+ Add</button>
</div>
{% endfor %}
</div>
{% if rt_movies | length > 5 %}
<button class="btn btn-quiet show-more" onclick="toggleMore(this, 'rt-movie-list')"
aria-expanded="false">Show {{ rt_movies | length - 5 }} more</button>
{% endif %}
{% else %}
<p class="empty">No movie recommendations available.</p>
{% endif %}
</div>
</div>
</div>
</section>
<!-- ══ Requests + Indexers ══ -->
<section class="section" aria-labelledby="h-ops">
<h2 id="h-ops">Requests &amp; indexers</h2>
<div class="row cols-2 align-top">
<!-- Ombi -->
<div class="card">
<div class="card-header">
<h3><a href="{{ service_urls.ombi }}" target="_blank" rel="noopener">Requests — Ombi</a></h3>
{% if ombi.status == 'ok' %}
<span class="card-meta">{{ ombi.total_pending }} pending of {{ ombi.total_requests }}</span>
{% endif %}
</div>
<div class="card-body">
{% if ombi.status == 'ok' %}
{% if ombi.recent %}
<div class="req-list" id="req-list">
{% for req in ombi.recent %}
<div class="req-item {{ 'is-hidden' if loop.index > 5 }}">
<span class="badge {{ 'movie' if req.type == 'Movie' else 'tv' }}">{{ req.type }}</span>
<div class="req-info">
<div class="req-title">{{ req.title }}</div>
<div class="req-sub">{{ req.user }} · {{ req.date_display }}</div>
</div>
<span class="badge {{ req.status | lower }}">{{ req.status }}</span>
</div>
{% endfor %}
</div>
{% if ombi.recent | length > 5 %}
<button class="btn btn-quiet show-more" onclick="toggleMore(this, 'req-list')"
aria-expanded="false">Show {{ ombi.recent | length - 5 }} more</button>
{% endif %}
{% else %}
<p class="empty">No recent requests.</p>
{% endif %}
{% else %}
{{ service_error("Ombi", ombi.error) }}
{% endif %}
</div>
</div>
<!-- Prowlarr -->
<div class="card" id="prowlarr-card">
<div class="card-header">
<h3><a href="{{ service_urls.prowlarr }}" target="_blank" rel="noopener">Indexers — Prowlarr</a></h3>
{% if prowlarr.status == 'ok' %}
<span class="card-meta">
<span id="prowlarr-up-count">{{ prowlarr.up_count }}</span>/<span id="prowlarr-total">{{ prowlarr.total }}</span> online
</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>
</div>
</section>
<!-- ══ Insights ══ -->
{% if tautulli.status == 'ok' %}
<section class="section" aria-labelledby="h-ins">
<h2 id="h-ins">Watch insights — last 30 days</h2>
<div class="row cols-2">
<div class="card">
<div class="card-header"><h3>Most played</h3></div>
<div class="card-body">
{% if tautulli.top_played %}
<div class="top-list">
{% for item in tautulli.top_played %}
<div class="top-item">
<span class="top-rank">{{ loop.index }}</span>
<div class="top-info">
<div class="top-name">{{ item.title }}</div>
<div class="top-sub">{{ item.user }}</div>
</div>
{% set mt = item.media_type | lower %}
<span class="badge {{ 'tv' if mt == 'episode' else 'movie' }}">{{ 'TV' if mt == 'episode' else ('Movie' if mt == 'movie' else mt or '?') }}</span>
<span class="top-plays">{{ item.plays }}×</span>
</div>
{% endfor %}
</div>
{% else %}
<p class="empty">No play history yet.</p>
{% endif %}
</div>
</div>
<div class="card">
<div class="card-header"><h3>Top viewers</h3></div>
<div class="card-body">
{% if tautulli.top_users %}
<div class="top-list">
{% for u in tautulli.top_users %}
<div class="top-item">
<span class="top-rank">{{ loop.index }}</span>
<div class="top-info">
<div class="top-name">{{ u.username }}</div>
</div>
<span class="top-plays">{{ u.plays }} plays</span>
</div>
{% endfor %}
</div>
{% else %}
<p class="empty">No viewer data yet.</p>
{% endif %}
</div>
</div>
</div>
{% if tautulli.top_tv_list or tautulli.top_movie_list %}
<div class="row cols-2" style="margin-top: var(--gap);">
<div class="card">
<div class="card-header"><h3>Top TV</h3></div>
<div class="card-body">
{% if tautulli.top_tv_list %}
<div class="top-list">
{% for item in tautulli.top_tv_list %}
<div class="top-item">
<span class="top-rank">{{ loop.index }}</span>
<div class="top-info"><div class="top-name">{{ item.title }}</div></div>
<span class="top-plays">{{ item.plays }} plays</span>
</div>
{% endfor %}
</div>
{% else %}<p class="empty">No data yet.</p>{% endif %}
</div>
</div>
<div class="card">
<div class="card-header"><h3>Top movies</h3></div>
<div class="card-body">
{% if tautulli.top_movie_list %}
<div class="top-list">
{% for item in tautulli.top_movie_list %}
<div class="top-item">
<span class="top-rank">{{ loop.index }}</span>
<div class="top-info"><div class="top-name">{{ item.title }}</div></div>
<span class="top-plays">{{ item.plays }} plays</span>
</div>
{% endfor %}
</div>
{% else %}<p class="empty">No data yet.</p>{% endif %}
</div>
</div>
</div>
{% endif %}
</section>
{% endif %}
</main>
<footer>Media Centre</footer>
<script>
const URL_BASE = "";
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'
});
} catch (e) {
el.textContent = new Date().toLocaleTimeString();
}
}
updateClock();
setInterval(updateClock, 1000);
/* ── Discover: show more / show less ── */
function toggleMore(btn, listId) {
const list = document.getElementById(listId);
if (!list) return;
const items = Array.from(list.children);
const hidden = items.filter(el => el.classList.contains('is-hidden'));
if (hidden.length) {
hidden.forEach(el => el.classList.remove('is-hidden'));
btn.textContent = 'Show less';
btn.setAttribute('aria-expanded', 'true');
} else {
items.forEach((el, i) => {
if (i >= 5) el.classList.add('is-hidden');
});
const n = items.length - 5;
btn.textContent = 'Show ' + n + ' more';
btn.setAttribute('aria-expanded', 'false');
}
}
/* ── Add flow: first tap arms, second tap commits ──
Adding queues a real download, so a single stray tap shouldn't do it. */
let armedBtn = null;
let armedTimer = null;
function disarm() {
if (armedBtn) {
armedBtn.classList.remove('armed');
armedBtn.textContent = '+ Add';
armedBtn = null;
}
clearTimeout(armedTimer);
}
function armAdd(btn) {
if (armedBtn === btn) { disarm(); return doAdd(btn); }
disarm();
armedBtn = btn;
btn.classList.add('armed');
btn.textContent = 'Confirm?';
armedTimer = setTimeout(disarm, 4000);
}
function removeRtItem(btn) {
const item = btn.closest('.rt-item');
if (!item) return;
item.classList.add('removing');
setTimeout(() => {
const list = item.parentElement;
item.remove();
if (list) {
let n = 0;
list.querySelectorAll('.rt-item').forEach(el => {
el.querySelector('.rt-rank').textContent = ++n;
});
}
}, 320);
}
async function doAdd(btn) {
const title = btn.dataset.title;
const target = btn.dataset.target; // 'sonarr' | 'radarr'
btn.disabled = true;
btn.textContent = 'Adding…';
try {
const resp = await fetch(URL_BASE + '/add-to-' + target, {
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.disabled = false;
btn.textContent = '+ Add';
alert('Failed to add "' + title + '": ' + (data.error || 'Unknown error'));
}
} catch (e) {
btn.disabled = false;
btn.textContent = '+ Add';
alert('Network error: ' + e.message);
}
}
// A tap anywhere else cancels a pending confirm.
document.addEventListener('click', e => {
if (armedBtn && !e.target.closest('.btn-add')) disarm();
});
/* ── SABnzbd live polling (never cached server-side) ── */
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');
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;
if (sabBadge) {
const s = (d.sab_status || '').toLowerCase();
sabBadge.className = 'badge ' + (s === 'downloading' ? 'downloading' : s === 'paused' ? 'paused' : 'idle');
sabBadge.textContent = d.sab_status;
}
const active = (d.sab_status || '').toLowerCase() === 'downloading'
&& d.timeleft && d.timeleft !== '0:00:00';
if (sabTimeleftRow) sabTimeleftRow.style.display = active ? 'inline' : 'none';
if (sabTimeleft) sabTimeleft.textContent = d.timeleft;
const pill = document.getElementById('svc-stat-sabnzbd');
if (pill) pill.textContent = d.speed;
} catch (e) { /* transient network error — try again next tick */ }
}
pollSabnzbd();
setInterval(pollSabnzbd, 3000);
/* ── RT descriptions arrive asynchronously from IMDB ── */
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 el = item.querySelector('.rt-desc');
if (!el || el.textContent.trim()) continue; // already filled
el.textContent = desc.length > 180 ? desc.slice(0, 180) + '…' : desc;
}
if (d.pending) setTimeout(loadRtDescriptions, 4000);
} catch (e) { /* silently skip */ }
}
setTimeout(loadRtDescriptions, 1500);
/* ── Prowlarr indexer 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 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;
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;
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); }
});
updateServicePill('prowlarr', d.up_count === d.total && d.total > 0);
const stat = document.getElementById('svc-stat-prowlarr');
if (stat) stat.textContent = d.up_count + '/' + d.total + ' up';
} catch (e) { /* silently skip */ }
}
pollProwlarr();
setInterval(pollProwlarr, 30000);
/* ── Header service lights ── */
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 */ }
}
pollServices();
setInterval(pollServices, 60000);
/* ── Tautulli live streams ── */
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 || [];
const stat = document.getElementById('svc-stat-tautulli');
if (stat) stat.textContent = count > 0 ? count + ' streaming' : '0 streams';
const countEl = document.getElementById('tautulli-count');
if (countEl) countEl.textContent = count + ' active';
const body = document.getElementById('now-playing');
if (!body) return;
if (sessions.length) {
body.innerHTML = sessions.map(s => {
const icon = s.media_type === 'movie' ? '🎬' : '📺';
const esc = t => String(t).replace(/[&<>"']/g, c =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
return `<div class="stream">
<span class="live-dot"></span>
<span class="stream-icon" aria-hidden="true">${icon}</span>
<span class="stream-info">
<span class="stream-title">${esc(s.title)}</span>
<span class="stream-user">${esc(s.user)}</span>
</span>
</div>`;
}).join('');
} else {
body.innerHTML = '<p class="empty">Nothing streaming right now.</p>';
}
} catch (e) { /* silently skip */ }
}
pollTautulli();
setInterval(pollTautulli, 15000);
/* ── Clear search results when the field is emptied ── */
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;
}
});
/* ── 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 done';
} else {
addBtn.textContent = '+ Add'; addBtn.disabled = false; addBtn.className = 'btn-add';
}
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 done';
} 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 done';
} else {
addBtn.textContent = '+ Add'; addBtn.disabled = false; addBtn.className = 'btn-add';
}
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 done';
} else {
addBtn.disabled = false; addBtn.textContent = '+ Add';
alert('Failed: ' + (d.error || 'Unknown error'));
}
} catch (e) { addBtn.disabled = false; addBtn.textContent = '+ Add'; }
}
</script>
</body>
</html>