feat: add automatic cleanup of old media recordings

Add configurable retention period for saved clips (default: 7 days).
Recordings older than the retention period are automatically deleted
once per day. Dangling symlinks are also cleaned up when their target
files are removed.

Configure via `media.retention_days` in config.yaml or
`MEDIA_RETENTION_DAYS` environment variable. Set to 0 to disable.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
pull/106/head
Jeff Culverhouse 1 month ago
parent c8bd5a193b
commit ebe8d04332

@ -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

@ -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")

@ -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"),
]

Loading…
Cancel
Save