All checks were successful
Deploy / deploy (push) Successful in 21s
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>
1557 lines
60 KiB
HTML
1557 lines
60 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Media Centre</title>
|
||
<link rel="icon" href="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 & 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 =>
|
||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>
|