diff --git a/services/capture/src/routes/capture.js b/services/capture/src/routes/capture.js
index c968645..8f032b1 100644
--- a/services/capture/src/routes/capture.js
+++ b/services/capture/src/routes/capture.js
@@ -1,5 +1,5 @@
import express from 'express';
-import { execSync } from 'child_process';
+import { execSync, spawn } from 'child_process';
import captureManager from '../capture-manager.js';
const router = express.Router();
@@ -60,6 +60,69 @@ router.get('/status', (req, res) => {
res.status(500).json({ error: 'Failed to get status' });
}
});
+router.post('/probe', async (req, res) => {
+ try {
+ const { source_type = 'sdi', source_url, listen = false, device } = req.body || {};
+
+ if (source_type === 'sdi') {
+ try {
+ const raw = execSync('ffmpeg -hide_banner -f decklink -list_devices 1 -i dummy 2>&1', { encoding: 'utf-8', timeout: 5000 });
+ const devices = [];
+ for (const line of raw.split('\n')) {
+ const m = line.match(/\[decklink[^\]]*\]\s+"([^"]+)"/);
+ if (m) devices.push(m[1]);
+ }
+ return res.json({ ok: true, source_type, devices });
+ } catch (err) {
+ const out = (err.stderr || err.stdout || err.toString()).toString();
+ return res.json({ ok: false, source_type, error: out.slice(0, 800) });
+ }
+ }
+
+ if (listen) {
+ return res.json({ ok: false, source_type, error: 'Listener-mode sources cannot be probed standalone. Start the recorder and watch the signal indicator.' });
+ }
+
+ if (!source_url) return res.status(400).json({ error: 'source_url is required' });
+
+ let url = source_url;
+ if (source_type === 'srt' && !/mode=/.test(url)) {
+ url += (url.includes('?') ? '&' : '?') + 'mode=caller';
+ }
+
+ const args = ['-hide_banner','-v','error','-probesize','32M','-analyzeduration','8M','-rw_timeout','7000000','-i', url, '-show_streams','-show_format','-of','json'];
+ const ff = spawn('ffprobe', args);
+ let stdout = '', stderr = '';
+ ff.stdout.on('data', (c) => { stdout += c; });
+ ff.stderr.on('data', (c) => { stderr += c; });
+ const killer = setTimeout(() => { try { ff.kill('SIGKILL'); } catch (_) {} }, 10000);
+ ff.on('close', (code) => {
+ clearTimeout(killer);
+ if (code !== 0) {
+ return res.json({ ok: false, source_type, source_url, error: (stderr || 'ffprobe failed').slice(0, 800) });
+ }
+ try {
+ const parsed = JSON.parse(stdout);
+ const streams = (parsed.streams || []).map(s => ({
+ index: s.index, codec_type: s.codec_type, codec_name: s.codec_name,
+ width: s.width, height: s.height, pix_fmt: s.pix_fmt,
+ r_frame_rate: s.r_frame_rate, avg_frame_rate: s.avg_frame_rate,
+ sample_rate: s.sample_rate, channels: s.channels,
+ channel_layout: s.channel_layout, bit_rate: s.bit_rate,
+ }));
+ return res.json({ ok: true, source_type, source_url,
+ format: { format_name: parsed.format && parsed.format.format_name, duration: parsed.format && parsed.format.duration, bit_rate: parsed.format && parsed.format.bit_rate },
+ streams });
+ } catch (err) {
+ return res.json({ ok: false, source_type, source_url, error: 'Could not parse ffprobe output: ' + err.message });
+ }
+ });
+ } catch (error) {
+ console.error('Probe error:', error);
+ res.status(500).json({ error: error.message });
+ }
+});
+
/**
* POST /start
diff --git a/services/mam-api/src/routes/recorders.js b/services/mam-api/src/routes/recorders.js
index 29e511e..61434fa 100644
--- a/services/mam-api/src/routes/recorders.js
+++ b/services/mam-api/src/routes/recorders.js
@@ -470,4 +470,19 @@ router.delete('/:id', async (req, res, next) => {
}
});
+router.post('/probe', async (req, res, next) => {
+ try {
+ const r = await fetch('http://capture:3001/capture/probe', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(req.body || {}),
+ signal: AbortSignal.timeout(15000),
+ });
+ const data = await r.json().catch(() => ({}));
+ res.status(r.status).json(data);
+ } catch (err) {
+ next(err);
+ }
+});
+
export default router;
diff --git a/services/web-ui/public/recorders.html b/services/web-ui/public/recorders.html
index 159c9b7..93b2b8d 100644
--- a/services/web-ui/public/recorders.html
+++ b/services/web-ui/public/recorders.html
@@ -351,6 +351,7 @@
@@ -385,6 +386,7 @@
document.getElementById('closePanelBtn').onclick = closePanel;
document.getElementById('panelOverlay').onclick = closePanel;
document.getElementById('saveRecorderBtn').onclick = handleSaveRecorder;
+ document.getElementById('probeBtn').onclick = handleProbe;
document.getElementById('proxyToggle').onchange = e => {
document.getElementById('proxyFields').style.display = e.target.checked ? 'grid' : 'none';
};
@@ -466,9 +468,9 @@
-
-
- ${isRecording ? 'Recording' : rec.status === 'error' ? 'Error' : 'Idle'}
+
+
+ ${isRecording ? 'Connecting...' : rec.status === 'error' ? 'Error' : 'Idle'}
${isRecording ? `00:00:00` : ''}
@@ -516,26 +518,36 @@
}
function updateSignalBadge(rid, st) {
- const el = document.getElementById(`signal-${rid}`);
- if (!el) return;
- const sig = st.signal || "connecting";
- const labels = {
- connecting: "Connecting…",
- receiving: `Receiving • ${st.framesReceived||0} fr • ${Math.round(st.currentFps||0)} fps`,
- lost: "No signal — stream dropped",
- error: "Connection error",
- stopped: "Stopped",
- };
- const colors = {
- connecting: "var(--status-yellow, oklch(82% 0.15 90))",
- receiving: "var(--status-green, oklch(68% 0.18 148))",
- lost: "var(--status-red, oklch(62% 0.22 25))",
- error: "var(--status-red, oklch(62% 0.22 25))",
- stopped: "var(--text-tertiary)",
- };
- el.textContent = labels[sig] || sig;
- el.style.color = colors[sig] || "var(--text-tertiary)";
- el.title = st.lastError || "";
+ const el = document.getElementById('signal-' + rid);
+ const sig = st.signal || 'connecting';
+ if (el) {
+ const detail = {
+ connecting: 'Waiting for stream...',
+ receiving: 'Receiving • ' + (st.framesReceived || 0) + ' fr • ' + Math.round(st.currentFps || 0) + ' fps',
+ lost: 'No signal — stream dropped',
+ error: st.lastError ? st.lastError : 'Connection error',
+ stopped: 'Stopped',
+ };
+ const col = {
+ connecting: 'var(--status-yellow, oklch(82% 0.15 90))',
+ receiving: 'var(--status-green, oklch(68% 0.18 148))',
+ lost: 'var(--status-red, oklch(62% 0.22 25))',
+ error: 'var(--status-red, oklch(62% 0.22 25))',
+ stopped: 'var(--text-tertiary)',
+ };
+ el.textContent = detail[sig] || sig;
+ el.style.color = col[sig] || 'var(--text-tertiary)';
+ el.title = st.lastError || '';
+ }
+ const mainTxt = document.getElementById('statusText-' + rid);
+ const mainDot = document.getElementById('statusDot-' + rid);
+ if (mainTxt && mainDot) {
+ const mainLabel = { connecting: 'Connecting...', receiving: 'Recording', lost: 'Signal lost', error: 'Connection error' }[sig] || 'Recording';
+ const mainCol = { connecting: 'var(--status-yellow, oklch(82% 0.15 90))', receiving: 'var(--accent)', lost: 'var(--status-red, oklch(62% 0.22 25))', error: 'var(--status-red, oklch(62% 0.22 25))' }[sig] || 'var(--accent)';
+ mainTxt.textContent = mainLabel;
+ mainTxt.style.color = mainCol;
+ mainDot.style.background = mainCol;
+ }
}
function formatDur(s) {
@@ -665,7 +677,70 @@
}
// ── Save recorder ─────────────────────────
- async function handleSaveRecorder() {
+
+ async function handleProbe() {
+ const btn = document.getElementById('probeBtn');
+ btn.disabled = true; btn.textContent = 'Probing...';
+ // Build payload from current form state
+ const type = pState.sourceType;
+ const payload = { source_type: type };
+ if (type === 'srt' && document.getElementById('srtUrl')) {
+ payload.source_url = document.getElementById('srtUrl').value.trim();
+ } else if (type === 'rtmp' && document.getElementById('rtmpUrl')) {
+ payload.source_url = document.getElementById('rtmpUrl').value.trim();
+ } else if (type === 'sdi') {
+ const d = document.getElementById('sdiDevice');
+ if (d) payload.device = parseInt(d.value || '0', 10);
+ }
+ try {
+ const r = await fetch('/api/v1/recorders/probe', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
+ body: JSON.stringify(payload),
+ });
+ const data = await r.json();
+ renderProbeResult(data);
+ } catch (err) {
+ renderProbeResult({ ok: false, error: 'Network error: ' + err.message });
+ } finally {
+ btn.disabled = false; btn.textContent = 'Probe source';
+ }
+ }
+
+ function renderProbeResult(d) {
+ let host = document.getElementById('probeResult');
+ if (!host) {
+ host = document.createElement('div');
+ host.id = 'probeResult';
+ host.style.cssText = 'margin-top:var(--sp-3);padding:var(--sp-3);border-radius:var(--r-md);border:1px solid var(--border);background:var(--bg-surface);font-size:var(--text-xs)';
+ const footer = document.querySelector('.slide-panel-footer');
+ footer.parentElement.insertBefore(host, footer);
+ }
+ if (!d.ok) {
+ host.style.borderColor = 'oklch(62% 0.22 25 / 0.5)';
+ host.style.background = 'oklch(62% 0.22 25 / 0.08)';
+ host.innerHTML = 'No signal detected
' + (d.error || 'Unknown error') + '
';
+ return;
+ }
+ host.style.borderColor = 'oklch(68% 0.18 148 / 0.5)';
+ host.style.background = 'oklch(68% 0.18 148 / 0.08)';
+ renderProbeOk(host, d);
+ }
+
+ function renderProbeOk(host, d) {
+ if (d.source_type === 'sdi') {
+ host.innerHTML = 'DeckLink devices found
' + (d.devices || []).map(n => '- ' + esc(n) + '
').join('') + '
';
+ return;
+ }
+ const v = (d.streams || []).find(s => s.codec_type === 'video');
+ const a = (d.streams || []).find(s => s.codec_type === 'audio');
+ let html = 'Signal detected
';
+ if (v) html += '
Video: ' + esc(v.codec_name || '?') + ' ' + (v.width || '?') + 'x' + (v.height || '?') + ' • ' + esc(v.r_frame_rate || v.avg_frame_rate || '?') + ' fps • ' + esc(v.pix_fmt || '?') + '
';
+ if (a) html += '
Audio: ' + esc(a.codec_name || '?') + ' • ' + (a.sample_rate || '?') + ' Hz • ' + (a.channels || '?') + ' ch
';
+ html += '
';
+ host.innerHTML = html;
+ }
+
+ async function handleSaveRecorder() {
const name = document.getElementById('recName').value.trim();
if (!name) { toast('Enter a recorder name', '', 'warning'); return; }