From 07c311c1cd5da59fee291f58037b8d9779afc34b Mon Sep 17 00:00:00 2001 From: Lukas Date: Sun, 7 Jun 2026 17:37:32 +0200 Subject: [PATCH] feat(setup): separate NFO scan into dedicated phase - Add /nfo-scan-phase endpoint to trigger NFO scan independently - Move NFO scan out of initial setup into separate post-unresolved phase - Add phase query param handling for /loading page (?phase=initial, ?phase=nfo) - Update setup redirect middleware to handle phase-based redirects - Update auth setup to pass phase=initial to loading page --- src/server/api/auth.py | 5 +- src/server/api/setup_endpoints.py | 47 +++++ src/server/middleware/setup_redirect.py | 21 ++- src/server/services/initialization_service.py | 80 +++++++- src/server/web/templates/loading.html | 178 +++++++++++++++--- src/server/web/templates/setup.html | 39 +--- src/server/web/templates/unresolved.html | 51 ++++- 7 files changed, 350 insertions(+), 71 deletions(-) diff --git a/src/server/api/auth.py b/src/server/api/auth.py index e32db4c..31d7f1c 100644 --- a/src/server/api/auth.py +++ b/src/server/api/auth.py @@ -208,8 +208,9 @@ async def setup_auth(req: SetupRequest): # Start initialization in background asyncio.create_task(run_initialization()) - # Return redirect to loading page - return {"status": "ok", "redirect": "/loading"} + # Return redirect to loading page with phase=initial + # The loading page will show ONLY series_sync step, then redirect to /setup/unresolved + return {"status": "ok", "redirect": "/loading?phase=initial"} # Note: Media scan is skipped during setup as it requires # background_loader service which is only available during # application lifespan. It will run on first application startup. diff --git a/src/server/api/setup_endpoints.py b/src/server/api/setup_endpoints.py index 33c300b..6c61f54 100644 --- a/src/server/api/setup_endpoints.py +++ b/src/server/api/setup_endpoints.py @@ -373,4 +373,51 @@ async def complete_unresolved_folders( status="success", message=f"Marked {count} folders as handled. Unresolved phase completed.", count=count, + ) + + +class NfoScanPhaseResponse(BaseModel): + """Response model for NFO scan phase trigger.""" + status: str = Field(..., description="Status of the operation") + message: str = Field(..., description="Human-readable message") + + +@router.post("/nfo-scan-phase", response_model=NfoScanPhaseResponse) +async def trigger_nfo_scan_phase() -> NfoScanPhaseResponse: + """Trigger the NFO scan phase. + + This endpoint is called by the loading page when accessed with ?phase=nfo. + It starts the NFO scan in the background and returns immediately. + The loading page then connects via WebSocket to receive progress updates. + + Returns: + NfoScanPhaseResponse with status and message + """ + import asyncio + + from src.server.services.initialization_service import perform_nfo_scan_phase + from src.server.services.progress_service import get_progress_service + + progress_service = get_progress_service() + + async def run_nfo_scan(): + """Run NFO scan phase with progress updates.""" + try: + await perform_nfo_scan_phase(progress_service) + logger.info("NFO scan phase completed via API trigger") + except Exception as e: + logger.error("NFO scan phase failed: %s", e, exc_info=True) + if progress_service: + await progress_service.fail_progress( + progress_id="nfo_scan", + error_message=f"NFO scan failed: {str(e)}", + metadata={"step_id": "nfo_scan", "phase": "nfo"} + ) + + # Start NFO scan in background + asyncio.create_task(run_nfo_scan()) + + return NfoScanPhaseResponse( + status="started", + message="NFO scan phase started. Check progress via WebSocket." ) \ No newline at end of file diff --git a/src/server/middleware/setup_redirect.py b/src/server/middleware/setup_redirect.py index 870ce54..51dab03 100644 --- a/src/server/middleware/setup_redirect.py +++ b/src/server/middleware/setup_redirect.py @@ -132,24 +132,31 @@ class SetupRedirectMiddleware(BaseHTTPMiddleware): Either a redirect to /setup or the normal response """ path = request.url.path + query_params = request.query_params # Check if trying to access setup or loading page after completion if path in ("/setup", "/loading", "/setup/unresolved"): if not self._needs_setup(): if path == "/setup": - # Redirect to loading if initialization is in progress - # Otherwise redirect to login + # Redirect to login if setup is already complete return RedirectResponse(url="/login", status_code=302) elif path == "/setup/unresolved": # Check if unresolved phase is already completed if self._is_unresolved_completed(): # Redirect to loading - unresolved phase already done - return RedirectResponse(url="/loading", status_code=302) + return RedirectResponse(url="/loading?phase=nfo", status_code=302) elif path == "/loading": - # Always allow access to loading page - it handles its own - # redirect flow via WebSocket events (initialization_complete - # event triggers redirect to /setup/unresolved) - pass + # Handle phase query parameter + phase = query_params.get("phase") + if phase == "initial": + # phase=initial should not be accessed after setup is complete + # Redirect to login + return RedirectResponse(url="/login", status_code=302) + elif not phase: + # No phase specified and setup is complete + # Redirect to login since user should be further in the flow + return RedirectResponse(url="/login", status_code=302) + # phase=nfo is allowed - it triggers the NFO scan phase # Skip setup check for exempt paths if self._is_path_exempt(path): diff --git a/src/server/services/initialization_service.py b/src/server/services/initialization_service.py index 2c43820..225c4d5 100644 --- a/src/server/services/initialization_service.py +++ b/src/server/services/initialization_service.py @@ -386,8 +386,8 @@ async def perform_initial_setup(progress_service=None): # Load series into memory from database await _load_series_into_memory(progress_service) - # Run NFO scan as part of initialization - await perform_nfo_scan_if_needed(progress_service) + # NOTE: NFO scan is NO longer run here - it runs in a separate phase + # after unresolved folders are completed (via /loading?phase=nfo) return True @@ -534,6 +534,82 @@ async def perform_nfo_scan_if_needed(progress_service=None): ) +async def perform_nfo_scan_phase(progress_service=None): + """Perform the NFO scan phase as part of the second loading page phase. + + This is called when the loading page is accessed with ?phase=nfo query param. + It runs the NFO scan and emits progress updates via the progress service. + + Args: + progress_service: Optional ProgressService for emitting updates + """ + logger.info("Starting NFO scan phase...") + + if progress_service: + from src.server.services.progress_service import ProgressType + await progress_service.start_progress( + progress_id="nfo_scan", + progress_type=ProgressType.SCAN, + title="Scanning NFO Files", + total=100, + message="Starting NFO scan...", + metadata={"step_id": "nfo_scan", "phase": "nfo"} + ) + + # Check if NFO scan was already completed + is_nfo_scan_done = await _check_nfo_scan_status() + + # Check if NFO features are configured + if not await _is_nfo_scan_configured(): + message = ( + "Skipped - TMDB API key not configured" + if not settings.tmdb_api_key + else "Skipped - NFO features disabled" + ) + logger.info("NFO scan phase skipped: %s", message) + + if progress_service: + await progress_service.complete_progress( + progress_id="nfo_scan", + message=message, + metadata={"step_id": "nfo_scan", "phase": "nfo", "nfo_scan_complete": True} + ) + return + + # Skip if already completed + if is_nfo_scan_done: + logger.info("Skipping NFO scan phase - already completed on previous run") + if progress_service: + await progress_service.complete_progress( + progress_id="nfo_scan", + message="Already completed", + metadata={"step_id": "nfo_scan", "phase": "nfo", "nfo_scan_complete": True} + ) + return + + # Execute the NFO scan + try: + await _execute_nfo_scan(progress_service) + await _mark_nfo_scan_completed() + + # Send completion event + if progress_service: + await progress_service.complete_progress( + progress_id="nfo_scan", + message="NFO scan completed successfully", + metadata={"step_id": "nfo_scan", "phase": "nfo", "nfo_scan_complete": True} + ) + logger.info("NFO scan phase completed successfully") + except Exception as e: + logger.error("Failed to complete NFO scan phase: %s", e, exc_info=True) + if progress_service: + await progress_service.fail_progress( + progress_id="nfo_scan", + error_message=f"NFO scan failed: {str(e)}", + metadata={"step_id": "nfo_scan", "phase": "nfo"} + ) + + async def _check_media_scan_status() -> bool: """Check if initial media scan has been completed. diff --git a/src/server/web/templates/loading.html b/src/server/web/templates/loading.html index 230e982..ae1ba11 100644 --- a/src/server/web/templates/loading.html +++ b/src/server/web/templates/loading.html @@ -279,6 +279,10 @@ let ws = null; const steps = new Map(); let isComplete = false; + + // Get phase from URL query parameter + const urlParams = new URLSearchParams(window.location.search); + const currentPhase = urlParams.get('phase') || 'initial'; const stepOrder = [ 'series_sync', @@ -290,6 +294,68 @@ 'nfo_scan': 'Scanning NFO Files' }; + // State management for setup flow + const SETUP_STATES = { + INITIAL: 'initial', + UNRESOLVED: 'unresolved', + NFO: 'nfo' + }; + + function setSetupPhase(phase) { + sessionStorage.setItem('setup_phase', phase); + } + + function getSetupPhase() { + return sessionStorage.getItem('setup_phase'); + } + + function clearSetupPhase() { + sessionStorage.removeItem('setup_phase'); + } + + function validateStateAndRedirect() { + const storedPhase = getSetupPhase(); + if (storedPhase && storedPhase !== currentPhase) { + // State mismatch - redirect to correct page based on stored phase + if (storedPhase === SETUP_STATES.INITIAL) { + window.location.href = '/loading?phase=initial'; + return false; + } else if (storedPhase === SETUP_STATES.UNRESOLVED) { + window.location.href = '/setup/unresolved'; + return false; + } else if (storedPhase === SETUP_STATES.NFO) { + window.location.href = '/loading?phase=nfo'; + return false; + } + } + return true; + } + + // For initial phase, we only show series_sync step + // For nfo phase, we only show nfo_scan step + function getStepsForPhase(phase) { + if (phase === 'nfo') { + return ['nfo_scan']; + } + return ['series_sync']; + } + + function triggerNfoScanPhase() { + // Call API to trigger NFO scan phase + fetch('/api/setup/nfo-scan-phase', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + } + }).then(res => { + if (!res.ok) { + console.error('Failed to trigger NFO scan phase'); + } + }).catch(err => { + console.error('Error triggering NFO scan phase:', err); + }); + } + function connectWebSocket() { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${window.location.host}/ws/connect`; @@ -300,21 +366,24 @@ console.log('WebSocket connected'); updateConnectionStatus(true); - // Subscribe to system room for progress updates - ws.send(JSON.stringify({ - action: 'join', - data: { - room: 'system' - } - })); - - // Subscribe to scan room for NFO scan progress - ws.send(JSON.stringify({ - action: 'join', - data: { - room: 'scan' - } - })); + // Subscribe to rooms based on phase + if (currentPhase === 'nfo') { + // For nfo phase, only subscribe to scan room + ws.send(JSON.stringify({ + action: 'join', + data: { + room: 'scan' + } + })); + } else { + // For initial phase (series_sync), subscribe to system room + ws.send(JSON.stringify({ + action: 'join', + data: { + room: 'system' + } + })); + } }; ws.onmessage = (event) => { @@ -359,12 +428,18 @@ const data = message.data || message; const { type, status, title, message: msg, percent, current, total, metadata } = data; - // Handle NFO scan events - if (type === 'nfo_scan_started' || type === 'nfo_scan_progress' || type === 'nfo_scan_completed') { + // For NFO phase, all events go to handleNfoScanUpdate + if (currentPhase === 'nfo') { handleNfoScanUpdate(data); return; } + // For initial phase (series_sync), skip NFO scan events + if (type === 'nfo_scan_started' || type === 'nfo_scan_progress' || type === 'nfo_scan_completed') { + // Ignore NFO scan events during initial phase + return; + } + // Determine step ID based on type and metadata let stepId = metadata?.step_id || type; @@ -375,9 +450,10 @@ updateStep(stepId, status, msg, percent, current, total); - // Check for completion - if (metadata?.initialization_complete) { - showCompletion(); + // Check for completion of series_sync + if (metadata?.initialization_complete || type === 'series_sync' && status === 'completed') { + // For initial phase, series_sync completion leads to /setup/unresolved + handleSeriesSyncComplete(); } // Handle errors @@ -385,6 +461,22 @@ showError(msg || 'An error occurred during initialization'); } } + + function handleSeriesSyncComplete() { + isComplete = true; + document.getElementById('connectionStatus').style.display = 'none'; + + if (ws) { + ws.close(); + } + + // Clear the initial phase state + clearSetupPhase(); + + // For initial phase, series_sync completion always leads to /setup/unresolved + // The unresolved page will handle checking if there are folders or redirect to nfo phase + window.location.href = '/setup/unresolved'; + } function handleNfoScanUpdate(data) { const stepId = 'nfo_scan'; @@ -404,7 +496,7 @@ const progressTextEl = stepEl.querySelector('.progress-text'); const nfoData = data.data || data; - const { status, message, current, total, key, folder } = nfoData; + const { status, message, current, total, key, folder, metadata } = nfoData; // Update status stepEl.className = 'progress-step'; @@ -447,13 +539,26 @@ progressTextEl.textContent = `${Math.round(percent)}%`; } - // Check for completion - if (data.type === 'nfo_scan_completed') { - setTimeout(() => { - checkUnresolvedAndProceed(); - }, 1000); + // Check for completion - handle based on phase + if (data.type === 'nfo_scan_completed' || metadata?.nfo_scan_complete) { + handleNfoPhaseComplete(); } } + + function handleNfoPhaseComplete() { + isComplete = true; + document.getElementById('connectionStatus').style.display = 'none'; + + if (ws) { + ws.close(); + } + + // Clear the NFO phase state + clearSetupPhase(); + + // For NFO phase, completion always goes to login + window.location.href = '/login'; + } function createStep(stepId, title) { const container = document.getElementById('progressContainer'); @@ -595,6 +700,27 @@ // Start WebSocket connection when page loads document.addEventListener('DOMContentLoaded', () => { + // Validate state and redirect if there's a mismatch + if (!validateStateAndRedirect()) { + return; // Redirect in progress + } + + // Set up the correct state for this phase + if (currentPhase === 'nfo') { + setSetupPhase(SETUP_STATES.NFO); + } else { + setSetupPhase(SETUP_STATES.INITIAL); + } + + // Initialize the correct steps based on phase + const stepsForPhase = getStepsForPhase(currentPhase); + if (stepsForPhase.length === 1 && stepsForPhase[0] === 'nfo_scan') { + // For nfo phase, create the step and trigger the scan immediately + createStep('nfo_scan', stepTitles['nfo_scan']); + // Trigger NFO scan phase via API + triggerNfoScanPhase(); + } + connectWebSocket(); }); diff --git a/src/server/web/templates/setup.html b/src/server/web/templates/setup.html index 06e208a..26e5f3d 100644 --- a/src/server/web/templates/setup.html +++ b/src/server/web/templates/setup.html @@ -790,37 +790,14 @@ const data = await response.json(); if (response.ok && data.status === 'ok') { - // Redirect to loading page if provided, otherwise check for unresolved folders - if (data.redirect) { - showMessage('Setup saved! Initializing your anime library...', 'success'); - setTimeout(() => { - window.location.href = data.redirect; - }, 500); - } else { - // Check for unresolved folders before redirecting - showMessage('Setup completed successfully! Checking for unresolved series...', 'success'); - setTimeout(async () => { - try { - const token = localStorage.getItem('auth_token'); - const res = await fetch('/api/setup/unresolved', { - headers: { 'Authorization': `Bearer ${token}` } - }); - if (res.ok) { - const unresolved = await res.json(); - if (unresolved && unresolved.length > 0) { - window.location.href = '/setup/unresolved'; - } else { - window.location.href = '/login'; - } - } else { - window.location.href = '/login'; - } - } catch (e) { - console.error('Error checking unresolved folders:', e); - window.location.href = '/login'; - } - }, 1000); - } + // Always redirect to loading page with initial phase + // The loading page will handle unresolved folder check + showMessage('Setup saved! Initializing your anime library...', 'success'); + setTimeout(() => { + // Set session storage state before redirecting + sessionStorage.setItem('setup_phase', 'initial'); + window.location.href = '/loading?phase=initial'; + }, 500); } else { const errorMessage = data.detail || data.message || 'Setup failed'; showMessage(errorMessage, 'error'); diff --git a/src/server/web/templates/unresolved.html b/src/server/web/templates/unresolved.html index 64c204b..626ce18 100644 --- a/src/server/web/templates/unresolved.html +++ b/src/server/web/templates/unresolved.html @@ -652,7 +652,11 @@ listEl.style.display = 'none'; emptyEl.style.display = 'block'; document.getElementById('skip-link').style.display = 'block'; - setTimeout(() => { window.location.href = '/loading'; }, 2000); + // No unresolved folders - redirect to NFO scan phase + setTimeout(() => { + sessionStorage.setItem('setup_phase', 'nfo'); + window.location.href = '/loading?phase=nfo'; + }, 2000); } else { listEl.style.display = 'flex'; emptyEl.style.display = 'none'; @@ -852,7 +856,11 @@ emptyEl.style.display = 'block'; skipLink.style.display = 'block'; showToast('All series configured!', 'success'); - setTimeout(() => { window.location.href = '/loading'; }, 2000); + // All folders resolved - redirect to NFO scan phase + setTimeout(() => { + sessionStorage.setItem('setup_phase', 'nfo'); + window.location.href = '/loading?phase=nfo'; + }, 2000); } } @@ -877,7 +885,12 @@ const result = await completeUnresolved(); if (result.status === 'success') { showToast(result.message, 'success'); - setTimeout(() => { window.location.href = '/loading'; }, 1000); + // Clear unresolved state and set NFO phase before redirecting + clearSetupPhase(); + setTimeout(() => { + sessionStorage.setItem('setup_phase', 'nfo'); + window.location.href = '/loading?phase=nfo'; + }, 1000); } else { showToast(result.message || 'Failed to complete', 'error'); doneBtn.disabled = false; @@ -896,8 +909,40 @@ doneBtn.style.display = 'inline-flex'; } + // State management for setup flow + function setSetupPhase(phase) { + sessionStorage.setItem('setup_phase', phase); + } + + function clearSetupPhase() { + sessionStorage.removeItem('setup_phase'); + } + + function validateStateAndRedirect() { + const storedPhase = sessionStorage.getItem('setup_phase'); + // If we have a stored phase that isn't 'unresolved', redirect appropriately + if (storedPhase && storedPhase !== 'unresolved') { + if (storedPhase === 'initial') { + window.location.href = '/loading?phase=initial'; + return false; + } else if (storedPhase === 'nfo') { + window.location.href = '/loading?phase=nfo'; + return false; + } + } + return true; + } + // Init (async function init() { + // Validate state and redirect if there's a mismatch + if (!validateStateAndRedirect()) { + return; // Redirect in progress + } + + // Set the unresolved phase state + setSetupPhase('unresolved'); + const folders = await fetchUnresolved(); if (folders !== null) { renderFolders(folders);