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:
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -420,18 +420,22 @@ 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 &&
|
||||||
|
((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="mt-3 rounded border border-star/40 bg-star/10 p-2">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="text-xs">
|
<div className="text-xs">
|
||||||
<div className="flex items-center gap-1.5 font-medium text-text">
|
<div className="flex items-center gap-1.5 font-medium text-text">
|
||||||
<AlertTriangle className="h-3.5 w-3.5 text-star" />
|
<AlertTriangle className="h-3.5 w-3.5 text-star" />
|
||||||
{missingStats.would_delete} orphaned photo rows
|
Orphaned rows
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 text-[10px] text-text-muted">
|
<div className="mt-0.5 text-[10px] text-text-muted">
|
||||||
Files no longer exist on disk under a mounted source
|
{missingStats.would_delete ?? 0} photos and{' '}
|
||||||
root. Usually means PHOTO_DIRS was repointed at a
|
{missingStats.would_delete_folders ?? 0} folders
|
||||||
different library.
|
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 > 0 && (
|
||||||
<>
|
<>
|
||||||
{' '}
|
{' '}
|
||||||
@@ -445,9 +449,11 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
|||||||
loading={busy['prune-missing']}
|
loading={busy['prune-missing']}
|
||||||
destructive
|
destructive
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
const photos = missingStats.would_delete ?? 0
|
||||||
|
const folders = missingStats.would_delete_folders ?? 0
|
||||||
if (
|
if (
|
||||||
!confirm(
|
!confirm(
|
||||||
`Delete ${missingStats.would_delete} photo rows whose files are missing? ` +
|
`Delete ${photos} photo rows and ${folders} folder rows whose paths are missing? ` +
|
||||||
'This cannot be undone.'
|
'This cannot be undone.'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -456,7 +462,8 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
|||||||
'prune-missing',
|
'prune-missing',
|
||||||
() => library.maintenance.pruneMissing(),
|
() => library.maintenance.pruneMissing(),
|
||||||
'Orphans pruned',
|
'Orphans pruned',
|
||||||
(r) => `${r.deleted ?? 0} rows deleted`
|
(r) =>
|
||||||
|
`${r.deleted ?? 0} photos + ${r.deleted_folders ?? 0} folders deleted`
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user