diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
deleted file mode 100644
index d87dbb239..000000000
--- a/.github/copilot-instructions.md
+++ /dev/null
@@ -1,439 +0,0 @@
-# GitHub Copilot Instructions for Frigate NVR
-
-This document provides coding guidelines and best practices for contributing to Frigate NVR, a complete and local NVR designed for Home Assistant with AI object detection.
-
-## Project Overview
-
-Frigate NVR is a realtime object detection system for IP cameras that uses:
-
-- **Backend**: Python 3.13+ with FastAPI, OpenCV, TensorFlow/ONNX
-- **Frontend**: React with TypeScript, Vite, TailwindCSS
-- **Architecture**: Multiprocessing design with ZMQ and MQTT communication
-- **Focus**: Minimal resource usage with maximum performance
-
-## Code Review Guidelines
-
-When reviewing code, do NOT comment on:
-
-- Missing imports - Static analysis tooling catches these
-- Code formatting - Ruff (Python) and Prettier (TypeScript/React) handle formatting
-- Minor style inconsistencies already enforced by linters
-
-## Python Backend Standards
-
-### Python Requirements
-
-- **Compatibility**: Python 3.13+
-- **Language Features**: Use modern Python features:
- - Pattern matching
- - Type hints (comprehensive typing preferred)
- - f-strings (preferred over `%` or `.format()`)
- - Dataclasses
- - Async/await patterns
-
-### Code Quality Standards
-
-- **Formatting**: Ruff (configured in `pyproject.toml`)
-- **Linting**: Ruff with rules defined in project config
-- **Type Checking**: Use type hints consistently
-- **Testing**: unittest framework - use `python3 -u -m unittest` to run tests
-- **Language**: American English for all code, comments, and documentation
-
-### Logging Standards
-
-- **Logger Pattern**: Use module-level logger
-
- ```python
- import logging
-
- logger = logging.getLogger(__name__)
- ```
-
-- **Format Guidelines**:
- - No periods at end of log messages
- - No sensitive data (keys, tokens, passwords)
- - Use lazy logging: `logger.debug("Message with %s", variable)`
-- **Log Levels**:
- - `debug`: Development and troubleshooting information
- - `info`: Important runtime events (startup, shutdown, state changes)
- - `warning`: Recoverable issues that should be addressed
- - `error`: Errors that affect functionality but don't crash the app
- - `exception`: Use in except blocks to include traceback
-
-### Error Handling
-
-- **Exception Types**: Choose most specific exception available
-- **Try/Catch Best Practices**:
- - Only wrap code that can throw exceptions
- - Keep try blocks minimal - process data after the try/except
- - Avoid bare exceptions except in background tasks
-
- Bad pattern:
-
- ```python
- try:
- data = await device.get_data() # Can throw
- # ❌ Don't process data inside try block
- processed = data.get("value", 0) * 100
- result = processed
- except DeviceError:
- logger.error("Failed to get data")
- ```
-
- Good pattern:
-
- ```python
- try:
- data = await device.get_data() # Can throw
- except DeviceError:
- logger.error("Failed to get data")
- return
-
- # ✅ Process data outside try block
- processed = data.get("value", 0) * 100
- result = processed
- ```
-
-### Async Programming
-
-- **External I/O**: All external I/O operations must be async
-- **Best Practices**:
- - Avoid sleeping in loops - use `asyncio.sleep()` not `time.sleep()`
- - Avoid awaiting in loops - use `asyncio.gather()` instead
- - No blocking calls in async functions
- - Use `asyncio.create_task()` for background operations
-- **Thread Safety**: Use proper synchronization for shared state
-
-### Documentation Standards
-
-- **Module Docstrings**: Concise descriptions at top of files
- ```python
- """Utilities for motion detection and analysis."""
- ```
-- **Function Docstrings**: Required for public functions and methods
-
- ```python
- async def process_frame(frame: ndarray, config: Config) -> Detection:
- """Process a video frame for object detection.
-
- Args:
- frame: The video frame as numpy array
- config: Detection configuration
-
- Returns:
- Detection results with bounding boxes
- """
- ```
-
-- **Comment Style**:
- - Explain the "why" not just the "what"
- - Keep lines under 88 characters when possible
- - Use clear, descriptive comments
-
-### File Organization
-
-- **API Endpoints**: `frigate/api/` - FastAPI route handlers
-- **Configuration**: `frigate/config/` - Configuration parsing and validation
-- **Detectors**: `frigate/detectors/` - Object detection backends
-- **Events**: `frigate/events/` - Event management and storage
-- **Utilities**: `frigate/util/` - Shared utility functions
-
-## Frontend (React/TypeScript) Standards
-
-### Internationalization (i18n)
-
-- **CRITICAL**: Never write user-facing strings directly in components
-- **Always use react-i18next**: Import and use the `t()` function
-
- ```tsx
- import { useTranslation } from "react-i18next";
-
- function MyComponent() {
- const { t } = useTranslation(["views/live"]);
- return
{t("camera_not_found")}
;
- }
- ```
-
-- **Translation Files**: Add English strings to the appropriate json files in `web/public/locales/en`
-- **Namespaces**: Organize translations by feature/view (e.g., `views/live`, `common`, `views/system`)
-
-### Code Quality
-
-- **Linting**: ESLint (see `web/.eslintrc.cjs`)
-- **Formatting**: Prettier with Tailwind CSS plugin
-- **Type Safety**: TypeScript strict mode enabled
-
-### Component Patterns
-
-- **UI Components**: Use Radix UI primitives (in `web/src/components/ui/`)
-- **Styling**: TailwindCSS with `cn()` utility for class merging
-- **State Management**: React hooks (useState, useEffect, useCallback, useMemo)
-- **Data Fetching**: Custom hooks with proper loading and error states
-
-### ESLint Rules
-
-Key rules enforced:
-
-- `react-hooks/rules-of-hooks`: error
-- `react-hooks/exhaustive-deps`: error
-- `no-console`: error (use proper logging or remove)
-- `@typescript-eslint/no-explicit-any`: warn (always use proper types instead of `any`)
-- Unused variables must be prefixed with `_`
-- Comma dangles required for multiline objects/arrays
-
-### File Organization
-
-- **Pages**: `web/src/pages/` - Route components
-- **Views**: `web/src/views/` - Complex view components
-- **Components**: `web/src/components/` - Reusable components
-- **Hooks**: `web/src/hooks/` - Custom React hooks
-- **API**: `web/src/api/` - API client functions
-- **Types**: `web/src/types/` - TypeScript type definitions
-
-## Testing Requirements
-
-### Backend Testing
-
-- **Framework**: Python unittest
-- **Run Command**: `python3 -u -m unittest`
-- **Location**: `frigate/test/`
-- **Coverage**: Aim for comprehensive test coverage of core functionality
-- **Pattern**: Use `TestCase` classes with descriptive test method names
- ```python
- class TestMotionDetection(unittest.TestCase):
- def test_detects_motion_above_threshold(self):
- # Test implementation
- ```
-
-### Test Best Practices
-
-- Always have a way to test your work and confirm your changes
-- Write tests for bug fixes to prevent regressions
-- Test edge cases and error conditions
-- Mock external dependencies (cameras, APIs, hardware)
-- Use fixtures for test data
-
-## Development Commands
-
-### Python Backend
-
-```bash
-# Run all tests
-python3 -u -m unittest
-
-# Run specific test file
-python3 -u -m unittest frigate.test.test_ffmpeg_presets
-
-# Check formatting (Ruff)
-ruff format --check frigate/
-
-# Apply formatting
-ruff format frigate/
-
-# Run linter
-ruff check frigate/
-
-# Type check
-python3 -u -m mypy --config-file frigate/mypy.ini frigate
-```
-
-### Frontend (from web/ directory)
-
-```bash
-# Start dev server (AI agents should never run this directly unless asked)
-npm run dev
-
-# Build for production
-npm run build
-
-# Run linter
-npm run lint
-
-# Fix linting issues
-npm run lint:fix
-
-# Format code
-npm run prettier:write
-
-# E2E: first-time setup
-npm install
-npx playwright install chromium
-
-# E2E: build the app and run all tests
-npm run e2e:build && npm run e2e
-
-# E2E: interactive UI for debugging
-npm run e2e:ui
-
-# E2E: run a specific spec
-npx playwright test --config e2e/playwright.config.ts e2e/specs/live.spec.ts
-
-# E2E: filter by name, or run only desktop/mobile
-npx playwright test --config e2e/playwright.config.ts --grep="severity tab"
-npx playwright test --config e2e/playwright.config.ts --project=desktop
-
-# E2E: regenerate mock data after backend model changes (from repo root)
-PYTHONPATH=. python3 web/e2e/fixtures/mock-data/generate-mock-data.py
-
-# Regenerate config translations from Pydantic models — outputs to
-# web/public/locales/en/config/{global,cameras}.json. NEVER edit those
-# JSON files by hand; change the Pydantic field title/description and
-# re-run this script. (from repo root)
-python3 generate_config_translations.py
-
-# Extract i18n keys from source into the locale files after adding
-# new t() calls. Use the :ci variant to verify the locale files are
-# in sync with source (fails if extraction would change anything).
-npm run i18n:extract
-npm run i18n:extract:ci
-```
-
-### Docker Development
-
-AI agents should never run these commands directly unless instructed.
-
-```bash
-# Build local image
-make local
-
-# Build debug image
-make debug
-```
-
-## Common Patterns
-
-### API Endpoint Pattern
-
-```python
-from fastapi import APIRouter, Request
-from frigate.api.defs.tags import Tags
-
-router = APIRouter(tags=[Tags.Events])
-
-@router.get("/events")
-async def get_events(request: Request, limit: int = 100):
- """Retrieve events from the database."""
- # Implementation
-```
-
-### Configuration Access
-
-```python
-# Access Frigate configuration
-config: FrigateConfig = request.app.frigate_config
-camera_config = config.cameras["front_door"]
-```
-
-### Database Queries
-
-```python
-from frigate.models import Event
-
-# Use Peewee ORM for database access
-events = (
- Event.select()
- .where(Event.camera == camera_name)
- .order_by(Event.start_time.desc())
- .limit(limit)
-)
-```
-
-## Common Anti-Patterns to Avoid
-
-### ❌ Avoid These
-
-```python
-# Blocking operations in async functions
-data = requests.get(url) # ❌ Use async HTTP client
-time.sleep(5) # ❌ Use asyncio.sleep()
-
-# Hardcoded strings in React components
-
Camera not found
# ❌ Use t("camera_not_found")
-
-# Missing error handling
-data = await api.get_data() # ❌ No exception handling
-
-# Bare exceptions in regular code
-try:
- value = await sensor.read()
-except Exception: # ❌ Too broad
- logger.error("Failed")
-
-# Returning exceptions in JSON responses
-except ValueError as e:
- return JSONResponse(
- content={"success": False, "message": str(e)},
- )
-```
-
-### ✅ Use These Instead
-
-```python
-# Async operations
-import aiohttp
-async with aiohttp.ClientSession() as session:
- async with session.get(url) as response:
- data = await response.json()
-
-await asyncio.sleep(5) # ✅ Non-blocking
-
-# Translatable strings in React
-const { t } = useTranslation();
-
{t("camera_not_found")}
# ✅ Translatable
-
-# Proper error handling
-try:
- data = await api.get_data()
-except ApiException as err:
- logger.error("API error: %s", err)
- raise
-
-# Specific exceptions
-try:
- value = await sensor.read()
-except SensorException as err: # ✅ Specific
- logger.exception("Failed to read sensor")
-
-# Safe error responses
-except ValueError:
- logger.exception("Invalid parameters for API request")
- return JSONResponse(
- content={
- "success": False,
- "message": "Invalid request parameters",
- },
- )
-```
-
-## WebSocket Broadcasts
-
-Outbound WebSocket broadcasts go through a per-recipient classifier in `frigate/comms/ws.py` that enforces camera-level access. **The classifier is fail-closed: any topic it doesn't recognize is dropped for every client.** New outbound topics must be classified there or they'll silently disappear.
-
-## Project-Specific Conventions
-
-### Configuration Files
-
-- Main config: `config/config.yml`
-
-### Directory Structure
-
-- Backend code: `frigate/`
-- Frontend code: `web/`
-- Docker files: `docker/`
-- Documentation: `docs/`
-- Database migrations: `migrations/`
-
-### Code Style Conformance
-
-Always conform new and refactored code to the existing coding style in the project:
-
-- Follow established patterns in similar files
-- Match indentation and formatting of surrounding code
-- Use consistent naming conventions (snake_case for Python, camelCase for TypeScript)
-- Maintain the same level of verbosity in comments and docstrings
-
-## Additional Resources
-
-- Documentation: https://docs.frigate.video
-- Main Repository: https://github.com/blakeblackshear/frigate
-- Home Assistant Integration: https://github.com/blakeblackshear/frigate-hass-integration
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
new file mode 120000
index 000000000..47dc3e3d8
--- /dev/null
+++ b/.github/copilot-instructions.md
@@ -0,0 +1 @@
+AGENTS.md
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 000000000..61b8373a8
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,439 @@
+# Agent Instructions for Frigate NVR
+
+This document provides coding guidelines and best practices for contributing to Frigate NVR, a complete and local NVR designed for Home Assistant with AI object detection.
+
+## Project Overview
+
+Frigate NVR is a realtime object detection system for IP cameras that uses:
+
+- **Backend**: Python 3.13+ with FastAPI, OpenCV, TensorFlow/ONNX
+- **Frontend**: React with TypeScript, Vite, TailwindCSS
+- **Architecture**: Multiprocessing design with ZMQ and MQTT communication
+- **Focus**: Minimal resource usage with maximum performance
+
+## Code Review Guidelines
+
+When reviewing code, do NOT comment on:
+
+- Missing imports - Static analysis tooling catches these
+- Code formatting - Ruff (Python) and Prettier (TypeScript/React) handle formatting
+- Minor style inconsistencies already enforced by linters
+
+## Python Backend Standards
+
+### Python Requirements
+
+- **Compatibility**: Python 3.13+
+- **Language Features**: Use modern Python features:
+ - Pattern matching
+ - Type hints (comprehensive typing preferred)
+ - f-strings (preferred over `%` or `.format()`)
+ - Dataclasses
+ - Async/await patterns
+
+### Code Quality Standards
+
+- **Formatting**: Ruff (configured in `pyproject.toml`)
+- **Linting**: Ruff with rules defined in project config
+- **Type Checking**: Use type hints consistently
+- **Testing**: unittest framework - use `python3 -u -m unittest` to run tests
+- **Language**: American English for all code, comments, and documentation
+
+### Logging Standards
+
+- **Logger Pattern**: Use module-level logger
+
+ ```python
+ import logging
+
+ logger = logging.getLogger(__name__)
+ ```
+
+- **Format Guidelines**:
+ - No periods at end of log messages
+ - No sensitive data (keys, tokens, passwords)
+ - Use lazy logging: `logger.debug("Message with %s", variable)`
+- **Log Levels**:
+ - `debug`: Development and troubleshooting information
+ - `info`: Important runtime events (startup, shutdown, state changes)
+ - `warning`: Recoverable issues that should be addressed
+ - `error`: Errors that affect functionality but don't crash the app
+ - `exception`: Use in except blocks to include traceback
+
+### Error Handling
+
+- **Exception Types**: Choose most specific exception available
+- **Try/Catch Best Practices**:
+ - Only wrap code that can throw exceptions
+ - Keep try blocks minimal - process data after the try/except
+ - Avoid bare exceptions except in background tasks
+
+ Bad pattern:
+
+ ```python
+ try:
+ data = await device.get_data() # Can throw
+ # ❌ Don't process data inside try block
+ processed = data.get("value", 0) * 100
+ result = processed
+ except DeviceError:
+ logger.error("Failed to get data")
+ ```
+
+ Good pattern:
+
+ ```python
+ try:
+ data = await device.get_data() # Can throw
+ except DeviceError:
+ logger.error("Failed to get data")
+ return
+
+ # ✅ Process data outside try block
+ processed = data.get("value", 0) * 100
+ result = processed
+ ```
+
+### Async Programming
+
+- **External I/O**: All external I/O operations must be async
+- **Best Practices**:
+ - Avoid sleeping in loops - use `asyncio.sleep()` not `time.sleep()`
+ - Avoid awaiting in loops - use `asyncio.gather()` instead
+ - No blocking calls in async functions
+ - Use `asyncio.create_task()` for background operations
+- **Thread Safety**: Use proper synchronization for shared state
+
+### Documentation Standards
+
+- **Module Docstrings**: Concise descriptions at top of files
+ ```python
+ """Utilities for motion detection and analysis."""
+ ```
+- **Function Docstrings**: Required for public functions and methods
+
+ ```python
+ async def process_frame(frame: ndarray, config: Config) -> Detection:
+ """Process a video frame for object detection.
+
+ Args:
+ frame: The video frame as numpy array
+ config: Detection configuration
+
+ Returns:
+ Detection results with bounding boxes
+ """
+ ```
+
+- **Comment Style**:
+ - Explain the "why" not just the "what"
+ - Keep lines under 88 characters when possible
+ - Use clear, descriptive comments
+
+### File Organization
+
+- **API Endpoints**: `frigate/api/` - FastAPI route handlers
+- **Configuration**: `frigate/config/` - Configuration parsing and validation
+- **Detectors**: `frigate/detectors/` - Object detection backends
+- **Events**: `frigate/events/` - Event management and storage
+- **Utilities**: `frigate/util/` - Shared utility functions
+
+## Frontend (React/TypeScript) Standards
+
+### Internationalization (i18n)
+
+- **CRITICAL**: Never write user-facing strings directly in components
+- **Always use react-i18next**: Import and use the `t()` function
+
+ ```tsx
+ import { useTranslation } from "react-i18next";
+
+ function MyComponent() {
+ const { t } = useTranslation(["views/live"]);
+ return
{t("camera_not_found")}
;
+ }
+ ```
+
+- **Translation Files**: Add English strings to the appropriate json files in `web/public/locales/en`
+- **Namespaces**: Organize translations by feature/view (e.g., `views/live`, `common`, `views/system`)
+
+### Code Quality
+
+- **Linting**: ESLint (see `web/.eslintrc.cjs`)
+- **Formatting**: Prettier with Tailwind CSS plugin
+- **Type Safety**: TypeScript strict mode enabled
+
+### Component Patterns
+
+- **UI Components**: Use Radix UI primitives (in `web/src/components/ui/`)
+- **Styling**: TailwindCSS with `cn()` utility for class merging
+- **State Management**: React hooks (useState, useEffect, useCallback, useMemo)
+- **Data Fetching**: Custom hooks with proper loading and error states
+
+### ESLint Rules
+
+Key rules enforced:
+
+- `react-hooks/rules-of-hooks`: error
+- `react-hooks/exhaustive-deps`: error
+- `no-console`: error (use proper logging or remove)
+- `@typescript-eslint/no-explicit-any`: warn (always use proper types instead of `any`)
+- Unused variables must be prefixed with `_`
+- Comma dangles required for multiline objects/arrays
+
+### File Organization
+
+- **Pages**: `web/src/pages/` - Route components
+- **Views**: `web/src/views/` - Complex view components
+- **Components**: `web/src/components/` - Reusable components
+- **Hooks**: `web/src/hooks/` - Custom React hooks
+- **API**: `web/src/api/` - API client functions
+- **Types**: `web/src/types/` - TypeScript type definitions
+
+## Testing Requirements
+
+### Backend Testing
+
+- **Framework**: Python unittest
+- **Run Command**: `python3 -u -m unittest`
+- **Location**: `frigate/test/`
+- **Coverage**: Aim for comprehensive test coverage of core functionality
+- **Pattern**: Use `TestCase` classes with descriptive test method names
+ ```python
+ class TestMotionDetection(unittest.TestCase):
+ def test_detects_motion_above_threshold(self):
+ # Test implementation
+ ```
+
+### Test Best Practices
+
+- Always have a way to test your work and confirm your changes
+- Write tests for bug fixes to prevent regressions
+- Test edge cases and error conditions
+- Mock external dependencies (cameras, APIs, hardware)
+- Use fixtures for test data
+
+## Development Commands
+
+### Python Backend
+
+```bash
+# Run all tests
+python3 -u -m unittest
+
+# Run specific test file
+python3 -u -m unittest frigate.test.test_ffmpeg_presets
+
+# Check formatting (Ruff)
+ruff format --check frigate/
+
+# Apply formatting
+ruff format frigate/
+
+# Run linter
+ruff check frigate/
+
+# Type check
+python3 -u -m mypy --config-file frigate/mypy.ini frigate
+```
+
+### Frontend (from web/ directory)
+
+```bash
+# Start dev server (AI agents should never run this directly unless asked)
+npm run dev
+
+# Build for production
+npm run build
+
+# Run linter
+npm run lint
+
+# Fix linting issues
+npm run lint:fix
+
+# Format code
+npm run prettier:write
+
+# E2E: first-time setup
+npm install
+npx playwright install chromium
+
+# E2E: build the app and run all tests
+npm run e2e:build && npm run e2e
+
+# E2E: interactive UI for debugging
+npm run e2e:ui
+
+# E2E: run a specific spec
+npx playwright test --config e2e/playwright.config.ts e2e/specs/live.spec.ts
+
+# E2E: filter by name, or run only desktop/mobile
+npx playwright test --config e2e/playwright.config.ts --grep="severity tab"
+npx playwright test --config e2e/playwright.config.ts --project=desktop
+
+# E2E: regenerate mock data after backend model changes (from repo root)
+PYTHONPATH=. python3 web/e2e/fixtures/mock-data/generate-mock-data.py
+
+# Regenerate config translations from Pydantic models — outputs to
+# web/public/locales/en/config/{global,cameras}.json. NEVER edit those
+# JSON files by hand; change the Pydantic field title/description and
+# re-run this script. (from repo root)
+python3 generate_config_translations.py
+
+# Extract i18n keys from source into the locale files after adding
+# new t() calls. Use the :ci variant to verify the locale files are
+# in sync with source (fails if extraction would change anything).
+npm run i18n:extract
+npm run i18n:extract:ci
+```
+
+### Docker Development
+
+AI agents should never run these commands directly unless instructed.
+
+```bash
+# Build local image
+make local
+
+# Build debug image
+make debug
+```
+
+## Common Patterns
+
+### API Endpoint Pattern
+
+```python
+from fastapi import APIRouter, Request
+from frigate.api.defs.tags import Tags
+
+router = APIRouter(tags=[Tags.Events])
+
+@router.get("/events")
+async def get_events(request: Request, limit: int = 100):
+ """Retrieve events from the database."""
+ # Implementation
+```
+
+### Configuration Access
+
+```python
+# Access Frigate configuration
+config: FrigateConfig = request.app.frigate_config
+camera_config = config.cameras["front_door"]
+```
+
+### Database Queries
+
+```python
+from frigate.models import Event
+
+# Use Peewee ORM for database access
+events = (
+ Event.select()
+ .where(Event.camera == camera_name)
+ .order_by(Event.start_time.desc())
+ .limit(limit)
+)
+```
+
+## Common Anti-Patterns to Avoid
+
+### ❌ Avoid These
+
+```python
+# Blocking operations in async functions
+data = requests.get(url) # ❌ Use async HTTP client
+time.sleep(5) # ❌ Use asyncio.sleep()
+
+# Hardcoded strings in React components
+
Camera not found
# ❌ Use t("camera_not_found")
+
+# Missing error handling
+data = await api.get_data() # ❌ No exception handling
+
+# Bare exceptions in regular code
+try:
+ value = await sensor.read()
+except Exception: # ❌ Too broad
+ logger.error("Failed")
+
+# Returning exceptions in JSON responses
+except ValueError as e:
+ return JSONResponse(
+ content={"success": False, "message": str(e)},
+ )
+```
+
+### ✅ Use These Instead
+
+```python
+# Async operations
+import aiohttp
+async with aiohttp.ClientSession() as session:
+ async with session.get(url) as response:
+ data = await response.json()
+
+await asyncio.sleep(5) # ✅ Non-blocking
+
+# Translatable strings in React
+const { t } = useTranslation();
+
{t("camera_not_found")}
# ✅ Translatable
+
+# Proper error handling
+try:
+ data = await api.get_data()
+except ApiException as err:
+ logger.error("API error: %s", err)
+ raise
+
+# Specific exceptions
+try:
+ value = await sensor.read()
+except SensorException as err: # ✅ Specific
+ logger.exception("Failed to read sensor")
+
+# Safe error responses
+except ValueError:
+ logger.exception("Invalid parameters for API request")
+ return JSONResponse(
+ content={
+ "success": False,
+ "message": "Invalid request parameters",
+ },
+ )
+```
+
+## WebSocket Broadcasts
+
+Outbound WebSocket broadcasts go through a per-recipient classifier in `frigate/comms/ws.py` that enforces camera-level access. **The classifier is fail-closed: any topic it doesn't recognize is dropped for every client.** New outbound topics must be classified there or they'll silently disappear.
+
+## Project-Specific Conventions
+
+### Configuration Files
+
+- Main config: `config/config.yml`
+
+### Directory Structure
+
+- Backend code: `frigate/`
+- Frontend code: `web/`
+- Docker files: `docker/`
+- Documentation: `docs/`
+- Database migrations: `migrations/`
+
+### Code Style Conformance
+
+Always conform new and refactored code to the existing coding style in the project:
+
+- Follow established patterns in similar files
+- Match indentation and formatting of surrounding code
+- Use consistent naming conventions (snake_case for Python, camelCase for TypeScript)
+- Maintain the same level of verbosity in comments and docstrings
+
+## Additional Resources
+
+- Documentation: https://docs.frigate.video
+- Main Repository: https://github.com/blakeblackshear/frigate
+- Home Assistant Integration: https://github.com/blakeblackshear/frigate-hass-integration
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 120000
index 000000000..47dc3e3d8
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1 @@
+AGENTS.md
\ No newline at end of file
diff --git a/docker/main/rootfs/usr/local/nginx/conf/nginx.conf b/docker/main/rootfs/usr/local/nginx/conf/nginx.conf
index d954bdcd5..d0b18ff80 100644
--- a/docker/main/rootfs/usr/local/nginx/conf/nginx.conf
+++ b/docker/main/rootfs/usr/local/nginx/conf/nginx.conf
@@ -252,6 +252,7 @@ http {
include proxy.conf;
proxy_cache api_cache;
+ proxy_cache_key "$scheme$proxy_host$request_uri|$role|$groups|$user";
proxy_cache_lock on;
proxy_cache_use_stale updating;
proxy_cache_valid 200 5s;
diff --git a/docs/static/frigate-api.yaml b/docs/static/frigate-api.yaml
index 9c4e44051..605eff92c 100644
--- a/docs/static/frigate-api.yaml
+++ b/docs/static/frigate-api.yaml
@@ -2058,6 +2058,47 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
+ /genai/models:
+ get:
+ tags:
+ - App
+ summary: List available GenAI models
+ description: Returns available models for each configured GenAI provider.
+ operationId: genai_models_genai_models_get
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema: {}
+ /genai/probe:
+ post:
+ tags:
+ - App
+ summary: Probe a GenAI provider without saving config
+ description: >-
+ Builds a transient client from the request body and returns its
+ available models. Used to validate provider credentials in the UI
+ before saving the configuration. Requires admin role.
+ operationId: genai_probe_genai_probe_post
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/GenAIProbeBody"
+ responses:
+ "200":
+ description: Successful Response
+ content:
+ application/json:
+ schema: {}
+ "422":
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/HTTPValidationError"
/vainfo:
get:
tags:
@@ -7031,6 +7072,39 @@ components:
"john_doe": ["face1.webp", "face2.jpg"],
"jane_smith": ["face3.png"]
}
+ GenAIProbeBody:
+ properties:
+ provider:
+ type: string
+ enum:
+ - openai
+ - azure_openai
+ - gemini
+ - ollama
+ - llamacpp
+ title: Provider
+ description: GenAI provider to probe
+ api_key:
+ anyOf:
+ - type: string
+ - type: "null"
+ title: API Key
+ description: API key for the provider (when applicable)
+ base_url:
+ anyOf:
+ - type: string
+ - type: "null"
+ title: Base URL
+ description: Base URL for self-hosted or compatible providers
+ provider_options:
+ type: object
+ title: Provider Options
+ description: Additional provider-specific options
+ default: {}
+ type: object
+ required:
+ - provider
+ title: GenAIProbeBody
GenerateObjectExamplesBody:
properties:
model_name:
diff --git a/frigate/api/app.py b/frigate/api/app.py
index 4fac58a71..2ba016d63 100644
--- a/frigate/api/app.py
+++ b/frigate/api/app.py
@@ -34,15 +34,17 @@ from frigate.api.auth import (
from frigate.api.defs.query.app_query_parameters import AppTimelineHourlyQueryParameters
from frigate.api.defs.request.app_body import (
AppConfigSetBody,
+ GenAIProbeBody,
MediaSyncBody,
)
from frigate.api.defs.tags import Tags
-from frigate.config import FrigateConfig
+from frigate.config import FrigateConfig, GenAIConfig, GenAIProviderEnum
from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
CameraConfigUpdateTopic,
)
from frigate.ffmpeg_presets import FFMPEG_HWACCEL_VAAPI, _gpu_selector
+from frigate.genai import PROVIDERS, load_providers
from frigate.jobs.media_sync import (
get_current_media_sync_job,
get_media_sync_job_by_id,
@@ -75,6 +77,14 @@ logger = logging.getLogger(__name__)
router = APIRouter(tags=[Tags.app])
+# Short timeout for the /genai/probe path. The probe is interactive — fail
+# fast on hung providers rather than holding an API worker thread.
+_PROBE_TIMEOUT_SECONDS = 10
+# Outer cap that returns control to the caller even if the underlying sync
+# HTTP call ignores its timeout. The sync work continues in the background
+# thread; only the response is bounded.
+_PROBE_OUTER_TIMEOUT_SECONDS = 15
+
@router.get(
"/", response_class=PlainTextResponse, dependencies=[Depends(allow_public())]
@@ -170,6 +180,95 @@ def genai_models(request: Request):
return JSONResponse(content=request.app.genai_manager.list_models())
+@router.post(
+ "/genai/probe",
+ dependencies=[Depends(require_role(["admin"]))],
+ summary="Probe a GenAI provider without saving config",
+ description=(
+ "Builds a transient client from the request body and returns its "
+ "available models. Used to validate provider credentials in the UI "
+ "before saving the configuration."
+ ),
+)
+async def genai_probe(body: GenAIProbeBody):
+ load_providers()
+
+ provider_cls = PROVIDERS.get(body.provider)
+ if not provider_cls:
+ return JSONResponse(
+ status_code=400,
+ content={"success": False, "message": "Unknown provider"},
+ )
+
+ # The OpenAI-compatible SDKs accept "timeout" as a constructor kwarg via
+ # provider_options; other plugins use GenAIClient.timeout passed below.
+ # Don't inject timeout for Gemini — its HttpOptions interprets the value
+ # in milliseconds and would clash with the plugin's own default.
+ probe_provider_options: dict[str, Any] = dict(body.provider_options or {})
+ if body.provider in (GenAIProviderEnum.openai, GenAIProviderEnum.azure_openai):
+ probe_provider_options.setdefault("timeout", _PROBE_TIMEOUT_SECONDS)
+
+ try:
+ transient_cfg = GenAIConfig(
+ provider=body.provider,
+ api_key=body.api_key,
+ base_url=body.base_url,
+ provider_options=probe_provider_options,
+ # model is required by the schema but irrelevant for listing.
+ model="probe",
+ roles=[],
+ )
+ except ValidationError:
+ logger.exception("GenAI probe: invalid configuration")
+ return JSONResponse(
+ status_code=400,
+ content={"success": False, "message": "Invalid provider configuration"},
+ )
+
+ try:
+ client = provider_cls(
+ transient_cfg,
+ timeout=_PROBE_TIMEOUT_SECONDS,
+ validate_model=False,
+ )
+ except Exception:
+ logger.exception("GenAI probe: failed to construct client")
+ return JSONResponse(
+ content={
+ "success": False,
+ "message": "Failed to connect to provider",
+ },
+ )
+
+ try:
+ models = await asyncio.wait_for(
+ asyncio.to_thread(client.list_models),
+ timeout=_PROBE_OUTER_TIMEOUT_SECONDS,
+ )
+ except asyncio.TimeoutError:
+ return JSONResponse(
+ content={"success": False, "message": "Probe timed out"},
+ )
+ except Exception:
+ logger.exception("GenAI probe: list_models failed")
+ return JSONResponse(
+ content={"success": False, "message": "Provider returned no models"},
+ )
+
+ if not models:
+ return JSONResponse(
+ content={
+ "success": False,
+ "message": (
+ "No models returned. Check the API key, base URL, and "
+ "that the provider is reachable."
+ ),
+ },
+ )
+
+ return JSONResponse(content={"success": True, "models": models})
+
+
@router.get("/config", dependencies=[Depends(allow_any_authenticated())])
def config(request: Request):
config_obj: FrigateConfig = request.app.frigate_config
diff --git a/frigate/api/defs/request/app_body.py b/frigate/api/defs/request/app_body.py
index d9d11fd01..2c37f6ae4 100644
--- a/frigate/api/defs/request/app_body.py
+++ b/frigate/api/defs/request/app_body.py
@@ -2,6 +2,8 @@ from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
+from frigate.config import GenAIProviderEnum
+
class AppConfigSetBody(BaseModel):
requires_restart: int = 1
@@ -10,6 +12,13 @@ class AppConfigSetBody(BaseModel):
skip_save: bool = False
+class GenAIProbeBody(BaseModel):
+ provider: GenAIProviderEnum
+ api_key: Optional[str] = None
+ base_url: Optional[str] = None
+ provider_options: Dict[str, Any] = Field(default_factory=dict)
+
+
class AppPutPasswordBody(BaseModel):
password: str
old_password: Optional[str] = None
diff --git a/frigate/config/camera/genai.py b/frigate/config/camera/genai.py
index 902c94c42..5b9475572 100644
--- a/frigate/config/camera/genai.py
+++ b/frigate/config/camera/genai.py
@@ -37,7 +37,7 @@ class GenAIConfig(FrigateBaseModel):
description="Base URL for self-hosted or compatible providers (for example an Ollama instance).",
)
model: str = Field(
- default="gpt-4o",
+ default="",
title="Model",
description="The model to use from the provider for generating descriptions or summaries.",
)
diff --git a/frigate/genai/__init__.py b/frigate/genai/__init__.py
index 864092df5..28a6844d9 100644
--- a/frigate/genai/__init__.py
+++ b/frigate/genai/__init__.py
@@ -50,9 +50,15 @@ def register_genai_provider(key: GenAIProviderEnum) -> Callable:
class GenAIClient:
"""Generative AI client for Frigate."""
- def __init__(self, genai_config: GenAIConfig, timeout: int = 120) -> None:
+ def __init__(
+ self,
+ genai_config: GenAIConfig,
+ timeout: int = 120,
+ validate_model: bool = True,
+ ) -> None:
self.genai_config: GenAIConfig = genai_config
self.timeout = timeout
+ self.validate_model = validate_model
self.provider = self._init_provider()
def generate_review_description(
diff --git a/frigate/genai/plugins/llama_cpp.py b/frigate/genai/plugins/llama_cpp.py
index 830dd6817..2dddf5244 100644
--- a/frigate/genai/plugins/llama_cpp.py
+++ b/frigate/genai/plugins/llama_cpp.py
@@ -150,6 +150,10 @@ class LlamaCppClient(GenAIClient):
else:
base_url = base_url.replace("/v1", "") # Strip /v1 if included in base_url
+ if not self.validate_model:
+ # Probe path
+ return base_url
+
configured_model = self.genai_config.model
info = self._get_model_info(base_url, configured_model)
diff --git a/frigate/genai/plugins/ollama.py b/frigate/genai/plugins/ollama.py
index a6f6d8ddd..0f95dd3f9 100644
--- a/frigate/genai/plugins/ollama.py
+++ b/frigate/genai/plugins/ollama.py
@@ -118,6 +118,9 @@ class OllamaClient(GenAIClient):
timeout=self.timeout,
headers=self._auth_headers(),
)
+ if not self.validate_model:
+ # Probe path
+ return client
# ensure the model is available locally
response = client.show(self.genai_config.model)
if response.get("error"):
diff --git a/frigate/test/http_api/test_http_app.py b/frigate/test/http_api/test_http_app.py
index 2be0e65da..319851526 100644
--- a/frigate/test/http_api/test_http_app.py
+++ b/frigate/test/http_api/test_http_app.py
@@ -1,5 +1,8 @@
-from unittest.mock import Mock
+from unittest.mock import Mock, patch
+import frigate.genai
+from frigate.config import GenAIProviderEnum
+from frigate.genai import GenAIClient
from frigate.models import Event, Recordings, ReviewSegment
from frigate.stats.emitter import StatsEmitter
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
@@ -71,3 +74,94 @@ class TestHttpApp(BaseTestHttp):
assert response.status_code == 200
assert app.frigate_config.cameras["front_door"].objects.track == ["person"]
+
+ ####################################################################################################################
+ ################################### POST /genai/probe Endpoint ##################################################
+ ####################################################################################################################
+ def test_genai_probe_requires_admin(self):
+ app = super().create_app()
+
+ with AuthTestClient(app) as client:
+ response = client.post(
+ "/genai/probe",
+ json={"provider": "openai"},
+ headers={"remote-user": "viewer", "remote-role": "viewer"},
+ )
+ assert response.status_code == 403
+
+ def test_genai_probe_returns_models_from_transient_client(self):
+ class FakeClient(GenAIClient):
+ def list_models(self):
+ return ["fake-model-a", "fake-model-b"]
+
+ app = super().create_app()
+
+ with (
+ AuthTestClient(app) as client,
+ patch.dict(
+ frigate.genai.PROVIDERS,
+ {GenAIProviderEnum.openai: FakeClient},
+ ),
+ ):
+ response = client.post(
+ "/genai/probe",
+ json={
+ "provider": "openai",
+ "api_key": "sk-test",
+ "base_url": "https://example.invalid",
+ },
+ )
+ assert response.status_code == 200
+ assert response.json() == {
+ "success": True,
+ "models": ["fake-model-a", "fake-model-b"],
+ }
+
+ def test_genai_probe_empty_list_is_treated_as_failure(self):
+ # The plugin's list_models() returns [] on connection failure rather
+ # than raising. The endpoint should surface that as success=false so
+ # the UI can show a meaningful error.
+ class EmptyClient(GenAIClient):
+ def list_models(self):
+ return []
+
+ app = super().create_app()
+
+ with (
+ AuthTestClient(app) as client,
+ patch.dict(
+ frigate.genai.PROVIDERS,
+ {GenAIProviderEnum.openai: EmptyClient},
+ ),
+ ):
+ response = client.post(
+ "/genai/probe",
+ json={"provider": "openai"},
+ )
+ assert response.status_code == 200
+ payload = response.json()
+ assert payload["success"] is False
+ assert "message" in payload
+
+ def test_genai_probe_handles_provider_failure(self):
+ class FailingClient(GenAIClient):
+ def list_models(self):
+ raise RuntimeError("provider unreachable")
+
+ app = super().create_app()
+
+ with (
+ AuthTestClient(app) as client,
+ patch.dict(
+ frigate.genai.PROVIDERS,
+ {GenAIProviderEnum.openai: FailingClient},
+ ),
+ ):
+ response = client.post(
+ "/genai/probe",
+ json={"provider": "openai"},
+ )
+ assert response.status_code == 200
+ payload = response.json()
+ assert payload["success"] is False
+ assert "message" in payload
diff --git a/web/e2e/specs/replay.spec.ts b/web/e2e/specs/replay.spec.ts
index c09abf10b..51a42737f 100644
--- a/web/e2e/specs/replay.spec.ts
+++ b/web/e2e/specs/replay.spec.ts
@@ -129,8 +129,14 @@ test.describe("Replay — active session @medium", () => {
);
await actionGroup.first().click();
- const dialog = frigateApp.page.getByRole("dialog");
- await expect(dialog).toBeVisible({ timeout: 5_000 });
+ // On mobile PlatformAwareSheet renders a MobilePage (full-screen panel)
+ // instead of a Radix Dialog, so assert the panel title heading is visible.
+ await expect(
+ frigateApp.page.getByRole("heading", {
+ level: 2,
+ name: /^Configuration$/i,
+ }),
+ ).toBeVisible({ timeout: 5_000 });
});
test("Objects tab renders with the camera_activity objects list", async ({
diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json
index 9f842b79c..65a343026 100644
--- a/web/public/locales/en/views/settings.json
+++ b/web/public/locales/en/views/settings.json
@@ -1557,9 +1557,14 @@
"searchPlaceholder": "Search...",
"addCustomLabel": "Add custom label...",
"genaiModel": {
- "placeholder": "Select model…",
- "search": "Search models…",
- "noModels": "No models available"
+ "placeholder": "Select or enter a model…",
+ "search": "Search or enter a model…",
+ "noModels": "No models available",
+ "available": "Available models",
+ "useCustom": "Use \"{{value}}\"",
+ "refresh": "Refresh models",
+ "probeFailed": "Failed to probe models",
+ "fetchedModels": "Successfully fetched model list"
}
},
"globalConfig": {
diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx
index e61ac8a6a..b4b566fc5 100644
--- a/web/src/components/config-form/sections/BaseSection.tsx
+++ b/web/src/components/config-form/sections/BaseSection.tsx
@@ -1288,11 +1288,6 @@ export function ConfigSection({