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

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