Compare commits

..

10 Commits

Author SHA1 Message Date
Hosted Weblate
abdf3b3bf0
Translated using Weblate (Norwegian Bokmål)
Currently translated at 100.0% (101 of 101 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: OverTheHillsAndFarAway <prosjektx@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/nb_NO/
Translation: Frigate NVR/components-dialog
2026-05-03 18:03:39 +02:00
Hosted Weblate
48ea1ce565
Translated using Weblate (Korean)
Currently translated at 100.0% (47 of 47 strings)

Translated using Weblate (Korean)

Currently translated at 6.1% (29 of 469 strings)

Translated using Weblate (Korean)

Currently translated at 100.0% (26 of 26 strings)

Translated using Weblate (Korean)

Currently translated at 12.2% (97 of 790 strings)

Translated using Weblate (Korean)

Currently translated at 100.0% (236 of 236 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: John <john@akfn.net>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/ko/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-camera/ko/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/ko/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/ko/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/ko/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/common
Translation: Frigate NVR/components-camera
Translation: Frigate NVR/components-player
2026-05-03 18:03:38 +02:00
Hosted Weblate
dd7b862e71
Translated using Weblate (Finnish)
Currently translated at 50.8% (120 of 236 strings)

Translated using Weblate (Finnish)

Currently translated at 39.0% (25 of 64 strings)

Translated using Weblate (Finnish)

Currently translated at 19.9% (216 of 1081 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: MarkA <marka@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/fi/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-events/fi/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/fi/
Translation: Frigate NVR/common
Translation: Frigate NVR/views-events
Translation: Frigate NVR/views-settings
2026-05-03 18:03:37 +02:00
Hosted Weblate
e8dbe5a9a3
Translated using Weblate (Swedish)
Currently translated at 4.5% (1 of 22 strings)

Translated using Weblate (Swedish)

Currently translated at 0.4% (2 of 469 strings)

Translated using Weblate (Swedish)

Currently translated at 4.0% (1 of 25 strings)

Translated using Weblate (Swedish)

Currently translated at 0.1% (1 of 790 strings)

Co-authored-by: Fredrik B <fredrik@brannvall.nu>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/sv/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/sv/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-groups/sv/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-validation/sv/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/Config - Groups
Translation: Frigate NVR/Config - Validation
2026-05-03 18:03:36 +02:00
Hosted Weblate
7065d798a1
Translated using Weblate (French)
Currently translated at 77.4% (837 of 1081 strings)

Translated using Weblate (French)

Currently translated at 100.0% (58 of 58 strings)

Translated using Weblate (French)

Currently translated at 100.0% (236 of 236 strings)

Translated using Weblate (French)

Currently translated at 100.0% (47 of 47 strings)

Translated using Weblate (French)

Currently translated at 100.0% (123 of 123 strings)

Translated using Weblate (French)

Currently translated at 100.0% (174 of 174 strings)

Translated using Weblate (French)

Currently translated at 33.9% (159 of 469 strings)

Translated using Weblate (French)

Currently translated at 5.4% (43 of 790 strings)

Translated using Weblate (French)

Currently translated at 80.1% (81 of 101 strings)

Translated using Weblate (French)

Currently translated at 100.0% (145 of 145 strings)

Co-authored-by: Buzz Android <romain.laurent.lejeune@outlook.fr>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/fr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-camera/fr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/fr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/fr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/fr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/objects/fr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-explore/fr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-facelibrary/fr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/fr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/fr/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/common
Translation: Frigate NVR/components-camera
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/objects
Translation: Frigate NVR/views-explore
Translation: Frigate NVR/views-facelibrary
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-05-03 18:03:34 +02:00
Hosted Weblate
32ff12c9bc
Translated using Weblate (Croatian)
Currently translated at 98.9% (98 of 99 strings)

Translated using Weblate (Croatian)

Currently translated at 0.2% (1 of 469 strings)

Translated using Weblate (Croatian)

Currently translated at 4.0% (1 of 25 strings)

Translated using Weblate (Croatian)

Currently translated at 0.1% (1 of 790 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: jasoisjaso <jaso.bih@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/hr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/hr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-groups/hr/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-live/hr/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/Config - Groups
Translation: Frigate NVR/views-live
2026-05-03 18:03:32 +02:00
Hosted Weblate
5a7d64adfc
Translated using Weblate (Catalan)
Currently translated at 100.0% (236 of 236 strings)

Co-authored-by: Eduardo Pastor Fernández <123eduardoneko123@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/ca/
Translation: Frigate NVR/common
2026-05-03 18:03:30 +02:00
Hosted Weblate
f6b084fdc7
Translated using Weblate (Japanese)
Currently translated at 81.1% (82 of 101 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: alpha <etc@alpha-line.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/ja/
Translation: Frigate NVR/components-dialog
2026-05-03 18:03:28 +02:00
Hosted Weblate
fe2c0e9f46
Translated using Weblate (Bosnian)
Currently translated at 10.0% (1 of 10 strings)

Translated using Weblate (Bosnian)

Currently translated at 9.0% (2 of 22 strings)

Translated using Weblate (Bosnian)

Currently translated at 3.4% (2 of 58 strings)

Translated using Weblate (Bosnian)

Currently translated at 2.0% (1 of 49 strings)

Translated using Weblate (Bosnian)

Currently translated at 8.0% (2 of 25 strings)

Translated using Weblate (Bosnian)

Currently translated at 0.8% (1 of 123 strings)

Translated using Weblate (Bosnian)

Currently translated at 0.3% (2 of 501 strings)

Translated using Weblate (Bosnian)

Currently translated at 20.0% (2 of 10 strings)

Translated using Weblate (Bosnian)

Currently translated at 4.2% (2 of 47 strings)

Translated using Weblate (Bosnian)

Currently translated at 100.0% (2 of 2 strings)

Translated using Weblate (Bosnian)

Currently translated at 2.0% (2 of 99 strings)

Translated using Weblate (Bosnian)

Currently translated at 1.9% (2 of 101 strings)

Translated using Weblate (Bosnian)

Currently translated at 0.2% (1 of 469 strings)

Translated using Weblate (Bosnian)

Currently translated at 2.3% (2 of 86 strings)

Translated using Weblate (Bosnian)

Currently translated at 0.1% (1 of 790 strings)

Translated using Weblate (Bosnian)

Currently translated at 5.0% (2 of 40 strings)

Translated using Weblate (Bosnian)

Currently translated at 50.0% (1 of 2 strings)

Translated using Weblate (Bosnian)

Currently translated at 0.8% (2 of 236 strings)

Translated using Weblate (Bosnian)

Currently translated at 3.1% (2 of 64 strings)

Translated using Weblate (Bosnian)

Currently translated at 1.3% (2 of 145 strings)

Translated using Weblate (Bosnian)

Currently translated at 1.3% (1 of 74 strings)

Translated using Weblate (Bosnian)

Currently translated at 1.1% (2 of 174 strings)

Translated using Weblate (Bosnian)

Currently translated at 33.3% (2 of 6 strings)

Translated using Weblate (Bosnian)

Currently translated at 7.6% (2 of 26 strings)

Translated using Weblate (Bosnian)

Currently translated at 0.1% (1 of 1081 strings)

Translated using Weblate (Bosnian)

Currently translated at 1.5% (2 of 129 strings)

Added translation using Weblate (Bosnian)

Added translation using Weblate (Bosnian)

Added translation using Weblate (Bosnian)

Added translation using Weblate (Bosnian)

Added translation using Weblate (Bosnian)

Added translation using Weblate (Bosnian)

Added translation using Weblate (Bosnian)

Added translation using Weblate (Bosnian)

Added translation using Weblate (Bosnian)

Added translation using Weblate (Bosnian)

Added translation using Weblate (Bosnian)

Added translation using Weblate (Bosnian)

Added translation using Weblate (Bosnian)

Added translation using Weblate (Bosnian)

Added translation using Weblate (Bosnian)

Added translation using Weblate (Bosnian)

Added translation using Weblate (Bosnian)

Added translation using Weblate (Bosnian)

Added translation using Weblate (Bosnian)

Added translation using Weblate (Bosnian)

Added translation using Weblate (Bosnian)

Added translation using Weblate (Bosnian)

Added translation using Weblate (Bosnian)

Added translation using Weblate (Bosnian)

Added translation using Weblate (Bosnian)

Added translation using Weblate (Bosnian)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: jasoisjaso <jaso.bih@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/audio/bs/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/bs/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-auth/bs/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-camera/bs/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/bs/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-filter/bs/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-icons/bs/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-input/bs/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/bs/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/bs/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/bs/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-groups/bs/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-validation/bs/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/objects/bs/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-chat/bs/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-classificationmodel/bs/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-configeditor/bs/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-events/bs/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-explore/bs/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-exports/bs/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-facelibrary/bs/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-live/bs/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-recording/bs/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-search/bs/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/bs/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/bs/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/Config - Groups
Translation: Frigate NVR/Config - Validation
Translation: Frigate NVR/audio
Translation: Frigate NVR/common
Translation: Frigate NVR/components-auth
Translation: Frigate NVR/components-camera
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/components-filter
Translation: Frigate NVR/components-icons
Translation: Frigate NVR/components-input
Translation: Frigate NVR/components-player
Translation: Frigate NVR/objects
Translation: Frigate NVR/views-chat
Translation: Frigate NVR/views-classificationmodel
Translation: Frigate NVR/views-configeditor
Translation: Frigate NVR/views-events
Translation: Frigate NVR/views-explore
Translation: Frigate NVR/views-exports
Translation: Frigate NVR/views-facelibrary
Translation: Frigate NVR/views-live
Translation: Frigate NVR/views-recording
Translation: Frigate NVR/views-search
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-05-03 18:03:26 +02:00
Hosted Weblate
f42307b8d9
Translated using Weblate (Estonian)
Currently translated at 17.5% (7 of 40 strings)

Added translation using Weblate (Estonian)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Priit Jõerüüt <jrthwlate@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-chat/et/
Translation: Frigate NVR/views-chat
2026-05-03 18:03:24 +02:00
200 changed files with 474 additions and 5060 deletions

5
.gitignore vendored
View File

@ -22,8 +22,3 @@ core
!/web/**/*.ts
.idea/*
.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

View File

@ -4,9 +4,6 @@ title: Installation
---
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.
@ -477,16 +474,6 @@ Finally, configure [hardware object detection](/configuration/object_detectors#a
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
services:
frigate:
@ -520,10 +507,6 @@ services:
environment:
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:

View File

@ -14,11 +14,9 @@
"@docusaurus/theme-mermaid": "^3.7.0",
"@inkeep/docusaurus": "^2.0.16",
"@mdx-js/react": "^3.1.0",
"@types/js-yaml": "^4.0.9",
"clsx": "^2.1.1",
"docusaurus-plugin-openapi-docs": "^4.5.1",
"docusaurus-theme-openapi-docs": "^4.5.1",
"js-yaml": "^4.1.1",
"prism-react-renderer": "^2.4.1",
"raw-loader": "^4.0.2",
"react": "^18.3.1",
@ -5749,11 +5747,6 @@
"@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": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@ -12890,7 +12883,7 @@
},
"node_modules/js-yaml": {
"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==",
"license": "MIT",
"dependencies": {

View File

@ -3,10 +3,9 @@
"version": "0.0.0",
"private": true,
"scripts": {
"build:config": "node scripts/build-config.mjs",
"docusaurus": "docusaurus",
"start": "npm run build:config && npm run regen-docs && docusaurus start --host 0.0.0.0",
"build": "npm run build:config && npm run regen-docs && docusaurus build",
"start": "npm run regen-docs && docusaurus start --host 0.0.0.0",
"build": "npm run regen-docs && docusaurus build",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
"clear": "docusaurus clear",
@ -24,11 +23,9 @@
"@docusaurus/theme-mermaid": "^3.7.0",
"@inkeep/docusaurus": "^2.0.16",
"@mdx-js/react": "^3.1.0",
"@types/js-yaml": "^4.0.9",
"clsx": "^2.1.1",
"docusaurus-plugin-openapi-docs": "^4.5.1",
"docusaurus-theme-openapi-docs": "^4.5.1",
"js-yaml": "^4.1.1",
"prism-react-renderer": "^2.4.1",
"raw-loader": "^4.0.2",
"react": "^18.3.1",

View File

@ -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!");

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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&apos;t specified a config file directory. You may want to modify the default path.</p>
</Admonition>
)}
{!mediaPath && (
<Admonition type="tip">
<p>You haven&apos;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&apos;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>
);
}

View File

@ -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>
);
}

View File

@ -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 &nbsp;|&nbsp; Multiple GPUs: 0,1,2
</p>
)}
</div>
)}
</div>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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

View File

@ -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";

View File

@ -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";
}

View File

@ -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");
}

View File

@ -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,
};
}

View File

@ -1 +0,0 @@
export { default } from "./DockerComposeGenerator";

View File

@ -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);
}

View File

@ -5997,10 +5997,7 @@ paths:
tags:
- App
summary: Start debug replay
description:
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.
description: Start a debug replay session from camera recordings.
operationId: start_debug_replay_debug_replay_start_post
requestBody:
required: true
@ -6009,16 +6006,12 @@ paths:
schema:
$ref: "#/components/schemas/DebugReplayStartBody"
responses:
"202":
"200":
description: Successful Response
content:
application/json:
schema:
$ref: "#/components/schemas/DebugReplayStartResponse"
"400":
description: Invalid camera, time range, or no recordings
"409":
description: A replay session is already active
"422":
description: Validation Error
content:
@ -6279,14 +6272,10 @@ components:
replay_camera:
type: string
title: Replay Camera
job_id:
type: string
title: Job Id
type: object
required:
- success
- replay_camera
- job_id
title: DebugReplayStartResponse
description: Response for starting a debug replay session.
DebugReplayStatusResponse:

View File

@ -10,7 +10,6 @@ from pydantic import BaseModel, Field
from frigate.api.auth import require_role
from frigate.api.defs.tags import Tags
from frigate.jobs.debug_replay import start_debug_replay_job
logger = logging.getLogger(__name__)
@ -30,17 +29,10 @@ class DebugReplayStartResponse(BaseModel):
success: bool
replay_camera: str
job_id: str
class DebugReplayStatusResponse(BaseModel):
"""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").
"""
"""Response for debug replay status."""
active: bool
replay_camera: str | None = None
@ -59,32 +51,15 @@ class DebugReplayStopResponse(BaseModel):
@router.post(
"/debug_replay/start",
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"]))],
summary="Start debug replay",
description="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.",
description="Start a debug replay session from camera recordings.",
)
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
try:
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:
if replay_manager.active:
return JSONResponse(
content={
"success": False,
@ -92,23 +67,38 @@ async def start_debug_replay(request: Request, body: DebugReplayStartBody):
},
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:
logger.exception("Rejected debug replay start request")
logger.exception("Invalid parameters for debug replay start request")
return JSONResponse(
content={
"success": False,
"message": "Invalid debug replay parameters",
"message": "Invalid debug replay request parameters",
},
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(
content={
"success": True,
"replay_camera": replay_manager.replay_camera_name,
"job_id": job_id,
},
status_code=202,
return DebugReplayStartResponse(
success=True,
replay_camera=replay_camera,
)
@ -128,16 +118,12 @@ def get_debug_replay_status(request: Request):
if replay_manager.active and replay_camera:
frame_processor = request.app.detected_frames_processor
frame = (
frame_processor.get_current_frame(replay_camera)
if frame_processor is not None
else None
)
frame = frame_processor.get_current_frame(replay_camera)
if frame is not None:
frame_time = frame_processor.get_current_frame_time(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:
retry_interval = float(camera_config.ffmpeg.retry_interval or 10)

View File

@ -174,10 +174,12 @@ async def latest_frame(
}
quality_params = get_image_quality_params(extension.value, params.quality)
camera_config = request.app.frigate_config.cameras.get(camera_name)
if camera_config is not None:
if camera_name in request.app.frigate_config.cameras:
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
if frame is None or datetime.now().timestamp() > (

View File

@ -1,13 +1,9 @@
"""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.
"""
"""Debug replay camera management for replaying recordings with detection overlays."""
import logging
import os
import shutil
import subprocess as sp
import threading
from ruamel.yaml import YAML
@ -25,7 +21,7 @@ from frigate.const import (
REPLAY_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.config import find_config_file
@ -33,14 +29,7 @@ logger = logging.getLogger(__name__)
class DebugReplayManager:
"""Owns the lifecycle pointers for 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.
"""
"""Manages a single debug replay session."""
def __init__(self) -> None:
self._lock = threading.Lock()
@ -52,66 +41,144 @@ class DebugReplayManager:
@property
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
def mark_starting(
def start(
self,
source_camera: str,
replay_camera_name: str,
start_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,
config_publisher: CameraConfigUpdatePublisher,
) -> None:
"""Build the in-memory replay camera config and publish the add event.
) -> str:
"""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]
camera_dict = self._build_camera_config_dict(
source_config, replay_name, clip_path
)
# Build an in-memory config with the replay camera added
config_file = find_config_file()
yaml_parser = YAML()
with open(config_file, "r") as f:
@ -124,48 +191,75 @@ class DebugReplayManager:
try:
new_config = FrigateConfig.parse_object(config_data)
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]
# Publish the add event
config_publisher.publish_update(
CameraConfigUpdateTopic(CameraConfigUpdateEnum.add, 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(
self,
frigate_config: FrigateConfig,
config_publisher: CameraConfigUpdatePublisher,
) -> 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:
if not self.active:
logger.warning("No active replay session to stop")
return
self._stop_locked(frigate_config, config_publisher)
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
# 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],
)
replay_name = self.replay_camera_name
if replay_name is not None:
self._cleanup_db(replay_name)
self._cleanup_files(replay_name)
# Publish remove event so subscribers stop and remove from their config
if replay_name in frigate_config.cameras:
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(
self,
@ -173,7 +267,16 @@ class DebugReplayManager:
replay_name: str,
clip_path: str,
) -> 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)
detect_dict = source_config.detect.model_dump(
exclude={"min_initialized", "max_disappeared", "enabled_in_config"}
@ -208,6 +311,7 @@ class DebugReplayManager:
zone_dump = zone_config.model_dump(
exclude={"contour", "color"}, exclude_defaults=True
)
# Always include required fields
zone_dump.setdefault("coordinates", zone_config.coordinates)
zones_dict[zone_name] = zone_dump

View File

@ -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()

View File

@ -23,13 +23,13 @@ from frigate.const import (
EXPORT_DIR,
MAX_PLAYLIST_SECONDS,
PREVIEW_FRAME_TYPE,
PROCESS_PRIORITY_LOW,
)
from frigate.ffmpeg_presets import (
EncodeTypeEnum,
parse_preset_hardware_acceleration_encode,
)
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
logger = logging.getLogger(__name__)
@ -243,29 +243,107 @@ class RecordingExporter(threading.Thread):
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(
self,
ffmpeg_cmd: list[str],
playlist_lines: str | list[str],
step: str = "encoding",
) -> 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):
stdin_payload = "\n".join(playlist_lines)
else:
stdin_payload = playlist_lines
return run_ffmpeg_with_progress(
ffmpeg_cmd,
expected_duration_seconds=self._expected_output_duration_seconds(),
on_progress=lambda percent: self._emit_progress(step, percent),
stdin_payload=stdin_payload,
use_low_priority=True,
expected_duration = self._expected_output_duration_seconds()
self._emit_progress(step, 0.0)
proc = sp.Popen(
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:
# return in iso format using the configured ui.timezone when set,
# so the auto-generated export name reflects local time rather
@ -342,7 +420,6 @@ class RecordingExporter(threading.Thread):
return None
total_output = windows[-1][2] + (windows[-1][1] - windows[-1][0])
last_recorded_end = windows[-1][1]
def wall_to_output(t: float) -> float:
t = max(float(self.start_time), min(float(self.end_time), t))
@ -355,18 +432,8 @@ class RecordingExporter(threading.Thread):
chapter_blocks: list[str] = []
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))
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
# too short to be navigable in a player.
@ -449,14 +516,16 @@ class RecordingExporter(threading.Thread):
except DoesNotExist:
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 = [
"/usr/lib/ffmpeg/7.0/bin/ffmpeg", # hardcode path for exports thumbnail due to missing libwebp support
"-hide_banner",
"-loglevel",
"warning",
"-ss",
f"{diff:.3f}",
f"00:{minutes}:{seconds}",
"-i",
preview.path,
"-frames",

View File

@ -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"])

View File

@ -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()

View File

@ -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()

View File

@ -14,7 +14,6 @@ from frigate.jobs.export import (
)
from frigate.record.export import PlaybackSourceEnum, RecordingExporter
from frigate.types import JobStatusTypesEnum
from frigate.util.ffmpeg import inject_progress_flags
def _make_exporter(
@ -119,9 +118,10 @@ class TestExpectedOutputDuration(unittest.TestCase):
class TestProgressFlagInjection(unittest.TestCase):
def test_inserts_before_output_path(self) -> None:
exporter = _make_exporter()
cmd = ["ffmpeg", "-i", "input.m3u8", "-c", "copy", "/tmp/output.mp4"]
result = inject_progress_flags(cmd)
result = exporter._inject_progress_flags(cmd)
assert result == [
"ffmpeg",
@ -136,7 +136,8 @@ class TestProgressFlagInjection(unittest.TestCase):
]
def test_handles_empty_cmd(self) -> None:
assert inject_progress_flags([]) == []
exporter = _make_exporter()
assert exporter._inject_progress_flags([]) == []
class TestFfmpegProgressParsing(unittest.TestCase):
@ -166,7 +167,7 @@ class TestFfmpegProgressParsing(unittest.TestCase):
fake_proc.returncode = 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(
["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
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__":
unittest.main()

View File

@ -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)

View File

@ -2,9 +2,8 @@
import logging
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
@ -47,124 +46,3 @@ def start_or_restart_ffmpeg(
start_new_session=True,
)
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)

View File

@ -31,7 +31,7 @@ test.describe("Replay — no active session @medium", () => {
await expect(
frigateApp.page.getByRole("heading", {
level: 2,
name: /No Active Debug Replay Session/i,
name: /No Active Replay Session/i,
}),
).toBeVisible({ timeout: 10_000 });
const goButton = frigateApp.page.getByRole("button", {
@ -48,7 +48,7 @@ test.describe("Replay — no active session @medium", () => {
await expect(
frigateApp.page.getByRole("heading", {
level: 2,
name: /No Active Debug Replay Session/i,
name: /No Active Replay Session/i,
}),
).toBeVisible({ timeout: 10_000 });
await frigateApp.page
@ -297,7 +297,7 @@ test.describe("Replay — mobile @medium @mobile", () => {
await expect(
frigateApp.page.getByRole("heading", {
level: 2,
name: /No Active Debug Replay Session/i,
name: /No Active Replay Session/i,
}),
).toBeVisible({ timeout: 10_000 });
});

View File

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
{}

View File

@ -1,503 +1,4 @@
{
"speech": "Govor",
"babbling": "Babavljanje",
"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"
"babbling": "Mrmljanje"
}

View File

@ -1,326 +1,6 @@
{
"time": {
"untilForTime": "Do {{time}}",
"untilForRestart": "Do ponovnog pokretanja Frigate.",
"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"
"untilForRestart": "Dok se Frigate ponovo ne pokrene."
}
}

View File

@ -1,16 +1,5 @@
{
"form": {
"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."
}
"user": "Korisničko ime"
}
}

View File

@ -1,87 +1,6 @@
{
"group": {
"label": "Grupe kamere",
"add": "Dodaj grupu kamere",
"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"
"label": "Grupe kamera",
"add": "Dodaj Grupu Kamera"
}
}

View File

@ -1,8 +1,5 @@
{
"iconPicker": {
"selectIcon": "Odaberite ikonu",
"search": {
"placeholder": "Pretražite ikonu…"
}
"selectIcon": "Izaberite ikonu"
}
}

View File

@ -1,28 +1,3 @@
{
"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"
"person": "Osoba"
}

View File

@ -1,5 +1,4 @@
{
"alerts": "Obavještenja",
"detections": "Detekcije",
"camera": "Kamera"
"detections": "Detekcije"
}

View File

@ -1 +0,0 @@
{}

View File

@ -1,5 +1,4 @@
{
"filter": "Filter",
"export": "Izvoz",
"calendar": "Kalendar"
"export": "Izvoz"
}

View File

@ -1 +0,0 @@
{}

View File

@ -1,6 +1,3 @@
{
"search": "Pretraga",
"button": {
"save": "Sačuvaj pretragu"
}
"search": "Pretraga"
}

View File

@ -1,9 +1,5 @@
{
"documentTitle": {
"default": "Postavke - Frigate"
},
"menu": {
"system": "Sistem",
"profiles": "Profili"
}
}

View File

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
{}

View File

@ -19,31 +19,26 @@
"startLabel": "Start",
"endLabel": "End",
"toast": {
"success": "Debug replay started successfully",
"error": "Failed to start debug replay: {{error}}",
"alreadyActive": "A replay session is already active",
"stopped": "Debug replay stopped",
"stopError": "Failed to stop debug replay: {{error}}",
"goToReplay": "Go to Replay"
}
},
"page": {
"noSession": "No Active Debug Replay Session",
"noSessionDesc": "Start a Debug Replay from History view by clicking the Actions button in the toolbar and choosing Debug Replay.",
"noSession": "No Active Replay Session",
"noSessionDesc": "Start a debug replay from the History view by clicking the Debug Replay button in the toolbar.",
"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",
"replayCamera": "Replay Camera",
"initializingReplay": "Initializing Debug Replay...",
"stoppingReplay": "Stopping Debug Replay...",
"initializingReplay": "Initializing replay...",
"stoppingReplay": "Stopping replay...",
"stopReplay": "Stop Replay",
"confirmStop": {
"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",
"cancel": "Cancel"
},
@ -54,6 +49,6 @@
"activeTracking": "Active tracking",
"noActiveTracking": "No active tracking",
"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."
}
}

View File

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
{}

View File

@ -11,7 +11,7 @@
"laughter": "Smijeh",
"train": "Vlak",
"snicker": "Smješkanje",
"boat": "Brod",
"boat": "ÄŒamac",
"crying": "Plakanje",
"singing": "Pjevanje",
"choir": "Zbor",
@ -350,7 +350,7 @@
"microwave_oven": "Mikrovalna pećnica",
"water_tap": "Vodovodna slavina",
"bathtub": "Kada",
"toilet_flush": "Ispiranje toaleta",
"toilet_flush": "Ispiranje WC-a",
"electric_toothbrush": "Električna četkica za zube",
"vacuum_cleaner": "Usisavač",
"zipper": "Patentni zatvarač",

View File

@ -22,11 +22,11 @@
"pm": "pm",
"am": "am",
"ago": "prije {{timeAgo}}",
"yr": "{{time}}g",
"yr": "{{time}}g.",
"year_one": "{{time}} godina",
"year_few": "{{time}} godine",
"year_other": "{{time}} godina",
"mo": "{{time}}mj",
"mo": "{{time}}mj.",
"month_one": "{{time}} mjesec",
"month_few": "{{time}} mjeseca",
"month_other": "{{time}} mjeseci",

View File

@ -6,7 +6,7 @@
"airplane": "Zrakoplov",
"bus": "Autobus",
"train": "Vlak",
"boat": "Brod",
"boat": "ÄŒamac",
"traffic_light": "Semafor",
"fire_hydrant": "Hidrant",
"street_sign": "Prometni znak",

View File

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
{}

View File

@ -274,8 +274,7 @@
"export": "Exportálás",
"deleteNow": "Törlés Most",
"next": "Következő",
"continue": "Tovább",
"add": "Hozzáad"
"continue": "Tovább"
},
"label": {
"back": "Vissza",

View File

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
{}

Some files were not shown because too many files have changed in this diff Show More