fix: prune orphaned folder rows alongside photo rows

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) <noreply@anthropic.com>
This commit is contained in:
root
2026-04-09 10:50:32 +02:00
parent 697343646a
commit d27ec1af2e
3 changed files with 121 additions and 67 deletions

View File

@@ -139,15 +139,18 @@ async def _warn_stale_source_roots(session: AsyncSession) -> int:
return stale return stale
async def find_missing_photos(session: AsyncSession) -> tuple[list[str], list[str]]: async def find_missing(
"""Walk every non-discarded photo and check whether its file is still session: AsyncSession,
on disk. Returns (deletable_ids, skipped_under_unmounted_roots). ) -> 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 Skipped rows are photos/folders whose owning source_root path itself
resolve — that's almost always an unmounted drive, and silently doesn't resolve — that's almost always an unmounted drive, and
deleting those rows would be data loss. The caller can surface the silently deleting those rows would be data loss. The caller can
skip count separately so the user knows the cleanup wasn't a no-op surface the skip count separately so the user knows the cleanup
by accident. wasn't a no-op by accident.
""" """
sr_rows = (await session.execute(select(SourceRoot))).scalars().all() 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} 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)) .where(Photo.is_discarded.is_(False))
)).all() )).all()
# folder -> source_root lookup folders = (await session.execute(
folders = (await session.execute(select(Folder.id, Folder.source_root_id))).all() select(Folder.id, Folder.path, Folder.source_root_id)
folder_to_sr = {fid: srid for fid, srid in folders} )).all()
folder_to_sr = {fid: srid for fid, _path, srid in folders}
deletable: list[str] = [] deletable_photos: list[str] = []
skipped: list[str] = [] skipped: list[str] = []
for pid, fp, folder_id in photos: for pid, fp, folder_id in photos:
sr_id = folder_to_sr.get(folder_id) 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) skipped.append(pid)
continue continue
if not os.path.exists(fp): if not os.path.exists(fp):
deletable.append(pid) deletable_photos.append(pid)
return deletable, skipped
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: async def prune_missing_photos(dry_run: bool = True) -> dict:
"""Delete photo rows whose files are no longer on disk *and* whose """Delete photo + folder rows whose paths are no longer on disk *and*
source root is currently mounted. Common cause: PHOTO_DIRS in .env whose source root is currently mounted. Common cause: PHOTO_DIRS in
was repointed at a different library, leaving every old row orphaned. .env was repointed at a different library, leaving every old row
orphaned.
Set dry_run=False to actually delete. The default is intentionally Set dry_run=False to actually delete. The default is intentionally
safe so the matching count can be surfaced in the UI before the safe so the matching count can be surfaced in the UI before the
user commits to it. user commits to it.
Function name kept for backwards compatibility — it now also prunes
folders, not just photos.
""" """
from sqlalchemy import delete from sqlalchemy import delete
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
try: try:
deletable, skipped = await find_missing_photos(session) deletable_photos, deletable_folders, skipped = await find_missing(session)
if not dry_run and deletable: if not dry_run:
# Chunked delete to keep the IN clause within SQLite limits.
CHUNK = 500 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( 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() 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 { return {
"would_delete" if dry_run else "deleted": len(deletable), key_p: len(deletable_photos),
key_f: len(deletable_folders),
"skipped_unmounted": len(skipped), "skipped_unmounted": len(skipped),
"dry_run": dry_run, "dry_run": dry_run,
} }

View File

@@ -420,52 +420,59 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
)} )}
{/* Orphaned rows (files gone from disk) */} {/* Orphaned rows (files gone from disk) */}
{missingStats && (missingStats.would_delete ?? 0) > 0 && ( {missingStats &&
<div className="mt-3 rounded border border-star/40 bg-star/10 p-2"> ((missingStats.would_delete ?? 0) > 0 ||
<div className="flex items-center justify-between gap-2"> (missingStats.would_delete_folders ?? 0) > 0) && (
<div className="text-xs"> <div className="mt-3 rounded border border-star/40 bg-star/10 p-2">
<div className="flex items-center gap-1.5 font-medium text-text"> <div className="flex items-center justify-between gap-2">
<AlertTriangle className="h-3.5 w-3.5 text-star" /> <div className="text-xs">
{missingStats.would_delete} orphaned photo rows <div className="flex items-center gap-1.5 font-medium text-text">
<AlertTriangle className="h-3.5 w-3.5 text-star" />
Orphaned rows
</div>
<div className="mt-0.5 text-[10px] text-text-muted">
{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.
</>
)}
</div>
</div> </div>
<div className="mt-0.5 text-[10px] text-text-muted"> <ActionButton
Files no longer exist on disk under a mounted source loading={busy['prune-missing']}
root. Usually means PHOTO_DIRS was repointed at a destructive
different library. onClick={() => {
{missingStats.skipped_unmounted > 0 && ( const photos = missingStats.would_delete ?? 0
<> const folders = missingStats.would_delete_folders ?? 0
{' '} if (
{missingStats.skipped_unmounted} more rows are !confirm(
under unmounted roots and will not be touched. `Delete ${photos} photo rows and ${folders} folder rows whose paths are missing? ` +
</> 'This cannot be undone.'
)} )
</div>
</div>
<ActionButton
loading={busy['prune-missing']}
destructive
onClick={() => {
if (
!confirm(
`Delete ${missingStats.would_delete} photo rows whose files are missing? ` +
'This cannot be undone.'
) )
) return
return runAction(
runAction( 'prune-missing',
'prune-missing', () => library.maintenance.pruneMissing(),
() => library.maintenance.pruneMissing(), 'Orphans pruned',
'Orphans pruned', (r) =>
(r) => `${r.deleted ?? 0} rows deleted` `${r.deleted ?? 0} photos + ${r.deleted_folders ?? 0} folders deleted`
) )
}} }}
> >
<AlertTriangle className="h-4 w-4" /> <AlertTriangle className="h-4 w-4" />
Prune Prune
</ActionButton> </ActionButton>
</div>
</div> </div>
</div> )}
)}
{/* Per-worker breakdown */} {/* Per-worker breakdown */}
{workerStatus && workerStatus.workers.length > 0 && ( {workerStatus && workerStatus.workers.length > 0 && (

View File

@@ -233,6 +233,8 @@ export interface RegenerateResult {
export interface MissingStats { export interface MissingStats {
would_delete?: number would_delete?: number
deleted?: number deleted?: number
would_delete_folders?: number
deleted_folders?: number
skipped_unmounted: number skipped_unmounted: number
dry_run: boolean dry_run: boolean
} }