smart_ims/server/modules/cctv/streamRelay.js
2026-01-23 16:52:14 +09:00

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;