make recordings summary endpoints DST aware

This commit is contained in:
Josh Hawkins 2025-11-03 17:32:41 -06:00
parent 85f7138361
commit 07926171e2

View File

@ -46,7 +46,7 @@ from frigate.models import Event, Previews, Recordings, Regions, ReviewSegment
from frigate.track.object_processing import TrackedObjectProcessor from frigate.track.object_processing import TrackedObjectProcessor
from frigate.util.image import get_image_from_recording from frigate.util.image import get_image_from_recording
from frigate.util.path import get_event_thumbnail_bytes from frigate.util.path import get_event_thumbnail_bytes
from frigate.util.time import get_tz_modifiers from frigate.util.time import get_dst_transitions
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -424,49 +424,81 @@ def all_recordings_summary(
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""" """Returns true/false by day indicating if recordings exist"""
hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(params.timezone) # Determine the set of cameras to query
cameras = params.cameras cameras = params.cameras
if cameras != "all": if cameras != "all":
requested = set(unquote(cameras).split(",")) requested = set(unquote(cameras).split(","))
filtered = requested.intersection(allowed_cameras) filtered = requested.intersection(allowed_cameras)
if not filtered: if not filtered:
return JSONResponse(content={}) return JSONResponse(content={})
cameras = ",".join(filtered) camera_list = list(filtered)
else: else:
cameras = allowed_cameras camera_list = allowed_cameras
query = ( # Find overall time range for the selected cameras
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_list)
.dicts()
.get()
)
min_time = time_range_query.get("min_time")
max_time = time_range_query.get("max_time")
# No recordings available
if min_time is None or max_time is None:
return JSONResponse(content={})
# Split time range into DST-consistent periods
dst_periods = get_dst_transitions(params.timezone, min_time, max_time)
days: dict[str, bool] = {}
# Query each DST period separately and merge day results
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"
period_query = (
Recordings.select( Recordings.select(
fn.strftime( fn.strftime(
"%Y-%m-%d", "%Y-%m-%d",
fn.datetime( fn.datetime(
Recordings.start_time + seconds_offset, Recordings.start_time,
"unixepoch", "unixepoch",
hour_modifier, period_hour_modifier,
minute_modifier, period_minute_modifier,
), ),
).alias("day") ).alias("day")
) )
.where(
(Recordings.camera << camera_list)
& (Recordings.end_time >= period_start)
& (Recordings.start_time <= period_end)
)
.group_by( .group_by(
fn.strftime( fn.strftime(
"%Y-%m-%d", "%Y-%m-%d",
fn.datetime( fn.datetime(
Recordings.start_time + seconds_offset, Recordings.start_time,
"unixepoch", "unixepoch",
hour_modifier, period_hour_modifier,
minute_modifier, period_minute_modifier,
), ),
) )
) )
.order_by(Recordings.start_time.desc()) .order_by(Recordings.start_time.desc())
.namedtuples()
) )
if params.cameras != "all": for g in period_query:
query = query.where(Recordings.camera << cameras.split(",")) days[g.day] = True
recording_days = query.namedtuples()
days = {day.day: True for day in recording_days}
return JSONResponse(content=days) return JSONResponse(content=days)
@ -476,21 +508,54 @@ def all_recordings_summary(
) )
async def recordings_summary(camera_name: str, timezone: str = "utc"): async def recordings_summary(camera_name: str, timezone: str = "utc"):
"""Returns hourly summary for recordings of given camera""" """Returns hourly summary for recordings of given camera"""
hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(timezone)
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"
recording_groups = ( recording_groups = (
Recordings.select( Recordings.select(
fn.strftime( fn.strftime(
"%Y-%m-%d %H", "%Y-%m-%d %H",
fn.datetime( fn.datetime(
Recordings.start_time, "unixepoch", hour_modifier, minute_modifier Recordings.start_time,
"unixepoch",
period_hour_modifier,
period_minute_modifier,
), ),
).alias("hour"), ).alias("hour"),
fn.SUM(Recordings.duration).alias("duration"), fn.SUM(Recordings.duration).alias("duration"),
fn.SUM(Recordings.motion).alias("motion"), fn.SUM(Recordings.motion).alias("motion"),
fn.SUM(Recordings.objects).alias("objects"), fn.SUM(Recordings.objects).alias("objects"),
) )
.where(Recordings.camera == camera_name) .where(
.group_by((Recordings.start_time + seconds_offset).cast("int") / 3600) (Recordings.camera == camera_name)
& (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()) .order_by(Recordings.start_time.desc())
.namedtuples() .namedtuples()
) )
@ -500,20 +565,24 @@ async def recordings_summary(camera_name: str, timezone: str = "utc"):
fn.strftime( fn.strftime(
"%Y-%m-%d %H", "%Y-%m-%d %H",
fn.datetime( fn.datetime(
Event.start_time, "unixepoch", hour_modifier, minute_modifier Event.start_time,
"unixepoch",
period_hour_modifier,
period_minute_modifier,
), ),
).alias("hour"), ).alias("hour"),
fn.COUNT(Event.id).alias("count"), fn.COUNT(Event.id).alias("count"),
) )
.where(Event.camera == camera_name, Event.has_clip) .where(Event.camera == camera_name, Event.has_clip)
.group_by((Event.start_time + seconds_offset).cast("int") / 3600) .where(
(Event.start_time >= period_start) & (Event.start_time <= period_end)
)
.group_by((Event.start_time + period_offset).cast("int") / 3600)
.namedtuples() .namedtuples()
) )
event_map = {g.hour: g.count for g in event_groups} event_map = {g.hour: g.count for g in event_groups}
days = {}
for recording_group in recording_groups: for recording_group in recording_groups:
parts = recording_group.hour.split() parts = recording_group.hour.split()
hour = parts[1] hour = parts[1]
@ -526,11 +595,16 @@ async def recordings_summary(camera_name: str, timezone: str = "utc"):
"objects": recording_group.objects, "objects": recording_group.objects,
"duration": round(recording_group.duration), "duration": round(recording_group.duration),
} }
if day not in days: if day in days:
days[day] = {"events": events_count, "hours": [hour_data], "day": day} # merge counts if already present (edge-case at DST boundary)
else: days[day]["events"] += events_count or 0
days[day]["events"] += events_count
days[day]["hours"].append(hour_data) 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())) return JSONResponse(content=list(days.values()))