Name the partner fallback folder from master.conf and give it the closest Emby user's avatar

This commit is contained in:
Gmer4Lfe
2026-08-17 05:27:25 -04:00
parent 3676526daf
commit 6dbb076a1e
2 changed files with 175 additions and 0 deletions
+5
View File
@@ -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"
+170
View File
@@ -0,0 +1,170 @@
#!/usr/bin/php -q
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Creates or updates the per-partner folder in folder.view3 — "<PartnerShort>-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";