Files
Varaverk/Plugin/unraid/include/arrs.php
T
Gmer4Lfe 04aba81ab3 Show orphan and junk sizes, and drop a cleanup field nothing could fill
The arr cleanups are entries in DAILY_MAINTENANCE_SCRIPTS, which the orchestrator
invokes with plain bash, so they never get the run record the primary parse needs
— every payload has come from the daily aggregate db. That path read every column
except the byte counts, leaving orphans_sz at its "0B" default, which is
invisible at zero orphans and would have read "12 orphans (0B)" the first time
there were any. 'total' had no source there and no reader anywhere, so it is gone.
Arr Sync now reads "disabled" rather than "never run" when its switch is off.
2026-08-14 18:15:56 -04:00

469 lines
21 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';
// ── 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 ─────────────────────────────────────────────────────────────
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;
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));
}
}
$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(),
'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(),
];
}