ffmpeg-stream-manager
Version:
🎥 A powerful TypeScript library for managing multiple simultaneous RTMP streams using FFmpeg. Perfect for streaming to platforms like YouTube Live, Twitch, and others.
226 lines • 8.29 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.StreamProcess = void 0;
const child_process_1 = require("child_process");
const events_1 = require("events");
const types_1 = require("../types");
const ffmpeg_command_builder_1 = require("../utils/ffmpeg-command-builder");
class StreamProcess extends events_1.EventEmitter {
constructor(config, options = {}) {
super();
this.config = config;
this.ffmpegProcess = null;
this._state = types_1.StreamState.STOPPED;
this._restartCount = 0;
this.autoRestart = options.autoRestart ?? true;
this.restartDelay = options.restartDelay ?? 5000;
}
on(event, listener) {
return super.on(event, listener);
}
emit(event, ...args) {
return super.emit(event, ...args);
}
get state() {
return this._state;
}
get startTime() {
return this._startTime;
}
get uptime() {
if (!this._startTime || this._state !== types_1.StreamState.RUNNING) {
return 0;
}
return Math.floor((Date.now() - this._startTime.getTime()) / 1000);
}
get restartCount() {
return this._restartCount;
}
get lastError() {
return this._lastError;
}
get pid() {
return this.ffmpegProcess?.pid;
}
async start() {
if (this._state !== types_1.StreamState.STOPPED) {
throw new Error(`Cannot start stream in state: ${this._state}`);
}
try {
ffmpeg_command_builder_1.FFmpegCommandBuilder.validateConfig(this.config);
this.setState(types_1.StreamState.STARTING);
this.clearRestartTimeout();
const command = ffmpeg_command_builder_1.FFmpegCommandBuilder.buildCommand(this.config);
this.log('info', `Starting FFmpeg with command: ffmpeg ${command.join(' ')}`);
this.ffmpegProcess = (0, child_process_1.spawn)('ffmpeg', command, {
stdio: ['ignore', 'pipe', 'pipe'],
detached: false
});
this.setupProcessHandlers();
// Give the process a moment to start
await new Promise(resolve => setTimeout(resolve, 1000));
if (this.ffmpegProcess && !this.ffmpegProcess.killed) {
this._startTime = new Date();
this.setState(types_1.StreamState.RUNNING);
this.log('info', 'Stream started successfully');
}
else {
throw new Error('FFmpeg process failed to start');
}
}
catch (error) {
this._lastError = error instanceof Error ? error.message : 'Unknown error';
this.setState(types_1.StreamState.ERROR);
throw error;
}
}
async stop() {
if (this._state === types_1.StreamState.STOPPED) {
return;
}
this.clearRestartTimeout();
this.autoRestart = false; // Disable auto-restart when manually stopping
if (this.ffmpegProcess) {
this.log('info', 'Stopping stream...');
// Try graceful termination first
this.ffmpegProcess.kill('SIGTERM');
// Force kill after 5 seconds if still running
setTimeout(() => {
if (this.ffmpegProcess && !this.ffmpegProcess.killed) {
this.log('warning', 'Force killing FFmpeg process');
this.ffmpegProcess.kill('SIGKILL');
}
}, 5000);
}
this.setState(types_1.StreamState.STOPPED);
this._startTime = undefined;
}
async restart() {
this.log('info', 'Restarting stream...');
this.setState(types_1.StreamState.RESTARTING);
await this.stop();
// Wait a bit before restarting
await new Promise(resolve => setTimeout(resolve, this.restartDelay));
this._restartCount++;
this.emit('restart');
await this.start();
}
updateConfig(newConfig) {
if (this._state === types_1.StreamState.RUNNING) {
throw new Error('Cannot update config while stream is running. Stop the stream first.');
}
ffmpeg_command_builder_1.FFmpegCommandBuilder.validateConfig(newConfig);
this.config = { ...newConfig };
this.log('info', 'Stream configuration updated');
}
getConfig() {
return { ...this.config };
}
setState(newState) {
if (this._state !== newState) {
const oldState = this._state;
this._state = newState;
this.log('info', `State changed from ${oldState} to ${newState}`);
this.emit('state-change', newState);
}
}
setupProcessHandlers() {
if (!this.ffmpegProcess)
return;
// Handle stdout
this.ffmpegProcess.stdout?.on('data', (data) => {
const message = data.toString().trim();
if (message) {
this.log('info', message, 'stdout');
}
});
// Handle stderr (FFmpeg writes most output to stderr)
this.ffmpegProcess.stderr?.on('data', (data) => {
const message = data.toString().trim();
if (message) {
// Parse FFmpeg output to determine if it's an error
const isError = this.isErrorMessage(message);
this.log(isError ? 'error' : 'info', message, 'stderr');
}
});
// Handle process exit
this.ffmpegProcess.on('exit', (code, signal) => {
this.log('info', `FFmpeg process exited with code ${code}, signal ${signal}`);
if (code !== 0 && code !== null) {
this._lastError = `FFmpeg exited with code ${code}`;
this.setState(types_1.StreamState.ERROR);
}
else {
this.setState(types_1.StreamState.STOPPED);
}
this.ffmpegProcess = null;
this._startTime = undefined;
// Auto-restart if enabled and not manually stopped
if (this.autoRestart && this._state === types_1.StreamState.ERROR) {
this.scheduleRestart();
}
});
// Handle process errors
this.ffmpegProcess.on('error', (error) => {
this._lastError = error.message;
this.log('error', `FFmpeg process error: ${error.message}`);
this.setState(types_1.StreamState.ERROR);
this.emit('error', error);
});
}
isErrorMessage(message) {
const errorIndicators = [
'error',
'failed',
'cannot',
'unable',
'invalid',
'not found',
'permission denied',
'connection refused'
];
return errorIndicators.some(indicator => message.toLowerCase().includes(indicator));
}
scheduleRestart() {
if (this.restartTimeout) {
clearTimeout(this.restartTimeout);
}
this.log('info', `Scheduling restart in ${this.restartDelay}ms`);
this.restartTimeout = setTimeout(async () => {
try {
await this.restart();
}
catch (error) {
this.log('error', `Restart failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
// Schedule another restart
this.scheduleRestart();
}
}, this.restartDelay);
}
clearRestartTimeout() {
if (this.restartTimeout) {
clearTimeout(this.restartTimeout);
this.restartTimeout = undefined;
}
}
log(level, message, source = 'stdout') {
const logEntry = {
streamId: this.config.id,
timestamp: new Date(),
level,
message,
source
};
this.emit('log', logEntry);
}
// Cleanup resources
destroy() {
this.clearRestartTimeout();
this.removeAllListeners();
if (this.ffmpegProcess && !this.ffmpegProcess.killed) {
this.ffmpegProcess.kill('SIGKILL');
}
}
}
exports.StreamProcess = StreamProcess;
//# sourceMappingURL=stream-process.js.map