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:
@@ -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)) {
|
||||
@@ -455,6 +486,7 @@ function vv_arrs_all(): array {
|
||||
// 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_servers' => function_exists('vv_media_server_stats') ? vv_media_server_stats() : [],
|
||||
'host' => $currentHost,
|
||||
'settings' => [
|
||||
'arr_sync_enabled' => ($vars['ARR_SYNC_ENABLED'] ?? 'true') !== 'false',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
</style>
|
||||
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;padding:0 2px;">
|
||||
@@ -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
|
||||
? `<div style="display:flex;gap:4px;margin-bottom:8px;">` +
|
||||
[['all', 'All'], ...types.map(t => [t, t.charAt(0).toUpperCase() + t.slice(1)])].map(([k, lbl]) =>
|
||||
`<button class="vv-arr-mstab${_vvMsFilter === k ? ' on' : ''}" data-mstab="${vvEscAttr(k)}">${vvEscHtml(lbl)}</button>`
|
||||
).join('') + `</div>`
|
||||
: '';
|
||||
|
||||
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) => `<div class="vv-arr-meta"><span class="vv-arr-meta-lbl">${l}</span><span class="vv-arr-meta-val">${v}</span></div>`;
|
||||
|
||||
return `<div class="vv-arr-card">
|
||||
<div class="vv-arr-hdr">
|
||||
<span class="vv-arr-name">${vvEscHtml(s.name)}</span>
|
||||
<span><span class="vv-arr-dot" style="background:${dot}"></span>
|
||||
<span class="vv-arr-ver">${vvEscHtml(up ? (s.version || 'online') : 'offline')}</span></span>
|
||||
</div>
|
||||
${up ? `
|
||||
<div class="vv-arr-stats">
|
||||
<div class="vv-arr-stat"><div class="vv-arr-stat-n${s.sessions ? '' : ' dim'}">${_n(s.sessions)}</div><div class="vv-arr-stat-l">Streams</div></div>
|
||||
<div class="vv-arr-stat"><div class="vv-arr-stat-n${s.users ? '' : ' dim'}">${_n(s.users)}</div><div class="vv-arr-stat-l">Users</div></div>
|
||||
<div class="vv-arr-stat"><div class="vv-arr-stat-n${s.transcodes ? '' : ' dim'}">${_n(s.transcodes)}</div><div class="vv-arr-stat-l">Transcode</div></div>
|
||||
</div>
|
||||
${row('CPU', `<span style="color:${cpuCol}">${cpu == null ? '—' : cpu.toFixed(1) + '%'}</span>`)}
|
||||
${row('Memory', vvEscHtml(s.mem_used || '—') + (s.mem_pct != null ? ` <span style="color:#3a3a3a">${s.mem_pct.toFixed(1)}%</span>` : ''))}
|
||||
${row('Uptime', s.uptime != null ? _uptime(s.uptime) : '—')}
|
||||
${s.os ? row('Host OS', vvEscHtml(s.os)) : ''}
|
||||
${flags.length ? `<div style="display:flex;gap:4px;flex-wrap:wrap;margin-top:6px;">` +
|
||||
flags.map(([t, c]) => `<span class="vv-arr-pill" style="color:${c};border:1px solid ${c}33;background:${c}14;">${t}</span>`).join('') +
|
||||
`</div>` : ''}
|
||||
<div style="font-size:9px;color:#2a2a2a;margin-top:6px;">checked ${_relTime(s.checked)}</div>
|
||||
` : `<div style="font-size:11px;color:#444;padding:6px 0;">Did not answer — ${vvEscHtml(s.container || 'container')} may be down</div>`}
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
return `<div style="grid-column:1/-1;">
|
||||
<div style="font-size:11px;font-weight:700;color:#555;text-transform:uppercase;letter-spacing:.07em;margin-bottom:8px;">Media servers</div>
|
||||
${tabs}
|
||||
<div class="vv-arr-cards">${cards}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user