diff --git a/Plugin/unraid/api/arrs.php b/Plugin/unraid/api/arrs.php
index 08b7221..4379452 100644
--- a/Plugin/unraid/api/arrs.php
+++ b/Plugin/unraid/api/arrs.php
@@ -2,6 +2,38 @@
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
+// ── Manual remote refresh — runs remote_arr_cache_writer for one host ─────────
+$_action = trim($_GET['action'] ?? '');
+if ($_action === 'refresh_remote') {
+ $host = strtolower(trim($_GET['host'] ?? ''));
+ if (!preg_match('/^host\d+$/', $host)) {
+ echo json_encode(['ok' => false, 'error' => 'Invalid host']); exit;
+ }
+ $script = SCRIPTS_DIR . '/Tools/remote_arr_cache_writer.sh';
+ if (!file_exists($script)) {
+ echo json_encode(['ok' => false, 'error' => 'remote_arr_cache_writer.sh not found']); exit;
+ }
+ set_time_limit(30);
+ $out = []; $exit = 0;
+ exec('bash ' . escapeshellarg($script) . ' --host=' . escapeshellarg(strtoupper($host)) . ' 2>&1', $out, $exit);
+
+ $cacheFile = VV_CACHE_DIR . '/arrs_remote_' . $host . '.json';
+ $node = null;
+ if (file_exists($cacheFile)) {
+ $node = json_decode(file_get_contents($cacheFile), true) ?: null;
+ if ($node) {
+ $node['cached'] = true;
+ $node['cache_age'] = time() - (int)filemtime($cacheFile);
+ }
+ }
+ // Bust the main arrs cache so next poll gets fresh data
+ @unlink(VV_CACHE_DIR . '/arrs.json');
+ echo json_encode(['ok' => $exit === 0, 'node' => $node, 'output' => implode("\n", $out)]);
+ exit;
+}
+unset($_action);
+
+// ── Normal data load ──────────────────────────────────────────────────────────
$_vv_cached = vv_cache_read('arrs', 90);
if ($_vv_cached !== null) { echo json_encode($_vv_cached); exit; }
unset($_vv_cached);
diff --git a/Plugin/unraid/include/arrs.php b/Plugin/unraid/include/arrs.php
index 266d4a0..5715167 100644
--- a/Plugin/unraid/include/arrs.php
+++ b/Plugin/unraid/include/arrs.php
@@ -240,32 +240,61 @@ function vv_arr_recovery_stats(): array {
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();
- $allNodes = vv_discover_arrs();
- $result = [];
- foreach ($allNodes as $node) {
- $h = $node['host'];
- $isLocal = ($h === $currentHost || $currentHost === 'unknown');
- $nodeOut = ['host' => $h, 'name' => $names[$h] ?? strtoupper($h), 'local' => $isLocal, 'arrs' => []];
+ // Local node — full live data
+ $result = [vv_arrs_local_node()];
- foreach ($node['arrs'] as $arr) {
- $entry = ['type' => $arr['type'], 'root' => $arr['root']];
- if ($isLocal) {
- $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']);
- } else {
- $entry['online'] = false;
- $entry['remote'] = true;
- }
- $nodeOut['arrs'][] = $entry;
+ // 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' => [],
+ ];
}
- $result[] = $nodeOut;
}
$vars = vv_conf_vars();
diff --git a/Plugin/unraid/pages/arrs.php b/Plugin/unraid/pages/arrs.php
index 4176954..047c483 100644
--- a/Plugin/unraid/pages/arrs.php
+++ b/Plugin/unraid/pages/arrs.php
@@ -248,16 +248,41 @@ function _arrCard(arr) {
}
// ── Node section ──────────────────────────────────────────────────────────────
-function _nodeSection(node, ownerHost) {
+function _nodeSection(node) {
const isLocal = node.local;
- const tag = isLocal ? 'local' : '';
- const cards = node.arrs.map(_arrCard).join('');
+ const isMiss = node.cache_miss;
+
+ let tag, body;
+
+ if (isLocal) {
+ tag = 'local';
+ body = `
${node.arrs.map(_arrCard).join('')}
`;
+ } else if (isMiss) {
+ tag = `no data
+ `;
+ body = `
+ No remote data yet — first fetch runs within 2 hours, or click ↻ Fetch now.
+
`;
+ } else {
+ const age = node.cache_age || 0;
+ const ageStr = age < 3600 ? Math.floor(age/60) + 'm ago'
+ : age < 86400 ? Math.floor(age/3600) + 'h ago'
+ : Math.floor(age/86400) + 'd ago';
+ tag = `cached ${ageStr}
+ `;
+ body = `${node.arrs.map(_arrCard).join('')}
`;
+ }
+
return `
${node.host.toUpperCase()}
${node.name}${tag}
-
${cards}
+ ${body}
`;
}
@@ -372,7 +397,7 @@ function _render(data) {
}
let html = '';
- for (const node of nodes) html += _nodeSection(node, data.host);
+ for (const node of nodes) html += _nodeSection(node);
html += _syncSection(data.sync || {}, data.recovery || {}, data.settings);
html += _settingsCard(data.settings);
@@ -393,6 +418,20 @@ setInterval(vvArrsLoad, 60000);
})();
+// ── Remote node refresh ───────────────────────────────────────────────────────
+function vvArrsRefreshRemote(host) {
+ const btn = document.getElementById('vv-arr-rfsh-' + host);
+ if (btn) { btn.disabled = true; btn.textContent = '↻…'; }
+ fetch(`/plugins/varaverk/api/arrs.php?action=refresh_remote&host=${host}&_=` + Date.now())
+ .then(r => r.json())
+ .then(d => {
+ if (btn) { btn.disabled = false; btn.textContent = d.ok ? '↻' : '✗'; }
+ // Main cache was busted — reload to show fresh data
+ setTimeout(vvArrsLoad, 500);
+ })
+ .catch(() => { if (btn) { btn.disabled = false; btn.textContent = '↻'; } });
+}
+
// ── Toggle handler ────────────────────────────────────────────────────────────
function vvArrToggle(track, key, file) {
const on = !track.classList.contains('on');
diff --git a/Tools/remote_arr_cache_writer.sh b/Tools/remote_arr_cache_writer.sh
new file mode 100755
index 0000000..3041a40
--- /dev/null
+++ b/Tools/remote_arr_cache_writer.sh
@@ -0,0 +1,122 @@
+#!/bin/bash
+# ==============================================================================================
+# ============================= Remote Arr Cache Writer ========================================
+# ==============================================================================================
+#
+# PURPOSE
+# ─────────────────────────────────────────────────────────────────────────────
+# SSHes to each remote partner host, calls vv_arrs_local_node() on their PHP
+# stack, and caches the result locally in /tmp/vv_cache/arrs_remote_hostN.json.
+#
+# The arrs page reads these files for instant initial load without hitting the
+# remote arr APIs on every page view. This script runs every 2 hours so remote
+# library counts stay reasonably current without hammering the network.
+#
+# Accepts --host=HOST2 to refresh a single host (used by the UI refresh button).
+#
+# ==============================================================================================
+# USAGE
+# ==============================================================================================
+#
+# remote_arr_cache_writer.sh — refresh all remote hosts
+# remote_arr_cache_writer.sh --host=HOST2 — refresh one host only
+# remote_arr_cache_writer.sh --dry-run — show what would happen
+# remote_arr_cache_writer.sh --log — verbose output
+#
+# ==============================================================================================
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+source "$SCRIPT_DIR/../load_config.sh"
+
+parse_args "$@"
+acquire_lock
+detect_hosts
+
+# Parse --host= from raw args
+TARGET_HOST=""
+for arg in "$@"; do
+ case "$arg" in --host=*) TARGET_HOST="${arg#--host=}" ;; esac
+done
+
+mkdir -p /tmp/vv_cache
+
+FETCH_OK=0
+FETCH_FAIL=0
+FETCH_SKIP=0
+
+echo ""
+echo "━━━ $ICON_SYNC Remote Arr Cache Writer ━━━"
+[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
+[[ -n "$TARGET_HOST" ]] && echo " Target: $TARGET_HOST"
+echo ""
+
+for host_var in HOST1 HOST2 HOST3 HOST4 HOST5 HOST6 HOST7 HOST8; do
+ [[ "$host_var" == "$MY_ID" ]] && continue
+
+ hostname="${!host_var:-}"
+ [[ -z "$hostname" ]] && continue
+
+ # If targeting a specific host, skip others
+ if [[ -n "$TARGET_HOST" ]]; then
+ [[ "${host_var,,}" != "${TARGET_HOST,,}" && "$host_var" != "$TARGET_HOST" ]] && continue
+ fi
+
+ host_id="${host_var,,}" # host1, host2, …
+ cache_file="/tmp/vv_cache/arrs_remote_${host_id}.json"
+
+ echo " $host_var ($hostname)…"
+
+ if [[ "$DRY_RUN" == true ]]; then
+ warn " DRY RUN — would SSH to $hostname and cache arr data"
+ (( FETCH_SKIP++ ))
+ continue
+ fi
+
+ # Resolve Tailscale IP
+ REMOTE_IP=$(resolve_tailscale_ip "$hostname")
+ if [[ -z "$REMOTE_IP" ]]; then
+ warn " $host_var: cannot resolve Tailscale IP for $hostname — skipping"
+ (( FETCH_FAIL++ ))
+ continue
+ fi
+ log " $host_var: resolved $hostname → $REMOTE_IP"
+
+ # SSH and call vv_arrs_local_node() on the remote
+ RESULT=$(ssh -i "$SSH_KEY" \
+ -o ConnectTimeout=10 \
+ -o StrictHostKeyChecking=no \
+ -o BatchMode=yes \
+ "root@${REMOTE_IP}" \
+ "php -r \"require_once '/usr/local/emhttp/plugins/varaverk/include/arrs.php'; echo json_encode(vv_arrs_local_node());\"" \
+ 2>/dev/null)
+
+ if [[ -z "$RESULT" ]]; then
+ warn " $host_var: empty response from SSH — skipping"
+ (( FETCH_FAIL++ ))
+ continue
+ fi
+
+ # Validate JSON
+ if ! echo "$RESULT" | php -r "
+ \$d = json_decode(file_get_contents('php://stdin'), true);
+ exit(!is_array(\$d) || !isset(\$d['arrs']) ? 1 : 0);
+ " 2>/dev/null; then
+ warn " $host_var: invalid or unexpected JSON — skipping"
+ log " Response: ${RESULT:0:200}"
+ (( FETCH_FAIL++ ))
+ continue
+ fi
+
+ echo "$RESULT" > "$cache_file"
+ CACHED_TYPES=$(echo "$RESULT" | php -r "
+ \$d = json_decode(file_get_contents('php://stdin'), true);
+ echo implode(', ', array_column(\$d['arrs'] ?? [], 'type'));
+ " 2>/dev/null)
+ echo " $host_var: cached ✅ — ${CACHED_TYPES:-no arrs}"
+ (( FETCH_OK++ ))
+done
+
+echo ""
+echo "━━━━━ $ICON_SUMMARY Cache Writer Summary ━━━━━"
+echo " $FETCH_OK updated · $FETCH_FAIL failed · $FETCH_SKIP skipped"
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"