docker stats sums CPU across every logical processor, so Jellyfin's "227%" is 2.3 threads busy — 7% of a 32-thread host, which is what Unraid shows and what the operator was reading. The card was printing the raw figure and colouring it against thresholds meant for a percentage, so ordinary background work looked like an emergency; I twice flagged Jellyfin as pegged on that basis and was wrong both times. Divides by nproc, not the watchdog's `cores` — that field is the physical 16 because it compares load averages, and borrowing it here would have looked reasonable and doubled every reading.
496 lines
23 KiB
PHP
496 lines
23 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),
|
|
];
|
|
}
|
|
|
|
// ── 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;
|
|
}
|