Skip to content

Instantly share code, notes, and snippets.

@dannysauer
Last active January 3, 2026 07:45
Show Gist options
  • Select an option

  • Save dannysauer/315bdd49f34133e1b6365aa360ccb20b to your computer and use it in GitHub Desktop.

Select an option

Save dannysauer/315bdd49f34133e1b6365aa360ccb20b to your computer and use it in GitHub Desktop.
Frigate data dir cleanup

Frigate Data Cleanup Script

A Python script to identify and clean up orphaned files in Frigate's data directory that are not referenced in the database.

This was generated by AI, and should be inspected carefully before running. I've used it successfully, but your mileage will vary.

Use at your own risk.

Background

This script addresses the issue described in frigate#21095 where files may remain in the Frigate data directory after database resets or other operations, consuming storage space without being tracked by Frigate.

Features

  • Safe by default: Summary mode shows what would be deleted without actually deleting anything
  • Detailed reporting: Shows file counts and sizes by camera
  • Verbose mode: Lists all files with timestamps and sizes
  • Database-aware: Only identifies files that are truly orphaned by checking against the Frigate database
  • Multiple file types: Handles recordings, snapshots/clips, and exports

Requirements

  • Python 3.10 or higher
  • Read access to Frigate's database (frigate.db)
  • Read/write access to Frigate's data directory

Installation

# Make the script executable
chmod +x frigate_cleanup.py

# Or install with pip (optional)
pip install -e .

Usage

Basic summary (safe, no deletion)

./frigate_cleanup.py /media/frigate

Show verbose file listings

./frigate_cleanup.py /media/frigate --verbose

Show only unowned files

./frigate_cleanup.py /media/frigate --unowned-only

Show verbose unowned files only

./frigate_cleanup.py /media/frigate --verbose --unowned-only

Actually delete unowned files (USE WITH CAUTION)

./frigate_cleanup.py /media/frigate --delete

Custom database path

./frigate_cleanup.py /media/frigate --db-path /config/frigate.db

File Classification

The script identifies files in three categories:

  1. Owned files (in database):

    • Recordings referenced in the recordings table
    • Snapshots/clips referenced in the events table
    • All files in the exports directory (always kept)
  2. Unowned files (NOT in database):

    • Recording segments not in the database
    • Snapshot/clip files not in the database
    • These are candidates for deletion
  3. Ignored directories:

    • The exports directory is always considered "owned" and never deleted

Safety Features

  • Read-only database access: Uses SQLite's read-only mode
  • Confirmation prompt: Requires typing "yes" before deletion
  • Detailed reporting: Shows exactly what will be deleted before doing it
  • No automatic deletion: Must explicitly use --delete flag
  • Preserves owned files: Only unowned files are ever deleted

Output Format

Summary Mode (Default)

Owned Files (in database):
----------------------------------------------------------------------
  camera_name         :    123 files, 45.67 GB

Unowned Files (NOT in database - candidates for deletion):
----------------------------------------------------------------------
  camera_name         :     45 files, 12.34 GB
  unowned             :      5 files, 123.45 MB

  Total               :     50 files, 12.46 GB

Verbose Mode

camera_name (45 files, 12.34 GB):
----------------------------------------------------------------------------------
  2024-12-01 10:30:15    45.67 MB  /media/frigate/recordings/2024-12-01/10/camera_name/30.15.mp4
  2024-12-01 10:31:25    46.23 MB  /media/frigate/recordings/2024-12-01/10/camera_name/31.25.mp4
  ...

Development

Code Quality Tools

The script is configured to pass:

  • black: Code formatting (90 char line length)
  • ruff: Fast Python linting
  • flake8: Style guide enforcement (90 char line length)
  • mypy: Static type checking

Run all checks:

black --check --line-length 90 frigate_cleanup.py
ruff check frigate_cleanup.py
flake8 --max-line-length 90 frigate_cleanup.py
mypy --strict frigate_cleanup.py

Auto-format code:

black --line-length 90 frigate_cleanup.py

Docker Usage

If running Frigate in Docker, you can use this script from the host:

# Find your Frigate data volume
docker volume inspect frigate_media

# Run the script with the mounted path
./frigate_cleanup.py /path/to/docker/volume

Or run it inside the container:

# Copy script into container
docker cp frigate_cleanup.py frigate:/tmp/

# Execute in container
docker exec -it frigate python3 /tmp/frigate_cleanup.py /media/frigate

Database Structure

The script queries these SQLite tables:

  • recordings: Contains path column with relative paths to recording segments
  • events: Contains thumbnail, has_snapshot, camera, and id columns for snapshot files

Caveats

  • The script assumes the standard Frigate directory structure:
    • recordings/YYYY-MM-DD/HH/<camera_name>/MM.SS.mp4
    • clips/<camera>/<event_id>.jpg
    • exports/* (always preserved)
  • Files being actively written may appear as "unowned" until they're committed to the database
  • Consider stopping Frigate before running with --delete to avoid race conditions

License

MIT

Frigate Cleanup - Usage Examples

Quick Start

# 1. Make the script executable
chmod +x frigate_cleanup.py

# 2. Run in summary mode (safe, shows what would be deleted)
./frigate_cleanup.py /media/frigate

# 3. Review the output, then delete if satisfied
./frigate_cleanup.py /media/frigate --delete

Common Scenarios

Scenario 1: Just checking what's orphaned

# Show summary of owned vs unowned files
./frigate_cleanup.py /srv/frigate/frigate/data

Expected Output:

Data directory: /srv/frigate/frigate/data
Database path: /srv/frigate/frigate/config/frigate.db

Reading database...
Found 45678 recordings and 2341 snapshots in database

Scanning filesystem...

Owned Files (in database):
----------------------------------------------------------------------
  front_door          :  12345 files, 456.78 GB
  back_yard           :   8901 files, 234.56 GB
  driveway            :   5678 files, 123.45 GB

  Total               :  26924 files, 814.79 GB

Unowned Files (NOT in database - candidates for deletion):
----------------------------------------------------------------------
  front_door          :   1234 files, 45.67 GB
  back_yard           :    567 files, 12.34 GB
  old_camera          :   8901 files, 234.56 GB

  Total               :  10702 files, 292.57 GB

Run with --delete to remove 10702 unowned files

Scenario 2: See detailed file listings

# Show all files with timestamps (oldest first)
./frigate_cleanup.py /srv/frigate/frigate/data --verbose

Expected Output:

...

Unowned Files (Detailed - Candidates for Deletion)
==========================================================================================

front_door (1234 files, 45.67 GB):
------------------------------------------------------------------------------------------
  2024-11-01 10:30:15    45.67 MB  /media/frigate/recordings/2024-11-01/10/front_door/30.15.mp4
  2024-11-01 10:31:25    46.23 MB  /media/frigate/recordings/2024-11-01/10/front_door/31.25.mp4
  2024-11-01 10:32:35    44.89 MB  /media/frigate/recordings/2024-11-01/10/front_door/32.35.mp4
  ...

Scenario 3: Only show unowned files

# Skip the owned files section entirely
./frigate_cleanup.py /srv/frigate/frigate/data --unowned-only

Scenario 4: Detailed unowned files only

# Combine --verbose and --unowned-only for a focused report
./frigate_cleanup.py /srv/frigate/frigate/data --verbose --unowned-only

Scenario 5: Custom database location

# If your database is in a non-standard location
./frigate_cleanup.py /media/frigate --db-path /db/frigate.db

Scenario 6: Running from Docker host

# Find your Frigate volumes
docker volume ls | grep frigate

# Inspect to get the mount point
docker volume inspect frigate_media

# Use the Mountpoint path
./frigate_cleanup.py /var/lib/docker/volumes/frigate_media/_data

Scenario 7: Running inside Docker container

# Copy script into running container
docker cp frigate_cleanup.py frigate:/tmp/

# Execute inside container
docker exec -it frigate python3 /tmp/frigate_cleanup.py /media/frigate

# Or with options
docker exec -it frigate python3 /tmp/frigate_cleanup.py \
  /media/frigate --verbose --unowned-only

Scenario 8: Actual deletion (CAUTION!)

# First, do a dry run
./frigate_cleanup.py /media/frigate --verbose --unowned-only

# Review the output carefully, then delete
./frigate_cleanup.py /media/frigate --delete

Expected Output:

Data directory: /media/frigate
Database path: /config/frigate.db

Reading database...
Found 45678 recordings and 2341 snapshots in database

Scanning filesystem...

Unowned Files (NOT in database - candidates for deletion):
----------------------------------------------------------------------
  front_door          :   1234 files, 45.67 GB
  back_yard           :    567 files, 12.34 GB
  old_camera          :   8901 files, 234.56 GB

  Total               :  10702 files, 292.57 GB

Are you sure you want to delete 10702 unowned files? (yes/no): yes

Deleting 10702 files (292.57 GB)...

Deleted 10702 files (292.57 GB)

Best Practices

1. Stop Frigate before deletion

# Stop Frigate to avoid race conditions
docker stop frigate

# Run cleanup
./frigate_cleanup.py /media/frigate --delete

# Restart Frigate
docker start frigate

2. Test with a small subset first

# Run in verbose mode to see specific files
./frigate_cleanup.py /media/frigate --verbose --unowned-only

# Manually delete a few files to verify they're truly orphaned
# Then run the full cleanup

3. Backup before major cleanups

# Backup the database
cp /config/frigate.db /config/frigate.db.backup

# Run cleanup
./frigate_cleanup.py /media/frigate --delete

4. Schedule regular checks

# Add to crontab to check weekly (no deletion)
0 2 * * 0 /path/to/frigate_cleanup.py /media/frigate --unowned-only >> /var/log/frigate_cleanup.log

Troubleshooting

Database not found

Error: Database not found at /config/frigate.db

Solution: Specify the database path explicitly:

./frigate_cleanup.py /media/frigate --db-path /path/to/frigate.db

Permission denied

Error reading database: unable to open database file

Solution: Run with appropriate permissions:

sudo ./frigate_cleanup.py /media/frigate
# Or
docker exec -u root -it frigate python3 /tmp/frigate_cleanup.py /media/frigate

No unowned files found (but you know there are some)

Possible causes:

  1. Database path is wrong
  2. Data directory path is wrong
  3. Files are being actively written (stop Frigate first)

Verification:

# Check database has recordings
sqlite3 /config/frigate.db "SELECT COUNT(*) FROM recordings"

# Check filesystem has files
find /media/frigate/recordings -name "*.mp4" | wc -l

Advanced Usage

Generate a report for analysis

# Redirect output to a file
./frigate_cleanup.py /media/frigate --verbose --unowned-only > cleanup_report.txt

# Review the report
less cleanup_report.txt

Compare multiple runs

# Before database reset
./frigate_cleanup.py /media/frigate > before.txt

# After database reset
./frigate_cleanup.py /media/frigate > after.txt

# Compare
diff before.txt after.txt

Filter output for specific cameras

# Use grep to focus on specific cameras
./frigate_cleanup.py /media/frigate --verbose --unowned-only | grep -A 50 "front_door"

Integration with Home Assistant

Notify when cleanup is needed

# In Home Assistant configuration.yaml
shell_command:
  frigate_cleanup_check: >
    /config/scripts/frigate_cleanup.py /media/frigate --unowned-only

sensor:
  - platform: command_line
    name: Frigate Orphaned Files
    command: "/config/scripts/frigate_cleanup.py /media/frigate --unowned-only | grep 'Total' | awk '{print $4}'"
    unit_of_measurement: "files"
    scan_interval: 3600

automation:
  - alias: Notify Frigate Cleanup Needed
    trigger:
      - platform: numeric_state
        entity_id: sensor.frigate_orphaned_files
        above: 1000
    action:
      - service: notify.mobile_app
        data:
          message: "Frigate has {{ states('sensor.frigate_orphaned_files') }} orphaned files to clean up"
#!/usr/bin/env python3
"""
Frigate Data Cleanup Script
Identifies and optionally removes orphaned files in Frigate's data
directory that are not referenced in the database.
Usage:
frigate_cleanup.py <data_dir> [options]
Options:
-v, --verbose Show detailed file listings
--unowned-only Show only unowned files
--delete Actually delete unowned files
--db-path PATH Path to frigate.db (default: <data_dir>/../config/frigate.db) # noqa: E501
"""
import argparse
import os
import sqlite3
import sys
from collections import defaultdict
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Set, Tuple
@dataclass
class FileInfo:
"""Information about a file on disk."""
path: Path
size: int
mtime: datetime
camera: str | None
def format_size(size_bytes: int) -> str:
"""Format bytes as human-readable size."""
for unit in ["B", "KB", "MB", "GB", "TB"]:
if size_bytes < 1024.0:
return f"{size_bytes:.2f} {unit}"
size_bytes /= 1024.0
return f"{size_bytes:.2f} PB"
def get_db_recordings(db_path: Path) -> Set[str]:
"""Get all recording paths from the database."""
if not db_path.exists():
print(f"Error: Database not found at {db_path}", file=sys.stderr)
sys.exit(1)
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
cursor = conn.cursor()
try:
# Get all paths from recordings table
cursor.execute("SELECT path FROM recordings")
paths = {row[0] for row in cursor.fetchall()}
except sqlite3.OperationalError as e:
print(f"Error reading database: {e}", file=sys.stderr)
sys.exit(1)
finally:
conn.close()
return paths
def get_db_snapshots(db_path: Path) -> Set[str]:
"""Get all snapshot/thumbnail paths from the database."""
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
cursor = conn.cursor()
paths = set()
try:
# Get thumbnail paths from events
cursor.execute("SELECT thumbnail FROM events WHERE thumbnail IS NOT NULL") # noqa: E501
for row in cursor.fetchall():
if row[0]:
paths.add(row[0])
# Get snapshot paths from events
cursor.execute(
"SELECT has_snapshot, camera, id FROM events WHERE has_snapshot = 1"
)
for row in cursor.fetchall():
# Snapshots are stored as clips/<camera>/<event_id>.jpg
event_id = row[2]
camera = row[1]
paths.add(f"clips/{camera}/{event_id}.jpg")
# Also check for clean snapshots
paths.add(f"clips/{camera}/{event_id}-clean.png")
except sqlite3.OperationalError:
pass # Table might not exist in older versions
finally:
conn.close()
return paths
def scan_directory(
data_dir: Path, db_recordings: Set[str], db_snapshots: Set[str]
) -> Tuple[List[FileInfo], List[FileInfo]]:
"""Scan data directory and classify files as owned or unowned."""
owned_files: List[FileInfo] = []
unowned_files: List[FileInfo] = []
# Scan recordings directory
recordings_dir = data_dir / "recordings"
if recordings_dir.exists():
for root, _, files in os.walk(recordings_dir):
for filename in files:
filepath = Path(root) / filename
rel_path = filepath.relative_to(data_dir)
rel_path_str = str(rel_path)
# Extract camera name from path structure
# recordings/YYYY-MM-DD/HH/<camera_name>/MM.SS.mp4
parts = rel_path.parts
camera = parts[3] if len(parts) >= 5 else None
stat = filepath.stat()
file_info = FileInfo(
path=filepath,
size=stat.st_size,
mtime=datetime.fromtimestamp(stat.st_mtime),
camera=camera,
)
if rel_path_str in db_recordings:
owned_files.append(file_info)
else:
unowned_files.append(file_info)
# Scan clips/snapshots directory
clips_dir = data_dir / "clips"
if clips_dir.exists():
for root, _, files in os.walk(clips_dir):
for filename in files:
filepath = Path(root) / filename
rel_path = filepath.relative_to(data_dir)
rel_path_str = str(rel_path)
# Extract camera name from clips/<camera>/<file>
parts = rel_path.parts
camera = parts[1] if len(parts) >= 3 else None
stat = filepath.stat()
file_info = FileInfo(
path=filepath,
size=stat.st_size,
mtime=datetime.fromtimestamp(stat.st_mtime),
camera=camera,
)
if rel_path_str in db_snapshots:
owned_files.append(file_info)
else:
unowned_files.append(file_info)
# Scan exports directory (these are always owned/wanted)
exports_dir = data_dir / "exports"
if exports_dir.exists():
for root, _, files in os.walk(exports_dir):
for filename in files:
filepath = Path(root) / filename
stat = filepath.stat()
file_info = FileInfo(
path=filepath,
size=stat.st_size,
mtime=datetime.fromtimestamp(stat.st_mtime),
camera=None, # Exports don't belong to specific cameras
)
owned_files.append(file_info)
return owned_files, unowned_files
def group_by_camera(files: List[FileInfo]) -> Dict[str, List[FileInfo]]:
"""Group files by camera name."""
grouped: Dict[str, List[FileInfo]] = defaultdict(list)
for file_info in files:
camera = file_info.camera if file_info.camera else "unowned"
grouped[camera].append(file_info)
return dict(grouped)
def print_summary(
owned_files: List[FileInfo],
unowned_files: List[FileInfo],
unowned_only: bool,
) -> None:
"""Print summary of files by camera."""
if not unowned_only:
owned_by_camera = group_by_camera(owned_files)
print("Owned Files (in database):")
print("-" * 70)
total_size = 0
for camera in sorted(owned_by_camera.keys()):
files = owned_by_camera[camera]
camera_size = sum(f.size for f in files)
total_size += camera_size
print(
f" {camera:20s}: {len(files):6d} files, {format_size(camera_size)}" # noqa: E501
)
print(f"\n {'Total':20s}: {len(owned_files):6d} files, {format_size(total_size)}") # noqa: E501
print()
# Print unowned files summary
unowned_by_camera = group_by_camera(unowned_files)
print("Unowned Files (NOT in database - candidates for deletion):")
print("-" * 70)
total_size = 0
for camera in sorted(unowned_by_camera.keys()):
files = unowned_by_camera[camera]
camera_size = sum(f.size for f in files)
total_size += camera_size
print(
f" {camera:20s}: {len(files):6d} files, {format_size(camera_size)}"
)
print(
f"\n {'Total':20s}: {len(unowned_files):6d} files, {format_size(total_size)}" # noqa: E501
)
def print_verbose(files: List[FileInfo], title: str) -> None:
"""Print detailed file listing."""
print(f"\n{title}")
print("=" * 90)
files_by_camera = group_by_camera(files)
for camera in sorted(files_by_camera.keys()):
camera_files = files_by_camera[camera]
# Sort by modification time (oldest first)
camera_files.sort(key=lambda f: f.mtime)
camera_size = sum(f.size for f in camera_files)
print(
f"\n{camera} ({len(camera_files)} files, {format_size(camera_size)}):" # noqa: E501
)
print("-" * 90)
for file_info in camera_files:
mtime_str = file_info.mtime.strftime("%Y-%m-%d %H:%M:%S")
print(
f" {mtime_str} {format_size(file_info.size):>12s} {file_info.path}" # noqa: E501
)
def delete_files(files: List[FileInfo]) -> None:
"""Delete the specified files."""
if not files:
print("No files to delete.")
return
total_size = sum(f.size for f in files)
print(f"\nDeleting {len(files)} files ({format_size(total_size)})...")
deleted_count = 0
deleted_size = 0
failed_count = 0
for file_info in files:
try:
file_info.path.unlink()
deleted_count += 1
deleted_size += file_info.size
except OSError as e:
print(f"Error deleting {file_info.path}: {e}", file=sys.stderr)
failed_count += 1
print(
f"\nDeleted {deleted_count} files ({format_size(deleted_size)})"
)
if failed_count > 0:
print(f"Failed to delete {failed_count} files", file=sys.stderr)
def main() -> None:
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Clean up orphaned Frigate data files",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"data_dir", type=Path, help="Path to Frigate data directory"
)
parser.add_argument(
"-v",
"--verbose",
action="store_true",
help="Show detailed file listings",
)
parser.add_argument(
"--unowned-only",
action="store_true",
help="Show only unowned files",
)
parser.add_argument(
"--delete",
action="store_true",
help="Actually delete unowned files (USE WITH CAUTION)",
)
parser.add_argument(
"--db-path",
type=Path,
help="Path to frigate.db (default: <data_dir>/../config/frigate.db)",
)
args = parser.parse_args()
# Validate data directory
if not args.data_dir.exists():
print(
f"Error: Data directory not found: {args.data_dir}",
file=sys.stderr,
)
sys.exit(1)
# Determine database path
if args.db_path:
db_path = args.db_path
else:
# Default: assume data is at /media/frigate, db at /config/frigate.db
db_path = args.data_dir.parent / "config" / "frigate.db"
print(f"Data directory: {args.data_dir}")
print(f"Database path: {db_path}")
print()
# Get database contents
print("Reading database...")
db_recordings = get_db_recordings(db_path)
db_snapshots = get_db_snapshots(db_path)
print(
f"Found {len(db_recordings)} recordings and {len(db_snapshots)} snapshots in database" # noqa: E501
)
print()
# Scan filesystem
print("Scanning filesystem...")
owned_files, unowned_files = scan_directory(
args.data_dir, db_recordings, db_snapshots
)
print()
# Print summary
print_summary(owned_files, unowned_files, args.unowned_only)
# Print verbose listing if requested
if args.verbose:
if not args.unowned_only:
print_verbose(owned_files, "Owned Files (Detailed)")
print_verbose(
unowned_files, "Unowned Files (Detailed - Candidates for Deletion)"
)
# Delete if requested
if args.delete:
print()
response = input(
f"Are you sure you want to delete {len(unowned_files)} unowned files? (yes/no): " # noqa: E501
)
if response.lower() == "yes":
delete_files(unowned_files)
else:
print("Deletion cancelled.")
elif unowned_files:
print(
f"\nRun with --delete to remove {len(unowned_files)} unowned files"
)
if __name__ == "__main__":
main()
[build-system]
requires = ["setuptools>=75.8.0", "wheel>=0.45.1"]
build-backend = "setuptools.build_meta"
[project]
name = "frigate-cleanup"
version = "1.0.0"
description = "Clean up orphaned Frigate data files"
readme = "README.md"
requires-python = ">=3.10"
license = {text = "MIT"}
authors = [
{name = "Danny Sauer"},
]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: System Administrators",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
# No runtime dependencies - uses only stdlib
dependencies = []
[project.scripts]
frigate-cleanup = "frigate_cleanup:main"
[tool.setuptools]
py-modules = ["frigate_cleanup"]
[tool.black]
line-length = 90
target-version = ["py310", "py311", "py312"]
[tool.ruff]
line-length = 90
target-version = "py310"
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"UP", # pyupgrade
]
ignore = []
[tool.mypy]
python_version = "3.10"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
strict_equality = true

Frigate Cleanup - Quick Reference

Installation

chmod +x frigate_cleanup.py

Basic Commands

Command Description
./frigate_cleanup.py <data_dir> Show summary (safe)
./frigate_cleanup.py <data_dir> -v Show detailed listings
./frigate_cleanup.py <data_dir> --unowned-only Show only orphans
./frigate_cleanup.py <data_dir> --delete DELETE orphans
./frigate_cleanup.py <data_dir> --db-path <path> Custom DB location

Safety Checklist

  • Backup database: cp frigate.db frigate.db.backup
  • Stop Frigate: docker stop frigate
  • Run in summary mode first
  • Review verbose output: --verbose --unowned-only
  • Verify files are truly orphaned
  • Run deletion: --delete
  • Restart Frigate: docker start frigate

What Gets Deleted

Will be deleted:

  • Recording segments NOT in recordings table
  • Snapshots NOT in events table
  • Files from cameras no longer configured

Will NEVER be deleted:

  • Files referenced in database
  • All files in exports/ directory
  • Actively written files (if Frigate is stopped)

Common Paths

Environment Data Dir Database
Docker default /media/frigate /config/frigate.db
HA Add-on /media /config/frigate.db
Custom mount Check docker-compose Usually /config/frigate.db

Example Workflow

# 1. Check what you have
./frigate_cleanup.py /media/frigate

# 2. See details
./frigate_cleanup.py /media/frigate -v --unowned-only

# 3. Stop Frigate
docker stop frigate

# 4. Delete orphans
./frigate_cleanup.py /media/frigate --delete

# 5. Start Frigate
docker start frigate

Troubleshooting

Problem Solution
Database not found Use --db-path /path/to/frigate.db
Permission denied Run with sudo or inside container
No unowned files shown Check paths are correct
Files still there after delete Check permissions / file locks

Support

#!/usr/bin/env python3
"""Test script for frigate_cleanup.py"""
import shutil
import sqlite3
import subprocess
import tempfile
from datetime import datetime, timedelta
from pathlib import Path
def create_test_environment() -> Path:
"""Create a test environment with database and files."""
# Create temporary directory structure
base_dir = Path(tempfile.mkdtemp(prefix="frigate_test_"))
config_dir = base_dir / "config"
data_dir = base_dir / "data"
config_dir.mkdir()
data_dir.mkdir()
# Create database
db_path = config_dir / "frigate.db"
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# Create tables
cursor.execute(
"""
CREATE TABLE recordings (
id INTEGER PRIMARY KEY,
camera TEXT NOT NULL,
path TEXT NOT NULL UNIQUE,
start_time REAL NOT NULL,
end_time REAL NOT NULL,
duration REAL NOT NULL,
motion INTEGER,
objects INTEGER,
segment_size INTEGER NOT NULL
)
"""
)
cursor.execute(
"""
CREATE TABLE events (
id TEXT PRIMARY KEY,
camera TEXT NOT NULL,
start_time REAL NOT NULL,
end_time REAL,
thumbnail TEXT,
has_snapshot INTEGER,
label TEXT
)
"""
)
conn.commit()
# Create test recordings directory structure
base_time = datetime.now() - timedelta(days=1)
# Camera 1: Some owned, some unowned
cam1_dir = data_dir / "recordings" / base_time.strftime("%Y-%m-%d") / "10" / "front_door" # noqa: E501
cam1_dir.mkdir(parents=True)
# Owned recordings (in database)
for minute in range(0, 5):
filename = f"{minute:02d}.00.mp4"
filepath = cam1_dir / filename
filepath.write_text("test data")
rel_path = f"recordings/{base_time.strftime('%Y-%m-%d')}/10/front_door/{filename}" # noqa: E501
cursor.execute(
"""
INSERT INTO recordings (camera, path, start_time, end_time,
duration, segment_size)
VALUES (?, ?, ?, ?, ?, ?)
""",
("front_door", rel_path, base_time.timestamp(), base_time.timestamp() + 60, 60, 1024), # noqa: E501
)
# Unowned recordings (NOT in database)
for minute in range(5, 10):
filename = f"{minute:02d}.00.mp4"
filepath = cam1_dir / filename
filepath.write_text("orphaned data")
# Camera 2: All owned
cam2_dir = data_dir / "recordings" / base_time.strftime("%Y-%m-%d") / "11" / "back_yard" # noqa: E501
cam2_dir.mkdir(parents=True)
for minute in range(0, 3):
filename = f"{minute:02d}.00.mp4"
filepath = cam2_dir / filename
filepath.write_text("test data")
rel_path = f"recordings/{base_time.strftime('%Y-%m-%d')}/11/back_yard/{filename}" # noqa: E501
cursor.execute(
"""
INSERT INTO recordings (camera, path, start_time, end_time,
duration, segment_size)
VALUES (?, ?, ?, ?, ?, ?)
""",
("back_yard", rel_path, base_time.timestamp(), base_time.timestamp() + 60, 60, 1024), # noqa: E501
)
# Create clips directory
clips_dir = data_dir / "clips" / "front_door"
clips_dir.mkdir(parents=True)
# Owned snapshot
event_id = "test-event-1"
snapshot_path = clips_dir / f"{event_id}.jpg"
snapshot_path.write_text("snapshot data")
cursor.execute(
"""
INSERT INTO events (id, camera, start_time, has_snapshot, label)
VALUES (?, ?, ?, ?, ?)
""",
(event_id, "front_door", base_time.timestamp(), 1, "person"),
)
# Unowned snapshot
orphan_snapshot = clips_dir / "orphan-event.jpg"
orphan_snapshot.write_text("orphaned snapshot")
# Create exports directory (always owned)
exports_dir = data_dir / "exports"
exports_dir.mkdir()
export_file = exports_dir / "export_2024.mp4"
export_file.write_text("export data")
conn.commit()
conn.close()
print(f"Created test environment in {base_dir}")
print(f"Database: {db_path}")
print(f"Data: {data_dir}")
return base_dir
def run_tests(base_dir: Path) -> bool:
"""Run tests and verify results."""
data_dir = base_dir / "data"
db_path = base_dir / "config" / "frigate.db"
print("\n" + "=" * 70)
print("TEST 1: Summary mode (default)")
print("=" * 70)
result = subprocess.run(
["python3", "frigate_cleanup.py", str(data_dir), "--db-path", str(db_path)], # noqa: E501
cwd="/home/claude",
capture_output=True,
text=True,
)
print(result.stdout)
if result.returncode != 0:
print(f"ERROR: {result.stderr}")
return False
# Should show 5 unowned recordings + 1 unowned snapshot = 6 total
if "6 files" not in result.stdout:
print("ERROR: Expected 6 unowned files")
return False
print("\n" + "=" * 70)
print("TEST 2: Verbose mode")
print("=" * 70)
result = subprocess.run(
[
"python3",
"frigate_cleanup.py",
str(data_dir),
"--db-path",
str(db_path),
"--verbose",
],
cwd="/home/claude",
capture_output=True,
text=True,
)
print(result.stdout)
if result.returncode != 0:
print(f"ERROR: {result.stderr}")
return False
print("\n" + "=" * 70)
print("TEST 3: Unowned only")
print("=" * 70)
result = subprocess.run(
[
"python3",
"frigate_cleanup.py",
str(data_dir),
"--db-path",
str(db_path),
"--unowned-only",
],
cwd="/home/claude",
capture_output=True,
text=True,
)
print(result.stdout)
if result.returncode != 0:
print(f"ERROR: {result.stderr}")
return False
# Should NOT show owned files section
if "Owned Files" in result.stdout:
print("ERROR: Should not show owned files with --unowned-only")
return False
print("\n" + "=" * 70)
print("All tests passed!")
print("=" * 70)
return True
def main() -> None:
"""Run the test suite."""
print("Setting up test environment...")
base_dir = create_test_environment()
try:
success = run_tests(base_dir)
if not success:
print("\n❌ Tests FAILED")
exit(1)
else:
print("\n✅ All tests PASSED")
finally:
# Cleanup
print(f"\nCleaning up test environment: {base_dir}")
shutil.rmtree(base_dir)
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment