From 79369c378ab722a42e0e57346257fb0c5dfd8906 Mon Sep 17 00:00:00 2001 From: Zac Date: Sun, 17 May 2026 07:00:52 -0400 Subject: [PATCH] fix: SRT/RTMP ingest + thumbnail crashes Recorder model was creating capture containers but ffmpeg never spawned inside them, so SRT/RTMP listeners bound the host port without ingesting anything. Thumbnail extraction was also crashing on yuv444p sources, leaving uploaded assets stuck at status=processing forever. * capture/src/index.js: read RECORDER_ID/SOURCE_TYPE/LISTEN/LISTEN_PORT/ STREAM_KEY/SOURCE_URL from env on startup and call captureManager.start() immediately. SIGTERM handler now flushes ffmpeg + S3 upload and POSTs the asset to mam-api before exiting. * worker/ffmpeg/executor.js: force -pix_fmt yuv420p on proxy transcode and -pix_fmt yuvj420p on thumbnail extraction so mjpeg encoder accepts the input regardless of source pixel format. * mam-api/routes/assets.js: when capture posts proxyKey=null but hiresKey is set (SRT/RTMP case), enqueue a proxy job from the hires so the asset ends up with a browser-playable proxy + thumbnail instead of stuck-ready. * mam-api/routes/recorders.js: accept UI field aliases (codec/resolution/ proxy_config), clean up unstarted containers on port collision, bump the docker stop timeout to 5min so long uploads can flush. * web-ui/recorders.html: change default ports from 1935/9000 to 41936/49001 to avoid common collisions with other RTMP/SRT services. --- services/capture/src/index.js | 110 +++++++++++++++++++++-- services/mam-api/src/routes/assets.js | 10 +++ services/mam-api/src/routes/recorders.js | 32 ++++--- services/web-ui/public/recorders.html | 58 ++++++------ services/worker/src/ffmpeg/executor.js | 3 + 5 files changed, 166 insertions(+), 47 deletions(-) diff --git a/services/capture/src/index.js b/services/capture/src/index.js index b031d18..35ff498 100644 --- a/services/capture/src/index.js +++ b/services/capture/src/index.js @@ -2,25 +2,125 @@ import express from 'express'; import cors from 'cors'; import dotenv from 'dotenv'; import captureRoutes from './routes/capture.js'; +import captureManager from './capture-manager.js'; dotenv.config(); const app = express(); const PORT = process.env.PORT || 3001; +const MAM_API_URL = process.env.MAM_API_URL || 'http://mam-api:3000'; -// Middleware app.use(cors()); app.use(express.json()); -// Health check app.get('/health', (req, res) => { res.json({ status: 'ok' }); }); -// Routes app.use('/capture', captureRoutes); -// Start server -app.listen(PORT, () => { +const server = app.listen(PORT, () => { console.log(`Wild Dragon Capture Service listening on port ${PORT}`); + bootstrapAutoStart().catch((err) => { + console.error('[bootstrap] auto-start failed:', err); + }); }); + +async function bootstrapAutoStart() { + const recorderId = process.env.RECORDER_ID; + const sourceType = process.env.SOURCE_TYPE; + if (!recorderId || !sourceType) { + console.log('[bootstrap] no RECORDER_ID/SOURCE_TYPE - on-demand sidecar'); + return; + } + + const projectId = process.env.PROJECT_ID; + const clipName = process.env.CLIP_NAME; + if (!projectId || !clipName) { + console.error('[bootstrap] missing PROJECT_ID or CLIP_NAME - cannot start'); + return; + } + + const listen = process.env.LISTEN === '1' || process.env.LISTEN === 'true'; + const listenPort = process.env.LISTEN_PORT + ? parseInt(process.env.LISTEN_PORT, 10) + : undefined; + const streamKey = process.env.STREAM_KEY || undefined; + const sourceUrl = process.env.SOURCE_URL || undefined; + + if (sourceType === 'sdi') { + console.warn('[bootstrap] SDI auto-start not supported'); + return; + } + + console.log(`[bootstrap] starting ${sourceType} ingest (listen=${listen} port=${listenPort || 'n/a'})...`); + try { + const session = await captureManager.start({ + projectId, + binId: process.env.BIN_ID || null, + clipName, + sourceType, + sourceUrl, + listen, + listenPort, + streamKey, + }); + console.log(`[bootstrap] session ${session.sessionId} started for clip ${clipName}`); + } catch (err) { + console.error('[bootstrap] failed to start capture:', err); + } +} + +let shuttingDown = false; +async function gracefulShutdown(signal) { + if (shuttingDown) return; + shuttingDown = true; + console.log(`[shutdown] ${signal} received`); + + const status = captureManager.getStatus(); + + if (status.recording) { + console.log(`[shutdown] stopping active session ${status.sessionId}...`); + try { + const completed = await captureManager.stop(status.sessionId); + console.log(`[shutdown] session ${completed.sessionId} finalised; duration=${completed.duration}s`); + + try { + const res = await fetch(`${MAM_API_URL}/api/v1/assets`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + projectId: completed.projectId, + binId: completed.binId, + clipName: completed.clipName, + sourceType: completed.sourceType, + hiresKey: completed.hiresKey, + proxyKey: completed.proxyKey, + needsProxy: completed.proxyKey === null, + duration: completed.duration, + capturedAt: completed.startedAt, + }), + }); + if (!res.ok) { + console.warn(`[shutdown] mam-api /assets returned ${res.status}: ${await res.text()}`); + } else { + console.log('[shutdown] asset registered with mam-api'); + } + } catch (mamErr) { + console.error('[shutdown] failed to register asset:', mamErr.message); + } + } catch (err) { + console.error('[shutdown] error during stop:', err); + } + } + + server.close(() => { + console.log('[shutdown] http server closed - exiting'); + process.exit(0); + }); + + setTimeout(() => process.exit(0), 5000).unref(); +} + +process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); +process.on('SIGINT', () => gracefulShutdown('SIGINT')); diff --git a/services/mam-api/src/routes/assets.js b/services/mam-api/src/routes/assets.js index 0459e72..9ceab04 100644 --- a/services/mam-api/src/routes/assets.js +++ b/services/mam-api/src/routes/assets.js @@ -19,6 +19,10 @@ const thumbnailQueue = new Queue('thumbnail', { connection: parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'), }); +const proxyQueue = new Queue('proxy', { + connection: parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'), +}); + // GET / - List assets with filtering router.get('/', async (req, res, next) => { try { @@ -145,6 +149,12 @@ router.post('/', async (req, res, next) => { proxyKey, outputKey: thumbnailKey, }); + } else if (hiresKey) { + await proxyQueue.add('generate', { + assetId: id, + inputKey: hiresKey, + outputKey: `proxies/${id}.mp4`, + }); } else { // No proxy yet — mark ready immediately (e.g. audio-only or test mode) await pool.query( diff --git a/services/mam-api/src/routes/recorders.js b/services/mam-api/src/routes/recorders.js index c1496d9..8952b38 100644 --- a/services/mam-api/src/routes/recorders.js +++ b/services/mam-api/src/routes/recorders.js @@ -86,17 +86,16 @@ router.get('/', async (req, res, next) => { // POST / - Create a new recorder router.post('/', async (req, res, next) => { try { - const { - name, - source_type, - source_config, - recording_codec, - recording_resolution, - proxy_enabled, - proxy_codec, - proxy_resolution, - project_id, - } = req.body; + const b = req.body || {}; + const name = b.name; + const source_type = b.source_type; + const source_config = b.source_config; + const recording_codec = b.recording_codec || b.codec; + const recording_resolution = b.recording_resolution || b.resolution; + const proxy_enabled = b.proxy_enabled !== undefined ? b.proxy_enabled : (b.proxy_config ? true : undefined); + const proxy_codec = b.proxy_codec || (b.proxy_config && b.proxy_config.codec); + const proxy_resolution = b.proxy_resolution || (b.proxy_config && (b.proxy_config.resolution || b.proxy_config.bitrate)); + const project_id = b.project_id; if (!name || !source_type) { return res @@ -268,6 +267,13 @@ router.post('/:id/start', async (req, res, next) => { const startRes = await dockerApi('POST', `/containers/${containerId}/start`); if (startRes.status !== 204) { + // Clean up the unstarted container so it doesn't accumulate as an orphan + // (e.g. when the requested host port is already bound by another process). + try { + await dockerApi('DELETE', `/containers/${containerId}?force=true`); + } catch (cleanupErr) { + console.error('Failed to remove unstarted container:', cleanupErr.message); + } return res.status(500).json({ error: 'Failed to start container', details: startRes.data, @@ -310,10 +316,10 @@ router.post('/:id/stop', async (req, res, next) => { return res.status(400).json({ error: 'No container running' }); } - // Stop container + // Stop container with 5-min grace so SRT/RTMP captures can flush S3 upload const stopRes = await dockerApi( 'POST', - `/containers/${recorder.container_id}/stop` + `/containers/${recorder.container_id}/stop?t=300` ); // 204 = stopped, 304 = already stopped — both are acceptable diff --git a/services/web-ui/public/recorders.html b/services/web-ui/public/recorders.html index c70f66f..59ff7e4 100644 --- a/services/web-ui/public/recorders.html +++ b/services/web-ui/public/recorders.html @@ -3,11 +3,11 @@ - Recorders — Wild Dragon + Recorders — Z-AMPP - +