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)