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
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,
}

View File

@@ -420,52 +420,59 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
)}
{/* Orphaned rows (files gone from disk) */}
{missingStats && (missingStats.would_delete ?? 0) > 0 && (
<div className="mt-3 rounded border border-star/40 bg-star/10 p-2">
<div className="flex items-center justify-between gap-2">
<div className="text-xs">
<div className="flex items-center gap-1.5 font-medium text-text">
<AlertTriangle className="h-3.5 w-3.5 text-star" />
{missingStats.would_delete} orphaned photo rows
{missingStats &&
((missingStats.would_delete ?? 0) > 0 ||
(missingStats.would_delete_folders ?? 0) > 0) && (
<div className="mt-3 rounded border border-star/40 bg-star/10 p-2">
<div className="flex items-center justify-between gap-2">
<div className="text-xs">
<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 className="mt-0.5 text-[10px] text-text-muted">
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.
</>
)}
</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.'
<ActionButton
loading={busy['prune-missing']}
destructive
onClick={() => {
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`
)
}}
>
<AlertTriangle className="h-4 w-4" />
Prune
</ActionButton>
return
runAction(
'prune-missing',
() => library.maintenance.pruneMissing(),
'Orphans pruned',
(r) =>
`${r.deleted ?? 0} photos + ${r.deleted_folders ?? 0} folders deleted`
)
}}
>
<AlertTriangle className="h-4 w-4" />
Prune
</ActionButton>
</div>
</div>
</div>
)}
)}
{/* Per-worker breakdown */}
{workerStatus && workerStatus.workers.length > 0 && (

View File

@@ -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
}