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
This commit is contained in:
Josh Hawkins 2026-03-23 11:05:38 -05:00 committed by GitHub
parent 5d67ba76fd
commit a89c7d8819
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 117 additions and 5 deletions

View File

@ -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. 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/<job_id>.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 :::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. 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.

View File

@ -6891,6 +6891,11 @@ components:
title: Force title: Force
description: "If True, bypass safety threshold checks" description: "If True, bypass safety threshold checks"
default: false default: false
verbose:
type: boolean
title: Verbose
description: "If True, write full orphan file list to /config/media_sync/<job_id>.txt"
default: false
type: object type: object
title: MediaSyncBody title: MediaSyncBody
MotionSearchMetricsResponse: MotionSearchMetricsResponse:

View File

@ -872,7 +872,10 @@ def sync_media(body: MediaSyncBody = Body(...)):
202 Accepted with job_id, or 409 Conflict if job already running. 202 Accepted with job_id, or 409 Conflict if job already running.
""" """
job_id = start_media_sync_job( 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: if job_id is None:

View File

@ -45,3 +45,7 @@ class MediaSyncBody(BaseModel):
force: bool = Field( force: bool = Field(
default=False, description="If True, bypass safety threshold checks" default=False, description="If True, bypass safety threshold checks"
) )
verbose: bool = Field(
default=False,
description="If True, write full orphan file list to disk",
)

View File

@ -1,13 +1,14 @@
"""Media sync job management with background execution.""" """Media sync job management with background execution."""
import logging import logging
import os
import threading import threading
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from frigate.comms.inter_process import InterProcessRequestor 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.job import Job
from frigate.jobs.manager import ( from frigate.jobs.manager import (
get_current_job, get_current_job,
@ -16,7 +17,7 @@ from frigate.jobs.manager import (
set_current_job, set_current_job,
) )
from frigate.types import JobStatusTypesEnum 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__) logger = logging.getLogger(__name__)
@ -29,6 +30,7 @@ class MediaSyncJob(Job):
dry_run: bool = False dry_run: bool = False
media_types: list[str] = field(default_factory=lambda: ["all"]) media_types: list[str] = field(default_factory=lambda: ["all"])
force: bool = False force: bool = False
verbose: bool = False
class MediaSyncRunner(threading.Thread): class MediaSyncRunner(threading.Thread):
@ -61,6 +63,21 @@ class MediaSyncRunner(threading.Thread):
force=self.job.force, 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 # Store results and mark as complete
self.job.results = results.to_dict() self.job.results = results.to_dict()
self.job.status = JobStatusTypesEnum.success self.job.status = JobStatusTypesEnum.success
@ -95,6 +112,7 @@ def start_media_sync_job(
dry_run: bool = False, dry_run: bool = False,
media_types: Optional[list[str]] = None, media_types: Optional[list[str]] = None,
force: bool = False, force: bool = False,
verbose: bool = False,
) -> Optional[str]: ) -> Optional[str]:
"""Start a new media sync job if none is currently running. """Start a new media sync job if none is currently running.
@ -113,6 +131,7 @@ def start_media_sync_job(
dry_run=dry_run, dry_run=dry_run,
media_types=media_types or ["all"], media_types=media_types or ["all"],
force=force, force=force,
verbose=verbose,
) )
logger.debug(f"Creating new media sync job: {job.id}") logger.debug(f"Creating new media sync job: {job.id}")

View File

@ -49,6 +49,7 @@ class SyncResult:
orphans_found: int = 0 orphans_found: int = 0
orphans_deleted: int = 0 orphans_deleted: int = 0
orphan_paths: list[str] = field(default_factory=list) orphan_paths: list[str] = field(default_factory=list)
orphan_db_paths: list[str] = field(default_factory=list)
aborted: bool = False aborted: bool = False
error: str | None = None error: str | None = None
@ -132,7 +133,7 @@ def sync_recordings(
) )
result.orphans_found += len(recordings_to_delete) result.orphans_found += len(recordings_to_delete)
result.orphan_paths.extend( result.orphan_db_paths.extend(
[ [
recording["path"] recording["path"]
for recording in recordings_to_delete for recording in recordings_to_delete
@ -773,6 +774,61 @@ class MediaSyncResults:
return results 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( def sync_all_media(
dry_run: bool = False, media_types: list[str] = ["all"], force: bool = False dry_run: bool = False, media_types: list[str] = ["all"], force: bool = False
) -> MediaSyncResults: ) -> MediaSyncResults:

View File

@ -1253,6 +1253,8 @@
"dryRunDisabled": "Files will be deleted", "dryRunDisabled": "Files will be deleted",
"force": "Force", "force": "Force",
"forceDesc": "Bypass safety threshold and complete sync even if more than 50% of the files would be deleted.", "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...", "running": "Sync Running...",
"start": "Start Sync", "start": "Start Sync",
"inProgress": "Sync is in progress. This page is disabled.", "inProgress": "Sync is in progress. This page is disabled.",

View File

@ -22,6 +22,7 @@ export default function MediaSyncSettingsView() {
]); ]);
const [dryRun, setDryRun] = useState(true); const [dryRun, setDryRun] = useState(true);
const [force, setForce] = useState(false); const [force, setForce] = useState(false);
const [verbose, setVerbose] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const MEDIA_TYPES = [ const MEDIA_TYPES = [
@ -67,6 +68,7 @@ export default function MediaSyncSettingsView() {
dry_run: dryRun, dry_run: dryRun,
media_types: selectedMediaTypes, media_types: selectedMediaTypes,
force: force, force: force,
verbose: verbose,
}, },
{ {
headers: { headers: {
@ -94,7 +96,7 @@ export default function MediaSyncSettingsView() {
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
}, [selectedMediaTypes, dryRun, force, t]); }, [selectedMediaTypes, dryRun, force, verbose, t]);
return ( return (
<> <>
@ -205,6 +207,25 @@ export default function MediaSyncSettingsView() {
/> />
</div> </div>
</div> </div>
<div className="flex flex-col">
<div className="flex flex-row items-center justify-between gap-4">
<div className="space-y-0.5">
<Label htmlFor="verbose" className="cursor-pointer">
{t("maintenance.sync.verbose")}
</Label>
<p className="text-xs text-muted-foreground">
{t("maintenance.sync.verboseDesc")}
</p>
</div>
<Switch
id="verbose"
checked={verbose}
onCheckedChange={setVerbose}
disabled={isJobRunning}
/>
</div>
</div>
</div> </div>
{/* Action Buttons */} {/* Action Buttons */}