'hostname', ...] // Returns ['host1' => 'hostname', 'host2' => 'hostname', ...] from master.conf. // Matches both HOST1="name" and HOST1_NAME="name" (either convention). function vv_arr_known_hosts(): array { $vars = vv_conf_vars(); // already parses HOST1="val" correctly $hosts = []; foreach ($vars as $k => $v) { if (preg_match('/^HOST(\d+)$/', $k, $m) && $v !== '') { $hosts['host' . $m[1]] = $v; } } ksort($hosts); return $hosts ?: ['host1' => 'HOST1']; } function vv_arr_node_names(): array { return array_map(fn($name) => $name, 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_column($data, 'episodeFileCount')); } } 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 ───────────────────────────────────────────────────────────────── function vv_arr_cleanup_stats(string $type): array { $slugs = ['sonarr' => 'Media/sonarr_cleanup', 'radarr' => 'Media/radarr_cleanup', 'lidarr' => 'Media/lidarr_cleanup']; $base = LOG_DIR . '/' . ($slugs[$type] ?? ''); $out = ['last_run' => null, 'end' => null, 'status' => null, 'tracked' => null, 'total' => null, 'orphans' => 0, 'orphans_sz' => '0B', 'junk' => 0]; $jf = $base . '.json'; if (!file_exists($jf)) return $out; $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)) return $out; $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\s*\(([\d,]+)/u', $blk, $m)) { $out['tracked'] = (int)str_replace(',', '', $m[1]); $out['total'] = (int)str_replace(',', '', $m[2]); } 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]); } return $out; } function vv_arr_discovery_stats(string $type): array { $slugs = ['sonarr' => 'Media/playback_aware_sonarr_discovery', 'radarr' => 'Media/playback_aware_radarr_discovery', 'lidarr' => 'Media/playback_aware_lidarr_discovery']; $base = LOG_DIR . '/' . ($slugs[$type] ?? ''); $out = ['last_run' => null, 'status' => null, 'added' => null]; $jf = $base . '.json'; if (!file_exists($jf)) return $out; $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]; } return $out; } function vv_arr_sync_stats(): array { $base = LOG_DIR . '/Media/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 . '/Media/arrs_failed_stalled_recovery'; $out = ['last_run' => null, 'status' => null, 'fixed' => 0, 'searched' => 0]; $jf = $base . '.json'; if (!file_exists($jf)) return $out; $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]; } 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(), ]; }