mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-26 12:18:57 +03:00
Add import/export for camera group layouts and per-camera streaming settings (#24025)
* add import/export for camera group layouts and streaming settings Camera group layouts and per-camera streaming settings are stored in the browser's IndexedDB, so they are tied to a single browser on a single device. Users with more than one device have to rebuild every group layout and re-pick every camera's stream settings by hand, and clearing browser data loses the work. Add a Backup & Restore card to Settings > UI Settings that exports these settings to a JSON file and imports that file on another device. Import shows a confirmation dialog with per-section counts, switches for layouts, streaming settings, and UI preferences, and warnings about camera groups or cameras in the file that are not on this server. Server-side storage is deliberately avoided. These are per-device presentation settings: a layout arranged for a desktop is wrong on a tablet, and continuous full-resolution streams that are free on a wired LAN are not on a phone. An explicit file moves settings only when the user chooses to move them. Implementation notes: - web/src/utils/uiSettingsTransfer.ts owns a registry of transferable IndexedDB keys. Each entry records whether the key is user-namespaced, matching which persistence hook wrote it, plus a zod schema for its value. - Only registry-known keys are ever written, and only when their value passes that schema. The file format deliberately lets unknown keys survive parsing, so this filter is what prevents a hand-edited file from writing arbitrary storage keys or out-of-range values. - Export falls back to the legacy un-namespaced key, because the username migration runs lazily on first mount of each owning hook. - Streaming settings merge per group rather than replacing the whole map, so groups configured only on the receiving device survive. - Import writes storage and then reloads, because useUserPersistence reads a key only on mount and StreamingSettingsProvider would otherwise write its stale in-memory state back over the import. - playbackBandwidthEstimate, frigate-search-history, and live-layout are excluded: the first two are measurements and user data rather than preferences, and live-layout's default is derived from the device. * merge imported streaming settings per camera instead of per group
This commit is contained in:
committed by
Nicolas Mowen
parent
27a40a507b
commit
257a05a7e2
@@ -0,0 +1,459 @@
|
||||
import { get as getData, set as setData } from "idb-keyval";
|
||||
import { z } from "zod";
|
||||
import { getUserNamespacedKey } from "@/hooks/use-user-persistence";
|
||||
|
||||
export const UI_SETTINGS_FILE_TYPE = "frigate-ui-settings";
|
||||
export const UI_SETTINGS_FILE_VERSION = 1;
|
||||
|
||||
export type TransferSection = "layouts" | "streaming" | "preferences";
|
||||
|
||||
const cameraStreamingSettingsSchema = z.object({
|
||||
streamName: z.string(),
|
||||
streamType: z.enum(["no-streaming", "smart", "continuous"]),
|
||||
compatibilityMode: z.boolean(),
|
||||
playAudio: z.boolean(),
|
||||
volume: z.number(),
|
||||
});
|
||||
|
||||
const allGroupsStreamingSettingsSchema = z.record(
|
||||
z.string(),
|
||||
z.record(z.string(), cameraStreamingSettingsSchema),
|
||||
);
|
||||
|
||||
type TransferKey = {
|
||||
key: string;
|
||||
section: Exclude<TransferSection, "layouts">;
|
||||
namespaced: boolean;
|
||||
// preference values are validated individually on import: an out-of-range
|
||||
// value would otherwise be written and crash the view that reads it
|
||||
schema: z.ZodType;
|
||||
};
|
||||
|
||||
// Single source of truth for which browser-stored settings move between
|
||||
// devices. Layout keys are derived per camera group at export time and so
|
||||
// are not listed here. `namespaced` mirrors which persistence hook wrote
|
||||
// the key: useUserPersistence namespaces by username, usePersistence does
|
||||
// not.
|
||||
export const TRANSFER_KEYS: TransferKey[] = [
|
||||
{
|
||||
key: "streaming-settings",
|
||||
section: "streaming",
|
||||
namespaced: true,
|
||||
schema: allGroupsStreamingSettingsSchema,
|
||||
},
|
||||
{
|
||||
key: "autoLiveView",
|
||||
section: "preferences",
|
||||
namespaced: true,
|
||||
schema: z.boolean(),
|
||||
},
|
||||
{
|
||||
key: "displayCameraNames",
|
||||
section: "preferences",
|
||||
namespaced: true,
|
||||
schema: z.boolean(),
|
||||
},
|
||||
{
|
||||
key: "alertVideos",
|
||||
section: "preferences",
|
||||
namespaced: true,
|
||||
schema: z.boolean(),
|
||||
},
|
||||
{
|
||||
key: "liveFallbackTimeout",
|
||||
section: "preferences",
|
||||
namespaced: true,
|
||||
schema: z.number().int().min(1).max(60),
|
||||
},
|
||||
{
|
||||
key: "playbackRate",
|
||||
section: "preferences",
|
||||
namespaced: true,
|
||||
schema: z.number().positive().max(64),
|
||||
},
|
||||
{
|
||||
key: "weekStartsOn",
|
||||
section: "preferences",
|
||||
namespaced: true,
|
||||
schema: z.union([z.literal(0), z.literal(1)]),
|
||||
},
|
||||
{
|
||||
key: "showReviewed",
|
||||
section: "preferences",
|
||||
namespaced: true,
|
||||
schema: z.boolean(),
|
||||
},
|
||||
{
|
||||
key: "exploreGridColumns",
|
||||
section: "preferences",
|
||||
namespaced: true,
|
||||
schema: z.number().int().min(1).max(12),
|
||||
},
|
||||
{
|
||||
key: "exploreDefaultView",
|
||||
section: "preferences",
|
||||
namespaced: true,
|
||||
schema: z.enum(["summary", "grid"]),
|
||||
},
|
||||
{
|
||||
key: "detailStreamActiveExpanded",
|
||||
section: "preferences",
|
||||
namespaced: true,
|
||||
schema: z.boolean(),
|
||||
},
|
||||
{
|
||||
key: "hlsPlayerMuted",
|
||||
section: "preferences",
|
||||
namespaced: true,
|
||||
schema: z.boolean(),
|
||||
},
|
||||
{
|
||||
key: "recordingQuality",
|
||||
section: "preferences",
|
||||
namespaced: false,
|
||||
schema: z.enum(["auto", "main", "sub"]),
|
||||
},
|
||||
{
|
||||
key: "chat-show-stats",
|
||||
section: "preferences",
|
||||
namespaced: false,
|
||||
schema: z.enum(["while_generating", "always"]),
|
||||
},
|
||||
{
|
||||
key: "chat-auto-scroll",
|
||||
section: "preferences",
|
||||
namespaced: false,
|
||||
schema: z.boolean(),
|
||||
},
|
||||
{
|
||||
key: "chat-thinking-enabled",
|
||||
section: "preferences",
|
||||
namespaced: false,
|
||||
schema: z.boolean(),
|
||||
},
|
||||
];
|
||||
|
||||
export function layoutKeyForGroup(group: string): string {
|
||||
return `${group}-draggable-layout`;
|
||||
}
|
||||
|
||||
function storageKey(
|
||||
entry: { key: string; namespaced: boolean },
|
||||
username: string | undefined,
|
||||
): string {
|
||||
return entry.namespaced
|
||||
? getUserNamespacedKey(entry.key, username)
|
||||
: entry.key;
|
||||
}
|
||||
|
||||
// useUserPersistence migrates legacy un-namespaced keys lazily, on first
|
||||
// mount of the hook that owns each key, so a value from a view the user
|
||||
// has not opened since upgrading still lives under the bare key.
|
||||
async function readTransferable(
|
||||
key: string,
|
||||
namespaced: boolean,
|
||||
username: string | undefined,
|
||||
) {
|
||||
if (!namespaced) {
|
||||
return getData(key);
|
||||
}
|
||||
|
||||
const namespacedKey = getUserNamespacedKey(key, username);
|
||||
const value = await getData(namespacedKey);
|
||||
|
||||
if (value !== undefined || namespacedKey === key) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return getData(key);
|
||||
}
|
||||
|
||||
// Layout items carry optional react-grid-layout fields (minW, static,
|
||||
// moved, and others) that vary by version, so unknown keys pass through
|
||||
// rather than failing validation.
|
||||
const layoutItemSchema = z
|
||||
.object({
|
||||
i: z.string(),
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
w: z.number(),
|
||||
h: z.number(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const uiSettingsFileSchema = z.object({
|
||||
type: z.literal(UI_SETTINGS_FILE_TYPE),
|
||||
version: z.number().int().positive(),
|
||||
exported_at: z.string(),
|
||||
frigate_version: z.string(),
|
||||
sections: z.object({
|
||||
layouts: z.record(z.string(), z.array(layoutItemSchema)),
|
||||
streaming: allGroupsStreamingSettingsSchema,
|
||||
preferences: z.record(z.string(), z.unknown()),
|
||||
}),
|
||||
});
|
||||
|
||||
export type UiSettingsFile = z.infer<typeof uiSettingsFileSchema>;
|
||||
|
||||
export async function buildExportPayload(
|
||||
groupNames: string[],
|
||||
frigateVersion: string,
|
||||
username: string | undefined,
|
||||
): Promise<UiSettingsFile> {
|
||||
const layouts: UiSettingsFile["sections"]["layouts"] = {};
|
||||
let streaming: UiSettingsFile["sections"]["streaming"] = {};
|
||||
const preferences: UiSettingsFile["sections"]["preferences"] = {};
|
||||
|
||||
await Promise.all(
|
||||
groupNames.map(async (group) => {
|
||||
const value = await readTransferable(
|
||||
layoutKeyForGroup(group),
|
||||
true,
|
||||
username,
|
||||
);
|
||||
|
||||
if (value !== undefined) {
|
||||
layouts[group] = value;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
TRANSFER_KEYS.map(async (entry) => {
|
||||
const value = await readTransferable(
|
||||
entry.key,
|
||||
entry.namespaced,
|
||||
username,
|
||||
);
|
||||
|
||||
if (value === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.section === "streaming") {
|
||||
// this key holds the entire group -> camera map, so its value is
|
||||
// the section payload rather than one entry within it
|
||||
streaming = value;
|
||||
return;
|
||||
}
|
||||
|
||||
preferences[entry.key] = value;
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
type: UI_SETTINGS_FILE_TYPE,
|
||||
version: UI_SETTINGS_FILE_VERSION,
|
||||
exported_at: new Date().toISOString(),
|
||||
frigate_version: frigateVersion,
|
||||
sections: { layouts, streaming, preferences },
|
||||
};
|
||||
}
|
||||
|
||||
export function exportFileName(now: Date): string {
|
||||
// local date rather than toISOString: a user west of UTC exporting in
|
||||
// the evening would otherwise get tomorrow's date in the filename
|
||||
const month = `${now.getMonth() + 1}`.padStart(2, "0");
|
||||
const day = `${now.getDate()}`.padStart(2, "0");
|
||||
|
||||
return `frigate-ui-settings-${now.getFullYear()}-${month}-${day}.json`;
|
||||
}
|
||||
|
||||
export function downloadJson(payload: unknown, fileName: string): void {
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const element = document.createElement("a");
|
||||
|
||||
element.href = url;
|
||||
element.download = fileName;
|
||||
element.style.display = "none";
|
||||
document.body.appendChild(element);
|
||||
element.click();
|
||||
document.body.removeChild(element);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export type ParseError =
|
||||
| "invalid_json"
|
||||
| "wrong_type"
|
||||
| "unsupported_version"
|
||||
| "invalid_schema";
|
||||
|
||||
export type ParseResult =
|
||||
| { ok: true; file: UiSettingsFile }
|
||||
| { ok: false; error: ParseError };
|
||||
|
||||
export function parseUiSettingsFile(text: string): ParseResult {
|
||||
let raw: unknown;
|
||||
|
||||
try {
|
||||
raw = JSON.parse(text);
|
||||
} catch {
|
||||
return { ok: false, error: "invalid_json" };
|
||||
}
|
||||
|
||||
if (
|
||||
typeof raw !== "object" ||
|
||||
raw === null ||
|
||||
(raw as { type?: unknown }).type !== UI_SETTINGS_FILE_TYPE
|
||||
) {
|
||||
return { ok: false, error: "wrong_type" };
|
||||
}
|
||||
|
||||
const version = (raw as { version?: unknown }).version;
|
||||
|
||||
if (typeof version === "number" && version > UI_SETTINGS_FILE_VERSION) {
|
||||
return { ok: false, error: "unsupported_version" };
|
||||
}
|
||||
|
||||
const parsed = uiSettingsFileSchema.safeParse(raw);
|
||||
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: "invalid_schema" };
|
||||
}
|
||||
|
||||
return { ok: true, file: parsed.data };
|
||||
}
|
||||
|
||||
export type ImportSummary = {
|
||||
layoutGroupCount: number;
|
||||
streamingCameraCount: number;
|
||||
preferenceCount: number;
|
||||
// unknown groups are split by the section that named them so the dialog
|
||||
// can warn only about sections the user is actually importing
|
||||
unknownLayoutGroups: string[];
|
||||
unknownStreamingGroups: string[];
|
||||
unknownCameras: string[];
|
||||
};
|
||||
|
||||
// the only place preference entries are accepted: the import counts and the
|
||||
// writes must agree, or the dialog reports settings it will not apply
|
||||
function validPreferenceEntries(
|
||||
preferences: UiSettingsFile["sections"]["preferences"],
|
||||
): { entry: TransferKey; value: unknown }[] {
|
||||
const accepted: { entry: TransferKey; value: unknown }[] = [];
|
||||
|
||||
TRANSFER_KEYS.forEach((entry) => {
|
||||
if (entry.section !== "preferences") {
|
||||
return;
|
||||
}
|
||||
|
||||
// hasOwnProperty rather than `in`, which walks the prototype chain
|
||||
if (!Object.prototype.hasOwnProperty.call(preferences, entry.key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = entry.schema.safeParse(preferences[entry.key]);
|
||||
|
||||
if (parsed.success) {
|
||||
accepted.push({ entry, value: parsed.data });
|
||||
}
|
||||
});
|
||||
|
||||
return accepted;
|
||||
}
|
||||
|
||||
export function summarizeImport(
|
||||
file: UiSettingsFile,
|
||||
knownGroups: string[],
|
||||
knownCameras: string[],
|
||||
): ImportSummary {
|
||||
const layoutGroups = Object.keys(file.sections.layouts);
|
||||
const streamingGroups = Object.keys(file.sections.streaming);
|
||||
const streamingCameras = new Set<string>();
|
||||
|
||||
Object.values(file.sections.streaming).forEach((group) =>
|
||||
Object.keys(group).forEach((camera) => streamingCameras.add(camera)),
|
||||
);
|
||||
|
||||
const knownGroupSet = new Set(knownGroups);
|
||||
const knownCameraSet = new Set(knownCameras);
|
||||
|
||||
return {
|
||||
layoutGroupCount: layoutGroups.length,
|
||||
streamingCameraCount: streamingCameras.size,
|
||||
preferenceCount: validPreferenceEntries(file.sections.preferences).length,
|
||||
unknownLayoutGroups: layoutGroups
|
||||
.filter((group) => !knownGroupSet.has(group))
|
||||
.sort(),
|
||||
unknownStreamingGroups: streamingGroups
|
||||
.filter((group) => !knownGroupSet.has(group))
|
||||
.sort(),
|
||||
unknownCameras: Array.from(streamingCameras)
|
||||
.filter((camera) => !knownCameraSet.has(camera))
|
||||
.sort(),
|
||||
};
|
||||
}
|
||||
|
||||
export function hasImportableContent(summary: ImportSummary): boolean {
|
||||
return (
|
||||
summary.layoutGroupCount > 0 ||
|
||||
summary.streamingCameraCount > 0 ||
|
||||
summary.preferenceCount > 0
|
||||
);
|
||||
}
|
||||
|
||||
export async function applyImportPayload(
|
||||
file: UiSettingsFile,
|
||||
sections: Record<TransferSection, boolean>,
|
||||
username: string | undefined,
|
||||
): Promise<void> {
|
||||
const writes: Promise<void>[] = [];
|
||||
|
||||
if (sections.layouts) {
|
||||
Object.entries(file.sections.layouts).forEach(([group, layout]) => {
|
||||
writes.push(
|
||||
setData(
|
||||
getUserNamespacedKey(layoutKeyForGroup(group), username),
|
||||
layout,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const streamingEntry = TRANSFER_KEYS.find(
|
||||
(entry) => entry.section === "streaming",
|
||||
);
|
||||
|
||||
if (
|
||||
sections.streaming &&
|
||||
streamingEntry &&
|
||||
Object.keys(file.sections.streaming).length > 0
|
||||
) {
|
||||
writes.push(
|
||||
(async () => {
|
||||
// one key holds every group and every camera within it, so merge
|
||||
// at camera level: a group or a camera configured only on this
|
||||
// device must survive an import that does not mention it
|
||||
const existing =
|
||||
(await readTransferable(
|
||||
streamingEntry.key,
|
||||
streamingEntry.namespaced,
|
||||
username,
|
||||
)) ?? {};
|
||||
|
||||
const merged: UiSettingsFile["sections"]["streaming"] = {
|
||||
...existing,
|
||||
};
|
||||
|
||||
Object.entries(file.sections.streaming).forEach(([group, cameras]) => {
|
||||
merged[group] = { ...(existing[group] ?? {}), ...cameras };
|
||||
});
|
||||
|
||||
await setData(storageKey(streamingEntry, username), merged);
|
||||
})(),
|
||||
);
|
||||
}
|
||||
|
||||
if (sections.preferences) {
|
||||
validPreferenceEntries(file.sections.preferences).forEach(
|
||||
({ entry, value }) => {
|
||||
writes.push(setData(storageKey(entry, username), value));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(writes);
|
||||
}
|
||||
Reference in New Issue
Block a user