Files
frigate/web/src/utils/cameraUtil.ts
T
Josh HawkinsandGitHub caa6edecac Migrate web to ESLint 10 flat config (#24326)
* migrate web to eslint 10 flat config

ESLint 10 dropped `.eslintrc` support, so `.eslintrc.cjs` is replaced with `eslint.config.js` and the lint scripts no longer pass `--ext` or `--ignore-path`. typescript-eslint moves to 8, react-hooks to 7, and react-refresh to 0.5, and the unused jest and vitest-globals plugins are removed. Lint behaves as it did before: catch variables aren't checked, unused disable directives aren't reported, and rules newly added to the recommended sets are off until the code passes them. typescript-eslint 8 flags constants used only in `typeof`, so those are now exported, or replaced with a union type where the export would trip react-refresh.

* fix lint findings from the eslint 10 recommended rules

Remove the rule overrides from the flat config migration and fix what they were hiding. Unused catch bindings are dropped, 20 disable directives that suppressed nothing are removed (react-hooks 5.2 and 7.1.1 report identical exhaustive-deps findings with inline config ignored), dead initial values are dropped, short-circuit calls become if statements or optional calls, rethrown errors pass `cause`, and the disabled "No recordings" tooltip in `ReviewTimeline` is removed along with its memo and the `getRecordingAvailability` prop. The 3 react-refresh warnings for files that export contexts or classes are left for a later refactor.
2026-09-14 07:53:45 -06:00

231 lines
6.1 KiB
TypeScript

import { baseUrl } from "@/api/baseUrl";
import { generateFixedHash, isValidId } from "./stringUtil";
import type { LiveStreamMetadata } from "@/types/live";
import type { StreamConfig } from "@/types/cameraWizard";
/**
* Processes a user-entered camera name and returns both the final camera name
* and friendly name for Frigate configuration.
*
* @param userInput - The name entered by the user (could be display name)
* @returns Object with finalCameraName and friendlyName
*/
export function processCameraName(userInput: string): {
finalCameraName: string;
friendlyName?: string;
} {
const normalizedInput = userInput.replace(/\s+/g, "_").toLowerCase();
if (isValidId(normalizedInput)) {
return {
finalCameraName: normalizedInput,
friendlyName: userInput.includes(" ") ? userInput : undefined,
};
}
return {
finalCameraName: generateFixedHash(userInput, "cam"),
friendlyName: userInput,
};
}
/**
* Detect Reolink camera capabilities and recommend optimal protocol
*
* Calls the Frigate backend API which queries the Reolink camera to determine
* its resolution and recommends either http-flv (for 5MP and below) or rtsp
* (for higher resolutions).
*
* @param host - Camera IP address or hostname
* @param username - Camera username
* @param password - Camera password
* @returns The recommended protocol key ("http-flv" or "rtsp"), or null if detection failed
*/
export async function detectReolinkCamera(
host: string,
username: string,
password: string,
): Promise<"http-flv" | "rtsp" | null> {
try {
const params = new URLSearchParams({
host,
username,
password,
});
const response = await fetch(
`${baseUrl}api/reolink/detect?${params.toString()}`,
{
method: "GET",
},
);
if (!response.ok) {
return null;
}
const data = await response.json();
if (data.success && data.protocol) {
return data.protocol;
}
return null;
} catch {
return null;
}
}
/**
* Mask credentials in RTSP URIs for display
*/
export function maskUri(uri: string): string {
try {
// Handle RTSP URLs with user:pass@host format
// Use greedy match for password to handle passwords with @
const rtspMatch = uri.match(/rtsp:\/\/([^:]+):(.+)@(.+)/);
if (rtspMatch) {
return `rtsp://${rtspMatch[1]}:${"*".repeat(4)}@${rtspMatch[3]}`;
}
// Handle HTTP/HTTPS URLs with password query parameter
const urlObj = new URL(uri);
if (urlObj.searchParams.has("password")) {
urlObj.searchParams.set("password", "*".repeat(4));
return urlObj.toString();
}
} catch {
// ignore
}
return uri;
}
/**
* Represents the audio features supported by a camera stream
*/
export type CameraAudioFeatures = {
twoWayAudio: boolean;
audioOutput: boolean;
};
/**
* Detects camera audio features from go2rtc stream metadata.
* Checks for two-way audio (backchannel) and audio output capabilities.
*
* @param metadata - The LiveStreamMetadata from go2rtc stream
* @param requireSecureContext - If true, two-way audio requires secure context (default: true)
* @returns CameraAudioFeatures object with detected capabilities
*/
/**
* Calculates optimal detect dimensions from stream resolution.
*
* Scales dimensions to an efficient size for object detection while
* preserving the stream's aspect ratio. Does not upscale.
*
* @param streamWidth - Native stream width in pixels
* @param streamHeight - Native stream height in pixels
* @returns Detect dimensions with even values, or null if inputs are invalid
*/
// Target size for the smaller dimension (width or height) for detect streams
export const DETECT_TARGET_PX = 720;
export function calculateDetectDimensions(
streamWidth: number,
streamHeight: number,
): { width: number; height: number } | null {
if (
!Number.isFinite(streamWidth) ||
!Number.isFinite(streamHeight) ||
streamWidth <= 0 ||
streamHeight <= 0
) {
return null;
}
const smallerDim = Math.min(streamWidth, streamHeight);
const target = Math.min(DETECT_TARGET_PX, smallerDim);
const scale = target / smallerDim;
let width = Math.round(streamWidth * scale);
let height = Math.round(streamHeight * scale);
// Round down to even numbers (required for video processing)
width = width - (width % 2);
height = height - (height % 2);
if (width < 2 || height < 2) {
return null;
}
return { width, height };
}
export function detectCameraAudioFeatures(
metadata: LiveStreamMetadata | null | undefined,
requireSecureContext: boolean = true,
): CameraAudioFeatures {
if (!metadata) {
return {
twoWayAudio: false,
audioOutput: false,
};
}
const twoWayAudio =
(!requireSecureContext || window.isSecureContext) &&
metadata.producers.find(
(prod) =>
prod.medias &&
prod.medias.find((media) => media.includes("audio, sendonly")) !=
undefined,
) != undefined;
const audioOutput =
metadata.producers.find(
(prod) =>
prod.medias &&
prod.medias.find((media) => media.includes("audio, recvonly")) !=
undefined,
) != undefined;
return {
twoWayAudio: !!twoWayAudio,
audioOutput: !!audioOutput,
};
}
const REPLAY_CAMERA_PREFIX = "_replay_";
/**
* Check if a camera name is a debug replay camera.
*
* @param name - The camera name to check
* @returns true if the camera is a replay camera
*/
export function isReplayCamera(name: string): boolean {
return name.startsWith(REPLAY_CAMERA_PREFIX);
}
const HEVC_CODEC_NAMES = ["hevc", "h265"];
function isHevcCodec(codec?: string): boolean {
return HEVC_CODEC_NAMES.includes((codec ?? "").trim().toLowerCase());
}
function isRecordingStream(stream: StreamConfig): boolean {
return stream.roles.includes("record") || stream.roles.includes("record_sub");
}
/**
* First recording stream probed as H.265. The other record output's codec
* doesn't matter: ffmpeg drops `-tag:v hvc1` on anything that isn't HEVC.
*/
export function hevcRecordingStreamId(
streams: StreamConfig[],
): string | undefined {
return streams.find(
(s) => isRecordingStream(s) && isHevcCodec(s.testResult?.videoCodec),
)?.id;
}