One container per channel. Built like capture/build-with-decklink: NDI + DeckLink SDKs fetched at build, runs --privileged with Xvfb for the GL context where no real display is present. Components: - entrypoint.sh: Xvfb + CasparCG launch, creates /media/live/<CHANNEL_ID> - src/amcp.js: TCP AMCP client - src/playout-manager.js: channel lifecycle, playlist walk via LOADBG AUTO for gapless transitions; primary consumer (decklink/ndi/srt/rtmp) plus a second FFMPEG HLS consumer (~600 kbps, 2s segments) for the UI preview - src/index.js: HTTP shim — /channel/start, /playlist/load, transport - frame-rate helper picks fps from video_format (59.94 → 60000/1001) so SEEK / LENGTH frame math is correct
85 lines
3.2 KiB
JavaScript
85 lines
3.2 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 }); } });
|
|
|
|
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'));
|