Recon: clone repo, snapshot deployed UI, inventory TrueNAS storage

Button audit pass across all pages

Deploy to zampp1 + push to main


1	import { execFile } from 'child_process';
2	import { promisify } from 'util';
3
4	const execFileAsync = promisify(execFile);
5
6	export const runFFmpeg = async (args, options = {}) => {
7	  const { stdio = 'pipe', ...otherOptions } = options;
8	  try {
9	    const { stdout, stderr } = await execFileAsync('ffmpeg', args, {
10	      stdio,
11	      ...otherOptions,
12	    });
13	    return { stdout, stderr };
14	  } catch (error) {
15	    throw new Error(`FFmpeg error: ${error.message}\nStderr: ${error.stderr}`);
16	  }
17	};
18
19	const runFFprobe = async (args) => {
20	  try {
21	    const { stdout } = await execFileAsync('ffprobe', args);
22	    return { stdout };
23	  } catch (error) {
24	    throw new Error(`FFprobe error: ${error.message}\nStderr: ${error.stderr}`);
25	  }
26	};
27
28	export const getMediaDuration = async (inputPath) => {
29	  const args = [
30	    '-v', 'error',
31	    '-show_entries', 'format=duration',
32	    '-of', 'default=noprint_wrappers=1:nokey=1',
33	    inputPath,
34	  ];
35	  const { stdout } = await runFFprobe(args);
36	  return parseFloat(stdout.trim());
37	};
38
39	/**
40	 * Return structured media metadata for an input file.
41	 * Result shape:
42	 *   { fps, codec, resolution, durationMs, fileSizeBytes }
43	 * Any field may be null if ffprobe cannot determine it.
44	 */
45	export const getMediaInfo = async (inputPath) => {
46	  const args = [
47	    '-v', 'quiet',
48	    '-print_format', 'json',
49	    '-show_streams',
50	    '-show_format',
51	    inputPath,
52	  ];
53	  const { stdout } = await runFFprobe(args);
54	  const info = JSON.parse(stdout);
55
56	  const videoStream = (info.streams || []).find(s => s.codec_type === 'video');
57	  const fmt         = info.format || {};
58
59	  let fps = null;
60	  if (videoStream?.r_frame_rate) {
61	    const [num, den] = videoStream.r_frame_rate.split('/').map(Number);
62	    if (den > 0) fps = Math.round((num / den) * 1000) / 1000;
63	  }
64
65	  return {
66	    fps,
67	    codec:          videoStream?.codec_name  || null,
68	    resolution:     videoStream ? `${videoStream.width}x${videoStream.height}` : null,
69	    durationMs:     fmt.duration ? Math.round(parseFloat(fmt.duration) * 1000) : null,
70	    fileSizeBytes:  fmt.size     ? parseInt(fmt.size, 10)                       : null,
71	  };
72	};
73
74	export const extractFrameAtTime = async (inputPath, outputPath, timeCode) => {
75	  const args = [
76	    '-ss', timeCode,
77	    '-i', inputPath,
78	    '-vframes', '1',
79	    '-q:v', '2',
80	    '-y',
81	    outputPath,
82	  ];
83	  await runFFmpeg(args);
84	};
85
86	export const transcodeVideo = async (inputPath, outputPath, options = {}) => {
87	  const {
88	    videoCodec = 'libx264',
89	    videoPreset = 'fast',
90	    videoBitrate = '10M',
91	    audioCodec = 'aac',
92	    audioBitrate = '192k',
93	  } = options;
94
95	  const args = [
96	    '-i', inputPath,
97	    '-c:v', videoCodec,
98	    '-preset', videoPreset,
99	    '-b:v', videoBitrate,
100	    '-c:a', audioCodec,
101	    '-b:a', audioBitrate,
102	    '-movflags', '+faststart',
103	    '-y',
104	    outputPath,
105	  ];
106
107	  await runFFmpeg(args);
108	};
109
110	export const trimSegment = async (inputPath, outputPath, inPoint, outPoint) => {
111	  const args = [
112	    '-i', inputPath,
113	    '-ss', inPoint,
114	    '-to', outPoint,
115	    '-c', 'copy',
116	    '-y',
117	    outputPath,
118	  ];
119	  await runFFmpeg(args);
120	};
121
122	export const concatSegments = async (segmentListFile, outputPath) => {
123	  const args = [
124	    '-f', 'concat',
125	    '-safe', '0',
126	    '-i', segmentListFile,
127	    '-c', 'copy',
128	    '-y',
129	    outputPath,
130	  ];
131	  await runFFmpeg(args);
132	};
133

import { execFile } from 'child_process';
import { promisify } from 'util';

const execFileAsync = promisify(execFile);

export const runFFmpeg = async (args, options = {}) => {
  const { stdio = 'pipe', ...otherOptions } = options;
  try {
    const { stdout, stderr } = await execFileAsync('ffmpeg', args, {
      stdio,
      ...otherOptions,
    });
    return { stdout, stderr };
  } catch (error) {
    throw new Error(`FFmpeg error: ${error.message}\nStderr: ${error.stderr}`);
  }
};

const runFFprobe = async (args) => {
  try {
    const { stdout } = await execFileAsync('ffprobe', args);
    return { stdout };
  } catch (error) {
    throw new Error(`FFprobe error: ${error.message}\nStderr: ${error.stderr}`);
  }
};

export const getMediaDuration = async (inputPath) => {
  const args = [
    '-v', 'error',
    '-show_entries', 'format=duration',
    '-of', 'default=noprint_wrappers=1:nokey=1',
    inputPath,
  ];
  const { stdout } = await runFFprobe(args);
  return parseFloat(stdout.trim());
};

/**
 * Return structured media metadata for an input file.
 * Result shape:
 *   { fps, codec, resolution, durationMs, fileSizeBytes }
 * Any field may be null if ffprobe cannot determine it.
 */
export const getMediaInfo = async (inputPath) => {
  const args = [
    '-v', 'quiet',
    '-print_format', 'json',
    '-show_streams',
    '-show_format',
    inputPath,
  ];
  const { stdout } = await runFFprobe(args);
  const info = JSON.parse(stdout);

  const videoStream = (info.streams || []).find(s => s.codec_type === 'video');
  const fmt         = info.format || {};

  let fps = null;
  if (videoStream?.r_frame_rate) {
    const [num, den] = videoStream.r_frame_rate.split('/').map(Number);
    if (den > 0) fps = Math.round((num / den) * 1000) / 1000;
  }

  return {
    fps,
    codec:          videoStream?.codec_name  || null,
    resolution:     videoStream ? `${videoStream.width}x${videoStream.height}` : null,
    durationMs:     fmt.duration ? Math.round(parseFloat(fmt.duration) * 1000) : null,
    fileSizeBytes:  fmt.size     ? parseInt(fmt.size, 10)                       : null,
  };
};

export const extractFrameAtTime = async (inputPath, outputPath, timeCode) => {
  const args = [
    '-ss', timeCode,
    '-i', inputPath,
    '-vframes', '1',
    '-q:v', '2',
    '-y',
    outputPath,
  ];
  await runFFmpeg(args);
};

export const transcodeVideo = async (inputPath, outputPath, options = {}) => {
  const {
    videoCodec = 'libx264',
    videoPreset = 'fast',
    videoBitrate = '10M',
    audioCodec = 'aac',
    audioBitrate = '192k',
  } = options;

  const args = [
    '-i', inputPath,
    '-c:v', videoCodec,
    '-preset', videoPreset,
    '-b:v', videoBitrate,
    '-c:a', audioCodec,
    '-b:a', audioBitrate,
    '-movflags', '+faststart',
    '-y',
    outputPath,
  ];

  await runFFmpeg(args);
};

export const trimSegment = async (inputPath, outputPath, inPoint, outPoint) => {
  const args = [
    '-i', inputPath,
    '-ss', inPoint,
    '-to', outPoint,
    '-c', 'copy',
    '-y',
    outputPath,
  ];
  await runFFmpeg(args);
};

export const concatSegments = async (segmentListFile, outputPath) => {
  const args = [
    '-f', 'concat',
    '-safe', '0',
    '-i', segmentListFile,
    '-c', 'copy',
    '-y',
    outputPath,
  ];
  await runFFmpeg(args);
};


Lets do a debugging & polish session on https://forge.wilddragon.net/zgaetano/wild-dragon , its currently deployed to Truenas. http://10.0.0.25:47434/   SRT / Recorder / Ingest page is still buggy, pr…


Debug ingest, monitors, buttons and prototype file editing


1	import { spawn } from 'child_process';
2	import { v4 as uuidv4 } from 'uuid';
3	import { createUploadStream } from './s3/client.js';
4
5	const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon';
6
7	// ── Codec catalogue ──────────────────────────────────────────────────────
8	// Each entry maps the UI value to a base ffmpeg flag set. Bitrate / framerate
9	// / pix_fmt are layered on top from the per-recorder configuration.
10	const VIDEO_CODECS = {
11	  prores_hq:    { args: ['-c:v', 'prores_ks', '-profile:v', '3'], bitrateControl: false, pixFmt: 'yuv422p10le' },
12	  prores_422:   { args: ['-c:v', 'prores_ks', '-profile:v', '2'], bitrateControl: false, pixFmt: 'yuv422p10le' },
13	  prores_lt:    { args: ['-c:v', 'prores_ks', '-profile:v', '1'], bitrateControl: false, pixFmt: 'yuv422p10le' },
14	  prores_proxy: { args: ['-c:v', 'prores_ks', '-profile:v', '0'], bitrateControl: false, pixFmt: 'yuv422p10le' },
15	  dnxhd:        { args: ['-c:v', 'dnxhd'],                          bitrateControl: true,  pixFmt: 'yuv422p' },
16	  dnxhr_hq:     { args: ['-c:v', 'dnxhd', '-profile:v', 'dnxhr_hq'], bitrateControl: false, pixFmt: 'yuv422p' },
17	  dnxhr_sq:     { args: ['-c:v', 'dnxhd', '-profile:v', 'dnxhr_sq'], bitrateControl: false, pixFmt: 'yuv422p' },
18	  h264:         { args: ['-c:v', 'libx264', '-preset', 'medium'],   bitrateControl: true,  pixFmt: 'yuv420p' },
19	  h264_nvenc:   { args: ['-c:v', 'h264_nvenc', '-preset', 'p5'],    bitrateControl: true,  pixFmt: 'yuv420p' },
20	  h265:         { args: ['-c:v', 'libx265', '-preset', 'medium'],   bitrateControl: true,  pixFmt: 'yuv420p' },
21	  hevc_nvenc:   { args: ['-c:v', 'hevc_nvenc', '-preset', 'p5'],    bitrateControl: true,  pixFmt: 'yuv420p' },
22	};
23
24	const AUDIO_CODECS = {
25	  pcm_s16le:   { args: ['-c:a', 'pcm_s16le'],   bitrateControl: false },
26	  pcm_s24le:   { args: ['-c:a', 'pcm_s24le'],   bitrateControl: false },
27	  pcm_s32le:   { args: ['-c:a', 'pcm_s32le'],   bitrateControl: false },
28	  aac:         { args: ['-c:a', 'aac'],         bitrateControl: true  },
29	  ac3:         { args: ['-c:a', 'ac3'],         bitrateControl: true  },
30	  opus:        { args: ['-c:a', 'libopus'],     bitrateControl: true  },
31	  flac:        { args: ['-c:a', 'flac'],        bitrateControl: false },
32	};
33
34	const CONTAINER_FMT = {
35	  mov: 'mov',
36	  mp4: 'mp4',
37	  mkv: 'matroska',
38	  mxf: 'mxf',
39	  ts:  'mpegts',
40	};
41
42	const CONTAINER_EXT = {
43	  mov: 'mov', mp4: 'mp4', mkv: 'mkv', mxf: 'mxf', ts: 'ts',
44	};
45
46	function buildEncodeArgs({
47	  codec, videoBitrate, framerate,
48	  audioCodec, audioBitrate, audioChannels,
49	  container, isNetwork, isProxy = false,
50	}) {
51	  const v = VIDEO_CODECS[codec] || (isProxy ? VIDEO_CODECS.h264 : VIDEO_CODECS.prores_hq);
52	  const a = AUDIO_CODECS[audioCodec] || (isProxy ? AUDIO_CODECS.aac : AUDIO_CODECS.pcm_s24le);
53	  const fmt = CONTAINER_FMT[container] || (isProxy ? 'mp4' : 'mov');
54
55	  const args = [];
56	  if (isNetwork) args.push('-map', '0:v:0?', '-map', '0:a:0?');
57
58	  args.push(...v.args);
59	  if (v.pixFmt) args.push('-pix_fmt', v.pixFmt);
60	  if (v.bitrateControl && videoBitrate) args.push('-b:v', videoBitrate);
61	  if (framerate && framerate !== 'native') args.push('-r', framerate);
62
63	  args.push(...a.args);
64	  if (a.bitrateControl && audioBitrate) args.push('-b:a', audioBitrate);
65	  if (audioChannels) args.push('-ac', String(audioChannels));
66
67	  if (fmt === 'mov' || fmt === 'mp4') {
68	    args.push('-movflags', '+frag_keyframe+empty_moov');
69	  }
70	  args.push('-f', fmt);
71
72	  return args;
73	}
74
75	class CaptureManager {
76	  constructor() {
77	    this.state = {
78	      recording: false,
79	      sessionId: null,
80	      processes: {},
81	      currentSession: {},
82	      framesReceived: 0,
83	      currentFps: 0,
84	      lastFrameAt: null,
85	      lastError: null,
86	    };
87	  }
88
89	  /**
90	   * Build FFmpeg input arguments based on source type.
91	   * Returns { inputArgs, isNetwork }
92	   * @private
93	   */
94	  async _buildInputArgs({ sourceType, device, sourceUrl, listen, listenPort, streamKey }) {
95	    if (sourceType === 'srt') {
96	      let url;
97	      if (listen) {
98	        const port = listenPort || 9000;
99	        url = `srt://0.0.0.0:${port}?mode=listener`;
100	      } else {
101	        url = sourceUrl;
102	        if (!url.includes('mode=')) {
103	          url += (url.includes('?') ? '&' : '?') + 'mode=caller';
104	        }
105	      }
106	      return { inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-i', url], isNetwork: true };
107	    }
108
109	    if (sourceType === 'rtmp') {
110	      if (listen) {
111	        const port = listenPort || 1935;
112	        const key = streamKey || 'stream';
113	        return {
114	          inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-listen', '1', '-i', `rtmp://0.0.0.0:${port}/live/${key}`],
115	          isNetwork: true,
116	        };
117	      }
118	      return { inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-i', sourceUrl], isNetwork: true };
119	    }
120
121	    // Default: SDI via DeckLink
122	    // device may be an integer index (0-based) or a full device name string.
123	    // FFmpeg 7.x DeckLink requires the full name (e.g. 'DeckLink Duo (2)').
124	    // Map integer index -> name using ffmpeg -sources decklink at runtime.
125	    let deckLinkName = String(device);
126	    if (typeof device === 'number' || /^\d+$/.test(String(device))) {
127	      const idx = parseInt(device, 10);
128	      try {
129	        const { execSync } = await import('child_process');
130	        const out = execSync('ffmpeg -sources decklink 2>&1', { encoding: 'utf-8', timeout: 5000 });
131	        const names = [];
132	        for (const line of out.split('\n')) {
133	          const m = line.match(/^\s+[0-9a-f:]+\s+\[([^\]]+)\]/);
134	          if (m) names.push(m[1]);
135	        }
136	        if (names[idx]) deckLinkName = names[idx];
137	        else deckLinkName = `DeckLink Duo (${idx + 1})`;
138	      } catch (_) {
139	        deckLinkName = `DeckLink Duo (${parseInt(device, 10) + 1})`;
140	      }
141	    }
142	    return {
143	      inputArgs: ['-f', 'decklink', '-i', deckLinkName],
144	      isNetwork: false,
145	    };
146	  }
147
148	  /**
149	   * Start a new capture session.
150	   *
151	   * Codec parameters all have sensible defaults so legacy callers (no codec
152	   * args) still produce ProRes HQ master + H.264 proxy.
153	   */
154	  async start({
155	    assetId,
156	    projectId,
157	    binId,
158	    clipName,
159	    device,
160	    sourceType = 'sdi',
161	    sourceUrl,
162	    listen = false,
163	    listenPort,
164	    streamKey,
165	    // ── Recording codec ─────────────────────────────────────────────
166	    videoCodec     = 'prores_hq',
167	    videoBitrate   = null,
168	    framerate      = null,
169	    audioCodec     = 'pcm_s24le',
170	    audioBitrate   = null,
171	    audioChannels  = 2,
172	    container      = 'mov',
173	    // ── Proxy codec ─────────────────────────────────────────────────
174	    proxyEnabled       = true,
175	    proxyVideoCodec    = 'h264',
176	    proxyVideoBitrate  = '8M',
177	    proxyFramerate     = null,
178	    proxyAudioCodec    = 'aac',
179	    proxyAudioBitrate  = '192k',
180	    proxyAudioChannels = 2,
181	    proxyContainer     = 'mp4',
182	  }) {
183	    this._assetIdForHls = assetId || null;
184	    if (this.state.recording) {
185	      throw new Error('Capture already in progress');
186	    }
187
188	    const sessionId = uuidv4();
189	    const hiresExt  = CONTAINER_EXT[container] || 'mov';
190	    const proxyExt  = CONTAINER_EXT[proxyContainer] || 'mp4';
191	    const hiresKey  = `projects/${projectId}/masters/${clipName}.${hiresExt}`;
192
193	    // Network sources cannot be opened by two FFmpeg processes simultaneously
194	    // (one socket = one consumer). For SRT/RTMP the BullMQ worker generates
195	    // the proxy after the recording stops.
196	    const proxyKey = (sourceType === 'sdi' && proxyEnabled)
197	      ? `projects/${projectId}/proxies/${clipName}.${proxyExt}`
198	      : null;
199
200	    const startedAt = new Date().toISOString();
201
202	    const { inputArgs, isNetwork } = await this._buildInputArgs({
203	      sourceType, device, sourceUrl, listen, listenPort, streamKey,
204	    });
205
206	    const hiresCodecArgs = buildEncodeArgs({
207	      codec: videoCodec, videoBitrate, framerate,
208	      audioCodec, audioBitrate, audioChannels,
209	      container,
210	      isNetwork,
211	      isProxy: false,
212	    });
213
214	    console.log('[capture] hires ffmpeg args:', hiresCodecArgs.join(' '));
215
216	    const sdiFilterArgs = (sourceType === 'sdi') ? ['-vf', 'yadif=mode=1:deint=1'] : [];
217
218	    const hiresProcess = spawn('ffmpeg', [
219	      ...inputArgs,
220	      ...sdiFilterArgs,
221	      ...hiresCodecArgs,
222	      'pipe:1',
223	    ], { stdio: ['ignore', 'pipe', 'pipe'] });
224
225	    const hiresUpload = createUploadStream(S3_BUCKET, hiresKey, hiresProcess.stdout);
226
227	    const processes = { hires: hiresProcess };
228	    const uploads = { hires: hiresUpload };
229
230	    // ── HLS tee for network sources (live preview in the UI) ──────────
231	    let hlsProcess = null;
232	    let hlsDir = null;
233	    if (isNetwork && this._assetIdForHls) {
234	      try {
235	        const fs = await import('node:fs');
236	        hlsDir = '/live/' + this._assetIdForHls;
237	        fs.mkdirSync(hlsDir, { recursive: true });
238	        const hlsArgs = [
239	          ...inputArgs,
240	          '-map', '0:v:0?', '-map', '0:a:0?',
241	          '-c:v', 'libx264', '-preset', 'veryfast', '-tune', 'zerolatency',
242	          '-pix_fmt', 'yuv420p', '-b:v', '2M', '-g', '60', '-sc_threshold', '0',
243	          '-c:a', 'aac', '-b:a', '128k', '-ar', '44100',
244	          '-f', 'hls', '-hls_time', '2', '-hls_list_size', '15',
245	          '-hls_flags', 'delete_segments+append_list+omit_endlist',
246	          '-hls_segment_filename', hlsDir + '/seg-%05d.ts',
247	          hlsDir + '/index.m3u8',
248	        ];
249	        hlsProcess = spawn('ffmpeg', hlsArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
250	        hlsProcess.stderr.on('data', (d) => { console.error('[HLS] ' + d); });
251	        hlsProcess.on('exit', (c) => console.log('[HLS] exited ' + c));
252	        processes.hls = hlsProcess;
253	        console.log('[HLS] tee started -> ' + hlsDir);
254	      } catch (err) {
255	        console.error('[HLS] tee failed:', err.message);
256	      }
257	    }
258
259	    hiresProcess.stderr.on('data', (data) => {
260	      const text = data.toString();
261	      console.error(`[HIRES] ${text}`);
262	      const m = text.match(/frame=\s*(\d+)\s+fps=\s*([\d.]+)/);
263	      if (m) {
264	        this.state.framesReceived = parseInt(m[1], 10);
265	        this.state.currentFps = parseFloat(m[2]);
266	        this.state.lastFrameAt = new Date().toISOString();
267	      }
268	      if (/Connection refused|No route to host|Connection failed|Input\/output error|Server returned|404 Not Found|Connection timed out/i.test(text)) {
269	        this.state.lastError = text.trim().slice(0, 240);
270	      }
271	    });
272
273	    // SDI only: spawn a second ffmpeg for the proxy.
274	    // DeckLink cards allow concurrent reads; network sockets do not.
275	    if (!isNetwork && proxyEnabled) {
276	      const proxyCodecArgs = buildEncodeArgs({
277	        codec: proxyVideoCodec,
278	        videoBitrate: proxyVideoBitrate,
279	        framerate: proxyFramerate,
280	        audioCodec: proxyAudioCodec,
281	        audioBitrate: proxyAudioBitrate,
282	        audioChannels: proxyAudioChannels,
283	        container: proxyContainer,
284	        isNetwork: false,
285	        isProxy: true,
286	      });
287
288	      console.log('[capture] proxy ffmpeg args:', proxyCodecArgs.join(' '));
289
290	      const proxyProcess = spawn('ffmpeg', [
291	        ...inputArgs,
292	        ...sdiFilterArgs,
293	        ...proxyCodecArgs,
294	        '-movflags', '+frag_keyframe+empty_moov',
295	        'pipe:1',
296	      ], { stdio: ['ignore', 'pipe', 'pipe'] });
297
298	      const proxyUpload = createUploadStream(S3_BUCKET, proxyKey, proxyProcess.stdout);
299	      processes.proxy = proxyProcess;
300	      uploads.proxy = proxyUpload;
301
302	      proxyProcess.stderr.on('data', (data) => {
303	        console.error(`[PROXY] ${data}`);
304	      });
305	    }
306
307	    this.state.recording = true;
308	    this.state.sessionId = sessionId;
309	    this.state.processes = processes;
310	    this.state.framesReceived = 0;
311	    this.state.currentFps = 0;
312	    this.state.lastFrameAt = null;
313	    this.state.lastError = null;
314	    this.state.currentSession = {
315	      sessionId,
316	      projectId,
317	      binId,
318	      clipName,
319	      device,
320	      sourceType,
321	      sourceUrl,
322	      hiresKey,
323	      proxyKey,
324	      startedAt,
325	      duration: 0,
326	      uploads,
327	      codecs: {
328	        videoCodec, videoBitrate, framerate,
329	        audioCodec, audioBitrate, audioChannels, container,
330	        proxyEnabled, proxyVideoCodec, proxyVideoBitrate,
331	        proxyAudioCodec, proxyAudioBitrate, proxyAudioChannels, proxyContainer,
332	      },
333	    };
334
335	    return this._formatSessionResponse();
336	  }
337
338	  async stop(sessionId) {
339	    if (!this.state.recording || this.state.sessionId !== sessionId) {
340	      throw new Error('No active capture session or session ID mismatch');
341	    }
342
343	    const { processes, currentSession } = this.state;
344
345	    if (processes.hires) processes.hires.kill('SIGINT');
346	    if (processes.proxy) processes.proxy.kill('SIGINT');
347	    if (processes.hls)   { try { processes.hls.kill('SIGINT'); } catch (_) {} }
348
349	    try {
350	      const uploadPromises = [currentSession.uploads.hires];
351	      if (currentSession.uploads.proxy) uploadPromises.push(currentSession.uploads.proxy);
352	      await Promise.all(uploadPromises);
353	    } catch (error) {
354	      console.error('Error during upload completion:', error);
355	    }
356
357	    const stoppedAt = new Date().toISOString();
358	    const startTime = new Date(currentSession.startedAt);
359	    const stopTime = new Date(stoppedAt);
360	    const duration = Math.round((stopTime - startTime) / 1000);
361
362	    this.state.recording = false;
363	    this.state.sessionId = null;
364	    this.state.processes = {};
365
366	    return {
367	      sessionId,
368	      projectId: currentSession.projectId,
369	      binId: currentSession.binId,
370	      clipName: currentSession.clipName,
371	      sourceType: currentSession.sourceType,
372	      hiresKey: currentSession.hiresKey,
373	      proxyKey: currentSession.proxyKey,
374	      startedAt: currentSession.startedAt,
375	      stoppedAt,
376	      duration,
377	    };
378	  }
379
380	  getStatus() {
381	    if (!this.state.recording) return { recording: false };
382
383	    const startTime = new Date(this.state.currentSession.startedAt);
384	    const now = new Date();
385	    const duration = Math.round((now - startTime) / 1000);
386
387	    const lastFrameAt = this.state.lastFrameAt;
388	    const msSinceFrame = lastFrameAt ? (Date.now() - new Date(lastFrameAt).getTime()) : null;
389	    let signal = 'connecting';
390	    if (this.state.framesReceived > 0) {
391	      signal = (msSinceFrame !== null && msSinceFrame < 5000) ? 'receiving' : 'lost';
392	    } else if (this.state.lastError) {
393	      signal = 'error';
394	    }
395	    return {
396	      recording: true,
397	      sessionId: this.state.sessionId,
398	      sourceType: this.state.currentSession.sourceType,
399	      device: this.state.currentSession.device,
400	      clipName: this.state.currentSession.clipName,
401	      projectId: this.state.currentSession.projectId,
402	      binId: this.state.currentSession.binId,
403	      duration,
404	      startedAt: this.state.currentSession.startedAt,
405	      signal,
406	      framesReceived: this.state.framesReceived,
407	      currentFps: this.state.currentFps,
408	      lastFrameAt,
409	      msSinceFrame,
410	      lastError: this.state.lastError,
411	      codecs: this.state.currentSession.codecs,
412	    };
413	  }
414
415	  _formatSessionResponse() {
416	    const { currentSession, sessionId } = this.state;
417	    return {
418	      sessionId,
419	      projectId: currentSession.projectId,
420	      binId: currentSession.binId,
421	      clipName: currentSession.clipName,
422	      device: currentSession.device,
423	      sourceType: currentSession.sourceType,
424	      hiresKey: currentSession.hiresKey,
425	      proxyKey: currentSession.proxyKey,
426	      startedAt: currentSession.startedAt,
427	      codecs: currentSession.codecs,
428	    };
429	  }
430	}
431
432	export default new CaptureManager();
433	export { VIDEO_CODECS, AUDIO_CODECS, CONTAINER_FMT, CONTAINER_EXT };
434

import { spawn } from 'child_process';
import { v4 as uuidv4 } from 'uuid';
import { createUploadStream } from './s3/client.js';

const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon';

// ── Codec catalogue ──────────────────────────────────────────────────────
// Each entry maps the UI value to a base ffmpeg flag set. Bitrate / framerate
// / pix_fmt are layered on top from the per-recorder configuration.
const VIDEO_CODECS = {
  prores_hq:    { args: ['-c:v', 'prores_ks', '-profile:v', '3'], bitrateControl: false, pixFmt: 'yuv422p10le' },
  prores_422:   { args: ['-c:v', 'prores_ks', '-profile:v', '2'], bitrateControl: false, pixFmt: 'yuv422p10le' },
  prores_lt:    { args: ['-c:v', 'prores_ks', '-profile:v', '1'], bitrateControl: false, pixFmt: 'yuv422p10le' },
  prores_proxy: { args: ['-c:v', 'prores_ks', '-profile:v', '0'], bitrateControl: false, pixFmt: 'yuv422p10le' },
  dnxhd:        { args: ['-c:v', 'dnxhd'],                          bitrateControl: true,  pixFmt: 'yuv422p' },
  dnxhr_hq:     { args: ['-c:v', 'dnxhd', '-profile:v', 'dnxhr_hq'], bitrateControl: false, pixFmt: 'yuv422p' },
  dnxhr_sq:     { args: ['-c:v', 'dnxhd', '-profile:v', 'dnxhr_sq'], bitrateControl: false, pixFmt: 'yuv422p' },
  h264:         { args: ['-c:v', 'libx264', '-preset', 'medium'],   bitrateControl: true,  pixFmt: 'yuv420p' },
  h264_nvenc:   { args: ['-c:v', 'h264_nvenc', '-preset', 'p5'],    bitrateControl: true,  pixFmt: 'yuv420p' },
  h265:         { args: ['-c:v', 'libx265', '-preset', 'medium'],   bitrateControl: true,  pixFmt: 'yuv420p' },
  hevc_nvenc:   { args: ['-c:v', 'hevc_nvenc', '-preset', 'p5'],    bitrateControl: true,  pixFmt: 'yuv420p' },
};

const AUDIO_CODECS = {
  pcm_s16le:   { args: ['-c:a', 'pcm_s16le'],   bitrateControl: false },
  pcm_s24le:   { args: ['-c:a', 'pcm_s24le'],   bitrateControl: false },
  pcm_s32le:   { args: ['-c:a', 'pcm_s32le'],   bitrateControl: false },
  aac:         { args: ['-c:a', 'aac'],         bitrateControl: true  },
  ac3:         { args: ['-c:a', 'ac3'],         bitrateControl: true  },
  opus:        { args: ['-c:a', 'libopus'],     bitrateControl: true  },
  flac:        { args: ['-c:a', 'flac'],        bitrateControl: false },
};

const CONTAINER_FMT = {
  mov: 'mov',
  mp4: 'mp4',
  mkv: 'matroska',
  mxf: 'mxf',
  ts:  'mpegts',
};

const CONTAINER_EXT = {
  mov: 'mov', mp4: 'mp4', mkv: 'mkv', mxf: 'mxf', ts: 'ts',
};

function buildEncodeArgs({
  codec, videoBitrate, framerate,
  audioCodec, audioBitrate, audioChannels,
  container, isNetwork, isProxy = false,
}) {
  const v = VIDEO_CODECS[codec] || (isProxy ? VIDEO_CODECS.h264 : VIDEO_CODECS.prores_hq);
  const a = AUDIO_CODECS[audioCodec] || (isProxy ? AUDIO_CODECS.aac : AUDIO_CODECS.pcm_s24le);
  const fmt = CONTAINER_FMT[container] || (isProxy ? 'mp4' : 'mov');

  const args = [];
  if (isNetwork) args.push('-map', '0:v:0?', '-map', '0:a:0?');

  args.push(...v.args);
  if (v.pixFmt) args.push('-pix_fmt', v.pixFmt);
  if (v.bitrateControl && videoBitrate) args.push('-b:v', videoBitrate);
  if (framerate && framerate !== 'native') args.push('-r', framerate);

  args.push(...a.args);
  if (a.bitrateControl && audioBitrate) args.push('-b:a', audioBitrate);
  if (audioChannels) args.push('-ac', String(audioChannels));

  if (fmt === 'mov' || fmt === 'mp4') {
    args.push('-movflags', '+frag_keyframe+empty_moov');
  }
  args.push('-f', fmt);

  return args;
}

class CaptureManager {
  constructor() {
    this.state = {
      recording: false,
      sessionId: null,
      processes: {},
      currentSession: {},
      framesReceived: 0,
      currentFps: 0,
      lastFrameAt: null,
      lastError: null,
    };
  }

  /**
   * Build FFmpeg input arguments based on source type.
   * Returns { inputArgs, isNetwork }
   * @private
   */
  async _buildInputArgs({ sourceType, device, sourceUrl, listen, listenPort, streamKey }) {
    if (sourceType === 'srt') {
      let url;
      if (listen) {
        const port = listenPort || 9000;
        url = `srt://0.0.0.0:${port}?mode=listener`;
      } else {
        url = sourceUrl;
        if (!url.includes('mode=')) {
          url += (url.includes('?') ? '&' : '?') + 'mode=caller';
        }
      }
      return { inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-i', url], isNetwork: true };
    }

    if (sourceType === 'rtmp') {
      if (listen) {
        const port = listenPort || 1935;
        const key = streamKey || 'stream';
        return {
          inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-listen', '1', '-i', `rtmp://0.0.0.0:${port}/live/${key}`],
          isNetwork: true,
        };
      }
      return { inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-i', sourceUrl], isNetwork: true };
    }

    // Default: SDI via DeckLink
    // device may be an integer index (0-based) or a full device name string.
    // FFmpeg 7.x DeckLink requires the full name (e.g. 'DeckLink Duo (2)').
    // Map integer index -> name using ffmpeg -sources decklink at runtime.
    let deckLinkName = String(device);
    if (typeof device === 'number' || /^\d+$/.test(String(device))) {
      const idx = parseInt(device, 10);
      try {
        const { execSync } = await import('child_process');
        const out = execSync('ffmpeg -sources decklink 2>&1', { encoding: 'utf-8', timeout: 5000 });
        const names = [];
        for (const line of out.split('\n')) {
          const m = line.match(/^\s+[0-9a-f:]+\s+\[([^\]]+)\]/);
          if (m) names.push(m[1]);
        }
        if (names[idx]) deckLinkName = names[idx];
        else deckLinkName = `DeckLink Duo (${idx + 1})`;
      } catch (_) {
        deckLinkName = `DeckLink Duo (${parseInt(device, 10) + 1})`;
      }
    }
    return {
      inputArgs: ['-f', 'decklink', '-i', deckLinkName],
      isNetwork: false,
    };
  }

  /**
   * Start a new capture session.
   *
   * Codec parameters all have sensible defaults so legacy callers (no codec
   * args) still produce ProRes HQ master + H.264 proxy.
   */
  async start({
    assetId,
    projectId,
    binId,
    clipName,
    device,
    sourceType = 'sdi',
    sourceUrl,
    listen = false,
    listenPort,
    streamKey,
    // ── Recording codec ─────────────────────────────────────────────
    videoCodec     = 'prores_hq',
    videoBitrate   = null,
    framerate      = null,
    audioCodec     = 'pcm_s24le',
    audioBitrate   = null,
    audioChannels  = 2,
    container      = 'mov',
    // ── Proxy codec ─────────────────────────────────────────────────
    proxyEnabled       = true,
    proxyVideoCodec    = 'h264',
    proxyVideoBitrate  = '8M',
    proxyFramerate     = null,
    proxyAudioCodec    = 'aac',
    proxyAudioBitrate  = '192k',
    proxyAudioChannels = 2,
    proxyContainer     = 'mp4',
  }) {
    this._assetIdForHls = assetId || null;
    if (this.state.recording) {
      throw new Error('Capture already in progress');
    }

    const sessionId = uuidv4();
    const hiresExt  = CONTAINER_EXT[container] || 'mov';
    const proxyExt  = CONTAINER_EXT[proxyContainer] || 'mp4';
    const hiresKey  = `projects/${projectId}/masters/${clipName}.${hiresExt}`;

    // Network sources cannot be opened by two FFmpeg processes simultaneously
    // (one socket = one consumer). For SRT/RTMP the BullMQ worker generates
    // the proxy after the recording stops.
    const proxyKey = (sourceType === 'sdi' && proxyEnabled)
      ? `projects/${projectId}/proxies/${clipName}.${proxyExt}`
      : null;

    const startedAt = new Date().toISOString();

    const { inputArgs, isNetwork } = await this._buildInputArgs({
      sourceType, device, sourceUrl, listen, listenPort, streamKey,
    });

    const hiresCodecArgs = buildEncodeArgs({
      codec: videoCodec, videoBitrate, framerate,
      audioCodec, audioBitrate, audioChannels,
      container,
      isNetwork,
      isProxy: false,
    });

    console.log('[capture] hires ffmpeg args:', hiresCodecArgs.join(' '));

    const sdiFilterArgs = (sourceType === 'sdi') ? ['-vf', 'yadif=mode=1:deint=1'] : [];

    const hiresProcess = spawn('ffmpeg', [
      ...inputArgs,
      ...sdiFilterArgs,
      ...hiresCodecArgs,
      'pipe:1',
    ], { stdio: ['ignore', 'pipe', 'pipe'] });

    const hiresUpload = createUploadStream(S3_BUCKET, hiresKey, hiresProcess.stdout);

    const processes = { hires: hiresProcess };
    const uploads = { hires: hiresUpload };

    // ── HLS tee for network sources (live preview in the UI) ──────────
    let hlsProcess = null;
    let hlsDir = null;
    if (isNetwork && this._assetIdForHls) {
      try {
        const fs = await import('node:fs');
        hlsDir = '/live/' + this._assetIdForHls;
        fs.mkdirSync(hlsDir, { recursive: true });
        const hlsArgs = [
          ...inputArgs,
          '-map', '0:v:0?', '-map', '0:a:0?',
          '-c:v', 'libx264', '-preset', 'veryfast', '-tune', 'zerolatency',
          '-pix_fmt', 'yuv420p', '-b:v', '2M', '-g', '60', '-sc_threshold', '0',
          '-c:a', 'aac', '-b:a', '128k', '-ar', '44100',
          '-f', 'hls', '-hls_time', '2', '-hls_list_size', '15',
          '-hls_flags', 'delete_segments+append_list+omit_endlist',
          '-hls_segment_filename', hlsDir + '/seg-%05d.ts',
          hlsDir + '/index.m3u8',
        ];
        hlsProcess = spawn('ffmpeg', hlsArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
        hlsProcess.stderr.on('data', (d) => { console.error('[HLS] ' + d); });
        hlsProcess.on('exit', (c) => console.log('[HLS] exited ' + c));
        processes.hls = hlsProcess;
        console.log('[HLS] tee started -> ' + hlsDir);
      } catch (err) {
        console.error('[HLS] tee failed:', err.message);
      }
    }

    hiresProcess.stderr.on('data', (data) => {
      const text = data.toString();
      console.error(`[HIRES] ${text}`);
      const m = text.match(/frame=\s*(\d+)\s+fps=\s*([\d.]+)/);
      if (m) {
        this.state.framesReceived = parseInt(m[1], 10);
        this.state.currentFps = parseFloat(m[2]);
        this.state.lastFrameAt = new Date().toISOString();
      }
      if (/Connection refused|No route to host|Connection failed|Input\/output error|Server returned|404 Not Found|Connection timed out/i.test(text)) {
        this.state.lastError = text.trim().slice(0, 240);
      }
    });

    // SDI only: spawn a second ffmpeg for the proxy.
    // DeckLink cards allow concurrent reads; network sockets do not.
    if (!isNetwork && proxyEnabled) {
      const proxyCodecArgs = buildEncodeArgs({
        codec: proxyVideoCodec,
        videoBitrate: proxyVideoBitrate,
        framerate: proxyFramerate,
        audioCodec: proxyAudioCodec,
        audioBitrate: proxyAudioBitrate,
        audioChannels: proxyAudioChannels,
        container: proxyContainer,
        isNetwork: false,
        isProxy: true,
      });

      console.log('[capture] proxy ffmpeg args:', proxyCodecArgs.join(' '));

      const proxyProcess = spawn('ffmpeg', [
        ...inputArgs,
        ...sdiFilterArgs,
        ...proxyCodecArgs,
        '-movflags', '+frag_keyframe+empty_moov',
        'pipe:1',
      ], { stdio: ['ignore', 'pipe', 'pipe'] });

      const proxyUpload = createUploadStream(S3_BUCKET, proxyKey, proxyProcess.stdout);
      processes.proxy = proxyProcess;
      uploads.proxy = proxyUpload;

      proxyProcess.stderr.on('data', (data) => {
        console.error(`[PROXY] ${data}`);
      });
    }

    this.state.recording = true;
    this.state.sessionId = sessionId;
    this.state.processes = processes;
    this.state.framesReceived = 0;
    this.state.currentFps = 0;
    this.state.lastFrameAt = null;
    this.state.lastError = null;
    this.state.currentSession = {
      sessionId,
      projectId,
      binId,
      clipName,
      device,
      sourceType,
      sourceUrl,
      hiresKey,
      proxyKey,
      startedAt,
      duration: 0,
      uploads,
      codecs: {
        videoCodec, videoBitrate, framerate,
        audioCodec, audioBitrate, audioChannels, container,
        proxyEnabled, proxyVideoCodec, proxyVideoBitrate,
        proxyAudioCodec, proxyAudioBitrate, proxyAudioChannels, proxyContainer,
      },
    };

    return this._formatSessionResponse();
  }

  async stop(sessionId) {
    if (!this.state.recording || this.state.sessionId !== sessionId) {
      throw new Error('No active capture session or session ID mismatch');
    }

    const { processes, currentSession } = this.state;

    if (processes.hires) processes.hires.kill('SIGINT');
    if (processes.proxy) processes.proxy.kill('SIGINT');
    if (processes.hls)   { try { processes.hls.kill('SIGINT'); } catch (_) {} }

    try {
      const uploadPromises = [currentSession.uploads.hires];
      if (currentSession.uploads.proxy) uploadPromises.push(currentSession.uploads.proxy);
      await Promise.all(uploadPromises);
    } catch (error) {
      console.error('Error during upload completion:', error);
    }

    const stoppedAt = new Date().toISOString();
    const startTime = new Date(currentSession.startedAt);
    const stopTime = new Date(stoppedAt);
    const duration = Math.round((stopTime - startTime) / 1000);

    this.state.recording = false;
    this.state.sessionId = null;
    this.state.processes = {};

    return {
      sessionId,
      projectId: currentSession.projectId,
      binId: currentSession.binId,
      clipName: currentSession.clipName,
      sourceType: currentSession.sourceType,
      hiresKey: currentSession.hiresKey,
      proxyKey: currentSession.proxyKey,
      startedAt: currentSession.startedAt,
      stoppedAt,
      duration,
    };
  }

  getStatus() {
    if (!this.state.recording) return { recording: false };

    const startTime = new Date(this.state.currentSession.startedAt);
    const now = new Date();
    const duration = Math.round((now - startTime) / 1000);

    const lastFrameAt = this.state.lastFrameAt;
    const msSinceFrame = lastFrameAt ? (Date.now() - new Date(lastFrameAt).getTime()) : null;
    let signal = 'connecting';
    if (this.state.framesReceived > 0) {
      signal = (msSinceFrame !== null && msSinceFrame < 5000) ? 'receiving' : 'lost';
    } else if (this.state.lastError) {
      signal = 'error';
    }
    return {
      recording: true,
      sessionId: this.state.sessionId,
      sourceType: this.state.currentSession.sourceType,
      device: this.state.currentSession.device,
      clipName: this.state.currentSession.clipName,
      projectId: this.state.currentSession.projectId,
      binId: this.state.currentSession.binId,
      duration,
      startedAt: this.state.currentSession.startedAt,
      signal,
      framesReceived: this.state.framesReceived,
      currentFps: this.state.currentFps,
      lastFrameAt,
      msSinceFrame,
      lastError: this.state.lastError,
      codecs: this.state.currentSession.codecs,
    };
  }

  _formatSessionResponse() {
    const { currentSession, sessionId } = this.state;
    return {
      sessionId,
      projectId: currentSession.projectId,
      binId: currentSession.binId,
      clipName: currentSession.clipName,
      device: currentSession.device,
      sourceType: currentSession.sourceType,
      hiresKey: currentSession.hiresKey,
      proxyKey: currentSession.proxyKey,
      startedAt: currentSession.startedAt,
      codecs: currentSession.codecs,
    };
  }
}

export default new CaptureManager();
export { VIDEO_CODECS, AUDIO_CODECS, CONTAINER_FMT, CONTAINER_EXT };


async () => { const r = await fetch('/api/v1/settings/s3', {credentials:'include'}); return JSON.stringify({status: r.status, body: await r.text()}); }


Bash

cd /tmp/wd/wild-dragon && ls services/mam-api/src/routes/ && echo "---" && ls services/capture/src/routes/

List route files


ampp.js
assets.js
auth.js
bins.js
capture.js
cluster.js
groups.js
jobs.js
projects.js
recorders.js
sequences.js
settings.js
system.js
tokens.js
upload.js
users.js
---
capture.js
capture.js.bak
Shell cwd was reset to /home/node


1	import express from 'express';
2	import http from 'http';
3	import net from 'net';
4	import dgram from 'dgram';
5	import pool from '../db/pool.js';
6	import { requireAuth } from '../middleware/auth.js';
7	import { v4 as uuidv4 } from 'uuid';
8
9	const router = express.Router();
10
11	router.use(requireAuth);
12
13	// Base port for on-demand SDI sidecar containers on remote worker nodes.
14	// Device index 0 → 7438, index 1 → 7439, etc.
15	const SIDECAR_PORT_BASE = 7438;
16
17	// Docker API helper function
18	function dockerApi(method, path, body = null) {
19	  return new Promise((resolve, reject) => {
20	    const options = {
21	      socketPath: '/var/run/docker.sock',
22	      path: `/v1.43${path}`,
23	      method,
24	      headers: { 'Content-Type': 'application/json' },
25	    };
26	    const req = http.request(options, (res) => {
27	      let data = '';
28	      res.on('data', chunk => data += chunk);
29	      res.on('end', () => {
30	        try {
31	          resolve({ status: res.statusCode, data: data ? JSON.parse(data) : {} });
32	        } catch {
33	          resolve({ status: res.statusCode, data });
34	        }
35	      });
36	    });
37	    req.on('error', reject);
38	    if (body) req.write(JSON.stringify(body));
39	    req.end();
40	  });
41	}
42
43	// Look up the cluster node for a recorder and decide if it is remote.
44	// Returns { remote: false } when the node is local or unset;
45	// { remote: true, apiUrl, ip } when it is a different host.
46	async function resolveNodeTarget(nodeId) {
47	  if (!nodeId) return { remote: false };
48	  const r = await pool.query(
49	    'SELECT hostname, ip_address, api_url FROM cluster_nodes WHERE id = $1',
50	    [nodeId]
51	  );
52	  if (r.rows.length === 0) return { remote: false };
53	  const node = r.rows[0];
54	  const localHostname = process.env.NODE_HOSTNAME || '';
55	  if (!node.api_url || node.hostname === localHostname) return { remote: false };
56	  return { remote: true, apiUrl: node.api_url, ip: node.ip_address };
57	}
58
59	// Helper function to generate clip name with timestamp
60	function generateClipName(recorderName) {
61	  const now = new Date();
62	  const year = now.getFullYear();
63	  const month = String(now.getMonth() + 1).padStart(2, '0');
64	  const day = String(now.getDate()).padStart(2, '0');
65	  const hours = String(now.getHours()).padStart(2, '0');
66	  const minutes = String(now.getMinutes()).padStart(2, '0');
67	  const seconds = String(now.getSeconds()).padStart(2, '0');
68	  return `${recorderName}_${year}${month}${day}_${hours}${minutes}${seconds}`;
69	}
70
71	/**
72	 * Build Docker PortBindings and ExposedPorts for listener-mode recorders.
73	 */
74	function buildPortConfig(sourceType, sourceConfig) {
75	  const portBindings = {};
76	  const exposedPorts = {};
77
78	  if (sourceConfig && sourceConfig.mode === 'listener') {
79	    if (sourceType === 'srt') {
80	      const port = String(sourceConfig.listen_port || 9000);
81	      const proto = `${port}/udp`;
82	      portBindings[proto] = [{ HostPort: port }];
83	      exposedPorts[proto] = {};
84	    } else if (sourceType === 'rtmp') {
85	      const port = String(sourceConfig.listen_port || 1935);
86	      const proto = `${port}/tcp`;
87	      portBindings[proto] = [{ HostPort: port }];
88	      exposedPorts[proto] = {};
89	    }
90	  }
91
92	  return { portBindings, exposedPorts };
93	}
94
95	// Whitelist of recorder columns the API accepts on POST/PATCH. Keeping it
96	// explicit prevents accidental writes to status / container_id / timestamps.
97	const RECORDER_FIELDS = [
98	  'name', 'source_type', 'source_config',
99	  'recording_codec', 'recording_resolution',
100	  'recording_video_bitrate', 'recording_framerate',
101	  'recording_audio_codec', 'recording_audio_bitrate', 'recording_audio_channels',
102	  'recording_container',
103	  'proxy_enabled', 'proxy_codec', 'proxy_resolution',
104	  'proxy_video_bitrate', 'proxy_framerate',
105	  'proxy_audio_codec', 'proxy_audio_bitrate', 'proxy_audio_channels',
106	  'proxy_container',
107	  'project_id', 'node_id', 'device_index',
108	];
109
110	function pickRecorderFields(body) {
111	  const out = {};
112	  for (const k of RECORDER_FIELDS) {
113	    if (body[k] !== undefined) out[k] = body[k];
114	  }
115	  return out;
116	}
117
118	// GET / - List all recorders
119	router.get('/', async (req, res, next) => {
120	  try {
121	    const result = await pool.query(
122	      'SELECT * FROM recorders ORDER BY created_at DESC'
123	    );
124	    const rows = await Promise.all(result.rows.map(async (r) => {
125	      if (r.status === 'recording' && r.container_id) {
126	        try {
127	          const insp = await dockerApi('GET', `/containers/${r.container_id}/json`);
128	          if (insp.status === 200 && insp.data && insp.data.State) {
129	            r.started_at = insp.data.State.StartedAt;
130	          }
131	        } catch (_) { /* leave started_at undefined */ }
132	        try {
133	          const live = await pool.query(
134	            `SELECT id FROM assets WHERE project_id = $1 AND display_name = $2 AND status = 'live' ORDER BY created_at DESC LIMIT 1`,
135	            [r.project_id, r.current_session_id]
136	          );
137	          if (live.rows.length > 0) r.live_asset_id = live.rows[0].id;
138	        } catch (_) { /* skip */ }
139	      }
140	      return r;
141	    }));
142	    res.json(rows);
143	  } catch (err) {
144	    next(err);
145	  }
146	});
147
148	// POST / - Create a new recorder
149	router.post('/', async (req, res, next) => {
150	  try {
151	    const fields = pickRecorderFields(req.body);
152
153	    if (!fields.name || !fields.source_type) {
154	      return res
155	        .status(400)
156	        .json({ error: 'Name and source_type are required' });
157	    }
158
159	    // Defaults — written on insert so the DB row is always self-contained.
160	    const defaults = {
161	      source_config:           {},
162	      recording_codec:         'prores_hq',
163	      recording_resolution:    'native',
164	      recording_audio_codec:   'pcm_s24le',
165	      recording_audio_channels: 2,
166	      recording_container:     'mov',
167	      proxy_enabled:           true,
168	      proxy_codec:             'h264',
169	      proxy_resolution:        '1920x1080',
170	      proxy_video_bitrate:     '2M',
171	      proxy_audio_codec:       'aac',
172	      proxy_audio_bitrate:     '128k',
173	      proxy_audio_channels:    2,
174	      proxy_container:         'mp4',
175	    };
176	    const row = { id: uuidv4(), status: 'stopped', ...defaults, ...fields };
177
178	    // Build INSERT dynamically so adding columns later means one place to update.
179	    const cols = Object.keys(row);
180	    const placeholders = cols.map((_, i) => `$${i + 1}`).join(', ');
181	    const values = cols.map(k => {
182	      const v = row[k];
183	      if (k === 'source_config') return v && typeof v === 'object' ? v : {};
184	      return v;
185	    });
186	    const result = await pool.query(
187	      `INSERT INTO recorders (${cols.join(', ')}, created_at, updated_at)
188	       VALUES (${placeholders}, NOW(), NOW())
189	       RETURNING *`,
190	      values
191	    );
192
193	    res.status(201).json(result.rows[0]);
194	  } catch (err) {
195	    next(err);
196	  }
197	});
198
199	// GET /:id - Get single recorder
200	router.get('/:id', async (req, res, next) => {
201	  try {
202	    const { id } = req.params;
203
204	    const result = await pool.query(
205	      'SELECT * FROM recorders WHERE id = $1',
206	      [id]
207	    );
208
209	    if (result.rows.length === 0) {
210	      return res.status(404).json({ error: 'Recorder not found' });
211	    }
212
213	    res.json(result.rows[0]);
214	  } catch (err) {
215	    next(err);
216	  }
217	});
218
219	// PATCH /:id - Edit recorder settings
220	// Blocked while recorder is actively recording to prevent config drift.
221	router.patch('/:id', async (req, res, next) => {
222	  try {
223	    const { id } = req.params;
224
225	    const recorderResult = await pool.query(
226	      'SELECT * FROM recorders WHERE id = $1',
227	      [id]
228	    );
229	    if (recorderResult.rows.length === 0) {
230	      return res.status(404).json({ error: 'Recorder not found' });
231	    }
232
233	    const recorder = recorderResult.rows[0];
234	    if (recorder.status === 'recording') {
235	      return res.status(409).json({ error: 'Cannot edit a recorder while it is recording — stop it first' });
236	    }
237
238	    const fields = pickRecorderFields(req.body);
239	    const cols = Object.keys(fields);
240	    if (cols.length === 0) {
241	      return res.status(400).json({ error: 'No fields to update' });
242	    }
243
244	    const setClause = cols.map((k, i) => `${k} = $${i + 1}`).join(', ');
245	    const params = cols.map(k => fields[k]);
246	    params.push(id);
247
248	    const result = await pool.query(
249	      `UPDATE recorders SET ${setClause}, updated_at = NOW() WHERE id = $${params.length} RETURNING *`,
250	      params
251	    );
252
253	    res.json(result.rows[0]);
254	  } catch (err) {
255	    next(err);
256	  }
257	});
258
259	// POST /:id/start - Start recording
260	router.post('/:id/start', async (req, res, next) => {
261	  try {
262	    const { id } = req.params;
263
264	    const recorderResult = await pool.query(
265	      'SELECT * FROM recorders WHERE id = $1',
266	      [id]
267	    );
268
269	    if (recorderResult.rows.length === 0) {
270	      return res.status(404).json({ error: 'Recorder not found' });
271	    }
272
273	    const recorder = recorderResult.rows[0];
274
275	    if (recorder.status === 'recording') {
276	      return res.status(400).json({ error: 'Recorder is already recording' });
277	    }
278
279	    const s3Endpoint = process.env.S3_ENDPOINT;
280	    const s3Bucket = process.env.S3_BUCKET;
281	    const s3AccessKey = process.env.S3_ACCESS_KEY;
282	    const s3SecretKey = process.env.S3_SECRET_KEY;
283	    const mamApiUrl = process.env.MAM_API_URL || 'http://mam-api:3000';
284	    const dockerNetwork = process.env.DOCKER_NETWORK || 'wild-dragon_wild-dragon';
285
286	    const clipName = generateClipName(recorder.name);
287
288	    // live-asset: create the asset row right now (status='live') so the
289	    // library shows the recording while it is happening.
290	    const assetIdLive = uuidv4();
291	    try {
292	      const ext = recorder.recording_container || 'mov';
293	      await pool.query(
294	        `INSERT INTO assets (
295	          id, project_id, bin_id, filename, display_name, status, media_type,
296	          original_s3_key, created_at, updated_at
297	        ) VALUES ($1, $2, NULL, $3, $3, 'live', 'video', $4, NOW(), NOW())`,
298	        [assetIdLive, recorder.project_id, clipName, `projects/${recorder.project_id}/masters/${clipName}.${ext}`]
299	      );
300	    } catch (e) {
301	      console.warn('[recorders] could not pre-create live asset:', e.message);
302	    }
303
304	    const sourceConfig = recorder.source_config || {};
305	    const isListener = sourceConfig.mode === 'listener';
306	    const sourceType = recorder.source_type;
307	    const deviceIndex = recorder.device_index ?? sourceConfig.device ?? 0;
308
309	    // Build container env — all codec controls flow through here.
310	    const env = [
311	      `S3_ENDPOINT=${s3Endpoint}`,
312	      `S3_BUCKET=${s3Bucket}`,
313	      `S3_ACCESS_KEY=${s3AccessKey}`,
314	      `S3_SECRET_KEY=${s3SecretKey}`,
315	      `S3_REGION=${process.env.S3_REGION || 'us-east-1'}`,
316	      `MAM_API_URL=${mamApiUrl}`,
317	      `RECORDER_ID=${id}`,
318	      `SOURCE_TYPE=${sourceType}`,
319	      `SOURCE_CONFIG=${JSON.stringify(sourceConfig)}`,
320	      `DEVICE_INDEX=${deviceIndex}`,
321
322	      // Recording codec controls
323	      `RECORDING_CODEC=${recorder.recording_codec || 'prores_hq'}`,
324	      `RECORDING_RESOLUTION=${recorder.recording_resolution || 'native'}`,
325	      `RECORDING_VIDEO_BITRATE=${recorder.recording_video_bitrate || ''}`,
326	      `RECORDING_FRAMERATE=${recorder.recording_framerate || ''}`,
327	      `RECORDING_AUDIO_CODEC=${recorder.recording_audio_codec || 'pcm_s24le'}`,
328	      `RECORDING_AUDIO_BITRATE=${recorder.recording_audio_bitrate || ''}`,
329	      `RECORDING_AUDIO_CHANNELS=${recorder.recording_audio_channels ?? 2}`,
330	      `RECORDING_CONTAINER=${recorder.recording_container || 'mov'}`,
331
332	      // Proxy codec controls
333	      `PROXY_ENABLED=${recorder.proxy_enabled !== false ? 'true' : 'false'}`,
334	      `PROXY_CODEC=${recorder.proxy_codec || 'h264'}`,
335	      `PROXY_RESOLUTION=${recorder.proxy_resolution || '1920x1080'}`,
336	      `PROXY_VIDEO_BITRATE=${recorder.proxy_video_bitrate || '2M'}`,
337	      `PROXY_FRAMERATE=${recorder.proxy_framerate || ''}`,
338	      `PROXY_AUDIO_CODEC=${recorder.proxy_audio_codec || 'aac'}`,
339	      `PROXY_AUDIO_BITRATE=${recorder.proxy_audio_bitrate || '128k'}`,
340	      `PROXY_AUDIO_CHANNELS=${recorder.proxy_audio_channels ?? 2}`,
341	      `PROXY_CONTAINER=${recorder.proxy_container || 'mp4'}`,
342
343	      `PROJECT_ID=${recorder.project_id}`,
344	      `CLIP_NAME=${clipName}`,
345	      `ASSET_ID=${assetIdLive}`,
346	    ];
347
348	    if (sourceType === 'srt' || sourceType === 'rtmp') {
349	      env.push(`LISTEN=${isListener ? '1' : '0'}`);
350	      if (isListener) {
351	        env.push(`LISTEN_PORT=${sourceConfig.listen_port || (sourceType === 'srt' ? 9000 : 1935)}`);
352	        if (sourceType === 'rtmp' && sourceConfig.stream_key) {
353	          env.push(`STREAM_KEY=${sourceConfig.stream_key}`);
354	        }
355	      } else if (sourceConfig.url) {
356	        env.push(`SOURCE_URL=${sourceConfig.url}`);
357	      }
358	    }
359
360	    // Determine whether to spawn locally or via a remote node-agent.
361	    const { remote: isRemote, apiUrl: targetNodeApiUrl } = await resolveNodeTarget(recorder.node_id);
362
363	    let containerId;
364
365	    if (isRemote) {
366	      // Remote node: delegate container lifecycle to that node's agent.
367	      const capturePort = SIDECAR_PORT_BASE + (deviceIndex || 0);
368	      const sidecarRes = await fetch(`${targetNodeApiUrl}/sidecar/start`, {
369	        method: 'POST',
370	        headers: { 'Content-Type': 'application/json' },
371	        body: JSON.stringify({ image: 'wild-dragon-capture:latest', env, capturePort, sourceType }),
372	        signal: AbortSignal.timeout(15000),
373	      });
374	      if (!sidecarRes.ok) {
375	        const details = await sidecarRes.json().catch(() => ({}));
376	        return res.status(502).json({ error: 'Remote node failed to start sidecar', details });
377	      }
378	      const sidecarData = await sidecarRes.json();
379	      containerId = sidecarData.containerId;
380	    } else {
381	      // Local spawn via Docker socket.
382	      const { portBindings, exposedPorts } = buildPortConfig(sourceType, sourceConfig);
383	      const alias = `recorder-${id}`;
384
385	      const hostBinds = ['/mnt/NVME/MAM/wild-dragon-live:/live'];
386	      if (sourceType === 'sdi') hostBinds.push('/dev/blackmagic:/dev/blackmagic');
387
388	      const containerConfig = {
389	        Image: 'wild-dragon-capture:latest',
390	        Env: env,
391	        ExposedPorts: Object.keys(exposedPorts).length > 0 ? exposedPorts : undefined,
392	        HostConfig: {
393	          Privileged: true,
394	          NetworkMode: dockerNetwork,
395	          PortBindings: Object.keys(portBindings).length > 0 ? portBindings : undefined,
396	          Binds: hostBinds,
397	        },
398	        NetworkingConfig: {
399	          EndpointsConfig: {
400	            [dockerNetwork]: { Aliases: [alias] },
401	          },
402	        },
403	        Hostname: alias,
404	      };
405
406	      const createRes = await dockerApi('POST', '/containers/create', containerConfig);
407	      if (createRes.status !== 201) {
408	        return res.status(500).json({
409	          error: 'Failed to create container',
410	          details: createRes.data,
411	        });
412	      }
413
414	      containerId = createRes.data.Id;
415	      const startRes = await dockerApi('POST', `/containers/${containerId}/start`);
416	      if (startRes.status !== 204) {
417	        return res.status(500).json({
418	          error: 'Failed to start container',
419	          details: startRes.data,
420	        });
421	      }
422	    }
423
424	    const updateResult = await pool.query(
425	      `UPDATE recorders
426	       SET container_id = $1, status = $2, current_session_id = $3, updated_at = NOW()
427	       WHERE id = $4
428	       RETURNING *`,
429	      [containerId, 'recording', clipName, id]
430	    );
431
432	    res.json(updateResult.rows[0]);
433	  } catch (err) {
434	    next(err);
435	  }
436	});
437
438	// POST /:id/stop - Stop recording
439	router.post('/:id/stop', async (req, res, next) => {
440	  try {
441	    const { id } = req.params;
442
443	    const recorderResult = await pool.query(
444	      'SELECT * FROM recorders WHERE id = $1',
445	      [id]
446	    );
447
448	    if (recorderResult.rows.length === 0) {
449	      return res.status(404).json({ error: 'Recorder not found' });
450	    }
451
452	    const recorder = recorderResult.rows[0];
453
454	    if (!recorder.container_id) {
455	      // No container tracked — reset stuck status gracefully.
456	      const result = await pool.query(
457	        `UPDATE recorders SET container_id = NULL, status = 'stopped', updated_at = NOW()
458	         WHERE id = $1 RETURNING *`,
459	        [id]
460	      );
461	      return res.json(result.rows[0]);
462	    }
463
464	    const { remote: isRemote, apiUrl: targetNodeApiUrl } = await resolveNodeTarget(recorder.node_id);
465
466	    if (isRemote) {
467	      const stopRes = await fetch(`${targetNodeApiUrl}/sidecar/${recorder.container_id}`, {
468	        method: 'DELETE',
469	        signal: AbortSignal.timeout(15000),
470	      });
471	      if (!stopRes.ok && stopRes.status !== 404) {
472	        return res.status(502).json({ error: 'Remote node failed to stop sidecar' });
473	      }
474	    } else {
475	      const stopRes = await dockerApi(
476	        'POST',
477	        `/containers/${recorder.container_id}/stop`
478	      );
479
480	      // 204 = stopped, 304 = already stopped, 404 = container gone — all acceptable.
481	      if (stopRes.status !== 204 && stopRes.status !== 304 && stopRes.status !== 404) {
482	        return res.status(500).json({
483	          error: 'Failed to stop container',
484	          details: stopRes.data,
485	        });
486	      }
487
488	      // Only attempt remove if the container existed (not 404).
489	      if (stopRes.status !== 404) {
490	        const removeRes = await dockerApi(
491	          'DELETE',
492	          `/containers/${recorder.container_id}`
493	        );
494
495	        if (removeRes.status !== 204 && removeRes.status !== 404) {
496	          return res.status(500).json({
497	            error: 'Failed to remove container',
498	            details: removeRes.data,
499	          });
500	        }
501	      }
502	    }
503
504	    const updateResult = await pool.query(
505	      `UPDATE recorders
506	       SET container_id = NULL, status = $1, updated_at = NOW()
507	       WHERE id = $2
508	       RETURNING *`,
509	      ['stopped', id]
510	    );
511
512	    res.json(updateResult.rows[0]);
513	  } catch (err) {
514	    next(err);
515	  }
516	});
517
518	// GET /:id/status - Get live status
519	router.get('/:id/status', async (req, res, next) => {
520	  try {
521	    const { id } = req.params;
522
523	    const recorderResult = await pool.query(
524	      'SELECT * FROM recorders WHERE id = $1',
525	      [id]
526	    );
527
528	    if (recorderResult.rows.length === 0) {
529	      return res.status(404).json({ error: 'Recorder not found' });
530	    }
531
532	    const recorder = recorderResult.rows[0];
533
534	    if (!recorder.container_id) {
535	      return res.json({
536	        status: recorder.status,
537	        duration: 0,
538	        containerId: null,
539	      });
540	    }
541
542	    const deviceIndex = recorder.device_index ?? (recorder.source_config?.device ?? 0);
543	    const { remote: isRemote, apiUrl: targetNodeApiUrl } = await resolveNodeTarget(recorder.node_id);
544
545	    let isRunning = false;
546	    let duration = 0;
547	    let signal = 'connecting';
548	    let signalKnown = false;
549	    let live = null;
550
551	    if (isRemote) {
552	      try {
553	        const statusRes = await fetch(`${targetNodeApiUrl}/sidecar/${recorder.container_id}/status`, {
554	          signal: AbortSignal.timeout(4000),
555	        });
556	        if (statusRes.ok) {
557	          const data = await statusRes.json();
558	          isRunning = data.running;
559	          if (data.startedAt) {
560	            duration = Math.floor((Date.now() - new Date(data.startedAt).getTime()) / 1000);
561	          }
562	          live = data.live;
563	        }
564	      } catch (_) { /* node unreachable */ }
565	    } else {
566	      const inspectRes = await dockerApi(
567	        'GET',
568	        `/containers/${recorder.container_id}/json`
569	      );
570
571	      if (inspectRes.status !== 200) {
572	        return res.json({
573	          status: 'unknown',
574	          duration: 0,
575	          containerId: recorder.container_id,
576	        });
577	      }
578
579	      const container = inspectRes.data;
580	      isRunning = container.State.Running;
581	      duration = Math.floor((Date.now() - new Date(container.State.StartedAt).getTime()) / 1000);
582
583	      try {
584	        const captureRes = await fetch(`http://recorder-${id}:3001/capture/status`, { signal: AbortSignal.timeout(2000) });
585	        if (captureRes.ok) live = await captureRes.json();
586	      } catch (_) { /* not ready yet */ }
587	    }
588
589	    if (isRunning) signal = 'receiving';
590	    if (!isRunning) signal = 'stopped';
591	    if (live && live.signal) { signal = live.signal; signalKnown = true; }
592
593	    res.json({
594	      status: isRunning ? 'recording' : 'stopped',
595	      duration,
596	      containerId: recorder.container_id,
597	      signal,
598	      signalKnown,
599	      framesReceived: live ? live.framesReceived : null,
600	      currentFps:     live ? live.currentFps     : null,
601	      lastFrameAt:    live ? live.lastFrameAt    : null,
602	      lastError:      live ? live.lastError      : null,
603	    });
604	  } catch (err) {
605	    next(err);
606	  }
607	});
608
609	// DELETE /:id - Delete recorder
610	router.delete('/:id', async (req, res, next) => {
611	  try {
612	    const { id } = req.params;
613
614	    const recorderResult = await pool.query(
615	      'SELECT * FROM recorders WHERE id = $1',
616	      [id]
617	    );
618
619	    if (recorderResult.rows.length === 0) {
620	      return res.status(404).json({ error: 'Recorder not found' });
621	    }
622
623	    const recorder = recorderResult.rows[0];
624
625	    if (recorder.container_id) {
626	      const { remote: isRemote, apiUrl: targetNodeApiUrl } = await resolveNodeTarget(recorder.node_id);
627	      try {
628	        if (isRemote) {
629	          await fetch(`${targetNodeApiUrl}/sidecar/${recorder.container_id}`, {
630	            method: 'DELETE',
631	            signal: AbortSignal.timeout(10000),
632	          });
633	        } else {
634	          await dockerApi('POST', `/containers/${recorder.container_id}/stop`);
635	          await dockerApi('DELETE', `/containers/${recorder.container_id}`);
636	        }
637	      } catch (err) {
638	        console.error('Error stopping container during delete:', err);
639	      }
640	    }
641
642	    const deleteResult = await pool.query(
643	      'DELETE FROM recorders WHERE id = $1 RETURNING *',
644	      [id]
645	    );
646
647	    res.json({ message: 'Recorder deleted', recorder: deleteResult.rows[0] });
648	  } catch (err) {
649	    next(err);
650	  }
651	});
652
653	// POST /probe - Probe a source URL for reachability.
654	// Tries the capture service first; falls back to basic TCP/UDP connectivity
655	// check when capture is not running.
656	router.post('/probe', async (req, res) => {
657	  const { source_type, url } = req.body || {};
658
659	  // Try the capture service first (5s timeout)
660	  try {
661	    const r = await fetch('http://capture:3001/capture/probe', {
662	      method: 'POST',
663	      headers: { 'Content-Type': 'application/json' },
664	      body: JSON.stringify(req.body || {}),
665	      signal: AbortSignal.timeout(5000),
666	    });
667	    const data = await r.json().catch(() => ({}));
668	    return res.status(r.status).json(data);
669	  } catch (_) {
670	    // capture service not running — fall through to basic connectivity probe
671	  }
672
673	  if (!url) {
674	    return res.json({
675	      reachable: false,
676	      mode: 'basic',
677	      note: 'Capture service offline. Provide a URL for connectivity check.',
678	    });
679	  }
680
681	  let parsed;
682	  try { parsed = new URL(url); } catch {
683	    return res.status(400).json({ error: 'Invalid URL' });
684	  }
685
686	  const host = parsed.hostname;
687	  const proto = (parsed.protocol || '').replace(':', '').toLowerCase();
688	  const isUdp = proto === 'srt' || source_type === 'srt';
689	  const port = parseInt(parsed.port, 10) || (isUdp ? 9000 : 1935);
690
691	  const reachable = await (isUdp ? probeUdp(host, port) : probeTcp(host, port));
692
693	  return res.json({
694	    reachable,
695	    mode: 'basic',
696	    note: `Capture service offline · ${isUdp ? 'UDP' : 'TCP'} connectivity check only`,
697	    ...(reachable
698	      ? { source: `${host}:${port}` }
699	      : { error: `${host}:${port} did not respond` }
700	    ),
701	  });
702	});
703
704	function probeTcp(host, port) {
705	  return new Promise((resolve) => {
706	    const sock = new net.Socket();
707	    let done = false;
708	    const finish = (ok) => { if (!done) { done = true; sock.destroy(); resolve(ok); } };
709	    sock.setTimeout(4000);
710	    sock.connect(port, host, () => finish(true));
711	    sock.on('error', () => finish(false));
712	    sock.on('timeout', () => finish(false));
713	  });
714	}
715
716	function probeUdp(host, port) {
717	  return new Promise((resolve) => {
718	    const sock = dgram.createSocket('udp4');
719	    let done = false;
720	    const finish = (ok) => {
721	      if (done) return;
722	      done = true;
723	      try { sock.close(); } catch (_) {}
724	      resolve(ok);
725	    };
726	    // ICMP port-unreachable will fire sock.on('error') within ~100ms if nothing is listening
727	    sock.on('error', () => finish(false));
728	    sock.send(Buffer.alloc(16, 0), 0, 16, port, host, (err) => {
729	      if (err) return finish(false);
730	      // No ICMP error after 2.5s → assume something is listening
731	      setTimeout(() => finish(true), 2500);
732	    });
733	    setTimeout(() => finish(false), 5000);
734	  });
735	}
736
737	export default router;
738

import express from 'express';
import http from 'http';
import net from 'net';
import dgram from 'dgram';
import pool from '../db/pool.js';
import { requireAuth } from '../middleware/auth.js';
import { v4 as uuidv4 } from 'uuid';

const router = express.Router();

router.use(requireAuth);

// Base port for on-demand SDI sidecar containers on remote worker nodes.
// Device index 0 → 7438, index 1 → 7439, etc.
const SIDECAR_PORT_BASE = 7438;

// Docker API helper function
function dockerApi(method, path, body = null) {
  return new Promise((resolve, reject) => {
    const options = {
      socketPath: '/var/run/docker.sock',
      path: `/v1.43${path}`,
      method,
      headers: { 'Content-Type': 'application/json' },
    };
    const req = http.request(options, (res) => {
      let data = '';
      res.on('data', chunk => data += chunk);
      res.on('end', () => {
        try {
          resolve({ status: res.statusCode, data: data ? JSON.parse(data) : {} });
        } catch {
          resolve({ status: res.statusCode, data });
        }
      });
    });
    req.on('error', reject);
    if (body) req.write(JSON.stringify(body));
    req.end();
  });
}

// Look up the cluster node for a recorder and decide if it is remote.
// Returns { remote: false } when the node is local or unset;
// { remote: true, apiUrl, ip } when it is a different host.
async function resolveNodeTarget(nodeId) {
  if (!nodeId) return { remote: false };
  const r = await pool.query(
    'SELECT hostname, ip_address, api_url FROM cluster_nodes WHERE id = $1',
    [nodeId]
  );
  if (r.rows.length === 0) return { remote: false };
  const node = r.rows[0];
  const localHostname = process.env.NODE_HOSTNAME || '';
  if (!node.api_url || node.hostname === localHostname) return { remote: false };
  return { remote: true, apiUrl: node.api_url, ip: node.ip_address };
}

// Helper function to generate clip name with timestamp
function generateClipName(recorderName) {
  const now = new Date();
  const year = now.getFullYear();
  const month = String(now.getMonth() + 1).padStart(2, '0');
  const day = String(now.getDate()).padStart(2, '0');
  const hours = String(now.getHours()).padStart(2, '0');
  const minutes = String(now.getMinutes()).padStart(2, '0');
  const seconds = String(now.getSeconds()).padStart(2, '0');
  return `${recorderName}_${year}${month}${day}_${hours}${minutes}${seconds}`;
}

/**
 * Build Docker PortBindings and ExposedPorts for listener-mode recorders.
 */
function buildPortConfig(sourceType, sourceConfig) {
  const portBindings = {};
  const exposedPorts = {};

  if (sourceConfig && sourceConfig.mode === 'listener') {
    if (sourceType === 'srt') {
      const port = String(sourceConfig.listen_port || 9000);
      const proto = `${port}/udp`;
      portBindings[proto] = [{ HostPort: port }];
      exposedPorts[proto] = {};
    } else if (sourceType === 'rtmp') {
      const port = String(sourceConfig.listen_port || 1935);
      const proto = `${port}/tcp`;
      portBindings[proto] = [{ HostPort: port }];
      exposedPorts[proto] = {};
    }
  }

  return { portBindings, exposedPorts };
}

// Whitelist of recorder columns the API accepts on POST/PATCH. Keeping it
// explicit prevents accidental writes to status / container_id / timestamps.
const RECORDER_FIELDS = [
  'name', 'source_type', 'source_config',
  'recording_codec', 'recording_resolution',
  'recording_video_bitrate', 'recording_framerate',
  'recording_audio_codec', 'recording_audio_bitrate', 'recording_audio_channels',
  'recording_container',
  'proxy_enabled', 'proxy_codec', 'proxy_resolution',
  'proxy_video_bitrate', 'proxy_framerate',
  'proxy_audio_codec', 'proxy_audio_bitrate', 'proxy_audio_channels',
  'proxy_container',
  'project_id', 'node_id', 'device_index',
];

function pickRecorderFields(body) {
  const out = {};
  for (const k of RECORDER_FIELDS) {
    if (body[k] !== undefined) out[k] = body[k];
  }
  return out;
}

// GET / - List all recorders
router.get('/', async (req, res, next) => {
  try {
    const result = await pool.query(
      'SELECT * FROM recorders ORDER BY created_at DESC'
    );
    const rows = await Promise.all(result.rows.map(async (r) => {
      if (r.status === 'recording' && r.container_id) {
        try {
          const insp = await dockerApi('GET', `/containers/${r.container_id}/json`);
          if (insp.status === 200 && insp.data && insp.data.State) {
            r.started_at = insp.data.State.StartedAt;
          }
        } catch (_) { /* leave started_at undefined */ }
        try {
          const live = await pool.query(
            `SELECT id FROM assets WHERE project_id = $1 AND display_name = $2 AND status = 'live' ORDER BY created_at DESC LIMIT 1`,
            [r.project_id, r.current_session_id]
          );
          if (live.rows.length > 0) r.live_asset_id = live.rows[0].id;
        } catch (_) { /* skip */ }
      }
      return r;
    }));
    res.json(rows);
  } catch (err) {
    next(err);
  }
});

// POST / - Create a new recorder
router.post('/', async (req, res, next) => {
  try {
    const fields = pickRecorderFields(req.body);

    if (!fields.name || !fields.source_type) {
      return res
        .status(400)
        .json({ error: 'Name and source_type are required' });
    }

    // Defaults — written on insert so the DB row is always self-contained.
    const defaults = {
      source_config:           {},
      recording_codec:         'prores_hq',
      recording_resolution:    'native',
      recording_audio_codec:   'pcm_s24le',
      recording_audio_channels: 2,
      recording_container:     'mov',
      proxy_enabled:           true,
      proxy_codec:             'h264',
      proxy_resolution:        '1920x1080',
      proxy_video_bitrate:     '2M',
      proxy_audio_codec:       'aac',
      proxy_audio_bitrate:     '128k',
      proxy_audio_channels:    2,
      proxy_container:         'mp4',
    };
    const row = { id: uuidv4(), status: 'stopped', ...defaults, ...fields };

    // Build INSERT dynamically so adding columns later means one place to update.
    const cols = Object.keys(row);
    const placeholders = cols.map((_, i) => `$${i + 1}`).join(', ');
    const values = cols.map(k => {
      const v = row[k];
      if (k === 'source_config') return v && typeof v === 'object' ? v : {};
      return v;
    });
    const result = await pool.query(
      `INSERT INTO recorders (${cols.join(', ')}, created_at, updated_at)
       VALUES (${placeholders}, NOW(), NOW())
       RETURNING *`,
      values
    );

    res.status(201).json(result.rows[0]);
  } catch (err) {
    next(err);
  }
});

// GET /:id - Get single recorder
router.get('/:id', async (req, res, next) => {
  try {
    const { id } = req.params;

    const result = await pool.query(
      'SELECT * FROM recorders WHERE id = $1',
      [id]
    );

    if (result.rows.length === 0) {
      return res.status(404).json({ error: 'Recorder not found' });
    }

    res.json(result.rows[0]);
  } catch (err) {
    next(err);
  }
});

// PATCH /:id - Edit recorder settings
// Blocked while recorder is actively recording to prevent config drift.
router.patch('/:id', async (req, res, next) => {
  try {
    const { id } = req.params;

    const recorderResult = await pool.query(
      'SELECT * FROM recorders WHERE id = $1',
      [id]
    );
    if (recorderResult.rows.length === 0) {
      return res.status(404).json({ error: 'Recorder not found' });
    }

    const recorder = recorderResult.rows[0];
    if (recorder.status === 'recording') {
      return res.status(409).json({ error: 'Cannot edit a recorder while it is recording — stop it first' });
    }

    const fields = pickRecorderFields(req.body);
    const cols = Object.keys(fields);
    if (cols.length === 0) {
      return res.status(400).json({ error: 'No fields to update' });
    }

    const setClause = cols.map((k, i) => `${k} = $${i + 1}`).join(', ');
    const params = cols.map(k => fields[k]);
    params.push(id);

    const result = await pool.query(
      `UPDATE recorders SET ${setClause}, updated_at = NOW() WHERE id = $${params.length} RETURNING *`,
      params
    );

    res.json(result.rows[0]);
  } catch (err) {
    next(err);
  }
});

// POST /:id/start - Start recording
router.post('/:id/start', async (req, res, next) => {
  try {
    const { id } = req.params;

    const recorderResult = await pool.query(
      'SELECT * FROM recorders WHERE id = $1',
      [id]
    );

    if (recorderResult.rows.length === 0) {
      return res.status(404).json({ error: 'Recorder not found' });
    }

    const recorder = recorderResult.rows[0];

    if (recorder.status === 'recording') {
      return res.status(400).json({ error: 'Recorder is already recording' });
    }

    const s3Endpoint = process.env.S3_ENDPOINT;
    const s3Bucket = process.env.S3_BUCKET;
    const s3AccessKey = process.env.S3_ACCESS_KEY;
    const s3SecretKey = process.env.S3_SECRET_KEY;
    const mamApiUrl = process.env.MAM_API_URL || 'http://mam-api:3000';
    const dockerNetwork = process.env.DOCKER_NETWORK || 'wild-dragon_wild-dragon';

    const clipName = generateClipName(recorder.name);

    // live-asset: create the asset row right now (status='live') so the
    // library shows the recording while it is happening.
    const assetIdLive = uuidv4();
    try {
      const ext = recorder.recording_container || 'mov';
      await pool.query(
        `INSERT INTO assets (
          id, project_id, bin_id, filename, display_name, status, media_type,
          original_s3_key, created_at, updated_at
        ) VALUES ($1, $2, NULL, $3, $3, 'live', 'video', $4, NOW(), NOW())`,
        [assetIdLive, recorder.project_id, clipName, `projects/${recorder.project_id}/masters/${clipName}.${ext}`]
      );
    } catch (e) {
      console.warn('[recorders] could not pre-create live asset:', e.message);
    }

    const sourceConfig = recorder.source_config || {};
    const isListener = sourceConfig.mode === 'listener';
    const sourceType = recorder.source_type;
    const deviceIndex = recorder.device_index ?? sourceConfig.device ?? 0;

    // Build container env — all codec controls flow through here.
    const env = [
      `S3_ENDPOINT=${s3Endpoint}`,
      `S3_BUCKET=${s3Bucket}`,
      `S3_ACCESS_KEY=${s3AccessKey}`,
      `S3_SECRET_KEY=${s3SecretKey}`,
      `S3_REGION=${process.env.S3_REGION || 'us-east-1'}`,
      `MAM_API_URL=${mamApiUrl}`,
      `RECORDER_ID=${id}`,
      `SOURCE_TYPE=${sourceType}`,
      `SOURCE_CONFIG=${JSON.stringify(sourceConfig)}`,
      `DEVICE_INDEX=${deviceIndex}`,

      // Recording codec controls
      `RECORDING_CODEC=${recorder.recording_codec || 'prores_hq'}`,
      `RECORDING_RESOLUTION=${recorder.recording_resolution || 'native'}`,
      `RECORDING_VIDEO_BITRATE=${recorder.recording_video_bitrate || ''}`,
      `RECORDING_FRAMERATE=${recorder.recording_framerate || ''}`,
      `RECORDING_AUDIO_CODEC=${recorder.recording_audio_codec || 'pcm_s24le'}`,
      `RECORDING_AUDIO_BITRATE=${recorder.recording_audio_bitrate || ''}`,
      `RECORDING_AUDIO_CHANNELS=${recorder.recording_audio_channels ?? 2}`,
      `RECORDING_CONTAINER=${recorder.recording_container || 'mov'}`,

      // Proxy codec controls
      `PROXY_ENABLED=${recorder.proxy_enabled !== false ? 'true' : 'false'}`,
      `PROXY_CODEC=${recorder.proxy_codec || 'h264'}`,
      `PROXY_RESOLUTION=${recorder.proxy_resolution || '1920x1080'}`,
      `PROXY_VIDEO_BITRATE=${recorder.proxy_video_bitrate || '2M'}`,
      `PROXY_FRAMERATE=${recorder.proxy_framerate || ''}`,
      `PROXY_AUDIO_CODEC=${recorder.proxy_audio_codec || 'aac'}`,
      `PROXY_AUDIO_BITRATE=${recorder.proxy_audio_bitrate || '128k'}`,
      `PROXY_AUDIO_CHANNELS=${recorder.proxy_audio_channels ?? 2}`,
      `PROXY_CONTAINER=${recorder.proxy_container || 'mp4'}`,

      `PROJECT_ID=${recorder.project_id}`,
      `CLIP_NAME=${clipName}`,
      `ASSET_ID=${assetIdLive}`,
    ];

    if (sourceType === 'srt' || sourceType === 'rtmp') {
      env.push(`LISTEN=${isListener ? '1' : '0'}`);
      if (isListener) {
        env.push(`LISTEN_PORT=${sourceConfig.listen_port || (sourceType === 'srt' ? 9000 : 1935)}`);
        if (sourceType === 'rtmp' && sourceConfig.stream_key) {
          env.push(`STREAM_KEY=${sourceConfig.stream_key}`);
        }
      } else if (sourceConfig.url) {
        env.push(`SOURCE_URL=${sourceConfig.url}`);
      }
    }

    // Determine whether to spawn locally or via a remote node-agent.
    const { remote: isRemote, apiUrl: targetNodeApiUrl } = await resolveNodeTarget(recorder.node_id);

    let containerId;

    if (isRemote) {
      // Remote node: delegate container lifecycle to that node's agent.
      const capturePort = SIDECAR_PORT_BASE + (deviceIndex || 0);
      const sidecarRes = await fetch(`${targetNodeApiUrl}/sidecar/start`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ image: 'wild-dragon-capture:latest', env, capturePort, sourceType }),
        signal: AbortSignal.timeout(15000),
      });
      if (!sidecarRes.ok) {
        const details = await sidecarRes.json().catch(() => ({}));
        return res.status(502).json({ error: 'Remote node failed to start sidecar', details });
      }
      const sidecarData = await sidecarRes.json();
      containerId = sidecarData.containerId;
    } else {
      // Local spawn via Docker socket.
      const { portBindings, exposedPorts } = buildPortConfig(sourceType, sourceConfig);
      const alias = `recorder-${id}`;

      const hostBinds = ['/mnt/NVME/MAM/wild-dragon-live:/live'];
      if (sourceType === 'sdi') hostBinds.push('/dev/blackmagic:/dev/blackmagic');

      const containerConfig = {
        Image: 'wild-dragon-capture:latest',
        Env: env,
        ExposedPorts: Object.keys(exposedPorts).length > 0 ? exposedPorts : undefined,
        HostConfig: {
          Privileged: true,
          NetworkMode: dockerNetwork,
          PortBindings: Object.keys(portBindings).length > 0 ? portBindings : undefined,
          Binds: hostBinds,
        },
        NetworkingConfig: {
          EndpointsConfig: {
            [dockerNetwork]: { Aliases: [alias] },
          },
        },
        Hostname: alias,
      };

      const createRes = await dockerApi('POST', '/containers/create', containerConfig);
      if (createRes.status !== 201) {
        return res.status(500).json({
          error: 'Failed to create container',
          details: createRes.data,
        });
      }

      containerId = createRes.data.Id;
      const startRes = await dockerApi('POST', `/containers/${containerId}/start`);
      if (startRes.status !== 204) {
        return res.status(500).json({
          error: 'Failed to start container',
          details: startRes.data,
        });
      }
    }

    const updateResult = await pool.query(
      `UPDATE recorders
       SET container_id = $1, status = $2, current_session_id = $3, updated_at = NOW()
       WHERE id = $4
       RETURNING *`,
      [containerId, 'recording', clipName, id]
    );

    res.json(updateResult.rows[0]);
  } catch (err) {
    next(err);
  }
});

// POST /:id/stop - Stop recording
router.post('/:id/stop', async (req, res, next) => {
  try {
    const { id } = req.params;

    const recorderResult = await pool.query(
      'SELECT * FROM recorders WHERE id = $1',
      [id]
    );

    if (recorderResult.rows.length === 0) {
      return res.status(404).json({ error: 'Recorder not found' });
    }

    const recorder = recorderResult.rows[0];

    if (!recorder.container_id) {
      // No container tracked — reset stuck status gracefully.
      const result = await pool.query(
        `UPDATE recorders SET container_id = NULL, status = 'stopped', updated_at = NOW()
         WHERE id = $1 RETURNING *`,
        [id]
      );
      return res.json(result.rows[0]);
    }

    const { remote: isRemote, apiUrl: targetNodeApiUrl } = await resolveNodeTarget(recorder.node_id);

    if (isRemote) {
      const stopRes = await fetch(`${targetNodeApiUrl}/sidecar/${recorder.container_id}`, {
        method: 'DELETE',
        signal: AbortSignal.timeout(15000),
      });
      if (!stopRes.ok && stopRes.status !== 404) {
        return res.status(502).json({ error: 'Remote node failed to stop sidecar' });
      }
    } else {
      const stopRes = await dockerApi(
        'POST',
        `/containers/${recorder.container_id}/stop`
      );

      // 204 = stopped, 304 = already stopped, 404 = container gone — all acceptable.
      if (stopRes.status !== 204 && stopRes.status !== 304 && stopRes.status !== 404) {
        return res.status(500).json({
          error: 'Failed to stop container',
          details: stopRes.data,
        });
      }

      // Only attempt remove if the container existed (not 404).
      if (stopRes.status !== 404) {
        const removeRes = await dockerApi(
          'DELETE',
          `/containers/${recorder.container_id}`
        );

        if (removeRes.status !== 204 && removeRes.status !== 404) {
          return res.status(500).json({
            error: 'Failed to remove container',
            details: removeRes.data,
          });
        }
      }
    }

    const updateResult = await pool.query(
      `UPDATE recorders
       SET container_id = NULL, status = $1, updated_at = NOW()
       WHERE id = $2
       RETURNING *`,
      ['stopped', id]
    );

    res.json(updateResult.rows[0]);
  } catch (err) {
    next(err);
  }
});

// GET /:id/status - Get live status
router.get('/:id/status', async (req, res, next) => {
  try {
    const { id } = req.params;

    const recorderResult = await pool.query(
      'SELECT * FROM recorders WHERE id = $1',
      [id]
    );

    if (recorderResult.rows.length === 0) {
      return res.status(404).json({ error: 'Recorder not found' });
    }

    const recorder = recorderResult.rows[0];

    if (!recorder.container_id) {
      return res.json({
        status: recorder.status,
        duration: 0,
        containerId: null,
      });
    }

    const deviceIndex = recorder.device_index ?? (recorder.source_config?.device ?? 0);
    const { remote: isRemote, apiUrl: targetNodeApiUrl } = await resolveNodeTarget(recorder.node_id);

    let isRunning = false;
    let duration = 0;
    let signal = 'connecting';
    let signalKnown = false;
    let live = null;

    if (isRemote) {
      try {
        const statusRes = await fetch(`${targetNodeApiUrl}/sidecar/${recorder.container_id}/status`, {
          signal: AbortSignal.timeout(4000),
        });
        if (statusRes.ok) {
          const data = await statusRes.json();
          isRunning = data.running;
          if (data.startedAt) {
            duration = Math.floor((Date.now() - new Date(data.startedAt).getTime()) / 1000);
          }
          live = data.live;
        }
      } catch (_) { /* node unreachable */ }
    } else {
      const inspectRes = await dockerApi(
        'GET',
        `/containers/${recorder.container_id}/json`
      );

      if (inspectRes.status !== 200) {
        return res.json({
          status: 'unknown',
          duration: 0,
          containerId: recorder.container_id,
        });
      }

      const container = inspectRes.data;
      isRunning = container.State.Running;
      duration = Math.floor((Date.now() - new Date(container.State.StartedAt).getTime()) / 1000);

      try {
        const captureRes = await fetch(`http://recorder-${id}:3001/capture/status`, { signal: AbortSignal.timeout(2000) });
        if (captureRes.ok) live = await captureRes.json();
      } catch (_) { /* not ready yet */ }
    }

    if (isRunning) signal = 'receiving';
    if (!isRunning) signal = 'stopped';
    if (live && live.signal) { signal = live.signal; signalKnown = true; }

    res.json({
      status: isRunning ? 'recording' : 'stopped',
      duration,
      containerId: recorder.container_id,
      signal,
      signalKnown,
      framesReceived: live ? live.framesReceived : null,
      currentFps:     live ? live.currentFps     : null,
      lastFrameAt:    live ? live.lastFrameAt    : null,
      lastError:      live ? live.lastError      : null,
    });
  } catch (err) {
    next(err);
  }
});

// DELETE /:id - Delete recorder
router.delete('/:id', async (req, res, next) => {
  try {
    const { id } = req.params;

    const recorderResult = await pool.query(
      'SELECT * FROM recorders WHERE id = $1',
      [id]
    );

    if (recorderResult.rows.length === 0) {
      return res.status(404).json({ error: 'Recorder not found' });
    }

    const recorder = recorderResult.rows[0];

    if (recorder.container_id) {
      const { remote: isRemote, apiUrl: targetNodeApiUrl } = await resolveNodeTarget(recorder.node_id);
      try {
        if (isRemote) {
          await fetch(`${targetNodeApiUrl}/sidecar/${recorder.container_id}`, {
            method: 'DELETE',
            signal: AbortSignal.timeout(10000),
          });
        } else {
          await dockerApi('POST', `/containers/${recorder.container_id}/stop`);
          await dockerApi('DELETE', `/containers/${recorder.container_id}`);
        }
      } catch (err) {
        console.error('Error stopping container during delete:', err);
      }
    }

    const deleteResult = await pool.query(
      'DELETE FROM recorders WHERE id = $1 RETURNING *',
      [id]
    );

    res.json({ message: 'Recorder deleted', recorder: deleteResult.rows[0] });
  } catch (err) {
    next(err);
  }
});

// POST /probe - Probe a source URL for reachability.
// Tries the capture service first; falls back to basic TCP/UDP connectivity
// check when capture is not running.
router.post('/probe', async (req, res) => {
  const { source_type, url } = req.body || {};

  // Try the capture service first (5s timeout)
  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(5000),
    });
    const data = await r.json().catch(() => ({}));
    return res.status(r.status).json(data);
  } catch (_) {
    // capture service not running — fall through to basic connectivity probe
  }

  if (!url) {
    return res.json({
      reachable: false,
      mode: 'basic',
      note: 'Capture service offline. Provide a URL for connectivity check.',
    });
  }

  let parsed;
  try { parsed = new URL(url); } catch {
    return res.status(400).json({ error: 'Invalid URL' });
  }

  const host = parsed.hostname;
  const proto = (parsed.protocol || '').replace(':', '').toLowerCase();
  const isUdp = proto === 'srt' || source_type === 'srt';
  const port = parseInt(parsed.port, 10) || (isUdp ? 9000 : 1935);

  const reachable = await (isUdp ? probeUdp(host, port) : probeTcp(host, port));

  return res.json({
    reachable,
    mode: 'basic',
    note: `Capture service offline · ${isUdp ? 'UDP' : 'TCP'} connectivity check only`,
    ...(reachable
      ? { source: `${host}:${port}` }
      : { error: `${host}:${port} did not respond` }
    ),
  });
});

function probeTcp(host, port) {
  return new Promise((resolve) => {
    const sock = new net.Socket();
    let done = false;
    const finish = (ok) => { if (!done) { done = true; sock.destroy(); resolve(ok); } };
    sock.setTimeout(4000);
    sock.connect(port, host, () => finish(true));
    sock.on('error', () => finish(false));
    sock.on('timeout', () => finish(false));
  });
}

function probeUdp(host, port) {
  return new Promise((resolve) => {
    const sock = dgram.createSocket('udp4');
    let done = false;
    const finish = (ok) => {
      if (done) return;
      done = true;
      try { sock.close(); } catch (_) {}
      resolve(ok);
    };
    // ICMP port-unreachable will fire sock.on('error') within ~100ms if nothing is listening
    sock.on('error', () => finish(false));
    sock.send(Buffer.alloc(16, 0), 0, 16, port, host, (err) => {
      if (err) return finish(false);
      // No ICMP error after 2.5s → assume something is listening
      setTimeout(() => finish(true), 2500);
    });
    setTimeout(() => finish(false), 5000);
  });
}

export default router;


Found 5 files
/tmp/wd/wild-dragon/services/mam-api/src/routes/assets.js
/tmp/wd/wild-dragon/services/mam-api/src/routes/jobs.js
/tmp/wd/wild-dragon/services/mam-api/src/routes/recorders.js
/tmp/wd/wild-dragon/services/mam-api/src/s3/client.js
/tmp/wd/wild-dragon/services/mam-api/src/db/migrations/001-add-live-status.sql