const WebSocket = require('ws'); const ffmpeg = require('fluent-ffmpeg'); const ffmpegPath = require('ffmpeg-static'); const db = require('../../db'); const fs = require('fs'); // Determine FFmpeg path: Prefer system install on Synology, fallback to static // Determine FFmpeg path: Prioritize Community packages (RTSP support) > System (limited) > Static let systemFfmpegPath = ffmpegPath; // Potential paths for fully-featured FFmpeg (SynoCommunity, etc.) // User 'find /usr' showed it's NOT in /usr/local/bin, so we must look in package dirs. const priorityPaths = [ // SynoCommunity or Package Center standard paths '/var/packages/ffmpeg/target/bin/ffmpeg', '/var/packages/ffmpeg7/target/bin/ffmpeg', '/var/packages/ffmpeg6/target/bin/ffmpeg', // Direct volume paths (common on Synology) '/volume1/@appstore/ffmpeg/bin/ffmpeg', '/volume1/@appstore/ffmpeg7/bin/ffmpeg', '/volume1/@appstore/ffmpeg6/bin/ffmpeg', // Standard Linux paths '/usr/local/bin/ffmpeg', '/usr/bin/ffmpeg', '/bin/ffmpeg' ]; let foundPath = false; for (const path of priorityPaths) { if (fs.existsSync(path)) { systemFfmpegPath = path; console.log(`[StreamRelay] Found FFmpeg at: ${path}`); // If we found a path that is NOT the system default (which we know is broken), likely we are good. // But even if it is system default, we use it if nothing else found. if (path !== '/usr/bin/ffmpeg' && path !== '/bin/ffmpeg') { console.log('[StreamRelay] Using generic/community FFmpeg (likely supports RTSP).'); } else { console.warn('[StreamRelay] WARNING: Using system FFmpeg. RTSP might fail ("Protocol not found").'); } foundPath = true; break; } } if (!foundPath) { console.warn('[StreamRelay] No FFmpeg binary found in priority paths. Falling back to ffmpeg-static or default PATH.'); } ffmpeg.setFfmpegPath(systemFfmpegPath); 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) { // url format: /api/stream?cameraId=1 or /api/stream/1 (if path matching works that way, but ws path is fixed) // Let's use query param: ws://host/api/stream?cameraId=1 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); } } } // Forcefully disconnect all clients to trigger reconnection resetStream(cameraId) { const stream = this.streams.get(cameraId); if (stream) { console.log(`Resetting stream for camera ${cameraId} (closing ${stream.clients.size} clients)...`); // Close all clients. This will trigger 'close' event on ws, // calling removeClient -> stopStream. for (const client of stream.clients) { if (client.readyState === WebSocket.OPEN) { client.close(1000, "Stream Reset"); } } // Ensure stream is stopped even if no clients existed 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]; // Construct RTSP URL 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})...`); const transportMode = camera.transport_mode === 'udp' ? 'udp' : 'tcp'; // Determine Quality Scaling let scaleFilter = []; let videoBitrate = '1000k'; const qual = camera.quality || 'low'; if (qual === 'low') { scaleFilter = ['-vf scale=640:-1']; videoBitrate = '1000k'; } else if (qual === 'medium') { scaleFilter = ['-vf scale=1280:-1']; videoBitrate = '2500k'; } else { // Original - Restore high bitrate as requested videoBitrate = '6000k'; } // Compatibility: Synology FFmpeg might not support -rtsp_transport flag or strict ordering // Try removing explicit transport mode if it fails, or rely on Auto. const inputOptions = []; if (transportMode !== 'auto') { // We restored this because we are now ensuring a proper FFmpeg version (v6/7 or static) is used. // REVERT: User reported local Windows issues with this flag enforced. // Commenting out to restore local functionality. Synology should still work via Auto or default. // inputOptions.push(`-rtsp_transport ${transportMode}`); } console.log(`[StreamRelay] Using FFmpeg binary: ${systemFfmpegPath}`); const command = ffmpeg(rtspUrl) .inputOptions(inputOptions) // .inputOptions([ // `-rtsp_transport ${transportMode}` // ]) .addOptions([ '-c:v mpeg1video', // Video codec for JSMpeg '-f mpegts', // Output format '-codec:a mp2', // Audio codec `-b:v ${videoBitrate}`, // Dynamic Video bitrate `-maxrate ${videoBitrate}`, // Cap max bitrate `-bufsize ${videoBitrate}`, '-r 30', // FPS 30 (Restored) '-g 30', // GOP size 30 (1 keyframe per sec) '-bf 0', // No B-frames ...scaleFilter ]) .on('start', (cmdLine) => { console.log('FFmpeg started:', cmdLine); }) .on('error', (err) => { // Only log if not killed manually (checking active stream usually tricky, but error msg usually enough) if (!err.message.includes('SIGKILL')) { console.error('FFmpeg error:', err.message); // If error occurs, maybe stop stream? // For reload, we might retry? simple stop for now. this.stopStream(cameraId); } }) .on('end', () => { console.log('FFmpeg exited'); // We don't indiscriminately stopStream here because 'reload' causes an exit too. // But 'stopStream' checks if process matches? // Let's rely on the process being replaced or nullified. }); 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;