mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-28 14:49:01 +03:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0592a8c2c0 | ||
|
|
e6601d50a6 | ||
|
|
efe585a920 | ||
|
|
f3a352ef3f |
@@ -15,4 +15,4 @@ nvidia-nccl-cu12==2.26.2.post1; platform_machine == 'x86_64'
|
||||
nvidia-nvjitlink-cu12==12.8.93; platform_machine == 'x86_64'
|
||||
onnx==1.16.*; platform_machine == 'x86_64'
|
||||
onnxruntime-gpu==1.24.*; platform_machine == 'x86_64'
|
||||
protobuf==3.20.3; platform_machine == 'x86_64'
|
||||
protobuf==7.35.1; platform_machine == 'x86_64'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
onnx == 1.14.0; platform_machine == 'aarch64'
|
||||
protobuf == 3.20.3; platform_machine == 'aarch64'
|
||||
protobuf == 7.35.1; platform_machine == 'aarch64'
|
||||
|
||||
@@ -212,7 +212,6 @@ audio:
|
||||
listen:
|
||||
- bark
|
||||
- fire_alarm
|
||||
- scream
|
||||
- speech
|
||||
- yell
|
||||
# Optional: Filters to configure detection.
|
||||
|
||||
@@ -88,7 +88,7 @@ Volume is considered motion for recordings, this means when the `record -> retai
|
||||
|
||||
### Configuring Audio Events
|
||||
|
||||
The included audio model has over [500 different types](https://github.com/blakeblackshear/frigate/blob/dev/audio-labelmap.txt) of audio that can be detected, many of which are not practical. By default `bark`, `fire_alarm`, `scream`, `speech`, and `yell` are enabled but these can be customized.
|
||||
The included audio model has over [500 different types](https://github.com/blakeblackshear/frigate/blob/dev/audio-labelmap.txt) of audio that can be detected, many of which are not practical. By default `bark`, `fire_alarm`, `speech`, and `yell` are enabled but these can be customized.
|
||||
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
@@ -107,7 +107,6 @@ audio:
|
||||
listen:
|
||||
- bark
|
||||
- fire_alarm
|
||||
- scream
|
||||
- speech
|
||||
- yell
|
||||
```
|
||||
@@ -115,6 +114,70 @@ audio:
|
||||
</TabItem>
|
||||
</ConfigTabs>
|
||||
|
||||
### Common Audio Labels
|
||||
|
||||
The labelmap includes hundreds of sound types. The labels below are the ones most users may find practical, grouped by what they're typically used for. Use the exact label string from the left column in your `listen` config, or search for the label in the Frigate UI directly.
|
||||
|
||||
Some labels cover several related sounds: `yell` is triggered by shouting, yelling, children shouting, and screaming; `crying` covers baby cries, sobbing, and whimpering; and `speech` covers ordinary talking and conversation.
|
||||
|
||||
**Safety and security**
|
||||
|
||||
| Label | Detects |
|
||||
| ---------------- | ---------------------------------- |
|
||||
| `yell` | Shouting, yelling, screaming |
|
||||
| `fire_alarm` | Fire and smoke alarm sirens |
|
||||
| `smoke_detector` | Smoke detector beeps |
|
||||
| `alarm` | General alarm sounds |
|
||||
| `car_alarm` | Car alarms |
|
||||
| `siren` | Emergency vehicle and civil sirens |
|
||||
| `glass` | Glass clinking |
|
||||
| `shatter` | Breaking glass |
|
||||
| `breaking` | Something breaking |
|
||||
| `gunshot` | Gunshots |
|
||||
| `explosion` | Explosions |
|
||||
|
||||
**People and activity**
|
||||
|
||||
| Label | Detects |
|
||||
| ----------- | ------------------------ |
|
||||
| `speech` | Talking and conversation |
|
||||
| `laughter` | Laughing |
|
||||
| `crying` | Baby crying and sobbing |
|
||||
| `cough` | Coughing |
|
||||
| `footsteps` | Footsteps and walking |
|
||||
| `knock` | Knocking on a door |
|
||||
| `doorbell` | Doorbell |
|
||||
| `ding-dong` | Doorbell chime |
|
||||
|
||||
**Pets and animals**
|
||||
|
||||
| Label | Detects |
|
||||
| ---------- | ---------------- |
|
||||
| `bark` | Dog barking |
|
||||
| `dog` | Other dog sounds |
|
||||
| `howl` | Howling |
|
||||
| `growling` | Growling |
|
||||
| `meow` | Cat meowing |
|
||||
| `cat` | Other cat sounds |
|
||||
| `hiss` | Hissing |
|
||||
|
||||
**Vehicles and driveway**
|
||||
|
||||
| Label | Detects |
|
||||
| ----------------- | -------------------- |
|
||||
| `car` | Passing cars |
|
||||
| `honk` | Car horns |
|
||||
| `truck` | Trucks |
|
||||
| `reversing_beeps` | Vehicle backup beeps |
|
||||
| `motorcycle` | Motorcycles |
|
||||
| `engine_starting` | Engines starting |
|
||||
|
||||
:::tip
|
||||
|
||||
Frequently-heard labels like `speech` can generate a lot of events, and each event could save a snapshot and recording based on your configuration, so start with a focused set — the defaults (`bark`, `fire_alarm`, `speech`, `yell`) plus a few of the safety labels above cover most needs — and expand from there. See the [full audio labelmap](https://github.com/blakeblackshear/frigate/blob/dev/audio-labelmap.txt) or the Frigate UI for every available type.
|
||||
|
||||
:::
|
||||
|
||||
### Audio Transcription
|
||||
|
||||
Frigate supports fully local audio transcription using either `sherpa-onnx` or OpenAI's open-source Whisper models via `faster-whisper`. The goal of this feature is to support Semantic Search for `speech` audio events. Frigate is not intended to act as a continuous, fully-automatic speech transcription service — automatically transcribing all speech (or queuing many audio events for transcription) requires substantial CPU (or GPU) resources and is impractical on most systems. For this reason, transcriptions for events are initiated manually from the UI or the API rather than being run continuously in the background.
|
||||
|
||||
@@ -5,7 +5,7 @@ title: Camera setup
|
||||
|
||||
Cameras configured to output H.264 video and AAC audio will offer the most compatibility with all features of Frigate and Home Assistant. H.265 has better compression, but less compatibility. Firefox 134+/136+/137+ (Windows/Mac/Linux & Android), Chrome 108+, Safari and Edge are the only browsers able to play H.265 and only support a limited number of H.265 profiles. Ideally, cameras should be configured directly for the desired resolutions and frame rates you want to use in Frigate. Reducing frame rates within Frigate will waste CPU resources decoding extra frames that are discarded. There are three different goals that you want to tune your stream configurations around.
|
||||
|
||||
- **Detection**: This is the only stream that Frigate will decode for processing. Also, this is the stream where snapshots will be generated from. The resolution for detection should be tuned for the size of the objects you want to detect. See [Choosing a detect resolution](#choosing-a-detect-resolution) for more details. The recommended frame rate is 5fps, but may need to be higher (10fps is the recommended maximum for most users) for very fast moving objects. Higher resolutions and frame rates will drive higher CPU usage on your server.
|
||||
- **Detection**: This is the only stream that Frigate will decode for processing. Also, this is the stream where snapshots will be generated from. The resolution for detection should be tuned for the size of the objects you want to detect. See [Choosing a detect resolution](#choosing-a-detect-resolution) for more details. The default frame rate of 5fps is correct for almost all cameras and rarely needs to be changed; see [Choosing a detect frame rate](#choosing-a-detect-frame-rate). Higher resolutions and frame rates will drive higher CPU usage on your server.
|
||||
|
||||
- **Recording**: This stream should be the resolution you wish to store for reference. Typically, this will be the highest resolution your camera supports. I recommend setting this feed in your camera's firmware to 15 fps.
|
||||
|
||||
@@ -25,6 +25,44 @@ Larger resolutions **do** improve performance if the objects are very small in t
|
||||
|
||||

|
||||
|
||||
### Choosing a detect frame rate
|
||||
|
||||
`detect.fps` controls how many times per second Frigate runs object detection — it does **not** need to match your camera's frame rate. The default of **5** is correct for the vast majority of cameras.
|
||||
|
||||
:::warning
|
||||
|
||||
Most users who raise `detect.fps` above the default don't need to. Increasing it consumes more CPU/GPU (detection load scales directly with the frame rate) while providing **no benefit to tracking** once objects are already being followed smoothly. Leave it at **5** unless you have a specific scene that fails the test below, and confirm any change actually helps in the debug view.
|
||||
|
||||
:::
|
||||
|
||||
#### Why 5 is enough for almost everyone
|
||||
|
||||
Frigate follows an object by matching its bounding box from one detection frame to the next, which requires the object to be detected often enough while it is on screen. At 5 fps this is satisfied in normal scenes: an object crossing a yard, porch, driveway, or walkway is in view for several seconds and produces ~15 or more detections, which is more than enough for a reliable track and a good snapshot. This includes fast subjects such as a running person or a bolting pet, which on a wide-angle view remain on screen for several seconds.
|
||||
|
||||
A higher rate helps only when an object crosses the **entire frame in less than two seconds**, which is determined by camera framing rather than object speed - for example, a camera aimed down a street at fast cross-traffic. In those scenes 5 fps may produce too few detections to hold a track. Cameras covering normal approaches and open areas are unaffected.
|
||||
|
||||
#### Checking whether a higher rate is needed
|
||||
|
||||
Estimate how long an object is visible as it crosses the area of interest, aiming for roughly 8–10 detections during the pass:
|
||||
|
||||
> **`detect.fps` ≈ 10 ÷ (seconds the object is in view)**
|
||||
|
||||
Most objects — people walking or running, pets, and vehicles in a yard, driveway, or walkway — stay in view for two seconds or more, so the default of 5 fps is correct. Slowly try raising it to 10 (the recommended maximum) in increments only when objects routinely cross the entire frame in about a second, such as a camera aimed at a street or sidewalk with fast cross-traffic. Objects that transit in under a second cannot be tracked reliably at any practical rate, so reposition the camera instead.
|
||||
|
||||
:::tip
|
||||
|
||||
If the formula calls for more than 10, the fix is **camera placement, not frame rate**. Angle the camera so objects move toward it rather than across the view, or aim it where traffic slows. A higher `detect.fps` increases CPU load proportionally without producing more detections of a too-brief object.
|
||||
|
||||
:::
|
||||
|
||||
#### Verify in the debug view
|
||||
|
||||
Confirm any change in the Debug view or Debug Replay. Watch a typical object cross the scene: if its bounding box follows it smoothly while visible, the rate is sufficient. A box that jumps erratically, drops out, or splits one object into multiple events indicates the rate should be increased one step.
|
||||
|
||||
#### Dedicated LPR cameras
|
||||
|
||||
A dedicated license plate recognition camera is the most common reason to use something higher than 5 fps: the camera is highly zoomed, the plate is small, and it moves at full vehicle speed, so it transits the frame quickly. However, the same ceiling applies: above 10 fps is unnecessary, and **placement matters most**: aim LPR cameras where vehicles slow down, such as gates, driveways, and parking entrances. A tight view of a fast through-road will not likely read plates reliably at any frame rate. See [License Plate Recognition](/configuration/license_plate_recognition) for details.
|
||||
|
||||
### Example Camera Configuration
|
||||
|
||||
For the Dahua/Loryta 5442 camera, I use the following settings:
|
||||
|
||||
@@ -5,20 +5,40 @@ title: Glossary
|
||||
|
||||
The glossary explains terms commonly used in Frigate's documentation.
|
||||
|
||||
## Alert
|
||||
|
||||
The higher-priority of the two [review item](#review-item) severities, the other being a [detection](#detection). By default a review item is an alert when it involves a `person` or `car`; the qualifying [labels](#label) and [zones](#zone) can be configured. [See the review docs for more info](/configuration/review)
|
||||
|
||||
## Attribute
|
||||
|
||||
A property detected on an [object](#object) that exists alongside its [label](#label). Unlike a [sub label](#sub-label), an object can carry several attributes at once. Some attributes come directly from the object detection [model](#model) — for example `face`, `license_plate`, or delivery carrier logos such as `amazon`, `ups`, and `fedex` — while others come from a [custom object classification model](/configuration/custom_classification/object_classification) configured with the `attribute` type. Attributes are visible in the Tracked Object Details pane in Explore, in `frigate/events` MQTT messages, and through the HTTP API.
|
||||
|
||||
## Bounding Box
|
||||
|
||||
A box returned from the object detection model that outlines an object in the frame. These have multiple colors depending on object type in the debug live view.
|
||||
A box returned by the object detection [model](#model) that outlines a detected [object](#object) in the frame. In the Debug view, bounding boxes are colored by object [label](#label).
|
||||
|
||||
### Bounding Box Colors
|
||||
|
||||
- At startup different colors will be assigned to each object label
|
||||
- A dark blue thin line indicates that object is not detected at this current point in time
|
||||
- A gray thin line indicates that object is detected as being stationary
|
||||
- A thick line indicates that object is the subject of autotracking (when enabled).
|
||||
- A thick line indicates that object is the subject of autotracking (when enabled)
|
||||
|
||||
## Class
|
||||
|
||||
The categories a classification [model](#model) is trained to distinguish between. Each class is a distinct visual category the model predicts, plus a `none` class for inputs that don't fit any category. For example, a custom object classification model for `person` objects might use the classes `delivery_person`, `resident`, and `none`. The predicted class is applied to the [object](#object) as either a [sub label](#sub-label) or an [attribute](#attribute), depending on the model's configuration. [See the object classification docs for more info](/configuration/custom_classification/object_classification)
|
||||
|
||||
## Detection
|
||||
|
||||
The lower-priority of the two [review item](#review-item) severities, the other being an [alert](#alert). By default, any review item that does not qualify as an alert is a detection; the qualifying [labels](#label) and [zones](#zone) can be configured. Despite the name, a detection is a category of review item — not the same as the object detection performed by the [model](#model). [See the review docs for more info](/configuration/review)
|
||||
|
||||
## False Positive
|
||||
|
||||
An incorrect detection of an object type. For example a dog being detected as a person, a chair being detected as a dog, etc. A person being detected in an area you want to ignore is not a false positive.
|
||||
An incorrect result from the object detection [model](#model), where it assigns the wrong [label](#label) to something in the frame — for example a dog identified as a person, or a chair identified as a dog. A person correctly identified in an area you want to ignore is not a false positive.
|
||||
|
||||
## Label
|
||||
|
||||
The type assigned to a detected [object](#object) by the object detection [model](#model), drawn from the model's labelmap — for example `person`, `car`, or `dog`. Frigate tracks `person` by default; additional labels are tracked by adding them to the objects configuration. [See the available objects docs for the full list](/configuration/objects)
|
||||
|
||||
## Mask
|
||||
|
||||
@@ -26,44 +46,56 @@ There are two types of masks in Frigate. [See the mask docs for more info](/conf
|
||||
|
||||
### Motion Mask
|
||||
|
||||
Motion masks prevent detection of [motion](#motion) in masked areas from triggering Frigate to run object detection, but do not prevent objects from being detected if object detection runs due to motion in nearby areas. For example: camera timestamps, skies, the tops of trees, etc.
|
||||
A motion mask stops [motion](#motion) in the masked area from triggering object detection. It does not stop an object from being detected when object detection runs because of motion in a nearby area. Use motion masks for parts of the frame that change constantly but never contain objects you care about — camera timestamps, the sky, the tops of trees, and so on.
|
||||
|
||||
### Object Mask
|
||||
|
||||
Object filter masks drop any bounding boxes where the bottom center (overlap doesn't matter) is in the masked area. It forces them to be considered a [false positive](#false-positive) so that they are ignored.
|
||||
An object filter mask drops any [bounding box](#bounding-box) whose bottom center falls inside the masked area (overlap elsewhere doesn't matter). The object is forced to be treated as a [false positive](#false-positive) and ignored.
|
||||
|
||||
## Min Score
|
||||
|
||||
The lowest score that an object can be detected with during tracking, any detection with a lower score will be assumed to be a false positive
|
||||
The lowest score a detected object can have to be kept during tracking. Anything scoring below the minimum is assumed to be a [false positive](#false-positive) and discarded.
|
||||
|
||||
## Model
|
||||
|
||||
A machine learning model that Frigate uses to detect or classify objects. The object detection model locates [objects](#object) in each frame and returns their [labels](#label) and [bounding boxes](#bounding-box). Additional enrichment models run on tracked objects to add detail: face recognition, license plate recognition, bird classification, custom object and state classification, and the embedding models used for semantic search. [See the object detectors docs for more info](/configuration/object_detectors)
|
||||
|
||||
## Motion
|
||||
|
||||
When pixels in the current camera frame are different than previous frames. When many nearby pixels are different in the current frame they grouped together and indicated with a red motion box in the live debug view. [See the motion detection docs for more info](/configuration/motion_detection)
|
||||
A change in pixels between the current camera frame and previous frames. When many nearby pixels change together, they are grouped and shown as a red motion box in the debug live view. [See the motion detection docs for more info](/configuration/motion_detection)
|
||||
|
||||
## Object
|
||||
|
||||
Something Frigate can detect and follow in a camera frame, identified by its [label](#label) (for example a person or a car). The object types Frigate watches for are set in the `objects` configuration. Once an object is detected and followed across frames it becomes a [tracked object](#tracked-object-event-in-previous-versions), which may also carry a [sub label](#sub-label) and [attributes](#attribute). [See the available objects docs for more info](/configuration/objects)
|
||||
|
||||
## Region
|
||||
|
||||
A portion of the camera frame that is sent to object detection, regions can be sent due to motion, active objects, or occasionally for stationary objects. These are represented by green boxes in the debug live view.
|
||||
A portion of the camera frame sent to the object detection [model](#model). Regions are selected because of [motion](#motion), active objects, or occasionally to recheck stationary objects, and are shown as green boxes in the debug live view.
|
||||
|
||||
## Review Item
|
||||
|
||||
A review item is a time period where any number of events/tracked objects were active. [See the review docs for more info](/configuration/review)
|
||||
A period of time during which one or more [tracked objects](#tracked-object-event-in-previous-versions) were active, grouped together for review. Each review item is categorized as either an [alert](#alert) or a [detection](#detection). [See the review docs for more info](/configuration/review)
|
||||
|
||||
## Snapshot Score
|
||||
|
||||
The score shown in a snapshot is the score of that object at that specific moment in time.
|
||||
The object's score at the specific moment the snapshot was captured.
|
||||
|
||||
## Sub Label
|
||||
|
||||
A more specific identity assigned to a [tracked object](#tracked-object-event-in-previous-versions) in addition to its [label](#label). A `person` may get the name of a recognized face, a `car` may get the name of a known license plate, and a `bird` may get its species. An object can have only one sub label at a time. Sub labels are produced by face recognition, license plate recognition, bird classification, custom object classification configured with the `sub label` type, and semantic search triggers.
|
||||
|
||||
## Threshold
|
||||
|
||||
The threshold is the median score that an object must reach in order to be considered a true positive.
|
||||
The median score an object must reach to be considered a true positive.
|
||||
|
||||
## Top Score
|
||||
|
||||
The top score for an object is the highest median score for an object.
|
||||
The highest median score an object reached over its lifetime.
|
||||
|
||||
## Tracked Object ("event" in previous versions)
|
||||
|
||||
The time period starting when a tracked object entered the frame and ending when it left the frame, including any time that the object remained still. Tracked objects are saved when it is considered a [true positive](#threshold) and meets the requirements for a snapshot or recording to be saved.
|
||||
An [object](#object) followed from the moment it enters the frame until it leaves, including any time it stays still. A tracked object is saved once it is considered a [true positive](#threshold) and meets the requirements for a snapshot or recording.
|
||||
|
||||
## Zone
|
||||
|
||||
Zones are areas of interest, zones can be used for notifications and for limiting the areas where Frigate will create a [review item](#review-item). [See the zone docs for more info](/configuration/zones)
|
||||
A user-defined area of interest within the camera frame. Zones can be used for notifications and to limit where Frigate creates a [review item](#review-item). [See the zone docs for more info](/configuration/zones)
|
||||
|
||||
@@ -121,6 +121,12 @@ If segments are only ~1 second instead of ~10 seconds, the camera is sending cor
|
||||
- **Changing codec, bitrate, or resolution mid-stream** — Any encoding changes during an active stream can cause unpredictable segment splitting.
|
||||
- **Camera firmware bugs** — Check for firmware updates from your camera manufacturer.
|
||||
|
||||
:::tip
|
||||
|
||||
You don't have to run `ffprobe` by hand to catch this. Open a camera's **Camera Probe Info** dialog (the info icon on the System → Metrics → Cameras page) and check the **Keyframe analysis** section. It probes the record stream and flags sparse or variable keyframes, which is what smart/"+" codecs (H.264+/H.265+) and long keyframe intervals produce.
|
||||
|
||||
:::
|
||||
|
||||
### Step 4: Check for a stuck detector
|
||||
|
||||
If the detect stream is not processing frames, segments will accumulate. Common causes:
|
||||
|
||||
Vendored
+29
@@ -400,6 +400,35 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/HTTPValidationError"
|
||||
/keyframe_analysis:
|
||||
get:
|
||||
tags:
|
||||
- Camera
|
||||
summary: Keyframe Analysis
|
||||
description: >-
|
||||
Probe a camera's record stream and classify its keyframe spacing.
|
||||
Detects smart/+ codecs and long/variable GOPs that degrade recording.
|
||||
operationId: keyframe_analysis_keyframe_analysis_get
|
||||
parameters:
|
||||
- name: camera
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
default: ""
|
||||
title: Camera
|
||||
responses:
|
||||
"200":
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
"422":
|
||||
description: Validation Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/HTTPValidationError"
|
||||
/ffprobe/snapshot:
|
||||
get:
|
||||
tags:
|
||||
|
||||
+48
-2
@@ -34,11 +34,15 @@ from frigate.config.camera.updater import (
|
||||
)
|
||||
from frigate.config.env import substitute_frigate_vars
|
||||
from frigate.models import User
|
||||
from frigate.util.builtin import clean_camera_user_pass
|
||||
from frigate.util.builtin import clean_camera_user_pass, get_record_segment_time
|
||||
from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files
|
||||
from frigate.util.config import find_config_file
|
||||
from frigate.util.image import run_ffmpeg_snapshot
|
||||
from frigate.util.services import ffprobe_stream, is_restricted_go2rtc_source
|
||||
from frigate.util.services import (
|
||||
analyze_record_keyframes,
|
||||
ffprobe_stream,
|
||||
is_restricted_go2rtc_source,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -362,6 +366,48 @@ def ffprobe(request: Request, paths: str = "", detailed: bool = False):
|
||||
return JSONResponse(content=output)
|
||||
|
||||
|
||||
@router.get("/keyframe_analysis", dependencies=[Depends(require_role(["admin"]))])
|
||||
async def keyframe_analysis(request: Request, camera: str = ""):
|
||||
"""Probe a camera's record stream and classify its keyframe spacing.
|
||||
|
||||
Detects smart/+ codecs and long/variable GOPs that degrade recording.
|
||||
"""
|
||||
config: FrigateConfig = request.app.frigate_config
|
||||
|
||||
if camera not in config.cameras:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": f"{camera} is not a valid camera."},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
camera_config = config.cameras[camera]
|
||||
|
||||
if not camera_config.enabled:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": f"{camera} is not enabled."},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
# keyframe spacing only matters when this camera is recording
|
||||
if not camera_config.record.enabled:
|
||||
return JSONResponse(content={"severity": "record_disabled"})
|
||||
|
||||
# recording guarantees an input carries the record role; its index matches
|
||||
# the "Stream N" numbering the ffprobe endpoint surfaces (same input order)
|
||||
record_index, record_input = next(
|
||||
(idx, i)
|
||||
for idx, i in enumerate(camera_config.ffmpeg.inputs)
|
||||
if "record" in i.roles
|
||||
)
|
||||
|
||||
segment_time = get_record_segment_time(camera_config)
|
||||
result = await analyze_record_keyframes(
|
||||
config.ffmpeg, record_input.path, segment_time
|
||||
)
|
||||
result["stream_index"] = record_index
|
||||
return JSONResponse(content=result)
|
||||
|
||||
|
||||
@router.get("/ffprobe/snapshot", dependencies=[Depends(require_role(["admin"]))])
|
||||
def ffprobe_snapshot(request: Request, url: str = "", timeout: int = 10):
|
||||
"""Get a snapshot from a stream URL using ffmpeg."""
|
||||
|
||||
@@ -9,7 +9,7 @@ from ..base import FrigateBaseModel
|
||||
__all__ = ["AudioConfig", "AudioFilterConfig"]
|
||||
|
||||
|
||||
DEFAULT_LISTEN_AUDIO = ["bark", "fire_alarm", "scream", "speech", "yell"]
|
||||
DEFAULT_LISTEN_AUDIO = ["bark", "fire_alarm", "speech", "yell"]
|
||||
|
||||
|
||||
class AudioFilterConfig(FrigateBaseModel):
|
||||
@@ -41,7 +41,7 @@ class AudioConfig(FrigateBaseModel):
|
||||
listen: list[str] = Field(
|
||||
default=DEFAULT_LISTEN_AUDIO,
|
||||
title="Listen types",
|
||||
description="List of audio event types to detect (for example: bark, fire_alarm, scream, speech, yell).",
|
||||
description="List of audio event types to detect (for example: bark, fire_alarm, speech, yell).",
|
||||
)
|
||||
filters: Optional[dict[str, AudioFilterConfig]] = Field(
|
||||
None,
|
||||
|
||||
@@ -49,7 +49,7 @@ class FfmpegConfig(FrigateBaseModel):
|
||||
path: str = Field(
|
||||
default="default",
|
||||
title="FFmpeg path",
|
||||
description='Path to the FFmpeg binary to use or a version alias ("5.0" or "8.0").',
|
||||
description='Path to the FFmpeg binary to use or a version alias ("7.0" or "8.0").',
|
||||
)
|
||||
global_args: Union[str, list[str]] = Field(
|
||||
default=FFMPEG_GLOBAL_ARGS_DEFAULT,
|
||||
|
||||
@@ -73,7 +73,12 @@ class CameraConfigUpdateSubscriber:
|
||||
|
||||
base_topic = "config/cameras"
|
||||
|
||||
if len(self.camera_configs) == 1:
|
||||
# global subscribers must hear every camera; only narrow per-camera workers
|
||||
is_global_subscriber = (
|
||||
CameraConfigUpdateEnum.add in self.topics
|
||||
or CameraConfigUpdateEnum.remove in self.topics
|
||||
)
|
||||
if not is_global_subscriber and len(self.camera_configs) == 1:
|
||||
base_topic += f"/{list(self.camera_configs.keys())[0]}"
|
||||
|
||||
self.subscriber = ConfigSubscriber(
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from frigate.models import Event, Recordings, ReviewSegment
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
|
||||
|
||||
class TestHttpKeyframeAnalysis(BaseTestHttp):
|
||||
def setUp(self):
|
||||
super().setUp([Event, Recordings, ReviewSegment])
|
||||
|
||||
def test_invalid_camera_returns_404(self):
|
||||
app = super().create_app()
|
||||
with AuthTestClient(app) as client:
|
||||
response = client.get("/keyframe_analysis?camera=does_not_exist")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_record_disabled_returns_neutral(self):
|
||||
# default minimal_config has recording disabled
|
||||
app = super().create_app()
|
||||
with AuthTestClient(app) as client:
|
||||
response = client.get("/keyframe_analysis?camera=front_door")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["severity"] == "record_disabled"
|
||||
|
||||
def test_probes_record_input_and_returns_severity(self):
|
||||
self.minimal_config["cameras"]["front_door"]["ffmpeg"]["inputs"] = [
|
||||
{
|
||||
"path": "rtsp://10.0.0.1:554/record",
|
||||
"roles": ["detect", "record"],
|
||||
}
|
||||
]
|
||||
self.minimal_config["cameras"]["front_door"]["record"] = {"enabled": True}
|
||||
app = super().create_app()
|
||||
|
||||
canned = {
|
||||
"severity": "ok",
|
||||
"keyframe_count": 5,
|
||||
"max_gap": 1.0,
|
||||
"mean_gap": 1.0,
|
||||
"min_gap": 1.0,
|
||||
"segment_time": 10,
|
||||
"duration_observed": 4.0,
|
||||
"thresholds": {"warning": 4.0, "error": 10},
|
||||
}
|
||||
|
||||
with patch(
|
||||
"frigate.api.camera.analyze_record_keyframes",
|
||||
AsyncMock(return_value=canned),
|
||||
) as mock_probe:
|
||||
with AuthTestClient(app) as client:
|
||||
response = client.get("/keyframe_analysis?camera=front_door")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["severity"] == "ok"
|
||||
# index matches the input carrying the record role ("Stream 1")
|
||||
assert response.json()["stream_index"] == 0
|
||||
# the record-role input path was probed
|
||||
assert mock_probe.await_args.args[1] == "rtsp://10.0.0.1:554/record"
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Tests for keyframe-spacing analysis used to detect smart/+ codecs."""
|
||||
|
||||
import asyncio
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from frigate.util.services import (
|
||||
analyze_record_keyframes,
|
||||
classify_keyframe_gaps,
|
||||
parse_keyframe_packets,
|
||||
)
|
||||
|
||||
|
||||
class TestClassifyKeyframeGaps(unittest.TestCase):
|
||||
def test_ok_when_gaps_small(self):
|
||||
# keyframes every ~1s
|
||||
pts = [0.0, 1.0, 2.0, 3.0, 4.0]
|
||||
result = classify_keyframe_gaps(pts, segment_time=10)
|
||||
self.assertEqual(result["severity"], "ok")
|
||||
self.assertEqual(result["max_gap"], 1.0)
|
||||
self.assertEqual(result["keyframe_count"], 5)
|
||||
self.assertEqual(result["thresholds"], {"warning": 4.0, "error": 10})
|
||||
|
||||
def test_warning_when_gap_exceeds_four_seconds(self):
|
||||
pts = [0.0, 1.0, 6.5] # 5.5s gap
|
||||
result = classify_keyframe_gaps(pts, segment_time=10)
|
||||
self.assertEqual(result["severity"], "warning")
|
||||
self.assertEqual(result["max_gap"], 5.5)
|
||||
|
||||
def test_error_when_gap_exceeds_segment_time(self):
|
||||
pts = [0.0, 12.0] # 12s gap > 10s segment
|
||||
result = classify_keyframe_gaps(pts, segment_time=10)
|
||||
self.assertEqual(result["severity"], "error")
|
||||
|
||||
def test_error_threshold_tracks_segment_time(self):
|
||||
pts = [0.0, 6.0] # 6s gap, segment_time=5 -> error
|
||||
result = classify_keyframe_gaps(pts, segment_time=5)
|
||||
self.assertEqual(result["severity"], "error")
|
||||
|
||||
def test_unknown_with_single_keyframe(self):
|
||||
result = classify_keyframe_gaps([1.0], segment_time=10)
|
||||
self.assertEqual(result["severity"], "unknown")
|
||||
self.assertIsNone(result["max_gap"])
|
||||
self.assertEqual(result["keyframe_count"], 1)
|
||||
|
||||
def test_unknown_with_no_keyframes(self):
|
||||
result = classify_keyframe_gaps([], segment_time=10)
|
||||
self.assertEqual(result["severity"], "unknown")
|
||||
self.assertEqual(result["keyframe_count"], 0)
|
||||
|
||||
|
||||
class TestParseKeyframePackets(unittest.TestCase):
|
||||
def test_extracts_keyframe_pts_and_max(self):
|
||||
output = "0.000000,K__\n0.033333,___\n1.000000,K__\n1.500000,___\n"
|
||||
keyframe_pts, max_pts = parse_keyframe_packets(output)
|
||||
self.assertEqual(keyframe_pts, [0.0, 1.0])
|
||||
self.assertEqual(max_pts, 1.5)
|
||||
|
||||
def test_skips_unparseable_and_empty_lines(self):
|
||||
output = "N/A,K__\n\n2.0,K__\nbad line\n"
|
||||
keyframe_pts, max_pts = parse_keyframe_packets(output)
|
||||
self.assertEqual(keyframe_pts, [2.0])
|
||||
self.assertEqual(max_pts, 2.0)
|
||||
|
||||
def test_empty_output(self):
|
||||
keyframe_pts, max_pts = parse_keyframe_packets("")
|
||||
self.assertEqual(keyframe_pts, [])
|
||||
self.assertIsNone(max_pts)
|
||||
|
||||
|
||||
class TestAnalyzeRecordKeyframes(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_merges_duration_and_classification(self):
|
||||
csv = b"0.0,K__\n1.0,___\n6.0,K__\n7.0,___\n"
|
||||
proc = MagicMock()
|
||||
proc.communicate = AsyncMock(return_value=(csv, b""))
|
||||
ffmpeg = MagicMock()
|
||||
ffmpeg.ffprobe_path = "/usr/bin/ffprobe"
|
||||
|
||||
with patch(
|
||||
"frigate.util.services.asyncio.create_subprocess_exec",
|
||||
AsyncMock(return_value=proc),
|
||||
):
|
||||
result = await analyze_record_keyframes(
|
||||
ffmpeg, "rtsp://cam/stream", segment_time=10
|
||||
)
|
||||
|
||||
self.assertEqual(result["severity"], "warning") # 6s gap > 4s
|
||||
self.assertEqual(result["max_gap"], 6.0)
|
||||
self.assertEqual(result["duration_observed"], 7.0)
|
||||
|
||||
async def test_timeout_returns_unknown(self):
|
||||
proc = MagicMock()
|
||||
proc.communicate = AsyncMock(side_effect=asyncio.TimeoutError())
|
||||
proc.kill = MagicMock()
|
||||
ffmpeg = MagicMock()
|
||||
ffmpeg.ffprobe_path = "/usr/bin/ffprobe"
|
||||
|
||||
with patch(
|
||||
"frigate.util.services.asyncio.create_subprocess_exec",
|
||||
AsyncMock(return_value=proc),
|
||||
):
|
||||
result = await analyze_record_keyframes(
|
||||
ffmpeg, "rtsp://cam/stream", segment_time=10
|
||||
)
|
||||
|
||||
self.assertEqual(result["severity"], "unknown")
|
||||
proc.kill.assert_called_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+22
-1
@@ -14,13 +14,16 @@ import urllib.parse
|
||||
from collections.abc import Mapping
|
||||
from multiprocessing.managers import ValueProxy
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Tuple, Union
|
||||
from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, Union
|
||||
|
||||
import numpy as np
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
from frigate.const import REGEX_HTTP_CAMERA_USER_PASS, REGEX_RTSP_CAMERA_USER_PASS
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frigate.config import CameraConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -132,6 +135,24 @@ def get_ffmpeg_arg_list(arg: Any) -> list:
|
||||
return arg if isinstance(arg, list) else shlex.split(arg)
|
||||
|
||||
|
||||
# all built-in record presets use this segment_time
|
||||
DEFAULT_RECORD_SEGMENT_TIME = 10
|
||||
|
||||
|
||||
def get_record_segment_time(config: "CameraConfig") -> int:
|
||||
"""Extract -segment_time from the camera's record output args."""
|
||||
record_args = get_ffmpeg_arg_list(config.ffmpeg.output_args.record)
|
||||
|
||||
if record_args and record_args[0].startswith("preset"):
|
||||
return DEFAULT_RECORD_SEGMENT_TIME
|
||||
|
||||
try:
|
||||
idx = record_args.index("-segment_time")
|
||||
return int(record_args[idx + 1])
|
||||
except (ValueError, IndexError):
|
||||
return DEFAULT_RECORD_SEGMENT_TIME
|
||||
|
||||
|
||||
def load_labels(
|
||||
path: Optional[str], encoding="utf-8", prefill=91, indexed: bool | None = None
|
||||
):
|
||||
|
||||
@@ -879,6 +879,131 @@ def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedPro
|
||||
return result
|
||||
|
||||
|
||||
KEYFRAME_PROBE_WINDOW_SECONDS = 20
|
||||
KEYFRAME_GAP_WARNING_SECONDS = 4.0
|
||||
|
||||
|
||||
def parse_keyframe_packets(output: str) -> Tuple[List[float], Optional[float]]:
|
||||
"""Parse ffprobe CSV `pts_time,flags` output.
|
||||
|
||||
Returns the presentation timestamps of keyframes (flags containing "K")
|
||||
and the maximum timestamp observed across all packets.
|
||||
"""
|
||||
keyframe_pts: List[float] = []
|
||||
max_pts: Optional[float] = None
|
||||
|
||||
for line in output.splitlines():
|
||||
parts = line.split(",")
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
try:
|
||||
pts = float(parts[0])
|
||||
except ValueError:
|
||||
continue
|
||||
if max_pts is None or pts > max_pts:
|
||||
max_pts = pts
|
||||
if "K" in parts[1]:
|
||||
keyframe_pts.append(pts)
|
||||
|
||||
return keyframe_pts, max_pts
|
||||
|
||||
|
||||
def classify_keyframe_gaps(
|
||||
keyframe_pts: List[float], segment_time: int
|
||||
) -> dict[str, Any]:
|
||||
"""Classify keyframe spacing for recording suitability.
|
||||
|
||||
A camera using a smart/+ codec or a long/variable GOP produces large or
|
||||
irregular gaps between keyframes, which breaks time-based recording
|
||||
segmentation. Severity:
|
||||
- "unknown" when fewer than two keyframes were observed
|
||||
- "error" when the longest gap exceeds the record segment length
|
||||
- "warning" when the longest gap exceeds the warning threshold
|
||||
- "ok" otherwise
|
||||
"""
|
||||
thresholds = {
|
||||
"warning": KEYFRAME_GAP_WARNING_SECONDS,
|
||||
"error": segment_time,
|
||||
}
|
||||
|
||||
if len(keyframe_pts) < 2:
|
||||
return {
|
||||
"keyframe_count": len(keyframe_pts),
|
||||
"max_gap": None,
|
||||
"mean_gap": None,
|
||||
"min_gap": None,
|
||||
"segment_time": segment_time,
|
||||
"severity": "unknown",
|
||||
"thresholds": thresholds,
|
||||
}
|
||||
|
||||
gaps = [b - a for a, b in zip(keyframe_pts, keyframe_pts[1:])]
|
||||
max_gap = max(gaps)
|
||||
|
||||
if max_gap > segment_time:
|
||||
severity = "error"
|
||||
elif max_gap > KEYFRAME_GAP_WARNING_SECONDS:
|
||||
severity = "warning"
|
||||
else:
|
||||
severity = "ok"
|
||||
|
||||
return {
|
||||
"keyframe_count": len(keyframe_pts),
|
||||
"max_gap": round(max_gap, 2),
|
||||
"mean_gap": round(sum(gaps) / len(gaps), 2),
|
||||
"min_gap": round(min(gaps), 2),
|
||||
"segment_time": segment_time,
|
||||
"severity": severity,
|
||||
"thresholds": thresholds,
|
||||
}
|
||||
|
||||
|
||||
async def analyze_record_keyframes(
|
||||
ffmpeg, url: str, segment_time: int, window: int = KEYFRAME_PROBE_WINDOW_SECONDS
|
||||
) -> dict[str, Any]:
|
||||
"""Probe a stream for ~`window` seconds and classify its keyframe spacing.
|
||||
|
||||
Reads video packet flags via ffprobe to find keyframes, then measures the
|
||||
gaps between them. On timeout or failure returns an "unknown" result rather
|
||||
than a false all-clear.
|
||||
"""
|
||||
clean_url = escape_special_characters(url)
|
||||
cmd = [
|
||||
ffmpeg.ffprobe_path,
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-read_intervals",
|
||||
f"%+{window}",
|
||||
"-show_entries",
|
||||
"packet=pts_time,flags",
|
||||
"-of",
|
||||
"csv=p=0",
|
||||
clean_url,
|
||||
]
|
||||
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=window + 15)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("Keyframe probe timed out for record stream")
|
||||
proc.kill()
|
||||
return classify_keyframe_gaps([], segment_time)
|
||||
except OSError as err:
|
||||
logger.error("Keyframe probe failed: %s", err)
|
||||
return classify_keyframe_gaps([], segment_time)
|
||||
|
||||
keyframe_pts, max_pts = parse_keyframe_packets(stdout.decode("utf-8", "replace"))
|
||||
result = classify_keyframe_gaps(keyframe_pts, segment_time)
|
||||
result["duration_observed"] = round(max_pts, 2) if max_pts is not None else None
|
||||
return result
|
||||
|
||||
|
||||
def vainfo_hwaccel(device_name: Optional[str] = None) -> sp.CompletedProcess:
|
||||
"""Run vainfo."""
|
||||
if not device_name:
|
||||
|
||||
+2
-19
@@ -24,7 +24,7 @@ from frigate.config.camera.updater import (
|
||||
)
|
||||
from frigate.const import PROCESS_PRIORITY_HIGH
|
||||
from frigate.log import LogPipe
|
||||
from frigate.util.builtin import EventsPerSecond, get_ffmpeg_arg_list
|
||||
from frigate.util.builtin import EventsPerSecond, get_record_segment_time
|
||||
from frigate.util.ffmpeg import start_or_restart_ffmpeg, stop_ffmpeg
|
||||
from frigate.util.image import (
|
||||
FrameManager,
|
||||
@@ -34,23 +34,6 @@ from frigate.util.process import FrigateProcess
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# all built-in record presets use this segment_time
|
||||
DEFAULT_RECORD_SEGMENT_TIME = 10
|
||||
|
||||
|
||||
def _get_record_segment_time(config: CameraConfig) -> int:
|
||||
"""Extract -segment_time from the camera's record output args."""
|
||||
record_args = get_ffmpeg_arg_list(config.ffmpeg.output_args.record)
|
||||
|
||||
if record_args and record_args[0].startswith("preset"):
|
||||
return DEFAULT_RECORD_SEGMENT_TIME
|
||||
|
||||
try:
|
||||
idx = record_args.index("-segment_time")
|
||||
return int(record_args[idx + 1])
|
||||
except (ValueError, IndexError):
|
||||
return DEFAULT_RECORD_SEGMENT_TIME
|
||||
|
||||
|
||||
def capture_frames(
|
||||
ffmpeg_process: sp.Popen[Any],
|
||||
@@ -185,7 +168,7 @@ class CameraWatchdog(threading.Thread):
|
||||
# `valid` segments are published with the segment's start time, so the
|
||||
# gap between consecutive publishes can reach 2 * segment_time. Pad the
|
||||
# staleness threshold so it's never tighter than that worst case.
|
||||
segment_time = _get_record_segment_time(self.config)
|
||||
segment_time = get_record_segment_time(self.config)
|
||||
self.record_stale_threshold = max(120, 2 * segment_time + 30)
|
||||
|
||||
# Stall tracking (based on last processed frame)
|
||||
|
||||
@@ -1 +1 @@
|
||||
[{"id": "case-001", "name": "Package Theft Investigation", "description": "Review of suspicious activity near the front porch", "created_at": 1775407931.3863528, "updated_at": 1775483531.3863528}]
|
||||
[{"id": "case-001", "name": "Package Theft Investigation", "description": "Review of suspicious activity near the front porch", "created_at": 1780597809.365581, "updated_at": 1780673409.365581}]
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
[{"id": "event-person-001", "label": "person", "sub_label": null, "camera": "front_door", "start_time": 1775487131.3863528, "end_time": 1775487161.3863528, "false_positive": false, "zones": ["front_yard"], "thumbnail": null, "has_clip": true, "has_snapshot": true, "retain_indefinitely": false, "plus_id": null, "model_hash": "abc123", "detector_type": "cpu", "model_type": "ssd", "data": {"top_score": 0.92, "score": 0.92, "region": [0.1, 0.1, 0.5, 0.8], "box": [0.2, 0.15, 0.45, 0.75], "area": 0.18, "ratio": 0.6, "type": "object", "description": "A person walking toward the front door", "average_estimated_speed": 1.2, "velocity_angle": 45.0, "path_data": [[[0.2, 0.5], 0.0], [[0.3, 0.5], 1.0]]}}, {"id": "event-car-001", "label": "car", "sub_label": null, "camera": "backyard", "start_time": 1775483531.3863528, "end_time": 1775483576.3863528, "false_positive": false, "zones": ["driveway"], "thumbnail": null, "has_clip": true, "has_snapshot": true, "retain_indefinitely": false, "plus_id": null, "model_hash": "def456", "detector_type": "cpu", "model_type": "ssd", "data": {"top_score": 0.87, "score": 0.87, "region": [0.3, 0.2, 0.9, 0.7], "box": [0.35, 0.25, 0.85, 0.65], "area": 0.2, "ratio": 1.25, "type": "object", "description": "A car parked in the driveway", "average_estimated_speed": 0.0, "velocity_angle": 0.0, "path_data": []}}, {"id": "event-person-002", "label": "person", "sub_label": null, "camera": "garage", "start_time": 1775479931.3863528, "end_time": 1775479951.3863528, "false_positive": false, "zones": [], "thumbnail": null, "has_clip": false, "has_snapshot": true, "retain_indefinitely": false, "plus_id": null, "model_hash": "ghi789", "detector_type": "cpu", "model_type": "ssd", "data": {"top_score": 0.78, "score": 0.78, "region": [0.0, 0.0, 0.6, 0.9], "box": [0.1, 0.05, 0.5, 0.85], "area": 0.32, "ratio": 0.5, "type": "object", "description": null, "average_estimated_speed": 0.5, "velocity_angle": 90.0, "path_data": [[[0.1, 0.4], 0.0]]}}]
|
||||
[{"id": "event-person-001", "label": "person", "sub_label": null, "camera": "front_door", "start_time": 1780677009.365581, "end_time": 1780677039.365581, "false_positive": false, "zones": ["front_yard"], "thumbnail": null, "has_clip": true, "has_snapshot": true, "retain_indefinitely": false, "plus_id": null, "model_hash": "abc123", "detector_type": "cpu", "model_type": "ssd", "data": {"top_score": 0.92, "score": 0.92, "region": [0.1, 0.1, 0.5, 0.8], "box": [0.2, 0.15, 0.45, 0.75], "area": 0.18, "ratio": 0.6, "type": "object", "description": "A person walking toward the front door", "average_estimated_speed": 1.2, "velocity_angle": 45.0, "path_data": [[[0.2, 0.5], 0.0], [[0.3, 0.5], 1.0]]}}, {"id": "event-car-001", "label": "car", "sub_label": null, "camera": "backyard", "start_time": 1780673409.365581, "end_time": 1780673454.365581, "false_positive": false, "zones": ["driveway"], "thumbnail": null, "has_clip": true, "has_snapshot": true, "retain_indefinitely": false, "plus_id": null, "model_hash": "def456", "detector_type": "cpu", "model_type": "ssd", "data": {"top_score": 0.87, "score": 0.87, "region": [0.3, 0.2, 0.9, 0.7], "box": [0.35, 0.25, 0.85, 0.65], "area": 0.2, "ratio": 1.25, "type": "object", "description": "A car parked in the driveway", "average_estimated_speed": 0.0, "velocity_angle": 0.0, "path_data": []}}, {"id": "event-person-002", "label": "person", "sub_label": null, "camera": "garage", "start_time": 1780669809.365581, "end_time": 1780669829.365581, "false_positive": false, "zones": [], "thumbnail": null, "has_clip": false, "has_snapshot": true, "retain_indefinitely": false, "plus_id": null, "model_hash": "ghi789", "detector_type": "cpu", "model_type": "ssd", "data": {"top_score": 0.78, "score": 0.78, "region": [0.0, 0.0, 0.6, 0.9], "box": [0.1, 0.05, 0.5, 0.85], "area": 0.32, "ratio": 0.5, "type": "object", "description": null, "average_estimated_speed": 0.5, "velocity_angle": 90.0, "path_data": [[[0.1, 0.4], 0.0]]}}]
|
||||
@@ -1 +1 @@
|
||||
[{"id": "export-001", "camera": "front_door", "name": "Front Door - Person Alert", "date": 1775490731.3863528, "video_path": "/exports/export-001.mp4", "thumb_path": "/exports/export-001-thumb.jpg", "in_progress": false, "export_case_id": null}, {"id": "export-002", "camera": "backyard", "name": "Backyard - Car Detection", "date": 1775483531.3863528, "video_path": "/exports/export-002.mp4", "thumb_path": "/exports/export-002-thumb.jpg", "in_progress": false, "export_case_id": "case-001"}, {"id": "export-003", "camera": "garage", "name": "Garage - In Progress", "date": 1775492531.3863528, "video_path": "/exports/export-003.mp4", "thumb_path": "/exports/export-003-thumb.jpg", "in_progress": true, "export_case_id": null}]
|
||||
[{"id": "export-001", "camera": "front_door", "name": "Front Door - Person Alert", "date": 1780680609.365581, "video_path": "/exports/export-001.mp4", "thumb_path": "/exports/export-001-thumb.jpg", "in_progress": false, "export_case_id": null}, {"id": "export-002", "camera": "backyard", "name": "Backyard - Car Detection", "date": 1780673409.365581, "video_path": "/exports/export-002.mp4", "thumb_path": "/exports/export-002-thumb.jpg", "in_progress": false, "export_case_id": "case-001"}, {"id": "export-003", "camera": "garage", "name": "Garage - In Progress", "date": 1780682409.365581, "video_path": "/exports/export-003.mp4", "thumb_path": "/exports/export-003-thumb.jpg", "in_progress": true, "export_case_id": null}]
|
||||
@@ -1 +1 @@
|
||||
{"2026-04-06": {"day": "2026-04-06", "reviewed_alert": 1, "reviewed_detection": 0, "total_alert": 2, "total_detection": 2}, "2026-04-05": {"day": "2026-04-05", "reviewed_alert": 3, "reviewed_detection": 2, "total_alert": 3, "total_detection": 4}}
|
||||
{"2026-06-05": {"day": "2026-06-05", "reviewed_alert": 1, "reviewed_detection": 0, "total_alert": 2, "total_detection": 2}, "2026-06-04": {"day": "2026-06-04", "reviewed_alert": 3, "reviewed_detection": 2, "total_alert": 3, "total_detection": 4}}
|
||||
@@ -1 +1 @@
|
||||
[{"id": "review-alert-001", "camera": "front_door", "start_time": "2026-04-06T09:52:11.386353", "end_time": "2026-04-06T09:52:41.386353", "has_been_reviewed": false, "severity": "alert", "thumb_path": "/clips/front_door/review-alert-001-thumb.jpg", "data": {"audio": [], "detections": ["person-abc123"], "objects": ["person"], "sub_labels": [], "significant_motion_areas": [], "zones": ["front_yard"]}}, {"id": "review-alert-002", "camera": "backyard", "start_time": "2026-04-06T08:52:11.386353", "end_time": "2026-04-06T08:52:56.386353", "has_been_reviewed": true, "severity": "alert", "thumb_path": "/clips/backyard/review-alert-002-thumb.jpg", "data": {"audio": [], "detections": ["car-def456"], "objects": ["car"], "sub_labels": [], "significant_motion_areas": [], "zones": ["driveway"]}}, {"id": "review-detect-001", "camera": "garage", "start_time": "2026-04-06T07:52:11.386353", "end_time": "2026-04-06T07:52:31.386353", "has_been_reviewed": false, "severity": "detection", "thumb_path": "/clips/garage/review-detect-001-thumb.jpg", "data": {"audio": [], "detections": ["person-ghi789"], "objects": ["person"], "sub_labels": [], "significant_motion_areas": [], "zones": []}}, {"id": "review-detect-002", "camera": "front_door", "start_time": "2026-04-06T06:52:11.386353", "end_time": "2026-04-06T06:52:26.386353", "has_been_reviewed": false, "severity": "detection", "thumb_path": "/clips/front_door/review-detect-002-thumb.jpg", "data": {"audio": [], "detections": ["car-jkl012"], "objects": ["car"], "sub_labels": [], "significant_motion_areas": [], "zones": ["front_yard"]}}]
|
||||
[{"id": "review-alert-001", "camera": "front_door", "start_time": "2026-06-05T11:30:09.365581", "end_time": "2026-06-05T11:30:39.365581", "has_been_reviewed": false, "severity": "alert", "thumb_path": "/clips/front_door/review-alert-001-thumb.jpg", "data": {"audio": [], "detections": ["person-abc123"], "objects": ["person"], "sub_labels": [], "significant_motion_areas": [], "zones": ["front_yard"]}}, {"id": "review-alert-002", "camera": "backyard", "start_time": "2026-06-05T10:30:09.365581", "end_time": "2026-06-05T10:30:54.365581", "has_been_reviewed": true, "severity": "alert", "thumb_path": "/clips/backyard/review-alert-002-thumb.jpg", "data": {"audio": [], "detections": ["car-def456"], "objects": ["car"], "sub_labels": [], "significant_motion_areas": [], "zones": ["driveway"]}}, {"id": "review-detect-001", "camera": "garage", "start_time": "2026-06-05T09:30:09.365581", "end_time": "2026-06-05T09:30:29.365581", "has_been_reviewed": false, "severity": "detection", "thumb_path": "/clips/garage/review-detect-001-thumb.jpg", "data": {"audio": [], "detections": ["person-ghi789"], "objects": ["person"], "sub_labels": [], "significant_motion_areas": [], "zones": []}}, {"id": "review-detect-002", "camera": "front_door", "start_time": "2026-06-05T08:30:09.365581", "end_time": "2026-06-05T08:30:24.365581", "has_been_reviewed": false, "severity": "detection", "thumb_path": "/clips/front_door/review-detect-002-thumb.jpg", "data": {"audio": [], "detections": ["car-jkl012"], "objects": ["car"], "sub_labels": [], "significant_motion_areas": [], "zones": ["front_yard"]}}]
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"listen": {
|
||||
"label": "Listen types",
|
||||
"description": "List of audio event types to detect (for example: bark, fire_alarm, scream, speech, yell)."
|
||||
"description": "List of audio event types to detect (for example: bark, fire_alarm, speech, yell)."
|
||||
},
|
||||
"filters": {
|
||||
"label": "Audio filters",
|
||||
@@ -156,7 +156,7 @@
|
||||
"description": "FFmpeg settings including binary path, args, hwaccel options, and per-role output args.",
|
||||
"path": {
|
||||
"label": "FFmpeg path",
|
||||
"description": "Path to the FFmpeg binary to use or a version alias (\"5.0\" or \"7.0\")."
|
||||
"description": "Path to the FFmpeg binary to use or a version alias (\"7.0\" or \"8.0\")."
|
||||
},
|
||||
"global_args": {
|
||||
"label": "FFmpeg global arguments",
|
||||
|
||||
@@ -547,7 +547,7 @@
|
||||
},
|
||||
"listen": {
|
||||
"label": "Listen types",
|
||||
"description": "List of audio event types to detect (for example: bark, fire_alarm, scream, speech, yell)."
|
||||
"description": "List of audio event types to detect (for example: bark, fire_alarm, speech, yell)."
|
||||
},
|
||||
"filters": {
|
||||
"label": "Audio filters",
|
||||
@@ -683,7 +683,7 @@
|
||||
"description": "FFmpeg settings including binary path, args, hwaccel options, and per-role output args.",
|
||||
"path": {
|
||||
"label": "FFmpeg path",
|
||||
"description": "Path to the FFmpeg binary to use or a version alias (\"5.0\" or \"7.0\")."
|
||||
"description": "Path to the FFmpeg binary to use or a version alias (\"7.0\" or \"8.0\")."
|
||||
},
|
||||
"global_args": {
|
||||
"label": "FFmpeg global arguments",
|
||||
|
||||
@@ -1914,6 +1914,9 @@
|
||||
"resolutionHigh": "This detect resolution is higher than recommended and may cause increased resource usage without improving detection accuracy. A detect resolution at or below 1080p is recommended for most cameras.",
|
||||
"globalResolutionMultipleCameras": "A global detect resolution is set while multiple cameras are configured. Unless all cameras share the same resolution and aspect ratio, the detect width and height should be defined per camera to match each camera's native aspect ratio."
|
||||
},
|
||||
"ffmpeg": {
|
||||
"hwaccelManualNotRecommended": "Manual hardware acceleration arguments are not recommended. Unless a specific requirement exists, select the preset that matches your hardware."
|
||||
},
|
||||
"objects": {
|
||||
"genaiNoDescriptionsProvider": "You must configure a GenAI provider with the 'descriptions' role for descriptions to be generated."
|
||||
},
|
||||
|
||||
@@ -174,6 +174,21 @@
|
||||
"error": "Error: {{error}}",
|
||||
"tips": {
|
||||
"title": "Camera Probe Info"
|
||||
},
|
||||
"keyframes": {
|
||||
"title": "Keyframe analysis",
|
||||
"analyzing": "Analyzing keyframes... {{seconds}} seconds remaining",
|
||||
"stillAnalyzing": "Still analyzing keyframes...",
|
||||
"recordStream": "Record stream:",
|
||||
"keyframeCount": "Keyframes observed:",
|
||||
"observedDuration": "Observed duration:",
|
||||
"gap": "Keyframe gap (min / avg / max):",
|
||||
"segmentLength": "Recording segment length:",
|
||||
"ok": "Keyframes every ~{{seconds}}s, good for recording and playback.",
|
||||
"warning": "Sparse or variable keyframes (longest gap ~{{seconds}}s), likely a smart codec (H.264+/H.265+), this is not recommended.",
|
||||
"error": "Keyframe gap (~{{seconds}}s) exceeds the recording segment length ({{segmentTime}}s). Some segments may have no keyframe, which breaks playback. Disable the smart/+ codec on the camera or shorten its keyframe interval.",
|
||||
"unknown": "Couldn't determine keyframe spacing.",
|
||||
"recordDisabled": "Recording is disabled for this camera."
|
||||
}
|
||||
},
|
||||
"framesAndDetections": "Frames / Detections",
|
||||
|
||||
@@ -22,6 +22,27 @@ const ffmpegArgsWidget = (
|
||||
const ffmpeg: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/ffmpeg_presets",
|
||||
fieldMessages: [
|
||||
{
|
||||
key: "hwaccel-manual-not-recommended",
|
||||
field: "hwaccel_args",
|
||||
position: "after",
|
||||
messageKey: "configMessages.ffmpeg.hwaccelManualNotRecommended",
|
||||
severity: "warning",
|
||||
condition: (ctx) => {
|
||||
// Manual mode is active when hwaccel_args is an explicit args list
|
||||
// or a non-preset string
|
||||
const value = ctx.formData?.hwaccel_args;
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return !value.startsWith("preset-");
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
],
|
||||
fieldDocs: {
|
||||
hwaccel_args: "/configuration/ffmpeg_presets#hwaccel-presets",
|
||||
"inputs.hwaccel_args": "/configuration/ffmpeg_presets#hwaccel-presets",
|
||||
|
||||
@@ -386,11 +386,14 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
||||
const beforeContent = renderCustom(beforeSpec);
|
||||
const afterContent = renderCustom(afterSpec);
|
||||
|
||||
// Read field-level conditional messages from FieldMessagesContext
|
||||
// Read field-level conditional messages from FieldMessagesContext.
|
||||
// For multi-schema fields (anyOf/oneOf), FieldTemplate renders twice for
|
||||
// the same path (wrapper + inner branch); skip the wrapper pass so the
|
||||
// message isn't shown twice, mirroring how labels/descriptions dedupe.
|
||||
const fieldPathStr = pathSegments.join(".");
|
||||
const fieldMessageSpecs = allFieldMessages.filter(
|
||||
(m) => m.field === fieldPathStr,
|
||||
);
|
||||
const fieldMessageSpecs = isMultiSchemaWrapper
|
||||
? []
|
||||
: allFieldMessages.filter((m) => m.field === fieldPathStr);
|
||||
const beforeMessages = fieldMessageSpecs.filter(
|
||||
(m) => (m.position ?? "before") === "before",
|
||||
);
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
DialogTitle,
|
||||
} from "../ui/dialog";
|
||||
import ActivityIndicator from "../indicators/activity-indicator";
|
||||
import { Ffprobe } from "@/types/stats";
|
||||
import KeyframeAnalysisSection from "./KeyframeAnalysisSection";
|
||||
import { Ffprobe, KeyframeAnalysis } from "@/types/stats";
|
||||
import { Button } from "../ui/button";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { CameraConfig } from "@/types/frigateConfig";
|
||||
@@ -30,6 +31,7 @@ export default function CameraInfoDialog({
|
||||
}: CameraInfoDialogProps) {
|
||||
const { t } = useTranslation(["views/system"]);
|
||||
const [ffprobeInfo, setFfprobeInfo] = useState<Ffprobe[]>();
|
||||
const [keyframeInfo, setKeyframeInfo] = useState<KeyframeAnalysis>();
|
||||
|
||||
useEffect(() => {
|
||||
axios
|
||||
@@ -67,7 +69,12 @@ export default function CameraInfoDialog({
|
||||
}, []);
|
||||
|
||||
const onCopyFfprobe = async () => {
|
||||
copy(JSON.stringify(ffprobeInfo));
|
||||
copy(
|
||||
JSON.stringify({
|
||||
ffprobe: ffprobeInfo,
|
||||
keyframe_analysis: keyframeInfo,
|
||||
}),
|
||||
);
|
||||
toast.success(t("cameras.toast.success.copyToClipboard"));
|
||||
};
|
||||
|
||||
@@ -96,7 +103,7 @@ export default function CameraInfoDialog({
|
||||
<Trans ns="views/system">cameras.info.streamDataFromFFPROBE</Trans>
|
||||
</DialogDescription>
|
||||
|
||||
<div className="mb-2 p-4">
|
||||
<div className="mb-2 p-4 text-sm">
|
||||
{ffprobeInfo ? (
|
||||
<div>
|
||||
{ffprobeInfo.map((stream, idx) => (
|
||||
@@ -184,6 +191,10 @@ export default function CameraInfoDialog({
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<KeyframeAnalysisSection
|
||||
cameraName={camera.name}
|
||||
onResult={setKeyframeInfo}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center">
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import axios from "axios";
|
||||
import { FaCircleCheck, FaTriangleExclamation } from "react-icons/fa6";
|
||||
import { LuX } from "react-icons/lu";
|
||||
import ActivityIndicator from "../indicators/activity-indicator";
|
||||
import { KeyframeAnalysis } from "@/types/stats";
|
||||
|
||||
const PROBE_WINDOW_SECONDS = 20;
|
||||
|
||||
type KeyframeAnalysisSectionProps = {
|
||||
cameraName: string;
|
||||
onResult?: (analysis: KeyframeAnalysis) => void;
|
||||
};
|
||||
|
||||
export default function KeyframeAnalysisSection({
|
||||
cameraName,
|
||||
onResult,
|
||||
}: KeyframeAnalysisSectionProps) {
|
||||
const { t } = useTranslation(["views/system"]);
|
||||
const [analysis, setAnalysis] = useState<KeyframeAnalysis>();
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [secondsRemaining, setSecondsRemaining] =
|
||||
useState(PROBE_WINDOW_SECONDS);
|
||||
|
||||
// fire the probe once on mount
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
axios
|
||||
.get("keyframe_analysis", { params: { camera: cameraName } })
|
||||
.then((res) => {
|
||||
if (active) {
|
||||
setAnalysis(res.data);
|
||||
onResult?.(res.data);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) {
|
||||
setFailed(true);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
// re-probing only depends on the camera; onResult is a stable setter
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cameraName]);
|
||||
|
||||
// countdown while waiting for the probe to return
|
||||
useEffect(() => {
|
||||
if (analysis || failed) {
|
||||
return;
|
||||
}
|
||||
const interval = setInterval(() => {
|
||||
setSecondsRemaining((s) => (s > 0 ? s - 1 : 0));
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [analysis, failed]);
|
||||
|
||||
const content = useMemo(() => {
|
||||
if (failed) {
|
||||
return <Row icon="unknown">{t("cameras.info.keyframes.unknown")}</Row>;
|
||||
}
|
||||
|
||||
if (!analysis) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<ActivityIndicator className="size-4" />
|
||||
<span>
|
||||
{secondsRemaining > 0
|
||||
? t("cameras.info.keyframes.analyzing", {
|
||||
seconds: secondsRemaining,
|
||||
})
|
||||
: t("cameras.info.keyframes.stillAnalyzing")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let summary;
|
||||
switch (analysis.severity) {
|
||||
case "ok":
|
||||
summary = (
|
||||
<Row icon="ok">
|
||||
{t("cameras.info.keyframes.ok", { seconds: analysis.mean_gap })}
|
||||
</Row>
|
||||
);
|
||||
break;
|
||||
case "warning":
|
||||
summary = (
|
||||
<Row icon="warning">
|
||||
{t("cameras.info.keyframes.warning", { seconds: analysis.max_gap })}
|
||||
</Row>
|
||||
);
|
||||
break;
|
||||
case "error":
|
||||
summary = (
|
||||
<Row icon="error">
|
||||
{t("cameras.info.keyframes.error", {
|
||||
seconds: analysis.max_gap,
|
||||
segmentTime: analysis.segment_time,
|
||||
})}
|
||||
</Row>
|
||||
);
|
||||
break;
|
||||
case "record_disabled":
|
||||
summary = (
|
||||
<Row icon="unknown">{t("cameras.info.keyframes.recordDisabled")}</Row>
|
||||
);
|
||||
break;
|
||||
default:
|
||||
summary = (
|
||||
<Row icon="unknown">{t("cameras.info.keyframes.unknown")}</Row>
|
||||
);
|
||||
}
|
||||
|
||||
// gap statistics are only meaningful once at least two keyframes were seen
|
||||
const hasStats = analysis.max_gap != null;
|
||||
const hasDetails = hasStats || analysis.stream_index != null;
|
||||
|
||||
return (
|
||||
<div className="text-muted-foreground">
|
||||
{analysis.stream_index != null && (
|
||||
<div>
|
||||
{t("cameras.info.keyframes.recordStream")}{" "}
|
||||
<span className="text-primary">
|
||||
{t("cameras.info.stream", { idx: analysis.stream_index + 1 })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{hasStats && (
|
||||
<div>
|
||||
<div>
|
||||
{t("cameras.info.keyframes.keyframeCount")}{" "}
|
||||
<span className="text-primary">{analysis.keyframe_count}</span>
|
||||
</div>
|
||||
<div>
|
||||
{t("cameras.info.keyframes.observedDuration")}{" "}
|
||||
<span className="text-primary">
|
||||
{analysis.duration_observed}s
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
{t("cameras.info.keyframes.gap")}{" "}
|
||||
<span className="text-primary">
|
||||
{analysis.min_gap}s / {analysis.mean_gap}s / {analysis.max_gap}s
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
{t("cameras.info.keyframes.segmentLength")}{" "}
|
||||
<span className="text-primary">{analysis.segment_time}s</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={hasDetails ? "mt-3" : undefined}>{summary}</div>
|
||||
</div>
|
||||
);
|
||||
}, [analysis, failed, secondsRemaining, t]);
|
||||
|
||||
return (
|
||||
<div className="mb-5">
|
||||
<div className="mb-1 rounded-md bg-secondary p-2 text-lg text-primary">
|
||||
{t("cameras.info.keyframes.title")}
|
||||
</div>
|
||||
<div className="ml-2">{content}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type RowProps = {
|
||||
icon: "ok" | "warning" | "error" | "unknown";
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
function Row({ icon, children }: RowProps) {
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
{icon === "ok" && (
|
||||
<FaCircleCheck className="mt-0.5 size-4 flex-shrink-0 text-success" />
|
||||
)}
|
||||
{icon === "warning" && (
|
||||
<FaTriangleExclamation className="mt-0.5 size-4 flex-shrink-0 text-yellow-500" />
|
||||
)}
|
||||
{icon === "error" && (
|
||||
<LuX className="mt-0.5 size-4 flex-shrink-0 text-danger" />
|
||||
)}
|
||||
{icon === "unknown" && (
|
||||
<FaTriangleExclamation className="mt-0.5 size-4 flex-shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-primary">{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -135,3 +135,22 @@ export type Ffprobe = {
|
||||
}[];
|
||||
};
|
||||
};
|
||||
|
||||
export type KeyframeSeverity =
|
||||
| "ok"
|
||||
| "warning"
|
||||
| "error"
|
||||
| "unknown"
|
||||
| "record_disabled";
|
||||
|
||||
export type KeyframeAnalysis = {
|
||||
severity: KeyframeSeverity;
|
||||
stream_index?: number;
|
||||
keyframe_count?: number;
|
||||
max_gap?: number | null;
|
||||
mean_gap?: number | null;
|
||||
min_gap?: number | null;
|
||||
duration_observed?: number | null;
|
||||
segment_time?: number;
|
||||
thresholds?: { warning: number; error: number };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user