From a89c7d8819a22052d4ae5e4fe262e1ac64181ff5 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 23 Mar 2026 11:05:38 -0500 Subject: [PATCH] Add verbose mode to Media Sync (#22592) * add verbose mode to media sync writes a report to /config/media_sync showing all of the orphaned paths by media type * frontend * docs --- docs/docs/configuration/record.md | 2 + docs/static/frigate-api.yaml | 5 ++ frigate/api/app.py | 5 +- frigate/api/defs/request/app_body.py | 4 ++ frigate/jobs/media_sync.py | 23 +++++++- frigate/util/media.py | 58 ++++++++++++++++++- web/public/locales/en/views/settings.json | 2 + .../views/settings/MediaSyncSettingsView.tsx | 23 +++++++- 8 files changed, 117 insertions(+), 5 deletions(-) diff --git a/docs/docs/configuration/record.md b/docs/docs/configuration/record.md index 881c4de91..afd26c641 100644 --- a/docs/docs/configuration/record.md +++ b/docs/docs/configuration/record.md @@ -162,6 +162,8 @@ Normal operation may leave small numbers of orphaned files until Frigate's sched 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. +Setting `verbose: true` writes a detailed report of every orphaned file and database entry to `/config/media_sync/.txt`. For recordings, the report separates orphaned database entries (DB records whose files are missing from disk) from orphaned files (files on disk with no corresponding database record). + :::warning This operation uses considerable CPU resources and includes a safety threshold that aborts if more than 50% of files would be deleted. Only run when necessary. If you set `force: true` the safety threshold will be bypassed; do not use `force` unless you are certain the deletions are intended. diff --git a/docs/static/frigate-api.yaml b/docs/static/frigate-api.yaml index 232e2e080..90fa505ec 100644 --- a/docs/static/frigate-api.yaml +++ b/docs/static/frigate-api.yaml @@ -6891,6 +6891,11 @@ components: title: Force description: "If True, bypass safety threshold checks" default: false + verbose: + type: boolean + title: Verbose + description: "If True, write full orphan file list to /config/media_sync/.txt" + default: false type: object title: MediaSyncBody MotionSearchMetricsResponse: diff --git a/frigate/api/app.py b/frigate/api/app.py index 06d1b30c6..498094ff8 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -872,7 +872,10 @@ def sync_media(body: MediaSyncBody = Body(...)): 202 Accepted with job_id, or 409 Conflict if job already running. """ job_id = start_media_sync_job( - 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, + verbose=body.verbose, ) if job_id is None: diff --git a/frigate/api/defs/request/app_body.py b/frigate/api/defs/request/app_body.py index 45392a138..d9d11fd01 100644 --- a/frigate/api/defs/request/app_body.py +++ b/frigate/api/defs/request/app_body.py @@ -45,3 +45,7 @@ class MediaSyncBody(BaseModel): force: bool = Field( default=False, description="If True, bypass safety threshold checks" ) + verbose: bool = Field( + default=False, + description="If True, write full orphan file list to disk", + ) diff --git a/frigate/jobs/media_sync.py b/frigate/jobs/media_sync.py index 7c15435fd..803a80a9d 100644 --- a/frigate/jobs/media_sync.py +++ b/frigate/jobs/media_sync.py @@ -1,13 +1,14 @@ """Media sync job management with background execution.""" import logging +import os 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.const import CONFIG_DIR, UPDATE_JOB_STATE from frigate.jobs.job import Job from frigate.jobs.manager import ( get_current_job, @@ -16,7 +17,7 @@ from frigate.jobs.manager import ( set_current_job, ) from frigate.types import JobStatusTypesEnum -from frigate.util.media import sync_all_media +from frigate.util.media import sync_all_media, write_orphan_report logger = logging.getLogger(__name__) @@ -29,6 +30,7 @@ class MediaSyncJob(Job): dry_run: bool = False media_types: list[str] = field(default_factory=lambda: ["all"]) force: bool = False + verbose: bool = False class MediaSyncRunner(threading.Thread): @@ -61,6 +63,21 @@ class MediaSyncRunner(threading.Thread): force=self.job.force, ) + # Write verbose report if requested + if self.job.verbose: + report_dir = os.path.join(CONFIG_DIR, "media_sync") + os.makedirs(report_dir, exist_ok=True) + report_path = os.path.join(report_dir, f"{self.job.id}.txt") + write_orphan_report( + results, + report_path, + job_id=self.job.id, + dry_run=self.job.dry_run, + ) + logger.info( + "Media sync verbose orphan report written to %s", report_path + ) + # Store results and mark as complete self.job.results = results.to_dict() self.job.status = JobStatusTypesEnum.success @@ -95,6 +112,7 @@ def start_media_sync_job( dry_run: bool = False, media_types: Optional[list[str]] = None, force: bool = False, + verbose: bool = False, ) -> Optional[str]: """Start a new media sync job if none is currently running. @@ -113,6 +131,7 @@ def start_media_sync_job( dry_run=dry_run, media_types=media_types or ["all"], force=force, + verbose=verbose, ) logger.debug(f"Creating new media sync job: {job.id}") diff --git a/frigate/util/media.py b/frigate/util/media.py index f3701f400..38f569806 100644 --- a/frigate/util/media.py +++ b/frigate/util/media.py @@ -49,6 +49,7 @@ class SyncResult: orphans_found: int = 0 orphans_deleted: int = 0 orphan_paths: list[str] = field(default_factory=list) + orphan_db_paths: list[str] = field(default_factory=list) aborted: bool = False error: str | None = None @@ -132,7 +133,7 @@ def sync_recordings( ) result.orphans_found += len(recordings_to_delete) - result.orphan_paths.extend( + result.orphan_db_paths.extend( [ recording["path"] for recording in recordings_to_delete @@ -773,6 +774,61 @@ class MediaSyncResults: return results +def write_orphan_report( + results: "MediaSyncResults", + path: str, + job_id: str = "", + dry_run: bool = False, +) -> None: + """Write a verbose orphan report file listing all orphan paths by media type. + + Args: + results: The completed MediaSyncResults. + path: File path to write the report to. + job_id: Job ID for the report header. + dry_run: Whether the sync was a dry run, for the report header. + """ + try: + with open(path, "w") as f: + f.write("# Media Sync Orphan Report\n") + f.write(f"# Job: {job_id}\n") + f.write( + f"# Date: {datetime.datetime.now().astimezone(datetime.timezone.utc).isoformat()}\n" + ) + f.write(f"# Mode: dry_run={dry_run}\n\n") + + for name, result in [ + ("recordings", results.recordings), + ("event_snapshots", results.event_snapshots), + ("event_thumbnails", results.event_thumbnails), + ("review_thumbnails", results.review_thumbnails), + ("previews", results.previews), + ("exports", results.exports), + ]: + if result is None: + continue + + if result.orphan_db_paths: + f.write( + f"## {name} - orphaned db entries ({len(result.orphan_db_paths)})\n" + ) + for orphan_path in result.orphan_db_paths: + f.write(f"{orphan_path}\n") + f.write("\n") + + if result.orphan_paths: + f.write( + f"## {name} - orphaned files ({len(result.orphan_paths)})\n" + ) + for orphan_path in result.orphan_paths: + f.write(f"{orphan_path}\n") + f.write("\n") + + logger.debug("Wrote verbose orphan report to %s", path) + except OSError as e: + logger.error("Failed to write orphan report to %s: %s", path, e) + + def sync_all_media( dry_run: bool = False, media_types: list[str] = ["all"], force: bool = False ) -> MediaSyncResults: diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 523d993cd..42de28d52 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -1253,6 +1253,8 @@ "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.", + "verbose": "Verbose", + "verboseDesc": "Write a full list of orphaned files to disk for review.", "running": "Sync Running...", "start": "Start Sync", "inProgress": "Sync is in progress. This page is disabled.", diff --git a/web/src/views/settings/MediaSyncSettingsView.tsx b/web/src/views/settings/MediaSyncSettingsView.tsx index afbfe3ea1..5ff72b8c8 100644 --- a/web/src/views/settings/MediaSyncSettingsView.tsx +++ b/web/src/views/settings/MediaSyncSettingsView.tsx @@ -22,6 +22,7 @@ export default function MediaSyncSettingsView() { ]); const [dryRun, setDryRun] = useState(true); const [force, setForce] = useState(false); + const [verbose, setVerbose] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const MEDIA_TYPES = [ @@ -67,6 +68,7 @@ export default function MediaSyncSettingsView() { dry_run: dryRun, media_types: selectedMediaTypes, force: force, + verbose: verbose, }, { headers: { @@ -94,7 +96,7 @@ export default function MediaSyncSettingsView() { } finally { setIsSubmitting(false); } - }, [selectedMediaTypes, dryRun, force, t]); + }, [selectedMediaTypes, dryRun, force, verbose, t]); return ( <> @@ -205,6 +207,25 @@ export default function MediaSyncSettingsView() { /> + +
+
+
+ +

+ {t("maintenance.sync.verboseDesc")} +

+
+ +
+
{/* Action Buttons */}