mirror of
git://f0xx.org/ac/ac-deploy
synced 2026-07-29 07:17:38 +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:
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();
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user