Files
frigate/frigate/api/record.py
T
Josh HawkinsandGitHub 4100383738 Miscellaneous fixes (#24352)
* fix recordings unavailable endpoint when no params are provided

we already import the datetime class directly, so those attribute lookups raised AttributeError and the request returned 500

* reject JWTs whose role is no longer in the config

`/auth` trusted the role inside the JWT and re-signed it on refresh without checking the config, so a user whose restricted role was deleted kept a session carrying a role that isn't in `auth.roles`. The media, clip, recording, export and go2rtc checks treat a missing role the same as a role with no camera list, so that session could open every camera. `/auth` now returns 401 for a token whose role isn't configured, which sends the user back through login, and login already falls back to `viewer` for a role that's gone.

* delete the deleted camera group's layout, not the open one's

Deleting a camera group removed the layout of the group being viewed, because the dialog's delete was bound to `${activeGroup}-draggable-layout`. It also cleared the saved group, and both ran before the `config/set` request whether or not it succeeded, so deleting one group while viewing another lost the open group's layout and left the deleted group's layout behind. The deleted group's own layout is now removed after a successful save, and the saved group is only cleared when it was the open group.

* don't block API when querying PTZ info

camera_ptz_info is async but waited on the ONVIF controller's future with future.result(), blocking the API event loop for as long as a slow or unreachable ONVIF camera took to answer (including reconnect attempts), so every other async request stalled with it. Await the future with asyncio.wrap_future instead. The coroutine runs on the controller's own loop and thread, so this cannot deadlock.

* for custom exports, only allow admin users to add to existing cases

follows the existing convention where attaching an export to an existing case is admin-only on `POST /export/{camera}/...` and `POST /exports/batch`

* drop pending edits for a camera or profile that no longer exists

* match cached preview frames to their camera exactly

https://github.com/blakeblackshear/frigate/pull/22594 added a trailing `-` to the `preview_{camera}` prefix so `camera` stopped matching `camera2`'s frames, but camera names can contain `-`, so `front` still matched `front-door`'s. After a restart, `front`'s preview recorder deleted this hour's frames of a matching camera that sorted before it and added the timestamps of one that sorted after it, so ffmpeg was asked for files that don't exist and that hour's preview was lost. The offline fallback for `latest.jpg` could also return `front-door`'s frame for `front`, even to a user without access to `front-door`, and a short export could take its fallback thumbnail from the other camera. These now compare the full camera name taken from the file name.
2026-09-15 07:48:30 -05:00

604 lines
20 KiB
Python

"""Recording APIs."""
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 fastapi import APIRouter, Depends, Request
from fastapi import Path as PathParam
from fastapi.responses import JSONResponse
from peewee import fn, operator
from frigate.api.auth import (
allow_any_authenticated,
get_allowed_cameras_for_filter,
require_camera_access,
require_role,
)
from frigate.api.defs.query.recordings_query_parameters import (
MediaRecordingsAvailabilityQueryParams,
MediaRecordingsSummaryQueryParams,
RecordingsDeleteQueryParams,
)
from frigate.api.defs.response.generic_response import GenericResponse
from frigate.api.defs.tags import Tags
from frigate.const import (
MAX_SEGMENT_DURATION,
RECORD_DIR,
STREAM_TYPE_MAIN,
STREAM_TYPE_SUB,
)
from frigate.models import Event, Recordings
from frigate.util.recording_coverage import (
coverage_spans,
known_video_codecs,
realized_timelines,
resolve_coverage,
stream_media_summary,
)
from frigate.util.time import get_dst_transitions
logger = logging.getLogger(__name__)
router = APIRouter(tags=[Tags.recordings])
@router.get("/recordings/storage", dependencies=[Depends(require_role(["admin"]))])
def get_recordings_storage_usage(request: Request):
recording_stats = request.app.stats_emitter.get_latest_stats()["service"][
"storage"
][RECORD_DIR]
if not recording_stats:
return JSONResponse({})
total_mb = recording_stats["total"]
camera_usages: dict[str, dict] = (
request.app.storage_maintainer.calculate_camera_usages()
)
for camera_name in camera_usages.keys():
if camera_usages.get(camera_name, {}).get("usage"):
camera_usages[camera_name]["usage_percent"] = (
camera_usages.get(camera_name, {}).get("usage", 0) / total_mb
) * 100
return JSONResponse(content=camera_usages)
@router.get("/recordings/summary", dependencies=[Depends(allow_any_authenticated())])
async def all_recordings_summary(
request: Request,
params: MediaRecordingsSummaryQueryParams = Depends(),
allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter),
):
"""Returns true/false by day indicating if recordings exist"""
cameras = params.cameras
if cameras != "all":
requested = set(unquote(cameras).split(","))
filtered = requested.intersection(allowed_cameras)
if not filtered:
return JSONResponse(content={})
camera_list = list(filtered)
else:
camera_list = allowed_cameras
min_time: float | None = None
max_time: float | None = None
for camera in camera_list:
cam_min = (
Recordings.select(fn.MIN(Recordings.start_time))
.where(Recordings.camera == camera)
.scalar()
)
if cam_min is None:
continue
cam_max = (
Recordings.select(fn.MAX(Recordings.start_time))
.where(Recordings.camera == camera)
.scalar()
)
min_time = cam_min if min_time is None else min(min_time, cam_min)
max_time = cam_max if max_time is None else max(max_time, cam_max)
if min_time is None or max_time is None:
return JSONResponse(content={})
dst_periods = get_dst_transitions(params.timezone, min_time, max_time)
days: dict[str, bool] = {}
for period_start, period_end, period_offset in dst_periods:
first_start = max(min_time, period_start - MAX_SEGMENT_DURATION)
first_day = int((first_start + period_offset) // 86400)
last_day = int((min(max_time, period_end) + period_offset) // 86400)
day_idx = first_day
while day_idx <= last_day:
day_str = (dt.date(1970, 1, 1) + dt.timedelta(days=day_idx)).isoformat()
day_start = day_idx * 86400 - period_offset
day_end = day_start + 86400
if day_str in days:
day_idx += 1
continue
if day_end <= period_end:
upper = Recordings.start_time < day_end
else:
upper = Recordings.start_time <= period_end
has_recordings = (
Recordings.select(Recordings.id)
.where(
(Recordings.camera << camera_list)
& (Recordings.end_time >= period_start)
& (Recordings.start_time >= day_start)
& upper
)
.exists()
)
if has_recordings:
days[day_str] = True
day_idx += 1
continue
# empty day
next_start: float | None = None
for camera in camera_list:
cam_next = (
Recordings.select(fn.MIN(Recordings.start_time))
.where(
Recordings.camera == camera,
Recordings.start_time >= day_end,
Recordings.start_time <= period_end,
)
.scalar()
)
if cam_next is not None and (
next_start is None or cam_next < next_start
):
next_start = cam_next
if next_start is None:
break
day_idx = max(day_idx + 1, int((next_start + period_offset) // 86400))
return JSONResponse(content=dict(sorted(days.items())))
@router.get(
"/{camera_name}/recordings/summary", dependencies=[Depends(require_camera_access)]
)
async def recordings_summary(camera_name: str, timezone: str = "utc"):
"""Returns hourly summary for recordings of given camera"""
time_range_query = (
Recordings.select(
fn.MIN(Recordings.start_time).alias("min_time"),
fn.MAX(Recordings.start_time).alias("max_time"),
)
.where(Recordings.camera == camera_name)
.dicts()
.get()
)
min_time = time_range_query.get("min_time")
max_time = time_range_query.get("max_time")
days: dict[str, dict] = {}
if min_time is None or max_time is None:
return JSONResponse(content=list(days.values()))
dst_periods = get_dst_transitions(timezone, min_time, max_time)
for period_start, period_end, period_offset in dst_periods:
hours_offset = int(period_offset / 60 / 60)
minutes_offset = int(period_offset / 60 - hours_offset * 60)
period_hour_modifier = f"{hours_offset} hour"
period_minute_modifier = f"{minutes_offset} minute"
hour_expression = fn.strftime(
"%Y-%m-%d %H",
fn.datetime(
Recordings.start_time,
"unixepoch",
period_hour_modifier,
period_minute_modifier,
),
)
# sub rows duplicate the camera's motion/object stats, so
# aggregating them too would double-count
recording_groups = (
Recordings.select(
hour_expression.alias("hour"),
fn.SUM(Recordings.duration).alias("duration"),
fn.SUM(Recordings.motion).alias("motion"),
fn.SUM(Recordings.objects).alias("objects"),
)
.where(
(Recordings.camera == camera_name)
& (Recordings.stream_type == STREAM_TYPE_MAIN)
& (Recordings.end_time >= period_start)
& (Recordings.start_time <= period_end)
)
.group_by((Recordings.start_time + period_offset).cast("int") / 3600)
.order_by(Recordings.start_time.desc())
.namedtuples()
)
# sub recordings can outlive main, so hours covered only by sub
# rows are reported too, flagged as sub_only
sub_groups = (
Recordings.select(
hour_expression.alias("hour"),
fn.SUM(Recordings.duration).alias("duration"),
)
.where(
(Recordings.camera == camera_name)
& (Recordings.stream_type == STREAM_TYPE_SUB)
& (Recordings.end_time >= period_start)
& (Recordings.start_time <= period_end)
)
.group_by((Recordings.start_time + period_offset).cast("int") / 3600)
.namedtuples()
)
event_groups = (
Event.select(
fn.strftime(
"%Y-%m-%d %H",
fn.datetime(
Event.start_time,
"unixepoch",
period_hour_modifier,
period_minute_modifier,
),
).alias("hour"),
fn.COUNT(Event.id).alias("count"),
)
.where(Event.camera == camera_name, Event.has_clip)
.where(
(Event.start_time >= period_start) & (Event.start_time <= period_end)
)
.group_by((Event.start_time + period_offset).cast("int") / 3600)
.namedtuples()
)
event_map = {g.hour: g.count for g in event_groups}
hour_stats = [
(
g.hour,
{
"motion": g.motion,
"objects": g.objects,
"duration": round(g.duration),
},
)
for g in recording_groups
]
main_hours = {group_hour for group_hour, _ in hour_stats}
hour_stats.extend(
(
g.hour,
{
"motion": 0,
"objects": 0,
"duration": round(g.duration),
"sub_only": True,
},
)
for g in sub_groups
if g.hour not in main_hours
)
# restore the most-recent-first ordering after merging in sub hours
hour_stats.sort(key=lambda entry: entry[0], reverse=True)
for group_hour, stats in hour_stats:
parts = group_hour.split()
hour = parts[1]
day = parts[0]
events_count = event_map.get(group_hour, 0)
hour_data = {
"hour": hour,
"events": events_count,
**stats,
}
if day in days:
# merge counts if already present (edge-case at DST boundary)
days[day]["events"] += events_count or 0
days[day]["hours"].append(hour_data)
else:
days[day] = {
"events": events_count or 0,
"hours": [hour_data],
"day": day,
}
return JSONResponse(content=list(days.values()))
@router.get(
"/{camera_name}/recordings/coverage",
dependencies=[Depends(require_camera_access)],
)
async def recordings_coverage(
camera_name: str, after: float, before: float, timelines: bool = False
):
"""Returns merged recording coverage spans plus codec compatibility.
codecs_compatible is false only when more than one known video codec
appears across the range's rows, the case where the merged vod route
degrades to a single-stream manifest.
"""
intervals = resolve_coverage(camera_name, after, before)
content = {
"spans": coverage_spans(intervals),
"codecs_compatible": len(known_video_codecs(intervals)) <= 1,
"streams": stream_media_summary(intervals),
}
# pure computation (shared plan_clip, record-time keyframe index), but
# opt-in for payload hygiene: day-level requests need only the spans
if timelines:
content["timelines"] = realized_timelines(intervals)
return JSONResponse(content=content)
@router.get("/{camera_name}/recordings", dependencies=[Depends(require_camera_access)])
async def recordings(
camera_name: str,
after: float | None = None,
before: float | None = None,
):
"""Return specific camera recordings between the given 'after'/'end' times. If not provided the last hour will be used"""
now = datetime.now()
after = after if after is not None else (now - timedelta(hours=1)).timestamp()
before = before if before is not None else now.timestamp()
recordings = (
Recordings.select(
Recordings.id,
Recordings.start_time,
Recordings.end_time,
Recordings.segment_size,
Recordings.motion,
Recordings.objects,
Recordings.motion_heatmap,
Recordings.duration,
)
.where(
Recordings.camera == camera_name,
Recordings.stream_type == STREAM_TYPE_MAIN,
Recordings.start_time >= after - MAX_SEGMENT_DURATION,
Recordings.end_time >= after,
Recordings.start_time <= before,
)
.order_by(Recordings.start_time)
.dicts()
.iterator()
)
return JSONResponse(content=list(recordings))
@router.get(
"/recordings/unavailable",
response_model=list[dict],
dependencies=[Depends(allow_any_authenticated())],
)
async def no_recordings(
request: Request,
params: MediaRecordingsAvailabilityQueryParams = Depends(),
allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter),
):
"""Get time ranges with no recordings."""
cameras = params.cameras
if cameras != "all":
requested = set(unquote(cameras).split(","))
camera_list = list(requested.intersection(allowed_cameras))
else:
camera_list = list(allowed_cameras)
if not camera_list:
return JSONResponse(content=[])
before = params.before or datetime.now().timestamp()
after = params.after or (datetime.now() - timedelta(hours=1)).timestamp()
scale = params.scale
recordings: list[tuple[float, float]] = []
for camera in camera_list:
recordings.extend(
Recordings.select(Recordings.start_time, Recordings.end_time)
.where(
Recordings.camera == camera,
Recordings.start_time >= after - MAX_SEGMENT_DURATION,
Recordings.end_time >= after,
Recordings.start_time <= before,
)
.tuples()
.iterator()
)
# the merge pass below expects a single start-ordered timeline
recordings.sort()
# Merge overlapping/adjacent recordings into covered intervals. The query
# orders by start_time, so a single pass merges them
covered: list[tuple[float, float]] = []
for rec_start, rec_end in recordings:
if covered and rec_start <= covered[-1][1]:
covered[-1] = (covered[-1][0], max(covered[-1][1], rec_end))
else:
covered.append((rec_start, rec_end))
# Iterate through time segments and check if each has any recording
no_recording_segments = []
current = after
current_gap_start = None
idx = 0
covered_count = len(covered)
while current < before:
segment_end = min(current + scale, before)
# Advance past covered intervals that end before this segment begins;
# they cannot overlap this or any later segment.
while idx < covered_count and covered[idx][1] <= current:
idx += 1
# A covered interval overlaps the segment when it starts before the
# segment ends (its end is already known to be > current).
has_recording = idx < covered_count and covered[idx][0] < segment_end
if not has_recording:
# This segment has no recordings
if current_gap_start is None:
current_gap_start = current # Start a new gap
else:
# This segment has recordings
if current_gap_start is not None:
# End the current gap and append it
no_recording_segments.append(
{"start_time": int(current_gap_start), "end_time": int(current)}
)
current_gap_start = None
current = segment_end
# Append the last gap if it exists
if current_gap_start is not None:
no_recording_segments.append(
{"start_time": int(current_gap_start), "end_time": int(before)}
)
return JSONResponse(content=no_recording_segments)
@router.delete(
"/recordings/start/{start}/end/{end}",
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
summary="Delete recordings",
description="""Deletes recordings within the specified time range.
Recordings can be filtered by cameras and kept based on motion, objects, or audio attributes.
""",
)
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),
):
"""Delete recordings in the specified time range."""
if start >= end:
return JSONResponse(
content={
"success": False,
"message": "Start time must be less than end time.",
},
status_code=400,
)
cameras = params.cameras
if cameras != "all":
requested = set(cameras.split(","))
filtered = requested.intersection(allowed_cameras)
if not filtered:
return JSONResponse(
content={
"success": False,
"message": "No valid cameras found in the request.",
},
status_code=400,
)
camera_list = list(filtered)
else:
camera_list = allowed_cameras
# Parse keep parameter
keep_set = set()
if params.keep:
keep_set = set(params.keep.split(","))
# Build query to find overlapping recordings
clauses = [
(
Recordings.start_time.between(start, end)
| Recordings.end_time.between(start, end)
| ((start > Recordings.start_time) & (end < Recordings.end_time))
),
(Recordings.camera << camera_list),
]
keep_clauses = []
if "motion" in keep_set:
keep_clauses.append(Recordings.motion.is_null(False) & (Recordings.motion > 0))
if "object" in keep_set:
keep_clauses.append(
Recordings.objects.is_null(False) & (Recordings.objects > 0)
)
if "audio" in keep_set:
keep_clauses.append(Recordings.dBFS.is_null(False))
if keep_clauses:
keep_condition = reduce(operator.or_, keep_clauses)
clauses.append(~keep_condition)
recordings_to_delete = (
Recordings.select(Recordings.id, Recordings.path)
.where(reduce(operator.and_, clauses))
.dicts()
.iterator()
)
recording_ids = []
deleted_count = 0
error_count = 0
for recording in recordings_to_delete:
recording_ids.append(recording["id"])
try:
Path(recording["path"]).unlink(missing_ok=True)
deleted_count += 1
except Exception as e:
logger.error(f"Failed to delete recording file {recording['path']}: {e}")
error_count += 1
if recording_ids:
max_deletes = 100000
recording_ids_list = list(recording_ids)
for i in range(0, len(recording_ids_list), max_deletes):
Recordings.delete().where(
Recordings.id << recording_ids_list[i : i + max_deletes]
).execute()
message = f"Successfully deleted {deleted_count} recording(s)."
if error_count > 0:
message += f" {error_count} file deletion error(s) occurred."
return JSONResponse(
content={"success": True, "message": message},
status_code=200,
)