mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-24 09:08:23 +03:00
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:
parent
5d67ba76fd
commit
a89c7d8819
@ -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.
|
||||||
|
|||||||
5
docs/static/frigate-api.yaml
vendored
5
docs/static/frigate-api.yaml
vendored
@ -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:
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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",
|
||||||
|
)
|
||||||
|
|||||||
@ -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}")
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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.",
|
||||||
|
|||||||
@ -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 */}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user