From b2e937526fda60991ea9d46ad20d5bcb041a9910 Mon Sep 17 00:00:00 2001 From: Gmer4Lfe Date: Sun, 24 May 2026 10:59:13 -0400 Subject: [PATCH] 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 --- .../emhttp/plugins/varaverk/api/confform.php | 43 ++++ .../emhttp/plugins/varaverk/api/media.php | 5 + .../emhttp/plugins/varaverk/api/script.php | 62 ++++++ .../plugins/varaverk/include/confform.php | 209 ++++++++++++++++++ .../emhttp/plugins/varaverk/include/media.php | 195 ++++++++++++++++ 5 files changed, 514 insertions(+) create mode 100644 Plugin/usr/local/emhttp/plugins/varaverk/api/confform.php create mode 100644 Plugin/usr/local/emhttp/plugins/varaverk/api/media.php create mode 100644 Plugin/usr/local/emhttp/plugins/varaverk/api/script.php create mode 100644 Plugin/usr/local/emhttp/plugins/varaverk/include/confform.php create mode 100644 Plugin/usr/local/emhttp/plugins/varaverk/include/media.php diff --git a/Plugin/usr/local/emhttp/plugins/varaverk/api/confform.php b/Plugin/usr/local/emhttp/plugins/varaverk/api/confform.php new file mode 100644 index 0000000..4565eae --- /dev/null +++ b/Plugin/usr/local/emhttp/plugins/varaverk/api/confform.php @@ -0,0 +1,43 @@ + 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']); diff --git a/Plugin/usr/local/emhttp/plugins/varaverk/api/media.php b/Plugin/usr/local/emhttp/plugins/varaverk/api/media.php new file mode 100644 index 0000000..2fcd580 --- /dev/null +++ b/Plugin/usr/local/emhttp/plugins/varaverk/api/media.php @@ -0,0 +1,5 @@ + 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']); diff --git a/Plugin/usr/local/emhttp/plugins/varaverk/include/confform.php b/Plugin/usr/local/emhttp/plugins/varaverk/include/confform.php new file mode 100644 index 0000000..063fbf2 --- /dev/null +++ b/Plugin/usr/local/emhttp/plugins/varaverk/include/confform.php @@ -0,0 +1,209 @@ + ['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; +} diff --git a/Plugin/usr/local/emhttp/plugins/varaverk/include/media.php b/Plugin/usr/local/emhttp/plugins/varaverk/include/media.php new file mode 100644 index 0000000..fdef0af --- /dev/null +++ b/Plugin/usr/local/emhttp/plugins/varaverk/include/media.php @@ -0,0 +1,195 @@ + 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), + ]; +}