show disk space reclaimed by media sync (#24189)

This commit is contained in:
Josh Hawkins
2026-09-12 07:30:04 -06:00
committed by Nicolas Mowen
parent d6b93301e2
commit 6d33b31bc6
6 changed files with 112 additions and 8 deletions
+1 -1
View File
@@ -499,7 +499,7 @@ Media files (event snapshots, event thumbnails, review thumbnails, previews, exp
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 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. Results include the disk space reclaimed, or with `dry_run: true`, the space that would be reclaimed.
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).
+65 -7
View File
@@ -41,6 +41,14 @@ FFPROBE_PATH = (
)
def _file_size(path: str) -> int:
"""Return the size of a file in bytes, or 0 if it cannot be read."""
try:
return os.path.getsize(path)
except OSError:
return 0
@dataclass
class SyncResult:
"""Result of a sync operation."""
@@ -49,6 +57,7 @@ class SyncResult:
files_checked: int = 0
orphans_found: int = 0
orphans_deleted: int = 0
bytes_reclaimed: int = 0
orphan_paths: list[str] = field(default_factory=list)
orphan_db_paths: list[str] = field(default_factory=list)
aborted: bool = False
@@ -60,6 +69,7 @@ class SyncResult:
"files_checked": self.files_checked,
"orphans_found": self.orphans_found,
"orphans_deleted": self.orphans_deleted,
"bytes_reclaimed": self.bytes_reclaimed,
"aborted": self.aborted,
"error": self.error,
}
@@ -235,6 +245,7 @@ def sync_recordings(
return result
if dry_run:
result.bytes_reclaimed = sum(_file_size(f) for f in files_to_delete)
logger.info(
f"Recordings sync (dry run): Found {len(files_to_delete)} orphaned files"
)
@@ -243,11 +254,15 @@ def sync_recordings(
# Delete orphans
logger.info(f"Deleting {len(files_to_delete)} orphaned recordings files")
for file in files_to_delete:
size = _file_size(file)
try:
os.unlink(file)
result.orphans_deleted += 1
except OSError as e:
logger.error(f"Failed to delete {file}: {e}")
continue
result.orphans_deleted += 1
result.bytes_reclaimed += size
logger.debug("End sync recordings.")
@@ -325,6 +340,7 @@ def sync_event_snapshots(dry_run: bool = False, force: bool = False) -> SyncResu
return result
if dry_run:
result.bytes_reclaimed = sum(_file_size(p) for p in orphans)
logger.info(
f"Event snapshots sync (dry run): Found {len(orphans)} orphaned files"
)
@@ -333,11 +349,15 @@ def sync_event_snapshots(dry_run: bool = False, force: bool = False) -> SyncResu
# Delete orphans
logger.info(f"Deleting {len(orphans)} orphaned event snapshot files")
for file_path in orphans:
size = _file_size(file_path)
try:
os.unlink(file_path)
result.orphans_deleted += 1
except OSError as e:
logger.error(f"Failed to delete {file_path}: {e}")
continue
result.orphans_deleted += 1
result.bytes_reclaimed += size
except Exception as e:
logger.error(f"Error syncing event snapshots: {e}")
@@ -421,6 +441,7 @@ def sync_event_thumbnails(dry_run: bool = False, force: bool = False) -> SyncRes
return result
if dry_run:
result.bytes_reclaimed = sum(_file_size(p) for p in orphans)
logger.info(
f"Event thumbnails sync (dry run): Found {len(orphans)} orphaned files"
)
@@ -429,11 +450,15 @@ def sync_event_thumbnails(dry_run: bool = False, force: bool = False) -> SyncRes
# Delete orphans
logger.info(f"Deleting {len(orphans)} orphaned event thumbnail files")
for file_path in orphans:
size = _file_size(file_path)
try:
os.unlink(file_path)
result.orphans_deleted += 1
except OSError as e:
logger.error(f"Failed to delete {file_path}: {e}")
continue
result.orphans_deleted += 1
result.bytes_reclaimed += size
except Exception as e:
logger.error(f"Error syncing event thumbnails: {e}")
@@ -501,6 +526,7 @@ def sync_review_thumbnails(dry_run: bool = False, force: bool = False) -> SyncRe
return result
if dry_run:
result.bytes_reclaimed = sum(_file_size(p) for p in orphans)
logger.info(
f"Review thumbnails sync (dry run): Found {len(orphans)} orphaned files"
)
@@ -509,11 +535,15 @@ def sync_review_thumbnails(dry_run: bool = False, force: bool = False) -> SyncRe
# Delete orphans
logger.info(f"Deleting {len(orphans)} orphaned review thumbnail files")
for file_path in orphans:
size = _file_size(file_path)
try:
os.unlink(file_path)
result.orphans_deleted += 1
except OSError as e:
logger.error(f"Failed to delete {file_path}: {e}")
continue
result.orphans_deleted += 1
result.bytes_reclaimed += size
except Exception as e:
logger.error(f"Error syncing review thumbnails: {e}")
@@ -581,17 +611,22 @@ def sync_previews(dry_run: bool = False, force: bool = False) -> SyncResult:
return result
if dry_run:
result.bytes_reclaimed = sum(_file_size(p) for p in orphans)
logger.info(f"Previews sync (dry run): Found {len(orphans)} orphaned files")
return result
# Delete orphans
logger.info(f"Deleting {len(orphans)} orphaned preview files")
for file_path in orphans:
size = _file_size(file_path)
try:
os.unlink(file_path)
result.orphans_deleted += 1
except OSError as e:
logger.error(f"Failed to delete {file_path}: {e}")
continue
result.orphans_deleted += 1
result.bytes_reclaimed += size
except Exception as e:
logger.error(f"Error syncing previews: {e}")
@@ -673,17 +708,22 @@ def sync_exports(dry_run: bool = False, force: bool = False) -> SyncResult:
return result
if dry_run:
result.bytes_reclaimed = sum(_file_size(p) for p in orphans)
logger.info(f"Exports sync (dry run): Found {len(orphans)} orphaned files")
return result
# Delete orphans
logger.info(f"Deleting {len(orphans)} orphaned export files")
for file_path in orphans:
size = _file_size(file_path)
try:
os.unlink(file_path)
result.orphans_deleted += 1
except OSError as e:
logger.error(f"Failed to delete {file_path}: {e}")
continue
result.orphans_deleted += 1
result.bytes_reclaimed += size
except Exception as e:
logger.error(f"Error syncing exports: {e}")
@@ -733,6 +773,21 @@ class MediaSyncResults:
total += result.orphans_found
return total
@property
def total_bytes_reclaimed(self) -> int:
total = 0
for result in [
self.event_snapshots,
self.event_thumbnails,
self.review_thumbnails,
self.previews,
self.exports,
self.recordings,
]:
if result:
total += result.bytes_reclaimed
return total
@property
def total_orphans_deleted(self) -> int:
total = 0
@@ -764,6 +819,7 @@ class MediaSyncResults:
"files_checked": result.files_checked,
"orphans_found": result.orphans_found,
"orphans_deleted": result.orphans_deleted,
"bytes_reclaimed": result.bytes_reclaimed,
"aborted": result.aborted,
"error": result.error,
}
@@ -771,6 +827,7 @@ class MediaSyncResults:
"files_checked": self.total_files_checked,
"orphans_found": self.total_orphans_found,
"orphans_deleted": self.total_orphans_deleted,
"bytes_reclaimed": self.total_bytes_reclaimed,
}
return results
@@ -874,7 +931,8 @@ def sync_all_media(
logger.info(
f"Media sync complete: checked {results.total_files_checked} files, "
f"found {results.total_orphans_found} orphans, "
f"deleted {results.total_orphans_deleted}"
f"deleted {results.total_orphans_deleted}, "
f"reclaimed {results.total_bytes_reclaimed} bytes"
)
return results
@@ -1469,6 +1469,8 @@
"filesChecked": "Files Checked",
"orphansFound": "Orphans Found",
"orphansDeleted": "Orphans Deleted",
"spaceToReclaim": "Space To Reclaim",
"spaceReclaimed": "Space Reclaimed",
"aborted": "Aborted. Deletion would exceed safety threshold.",
"error": "Error",
"totals": "Totals"
+3
View File
@@ -131,6 +131,7 @@ export type MediaSyncStats = {
files_checked: number;
orphans_found: number;
orphans_deleted: number;
bytes_reclaimed: number;
aborted: boolean;
error: string | null;
};
@@ -139,6 +140,7 @@ export type MediaSyncTotals = {
files_checked: number;
orphans_found: number;
orphans_deleted: number;
bytes_reclaimed: number;
};
export type MediaSyncResults = {
@@ -154,4 +156,5 @@ export type Job<TResults = unknown> = {
start_time?: number;
end_time?: number;
error_message?: string;
dry_run?: boolean;
};
+8
View File
@@ -5,3 +5,11 @@ export const getUnitSize = (MB: number) => {
return `${(MB / 1048576).toFixed(2)} TiB`;
};
export const getUnitSizeFromBytes = (bytes: number) => {
if (bytes === null || isNaN(bytes) || bytes < 0) return "Invalid number";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1048576) return `${(bytes / 1024).toFixed(2)} KiB`;
return getUnitSize(bytes / 1048576);
};
@@ -15,6 +15,7 @@ import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { MediaSyncResults, MediaSyncStats } from "@/types/ws";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { Link } from "react-router-dom";
import { getUnitSizeFromBytes } from "@/utils/storageUtil";
export default function MediaSyncSettingsView() {
const { t } = useTranslation("views/settings");
@@ -376,6 +377,22 @@ export default function MediaSyncSettingsView() {
{mediaStats.orphans_deleted}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">
{currentJob?.dry_run
? t(
"maintenance.sync.resultsFields.spaceToReclaim",
)
: t(
"maintenance.sync.resultsFields.spaceReclaimed",
)}
</span>
<span>
{getUnitSizeFromBytes(
mediaStats.bytes_reclaimed,
)}
</span>
</div>
{mediaStats.aborted && (
<div className="flex items-center gap-2 text-destructive">
<LuX className="size-4" />
@@ -449,6 +466,22 @@ export default function MediaSyncSettingsView() {
{mediaSyncResults.totals.orphans_deleted}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">
{currentJob?.dry_run
? t(
"maintenance.sync.resultsFields.spaceToReclaim",
)
: t(
"maintenance.sync.resultsFields.spaceReclaimed",
)}
</span>
<span className="font-medium">
{getUnitSizeFromBytes(
mediaSyncResults.totals.bytes_reclaimed,
)}
</span>
</div>
</div>
</div>
)}