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; }