feat(setup): add done button and integrate NFO scan into initialization

- Add /api/setup/unresolved/done endpoint to mark phase complete
- NFO scan now runs after series sync during initialization
- Middleware redirects to /login after setup complete (was /loading)
- Done button allows skipping folder resolution with redirect to NFO scan phase
This commit is contained in:
2026-06-06 23:47:48 +02:00
parent be7b210959
commit 275aeb4544
7 changed files with 332 additions and 31 deletions

View File

@@ -13,18 +13,38 @@ The application uses a middleware-based redirect system to ensure users complete
│ SETUP FLOW │ │ SETUP FLOW │
├─────────────────────────────────────────────────────────────────────┤ ├─────────────────────────────────────────────────────────────────────┤
│ │ │ │
│ /setup ──► /loading ──┬──► /setup/unresolved ──► /loading │ /setup ──► /loading ──► /setup/unresolved ──► /loading ──► /login
│ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │
│ ▼ ▼ │ │ ▼ ▼ ▼
│ (first time) (WebSocket) (has folders) (all resolved) │ │ (first (Series Scan + (has folders) (all resolved)
time) NFO Scan) │ │
│ │
/login ◄───────────────────┴──────────────────────┤ │ │ │
│ │ ▼ │
│ │ [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 ## Middleware: SetupRedirectMiddleware
**File:** `src/server/middleware/setup_redirect.py` **File:** `src/server/middleware/setup_redirect.py`
@@ -49,9 +69,12 @@ The middleware intercepts all requests and redirects to `/setup` if:
### Middleware Logic ### Middleware Logic
1. **Setup incomplete** → Redirect to `/setup` 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) 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 ## Pages
@@ -104,17 +127,25 @@ Allows manual resolution of folders that couldn't be auto-matched:
- Provides search suggestions - Provides search suggestions
- Input field for entering provider key - Input field for entering provider key
- Resolve/delete actions - Resolve/delete actions
- **Done button** at top to complete the phase without resolving all folders
**Post-resolution flow:** **Post-resolution flow:**
```javascript ```javascript
function checkEmptyList() { // After clicking "Done" button
if (listEl.children.length === 0) { async function handleDone() {
// All folders resolved → return to loading // Call API to mark phase as complete
setTimeout(() => { window.location.href = '/loading'; }, 2000); 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`) ### 4. Login Page (`/login`)
**File:** `src/server/web/templates/login.html` **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}/resolve` | Resolve with provider key |
| `POST` | `/api/setup/unresolved/{folder_name}/search` | Re-search for matches | | `POST` | `/api/setup/unresolved/{folder_name}/search` | Re-search for matches |
| `DELETE` | `/api/setup/unresolved/{folder_name}` | Remove folder from tracking | | `DELETE` | `/api/setup/unresolved/{folder_name}` | Remove folder from tracking |
| `POST` | `/api/setup/unresolved/done` | Mark unresolved phase as complete |
### Auth API ### Auth API

View File

@@ -175,7 +175,6 @@ async def setup_auth(req: SetupRequest):
# Continue — scheduler failure should not break initialization # Continue — scheduler failure should not break initialization
# Send completion event # Send completion event
from src.server.services.progress_service import ProgressType
await progress_service.start_progress( await progress_service.start_progress(
progress_id="initialization_complete", progress_id="initialization_complete",
progress_type=ProgressType.SYSTEM, progress_type=ProgressType.SYSTEM,

View File

@@ -321,4 +321,56 @@ async def delete_unresolved_folder(
detail=f"Unresolved folder not found: {folder_name}" detail=f"Unresolved folder not found: {folder_name}"
) )
return {"status": "success", "message": f"Deleted unresolved folder: {folder_name}"} 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,
)

View File

@@ -105,6 +105,20 @@ class SetupRedirectMiddleware(BaseHTTPMiddleware):
return False 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( async def dispatch(
self, request: Request, call_next: Callable self, request: Request, call_next: Callable
) -> Response: ) -> Response:
@@ -120,13 +134,17 @@ class SetupRedirectMiddleware(BaseHTTPMiddleware):
path = request.url.path path = request.url.path
# Check if trying to access setup or loading page after completion # 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(): if not self._needs_setup():
# Setup is complete, check loading status
if path == "/setup": if path == "/setup":
# Redirect to loading if initialization is in progress # Redirect to loading if initialization is in progress
# Otherwise redirect to login # Otherwise redirect to login
return RedirectResponse(url="/login", status_code=302) 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": elif path == "/loading":
# Always allow access to loading page - it handles its own # Always allow access to loading page - it handles its own
# redirect flow via WebSocket events (initialization_complete # redirect flow via WebSocket events (initialization_complete

View File

@@ -386,6 +386,9 @@ async def perform_initial_setup(progress_service=None):
# Load series into memory from database # Load series into memory from database
await _load_series_into_memory(progress_service) await _load_series_into_memory(progress_service)
# Run NFO scan as part of initialization
await perform_nfo_scan_if_needed(progress_service)
return True return True
except (OSError, RuntimeError, ValueError) as e: 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: async def _execute_nfo_scan(progress_service=None) -> None:
"""Execute the actual NFO scan with TMDB data. """Execute the actual NFO scan with TMDB data.
Note: NFO service removed. This function is now a no-op stub.
Args: 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") from src.server.services.anime_service import get_anime_service
return 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): 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 from src.server.services.progress_service import ProgressType
await progress_service.start_progress( await progress_service.start_progress(
progress_id="nfo_scan", progress_id="nfo_scan",
progress_type=ProgressType.SYSTEM, progress_type=ProgressType.SCAN,
title="Processing NFO Metadata", title="Scanning NFO Files",
total=100, total=100,
message="Checking NFO scan status...", message="Checking NFO scan status...",
metadata={"step_id": "nfo_scan"} metadata={"step_id": "nfo_scan"}

View File

@@ -281,11 +281,13 @@
let isComplete = false; let isComplete = false;
const stepOrder = [ const stepOrder = [
'series_sync' 'series_sync',
'nfo_scan'
]; ];
const stepTitles = { const stepTitles = {
'series_sync': 'Syncing Series Database' 'series_sync': 'Syncing Series Database',
'nfo_scan': 'Scanning NFO Files'
}; };
function connectWebSocket() { function connectWebSocket() {
@@ -305,6 +307,14 @@
room: 'system' room: 'system'
} }
})); }));
// Subscribe to scan room for NFO scan progress
ws.send(JSON.stringify({
action: 'join',
data: {
room: 'scan'
}
}));
}; };
ws.onmessage = (event) => { ws.onmessage = (event) => {
@@ -349,6 +359,12 @@
const data = message.data || message; const data = message.data || message;
const { type, status, title, message: msg, percent, current, total, metadata } = data; 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 // Determine step ID based on type and metadata
let stepId = metadata?.step_id || type; 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) { function createStep(stepId, title) {
const container = document.getElementById('progressContainer'); const container = document.getElementById('progressContainer');
@@ -475,8 +560,8 @@
} }
async function checkUnresolvedAndProceed() { async function checkUnresolvedAndProceed() {
// Fetch unresolved folders and only redirect if there are any // Always check for unresolved folders first
// Otherwise go directly to login // After setup -> loading, always go through unresolved if there are any
try { try {
const token = localStorage.getItem('auth_token'); const token = localStorage.getItem('auth_token');
const res = await fetch('/api/setup/unresolved', { const res = await fetch('/api/setup/unresolved', {
@@ -493,7 +578,7 @@
} catch (err) { } catch (err) {
console.error('Failed to check unresolved folders:', 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'; window.location.href = '/login';
} }

View File

@@ -415,6 +415,36 @@
text-decoration: underline; 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) { @media (max-width: 600px) {
.folder-input-row { .folder-input-row {
flex-direction: column; flex-direction: column;
@@ -439,6 +469,11 @@
</div> </div>
<h1>Resolve Unresolved Series</h1> <h1>Resolve Unresolved Series</h1>
<p>Some series couldn't be found automatically. Enter the provider key for each folder to complete setup.</p> <p>Some series couldn't be found automatically. Enter the provider key for each folder to complete setup.</p>
<div class="header-actions">
<button class="done-btn" id="done-btn" onclick="handleDone()">
<i class="fas fa-check"></i> Done
</button>
</div>
</div> </div>
<div id="loading-state" class="loading-state"> <div id="loading-state" class="loading-state">
@@ -754,6 +789,7 @@
const listEl = document.getElementById('folder-list'); const listEl = document.getElementById('folder-list');
const emptyEl = document.getElementById('empty-state'); const emptyEl = document.getElementById('empty-state');
const skipLink = document.getElementById('skip-link'); const skipLink = document.getElementById('skip-link');
const doneBtn = document.getElementById('done-btn');
if (listEl.children.length === 0) { if (listEl.children.length === 0) {
listEl.style.display = 'none'; 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 = '<i class="fas fa-spinner fa-spin"></i> 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 = '<i class="fas fa-check"></i> Done';
}
} catch (err) {
showToast('Server error. Please try again.', 'error');
doneBtn.disabled = false;
doneBtn.innerHTML = '<i class="fas fa-check"></i> Done';
}
}
// Show Done button when there are folders
function showDoneButton() {
const doneBtn = document.getElementById('done-btn');
doneBtn.style.display = 'inline-flex';
}
// Init // Init
(async function init() { (async function init() {
const folders = await fetchUnresolved(); const folders = await fetchUnresolved();
if (folders !== null) { if (folders !== null) {
renderFolders(folders); renderFolders(folders);
if (folders.length > 0) {
showDoneButton();
}
} }
})(); })();
</script> </script>