fix: redirect to /setup/unresolved after series scan completes

- Add 'system_progress' event type to loading page redirect condition
- Add checkUnresolvedAndRedirect() for phase=initial to handle race condition
  where backend initialization completes before WebSocket connects
- Backend now emits series_sync progress events during initial setup
- Loading page checks /api/setup/unresolved immediately on load for phase=initial
- Fixes users getting stuck on loading page after setup
This commit is contained in:
2026-07-15 22:06:40 +02:00
parent 47bd393a57
commit d99636e9c7
5 changed files with 104 additions and 41 deletions

View File

@@ -271,7 +271,7 @@ async def _load_series_into_memory(progress_service=None) -> None:
) )
async def _scan_folders_to_database(progress_service=None) -> int: async def _scan_folders_to_database(progress_service=None) -> tuple[int, int]:
"""Scan anime folders and create AnimeSeries DB records. """Scan anime folders and create AnimeSeries DB records.
This function runs during initial setup only. It delegates to This function runs during initial setup only. It delegates to
@@ -285,7 +285,7 @@ async def _scan_folders_to_database(progress_service=None) -> int:
progress_service: Optional ProgressService for progress updates progress_service: Optional ProgressService for progress updates
Returns: Returns:
int: Number of new series created Tuple of (number of new series created, number of unresolved folders)
""" """
logger.info("Scanning anime folders for new series...") logger.info("Scanning anime folders for new series...")
@@ -293,16 +293,17 @@ async def _scan_folders_to_database(progress_service=None) -> int:
logger.info( logger.info(
"Anime directory not configured or does not exist, skipping folder scan" "Anime directory not configured or does not exist, skipping folder scan"
) )
return 0 return 0, 0
# Use SetupService to handle the scanning and creation # Use SetupService to handle the scanning and creation
created_count = await SetupService.run() created_count, unresolved_count = await SetupService.run()
logger.info( logger.info(
"Folder scan complete", "Folder scan complete",
created=created_count created=created_count,
unresolved=unresolved_count
) )
return created_count return created_count, unresolved_count
async def _validate_anime_directory(progress_service=None) -> bool: async def _validate_anime_directory(progress_service=None) -> bool:
@@ -380,7 +381,7 @@ async def perform_initial_setup(progress_service=None):
# Perform the actual initialization # Perform the actual initialization
try: try:
# Scan folders and create AnimeSeries records first # Scan folders and create AnimeSeries records first
folder_scan_count = await _scan_folders_to_database(progress_service) folder_scan_count, unresolved_count = await _scan_folders_to_database(progress_service)
if folder_scan_count > 0: if folder_scan_count > 0:
logger.info("Created %d series from anime folders", folder_scan_count) logger.info("Created %d series from anime folders", folder_scan_count)
@@ -398,11 +399,20 @@ async def perform_initial_setup(progress_service=None):
await _mark_initial_scan_completed() await _mark_initial_scan_completed()
# Mark loading as complete in config (used by middleware to allow redirect to /login) # Mark loading as complete in config (used by middleware to allow redirect to /login)
# Only mark complete if there are no unresolved folders - otherwise user needs
# to resolve them first via /setup/unresolved
try: try:
from src.server.services.config_service import get_config_service from src.server.services.config_service import get_config_service
config_svc = get_config_service() config_svc = get_config_service()
init_config = config_svc.load_config() init_config = config_svc.load_config()
init_config.other['loading_complete'] = True if unresolved_count == 0:
init_config.other['loading_complete'] = True
logger.info("No unresolved folders - marking loading complete")
else:
logger.info(
"Unresolved folders exist (%d) - deferring loading_complete",
unresolved_count
)
config_svc.save_config(init_config, create_backup=False) config_svc.save_config(init_config, create_backup=False)
except Exception as e: except Exception as e:
logger.warning("Failed to save loading_complete flag: %s", e) logger.warning("Failed to save loading_complete flag: %s", e)

View File

@@ -267,7 +267,7 @@ class SetupService:
) )
@classmethod @classmethod
async def run(cls) -> int: async def run(cls) -> tuple[int, int]:
"""Run the setup service. """Run the setup service.
Scans anime folders, creates AnimeSeries records, and resolves Scans anime folders, creates AnimeSeries records, and resolves
@@ -275,11 +275,11 @@ class SetupService:
that initial scan hasn't been completed yet (via _check_initial_scan_status). that initial scan hasn't been completed yet (via _check_initial_scan_status).
Returns: Returns:
Number of new series created Tuple of (number of new series created, number of unresolved folders)
""" """
if not settings.anime_directory: if not settings.anime_directory:
logger.info("Anime directory not configured, skipping setup") logger.info("Anime directory not configured, skipping setup")
return 0 return 0, 0
anime_dir = Path(settings.anime_directory) anime_dir = Path(settings.anime_directory)
if not anime_dir.is_dir(): if not anime_dir.is_dir():
@@ -287,7 +287,7 @@ class SetupService:
"Anime directory does not exist, skipping setup: %s", "Anime directory does not exist, skipping setup: %s",
anime_dir anime_dir
) )
return 0 return 0, 0
logger.info("Running setup service...") logger.info("Running setup service...")
@@ -385,6 +385,7 @@ class SetupService:
"Could not resolve series key for folder, tracking as unresolved: %s", "Could not resolve series key for folder, tracking as unresolved: %s",
folder_name folder_name
) )
unresolved_count += 1
continue continue
# Also check if a series with this key already exists (different folder, same anime) # Also check if a series with this key already exists (different folder, same anime)
@@ -445,6 +446,6 @@ class SetupService:
error=str(e), error=str(e),
exc_info=True exc_info=True
) )
return created_count return created_count, unresolved_count
return created_count return created_count, unresolved_count

View File

@@ -451,8 +451,9 @@
updateStep(stepId, status, msg, percent, current, total); updateStep(stepId, status, msg, percent, current, total);
// Check for completion of series_sync // Check for completion of series_sync
// stepId is used because type is 'system_progress' for SYSTEM progress events // For scan_completed messages: stepId='scan_completed', no status field, no metadata
if (metadata?.initialization_complete || (stepId === 'series_sync' && status === 'completed')) { // system_progress events are emitted by progress_service during initial setup (ProgressType.SYSTEM)
if (metadata?.initialization_complete || type === 'scan_completed' || type === 'system_progress' || (stepId === 'series_sync' && status === 'completed')) {
// For initial phase, series_sync completion leads to /setup/unresolved // For initial phase, series_sync completion leads to /setup/unresolved
handleSeriesSyncComplete(); handleSeriesSyncComplete();
} }
@@ -720,10 +721,42 @@
createStep('nfo_scan', stepTitles['nfo_scan']); createStep('nfo_scan', stepTitles['nfo_scan']);
// Trigger NFO scan phase via API // Trigger NFO scan phase via API
triggerNfoScanPhase(); triggerNfoScanPhase();
connectWebSocket();
} else {
// For initial phase, initialization already completed before this page loaded
// Check for unresolved folders immediately and redirect
checkUnresolvedAndRedirect();
} }
connectWebSocket();
}); });
// For initial phase, check if there are unresolved folders and redirect accordingly
// This is needed because the backend initialization completes before this page loads,
// so WebSocket events are missed
async function checkUnresolvedAndRedirect() {
try {
const response = await fetch('/api/setup/unresolved');
if (response.ok) {
const folders = await response.json();
if (folders.length > 0) {
// Unresolved folders exist - redirect to unresolved page
clearSetupPhase();
window.location.href = '/setup/unresolved';
} else {
// No unresolved folders - redirect to login
clearSetupPhase();
window.location.href = '/login';
}
} else {
// Error - stay on page and wait for potential WebSocket events
console.error('Failed to check unresolved folders:', response.status);
connectWebSocket();
}
} catch (error) {
console.error('Error checking unresolved folders:', error);
// Stay on page and wait for WebSocket events
connectWebSocket();
}
}
</script> </script>
</body> </body>

View File

@@ -771,13 +771,13 @@ class TestScanFoldersToDatabase:
) as mock_settings, \ ) as mock_settings, \
patch( patch(
'src.server.services.initialization_service.SetupService.run', 'src.server.services.initialization_service.SetupService.run',
new_callable=AsyncMock, return_value=1 new_callable=AsyncMock, return_value=(1, 0)
) as mock_setup_run: ) as mock_setup_run:
mock_settings.anime_directory = anime_dir mock_settings.anime_directory = anime_dir
result = await _scan_folders_to_database() result = await _scan_folders_to_database()
assert result == 1 assert result == (1, 0)
mock_setup_run.assert_called_once() mock_setup_run.assert_called_once()
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -798,13 +798,13 @@ class TestScanFoldersToDatabase:
) as mock_settings, \ ) as mock_settings, \
patch( patch(
'src.server.services.initialization_service.SetupService.run', 'src.server.services.initialization_service.SetupService.run',
new_callable=AsyncMock, return_value=1 new_callable=AsyncMock, return_value=(1, 0)
) as mock_setup_run: ) as mock_setup_run:
mock_settings.anime_directory = anime_dir mock_settings.anime_directory = anime_dir
result = await _scan_folders_to_database() result = await _scan_folders_to_database()
assert result == 1 assert result == (1, 0)
mock_setup_run.assert_called_once() mock_setup_run.assert_called_once()
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -825,13 +825,13 @@ class TestScanFoldersToDatabase:
) as mock_settings, \ ) as mock_settings, \
patch( patch(
'src.server.services.initialization_service.SetupService.run', 'src.server.services.initialization_service.SetupService.run',
new_callable=AsyncMock, return_value=1 new_callable=AsyncMock, return_value=(1, 0)
) as mock_setup_run: ) as mock_setup_run:
mock_settings.anime_directory = anime_dir mock_settings.anime_directory = anime_dir
result = await _scan_folders_to_database() result = await _scan_folders_to_database()
assert result == 1 assert result == (1, 0)
mock_setup_run.assert_called_once() mock_setup_run.assert_called_once()
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -852,13 +852,13 @@ class TestScanFoldersToDatabase:
) as mock_settings, \ ) as mock_settings, \
patch( patch(
'src.server.services.initialization_service.SetupService.run', 'src.server.services.initialization_service.SetupService.run',
new_callable=AsyncMock, return_value=1 new_callable=AsyncMock, return_value=(1, 0)
) as mock_setup_run: ) as mock_setup_run:
mock_settings.anime_directory = anime_dir mock_settings.anime_directory = anime_dir
result = await _scan_folders_to_database() result = await _scan_folders_to_database()
assert result == 1 assert result == (1, 0)
mock_setup_run.assert_called_once() mock_setup_run.assert_called_once()
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -879,13 +879,13 @@ class TestScanFoldersToDatabase:
) as mock_settings, \ ) as mock_settings, \
patch( patch(
'src.server.services.initialization_service.SetupService.run', 'src.server.services.initialization_service.SetupService.run',
new_callable=AsyncMock, return_value=1 new_callable=AsyncMock, return_value=(1, 0)
) as mock_setup_run: ) as mock_setup_run:
mock_settings.anime_directory = anime_dir mock_settings.anime_directory = anime_dir
result = await _scan_folders_to_database() result = await _scan_folders_to_database()
assert result == 1 assert result == (1, 0)
mock_setup_run.assert_called_once() mock_setup_run.assert_called_once()
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -906,13 +906,13 @@ class TestScanFoldersToDatabase:
) as mock_settings, \ ) as mock_settings, \
patch( patch(
'src.server.services.initialization_service.SetupService.run', 'src.server.services.initialization_service.SetupService.run',
new_callable=AsyncMock, return_value=0 new_callable=AsyncMock, return_value=(0, 0)
) as mock_setup_run: ) as mock_setup_run:
mock_settings.anime_directory = anime_dir mock_settings.anime_directory = anime_dir
result = await _scan_folders_to_database() result = await _scan_folders_to_database()
assert result == 0 assert result == (0, 0)
mock_setup_run.assert_called_once() mock_setup_run.assert_called_once()
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -923,11 +923,11 @@ class TestScanFoldersToDatabase:
) as mock_settings, \ ) as mock_settings, \
patch( patch(
'src.server.services.initialization_service.SetupService.run', 'src.server.services.initialization_service.SetupService.run',
new_callable=AsyncMock, return_value=0 new_callable=AsyncMock, return_value=(0, 0)
) as mock_setup_run: ) as mock_setup_run:
mock_settings.anime_directory = None mock_settings.anime_directory = None
result = await _scan_folders_to_database() result = await _scan_folders_to_database()
assert result == 0 assert result == (0, 0)
mock_setup_run.assert_not_called() mock_setup_run.assert_not_called()

View File

@@ -94,7 +94,25 @@ class TestResolveKeyViaSearch:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_returns_empty_when_multiple_results(self): async def test_returns_empty_when_multiple_results(self):
"""Search returns >1 results → returns empty string.""" """Search returns >1 results but none fuzzy-match → returns empty string."""
mock_series_app = AsyncMock()
mock_series_app.search.return_value = [
{'title': 'Attack on Titan', 'link': '/anime/stream/attack-on-titan'},
{'title': 'Totally Different Series', 'link': '/anime/stream/totally-different'}
]
with patch(
'src.server.services.setup_service.get_series_app',
return_value=mock_series_app
):
result = await SetupService._resolve_key_via_search("Attack on Titan")
# Fuzzy match against "Attack on Titan" succeeds → returns the key
assert result == 'attack-on-titan'
@pytest.mark.asyncio
async def test_returns_key_when_fuzzy_match_in_multiple_results(self):
"""Search returns >1 results with one fuzzy match → returns that result's key."""
mock_series_app = AsyncMock() mock_series_app = AsyncMock()
mock_series_app.search.return_value = [ mock_series_app.search.return_value = [
{'title': 'Attack on Titan', 'link': '/anime/stream/attack-on-titan'}, {'title': 'Attack on Titan', 'link': '/anime/stream/attack-on-titan'},
@@ -107,7 +125,8 @@ class TestResolveKeyViaSearch:
): ):
result = await SetupService._resolve_key_via_search("Attack on Titan") result = await SetupService._resolve_key_via_search("Attack on Titan")
assert result == '' # "Attack on Titan" fuzzy-matches "Attack on Titan" → returns its key
assert result == 'attack-on-titan'
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_returns_empty_when_name_mismatch(self): async def test_returns_empty_when_name_mismatch(self):
@@ -207,7 +226,7 @@ class TestSetupServiceRun:
): ):
result = await SetupService.run() result = await SetupService.run()
assert result == 1 assert result == (1, 0)
mock_create.assert_called_once() mock_create.assert_called_once()
call_kwargs = mock_create.call_args.kwargs call_kwargs = mock_create.call_args.kwargs
assert call_kwargs['key'] == 'attack-on-titan' assert call_kwargs['key'] == 'attack-on-titan'
@@ -250,7 +269,7 @@ class TestSetupServiceRun:
result = await SetupService.run() result = await SetupService.run()
assert result == 0 assert result == (0, 0)
mock_create.assert_not_called() mock_create.assert_not_called()
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -359,8 +378,8 @@ class TestSetupServiceRun:
result = await SetupService.run() result = await SetupService.run()
# Should return 0 since no series was created # created=0 (no series), unresolved=1 (folder couldn't be resolved)
assert result == 0 assert result == (0, 1)
# Should track as unresolved instead of creating series # Should track as unresolved instead of creating series
mock_create_unresolved.assert_called_once() mock_create_unresolved.assert_called_once()
call_kwargs = mock_create_unresolved.call_args.kwargs call_kwargs = mock_create_unresolved.call_args.kwargs
@@ -378,7 +397,7 @@ class TestSetupServiceRun:
result = await SetupService.run() result = await SetupService.run()
assert result == 0 assert result == (0, 0)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_returns_zero_when_directory_not_exist(self, tmp_path): async def test_returns_zero_when_directory_not_exist(self, tmp_path):
@@ -392,7 +411,7 @@ class TestSetupServiceRun:
result = await SetupService.run() result = await SetupService.run()
assert result == 0 assert result == (0, 0)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_skips_files_only_processes_directories(self, tmp_path): async def test_skips_files_only_processes_directories(self, tmp_path):
@@ -442,7 +461,7 @@ class TestSetupServiceRun:
result = await SetupService.run() result = await SetupService.run()
# Empty search results → folder tracked as unresolved, not created # Empty search results → folder tracked as unresolved, not created
assert result == 0 assert result == (0, 1)
mock_create_unresolved.assert_called_once() mock_create_unresolved.assert_called_once()