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