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:
2026-07-26 21:45:08 +02:00
parent a384072901
commit 5f46d2e802
9 changed files with 2974 additions and 2447 deletions

View 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)"