249 lines
9.4 KiB
JavaScript
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;
|