feat: scan anime folders to populate AnimeSeries DB

- Add _scan_folders_to_database() - iterates anime_directory subdirs
- Extract title/year from folder names via (YYYY) pattern
- Resolve provider key via search when single match found
- Create AnimeSeries records for new folders only
- Add corresponding unit tests

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-06-04 21:34:10 +02:00
parent 830f6b4c93
commit 2b5c969a83
2 changed files with 396 additions and 0 deletions

View File

@@ -23,6 +23,7 @@ from src.server.services.initialization_service import (
_mark_media_scan_completed,
_mark_nfo_scan_completed,
_mark_scan_completed,
_scan_folders_to_database,
_sync_anime_folders,
_validate_anime_directory,
perform_initial_setup,
@@ -738,3 +739,284 @@ class TestInitializationIntegration:
result2 = await perform_initial_setup()
assert result2 is False
class TestScanFoldersToDatabase:
"""Test folder scanning and AnimeSeries creation."""
@pytest.mark.asyncio
async def test_scan_folders_extracts_year(self, tmp_path):
"""Folder 'Attack on Titan (2013)' → title='Attack on Titan', year=2013."""
# Create a real directory structure
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
folder_path = anime_dir / "Attack on Titan (2013)"
folder_path.mkdir()
mock_series_app = AsyncMock()
mock_series_app.search.return_value = []
mock_db = AsyncMock()
mock_get_db = MagicMock()
mock_get_db.__aenter__.return_value = mock_db
mock_get_db.__aexit__.return_value = None
with patch(
'src.server.services.initialization_service.settings'
) as mock_settings, \
patch(
'src.server.utils.dependencies.get_series_app',
return_value=mock_series_app
), \
patch(
'src.server.database.connection.get_db_session',
return_value=mock_get_db
), \
patch(
'src.server.services.initialization_service.AnimeSeriesService.get_by_folder',
new_callable=AsyncMock, return_value=None
), \
patch(
'src.server.services.initialization_service.AnimeSeriesService.create',
new_callable=AsyncMock
) as mock_create:
mock_settings.anime_directory = anime_dir
result = await _scan_folders_to_database()
assert result == 1
mock_create.assert_called_once()
call_kwargs = mock_create.call_args.kwargs
assert call_kwargs['name'] == "Attack on Titan"
assert call_kwargs['year'] == 2013
@pytest.mark.asyncio
async def test_scan_folders_no_year(self, tmp_path):
"""Folder 'OnePiece' → title='OnePiece', year=None."""
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
folder_path = anime_dir / "OnePiece"
folder_path.mkdir()
mock_series_app = AsyncMock()
mock_series_app.search.return_value = []
mock_db = AsyncMock()
mock_get_db = MagicMock()
mock_get_db.__aenter__.return_value = mock_db
mock_get_db.__aexit__.return_value = None
with patch(
'src.server.services.initialization_service.settings'
) as mock_settings, \
patch(
'src.server.utils.dependencies.get_series_app',
return_value=mock_series_app
), \
patch(
'src.server.database.connection.get_db_session',
return_value=mock_get_db
), \
patch(
'src.server.services.initialization_service.AnimeSeriesService.get_by_folder',
new_callable=AsyncMock, return_value=None
), \
patch(
'src.server.services.initialization_service.AnimeSeriesService.create',
new_callable=AsyncMock
) as mock_create:
mock_settings.anime_directory = anime_dir
result = await _scan_folders_to_database()
assert result == 1
call_kwargs = mock_create.call_args.kwargs
assert call_kwargs['name'] == "OnePiece"
assert call_kwargs['year'] is None
@pytest.mark.asyncio
async def test_scan_folders_single_match_uses_key(self, tmp_path):
"""Search returns 1 match with same name → use its key."""
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
folder_path = anime_dir / "Attack on Titan (2013)"
folder_path.mkdir()
mock_series_app = AsyncMock()
mock_series_app.search.return_value = [
{'key': 'attack-on-titan', 'name': 'Attack on Titan'}
]
mock_db = AsyncMock()
mock_get_db = MagicMock()
mock_get_db.__aenter__.return_value = mock_db
mock_get_db.__aexit__.return_value = None
with patch(
'src.server.services.initialization_service.settings'
) as mock_settings, \
patch(
'src.server.utils.dependencies.get_series_app',
return_value=mock_series_app
), \
patch(
'src.server.database.connection.get_db_session',
return_value=mock_get_db
), \
patch(
'src.server.services.initialization_service.AnimeSeriesService.get_by_folder',
new_callable=AsyncMock, return_value=None
), \
patch(
'src.server.services.initialization_service.AnimeSeriesService.create',
new_callable=AsyncMock
) as mock_create:
mock_settings.anime_directory = anime_dir
result = await _scan_folders_to_database()
assert result == 1
call_kwargs = mock_create.call_args.kwargs
assert call_kwargs['key'] == 'attack-on-titan'
@pytest.mark.asyncio
async def test_scan_folders_no_match_leaves_key_empty(self, tmp_path):
"""Search returns 0 results → key=''."""
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
folder_path = anime_dir / "Unknown Series (2020)"
folder_path.mkdir()
mock_series_app = AsyncMock()
mock_series_app.search.return_value = []
mock_db = AsyncMock()
mock_get_db = MagicMock()
mock_get_db.__aenter__.return_value = mock_db
mock_get_db.__aexit__.return_value = None
with patch(
'src.server.services.initialization_service.settings'
) as mock_settings, \
patch(
'src.server.utils.dependencies.get_series_app',
return_value=mock_series_app
), \
patch(
'src.server.database.connection.get_db_session',
return_value=mock_get_db
), \
patch(
'src.server.services.initialization_service.AnimeSeriesService.get_by_folder',
new_callable=AsyncMock, return_value=None
), \
patch(
'src.server.services.initialization_service.AnimeSeriesService.create',
new_callable=AsyncMock
) as mock_create:
mock_settings.anime_directory = anime_dir
result = await _scan_folders_to_database()
assert result == 1
call_kwargs = mock_create.call_args.kwargs
assert call_kwargs['key'] == ''
@pytest.mark.asyncio
async def test_scan_folders_multiple_matches_leaves_key_empty(self, tmp_path):
"""Search returns >1 results → key=''."""
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
folder_path = anime_dir / "Attack on Titan (2013)"
folder_path.mkdir()
mock_series_app = AsyncMock()
mock_series_app.search.return_value = [
{'key': 'attack-on-titan', 'name': 'Attack on Titan'},
{'key': 'attack-on-titan-clone', 'name': 'Attack on Titan Clone'}
]
mock_db = AsyncMock()
mock_get_db = MagicMock()
mock_get_db.__aenter__.return_value = mock_db
mock_get_db.__aexit__.return_value = None
with patch(
'src.server.services.initialization_service.settings'
) as mock_settings, \
patch(
'src.server.utils.dependencies.get_series_app',
return_value=mock_series_app
), \
patch(
'src.server.database.connection.get_db_session',
return_value=mock_get_db
), \
patch(
'src.server.services.initialization_service.AnimeSeriesService.get_by_folder',
new_callable=AsyncMock, return_value=None
), \
patch(
'src.server.services.initialization_service.AnimeSeriesService.create',
new_callable=AsyncMock
) as mock_create:
mock_settings.anime_directory = anime_dir
result = await _scan_folders_to_database()
assert result == 1
call_kwargs = mock_create.call_args.kwargs
assert call_kwargs['key'] == ''
@pytest.mark.asyncio
async def test_scan_folders_skips_existing(self, tmp_path):
"""Series with same folder already in DB → skip."""
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
folder_path = anime_dir / "Attack on Titan (2013)"
folder_path.mkdir()
mock_series_app = AsyncMock()
mock_db = AsyncMock()
mock_get_db = MagicMock()
mock_get_db.__aenter__.return_value = mock_db
mock_get_db.__aexit__.return_value = None
with patch(
'src.server.services.initialization_service.settings'
) as mock_settings, \
patch(
'src.server.utils.dependencies.get_series_app',
return_value=mock_series_app
), \
patch(
'src.server.database.connection.get_db_session',
return_value=mock_get_db
), \
patch(
'src.server.services.initialization_service.AnimeSeriesService.get_by_folder',
new_callable=AsyncMock, return_value=MagicMock() # existing series
), \
patch(
'src.server.services.initialization_service.AnimeSeriesService.create',
new_callable=AsyncMock
) as mock_create:
mock_settings.anime_directory = anime_dir
result = await _scan_folders_to_database()
assert result == 0
mock_create.assert_not_called()
@pytest.mark.asyncio
async def test_scan_folders_empty_anime_directory(self):
"""No anime directory configured → return 0."""
with patch(
'src.server.services.initialization_service.settings'
) as mock_settings:
mock_settings.anime_directory = None
result = await _scan_folders_to_database()
assert result == 0