#!/usr/bin/php -q -Fallback" — that // holds the containers this host runs on the partner's behalf. Named from master.conf, so the // folder name follows the hostnames rather than a value someone has to keep in step by hand. // // Onboard calls this on the mirror after deploying the owner's stack, so those containers land // somewhere that says whose they are instead of scattering into the mirror's own groups. // // OPERATIONAL MODEL // Create-or-update, never replace. The named folder is found in folder.view3 if it exists and // is amended in place; only its absence causes one to be created. Every other folder in the // file, and every container already filed elsewhere, is left exactly as it was — this is one // shelf in someone else's cupboard. // // Called during onboard on the mirror, after the owner's stack has been deployed there, so the // containers exist by the time anything tries to file them. // // The icon is resolved separately from the folder and never gates it. --icon-only performs // just that lookup and prints the URL, which is how onboard asks for it without writing // anything. // // DESIGN PRINCIPLES // The name comes from master.conf, never from a second source. // HOST2="unRAID-Jayred36" → "Jayred36-Fallback". The convention already existed by hand as // "Jayred365-Fallback", which did not match any derivable value — so the folder is renamed // to follow the conf rather than a conf field being invented to follow the folder. // // Upsert by name, never append. // folder.view3 keys folders by a random id, so writing without looking produces a second // folder with the same name and half the contents. That is exactly what happened here: a // "Jayred365-Fallback" with the icon and no containers, beside one with the containers and // no icon. Match on name, keep the existing id. // // This writes one folder, it does not replace the file. // include/docker.php used to mirror Varaverk's whole folder store over the top of // folder.view3's, destroying anything created in that plugin's UI. That write is gone. A // co-writer that edits a single key it owns is a different thing from one that overwrites // everything, and only the second kind loses data. // // Closest Emby user wins the icon, and a wrong guess costs nothing. // The folder image is decoration. Exact match first, then lowest Levenshtein distance // within a bound. "Jayred36" resolves to "Jayred365" (distance 1) over "Jayred" (2). // This is deliberately unlike resolve_tailscale_ip's exact-prefix-plus-ambiguity-guard — // there, a wrong match sends data to the wrong machine; here it picks the wrong avatar. // // OPERATIONAL SAFEGUARDS // Atomic write — .vv.tmp then rename(), so folder.view3 never reads a truncated file. // Absent plugin is a clean skip, not an error — nothing to do if folder.view3 is not installed. // --dry-run prints the resulting folder and writes nothing. // Icon resolution failing never blocks the folder: no image is a cosmetic loss, no folder is not. // // RUNTIME MODES // fallback_folder.php --host=HOST2 [--containers=A,B,C] [--icon=URL] [--dry-run] // fallback_folder.php --host=HOST2 --icon-only resolve and print the icon URL, write nothing // // CONFIGURATION // HOST1/HOST2… master.conf — the hostname the folder is named after // HOST*_EMBY_URL where to look users up (local is fine, it is a server-side call) // HOST*_EMBY_API_KEY " // HOST*_EMBY_PUBLIC_URL base the ICON is built from. Must be reachable from a browser on // either host, so localhost:8096 is not it — the icon renders in the // WebGUI of whichever machine is looking. Empty means no icon. // ═══════════════════════════════════════════════════════════════════════════════════════════════ $pluginDir = dirname(__DIR__); require_once $pluginDir . '/include/config.php'; define('FV3_JSON', '/boot/config/plugins/folder.view3/docker.json'); // ── Args ────────────────────────────────────────────────────────────────────── $opts = ['host' => '', 'containers' => '', 'icon' => '', 'dry-run' => false, 'icon-only' => false]; foreach (array_slice($argv, 1) as $a) { if ($a === '--dry-run') { $opts['dry-run'] = true; continue; } if ($a === '--icon-only'){ $opts['icon-only'] = true; continue; } if (preg_match('/^--([a-z-]+)=(.*)$/', $a, $m)) $opts[$m[1]] = $m[2]; } $hostId = strtoupper(trim($opts['host'])); if (!preg_match('/^HOST\d+$/', $hostId)) { fwrite(STDERR, "usage: fallback_folder.php --host=HOST2 [--containers=A,B] [--dry-run]\n"); exit(2); } $vars = vv_conf_vars(); $hostname = trim($vars[$hostId] ?? ''); if ($hostname === '') { fwrite(STDERR, "$hostId is not set in master.conf\n"); exit(1); } // derive_short_name() in common.sh: lowercase, strip a leading "unraid-", capitalise. $short = preg_replace('/^unraid-/i', '', $hostname); $short = ucfirst($short); $folderName = $short . '-Fallback'; // ── Icon: closest Emby user ─────────────────────────────────────────────────── function vv_closest_emby_icon(string $short, array $vars, string $meId): array { $url = rtrim(trim($vars[$meId . '_EMBY_URL'] ?? ''), '/'); $key = trim($vars[$meId . '_EMBY_API_KEY'] ?? ''); $pub = rtrim(trim($vars[$meId . '_EMBY_PUBLIC_URL'] ?? ''), '/'); if ($url === '' || $key === '') return ['', 'no Emby url/key configured']; $raw = @file_get_contents("$url/Users?api_key=" . urlencode($key), false, stream_context_create(['http' => ['timeout' => 10]])); $users = json_decode((string)$raw, true); if (!is_array($users) || !$users) return ['', 'Emby returned no users']; $needle = strtolower($short); $best = null; $bestD = PHP_INT_MAX; $runnerUp = null; foreach ($users as $u) { $name = (string)($u['Name'] ?? ''); if ($name === '' || empty($u['Id'])) continue; $d = levenshtein($needle, strtolower($name)); if ($d < $bestD) { $runnerUp = $best; $bestD = $d; $best = $u; } elseif ($runnerUp === null || $d < levenshtein($needle, strtolower((string)$runnerUp['Name']))) { $runnerUp = $u; } } if (!$best) return ['', 'no candidate users']; // Bound it so an unrelated name never wins by being the least-bad of a bad field. $limit = max(2, (int)floor(strlen($needle) * 0.5)); if ($bestD > $limit) { return ['', sprintf('closest was "%s" (distance %d > limit %d) — too far, no icon', $best['Name'], $bestD, $limit)]; } if ($pub === '') { return ['', sprintf('matched Emby user "%s" (distance %d) but %s_EMBY_PUBLIC_URL is unset — ' . 'a localhost icon would not render in a browser', $best['Name'], $bestD, $meId)]; } $tag = (string)($best['PrimaryImageTag'] ?? ''); if ($tag === '') return ['', sprintf('Emby user "%s" has no primary image', $best['Name'])]; $icon = sprintf('%s/Users/%s/Images/Primary?maxWidth=200&tag=%s&quality=90', $pub, $best['Id'], $tag); $note = sprintf('matched Emby user "%s" (distance %d%s)', $best['Name'], $bestD, $runnerUp ? sprintf(', next "%s" at %d', $runnerUp['Name'], levenshtein($needle, strtolower((string)$runnerUp['Name']))) : ''); return [$icon, $note]; } $meId = strtoupper(vv_detect_host()); // --icon= skips the lookup entirely. The mirror needs this: it is being given a folder named // after the OWNER, and the avatar lives in the owner's Emby — which the mirror has no key for and // may not run at all. So the owner resolves the URL with --icon-only and hands it over, rather // than the mirror guessing from a userbase it cannot see. if (trim($opts['icon']) !== '') { $icon = trim($opts['icon']); $iconNote = 'supplied by caller'; } else { [$icon, $iconNote] = vv_closest_emby_icon($short, $vars, $meId); } // --icon-only: resolve and print, touch nothing. Exits non-zero when there is no icon, so a // caller can tell "no image" from "empty string because something broke". if ($opts['icon-only']) { if ($icon === '') { fwrite(STDERR, "no icon: $iconNote\n"); exit(1); } echo $icon . "\n"; exit(0); } // ── Upsert the folder ───────────────────────────────────────────────────────── // A host does not keep a fallback folder for itself — the folder means "containers I run on // SOMEONE ELSE's behalf", so naming it after this machine is always wrong. Checked here rather // than at argument parsing, because --icon-only legitimately asks for THIS host's own avatar: // the owner resolves its own picture to hand to the mirror, which is the whole point of that mode. // // Refused rather than created, because the failure is otherwise silent — an empty folder named // after yourself looks plausible enough to survive a glance. One appeared on HOST1 exactly this // way, when an older copy of this script ignored an unrecognised flag and ran the upsert anyway. if (strcasecmp($hostId, strtoupper(vv_detect_host())) === 0) { fwrite(STDERR, "$hostId is this host — a fallback folder is named after the PARTNER, not self\n"); exit(2); } if (!file_exists(FV3_JSON)) { echo "folder.view3 is not installed — nothing to do\n"; exit(0); } $j = json_decode((string)@file_get_contents(FV3_JSON), true); if (!is_array($j)) { fwrite(STDERR, "folder.view3 docker.json is unreadable\n"); exit(1); } $containers = array_values(array_filter(array_map('trim', explode(',', (string)$opts['containers'])))); // Match on name, case-insensitively, so a hand-made folder is adopted rather than duplicated. $targetId = null; foreach ($j as $id => $f) { if (is_array($f) && strcasecmp((string)($f['name'] ?? ''), $folderName) === 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); $j[$targetId] = ['name' => $folderName, 'icon' => '', 'settings' => ['', '', '1', '', '1', ''], 'regex' => '', 'containers' => [], 'containerImages' => []]; $created = true; } $j[$targetId]['name'] = $folderName; if ($icon !== '') $j[$targetId]['icon'] = $icon; foreach ($containers as $c) { if (!in_array($c, (array)$j[$targetId]['containers'], true)) $j[$targetId]['containers'][] = $c; } $j[$targetId]['containers'] = array_values((array)$j[$targetId]['containers']); printf("%s %s (id %s)\n", $created ? 'create' : 'update', $folderName, $targetId); printf(" icon : %s\n", $icon !== '' ? $icon : '(none) — ' . $iconNote); if ($icon !== '') printf(" via : %s\n", $iconNote); printf(" containers: %s\n", implode(', ', (array)$j[$targetId]['containers']) ?: '(none)'); if ($opts['dry-run']) { echo " DRY RUN — nothing written\n"; exit(0); } $tmp = FV3_JSON . '.vv.tmp'; if (file_put_contents($tmp, json_encode($j, JSON_UNESCAPED_SLASHES)) === false || !rename($tmp, FV3_JSON)) { fwrite(STDERR, "failed to write " . FV3_JSON . "\n"); exit(1); } echo " written ✅\n";