2023-04-26 07:25:26 -06:00
"""Maintain recording segments in cache."""
2023-06-16 07:09:13 -06:00
import asyncio
2020-11-29 21:31:02 -06:00
import datetime
import logging
import os
2021-06-06 21:24:36 -04:00
import random
import string
2020-11-29 21:31:02 -06:00
import threading
2024-03-23 13:45:15 -06:00
import time
2023-04-26 07:25:26 -06:00
from collections import defaultdict
from multiprocessing.synchronize import Event as MpEvent
from pathlib import Path
2023-07-14 18:05:14 -06:00
from typing import Any , Optional , Tuple
2021-07-09 16:14:16 -04:00
2023-07-14 18:05:14 -06:00
import numpy as np
2023-05-29 12:31:17 +02:00
import psutil
2024-02-19 06:26:59 -07:00
from frigate.comms.detections_updater import DetectionSubscriber , DetectionTypeEnum
2024-02-14 17:24:36 -07:00
from frigate.comms.inter_process import InterProcessRequestor
2025-02-21 07:51:37 -06:00
from frigate.comms.recordings_updater import (
RecordingsDataPublisher ,
RecordingsDataTypeEnum ,
)
2023-05-29 12:31:17 +02:00
from frigate.config import FrigateConfig , RetainModeEnum
2025-05-22 12:16:51 -06:00
from frigate.config.camera.updater import (
CameraConfigUpdateEnum ,
CameraConfigUpdateSubscriber ,
)
2023-07-26 04:55:08 -06:00
from frigate.const import (
CACHE_DIR ,
2023-11-05 13:30:29 -07:00
CACHE_SEGMENT_FORMAT ,
2025-04-30 08:42:53 -05:00
FAST_QUEUE_TIMEOUT ,
2023-07-26 04:55:08 -06:00
INSERT_MANY_RECORDINGS ,
MAX_SEGMENT_DURATION ,
2023-11-18 14:37:06 -07:00
MAX_SEGMENTS_IN_CACHE ,
2023-07-26 04:55:08 -06:00
RECORD_DIR ,
)
2024-09-02 07:22:53 -06:00
from frigate.models import Recordings , ReviewSegment
2024-12-19 09:46:14 -06:00
from frigate.review.types import SeverityEnum
2023-07-06 08:28:50 -06:00
from frigate.util.services import get_video_properties
2020-11-29 21:31:02 -06:00
logger = logging . getLogger ( __name__ )
2020-11-30 21:08:47 -06:00
2023-07-16 12:07:15 -06:00
class SegmentInfo :
def __init__ (
2024-03-15 09:29:22 -06:00
self ,
2024-03-15 13:13:40 -06:00
motion_count : int ,
2024-03-15 09:29:22 -06:00
active_object_count : int ,
region_count : int ,
average_dBFS : int ,
2026-03-05 17:53:48 -06:00
motion_heatmap : dict [ str , int ] | None = None ,
2023-07-16 12:07:15 -06:00
) -> None :
2024-03-15 13:13:40 -06:00
self . motion_count = motion_count
2023-07-16 12:07:15 -06:00
self . active_object_count = active_object_count
2024-03-15 09:29:22 -06:00
self . region_count = region_count
2023-07-16 12:07:15 -06:00
self . average_dBFS = average_dBFS
2026-03-05 17:53:48 -06:00
self . motion_heatmap = motion_heatmap
2023-07-16 12:07:15 -06:00
def should_discard_segment ( self , retain_mode : RetainModeEnum ) -> bool :
2025-10-15 11:09:28 -06:00
keep = False
# all mode should never discard
if retain_mode == RetainModeEnum . all :
keep = True
# motion mode should keep if motion or audio is detected
if (
not keep
and retain_mode == RetainModeEnum . motion
2025-12-15 08:32:11 -07:00
and ( self . motion_count > 0 or self . average_dBFS != 0 )
2025-10-15 11:09:28 -06:00
):
keep = True
# active objects mode should keep if any active objects are detected
if not keep and self . active_object_count > 0 :
keep = True
return not keep
2023-07-16 12:07:15 -06:00
2020-11-29 21:31:02 -06:00
class RecordingMaintainer ( threading . Thread ):
2024-02-19 06:26:59 -07:00
def __init__ ( self , config : FrigateConfig , stop_event : MpEvent ):
2024-10-03 04:35:46 +03:00
super () . __init__ ( name = "recording_maintainer" )
2020-11-29 21:31:02 -06:00
self . config = config
2024-02-14 17:24:36 -07:00
# create communication for retained recordings
self . requestor = InterProcessRequestor ()
2025-05-22 12:16:51 -06:00
self . config_subscriber = CameraConfigUpdateSubscriber (
2025-06-11 11:25:30 -06:00
self . config ,
self . config . cameras ,
[ CameraConfigUpdateEnum . add , CameraConfigUpdateEnum . record ],
2025-05-22 12:16:51 -06:00
)
2025-08-08 06:08:37 -06:00
self . detection_subscriber = DetectionSubscriber ( DetectionTypeEnum . all . value )
2025-09-28 11:52:14 -05:00
self . recordings_publisher = RecordingsDataPublisher ()
2024-02-14 17:24:36 -07:00
2020-11-29 21:31:02 -06:00
self . stop_event = stop_event
2023-07-14 18:05:14 -06:00
self . object_recordings_info : dict [ str , list ] = defaultdict ( list )
self . audio_recordings_info : dict [ str , list ] = defaultdict ( list )
2023-04-26 07:25:26 -06:00
self . end_time_cache : dict [ str , Tuple [ datetime . datetime , float ]] = {}
2026-01-16 20:23:23 -06:00
self . unexpected_cache_files_logged : bool = False
2020-11-29 21:31:02 -06:00
2023-06-16 07:09:13 -06:00
async def move_files ( self ) -> None :
2023-11-05 13:30:29 -07:00
cache_files = [
d
for d in os . listdir ( CACHE_DIR )
if os . path . isfile ( os . path . join ( CACHE_DIR , d ))
and d . endswith ( ".mp4" )
2024-06-09 13:45:26 -05:00
and not d . startswith ( "preview_" )
2023-11-05 13:30:29 -07:00
]
2020-11-29 21:31:02 -06:00
2025-09-28 11:52:14 -05:00
# publish newest cached segment per camera (including in use files)
newest_cache_segments : dict [ str , dict [ str , Any ]] = {}
for cache in cache_files :
cache_path = os . path . join ( CACHE_DIR , cache )
basename = os . path . splitext ( cache )[ 0 ]
2026-01-16 20:23:23 -06:00
try :
camera , date = basename . rsplit ( "@" , maxsplit = 1 )
except ValueError :
if not self . unexpected_cache_files_logged :
logger . warning ( "Skipping unexpected files in cache" )
self . unexpected_cache_files_logged = True
continue
2025-09-28 11:52:14 -05:00
start_time = datetime . datetime . strptime (
date , CACHE_SEGMENT_FORMAT
) . astimezone ( datetime . timezone . utc )
if (
camera not in newest_cache_segments
or start_time > newest_cache_segments [ camera ][ "start_time" ]
):
newest_cache_segments [ camera ] = {
"start_time" : start_time ,
"cache_path" : cache_path ,
}
for camera , newest in newest_cache_segments . items ():
self . recordings_publisher . publish (
(
camera ,
newest [ "start_time" ] . timestamp (),
newest [ "cache_path" ],
),
RecordingsDataTypeEnum . latest . value ,
)
# publish None for cameras with no cache files (but only if we know the camera exists)
for camera_name in self . config . cameras :
if camera_name not in newest_cache_segments :
self . recordings_publisher . publish (
( camera_name , None , None ),
RecordingsDataTypeEnum . latest . value ,
)
2020-11-29 21:31:02 -06:00
files_in_use = []
for process in psutil . process_iter ():
try :
2021-02-17 07:23:32 -06:00
if process . name () != "ffmpeg" :
2020-12-24 14:23:59 -06:00
continue
2024-09-17 18:41:46 +03:00
file_list = process . open_files ()
if file_list :
for nt in file_list :
2021-07-09 16:14:16 -04:00
if nt . path . startswith ( CACHE_DIR ):
2021-02-17 07:23:32 -06:00
files_in_use . append ( nt . path . split ( "/" )[ - 1 ])
2023-05-29 12:31:17 +02:00
except psutil . Error :
2020-11-29 21:31:02 -06:00
continue
2025-09-28 11:52:14 -05:00
# group recordings by camera (skip in-use for validation/moving)
2023-04-26 07:25:26 -06:00
grouped_recordings : defaultdict [ str , list [ dict [ str , Any ]]] = defaultdict ( list )
for cache in cache_files :
2021-07-11 15:34:48 -04:00
# Skip files currently in use
2023-04-26 07:25:26 -06:00
if cache in files_in_use :
2020-11-29 21:31:02 -06:00
continue
2023-04-26 07:25:26 -06:00
cache_path = os . path . join ( CACHE_DIR , cache )
basename = os . path . splitext ( cache )[ 0 ]
2026-01-16 20:23:23 -06:00
try :
camera , date = basename . rsplit ( "@" , maxsplit = 1 )
except ValueError :
if not self . unexpected_cache_files_logged :
logger . warning ( "Skipping unexpected files in cache" )
self . unexpected_cache_files_logged = True
continue
2023-11-05 13:30:29 -07:00
# important that start_time is utc because recordings are stored and compared in utc
start_time = datetime . datetime . strptime (
date , CACHE_SEGMENT_FORMAT
) . astimezone ( datetime . timezone . utc )
2021-02-17 07:23:32 -06:00
2021-10-23 16:18:13 -05:00
grouped_recordings [ camera ] . append (
{
"cache_path" : cache_path ,
"start_time" : start_time ,
}
2021-02-17 07:23:32 -06:00
)
2020-11-29 21:31:02 -06:00
2023-11-18 14:37:06 -07:00
# delete all cached files past the most recent MAX_SEGMENTS_IN_CACHE
keep_count = MAX_SEGMENTS_IN_CACHE
2021-11-17 08:57:57 -06:00
for camera in grouped_recordings . keys ():
2023-11-05 13:30:29 -07:00
# sort based on start time
grouped_recordings [ camera ] = sorted (
grouped_recordings [ camera ], key = lambda s : s [ "start_time" ]
)
2024-10-02 11:55:55 -06:00
camera_info = self . object_recordings_info [ camera ]
most_recently_processed_frame_time = (
camera_info [ - 1 ][ 0 ] if len ( camera_info ) > 0 else 0
)
processed_segment_count = len (
list (
filter (
2026-02-03 13:29:52 -06:00
lambda r : (
r [ "start_time" ] . timestamp ()
< most_recently_processed_frame_time
),
2024-10-02 11:55:55 -06:00
grouped_recordings [ camera ],
)
)
)
2024-10-14 16:11:43 -06:00
# see if the recording mover is too slow and segments need to be deleted
2024-10-02 11:55:55 -06:00
if processed_segment_count > keep_count :
2023-01-30 17:42:53 -06:00
logger . warning (
2024-10-02 11:55:55 -06:00
f "Unable to keep up with recording segments in cache for { camera } . Keeping the { keep_count } most recent segments out of { processed_segment_count } and discarding the rest..."
2023-01-30 17:42:53 -06:00
)
2021-12-10 22:56:29 -06:00
to_remove = grouped_recordings [ camera ][: - keep_count ]
2023-04-26 07:25:26 -06:00
for rec in to_remove :
cache_path = rec [ "cache_path" ]
2022-07-19 13:24:44 +01:00
Path ( cache_path ) . unlink ( missing_ok = True )
self . end_time_cache . pop ( cache_path , None )
2021-12-10 22:56:29 -06:00
grouped_recordings [ camera ] = grouped_recordings [ camera ][ - keep_count :]
2021-11-10 21:12:41 -06:00
2024-10-14 16:11:43 -06:00
# see if detection has failed and unprocessed segments need to be deleted
unprocessed_segment_count = (
len ( grouped_recordings [ camera ]) - processed_segment_count
)
if unprocessed_segment_count > keep_count :
logger . warning (
f "Too many unprocessed recording segments in cache for { camera } . This likely indicates an issue with the detect stream, keeping the { keep_count } most recent segments out of { unprocessed_segment_count } and discarding the rest..."
)
to_remove = grouped_recordings [ camera ][: - keep_count ]
for rec in to_remove :
cache_path = rec [ "cache_path" ]
Path ( cache_path ) . unlink ( missing_ok = True )
self . end_time_cache . pop ( cache_path , None )
grouped_recordings [ camera ] = grouped_recordings [ camera ][ - keep_count :]
2023-07-21 06:29:50 -06:00
tasks = []
2021-10-23 16:18:13 -05:00
for camera , recordings in grouped_recordings . items ():
2023-07-14 18:05:14 -06:00
# clear out all the object recording info for old frames
2021-12-10 22:56:29 -06:00
while (
2023-07-14 18:05:14 -06:00
len ( self . object_recordings_info [ camera ]) > 0
and self . object_recordings_info [ camera ][ 0 ][ 0 ]
2021-12-10 22:56:29 -06:00
< recordings [ 0 ][ "start_time" ] . timestamp ()
):
2023-07-14 18:05:14 -06:00
self . object_recordings_info [ camera ] . pop ( 0 )
# clear out all the audio recording info for old frames
while (
len ( self . audio_recordings_info [ camera ]) > 0
and self . audio_recordings_info [ camera ][ 0 ][ 0 ]
< recordings [ 0 ][ "start_time" ] . timestamp ()
):
self . audio_recordings_info [ camera ] . pop ( 0 )
2021-12-10 22:56:29 -06:00
2024-09-02 07:22:53 -06:00
# get all reviews with the end time after the start of the oldest cache file
2021-10-23 16:18:13 -05:00
# or with end_time None
2026-03-25 18:30:59 -06:00
reviews = (
2024-09-02 07:22:53 -06:00
ReviewSegment . select (
ReviewSegment . start_time ,
ReviewSegment . end_time ,
2024-12-19 09:46:14 -06:00
ReviewSegment . severity ,
2024-09-02 07:22:53 -06:00
ReviewSegment . data ,
2023-09-11 16:07:04 -06:00
)
2021-10-23 16:18:13 -05:00
. where (
2024-09-02 07:22:53 -06:00
ReviewSegment . camera == camera ,
( ReviewSegment . end_time == None )
| (
ReviewSegment . end_time
>= recordings [ 0 ][ "start_time" ] . timestamp ()
),
2021-10-23 16:18:13 -05:00
)
2024-09-02 07:22:53 -06:00
. order_by ( ReviewSegment . start_time )
2021-06-06 21:24:36 -04:00
)
2021-10-23 16:18:13 -05:00
2023-07-21 06:29:50 -06:00
tasks . extend (
2024-09-02 07:22:53 -06:00
[ self . validate_and_move_segment ( camera , reviews , r ) for r in recordings ]
2023-06-16 07:09:13 -06:00
)
2025-02-21 07:51:37 -06:00
# publish most recently available recording time and None if disabled
2026-03-04 10:07:34 -06:00
camera_cfg = self . config . cameras . get ( camera )
2025-02-21 07:51:37 -06:00
self . recordings_publisher . publish (
(
camera ,
recordings [ 0 ][ "start_time" ] . timestamp ()
2026-03-04 10:07:34 -06:00
if camera_cfg and camera_cfg . record . enabled
2025-02-21 07:51:37 -06:00
else None ,
2025-09-28 11:52:14 -05:00
None ,
),
RecordingsDataTypeEnum . saved . value ,
2025-02-21 07:51:37 -06:00
)
2026-03-25 18:30:59 -06:00
recordings_to_insert : list [ Optional [ dict [ str , Any ]]] = await asyncio . gather (
* tasks
)
2023-07-26 04:55:08 -06:00
# fire and forget recordings entries
2024-02-14 17:24:36 -07:00
self . requestor . send_data (
INSERT_MANY_RECORDINGS ,
[ r for r in recordings_to_insert if r is not None ],
2023-07-26 04:55:08 -06:00
)
2023-07-21 06:29:50 -06:00
2024-12-19 09:46:14 -06:00
def drop_segment ( self , cache_path : str ) -> None :
Path ( cache_path ) . unlink ( missing_ok = True )
self . end_time_cache . pop ( cache_path , None )
2023-06-16 07:09:13 -06:00
async def validate_and_move_segment (
2026-03-25 18:30:59 -06:00
self , camera : str , reviews : Any , recording : dict [ str , Any ]
) -> Optional [ dict [ str , Any ]]:
2024-12-19 09:46:14 -06:00
cache_path : str = recording [ "cache_path" ]
start_time : datetime . datetime = recording [ "start_time" ]
2023-06-16 07:09:13 -06:00
2026-03-04 10:07:34 -06:00
# Just delete files if camera removed or recordings are turned off
2023-06-16 07:09:13 -06:00
if (
camera not in self . config . cameras
2024-02-19 06:26:59 -07:00
or not self . config . cameras [ camera ] . record . enabled
2023-06-16 07:09:13 -06:00
):
2024-12-19 09:46:14 -06:00
self . drop_segment ( cache_path )
2025-09-28 11:52:14 -05:00
return None
2023-06-16 07:09:13 -06:00
if cache_path in self . end_time_cache :
end_time , duration = self . end_time_cache [ cache_path ]
else :
2024-09-13 14:14:51 -06:00
segment_info = await get_video_properties (
self . config . ffmpeg , cache_path , get_duration = True
)
2023-06-16 07:09:13 -06:00
2025-09-28 11:52:14 -05:00
if not segment_info . get ( "has_valid_video" , False ):
logger . warning (
f "Invalid or missing video stream in segment { cache_path } . Discarding."
)
self . recordings_publisher . publish (
( camera , start_time . timestamp (), cache_path ),
RecordingsDataTypeEnum . invalid . value ,
)
self . drop_segment ( cache_path )
return None
duration = float ( segment_info . get ( "duration" , - 1 ))
2023-06-16 07:09:13 -06:00
# ensure duration is within expected length
if 0 < duration < MAX_SEGMENT_DURATION :
end_time = start_time + datetime . timedelta ( seconds = duration )
self . end_time_cache [ cache_path ] = ( end_time , duration )
else :
if duration == - 1 :
logger . warning ( f "Failed to probe corrupt segment { cache_path } " )
logger . warning ( f "Discarding a corrupt recording segment: { cache_path } " )
2025-09-28 11:52:14 -05:00
self . recordings_publisher . publish (
( camera , start_time . timestamp (), cache_path ),
RecordingsDataTypeEnum . invalid . value ,
)
self . drop_segment ( cache_path )
return None
# this segment has a valid duration and has video data, so publish an update
self . recordings_publisher . publish (
( camera , start_time . timestamp (), cache_path ),
RecordingsDataTypeEnum . valid . value ,
)
2023-06-16 07:09:13 -06:00
2025-05-30 17:01:39 -06:00
record_config = self . config . cameras [ camera ] . record
2026-04-15 08:32:26 -06:00
segment_info : SegmentInfo | None = None
2025-05-30 17:01:39 -06:00
highest = None
if record_config . continuous . days > 0 :
highest = "continuous"
elif record_config . motion . days > 0 :
highest = "motion"
2025-10-15 11:09:28 -06:00
# if we have continuous or motion recording enabled
# we should first just check if this segment matches that
# and avoid any DB calls
if highest is not None :
2023-10-25 19:23:15 -05:00
# assume that empty means the relevant recording info has not been received yet
camera_info = self . object_recordings_info [ camera ]
most_recently_processed_frame_time = (
camera_info [ - 1 ][ 0 ] if len ( camera_info ) > 0 else 0
)
2023-10-25 14:06:57 -05:00
# ensure delayed segment info does not lead to lost segments
2023-11-05 13:30:29 -07:00
if (
datetime . datetime . fromtimestamp (
most_recently_processed_frame_time
) . astimezone ( datetime . timezone . utc )
>= end_time
2023-10-30 18:25:21 -06:00
):
2025-05-30 17:01:39 -06:00
record_mode = (
RetainModeEnum . all
if highest == "continuous"
else RetainModeEnum . motion
)
2026-04-15 08:32:26 -06:00
segment_info = self . segment_stats ( camera , start_time , end_time )
# Here we only check if we should move the segment based on non-object recording retention
# we will always want to check for overlapping review items below before dropping the segment
if not segment_info . should_discard_segment ( record_mode ):
return await self . move_segment (
camera ,
start_time ,
end_time ,
duration ,
cache_path ,
segment_info ,
)
2021-10-23 16:18:13 -05:00
2025-10-15 11:09:28 -06:00
# we fell through the continuous / motion check, so we need to check the review items
# if the cached segment overlaps with the review items:
overlaps = False
for review in reviews :
severity = SeverityEnum [ review . severity ]
# if the review item starts in the future, stop checking review items
# and remove this segment
if (
review . start_time - record_config . get_review_pre_capture ( severity )
) > end_time . timestamp ():
overlaps = False
break
# if the review item is in progress or ends after the recording starts, keep it
# and stop looking at review items
if (
review . end_time is None
or ( review . end_time + record_config . get_review_post_capture ( severity ))
>= start_time . timestamp ()
):
overlaps = True
break
if overlaps :
record_mode = (
record_config . alerts . retain . mode
if review . severity == "alert"
else record_config . detections . retain . mode
)
2026-04-15 08:32:26 -06:00
if segment_info is None :
segment_info = self . segment_stats ( camera , start_time , end_time )
if not segment_info . should_discard_segment ( record_mode ):
# move from cache to recordings immediately
return await self . move_segment (
camera ,
start_time ,
end_time ,
duration ,
cache_path ,
segment_info ,
)
else :
self . drop_segment ( cache_path )
return None
2025-10-15 11:09:28 -06:00
# if it doesn't overlap with an review item, go ahead and drop the segment
# if it ends more than the configured pre_capture for the camera
# BUT only if continuous/motion is NOT enabled (otherwise wait for processing)
elif highest is None :
camera_info = self . object_recordings_info [ camera ]
most_recently_processed_frame_time = (
camera_info [ - 1 ][ 0 ] if len ( camera_info ) > 0 else 0
)
retain_cutoff = datetime . datetime . fromtimestamp (
most_recently_processed_frame_time - record_config . event_pre_capture
) . astimezone ( datetime . timezone . utc )
2026-04-15 08:32:26 -06:00
2025-10-15 11:09:28 -06:00
if end_time < retain_cutoff :
self . drop_segment ( cache_path )
2026-03-25 18:30:59 -06:00
return None
2026-03-05 17:53:48 -06:00
def _compute_motion_heatmap (
self , camera : str , motion_boxes : list [ tuple [ int , int , int , int ]]
) -> dict [ str , int ] | None :
"""Compute a 16x16 motion intensity heatmap from motion boxes.
Returns a sparse dict mapping cell index (as string) to intensity (1-255).
Only cells with motion are included.
Args:
camera: Camera name to get detect dimensions from.
motion_boxes: List of (x1, y1, x2, y2) pixel coordinates.
Returns:
Sparse dict like {"45": 3, "46": 5}, or None if no boxes.
"""
if not motion_boxes :
return None
camera_config = self . config . cameras . get ( camera )
if not camera_config :
return None
frame_width = camera_config . detect . width
frame_height = camera_config . detect . height
2026-03-25 18:30:59 -06:00
if not frame_width or frame_width <= 0 or not frame_height or frame_height <= 0 :
2026-03-05 17:53:48 -06:00
return None
GRID_SIZE = 16
counts : dict [ int , int ] = {}
for box in motion_boxes :
if len ( box ) < 4 :
continue
x1 , y1 , x2 , y2 = box
# Convert pixel coordinates to grid cells
grid_x1 = max ( 0 , int (( x1 / frame_width ) * GRID_SIZE ))
grid_y1 = max ( 0 , int (( y1 / frame_height ) * GRID_SIZE ))
grid_x2 = min ( GRID_SIZE - 1 , int (( x2 / frame_width ) * GRID_SIZE ))
grid_y2 = min ( GRID_SIZE - 1 , int (( y2 / frame_height ) * GRID_SIZE ))
for y in range ( grid_y1 , grid_y2 + 1 ):
for x in range ( grid_x1 , grid_x2 + 1 ):
idx = y * GRID_SIZE + x
counts [ idx ] = min ( 255 , counts . get ( idx , 0 ) + 1 )
if not counts :
return None
# Convert to string keys for JSON storage
return { str ( k ): v for k , v in counts . items ()}
2023-04-26 07:25:26 -06:00
def segment_stats (
self , camera : str , start_time : datetime . datetime , end_time : datetime . datetime
2023-07-16 12:07:15 -06:00
) -> SegmentInfo :
2024-03-14 13:57:14 -06:00
video_frame_count = 0
2021-12-11 13:11:39 -06:00
active_count = 0
2024-03-15 09:29:22 -06:00
region_count = 0
2024-03-15 13:13:40 -06:00
motion_count = 0
2026-03-05 17:53:48 -06:00
all_motion_boxes : list [ tuple [ int , int , int , int ]] = []
2023-07-14 18:05:14 -06:00
for frame in self . object_recordings_info [ camera ]:
2021-12-11 13:11:39 -06:00
# frame is after end time of segment
if frame [ 0 ] > end_time . timestamp ():
break
# frame is before start time of segment
if frame [ 0 ] < start_time . timestamp ():
continue
2024-03-14 13:57:14 -06:00
video_frame_count += 1
2021-12-11 13:11:39 -06:00
active_count += len (
[
o
for o in frame [ 1 ]
2022-02-06 09:56:06 -06:00
if not o [ "false_positive" ] and o [ "motionless_count" ] == 0
2021-12-11 13:11:39 -06:00
]
)
2024-03-15 13:13:40 -06:00
motion_count += len ( frame [ 2 ])
2024-03-15 09:29:22 -06:00
region_count += len ( frame [ 3 ])
2026-03-05 17:53:48 -06:00
# Collect motion boxes for heatmap computation
all_motion_boxes . extend ( frame [ 2 ])
2024-03-14 13:57:14 -06:00
2023-07-14 18:05:14 -06:00
audio_values = []
for frame in self . audio_recordings_info [ camera ]:
# frame is after end time of segment
if frame [ 0 ] > end_time . timestamp ():
break
# frame is before start time of segment
if frame [ 0 ] < start_time . timestamp ():
continue
2023-10-24 17:26:46 -06:00
# add active audio label count to count of active objects
active_count += len ( frame [ 2 ])
# add sound level to audio values
2023-07-14 18:05:14 -06:00
audio_values . append ( frame [ 1 ])
average_dBFS = 0 if not audio_values else np . average ( audio_values )
2026-03-05 17:53:48 -06:00
motion_heatmap = self . _compute_motion_heatmap ( camera , all_motion_boxes )
2024-03-15 09:29:22 -06:00
return SegmentInfo (
2026-03-05 17:53:48 -06:00
motion_count ,
active_count ,
region_count ,
round ( average_dBFS ),
motion_heatmap ,
2024-03-15 09:29:22 -06:00
)
2021-12-11 13:11:39 -06:00
2023-07-26 04:55:08 -06:00
async def move_segment (
2021-12-11 13:11:39 -06:00
self ,
2023-04-26 07:25:26 -06:00
camera : str ,
2022-12-11 06:45:32 -07:00
start_time : datetime . datetime ,
end_time : datetime . datetime ,
2023-04-26 07:25:26 -06:00
duration : float ,
cache_path : str ,
2026-04-15 08:32:26 -06:00
segment_info : SegmentInfo ,
2026-03-25 18:30:59 -06:00
) -> Optional [ dict [ str , Any ]]:
2023-11-05 13:30:29 -07:00
# directory will be in utc due to start_time being in utc
2022-12-11 06:45:32 -07:00
directory = os . path . join (
RECORD_DIR ,
2023-11-05 13:30:29 -07:00
start_time . strftime ( "%Y-%m- %d /%H" ),
2022-12-11 06:45:32 -07:00
camera ,
)
2021-10-23 16:18:13 -05:00
if not os . path . exists ( directory ):
os . makedirs ( directory )
2023-11-05 13:30:29 -07:00
# file will be in utc due to start_time being in utc
file_name = f " { start_time . strftime ( '%M.%S.mp4' ) } "
2021-10-23 16:18:13 -05:00
file_path = os . path . join ( directory , file_name )
2021-11-09 07:05:21 -06:00
try :
2022-10-01 18:11:29 -05:00
if not os . path . exists ( file_path ):
start_frame = datetime . datetime . now () . timestamp ()
2022-12-17 16:53:34 -07:00
# add faststart to kept segments to improve metadata reading
2023-07-26 04:55:08 -06:00
p = await asyncio . create_subprocess_exec (
2024-09-13 14:14:51 -06:00
self . config . ffmpeg . ffmpeg_path ,
2023-06-01 04:46:34 -06:00
"-hide_banner" ,
2022-12-17 16:53:34 -07:00
"-y" ,
"-i" ,
cache_path ,
"-c" ,
"copy" ,
"-movflags" ,
"+faststart" ,
file_path ,
2023-07-26 04:55:08 -06:00
stderr = asyncio . subprocess . PIPE ,
2023-10-13 16:04:38 -06:00
stdout = asyncio . subprocess . DEVNULL ,
2022-10-01 18:11:29 -05:00
)
2023-07-26 04:55:08 -06:00
await p . wait ()
2021-10-23 16:18:13 -05:00
2022-12-17 16:53:34 -07:00
if p . returncode != 0 :
logger . error ( f "Unable to convert { cache_path } to { file_path } " )
2026-03-25 18:30:59 -06:00
if p . stderr :
logger . error (( await p . stderr . read ()) . decode ( "ascii" ))
2023-07-21 06:29:50 -06:00
return None
2022-12-17 16:53:34 -07:00
else :
logger . debug (
2025-01-11 08:04:11 -06:00
f "Copied { file_path } in { datetime . datetime . now () . timestamp () - start_frame } seconds."
2022-12-17 16:53:34 -07:00
)
2022-10-09 05:28:26 -06:00
try :
2022-12-17 16:53:34 -07:00
# get the segment size of the cache file
# file without faststart is same size
2022-10-09 05:28:26 -06:00
segment_size = round (
2025-02-27 16:28:53 +01:00
float ( os . path . getsize ( cache_path )) / pow ( 2 , 20 ), 2
2022-10-09 05:28:26 -06:00
)
except OSError :
segment_size = 0
os . remove ( cache_path )
2022-10-01 18:11:29 -05:00
rand_id = "" . join (
random . choices ( string . ascii_lowercase + string . digits , k = 6 )
)
2023-07-21 06:29:50 -06:00
return {
2024-07-24 08:58:23 -06:00
Recordings . id . name : f " { start_time . timestamp () } - { rand_id } " ,
Recordings . camera . name : camera ,
Recordings . path . name : file_path ,
Recordings . start_time . name : start_time . timestamp (),
Recordings . end_time . name : end_time . timestamp (),
Recordings . duration . name : duration ,
Recordings . motion . name : segment_info . motion_count ,
2022-10-01 18:11:29 -05:00
# TODO: update this to store list of active objects at some point
2024-09-02 07:22:53 -06:00
Recordings . objects . name : segment_info . active_object_count ,
2024-07-24 08:58:23 -06:00
Recordings . regions . name : segment_info . region_count ,
Recordings . dBFS . name : segment_info . average_dBFS ,
Recordings . segment_size . name : segment_size ,
2026-03-05 17:53:48 -06:00
Recordings . motion_heatmap . name : segment_info . motion_heatmap ,
2023-07-21 06:29:50 -06:00
}
2021-11-09 07:05:21 -06:00
except Exception as e :
logger . error ( f "Unable to store recording segment { cache_path } " )
Path ( cache_path ) . unlink ( missing_ok = True )
logger . error ( e )
2020-11-29 21:31:02 -06:00
2021-11-19 07:19:45 -06:00
# clear end_time cache
self . end_time_cache . pop ( cache_path , None )
2023-07-21 06:29:50 -06:00
return None
2021-11-19 07:19:45 -06:00
2023-04-26 07:25:26 -06:00
def run ( self ) -> None :
2021-07-11 15:34:48 -04:00
# Check for new files every 5 seconds
2023-07-21 06:29:50 -06:00
wait_time = 0.0
2024-03-23 13:45:15 -06:00
while not self . stop_event . is_set ():
time . sleep ( wait_time )
if self . stop_event . is_set ():
break
2021-10-22 07:23:18 -05:00
run_start = datetime . datetime . now () . timestamp ()
2024-02-19 06:26:59 -07:00
# check if there is an updated config
2025-05-22 12:16:51 -06:00
self . config_subscriber . check_for_updates ()
2024-02-19 06:26:59 -07:00
2023-10-18 04:52:48 -07:00
stale_frame_count = 0
stale_frame_count_threshold = 10
2023-07-14 18:05:14 -06:00
# empty the object recordings info queue
2021-12-10 22:56:29 -06:00
while True :
2026-03-25 18:30:59 -06:00
result = self . detection_subscriber . check_for_update (
2025-04-30 08:42:53 -05:00
timeout = FAST_QUEUE_TIMEOUT
2024-02-19 06:26:59 -07:00
)
2026-03-25 18:30:59 -06:00
if not result :
break
topic , data = result
if not topic or not data :
2024-02-19 06:26:59 -07:00
break
2025-08-08 06:08:37 -06:00
if topic == DetectionTypeEnum . video . value :
2021-12-10 22:56:29 -06:00
(
camera ,
2024-11-16 16:00:19 -07:00
_ ,
2021-12-10 22:56:29 -06:00
frame_time ,
current_tracked_objects ,
motion_boxes ,
regions ,
2024-02-19 06:26:59 -07:00
) = data
2023-10-18 04:52:48 -07:00
2024-02-19 06:26:59 -07:00
if self . config . cameras [ camera ] . record . enabled :
2023-07-14 18:05:14 -06:00
self . object_recordings_info [ camera ] . append (
2021-12-10 22:56:29 -06:00
(
frame_time ,
current_tracked_objects ,
motion_boxes ,
regions ,
)
)
2025-08-08 06:08:37 -06:00
elif topic == DetectionTypeEnum . audio . value :
2024-02-19 06:26:59 -07:00
(
camera ,
frame_time ,
dBFS ,
audio_detections ,
) = data
if self . config . cameras [ camera ] . record . enabled :
self . audio_recordings_info [ camera ] . append (
(
frame_time ,
dBFS ,
audio_detections ,
)
2023-10-18 04:52:48 -07:00
)
2025-08-08 06:08:37 -06:00
elif (
2026-03-18 22:40:54 +08:00
topic == DetectionTypeEnum . api . value
or topic == DetectionTypeEnum . lpr . value
2025-08-08 06:08:37 -06:00
):
2024-04-02 07:07:50 -06:00
continue
2024-02-19 06:26:59 -07:00
if frame_time < run_start - stale_frame_count_threshold :
stale_frame_count += 1
2021-12-10 22:56:29 -06:00
2023-10-18 04:52:48 -07:00
if stale_frame_count > 0 :
2023-10-26 16:50:31 -05:00
logger . debug ( f "Found { stale_frame_count } old frames." )
2023-10-18 04:52:48 -07:00
2021-11-09 07:05:21 -06:00
try :
2023-06-16 07:09:13 -06:00
asyncio . run ( self . move_files ())
2021-11-09 07:05:21 -06:00
except Exception as e :
logger . error (
"Error occurred when attempting to maintain recording cache"
)
logger . error ( e )
2021-11-17 08:57:57 -06:00
duration = datetime . datetime . now () . timestamp () - run_start
wait_time = max ( 0 , 5 - duration )
2021-07-11 15:34:48 -04:00
2024-02-14 17:24:36 -07:00
self . requestor . stop ()
2024-02-19 06:26:59 -07:00
self . config_subscriber . stop ()
self . detection_subscriber . stop ()
2025-02-21 07:51:37 -06:00
self . recordings_publisher . stop ()
2023-05-29 12:31:17 +02:00
logger . info ( "Exiting recording maintenance..." )