diff --git a/config.yaml.sample b/config.yaml.sample index 509b91c..78b06ab 100644 --- a/config.yaml.sample +++ b/config.yaml.sample @@ -33,4 +33,5 @@ amcrest: media: path: /media max_size: 25 # per recording, in MB; default is 25 + retention_days: 7 # days to keep recordings; 0 = disabled; default is 7 media_source: media-source://media_source/local/videos diff --git a/src/amcrest2mqtt/mixins/helpers.py b/src/amcrest2mqtt/mixins/helpers.py index f355232..317246f 100644 --- a/src/amcrest2mqtt/mixins/helpers.py +++ b/src/amcrest2mqtt/mixins/helpers.py @@ -12,7 +12,7 @@ import threading from types import FrameType import yaml from pathlib import Path -from datetime import datetime +from datetime import datetime, timedelta from typing import TYPE_CHECKING, Any, cast if TYPE_CHECKING: @@ -186,7 +186,12 @@ class HelpersMixin: if os.path.exists(media_path) and os.access(media_path, os.W_OK): media["path"] = media_path media.setdefault("max_size", 25) - self.logger.info(f"storing recordings in {media_path} up to {media["max_size"]} MB per file. Watch that it doesn't fill up the file system") + media["retention_days"] = int(str(media.get("retention_days") or os.getenv("MEDIA_RETENTION_DAYS", 7))) + self.logger.info(f"storing recordings in {media_path} up to {media["max_size"]} MB per file") + if media["retention_days"] > 0: + self.logger.info(f"recordings will be retained for {media["retention_days"]} days") + else: + self.logger.info("recording retention is disabled (retention_days=0). Watch that it doesn't fill up the file system") else: self.logger.info("media_path not configured, not found, or is not writable. Will not be saving recordings") @@ -286,6 +291,40 @@ class HelpersMixin: return file_name + async def cleanup_old_recordings(self: Amcrest2Mqtt) -> None: + media_path = self.config["media"].get("path") + retention_days = self.config["media"].get("retention_days", 7) + + if not media_path or retention_days <= 0: + return + + cutoff = datetime.now() - timedelta(days=retention_days) + path = Path(media_path) + + for file in path.glob("*.mp4"): + if file.is_symlink(): + continue + + # Extract timestamp from filename: {name}-YYYYMMDD-HHMMSS.mp4 + match = re.search(r"-(\d{8}-\d{6})\.mp4$", file.name) + if match: + file_time = datetime.strptime(match.group(1), "%Y%m%d-%H%M%S") + if file_time < cutoff: + try: + file.unlink() + self.logger.info(f"deleted old recording: {file.name}") + except Exception as err: + self.logger.error(f"failed to delete old recording {file.name}: {err!r}") + + # Clean up dangling symlinks (symlinks pointing to deleted files) + for link in path.glob("*-latest.mp4"): + if link.is_symlink() and not link.exists(): + try: + link.unlink() + self.logger.info(f"deleted dangling symlink: {link.name}") + except Exception as err: + self.logger.error(f"failed to delete dangling symlink {link.name}: {err!r}") + def handle_signal(self: Amcrest2Mqtt, signum: int, _: FrameType | None) -> Any: sig_name = signal.Signals(signum).name self.logger.warning(f"{sig_name} received - stopping service loop") diff --git a/src/amcrest2mqtt/mixins/loops.py b/src/amcrest2mqtt/mixins/loops.py index c82a15a..85e8afe 100644 --- a/src/amcrest2mqtt/mixins/loops.py +++ b/src/amcrest2mqtt/mixins/loops.py @@ -54,6 +54,15 @@ class LoopsMixin: self.logger.debug("heartbeat cancelled during sleep") break + async def cleanup_recordings_loop(self: Amcrest2Mqtt) -> None: + while self.running: + await self.cleanup_old_recordings() + try: + await asyncio.sleep(86400) # once per day + except asyncio.CancelledError: + self.logger.debug("cleanup_recordings_loop cancelled during sleep") + break + # main loop async def main_loop(self: Amcrest2Mqtt) -> None: for sig in (signal.SIGTERM, signal.SIGINT): @@ -71,6 +80,7 @@ class LoopsMixin: asyncio.create_task(self.collect_events_loop(), name="collect events loop"), asyncio.create_task(self.check_event_queue_loop(), name="check events queue loop"), asyncio.create_task(self.collect_snapshots_loop(), name="collect snapshot loop"), + asyncio.create_task(self.cleanup_recordings_loop(), name="cleanup recordings loop"), asyncio.create_task(self.heartbeat(), name="heartbeat"), ]