Read these files from the local filesystem and return their full contents:
- /tmp/wd-design/zampp-platform/project/ — actually these aren't here.

Instead, use the Forgejo MCP tools to get these files from repo `zgaetano/wild-dragon` branch `main`:
- services/mam-api/src/routes/assets.js
- services/mam-api/src/routes/projects.js
- services/mam-api/src/routes/recorders.js
- services/mam-api/src/routes/jobs.js
- services/mam-api/src/routes/cluster.js

Return the full content of each file. I need to understand the exact JSON response shapes (field names, structure) for each endpoint so I can wire a React frontend to them.


I'll retrieve these files from the Forgejo repository for you.


File: services/mam-api/src/routes/assets.js

import express from 'express';
import { Queue } from 'bullmq';
import { v4 as uuidv4 } from 'uuid';
import pool from '../db/pool.js';
import { getSignedUrlForObject, deleteObject, s3Client, getS3Bucket } from '../s3/client.js';
import { GetObjectCommand } from '@aws-sdk/client-s3';
import { requireAuth } from '../middleware/auth.js';

const router = express.Router();

router.use(requireAuth);

// BullMQ queue connection (mirrors worker/src/index.js)
const parseRedisUrl = (url) => {
  const parsed = new URL(url);
  return { host: parsed.hostname, port: parseInt(parsed.port, 10) };
};

const proxyQueue = new Queue('proxy', {
  connection: parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'),
});

const thumbnailQueue = new Queue('thumbnail', {
  connection: parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'),
});

// GET / - List assets with filtering
router.get('/', async (req, res, next) => {
  try {
    const {
      project_id,
      bin_id,
      status,
      search,
      media_type,
      limit = 50,
      offset = 0,
      include_archived,
    } = req.query;

    let query = `
      SELECT a.*,
             COUNT(*) OVER() AS full_count
      FROM assets a
      WHERE 1=1
    `;
    const params = [];
    let paramCount = 1;

    if (!status && include_archived !== 'true') {
      query += ` AND a.status <> 'archived'`;
    }

    if (project_id) {
      query += ` AND a.project_id = $${paramCount++}`;
      params.push(project_id);
    }

    if (bin_id) {
      query += ` AND a.bin_id = $${paramCount++}`;
      params.push(bin_id);
    }

    if (status) {
      query += ` AND a.status = $${paramCount++}`;
      params.push(status);
    }

    if (media_type) {
      query += ` AND a.media_type = $${paramCount++}`;
      params.push(media_type);
    }

    if (search) {
      query += ` AND (a.display_name ILIKE $${paramCount} OR a.filename ILIKE $${paramCount} OR a.notes ILIKE $${paramCount})`;
      params.push(`%${search}%`);
      paramCount++;
    }

    query += ` ORDER BY a.created_at DESC`;
    query += ` LIMIT $${paramCount++} OFFSET $${paramCount++}`;
    params.push(limit, offset);

    const result = await pool.query(query, params);
    const total = result.rows.length > 0 ? parseInt(result.rows[0].full_count, 10) : 0;

    res.json({
      assets: result.rows.map(({ full_count, ...rest }) => rest),
      total,
    });
  } catch (err) {
    next(err);
  }
});

// POST / - Register a new asset from a completed capture session
router.post('/', async (req, res, next) => {
  try {
    const {
      projectId,
      binId,
      clipName,
      hiresKey,
      proxyKey,
      duration,
      capturedAt,
    } = req.body;

    if (!projectId || !clipName) {
      return res.status(400).json({ error: 'projectId and clipName are required' });
    }

    const durationMs = duration ? Math.round(duration * 1000) : null;

    const existing = await pool.query(
      `SELECT * FROM assets
         WHERE project_id = $1 AND display_name = $2 AND status = 'live'
         ORDER BY created_at DESC LIMIT 1`,
      [projectId, clipName]
    );

    let id;
    let asset;
    if (existing.rows.length > 0) {
      id = existing.rows[0].id;
      const upd = await pool.query(
        `UPDATE assets
           SET status          = 'processing',
               original_s3_key = COALESCE($2, original_s3_key),
               proxy_s3_key    = COALESCE($3, proxy_s3_key),
               duration_ms     = COALESCE($4, duration_ms),
               bin_id          = COALESCE(bin_id, $5),
               updated_at      = NOW()
         WHERE id = $1
         RETURNING *`,
        [id, hiresKey || null, proxyKey || null, durationMs, binId || null]
      );
      asset = upd.rows[0];
    } else {
      id = uuidv4();
      const ins = await pool.query(
        `INSERT INTO assets (
          id, project_id, bin_id,
          filename, display_name,
          status, media_type,
          original_s3_key, proxy_s3_key,
          duration_ms,
          created_at, updated_at
        )
        VALUES (
          $1, $2, $3,
          $4, $4,
          'processing', 'video',
          $5, $6,
          $7,
          COALESCE($8::timestamptz, NOW()), NOW()
        )
        RETURNING *`,
        [
          id, projectId, binId || null,
          clipName,
          hiresKey || null, proxyKey || null,
          durationMs,
          capturedAt || null,
        ]
      );
      asset = ins.rows[0];
    }

    const thumbnailKey = `thumbnails/${id}.jpg`;

    if (proxyKey) {
      await thumbnailQueue.add('generate', {
        assetId: id,
        proxyKey,
        outputKey: thumbnailKey,
      });
    } else {
      await pool.query(
        `UPDATE assets SET status = 'ready', updated_at = NOW() WHERE id = $1`,
        [id]
      );
      asset.status = 'ready';
    }

    res.status(existing.rows.length > 0 ? 200 : 201).json(asset);
  } catch (err) {
    next(err);
  }
});

// POST /cleanup-live
router.post('/cleanup-live', async (req, res, next) => {
  try {
    const maxAgeHours = Math.max(1, parseInt(req.query.max_age_hours || '4', 10));
    const result = await pool.query(
      `UPDATE assets
         SET status = 'error', updated_at = NOW()
       WHERE status = 'live'
         AND created_at < NOW() - ($1 * INTERVAL '1 hour')
       RETURNING id, display_name, project_id, created_at`,
      [maxAgeHours]
    );
    res.json({ cleaned: result.rowCount, assets: result.rows });
  } catch (err) {
    next(err);
  }
});

// GET /:id
router.get('/:id', async (req, res, next) => {
  try {
    const { id } = req.params;
    const result = await pool.query('SELECT * FROM assets WHERE id = $1', [id]);
    if (result.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
    res.json(result.rows[0]);
  } catch (err) {
    next(err);
  }
});

// PATCH /:id
router.patch('/:id', async (req, res, next) => {
  try {
    const { id } = req.params;
    const { display_name, tags, notes, bin_id } = req.body;

    const updates = [];
    const params = [];
    let paramCount = 1;

    if (display_name !== undefined) { updates.push(`display_name = $${paramCount++}`); params.push(display_name); }
    if (tags        !== undefined) { updates.push(`tags = $${paramCount++}`);         params.push(tags); }
    if (notes       !== undefined) { updates.push(`notes = $${paramCount++}`);        params.push(notes); }
    if (bin_id      !== undefined) { updates.push(`bin_id = $${paramCount++}`);       params.push(bin_id || null); }

    if (updates.length === 0) return res.status(400).json({ error: 'No fields to update' });

    updates.push(`updated_at = NOW()`);
    params.push(id);

    const result = await pool.query(
      `UPDATE assets SET ${updates.join(', ')} WHERE id = $${paramCount} RETURNING *`,
      params
    );
    if (result.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
    res.json(result.rows[0]);
  } catch (err) {
    next(err);
  }
});

// POST /:id/copy
router.post('/:id/copy', async (req, res, next) => {
  try {
    const { id } = req.params;
    const { binId, projectId } = req.body;
    const r = await pool.query('SELECT * FROM assets WHERE id = $1', [id]);
    if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
    const src = r.rows[0];
    const newId = uuidv4();
    const ins = await pool.query(
      `INSERT INTO assets (
        id, project_id, bin_id, filename, display_name,
        status, media_type, original_s3_key, proxy_s3_key, thumbnail_s3_key,
        codec, resolution, fps, duration_ms, start_tc, file_size, tags, notes,
        created_at, updated_at
      ) VALUES (
        $1, $2, $3, $4, $5,
        $6, $7, $8, $9, $10,
        $11, $12, $13, $14, $15, $16, $17, $18,
        NOW(), NOW()
      ) RETURNING *`,
      [
        newId,
        projectId || src.project_id,
        binId === undefined ? src.bin_id : (binId || null),
        src.filename, src.display_name,
        src.status, src.media_type,
        src.original_s3_key, src.proxy_s3_key, src.thumbnail_s3_key,
        src.codec, src.resolution, src.fps, src.duration_ms, src.start_tc,
        src.file_size, src.tags, src.notes,
      ]
    );
    res.status(201).json(ins.rows[0]);
  } catch (err) {
    next(err);
  }
});

// POST /:id/retry
router.post('/:id/retry', async (req, res, next) => {
  try {
    const { id } = req.params;
    const r = await pool.query('SELECT * FROM assets WHERE id = $1', [id]);
    if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
    const asset = r.rows[0];
    if (asset.status !== 'error') return res.status(400).json({ error: `Asset is not in error state (current: ${asset.status})` });
    if (!asset.original_s3_key) return res.status(400).json({ error: 'Asset has no source file to reprocess' });
    const proxyKey = asset.proxy_s3_key || `proxies/${id}.mp4`;
    await proxyQueue.add('generate', { assetId: id, inputKey: asset.original_s3_key, outputKey: proxyKey });
    const updated = await pool.query(
      `UPDATE assets SET status = 'processing', updated_at = NOW() WHERE id = $1 RETURNING *`,
      [id]
    );
    res.json(updated.rows[0]);
  } catch (err) {
    next(err);
  }
});

// DELETE /:id
router.delete('/:id', async (req, res, next) => {
  try {
    const { id } = req.params;
    const { hard } = req.query;
    if (hard === 'true') {
      const assetResult = await pool.query('SELECT * FROM assets WHERE id = $1', [id]);
      if (assetResult.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
      const asset = assetResult.rows[0];
      if (asset.proxy_s3_key)     await deleteObject(asset.proxy_s3_key);
      if (asset.thumbnail_s3_key) await deleteObject(asset.thumbnail_s3_key);
      if (asset.original_s3_key)  await deleteObject(asset.original_s3_key);
      await pool.query('DELETE FROM assets WHERE id = $1', [id]);
      res.json({ message: 'Asset deleted permanently' });
    } else {
      const result = await pool.query(
        `UPDATE assets SET status = 'archived', updated_at = NOW() WHERE id = $1 RETURNING *`,
        [id]
      );
      if (result.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
      res.json(result.rows[0]);
    }
  } catch (err) {
    next(err);
  }
});

// GET /:id/stream
router.get('/:id/stream', async (req, res, next) => {
  try {
    const { id } = req.params;
    const r = await pool.query('SELECT * FROM assets WHERE id = $1', [id]);
    if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
    const a = r.rows[0];
    if (a.status === 'live') return res.json({ url: `/live/${a.id}/index.m3u8`, type: 'hls', live: true });
    if (a.proxy_s3_key) return res.json({ url: `/api/v1/assets/${id}/video`, type: 'mp4' });
    const orig = a.original_s3_key;
    if (orig && orig.toLowerCase().endsWith('.mp4')) return res.json({ url: `/api/v1/assets/${id}/video`, type: 'mp4' });
    return res.json({ url: null, type: null, reason: 'no_proxy' });
  } catch (err) {
    next(err);
  }
});

// GET /:id/video
router.get('/:id/video', async (req, res, next) => {
  try {
    const { id } = req.params;
    const r = await pool.query('SELECT proxy_s3_key, original_s3_key FROM assets WHERE id = $1', [id]);
    if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
    const a = r.rows[0];
    const key = a.proxy_s3_key || (a.original_s3_key?.toLowerCase().endsWith('.mp4') ? a.original_s3_key : null);
    if (!key) return res.status(404).json({ error: 'No browser-playable source' });
    const params = { Bucket: getS3Bucket(), Key: key };
    const rangeHeader = req.headers.range;
    if (rangeHeader) params.Range = rangeHeader;
    const s3Res = await s3Client.send(new GetObjectCommand(params));
    const status = rangeHeader ? 206 : 200;
    const headers = { 'Content-Type': 'video/mp4', 'Accept-Ranges': 'bytes', 'Cache-Control': 'no-store' };
    if (s3Res.ContentLength) headers['Content-Length'] = String(s3Res.ContentLength);
    if (s3Res.ContentRange)  headers['Content-Range']  = s3Res.ContentRange;
    res.writeHead(status, headers);
    s3Res.Body.pipe(res);
  } catch (err) { next(err); }
});

// GET /:id/hires
router.get('/:id/hires', async (req, res, next) => {
  try {
    const { id } = req.params;
    const r = await pool.query(
      'SELECT original_s3_key, filename, display_name, file_size FROM assets WHERE id = $1',
      [id]
    );
    if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
    const a = r.rows[0];
    if (!a.original_s3_key) return res.status(404).json({ error: 'No hi-res source available' });
    const url   = await getSignedUrlForObject(a.original_s3_key);
    const parts = a.original_s3_key.split('.');
    const ext   = (parts.length > 1 ? parts[parts.length - 1] : 'mxf').toLowerCase();
    const base  = (a.display_name || a.filename || id).replace(/[^\w.-]/g, '_').substring(0, 100);
    res.json({ url, filename: `${base}.${ext}`, ext, file_size: a.file_size || null, type: 'hires' });
  } catch (err) { next(err); }
});

// GET /:id/thumbnail
router.get('/:id/thumbnail', async (req, res, next) => {
  try {
    const { id } = req.params;
    const result = await pool.query('SELECT thumbnail_s3_key FROM assets WHERE id = $1', [id]);
    if (result.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
    const { thumbnail_s3_key } = result.rows[0];
    if (!thumbnail_s3_key) return res.status(404).json({ error: 'Thumbnail not yet available' });
    const url = await getSignedUrlForObject(thumbnail_s3_key);
    if (req.query.redirect === '1') return res.redirect(302, url);
    res.json({ url });
  } catch (err) {
    next(err);
  }
});

export default router;


File: services/mam-api/src/routes/projects.js

import express from 'express';
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);

// Helper function to slugify
const slugify = (str) => {
  return str
    .toLowerCase()
    .trim()
    .replace(/[^\w\s-]/g, '')
    .replace(/\s+/g, '-')
    .replace(/-+/g, '-');
};

// GET / - List all projects
router.get('/', async (req, res, next) => {
  try {
    const result = await pool.query('SELECT * FROM projects ORDER BY created_at DESC');
    res.json(result.rows);
  } catch (err) {
    next(err);
  }
});

// POST / - Create project
router.post('/', async (req, res, next) => {
  try {
    const { name, description } = req.body;

    if (!name) {
      return res.status(400).json({ error: 'Name is required' });
    }

    const id = uuidv4();
    const s3_prefix = slugify(name);

    const result = await pool.query(
      `INSERT INTO projects (id, name, description, s3_prefix, created_at, updated_at)
       VALUES ($1, $2, $3, $4, NOW(), NOW())
       RETURNING *`,
      [id, name, description || null, s3_prefix]
    );

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

// GET /:id - Single project with asset count
router.get('/:id', async (req, res, next) => {
  try {
    const { id } = req.params;

    const result = await pool.query(
      `SELECT p.*,
              COUNT(a.id) AS asset_count
       FROM projects p
       LEFT JOIN assets a ON a.project_id = p.id AND a.status != 'archived'
       WHERE p.id = $1
       GROUP BY p.id`,
      [id]
    );

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

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

// PATCH /:id - Update project
router.patch('/:id', async (req, res, next) => {
  try {
    const { id } = req.params;
    const { name, description } = req.body;

    const updates = [];
    const params = [];
    let paramCount = 1;

    if (name !== undefined) {
      updates.push(`name = $${paramCount++}`);
      params.push(name);
    }

    if (description !== undefined) {
      updates.push(`description = $${paramCount++}`);
      params.push(description);
    }

    if (updates.length === 0) {
      return res.status(400).json({ error: 'No fields to update' });
    }

    updates.push(`updated_at = NOW()`);
    params.push(id);

    const query = `
      UPDATE projects
      SET ${updates.join(', ')}
      WHERE id = $${paramCount}
      RETURNING *
    `;

    const result = await pool.query(query, params);

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

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

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

    // Delete project (cascade should handle related records)
    const result = await pool.query(
      'DELETE FROM projects WHERE id = $1 RETURNING *',
      [id]
    );

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

    res.json({ message: 'Project deleted', project: result.rows[0] });
  } catch (err) {
    next(err);
  }
});

export default router;


File: services/mam-api/src/routes/recorders.js

import express from 'express';
import http from 'http';
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:     '8M',
      proxy_audio_codec:       'aac',
      proxy_audio_bitrate:     '192k',
      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 || '8M'}`,
      `PROXY_FRAMERATE=${recorder.proxy_framerate || ''}`,
      `PROXY_AUDIO_CODEC=${recorder.proxy_audio_codec || 'aac'}`,
      `PROXY_AUDIO_BITRATE=${recorder.proxy_audio_bitrate || '192k'}`,
      `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) {
      return res.status(400).json({ error: 'No container running' });
    }

    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`
      );

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

      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);
  }
});

router.post('/probe', async (req, res, next) => {
  try {
    const r = await fetch('http://capture:3001/capture/probe', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(req.body || {}),
      signal: AbortSignal.timeout(15000),
    });
    const data = await r.json().catch(() => ({}));
    res.status(r.status).json(data);
  } catch (err) {
    next(err);
  }
});

export default router;


File: services/mam-api/src/routes/jobs.js

import express from 'express';
import pool from '../db/pool.js';
import { requireAuth } from '../middleware/auth.js';
import { Queue } from 'bullmq';

const router = express.Router();
router.use(requireAuth);

// ── Redis connection ──────────────────────────────────────────────────────────
const parseRedisUrl = (url) => {
  try {
    const parsed = new URL(url);
    return { host: parsed.hostname, port: parseInt(parsed.port, 10) || 6379 };
  } catch {
    return { host: 'localhost', port: 6379 };
  }
};

const redisConn = parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379');

const proxyQueue     = new Queue('proxy',     { connection: redisConn });
const thumbnailQueue = new Queue('thumbnail', { connection: redisConn });
const conformQueue   = new Queue('conform',   { connection: redisConn });

const QUEUES = [
  { queue: proxyQueue,     type: 'proxy'     },
  { queue: thumbnailQueue, type: 'thumbnail' },
  { queue: conformQueue,   type: 'conform'   },
];

// BullMQ state → API status mapping
const STATE_MAP = {
  waiting:   'waiting',
  active:    'active',
  completed: 'completed',
  failed:    'failed',
  delayed:   'waiting',
  paused:    'waiting',
};

// Ordered state buckets used for bulk fetch — avoids N+1 getState() calls.
const STATE_BUCKETS = ['active', 'waiting', 'completed', 'failed', 'delayed', 'paused'];

function normalizeJob(bullJob, type, apiStatus) {
  const isCompleted = apiStatus === 'completed';
  const isFailed    = apiStatus === 'failed';
  return {
    id:           `${type}:${bullJob.id}`,
    type,
    status:       apiStatus,
    progress:     typeof bullJob.progress === 'number' ? bullJob.progress : 0,
    asset_id:     bullJob.data?.assetId    || null,
    asset_name:   bullJob.data?.assetName  || null,
    created_at:   bullJob.timestamp   ? new Date(bullJob.timestamp).toISOString()   : null,
    started_at:   bullJob.processedOn ? new Date(bullJob.processedOn).toISOString() : null,
    completed_at: isCompleted && bullJob.finishedOn ? new Date(bullJob.finishedOn).toISOString() : null,
    failed_at:    isFailed    && bullJob.finishedOn ? new Date(bullJob.finishedOn).toISOString() : null,
    error:        bullJob.failedReason || null,
    metadata:     bullJob.data || {},
  };
}

// Fetch all jobs from all queues in bulk by state bucket (no per-job getState() calls).
async function getAllBullMQJobs() {
  const results = [];
  for (const { queue, type } of QUEUES) {
    for (const bucket of STATE_BUCKETS) {
      try {
        const apiStatus = STATE_MAP[bucket] || bucket;
        // getJobs([state], start, end) returns at most 200 per bucket
        const jobs = await queue.getJobs([bucket], 0, 200);
        for (const job of jobs) {
          results.push(normalizeJob(job, type, apiStatus));
        }
      } catch {
        // queue or bucket unavailable — skip
      }
    }
  }
  return results;
}

// ── GET /events – Server-Sent Events stream of live job updates ───────────────
//
// Must be declared BEFORE GET /:id so the literal path "events" isn't treated
// as a job-id parameter.
//
router.get('/events', async (req, res) => {
  res.setHeader('Content-Type',      'text/event-stream');
  res.setHeader('Cache-Control',     'no-cache');
  res.setHeader('Connection',        'keep-alive');
  res.setHeader('X-Accel-Buffering', 'no'); // disable nginx proxy buffering
  res.flushHeaders();

  let closed = false;
  req.on('close', () => { closed = true; });

  const push = async () => {
    if (closed) return;
    try {
      const jobs = await getAllBullMQJobs();
      if (!closed) res.write(`data: ${JSON.stringify({ type: 'jobs', jobs })}\n\n`);
    } catch (err) {
      if (!closed) res.write(`data: ${JSON.stringify({ type: 'error', message: err.message })}\n\n`);
    }
    if (!closed) setTimeout(push, 2000);
  };

  // Send initial snapshot immediately, then every 2 s
  await push();
});

// ── GET / - List jobs (BullMQ queues) ────────────────────────────────────────
router.get('/', async (req, res, next) => {
  try {
    const { type, status, asset_id } = req.query;
    let jobs = await getAllBullMQJobs();

    if (type)     jobs = jobs.filter(j => j.type === type);
    if (status)   jobs = jobs.filter(j => j.status === status);
    if (asset_id) jobs = jobs.filter(j => j.asset_id === asset_id);

    jobs.sort((a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0));
    res.json(jobs);
  } catch (err) {
    next(err);
  }
});

// ── GET /:id - Single job ─────────────────────────────────────────────────────
router.get('/:id', async (req, res, next) => {
  try {
    const { id } = req.params;
    // id format: "type:bullId"  e.g. "proxy:1"
    const colonIdx = id.indexOf(':');
    const qType  = colonIdx > -1 ? id.slice(0, colonIdx) : null;
    const bullId = colonIdx > -1 ? id.slice(colonIdx + 1) : id;

    for (const { queue, type } of QUEUES) {
      if (qType && type !== qType) continue;
      try {
        const job = await queue.getJob(bullId);
        if (job) {
          const state = await job.getState();
          const apiStatus = STATE_MAP[state] || state;
          return res.json(normalizeJob(job, type, apiStatus));
        }
      } catch { /* try next queue */ }
    }
    res.status(404).json({ error: 'Job not found' });
  } catch (err) {
    next(err);
  }
});

// ── DELETE /:id - Remove a job ────────────────────────────────────────────────
router.delete('/:id', async (req, res, next) => {
  try {
    const { id } = req.params;
    const colonIdx = id.indexOf(':');
    const qType  = colonIdx > -1 ? id.slice(0, colonIdx) : null;
    const bullId = colonIdx > -1 ? id.slice(colonIdx + 1) : id;

    for (const { queue, type } of QUEUES) {
      if (qType && type !== qType) continue;
      try {
        const job = await queue.getJob(bullId);
        if (job) {
          await job.remove();
          return res.json({ success: true });
        }
      } catch { /* try next queue */ }
    }
    res.status(404).json({ error: 'Job not found' });
  } catch (err) {
    next(err);
  }
});

// ── POST /conform - Submit a conform (EDL export) job ────────────────────────
router.post('/conform', async (req, res, next) => {
  try {
    const { edl, project_id, output_format } = req.body;

    if (!edl || !project_id || !output_format) {
      return res.status(400).json({
        error: 'edl, project_id, and output_format are required',
      });
    }

    const bullJob = await conformQueue.add('conform-task', {
      edl,
      projectId:    project_id,
      outputFormat: output_format,
    });

    res.status(202).json({ id: `conform:${bullJob.id}`, status: 'queued' });
  } catch (err) {
    next(err);
  }
});

export default router;


File: services/mam-api/src/routes/cluster.js

import express from 'express';
import pool from '../db/pool.js';
import { requireAuth } from '../middleware/auth.js';

const router = express.Router();
router.use(requireAuth);

// If the agent reported Docker's default bridge IP (172.17.x) but the request
// itself came from a real LAN address, prefer the request source IP instead.
// We only check 172.17.x — the default docker0 bridge — not the full RFC1918
// 172.16/12 block, since real LANs (e.g. 172.18.91.x) fall in that range.
function pickIp(reportedIp, reqIp) {
  const clean = (s) => (s || '').replace(/^::ffff:/, '');
  const isDockerBridge = (ip) => /^172\.17\./.test(ip || '');
  const r = clean(reqIp);
  if (!reportedIp) return r || null;
  if (isDockerBridge(reportedIp) && r && !isDockerBridge(r)) return r;
  return reportedIp;
}

// GET / – list all registered cluster nodes with online status
router.get('/', async (req, res, next) => {
  try {
    const r = await pool.query(
      `SELECT *,
              EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
       FROM cluster_nodes
       ORDER BY registered_at ASC`
    );
    res.json(r.rows.map(row => ({
      ...row,
      online: Number(row.stale_seconds) < 120,
    })));
  } catch (err) { next(err); }
});

// POST /heartbeat – upsert this node's registration (includes hardware capabilities)
router.post('/heartbeat', async (req, res, next) => {
  try {
    const {
      hostname, ip_address,
      role = 'worker', version, api_url,
      cpu_usage, mem_used_mb, mem_total_mb,
      capabilities, metadata,
    } = req.body;

    if (!hostname) return res.status(400).json({ error: 'hostname is required' });

    const effectiveIp = pickIp(ip_address, req.ip || req.socket?.remoteAddress);

    const r = await pool.query(
      `INSERT INTO cluster_nodes
         (hostname, ip_address, role, version, api_url,
          cpu_usage, mem_used_mb, mem_total_mb, last_seen, capabilities, metadata)
       VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NOW(),$9,$10)
       ON CONFLICT (hostname) DO UPDATE SET
         ip_address   = EXCLUDED.ip_address,
         role         = EXCLUDED.role,
         version      = EXCLUDED.version,
         api_url      = EXCLUDED.api_url,
         cpu_usage    = EXCLUDED.cpu_usage,
         mem_used_mb  = EXCLUDED.mem_used_mb,
         mem_total_mb = EXCLUDED.mem_total_mb,
         last_seen    = NOW(),
         capabilities = EXCLUDED.capabilities,
         metadata     = EXCLUDED.metadata
       RETURNING *`,
      [
        hostname,
        effectiveIp,
        role,
        version      || null,
        api_url      || null,
        cpu_usage    != null ? cpu_usage    : null,
        mem_used_mb  != null ? mem_used_mb  : null,
        mem_total_mb != null ? mem_total_mb : null,
        capabilities != null ? JSON.stringify(capabilities) : '{}',
        metadata     != null ? JSON.stringify(metadata) : null,
      ]
    );
    res.json(r.rows[0]);
  } catch (err) { next(err); }
});

// GET /devices/blackmagic – flatten every node's DeckLink cards for the
// recorder picker. Returns one entry per device with the host node info.
router.get('/devices/blackmagic', async (req, res, next) => {
  try {
    const r = await pool.query(
      `SELECT id, hostname, ip_address, role, capabilities,
              EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
       FROM cluster_nodes
       WHERE capabilities IS NOT NULL`
    );
    const out = [];
    for (const row of r.rows) {
      const online = Number(row.stale_seconds) < 120;
      const bm = (row.capabilities && row.capabilities.blackmagic) || [];
      const model = (row.capabilities && row.capabilities.blackmagic_model) || null;
      bm.forEach((d, idx) => {
        out.push({
          node_id:    row.id,
          hostname:   row.hostname,
          ip_address: row.ip_address,
          role:       row.role,
          online,
          model,
          index:      d.index !== undefined ? d.index : idx,
          device:     d.device,
        });
      });
    }
    res.json(out);
  } catch (err) { next(err); }
});

// GET /:id/ping – probe the node's api_url/health endpoint directly
router.get('/:id/ping', async (req, res, next) => {
  try {
    const r = await pool.query(
      'SELECT id, hostname, api_url FROM cluster_nodes WHERE id = $1',
      [req.params.id]
    );
    if (r.rowCount === 0) return res.status(404).json({ error: 'Node not found' });

    const node = r.rows[0];
    if (!node.api_url) return res.json({ reachable: false, reason: 'no api_url registered' });

    const start = Date.now();
    try {
      const upstream = await fetch(`${node.api_url}/health`, {
        signal: AbortSignal.timeout(4000),
      });
      const latency_ms = Date.now() - start;
      const body = await upstream.json().catch(() => ({}));
      res.json({ reachable: upstream.ok, latency_ms, status: upstream.status, agent: body });
    } catch (err) {
      res.json({ reachable: false, latency_ms: Date.now() - start, reason: err.message });
    }
  } catch (err) { next(err); }
});

// DELETE /:id – deregister a node
router.delete('/:id', async (req, res, next) => {
  try {
    const r = await pool.query(
      'DELETE FROM cluster_nodes WHERE id = $1 RETURNING id',
      [req.params.id]
    );
    if (r.rowCount === 0) return res.status(404).json({ error: 'Node not found' });
    res.json({ ok: true });
  } catch (err) { next(err); }
});

export default router;


Here are the complete JSON response shapes for each endpoint in the wild-dragon MAM API:

## **assets.js** (`/api/v1/assets`)

**GET /** - List assets
```json
{
  "assets": [
    {
      "id": "uuid",
      "project_id": "uuid",
      "bin_id": "uuid|null",
      "filename": "string",
      "display_name": "string",
      "status": "live|processing|ready|error|archived",
      "media_type": "string",
      "original_s3_key": "string|null",
      "proxy_s3_key": "string|null",
      "thumbnail_s3_key": "string|null",
      "codec": "string|null",
      "resolution": "string|null",
      "fps": "number|null",
      "duration_ms": "number|null",
      "start_tc": "string|null",
      "file_size": "number|null",
      "tags": "array|null",
      "notes": "string|null",
      "created_at": "iso8601",
      "updated_at": "iso8601"
    }
  ],
  "total": "number"
}
```

**POST /** - Register new asset
Returns single asset object (same schema as list item)

**POST /cleanup-live** - Clean up expired live assets
```json
{
  "cleaned": "number",
  "assets": [
    {
      "id": "uuid",
      "display_name": "string",
      "project_id": "uuid",
      "created_at": "iso8601"
    }
  ]
}
```

**GET /:id** - Get single asset
Returns asset object

**PATCH /:id** - Update asset metadata
Returns updated asset object

**POST /:id/copy** - Copy asset
Returns new asset object (201)

**POST /:id/retry** - Retry failed asset processing
Returns updated asset object

**DELETE /:id** - Archive or permanently delete asset
Returns updated asset object (status=archived) or `{ message: "Asset deleted permanently" }`

**GET /:id/stream** - Get streaming info
```json
{
  "url": "string|null",
  "type": "hls|mp4|null",
  "live": "boolean|undefined",
  "reason": "string|undefined"
}
```

**GET /:id/video** - Stream proxy/original video
Returns video file (HTTP 200/206 with video/mp4 content type)

**GET /:id/hires** - Get hi-res download link
```json
{
  "url": "string (signed S3 URL)",
  "filename": "string",
  "ext": "string",
  "file_size": "number|null",
  "type": "hires"
}
```

**GET /:id/thumbnail** - Get thumbnail URL
```json
{
  "url": "string (signed S3 URL)"
}
```

---

## **projects.js** (`/api/v1/projects`)

**GET /** - List all projects
```json
[
  {
    "id": "uuid",
    "name": "string",
    "description": "string|null",
    "s3_prefix": "string",
    "created_at": "iso8601",
    "updated_at": "iso8601"
  }
]
```

**POST /** - Create project
```json
{
  "id": "uuid",
  "name": "string",
  "description": "string|null",
  "s3_prefix": "string",
  "created_at": "iso8601",
  "updated_at": "iso8601"
}
```

**GET /:id** - Get single project with asset count
```json
{
  "id": "uuid",
  "name": "string",
  "description": "string|null",
  "s3_prefix": "string",
  "asset_count": "number",
  "created_at": "iso8601",
  "updated_at": "iso8601"
}
```

**PATCH /:id** - Update project
Returns updated project object

**DELETE /:id** - Delete project
```json
{
  "message": "Project deleted",
  "project": { /* project object */ }
}
```

---

## **recorders.js** (`/api/v1/recorders`)

**GET /** - List all recorders
```json
[
  {
    "id": "uuid",
    "name": "string",
    "source_type": "sdi|srt|rtmp",
    "source_config": "object",
    "recording_codec": "string",
    "recording_resolution": "string",
    "recording_video_bitrate": "string|null",
    "recording_framerate": "string|null",
    "recording_audio_codec": "string",
    "recording_audio_bitrate": "string|null",
    "recording_audio_channels": "number",
    "recording_container": "string",
    "proxy_enabled": "boolean",
    "proxy_codec": "string",
    "proxy_resolution": "string",
    "proxy_video_bitrate": "string",
    "proxy_framerate": "string|null",
    "proxy_audio_codec": "string",
    "proxy_audio_bitrate": "string",
    "proxy_audio_channels": "number",
    "proxy_container": "string",
    "project_id": "uuid",
    "node_id": "uuid|null",
    "device_index": "number|null",
    "status": "stopped|recording",
    "container_id": "string|null",
    "current_session_id": "string|null",
    "created_at": "iso8601",
    "updated_at": "iso8601",
    "started_at": "iso8601|undefined",
    "live_asset_id": "uuid|undefined"
  }
]
```

**POST /** - Create recorder
Returns recorder object (201)

**GET /:id** - Get single recorder
Returns recorder object

**PATCH /:id** - Update recorder (blocked while recording)
Returns updated recorder object

**POST /:id/start** - Start recording
Returns recorder object with updated status

**POST /:id/stop** - Stop recording
Returns recorder object with status=stopped

**GET /:id/status** - Get live status
```json
{
  "status": "recording|stopped|unknown",
  "duration": "number (seconds)",
  "containerId": "string|null",
  "signal": "connecting|receiving|stopped",
  "signalKnown": "boolean",
  "framesReceived": "number|null",
  "currentFps": "number|null",
  "lastFrameAt": "iso8601|null",
  "lastError": "string|null"
}
```

**DELETE /:id** - Delete recorder (stops container first)
```json
{
  "message": "Recorder deleted",
  "recorder": { /* recorder object */ }
}
```

**POST /probe** - Probe capture device
Proxies request to local capture container at `http://capture:3001/capture/probe`

---

## **jobs.js** (`/api/v1/jobs`)

**GET /** - List all jobs (from BullMQ queues)
```json
[
  {
    "id": "proxy:123|thumbnail:456|conform:789",
    "type": "proxy|thumbnail|conform",
    "status": "waiting|active|completed|failed",
    "progress": "number (0-100)",
    "asset_id": "uuid|null",
    "asset_name": "string|null",
    "created_at": "iso8601|null",
    "started_at": "iso8601|null",
    "completed_at": "iso8601|null",
    "failed_at": "iso8601|null",
    "error": "string|null",
    "metadata": "object"
  }
]
```

**GET /events** - Server-Sent Events stream
Streams job updates every 2s as newline-delimited JSON:
```
data: {"type":"jobs","jobs":[...]}
```

**GET /:id** - Get single job (format: "type:bullId")
Returns single job object

**DELETE /:id** - Remove job
```json
{
  "success": true
}
```

**POST /conform** - Submit EDL conform job
```json
{
  "id": "conform:123",
  "status": "queued"
}
```
Request body:
```json
{
  "edl": "string (EDL content)",
  "project_id": "uuid",
  "output_format": "string"
}
```

---

## **cluster.js** (`/api/v1/cluster`)

**GET /** - List cluster nodes with online status
```json
[
  {
    "id": "uuid",
    "hostname": "string",
    "ip_address": "string",
    "role": "worker|other",
    "version": "string|null",
    "api_url": "string|null",
    "cpu_usage": "number|null",
    "mem_used_mb": "number|null",
    "mem_total_mb": "number|null",
    "capabilities": "object|null",
    "metadata": "object|null",
    "stale_seconds": "number",
    "online": "boolean",
    "registered_at": "iso8601",
    "last_seen": "iso8601"
  }
]
```

**POST /heartbeat** - Register/update node
Returns node object

**GET /devices/blackmagic** - List all DeckLink devices across nodes
```json
[
  {
    "node_id": "uuid",
    "hostname": "string",
    "ip_address": "string",
    "role": "string",
    "online": "boolean",
    "model": "string|null",
    "index": "number",
    "device": "string"
  }
]
```

**GET /:id/ping** - Probe node health
```json
{
  "reachable": "boolean",
  "latency_ms": "number",
  "status": "number|undefined",
  "agent": "object|undefined",
  "reason": "string|undefined"
}
```

**DELETE /:id** - Deregister node
```json
{
  "ok": true
}
```

These JSON shapes should give you everything needed to wire your React frontend to the endpoints. All timestamps are ISO 8601 strings, asset/project/recorder IDs are UUIDs, and status fields use lowercase enum values.