Compare commits

...

5 Commits

Author SHA1 Message Date
dependabot[bot]
5a6f720888
Merge 5d0d89a3f2 into 37ea6b46b5 2026-06-18 22:56:12 -04:00
Josh Hawkins
37ea6b46b5
small docs tweaks (#23506) 2026-06-18 12:44:04 -06:00
Josh Hawkins
8203e39b7f
add go2rtc settings section to the save all flow (#23501)
Some checks failed
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
2026-06-17 08:10:23 -06:00
Josh Hawkins
282e70d4bf
Add go2rtc stream selection to camera configuration (#23496)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* add go2rtc stream selection to camera ffmpeg config

* i18n

* add config-schema.json to generated e2e mock data

* e2e test

* docs

* fix test
2026-06-16 16:12:39 -06:00
dependabot[bot]
5d0d89a3f2
Bump mermaid from 11.12.2 to 11.15.0 in /docs
Bumps [mermaid](https://github.com/mermaid-js/mermaid) from 11.12.2 to 11.15.0.
- [Release notes](https://github.com/mermaid-js/mermaid/releases)
- [Commits](https://github.com/mermaid-js/mermaid/compare/mermaid@11.12.2...mermaid@11.15.0)

---
updated-dependencies:
- dependency-name: mermaid
  dependency-version: 11.15.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-11 22:56:50 +00:00
14 changed files with 930 additions and 289 deletions

View File

@ -30,6 +30,8 @@ Navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" /
| ----------------- | ------------------------------------------------------------------- |
| **Camera inputs** | List of input stream definitions (paths and roles) for this camera. |
For each input you can choose its source: select **Restream (go2rtc)** to pick an existing [go2rtc stream](restream.md) from a dropdown (Frigate uses the `rtsp://127.0.0.1:8554/<stream>` path and `preset-rtsp-restream` input args for that input automatically), or **Manual input path** to type the stream URL directly.
Navigate to <NavPath path="Settings > Camera configuration > Object detection" />.
| Field | Description |

View File

@ -432,3 +432,5 @@ When your browser runs into problems playing back your camera streams, it will l
roles:
- detect
```
The same applies to your `record` stream: if its aspect ratio differs from your `detect` stream, your recordings will appear in a different shape than the live view. For consistent framing across live view and recordings, use the same aspect ratio for all of a camera's streams (the resolution can still differ).

View File

@ -61,7 +61,7 @@ Configure the go2rtc stream and point the camera inputs at the local restream.
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > go2rtc streams" /> and add stream entries for each camera. Then navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" /> for each camera and set the input paths to use the local restream URL (`rtsp://127.0.0.1:8554/<camera_name>`).
Navigate to <NavPath path="Settings > System > go2rtc streams" /> and add stream entries for each camera. Then navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" /> for each camera. For each input, choose **Restream (go2rtc)** and pick the matching stream from the dropdown — Frigate uses the local restream URL (`rtsp://127.0.0.1:8554/<camera_name>`) and the `preset-rtsp-restream` input args for that input automatically. (Choose **Manual input path** instead to type a URL directly.)
</TabItem>
<TabItem value="yaml">
@ -111,7 +111,7 @@ Two connections are made to the camera. One for the sub stream, one for the rest
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > go2rtc streams" /> and add stream entries for each camera and its sub stream. Then navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" /> for each camera and configure separate inputs for the main and sub streams using the local restream URLs.
Navigate to <NavPath path="Settings > System > go2rtc streams" /> and add stream entries for each camera and its sub stream. Then navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" /> for each camera and add separate inputs for the main and sub streams. Set each input's source to **Restream (go2rtc)** and pick the matching stream from the dropdown — Frigate uses the local restream URL and the `preset-rtsp-restream` input args for that input automatically.
</TabItem>
<TabItem value="yaml">

View File

@ -10,13 +10,14 @@ A reverse proxy is typically needed if you want to set up Frigate on a custom UR
Before setting up a reverse proxy, check if any of the built-in functionality in Frigate suits your needs:
|Topic|Docs|
|-|-|
|TLS|Please see the `tls` [configuration option](../configuration/tls.md)|
|TLS|Please see the `tls` [configuration option](../configuration/tls.md)|
|Authentication|Please see the [authentication](../configuration/authentication.md) documentation|
|IPv6|[Enabling IPv6](../configuration/advanced/system.md#enabling-ipv6)
**Note about TLS**
When using a reverse proxy, the TLS session is usually terminated at the proxy, sending the internal request over plain HTTP. If this is the desired behavior, TLS must first be disabled in Frigate, or you will encounter an HTTP 400 error: "The plain HTTP request was sent to HTTPS port."
**Note about TLS**
When using a reverse proxy, the TLS session is usually terminated at the proxy, sending the internal request over plain HTTP. If this is the desired behavior, TLS must first be disabled in Frigate, or you will encounter an HTTP 400 error: "The plain HTTP request was sent to HTTPS port."
To disable TLS, set the following in your Frigate configuration:
```yml
tls:
enabled: false
@ -24,18 +25,26 @@ tls:
:::warning
A reverse proxy can be used to secure access to an internal web server, but the user will be entirely reliant on the steps they have taken. You must ensure you are following security best practices.
This page does not attempt to outline the specific steps needed to secure your internal website.
This page does not attempt to outline the specific steps needed to secure your internal website.
Please use your own knowledge to assess and vet the reverse proxy software before you install anything on your system.
:::
## WebSocket support
Frigate relies on WebSockets for real-time communication between the browser and the backend. Features such as camera controls (enabling/disabling a camera, audio, detect, recordings, and other toggles), live stream playback, and other live-updating parts of the UI will not function correctly if WebSocket connections are not proxied.
Your reverse proxy must be configured to forward the `Upgrade` and `Connection` headers so that WebSocket connections can be established. Each proxy example below already includes the directives needed to do this, but if you are adapting your own configuration, ensure these headers are passed through.
Note that some proxies disable WebSocket support by default — for example, Nginx Proxy Manager has a "Websockets Support" toggle that must be enabled.
## Proxies
There are many solutions available to implement reverse proxies and the community is invited to help out documenting others through a contribution to this page.
* [Apache2](#apache2-reverse-proxy)
* [Nginx](#nginx-reverse-proxy)
* [Traefik](#traefik-reverse-proxy)
* [Caddy](#caddy-reverse-proxy)
- [Apache2](#apache2-reverse-proxy)
- [Nginx](#nginx-reverse-proxy)
- [Traefik](#traefik-reverse-proxy)
- [Caddy](#caddy-reverse-proxy)
## Apache2 Reverse Proxy
@ -159,7 +168,7 @@ The settings below enabled connection upgrade, sets up logging (optional) and pr
## Traefik Reverse Proxy
This example shows how to add a `label` to the Frigate Docker compose file, enabling Traefik to automatically discover your Frigate instance.
This example shows how to add a `label` to the Frigate Docker compose file, enabling Traefik to automatically discover your Frigate instance.
Before using the example below, you must first set up Traefik with the [Docker provider](https://doc.traefik.io/traefik/providers/docker/)
```yml
@ -203,7 +212,7 @@ This example shows Frigate running under a subdomain with logging and a tls cert
}
frigate.YOUR_DOMAIN.TLD {
reverse_proxy http://localhost:8971
reverse_proxy http://localhost:8971
import tls
import logging frigate.YOUR_DOMAIN.TLD
}

215
docs/package-lock.json generated
View File

@ -2072,43 +2072,10 @@
"integrity": "sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==",
"license": "MIT"
},
"node_modules/@chevrotain/cst-dts-gen": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.0.3.tgz",
"integrity": "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==",
"license": "Apache-2.0",
"dependencies": {
"@chevrotain/gast": "11.0.3",
"@chevrotain/types": "11.0.3",
"lodash-es": "4.17.21"
}
},
"node_modules/@chevrotain/gast": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.0.3.tgz",
"integrity": "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==",
"license": "Apache-2.0",
"dependencies": {
"@chevrotain/types": "11.0.3",
"lodash-es": "4.17.21"
}
},
"node_modules/@chevrotain/regexp-to-ast": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.0.3.tgz",
"integrity": "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==",
"license": "Apache-2.0"
},
"node_modules/@chevrotain/types": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.0.3.tgz",
"integrity": "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==",
"license": "Apache-2.0"
},
"node_modules/@chevrotain/utils": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.0.3.tgz",
"integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==",
"version": "11.1.2",
"resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz",
"integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==",
"license": "Apache-2.0"
},
"node_modules/@colors/colors": {
@ -4504,12 +4471,12 @@
}
},
"node_modules/@mermaid-js/parser": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.6.3.tgz",
"integrity": "sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.1.tgz",
"integrity": "sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==",
"license": "MIT",
"dependencies": {
"langium": "3.3.1"
"@chevrotain/types": "~11.1.1"
}
},
"node_modules/@nodelib/fs.scandir": {
@ -5995,6 +5962,16 @@
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
"license": "ISC"
},
"node_modules/@upsetjs/venn.js": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz",
"integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==",
"license": "MIT",
"optionalDependencies": {
"d3-selection": "^3.0.0",
"d3-transition": "^3.0.1"
}
},
"node_modules/@vercel/oidc": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.0.5.tgz",
@ -7199,32 +7176,6 @@
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/chevrotain": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz",
"integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==",
"license": "Apache-2.0",
"dependencies": {
"@chevrotain/cst-dts-gen": "11.0.3",
"@chevrotain/gast": "11.0.3",
"@chevrotain/regexp-to-ast": "11.0.3",
"@chevrotain/types": "11.0.3",
"@chevrotain/utils": "11.0.3",
"lodash-es": "4.17.21"
}
},
"node_modules/chevrotain-allstar": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.3.1.tgz",
"integrity": "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==",
"license": "MIT",
"dependencies": {
"lodash-es": "^4.17.21"
},
"peerDependencies": {
"chevrotain": "^11.0.0"
}
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@ -8560,9 +8511,9 @@
}
},
"node_modules/d3-format": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
@ -8796,9 +8747,9 @@
}
},
"node_modules/dagre-d3-es": {
"version": "7.0.13",
"resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.13.tgz",
"integrity": "sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==",
"version": "7.0.14",
"resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz",
"integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==",
"license": "MIT",
"dependencies": {
"d3": "^7.9.0",
@ -8973,9 +8924,9 @@
}
},
"node_modules/delaunator": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz",
"integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==",
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz",
"integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==",
"license": "ISC",
"dependencies": {
"robust-predicates": "^3.0.2"
@ -10499,6 +10450,16 @@
"node": ">= 0.4"
}
},
"node_modules/es-toolkit": {
"version": "1.46.1",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz",
"integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/es6-promise": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.3.1.tgz",
@ -13058,22 +13019,6 @@
"node": ">=6"
}
},
"node_modules/langium": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/langium/-/langium-3.3.1.tgz",
"integrity": "sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==",
"license": "MIT",
"dependencies": {
"chevrotain": "~11.0.3",
"chevrotain-allstar": "~0.3.0",
"vscode-languageserver": "~9.0.1",
"vscode-languageserver-textdocument": "~1.0.11",
"vscode-uri": "~3.0.8"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/latest-version": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/latest-version/-/latest-version-7.0.0.tgz",
@ -13190,9 +13135,9 @@
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT"
},
"node_modules/lodash.debounce": {
@ -13840,31 +13785,32 @@
}
},
"node_modules/mermaid": {
"version": "11.12.2",
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.12.2.tgz",
"integrity": "sha512-n34QPDPEKmaeCG4WDMGy0OT6PSyxKCfy2pJgShP+Qow2KLrvWjclwbc3yXfSIf4BanqWEhQEpngWwNp/XhZt6w==",
"version": "11.15.0",
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.15.0.tgz",
"integrity": "sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==",
"license": "MIT",
"dependencies": {
"@braintree/sanitize-url": "^7.1.1",
"@iconify/utils": "^3.0.1",
"@mermaid-js/parser": "^0.6.3",
"@iconify/utils": "^3.0.2",
"@mermaid-js/parser": "^1.1.1",
"@types/d3": "^7.4.3",
"cytoscape": "^3.29.3",
"@upsetjs/venn.js": "^2.0.0",
"cytoscape": "^3.33.1",
"cytoscape-cose-bilkent": "^4.1.0",
"cytoscape-fcose": "^2.2.0",
"d3": "^7.9.0",
"d3-sankey": "^0.12.3",
"dagre-d3-es": "7.0.13",
"dayjs": "^1.11.18",
"dompurify": "^3.2.5",
"katex": "^0.16.22",
"dagre-d3-es": "7.0.14",
"dayjs": "^1.11.19",
"dompurify": "^3.3.1",
"es-toolkit": "^1.45.1",
"katex": "^0.16.25",
"khroma": "^2.1.0",
"lodash-es": "^4.17.21",
"marked": "^16.2.1",
"marked": "^16.3.0",
"roughjs": "^4.6.6",
"stylis": "^4.3.6",
"ts-dedent": "^2.2.0",
"uuid": "^11.1.0"
"uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0"
}
},
"node_modules/methods": {
@ -20210,9 +20156,9 @@
}
},
"node_modules/robust-predicates": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",
"integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
"integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==",
"license": "Unlicense"
},
"node_modules/roughjs": {
@ -22561,55 +22507,6 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/vscode-jsonrpc": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
"integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/vscode-languageserver": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz",
"integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==",
"license": "MIT",
"dependencies": {
"vscode-languageserver-protocol": "3.17.5"
},
"bin": {
"installServerIntoExtension": "bin/installServerIntoExtension"
}
},
"node_modules/vscode-languageserver-protocol": {
"version": "3.17.5",
"resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz",
"integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==",
"license": "MIT",
"dependencies": {
"vscode-jsonrpc": "8.2.0",
"vscode-languageserver-types": "3.17.5"
}
},
"node_modules/vscode-languageserver-textdocument": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz",
"integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==",
"license": "MIT"
},
"node_modules/vscode-languageserver-types": {
"version": "3.17.5",
"resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
"integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==",
"license": "MIT"
},
"node_modules/vscode-uri": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz",
"integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==",
"license": "MIT"
},
"node_modules/warning": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",

File diff suppressed because one or more lines are too long

View File

@ -111,6 +111,18 @@ def generate_config():
return snapshot
def generate_config_schema():
"""Generate the JSON Schema for FrigateConfig from the backend model.
This is what the app fetches from /api/config/schema.json to drive the
RJSF-based config form. Generating it here keeps the e2e fixture in sync
with the backend whenever config models change.
"""
from frigate.config import FrigateConfig
return FrigateConfig.model_json_schema()
def generate_reviews():
"""Generate ReviewSegmentResponse[] validated against Pydantic + Peewee."""
from frigate.api.defs.response.review_response import ReviewSegmentResponse
@ -411,6 +423,7 @@ def main():
print()
write_json("config-snapshot.json", generate_config())
write_json("config-schema.json", generate_config_schema())
write_json("reviews.json", generate_reviews())
write_json("events.json", generate_events())
write_json("exports.json", generate_exports())

View File

@ -0,0 +1,203 @@
/**
* Camera ffmpeg streams settings tests -- MEDIUM tier.
*
* Covers the input-path source toggle: each ffmpeg input can either point at a
* go2rtc restream (picked from a dropdown, which writes the rtsp://127.0.0.1:8554
* path plus the preset-rtsp-restream input_args) or use a manually typed path.
*/
import { readFileSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { test, expect } from "../../fixtures/frigate-test";
import type { Page } from "@playwright/test";
import { configFactory } from "../../fixtures/mock-data/config";
const __dirname = dirname(fileURLToPath(import.meta.url));
const CONFIG_SCHEMA = JSON.parse(
readFileSync(
resolve(__dirname, "../../fixtures/mock-data/config-schema.json"),
"utf-8",
),
);
const GO2RTC_STREAMS = {
dome_main: ["rtsp://user:pass@192.168.0.20:554/Stream1"],
dome_sub: ["rtsp://user:pass@192.168.0.20:554/Stream2"],
};
type CameraInput = {
path: string;
roles: string[];
input_args?: string;
};
async function installRoutes(page: Page, frontDoorInputs: CameraInput[]) {
const config = configFactory({
go2rtc: { streams: GO2RTC_STREAMS },
cameras: {
front_door: {
ffmpeg: { inputs: frontDoorInputs },
},
},
});
let lastSavedConfig: unknown = null;
await page.route("**/api/config/schema.json", (route) =>
route.fulfill({ json: CONFIG_SCHEMA }),
);
await page.route("**/api/config", (route) => {
if (route.request().method() === "GET") {
return route.fulfill({ json: config });
}
return route.fulfill({ json: { success: true } });
});
await page.route("**/api/config/raw_paths", (route) =>
route.fulfill({
json: {
cameras: { front_door: { ffmpeg: { inputs: frontDoorInputs } } },
go2rtc: { streams: GO2RTC_STREAMS },
},
}),
);
await page.route("**/api/config/set", async (route) => {
lastSavedConfig = route.request().postDataJSON();
await route.fulfill({ json: { success: true, require_restart: false } });
});
await page.route("**/api/ffmpeg/presets", (route) =>
route.fulfill({
json: {
hwaccel_args: [],
input_args: ["preset-rtsp-restream", "preset-rtsp-generic"],
output_args: { record: [], detect: [] },
},
}),
);
return { capturedConfig: () => lastSavedConfig };
}
const RESTREAM_RADIO = "Restream (go2rtc)";
const MANUAL_RADIO = "Manual input path";
test.describe("camera ffmpeg input source toggle @medium", () => {
test("manual input defaults to the manual text field", async ({
frigateApp,
}) => {
await installRoutes(frigateApp.page, [
{ path: "rtsp://10.0.0.1:554/video", roles: ["detect"] },
]);
await frigateApp.goto("/settings?page=cameraFfmpeg&camera=front_door");
await expect(
frigateApp.page.getByRole("radio", { name: MANUAL_RADIO }),
).toBeChecked();
await expect(
frigateApp.page.getByRole("textbox", { name: "Input path" }),
).toHaveValue("rtsp://10.0.0.1:554/video");
});
test("an existing restream path auto-detects into restream mode", async ({
frigateApp,
}) => {
await installRoutes(frigateApp.page, [
{
path: "rtsp://127.0.0.1:8554/dome_main",
roles: ["detect"],
input_args: "preset-rtsp-restream",
},
]);
await frigateApp.goto("/settings?page=cameraFfmpeg&camera=front_door");
await expect(
frigateApp.page.getByRole("radio", { name: RESTREAM_RADIO }),
).toBeChecked();
// The dropdown is preselected to the matching go2rtc stream.
await expect(
frigateApp.page.getByRole("combobox", { name: /go2rtc stream/i }),
).toContainText("dome_main");
});
test("selecting a restream writes the path and preset", async ({
frigateApp,
}) => {
const capture = await installRoutes(frigateApp.page, [
{ path: "rtsp://10.0.0.1:554/video", roles: ["detect"] },
]);
await frigateApp.goto("/settings?page=cameraFfmpeg&camera=front_door");
await frigateApp.page.getByRole("radio", { name: RESTREAM_RADIO }).click();
await frigateApp.page
.getByRole("combobox", { name: /go2rtc stream/i })
.click();
// The dropdown is searchable: typing narrows the list to matches only,
// with no option to enter a custom stream name.
await frigateApp.page.getByPlaceholder("Search streams...").fill("sub");
await expect(
frigateApp.page.getByRole("option", { name: "dome_main" }),
).toBeHidden();
await frigateApp.page.getByRole("option", { name: "dome_sub" }).click();
await frigateApp.page.getByRole("button", { name: "Save" }).click();
await expect
.poll(() => capture.capturedConfig(), { timeout: 5_000 })
.toMatchObject({
config_data: {
cameras: {
front_door: {
ffmpeg: {
inputs: [
{
path: "rtsp://127.0.0.1:8554/dome_sub",
input_args: "preset-rtsp-restream",
},
],
},
},
},
},
});
});
test("switching a restream back to manual reverts the preset", async ({
frigateApp,
}) => {
const capture = await installRoutes(frigateApp.page, [
{
path: "rtsp://127.0.0.1:8554/dome_main",
roles: ["detect"],
input_args: "preset-rtsp-restream",
},
]);
await frigateApp.goto("/settings?page=cameraFfmpeg&camera=front_door");
await frigateApp.page.getByRole("radio", { name: MANUAL_RADIO }).click();
// The restream path stays editable in the manual text field.
await expect(
frigateApp.page.getByRole("textbox", { name: "Input path" }),
).toHaveValue("rtsp://127.0.0.1:8554/dome_main");
await frigateApp.page.getByRole("button", { name: "Save" }).click();
await expect
.poll(() => capture.capturedConfig(), { timeout: 5_000 })
.not.toBeNull();
const payload = capture.capturedConfig() as {
config_data?: {
cameras?: {
front_door?: {
ffmpeg?: { inputs?: Array<{ input_args?: unknown }> };
};
};
};
};
const input =
payload?.config_data?.cameras?.front_door?.ffmpeg?.inputs?.[0];
expect(input?.input_args).not.toBe("preset-rtsp-restream");
});
});

View File

@ -1553,7 +1553,17 @@
}
},
"cameraInputs": {
"itemTitle": "Stream {{index}}"
"itemTitle": "Stream {{index}}",
"sourceMode": {
"restream": "Restream (go2rtc)",
"manual": "Manual input path",
"go2rtcStreamLabel": "go2rtc stream",
"go2rtcStreamPlaceholder": "Select a go2rtc stream",
"noGo2rtcStreams": "No go2rtc streams configured",
"go2rtcStreamSearch": "Search streams...",
"availableStreams": "Available streams",
"noMatchingStreams": "No matching streams"
}
},
"restartRequiredField": "Restart required",
"restartRequiredFooter": "Configuration changed - Restart required",

View File

@ -29,11 +29,19 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { StreamSourceSelector } from "./StreamSourceSelector";
import {
buildRestreamPath,
parseRestreamStreamName,
RESTREAM_PRESET,
type StreamSourceMode,
} from "./streamSource";
type FfmpegInput = {
path?: string;
roles?: string[];
hwaccel_args?: unknown;
input_args?: unknown;
};
const asInputList = (formData: unknown): FfmpegInput[] => {
@ -137,7 +145,30 @@ export function CameraInputsField(props: FieldProps) {
);
const SchemaField = registry.fields.SchemaField;
const go2rtcStreamNames = useMemo<string[]>(() => {
const streams = formContext?.fullConfig?.go2rtc?.streams;
if (!streams || typeof streams !== "object") {
return [];
}
return Object.keys(streams).sort();
}, [formContext?.fullConfig?.go2rtc?.streams]);
const [openByIndex, setOpenByIndex] = useState<Record<number, boolean>>({});
const [sourceModeByIndex, setSourceModeByIndex] = useState<
Record<number, StreamSourceMode>
>({});
// Detect whether an existing input path points at a known go2rtc restream so
// the source toggle can default to the right mode for existing configs.
const detectMode = useCallback(
(path: string | undefined): StreamSourceMode => {
const streamName = parseRestreamStreamName(path);
return streamName && go2rtcStreamNames.includes(streamName)
? "restream"
: "manual";
},
[go2rtcStreamNames],
);
useEffect(() => {
setOpenByIndex((previous) => {
@ -171,6 +202,55 @@ export function CameraInputsField(props: FieldProps) {
[fieldPathId.path, inputs, onChange],
);
// Update several fields of one input in a single change so that path and
// input_args never race on a stale snapshot of inputs.
const handleFieldValuesChange = useCallback(
(index: number, partial: Record<string, unknown>) => {
const nextInputs = cloneDeep(inputs);
const item =
(nextInputs[index] as Record<string, unknown> | undefined) ??
({} as Record<string, unknown>);
Object.assign(item, partial);
nextInputs[index] = item;
onChange(normalizeNonDetectHwaccel(nextInputs), fieldPathId.path);
},
[fieldPathId.path, inputs, onChange],
);
const handleSourceModeChange = useCallback(
(index: number, nextMode: StreamSourceMode) => {
const input = inputs[index];
const currentPath =
typeof input?.path === "string" ? input.path : undefined;
if (nextMode === "manual") {
// Only revert the preset we set ourselves; never clobber custom args.
if (input?.input_args === RESTREAM_PRESET) {
handleFieldValuesChange(index, { input_args: undefined });
}
} else if (!parseRestreamStreamName(currentPath)) {
// Entering restream with a non-restream path: clear it so the dropdown
// shows its placeholder until a stream is chosen.
handleFieldValuesChange(index, { path: undefined });
}
setSourceModeByIndex((previous) => ({ ...previous, [index]: nextMode }));
},
[inputs, handleFieldValuesChange],
);
const handleSelectRestreamStream = useCallback(
(index: number, streamName: string) => {
handleFieldValuesChange(index, {
path: buildRestreamPath(streamName),
input_args: RESTREAM_PRESET,
});
},
[handleFieldValuesChange],
);
const handleAddInput = useCallback(() => {
const base = itemSchema
? (applySchemaDefaults(itemSchema) as FfmpegInput)
@ -186,8 +266,9 @@ export function CameraInputsField(props: FieldProps) {
(_, currentIndex) => currentIndex !== index,
);
onChange(nextInputs, fieldPathId.path);
setOpenByIndex((previous) => {
const next: Record<number, boolean> = {};
const reindex = <T,>(previous: Record<number, T>): Record<number, T> => {
const next: Record<number, T> = {};
Object.entries(previous).forEach(([key, value]) => {
const current = Number(key);
if (Number.isNaN(current) || current === index) {
@ -197,7 +278,10 @@ export function CameraInputsField(props: FieldProps) {
next[current > index ? current - 1 : current] = value;
});
return next;
});
};
setOpenByIndex(reindex);
setSourceModeByIndex(reindex);
},
[fieldPathId.path, inputs, onChange],
);
@ -354,16 +438,32 @@ export function CameraInputsField(props: FieldProps) {
<CollapsibleContent>
<CardContent className="space-y-4 p-4 pt-0">
<div className="w-full">
{renderField(index, "path", {
extraUiSchema: {
"ui:widget": "CameraPathWidget",
"ui:options": {
size: "full",
splitLayout: false,
<StreamSourceSelector
idPrefix={`${baseId}-${index}`}
mode={sourceModeByIndex[index] ?? detectMode(input.path)}
onModeChange={(nextMode) =>
handleSourceModeChange(index, nextMode)
}
streamNames={go2rtcStreamNames}
selectedStreamName={
parseRestreamStreamName(input.path) ?? ""
}
onSelectStream={(streamName) =>
handleSelectRestreamStream(index, streamName)
}
manualField={renderField(index, "path", {
extraUiSchema: {
"ui:widget": "CameraPathWidget",
"ui:options": {
size: "full",
splitLayout: false,
},
},
},
showSchemaDescription: true,
})}
showSchemaDescription: true,
})}
disabled={disabled}
readonly={readonly}
/>
</div>
<div className="w-full">{renderField(index, "roles")}</div>

View File

@ -0,0 +1,217 @@
import type { ReactNode } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { Check, ChevronsUpDown } from "lucide-react";
import type { StreamSourceMode } from "./streamSource";
type Go2rtcStreamComboboxProps = {
id: string;
value: string;
options: string[];
disabled?: boolean;
onSelect: (streamName: string) => void;
};
// Searchable dropdown of existing go2rtc streams
function Go2rtcStreamCombobox({
id,
value,
options,
disabled,
onSelect,
}: Go2rtcStreamComboboxProps) {
const { t } = useTranslation(["views/settings", "common"]);
const [open, setOpen] = useState(false);
const [searchValue, setSearchValue] = useState("");
const commit = (next: string) => {
onSelect(next);
setSearchValue("");
setOpen(false);
};
return (
<Popover
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) setSearchValue("");
}}
>
<PopoverTrigger asChild>
<Button
id={id}
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn(
"w-full justify-between font-normal sm:max-w-xs",
!value && "text-muted-foreground",
)}
>
<span className="truncate">
{value ||
t("configForm.cameraInputs.sourceMode.go2rtcStreamPlaceholder")}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0">
<Command>
<CommandInput
placeholder={t(
"configForm.cameraInputs.sourceMode.go2rtcStreamSearch",
)}
value={searchValue}
onValueChange={setSearchValue}
/>
<CommandList>
<CommandEmpty>
{t("configForm.cameraInputs.sourceMode.noMatchingStreams")}
</CommandEmpty>
<CommandGroup
heading={t("configForm.cameraInputs.sourceMode.availableStreams")}
>
{options.map((option) => (
<CommandItem
key={option}
value={option}
onSelect={() => commit(option)}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === option ? "opacity-100" : "opacity-0",
)}
/>
{option}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
type StreamSourceSelectorProps = {
idPrefix: string;
mode: StreamSourceMode;
onModeChange: (mode: StreamSourceMode) => void;
streamNames: string[];
selectedStreamName: string;
onSelectStream: (streamName: string) => void;
manualField: ReactNode;
disabled?: boolean;
readonly?: boolean;
};
export function StreamSourceSelector({
idPrefix,
mode,
onModeChange,
streamNames,
selectedStreamName,
onSelectStream,
manualField,
disabled,
readonly,
}: StreamSourceSelectorProps) {
const { t } = useTranslation(["views/settings", "common"]);
const restreamId = `${idPrefix}-source-restream`;
const manualId = `${idPrefix}-source-manual`;
const selectId = `${idPrefix}-restream-select`;
const hasStreams = streamNames.length > 0;
const isDisabled = disabled || readonly;
return (
<div className="space-y-3">
<RadioGroup
value={mode}
onValueChange={(value) => onModeChange(value as StreamSourceMode)}
className="flex flex-col gap-2 sm:flex-row sm:gap-6"
disabled={isDisabled}
>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="restream"
id={restreamId}
className={
mode === "restream"
? "bg-selected from-selected/50 to-selected/90 text-selected"
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
}
/>
<label htmlFor={restreamId} className="cursor-pointer text-sm">
{t("configForm.cameraInputs.sourceMode.restream")}
</label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="manual"
id={manualId}
className={
mode === "manual"
? "bg-selected from-selected/50 to-selected/90 text-selected"
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
}
/>
<label htmlFor={manualId} className="cursor-pointer text-sm">
{t("configForm.cameraInputs.sourceMode.manual")}
</label>
</div>
</RadioGroup>
{mode === "restream" ? (
<div className="space-y-2 pt-1">
<Label htmlFor={selectId} className="block">
{t("configForm.cameraInputs.sourceMode.go2rtcStreamLabel")}
</Label>
{hasStreams ? (
<Go2rtcStreamCombobox
id={selectId}
value={selectedStreamName}
options={streamNames}
disabled={isDisabled}
onSelect={onSelectStream}
/>
) : (
<p
className={cn(
"rounded-md border border-dashed p-3 text-sm text-muted-foreground sm:max-w-xs",
)}
>
{t("configForm.cameraInputs.sourceMode.noGo2rtcStreams")}
</p>
)}
</div>
) : (
manualField
)}
</div>
);
}
export default StreamSourceSelector;

View File

@ -0,0 +1,33 @@
export type StreamSourceMode = "restream" | "manual";
// The literal go2rtc restream prefix matches what the camera wizard inlines
// when it builds a restreamed input path. Only this exact host:port is treated
// as a restream so manually typed URLs (including localhost) stay manual.
export const RESTREAM_PREFIX = "rtsp://127.0.0.1:8554/";
export const RESTREAM_PRESET = "preset-rtsp-restream";
/** Build the restream input path for a given go2rtc stream name. */
export function buildRestreamPath(streamName: string): string {
return `${RESTREAM_PREFIX}${streamName}`;
}
/**
* Extract the go2rtc stream name from a restream input path.
*
* Returns the stream name when the path is a well-formed restream URL with no
* extra path segments or query, otherwise undefined.
*/
export function parseRestreamStreamName(
path: string | undefined,
): string | undefined {
if (typeof path !== "string" || !path.startsWith(RESTREAM_PREFIX)) {
return undefined;
}
const name = path.slice(RESTREAM_PREFIX.length);
if (name.length === 0 || /[/?#]/.test(name)) {
return undefined;
}
return name;
}

View File

@ -34,6 +34,8 @@ import { isMobile } from "react-device-detect";
import { FaVideo } from "react-icons/fa";
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import type { ConfigSectionData, JsonObject } from "@/types/configForm";
import isEqual from "lodash/isEqual";
import { maskCredentials } from "@/utils/credentialMask";
import useSWR from "swr";
import FilterSwitch from "@/components/filter/FilterSwitch";
import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter";
@ -660,6 +662,11 @@ export default function Settings() {
const isAdmin = useIsAdmin();
// for unmasked go2rtc stream sources
const { data: rawPaths } = useSWR<{
go2rtc: { streams: Record<string, string | string[]> };
}>(isAdmin ? "config/raw_paths" : null);
const visibleSettingsViews = !isAdmin
? ALLOWED_VIEWS_FOR_VIEWER
: allSettingsViews;
@ -788,6 +795,40 @@ export default function Settings() {
},
);
// go2rtc streams aren't schema-backed, so build their preview items directly
if ("go2rtc_streams" in pendingDataBySection) {
const live =
(pendingDataBySection["go2rtc_streams"] as Record<string, string[]>) ??
{};
const saved: Record<string, string[]> = {};
for (const [name, urls] of Object.entries(
rawPaths?.go2rtc?.streams ?? {},
)) {
saved[name] = Array.isArray(urls) ? urls : [urls];
}
// Added or changed streams
for (const [name, urls] of Object.entries(live)) {
if (name in saved && isEqual(urls, saved[name])) continue;
const masked = urls.map((url) => maskCredentials(url));
items.push({
scope: "global",
fieldPath: `go2rtc.streams.${name}`,
value: masked.length === 1 ? masked[0] : masked,
});
}
// Deleted streams (present in saved config, absent from pending)
for (const name of Object.keys(saved)) {
if (name in live) continue;
items.push({
scope: "global",
fieldPath: `go2rtc.streams.${name}`,
value: "",
});
}
}
return items.sort((left, right) => {
const scopeCompare = left.scope.localeCompare(right.scope);
if (scopeCompare !== 0) return scopeCompare;
@ -797,7 +838,13 @@ export default function Settings() {
if (cameraCompare !== 0) return cameraCompare;
return left.fieldPath.localeCompare(right.fieldPath);
});
}, [config, fullSchema, pendingDataBySection, profileFriendlyNames]);
}, [
config,
fullSchema,
pendingDataBySection,
profileFriendlyNames,
rawPaths,
]);
// Map a pendingDataKey to SettingsType menu key for clearing section status
const pendingKeyToMenuKey = useCallback(
@ -869,10 +916,7 @@ export default function Settings() {
// after `mutate("config")` resolves
const keysToClear: string[] = [];
// `detectors` and `model` are owned by DetectorsAndModelSettingsView,
// which saves them atomically (single combined PUT with a pre-clear when
// detector keys change or the Plus/Custom tab flips). Doing the same here
// keeps Save All consistent with the page's own Save button
// `detectors` and `model` are owned by DetectorsAndModelSettingsView
const hasPendingDetectors = "detectors" in pendingDataBySection;
const hasPendingModel = "model" in pendingDataBySection;
if (hasPendingDetectors || hasPendingModel) {
@ -975,8 +1019,58 @@ export default function Settings() {
}
}
// go2rtc streams are owned by Go2RtcStreamsSettingsView
if ("go2rtc_streams" in pendingDataBySection) {
try {
const liveStreams =
(pendingDataBySection["go2rtc_streams"] as Record<
string,
string[]
>) ?? {};
const streamsPayload: Record<string, string[] | string> = {
...liveStreams,
};
const deletedStreamNames = Object.keys(
config.go2rtc?.streams ?? {},
).filter((name) => !(name in liveStreams));
for (const deleted of deletedStreamNames) {
streamsPayload[deleted] = "";
}
await axios.put("config/set", {
requires_restart: 0,
config_data: { go2rtc: { streams: streamsPayload } },
});
// Update the running go2rtc instance to match
const go2rtcUpdates: Promise<unknown>[] = [];
for (const [streamName, urls] of Object.entries(liveStreams)) {
if (urls[0]) {
go2rtcUpdates.push(
axios.put(
`go2rtc/streams/${streamName}?src=${encodeURIComponent(urls[0])}`,
),
);
}
}
for (const deleted of deletedStreamNames) {
go2rtcUpdates.push(axios.delete(`go2rtc/streams/${deleted}`));
}
await Promise.allSettled(go2rtcUpdates);
keysToClear.push("go2rtc_streams");
savedKeys.push("go2rtc_streams");
successCount++;
} catch (error) {
// eslint-disable-next-line no-console
console.error("Save All error saving go2rtc streams", error);
failCount++;
}
}
const pendingKeys = Object.keys(pendingDataBySection).filter(
(key) => key !== "detectors" && key !== "model",
(key) =>
key !== "detectors" && key !== "model" && key !== "go2rtc_streams",
);
for (const key of pendingKeys) {

View File

@ -58,8 +58,13 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import SaveAllPreviewPopover, {
type SaveAllPreviewItem,
} from "@/components/overlay/detail/SaveAllPreviewPopover";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { FrigateConfig } from "@/types/frigateConfig";
import type { SettingsPageProps } from "@/views/settings/SingleSectionPage";
import type { ConfigSectionData } from "@/types/configForm";
import { cn } from "@/lib/utils";
import {
isMaskedPath,
@ -85,18 +90,8 @@ type RawPathsResponse = {
go2rtc: { streams: Record<string, string | string[]> };
};
type Go2RtcStreamsSettingsViewProps = {
setUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
onSectionStatusChange?: (
sectionKey: string,
level: "global" | "camera",
status: {
hasChanges: boolean;
isOverridden: boolean;
hasValidationErrors: boolean;
},
) => void;
};
const SECTION_KEY = "go2rtc_streams";
const EMPTY_PENDING: Record<string, ConfigSectionData> = {};
const STREAM_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
@ -114,7 +109,11 @@ function normalizeStreams(
export default function Go2RtcStreamsSettingsView({
setUnsavedChanges,
onSectionStatusChange,
}: Go2RtcStreamsSettingsViewProps) {
pendingDataBySection,
onPendingDataChange,
isSavingAll,
onSectionSavingChange,
}: SettingsPageProps) {
const { t } = useTranslation(["views/settings", "common"]);
const { getLocaleDocUrl } = useDocDomain();
const { data: config, mutate: updateConfig } =
@ -122,13 +121,6 @@ export default function Go2RtcStreamsSettingsView({
const { data: rawPaths, mutate: updateRawPaths } =
useSWR<RawPathsResponse>("config/raw_paths");
const [editedStreams, setEditedStreams] = useState<Record<string, string[]>>(
{},
);
const [serverStreams, setServerStreams] = useState<Record<string, string[]>>(
{},
);
const [initialized, setInitialized] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [credentialVisibility, setCredentialVisibility] = useState<
Record<string, boolean>
@ -138,34 +130,51 @@ export default function Go2RtcStreamsSettingsView({
const [addStreamDialogOpen, setAddStreamDialogOpen] = useState(false);
const [newlyAdded, setNewlyAdded] = useState<Set<string>>(new Set());
// Initialize from config — wait for both config and rawPaths to avoid
// a mismatch when rawPaths arrives after config with different data
useEffect(() => {
if (!config || !rawPaths) return;
const childPending = pendingDataBySection ?? EMPTY_PENDING;
// Always use rawPaths for go2rtc streams — the /config endpoint masks
// credentials, so using config.go2rtc.streams would save masked values
const normalized = normalizeStreams(rawPaths.go2rtc?.streams);
// Saved/server state. Always read from rawPaths
const serverStreams = useMemo<Record<string, string[]>>(
() => normalizeStreams(rawPaths?.go2rtc?.streams),
[rawPaths],
);
setServerStreams(normalized);
if (!initialized) {
setEditedStreams(normalized);
setInitialized(true);
}
}, [config, rawPaths, initialized]);
// Pending edits live in the parent's store so they survive navigation; fall back to saved state
const liveStreams = useMemo<Record<string, string[]>>(
() =>
(childPending[SECTION_KEY] as Record<string, string[]> | undefined) ??
serverStreams,
[childPending, serverStreams],
);
// Persist edits to the parent store, clearing the entry when an edit returns
// the section to its saved state so Save All and the sidebar dot reset cleanly.
const commitStreams = useCallback(
(next: Record<string, string[]>) => {
if (isEqual(next, serverStreams)) {
onPendingDataChange?.(SECTION_KEY, undefined, null);
} else {
onPendingDataChange?.(
SECTION_KEY,
undefined,
next as ConfigSectionData,
);
}
},
[serverStreams, onPendingDataChange],
);
// Track unsaved changes
const hasChanges = useMemo(
() => initialized && !isEqual(editedStreams, serverStreams),
[editedStreams, serverStreams, initialized],
() => !isEqual(liveStreams, serverStreams),
[liveStreams, serverStreams],
);
useEffect(() => {
setUnsavedChanges(hasChanges);
setUnsavedChanges?.(hasChanges);
}, [hasChanges, setUnsavedChanges]);
const hasValidationErrors = useMemo(() => {
const names = Object.keys(editedStreams);
const names = Object.keys(liveStreams);
const seenNames = new Set<string>();
for (const name of names) {
@ -173,13 +182,43 @@ export default function Go2RtcStreamsSettingsView({
if (seenNames.has(name)) return true;
seenNames.add(name);
const urls = editedStreams[name];
const urls = liveStreams[name];
if (!urls || urls.length === 0 || urls.every((u) => !u.trim()))
return true;
}
return false;
}, [editedStreams]);
}, [liveStreams]);
// Pending changes for this section's Save All preview popover. Diff the
// pending streams against the saved state and mask credentials for display.
const sectionPreviewItems = useMemo<SaveAllPreviewItem[]>(() => {
if (!hasChanges) return [];
const items: SaveAllPreviewItem[] = [];
// Added or changed streams
for (const [name, urls] of Object.entries(liveStreams)) {
if (name in serverStreams && isEqual(urls, serverStreams[name])) continue;
const masked = urls.map((url) => maskCredentials(url));
items.push({
scope: "global",
fieldPath: `go2rtc.streams.${name}`,
value: masked.length === 1 ? masked[0] : masked,
});
}
// Deleted streams (present in saved config, absent from pending)
for (const name of Object.keys(serverStreams)) {
if (name in liveStreams) continue;
items.push({
scope: "global",
fieldPath: `go2rtc.streams.${name}`,
value: "",
});
}
return items;
}, [hasChanges, liveStreams, serverStreams]);
// Report status to parent for sidebar red dot
useEffect(() => {
@ -193,13 +232,14 @@ export default function Go2RtcStreamsSettingsView({
// Save handler
const saveToConfig = useCallback(async () => {
setIsLoading(true);
onSectionSavingChange?.(true);
try {
const streamsPayload: Record<string, string[] | string> = {
...editedStreams,
...liveStreams,
};
const deletedStreamNames = Object.keys(serverStreams).filter(
(name) => !(name in editedStreams),
(name) => !(name in liveStreams),
);
for (const deleted of deletedStreamNames) {
streamsPayload[deleted] = "";
@ -212,7 +252,7 @@ export default function Go2RtcStreamsSettingsView({
// Update running go2rtc instance
const go2rtcUpdates: Promise<unknown>[] = [];
for (const [streamName, urls] of Object.entries(editedStreams)) {
for (const [streamName, urls] of Object.entries(liveStreams)) {
if (urls[0]) {
go2rtcUpdates.push(
axios.put(
@ -233,9 +273,9 @@ export default function Go2RtcStreamsSettingsView({
}),
);
setServerStreams(editedStreams);
updateConfig();
updateRawPaths();
await updateConfig();
await updateRawPaths();
onPendingDataChange?.(SECTION_KEY, undefined, null);
} catch {
toast.error(
t("toast.error", {
@ -245,74 +285,86 @@ export default function Go2RtcStreamsSettingsView({
);
} finally {
setIsLoading(false);
onSectionSavingChange?.(false);
}
}, [editedStreams, serverStreams, t, updateConfig, updateRawPaths]);
}, [
liveStreams,
serverStreams,
t,
updateConfig,
updateRawPaths,
onPendingDataChange,
onSectionSavingChange,
]);
// Reset handler
const onReset = useCallback(() => {
setEditedStreams(serverStreams);
onPendingDataChange?.(SECTION_KEY, undefined, null);
setCredentialVisibility({});
}, [serverStreams]);
}, [onPendingDataChange]);
// Stream CRUD operations
const addStream = useCallback((name: string) => {
setEditedStreams((prev) => ({ ...prev, [name]: [""] }));
setNewlyAdded((prev) => new Set(prev).add(name));
setAddStreamDialogOpen(false);
}, []);
const addStream = useCallback(
(name: string) => {
commitStreams({ ...liveStreams, [name]: [""] });
setNewlyAdded((prev) => new Set(prev).add(name));
setAddStreamDialogOpen(false);
},
[liveStreams, commitStreams],
);
const deleteStream = useCallback((streamName: string) => {
setEditedStreams((prev) => {
const { [streamName]: _, ...rest } = prev;
return rest;
});
setDeleteDialog(null);
}, []);
const deleteStream = useCallback(
(streamName: string) => {
const { [streamName]: _removed, ...rest } = liveStreams;
commitStreams(rest);
setDeleteDialog(null);
},
[liveStreams, commitStreams],
);
const renameStream = useCallback((oldName: string, newName: string) => {
if (oldName === newName || !newName.trim()) return;
const renameStream = useCallback(
(oldName: string, newName: string) => {
if (oldName === newName || !newName.trim()) return;
if (!(oldName in liveStreams)) return;
setEditedStreams((prev) => {
const urls = prev[oldName];
if (!urls) return prev;
const entries = Object.entries(prev);
const result: Record<string, string[]> = {};
for (const [key, value] of entries) {
if (key === oldName) {
result[newName] = value;
} else {
result[key] = value;
}
for (const [key, value] of Object.entries(liveStreams)) {
result[key === oldName ? newName : key] = value;
}
return result;
});
}, []);
commitStreams(result);
},
[liveStreams, commitStreams],
);
const updateUrl = useCallback(
(streamName: string, urlIndex: number, newUrl: string) => {
setEditedStreams((prev) => {
const urls = [...(prev[streamName] || [])];
urls[urlIndex] = newUrl;
return { ...prev, [streamName]: urls };
});
const urls = [...(liveStreams[streamName] || [])];
urls[urlIndex] = newUrl;
commitStreams({ ...liveStreams, [streamName]: urls });
},
[],
[liveStreams, commitStreams],
);
const addUrl = useCallback((streamName: string) => {
setEditedStreams((prev) => {
const urls = [...(prev[streamName] || []), ""];
return { ...prev, [streamName]: urls };
});
}, []);
const addUrl = useCallback(
(streamName: string) => {
const urls = [...(liveStreams[streamName] || []), ""];
commitStreams({ ...liveStreams, [streamName]: urls });
},
[liveStreams, commitStreams],
);
const removeUrl = useCallback((streamName: string, urlIndex: number) => {
setEditedStreams((prev) => {
const urls = (prev[streamName] || []).filter((_, i) => i !== urlIndex);
return { ...prev, [streamName]: urls.length > 0 ? urls : [""] };
});
}, []);
const removeUrl = useCallback(
(streamName: string, urlIndex: number) => {
const urls = (liveStreams[streamName] || []).filter(
(_, i) => i !== urlIndex,
);
commitStreams({
...liveStreams,
[streamName]: urls.length > 0 ? urls : [""],
});
},
[liveStreams, commitStreams],
);
const toggleCredentialVisibility = useCallback((key: string) => {
setCredentialVisibility((prev) => ({ ...prev, [key]: !prev[key] }));
@ -320,7 +372,7 @@ export default function Go2RtcStreamsSettingsView({
if (!config) return null;
const streamEntries = Object.entries(editedStreams);
const streamEntries = Object.entries(liveStreams);
return (
<div className="flex size-full flex-col lg:pr-2">
@ -391,6 +443,12 @@ export default function Go2RtcStreamsSettingsView({
<span className="text-sm text-unsaved">
{t("unsavedChanges")}
</span>
<SaveAllPreviewPopover
items={sectionPreviewItems}
className="h-7 w-7"
align="start"
side="top"
/>
</div>
)}
<div className="flex w-full flex-col gap-2 sm:flex-row sm:items-center md:w-auto">
@ -398,7 +456,7 @@ export default function Go2RtcStreamsSettingsView({
<Button
onClick={onReset}
variant="outline"
disabled={isLoading}
disabled={isLoading || isSavingAll}
className="flex min-w-36 flex-1 gap-2"
>
{t("button.undo", { ns: "common" })}
@ -407,7 +465,9 @@ export default function Go2RtcStreamsSettingsView({
<Button
onClick={saveToConfig}
variant="select"
disabled={!hasChanges || isLoading || hasValidationErrors}
disabled={
!hasChanges || isLoading || isSavingAll || hasValidationErrors
}
className="flex min-w-36 flex-1 gap-2"
>
{isLoading ? (
@ -459,7 +519,7 @@ export default function Go2RtcStreamsSettingsView({
<RenameStreamDialog
open={renameDialog !== null}
streamName={renameDialog ?? ""}
allStreamNames={Object.keys(editedStreams)}
allStreamNames={Object.keys(liveStreams)}
onRename={(oldName, newName) => {
renameStream(oldName, newName);
setRenameDialog(null);
@ -469,7 +529,7 @@ export default function Go2RtcStreamsSettingsView({
<AddStreamDialog
open={addStreamDialogOpen}
allStreamNames={Object.keys(editedStreams)}
allStreamNames={Object.keys(liveStreams)}
onAdd={addStream}
onClose={() => setAddStreamDialogOpen(false)}
/>