feat: Add camera nickname (#19567)

* refactor: Refactor camera nickname

* fix: fix cameraNameLabel visually

* chore: The Explore search function also displays the Camera's nickname in English

* chore: add mobile page camera nickname

* feat: webpush support camera nickname

* fix: fix storage camera name is null

* chore: fix review detail and context menu camera nickname

* chore: fix use-stats and notification setting camera nickname

* fix: fix stats camera if not nickname need capitalize

* fix: fix debug page open camera web ui i18n and camera nickname support

* fix: fix camera metrics not use nickname

* refactor: refactor use-camera-nickname hook.
This commit is contained in:
GuoQing Liu
2025-08-26 11:15:01 -06:00
committed by GitHub
parent 195f705616
commit d3af748366
31 changed files with 276 additions and 99 deletions
+45 -18
View File
@@ -30,6 +30,12 @@ type ConfigSetBody = {
config_data: any;
update_topic?: string;
};
const generateFixedHash = (name: string): string => {
const encoded = encodeURIComponent(name);
const base64 = btoa(encoded);
const cleanHash = base64.replace(/[^a-zA-Z0-9]/g, "").substring(0, 8);
return `cam_${cleanHash.toLowerCase()}`;
};
const RoleEnum = z.enum(["audio", "detect", "record"]);
type Role = z.infer<typeof RoleEnum>;
@@ -54,10 +60,7 @@ export default function CameraEditForm({
z.object({
cameraName: z
.string()
.min(1, { message: t("camera.cameraConfig.nameRequired") })
.regex(/^[a-zA-Z0-9_-]+$/, {
message: t("camera.cameraConfig.nameInvalid"),
}),
.min(1, { message: t("camera.cameraConfig.nameRequired") }),
enabled: z.boolean(),
ffmpeg: z.object({
inputs: z
@@ -101,26 +104,37 @@ export default function CameraEditForm({
type FormValues = z.infer<typeof formSchema>;
// Determine available roles for default values
const usedRoles = useMemo(() => {
const roles = new Set<Role>();
if (cameraName && config?.cameras[cameraName]) {
const camera = config.cameras[cameraName];
camera.ffmpeg?.inputs?.forEach((input) => {
input.roles.forEach((role) => roles.add(role as Role));
});
const cameraInfo = useMemo(() => {
if (!cameraName || !config?.cameras[cameraName]) {
return {
nickname: undefined,
name: cameraName || "",
roles: new Set<Role>(),
};
}
return roles;
const camera = config.cameras[cameraName];
const roles = new Set<Role>();
camera.ffmpeg?.inputs?.forEach((input) => {
input.roles.forEach((role) => roles.add(role as Role));
});
return {
nickname: camera?.nickname || cameraName,
name: cameraName,
roles,
};
}, [cameraName, config]);
const defaultValues: FormValues = {
cameraName: cameraName || "",
cameraName: cameraInfo?.nickname || cameraName || "",
enabled: true,
ffmpeg: {
inputs: [
{
path: "",
roles: usedRoles.has("detect") ? [] : ["detect"],
roles: cameraInfo.roles.has("detect") ? [] : ["detect"],
},
],
},
@@ -154,10 +168,19 @@ export default function CameraEditForm({
const saveCameraConfig = (values: FormValues) => {
setIsLoading(true);
let finalCameraName = values.cameraName;
let nickname: string | undefined = undefined;
const isValidName = /^[a-zA-Z0-9_-]+$/.test(values.cameraName);
if (!isValidName) {
finalCameraName = generateFixedHash(finalCameraName);
nickname = values.cameraName;
}
const configData: ConfigSetBody["config_data"] = {
cameras: {
[values.cameraName]: {
[finalCameraName]: {
enabled: values.enabled,
...(nickname && { nickname }),
ffmpeg: {
inputs: values.ffmpeg.inputs.map((input) => ({
path: input.path,
@@ -175,7 +198,7 @@ export default function CameraEditForm({
// Add update_topic for new cameras
if (!cameraName) {
requestBody.update_topic = `config/cameras/${values.cameraName}/add`;
requestBody.update_topic = `config/cameras/${finalCameraName}/add`;
}
axios
@@ -209,7 +232,11 @@ export default function CameraEditForm({
};
const onSubmit = (values: FormValues) => {
if (cameraName && values.cameraName !== cameraName) {
if (
cameraName &&
values.cameraName !== cameraName &&
values.cameraName !== cameraInfo?.nickname
) {
// If camera name changed, delete old camera config
const deleteRequestBody: ConfigSetBody = {
requires_restart: 1,
@@ -33,6 +33,7 @@ import { Link } from "react-router-dom";
import { LiveStreamMetadata } from "@/types/live";
import { Trans, useTranslation } from "react-i18next";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { useCameraNickname } from "@/hooks/use-camera-nickname";
type CameraStreamingDialogProps = {
camera: string;
@@ -56,6 +57,8 @@ export function CameraStreamingDialog({
const { getLocaleDocUrl } = useDocDomain();
const { data: config } = useSWR<FrigateConfig>("config");
const cameraName = useCameraNickname(camera);
const [isLoading, setIsLoading] = useState(false);
const [streamName, setStreamName] = useState(
@@ -190,7 +193,7 @@ export function CameraStreamingDialog({
<DialogHeader className="mb-4">
<DialogTitle className="smart-capitalize">
{t("group.camera.setting.title", {
cameraName: camera.replaceAll("_", " "),
cameraName: cameraName,
})}
</DialogTitle>
<DialogDescription>