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.
603 lines
28 KiB
PHP
603 lines
28 KiB
PHP
<?php
|
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
// PURPOSE
|
|
// Media server session helpers. Discovers the Emby / Jellyfin / Plex instances declared in
|
|
// the host confs and returns who is currently watching what, for the monitor page's
|
|
// now-playing panel.
|
|
//
|
|
// DESIGN PRINCIPLES
|
|
// Conf declares the servers; nothing is probed or auto-detected.
|
|
// A server appears only because a URL and key were configured for it. Three server
|
|
// types are supported side by side — this is not an either/or.
|
|
//
|
|
// Placeholder credentials count as absent.
|
|
// A key still containing "your-" is a template default that was never filled in, so the
|
|
// server is skipped rather than queried. Half-configured is treated as unconfigured.
|
|
//
|
|
// Unknown host reads both host slots.
|
|
// When vv_detect_host() cannot identify the machine (development, or a hostname that
|
|
// does not match any HOST<n>), both host confs are tried so the page still shows
|
|
// something useful instead of nothing.
|
|
//
|
|
// Session shape is normalised across server types.
|
|
// Emby, Jellyfin and Plex return quite different payloads; callers get one consistent
|
|
// structure and do not branch on server type.
|
|
//
|
|
// OPERATIONAL SAFEGUARDS
|
|
// Every request is time-boxed at 3 seconds.
|
|
// Session lookups run inside a page render, so a hung media server must not hold the
|
|
// request open. The stream context timeout is the only thing standing between a
|
|
// wedged Emby and a page that never returns.
|
|
//
|
|
// Any failure yields an empty list, never an exception.
|
|
// Unreachable server, non-JSON body, or an unexpected shape all return [] — the panel
|
|
// renders empty and the rest of the page is unaffected.
|
|
//
|
|
// Read-only. Sessions are observed; nothing is stopped, transcoded, or messaged.
|
|
//
|
|
// EXPORTS
|
|
// vv_discover_media_servers() configured Emby / Jellyfin / Plex instances for this host
|
|
// vv_media_sessions() normalised active sessions across all discovered servers
|
|
// vv_fetch_jf_sessions() Jellyfin-specific fetch
|
|
// vv_fetch_plex_sessions() Plex-specific fetch
|
|
// vv_media_conf_scalar() conf scalar reader used by the above
|
|
//
|
|
// CONFIGURATION
|
|
// HOST*_EMBY_URL / _EMBY_API_KEY / _EMBY_CONTAINER
|
|
// HOST*_JELLYFIN_URL / _JELLYFIN_API_KEY / _JELLYFIN_CONTAINER
|
|
// HOST*_PLEX_URL / _PLEX_TOKEN
|
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
|
|
require_once __DIR__ . '/config.php';
|
|
|
|
// ── Conf reader ───────────────────────────────────────────────────────────────
|
|
|
|
function vv_media_conf_scalar(string $raw, string $key): string {
|
|
return vv_parse_conf_scalar($raw, $key);
|
|
}
|
|
|
|
// ── Server list from host conf ────────────────────────────────────────────────
|
|
|
|
function vv_discover_media_servers(): array {
|
|
$host = vv_detect_host(); // 'host1', 'host2', 'unknown'
|
|
|
|
// For unknown (dev), try both host confs; otherwise read only the current host's file.
|
|
$hostSlots = $host !== 'unknown' ? [$host] : ['host1', 'host2'];
|
|
|
|
$servers = [];
|
|
foreach ($hostSlots as $h) {
|
|
$raw = vv_read_conf_raw($h . '.conf');
|
|
$prefix = strtoupper($h) . '_'; // HOST1_ or HOST2_
|
|
|
|
$get = fn(string $k) => vv_media_conf_scalar($raw, $prefix . $k);
|
|
|
|
// ── Emby ──────────────────────────────────────────────────────────────
|
|
$embyUrl = $get('EMBY_URL');
|
|
$embyKey = $get('EMBY_API_KEY');
|
|
if ($embyUrl && $embyKey && !str_contains($embyKey, 'your-')) {
|
|
$servers[] = [
|
|
'type' => 'emby',
|
|
'name' => $get('EMBY_CONTAINER') ?: 'Emby',
|
|
'url' => $embyUrl,
|
|
'key' => $embyKey,
|
|
];
|
|
}
|
|
|
|
// ── Jellyfin ──────────────────────────────────────────────────────────
|
|
$jfUrl = $get('JELLYFIN_URL');
|
|
$jfKey = $get('JELLYFIN_API_KEY');
|
|
if ($jfUrl && $jfKey && !str_contains($jfKey, 'your-')) {
|
|
$servers[] = [
|
|
'type' => 'jellyfin',
|
|
'name' => $get('JELLYFIN_CONTAINER') ?: 'Jellyfin',
|
|
'url' => $jfUrl,
|
|
'key' => $jfKey,
|
|
];
|
|
}
|
|
|
|
// ── Plex (optional) ───────────────────────────────────────────────────
|
|
$plexToken = $get('PLEX_TOKEN');
|
|
$plexUrl = $get('PLEX_URL') ?: 'http://localhost:32400';
|
|
if ($plexToken && !str_contains($plexToken, 'your-')) {
|
|
$servers[] = [
|
|
'type' => 'plex',
|
|
'name' => 'Plex',
|
|
'url' => $plexUrl,
|
|
'token' => $plexToken,
|
|
];
|
|
}
|
|
}
|
|
|
|
// Deduplicate — same server can appear from multiple conf sources (unknown host, shared keys)
|
|
$seen = [];
|
|
$unique = [];
|
|
foreach ($servers as $s) {
|
|
$k = $s['type'] . '|' . ($s['url'] ?? $s['token'] ?? '');
|
|
if (!isset($seen[$k])) { $seen[$k] = true; $unique[] = $s; }
|
|
}
|
|
return $unique;
|
|
}
|
|
|
|
// ── Session fetchers ──────────────────────────────────────────────────────────
|
|
|
|
function vv_fetch_jf_sessions(array $srv): array {
|
|
$url = rtrim($srv['url'], '/') . '/Sessions?api_key=' . urlencode($srv['key']) . '&activeWithinSeconds=60';
|
|
$ctx = stream_context_create(['http' => ['timeout' => 3]]);
|
|
$raw = @file_get_contents($url, false, $ctx);
|
|
if (!$raw) return [];
|
|
$sessions = json_decode($raw, true);
|
|
if (!is_array($sessions)) return [];
|
|
|
|
$result = [];
|
|
foreach ($sessions as $s) {
|
|
if (empty($s['NowPlayingItem'])) continue;
|
|
$item = $s['NowPlayingItem'];
|
|
$ps = $s['PlayState'] ?? [];
|
|
$tc = $s['TranscodingInfo'] ?? null;
|
|
|
|
$type = $item['Type'] ?? '';
|
|
$title = $item['Name'] ?? 'Unknown';
|
|
if ($type === 'Episode' && !empty($item['SeriesName'])) {
|
|
$ep = sprintf('S%02dE%02d', $item['ParentIndexNumber'] ?? 0, $item['IndexNumber'] ?? 0);
|
|
$title = $item['SeriesName'] . ' ' . $ep;
|
|
}
|
|
|
|
$pos = (int)($ps['PositionTicks'] ?? 0);
|
|
$dur = (int)($item['RunTimeTicks'] ?? 0);
|
|
$pct = $dur > 0 ? min(100, (int)round($pos / $dur * 100)) : 0;
|
|
|
|
if ($tc) {
|
|
$vc = strtoupper($tc['VideoCodec'] ?? '');
|
|
$hw = !empty($tc['IsHardwareAcceleratedVideoDecoding']) ? ' HW' : '';
|
|
$method = 'Transcode' . ($vc ? " ($vc$hw)" : '');
|
|
} else {
|
|
$pm = $ps['PlayMethod'] ?? '';
|
|
$method = $pm === 'DirectStream' ? 'Direct Stream' : 'Direct Play';
|
|
}
|
|
|
|
// Source video stream — resolution and codec of the file being played
|
|
$videoStream = null;
|
|
foreach ($item['MediaStreams'] ?? [] as $ms) {
|
|
if (($ms['Type'] ?? '') === 'Video') { $videoStream = $ms; break; }
|
|
}
|
|
|
|
$result[] = [
|
|
'server' => $srv['name'],
|
|
'server_type' => $srv['type'],
|
|
'user' => $s['UserName'] ?? '?',
|
|
'title' => $title,
|
|
'type' => $type,
|
|
'client' => trim(($s['Client'] ?? '') . ' / ' . ($s['DeviceName'] ?? ''), ' /'),
|
|
'method' => $method,
|
|
'paused' => !empty($ps['IsPaused']),
|
|
'pct' => $pct,
|
|
'pos_sec' => (int)($pos / 10000000),
|
|
'dur_sec' => (int)($dur / 10000000),
|
|
'is_tc' => $tc !== null,
|
|
'height' => (int)($videoStream['Height'] ?? 0),
|
|
'codec' => strtolower($videoStream['Codec'] ?? ''),
|
|
];
|
|
}
|
|
return $result;
|
|
}
|
|
|
|
function vv_fetch_plex_sessions(array $srv): array {
|
|
$url = rtrim($srv['url'], '/') . '/status/sessions?X-Plex-Token=' . urlencode($srv['token']);
|
|
$ctx = stream_context_create(['http' => ['timeout' => 3, 'header' => "Accept: application/json\r\n"]]);
|
|
$raw = @file_get_contents($url, false, $ctx);
|
|
if (!$raw) return [];
|
|
$data = json_decode($raw, true);
|
|
$items = $data['MediaContainer']['Metadata'] ?? [];
|
|
if (!is_array($items)) return [];
|
|
|
|
$result = [];
|
|
foreach ($items as $m) {
|
|
$type = strtolower($m['type'] ?? '');
|
|
$title = $m['title'] ?? 'Unknown';
|
|
if ($type === 'episode') {
|
|
$title = ($m['grandparentTitle'] ?? '') . ' S' . str_pad($m['parentIndex'] ?? 0, 2, '0', STR_PAD_LEFT)
|
|
. 'E' . str_pad($m['index'] ?? 0, 2, '0', STR_PAD_LEFT);
|
|
}
|
|
|
|
$dur = (int)($m['duration'] ?? 0);
|
|
$offset = (int)($m['viewOffset'] ?? 0);
|
|
$pct = $dur > 0 ? min(100, (int)round($offset / $dur * 100)) : 0;
|
|
|
|
$tcInfo = $m['TranscodeSession'] ?? null;
|
|
$isTc = $tcInfo !== null;
|
|
if ($isTc) {
|
|
$vc = strtoupper($tcInfo['videoCodec'] ?? '');
|
|
$hw = !empty($tcInfo['transcodeHwEncoding']) ? ' HW' : '';
|
|
$method = 'Transcode' . ($vc ? " ($vc$hw)" : '');
|
|
} else {
|
|
$method = 'Direct Play';
|
|
}
|
|
|
|
$player = $m['Player'] ?? [];
|
|
$media0 = $m['Media'][0] ?? [];
|
|
$plexH = (int)($media0['height'] ?? 0);
|
|
if (!$plexH && !empty($media0['videoResolution'])) {
|
|
$vr = strtolower($media0['videoResolution']);
|
|
$plexH = $vr === '4k' ? 2160 : (int)$vr;
|
|
}
|
|
$result[] = [
|
|
'server' => $srv['name'],
|
|
'server_type' => $srv['type'],
|
|
'user' => ($m['User']['title'] ?? '?'),
|
|
'title' => $title,
|
|
'type' => ucfirst($type),
|
|
'client' => trim(($player['product'] ?? '') . ' / ' . ($player['title'] ?? ''), ' /'),
|
|
'method' => $method,
|
|
'paused' => ($player['state'] ?? '') === 'paused',
|
|
'pct' => $pct,
|
|
'pos_sec' => (int)($offset / 1000),
|
|
'dur_sec' => (int)($dur / 1000),
|
|
'is_tc' => $isTc,
|
|
'height' => $plexH,
|
|
'codec' => strtolower($media0['videoCodec'] ?? ''),
|
|
];
|
|
}
|
|
return $result;
|
|
}
|
|
|
|
// ── Public entry point ────────────────────────────────────────────────────────
|
|
|
|
function vv_media_sessions(): array {
|
|
$servers = vv_discover_media_servers();
|
|
$sessions = [];
|
|
foreach ($servers as $srv) {
|
|
$found = $srv['type'] === 'plex'
|
|
? vv_fetch_plex_sessions($srv)
|
|
: vv_fetch_jf_sessions($srv);
|
|
foreach ($found as $s) $sessions[] = $s;
|
|
}
|
|
return [
|
|
'sessions' => $sessions,
|
|
'server_names' => array_column($servers, 'name'),
|
|
'server_count' => count($servers),
|
|
];
|
|
}
|
|
|
|
// 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
|
|
// and how long it has been up. Neither half can answer "is Emby healthy" on its own — a server
|
|
// answering happily at 130% CPU is a different situation from one answering happily at 5%.
|
|
//
|
|
// Cost is one scoped `docker stats` (~2s for two containers, not the ~8s an unscoped call takes),
|
|
// one inspect, and one HTTP call per server. That rides the arrs payload's cache rather than being
|
|
// paid per page load — the background writer refreshes it every minute, so the CPU figure is at
|
|
// most a minute stale, which is honest for a card rather than a live graph. Monitor is where live
|
|
// belongs.
|
|
// Logical CPUs, which is what `docker stats` divides its CPU figure by — and therefore what has to
|
|
// be divided back out to get a share of the machine.
|
|
//
|
|
// Logical, not physical. This host reports 16 physical cores and 32 threads; docker's percentage is
|
|
// summed across all 32, so dividing by 16 would double every reading. The watchdog's own `cores`
|
|
// field is the physical 16 because it is comparing load averages, which is a different question —
|
|
// borrowing it here would have looked reasonable and been wrong by exactly a factor of two.
|
|
function vv_media_cpu_count(): int {
|
|
static $n = null;
|
|
if ($n !== null) return $n;
|
|
$n = (int)trim((string)@shell_exec('nproc 2>/dev/null'));
|
|
if ($n < 1) $n = max(1, (int)@substr_count((string)@file_get_contents('/proc/cpuinfo'), 'processor'));
|
|
return $n;
|
|
}
|
|
|
|
function vv_media_container_stats(array $names): array {
|
|
$names = array_values(array_filter($names));
|
|
if (!$names) return [];
|
|
|
|
$cpus = vv_media_cpu_count();
|
|
$out = [];
|
|
$args = implode(' ', array_map('escapeshellarg', $names));
|
|
$fmt = '{{.Name}}|{{.CPUPerc}}|{{.MemUsage}}|{{.MemPerc}}';
|
|
$raw = shell_exec("docker stats --no-stream --format " . escapeshellarg($fmt) . " $args 2>/dev/null") ?: '';
|
|
foreach (explode("\n", trim($raw)) as $line) {
|
|
$p = explode('|', $line);
|
|
if (count($p) < 4) continue;
|
|
[$mUsed, $mLimit] = array_pad(array_map('trim', explode('/', $p[2])), 2, '');
|
|
$raw = (float)rtrim($p[1], '%');
|
|
$out[$p[0]] = [
|
|
// Share of the whole machine, so this card and Unraid's own Docker page report the
|
|
// same container as the same number. docker's raw figure is per-core-summed: 227% is
|
|
// 2.3 cores busy, which reads as an emergency on a card and is 7% of a 32-thread host.
|
|
// Kept alongside rather than discarded — "2.3 cores" is the useful form once you know
|
|
// it is not a percentage of anything.
|
|
'cpu_pct' => round($raw / max(1, $cpus), 2),
|
|
'cpu_raw' => $raw,
|
|
'cpu_cores' => round($raw / 100, 2),
|
|
'mem_used' => $mUsed,
|
|
'mem_limit' => $mLimit,
|
|
'mem_pct' => (float)rtrim($p[3], '%'),
|
|
];
|
|
}
|
|
|
|
// Uptime separately: docker stats does not carry it, and StartedAt is the only thing that
|
|
// distinguishes "up for a week" from "restarted four minutes ago", which is the first thing
|
|
// worth knowing about a server that is behaving oddly.
|
|
$insp = shell_exec("docker inspect -f '{{.Name}}|{{.State.StartedAt}}|{{.State.Running}}' $args 2>/dev/null") ?: '';
|
|
foreach (explode("\n", trim($insp)) as $line) {
|
|
$p = explode('|', $line);
|
|
if (count($p) < 3) continue;
|
|
$n = ltrim(trim($p[0]), '/');
|
|
$started = strtotime(trim($p[1])) ?: 0;
|
|
$out[$n]['uptime'] = $started ? max(0, time() - $started) : null;
|
|
$out[$n]['running'] = trim($p[2]) === 'true';
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
// Emby and Jellyfin both answer /System/Info with the same field names — they share an ancestor —
|
|
// so one shape covers both. Plex does not, and is deliberately left reporting only what the
|
|
// session layer already knows about it rather than guessing at an equivalent.
|
|
function vv_media_server_info(array $srv): array {
|
|
$hdr = $srv['type'] === 'jellyfin'
|
|
? "X-Emby-Token: {$srv['key']}\r\n"
|
|
: "X-Emby-Token: {$srv['key']}\r\n";
|
|
$ctx = stream_context_create(['http' => [
|
|
'timeout' => 4, 'header' => $hdr . "Accept: application/json\r\n", 'ignore_errors' => true,
|
|
]]);
|
|
$raw = @file_get_contents(rtrim($srv['url'], '/') . '/System/Info', false, $ctx);
|
|
$d = $raw ? json_decode($raw, true) : null;
|
|
if (!is_array($d)) return ['online' => false];
|
|
|
|
return [
|
|
'online' => true,
|
|
'version' => $d['Version'] ?? null,
|
|
'server_name' => $d['ServerName'] ?? null,
|
|
'os' => $d['OperatingSystemDisplayName'] ?? ($d['OperatingSystem'] ?? null),
|
|
'update_available' => !empty($d['HasUpdateAvailable']),
|
|
'pending_restart' => !empty($d['HasPendingRestart']),
|
|
'maintenance' => !empty($d['IsInMaintenanceMode']),
|
|
];
|
|
}
|
|
|
|
function vv_media_server_stats(): array {
|
|
$servers = vv_discover_media_servers();
|
|
if (!$servers) return [];
|
|
|
|
$vars = vv_conf_vars();
|
|
$myId = strtoupper(vv_detect_host());
|
|
|
|
// Container name comes from conf, not from the server's own name: HOST1_EMBY_CONTAINER is what
|
|
// docker answers to, and the two are routinely different — this one calls itself
|
|
// "Emby-Gmer4Lfe" and runs in a container called "Emby".
|
|
$ctrOf = [];
|
|
foreach ($servers as $s) {
|
|
$ctrOf[$s['name']] = $vars[$myId . '_' . strtoupper($s['type']) . '_CONTAINER'] ?? '';
|
|
}
|
|
$stats = vv_media_container_stats(array_values($ctrOf));
|
|
|
|
// One session fetch for everything, then split by server — asking each server separately would
|
|
// duplicate work the session layer already does for the whole estate.
|
|
$sess = vv_media_sessions()['sessions'] ?? [];
|
|
|
|
$out = [];
|
|
foreach ($servers as $s) {
|
|
$mine = array_values(array_filter($sess, fn($x) => ($x['server'] ?? '') === $s['name']));
|
|
$users = array_values(array_unique(array_filter(array_column($mine, 'user'))));
|
|
$ctr = $ctrOf[$s['name']] ?? '';
|
|
$out[] = [
|
|
'name' => $s['name'],
|
|
'type' => $s['type'],
|
|
'container' => $ctr,
|
|
'sessions' => count($mine),
|
|
'users' => count($users),
|
|
'transcodes'=> count(array_filter($mine, fn($x) => !empty($x['is_tc']))),
|
|
'checked' => time(),
|
|
] + vv_media_server_info($s) + ($stats[$ctr] ?? []);
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
// ── The mesh view ─────────────────────────────────────────────────────────────────────────────
|
|
// Local live, partners from cache — the same split the arrs use, for the same reason. A partner's
|
|
// Emby URL is http://localhost:8096, which is true on that host and meaningless here, so nobody
|
|
// queries a partner's media server directly. Each host answers about itself over SSH in
|
|
// remote_arr_cache_writer.sh and its API keys never leave it.
|
|
//
|
|
// A partner with no cache is reported as not collected rather than as having no media servers.
|
|
// Those are different states and only one of them is the partner's fault — the arrs page already
|
|
// makes that distinction and this matches it rather than inventing a second vocabulary.
|
|
function vv_media_servers_mesh(): array {
|
|
$me = vv_detect_host();
|
|
$names = function_exists('vv_known_hosts') ? vv_known_hosts() : [$me => $me];
|
|
$out = [];
|
|
|
|
foreach (array_keys($names) as $h) {
|
|
if ($h === $me) {
|
|
$out[] = [
|
|
'host' => $h,
|
|
'name' => $names[$h] ?? $h,
|
|
'local' => true,
|
|
'servers' => vv_media_server_stats(),
|
|
];
|
|
continue;
|
|
}
|
|
$f = VV_CACHE_DIR . '/media_remote_' . $h . '.json';
|
|
$raw = @file_get_contents($f);
|
|
$d = $raw !== false ? json_decode($raw, true) : null;
|
|
$out[] = [
|
|
'host' => $h,
|
|
'name' => $names[$h] ?? $h,
|
|
'local' => false,
|
|
'cached' => is_array($d),
|
|
'cache_miss' => !is_array($d),
|
|
'cache_age' => is_array($d) ? max(0, time() - (int)@filemtime($f)) : null,
|
|
'servers' => is_array($d) ? $d : [],
|
|
];
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
// ── The media jobs that had nowhere to be seen ────────────────────────────────────────────────
|
|
// Three scripts that operate on the media files every day and appeared in no tab: play state sync
|
|
// every thirty minutes, permissions and the cleaner nightly. The only way to know whether any of
|
|
// them had run was to open the Scheduler and read an orchestrator's log.
|
|
//
|
|
// Readable now because run_orch_child() writes a run record and a per-script log for its children.
|
|
// Before that this function could not have been written: the record did not exist, and the output
|
|
// was interleaved into a parent log with forty other scripts behind headings that name no script.
|
|
const VV_MEDIA_JOBS = [
|
|
'Media/play_state_sync' => 'Play state sync',
|
|
'Media/media_shares_permissions' => 'Media permissions',
|
|
'Media/media_cleaner' => 'Media cleaner',
|
|
];
|
|
|
|
// The last line the script chose to end on. All three close with a SUMMARY block whose final 🏁
|
|
// line is the sentence a human would read out — "done — 21 shares updated", "clean — nothing to
|
|
// remove" — so that line is taken verbatim rather than re-derived from counts this would have to
|
|
// parse differently per script.
|
|
//
|
|
// Decorations are stripped, not matched: the three write 🏁 with varying spacing and only some
|
|
// prefix "Status:" or "[OK]". Matching those shapes would break the first time one was reworded,
|
|
// and the words after them are the part with the meaning.
|
|
function vv_media_job_summary(string $id): string {
|
|
$path = LOG_DIR . '/' . $id . '.log';
|
|
$raw = @file_get_contents($path);
|
|
if ($raw === false) return '';
|
|
|
|
$lines = array_slice(explode("\n", rtrim($raw)), -60);
|
|
$out = '';
|
|
foreach ($lines as $line) {
|
|
if (!str_contains($line, '🏁')) continue;
|
|
$t = trim(str_replace('🏁', '', $line));
|
|
$t = preg_replace('/^\[(OK|WARN|INFO|ERROR)\]\s*/u', '', trim($t));
|
|
$t = preg_replace('/^Status:\s*/u', '', $t);
|
|
$t = trim($t);
|
|
// "Done ✅" carries nothing the status field does not already say; keep looking for a line
|
|
// that does. If nothing better appears, the earlier one already captured is kept.
|
|
if ($t === '' || preg_match('/^done\s*✅?$/iu', $t)) continue;
|
|
$out = $t;
|
|
}
|
|
return mb_substr($out, 0, 160);
|
|
}
|
|
|
|
function vv_media_jobs(): array {
|
|
$out = [];
|
|
foreach (VV_MEDIA_JOBS as $id => $label) {
|
|
$rec = @json_decode((string)@file_get_contents(LOG_DIR . '/' . $id . '.json'), true);
|
|
$rec = is_array($rec) ? $rec : [];
|
|
$start = isset($rec['start']) ? (int)$rec['start'] : 0;
|
|
$end = isset($rec['end']) ? (int)$rec['end'] : 0;
|
|
$out[] = [
|
|
'id' => $id . '.sh',
|
|
'label' => $label,
|
|
'last_run' => $start ?: null,
|
|
'status' => $rec['status'] ?? null,
|
|
'exit' => isset($rec['exit']) ? (int)$rec['exit'] : null,
|
|
'duration' => ($start && $end) ? $end - $start : null,
|
|
'summary' => vv_media_job_summary($id),
|
|
];
|
|
}
|
|
return $out;
|
|
}
|