diff --git a/sim/cluster0/lab-seeds/backend/config/config.example.php b/sim/cluster0/lab-seeds/backend/config/config.example.php index a994f44..44edaf8 100644 --- a/sim/cluster0/lab-seeds/backend/config/config.example.php +++ b/sim/cluster0/lab-seeds/backend/config/config.example.php @@ -121,4 +121,15 @@ return [ ], ], ], + // V.2x experimental restream — ac-ms-media-transcode worker scripts + 'media_pipelines' => [ + 'enabled' => true, + 'developers_only' => true, + 'script_root' => '/var/www/ac/workspace/ac-ms-media-transcode', + 'storage_root' => '/var/www/ac/media-pipelines', + 'hls_url_prefix' => '/app/androidcast_project/media/hls', + 'hls_filesystem_root' => '/var/www/ac/media-pipelines/hls', + 'logo_path' => '/var/www/ac/workspace/ac-ms-media-transcode/assets/pirate_drift.png', + 'ytdlp_enabled' => true, + ], ]; diff --git a/sim/cluster0/lab-seeds/backend/public/api/media_pipelines.php b/sim/cluster0/lab-seeds/backend/public/api/media_pipelines.php new file mode 100644 index 0000000..b787329 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/public/api/media_pipelines.php @@ -0,0 +1,118 @@ + false, 'error' => 'forbidden'], 403); +} + +$user = Auth::user(); +$userId = (int) ($user['id'] ?? 0); +$companyId = (int) cfg('rbac.default_company_id', 1); + +$method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; +$action = trim((string) ($_GET['action'] ?? '')); + +if ($method === 'GET') { + if ($action === 'config') { + json_out([ + 'ok' => true, + 'config' => [ + 'hls_url_prefix' => MediaPipelineRunner::hlsPublicPrefix(), + 'ytdlp_enabled' => (bool) cfg('media_pipelines.ytdlp_enabled', true), + 'script_root' => MediaPipelineRunner::scriptRoot(), + ], + 'permissions' => [ + 'can_operate' => MediaPipelinesRepository::gateCanOperate(), + ], + ]); + } + if ($action === 'list') { + json_out([ + 'ok' => true, + 'pipelines' => MediaPipelinesRepository::listForUser($userId, $companyId), + ]); + } + $id = (int) ($_GET['id'] ?? 0); + if ($action === 'get' && $id > 0) { + $row = MediaPipelinesRepository::getById($id, $userId, $companyId); + if ($row === null) { + json_out(['ok' => false, 'error' => 'not_found'], 404); + } + json_out(['ok' => true, 'pipeline' => $row]); + } + if ($action === 'logs' && $id > 0) { + $row = MediaPipelinesRepository::getById($id, $userId, $companyId); + if ($row === null) { + json_out(['ok' => false, 'error' => 'not_found'], 404); + } + json_out(['ok' => true, 'lines' => MediaPipelineRunner::tailLog($row)]); + } + json_out(['ok' => false, 'error' => 'unknown_action'], 400); +} + +if ($method !== 'POST') { + json_out(['ok' => false, 'error' => 'method_not_allowed'], 405); +} + +if (!MediaPipelinesRepository::gateCanOperate()) { + json_out(['ok' => false, 'error' => 'forbidden'], 403); +} + +$raw = file_get_contents('php://input') ?: ''; +$body = json_decode($raw, true); +if (!is_array($body)) { + json_out(['ok' => false, 'error' => 'invalid_json'], 400); +} + +if ($action === 'create') { + try { + $row = MediaPipelinesRepository::create($userId, $companyId, $body); + json_out(['ok' => true, 'pipeline' => $row]); + } catch (InvalidArgumentException $e) { + json_out(['ok' => false, 'error' => $e->getMessage()], 400); + } catch (Throwable $e) { + json_out(['ok' => false, 'error' => 'create_failed'], 500); + } +} + +$id = (int) ($body['id'] ?? $_GET['id'] ?? 0); +if ($id <= 0) { + json_out(['ok' => false, 'error' => 'missing_id'], 400); +} + +$pipeline = MediaPipelinesRepository::getById($id, $userId, $companyId); +if ($pipeline === null) { + json_out(['ok' => false, 'error' => 'not_found'], 404); +} + +if ($action === 'probe') { + $result = MediaPipelineRunner::probe($pipeline); + if ($result['ok'] ?? false) { + MediaPipelinesRepository::updateRuntime($id, [ + 'probe_json' => json_encode($result['probe'], JSON_UNESCAPED_SLASHES), + ]); + } + json_out($result); +} + +if ($action === 'start') { + try { + json_out(MediaPipelineRunner::start($pipeline)); + } catch (Throwable $e) { + MediaPipelinesRepository::updateStatus($id, 'error', $e->getMessage()); + json_out(['ok' => false, 'error' => $e->getMessage()], 500); + } +} + +if ($action === 'stop') { + json_out(MediaPipelineRunner::stop($id, $userId, $companyId)); +} + +json_out(['ok' => false, 'error' => 'unknown_action'], 400); diff --git a/sim/cluster0/lab-seeds/backend/public/assets/js/media_pipelines.js b/sim/cluster0/lab-seeds/backend/public/assets/js/media_pipelines.js new file mode 100644 index 0000000..8383f3a --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/public/assets/js/media_pipelines.js @@ -0,0 +1,233 @@ +(function () { + const API = 'media_pipelines.php'; + let canOperate = document.body.getAttribute('data-can-mp-operate') === '1'; + let player = null; + let selectedId = null; + let pollTimer = null; + + function basePath() { + return document.body.getAttribute('data-base-path') || ''; + } + + function apiUrl(action, params) { + const u = new URL(basePath() + '/api/' + API, window.location.origin); + u.searchParams.set('action', action); + if (params) { + Object.keys(params).forEach(function (k) { + u.searchParams.set(k, params[k]); + }); + } + return u.toString(); + } + + function setStatus(msg, isError) { + const el = document.getElementById('mp-status'); + if (!el) return; + el.textContent = msg; + el.classList.toggle('reports-status--error', !!isError); + } + + async function fetchJson(url, options) { + const res = await fetch(url, Object.assign({ credentials: 'same-origin' }, options || {})); + const data = await res.json().catch(function () { return { ok: false, error: 'invalid_json' }; }); + if (!res.ok && data.ok !== false) { + data.ok = false; + data.error = data.error || ('http_' + res.status); + } + return data; + } + + function esc(s) { + const d = document.createElement('div'); + d.textContent = s == null ? '' : String(s); + return d.innerHTML; + } + + function truncate(s, n) { + s = String(s || ''); + return s.length > n ? s.slice(0, n) + '…' : s; + } + + function destroyPlayer() { + if (player && typeof player.dispose === 'function') { + try { player.dispose(); } catch (e) { /* ignore */ } + } + player = null; + } + + function loadPlayer(hlsUrl) { + const section = document.getElementById('mp-player-section'); + const urlEl = document.getElementById('mp-player-url'); + if (!section || !hlsUrl) { + if (section) section.hidden = true; + destroyPlayer(); + return; + } + section.hidden = false; + if (urlEl) urlEl.textContent = hlsUrl; + + if (typeof window.videojs !== 'function') { + setStatus('Video.js not loaded — check nuevo-player assets', true); + return; + } + + destroyPlayer(); + player = window.videojs('mp-player', { + controls: true, + autoplay: false, + preload: 'auto', + fluid: true, + html5: { vhs: { overrideNative: true }, nativeAudioTracks: false, nativeTextTracks: false }, + }); + player.src({ src: hlsUrl, type: 'application/x-mpegURL' }); + if (typeof player.nuevo === 'function') { + player.nuevo({}); + } + } + + async function loadList() { + const data = await fetchJson(apiUrl('list')); + if (!data.ok) { + setStatus(data.error || 'list failed', true); + return; + } + renderTable(data.pipelines || []); + setStatus('Updated — ' + (data.pipelines || []).length + ' pipeline(s)'); + } + + function renderTable(rows) { + const tbody = document.getElementById('mp-tbody'); + if (!tbody) return; + tbody.innerHTML = ''; + rows.forEach(function (p) { + const tr = document.createElement('tr'); + const hls = p.hls_playlist_url + ? 'playlist' + : '—'; + const actions = []; + actions.push(''); + if (canOperate) { + actions.push(''); + actions.push(''); + actions.push(''); + actions.push(''); + } + tr.innerHTML = + '' + esc(p.name) + '' + + '' + esc(p.status) + '' + + '' + esc(p.run_mode) + '' + + '' + esc(truncate(p.source_url, 48)) + '' + + '' + hls + '' + + '' + actions.join(' ') + ''; + tbody.appendChild(tr); + }); + } + + async function selectPipeline(id) { + selectedId = id; + const data = await fetchJson(apiUrl('get', { id: id })); + if (!data.ok || !data.pipeline) return; + const p = data.pipeline; + if (p.probe) { + document.getElementById('mp-probe-section').hidden = false; + document.getElementById('mp-probe-json').textContent = JSON.stringify(p.probe, null, 2); + } + if (p.hls_playlist_url) { + loadPlayer(p.hls_playlist_url); + } + } + + async function onCreate(ev) { + ev.preventDefault(); + if (!canOperate) return; + const form = ev.target; + const body = { + name: form.name.value, + source_url: form.source_url.value, + source_fmt: form.source_fmt.value, + audio_source: form.audio_source.value, + filter_profile: form.filter_profile.value, + output_preset: form.output_preset.value, + run_mode: form.run_mode.value, + }; + setStatus('Creating…'); + const data = await fetchJson(apiUrl('create'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!data.ok) { + setStatus(data.error || 'create failed', true); + return; + } + form.reset(); + await loadList(); + if (data.pipeline && data.pipeline.id) { + await selectPipeline(data.pipeline.id); + } + } + + async function postAction(action, id) { + setStatus(action + '…'); + const data = await fetchJson(apiUrl(action, { id: id }), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: id }), + }); + if (!data.ok) { + setStatus(data.error || action + ' failed', true); + return data; + } + await loadList(); + await selectPipeline(id); + if (action === 'start' && data.hls_url) { + loadPlayer(data.hls_url); + } + setStatus(action + ' OK'); + return data; + } + + async function showLogs(id) { + const data = await fetchJson(apiUrl('logs', { id: id })); + const sec = document.getElementById('mp-logs-section'); + const pre = document.getElementById('mp-logs'); + if (!sec || !pre) return; + sec.hidden = false; + pre.textContent = (data.lines || []).join('\n') || '(no log yet)'; + } + + function onTableClick(ev) { + const t = ev.target; + if (!t || !t.getAttribute) return; + const id = t.getAttribute('data-mp-select') || t.getAttribute('data-mp-probe') + || t.getAttribute('data-mp-start') || t.getAttribute('data-mp-stop') || t.getAttribute('data-mp-logs'); + if (!id) return; + if (t.hasAttribute('data-mp-select')) selectPipeline(id); + else if (t.hasAttribute('data-mp-probe')) postAction('probe', id); + else if (t.hasAttribute('data-mp-start')) postAction('start', id); + else if (t.hasAttribute('data-mp-stop')) postAction('stop', id); + else if (t.hasAttribute('data-mp-logs')) showLogs(id); + } + + function init() { + const app = document.getElementById('media-pipelines-app'); + if (!app) return; + + document.getElementById('mp-create-form')?.addEventListener('submit', onCreate); + document.getElementById('mp-tbody')?.addEventListener('click', onTableClick); + document.getElementById('mp-refresh')?.addEventListener('click', loadList); + + if (!canOperate) { + document.getElementById('mp-create-section')?.setAttribute('hidden', ''); + } + + loadList(); + pollTimer = window.setInterval(loadList, 15000); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/sim/cluster0/lab-seeds/backend/sql/migrations/081_media_pipelines.sql b/sim/cluster0/lab-seeds/backend/sql/migrations/081_media_pipelines.sql new file mode 100644 index 0000000..82f8998 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/sql/migrations/081_media_pipelines.sql @@ -0,0 +1,29 @@ +-- Media pipelines (V.2x experimental restream) — MariaDB +-- mysql -u root -p androidcast_crashes < sql/migrations/081_media_pipelines.sql + +CREATE TABLE IF NOT EXISTS media_pipelines ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + company_id INT UNSIGNED NOT NULL DEFAULT 1, + owner_user_id INT UNSIGNED NOT NULL, + name VARCHAR(128) NOT NULL, + source_url TEXT NOT NULL, + source_fmt VARCHAR(64) NULL, + audio_source TEXT NULL, + filter_profile VARCHAR(32) NOT NULL DEFAULT 'default', + output_preset VARCHAR(16) NOT NULL DEFAULT '1080p', + run_mode ENUM('full','encode_only') NOT NULL DEFAULT 'full', + status ENUM('idle','starting','running','stopping','error') NOT NULL DEFAULT 'idle', + hls_slug VARCHAR(64) NULL, + pid_encode INT NULL, + pid_fanout INT NULL, + work_dir TEXT NULL, + record_path TEXT NULL, + hls_playlist_url TEXT NULL, + probe_json LONGTEXT NULL, + last_error TEXT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_mp_company (company_id, status), + KEY idx_mp_owner (owner_user_id), + KEY idx_mp_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/sim/cluster0/lab-seeds/backend/sql/migrations/081_media_pipelines.sqlite.sql b/sim/cluster0/lab-seeds/backend/sql/migrations/081_media_pipelines.sqlite.sql new file mode 100644 index 0000000..9590bcb --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/sql/migrations/081_media_pipelines.sqlite.sql @@ -0,0 +1,28 @@ +-- Media pipelines — SQLite (local dev) + +CREATE TABLE IF NOT EXISTS media_pipelines ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + company_id INTEGER NOT NULL DEFAULT 1, + owner_user_id INTEGER NOT NULL, + name TEXT NOT NULL, + source_url TEXT NOT NULL, + source_fmt TEXT NULL, + audio_source TEXT NULL, + filter_profile TEXT NOT NULL DEFAULT 'default', + output_preset TEXT NOT NULL DEFAULT '1080p', + run_mode TEXT NOT NULL DEFAULT 'full', + status TEXT NOT NULL DEFAULT 'idle', + hls_slug TEXT NULL, + pid_encode INTEGER NULL, + pid_fanout INTEGER NULL, + work_dir TEXT NULL, + record_path TEXT NULL, + hls_playlist_url TEXT NULL, + probe_json TEXT NULL, + last_error TEXT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_mp_company ON media_pipelines (company_id, status); +CREATE INDEX IF NOT EXISTS idx_mp_owner ON media_pipelines (owner_user_id); diff --git a/sim/cluster0/lab-seeds/backend/src/MediaPipelineRunner.php b/sim/cluster0/lab-seeds/backend/src/MediaPipelineRunner.php new file mode 100644 index 0000000..e6ac52f --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/src/MediaPipelineRunner.php @@ -0,0 +1,227 @@ + $pipeline */ + public static function workDir(array $pipeline): string { + $base = self::storageRoot() . '/pipelines/' . (int) $pipeline['id']; + if (!is_dir($base)) { + mkdir($base, 0755, true); + } + return $base; + } + + /** @param array $pipeline */ + public static function probe(array $pipeline): array { + $root = self::scriptRoot(); + $cmd = escapeshellcmd($root . '/bin/probe-source.sh') . ' ' + . escapeshellarg((string) $pipeline['source_url']); + $out = []; + $code = 0; + exec($cmd . ' 2>&1', $out, $code); + $text = trim(implode("\n", $out)); + if ($code !== 0) { + return ['ok' => false, 'error' => $text !== '' ? $text : 'probe failed']; + } + $json = json_decode($text, true); + if (!is_array($json)) { + return ['ok' => false, 'error' => 'invalid probe json', 'raw' => $text]; + } + return ['ok' => true, 'probe' => $json]; + } + + /** @param array $pipeline */ + public static function start(array $pipeline): array { + if (!MediaPipelinesRepository::gateCanOperate()) { + return ['ok' => false, 'error' => 'forbidden']; + } + $id = (int) $pipeline['id']; + $work = self::workDir($pipeline); + $hlsSlug = (string) ($pipeline['hls_slug'] ?? ('p' . $id)); + $hlsDir = self::hlsFilesystemRoot() . '/' . $hlsSlug; + $sourcesFile = $work . '/sources.list'; + $sinksFile = $work . '/sinks.list'; + $localEnv = $work . '/local.env'; + + $line = (string) $pipeline['source_url']; + if (!empty($pipeline['source_fmt'])) { + $line .= ' ' . (string) $pipeline['source_fmt']; + } + file_put_contents($sourcesFile, $line . "\n"); + + $defaultSinks = self::scriptRoot() . '/config/sinks.list'; + if (is_readable($defaultSinks)) { + copy($defaultSinks, $sinksFile); + } else { + file_put_contents($sinksFile, "# no sinks\n"); + } + + $envLines = [ + 'SOURCES_LIST=' . $sourcesFile, + 'SINKS_LIST=' . $sinksFile, + 'STORAGE_DIR=' . $work, + 'RUN_DIR=' . $work . '/run', + 'HLS_OUTPUT_DIR=' . $hlsDir, + 'LOGO_PATH=' . self::logoPath(), + 'FILTER_PROFILE=' . (string) ($pipeline['filter_profile'] ?? 'default'), + 'OUTPUT_PRESET=' . (string) ($pipeline['output_preset'] ?? '1080p'), + 'YTDLP_ENABLED=' . (cfg('media_pipelines.ytdlp_enabled', true) ? '1' : '0'), + ]; + if (!empty($pipeline['audio_source'])) { + $envLines[] = 'AUDIO_SOURCE=' . (string) $pipeline['audio_source']; + } + file_put_contents($localEnv, implode("\n", $envLines) . "\n"); + + MediaPipelinesRepository::updateStatus($id, 'starting'); + + self::writeWrapper($work, $runMode); + + $cmd = 'nohup ' . escapeshellarg($work . '/invoke.sh') + . ' >> ' . escapeshellarg($work . '/runner.log') . ' 2>&1 & echo $!'; + $pidLine = trim((string) shell_exec($cmd)); + $pid = (int) $pidLine; + + $hlsUrl = self::hlsPublicPrefix() . '/' . rawurlencode($hlsSlug) . '/index.m3u8'; + MediaPipelinesRepository::updateRuntime($id, [ + 'status' => 'running', + 'pid_encode' => $pid > 0 ? $pid : null, + 'work_dir' => $work, + 'hls_playlist_url' => $hlsUrl, + 'last_error' => null, + ]); + + return [ + 'ok' => true, + 'pid' => $pid, + 'hls_url' => $hlsUrl, + 'work_dir' => $work, + ]; + } + + public static function stop(int $pipelineId, int $userId, int $companyId): array { + $pipeline = MediaPipelinesRepository::getById($pipelineId, $userId, $companyId); + if ($pipeline === null) { + return ['ok' => false, 'error' => 'not_found']; + } + MediaPipelinesRepository::updateStatus($pipelineId, 'stopping'); + $root = self::scriptRoot(); + $cmd = 'cd ' . escapeshellarg($root) . ' && ' + . escapeshellcmd($root . '/bin/stop.sh') . ' 2>/dev/null; true'; + exec($cmd); + foreach (['pid_encode', 'pid_fanout'] as $key) { + $pid = (int) ($pipeline[$key] ?? 0); + if ($pid > 0) { + exec('kill -15 ' . $pid . ' 2>/dev/null; kill -9 ' . $pid . ' 2>/dev/null'); + } + } + MediaPipelinesRepository::updateRuntime($pipelineId, [ + 'status' => 'idle', + 'pid_encode' => null, + 'pid_fanout' => null, + 'last_error' => null, + ]); + return ['ok' => true]; + } + + private static function writeWrapper(string $work, string $runMode): void { + $root = self::scriptRoot(); + $script = $runMode === 'encode_only' ? 'run-encode-only.sh' : 'run.sh'; + $wrapper = $work . '/invoke.sh'; + $body = <<<'SH' +#!/bin/sh +set -eu +WORK_DIR="@WORK@" +ROOT="@ROOT@" +if [ -f "${WORK_DIR}/local.env" ]; then + set -a + # shellcheck disable=SC1090 + . "${WORK_DIR}/local.env" + set +a +fi +# shellcheck disable=SC1091 +. "${ROOT}/lib/common.sh" +mkdir -p "${RUN_DIR:-${WORK_DIR}/run}" +exec "${ROOT}/bin/@SCRIPT@" 1 +SH; + $body = str_replace( + ['@WORK@', '@ROOT@', '@SCRIPT@'], + [$work, $root, $script], + $body + ); + file_put_contents($wrapper, $body); + chmod($wrapper, 0755); + } + + /** @return list */ + public static function tailLog(array $pipeline, int $lines = 80): array { + $log = ($pipeline['work_dir'] ?? '') . '/encode.log'; + if (!is_string($log) || !is_readable($log)) { + $log = self::storageRoot() . '/encode.log'; + } + if (!is_readable($log)) { + return []; + } + $out = []; + exec('tail -n ' . (int) $lines . ' ' . escapeshellarg($log), $out); + return $out; + } +} diff --git a/sim/cluster0/lab-seeds/backend/src/MediaPipelinesRepository.php b/sim/cluster0/lab-seeds/backend/src/MediaPipelinesRepository.php new file mode 100644 index 0000000..89a0778 --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/src/MediaPipelinesRepository.php @@ -0,0 +1,185 @@ +exec($sql); + } + return; + } + $sql = file_get_contents(__DIR__ . '/../sql/migrations/081_media_pipelines.sqlite.sql') ?: ''; + if ($sql !== '') { + $pdo->exec($sql); + } + }); + self::$schemaEnsured = true; + } + + /** @return list> */ + public static function listForUser(int $userId, int $companyId): array { + self::ensureSchema(); + $pdo = Database::pdo(); + $stmt = $pdo->prepare( + 'SELECT * FROM media_pipelines + WHERE company_id = ? AND owner_user_id = ? + ORDER BY updated_at DESC, id DESC + LIMIT 200' + ); + $stmt->execute([$companyId, $userId]); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + return is_array($rows) ? array_map([self::class, 'normalizeRow'], $rows) : []; + } + + public static function getById(int $id, int $userId, int $companyId): ?array { + self::ensureSchema(); + $pdo = Database::pdo(); + $stmt = $pdo->prepare( + 'SELECT * FROM media_pipelines WHERE id = ? AND company_id = ? AND owner_user_id = ? LIMIT 1' + ); + $stmt->execute([$id, $companyId, $userId]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + return is_array($row) ? self::normalizeRow($row) : null; + } + + /** @param array $input */ + public static function create(int $userId, int $companyId, array $input): array { + self::ensureSchema(); + $name = trim((string) ($input['name'] ?? 'Pipeline')); + $sourceUrl = trim((string) ($input['source_url'] ?? '')); + if ($sourceUrl === '') { + throw new InvalidArgumentException('source_url required'); + } + $pdo = Database::pdo(); + $stmt = $pdo->prepare( + 'INSERT INTO media_pipelines + (company_id, owner_user_id, name, source_url, source_fmt, audio_source, + filter_profile, output_preset, run_mode, status, hls_slug) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + ); + $hlsSlug = 'p' . bin2hex(random_bytes(8)); + $stmt->execute([ + $companyId, + $userId, + $name !== '' ? $name : 'Pipeline', + $sourceUrl, + self::nullOrTrim($input['source_fmt'] ?? null), + self::nullOrTrim($input['audio_source'] ?? null), + self::sanitizeProfile((string) ($input['filter_profile'] ?? 'default')), + self::sanitizePreset((string) ($input['output_preset'] ?? '1080p')), + self::sanitizeRunMode((string) ($input['run_mode'] ?? 'full')), + 'idle', + $hlsSlug, + ]); + $id = (int) $pdo->lastInsertId(); + $row = self::getById($id, $userId, $companyId); + if ($row === null) { + throw new RuntimeException('create failed'); + } + return $row; + } + + public static function updateStatus(int $id, string $status, ?string $error = null): void { + self::ensureSchema(); + $pdo = Database::pdo(); + $stmt = $pdo->prepare( + 'UPDATE media_pipelines SET status = ?, last_error = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?' + ); + $stmt->execute([$status, $error, $id]); + } + + /** @param array $fields */ + public static function updateRuntime(int $id, array $fields): void { + self::ensureSchema(); + $allowed = [ + 'status', 'pid_encode', 'pid_fanout', 'work_dir', 'record_path', + 'hls_playlist_url', 'probe_json', 'last_error', + ]; + $sets = []; + $vals = []; + foreach ($allowed as $key) { + if (array_key_exists($key, $fields)) { + $sets[] = "$key = ?"; + $vals[] = $fields[$key]; + } + } + if ($sets === []) { + return; + } + $sets[] = 'updated_at = CURRENT_TIMESTAMP'; + $vals[] = $id; + $pdo = Database::pdo(); + $pdo->prepare('UPDATE media_pipelines SET ' . implode(', ', $sets) . ' WHERE id = ?')->execute($vals); + } + + /** @param array $row */ + private static function normalizeRow(array $row): array { + $row['id'] = (int) ($row['id'] ?? 0); + $row['company_id'] = (int) ($row['company_id'] ?? 1); + $row['owner_user_id'] = (int) ($row['owner_user_id'] ?? 0); + $row['pid_encode'] = isset($row['pid_encode']) ? (int) $row['pid_encode'] : null; + $row['pid_fanout'] = isset($row['pid_fanout']) ? (int) $row['pid_fanout'] : null; + if (!empty($row['probe_json']) && is_string($row['probe_json'])) { + $decoded = json_decode($row['probe_json'], true); + $row['probe'] = is_array($decoded) ? $decoded : null; + } else { + $row['probe'] = null; + } + return $row; + } + + private static function nullOrTrim(mixed $v): ?string { + if ($v === null) { + return null; + } + $s = trim((string) $v); + return $s === '' ? null : $s; + } + + private static function sanitizeProfile(string $p): string { + return in_array($p, ['default', 'blurred', 'nologo'], true) ? $p : 'default'; + } + + private static function sanitizePreset(string $p): string { + return in_array($p, ['1080p', '720p'], true) ? $p : '1080p'; + } + + private static function sanitizeRunMode(string $m): string { + return $m === 'encode_only' ? 'encode_only' : 'full'; + } +} diff --git a/sim/cluster0/nginx/apps-port80.conf b/sim/cluster0/nginx/apps-port80.conf index d73fe03..e9e814e 100644 --- a/sim/cluster0/nginx/apps-port80.conf +++ b/sim/cluster0/nginx/apps-port80.conf @@ -276,6 +276,18 @@ server { client_max_body_size 512m; } + # ── Media pipelines HLS (streaming_v2 experimental) ───────────────────── + location ^~ /app/androidcast_project/media/hls/ { + alias /var/www/ac/media-pipelines/hls/; + add_header Cache-Control "no-cache"; + add_header Access-Control-Allow-Origin "*"; + types { + application/vnd.apple.mpegurl m3u8; + video/mp2t ts; + } + default_type application/vnd.apple.mpegurl; + } + # ── SFU signaling — Janus Gateway WebRTC backend ───────────────────────── # REST API → PHP signaling service location /app/androidcast_project/sfu/api/ { diff --git a/sim/cluster0/scripts/compose-lab-backend.sh b/sim/cluster0/scripts/compose-lab-backend.sh index 80a32c1..cf9bed8 100755 --- a/sim/cluster0/scripts/compose-lab-backend.sh +++ b/sim/cluster0/scripts/compose-lab-backend.sh @@ -94,6 +94,40 @@ overlay_lab_seed_src() { } overlay_lab_seed_src ShortLinksRepository.php overlay_lab_seed_src UrlShortenerDatabase.php +overlay_lab_seed_src MediaPipelinesRepository.php +overlay_lab_seed_src MediaPipelineRunner.php + +overlay_lab_seed_api() { + _f="$1" + if [ -f "${LEGACY}/public/api/${_f}" ]; then + log "overlay lab-seed api ${_f}" + mkdir -p "${COMPOSED}/public/api" + cp -a "${LEGACY}/public/api/${_f}" "${COMPOSED}/public/api/" + fi +} +overlay_lab_seed_api media_pipelines.php + +overlay_lab_seed_public() { + _rel="$1" + if [ -f "${LEGACY}/public/${_rel}" ]; then + log "overlay lab-seed public/${_rel}" + mkdir -p "${COMPOSED}/public/$(dirname "$_rel")" + cp -a "${LEGACY}/public/${_rel}" "${COMPOSED}/public/${_rel}" + fi +} +overlay_lab_seed_public assets/js/media_pipelines.js +overlay_lab_seed_public index.php + +overlay_lab_seed_sql() { + _f="$1" + if [ -f "${LEGACY}/sql/migrations/${_f}" ]; then + log "overlay lab-seed sql/migrations/${_f}" + mkdir -p "${COMPOSED}/sql/migrations" + cp -a "${LEGACY}/sql/migrations/${_f}" "${COMPOSED}/sql/migrations/" + fi +} +overlay_lab_seed_sql 081_media_pipelines.sql +overlay_lab_seed_sql 081_media_pipelines.sqlite.sql overlay_api ac-ms-identity overlay_api ac-ms-rbac @@ -253,9 +287,30 @@ return [ ], ], ], + 'media_pipelines' => [ + 'enabled' => true, + 'developers_only' => true, + 'script_root' => '${WS}/ac-ms-media-transcode', + 'storage_root' => '/var/www/ac/media-pipelines', + 'hls_url_prefix' => '/app/androidcast_project/media/hls', + 'hls_filesystem_root' => '/var/www/ac/media-pipelines/hls', + 'logo_path' => '${WS}/ac-ms-media-transcode/assets/pirate_drift.png', + 'ytdlp_enabled' => true, + ], ]; PHP +MEDIA_ROOT="/var/www/ac/media-pipelines" +MEDIA_HLS="${MEDIA_ROOT}/hls" +MT_ROOT="${WS}/ac-ms-media-transcode" +log "ensure media-pipelines dirs ${MEDIA_ROOT}" +mkdir -p "${MEDIA_HLS}" "${MEDIA_ROOT}/pipelines" +chown nginx:nginx "${MEDIA_ROOT}" "${MEDIA_HLS}" 2>/dev/null || true +if [ -d "${MT_ROOT}/bin" ]; then + chmod +x "${MT_ROOT}"/bin/*.sh 2>/dev/null || true + log "media-transcode scripts at ${MT_ROOT} (logo: ${MT_ROOT}/assets/pirate_drift.png)" +fi + BUILD_CFG="${WS}/ac-be-builder/config/config.php" if [ -f "${COMPOSED}/config/config.php" ]; then log "write ac-be-builder config.php (shared cluster session + DB)"