diff --git a/docs/docs/configuration/record.md b/docs/docs/configuration/record.md index f573777ab9..2ba9896dbf 100644 --- a/docs/docs/configuration/record.md +++ b/docs/docs/configuration/record.md @@ -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/.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). diff --git a/frigate/util/media.py b/frigate/util/media.py index 7a7292e5d4..5810a75ee4 100644 --- a/frigate/util/media.py +++ b/frigate/util/media.py @@ -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 diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 44d8bfb609..4790c1995d 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -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" diff --git a/web/src/types/ws.ts b/web/src/types/ws.ts index 1ed4ca039c..f17caa8cf3 100644 --- a/web/src/types/ws.ts +++ b/web/src/types/ws.ts @@ -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 = { start_time?: number; end_time?: number; error_message?: string; + dry_run?: boolean; }; diff --git a/web/src/utils/storageUtil.ts b/web/src/utils/storageUtil.ts index b9aef00951..2c3a0f47a2 100644 --- a/web/src/utils/storageUtil.ts +++ b/web/src/utils/storageUtil.ts @@ -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); +}; diff --git a/web/src/views/settings/MediaSyncSettingsView.tsx b/web/src/views/settings/MediaSyncSettingsView.tsx index 6f70dc60ad..1efff29b02 100644 --- a/web/src/views/settings/MediaSyncSettingsView.tsx +++ b/web/src/views/settings/MediaSyncSettingsView.tsx @@ -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} +
+ + {currentJob?.dry_run + ? t( + "maintenance.sync.resultsFields.spaceToReclaim", + ) + : t( + "maintenance.sync.resultsFields.spaceReclaimed", + )} + + + {getUnitSizeFromBytes( + mediaStats.bytes_reclaimed, + )} + +
{mediaStats.aborted && (
@@ -449,6 +466,22 @@ export default function MediaSyncSettingsView() { {mediaSyncResults.totals.orphans_deleted}
+
+ + {currentJob?.dry_run + ? t( + "maintenance.sync.resultsFields.spaceToReclaim", + ) + : t( + "maintenance.sync.resultsFields.spaceReclaimed", + )} + + + {getUnitSizeFromBytes( + mediaSyncResults.totals.bytes_reclaimed, + )} + +
)}