feat: add folder naming service to fix missing years in anime folder names
Runs after NFO refresh during scheduled rescans. Renames folders that are missing a year (e.g. 'Naruto' → 'Naruto (1999)') using the year from the database record. Safety: _build_target_folder() always strips any existing year suffix first, preventing double/triple year accumulation like 'Naruto (1999) (1999) (1999)'. Changes: - New FolderNamingService (folder_naming_service.py) with safe target name construction, DB update, and in-memory cache update - New SchedulerConfig field: folder_naming_after_nfo_scan (default True) - Integrated as step 3 in scheduler _perform_rescan() after NFO scan - Runtime UI: existing 'folder-scan-enabled' checkbox in index.html now wired to toggle the feature (app.js + scheduler-config.js) - Setup screen: new checkbox in setup.html Scheduler Settings section - API: scheduler config endpoint returns all scan toggles - Tests: 39 unit tests covering static helpers, rename logic, safety guard, and integration cases (folder_naming_service.py) - Docs: testing guide updated with FolderNamingService examples
This commit is contained in:
344
tests/unit/test_folder_naming_service.py
Normal file
344
tests/unit/test_folder_naming_service.py
Normal file
@@ -0,0 +1,344 @@
|
||||
"""Tests for FolderNamingService."""
|
||||
import os
|
||||
import re
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.server.services.folder_naming_service import (
|
||||
FolderNamingService,
|
||||
FolderRenameResult,
|
||||
FolderNamingReport,
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Static method tests
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
class TestFolderHasYear:
|
||||
"""Tests for _folder_has_year."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("folder", "expected"),
|
||||
[
|
||||
("Naruto", False),
|
||||
("Naruto (2020)", True),
|
||||
("Attack on Titan (2013)", True),
|
||||
("", False),
|
||||
("One Piece", False),
|
||||
("(2020)", True),
|
||||
("Naruto 2020", False), # no parentheses
|
||||
("Naruto (20)", False), # too short
|
||||
("Naruto (abcd)", False), # not digits
|
||||
],
|
||||
)
|
||||
def test_folder_has_year(self, folder, expected):
|
||||
assert FolderNamingService._folder_has_year(folder) == expected
|
||||
|
||||
|
||||
class TestExtractYearFromFolderName:
|
||||
"""Tests for _extract_year_from_folder_name."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("folder", "expected"),
|
||||
[
|
||||
("Naruto", None),
|
||||
("Naruto (2020)", 2020),
|
||||
("Attack on Titan (2013)", 2013),
|
||||
("", None),
|
||||
("(2020)", 2020),
|
||||
("Title (1800)", None), # out of range
|
||||
("Title (2200)", None), # out of range
|
||||
("Title (2020) - Extra", 2020), # year in middle
|
||||
],
|
||||
)
|
||||
def test_extract_year(self, folder, expected):
|
||||
assert FolderNamingService._extract_year_from_folder_name(folder) == expected
|
||||
|
||||
|
||||
class TestExtractTitleFromFolderName:
|
||||
"""Tests for _extract_title_from_folder_name."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("folder", "expected"),
|
||||
[
|
||||
("Naruto", "Naruto"),
|
||||
("Naruto (2020)", "Naruto"),
|
||||
("Attack on Titan (2013)", "Attack on Titan"),
|
||||
("", ""),
|
||||
("Naruto (2020) ", "Naruto"), # extra spaces
|
||||
("Title (2020) - Extra", "Title (2020) - Extra"), # year in middle not removed
|
||||
],
|
||||
)
|
||||
def test_extract_title(self, folder, expected):
|
||||
assert FolderNamingService._extract_title_from_folder_name(folder) == expected
|
||||
|
||||
|
||||
class TestBuildTargetFolder:
|
||||
"""Tests for _build_target_folder — the critical double-year prevention."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("folder", "year", "expected"),
|
||||
[
|
||||
# No year → add year
|
||||
("Naruto", 1999, "Naruto (1999)"),
|
||||
("One Piece", 1999, "One Piece (1999)"),
|
||||
# Has year → strip and replace
|
||||
("Naruto (2020)", 1999, "Naruto (1999)"),
|
||||
("Naruto (2020)", 2020, "Naruto (2020)"),
|
||||
# Has year with extra spaces
|
||||
("Naruto (2020)", 1999, "Naruto (1999)"),
|
||||
# Title with inner parens (not a year) → preserved
|
||||
("Dragon Ball (Super)", 1989, "Dragon Ball (Super) (1989)"),
|
||||
],
|
||||
)
|
||||
def test_build_target_folder(self, folder, year, expected):
|
||||
assert FolderNamingService._build_target_folder(folder, year) == expected
|
||||
|
||||
def test_repeated_calls_do_not_accumulate_years(self):
|
||||
"""Ensures safe: Naruto -> (1999) -> (1999) stays (1999), not (1999)(1999)."""
|
||||
folder = "Naruto"
|
||||
year = 1999
|
||||
step1 = FolderNamingService._build_target_folder(folder, year)
|
||||
step2 = FolderNamingService._build_target_folder(step1, year)
|
||||
step3 = FolderNamingService._build_target_folder(step2, year)
|
||||
assert step1 == "Naruto (1999)"
|
||||
assert step2 == "Naruto (1999)"
|
||||
assert step3 == "Naruto (1999)"
|
||||
|
||||
def test_already_yeared_folder_year_replaced_not_appended(self):
|
||||
"""Folder 'Naruto (2020)' with year=1999 → 'Naruto (1999)', not 'Naruto (2020) (1999)'."""
|
||||
result = FolderNamingService._build_target_folder("Naruto (2020)", 1999)
|
||||
assert result == "Naruto (1999)"
|
||||
assert "2020" not in result
|
||||
|
||||
def test_different_years_each_call_is_safe(self):
|
||||
"""Multiple different years applied sequentially always produce clean name."""
|
||||
folder = "Naruto"
|
||||
r1 = FolderNamingService._build_target_folder(folder, 1999)
|
||||
r2 = FolderNamingService._build_target_folder(r1, 1997)
|
||||
r3 = FolderNamingService._build_target_folder(r2, 1999)
|
||||
assert r1 == "Naruto (1999)"
|
||||
assert r2 == "Naruto (1997)"
|
||||
assert r3 == "Naruto (1999)"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Service tests (need mock DB + filesystem)
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db_session():
|
||||
"""Mock async DB session."""
|
||||
with patch("src.server.services.folder_naming_service._get_db_session") as mock:
|
||||
session = AsyncMock()
|
||||
mock.return_value.__aenter__.return_value = session
|
||||
mock.return_value.__aexit__.return_value = None
|
||||
yield session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_series(flash=False):
|
||||
"""Factory for mock anime series objects."""
|
||||
def _make(key, folder, year):
|
||||
series = MagicMock()
|
||||
series.key = key
|
||||
series.folder = folder
|
||||
series.year = year
|
||||
return series
|
||||
return _make
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_settings(tmp_path):
|
||||
"""Mock settings with a temp anime directory."""
|
||||
with patch("src.server.services.folder_naming_service.settings") as mock:
|
||||
mock.anime_directory = str(tmp_path)
|
||||
yield mock
|
||||
|
||||
|
||||
class TestFolderNamingServiceIntegration:
|
||||
"""Integration tests with mocked filesystem and DB."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_when_folder_already_has_year(
|
||||
self, mock_db_session, mock_series, mock_settings
|
||||
):
|
||||
"""Folder 'Naruto (1999)' with DB year=1999 → skipped."""
|
||||
series = mock_series("key1", "Naruto (1999)", 1999)
|
||||
mock_db_session.__aenter__.return_value.__aexit__.return_value = None
|
||||
|
||||
# Mock AnimeSeriesService.get_all
|
||||
with patch("src.server.services.folder_naming_service.AnimeSeriesService.get_all", new_callable=AsyncMock) as mock_get_all:
|
||||
mock_get_all.return_value = [series]
|
||||
|
||||
service = FolderNamingService()
|
||||
report = await service.run()
|
||||
|
||||
assert report.total == 1
|
||||
assert report.skipped == 1
|
||||
assert report.renamed == 0
|
||||
assert report.errors == 0
|
||||
assert report.results[0].reason == "folder already has year"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_when_db_has_no_year(
|
||||
self, mock_db_session, mock_series, mock_settings
|
||||
):
|
||||
"""Folder 'Naruto' with DB year=None → skipped."""
|
||||
series = mock_series("key1", "Naruto", 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:
|
||||
mock_get_all.return_value = [series]
|
||||
|
||||
service = FolderNamingService()
|
||||
report = await service.run()
|
||||
|
||||
assert report.skipped == 1
|
||||
assert report.results[0].reason == "no year in DB record"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_renames_folder_and_updates_db(
|
||||
self, tmp_path, mock_db_session, mock_series, mock_settings
|
||||
):
|
||||
"""Folder 'Naruto' with DB year=1999 → renames to 'Naruto (1999)'."""
|
||||
anime_dir = tmp_path
|
||||
(anime_dir / "Naruto").mkdir()
|
||||
(anime_dir / "Naruto" / "episode1.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) 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.skipped == 0
|
||||
assert report.errors == 0
|
||||
assert report.results[0].new_folder == "Naruto (1999)"
|
||||
assert (anime_dir / "Naruto (1999)").exists()
|
||||
assert not (anime_dir / "Naruto").exists()
|
||||
mock_update.assert_called_once()
|
||||
call_kwargs = mock_update.call_args.kwargs
|
||||
assert call_kwargs["folder"] == "Naruto (1999)"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_when_target_folder_already_exists(
|
||||
self, tmp_path, mock_db_session, mock_series, mock_settings
|
||||
):
|
||||
"""If 'Naruto (1999)' already exists, rename is skipped."""
|
||||
anime_dir = tmp_path
|
||||
(anime_dir / "Naruto").mkdir()
|
||||
(anime_dir / "Naruto (1999)").mkdir() # target already exists
|
||||
|
||||
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:
|
||||
mock_get_all.return_value = [series]
|
||||
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
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_safety_guard_detects_wrong_year_in_target(self, tmp_path, mock_db_session, mock_series, mock_settings):
|
||||
"""Safety guard triggers when _build_target_folder produces wrong year.
|
||||
|
||||
Uses a folder without a detectible year so _process_series proceeds to
|
||||
build a target, then the safety re-check finds a mismatch.
|
||||
"""
|
||||
anime_dir = tmp_path
|
||||
(anime_dir / "Naruto").mkdir()
|
||||
|
||||
series = mock_series("key1", "Naruto", 1999)
|
||||
mock_db_session.__aenter__.return_value.__aexit__.return_value = None
|
||||
|
||||
# Patch _build_target_folder to return a folder whose extracted year != DB year
|
||||
with patch("src.server.services.folder_naming_service.AnimeSeriesService.get_all", new_callable=AsyncMock) as mock_get_all, \
|
||||
patch.object(FolderNamingService, "_build_target_folder", return_value="Naruto (2020)"):
|
||||
mock_get_all.return_value = [series]
|
||||
mock_settings.anime_directory = str(anime_dir)
|
||||
|
||||
service = FolderNamingService()
|
||||
report = await service.run()
|
||||
|
||||
# Should be skipped by safety guard
|
||||
assert report.results[0].skipped is True
|
||||
assert "safety guard" in report.results[0].reason.lower()
|
||||
# Folder should NOT have been renamed
|
||||
assert (anime_dir / "Naruto").exists()
|
||||
assert not (anime_dir / "Naruto (2020)").exists()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_series_mixed_results(
|
||||
self, tmp_path, mock_db_session, mock_series, mock_settings
|
||||
):
|
||||
"""Series 1 needs rename, series 2 is skipped (has year), series 3 has no year."""
|
||||
anime_dir = tmp_path
|
||||
(anime_dir / "Naruto").mkdir()
|
||||
(anime_dir / "One Piece (1999)").mkdir()
|
||||
(anime_dir / "Bleach").mkdir()
|
||||
|
||||
series = [
|
||||
mock_series("key1", "Naruto", 1999),
|
||||
mock_series("key2", "One Piece (1999)", 1999),
|
||||
mock_series("key3", "Bleach", 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, \
|
||||
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 = 1
|
||||
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.total == 3
|
||||
assert report.renamed == 1 # Naruto → Naruto (1999)
|
||||
assert report.skipped == 2 # One Piece (has year), Bleach (no year)
|
||||
assert report.errors == 0
|
||||
|
||||
|
||||
class TestFolderNamingReport:
|
||||
def test_to_dict(self):
|
||||
result = FolderRenameResult(key="k1", old_folder="Naruto", new_folder="Naruto (1999)", success=True, skipped=False)
|
||||
report = FolderNamingReport(total=1, renamed=1, skipped=0, errors=0, results=[result])
|
||||
d = report.to_dict()
|
||||
assert d["total"] == 1
|
||||
assert d["renamed"] == 1
|
||||
assert d["results"][0]["new_folder"] == "Naruto (1999)"
|
||||
Reference in New Issue
Block a user