Fix recordings summary for DST (#20784)

* make recordings summary endpoints DST aware

* remove unused

* clean up
This commit is contained in:
Josh Hawkins 2025-11-03 18:30:56 -06:00 committed by GitHub
parent 85f7138361
commit 9e83888133
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 147 additions and 78 deletions

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,7 +424,6 @@ 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)
cameras = params.cameras cameras = params.cameras
if cameras != "all": if cameras != "all":
@ -432,41 +431,70 @@ def all_recordings_summary(
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 = ( time_range_query = (
Recordings.select( Recordings.select(
fn.strftime( fn.MIN(Recordings.start_time).alias("min_time"),
"%Y-%m-%d", fn.MAX(Recordings.start_time).alias("max_time"),
fn.datetime(
Recordings.start_time + seconds_offset,
"unixepoch",
hour_modifier,
minute_modifier,
),
).alias("day")
) )
.group_by( .where(Recordings.camera << camera_list)
fn.strftime( .dicts()
"%Y-%m-%d", .get()
fn.datetime(
Recordings.start_time + seconds_offset,
"unixepoch",
hour_modifier,
minute_modifier,
),
)
)
.order_by(Recordings.start_time.desc())
) )
if params.cameras != "all": min_time = time_range_query.get("min_time")
query = query.where(Recordings.camera << cameras.split(",")) max_time = time_range_query.get("max_time")
recording_days = query.namedtuples() if min_time is None or max_time is None:
days = {day.day: True for day in recording_days} 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:
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(
fn.strftime(
"%Y-%m-%d",
fn.datetime(
Recordings.start_time,
"unixepoch",
period_hour_modifier,
period_minute_modifier,
),
).alias("day")
)
.where(
(Recordings.camera << camera_list)
& (Recordings.end_time >= period_start)
& (Recordings.start_time <= period_end)
)
.group_by(
fn.strftime(
"%Y-%m-%d",
fn.datetime(
Recordings.start_time,
"unixepoch",
period_hour_modifier,
period_minute_modifier,
),
)
)
.order_by(Recordings.start_time.desc())
.namedtuples()
)
for g in period_query:
days[g.day] = True
return JSONResponse(content=days) return JSONResponse(content=days)
@ -476,61 +504,103 @@ 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)
recording_groups = ( time_range_query = (
Recordings.select( Recordings.select(
fn.strftime( fn.MIN(Recordings.start_time).alias("min_time"),
"%Y-%m-%d %H", fn.MAX(Recordings.start_time).alias("max_time"),
fn.datetime(
Recordings.start_time, "unixepoch", hour_modifier, minute_modifier
),
).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) .where(Recordings.camera == camera_name)
.group_by((Recordings.start_time + seconds_offset).cast("int") / 3600) .dicts()
.order_by(Recordings.start_time.desc()) .get()
.namedtuples()
) )
event_groups = ( min_time = time_range_query.get("min_time")
Event.select( max_time = time_range_query.get("max_time")
fn.strftime(
"%Y-%m-%d %H", days: dict[str, dict] = {}
fn.datetime(
Event.start_time, "unixepoch", hour_modifier, minute_modifier if min_time is None or max_time is None:
), return JSONResponse(content=list(days.values()))
).alias("hour"),
fn.COUNT(Event.id).alias("count"), 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 = (
Recordings.select(
fn.strftime(
"%Y-%m-%d %H",
fn.datetime(
Recordings.start_time,
"unixepoch",
period_hour_modifier,
period_minute_modifier,
),
).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.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()
) )
.where(Event.camera == camera_name, Event.has_clip)
.group_by((Event.start_time + seconds_offset).cast("int") / 3600)
.namedtuples()
)
event_map = {g.hour: g.count for g in event_groups} 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()
)
days = {} event_map = {g.hour: g.count for g in event_groups}
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]
day = parts[0] day = parts[0]
events_count = event_map.get(recording_group.hour, 0) events_count = event_map.get(recording_group.hour, 0)
hour_data = { hour_data = {
"hour": hour, "hour": hour,
"events": events_count, "events": events_count,
"motion": recording_group.motion, "motion": recording_group.motion,
"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()))

View File

@ -36,7 +36,7 @@ from frigate.config import FrigateConfig
from frigate.embeddings import EmbeddingsContext from frigate.embeddings import EmbeddingsContext
from frigate.models import Recordings, ReviewSegment, UserReviewStatus from frigate.models import Recordings, ReviewSegment, UserReviewStatus
from frigate.review.types import SeverityEnum from frigate.review.types import SeverityEnum
from frigate.util.time import get_dst_transitions, get_tz_modifiers from frigate.util.time import get_dst_transitions
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -197,7 +197,6 @@ async def review_summary(
user_id = current_user["username"] user_id = current_user["username"]
hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(params.timezone)
day_ago = (datetime.datetime.now() - datetime.timedelta(hours=24)).timestamp() day_ago = (datetime.datetime.now() - datetime.timedelta(hours=24)).timestamp()
cameras = params.cameras cameras = params.cameras