From d27ec1af2ecc70cfdd2e5c848a0b13a4523c2e6e Mon Sep 17 00:00:00 2001 From: root Date: Thu, 9 Apr 2026 10:50:32 +0200 Subject: [PATCH] fix: prune orphaned folder rows alongside photo rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit prune_missing_photos() previously only deleted Photo rows whose files were gone, leaving every folder row from the old library in the DB — which made the sidebar tree wildly out of sync with the on-disk structure (still showing /photos/2024/, /photos/2026/03/, etc. that no longer exist). Now also drops Folder rows whose path doesn't resolve under a mounted source root, with the same defensive "skip if source root unmounted" guard. The Settings orphan card surfaces both counts. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/services/cleanup.py | 93 ++++++++++++++----- .../src/components/dialogs/SettingsDialog.tsx | 93 ++++++++++--------- frontend/src/services/api.ts | 2 + 3 files changed, 121 insertions(+), 67 deletions(-) diff --git a/backend/app/services/cleanup.py b/backend/app/services/cleanup.py index 01200fe..613fe84 100644 --- a/backend/app/services/cleanup.py +++ b/backend/app/services/cleanup.py @@ -139,15 +139,18 @@ async def _warn_stale_source_roots(session: AsyncSession) -> int: return stale -async def find_missing_photos(session: AsyncSession) -> tuple[list[str], list[str]]: - """Walk every non-discarded photo and check whether its file is still - on disk. Returns (deletable_ids, skipped_under_unmounted_roots). +async def find_missing( + session: AsyncSession, +) -> tuple[list[str], list[str], list[str]]: + """Walk every non-discarded photo + every folder and check whether + they still resolve on disk. Returns + (deletable_photo_ids, deletable_folder_ids, skipped_photo_ids). - Skipped rows are photos whose owning source_root path itself doesn't - resolve — that's almost always an unmounted drive, and silently - deleting those rows would be data loss. The caller can surface the - skip count separately so the user knows the cleanup wasn't a no-op - by accident. + Skipped rows are photos/folders whose owning source_root path itself + doesn't resolve — that's almost always an unmounted drive, and + silently deleting those rows would be data loss. The caller can + surface the skip count separately so the user knows the cleanup + wasn't a no-op by accident. """ sr_rows = (await session.execute(select(SourceRoot))).scalars().all() sr_mounted: dict[str, bool] = {sr.id: os.path.isdir(sr.path) for sr in sr_rows} @@ -157,11 +160,12 @@ async def find_missing_photos(session: AsyncSession) -> tuple[list[str], list[st .where(Photo.is_discarded.is_(False)) )).all() - # folder -> source_root lookup - folders = (await session.execute(select(Folder.id, Folder.source_root_id))).all() - folder_to_sr = {fid: srid for fid, srid in folders} + folders = (await session.execute( + select(Folder.id, Folder.path, Folder.source_root_id) + )).all() + folder_to_sr = {fid: srid for fid, _path, srid in folders} - deletable: list[str] = [] + deletable_photos: list[str] = [] skipped: list[str] = [] for pid, fp, folder_id in photos: sr_id = folder_to_sr.get(folder_id) @@ -169,34 +173,75 @@ async def find_missing_photos(session: AsyncSession) -> tuple[list[str], list[st skipped.append(pid) continue if not os.path.exists(fp): - deletable.append(pid) - return deletable, skipped + deletable_photos.append(pid) + + deletable_folders: list[str] = [] + for fid, fpath, sr_id in folders: + if sr_id is None or not sr_mounted.get(sr_id, False): + continue + if not os.path.isdir(fpath): + deletable_folders.append(fid) + + return deletable_photos, deletable_folders, skipped async def prune_missing_photos(dry_run: bool = True) -> dict: - """Delete photo rows whose files are no longer on disk *and* whose - source root is currently mounted. Common cause: PHOTO_DIRS in .env - was repointed at a different library, leaving every old row orphaned. + """Delete photo + folder rows whose paths are no longer on disk *and* + whose source root is currently mounted. Common cause: PHOTO_DIRS in + .env was repointed at a different library, leaving every old row + orphaned. Set dry_run=False to actually delete. The default is intentionally safe so the matching count can be surfaced in the UI before the user commits to it. + + Function name kept for backwards compatibility — it now also prunes + folders, not just photos. """ from sqlalchemy import delete async with AsyncSessionLocal() as session: try: - deletable, skipped = await find_missing_photos(session) - if not dry_run and deletable: - # Chunked delete to keep the IN clause within SQLite limits. + deletable_photos, deletable_folders, skipped = await find_missing(session) + if not dry_run: CHUNK = 500 - for i in range(0, len(deletable), CHUNK): + # Photos first (folders may FK from them via folder_id). + for i in range(0, len(deletable_photos), CHUNK): await session.execute( - delete(Photo).where(Photo.id.in_(deletable[i:i + CHUNK])) + delete(Photo).where( + Photo.id.in_(deletable_photos[i:i + CHUNK]) + ) ) + # Then drop folders that ALSO no longer have any photos + # pointing at them. We re-check after the photo delete so + # we don't strand a folder that legitimately exists on + # disk but happened to match the orphan list. + if deletable_folders: + for i in range(0, len(deletable_folders), CHUNK): + chunk = deletable_folders[i:i + CHUNK] + # Only delete folders that now have zero photos + # left attached (defensive — should always be 0 + # if the path is gone, but a concurrent scan + # could re-create rows). + still_used = (await session.execute( + select(Photo.folder_id) + .where(Photo.folder_id.in_(chunk)) + .distinct() + )).scalars().all() + safe = [f for f in chunk if f not in set(still_used)] + if safe: + await session.execute( + delete(Folder).where(Folder.id.in_(safe)) + ) await session.commit() - logger.info(f"Pruned {len(deletable)} orphaned photo rows") + logger.info( + f"Pruned {len(deletable_photos)} photo rows + " + f"{len(deletable_folders)} folder rows" + ) + key_p = "would_delete" if dry_run else "deleted" + key_f = "would_delete_folders" if dry_run else "deleted_folders" return { - "would_delete" if dry_run else "deleted": len(deletable), + key_p: len(deletable_photos), + key_f: len(deletable_folders), "skipped_unmounted": len(skipped), "dry_run": dry_run, } diff --git a/frontend/src/components/dialogs/SettingsDialog.tsx b/frontend/src/components/dialogs/SettingsDialog.tsx index eef1ff2..fb8d0de 100644 --- a/frontend/src/components/dialogs/SettingsDialog.tsx +++ b/frontend/src/components/dialogs/SettingsDialog.tsx @@ -420,52 +420,59 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { )} {/* Orphaned rows (files gone from disk) */} - {missingStats && (missingStats.would_delete ?? 0) > 0 && ( -
-
-
-
- - {missingStats.would_delete} orphaned photo rows + {missingStats && + ((missingStats.would_delete ?? 0) > 0 || + (missingStats.would_delete_folders ?? 0) > 0) && ( +
+
+
+
+ + Orphaned rows +
+
+ {missingStats.would_delete ?? 0} photos and{' '} + {missingStats.would_delete_folders ?? 0} folders + point at paths that no longer exist on disk under a + mounted source root. Usually means PHOTO_DIRS was + repointed at a different library. + {missingStats.skipped_unmounted > 0 && ( + <> + {' '} + {missingStats.skipped_unmounted} more rows are + under unmounted roots and will not be touched. + + )} +
-
- Files no longer exist on disk under a mounted source - root. Usually means PHOTO_DIRS was repointed at a - different library. - {missingStats.skipped_unmounted > 0 && ( - <> - {' '} - {missingStats.skipped_unmounted} more rows are - under unmounted roots and will not be touched. - - )} -
-
- { - if ( - !confirm( - `Delete ${missingStats.would_delete} photo rows whose files are missing? ` + - 'This cannot be undone.' + { + const photos = missingStats.would_delete ?? 0 + const folders = missingStats.would_delete_folders ?? 0 + if ( + !confirm( + `Delete ${photos} photo rows and ${folders} folder rows whose paths are missing? ` + + 'This cannot be undone.' + ) ) - ) - return - runAction( - 'prune-missing', - () => library.maintenance.pruneMissing(), - 'Orphans pruned', - (r) => `${r.deleted ?? 0} rows deleted` - ) - }} - > - - Prune - + return + runAction( + 'prune-missing', + () => library.maintenance.pruneMissing(), + 'Orphans pruned', + (r) => + `${r.deleted ?? 0} photos + ${r.deleted_folders ?? 0} folders deleted` + ) + }} + > + + Prune + +
-
- )} + )} {/* Per-worker breakdown */} {workerStatus && workerStatus.workers.length > 0 && ( diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index c30914a..9f3de85 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -233,6 +233,8 @@ export interface RegenerateResult { export interface MissingStats { would_delete?: number deleted?: number + would_delete_folders?: number + deleted_folders?: number skipped_unmounted: number dry_run: boolean }