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:
Josh Hawkins 2026-01-06 09:20:19 -06:00 committed by GitHub
parent a77b0a7c4b
commit f1a19128ed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 951 additions and 67 deletions

View File

@ -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. 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. 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.
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.
:::warning :::warning

View File

@ -326,6 +326,59 @@ paths:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/HTTPValidationError" $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: /faces/train/{name}/classify:
post: post:
tags: tags:

View File

@ -33,8 +33,14 @@ from frigate.config.camera.updater import (
CameraConfigUpdateTopic, CameraConfigUpdateTopic,
) )
from frigate.ffmpeg_presets import FFMPEG_HWACCEL_VAAPI, _gpu_selector 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.models import Event, Timeline
from frigate.stats.prometheus import get_metrics, update_metrics from frigate.stats.prometheus import get_metrics, update_metrics
from frigate.types import JobStatusTypesEnum
from frigate.util.builtin import ( from frigate.util.builtin import (
clean_camera_user_pass, clean_camera_user_pass,
flatten_config_data, flatten_config_data,
@ -42,7 +48,6 @@ from frigate.util.builtin import (
update_yaml_file_bulk, update_yaml_file_bulk,
) )
from frigate.util.config import find_config_file from frigate.util.config import find_config_file
from frigate.util.media import sync_all_media
from frigate.util.services import ( from frigate.util.services import (
get_nvidia_driver_info, get_nvidia_driver_info,
process_logs, 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(...)): 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, 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: Args:
body: MediaSyncBody with dry_run flag and media_types list. body: MediaSyncBody with dry_run flag and media_types list.
@ -616,52 +628,75 @@ def sync_media(body: MediaSyncBody = Body(...)):
'review_thumbnails', 'previews', 'exports', 'recordings' 'review_thumbnails', 'previews', 'exports', 'recordings'
Returns: Returns:
JSON response with sync results for each requested media type. 202 Accepted with job_id, or 409 Conflict if job already running.
""" """
try: job_id = start_media_sync_job(
results = sync_all_media(
dry_run=body.dry_run, media_types=body.media_types, force=body.force dry_run=body.dry_run, media_types=body.media_types, force=body.force
) )
# Check if any operations were aborted or had errors if job_id is None:
has_errors = False # A job is already running
for result_name in [ current = get_current_media_sync_job()
"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}")
return JSONResponse( return JSONResponse(
content={ content={
"success": False, "error": "A media sync job is already running",
"message": f"Error syncing media files: {str(e)}", "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,
) )

View File

@ -28,6 +28,7 @@ from frigate.const import (
UPDATE_CAMERA_ACTIVITY, UPDATE_CAMERA_ACTIVITY,
UPDATE_EMBEDDINGS_REINDEX_PROGRESS, UPDATE_EMBEDDINGS_REINDEX_PROGRESS,
UPDATE_EVENT_DESCRIPTION, UPDATE_EVENT_DESCRIPTION,
UPDATE_JOB_STATE,
UPDATE_MODEL_STATE, UPDATE_MODEL_STATE,
UPDATE_REVIEW_DESCRIPTION, UPDATE_REVIEW_DESCRIPTION,
UPSERT_REVIEW_SEGMENT, UPSERT_REVIEW_SEGMENT,
@ -60,6 +61,7 @@ class Dispatcher:
self.camera_activity = CameraActivityManager(config, self.publish) self.camera_activity = CameraActivityManager(config, self.publish)
self.audio_activity = AudioActivityManager(config, self.publish) self.audio_activity = AudioActivityManager(config, self.publish)
self.model_state: dict[str, ModelStatusTypesEnum] = {} 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.embeddings_reindex: dict[str, Any] = {}
self.birdseye_layout: dict[str, Any] = {} self.birdseye_layout: dict[str, Any] = {}
self.audio_transcription_state: str = "idle" self.audio_transcription_state: str = "idle"
@ -180,6 +182,19 @@ class Dispatcher:
def handle_model_state() -> None: def handle_model_state() -> None:
self.publish("model_state", json.dumps(self.model_state.copy())) 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: def handle_update_audio_transcription_state() -> None:
if payload: if payload:
self.audio_transcription_state = payload self.audio_transcription_state = payload
@ -277,6 +292,7 @@ class Dispatcher:
UPDATE_EVENT_DESCRIPTION: handle_update_event_description, UPDATE_EVENT_DESCRIPTION: handle_update_event_description,
UPDATE_REVIEW_DESCRIPTION: handle_update_review_description, UPDATE_REVIEW_DESCRIPTION: handle_update_review_description,
UPDATE_MODEL_STATE: handle_update_model_state, UPDATE_MODEL_STATE: handle_update_model_state,
UPDATE_JOB_STATE: handle_update_job_state,
UPDATE_EMBEDDINGS_REINDEX_PROGRESS: handle_update_embeddings_reindex_progress, UPDATE_EMBEDDINGS_REINDEX_PROGRESS: handle_update_embeddings_reindex_progress,
UPDATE_BIRDSEYE_LAYOUT: handle_update_birdseye_layout, UPDATE_BIRDSEYE_LAYOUT: handle_update_birdseye_layout,
UPDATE_AUDIO_TRANSCRIPTION_STATE: handle_update_audio_transcription_state, UPDATE_AUDIO_TRANSCRIPTION_STATE: handle_update_audio_transcription_state,
@ -284,6 +300,7 @@ class Dispatcher:
"restart": handle_restart, "restart": handle_restart,
"embeddingsReindexProgress": handle_embeddings_reindex_progress, "embeddingsReindexProgress": handle_embeddings_reindex_progress,
"modelState": handle_model_state, "modelState": handle_model_state,
"jobState": handle_job_state,
"audioTranscriptionState": handle_audio_transcription_state, "audioTranscriptionState": handle_audio_transcription_state,
"birdseyeLayout": handle_birdseye_layout, "birdseyeLayout": handle_birdseye_layout,
"onConnect": handle_on_connect, "onConnect": handle_on_connect,

View File

@ -119,6 +119,7 @@ UPDATE_REVIEW_DESCRIPTION = "update_review_description"
UPDATE_MODEL_STATE = "update_model_state" UPDATE_MODEL_STATE = "update_model_state"
UPDATE_EMBEDDINGS_REINDEX_PROGRESS = "handle_embeddings_reindex_progress" UPDATE_EMBEDDINGS_REINDEX_PROGRESS = "handle_embeddings_reindex_progress"
UPDATE_BIRDSEYE_LAYOUT = "update_birdseye_layout" UPDATE_BIRDSEYE_LAYOUT = "update_birdseye_layout"
UPDATE_JOB_STATE = "update_job_state"
NOTIFICATION_TEST = "notification_test" NOTIFICATION_TEST = "notification_test"
# IO Nice Values # IO Nice Values

0
frigate/jobs/__init__.py Normal file
View File

21
frigate/jobs/job.py Normal file
View 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
View 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
View 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)

View File

@ -26,6 +26,15 @@ class ModelStatusTypesEnum(str, Enum):
failed = "failed" failed = "failed"
class JobStatusTypesEnum(str, Enum):
pending = "pending"
queued = "queued"
running = "running"
success = "success"
failed = "failed"
cancelled = "cancelled"
class TrackedObjectUpdateTypesEnum(str, Enum): class TrackedObjectUpdateTypesEnum(str, Enum):
description = "description" description = "description"
face = "face" face = "face"

View File

@ -1065,5 +1065,53 @@
"deleteTriggerFailed": "Failed to delete trigger: {{errorMessage}}" "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"
}
} }
} }

View File

@ -11,6 +11,7 @@ import {
TrackedObjectUpdateReturnType, TrackedObjectUpdateReturnType,
TriggerStatus, TriggerStatus,
FrigateAudioDetections, FrigateAudioDetections,
Job,
} from "@/types/ws"; } from "@/types/ws";
import { FrigateStats } from "@/types/stats"; import { FrigateStats } from "@/types/stats";
import { createContainer } from "react-tracked"; import { createContainer } from "react-tracked";
@ -651,3 +652,40 @@ export function useTriggers(): { payload: TriggerStatus } {
: { name: "", camera: "", event_id: "", type: "", score: 0 }; : { name: "", camera: "", event_id: "", type: "", score: 0 };
return { payload: useDeepMemo(parsed) }; 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 };
}

View File

@ -36,6 +36,7 @@ import NotificationView from "@/views/settings/NotificationsSettingsView";
import EnrichmentsSettingsView from "@/views/settings/EnrichmentsSettingsView"; import EnrichmentsSettingsView from "@/views/settings/EnrichmentsSettingsView";
import UiSettingsView from "@/views/settings/UiSettingsView"; import UiSettingsView from "@/views/settings/UiSettingsView";
import FrigatePlusSettingsView from "@/views/settings/FrigatePlusSettingsView"; import FrigatePlusSettingsView from "@/views/settings/FrigatePlusSettingsView";
import MaintenanceSettingsView from "@/views/settings/MaintenanceSettingsView";
import { useSearchEffect } from "@/hooks/use-overlay-state"; import { useSearchEffect } from "@/hooks/use-overlay-state";
import { useNavigate, useSearchParams } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import { useInitialCameraState } from "@/api/ws"; import { useInitialCameraState } from "@/api/ws";
@ -81,6 +82,7 @@ const allSettingsViews = [
"roles", "roles",
"notifications", "notifications",
"frigateplus", "frigateplus",
"maintenance",
] as const; ] as const;
type SettingsType = (typeof allSettingsViews)[number]; type SettingsType = (typeof allSettingsViews)[number];
@ -120,6 +122,10 @@ const settingsGroups = [
label: "frigateplus", label: "frigateplus",
items: [{ key: "frigateplus", component: FrigatePlusSettingsView }], items: [{ key: "frigateplus", component: FrigatePlusSettingsView }],
}, },
{
label: "maintenance",
items: [{ key: "maintenance", component: MaintenanceSettingsView }],
},
]; ];
const CAMERA_SELECT_BUTTON_PAGES = [ const CAMERA_SELECT_BUTTON_PAGES = [

View File

@ -126,3 +126,32 @@ export type TriggerStatus = {
type: string; type: string;
score: number; 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;
};

View 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>
</>
);
}