mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-28 10:56:52 +03:00
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:
committed by
Blake Blackshear
parent
0b9997015a
commit
3f8ec72336
@@ -22,27 +22,31 @@ __all__ = [
|
||||
DEFAULT_TIME_LAPSE_FFMPEG_ARGS = "-vf setpts=0.04*PTS -r 30"
|
||||
|
||||
|
||||
class RecordRetainConfig(FrigateBaseModel):
|
||||
days: float = Field(default=0, ge=0, title="Default retention period.")
|
||||
|
||||
|
||||
class RetainModeEnum(str, Enum):
|
||||
all = "all"
|
||||
motion = "motion"
|
||||
active_objects = "active_objects"
|
||||
|
||||
|
||||
class RecordRetainConfig(FrigateBaseModel):
|
||||
days: float = Field(default=0, title="Default retention period.")
|
||||
mode: RetainModeEnum = Field(default=RetainModeEnum.all, title="Retain mode.")
|
||||
|
||||
|
||||
class ReviewRetainConfig(FrigateBaseModel):
|
||||
days: float = Field(default=10, title="Default retention period.")
|
||||
days: float = Field(default=10, ge=0, title="Default retention period.")
|
||||
mode: RetainModeEnum = Field(default=RetainModeEnum.motion, title="Retain mode.")
|
||||
|
||||
|
||||
class EventsConfig(FrigateBaseModel):
|
||||
pre_capture: int = Field(
|
||||
default=5, title="Seconds to retain before event starts.", le=MAX_PRE_CAPTURE
|
||||
default=5,
|
||||
title="Seconds to retain before event starts.",
|
||||
le=MAX_PRE_CAPTURE,
|
||||
ge=0,
|
||||
)
|
||||
post_capture: int = Field(
|
||||
default=5, ge=0, title="Seconds to retain after event ends."
|
||||
)
|
||||
post_capture: int = Field(default=5, title="Seconds to retain after event ends.")
|
||||
retain: ReviewRetainConfig = Field(
|
||||
default_factory=ReviewRetainConfig, title="Event retention settings."
|
||||
)
|
||||
@@ -77,8 +81,12 @@ class RecordConfig(FrigateBaseModel):
|
||||
default=60,
|
||||
title="Number of minutes to wait between cleanup runs.",
|
||||
)
|
||||
retain: RecordRetainConfig = Field(
|
||||
default_factory=RecordRetainConfig, title="Record retention settings."
|
||||
continuous: RecordRetainConfig = Field(
|
||||
default_factory=RecordRetainConfig,
|
||||
title="Continuous recording retention settings.",
|
||||
)
|
||||
motion: RecordRetainConfig = Field(
|
||||
default_factory=RecordRetainConfig, title="Motion recording retention settings."
|
||||
)
|
||||
detections: EventsConfig = Field(
|
||||
default_factory=EventsConfig, title="Detection specific retention settings."
|
||||
|
||||
@@ -48,7 +48,7 @@ from .camera.genai import GenAIConfig
|
||||
from .camera.motion import MotionConfig
|
||||
from .camera.notification import NotificationConfig
|
||||
from .camera.objects import FilterConfig, ObjectConfig
|
||||
from .camera.record import RecordConfig, RetainModeEnum
|
||||
from .camera.record import RecordConfig
|
||||
from .camera.review import ReviewConfig
|
||||
from .camera.snapshots import SnapshotsConfig
|
||||
from .camera.timestamp import TimestampStyleConfig
|
||||
@@ -204,33 +204,6 @@ def verify_valid_live_stream_names(
|
||||
)
|
||||
|
||||
|
||||
def verify_recording_retention(camera_config: CameraConfig) -> None:
|
||||
"""Verify that recording retention modes are ranked correctly."""
|
||||
rank_map = {
|
||||
RetainModeEnum.all: 0,
|
||||
RetainModeEnum.motion: 1,
|
||||
RetainModeEnum.active_objects: 2,
|
||||
}
|
||||
|
||||
if (
|
||||
camera_config.record.retain.days != 0
|
||||
and rank_map[camera_config.record.retain.mode]
|
||||
> rank_map[camera_config.record.alerts.retain.mode]
|
||||
):
|
||||
logger.warning(
|
||||
f"{camera_config.name}: Recording retention is configured for {camera_config.record.retain.mode} and alert retention is configured for {camera_config.record.alerts.retain.mode}. The more restrictive retention policy will be applied."
|
||||
)
|
||||
|
||||
if (
|
||||
camera_config.record.retain.days != 0
|
||||
and rank_map[camera_config.record.retain.mode]
|
||||
> rank_map[camera_config.record.detections.retain.mode]
|
||||
):
|
||||
logger.warning(
|
||||
f"{camera_config.name}: Recording retention is configured for {camera_config.record.retain.mode} and detection retention is configured for {camera_config.record.detections.retain.mode}. The more restrictive retention policy will be applied."
|
||||
)
|
||||
|
||||
|
||||
def verify_recording_segments_setup_with_reasonable_time(
|
||||
camera_config: CameraConfig,
|
||||
) -> None:
|
||||
@@ -697,7 +670,6 @@ class FrigateConfig(FrigateBaseModel):
|
||||
|
||||
verify_config_roles(camera_config)
|
||||
verify_valid_live_stream_names(self, camera_config)
|
||||
verify_recording_retention(camera_config)
|
||||
verify_recording_segments_setup_with_reasonable_time(camera_config)
|
||||
verify_zone_objects_are_tracked(camera_config)
|
||||
verify_required_zones_exist(camera_config)
|
||||
|
||||
+30
-12
@@ -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.")
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
+59
-1
@@ -13,7 +13,7 @@ from frigate.util.services import get_video_properties
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CURRENT_CONFIG_VERSION = "0.16-0"
|
||||
CURRENT_CONFIG_VERSION = "0.17-0"
|
||||
DEFAULT_CONFIG_FILE = os.path.join(CONFIG_DIR, "config.yml")
|
||||
|
||||
|
||||
@@ -91,6 +91,13 @@ def migrate_frigate_config(config_file: str):
|
||||
yaml.dump(new_config, f)
|
||||
previous_version = "0.16-0"
|
||||
|
||||
if previous_version < "0.17-0":
|
||||
logger.info(f"Migrating frigate config from {previous_version} to 0.17-0...")
|
||||
new_config = migrate_017_0(config)
|
||||
with open(config_file, "w") as f:
|
||||
yaml.dump(new_config, f)
|
||||
previous_version = "0.17-0"
|
||||
|
||||
logger.info("Finished frigate config migration...")
|
||||
|
||||
|
||||
@@ -340,6 +347,57 @@ def migrate_016_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]
|
||||
return new_config
|
||||
|
||||
|
||||
def migrate_017_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
||||
"""Handle migrating frigate config to 0.16-0"""
|
||||
new_config = config.copy()
|
||||
|
||||
# migrate global to new recording configuration
|
||||
global_record_retain = config.get("record", {}).get("retain")
|
||||
|
||||
if global_record_retain:
|
||||
continuous = {"days": 0}
|
||||
motion = {"days": 0}
|
||||
days = global_record_retain.get("days")
|
||||
mode = global_record_retain.get("mode", "all")
|
||||
|
||||
if days:
|
||||
if mode == "all":
|
||||
continuous["days"] = days
|
||||
else:
|
||||
motion["days"] = days
|
||||
|
||||
new_config["record"]["continuous"] = continuous
|
||||
new_config["record"]["motion"] = motion
|
||||
|
||||
del new_config["record"]["retain"]
|
||||
|
||||
for name, camera in config.get("cameras", {}).items():
|
||||
camera_config: dict[str, dict[str, Any]] = camera.copy()
|
||||
camera_record_retain = camera_config.get("record", {}).get("retain")
|
||||
|
||||
if camera_record_retain:
|
||||
continuous = {"days": 0}
|
||||
motion = {"days": 0}
|
||||
days = camera_record_retain.get("days")
|
||||
mode = camera_record_retain.get("mode", "all")
|
||||
|
||||
if days:
|
||||
if mode == "all":
|
||||
continuous["days"] = days
|
||||
else:
|
||||
motion["days"] = days
|
||||
|
||||
camera_config["record"]["continuous"] = continuous
|
||||
camera_config["record"]["motion"] = motion
|
||||
|
||||
del camera_config["record"]["retain"]
|
||||
|
||||
new_config["cameras"][name] = camera_config
|
||||
|
||||
new_config["version"] = "0.17-0"
|
||||
return new_config
|
||||
|
||||
|
||||
def get_relative_coordinates(
|
||||
mask: Optional[Union[str, list]], frame_shape: tuple[int, int]
|
||||
) -> Union[str, list]:
|
||||
|
||||
Reference in New Issue
Block a user