mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-06 06:42:51 +03:00
Add ability to delete cameras (#22336)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* refactor camera cleanup code to generic util * add api endpoint for deleting a camera * frontend * i18n * clean up
This commit is contained in:
+161
-1
@@ -1,5 +1,6 @@
|
||||
"""Camera apis."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
@@ -11,7 +12,9 @@ import httpx
|
||||
import requests
|
||||
from fastapi import APIRouter, Depends, Query, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from filelock import FileLock, Timeout
|
||||
from onvif import ONVIFCamera, ONVIFError
|
||||
from ruamel.yaml import YAML
|
||||
from zeep.exceptions import Fault, TransportError
|
||||
from zeep.transports import AsyncTransport
|
||||
|
||||
@@ -21,8 +24,14 @@ from frigate.api.auth import (
|
||||
require_role,
|
||||
)
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.config.config import FrigateConfig
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdateTopic,
|
||||
)
|
||||
from frigate.util.builtin import clean_camera_user_pass
|
||||
from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files
|
||||
from frigate.util.config import find_config_file
|
||||
from frigate.util.image import run_ffmpeg_snapshot
|
||||
from frigate.util.services import ffprobe_stream
|
||||
|
||||
@@ -995,3 +1004,154 @@ async def onvif_probe(
|
||||
await onvif_camera.close()
|
||||
except Exception as e:
|
||||
logger.debug(f"Error closing ONVIF camera session: {e}")
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/cameras/{camera_name}",
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
)
|
||||
async def delete_camera(
|
||||
request: Request,
|
||||
camera_name: str,
|
||||
delete_exports: bool = Query(default=False),
|
||||
):
|
||||
"""Delete a camera and all its associated data.
|
||||
|
||||
Removes the camera from config, stops processes, and cleans up
|
||||
all database entries and media files.
|
||||
|
||||
Args:
|
||||
camera_name: Name of the camera to delete
|
||||
delete_exports: Whether to also delete exports for this camera
|
||||
"""
|
||||
frigate_config: FrigateConfig = request.app.frigate_config
|
||||
|
||||
if camera_name not in frigate_config.cameras:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": f"Camera {camera_name} not found",
|
||||
},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
old_camera_config = frigate_config.cameras[camera_name]
|
||||
config_file = find_config_file()
|
||||
lock = FileLock(f"{config_file}.lock", timeout=5)
|
||||
|
||||
try:
|
||||
with lock:
|
||||
with open(config_file, "r") as f:
|
||||
old_raw_config = f.read()
|
||||
|
||||
try:
|
||||
yaml = YAML()
|
||||
yaml.indent(mapping=2, sequence=4, offset=2)
|
||||
|
||||
with open(config_file, "r") as f:
|
||||
data = yaml.load(f)
|
||||
|
||||
# Remove camera from config
|
||||
if "cameras" in data and camera_name in data["cameras"]:
|
||||
del data["cameras"][camera_name]
|
||||
|
||||
# Remove camera from auth roles
|
||||
auth = data.get("auth", {})
|
||||
if auth and "roles" in auth:
|
||||
empty_roles = []
|
||||
for role_name, cameras_list in auth["roles"].items():
|
||||
if (
|
||||
isinstance(cameras_list, list)
|
||||
and camera_name in cameras_list
|
||||
):
|
||||
cameras_list.remove(camera_name)
|
||||
# Custom roles can't be empty; mark for removal
|
||||
if not cameras_list and role_name not in (
|
||||
"admin",
|
||||
"viewer",
|
||||
):
|
||||
empty_roles.append(role_name)
|
||||
for role_name in empty_roles:
|
||||
del auth["roles"][role_name]
|
||||
|
||||
with open(config_file, "w") as f:
|
||||
yaml.dump(data, f)
|
||||
|
||||
with open(config_file, "r") as f:
|
||||
new_raw_config = f.read()
|
||||
|
||||
try:
|
||||
config = FrigateConfig.parse(new_raw_config)
|
||||
except Exception:
|
||||
with open(config_file, "w") as f:
|
||||
f.write(old_raw_config)
|
||||
logger.exception(
|
||||
"Config error after removing camera %s",
|
||||
camera_name,
|
||||
)
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "Error parsing config after camera removal",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error updating config to remove camera %s: %s", camera_name, e
|
||||
)
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "Error updating config",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
# Update runtime config
|
||||
request.app.frigate_config = config
|
||||
request.app.genai_manager.update_config(config)
|
||||
|
||||
# Publish removal to stop ffmpeg processes and clean up runtime state
|
||||
request.app.config_publisher.publish_update(
|
||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum.remove, camera_name),
|
||||
old_camera_config,
|
||||
)
|
||||
|
||||
except Timeout:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "Another process is currently updating the config",
|
||||
},
|
||||
status_code=409,
|
||||
)
|
||||
|
||||
# Clean up database entries
|
||||
counts, export_paths = await asyncio.to_thread(
|
||||
cleanup_camera_db, camera_name, delete_exports
|
||||
)
|
||||
|
||||
# Clean up media files in background thread
|
||||
await asyncio.to_thread(
|
||||
cleanup_camera_files, camera_name, export_paths if delete_exports else None
|
||||
)
|
||||
|
||||
# Best-effort go2rtc stream removal
|
||||
try:
|
||||
requests.delete(
|
||||
"http://127.0.0.1:1984/api/streams",
|
||||
params={"src": camera_name},
|
||||
timeout=5,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Failed to remove go2rtc stream for %s", camera_name)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": True,
|
||||
"message": f"Camera {camera_name} has been deleted",
|
||||
"cleanup": counts,
|
||||
},
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
+5
-34
@@ -21,7 +21,8 @@ from frigate.const import (
|
||||
REPLAY_DIR,
|
||||
THUMB_DIR,
|
||||
)
|
||||
from frigate.models import Event, Recordings, ReviewSegment, Timeline
|
||||
from frigate.models import Recordings
|
||||
from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files
|
||||
from frigate.util.config import find_config_file
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -357,43 +358,13 @@ class DebugReplayManager:
|
||||
|
||||
def _cleanup_db(self, camera_name: str) -> None:
|
||||
"""Defensively remove any database rows for the replay camera."""
|
||||
try:
|
||||
Event.delete().where(Event.camera == camera_name).execute()
|
||||
except Exception as e:
|
||||
logger.error("Failed to delete replay events: %s", e)
|
||||
|
||||
try:
|
||||
Timeline.delete().where(Timeline.camera == camera_name).execute()
|
||||
except Exception as e:
|
||||
logger.error("Failed to delete replay timeline: %s", e)
|
||||
|
||||
try:
|
||||
Recordings.delete().where(Recordings.camera == camera_name).execute()
|
||||
except Exception as e:
|
||||
logger.error("Failed to delete replay recordings: %s", e)
|
||||
|
||||
try:
|
||||
ReviewSegment.delete().where(ReviewSegment.camera == camera_name).execute()
|
||||
except Exception as e:
|
||||
logger.error("Failed to delete replay review segments: %s", e)
|
||||
cleanup_camera_db(camera_name)
|
||||
|
||||
def _cleanup_files(self, camera_name: str) -> None:
|
||||
"""Remove filesystem artifacts for the replay camera."""
|
||||
dirs_to_clean = [
|
||||
os.path.join(RECORD_DIR, camera_name),
|
||||
os.path.join(CLIPS_DIR, camera_name),
|
||||
os.path.join(THUMB_DIR, camera_name),
|
||||
]
|
||||
cleanup_camera_files(camera_name)
|
||||
|
||||
for dir_path in dirs_to_clean:
|
||||
if os.path.exists(dir_path):
|
||||
try:
|
||||
shutil.rmtree(dir_path)
|
||||
logger.debug("Removed replay directory: %s", dir_path)
|
||||
except Exception as e:
|
||||
logger.error("Failed to remove %s: %s", dir_path, e)
|
||||
|
||||
# Remove replay clip and any related files
|
||||
# Remove replay-specific cache directory
|
||||
if os.path.exists(REPLAY_DIR):
|
||||
try:
|
||||
shutil.rmtree(REPLAY_DIR)
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Utilities for cleaning up camera data from database and filesystem."""
|
||||
|
||||
import glob
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from frigate.const import CLIPS_DIR, RECORD_DIR, THUMB_DIR
|
||||
from frigate.models import (
|
||||
Event,
|
||||
Export,
|
||||
Previews,
|
||||
Recordings,
|
||||
Regions,
|
||||
ReviewSegment,
|
||||
Timeline,
|
||||
Trigger,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def cleanup_camera_db(
|
||||
camera_name: str, delete_exports: bool = False
|
||||
) -> tuple[dict[str, int], list[str]]:
|
||||
"""Remove all database rows for a camera.
|
||||
|
||||
Args:
|
||||
camera_name: The camera name to clean up
|
||||
delete_exports: Whether to also delete export records
|
||||
|
||||
Returns:
|
||||
Tuple of (deletion counts dict, list of export file paths to remove)
|
||||
"""
|
||||
counts: dict[str, int] = {}
|
||||
export_paths: list[str] = []
|
||||
|
||||
try:
|
||||
counts["events"] = Event.delete().where(Event.camera == camera_name).execute()
|
||||
except Exception as e:
|
||||
logger.error("Failed to delete events for camera %s: %s", camera_name, e)
|
||||
|
||||
try:
|
||||
counts["timeline"] = (
|
||||
Timeline.delete().where(Timeline.camera == camera_name).execute()
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to delete timeline for camera %s: %s", camera_name, e)
|
||||
|
||||
try:
|
||||
counts["recordings"] = (
|
||||
Recordings.delete().where(Recordings.camera == camera_name).execute()
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to delete recordings for camera %s: %s", camera_name, e)
|
||||
|
||||
try:
|
||||
counts["review_segments"] = (
|
||||
ReviewSegment.delete().where(ReviewSegment.camera == camera_name).execute()
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to delete review segments for camera %s: %s", camera_name, e
|
||||
)
|
||||
|
||||
try:
|
||||
counts["previews"] = (
|
||||
Previews.delete().where(Previews.camera == camera_name).execute()
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to delete previews for camera %s: %s", camera_name, e)
|
||||
|
||||
try:
|
||||
counts["regions"] = (
|
||||
Regions.delete().where(Regions.camera == camera_name).execute()
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to delete regions for camera %s: %s", camera_name, e)
|
||||
|
||||
try:
|
||||
counts["triggers"] = (
|
||||
Trigger.delete().where(Trigger.camera == camera_name).execute()
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to delete triggers for camera %s: %s", camera_name, e)
|
||||
|
||||
if delete_exports:
|
||||
try:
|
||||
exports = Export.select(Export.video_path, Export.thumb_path).where(
|
||||
Export.camera == camera_name
|
||||
)
|
||||
for export in exports:
|
||||
export_paths.append(export.video_path)
|
||||
export_paths.append(export.thumb_path)
|
||||
|
||||
counts["exports"] = (
|
||||
Export.delete().where(Export.camera == camera_name).execute()
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to delete exports for camera %s: %s", camera_name, e)
|
||||
|
||||
return counts, export_paths
|
||||
|
||||
|
||||
def cleanup_camera_files(
|
||||
camera_name: str, export_paths: list[str] | None = None
|
||||
) -> None:
|
||||
"""Remove filesystem artifacts for a camera.
|
||||
|
||||
Args:
|
||||
camera_name: The camera name to clean up
|
||||
export_paths: Optional list of export file paths to remove
|
||||
"""
|
||||
dirs_to_clean = [
|
||||
os.path.join(RECORD_DIR, camera_name),
|
||||
os.path.join(CLIPS_DIR, camera_name),
|
||||
os.path.join(THUMB_DIR, camera_name),
|
||||
os.path.join(CLIPS_DIR, "previews", camera_name),
|
||||
]
|
||||
|
||||
for dir_path in dirs_to_clean:
|
||||
if os.path.exists(dir_path):
|
||||
try:
|
||||
shutil.rmtree(dir_path)
|
||||
logger.debug("Removed directory: %s", dir_path)
|
||||
except Exception as e:
|
||||
logger.error("Failed to remove %s: %s", dir_path, e)
|
||||
|
||||
# Remove event snapshot files
|
||||
for snapshot in glob.glob(os.path.join(CLIPS_DIR, f"{camera_name}-*.jpg")):
|
||||
try:
|
||||
os.remove(snapshot)
|
||||
except Exception as e:
|
||||
logger.error("Failed to remove snapshot %s: %s", snapshot, e)
|
||||
|
||||
# Remove review thumbnail files
|
||||
for thumb in glob.glob(
|
||||
os.path.join(CLIPS_DIR, "review", f"thumb-{camera_name}-*.webp")
|
||||
):
|
||||
try:
|
||||
os.remove(thumb)
|
||||
except Exception as e:
|
||||
logger.error("Failed to remove review thumbnail %s: %s", thumb, e)
|
||||
|
||||
# Remove export files if requested
|
||||
if export_paths:
|
||||
for path in export_paths:
|
||||
if path and os.path.exists(path):
|
||||
try:
|
||||
os.remove(path)
|
||||
logger.debug("Removed export file: %s", path)
|
||||
except Exception as e:
|
||||
logger.error("Failed to remove export file %s: %s", path, e)
|
||||
Reference in New Issue
Block a user