From 0f872276ddc40b416997db608591af8f1cd1874e Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 28 Aug 2026 21:42:55 +0200 Subject: [PATCH] fix: merge bare folder into year-suffixed one instead of bailing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/server/services/anime_service.py | 134 +++++++++- src/server/services/folder_naming_service.py | 133 +++++++++- tests/unit/test_folder_naming_service.py | 147 ++++++++++- tests/unit/test_rename_folder_if_needed.py | 242 +++++++++++++++++++ 4 files changed, 636 insertions(+), 20 deletions(-) create mode 100644 tests/unit/test_rename_folder_if_needed.py diff --git a/src/server/services/anime_service.py b/src/server/services/anime_service.py index 710a248..e89aa5e 100644 --- a/src/server/services/anime_service.py +++ b/src/server/services/anime_service.py @@ -1244,8 +1244,7 @@ class AnimeService: Returns: True if rename was performed, False if no rename needed or failed """ - import os - import shutil + from pathlib import Path if current_folder == target_folder: logger.debug( @@ -1254,8 +1253,9 @@ class AnimeService: ) return False - current_path = self._directory / current_folder - target_path = self._directory / target_folder + base_dir = Path(self._directory) + current_path = base_dir / current_folder + target_path = base_dir / target_folder if not current_path.exists(): logger.debug( @@ -1265,15 +1265,54 @@ class AnimeService: return False if target_path.exists(): - logger.warning( - "Cannot rename folder for %s: target path already exists: %s", - key, - target_path + # Target already exists — merge source into target instead of + # bailing. Without this, a bare folder ('Naruto') next to the + # year-suffixed one ('Naruto (2019)') would orphan the bare + # folder forever, producing the "series added twice" symptom. + try: + summary = self._merge_folder_into_target( + str(current_path), str(target_path) + ) + except Exception as exc: + logger.error( + "Failed to merge %s -> %s for %s: %s", + current_folder, target_folder, key, exc, + ) + return False + logger.info( + "Merged folder %s -> %s for series %s (moved=%d skipped=%d removed_source=%s)", + current_folder, target_folder, key, + summary["moved"], summary["skipped"], summary["removed_source"], ) - return False + + # Update in-memory cache + if key in self._app.list.keyDict: + self._app.list.keyDict[key].folder = target_folder + logger.debug( + "Updated in-memory cache folder for %s: %s", + key, target_folder + ) + + # Update database if session provided + if db is not None: + from src.server.database.service import AnimeSeriesService + + # Look up series by key to get database ID + series = await AnimeSeriesService.get_by_key(db, key) + if series: + await AnimeSeriesService.update( + db, series_id=series.id, folder=target_folder + ) + logger.debug( + "Updated DB folder for %s: %s", + key, target_folder + ) + + return True try: # Rename folder on disk + import shutil shutil.move(str(current_path), str(target_path)) logger.info( "Renamed folder for %s: %s -> %s", @@ -1317,6 +1356,83 @@ class AnimeService: ) return False + @staticmethod + def _merge_folder_into_target(source: str, target: str) -> dict: + """Merge a source folder's contents into an existing target folder. + + Walks the source tree and moves every file into the matching path + under the target. When a destination file already exists, the + source copy is removed (the target version wins; we don't keep + duplicates). When the source tree is fully consumed, the + (now-empty) source directory is removed. + + Both paths must be absolute and ``target`` must already exist on + disk. + + Returns a summary dict with ``moved`` (file count), ``skipped`` + (file count where target already had a copy), and + ``removed_source`` (bool). + """ + import os + import shutil + + if not os.path.isdir(source): + return {"moved": 0, "skipped": 0, "removed_source": False} + if not os.path.isdir(target): + raise ValueError(f"target does not exist: {target}") + + moved = 0 + skipped = 0 + for root, _dirs, files in os.walk(source): + rel_root = os.path.relpath(root, source) + dest_root = ( + target if rel_root == "." + else os.path.join(target, rel_root) + ) + os.makedirs(dest_root, exist_ok=True) + for name in files: + src_file = os.path.join(root, name) + dest_file = os.path.join(dest_root, name) + if os.path.exists(dest_file): + # Target wins — never overwrite existing content. + # Remove the orphaned source copy so cleanup below + # can rmdir it. + try: + os.remove(src_file) + except OSError as exc: + logger.warning( + "merge: could not remove duplicate %s: %s", + src_file, exc, + ) + skipped += 1 + logger.warning( + "merge: skipping %s (target already has %s)", + src_file, dest_file, + ) + continue + shutil.move(src_file, dest_file) + moved += 1 + + # Try to remove the (now empty) source tree. Walk bottom-up so + # leaf directories are removed before their parents. + removed_source = False + for root, dirs, files in os.walk(source, topdown=False): + for d in dirs: + try: + os.rmdir(os.path.join(root, d)) + except OSError: + pass + try: + os.rmdir(source) + removed_source = True + except OSError as exc: + logger.warning( + "merge: could not remove source directory %s: %s", + source, exc, + ) + + return {"moved": moved, "skipped": skipped, "removed_source": removed_source} + async def contains_in_db(self, key: str, db) -> bool: """ Check if a series with the given key exists in the database. diff --git a/src/server/services/folder_naming_service.py b/src/server/services/folder_naming_service.py index a5ebe37..8024f93 100644 --- a/src/server/services/folder_naming_service.py +++ b/src/server/services/folder_naming_service.py @@ -119,6 +119,79 @@ class FolderNamingService: return await self._execute_rename(series, folder, target_folder) + @staticmethod + def _merge_folder_into_target(source: str, target: str) -> dict: + """Merge a source folder's contents into an existing target folder. + + Walks the source tree and moves every file into the matching path under + the target. When a destination file already exists, the source copy is + removed (the target version wins; we don't keep duplicates). When the + source tree is fully consumed, the (now-empty) source directory is + removed. + + Both paths must be absolute and ``target`` must already exist on disk. + + Returns a summary dict with ``moved`` (file count), ``skipped`` (file + count where target already had a copy), and ``removed_source`` (bool). + Caller is responsible for any DB / cache updates that depend on the + outcome. + """ + if not os.path.isdir(source): + return {"moved": 0, "skipped": 0, "removed_source": False} + if not os.path.isdir(target): + raise ValueError(f"target does not exist: {target}") + + moved = 0 + skipped = 0 + for root, _dirs, files in os.walk(source): + rel_root = os.path.relpath(root, source) + dest_root = ( + target if rel_root == "." + else os.path.join(target, rel_root) + ) + os.makedirs(dest_root, exist_ok=True) + for name in files: + src_file = os.path.join(root, name) + dest_file = os.path.join(dest_root, name) + if os.path.exists(dest_file): + # Target wins — never overwrite existing content. Remove + # the orphaned source copy so cleanup below can rmdir it. + try: + os.remove(src_file) + except OSError as exc: + logger.warning( + "merge: could not remove duplicate %s: %s", + src_file, exc, + ) + skipped += 1 + logger.warning( + "merge: skipping %s (target already has %s)", + src_file, dest_file, + ) + continue + shutil.move(src_file, dest_file) + moved += 1 + + # Try to remove the (now empty) source tree. Walk bottom-up so leaf + # directories are removed before their parents. + removed_source = False + for root, dirs, files in os.walk(source, topdown=False): + for d in dirs: + try: + os.rmdir(os.path.join(root, d)) + except OSError: + pass + try: + os.rmdir(source) + removed_source = True + except OSError as exc: + logger.warning( + "merge: could not remove source directory %s: %s", + source, exc, + ) + + return {"moved": moved, "skipped": skipped, "removed_source": removed_source} + async def _execute_rename(self, series, old_folder: str, target_folder: str) -> FolderRenameResult: key = series.key @@ -132,16 +205,72 @@ class FolderNamingService: if not os.path.isdir(old_path): return FolderRenameResult(key=key, old_folder=old_folder, new_folder=None, success=False, skipped=False, reason="source folder does not exist on disk") + # If the target already exists, merge source into it instead of bailing. + # A bare folder ('Naruto') sitting next to the year-suffixed one + # ('Naruto (2019)') is how we get a series "added twice". Merging + # makes the rename succeed and removes the orphan folder. if os.path.isdir(target_path): - return FolderRenameResult(key=key, old_folder=old_folder, new_folder=None, success=False, skipped=False, reason="target folder already exists on disk") + try: + summary = self._merge_folder_into_target(old_path, target_path) + except Exception as exc: + logger.error( + "Failed to merge %s -> %s for %s: %s", + old_folder, target_folder, key, exc, + ) + return FolderRenameResult( + key=key, old_folder=old_folder, new_folder=None, + success=False, skipped=False, + reason=f"merge failed: {exc}", + ) + logger.info( + "Merged folder %s -> %s for series %s (moved=%d skipped=%d removed_source=%s)", + old_folder, target_folder, key, + summary["moved"], summary["skipped"], summary["removed_source"], + ) + # Update in-memory cache (best-effort) + try: + from src.server.utils.dependencies import get_series_app + series_app = get_series_app() + if key in series_app.list.keyDict: + series_app.list.keyDict[key].folder = target_folder + except Exception as exc: + logger.warning("Failed to update in-memory cache for %s: %s", key, exc) + + # Update database + async with _get_db_session() as db: + db_series = await AnimeSeriesService.get_by_key(db, key) + if db_series: + await AnimeSeriesService.update(db, series_id=db_series.id, folder=target_folder) + logger.debug("Updated DB folder for %s to %s", key, target_folder) + + # If source couldn't be removed (still had unexpected files) the + # state is worse than the original orphan, so surface that as a + # warning in the result while still reporting success. + note = None + if not summary["removed_source"]: + note = ( + f"merged (moved={summary['moved']}, skipped={summary['skipped']}) " + f"but source folder could not be removed" + ) + elif summary["skipped"]: + note = ( + f"merged (moved={summary['moved']}, " + f"kept target copies for {summary['skipped']} file(s))" + ) + return FolderRenameResult( + key=key, old_folder=old_folder, new_folder=target_folder, + success=True, skipped=False, reason=note, + ) + + # Target doesn't exist — plain rename. try: shutil.move(old_path, target_path) logger.info("Renamed folder %s -> %s for series %s", old_folder, target_folder, key) # Update in-memory cache try: - from src.server.SeriesApp import get_series_app + from src.server.utils.dependencies import get_series_app series_app = get_series_app() if key in series_app.list.keyDict: series_app.list.keyDict[key].folder = target_folder diff --git a/tests/unit/test_folder_naming_service.py b/tests/unit/test_folder_naming_service.py index cc2f1d8..26ae118 100644 --- a/tests/unit/test_folder_naming_service.py +++ b/tests/unit/test_folder_naming_service.py @@ -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): diff --git a/tests/unit/test_rename_folder_if_needed.py b/tests/unit/test_rename_folder_if_needed.py new file mode 100644 index 0000000..da7c34e --- /dev/null +++ b/tests/unit/test_rename_folder_if_needed.py @@ -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()