Add orphaned folder cleanup after rename

- Add _cleanup_orphaned_folder() to delete/move old folder contents after rename
- Empty folders: delete directly via rmdir()
- Non-empty folders: move contents to new path, then delete old folder
- Handle PermissionError and OSError gracefully with logging
- Add dry_run parameter to preview changes without applying them
- Add --dry-run support to validate_and_rename_series_folders()
- Add unit tests for _cleanup_orphaned_folder and dry-run mode
- All 66 related tests pass

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-05-28 21:24:06 +02:00
parent 51b7f349f8
commit 239341629c
2 changed files with 221 additions and 1 deletions

View File

@@ -138,6 +138,113 @@ def _cleanup_stale_files_after_rename(new_path: Path, new_name: str) -> None:
)
def _cleanup_orphaned_folder(old_path: Path, new_path: Path, dry_run: bool = False) -> bool:
"""Clean up orphaned folder after successful rename.
After a folder is successfully renamed to new_path, this function checks
if the old_path still exists (orphaned folder) and removes it. If the
old folder contains files, they are moved to new_path before deletion.
Args:
old_path: The original folder path before rename.
new_path: The new folder path after rename.
dry_run: If True, only log actions without executing them.
Returns:
True if old folder was cleaned up (or would be in dry-run mode),
False if old folder does not exist or cleanup failed.
"""
if not old_path.exists():
logger.debug(
"Old folder does not exist, no cleanup needed: %s", old_path
)
return False
# Check if folder is empty
try:
contents = list(old_path.iterdir())
except PermissionError as exc:
logger.warning(
"Permission denied accessing old folder %s: %s", old_path, exc
)
return False
except OSError as exc:
logger.warning(
"OS error accessing old folder %s: %s", old_path, exc
)
return False
if not contents:
# Empty folder — delete it
if dry_run:
logger.info(
"[DRY-RUN] Would delete empty orphaned folder: %s", old_path
)
return True
try:
old_path.rmdir()
logger.info("Deleted empty orphaned folder: %s", old_path)
return True
except PermissionError as exc:
logger.warning(
"Permission denied deleting folder %s: %s", old_path, exc
)
return False
except OSError as exc:
logger.warning(
"OS error deleting folder %s: %s", old_path, exc
)
return False
# Folder has contents — move files to new_path then delete
if dry_run:
logger.info(
"[DRY-RUN] Would move %d files from orphaned folder %s to %s",
len(contents), old_path, new_path
)
for item in contents:
logger.info("[DRY-RUN] Would move: %s%s", item, new_path / item.name)
logger.info("[DRY-RUN] Would then delete orphaned folder: %s", old_path)
return True
files_moved = 0
errors = 0
for item in contents:
try:
dest = new_path / item.name
item.rename(dest)
logger.debug("Moved %s%s", item, dest)
files_moved += 1
except PermissionError as exc:
logger.warning(
"Permission denied moving %s: %s", item, exc
)
errors += 1
except OSError as exc:
logger.warning(
"OS error moving %s: %s", item, exc
)
errors += 1
if files_moved > 0:
logger.info(
"Moved %d files from orphaned folder to %s",
files_moved, new_path
)
# Delete the now-empty old folder
try:
old_path.rmdir()
logger.info("Deleted orphaned folder after moving contents: %s", old_path)
return errors == 0
except OSError as exc:
logger.warning(
"Could not delete orphaned folder %s (may not be empty): %s",
old_path, exc
)
return False
async def _update_database_paths(
old_folder: str,
new_folder: str,
@@ -234,7 +341,7 @@ async def _update_database_paths(
)
async def validate_and_rename_series_folders() -> Dict[str, int]:
async def validate_and_rename_series_folders(dry_run: bool = False) -> Dict[str, int]:
"""Validate and rename series folders to match NFO metadata.
Iterates over every subfolder in ``settings.anime_directory`` that
@@ -249,6 +356,10 @@ async def validate_and_rename_series_folders() -> Dict[str, int]:
Skips folders where title or year is missing/empty. Logs every
rename action.
Args:
dry_run: If True, simulate rename operations without actually
moving folders or updating the database.
Returns:
Dictionary with counts:
- ``"scanned"``: total folders scanned
@@ -267,6 +378,9 @@ async def validate_and_rename_series_folders() -> Dict[str, int]:
)
return {"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0}
if dry_run:
logger.info("Running in DRY-RUN mode — no changes will be made")
stats = {"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0}
for series_dir in sorted(anime_dir.iterdir()):
@@ -328,7 +442,17 @@ async def validate_and_rename_series_folders() -> Dict[str, int]:
stats["errors"] += 1
continue
if dry_run:
logger.info(
"[DRY-RUN] Would rename folder: '%s''%s'",
current_name,
expected_name,
)
stats["renamed"] += 1
continue
try:
old_path = series_dir
series_dir.rename(expected_path)
logger.info(
"Renamed folder: '%s''%s'", current_name, expected_name
@@ -341,6 +465,9 @@ async def validate_and_rename_series_folders() -> Dict[str, int]:
# Clean up stale/legacy files after successful rename
_cleanup_stale_files_after_rename(expected_path, expected_name)
# Clean up orphaned folder if old path still exists
_cleanup_orphaned_folder(old_path, expected_path, dry_run=False)
except PermissionError as exc:
logger.error(
"Permission denied renaming '%s''%s': %s",