mirror of
git://f0xx.org/ac/ac-deploy
synced 2026-07-29 03:38:07 +03:00
Enable lab media_pipelines in composed config and wire ac-ms-media-transcode.
Overlay media pipeline PHP/JS/SQL on compose, nginx HLS location, enable feature in lab config.php, and ensure media storage dirs on BE nodes. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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,
|
||||
],
|
||||
];
|
||||
|
||||
118
sim/cluster0/lab-seeds/backend/public/api/media_pipelines.php
Normal file
118
sim/cluster0/lab-seeds/backend/public/api/media_pipelines.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
require_once __DIR__ . '/../../src/MediaPipelinesRepository.php';
|
||||
require_once __DIR__ . '/../../src/MediaPipelineRunner.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
Auth::check();
|
||||
|
||||
if (!MediaPipelinesRepository::gateAllowed()) {
|
||||
json_out(['ok' => 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);
|
||||
@@ -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
|
||||
? '<a href="' + esc(p.hls_playlist_url) + '" target="_blank" rel="noopener">playlist</a>'
|
||||
: '—';
|
||||
const actions = [];
|
||||
actions.push('<button type="button" class="btn btn--sm" data-mp-select="' + p.id + '">Select</button>');
|
||||
if (canOperate) {
|
||||
actions.push('<button type="button" class="btn btn--sm" data-mp-probe="' + p.id + '">Probe</button>');
|
||||
actions.push('<button type="button" class="btn btn--sm" data-mp-start="' + p.id + '">Start</button>');
|
||||
actions.push('<button type="button" class="btn btn--sm" data-mp-stop="' + p.id + '">Stop</button>');
|
||||
actions.push('<button type="button" class="btn btn--sm" data-mp-logs="' + p.id + '">Logs</button>');
|
||||
}
|
||||
tr.innerHTML =
|
||||
'<td>' + esc(p.name) + '</td>' +
|
||||
'<td><span class="tag-pill">' + esc(p.status) + '</span></td>' +
|
||||
'<td>' + esc(p.run_mode) + '</td>' +
|
||||
'<td title="' + esc(p.source_url) + '">' + esc(truncate(p.source_url, 48)) + '</td>' +
|
||||
'<td>' + hls + '</td>' +
|
||||
'<td class="toolbar-actions">' + actions.join(' ') + '</td>';
|
||||
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();
|
||||
}
|
||||
})();
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
227
sim/cluster0/lab-seeds/backend/src/MediaPipelineRunner.php
Normal file
227
sim/cluster0/lab-seeds/backend/src/MediaPipelineRunner.php
Normal file
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Invokes ac-ms-media-transcode shell scripts on the BE host (lab / dev).
|
||||
*/
|
||||
final class MediaPipelineRunner {
|
||||
public static function scriptRoot(): string {
|
||||
$root = trim((string) cfg('media_pipelines.script_root', ''));
|
||||
if ($root !== '' && is_dir($root)) {
|
||||
return rtrim($root, '/');
|
||||
}
|
||||
$env = getenv('AC_MEDIA_PIPELINES_ROOT');
|
||||
if (is_string($env) && $env !== '' && is_dir($env)) {
|
||||
return rtrim($env, '/');
|
||||
}
|
||||
$fallback = '/var/www/ac/workspace/ac-ms-media-transcode';
|
||||
if (is_dir($fallback)) {
|
||||
return $fallback;
|
||||
}
|
||||
throw new RuntimeException(
|
||||
'ac-ms-media-transcode script root not found — set media_pipelines.script_root in config.php'
|
||||
);
|
||||
}
|
||||
|
||||
public static function logoPath(): string {
|
||||
$logo = trim((string) cfg('media_pipelines.logo_path', ''));
|
||||
if ($logo !== '' && is_file($logo)) {
|
||||
return $logo;
|
||||
}
|
||||
$default = self::scriptRoot() . '/assets/pirate_drift.png';
|
||||
if (is_file($default)) {
|
||||
return $default;
|
||||
}
|
||||
throw new RuntimeException('pirate_drift.png not found under ac-ms-media-transcode/assets/');
|
||||
}
|
||||
|
||||
public static function storageRoot(): string {
|
||||
$root = trim((string) cfg('media_pipelines.storage_root', ''));
|
||||
if ($root === '') {
|
||||
$root = self::scriptRoot() . '/storage';
|
||||
}
|
||||
if (!is_dir($root)) {
|
||||
mkdir($root, 0755, true);
|
||||
}
|
||||
return rtrim($root, '/');
|
||||
}
|
||||
|
||||
public static function hlsFilesystemRoot(): string {
|
||||
$root = trim((string) cfg('media_pipelines.hls_filesystem_root', ''));
|
||||
if ($root === '') {
|
||||
$root = self::storageRoot() . '/hls';
|
||||
}
|
||||
if (!is_dir($root)) {
|
||||
mkdir($root, 0755, true);
|
||||
}
|
||||
return rtrim($root, '/');
|
||||
}
|
||||
|
||||
public static function hlsPublicPrefix(): string {
|
||||
return rtrim((string) cfg('media_pipelines.hls_url_prefix', '/app/androidcast_project/media/hls'), '/');
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $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<string, mixed> $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<string, mixed> $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<string> */
|
||||
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;
|
||||
}
|
||||
}
|
||||
185
sim/cluster0/lab-seeds/backend/src/MediaPipelinesRepository.php
Normal file
185
sim/cluster0/lab-seeds/backend/src/MediaPipelinesRepository.php
Normal file
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Experimental V.2x media pipelines — DB + access gate + CRUD.
|
||||
* Control plane only; workers run streaming_v2 shell scripts on BE.
|
||||
*/
|
||||
final class MediaPipelinesRepository {
|
||||
private static bool $schemaEnsured = false;
|
||||
|
||||
public static function gateAllowed(): bool {
|
||||
if (!cfg('media_pipelines.enabled', false)) {
|
||||
return false;
|
||||
}
|
||||
if (cfg('media_pipelines.developers_only', true)) {
|
||||
return Rbac::isGlobalAdmin()
|
||||
|| Rbac::can('media_pipelines_view')
|
||||
|| Rbac::can('media_pipelines_operate');
|
||||
}
|
||||
return Auth::user() !== null;
|
||||
}
|
||||
|
||||
public static function gateCanOperate(): bool {
|
||||
if (!self::gateAllowed()) {
|
||||
return false;
|
||||
}
|
||||
return Rbac::isGlobalAdmin() || Rbac::can('media_pipelines_operate');
|
||||
}
|
||||
|
||||
public static function ensureSchema(): void {
|
||||
if (self::$schemaEnsured) {
|
||||
return;
|
||||
}
|
||||
$pdo = Database::pdo();
|
||||
Database::withMigrationLock($pdo, 'media_pipelines', static function () use ($pdo): void {
|
||||
if (Database::tableExists($pdo, 'media_pipelines')) {
|
||||
return;
|
||||
}
|
||||
if (Database::isMysql()) {
|
||||
$sql = file_get_contents(__DIR__ . '/../sql/migrations/081_media_pipelines.sql') ?: '';
|
||||
if ($sql !== '') {
|
||||
$pdo->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<array<string, mixed>> */
|
||||
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<string, mixed> $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<string, mixed> $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<string, mixed> $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';
|
||||
}
|
||||
}
|
||||
@@ -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/ {
|
||||
|
||||
@@ -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)"
|
||||
|
||||
Reference in New Issue
Block a user