From 9ed229ca9a7e36051f26606e28fd7b12e5d4e483 Mon Sep 17 00:00:00 2001 From: Cameron Bulock Date: Sun, 6 Sep 2026 15:34:49 -0400 Subject: [PATCH] fix: complete stable playback and proxy safeguards --- admin/src/App.vue | 23 +++- libs/source-sync-service.js | 13 ++- scripts/parseM3U.js | 9 +- server/mcp.js | 4 + server/transcode.js | 45 ++++++-- test/integration/improvements.test.js | 56 +++++++++- test/integration/mcp.test.js | 10 +- .../integration/output-profile-routes.test.js | 11 ++ test/integration/parseM3U-sqlite.test.js | 16 +++ test/integration/transcode.test.js | 101 +++++++++--------- 10 files changed, 219 insertions(+), 69 deletions(-) diff --git a/admin/src/App.vue b/admin/src/App.vue index d5467d5..17eb071 100644 --- a/admin/src/App.vue +++ b/admin/src/App.vue @@ -3115,7 +3115,7 @@ async function setupVideoPlayer() { source: ch.source, hdhomerun: Boolean(ch.hdhomerun), streamUrl, - transcodeUrl: `/transcode/${encodeURIComponent(ch.source || '')}/${encodeURIComponent(ch.name || '')}`, + transcodeUrl: getPreviewChannelRoute('/transcode', ch), probeUrl: null, probeResult: null, playerMode: null, @@ -3163,7 +3163,7 @@ async function setupVideoPlayer() { // The probe runs in parallel with HLS.js so it does not add latency when the // codecs are already browser-compatible. const probeChannel = state.previewWatchingChannel; - const probeBase = `/api/stream-probe/${encodeURIComponent(probeChannel?.source || '')}/${encodeURIComponent(probeChannel?.name || '')}`; + const probeBase = getPreviewChannelRoute('/api/stream-probe', probeChannel); // Do NOT append ?streamMode=hls for HDHomeRun — see comment above. const probeUrl = probeBase; dbg.probeUrl = probeUrl; @@ -4271,7 +4271,7 @@ const previewTableRows = computed(() => const previewStreamUrl = computed(() => { const ch = state.previewWatchingChannel; if (!ch) return ''; - const base = `/stream/${encodeURIComponent(ch.source || '')}/${encodeURIComponent(ch.name || '')}`; + const base = getPreviewChannelRoute('/stream', ch); // HDHomeRun OTA broadcasts use MPEG-2 video and AC-3 audio — codecs not supported // by browser MSE. Append ?streamMode=hls so the server requests the HLS variant // from the HDHomeRun device. Note: HLS mode wraps the MPEG-TS in an HLS playlist @@ -4285,9 +4285,24 @@ const previewTranscodeUrl = computed(() => { if (!ch) return ''; // Server-side transcoding endpoint — converts MPEG-2/AC-3 MPEG-TS to H.264/AAC // using ffmpeg so the browser can play the stream natively via mpegts.js. - return `/transcode/${encodeURIComponent(ch.source || '')}/${encodeURIComponent(ch.name || '')}`; + return getPreviewChannelRoute('/transcode', ch); }); +/** + * Build a playback route for a persisted source channel. Source and display + * names are editable, while sourceChannelId is the database identity exposed + * by output profiles, so prefer it whenever it is available. The name route + * remains the compatibility fallback for preview data created from a temporary + * (not-yet-persisted) configuration. + */ +function getPreviewChannelRoute(prefix, channel) { + const sourceChannelId = String(channel?.sourceChannelId || '').trim(); + if (sourceChannelId) { + return `${prefix}/channel/${encodeURIComponent(sourceChannelId)}`; + } + return `${prefix}/${encodeURIComponent(channel?.source || '')}/${encodeURIComponent(channel?.name || '')}`; +} + // Show the transcoding button only when the unsupported-codec error is active // (i.e. the stream is live but uses codecs the browser cannot decode). const showTranscodeButton = computed(() => state.playerError === ERR_UNSUPPORTED_CODEC); diff --git a/libs/source-sync-service.js b/libs/source-sync-service.js index 78fe7d1..500dda2 100644 --- a/libs/source-sync-service.js +++ b/libs/source-sync-service.js @@ -105,6 +105,7 @@ export function replaceDiscoveredSourceChannels(sourceId, channels) { const seenAt = new Date().toISOString(); const retainedIds = new Set(); + const sourceChannelIds = new Map(); for (const channel of nextChannels) { const existing = (channel.external_key ? existingByExternalKey.get(channel.external_key) : null) || @@ -123,6 +124,14 @@ export function replaceDiscoveredSourceChannels(sourceId, channels) { ); const channelId = existing?.id || crypto.randomUUID(); + // Expose the persistent identity to the in-memory snapshot as well as + // the normalized source_channels row. Playback clients must not use the + // editable provider/channel names as an identifier. + channel.sourceChannelId = channelId; + sourceChannelIds.set( + channel.external_key || getChannelIdentity(channel), + channelId + ); retainedIds.add(channelId); if (existing) { @@ -160,9 +169,11 @@ export function replaceDiscoveredSourceChannels(sourceId, channels) { deleteChannel.run(row.id); } } + + return sourceChannelIds; }); - saveChannels(channels); + return saveChannels(channels); } export default { diff --git a/scripts/parseM3U.js b/scripts/parseM3U.js index 70ee21d..701e8af 100644 --- a/scripts/parseM3U.js +++ b/scripts/parseM3U.js @@ -237,7 +237,14 @@ async function processSource(source, map) { } if (source.id) { - replaceDiscoveredSourceChannels(source.id, discoveredChannels); + const sourceChannelIds = replaceDiscoveredSourceChannels(source.id, discoveredChannels); + for (const channel of channels) { + const key = channel.external_key || ''; + const sourceChannelId = sourceChannelIds?.get(key); + if (sourceChannelId) { + channel.sourceChannelId = sourceChannelId; + } + } } if (syncRunId) { finishSourceSyncRun(syncRunId, { status: 'success' }); diff --git a/server/mcp.js b/server/mcp.js index c0e2c7a..ec7e4bd 100644 --- a/server/mcp.js +++ b/server/mcp.js @@ -449,6 +449,10 @@ function createMcpServer() { } const channels = result.slice(0, limit).map(channel => ({ + // Stable identity for agent-created playback URLs. Channel names and + // provider display names can change after a refresh, so consumers + // should prefer /stream/channel/:sourceChannelId when this is present. + sourceChannelId: channel.sourceChannelId || null, name: channel.name, source: channel.source, tvg_id: channel.tvg_id || null, diff --git a/server/transcode.js b/server/transcode.js index 376edd3..51c0f43 100644 --- a/server/transcode.js +++ b/server/transcode.js @@ -17,9 +17,15 @@ const transcodeLimiter = RateLimit({ // A request rate is not a bound on the amount of work currently running. Keep // this intentionally small: every worker encodes video and can consume a core. -const MAX_ACTIVE_WORKERS = Number.parseInt(process.env.TRANSCODE_MAX_WORKERS || '3', 10); -const STARTUP_TIMEOUT_MS = Number.parseInt(process.env.TRANSCODE_STARTUP_TIMEOUT_MS || '15000', 10); -const IDLE_TIMEOUT_MS = Number.parseInt(process.env.TRANSCODE_IDLE_TIMEOUT_MS || '30000', 10); +function positiveInteger(value, fallback) { + const parsed = Number.parseInt(value, 10); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +const MAX_ACTIVE_WORKERS = positiveInteger(process.env.TRANSCODE_MAX_WORKERS, 3); +const STARTUP_TIMEOUT_MS = positiveInteger(process.env.TRANSCODE_STARTUP_TIMEOUT_MS, 15000); +const IDLE_TIMEOUT_MS = positiveInteger(process.env.TRANSCODE_IDLE_TIMEOUT_MS, 30000); +const KILL_GRACE_MS = 5000; const MAX_STDERR_BYTES = 16 * 1024; const activeWorkers = new Set(); @@ -101,10 +107,23 @@ export function setupTranscodeRoutes(app) { 'pipe:1', ]; - const ffmpegProcess = spawn(ffmpegCommand(), ffmpegArgs, { - shell: false, - windowsHide: true, - }); + let ffmpegProcess; + try { + ffmpegProcess = spawn(ffmpegCommand(), ffmpegArgs, { + shell: false, + windowsHide: true, + }); + } catch (err) { + // child_process.spawn normally reports failures through its error event, + // but can throw synchronously for OS policy failures and invalid commands. + console.warn('[transcode] spawn error %s/%s: %s', source, name, err.message); + if (err.code === 'ENOENT') { + return res.status(503).json({ + error: 'ffmpeg is not installed on this server. Install ffmpeg to enable server-side transcoding.', + }); + } + return res.status(502).json({ error: 'Transcoding failed' }); + } activeWorkers.add(ffmpegProcess); res.setHeader('Content-Type', 'video/MP2T'); @@ -118,18 +137,27 @@ export function setupTranscodeRoutes(app) { let stderrText = ''; let cleanedUp = false; let idleTimer; + let forceKillTimer; const stopWorker = signal => { - if (ffmpegProcess.exitCode === null && !ffmpegProcess.killed) ffmpegProcess.kill(signal); + if (ffmpegProcess.exitCode !== null || ffmpegProcess.killed) return; + ffmpegProcess.kill(signal); + // SIGTERM is advisory on Windows. Escalate so a stalled upstream cannot + // retain an expensive encoder worker indefinitely. + forceKillTimer = setTimeout(() => { + if (ffmpegProcess.exitCode === null && !ffmpegProcess.killed) ffmpegProcess.kill('SIGKILL'); + }, KILL_GRACE_MS); }; const clearTimers = () => { clearTimeout(startupTimer); clearTimeout(idleTimer); + clearTimeout(forceKillTimer); }; const resetIdleTimer = () => { clearTimeout(idleTimer); idleTimer = setTimeout(() => { console.warn('[transcode] ffmpeg idle timeout for %s/%s', source, name); + if (!res.writableEnded) res.end(); stopWorker('SIGTERM'); }, IDLE_TIMEOUT_MS); }; @@ -143,6 +171,7 @@ export function setupTranscodeRoutes(app) { const startupTimer = setTimeout(() => { if (!responseStarted) { console.warn('[transcode] ffmpeg startup timeout for %s/%s', source, name); + if (!res.headersSent) res.status(504).json({ error: 'Transcoding startup timed out' }); stopWorker('SIGTERM'); } }, STARTUP_TIMEOUT_MS); diff --git a/test/integration/improvements.test.js b/test/integration/improvements.test.js index 54dbd31..328156f 100644 --- a/test/integration/improvements.test.js +++ b/test/integration/improvements.test.js @@ -7,6 +7,7 @@ import os from 'os'; import path from 'path'; import sinon from 'sinon'; import { closeDatabase } from '../../libs/database.js'; +import getBaseUrl from '../../libs/getBaseUrl.js'; // ────────────────────────────────────────────────────────────────────────────── // Helpers @@ -560,7 +561,58 @@ describe('Rate limiting', () => { }); // ────────────────────────────────────────────────────────────────────────────── -// 6. GET /channels?mapped_only=true +// 6. Reverse proxy trust +// ────────────────────────────────────────────────────────────────────────────── +describe('Reverse proxy trust', () => { + async function requestIdentity(trustedProxies) { + const app = express(); + app.set('trust proxy', trustedProxies); + app.get('/identity', (req, res) => { + res.json({ + ip: req.ip, + protocol: req.protocol, + host: req.host, + baseUrl: getBaseUrl(req), + }); + }); + + const { server, baseUrl } = await startServer(app); + try { + return await axios.get(`${baseUrl}/identity`, { + headers: { + 'X-Forwarded-For': '203.0.113.50', + 'X-Forwarded-Host': 'forged.example.test', + 'X-Forwarded-Proto': 'https', + }, + }); + } finally { + await stopServer(server); + } + } + + it('ignores forged forwarded identity, host, and protocol headers by default', async () => { + const response = await requestIdentity(false); + + expect(response.data.ip).to.equal('127.0.0.1'); + expect(response.data.protocol).to.equal('http'); + expect(response.data.host).to.match(/^127\.0\.0\.1:\d+$/); + expect(response.data.baseUrl).to.match(/^http:\/\/localhost:\d+$/); + }); + + it('honors forwarded headers when the direct peer is an explicitly trusted proxy', async () => { + const response = await requestIdentity(['127.0.0.1']); + + expect(response.data).to.deep.equal({ + ip: '203.0.113.50', + protocol: 'https', + host: 'forged.example.test', + baseUrl: 'https://forged.example.test', + }); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// 7. GET /channels?mapped_only=true // ────────────────────────────────────────────────────────────────────────────── describe('GET /channels?mapped_only=true', () => { let tmpDir; @@ -657,7 +709,7 @@ describe('GET /channels?mapped_only=true', () => { }); // ────────────────────────────────────────────────────────────────────────────── -// 7. GET /channels?mapped_only=true — HDHomeRun channel filtering +// 8. GET /channels?mapped_only=true — HDHomeRun channel filtering // ────────────────────────────────────────────────────────────────────────────── describe('GET /channels?mapped_only=true with HDHomeRun channels', () => { let tmpDir; diff --git a/test/integration/mcp.test.js b/test/integration/mcp.test.js index 0f97d4f..f788fb3 100644 --- a/test/integration/mcp.test.js +++ b/test/integration/mcp.test.js @@ -108,7 +108,13 @@ describe('MCP Route Integration', () => { // Write test channels to the isolated temp data dir const testChannels = [ - { name: 'CNN', tvg_id: 'cnn.us', source: 'TestProvider', group: 'News' }, + { + name: 'CNN', + tvg_id: 'cnn.us', + source: 'TestProvider', + sourceChannelId: 'source-channel-cnn', + group: 'News', + }, { name: 'ESPN', tvg_id: 'espn.us', source: 'TestProvider', group: 'Sports' }, { name: 'Fox News', @@ -231,6 +237,7 @@ describe('MCP Route Integration', () => { const channels = payload.data; expect(channels).to.have.lengthOf(3); expect(channels[0]).to.have.all.keys( + 'sourceChannelId', 'name', 'source', 'tvg_id', @@ -238,6 +245,7 @@ describe('MCP Route Integration', () => { 'group', 'logo' ); + expect(channels[0].sourceChannelId).to.equal('source-channel-cnn'); }); it('list_channels filters by source', async () => { diff --git a/test/integration/output-profile-routes.test.js b/test/integration/output-profile-routes.test.js index cf14a80..434687d 100644 --- a/test/integration/output-profile-routes.test.js +++ b/test/integration/output-profile-routes.test.js @@ -551,6 +551,17 @@ describe('output profile routes', () => { `${publicBaseUrl}/stream/IPTV%20One/Living%20Room%20One` ); + // The stable source-channel route must remain playable after the canonical + // display name changes. This is the URL emitted to clients, so exercise + // the real proxy rather than only asserting the playlist text. + nock('http://streams.example') + .get('/one') + .reply(200, 'stable source-channel stream', { 'content-type': 'video/mp2t' }); + const stableStreamResponse = await axios.get(stableStreamUrl, { + responseType: 'arraybuffer', + }); + expect(Buffer.from(stableStreamResponse.data).toString()).to.equal('stable source-channel stream'); + const xmltvResponse = await axios.get(`${baseUrl}/xmltv.xml`); expect(xmltvResponse.data).to.include(''); expect(xmltvResponse.data).to.include('Living Room One'); diff --git a/test/integration/parseM3U-sqlite.test.js b/test/integration/parseM3U-sqlite.test.js index 235269c..a7b5d30 100644 --- a/test/integration/parseM3U-sqlite.test.js +++ b/test/integration/parseM3U-sqlite.test.js @@ -87,6 +87,22 @@ describe('parseAll SQLite persistence', () => { }, ]); + // The snapshot feeds playback consumers (including MCP), so it must carry + // the durable source_channels id rather than requiring callers to rebuild + // a route from editable source/name strings. + const snapshot = JSON.parse( + databaseModule.get('SELECT channels_json FROM channel_snapshots WHERE id = 1').channels_json + ); + const sourceIdsByName = new Map( + databaseModule + .all('SELECT id, name FROM source_channels WHERE source_id = ?', [source.id]) + .map(row => [row.name, row.id]) + ); + expect(snapshot).to.have.lengthOf(2); + for (const channel of snapshot) { + expect(channel.sourceChannelId).to.equal(sourceIdsByName.get(channel.name)); + } + const syncRun = databaseModule.get( 'SELECT kind, status, error FROM source_sync_runs WHERE source_id = ? ORDER BY started_at DESC LIMIT 1', [source.id] diff --git a/test/integration/transcode.test.js b/test/integration/transcode.test.js index 6aa67ba..a934857 100644 --- a/test/integration/transcode.test.js +++ b/test/integration/transcode.test.js @@ -11,34 +11,23 @@ import { setupTranscodeRoutes } from '../../server/transcode.js'; import { errorHandler } from '../../server/error-handler.js'; /** - * Create a minimal Node.js script that exits with the given code and optionally - * writes bytes to stdout first. Using Node.js avoids any shell-escaping concerns - * with the stdout data. Returns the path to the script. + * Configure the current Node executable as a direct ffmpeg fixture. This is + * deliberately not a .cmd/.bat wrapper: on Windows those wrappers require + * cmd.exe, which would undermine the route's no-shell spawning guarantee. */ -async function makeFFmpegStub(tmpDir, { exitCode = 0, stdoutData = null, argsFile = null } = {}) { - let script = '#!/usr/bin/env node\n'; - if (argsFile !== null) { - // The stub runs in a temp dir with no package.json, so Node.js treats it as - // CommonJS. Using require() here is intentional and correct for that context. - script += 'const _fs = require(\'fs\');\n'; - script += `_fs.writeFileSync(${JSON.stringify(argsFile)}, JSON.stringify(process.argv.slice(2)));\n`; - } - if (stdoutData !== null) { - script += `process.stdout.write(${JSON.stringify(stdoutData)});\n`; - } - script += `process.exit(${exitCode});\n`; - - if (process.platform === 'win32') { - const scriptPath = path.join(tmpDir, 'ffmpeg-stub.cjs'); - const bin = path.join(tmpDir, 'ffmpeg.cmd'); - await fs.writeFile(scriptPath, script, 'utf8'); - await fs.writeFile(bin, '@echo off\r\nnode "%~dp0\\ffmpeg-stub.cjs" %*\r\n', 'utf8'); - return bin; - } - - const bin = path.join(tmpDir, 'ffmpeg'); - await fs.writeFile(bin, script, { mode: 0o755 }); - return bin; +async function makeFFmpegStub(tmpDir, options = {}) { + const preloadPath = path.join(tmpDir, 'ffmpeg-stub.cjs'); + const preload = ` +const fs = require('fs'); +const config = JSON.parse(process.env.FFMPEG_STUB_CONFIG); +if (config.argsFile) fs.writeFileSync(config.argsFile, JSON.stringify(process.argv.slice(2))); +if (config.stderrData) process.stderr.write(config.stderrData); +if (config.stdoutData) process.stdout.write(config.stdoutData); +if (config.delayMs) setTimeout(() => process.exit(config.exitCode), config.delayMs); +else process.exit(config.exitCode); +`; + await fs.writeFile(preloadPath, preload, 'utf8'); + return { preloadPath, options: { exitCode: 0, ...options } }; } function setProcessPath(value) { @@ -46,12 +35,21 @@ function setProcessPath(value) { process.env.Path = value; } +function setFfmpegStub(stub, originalNodeOptions) { + process.env.FFMPEG_PATH = process.execPath; + process.env.FFMPEG_STUB_CONFIG = JSON.stringify(stub.options); + process.env.NODE_OPTIONS = `${originalNodeOptions || ''} --require ${JSON.stringify(stub.preloadPath)}`.trim(); +} + describe('Transcode Route Integration', () => { let originalChannels = null; let server = null; let baseUrl = ''; let tmpBinDir = null; let originalPath = ''; + let originalFfmpegPath; + let originalNodeOptions; + let originalStubConfig; before(async () => { originalChannels = loadChannelSnapshot(); @@ -72,6 +70,9 @@ describe('Transcode Route Integration', () => { // Create a temporary directory for fake ffmpeg binaries tmpBinDir = await fs.mkdtemp(path.join(os.tmpdir(), 'transcode-test-')); originalPath = process.env.Path || process.env.PATH || ''; + originalFfmpegPath = process.env.FFMPEG_PATH; + originalNodeOptions = process.env.NODE_OPTIONS; + originalStubConfig = process.env.FFMPEG_STUB_CONFIG; const app = express(); setupTranscodeRoutes(app); @@ -89,6 +90,12 @@ describe('Transcode Route Integration', () => { after(async () => { // Restore PATH setProcessPath(originalPath); + if (originalFfmpegPath === undefined) delete process.env.FFMPEG_PATH; + else process.env.FFMPEG_PATH = originalFfmpegPath; + if (originalNodeOptions === undefined) delete process.env.NODE_OPTIONS; + else process.env.NODE_OPTIONS = originalNodeOptions; + if (originalStubConfig === undefined) delete process.env.FFMPEG_STUB_CONFIG; + else process.env.FFMPEG_STUB_CONFIG = originalStubConfig; if (server) { await new Promise(resolve => server.close(resolve)); @@ -103,24 +110,14 @@ describe('Transcode Route Integration', () => { }); afterEach(async () => { - // Remove any ffmpeg stub placed in tmpBinDir between tests - try { - await fs.unlink(path.join(tmpBinDir, 'ffmpeg')); - } catch (_) { - // not present — that's fine - } - try { - await fs.unlink(path.join(tmpBinDir, 'ffmpeg.cmd')); - } catch (_) { - // not present — that's fine - } - try { - await fs.unlink(path.join(tmpBinDir, 'ffmpeg-stub.cjs')); - } catch (_) { - // not present — that's fine - } // Restore PATH to its original value setProcessPath(originalPath); + if (originalFfmpegPath === undefined) delete process.env.FFMPEG_PATH; + else process.env.FFMPEG_PATH = originalFfmpegPath; + if (originalNodeOptions === undefined) delete process.env.NODE_OPTIONS; + else process.env.NODE_OPTIONS = originalNodeOptions; + if (originalStubConfig === undefined) delete process.env.FFMPEG_STUB_CONFIG; + else process.env.FFMPEG_STUB_CONFIG = originalStubConfig; }); it('returns 404 for an unknown channel', async () => { @@ -131,9 +128,9 @@ describe('Transcode Route Integration', () => { }); it('returns 503 when ffmpeg is not installed (ENOENT)', async () => { - // Remove tmpBinDir from PATH so that the real ffmpeg (if present) cannot be used - // and the stub is also absent, guaranteeing an ENOENT spawn error. - setProcessPath('/nonexistent-path-for-test'); + // An explicit missing binary guarantees ENOENT without relying on PATH or + // a Windows command-wrapper fixture. + process.env.FFMPEG_PATH = path.join(tmpBinDir, 'missing-ffmpeg-binary'); const response = await axios.get(`${baseUrl}/transcode/Antenna/OTA%20Channel`, { validateStatus: () => true, @@ -146,8 +143,8 @@ describe('Transcode Route Integration', () => { it('returns 502 when ffmpeg exits non-zero before writing any output', async () => { // Place a stub that exits immediately with code 1 (no stdout) - await makeFFmpegStub(tmpBinDir, { exitCode: 1 }); - setProcessPath(`${tmpBinDir}${path.delimiter}${originalPath}`); + const stub = await makeFFmpegStub(tmpBinDir, { exitCode: 1 }); + setFfmpegStub(stub, originalNodeOptions); const response = await axios.get(`${baseUrl}/transcode/Antenna/OTA%20Channel`, { validateStatus: () => true, @@ -162,8 +159,8 @@ describe('Transcode Route Integration', () => { it('pipes ffmpeg stdout to the response', async () => { // Place a stub that writes known bytes to stdout and exits cleanly - await makeFFmpegStub(tmpBinDir, { exitCode: 0, stdoutData: 'FAKEDATA' }); - setProcessPath(`${tmpBinDir}${path.delimiter}${originalPath}`); + const stub = await makeFFmpegStub(tmpBinDir, { exitCode: 0, stdoutData: 'FAKEDATA' }); + setFfmpegStub(stub, originalNodeOptions); const response = await axios.get(`${baseUrl}/transcode/Antenna/OTA%20Channel`, { responseType: 'arraybuffer', @@ -177,8 +174,8 @@ describe('Transcode Route Integration', () => { it('passes browser-compatible encoding flags to ffmpeg', async () => { const argsFile = path.join(tmpBinDir, 'ffmpeg-args.json'); - await makeFFmpegStub(tmpBinDir, { exitCode: 0, stdoutData: 'X', argsFile }); - setProcessPath(`${tmpBinDir}${path.delimiter}${originalPath}`); + const stub = await makeFFmpegStub(tmpBinDir, { exitCode: 0, stdoutData: 'X', argsFile }); + setFfmpegStub(stub, originalNodeOptions); await axios.get(`${baseUrl}/transcode/Antenna/OTA%20Channel`, { responseType: 'arraybuffer',