1
0
mirror of git://f0xx.org/ac/ac-deploy synced 2026-07-29 02:37:39 +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:
Anton Afanasyeu
2026-07-11 15:27:01 +02:00
parent 54174e7497
commit b8bd0e673c
9 changed files with 898 additions and 0 deletions

View File

@@ -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,
],
];

View 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);

View File

@@ -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();
}
})();

View File

@@ -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;

View File

@@ -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);

View 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;
}
}

View 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';
}
}

View File

@@ -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/ {

View File

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