mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-06-28 07:11:53 +03:00
Compare commits
12 Commits
525a6a1b56
...
d309d18fbe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d309d18fbe | ||
|
|
c25a522fcc | ||
|
|
db9e64c598 | ||
|
|
570e21340a | ||
|
|
c575fb223b | ||
|
|
9fa345f192 | ||
|
|
7b55c4b758 | ||
|
|
570e2e3f76 | ||
|
|
39fba9b0a7 | ||
|
|
328a26b169 | ||
|
|
311fb1bd19 | ||
|
|
48b1426891 |
@ -179,7 +179,7 @@ The FeatureList on the [ONVIF Conformant Products Database](https://www.onvif.or
|
||||
| Hikvision DS-2DE3A404IWG-E/W | ✅ | ✅ | |
|
||||
| Reolink | ✅ | ❌ | |
|
||||
| Speco O8P32X | ✅ | ❌ | |
|
||||
| Sunba 405-D20X | ✅ | ❌ | Incomplete ONVIF support reported on original, and 4k models. All models are suspected incompatable. |
|
||||
| Sunba 405-D20X | ✅ | ❌ | Incomplete ONVIF support reported on original, and 4k models. All models are suspected incompatible. |
|
||||
| Tapo | ✅ | ❌ | Many models supported, ONVIF Service Port: 2020 |
|
||||
| Uniview IPC672LR-AX4DUPK | ✅ | ❌ | Firmware says FOV relative movement is supported, but camera doesn't actually move when sending ONVIF commands |
|
||||
| Uniview IPC6612SR-X33-VG | ✅ | ✅ | Leave `calibrate_on_startup` as `False`. A user has reported that zooming with `absolute` is working. |
|
||||
|
||||
@ -660,7 +660,7 @@ Note that the labelmap uses a subset of the complete COCO label set that has onl
|
||||
|
||||
#### RF-DETR
|
||||
|
||||
[RF-DETR](https://github.com/roboflow/rf-detr) is a DETR based model. The ONNX exported models are supported, but not included by default. See [the models section](#downloading-rf-detr-model) for more informatoin on downloading the RF-DETR model for use in Frigate.
|
||||
[RF-DETR](https://github.com/roboflow/rf-detr) is a DETR based model. The ONNX exported models are supported, but not included by default. See [the models section](#downloading-rf-detr-model) for more information on downloading the RF-DETR model for use in Frigate.
|
||||
|
||||
:::warning
|
||||
|
||||
|
||||
@ -749,7 +749,7 @@ Failure to remap port 5000 on the host will result in the WebUI and all API endp
|
||||
|
||||
:::
|
||||
|
||||
Docker containers on macOS can be orchestrated by either [Docker Desktop](https://docs.docker.com/desktop/setup/install/mac-install/) or [OrbStack](https://orbstack.dev) (native swift app). The difference in inference speeds is negligable, however CPU, power consumption and container start times will be lower on OrbStack because it is a native Swift application.
|
||||
Docker containers on macOS can be orchestrated by either [Docker Desktop](https://docs.docker.com/desktop/setup/install/mac-install/) or [OrbStack](https://orbstack.dev) (native Swift app). The difference in inference speeds is negligible, however CPU, power consumption and container start times will be lower on OrbStack because it is a native Swift application.
|
||||
|
||||
To allow Frigate to use the Apple Silicon Neural Engine / Processing Unit (NPU) the host must be running [Apple Silicon Detector](../configuration/object_detectors.md#apple-silicon-detector) on the host (outside Docker)
|
||||
|
||||
@ -768,7 +768,7 @@ services:
|
||||
- /path/to/your/recordings:/recordings
|
||||
ports:
|
||||
- "8971:8971"
|
||||
# If exposing on macOS map to a diffent host port like 5001 or any orher port with no conflicts
|
||||
# If exposing on macOS map to a different host port like 5001 or any other port with no conflicts
|
||||
# - "5001:5000" # Internal unauthenticated access. Expose carefully.
|
||||
- "8554:8554" # RTSP feeds
|
||||
extra_hosts:
|
||||
|
||||
@ -389,82 +389,106 @@ def events_explore(
|
||||
limit: int = 10,
|
||||
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
||||
):
|
||||
# get distinct labels for all events
|
||||
distinct_labels = (
|
||||
Event.select(Event.label)
|
||||
.where(Event.camera << allowed_cameras)
|
||||
.distinct()
|
||||
.order_by(Event.label)
|
||||
if not allowed_cameras:
|
||||
return JSONResponse(content=[])
|
||||
|
||||
explore_columns = (
|
||||
Event.id,
|
||||
Event.camera,
|
||||
Event.label,
|
||||
Event.sub_label,
|
||||
Event.zones,
|
||||
Event.start_time,
|
||||
Event.end_time,
|
||||
Event.has_clip,
|
||||
Event.has_snapshot,
|
||||
Event.plus_id,
|
||||
Event.retain_indefinitely,
|
||||
Event.top_score,
|
||||
Event.false_positive,
|
||||
Event.box,
|
||||
Event.data,
|
||||
)
|
||||
|
||||
label_counts = {}
|
||||
|
||||
def event_generator():
|
||||
for label_obj in distinct_labels.iterator():
|
||||
label = label_obj.label
|
||||
|
||||
# get most recent events for this label
|
||||
label_events = (
|
||||
Event.select()
|
||||
.where((Event.label == label) & (Event.camera << allowed_cameras))
|
||||
.order_by(Event.start_time.desc())
|
||||
.limit(limit)
|
||||
.iterator()
|
||||
)
|
||||
|
||||
# count total events for this label
|
||||
label_counts[label] = (
|
||||
Event.select()
|
||||
.where((Event.label == label) & (Event.camera << allowed_cameras))
|
||||
.count()
|
||||
)
|
||||
|
||||
yield from label_events
|
||||
|
||||
def process_events():
|
||||
for event in event_generator():
|
||||
processed_event = {
|
||||
"id": event.id,
|
||||
"camera": event.camera,
|
||||
"label": event.label,
|
||||
"zones": event.zones,
|
||||
"start_time": event.start_time,
|
||||
"end_time": event.end_time,
|
||||
"has_clip": event.has_clip,
|
||||
"has_snapshot": event.has_snapshot,
|
||||
"plus_id": event.plus_id,
|
||||
"retain_indefinitely": event.retain_indefinitely,
|
||||
"sub_label": event.sub_label,
|
||||
"top_score": event.top_score,
|
||||
"false_positive": event.false_positive,
|
||||
"box": event.box,
|
||||
"data": {
|
||||
k: v
|
||||
for k, v in event.data.items()
|
||||
if k
|
||||
in [
|
||||
"type",
|
||||
"score",
|
||||
"top_score",
|
||||
"description",
|
||||
"sub_label_score",
|
||||
"average_estimated_speed",
|
||||
"velocity_angle",
|
||||
"path_data",
|
||||
"recognized_license_plate",
|
||||
"recognized_license_plate_score",
|
||||
]
|
||||
},
|
||||
"event_count": label_counts[event.label],
|
||||
}
|
||||
yield processed_event
|
||||
|
||||
# convert iterator to list and sort
|
||||
processed_events = sorted(
|
||||
process_events(),
|
||||
key=lambda x: (x["event_count"], x["start_time"]),
|
||||
reverse=True,
|
||||
# Single query: per-label COUNT and top-N ranking by start_time computed
|
||||
# via window functions in a CTE, then filtered to rn <= limit
|
||||
event_count = (
|
||||
fn.COUNT(Event.id).over(partition_by=[Event.label]).alias("event_count")
|
||||
)
|
||||
rn = (
|
||||
fn.ROW_NUMBER()
|
||||
.over(partition_by=[Event.label], order_by=[Event.start_time.desc()])
|
||||
.alias("rn")
|
||||
)
|
||||
|
||||
base_query = Event.select(
|
||||
*explore_columns,
|
||||
event_count,
|
||||
rn,
|
||||
).where(Event.camera << allowed_cameras)
|
||||
ranked = base_query.cte("ranked")
|
||||
query = (
|
||||
Event.select(
|
||||
ranked.c.id,
|
||||
ranked.c.camera,
|
||||
ranked.c.label,
|
||||
ranked.c.sub_label,
|
||||
ranked.c.zones,
|
||||
ranked.c.start_time,
|
||||
ranked.c.end_time,
|
||||
ranked.c.has_clip,
|
||||
ranked.c.has_snapshot,
|
||||
ranked.c.plus_id,
|
||||
ranked.c.retain_indefinitely,
|
||||
ranked.c.top_score,
|
||||
ranked.c.false_positive,
|
||||
ranked.c.box,
|
||||
ranked.c.data,
|
||||
ranked.c.event_count,
|
||||
)
|
||||
.from_(ranked)
|
||||
.with_cte(ranked)
|
||||
.where(ranked.c.rn <= limit)
|
||||
.order_by(ranked.c.event_count.desc(), ranked.c.start_time.desc())
|
||||
.objects()
|
||||
)
|
||||
|
||||
allowed_data_keys = {
|
||||
"type",
|
||||
"score",
|
||||
"top_score",
|
||||
"description",
|
||||
"sub_label_score",
|
||||
"average_estimated_speed",
|
||||
"velocity_angle",
|
||||
"path_data",
|
||||
"recognized_license_plate",
|
||||
"recognized_license_plate_score",
|
||||
}
|
||||
|
||||
processed_events = [
|
||||
{
|
||||
"id": event.id,
|
||||
"camera": event.camera,
|
||||
"label": event.label,
|
||||
"zones": event.zones,
|
||||
"start_time": event.start_time,
|
||||
"end_time": event.end_time,
|
||||
"has_clip": event.has_clip,
|
||||
"has_snapshot": event.has_snapshot,
|
||||
"plus_id": event.plus_id,
|
||||
"retain_indefinitely": event.retain_indefinitely,
|
||||
"sub_label": event.sub_label,
|
||||
"top_score": event.top_score,
|
||||
"false_positive": event.false_positive,
|
||||
"box": event.box,
|
||||
"data": {
|
||||
k: v for k, v in (event.data or {}).items() if k in allowed_data_keys
|
||||
},
|
||||
"event_count": event.event_count,
|
||||
}
|
||||
for event in query
|
||||
]
|
||||
|
||||
return JSONResponse(content=processed_events)
|
||||
|
||||
@ -487,22 +511,18 @@ async def event_ids(ids: str, request: Request):
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
for event_id in ids:
|
||||
try:
|
||||
event = Event.get(Event.id == event_id)
|
||||
await require_camera_access(event.camera, request=request)
|
||||
except DoesNotExist:
|
||||
# we should not fail the entire request if an event is not found
|
||||
continue
|
||||
|
||||
try:
|
||||
events = Event.select().where(Event.id << ids).dicts().iterator()
|
||||
return JSONResponse(list(events))
|
||||
events = list(Event.select().where(Event.id << ids).dicts().iterator())
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
content=({"success": False, "message": "Events not found"}), status_code=400
|
||||
)
|
||||
|
||||
for event in events:
|
||||
await require_camera_access(event["camera"], request=request)
|
||||
|
||||
return JSONResponse(events)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/events/search",
|
||||
|
||||
@ -299,22 +299,36 @@ async def no_recordings(
|
||||
.iterator()
|
||||
)
|
||||
|
||||
# Convert recordings to list of (start, end) tuples
|
||||
# Convert recordings to list of (start, end) tuples, ordered by start_time
|
||||
recordings = [(r["start_time"], r["end_time"]) for r in data]
|
||||
|
||||
# Merge overlapping/adjacent recordings into covered intervals. The query
|
||||
# orders by start_time, so a single pass merges them
|
||||
covered: list[tuple[float, float]] = []
|
||||
for rec_start, rec_end in recordings:
|
||||
if covered and rec_start <= covered[-1][1]:
|
||||
covered[-1] = (covered[-1][0], max(covered[-1][1], rec_end))
|
||||
else:
|
||||
covered.append((rec_start, rec_end))
|
||||
|
||||
# Iterate through time segments and check if each has any recording
|
||||
no_recording_segments = []
|
||||
current = after
|
||||
current_gap_start = None
|
||||
idx = 0
|
||||
covered_count = len(covered)
|
||||
|
||||
while current < before:
|
||||
segment_end = min(current + scale, before)
|
||||
|
||||
# Check if this segment overlaps with any recording
|
||||
has_recording = any(
|
||||
rec_start < segment_end and rec_end > current
|
||||
for rec_start, rec_end in recordings
|
||||
)
|
||||
# Advance past covered intervals that end before this segment begins;
|
||||
# they cannot overlap this or any later segment.
|
||||
while idx < covered_count and covered[idx][1] <= current:
|
||||
idx += 1
|
||||
|
||||
# A covered interval overlaps the segment when it starts before the
|
||||
# segment ends (its end is already known to be > current).
|
||||
has_recording = idx < covered_count and covered[idx][0] < segment_end
|
||||
|
||||
if not has_recording:
|
||||
# This segment has no recordings
|
||||
|
||||
@ -10,7 +10,7 @@ import pandas as pd
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.params import Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from peewee import Case, DoesNotExist, IntegrityError, fn, operator
|
||||
from peewee import Case, DoesNotExist, fn, operator
|
||||
from playhouse.shortcuts import model_to_dict
|
||||
|
||||
from frigate.api.auth import (
|
||||
@ -172,11 +172,19 @@ async def review_ids(request: Request, ids: str):
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
try:
|
||||
reviews = list(
|
||||
ReviewSegment.select().where(ReviewSegment.id << ids).dicts().iterator()
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
content=({"success": False, "message": "Review segments not found"}),
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
found_ids = {r["id"] for r in reviews}
|
||||
for review_id in ids:
|
||||
try:
|
||||
review = ReviewSegment.get(ReviewSegment.id == review_id)
|
||||
await require_camera_access(review.camera, request=request)
|
||||
except DoesNotExist:
|
||||
if review_id not in found_ids:
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{"success": False, "message": f"Review {review_id} not found"}
|
||||
@ -184,16 +192,10 @@ async def review_ids(request: Request, ids: str):
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
try:
|
||||
reviews = (
|
||||
ReviewSegment.select().where(ReviewSegment.id << ids).dicts().iterator()
|
||||
)
|
||||
return JSONResponse(list(reviews))
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
content=({"success": False, "message": "Review segments not found"}),
|
||||
status_code=400,
|
||||
)
|
||||
for review in reviews:
|
||||
await require_camera_access(review["camera"], request=request)
|
||||
|
||||
return JSONResponse(reviews)
|
||||
|
||||
|
||||
@router.get(
|
||||
@ -490,27 +492,52 @@ async def set_multiple_reviewed(
|
||||
|
||||
user_id = current_user["username"]
|
||||
|
||||
for review_id in body.ids:
|
||||
try:
|
||||
review = ReviewSegment.get(ReviewSegment.id == review_id)
|
||||
await require_camera_access(review.camera, request=request)
|
||||
review_status = UserReviewStatus.get(
|
||||
UserReviewStatus.user_id == user_id,
|
||||
UserReviewStatus.review_segment == review_id,
|
||||
reviews = list(
|
||||
ReviewSegment.select(ReviewSegment.id, ReviewSegment.camera).where(
|
||||
ReviewSegment.id << body.ids
|
||||
)
|
||||
)
|
||||
|
||||
for review in reviews:
|
||||
await require_camera_access(review.camera, request=request)
|
||||
|
||||
found_ids = [r.id for r in reviews]
|
||||
|
||||
if found_ids:
|
||||
existing_statuses = list(
|
||||
UserReviewStatus.select().where(
|
||||
(UserReviewStatus.user_id == user_id)
|
||||
& (UserReviewStatus.review_segment << found_ids)
|
||||
)
|
||||
# Update based on the reviewed parameter
|
||||
if review_status.has_been_reviewed != body.reviewed:
|
||||
review_status.has_been_reviewed = body.reviewed
|
||||
review_status.save()
|
||||
except DoesNotExist:
|
||||
try:
|
||||
UserReviewStatus.create(
|
||||
user_id=user_id,
|
||||
review_segment=ReviewSegment.get(id=review_id),
|
||||
has_been_reviewed=body.reviewed,
|
||||
)
|
||||
|
||||
status_by_review = {s.review_segment_id: s for s in existing_statuses}
|
||||
|
||||
to_update = []
|
||||
to_create = []
|
||||
|
||||
for review_id in found_ids:
|
||||
if review_id in status_by_review:
|
||||
status = status_by_review[review_id]
|
||||
if status.has_been_reviewed != body.reviewed:
|
||||
status.has_been_reviewed = body.reviewed
|
||||
to_update.append(status)
|
||||
else:
|
||||
to_create.append(
|
||||
{
|
||||
"user_id": user_id,
|
||||
"review_segment_id": review_id,
|
||||
"has_been_reviewed": body.reviewed,
|
||||
}
|
||||
)
|
||||
except (DoesNotExist, IntegrityError):
|
||||
pass
|
||||
|
||||
if to_update:
|
||||
UserReviewStatus.bulk_update(
|
||||
to_update, fields=[UserReviewStatus.has_been_reviewed], batch_size=100
|
||||
)
|
||||
|
||||
if to_create:
|
||||
UserReviewStatus.insert_many(to_create).execute()
|
||||
|
||||
return JSONResponse(
|
||||
content=(
|
||||
@ -605,9 +632,10 @@ def motion_activity(
|
||||
if not filtered:
|
||||
return JSONResponse(content=[])
|
||||
camera_list = list(filtered)
|
||||
clauses.append((Recordings.camera << camera_list))
|
||||
else:
|
||||
clauses.append((Recordings.camera << allowed_cameras))
|
||||
camera_list = list(allowed_cameras)
|
||||
|
||||
clauses.append((Recordings.camera << camera_list))
|
||||
|
||||
data: list[Recordings] = (
|
||||
Recordings.select(
|
||||
@ -635,14 +663,12 @@ def motion_activity(
|
||||
df.set_index(["start_time"], inplace=True)
|
||||
|
||||
# normalize data
|
||||
motion = (
|
||||
df["motion"]
|
||||
.resample(f"{scale}s")
|
||||
.apply(lambda x: max(x, key=abs, default=0.0))
|
||||
.fillna(0.0)
|
||||
.to_frame()
|
||||
)
|
||||
cameras = df["camera"].resample(f"{scale}s").agg(lambda x: ",".join(set(x)))
|
||||
motion = df["motion"].resample(f"{scale}s").max().fillna(0.0).to_frame()
|
||||
|
||||
if len(camera_list) == 1:
|
||||
cameras = df["camera"].resample(f"{scale}s").first().fillna("")
|
||||
else:
|
||||
cameras = df["camera"].resample(f"{scale}s").agg(lambda x: ",".join(set(x)))
|
||||
df = motion.join(cameras)
|
||||
|
||||
length = df.shape[0]
|
||||
@ -658,6 +684,11 @@ def motion_activity(
|
||||
else:
|
||||
df.iloc[i : i + chunk, 0] = 0.0
|
||||
|
||||
# Drop resample gap-fill buckets. The resample above emits a row for every
|
||||
# {scale}s bucket spanning the range, and buckets with no recording get a
|
||||
# motion of 0 (from fillna) and an empty camera (from joining an empty set).
|
||||
df = df[df["camera"] != ""]
|
||||
|
||||
# change types for output
|
||||
df.index = df.index.astype(int) // (10**9)
|
||||
normalized = df.reset_index().to_dict("records")
|
||||
|
||||
@ -5,7 +5,7 @@ import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
@ -34,6 +34,45 @@ PROFILE_SECTION_UPDATES: dict[str, CameraConfigUpdateEnum] = {
|
||||
"zones": CameraConfigUpdateEnum.zones,
|
||||
}
|
||||
|
||||
# Retained MQTT switch topics per profile section, with a payload getter.
|
||||
# Republished on profile change so MQTT/HA don't show a stale toggle.
|
||||
SECTION_STATE_TOPICS: dict[str, list[tuple[str, Callable[[Any], Any]]]] = {
|
||||
"audio": [("audio", lambda c: "ON" if c.audio.enabled else "OFF")],
|
||||
"birdseye": [
|
||||
("birdseye", lambda c: "ON" if c.birdseye.enabled else "OFF"),
|
||||
(
|
||||
"birdseye_mode",
|
||||
lambda c: c.birdseye.mode.value.upper() if c.birdseye.enabled else "OFF",
|
||||
),
|
||||
],
|
||||
"detect": [("detect", lambda c: "ON" if c.detect.enabled else "OFF")],
|
||||
"motion": [
|
||||
("motion", lambda c: "ON" if c.motion.enabled else "OFF"),
|
||||
("improve_contrast", lambda c: "ON" if c.motion.improve_contrast else "OFF"),
|
||||
("motion_threshold", lambda c: c.motion.threshold),
|
||||
("motion_contour_area", lambda c: c.motion.contour_area),
|
||||
],
|
||||
"notifications": [
|
||||
("notifications", lambda c: "ON" if c.notifications.enabled else "OFF"),
|
||||
],
|
||||
"objects": [
|
||||
("object_descriptions", lambda c: "ON" if c.objects.genai.enabled else "OFF"),
|
||||
],
|
||||
"record": [("recordings", lambda c: "ON" if c.record.enabled else "OFF")],
|
||||
"review": [
|
||||
("review_alerts", lambda c: "ON" if c.review.alerts.enabled else "OFF"),
|
||||
(
|
||||
"review_detections",
|
||||
lambda c: "ON" if c.review.detections.enabled else "OFF",
|
||||
),
|
||||
(
|
||||
"review_descriptions",
|
||||
lambda c: "ON" if c.review.genai.enabled else "OFF",
|
||||
),
|
||||
],
|
||||
"snapshots": [("snapshots", lambda c: "ON" if c.snapshots.enabled else "OFF")],
|
||||
}
|
||||
|
||||
PERSISTENCE_FILE = Path(CONFIG_DIR) / ".profiles"
|
||||
|
||||
|
||||
@ -310,6 +349,15 @@ class ProfileManager:
|
||||
settings,
|
||||
)
|
||||
|
||||
# republish MQTT switch states
|
||||
if self.dispatcher is not None:
|
||||
for suffix, get_payload in SECTION_STATE_TOPICS.get(section, ()):
|
||||
self.dispatcher.publish(
|
||||
f"{cam_name}/{suffix}/state",
|
||||
get_payload(cam_config),
|
||||
retain=True,
|
||||
)
|
||||
|
||||
def _persist_active_profile(self, profile_name: Optional[str]) -> None:
|
||||
"""Persist the active profile state to disk as JSON."""
|
||||
try:
|
||||
|
||||
@ -403,3 +403,75 @@ class TestHttpMedia(BaseTestHttp):
|
||||
assert len(summary) == 1
|
||||
assert "2024-03-10" in summary
|
||||
assert summary["2024-03-10"] is True
|
||||
|
||||
def test_recordings_unavailable_reports_gap_between_recordings(self):
|
||||
"""A gap between two recordings is reported as an unavailable segment."""
|
||||
with AuthTestClient(self.app) as client:
|
||||
# Two recordings with a 20s gap (1010-1030) between them.
|
||||
Recordings.insert(
|
||||
id="rec_a",
|
||||
path="/media/recordings/a.mp4",
|
||||
camera="front_door",
|
||||
start_time=1000,
|
||||
end_time=1010,
|
||||
duration=10,
|
||||
motion=0,
|
||||
).execute()
|
||||
Recordings.insert(
|
||||
id="rec_b",
|
||||
path="/media/recordings/b.mp4",
|
||||
camera="front_door",
|
||||
start_time=1030,
|
||||
end_time=1040,
|
||||
duration=10,
|
||||
motion=0,
|
||||
).execute()
|
||||
|
||||
response = client.get(
|
||||
"/recordings/unavailable",
|
||||
params={
|
||||
"after": 1000,
|
||||
"before": 1040,
|
||||
"scale": 5,
|
||||
"cameras": "front_door",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == [{"start_time": 1010, "end_time": 1030}]
|
||||
|
||||
def test_recordings_unavailable_merges_overlapping_recordings(self):
|
||||
"""Overlapping recordings are merged so no false gap is reported."""
|
||||
with AuthTestClient(self.app) as client:
|
||||
# Overlapping recordings spanning the whole requested range.
|
||||
Recordings.insert(
|
||||
id="rec_a",
|
||||
path="/media/recordings/a.mp4",
|
||||
camera="front_door",
|
||||
start_time=1000,
|
||||
end_time=1020,
|
||||
duration=20,
|
||||
motion=0,
|
||||
).execute()
|
||||
Recordings.insert(
|
||||
id="rec_b",
|
||||
path="/media/recordings/b.mp4",
|
||||
camera="front_door",
|
||||
start_time=1010,
|
||||
end_time=1030,
|
||||
duration=20,
|
||||
motion=0,
|
||||
).execute()
|
||||
|
||||
response = client.get(
|
||||
"/recordings/unavailable",
|
||||
params={
|
||||
"after": 1000,
|
||||
"before": 1030,
|
||||
"scale": 5,
|
||||
"cameras": "front_door",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
|
||||
@ -610,19 +610,16 @@ class TestHttpReview(BaseTestHttp):
|
||||
response = client.get("/review/activity/motion", params=params)
|
||||
assert response.status_code == 200
|
||||
response_json = response.json()
|
||||
assert len(response_json) == 61
|
||||
# Only buckets with an actual recording are returned. Empty
|
||||
# gap-fill buckets between the two recordings are dropped.
|
||||
assert len(response_json) == 2
|
||||
self.assertDictEqual(
|
||||
{"motion": 50.5, "camera": "front_door", "start_time": now + 1},
|
||||
response_json[0],
|
||||
)
|
||||
for item in response_json[1:-1]:
|
||||
self.assertDictEqual(
|
||||
{"motion": 0.0, "camera": "", "start_time": item["start_time"]},
|
||||
item,
|
||||
)
|
||||
self.assertDictEqual(
|
||||
{"motion": 100.0, "camera": "front_door", "start_time": one_m + 1},
|
||||
response_json[len(response_json) - 1],
|
||||
response_json[1],
|
||||
)
|
||||
|
||||
####################################################################################################################
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
"""Tests for the profiles system."""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import unittest
|
||||
@ -746,6 +747,36 @@ class TestProfileManager(unittest.TestCase):
|
||||
manager.activate_profile(None)
|
||||
dispatcher.clear_runtime_state.assert_called_once_with()
|
||||
|
||||
@patch.object(ProfileManager, "_persist_active_profile")
|
||||
def test_profile_change_republishes_switch_states(self, mock_persist):
|
||||
"""Profile changes republish MQTT switch states so HA stays in sync.
|
||||
|
||||
Regression: activating/deactivating a profile updated the in-memory
|
||||
config (and Frigate's behavior) but left the retained MQTT state
|
||||
topics stale, so external integrations like Home Assistant kept
|
||||
showing the pre-profile toggle position.
|
||||
"""
|
||||
config_data = copy.deepcopy(self.config_data)
|
||||
config_data["cameras"]["front"]["profiles"]["disarmed"]["review"] = {
|
||||
"alerts": {"enabled": False},
|
||||
}
|
||||
config = FrigateConfig(**config_data)
|
||||
dispatcher = MagicMock()
|
||||
manager = ProfileManager(config, self.mock_updater, dispatcher)
|
||||
|
||||
# Activating disarmed turns alerts off -> MQTT state must follow
|
||||
manager.activate_profile("disarmed")
|
||||
dispatcher.publish.assert_any_call(
|
||||
"front/review_alerts/state", "OFF", retain=True
|
||||
)
|
||||
|
||||
# Deactivating restores the base (alerts on) -> MQTT state must follow
|
||||
dispatcher.publish.reset_mock()
|
||||
manager.activate_profile(None)
|
||||
dispatcher.publish.assert_any_call(
|
||||
"front/review_alerts/state", "ON", retain=True
|
||||
)
|
||||
|
||||
@patch.object(ProfileManager, "_persist_active_profile")
|
||||
def test_startup_replay_does_not_clear_runtime_state(self, mock_persist):
|
||||
"""Startup callers pass clear_runtime_overrides=False to preserve state."""
|
||||
|
||||
27
migrations/036_add_perf_indexes.py
Normal file
27
migrations/036_add_perf_indexes.py
Normal file
@ -0,0 +1,27 @@
|
||||
"""Peewee migrations -- 036_add_perf_indexes.py.
|
||||
|
||||
Adds composite/single-column indexes to speed up the most common queries
|
||||
issued by the web UI on initial page load:
|
||||
|
||||
- event(camera, start_time DESC): /events list filtered by camera + time range
|
||||
- reviewsegment(camera, start_time DESC): /api/review filtered by camera + time range
|
||||
- reviewsegment(end_time): supports the end_time > after half of /api/review's range
|
||||
|
||||
The existing event(label, start_time DESC) index from migration 027 already
|
||||
covers /events/explore, so it is intentionally not duplicated here.
|
||||
"""
|
||||
|
||||
import peewee as pw
|
||||
|
||||
SQL = pw.SQL
|
||||
|
||||
|
||||
def migrate(migrator, database, fake=False, **kwargs):
|
||||
migrator.sql(
|
||||
'CREATE INDEX IF NOT EXISTS "event_camera_start_time" '
|
||||
'ON "event" ("camera", "start_time" DESC)'
|
||||
)
|
||||
|
||||
|
||||
def rollback(migrator, database, fake=False, **kwargs):
|
||||
migrator.sql('DROP INDEX IF EXISTS "event_camera_start_time"')
|
||||
@ -12,7 +12,7 @@ export const JINA_EMBEDDING_MODELS = ["jinav1", "jinav2"] as const;
|
||||
export const REDACTED_CREDENTIAL_SENTINEL = "__FRIGATE_SAVED_CREDENTIAL__";
|
||||
|
||||
export const ANNOTATION_OFFSET_MIN = -10000;
|
||||
export const ANNOTATION_OFFSET_MAX = 5000;
|
||||
export const ANNOTATION_OFFSET_MAX = 10000;
|
||||
export const ANNOTATION_OFFSET_STEP = 50;
|
||||
|
||||
export const supportedLanguageKeys = [
|
||||
|
||||
@ -53,6 +53,7 @@ export default function MasksAndZonesView({
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const [allPolygons, setAllPolygons] = useState<Polygon[]>([]);
|
||||
const [editingPolygons, setEditingPolygons] = useState<Polygon[]>([]);
|
||||
const [polygonsInitialized, setPolygonsInitialized] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [loadingPolygonIndex, setLoadingPolygonIndex] = useState<
|
||||
number | undefined
|
||||
@ -609,6 +610,7 @@ export default function MasksAndZonesView({
|
||||
...globalObjectMasks,
|
||||
...objectMasks,
|
||||
]);
|
||||
setPolygonsInitialized(true);
|
||||
// Don't overwrite editingPolygons during editing – layout shifts
|
||||
// from switching to the edit pane can trigger a resize which
|
||||
// recalculates scaledWidth/scaledHeight and would discard the
|
||||
@ -676,7 +678,7 @@ export default function MasksAndZonesView({
|
||||
}, [currentEditingProfile]);
|
||||
|
||||
useSearchEffect("object_mask", (coordinates: string) => {
|
||||
if (!scaledWidth || !scaledHeight || isLoading) {
|
||||
if (!scaledWidth || !scaledHeight || isLoading || !polygonsInitialized) {
|
||||
return false;
|
||||
}
|
||||
// convert box points string to points array
|
||||
|
||||
Loading…
Reference in New Issue
Block a user