mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-28 19:06:52 +03:00
* build out system health pane * tweaks * fixes * fix notice link so it opens the correct camera * tweak language
299 lines
7.9 KiB
TypeScript
299 lines
7.9 KiB
TypeScript
import type { TFunction } from "i18next";
|
|
import { parseRestreamStreamName } from "@/components/config-form/theme/fields/streamSource";
|
|
import type { CameraBrand, StreamRole, TestResult } from "@/types/cameraWizard";
|
|
|
|
export type StreamIssue = {
|
|
type: "good" | "warning" | "error";
|
|
message: string;
|
|
/** stable key for the rule that fired, for filtering and tests */
|
|
rule: string;
|
|
};
|
|
|
|
export type StreamIssueInput = {
|
|
url: string;
|
|
roles: StreamRole[];
|
|
brand?: CameraBrand;
|
|
useFfmpeg?: boolean;
|
|
restream?: boolean;
|
|
testResult?: TestResult;
|
|
};
|
|
|
|
type ProbeStream = {
|
|
codec_type?: string;
|
|
codec_name?: string;
|
|
width?: number;
|
|
height?: number;
|
|
avg_frame_rate?: string;
|
|
};
|
|
|
|
export type FfprobeEntry = {
|
|
return_code?: number;
|
|
stdout?: { streams?: ProbeStream[] } | string;
|
|
/** the backend sends a list of non-empty lines on failure */
|
|
stderr?: string | string[];
|
|
};
|
|
|
|
function errorText(stderr: string | string[] | undefined): string {
|
|
const text = Array.isArray(stderr) ? stderr.join("\n") : stderr;
|
|
return text?.trim() || "Unknown error";
|
|
}
|
|
|
|
/** The human-readable end of an ffprobe error; the first lines are plumbing. */
|
|
export function lastErrorLine(error: string | undefined): string {
|
|
const lines = (error ?? "")
|
|
.split("\n")
|
|
.map((line) => line.trim())
|
|
.filter(Boolean);
|
|
return lines[lines.length - 1] ?? "";
|
|
}
|
|
|
|
/** Parse one entry of the ffprobe API response the way the wizard does. */
|
|
export function ffprobeToTestResult(
|
|
entry: FfprobeEntry | undefined,
|
|
): TestResult {
|
|
if (!entry || entry.return_code !== 0 || typeof entry.stdout !== "object") {
|
|
return { success: false, error: errorText(entry?.stderr) };
|
|
}
|
|
|
|
const streams = entry.stdout?.streams ?? [];
|
|
const videoStream = streams.find(
|
|
(s) =>
|
|
s.codec_type === "video" ||
|
|
s.codec_name?.includes("h264") ||
|
|
s.codec_name?.includes("h265"),
|
|
);
|
|
const audioStream = streams.find(
|
|
(s) =>
|
|
s.codec_type === "audio" ||
|
|
s.codec_name?.includes("aac") ||
|
|
s.codec_name?.includes("mp3"),
|
|
);
|
|
|
|
const resolution = videoStream
|
|
? `${videoStream.width}x${videoStream.height}`
|
|
: undefined;
|
|
const fps = videoStream?.avg_frame_rate
|
|
? parseFloat(videoStream.avg_frame_rate.split("/")[0]) /
|
|
parseFloat(videoStream.avg_frame_rate.split("/")[1])
|
|
: undefined;
|
|
|
|
return {
|
|
success: true,
|
|
resolution,
|
|
videoCodec: videoStream?.codec_name,
|
|
audioCodec: audioStream?.codec_name,
|
|
fps: fps && !isNaN(fps) ? fps : undefined,
|
|
};
|
|
}
|
|
|
|
/** The wizard's Stream Validation rules, unchanged, over plain input. */
|
|
export function getStreamIssues(
|
|
input: StreamIssueInput,
|
|
t: TFunction,
|
|
): StreamIssue[] {
|
|
const result: StreamIssue[] = [];
|
|
const { roles, testResult } = input;
|
|
|
|
if (input.brand === "reolink") {
|
|
const streamUrl = input.url.toLowerCase();
|
|
if (streamUrl.startsWith("rtsp://")) {
|
|
result.push({
|
|
type: "warning",
|
|
rule: "reolink-rtsp",
|
|
message: t("cameraWizard.step4.issues.brands.reolink-rtsp", {
|
|
ns: "views/settings",
|
|
}),
|
|
});
|
|
}
|
|
|
|
if (streamUrl.startsWith("http://") && !input.useFfmpeg) {
|
|
result.push({
|
|
type: "warning",
|
|
rule: "reolink-http",
|
|
message: t("cameraWizard.step4.issues.brands.reolink-http", {
|
|
ns: "views/settings",
|
|
}),
|
|
});
|
|
}
|
|
}
|
|
|
|
if (testResult?.videoCodec) {
|
|
const videoCodec = testResult.videoCodec.toLowerCase();
|
|
if (["h264", "h265", "hevc"].includes(videoCodec)) {
|
|
result.push({
|
|
type: "good",
|
|
rule: "video-codec",
|
|
message: t("cameraWizard.step4.issues.videoCodecGood", {
|
|
ns: "views/settings",
|
|
codec: testResult.videoCodec,
|
|
}),
|
|
});
|
|
}
|
|
}
|
|
|
|
if (roles.includes("record")) {
|
|
if (testResult?.audioCodec) {
|
|
const audioCodec = testResult.audioCodec.toLowerCase();
|
|
if (audioCodec === "aac") {
|
|
result.push({
|
|
type: "good",
|
|
rule: "audio-codec",
|
|
message: t("cameraWizard.step4.issues.audioCodecGood", {
|
|
ns: "views/settings",
|
|
codec: testResult.audioCodec,
|
|
}),
|
|
});
|
|
} else {
|
|
result.push({
|
|
type: "error",
|
|
rule: "audio-codec-record",
|
|
message: t("cameraWizard.step4.issues.audioCodecRecordError", {
|
|
ns: "views/settings",
|
|
}),
|
|
});
|
|
}
|
|
} else {
|
|
result.push({
|
|
type: "warning",
|
|
rule: "no-audio",
|
|
message: t("cameraWizard.step4.issues.noAudioWarning", {
|
|
ns: "views/settings",
|
|
}),
|
|
});
|
|
}
|
|
}
|
|
|
|
if (roles.includes("audio") && !testResult?.audioCodec) {
|
|
result.push({
|
|
type: "error",
|
|
rule: "audio-required",
|
|
message: t("cameraWizard.step4.issues.audioCodecRequired", {
|
|
ns: "views/settings",
|
|
}),
|
|
});
|
|
}
|
|
|
|
if (roles.includes("record") && input.restream) {
|
|
result.push({
|
|
type: "warning",
|
|
rule: "restream",
|
|
message: t("cameraWizard.step4.issues.restreamingWarning", {
|
|
ns: "views/settings",
|
|
}),
|
|
});
|
|
}
|
|
|
|
if (roles.includes("detect") && testResult) {
|
|
const probedResolution = testResult.resolution;
|
|
let probedWidth = 0;
|
|
let probedHeight = 0;
|
|
if (probedResolution) {
|
|
const [w, h] = probedResolution.split("x").map(Number);
|
|
if (!isNaN(w) && !isNaN(h)) {
|
|
probedWidth = w;
|
|
probedHeight = h;
|
|
}
|
|
}
|
|
|
|
if (probedWidth <= 0 || probedHeight <= 0) {
|
|
result.push({
|
|
type: "error",
|
|
rule: "resolution-unknown",
|
|
message: t("cameraWizard.step4.issues.resolutionUnknown", {
|
|
ns: "views/settings",
|
|
}),
|
|
});
|
|
} else {
|
|
const minDimension = Math.min(probedWidth, probedHeight);
|
|
const maxDimension = Math.max(probedWidth, probedHeight);
|
|
if (minDimension > 1080) {
|
|
result.push({
|
|
type: "warning",
|
|
rule: "resolution-high",
|
|
message: t("cameraWizard.step4.issues.resolutionHigh", {
|
|
ns: "views/settings",
|
|
resolution: probedResolution,
|
|
}),
|
|
});
|
|
} else if (maxDimension < 640) {
|
|
result.push({
|
|
type: "error",
|
|
rule: "resolution-low",
|
|
message: t("cameraWizard.step4.issues.resolutionLow", {
|
|
ns: "views/settings",
|
|
resolution: probedResolution,
|
|
}),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if (
|
|
input.brand === "dahua" &&
|
|
roles.includes("detect") &&
|
|
input.url.includes("subtype=1")
|
|
) {
|
|
result.push({
|
|
type: "warning",
|
|
rule: "dahua-substream",
|
|
message: t("cameraWizard.step4.issues.dahua.substreamWarning", {
|
|
ns: "views/settings",
|
|
}),
|
|
});
|
|
}
|
|
|
|
if (
|
|
input.brand === "hikvision" &&
|
|
roles.includes("detect") &&
|
|
input.url.includes("/102")
|
|
) {
|
|
result.push({
|
|
type: "warning",
|
|
rule: "hikvision-substream",
|
|
message: t("cameraWizard.step4.issues.hikvision.substreamWarning", {
|
|
ns: "views/settings",
|
|
}),
|
|
});
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* For an input that points at a go2rtc restream, find the camera URL behind
|
|
* it. Returns undefined when the path is not a restream or the stream is not
|
|
* in the go2rtc config. The /config response redacts credentials in these
|
|
* sources, so the URL is only good for pattern matching.
|
|
*/
|
|
export function resolveRestreamSource(
|
|
path: string,
|
|
streams: Record<string, string | string[]> | undefined,
|
|
): { url: string; useFfmpeg: boolean } | undefined {
|
|
const name = parseRestreamStreamName(path);
|
|
|
|
if (!name || !streams) {
|
|
return undefined;
|
|
}
|
|
|
|
const configured = streams[name];
|
|
const sources = Array.isArray(configured)
|
|
? configured
|
|
: configured
|
|
? [configured]
|
|
: [];
|
|
const source = sources.find((s) => !s.startsWith(`ffmpeg:${name}`));
|
|
|
|
if (!source) {
|
|
return undefined;
|
|
}
|
|
|
|
if (source.startsWith("ffmpeg:")) {
|
|
return {
|
|
url: source.slice("ffmpeg:".length).split("#")[0],
|
|
useFfmpeg: true,
|
|
};
|
|
}
|
|
|
|
return { url: source, useFfmpeg: false };
|
|
}
|