import net from 'node:net'; // Minimal AMCP (Advanced Media Control Protocol) client for CasparCG. // // AMCP is a line-based TCP protocol: each command is a single CRLF-terminated // line, and the server replies with a status line ("201 PLAY OK\r\n") optionally // followed by data lines. We keep one persistent socket per CasparCG instance // and serialize commands through a FIFO queue — CasparCG processes one command // at a time per connection, so interleaving replies would otherwise be // ambiguous. // // We only implement the subset the playout sidecar needs (PLAY / LOADBG / STOP / // CLEAR / INFO / ADD / REMOVE). Responses are returned raw; callers parse the // status code where they care. const CRLF = '\r\n'; export class AmcpClient { constructor({ host = '127.0.0.1', port = 5250 } = {}) { this.host = host; this.port = port; this.socket = null; this.connected = false; this._buffer = ''; this._queue = []; // pending { command, resolve, reject, timer } this._active = null; // command currently awaiting a reply this._reconnectTimer = null; } connect() { if (this.socket) return; const socket = net.createConnection({ host: this.host, port: this.port }); socket.setEncoding('utf8'); socket.setKeepAlive(true, 10000); socket.on('connect', () => { this.connected = true; console.log(`[amcp] connected to ${this.host}:${this.port}`); }); socket.on('data', (chunk) => this._onData(chunk)); socket.on('error', (err) => { console.error(`[amcp] socket error: ${err.message}`); }); socket.on('close', () => { this.connected = false; this.socket = null; // Fail any in-flight + queued commands so callers don't hang. const pending = this._active ? [this._active, ...this._queue] : [...this._queue]; this._active = null; this._queue = []; for (const p of pending) { clearTimeout(p.timer); p.reject(new Error('AMCP connection closed')); } this._scheduleReconnect(); }); this.socket = socket; } _scheduleReconnect() { if (this._reconnectTimer) return; this._reconnectTimer = setTimeout(() => { this._reconnectTimer = null; console.log('[amcp] reconnecting...'); this.connect(); }, 2000); } // Wait until the socket is usable, up to timeoutMs. async waitReady(timeoutMs = 30000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (this.connected) return true; if (!this.socket) this.connect(); await new Promise((r) => setTimeout(r, 250)); } throw new Error('AMCP not ready within timeout'); } _onData(chunk) { this._buffer += chunk; // A CasparCG reply is a status line, optionally followed by data lines. // The simplest robust framing: a command's reply is complete when we see a // status line AND (for 2-line "200" multi-line replies) the terminating // blank line. For our command subset, single-status-line replies dominate; // we treat a reply as complete at each newline and let the active command // decide whether it has enough. To keep this correct for INFO (multi-line), // we accumulate until the buffer ends with a known terminator. if (!this._active) { // Unsolicited data (e.g. connection banner) — discard. this._buffer = ''; return; } // CasparCG ends multi-line replies with CRLF on an empty line. Single-line // replies (201/202/4xx/5xx) end with a single CRLF. Resolve when we have at // least one complete line; for "200 ... OK" (list follows) wait for the // blank-line terminator. const firstLineEnd = this._buffer.indexOf(CRLF); if (firstLineEnd === -1) return; const statusLine = this._buffer.slice(0, firstLineEnd); const code = parseInt(statusLine, 10); if (code === 200) { // Multi-line: data lines until an empty line. const term = this._buffer.indexOf(CRLF + CRLF); if (term === -1) return; // wait for more const full = this._buffer.slice(0, term); this._buffer = this._buffer.slice(term + 4); this._finishActive(null, full); return; } if (code === 201 || code === 202) { // 201: one data line follows the status line. 202: status only. if (code === 201) { const secondLineEnd = this._buffer.indexOf(CRLF, firstLineEnd + 2); if (secondLineEnd === -1) return; const full = this._buffer.slice(0, secondLineEnd); this._buffer = this._buffer.slice(secondLineEnd + 2); this._finishActive(null, full); } else { const full = this._buffer.slice(0, firstLineEnd); this._buffer = this._buffer.slice(firstLineEnd + 2); this._finishActive(null, full); } return; } // 4xx / 5xx error, or any other single-line status. const full = this._buffer.slice(0, firstLineEnd); this._buffer = this._buffer.slice(firstLineEnd + 2); if (code >= 400) this._finishActive(new Error(`AMCP error: ${full}`), full); else this._finishActive(null, full); } _finishActive(err, data) { const active = this._active; this._active = null; if (active) { clearTimeout(active.timer); if (err) active.reject(err); else active.resolve(data); } this._pump(); } _pump() { if (this._active || this._queue.length === 0) return; const next = this._queue.shift(); this._active = next; try { this.socket.write(next.command + CRLF); } catch (err) { this._active = null; clearTimeout(next.timer); next.reject(err); } } // Send a single AMCP command and resolve with the raw reply string. send(command, { timeoutMs = 15000 } = {}) { return new Promise((resolve, reject) => { const entry = { command, resolve, reject, timer: null }; entry.timer = setTimeout(() => { // Drop from queue if still pending; if active, detach so the next // reply doesn't get misrouted. if (this._active === entry) this._active = null; else this._queue = this._queue.filter((e) => e !== entry); reject(new Error(`AMCP command timed out: ${command}`)); }, timeoutMs); this._queue.push(entry); this._pump(); }); } close() { if (this._reconnectTimer) { clearTimeout(this._reconnectTimer); this._reconnectTimer = null; } if (this.socket) { try { this.socket.destroy(); } catch (_) {} this.socket = null; } this.connected = false; } }