-Fallback". That says whose // they are and nothing about what they do, and it is the wrong shape for a stack that runs // continuously rather than only during a failover. // // The fallback folder is for what is genuinely fallback-only. // Anything in PARTNERSHIP_FALLBACK_ONLY, plus anything deployed that the owner does not // file anywhere, goes to "-Fallback". A container that runs on the mirror all // the time belongs with its peers; a container that exists only to cover the owner going // dark belongs in a folder named after the owner. // // OPERATIONAL SAFEGUARDS // Upsert by name, never append — folder.view3 keys by random id, so a blind write produces a // second folder with the same name and half the contents. // // Atomic write: .vv.tmp then rename(), so folder.view3 never reads a truncated file. // // Absent plugin is a clean skip, not an error. A mirror without folder.view3 installed has // nowhere to put folders and that is not a failure of the onboard. // // Import trusts nothing about shape: every folder needs a non-empty name and an array of // container names, and anything else in the payload is ignored rather than merged. // // Icons are carried across as URLs. They are the owner's own icon values, already resolvable // from any host, and an icon that fails to load is cosmetic. // // REQUEST // mirror_folders.php --export --containers=Sonarr,Radarr[,…] [--fallback-only=a,b] [--owner=HOST1] // mirror_folders.php --import [--dry-run] (plan on stdin) // // RESPONSE // export: JSON plan on stdout — {"folders":[{"name":…,"icon":…,"containers":[…]}, …], // "unfiled":[…]} // import: one line per folder written, then a count // // DEPENDS ON // /boot/config/plugins/folder.view3/docker.json the layout both sides read // Tools/fallback_folder.php still owns the "-Fallback" folder // ═══════════════════════════════════════════════════════════════════════════════════════════════ define('FV3_JSON', '/boot/config/plugins/folder.view3/docker.json'); $opts = getopt('', ['export', 'import', 'containers:', 'fallback-only:', 'dry-run']); $listArg = function (string $k) use ($opts): array { if (!isset($opts[$k])) return []; return array_values(array_filter(array_map('trim', explode(',', (string)$opts[$k])), 'strlen')); }; // ── Load ───────────────────────────────────────────────────────────────────────────────────── if (!file_exists(FV3_JSON)) { // Export has nothing to read; import has nowhere to write. Both are clean no-ops. if (isset($opts['export'])) { echo json_encode(['folders' => [], 'unfiled' => $listArg('containers')]) . "\n"; exit(0); } echo "folder.view3 is not installed — nothing to do\n"; exit(0); } $fv3 = json_decode((string)@file_get_contents(FV3_JSON), true); if (!is_array($fv3)) { fwrite(STDERR, "folder.view3 docker.json is unreadable\n"); exit(1); } // ── Export ─────────────────────────────────────────────────────────────────────────────────── if (isset($opts['export'])) { $deployed = $listArg('containers'); $fallbackOnly = array_map('strtolower', $listArg('fallback-only')); if (!$deployed) { fwrite(STDERR, "--containers is required for --export\n"); exit(2); } // Case-insensitive membership, because container names come from XML filenames on one side // and docker on the other, and those have disagreed on capitalisation before (my-prowlarr). $remaining = []; foreach ($deployed as $c) $remaining[strtolower($c)] = $c; $plan = []; foreach ($fv3 as $f) { if (!is_array($f)) continue; $name = trim((string)($f['name'] ?? '')); if ($name === '') continue; // The owner's own fallback folders describe the owner's coverage of someone else. They // are not part of the mirror's layout and copying them would be nonsense on that host. if (preg_match('/-Fallback$/i', $name)) continue; $members = []; foreach ((array)($f['containers'] ?? []) as $c) { $lc = strtolower(trim((string)$c)); if ($lc === '' || !isset($remaining[$lc])) continue; if (in_array($lc, $fallbackOnly, true)) continue; // claimed by the fallback folder $members[] = $remaining[$lc]; unset($remaining[$lc]); } if ($members) $plan[] = ['name' => $name, 'icon' => (string)($f['icon'] ?? ''), 'containers' => $members]; } // Whatever the owner files nowhere, plus everything explicitly marked fallback-only. echo json_encode(['folders' => $plan, 'unfiled' => array_values($remaining)], JSON_UNESCAPED_SLASHES) . "\n"; exit(0); } // ── Import ─────────────────────────────────────────────────────────────────────────────────── if (!isset($opts['import'])) { fwrite(STDERR, "usage: mirror_folders.php --export --containers=… | --import\n"); exit(2); } $raw = stream_get_contents(STDIN); $plan = json_decode((string)$raw, true); if (!is_array($plan) || !isset($plan['folders']) || !is_array($plan['folders'])) { fwrite(STDERR, "import: no usable plan on stdin\n"); exit(1); } $written = 0; foreach ($plan['folders'] as $spec) { if (!is_array($spec)) continue; $name = trim((string)($spec['name'] ?? '')); $cs = array_values(array_filter(array_map('trim', (array)($spec['containers'] ?? [])), 'strlen')); if ($name === '' || !$cs) continue; $targetId = null; foreach ($fv3 as $id => $f) { if (is_array($f) && strcasecmp((string)($f['name'] ?? ''), $name) === 0) { $targetId = $id; break; } } $created = false; if ($targetId === null) { // folder.view3's own id shape: 20 chars of url-safe base64. $targetId = substr(str_replace(['+', '/', '='], '', base64_encode(random_bytes(15))), 0, 20); $fv3[$targetId] = ['name' => $name, 'icon' => '', 'settings' => ['', '', '1', '', '1', ''], 'regex' => '', 'containers' => [], 'containerImages' => []]; $created = true; } $fv3[$targetId]['name'] = $name; // Only fill an icon that is missing — a mirror that has styled its own folder keeps its choice. if (($spec['icon'] ?? '') !== '' && trim((string)($fv3[$targetId]['icon'] ?? '')) === '') { $fv3[$targetId]['icon'] = (string)$spec['icon']; } $have = (array)($fv3[$targetId]['containers'] ?? []); $lc = array_map('strtolower', array_map('strval', $have)); $added = 0; foreach ($cs as $c) { if (in_array(strtolower($c), $lc, true)) continue; $have[] = $c; $lc[] = strtolower($c); $added++; } $fv3[$targetId]['containers'] = array_values($have); printf(" %-7s %-22s +%d (%s)\n", $created ? 'create' : 'update', $name, $added, implode(', ', $cs)); $written++; } // ── Take the filed containers back out of any "-Fallback" folder ───────────────────────────── // A container that now sits in "Arrs Stack" must not also sit in "Gmer4Lfe-Fallback". The // fallback folder answers "what is this host covering for the owner", and an earlier Step 12 // dumped every deployed container into it — so it claimed the whole auth and arr stacks, which // run here continuously and are not failover coverage at all. // // Only containers this plan just filed are removed. Anything the operator put in that folder by // hand, or that the onboard filed there deliberately as fallback-only, is left alone. $filed = []; foreach ($plan['folders'] as $spec) { foreach ((array)($spec['containers'] ?? []) as $c) $filed[strtolower(trim((string)$c))] = true; } $pruned = 0; foreach ($fv3 as $id => $f) { if (!is_array($f) || !preg_match('/-Fallback$/i', (string)($f['name'] ?? ''))) continue; $keep = []; foreach ((array)($f['containers'] ?? []) as $c) { if (isset($filed[strtolower(trim((string)$c))])) { $pruned++; continue; } $keep[] = $c; } if ($pruned) $fv3[$id]['containers'] = array_values($keep); } if ($pruned) printf(" pruned %d container(s) from -Fallback folder(s) — they are filed properly now\n", $pruned); if (!$written && !$pruned) { echo " nothing to write\n"; exit(0); } if (isset($opts['dry-run'])) { echo " DRY RUN — nothing written\n"; exit(0); } $tmp = FV3_JSON . '.vv.tmp'; if (file_put_contents($tmp, json_encode($fv3, JSON_UNESCAPED_SLASHES)) === false || !rename($tmp, FV3_JSON)) { fwrite(STDERR, "failed to write " . FV3_JSON . "\n"); exit(1); } printf(" %d folder(s) written ✅\n", $written);