Compare commits

...
Author SHA1 Message Date
Nicolas Mowen ebd3db4232 Don't allow path traversal 2026-06-16 07:45:41 -06:00
Nicolas Mowen f4b6652f5f Fix go2rtc nested key dict 2026-06-16 07:33:58 -06:00
Nicolas MowenandGitHub 06e3d0ac5d Chapter tweaks (#23440)
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
* Add camera metadata and fix preview chapters

* Add config option for chapters
2026-06-09 09:07:42 -06:00
Nicolas MowenandGitHub 28e3e1ec74 Add ability to control chapters set on MP4 Export (#23310)
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 / Assemble and push default build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
2026-05-25 13:06:16 -05:00
Josh HawkinsandGitHub fa07109a85 filter motion review by allowed cameras (#23294)
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-05-23 06:47:32 -06:00
Josh HawkinsandGitHub 910059281f update mask docs for more clarity (#23282) 2026-05-21 14:00:46 -06:00
Josh HawkinsandGitHub ef44c18c07 Docs update (#23280)
* stationary car detection troubleshooting tips

* tweak
2026-05-21 09:04:41 -05:00
Josh HawkinsandGitHub 06b059c36a fix admin response cache leak to non-admin users via nginx proxy_cache (#23261)
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-05-20 07:29:37 -05:00
Nicolas MowenandGitHub 26d31300e6 Add metadata for creation time to recording segments and exports (#23239)
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 / Assemble and push default build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
2026-05-18 10:58:10 -05:00
0013555528 Fixes (#23235)
* use stable empty object reference for swr metadata default

* version bump

* Refactor get_min_region_size for dimension normalization

Refactor get_min_region_size to normalize dimensions for smaller models and ensure minimum region size is 320 for larger models.

* reject restricted go2rtc stream sources when added via api

* add env var check function

* fix typing

---------

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
2026-05-18 10:32:39 -05:00
17 changed files with 366 additions and 67 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
default_target: local
COMMIT_HASH := $(shell git log -1 --pretty=format:"%h"|tail -1)
VERSION = 0.17.1
VERSION = 0.17.2
IMAGE_REPO ?= ghcr.io/blakeblackshear/frigate
GITHUB_REF_NAME ?= $(shell git rev-parse --abbrev-ref HEAD)
BOARDS= #Initialized empty
@@ -17,36 +17,15 @@ from frigate.const import (
)
from frigate.ffmpeg_presets import parse_preset_hardware_acceleration_encode
from frigate.util.config import find_config_file
from frigate.util.services import (
is_go2rtc_arbitrary_exec_allowed,
is_restricted_go2rtc_source,
)
sys.path.remove("/opt/frigate")
yaml = YAML()
# Check if arbitrary exec sources are allowed (defaults to False for security)
allow_arbitrary_exec = None
if "GO2RTC_ALLOW_ARBITRARY_EXEC" in os.environ:
allow_arbitrary_exec = os.environ.get("GO2RTC_ALLOW_ARBITRARY_EXEC")
elif (
os.path.isdir("/run/secrets")
and os.access("/run/secrets", os.R_OK)
and "GO2RTC_ALLOW_ARBITRARY_EXEC" in os.listdir("/run/secrets")
):
allow_arbitrary_exec = (
Path(os.path.join("/run/secrets", "GO2RTC_ALLOW_ARBITRARY_EXEC"))
.read_text()
.strip()
)
# check for the add-on options file
elif os.path.isfile("/data/options.json"):
with open("/data/options.json") as f:
raw_options = f.read()
options = json.loads(raw_options)
allow_arbitrary_exec = options.get("go2rtc_allow_arbitrary_exec")
ALLOW_ARBITRARY_EXEC = allow_arbitrary_exec is not None and str(
allow_arbitrary_exec
).lower() in ("true", "1", "yes")
FRIGATE_ENV_VARS = {k: v for k, v in os.environ.items() if k.startswith("FRIGATE_")}
# read docker secret files as env vars too
if os.path.isdir("/run/secrets"):
@@ -135,18 +114,13 @@ if LIBAVFORMAT_VERSION_MAJOR < 59:
go2rtc_config["ffmpeg"]["rtsp"] = rtsp_args
def is_restricted_source(stream_source: str) -> bool:
"""Check if a stream source is restricted (echo, expr, or exec)."""
return stream_source.strip().startswith(("echo:", "expr:", "exec:"))
for name in list(go2rtc_config.get("streams", {})):
stream = go2rtc_config["streams"][name]
if isinstance(stream, str):
try:
formatted_stream = stream.format(**FRIGATE_ENV_VARS)
if not ALLOW_ARBITRARY_EXEC and is_restricted_source(formatted_stream):
if is_restricted_go2rtc_source(formatted_stream):
print(
f"[ERROR] Stream '{name}' uses a restricted source (echo/expr/exec) which is disabled by default for security. "
f"Set GO2RTC_ALLOW_ARBITRARY_EXEC=true to enable arbitrary exec sources."
@@ -165,7 +139,7 @@ for name in list(go2rtc_config.get("streams", {})):
for i, stream_item in enumerate(stream):
try:
formatted_stream = stream_item.format(**FRIGATE_ENV_VARS)
if not ALLOW_ARBITRARY_EXEC and is_restricted_source(formatted_stream):
if is_restricted_go2rtc_source(formatted_stream):
print(
f"[ERROR] Stream '{name}' item {i + 1} uses a restricted source (echo/expr/exec) which is disabled by default for security. "
f"Set GO2RTC_ALLOW_ARBITRARY_EXEC=true to enable arbitrary exec sources."
@@ -188,6 +162,20 @@ for name in list(go2rtc_config.get("streams", {})):
)
del go2rtc_config["streams"][name]
elif isinstance(stream, dict):
# The map form ({"url": ...}) lets go2rtc resolve the source
# recursively, so it is effectively a dynamic way to generate the URL
# for a stream. That can only be backed by an exec source, so it cannot
# be allowed unless arbitrary exec is explicitly enabled. When it is
# enabled, leave the map untouched for go2rtc to resolve.
if not is_go2rtc_arbitrary_exec_allowed():
print(
f"[ERROR] Stream '{name}' uses a dynamic source format which is disabled by default for security. "
f"Set GO2RTC_ALLOW_ARBITRARY_EXEC=true to enable arbitrary exec sources."
)
del go2rtc_config["streams"][name]
continue
# add birdseye restream stream if enabled
if config.get("birdseye", {}).get("restream", False):
birdseye: dict[str, Any] = config.get("birdseye")
@@ -259,6 +259,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;
+22
View File
@@ -3,6 +3,8 @@ id: masks
title: Masks
---
Frigate has two kinds of masks: motion masks and object filter masks. Both are narrow tools for fine-tuning, **not for hiding an area from Frigate**. Masks should be used sparingly; in most cases where users reach for one, a [zone](zones.md) with `required_zones` is the right tool instead. See [Which tool do I need?](#which-tool-do-i-need) and [Common mistakes](#common-mistakes) below if you're new to Frigate's mask behavior.
## Motion masks
Motion masks are used to prevent unwanted types of motion from triggering detection. Try watching the Debug feed (Settings --> Debug) with `Motion Boxes` enabled to see what may be regularly detected as motion. For example, you want to mask out your timestamp, the sky, rooftops, etc. Keep in mind that this mask only prevents motion from being detected and does not prevent objects from being detected if object detection was started due to motion in unmasked areas. Motion is also used during object tracking to refine the object detection area in the next frame. _Over-masking will make it more difficult for objects to be tracked._
@@ -17,6 +19,15 @@ Object filter masks can be used to filter out stubborn false positives in fixed
![object mask](/img/bottom-center-mask.jpg)
## Which tool do I need?
| What you're trying to do | Recommended tool | How it works |
| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Don't get alerts or recordings for activity in an area (e.g., the sidewalk in front of your house) | A [zone](zones.md) combined with `review.alerts.required_zones` (and/or `review.detections.required_zones`) | Frigate keeps detecting and tracking activity in the area, but a review item is only created once the bottom-center of an object's bounding box enters a required zone. |
| Stop a stubborn false positive at a specific fixed spot (e.g., a tree base that keeps being detected as a person) | An **object filter mask** for that object type | Any detection of that object type whose bounding-box bottom-center lands inside the mask is treated as a false positive and discarded. |
| Ignore motion in an area that obviously isn't an object of interest (e.g., the camera timestamp, sky, flags, treetops swaying) | A **motion mask** | Motion inside the mask is ignored when deciding whether to run object detection. Objects can still be detected in a motion masked area if motion elsewhere in the frame triggers detection. |
| Stop tracking an object type altogether on this camera (e.g., you never care about cats) | Remove the object from the camera's [`objects.track`](objects.md) list | Frigate skips this object type entirely on this camera, regardless of where it appears. |
## Using the mask creator
To create a poly mask:
@@ -82,3 +93,14 @@ This is what `required_zones` are for. You should define a zone (remember this i
> Maybe my specific situation just warrants this. I've just been having a hard time understanding the relevance of this information - it seems to be that it's exactly what would be expected when "masking out" an area of ANY image.
That may be the case for you. Frigate will definitely work harder tracking people on the sidewalk to make sure it doesn't miss anyone who steps foot on your stoop. The trade off with the way you have it now is slower recognition of objects and potential misses. That may be acceptable based on your needs. Also, if your resolution is low enough on the detect stream, your regions may already be so big that they grab the entire object anyway.
## Common mistakes
**"I added a motion mask to ignore my driveway/sidewalk."**
A motion mask doesn't hide an area from Frigate. Objects can still be detected and tracked inside a masked area. The mask only stops motion _in that area_ from triggering object detection. If you want activity on the sidewalk to never produce a review item, define a [zone](zones.md) over the area you DO care about (your stoop, your driveway) and add it to `review.alerts.required_zones`. Frigate will still see people on the sidewalk, but it won't create an alert until they cross into the zone.
**"I added an object filter mask because I don't care about cars in my yard."**
Object filter masks are for stubborn false positives at fixed locations, not for filtering whole areas or whole object types. If you only want alerts when a car enters the driveway, use a [zone](zones.md) with `required_zones`. If you don't care about a whole object type on this camera, remove it from [`objects.track`](objects.md).
**"I masked everything except a thin strip on my stoop."**
Heavy masking hurts tracking. Frigate uses motion near a tracked object's previous bounding box to decide where to look in the next frame; with most of the frame masked, an object walking from an unmasked area into a masked one effectively disappears and gets picked up as a "new" object when it reappears. For example: someone walks down your sidewalk, stops under a tree (masked area) to tie their shoe, then continues. Frigate sees that as two separate people and can create two separate review items. Because Frigate needs several consecutive frames above the confidence threshold to commit to a detection, each re-appearance can also delay or miss alerts. Use `required_zones` for "only alert me about this spot" and leave the surrounding area unmasked so tracking stays intact.
+6 -6
View File
@@ -5,7 +5,7 @@ title: Updating
# Updating Frigate
The current stable version of Frigate is **0.17.0**. The release notes and any breaking changes for this version can be found on the [Frigate GitHub releases page](https://github.com/blakeblackshear/frigate/releases/tag/v0.17.0).
The current stable version of Frigate is **0.17.2**. The release notes and any breaking changes for this version can be found on the [Frigate GitHub releases page](https://github.com/blakeblackshear/frigate/releases/tag/v0.17.2).
Keeping Frigate up to date ensures you benefit from the latest features, performance improvements, and bug fixes. The update process varies slightly depending on your installation method (Docker, Home Assistant App, etc.). Below are instructions for the most common setups.
@@ -31,21 +31,21 @@ If youre running Frigate via Docker (recommended method), follow these steps:
2. **Update and Pull the Latest Image**:
- If using Docker Compose:
- Edit your `docker-compose.yml` file to specify the desired version tag (e.g., `0.17.0` instead of `0.16.4`). For example:
- Edit your `docker-compose.yml` file to specify the desired version tag (e.g., `0.17.2` instead of `0.16.4`). For example:
```yaml
services:
frigate:
image: ghcr.io/blakeblackshear/frigate:0.17.0
image: ghcr.io/blakeblackshear/frigate:0.17.2
```
- Then pull the image:
```bash
docker pull ghcr.io/blakeblackshear/frigate:0.17.0
docker pull ghcr.io/blakeblackshear/frigate:0.17.2
```
- **Note for `stable` Tag Users**: If your `docker-compose.yml` uses the `stable` tag (e.g., `ghcr.io/blakeblackshear/frigate:stable`), you dont need to update the tag manually. The `stable` tag always points to the latest stable release after pulling.
- If using `docker run`:
- Pull the image with the appropriate tag (e.g., `0.17.0`, `0.17.0-tensorrt`, or `stable`):
- Pull the image with the appropriate tag (e.g., `0.17.2`, `0.17.2-tensorrt`, or `stable`):
```bash
docker pull ghcr.io/blakeblackshear/frigate:0.17.0
docker pull ghcr.io/blakeblackshear/frigate:0.17.2
```
3. **Start the Container**:
+16
View File
@@ -110,3 +110,19 @@ No. Frigate uses the TCP protocol to connect to your camera's RTSP URL. VLC auto
TCP ensures that all data packets arrive in the correct order. This is crucial for video recording, decoding, and stream processing, which is why Frigate enforces a TCP connection. UDP is faster but less reliable, as it does not guarantee packet delivery or order, and VLC does not have the same requirements as Frigate.
You can still configure Frigate to use UDP by using ffmpeg input args or the preset `preset-rtsp-udp`. See the [ffmpeg presets](/configuration/ffmpeg_presets) documentation.
### Why does Frigate keep creating new events for my parked car?
Stationary tracking is designed to _prevent_ this — a parked car should stay one tracked object and not generate new events. If you're getting repeated events for the same car, it's likely that Frigate is losing the tracked object and re-detecting it as a new one.
Open one of the events in Explore → **Tracking Details**. If the detection scores are low (< 70% or so), the model isn't confident the parked car is a car. This is common with the free [COCO-trained](https://cocodataset.org/#explore) object detection models on steep/top-down angles, partially occluded cars, foliage, or low-light footage. When detections fall below `min_score` for too many frames the tracker loses the object, and the next confident frame creates a brand new one.
What helps:
- **Improve the view** — even a small angle change that gets more of the car visible could lift scores enough to stabilize tracking.
- **Use a more accurate model** — switching from `mobiledet` to `yolov9`, or stepping up to a larger variant like `yolov9-s` over `yolov9-t`, can help (at the cost of inference time, and still on the COCO dataset). The biggest gains usually come from fine-tuning a model on images from your own cameras so it learns your specific scene. [Frigate+](https://frigate.video/plus) is a paid option that does this - models are trained on security-camera footage and can be fine-tuned on images you submit from your own setup.
- **Don't set `detect -> stationary -> max_frames` for `car`** — it artificially ends tracking and forces re-detection as a new object. See [Stationary Objects](../configuration/stationary_objects.md).
- **Restrict alerts to the areas you care about** with `required_zones` — see [Zones](../configuration/zones.md#restricting-alerts-and-detections-to-specific-zones). Make sure those zones use the default `loitering_time: 0` unless you specifically want the review item to stay open until the car leaves.
- **Filter impossible locations** with [object filter masks](../configuration/masks.md#object-filter-masks) if cars are being detected on rooftops, treetops, etc.
See [Object Filters](../configuration/object_filters.md) for more on tuning `min_score` and `threshold` — note that raising them too high will make this exact problem worse.
+14 -1
View File
@@ -24,7 +24,7 @@ from frigate.api.defs.tags import Tags
from frigate.config.config import FrigateConfig
from frigate.util.builtin import clean_camera_user_pass
from frigate.util.image import run_ffmpeg_snapshot
from frigate.util.services import ffprobe_stream
from frigate.util.services import ffprobe_stream, is_restricted_go2rtc_source
logger = logging.getLogger(__name__)
@@ -111,6 +111,19 @@ def go2rtc_camera_stream(request: Request, stream_name: str):
)
def go2rtc_add_stream(request: Request, stream_name: str, src: str = ""):
"""Add or update a go2rtc stream configuration."""
if src and is_restricted_go2rtc_source(src):
logger.warning(
"Rejected go2rtc stream '%s' with restricted source type (echo/expr/exec)",
stream_name,
)
return JSONResponse(
content={
"success": False,
"message": "Restricted stream source type",
},
status_code=400,
)
try:
params = {"name": stream_name}
if src:
@@ -4,6 +4,7 @@ from pydantic import BaseModel, Field
from pydantic.json_schema import SkipJsonSchema
from frigate.record.export import (
ChaptersEnum,
PlaybackFactorEnum,
PlaybackSourceEnum,
)
@@ -18,3 +19,11 @@ class ExportRecordingsBody(BaseModel):
)
name: Optional[str] = Field(title="Friendly name", default=None, max_length=256)
image_path: Union[str, SkipJsonSchema[None]] = None
chapters: Optional[ChaptersEnum] = Field(
default=None,
title="Chapter mode",
description=(
"Optional chapter metadata to embed in the export. When omitted, "
"no chapter track is added."
),
)
+19
View File
@@ -91,8 +91,26 @@ def export_recording(
playback_factor = body.playback
playback_source = body.source
friendly_name = body.name
# sanitize_filepath normalizes "\" to "/" but leaves ".." intact, so a path
# like "clips\..\..\etc/passwd" passes the CLIPS_DIR prefix check yet still
# escapes the directory once resolved. A valid snapshot path never uses "..".
if body.image_path and ".." in body.image_path:
return JSONResponse(
content=({"success": False, "message": "Invalid image path"}),
status_code=400,
)
existing_image = sanitize_filepath(body.image_path) if body.image_path else None
# a chapters value in the request body overrides the camera's export config
camera_config = request.app.frigate_config.cameras[camera_name]
chapters = (
body.chapters
if body.chapters is not None
else camera_config.record.export.chapters
)
# Ensure that existing_image is a valid path
if existing_image and not existing_image.startswith(CLIPS_DIR):
return JSONResponse(
@@ -161,6 +179,7 @@ def export_recording(
if playback_source in PlaybackSourceEnum.__members__.values()
else PlaybackSourceEnum.recordings
),
chapters=chapters,
)
exporter.start()
return JSONResponse(
+10
View File
@@ -9,6 +9,7 @@ from frigate.review.types import SeverityEnum
from ..base import FrigateBaseModel
__all__ = [
"ChaptersEnum",
"RecordConfig",
"RecordExportConfig",
"RecordPreviewConfig",
@@ -66,10 +67,19 @@ class RecordPreviewConfig(FrigateBaseModel):
)
class ChaptersEnum(str, Enum):
none = "none"
recording_segments = "recording_segments"
class RecordExportConfig(FrigateBaseModel):
timelapse_args: str = Field(
default=DEFAULT_TIME_LAPSE_FFMPEG_ARGS, title="Timelapse Args"
)
chapters: ChaptersEnum = Field(
default=ChaptersEnum.none,
title="Chapter metadata to embed in exported recordings",
)
class RecordConfig(FrigateBaseModel):
+140 -5
View File
@@ -12,9 +12,11 @@ from enum import Enum
from pathlib import Path
from typing import Optional
import pytz
from peewee import DoesNotExist
from frigate.config import FfmpegConfig, FrigateConfig
from frigate.config.camera.record import ChaptersEnum
from frigate.const import (
CACHE_DIR,
CLIPS_DIR,
@@ -64,6 +66,7 @@ class RecordingExporter(threading.Thread):
end_time: int,
playback_factor: PlaybackFactorEnum,
playback_source: PlaybackSourceEnum,
chapters: Optional[ChaptersEnum] = None,
) -> None:
super().__init__()
self.config = config
@@ -75,6 +78,7 @@ class RecordingExporter(threading.Thread):
self.end_time = end_time
self.playback_factor = playback_factor
self.playback_source = playback_source
self.chapters = chapters
# ensure export thumb dir
Path(os.path.join(CLIPS_DIR, "export")).mkdir(exist_ok=True)
@@ -83,6 +87,77 @@ class RecordingExporter(threading.Thread):
# return in iso format
return datetime.datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S")
def _chapter_metadata_path(self) -> str:
return os.path.join(CACHE_DIR, f"export_chapters_{self.export_id}.txt")
def _build_recording_segment_chapter_metadata_file(
self, recordings: list
) -> Optional[str]:
"""Write an FFmpeg metadata file with one chapter per recording segment.
Each chapter's title is the segment's wallclock start time in
strict ISO 8601 form so a viewer can map any point in the
export's playback timeline back to real-world time without
OCR-ing a burnt-in timestamp. Chapter offsets are computed in
*output time*: the VOD endpoint concatenates recording clips
back-to-back, so wall-clock gaps between recordings collapse in
the produced video. Returns ``None`` when there are no
recordings or every segment is empty after clipping.
"""
if not recordings:
return None
tz_name = self.config.ui.timezone
tz: Optional[datetime.tzinfo] = None
if tz_name:
try:
tz = pytz.timezone(tz_name)
except pytz.UnknownTimeZoneError:
tz = None
if tz is None:
tz = datetime.timezone.utc
chapter_blocks: list[str] = []
output_offset_ms = 0
for rec in recordings:
clipped_start = max(float(rec.start_time), float(self.start_time))
clipped_end = min(float(rec.end_time), float(self.end_time))
if clipped_end <= clipped_start:
continue
duration_ms = int(round((clipped_end - clipped_start) * 1000))
if duration_ms <= 0:
continue
title = datetime.datetime.fromtimestamp(clipped_start, tz=tz).isoformat(
timespec="seconds"
)
chapter_blocks.append(
"[CHAPTER]\n"
"TIMEBASE=1/1000\n"
f"START={output_offset_ms}\n"
f"END={output_offset_ms + duration_ms}\n"
f"title={title}"
)
output_offset_ms += duration_ms
if not chapter_blocks:
return None
meta_path = self._chapter_metadata_path()
try:
with open(meta_path, "w", encoding="utf-8") as f:
f.write(";FFMETADATA1\n")
f.write("\n".join(chapter_blocks))
f.write("\n")
except OSError:
logger.exception(
"Failed to write chapter metadata file for export %s", self.export_id
)
return None
return meta_path
def save_thumbnail(self, id: str) -> str:
thumb_path = os.path.join(CLIPS_DIR, f"export/{id}.webp")
@@ -218,9 +293,41 @@ class RecordingExporter(threading.Thread):
ffmpeg_input = "-y -protocol_whitelist pipe,file,http,tcp -f concat -safe 0 -i /dev/stdin"
# When chapters are requested, query the per-segment recording rows
# and write an FFmpeg metadata sidecar. Timelapse playback rescales
# time so chapter offsets would no longer match wallclock — restrict
# chapter injection to realtime playback.
chapter_args = ""
if (
self.chapters == ChaptersEnum.recording_segments
and self.playback_factor == PlaybackFactorEnum.realtime
):
recordings = list(
Recordings.select(
Recordings.start_time,
Recordings.end_time,
)
.where(
Recordings.start_time.between(self.start_time, self.end_time)
| Recordings.end_time.between(self.start_time, self.end_time)
| (
(self.start_time > Recordings.start_time)
& (self.end_time < Recordings.end_time)
)
)
.where(Recordings.camera == self.camera)
.order_by(Recordings.start_time.asc())
.iterator()
)
chapters_path = self._build_recording_segment_chapter_metadata_file(
recordings
)
if chapters_path:
chapter_args = f" -i {chapters_path} -map 0 -dn -map_metadata 1"
if self.playback_factor == PlaybackFactorEnum.realtime:
ffmpeg_cmd = (
f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input} -c copy -movflags +faststart"
f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input}{chapter_args} -c copy -movflags +faststart"
).split(" ")
elif self.playback_factor == PlaybackFactorEnum.timelapse_25x:
ffmpeg_cmd = (
@@ -235,7 +342,19 @@ class RecordingExporter(threading.Thread):
# add metadata
title = f"Frigate Recording for {self.camera}, {self.get_datetime_from_timestamp(self.start_time)} - {self.get_datetime_from_timestamp(self.end_time)}"
ffmpeg_cmd.extend(["-metadata", f"title={title}"])
creation_time = datetime.datetime.fromtimestamp(
self.start_time, tz=datetime.timezone.utc
).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
ffmpeg_cmd.extend(
[
"-metadata",
f"title={title}",
"-metadata",
f"creation_time={creation_time}",
"-metadata",
f"comment=Camera: {self.camera}",
]
)
ffmpeg_cmd.append(video_path)
@@ -311,7 +430,7 @@ class RecordingExporter(threading.Thread):
if self.playback_factor == PlaybackFactorEnum.realtime:
ffmpeg_cmd = (
f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input} {codec} -movflags +faststart {video_path}"
f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input} {codec} -movflags +faststart"
).split(" ")
elif self.playback_factor == PlaybackFactorEnum.timelapse_25x:
ffmpeg_cmd = (
@@ -319,14 +438,28 @@ class RecordingExporter(threading.Thread):
self.config.ffmpeg.ffmpeg_path,
self.config.ffmpeg.hwaccel_args,
f"{TIMELAPSE_DATA_INPUT_ARGS} {ffmpeg_input}",
f"{self.config.cameras[self.camera].record.export.timelapse_args} -movflags +faststart {video_path}",
f"{self.config.cameras[self.camera].record.export.timelapse_args} -movflags +faststart",
EncodeTypeEnum.timelapse,
)
).split(" ")
# add metadata
title = f"Frigate Preview for {self.camera}, {self.get_datetime_from_timestamp(self.start_time)} - {self.get_datetime_from_timestamp(self.end_time)}"
ffmpeg_cmd.extend(["-metadata", f"title={title}"])
creation_time = datetime.datetime.fromtimestamp(
self.start_time, tz=datetime.timezone.utc
).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
ffmpeg_cmd.extend(
[
"-metadata",
f"title={title}",
"-metadata",
f"creation_time={creation_time}",
"-metadata",
f"comment=Camera: {self.camera}",
]
)
ffmpeg_cmd.append(video_path)
return ffmpeg_cmd, playlist_lines
@@ -376,6 +509,8 @@ class RecordingExporter(threading.Thread):
capture_output=True,
)
Path(self._chapter_metadata_path()).unlink(missing_ok=True)
if p.returncode != 0:
logger.error(
f"Failed to export {self.playback_source.value} for command {' '.join(ffmpeg_cmd)}"
+2
View File
@@ -547,6 +547,8 @@ class RecordingMaintainer(threading.Thread):
"copy",
"-movflags",
"+faststart",
"-metadata",
f"creation_time={start_time.strftime('%Y-%m-%dT%H:%M:%S.%fZ')}",
file_path,
stderr=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.DEVNULL,
@@ -1,3 +1,4 @@
import os
from unittest.mock import patch
from fastapi import HTTPException, Request
@@ -357,6 +358,51 @@ class TestGo2rtcStreamAccess(BaseTestHttp):
f"got {resp.status_code}"
)
def test_add_stream_rejects_restricted_source(self):
"""PUT /go2rtc/streams must reject exec:/echo:/expr: sources even for
admins"""
app = self._make_app(_MULTI_CAMERA_CONFIG)
with AuthTestClient(app) as client:
for src in (
"exec:/tmp/rev.sh",
"echo:foo",
"expr:bar",
" exec:/tmp/rev.sh",
):
resp = client.put(f"/go2rtc/streams/revshell?src={src}")
assert resp.status_code == 400, (
f"Expected 400 for restricted src {src!r}; got {resp.status_code}"
)
assert resp.json().get("success") is False
def test_add_stream_allows_non_restricted_source(self):
"""A normal stream URL should pass the restricted-source check and reach
the (unavailable in tests) go2rtc proxy — so we expect 500, not 400."""
app = self._make_app(_MULTI_CAMERA_CONFIG)
with AuthTestClient(app) as client:
resp = client.put("/go2rtc/streams/legit?src=rtsp://10.0.0.1:554/video")
assert resp.status_code != 400, (
f"Non-restricted source should not be rejected with 400; got {resp.status_code}"
)
def test_add_stream_allows_restricted_source_when_override_set(self):
"""When GO2RTC_ALLOW_ARBITRARY_EXEC is set, the API must defer to operator
intent and forward the request to go2rtc instead of short-circuiting with 400."""
app = self._make_app(_MULTI_CAMERA_CONFIG)
mock_response = type("R", (), {"ok": True, "status_code": 200, "text": "ok"})()
with patch.dict(os.environ, {"GO2RTC_ALLOW_ARBITRARY_EXEC": "true"}):
with patch(
"frigate.api.camera.requests.put", return_value=mock_response
) as mock_put:
with AuthTestClient(app) as client:
resp = client.put("/go2rtc/streams/legit?src=exec:/tmp/something")
assert resp.status_code == 200, (
f"Restricted src should be forwarded when override set; got {resp.status_code}"
)
mock_put.assert_called_once()
forwarded_src = mock_put.call_args.kwargs["params"]["src"]
assert forwarded_src == "exec:/tmp/something"
def test_stream_alias_blocked_when_owning_camera_disallowed(self):
"""limited_user cannot access a stream alias that belongs to a camera they
are not allowed to see."""
+9 -10
View File
@@ -271,18 +271,17 @@ def get_min_region_size(model_config: ModelConfig) -> int:
"""Get the min region size."""
largest_dimension = max(model_config.height, model_config.width)
if largest_dimension > 320:
# We originally tested allowing any model to have a region down to half of the model size
# but this led to many false positives. In this case we specifically target larger models
# which can benefit from a smaller region in some cases to detect smaller objects.
half = int(largest_dimension / 2)
# return largest dimension for smaller models, but make sure the dimension is normalized
if largest_dimension < 320:
if largest_dimension % 4 == 0:
return largest_dimension
if half % 4 == 0:
return half
return int((largest_dimension + 3) / 4) * 4
return int((half + 3) / 4) * 4
return largest_dimension
# Any model that is 320 or larger should have a minimum region size of 320
# this allows larger models to use smaller regions to detect smaller objects
# in the case that the motion area is smaller so that it can be upscaled.
return 320
def create_tensor_input(frame, model_config: ModelConfig, region):
+35
View File
@@ -556,6 +556,41 @@ def get_jetson_stats() -> Optional[dict[int, dict]]:
return results
def is_go2rtc_arbitrary_exec_allowed() -> bool:
"""Read the GO2RTC_ALLOW_ARBITRARY_EXEC override from env, docker
secrets, or the Home Assistant add-on options file."""
raw: Optional[str] = None
if "GO2RTC_ALLOW_ARBITRARY_EXEC" in os.environ:
raw = os.environ.get("GO2RTC_ALLOW_ARBITRARY_EXEC")
elif (
os.path.isdir("/run/secrets")
and os.access("/run/secrets", os.R_OK)
and "GO2RTC_ALLOW_ARBITRARY_EXEC" in os.listdir("/run/secrets")
):
try:
with open("/run/secrets/GO2RTC_ALLOW_ARBITRARY_EXEC") as f:
raw = f.read().strip()
except OSError:
raw = None
elif os.path.isfile("/data/options.json"):
try:
with open("/data/options.json") as f:
options = json.loads(f.read())
raw = options.get("go2rtc_allow_arbitrary_exec")
except (OSError, json.JSONDecodeError):
raw = None
return raw is not None and str(raw).lower() in ("true", "1", "yes")
def is_restricted_go2rtc_source(stream_source: str) -> bool:
"""Check if a stream source is a restricted type (echo, expr, or exec)
and the GO2RTC_ALLOW_ARBITRARY_EXEC override is not set."""
if not stream_source.strip().startswith(("echo:", "expr:", "exec:")):
return False
return not is_go2rtc_arbitrary_exec_allowed()
def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedProcess:
"""Run ffprobe on stream."""
clean_path = escape_special_characters(path)
@@ -5,6 +5,8 @@ import { LiveStreamMetadata } from "@/types/live";
const FETCH_TIMEOUT_MS = 10000;
const DEFER_DELAY_MS = 2000;
const emptyObject: Readonly<{ [key: string]: LiveStreamMetadata }> =
Object.freeze({});
/**
* Hook that fetches go2rtc stream metadata with deferred loading.
@@ -77,7 +79,7 @@ export default function useDeferredStreamMetadata(streamNames: string[]) {
return metadata;
}, []);
const { data: metadata = {} } = useSWR<{
const { data: metadata = emptyObject } = useSWR<{
[key: string]: LiveStreamMetadata;
}>(swrKey, fetcher, {
revalidateOnFocus: false,
+13 -11
View File
@@ -44,6 +44,7 @@ import SummaryTimeline from "@/components/timeline/SummaryTimeline";
import { RecordingStartingPoint } from "@/types/record";
import VideoControls from "@/components/player/VideoControls";
import { TimeRange } from "@/types/timeline";
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
import { useCameraMotionNextTimestamp } from "@/hooks/use-camera-activity";
import useOptimisticState from "@/hooks/use-optimistic-state";
import { Skeleton } from "@/components/ui/skeleton";
@@ -918,25 +919,26 @@ function MotionReview({
}: MotionReviewProps) {
const segmentDuration = 30;
const { data: config } = useSWR<FrigateConfig>("config");
const allowedCameras = useAllowedCameras();
const reviewCameras = useMemo(() => {
if (!config) {
return [];
}
let cameras;
if (!filter || !filter.cameras) {
cameras = Object.values(config.cameras);
} else {
const filteredCams = filter.cameras;
cameras = Object.values(config.cameras).filter((cam) =>
filteredCams.includes(cam.name),
);
}
const selectedCams = filter?.cameras;
const cameras = Object.values(config.cameras).filter((cam) => {
if (!allowedCameras.includes(cam.name)) {
return false;
}
if (selectedCams && !selectedCams.includes(cam.name)) {
return false;
}
return true;
});
return cameras.sort((a, b) => a.ui.order - b.ui.order);
}, [config, filter]);
}, [config, filter, allowedCameras]);
const videoPlayersRef = useRef<{ [camera: string]: PreviewController }>({});