From 102be124d442de3df40030c7846e360c370adc31 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 23 Mar 2026 10:09:10 -0500 Subject: [PATCH] add verbose mode to media sync writes a report to /config/media_sync showing all of the orphaned paths by media type --- frigate/api/app.py | 5 ++- frigate/api/defs/request/app_body.py | 4 ++ frigate/jobs/media_sync.py | 23 ++++++++++- frigate/util/media.py | 58 +++++++++++++++++++++++++++- 4 files changed, 86 insertions(+), 4 deletions(-) diff --git a/frigate/api/app.py b/frigate/api/app.py index b51aaded3..d671cca9f 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -864,7 +864,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: