diff --git a/Deployment/host.conf.template b/Deployment/host.conf.template index 737cf7f..61a047d 100644 --- a/Deployment/host.conf.template +++ b/Deployment/host.conf.template @@ -113,6 +113,11 @@ HOSTN_EMBY_CONTAINER="Emby" HOSTN_EMBY_URL="http://localhost:8096" HOSTN_EMBY_API_KEY="" # Emby Dashboard → API Keys → + New Key + HOSTN_EMBY_PUBLIC_URL="" # e.g. https://media.example.com/emby — browser-reachable base, used + # to build image URLs that render in the WebGUI. Deliberately separate + # from HOSTN_EMBY_URL: that one is for server-side API calls and is + # usually localhost, which resolves to the wrong machine in a browser. + # Empty = features that need an image quietly go without one. # ━━━ Jellyfin ━━━ HOSTN_JELLYFIN_CONTAINER="Jellyfin" diff --git a/Plugin/unraid/Tools/fallback_folder.php b/Plugin/unraid/Tools/fallback_folder.php new file mode 100755 index 0000000..2e76a87 --- /dev/null +++ b/Plugin/unraid/Tools/fallback_folder.php @@ -0,0 +1,170 @@ +#!/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. +// +// 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. +// +// REQUEST +// fallback_folder.php --host=HOST2 [--containers=A,B,C] [--dry-run] +// +// 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' => '', 'dry-run' => false]; +foreach (array_slice($argv, 1) as $a) { + if ($a === '--dry-run') { $opts['dry-run'] = 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, $iconNote] = vv_closest_emby_icon($short, $vars, $meId); + +// ── Upsert the folder ───────────────────────────────────────────────────────── +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";