-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. // // Both stores or the layout is only half real. The Monitor container card reads Varaverk's // docker_folders.json, never folder.view3's, and include/docker.php seeds it exactly once — // when the file is absent. A mirror onboarded before this step existed had already been // seeded, so writing only folder.view3 left the card showing the old single lump forever. // // Absent plugin is no longer a skip on import. folder.view3 is optional; Varaverk's own // store is not, and it is the one the card reads. Export still needs the plugin and says so. // // 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 third-party layout, optional on import // SCRIPTS_DIR/docker_folders.json Varaverk's own layout — what the card reads // Tools/fallback_folder.php still owns the "-Fallback" folder // ═══════════════════════════════════════════════════════════════════════════════════════════════ $pluginDir = dirname(__DIR__); require_once $pluginDir . '/include/config.php'; define('FV3_JSON', '/boot/config/plugins/folder.view3/docker.json'); // Varaverk's own store. Written as well as folder.view3's, because the Monitor container card // reads THIS file and nothing else — include/docker.php imports folder.view3 exactly once, when // this file does not yet exist. On a mirror onboarded before the layout step existed, that // bootstrap had already happened, so the card went on showing one lump "-Fallback" // holding the entire auth and arr stacks while folder.view3 next to it showed the real shelves. define('VV_STORE', SCRIPTS_DIR . '/docker_folders.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 ───────────────────────────────────────────────────────────────────────────────────── // folder.view3 is optional on the mirror. Export needs it and has nothing to say without it; // import does not — Varaverk's own store is the one the card reads, and it is always writable. $haveFv3 = file_exists(FV3_JSON); if (!$haveFv3 && isset($opts['export'])) { echo json_encode(['folders' => [], 'unfiled' => $listArg('containers')]) . "\n"; exit(0); } $fv3 = []; if ($haveFv3) { $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); } // Applied to both stores, so they cannot drift apart the way they already did once. Takes a // store, returns the store with the plan folded in plus what changed — no writing, no printing, // because the two callers report differently. $applyPlan = function (array $store) use ($plan): array { $written = 0; $lines = []; 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 ($store 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); $store[$targetId] = ['name' => $name, 'icon' => '', 'settings' => ['', '', '1', '', '1', ''], 'regex' => '', 'containers' => [], 'containerImages' => []]; $created = true; } $store[$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)($store[$targetId]['icon'] ?? '')) === '') { $store[$targetId]['icon'] = (string)$spec['icon']; } $have = (array)($store[$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++; } $store[$targetId]['containers'] = array_values($have); $lines[] = sprintf(" %-7s %-22s +%d (%s)", $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 ($store 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; } $store[$id]['containers'] = array_values($keep); } return [$store, $written, $pruned, $lines]; }; $dryRun = isset($opts['dry-run']); // ── folder.view3's file ────────────────────────────────────────────────────────────────────── $fvWritten = 0; if ($haveFv3) { [$fv3, $fvWritten, $fvPruned, $fvLines] = $applyPlan($fv3); foreach ($fvLines as $l) echo $l . "\n"; if ($fvPruned) printf(" pruned %d container(s) from -Fallback folder(s) — they are filed properly now\n", $fvPruned); if (($fvWritten || $fvPruned) && !$dryRun) { $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 to folder.view3 ✅\n", $fvWritten); } } else { echo " folder.view3 not installed — Varaverk's own layout only\n"; } // ── Varaverk's own store ───────────────────────────────────────────────────────────────────── // Seeded from folder.view3 when it does not exist yet, which is the same bootstrap // include/docker.php performs — done here too so the very first import lands on a real layout // rather than an empty file. $vvStore = []; if (file_exists(VV_STORE)) { $vvStore = json_decode((string)@file_get_contents(VV_STORE), true); if (!is_array($vvStore)) $vvStore = []; } elseif ($haveFv3) { $vvStore = $fv3; } [$vvStore, $vvWritten, $vvPruned, ] = $applyPlan($vvStore); if (($vvWritten || $vvPruned) && !$dryRun) { @mkdir(dirname(VV_STORE), 0755, true); $tmp = VV_STORE . '.vv.tmp'; if (file_put_contents($tmp, json_encode($vvStore, JSON_UNESCAPED_SLASHES)) === false || !rename($tmp, VV_STORE)) { fwrite(STDERR, "failed to write " . VV_STORE . "\n"); exit(1); } } printf(" %d folder(s) %s Varaverk's layout%s\n", $vvWritten, $dryRun ? 'would be written to' : 'written to', $vvPruned ? sprintf(" (%d unfiled from -Fallback)", $vvPruned) : ''); if ($dryRun) echo " DRY RUN — nothing written\n"; if (!$fvWritten && !$vvWritten) echo " nothing to write\n";