From 4bb2c959a85bf0e35af31140c28d8a6af96f19dc Mon Sep 17 00:00:00 2001 From: Claudio Date: Sun, 10 May 2026 21:24:28 +0200 Subject: [PATCH] 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) --- backend/app/services/cleanup.py | 70 +++++++++++++++++++++++++++------ 1 file changed, 59 insertions(+), 11 deletions(-) diff --git a/backend/app/services/cleanup.py b/backend/app/services/cleanup.py index 613fe84..60fb488 100644 --- a/backend/app/services/cleanup.py +++ b/backend/app/services/cleanup.py @@ -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)