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

@@ -1244,8 +1244,7 @@ class AnimeService:
Returns: Returns:
True if rename was performed, False if no rename needed or failed True if rename was performed, False if no rename needed or failed
""" """
import os from pathlib import Path
import shutil
if current_folder == target_folder: if current_folder == target_folder:
logger.debug( logger.debug(
@@ -1254,8 +1253,9 @@ class AnimeService:
) )
return False return False
current_path = self._directory / current_folder base_dir = Path(self._directory)
target_path = self._directory / target_folder current_path = base_dir / current_folder
target_path = base_dir / target_folder
if not current_path.exists(): if not current_path.exists():
logger.debug( logger.debug(
@@ -1265,15 +1265,54 @@ class AnimeService:
return False return False
if target_path.exists(): if target_path.exists():
logger.warning( # Target already exists — merge source into target instead of
"Cannot rename folder for %s: target path already exists: %s", # bailing. Without this, a bare folder ('Naruto') next to the
key, # year-suffixed one ('Naruto (2019)') would orphan the bare
target_path # 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: try:
# Rename folder on disk # Rename folder on disk
import shutil
shutil.move(str(current_path), str(target_path)) shutil.move(str(current_path), str(target_path))
logger.info( logger.info(
"Renamed folder for %s: %s -> %s", "Renamed folder for %s: %s -> %s",
@@ -1317,6 +1356,83 @@ class AnimeService:
) )
return False 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: async def contains_in_db(self, key: str, db) -> bool:
""" """
Check if a series with the given key exists in the database. Check if a series with the given key exists in the database.

View File

@@ -119,6 +119,79 @@ class FolderNamingService:
return await self._execute_rename(series, folder, target_folder) 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: async def _execute_rename(self, series, old_folder: str, target_folder: str) -> FolderRenameResult:
key = series.key key = series.key
@@ -132,16 +205,72 @@ class FolderNamingService:
if not os.path.isdir(old_path): 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") 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): 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: try:
shutil.move(old_path, target_path) shutil.move(old_path, target_path)
logger.info("Renamed folder %s -> %s for series %s", old_folder, target_folder, key) logger.info("Renamed folder %s -> %s for series %s", old_folder, target_folder, key)
# Update in-memory cache # Update in-memory cache
try: try:
from src.server.SeriesApp import get_series_app from src.server.utils.dependencies import get_series_app
series_app = get_series_app() series_app = get_series_app()
if key in series_app.list.keyDict: if key in series_app.list.keyDict:
series_app.list.keyDict[key].folder = target_folder series_app.list.keyDict[key].folder = target_folder

View File

@@ -241,28 +241,157 @@ class TestFolderNamingServiceIntegration:
assert call_kwargs["folder"] == "Naruto (1999)" assert call_kwargs["folder"] == "Naruto (1999)"
@pytest.mark.asyncio @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 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 = tmp_path
(anime_dir / "Naruto").mkdir() source = anime_dir / "Naruto"
(anime_dir / "Naruto (1999)").mkdir() # target already exists 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) series = mock_series("key1", "Naruto", 1999)
mock_db_session.__aenter__.return_value.__aexit__.return_value = None 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] 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) mock_settings.anime_directory = str(anime_dir)
service = FolderNamingService() service = FolderNamingService()
report = await service.run() report = await service.run()
assert report.errors == 1 # Outcome: renamed (not error) — source merged into target
assert report.renamed == 0 assert report.renamed == 1
assert report.results[0].reason == "target folder already exists on disk" assert report.errors == 0
assert (anime_dir / "Naruto").exists() # source not moved 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 @pytest.mark.asyncio
async def test_safety_guard_detects_wrong_year_in_target(self, tmp_path, mock_db_session, mock_series, mock_settings): 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()