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)"