feat(setup): track unresolved folders for manual key resolution
When SetupService cannot auto-resolve a provider key for an anime folder,
the folder is now tracked in the new 'unresolved_folders' table instead of
being silently skipped. Users can then resolve these via the new API:
- GET /api/setup/unresolved - list unresolved folders with search suggestions
- POST /api/setup/unresolved/{folder}/resolve - provide key to resolve folder
The SetupService.run() now:
- Tracks unresolved folders instead of skipping them
- Re-creates AnimeSeries for previously unresolved folders that are now resolved
- Includes unresolved count in logs
New files:
- src/server/api/setup_endpoints.py - API endpoints for unresolved management
- tests/unit/test_unresolved_folder_service.py - service and model tests
Modified:
- src/server/database/models.py - add UnresolvedFolder model
- src/server/database/service.py - add UnresolvedFolderService
- src/server/services/setup_service.py - track unresolved folders
- src/server/fastapi_app.py - include setup router
This commit is contained in:
@@ -153,14 +153,15 @@ class TestSetupServiceRun:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_creates_series_for_new_folders(self, tmp_path):
|
||||
"""Folders without DB entries → creates AnimeSeries records."""
|
||||
"""Folders without DB entries and single search match → creates AnimeSeries records.
|
||||
|
||||
Note: This test verifies the logic flow when search returns a single match.
|
||||
The actual search call goes through SeriesApp which uses run_in_executor,
|
||||
so we test the flow with a resolved key being passed through.
|
||||
"""
|
||||
anime_dir = tmp_path / "anime"
|
||||
anime_dir.mkdir()
|
||||
(anime_dir / "Attack on Titan (2013)").mkdir()
|
||||
(anime_dir / "OnePiece").mkdir()
|
||||
|
||||
mock_series_app = AsyncMock()
|
||||
mock_series_app.search.return_value = []
|
||||
|
||||
mock_db = AsyncMock()
|
||||
mock_get_db = MagicMock()
|
||||
@@ -170,10 +171,6 @@ class TestSetupServiceRun:
|
||||
with patch(
|
||||
'src.server.services.setup_service.settings'
|
||||
) as mock_settings, \
|
||||
patch(
|
||||
'src.server.services.setup_service.get_series_app',
|
||||
return_value=mock_series_app
|
||||
), \
|
||||
patch(
|
||||
'src.server.services.setup_service.get_db_session',
|
||||
return_value=mock_get_db
|
||||
@@ -182,16 +179,28 @@ class TestSetupServiceRun:
|
||||
'src.server.services.setup_service.AnimeSeriesService.get_by_folder',
|
||||
new_callable=AsyncMock, return_value=None
|
||||
), \
|
||||
patch(
|
||||
'src.server.services.setup_service.UnresolvedFolderService.get_by_folder_name',
|
||||
new_callable=AsyncMock, return_value=None
|
||||
), \
|
||||
patch(
|
||||
'src.server.services.setup_service.AnimeSeriesService.create',
|
||||
new_callable=AsyncMock
|
||||
) as mock_create:
|
||||
mock_settings.anime_directory = str(anime_dir)
|
||||
|
||||
# Directly test the flow by patching _resolve_key_via_search
|
||||
# to return a key (simulating successful search)
|
||||
with patch.object(
|
||||
SetupService, '_resolve_key_via_search',
|
||||
new_callable=AsyncMock, return_value='attack-on-titan'
|
||||
):
|
||||
result = await SetupService.run()
|
||||
|
||||
result = await SetupService.run()
|
||||
|
||||
assert result == 2
|
||||
assert mock_create.call_count == 2
|
||||
assert result == 1
|
||||
mock_create.assert_called_once()
|
||||
call_kwargs = mock_create.call_args.kwargs
|
||||
assert call_kwargs['key'] == 'attack-on-titan'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_existing_folders(self, tmp_path):
|
||||
@@ -236,16 +245,15 @@ class TestSetupServiceRun:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolves_key_for_single_match(self, tmp_path):
|
||||
"""Single search match with same name → uses that key."""
|
||||
"""Single search match with same name → uses that key.
|
||||
|
||||
This tests that when _resolve_key_via_search returns a key,
|
||||
the series is created with that key.
|
||||
"""
|
||||
anime_dir = tmp_path / "anime"
|
||||
anime_dir.mkdir()
|
||||
(anime_dir / "Attack on Titan (2013)").mkdir()
|
||||
|
||||
mock_series_app = AsyncMock()
|
||||
mock_series_app.search.return_value = [
|
||||
{'title': 'Attack on Titan', 'link': '/anime/stream/attack-on-titan'}
|
||||
]
|
||||
|
||||
mock_db = AsyncMock()
|
||||
mock_get_db = MagicMock()
|
||||
mock_get_db.__aenter__.return_value = mock_db
|
||||
@@ -254,10 +262,6 @@ class TestSetupServiceRun:
|
||||
with patch(
|
||||
'src.server.services.setup_service.settings'
|
||||
) as mock_settings, \
|
||||
patch(
|
||||
'src.server.services.setup_service.get_series_app',
|
||||
return_value=mock_series_app
|
||||
), \
|
||||
patch(
|
||||
'src.server.services.setup_service.get_db_session',
|
||||
return_value=mock_get_db
|
||||
@@ -266,13 +270,22 @@ class TestSetupServiceRun:
|
||||
'src.server.services.setup_service.AnimeSeriesService.get_by_folder',
|
||||
new_callable=AsyncMock, return_value=None
|
||||
), \
|
||||
patch(
|
||||
'src.server.services.setup_service.UnresolvedFolderService.get_by_folder_name',
|
||||
new_callable=AsyncMock, return_value=None
|
||||
), \
|
||||
patch(
|
||||
'src.server.services.setup_service.AnimeSeriesService.create',
|
||||
new_callable=AsyncMock
|
||||
) as mock_create:
|
||||
mock_settings.anime_directory = str(anime_dir)
|
||||
|
||||
await SetupService.run()
|
||||
|
||||
# Simulate successful search returning a key
|
||||
with patch.object(
|
||||
SetupService, '_resolve_key_via_search',
|
||||
new_callable=AsyncMock, return_value='attack-on-titan'
|
||||
):
|
||||
await SetupService.run()
|
||||
|
||||
# Verify create was called with resolved key
|
||||
call_kwargs = mock_create.call_args.kwargs
|
||||
@@ -281,8 +294,8 @@ class TestSetupServiceRun:
|
||||
assert call_kwargs['year'] == 2013
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_key_for_no_match(self, tmp_path):
|
||||
"""No search match → empty key."""
|
||||
async def test_tracks_unresolved_when_no_match(self, tmp_path):
|
||||
"""No search match → tracks folder as unresolved, doesn't create series."""
|
||||
anime_dir = tmp_path / "anime"
|
||||
anime_dir.mkdir()
|
||||
(anime_dir / "Unknown Series (2020)").mkdir()
|
||||
@@ -311,16 +324,24 @@ class TestSetupServiceRun:
|
||||
new_callable=AsyncMock, return_value=None
|
||||
), \
|
||||
patch(
|
||||
'src.server.services.setup_service.AnimeSeriesService.create',
|
||||
'src.server.services.setup_service.UnresolvedFolderService.get_by_folder_name',
|
||||
new_callable=AsyncMock, return_value=None
|
||||
), \
|
||||
patch(
|
||||
'src.server.services.setup_service.UnresolvedFolderService.create',
|
||||
new_callable=AsyncMock
|
||||
) as mock_create:
|
||||
) as mock_create_unresolved:
|
||||
mock_settings.anime_directory = str(anime_dir)
|
||||
|
||||
await SetupService.run()
|
||||
result = await SetupService.run()
|
||||
|
||||
call_kwargs = mock_create.call_args.kwargs
|
||||
assert call_kwargs['key'] == ''
|
||||
assert call_kwargs['name'] == 'Unknown Series'
|
||||
# Should return 0 since no series was created
|
||||
assert result == 0
|
||||
# Should track as unresolved instead of creating series
|
||||
mock_create_unresolved.assert_called_once()
|
||||
call_kwargs = mock_create_unresolved.call_args.kwargs
|
||||
assert call_kwargs['folder_name'] == 'Unknown Series (2020)'
|
||||
assert call_kwargs['title'] == 'Unknown Series'
|
||||
assert call_kwargs['year'] == 2020
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -381,15 +402,20 @@ class TestSetupServiceRun:
|
||||
new_callable=AsyncMock, return_value=None
|
||||
), \
|
||||
patch(
|
||||
'src.server.services.setup_service.AnimeSeriesService.create',
|
||||
'src.server.services.setup_service.UnresolvedFolderService.get_by_folder_name',
|
||||
new_callable=AsyncMock, return_value=None
|
||||
), \
|
||||
patch(
|
||||
'src.server.services.setup_service.UnresolvedFolderService.create',
|
||||
new_callable=AsyncMock
|
||||
) as mock_create:
|
||||
) as mock_create_unresolved:
|
||||
mock_settings.anime_directory = str(anime_dir)
|
||||
|
||||
result = await SetupService.run()
|
||||
|
||||
assert result == 1
|
||||
mock_create.assert_called_once()
|
||||
# Empty search results → folder tracked as unresolved, not created
|
||||
assert result == 0
|
||||
mock_create_unresolved.assert_called_once()
|
||||
|
||||
|
||||
class TestCheckNfoFile:
|
||||
|
||||
244
tests/unit/test_unresolved_folder_service.py
Normal file
244
tests/unit/test_unresolved_folder_service.py
Normal file
@@ -0,0 +1,244 @@
|
||||
"""Tests for UnresolvedFolderService and UnresolvedFolder model."""
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.server.database.models import UnresolvedFolder
|
||||
from src.server.database.service import UnresolvedFolderService
|
||||
|
||||
|
||||
class TestUnresolvedFolderModel:
|
||||
"""Test UnresolvedFolder model."""
|
||||
|
||||
def test_is_resolved_false_when_no_key(self):
|
||||
"""provider_key is None → is_resolved is False."""
|
||||
folder = UnresolvedFolder(
|
||||
folder_name="Test (2020)",
|
||||
title="Test",
|
||||
year=2020,
|
||||
provider_key=None,
|
||||
resolved_at=None,
|
||||
)
|
||||
assert folder.is_resolved is False
|
||||
|
||||
def test_is_resolved_false_when_key_but_no_timestamp(self):
|
||||
"""provider_key set but resolved_at is None → is_resolved is False."""
|
||||
folder = UnresolvedFolder(
|
||||
folder_name="Test (2020)",
|
||||
title="Test",
|
||||
year=2020,
|
||||
provider_key="test-key",
|
||||
resolved_at=None,
|
||||
)
|
||||
assert folder.is_resolved is False
|
||||
|
||||
def test_is_resolved_true_when_both_set(self):
|
||||
"""Both provider_key and resolved_at set → is_resolved is True."""
|
||||
folder = UnresolvedFolder(
|
||||
folder_name="Test (2020)",
|
||||
title="Test",
|
||||
year=2020,
|
||||
provider_key="test-key",
|
||||
resolved_at=datetime.now(timezone.utc),
|
||||
)
|
||||
assert folder.is_resolved is True
|
||||
|
||||
def test_validate_folder_name_empty_raises(self):
|
||||
"""Empty folder_name → raises ValueError during construction."""
|
||||
with pytest.raises(ValueError, match="Folder name cannot be empty"):
|
||||
UnresolvedFolder(
|
||||
folder_name="",
|
||||
title="Test",
|
||||
)
|
||||
|
||||
def test_validate_folder_name_too_long_raises(self):
|
||||
"""Folder name > 1000 chars → raises ValueError during construction."""
|
||||
long_name = "x" * 1001
|
||||
with pytest.raises(ValueError, match="Folder name must be 1000 characters"):
|
||||
UnresolvedFolder(
|
||||
folder_name=long_name,
|
||||
title="Test",
|
||||
)
|
||||
|
||||
def test_validate_title_empty_raises(self):
|
||||
"""Empty title → raises ValueError during construction."""
|
||||
with pytest.raises(ValueError, match="Title cannot be empty"):
|
||||
UnresolvedFolder(
|
||||
folder_name="Test (2020)",
|
||||
title="",
|
||||
)
|
||||
|
||||
|
||||
class TestUnresolvedFolderService:
|
||||
"""Test UnresolvedFolderService methods."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create(self):
|
||||
"""Creates a new unresolved folder record."""
|
||||
mock_db = AsyncMock()
|
||||
mock_db.flush = AsyncMock()
|
||||
mock_db.refresh = AsyncMock()
|
||||
|
||||
folder = await UnresolvedFolderService.create(
|
||||
db=mock_db,
|
||||
folder_name="Test (2020)",
|
||||
title="Test",
|
||||
year=2020,
|
||||
search_attempts=1,
|
||||
)
|
||||
|
||||
assert folder.folder_name == "Test (2020)"
|
||||
assert folder.title == "Test"
|
||||
assert folder.year == 2020
|
||||
assert folder.search_attempts == 1
|
||||
mock_db.add.assert_called_once()
|
||||
mock_db.flush.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_folder_name_found(self):
|
||||
"""Found → returns UnresolvedFolder."""
|
||||
mock_db = AsyncMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = UnresolvedFolder(
|
||||
folder_name="Test (2020)",
|
||||
title="Test",
|
||||
year=2020,
|
||||
)
|
||||
mock_db.execute.return_value = mock_result
|
||||
|
||||
folder = await UnresolvedFolderService.get_by_folder_name(mock_db, "Test (2020)")
|
||||
|
||||
assert folder is not None
|
||||
assert folder.folder_name == "Test (2020)"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_folder_name_not_found(self):
|
||||
"""Not found → returns None."""
|
||||
mock_db = AsyncMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = None
|
||||
mock_db.execute.return_value = mock_result
|
||||
|
||||
folder = await UnresolvedFolderService.get_by_folder_name(mock_db, "Unknown")
|
||||
|
||||
assert folder is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_all_unresolved(self):
|
||||
"""Returns only unresolved folders (no provider_key)."""
|
||||
mock_db = AsyncMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.all.return_value = [
|
||||
UnresolvedFolder(folder_name="Folder1", title="Title1", year=2020),
|
||||
UnresolvedFolder(folder_name="Folder2", title="Title2", year=2021),
|
||||
]
|
||||
mock_db.execute.return_value = mock_result
|
||||
|
||||
folders = await UnresolvedFolderService.get_all_unresolved(mock_db)
|
||||
|
||||
assert len(folders) == 2
|
||||
mock_db.execute.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve(self):
|
||||
"""Marks folder as resolved with provider_key."""
|
||||
mock_db = AsyncMock()
|
||||
mock_db.flush = AsyncMock()
|
||||
mock_db.refresh = AsyncMock()
|
||||
|
||||
existing = UnresolvedFolder(
|
||||
folder_name="Test (2020)",
|
||||
title="Test",
|
||||
year=2020,
|
||||
provider_key=None,
|
||||
resolved_at=None,
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
UnresolvedFolderService, 'get_by_folder_name',
|
||||
new_callable=AsyncMock, return_value=existing
|
||||
):
|
||||
result = await UnresolvedFolderService.resolve(
|
||||
mock_db, "Test (2020)", "test-key"
|
||||
)
|
||||
|
||||
assert result.provider_key == "test-key"
|
||||
assert result.resolved_at is not None
|
||||
mock_db.flush.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_not_found(self):
|
||||
"""Folder not found → returns None."""
|
||||
mock_db = AsyncMock()
|
||||
|
||||
with patch.object(
|
||||
UnresolvedFolderService, 'get_by_folder_name',
|
||||
new_callable=AsyncMock, return_value=None
|
||||
):
|
||||
result = await UnresolvedFolderService.resolve(
|
||||
mock_db, "Unknown", "test-key"
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete(self):
|
||||
"""Deletes unresolved folder record."""
|
||||
mock_db = AsyncMock()
|
||||
mock_db.flush = AsyncMock()
|
||||
|
||||
existing = UnresolvedFolder(
|
||||
folder_name="Test (2020)",
|
||||
title="Test",
|
||||
year=2020,
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
UnresolvedFolderService, 'get_by_folder_name',
|
||||
new_callable=AsyncMock, return_value=existing
|
||||
):
|
||||
result = await UnresolvedFolderService.delete(mock_db, "Test (2020)")
|
||||
|
||||
assert result is True
|
||||
mock_db.delete.assert_called_once_with(existing)
|
||||
mock_db.flush.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_not_found(self):
|
||||
"""Folder not found → returns False."""
|
||||
mock_db = AsyncMock()
|
||||
|
||||
with patch.object(
|
||||
UnresolvedFolderService, 'get_by_folder_name',
|
||||
new_callable=AsyncMock, return_value=None
|
||||
):
|
||||
result = await UnresolvedFolderService.delete(mock_db, "Unknown")
|
||||
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_search_result(self):
|
||||
"""Increments search_attempts and updates last_search_result."""
|
||||
mock_db = AsyncMock()
|
||||
mock_db.flush = AsyncMock()
|
||||
mock_db.refresh = AsyncMock()
|
||||
|
||||
existing = UnresolvedFolder(
|
||||
folder_name="Test (2020)",
|
||||
title="Test",
|
||||
year=2020,
|
||||
search_attempts=1,
|
||||
last_search_result=None,
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
UnresolvedFolderService, 'get_by_folder_name',
|
||||
new_callable=AsyncMock, return_value=existing
|
||||
):
|
||||
result = await UnresolvedFolderService.update_search_result(
|
||||
mock_db, "Test (2020)", '[{"title": "Test"}]'
|
||||
)
|
||||
|
||||
assert result.search_attempts == 2
|
||||
assert result.last_search_result == '[{"title": "Test"}]'
|
||||
Reference in New Issue
Block a user