Add go2rtc streams to settings UI (#22531)

* Add go2rtc settings section

- create separate settings section for all go2rtc streams
- extract credentials mask code into util
- create ffmpeg module utility
- i18n

* add camera config updater topic for live section

to support adding go2rtc streams after configuring a new one via the UI

* clean up

* tweak delete button color for consistency

* tweaks
This commit is contained in:
Josh Hawkins 2026-03-19 11:33:42 -05:00 committed by GitHub
parent c93dad9bd9
commit e2bfa26719
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 1246 additions and 43 deletions

View File

@ -18,6 +18,7 @@ class CameraConfigUpdateEnum(str, Enum):
detect = "detect"
enabled = "enabled"
ffmpeg = "ffmpeg"
live = "live"
motion = "motion" # includes motion and motion masks
notifications = "notifications"
objects = "objects"
@ -107,6 +108,8 @@ class CameraConfigUpdateSubscriber:
config.enabled = updated_config
elif update_type == CameraConfigUpdateEnum.object_genai:
config.objects.genai = updated_config
elif update_type == CameraConfigUpdateEnum.live:
config.live = updated_config
elif update_type == CameraConfigUpdateEnum.motion:
config.motion = updated_config
elif update_type == CameraConfigUpdateEnum.notifications:

View File

@ -55,6 +55,7 @@
"systemDetectorHardware": "Detector hardware",
"systemDetectionModel": "Detection model",
"systemMqtt": "MQTT",
"systemGo2rtcStreams": "go2rtc streams",
"integrationSemanticSearch": "Semantic search",
"integrationGenerativeAi": "Generative AI",
"integrationFaceRecognition": "Face recognition",
@ -1492,5 +1493,47 @@
"unsavedChanges": "You have unsaved changes",
"confirmReset": "Confirm Reset",
"resetToDefaultDescription": "This will reset all settings in this section to their default values. This action cannot be undone.",
"resetToGlobalDescription": "This will reset the settings in this section to the global defaults. This action cannot be undone."
"resetToGlobalDescription": "This will reset the settings in this section to the global defaults. This action cannot be undone.",
"go2rtcStreams": {
"title": "go2rtc Streams",
"description": "Manage go2rtc stream configurations for camera restreaming. Each stream has a name and one or more source URLs.",
"addStream": "Add stream",
"addStreamDesc": "Enter a name for the new stream. This name will be used to reference the stream in your camera configuration.",
"addUrl": "Add URL",
"streamName": "Stream name",
"streamNamePlaceholder": "e.g., front_door",
"streamUrlPlaceholder": "e.g., rtsp://user:pass@192.168.1.100/stream",
"deleteStream": "Delete stream",
"deleteStreamConfirm": "Are you sure you want to delete the stream \"{{streamName}}\"? Cameras that reference this stream may stop working.",
"noStreams": "No go2rtc streams configured. Add a stream to get started.",
"validation": {
"nameRequired": "Stream name is required",
"nameDuplicate": "A stream with this name already exists",
"nameInvalid": "Stream name can only contain letters, numbers, underscores, and hyphens",
"urlRequired": "At least one URL is required"
},
"renameStream": "Rename stream",
"renameStreamDesc": "Enter a new name for this stream. Renaming a stream may break cameras or other streams that reference it by name.",
"newStreamName": "New stream name",
"ffmpeg": {
"useFfmpegModule": "Use compatibility mode (ffmpeg)",
"video": "Video",
"audio": "Audio",
"hardware": "Hardware acceleration",
"videoCopy": "Copy",
"videoH264": "Transcode to H.264",
"videoH265": "Transcode to H.265",
"videoExclude": "Exclude",
"audioCopy": "Copy",
"audioAac": "Transcode to AAC",
"audioOpus": "Transcode to Opus",
"audioPcmu": "Transcode to PCM μ-law",
"audioPcma": "Transcode to PCM A-law",
"audioPcm": "Transcode to PCM",
"audioMp3": "Transcode to MP3",
"audioExclude": "Exclude",
"hardwareNone": "No hardware acceleration",
"hardwareAuto": "Automatic hardware acceleration"
}
}
}

View File

@ -8,6 +8,11 @@ import { Input } from "@/components/ui/input";
import type { ConfigFormContext } from "@/types/configForm";
import { cn } from "@/lib/utils";
import { getSizedFieldClassName } from "../utils";
import {
isMaskedPath,
hasCredentials,
maskCredentials,
} from "@/utils/credentialMask";
type RawPathsResponse = {
cameras?: Record<
@ -22,9 +27,6 @@ type RawPathsResponse = {
>;
};
const MASKED_AUTH_PATTERN = /:\/\/\*:\*@/i;
const MASKED_QUERY_PATTERN = /(?:[?&])user=\*&password=\*/i;
const getInputIndexFromWidgetId = (id: string): number | undefined => {
const match = id.match(/_inputs_(\d+)_path$/);
if (!match) {
@ -35,44 +37,6 @@ const getInputIndexFromWidgetId = (id: string): number | undefined => {
return Number.isNaN(index) ? undefined : index;
};
const isMaskedPath = (value: string): boolean =>
MASKED_AUTH_PATTERN.test(value) || MASKED_QUERY_PATTERN.test(value);
const hasCredentials = (value: string): boolean => {
if (!value) {
return false;
}
if (isMaskedPath(value)) {
return true;
}
try {
const parsed = new URL(value);
if (parsed.username || parsed.password) {
return true;
}
return (
parsed.searchParams.has("user") && parsed.searchParams.has("password")
);
} catch {
return /:\/\/[^:@/\s]+:[^@/\s]+@/.test(value);
}
};
const maskCredentials = (value: string): string => {
if (!value) {
return value;
}
const maskedAuth = value.replace(/:\/\/[^:@/\s]+:[^@/\s]*@/g, "://*:*@");
return maskedAuth
.replace(/([?&]user=)[^&]*/gi, "$1*")
.replace(/([?&]password=)[^&]*/gi, "$1*");
};
export function CameraPathWidget(props: WidgetProps) {
const {
id,

View File

@ -47,6 +47,7 @@ import ProfilesView from "@/views/settings/ProfilesView";
import FrigatePlusSettingsView from "@/views/settings/FrigatePlusSettingsView";
import MediaSyncSettingsView from "@/views/settings/MediaSyncSettingsView";
import RegionGridSettingsView from "@/views/settings/RegionGridSettingsView";
import Go2RtcStreamsSettingsView from "@/views/settings/Go2RtcStreamsSettingsView";
import SystemDetectionModelSettingsView from "@/views/settings/SystemDetectionModelSettingsView";
import {
SingleSectionPage,
@ -132,6 +133,7 @@ const allSettingsViews = [
"systemDetectorHardware",
"systemDetectionModel",
"systemMqtt",
"systemGo2rtcStreams",
"integrationSemanticSearch",
"integrationGenerativeAi",
"integrationFaceRecognition",
@ -414,6 +416,10 @@ const settingsGroups = [
{
label: "system",
items: [
{
key: "systemGo2rtcStreams",
component: Go2RtcStreamsSettingsView,
},
{
key: "systemDetectorHardware",
component: SystemDetectorHardwareSettingsPage,
@ -562,6 +568,7 @@ const ENRICHMENTS_SECTION_MAPPING: Record<string, SettingsType> = {
};
const SYSTEM_SECTION_MAPPING: Record<string, SettingsType> = {
go2rtc_streams: "systemGo2rtcStreams",
database: "systemDatabase",
mqtt: "systemMqtt",
tls: "systemTls",

View File

@ -486,7 +486,7 @@ export interface FrigateConfig {
};
go2rtc: {
streams: string[];
streams: Record<string, string | string[]>;
webrtc: {
candidates: string[];
};

View File

@ -0,0 +1,40 @@
const MASKED_AUTH_PATTERN = /:\/\/\*:\*@/i;
const MASKED_QUERY_PATTERN = /(?:[?&])user=\*&password=\*/i;
export const isMaskedPath = (value: string): boolean =>
MASKED_AUTH_PATTERN.test(value) || MASKED_QUERY_PATTERN.test(value);
export const hasCredentials = (value: string): boolean => {
if (!value) {
return false;
}
if (isMaskedPath(value)) {
return true;
}
try {
const parsed = new URL(value);
if (parsed.username || parsed.password) {
return true;
}
return (
parsed.searchParams.has("user") && parsed.searchParams.has("password")
);
} catch {
return /:\/\/[^:@/\s]+:[^@/\s]+@/.test(value);
}
};
export const maskCredentials = (value: string): string => {
if (!value) {
return value;
}
const maskedAuth = value.replace(/:\/\/[^:@/\s]+:[^@/\s]*@/g, "://*:*@");
return maskedAuth
.replace(/([?&]user=)[^&]*/gi, "$1*")
.replace(/([?&]password=)[^&]*/gi, "$1*");
};

View File

@ -0,0 +1,137 @@
export type FfmpegVideoOption = "copy" | "h264" | "h265" | "exclude";
export type FfmpegAudioOption =
| "copy"
| "aac"
| "opus"
| "pcmu"
| "pcma"
| "pcm"
| "mp3"
| "exclude";
export type FfmpegHardwareOption = "none" | "auto";
export type ParsedFfmpegUrl = {
isFfmpeg: boolean;
baseUrl: string;
video: FfmpegVideoOption;
audio: FfmpegAudioOption;
hardware: FfmpegHardwareOption;
extraFragments: string[];
};
const VIDEO_VALUES = new Set(["copy", "h264", "h265"]);
const AUDIO_VALUES = new Set([
"copy",
"aac",
"opus",
"pcmu",
"pcma",
"pcm",
"mp3",
]);
const HARDWARE_SPECIFIC = new Set([
"vaapi",
"cuda",
"v4l2m2m",
"dxva2",
"videotoolbox",
]);
export function parseFfmpegUrl(url: string): ParsedFfmpegUrl {
if (!url.startsWith("ffmpeg:")) {
return {
isFfmpeg: false,
baseUrl: url,
video: "copy",
audio: "copy",
hardware: "none",
extraFragments: [],
};
}
const withoutPrefix = url.slice(7);
const parts = withoutPrefix.split("#");
const baseUrl = parts[0];
const fragments = parts.slice(1);
let video: FfmpegVideoOption | null = null;
let audio: FfmpegAudioOption | null = null;
let hardware: FfmpegHardwareOption = "none";
const extraFragments: string[] = [];
for (const frag of fragments) {
if (frag.startsWith("video=")) {
const val = frag.slice(6);
if (VIDEO_VALUES.has(val)) {
video = val as FfmpegVideoOption;
} else {
extraFragments.push(frag);
}
} else if (frag.startsWith("audio=")) {
const val = frag.slice(6);
if (AUDIO_VALUES.has(val)) {
audio = val as FfmpegAudioOption;
} else {
extraFragments.push(frag);
}
} else if (frag === "hardware") {
hardware = "auto";
} else if (frag.startsWith("hardware=")) {
const val = frag.slice(9);
if (HARDWARE_SPECIFIC.has(val)) {
hardware = "auto";
} else {
extraFragments.push(frag);
}
} else {
extraFragments.push(frag);
}
}
const hasAnyKnownFragment = video !== null || audio !== null;
return {
isFfmpeg: true,
baseUrl,
video: video ?? (hasAnyKnownFragment ? "exclude" : "copy"),
audio: audio ?? (hasAnyKnownFragment ? "exclude" : "copy"),
hardware,
extraFragments,
};
}
export function buildFfmpegUrl(parsed: ParsedFfmpegUrl): string {
let url = `ffmpeg:${parsed.baseUrl}`;
if (parsed.video !== "exclude") {
url += `#video=${parsed.video}`;
}
if (parsed.audio !== "exclude") {
url += `#audio=${parsed.audio}`;
}
if (parsed.hardware === "auto") {
url += "#hardware";
}
for (const frag of parsed.extraFragments) {
url += `#${frag}`;
}
return url;
}
export function toggleFfmpegMode(url: string, enable: boolean): string {
if (enable) {
if (url.startsWith("ffmpeg:")) {
return url;
}
return `ffmpeg:${url}#video=copy#audio=copy`;
}
if (!url.startsWith("ffmpeg:")) {
return url;
}
const withoutPrefix = url.slice(7);
const baseUrl = withoutPrefix.split("#")[0];
return baseUrl;
}

File diff suppressed because it is too large Load Diff