mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-30 11:56:49 +03:00
Compare commits
3
Commits
75bdb8a7b3
...
97ca7a41f9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97ca7a41f9 | ||
|
|
6f80bcd19f | ||
|
|
b0588a02f9 |
@@ -1,4 +1,4 @@
|
||||
aiofiles == 24.1.*
|
||||
anyio == 4.14.*
|
||||
click == 8.1.*
|
||||
# FastAPI
|
||||
aiohttp == 3.12.*
|
||||
|
||||
@@ -232,6 +232,21 @@ No. Only one profile can be active at a time. Activating a new profile automatic
|
||||
|
||||
When you delete a base zone or mask in the Frigate UI, any profile overrides for that entry are deleted automatically as part of the same operation. If you remove a base entry by editing your config file directly and leave a profile override behind, the config will fail validation at startup until the orphaned override is removed as well.
|
||||
|
||||
### How do I make a YAML profile track no objects at all?
|
||||
|
||||
Set the tracked object list explicitly to an empty list in the profile:
|
||||
|
||||
```yaml
|
||||
cameras:
|
||||
front_door:
|
||||
profiles:
|
||||
home:
|
||||
objects:
|
||||
track: []
|
||||
```
|
||||
|
||||
Leaving the `objects` section empty (or omitting `track`) does not clear the list. Empty sections set no fields, so the profile inherits the full tracked object list from the base config, including anything set at the global level. The same applies to other lists, such as `audio.listen`.
|
||||
|
||||
### Why are some settings missing when I configure a profile override?
|
||||
|
||||
Fields that require a Frigate restart to take effect cannot be overridden by profiles, since profiles are applied at runtime without restarting. Those fields are hidden when editing a profile override and can only be changed on the base configuration.
|
||||
|
||||
@@ -428,3 +428,19 @@ You'll want to:
|
||||
- [Tune your motion detection settings](/configuration/motion_detection) either by editing your config file or by using the UI's Motion Tuner.
|
||||
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem id="my-timeline-previews-are-black-after-restarting-frigate-or-recreating-the-container" question="My timeline previews are black after restarting Frigate or recreating the container. Why?">
|
||||
|
||||
The scrubbing previews (the timelapse clips shown when dragging the History timeline, the secondary-camera previews, and the preview that plays when hovering a review card) are not recorded continuously. Frigate caches low-resolution preview frames in `/tmp/cache` throughout each hour and only assembles them into a finished preview clip **at the top of the hour**.
|
||||
|
||||
In the recommended configuration, `/tmp/cache` is a small in-memory (`tmpfs`) area. When Frigate starts, it tries to restore the current hour's cached frames, so a **soft restart from the UI** preserves them. But if you recreate the Docker container or stop Frigate forcibly by any other means partway through an hour, the in-memory cache is discarded, so no preview clip is produced for that partial hour.
|
||||
|
||||
This is expected behavior, not a bug:
|
||||
|
||||
- Previews for hours that already completed and were written to disk are unaffected.
|
||||
- The next full hour after a restart will generate previews normally.
|
||||
- This is unrelated to `shm_size`; increasing shared memory does not change it.
|
||||
|
||||
To avoid the gap, use the **Restart Frigate** button in the UI's Settings menu rather than recreating the container when possible.
|
||||
|
||||
</FaqItem>
|
||||
|
||||
@@ -34,7 +34,7 @@ All of your exports live on the **Exports** page, reachable from the main naviga
|
||||
- **Rename** it, and
|
||||
- **Delete** it: deleting is the only way an export is removed.
|
||||
|
||||
You can also select multiple exports at once to **delete** them in bulk, or to **add them to** (or **remove them from**) a [case](#cases).
|
||||
You can also select multiple exports at once to **delete** them in bulk, or to **add them to** (or **remove them from**) a [case](#cases). To download multiple exports as a zip archive, add them to a **case** and use the Download button there.
|
||||
|
||||
## Cases
|
||||
|
||||
|
||||
Vendored
+5
@@ -8244,6 +8244,11 @@ components:
|
||||
properties:
|
||||
provider:
|
||||
$ref: '#/components/schemas/GenAIProviderEnum'
|
||||
name:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
title: Name
|
||||
api_key:
|
||||
anyOf:
|
||||
- type: string
|
||||
|
||||
+12
-5
@@ -14,8 +14,8 @@ from io import StringIO
|
||||
from pathlib import Path as FilePath
|
||||
from typing import Any
|
||||
|
||||
import aiofiles
|
||||
import ruamel.yaml
|
||||
from anyio import open_file as aopen
|
||||
from fastapi import APIRouter, Body, Path, Request, Response
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.params import Depends
|
||||
@@ -196,7 +196,7 @@ def genai_models(request: Request):
|
||||
"before saving the configuration."
|
||||
),
|
||||
)
|
||||
async def genai_probe(body: GenAIProbeBody):
|
||||
async def genai_probe(request: Request, body: GenAIProbeBody):
|
||||
load_providers()
|
||||
|
||||
provider_cls = PROVIDERS.get(body.provider)
|
||||
@@ -206,6 +206,13 @@ async def genai_probe(body: GenAIProbeBody):
|
||||
content={"success": False, "message": "Unknown provider"},
|
||||
)
|
||||
|
||||
api_key = body.api_key
|
||||
if api_key == REDACTED_CREDENTIAL_SENTINEL:
|
||||
saved_cfg = (
|
||||
request.app.frigate_config.genai.get(body.name) if body.name else None
|
||||
)
|
||||
api_key = saved_cfg.api_key if saved_cfg else None
|
||||
|
||||
# The OpenAI-compatible SDKs accept "timeout" as a constructor kwarg via
|
||||
# provider_options; other plugins use GenAIClient.timeout passed below.
|
||||
# Don't inject timeout for Gemini — its HttpOptions interprets the value
|
||||
@@ -217,7 +224,7 @@ async def genai_probe(body: GenAIProbeBody):
|
||||
try:
|
||||
transient_cfg = GenAIConfig(
|
||||
provider=body.provider,
|
||||
api_key=body.api_key,
|
||||
api_key=api_key,
|
||||
base_url=body.base_url,
|
||||
provider_options=probe_provider_options,
|
||||
# model is required by the schema but irrelevant for listing.
|
||||
@@ -1045,7 +1052,7 @@ async def logs(
|
||||
"""Asynchronously stream log lines."""
|
||||
buffer = ""
|
||||
try:
|
||||
async with aiofiles.open(file_path) as file:
|
||||
async with await aopen(file_path) as file:
|
||||
await file.seek(0, 2)
|
||||
while True:
|
||||
line = await file.readline()
|
||||
@@ -1083,7 +1090,7 @@ async def logs(
|
||||
|
||||
# For full logs initially
|
||||
try:
|
||||
async with aiofiles.open(service_location) as file:
|
||||
async with await aopen(service_location) as file:
|
||||
contents = await file.read()
|
||||
|
||||
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 requests
|
||||
from anyio import open_file as aopen
|
||||
from fastapi import APIRouter, Depends, Query, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from filelock import FileLock, Timeout
|
||||
@@ -1188,15 +1189,17 @@ async def delete_camera(
|
||||
|
||||
try:
|
||||
with lock:
|
||||
with open(config_file) as f:
|
||||
old_raw_config = f.read()
|
||||
async with await aopen(config_file) as f:
|
||||
old_raw_config = await f.read()
|
||||
|
||||
try:
|
||||
yaml = YAML()
|
||||
yaml.indent(mapping=2, sequence=4, offset=2)
|
||||
|
||||
with open(config_file) as f:
|
||||
data = yaml.load(f)
|
||||
async with await aopen(config_file) as f:
|
||||
text = await f.read()
|
||||
|
||||
data = yaml.load(text)
|
||||
|
||||
# Remove camera from config
|
||||
if "cameras" in data and camera_name in data["cameras"]:
|
||||
@@ -1221,17 +1224,17 @@ async def delete_camera(
|
||||
for role_name in empty_roles:
|
||||
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)
|
||||
|
||||
with open(config_file) as f:
|
||||
new_raw_config = f.read()
|
||||
async with await aopen(config_file) as f:
|
||||
new_raw_config = await f.read()
|
||||
|
||||
try:
|
||||
config = FrigateConfig.parse(new_raw_config)
|
||||
except Exception:
|
||||
with open(config_file, "w") as f:
|
||||
f.write(old_raw_config)
|
||||
async with await aopen(config_file, "w") as f:
|
||||
await f.write(old_raw_config)
|
||||
logger.exception(
|
||||
"Config error after removing camera %s",
|
||||
camera_name,
|
||||
|
||||
@@ -14,6 +14,7 @@ class AppConfigSetBody(BaseModel):
|
||||
|
||||
class GenAIProbeBody(BaseModel):
|
||||
provider: GenAIProviderEnum
|
||||
name: str | None = None
|
||||
api_key: str | None = None
|
||||
base_url: str | None = None
|
||||
provider_options: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@@ -13,6 +13,7 @@ from pathlib import Path
|
||||
from urllib.parse import unquote
|
||||
|
||||
import numpy as np
|
||||
from anyio import Path as AsyncPath
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.params import Depends
|
||||
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")
|
||||
available_labels = set()
|
||||
|
||||
if os.path.exists(dataset_dir):
|
||||
if await AsyncPath(dataset_dir).exists():
|
||||
for category_name in os.listdir(dataset_dir):
|
||||
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)
|
||||
|
||||
if not available_labels:
|
||||
|
||||
+13
-11
@@ -15,6 +15,8 @@ from urllib.parse import unquote
|
||||
import cv2
|
||||
import numpy as np
|
||||
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.responses import FileResponse, JSONResponse, StreamingResponse
|
||||
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_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
|
||||
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 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 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:
|
||||
return JSONResponse(
|
||||
@@ -1149,8 +1151,8 @@ async def event_snapshot_clean(request: Request, event_id: str, download: bool =
|
||||
)
|
||||
|
||||
if image_path.endswith(".webp"):
|
||||
with open(image_path, "rb") as image_file:
|
||||
webp_bytes = image_file.read()
|
||||
async with await aopen(image_path, "rb") as image_file:
|
||||
webp_bytes = await image_file.read()
|
||||
else:
|
||||
image = load_event_snapshot_image(event, clean_only=True)[0]
|
||||
if image is None:
|
||||
@@ -1366,7 +1368,7 @@ async def preview_gif(
|
||||
# need to generate from existing images
|
||||
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(
|
||||
content={"success": False, "message": "Preview not found"},
|
||||
status_code=404,
|
||||
@@ -1555,7 +1557,7 @@ async def preview_mp4(
|
||||
# need to generate from existing images
|
||||
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(
|
||||
content={"success": False, "message": "Preview not found"},
|
||||
status_code=404,
|
||||
@@ -1633,7 +1635,7 @@ async def preview_mp4(
|
||||
"Content-Description": "File Transfer",
|
||||
"Cache-Control": f"private, max-age={_resolve_cache_age(max_cache_age)}",
|
||||
"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
|
||||
"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")
|
||||
|
||||
try:
|
||||
with open(
|
||||
async with await aopen(
|
||||
os.path.join(preview_dir, safe_file_name_current), "rb"
|
||||
) as image_file:
|
||||
jpg_bytes = image_file.read()
|
||||
jpg_bytes = await image_file.read()
|
||||
except FileNotFoundError:
|
||||
return JSONResponse(
|
||||
content=({"success": False, "message": "Image file not found"}),
|
||||
|
||||
@@ -4,9 +4,9 @@ import datetime as dt
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from functools import reduce
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote
|
||||
|
||||
from anyio import Path as AsyncPath
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi import Path as PathParam
|
||||
from fastapi.responses import JSONResponse
|
||||
@@ -443,7 +443,7 @@ async def delete_recordings(
|
||||
recording_ids.append(recording["id"])
|
||||
|
||||
try:
|
||||
Path(recording["path"]).unlink(missing_ok=True)
|
||||
await AsyncPath(recording["path"]).unlink(missing_ok=True)
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete recording file {recording['path']}: {e}")
|
||||
|
||||
@@ -15,6 +15,7 @@ from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import psutil
|
||||
from anyio import Path as AsyncPath
|
||||
|
||||
from frigate.comms.detections_updater import DetectionSubscriber, DetectionTypeEnum
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
@@ -105,11 +106,11 @@ class RecordingMaintainer(threading.Thread):
|
||||
|
||||
async def move_files(self) -> None:
|
||||
cache_files = [
|
||||
d
|
||||
for d in os.listdir(CACHE_DIR)
|
||||
if os.path.isfile(os.path.join(CACHE_DIR, d))
|
||||
and d.endswith(".mp4")
|
||||
and not d.startswith("preview_")
|
||||
path.name
|
||||
async for path in AsyncPath(CACHE_DIR).iterdir()
|
||||
if await path.is_file()
|
||||
and path.suffix == ".mp4"
|
||||
and not path.name.startswith("preview_")
|
||||
]
|
||||
|
||||
# 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]
|
||||
for rec in to_remove:
|
||||
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)
|
||||
grouped_recordings[camera] = grouped_recordings[camera][-keep_count:]
|
||||
|
||||
@@ -244,7 +245,7 @@ class RecordingMaintainer(threading.Thread):
|
||||
to_remove = grouped_recordings[camera][:-keep_count]
|
||||
for rec in to_remove:
|
||||
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)
|
||||
grouped_recordings[camera] = grouped_recordings[camera][-keep_count:]
|
||||
|
||||
@@ -634,7 +635,7 @@ class RecordingMaintainer(threading.Thread):
|
||||
file_path = os.path.join(directory, file_name)
|
||||
|
||||
try:
|
||||
if not os.path.exists(file_path):
|
||||
if not await AsyncPath(file_path).exists():
|
||||
start_frame = datetime.datetime.now().timestamp()
|
||||
|
||||
# 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
|
||||
# file without faststart is same size
|
||||
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:
|
||||
segment_size = 0
|
||||
@@ -698,7 +701,7 @@ class RecordingMaintainer(threading.Thread):
|
||||
}
|
||||
except Exception as e:
|
||||
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)
|
||||
|
||||
# clear end_time cache
|
||||
|
||||
@@ -132,6 +132,77 @@ class TestHttpApp(BaseTestHttp):
|
||||
"models": ["fake-model-a", "fake-model-b"],
|
||||
}
|
||||
|
||||
def test_genai_probe_resolves_sentinel_to_saved_api_key(self):
|
||||
# After a save the UI's api_key field holds the redaction sentinel;
|
||||
# the probe must substitute the saved key for the named entry instead
|
||||
# of sending the literal sentinel to the provider (GH discussion 23754).
|
||||
probed_keys: list[str | None] = []
|
||||
|
||||
class CapturingClient(GenAIClient):
|
||||
def list_models(self):
|
||||
probed_keys.append(self.genai_config.api_key)
|
||||
return ["fake-model"]
|
||||
|
||||
self.minimal_config["genai"] = {
|
||||
"llm": {
|
||||
"provider": "openai",
|
||||
"api_key": "sk-saved",
|
||||
"base_url": "https://example.invalid",
|
||||
"model": "fake-model",
|
||||
}
|
||||
}
|
||||
app = super().create_app()
|
||||
|
||||
with (
|
||||
AuthTestClient(app) as client,
|
||||
patch.dict(
|
||||
frigate.genai.PROVIDERS,
|
||||
{GenAIProviderEnum.openai: CapturingClient},
|
||||
),
|
||||
):
|
||||
response = client.post(
|
||||
"/genai/probe",
|
||||
json={
|
||||
"provider": "openai",
|
||||
"name": "llm",
|
||||
"api_key": REDACTED_CREDENTIAL_SENTINEL,
|
||||
"base_url": "https://example.invalid",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["success"] is True
|
||||
assert probed_keys == ["sk-saved"]
|
||||
|
||||
def test_genai_probe_sentinel_without_saved_entry_sends_no_key(self):
|
||||
# If the sentinel arrives for an entry that has no saved config, the
|
||||
# probe must drop the key entirely rather than leak the sentinel.
|
||||
probed_keys: list[str | None] = []
|
||||
|
||||
class CapturingClient(GenAIClient):
|
||||
def list_models(self):
|
||||
probed_keys.append(self.genai_config.api_key)
|
||||
return ["fake-model"]
|
||||
|
||||
app = super().create_app()
|
||||
|
||||
with (
|
||||
AuthTestClient(app) as client,
|
||||
patch.dict(
|
||||
frigate.genai.PROVIDERS,
|
||||
{GenAIProviderEnum.openai: CapturingClient},
|
||||
),
|
||||
):
|
||||
response = client.post(
|
||||
"/genai/probe",
|
||||
json={
|
||||
"provider": "openai",
|
||||
"name": "llm",
|
||||
"api_key": REDACTED_CREDENTIAL_SENTINEL,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert probed_keys == [None]
|
||||
|
||||
def test_genai_probe_empty_list_is_treated_as_failure(self):
|
||||
# The plugin's list_models() returns [] on connection failure rather
|
||||
# than raising. The endpoint should surface that as success=false so
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import datetime
|
||||
import sys
|
||||
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
|
||||
# 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
|
||||
files = ["bad_filename.mp4", "camera@20210101000000+0000.mp4"]
|
||||
|
||||
with patch("os.listdir", return_value=files):
|
||||
with patch("os.path.isfile", return_value=True):
|
||||
with patch(
|
||||
"frigate.record.maintainer.psutil.process_iter", return_value=[]
|
||||
):
|
||||
with patch("frigate.record.maintainer.logger.warning") as warn:
|
||||
# Mock validate_and_move_segment to avoid further logic
|
||||
maintainer.validate_and_move_segment = MagicMock()
|
||||
mock_paths = []
|
||||
for filename in files:
|
||||
path = MagicMock()
|
||||
path.name = filename
|
||||
path.suffix = ".mp4"
|
||||
path.is_file = AsyncMock(return_value=True)
|
||||
mock_paths.append(path)
|
||||
|
||||
try:
|
||||
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
|
||||
async def mock_iterdir():
|
||||
for path in mock_paths:
|
||||
yield path
|
||||
|
||||
# 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)}",
|
||||
)
|
||||
with patch("frigate.record.maintainer.AsyncPath") as mock_async_path:
|
||||
mock_async_path.return_value.iterdir = mock_iterdir
|
||||
|
||||
with patch(
|
||||
"frigate.record.maintainer.psutil.process_iter", return_value=[]
|
||||
):
|
||||
with patch("frigate.record.maintainer.logger.warning") as warn:
|
||||
# Mock validate_and_move_segment to avoid further logic
|
||||
maintainer.validate_and_move_segment = MagicMock()
|
||||
|
||||
try:
|
||||
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):
|
||||
# Regression: when motion retention is enabled but a segment has no
|
||||
|
||||
+17
-2
@@ -2,5 +2,20 @@
|
||||
target-version = "py311"
|
||||
|
||||
[tool.ruff.lint]
|
||||
ignore = ["E501","E711","E712","UP031","UP032","UP042","G004"]
|
||||
extend-select = ["I", "UP", "G", "ASYNC210", "B904"]
|
||||
ignore = [
|
||||
"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
|
||||
]
|
||||
|
||||
@@ -160,6 +160,7 @@ export function GenAIModelWidget(props: WidgetProps) {
|
||||
try {
|
||||
const res = await axios.post<ProbeResponse>("genai/probe", {
|
||||
provider: formProvider,
|
||||
name: providerKey,
|
||||
api_key:
|
||||
typeof formEntry.api_key === "string" ? formEntry.api_key : null,
|
||||
base_url:
|
||||
|
||||
@@ -444,10 +444,10 @@ export function ExportContent({
|
||||
}
|
||||
|
||||
setRange({
|
||||
before: latestTime,
|
||||
after: latestTime - 3600,
|
||||
before: currentTime + 1800,
|
||||
after: currentTime - 1800,
|
||||
});
|
||||
}, [activeTab, latestTime, range, setRange]);
|
||||
}, [activeTab, currentTime, range, setRange]);
|
||||
|
||||
const { data: events, isLoading: isEventsLoading } = useSWR<Event[]>(
|
||||
activeTab === "multi" && debouncedRange
|
||||
@@ -817,7 +817,19 @@ export function ExportContent({
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) => setActiveTab(value as ExportTab)}
|
||||
onValueChange={(value) => {
|
||||
const tab = value as ExportTab;
|
||||
if (tab === "multi") {
|
||||
setRange({
|
||||
before: currentTime + 1800,
|
||||
after: currentTime - 1800,
|
||||
});
|
||||
} else {
|
||||
onSelectTime(selectedOption);
|
||||
}
|
||||
|
||||
setActiveTab(tab);
|
||||
}}
|
||||
className={cn("w-full", !isDesktop && "flex min-h-0 flex-1 flex-col")}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
|
||||
Reference in New Issue
Block a user