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:
2026-08-28 21:42:55 +02:00
parent 818e621288
commit 0f872276dd
4 changed files with 636 additions and 20 deletions

View File

@@ -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):

View File

@@ -0,0 +1,242 @@
"""Tests for AnimeService.rename_folder_if_needed.
The behavior under test: when both the source folder (without year) and the
target folder (with year) exist on disk, the rename must not silently bail
out — it must merge the source into the target and remove the empty source.
This is what prevents the "Ultraman" + "Ultraman (2019)" duplicate-folder
problem reported by users.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from src.server.services.anime_service import AnimeService
@pytest.fixture
def anime_service_with_dir(tmp_path):
"""Create AnimeService pointing at a temp directory."""
mock_app = MagicMock()
mock_app.directory_to_search = str(tmp_path)
mock_app.list.keyDict = {}
progress = MagicMock()
service = AnimeService(series_app=mock_app, progress_service=progress)
return service, tmp_path
class TestRenameFolderIfNeededMerge:
"""Tests for the merge-into-existing-target behavior."""
@pytest.mark.asyncio
async def test_merges_seasons_when_target_exists(
self, anime_service_with_dir
):
service, anime_dir = anime_service_with_dir
source = anime_dir / "Naruto"
target = anime_dir / "Naruto (1999)"
source.mkdir()
(source / "Season 1").mkdir()
(source / "Season 1" / "ep01.mp4").touch()
target.mkdir()
db = AsyncMock()
db_series = MagicMock()
db_series.id = 1
db_series.folder = "Naruto"
with patch(
"src.server.database.service.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
return_value=db_series,
), patch(
"src.server.database.service.AnimeSeriesService.update",
new_callable=AsyncMock,
) as mock_update:
ok = await service.rename_folder_if_needed(
key="naruto",
current_folder="Naruto",
target_folder="Naruto (1999)",
db=db,
)
# Outcome: rename "succeeded" (target now contains source content)
assert ok is True
assert not source.exists(), "Source should be removed after merge"
assert (target / "Season 1" / "ep01.mp4").exists()
# DB row updated to target
assert mock_update.call_args.kwargs["folder"] == "Naruto (1999)"
@pytest.mark.asyncio
async def test_does_not_overwrite_existing_target_files(
self, anime_service_with_dir
):
"""If target already has an episode file, the source copy is removed
(target version wins; no duplicate retained).
"""
service, anime_dir = anime_service_with_dir
source = anime_dir / "Naruto"
target = anime_dir / "Naruto (1999)"
source.mkdir()
target.mkdir()
(target / "Season 1").mkdir()
target_existing = target / "Season 1" / "ep01.mp4"
target_existing.write_text("target-version")
(source / "Season 1").mkdir()
source_conflict = source / "Season 1" / "ep01.mp4"
source_conflict.write_text("source-version")
db = AsyncMock()
db_series = MagicMock()
db_series.id = 1
with patch(
"src.server.database.service.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
return_value=db_series,
), patch(
"src.server.database.service.AnimeSeriesService.update",
new_callable=AsyncMock,
):
ok = await service.rename_folder_if_needed(
key="naruto",
current_folder="Naruto",
target_folder="Naruto (1999)",
db=db,
)
assert ok is True
# Target version preserved
assert target_existing.read_text() == "target-version"
# Source folder removed (after merge, even with skipped conflicts)
assert not source.exists()
@pytest.mark.asyncio
async def test_removes_empty_source_when_target_exists(
self, anime_service_with_dir
):
service, anime_dir = anime_service_with_dir
source = anime_dir / "Naruto"
target = anime_dir / "Naruto (1999)"
source.mkdir()
target.mkdir()
(target / "tvshow.nfo").write_text("kept")
db = AsyncMock()
db_series = MagicMock()
db_series.id = 1
with patch(
"src.server.database.service.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
return_value=db_series,
), patch(
"src.server.database.service.AnimeSeriesService.update",
new_callable=AsyncMock,
) as mock_update:
ok = await service.rename_folder_if_needed(
key="naruto",
current_folder="Naruto",
target_folder="Naruto (1999)",
db=db,
)
assert ok is True
assert not source.exists()
assert (target / "tvshow.nfo").read_text() == "kept"
assert mock_update.call_args.kwargs["folder"] == "Naruto (1999)"
@pytest.mark.asyncio
async def test_simple_rename_when_target_does_not_exist(
self, anime_service_with_dir
):
"""Regression: plain rename (no merge needed) still works."""
service, anime_dir = anime_service_with_dir
source = anime_dir / "Naruto"
source.mkdir()
(source / "Season 1").mkdir()
(source / "Season 1" / "ep01.mp4").touch()
db = AsyncMock()
db_series = MagicMock()
db_series.id = 1
with patch(
"src.server.database.service.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
return_value=db_series,
), patch(
"src.server.database.service.AnimeSeriesService.update",
new_callable=AsyncMock,
) as mock_update:
ok = await service.rename_folder_if_needed(
key="naruto",
current_folder="Naruto",
target_folder="Naruto (1999)",
db=db,
)
assert ok is True
assert not source.exists()
assert (anime_dir / "Naruto (1999)" / "Season 1" / "ep01.mp4").exists()
assert mock_update.call_args.kwargs["folder"] == "Naruto (1999)"
@pytest.mark.asyncio
async def test_no_op_when_source_and_target_same(
self, anime_service_with_dir
):
"""Regression: same-name case returns False without touching disk."""
service, anime_dir = anime_service_with_dir
source = anime_dir / "Naruto (1999)"
source.mkdir()
ok = await service.rename_folder_if_needed(
key="naruto",
current_folder="Naruto (1999)",
target_folder="Naruto (1999)",
db=None,
)
assert ok is False
assert source.exists()
@pytest.mark.asyncio
async def test_no_op_when_source_missing(self, anime_service_with_dir):
"""Regression: source missing on disk returns False without error."""
service, anime_dir = anime_service_with_dir
ok = await service.rename_folder_if_needed(
key="naruto",
current_folder="Naruto",
target_folder="Naruto (1999)",
db=None,
)
assert ok is False
@pytest.mark.asyncio
async def test_path_typesafe_with_string_directory(self, tmp_path):
"""Regression: directory_to_search being a string (not Path) works.
Original code did `self._directory / current_folder` which raised
TypeError when _directory was a str. This was silently swallowed
by the caller's try/except, leaving the rename undone.
"""
mock_app = MagicMock()
mock_app.directory_to_search = str(tmp_path) # string, not Path
mock_app.list.keyDict = {}
progress = MagicMock()
service = AnimeService(series_app=mock_app, progress_service=progress)
source = tmp_path / "Naruto"
target = tmp_path / "Naruto (1999)"
source.mkdir()
target.mkdir()
ok = await service.rename_folder_if_needed(
key="naruto",
current_folder="Naruto",
target_folder="Naruto (1999)",
db=None,
)
# Must not raise; must succeed (merge path).
assert ok is True
assert not source.exists()