Files
Varaverk/Plugin/unraid/include/media.php
T
Gmer4Lfe 1381a526ab Surface the three media jobs that appeared in no tab at all
play_state_sync, media_shares_permissions and media_cleaner work on the same
files the arrs manage and were visible only by opening the Scheduler and reading
an orchestrator's log. Readable at all because run_orch_child() now writes a run
record and a per-script log for its children — this could not have been written
yesterday. Each card shows the sentence the script itself ended on rather than a
count re-derived here, since the three word their outcome differently and the
wording is the part worth reading.
2026-08-14 18:51:54 -04:00

323 lines
15 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),
];
}
// ── 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;
}