mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-01 12:26:50 +03:00
show disk space reclaimed by media sync (#24189)
This commit is contained in:
committed by
Nicolas Mowen
parent
d6b93301e2
commit
6d33b31bc6
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user