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: media:
path: /media path: /media
max_size: 25 # per recording, in MB; default is 25 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 media_source: media-source://media_source/local/videos

@ -12,7 +12,7 @@ import threading
from types import FrameType from types import FrameType
import yaml import yaml
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any, cast from typing import TYPE_CHECKING, Any, cast
if TYPE_CHECKING: if TYPE_CHECKING:
@ -186,7 +186,12 @@ class HelpersMixin:
if os.path.exists(media_path) and os.access(media_path, os.W_OK): if os.path.exists(media_path) and os.access(media_path, os.W_OK):
media["path"] = media_path media["path"] = media_path
media.setdefault("max_size", 25) 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: else:
self.logger.info("media_path not configured, not found, or is not writable. Will not be saving recordings") 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 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: def handle_signal(self: Amcrest2Mqtt, signum: int, _: FrameType | None) -> Any:
sig_name = signal.Signals(signum).name sig_name = signal.Signals(signum).name
self.logger.warning(f"{sig_name} received - stopping service loop") self.logger.warning(f"{sig_name} received - stopping service loop")

@ -54,6 +54,15 @@ class LoopsMixin:
self.logger.debug("heartbeat cancelled during sleep") self.logger.debug("heartbeat cancelled during sleep")
break 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 # main loop
async def main_loop(self: Amcrest2Mqtt) -> None: async def main_loop(self: Amcrest2Mqtt) -> None:
for sig in (signal.SIGTERM, signal.SIGINT): 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.collect_events_loop(), name="collect events loop"),
asyncio.create_task(self.check_event_queue_loop(), name="check events queue 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.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"), asyncio.create_task(self.heartbeat(), name="heartbeat"),
] ]

Loading…
Cancel
Save