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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
242
tests/unit/test_rename_folder_if_needed.py
Normal file
242
tests/unit/test_rename_folder_if_needed.py
Normal 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()
|
||||
Reference in New Issue
Block a user