smart_ims/server/modules/cctv/streamRelay.js
2026-01-22 23:42:55 +09:00

249 lines
9.4 KiB
JavaScript

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;