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;