223 lines
8.3 KiB
JavaScript
223 lines
8.3 KiB
JavaScript
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 = `SmartIMS-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;
|