Increase ruff coverage (#23644)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
CI / Jetson Jetpack 6 (push) Waiting to run

* Pin ruff

* Add python upgrade fixes

This enables python upgrade checks in ruff to look for deprecated types and patterns. This namely fixes:
- usage of deprecated `Typing` which is now built in
- some specific exceptions which are caught and have new aliases

Some specific UP checks were also ignored as they are stylistic / unimportant and likely to cause bugs

* Remove async blocking calls

Use asyncio.to_thread on two remaining blocking calls to fix hanging event thread loop. Enable this specific rule to block it in the future.

* Use proper logging mechanism

* Correctly format logs

* Raise with context

When raising an exception include the from context to improve debugging

* Cleanup
This commit is contained in:
Nicolas Mowen
2026-07-06 12:28:02 -05:00
committed by GitHub
parent 455b8687e8
commit 4ee12e6237
169 changed files with 1053 additions and 1150 deletions
+34 -34
View File
@@ -12,7 +12,7 @@ from datetime import datetime, timedelta
from functools import reduce
from io import StringIO
from pathlib import Path as FilePath
from typing import Any, Dict, List, Optional
from typing import Any
import aiofiles
import ruamel.yaml
@@ -113,7 +113,7 @@ def version():
@router.get("/stats", dependencies=[Depends(allow_any_authenticated())])
def stats(
request: Request,
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter),
):
stats_data = request.app.stats_emitter.get_latest_stats()
@@ -164,7 +164,7 @@ def metrics(request: Request):
# Retrieve the latest statistics and update the Prometheus metrics
stats = request.app.stats_emitter.get_latest_stats()
# query DB for count of events by camera, label
event_counts: List[Dict[str, Any]] = (
event_counts: list[dict[str, Any]] = (
Event.select(Event.camera, Event.label, fn.Count())
.group_by(Event.camera, Event.label)
.dicts()
@@ -250,7 +250,7 @@ async def genai_probe(body: GenAIProbeBody):
asyncio.to_thread(client.list_models),
timeout=_PROBE_OUTER_TIMEOUT_SECONDS,
)
except asyncio.TimeoutError:
except TimeoutError:
return JSONResponse(
content={"success": False, "message": "Probe timed out"},
)
@@ -374,7 +374,7 @@ def config(request: Request):
if model_path:
model_json_path = FilePath(model_path).with_suffix(".json")
try:
with open(model_json_path, "r") as f:
with open(model_json_path) as f:
model_plus_data = json.load(f)
config["model"]["plus"] = model_plus_data
except FileNotFoundError:
@@ -502,7 +502,7 @@ def config_raw():
status_code=404,
)
with open(config_file, "r") as f:
with open(config_file) as f:
raw_config = f.read()
f.close()
@@ -807,7 +807,7 @@ def config_set(request: Request, body: AppConfigSetBody):
try:
with lock:
with open(config_file, "r") as f:
with open(config_file) as f:
old_raw_config = f.read()
try:
@@ -854,7 +854,7 @@ def config_set(request: Request, body: AppConfigSetBody):
update_yaml_file_bulk(config_file, updates)
# validate the updated config
with open(config_file, "r") as f:
with open(config_file) as f:
new_raw_config = f.read()
try:
@@ -1028,16 +1028,16 @@ def nvinfo():
)
async def logs(
service: str = Path(enum=["frigate", "nginx", "go2rtc"]),
download: Optional[str] = None,
stream: Optional[bool] = False,
start: Optional[int] = 0,
end: Optional[int] = None,
download: str | None = None,
stream: bool | None = False,
start: int | None = 0,
end: int | None = None,
):
"""Get logs for the requested service (frigate/nginx/go2rtc)"""
def download_logs(service_location: str):
try:
file = open(service_location, "r")
file = open(service_location)
contents = file.read()
file.close()
return JSONResponse(jsonable_encoder(contents))
@@ -1052,7 +1052,7 @@ async def logs(
"""Asynchronously stream log lines."""
buffer = ""
try:
async with aiofiles.open(file_path, "r") as file:
async with aiofiles.open(file_path) as file:
await file.seek(0, 2)
while True:
line = await file.readline()
@@ -1090,7 +1090,7 @@ async def logs(
# For full logs initially
try:
async with aiofiles.open(service_location, "r") as file:
async with aiofiles.open(service_location) as file:
contents = await file.read()
total_lines, log_lines = process_logs(contents, service, start, end)
@@ -1231,7 +1231,7 @@ def get_media_sync_status(job_id: str):
@router.get("/labels", dependencies=[Depends(allow_any_authenticated())])
def get_labels(
camera: str = "",
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter),
):
try:
if camera:
@@ -1263,8 +1263,8 @@ def get_labels(
@router.get("/sub_labels", dependencies=[Depends(allow_any_authenticated())])
def get_sub_labels(
split_joined: Optional[int] = None,
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
split_joined: int | None = None,
allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter),
):
try:
events = (
@@ -1351,8 +1351,8 @@ def plusModels(request: Request, filterByCurrentModelDetector: bool = False):
"/recognized_license_plates", dependencies=[Depends(allow_any_authenticated())]
)
def get_recognized_license_plates(
split_joined: Optional[int] = None,
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
split_joined: int | None = None,
allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter),
):
try:
query = (
@@ -1393,8 +1393,8 @@ def get_recognized_license_plates(
def timeline(
camera: str = "all",
limit: int = 100,
source_id: Optional[str] = None,
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
source_id: str | None = None,
allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter),
):
clauses = []
@@ -1408,20 +1408,20 @@ def timeline(
]
if camera != "all":
clauses.append((Timeline.camera == camera))
clauses.append(Timeline.camera == camera)
if source_id:
source_ids = [sid.strip() for sid in source_id.split(",")]
if len(source_ids) == 1:
clauses.append((Timeline.source_id == source_ids[0]))
clauses.append(Timeline.source_id == source_ids[0])
else:
clauses.append((Timeline.source_id.in_(source_ids)))
clauses.append(Timeline.source_id.in_(source_ids))
# Enforce per-camera access control
clauses.append((Timeline.camera << allowed_cameras))
clauses.append(Timeline.camera << allowed_cameras)
if len(clauses) == 0:
clauses.append((True))
clauses.append(True)
timeline = (
Timeline.select(*selected_columns)
@@ -1437,7 +1437,7 @@ def timeline(
@router.get("/timeline/hourly", dependencies=[Depends(allow_any_authenticated())])
def hourly_timeline(
params: AppTimelineHourlyQueryParameters = Depends(),
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter),
):
"""Get hourly summary for timeline."""
cameras = params.cameras
@@ -1454,23 +1454,23 @@ def hourly_timeline(
if cameras != "all":
camera_list = cameras.split(",")
clauses.append((Timeline.camera << camera_list))
clauses.append(Timeline.camera << camera_list)
# Enforce per-camera access control
clauses.append((Timeline.camera << allowed_cameras))
clauses.append(Timeline.camera << allowed_cameras)
if labels != "all":
label_list = labels.split(",")
clauses.append((Timeline.data["label"] << label_list))
clauses.append(Timeline.data["label"] << label_list)
if before:
clauses.append((Timeline.timestamp < before))
clauses.append(Timeline.timestamp < before)
if after:
clauses.append((Timeline.timestamp > after))
clauses.append(Timeline.timestamp > after)
if len(clauses) == 0:
clauses.append((True))
clauses.append(True)
timeline = (
Timeline.select(
+6 -7
View File
@@ -11,7 +11,6 @@ import secrets
import time
from datetime import datetime
from pathlib import Path
from typing import List, Optional
from urllib.parse import parse_qs, urlparse
from fastapi import APIRouter, Depends, HTTPException, Request, Response
@@ -390,7 +389,7 @@ def verify_password(password, password_hash):
return secrets.compare_digest(password_hash, compare_hash)
def validate_password_strength(password: str) -> tuple[bool, Optional[str]]:
def validate_password_strength(password: str) -> tuple[bool, str | None]:
"""
Validate password strength.
@@ -445,7 +444,7 @@ async def get_current_user(request: Request):
return {"username": username, "role": role}
def require_role(required_roles: List[str]):
def require_role(required_roles: list[str]):
async def role_checker(request: Request):
proxy_config: ProxyConfig = request.app.frigate_config.proxy
config_roles = list(request.app.frigate_config.auth.roles.keys())
@@ -1083,7 +1082,7 @@ async def update_role(
async def require_camera_access(
camera_name: Optional[str] = None,
camera_name: str | None = None,
request: Request = None,
):
"""Dependency to enforce camera access based on user role."""
@@ -1148,8 +1147,8 @@ GO2RTC_STREAM_PROXY_PATHS = frozenset(
def deny_response_for_go2rtc_stream(
original_url: Optional[str], role: Optional[str], request: Request
) -> Optional[int]:
original_url: str | None, role: str | None, request: Request
) -> int | None:
"""Block role-restricted users from go2rtc live streams they cannot access.
Returns 403 when any `src` stream named in `original_url` resolves to a
@@ -1194,7 +1193,7 @@ def deny_response_for_go2rtc_stream(
async def require_go2rtc_stream_access(
stream_name: Optional[str] = None,
stream_name: str | None = None,
request: Request = None,
):
"""Dependency to enforce go2rtc stream access based on owning camera access."""
+6 -5
View File
@@ -74,7 +74,7 @@ def _is_valid_host(host: str) -> bool:
@router.get("/go2rtc/streams", dependencies=[Depends(allow_any_authenticated())])
async def go2rtc_streams(request: Request):
r = requests.get("http://127.0.0.1:1984/api/streams")
r = await asyncio.to_thread(requests.get, "http://127.0.0.1:1984/api/streams")
if not r.ok:
logger.error("Failed to fetch streams from go2rtc")
return JSONResponse(
@@ -1187,14 +1187,14 @@ async def delete_camera(
try:
with lock:
with open(config_file, "r") as f:
with open(config_file) as f:
old_raw_config = f.read()
try:
yaml = YAML()
yaml.indent(mapping=2, sequence=4, offset=2)
with open(config_file, "r") as f:
with open(config_file) as f:
data = yaml.load(f)
# Remove camera from config
@@ -1223,7 +1223,7 @@ async def delete_camera(
with open(config_file, "w") as f:
yaml.dump(data, f)
with open(config_file, "r") as f:
with open(config_file) as f:
new_raw_config = f.read()
try:
@@ -1285,7 +1285,8 @@ async def delete_camera(
# Best-effort go2rtc stream removal
try:
requests.delete(
await asyncio.to_thread(
requests.delete,
"http://127.0.0.1:1984/api/streams",
params={"src": camera_name},
timeout=5,
+10 -11
View File
@@ -7,7 +7,7 @@ import operator
import time
from datetime import datetime
from functools import reduce
from typing import Any, Optional
from typing import Any
import cv2
from fastapi import APIRouter, Body, Depends, HTTPException, Request
@@ -201,7 +201,7 @@ async def _execute_search_objects(
# Return it as-is for the LLM
return response
except Exception as e:
logger.error(f"Error executing search_objects: {e}", exc_info=True)
logger.exception(f"Error executing search_objects: {e}")
return JSONResponse(
content={
"success": False,
@@ -611,7 +611,7 @@ async def _execute_get_live_context(
return result
except Exception as e:
logger.error(f"Error executing get_live_context: {e}", exc_info=True)
logger.exception(f"Error executing get_live_context: {e}")
return {
"error": "Error getting live context",
}
@@ -621,7 +621,7 @@ async def _get_live_frame_image_url(
request: Request,
camera: str,
allowed_cameras: list[str],
) -> Optional[str]:
) -> str | None:
"""
Fetch the current live frame for a camera as a base64 data URL.
@@ -801,7 +801,7 @@ async def _execute_start_camera_watch(
zones=zones,
)
except RuntimeError as e:
logger.error("Failed to start VLM watch job: %s", e, exc_info=True)
logger.exception("Failed to start VLM watch job: %s", e)
return {"error": "Failed to start VLM watch job."}
return {
@@ -979,7 +979,7 @@ def _execute_get_recap(
return {"events": events}
except Exception as e:
logger.error("Error executing get_recap: %s", e, exc_info=True)
logger.exception("Error executing get_recap: %s", e)
return {"error": "Failed to fetch recap data."}
@@ -1072,13 +1072,12 @@ async def _execute_pending_tools(
}
)
except Exception as e:
logger.error(
logger.exception(
"Error executing tool %s (id: %s): %s. Arguments: %s",
tool_name,
tool_call_id,
e,
json.dumps(tool_args),
exc_info=True,
)
error_content = json.dumps({"error": f"Tool execution failed: {str(e)}"})
tool_calls_out.append(
@@ -1186,7 +1185,7 @@ async def chat_completion(
async def stream_body_llm():
nonlocal conversation, stream_iterations
def _emit_chain(extra: Optional[list[dict[str, Any]]] = None):
def _emit_chain(extra: list[dict[str, Any]] | None = None):
# Return the full conversation (including the system message) so
# the client persists and replays it verbatim next turn.
chain = conversation + (extra or [])
@@ -1414,7 +1413,7 @@ async def chat_completion(
)
except Exception as e:
logger.error(f"Error in chat completion: {e}", exc_info=True)
logger.exception(f"Error in chat completion: {e}")
return JSONResponse(
content={
"error": "An error occurred while processing your request.",
@@ -1477,7 +1476,7 @@ async def start_vlm_monitor(
username=request.headers.get("remote-user", ""),
)
except RuntimeError as e:
logger.error("Failed to start VLM watch job: %s", e, exc_info=True)
logger.exception("Failed to start VLM watch job: %s", e)
return JSONResponse(
content={"success": False, "message": "Failed to start VLM watch job."},
status_code=409,
+11 -10
View File
@@ -9,8 +9,9 @@ loop state — all inputs and outputs are plain data.
import logging
import math
import time
from collections.abc import Generator
from datetime import datetime
from typing import Any, Dict, Generator, List, Optional
from typing import Any
from frigate.embeddings.util import ZScoreNormalization
from frigate.models import Event
@@ -30,7 +31,7 @@ def chunk_content(content: str, chunk_size: int = 80) -> Generator[str, None, No
if not content:
return
words = content.split(" ")
current: List[str] = []
current: list[str] = []
current_len = 0
for w in words:
current.append(w)
@@ -44,8 +45,8 @@ def chunk_content(content: str, chunk_size: int = 80) -> Generator[str, None, No
def format_events_with_local_time(
events_list: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
events_list: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Add human-readable local start/end times to each event for the LLM."""
result = []
for evt in events_list:
@@ -84,9 +85,9 @@ def distance_to_score(distance: float, stats: ZScoreNormalization) -> float:
def fuse_scores(
visual_score: Optional[float],
description_score: Optional[float],
) -> Optional[float]:
visual_score: float | None,
description_score: float | None,
) -> float | None:
"""Weighted fusion of visual and description similarity scores.
If one side is missing (e.g., no description embedding for this event),
@@ -102,7 +103,7 @@ def fuse_scores(
return VISUAL_WEIGHT * visual_score + DESCRIPTION_WEIGHT * description_score
def parse_iso_to_timestamp(value: Optional[str]) -> Optional[float]:
def parse_iso_to_timestamp(value: str | None) -> float | None:
"""Parse an ISO-8601 string as server-local time -> unix timestamp.
Mirrors the parsing _execute_search_objects uses so both tools accept the
@@ -119,9 +120,9 @@ def parse_iso_to_timestamp(value: Optional[str]) -> Optional[float]:
return None
def hydrate_event(event: Event, score: Optional[float] = None) -> Dict[str, Any]:
def hydrate_event(event: Event, score: float | None = None) -> dict[str, Any]:
"""Convert an Event row into the dict shape returned by find_similar_objects."""
data: Dict[str, Any] = {
data: dict[str, Any] = {
"id": event.id,
"camera": event.camera,
"label": event.label,
@@ -1,12 +1,10 @@
from typing import Optional
from pydantic import BaseModel
class AppTimelineHourlyQueryParameters(BaseModel):
cameras: Optional[str] = "all"
labels: Optional[str] = "all"
after: Optional[float] = None
before: Optional[float] = None
limit: Optional[int] = 200
timezone: Optional[str] = "utc"
cameras: str | None = "all"
labels: str | None = "all"
after: float | None = None
before: float | None = None
limit: int | None = 200
timezone: str | None = "utc"
@@ -1,28 +1,26 @@
from typing import Optional
from pydantic import BaseModel, Field
DEFAULT_TIME_RANGE = "00:00,24:00"
class EventsQueryParams(BaseModel):
camera: Optional[str] = "all"
cameras: Optional[str] = "all"
label: Optional[str] = "all"
labels: Optional[str] = "all"
sub_label: Optional[str] = "all"
sub_labels: Optional[str] = "all"
attributes: Optional[str] = "all"
zone: Optional[str] = "all"
zones: Optional[str] = "all"
limit: Optional[int] = 100
after: Optional[float] = None
before: Optional[float] = None
time_range: Optional[str] = DEFAULT_TIME_RANGE
has_clip: Optional[int] = None
has_snapshot: Optional[int] = None
in_progress: Optional[int] = None
include_thumbnails: Optional[int] = Field(
camera: str | None = "all"
cameras: str | None = "all"
label: str | None = "all"
labels: str | None = "all"
sub_label: str | None = "all"
sub_labels: str | None = "all"
attributes: str | None = "all"
zone: str | None = "all"
zones: str | None = "all"
limit: int | None = 100
after: float | None = None
before: float | None = None
time_range: str | None = DEFAULT_TIME_RANGE
has_clip: int | None = None
has_snapshot: int | None = None
in_progress: int | None = None
include_thumbnails: int | None = Field(
1,
description=(
"Deprecated. Thumbnail data is no longer included in the response. "
@@ -30,25 +28,25 @@ class EventsQueryParams(BaseModel):
),
deprecated=True,
)
favorites: Optional[int] = None
min_score: Optional[float] = None
max_score: Optional[float] = None
min_speed: Optional[float] = None
max_speed: Optional[float] = None
recognized_license_plate: Optional[str] = "all"
is_submitted: Optional[int] = None
min_length: Optional[float] = None
max_length: Optional[float] = None
event_id: Optional[str] = None
sort: Optional[str] = None
timezone: Optional[str] = "utc"
favorites: int | None = None
min_score: float | None = None
max_score: float | None = None
min_speed: float | None = None
max_speed: float | None = None
recognized_license_plate: str | None = "all"
is_submitted: int | None = None
min_length: float | None = None
max_length: float | None = None
event_id: str | None = None
sort: str | None = None
timezone: str | None = "utc"
class EventsSearchQueryParams(BaseModel):
query: Optional[str] = None
event_id: Optional[str] = None
search_type: Optional[str] = "thumbnail"
include_thumbnails: Optional[int] = Field(
query: str | None = None
event_id: str | None = None
search_type: str | None = "thumbnail"
include_thumbnails: int | None = Field(
1,
description=(
"Deprecated. Thumbnail data is no longer included in the response. "
@@ -56,28 +54,28 @@ class EventsSearchQueryParams(BaseModel):
),
deprecated=True,
)
limit: Optional[int] = 50
cameras: Optional[str] = "all"
labels: Optional[str] = "all"
sub_labels: Optional[str] = "all"
attributes: Optional[str] = "all"
zones: Optional[str] = "all"
after: Optional[float] = None
before: Optional[float] = None
time_range: Optional[str] = DEFAULT_TIME_RANGE
has_clip: Optional[bool] = None
has_snapshot: Optional[bool] = None
is_submitted: Optional[bool] = None
timezone: Optional[str] = "utc"
min_score: Optional[float] = None
max_score: Optional[float] = None
min_speed: Optional[float] = None
max_speed: Optional[float] = None
recognized_license_plate: Optional[str] = "all"
sort: Optional[str] = None
limit: int | None = 50
cameras: str | None = "all"
labels: str | None = "all"
sub_labels: str | None = "all"
attributes: str | None = "all"
zones: str | None = "all"
after: float | None = None
before: float | None = None
time_range: str | None = DEFAULT_TIME_RANGE
has_clip: bool | None = None
has_snapshot: bool | None = None
is_submitted: bool | None = None
timezone: str | None = "utc"
min_score: float | None = None
max_score: float | None = None
min_speed: float | None = None
max_speed: float | None = None
recognized_license_plate: str | None = "all"
sort: str | None = None
class EventsSummaryQueryParams(BaseModel):
timezone: Optional[str] = "utc"
has_clip: Optional[int] = None
has_snapshot: Optional[int] = None
timezone: str | None = "utc"
has_clip: int | None = None
has_snapshot: int | None = None
@@ -1,5 +1,4 @@
from enum import Enum
from typing import Optional
from pydantic import BaseModel
@@ -17,33 +16,33 @@ class Extension(str, Enum):
class MediaLatestFrameQueryParams(BaseModel):
bbox: Optional[int] = None
timestamp: Optional[int] = None
zones: Optional[int] = None
mask: Optional[int] = None
motion: Optional[int] = None
paths: Optional[int] = None
regions: Optional[int] = None
quality: Optional[int] = 70
height: Optional[int] = None
store: Optional[int] = None
bbox: int | None = None
timestamp: int | None = None
zones: int | None = None
mask: int | None = None
motion: int | None = None
paths: int | None = None
regions: int | None = None
quality: int | None = 70
height: int | None = None
store: int | None = None
class MediaEventsSnapshotQueryParams(BaseModel):
download: Optional[bool] = False
timestamp: Optional[int] = None
bbox: Optional[int] = None
crop: Optional[int] = None
height: Optional[int] = None
quality: Optional[int] = None
download: bool | None = False
timestamp: int | None = None
bbox: int | None = None
crop: int | None = None
height: int | None = None
quality: int | None = None
class MediaMjpegFeedQueryParams(BaseModel):
fps: int = 3
height: int = 360
bbox: Optional[int] = None
timestamp: Optional[int] = None
zones: Optional[int] = None
mask: Optional[int] = None
motion: Optional[int] = None
regions: Optional[int] = None
bbox: int | None = None
timestamp: int | None = None
zones: int | None = None
mask: int | None = None
motion: int | None = None
regions: int | None = None
@@ -1,21 +1,19 @@
from typing import Optional, Union
from pydantic import BaseModel
from pydantic.json_schema import SkipJsonSchema
class MediaRecordingsSummaryQueryParams(BaseModel):
timezone: str = "utc"
cameras: Optional[str] = "all"
cameras: str | None = "all"
class MediaRecordingsAvailabilityQueryParams(BaseModel):
cameras: str = "all"
before: Union[float, SkipJsonSchema[None]] = None
after: Union[float, SkipJsonSchema[None]] = None
before: float | SkipJsonSchema[None] = None
after: float | SkipJsonSchema[None] = None
scale: int = 30
class RecordingsDeleteQueryParams(BaseModel):
keep: Optional[str] = None
cameras: Optional[str] = "all"
keep: str | None = None
cameras: str | None = "all"
@@ -1,13 +1,11 @@
from typing import Optional
from pydantic import BaseModel, Field
from frigate.events.types import RegenerateDescriptionEnum
class RegenerateQueryParameters(BaseModel):
source: Optional[RegenerateDescriptionEnum] = RegenerateDescriptionEnum.thumbnails
force: Optional[bool] = Field(
source: RegenerateDescriptionEnum | None = RegenerateDescriptionEnum.thumbnails
force: bool | None = Field(
default=False,
description="Force (re)generating the description even if GenAI is disabled for this camera.",
)
@@ -1,5 +1,3 @@
from typing import Union
from pydantic import BaseModel
from pydantic.json_schema import SkipJsonSchema
@@ -10,11 +8,11 @@ class ReviewQueryParams(BaseModel):
cameras: str = "all"
labels: str = "all"
zones: str = "all"
reviewed: Union[int, SkipJsonSchema[None]] = None
limit: Union[int, SkipJsonSchema[None]] = None
severity: Union[SeverityEnum, SkipJsonSchema[None]] = None
before: Union[float, SkipJsonSchema[None]] = None
after: Union[float, SkipJsonSchema[None]] = None
reviewed: int | SkipJsonSchema[None] = None
limit: int | SkipJsonSchema[None] = None
severity: SeverityEnum | SkipJsonSchema[None] = None
before: float | SkipJsonSchema[None] = None
after: float | SkipJsonSchema[None] = None
class ReviewSummaryQueryParams(BaseModel):
@@ -26,6 +24,6 @@ class ReviewSummaryQueryParams(BaseModel):
class ReviewActivityMotionQueryParams(BaseModel):
cameras: str = "all"
before: Union[float, SkipJsonSchema[None]] = None
after: Union[float, SkipJsonSchema[None]] = None
before: float | SkipJsonSchema[None] = None
after: float | SkipJsonSchema[None] = None
scale: int = 30
+8 -8
View File
@@ -1,4 +1,4 @@
from typing import Any, Dict, List, Optional
from typing import Any
from pydantic import BaseModel, Field
@@ -8,26 +8,26 @@ from frigate.config import GenAIProviderEnum
class AppConfigSetBody(BaseModel):
requires_restart: int = 1
update_topic: str | None = None
config_data: Optional[Dict[str, Any]] = None
config_data: dict[str, Any] | None = None
skip_save: bool = False
class GenAIProbeBody(BaseModel):
provider: GenAIProviderEnum
api_key: Optional[str] = None
base_url: Optional[str] = None
provider_options: Dict[str, Any] = Field(default_factory=dict)
api_key: str | None = None
base_url: str | None = None
provider_options: dict[str, Any] = Field(default_factory=dict)
class AppPutPasswordBody(BaseModel):
password: str
old_password: Optional[str] = None
old_password: str | None = None
class AppPostUsersBody(BaseModel):
username: str
password: str
role: Optional[str] = "viewer"
role: str | None = "viewer"
class AppPostLoginBody(BaseModel):
@@ -47,7 +47,7 @@ class MediaSyncBody(BaseModel):
dry_run: bool = Field(
default=True, description="If True, only report orphans without deleting them"
)
media_types: List[str] = Field(
media_types: list[str] = Field(
default=["all"],
description="Types of media to sync: 'all', 'event_snapshots', 'event_thumbnails', 'review_thumbnails', 'previews', 'exports', 'recordings'",
)
@@ -1,5 +1,3 @@
from typing import List, Optional
from pydantic import BaseModel, Field, model_validator
MAX_BATCH_EXPORT_ITEMS = 50
@@ -9,18 +7,18 @@ class BatchExportItem(BaseModel):
camera: str = Field(title="Camera name")
start_time: float = Field(title="Start time")
end_time: float = Field(title="End time")
image_path: Optional[str] = Field(
image_path: str | None = Field(
default=None,
title="Existing thumbnail path",
description="Optional existing image to use as the export thumbnail",
)
friendly_name: Optional[str] = Field(
friendly_name: str | None = Field(
default=None,
title="Friendly name",
max_length=256,
description="Optional friendly name for this specific export item",
)
client_item_id: Optional[str] = Field(
client_item_id: str | None = Field(
default=None,
title="Client item ID",
max_length=128,
@@ -29,13 +27,13 @@ class BatchExportItem(BaseModel):
class BatchExportBody(BaseModel):
items: List[BatchExportItem] = Field(
items: list[BatchExportItem] = Field(
title="Items",
min_length=1,
max_length=MAX_BATCH_EXPORT_ITEMS,
description="List of export items. Each item has its own camera and time range.",
)
export_case_id: Optional[str] = Field(
export_case_id: str | None = Field(
default=None,
title="Export case ID",
max_length=30,
@@ -44,13 +42,13 @@ class BatchExportBody(BaseModel):
"existing case is temporarily admin-only until case-level ACLs exist."
),
)
new_case_name: Optional[str] = Field(
new_case_name: str | None = Field(
default=None,
title="New case name",
max_length=100,
description="Name of a new export case to create when export_case_id is omitted",
)
new_case_description: Optional[str] = Field(
new_case_description: str | None = Field(
default=None,
title="New case description",
description="Optional description for a newly created export case",
+6 -6
View File
@@ -1,6 +1,6 @@
"""Chat API request models."""
from typing import Any, Optional
from typing import Any
from pydantic import BaseModel, Field
@@ -11,7 +11,7 @@ class ChatMessage(BaseModel):
role: str = Field(
description="Message role: 'user', 'assistant', 'system', or 'tool'"
)
content: Optional[Any] = Field(
content: Any | None = Field(
default=None,
description=(
"Message content. Usually a string, but may be a multimodal content "
@@ -19,13 +19,13 @@ class ChatMessage(BaseModel):
"request tool calls."
),
)
tool_call_id: Optional[str] = Field(
tool_call_id: str | None = Field(
default=None, description="For tool messages, the ID of the tool call"
)
name: Optional[str] = Field(
name: str | None = Field(
default=None, description="For tool messages, the tool name"
)
tool_calls: Optional[list[dict[str, Any]]] = Field(
tool_calls: list[dict[str, Any]] | None = Field(
default=None,
description=(
"For assistant messages replayed from prior turns, the OpenAI-format "
@@ -52,7 +52,7 @@ class ChatCompletionRequest(BaseModel):
default=False,
description="If true, stream the final assistant response in the body as newline-delimited JSON.",
)
enable_thinking: Optional[bool] = Field(
enable_thinking: bool | None = Field(
default=None,
description=(
"Per-request thinking toggle. None means use the provider default. "
@@ -1,5 +1,3 @@
from typing import Dict, List, Tuple
from pydantic import BaseModel, Field
@@ -12,14 +10,14 @@ class AudioTranscriptionBody(BaseModel):
class DeleteFaceImagesBody(BaseModel):
ids: List[str] = Field(
ids: list[str] = Field(
description="List of image filenames to delete from the face folder"
)
class GenerateStateExamplesBody(BaseModel):
model_name: str = Field(description="Name of the classification model")
cameras: Dict[str, Tuple[float, float, float, float]] = Field(
cameras: dict[str, tuple[float, float, float, float]] = Field(
description="Dictionary mapping camera names to normalized crop coordinates in [x1, y1, x2, y2] format (values 0-1)"
)
+13 -17
View File
@@ -1,5 +1,3 @@
from typing import List, Optional, Union
from pydantic import BaseModel, Field
from frigate.config.classification import TriggerType
@@ -7,49 +5,47 @@ from frigate.config.classification import TriggerType
class EventsSubLabelBody(BaseModel):
subLabel: str = Field(title="Sub label", max_length=100)
subLabelScore: Optional[float] = Field(
subLabelScore: float | None = Field(
title="Score for sub label", default=None, gt=0.0, le=1.0
)
camera: Optional[str] = Field(
title="Camera this object is detected on.", default=None
)
camera: str | None = Field(title="Camera this object is detected on.", default=None)
class EventsLPRBody(BaseModel):
recognizedLicensePlate: str = Field(
title="Recognized License Plate", max_length=100
)
recognizedLicensePlateScore: Optional[float] = Field(
recognizedLicensePlateScore: float | None = Field(
title="Score for recognized license plate", default=None, gt=0.0, le=1.0
)
class EventsAttributesBody(BaseModel):
attributes: List[str] = Field(
attributes: list[str] = Field(
title="Selected classification attributes for the event",
default_factory=list,
)
class EventsDescriptionBody(BaseModel):
description: Union[str, None] = Field(title="The description of the event")
description: str | None = Field(title="The description of the event")
class EventsCreateBody(BaseModel):
sub_label: Optional[str] = None
score: Optional[float] = 0
duration: Optional[int] = 30
include_recording: Optional[bool] = True
draw: Optional[dict] = {}
pre_capture: Optional[int] = None
sub_label: str | None = None
score: float | None = 0
duration: int | None = 30
include_recording: bool | None = True
draw: dict | None = {}
pre_capture: int | None = None
class EventsEndBody(BaseModel):
end_time: Optional[float] = None
end_time: float | None = None
class EventsDeleteBody(BaseModel):
event_ids: List[str] = Field(title="The event IDs to delete")
event_ids: list[str] = Field(title="The event IDs to delete")
class SubmitPlusBody(BaseModel):
+1 -3
View File
@@ -1,7 +1,5 @@
"""Request bodies for bulk export operations."""
from typing import Optional
from pydantic import BaseModel, Field, conlist, constr
@@ -17,7 +15,7 @@ class ExportBulkReassignBody(BaseModel):
# List of export IDs with at least one element and each element with at least one char
ids: conlist(constr(min_length=1), min_length=1)
export_case_id: Optional[str] = Field(
export_case_id: str | None = Field(
default=None,
max_length=30,
description="Case ID to assign to, or null to unassign from current case",
+3 -5
View File
@@ -1,5 +1,3 @@
from typing import Optional
from pydantic import BaseModel, Field
@@ -7,7 +5,7 @@ class ExportCaseCreateBody(BaseModel):
"""Request body for creating a new export case."""
name: str = Field(max_length=100, description="Friendly name of the export case")
description: Optional[str] = Field(
description: str | None = Field(
default=None, description="Optional description of the export case"
)
@@ -15,11 +13,11 @@ class ExportCaseCreateBody(BaseModel):
class ExportCaseUpdateBody(BaseModel):
"""Request body for updating an existing export case."""
name: Optional[str] = Field(
name: str | None = Field(
default=None,
max_length=100,
description="Updated friendly name of the export case",
)
description: Optional[str] = Field(
description: str | None = Field(
default=None, description="Updated description of the export case"
)
@@ -1,5 +1,3 @@
from typing import Optional, Union
from pydantic import BaseModel, Field
from pydantic.json_schema import SkipJsonSchema
@@ -13,15 +11,15 @@ class ExportRecordingsBody(BaseModel):
source: PlaybackSourceEnum = Field(
default=PlaybackSourceEnum.recordings, title="Playback source"
)
name: Optional[str] = Field(title="Friendly name", default=None, max_length=256)
image_path: Union[str, SkipJsonSchema[None]] = None
export_case_id: Optional[str] = Field(
name: str | None = Field(title="Friendly name", default=None, max_length=256)
image_path: str | SkipJsonSchema[None] = None
export_case_id: str | None = Field(
default=None,
title="Export case ID",
max_length=30,
description="ID of the export case to assign this export to",
)
chapters: Optional[ChaptersEnum] = Field(
chapters: ChaptersEnum | None = Field(
default=None,
title="Chapter mode",
description=(
@@ -36,19 +34,19 @@ class ExportRecordingsCustomBody(BaseModel):
default=PlaybackSourceEnum.recordings, title="Playback source"
)
name: str = Field(title="Friendly name", default=None, max_length=256)
image_path: Union[str, SkipJsonSchema[None]] = None
export_case_id: Optional[str] = Field(
image_path: str | SkipJsonSchema[None] = None
export_case_id: str | None = Field(
default=None,
title="Export case ID",
max_length=30,
description="ID of the export case to assign this export to",
)
ffmpeg_input_args: Optional[str] = Field(
ffmpeg_input_args: str | None = Field(
default=None,
title="FFmpeg input arguments",
description="Custom FFmpeg input arguments. If not provided, defaults to timelapse input args.",
)
ffmpeg_output_args: Optional[str] = Field(
ffmpeg_output_args: str | None = Field(
default=None,
title="FFmpeg output arguments",
description="Custom FFmpeg output arguments. If not provided, defaults to timelapse output args.",
+4 -4
View File
@@ -1,6 +1,6 @@
"""Chat API response models."""
from typing import Any, Optional
from typing import Any
from pydantic import BaseModel, Field
@@ -17,14 +17,14 @@ class ChatMessageResponse(BaseModel):
"""A message in the chat response."""
role: str = Field(description="Message role")
content: Optional[str] = Field(
content: str | None = Field(
default=None, description="Message content (None if tool calls present)"
)
reasoning: Optional[str] = Field(
reasoning: str | None = Field(
default=None,
description="Separated reasoning/thinking trace if the model emitted one",
)
tool_calls: Optional[list[ToolCallInvocation]] = Field(
tool_calls: list[ToolCallInvocation] | None = Field(
default=None, description="Tool calls if LLM wants to call tools"
)
@@ -1,9 +1,7 @@
from typing import Dict, List, Optional
from pydantic import BaseModel, Field, RootModel
class FacesResponse(RootModel[Dict[str, List[str]]]):
class FacesResponse(RootModel[dict[str, list[str]]]):
"""Response model for the get_faces endpoint.
Returns a mapping of face names to lists of image filenames.
@@ -17,7 +15,7 @@ class FacesResponse(RootModel[Dict[str, List[str]]]):
}
"""
root: Dict[str, List[str]] = Field(
root: dict[str, list[str]] = Field(
default_factory=dict,
description="Dictionary mapping face names to lists of image filenames",
)
@@ -30,9 +28,9 @@ class FaceRecognitionResponse(BaseModel):
"""
success: bool = Field(description="Whether the face recognition was successful")
score: Optional[float] = Field(
score: float | None = Field(
default=None, description="Confidence score of the recognition (0-1)"
)
face_name: Optional[str] = Field(
face_name: str | None = Field(
default=None, description="The recognized face name if successful"
)
+9 -9
View File
@@ -1,4 +1,4 @@
from typing import Any, Optional
from typing import Any
from pydantic import BaseModel, ConfigDict
@@ -6,20 +6,20 @@ from pydantic import BaseModel, ConfigDict
class EventResponse(BaseModel):
id: str
label: str
sub_label: Optional[str]
sub_label: str | None
camera: str
start_time: float
end_time: Optional[float]
false_positive: Optional[bool]
end_time: float | None
false_positive: bool | None
zones: list[str]
thumbnail: Optional[str]
thumbnail: str | None
has_clip: bool
has_snapshot: bool
retain_indefinitely: bool
plus_id: Optional[str]
model_hash: Optional[str]
detector_type: Optional[str]
model_type: Optional[str]
plus_id: str | None
model_hash: str | None
detector_type: str | None
model_type: str | None
data: dict[str, Any]
model_config = ConfigDict(protected_namespaces=())
@@ -1,5 +1,3 @@
from typing import List, Optional
from pydantic import BaseModel, Field
@@ -8,7 +6,7 @@ class ExportCaseModel(BaseModel):
id: str = Field(description="Unique identifier for the export case")
name: str = Field(description="Friendly name of the export case")
description: Optional[str] = Field(
description: str | None = Field(
default=None, description="Optional description of the export case"
)
created_at: float = Field(
@@ -19,4 +17,4 @@ class ExportCaseModel(BaseModel):
)
ExportCasesResponse = List[ExportCaseModel]
ExportCasesResponse = list[ExportCaseModel]
+20 -20
View File
@@ -1,4 +1,4 @@
from typing import Any, List, Optional
from typing import Any
from pydantic import BaseModel, Field
@@ -15,7 +15,7 @@ class ExportModel(BaseModel):
in_progress: bool = Field(
description="Whether the export is currently being processed"
)
export_case_id: Optional[str] = Field(
export_case_id: str | None = Field(
default=None, description="ID of the export case this export belongs to"
)
@@ -25,10 +25,10 @@ class StartExportResponse(BaseModel):
success: bool = Field(description="Whether the export was started successfully")
message: str = Field(description="Status or error message")
export_id: Optional[str] = Field(
export_id: str | None = Field(
default=None, description="The export ID if successfully started"
)
status: Optional[str] = Field(
status: str | None = Field(
default=None,
description="Queue status for the export job",
)
@@ -38,24 +38,24 @@ class BatchExportResultModel(BaseModel):
"""Per-item result for a batch export request."""
camera: str = Field(description="Camera name for this export attempt")
export_id: Optional[str] = Field(
export_id: str | None = Field(
default=None,
description="The export ID when the export was successfully queued",
)
success: bool = Field(description="Whether the export was successfully queued")
status: Optional[str] = Field(
status: str | None = Field(
default=None,
description="Queue status for this camera export",
)
error: Optional[str] = Field(
error: str | None = Field(
default=None,
description="Validation or queueing error for this item, if any",
)
item_index: Optional[int] = Field(
item_index: int | None = Field(
default=None,
description="Zero-based index of this result within the request items list",
)
client_item_id: Optional[str] = Field(
client_item_id: str | None = Field(
default=None,
description="Opaque client-supplied item identifier echoed from the request",
)
@@ -64,12 +64,12 @@ class BatchExportResultModel(BaseModel):
class BatchExportResponse(BaseModel):
"""Response model for starting an export batch."""
export_case_id: Optional[str] = Field(
export_case_id: str | None = Field(
default=None,
description="Export case ID associated with the batch",
)
export_ids: List[str] = Field(description="Export IDs successfully queued")
results: List[BatchExportResultModel] = Field(
export_ids: list[str] = Field(description="Export IDs successfully queued")
results: list[BatchExportResultModel] = Field(
description="Per-item batch export results"
)
@@ -81,29 +81,29 @@ class ExportJobModel(BaseModel):
job_type: str = Field(description="Job type")
status: str = Field(description="Current job status")
camera: str = Field(description="Camera associated with this export job")
name: Optional[str] = Field(
name: str | None = Field(
default=None,
description="Friendly name for the export",
)
export_case_id: Optional[str] = Field(
export_case_id: str | None = Field(
default=None,
description="ID of the export case this export belongs to",
)
request_start_time: float = Field(description="Requested export start time")
request_end_time: float = Field(description="Requested export end time")
start_time: Optional[float] = Field(
start_time: float | None = Field(
default=None,
description="Unix timestamp when execution started",
)
end_time: Optional[float] = Field(
end_time: float | None = Field(
default=None,
description="Unix timestamp when execution completed",
)
error_message: Optional[str] = Field(
error_message: str | None = Field(
default=None,
description="Error message for failed jobs",
)
results: Optional[dict[str, Any]] = Field(
results: dict[str, Any] | None = Field(
default=None,
description="Result metadata for completed jobs",
)
@@ -117,7 +117,7 @@ class ExportJobModel(BaseModel):
)
ExportJobsResponse = List[ExportJobModel]
ExportJobsResponse = list[ExportJobModel]
ExportsResponse = List[ExportModel]
ExportsResponse = list[ExportModel]
@@ -1,5 +1,3 @@
from typing import List
from pydantic import BaseModel, Field
@@ -13,5 +11,5 @@ class PreviewModel(BaseModel):
end: float = Field(description="Unix timestamp when the preview ends")
PreviewsResponse = List[PreviewModel]
PreviewFramesResponse = List[str]
PreviewsResponse = list[PreviewModel]
PreviewFramesResponse = list[str]
+1 -2
View File
@@ -1,5 +1,4 @@
from datetime import datetime
from typing import Dict
from pydantic import BaseModel, Json
@@ -34,7 +33,7 @@ class DayReview(BaseModel):
class ReviewSummaryResponse(BaseModel):
last24Hours: Last24HoursReview
root: Dict[str, DayReview]
root: dict[str, DayReview]
class ReviewActivityMotionResponse(BaseModel):
+68 -73
View File
@@ -10,7 +10,6 @@ import random
import string
from functools import reduce
from pathlib import Path
from typing import List
from urllib.parse import unquote
import numpy as np
@@ -97,7 +96,7 @@ def _build_attribute_filter_clause(attributes: str):
)
def events(
params: EventsQueryParams = Depends(),
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter),
):
camera = params.camera
cameras = params.cameras
@@ -171,7 +170,7 @@ def events(
]
if camera != "all":
clauses.append((Event.camera == camera))
clauses.append(Event.camera == camera)
if cameras != "all":
requested = set(cameras.split(","))
@@ -181,11 +180,11 @@ def events(
camera_list = list(filtered)
else:
camera_list = allowed_cameras
clauses.append((Event.camera << camera_list))
clauses.append(Event.camera << camera_list)
if labels != "all":
label_list = labels.split(",")
clauses.append((Event.label << label_list))
clauses.append(Event.label << label_list)
if sub_labels != "all":
# use matching so joined sub labels are included
@@ -196,24 +195,24 @@ def events(
if "None" in filtered_sub_labels:
filtered_sub_labels.remove("None")
sub_label_clauses.append((Event.sub_label.is_null()))
sub_label_clauses.append(Event.sub_label.is_null())
for label in filtered_sub_labels:
lowered = label.lower()
sub_label_clauses.append(
(fn.LOWER(Event.sub_label.cast("text")) == lowered)
fn.LOWER(Event.sub_label.cast("text")) == lowered
) # include exact matches (case-insensitive)
# include this label when part of a list (LIKE is case-insensitive in sqlite for ASCII)
sub_label_clauses.append(
(fn.LOWER(Event.sub_label.cast("text")) % f"*{lowered},*")
fn.LOWER(Event.sub_label.cast("text")) % f"*{lowered},*"
)
sub_label_clauses.append(
(fn.LOWER(Event.sub_label.cast("text")) % f"*, {lowered}*")
fn.LOWER(Event.sub_label.cast("text")) % f"*, {lowered}*"
)
sub_label_clause = reduce(operator.or_, sub_label_clauses)
clauses.append((sub_label_clause))
clauses.append(sub_label_clause)
if attributes != "all":
# Custom classification results are stored as data[model_name] = result_value
@@ -257,19 +256,19 @@ def events(
if "None" in filtered_zones:
filtered_zones.remove("None")
zone_clauses.append((Event.zones.length() == 0))
zone_clauses.append(Event.zones.length() == 0)
for zone in filtered_zones:
zone_clauses.append((Event.zones.cast("text") % f'*"{zone}"*'))
zone_clauses.append(Event.zones.cast("text") % f'*"{zone}"*')
zone_clause = reduce(operator.or_, zone_clauses)
clauses.append((zone_clause))
clauses.append(zone_clause)
if after:
clauses.append((Event.start_time > after))
clauses.append(Event.start_time > after)
if before:
clauses.append((Event.start_time < before))
clauses.append(Event.start_time < before)
if time_range != DEFAULT_TIME_RANGE:
# get timezone arg to ensure browser times are used
@@ -289,62 +288,60 @@ def events(
# should use or operator
if time_after > time_before:
clauses.append(
(
reduce(
operator.or_,
[(start_hour_fun > time_after), (start_hour_fun < time_before)],
)
reduce(
operator.or_,
[(start_hour_fun > time_after), (start_hour_fun < time_before)],
)
)
# all other cases should be and operator
else:
clauses.append((start_hour_fun > time_after))
clauses.append((start_hour_fun < time_before))
clauses.append(start_hour_fun > time_after)
clauses.append(start_hour_fun < time_before)
if has_clip is not None:
clauses.append((Event.has_clip == has_clip))
clauses.append(Event.has_clip == has_clip)
if has_snapshot is not None:
clauses.append((Event.has_snapshot == has_snapshot))
clauses.append(Event.has_snapshot == has_snapshot)
if in_progress is not None:
clauses.append((Event.end_time.is_null(in_progress)))
clauses.append(Event.end_time.is_null(in_progress))
if include_thumbnails:
selected_columns.append(Event.thumbnail)
if favorites:
clauses.append((Event.retain_indefinitely == favorites))
clauses.append(Event.retain_indefinitely == favorites)
if max_score is not None:
clauses.append((Event.data["score"] <= max_score))
clauses.append(Event.data["score"] <= max_score)
if min_score is not None:
clauses.append((Event.data["score"] >= min_score))
clauses.append(Event.data["score"] >= min_score)
if max_speed is not None:
clauses.append((Event.data["average_estimated_speed"] <= max_speed))
clauses.append(Event.data["average_estimated_speed"] <= max_speed)
if min_speed is not None:
clauses.append((Event.data["average_estimated_speed"] >= min_speed))
clauses.append(Event.data["average_estimated_speed"] >= min_speed)
if min_length is not None:
clauses.append(((Event.end_time - Event.start_time) >= min_length))
clauses.append((Event.end_time - Event.start_time) >= min_length)
if max_length is not None:
clauses.append(((Event.end_time - Event.start_time) <= max_length))
clauses.append((Event.end_time - Event.start_time) <= max_length)
if is_submitted is not None:
if is_submitted == 0:
clauses.append((Event.plus_id.is_null()))
clauses.append(Event.plus_id.is_null())
elif is_submitted > 0:
clauses.append((Event.plus_id != ""))
clauses.append(Event.plus_id != "")
if event_id is not None:
clauses.append((Event.id == event_id))
clauses.append(Event.id == event_id)
if len(clauses) == 0:
clauses.append((True))
clauses.append(True)
if sort:
if sort == "score_asc":
@@ -387,7 +384,7 @@ def events(
)
def events_explore(
limit: int = 10,
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter),
):
# get distinct labels for all events
distinct_labels = (
@@ -515,7 +512,7 @@ async def event_ids(ids: str, request: Request):
def events_search(
request: Request,
params: EventsSearchQueryParams = Depends(),
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter),
):
query = params.query
search_type = params.search_type
@@ -595,12 +592,12 @@ def events_search(
filtered = requested.intersection(allowed_cameras)
if not filtered:
return JSONResponse(content=[])
event_filters.append((Event.camera << list(filtered)))
event_filters.append(Event.camera << list(filtered))
else:
event_filters.append((Event.camera << allowed_cameras))
event_filters.append(Event.camera << allowed_cameras)
if labels != "all":
event_filters.append((Event.label << labels.split(",")))
event_filters.append(Event.label << labels.split(","))
if sub_labels != "all":
# use matching so joined sub labels are included
@@ -611,23 +608,23 @@ def events_search(
if "None" in filtered_sub_labels:
filtered_sub_labels.remove("None")
sub_label_clauses.append((Event.sub_label.is_null()))
sub_label_clauses.append(Event.sub_label.is_null())
for label in filtered_sub_labels:
lowered = label.lower()
sub_label_clauses.append(
(fn.LOWER(Event.sub_label.cast("text")) == lowered)
fn.LOWER(Event.sub_label.cast("text")) == lowered
) # include exact matches (case-insensitive)
# include this label when part of a list (LIKE is case-insensitive in sqlite for ASCII)
sub_label_clauses.append(
(fn.LOWER(Event.sub_label.cast("text")) % f"*{lowered},*")
fn.LOWER(Event.sub_label.cast("text")) % f"*{lowered},*"
)
sub_label_clauses.append(
(fn.LOWER(Event.sub_label.cast("text")) % f"*, {lowered}*")
fn.LOWER(Event.sub_label.cast("text")) % f"*, {lowered}*"
)
event_filters.append((reduce(operator.or_, sub_label_clauses)))
event_filters.append(reduce(operator.or_, sub_label_clauses))
if attributes != "all":
# Custom classification results are stored as data[model_name] = result_value
@@ -641,12 +638,12 @@ def events_search(
if "None" in filtered_zones:
filtered_zones.remove("None")
zone_clauses.append((Event.zones.length() == 0))
zone_clauses.append(Event.zones.length() == 0)
for zone in filtered_zones:
zone_clauses.append((Event.zones.cast("text") % f'*"{zone}"*'))
zone_clauses.append(Event.zones.cast("text") % f'*"{zone}"*')
event_filters.append((reduce(operator.or_, zone_clauses)))
event_filters.append(reduce(operator.or_, zone_clauses))
if recognized_license_plate != "all":
filtered_recognized_license_plates = recognized_license_plate.split(",")
@@ -674,43 +671,43 @@ def events_search(
)
recognized_license_plate_clause = reduce(operator.or_, clauses_for_plates)
event_filters.append((recognized_license_plate_clause))
event_filters.append(recognized_license_plate_clause)
if after:
event_filters.append((Event.start_time > after))
event_filters.append(Event.start_time > after)
if before:
event_filters.append((Event.start_time < before))
event_filters.append(Event.start_time < before)
if has_clip is not None:
event_filters.append((Event.has_clip == has_clip))
event_filters.append(Event.has_clip == has_clip)
if has_snapshot is not None:
event_filters.append((Event.has_snapshot == has_snapshot))
event_filters.append(Event.has_snapshot == has_snapshot)
if is_submitted is not None:
if is_submitted == 0:
event_filters.append((Event.plus_id.is_null()))
event_filters.append(Event.plus_id.is_null())
elif is_submitted > 0:
event_filters.append((Event.plus_id != ""))
event_filters.append(Event.plus_id != "")
if min_score is not None and max_score is not None:
event_filters.append((Event.data["score"].between(min_score, max_score)))
event_filters.append(Event.data["score"].between(min_score, max_score))
else:
if min_score is not None:
event_filters.append((Event.data["score"] >= min_score))
event_filters.append(Event.data["score"] >= min_score)
if max_score is not None:
event_filters.append((Event.data["score"] <= max_score))
event_filters.append(Event.data["score"] <= max_score)
if min_speed is not None and max_speed is not None:
event_filters.append(
(Event.data["average_estimated_speed"].between(min_speed, max_speed))
Event.data["average_estimated_speed"].between(min_speed, max_speed)
)
else:
if min_speed is not None:
event_filters.append((Event.data["average_estimated_speed"] >= min_speed))
event_filters.append(Event.data["average_estimated_speed"] >= min_speed)
if max_speed is not None:
event_filters.append((Event.data["average_estimated_speed"] <= max_speed))
event_filters.append(Event.data["average_estimated_speed"] <= max_speed)
if time_range != DEFAULT_TIME_RANGE:
tz_name = params.timezone
@@ -728,17 +725,15 @@ def events_search(
# should use or operator
if time_after > time_before:
event_filters.append(
(
reduce(
operator.or_,
[(start_hour_fun > time_after), (start_hour_fun < time_before)],
)
reduce(
operator.or_,
[(start_hour_fun > time_after), (start_hour_fun < time_before)],
)
)
# all other cases should be and operator
else:
event_filters.append((start_hour_fun > time_after))
event_filters.append((start_hour_fun < time_before))
event_filters.append(start_hour_fun > time_after)
event_filters.append(start_hour_fun < time_before)
# Perform semantic search
search_results = {}
@@ -894,7 +889,7 @@ def events_search(
@router.get("/events/summary", dependencies=[Depends(allow_any_authenticated())])
def events_summary(
params: EventsSummaryQueryParams = Depends(),
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter),
):
tz_name = params.timezone
has_clip = params.has_clip
@@ -903,13 +898,13 @@ def events_summary(
clauses = []
if has_clip is not None:
clauses.append((Event.has_clip == has_clip))
clauses.append(Event.has_clip == has_clip)
if has_snapshot is not None:
clauses.append((Event.has_snapshot == has_snapshot))
clauses.append(Event.has_snapshot == has_snapshot)
if len(clauses) == 0:
clauses.append((True))
clauses.append(True)
time_range_query = (
Event.select(
+24 -24
View File
@@ -7,8 +7,8 @@ import string
import time
import zipfile
from collections import deque
from collections.abc import Iterator
from pathlib import Path
from typing import Iterator, List, Optional
import psutil
from fastapi import APIRouter, Depends, Query, Request
@@ -89,7 +89,7 @@ def _generate_export_id(camera_name: str) -> str:
def _create_export_case_record(
name: str,
description: Optional[str],
description: str | None,
) -> ExportCase:
now = datetime.datetime.fromtimestamp(time.time())
return ExportCase.create(
@@ -101,7 +101,7 @@ def _create_export_case_record(
)
def _validate_camera_name(request: Request, camera_name: str) -> Optional[JSONResponse]:
def _validate_camera_name(request: Request, camera_name: str) -> JSONResponse | None:
if camera_name and request.app.frigate_config.cameras.get(camera_name):
return None
@@ -111,7 +111,7 @@ def _validate_camera_name(request: Request, camera_name: str) -> Optional[JSONRe
)
def _validate_export_case(export_case_id: Optional[str]) -> Optional[JSONResponse]:
def _validate_export_case(export_case_id: str | None) -> JSONResponse | None:
if export_case_id is None:
return None
@@ -127,8 +127,8 @@ def _validate_export_case(export_case_id: Optional[str]) -> Optional[JSONRespons
def _sanitize_existing_image(
image_path: Optional[str],
) -> tuple[Optional[str], Optional[JSONResponse]]:
image_path: str | None,
) -> tuple[str | None, JSONResponse | None]:
# sanitize_filepath normalizes "\" to "/" but leaves ".." intact, so a path
# like "clips\..\..\etc/passwd" passes the CLIPS_DIR prefix check yet still
# escapes the directory once resolved. A valid snapshot path never uses "..".
@@ -154,7 +154,7 @@ def _validate_export_source(
start_time: float,
end_time: float,
playback_source: PlaybackSourceEnum,
) -> Optional[str]:
) -> str | None:
if playback_source == PlaybackSourceEnum.recordings:
recordings_count = (
Recordings.select()
@@ -257,14 +257,14 @@ def _build_export_job(
camera_name: str,
start_time: float,
end_time: float,
friendly_name: Optional[str],
existing_image: Optional[str],
friendly_name: str | None,
existing_image: str | None,
playback_source: PlaybackSourceEnum,
export_case_id: Optional[str],
ffmpeg_input_args: Optional[str] = None,
ffmpeg_output_args: Optional[str] = None,
export_case_id: str | None,
ffmpeg_input_args: str | None = None,
ffmpeg_output_args: str | None = None,
cpu_fallback: bool = False,
chapters: Optional[ChaptersEnum] = None,
chapters: ChaptersEnum | None = None,
) -> ExportJob:
return ExportJob(
id=_generate_export_id(camera_name),
@@ -302,11 +302,11 @@ def _export_case_to_dict(case: ExportCase) -> dict[str, object]:
Returns a list of exports ordered by date (most recent first).""",
)
def get_exports(
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
export_case_id: Optional[str] = None,
cameras: Optional[str] = Query(default="all"),
start_date: Optional[float] = None,
end_date: Optional[float] = None,
allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter),
export_case_id: str | None = None,
cameras: str | None = Query(default="all"),
start_date: float | None = None,
end_date: float | None = None,
):
query = Export.select().where(Export.camera << allowed_cameras)
@@ -422,7 +422,7 @@ def _unique_archive_name(export: Export, used: set[str]) -> str:
return candidate
def _stream_case_archive(exports: List[Export]) -> Iterator[bytes]:
def _stream_case_archive(exports: list[Export]) -> Iterator[bytes]:
"""Yield bytes of a zip archive built from the given exports' mp4 files."""
buffer = _StreamingZipBuffer()
used_names: set[str] = set()
@@ -466,7 +466,7 @@ def _stream_case_archive(exports: List[Export]) -> Iterator[bytes]:
)
def download_export_case(
case_id: str,
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter),
):
try:
case = ExportCase.get(ExportCase.id == case_id)
@@ -580,7 +580,7 @@ def delete_export_case(case_id: str, request: Request, delete_exports: bool = Fa
)
def get_active_export_jobs(
request: Request,
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter),
):
jobs = list_active_export_jobs(request.app.frigate_config)
return JSONResponse(
@@ -622,7 +622,7 @@ async def get_export_job_status(export_id: str, request: Request):
def export_recordings_batch(
request: Request,
body: BatchExportBody,
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter),
current_user: dict = Depends(get_current_user),
):
if isinstance(current_user, JSONResponse):
@@ -662,7 +662,7 @@ def export_recordings_batch(
# Sanitize each item's image_path up front. A bad path in any item
# kills the whole request, consistent with single-export behavior.
sanitized_images: list[Optional[str]] = []
sanitized_images: list[str | None] = []
for item in body.items:
existing_image, image_validation_error = _sanitize_existing_image(
item.image_path
@@ -713,7 +713,7 @@ def export_recordings_batch(
export_case_id = export_case.id
export_ids: list[str] = []
results: list[dict[str, Optional[str] | bool | int]] = []
results: list[dict[str, str | None | bool | int]] = []
for index, item in enumerate(body.items):
if index in item_errors:
results.append(
+3 -4
View File
@@ -1,7 +1,6 @@
import asyncio
import logging
import re
from typing import Optional
from fastapi import Depends, FastAPI, Request
from fastapi.responses import JSONResponse
@@ -64,7 +63,7 @@ class RemoteUserPlugin(Plugin):
def create_fastapi_app(
frigate_config: FrigateConfig,
database: SqliteQueueDatabase,
embeddings: Optional[EmbeddingsContext],
embeddings: EmbeddingsContext | None,
detected_frames_processor,
storage_maintainer: StorageMaintainer,
onvif: OnvifController,
@@ -72,8 +71,8 @@ def create_fastapi_app(
event_metadata_updater: EventMetadataPublisher,
config_publisher: CameraConfigUpdatePublisher,
replay_manager: DebugReplayManager,
dispatcher: Optional[Dispatcher] = None,
profile_manager: Optional[ProfileManager] = None,
dispatcher: Dispatcher | None = None,
profile_manager: ProfileManager | None = None,
enforce_default_admin: bool = True,
):
logger.info("Starting FastAPI app")
+7 -14
View File
@@ -7,7 +7,7 @@ import math
import os
import subprocess as sp
import time
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from pathlib import Path as FilePath
from typing import Any
from urllib.parse import unquote
@@ -314,10 +314,8 @@ async def get_snapshot_from_recording(
Recordings.start_time,
)
.where(
(
(frame_time >= Recordings.start_time)
& (frame_time <= Recordings.end_time)
)
(frame_time >= Recordings.start_time)
& (frame_time <= Recordings.end_time)
)
.where(Recordings.camera == camera_name)
.order_by(Recordings.start_time.desc())
@@ -335,10 +333,8 @@ async def get_snapshot_from_recording(
Recordings.start_time,
)
.where(
(
(frame_time >= Recordings.start_time)
& (frame_time <= Recordings.end_time)
)
(frame_time >= Recordings.start_time)
& (frame_time <= Recordings.end_time)
)
.where(Recordings.camera == camera_name)
.order_by(Recordings.start_time.desc())
@@ -398,10 +394,7 @@ async def submit_recording_snapshot_to_plus(
Recordings.start_time,
)
.where(
(
(frame_time >= Recordings.start_time)
& (frame_time <= Recordings.end_time)
)
(frame_time >= Recordings.start_time) & (frame_time <= Recordings.end_time)
)
.where(Recordings.camera == camera_name)
.order_by(Recordings.start_time.desc())
@@ -719,7 +712,7 @@ async def vod_hour(
):
parts = year_month.split("-")
start_date = (
datetime(int(parts[0]), int(parts[1]), day, hour, tzinfo=timezone.utc)
datetime(int(parts[0]), int(parts[1]), day, hour, tzinfo=UTC)
- datetime.now(pytz.timezone(tz_name.replace(",", "/"))).utcoffset()
)
end_date = start_date + timedelta(hours=1) - timedelta(milliseconds=1)
+15 -16
View File
@@ -14,7 +14,6 @@ from __future__ import annotations
import logging
import os
from enum import Enum
from typing import Optional
from urllib.parse import unquote, urlparse
from peewee import DoesNotExist
@@ -42,7 +41,7 @@ class MediaAuthResolution(str, Enum):
UNKNOWN = "unknown"
def extract_path(original_url: Optional[str]) -> Optional[str]:
def extract_path(original_url: str | None) -> str | None:
"""Return the decoded path component of nginx's `X-Original-URL` header.
nginx forwards the *raw* request URI (with `..` segments intact) via
@@ -72,8 +71,8 @@ def extract_path(original_url: Optional[str]) -> Optional[str]:
def resolve_media_uri(
uri: str, frigate_config: Optional[FrigateConfig] = None
) -> tuple[MediaAuthResolution, Optional[str]]:
uri: str, frigate_config: FrigateConfig | None = None
) -> tuple[MediaAuthResolution, str | None]:
"""Classify a URI and return the owning camera if applicable.
`frigate_config` is used to disambiguate clip/review filenames whose
@@ -100,7 +99,7 @@ def resolve_media_uri(
def _resolve_recording(
parts: list[str],
) -> tuple[MediaAuthResolution, Optional[str]]:
) -> tuple[MediaAuthResolution, str | None]:
# /recordings → neutral
# /recordings/{date} → neutral
# /recordings/{date}/{hour} → multi-camera listing
@@ -113,8 +112,8 @@ def _resolve_recording(
def _resolve_clip(
parts: list[str], frigate_config: Optional[FrigateConfig]
) -> tuple[MediaAuthResolution, Optional[str]]:
parts: list[str], frigate_config: FrigateConfig | None
) -> tuple[MediaAuthResolution, str | None]:
# /clips → multi-camera listing
# /clips/thumbs/{cam}/... → camera
# /clips/previews/{cam}/... → camera
@@ -159,8 +158,8 @@ def _resolve_clip(
def _longest_prefix_camera(
stem: str, frigate_config: Optional[FrigateConfig]
) -> Optional[str]:
stem: str, frigate_config: FrigateConfig | None
) -> str | None:
if frigate_config is None:
return None
for cam in sorted(frigate_config.cameras.keys(), key=len, reverse=True):
@@ -170,8 +169,8 @@ def _longest_prefix_camera(
def _camera_from_clip_filename(
filename: str, frigate_config: Optional[FrigateConfig]
) -> Optional[str]:
filename: str, frigate_config: FrigateConfig | None
) -> str | None:
"""Match a flat clip filename `{camera}-{event_id}[-clean].{ext}` against
configured camera names. Longest-prefix wins so camera names containing
hyphens (e.g. `front-door`) resolve correctly.
@@ -182,8 +181,8 @@ def _camera_from_clip_filename(
def _camera_from_thumb_filename(
filename: str, frigate_config: Optional[FrigateConfig]
) -> Optional[str]:
filename: str, frigate_config: FrigateConfig | None
) -> str | None:
"""Match a review thumbnail filename `thumb-{camera}-{review_id}.webp`."""
if not filename.startswith("thumb-"):
return None
@@ -194,7 +193,7 @@ def _camera_from_thumb_filename(
def _resolve_export(
parts: list[str],
) -> tuple[MediaAuthResolution, Optional[str]]:
) -> tuple[MediaAuthResolution, str | None]:
# /exports → multi-camera listing
# /exports/{filename}.mp4 → camera (DB lookup by exact path)
if len(parts) == 1:
@@ -240,8 +239,8 @@ def is_role_restricted(role: str, frigate_config: FrigateConfig) -> bool:
def deny_response_for_media_uri(
original_url: Optional[str], role: Optional[str], frigate_config: FrigateConfig
) -> Optional[int]:
original_url: str | None, role: str | None, frigate_config: FrigateConfig
) -> int | None:
"""Decide whether the current role should be blocked from `original_url`.
Returns an HTTP status code (403) when access should be denied, or `None`
+8 -8
View File
@@ -1,7 +1,7 @@
"""Motion search API for detecting changes within a region of interest."""
import logging
from typing import Any, List, Optional
from typing import Any
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
@@ -26,7 +26,7 @@ class MotionSearchRequest(BaseModel):
start_time: float = Field(description="Start timestamp for the search range")
end_time: float = Field(description="End timestamp for the search range")
polygon_points: List[List[float]] = Field(
polygon_points: list[list[float]] = Field(
description="List of [x, y] normalized coordinates (0-1) defining the ROI polygon"
)
threshold: int = Field(
@@ -87,12 +87,12 @@ class MotionSearchStatusResponse(BaseModel):
success: bool
message: str
status: str # "queued", "running", "success", "failed", or "cancelled"
results: Optional[List[MotionSearchResult]] = None
total_frames_processed: Optional[int] = None
error_message: Optional[str] = None
metrics: Optional[MotionSearchMetricsResponse] = None
scanning_timestamp: Optional[float] = None
progress: Optional[float] = None
results: list[MotionSearchResult] | None = None
total_frames_processed: int | None = None
error_message: str | None = None
metrics: MotionSearchMetricsResponse | None = None
scanning_timestamp: float | None = None
progress: float | None = None
@router.post(
+2 -2
View File
@@ -4,7 +4,7 @@ import bisect
import logging
import os
import threading
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
import pytz
from fastapi import APIRouter, Depends, HTTPException
@@ -125,7 +125,7 @@ def preview_hour(
"""Get all mp4 previews relevant for time period given the timezone"""
parts = year_month.split("-")
start_date = (
datetime(int(parts[0]), int(parts[1]), int(day), int(hour), tzinfo=timezone.utc)
datetime(int(parts[0]), int(parts[1]), int(day), int(hour), tzinfo=UTC)
- datetime.now(pytz.timezone(tz_name.replace(",", "/"))).utcoffset()
)
end_date = start_date + timedelta(hours=1) - timedelta(milliseconds=1)
+3 -4
View File
@@ -5,7 +5,6 @@ import logging
from datetime import datetime, timedelta
from functools import reduce
from pathlib import Path
from typing import List
from urllib.parse import unquote
from fastapi import APIRouter, Depends, Request
@@ -63,7 +62,7 @@ def get_recordings_storage_usage(request: Request):
def all_recordings_summary(
request: Request,
params: MediaRecordingsSummaryQueryParams = Depends(),
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter),
):
"""Returns true/false by day indicating if recordings exist"""
@@ -263,7 +262,7 @@ async def recordings(
async def no_recordings(
request: Request,
params: MediaRecordingsAvailabilityQueryParams = Depends(),
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter),
):
"""Get time ranges with no recordings."""
cameras = params.cameras
@@ -365,7 +364,7 @@ async def delete_recordings(
start: float = PathParam(..., description="Start timestamp (unix)"),
end: float = PathParam(..., description="End timestamp (unix)"),
params: RecordingsDeleteQueryParams = Depends(),
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter),
):
"""Delete recordings in the specified time range."""
if start >= end:
+10 -11
View File
@@ -4,7 +4,6 @@ import datetime
import logging
from functools import reduce
from pathlib import Path
from typing import List
import pandas as pd
from fastapi import APIRouter, Request
@@ -51,7 +50,7 @@ router = APIRouter(tags=[Tags.review])
async def review(
params: ReviewQueryParams = Depends(),
current_user: dict = Depends(get_current_user),
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter),
):
if isinstance(current_user, JSONResponse):
return current_user
@@ -83,7 +82,7 @@ async def review(
camera_list = list(filtered)
else:
camera_list = allowed_cameras
clauses.append((ReviewSegment.camera << camera_list))
clauses.append(ReviewSegment.camera << camera_list)
if labels != "all":
# use matching so segments with multiple labels
@@ -106,12 +105,12 @@ async def review(
for zone in filtered_zones:
zone_clauses.append(
(ReviewSegment.data["zones"].cast("text") % f'*"{zone}"*')
ReviewSegment.data["zones"].cast("text") % f'*"{zone}"*'
)
clauses.append(reduce(operator.or_, zone_clauses))
if severity:
clauses.append((ReviewSegment.severity == severity))
clauses.append(ReviewSegment.severity == severity)
# Join with UserReviewStatus to get per-user review status
review_query = (
@@ -204,7 +203,7 @@ async def review_ids(request: Request, ids: str):
async def review_summary(
params: ReviewSummaryQueryParams = Depends(),
current_user: dict = Depends(get_current_user),
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter),
):
if isinstance(current_user, JSONResponse):
return current_user
@@ -227,7 +226,7 @@ async def review_summary(
camera_list = list(filtered)
else:
camera_list = allowed_cameras
clauses.append((ReviewSegment.camera << camera_list))
clauses.append(ReviewSegment.camera << camera_list)
if labels != "all":
# use matching so segments with multiple labels
@@ -328,7 +327,7 @@ async def review_summary(
camera_list = list(filtered)
else:
camera_list = allowed_cameras
clauses.append((ReviewSegment.camera << camera_list))
clauses.append(ReviewSegment.camera << camera_list)
if labels != "all":
# use matching so segments with multiple labels
@@ -584,7 +583,7 @@ def delete_reviews(body: ReviewModifyMultipleBody):
)
def motion_activity(
params: ReviewActivityMotionQueryParams = Depends(),
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter),
):
"""Get motion and audio activity."""
cameras = params.cameras
@@ -597,7 +596,7 @@ def motion_activity(
scale = params.scale
clauses = [(Recordings.start_time > after) & (Recordings.end_time < before)]
clauses.append((Recordings.motion > 0))
clauses.append(Recordings.motion > 0)
if cameras != "all":
requested = set(cameras.split(","))
@@ -608,7 +607,7 @@ def motion_activity(
else:
camera_list = list(allowed_cameras)
clauses.append((Recordings.camera << camera_list))
clauses.append(Recordings.camera << camera_list)
data: list[Recordings] = (
Recordings.select(