diff --git a/Docs/NAVIGATION.md b/Docs/NAVIGATION.md index cf49aee..1b51190 100644 --- a/Docs/NAVIGATION.md +++ b/Docs/NAVIGATION.md @@ -13,18 +13,38 @@ The application uses a middleware-based redirect system to ensure users complete │ SETUP FLOW │ ├─────────────────────────────────────────────────────────────────────┤ │ │ -│ /setup ──► /loading ──┬──► /setup/unresolved ──► /loading │ -│ │ │ │ │ │ -│ │ │ │ │ │ -│ ▼ ▼ ▼ ▼ │ -│ (first time) (WebSocket) (has folders) (all resolved) │ -│ │ │ │ -│ ▼ │ │ -│ /login ◄───────────────────┴──────────────────────┤ +│ /setup ──► /loading ──► /setup/unresolved ──► /loading ──► /login │ +│ │ │ │ │ │ +│ │ │ │ │ │ +│ ▼ ▼ ▼ ▼ │ +│ (first (Series Scan + (has folders) (all resolved) │ +│ time) NFO Scan) │ │ +│ │ │ │ +│ │ │ │ +│ │ ▼ │ +│ │ [Done button] ──► marks complete │ +│ │ │ │ +│ │ ▼ │ +│ │ /loading (NFO phase runs again) │ +│ │ │ │ +│ └────────┴─────────────────────────────────────┘ │ │ └─────────────────────────────────────────────────────────────────────┘ ``` +**New Navigation Order:** +1. `/setup` → Initial configuration +2. `/loading` → Series scan + NFO scan +3. `/setup/unresolved` → Resolve folders (if any) +4. `/loading` → NFO scan runs again +5. `/login` → Authentication + +**Key Changes:** +- After `/setup/unresolved`, the "Done" button marks the phase as complete +- Revisiting `/setup/unresolved` after completion → redirects to `/loading` +- `/loading` always goes to `/setup/unresolved` if unresolved folders exist +- NFO scan runs as a separate phase after series sync during initialization + ## Middleware: SetupRedirectMiddleware **File:** `src/server/middleware/setup_redirect.py` @@ -49,9 +69,12 @@ The middleware intercepts all requests and redirects to `/setup` if: ### Middleware Logic 1. **Setup incomplete** → Redirect to `/setup` -2. **Setup complete, accessing `/setup`** → Redirect to `/loading` +2. **Setup complete, accessing `/setup`** → Redirect to `/login` 3. **Setup complete, accessing `/loading`** → Allow access (page handles its own redirect) -4. **API requests during setup** → Return 503 with `setup_url` +4. **Setup complete, accessing `/setup/unresolved`**: + - If `unresolved_completed` flag is set → Redirect to `/loading` + - Otherwise → Allow access +5. **API requests during setup** → Return 503 with `setup_url` ## Pages @@ -104,17 +127,25 @@ Allows manual resolution of folders that couldn't be auto-matched: - Provides search suggestions - Input field for entering provider key - Resolve/delete actions +- **Done button** at top to complete the phase without resolving all folders **Post-resolution flow:** ```javascript -function checkEmptyList() { - if (listEl.children.length === 0) { - // All folders resolved → return to loading - setTimeout(() => { window.location.href = '/loading'; }, 2000); - } +// After clicking "Done" button +async function handleDone() { + // Call API to mark phase as complete + await fetch('/api/setup/unresolved/done', { method: 'POST' }); + // Redirect to loading for final NFO scan + window.location.href = '/loading'; } ``` +**Done button behavior:** +- Marks all remaining folders as handled +- Sets `unresolved_completed` flag in config +- Redirects to `/loading` to run final NFO scan +- After completion, `/setup/unresolved` becomes inaccessible (redirects to `/loading`) + ### 4. Login Page (`/login`) **File:** `src/server/web/templates/login.html` @@ -132,6 +163,7 @@ Authentication page. After successful login → redirect to `/` (main app). | `POST` | `/api/setup/unresolved/{folder_name}/resolve` | Resolve with provider key | | `POST` | `/api/setup/unresolved/{folder_name}/search` | Re-search for matches | | `DELETE` | `/api/setup/unresolved/{folder_name}` | Remove folder from tracking | +| `POST` | `/api/setup/unresolved/done` | Mark unresolved phase as complete | ### Auth API diff --git a/src/server/api/auth.py b/src/server/api/auth.py index b6ba3ba..e32db4c 100644 --- a/src/server/api/auth.py +++ b/src/server/api/auth.py @@ -175,7 +175,6 @@ async def setup_auth(req: SetupRequest): # Continue — scheduler failure should not break initialization # Send completion event - from src.server.services.progress_service import ProgressType await progress_service.start_progress( progress_id="initialization_complete", progress_type=ProgressType.SYSTEM, diff --git a/src/server/api/setup_endpoints.py b/src/server/api/setup_endpoints.py index de11a8f..33c300b 100644 --- a/src/server/api/setup_endpoints.py +++ b/src/server/api/setup_endpoints.py @@ -321,4 +321,56 @@ async def delete_unresolved_folder( detail=f"Unresolved folder not found: {folder_name}" ) - return {"status": "success", "message": f"Deleted unresolved folder: {folder_name}"} \ No newline at end of file + return {"status": "success", "message": f"Deleted unresolved folder: {folder_name}"} + + +class DoneResponse(BaseModel): + """Response model for completing unresolved folders.""" + status: str = Field(..., description="Operation status") + message: str = Field(..., description="Human-readable message") + count: int = Field(..., description="Number of folders marked as done") + + +@router.post("/unresolved/done", response_model=DoneResponse) +async def complete_unresolved_folders( + db=Depends(get_database_session), +) -> DoneResponse: + """Mark all unresolved folders as handled and complete the unresolved phase. + + This endpoint: + 1. Marks the unresolved phase as completed in config + 2. Returns the count of folders that were handled + + After this, /setup/unresolved will redirect to /loading. + + Returns: + DoneResponse with status and count of handled folders + """ + from src.server.services.config_service import get_config_service + + # Get all unresolved folders + folders = await UnresolvedFolderService.get_all_unresolved(db) + count = len(folders) + + # Mark unresolved as completed in config + config_service = get_config_service() + try: + config = config_service.load_config() + if config.other is None: + config.other = {} + config.other['unresolved_completed'] = True + config_service.save_config(config, create_backup=False) + logger.info("Marked unresolved phase as completed") + except Exception as e: + logger.warning("Failed to save unresolved_completed flag: %s", e) + + logger.info( + "Completed unresolved phase: %d folders handled", + count + ) + + return DoneResponse( + status="success", + message=f"Marked {count} folders as handled. Unresolved phase completed.", + count=count, + ) \ No newline at end of file diff --git a/src/server/middleware/setup_redirect.py b/src/server/middleware/setup_redirect.py index 7958b42..870ce54 100644 --- a/src/server/middleware/setup_redirect.py +++ b/src/server/middleware/setup_redirect.py @@ -105,6 +105,20 @@ class SetupRedirectMiddleware(BaseHTTPMiddleware): return False + def _is_unresolved_completed(self) -> bool: + """Check if the unresolved phase has been completed. + + Returns: + True if unresolved phase is complete, False otherwise + """ + try: + config_service = get_config_service() + config = config_service.load_config() + other = config.other or {} + return bool(other.get('unresolved_completed', False)) + except Exception: + return False + async def dispatch( self, request: Request, call_next: Callable ) -> Response: @@ -120,13 +134,17 @@ class SetupRedirectMiddleware(BaseHTTPMiddleware): path = request.url.path # Check if trying to access setup or loading page after completion - if path in ("/setup", "/loading"): + if path in ("/setup", "/loading", "/setup/unresolved"): if not self._needs_setup(): - # Setup is complete, check loading status if path == "/setup": # Redirect to loading if initialization is in progress # Otherwise redirect to login 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) elif path == "/loading": # Always allow access to loading page - it handles its own # redirect flow via WebSocket events (initialization_complete diff --git a/src/server/services/initialization_service.py b/src/server/services/initialization_service.py index ef0197d..ca17ef8 100644 --- a/src/server/services/initialization_service.py +++ b/src/server/services/initialization_service.py @@ -386,6 +386,9 @@ 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) + return True except (OSError, RuntimeError, ValueError) as e: @@ -427,13 +430,46 @@ async def _is_nfo_scan_configured() -> bool: async def _execute_nfo_scan(progress_service=None) -> None: """Execute the actual NFO scan with TMDB data. - Note: NFO service removed. This function is now a no-op stub. - Args: - progress_service: Unused. Kept to avoid breaking call-sites. + progress_service: Optional ProgressService for emitting updates """ - logger.info("NFO scan skipped — NFO service removed") - return + from src.server.services.anime_service import get_anime_service + from src.server.services.nfo_scan_service import NfoScanService + + logger.info("Starting NFO scan...") + + anime_service = get_anime_service() + nfo_service = NfoScanService() + + # Subscribe to NFO events and forward to progress service + async def nfo_event_handler(event_data): + if event_data.get('type') == 'nfo_scan_progress': + data = event_data.get('data', {}) + if progress_service: + await progress_service.update_progress( + progress_id="nfo_scan", + current=data.get('current', 0), + total=data.get('total', 100), + message=data.get('message', 'Scanning...'), + key=data.get('key'), + folder=data.get('folder'), + ) + elif event_data.get('type') == 'nfo_scan_completed': + stats = event_data.get('statistics', {}) + if progress_service: + await progress_service.complete_progress( + progress_id="nfo_scan", + message=f"NFO scan complete: {stats.get('created', 0)} created, {stats.get('updated', 0)} updated", + ) + + nfo_service.subscribe_to_scan_events(nfo_event_handler) + + try: + # Run the scan + nfo_result = await nfo_service.scan_all(anime_service) + logger.info("NFO scan completed: %s", nfo_result) + finally: + nfo_service.unsubscribe_from_scan_events(nfo_event_handler) async def perform_nfo_scan_if_needed(progress_service=None): @@ -446,8 +482,8 @@ async def perform_nfo_scan_if_needed(progress_service=None): from src.server.services.progress_service import ProgressType await progress_service.start_progress( progress_id="nfo_scan", - progress_type=ProgressType.SYSTEM, - title="Processing NFO Metadata", + progress_type=ProgressType.SCAN, + title="Scanning NFO Files", total=100, message="Checking NFO scan status...", metadata={"step_id": "nfo_scan"} diff --git a/src/server/web/templates/loading.html b/src/server/web/templates/loading.html index 73a02e6..230e982 100644 --- a/src/server/web/templates/loading.html +++ b/src/server/web/templates/loading.html @@ -281,11 +281,13 @@ let isComplete = false; const stepOrder = [ - 'series_sync' + 'series_sync', + 'nfo_scan' ]; const stepTitles = { - 'series_sync': 'Syncing Series Database' + 'series_sync': 'Syncing Series Database', + 'nfo_scan': 'Scanning NFO Files' }; function connectWebSocket() { @@ -305,6 +307,14 @@ room: 'system' } })); + + // Subscribe to scan room for NFO scan progress + ws.send(JSON.stringify({ + action: 'join', + data: { + room: 'scan' + } + })); }; ws.onmessage = (event) => { @@ -349,6 +359,12 @@ 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') { + handleNfoScanUpdate(data); + return; + } + // Determine step ID based on type and metadata let stepId = metadata?.step_id || type; @@ -370,6 +386,75 @@ } } + function handleNfoScanUpdate(data) { + const stepId = 'nfo_scan'; + + if (!steps.has(stepId)) { + createStep(stepId, stepTitles[stepId] || 'Scanning NFO Files'); + } + + const stepEl = steps.get(stepId); + if (!stepEl) return; + + const iconEl = stepEl.querySelector('.step-icon'); + const statusEl = stepEl.querySelector('.step-status'); + const messageEl = stepEl.querySelector('.step-message'); + const progressEl = stepEl.querySelector('.step-progress'); + const progressFillEl = stepEl.querySelector('.progress-bar-fill'); + const progressTextEl = stepEl.querySelector('.progress-text'); + + const nfoData = data.data || data; + const { status, message, current, total, key, folder } = nfoData; + + // Update status + stepEl.className = 'progress-step'; + if (status === 'started') { + stepEl.classList.add('active'); + iconEl.className = 'fas fa-circle-notch fa-spin step-icon loading'; + statusEl.textContent = 'Starting...'; + } else if (status === 'in_progress') { + stepEl.classList.add('active'); + iconEl.className = 'fas fa-circle-notch fa-spin step-icon loading'; + statusEl.textContent = 'In Progress...'; + } else if (status === 'completed') { + stepEl.classList.add('completed'); + iconEl.className = 'fas fa-check-circle step-icon completed'; + statusEl.textContent = 'Complete'; + } else if (status === 'failed') { + stepEl.classList.add('error'); + iconEl.className = 'fas fa-exclamation-circle step-icon error'; + statusEl.textContent = 'Failed'; + } + + // Update message - show current folder being processed + if (message) { + messageEl.textContent = message; + messageEl.style.display = 'block'; + } else if (key && folder) { + messageEl.textContent = `Processing: ${folder}`; + messageEl.style.display = 'block'; + } + + // Update progress bar + if (current > 0 && total > 0) { + const actualPercent = (current / total) * 100; + progressEl.style.display = 'block'; + progressFillEl.style.width = `${actualPercent}%`; + progressTextEl.textContent = `${current}/${total} series`; + } else if (percent > 0) { + progressEl.style.display = 'block'; + progressFillEl.style.width = `${percent}%`; + progressTextEl.textContent = `${Math.round(percent)}%`; + } + + // Check for completion + if (data.type === 'nfo_scan_completed') { + setTimeout(() => { + checkUnresolvedAndProceed(); + }, 1000); + } + } + function createStep(stepId, title) { const container = document.getElementById('progressContainer'); @@ -475,8 +560,8 @@ } async function checkUnresolvedAndProceed() { - // Fetch unresolved folders and only redirect if there are any - // Otherwise go directly to login + // Always check for unresolved folders first + // After setup -> loading, always go through unresolved if there are any try { const token = localStorage.getItem('auth_token'); const res = await fetch('/api/setup/unresolved', { @@ -493,7 +578,7 @@ } catch (err) { console.error('Failed to check unresolved folders:', err); } - // No unresolved folders or error - go to login + // No unresolved folders - go to login window.location.href = '/login'; } diff --git a/src/server/web/templates/unresolved.html b/src/server/web/templates/unresolved.html index 6151aee..829e986 100644 --- a/src/server/web/templates/unresolved.html +++ b/src/server/web/templates/unresolved.html @@ -415,6 +415,36 @@ text-decoration: underline; } + .done-btn { + background: var(--color-success); + color: white; + border: none; + padding: 0.75rem 1.5rem; + border-radius: var(--border-radius-md); + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all var(--transition-duration); + display: none; + } + + .done-btn:hover:not(:disabled) { + background: #27ae60; + transform: translateY(-2px); + } + + .done-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .header-actions { + display: flex; + justify-content: center; + gap: 1rem; + margin-top: 1.5rem; + } + @media (max-width: 600px) { .folder-input-row { flex-direction: column; @@ -439,6 +469,11 @@

Resolve Unresolved Series

Some series couldn't be found automatically. Enter the provider key for each folder to complete setup.

+
+ +
@@ -754,6 +789,7 @@ const listEl = document.getElementById('folder-list'); const emptyEl = document.getElementById('empty-state'); const skipLink = document.getElementById('skip-link'); + const doneBtn = document.getElementById('done-btn'); if (listEl.children.length === 0) { listEl.style.display = 'none'; @@ -764,11 +800,54 @@ } } + async function completeUnresolved() { + const token = localStorage.getItem('auth_token'); + const res = await fetch('/api/setup/unresolved/done', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + return res.json(); + } + + async function handleDone() { + const doneBtn = document.getElementById('done-btn'); + doneBtn.disabled = true; + doneBtn.innerHTML = ' Processing...'; + + try { + const result = await completeUnresolved(); + if (result.status === 'success') { + showToast(result.message, 'success'); + setTimeout(() => { window.location.href = '/loading'; }, 1000); + } else { + showToast(result.message || 'Failed to complete', 'error'); + doneBtn.disabled = false; + doneBtn.innerHTML = ' Done'; + } + } catch (err) { + showToast('Server error. Please try again.', 'error'); + doneBtn.disabled = false; + doneBtn.innerHTML = ' Done'; + } + } + + // Show Done button when there are folders + function showDoneButton() { + const doneBtn = document.getElementById('done-btn'); + doneBtn.style.display = 'inline-flex'; + } + // Init (async function init() { const folders = await fetchUnresolved(); if (folders !== null) { renderFolders(folders); + if (folders.length > 0) { + showDoneButton(); + } } })();