Add dual-stream support for Frigate with optional sub-stream selection
Features: - Optional sub-stream selection from already discovered streams - No additional scanning required - reuse existing results - UI: "Add Sub Stream" button to select secondary stream - UI: "Remove Sub Stream" button to clear selection - Smart stream routing in Frigate configs - Go2RTC: generates _main and _sub stream names - Frigate: detect on sub (CPU efficient), record on main (quality) - Frigate: auto-detection of stream resolution - Object detection: person, car, cat, dog - Motion-based recording by default - Live view streams configuration - Support for any resolution: HD, 4K, 8K+ - Comprehensive documentation with examples
This commit is contained in:
@@ -1,41 +1,158 @@
|
||||
/**
|
||||
* Frigate NVR Configuration Generator
|
||||
* Generates unified Frigate + Go2RTC YAML configs
|
||||
* All cameras are routed through Frigate's built-in go2rtc for optimal performance
|
||||
*/
|
||||
export class FrigateGenerator {
|
||||
static generate(stream) {
|
||||
// For non-RTSP streams, suggest using Go2RTC
|
||||
if (stream.type !== 'FFMPEG' || stream.protocol !== 'rtsp') {
|
||||
return `# This stream type requires Go2RTC proxy\n\n` +
|
||||
`# This ${stream.type} stream is not natively supported by Frigate.\n` +
|
||||
`# Please use Go2RTC to convert it to RTSP first.\n\n` +
|
||||
`# Steps:\n` +
|
||||
`# 1. Add this stream to your Go2RTC configuration\n` +
|
||||
`# 2. Use the Go2RTC RTSP endpoint in Frigate\n` +
|
||||
`# 3. Example: rtsp://localhost:8554/camera_stream_0`;
|
||||
}
|
||||
|
||||
// Generate RTSP config for Frigate
|
||||
const cameraName = this.generateCameraName(stream);
|
||||
/**
|
||||
* Generate complete Frigate config with embedded Go2RTC
|
||||
* @param {Object} mainStream - Main stream object (used for recording)
|
||||
* @param {Object} subStream - Optional sub stream object (used for detection if provided)
|
||||
* @returns {string} YAML configuration string
|
||||
*/
|
||||
static generate(mainStream, subStream = null) {
|
||||
const cameraName = this.generateCameraName(mainStream);
|
||||
const config = [];
|
||||
|
||||
config.push(`cameras:`);
|
||||
config.push(` ${cameraName}:`);
|
||||
config.push(` ffmpeg:`);
|
||||
config.push(` inputs:`);
|
||||
config.push(` - path: ${stream.url}`);
|
||||
config.push(` roles:`);
|
||||
config.push(` - detect`);
|
||||
config.push(` - record`);
|
||||
// MQTT Configuration
|
||||
config.push('mqtt:');
|
||||
config.push(' enabled: false');
|
||||
config.push('');
|
||||
|
||||
if (stream.resolution) {
|
||||
config.push(` detect:`);
|
||||
const [width, height] = stream.resolution.split('x').map(Number);
|
||||
if (width && height) {
|
||||
config.push(` width: ${width}`);
|
||||
config.push(` height: ${height}`);
|
||||
}
|
||||
// Global Record Configuration
|
||||
config.push('# Global Recording Settings');
|
||||
config.push('record:');
|
||||
config.push(' enabled: true');
|
||||
config.push(' retain:');
|
||||
config.push(' days: 7');
|
||||
config.push(' mode: motion # Record only on motion detection');
|
||||
config.push('');
|
||||
|
||||
// Generate Go2RTC section
|
||||
config.push('# Go2RTC Configuration (Frigate built-in)');
|
||||
config.push('go2rtc:');
|
||||
config.push(' streams:');
|
||||
|
||||
// Main stream configuration
|
||||
const mainStreamName = this.generateStreamName(mainStream, 'main');
|
||||
const mainSource = this.generateGo2RTCSource(mainStream);
|
||||
config.push(` '${mainStreamName}':`);
|
||||
config.push(` - ${mainSource}`);
|
||||
|
||||
// Sub stream configuration if provided
|
||||
if (subStream) {
|
||||
config.push('');
|
||||
const subStreamName = this.generateStreamName(subStream, 'sub');
|
||||
const subSource = this.generateGo2RTCSource(subStream);
|
||||
config.push(` '${subStreamName}':`);
|
||||
config.push(` - ${subSource}`);
|
||||
}
|
||||
|
||||
config.push('');
|
||||
|
||||
// Generate Frigate cameras section
|
||||
config.push('# Frigate Camera Configuration');
|
||||
config.push('cameras:');
|
||||
config.push(` ${cameraName}:`);
|
||||
config.push(' ffmpeg:');
|
||||
config.push(' inputs:');
|
||||
|
||||
if (subStream) {
|
||||
// If sub stream exists: use it for detection, main for recording
|
||||
const subStreamName = this.generateStreamName(subStream, 'sub');
|
||||
config.push(` - path: rtsp://127.0.0.1:8554/${subStreamName}`);
|
||||
config.push(' input_args: preset-rtsp-restream');
|
||||
config.push(' roles:');
|
||||
config.push(' - detect');
|
||||
config.push(` - path: rtsp://127.0.0.1:8554/${mainStreamName}`);
|
||||
config.push(' input_args: preset-rtsp-restream');
|
||||
config.push(' roles:');
|
||||
config.push(' - record');
|
||||
} else {
|
||||
// No sub stream: use main for both detection and recording
|
||||
config.push(` - path: rtsp://127.0.0.1:8554/${mainStreamName}`);
|
||||
config.push(' input_args: preset-rtsp-restream');
|
||||
config.push(' roles:');
|
||||
config.push(' - detect');
|
||||
config.push(' - record');
|
||||
}
|
||||
|
||||
// Live view configuration
|
||||
if (subStream) {
|
||||
config.push(' live:');
|
||||
config.push(' streams:');
|
||||
config.push(` Main Stream: ${mainStreamName} # HD для просмотра`);
|
||||
config.push(` Sub Stream: ${this.generateStreamName(subStream, 'sub')} # Низкое разрешение (опционально)`);
|
||||
}
|
||||
|
||||
// Object detection configuration
|
||||
config.push(' objects:');
|
||||
config.push(' track:');
|
||||
config.push(' - person');
|
||||
config.push(' - car');
|
||||
config.push(' - cat');
|
||||
config.push(' - dog');
|
||||
|
||||
// Recording configuration
|
||||
config.push(' record:');
|
||||
config.push(' enabled: true');
|
||||
|
||||
config.push('');
|
||||
config.push('version: 0.16-0');
|
||||
|
||||
return config.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Go2RTC source configuration based on stream type
|
||||
* Returns the source string for go2rtc streams section
|
||||
*/
|
||||
static generateGo2RTCSource(stream) {
|
||||
// Handle JPEG snapshots with exec:ffmpeg conversion
|
||||
// Uses full path to ffmpeg and {{output}} for Frigate template escaping
|
||||
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}}' // Double braces for Frigate template escaping
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
// Handle ONVIF - convert to onvif:// format if needed
|
||||
if (stream.type === 'ONVIF') {
|
||||
try {
|
||||
const urlObj = new URL(stream.url);
|
||||
// Extract credentials and host from HTTP URL
|
||||
const username = urlObj.username || 'admin';
|
||||
const password = urlObj.password || '';
|
||||
const host = urlObj.hostname;
|
||||
const port = urlObj.port || '80';
|
||||
|
||||
// Generate onvif:// URL
|
||||
return `onvif://${username}:${password}@${host}:${port}`;
|
||||
} catch (e) {
|
||||
// If URL parsing fails, return as-is
|
||||
return stream.url;
|
||||
}
|
||||
}
|
||||
|
||||
// For all other types (RTSP, MJPEG, HLS, HTTP-FLV, RTMP, etc.): use direct URL
|
||||
// Go2RTC handles these formats natively
|
||||
return stream.url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate camera name from IP address
|
||||
* Format: "camera_192_168_1_100"
|
||||
*/
|
||||
static generateCameraName(stream) {
|
||||
try {
|
||||
const urlObj = new URL(stream.url);
|
||||
@@ -45,4 +162,18 @@ export class FrigateGenerator {
|
||||
return 'camera';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate stream name for Go2RTC reference
|
||||
* Format: "192_168_1_100_main" or "192_168_1_100_sub"
|
||||
*/
|
||||
static generateStreamName(stream, suffix) {
|
||||
try {
|
||||
const urlObj = new URL(stream.url);
|
||||
const ip = urlObj.hostname.replace(/\./g, '_').replace(/:/g, '_');
|
||||
return `${ip}_${suffix}`;
|
||||
} catch (e) {
|
||||
return `camera_stream_${suffix}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +1,80 @@
|
||||
/**
|
||||
* Go2RTC Configuration Generator
|
||||
* Generates proper go2rtc YAML configs based on stream type
|
||||
* Following go2rtc documentation and best practices
|
||||
*/
|
||||
export class Go2RTCGenerator {
|
||||
static generate(stream) {
|
||||
const streamName = this.generateStreamName(stream);
|
||||
/**
|
||||
* Generate go2rtc config for streams (main + optional sub)
|
||||
* @param {Object} mainStream - Main stream object with type, protocol, and url
|
||||
* @param {Object} subStream - Optional sub stream object
|
||||
* @returns {string} YAML configuration string
|
||||
*/
|
||||
static generate(mainStream, subStream = null) {
|
||||
const configs = [];
|
||||
configs.push('streams:');
|
||||
|
||||
switch (stream.type) {
|
||||
case 'FFMPEG':
|
||||
if (stream.protocol === 'rtsp') {
|
||||
return this.generateRTSP(streamName, stream);
|
||||
}
|
||||
break;
|
||||
case 'JPEG':
|
||||
return this.generateJPEG(streamName, stream);
|
||||
case 'MJPEG':
|
||||
return this.generateMJPEG(streamName, stream);
|
||||
case 'HTTP_VIDEO':
|
||||
return this.generateHTTPVideo(streamName, stream);
|
||||
case 'HLS':
|
||||
return this.generateHLS(streamName, stream);
|
||||
case 'ONVIF':
|
||||
return `# ONVIF Device Service\n# This is a device management endpoint, not a stream\n# URL: ${stream.url}`;
|
||||
default:
|
||||
return this.generateRTSP(streamName, stream);
|
||||
// Generate main stream config
|
||||
const mainStreamName = this.generateStreamName(mainStream, 'main');
|
||||
const mainSource = this.generateSource(mainStream);
|
||||
configs.push(` '${mainStreamName}':`);
|
||||
configs.push(` - ${mainSource}`);
|
||||
|
||||
// Generate sub stream config if provided
|
||||
if (subStream) {
|
||||
configs.push('');
|
||||
const subStreamName = this.generateStreamName(subStream, 'sub');
|
||||
const subSource = this.generateSource(subStream);
|
||||
configs.push(` '${subStreamName}':`);
|
||||
configs.push(` - ${subSource}`);
|
||||
}
|
||||
|
||||
return configs.join('\n');
|
||||
}
|
||||
|
||||
static generateStreamName(stream) {
|
||||
/**
|
||||
* Generate stream name from IP address with suffix
|
||||
* Format: "192_168_1_100_main" or "192_168_1_100_sub"
|
||||
*/
|
||||
static generateStreamName(stream, suffix) {
|
||||
try {
|
||||
const urlObj = new URL(stream.url);
|
||||
const ip = urlObj.hostname.replace(/\./g, '_').replace(/:/g, '_');
|
||||
return `${ip}_0`;
|
||||
return `${ip}_${suffix}`;
|
||||
} catch (e) {
|
||||
return 'camera_stream_0';
|
||||
return `camera_stream_${suffix}`;
|
||||
}
|
||||
}
|
||||
|
||||
static generateRTSP(streamName, stream) {
|
||||
return `streams:\n '${streamName}':\n - ${stream.url}`;
|
||||
/**
|
||||
* Generate source configuration based on stream type
|
||||
*/
|
||||
static generateSource(stream) {
|
||||
// Handle JPEG snapshots with special exec:ffmpeg conversion
|
||||
if (stream.type === 'JPEG') {
|
||||
return this.generateJPEGSource(stream);
|
||||
}
|
||||
|
||||
// Handle ONVIF
|
||||
if (stream.type === 'ONVIF') {
|
||||
return this.generateONVIFSource(stream);
|
||||
}
|
||||
|
||||
// For all other types: use direct URL
|
||||
return stream.url;
|
||||
}
|
||||
|
||||
static generateJPEG(streamName, stream) {
|
||||
const framerate = 10;
|
||||
const ffmpegCmd = [
|
||||
/**
|
||||
* Generate JPEG snapshot conversion using exec:ffmpeg
|
||||
* Converts static JPEG to RTSP stream with H264 encoding
|
||||
*/
|
||||
static generateJPEGSource(stream) {
|
||||
return [
|
||||
'exec:ffmpeg',
|
||||
'-loglevel quiet',
|
||||
'-f image2',
|
||||
'-loop 1',
|
||||
`-framerate ${framerate}`,
|
||||
'-framerate 10',
|
||||
`-i ${stream.url}`,
|
||||
'-c:v libx264',
|
||||
'-preset ultrafast',
|
||||
@@ -52,36 +82,22 @@ export class Go2RTCGenerator {
|
||||
'-g 20',
|
||||
'-f rtsp {output}'
|
||||
].join(' ');
|
||||
|
||||
return `streams:\n '${streamName}':\n - ${ffmpegCmd}`;
|
||||
}
|
||||
|
||||
static generateMJPEG(streamName, stream) {
|
||||
const ffmpegCmd = [
|
||||
'exec:ffmpeg',
|
||||
'-loglevel quiet',
|
||||
`-i ${stream.url}`,
|
||||
'-c:v copy',
|
||||
'-f rtsp {output}'
|
||||
].join(' ');
|
||||
|
||||
return `streams:\n '${streamName}':\n - ${ffmpegCmd}`;
|
||||
}
|
||||
|
||||
static generateHTTPVideo(streamName, stream) {
|
||||
const ffmpegCmd = [
|
||||
'exec:ffmpeg',
|
||||
'-loglevel quiet',
|
||||
`-i ${stream.url}`,
|
||||
'-c:v copy',
|
||||
'-c:a copy',
|
||||
'-f rtsp {output}'
|
||||
].join(' ');
|
||||
|
||||
return `streams:\n '${streamName}':\n - ${ffmpegCmd}`;
|
||||
}
|
||||
|
||||
static generateHLS(streamName, stream) {
|
||||
return `streams:\n '${streamName}':\n - ${stream.url}`;
|
||||
/**
|
||||
* Generate ONVIF source
|
||||
* Converts HTTP device service endpoint to onvif:// format
|
||||
*/
|
||||
static generateONVIFSource(stream) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+79
-10
@@ -16,7 +16,9 @@ class StrixApp {
|
||||
|
||||
this.currentAddress = '';
|
||||
this.currentStreams = [];
|
||||
this.currentStream = null;
|
||||
this.selectedMainStream = null;
|
||||
this.selectedSubStream = null;
|
||||
this.isSelectingSubStream = false;
|
||||
|
||||
this.init();
|
||||
}
|
||||
@@ -94,11 +96,16 @@ class StrixApp {
|
||||
|
||||
// Screen 4: Configuration output
|
||||
document.getElementById('btn-back-to-streams').addEventListener('click', () => {
|
||||
this.isSelectingSubStream = false;
|
||||
this.showScreen('discovery');
|
||||
});
|
||||
|
||||
document.getElementById('btn-copy-config').addEventListener('click', () => this.copyConfig());
|
||||
document.getElementById('btn-download-config').addEventListener('click', () => this.downloadConfig());
|
||||
|
||||
document.getElementById('btn-add-sub-stream').addEventListener('click', () => this.addSubStream());
|
||||
document.getElementById('btn-remove-sub').addEventListener('click', () => this.removeSubStream());
|
||||
|
||||
document.getElementById('btn-new-search').addEventListener('click', () => {
|
||||
this.reset();
|
||||
this.showScreen('address');
|
||||
@@ -171,9 +178,16 @@ class StrixApp {
|
||||
async searchCameraModels(query, limit = 10, append = false) {
|
||||
const dropdown = document.getElementById('autocomplete-dropdown');
|
||||
|
||||
// Keep dropdown open and show loading state smoothly
|
||||
if (!append) {
|
||||
dropdown.innerHTML = '<div class="autocomplete-loading">Searching...</div>';
|
||||
dropdown.classList.remove('hidden');
|
||||
const isOpen = !dropdown.classList.contains('hidden');
|
||||
if (!isOpen) {
|
||||
dropdown.classList.remove('hidden');
|
||||
}
|
||||
// Show loading only if dropdown was empty or closed
|
||||
if (!isOpen || dropdown.children.length === 0) {
|
||||
dropdown.innerHTML = '<div class="autocomplete-loading">Searching...</div>';
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -316,9 +330,52 @@ class StrixApp {
|
||||
}
|
||||
|
||||
selectStream(stream, index) {
|
||||
this.currentStream = stream;
|
||||
this.configPanel.render(stream);
|
||||
this.showScreen('output');
|
||||
if (!this.isSelectingSubStream) {
|
||||
// Selecting main stream
|
||||
this.selectedMainStream = stream;
|
||||
this.selectedSubStream = null;
|
||||
this.configPanel.render(this.selectedMainStream, this.selectedSubStream);
|
||||
this.updateSubStreamUI();
|
||||
this.showScreen('output');
|
||||
} else {
|
||||
// Selecting sub stream
|
||||
this.selectedSubStream = stream;
|
||||
this.isSelectingSubStream = false;
|
||||
this.configPanel.render(this.selectedMainStream, this.selectedSubStream);
|
||||
this.updateSubStreamUI();
|
||||
this.showScreen('output');
|
||||
}
|
||||
}
|
||||
|
||||
addSubStream() {
|
||||
if (this.currentStreams.length === 0) {
|
||||
showToast('No streams available to select');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isSelectingSubStream = true;
|
||||
showToast('Select a sub stream from available streams');
|
||||
this.showScreen('discovery');
|
||||
}
|
||||
|
||||
removeSubStream() {
|
||||
this.selectedSubStream = null;
|
||||
this.configPanel.render(this.selectedMainStream, this.selectedSubStream);
|
||||
this.updateSubStreamUI();
|
||||
showToast('Sub stream removed');
|
||||
}
|
||||
|
||||
updateSubStreamUI() {
|
||||
const subStreamInfo = document.getElementById('sub-stream-info');
|
||||
const addSubStreamBtn = document.getElementById('btn-add-sub-stream');
|
||||
|
||||
if (this.selectedSubStream) {
|
||||
subStreamInfo.classList.remove('hidden');
|
||||
addSubStreamBtn.style.display = 'none';
|
||||
} else {
|
||||
subStreamInfo.classList.add('hidden');
|
||||
addSubStreamBtn.style.display = 'inline-flex';
|
||||
}
|
||||
}
|
||||
|
||||
switchTab(tabName) {
|
||||
@@ -336,12 +393,22 @@ class StrixApp {
|
||||
const configElement = document.getElementById(`config-${activeTab}`);
|
||||
const text = configElement.textContent;
|
||||
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.left = '-9999px';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
showToast('Copied to clipboard!');
|
||||
}).catch(err => {
|
||||
} catch (err) {
|
||||
showToast('Failed to copy');
|
||||
console.error('Copy error:', err);
|
||||
});
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
|
||||
downloadConfig() {
|
||||
@@ -364,7 +431,9 @@ class StrixApp {
|
||||
reset() {
|
||||
this.currentAddress = '';
|
||||
this.currentStreams = [];
|
||||
this.currentStream = null;
|
||||
this.selectedMainStream = null;
|
||||
this.selectedSubStream = null;
|
||||
this.isSelectingSubStream = false;
|
||||
|
||||
document.getElementById('network-address').value = '';
|
||||
document.getElementById('camera-model').value = '';
|
||||
|
||||
@@ -3,20 +3,28 @@ import { FrigateGenerator } from '../config-generators/frigate/index.js';
|
||||
|
||||
export class ConfigPanel {
|
||||
constructor() {
|
||||
this.stream = null;
|
||||
this.mainStream = null;
|
||||
this.subStream = null;
|
||||
}
|
||||
|
||||
render(stream) {
|
||||
this.stream = stream;
|
||||
render(mainStream, subStream = null) {
|
||||
this.mainStream = mainStream;
|
||||
this.subStream = subStream;
|
||||
|
||||
// Update selected stream info
|
||||
document.getElementById('selected-stream-type').textContent = stream.type;
|
||||
document.getElementById('selected-stream-url').textContent = this.maskCredentials(stream.url);
|
||||
// Update main stream info
|
||||
document.getElementById('selected-main-type').textContent = mainStream.type;
|
||||
document.getElementById('selected-main-url').textContent = this.maskCredentials(mainStream.url);
|
||||
|
||||
// Update sub stream info if provided
|
||||
if (subStream) {
|
||||
document.getElementById('selected-sub-type').textContent = subStream.type;
|
||||
document.getElementById('selected-sub-url').textContent = this.maskCredentials(subStream.url);
|
||||
}
|
||||
|
||||
// Generate configs
|
||||
const urlConfig = stream.url;
|
||||
const go2rtcConfig = Go2RTCGenerator.generate(stream);
|
||||
const frigateConfig = FrigateGenerator.generate(stream);
|
||||
const urlConfig = this.generateURLConfig();
|
||||
const go2rtcConfig = Go2RTCGenerator.generate(mainStream, subStream);
|
||||
const frigateConfig = FrigateGenerator.generate(mainStream, subStream);
|
||||
|
||||
// Update config displays
|
||||
document.getElementById('config-url').textContent = urlConfig;
|
||||
@@ -24,6 +32,13 @@ export class ConfigPanel {
|
||||
document.getElementById('config-frigate').textContent = frigateConfig;
|
||||
}
|
||||
|
||||
generateURLConfig() {
|
||||
if (this.subStream) {
|
||||
return `Main Stream:\n${this.mainStream.url}\n\nSub Stream:\n${this.subStream.url}`;
|
||||
}
|
||||
return this.mainStream.url;
|
||||
}
|
||||
|
||||
maskCredentials(url) {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
|
||||
Reference in New Issue
Block a user