const WebSocket = require('ws'); const ffmpeg = require('fluent-ffmpeg'); const staticFfmpegBinary = require('ffmpeg-static'); // Renamed for clarity const db = require('../../db'); const fs = require('fs'); // We don't set global ffmpeg path here immediately because we might want to switch per-stream or check system paths dynamically // (though fluent-ffmpeg usage usually suggests setting it once if possible, but we check inside startFFmpeg for robustness) class StreamRelay { constructor(server) { this.wss = new WebSocket.Server({ server, path: '/api/stream' }); this.streams = new Map(); // cameraId -> { process, clients: Set } this.wss.on('connection', (ws, req) => { const cameraId = this.parseCameraId(req.url); if (!cameraId) { ws.close(); return; } console.log(`Client connected to stream for camera ${cameraId}`); this.addClient(cameraId, ws); ws.on('close', () => { console.log(`Client disconnected from stream for camera ${cameraId}`); this.removeClient(cameraId, ws); }); }); } parseCameraId(url) { try { const params = new URLSearchParams(url.split('?')[1]); return params.get('cameraId'); } catch (e) { return null; } } async addClient(cameraId, ws) { let stream = this.streams.get(cameraId); if (!stream) { stream = { clients: new Set(), process: null }; this.streams.set(cameraId, stream); await this.startFFmpeg(cameraId); } stream.clients.add(ws); } removeClient(cameraId, ws) { const stream = this.streams.get(cameraId); if (stream) { stream.clients.delete(ws); if (stream.clients.size === 0) { this.stopStream(cameraId); } } } resetStream(cameraId) { const stream = this.streams.get(cameraId); if (stream) { console.log(`Resetting stream for camera ${cameraId} (closing ${stream.clients.size} clients)...`); for (const client of stream.clients) { if (client.readyState === WebSocket.OPEN) { client.close(1000, "Stream Reset"); } } this.stopStream(cameraId); } } async startFFmpeg(cameraId) { const stream = this.streams.get(cameraId); if (!stream) return; try { const [rows] = await db.query('SELECT * FROM camera_settings WHERE id = ?', [cameraId]); if (rows.length === 0) { console.error(`Camera ${cameraId} not found`); return; } const camera = rows[0]; // Check if streaming is enabled // Ignore if IGNORE_CAMERA_STATUS is explicitly 'true' (for local dev) if ((camera.is_active === 0 || camera.is_active === false)) { if (process.env.IGNORE_CAMERA_STATUS === 'true') { console.log(`[DEV OVERRIDE] Streaming disabled in DB for camera ${cameraId}, but forced ON by IGNORE_CAMERA_STATUS.`); } else { console.log(`Streaming is disabled for camera ${cameraId} (${camera.name}). skipping FFmpeg start.`); return; } } let rtspUrl = 'rtsp://'; if (camera.username && camera.password) { const user = camera.rtsp_encoding ? encodeURIComponent(camera.username) : camera.username; const pass = camera.rtsp_encoding ? encodeURIComponent(camera.password) : camera.password; rtspUrl += `${user}:${pass}@`; } rtspUrl += `${camera.ip_address}:${camera.port || 554}${camera.stream_path || '/stream1'}`; console.log(`Starting FFmpeg for camera ${cameraId} (${camera.name})...`); // 1. Determine FFmpeg Binary Path (v1.0.8 Fix) let selectedFfmpegPath; if (process.env.FORCE_STATIC_FFMPEG === 'true') { selectedFfmpegPath = staticFfmpegBinary; } else { const systemFfmpegPath = '/var/packages/ffmpeg7/target/bin/ffmpeg'; // Check system paths, priority to Synology packages, fallback to static if (fs.existsSync(systemFfmpegPath)) { selectedFfmpegPath = systemFfmpegPath; } else if (fs.existsSync('/var/packages/ffmpeg6/target/bin/ffmpeg')) { selectedFfmpegPath = '/var/packages/ffmpeg6/target/bin/ffmpeg'; } else { selectedFfmpegPath = staticFfmpegBinary; } } // Set for this command (Note: fluent-ffmpeg setFfmpegPath is global, but usually fine if concurrent calls settle on same binary or if we just set it before command) ffmpeg.setFfmpegPath(selectedFfmpegPath); // 2. Transport Mode const transportMode = process.env.CCTV_TRANSPORT_OVERRIDE || camera.transport_mode || 'tcp'; // 3. Unique User-Agent to prevent session conflicts const userAgent = `SmartAsset-Relay-v1.0.8-${transportMode}`; // Quality Scaling let scaleFilter = []; let videoBitrate = '1000k'; const qual = camera.quality || 'low'; if (qual === 'low') { scaleFilter = ['-vf scale=1280:-1']; // Using 720p even for low for better visibility, bitrate controls size videoBitrate = '2000k'; } else if (qual === 'medium') { scaleFilter = ['-vf scale=1280:-1']; videoBitrate = '4000k'; } else { videoBitrate = '6000k'; } const inputOptions = [ '-user_agent', userAgent ]; if (transportMode !== 'auto') { inputOptions.push('-rtsp_transport', transportMode); } console.log(`[StreamRelay v1.0.8] Using FFmpeg binary: ${selectedFfmpegPath}`); console.log(`[StreamRelay v1.0.10] Transport Mode: ${transportMode} (Override: ${process.env.CCTV_TRANSPORT_OVERRIDE || 'none'})`); console.log(`[StreamRelay v1.0.8] User-Agent: ${userAgent}`); console.log(`[StreamRelay v1.0.8] Input Options:`, inputOptions); const command = ffmpeg(rtspUrl); if (inputOptions.length > 0) { command.inputOptions(inputOptions); } command.addOptions([ '-c:v mpeg1video', '-f mpegts', '-codec:a mp2', `-b:v ${videoBitrate}`, `-maxrate ${videoBitrate}`, `-bufsize ${videoBitrate}`, '-r 30', '-g 30', '-bf 0', ...scaleFilter ]) .on('start', (cmdLine) => { console.log('FFmpeg started:', cmdLine); }) .on('error', (err) => { if (!err.message.includes('SIGKILL')) { console.error('FFmpeg error:', err.message); this.stopStream(cameraId); } }) .on('end', () => { console.log('FFmpeg exited'); }); stream.process = command; const ffstream = command.pipe(); ffstream.on('data', (data) => { stream.clients.forEach(client => { if (client.readyState === WebSocket.OPEN) { client.send(data); } }); }); } catch (err) { console.error('Failed to start stream:', err); this.stopStream(cameraId); } } stopStream(cameraId) { const stream = this.streams.get(cameraId); if (stream) { console.log(`Stopping stream for camera ${cameraId}`); if (stream.process) { try { stream.process.kill('SIGKILL'); } catch (e) { console.error('Error killing ffmpeg:', e); } } this.streams.delete(cameraId); } } } module.exports = StreamRelay;