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:
@@ -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()
|
||||||
|
if unresolved_count == 0:
|
||||||
init_config.other['loading_complete'] = True
|
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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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();
|
connectWebSocket();
|
||||||
|
} else {
|
||||||
|
// For initial phase, initialization already completed before this page loaded
|
||||||
|
// Check for unresolved folders immediately and redirect
|
||||||
|
checkUnresolvedAndRedirect();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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>
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user