627409cf56
- Refactor Frigate generator to support adding cameras to existing configs - Add text-based YAML parsing to preserve formatting and comments - Implement duplicate camera/stream name detection and auto-numbering - Add support for inserting cameras into existing go2rtc and cameras sections - Update UI: add textarea for existing config input and generate button - Preserve user's existing configuration when adding new cameras - Add example config template for new users - Update ConfigPanel to initialize Frigate tab instead of auto-generating - Add FrigateGenerator import to main.js - Add custom styles for Frigate config input and output sections - Support both empty config (create from scratch) and existing config (merge) modes Camera database updates: - Add OpenIPC firmware camera support (257 models) - Add Yi-Hack firmware variants (v4, v5, Allwinner, MStar) - Add Fang-Hacks firmware support - Add OpenMiko firmware support - Update Sonoff camera models - Update Thingino firmware camera models
412 lines
14 KiB
JavaScript
412 lines
14 KiB
JavaScript
/**
|
|
* Frigate NVR Configuration Generator
|
|
* Adds cameras to existing Frigate configuration
|
|
* Based on frigate-conf-generate logic
|
|
*/
|
|
export class FrigateGenerator {
|
|
/**
|
|
* Main entry point - generates config (new or adds to existing)
|
|
* @param {string} existingConfig - Existing YAML config text (or empty string)
|
|
* @param {Object} mainStream - Main stream object
|
|
* @param {Object} subStream - Optional sub stream object
|
|
* @returns {string} YAML configuration string
|
|
*/
|
|
static generate(existingConfig, mainStream, subStream = null) {
|
|
if (!existingConfig || existingConfig.trim() === '') {
|
|
// Create new config from scratch
|
|
return this.createNewConfig(mainStream, subStream);
|
|
}
|
|
|
|
// Add to existing config
|
|
return this.addToExistingConfig(existingConfig, mainStream, subStream);
|
|
}
|
|
|
|
/**
|
|
* Add camera to existing config (text-based, preserves everything)
|
|
*/
|
|
static addToExistingConfig(existingConfig, mainStream, subStream) {
|
|
const lines = existingConfig.split('\n');
|
|
|
|
// Find existing camera names and stream names to avoid duplicates
|
|
const existingCameras = this.findExistingCameras(lines);
|
|
const existingStreams = this.findExistingStreams(lines);
|
|
|
|
// Generate unique camera info
|
|
const cameraInfo = this.generateUniqueCameraInfo(mainStream, subStream, existingCameras, existingStreams);
|
|
|
|
// Find insertion points
|
|
const go2rtcStreamIndex = this.findGo2rtcStreamsInsertionPoint(lines);
|
|
const camerasInsertIndex = this.findCamerasInsertionPoint(lines);
|
|
|
|
if (go2rtcStreamIndex === -1 || camerasInsertIndex === -1) {
|
|
throw new Error('Could not find go2rtc streams or cameras section in config');
|
|
}
|
|
|
|
// Generate new stream lines
|
|
const streamLines = this.generateStreamLines(cameraInfo);
|
|
|
|
// Generate new camera lines
|
|
const cameraLines = this.generateCameraLines(cameraInfo);
|
|
|
|
// Insert streams into go2rtc section
|
|
lines.splice(go2rtcStreamIndex, 0, ...streamLines);
|
|
|
|
// Insert camera into cameras section (adjust index after first insertion)
|
|
const adjustedCameraIndex = camerasInsertIndex + streamLines.length;
|
|
lines.splice(adjustedCameraIndex, 0, ...cameraLines);
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
/**
|
|
* Find existing camera names
|
|
*/
|
|
static findExistingCameras(lines) {
|
|
const cameras = new Set();
|
|
let inCamerasSection = false;
|
|
|
|
for (const line of lines) {
|
|
if (line.match(/^cameras:/)) {
|
|
inCamerasSection = true;
|
|
continue;
|
|
}
|
|
|
|
if (inCamerasSection && line.match(/^[a-z]/)) {
|
|
break; // Next top-level section
|
|
}
|
|
|
|
if (inCamerasSection && line.match(/^\s{2}(\w+):/)) {
|
|
const match = line.match(/^\s{2}(\w+):/);
|
|
cameras.add(match[1]);
|
|
}
|
|
}
|
|
|
|
return cameras;
|
|
}
|
|
|
|
/**
|
|
* Find existing stream names
|
|
*/
|
|
static findExistingStreams(lines) {
|
|
const streams = new Set();
|
|
let inStreamsSection = false;
|
|
|
|
for (const line of lines) {
|
|
if (line.match(/^\s{2}streams:/)) {
|
|
inStreamsSection = true;
|
|
continue;
|
|
}
|
|
|
|
if (inStreamsSection && line.match(/^[a-z]/)) {
|
|
break; // Next top-level section
|
|
}
|
|
|
|
if (inStreamsSection && line.match(/^\s{4}'?(\w+)'?:/)) {
|
|
const match = line.match(/^\s{4}'?(\w+)'?:/);
|
|
streams.add(match[1]);
|
|
}
|
|
}
|
|
|
|
return streams;
|
|
}
|
|
|
|
/**
|
|
* Find where to insert new streams in go2rtc section
|
|
*/
|
|
static findGo2rtcStreamsInsertionPoint(lines) {
|
|
let inStreamsSection = false;
|
|
let lastStreamIndex = -1;
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
|
|
if (line.match(/^\s{2}streams:/)) {
|
|
inStreamsSection = true;
|
|
continue;
|
|
}
|
|
|
|
if (inStreamsSection) {
|
|
// Check if this is a stream definition or its content
|
|
if (line.match(/^\s{4,}/)) {
|
|
lastStreamIndex = i;
|
|
} else if (line.match(/^[a-z#]/)) {
|
|
// Found next section - insert before empty line if it exists
|
|
if (lastStreamIndex >= 0 && lines[lastStreamIndex + 1]?.trim() === '') {
|
|
return lastStreamIndex + 2; // After existing empty line
|
|
}
|
|
return lastStreamIndex + 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
return lastStreamIndex + 1;
|
|
}
|
|
|
|
/**
|
|
* Find where to insert new camera in cameras section
|
|
*/
|
|
static findCamerasInsertionPoint(lines) {
|
|
let inCamerasSection = false;
|
|
let lastCameraLineIndex = -1;
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
|
|
if (line.match(/^cameras:/)) {
|
|
inCamerasSection = true;
|
|
continue;
|
|
}
|
|
|
|
if (inCamerasSection) {
|
|
// Check if we're still in a camera definition
|
|
if (line.match(/^\s{2}\w+:/)) {
|
|
// New camera starting
|
|
lastCameraLineIndex = i;
|
|
} else if (line.match(/^\s{2,}\S/)) {
|
|
// Still inside camera definition
|
|
lastCameraLineIndex = i;
|
|
} else if (line.match(/^[a-z]/) && !line.match(/^cameras:/)) {
|
|
// Found next top-level section
|
|
// Skip any empty lines before it
|
|
let insertIndex = lastCameraLineIndex + 1;
|
|
while (insertIndex < lines.length && lines[insertIndex].trim() === '') {
|
|
insertIndex++;
|
|
}
|
|
return insertIndex;
|
|
} else if (line.match(/^version:/)) {
|
|
// Insert before version, skip empty lines
|
|
let insertIndex = i;
|
|
while (insertIndex > 0 && lines[insertIndex - 1].trim() === '') {
|
|
insertIndex--;
|
|
}
|
|
return insertIndex;
|
|
}
|
|
}
|
|
}
|
|
|
|
// If we reach end of file, insert at end
|
|
return lines.length;
|
|
}
|
|
|
|
/**
|
|
* Generate unique camera info avoiding duplicates
|
|
*/
|
|
static generateUniqueCameraInfo(mainStream, subStream, existingCameras, existingStreams) {
|
|
const ip = this.extractIP(mainStream.url);
|
|
const baseName = ip ? `camera_${ip.replace(/\./g, '_').replace(/:/g, '_')}` : 'camera';
|
|
const streamBaseName = ip ? ip.replace(/\./g, '_').replace(/:/g, '_') : 'stream';
|
|
|
|
// Find unique camera name
|
|
let cameraName = baseName;
|
|
let suffix = 0;
|
|
while (existingCameras.has(cameraName)) {
|
|
suffix++;
|
|
cameraName = `${baseName}_${suffix}`;
|
|
}
|
|
|
|
// Find unique stream names
|
|
let mainStreamName = `${streamBaseName}_main${suffix ? `_${suffix}` : ''}`;
|
|
while (existingStreams.has(mainStreamName)) {
|
|
suffix++;
|
|
mainStreamName = `${streamBaseName}_main_${suffix}`;
|
|
}
|
|
|
|
let subStreamName = null;
|
|
if (subStream) {
|
|
subStreamName = `${streamBaseName}_sub${suffix ? `_${suffix}` : ''}`;
|
|
while (existingStreams.has(subStreamName)) {
|
|
suffix++;
|
|
subStreamName = `${streamBaseName}_sub_${suffix}`;
|
|
}
|
|
}
|
|
|
|
return {
|
|
cameraName,
|
|
mainStreamName,
|
|
subStreamName,
|
|
mainStream,
|
|
subStream
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Generate stream lines for go2rtc section
|
|
*/
|
|
static generateStreamLines(cameraInfo) {
|
|
const lines = [];
|
|
|
|
// Add main stream
|
|
const mainSource = this.generateGo2RTCSource(cameraInfo.mainStream);
|
|
lines.push(` '${cameraInfo.mainStreamName}':`);
|
|
lines.push(` - ${mainSource}`);
|
|
|
|
// Add sub stream if provided
|
|
if (cameraInfo.subStream) {
|
|
lines.push('');
|
|
const subSource = this.generateGo2RTCSource(cameraInfo.subStream);
|
|
lines.push(` '${cameraInfo.subStreamName}':`);
|
|
lines.push(` - ${subSource}`);
|
|
}
|
|
|
|
lines.push('');
|
|
return lines;
|
|
}
|
|
|
|
/**
|
|
* Generate camera lines for cameras section
|
|
*/
|
|
static generateCameraLines(cameraInfo) {
|
|
const lines = [];
|
|
|
|
lines.push(` ${cameraInfo.cameraName}:`);
|
|
lines.push(' ffmpeg:');
|
|
lines.push(' inputs:');
|
|
|
|
if (cameraInfo.subStream) {
|
|
// Use sub for detect, main for record
|
|
lines.push(` - path: rtsp://127.0.0.1:8554/${cameraInfo.subStreamName}`);
|
|
lines.push(' input_args: preset-rtsp-restream');
|
|
lines.push(' roles:');
|
|
lines.push(' - detect');
|
|
lines.push(` - path: rtsp://127.0.0.1:8554/${cameraInfo.mainStreamName}`);
|
|
lines.push(' input_args: preset-rtsp-restream');
|
|
lines.push(' roles:');
|
|
lines.push(' - record');
|
|
|
|
// Add live view configuration
|
|
lines.push(' live:');
|
|
lines.push(' streams:');
|
|
lines.push(` Main Stream: ${cameraInfo.mainStreamName} # HD для просмотра`);
|
|
lines.push(` Sub Stream: ${cameraInfo.subStreamName} # Низкое разрешение (опционально)`);
|
|
} else {
|
|
// Use main for both detect and record
|
|
lines.push(` - path: rtsp://127.0.0.1:8554/${cameraInfo.mainStreamName}`);
|
|
lines.push(' input_args: preset-rtsp-restream');
|
|
lines.push(' roles:');
|
|
lines.push(' - detect');
|
|
lines.push(' - record');
|
|
}
|
|
|
|
// Add objects configuration
|
|
lines.push(' objects:');
|
|
lines.push(' track:');
|
|
lines.push(' - person');
|
|
lines.push(' - car');
|
|
lines.push(' - cat');
|
|
lines.push(' - dog');
|
|
|
|
// Add record configuration
|
|
lines.push(' record:');
|
|
lines.push(' enabled: true');
|
|
lines.push('');
|
|
|
|
return lines;
|
|
}
|
|
|
|
/**
|
|
* Create new configuration from scratch
|
|
*/
|
|
static createNewConfig(mainStream, subStream) {
|
|
const cameraInfo = this.generateUniqueCameraInfo(mainStream, subStream, new Set(), new Set());
|
|
const lines = [];
|
|
|
|
// MQTT
|
|
lines.push('mqtt:');
|
|
lines.push(' enabled: false');
|
|
lines.push('');
|
|
|
|
// Record
|
|
lines.push('# Global Recording Settings');
|
|
lines.push('record:');
|
|
lines.push(' enabled: true');
|
|
lines.push(' retain:');
|
|
lines.push(' days: 7');
|
|
lines.push(' mode: motion # Record only on motion detection');
|
|
lines.push('');
|
|
|
|
// Go2RTC
|
|
lines.push('# Go2RTC Configuration (Frigate built-in)');
|
|
lines.push('go2rtc:');
|
|
lines.push(' streams:');
|
|
lines.push(...this.generateStreamLines(cameraInfo));
|
|
|
|
// Cameras
|
|
lines.push('# Frigate Camera Configuration');
|
|
lines.push('cameras:');
|
|
lines.push(...this.generateCameraLines(cameraInfo));
|
|
|
|
// Version
|
|
lines.push('version: 0.16-0');
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
/**
|
|
* Extract IP address from URL
|
|
*/
|
|
static extractIP(url) {
|
|
try {
|
|
const urlObj = new URL(url);
|
|
return urlObj.hostname;
|
|
} catch (e) {
|
|
// Try to extract IP with regex
|
|
const match = url.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/);
|
|
return match ? match[1] : null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate Go2RTC source configuration based on stream type
|
|
*/
|
|
static generateGo2RTCSource(stream) {
|
|
// Handle JPEG snapshots with exec:ffmpeg conversion
|
|
if (stream.type === 'JPEG') {
|
|
return [
|
|
'exec:/usr/lib/ffmpeg/7.0/bin/ffmpeg',
|
|
'-loglevel quiet',
|
|
'-f image2',
|
|
'-loop 1',
|
|
'-framerate 10',
|
|
`-i ${stream.url}`,
|
|
'-c:v libx264',
|
|
'-preset ultrafast',
|
|
'-tune zerolatency',
|
|
'-g 20',
|
|
'-f rtsp {{output}}'
|
|
].join(' ');
|
|
}
|
|
|
|
// Handle ONVIF - convert to onvif:// format if needed
|
|
if (stream.type === 'ONVIF') {
|
|
try {
|
|
const urlObj = new URL(stream.url);
|
|
const username = urlObj.username || 'admin';
|
|
const password = urlObj.password || '';
|
|
const host = urlObj.hostname;
|
|
const port = urlObj.port || '80';
|
|
return `onvif://${username}:${password}@${host}:${port}`;
|
|
} catch (e) {
|
|
return stream.url;
|
|
}
|
|
}
|
|
|
|
// Handle BUBBLE protocol - convert to bubble:// format for go2rtc
|
|
if (stream.type === 'BUBBLE') {
|
|
try {
|
|
const urlObj = new URL(stream.url);
|
|
const username = urlObj.username || 'admin';
|
|
const password = urlObj.password || '';
|
|
const host = urlObj.hostname;
|
|
const port = urlObj.port || '80';
|
|
const path = urlObj.pathname + urlObj.search;
|
|
return `bubble://${username}:${password}@${host}:${port}${path}#video=copy`;
|
|
} catch (e) {
|
|
return stream.url;
|
|
}
|
|
}
|
|
|
|
// For all other types: use direct URL
|
|
return stream.url;
|
|
}
|
|
}
|