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, _discovery_history.db // VV_CACHE_DIR arrs_remote_.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/_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(), ]; }