From 81abc5adee423085da1c87859729dc9b1cd7a30c Mon Sep 17 00:00:00 2001 From: Gmer4Lfe Date: Fri, 21 Aug 2026 08:38:37 -0400 Subject: [PATCH] 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. --- Plugin/unraid/api/media.php | 16 ++++- Plugin/unraid/css/varaverk.css | 13 ++++ Plugin/unraid/include/media.php | 107 ++++++++++++++++++++++++++++++++ Plugin/unraid/pages/monitor.php | 74 ++++++++++++++++++++-- 4 files changed, 202 insertions(+), 8 deletions(-) diff --git a/Plugin/unraid/api/media.php b/Plugin/unraid/api/media.php index ed5cff1..a8004c7 100644 --- a/Plugin/unraid/api/media.php +++ b/Plugin/unraid/api/media.php @@ -30,10 +30,22 @@ // RESPONSE // vv_media_sessions() verbatim — a flat list of normalised sessions across all servers // +// REQUEST +// GET this host's sessions +// GET ?scope=mesh every node's sessions, each row tagged with the host it is playing on +// +// Local is the default and stays the cheap path: one call per configured media server here. +// Mesh adds one bounded SSH hop per partner and is only requested while the operator is looking +// at the mesh view, so a dashboard left open on the default costs exactly what it did before. +// // DEPENDS ON -// include/media.php vv_media_sessions() +// include/media.php vv_media_sessions(), vv_media_sessions_mesh() // ═══════════════════════════════════════════════════════════════════════════════════════════════ header('Content-Type: application/json'); require_once dirname(__DIR__) . '/include/media.php'; -echo json_encode(vv_media_sessions()); +// Anything that is not the literal "mesh" is local. Fail-closed on the expensive path, matching +// how every other toggle in this plugin reads its value. +echo json_encode(($_GET['scope'] ?? '') === 'mesh' + ? vv_media_sessions_mesh() + : vv_media_sessions() + ['scope' => 'local']); diff --git a/Plugin/unraid/css/varaverk.css b/Plugin/unraid/css/varaverk.css index 08734ee..5118a3e 100644 --- a/Plugin/unraid/css/varaverk.css +++ b/Plugin/unraid/css/varaverk.css @@ -1167,6 +1167,19 @@ mark { background: #5d4037; color: #ffcc80; border-radius: 2px; } .vv-chip-group-device, .vv-chip-group-res, .vv-chip-group-codec { display: contents; } .vv-stream-empty { color: #555; font-style: italic; font-size: 12px; margin: 4px 0; } .vv-stream-empty span { font-size: 11px; color: #444; } + +/* Streams scope control. Deliberately named vv-strm-* rather than reusing a bare utility name: + Unraid Connect injects a global Tailwind layer into every page, and a class named after a + utility gets whatever that layer says. */ +.vv-strm-tab { background: #111; border: 1px solid #242424; color: #555; font-size: 10px; + padding: 2px 10px; border-radius: 3px; cursor: pointer; text-transform: uppercase; + letter-spacing: .05em; } +.vv-strm-tab:hover { color: #bbb; border-color: #3a3a3a; } +.vv-strm-tab.on { color: #4caf50; border-color: #2d4a2d; background: #0d1a0d; } +/* The node a stream is playing on. Only rendered in mesh scope — in local scope every row is + this host and the label would be noise on every line. */ +.vv-strm-node { font-size: 9px; color: #4a7a9f; border: 1px solid #24384a; background: #0d151c; + border-radius: 2px; padding: 0 4px; margin-left: 5px; white-space: nowrap; } .vv-stream-row { margin-bottom: 10px; padding-bottom: 10px; border-bottom: 1px solid #282828; } .vv-stream-row:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; } .vv-stream-top { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; } diff --git a/Plugin/unraid/include/media.php b/Plugin/unraid/include/media.php index c73986a..8a0fa7e 100644 --- a/Plugin/unraid/include/media.php +++ b/Plugin/unraid/include/media.php @@ -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 diff --git a/Plugin/unraid/pages/monitor.php b/Plugin/unraid/pages/monitor.php index f8600c0..cb8fe2c 100644 --- a/Plugin/unraid/pages/monitor.php +++ b/Plugin/unraid/pages/monitor.php @@ -243,12 +243,20 @@ $_vv_doc_vars = array_merge(vv_conf_vars(), ['SCRIPTS_DIR' => SCRIPTS_DIR]);
Loading...
+

Streams +

Loading...
@@ -986,6 +994,12 @@ function vvPollMonitor(live) { const pt = d.partner ?? {}; const ptHosts = pt.hosts ?? []; const ptRemote = d.remote_hosts ?? {}; + + // The Streams scope control is offered only once there is a partner to combine with. On a + // single-host install "This host" and "Mesh" are two names for the same view, and the + // Media Stack tab hides its equivalent for the same reason. + const _strmScopeEl = document.getElementById('vv-streams-scope'); + if (_strmScopeEl) _strmScopeEl.style.display = Object.keys(ptRemote).length ? 'flex' : 'none'; const ptStatus = pt.enabled ? `enabled · sync every ${pt.sync_min}min` : `disabled`; @@ -2407,7 +2421,8 @@ function vvRenderStreams() { if (sessions.length === 0) { el.innerHTML = `
${badges}
` - + '

Nothing playing

'; + + `

Nothing playing${vvStreamScope === 'mesh' ? ' anywhere in the mesh' : ''}

` + + vvStreamNodeNotes(); return; } @@ -2437,7 +2452,12 @@ function vvRenderStreams() {
${icon} ${vvEscHtml(s.title)} - ${vvEscHtml(s.server)} + ${vvEscHtml(s.server)}${ + // Only in mesh scope: in local scope every row is this host, and the badge would be the + // same word on every line. + vvStreamScope === 'mesh' && s._hostName + ? `${vvEscHtml(s._hostName)}` : '' + }
${vvEscHtml(s.user)} @@ -2468,22 +2488,36 @@ function vvRenderStreams() {
${badges}${mtypeSection}
${deviceSection}${resSection}${codecSection}
-
${sCols.map(vvStreamCol).join('')}
${overflow}`; +
${sCols.map(vvStreamCol).join('')}
${overflow}${vvStreamNodeNotes()}`; } +// Local by default, and it stays the cheap path — one call per media server on this box. Mesh adds +// a bounded SSH hop per partner, paid only while the operator is looking at it. +let vvStreamScope = 'local'; +let vvStreamNodes = []; + function vvPollStreams() { - return fetch('/plugins/varaverk/api/media.php') + const scope = vvStreamScope; + return fetch('/plugins/varaverk/api/media.php' + (scope === 'mesh' ? '?scope=mesh' : '')) .then(r => r.json()) .then(d => { + // A reply that arrives after the operator switched scope describes the other view. Dropped + // rather than rendered: mesh rows landing in a local view is the kind of wrongness nobody + // reads as a bug, they just believe it. + if (scope !== vvStreamScope) return; + vvLastSessions = d.sessions ?? []; vvLastStreamNames = d.server_names ?? []; vvStreamServerCount = d.server_count ?? 0; + vvStreamNodes = d.nodes ?? []; vvLastStreamPollAt = Math.floor(Date.now() / 1000); const el = document.getElementById('vv-streams-body'); if (vvStreamServerCount === 0) { - el.innerHTML = '

No media servers detected.
' - + 'Add EMBY_API_KEY / JELLYFIN_API_KEY / PLEX_TOKEN to master.conf to configure.

'; + el.innerHTML = scope === 'mesh' + ? '

No media servers anywhere in the mesh.

' + vvStreamNodeNotes() + : '

No media servers detected.
' + + 'Add EMBY_API_KEY / JELLYFIN_API_KEY / PLEX_TOKEN to master.conf to configure.

'; return; } vvRenderStreams(); @@ -2491,6 +2525,34 @@ function vvPollStreams() { .catch(() => {}); } +// An unreachable partner is stated, never folded into the totals as zero. "Nobody is watching +// there" and "that node did not answer" are opposite facts and look identical in a count. +function vvStreamNodeNotes() { + if (vvStreamScope !== 'mesh') return ''; + const bad = vvStreamNodes.filter(n => !n.local && !n.reachable); + if (!bad.length) return ''; + return bad.map(n => + `
${vvEscHtml(n.name)} — unreachable, not counted
` + ).join(''); +} + +// Delegated off the card, so it survives the body being replaced on every poll. +document.getElementById('vv-streams')?.addEventListener('click', ev => { + const btn = ev.target.closest('[data-strmscope]'); + if (!btn) return; + const next = btn.dataset.strmscope; + if (next === vvStreamScope) return; + + vvStreamScope = next; + document.querySelectorAll('#vv-streams [data-strmscope]').forEach(b => + b.classList.toggle('on', b.dataset.strmscope === next)); + + // Switching is a request to see the other view now, not at the next tick. + const el = document.getElementById('vv-streams-body'); + if (el) el.innerHTML = '

Loading…

'; + vvPollStreams(); +}); + // Guarded like the others — this one reaches out to every configured media server, so a wedged // Emby is exactly the case where unguarded ticks would stack. vvPollRunner(vvPollStreams, 12000);