Files
Varaverk/Plugin/unraid/include/arrs.php
T
Gmer4Lfe 04a0dcb39a Let the media server card show the whole mesh, not just this host
A partner's Emby URL is http://localhost:8096 — true there, meaningless here —
so nobody queries a partner's media server directly. Each host reports its own
over the SSH call remote_arr_cache_writer.sh already makes, which keeps its API
keys on it and costs no extra connection. Local is the default: opening the tab
to check your own server should not first make you read past a partner's.
A partner with no cache is stated as not collected rather than omitted, because
an empty mesh view and a partner that is down look identical otherwise.
2026-08-14 19:30:23 -04:00

506 lines
23 KiB
PHP

<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Arr data layer for the arrs page. Discovers every Sonarr / Radarr / Lidarr instance
// declared in the host confs, fetches live library counts and queue state from each, and
// attaches the cleanup / discovery / sync / recovery statistics the scripts have recorded.
//
// DESIGN PRINCIPLES
// Each host builds its own payload; nobody queries a partner's arrs.
// vv_arrs_local_node() is what remote_arr_cache_writer.sh invokes over SSH on the
// partner, so the partner assembles its own node using its own local URLs and keys.
// This host therefore never holds credentials for a remote's arrs, and no path mapping
// between hosts is involved.
//
// Live for local, cache for remote.
// vv_arrs_all() calls the local node live and reads remote nodes from the JSON the
// cache writer left behind. Cross-host work happens on a 2h timer, never in a page load.
//
// Instances come from conf, not from probing.
// vv_discover_arrs() enumerates what the host confs declare. An arr that exists but is
// not configured is intentionally invisible — conf is the source of truth.
//
// OPERATIONAL SAFEGUARDS
// Every arr HTTP call is time-boxed.
// vv_arr_http() defaults to a 4s timeout and returns null on any failure. One
// unreachable instance costs four seconds, not the page.
//
// A missing remote cache is reported, not faked.
// No cache file yields an explicit cache_miss => true with an empty arrs list, so the
// page can say "not yet collected" rather than implying the partner has no libraries.
//
// Cached remote nodes carry their own age.
// cache_age is attached to every cached node so the UI can show staleness instead of
// presenting 2-hour-old counts as current.
//
// Read-only. Statistics are parsed from the databases the scripts write; nothing here
// triggers a scan, cleanup, or import.
//
// EXPORTS
// Discovery vv_discover_arrs(), vv_arr_known_hosts(), vv_arr_node_names()
// Fetch vv_arr_http(), vv_fetch_arr_live()
// Statistics vv_arr_cleanup_stats(), vv_arr_discovery_stats(), vv_arr_sync_stats(),
// vv_arr_recovery_stats()
// Assembly vv_arrs_local_node() ← called over SSH by remote_arr_cache_writer.sh
// vv_arrs_all() ← local live + remote cached
//
// CONFIGURATION
// HOST*_SONARR_URL / _RADARR_URL / _LIDARR_URL per-instance endpoints
// HOST*_SONARR_API_KEY / _RADARR_API_KEY / _LIDARR_API_KEY per-instance keys
// HOST*_SONARR_TV_ROOT / _RADARR_MOVIES_ROOT / _LIDARR_MUSIC_ROOT
// ARR_SYNC_SONARR_PORT / _RADARR_PORT / _LIDARR_PORT used to reach partner instances
// DATA_DIR arr_cleanup_stats.db, arr_recovery_stats.db, <type>_discovery_history.db
// VV_CACHE_DIR arrs_remote_<host>.json — written by remote_arr_cache_writer.sh
// ═══════════════════════════════════════════════════════════════════════════════════════════════
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/media.php'; // vv_media_jobs() — the media-side jobs shown on this page
// ── Conf helpers ──────────────────────────────────────────────────────────────
function vv_arr_scalar(string $raw, string $key): string {
return vv_parse_conf_scalar($raw, $key);
}
function vv_arr_known_hosts(): array {
return vv_known_hosts();
}
function vv_arr_node_names(): array {
return vv_arr_known_hosts();
}
// ── Discovery ─────────────────────────────────────────────────────────────────
function vv_discover_arrs(): array {
$nodes = [];
$defs = [
'sonarr' => ['SONARR_URL', 'SONARR_API_KEY', 'SONARR_TV_ROOT', 'v3'],
'radarr' => ['RADARR_URL', 'RADARR_API_KEY', 'RADARR_MOVIES_ROOT', 'v3'],
'lidarr' => ['LIDARR_URL', 'LIDARR_API_KEY', 'LIDARR_MUSIC_ROOT', 'v1'],
];
foreach (array_keys(vv_arr_known_hosts()) as $h) {
$raw = vv_read_conf_raw($h . '.conf');
if (!$raw) continue;
$pfx = strtoupper($h) . '_';
$get = fn($k) => vv_arr_scalar($raw, $pfx . $k);
$arrs = [];
foreach ($defs as $type => [$uk, $ak, $rk, $api]) {
$url = $get($uk);
$key = $get($ak);
if ($url && $key && !str_contains($key, 'your-')) {
$arrs[] = ['type' => $type, 'url' => $url, 'key' => $key,
'root' => $get($rk), 'api' => $api];
}
}
if ($arrs) $nodes[] = ['host' => $h, 'arrs' => $arrs];
}
return $nodes;
}
// ── HTTP ──────────────────────────────────────────────────────────────────────
function vv_arr_http(string $url, string $apiKey, string $path, int $timeout = 4): ?array {
$ctx = stream_context_create(['http' => [
'timeout' => $timeout,
'header' => "X-Api-Key: $apiKey\r\nAccept: application/json\r\n",
'ignore_errors' => true,
]]);
$raw = @file_get_contents(rtrim($url, '/') . $path, false, $ctx);
return $raw ? (json_decode($raw, true) ?: null) : null;
}
// ── 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'];
$base = '/api/' . $arr['api'];
$type = $arr['type'];
$out = ['online' => false, 'version' => null, 'health' => [],
'queue' => ['dl' => 0, 'warn' => 0, 'err' => 0], 'disk' => []];
$sys = vv_arr_http($url, $key, "$base/system/status");
if (!$sys) return $out;
$out['online'] = true;
$out['version'] = $sys['version'] ?? null;
$out += vv_arr_library_counts($arr);
$q = vv_arr_http($url, $key, "$base/queue?page=1&pageSize=500");
if (is_array($q)) {
foreach (($q['records'] ?? $q) as $r) {
if (!is_array($r)) continue;
$s = $r['status'] ?? '';
$tds = strtolower($r['trackedDownloadStatus'] ?? '');
$tst = strtolower($r['trackedDownloadState'] ?? '');
if ($s === 'downloading') $out['queue']['dl']++;
if ($tds === 'warning' || $tst === 'downloadingstalled') $out['queue']['warn']++;
if ($tds === 'error') $out['queue']['err']++;
}
}
$h = vv_arr_http($url, $key, "$base/health");
if (is_array($h)) $out['health'] = $h;
$d = vv_arr_http($url, $key, "$base/diskspace");
if (is_array($d)) $out['disk'] = $d;
return $out;
}
// ── Log stats ─────────────────────────────────────────────────────────────────
// Bytes → a short human string, matching the shape the log SUMMARY already produces ("1.4G") so
// the two sources of orphans_sz read alike rather than one saying "1.4G" and the other "1.4 GB".
//
// Decimal, like every other capacity figure in the plugin — see _sz() in js/varaverk.js and
// _vv_api_fs_gb() in include/unraid_api.php for the same choice made on the other two sides.
function vv_arr_fmt_bytes(int $b): string {
if ($b <= 0) return '0B';
if ($b >= 1e12) return round($b / 1e12, 1) . 'T';
if ($b >= 1e9) return round($b / 1e9, 1) . 'G';
if ($b >= 1e6) return round($b / 1e6) . 'M';
if ($b >= 1e3) return round($b / 1e3) . 'K';
return $b . 'B';
}
function vv_arr_cleanup_stats(string $type): array {
$slugs = ['sonarr' => 'Arrs_Stack/sonarr_cleanup',
'radarr' => 'Arrs_Stack/radarr_cleanup',
'lidarr' => 'Arrs_Stack/lidarr_cleanup'];
$base = LOG_DIR . '/' . ($slugs[$type] ?? '');
// 'total' is gone: nothing populated it on the path that actually runs, and no consumer ever
// read it. It came from the log SUMMARY's "Tracked: N files (M total)", which the daily
// aggregate has no equivalent for — so it was null in every payload this page has ever served.
//
// orphans_sz was in the same position — carried and never rendered — which is why nobody
// noticed it was stuck at its default. It stays, and the page now shows it, because a count
// of orphans is not actionable and a size is: 12 orphans is a shrug, 12 orphans at 400GB is
// an afternoon.
$out = ['last_run' => null, 'end' => null, 'status' => null,
'tracked' => null,
'orphans' => 0, 'orphans_sz' => '0B',
'junk' => 0, 'junk_sz' => '0B'];
$jf = $base . '.json';
if (file_exists($jf)) {
$meta = json_decode(file_get_contents($jf), true) ?: [];
$out['last_run'] = $meta['start'] ?? null;
$out['end'] = $meta['end'] ?? null;
$out['status'] = $meta['status'] ?? null;
$lf = $base . '.log';
if (file_exists($lf)) {
$log = file_get_contents($lf);
$parts = preg_split('/━{3,}[^\n]*SUMMARY[^\n]*/u', $log);
$blk = count($parts) > 1 ? end($parts) : $log;
if (preg_match('/Tracked:\s*([\d,]+)\s*files/u', $blk, $m)) {
$out['tracked'] = (int)str_replace(',', '', $m[1]);
}
if (preg_match('/Orphans:\s*([\d,]+)\s*files\s*\(([^)]+)\)/u', $blk, $m)) {
$out['orphans'] = (int)str_replace(',', '', $m[1]);
$out['orphans_sz'] = trim($m[2]);
}
if (preg_match('/Junk:\s*([\d,]+)\s*files/u', $blk, $m)) {
$out['junk'] = (int)str_replace(',', '', $m[1]);
}
}
}
// Daily aggregate db — date|arr|orphan_count|orphan_bytes|junk_count|junk_bytes|recent_count|tracked_count
//
// Called the fallback, and it is in fact the only path that ever runs. The block above needs
// LOG_DIR/Arrs_Stack/<type>_cleanup.json, which run_job.sh writes for a top-level job — and
// the cleanups are not top-level jobs. They are entries in DAILY_MAINTENANCE_SCRIPTS, which
// daily_sync_maintenance.sh invokes with plain `bash`, so their output lands inside the
// orchestrator's log and no run record is written for them at all.
//
// The block above is therefore live only when someone runs a cleanup by hand from the
// Scheduler tab, which does go through run_job.sh. It is kept for that case rather than
// deleted, but on the nightly path everything below is what the page shows.
if ($out['last_run'] === null) {
$dbFile = DB_DIR . '/arr_cleanup_stats.db';
if (file_exists($dbFile)) {
$last = null;
foreach (file($dbFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$p = explode('|', $line);
if (count($p) >= 8 && $p[1] === $type) $last = $p;
}
if ($last) {
$out['last_run'] = strtotime($last[0] . ' 23:59:00') ?: null;
$out['status'] = 'ok';
$out['orphans'] = (int)$last[2];
$out['junk'] = (int)$last[4];
$out['tracked'] = (int)$last[7];
// Sizes were being dropped on the floor. The db records orphan_bytes beside the
// count and this read every column except that one, so orphans_sz kept its '0B'
// default — which is invisible today at zero orphans and would have read "12
// orphans (0B)" the first time there were any, on the one number that decides
// whether it is worth acting on.
$out['orphans_sz'] = vv_arr_fmt_bytes((int)$last[3]);
$out['junk_sz'] = vv_arr_fmt_bytes((int)$last[5]);
}
}
}
return $out;
}
function vv_arr_discovery_stats(string $type): array {
$slugs = ['sonarr' => 'Arrs_Stack/playback_aware_sonarr_discovery',
'radarr' => 'Arrs_Stack/playback_aware_radarr_discovery',
'lidarr' => 'Arrs_Stack/playback_aware_lidarr_discovery'];
$base = LOG_DIR . '/' . ($slugs[$type] ?? '');
$out = ['last_run' => null, 'status' => null, 'added' => null];
$jf = $base . '.json';
if (file_exists($jf)) {
$meta = json_decode(file_get_contents($jf), true) ?: [];
$out['last_run'] = $meta['start'] ?? null;
$out['status'] = $meta['status'] ?? null;
$lf = $base . '.log';
if (file_exists($lf)) {
$log = file_get_contents($lf);
if (preg_match('/Added[:\s]+(\d+)/i', $log, $m)) $out['added'] = (int)$m[1];
elseif (preg_match('/(\d+)\s+added/i', $log, $m)) $out['added'] = (int)$m[1];
}
}
// Fallback: per-title history db — status|id|date[|title]
if ($out['last_run'] === null) {
$dbFile = DB_DIR . '/' . $type . '_discovery_history.db';
if (file_exists($dbFile)) {
$lastDate = null; $added = 0;
foreach (file($dbFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$p = explode('|', $line);
if (count($p) < 3) continue;
$date = $p[2];
if ($date !== $lastDate) { $lastDate = $date; $added = 0; }
if ($p[0] === 'ACCEPT') $added++;
}
if ($lastDate) {
$out['last_run'] = strtotime($lastDate . ' 23:59:00') ?: null;
$out['status'] = 'ok';
$out['added'] = $added;
}
}
}
return $out;
}
function vv_arr_sync_stats(): array {
$base = LOG_DIR . '/Arrs_Stack/arr_sync';
$out = ['last_run' => null, 'status' => null, 'added' => null,
'nodes' => null, 'blocklist_count' => null];
$jf = $base . '.json';
if (file_exists($jf)) {
$meta = json_decode(file_get_contents($jf), true) ?: [];
$out['last_run'] = $meta['start'] ?? null;
$out['status'] = $meta['status'] ?? null;
}
$master = vv_read_conf_raw('master.conf');
if (preg_match('/ARR_SYNC_BLOCKLIST\s*=\s*"?([^"\n#]+)"?/m', $master, $m)) {
$blPath = trim($m[1]);
if (file_exists($blPath)) {
$out['blocklist_count'] = count(array_filter(
file($blPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES)));
}
}
$lf = $base . '.log';
if (file_exists($lf)) {
$log = file_get_contents($lf);
if (preg_match('/Total added[:\s]+(\d+)/i', $log, $m)) $out['added'] = (int)$m[1];
if (preg_match('/Nodes?[:\s]+(\d+)/i', $log, $m)) $out['nodes'] = (int)$m[1];
}
return $out;
}
function vv_arr_recovery_stats(): array {
$base = LOG_DIR . '/Arrs_Stack/arrs_failed_stalled_recovery';
$out = ['last_run' => null, 'status' => null, 'fixed' => 0, 'searched' => 0];
$jf = $base . '.json';
if (file_exists($jf)) {
$meta = json_decode(file_get_contents($jf), true) ?: [];
$out['last_run'] = $meta['start'] ?? null;
$out['status'] = $meta['status'] ?? null;
$lf = $base . '.log';
if (file_exists($lf)) {
$log = file_get_contents($lf);
if (preg_match('/Removed[:\s]+(\d+)/i', $log, $m)) $out['fixed'] = (int)$m[1];
if (preg_match('/Re-searched[:\s]+(\d+)/i',$log, $m)) $out['searched'] = (int)$m[1];
}
}
// Fallback: daily aggregate db — date|time|count|bytes
if ($out['last_run'] === null) {
$dbFile = DB_DIR . '/arr_recovery_stats.db';
if (file_exists($dbFile)) {
$lines = file($dbFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$last = $lines ? end($lines) : null;
if ($last) {
$p = explode('|', $last);
if (count($p) >= 3) {
$ts = strtotime(($p[0] ?? '') . ' ' . ($p[1] ?? '00:00')) ?: null;
if ($ts) {
$out['last_run'] = $ts;
$out['status'] = 'ok';
$out['fixed'] = (int)($p[2] ?? 0);
}
}
}
}
}
return $out;
}
// ── Local node only — called via SSH by remote_arr_cache_writer.sh on remote hosts ───────────
function vv_arrs_local_node(): array {
$host = vv_detect_host();
$names = vv_arr_node_names();
$arrs = [];
foreach (vv_discover_arrs() as $node) {
if ($node['host'] !== $host) continue;
foreach ($node['arrs'] as $arr) {
$entry = ['type' => $arr['type'], 'root' => $arr['root']];
$entry = array_merge($entry, vv_fetch_arr_live($arr));
$entry['cleanup'] = vv_arr_cleanup_stats($arr['type']);
$entry['discovery'] = vv_arr_discovery_stats($arr['type']);
$arrs[] = $entry;
}
break;
}
return [
'host' => $host,
'name' => $names[$host] ?? strtoupper($host),
'local' => true,
'arrs' => $arrs,
];
}
// ── Entry point ───────────────────────────────────────────────────────────────
function vv_arrs_all(): array {
$currentHost = vv_detect_host();
$names = vv_arr_node_names();
// Local node — full live data
$result = [vv_arrs_local_node()];
// Remote nodes — read from file cache written by remote_arr_cache_writer.sh
foreach (array_keys($names) as $h) {
if ($h === $currentHost) continue;
$cacheFile = VV_CACHE_DIR . '/arrs_remote_' . $h . '.json';
if (file_exists($cacheFile)) {
$node = json_decode(file_get_contents($cacheFile), true) ?: [];
$node['cached'] = true;
$node['cache_age'] = time() - (int)filemtime($cacheFile);
$result[] = $node;
} else {
$result[] = [
'host' => $h,
'name' => $names[$h],
'local' => false,
'cached' => false,
'cache_miss' => true,
'arrs' => [],
];
}
}
$vars = vv_conf_vars();
$myId = strtoupper($currentHost);
return [
'nodes' => $result,
'sync' => vv_arr_sync_stats(),
'recovery' => vv_arr_recovery_stats(),
// 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_servers' => function_exists('vv_media_servers_mesh') ? vv_media_servers_mesh() : [],
'host' => $currentHost,
'settings' => [
'arr_sync_enabled' => ($vars['ARR_SYNC_ENABLED'] ?? 'true') !== 'false',
'sonarr_recovery' => ($vars[$myId . '_SONARR_RECOVERY'] ?? 'true') !== 'false',
'radarr_recovery' => ($vars[$myId . '_RADARR_RECOVERY'] ?? 'true') !== 'false',
'lidarr_recovery' => ($vars[$myId . '_LIDARR_RECOVERY'] ?? 'true') !== 'false',
'recovery_age_hours' => (int)($vars['ARR_IMPORT_RECOVERY_AGE'] ?? 6),
'sonarr_port' => (int)($vars['ARR_SYNC_SONARR_PORT'] ?? 8989),
'radarr_port' => (int)($vars['ARR_SYNC_RADARR_PORT'] ?? 7878),
'lidarr_port' => (int)($vars['ARR_SYNC_LIDARR_PORT'] ?? 8686),
'my_host' => $currentHost,
'my_id' => $myId,
],
'ts' => time(),
];
}