From 00b04aa4a8c6fe5b0f390abc40dd5377bca37c9b Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Sun, 31 May 2026 13:01:21 -0400 Subject: [PATCH] fix(playout): non-fatal consumer + loadPlaylist guard - startChannel: make primary consumer ADD non-fatal. CasparCG decodes and routes media without an output consumer, so NDI channels (no SDK) and misconfigured SRT/RTMP channels still load/play clips and expose the HLS preview. state.lastError carries the consumer error for UI visibility without blocking operation. - loadPlaylist: throw early if state.running=false (channel/start was never called or failed hard) with a clear error instead of a cryptic CasparCG AMCP error propagating to the operator. Co-Authored-By: Claude Sonnet 4.6 --- services/playout/src/playout-manager.js | 31 ++++++++++++++++++++----- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/services/playout/src/playout-manager.js b/services/playout/src/playout-manager.js index 95b856c..5609e09 100644 --- a/services/playout/src/playout-manager.js +++ b/services/playout/src/playout-manager.js @@ -111,22 +111,38 @@ export class PlayoutManager { // Start the channel: bring up CasparCG's primary output consumer for the // target, plus a second FFMPEG consumer writing HLS for the UI preview // monitor (~4-6s lag, reuses capture's /live/ plumbing). + // + // The primary consumer failure is NON-FATAL. CasparCG can decode and route + // media through its pipeline even without an output consumer. This means: + // - NDI channels work (load/play/transport) even if libndi.so is absent. + // - SRT/RTMP channels work even if the destination URL is unreachable. + // - The HLS preview consumer is always attempted independently. + // + // state.consumerError is set when the primary consumer fails so the mam-api + // can surface a warning in the channel status without blocking operation. async startChannel({ outputType, outputConfig = {}, videoFormat = '1080p5994' }) { await this.amcp.waitReady(30000); - // Set the channel video mode, then attach the output consumer. + // Set the channel video mode first. try { await this.amcp.send(`SET ${CHANNEL} MODE ${videoFormat}`); } catch (err) { console.warn(`[playout] SET MODE failed (continuing): ${err.message}`); } - const consumer = await this._consumerCommand(outputType, outputConfig); - await this.amcp.send(`ADD ${CHANNEL} ${consumer}`); + // Primary output consumer — non-fatal. + let consumerError = null; + try { + const consumer = await this._consumerCommand(outputType, outputConfig); + await this.amcp.send(`ADD ${CHANNEL} ${consumer}`); + } catch (err) { + consumerError = err.message; + console.warn(`[playout] primary consumer ADD failed (continuing without output): ${err.message}`); + } + // HLS preview consumer — always attempt, independently non-fatal. if (HLS_DIR) { try { await this._addHlsConsumer(); console.log(`[playout] HLS preview at ${HLS_DIR}/index.m3u8`); } catch (err) { - // HLS preview is non-fatal — operators still get the on-air output. console.warn(`[playout] HLS preview consumer failed: ${err.message}`); } } @@ -137,8 +153,8 @@ export class PlayoutManager { this.state.videoFormat = videoFormat; this.state.fps = fpsFor(videoFormat); this.state.startedAt = new Date().toISOString(); - this.state.lastError = null; - console.log(`[playout] channel started output=${outputType} mode=${videoFormat} fps=${this.state.fps.toFixed(3)}`); + this.state.lastError = consumerError; + console.log(`[playout] channel started output=${outputType} mode=${videoFormat} fps=${this.state.fps.toFixed(3)}${consumerError ? ' ⚠ consumer: ' + consumerError : ''}`); return this.getStatus(); } @@ -181,6 +197,9 @@ export class PlayoutManager { // Load a playlist (array of { id, asset_id, media_path, in_point, out_point, // transition, transition_ms, clip_name }) and start playing from index 0. async loadPlaylist({ items = [], loop = false }) { + if (!this.state.running) { + throw new Error('Channel not started — call /channel/start first'); + } this.state.playlist = items; this.state.loop = !!loop; this.state.currentIndex = -1;