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
2026-07-06 09:28:02 -08:00
from typing import Any
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
2026-08-16 13:59:03 -05:00
from peewee import fn
2023-05-29 12:31:17 +02:00
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 ,
2026-08-16 13:59:03 -05:00
STREAM_TYPE_MAIN ,
STREAM_TYPE_SUB ,
SUB_CACHE_TAG ,
2023-07-26 04:55:08 -06:00
)
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
2026-08-16 13:59:03 -05:00
from frigate.util.media import get_keyframe_offsets
2026-08-30 09:14:43 -05:00
from frigate.util.ownership import chown_to_runtime
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__ )
2026-06-22 15:33:56 -05:00
STALE_RECORDINGS_INFO_TTL = MAX_SEGMENTS_IN_CACHE * MAX_SEGMENT_DURATION * 2
2026-08-16 13:59:03 -05:00
# cache filenames have whole-second resolution, so a contiguous segment's
# parsed start lands up to 1s before the previous segment's true end
SEGMENT_CHAIN_TOLERANCE_S = 1.0
# against an mtime-measured start, disagreement beyond this means
# accumulated probe-duration error and the chain re-anchors on the mtime
SEGMENT_CHAIN_DRIFT_LIMIT_S = 0.5
# probing every cached segment at once starves the camera and detection
# processes, and the probes then blow their own timeouts together, so
# segments get discarded as corrupt and the record watchdog restarts ffmpeg
MAX_CONCURRENT_SEGMENT_PROBES = 4
def parse_cache_segment_name ( basename : str ) -> tuple [ str , str , str ] | None :
"""Parse a cache segment basename into (camera, stream_type, date).
Main segments are named {camera} @ {date} ; sub segments {camera} @sub@ {date} .
"""
try :
prefix , date = basename . rsplit ( "@" , maxsplit = 1 )
except ValueError :
return None
if prefix . endswith ( SUB_CACHE_TAG ):
return ( prefix [: - len ( SUB_CACHE_TAG )], STREAM_TYPE_SUB , date )
return ( prefix , STREAM_TYPE_MAIN , date )
2020-11-30 21:08:47 -06:00
2026-08-25 12:51:58 -05:00
def format_segment_details ( cache_path : str , segment_info : dict [ str , Any ]) -> str :
"""Comma separated facts about a segment, for discard warnings."""
details : list [ str ] = []
duration = segment_info . get ( "duration" , - 1 )
if duration != - 1 :
details . append ( f "duration: { duration : .2f } s" )
try :
details . append ( f "size: { os . path . getsize ( cache_path ) / 1024 : .1f } KB" )
except OSError :
pass
details . append ( f "video: { segment_info . get ( 'video_codec' ) or 'none' } " )
if segment_info . get ( "has_audio" ):
audio = segment_info . get ( "audio_codec" ) or "unknown"
rate = segment_info . get ( "audio_rate" )
details . append ( f "audio: { audio } { rate } Hz" if rate else f "audio: { audio } " )
else :
details . append ( "audio: none" )
return ", " . join ( details )
def segment_path_time ( cache_path : str ) -> datetime . datetime | None :
"""Timestamp a segment's recording path is built from, or None if unparsable.
Recording paths carry one second of resolution, and so does ffmpeg's cache
segment template, which makes a cache file name unique per camera stream
and second. Resolved start times are not: a stream cutting segments faster
than once a second resolves consecutive segments into the same second, and
building the path from those collides on the unique path index.
"""
parsed = parse_cache_segment_name ( Path ( cache_path ) . stem )
if parsed is None :
return None
try :
return datetime . datetime . strptime ( parsed [ 2 ], CACHE_SEGMENT_FORMAT ) . astimezone (
datetime . UTC
)
except ValueError :
return None
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 ):
2026-08-16 13:59:03 -05:00
# move_files replaces this per cycle: an asyncio primitive binds to the
# first event loop that contends it, and every cycle runs in a new loop
probe_semaphore = asyncio . Semaphore ( MAX_CONCURRENT_SEGMENT_PROBES )
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 )
2026-08-16 13:59:03 -05:00
# cache_path -> (end_time, duration, has_audio, audio_rate,
# audio_codec, video_codec, keyframes)
self . end_time_cache : dict [
str ,
tuple [
datetime . datetime ,
float ,
bool | None ,
int | None ,
str | None ,
str | None ,
list [ int ] | None ,
],
] = {}
# last known capture end per (camera, stream_type); 0.0 marks a key
# whose DB seed found no rows
self . last_segment_end : dict [ tuple [ str , str ], float ] = {}
2026-01-16 20:23:23 -06:00
self . unexpected_cache_files_logged : bool = False
2020-11-29 21:31:02 -06:00
2026-08-16 13:59:03 -05:00
def _get_last_segment_end ( self , camera : str , stream_type : str ) -> float | None :
"""Return the last known capture end time for a camera stream.
Lazily seeds from the most recent stored recording so start-time
chains survive restarts.
"""
key = ( camera , stream_type )
if key not in self . last_segment_end :
last_db_end = (
Recordings . select ( fn . MAX ( Recordings . end_time ))
. where (
Recordings . camera == camera ,
Recordings . stream_type == stream_type ,
)
. scalar ()
)
# the 0.0 sentinel keeps the seed query from repeating
self . last_segment_end [ key ] = last_db_end if last_db_end is not None else 0.0
return self . last_segment_end [ key ] or None
def _resolve_segment_start (
self ,
camera : str ,
stream_type : str ,
filename_start : datetime . datetime ,
duration : float ,
cache_path : str ,
) -> datetime . datetime :
"""Resolve a segment's true start time from its cache file.
Cache filenames carry whole-second resolution, so the parsed start
sits up to 1s early. The cache file's mtime is the wall clock when
ffmpeg rolled the segment, so mtime minus the probed duration
restores the fractional start. Contiguous segments still chain to
the previous segment's end so rows stay exactly adjacent.
"""
filename_ts = filename_start . timestamp ()
measured : float | None = None
try :
mtime = os . path . getmtime ( cache_path )
except OSError :
mtime = None
if mtime is not None :
candidate = mtime - duration
# media shorter than its wall span (a stalled stream, an early
# close) derives a start past the truncation window, where the
# floored filename start is safer
if 0 <= candidate - filename_ts < SEGMENT_CHAIN_TOLERANCE_S :
measured = candidate
last_end = self . _get_last_segment_end ( camera , stream_type )
if measured is not None :
if (
last_end is not None
and abs ( last_end - measured ) < SEGMENT_CHAIN_DRIFT_LIMIT_S
):
return datetime . datetime . fromtimestamp ( last_end , tz = datetime . UTC )
return datetime . datetime . fromtimestamp ( measured , tz = datetime . UTC )
# no usable mtime: capture is continuous within a run, so a
# filename start just before the previous end chains to that end
if (
last_end is not None
and 0 <= last_end - filename_ts < SEGMENT_CHAIN_TOLERANCE_S
):
return datetime . datetime . fromtimestamp ( last_end , tz = datetime . UTC )
return filename_start
2023-06-16 07:09:13 -06:00
async def move_files ( self ) -> None :
2026-08-16 13:59:03 -05:00
self . probe_semaphore = asyncio . Semaphore ( MAX_CONCURRENT_SEGMENT_PROBES )
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
2026-08-25 12:51:58 -05:00
# publish newest cached segment per camera stream (including in use files)
newest_cache_segments : dict [ tuple [ str , str ], dict [ str , Any ]] = {}
2025-09-28 11:52:14 -05:00
for cache in cache_files :
cache_path = os . path . join ( CACHE_DIR , cache )
basename = os . path . splitext ( cache )[ 0 ]
2026-08-16 13:59:03 -05:00
parsed = parse_cache_segment_name ( basename )
if parsed is None :
2026-01-16 20:23:23 -06:00
if not self . unexpected_cache_files_logged :
2026-08-25 12:51:58 -05:00
logger . warning ( f "Skipping unexpected files in cache, e.g. { cache } " )
2026-01-16 20:23:23 -06:00
self . unexpected_cache_files_logged = True
continue
2026-08-16 13:59:03 -05:00
camera , stream_type , date = parsed
2025-09-28 11:52:14 -05:00
start_time = datetime . datetime . strptime (
date , CACHE_SEGMENT_FORMAT
2026-07-06 09:28:02 -08:00
) . astimezone ( datetime . UTC )
2026-08-25 12:51:58 -05:00
key = ( camera , stream_type )
2025-09-28 11:52:14 -05:00
if (
2026-08-25 12:51:58 -05:00
key not in newest_cache_segments
or start_time > newest_cache_segments [ key ][ "start_time" ]
2025-09-28 11:52:14 -05:00
):
2026-08-25 12:51:58 -05:00
newest_cache_segments [ key ] = {
2025-09-28 11:52:14 -05:00
"start_time" : start_time ,
"cache_path" : cache_path ,
}
2026-08-25 12:51:58 -05:00
for ( camera , stream_type ), newest in newest_cache_segments . items ():
2025-09-28 11:52:14 -05:00
self . recordings_publisher . publish (
(
camera ,
2026-08-25 12:51:58 -05:00
stream_type ,
2025-09-28 11:52:14 -05:00
newest [ "start_time" ] . timestamp (),
newest [ "cache_path" ],
),
RecordingsDataTypeEnum . latest . value ,
)
2026-08-25 12:51:58 -05:00
# publish None for streams with no cache files (but only if we know the camera exists)
for camera_name , camera_config in self . config . cameras . items ():
stream_types = [ STREAM_TYPE_MAIN ]
if camera_config . record . sub . enabled :
stream_types . append ( STREAM_TYPE_SUB )
for stream_type in stream_types :
if ( camera_name , stream_type ) not in newest_cache_segments :
self . recordings_publisher . publish (
( camera_name , stream_type , None , None ),
RecordingsDataTypeEnum . latest . value ,
)
2025-09-28 11:52:14 -05:00
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
2026-08-16 13:59:03 -05:00
# group recordings by camera and stream type (skip in-use for validation/moving)
grouped_recordings : defaultdict [ tuple [ str , str ], list [ dict [ str , Any ]]] = (
defaultdict ( list )
)
2023-04-26 07:25:26 -06:00
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-08-16 13:59:03 -05:00
parsed = parse_cache_segment_name ( basename )
if parsed is None :
2026-01-16 20:23:23 -06:00
if not self . unexpected_cache_files_logged :
2026-08-25 12:51:58 -05:00
logger . warning ( f "Skipping unexpected files in cache, e.g. { cache } " )
2026-01-16 20:23:23 -06:00
self . unexpected_cache_files_logged = True
continue
2026-08-16 13:59:03 -05:00
camera , stream_type , date = parsed
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
2026-07-06 09:28:02 -08:00
) . astimezone ( datetime . UTC )
2021-02-17 07:23:32 -06:00
2026-08-16 13:59:03 -05:00
grouped_recordings [( camera , stream_type )] . append (
2021-10-23 16:18:13 -05:00
{
"cache_path" : cache_path ,
"start_time" : start_time ,
2026-08-16 13:59:03 -05:00
"stream_type" : stream_type ,
2021-10-23 16:18:13 -05:00
}
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
2026-08-16 13:59:03 -05:00
for key in grouped_recordings . keys ():
camera , stream_type = key
2023-11-05 13:30:29 -07:00
# sort based on start time
2026-08-16 13:59:03 -05:00
grouped_recordings [ key ] = sorted (
grouped_recordings [ key ], key = lambda s : s [ "start_time" ]
2023-11-05 13:30:29 -07:00
)
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
),
2026-08-16 13:59:03 -05:00
grouped_recordings [ key ],
2024-10-02 11:55:55 -06:00
)
)
)
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
)
2026-08-16 13:59:03 -05:00
to_remove = grouped_recordings [ key ][: - 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 )
2026-08-16 13:59:03 -05:00
grouped_recordings [ key ] = grouped_recordings [ key ][ - 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 = (
2026-08-16 13:59:03 -05:00
len ( grouped_recordings [ key ]) - processed_segment_count
2024-10-14 16:11:43 -06:00
)
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..."
)
2026-08-16 13:59:03 -05:00
to_remove = grouped_recordings [ key ][: - keep_count ]
2024-10-14 16:11:43 -06:00
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 )
2026-08-16 13:59:03 -05:00
grouped_recordings [ key ] = grouped_recordings [ key ][ - keep_count :]
2024-10-14 16:11:43 -06:00
2026-08-16 13:59:03 -05:00
# frame stats are shared per camera across stream types, so trimming
# to one stream's oldest cache would pop frames the other still needs
min_start_per_camera : dict [ str , float ] = {}
for key , recordings in grouped_recordings . items ():
camera , _ = key
oldest_start = recordings [ 0 ][ "start_time" ] . timestamp ()
if (
camera not in min_start_per_camera
or oldest_start < min_start_per_camera [ camera ]
):
min_start_per_camera [ camera ] = oldest_start
for camera , min_start in min_start_per_camera . 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
2026-08-16 13:59:03 -05:00
and self . object_recordings_info [ camera ][ 0 ][ 0 ] < min_start
2021-12-10 22:56:29 -06:00
):
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
2026-08-16 13:59:03 -05:00
and self . audio_recordings_info [ camera ][ 0 ][ 0 ] < min_start
2023-07-14 18:05:14 -06:00
):
self . audio_recordings_info [ camera ] . pop ( 0 )
2021-12-10 22:56:29 -06:00
2026-08-16 13:59:03 -05:00
tasks = []
reviews_by_camera : dict [ str , Any ] = {}
for key , recordings in grouped_recordings . items ():
camera , stream_type = key
# get all reviews with the end time after the start of the oldest
# cache file or with end_time None; shared across stream types
if camera not in reviews_by_camera :
reviews_by_camera [ camera ] = (
ReviewSegment . select (
ReviewSegment . start_time ,
ReviewSegment . end_time ,
ReviewSegment . severity ,
ReviewSegment . data ,
)
. where (
ReviewSegment . camera == camera ,
( ReviewSegment . end_time == None )
| ( ReviewSegment . end_time >= min_start_per_camera [ camera ]),
)
. order_by ( ReviewSegment . start_time )
2023-09-11 16:07:04 -06:00
)
2026-08-16 13:59:03 -05:00
reviews = reviews_by_camera [ camera ]
2021-10-23 16:18:13 -05:00
2026-09-28 14:45:05 -05:00
# probes run concurrently, but each segment's start chains off the
# previous segment's end, so starts resolve in segment order
previous : asyncio . Event | None = None
for recording in recordings :
resolved = asyncio . Event ()
tasks . append (
self . _validate_in_order (
camera , reviews , recording , previous , resolved
)
)
previous = resolved
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-08-16 13:59:03 -05:00
if stream_type == STREAM_TYPE_MAIN :
camera_cfg = self . config . cameras . get ( camera )
self . recordings_publisher . publish (
(
camera ,
2026-08-25 12:51:58 -05:00
stream_type ,
2026-08-16 13:59:03 -05:00
recordings [ 0 ][ "start_time" ] . timestamp ()
if camera_cfg and camera_cfg . record . enabled
else None ,
None ,
),
RecordingsDataTypeEnum . saved . value ,
)
2025-02-21 07:51:37 -06:00
2026-06-22 15:33:56 -05:00
self . _expire_stale_recordings_info ( grouped_recordings )
2026-08-16 13:59:03 -05:00
# one segment must not abort the cycle: an exception propagating out
# of gather would abandon the other segments' in-flight probes
results : list [ dict [ str , Any ] | None | BaseException ] = await asyncio . gather (
* tasks , return_exceptions = True
2023-07-26 04:55:08 -06:00
)
2023-07-21 06:29:50 -06:00
2026-08-16 13:59:03 -05:00
recordings_to_insert : list [ dict [ str , Any ]] = []
for result in results :
if isinstance ( result , BaseException ):
logger . error (
"Failed to validate and move a recording segment" , exc_info = result
)
continue
if result is not None :
recordings_to_insert . append ( result )
# fire and forget recordings entries
self . requestor . send_data ( INSERT_MANY_RECORDINGS , recordings_to_insert )
2026-06-22 15:33:56 -05:00
def _expire_stale_recordings_info (
2026-08-16 13:59:03 -05:00
self , grouped_recordings : defaultdict [ tuple [ str , str ], list [ dict [ str , Any ]]]
2026-06-22 15:33:56 -05:00
) -> None :
expire_before = datetime . datetime . now () . timestamp () - STALE_RECORDINGS_INFO_TTL
2026-08-16 13:59:03 -05:00
# a camera is still active when any of its streams cached segments
cameras_with_cache = { camera for camera , _ in grouped_recordings }
2026-06-22 15:33:56 -05:00
for recordings_info in (
self . object_recordings_info ,
self . audio_recordings_info ,
):
for camera in list ( recordings_info . keys ()):
2026-08-16 13:59:03 -05:00
if camera in cameras_with_cache :
2026-06-22 15:33:56 -05:00
continue
info = recordings_info [ camera ]
while info and info [ 0 ][ 0 ] < expire_before :
info . pop ( 0 )
2026-09-28 14:45:05 -05:00
async def _validate_in_order (
self ,
camera : str ,
reviews : Any ,
recording : dict [ str , Any ],
previous_start : asyncio . Event | None ,
start_resolved : asyncio . Event ,
) -> dict [ str , Any ] | None :
"""Validate a segment, always releasing the next one in its stream."""
try :
return await self . validate_and_move_segment (
camera , reviews , recording , previous_start , start_resolved
)
finally :
start_resolved . set ()
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-09-28 14:45:05 -05:00
self ,
camera : str ,
reviews : Any ,
recording : dict [ str , Any ],
previous_start : asyncio . Event | None = None ,
start_resolved : asyncio . Event | None = None ,
2026-07-06 09:28:02 -08:00
) -> dict [ str , Any ] | None :
2024-12-19 09:46:14 -06:00
cache_path : str = recording [ "cache_path" ]
start_time : datetime . datetime = recording [ "start_time" ]
2026-08-16 13:59:03 -05:00
stream_type : str = recording [ "stream_type" ]
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
2026-08-16 13:59:03 -05:00
or (
stream_type == STREAM_TYPE_SUB
and not self . config . cameras [ camera ] . record . sub . 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 :
2026-08-16 13:59:03 -05:00
(
end_time ,
duration ,
has_audio ,
audio_rate ,
audio_codec ,
video_codec ,
keyframes ,
) = self . end_time_cache [ cache_path ]
# recover the resolved start rather than reusing the truncated
# filename timestamp
start_time = end_time - datetime . timedelta ( seconds = duration )
2023-06-16 07:09:13 -06:00
else :
2026-08-16 13:59:03 -05:00
async with self . probe_semaphore :
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 (
2026-08-25 12:51:58 -05:00
f "Invalid or missing video stream in segment { cache_path } "
f "( { format_segment_details ( cache_path , segment_info ) } ). Discarding."
)
self . recordings_publisher . publish (
( camera , stream_type , start_time . timestamp (), cache_path ),
RecordingsDataTypeEnum . invalid . value ,
2025-09-28 11:52:14 -05:00
)
self . drop_segment ( cache_path )
return None
duration = float ( segment_info . get ( "duration" , - 1 ))
2026-08-16 13:59:03 -05:00
has_audio = segment_info . get ( "has_audio" )
audio_rate = segment_info . get ( "audio_rate" )
audio_codec = segment_info . get ( "audio_codec" )
video_codec = segment_info . get ( "video_codec" )
2023-06-16 07:09:13 -06:00
# ensure duration is within expected length
if 0 < duration < MAX_SEGMENT_DURATION :
2026-08-16 13:59:03 -05:00
# playback snaps mid-file entry points against these offsets
# instead of probing files on demand
async with self . probe_semaphore :
keyframes = await get_keyframe_offsets ( cache_path )
2026-09-28 14:45:05 -05:00
if previous_start is not None :
await previous_start . wait ()
2026-08-16 13:59:03 -05:00
start_time = self . _resolve_segment_start (
camera , stream_type , start_time , duration , cache_path
)
2023-06-16 07:09:13 -06:00
end_time = start_time + datetime . timedelta ( seconds = duration )
2026-08-16 13:59:03 -05:00
self . end_time_cache [ cache_path ] = (
end_time ,
duration ,
has_audio ,
audio_rate ,
audio_codec ,
video_codec ,
keyframes ,
)
# segments later discarded by retention still advance the
# chain for the next kept segment
self . last_segment_end [( camera , stream_type )] = end_time . timestamp ()
2023-06-16 07:09:13 -06:00
else :
if duration == - 1 :
logger . warning ( f "Failed to probe corrupt segment { cache_path } " )
2026-08-25 12:51:58 -05:00
logger . warning (
f "Discarding a corrupt recording segment: { cache_path } "
f "( { format_segment_details ( cache_path , segment_info ) } )"
)
self . recordings_publisher . publish (
( camera , stream_type , start_time . timestamp (), cache_path ),
RecordingsDataTypeEnum . invalid . value ,
)
2025-09-28 11:52:14 -05:00
self . drop_segment ( cache_path )
return None
# this segment has a valid duration and has video data, so publish an update
2026-08-25 12:51:58 -05:00
self . recordings_publisher . publish (
( camera , stream_type , start_time . timestamp (), cache_path ),
RecordingsDataTypeEnum . valid . value ,
)
2023-06-16 07:09:13 -06:00
2026-09-28 14:45:05 -05:00
# the start is settled, so the next segment of the stream can chain
# off it while this one waits on retention and the move
if start_resolved is not None :
start_resolved . set ()
2026-09-30 07:29:45 -06: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
)
# ensure delayed segment info does not lead to lost segments, every
# retention decision below depends on complete stats for the segment
if (
datetime . datetime . fromtimestamp (
most_recently_processed_frame_time
) . astimezone ( datetime . UTC )
< end_time
):
return None
2025-05-30 17:01:39 -06:00
record_config = self . config . cameras [ camera ] . record
2026-08-16 13:59:03 -05:00
# sub's alerts/detections carry the retain mode directly, unlike
# main's nested retain config
if stream_type == STREAM_TYPE_SUB :
continuous_days = record_config . sub . continuous . days
motion_days = record_config . sub . motion . days
alerts_retain_mode = record_config . sub . alerts . mode
detections_retain_mode = record_config . sub . detections . mode
else :
continuous_days = record_config . continuous . days
motion_days = record_config . motion . days
alerts_retain_mode = record_config . alerts . retain . mode
detections_retain_mode = record_config . detections . retain . mode
2026-04-15 09:02:41 -06:00
segment_stats : SegmentInfo | None = None
2025-05-30 17:01:39 -06:00
highest = None
2026-08-16 13:59:03 -05:00
if continuous_days > 0 :
2025-05-30 17:01:39 -06:00
highest = "continuous"
2026-08-16 13:59:03 -05:00
elif motion_days > 0 :
2025-05-30 17:01:39 -06:00
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 :
2026-09-30 07:29:45 -06:00
record_mode = (
RetainModeEnum . all if highest == "continuous" else RetainModeEnum . motion
2023-10-25 19:23:15 -05:00
)
2026-09-30 07:29:45 -06:00
segment_stats = self . segment_stats ( camera , start_time , end_time )
2023-10-25 14:06:57 -05:00
2026-09-30 07:29:45 -06:00
# 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_stats . should_discard_segment ( record_mode ):
return await self . move_segment (
camera ,
2026-10-02 06:39:23 -05:00
stream_type ,
2026-09-30 07:29:45 -06:00
start_time ,
end_time ,
duration ,
cache_path ,
segment_stats ,
2026-10-02 06:39:23 -05:00
has_audio ,
audio_rate ,
audio_codec ,
video_codec ,
keyframes ,
2025-05-30 17:01:39 -06:00
)
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 = (
2026-08-16 13:59:03 -05:00
alerts_retain_mode
2025-10-15 11:09:28 -06:00
if review . severity == "alert"
2026-08-16 13:59:03 -05:00
else detections_retain_mode
2025-10-15 11:09:28 -06:00
)
2026-04-15 08:32:26 -06:00
2026-04-15 09:02:41 -06:00
if segment_stats is None :
segment_stats = self . segment_stats ( camera , start_time , end_time )
2026-04-15 08:32:26 -06:00
2026-04-15 09:02:41 -06:00
if not segment_stats . should_discard_segment ( record_mode ):
2026-04-15 08:32:26 -06:00
# move from cache to recordings immediately
return await self . move_segment (
camera ,
2026-08-16 13:59:03 -05:00
stream_type ,
2026-04-15 08:32:26 -06:00
start_time ,
end_time ,
duration ,
cache_path ,
2026-04-15 09:02:41 -06:00
segment_stats ,
2026-08-16 13:59:03 -05:00
has_audio ,
audio_rate ,
audio_codec ,
video_codec ,
keyframes ,
2026-04-15 08:32:26 -06:00
)
else :
self . drop_segment ( cache_path )
return None
2026-04-18 08:10:50 -05:00
# if it doesn't overlap with a review item, drop the segment once it
# ends more than event_pre_capture before the most recently processed
# frame. at this point we've already decided not to keep it for
# continuous/motion retention (either disabled or segment_stats said
# discard), so waiting longer just fills the cache.
else :
2025-10-15 11:09:28 -06:00
retain_cutoff = datetime . datetime . fromtimestamp (
most_recently_processed_frame_time - record_config . event_pre_capture
2026-07-06 09:28:02 -08:00
) . astimezone ( datetime . 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 :
2026-10-10 15:01:33 -05:00
continue # type: ignore[unreachable]
2026-03-05 17:53:48 -06:00
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 ,
2026-08-16 13:59:03 -05:00
stream_type : 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-08-16 13:59:03 -05:00
has_audio : bool | None = None ,
audio_rate : int | None = None ,
audio_codec : str | None = None ,
video_codec : str | None = None ,
keyframes : list [ int ] | None = None ,
2026-07-06 09:28:02 -08:00
) -> dict [ str , Any ] | None :
2026-08-25 12:51:58 -05:00
path_time = segment_path_time ( cache_path ) or start_time
# directory will be in utc due to path_time being in utc
2026-08-16 13:59:03 -05:00
# sub segments get a tagged directory to avoid filename collisions
2022-12-11 06:45:32 -07:00
directory = os . path . join (
RECORD_DIR ,
2026-08-25 12:51:58 -05:00
path_time . strftime ( "%Y-%m- %d /%H" ),
2026-08-16 13:59:03 -05:00
camera if stream_type == STREAM_TYPE_MAIN else f " { camera }{ SUB_CACHE_TAG } " ,
2022-12-11 06:45:32 -07:00
)
2021-10-23 16:18:13 -05:00
2026-05-12 11:34:46 -07:00
os . makedirs ( directory , exist_ok = True )
2026-08-30 09:14:43 -05:00
# own every level makedirs creates so the host user can prune recordings
level = directory
while level != RECORD_DIR :
chown_to_runtime ( level )
level = os . path . dirname ( level )
2021-10-23 16:18:13 -05:00
2026-08-25 12:51:58 -05:00
# file will be in utc due to path_time being in utc
file_name = f " { path_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" ,
2026-05-18 09:58:10 -06:00
"-metadata" ,
f "creation_time= { start_time . strftime ( '%Y-%m- %d T%H:%M:%S. %f Z' ) } " ,
2022-12-17 16:53:34 -07:00
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" ))
2026-10-18 09:50:25 -05:00
Path ( file_path ) . unlink ( missing_ok = True )
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
)
2026-08-30 09:14:43 -05:00
chown_to_runtime ( file_path )
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
2026-10-18 09:50:25 -05:00
self . drop_segment ( cache_path )
2022-10-09 05:28:26 -06:00
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 ,
2026-08-16 13:59:03 -05:00
Recordings . stream_type . name : stream_type ,
2024-07-24 08:58:23 -06:00
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 ,
2026-08-16 13:59:03 -05:00
Recordings . has_audio . name : has_audio ,
Recordings . audio_rate . name : audio_rate ,
Recordings . audio_codec . name : audio_codec ,
Recordings . video_codec . name : video_codec ,
Recordings . keyframes . name : keyframes ,
2023-07-21 06:29:50 -06:00
}
2026-08-25 12:51:58 -05:00
except Exception :
logger . exception ( f "Unable to store recording segment { cache_path } " )
2020-11-29 21:31:02 -06:00
2026-10-18 09:50:25 -05:00
self . drop_segment ( cache_path )
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
2026-08-15 07:32:47 -05:00
camera_config = self . config . cameras . get ( camera )
if camera_config is not None and camera_config . 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
2026-08-15 07:32:47 -05:00
camera_config = self . config . cameras . get ( camera )
if camera_config is not None and camera_config . record . enabled :
2024-02-19 06:26:59 -07:00
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
2026-09-29 11:43:08 -06:00
or topic == DetectionTypeEnum . classification_state . 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 ())
2026-08-16 13:59:03 -05:00
except Exception :
logger . exception (
2021-11-09 07:05:21 -06:00
"Error occurred when attempting to maintain recording cache"
)
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..." )