From 24710af156c8137ff1612b25ef850625090881a4 Mon Sep 17 00:00:00 2001 From: Gmer4Lfe Date: Fri, 14 Aug 2026 19:23:10 -0400 Subject: [PATCH] Add media server cards, and stop re-counting whole libraries every minute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The counts need the entire library downloaded to compute six numbers — 16.0 MB from Radarr and 5.5 MB from Sonarr, measured — and they were on the same one-minute clock as queue depth and health, which pulled ~30 GB a day out of the arrs to re-count records that had not changed. They now cache for fifteen minutes against how often the numbers actually move: 7.6s to 3.0s per refresh. Emby and Jellyfin get a card each: version, CPU, memory, uptime, streams, users and transcodes, with update/restart flags. Two halves of one question — a server answering happily at 145% CPU is a different situation from one at 8%, and neither the app nor the container says so alone. --- Plugin/unraid/include/arrs.php | 82 ++++++++++++++++-------- Plugin/unraid/include/media.php | 109 ++++++++++++++++++++++++++++++++ Plugin/unraid/pages/arrs.php | 89 ++++++++++++++++++++++++++ 3 files changed, 255 insertions(+), 25 deletions(-) diff --git a/Plugin/unraid/include/arrs.php b/Plugin/unraid/include/arrs.php index e8bddcf..a11e12e 100644 --- a/Plugin/unraid/include/arrs.php +++ b/Plugin/unraid/include/arrs.php @@ -112,6 +112,60 @@ function vv_arr_http(string $url, string $apiKey, string $path, int $timeout = 4 // ── Live arr data ───────────────────────────────────────────────────────────── +// ── Library counts, on their own clock ──────────────────────────────────────── +// Six numbers — total, monitored, and the file/episode/album count — and the only way to get them +// from these APIs is to download the entire library and count it. Measured against this install: +// 16.0 MB from Radarr and 5.5 MB from Sonarr, per fetch. +// +// The rest of this payload refreshes every minute because queue depth and health genuinely change +// that fast. These do not: a library gains items a few times a day. Refreshing them on the same +// clock pulled ~21.5 MB out of the arrs every sixty seconds — about 30 GB a day — to re-count +// records that had not changed. The page cache hid it perfectly, which is why it ran that way for +// as long as it did: the cost fell entirely on the background writer and on the arrs themselves. +// +// Fifteen minutes is chosen against how often the numbers move, not how fresh they could be. A +// library count that is ten minutes stale has never been the wrong answer to anything, and the +// discovery scripts that add items run far less often than that. +// +// Keyed per instance URL so two hosts' Sonarrs never share an entry, and only written on success — +// caching an empty result would keep an arr looking empty for fifteen minutes after it came back. +function vv_arr_library_counts(array $arr): array { + $ck = 'arr_counts_' . $arr['type'] . '_' . substr(sha1($arr['url']), 0, 10); + $hit = vv_cache_read($ck, 900); + if ($hit !== null) return $hit; + + $base = '/api/' . $arr['api']; + $out = []; + + if ($arr['type'] === 'sonarr') { + $data = vv_arr_http($arr['url'], $arr['key'], "$base/series", 20); + if (is_array($data)) { + $out['total'] = count($data); + $out['monitored'] = count(array_filter($data, fn($x) => !empty($x['monitored']))); + $out['episodes'] = array_sum(array_map( + fn($x) => $x['statistics']['episodeFileCount'] ?? $x['episodeFileCount'] ?? 0, $data)); + } + } elseif ($arr['type'] === 'radarr') { + $data = vv_arr_http($arr['url'], $arr['key'], "$base/movie", 20); + if (is_array($data)) { + $out['total'] = count($data); + $out['monitored'] = count(array_filter($data, fn($x) => !empty($x['monitored']))); + $out['files'] = count(array_filter($data, fn($x) => !empty($x['hasFile']))); + } + } elseif ($arr['type'] === 'lidarr') { + $data = vv_arr_http($arr['url'], $arr['key'], "$base/artist", 20); + if (is_array($data)) { + $out['total'] = count($data); + $out['monitored'] = count(array_filter($data, fn($x) => !empty($x['monitored']))); + $out['albums'] = array_sum(array_map( + fn($a) => $a['statistics']['albumCount'] ?? $a['albumCount'] ?? 0, $data)); + } + } + + if ($out) vv_cache_write($ck, $out); + return $out; +} + function vv_fetch_arr_live(array $arr): array { $url = $arr['url']; $key = $arr['key']; @@ -126,30 +180,7 @@ function vv_fetch_arr_live(array $arr): array { $out['online'] = true; $out['version'] = $sys['version'] ?? null; - if ($type === 'sonarr') { - $data = vv_arr_http($url, $key, "$base/series"); - if (is_array($data)) { - $out['total'] = count($data); - $out['monitored'] = count(array_filter($data, fn($x) => !empty($x['monitored']))); - $out['episodes'] = array_sum(array_map( - fn($x) => $x['statistics']['episodeFileCount'] ?? $x['episodeFileCount'] ?? 0, $data)); - } - } elseif ($type === 'radarr') { - $data = vv_arr_http($url, $key, "$base/movie"); - if (is_array($data)) { - $out['total'] = count($data); - $out['monitored'] = count(array_filter($data, fn($x) => !empty($x['monitored']))); - $out['files'] = count(array_filter($data, fn($x) => !empty($x['hasFile']))); - } - } elseif ($type === 'lidarr') { - $data = vv_arr_http($url, $key, "$base/artist"); - if (is_array($data)) { - $out['total'] = count($data); - $out['monitored'] = count(array_filter($data, fn($x) => !empty($x['monitored']))); - $out['albums'] = array_sum(array_map( - fn($a) => $a['statistics']['albumCount'] ?? $a['albumCount'] ?? 0, $data)); - } - } + $out += vv_arr_library_counts($arr); $q = vv_arr_http($url, $key, "$base/queue?page=1&pageSize=500"); if (is_array($q)) { @@ -454,7 +485,8 @@ function vv_arrs_all(): array { // The three media jobs that operate on the same files the arrs manage and had no tab of // their own. Cheap — three run records and three log tails — and it rides this payload // rather than a fourth endpoint because it is shown on this page and cached with it. - 'media_jobs' => function_exists('vv_media_jobs') ? vv_media_jobs() : [], + 'media_jobs' => function_exists('vv_media_jobs') ? vv_media_jobs() : [], + 'media_servers' => function_exists('vv_media_server_stats') ? vv_media_server_stats() : [], 'host' => $currentHost, 'settings' => [ 'arr_sync_enabled' => ($vars['ARR_SYNC_ENABLED'] ?? 'true') !== 'false', diff --git a/Plugin/unraid/include/media.php b/Plugin/unraid/include/media.php index a09426f..dea24a4 100644 --- a/Plugin/unraid/include/media.php +++ b/Plugin/unraid/include/media.php @@ -258,6 +258,115 @@ function vv_media_sessions(): array { ]; } +// ── What each media server is actually doing ────────────────────────────────────────────────── +// Two halves that only mean something together. The application knows its version, whether an +// update is waiting and who is watching; the container knows what that is costing in CPU, memory +// and how long it has been up. Neither half can answer "is Emby healthy" on its own — a server +// answering happily at 130% CPU is a different situation from one answering happily at 5%. +// +// Cost is one scoped `docker stats` (~2s for two containers, not the ~8s an unscoped call takes), +// one inspect, and one HTTP call per server. That rides the arrs payload's cache rather than being +// paid per page load — the background writer refreshes it every minute, so the CPU figure is at +// most a minute stale, which is honest for a card rather than a live graph. Monitor is where live +// belongs. +function vv_media_container_stats(array $names): array { + $names = array_values(array_filter($names)); + if (!$names) return []; + + $out = []; + $args = implode(' ', array_map('escapeshellarg', $names)); + $fmt = '{{.Name}}|{{.CPUPerc}}|{{.MemUsage}}|{{.MemPerc}}'; + $raw = shell_exec("docker stats --no-stream --format " . escapeshellarg($fmt) . " $args 2>/dev/null") ?: ''; + foreach (explode("\n", trim($raw)) as $line) { + $p = explode('|', $line); + if (count($p) < 4) continue; + [$mUsed, $mLimit] = array_pad(array_map('trim', explode('/', $p[2])), 2, ''); + $out[$p[0]] = [ + 'cpu_pct' => (float)rtrim($p[1], '%'), + 'mem_used' => $mUsed, + 'mem_limit' => $mLimit, + 'mem_pct' => (float)rtrim($p[3], '%'), + ]; + } + + // Uptime separately: docker stats does not carry it, and StartedAt is the only thing that + // distinguishes "up for a week" from "restarted four minutes ago", which is the first thing + // worth knowing about a server that is behaving oddly. + $insp = shell_exec("docker inspect -f '{{.Name}}|{{.State.StartedAt}}|{{.State.Running}}' $args 2>/dev/null") ?: ''; + foreach (explode("\n", trim($insp)) as $line) { + $p = explode('|', $line); + if (count($p) < 3) continue; + $n = ltrim(trim($p[0]), '/'); + $started = strtotime(trim($p[1])) ?: 0; + $out[$n]['uptime'] = $started ? max(0, time() - $started) : null; + $out[$n]['running'] = trim($p[2]) === 'true'; + } + return $out; +} + +// Emby and Jellyfin both answer /System/Info with the same field names — they share an ancestor — +// so one shape covers both. Plex does not, and is deliberately left reporting only what the +// session layer already knows about it rather than guessing at an equivalent. +function vv_media_server_info(array $srv): array { + $hdr = $srv['type'] === 'jellyfin' + ? "X-Emby-Token: {$srv['key']}\r\n" + : "X-Emby-Token: {$srv['key']}\r\n"; + $ctx = stream_context_create(['http' => [ + 'timeout' => 4, 'header' => $hdr . "Accept: application/json\r\n", 'ignore_errors' => true, + ]]); + $raw = @file_get_contents(rtrim($srv['url'], '/') . '/System/Info', false, $ctx); + $d = $raw ? json_decode($raw, true) : null; + if (!is_array($d)) return ['online' => false]; + + return [ + 'online' => true, + 'version' => $d['Version'] ?? null, + 'server_name' => $d['ServerName'] ?? null, + 'os' => $d['OperatingSystemDisplayName'] ?? ($d['OperatingSystem'] ?? null), + 'update_available' => !empty($d['HasUpdateAvailable']), + 'pending_restart' => !empty($d['HasPendingRestart']), + 'maintenance' => !empty($d['IsInMaintenanceMode']), + ]; +} + +function vv_media_server_stats(): array { + $servers = vv_discover_media_servers(); + if (!$servers) return []; + + $vars = vv_conf_vars(); + $myId = strtoupper(vv_detect_host()); + + // Container name comes from conf, not from the server's own name: HOST1_EMBY_CONTAINER is what + // docker answers to, and the two are routinely different — this one calls itself + // "Emby-Gmer4Lfe" and runs in a container called "Emby". + $ctrOf = []; + foreach ($servers as $s) { + $ctrOf[$s['name']] = $vars[$myId . '_' . strtoupper($s['type']) . '_CONTAINER'] ?? ''; + } + $stats = vv_media_container_stats(array_values($ctrOf)); + + // One session fetch for everything, then split by server — asking each server separately would + // duplicate work the session layer already does for the whole estate. + $sess = vv_media_sessions()['sessions'] ?? []; + + $out = []; + foreach ($servers as $s) { + $mine = array_values(array_filter($sess, fn($x) => ($x['server'] ?? '') === $s['name'])); + $users = array_values(array_unique(array_filter(array_column($mine, 'user')))); + $ctr = $ctrOf[$s['name']] ?? ''; + $out[] = [ + 'name' => $s['name'], + 'type' => $s['type'], + 'container' => $ctr, + 'sessions' => count($mine), + 'users' => count($users), + 'transcodes'=> count(array_filter($mine, fn($x) => !empty($x['is_tc']))), + 'checked' => time(), + ] + vv_media_server_info($s) + ($stats[$ctr] ?? []); + } + return $out; +} + // ── The media jobs that had nowhere to be seen ──────────────────────────────────────────────── // Three scripts that operate on the media files every day and appeared in no tab: play state sync // every thirty minutes, permissions and the cleaner nightly. The only way to know whether any of diff --git a/Plugin/unraid/pages/arrs.php b/Plugin/unraid/pages/arrs.php index d01ab3c..d1a889e 100644 --- a/Plugin/unraid/pages/arrs.php +++ b/Plugin/unraid/pages/arrs.php @@ -105,6 +105,13 @@ require_once dirname(__DIR__) . '/include/ai_chat.php'; .vv-arr-pill.ok { background:#0d1f0d;color:#4caf50;border:1px solid #1a3a1a; } .vv-arr-pill.err { background:#200d0d;color:#ef5350;border:1px solid #3a1a1a; } .vv-arr-pill.off { background:#1e1e1e;color:#444;border:1px solid #2a2a2a; } + +/* ── Media server filter ─────────────────────────────────── */ +.vv-arr-mstab { background:#111;border:1px solid #242424;color:#555;font-size:10px; + padding:2px 10px;border-radius:3px;cursor:pointer;text-transform:uppercase; + letter-spacing:.05em; } +.vv-arr-mstab:hover { color:#bbb;border-color:#3a3a3a; } +.vv-arr-mstab.on { color:#4caf50;border-color:#2d4a2d;background:#0d1a0d; }
@@ -386,6 +393,84 @@ function _dur(s) { return sec + 's'; } +// ── Media servers ───────────────────────────────────────────────────────────── +// Two halves of one question. The application supplies its version, whether an update or a restart +// is waiting, and who is watching; the container supplies what that costs in CPU, memory and how +// long it has been up. A server answering happily at 145% CPU is a different situation from one +// answering happily at 8%, and neither half says so alone. +let _vvMsFilter = 'all'; + +function _mediaServersSection(servers) { + if (!servers || !servers.length) return ''; + + const types = [...new Set(servers.map(s => s.type))]; + // The filter only earns its space when there is something to filter. One server means the + // buttons would be three ways of saying the same thing. + const tabs = types.length > 1 + ? `
` + + [['all', 'All'], ...types.map(t => [t, t.charAt(0).toUpperCase() + t.slice(1)])].map(([k, lbl]) => + `` + ).join('') + `
` + : ''; + + const shown = servers.filter(s => _vvMsFilter === 'all' || s.type === _vvMsFilter); + + const cards = shown.map(s => { + const up = !!s.online; + const dot = !up ? '#c62828' : s.pending_restart || s.update_available ? '#ffb74d' : '#4caf50'; + // Colour the CPU against cores, not against 100%: these are containers on a 16-core host, so + // 145% is busy rather than broken, and 1500% would be the number that matters. + const cpu = s.cpu_pct; + const cpuCol = cpu == null ? '#555' : cpu > 400 ? '#ef5350' : cpu > 150 ? '#ffb74d' : '#888'; + + const flags = []; + if (s.update_available) flags.push(['update available', '#ffb74d']); + if (s.pending_restart) flags.push(['restart pending', '#ffb74d']); + if (s.maintenance) flags.push(['maintenance mode', '#ef5350']); + + const row = (l, v) => `
${l}${v}
`; + + return `
+
+ ${vvEscHtml(s.name)} + + ${vvEscHtml(up ? (s.version || 'online') : 'offline')} +
+ ${up ? ` +
+
${_n(s.sessions)}
Streams
+
${_n(s.users)}
Users
+
${_n(s.transcodes)}
Transcode
+
+ ${row('CPU', `${cpu == null ? '—' : cpu.toFixed(1) + '%'}`)} + ${row('Memory', vvEscHtml(s.mem_used || '—') + (s.mem_pct != null ? ` ${s.mem_pct.toFixed(1)}%` : ''))} + ${row('Uptime', s.uptime != null ? _uptime(s.uptime) : '—')} + ${s.os ? row('Host OS', vvEscHtml(s.os)) : ''} + ${flags.length ? `
` + + flags.map(([t, c]) => `${t}`).join('') + + `
` : ''} +
checked ${_relTime(s.checked)}
+ ` : `
Did not answer — ${vvEscHtml(s.container || 'container')} may be down
`} +
`; + }).join(''); + + return `
+
Media servers
+ ${tabs} +
${cards}
+
`; +} + +// Delegated, because the row is rebuilt on every poll and per-button handlers would be rebound +// constantly. Re-renders from the payload already in hand rather than refetching — the filter is +// a view over data the page has, not a new question for the server. +document.addEventListener('click', ev => { + const b = ev.target.closest('.vv-arr-mstab'); + if (!b) return; + _vvMsFilter = b.dataset.mstab || 'all'; + if (_vvArrsLast) _render(_vvArrsLast); +}); + // ── Media jobs ──────────────────────────────────────────────────────────────── // Play state sync, permissions and the cleaner. They work on the same files the arrs manage and // appeared in no tab at all — the only way to know whether one had run was to open the Scheduler @@ -473,7 +558,10 @@ function _settingsCard(s) { } // ── Main render ─────────────────────────────────────────────────────────────── +let _vvArrsLast = null; + function _render(data) { + _vvArrsLast = data; const nodes = data.nodes || []; if (!nodes.length && !(data.sync?.last_run) && !(data.recovery?.last_run)) { document.getElementById('vv-arrs-grid').innerHTML = @@ -486,6 +574,7 @@ function _render(data) { let html = ''; for (const node of nodes) html += _nodeSection(node); html += _syncSection(data.sync || {}, data.recovery || {}, data.settings); + html += _mediaServersSection(data.media_servers); html += _mediaJobsSection(data.media_jobs); html += _settingsCard(data.settings);