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]);
Nothing playing
'; + + `Nothing playing${vvStreamScope === 'mesh' ? ' anywhere in the mesh' : ''}
` + + vvStreamNodeNotes(); return; } @@ -2437,7 +2452,12 @@ function vvRenderStreams() {No media servers detected.
'
- + 'Add EMBY_API_KEY / JELLYFIN_API_KEY / PLEX_TOKEN to master.conf to configure.
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.
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);