mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-08 07:42:47 +03:00
Compare commits
5
Commits
48d09efd41
...
75bdb8a7b3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75bdb8a7b3 | ||
|
|
d02a1156b7 | ||
|
|
c17538aff9 | ||
|
|
dd7e9f1bc5 | ||
|
|
b0588a02f9 |
@@ -1,4 +1,4 @@
|
|||||||
aiofiles == 24.1.*
|
anyio == 4.14.*
|
||||||
click == 8.1.*
|
click == 8.1.*
|
||||||
# FastAPI
|
# FastAPI
|
||||||
aiohttp == 3.12.*
|
aiohttp == 3.12.*
|
||||||
|
|||||||
@@ -262,6 +262,19 @@ In this example:
|
|||||||
|
|
||||||
- Admin precedence: if the `admin` mapping matches, Frigate resolves the session to `admin` to avoid accidental downgrade when a user belongs to multiple groups (for example both `admin` and `viewer` groups).
|
- Admin precedence: if the `admin` mapping matches, Frigate resolves the session to `admin` to avoid accidental downgrade when a user belongs to multiple groups (for example both `admin` and `viewer` groups).
|
||||||
|
|
||||||
|
:::note
|
||||||
|
|
||||||
|
If a user isn't getting the role you expect, enable debug logging to see exactly what headers Frigate is receiving from your proxy:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
logger:
|
||||||
|
default: info
|
||||||
|
logs:
|
||||||
|
frigate.api.auth: debug
|
||||||
|
```
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
#### Port Considerations
|
#### Port Considerations
|
||||||
|
|
||||||
**Authenticated Port (8971)**
|
**Authenticated Port (8971)**
|
||||||
|
|||||||
+3
-3
@@ -14,8 +14,8 @@ from io import StringIO
|
|||||||
from pathlib import Path as FilePath
|
from pathlib import Path as FilePath
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import aiofiles
|
|
||||||
import ruamel.yaml
|
import ruamel.yaml
|
||||||
|
from anyio import open_file as aopen
|
||||||
from fastapi import APIRouter, Body, Path, Request, Response
|
from fastapi import APIRouter, Body, Path, Request, Response
|
||||||
from fastapi.encoders import jsonable_encoder
|
from fastapi.encoders import jsonable_encoder
|
||||||
from fastapi.params import Depends
|
from fastapi.params import Depends
|
||||||
@@ -1045,7 +1045,7 @@ async def logs(
|
|||||||
"""Asynchronously stream log lines."""
|
"""Asynchronously stream log lines."""
|
||||||
buffer = ""
|
buffer = ""
|
||||||
try:
|
try:
|
||||||
async with aiofiles.open(file_path) as file:
|
async with await aopen(file_path) as file:
|
||||||
await file.seek(0, 2)
|
await file.seek(0, 2)
|
||||||
while True:
|
while True:
|
||||||
line = await file.readline()
|
line = await file.readline()
|
||||||
@@ -1083,7 +1083,7 @@ async def logs(
|
|||||||
|
|
||||||
# For full logs initially
|
# For full logs initially
|
||||||
try:
|
try:
|
||||||
async with aiofiles.open(service_location) as file:
|
async with await aopen(service_location) as file:
|
||||||
contents = await file.read()
|
contents = await file.read()
|
||||||
|
|
||||||
total_lines, log_lines = process_logs(contents, service, start, end)
|
total_lines, log_lines = process_logs(contents, service, start, end)
|
||||||
|
|||||||
+12
-9
@@ -10,6 +10,7 @@ from urllib.parse import quote_plus
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import requests
|
import requests
|
||||||
|
from anyio import open_file as aopen
|
||||||
from fastapi import APIRouter, Depends, Query, Request, Response
|
from fastapi import APIRouter, Depends, Query, Request, Response
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from filelock import FileLock, Timeout
|
from filelock import FileLock, Timeout
|
||||||
@@ -1188,15 +1189,17 @@ async def delete_camera(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
with lock:
|
with lock:
|
||||||
with open(config_file) as f:
|
async with await aopen(config_file) as f:
|
||||||
old_raw_config = f.read()
|
old_raw_config = await f.read()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
yaml = YAML()
|
yaml = YAML()
|
||||||
yaml.indent(mapping=2, sequence=4, offset=2)
|
yaml.indent(mapping=2, sequence=4, offset=2)
|
||||||
|
|
||||||
with open(config_file) as f:
|
async with await aopen(config_file) as f:
|
||||||
data = yaml.load(f)
|
text = await f.read()
|
||||||
|
|
||||||
|
data = yaml.load(text)
|
||||||
|
|
||||||
# Remove camera from config
|
# Remove camera from config
|
||||||
if "cameras" in data and camera_name in data["cameras"]:
|
if "cameras" in data and camera_name in data["cameras"]:
|
||||||
@@ -1221,17 +1224,17 @@ async def delete_camera(
|
|||||||
for role_name in empty_roles:
|
for role_name in empty_roles:
|
||||||
del auth["roles"][role_name]
|
del auth["roles"][role_name]
|
||||||
|
|
||||||
with open(config_file, "w") as f:
|
async with await aopen(config_file, "w") as f:
|
||||||
yaml.dump(data, f)
|
yaml.dump(data, f)
|
||||||
|
|
||||||
with open(config_file) as f:
|
async with await aopen(config_file) as f:
|
||||||
new_raw_config = f.read()
|
new_raw_config = await f.read()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
config = FrigateConfig.parse(new_raw_config)
|
config = FrigateConfig.parse(new_raw_config)
|
||||||
except Exception:
|
except Exception:
|
||||||
with open(config_file, "w") as f:
|
async with await aopen(config_file, "w") as f:
|
||||||
f.write(old_raw_config)
|
await f.write(old_raw_config)
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"Config error after removing camera %s",
|
"Config error after removing camera %s",
|
||||||
camera_name,
|
camera_name,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from pathlib import Path
|
|||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
from anyio import Path as AsyncPath
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Request
|
||||||
from fastapi.params import Depends
|
from fastapi.params import Depends
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
@@ -1455,10 +1456,10 @@ async def set_attributes(
|
|||||||
dataset_dir = os.path.join(CLIPS_DIR, sanitize_filename(model_key), "dataset")
|
dataset_dir = os.path.join(CLIPS_DIR, sanitize_filename(model_key), "dataset")
|
||||||
available_labels = set()
|
available_labels = set()
|
||||||
|
|
||||||
if os.path.exists(dataset_dir):
|
if await AsyncPath(dataset_dir).exists():
|
||||||
for category_name in os.listdir(dataset_dir):
|
for category_name in os.listdir(dataset_dir):
|
||||||
category_dir = os.path.join(dataset_dir, category_name)
|
category_dir = os.path.join(dataset_dir, category_name)
|
||||||
if os.path.isdir(category_dir):
|
if await AsyncPath(category_dir).is_dir():
|
||||||
available_labels.add(category_name)
|
available_labels.add(category_name)
|
||||||
|
|
||||||
if not available_labels:
|
if not available_labels:
|
||||||
|
|||||||
+13
-11
@@ -15,6 +15,8 @@ from urllib.parse import unquote
|
|||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pytz
|
import pytz
|
||||||
|
from anyio import Path as AsyncPath
|
||||||
|
from anyio import open_file as aopen
|
||||||
from fastapi import APIRouter, Depends, Path, Query, Request, Response
|
from fastapi import APIRouter, Depends, Path, Query, Request, Response
|
||||||
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
||||||
from pathvalidate import sanitize_filename
|
from pathvalidate import sanitize_filename
|
||||||
@@ -497,18 +499,18 @@ async def recording_clip(
|
|||||||
|
|
||||||
file_name = sanitize_filename(f"playlist_{camera_name}_{start_ts}-{end_ts}.txt")
|
file_name = sanitize_filename(f"playlist_{camera_name}_{start_ts}-{end_ts}.txt")
|
||||||
file_path = os.path.join(CACHE_DIR, file_name)
|
file_path = os.path.join(CACHE_DIR, file_name)
|
||||||
with open(file_path, "w") as file:
|
async with await aopen(file_path, "w") as file:
|
||||||
clip: Recordings
|
clip: Recordings
|
||||||
for clip in recordings:
|
for clip in recordings:
|
||||||
file.write(f"file '{clip.path}'\n")
|
await file.write(f"file '{clip.path}'\n")
|
||||||
|
|
||||||
# if this is the starting clip, add an inpoint
|
# if this is the starting clip, add an inpoint
|
||||||
if clip.start_time < start_ts:
|
if clip.start_time < start_ts:
|
||||||
file.write(f"inpoint {int(start_ts - clip.start_time)}\n")
|
await file.write(f"inpoint {int(start_ts - clip.start_time)}\n")
|
||||||
|
|
||||||
# if this is the ending clip, add an outpoint
|
# if this is the ending clip, add an outpoint
|
||||||
if clip.end_time > end_ts:
|
if clip.end_time > end_ts:
|
||||||
file.write(f"outpoint {int(end_ts - clip.start_time)}\n")
|
await file.write(f"outpoint {int(end_ts - clip.start_time)}\n")
|
||||||
|
|
||||||
if len(file_name) > 1000:
|
if len(file_name) > 1000:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
@@ -1149,8 +1151,8 @@ async def event_snapshot_clean(request: Request, event_id: str, download: bool =
|
|||||||
)
|
)
|
||||||
|
|
||||||
if image_path.endswith(".webp"):
|
if image_path.endswith(".webp"):
|
||||||
with open(image_path, "rb") as image_file:
|
async with await aopen(image_path, "rb") as image_file:
|
||||||
webp_bytes = image_file.read()
|
webp_bytes = await image_file.read()
|
||||||
else:
|
else:
|
||||||
image = load_event_snapshot_image(event, clean_only=True)[0]
|
image = load_event_snapshot_image(event, clean_only=True)[0]
|
||||||
if image is None:
|
if image is None:
|
||||||
@@ -1366,7 +1368,7 @@ async def preview_gif(
|
|||||||
# need to generate from existing images
|
# need to generate from existing images
|
||||||
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
|
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
|
||||||
|
|
||||||
if not os.path.isdir(preview_dir):
|
if not await AsyncPath(preview_dir).is_dir():
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={"success": False, "message": "Preview not found"},
|
content={"success": False, "message": "Preview not found"},
|
||||||
status_code=404,
|
status_code=404,
|
||||||
@@ -1555,7 +1557,7 @@ async def preview_mp4(
|
|||||||
# need to generate from existing images
|
# need to generate from existing images
|
||||||
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
|
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
|
||||||
|
|
||||||
if not os.path.isdir(preview_dir):
|
if not await AsyncPath(preview_dir).is_dir():
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={"success": False, "message": "Preview not found"},
|
content={"success": False, "message": "Preview not found"},
|
||||||
status_code=404,
|
status_code=404,
|
||||||
@@ -1633,7 +1635,7 @@ async def preview_mp4(
|
|||||||
"Content-Description": "File Transfer",
|
"Content-Description": "File Transfer",
|
||||||
"Cache-Control": f"private, max-age={_resolve_cache_age(max_cache_age)}",
|
"Cache-Control": f"private, max-age={_resolve_cache_age(max_cache_age)}",
|
||||||
"Content-Type": "video/mp4",
|
"Content-Type": "video/mp4",
|
||||||
"Content-Length": str(os.path.getsize(path)),
|
"Content-Length": str((await AsyncPath(path).stat()).st_size),
|
||||||
# nginx: https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_ignore_headers
|
# nginx: https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_ignore_headers
|
||||||
"X-Accel-Redirect": f"/cache/{file_name}",
|
"X-Accel-Redirect": f"/cache/{file_name}",
|
||||||
}
|
}
|
||||||
@@ -1707,10 +1709,10 @@ async def preview_thumbnail(request: Request, file_name: str):
|
|||||||
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
|
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(
|
async with await aopen(
|
||||||
os.path.join(preview_dir, safe_file_name_current), "rb"
|
os.path.join(preview_dir, safe_file_name_current), "rb"
|
||||||
) as image_file:
|
) as image_file:
|
||||||
jpg_bytes = image_file.read()
|
jpg_bytes = await image_file.read()
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content=({"success": False, "message": "Image file not found"}),
|
content=({"success": False, "message": "Image file not found"}),
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import datetime as dt
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
from pathlib import Path
|
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
|
|
||||||
|
from anyio import Path as AsyncPath
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
from fastapi import Path as PathParam
|
from fastapi import Path as PathParam
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
@@ -443,7 +443,7 @@ async def delete_recordings(
|
|||||||
recording_ids.append(recording["id"])
|
recording_ids.append(recording["id"])
|
||||||
|
|
||||||
try:
|
try:
|
||||||
Path(recording["path"]).unlink(missing_ok=True)
|
await AsyncPath(recording["path"]).unlink(missing_ok=True)
|
||||||
deleted_count += 1
|
deleted_count += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to delete recording file {recording['path']}: {e}")
|
logger.error(f"Failed to delete recording file {recording['path']}: {e}")
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ from frigate.const import (
|
|||||||
EXPIRE_AUDIO_ACTIVITY,
|
EXPIRE_AUDIO_ACTIVITY,
|
||||||
INSERT_MANY_RECORDINGS,
|
INSERT_MANY_RECORDINGS,
|
||||||
INSERT_PREVIEW,
|
INSERT_PREVIEW,
|
||||||
NOTIFICATION_TEST,
|
|
||||||
REQUEST_REGION_GRID,
|
REQUEST_REGION_GRID,
|
||||||
UPDATE_AUDIO_ACTIVITY,
|
UPDATE_AUDIO_ACTIVITY,
|
||||||
UPDATE_AUDIO_TRANSCRIPTION_STATE,
|
UPDATE_AUDIO_TRANSCRIPTION_STATE,
|
||||||
@@ -57,7 +56,6 @@ _WS_BLOCKED_TOPICS = frozenset(
|
|||||||
UPDATE_EMBEDDINGS_REINDEX_PROGRESS,
|
UPDATE_EMBEDDINGS_REINDEX_PROGRESS,
|
||||||
UPDATE_BIRDSEYE_LAYOUT,
|
UPDATE_BIRDSEYE_LAYOUT,
|
||||||
UPDATE_AUDIO_TRANSCRIPTION_STATE,
|
UPDATE_AUDIO_TRANSCRIPTION_STATE,
|
||||||
NOTIFICATION_TEST,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -288,6 +288,10 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
|||||||
max(0, face_box[0]) : min(frame.shape[1], face_box[2]),
|
max(0, face_box[0]) : min(frame.shape[1], face_box[2]),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if face_frame.size == 0:
|
||||||
|
logger.debug(f"Empty face crop for {id}")
|
||||||
|
return
|
||||||
|
|
||||||
res = self.recognizer.classify(face_frame)
|
res = self.recognizer.classify(face_frame)
|
||||||
|
|
||||||
if not res:
|
if not res:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from typing import Any
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import psutil
|
import psutil
|
||||||
|
from anyio import Path as AsyncPath
|
||||||
|
|
||||||
from frigate.comms.detections_updater import DetectionSubscriber, DetectionTypeEnum
|
from frigate.comms.detections_updater import DetectionSubscriber, DetectionTypeEnum
|
||||||
from frigate.comms.inter_process import InterProcessRequestor
|
from frigate.comms.inter_process import InterProcessRequestor
|
||||||
@@ -105,11 +106,11 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
|
|
||||||
async def move_files(self) -> None:
|
async def move_files(self) -> None:
|
||||||
cache_files = [
|
cache_files = [
|
||||||
d
|
path.name
|
||||||
for d in os.listdir(CACHE_DIR)
|
async for path in AsyncPath(CACHE_DIR).iterdir()
|
||||||
if os.path.isfile(os.path.join(CACHE_DIR, d))
|
if await path.is_file()
|
||||||
and d.endswith(".mp4")
|
and path.suffix == ".mp4"
|
||||||
and not d.startswith("preview_")
|
and not path.name.startswith("preview_")
|
||||||
]
|
]
|
||||||
|
|
||||||
# publish newest cached segment per camera (including in use files)
|
# publish newest cached segment per camera (including in use files)
|
||||||
@@ -229,7 +230,7 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
to_remove = grouped_recordings[camera][:-keep_count]
|
to_remove = grouped_recordings[camera][:-keep_count]
|
||||||
for rec in to_remove:
|
for rec in to_remove:
|
||||||
cache_path = rec["cache_path"]
|
cache_path = rec["cache_path"]
|
||||||
Path(cache_path).unlink(missing_ok=True)
|
await AsyncPath(cache_path).unlink(missing_ok=True)
|
||||||
self.end_time_cache.pop(cache_path, None)
|
self.end_time_cache.pop(cache_path, None)
|
||||||
grouped_recordings[camera] = grouped_recordings[camera][-keep_count:]
|
grouped_recordings[camera] = grouped_recordings[camera][-keep_count:]
|
||||||
|
|
||||||
@@ -244,7 +245,7 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
to_remove = grouped_recordings[camera][:-keep_count]
|
to_remove = grouped_recordings[camera][:-keep_count]
|
||||||
for rec in to_remove:
|
for rec in to_remove:
|
||||||
cache_path = rec["cache_path"]
|
cache_path = rec["cache_path"]
|
||||||
Path(cache_path).unlink(missing_ok=True)
|
await AsyncPath(cache_path).unlink(missing_ok=True)
|
||||||
self.end_time_cache.pop(cache_path, None)
|
self.end_time_cache.pop(cache_path, None)
|
||||||
grouped_recordings[camera] = grouped_recordings[camera][-keep_count:]
|
grouped_recordings[camera] = grouped_recordings[camera][-keep_count:]
|
||||||
|
|
||||||
@@ -634,7 +635,7 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
file_path = os.path.join(directory, file_name)
|
file_path = os.path.join(directory, file_name)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not os.path.exists(file_path):
|
if not await AsyncPath(file_path).exists():
|
||||||
start_frame = datetime.datetime.now().timestamp()
|
start_frame = datetime.datetime.now().timestamp()
|
||||||
|
|
||||||
# add faststart to kept segments to improve metadata reading
|
# add faststart to kept segments to improve metadata reading
|
||||||
@@ -670,7 +671,9 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
# get the segment size of the cache file
|
# get the segment size of the cache file
|
||||||
# file without faststart is same size
|
# file without faststart is same size
|
||||||
segment_size = round(
|
segment_size = round(
|
||||||
float(os.path.getsize(cache_path)) / pow(2, 20), 2
|
float((await AsyncPath(cache_path).stat()).st_size)
|
||||||
|
/ pow(2, 20),
|
||||||
|
2,
|
||||||
)
|
)
|
||||||
except OSError:
|
except OSError:
|
||||||
segment_size = 0
|
segment_size = 0
|
||||||
@@ -698,7 +701,7 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Unable to store recording segment {cache_path}")
|
logger.error(f"Unable to store recording segment {cache_path}")
|
||||||
Path(cache_path).unlink(missing_ok=True)
|
await AsyncPath(cache_path).unlink(missing_ok=True)
|
||||||
logger.error(e)
|
logger.error(e)
|
||||||
|
|
||||||
# clear end_time cache
|
# clear end_time cache
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ from frigate.util.builtin import clean_camera_user_pass, escape_special_characte
|
|||||||
class TestUserPassCleanup(unittest.TestCase):
|
class TestUserPassCleanup(unittest.TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.rtsp_with_pass = "rtsp://user:password@192.168.0.2:554/live"
|
self.rtsp_with_pass = "rtsp://user:password@192.168.0.2:554/live"
|
||||||
self.rtsp_with_special_pass = (
|
self.rtsp_with_special_pass = "rtsp://user:password`~!@#$%^&*()-_;',.<>:\"\\{\\}\\[\\]@@192.168.0.2:554/live"
|
||||||
"rtsp://user:password`~!@#$%^&*()-_;',.<>:\"\{\}\[\]@@192.168.0.2:554/live"
|
|
||||||
)
|
|
||||||
self.rtsp_no_pass = "rtsp://192.168.0.3:554/live"
|
self.rtsp_no_pass = "rtsp://192.168.0.3:554/live"
|
||||||
|
|
||||||
def test_cleanup(self):
|
def test_cleanup(self):
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import sys
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
# Mock complex imports before importing maintainer, saving originals so we can
|
# Mock complex imports before importing maintainer, saving originals so we can
|
||||||
# restore them after import and avoid polluting sys.modules for other tests.
|
# restore them after import and avoid polluting sys.modules for other tests.
|
||||||
@@ -42,38 +42,51 @@ class TestMaintainer(unittest.IsolatedAsyncioTestCase):
|
|||||||
# One bad file, one good file
|
# One bad file, one good file
|
||||||
files = ["bad_filename.mp4", "camera@20210101000000+0000.mp4"]
|
files = ["bad_filename.mp4", "camera@20210101000000+0000.mp4"]
|
||||||
|
|
||||||
with patch("os.listdir", return_value=files):
|
mock_paths = []
|
||||||
with patch("os.path.isfile", return_value=True):
|
for filename in files:
|
||||||
with patch(
|
path = MagicMock()
|
||||||
"frigate.record.maintainer.psutil.process_iter", return_value=[]
|
path.name = filename
|
||||||
):
|
path.suffix = ".mp4"
|
||||||
with patch("frigate.record.maintainer.logger.warning") as warn:
|
path.is_file = AsyncMock(return_value=True)
|
||||||
# Mock validate_and_move_segment to avoid further logic
|
mock_paths.append(path)
|
||||||
maintainer.validate_and_move_segment = MagicMock()
|
|
||||||
|
|
||||||
try:
|
async def mock_iterdir():
|
||||||
await maintainer.move_files()
|
for path in mock_paths:
|
||||||
except ValueError as e:
|
yield path
|
||||||
if "not enough values to unpack" in str(e):
|
|
||||||
self.fail("move_files() crashed on bad filename!")
|
|
||||||
raise e
|
|
||||||
except Exception:
|
|
||||||
# Ignore other errors (like DB connection) as we only care about the unpack crash
|
|
||||||
pass
|
|
||||||
|
|
||||||
# The bad filename is encountered in multiple loops, but should only warn once.
|
with patch("frigate.record.maintainer.AsyncPath") as mock_async_path:
|
||||||
matching = [
|
mock_async_path.return_value.iterdir = mock_iterdir
|
||||||
c
|
|
||||||
for c in warn.call_args_list
|
with patch(
|
||||||
if c.args
|
"frigate.record.maintainer.psutil.process_iter", return_value=[]
|
||||||
and isinstance(c.args[0], str)
|
):
|
||||||
and "Skipping unexpected files in cache" in c.args[0]
|
with patch("frigate.record.maintainer.logger.warning") as warn:
|
||||||
]
|
# Mock validate_and_move_segment to avoid further logic
|
||||||
self.assertEqual(
|
maintainer.validate_and_move_segment = MagicMock()
|
||||||
1,
|
|
||||||
len(matching),
|
try:
|
||||||
f"Expected a single warning for unexpected files, got {len(matching)}",
|
await maintainer.move_files()
|
||||||
)
|
except ValueError as e:
|
||||||
|
if "not enough values to unpack" in str(e):
|
||||||
|
self.fail("move_files() crashed on bad filename!")
|
||||||
|
raise e
|
||||||
|
except Exception:
|
||||||
|
# Ignore other errors (like DB connection) as we only care about the unpack crash
|
||||||
|
pass
|
||||||
|
|
||||||
|
# The bad filename is encountered in multiple loops, but should only warn once.
|
||||||
|
matching = [
|
||||||
|
c
|
||||||
|
for c in warn.call_args_list
|
||||||
|
if c.args
|
||||||
|
and isinstance(c.args[0], str)
|
||||||
|
and "Skipping unexpected files in cache" in c.args[0]
|
||||||
|
]
|
||||||
|
self.assertEqual(
|
||||||
|
1,
|
||||||
|
len(matching),
|
||||||
|
f"Expected a single warning for unexpected files, got {len(matching)}",
|
||||||
|
)
|
||||||
|
|
||||||
async def test_drops_quiet_segment_when_only_motion_retention(self):
|
async def test_drops_quiet_segment_when_only_motion_retention(self):
|
||||||
# Regression: when motion retention is enabled but a segment has no
|
# Regression: when motion retention is enabled but a segment has no
|
||||||
|
|||||||
@@ -115,6 +115,13 @@ class TestCheckWsAuthorization(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_viewer_blocked_from_notification_test(self):
|
||||||
|
self.assertFalse(
|
||||||
|
_check_ws_authorization(
|
||||||
|
"notification_test", "viewer", self.DEFAULT_SEPARATOR
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# --- Admin access ---
|
# --- Admin access ---
|
||||||
|
|
||||||
def test_admin_can_send_restart(self):
|
def test_admin_can_send_restart(self):
|
||||||
@@ -134,6 +141,13 @@ class TestCheckWsAuthorization(unittest.TestCase):
|
|||||||
_check_ws_authorization("front_door/ptz", "admin", self.DEFAULT_SEPARATOR)
|
_check_ws_authorization("front_door/ptz", "admin", self.DEFAULT_SEPARATOR)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_admin_can_send_notification_test(self):
|
||||||
|
self.assertTrue(
|
||||||
|
_check_ws_authorization(
|
||||||
|
"notification_test", "admin", self.DEFAULT_SEPARATOR
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# --- Comma-separated roles ---
|
# --- Comma-separated roles ---
|
||||||
|
|
||||||
def test_comma_separated_admin_viewer_grants_admin(self):
|
def test_comma_separated_admin_viewer_grants_admin(self):
|
||||||
|
|||||||
@@ -684,22 +684,21 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
# check for config updates
|
# check for config updates
|
||||||
updated_topics = self.camera_config_subscriber.check_for_updates()
|
updated_topics = self.camera_config_subscriber.check_for_updates()
|
||||||
|
|
||||||
if "enabled" in updated_topics:
|
# a single drain can carry several topics at once, so add and
|
||||||
for camera in updated_topics["enabled"]:
|
# remove are handled independently rather than as exclusive branches
|
||||||
if self.camera_states[camera].prev_enabled is None:
|
for camera in updated_topics.get("add", []):
|
||||||
self.camera_states[camera].prev_enabled = self.config.cameras[
|
self.config.cameras[camera] = (
|
||||||
camera
|
self.camera_config_subscriber.camera_configs[camera]
|
||||||
].enabled
|
)
|
||||||
elif "add" in updated_topics:
|
self.create_camera_state(camera)
|
||||||
for camera in updated_topics["add"]:
|
|
||||||
self.config.cameras[camera] = (
|
if "remove" in updated_topics:
|
||||||
self.camera_config_subscriber.camera_configs[camera]
|
|
||||||
)
|
|
||||||
self.create_camera_state(camera)
|
|
||||||
elif "remove" in updated_topics:
|
|
||||||
for camera in updated_topics["remove"]:
|
for camera in updated_topics["remove"]:
|
||||||
removed_camera_state = self.camera_states[camera]
|
camera_state = self.camera_states.get(camera)
|
||||||
removed_camera_state.shutdown()
|
if camera_state is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
camera_state.shutdown()
|
||||||
self.camera_states.pop(camera)
|
self.camera_states.pop(camera)
|
||||||
self.camera_activity.pop(camera, None)
|
self.camera_activity.pop(camera, None)
|
||||||
self.last_motion_detected.pop(camera, None)
|
self.last_motion_detected.pop(camera, None)
|
||||||
|
|||||||
+17
-2
@@ -2,5 +2,20 @@
|
|||||||
target-version = "py311"
|
target-version = "py311"
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
ignore = ["E501","E711","E712","UP031","UP032","UP042","G004"]
|
ignore = [
|
||||||
extend-select = ["I", "UP", "G", "ASYNC210", "B904"]
|
"ASYNC109", # Async function definition with a timeout parameter
|
||||||
|
"E501", # line-too-long
|
||||||
|
"E711", # none-comparison
|
||||||
|
"E712", # true-false-comparison
|
||||||
|
"UP031", # printf-string-formatting
|
||||||
|
"UP032", # f-string
|
||||||
|
"UP042", # replace-str-enum
|
||||||
|
"G004", # logging-f-string
|
||||||
|
]
|
||||||
|
extend-select = [
|
||||||
|
"ASYNC", # https://docs.astral.sh/ruff/rules/#flake8-async-async
|
||||||
|
"B904", # https://docs.astral.sh/ruff/rules/raise-without-from-inside-except/
|
||||||
|
"G", # https://docs.astral.sh/ruff/rules/#flake8-logging-format-g
|
||||||
|
"I", # https://docs.astral.sh/ruff/rules/#isort-i
|
||||||
|
"UP", # https://docs.astral.sh/ruff/rules/#pyupgrade-up
|
||||||
|
]
|
||||||
|
|||||||
@@ -227,16 +227,18 @@ export function GenAIModelWidget(props: WidgetProps) {
|
|||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
disabled={disabled || readonly}
|
disabled={disabled || readonly}
|
||||||
className={cn(
|
className={cn(
|
||||||
"justify-between font-normal",
|
"min-w-0 justify-between font-normal",
|
||||||
!currentLabel && "text-muted-foreground",
|
!currentLabel && "text-muted-foreground",
|
||||||
fieldClassName,
|
fieldClassName,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{currentLabel ??
|
<span className="truncate">
|
||||||
t("configForm.genaiModel.placeholder", {
|
{currentLabel ??
|
||||||
ns: "views/settings",
|
t("configForm.genaiModel.placeholder", {
|
||||||
defaultValue: "Select or enter a model…",
|
ns: "views/settings",
|
||||||
})}
|
defaultValue: "Select or enter a model…",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
@@ -263,12 +265,14 @@ export function GenAIModelWidget(props: WidgetProps) {
|
|||||||
value={trimmedSearch}
|
value={trimmedSearch}
|
||||||
onSelect={() => commit(trimmedSearch)}
|
onSelect={() => commit(trimmedSearch)}
|
||||||
>
|
>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4 shrink-0" />
|
||||||
{t("configForm.genaiModel.useCustom", {
|
<span className="truncate">
|
||||||
ns: "views/settings",
|
{t("configForm.genaiModel.useCustom", {
|
||||||
value: trimmedSearch,
|
ns: "views/settings",
|
||||||
defaultValue: 'Use "{{value}}"',
|
value: trimmedSearch,
|
||||||
})}
|
defaultValue: 'Use "{{value}}"',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
)}
|
)}
|
||||||
@@ -287,11 +291,11 @@ export function GenAIModelWidget(props: WidgetProps) {
|
|||||||
>
|
>
|
||||||
<Check
|
<Check
|
||||||
className={cn(
|
className={cn(
|
||||||
"mr-2 h-4 w-4",
|
"mr-2 h-4 w-4 shrink-0",
|
||||||
value === model ? "opacity-100" : "opacity-0",
|
value === model ? "opacity-100" : "opacity-0",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{model}
|
<span className="truncate">{model}</span>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ export function GeneralFilterContent({
|
|||||||
className="mx-2 w-full cursor-pointer text-primary smart-capitalize"
|
className="mx-2 w-full cursor-pointer text-primary smart-capitalize"
|
||||||
htmlFor={item}
|
htmlFor={item}
|
||||||
>
|
>
|
||||||
{item.replaceAll("_", " ")}
|
{t(`logger.logLevel.${item}`, { ns: "views/settings" })}
|
||||||
</Label>
|
</Label>
|
||||||
<Switch
|
<Switch
|
||||||
key={item}
|
key={item}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { LogSeverity } from "@/types/log";
|
|||||||
import { ReactNode, useMemo } from "react";
|
import { ReactNode, useMemo } from "react";
|
||||||
import { isIOS } from "react-device-detect";
|
import { isIOS } from "react-device-detect";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
type ChipProps = {
|
type ChipProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -50,6 +51,7 @@ type LogChipProps = {
|
|||||||
onClickSeverity?: () => void;
|
onClickSeverity?: () => void;
|
||||||
};
|
};
|
||||||
export function LogChip({ severity, onClickSeverity }: LogChipProps) {
|
export function LogChip({ severity, onClickSeverity }: LogChipProps) {
|
||||||
|
const { t } = useTranslation(["views/settings"]);
|
||||||
const severityClassName = useMemo(() => {
|
const severityClassName = useMemo(() => {
|
||||||
switch (severity) {
|
switch (severity) {
|
||||||
case "info":
|
case "info":
|
||||||
@@ -73,7 +75,7 @@ export function LogChip({ severity, onClickSeverity }: LogChipProps) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{severity}
|
{t(`logger.logLevel.${severity}`, { ns: "views/settings" })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -60,7 +60,10 @@ const CommandList = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<CommandPrimitive.List
|
<CommandPrimitive.List
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
className={cn(
|
||||||
|
"scrollbar-container max-h-[300px] overflow-y-auto overflow-x-hidden",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -770,6 +770,34 @@ export default function MotionSearchView({
|
|||||||
};
|
};
|
||||||
}, [cancelMotionSearchJobViaBeacon]);
|
}, [cancelMotionSearchJobViaBeacon]);
|
||||||
|
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
if (onBack) {
|
||||||
|
onBack();
|
||||||
|
} else {
|
||||||
|
navigate(-1);
|
||||||
|
}
|
||||||
|
}, [navigate, onBack]);
|
||||||
|
|
||||||
|
// Dismissing the entry dialog (escape / click outside) before a search has
|
||||||
|
// run leaves nothing behind it, so cancel the flow instead of revealing an
|
||||||
|
// empty page.
|
||||||
|
const handleSearchDialogOpenChange = useCallback(
|
||||||
|
(nextOpen: boolean) => {
|
||||||
|
if (
|
||||||
|
!nextOpen &&
|
||||||
|
!isSearching &&
|
||||||
|
!hasSearched &&
|
||||||
|
searchResults.length === 0
|
||||||
|
) {
|
||||||
|
handleBack();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSearchDialogOpen(nextOpen);
|
||||||
|
},
|
||||||
|
[handleBack, hasSearched, isSearching, searchResults.length],
|
||||||
|
);
|
||||||
|
|
||||||
const handleNewSearch = useCallback(() => {
|
const handleNewSearch = useCallback(() => {
|
||||||
if (jobId && jobCamera) {
|
if (jobId && jobCamera) {
|
||||||
void cancelMotionSearchJob(jobId, jobCamera);
|
void cancelMotionSearchJob(jobId, jobCamera);
|
||||||
@@ -1238,7 +1266,7 @@ export default function MotionSearchView({
|
|||||||
<Toaster closeButton={true} position="top-center" />
|
<Toaster closeButton={true} position="top-center" />
|
||||||
<MotionSearchDialog
|
<MotionSearchDialog
|
||||||
open={isSearchDialogOpen}
|
open={isSearchDialogOpen}
|
||||||
onOpenChange={setIsSearchDialogOpen}
|
onOpenChange={handleSearchDialogOpenChange}
|
||||||
config={config}
|
config={config}
|
||||||
cameras={cameras}
|
cameras={cameras}
|
||||||
selectedCamera={selectedCamera}
|
selectedCamera={selectedCamera}
|
||||||
@@ -1276,7 +1304,7 @@ export default function MotionSearchView({
|
|||||||
className="flex items-center gap-2.5 rounded-lg"
|
className="flex items-center gap-2.5 rounded-lg"
|
||||||
aria-label={t("label.back", { ns: "common" })}
|
aria-label={t("label.back", { ns: "common" })}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => (onBack ? onBack() : navigate(-1))}
|
onClick={handleBack}
|
||||||
>
|
>
|
||||||
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
|
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
|
||||||
{isDesktop && (
|
{isDesktop && (
|
||||||
|
|||||||
Reference in New Issue
Block a user