dragonflight/services/playout/src/index.js
Zac Gaetano 984a73e8ec feat(playout): redesigned MCR screen + SCTE-35 end-to-end
Drop in the redesigned timeline-centric Playout (PGM monitor, transport,
SCTE-35 card, as-run drawer) from the on-node redesign, fully wired to the
real playout API (channels/transport/HLS preview w/ error-recovery/as-run);
no mock data. In-page ConfirmModal for destructive actions.

SCTE-35: new playout_scte_breaks table (migration 033), endpoints to
schedule/trigger/list/cancel breaks (POST/GET/DELETE /channels/:id/scte[/trigger]),
scheduler due-break sweep, engine triggerScte + auto-return + as-run 'scte'
rows + on-air SCTE-BREAK state and timeline AD markers. In-stream SCTE-35
cue injection is a documented stub (CasparCG FFMPEG consumer exposes no
scte35 muxer) — scheduling/triggering/countdown/as-run are functional.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 19:58:02 -04:00

99 lines
3.8 KiB
JavaScript

import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import playoutManager from './playout-manager.js';
dotenv.config();
const app = express();
const PORT = process.env.PORT || 3002;
app.use(cors());
app.use(express.json());
app.get('/health', (req, res) => res.json({ status: 'ok' }));
// Start the channel's output consumer. Body: { outputType, outputConfig, videoFormat }
app.post('/channel/start', async (req, res) => {
try {
const out = await playoutManager.startChannel(req.body || {});
res.json(out);
} catch (err) {
console.error('[playout] /channel/start error:', err.message);
res.status(500).json({ error: err.message });
}
});
app.post('/channel/stop', async (req, res) => {
try { res.json(await playoutManager.stopChannel()); }
catch (err) { res.status(500).json({ error: err.message }); }
});
// Load + start a playlist. Body: { items: [...], loop }
app.post('/playlist/load', async (req, res) => {
try {
const { items = [], loop = false } = req.body || {};
res.json(await playoutManager.loadPlaylist({ items, loop }));
} catch (err) {
console.error('[playout] /playlist/load error:', err.message);
res.status(500).json({ error: err.message });
}
});
app.post('/transport/skip', async (req, res) => { try { res.json(await playoutManager.skip()); } catch (e) { res.status(500).json({ error: e.message }); } });
app.post('/transport/pause', async (req, res) => { try { res.json(await playoutManager.pause()); } catch (e) { res.status(500).json({ error: e.message }); } });
app.post('/transport/resume', async (req, res) => { try { res.json(await playoutManager.resume()); } catch (e) { res.status(500).json({ error: e.message }); } });
// Fire an SCTE-35 ad-break splice on the live output. Body:
// { eventId, type: 'splice_insert'|'immediate'|'splice_out'|'splice_in', durationS }
// Returns the active-break descriptor (or the splice_in ack) so the mam-api can
// stamp the as-run log.
app.post('/scte/trigger', (req, res) => {
try {
const { eventId = 1, type = 'splice_insert', durationS = 30 } = req.body || {};
res.json(playoutManager.triggerScte({ eventId, type, durationS }));
} catch (err) {
console.error('[playout] /scte/trigger error:', err.message);
res.status(500).json({ error: err.message });
}
});
app.get('/status', (req, res) => res.json(playoutManager.getStatus()));
// Auto-start: when the sidecar is spawned by mam-api with channel env, bring up
// the output consumer immediately so the container is "on air idle" (black/slate)
// the moment it boots, mirroring the capture sidecar's bootstrap pattern.
async function bootstrap() {
const outputType = process.env.OUTPUT_TYPE;
if (!outputType) {
console.log('[bootstrap] no OUTPUT_TYPE — on-demand sidecar, waiting for /channel/start');
return;
}
let outputConfig = {};
try { outputConfig = JSON.parse(process.env.OUTPUT_CONFIG || '{}'); }
catch (err) { console.error('[bootstrap] bad OUTPUT_CONFIG json:', err.message); }
const videoFormat = process.env.VIDEO_FORMAT || '1080i5994';
try {
await playoutManager.startChannel({ outputType, outputConfig, videoFormat });
} catch (err) {
console.error('[bootstrap] channel start failed:', err.message);
}
}
const server = app.listen(PORT, () => {
console.log(`Wild Dragon Playout Service listening on port ${PORT}`);
// Give CasparCG a moment to come up (started by the container entrypoint).
playoutManager.amcp.connect();
bootstrap();
});
function shutdown(sig) {
console.log(`[playout] ${sig} — shutting down`);
playoutManager.stopChannel().catch(() => {}).finally(() => {
playoutManager.amcp.close();
server.close(() => process.exit(0));
setTimeout(() => process.exit(0), 5000);
});
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));