mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-07 05:55:27 +03:00
Compare commits
10 Commits
06dd44ac41
...
abdf3b3bf0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abdf3b3bf0 | ||
|
|
48ea1ce565 | ||
|
|
dd7b862e71 | ||
|
|
e8dbe5a9a3 | ||
|
|
7065d798a1 | ||
|
|
32ff12c9bc | ||
|
|
5a7d64adfc | ||
|
|
f6b084fdc7 | ||
|
|
fe2c0e9f46 | ||
|
|
f42307b8d9 |
5
.gitignore
vendored
5
.gitignore
vendored
@ -22,8 +22,3 @@ core
|
|||||||
!/web/**/*.ts
|
!/web/**/*.ts
|
||||||
.idea/*
|
.idea/*
|
||||||
.ipynb_checkpoints
|
.ipynb_checkpoints
|
||||||
|
|
||||||
# Auto-generated Docker Compose Generator config files
|
|
||||||
docs/src/components/DockerComposeGenerator/config/devices.ts
|
|
||||||
docs/src/components/DockerComposeGenerator/config/hardware.ts
|
|
||||||
docs/src/components/DockerComposeGenerator/config/ports.ts
|
|
||||||
|
|||||||
@ -4,9 +4,6 @@ title: Installation
|
|||||||
---
|
---
|
||||||
|
|
||||||
import ShmCalculator from '@site/src/components/ShmCalculator'
|
import ShmCalculator from '@site/src/components/ShmCalculator'
|
||||||
import DockerComposeGenerator from '@site/src/components/DockerComposeGenerator'
|
|
||||||
import Tabs from '@theme/Tabs';
|
|
||||||
import TabItem from '@theme/TabItem';
|
|
||||||
|
|
||||||
Frigate is a Docker container that can be run on any Docker host including as a [Home Assistant App](https://www.home-assistant.io/apps/). Note that the Home Assistant App is **not** the same thing as the integration. The [integration](/integrations/home-assistant) is required to integrate Frigate into Home Assistant, whether you are running Frigate as a standalone Docker container or as a Home Assistant App.
|
Frigate is a Docker container that can be run on any Docker host including as a [Home Assistant App](https://www.home-assistant.io/apps/). Note that the Home Assistant App is **not** the same thing as the integration. The [integration](/integrations/home-assistant) is required to integrate Frigate into Home Assistant, whether you are running Frigate as a standalone Docker container or as a Home Assistant App.
|
||||||
|
|
||||||
@ -477,16 +474,6 @@ Finally, configure [hardware object detection](/configuration/object_detectors#a
|
|||||||
|
|
||||||
Running through Docker with Docker Compose is the recommended install method.
|
Running through Docker with Docker Compose is the recommended install method.
|
||||||
|
|
||||||
<Tabs>
|
|
||||||
<TabItem value="domestic" label="Docker Compose Generator" default>
|
|
||||||
|
|
||||||
Generate a Frigate Docker Compose configuration based on your hardware and requirements.
|
|
||||||
|
|
||||||
<DockerComposeGenerator/>
|
|
||||||
|
|
||||||
|
|
||||||
</TabItem>
|
|
||||||
<TabItem value="original" label="Example Docker Compose File">
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
frigate:
|
frigate:
|
||||||
@ -520,10 +507,6 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
FRIGATE_RTSP_PASSWORD: "password"
|
FRIGATE_RTSP_PASSWORD: "password"
|
||||||
```
|
```
|
||||||
</TabItem>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
**Docker CLI**
|
|
||||||
|
|
||||||
If you can't use Docker Compose, you can run the container with something similar to this:
|
If you can't use Docker Compose, you can run the container with something similar to this:
|
||||||
|
|
||||||
|
|||||||
9
docs/package-lock.json
generated
9
docs/package-lock.json
generated
@ -14,11 +14,9 @@
|
|||||||
"@docusaurus/theme-mermaid": "^3.7.0",
|
"@docusaurus/theme-mermaid": "^3.7.0",
|
||||||
"@inkeep/docusaurus": "^2.0.16",
|
"@inkeep/docusaurus": "^2.0.16",
|
||||||
"@mdx-js/react": "^3.1.0",
|
"@mdx-js/react": "^3.1.0",
|
||||||
"@types/js-yaml": "^4.0.9",
|
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"docusaurus-plugin-openapi-docs": "^4.5.1",
|
"docusaurus-plugin-openapi-docs": "^4.5.1",
|
||||||
"docusaurus-theme-openapi-docs": "^4.5.1",
|
"docusaurus-theme-openapi-docs": "^4.5.1",
|
||||||
"js-yaml": "^4.1.1",
|
|
||||||
"prism-react-renderer": "^2.4.1",
|
"prism-react-renderer": "^2.4.1",
|
||||||
"raw-loader": "^4.0.2",
|
"raw-loader": "^4.0.2",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
@ -5749,11 +5747,6 @@
|
|||||||
"@types/istanbul-lib-report": "*"
|
"@types/istanbul-lib-report": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/js-yaml": {
|
|
||||||
"version": "4.0.9",
|
|
||||||
"resolved": "https://mirrors.tencent.com/npm/@types/js-yaml/-/js-yaml-4.0.9.tgz",
|
|
||||||
"integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="
|
|
||||||
},
|
|
||||||
"node_modules/@types/json-schema": {
|
"node_modules/@types/json-schema": {
|
||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||||
@ -12890,7 +12883,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://mirrors.tencent.com/npm/js-yaml/-/js-yaml-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@ -3,10 +3,9 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build:config": "node scripts/build-config.mjs",
|
|
||||||
"docusaurus": "docusaurus",
|
"docusaurus": "docusaurus",
|
||||||
"start": "npm run build:config && npm run regen-docs && docusaurus start --host 0.0.0.0",
|
"start": "npm run regen-docs && docusaurus start --host 0.0.0.0",
|
||||||
"build": "npm run build:config && npm run regen-docs && docusaurus build",
|
"build": "npm run regen-docs && docusaurus build",
|
||||||
"swizzle": "docusaurus swizzle",
|
"swizzle": "docusaurus swizzle",
|
||||||
"deploy": "docusaurus deploy",
|
"deploy": "docusaurus deploy",
|
||||||
"clear": "docusaurus clear",
|
"clear": "docusaurus clear",
|
||||||
@ -24,11 +23,9 @@
|
|||||||
"@docusaurus/theme-mermaid": "^3.7.0",
|
"@docusaurus/theme-mermaid": "^3.7.0",
|
||||||
"@inkeep/docusaurus": "^2.0.16",
|
"@inkeep/docusaurus": "^2.0.16",
|
||||||
"@mdx-js/react": "^3.1.0",
|
"@mdx-js/react": "^3.1.0",
|
||||||
"@types/js-yaml": "^4.0.9",
|
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"docusaurus-plugin-openapi-docs": "^4.5.1",
|
"docusaurus-plugin-openapi-docs": "^4.5.1",
|
||||||
"docusaurus-theme-openapi-docs": "^4.5.1",
|
"docusaurus-theme-openapi-docs": "^4.5.1",
|
||||||
"js-yaml": "^4.1.1",
|
|
||||||
"prism-react-renderer": "^2.4.1",
|
"prism-react-renderer": "^2.4.1",
|
||||||
"raw-loader": "^4.0.2",
|
"raw-loader": "^4.0.2",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
|||||||
@ -1,64 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build script: reads config.yaml and generates TypeScript files
|
|
||||||
* for the Docker Compose Generator.
|
|
||||||
*
|
|
||||||
* Usage: node scripts/build-config.mjs
|
|
||||||
*/
|
|
||||||
|
|
||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import yaml from "js-yaml";
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const CONFIG_DIR = path.resolve(__dirname, "../src/components/DockerComposeGenerator/config");
|
|
||||||
const YAML_PATH = path.join(CONFIG_DIR, "config.yaml");
|
|
||||||
|
|
||||||
// Read & parse YAML
|
|
||||||
const raw = fs.readFileSync(YAML_PATH, "utf8");
|
|
||||||
const config = yaml.load(raw);
|
|
||||||
|
|
||||||
if (!config.devices || !config.hardware || !config.ports) {
|
|
||||||
console.error("config.yaml must contain 'devices', 'hardware', and 'ports' sections.");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a .ts file from a section of the YAML config.
|
|
||||||
*/
|
|
||||||
function generateTsFile(sectionName, items, typeName, varName, mapVarName, yamlFilename) {
|
|
||||||
const jsonItems = JSON.stringify(items, null, 2);
|
|
||||||
// Indent JSON to fit inside the array literal
|
|
||||||
const indented = jsonItems
|
|
||||||
.split("\n")
|
|
||||||
.map((line, i) => (i === 0 ? line : " " + line))
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
const content = `/**
|
|
||||||
* AUTO-GENERATED FILE — do not edit directly.
|
|
||||||
* Source: ${yamlFilename}
|
|
||||||
* To update, edit the YAML file and run: npm run build:config
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ${typeName} } from "./types";
|
|
||||||
|
|
||||||
export const ${varName}: ${typeName}[] = ${indented};
|
|
||||||
|
|
||||||
/** Lookup map for quick access by ID */
|
|
||||||
export const ${mapVarName}: Map<string, ${typeName}> = new Map(${varName}.map((item) => [item.id, item]));
|
|
||||||
`;
|
|
||||||
|
|
||||||
const outPath = path.join(CONFIG_DIR, `${sectionName}.ts`);
|
|
||||||
fs.writeFileSync(outPath, content, "utf8");
|
|
||||||
console.log(` ✓ Generated ${sectionName}.ts (${items.length} items)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("Building config from config.yaml...");
|
|
||||||
|
|
||||||
generateTsFile("devices", config.devices, "DeviceConfig", "devices", "deviceMap", "config.yaml");
|
|
||||||
generateTsFile("hardware", config.hardware, "HardwareOption", "hardwareOptions", "hardwareMap", "config.yaml");
|
|
||||||
generateTsFile("ports", config.ports, "PortConfig", "ports", "portMap", "config.yaml");
|
|
||||||
|
|
||||||
console.log("Done!");
|
|
||||||
@ -1,108 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import Admonition from "@theme/Admonition";
|
|
||||||
import DeviceSelector from "./components/DeviceSelector";
|
|
||||||
import HardwareOptions from "./components/HardwareOptions";
|
|
||||||
import PortConfigSection from "./components/PortConfig";
|
|
||||||
import StoragePaths from "./components/StoragePaths";
|
|
||||||
import NvidiaGpuConfig from "./components/NvidiaGpuConfig";
|
|
||||||
import OtherOptions from "./components/OtherOptions";
|
|
||||||
import GeneratedOutput from "./components/GeneratedOutput";
|
|
||||||
import { useConfigGenerator } from "./hooks/useConfigGenerator";
|
|
||||||
import styles from "./styles.module.css";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simple markdown-link-to-React renderer for help text.
|
|
||||||
* Only supports [text](url) syntax — no nested brackets.
|
|
||||||
*/
|
|
||||||
function renderHelpText(text: string): React.ReactNode {
|
|
||||||
const parts = text.split(/(\[[^\]]+\]\([^)]+\))/g);
|
|
||||||
return parts.map((part, i) => {
|
|
||||||
const match = part.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
|
|
||||||
if (match) {
|
|
||||||
return (
|
|
||||||
<a key={i} href={match[2]}>
|
|
||||||
{match[1]}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return <React.Fragment key={i}>{part}</React.Fragment>;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DockerComposeGenerator() {
|
|
||||||
const {
|
|
||||||
deviceId, device, hardwareEnabled,
|
|
||||||
portEnabled,
|
|
||||||
nvidiaGpuCount, nvidiaGpuDeviceId,
|
|
||||||
configPath, mediaPath, rtspPassword, timezone, shmSize,
|
|
||||||
shmSizeError, gpuDeviceIdError, configPathError, mediaPathError,
|
|
||||||
hasAnyHardware, generatedYaml,
|
|
||||||
selectDevice, toggleHardware, togglePort,
|
|
||||||
handleShmSizeChange, handleConfigPathChange, handleMediaPathChange,
|
|
||||||
handleNvidiaGpuCountChange, handleNvidiaGpuDeviceIdChange,
|
|
||||||
setRtspPassword, setTimezone, isHardwareDisabled,
|
|
||||||
} = useConfigGenerator();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.generator}>
|
|
||||||
<div className={styles.card}>
|
|
||||||
<DeviceSelector selectedId={deviceId} onSelect={selectDevice} />
|
|
||||||
|
|
||||||
{device.helpText && (
|
|
||||||
<Admonition type={device.helpType || "info"}>
|
|
||||||
{renderHelpText(device.helpText)}
|
|
||||||
</Admonition>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{device.needsNvidiaConfig && (
|
|
||||||
<NvidiaGpuConfig
|
|
||||||
gpuCount={nvidiaGpuCount}
|
|
||||||
gpuDeviceId={nvidiaGpuDeviceId}
|
|
||||||
gpuDeviceIdError={gpuDeviceIdError}
|
|
||||||
onGpuCountChange={handleNvidiaGpuCountChange}
|
|
||||||
onGpuDeviceIdChange={handleNvidiaGpuDeviceIdChange}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<HardwareOptions
|
|
||||||
deviceId={deviceId}
|
|
||||||
hardwareEnabled={hardwareEnabled}
|
|
||||||
onToggle={toggleHardware}
|
|
||||||
isDisabled={isHardwareDisabled}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<StoragePaths
|
|
||||||
configPath={configPath}
|
|
||||||
mediaPath={mediaPath}
|
|
||||||
configPathError={configPathError}
|
|
||||||
mediaPathError={mediaPathError}
|
|
||||||
onConfigPathChange={handleConfigPathChange}
|
|
||||||
onMediaPathChange={handleMediaPathChange}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<PortConfigSection
|
|
||||||
portEnabled={portEnabled}
|
|
||||||
onTogglePort={togglePort}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<OtherOptions
|
|
||||||
rtspPassword={rtspPassword}
|
|
||||||
timezone={timezone}
|
|
||||||
shmSize={shmSize}
|
|
||||||
shmSizeError={shmSizeError}
|
|
||||||
onRtspPasswordChange={setRtspPassword}
|
|
||||||
onTimezoneChange={setTimezone}
|
|
||||||
onShmSizeChange={handleShmSizeChange}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<GeneratedOutput
|
|
||||||
yaml={generatedYaml}
|
|
||||||
configPath={configPath}
|
|
||||||
mediaPath={mediaPath}
|
|
||||||
hasAnyHardware={hasAnyHardware}
|
|
||||||
deviceId={deviceId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,147 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { useColorMode } from "@docusaurus/theme-common";
|
|
||||||
import { devices } from "../config";
|
|
||||||
import type { DeviceConfig } from "../config";
|
|
||||||
import styles from "../styles.module.css";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
selectedId: string;
|
|
||||||
onSelect: (id: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine the icon type from the icon string:
|
|
||||||
* - Starts with "<svg" → inline SVG
|
|
||||||
* - Starts with "/" or "http" → image URL/path
|
|
||||||
* - Otherwise → emoji text
|
|
||||||
*/
|
|
||||||
function getIconType(icon: string): "svg" | "image" | "emoji" {
|
|
||||||
const trimmed = icon.trim();
|
|
||||||
if (trimmed.startsWith("<svg")) return "svg";
|
|
||||||
if (trimmed.startsWith("/") || trimmed.startsWith("http://") || trimmed.startsWith("https://")) return "image";
|
|
||||||
return "emoji";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the style object contains background-* properties,
|
|
||||||
* indicating the image should be rendered as a CSS background-image
|
|
||||||
* rather than an <img> tag.
|
|
||||||
*/
|
|
||||||
function hasBackgroundProps(style: React.CSSProperties | undefined): boolean {
|
|
||||||
if (!style) return false;
|
|
||||||
return Object.keys(style).some((key) => {
|
|
||||||
const k = key.toLowerCase().replace(/-/g, "");
|
|
||||||
return k === "backgroundsize" || k === "backgroundposition" || k === "backgroundrepeat" || k === "backgroundimage";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a style object to CSS custom properties (e.g. { width: "24px" } → { "--svg-width": "24px" })
|
|
||||||
* so they can be consumed by CSS rules targeting child elements like <svg>.
|
|
||||||
*/
|
|
||||||
function toCssVars(style: React.CSSProperties | undefined, prefix: string): React.CSSProperties {
|
|
||||||
if (!style) return {};
|
|
||||||
const vars: Record<string, string> = {};
|
|
||||||
for (const [key, value] of Object.entries(style)) {
|
|
||||||
const cssKey = key.replace(/([A-Z])/g, "-$1").toLowerCase();
|
|
||||||
vars[`--${prefix}-${cssKey}`] = value;
|
|
||||||
}
|
|
||||||
return vars as React.CSSProperties;
|
|
||||||
}
|
|
||||||
|
|
||||||
function DeviceIcon({ device }: { device: DeviceConfig }) {
|
|
||||||
const { isDarkTheme } = useColorMode();
|
|
||||||
const iconStr = isDarkTheme && device.iconDark ? device.iconDark : device.icon;
|
|
||||||
const iconStyle = (isDarkTheme && device.iconDarkStyle
|
|
||||||
? device.iconDarkStyle
|
|
||||||
: device.iconStyle) as React.CSSProperties | undefined;
|
|
||||||
const svgStyle = (isDarkTheme && device.svgDarkStyle
|
|
||||||
? device.svgDarkStyle
|
|
||||||
: device.svgStyle) as React.CSSProperties | undefined;
|
|
||||||
|
|
||||||
const iconType = getIconType(iconStr);
|
|
||||||
|
|
||||||
if (iconType === "svg") {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={styles.deviceIconSvg}
|
|
||||||
style={{ ...iconStyle, ...toCssVars(svgStyle, "svg") }}
|
|
||||||
dangerouslySetInnerHTML={{ __html: iconStr }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (iconType === "image") {
|
|
||||||
// When iconStyle contains background-* properties, render as background-image
|
|
||||||
// on the container div instead of an <img> tag, enabling background-size/position control.
|
|
||||||
if (hasBackgroundProps(iconStyle)) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={styles.deviceIconImage}
|
|
||||||
style={{
|
|
||||||
backgroundImage: `url(${iconStr})`,
|
|
||||||
backgroundRepeat: "no-repeat",
|
|
||||||
backgroundPosition: "center",
|
|
||||||
backgroundSize: "contain",
|
|
||||||
...iconStyle,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className={styles.deviceIconImage}>
|
|
||||||
<img src={iconStr} alt={device.name} style={iconStyle} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.deviceIcon} style={iconStyle}>
|
|
||||||
{iconStr}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DeviceCard({
|
|
||||||
device,
|
|
||||||
active,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
device: DeviceConfig;
|
|
||||||
active: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`${styles.deviceCard} ${active ? styles.deviceCardActive : ""}`}
|
|
||||||
onClick={onClick}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") onClick();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DeviceIcon device={device} />
|
|
||||||
<div className={styles.deviceName}>{device.name}</div>
|
|
||||||
<div className={styles.deviceDesc}>{device.description}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DeviceSelector({ selectedId, onSelect }: Props) {
|
|
||||||
return (
|
|
||||||
<div className={styles.formSection}>
|
|
||||||
<h4>Device Type</h4>
|
|
||||||
<div className={styles.deviceGrid}>
|
|
||||||
{devices.map((d) => (
|
|
||||||
<DeviceCard
|
|
||||||
key={d.id}
|
|
||||||
device={d}
|
|
||||||
active={selectedId === d.id}
|
|
||||||
onClick={() => onSelect(d.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,60 +0,0 @@
|
|||||||
import React, { useState, useCallback } from "react";
|
|
||||||
import CodeBlock from "@theme/CodeBlock";
|
|
||||||
import Admonition from "@theme/Admonition";
|
|
||||||
import styles from "../styles.module.css";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
yaml: string;
|
|
||||||
configPath: string;
|
|
||||||
mediaPath: string;
|
|
||||||
hasAnyHardware: boolean;
|
|
||||||
deviceId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function GeneratedOutput({
|
|
||||||
yaml,
|
|
||||||
configPath,
|
|
||||||
mediaPath,
|
|
||||||
hasAnyHardware,
|
|
||||||
deviceId,
|
|
||||||
}: Props) {
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
|
|
||||||
const handleCopy = useCallback(() => {
|
|
||||||
navigator.clipboard.writeText(yaml).then(() => {
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
});
|
|
||||||
}, [yaml]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.resultSection}>
|
|
||||||
<div className={styles.resultHeader}>
|
|
||||||
<h4>Generated Configuration</h4>
|
|
||||||
<button className="button button--primary button--sm" onClick={handleCopy}>
|
|
||||||
{copied ? "Copied!" : "Copy"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!configPath && (
|
|
||||||
<Admonition type="tip">
|
|
||||||
<p>You haven't specified a config file directory. You may want to modify the default path.</p>
|
|
||||||
</Admonition>
|
|
||||||
)}
|
|
||||||
{!mediaPath && (
|
|
||||||
<Admonition type="tip">
|
|
||||||
<p>You haven't specified a recording storage directory. You may want to modify the default path.</p>
|
|
||||||
</Admonition>
|
|
||||||
)}
|
|
||||||
{deviceId === "stable" && !hasAnyHardware && (
|
|
||||||
<Admonition type="warning">
|
|
||||||
<p>You haven't selected any hardware acceleration. Please check if you have supported hardware available.</p>
|
|
||||||
</Admonition>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<CodeBlock language="yaml" title="docker-compose.yml">
|
|
||||||
{yaml}
|
|
||||||
</CodeBlock>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,62 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { hardwareOptions } from "../config";
|
|
||||||
import type { HardwareOption } from "../config";
|
|
||||||
import styles from "../styles.module.css";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
deviceId: string;
|
|
||||||
hardwareEnabled: Record<string, boolean>;
|
|
||||||
onToggle: (hwId: string) => void;
|
|
||||||
isDisabled: (hwId: string) => boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderDescription(text: string): React.ReactNode {
|
|
||||||
const parts = text.split(/(\[[^\]]+\]\([^)]+\))/g);
|
|
||||||
return parts.map((part, i) => {
|
|
||||||
const match = part.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
|
|
||||||
if (match) {
|
|
||||||
return <a key={i} href={match[2]}>{match[1]}</a>;
|
|
||||||
}
|
|
||||||
return <React.Fragment key={i}>{part}</React.Fragment>;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function HardwareCheckbox({
|
|
||||||
hw, disabled, checked, onToggle,
|
|
||||||
}: {
|
|
||||||
hw: HardwareOption; disabled: boolean; checked: boolean; onToggle: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className={styles.hardwareItem}>
|
|
||||||
<label className={`${styles.checkboxLabel} ${disabled ? styles.checkboxDisabled : ""}`}>
|
|
||||||
<input type="checkbox" checked={checked} onChange={onToggle} disabled={disabled} />
|
|
||||||
<span>{hw.label}</span>
|
|
||||||
</label>
|
|
||||||
{checked && hw.description && (
|
|
||||||
<div className={styles.hardwareDescription}>{renderDescription(hw.description)}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function HardwareOptions({ deviceId, hardwareEnabled, onToggle, isDisabled }: Props) {
|
|
||||||
return (
|
|
||||||
<div className={styles.formSection}>
|
|
||||||
<h4>Generic Hardware Devices</h4>
|
|
||||||
{deviceId !== "stable" && (
|
|
||||||
<p className={styles.helpText}>
|
|
||||||
Some options have been auto-configured based on your device type.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className={styles.checkboxGrid}>
|
|
||||||
{hardwareOptions.map((hw) => {
|
|
||||||
const disabled = isDisabled(hw.id);
|
|
||||||
const checked = disabled ? false : !!hardwareEnabled[hw.id];
|
|
||||||
return (
|
|
||||||
<HardwareCheckbox key={hw.id} hw={hw} disabled={disabled} checked={checked} onToggle={() => onToggle(hw.id)} />
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,64 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import styles from "../styles.module.css";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
gpuCount: string;
|
|
||||||
gpuDeviceId: string;
|
|
||||||
gpuDeviceIdError: boolean;
|
|
||||||
onGpuCountChange: (value: string) => void;
|
|
||||||
onGpuDeviceIdChange: (value: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function NvidiaGpuConfig({
|
|
||||||
gpuCount,
|
|
||||||
gpuDeviceId,
|
|
||||||
gpuDeviceIdError,
|
|
||||||
onGpuCountChange,
|
|
||||||
onGpuDeviceIdChange,
|
|
||||||
}: Props) {
|
|
||||||
const showDeviceId = gpuCount !== "";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.nvidiaConfig}>
|
|
||||||
<div className={styles.formGroup}>
|
|
||||||
<label htmlFor="dcg-gpu-count" className={styles.label}>
|
|
||||||
GPU count:
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="dcg-gpu-count"
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
className={styles.input}
|
|
||||||
value={gpuCount}
|
|
||||||
placeholder="all"
|
|
||||||
onChange={(e) => onGpuCountChange(e.target.value.replace(/\D/g, ""))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{showDeviceId && (
|
|
||||||
<div className={styles.formGroup}>
|
|
||||||
<label htmlFor="dcg-gpu-device-id" className={styles.label}>
|
|
||||||
GPU device IDs (required, comma-separated):
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="dcg-gpu-device-id"
|
|
||||||
type="text"
|
|
||||||
className={`${styles.input} ${gpuDeviceIdError ? styles.inputError : ""}`}
|
|
||||||
value={gpuDeviceId}
|
|
||||||
placeholder="0"
|
|
||||||
onChange={(e) => onGpuDeviceIdChange(e.target.value)}
|
|
||||||
/>
|
|
||||||
{gpuDeviceIdError ? (
|
|
||||||
<p className={styles.helpText}>
|
|
||||||
⚠️ GPU device IDs are required when GPU count is a number
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p className={styles.helpText}>
|
|
||||||
Single GPU: 0 | Multiple GPUs: 0,1,2
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,122 +0,0 @@
|
|||||||
import React, { useMemo } from "react";
|
|
||||||
import CodeInline from "@theme/CodeInline";
|
|
||||||
import styles from "../styles.module.css";
|
|
||||||
|
|
||||||
const AUTO_TIMEZONE_VALUE = "__auto__";
|
|
||||||
|
|
||||||
function getTimezoneList(): string[] {
|
|
||||||
if (typeof Intl !== "undefined") {
|
|
||||||
const intl = Intl as typeof Intl & {
|
|
||||||
supportedValuesOf?: (key: string) => string[];
|
|
||||||
};
|
|
||||||
const supported = intl.supportedValuesOf?.("timeZone");
|
|
||||||
if (supported && supported.length > 0) {
|
|
||||||
return [...supported].sort();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fallback = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
||||||
return fallback ? [fallback] : ["UTC"];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
rtspPassword: string;
|
|
||||||
timezone: string;
|
|
||||||
shmSize: string;
|
|
||||||
shmSizeError: boolean;
|
|
||||||
onRtspPasswordChange: (value: string) => void;
|
|
||||||
onTimezoneChange: (value: string) => void;
|
|
||||||
onShmSizeChange: (value: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function OtherOptions({
|
|
||||||
rtspPassword,
|
|
||||||
timezone,
|
|
||||||
shmSize,
|
|
||||||
shmSizeError,
|
|
||||||
onRtspPasswordChange,
|
|
||||||
onTimezoneChange,
|
|
||||||
onShmSizeChange,
|
|
||||||
}: Props) {
|
|
||||||
const timezones = useMemo(() => getTimezoneList(), []);
|
|
||||||
const systemTimezone =
|
|
||||||
Intl.DateTimeFormat().resolvedOptions().timeZone || "Etc/UTC";
|
|
||||||
const selectedValue = timezone || AUTO_TIMEZONE_VALUE;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.formSection}>
|
|
||||||
<h4>Other Options</h4>
|
|
||||||
<div className={styles.formGrid}>
|
|
||||||
<div className={styles.formGroup}>
|
|
||||||
<label htmlFor="dcg-timezone" className={styles.label}>
|
|
||||||
Timezone:
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="dcg-timezone"
|
|
||||||
className={`${styles.input} ${styles.select}`}
|
|
||||||
value={selectedValue}
|
|
||||||
onChange={(e) =>
|
|
||||||
onTimezoneChange(
|
|
||||||
e.target.value === AUTO_TIMEZONE_VALUE ? "" : e.target.value
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value={AUTO_TIMEZONE_VALUE}>
|
|
||||||
Use browser timezone ({systemTimezone})
|
|
||||||
</option>
|
|
||||||
{timezones.map((tz) => (
|
|
||||||
<option key={tz} value={tz}>
|
|
||||||
{tz}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className={styles.formGroup}>
|
|
||||||
<label htmlFor="dcg-shm-size" className={styles.label}>
|
|
||||||
Shared memory (SHM):
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="dcg-shm-size"
|
|
||||||
type="text"
|
|
||||||
className={`${styles.input} ${shmSizeError ? styles.inputError : ""}`}
|
|
||||||
value={shmSize}
|
|
||||||
placeholder="512mb"
|
|
||||||
onChange={(e) => onShmSizeChange(e.target.value)}
|
|
||||||
/>
|
|
||||||
{shmSizeError ? (
|
|
||||||
<p className={styles.helpText}>
|
|
||||||
⚠️ Invalid format. Use a number followed by a unit (e.g. 512mb, 1gb)
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p className={styles.helpText}>
|
|
||||||
See{" "}
|
|
||||||
<a href="/frigate/installation#calculating-required-shm-size">
|
|
||||||
calculating required SHM size
|
|
||||||
</a>{" "}
|
|
||||||
for the correct value.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className={styles.formGroup}>
|
|
||||||
<label htmlFor="dcg-rtsp-password" className={styles.label}>
|
|
||||||
RTSP password:
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="dcg-rtsp-password"
|
|
||||||
type="text"
|
|
||||||
className={styles.input}
|
|
||||||
value={rtspPassword}
|
|
||||||
placeholder="password"
|
|
||||||
onChange={(e) => onRtspPasswordChange(e.target.value)}
|
|
||||||
/>
|
|
||||||
<p className={styles.helpText}>
|
|
||||||
Optional. You can specify{" "}
|
|
||||||
<CodeInline>{"{FRIGATE_RTSP_PASSWORD}"}</CodeInline>{" "}
|
|
||||||
in the config file to reference camera stream passwords. This is NOT
|
|
||||||
the Frigate login password.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,71 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import Admonition from "@theme/Admonition";
|
|
||||||
import { ports } from "../config";
|
|
||||||
import styles from "../styles.module.css";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
portEnabled: Record<string, boolean>;
|
|
||||||
onTogglePort: (portId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function PortItem({
|
|
||||||
port,
|
|
||||||
enabled,
|
|
||||||
onToggle,
|
|
||||||
}: {
|
|
||||||
port: typeof ports[number];
|
|
||||||
enabled: boolean;
|
|
||||||
onToggle: () => void;
|
|
||||||
}) {
|
|
||||||
const showWarning = port.warningContent && (
|
|
||||||
port.warningWhen === "checked" ? enabled :
|
|
||||||
port.warningWhen === "unchecked" ? !enabled : enabled
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.hardwareItem}>
|
|
||||||
<label className={`${styles.checkboxLabel} ${port.locked ? styles.checkboxDisabled : ""}`}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={enabled}
|
|
||||||
onChange={onToggle}
|
|
||||||
disabled={port.locked}
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
{port.locked && "🔒 "}
|
|
||||||
Port {port.host}
|
|
||||||
{port.protocol !== "tcp" && `/${port.protocol}`}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
{port.description && (
|
|
||||||
<div className={styles.hardwareDescription}>{port.description}</div>
|
|
||||||
)}
|
|
||||||
{showWarning && (
|
|
||||||
<Admonition type={port.warningType || "warning"}>
|
|
||||||
{port.warningContent}
|
|
||||||
</Admonition>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PortConfigSection({
|
|
||||||
portEnabled,
|
|
||||||
onTogglePort,
|
|
||||||
}: Props) {
|
|
||||||
return (
|
|
||||||
<div className={styles.formSection}>
|
|
||||||
<h4>Port Configuration</h4>
|
|
||||||
<div className={styles.checkboxGrid}>
|
|
||||||
{ports.map((port) => (
|
|
||||||
<PortItem
|
|
||||||
key={port.id}
|
|
||||||
port={port}
|
|
||||||
enabled={!!portEnabled[port.id]}
|
|
||||||
onToggle={() => onTogglePort(port.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,66 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import styles from "../styles.module.css";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
configPath: string;
|
|
||||||
mediaPath: string;
|
|
||||||
configPathError: boolean;
|
|
||||||
mediaPathError: boolean;
|
|
||||||
onConfigPathChange: (value: string) => void;
|
|
||||||
onMediaPathChange: (value: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function StoragePaths({
|
|
||||||
configPath,
|
|
||||||
mediaPath,
|
|
||||||
configPathError,
|
|
||||||
mediaPathError,
|
|
||||||
onConfigPathChange,
|
|
||||||
onMediaPathChange,
|
|
||||||
}: Props) {
|
|
||||||
return (
|
|
||||||
<div className={styles.formSection}>
|
|
||||||
<h4>Storage Paths</h4>
|
|
||||||
<div className={styles.formGrid}>
|
|
||||||
<div className={styles.formGroup}>
|
|
||||||
<label htmlFor="dcg-config-path" className={styles.label}>
|
|
||||||
Config / DB / model cache directory (on your host):
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="dcg-config-path"
|
|
||||||
type="text"
|
|
||||||
className={`${styles.input} ${configPathError ? styles.inputError : ""}`}
|
|
||||||
value={configPath}
|
|
||||||
placeholder="/path/to/your/config"
|
|
||||||
onChange={(e) => onConfigPathChange(e.target.value)}
|
|
||||||
/>
|
|
||||||
{configPathError && (
|
|
||||||
<p className={styles.helpText}>
|
|
||||||
⚠️ Path contains invalid characters. Only letters, numbers,
|
|
||||||
underscores, hyphens, slashes, and dots are allowed.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className={styles.formGroup}>
|
|
||||||
<label htmlFor="dcg-media-path" className={styles.label}>
|
|
||||||
Recording storage directory (on your host):
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="dcg-media-path"
|
|
||||||
type="text"
|
|
||||||
className={`${styles.input} ${mediaPathError ? styles.inputError : ""}`}
|
|
||||||
value={mediaPath}
|
|
||||||
placeholder="/path/to/your/storage"
|
|
||||||
onChange={(e) => onMediaPathChange(e.target.value)}
|
|
||||||
/>
|
|
||||||
{mediaPathError && (
|
|
||||||
<p className={styles.helpText}>
|
|
||||||
⚠️ Path contains invalid characters. Only letters, numbers,
|
|
||||||
underscores, hyphens, slashes, and dots are allowed.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
@ -1,12 +0,0 @@
|
|||||||
export { devices, deviceMap } from "./devices";
|
|
||||||
export { hardwareOptions, hardwareMap } from "./hardware";
|
|
||||||
export { ports, portMap } from "./ports";
|
|
||||||
|
|
||||||
export type {
|
|
||||||
DeviceConfig,
|
|
||||||
DeviceMapping,
|
|
||||||
VolumeMapping,
|
|
||||||
HardwareOption,
|
|
||||||
PortConfig,
|
|
||||||
NvidiaDeployConfig,
|
|
||||||
} from "./types";
|
|
||||||
@ -1,154 +0,0 @@
|
|||||||
/**
|
|
||||||
* Type definitions for the Docker Compose Generator configuration.
|
|
||||||
* All device, hardware, and port options are declaratively defined
|
|
||||||
* so that adding a new device only requires editing config files.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** A single device mapping entry (e.g. /dev/dri:/dev/dri) */
|
|
||||||
export interface DeviceMapping {
|
|
||||||
/** Host device path */
|
|
||||||
host: string;
|
|
||||||
/** Container device path (defaults to host if omitted) */
|
|
||||||
container?: string;
|
|
||||||
/** Inline comment for this device line */
|
|
||||||
comment?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A single volume mapping entry */
|
|
||||||
export interface VolumeMapping {
|
|
||||||
/** Host path */
|
|
||||||
host: string;
|
|
||||||
/** Container path */
|
|
||||||
container: string;
|
|
||||||
/** Whether the mount is read-only */
|
|
||||||
readOnly?: boolean;
|
|
||||||
/** Inline comment */
|
|
||||||
comment?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** NVIDIA deploy configuration for docker-compose */
|
|
||||||
export interface NvidiaDeployConfig {
|
|
||||||
/** "all" or a specific number */
|
|
||||||
count: string;
|
|
||||||
/** Specific GPU device IDs (when count is a number) */
|
|
||||||
deviceIds?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Full device type definition */
|
|
||||||
export interface DeviceConfig {
|
|
||||||
/** Unique identifier, e.g. "intel" */
|
|
||||||
id: string;
|
|
||||||
/** Display name, e.g. "Intel GPU" */
|
|
||||||
name: string;
|
|
||||||
/** Short description */
|
|
||||||
description: string;
|
|
||||||
/**
|
|
||||||
* Icon for the device card. Supports:
|
|
||||||
* - Emoji string (e.g. "🖥️")
|
|
||||||
* - Image URL or static path (e.g. "/img/intel.svg", "https://example.com/icon.png")
|
|
||||||
* - Inline SVG markup (e.g. "<svg>...</svg>")
|
|
||||||
*/
|
|
||||||
icon: string;
|
|
||||||
/**
|
|
||||||
* Additional CSS properties applied to the icon element.
|
|
||||||
* - For image-type icons: if any `background-*` property (e.g. `background-size`,
|
|
||||||
* `background-position`) is present, the image is rendered as a CSS `background-image`
|
|
||||||
* on the container div, enabling full background positioning control.
|
|
||||||
* Otherwise the image is rendered as an `<img>` tag and styles apply to it.
|
|
||||||
* - For emoji/SVG icons: styles apply to the container div.
|
|
||||||
*/
|
|
||||||
iconStyle?: Record<string, string>;
|
|
||||||
/**
|
|
||||||
* Additional CSS properties applied directly to the inner `<svg>` element
|
|
||||||
* when the icon is an inline SVG. Use this to override the default
|
|
||||||
* `width: 100%; height: 100%` or set `fill`, `transform`, etc.
|
|
||||||
* Ignored for emoji and image-type icons.
|
|
||||||
*/
|
|
||||||
svgStyle?: Record<string, string>;
|
|
||||||
/**
|
|
||||||
* Icon for dark mode. Same format as `icon`. When provided, this icon
|
|
||||||
* replaces `icon` when the user is in dark mode.
|
|
||||||
*/
|
|
||||||
iconDark?: string;
|
|
||||||
/** Additional CSS properties for the dark mode icon container */
|
|
||||||
iconDarkStyle?: Record<string, string>;
|
|
||||||
/**
|
|
||||||
* SVG-specific styles for dark mode. Same as `svgStyle` but applied
|
|
||||||
* when dark mode is active. Merged over `svgStyle` in dark mode.
|
|
||||||
*/
|
|
||||||
svgDarkStyle?: Record<string, string>;
|
|
||||||
/** Docker image tag, e.g. "stable" */
|
|
||||||
imageTag: string;
|
|
||||||
/**
|
|
||||||
* Image tag suffix appended to the base tag.
|
|
||||||
* e.g. "-standard-arm64" produces "stable-standard-arm64"
|
|
||||||
*/
|
|
||||||
imageTagSuffix?: string;
|
|
||||||
/** Hardware option IDs to auto-enable when this device is selected */
|
|
||||||
autoHardware: string[];
|
|
||||||
/** Help text shown as an admonition when this device is selected */
|
|
||||||
helpText?: string;
|
|
||||||
/** Admonition type for help text */
|
|
||||||
helpType?: "info" | "warning" | "danger";
|
|
||||||
/** Device mappings always added for this device type */
|
|
||||||
devices?: DeviceMapping[];
|
|
||||||
/** Volume mappings always added for this device type */
|
|
||||||
volumes?: VolumeMapping[];
|
|
||||||
/** Extra environment variables for this device type */
|
|
||||||
env?: Record<string, string>;
|
|
||||||
/** NVIDIA deploy config (only for tensorrt) */
|
|
||||||
nvidiaDeploy?: NvidiaDeployConfig;
|
|
||||||
/** Runtime setting, e.g. "nvidia" for Jetson */
|
|
||||||
runtime?: string;
|
|
||||||
/** Extra hosts entries, e.g. "host.docker.internal:host-gateway" */
|
|
||||||
extraHosts?: string[];
|
|
||||||
/** Security options, e.g. ["apparmor=unconfined"] */
|
|
||||||
securityOpt?: string[];
|
|
||||||
/** Whether this device type needs the NVIDIA GPU config UI */
|
|
||||||
needsNvidiaConfig?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Generic hardware acceleration option definition */
|
|
||||||
export interface HardwareOption {
|
|
||||||
/** Unique identifier, e.g. "usbCoral" */
|
|
||||||
id: string;
|
|
||||||
/** Display label */
|
|
||||||
label: string;
|
|
||||||
/**
|
|
||||||
* Description shown below the checkbox when this option is enabled.
|
|
||||||
* Supports markdown link syntax: [text](url)
|
|
||||||
*/
|
|
||||||
description?: string;
|
|
||||||
/** Device IDs that disable this option */
|
|
||||||
disabledWhen?: string[];
|
|
||||||
/** Device mappings added when this option is enabled */
|
|
||||||
devices?: DeviceMapping[];
|
|
||||||
/** Volume mappings added when this option is enabled */
|
|
||||||
volumes?: VolumeMapping[];
|
|
||||||
/** Extra environment variables */
|
|
||||||
env?: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Port definition */
|
|
||||||
export interface PortConfig {
|
|
||||||
/** Unique identifier (also the default host port as string) */
|
|
||||||
id: string;
|
|
||||||
/** Host port number */
|
|
||||||
host: number;
|
|
||||||
/** Container port number */
|
|
||||||
container: number;
|
|
||||||
/** Protocol */
|
|
||||||
protocol?: "tcp" | "udp";
|
|
||||||
/** Description of the port's purpose */
|
|
||||||
description: string;
|
|
||||||
/** Whether enabled by default */
|
|
||||||
defaultEnabled: boolean;
|
|
||||||
/** Whether this port is locked (always enabled, cannot be toggled off) */
|
|
||||||
locked?: boolean;
|
|
||||||
/** Admonition type for the warning */
|
|
||||||
warningType?: "warning" | "danger";
|
|
||||||
/** Warning content (markdown) */
|
|
||||||
warningContent?: string;
|
|
||||||
/** When to show the warning: when the port is checked or unchecked */
|
|
||||||
warningWhen?: "checked" | "unchecked";
|
|
||||||
}
|
|
||||||
@ -1,250 +0,0 @@
|
|||||||
import type {
|
|
||||||
DeviceConfig,
|
|
||||||
DeviceMapping,
|
|
||||||
VolumeMapping,
|
|
||||||
} from "../config/types";
|
|
||||||
import { hardwareMap } from "../config";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Input type
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface GeneratorInput {
|
|
||||||
device: DeviceConfig;
|
|
||||||
selectedHardware: string[];
|
|
||||||
enabledPorts: string[];
|
|
||||||
configPath: string;
|
|
||||||
mediaPath: string;
|
|
||||||
rtspPassword?: string;
|
|
||||||
timezone: string;
|
|
||||||
shmSize: string;
|
|
||||||
nvidiaGpuCount?: string;
|
|
||||||
nvidiaGpuDeviceId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function deviceLine(dm: DeviceMapping): string {
|
|
||||||
const host = dm.host;
|
|
||||||
const container = dm.container ?? dm.host;
|
|
||||||
const mapping = host === container ? host : `${host}:${container}`;
|
|
||||||
const comment = dm.comment ? ` # ${dm.comment}` : "";
|
|
||||||
return ` - ${mapping}${comment}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function volumeLine(vm: VolumeMapping): string {
|
|
||||||
const ro = vm.readOnly ? ":ro" : "";
|
|
||||||
const comment = vm.comment ? ` # ${vm.comment}` : "";
|
|
||||||
return ` - ${vm.host}:${vm.container}${ro}${comment}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// YAML builder — each section returns an array of lines
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function buildImage(device: DeviceConfig): string[] {
|
|
||||||
const tag = device.imageTagSuffix
|
|
||||||
? `${device.imageTag}${device.imageTagSuffix}`
|
|
||||||
: device.imageTag;
|
|
||||||
return [` image: ghcr.io/blakeblackshear/frigate:${tag}`];
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildDevices(
|
|
||||||
device: DeviceConfig,
|
|
||||||
hwDevices: DeviceMapping[]
|
|
||||||
): string[] {
|
|
||||||
const all: DeviceMapping[] = [
|
|
||||||
...(device.devices ?? []),
|
|
||||||
...hwDevices,
|
|
||||||
];
|
|
||||||
if (all.length === 0) return [];
|
|
||||||
return [
|
|
||||||
" devices:",
|
|
||||||
...all.map(deviceLine),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildVolumes(
|
|
||||||
device: DeviceConfig,
|
|
||||||
hwVolumes: VolumeMapping[],
|
|
||||||
configPath: string,
|
|
||||||
mediaPath: string
|
|
||||||
): string[] {
|
|
||||||
const all: VolumeMapping[] = [
|
|
||||||
...(device.volumes ?? []),
|
|
||||||
...hwVolumes,
|
|
||||||
];
|
|
||||||
return [
|
|
||||||
" volumes:",
|
|
||||||
" - /etc/localtime:/etc/localtime:ro # Sync host time",
|
|
||||||
` - ${configPath}:/config # Config file directory`,
|
|
||||||
` - ${mediaPath}:/media/frigate # Recording storage directory`,
|
|
||||||
" - type: tmpfs # 1GB in-memory filesystem for recording segment storage",
|
|
||||||
" target: /tmp/cache",
|
|
||||||
" tmpfs:",
|
|
||||||
" size: 1000000000",
|
|
||||||
...all.map(volumeLine),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPorts(enabledPorts: string[]): string[] {
|
|
||||||
return [
|
|
||||||
" ports:",
|
|
||||||
...enabledPorts,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildEnvironment(
|
|
||||||
device: DeviceConfig,
|
|
||||||
hwEnv: Record<string, string>,
|
|
||||||
rtspPassword: string | undefined,
|
|
||||||
timezone: string
|
|
||||||
): string[] {
|
|
||||||
const allEnv: Record<string, string> = {
|
|
||||||
...hwEnv,
|
|
||||||
...(device.env ?? {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const lines: string[] = [" environment:"];
|
|
||||||
|
|
||||||
if (rtspPassword) {
|
|
||||||
lines.push(
|
|
||||||
` FRIGATE_RTSP_PASSWORD: "${rtspPassword}" # RTSP password — change to your own`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push(` TZ: "${timezone}" # Timezone`);
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(allEnv)) {
|
|
||||||
lines.push(` ${key}: "${value}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildDeploy(device: DeviceConfig, input: GeneratorInput): string[] {
|
|
||||||
if (device.id === "stable-tensorrt") {
|
|
||||||
const count = input.nvidiaGpuCount || "all";
|
|
||||||
const isAll = count === "all";
|
|
||||||
const deviceId = input.nvidiaGpuDeviceId?.trim();
|
|
||||||
|
|
||||||
if (isAll) {
|
|
||||||
return [
|
|
||||||
" deploy:",
|
|
||||||
" resources:",
|
|
||||||
" reservations:",
|
|
||||||
" devices:",
|
|
||||||
" - driver: nvidia",
|
|
||||||
" count: all # Use all GPUs",
|
|
||||||
" capabilities: [gpu]",
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deviceId) {
|
|
||||||
const ids = deviceId
|
|
||||||
.split(",")
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((s) => `'${s}'`)
|
|
||||||
.join(", ");
|
|
||||||
return [
|
|
||||||
" deploy:",
|
|
||||||
" resources:",
|
|
||||||
" reservations:",
|
|
||||||
" devices:",
|
|
||||||
" - driver: nvidia",
|
|
||||||
` device_ids: [${ids}] # GPU device IDs`,
|
|
||||||
` count: ${count} # GPU count`,
|
|
||||||
" capabilities: [gpu]",
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
" deploy:",
|
|
||||||
" resources:",
|
|
||||||
" reservations:",
|
|
||||||
" devices:",
|
|
||||||
" - driver: nvidia",
|
|
||||||
` count: ${count} # GPU count`,
|
|
||||||
" capabilities: [gpu]",
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRuntime(device: DeviceConfig): string[] {
|
|
||||||
if (device.runtime) {
|
|
||||||
return [` runtime: ${device.runtime}`];
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildExtraHosts(device: DeviceConfig): string[] {
|
|
||||||
if (!device.extraHosts?.length) return [];
|
|
||||||
return [
|
|
||||||
" extra_hosts:",
|
|
||||||
...device.extraHosts.map(
|
|
||||||
(h, i) =>
|
|
||||||
` - "${h}"${i === 0 ? " # Required to talk to the NPU detector" : ""}`
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSecurityOpt(device: DeviceConfig): string[] {
|
|
||||||
if (!device.securityOpt?.length) return [];
|
|
||||||
return [
|
|
||||||
" security_opt:",
|
|
||||||
...device.securityOpt.map((s) => ` - ${s}`),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Public API
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a docker-compose YAML string from the given input.
|
|
||||||
* The output is pure YAML with inline comments (no Shiki annotations).
|
|
||||||
*/
|
|
||||||
export function generateDockerCompose(input: GeneratorInput): string {
|
|
||||||
const { device } = input;
|
|
||||||
|
|
||||||
// Collect hardware-level devices, volumes, and env
|
|
||||||
const hwDevices: DeviceMapping[] = [];
|
|
||||||
const hwVolumes: VolumeMapping[] = [];
|
|
||||||
const hwEnv: Record<string, string> = {};
|
|
||||||
|
|
||||||
for (const hwId of input.selectedHardware) {
|
|
||||||
const hw = hardwareMap.get(hwId);
|
|
||||||
if (!hw) continue;
|
|
||||||
// Skip GPU device mapping for tensorrt images (it uses deploy instead)
|
|
||||||
if (hw.id === "gpu" && device.imageTag === "stable-tensorrt") continue;
|
|
||||||
hwDevices.push(...(hw.devices ?? []));
|
|
||||||
hwVolumes.push(...(hw.volumes ?? []));
|
|
||||||
Object.assign(hwEnv, hw.env ?? {});
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines: string[] = [
|
|
||||||
"services:",
|
|
||||||
" frigate:",
|
|
||||||
" container_name: frigate",
|
|
||||||
" privileged: true # This may not be necessary for all setups",
|
|
||||||
" restart: unless-stopped",
|
|
||||||
" stop_grace_period: 30s # Allow enough time to shut down the various services",
|
|
||||||
...buildImage(device),
|
|
||||||
` shm_size: "${input.shmSize || "512mb"}" # Update for your cameras based on SHM calculation`,
|
|
||||||
...buildRuntime(device),
|
|
||||||
...buildDeploy(device, input),
|
|
||||||
...buildExtraHosts(device),
|
|
||||||
...buildSecurityOpt(device),
|
|
||||||
...buildDevices(device, hwDevices),
|
|
||||||
...buildVolumes(device, hwVolumes, input.configPath, input.mediaPath),
|
|
||||||
...buildPorts(input.enabledPorts),
|
|
||||||
...buildEnvironment(device, hwEnv, input.rtspPassword, input.timezone),
|
|
||||||
];
|
|
||||||
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
@ -1,195 +0,0 @@
|
|||||||
import { useState, useCallback, useMemo } from "react";
|
|
||||||
import { deviceMap, hardwareMap, portMap } from "../config";
|
|
||||||
import { generateDockerCompose } from "../generator";
|
|
||||||
import type { GeneratorInput } from "../generator";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Main hook that holds all form state and generates the Docker Compose output.
|
|
||||||
* Configuration is loaded synchronously from build-time generated .ts files.
|
|
||||||
*/
|
|
||||||
export function useConfigGenerator() {
|
|
||||||
const [deviceId, setDeviceId] = useState("stable");
|
|
||||||
|
|
||||||
const [hardwareEnabled, setHardwareEnabled] = useState<Record<string, boolean>>(() => {
|
|
||||||
const defaultDevice = deviceMap.get("stable");
|
|
||||||
const initial: Record<string, boolean> = {};
|
|
||||||
if (defaultDevice) {
|
|
||||||
for (const hwId of defaultDevice.autoHardware) {
|
|
||||||
initial[hwId] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return initial;
|
|
||||||
});
|
|
||||||
|
|
||||||
const [portEnabled, setPortEnabled] = useState<Record<string, boolean>>(() => {
|
|
||||||
const initial: Record<string, boolean> = {};
|
|
||||||
for (const p of portMap.values()) {
|
|
||||||
initial[p.id] = p.defaultEnabled;
|
|
||||||
}
|
|
||||||
return initial;
|
|
||||||
});
|
|
||||||
|
|
||||||
const [nvidiaGpuCount, setNvidiaGpuCount] = useState("");
|
|
||||||
const [nvidiaGpuDeviceId, setNvidiaGpuDeviceId] = useState("");
|
|
||||||
const [configPath, setConfigPath] = useState("");
|
|
||||||
const [mediaPath, setMediaPath] = useState("");
|
|
||||||
const [rtspPassword, setRtspPassword] = useState("");
|
|
||||||
const [timezone, setTimezone] = useState("");
|
|
||||||
const [shmSize, setShmSize] = useState("512mb");
|
|
||||||
const [shmSizeError, setShmSizeError] = useState(false);
|
|
||||||
const [gpuDeviceIdError, setGpuDeviceIdError] = useState(false);
|
|
||||||
const [configPathError, setConfigPathError] = useState(false);
|
|
||||||
const [mediaPathError, setMediaPathError] = useState(false);
|
|
||||||
|
|
||||||
const device = useMemo(() => deviceMap.get(deviceId)!, [deviceId]);
|
|
||||||
|
|
||||||
const selectDevice = useCallback((id: string) => {
|
|
||||||
const newDevice = deviceMap.get(id);
|
|
||||||
if (!newDevice) return;
|
|
||||||
setDeviceId(id);
|
|
||||||
setHardwareEnabled(() => {
|
|
||||||
const next: Record<string, boolean> = {};
|
|
||||||
for (const hwId of newDevice.autoHardware) {
|
|
||||||
next[hwId] = true;
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
setNvidiaGpuCount("");
|
|
||||||
setNvidiaGpuDeviceId("");
|
|
||||||
setGpuDeviceIdError(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleHardware = useCallback((hwId: string) => {
|
|
||||||
setHardwareEnabled((prev) => ({ ...prev, [hwId]: !prev[hwId] }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const togglePort = useCallback((portId: string) => {
|
|
||||||
const port = portMap.get(portId);
|
|
||||||
if (port?.locked) return;
|
|
||||||
setPortEnabled((prev) => ({ ...prev, [portId]: !prev[portId] }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const isHardwareDisabled = useCallback(
|
|
||||||
(hwId: string): boolean => {
|
|
||||||
const hw = hardwareMap.get(hwId);
|
|
||||||
if (!hw) return false;
|
|
||||||
return hw.disabledWhen?.includes(deviceId) ?? false;
|
|
||||||
},
|
|
||||||
[deviceId]
|
|
||||||
);
|
|
||||||
|
|
||||||
const validateShmSize = useCallback((value: string): boolean => {
|
|
||||||
if (!value) return true;
|
|
||||||
return /^\d+(\.\d+)?[bkmgBKMG]{1,2}$/.test(value);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const validatePath = useCallback((value: string): boolean => {
|
|
||||||
if (!value) return true;
|
|
||||||
return /^[a-zA-Z0-9_\-/./]+$/.test(value);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleShmSizeChange = useCallback(
|
|
||||||
(value: string) => {
|
|
||||||
const filtered = value.replace(/[^0-9.bkmgBKMG]/g, "");
|
|
||||||
const valid = validateShmSize(filtered);
|
|
||||||
setShmSize(filtered);
|
|
||||||
setShmSizeError(!valid && filtered !== "");
|
|
||||||
},
|
|
||||||
[validateShmSize]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleConfigPathChange = useCallback(
|
|
||||||
(value: string) => {
|
|
||||||
const filtered = value.replace(/[^a-zA-Z0-9_\-/./]/g, "");
|
|
||||||
const valid = validatePath(filtered);
|
|
||||||
setConfigPath(filtered);
|
|
||||||
setConfigPathError(!valid && filtered !== "");
|
|
||||||
},
|
|
||||||
[validatePath]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleMediaPathChange = useCallback(
|
|
||||||
(value: string) => {
|
|
||||||
const filtered = value.replace(/[^a-zA-Z0-9_\-/./]/g, "");
|
|
||||||
const valid = validatePath(filtered);
|
|
||||||
setMediaPath(filtered);
|
|
||||||
setMediaPathError(!valid && filtered !== "");
|
|
||||||
},
|
|
||||||
[validatePath]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleNvidiaGpuCountChange = useCallback((value: string) => {
|
|
||||||
// Only allow digits
|
|
||||||
setNvidiaGpuCount(value);
|
|
||||||
if (value === "") {
|
|
||||||
setNvidiaGpuDeviceId("");
|
|
||||||
setGpuDeviceIdError(false);
|
|
||||||
} else {
|
|
||||||
setGpuDeviceIdError(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleNvidiaGpuDeviceIdChange = useCallback((value: string) => {
|
|
||||||
setNvidiaGpuDeviceId(value.trim());
|
|
||||||
setGpuDeviceIdError(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const enabledPortLines = useMemo(() => {
|
|
||||||
const lines: string[] = [];
|
|
||||||
for (const [id, enabled] of Object.entries(portEnabled)) {
|
|
||||||
if (!enabled) continue;
|
|
||||||
const p = portMap.get(id);
|
|
||||||
if (!p) continue;
|
|
||||||
const proto = p.protocol && p.protocol !== "tcp" ? `/${p.protocol}` : "";
|
|
||||||
const comment = p.description ? ` # ${p.description}` : "";
|
|
||||||
lines.push(` - "${p.host}:${p.container}${proto}"${comment}`);
|
|
||||||
}
|
|
||||||
return lines;
|
|
||||||
}, [portEnabled]);
|
|
||||||
|
|
||||||
const selectedHardwareIds = useMemo(() => {
|
|
||||||
return Object.entries(hardwareEnabled)
|
|
||||||
.filter(([id, enabled]) => {
|
|
||||||
if (!enabled) return false;
|
|
||||||
const hw = hardwareMap.get(id);
|
|
||||||
if (!hw) return false;
|
|
||||||
if (hw.disabledWhen?.includes(deviceId)) return false;
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
.map(([id]) => id);
|
|
||||||
}, [hardwareEnabled, deviceId]);
|
|
||||||
|
|
||||||
const generatedYaml = useMemo(() => {
|
|
||||||
const input: GeneratorInput = {
|
|
||||||
device,
|
|
||||||
selectedHardware: selectedHardwareIds,
|
|
||||||
enabledPorts: enabledPortLines,
|
|
||||||
configPath: configPath || "/path/to/your/config",
|
|
||||||
mediaPath: mediaPath || "/path/to/your/storage",
|
|
||||||
rtspPassword,
|
|
||||||
timezone: timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || "Etc/UTC",
|
|
||||||
shmSize: shmSize || "512mb",
|
|
||||||
nvidiaGpuCount,
|
|
||||||
nvidiaGpuDeviceId,
|
|
||||||
};
|
|
||||||
return generateDockerCompose(input);
|
|
||||||
}, [
|
|
||||||
device, selectedHardwareIds, enabledPortLines,
|
|
||||||
configPath, mediaPath, rtspPassword, timezone, shmSize,
|
|
||||||
nvidiaGpuCount, nvidiaGpuDeviceId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const hasAnyHardware = selectedHardwareIds.length > 0 || !!device?.devices?.length;
|
|
||||||
|
|
||||||
return {
|
|
||||||
deviceId, device, hardwareEnabled, portEnabled,
|
|
||||||
nvidiaGpuCount, nvidiaGpuDeviceId,
|
|
||||||
configPath, mediaPath, rtspPassword, timezone, shmSize,
|
|
||||||
shmSizeError, gpuDeviceIdError, configPathError, mediaPathError,
|
|
||||||
hasAnyHardware, generatedYaml,
|
|
||||||
selectDevice, toggleHardware, togglePort,
|
|
||||||
handleShmSizeChange, handleConfigPathChange, handleMediaPathChange,
|
|
||||||
handleNvidiaGpuCountChange, handleNvidiaGpuDeviceIdChange,
|
|
||||||
setRtspPassword, setTimezone, isHardwareDisabled,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { default } from "./DockerComposeGenerator";
|
|
||||||
@ -1,381 +0,0 @@
|
|||||||
/* ===================================================================
|
|
||||||
Docker Compose Generator — styles
|
|
||||||
Uses Docusaurus / Infima CSS variables for theme compatibility.
|
|
||||||
=================================================================== */
|
|
||||||
|
|
||||||
.generator {
|
|
||||||
margin: 2rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background: var(--ifm-background-surface-color);
|
|
||||||
border: 1px solid var(--ifm-color-emphasis-400);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 2rem;
|
|
||||||
box-shadow: var(--ifm-global-shadow-lw);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="light"] .card {
|
|
||||||
background: var(--ifm-color-emphasis-100);
|
|
||||||
border: 1px solid var(--ifm-color-emphasis-300);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Form sections --- */
|
|
||||||
|
|
||||||
.formSection {
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
padding-bottom: 1.5rem;
|
|
||||||
border-bottom: 1px solid var(--ifm-color-emphasis-400);
|
|
||||||
}
|
|
||||||
|
|
||||||
.formSection:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
margin-bottom: 0;
|
|
||||||
padding-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.formSection h4 {
|
|
||||||
margin: 0 0 1rem 0;
|
|
||||||
color: var(--ifm-font-color-base);
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: var(--ifm-font-weight-semibold);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Form controls --- */
|
|
||||||
|
|
||||||
.formGroup {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.formGroup:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
color: var(--ifm-font-color-base);
|
|
||||||
font-weight: var(--ifm-font-weight-semibold);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
border: 1px solid var(--ifm-color-emphasis-400);
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--ifm-background-color);
|
|
||||||
color: var(--ifm-font-color-base);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
transition: border-color 0.2s, box-shadow 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="light"] .input {
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #d0d7de;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--ifm-color-primary);
|
|
||||||
box-shadow: 0 0 0 3px var(--ifm-color-primary-lightest);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] .input {
|
|
||||||
border-color: var(--ifm-color-emphasis-300);
|
|
||||||
}
|
|
||||||
|
|
||||||
.inputError {
|
|
||||||
border-color: #e74c3c;
|
|
||||||
animation: shake 0.3s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes shake {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
25% {
|
|
||||||
transform: translateX(-5px);
|
|
||||||
}
|
|
||||||
75% {
|
|
||||||
transform: translateX(5px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Select dropdown --- */
|
|
||||||
|
|
||||||
.select {
|
|
||||||
cursor: pointer;
|
|
||||||
appearance: none;
|
|
||||||
-moz-appearance: none;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
background: var(--ifm-background-color)
|
|
||||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 8L1 3h10z'/%3E%3C/svg%3E")
|
|
||||||
no-repeat right 0.75rem center / 12px 12px;
|
|
||||||
padding-right: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="light"] .select {
|
|
||||||
background: #fff
|
|
||||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23555' d='M6 8L1 3h10z'/%3E%3C/svg%3E")
|
|
||||||
no-repeat right 0.75rem center / 12px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.helpText {
|
|
||||||
margin: 0.5rem 0 0 0;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--ifm-font-color-secondary);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.helpText a {
|
|
||||||
color: var(--ifm-color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Device grid --- */
|
|
||||||
|
|
||||||
.deviceGrid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deviceCard {
|
|
||||||
padding: 0.75rem;
|
|
||||||
border: 2px solid var(--ifm-color-emphasis-400);
|
|
||||||
border-radius: 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
text-align: center;
|
|
||||||
background: var(--ifm-background-color);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="light"] .deviceCard {
|
|
||||||
border: 2px solid #d0d7de;
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deviceCard:hover {
|
|
||||||
border-color: var(--ifm-color-primary);
|
|
||||||
background: var(--ifm-color-emphasis-100);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.deviceCardActive {
|
|
||||||
border-color: var(--ifm-color-primary);
|
|
||||||
background: var(--ifm-color-primary-lightest);
|
|
||||||
box-shadow: 0 0 0 1px var(--ifm-color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="light"] .deviceCardActive {
|
|
||||||
background: color-mix(in srgb, var(--ifm-color-primary) 12%, #fff);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] .deviceCardActive {
|
|
||||||
background: color-mix(in srgb, var(--ifm-color-primary) 25%, #1b1b1b);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] .deviceCardActive .deviceName {
|
|
||||||
color: var(--ifm-color-primary-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] .deviceCardActive .deviceDesc {
|
|
||||||
color: var(--ifm-color-primary-light);
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deviceIcon {
|
|
||||||
font-size: 2rem;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
height: 40px;
|
|
||||||
width: 50px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deviceIconSvg {
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
height: 40px;
|
|
||||||
width: 50px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
overflow: visible;
|
|
||||||
/* Allow iconStyle width/height to override */
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deviceIconSvg svg {
|
|
||||||
width: var(--svg-width, 100%);
|
|
||||||
height: var(--svg-height, 100%);
|
|
||||||
fill: var(--svg-fill, currentColor);
|
|
||||||
transform: var(--svg-transform, none);
|
|
||||||
}
|
|
||||||
|
|
||||||
.deviceIconImage {
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
height: 40px;
|
|
||||||
width: 50px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deviceIconImage img {
|
|
||||||
max-width: 100%;
|
|
||||||
max-height: 100%;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deviceName {
|
|
||||||
font-weight: var(--ifm-font-weight-semibold);
|
|
||||||
color: var(--ifm-font-color-base);
|
|
||||||
margin-bottom: 0.15rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deviceDesc {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--ifm-font-color-secondary);
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Checkbox grid --- */
|
|
||||||
|
|
||||||
.checkboxGrid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 576px) {
|
|
||||||
.checkboxGrid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.hardwareItem {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hardwareDescription {
|
|
||||||
margin: 0.15rem 0 0.4rem 1.6rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--ifm-font-color-secondary);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hardwareDescription a {
|
|
||||||
color: var(--ifm-color-primary);
|
|
||||||
text-decoration: underline;
|
|
||||||
text-underline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkboxLabel {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0.4rem 0.5rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
transition: background-color 0.2s;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkboxLabel:hover {
|
|
||||||
background: var(--ifm-color-emphasis-100);
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkboxLabel input[type="checkbox"] {
|
|
||||||
width: 1.1rem;
|
|
||||||
height: 1.1rem;
|
|
||||||
cursor: pointer;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkboxLabel span {
|
|
||||||
color: var(--ifm-font-color-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkboxDisabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkboxDisabled:hover {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkboxDisabled input[type="checkbox"] {
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Form grid (side-by-side) --- */
|
|
||||||
|
|
||||||
.formGrid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 576px) {
|
|
||||||
.formGrid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.formGrid .formGroup {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Port section --- */
|
|
||||||
|
|
||||||
.portSection {
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.warningBadge {
|
|
||||||
margin-left: auto;
|
|
||||||
color: #e67e22;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- NVIDIA config --- */
|
|
||||||
|
|
||||||
.nvidiaConfig {
|
|
||||||
margin-top: 1rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
padding: 1rem;
|
|
||||||
background: var(--ifm-background-color);
|
|
||||||
border-radius: 8px;
|
|
||||||
border-left: 3px solid var(--ifm-color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="light"] .nvidiaConfig {
|
|
||||||
background: #f6f8fa;
|
|
||||||
border-left: 3px solid var(--ifm-color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Result section --- */
|
|
||||||
|
|
||||||
.resultSection {
|
|
||||||
margin-top: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resultHeader {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resultHeader h4 {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--ifm-font-color-base);
|
|
||||||
}
|
|
||||||
15
docs/static/frigate-api.yaml
vendored
15
docs/static/frigate-api.yaml
vendored
@ -5997,10 +5997,7 @@ paths:
|
|||||||
tags:
|
tags:
|
||||||
- App
|
- App
|
||||||
summary: Start debug replay
|
summary: Start debug replay
|
||||||
description:
|
description: Start a debug replay session from camera recordings.
|
||||||
Start a debug replay session from camera recordings. Returns
|
|
||||||
immediately while clip generation runs as a background job; subscribe
|
|
||||||
to the 'debug_replay' job_state WS topic to track progress.
|
|
||||||
operationId: start_debug_replay_debug_replay_start_post
|
operationId: start_debug_replay_debug_replay_start_post
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
@ -6009,16 +6006,12 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/DebugReplayStartBody"
|
$ref: "#/components/schemas/DebugReplayStartBody"
|
||||||
responses:
|
responses:
|
||||||
"202":
|
"200":
|
||||||
description: Successful Response
|
description: Successful Response
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/DebugReplayStartResponse"
|
$ref: "#/components/schemas/DebugReplayStartResponse"
|
||||||
"400":
|
|
||||||
description: Invalid camera, time range, or no recordings
|
|
||||||
"409":
|
|
||||||
description: A replay session is already active
|
|
||||||
"422":
|
"422":
|
||||||
description: Validation Error
|
description: Validation Error
|
||||||
content:
|
content:
|
||||||
@ -6279,14 +6272,10 @@ components:
|
|||||||
replay_camera:
|
replay_camera:
|
||||||
type: string
|
type: string
|
||||||
title: Replay Camera
|
title: Replay Camera
|
||||||
job_id:
|
|
||||||
type: string
|
|
||||||
title: Job Id
|
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
- success
|
- success
|
||||||
- replay_camera
|
- replay_camera
|
||||||
- job_id
|
|
||||||
title: DebugReplayStartResponse
|
title: DebugReplayStartResponse
|
||||||
description: Response for starting a debug replay session.
|
description: Response for starting a debug replay session.
|
||||||
DebugReplayStatusResponse:
|
DebugReplayStatusResponse:
|
||||||
|
|||||||
@ -10,7 +10,6 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
from frigate.api.auth import require_role
|
from frigate.api.auth import require_role
|
||||||
from frigate.api.defs.tags import Tags
|
from frigate.api.defs.tags import Tags
|
||||||
from frigate.jobs.debug_replay import start_debug_replay_job
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -30,17 +29,10 @@ class DebugReplayStartResponse(BaseModel):
|
|||||||
|
|
||||||
success: bool
|
success: bool
|
||||||
replay_camera: str
|
replay_camera: str
|
||||||
job_id: str
|
|
||||||
|
|
||||||
|
|
||||||
class DebugReplayStatusResponse(BaseModel):
|
class DebugReplayStatusResponse(BaseModel):
|
||||||
"""Response for debug replay status.
|
"""Response for debug replay status."""
|
||||||
|
|
||||||
Returns only session-presence fields. Startup progress and error
|
|
||||||
details flow through the job_state WebSocket topic via the
|
|
||||||
debug_replay job (see frigate.jobs.debug_replay); the
|
|
||||||
Replay page subscribes there with useJobStatus("debug_replay").
|
|
||||||
"""
|
|
||||||
|
|
||||||
active: bool
|
active: bool
|
||||||
replay_camera: str | None = None
|
replay_camera: str | None = None
|
||||||
@ -59,32 +51,15 @@ class DebugReplayStopResponse(BaseModel):
|
|||||||
@router.post(
|
@router.post(
|
||||||
"/debug_replay/start",
|
"/debug_replay/start",
|
||||||
response_model=DebugReplayStartResponse,
|
response_model=DebugReplayStartResponse,
|
||||||
status_code=202,
|
|
||||||
responses={
|
|
||||||
400: {"description": "Invalid camera, time range, or no recordings"},
|
|
||||||
409: {"description": "A replay session is already active"},
|
|
||||||
},
|
|
||||||
dependencies=[Depends(require_role(["admin"]))],
|
dependencies=[Depends(require_role(["admin"]))],
|
||||||
summary="Start debug replay",
|
summary="Start debug replay",
|
||||||
description="Start a debug replay session from camera recordings. Returns "
|
description="Start a debug replay session from camera recordings.",
|
||||||
"immediately while clip generation runs as a background job; subscribe "
|
|
||||||
"to the 'debug_replay' job_state WS topic to track progress.",
|
|
||||||
)
|
)
|
||||||
async def start_debug_replay(request: Request, body: DebugReplayStartBody):
|
async def start_debug_replay(request: Request, body: DebugReplayStartBody):
|
||||||
"""Start a debug replay session asynchronously."""
|
"""Start a debug replay session."""
|
||||||
replay_manager = request.app.replay_manager
|
replay_manager = request.app.replay_manager
|
||||||
|
|
||||||
try:
|
if replay_manager.active:
|
||||||
job_id = await asyncio.to_thread(
|
|
||||||
start_debug_replay_job,
|
|
||||||
source_camera=body.camera,
|
|
||||||
start_ts=body.start_time,
|
|
||||||
end_ts=body.end_time,
|
|
||||||
frigate_config=request.app.frigate_config,
|
|
||||||
config_publisher=request.app.config_publisher,
|
|
||||||
replay_manager=replay_manager,
|
|
||||||
)
|
|
||||||
except RuntimeError:
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={
|
content={
|
||||||
"success": False,
|
"success": False,
|
||||||
@ -92,23 +67,38 @@ async def start_debug_replay(request: Request, body: DebugReplayStartBody):
|
|||||||
},
|
},
|
||||||
status_code=409,
|
status_code=409,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
replay_camera = await asyncio.to_thread(
|
||||||
|
replay_manager.start,
|
||||||
|
source_camera=body.camera,
|
||||||
|
start_ts=body.start_time,
|
||||||
|
end_ts=body.end_time,
|
||||||
|
frigate_config=request.app.frigate_config,
|
||||||
|
config_publisher=request.app.config_publisher,
|
||||||
|
)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.exception("Rejected debug replay start request")
|
logger.exception("Invalid parameters for debug replay start request")
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={
|
content={
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Invalid debug replay parameters",
|
"message": "Invalid debug replay request parameters",
|
||||||
},
|
},
|
||||||
status_code=400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
|
except RuntimeError:
|
||||||
|
logger.exception("Error while starting debug replay session")
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"success": False,
|
||||||
|
"message": "An internal error occurred while starting debug replay",
|
||||||
|
},
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
|
||||||
return JSONResponse(
|
return DebugReplayStartResponse(
|
||||||
content={
|
success=True,
|
||||||
"success": True,
|
replay_camera=replay_camera,
|
||||||
"replay_camera": replay_manager.replay_camera_name,
|
|
||||||
"job_id": job_id,
|
|
||||||
},
|
|
||||||
status_code=202,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -128,16 +118,12 @@ def get_debug_replay_status(request: Request):
|
|||||||
|
|
||||||
if replay_manager.active and replay_camera:
|
if replay_manager.active and replay_camera:
|
||||||
frame_processor = request.app.detected_frames_processor
|
frame_processor = request.app.detected_frames_processor
|
||||||
frame = (
|
frame = frame_processor.get_current_frame(replay_camera)
|
||||||
frame_processor.get_current_frame(replay_camera)
|
|
||||||
if frame_processor is not None
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
if frame is not None:
|
if frame is not None:
|
||||||
frame_time = frame_processor.get_current_frame_time(replay_camera)
|
frame_time = frame_processor.get_current_frame_time(replay_camera)
|
||||||
camera_config = request.app.frigate_config.cameras.get(replay_camera)
|
camera_config = request.app.frigate_config.cameras.get(replay_camera)
|
||||||
retry_interval = 10.0
|
retry_interval = 10
|
||||||
|
|
||||||
if camera_config is not None:
|
if camera_config is not None:
|
||||||
retry_interval = float(camera_config.ffmpeg.retry_interval or 10)
|
retry_interval = float(camera_config.ffmpeg.retry_interval or 10)
|
||||||
|
|||||||
@ -174,10 +174,12 @@ async def latest_frame(
|
|||||||
}
|
}
|
||||||
quality_params = get_image_quality_params(extension.value, params.quality)
|
quality_params = get_image_quality_params(extension.value, params.quality)
|
||||||
|
|
||||||
camera_config = request.app.frigate_config.cameras.get(camera_name)
|
if camera_name in request.app.frigate_config.cameras:
|
||||||
if camera_config is not None:
|
|
||||||
frame = frame_processor.get_current_frame(camera_name, draw_options)
|
frame = frame_processor.get_current_frame(camera_name, draw_options)
|
||||||
retry_interval = float(camera_config.ffmpeg.retry_interval or 10)
|
retry_interval = float(
|
||||||
|
request.app.frigate_config.cameras.get(camera_name).ffmpeg.retry_interval
|
||||||
|
or 10
|
||||||
|
)
|
||||||
|
|
||||||
is_offline = False
|
is_offline = False
|
||||||
if frame is None or datetime.now().timestamp() > (
|
if frame is None or datetime.now().timestamp() > (
|
||||||
|
|||||||
@ -1,13 +1,9 @@
|
|||||||
"""Debug replay camera management for replaying recordings with detection overlays.
|
"""Debug replay camera management for replaying recordings with detection overlays."""
|
||||||
|
|
||||||
The startup work (ffmpeg concat + camera config publish) lives in
|
|
||||||
frigate.jobs.debug_replay. This module owns only session presence
|
|
||||||
(active), session metadata, and post-session cleanup.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
import subprocess as sp
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
from ruamel.yaml import YAML
|
from ruamel.yaml import YAML
|
||||||
@ -25,7 +21,7 @@ from frigate.const import (
|
|||||||
REPLAY_DIR,
|
REPLAY_DIR,
|
||||||
THUMB_DIR,
|
THUMB_DIR,
|
||||||
)
|
)
|
||||||
from frigate.jobs.debug_replay import cancel_debug_replay_job, wait_for_runner
|
from frigate.models import Recordings
|
||||||
from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files
|
from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files
|
||||||
from frigate.util.config import find_config_file
|
from frigate.util.config import find_config_file
|
||||||
|
|
||||||
@ -33,14 +29,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class DebugReplayManager:
|
class DebugReplayManager:
|
||||||
"""Owns the lifecycle pointers for a single debug replay session.
|
"""Manages a single debug replay session."""
|
||||||
|
|
||||||
A session exists from the moment mark_starting is called (synchronously,
|
|
||||||
inside the API handler) until clear_session runs (on success cleanup,
|
|
||||||
failure, or stop). The active property is the source of truth that the
|
|
||||||
status bar consumes — broader than the startup job, which only covers the
|
|
||||||
preparing_clip / starting_camera window.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
@ -52,66 +41,144 @@ class DebugReplayManager:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def active(self) -> bool:
|
def active(self) -> bool:
|
||||||
"""True from mark_starting until clear_session."""
|
"""Whether a replay session is currently active."""
|
||||||
return self.replay_camera_name is not None
|
return self.replay_camera_name is not None
|
||||||
|
|
||||||
def mark_starting(
|
def start(
|
||||||
self,
|
self,
|
||||||
source_camera: str,
|
source_camera: str,
|
||||||
replay_camera_name: str,
|
|
||||||
start_ts: float,
|
start_ts: float,
|
||||||
end_ts: float,
|
end_ts: float,
|
||||||
) -> None:
|
|
||||||
"""Synchronously claim the session before the job runner starts.
|
|
||||||
|
|
||||||
Called inside the API handler so the status bar sees active=True
|
|
||||||
immediately, before the worker thread does any ffmpeg work.
|
|
||||||
"""
|
|
||||||
with self._lock:
|
|
||||||
self.replay_camera_name = replay_camera_name
|
|
||||||
self.source_camera = source_camera
|
|
||||||
self.start_ts = start_ts
|
|
||||||
self.end_ts = end_ts
|
|
||||||
self.clip_path = None
|
|
||||||
|
|
||||||
def mark_session_ready(self, clip_path: str) -> None:
|
|
||||||
"""Record the on-disk clip path after the camera has been published."""
|
|
||||||
with self._lock:
|
|
||||||
self.clip_path = clip_path
|
|
||||||
|
|
||||||
def clear_session(self) -> None:
|
|
||||||
"""Reset session pointers without publishing camera removal.
|
|
||||||
|
|
||||||
Used by the job runner on failure paths. stop() does the camera
|
|
||||||
teardown plus this clear in one step.
|
|
||||||
"""
|
|
||||||
with self._lock:
|
|
||||||
self._clear_locked()
|
|
||||||
|
|
||||||
def _clear_locked(self) -> None:
|
|
||||||
self.replay_camera_name = None
|
|
||||||
self.source_camera = None
|
|
||||||
self.clip_path = None
|
|
||||||
self.start_ts = None
|
|
||||||
self.end_ts = None
|
|
||||||
|
|
||||||
def publish_camera(
|
|
||||||
self,
|
|
||||||
source_camera: str,
|
|
||||||
replay_name: str,
|
|
||||||
clip_path: str,
|
|
||||||
frigate_config: FrigateConfig,
|
frigate_config: FrigateConfig,
|
||||||
config_publisher: CameraConfigUpdatePublisher,
|
config_publisher: CameraConfigUpdatePublisher,
|
||||||
) -> None:
|
) -> str:
|
||||||
"""Build the in-memory replay camera config and publish the add event.
|
"""Start a debug replay session.
|
||||||
|
|
||||||
Called by the job runner during the starting_camera phase.
|
Args:
|
||||||
|
source_camera: Name of the source camera to replay
|
||||||
|
start_ts: Start timestamp
|
||||||
|
end_ts: End timestamp
|
||||||
|
frigate_config: Current Frigate configuration
|
||||||
|
config_publisher: Publisher for camera config updates
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The replay camera name
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If a session is already active or parameters are invalid
|
||||||
|
RuntimeError: If clip generation fails
|
||||||
"""
|
"""
|
||||||
|
with self._lock:
|
||||||
|
return self._start_locked(
|
||||||
|
source_camera, start_ts, end_ts, frigate_config, config_publisher
|
||||||
|
)
|
||||||
|
|
||||||
|
def _start_locked(
|
||||||
|
self,
|
||||||
|
source_camera: str,
|
||||||
|
start_ts: float,
|
||||||
|
end_ts: float,
|
||||||
|
frigate_config: FrigateConfig,
|
||||||
|
config_publisher: CameraConfigUpdatePublisher,
|
||||||
|
) -> str:
|
||||||
|
if self.active:
|
||||||
|
raise ValueError("A replay session is already active")
|
||||||
|
|
||||||
|
if source_camera not in frigate_config.cameras:
|
||||||
|
raise ValueError(f"Camera '{source_camera}' not found")
|
||||||
|
|
||||||
|
if end_ts <= start_ts:
|
||||||
|
raise ValueError("End time must be after start time")
|
||||||
|
|
||||||
|
# Query recordings for the source camera in the time range
|
||||||
|
recordings = (
|
||||||
|
Recordings.select(
|
||||||
|
Recordings.path,
|
||||||
|
Recordings.start_time,
|
||||||
|
Recordings.end_time,
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
Recordings.start_time.between(start_ts, end_ts)
|
||||||
|
| Recordings.end_time.between(start_ts, end_ts)
|
||||||
|
| ((start_ts > Recordings.start_time) & (end_ts < Recordings.end_time))
|
||||||
|
)
|
||||||
|
.where(Recordings.camera == source_camera)
|
||||||
|
.order_by(Recordings.start_time.asc())
|
||||||
|
)
|
||||||
|
|
||||||
|
if not recordings.count():
|
||||||
|
raise ValueError(
|
||||||
|
f"No recordings found for camera '{source_camera}' in the specified time range"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create replay directory
|
||||||
|
os.makedirs(REPLAY_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
# Generate replay camera name
|
||||||
|
replay_name = f"{REPLAY_CAMERA_PREFIX}{source_camera}"
|
||||||
|
|
||||||
|
# Build concat file for ffmpeg
|
||||||
|
concat_file = os.path.join(REPLAY_DIR, f"{replay_name}_concat.txt")
|
||||||
|
clip_path = os.path.join(REPLAY_DIR, f"{replay_name}.mp4")
|
||||||
|
|
||||||
|
with open(concat_file, "w") as f:
|
||||||
|
for recording in recordings:
|
||||||
|
f.write(f"file '{recording.path}'\n")
|
||||||
|
|
||||||
|
# Concatenate recordings into a single clip with -c copy (fast)
|
||||||
|
ffmpeg_cmd = [
|
||||||
|
frigate_config.ffmpeg.ffmpeg_path,
|
||||||
|
"-hide_banner",
|
||||||
|
"-y",
|
||||||
|
"-f",
|
||||||
|
"concat",
|
||||||
|
"-safe",
|
||||||
|
"0",
|
||||||
|
"-i",
|
||||||
|
concat_file,
|
||||||
|
"-c",
|
||||||
|
"copy",
|
||||||
|
"-movflags",
|
||||||
|
"+faststart",
|
||||||
|
clip_path,
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Generating replay clip for %s (%.1f - %.1f)",
|
||||||
|
source_camera,
|
||||||
|
start_ts,
|
||||||
|
end_ts,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = sp.run(
|
||||||
|
ffmpeg_cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.error("FFmpeg error: %s", result.stderr)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Failed to generate replay clip: {result.stderr[-500:]}"
|
||||||
|
)
|
||||||
|
except sp.TimeoutExpired:
|
||||||
|
raise RuntimeError("Clip generation timed out")
|
||||||
|
finally:
|
||||||
|
# Clean up concat file
|
||||||
|
if os.path.exists(concat_file):
|
||||||
|
os.remove(concat_file)
|
||||||
|
|
||||||
|
if not os.path.exists(clip_path):
|
||||||
|
raise RuntimeError("Clip file was not created")
|
||||||
|
|
||||||
|
# Build camera config dict for the replay camera
|
||||||
source_config = frigate_config.cameras[source_camera]
|
source_config = frigate_config.cameras[source_camera]
|
||||||
camera_dict = self._build_camera_config_dict(
|
camera_dict = self._build_camera_config_dict(
|
||||||
source_config, replay_name, clip_path
|
source_config, replay_name, clip_path
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Build an in-memory config with the replay camera added
|
||||||
config_file = find_config_file()
|
config_file = find_config_file()
|
||||||
yaml_parser = YAML()
|
yaml_parser = YAML()
|
||||||
with open(config_file, "r") as f:
|
with open(config_file, "r") as f:
|
||||||
@ -124,48 +191,75 @@ class DebugReplayManager:
|
|||||||
try:
|
try:
|
||||||
new_config = FrigateConfig.parse_object(config_data)
|
new_config = FrigateConfig.parse_object(config_data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise RuntimeError(f"Failed to validate replay camera config: {e}") from e
|
raise RuntimeError(f"Failed to validate replay camera config: {e}")
|
||||||
|
|
||||||
|
# Update the running config
|
||||||
frigate_config.cameras[replay_name] = new_config.cameras[replay_name]
|
frigate_config.cameras[replay_name] = new_config.cameras[replay_name]
|
||||||
|
|
||||||
|
# Publish the add event
|
||||||
config_publisher.publish_update(
|
config_publisher.publish_update(
|
||||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum.add, replay_name),
|
CameraConfigUpdateTopic(CameraConfigUpdateEnum.add, replay_name),
|
||||||
new_config.cameras[replay_name],
|
new_config.cameras[replay_name],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Store session state
|
||||||
|
self.replay_camera_name = replay_name
|
||||||
|
self.source_camera = source_camera
|
||||||
|
self.clip_path = clip_path
|
||||||
|
self.start_ts = start_ts
|
||||||
|
self.end_ts = end_ts
|
||||||
|
|
||||||
|
logger.info("Debug replay started: %s -> %s", source_camera, replay_name)
|
||||||
|
return replay_name
|
||||||
|
|
||||||
def stop(
|
def stop(
|
||||||
self,
|
self,
|
||||||
frigate_config: FrigateConfig,
|
frigate_config: FrigateConfig,
|
||||||
config_publisher: CameraConfigUpdatePublisher,
|
config_publisher: CameraConfigUpdatePublisher,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Cancel any in-flight startup job and tear down the active session.
|
"""Stop the active replay session and clean up all artifacts.
|
||||||
|
|
||||||
Safe to call when no session is active (no-op with a warning).
|
Args:
|
||||||
|
frigate_config: Current Frigate configuration
|
||||||
|
config_publisher: Publisher for camera config updates
|
||||||
"""
|
"""
|
||||||
cancel_debug_replay_job()
|
|
||||||
wait_for_runner(timeout=2.0)
|
|
||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if not self.active:
|
self._stop_locked(frigate_config, config_publisher)
|
||||||
logger.warning("No active replay session to stop")
|
|
||||||
return
|
|
||||||
|
|
||||||
replay_name = self.replay_camera_name
|
def _stop_locked(
|
||||||
|
self,
|
||||||
|
frigate_config: FrigateConfig,
|
||||||
|
config_publisher: CameraConfigUpdatePublisher,
|
||||||
|
) -> None:
|
||||||
|
if not self.active:
|
||||||
|
logger.warning("No active replay session to stop")
|
||||||
|
return
|
||||||
|
|
||||||
# Only publish remove if the camera was actually added to the live
|
replay_name = self.replay_camera_name
|
||||||
# config (i.e. the runner reached the starting_camera phase).
|
|
||||||
if replay_name is not None and replay_name in frigate_config.cameras:
|
|
||||||
config_publisher.publish_update(
|
|
||||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum.remove, replay_name),
|
|
||||||
frigate_config.cameras[replay_name],
|
|
||||||
)
|
|
||||||
|
|
||||||
if replay_name is not None:
|
# Publish remove event so subscribers stop and remove from their config
|
||||||
self._cleanup_db(replay_name)
|
if replay_name in frigate_config.cameras:
|
||||||
self._cleanup_files(replay_name)
|
config_publisher.publish_update(
|
||||||
|
CameraConfigUpdateTopic(CameraConfigUpdateEnum.remove, replay_name),
|
||||||
|
frigate_config.cameras[replay_name],
|
||||||
|
)
|
||||||
|
# Do NOT pop here — let subscribers handle removal from the shared
|
||||||
|
# config dict when they process the ZMQ message to avoid race conditions
|
||||||
|
|
||||||
self._clear_locked()
|
# Defensive DB cleanup
|
||||||
|
self._cleanup_db(replay_name)
|
||||||
|
|
||||||
logger.info("Debug replay stopped and cleaned up: %s", replay_name)
|
# Remove filesystem artifacts
|
||||||
|
self._cleanup_files(replay_name)
|
||||||
|
|
||||||
|
# Reset state
|
||||||
|
self.replay_camera_name = None
|
||||||
|
self.source_camera = None
|
||||||
|
self.clip_path = None
|
||||||
|
self.start_ts = None
|
||||||
|
self.end_ts = None
|
||||||
|
|
||||||
|
logger.info("Debug replay stopped and cleaned up: %s", replay_name)
|
||||||
|
|
||||||
def _build_camera_config_dict(
|
def _build_camera_config_dict(
|
||||||
self,
|
self,
|
||||||
@ -173,7 +267,16 @@ class DebugReplayManager:
|
|||||||
replay_name: str,
|
replay_name: str,
|
||||||
clip_path: str,
|
clip_path: str,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Build a camera config dictionary for the replay camera."""
|
"""Build a camera config dictionary for the replay camera.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_config: Source camera's CameraConfig
|
||||||
|
replay_name: Name for the replay camera
|
||||||
|
clip_path: Path to the replay clip file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Camera config as a dictionary
|
||||||
|
"""
|
||||||
# Extract detect config (exclude computed fields)
|
# Extract detect config (exclude computed fields)
|
||||||
detect_dict = source_config.detect.model_dump(
|
detect_dict = source_config.detect.model_dump(
|
||||||
exclude={"min_initialized", "max_disappeared", "enabled_in_config"}
|
exclude={"min_initialized", "max_disappeared", "enabled_in_config"}
|
||||||
@ -208,6 +311,7 @@ class DebugReplayManager:
|
|||||||
zone_dump = zone_config.model_dump(
|
zone_dump = zone_config.model_dump(
|
||||||
exclude={"contour", "color"}, exclude_defaults=True
|
exclude={"contour", "color"}, exclude_defaults=True
|
||||||
)
|
)
|
||||||
|
# Always include required fields
|
||||||
zone_dump.setdefault("coordinates", zone_config.coordinates)
|
zone_dump.setdefault("coordinates", zone_config.coordinates)
|
||||||
zones_dict[zone_name] = zone_dump
|
zones_dict[zone_name] = zone_dump
|
||||||
|
|
||||||
|
|||||||
@ -1,386 +0,0 @@
|
|||||||
"""Debug replay startup job: ffmpeg concat + camera config publish.
|
|
||||||
|
|
||||||
The runner orchestrates the async portion of starting a debug replay
|
|
||||||
session. The DebugReplayManager (in frigate.debug_replay) owns session
|
|
||||||
presence so the status bar can keep reading a single `active` flag from
|
|
||||||
/debug_replay/status for the entire session window — which is broader
|
|
||||||
than this job's lifetime.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import subprocess as sp
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import TYPE_CHECKING, Any, Optional, cast
|
|
||||||
|
|
||||||
from peewee import ModelSelect
|
|
||||||
|
|
||||||
from frigate.config import FrigateConfig
|
|
||||||
from frigate.config.camera.updater import CameraConfigUpdatePublisher
|
|
||||||
from frigate.const import REPLAY_CAMERA_PREFIX, REPLAY_DIR
|
|
||||||
from frigate.jobs.export import JobStatePublisher
|
|
||||||
from frigate.jobs.job import Job
|
|
||||||
from frigate.jobs.manager import job_is_running, set_current_job
|
|
||||||
from frigate.models import Recordings
|
|
||||||
from frigate.types import JobStatusTypesEnum
|
|
||||||
from frigate.util.ffmpeg import run_ffmpeg_with_progress
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from frigate.debug_replay import DebugReplayManager
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Coalesce frequent ffmpeg progress callbacks so the WS isn't flooded.
|
|
||||||
PROGRESS_BROADCAST_MIN_INTERVAL = 1.0
|
|
||||||
|
|
||||||
JOB_TYPE = "debug_replay"
|
|
||||||
|
|
||||||
STEP_PREPARING_CLIP = "preparing_clip"
|
|
||||||
STEP_STARTING_CAMERA = "starting_camera"
|
|
||||||
|
|
||||||
|
|
||||||
_active_runner: Optional["DebugReplayJobRunner"] = None
|
|
||||||
_runner_lock = threading.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
def _set_active_runner(runner: Optional["DebugReplayJobRunner"]) -> None:
|
|
||||||
global _active_runner
|
|
||||||
with _runner_lock:
|
|
||||||
_active_runner = runner
|
|
||||||
|
|
||||||
|
|
||||||
def get_active_runner() -> Optional["DebugReplayJobRunner"]:
|
|
||||||
with _runner_lock:
|
|
||||||
return _active_runner
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class DebugReplayJob(Job):
|
|
||||||
"""Job state for a debug replay startup."""
|
|
||||||
|
|
||||||
job_type: str = JOB_TYPE
|
|
||||||
source_camera: str = ""
|
|
||||||
replay_camera_name: str = ""
|
|
||||||
start_ts: float = 0.0
|
|
||||||
end_ts: float = 0.0
|
|
||||||
current_step: Optional[str] = None
|
|
||||||
progress_percent: float = 0.0
|
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
|
||||||
"""Whitelisted payload for the job_state WS topic.
|
|
||||||
|
|
||||||
Replay-specific fields land in results so the frontend's
|
|
||||||
generic Job<TResults> type can be parameterised cleanly.
|
|
||||||
"""
|
|
||||||
return {
|
|
||||||
"id": self.id,
|
|
||||||
"job_type": self.job_type,
|
|
||||||
"status": self.status,
|
|
||||||
"start_time": self.start_time,
|
|
||||||
"end_time": self.end_time,
|
|
||||||
"error_message": self.error_message,
|
|
||||||
"results": {
|
|
||||||
"current_step": self.current_step,
|
|
||||||
"progress_percent": self.progress_percent,
|
|
||||||
"source_camera": self.source_camera,
|
|
||||||
"replay_camera_name": self.replay_camera_name,
|
|
||||||
"start_ts": self.start_ts,
|
|
||||||
"end_ts": self.end_ts,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def query_recordings(source_camera: str, start_ts: float, end_ts: float) -> ModelSelect:
|
|
||||||
"""Return the Recordings query for the time range.
|
|
||||||
|
|
||||||
Module-level so tests can patch it without instantiating a runner.
|
|
||||||
"""
|
|
||||||
query = (
|
|
||||||
Recordings.select(
|
|
||||||
Recordings.path,
|
|
||||||
Recordings.start_time,
|
|
||||||
Recordings.end_time,
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
Recordings.start_time.between(start_ts, end_ts)
|
|
||||||
| Recordings.end_time.between(start_ts, end_ts)
|
|
||||||
| ((start_ts > Recordings.start_time) & (end_ts < Recordings.end_time))
|
|
||||||
)
|
|
||||||
.where(Recordings.camera == source_camera)
|
|
||||||
.order_by(Recordings.start_time.asc())
|
|
||||||
)
|
|
||||||
return cast(ModelSelect, query)
|
|
||||||
|
|
||||||
|
|
||||||
class DebugReplayJobRunner(threading.Thread):
|
|
||||||
"""Worker thread that drives the startup job to completion.
|
|
||||||
|
|
||||||
Owns the live ffmpeg Popen reference for cancellation. Cancellation
|
|
||||||
is two-step (threading.Event + proc.terminate()) so the runner
|
|
||||||
both knows it should stop and is unblocked from its blocking subprocess
|
|
||||||
wait.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
job: DebugReplayJob,
|
|
||||||
frigate_config: FrigateConfig,
|
|
||||||
config_publisher: CameraConfigUpdatePublisher,
|
|
||||||
replay_manager: "DebugReplayManager",
|
|
||||||
publisher: Optional[JobStatePublisher] = None,
|
|
||||||
) -> None:
|
|
||||||
super().__init__(daemon=True, name=f"debug_replay_{job.id}")
|
|
||||||
self.job = job
|
|
||||||
self.frigate_config = frigate_config
|
|
||||||
self.config_publisher = config_publisher
|
|
||||||
self.replay_manager = replay_manager
|
|
||||||
self.publisher = publisher if publisher is not None else JobStatePublisher()
|
|
||||||
self._cancel_event = threading.Event()
|
|
||||||
self._active_process: sp.Popen | None = None
|
|
||||||
self._proc_lock = threading.Lock()
|
|
||||||
self._last_broadcast_monotonic: float = 0.0
|
|
||||||
|
|
||||||
def cancel(self) -> None:
|
|
||||||
"""Request cancellation. Idempotent."""
|
|
||||||
self._cancel_event.set()
|
|
||||||
with self._proc_lock:
|
|
||||||
proc = self._active_process
|
|
||||||
if proc is not None:
|
|
||||||
try:
|
|
||||||
proc.terminate()
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("Failed to terminate ffmpeg subprocess: %s", exc)
|
|
||||||
|
|
||||||
def is_cancelled(self) -> bool:
|
|
||||||
return self._cancel_event.is_set()
|
|
||||||
|
|
||||||
def _record_proc(self, proc: sp.Popen) -> None:
|
|
||||||
with self._proc_lock:
|
|
||||||
self._active_process = proc
|
|
||||||
# Race: cancel arrived between Popen and _record_proc.
|
|
||||||
if self._cancel_event.is_set():
|
|
||||||
try:
|
|
||||||
proc.terminate()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _broadcast(self, force: bool = False) -> None:
|
|
||||||
now = time.monotonic()
|
|
||||||
if (
|
|
||||||
not force
|
|
||||||
and now - self._last_broadcast_monotonic < PROGRESS_BROADCAST_MIN_INTERVAL
|
|
||||||
):
|
|
||||||
return
|
|
||||||
self._last_broadcast_monotonic = now
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.publisher.publish(self.job.to_dict())
|
|
||||||
except Exception as err:
|
|
||||||
logger.warning("Publisher raised during job state broadcast: %s", err)
|
|
||||||
|
|
||||||
def run(self) -> None:
|
|
||||||
replay_name = self.job.replay_camera_name
|
|
||||||
os.makedirs(REPLAY_DIR, exist_ok=True)
|
|
||||||
concat_file = os.path.join(REPLAY_DIR, f"{replay_name}_concat.txt")
|
|
||||||
clip_path = os.path.join(REPLAY_DIR, f"{replay_name}.mp4")
|
|
||||||
|
|
||||||
self.job.status = JobStatusTypesEnum.running
|
|
||||||
self.job.start_time = time.time()
|
|
||||||
self.job.current_step = STEP_PREPARING_CLIP
|
|
||||||
self._broadcast(force=True)
|
|
||||||
|
|
||||||
try:
|
|
||||||
recordings = query_recordings(
|
|
||||||
self.job.source_camera, self.job.start_ts, self.job.end_ts
|
|
||||||
)
|
|
||||||
with open(concat_file, "w") as f:
|
|
||||||
for recording in recordings:
|
|
||||||
f.write(f"file '{recording.path}'\n")
|
|
||||||
|
|
||||||
ffmpeg_cmd = [
|
|
||||||
self.frigate_config.ffmpeg.ffmpeg_path,
|
|
||||||
"-hide_banner",
|
|
||||||
"-y",
|
|
||||||
"-f",
|
|
||||||
"concat",
|
|
||||||
"-safe",
|
|
||||||
"0",
|
|
||||||
"-i",
|
|
||||||
concat_file,
|
|
||||||
"-c",
|
|
||||||
"copy",
|
|
||||||
"-movflags",
|
|
||||||
"+faststart",
|
|
||||||
clip_path,
|
|
||||||
]
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Generating replay clip for %s (%.1f - %.1f)",
|
|
||||||
self.job.source_camera,
|
|
||||||
self.job.start_ts,
|
|
||||||
self.job.end_ts,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _on_progress(percent: float) -> None:
|
|
||||||
self.job.progress_percent = percent
|
|
||||||
self._broadcast()
|
|
||||||
|
|
||||||
try:
|
|
||||||
returncode, stderr = run_ffmpeg_with_progress(
|
|
||||||
ffmpeg_cmd,
|
|
||||||
expected_duration_seconds=max(
|
|
||||||
0.0, self.job.end_ts - self.job.start_ts
|
|
||||||
),
|
|
||||||
on_progress=_on_progress,
|
|
||||||
process_started=self._record_proc,
|
|
||||||
use_low_priority=True,
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
with self._proc_lock:
|
|
||||||
self._active_process = None
|
|
||||||
|
|
||||||
if self._cancel_event.is_set():
|
|
||||||
self._finalize_cancelled(clip_path)
|
|
||||||
return
|
|
||||||
|
|
||||||
if returncode != 0:
|
|
||||||
raise RuntimeError(f"FFmpeg failed: {stderr[-500:]}")
|
|
||||||
|
|
||||||
if not os.path.exists(clip_path):
|
|
||||||
raise RuntimeError("Clip file was not created")
|
|
||||||
|
|
||||||
self.job.current_step = STEP_STARTING_CAMERA
|
|
||||||
self.job.progress_percent = 100.0
|
|
||||||
self._broadcast(force=True)
|
|
||||||
|
|
||||||
if self._cancel_event.is_set():
|
|
||||||
self._finalize_cancelled(clip_path)
|
|
||||||
return
|
|
||||||
|
|
||||||
self.replay_manager.publish_camera(
|
|
||||||
source_camera=self.job.source_camera,
|
|
||||||
replay_name=replay_name,
|
|
||||||
clip_path=clip_path,
|
|
||||||
frigate_config=self.frigate_config,
|
|
||||||
config_publisher=self.config_publisher,
|
|
||||||
)
|
|
||||||
self.replay_manager.mark_session_ready(clip_path)
|
|
||||||
|
|
||||||
self.job.status = JobStatusTypesEnum.success
|
|
||||||
self.job.end_time = time.time()
|
|
||||||
self._broadcast(force=True)
|
|
||||||
logger.info(
|
|
||||||
"Debug replay started: %s -> %s",
|
|
||||||
self.job.source_camera,
|
|
||||||
replay_name,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.exception("Debug replay startup failed")
|
|
||||||
self.job.status = JobStatusTypesEnum.failed
|
|
||||||
self.job.error_message = str(exc)
|
|
||||||
self.job.end_time = time.time()
|
|
||||||
self._broadcast(force=True)
|
|
||||||
self.replay_manager.clear_session()
|
|
||||||
_remove_silent(clip_path)
|
|
||||||
finally:
|
|
||||||
_remove_silent(concat_file)
|
|
||||||
_set_active_runner(None)
|
|
||||||
|
|
||||||
def _finalize_cancelled(self, clip_path: str) -> None:
|
|
||||||
logger.info("Debug replay startup cancelled")
|
|
||||||
self.job.status = JobStatusTypesEnum.cancelled
|
|
||||||
self.job.end_time = time.time()
|
|
||||||
self._broadcast(force=True)
|
|
||||||
# The caller of cancel_debug_replay_job (DebugReplayManager.stop) owns
|
|
||||||
# session cleanup — db rows, filesystem artifacts, clear_session. We
|
|
||||||
# only clean up the partial concat output we created.
|
|
||||||
_remove_silent(clip_path)
|
|
||||||
|
|
||||||
|
|
||||||
def _remove_silent(path: str) -> None:
|
|
||||||
try:
|
|
||||||
if os.path.exists(path):
|
|
||||||
os.remove(path)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def start_debug_replay_job(
|
|
||||||
*,
|
|
||||||
source_camera: str,
|
|
||||||
start_ts: float,
|
|
||||||
end_ts: float,
|
|
||||||
frigate_config: FrigateConfig,
|
|
||||||
config_publisher: CameraConfigUpdatePublisher,
|
|
||||||
replay_manager: "DebugReplayManager",
|
|
||||||
) -> str:
|
|
||||||
"""Validate, create job, start runner. Returns the job id.
|
|
||||||
|
|
||||||
Raises ValueError for bad params (camera missing, time range
|
|
||||||
invalid, no recordings) and RuntimeError if a session is already
|
|
||||||
active.
|
|
||||||
"""
|
|
||||||
if job_is_running(JOB_TYPE) or replay_manager.active:
|
|
||||||
raise RuntimeError("A replay session is already active")
|
|
||||||
|
|
||||||
if source_camera not in frigate_config.cameras:
|
|
||||||
raise ValueError(f"Camera '{source_camera}' not found")
|
|
||||||
|
|
||||||
if end_ts <= start_ts:
|
|
||||||
raise ValueError("End time must be after start time")
|
|
||||||
|
|
||||||
recordings = query_recordings(source_camera, start_ts, end_ts)
|
|
||||||
if not recordings.count():
|
|
||||||
raise ValueError(
|
|
||||||
f"No recordings found for camera '{source_camera}' in the specified time range"
|
|
||||||
)
|
|
||||||
|
|
||||||
replay_name = f"{REPLAY_CAMERA_PREFIX}{source_camera}"
|
|
||||||
replay_manager.mark_starting(
|
|
||||||
source_camera=source_camera,
|
|
||||||
replay_camera_name=replay_name,
|
|
||||||
start_ts=start_ts,
|
|
||||||
end_ts=end_ts,
|
|
||||||
)
|
|
||||||
|
|
||||||
job = DebugReplayJob(
|
|
||||||
source_camera=source_camera,
|
|
||||||
replay_camera_name=replay_name,
|
|
||||||
start_ts=start_ts,
|
|
||||||
end_ts=end_ts,
|
|
||||||
)
|
|
||||||
set_current_job(job)
|
|
||||||
|
|
||||||
runner = DebugReplayJobRunner(
|
|
||||||
job=job,
|
|
||||||
frigate_config=frigate_config,
|
|
||||||
config_publisher=config_publisher,
|
|
||||||
replay_manager=replay_manager,
|
|
||||||
)
|
|
||||||
_set_active_runner(runner)
|
|
||||||
runner.start()
|
|
||||||
|
|
||||||
return job.id
|
|
||||||
|
|
||||||
|
|
||||||
def cancel_debug_replay_job() -> bool:
|
|
||||||
"""Signal the active runner to cancel.
|
|
||||||
|
|
||||||
Returns True if a runner was signalled, False if no job was active.
|
|
||||||
"""
|
|
||||||
runner = get_active_runner()
|
|
||||||
if runner is None:
|
|
||||||
return False
|
|
||||||
runner.cancel()
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def wait_for_runner(timeout: float = 2.0) -> bool:
|
|
||||||
"""Join the active runner. Returns True if the runner ended in time."""
|
|
||||||
runner = get_active_runner()
|
|
||||||
if runner is None:
|
|
||||||
return True
|
|
||||||
runner.join(timeout=timeout)
|
|
||||||
return not runner.is_alive()
|
|
||||||
@ -23,13 +23,13 @@ from frigate.const import (
|
|||||||
EXPORT_DIR,
|
EXPORT_DIR,
|
||||||
MAX_PLAYLIST_SECONDS,
|
MAX_PLAYLIST_SECONDS,
|
||||||
PREVIEW_FRAME_TYPE,
|
PREVIEW_FRAME_TYPE,
|
||||||
|
PROCESS_PRIORITY_LOW,
|
||||||
)
|
)
|
||||||
from frigate.ffmpeg_presets import (
|
from frigate.ffmpeg_presets import (
|
||||||
EncodeTypeEnum,
|
EncodeTypeEnum,
|
||||||
parse_preset_hardware_acceleration_encode,
|
parse_preset_hardware_acceleration_encode,
|
||||||
)
|
)
|
||||||
from frigate.models import Export, Previews, Recordings, ReviewSegment
|
from frigate.models import Export, Previews, Recordings, ReviewSegment
|
||||||
from frigate.util.ffmpeg import run_ffmpeg_with_progress
|
|
||||||
from frigate.util.time import is_current_hour
|
from frigate.util.time import is_current_hour
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -243,29 +243,107 @@ class RecordingExporter(threading.Thread):
|
|||||||
|
|
||||||
return total
|
return total
|
||||||
|
|
||||||
|
def _inject_progress_flags(self, ffmpeg_cmd: list[str]) -> list[str]:
|
||||||
|
"""Insert FFmpeg progress reporting flags before the output path.
|
||||||
|
|
||||||
|
``-progress pipe:2`` writes structured key=value lines to stderr,
|
||||||
|
``-nostats`` suppresses the noisy default stats output.
|
||||||
|
"""
|
||||||
|
if not ffmpeg_cmd:
|
||||||
|
return ffmpeg_cmd
|
||||||
|
return ffmpeg_cmd[:-1] + ["-progress", "pipe:2", "-nostats", ffmpeg_cmd[-1]]
|
||||||
|
|
||||||
def _run_ffmpeg_with_progress(
|
def _run_ffmpeg_with_progress(
|
||||||
self,
|
self,
|
||||||
ffmpeg_cmd: list[str],
|
ffmpeg_cmd: list[str],
|
||||||
playlist_lines: str | list[str],
|
playlist_lines: str | list[str],
|
||||||
step: str = "encoding",
|
step: str = "encoding",
|
||||||
) -> tuple[int, str]:
|
) -> tuple[int, str]:
|
||||||
"""Delegate to the shared helper, mapping percent → (step, percent).
|
"""Run an FFmpeg export command, parsing progress events from stderr.
|
||||||
|
|
||||||
Returns ``(returncode, captured_stderr)``.
|
Returns ``(returncode, captured_stderr)``. Stdout is left attached to
|
||||||
|
the parent process so we don't have to drain it (and risk a deadlock
|
||||||
|
if the buffer fills). Progress percent is computed against the
|
||||||
|
expected output duration; values are clamped to [0, 100] inside
|
||||||
|
:py:meth:`_emit_progress`.
|
||||||
"""
|
"""
|
||||||
|
cmd = ["nice", "-n", str(PROCESS_PRIORITY_LOW)] + self._inject_progress_flags(
|
||||||
|
ffmpeg_cmd
|
||||||
|
)
|
||||||
|
|
||||||
if isinstance(playlist_lines, list):
|
if isinstance(playlist_lines, list):
|
||||||
stdin_payload = "\n".join(playlist_lines)
|
stdin_payload = "\n".join(playlist_lines)
|
||||||
else:
|
else:
|
||||||
stdin_payload = playlist_lines
|
stdin_payload = playlist_lines
|
||||||
|
|
||||||
return run_ffmpeg_with_progress(
|
expected_duration = self._expected_output_duration_seconds()
|
||||||
ffmpeg_cmd,
|
|
||||||
expected_duration_seconds=self._expected_output_duration_seconds(),
|
self._emit_progress(step, 0.0)
|
||||||
on_progress=lambda percent: self._emit_progress(step, percent),
|
|
||||||
stdin_payload=stdin_payload,
|
proc = sp.Popen(
|
||||||
use_low_priority=True,
|
cmd,
|
||||||
|
stdin=sp.PIPE,
|
||||||
|
stderr=sp.PIPE,
|
||||||
|
text=True,
|
||||||
|
encoding="ascii",
|
||||||
|
errors="replace",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert proc.stdin is not None
|
||||||
|
assert proc.stderr is not None
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc.stdin.write(stdin_payload)
|
||||||
|
except (BrokenPipeError, OSError):
|
||||||
|
# FFmpeg may have rejected the input early; still wait for it
|
||||||
|
# to terminate so the returncode is meaningful.
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
proc.stdin.close()
|
||||||
|
except (BrokenPipeError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
captured: list[str] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
for raw_line in proc.stderr:
|
||||||
|
captured.append(raw_line)
|
||||||
|
line = raw_line.strip()
|
||||||
|
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if line.startswith("out_time_us="):
|
||||||
|
if expected_duration <= 0:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
out_time_us = int(line.split("=", 1)[1])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
continue
|
||||||
|
if out_time_us < 0:
|
||||||
|
continue
|
||||||
|
out_seconds = out_time_us / 1_000_000.0
|
||||||
|
percent = (out_seconds / expected_duration) * 100.0
|
||||||
|
self._emit_progress(step, percent)
|
||||||
|
elif line == "progress=end":
|
||||||
|
self._emit_progress(step, 100.0)
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed reading FFmpeg progress for %s", self.export_id)
|
||||||
|
|
||||||
|
proc.wait()
|
||||||
|
|
||||||
|
# Drain any remaining stderr so callers can log it on failure.
|
||||||
|
try:
|
||||||
|
remaining = proc.stderr.read()
|
||||||
|
if remaining:
|
||||||
|
captured.append(remaining)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return proc.returncode, "".join(captured)
|
||||||
|
|
||||||
def get_datetime_from_timestamp(self, timestamp: int) -> str:
|
def get_datetime_from_timestamp(self, timestamp: int) -> str:
|
||||||
# return in iso format using the configured ui.timezone when set,
|
# return in iso format using the configured ui.timezone when set,
|
||||||
# so the auto-generated export name reflects local time rather
|
# so the auto-generated export name reflects local time rather
|
||||||
@ -342,7 +420,6 @@ class RecordingExporter(threading.Thread):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
total_output = windows[-1][2] + (windows[-1][1] - windows[-1][0])
|
total_output = windows[-1][2] + (windows[-1][1] - windows[-1][0])
|
||||||
last_recorded_end = windows[-1][1]
|
|
||||||
|
|
||||||
def wall_to_output(t: float) -> float:
|
def wall_to_output(t: float) -> float:
|
||||||
t = max(float(self.start_time), min(float(self.end_time), t))
|
t = max(float(self.start_time), min(float(self.end_time), t))
|
||||||
@ -355,18 +432,8 @@ class RecordingExporter(threading.Thread):
|
|||||||
|
|
||||||
chapter_blocks: list[str] = []
|
chapter_blocks: list[str] = []
|
||||||
for review in review_rows:
|
for review in review_rows:
|
||||||
if review.start_time is None:
|
|
||||||
continue
|
|
||||||
# In-progress segments have a NULL end_time until the activity
|
|
||||||
# closes; clamp to the last recorded second so the chapter never
|
|
||||||
# extends past the actual video.
|
|
||||||
review_end = (
|
|
||||||
float(review.end_time)
|
|
||||||
if review.end_time is not None
|
|
||||||
else last_recorded_end
|
|
||||||
)
|
|
||||||
start_out = wall_to_output(float(review.start_time))
|
start_out = wall_to_output(float(review.start_time))
|
||||||
end_out = wall_to_output(review_end)
|
end_out = wall_to_output(float(review.end_time))
|
||||||
|
|
||||||
# Drop chapters that fall entirely in a recording gap, or are
|
# Drop chapters that fall entirely in a recording gap, or are
|
||||||
# too short to be navigable in a player.
|
# too short to be navigable in a player.
|
||||||
@ -449,14 +516,16 @@ class RecordingExporter(threading.Thread):
|
|||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
diff = max(0.0, float(self.start_time) - float(preview.start_time))
|
diff = self.start_time - preview.start_time
|
||||||
|
minutes = int(diff / 60)
|
||||||
|
seconds = int(diff % 60)
|
||||||
ffmpeg_cmd = [
|
ffmpeg_cmd = [
|
||||||
"/usr/lib/ffmpeg/7.0/bin/ffmpeg", # hardcode path for exports thumbnail due to missing libwebp support
|
"/usr/lib/ffmpeg/7.0/bin/ffmpeg", # hardcode path for exports thumbnail due to missing libwebp support
|
||||||
"-hide_banner",
|
"-hide_banner",
|
||||||
"-loglevel",
|
"-loglevel",
|
||||||
"warning",
|
"warning",
|
||||||
"-ss",
|
"-ss",
|
||||||
f"{diff:.3f}",
|
f"00:{minutes}:{seconds}",
|
||||||
"-i",
|
"-i",
|
||||||
preview.path,
|
preview.path,
|
||||||
"-frames",
|
"-frames",
|
||||||
|
|||||||
@ -1,123 +0,0 @@
|
|||||||
"""Tests for /debug_replay API endpoints."""
|
|
||||||
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from frigate.models import Event, Recordings, ReviewSegment
|
|
||||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
|
||||||
|
|
||||||
|
|
||||||
class TestDebugReplayAPI(BaseTestHttp):
|
|
||||||
def setUp(self):
|
|
||||||
super().setUp([Event, Recordings, ReviewSegment])
|
|
||||||
self.app = self.create_app()
|
|
||||||
|
|
||||||
def test_start_returns_202_with_job_id(self):
|
|
||||||
# Stub the factory to skip validation/threading and just record the
|
|
||||||
# name on the manager the way the real factory's mark_starting would.
|
|
||||||
def fake_start(**kwargs):
|
|
||||||
kwargs["replay_manager"].mark_starting(
|
|
||||||
source_camera=kwargs["source_camera"],
|
|
||||||
replay_camera_name="_replay_front",
|
|
||||||
start_ts=kwargs["start_ts"],
|
|
||||||
end_ts=kwargs["end_ts"],
|
|
||||||
)
|
|
||||||
return "job-1234"
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"frigate.api.debug_replay.start_debug_replay_job",
|
|
||||||
side_effect=fake_start,
|
|
||||||
):
|
|
||||||
with AuthTestClient(self.app) as client:
|
|
||||||
resp = client.post(
|
|
||||||
"/debug_replay/start",
|
|
||||||
json={
|
|
||||||
"camera": "front",
|
|
||||||
"start_time": 100,
|
|
||||||
"end_time": 200,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(resp.status_code, 202)
|
|
||||||
body = resp.json()
|
|
||||||
self.assertTrue(body["success"])
|
|
||||||
self.assertEqual(body["job_id"], "job-1234")
|
|
||||||
self.assertEqual(body["replay_camera"], "_replay_front")
|
|
||||||
|
|
||||||
def test_start_returns_400_on_validation_error(self):
|
|
||||||
with patch(
|
|
||||||
"frigate.api.debug_replay.start_debug_replay_job",
|
|
||||||
side_effect=ValueError("Camera 'missing' not found"),
|
|
||||||
):
|
|
||||||
with AuthTestClient(self.app) as client:
|
|
||||||
resp = client.post(
|
|
||||||
"/debug_replay/start",
|
|
||||||
json={
|
|
||||||
"camera": "missing",
|
|
||||||
"start_time": 100,
|
|
||||||
"end_time": 200,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(resp.status_code, 400)
|
|
||||||
body = resp.json()
|
|
||||||
self.assertFalse(body["success"])
|
|
||||||
# Message is hard-coded so we don't echo exception text back to clients
|
|
||||||
# (CodeQL: information exposure through an exception).
|
|
||||||
self.assertEqual(body["message"], "Invalid debug replay parameters")
|
|
||||||
|
|
||||||
def test_start_returns_409_when_session_already_active(self):
|
|
||||||
with patch(
|
|
||||||
"frigate.api.debug_replay.start_debug_replay_job",
|
|
||||||
side_effect=RuntimeError("A replay session is already active"),
|
|
||||||
):
|
|
||||||
with AuthTestClient(self.app) as client:
|
|
||||||
resp = client.post(
|
|
||||||
"/debug_replay/start",
|
|
||||||
json={
|
|
||||||
"camera": "front",
|
|
||||||
"start_time": 100,
|
|
||||||
"end_time": 200,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(resp.status_code, 409)
|
|
||||||
body = resp.json()
|
|
||||||
self.assertFalse(body["success"])
|
|
||||||
|
|
||||||
def test_status_inactive_when_no_session(self):
|
|
||||||
with AuthTestClient(self.app) as client:
|
|
||||||
resp = client.get("/debug_replay/status")
|
|
||||||
|
|
||||||
self.assertEqual(resp.status_code, 200)
|
|
||||||
body = resp.json()
|
|
||||||
self.assertFalse(body["active"])
|
|
||||||
self.assertIsNone(body["replay_camera"])
|
|
||||||
self.assertIsNone(body["source_camera"])
|
|
||||||
self.assertIsNone(body["start_time"])
|
|
||||||
self.assertIsNone(body["end_time"])
|
|
||||||
self.assertFalse(body["live_ready"])
|
|
||||||
# Make sure deprecated fields are gone
|
|
||||||
self.assertNotIn("state", body)
|
|
||||||
self.assertNotIn("progress_percent", body)
|
|
||||||
self.assertNotIn("error_message", body)
|
|
||||||
|
|
||||||
def test_status_active_after_mark_starting(self):
|
|
||||||
manager = self.app.replay_manager
|
|
||||||
manager.mark_starting(
|
|
||||||
source_camera="front",
|
|
||||||
replay_camera_name="_replay_front",
|
|
||||||
start_ts=100.0,
|
|
||||||
end_ts=200.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
with AuthTestClient(self.app) as client:
|
|
||||||
resp = client.get("/debug_replay/status")
|
|
||||||
|
|
||||||
self.assertEqual(resp.status_code, 200)
|
|
||||||
body = resp.json()
|
|
||||||
self.assertTrue(body["active"])
|
|
||||||
self.assertEqual(body["replay_camera"], "_replay_front")
|
|
||||||
self.assertEqual(body["source_camera"], "front")
|
|
||||||
self.assertEqual(body["start_time"], 100.0)
|
|
||||||
self.assertEqual(body["end_time"], 200.0)
|
|
||||||
self.assertFalse(body["live_ready"])
|
|
||||||
@ -1,242 +0,0 @@
|
|||||||
"""Tests for the simplified DebugReplayManager.
|
|
||||||
|
|
||||||
Startup orchestration lives in ``frigate.jobs.debug_replay`` (covered by
|
|
||||||
``test_debug_replay_job``). The manager owns only session presence and
|
|
||||||
cleanup.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import unittest
|
|
||||||
import unittest.mock
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
|
|
||||||
class TestDebugReplayManagerSession(unittest.TestCase):
|
|
||||||
def test_inactive_by_default(self) -> None:
|
|
||||||
from frigate.debug_replay import DebugReplayManager
|
|
||||||
|
|
||||||
manager = DebugReplayManager()
|
|
||||||
|
|
||||||
self.assertFalse(manager.active)
|
|
||||||
self.assertIsNone(manager.replay_camera_name)
|
|
||||||
self.assertIsNone(manager.source_camera)
|
|
||||||
self.assertIsNone(manager.clip_path)
|
|
||||||
self.assertIsNone(manager.start_ts)
|
|
||||||
self.assertIsNone(manager.end_ts)
|
|
||||||
|
|
||||||
def test_mark_starting_sets_session_pointers_and_active(self) -> None:
|
|
||||||
from frigate.debug_replay import DebugReplayManager
|
|
||||||
|
|
||||||
manager = DebugReplayManager()
|
|
||||||
|
|
||||||
manager.mark_starting(
|
|
||||||
source_camera="front",
|
|
||||||
replay_camera_name="_replay_front",
|
|
||||||
start_ts=100.0,
|
|
||||||
end_ts=200.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertTrue(manager.active)
|
|
||||||
self.assertEqual(manager.replay_camera_name, "_replay_front")
|
|
||||||
self.assertEqual(manager.source_camera, "front")
|
|
||||||
self.assertEqual(manager.start_ts, 100.0)
|
|
||||||
self.assertEqual(manager.end_ts, 200.0)
|
|
||||||
self.assertIsNone(manager.clip_path)
|
|
||||||
|
|
||||||
def test_mark_session_ready_sets_clip_path(self) -> None:
|
|
||||||
from frigate.debug_replay import DebugReplayManager
|
|
||||||
|
|
||||||
manager = DebugReplayManager()
|
|
||||||
manager.mark_starting("front", "_replay_front", 100.0, 200.0)
|
|
||||||
|
|
||||||
manager.mark_session_ready(clip_path="/tmp/replay/_replay_front.mp4")
|
|
||||||
|
|
||||||
self.assertEqual(manager.clip_path, "/tmp/replay/_replay_front.mp4")
|
|
||||||
self.assertTrue(manager.active)
|
|
||||||
|
|
||||||
def test_clear_session_resets_all_pointers(self) -> None:
|
|
||||||
from frigate.debug_replay import DebugReplayManager
|
|
||||||
|
|
||||||
manager = DebugReplayManager()
|
|
||||||
manager.mark_starting("front", "_replay_front", 100.0, 200.0)
|
|
||||||
manager.mark_session_ready("/tmp/replay/clip.mp4")
|
|
||||||
|
|
||||||
manager.clear_session()
|
|
||||||
|
|
||||||
self.assertFalse(manager.active)
|
|
||||||
self.assertIsNone(manager.replay_camera_name)
|
|
||||||
self.assertIsNone(manager.source_camera)
|
|
||||||
self.assertIsNone(manager.clip_path)
|
|
||||||
self.assertIsNone(manager.start_ts)
|
|
||||||
self.assertIsNone(manager.end_ts)
|
|
||||||
|
|
||||||
|
|
||||||
class TestDebugReplayManagerStop(unittest.TestCase):
|
|
||||||
def test_stop_when_inactive_is_a_noop(self) -> None:
|
|
||||||
from frigate.debug_replay import DebugReplayManager
|
|
||||||
|
|
||||||
manager = DebugReplayManager()
|
|
||||||
frigate_config = MagicMock()
|
|
||||||
frigate_config.cameras = {}
|
|
||||||
publisher = MagicMock()
|
|
||||||
|
|
||||||
# Should not raise; should not publish any events.
|
|
||||||
manager.stop(frigate_config=frigate_config, config_publisher=publisher)
|
|
||||||
|
|
||||||
publisher.publish_update.assert_not_called()
|
|
||||||
|
|
||||||
def test_stop_publishes_remove_when_camera_was_published(self) -> None:
|
|
||||||
from frigate.config.camera.updater import CameraConfigUpdateEnum
|
|
||||||
from frigate.debug_replay import DebugReplayManager
|
|
||||||
|
|
||||||
manager = DebugReplayManager()
|
|
||||||
manager.mark_starting("front", "_replay_front", 100.0, 200.0)
|
|
||||||
manager.mark_session_ready("/tmp/replay/_replay_front.mp4")
|
|
||||||
|
|
||||||
camera_config = MagicMock()
|
|
||||||
frigate_config = MagicMock()
|
|
||||||
frigate_config.cameras = {"_replay_front": camera_config}
|
|
||||||
publisher = MagicMock()
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch.object(manager, "_cleanup_db"),
|
|
||||||
patch.object(manager, "_cleanup_files"),
|
|
||||||
patch("frigate.debug_replay.cancel_debug_replay_job", return_value=False),
|
|
||||||
):
|
|
||||||
manager.stop(frigate_config=frigate_config, config_publisher=publisher)
|
|
||||||
|
|
||||||
# One publish_update call with a remove topic.
|
|
||||||
self.assertEqual(publisher.publish_update.call_count, 1)
|
|
||||||
topic_arg = publisher.publish_update.call_args.args[0]
|
|
||||||
self.assertEqual(topic_arg.update_type, CameraConfigUpdateEnum.remove)
|
|
||||||
self.assertFalse(manager.active)
|
|
||||||
|
|
||||||
def test_stop_skips_remove_publish_when_camera_not_in_config(self) -> None:
|
|
||||||
"""Cancellation during preparing_clip: no camera was published yet."""
|
|
||||||
from frigate.debug_replay import DebugReplayManager
|
|
||||||
|
|
||||||
manager = DebugReplayManager()
|
|
||||||
manager.mark_starting("front", "_replay_front", 100.0, 200.0)
|
|
||||||
# clip_path stays None because we cancelled before camera publish.
|
|
||||||
|
|
||||||
frigate_config = MagicMock()
|
|
||||||
frigate_config.cameras = {} # _replay_front not present
|
|
||||||
publisher = MagicMock()
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch.object(manager, "_cleanup_db"),
|
|
||||||
patch.object(manager, "_cleanup_files"),
|
|
||||||
patch("frigate.debug_replay.cancel_debug_replay_job", return_value=True),
|
|
||||||
):
|
|
||||||
manager.stop(frigate_config=frigate_config, config_publisher=publisher)
|
|
||||||
|
|
||||||
publisher.publish_update.assert_not_called()
|
|
||||||
self.assertFalse(manager.active)
|
|
||||||
|
|
||||||
def test_stop_calls_cancel_debug_replay_job(self) -> None:
|
|
||||||
from frigate.debug_replay import DebugReplayManager
|
|
||||||
|
|
||||||
manager = DebugReplayManager()
|
|
||||||
manager.mark_starting("front", "_replay_front", 100.0, 200.0)
|
|
||||||
|
|
||||||
frigate_config = MagicMock()
|
|
||||||
frigate_config.cameras = {}
|
|
||||||
publisher = MagicMock()
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch.object(manager, "_cleanup_db"),
|
|
||||||
patch.object(manager, "_cleanup_files"),
|
|
||||||
patch(
|
|
||||||
"frigate.debug_replay.cancel_debug_replay_job",
|
|
||||||
return_value=True,
|
|
||||||
) as mock_cancel,
|
|
||||||
):
|
|
||||||
manager.stop(frigate_config=frigate_config, config_publisher=publisher)
|
|
||||||
|
|
||||||
mock_cancel.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
class TestDebugReplayManagerPublishCamera(unittest.TestCase):
|
|
||||||
def test_publish_camera_invokes_publisher_with_add_topic(self) -> None:
|
|
||||||
from frigate.config.camera.updater import CameraConfigUpdateEnum
|
|
||||||
from frigate.debug_replay import DebugReplayManager
|
|
||||||
|
|
||||||
manager = DebugReplayManager()
|
|
||||||
|
|
||||||
source_config = MagicMock()
|
|
||||||
new_camera_config = MagicMock()
|
|
||||||
frigate_config = MagicMock()
|
|
||||||
frigate_config.cameras = {"front": source_config}
|
|
||||||
publisher = MagicMock()
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch.object(
|
|
||||||
manager,
|
|
||||||
"_build_camera_config_dict",
|
|
||||||
return_value={"enabled": True},
|
|
||||||
),
|
|
||||||
patch("frigate.debug_replay.find_config_file", return_value="/cfg.yml"),
|
|
||||||
patch("frigate.debug_replay.YAML") as yaml_cls,
|
|
||||||
patch("frigate.debug_replay.FrigateConfig.parse_object") as parse_object,
|
|
||||||
patch("builtins.open", unittest.mock.mock_open(read_data="cameras:\n")),
|
|
||||||
):
|
|
||||||
yaml_instance = yaml_cls.return_value
|
|
||||||
yaml_instance.load.return_value = {"cameras": {}}
|
|
||||||
parsed = MagicMock()
|
|
||||||
parsed.cameras = {"_replay_front": new_camera_config}
|
|
||||||
parse_object.return_value = parsed
|
|
||||||
|
|
||||||
manager.publish_camera(
|
|
||||||
source_camera="front",
|
|
||||||
replay_name="_replay_front",
|
|
||||||
clip_path="/tmp/clip.mp4",
|
|
||||||
frigate_config=frigate_config,
|
|
||||||
config_publisher=publisher,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Camera registered into the live config dict
|
|
||||||
self.assertIn("_replay_front", frigate_config.cameras)
|
|
||||||
# Publisher invoked with an add topic
|
|
||||||
self.assertEqual(publisher.publish_update.call_count, 1)
|
|
||||||
topic_arg = publisher.publish_update.call_args.args[0]
|
|
||||||
self.assertEqual(topic_arg.update_type, CameraConfigUpdateEnum.add)
|
|
||||||
|
|
||||||
def test_publish_camera_wraps_parse_failure_in_runtime_error(self) -> None:
|
|
||||||
from frigate.debug_replay import DebugReplayManager
|
|
||||||
|
|
||||||
manager = DebugReplayManager()
|
|
||||||
frigate_config = MagicMock()
|
|
||||||
frigate_config.cameras = {"front": MagicMock()}
|
|
||||||
publisher = MagicMock()
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch.object(
|
|
||||||
manager,
|
|
||||||
"_build_camera_config_dict",
|
|
||||||
return_value={"enabled": True},
|
|
||||||
),
|
|
||||||
patch("frigate.debug_replay.find_config_file", return_value="/cfg.yml"),
|
|
||||||
patch("frigate.debug_replay.YAML") as yaml_cls,
|
|
||||||
patch(
|
|
||||||
"frigate.debug_replay.FrigateConfig.parse_object",
|
|
||||||
side_effect=ValueError("zone foo has invalid coordinates"),
|
|
||||||
),
|
|
||||||
patch("builtins.open", unittest.mock.mock_open(read_data="cameras:\n")),
|
|
||||||
):
|
|
||||||
yaml_cls.return_value.load.return_value = {"cameras": {}}
|
|
||||||
|
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
|
||||||
manager.publish_camera(
|
|
||||||
source_camera="front",
|
|
||||||
replay_name="_replay_front",
|
|
||||||
clip_path="/tmp/clip.mp4",
|
|
||||||
frigate_config=frigate_config,
|
|
||||||
config_publisher=publisher,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertIn("replay camera config", str(ctx.exception))
|
|
||||||
self.assertIn("invalid coordinates", str(ctx.exception))
|
|
||||||
publisher.publish_update.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@ -1,460 +0,0 @@
|
|||||||
"""Tests for the debug replay job runner and factory."""
|
|
||||||
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
import unittest
|
|
||||||
import unittest.mock
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
from frigate.debug_replay import DebugReplayManager
|
|
||||||
from frigate.jobs.debug_replay import (
|
|
||||||
DebugReplayJob,
|
|
||||||
cancel_debug_replay_job,
|
|
||||||
get_active_runner,
|
|
||||||
start_debug_replay_job,
|
|
||||||
)
|
|
||||||
from frigate.jobs.export import JobStatePublisher
|
|
||||||
from frigate.jobs.manager import _completed_jobs, _current_jobs
|
|
||||||
from frigate.types import JobStatusTypesEnum
|
|
||||||
|
|
||||||
|
|
||||||
def _reset_job_manager() -> None:
|
|
||||||
"""Clear the global job manager state between tests."""
|
|
||||||
_current_jobs.clear()
|
|
||||||
_completed_jobs.clear()
|
|
||||||
|
|
||||||
|
|
||||||
def _patch_publisher(test_case: unittest.TestCase) -> None:
|
|
||||||
"""Replace JobStatePublisher.publish with a no-op to avoid hanging on IPC."""
|
|
||||||
publisher_patch = patch.object(
|
|
||||||
JobStatePublisher, "publish", lambda self, payload: None
|
|
||||||
)
|
|
||||||
publisher_patch.start()
|
|
||||||
test_case.addCleanup(publisher_patch.stop)
|
|
||||||
|
|
||||||
|
|
||||||
class TestDebugReplayJob(unittest.TestCase):
|
|
||||||
def test_default_fields(self) -> None:
|
|
||||||
job = DebugReplayJob()
|
|
||||||
|
|
||||||
self.assertEqual(job.job_type, "debug_replay")
|
|
||||||
self.assertEqual(job.status, JobStatusTypesEnum.queued)
|
|
||||||
self.assertIsNone(job.current_step)
|
|
||||||
self.assertEqual(job.progress_percent, 0.0)
|
|
||||||
|
|
||||||
def test_to_dict_whitelist(self) -> None:
|
|
||||||
job = DebugReplayJob(
|
|
||||||
source_camera="front",
|
|
||||||
replay_camera_name="_replay_front",
|
|
||||||
start_ts=100.0,
|
|
||||||
end_ts=200.0,
|
|
||||||
)
|
|
||||||
job.current_step = "preparing_clip"
|
|
||||||
job.progress_percent = 42.5
|
|
||||||
|
|
||||||
payload = job.to_dict()
|
|
||||||
|
|
||||||
# Top-level matches the standard Job<TResults> shape.
|
|
||||||
for key in (
|
|
||||||
"id",
|
|
||||||
"job_type",
|
|
||||||
"status",
|
|
||||||
"start_time",
|
|
||||||
"end_time",
|
|
||||||
"error_message",
|
|
||||||
"results",
|
|
||||||
):
|
|
||||||
self.assertIn(key, payload, f"missing top-level field: {key}")
|
|
||||||
|
|
||||||
results = payload["results"]
|
|
||||||
self.assertEqual(results["source_camera"], "front")
|
|
||||||
self.assertEqual(results["replay_camera_name"], "_replay_front")
|
|
||||||
self.assertEqual(results["current_step"], "preparing_clip")
|
|
||||||
self.assertEqual(results["progress_percent"], 42.5)
|
|
||||||
self.assertEqual(results["start_ts"], 100.0)
|
|
||||||
self.assertEqual(results["end_ts"], 200.0)
|
|
||||||
|
|
||||||
|
|
||||||
class TestStartDebugReplayJob(unittest.TestCase):
|
|
||||||
def setUp(self) -> None:
|
|
||||||
_reset_job_manager()
|
|
||||||
_patch_publisher(self)
|
|
||||||
self.manager = DebugReplayManager()
|
|
||||||
self.frigate_config = MagicMock()
|
|
||||||
self.frigate_config.cameras = {"front": MagicMock()}
|
|
||||||
self.frigate_config.ffmpeg.ffmpeg_path = "/bin/true"
|
|
||||||
self.publisher = MagicMock()
|
|
||||||
|
|
||||||
self.recordings_qs = MagicMock()
|
|
||||||
self.recordings_qs.count.return_value = 1
|
|
||||||
self.recordings_qs.__iter__.return_value = iter([MagicMock(path="/tmp/r1.mp4")])
|
|
||||||
|
|
||||||
def tearDown(self) -> None:
|
|
||||||
runner = get_active_runner()
|
|
||||||
if runner is not None:
|
|
||||||
runner.cancel()
|
|
||||||
runner.join(timeout=2.0)
|
|
||||||
_reset_job_manager()
|
|
||||||
|
|
||||||
def test_rejects_unknown_camera(self) -> None:
|
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
start_debug_replay_job(
|
|
||||||
source_camera="missing",
|
|
||||||
start_ts=100.0,
|
|
||||||
end_ts=200.0,
|
|
||||||
frigate_config=self.frigate_config,
|
|
||||||
config_publisher=self.publisher,
|
|
||||||
replay_manager=self.manager,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_rejects_invalid_time_range(self) -> None:
|
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
start_debug_replay_job(
|
|
||||||
source_camera="front",
|
|
||||||
start_ts=200.0,
|
|
||||||
end_ts=100.0,
|
|
||||||
frigate_config=self.frigate_config,
|
|
||||||
config_publisher=self.publisher,
|
|
||||||
replay_manager=self.manager,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_rejects_when_no_recordings(self) -> None:
|
|
||||||
empty_qs = MagicMock()
|
|
||||||
empty_qs.count.return_value = 0
|
|
||||||
with patch("frigate.jobs.debug_replay.query_recordings", return_value=empty_qs):
|
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
start_debug_replay_job(
|
|
||||||
source_camera="front",
|
|
||||||
start_ts=100.0,
|
|
||||||
end_ts=200.0,
|
|
||||||
frigate_config=self.frigate_config,
|
|
||||||
config_publisher=self.publisher,
|
|
||||||
replay_manager=self.manager,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_returns_job_id_and_marks_session_starting(self) -> None:
|
|
||||||
block = threading.Event()
|
|
||||||
|
|
||||||
def slow_helper(cmd, **kwargs):
|
|
||||||
block.wait(timeout=5)
|
|
||||||
return 0, ""
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch(
|
|
||||||
"frigate.jobs.debug_replay.query_recordings",
|
|
||||||
return_value=self.recordings_qs,
|
|
||||||
),
|
|
||||||
patch(
|
|
||||||
"frigate.jobs.debug_replay.run_ffmpeg_with_progress",
|
|
||||||
side_effect=slow_helper,
|
|
||||||
),
|
|
||||||
patch.object(self.manager, "publish_camera"),
|
|
||||||
patch("os.path.exists", return_value=True),
|
|
||||||
patch("os.makedirs"),
|
|
||||||
patch("builtins.open", unittest.mock.mock_open()),
|
|
||||||
):
|
|
||||||
job_id = start_debug_replay_job(
|
|
||||||
source_camera="front",
|
|
||||||
start_ts=100.0,
|
|
||||||
end_ts=200.0,
|
|
||||||
frigate_config=self.frigate_config,
|
|
||||||
config_publisher=self.publisher,
|
|
||||||
replay_manager=self.manager,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertIsInstance(job_id, str)
|
|
||||||
self.assertTrue(self.manager.active)
|
|
||||||
self.assertEqual(self.manager.replay_camera_name, "_replay_front")
|
|
||||||
self.assertEqual(self.manager.source_camera, "front")
|
|
||||||
|
|
||||||
block.set()
|
|
||||||
|
|
||||||
def test_rejects_concurrent_calls(self) -> None:
|
|
||||||
block = threading.Event()
|
|
||||||
|
|
||||||
def slow_helper(cmd, **kwargs):
|
|
||||||
block.wait(timeout=5)
|
|
||||||
return 0, ""
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch(
|
|
||||||
"frigate.jobs.debug_replay.query_recordings",
|
|
||||||
return_value=self.recordings_qs,
|
|
||||||
),
|
|
||||||
patch(
|
|
||||||
"frigate.jobs.debug_replay.run_ffmpeg_with_progress",
|
|
||||||
side_effect=slow_helper,
|
|
||||||
),
|
|
||||||
patch.object(self.manager, "publish_camera"),
|
|
||||||
patch("os.path.exists", return_value=True),
|
|
||||||
patch("os.makedirs"),
|
|
||||||
patch("builtins.open", unittest.mock.mock_open()),
|
|
||||||
):
|
|
||||||
start_debug_replay_job(
|
|
||||||
source_camera="front",
|
|
||||||
start_ts=100.0,
|
|
||||||
end_ts=200.0,
|
|
||||||
frigate_config=self.frigate_config,
|
|
||||||
config_publisher=self.publisher,
|
|
||||||
replay_manager=self.manager,
|
|
||||||
)
|
|
||||||
|
|
||||||
with self.assertRaises(RuntimeError):
|
|
||||||
start_debug_replay_job(
|
|
||||||
source_camera="front",
|
|
||||||
start_ts=100.0,
|
|
||||||
end_ts=200.0,
|
|
||||||
frigate_config=self.frigate_config,
|
|
||||||
config_publisher=self.publisher,
|
|
||||||
replay_manager=self.manager,
|
|
||||||
)
|
|
||||||
|
|
||||||
block.set()
|
|
||||||
|
|
||||||
|
|
||||||
class TestRunnerHappyPath(unittest.TestCase):
|
|
||||||
def setUp(self) -> None:
|
|
||||||
_reset_job_manager()
|
|
||||||
_patch_publisher(self)
|
|
||||||
self.manager = DebugReplayManager()
|
|
||||||
self.frigate_config = MagicMock()
|
|
||||||
self.frigate_config.cameras = {"front": MagicMock()}
|
|
||||||
self.frigate_config.ffmpeg.ffmpeg_path = "/bin/true"
|
|
||||||
self.publisher = MagicMock()
|
|
||||||
|
|
||||||
self.recordings_qs = MagicMock()
|
|
||||||
self.recordings_qs.count.return_value = 1
|
|
||||||
self.recordings_qs.__iter__.return_value = iter([MagicMock(path="/tmp/r1.mp4")])
|
|
||||||
|
|
||||||
def tearDown(self) -> None:
|
|
||||||
runner = get_active_runner()
|
|
||||||
if runner is not None:
|
|
||||||
runner.cancel()
|
|
||||||
runner.join(timeout=2.0)
|
|
||||||
_reset_job_manager()
|
|
||||||
|
|
||||||
def _wait_for(self, predicate, timeout: float = 5.0) -> bool:
|
|
||||||
deadline = time.time() + timeout
|
|
||||||
while time.time() < deadline:
|
|
||||||
if predicate():
|
|
||||||
return True
|
|
||||||
time.sleep(0.02)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def test_progress_callback_updates_job_percent(self) -> None:
|
|
||||||
captured: list[float] = []
|
|
||||||
|
|
||||||
def fake_helper(cmd, *, on_progress=None, **kwargs):
|
|
||||||
on_progress(0.0)
|
|
||||||
on_progress(50.0)
|
|
||||||
on_progress(100.0)
|
|
||||||
return 0, ""
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch(
|
|
||||||
"frigate.jobs.debug_replay.query_recordings",
|
|
||||||
return_value=self.recordings_qs,
|
|
||||||
),
|
|
||||||
patch(
|
|
||||||
"frigate.jobs.debug_replay.run_ffmpeg_with_progress",
|
|
||||||
side_effect=fake_helper,
|
|
||||||
),
|
|
||||||
patch.object(
|
|
||||||
self.manager,
|
|
||||||
"publish_camera",
|
|
||||||
side_effect=lambda *a, **kw: captured.append("published"),
|
|
||||||
),
|
|
||||||
patch("os.path.exists", return_value=True),
|
|
||||||
patch("os.makedirs"),
|
|
||||||
patch("builtins.open", unittest.mock.mock_open()),
|
|
||||||
):
|
|
||||||
start_debug_replay_job(
|
|
||||||
source_camera="front",
|
|
||||||
start_ts=100.0,
|
|
||||||
end_ts=200.0,
|
|
||||||
frigate_config=self.frigate_config,
|
|
||||||
config_publisher=self.publisher,
|
|
||||||
replay_manager=self.manager,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertTrue(
|
|
||||||
self._wait_for(lambda: get_active_runner() is None),
|
|
||||||
"runner did not finish",
|
|
||||||
)
|
|
||||||
|
|
||||||
from frigate.jobs.manager import get_current_job
|
|
||||||
|
|
||||||
job = get_current_job("debug_replay")
|
|
||||||
self.assertIsNotNone(job)
|
|
||||||
self.assertEqual(job.status, JobStatusTypesEnum.success)
|
|
||||||
self.assertEqual(job.progress_percent, 100.0)
|
|
||||||
self.assertEqual(captured, ["published"])
|
|
||||||
# Manager should have been told the session is ready with the clip path.
|
|
||||||
self.assertIsNotNone(self.manager.clip_path)
|
|
||||||
|
|
||||||
|
|
||||||
class TestRunnerFailurePath(unittest.TestCase):
|
|
||||||
def setUp(self) -> None:
|
|
||||||
_reset_job_manager()
|
|
||||||
_patch_publisher(self)
|
|
||||||
self.manager = DebugReplayManager()
|
|
||||||
self.frigate_config = MagicMock()
|
|
||||||
self.frigate_config.cameras = {"front": MagicMock()}
|
|
||||||
self.frigate_config.ffmpeg.ffmpeg_path = "/bin/true"
|
|
||||||
self.publisher = MagicMock()
|
|
||||||
self.recordings_qs = MagicMock()
|
|
||||||
self.recordings_qs.count.return_value = 1
|
|
||||||
self.recordings_qs.__iter__.return_value = iter([MagicMock(path="/tmp/r1.mp4")])
|
|
||||||
|
|
||||||
def tearDown(self) -> None:
|
|
||||||
runner = get_active_runner()
|
|
||||||
if runner is not None:
|
|
||||||
runner.cancel()
|
|
||||||
runner.join(timeout=2.0)
|
|
||||||
_reset_job_manager()
|
|
||||||
|
|
||||||
def _wait_for(self, predicate, timeout: float = 5.0) -> bool:
|
|
||||||
deadline = time.time() + timeout
|
|
||||||
while time.time() < deadline:
|
|
||||||
if predicate():
|
|
||||||
return True
|
|
||||||
time.sleep(0.02)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def test_ffmpeg_failure_marks_job_failed_and_clears_session(self) -> None:
|
|
||||||
def failing_helper(cmd, **kwargs):
|
|
||||||
return 1, "ffmpeg exploded"
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch(
|
|
||||||
"frigate.jobs.debug_replay.query_recordings",
|
|
||||||
return_value=self.recordings_qs,
|
|
||||||
),
|
|
||||||
patch(
|
|
||||||
"frigate.jobs.debug_replay.run_ffmpeg_with_progress",
|
|
||||||
side_effect=failing_helper,
|
|
||||||
),
|
|
||||||
patch("os.path.exists", return_value=True),
|
|
||||||
patch("os.makedirs"),
|
|
||||||
patch("os.remove"),
|
|
||||||
patch("builtins.open", unittest.mock.mock_open()),
|
|
||||||
):
|
|
||||||
start_debug_replay_job(
|
|
||||||
source_camera="front",
|
|
||||||
start_ts=100.0,
|
|
||||||
end_ts=200.0,
|
|
||||||
frigate_config=self.frigate_config,
|
|
||||||
config_publisher=self.publisher,
|
|
||||||
replay_manager=self.manager,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertTrue(
|
|
||||||
self._wait_for(lambda: get_active_runner() is None),
|
|
||||||
"runner did not finish",
|
|
||||||
)
|
|
||||||
|
|
||||||
from frigate.jobs.manager import get_current_job
|
|
||||||
|
|
||||||
job = get_current_job("debug_replay")
|
|
||||||
self.assertIsNotNone(job)
|
|
||||||
self.assertEqual(job.status, JobStatusTypesEnum.failed)
|
|
||||||
self.assertIsNotNone(job.error_message)
|
|
||||||
self.assertIn("ffmpeg", job.error_message.lower())
|
|
||||||
# Session cleared so a new /start is allowed
|
|
||||||
self.assertFalse(self.manager.active)
|
|
||||||
|
|
||||||
|
|
||||||
class TestRunnerCancellation(unittest.TestCase):
|
|
||||||
def setUp(self) -> None:
|
|
||||||
_reset_job_manager()
|
|
||||||
_patch_publisher(self)
|
|
||||||
self.manager = DebugReplayManager()
|
|
||||||
self.frigate_config = MagicMock()
|
|
||||||
self.frigate_config.cameras = {"front": MagicMock()}
|
|
||||||
self.frigate_config.ffmpeg.ffmpeg_path = "/bin/true"
|
|
||||||
self.publisher = MagicMock()
|
|
||||||
self.recordings_qs = MagicMock()
|
|
||||||
self.recordings_qs.count.return_value = 1
|
|
||||||
self.recordings_qs.__iter__.return_value = iter([MagicMock(path="/tmp/r1.mp4")])
|
|
||||||
|
|
||||||
def tearDown(self) -> None:
|
|
||||||
runner = get_active_runner()
|
|
||||||
if runner is not None:
|
|
||||||
runner.cancel()
|
|
||||||
runner.join(timeout=2.0)
|
|
||||||
_reset_job_manager()
|
|
||||||
|
|
||||||
def _wait_for(self, predicate, timeout: float = 5.0) -> bool:
|
|
||||||
deadline = time.time() + timeout
|
|
||||||
while time.time() < deadline:
|
|
||||||
if predicate():
|
|
||||||
return True
|
|
||||||
time.sleep(0.02)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def test_cancel_terminates_ffmpeg_and_marks_cancelled(self) -> None:
|
|
||||||
terminated = threading.Event()
|
|
||||||
fake_proc = MagicMock()
|
|
||||||
fake_proc.terminate = MagicMock(side_effect=lambda: terminated.set())
|
|
||||||
|
|
||||||
def fake_helper(cmd, *, process_started=None, **kwargs):
|
|
||||||
if process_started is not None:
|
|
||||||
process_started(fake_proc)
|
|
||||||
terminated.wait(timeout=5)
|
|
||||||
return -15, "killed"
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch(
|
|
||||||
"frigate.jobs.debug_replay.query_recordings",
|
|
||||||
return_value=self.recordings_qs,
|
|
||||||
),
|
|
||||||
patch(
|
|
||||||
"frigate.jobs.debug_replay.run_ffmpeg_with_progress",
|
|
||||||
side_effect=fake_helper,
|
|
||||||
),
|
|
||||||
patch("os.path.exists", return_value=True),
|
|
||||||
patch("os.makedirs"),
|
|
||||||
patch("os.remove"),
|
|
||||||
patch("builtins.open", unittest.mock.mock_open()),
|
|
||||||
):
|
|
||||||
start_debug_replay_job(
|
|
||||||
source_camera="front",
|
|
||||||
start_ts=100.0,
|
|
||||||
end_ts=200.0,
|
|
||||||
frigate_config=self.frigate_config,
|
|
||||||
config_publisher=self.publisher,
|
|
||||||
replay_manager=self.manager,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Wait for the runner to register the active process.
|
|
||||||
self.assertTrue(
|
|
||||||
self._wait_for(
|
|
||||||
lambda: (
|
|
||||||
get_active_runner() is not None
|
|
||||||
and get_active_runner()._active_process is fake_proc
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
cancelled = cancel_debug_replay_job()
|
|
||||||
self.assertTrue(cancelled)
|
|
||||||
self.assertTrue(fake_proc.terminate.called)
|
|
||||||
|
|
||||||
self.assertTrue(
|
|
||||||
self._wait_for(lambda: get_active_runner() is None),
|
|
||||||
"runner did not finish",
|
|
||||||
)
|
|
||||||
|
|
||||||
from frigate.jobs.manager import get_current_job
|
|
||||||
|
|
||||||
job = get_current_job("debug_replay")
|
|
||||||
self.assertEqual(job.status, JobStatusTypesEnum.cancelled)
|
|
||||||
# Runner must not clear the manager session on cancellation —
|
|
||||||
# that belongs to the caller of cancel_debug_replay_job (stop()).
|
|
||||||
# If the runner cleared it, stop() would log "no active session"
|
|
||||||
# and skip its cleanup_db / cleanup_files calls.
|
|
||||||
self.assertTrue(self.manager.active)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@ -14,7 +14,6 @@ from frigate.jobs.export import (
|
|||||||
)
|
)
|
||||||
from frigate.record.export import PlaybackSourceEnum, RecordingExporter
|
from frigate.record.export import PlaybackSourceEnum, RecordingExporter
|
||||||
from frigate.types import JobStatusTypesEnum
|
from frigate.types import JobStatusTypesEnum
|
||||||
from frigate.util.ffmpeg import inject_progress_flags
|
|
||||||
|
|
||||||
|
|
||||||
def _make_exporter(
|
def _make_exporter(
|
||||||
@ -119,9 +118,10 @@ class TestExpectedOutputDuration(unittest.TestCase):
|
|||||||
|
|
||||||
class TestProgressFlagInjection(unittest.TestCase):
|
class TestProgressFlagInjection(unittest.TestCase):
|
||||||
def test_inserts_before_output_path(self) -> None:
|
def test_inserts_before_output_path(self) -> None:
|
||||||
|
exporter = _make_exporter()
|
||||||
cmd = ["ffmpeg", "-i", "input.m3u8", "-c", "copy", "/tmp/output.mp4"]
|
cmd = ["ffmpeg", "-i", "input.m3u8", "-c", "copy", "/tmp/output.mp4"]
|
||||||
|
|
||||||
result = inject_progress_flags(cmd)
|
result = exporter._inject_progress_flags(cmd)
|
||||||
|
|
||||||
assert result == [
|
assert result == [
|
||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
@ -136,7 +136,8 @@ class TestProgressFlagInjection(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
|
|
||||||
def test_handles_empty_cmd(self) -> None:
|
def test_handles_empty_cmd(self) -> None:
|
||||||
assert inject_progress_flags([]) == []
|
exporter = _make_exporter()
|
||||||
|
assert exporter._inject_progress_flags([]) == []
|
||||||
|
|
||||||
|
|
||||||
class TestFfmpegProgressParsing(unittest.TestCase):
|
class TestFfmpegProgressParsing(unittest.TestCase):
|
||||||
@ -166,7 +167,7 @@ class TestFfmpegProgressParsing(unittest.TestCase):
|
|||||||
fake_proc.returncode = 0
|
fake_proc.returncode = 0
|
||||||
fake_proc.wait = MagicMock(return_value=0)
|
fake_proc.wait = MagicMock(return_value=0)
|
||||||
|
|
||||||
with patch("frigate.util.ffmpeg.sp.Popen", return_value=fake_proc):
|
with patch("frigate.record.export.sp.Popen", return_value=fake_proc):
|
||||||
returncode, _stderr = exporter._run_ffmpeg_with_progress(
|
returncode, _stderr = exporter._run_ffmpeg_with_progress(
|
||||||
["ffmpeg", "-i", "x.m3u8", "/tmp/out.mp4"], "playlist", step="encoding"
|
["ffmpeg", "-i", "x.m3u8", "/tmp/out.mp4"], "playlist", step="encoding"
|
||||||
)
|
)
|
||||||
@ -498,56 +499,5 @@ class TestSchedulesCleanup(unittest.TestCase):
|
|||||||
assert job.id not in manager.jobs
|
assert job.id not in manager.jobs
|
||||||
|
|
||||||
|
|
||||||
class TestChapterMetadataInProgressReview(unittest.TestCase):
|
|
||||||
"""Regression: in-progress review segments have end_time=NULL until the
|
|
||||||
activity closes. The chapter builder must clamp the chapter end to the
|
|
||||||
last recorded second instead of crashing on float(None)."""
|
|
||||||
|
|
||||||
def _fake_select_returning(self, rows: list) -> MagicMock:
|
|
||||||
mock_query = MagicMock()
|
|
||||||
mock_query.where.return_value = mock_query
|
|
||||||
mock_query.order_by.return_value = mock_query
|
|
||||||
mock_query.iterator.return_value = iter(rows)
|
|
||||||
return mock_query
|
|
||||||
|
|
||||||
def test_in_progress_review_does_not_crash_and_clamps_to_last_recording(
|
|
||||||
self,
|
|
||||||
) -> None:
|
|
||||||
exporter = _make_exporter(end_minus_start=200)
|
|
||||||
# Recordings cover [1000, 1150]; export window is [1000, 1200] so
|
|
||||||
# the last recorded second is 1150 (a 50s gap at the tail).
|
|
||||||
recordings = [
|
|
||||||
MagicMock(start_time=1000.0, end_time=1150.0),
|
|
||||||
]
|
|
||||||
in_progress = MagicMock(
|
|
||||||
start_time=1100.0,
|
|
||||||
end_time=None,
|
|
||||||
severity="alert",
|
|
||||||
data={"objects": ["person"]},
|
|
||||||
)
|
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
|
||||||
chapter_path = os.path.join(tmpdir, "chapters.txt")
|
|
||||||
exporter._chapter_metadata_path = lambda: chapter_path # type: ignore[method-assign]
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"frigate.record.export.ReviewSegment.select",
|
|
||||||
return_value=self._fake_select_returning([in_progress]),
|
|
||||||
):
|
|
||||||
result = exporter._build_chapter_metadata_file(recordings)
|
|
||||||
|
|
||||||
assert result == chapter_path
|
|
||||||
with open(chapter_path) as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
# Output time is windows[-1][1] - windows[-1][0] = 150s.
|
|
||||||
# Review starts at wall=1100, output offset = 100s -> 100000ms.
|
|
||||||
# Clamped end = last_recorded_end (1150) -> output offset = 150s -> 150000ms.
|
|
||||||
assert "[CHAPTER]" in content
|
|
||||||
assert "START=100000" in content
|
|
||||||
assert "END=150000" in content
|
|
||||||
assert "title=Alert: person" in content
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@ -1,111 +0,0 @@
|
|||||||
"""Tests for the shared ffmpeg progress helper."""
|
|
||||||
|
|
||||||
import unittest
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
from frigate.util.ffmpeg import inject_progress_flags, run_ffmpeg_with_progress
|
|
||||||
|
|
||||||
|
|
||||||
class TestInjectProgressFlags(unittest.TestCase):
|
|
||||||
def test_inserts_flags_before_output_path(self):
|
|
||||||
cmd = ["ffmpeg", "-i", "in.mp4", "-c", "copy", "out.mp4"]
|
|
||||||
result = inject_progress_flags(cmd)
|
|
||||||
self.assertEqual(
|
|
||||||
result,
|
|
||||||
[
|
|
||||||
"ffmpeg",
|
|
||||||
"-i",
|
|
||||||
"in.mp4",
|
|
||||||
"-c",
|
|
||||||
"copy",
|
|
||||||
"-progress",
|
|
||||||
"pipe:2",
|
|
||||||
"-nostats",
|
|
||||||
"out.mp4",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_empty_cmd_returns_empty(self):
|
|
||||||
self.assertEqual(inject_progress_flags([]), [])
|
|
||||||
|
|
||||||
|
|
||||||
class TestRunFfmpegWithProgress(unittest.TestCase):
|
|
||||||
def _make_fake_proc(self, stderr_lines, returncode=0):
|
|
||||||
proc = MagicMock()
|
|
||||||
proc.stderr = iter(stderr_lines)
|
|
||||||
proc.stdin = MagicMock()
|
|
||||||
proc.returncode = returncode
|
|
||||||
proc.wait = MagicMock()
|
|
||||||
return proc
|
|
||||||
|
|
||||||
def test_emits_percent_from_out_time_us_lines(self):
|
|
||||||
captured: list[float] = []
|
|
||||||
|
|
||||||
def on_progress(percent: float) -> None:
|
|
||||||
captured.append(percent)
|
|
||||||
|
|
||||||
stderr_lines = [
|
|
||||||
"out_time_us=1000000\n",
|
|
||||||
"out_time_us=5000000\n",
|
|
||||||
"progress=end\n",
|
|
||||||
]
|
|
||||||
proc = self._make_fake_proc(stderr_lines)
|
|
||||||
proc.stderr = MagicMock()
|
|
||||||
proc.stderr.__iter__ = lambda self: iter(stderr_lines)
|
|
||||||
proc.stderr.read = MagicMock(return_value="")
|
|
||||||
|
|
||||||
with patch("subprocess.Popen", return_value=proc):
|
|
||||||
returncode, _stderr = run_ffmpeg_with_progress(
|
|
||||||
["ffmpeg", "-i", "in", "out"],
|
|
||||||
expected_duration_seconds=10.0,
|
|
||||||
on_progress=on_progress,
|
|
||||||
use_low_priority=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(returncode, 0)
|
|
||||||
self.assertEqual(len(captured), 4) # initial 0.0 + two parsed + final 100.0
|
|
||||||
self.assertAlmostEqual(captured[0], 0.0)
|
|
||||||
self.assertAlmostEqual(captured[1], 10.0)
|
|
||||||
self.assertAlmostEqual(captured[2], 50.0)
|
|
||||||
self.assertAlmostEqual(captured[3], 100.0)
|
|
||||||
|
|
||||||
def test_passes_started_process_to_callback(self):
|
|
||||||
proc = self._make_fake_proc([])
|
|
||||||
proc.stderr = MagicMock()
|
|
||||||
proc.stderr.__iter__ = lambda self: iter([])
|
|
||||||
proc.stderr.read = MagicMock(return_value="")
|
|
||||||
|
|
||||||
seen: list = []
|
|
||||||
|
|
||||||
with patch("subprocess.Popen", return_value=proc):
|
|
||||||
run_ffmpeg_with_progress(
|
|
||||||
["ffmpeg", "out"],
|
|
||||||
expected_duration_seconds=1.0,
|
|
||||||
process_started=lambda p: seen.append(p),
|
|
||||||
use_low_priority=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(seen, [proc])
|
|
||||||
|
|
||||||
def test_clamps_percent_to_0_100(self):
|
|
||||||
captured: list[float] = []
|
|
||||||
|
|
||||||
def on_progress(percent: float) -> None:
|
|
||||||
captured.append(percent)
|
|
||||||
|
|
||||||
stderr_lines = ["out_time_us=999999999999\n"]
|
|
||||||
proc = self._make_fake_proc(stderr_lines)
|
|
||||||
proc.stderr = MagicMock()
|
|
||||||
proc.stderr.__iter__ = lambda self: iter(stderr_lines)
|
|
||||||
proc.stderr.read = MagicMock(return_value="")
|
|
||||||
|
|
||||||
with patch("subprocess.Popen", return_value=proc):
|
|
||||||
run_ffmpeg_with_progress(
|
|
||||||
["ffmpeg", "out"],
|
|
||||||
expected_duration_seconds=10.0,
|
|
||||||
on_progress=on_progress,
|
|
||||||
use_low_priority=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
# initial 0.0 then a clamped reading
|
|
||||||
self.assertEqual(captured[-1], 100.0)
|
|
||||||
@ -2,9 +2,8 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import subprocess as sp
|
import subprocess as sp
|
||||||
from typing import Any, Callable, Optional
|
from typing import Any
|
||||||
|
|
||||||
from frigate.const import PROCESS_PRIORITY_LOW
|
|
||||||
from frigate.log import LogPipe
|
from frigate.log import LogPipe
|
||||||
|
|
||||||
|
|
||||||
@ -47,124 +46,3 @@ def start_or_restart_ffmpeg(
|
|||||||
start_new_session=True,
|
start_new_session=True,
|
||||||
)
|
)
|
||||||
return process
|
return process
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def inject_progress_flags(cmd: list[str]) -> list[str]:
|
|
||||||
"""Insert `-progress pipe:2 -nostats` immediately before the output path.
|
|
||||||
|
|
||||||
`-progress pipe:2` writes structured key=value lines to stderr;
|
|
||||||
`-nostats` suppresses the noisy default stats output. The output path
|
|
||||||
is conventionally the last token in an FFmpeg argv.
|
|
||||||
"""
|
|
||||||
if not cmd:
|
|
||||||
return cmd
|
|
||||||
return cmd[:-1] + ["-progress", "pipe:2", "-nostats", cmd[-1]]
|
|
||||||
|
|
||||||
|
|
||||||
def run_ffmpeg_with_progress(
|
|
||||||
cmd: list[str],
|
|
||||||
*,
|
|
||||||
expected_duration_seconds: float,
|
|
||||||
on_progress: Optional[Callable[[float], None]] = None,
|
|
||||||
stdin_payload: Optional[str] = None,
|
|
||||||
process_started: Optional[Callable[[sp.Popen], None]] = None,
|
|
||||||
use_low_priority: bool = True,
|
|
||||||
) -> tuple[int, str]:
|
|
||||||
"""Run an ffmpeg command, streaming progress via `-progress pipe:2`.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
cmd: ffmpeg argv. Output path must be the last token.
|
|
||||||
expected_duration_seconds: Duration of the expected output clip in
|
|
||||||
seconds. Used to convert ffmpeg's `out_time_us` into a percent.
|
|
||||||
on_progress: Optional callback invoked with a percent in [0, 100].
|
|
||||||
Called once with 0.0 at start, again on each `out_time_us=`
|
|
||||||
stderr line, and once with 100.0 on `progress=end`.
|
|
||||||
stdin_payload: Optional string written to ffmpeg stdin (used by
|
|
||||||
export for concat playlists).
|
|
||||||
process_started: Optional callback invoked with the live `Popen`
|
|
||||||
once spawned — lets callers store the ref for cancellation.
|
|
||||||
use_low_priority: When True, prepend `nice -n PROCESS_PRIORITY_LOW`
|
|
||||||
so concat doesn't starve detection.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of `(returncode, captured_stderr)`. Stdout is left attached
|
|
||||||
to the parent process to avoid buffer-full deadlocks.
|
|
||||||
"""
|
|
||||||
full_cmd = inject_progress_flags(cmd)
|
|
||||||
if use_low_priority:
|
|
||||||
full_cmd = ["nice", "-n", str(PROCESS_PRIORITY_LOW)] + full_cmd
|
|
||||||
|
|
||||||
def emit(percent: float) -> None:
|
|
||||||
if on_progress is None:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
on_progress(max(0.0, min(100.0, percent)))
|
|
||||||
except Exception:
|
|
||||||
logger.exception("FFmpeg progress callback failed")
|
|
||||||
|
|
||||||
emit(0.0)
|
|
||||||
|
|
||||||
proc = sp.Popen(
|
|
||||||
full_cmd,
|
|
||||||
stdin=sp.PIPE if stdin_payload is not None else None,
|
|
||||||
stderr=sp.PIPE,
|
|
||||||
text=True,
|
|
||||||
encoding="ascii",
|
|
||||||
errors="replace",
|
|
||||||
)
|
|
||||||
if process_started is not None:
|
|
||||||
try:
|
|
||||||
process_started(proc)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("FFmpeg process_started callback failed")
|
|
||||||
|
|
||||||
if stdin_payload is not None and proc.stdin is not None:
|
|
||||||
try:
|
|
||||||
proc.stdin.write(stdin_payload)
|
|
||||||
except (BrokenPipeError, OSError):
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
proc.stdin.close()
|
|
||||||
except (BrokenPipeError, OSError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
captured: list[str] = []
|
|
||||||
if proc.stderr is not None:
|
|
||||||
try:
|
|
||||||
for raw_line in proc.stderr:
|
|
||||||
captured.append(raw_line)
|
|
||||||
line = raw_line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
if line.startswith("out_time_us="):
|
|
||||||
if expected_duration_seconds <= 0:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
out_time_us = int(line.split("=", 1)[1])
|
|
||||||
except (ValueError, IndexError):
|
|
||||||
continue
|
|
||||||
if out_time_us < 0:
|
|
||||||
continue
|
|
||||||
out_seconds = out_time_us / 1_000_000.0
|
|
||||||
emit((out_seconds / expected_duration_seconds) * 100.0)
|
|
||||||
elif line == "progress=end":
|
|
||||||
emit(100.0)
|
|
||||||
break
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed reading FFmpeg progress stream")
|
|
||||||
|
|
||||||
proc.wait()
|
|
||||||
|
|
||||||
if proc.stderr is not None:
|
|
||||||
try:
|
|
||||||
remaining = proc.stderr.read()
|
|
||||||
if remaining:
|
|
||||||
captured.append(remaining)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return proc.returncode or 0, "".join(captured)
|
|
||||||
|
|||||||
@ -31,7 +31,7 @@ test.describe("Replay — no active session @medium", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
frigateApp.page.getByRole("heading", {
|
frigateApp.page.getByRole("heading", {
|
||||||
level: 2,
|
level: 2,
|
||||||
name: /No Active Debug Replay Session/i,
|
name: /No Active Replay Session/i,
|
||||||
}),
|
}),
|
||||||
).toBeVisible({ timeout: 10_000 });
|
).toBeVisible({ timeout: 10_000 });
|
||||||
const goButton = frigateApp.page.getByRole("button", {
|
const goButton = frigateApp.page.getByRole("button", {
|
||||||
@ -48,7 +48,7 @@ test.describe("Replay — no active session @medium", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
frigateApp.page.getByRole("heading", {
|
frigateApp.page.getByRole("heading", {
|
||||||
level: 2,
|
level: 2,
|
||||||
name: /No Active Debug Replay Session/i,
|
name: /No Active Replay Session/i,
|
||||||
}),
|
}),
|
||||||
).toBeVisible({ timeout: 10_000 });
|
).toBeVisible({ timeout: 10_000 });
|
||||||
await frigateApp.page
|
await frigateApp.page
|
||||||
@ -297,7 +297,7 @@ test.describe("Replay — mobile @medium @mobile", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
frigateApp.page.getByRole("heading", {
|
frigateApp.page.getByRole("heading", {
|
||||||
level: 2,
|
level: 2,
|
||||||
name: /No Active Debug Replay Session/i,
|
name: /No Active Replay Session/i,
|
||||||
}),
|
}),
|
||||||
).toBeVisible({ timeout: 10_000 });
|
).toBeVisible({ timeout: 10_000 });
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -1,503 +1,4 @@
|
|||||||
{
|
{
|
||||||
"speech": "Govor",
|
"speech": "Govor",
|
||||||
"babbling": "Babavljanje",
|
"babbling": "Mrmljanje"
|
||||||
"bicycle": "Kolo",
|
|
||||||
"yell": "Vik",
|
|
||||||
"bellow": "Bubanj",
|
|
||||||
"whoop": "Vrisak",
|
|
||||||
"whispering": "Šaputanje",
|
|
||||||
"laughter": "Smijeh",
|
|
||||||
"snicker": "Prijem",
|
|
||||||
"crying": "Plač",
|
|
||||||
"sigh": "Usklik",
|
|
||||||
"singing": "Pjevanje",
|
|
||||||
"choir": "Hors",
|
|
||||||
"yodeling": "Jodelanje",
|
|
||||||
"chant": "Pjevanje",
|
|
||||||
"mantra": "Mantra",
|
|
||||||
"child_singing": "Dječje pjevanje",
|
|
||||||
"synthetic_singing": "Sintetičko pjevanje",
|
|
||||||
"rapping": "Rap",
|
|
||||||
"humming": "Hum",
|
|
||||||
"groan": "Grokot",
|
|
||||||
"grunt": "Grunt",
|
|
||||||
"whistling": "Pucanje",
|
|
||||||
"breathing": "Disanje",
|
|
||||||
"wheeze": "Pijuckanje",
|
|
||||||
"snoring": "Kicanje",
|
|
||||||
"gasp": "Udah",
|
|
||||||
"pant": "Pantanje",
|
|
||||||
"snort": "Snortanje",
|
|
||||||
"cough": "Kašljanje",
|
|
||||||
"throat_clearing": "Očišćavanje grla",
|
|
||||||
"sneeze": "Prašanje",
|
|
||||||
"sniff": "Njuhanje",
|
|
||||||
"run": "Trčanje",
|
|
||||||
"shuffle": "Prelazak",
|
|
||||||
"footsteps": "Koraci",
|
|
||||||
"chewing": "Zubljanje",
|
|
||||||
"biting": "Gubitak",
|
|
||||||
"gargling": "Peranje grla",
|
|
||||||
"stomach_rumble": "Grušenje",
|
|
||||||
"burping": "Puknutje",
|
|
||||||
"hiccup": "Kikot",
|
|
||||||
"fart": "Pucanje",
|
|
||||||
"hands": "Ruke",
|
|
||||||
"finger_snapping": "Prašanje prstiju",
|
|
||||||
"clapping": "Ključanje",
|
|
||||||
"heartbeat": "Taktilno",
|
|
||||||
"heart_murmur": "Šum srca",
|
|
||||||
"cheering": "Pozdrav",
|
|
||||||
"applause": "Pozdravljati",
|
|
||||||
"chatter": "Šaputanje",
|
|
||||||
"crowd": "Gomila",
|
|
||||||
"children_playing": "Dječja igra",
|
|
||||||
"animal": "Životinja",
|
|
||||||
"pets": "Hrana",
|
|
||||||
"dog": "Pas",
|
|
||||||
"bark": "Glavu",
|
|
||||||
"yip": "Jauk",
|
|
||||||
"howl": "Vijuk",
|
|
||||||
"bow_wow": "Vau vau",
|
|
||||||
"growling": "Gručenje",
|
|
||||||
"whimper_dog": "Pijuckanje psa",
|
|
||||||
"cat": "Mačka",
|
|
||||||
"purr": "Mrmor",
|
|
||||||
"meow": "Mjau",
|
|
||||||
"hiss": "Zujanje",
|
|
||||||
"caterwaul": "Krik",
|
|
||||||
"livestock": "Stoke",
|
|
||||||
"horse": "Konj",
|
|
||||||
"clip_clop": "Klik klok",
|
|
||||||
"neigh": "Kijanje",
|
|
||||||
"cattle": "Stoke",
|
|
||||||
"moo": "Muu",
|
|
||||||
"cowbell": "Kovčeg",
|
|
||||||
"pig": "Svinja",
|
|
||||||
"oink": "Oink",
|
|
||||||
"goat": "Koza",
|
|
||||||
"bleat": "Blejkanje",
|
|
||||||
"sheep": "Ovca",
|
|
||||||
"fowl": "Ptica",
|
|
||||||
"chicken": "Pilica",
|
|
||||||
"cluck": "Kukanje",
|
|
||||||
"cock_a_doodle_doo": "Kukavica",
|
|
||||||
"turkey": "Gusa",
|
|
||||||
"gobble": "Gubljanje",
|
|
||||||
"duck": "Kuja",
|
|
||||||
"quack": "Kvaka",
|
|
||||||
"goose": "Guska",
|
|
||||||
"honk": "Honk",
|
|
||||||
"wild_animals": "Divlja životinja",
|
|
||||||
"roaring_cats": "Vrišćeći mački",
|
|
||||||
"roar": "Vrištanje",
|
|
||||||
"bird": "Ptica",
|
|
||||||
"chirp": "Pijuckanje",
|
|
||||||
"squawk": "Krik",
|
|
||||||
"pigeon": "Papiga",
|
|
||||||
"coo": "Kukanje",
|
|
||||||
"crow": "Vran",
|
|
||||||
"caw": "Vranje",
|
|
||||||
"owl": "Kukavica",
|
|
||||||
"hoot": "Kukavica",
|
|
||||||
"flapping_wings": "Flapping Wings",
|
|
||||||
"dogs": "Dogs",
|
|
||||||
"rats": "Rats",
|
|
||||||
"mouse": "Mouse",
|
|
||||||
"patter": "Patter",
|
|
||||||
"insect": "Insect",
|
|
||||||
"cricket": "Cricket",
|
|
||||||
"mosquito": "Mosquito",
|
|
||||||
"fly": "Fly",
|
|
||||||
"buzz": "Buzz",
|
|
||||||
"frog": "Frog",
|
|
||||||
"croak": "Croak",
|
|
||||||
"snake": "Snake",
|
|
||||||
"rattle": "Rattle",
|
|
||||||
"whale_vocalization": "Whale Vocalization",
|
|
||||||
"music": "Music",
|
|
||||||
"musical_instrument": "Musical Instrument",
|
|
||||||
"plucked_string_instrument": "Plucked String Instrument",
|
|
||||||
"guitar": "Guitar",
|
|
||||||
"electric_guitar": "Elektricna Gitara",
|
|
||||||
"bass_guitar": "Bass Guitar",
|
|
||||||
"acoustic_guitar": "Acoustic Guitar",
|
|
||||||
"steel_guitar": "Steel Guitar",
|
|
||||||
"tapping": "Tapping",
|
|
||||||
"strum": "Strum",
|
|
||||||
"banjo": "Banjo",
|
|
||||||
"sitar": "Sitar",
|
|
||||||
"mandolin": "Mandolin",
|
|
||||||
"zither": "Zither",
|
|
||||||
"ukulele": "Ukulele",
|
|
||||||
"keyboard": "Klaviatura",
|
|
||||||
"piano": "Klavir",
|
|
||||||
"electric_piano": "Električni piano",
|
|
||||||
"organ": "Organ",
|
|
||||||
"electronic_organ": "Elektronski organ",
|
|
||||||
"hammond_organ": "Hammond organ",
|
|
||||||
"synthesizer": "Sintetizator",
|
|
||||||
"sampler": "Sampler",
|
|
||||||
"harpsichord": "Harfura",
|
|
||||||
"percussion": "Percuzija",
|
|
||||||
"drum_kit": "Set bubnjeva",
|
|
||||||
"drum_machine": "Mašina za bubnjeve",
|
|
||||||
"drum": "Bubanj",
|
|
||||||
"snare_drum": "Bubanj sa zavojima",
|
|
||||||
"rimshot": "Rimshot",
|
|
||||||
"drum_roll": "Bubanj za roliranje",
|
|
||||||
"bass_drum": "Bubanj za bas",
|
|
||||||
"timpani": "Timpani",
|
|
||||||
"tabla": "Tabla",
|
|
||||||
"cymbal": "Cimbale",
|
|
||||||
"hi_hat": "Hi-Hat",
|
|
||||||
"wood_block": "Drveni blok",
|
|
||||||
"tambourine": "Tamburina",
|
|
||||||
"maraca": "Maraka",
|
|
||||||
"gong": "Gong",
|
|
||||||
"tubular_bells": "Cijevasti zvoni",
|
|
||||||
"mallet_percussion": "Percusija s mljevima",
|
|
||||||
"marimba": "Marimba",
|
|
||||||
"glockenspiel": "Glockenspiel",
|
|
||||||
"vibraphone": "Vibrafon",
|
|
||||||
"steelpan": "Stelpan",
|
|
||||||
"orchestra": "Orkestar",
|
|
||||||
"brass_instrument": "Bronski instrument",
|
|
||||||
"french_horn": "Francuski rog",
|
|
||||||
"trumpet": "Truba",
|
|
||||||
"trombone": "Trombon",
|
|
||||||
"bowed_string_instrument": "Užadno strunski instrument",
|
|
||||||
"string_section": "Strunski sekcija",
|
|
||||||
"violin": "Violina",
|
|
||||||
"pizzicato": "Pizzicato",
|
|
||||||
"cello": "Celula",
|
|
||||||
"double_bass": "Dvostruki bas",
|
|
||||||
"wind_instrument": "Vjetreni instrument",
|
|
||||||
"flute": "Flauta",
|
|
||||||
"saxophone": "Saksafon",
|
|
||||||
"clarinet": "Klarinet",
|
|
||||||
"harp": "Harfa",
|
|
||||||
"bell": "Zvono",
|
|
||||||
"church_bell": "Crkveno zvono",
|
|
||||||
"jingle_bell": "Zvono za igračke",
|
|
||||||
"bicycle_bell": "Zvono za bicikl",
|
|
||||||
"tuning_fork": "Zvučnik",
|
|
||||||
"chime": "Zvono",
|
|
||||||
"wind_chime": "Vjetrenjac",
|
|
||||||
"harmonica": "Harmonika",
|
|
||||||
"accordion": "Akkordon",
|
|
||||||
"bagpipes": "Bogovina",
|
|
||||||
"didgeridoo": "Didgeridoo",
|
|
||||||
"theremin": "Teremin",
|
|
||||||
"singing_bowl": "Pjevni čaša",
|
|
||||||
"scratching": "Scratching",
|
|
||||||
"pop_music": "Pop muzika",
|
|
||||||
"hip_hop_music": "Hip-Hop muzika",
|
|
||||||
"beatboxing": "Beatboxing",
|
|
||||||
"rock_music": "Rock muzika",
|
|
||||||
"heavy_metal": "Heavy metal",
|
|
||||||
"punk_rock": "Punk rock",
|
|
||||||
"grunge": "Grunge",
|
|
||||||
"progressive_rock": "Progressivni rock",
|
|
||||||
"rock_and_roll": "Rock and roll",
|
|
||||||
"psychedelic_rock": "Psihederički rock",
|
|
||||||
"rhythm_and_blues": "Ritam i blues",
|
|
||||||
"soul_music": "Soul glazba",
|
|
||||||
"reggae": "Reggae",
|
|
||||||
"country": "Country",
|
|
||||||
"swing_music": "Swing glazba",
|
|
||||||
"bluegrass": "Bluegrass",
|
|
||||||
"funk": "Funk",
|
|
||||||
"folk_music": "Folklorno glazba",
|
|
||||||
"middle_eastern_music": "Glazba Bliskog istoka",
|
|
||||||
"jazz": "Jazz",
|
|
||||||
"disco": "Disco",
|
|
||||||
"classical_music": "Klasična glazba",
|
|
||||||
"opera": "Opera",
|
|
||||||
"electronic_music": "Elektronska glazba",
|
|
||||||
"house_music": "House glazba",
|
|
||||||
"techno": "Techno",
|
|
||||||
"dubstep": "Dubstep",
|
|
||||||
"drum_and_bass": "Drum i bass",
|
|
||||||
"electronica": "Elektronika",
|
|
||||||
"electronic_dance_music": "Elektronska plesna glazba",
|
|
||||||
"ambient_music": "Ambient glazba",
|
|
||||||
"trance_music": "Trance glazba",
|
|
||||||
"music_of_latin_america": "Glazba Latinske Amerike",
|
|
||||||
"salsa_music": "Salsa glazba",
|
|
||||||
"flamenco": "Flamenco",
|
|
||||||
"blues": "Blues",
|
|
||||||
"music_for_children": "Muzika za djecu",
|
|
||||||
"new-age_music": "Muzika novog doba",
|
|
||||||
"vocal_music": "Vokalna muzika",
|
|
||||||
"a_capella": "A Capella",
|
|
||||||
"music_of_africa": "Afrička muzika",
|
|
||||||
"afrobeat": "Afrobeat",
|
|
||||||
"christian_music": "Kršćanska muzika",
|
|
||||||
"gospel_music": "Gospel muzika",
|
|
||||||
"music_of_asia": "Azijatska muzika",
|
|
||||||
"carnatic_music": "Karnatička muzika",
|
|
||||||
"music_of_bollywood": "Bollywood muzika",
|
|
||||||
"ska": "Ska",
|
|
||||||
"traditional_music": "Tradicionalna muzika",
|
|
||||||
"independent_music": "Nezavisna muzika",
|
|
||||||
"song": "Pjesma",
|
|
||||||
"background_music": "Pozadinska muzika",
|
|
||||||
"theme_music": "Tema muzika",
|
|
||||||
"jingle": "Jingle",
|
|
||||||
"soundtrack_music": "Soundtrack muzika",
|
|
||||||
"lullaby": "Pjesma za uspavanje",
|
|
||||||
"video_game_music": "Muzika za video igre",
|
|
||||||
"christmas_music": "Božićna muzika",
|
|
||||||
"dance_music": "Dance muzika",
|
|
||||||
"wedding_music": "Venčanska glazba",
|
|
||||||
"happy_music": "Sretna glazba",
|
|
||||||
"sad_music": "Tužna glazba",
|
|
||||||
"tender_music": "Tenderna glazba",
|
|
||||||
"exciting_music": "Uzbudljiva glazba",
|
|
||||||
"angry_music": "Zlobna glazba",
|
|
||||||
"scary_music": "Strašna glazba",
|
|
||||||
"wind": "Vjetar",
|
|
||||||
"rustling_leaves": "Šum listova",
|
|
||||||
"wind_noise": "Šum vjetra",
|
|
||||||
"thunderstorm": "Grmljavina",
|
|
||||||
"thunder": "Grmljavac",
|
|
||||||
"water": "Voda",
|
|
||||||
"rain": "Kisa",
|
|
||||||
"raindrop": "Kap kise",
|
|
||||||
"rain_on_surface": "Kisa na površini",
|
|
||||||
"stream": "Tok",
|
|
||||||
"waterfall": "Padina",
|
|
||||||
"ocean": "Okean",
|
|
||||||
"waves": "Valovi",
|
|
||||||
"steam": "Par",
|
|
||||||
"gurgling": "Gurkanje",
|
|
||||||
"fire": "Vatra",
|
|
||||||
"crackle": "Krik",
|
|
||||||
"vehicle": "Vozilo",
|
|
||||||
"boat": "Brod",
|
|
||||||
"sailboat": "Jedrilica",
|
|
||||||
"rowboat": "Čamac",
|
|
||||||
"motorboat": "Motorni čamac",
|
|
||||||
"ship": "Brod",
|
|
||||||
"motor_vehicle": "Motorno vozilo",
|
|
||||||
"car": "Automobil",
|
|
||||||
"toot": "Zvuk klaksona",
|
|
||||||
"car_alarm": "Automobilski alarm",
|
|
||||||
"power_windows": "Električna prozora",
|
|
||||||
"skidding": "Klizanje",
|
|
||||||
"tire_squeal": "Krik kotača",
|
|
||||||
"car_passing_by": "Automobil prolazi",
|
|
||||||
"race_car": "Racing automobil",
|
|
||||||
"truck": "Kamion",
|
|
||||||
"air_brake": "Vazdušni kočnici",
|
|
||||||
"air_horn": "Vazdušni signal",
|
|
||||||
"reversing_beeps": "Zvukovi za odlazak unazad",
|
|
||||||
"ice_cream_truck": "Kamion za sladoled",
|
|
||||||
"bus": "Autobus",
|
|
||||||
"emergency_vehicle": "Hitni vozilo",
|
|
||||||
"police_car": "Policijski automobil",
|
|
||||||
"ambulance": "Ambulansa",
|
|
||||||
"fire_engine": "Pogonski automobil",
|
|
||||||
"motorcycle": "Motocikl",
|
|
||||||
"traffic_noise": "Prometni šum",
|
|
||||||
"rail_transport": "Željeznički transport",
|
|
||||||
"train": "Vlak",
|
|
||||||
"train_whistle": "Vlakovni svirac",
|
|
||||||
"train_horn": "Vlakovni rohorn",
|
|
||||||
"railroad_car": "Željeznički vagon",
|
|
||||||
"train_wheels_squealing": "Vlakove točkove koje zavijaju",
|
|
||||||
"subway": "Metropolitena",
|
|
||||||
"aircraft": "Avion",
|
|
||||||
"aircraft_engine": "Avionski motor",
|
|
||||||
"jet_engine": "Reaktivni motor",
|
|
||||||
"propeller": "Vijak",
|
|
||||||
"helicopter": "Heličopter",
|
|
||||||
"fixed-wing_aircraft": "Avion s krilima",
|
|
||||||
"skateboard": "Skateboard",
|
|
||||||
"engine": "Motor",
|
|
||||||
"light_engine": "Lagani motor",
|
|
||||||
"dental_drill's_drill": "Stomatološki bušilica",
|
|
||||||
"lawn_mower": "Kosilica",
|
|
||||||
"chainsaw": "Pilica",
|
|
||||||
"medium_engine": "Srednji motor",
|
|
||||||
"heavy_engine": "Teški motor",
|
|
||||||
"engine_knocking": "Kloping motora",
|
|
||||||
"engine_starting": "Pokretanje motora",
|
|
||||||
"idling": "Miris",
|
|
||||||
"accelerating": "Ubrzavanje",
|
|
||||||
"door": "Vrata",
|
|
||||||
"doorbell": "Zvonce",
|
|
||||||
"ding-dong": "Ding-dong",
|
|
||||||
"sliding_door": "Klizna vrata",
|
|
||||||
"slam": "Zatvaranje",
|
|
||||||
"knock": "Kucanje",
|
|
||||||
"tap": "Tap",
|
|
||||||
"squeak": "Krik",
|
|
||||||
"cupboard_open_or_close": "Otvorenje ili zatvaranje police",
|
|
||||||
"drawer_open_or_close": "Otvorenje ili zatvaranje vunca",
|
|
||||||
"dishes": "Posuđe",
|
|
||||||
"cutlery": "Posuđe za jelo",
|
|
||||||
"chopping": "Rezanje",
|
|
||||||
"frying": "Praženje",
|
|
||||||
"microwave_oven": "Mikrotalasna pećnica",
|
|
||||||
"blender": "Miksere",
|
|
||||||
"water_tap": "Kran",
|
|
||||||
"sink": "Lavabo",
|
|
||||||
"bathtub": "Kupatilo",
|
|
||||||
"hair_dryer": "Sušilac za kosu",
|
|
||||||
"toilet_flush": "Očišćavanje toaleta",
|
|
||||||
"toothbrush": "Šetka za zube",
|
|
||||||
"electric_toothbrush": "Električna šetka za zube",
|
|
||||||
"vacuum_cleaner": "Praškoljac",
|
|
||||||
"zipper": "Zatvarac",
|
|
||||||
"keys_jangling": "Ključevi koji se škripi",
|
|
||||||
"coin": "Novčanik",
|
|
||||||
"scissors": "Škare",
|
|
||||||
"electric_shaver": "Električni šavac",
|
|
||||||
"shuffling_cards": "Premještanje karata",
|
|
||||||
"typing": "Kucanje",
|
|
||||||
"typewriter": "Tipkovnica",
|
|
||||||
"computer_keyboard": "Računalna tipkovnica",
|
|
||||||
"writing": "Pisanje",
|
|
||||||
"alarm": "Alarm",
|
|
||||||
"telephone": "Telefon",
|
|
||||||
"telephone_bell_ringing": "Zvono telefona",
|
|
||||||
"ringtone": "Ton za poziv",
|
|
||||||
"telephone_dialing": "Pozivanje telefona",
|
|
||||||
"dial_tone": "Ton za poziv",
|
|
||||||
"busy_signal": "Signal zauzetosti",
|
|
||||||
"alarm_clock": "Budilica",
|
|
||||||
"siren": "Sirena",
|
|
||||||
"civil_defense_siren": "Sirena za civilnu zaštitu",
|
|
||||||
"buzzer": "Buzer",
|
|
||||||
"smoke_detector": "Detektor dima",
|
|
||||||
"fire_alarm": "Pozar alarm",
|
|
||||||
"foghorn": "Mlazni svirac",
|
|
||||||
"whistle": "Štiklja",
|
|
||||||
"steam_whistle": "Parni zvono",
|
|
||||||
"mechanisms": "Mehanizmi",
|
|
||||||
"ratchet": "Ratchet",
|
|
||||||
"clock": "Sat",
|
|
||||||
"tick": "Tik",
|
|
||||||
"tick-tock": "Tik-tak",
|
|
||||||
"gears": "Zupčanici",
|
|
||||||
"pulleys": "Koturači",
|
|
||||||
"sewing_machine": "Šitna mašina",
|
|
||||||
"mechanical_fan": "Mehanički ventilator",
|
|
||||||
"air_conditioning": "Klima uređaj",
|
|
||||||
"cash_register": "Gotovinska kasica",
|
|
||||||
"printer": "Štampač",
|
|
||||||
"camera": "Kamera",
|
|
||||||
"single-lens_reflex_camera": "Kamera s jednim objektivom",
|
|
||||||
"tools": "Alati",
|
|
||||||
"hammer": "Klubica",
|
|
||||||
"jackhammer": "Betonomijak",
|
|
||||||
"sawing": "Sečenje",
|
|
||||||
"filing": "Flešanje",
|
|
||||||
"sanding": "Šljokanje",
|
|
||||||
"power_tool": "Električni alat",
|
|
||||||
"drill": "Bušilica",
|
|
||||||
"explosion": "Eksplozija",
|
|
||||||
"gunshot": "Pucanj",
|
|
||||||
"machine_gun": "Automatska puška",
|
|
||||||
"fusillade": "Fusiladža",
|
|
||||||
"artillery_fire": "Pucanj topovima",
|
|
||||||
"cap_gun": "Pistolj za pucanje",
|
|
||||||
"fireworks": "Pucanje svjetiljki",
|
|
||||||
"firecracker": "Svjetiljka",
|
|
||||||
"burst": "Izbič",
|
|
||||||
"eruption": "Eruptija",
|
|
||||||
"boom": "Bum",
|
|
||||||
"wood": "Drvo",
|
|
||||||
"chop": "Rezanje",
|
|
||||||
"splinter": "Razlomak",
|
|
||||||
"crack": "Klackanje",
|
|
||||||
"glass": "Staklo",
|
|
||||||
"chink": "Prozor",
|
|
||||||
"shatter": "Razbijanje",
|
|
||||||
"silence": "Tišina",
|
|
||||||
"sound_effect": "Zvučni efekt",
|
|
||||||
"environmental_noise": "Okolišni šum",
|
|
||||||
"static": "Statički šum",
|
|
||||||
"white_noise": "Bijeli šum",
|
|
||||||
"pink_noise": "Rumeni šum",
|
|
||||||
"television": "Televizija",
|
|
||||||
"radio": "Radio",
|
|
||||||
"field_recording": "Snimka na terenu",
|
|
||||||
"scream": "Vrisak",
|
|
||||||
"sodeling": "Sodeling",
|
|
||||||
"chird": "Chird",
|
|
||||||
"change_ringing": "Promjena zvona",
|
|
||||||
"shofar": "Šofar",
|
|
||||||
"liquid": "Tekućina",
|
|
||||||
"splash": "Pljuskanje",
|
|
||||||
"slosh": "Sloš",
|
|
||||||
"squish": "Škripanje",
|
|
||||||
"drip": "Kapanje",
|
|
||||||
"pour": "Prelivanje",
|
|
||||||
"trickle": "Tijek",
|
|
||||||
"gush": "Gusenje",
|
|
||||||
"fill": "Popunjavanje",
|
|
||||||
"spray": "Sprajanje",
|
|
||||||
"pump": "Pumpa",
|
|
||||||
"stir": "Miješanje",
|
|
||||||
"boiling": "Vrećenje",
|
|
||||||
"sonar": "Sonar",
|
|
||||||
"arrow": "Strela",
|
|
||||||
"whoosh": "Šum",
|
|
||||||
"thump": "Tupanje",
|
|
||||||
"thunk": "Tunk",
|
|
||||||
"electronic_tuner": "Elektronski tuner",
|
|
||||||
"effects_unit": "Jedinica efekata",
|
|
||||||
"chorus_effect": "Efekt korusa",
|
|
||||||
"basketball_bounce": "Košarkaški skok",
|
|
||||||
"bang": "BANG",
|
|
||||||
"slap": "Slap",
|
|
||||||
"whack": "Perc",
|
|
||||||
"smash": "Sprem",
|
|
||||||
"breaking": "Raskidanje",
|
|
||||||
"bouncing": "Skakanje",
|
|
||||||
"whip": "Škripanje",
|
|
||||||
"flap": "Klizanje",
|
|
||||||
"scratch": "Oštećenje",
|
|
||||||
"scrape": "Prašenje",
|
|
||||||
"rub": "Trenje",
|
|
||||||
"roll": "Kotrljanje",
|
|
||||||
"crushing": "Stiskanje",
|
|
||||||
"crumpling": "Sklapanje",
|
|
||||||
"tearing": "Raskidanje",
|
|
||||||
"beep": "Zvuk",
|
|
||||||
"ping": "Poziv",
|
|
||||||
"ding": "Zvuk",
|
|
||||||
"clang": "Zvuk",
|
|
||||||
"squeal": "Zvuk",
|
|
||||||
"creak": "Zvuk",
|
|
||||||
"rustle": "Zvuk",
|
|
||||||
"whir": "Zvuk",
|
|
||||||
"clatter": "Zvuk",
|
|
||||||
"sizzle": "Šištanje",
|
|
||||||
"clicking": "Klikanje",
|
|
||||||
"clickety_clack": "Klik-tak",
|
|
||||||
"rumble": "Rumbljanje",
|
|
||||||
"plop": "Plop",
|
|
||||||
"hum": "Hum",
|
|
||||||
"zing": "Zing",
|
|
||||||
"boing": "Boing",
|
|
||||||
"crunch": "Crunch",
|
|
||||||
"sine_wave": "Sinusna valna",
|
|
||||||
"harmonic": "Harmonični",
|
|
||||||
"chirp_tone": "Tanjirasti ton",
|
|
||||||
"pulse": "Impuls",
|
|
||||||
"inside": "Unutra",
|
|
||||||
"outside": "Van",
|
|
||||||
"reverberation": "Reverberacija",
|
|
||||||
"echo": "Odjek",
|
|
||||||
"noise": "Šum",
|
|
||||||
"mains_hum": "Glavni šum",
|
|
||||||
"distortion": "Distorzija",
|
|
||||||
"sidetone": "Sidetone",
|
|
||||||
"cacophony": "Kacofonija",
|
|
||||||
"throbbing": "Tremor",
|
|
||||||
"vibration": "Vibracija"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,326 +1,6 @@
|
|||||||
{
|
{
|
||||||
"time": {
|
"time": {
|
||||||
"untilForTime": "Do {{time}}",
|
"untilForTime": "Do {{time}}",
|
||||||
"untilForRestart": "Do ponovnog pokretanja Frigate.",
|
"untilForRestart": "Dok se Frigate ponovo ne pokrene."
|
||||||
"untilRestart": "Do ponovnog pokretanja",
|
}
|
||||||
"never": "Nikad",
|
|
||||||
"ago": "{{timeAgo}} prije",
|
|
||||||
"justNow": "Sada",
|
|
||||||
"today": "Danas",
|
|
||||||
"yesterday": "Jučer",
|
|
||||||
"last7": "Prošlih 7 dana",
|
|
||||||
"last14": "Prošlih 14 dana",
|
|
||||||
"last30": "Prošlih 30 dana",
|
|
||||||
"thisWeek": "Ova sedmica",
|
|
||||||
"lastWeek": "Prošla sedmica",
|
|
||||||
"thisMonth": "Ovaj mjesec",
|
|
||||||
"lastMonth": "Prošli mjesec",
|
|
||||||
"5minutes": "5 minuta",
|
|
||||||
"10minutes": "10 minuta",
|
|
||||||
"30minutes": "30 minuta",
|
|
||||||
"1hour": "1 sat",
|
|
||||||
"12hours": "12 sati",
|
|
||||||
"24hours": "24 sata",
|
|
||||||
"pm": "posle podne",
|
|
||||||
"am": "pre podne",
|
|
||||||
"yr": "{{time}} god",
|
|
||||||
"year_one": "{{time}} godina",
|
|
||||||
"year_few": "",
|
|
||||||
"year_other": "{{time}} godine",
|
|
||||||
"mo": "{{time}} mjes",
|
|
||||||
"month_one": "{{time}} mjesec",
|
|
||||||
"month_few": "",
|
|
||||||
"month_other": "{{time}} mjeseci",
|
|
||||||
"d": "{{time}}d",
|
|
||||||
"day_one": "{{time}} dan",
|
|
||||||
"day_few": "",
|
|
||||||
"day_other": "{{time}} dana",
|
|
||||||
"h": "{{time}}h",
|
|
||||||
"hour_one": "{{time}} sat",
|
|
||||||
"hour_few": "",
|
|
||||||
"hour_other": "{{time}} sati",
|
|
||||||
"m": "{{time}}m",
|
|
||||||
"minute_one": "{{time}} minut",
|
|
||||||
"minute_few": "",
|
|
||||||
"minute_other": "{{time}} minuta",
|
|
||||||
"s": "{{time}}s",
|
|
||||||
"second_one": "{{time}} sekunda",
|
|
||||||
"second_few": "",
|
|
||||||
"second_other": "{{time}} sekunde",
|
|
||||||
"formattedTimestamp": {
|
|
||||||
"12hour": "MMM d, h:mm:ss aaa",
|
|
||||||
"24hour": "MMM d, HH:mm:ss"
|
|
||||||
},
|
|
||||||
"formattedTimestamp2": {
|
|
||||||
"12hour": "MM/dd h:mm:ssa",
|
|
||||||
"24hour": "d MMM HH:mm:ss"
|
|
||||||
},
|
|
||||||
"formattedTimestampHourMinute": {
|
|
||||||
"12hour": "h:mm aaa",
|
|
||||||
"24hour": "HH:mm"
|
|
||||||
},
|
|
||||||
"formattedTimestampHourMinuteSecond": {
|
|
||||||
"12hour": "h:mm:ss aaa",
|
|
||||||
"24hour": "HH:mm:ss"
|
|
||||||
},
|
|
||||||
"formattedTimestampMonthDayHourMinute": {
|
|
||||||
"12hour": "MMM d, h:mm aaa",
|
|
||||||
"24hour": "MMM d, HH:mm"
|
|
||||||
},
|
|
||||||
"formattedTimestampMonthDayYear": {
|
|
||||||
"12hour": "MMM d, yyyy",
|
|
||||||
"24hour": "MMM d, yyyy"
|
|
||||||
},
|
|
||||||
"formattedTimestampMonthDayYearHourMinute": {
|
|
||||||
"12hour": "MMM d yyyy, h:mm aaa",
|
|
||||||
"24hour": "MMM d yyyy, HH:mm"
|
|
||||||
},
|
|
||||||
"formattedTimestampMonthDay": "MMM d",
|
|
||||||
"formattedTimestampFilename": {
|
|
||||||
"12hour": "MM-dd-yy-h-mm-ss-a",
|
|
||||||
"24hour": "MM-dd-yy-HH-mm-ss"
|
|
||||||
},
|
|
||||||
"inProgress": "U toku",
|
|
||||||
"invalidStartTime": "Neispravno početno vrijeme",
|
|
||||||
"invalidEndTime": "Neispravno krajnje vrijeme"
|
|
||||||
},
|
|
||||||
"unit": {
|
|
||||||
"speed": {
|
|
||||||
"mph": "mph",
|
|
||||||
"kph": "kph"
|
|
||||||
},
|
|
||||||
"length": {
|
|
||||||
"feet": "fut",
|
|
||||||
"meters": "metar"
|
|
||||||
},
|
|
||||||
"data": {
|
|
||||||
"kbps": "kB/s",
|
|
||||||
"mbps": "MB/s",
|
|
||||||
"gbps": "GB/s",
|
|
||||||
"kbph": "kB/sat",
|
|
||||||
"mbph": "MB/sat",
|
|
||||||
"gbph": "GB/sat"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"label": {
|
|
||||||
"back": "Povratak",
|
|
||||||
"hide": "Sakrij {{item}}",
|
|
||||||
"show": "Prikaži {{item}}",
|
|
||||||
"ID": "ID",
|
|
||||||
"none": "Nijedan",
|
|
||||||
"all": "Sve",
|
|
||||||
"other": "Ostalo"
|
|
||||||
},
|
|
||||||
"list": {
|
|
||||||
"two": "{{0}} i {{1}}",
|
|
||||||
"many": "{{items}}, i {{last}}",
|
|
||||||
"separatorWithSpace": ", "
|
|
||||||
},
|
|
||||||
"field": {
|
|
||||||
"optional": "Opcionalno",
|
|
||||||
"internalID": "Unutarnji ID koji Frigate koristi u konfiguraciji i bazi podataka"
|
|
||||||
},
|
|
||||||
"button": {
|
|
||||||
"add": "Dodaj",
|
|
||||||
"apply": "Primijeni",
|
|
||||||
"applying": "Primjenjuje se…",
|
|
||||||
"reset": "Resetuj",
|
|
||||||
"undo": "Poništi",
|
|
||||||
"done": "Gotovo",
|
|
||||||
"enabled": "Omogućeno",
|
|
||||||
"enable": "Omogući",
|
|
||||||
"disabled": "Onemogućeno",
|
|
||||||
"disable": "Onemogući",
|
|
||||||
"save": "Sačuvaj",
|
|
||||||
"saving": "Sačuvanje…",
|
|
||||||
"cancel": "Otkaži",
|
|
||||||
"close": "Zatvori",
|
|
||||||
"copy": "Kopiraj",
|
|
||||||
"copiedToClipboard": "Kopirano u međuspremnik",
|
|
||||||
"back": "Nazad",
|
|
||||||
"history": "Historija",
|
|
||||||
"fullscreen": "Pun ekran",
|
|
||||||
"exitFullscreen": "Napusti pun ekran",
|
|
||||||
"pictureInPicture": "Slika u slici",
|
|
||||||
"twoWayTalk": "Dvostrani razgovor",
|
|
||||||
"cameraAudio": "Zvuk kamere",
|
|
||||||
"on": "Uključeno",
|
|
||||||
"off": "Isključeno",
|
|
||||||
"edit": "Uredi",
|
|
||||||
"copyCoordinates": "Kopiraj koordinate",
|
|
||||||
"delete": "Obriši",
|
|
||||||
"yes": "Da",
|
|
||||||
"no": "Ne",
|
|
||||||
"download": "Preuzmi",
|
|
||||||
"info": "Informacija",
|
|
||||||
"suspended": "Otkazano",
|
|
||||||
"unsuspended": "Ponovi",
|
|
||||||
"play": "Reproduciraj",
|
|
||||||
"unselect": "Odznači",
|
|
||||||
"export": "Izvoz",
|
|
||||||
"deleteNow": "Obriši sada",
|
|
||||||
"next": "Sljedeće",
|
|
||||||
"continue": "Nastavi",
|
|
||||||
"modified": "Izmijenjeno",
|
|
||||||
"overridden": "Preklopljeno",
|
|
||||||
"resetToGlobal": "Vrati na globalno",
|
|
||||||
"resetToDefault": "Vrati na podrazumijevano",
|
|
||||||
"saveAll": "Sačuvaj sve",
|
|
||||||
"savingAll": "Sačuvanje svih…",
|
|
||||||
"undoAll": "Poništi sve",
|
|
||||||
"retry": "Pokušaj ponovno"
|
|
||||||
},
|
|
||||||
"menu": {
|
|
||||||
"system": "Sistem",
|
|
||||||
"systemMetrics": "Sistem metrike",
|
|
||||||
"configuration": "Konfiguracija",
|
|
||||||
"systemLogs": "Sistemski zapisi",
|
|
||||||
"profiles": "Profili",
|
|
||||||
"settings": "Postavke",
|
|
||||||
"configurationEditor": "Uređivač konfiguracije",
|
|
||||||
"languages": "Jezici",
|
|
||||||
"language": {
|
|
||||||
"en": "Engleski (English)",
|
|
||||||
"es": "Španjolski (Spanish)",
|
|
||||||
"zhCN": "Jednostavni kineski (Simplified Chinese)",
|
|
||||||
"hi": "Hindi (Hindi)",
|
|
||||||
"fr": "Francuski (French)",
|
|
||||||
"ar": "Arapski (Arabic)",
|
|
||||||
"pt": "Portugalski (Portuguese)",
|
|
||||||
"ptBR": "Portugalski brazilski (Brazilian Portuguese)",
|
|
||||||
"ru": "Ruski (Russian)",
|
|
||||||
"de": "Nemački (German)",
|
|
||||||
"ja": "Japanski (Japanese)",
|
|
||||||
"tr": "Turski (Turkish)",
|
|
||||||
"it": "Talijanski (Italian)",
|
|
||||||
"nl": "Nizozemski (Dutch)",
|
|
||||||
"sv": "Švedski (Swedish)",
|
|
||||||
"cs": "Češki (Czech)",
|
|
||||||
"nb": "Norveški bokmål (Norwegian Bokmål)",
|
|
||||||
"ko": "Koreanski (Korean)",
|
|
||||||
"vi": "Vietnamski (Vietnamese)",
|
|
||||||
"fa": "Perzijski (Persian)",
|
|
||||||
"pl": "Polski (Poljski)",
|
|
||||||
"uk": "Українська (Ukrajinski)",
|
|
||||||
"he": "עברית (Hebrejski)",
|
|
||||||
"el": "Ελληνικά (Grčki)",
|
|
||||||
"ro": "Română (Romunski)",
|
|
||||||
"hu": "Magyar (Mađarski)",
|
|
||||||
"fi": "Suomi (Finski)",
|
|
||||||
"da": "Dansk (Danski)",
|
|
||||||
"sk": "Slovenčina (Slovački)",
|
|
||||||
"yue": "粵語 (Kantonski)",
|
|
||||||
"th": "ไทย (Tajski)",
|
|
||||||
"ca": "Català (Katalonski)",
|
|
||||||
"hr": "Hrvatski (Hrvatski)",
|
|
||||||
"sr": "Српски (Srpski)",
|
|
||||||
"sl": "Slovenščina (Slovenski)",
|
|
||||||
"lt": "Lietuvių (Lietuvių)",
|
|
||||||
"bg": "Български (Bugarinski)",
|
|
||||||
"gl": "Galego (Galicijski)",
|
|
||||||
"id": "Bahasa Indonesia (Indoneziski)",
|
|
||||||
"ur": "Urdu",
|
|
||||||
"withSystem": {
|
|
||||||
"label": "Koristite postavke sistema za jezik"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"appearance": "Izgled",
|
|
||||||
"darkMode": {
|
|
||||||
"label": "Tamni režim",
|
|
||||||
"light": "Svijetla",
|
|
||||||
"dark": "Tamna",
|
|
||||||
"withSystem": {
|
|
||||||
"label": "Koristite postavke sistema za svjetlosni ili tamni režim"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"withSystem": "Sistem",
|
|
||||||
"theme": {
|
|
||||||
"label": "Tema",
|
|
||||||
"blue": "Plava",
|
|
||||||
"green": "Zelena",
|
|
||||||
"nord": "Nord",
|
|
||||||
"red": "Crvena",
|
|
||||||
"highcontrast": "Visok kontrast",
|
|
||||||
"default": "Zadano"
|
|
||||||
},
|
|
||||||
"help": "Pomoć",
|
|
||||||
"documentation": {
|
|
||||||
"title": "Dokumentacija",
|
|
||||||
"label": "Dokumentacija za Frigate"
|
|
||||||
},
|
|
||||||
"restart": "Ponovno pokreni Frigate",
|
|
||||||
"live": {
|
|
||||||
"title": "Uživo",
|
|
||||||
"allCameras": "Sve Kamere",
|
|
||||||
"cameras": {
|
|
||||||
"title": "Kamere",
|
|
||||||
"count_one": "{{count}} Kamera",
|
|
||||||
"count_few": "",
|
|
||||||
"count_other": "{{count}} Kamere"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"review": "Pregled",
|
|
||||||
"explore": "Istraži",
|
|
||||||
"export": "Izvoz",
|
|
||||||
"actions": "Akcije",
|
|
||||||
"uiPlayground": "UI igralište",
|
|
||||||
"features": "Funkcije",
|
|
||||||
"faceLibrary": "Biblioteka lica",
|
|
||||||
"classification": "Klasifikacija",
|
|
||||||
"chat": "Chat",
|
|
||||||
"user": {
|
|
||||||
"title": "Korisnik",
|
|
||||||
"account": "Račun",
|
|
||||||
"current": "Trenutni korisnik: {{user}}",
|
|
||||||
"anonymous": "anons",
|
|
||||||
"logout": "Odjava",
|
|
||||||
"setPassword": "Postavi lozinku"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"toast": {
|
|
||||||
"copyUrlToClipboard": "URL kopiran u međuspremnik.",
|
|
||||||
"save": {
|
|
||||||
"title": "Sačuvaj",
|
|
||||||
"error": {
|
|
||||||
"title": "Nije uspješno sačuvana promjena konfiguracije: {{errorMessage}}",
|
|
||||||
"noMessage": "Nije uspješno sačuvana promjena konfiguracije"
|
|
||||||
},
|
|
||||||
"success": "Uspješno sačuvana promjena konfiguracije."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"role": {
|
|
||||||
"title": "Uloga",
|
|
||||||
"admin": "Admin",
|
|
||||||
"viewer": "Pregledač",
|
|
||||||
"desc": "Admini imaju pun pristup svim funkcijama u korisničkom sučelju Frigate. Pregledači su ograničeni na pregled kamere, pregled stavki i povijesne snimke u korisničkom sučelju."
|
|
||||||
},
|
|
||||||
"pagination": {
|
|
||||||
"label": "paginacija",
|
|
||||||
"previous": {
|
|
||||||
"title": "Prethodno",
|
|
||||||
"label": "Idi na prethodnu stranicu"
|
|
||||||
},
|
|
||||||
"next": {
|
|
||||||
"title": "Sljedeće",
|
|
||||||
"label": "Idi na sljedeću stranicu"
|
|
||||||
},
|
|
||||||
"more": "Više stranica"
|
|
||||||
},
|
|
||||||
"accessDenied": {
|
|
||||||
"documentTitle": "Pristup odbijen - Frigate",
|
|
||||||
"title": "Pristup odbijen",
|
|
||||||
"desc": "Nemate dozvolu za pregled ove stranice."
|
|
||||||
},
|
|
||||||
"notFound": {
|
|
||||||
"documentTitle": "Nije pronađeno - Frigate",
|
|
||||||
"title": "404",
|
|
||||||
"desc": "Stranica nije pronađena"
|
|
||||||
},
|
|
||||||
"selectItem": "Odaberite {{item}}",
|
|
||||||
"readTheDocumentation": "Pročitajte dokumentaciju",
|
|
||||||
"information": {
|
|
||||||
"pixels": "{{area}}px"
|
|
||||||
},
|
|
||||||
"no_items": "Nema stavki",
|
|
||||||
"validation_errors": "Greške validacije"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,16 +1,5 @@
|
|||||||
{
|
{
|
||||||
"form": {
|
"form": {
|
||||||
"user": "Korisničko ime",
|
"user": "Korisničko ime"
|
||||||
"password": "Lozinka",
|
|
||||||
"login": "Prijava",
|
|
||||||
"firstTimeLogin": "Pokušavate se prijaviti prvi put? Vjerodajnice su ispisane u logovima Frigate.",
|
|
||||||
"errors": {
|
|
||||||
"usernameRequired": "Korisničko ime je obavezno",
|
|
||||||
"passwordRequired": "Lozinka je obavezna",
|
|
||||||
"rateLimit": "Premašen je limit brzine. Pokušajte kasnije.",
|
|
||||||
"loginFailed": "Prijava nije uspješna",
|
|
||||||
"unknownError": "Nepoznata greška. Provjerite zapise.",
|
|
||||||
"webUnknownError": "Nepoznata greška. Provjerite konzolne zapise."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,87 +1,6 @@
|
|||||||
{
|
{
|
||||||
"group": {
|
"group": {
|
||||||
"label": "Grupe kamere",
|
"label": "Grupe kamera",
|
||||||
"add": "Dodaj grupu kamere",
|
"add": "Dodaj Grupu Kamera"
|
||||||
"edit": "Uredi grupu kamera",
|
|
||||||
"delete": {
|
|
||||||
"label": "Obriši grupu kamere",
|
|
||||||
"confirm": {
|
|
||||||
"title": "Potvrdi brisanje",
|
|
||||||
"desc": "Sigurno li želite da obrišete grupu kamere <em>{{name}}</em>?"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"name": {
|
|
||||||
"label": "Ime",
|
|
||||||
"placeholder": "Unesite ime…",
|
|
||||||
"errorMessage": {
|
|
||||||
"mustLeastCharacters": "Ime grupe kamere mora imati najmanje 2 karaktera.",
|
|
||||||
"exists": "Ime grupe kamere već postoji.",
|
|
||||||
"nameMustNotPeriod": "Ime grupe kamere ne smije sadržavati tačku.",
|
|
||||||
"invalid": "Neispravno ime grupe kamere."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"cameras": {
|
|
||||||
"label": "Kamere",
|
|
||||||
"desc": "Odaberite kamere za ovu grupu."
|
|
||||||
},
|
|
||||||
"icon": "Ikona",
|
|
||||||
"success": "Grupa kamere ({{name}}) je sačuvana.",
|
|
||||||
"camera": {
|
|
||||||
"birdseye": "Birdseye",
|
|
||||||
"setting": {
|
|
||||||
"label": "Postavke prenošenja kamere",
|
|
||||||
"title": "Postavke prenošenja {{cameraName}}",
|
|
||||||
"desc": "Promijenite opcije uživo prenošenja za tablicu upravljanja ove grupe kamere. <em>Ove postavke su specifične za uređaj/pretvarač.</em>",
|
|
||||||
"audioIsAvailable": "Audio je dostupan za ovaj stream",
|
|
||||||
"audioIsUnavailable": "Zvuk nije dostupan za ovaj tok",
|
|
||||||
"audio": {
|
|
||||||
"tips": {
|
|
||||||
"title": "Audio mora biti izlaz iz vaše kamere i konfiguriran u go2rtc za ovaj stream."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"stream": "Tok",
|
|
||||||
"placeholder": "Odaberite tok",
|
|
||||||
"streamMethod": {
|
|
||||||
"label": "Način prenošenja",
|
|
||||||
"placeholder": "Odaberite način prenošenja",
|
|
||||||
"method": {
|
|
||||||
"noStreaming": {
|
|
||||||
"label": "Bez prenošenja",
|
|
||||||
"desc": "Slike kamere će se ažurirati samo jednom na minut i neće se dogoditi uživo prenošenje."
|
|
||||||
},
|
|
||||||
"smartStreaming": {
|
|
||||||
"label": "Pametno prenošenje (preporučeno)",
|
|
||||||
"desc": "Pametno prenošenje će ažurirati sliku kamere jednom na minut kada se ne događa detektovana aktivnost kako bi se uštedjelo na širovini i resursima. Kada se detektuje aktivnost, slika se glatko prebacuje u uživo prenošenje."
|
|
||||||
},
|
|
||||||
"continuousStreaming": {
|
|
||||||
"label": "Neprekidno prenošenje",
|
|
||||||
"desc": {
|
|
||||||
"title": "Slika kamere uvijek će biti živo prenošenje kada je vidljiva na ploči, čak i ako se ne detektira aktivnost.",
|
|
||||||
"warning": "Neprekidno prenošenje može uzrokovati visoku upotrebu širine pojasa i probleme s performansama. Koristite s oprezom."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"compatibilityMode": {
|
|
||||||
"label": "Režim kompatibilnosti",
|
|
||||||
"desc": "Omogućite ovu opciju samo ako se živo prenošenje vaše kamere prikazuje s bojnim artefaktima i dijagonalnom linijom na desnoj strani slike."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"debug": {
|
|
||||||
"options": {
|
|
||||||
"label": "Postavke",
|
|
||||||
"title": "Opcije",
|
|
||||||
"showOptions": "Prikaži opcije",
|
|
||||||
"hideOptions": "Sakrij opcije"
|
|
||||||
},
|
|
||||||
"boundingBox": "Okvir",
|
|
||||||
"timestamp": "Vremenski pečat",
|
|
||||||
"zones": "Zone",
|
|
||||||
"mask": "Maska",
|
|
||||||
"motion": "Kretanje",
|
|
||||||
"regions": "Regije",
|
|
||||||
"paths": "Putanje"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,5 @@
|
|||||||
{
|
{
|
||||||
"iconPicker": {
|
"iconPicker": {
|
||||||
"selectIcon": "Odaberite ikonu",
|
"selectIcon": "Izaberite ikonu"
|
||||||
"search": {
|
|
||||||
"placeholder": "Pretražite ikonu…"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,28 +1,3 @@
|
|||||||
{
|
{
|
||||||
"person": "Osoba",
|
"person": "Osoba"
|
||||||
"bicycle": "Kolo",
|
|
||||||
"animal": "Životinja",
|
|
||||||
"dog": "Pas",
|
|
||||||
"bark": "Glavu",
|
|
||||||
"cat": "Mačka",
|
|
||||||
"horse": "Konj",
|
|
||||||
"goat": "Koza",
|
|
||||||
"sheep": "Ovca",
|
|
||||||
"bird": "Ptica",
|
|
||||||
"mouse": "Mouse",
|
|
||||||
"keyboard": "Klaviatura",
|
|
||||||
"vehicle": "Vozilo",
|
|
||||||
"boat": "Brod",
|
|
||||||
"car": "Automobil",
|
|
||||||
"bus": "Autobus",
|
|
||||||
"motorcycle": "Motocikl",
|
|
||||||
"train": "Vlak",
|
|
||||||
"skateboard": "Skateboard",
|
|
||||||
"door": "Vrata",
|
|
||||||
"blender": "Miksere",
|
|
||||||
"sink": "Lavabo",
|
|
||||||
"hair_dryer": "Sušilac za kosu",
|
|
||||||
"toothbrush": "Šetka za zube",
|
|
||||||
"scissors": "Škare",
|
|
||||||
"clock": "Sat"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
{
|
{
|
||||||
"alerts": "Obavještenja",
|
"alerts": "Obavještenja",
|
||||||
"detections": "Detekcije",
|
"detections": "Detekcije"
|
||||||
"camera": "Kamera"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -1,5 +1,4 @@
|
|||||||
{
|
{
|
||||||
"filter": "Filter",
|
"filter": "Filter",
|
||||||
"export": "Izvoz",
|
"export": "Izvoz"
|
||||||
"calendar": "Kalendar"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -1,6 +1,3 @@
|
|||||||
{
|
{
|
||||||
"search": "Pretraga",
|
"search": "Pretraga"
|
||||||
"button": {
|
|
||||||
"save": "Sačuvaj pretragu"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,5 @@
|
|||||||
{
|
{
|
||||||
"documentTitle": {
|
"documentTitle": {
|
||||||
"default": "Postavke - Frigate"
|
"default": "Postavke - Frigate"
|
||||||
},
|
|
||||||
"menu": {
|
|
||||||
"system": "Sistem",
|
|
||||||
"profiles": "Profili"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -19,31 +19,26 @@
|
|||||||
"startLabel": "Start",
|
"startLabel": "Start",
|
||||||
"endLabel": "End",
|
"endLabel": "End",
|
||||||
"toast": {
|
"toast": {
|
||||||
|
"success": "Debug replay started successfully",
|
||||||
"error": "Failed to start debug replay: {{error}}",
|
"error": "Failed to start debug replay: {{error}}",
|
||||||
"alreadyActive": "A replay session is already active",
|
"alreadyActive": "A replay session is already active",
|
||||||
|
"stopped": "Debug replay stopped",
|
||||||
"stopError": "Failed to stop debug replay: {{error}}",
|
"stopError": "Failed to stop debug replay: {{error}}",
|
||||||
"goToReplay": "Go to Replay"
|
"goToReplay": "Go to Replay"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"noSession": "No Active Debug Replay Session",
|
"noSession": "No Active Replay Session",
|
||||||
"noSessionDesc": "Start a Debug Replay from History view by clicking the Actions button in the toolbar and choosing Debug Replay.",
|
"noSessionDesc": "Start a debug replay from the History view by clicking the Debug Replay button in the toolbar.",
|
||||||
"goToRecordings": "Go to History",
|
"goToRecordings": "Go to History",
|
||||||
"preparingClip": "Preparing clip…",
|
|
||||||
"preparingClipDesc": "Frigate is stitching together recordings for the selected time range. This can take a minute for longer ranges.",
|
|
||||||
"startingCamera": "Starting Debug Replay…",
|
|
||||||
"startError": {
|
|
||||||
"title": "Failed to start Debug Replay",
|
|
||||||
"back": "Back to History"
|
|
||||||
},
|
|
||||||
"sourceCamera": "Source Camera",
|
"sourceCamera": "Source Camera",
|
||||||
"replayCamera": "Replay Camera",
|
"replayCamera": "Replay Camera",
|
||||||
"initializingReplay": "Initializing Debug Replay...",
|
"initializingReplay": "Initializing replay...",
|
||||||
"stoppingReplay": "Stopping Debug Replay...",
|
"stoppingReplay": "Stopping replay...",
|
||||||
"stopReplay": "Stop Replay",
|
"stopReplay": "Stop Replay",
|
||||||
"confirmStop": {
|
"confirmStop": {
|
||||||
"title": "Stop Debug Replay?",
|
"title": "Stop Debug Replay?",
|
||||||
"description": "This will stop the session and clean up all temporary data. Are you sure?",
|
"description": "This will stop the replay session and clean up all temporary data. Are you sure?",
|
||||||
"confirm": "Stop Replay",
|
"confirm": "Stop Replay",
|
||||||
"cancel": "Cancel"
|
"cancel": "Cancel"
|
||||||
},
|
},
|
||||||
@ -54,6 +49,6 @@
|
|||||||
"activeTracking": "Active tracking",
|
"activeTracking": "Active tracking",
|
||||||
"noActiveTracking": "No active tracking",
|
"noActiveTracking": "No active tracking",
|
||||||
"configuration": "Configuration",
|
"configuration": "Configuration",
|
||||||
"configurationDesc": "Fine tune motion detection and object tracking settings for the Debug Replay camera. No changes are saved to your Frigate configuration file."
|
"configurationDesc": "Fine tune motion detection and object tracking settings for the debug replay camera. No changes are saved to your Frigate configuration file."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -11,7 +11,7 @@
|
|||||||
"laughter": "Smijeh",
|
"laughter": "Smijeh",
|
||||||
"train": "Vlak",
|
"train": "Vlak",
|
||||||
"snicker": "Smješkanje",
|
"snicker": "Smješkanje",
|
||||||
"boat": "Brod",
|
"boat": "ÄŒamac",
|
||||||
"crying": "Plakanje",
|
"crying": "Plakanje",
|
||||||
"singing": "Pjevanje",
|
"singing": "Pjevanje",
|
||||||
"choir": "Zbor",
|
"choir": "Zbor",
|
||||||
@ -350,7 +350,7 @@
|
|||||||
"microwave_oven": "Mikrovalna pećnica",
|
"microwave_oven": "Mikrovalna pećnica",
|
||||||
"water_tap": "Vodovodna slavina",
|
"water_tap": "Vodovodna slavina",
|
||||||
"bathtub": "Kada",
|
"bathtub": "Kada",
|
||||||
"toilet_flush": "Ispiranje toaleta",
|
"toilet_flush": "Ispiranje WC-a",
|
||||||
"electric_toothbrush": "Električna četkica za zube",
|
"electric_toothbrush": "Električna četkica za zube",
|
||||||
"vacuum_cleaner": "Usisavač",
|
"vacuum_cleaner": "Usisavač",
|
||||||
"zipper": "Patentni zatvarač",
|
"zipper": "Patentni zatvarač",
|
||||||
|
|||||||
@ -22,11 +22,11 @@
|
|||||||
"pm": "pm",
|
"pm": "pm",
|
||||||
"am": "am",
|
"am": "am",
|
||||||
"ago": "prije {{timeAgo}}",
|
"ago": "prije {{timeAgo}}",
|
||||||
"yr": "{{time}}g",
|
"yr": "{{time}}g.",
|
||||||
"year_one": "{{time}} godina",
|
"year_one": "{{time}} godina",
|
||||||
"year_few": "{{time}} godine",
|
"year_few": "{{time}} godine",
|
||||||
"year_other": "{{time}} godina",
|
"year_other": "{{time}} godina",
|
||||||
"mo": "{{time}}mj",
|
"mo": "{{time}}mj.",
|
||||||
"month_one": "{{time}} mjesec",
|
"month_one": "{{time}} mjesec",
|
||||||
"month_few": "{{time}} mjeseca",
|
"month_few": "{{time}} mjeseca",
|
||||||
"month_other": "{{time}} mjeseci",
|
"month_other": "{{time}} mjeseci",
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
"airplane": "Zrakoplov",
|
"airplane": "Zrakoplov",
|
||||||
"bus": "Autobus",
|
"bus": "Autobus",
|
||||||
"train": "Vlak",
|
"train": "Vlak",
|
||||||
"boat": "Brod",
|
"boat": "ÄŒamac",
|
||||||
"traffic_light": "Semafor",
|
"traffic_light": "Semafor",
|
||||||
"fire_hydrant": "Hidrant",
|
"fire_hydrant": "Hidrant",
|
||||||
"street_sign": "Prometni znak",
|
"street_sign": "Prometni znak",
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -274,8 +274,7 @@
|
|||||||
"export": "Exportálás",
|
"export": "Exportálás",
|
||||||
"deleteNow": "Törlés Most",
|
"deleteNow": "Törlés Most",
|
||||||
"next": "Következő",
|
"next": "Következő",
|
||||||
"continue": "Tovább",
|
"continue": "Tovább"
|
||||||
"add": "Hozzáad"
|
|
||||||
},
|
},
|
||||||
"label": {
|
"label": {
|
||||||
"back": "Vissza",
|
"back": "Vissza",
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user