mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-01-22 12:08:29 +03:00
Media sync API refactor and UI (#21542)
* generic job infrastructure * types and dispatcher changes for jobs * save data in memory only for completed jobs * implement media sync job and endpoints * change logs to debug * websocket hook and types * frontend * i18n * docs tweaks * endpoint descriptions * tweak docs
This commit is contained in:
parent
a77b0a7c4b
commit
f1a19128ed
@ -158,29 +158,9 @@ Apple devices running the Safari browser may fail to playback h.265 recordings.
|
||||
|
||||
Media files (event snapshots, event thumbnails, review thumbnails, previews, exports, and recordings) can become orphaned when database entries are deleted but the corresponding files remain on disk.
|
||||
|
||||
This feature checks the file system for media files and removes any that are not referenced in the database.
|
||||
Normal operation may leave small numbers of orphaned files until Frigate's scheduled cleanup, but crashes, configuration changes, or upgrades may cause more orphaned files that Frigate does not clean up. This feature checks the file system for media files and removes any that are not referenced in the database.
|
||||
|
||||
The API endpoint `POST /api/media/sync` can be used to trigger a media sync. The endpoint accepts a JSON request body to control the operation.
|
||||
|
||||
Request body schema (JSON):
|
||||
|
||||
```json
|
||||
{
|
||||
"dry_run": true,
|
||||
"media_types": ["all"],
|
||||
"force": false
|
||||
}
|
||||
```
|
||||
|
||||
- `dry_run` (boolean): If `true` (default) the service will only report orphaned files without deleting them. Set to `false` to allow deletions.
|
||||
- `media_types` (array of strings): Which media types to sync. Use `"all"` to sync everything, or a list of one or more of:
|
||||
- `event_snapshots`
|
||||
- `event_thumbnails`
|
||||
- `review_thumbnails`
|
||||
- `previews`
|
||||
- `exports`
|
||||
- `recordings`
|
||||
- `force` (boolean): If `true` the safety threshold is bypassed and deletions proceed even if the operation would remove a large proportion of files. Use with extreme caution.
|
||||
The Maintenance pane in the Frigate UI or an API endpoint `POST /api/media/sync` can be used to trigger a media sync. When using the API, a job ID is returned and the operation continues on the server. Status can be checked with the `/api/media/sync/status/{job_id}` endpoint.
|
||||
|
||||
:::warning
|
||||
|
||||
|
||||
53
docs/static/frigate-api.yaml
vendored
53
docs/static/frigate-api.yaml
vendored
@ -326,6 +326,59 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/HTTPValidationError"
|
||||
/media/sync:
|
||||
post:
|
||||
tags:
|
||||
- App
|
||||
summary: Start media sync job
|
||||
description: |-
|
||||
Start an asynchronous media sync job to find and (optionally) remove orphaned media files.
|
||||
Returns 202 with job details when queued, or 409 if a job is already running.
|
||||
operationId: sync_media_media_sync_post
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
responses:
|
||||
"202":
|
||||
description: Accepted - Job queued
|
||||
"409":
|
||||
description: Conflict - Job already running
|
||||
"422":
|
||||
description: Validation Error
|
||||
|
||||
/media/sync/current:
|
||||
get:
|
||||
tags:
|
||||
- App
|
||||
summary: Get current media sync job
|
||||
description: |-
|
||||
Retrieve the current running media sync job, if any. Returns the job details or null when no job is active.
|
||||
operationId: get_media_sync_current_media_sync_current_get
|
||||
responses:
|
||||
"200":
|
||||
description: Successful Response
|
||||
"422":
|
||||
description: Validation Error
|
||||
|
||||
/media/sync/status/{job_id}:
|
||||
get:
|
||||
tags:
|
||||
- App
|
||||
summary: Get media sync job status
|
||||
description: |-
|
||||
Get status and results for the specified media sync job id. Returns 200 with job details including results, or 404 if the job is not found.
|
||||
operationId: get_media_sync_status_media_sync_status__job_id__get
|
||||
parameters:
|
||||
- name: job_id
|
||||
in: path
|
||||
responses:
|
||||
"200":
|
||||
description: Successful Response
|
||||
"404":
|
||||
description: Not Found - Job not found
|
||||
"422":
|
||||
description: Validation Error
|
||||
/faces/train/{name}/classify:
|
||||
post:
|
||||
tags:
|
||||
|
||||
@ -33,8 +33,14 @@ from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateTopic,
|
||||
)
|
||||
from frigate.ffmpeg_presets import FFMPEG_HWACCEL_VAAPI, _gpu_selector
|
||||
from frigate.jobs.media_sync import (
|
||||
get_current_media_sync_job,
|
||||
get_media_sync_job_by_id,
|
||||
start_media_sync_job,
|
||||
)
|
||||
from frigate.models import Event, Timeline
|
||||
from frigate.stats.prometheus import get_metrics, update_metrics
|
||||
from frigate.types import JobStatusTypesEnum
|
||||
from frigate.util.builtin import (
|
||||
clean_camera_user_pass,
|
||||
flatten_config_data,
|
||||
@ -42,7 +48,6 @@ from frigate.util.builtin import (
|
||||
update_yaml_file_bulk,
|
||||
)
|
||||
from frigate.util.config import find_config_file
|
||||
from frigate.util.media import sync_all_media
|
||||
from frigate.util.services import (
|
||||
get_nvidia_driver_info,
|
||||
process_logs,
|
||||
@ -603,12 +608,19 @@ def restart():
|
||||
)
|
||||
|
||||
|
||||
@router.post("/media/sync", dependencies=[Depends(require_role(["admin"]))])
|
||||
@router.post(
|
||||
"/media/sync",
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Start media sync job",
|
||||
description="""Start an asynchronous media sync job to find and (optionally) remove orphaned media files.
|
||||
Returns 202 with job details when queued, or 409 if a job is already running.""",
|
||||
)
|
||||
def sync_media(body: MediaSyncBody = Body(...)):
|
||||
"""Sync media files with database - remove orphaned files.
|
||||
"""Start async media sync job - remove orphaned files.
|
||||
|
||||
Syncs specified media types: event snapshots, event thumbnails, review thumbnails,
|
||||
previews, exports, and/or recordings.
|
||||
previews, exports, and/or recordings. Job runs in background; use /media/sync/current
|
||||
or /media/sync/status/{job_id} to check status.
|
||||
|
||||
Args:
|
||||
body: MediaSyncBody with dry_run flag and media_types list.
|
||||
@ -616,54 +628,77 @@ def sync_media(body: MediaSyncBody = Body(...)):
|
||||
'review_thumbnails', 'previews', 'exports', 'recordings'
|
||||
|
||||
Returns:
|
||||
JSON response with sync results for each requested media type.
|
||||
202 Accepted with job_id, or 409 Conflict if job already running.
|
||||
"""
|
||||
try:
|
||||
results = sync_all_media(
|
||||
dry_run=body.dry_run, media_types=body.media_types, force=body.force
|
||||
)
|
||||
job_id = start_media_sync_job(
|
||||
dry_run=body.dry_run, media_types=body.media_types, force=body.force
|
||||
)
|
||||
|
||||
# Check if any operations were aborted or had errors
|
||||
has_errors = False
|
||||
for result_name in [
|
||||
"event_snapshots",
|
||||
"event_thumbnails",
|
||||
"review_thumbnails",
|
||||
"previews",
|
||||
"exports",
|
||||
"recordings",
|
||||
]:
|
||||
result = getattr(results, result_name, None)
|
||||
if result and (result.aborted or result.error):
|
||||
has_errors = True
|
||||
break
|
||||
|
||||
content = {
|
||||
"success": not has_errors,
|
||||
"dry_run": body.dry_run,
|
||||
"media_types": body.media_types,
|
||||
"results": results.to_dict(),
|
||||
}
|
||||
|
||||
if has_errors:
|
||||
content["message"] = (
|
||||
"Some sync operations were aborted or had errors; check logs for details."
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content=content,
|
||||
status_code=200,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error syncing media files: {e}")
|
||||
if job_id is None:
|
||||
# A job is already running
|
||||
current = get_current_media_sync_job()
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": f"Error syncing media files: {str(e)}",
|
||||
"error": "A media sync job is already running",
|
||||
"current_job_id": current.id if current else None,
|
||||
},
|
||||
status_code=500,
|
||||
status_code=409,
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"job": {
|
||||
"job_type": "media_sync",
|
||||
"status": JobStatusTypesEnum.queued,
|
||||
"id": job_id,
|
||||
}
|
||||
},
|
||||
status_code=202,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/media/sync/current",
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Get current media sync job",
|
||||
description="""Retrieve the current running media sync job, if any. Returns the job details
|
||||
or null when no job is active.""",
|
||||
)
|
||||
def get_media_sync_current():
|
||||
"""Get the current running media sync job, if any."""
|
||||
job = get_current_media_sync_job()
|
||||
|
||||
if job is None:
|
||||
return JSONResponse(content={"job": None}, status_code=200)
|
||||
|
||||
return JSONResponse(
|
||||
content={"job": job.to_dict()},
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/media/sync/status/{job_id}",
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Get media sync job status",
|
||||
description="""Get status and results for the specified media sync job id. Returns 200 with
|
||||
job details including results, or 404 if the job is not found.""",
|
||||
)
|
||||
def get_media_sync_status(job_id: str):
|
||||
"""Get the status of a specific media sync job."""
|
||||
job = get_media_sync_job_by_id(job_id)
|
||||
|
||||
if job is None:
|
||||
return JSONResponse(
|
||||
content={"error": "Job not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content={"job": job.to_dict()},
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/labels", dependencies=[Depends(allow_any_authenticated())])
|
||||
def get_labels(camera: str = ""):
|
||||
|
||||
@ -28,6 +28,7 @@ from frigate.const import (
|
||||
UPDATE_CAMERA_ACTIVITY,
|
||||
UPDATE_EMBEDDINGS_REINDEX_PROGRESS,
|
||||
UPDATE_EVENT_DESCRIPTION,
|
||||
UPDATE_JOB_STATE,
|
||||
UPDATE_MODEL_STATE,
|
||||
UPDATE_REVIEW_DESCRIPTION,
|
||||
UPSERT_REVIEW_SEGMENT,
|
||||
@ -60,6 +61,7 @@ class Dispatcher:
|
||||
self.camera_activity = CameraActivityManager(config, self.publish)
|
||||
self.audio_activity = AudioActivityManager(config, self.publish)
|
||||
self.model_state: dict[str, ModelStatusTypesEnum] = {}
|
||||
self.job_state: dict[str, dict[str, Any]] = {} # {job_type: job_data}
|
||||
self.embeddings_reindex: dict[str, Any] = {}
|
||||
self.birdseye_layout: dict[str, Any] = {}
|
||||
self.audio_transcription_state: str = "idle"
|
||||
@ -180,6 +182,19 @@ class Dispatcher:
|
||||
def handle_model_state() -> None:
|
||||
self.publish("model_state", json.dumps(self.model_state.copy()))
|
||||
|
||||
def handle_update_job_state() -> None:
|
||||
if payload and isinstance(payload, dict):
|
||||
job_type = payload.get("job_type")
|
||||
if job_type:
|
||||
self.job_state[job_type] = payload
|
||||
self.publish(
|
||||
"job_state",
|
||||
json.dumps(self.job_state),
|
||||
)
|
||||
|
||||
def handle_job_state() -> None:
|
||||
self.publish("job_state", json.dumps(self.job_state.copy()))
|
||||
|
||||
def handle_update_audio_transcription_state() -> None:
|
||||
if payload:
|
||||
self.audio_transcription_state = payload
|
||||
@ -277,6 +292,7 @@ class Dispatcher:
|
||||
UPDATE_EVENT_DESCRIPTION: handle_update_event_description,
|
||||
UPDATE_REVIEW_DESCRIPTION: handle_update_review_description,
|
||||
UPDATE_MODEL_STATE: handle_update_model_state,
|
||||
UPDATE_JOB_STATE: handle_update_job_state,
|
||||
UPDATE_EMBEDDINGS_REINDEX_PROGRESS: handle_update_embeddings_reindex_progress,
|
||||
UPDATE_BIRDSEYE_LAYOUT: handle_update_birdseye_layout,
|
||||
UPDATE_AUDIO_TRANSCRIPTION_STATE: handle_update_audio_transcription_state,
|
||||
@ -284,6 +300,7 @@ class Dispatcher:
|
||||
"restart": handle_restart,
|
||||
"embeddingsReindexProgress": handle_embeddings_reindex_progress,
|
||||
"modelState": handle_model_state,
|
||||
"jobState": handle_job_state,
|
||||
"audioTranscriptionState": handle_audio_transcription_state,
|
||||
"birdseyeLayout": handle_birdseye_layout,
|
||||
"onConnect": handle_on_connect,
|
||||
|
||||
@ -119,6 +119,7 @@ UPDATE_REVIEW_DESCRIPTION = "update_review_description"
|
||||
UPDATE_MODEL_STATE = "update_model_state"
|
||||
UPDATE_EMBEDDINGS_REINDEX_PROGRESS = "handle_embeddings_reindex_progress"
|
||||
UPDATE_BIRDSEYE_LAYOUT = "update_birdseye_layout"
|
||||
UPDATE_JOB_STATE = "update_job_state"
|
||||
NOTIFICATION_TEST = "notification_test"
|
||||
|
||||
# IO Nice Values
|
||||
|
||||
0
frigate/jobs/__init__.py
Normal file
0
frigate/jobs/__init__.py
Normal file
21
frigate/jobs/job.py
Normal file
21
frigate/jobs/job.py
Normal file
@ -0,0 +1,21 @@
|
||||
"""Generic base class for long-running background jobs."""
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class Job:
|
||||
"""Base class for long-running background jobs."""
|
||||
|
||||
id: str = field(default_factory=lambda: __import__("uuid").uuid4().__str__()[:12])
|
||||
job_type: str = "" # Must be set by subclasses
|
||||
status: str = "queued" # queued, running, success, failed, cancelled
|
||||
results: Optional[dict[str, Any]] = None
|
||||
start_time: Optional[float] = None
|
||||
end_time: Optional[float] = None
|
||||
error_message: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to dictionary for WebSocket transmission."""
|
||||
return asdict(self)
|
||||
70
frigate/jobs/manager.py
Normal file
70
frigate/jobs/manager.py
Normal file
@ -0,0 +1,70 @@
|
||||
"""Generic job management for long-running background tasks."""
|
||||
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
from frigate.jobs.job import Job
|
||||
from frigate.types import JobStatusTypesEnum
|
||||
|
||||
# Global state and locks for enforcing single concurrent job per job type
|
||||
_job_locks: dict[str, threading.Lock] = {}
|
||||
_current_jobs: dict[str, Optional[Job]] = {}
|
||||
# Keep completed jobs for retrieval, keyed by (job_type, job_id)
|
||||
_completed_jobs: dict[tuple[str, str], Job] = {}
|
||||
|
||||
|
||||
def _get_lock(job_type: str) -> threading.Lock:
|
||||
"""Get or create a lock for the specified job type."""
|
||||
if job_type not in _job_locks:
|
||||
_job_locks[job_type] = threading.Lock()
|
||||
return _job_locks[job_type]
|
||||
|
||||
|
||||
def set_current_job(job: Job) -> None:
|
||||
"""Set the current job for a given job type."""
|
||||
lock = _get_lock(job.job_type)
|
||||
with lock:
|
||||
# Store the previous job if it was completed
|
||||
old_job = _current_jobs.get(job.job_type)
|
||||
if old_job and old_job.status in (
|
||||
JobStatusTypesEnum.success,
|
||||
JobStatusTypesEnum.failed,
|
||||
JobStatusTypesEnum.cancelled,
|
||||
):
|
||||
_completed_jobs[(job.job_type, old_job.id)] = old_job
|
||||
_current_jobs[job.job_type] = job
|
||||
|
||||
|
||||
def clear_current_job(job_type: str, job_id: Optional[str] = None) -> None:
|
||||
"""Clear the current job for a given job type, optionally checking the ID."""
|
||||
lock = _get_lock(job_type)
|
||||
with lock:
|
||||
if job_type in _current_jobs:
|
||||
current = _current_jobs[job_type]
|
||||
if current is None or (job_id is None or current.id == job_id):
|
||||
_current_jobs[job_type] = None
|
||||
|
||||
|
||||
def get_current_job(job_type: str) -> Optional[Job]:
|
||||
"""Get the current running/queued job for a given job type, if any."""
|
||||
lock = _get_lock(job_type)
|
||||
with lock:
|
||||
return _current_jobs.get(job_type)
|
||||
|
||||
|
||||
def get_job_by_id(job_type: str, job_id: str) -> Optional[Job]:
|
||||
"""Get job by ID. Checks current job first, then completed jobs."""
|
||||
lock = _get_lock(job_type)
|
||||
with lock:
|
||||
# Check if it's the current job
|
||||
current = _current_jobs.get(job_type)
|
||||
if current and current.id == job_id:
|
||||
return current
|
||||
# Check if it's a completed job
|
||||
return _completed_jobs.get((job_type, job_id))
|
||||
|
||||
|
||||
def job_is_running(job_type: str) -> bool:
|
||||
"""Check if a job of the given type is currently running or queued."""
|
||||
job = get_current_job(job_type)
|
||||
return job is not None and job.status in ("queued", "running")
|
||||
135
frigate/jobs/media_sync.py
Normal file
135
frigate/jobs/media_sync.py
Normal file
@ -0,0 +1,135 @@
|
||||
"""Media sync job management with background execution."""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.const import UPDATE_JOB_STATE
|
||||
from frigate.jobs.job import Job
|
||||
from frigate.jobs.manager import (
|
||||
get_current_job,
|
||||
get_job_by_id,
|
||||
job_is_running,
|
||||
set_current_job,
|
||||
)
|
||||
from frigate.types import JobStatusTypesEnum
|
||||
from frigate.util.media import sync_all_media
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MediaSyncJob(Job):
|
||||
"""In-memory job state for media sync operations."""
|
||||
|
||||
job_type: str = "media_sync"
|
||||
dry_run: bool = False
|
||||
media_types: list[str] = field(default_factory=lambda: ["all"])
|
||||
force: bool = False
|
||||
|
||||
|
||||
class MediaSyncRunner(threading.Thread):
|
||||
"""Thread-based runner for media sync jobs."""
|
||||
|
||||
def __init__(self, job: MediaSyncJob) -> None:
|
||||
super().__init__(daemon=True, name="media_sync")
|
||||
self.job = job
|
||||
self.requestor = InterProcessRequestor()
|
||||
|
||||
def run(self) -> None:
|
||||
"""Execute the media sync job and broadcast status updates."""
|
||||
try:
|
||||
# Update job status to running
|
||||
self.job.status = JobStatusTypesEnum.running
|
||||
self.job.start_time = datetime.now().timestamp()
|
||||
self._broadcast_status()
|
||||
|
||||
# Execute sync with provided parameters
|
||||
logger.debug(
|
||||
f"Starting media sync job {self.job.id}: "
|
||||
f"media_types={self.job.media_types}, "
|
||||
f"dry_run={self.job.dry_run}, "
|
||||
f"force={self.job.force}"
|
||||
)
|
||||
|
||||
results = sync_all_media(
|
||||
dry_run=self.job.dry_run,
|
||||
media_types=self.job.media_types,
|
||||
force=self.job.force,
|
||||
)
|
||||
|
||||
# Store results and mark as complete
|
||||
self.job.results = results.to_dict()
|
||||
self.job.status = JobStatusTypesEnum.success
|
||||
self.job.end_time = datetime.now().timestamp()
|
||||
|
||||
logger.debug(f"Media sync job {self.job.id} completed successfully")
|
||||
self._broadcast_status()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Media sync job {self.job.id} failed: {e}", exc_info=True)
|
||||
self.job.status = JobStatusTypesEnum.failed
|
||||
self.job.error_message = str(e)
|
||||
self.job.end_time = datetime.now().timestamp()
|
||||
self._broadcast_status()
|
||||
|
||||
finally:
|
||||
if self.requestor:
|
||||
self.requestor.stop()
|
||||
|
||||
def _broadcast_status(self) -> None:
|
||||
"""Broadcast job status update via IPC to all WebSocket subscribers."""
|
||||
try:
|
||||
self.requestor.send_data(
|
||||
UPDATE_JOB_STATE,
|
||||
self.job.to_dict(),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to broadcast media sync status: {e}")
|
||||
|
||||
|
||||
def start_media_sync_job(
|
||||
dry_run: bool = False,
|
||||
media_types: Optional[list[str]] = None,
|
||||
force: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""Start a new media sync job if none is currently running.
|
||||
|
||||
Returns job ID on success, None if job already running.
|
||||
"""
|
||||
# Check if a job is already running
|
||||
if job_is_running("media_sync"):
|
||||
current = get_current_job("media_sync")
|
||||
logger.warning(
|
||||
f"Media sync job {current.id} is already running. Rejecting new request."
|
||||
)
|
||||
return None
|
||||
|
||||
# Create and start new job
|
||||
job = MediaSyncJob(
|
||||
dry_run=dry_run,
|
||||
media_types=media_types or ["all"],
|
||||
force=force,
|
||||
)
|
||||
|
||||
logger.debug(f"Creating new media sync job: {job.id}")
|
||||
set_current_job(job)
|
||||
|
||||
# Start the background runner
|
||||
runner = MediaSyncRunner(job)
|
||||
runner.start()
|
||||
|
||||
return job.id
|
||||
|
||||
|
||||
def get_current_media_sync_job() -> Optional[MediaSyncJob]:
|
||||
"""Get the current running/queued media sync job, if any."""
|
||||
return get_current_job("media_sync")
|
||||
|
||||
|
||||
def get_media_sync_job_by_id(job_id: str) -> Optional[MediaSyncJob]:
|
||||
"""Get media sync job by ID. Currently only tracks the current job."""
|
||||
return get_job_by_id("media_sync", job_id)
|
||||
@ -26,6 +26,15 @@ class ModelStatusTypesEnum(str, Enum):
|
||||
failed = "failed"
|
||||
|
||||
|
||||
class JobStatusTypesEnum(str, Enum):
|
||||
pending = "pending"
|
||||
queued = "queued"
|
||||
running = "running"
|
||||
success = "success"
|
||||
failed = "failed"
|
||||
cancelled = "cancelled"
|
||||
|
||||
|
||||
class TrackedObjectUpdateTypesEnum(str, Enum):
|
||||
description = "description"
|
||||
face = "face"
|
||||
|
||||
@ -1065,5 +1065,53 @@
|
||||
"deleteTriggerFailed": "Failed to delete trigger: {{errorMessage}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"maintenance": {
|
||||
"title": "Maintenance",
|
||||
"sync": {
|
||||
"title": "Media Sync",
|
||||
"desc": "Frigate will periodically clean up media on a regular schedule according to your retention configuration. It is normal to see a few orphaned files as Frigate runs. Use this feature to remove orphaned media files from disk that are no longer referenced in the database.",
|
||||
"started": "Media sync started.",
|
||||
"alreadyRunning": "A sync job is already running",
|
||||
"error": "Failed to start sync",
|
||||
"currentStatus": "Status",
|
||||
"jobId": "Job ID",
|
||||
"startTime": "Start Time",
|
||||
"endTime": "End Time",
|
||||
"statusLabel": "Status",
|
||||
"results": "Results",
|
||||
"errorLabel": "Error",
|
||||
"mediaTypes": "Media Types",
|
||||
"allMedia": "All Media",
|
||||
"dryRun": "Dry Run",
|
||||
"dryRunEnabled": "No files will be deleted",
|
||||
"dryRunDisabled": "Files will be deleted",
|
||||
"force": "Force",
|
||||
"forceDesc": "Bypass safety threshold and complete sync even if more than 50% of the files would be deleted.",
|
||||
"running": "Sync Running...",
|
||||
"start": "Start Sync",
|
||||
"inProgress": "Sync is in progress. This page is disabled.",
|
||||
"status": {
|
||||
"queued": "Queued",
|
||||
"running": "Running",
|
||||
"completed": "Completed",
|
||||
"failed": "Failed",
|
||||
"notRunning": "Not Running"
|
||||
},
|
||||
"resultsFields": {
|
||||
"filesChecked": "Files Checked",
|
||||
"orphansFound": "Orphans Found",
|
||||
"orphansDeleted": "Orphans Deleted",
|
||||
"aborted": "Aborted. Deletion would exceed safety threshold.",
|
||||
"error": "Error",
|
||||
"totals": "Totals"
|
||||
},
|
||||
"event_snapshots": "Tracked Object Snapshots",
|
||||
"event_thumbnails": "Tracked Object Thumbnails",
|
||||
"review_thumbnails": "Review Thumbnails",
|
||||
"previews": "Previews",
|
||||
"exports": "Exports",
|
||||
"recordings": "Recordings"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
TrackedObjectUpdateReturnType,
|
||||
TriggerStatus,
|
||||
FrigateAudioDetections,
|
||||
Job,
|
||||
} from "@/types/ws";
|
||||
import { FrigateStats } from "@/types/stats";
|
||||
import { createContainer } from "react-tracked";
|
||||
@ -651,3 +652,40 @@ export function useTriggers(): { payload: TriggerStatus } {
|
||||
: { name: "", camera: "", event_id: "", type: "", score: 0 };
|
||||
return { payload: useDeepMemo(parsed) };
|
||||
}
|
||||
|
||||
export function useJobStatus(
|
||||
jobType: string,
|
||||
revalidateOnFocus: boolean = true,
|
||||
): { payload: Job | null } {
|
||||
const {
|
||||
value: { payload },
|
||||
send: sendCommand,
|
||||
} = useWs("job_state", "jobState");
|
||||
|
||||
const jobData = useDeepMemo(
|
||||
payload && typeof payload === "string" ? JSON.parse(payload) : {},
|
||||
);
|
||||
const currentJob = jobData[jobType] || null;
|
||||
|
||||
useEffect(() => {
|
||||
let listener: (() => void) | undefined;
|
||||
if (revalidateOnFocus) {
|
||||
sendCommand("jobState");
|
||||
listener = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
sendCommand("jobState");
|
||||
}
|
||||
};
|
||||
addEventListener("visibilitychange", listener);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (listener) {
|
||||
removeEventListener("visibilitychange", listener);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [revalidateOnFocus]);
|
||||
|
||||
return { payload: currentJob as Job | null };
|
||||
}
|
||||
|
||||
@ -36,6 +36,7 @@ import NotificationView from "@/views/settings/NotificationsSettingsView";
|
||||
import EnrichmentsSettingsView from "@/views/settings/EnrichmentsSettingsView";
|
||||
import UiSettingsView from "@/views/settings/UiSettingsView";
|
||||
import FrigatePlusSettingsView from "@/views/settings/FrigatePlusSettingsView";
|
||||
import MaintenanceSettingsView from "@/views/settings/MaintenanceSettingsView";
|
||||
import { useSearchEffect } from "@/hooks/use-overlay-state";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useInitialCameraState } from "@/api/ws";
|
||||
@ -81,6 +82,7 @@ const allSettingsViews = [
|
||||
"roles",
|
||||
"notifications",
|
||||
"frigateplus",
|
||||
"maintenance",
|
||||
] as const;
|
||||
type SettingsType = (typeof allSettingsViews)[number];
|
||||
|
||||
@ -120,6 +122,10 @@ const settingsGroups = [
|
||||
label: "frigateplus",
|
||||
items: [{ key: "frigateplus", component: FrigatePlusSettingsView }],
|
||||
},
|
||||
{
|
||||
label: "maintenance",
|
||||
items: [{ key: "maintenance", component: MaintenanceSettingsView }],
|
||||
},
|
||||
];
|
||||
|
||||
const CAMERA_SELECT_BUTTON_PAGES = [
|
||||
|
||||
@ -126,3 +126,32 @@ export type TriggerStatus = {
|
||||
type: string;
|
||||
score: number;
|
||||
};
|
||||
|
||||
export type MediaSyncStats = {
|
||||
files_checked: number;
|
||||
orphans_found: number;
|
||||
orphans_deleted: number;
|
||||
aborted: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export type MediaSyncTotals = {
|
||||
files_checked: number;
|
||||
orphans_found: number;
|
||||
orphans_deleted: number;
|
||||
};
|
||||
|
||||
export type MediaSyncResults = {
|
||||
[mediaType: string]: MediaSyncStats | MediaSyncTotals;
|
||||
totals: MediaSyncTotals;
|
||||
};
|
||||
|
||||
export type Job = {
|
||||
id: string;
|
||||
job_type: string;
|
||||
status: string;
|
||||
results?: MediaSyncResults;
|
||||
start_time?: number;
|
||||
end_time?: number;
|
||||
error_message?: string;
|
||||
};
|
||||
|
||||
442
web/src/views/settings/MaintenanceSettingsView.tsx
Normal file
442
web/src/views/settings/MaintenanceSettingsView.tsx
Normal file
@ -0,0 +1,442 @@
|
||||
import Heading from "@/components/ui/heading";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import { useJobStatus } from "@/api/ws";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { LuCheck, LuX } from "react-icons/lu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||
import { MediaSyncStats } from "@/types/ws";
|
||||
|
||||
export default function MaintenanceSettingsView() {
|
||||
const { t } = useTranslation("views/settings");
|
||||
const [selectedMediaTypes, setSelectedMediaTypes] = useState<string[]>([
|
||||
"all",
|
||||
]);
|
||||
const [dryRun, setDryRun] = useState(true);
|
||||
const [force, setForce] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const MEDIA_TYPES = [
|
||||
{ id: "event_snapshots", label: t("maintenance.sync.event_snapshots") },
|
||||
{ id: "event_thumbnails", label: t("maintenance.sync.event_thumbnails") },
|
||||
{ id: "review_thumbnails", label: t("maintenance.sync.review_thumbnails") },
|
||||
{ id: "previews", label: t("maintenance.sync.previews") },
|
||||
{ id: "exports", label: t("maintenance.sync.exports") },
|
||||
{ id: "recordings", label: t("maintenance.sync.recordings") },
|
||||
];
|
||||
|
||||
// Subscribe to media sync status via WebSocket
|
||||
const { payload: currentJob } = useJobStatus("media_sync");
|
||||
|
||||
const isJobRunning = Boolean(
|
||||
currentJob &&
|
||||
(currentJob.status === "queued" || currentJob.status === "running"),
|
||||
);
|
||||
|
||||
const handleMediaTypeChange = useCallback((id: string, checked: boolean) => {
|
||||
setSelectedMediaTypes((prev) => {
|
||||
if (id === "all") {
|
||||
return checked ? ["all"] : [];
|
||||
}
|
||||
|
||||
let next = prev.filter((t) => t !== "all");
|
||||
if (checked) {
|
||||
next.push(id);
|
||||
} else {
|
||||
next = next.filter((t) => t !== id);
|
||||
}
|
||||
return next.length === 0 ? ["all"] : next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleStartSync = useCallback(async () => {
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
"/media/sync",
|
||||
{
|
||||
dry_run: dryRun,
|
||||
media_types: selectedMediaTypes,
|
||||
force: force,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (response.status === 202) {
|
||||
toast.success(t("maintenance.sync.started"), {
|
||||
position: "top-center",
|
||||
closeButton: true,
|
||||
});
|
||||
} else if (response.status === 409) {
|
||||
toast.error(t("maintenance.sync.alreadyRunning"), {
|
||||
position: "top-center",
|
||||
closeButton: true,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("maintenance.sync.error"), {
|
||||
position: "top-center",
|
||||
closeButton: true,
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [selectedMediaTypes, dryRun, force, t]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex size-full flex-col md:flex-row">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto px-2 md:order-none">
|
||||
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="col-span-1">
|
||||
<Heading as="h4" className="mb-2">
|
||||
{t("maintenance.sync.title")}
|
||||
</Heading>
|
||||
|
||||
<div className="max-w-6xl">
|
||||
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
|
||||
<p>{t("maintenance.sync.desc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Media Types Selection */}
|
||||
<div>
|
||||
<Label className="mb-2 flex flex-row items-center text-base">
|
||||
{t("maintenance.sync.mediaTypes")}
|
||||
</Label>
|
||||
<div className="max-w-md space-y-2 rounded-lg bg-secondary p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label
|
||||
htmlFor="all-media"
|
||||
className="cursor-pointer font-medium"
|
||||
>
|
||||
{t("maintenance.sync.allMedia")}
|
||||
</Label>
|
||||
<Switch
|
||||
id="all-media"
|
||||
checked={selectedMediaTypes.includes("all")}
|
||||
onCheckedChange={(checked) =>
|
||||
handleMediaTypeChange("all", checked)
|
||||
}
|
||||
disabled={isJobRunning}
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-4 space-y-2">
|
||||
{MEDIA_TYPES.map((type) => (
|
||||
<div
|
||||
key={type.id}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<Label htmlFor={type.id} className="cursor-pointer">
|
||||
{type.label}
|
||||
</Label>
|
||||
<Switch
|
||||
id={type.id}
|
||||
checked={
|
||||
selectedMediaTypes.includes("all") ||
|
||||
selectedMediaTypes.includes(type.id)
|
||||
}
|
||||
onCheckedChange={(checked) =>
|
||||
handleMediaTypeChange(type.id, checked)
|
||||
}
|
||||
disabled={
|
||||
isJobRunning || selectedMediaTypes.includes("all")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-row items-center">
|
||||
<Switch
|
||||
id="dry-run"
|
||||
className="mr-3"
|
||||
checked={dryRun}
|
||||
onCheckedChange={setDryRun}
|
||||
disabled={isJobRunning}
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="dry-run" className="cursor-pointer">
|
||||
{t("maintenance.sync.dryRun")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dryRun
|
||||
? t("maintenance.sync.dryRunEnabled")
|
||||
: t("maintenance.sync.dryRunDisabled")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-row items-center">
|
||||
<Switch
|
||||
id="force"
|
||||
className="mr-3"
|
||||
checked={force}
|
||||
onCheckedChange={setForce}
|
||||
disabled={isJobRunning}
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="force" className="cursor-pointer">
|
||||
{t("maintenance.sync.force")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("maintenance.sync.forceDesc")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[50%]">
|
||||
<Button
|
||||
onClick={handleStartSync}
|
||||
disabled={isJobRunning || isSubmitting}
|
||||
className="flex flex-1"
|
||||
variant={"select"}
|
||||
>
|
||||
{(isSubmitting || isJobRunning) && (
|
||||
<ActivityIndicator className="mr-2 size-6" />
|
||||
)}
|
||||
{isJobRunning
|
||||
? t("maintenance.sync.running")
|
||||
: t("maintenance.sync.start")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-1">
|
||||
<div className="mt-4 gap-2 space-y-3 md:mt-8">
|
||||
<Separator className="my-2 flex bg-secondary md:hidden" />
|
||||
<div className="flex flex-row items-center justify-between rounded-lg bg-card p-3 md:mr-2">
|
||||
<Heading as="h4" className="my-2">
|
||||
{t("maintenance.sync.currentStatus")}
|
||||
</Heading>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-row items-center gap-2",
|
||||
currentJob?.status === "success" && "text-green-500",
|
||||
currentJob?.status === "failed" && "text-destructive",
|
||||
)}
|
||||
>
|
||||
{currentJob?.status === "success" && (
|
||||
<LuCheck className="size-5" />
|
||||
)}
|
||||
{currentJob?.status === "failed" && (
|
||||
<LuX className="size-5" />
|
||||
)}
|
||||
{(currentJob?.status === "running" ||
|
||||
currentJob?.status === "queued") && (
|
||||
<ActivityIndicator className="size-5" />
|
||||
)}
|
||||
{t(
|
||||
`maintenance.sync.status.${currentJob?.status ?? "notRunning"}`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Job Status */}
|
||||
<div className="space-y-2 text-sm">
|
||||
{currentJob?.start_time && (
|
||||
<div className="flex gap-1">
|
||||
<span className="text-muted-foreground">
|
||||
{t("maintenance.sync.startTime")}:
|
||||
</span>
|
||||
<span className="font-mono">
|
||||
{formatUnixTimestampToDateTime(
|
||||
currentJob?.start_time ?? "-",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{currentJob?.end_time && (
|
||||
<div className="flex gap-1">
|
||||
<span className="text-muted-foreground">
|
||||
{t("maintenance.sync.endTime")}:
|
||||
</span>
|
||||
<span className="font-mono">
|
||||
{formatUnixTimestampToDateTime(currentJob?.end_time)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{currentJob?.results && (
|
||||
<div className="mt-2 space-y-2 md:mr-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{t("maintenance.sync.results")}
|
||||
</p>
|
||||
<div className="rounded-md border border-secondary">
|
||||
{/* Individual media type results */}
|
||||
<div className="divide-y divide-secondary">
|
||||
{Object.entries(currentJob.results)
|
||||
.filter(([key]) => key !== "totals")
|
||||
.map(([mediaType, stats]) => {
|
||||
const mediaStats = stats as MediaSyncStats;
|
||||
return (
|
||||
<div key={mediaType} className="p-3 pb-3">
|
||||
<p className="mb-1 font-medium capitalize">
|
||||
{t(`maintenance.sync.${mediaType}`)}
|
||||
</p>
|
||||
<div className="ml-2 space-y-0.5">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{t(
|
||||
"maintenance.sync.resultsFields.filesChecked",
|
||||
)}
|
||||
</span>
|
||||
<span>{mediaStats.files_checked}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{t(
|
||||
"maintenance.sync.resultsFields.orphansFound",
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
mediaStats.orphans_found > 0
|
||||
? "text-yellow-500"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{mediaStats.orphans_found}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{t(
|
||||
"maintenance.sync.resultsFields.orphansDeleted",
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground",
|
||||
mediaStats.orphans_deleted > 0 &&
|
||||
"text-success",
|
||||
mediaStats.orphans_deleted === 0 &&
|
||||
mediaStats.aborted &&
|
||||
"text-destructive",
|
||||
)}
|
||||
>
|
||||
{mediaStats.orphans_deleted}
|
||||
</span>
|
||||
</div>
|
||||
{mediaStats.aborted && (
|
||||
<div className="flex items-center gap-2 text-destructive">
|
||||
<LuX className="size-4" />
|
||||
|
||||
{t(
|
||||
"maintenance.sync.resultsFields.aborted",
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{mediaStats.error && (
|
||||
<div className="text-destructive">
|
||||
{t(
|
||||
"maintenance.sync.resultsFields.error",
|
||||
)}
|
||||
{": "}
|
||||
{mediaStats.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Totals */}
|
||||
{currentJob.results.totals && (
|
||||
<div className="border-t border-secondary bg-background_alt p-3">
|
||||
<p className="mb-1 font-medium">
|
||||
{t("maintenance.sync.resultsFields.totals")}
|
||||
</p>
|
||||
<div className="ml-2 space-y-0.5">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{t(
|
||||
"maintenance.sync.resultsFields.filesChecked",
|
||||
)}
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{currentJob.results.totals.files_checked}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{t(
|
||||
"maintenance.sync.resultsFields.orphansFound",
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
currentJob.results.totals.orphans_found > 0
|
||||
? "font-medium text-yellow-500"
|
||||
: "font-medium"
|
||||
}
|
||||
>
|
||||
{currentJob.results.totals.orphans_found}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{t(
|
||||
"maintenance.sync.resultsFields.orphansDeleted",
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-medium",
|
||||
currentJob.results.totals.orphans_deleted >
|
||||
0
|
||||
? "text-success"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{currentJob.results.totals.orphans_deleted}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{currentJob?.error_message && (
|
||||
<div className="text-destructive">
|
||||
<p className="text-muted-foreground">
|
||||
{t("maintenance.sync.errorLabel")}
|
||||
</p>
|
||||
<p>{currentJob?.error_message}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user