fix: merge bare folder into year-suffixed one instead of bailing
When the year-suffixed target folder already exists on disk, both
FolderNamingService and AnimeService.rename_folder_if_needed used to
silently bail out. The bare folder (e.g. 'Ultraman') was left next to
the year-suffixed one ('Ultraman (2019)'), producing the symptom
'reports series like Ultraman as added twice' — the DB has one row but
the filesystem has two folders holding the same content.
Fix: when the target already exists, merge the source's contents into
the target (target version wins on file conflicts; source copies are
removed so cleanup succeeds), remove the now-empty source directory,
update DB row + in-memory cache. Plain rename path is unchanged.
Also fixes a latent TypeError in rename_folder_if_needed where
self._directory (a str) was used with the '/' operator. Production
behavior was that any rename through that method raised and was
swallowed by the caller's try/except, leaving the bare folder
untouched. The new path builds Path objects from the string base.
Tests:
- Replaced test_skips_when_target_folder_already_exists (which
codified the bug) with three tests that cover the new merge
contract: clean merge, no-overwrite, and empty-source removal.
- Added tests/unit/test_rename_folder_if_needed.py covering the
same scenarios plus the str-directory regression. All seven go
red on the unfixed code and green with the fix.
Fixes the 'Ultraman' / 'Ultraman (2019)' duplicate-folder bug.
This commit is contained in:
@@ -241,28 +241,157 @@ class TestFolderNamingServiceIntegration:
|
||||
assert call_kwargs["folder"] == "Naruto (1999)"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_when_target_folder_already_exists(
|
||||
async def test_merges_source_into_existing_target_when_target_has_no_overlap(
|
||||
self, tmp_path, mock_db_session, mock_series, mock_settings
|
||||
):
|
||||
"""If 'Naruto (1999)' already exists, rename is skipped."""
|
||||
"""Source 'Naruto' with seasons/episodes, target 'Naruto (1999)' exists empty.
|
||||
|
||||
Source files are moved into target. Empty source directory is removed.
|
||||
DB folder is updated to target. Result is success (renamed).
|
||||
"""
|
||||
anime_dir = tmp_path
|
||||
(anime_dir / "Naruto").mkdir()
|
||||
(anime_dir / "Naruto (1999)").mkdir() # target already exists
|
||||
source = anime_dir / "Naruto"
|
||||
target = anime_dir / "Naruto (1999)"
|
||||
source.mkdir()
|
||||
target.mkdir()
|
||||
(source / "Season 1").mkdir()
|
||||
(source / "Season 1" / "ep01.mp4").touch()
|
||||
(source / "Season 2").mkdir()
|
||||
(source / "Season 2" / "ep01.mp4").touch()
|
||||
|
||||
series = mock_series("key1", "Naruto", 1999)
|
||||
mock_db_session.__aenter__.return_value.__aexit__.return_value = None
|
||||
|
||||
with patch("src.server.services.folder_naming_service.AnimeSeriesService.get_all", new_callable=AsyncMock) as mock_get_all:
|
||||
with patch("src.server.services.folder_naming_service.AnimeSeriesService.get_all", new_callable=AsyncMock) as mock_get_all, \
|
||||
patch("src.server.services.folder_naming_service.AnimeSeriesService.get_by_key", new_callable=AsyncMock) as mock_get_by_key, \
|
||||
patch("src.server.services.folder_naming_service.AnimeSeriesService.update", new_callable=AsyncMock) as mock_update, \
|
||||
patch("src.server.utils.dependencies.get_series_app") as mock_get_app:
|
||||
|
||||
mock_get_all.return_value = [series]
|
||||
db_series = MagicMock()
|
||||
db_series.id = 42
|
||||
mock_get_by_key.return_value = db_series
|
||||
|
||||
app_instance = MagicMock()
|
||||
app_instance.list.keyDict = {"key1": MagicMock()}
|
||||
mock_get_app.return_value = app_instance
|
||||
|
||||
mock_settings.anime_directory = str(anime_dir)
|
||||
|
||||
service = FolderNamingService()
|
||||
report = await service.run()
|
||||
|
||||
assert report.errors == 1
|
||||
assert report.renamed == 0
|
||||
assert report.results[0].reason == "target folder already exists on disk"
|
||||
assert (anime_dir / "Naruto").exists() # source not moved
|
||||
# Outcome: renamed (not error) — source merged into target
|
||||
assert report.renamed == 1
|
||||
assert report.errors == 0
|
||||
assert report.results[0].success is True
|
||||
assert report.results[0].skipped is False
|
||||
assert report.results[0].new_folder == "Naruto (1999)"
|
||||
|
||||
# Source folder gone
|
||||
assert not source.exists(), "Source folder should be removed after merge"
|
||||
# Target folder has merged content
|
||||
assert (target / "Season 1" / "ep01.mp4").exists()
|
||||
assert (target / "Season 2" / "ep01.mp4").exists()
|
||||
# DB updated
|
||||
assert mock_update.call_args.kwargs["folder"] == "Naruto (1999)"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merges_only_missing_seasons_preserving_existing_target_files(
|
||||
self, tmp_path, mock_db_session, mock_series, mock_settings
|
||||
):
|
||||
"""Source has S01 ep01, target already has S01 ep01 (different content).
|
||||
|
||||
Existing target files are kept. Source's S01 ep01 is NOT overwritten.
|
||||
Source's S02 (new) is moved. Empty source is removed.
|
||||
"""
|
||||
anime_dir = tmp_path
|
||||
source = anime_dir / "Naruto"
|
||||
target = anime_dir / "Naruto (1999)"
|
||||
source.mkdir()
|
||||
target.mkdir()
|
||||
|
||||
# Target already has S01 with one episode
|
||||
(target / "Season 1").mkdir()
|
||||
target_existing = target / "Season 1" / "ep01.mp4"
|
||||
target_existing.write_text("target-version")
|
||||
|
||||
# Source has S01 with same episode (different content) and S02
|
||||
(source / "Season 1").mkdir()
|
||||
source_conflict = source / "Season 1" / "ep01.mp4"
|
||||
source_conflict.write_text("source-version")
|
||||
(source / "Season 2").mkdir()
|
||||
(source / "Season 2" / "ep01.mp4").touch()
|
||||
|
||||
series = mock_series("key1", "Naruto", 1999)
|
||||
mock_db_session.__aenter__.return_value.__aexit__.return_value = None
|
||||
|
||||
with patch("src.server.services.folder_naming_service.AnimeSeriesService.get_all", new_callable=AsyncMock) as mock_get_all, \
|
||||
patch("src.server.services.folder_naming_service.AnimeSeriesService.get_by_key", new_callable=AsyncMock) as mock_get_by_key, \
|
||||
patch("src.server.services.folder_naming_service.AnimeSeriesService.update", new_callable=AsyncMock), \
|
||||
patch("src.server.utils.dependencies.get_series_app") as mock_get_app:
|
||||
|
||||
mock_get_all.return_value = [series]
|
||||
db_series = MagicMock()
|
||||
db_series.id = 42
|
||||
mock_get_by_key.return_value = db_series
|
||||
|
||||
app_instance = MagicMock()
|
||||
app_instance.list.keyDict = {"key1": MagicMock()}
|
||||
mock_get_app.return_value = app_instance
|
||||
mock_settings.anime_directory = str(anime_dir)
|
||||
|
||||
service = FolderNamingService()
|
||||
report = await service.run()
|
||||
|
||||
# Renamed (source effectively absorbed)
|
||||
assert report.renamed == 1
|
||||
assert not source.exists(), "Source should be removed after merge"
|
||||
# Target S01 ep01 keeps the target version (not overwritten)
|
||||
assert target_existing.read_text() == "target-version"
|
||||
# New S02 moved in
|
||||
assert (target / "Season 2" / "ep01.mp4").exists()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_removes_empty_source_folder_when_target_exists(
|
||||
self, tmp_path, mock_db_session, mock_series, mock_settings
|
||||
):
|
||||
"""Source folder exists but is empty, target already exists.
|
||||
|
||||
Source should be removed silently, DB updated, success.
|
||||
"""
|
||||
anime_dir = tmp_path
|
||||
source = anime_dir / "Naruto"
|
||||
target = anime_dir / "Naruto (1999)"
|
||||
source.mkdir()
|
||||
target.mkdir()
|
||||
(target / "tvshow.nfo").write_text("existing nfo")
|
||||
|
||||
series = mock_series("key1", "Naruto", 1999)
|
||||
mock_db_session.__aenter__.return_value.__aexit__.return_value = None
|
||||
|
||||
with patch("src.server.services.folder_naming_service.AnimeSeriesService.get_all", new_callable=AsyncMock) as mock_get_all, \
|
||||
patch("src.server.services.folder_naming_service.AnimeSeriesService.get_by_key", new_callable=AsyncMock) as mock_get_by_key, \
|
||||
patch("src.server.services.folder_naming_service.AnimeSeriesService.update", new_callable=AsyncMock) as mock_update, \
|
||||
patch("src.server.utils.dependencies.get_series_app") as mock_get_app:
|
||||
|
||||
mock_get_all.return_value = [series]
|
||||
db_series = MagicMock()
|
||||
db_series.id = 42
|
||||
mock_get_by_key.return_value = db_series
|
||||
app_instance = MagicMock()
|
||||
app_instance.list.keyDict = {"key1": MagicMock()}
|
||||
mock_get_app.return_value = app_instance
|
||||
mock_settings.anime_directory = str(anime_dir)
|
||||
|
||||
service = FolderNamingService()
|
||||
report = await service.run()
|
||||
|
||||
assert report.renamed == 1
|
||||
assert report.errors == 0
|
||||
assert not source.exists(), "Empty source should be removed"
|
||||
assert (target / "tvshow.nfo").exists(), "Target content preserved"
|
||||
assert mock_update.call_args.kwargs["folder"] == "Naruto (1999)"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_safety_guard_detects_wrong_year_in_target(self, tmp_path, mock_db_session, mock_series, mock_settings):
|
||||
|
||||
Reference in New Issue
Block a user