Add media server cards, and stop re-counting whole libraries every minute

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.
This commit is contained in:
Gmer4Lfe
2026-08-14 19:23:10 -04:00
parent e149046272
commit 24710af156
3 changed files with 255 additions and 25 deletions
+57 -25
View File
@@ -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',
+109
View File
@@ -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