varaverk: add media, confform, script API/include files (from previous session)

media.php: Emby/Jellyfin/Plex session poller for Streams card
confform.php: conf file form helpers for Config tab
script.php: per-script run/log API endpoint for Scheduler tab
This commit is contained in:
Gmer4Lfe
2026-05-24 10:59:13 -04:00
parent 279ac156c2
commit b2e937526f
5 changed files with 514 additions and 0 deletions
@@ -0,0 +1,43 @@
<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/scheduler.php';
require_once dirname(__DIR__) . '/include/confform.php';
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$id = trim($_GET['id'] ?? '');
if (!$id || str_contains($id, '..')) {
echo json_encode(['ok' => false, 'error' => 'Invalid id']);
exit;
}
$groups = vv_conf_fields_for_script($id);
echo json_encode(['ok' => true, 'groups' => $groups]);
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$id = trim($_POST['id'] ?? '');
$rawJson = $_POST['changes'] ?? '[]';
if (!$id) { echo json_encode(['ok' => false, 'error' => 'Missing id']); exit; }
$changes = json_decode($rawJson, true);
if (!is_array($changes)) { echo json_encode(['ok' => false, 'error' => 'Invalid changes']); exit; }
$allowed = vv_get_conf_files();
foreach ($changes as $c) {
if (empty($c['file']) || !in_array($c['file'], $allowed, true)) {
echo json_encode(['ok' => false, 'error' => 'Unauthorized file: ' . ($c['file'] ?? '')]);
exit;
}
if (empty($c['key']) || !preg_match('/^[A-Z_][A-Z0-9_]*$/', $c['key'])) {
echo json_encode(['ok' => false, 'error' => 'Invalid key: ' . ($c['key'] ?? '')]);
exit;
}
}
$results = vv_conf_write_changes($changes);
echo json_encode(['ok' => !in_array(false, $results, true), 'files' => $results]);
exit;
}
echo json_encode(['ok' => false, 'error' => 'Method not allowed']);
@@ -0,0 +1,5 @@
<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/media.php';
echo json_encode(vv_media_sessions());
@@ -0,0 +1,62 @@
<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/scheduler.php';
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$id = trim($_GET['id'] ?? '');
if (!$id || !preg_match('/^Custom\/[a-zA-Z0-9_\-]+\.sh$/', $id) || str_contains($id, '..')) {
echo json_encode(['ok' => false, 'error' => 'Invalid id']);
exit;
}
$path = SCRIPTS_DIR . '/' . $id;
echo json_encode(['ok' => true, 'content' => file_exists($path) ? file_get_contents($path) : '']);
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = trim($_POST['action'] ?? 'save');
$name = trim($_POST['name'] ?? '');
$content = $_POST['content'] ?? '';
if (!$name || !preg_match('/^[a-zA-Z0-9_\-]+$/', $name)) {
echo json_encode(['ok' => false, 'error' => 'Name must be letters, numbers, _ or - only']);
exit;
}
$id = 'Custom/' . $name . '.sh';
$path = SCRIPTS_DIR . '/Custom/' . $name . '.sh';
if ($action === 'delete') {
if (!file_exists($path)) {
echo json_encode(['ok' => false, 'error' => 'Script not found']);
exit;
}
unlink($path);
$schedule = vv_schedule_load();
unset($schedule[$id]);
vv_schedule_save($schedule);
vv_cron_rebuild($schedule);
echo json_encode(['ok' => true]);
exit;
}
$dir = SCRIPTS_DIR . '/Custom';
if (!is_dir($dir)) mkdir($dir, 0755, true);
if (file_put_contents($path, $content) === false) {
echo json_encode(['ok' => false, 'error' => 'Failed to write script']);
exit;
}
chmod($path, 0755);
// Ensure schedule.json has an entry so the script appears in the job list
$schedule = vv_schedule_load();
if (!isset($schedule[$id])) {
$schedule[$id] = ['id' => $id, 'enabled' => false, 'cron' => '', 'log_enabled' => false, 'updated' => date('c')];
vv_schedule_save($schedule);
}
echo json_encode(['ok' => true, 'id' => $id]);
exit;
}
echo json_encode(['ok' => false, 'error' => 'Method not allowed']);
@@ -0,0 +1,209 @@
<?php
// confform.php — script→conf-section mapping, field parsing, and write-back.
// Map: script relative id → subsection names (must match # ━━━ Name ━━━ or # ── Name ── headers).
const VV_SCRIPT_CONF_SECTIONS = [
// Orchestrators
'Orchestrators/array_started.sh' => ['Array Start'],
'Orchestrators/array_stopping.sh' => ['Array Stop'],
'Orchestrators/watchdog_orchestrator.sh' => ['Watchdog Orchestrator', 'System Watchdog'],
'Orchestrators/critical_sync_maintenance.sh' => ['Critical Sync Maintenance', 'Critical Sync Shares'],
'Orchestrators/intermediate_sync_maintenance.sh' => ['Intermediate Sync Maintenance', 'Intermediate Sync Shares'],
'Orchestrators/daily_sync_maintenance.sh' => ['Daily Sync Maintenance', 'Daily Sync Shares'],
'Orchestrators/weekly_sync_maintenance.sh' => ['Weekly Sync Maintenance', 'Weekly Sync Shares'],
'Orchestrators/monthly_maintenance.sh' => ['Monthly Maintenance'],
'Orchestrators/transcode_management.sh' => ['Transcode Manager', 'Transcode Server Array', 'Transcodes'],
// Docker Essentials
'Docker_Essentials/docker_daily_restart.sh' => ['Docker Daily Restart'],
'Docker_Essentials/docker_weekly_restart.sh' => ['Docker Weekly Restart'],
'Docker_Essentials/docker_network_connect.sh' => ['Docker Network Connect'],
'Docker_Essentials/downloaders_reset.sh' => ['Downloaders Reset', 'Downloaders'],
// Watchdogs
'Watchdogs/docker_watchdog.sh' => ['Docker Watchdog'],
'Watchdogs/resource_watchdog.sh' => ['Pressure Levels'],
'Watchdogs/System/network_watchdog.sh' => ['Network Watchdog'],
'Watchdogs/System/webgui_watchdog.sh' => ['WebGUI Watchdog'],
// Media
'Media/media_cleaner.sh' => ['Media Cleaner'],
'Media/media_shares_permissions.sh' => ['Media Permissions'],
'Media/arrs_failed_stalled_recovery.sh' => ['Arr Failed/Stalled Recovery'],
'Media/radarr_cleanup.sh' => ['Arr Cleanup'],
'Media/lidarr_cleanup.sh' => ['Arr Cleanup'],
'Media/sonarr_cleanup.sh' => ['Arr Cleanup'],
// Monitors
'Monitors/cert_monitor.sh' => ['Certificate Monitor'],
'Monitors/backup_verify.sh' => ['Backup Verify'],
'Monitors/smart_health.sh' => ['SMART Health'],
'Monitors/bandwidth_monitor.sh' => ['Bandwidth Monitor'],
'Monitors/emby_session_report.sh' => ['Emby Session Report'],
'Monitors/zfs_memory_snapshot.sh' => ['ZFS Report'],
];
function vv_conf_has_sections(string $id): bool {
return !empty(VV_SCRIPT_CONF_SECTIONS[$id] ?? []);
}
// Parse fields from a named subsection in raw conf content.
// Headers accepted: # ━━━ Name ━━━ OR # ── Name ── (any mix of ━ ─ chars).
// Returns array of field defs, or null if subsection not found.
function vv_conf_parse_subsection(string $raw, string $subName, string $filename): ?array {
$lines = explode("\n", $raw);
$n = count($lines);
$needle = mb_strtolower(trim(preg_replace('/\s+/', ' ', $subName)));
$start = -1;
for ($i = 0; $i < $n; $i++) {
if (!preg_match('/^#\s*[━─]{2,}\s+([A-Za-z].+?)\s+[━─]{2,}/', $lines[$i], $m)) continue;
$t = mb_strtolower(trim(preg_replace('/\s+/', ' ', $m[1])));
if ($t === $needle) { $start = $i + 1; break; }
}
if ($start === -1) return null;
// End at next section/subsection line (3+ consecutive divider chars after #)
$end = $n;
for ($i = $start; $i < $n; $i++) {
if (preg_match('/^#\s*[━─═=]{3,}/', $lines[$i])) { $end = $i; break; }
}
$fields = [];
$pendingDesc = [];
for ($i = $start; $i < $end; $i++) {
$line = rtrim($lines[$i]);
if ($line === '' || $line === '#') { $pendingDesc = []; continue; }
// Pure comment line
if (preg_match('/^#\s*(.*)$/', $line, $cm)) {
$inner = trim($cm[1]);
if ($inner !== '' && !preg_match('/^[━─═=\-\s]+$/', $inner)) {
$pendingDesc[] = $inner;
}
continue;
}
$desc = implode(' ', $pendingDesc);
$pendingDesc = [];
// declare -A KEY=(
if (preg_match('/^(\s*)declare\s+-A\s+([A-Z_][A-Z0-9_]*)\s*=\s*\(/', $line, $m)) {
$indent = $m[1]; $key = $m[2];
$blockLines = [];
$j = $i + 1;
while ($j < $end && !preg_match('/^\s*\)\s*$/', $lines[$j])) {
$blockLines[] = rtrim($lines[$j]);
$j++;
}
$fields[] = ['key' => $key, 'value' => implode("\n", $blockLines),
'type' => 'assoc_array', 'desc' => $desc, 'file' => $filename, 'indent' => $indent];
$i = $j;
continue;
}
// KEY=( (array)
if (preg_match('/^(\s*)([A-Z_][A-Z0-9_]*)\s*=\s*\(/', $line, $m)) {
$indent = $m[1]; $key = $m[2];
// Single-line: KEY=( ... )
if (preg_match('/^[^(]*\(([^)]*)\)/', $line, $sm)) {
$fields[] = ['key' => $key, 'value' => $sm[1],
'type' => 'array_single', 'desc' => $desc, 'file' => $filename, 'indent' => $indent];
continue;
}
// Multi-line
$blockLines = [];
$j = $i + 1;
while ($j < $end && !preg_match('/^\s*\)\s*$/', $lines[$j])) {
$blockLines[] = rtrim($lines[$j]);
$j++;
}
$fields[] = ['key' => $key, 'value' => implode("\n", $blockLines),
'type' => 'array', 'desc' => $desc, 'file' => $filename, 'indent' => $indent];
$i = $j;
continue;
}
// Scalar: KEY="value" or KEY=value
if (preg_match('/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*"([^"]*)"(?:\s+#\s*(.+))?$/', $line, $m)) {
$fields[] = ['key' => $m[1], 'value' => $m[2],
'type' => 'scalar', 'desc' => ($m[3] ?? '') ?: $desc, 'file' => $filename];
continue;
}
if (preg_match('/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*([^(\n#]*?)(?:\s+#\s*(.+))?$/', $line, $m)) {
$val = trim($m[2]);
if ($val === '') continue;
$fields[] = ['key' => $m[1], 'value' => $val,
'type' => 'scalar', 'desc' => ($m[3] ?? '') ?: $desc, 'file' => $filename];
}
}
return $fields ?: null;
}
// Return all conf groups (subsection + fields) for a script on the current host.
function vv_conf_fields_for_script(string $id): array {
$sectionNames = VV_SCRIPT_CONF_SECTIONS[$id] ?? [];
if (!$sectionNames) return [];
$groups = [];
foreach ($sectionNames as $name) {
foreach (vv_get_conf_files() as $filename) {
$fields = vv_conf_parse_subsection(vv_read_conf_raw($filename), $name, $filename);
if ($fields !== null) {
$groups[] = ['subsection' => $name, 'file' => $filename, 'fields' => $fields];
}
}
}
return $groups;
}
// Write a batch of field changes back to their respective conf files.
// Each change: {file, key, value, type}
function vv_conf_write_changes(array $changes): array {
$byFile = [];
foreach ($changes as $c) {
if (!empty($c['file']) && !empty($c['key'])) $byFile[$c['file']][] = $c;
}
$results = [];
foreach ($byFile as $file => $fileChanges) {
$raw = vv_read_conf_raw($file);
if ($raw === '') { $results[$file] = false; continue; }
foreach ($fileChanges as $c) {
$qKey = preg_quote($c['key'], '/');
$value = $c['value'];
$type = $c['type'] ?? 'scalar';
if ($type === 'scalar') {
$raw = preg_replace_callback(
'/^(\s*' . $qKey . '\s*=\s*)("(?:[^"\\\\]|\\\\.)*"|\'(?:[^\'\\\\]|\\\\.)*\'|[^#\n]*?)(\s*(?:#[^\n]*)?)$/m',
fn($m) => $m[1] . '"' . str_replace(['"', '\\'], ['\\"', '\\\\'], $value) . '"' . $m[3],
$raw
) ?? $raw;
} elseif ($type === 'array_single') {
$raw = preg_replace_callback(
'/^(\s*' . $qKey . '\s*=\s*\()([^)]*)(\)(?:\s*(?:#[^\n]*)?)?)$/m',
fn($m) => $m[1] . $value . $m[3],
$raw
) ?? $raw;
} elseif ($type === 'array') {
$raw = preg_replace_callback(
'/^(\s*)(' . $qKey . '\s*=\s*\()[^)]*\)/ms',
fn($m) => $m[1] . $m[2] . "\n" . $value . "\n" . $m[1] . ")",
$raw
) ?? $raw;
} elseif ($type === 'assoc_array') {
$raw = preg_replace_callback(
'/^(\s*)(declare\s+-A\s+' . $qKey . '\s*=\s*\()[^)]*\)/ms',
fn($m) => $m[1] . $m[2] . "\n" . $value . "\n" . $m[1] . ")",
$raw
) ?? $raw;
}
}
$results[$file] = vv_write_conf_raw($file, $raw);
}
return $results;
}
@@ -0,0 +1,195 @@
<?php
// Media server session helpers — reads HOST*_EMBY_* / JELLYFIN_* / PLEX_* from host conf.
require_once __DIR__ . '/config.php';
// ── Conf reader ───────────────────────────────────────────────────────────────
function vv_media_conf_scalar(string $raw, string $key): string {
return preg_match('/^\s*' . preg_quote($key, '/') . '\s*=\s*"?([^"\n]*)"?/m', $raw, $m)
? trim($m[1]) : '';
}
// ── 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';
}
$result[] = [
'server' => $srv['name'],
'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,
];
}
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'] ?? [];
$result[] = [
'server' => $srv['name'],
'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,
];
}
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),
];
}