), 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), ]; } // ── 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. function vv_media_container_stats(array $names): array { $names = array_values(array_filter($names)); if (!$names) return []; $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, ''); $out[$p[0]] = [ 'cpu_pct' => (float)rtrim($p[1], '%'), '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; }