separate and consolidate global and camera i18n namespaces

This commit is contained in:
Josh Hawkins 2026-01-31 09:58:10 -06:00
parent 8f681d5689
commit 3f7f5e3253
37 changed files with 1666 additions and 220 deletions

View File

@ -26,7 +26,7 @@ class AudioConfig(FrigateBaseModel):
enabled: bool = Field( enabled: bool = Field(
default=False, default=False,
title="Enable audio detection", title="Enable audio detection",
description="Enable or disable audio event detection; can be overridden per-camera.", description="Enable or disable audio event detection for all cameras; can be overridden per-camera.",
) )
max_not_heard: int = Field( max_not_heard: int = Field(
default=30, default=30,

View File

@ -50,7 +50,7 @@ class DetectConfig(FrigateBaseModel):
enabled: bool = Field( enabled: bool = Field(
default=False, default=False,
title="Detection enabled", title="Detection enabled",
description="Enable or disable object detection for this camera. Detection must be enabled for object tracking to run.", description="Enable or disable object detection for all cameras; can be overridden per-camera. Detection must be enabled for object tracking to run.",
) )
height: Optional[int] = Field( height: Optional[int] = Field(
default=None, default=None,

View File

@ -11,7 +11,7 @@ class MotionConfig(FrigateBaseModel):
enabled: bool = Field( enabled: bool = Field(
default=True, default=True,
title="Enable motion detection", title="Enable motion detection",
description="Enable or disable motion detection; can be overridden per-camera.", description="Enable or disable motion detection for all cameras; can be overridden per-camera.",
) )
threshold: int = Field( threshold: int = Field(
default=30, default=30,

View File

@ -11,7 +11,7 @@ class NotificationConfig(FrigateBaseModel):
enabled: bool = Field( enabled: bool = Field(
default=False, default=False,
title="Enable notifications", title="Enable notifications",
description="Enable or disable notifications; can be overridden per-camera.", description="Enable or disable notifications for all cameras; can be overridden per-camera.",
) )
email: Optional[str] = Field( email: Optional[str] = Field(
default=None, default=None,

View File

@ -132,7 +132,7 @@ class ObjectConfig(FrigateBaseModel):
track: list[str] = Field( track: list[str] = Field(
default=DEFAULT_TRACKED_OBJECTS, default=DEFAULT_TRACKED_OBJECTS,
title="Objects to track", title="Objects to track",
description="List of object labels to track; can be overridden per-camera.", description="List of object labels to track for all cameras; can be overridden per-camera.",
) )
filters: dict[str, FilterConfig] = Field( filters: dict[str, FilterConfig] = Field(
default_factory=dict, default_factory=dict,

View File

@ -98,7 +98,7 @@ class RecordConfig(FrigateBaseModel):
enabled: bool = Field( enabled: bool = Field(
default=False, default=False,
title="Enable recording", title="Enable recording",
description="Enable or disable recording; can be overridden per-camera.", description="Enable or disable recording for all cameras; can be overridden per-camera.",
) )
expire_interval: int = Field( expire_interval: int = Field(
default=60, default=60,

View File

@ -30,7 +30,7 @@ class SnapshotsConfig(FrigateBaseModel):
enabled: bool = Field( enabled: bool = Field(
default=False, default=False,
title="Snapshots enabled", title="Snapshots enabled",
description="Enable or disable saving snapshots; can be overridden per-camera.", description="Enable or disable saving snapshots for all cameras; can be overridden per-camera.",
) )
clean_copy: bool = Field( clean_copy: bool = Field(
default=True, default=True,

View File

@ -46,7 +46,7 @@ class AudioTranscriptionConfig(FrigateBaseModel):
enabled: bool = Field( enabled: bool = Field(
default=False, default=False,
title="Enable audio transcription", title="Enable audio transcription",
description="Enable or disable automatic audio transcription; can be overridden per-camera.", description="Enable or disable automatic audio transcription for all cameras; can be overridden per-camera.",
) )
language: str = Field( language: str = Field(
default="en", default="en",
@ -240,7 +240,7 @@ class FaceRecognitionConfig(FrigateBaseModel):
enabled: bool = Field( enabled: bool = Field(
default=False, default=False,
title="Enable face recognition", title="Enable face recognition",
description="Enable or disable face recognition; can be overridden per-camera.", description="Enable or disable face recognition for all cameras; can be overridden per-camera.",
) )
model_size: str = Field( model_size: str = Field(
default="small", default="small",
@ -322,7 +322,7 @@ class LicensePlateRecognitionConfig(FrigateBaseModel):
enabled: bool = Field( enabled: bool = Field(
default=False, default=False,
title="Enable LPR", title="Enable LPR",
description="Enable or disable license plate recognition; can be overridden per-camera.", description="Enable or disable license plate recognition for all cameras; can be overridden per-camera.",
) )
model_size: str = Field( model_size: str = Field(
default="small", default="small",

View File

@ -346,7 +346,7 @@ class FrigateConfig(FrigateBaseModel):
notifications: NotificationConfig = Field( notifications: NotificationConfig = Field(
default_factory=NotificationConfig, default_factory=NotificationConfig,
title="Notifications", title="Notifications",
description="Settings to enable and control notifications; can be overridden per-camera.", description="Settings to enable and control notifications for all cameras; can be overridden per-camera.",
) )
networking: NetworkingConfig = Field( networking: NetworkingConfig = Field(
default_factory=NetworkingConfig, default_factory=NetworkingConfig,
@ -398,7 +398,7 @@ class FrigateConfig(FrigateBaseModel):
audio: AudioConfig = Field( audio: AudioConfig = Field(
default_factory=AudioConfig, default_factory=AudioConfig,
title="Audio events", title="Audio events",
description="Settings for audio-based event detection; can be overridden per-camera.", description="Settings for audio-based event detection for all cameras; can be overridden per-camera.",
) )
birdseye: BirdseyeConfig = Field( birdseye: BirdseyeConfig = Field(
default_factory=BirdseyeConfig, default_factory=BirdseyeConfig,
@ -443,7 +443,7 @@ class FrigateConfig(FrigateBaseModel):
snapshots: SnapshotsConfig = Field( snapshots: SnapshotsConfig = Field(
default_factory=SnapshotsConfig, default_factory=SnapshotsConfig,
title="Snapshots", title="Snapshots",
description="Settings for saved JPEG snapshots of tracked objects; can be overridden per-camera.", description="Settings for saved JPEG snapshots of tracked objects for all cameras; can be overridden per-camera.",
) )
timestamp_style: TimestampStyleConfig = Field( timestamp_style: TimestampStyleConfig = Field(
default_factory=TimestampStyleConfig, default_factory=TimestampStyleConfig,
@ -470,7 +470,7 @@ class FrigateConfig(FrigateBaseModel):
face_recognition: FaceRecognitionConfig = Field( face_recognition: FaceRecognitionConfig = Field(
default_factory=FaceRecognitionConfig, default_factory=FaceRecognitionConfig,
title="Face recognition", title="Face recognition",
description="Settings for face detection and recognition; can be overridden per-camera.", description="Settings for face detection and recognition for all cameras; can be overridden per-camera.",
) )
lpr: LicensePlateRecognitionConfig = Field( lpr: LicensePlateRecognitionConfig = Field(
default_factory=LicensePlateRecognitionConfig, default_factory=LicensePlateRecognitionConfig,

View File

@ -209,6 +209,8 @@ def main():
config_fields = FrigateConfig.model_fields config_fields = FrigateConfig.model_fields
logger.info(f"Found {len(config_fields)} top-level config sections") logger.info(f"Found {len(config_fields)} top-level config sections")
global_translations = {}
for field_name, field_info in config_fields.items(): for field_name, field_info in config_fields.items():
if field_name.startswith("_"): if field_name.startswith("_"):
continue continue
@ -351,12 +353,10 @@ def main():
f"Could not add camera-level fields for {field_name}: {e}" f"Could not add camera-level fields for {field_name}: {e}"
) )
output_file = output_dir / f"{field_name}.json" # Add to global translations instead of writing separate files
with open(output_file, "w", encoding="utf-8") as f: global_translations[field_name] = section_data
json.dump(section_data, f, indent=2, ensure_ascii=False)
f.write("\n") # Add trailing newline
logger.info(f"Generated: {output_file}") logger.info(f"Added section to global translations: {field_name}")
# Handle camera-level configs that aren't top-level FrigateConfig fields # Handle camera-level configs that aren't top-level FrigateConfig fields
# These are defined as fields in CameraConfig, so we extract title/description from there # These are defined as fields in CameraConfig, so we extract title/description from there
@ -403,15 +403,71 @@ def main():
} }
section_data.update(nested_without_root) section_data.update(nested_without_root)
output_file = output_dir / f"{config_name}.json" # Add camera-level section into global translations (do not write separate file)
with open(output_file, "w", encoding="utf-8") as f: global_translations[config_name] = section_data
json.dump(section_data, f, indent=2, ensure_ascii=False) logger.info(
f.write("\n") # Add trailing newline f"Added camera-level section to global translations: {config_name}"
)
logger.info(f"Generated: {output_file}")
except Exception as e: except Exception as e:
logger.error(f"Failed to generate {config_name}: {e}") logger.error(f"Failed to generate {config_name}: {e}")
# Remove top-level 'cameras' field if present so it remains a separate file
if "cameras" in global_translations:
logger.info(
"Removing top-level 'cameras' from global translations to keep it as a separate cameras.json"
)
del global_translations["cameras"]
# Write consolidated global.json with per-section keys
global_file = output_dir / "global.json"
with open(global_file, "w", encoding="utf-8") as f:
json.dump(global_translations, f, indent=2, ensure_ascii=False)
f.write("\n")
logger.info(f"Generated consolidated translations: {global_file}")
if not global_translations:
logger.warning("No global translations were generated!")
else:
logger.info(f"Global contains {len(global_translations)} sections")
# Generate cameras.json from CameraConfig schema
cameras_file = output_dir / "cameras.json"
logger.info(f"Generating cameras.json: {cameras_file}")
try:
if "camera_config_schema" in locals():
camera_schema = camera_config_schema
else:
from frigate.config.camera.camera import CameraConfig
camera_schema = CameraConfig.model_json_schema()
camera_translations = extract_translations_from_schema(camera_schema)
# Change descriptions to use 'for this camera' for fields that are global
def sanitize_camera_descriptions(obj):
if isinstance(obj, dict):
for k, v in list(obj.items()):
if k == "description" and isinstance(v, str):
obj[k] = v.replace(
"for all cameras; can be overridden per-camera",
"for this camera",
)
else:
sanitize_camera_descriptions(v)
elif isinstance(obj, list):
for item in obj:
sanitize_camera_descriptions(item)
sanitize_camera_descriptions(camera_translations)
with open(cameras_file, "w", encoding="utf-8") as f:
json.dump(camera_translations, f, indent=2, ensure_ascii=False)
f.write("\n")
logger.info(f"Generated cameras.json: {cameras_file}")
except Exception as e:
logger.error(f"Failed to generate cameras.json: {e}")
logger.info("Translation generation complete!") logger.info("Translation generation complete!")

View File

@ -1,6 +1,5 @@
{ {
"label": "Cameras", "label": "CameraConfig",
"description": "Cameras",
"name": { "name": {
"label": "Camera name", "label": "Camera name",
"description": "Camera name is required" "description": "Camera name is required"
@ -153,7 +152,7 @@
"description": "FFmpeg settings including binary path, args, hwaccel options, and per-role output args.", "description": "FFmpeg settings including binary path, args, hwaccel options, and per-role output args.",
"path": { "path": {
"label": "FFmpeg path", "label": "FFmpeg path",
"description": "Path to the FFmpeg binary to use for this camera or a version alias (\"5.0\" or \"7.0\")." "description": "Path to the FFmpeg binary to use or a version alias (\"5.0\" or \"7.0\")."
}, },
"global_args": { "global_args": {
"label": "FFmpeg global args", "label": "FFmpeg global args",

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,7 @@ import { createConfigSection } from "./BaseSection";
export const AudioSection = createConfigSection({ export const AudioSection = createConfigSection({
sectionPath: "audio", sectionPath: "audio",
i18nNamespace: "config/audio", i18nNamespace: "config/global",
defaultConfig: { defaultConfig: {
fieldOrder: [ fieldOrder: [
"enabled", "enabled",

View File

@ -5,7 +5,7 @@ import { createConfigSection } from "./BaseSection";
export const AudioTranscriptionSection = createConfigSection({ export const AudioTranscriptionSection = createConfigSection({
sectionPath: "audio_transcription", sectionPath: "audio_transcription",
i18nNamespace: "config/audio_transcription", i18nNamespace: "config/global",
defaultConfig: { defaultConfig: {
fieldOrder: ["enabled", "language", "device", "model_size", "live_enabled"], fieldOrder: ["enabled", "language", "device", "model_size", "live_enabled"],
hiddenFields: ["enabled_in_config"], hiddenFields: ["enabled_in_config"],

View File

@ -460,31 +460,26 @@ export function createConfigSection({
return null; return null;
} }
// Get section title from config namespace. For camera-level sections we // Get section title from config namespace
// prefer the `config/cameras` namespace where keys are nested under the
// section name (e.g., `audio.label`). Fall back to provided i18nNamespace.
const defaultTitle = const defaultTitle =
sectionPath.charAt(0).toUpperCase() + sectionPath.charAt(0).toUpperCase() +
sectionPath.slice(1).replace(/_/g, " "); sectionPath.slice(1).replace(/_/g, " ");
const title =
level === "camera"
? t(`${sectionPath}.label`, {
ns: "config/cameras",
defaultValue: defaultTitle,
})
: t("label", {
ns: i18nNamespace,
defaultValue: defaultTitle,
});
const sectionDescription = // For camera-level sections, keys live under `config/cameras` and are
level === "camera" // nested under the section name (e.g., `audio.label`). For global-level
? i18n.exists(`${sectionPath}.description`, { ns: "config/cameras" }) // sections, keys are nested under the section name in `config/global`.
? t(`${sectionPath}.description`, { ns: "config/cameras" }) const configNamespace =
: undefined level === "camera" ? "config/cameras" : "config/global";
: i18n.exists("description", { ns: i18nNamespace }) const title = t(`${sectionPath}.label`, {
? t("description", { ns: i18nNamespace }) ns: configNamespace,
: undefined; defaultValue: defaultTitle,
});
const sectionDescription = i18n.exists(`${sectionPath}.description`, {
ns: configNamespace,
})
? t(`${sectionPath}.description`, { ns: configNamespace })
: undefined;
const sectionContent = ( const sectionContent = (
<div className="space-y-6"> <div className="space-y-6">
@ -502,7 +497,7 @@ export function createConfigSection({
disabled={disabled || isSaving} disabled={disabled || isSaving}
readonly={readonly} readonly={readonly}
showSubmit={false} showSubmit={false}
i18nNamespace={i18nNamespace} i18nNamespace={configNamespace}
formContext={{ formContext={{
level, level,
cameraName, cameraName,
@ -516,7 +511,10 @@ export function createConfigSection({
fullConfig: config, fullConfig: config,
// When rendering camera-level sections, provide the section path so // When rendering camera-level sections, provide the section path so
// field templates can look up keys under the `config/cameras` namespace // field templates can look up keys under the `config/cameras` namespace
sectionI18nPrefix: level === "camera" ? sectionPath : undefined, // When using a consolidated global namespace, keys are nested
// under the section name (e.g., `audio.label`) so provide the
// section prefix to templates so they can attempt `${section}.${field}` lookups.
sectionI18nPrefix: sectionPath,
t, t,
}} }}
/> />

View File

@ -5,7 +5,7 @@ import { createConfigSection } from "./BaseSection";
export const BirdseyeSection = createConfigSection({ export const BirdseyeSection = createConfigSection({
sectionPath: "birdseye", sectionPath: "birdseye",
i18nNamespace: "config/birdseye", i18nNamespace: "config/global",
defaultConfig: { defaultConfig: {
fieldOrder: ["enabled", "mode", "order"], fieldOrder: ["enabled", "mode", "order"],
hiddenFields: [], hiddenFields: [],

View File

@ -5,7 +5,7 @@ import { createConfigSection } from "./BaseSection";
export const CameraMqttSection = createConfigSection({ export const CameraMqttSection = createConfigSection({
sectionPath: "mqtt", sectionPath: "mqtt",
i18nNamespace: "config/camera_mqtt", i18nNamespace: "config/global",
defaultConfig: { defaultConfig: {
fieldOrder: [ fieldOrder: [
"enabled", "enabled",

View File

@ -5,7 +5,7 @@ import { createConfigSection } from "./BaseSection";
export const CameraUiSection = createConfigSection({ export const CameraUiSection = createConfigSection({
sectionPath: "ui", sectionPath: "ui",
i18nNamespace: "config/camera_ui", i18nNamespace: "config/global",
defaultConfig: { defaultConfig: {
fieldOrder: ["dashboard", "order"], fieldOrder: ["dashboard", "order"],
hiddenFields: [], hiddenFields: [],

View File

@ -5,7 +5,7 @@ import { createConfigSection } from "./BaseSection";
export const DetectSection = createConfigSection({ export const DetectSection = createConfigSection({
sectionPath: "detect", sectionPath: "detect",
i18nNamespace: "config/detect", i18nNamespace: "config/global",
defaultConfig: { defaultConfig: {
fieldOrder: [ fieldOrder: [
"enabled", "enabled",

View File

@ -5,7 +5,7 @@ import { createConfigSection } from "./BaseSection";
export const FaceRecognitionSection = createConfigSection({ export const FaceRecognitionSection = createConfigSection({
sectionPath: "face_recognition", sectionPath: "face_recognition",
i18nNamespace: "config/face_recognition", i18nNamespace: "config/global",
defaultConfig: { defaultConfig: {
fieldOrder: ["enabled", "min_area"], fieldOrder: ["enabled", "min_area"],
hiddenFields: [], hiddenFields: [],

View File

@ -5,7 +5,7 @@ import { createConfigSection } from "./BaseSection";
export const FfmpegSection = createConfigSection({ export const FfmpegSection = createConfigSection({
sectionPath: "ffmpeg", sectionPath: "ffmpeg",
i18nNamespace: "config/ffmpeg", i18nNamespace: "config/global",
defaultConfig: { defaultConfig: {
fieldOrder: [ fieldOrder: [
"inputs", "inputs",

View File

@ -5,7 +5,7 @@ import { createConfigSection } from "./BaseSection";
export const LiveSection = createConfigSection({ export const LiveSection = createConfigSection({
sectionPath: "live", sectionPath: "live",
i18nNamespace: "config/live", i18nNamespace: "config/global",
defaultConfig: { defaultConfig: {
fieldOrder: ["stream_name", "height", "quality"], fieldOrder: ["stream_name", "height", "quality"],
fieldGroups: {}, fieldGroups: {},

View File

@ -5,7 +5,7 @@ import { createConfigSection } from "./BaseSection";
export const LprSection = createConfigSection({ export const LprSection = createConfigSection({
sectionPath: "lpr", sectionPath: "lpr",
i18nNamespace: "config/lpr", i18nNamespace: "config/global",
defaultConfig: { defaultConfig: {
fieldOrder: ["enabled", "expire_time", "min_area", "enhancement"], fieldOrder: ["enabled", "expire_time", "min_area", "enhancement"],
hiddenFields: [], hiddenFields: [],

View File

@ -5,7 +5,7 @@ import { createConfigSection } from "./BaseSection";
export const MotionSection = createConfigSection({ export const MotionSection = createConfigSection({
sectionPath: "motion", sectionPath: "motion",
i18nNamespace: "config/motion", i18nNamespace: "config/global",
defaultConfig: { defaultConfig: {
fieldOrder: [ fieldOrder: [
"enabled", "enabled",

View File

@ -5,7 +5,7 @@ import { createConfigSection } from "./BaseSection";
export const NotificationsSection = createConfigSection({ export const NotificationsSection = createConfigSection({
sectionPath: "notifications", sectionPath: "notifications",
i18nNamespace: "config/notifications", i18nNamespace: "config/global",
defaultConfig: { defaultConfig: {
fieldOrder: ["enabled", "email"], fieldOrder: ["enabled", "email"],
fieldGroups: {}, fieldGroups: {},

View File

@ -5,7 +5,7 @@ import { createConfigSection } from "./BaseSection";
export const ObjectsSection = createConfigSection({ export const ObjectsSection = createConfigSection({
sectionPath: "objects", sectionPath: "objects",
i18nNamespace: "config/objects", i18nNamespace: "config/global",
defaultConfig: { defaultConfig: {
fieldOrder: ["track", "alert", "detect", "filters"], fieldOrder: ["track", "alert", "detect", "filters"],
fieldGroups: { fieldGroups: {

View File

@ -5,7 +5,7 @@ import { createConfigSection } from "./BaseSection";
export const OnvifSection = createConfigSection({ export const OnvifSection = createConfigSection({
sectionPath: "onvif", sectionPath: "onvif",
i18nNamespace: "config/onvif", i18nNamespace: "config/global",
defaultConfig: { defaultConfig: {
fieldOrder: [ fieldOrder: [
"host", "host",

View File

@ -5,7 +5,7 @@ import { createConfigSection } from "./BaseSection";
export const RecordSection = createConfigSection({ export const RecordSection = createConfigSection({
sectionPath: "record", sectionPath: "record",
i18nNamespace: "config/record", i18nNamespace: "config/global",
defaultConfig: { defaultConfig: {
fieldOrder: [ fieldOrder: [
"enabled", "enabled",

View File

@ -5,7 +5,7 @@ import { createConfigSection } from "./BaseSection";
export const ReviewSection = createConfigSection({ export const ReviewSection = createConfigSection({
sectionPath: "review", sectionPath: "review",
i18nNamespace: "config/review", i18nNamespace: "config/global",
defaultConfig: { defaultConfig: {
fieldOrder: ["alerts", "detections", "genai"], fieldOrder: ["alerts", "detections", "genai"],
fieldGroups: {}, fieldGroups: {},

View File

@ -5,7 +5,7 @@ import { createConfigSection } from "./BaseSection";
export const SemanticSearchSection = createConfigSection({ export const SemanticSearchSection = createConfigSection({
sectionPath: "semantic_search", sectionPath: "semantic_search",
i18nNamespace: "config/semantic_search", i18nNamespace: "config/global",
defaultConfig: { defaultConfig: {
fieldOrder: ["triggers"], fieldOrder: ["triggers"],
hiddenFields: [], hiddenFields: [],

View File

@ -5,7 +5,7 @@ import { createConfigSection } from "./BaseSection";
export const SnapshotsSection = createConfigSection({ export const SnapshotsSection = createConfigSection({
sectionPath: "snapshots", sectionPath: "snapshots",
i18nNamespace: "config/snapshots", i18nNamespace: "config/global",
defaultConfig: { defaultConfig: {
fieldOrder: [ fieldOrder: [
"enabled", "enabled",

View File

@ -5,7 +5,7 @@ import { createConfigSection } from "./BaseSection";
export const TimestampSection = createConfigSection({ export const TimestampSection = createConfigSection({
sectionPath: "timestamp_style", sectionPath: "timestamp_style",
i18nNamespace: "config/timestamp_style", i18nNamespace: "config/global",
defaultConfig: { defaultConfig: {
fieldOrder: ["position", "format", "color", "thickness"], fieldOrder: ["position", "format", "color", "thickness"],
hiddenFields: ["effect", "enabled_in_config"], hiddenFields: ["effect", "enabled_in_config"],

View File

@ -21,7 +21,8 @@ export function DescriptionFieldTemplate(props: DescriptionFieldProps) {
let resolvedDescription = description; let resolvedDescription = description;
if (isCameraLevel && sectionI18nPrefix && effectiveNamespace) { // Support nested keys for both camera-level and consolidated global namespace
if (sectionI18nPrefix && effectiveNamespace) {
const descriptionKey = `${sectionI18nPrefix}.description`; const descriptionKey = `${sectionI18nPrefix}.description`;
if (i18n.exists(descriptionKey, { ns: effectiveNamespace })) { if (i18n.exists(descriptionKey, { ns: effectiveNamespace })) {
resolvedDescription = t(descriptionKey, { ns: effectiveNamespace }); resolvedDescription = t(descriptionKey, { ns: effectiveNamespace });

View File

@ -199,7 +199,7 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
const label = domain const label = domain
? t(`${domain}.${groupKey}`, { ? t(`${domain}.${groupKey}`, {
ns: "config/groups", ns: "config/global",
defaultValue: toTitle(groupKey), defaultValue: toTitle(groupKey),
}) })
: t(`groups.${groupKey}`, { : t(`groups.${groupKey}`, {

View File

@ -52,27 +52,10 @@ i18n
"views/system", "views/system",
"views/exports", "views/exports",
"views/explore", "views/explore",
// Config section translations // Config namespaces: single consolidated global file + camera-level keys
"config/global",
"config/cameras", "config/cameras",
"config/detect", // keep these for backwards compatibility with explicit ns usage
"config/record",
"config/snapshots",
"config/motion",
"config/objects",
"config/review",
"config/audio",
"config/notifications",
"config/live",
"config/timestamp_style",
"config/mqtt",
"config/database",
"config/auth",
"config/tls",
"config/telemetry",
"config/birdseye",
"config/semantic_search",
"config/face_recognition",
"config/lpr",
"config/validation", "config/validation",
"config/groups", "config/groups",
], ],

View File

@ -180,29 +180,12 @@ const CameraConfigContent = memo(function CameraConfigContent({
onSave, onSave,
}: CameraConfigContentProps) { }: CameraConfigContentProps) {
const { t } = useTranslation([ const { t } = useTranslation([
"config/detect",
"config/record",
"config/snapshots",
"config/motion",
"config/objects",
"config/review",
"config/audio",
"config/cameras", "config/cameras",
"config/audio_transcription", "config/cameras",
"config/birdseye",
"config/camera_mqtt",
"config/camera_ui",
"config/face_recognition",
"config/ffmpeg",
"config/lpr",
"config/notifications",
"config/onvif",
"config/live",
"config/semantic_search",
"config/timestamp_style",
"views/settings", "views/settings",
"common", "common",
]); ]);
const [activeSection, setActiveSection] = useState("detect"); const [activeSection, setActiveSection] = useState("detect");
const cameraConfig = config.cameras?.[cameraName]; const cameraConfig = config.cameras?.[cameraName];
@ -226,92 +209,92 @@ const CameraConfigContent = memo(function CameraConfigContent({
}> = [ }> = [
{ {
key: "detect", key: "detect",
i18nNamespace: "config/detect", i18nNamespace: "config/cameras",
component: DetectSection, component: DetectSection,
}, },
{ {
key: "ffmpeg", key: "ffmpeg",
i18nNamespace: "config/ffmpeg", i18nNamespace: "config/cameras",
component: FfmpegSection, component: FfmpegSection,
showOverrideIndicator: true, showOverrideIndicator: true,
}, },
{ {
key: "record", key: "record",
i18nNamespace: "config/record", i18nNamespace: "config/cameras",
component: RecordSection, component: RecordSection,
}, },
{ {
key: "snapshots", key: "snapshots",
i18nNamespace: "config/snapshots", i18nNamespace: "config/cameras",
component: SnapshotsSection, component: SnapshotsSection,
}, },
{ {
key: "motion", key: "motion",
i18nNamespace: "config/motion", i18nNamespace: "config/cameras",
component: MotionSection, component: MotionSection,
}, },
{ {
key: "objects", key: "objects",
i18nNamespace: "config/objects", i18nNamespace: "config/cameras",
component: ObjectsSection, component: ObjectsSection,
}, },
{ {
key: "review", key: "review",
i18nNamespace: "config/review", i18nNamespace: "config/cameras",
component: ReviewSection, component: ReviewSection,
}, },
{ key: "audio", i18nNamespace: "config/audio", component: AudioSection }, { key: "audio", i18nNamespace: "config/cameras", component: AudioSection },
{ {
key: "audio_transcription", key: "audio_transcription",
i18nNamespace: "config/audio_transcription", i18nNamespace: "config/cameras",
component: AudioTranscriptionSection, component: AudioTranscriptionSection,
showOverrideIndicator: true, showOverrideIndicator: true,
}, },
{ {
key: "notifications", key: "notifications",
i18nNamespace: "config/notifications", i18nNamespace: "config/cameras",
component: NotificationsSection, component: NotificationsSection,
}, },
{ key: "live", i18nNamespace: "config/live", component: LiveSection }, { key: "live", i18nNamespace: "config/cameras", component: LiveSection },
{ {
key: "birdseye", key: "birdseye",
i18nNamespace: "config/birdseye", i18nNamespace: "config/cameras",
component: BirdseyeSection, component: BirdseyeSection,
showOverrideIndicator: true, showOverrideIndicator: true,
}, },
{ {
key: "face_recognition", key: "face_recognition",
i18nNamespace: "config/face_recognition", i18nNamespace: "config/cameras",
component: FaceRecognitionSection, component: FaceRecognitionSection,
showOverrideIndicator: true, showOverrideIndicator: true,
}, },
{ {
key: "lpr", key: "lpr",
i18nNamespace: "config/lpr", i18nNamespace: "config/cameras",
component: LprSection, component: LprSection,
showOverrideIndicator: true, showOverrideIndicator: true,
}, },
{ {
key: "mqtt", key: "mqtt",
i18nNamespace: "config/camera_mqtt", i18nNamespace: "config/cameras",
component: CameraMqttSection, component: CameraMqttSection,
showOverrideIndicator: false, showOverrideIndicator: false,
}, },
{ {
key: "onvif", key: "onvif",
i18nNamespace: "config/onvif", i18nNamespace: "config/cameras",
component: OnvifSection, component: OnvifSection,
showOverrideIndicator: false, showOverrideIndicator: false,
}, },
{ {
key: "ui", key: "ui",
i18nNamespace: "config/camera_ui", i18nNamespace: "config/cameras",
component: CameraUiSection, component: CameraUiSection,
showOverrideIndicator: false, showOverrideIndicator: false,
}, },
{ {
key: "timestamp_style", key: "timestamp_style",
i18nNamespace: "config/timestamp_style", i18nNamespace: "config/cameras",
component: TimestampSection, component: TimestampSection,
}, },
]; ];
@ -326,12 +309,10 @@ const CameraConfigContent = memo(function CameraConfigContent({
const defaultSectionLabel = const defaultSectionLabel =
section.key.charAt(0).toUpperCase() + section.key.charAt(0).toUpperCase() +
section.key.slice(1).replace(/_/g, " "); section.key.slice(1).replace(/_/g, " ");
const sectionLabel = t(`${section.key}.label`, { const sectionLabel = t(`${section.key}.label`, {
ns: "config/cameras", ns: "config/cameras",
defaultValue: t("label", { defaultValue: defaultSectionLabel,
ns: section.i18nNamespace,
defaultValue: defaultSectionLabel,
}),
}); });
return ( return (

View File

@ -30,25 +30,25 @@ import { cn } from "@/lib/utils";
// Shared sections that can be overridden at camera level // Shared sections that can be overridden at camera level
const sharedSections = [ const sharedSections = [
{ key: "detect", i18nNamespace: "config/detect", component: DetectSection }, { key: "detect", i18nNamespace: "config/global", component: DetectSection },
{ key: "record", i18nNamespace: "config/record", component: RecordSection }, { key: "record", i18nNamespace: "config/global", component: RecordSection },
{ {
key: "snapshots", key: "snapshots",
i18nNamespace: "config/snapshots", i18nNamespace: "config/global",
component: SnapshotsSection, component: SnapshotsSection,
}, },
{ key: "motion", i18nNamespace: "config/motion", component: MotionSection }, { key: "motion", i18nNamespace: "config/global", component: MotionSection },
{ {
key: "objects", key: "objects",
i18nNamespace: "config/objects", i18nNamespace: "config/global",
component: ObjectsSection, component: ObjectsSection,
}, },
{ key: "review", i18nNamespace: "config/review", component: ReviewSection }, { key: "review", i18nNamespace: "config/global", component: ReviewSection },
{ key: "audio", i18nNamespace: "config/audio", component: AudioSection }, { key: "audio", i18nNamespace: "config/global", component: AudioSection },
{ key: "live", i18nNamespace: "config/live", component: LiveSection }, { key: "live", i18nNamespace: "config/global", component: LiveSection },
{ {
key: "timestamp_style", key: "timestamp_style",
i18nNamespace: "config/timestamp_style", i18nNamespace: "config/global",
component: TimestampSection, component: TimestampSection,
}, },
]; ];
@ -66,7 +66,7 @@ const globalSectionConfigs: Record<
} }
> = { > = {
mqtt: { mqtt: {
i18nNamespace: "config/mqtt", i18nNamespace: "config/global",
fieldOrder: [ fieldOrder: [
"enabled", "enabled",
"host", "host",
@ -93,12 +93,12 @@ const globalSectionConfigs: Record<
liveValidate: true, liveValidate: true,
}, },
database: { database: {
i18nNamespace: "config/database", i18nNamespace: "config/global",
fieldOrder: ["path"], fieldOrder: ["path"],
advancedFields: [], advancedFields: [],
}, },
auth: { auth: {
i18nNamespace: "config/auth", i18nNamespace: "config/global",
fieldOrder: [ fieldOrder: [
"enabled", "enabled",
"reset_admin_password", "reset_admin_password",
@ -130,17 +130,17 @@ const globalSectionConfigs: Record<
}, },
}, },
tls: { tls: {
i18nNamespace: "config/tls", i18nNamespace: "config/global",
fieldOrder: ["enabled", "cert", "key"], fieldOrder: ["enabled", "cert", "key"],
advancedFields: [], advancedFields: [],
}, },
networking: { networking: {
i18nNamespace: "config/networking", i18nNamespace: "config/global",
fieldOrder: ["ipv6"], fieldOrder: ["ipv6"],
advancedFields: [], advancedFields: [],
}, },
proxy: { proxy: {
i18nNamespace: "config/proxy", i18nNamespace: "config/global",
fieldOrder: [ fieldOrder: [
"header_map", "header_map",
"logout_url", "logout_url",
@ -152,7 +152,7 @@ const globalSectionConfigs: Record<
liveValidate: true, liveValidate: true,
}, },
ui: { ui: {
i18nNamespace: "config/ui", i18nNamespace: "config/global",
fieldOrder: [ fieldOrder: [
"timezone", "timezone",
"time_format", "time_format",
@ -163,22 +163,22 @@ const globalSectionConfigs: Record<
advancedFields: [], advancedFields: [],
}, },
logger: { logger: {
i18nNamespace: "config/logger", i18nNamespace: "config/global",
fieldOrder: ["default", "logs"], fieldOrder: ["default", "logs"],
advancedFields: ["logs"], advancedFields: ["logs"],
}, },
environment_vars: { environment_vars: {
i18nNamespace: "config/environment_vars", i18nNamespace: "config/global",
fieldOrder: [], fieldOrder: [],
advancedFields: [], advancedFields: [],
}, },
telemetry: { telemetry: {
i18nNamespace: "config/telemetry", i18nNamespace: "config/global",
fieldOrder: ["network_interfaces", "stats", "version_check"], fieldOrder: ["network_interfaces", "stats", "version_check"],
advancedFields: [], advancedFields: [],
}, },
birdseye: { birdseye: {
i18nNamespace: "config/birdseye", i18nNamespace: "config/global",
fieldOrder: [ fieldOrder: [
"enabled", "enabled",
"restream", "restream",
@ -193,7 +193,7 @@ const globalSectionConfigs: Record<
advancedFields: ["width", "height", "quality", "inactivity_threshold"], advancedFields: ["width", "height", "quality", "inactivity_threshold"],
}, },
ffmpeg: { ffmpeg: {
i18nNamespace: "config/ffmpeg", i18nNamespace: "config/global",
fieldOrder: [ fieldOrder: [
"path", "path",
"global_args", "global_args",
@ -253,12 +253,12 @@ const globalSectionConfigs: Record<
}, },
}, },
detectors: { detectors: {
i18nNamespace: "config/detectors", i18nNamespace: "config/global",
fieldOrder: [], fieldOrder: [],
advancedFields: [], advancedFields: [],
}, },
model: { model: {
i18nNamespace: "config/model", i18nNamespace: "config/global",
fieldOrder: [ fieldOrder: [
"path", "path",
"labelmap_path", "labelmap_path",
@ -278,7 +278,7 @@ const globalSectionConfigs: Record<
hiddenFields: ["labelmap", "attributes_map"], hiddenFields: ["labelmap", "attributes_map"],
}, },
genai: { genai: {
i18nNamespace: "config/genai", i18nNamespace: "config/global",
fieldOrder: [ fieldOrder: [
"provider", "provider",
"api_key", "api_key",
@ -291,22 +291,22 @@ const globalSectionConfigs: Record<
hiddenFields: ["genai.enabled_in_config"], hiddenFields: ["genai.enabled_in_config"],
}, },
classification: { classification: {
i18nNamespace: "config/classification", i18nNamespace: "config/global",
hiddenFields: ["custom"], hiddenFields: ["custom"],
advancedFields: [], advancedFields: [],
}, },
semantic_search: { semantic_search: {
i18nNamespace: "config/semantic_search", i18nNamespace: "config/global",
fieldOrder: ["enabled", "reindex", "model", "model_size", "device"], fieldOrder: ["enabled", "reindex", "model", "model_size", "device"],
advancedFields: ["reindex", "device"], advancedFields: ["reindex", "device"],
}, },
audio_transcription: { audio_transcription: {
i18nNamespace: "config/audio_transcription", i18nNamespace: "config/global",
fieldOrder: ["enabled", "language", "device", "model_size", "live_enabled"], fieldOrder: ["enabled", "language", "device", "model_size", "live_enabled"],
advancedFields: ["language", "device", "model_size"], advancedFields: ["language", "device", "model_size"],
}, },
face_recognition: { face_recognition: {
i18nNamespace: "config/face_recognition", i18nNamespace: "config/global",
fieldOrder: [ fieldOrder: [
"enabled", "enabled",
"model_size", "model_size",
@ -331,7 +331,7 @@ const globalSectionConfigs: Record<
], ],
}, },
lpr: { lpr: {
i18nNamespace: "config/lpr", i18nNamespace: "config/global",
fieldOrder: [ fieldOrder: [
"enabled", "enabled",
"model_size", "model_size",
@ -522,7 +522,8 @@ function GlobalConfigSection({
liveValidate={sectionConfig.liveValidate} liveValidate={sectionConfig.liveValidate}
uiSchema={sectionConfig.uiSchema} uiSchema={sectionConfig.uiSchema}
showSubmit={false} showSubmit={false}
i18nNamespace={sectionConfig.i18nNamespace} formContext={{ sectionI18nPrefix: sectionKey }}
i18nNamespace="config/global"
disabled={isSaving} disabled={isSaving}
/> />
@ -565,44 +566,7 @@ function GlobalConfigSection({
} }
export default function GlobalConfigView() { export default function GlobalConfigView() {
const { t } = useTranslation([ const { t } = useTranslation(["views/settings", "config/global", "common"]);
"views/settings",
"config/detect",
"config/record",
"config/snapshots",
"config/motion",
"config/objects",
"config/review",
"config/audio",
"config/notifications",
"config/live",
"config/timestamp_style",
"config/mqtt",
"config/audio_transcription",
"config/database",
"config/auth",
"config/tls",
"config/networking",
"config/proxy",
"config/ui",
"config/logger",
"config/environment_vars",
"config/telemetry",
"config/birdseye",
"config/ffmpeg",
"config/detectors",
"config/model",
"config/genai",
"config/classification",
"config/semantic_search",
"config/face_recognition",
"config/lpr",
"config/go2rtc",
"config/camera_groups",
"config/safe_mode",
"config/version",
"common",
]);
const [activeTab, setActiveTab] = useState("shared"); const [activeTab, setActiveTab] = useState("shared");
const [activeSection, setActiveSection] = useState("detect"); const [activeSection, setActiveSection] = useState("detect");
@ -695,11 +659,12 @@ export default function GlobalConfigView() {
<nav className="w-64 shrink-0"> <nav className="w-64 shrink-0">
<ul className="space-y-1"> <ul className="space-y-1">
{currentSections.map((section) => { {currentSections.map((section) => {
const sectionLabel = t("label", { const defaultLabel =
ns: section.i18nNamespace, section.key.charAt(0).toUpperCase() +
defaultValue: section.key.slice(1).replace(/_/g, " ");
section.key.charAt(0).toUpperCase() + const sectionLabel = t(`${section.key}.label`, {
section.key.slice(1).replace(/_/g, " "), ns: "config/global",
defaultValue: defaultLabel,
}); });
return ( return (
@ -735,8 +700,8 @@ export default function GlobalConfigView() {
)} )}
> >
<Heading as="h4" className="mb-4"> <Heading as="h4" className="mb-4">
{t("label", { {t(`${section.key}.label`, {
ns: section.i18nNamespace, ns: "config/global",
defaultValue: defaultValue:
section.key.charAt(0).toUpperCase() + section.key.charAt(0).toUpperCase() +
section.key.slice(1).replace(/_/g, " "), section.key.slice(1).replace(/_/g, " "),
@ -756,12 +721,21 @@ export default function GlobalConfigView() {
{activeTab === "system" && ( {activeTab === "system" && (
<> <>
{systemSections.map((sectionKey) => { {systemSections.map((sectionKey) => {
const sectionTitle = t("label", { const ns = globalSectionConfigs[sectionKey].i18nNamespace;
ns: globalSectionConfigs[sectionKey].i18nNamespace, const sectionTitle =
defaultValue: ns === "config/global"
sectionKey.charAt(0).toUpperCase() + ? t(`${sectionKey}.label`, {
sectionKey.slice(1).replace(/_/g, " "), ns: "config/global",
}); defaultValue:
sectionKey.charAt(0).toUpperCase() +
sectionKey.slice(1).replace(/_/g, " "),
})
: t("label", {
ns,
defaultValue:
sectionKey.charAt(0).toUpperCase() +
sectionKey.slice(1).replace(/_/g, " "),
});
return ( return (
<div <div
@ -786,12 +760,21 @@ export default function GlobalConfigView() {
{activeTab === "integrations" && ( {activeTab === "integrations" && (
<> <>
{integrationSections.map((sectionKey) => { {integrationSections.map((sectionKey) => {
const sectionTitle = t("label", { const ns = globalSectionConfigs[sectionKey].i18nNamespace;
ns: globalSectionConfigs[sectionKey].i18nNamespace, const sectionTitle =
defaultValue: ns === "config/global"
sectionKey.charAt(0).toUpperCase() + ? t(`${sectionKey}.label`, {
sectionKey.slice(1).replace(/_/g, " "), ns: "config/global",
}); defaultValue:
sectionKey.charAt(0).toUpperCase() +
sectionKey.slice(1).replace(/_/g, " "),
})
: t("label", {
ns,
defaultValue:
sectionKey.charAt(0).toUpperCase() +
sectionKey.slice(1).replace(/_/g, " "),
});
return ( return (
<div <div