Tiered recordings (#18492)

* Implement tiered recording

* Add migration for record config

* Update docs

* Update reference docs

* Fix preview query

* Fix incorrect accesses

* Fix

* Fix

* Fix

* Fix
This commit is contained in:
Nicolas Mowen
2025-08-16 10:20:33 -05:00
committed by Blake Blackshear
parent 0b9997015a
commit 3f8ec72336
7 changed files with 143 additions and 110 deletions
+30 -12
View File
@@ -100,7 +100,11 @@ class RecordingCleanup(threading.Thread):
).execute()
def expire_existing_camera_recordings(
self, expire_date: float, config: CameraConfig, reviews: ReviewSegment
self,
continuous_expire_date: float,
motion_expire_date: float,
config: CameraConfig,
reviews: ReviewSegment,
) -> None:
"""Delete recordings for existing camera based on retention config."""
# Get the timestamp for cutoff of retained days
@@ -116,8 +120,14 @@ class RecordingCleanup(threading.Thread):
Recordings.motion,
)
.where(
Recordings.camera == config.name,
Recordings.end_time < expire_date,
(Recordings.camera == config.name)
& (
(
(Recordings.end_time < continuous_expire_date)
& (Recordings.motion == 0)
)
| (Recordings.end_time < motion_expire_date)
)
)
.order_by(Recordings.start_time)
.namedtuples()
@@ -188,7 +198,7 @@ class RecordingCleanup(threading.Thread):
Recordings.id << deleted_recordings_list[i : i + max_deletes]
).execute()
previews: Previews = (
previews: list[Previews] = (
Previews.select(
Previews.id,
Previews.start_time,
@@ -196,8 +206,9 @@ class RecordingCleanup(threading.Thread):
Previews.path,
)
.where(
Previews.camera == config.name,
Previews.end_time < expire_date,
(Previews.camera == config.name)
& (Previews.end_time < continuous_expire_date)
& (Previews.end_time < motion_expire_date)
)
.order_by(Previews.start_time)
.namedtuples()
@@ -253,7 +264,9 @@ class RecordingCleanup(threading.Thread):
logger.debug("Start deleted cameras.")
# Handle deleted cameras
expire_days = self.config.record.retain.days
expire_days = max(
self.config.record.continuous.days, self.config.record.motion.days
)
expire_before = (
datetime.datetime.now() - datetime.timedelta(days=expire_days)
).timestamp()
@@ -291,9 +304,12 @@ class RecordingCleanup(threading.Thread):
now = datetime.datetime.now()
self.expire_review_segments(config, now)
expire_days = config.record.retain.days
expire_date = (now - datetime.timedelta(days=expire_days)).timestamp()
continuous_expire_date = (
now - datetime.timedelta(days=config.record.continuous.days)
).timestamp()
motion_expire_date = (
now - datetime.timedelta(days=config.record.motion.days)
).timestamp()
# Get all the reviews to check against
reviews: ReviewSegment = (
@@ -306,13 +322,15 @@ class RecordingCleanup(threading.Thread):
ReviewSegment.camera == camera,
# need to ensure segments for all reviews starting
# before the expire date are included
ReviewSegment.start_time < expire_date,
ReviewSegment.start_time < motion_expire_date,
)
.order_by(ReviewSegment.start_time)
.namedtuples()
)
self.expire_existing_camera_recordings(expire_date, config, reviews)
self.expire_existing_camera_recordings(
continuous_expire_date, motion_expire_date, config, reviews
)
logger.debug(f"End camera: {camera}.")
logger.debug("End all cameras.")
+16 -9
View File
@@ -285,12 +285,16 @@ class RecordingMaintainer(threading.Thread):
Path(cache_path).unlink(missing_ok=True)
return
# if cached file's start_time is earlier than the retain days for the camera
# meaning continuous recording is not enabled
if start_time <= (
datetime.datetime.now().astimezone(datetime.timezone.utc)
- datetime.timedelta(days=self.config.cameras[camera].record.retain.days)
):
record_config = self.config.cameras[camera].record
highest = None
if record_config.continuous.days > 0:
highest = "continuous"
elif record_config.motion.days > 0:
highest = "motion"
# continuous / motion recording is not enabled
if highest is None:
# if the cached segment overlaps with the review items:
overlaps = False
for review in reviews:
@@ -344,8 +348,7 @@ class RecordingMaintainer(threading.Thread):
).astimezone(datetime.timezone.utc)
if end_time < retain_cutoff:
self.drop_segment(cache_path)
# else retain days includes this segment
# meaning continuous recording is enabled
# continuous / motion is enabled
else:
# assume that empty means the relevant recording info has not been received yet
camera_info = self.object_recordings_info[camera]
@@ -360,7 +363,11 @@ class RecordingMaintainer(threading.Thread):
).astimezone(datetime.timezone.utc)
>= end_time
):
record_mode = self.config.cameras[camera].record.retain.mode
record_mode = (
RetainModeEnum.all
if highest == "continuous"
else RetainModeEnum.motion
)
return await self.move_segment(
camera, start_time, end_time, duration, cache_path, record_mode
)