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

@@ -9,6 +9,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from src.server.services.folder_rename_service import (
_cleanup_orphaned_folder,
_compute_expected_folder_name,
_is_series_being_downloaded,
_parse_nfo_title_and_year,
@@ -278,6 +279,71 @@ class TestUpdateDatabasePaths:
assert mock_episode.file_path == str(new_path)
class TestCleanupOrphanedFolder:
"""Tests for _cleanup_orphaned_folder."""
def test_returns_false_when_old_folder_does_not_exist(self, tmp_path: Path) -> None:
old_path = tmp_path / "nonexistent"
new_path = tmp_path / "new"
result = _cleanup_orphaned_folder(old_path, new_path)
assert result is False
def test_deletes_empty_folder(self, tmp_path: Path) -> None:
old_path = tmp_path / "empty_orphan"
old_path.mkdir()
new_path = tmp_path / "new"
new_path.mkdir()
result = _cleanup_orphaned_folder(old_path, new_path)
assert result is True
assert not old_path.exists()
def test_moves_files_and_deletes_folder(self, tmp_path: Path) -> None:
old_path = tmp_path / "old_orphan"
old_path.mkdir()
new_path = tmp_path / "new"
new_path.mkdir()
file1 = old_path / "S01E01.mkv"
file1.write_text("episode 1")
file2 = old_path / "S01E02.mkv"
file2.write_text("episode 2")
result = _cleanup_orphaned_folder(old_path, new_path)
assert result is True
assert not old_path.exists()
assert (new_path / "S01E01.mkv").exists()
assert (new_path / "S01E02.mkv").exists()
def test_dry_run_does_not_delete_empty_folder(self, tmp_path: Path) -> None:
old_path = tmp_path / "empty_orphan"
old_path.mkdir()
new_path = tmp_path / "new"
new_path.mkdir()
result = _cleanup_orphaned_folder(old_path, new_path, dry_run=True)
assert result is True
assert old_path.exists()
def test_dry_run_does_not_move_files(self, tmp_path: Path) -> None:
old_path = tmp_path / "old_orphan"
old_path.mkdir()
new_path = tmp_path / "new"
new_path.mkdir()
file1 = old_path / "S01E01.mkv"
file1.write_text("episode 1")
result = _cleanup_orphaned_folder(old_path, new_path, dry_run=True)
assert result is True
assert old_path.exists()
assert not (new_path / "S01E01.mkv").exists()
def test_handles_permission_error_gracefully(self, tmp_path: Path) -> None:
old_path = tmp_path / "permission_denied"
old_path.mkdir()
new_path = tmp_path / "new"
new_path.mkdir()
# Simulate permission error by patching rmdir
with patch.object(Path, "rmdir", side_effect=PermissionError("Access denied")):
result = _cleanup_orphaned_folder(old_path, new_path)
assert result is False
class TestValidateAndRenameSeriesFolders:
"""Integration-style tests for validate_and_rename_series_folders."""
@@ -459,3 +525,30 @@ class TestValidateAndRenameSeriesFolders:
assert (anime_dir / "Show A (2020)").is_dir()
assert d2.is_dir()
assert d3.is_dir()
@pytest.mark.asyncio
async def test_dry_run_does_not_rename_folders(self, tmp_path: Path) -> None:
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
series_dir = anime_dir / "Attack on Titan"
series_dir.mkdir()
(series_dir / "tvshow.nfo").write_text(
"<tvshow><title>Attack on Titan</title><year>2013</year></tvshow>"
)
with patch(
"src.server.services.folder_rename_service.settings.anime_directory",
str(anime_dir),
), patch(
"src.server.services.folder_rename_service._is_series_being_downloaded",
return_value=False,
):
stats = await validate_and_rename_series_folders(dry_run=True)
assert stats["scanned"] == 1
assert stats["renamed"] == 1
assert stats["skipped"] == 0
assert stats["errors"] == 0
# Original folder should still exist (not renamed in dry-run)
assert series_dir.is_dir()
assert not (anime_dir / "Attack on Titan (2013)").exists()