From d99636e9c742464f398a3e93266431f408f0fe65 Mon Sep 17 00:00:00 2001 From: Lukas Date: Wed, 15 Jul 2026 22:06:40 +0200 Subject: [PATCH] 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 --- src/server/services/initialization_service.py | 26 ++++++++---- src/server/services/setup_service.py | 13 +++--- src/server/web/templates/loading.html | 41 +++++++++++++++++-- tests/unit/test_initialization_service.py | 28 ++++++------- tests/unit/test_setup_service.py | 37 +++++++++++++---- 5 files changed, 104 insertions(+), 41 deletions(-) diff --git a/src/server/services/initialization_service.py b/src/server/services/initialization_service.py index cccfecc..851c85e 100644 --- a/src/server/services/initialization_service.py +++ b/src/server/services/initialization_service.py @@ -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. 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 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...") @@ -293,16 +293,17 @@ async def _scan_folders_to_database(progress_service=None) -> int: logger.info( "Anime directory not configured or does not exist, skipping folder scan" ) - return 0 + return 0, 0 # Use SetupService to handle the scanning and creation - created_count = await SetupService.run() + created_count, unresolved_count = await SetupService.run() logger.info( "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: @@ -380,7 +381,7 @@ async def perform_initial_setup(progress_service=None): # Perform the actual initialization try: # 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: 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() # 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: from src.server.services.config_service import get_config_service config_svc = get_config_service() 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) except Exception as e: logger.warning("Failed to save loading_complete flag: %s", e) diff --git a/src/server/services/setup_service.py b/src/server/services/setup_service.py index e50abab..9bf3613 100644 --- a/src/server/services/setup_service.py +++ b/src/server/services/setup_service.py @@ -267,7 +267,7 @@ class SetupService: ) @classmethod - async def run(cls) -> int: + async def run(cls) -> tuple[int, int]: """Run the setup service. 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). Returns: - Number of new series created + Tuple of (number of new series created, number of unresolved folders) """ if not settings.anime_directory: logger.info("Anime directory not configured, skipping setup") - return 0 + return 0, 0 anime_dir = Path(settings.anime_directory) if not anime_dir.is_dir(): @@ -287,7 +287,7 @@ class SetupService: "Anime directory does not exist, skipping setup: %s", anime_dir ) - return 0 + return 0, 0 logger.info("Running setup service...") @@ -385,6 +385,7 @@ class SetupService: "Could not resolve series key for folder, tracking as unresolved: %s", folder_name ) + unresolved_count += 1 continue # Also check if a series with this key already exists (different folder, same anime) @@ -445,6 +446,6 @@ class SetupService: error=str(e), exc_info=True ) - return created_count + return created_count, unresolved_count - return created_count \ No newline at end of file + return created_count, unresolved_count \ No newline at end of file diff --git a/src/server/web/templates/loading.html b/src/server/web/templates/loading.html index e9c421c..53c5292 100644 --- a/src/server/web/templates/loading.html +++ b/src/server/web/templates/loading.html @@ -451,8 +451,9 @@ updateStep(stepId, status, msg, percent, current, total); // Check for completion of series_sync - // stepId is used because type is 'system_progress' for SYSTEM progress events - if (metadata?.initialization_complete || (stepId === 'series_sync' && status === 'completed')) { + // For scan_completed messages: stepId='scan_completed', no status field, no metadata + // 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 handleSeriesSyncComplete(); } @@ -720,10 +721,42 @@ createStep('nfo_scan', stepTitles['nfo_scan']); // Trigger NFO scan phase via API 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(); + } + } diff --git a/tests/unit/test_initialization_service.py b/tests/unit/test_initialization_service.py index 61c8fd7..046ddc4 100644 --- a/tests/unit/test_initialization_service.py +++ b/tests/unit/test_initialization_service.py @@ -771,13 +771,13 @@ class TestScanFoldersToDatabase: ) as mock_settings, \ patch( 'src.server.services.initialization_service.SetupService.run', - new_callable=AsyncMock, return_value=1 + new_callable=AsyncMock, return_value=(1, 0) ) as mock_setup_run: mock_settings.anime_directory = anime_dir result = await _scan_folders_to_database() - assert result == 1 + assert result == (1, 0) mock_setup_run.assert_called_once() @pytest.mark.asyncio @@ -798,13 +798,13 @@ class TestScanFoldersToDatabase: ) as mock_settings, \ patch( 'src.server.services.initialization_service.SetupService.run', - new_callable=AsyncMock, return_value=1 + new_callable=AsyncMock, return_value=(1, 0) ) as mock_setup_run: mock_settings.anime_directory = anime_dir result = await _scan_folders_to_database() - assert result == 1 + assert result == (1, 0) mock_setup_run.assert_called_once() @pytest.mark.asyncio @@ -825,13 +825,13 @@ class TestScanFoldersToDatabase: ) as mock_settings, \ patch( 'src.server.services.initialization_service.SetupService.run', - new_callable=AsyncMock, return_value=1 + new_callable=AsyncMock, return_value=(1, 0) ) as mock_setup_run: mock_settings.anime_directory = anime_dir result = await _scan_folders_to_database() - assert result == 1 + assert result == (1, 0) mock_setup_run.assert_called_once() @pytest.mark.asyncio @@ -852,13 +852,13 @@ class TestScanFoldersToDatabase: ) as mock_settings, \ patch( 'src.server.services.initialization_service.SetupService.run', - new_callable=AsyncMock, return_value=1 + new_callable=AsyncMock, return_value=(1, 0) ) as mock_setup_run: mock_settings.anime_directory = anime_dir result = await _scan_folders_to_database() - assert result == 1 + assert result == (1, 0) mock_setup_run.assert_called_once() @pytest.mark.asyncio @@ -879,13 +879,13 @@ class TestScanFoldersToDatabase: ) as mock_settings, \ patch( 'src.server.services.initialization_service.SetupService.run', - new_callable=AsyncMock, return_value=1 + new_callable=AsyncMock, return_value=(1, 0) ) as mock_setup_run: mock_settings.anime_directory = anime_dir result = await _scan_folders_to_database() - assert result == 1 + assert result == (1, 0) mock_setup_run.assert_called_once() @pytest.mark.asyncio @@ -906,13 +906,13 @@ class TestScanFoldersToDatabase: ) as mock_settings, \ patch( 'src.server.services.initialization_service.SetupService.run', - new_callable=AsyncMock, return_value=0 + new_callable=AsyncMock, return_value=(0, 0) ) as mock_setup_run: mock_settings.anime_directory = anime_dir result = await _scan_folders_to_database() - assert result == 0 + assert result == (0, 0) mock_setup_run.assert_called_once() @pytest.mark.asyncio @@ -923,11 +923,11 @@ class TestScanFoldersToDatabase: ) as mock_settings, \ patch( 'src.server.services.initialization_service.SetupService.run', - new_callable=AsyncMock, return_value=0 + new_callable=AsyncMock, return_value=(0, 0) ) as mock_setup_run: mock_settings.anime_directory = None result = await _scan_folders_to_database() - assert result == 0 + assert result == (0, 0) mock_setup_run.assert_not_called() diff --git a/tests/unit/test_setup_service.py b/tests/unit/test_setup_service.py index 9784f99..065b033 100644 --- a/tests/unit/test_setup_service.py +++ b/tests/unit/test_setup_service.py @@ -94,7 +94,25 @@ class TestResolveKeyViaSearch: @pytest.mark.asyncio 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.search.return_value = [ {'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") - assert result == '' + # "Attack on Titan" fuzzy-matches "Attack on Titan" → returns its key + assert result == 'attack-on-titan' @pytest.mark.asyncio async def test_returns_empty_when_name_mismatch(self): @@ -207,7 +226,7 @@ class TestSetupServiceRun: ): result = await SetupService.run() - assert result == 1 + assert result == (1, 0) mock_create.assert_called_once() call_kwargs = mock_create.call_args.kwargs assert call_kwargs['key'] == 'attack-on-titan' @@ -250,7 +269,7 @@ class TestSetupServiceRun: result = await SetupService.run() - assert result == 0 + assert result == (0, 0) mock_create.assert_not_called() @pytest.mark.asyncio @@ -359,8 +378,8 @@ class TestSetupServiceRun: result = await SetupService.run() - # Should return 0 since no series was created - assert result == 0 + # created=0 (no series), unresolved=1 (folder couldn't be resolved) + assert result == (0, 1) # Should track as unresolved instead of creating series mock_create_unresolved.assert_called_once() call_kwargs = mock_create_unresolved.call_args.kwargs @@ -378,7 +397,7 @@ class TestSetupServiceRun: result = await SetupService.run() - assert result == 0 + assert result == (0, 0) @pytest.mark.asyncio async def test_returns_zero_when_directory_not_exist(self, tmp_path): @@ -392,7 +411,7 @@ class TestSetupServiceRun: result = await SetupService.run() - assert result == 0 + assert result == (0, 0) @pytest.mark.asyncio async def test_skips_files_only_processes_directories(self, tmp_path): @@ -442,7 +461,7 @@ class TestSetupServiceRun: result = await SetupService.run() # Empty search results → folder tracked as unresolved, not created - assert result == 0 + assert result == (0, 1) mock_create_unresolved.assert_called_once()