fix(cleanup): distinguish renamed source root from unmounted drive

prune_missing_photos previously skipped every photo whose source root
path didn't resolve, on the assumption that a missing path meant the
underlying drive was unmounted (and silently deleting under those
conditions would be data loss). That conflated 'drive unmounted'
with 'user renamed the folder in their file manager'.

A library with 4,154 orphaned photo rows from a since-renamed Nextcloud
folder hit exactly this case: the /nextcloud-users mount was fine, but
the source root path 'Taco and Muli - 2024 onward' no longer existed
because the user had renamed it to 'Photo Archive 2004-2024'. Every
photo under it was reported as skipped_unmounted forever.

Classify source root state as present/renamed/unmounted by checking
whether the immediate parent is readable. 'renamed' is now treated as
prunable; 'unmounted' still skips. Warning messages differ so the user
knows which fix to apply.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claudio
2026-05-10 21:24:28 +02:00
parent 758fda619e
commit 4bb2c959a8

View File

@@ -119,22 +119,64 @@ async def _recompute_folder_counts(session: AsyncSession) -> None:
f.photo_count = int(count_result.scalar() or 0)
def _parent_is_accessible(path: str) -> bool:
"""True if the parent directory of `path` is readable. Used to
distinguish 'user renamed/deleted the source root folder' (parent
mount fine, leaf gone) from 'drive unmounted' (whole subtree
inaccessible). The former is safe to prune from; the latter is
not."""
parent = os.path.dirname(path.rstrip(os.sep))
if not parent:
return False
try:
os.listdir(parent)
return True
except OSError:
return False
def _sr_state(sr_path: str) -> str:
"""Classify a source root path as one of:
'present' — directory exists, business as usual
'renamed' — leaf missing but parent mount is accessible (user
renamed/deleted the folder in their file manager)
'unmounted'— parent itself inaccessible (drive not mounted)
"""
if os.path.isdir(sr_path):
return 'present'
if _parent_is_accessible(sr_path):
return 'renamed'
return 'unmounted'
async def _warn_stale_source_roots(session: AsyncSession) -> int:
"""Log a warning for any active source root whose path no longer exists
on disk. Doesn't delete — a missing path could be a temporarily
unmounted drive, and silently dropping user data is worse than
surfacing a noisy log line.
surfacing a noisy log line. Logs different hints for renamed-vs-
unmounted so the user knows which knob to turn.
"""
result = await session.execute(select(SourceRoot))
rows = result.scalars().all()
stale = 0
for sr in rows:
if not os.path.isdir(sr.path):
stale += 1
state = _sr_state(sr.path)
if state == 'present':
continue
stale += 1
if state == 'renamed':
logger.warning(
f"Source root '{sr.name}' path is missing on disk: {sr.path} "
f"is the docker mount still in place? "
f"(Edit docker-compose.yml or PHOTO_DIRS in .env to fix.)"
f"parent mount is fine, looks like the folder was renamed "
f"or deleted. Photos under it can be cleared via "
f"POST /api/v1/library/maintenance/prune-missing."
)
else:
logger.warning(
f"Source root '{sr.name}' path is missing on disk: {sr.path} "
f"— parent directory is also inaccessible; is the docker "
f"mount still in place? (Edit docker-compose.yml or "
f"PHOTO_DIRS in .env to fix.)"
)
return stale
@@ -146,14 +188,20 @@ async def find_missing(
they still resolve on disk. Returns
(deletable_photo_ids, deletable_folder_ids, skipped_photo_ids).
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.
Skipped rows are photos/folders whose owning source_root is truly
inaccessible (parent mount missing) — that's almost always an
unmounted drive, and silently deleting those rows would be data
loss. Photos under a source root whose leaf is missing but whose
parent mount IS accessible (user renamed/deleted the folder) are
treated as deletable, since their files are genuinely gone from
the user's library.
"""
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}
# "Available" = source root path exists OR parent mount is accessible.
# Only truly-unmounted source roots skip pruning.
sr_mounted: dict[str, bool] = {
sr.id: _sr_state(sr.path) != 'unmounted' for sr in sr_rows
}
photos = (await session.execute(
select(Photo.id, Photo.filepath, Photo.folder_id)