diff --git a/frigate/config/recap.py b/frigate/config/recap.py index 462e4512d..772f85e09 100644 --- a/frigate/config/recap.py +++ b/frigate/config/recap.py @@ -1,4 +1,4 @@ -from pydantic import Field +from pydantic import Field, field_validator from .base import FrigateBaseModel @@ -11,15 +11,44 @@ class RecapConfig(FrigateBaseModel): title="Enable recaps", description="Allow generation of time-stacked recap videos that composite detected objects onto a clean background.", ) + auto_generate: bool = Field( + default=False, + title="Auto-generate daily", + description="Automatically generate a recap for the previous day at the scheduled time.", + ) + schedule_time: str = Field( + default="02:00", + title="Schedule time", + description="Time of day (HH:MM, 24h format) to auto-generate the previous day's recap. Only used when auto_generate is true.", + ) + cameras: list[str] = Field( + default=[], + title="Cameras", + description="List of camera names to generate recaps for. Empty list means all cameras.", + ) default_label: str = Field( default="person", title="Default object label", - description="The object type to include in recaps by default.", + description="The object type to include in recaps.", + ) + speed: int = Field( + default=2, + title="Playback speed", + description="Speed multiplier for the output video.", + ge=1, + le=8, + ) + max_per_group: int = Field( + default=3, + title="Max events per group", + description="Maximum number of events to composite simultaneously. Higher values pack more into the video but can get crowded.", + ge=1, + le=10, ) ghost_duration: float = Field( default=3.0, title="Ghost visibility duration", - description="How long (in seconds) each detected object stays visible on the recap video.", + description="How long (in seconds of video time) each detection stays visible when path data is unavailable.", ge=0.5, le=30.0, ) @@ -32,8 +61,8 @@ class RecapConfig(FrigateBaseModel): ) video_duration: int = Field( default=30, - title="Video duration", - description="Target length in seconds for the output video. The full time range is compressed into this duration.", + title="Minimum video duration", + description="Minimum length in seconds for the output video. Actual length depends on event count and durations.", ge=5, le=300, ) @@ -44,3 +73,17 @@ class RecapConfig(FrigateBaseModel): ge=5, le=100, ) + + @field_validator("schedule_time") + @classmethod + def validate_schedule_time(cls, v: str) -> str: + parts = v.split(":") + if len(parts) != 2: + raise ValueError("schedule_time must be HH:MM format") + try: + h, m = int(parts[0]), int(parts[1]) + except ValueError: + raise ValueError("schedule_time must be HH:MM format") + if not (0 <= h <= 23 and 0 <= m <= 59): + raise ValueError("schedule_time hours must be 0-23 and minutes 0-59") + return v diff --git a/frigate/recap/recap.py b/frigate/recap/recap.py index 0f9e13180..a310d2821 100644 --- a/frigate/recap/recap.py +++ b/frigate/recap/recap.py @@ -317,8 +317,8 @@ class RecapGenerator(threading.Thread): recap_cfg = config.recap self.output_fps = recap_cfg.output_fps - self.speed = 2 - self.max_per_group = 3 + self.speed = recap_cfg.speed + self.max_per_group = recap_cfg.max_per_group self.video_duration = recap_cfg.video_duration self.background_samples = recap_cfg.background_samples diff --git a/frigate/recap/scheduler.py b/frigate/recap/scheduler.py new file mode 100644 index 000000000..34a828ba8 --- /dev/null +++ b/frigate/recap/scheduler.py @@ -0,0 +1,94 @@ +"""Scheduled daily recap generation. + +Runs as a background thread, checks once per minute if it's time +to generate recaps for the previous day. +""" + +import logging +import random +import string +import threading +import time +from datetime import datetime, timedelta + +from frigate.config import FrigateConfig +from frigate.recap.recap import RecapGenerator + +logger = logging.getLogger(__name__) + + +class RecapScheduler(threading.Thread): + """Triggers daily recap generation at the configured time.""" + + def __init__(self, config: FrigateConfig): + super().__init__(daemon=True, name="recap_scheduler") + self.config = config + self._last_run_date = None + + def run(self): + recap_cfg = self.config.recap + if not recap_cfg.enabled or not recap_cfg.auto_generate: + logger.info("recap scheduler not enabled, exiting") + return + + hour, minute = (int(x) for x in recap_cfg.schedule_time.split(":")) + logger.info( + "recap scheduler started, will run daily at %02d:%02d", hour, minute + ) + + while True: + now = datetime.now() + today = now.date() + + # check if it's time and we haven't already run today + if ( + now.hour == hour + and now.minute == minute + and self._last_run_date != today + ): + self._last_run_date = today + self._generate_all() + + # sleep until next minute + time.sleep(60) + + def _generate_all(self): + recap_cfg = self.config.recap + yesterday = datetime.now() - timedelta(days=1) + start = yesterday.replace(hour=0, minute=0, second=0, microsecond=0) + end = start + timedelta(days=1) + + # figure out which cameras to process + camera_names = ( + list(recap_cfg.cameras) + if recap_cfg.cameras + else list(self.config.cameras.keys()) + ) + + logger.info( + "auto-generating recaps for %d cameras (%s)", + len(camera_names), + start.strftime("%Y-%m-%d"), + ) + + for camera in camera_names: + if camera not in self.config.cameras: + logger.warning("recap: camera %s not found, skipping", camera) + continue + + export_id = ( + f"{camera}_recap_" + f"{''.join(random.choices(string.ascii_lowercase + string.digits, k=6))}" + ) + + generator = RecapGenerator( + config=self.config, + export_id=export_id, + camera=camera, + start_time=start.timestamp(), + end_time=end.timestamp(), + label=recap_cfg.default_label, + ) + generator.start() + + logger.info("recap started for %s (export_id=%s)", camera, export_id) diff --git a/frigate/test/test_recap.py b/frigate/test/test_recap.py index c43d93464..eb94d8d54 100644 --- a/frigate/test/test_recap.py +++ b/frigate/test/test_recap.py @@ -199,10 +199,32 @@ class TestRecapConfig(unittest.TestCase): cfg = RecapConfig() self.assertFalse(cfg.enabled) + self.assertFalse(cfg.auto_generate) + self.assertEqual(cfg.schedule_time, "02:00") + self.assertEqual(cfg.cameras, []) self.assertEqual(cfg.default_label, "person") + self.assertEqual(cfg.speed, 2) + self.assertEqual(cfg.max_per_group, 3) self.assertEqual(cfg.video_duration, 30) - def test_validation(self): + def test_custom_values(self): + from frigate.config.recap import RecapConfig + + cfg = RecapConfig( + enabled=True, + auto_generate=True, + schedule_time="03:30", + cameras=["front", "back"], + speed=4, + max_per_group=5, + ) + self.assertTrue(cfg.auto_generate) + self.assertEqual(cfg.schedule_time, "03:30") + self.assertEqual(cfg.cameras, ["front", "back"]) + self.assertEqual(cfg.speed, 4) + self.assertEqual(cfg.max_per_group, 5) + + def test_validation_ranges(self): from pydantic import ValidationError from frigate.config.recap import RecapConfig @@ -215,6 +237,29 @@ class TestRecapConfig(unittest.TestCase): RecapConfig(video_duration=2) with self.assertRaises(ValidationError): RecapConfig(background_samples=2) + with self.assertRaises(ValidationError): + RecapConfig(speed=0) + with self.assertRaises(ValidationError): + RecapConfig(speed=10) + with self.assertRaises(ValidationError): + RecapConfig(max_per_group=0) + + def test_schedule_time_validation(self): + from pydantic import ValidationError + + from frigate.config.recap import RecapConfig + + with self.assertRaises(ValidationError): + RecapConfig(schedule_time="25:00") + with self.assertRaises(ValidationError): + RecapConfig(schedule_time="abc") + with self.assertRaises(ValidationError): + RecapConfig(schedule_time="12:60") + # valid edge cases + cfg = RecapConfig(schedule_time="00:00") + self.assertEqual(cfg.schedule_time, "00:00") + cfg = RecapConfig(schedule_time="23:59") + self.assertEqual(cfg.schedule_time, "23:59") if __name__ == "__main__":