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. 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). 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 @dataclass
class SyncResult: class SyncResult:
"""Result of a sync operation.""" """Result of a sync operation."""
@@ -49,6 +57,7 @@ class SyncResult:
files_checked: int = 0 files_checked: int = 0
orphans_found: int = 0 orphans_found: int = 0
orphans_deleted: int = 0 orphans_deleted: int = 0
bytes_reclaimed: 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) orphan_db_paths: list[str] = field(default_factory=list)
aborted: bool = False aborted: bool = False
@@ -60,6 +69,7 @@ class SyncResult:
"files_checked": self.files_checked, "files_checked": self.files_checked,
"orphans_found": self.orphans_found, "orphans_found": self.orphans_found,
"orphans_deleted": self.orphans_deleted, "orphans_deleted": self.orphans_deleted,
"bytes_reclaimed": self.bytes_reclaimed,
"aborted": self.aborted, "aborted": self.aborted,
"error": self.error, "error": self.error,
} }
@@ -235,6 +245,7 @@ def sync_recordings(
return result return result
if dry_run: if dry_run:
result.bytes_reclaimed = sum(_file_size(f) for f in files_to_delete)
logger.info( logger.info(
f"Recordings sync (dry run): Found {len(files_to_delete)} orphaned files" f"Recordings sync (dry run): Found {len(files_to_delete)} orphaned files"
) )
@@ -243,11 +254,15 @@ def sync_recordings(
# Delete orphans # Delete orphans
logger.info(f"Deleting {len(files_to_delete)} orphaned recordings files") logger.info(f"Deleting {len(files_to_delete)} orphaned recordings files")
for file in files_to_delete: for file in files_to_delete:
size = _file_size(file)
try: try:
os.unlink(file) os.unlink(file)
result.orphans_deleted += 1
except OSError as e: except OSError as e:
logger.error(f"Failed to delete {file}: {e}") logger.error(f"Failed to delete {file}: {e}")
continue
result.orphans_deleted += 1
result.bytes_reclaimed += size
logger.debug("End sync recordings.") logger.debug("End sync recordings.")
@@ -325,6 +340,7 @@ def sync_event_snapshots(dry_run: bool = False, force: bool = False) -> SyncResu
return result return result
if dry_run: if dry_run:
result.bytes_reclaimed = sum(_file_size(p) for p in orphans)
logger.info( logger.info(
f"Event snapshots sync (dry run): Found {len(orphans)} orphaned files" 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 # Delete orphans
logger.info(f"Deleting {len(orphans)} orphaned event snapshot files") logger.info(f"Deleting {len(orphans)} orphaned event snapshot files")
for file_path in orphans: for file_path in orphans:
size = _file_size(file_path)
try: try:
os.unlink(file_path) os.unlink(file_path)
result.orphans_deleted += 1
except OSError as e: except OSError as e:
logger.error(f"Failed to delete {file_path}: {e}") logger.error(f"Failed to delete {file_path}: {e}")
continue
result.orphans_deleted += 1
result.bytes_reclaimed += size
except Exception as e: except Exception as e:
logger.error(f"Error syncing event snapshots: {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 return result
if dry_run: if dry_run:
result.bytes_reclaimed = sum(_file_size(p) for p in orphans)
logger.info( logger.info(
f"Event thumbnails sync (dry run): Found {len(orphans)} orphaned files" 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 # Delete orphans
logger.info(f"Deleting {len(orphans)} orphaned event thumbnail files") logger.info(f"Deleting {len(orphans)} orphaned event thumbnail files")
for file_path in orphans: for file_path in orphans:
size = _file_size(file_path)
try: try:
os.unlink(file_path) os.unlink(file_path)
result.orphans_deleted += 1
except OSError as e: except OSError as e:
logger.error(f"Failed to delete {file_path}: {e}") logger.error(f"Failed to delete {file_path}: {e}")
continue
result.orphans_deleted += 1
result.bytes_reclaimed += size
except Exception as e: except Exception as e:
logger.error(f"Error syncing event thumbnails: {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 return result
if dry_run: if dry_run:
result.bytes_reclaimed = sum(_file_size(p) for p in orphans)
logger.info( logger.info(
f"Review thumbnails sync (dry run): Found {len(orphans)} orphaned files" 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 # Delete orphans
logger.info(f"Deleting {len(orphans)} orphaned review thumbnail files") logger.info(f"Deleting {len(orphans)} orphaned review thumbnail files")
for file_path in orphans: for file_path in orphans:
size = _file_size(file_path)
try: try:
os.unlink(file_path) os.unlink(file_path)
result.orphans_deleted += 1
except OSError as e: except OSError as e:
logger.error(f"Failed to delete {file_path}: {e}") logger.error(f"Failed to delete {file_path}: {e}")
continue
result.orphans_deleted += 1
result.bytes_reclaimed += size
except Exception as e: except Exception as e:
logger.error(f"Error syncing review thumbnails: {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 return result
if dry_run: 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") logger.info(f"Previews sync (dry run): Found {len(orphans)} orphaned files")
return result return result
# Delete orphans # Delete orphans
logger.info(f"Deleting {len(orphans)} orphaned preview files") logger.info(f"Deleting {len(orphans)} orphaned preview files")
for file_path in orphans: for file_path in orphans:
size = _file_size(file_path)
try: try:
os.unlink(file_path) os.unlink(file_path)
result.orphans_deleted += 1
except OSError as e: except OSError as e:
logger.error(f"Failed to delete {file_path}: {e}") logger.error(f"Failed to delete {file_path}: {e}")
continue
result.orphans_deleted += 1
result.bytes_reclaimed += size
except Exception as e: except Exception as e:
logger.error(f"Error syncing previews: {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 return result
if dry_run: 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") logger.info(f"Exports sync (dry run): Found {len(orphans)} orphaned files")
return result return result
# Delete orphans # Delete orphans
logger.info(f"Deleting {len(orphans)} orphaned export files") logger.info(f"Deleting {len(orphans)} orphaned export files")
for file_path in orphans: for file_path in orphans:
size = _file_size(file_path)
try: try:
os.unlink(file_path) os.unlink(file_path)
result.orphans_deleted += 1
except OSError as e: except OSError as e:
logger.error(f"Failed to delete {file_path}: {e}") logger.error(f"Failed to delete {file_path}: {e}")
continue
result.orphans_deleted += 1
result.bytes_reclaimed += size
except Exception as e: except Exception as e:
logger.error(f"Error syncing exports: {e}") logger.error(f"Error syncing exports: {e}")
@@ -733,6 +773,21 @@ class MediaSyncResults:
total += result.orphans_found total += result.orphans_found
return total 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 @property
def total_orphans_deleted(self) -> int: def total_orphans_deleted(self) -> int:
total = 0 total = 0
@@ -764,6 +819,7 @@ class MediaSyncResults:
"files_checked": result.files_checked, "files_checked": result.files_checked,
"orphans_found": result.orphans_found, "orphans_found": result.orphans_found,
"orphans_deleted": result.orphans_deleted, "orphans_deleted": result.orphans_deleted,
"bytes_reclaimed": result.bytes_reclaimed,
"aborted": result.aborted, "aborted": result.aborted,
"error": result.error, "error": result.error,
} }
@@ -771,6 +827,7 @@ class MediaSyncResults:
"files_checked": self.total_files_checked, "files_checked": self.total_files_checked,
"orphans_found": self.total_orphans_found, "orphans_found": self.total_orphans_found,
"orphans_deleted": self.total_orphans_deleted, "orphans_deleted": self.total_orphans_deleted,
"bytes_reclaimed": self.total_bytes_reclaimed,
} }
return results return results
@@ -874,7 +931,8 @@ def sync_all_media(
logger.info( logger.info(
f"Media sync complete: checked {results.total_files_checked} files, " f"Media sync complete: checked {results.total_files_checked} files, "
f"found {results.total_orphans_found} orphans, " 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 return results
@@ -1469,6 +1469,8 @@
"filesChecked": "Files Checked", "filesChecked": "Files Checked",
"orphansFound": "Orphans Found", "orphansFound": "Orphans Found",
"orphansDeleted": "Orphans Deleted", "orphansDeleted": "Orphans Deleted",
"spaceToReclaim": "Space To Reclaim",
"spaceReclaimed": "Space Reclaimed",
"aborted": "Aborted. Deletion would exceed safety threshold.", "aborted": "Aborted. Deletion would exceed safety threshold.",
"error": "Error", "error": "Error",
"totals": "Totals" "totals": "Totals"
+3
View File
@@ -131,6 +131,7 @@ export type MediaSyncStats = {
files_checked: number; files_checked: number;
orphans_found: number; orphans_found: number;
orphans_deleted: number; orphans_deleted: number;
bytes_reclaimed: number;
aborted: boolean; aborted: boolean;
error: string | null; error: string | null;
}; };
@@ -139,6 +140,7 @@ export type MediaSyncTotals = {
files_checked: number; files_checked: number;
orphans_found: number; orphans_found: number;
orphans_deleted: number; orphans_deleted: number;
bytes_reclaimed: number;
}; };
export type MediaSyncResults = { export type MediaSyncResults = {
@@ -154,4 +156,5 @@ export type Job<TResults = unknown> = {
start_time?: number; start_time?: number;
end_time?: number; end_time?: number;
error_message?: string; error_message?: string;
dry_run?: boolean;
}; };
+8
View File
@@ -5,3 +5,11 @@ export const getUnitSize = (MB: number) => {
return `${(MB / 1048576).toFixed(2)} TiB`; 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 { MediaSyncResults, MediaSyncStats } from "@/types/ws";
import { useDocDomain } from "@/hooks/use-doc-domain"; import { useDocDomain } from "@/hooks/use-doc-domain";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { getUnitSizeFromBytes } from "@/utils/storageUtil";
export default function MediaSyncSettingsView() { export default function MediaSyncSettingsView() {
const { t } = useTranslation("views/settings"); const { t } = useTranslation("views/settings");
@@ -376,6 +377,22 @@ export default function MediaSyncSettingsView() {
{mediaStats.orphans_deleted} {mediaStats.orphans_deleted}
</span> </span>
</div> </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 && ( {mediaStats.aborted && (
<div className="flex items-center gap-2 text-destructive"> <div className="flex items-center gap-2 text-destructive">
<LuX className="size-4" /> <LuX className="size-4" />
@@ -449,6 +466,22 @@ export default function MediaSyncSettingsView() {
{mediaSyncResults.totals.orphans_deleted} {mediaSyncResults.totals.orphans_deleted}
</span> </span>
</div> </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>
</div> </div>
)} )}