Add a mesh scope to the Streams card

Local stays the default and the cheap path; mesh asks each partner for its own
sessions over SSH, live rather than cached, because a stream is true for
minutes and a cached one would be confidently wrong about the only thing the
card exists to say.
This commit is contained in:
Gmer4Lfe
2026-08-21 08:38:37 -04:00
parent 331b7b6b13
commit 81abc5adee
4 changed files with 202 additions and 8 deletions
+107
View File
@@ -258,6 +258,113 @@ function vv_media_sessions(): array {
];
}
// Who is watching, across the whole mesh.
//
// Fetched live rather than read from a cache, unlike vv_media_servers_mesh(). A server's version
// and CPU are true for hours; a stream is true for minutes, and a "now playing" card assembled
// from a two-hour-old file would be confidently wrong about the one thing it exists to say. The
// cost is only paid when the operator asks for the mesh view — the card polls local by default.
//
// Each partner answers about itself for the reason the arr and media-server collectors do: its
// Emby URL is http://localhost:8096, which is true there and meaningless here, and its API keys
// never leave it. Same transport as remote_arr_cache_writer.sh, at a faster cadence, so the
// connection is multiplexed and every hop is bounded.
//
// A partner that cannot be reached is reported as unreachable, never as zero streams. Those look
// identical in a total and mean opposite things.
function vv_media_sessions_mesh(): array {
$me = vv_detect_host();
$hosts = function_exists('vv_known_hosts') ? vv_known_hosts() : [$me => $me];
$nodes = [];
$sessions = [];
$names = [];
$count = 0;
foreach ($hosts as $h => $hostName) {
if ($h === $me) {
$local = vv_media_sessions();
$nodes[] = ['host' => $h, 'name' => $hostName, 'local' => true, 'reachable' => true,
'server_count' => $local['server_count'], 'stream_count' => count($local['sessions'])];
foreach ($local['sessions'] as $s) {
$s['_host'] = $h; $s['_hostName'] = $hostName; $s['_local'] = true;
$sessions[] = $s;
}
$names = array_merge($names, $local['server_names']);
$count += $local['server_count'];
continue;
}
$remote = vv_media_sessions_remote($h, $hostName);
$nodes[] = ['host' => $h, 'name' => $hostName, 'local' => false,
'reachable' => $remote !== null,
'server_count' => $remote['server_count'] ?? 0,
'stream_count' => $remote !== null ? count($remote['sessions']) : 0];
if ($remote === null) continue;
foreach ($remote['sessions'] as $s) {
$s['_host'] = $h; $s['_hostName'] = $hostName; $s['_local'] = false;
$sessions[] = $s;
}
$names = array_merge($names, $remote['server_names']);
$count += $remote['server_count'];
}
return [
'scope' => 'mesh',
'nodes' => $nodes,
'sessions' => $sessions,
'server_names' => $names,
'server_count' => $count,
];
}
// One partner's sessions, or null when it could not be asked.
//
// Null rather than an empty payload, deliberately: the caller has to be able to distinguish "that
// node has nobody watching" from "that node did not answer", and an empty array cannot carry the
// difference.
function vv_media_sessions_remote(string $host, string $hostName): ?array {
$vars = vv_conf_vars();
$sshKey = $vars[strtoupper(vv_detect_host()) . '_SSH_KEY'] ?? '';
if (!$sshKey || !is_file($sshKey)) return null;
$ip = vv_resolve_tailscale_ip($hostName);
if (!$ip) return null;
// The WebGUI symlink, which is where Unraid serves the plugin from on every node whatever its
// storage mode — the same path remote_arr_cache_writer.sh uses, and not a guess about where
// the partner installed itself.
$php = 'php -r ' . escapeshellarg(
"require_once '/usr/local/emhttp/plugins/varaverk/include/media.php';"
. " echo json_encode(vv_media_sessions());"
);
// ControlPersist because the card polls this every 12 seconds while the mesh view is open, and
// a fresh handshake per poll per partner is most of the cost. Timeouts are short: a dark
// partner must render as unreachable quickly, not hold the whole card.
$sock = rtrim(VV_CACHE_ROOT, '/') . '/ssh';
if (!is_dir($sock)) @mkdir($sock, 0700, true);
$cmd = 'timeout 8 ssh -i ' . escapeshellarg($sshKey)
. ' -o BatchMode=yes -o StrictHostKeyChecking=no -o ConnectTimeout=5'
. ' -o ControlMaster=auto -o ControlPersist=60s'
. ' -o ControlPath=' . escapeshellarg($sock . '/media-%h')
. ' root@' . escapeshellarg($ip) . ' ' . escapeshellarg($php) . ' 2>/dev/null';
$raw = shell_exec($cmd);
if (!$raw) return null;
$d = json_decode(trim($raw), true);
if (!is_array($d) || !isset($d['sessions'])) return null;
return [
'sessions' => is_array($d['sessions']) ? $d['sessions'] : [],
'server_names' => is_array($d['server_names'] ?? null) ? $d['server_names'] : [],
'server_count' => (int)($d['server_count'] ?? 0),
];
}
// ── What each media server is actually doing ──────────────────────────────────────────────────
// Two halves that only mean something together. The application knows its version, whether an
// update is waiting and who is watching; the container knows what that is costing in CPU, memory