mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-23 08:38:22 +03:00
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:
parent
c93dad9bd9
commit
e2bfa26719
@ -18,6 +18,7 @@ class CameraConfigUpdateEnum(str, Enum):
|
|||||||
detect = "detect"
|
detect = "detect"
|
||||||
enabled = "enabled"
|
enabled = "enabled"
|
||||||
ffmpeg = "ffmpeg"
|
ffmpeg = "ffmpeg"
|
||||||
|
live = "live"
|
||||||
motion = "motion" # includes motion and motion masks
|
motion = "motion" # includes motion and motion masks
|
||||||
notifications = "notifications"
|
notifications = "notifications"
|
||||||
objects = "objects"
|
objects = "objects"
|
||||||
@ -107,6 +108,8 @@ class CameraConfigUpdateSubscriber:
|
|||||||
config.enabled = updated_config
|
config.enabled = updated_config
|
||||||
elif update_type == CameraConfigUpdateEnum.object_genai:
|
elif update_type == CameraConfigUpdateEnum.object_genai:
|
||||||
config.objects.genai = updated_config
|
config.objects.genai = updated_config
|
||||||
|
elif update_type == CameraConfigUpdateEnum.live:
|
||||||
|
config.live = updated_config
|
||||||
elif update_type == CameraConfigUpdateEnum.motion:
|
elif update_type == CameraConfigUpdateEnum.motion:
|
||||||
config.motion = updated_config
|
config.motion = updated_config
|
||||||
elif update_type == CameraConfigUpdateEnum.notifications:
|
elif update_type == CameraConfigUpdateEnum.notifications:
|
||||||
|
|||||||
@ -55,6 +55,7 @@
|
|||||||
"systemDetectorHardware": "Detector hardware",
|
"systemDetectorHardware": "Detector hardware",
|
||||||
"systemDetectionModel": "Detection model",
|
"systemDetectionModel": "Detection model",
|
||||||
"systemMqtt": "MQTT",
|
"systemMqtt": "MQTT",
|
||||||
|
"systemGo2rtcStreams": "go2rtc streams",
|
||||||
"integrationSemanticSearch": "Semantic search",
|
"integrationSemanticSearch": "Semantic search",
|
||||||
"integrationGenerativeAi": "Generative AI",
|
"integrationGenerativeAi": "Generative AI",
|
||||||
"integrationFaceRecognition": "Face recognition",
|
"integrationFaceRecognition": "Face recognition",
|
||||||
@ -1492,5 +1493,47 @@
|
|||||||
"unsavedChanges": "You have unsaved changes",
|
"unsavedChanges": "You have unsaved changes",
|
||||||
"confirmReset": "Confirm Reset",
|
"confirmReset": "Confirm Reset",
|
||||||
"resetToDefaultDescription": "This will reset all settings in this section to their default values. This action cannot be undone.",
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,11 @@ import { Input } from "@/components/ui/input";
|
|||||||
import type { ConfigFormContext } from "@/types/configForm";
|
import type { ConfigFormContext } from "@/types/configForm";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { getSizedFieldClassName } from "../utils";
|
import { getSizedFieldClassName } from "../utils";
|
||||||
|
import {
|
||||||
|
isMaskedPath,
|
||||||
|
hasCredentials,
|
||||||
|
maskCredentials,
|
||||||
|
} from "@/utils/credentialMask";
|
||||||
|
|
||||||
type RawPathsResponse = {
|
type RawPathsResponse = {
|
||||||
cameras?: Record<
|
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 getInputIndexFromWidgetId = (id: string): number | undefined => {
|
||||||
const match = id.match(/_inputs_(\d+)_path$/);
|
const match = id.match(/_inputs_(\d+)_path$/);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
@ -35,44 +37,6 @@ const getInputIndexFromWidgetId = (id: string): number | undefined => {
|
|||||||
return Number.isNaN(index) ? undefined : index;
|
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) {
|
export function CameraPathWidget(props: WidgetProps) {
|
||||||
const {
|
const {
|
||||||
id,
|
id,
|
||||||
|
|||||||
@ -47,6 +47,7 @@ import ProfilesView from "@/views/settings/ProfilesView";
|
|||||||
import FrigatePlusSettingsView from "@/views/settings/FrigatePlusSettingsView";
|
import FrigatePlusSettingsView from "@/views/settings/FrigatePlusSettingsView";
|
||||||
import MediaSyncSettingsView from "@/views/settings/MediaSyncSettingsView";
|
import MediaSyncSettingsView from "@/views/settings/MediaSyncSettingsView";
|
||||||
import RegionGridSettingsView from "@/views/settings/RegionGridSettingsView";
|
import RegionGridSettingsView from "@/views/settings/RegionGridSettingsView";
|
||||||
|
import Go2RtcStreamsSettingsView from "@/views/settings/Go2RtcStreamsSettingsView";
|
||||||
import SystemDetectionModelSettingsView from "@/views/settings/SystemDetectionModelSettingsView";
|
import SystemDetectionModelSettingsView from "@/views/settings/SystemDetectionModelSettingsView";
|
||||||
import {
|
import {
|
||||||
SingleSectionPage,
|
SingleSectionPage,
|
||||||
@ -132,6 +133,7 @@ const allSettingsViews = [
|
|||||||
"systemDetectorHardware",
|
"systemDetectorHardware",
|
||||||
"systemDetectionModel",
|
"systemDetectionModel",
|
||||||
"systemMqtt",
|
"systemMqtt",
|
||||||
|
"systemGo2rtcStreams",
|
||||||
"integrationSemanticSearch",
|
"integrationSemanticSearch",
|
||||||
"integrationGenerativeAi",
|
"integrationGenerativeAi",
|
||||||
"integrationFaceRecognition",
|
"integrationFaceRecognition",
|
||||||
@ -414,6 +416,10 @@ const settingsGroups = [
|
|||||||
{
|
{
|
||||||
label: "system",
|
label: "system",
|
||||||
items: [
|
items: [
|
||||||
|
{
|
||||||
|
key: "systemGo2rtcStreams",
|
||||||
|
component: Go2RtcStreamsSettingsView,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "systemDetectorHardware",
|
key: "systemDetectorHardware",
|
||||||
component: SystemDetectorHardwareSettingsPage,
|
component: SystemDetectorHardwareSettingsPage,
|
||||||
@ -562,6 +568,7 @@ const ENRICHMENTS_SECTION_MAPPING: Record<string, SettingsType> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const SYSTEM_SECTION_MAPPING: Record<string, SettingsType> = {
|
const SYSTEM_SECTION_MAPPING: Record<string, SettingsType> = {
|
||||||
|
go2rtc_streams: "systemGo2rtcStreams",
|
||||||
database: "systemDatabase",
|
database: "systemDatabase",
|
||||||
mqtt: "systemMqtt",
|
mqtt: "systemMqtt",
|
||||||
tls: "systemTls",
|
tls: "systemTls",
|
||||||
|
|||||||
@ -486,7 +486,7 @@ export interface FrigateConfig {
|
|||||||
};
|
};
|
||||||
|
|
||||||
go2rtc: {
|
go2rtc: {
|
||||||
streams: string[];
|
streams: Record<string, string | string[]>;
|
||||||
webrtc: {
|
webrtc: {
|
||||||
candidates: string[];
|
candidates: string[];
|
||||||
};
|
};
|
||||||
|
|||||||
40
web/src/utils/credentialMask.ts
Normal file
40
web/src/utils/credentialMask.ts
Normal 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*");
|
||||||
|
};
|
||||||
137
web/src/utils/go2rtcFfmpeg.ts
Normal file
137
web/src/utils/go2rtcFfmpeg.ts
Normal 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;
|
||||||
|
}
|
||||||
1009
web/src/views/settings/Go2RtcStreamsSettingsView.tsx
Normal file
1009
web/src/views/settings/Go2RtcStreamsSettingsView.tsx
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user