feat: add loading page with real-time initialization progress

- Create loading.html template with WebSocket-based progress updates
- Update initialization_service to emit progress events via ProgressService
- Modify setup endpoint to run initialization in background and redirect to loading page
- Add /loading route in page_controller
- Show real-time progress for series sync, NFO scan, and media scan steps
- Display completion message with button to continue to app
- Handle errors with visual feedback
This commit is contained in:
2026-01-23 14:54:56 +01:00
parent 77ffdac84b
commit 48a2fd0f2a
5 changed files with 714 additions and 21 deletions

View File

@@ -30,7 +30,8 @@ async def setup_auth(req: SetupRequest):
"""Initial setup endpoint to configure the master password.
This endpoint also initializes the configuration with all provided values
and saves them to config.json.
and saves them to config.json. It triggers background initialization
and redirects to a loading page that shows real-time progress.
"""
if auth_service.is_configured():
raise HTTPException(
@@ -117,17 +118,9 @@ async def setup_auth(req: SetupRequest):
# Save the config with all updates
config_service.save_config(config, create_backup=False)
# Perform initial setup (series sync, NFO scan, media scan)
# This ensures everything is initialized immediately after setup
# without requiring an application restart
from src.config.settings import settings
from src.server.services.initialization_service import (
perform_initial_setup,
perform_nfo_scan_if_needed,
)
# Sync config.json values to settings object
# (mirroring the logic in fastapi_app.py lifespan)
from src.config.settings import settings
other_settings = dict(config.other) if config.other else {}
if other_settings.get("anime_directory"):
settings.anime_directory = str(other_settings["anime_directory"])
@@ -142,12 +135,53 @@ async def setup_auth(req: SetupRequest):
settings.nfo_download_fanart = config.nfo.download_fanart
settings.nfo_image_size = config.nfo.image_size
# Perform the initial series sync and mark as completed
await perform_initial_setup()
# Trigger initialization in background task
import asyncio
from src.server.services.initialization_service import (
perform_initial_setup,
perform_nfo_scan_if_needed,
)
from src.server.utils.dependencies import get_progress_service
# Perform NFO scan if configured
await perform_nfo_scan_if_needed()
progress_service = get_progress_service()
async def run_initialization():
"""Run initialization steps with progress updates."""
try:
# Perform the initial series sync and mark as completed
await perform_initial_setup(progress_service)
# Perform NFO scan if configured
await perform_nfo_scan_if_needed(progress_service)
# Send completion event
progress_service.emit_progress(
progress_id="initialization_complete",
progress_type="system",
status="completed",
title="Initialization Complete",
message="All initialization tasks completed successfully",
percent=100,
metadata={"initialization_complete": True}
)
except Exception as e:
# Send error event
progress_service.emit_progress(
progress_id="initialization_error",
progress_type="error",
status="failed",
title="Initialization Failed",
message=str(e),
percent=0,
metadata={"initialization_complete": True, "error": str(e)}
)
# Start initialization in background
asyncio.create_task(run_initialization())
# Return redirect to loading page
return {"status": "ok", "redirect": "/loading"}
# 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.

View File

@@ -49,3 +49,13 @@ async def queue_page(request: Request):
request,
title="Download Queue - Aniworld"
)
@router.get("/loading", response_class=HTMLResponse)
async def loading_page(request: Request):
"""Serve the initialization loading page."""
return render_template(
"loading.html",
request,
title="Initializing - Aniworld"
)

View File

@@ -1,4 +1,6 @@
"""Centralized initialization service for application startup and setup."""
from typing import Optional
import structlog
from src.config.settings import settings
@@ -7,7 +9,7 @@ from src.server.services.anime_service import sync_series_from_data_files
logger = structlog.get_logger(__name__)
async def perform_initial_setup():
async def perform_initial_setup(progress_service=None):
"""Perform initial setup including series sync and scan completion marking.
This function is called both during application lifespan startup
@@ -18,12 +20,27 @@ async def perform_initial_setup():
4. NFO scan is performed if configured
5. Media scan is performed
Args:
progress_service: Optional ProgressService instance for emitting updates
Returns:
bool: True if initialization was performed, False if skipped
"""
from src.server.database.connection import get_db_session
from src.server.database.system_settings_service import SystemSettingsService
# Send initial progress update
if progress_service:
progress_service.emit_progress(
progress_id="series_sync",
progress_type="system",
status="started",
title="Syncing Series Database",
message="Checking initialization status...",
percent=0,
metadata={"step_id": "series_sync"}
)
# Check if initial setup has been completed
try:
async with get_db_session() as db:
@@ -35,6 +52,16 @@ async def perform_initial_setup():
logger.info(
"Initial scan already completed, skipping data file sync"
)
if progress_service:
progress_service.emit_progress(
progress_id="series_sync",
progress_type="system",
status="completed",
title="Syncing Series Database",
message="Already completed",
percent=100,
metadata={"step_id": "series_sync"}
)
return False
else:
logger.info(
@@ -58,11 +85,32 @@ async def perform_initial_setup():
logger.info(
"Initialization skipped - anime directory not configured"
)
if progress_service:
progress_service.emit_progress(
progress_id="series_sync",
progress_type="system",
status="completed",
title="Syncing Series Database",
message="No anime directory configured",
percent=100,
metadata={"step_id": "series_sync"}
)
return False
# Only sync from data files on first run
if not is_initial_scan_done:
logger.info("Performing initial anime folder scan...")
if progress_service:
progress_service.emit_progress(
progress_id="series_sync",
progress_type="system",
status="in_progress",
title="Syncing Series Database",
message="Scanning anime folders...",
percent=25,
metadata={"step_id": "series_sync"}
)
sync_count = await sync_series_from_data_files(
settings.anime_directory
)
@@ -70,6 +118,17 @@ async def perform_initial_setup():
"Data file sync complete. Added %d series.", sync_count
)
if progress_service:
progress_service.emit_progress(
progress_id="series_sync",
progress_type="system",
status="in_progress",
title="Syncing Series Database",
message=f"Synced {sync_count} series from data files",
percent=75,
metadata={"step_id": "series_sync"}
)
# Mark initial scan as completed
try:
async with get_db_session() as db:
@@ -94,6 +153,17 @@ async def perform_initial_setup():
await anime_service._load_series_from_db()
logger.info("Series loaded from database into memory")
if progress_service:
progress_service.emit_progress(
progress_id="series_sync",
progress_type="system",
status="completed",
title="Syncing Series Database",
message="Series loaded into memory",
percent=100,
metadata={"step_id": "series_sync"}
)
return True
except (OSError, RuntimeError, ValueError) as e:
@@ -101,11 +171,26 @@ async def perform_initial_setup():
return False
async def perform_nfo_scan_if_needed():
"""Perform initial NFO scan if not yet completed and configured."""
async def perform_nfo_scan_if_needed(progress_service=None):
"""Perform initial NFO scan if not yet completed and configured.
Args:
progress_service: Optional ProgressService instance for emitting updates
"""
from src.server.database.connection import get_db_session
from src.server.database.system_settings_service import SystemSettingsService
if progress_service:
progress_service.emit_progress(
progress_id="nfo_scan",
progress_type="system",
status="started",
title="Processing NFO Metadata",
message="Checking NFO scan status...",
percent=0,
metadata={"step_id": "nfo_scan"}
)
# Check if initial NFO scan has been completed
try:
async with get_db_session() as db:
@@ -126,16 +211,51 @@ async def perform_nfo_scan_if_needed():
):
if not is_nfo_scan_done:
logger.info("Performing initial NFO scan...")
if progress_service:
progress_service.emit_progress(
progress_id="nfo_scan",
progress_type="system",
status="in_progress",
title="Processing NFO Metadata",
message="Scanning series for NFO files...",
percent=25,
metadata={"step_id": "nfo_scan"}
)
try:
from src.core.services.series_manager_service import (
SeriesManagerService,
)
manager = SeriesManagerService.from_settings()
if progress_service:
progress_service.emit_progress(
progress_id="nfo_scan",
progress_type="system",
status="in_progress",
title="Processing NFO Metadata",
message="Processing NFO files with TMDB data...",
percent=50,
metadata={"step_id": "nfo_scan"}
)
await manager.scan_and_process_nfo()
await manager.close()
logger.info("Initial NFO scan completed")
if progress_service:
progress_service.emit_progress(
progress_id="nfo_scan",
progress_type="system",
status="completed",
title="Processing NFO Metadata",
message="NFO scan completed successfully",
percent=100,
metadata={"step_id": "nfo_scan"}
)
# Mark NFO scan as completed
try:
async with get_db_session() as db:
@@ -155,15 +275,49 @@ async def perform_nfo_scan_if_needed():
e,
exc_info=True
)
if progress_service:
progress_service.emit_progress(
progress_id="nfo_scan",
progress_type="system",
status="failed",
title="Processing NFO Metadata",
message=f"NFO scan failed: {str(e)}",
percent=0,
metadata={"step_id": "nfo_scan"}
)
else:
logger.info(
"Skipping NFO scan - already completed on previous run"
)
if progress_service:
progress_service.emit_progress(
progress_id="nfo_scan",
progress_type="system",
status="completed",
title="Processing NFO Metadata",
message="Already completed",
percent=100,
metadata={"step_id": "nfo_scan"}
)
else:
if not settings.tmdb_api_key:
logger.info(
"NFO scan skipped - TMDB API key not configured"
)
message = "Skipped - TMDB API key not configured"
else:
message = "Skipped - NFO features disabled"
if progress_service:
progress_service.emit_progress(
progress_id="nfo_scan",
progress_type="system",
status="completed",
title="Processing NFO Metadata",
message=message,
percent=100,
metadata={"step_id": "nfo_scan"}
)
else:
logger.info(
"NFO scan skipped - auto_create and update_on_scan "

View File

@@ -0,0 +1,487 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AniWorld Manager - Initializing</title>
<link rel="stylesheet" href="/static/css/styles.css">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style>
.loading-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--color-primary-light) 0%, var(--color-primary) 100%);
padding: 2rem 1rem;
}
.loading-card {
background: var(--color-surface);
border-radius: 16px;
padding: 3rem 2rem;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 600px;
border: 1px solid var(--color-border);
}
.loading-header {
text-align: center;
margin-bottom: 2rem;
}
.loading-header .logo {
font-size: 3.5rem;
color: var(--color-primary);
margin-bottom: 1rem;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.05);
opacity: 0.8;
}
}
.loading-header h1 {
margin: 0;
color: var(--color-text);
font-size: 1.8rem;
font-weight: 600;
}
.loading-header p {
margin: 0.5rem 0 0 0;
color: var(--color-text-secondary);
font-size: 1rem;
}
.progress-container {
margin-top: 2rem;
}
.progress-step {
margin-bottom: 1.5rem;
padding: 1rem;
border-radius: 8px;
background: var(--color-background);
border-left: 4px solid transparent;
transition: all 0.3s ease;
}
.progress-step.active {
border-left-color: var(--color-primary);
background: var(--color-primary-light);
}
.progress-step.completed {
border-left-color: var(--color-success);
opacity: 0.7;
}
.progress-step.error {
border-left-color: var(--color-error);
}
.step-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.step-icon {
font-size: 1.2rem;
width: 24px;
text-align: center;
}
.step-icon.loading {
color: var(--color-primary);
animation: spin 1s linear infinite;
}
.step-icon.completed {
color: var(--color-success);
}
.step-icon.error {
color: var(--color-error);
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.step-title {
font-weight: 600;
color: var(--color-text);
font-size: 1rem;
flex: 1;
}
.step-status {
font-size: 0.875rem;
color: var(--color-text-secondary);
}
.step-message {
font-size: 0.875rem;
color: var(--color-text-secondary);
margin-left: 2rem;
line-height: 1.4;
}
.step-progress {
margin-left: 2rem;
margin-top: 0.5rem;
}
.progress-bar {
height: 6px;
background: var(--color-border);
border-radius: 3px;
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
background: var(--color-primary);
transition: width 0.3s ease;
border-radius: 3px;
}
.progress-text {
font-size: 0.75rem;
color: var(--color-text-secondary);
margin-top: 0.25rem;
text-align: right;
}
.error-message {
background: var(--color-error-light);
border: 1px solid var(--color-error);
border-radius: 8px;
padding: 1rem;
margin-top: 1rem;
color: var(--color-error);
}
.error-message i {
margin-right: 0.5rem;
}
.completion-message {
text-align: center;
padding: 2rem;
margin-top: 1rem;
}
.completion-message i {
font-size: 3rem;
color: var(--color-success);
margin-bottom: 1rem;
}
.completion-message h2 {
margin: 0 0 0.5rem 0;
color: var(--color-text);
}
.completion-message p {
color: var(--color-text-secondary);
margin-bottom: 1.5rem;
}
.btn-continue {
background: var(--color-primary);
color: white;
border: none;
padding: 0.75rem 2rem;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-continue:hover {
background: var(--color-primary-dark);
transform: translateY(-2px);
}
.connection-status {
text-align: center;
padding: 1rem;
margin-top: 1rem;
font-size: 0.875rem;
color: var(--color-text-secondary);
}
.connection-status.connected {
color: var(--color-success);
}
.connection-status.disconnected {
color: var(--color-error);
}
</style>
</head>
<body>
<div class="loading-container">
<div class="loading-card">
<div class="loading-header">
<div class="logo">
<i class="fas fa-tv"></i>
</div>
<h1>Initializing AniWorld Manager</h1>
<p>Please wait while we set up your anime library...</p>
</div>
<div class="progress-container" id="progressContainer">
<!-- Steps will be dynamically added here -->
</div>
<div class="connection-status" id="connectionStatus">
<i class="fas fa-circle-notch fa-spin"></i> Connecting...
</div>
<div class="completion-message" id="completionMessage" style="display: none;">
<i class="fas fa-check-circle"></i>
<h2>Initialization Complete!</h2>
<p>Your anime library is ready to use.</p>
<button class="btn-continue" onclick="continueToApp()">
<i class="fas fa-arrow-right"></i> Continue to AniWorld Manager
</button>
</div>
<div class="error-message" id="errorMessage" style="display: none;">
<i class="fas fa-exclamation-triangle"></i>
<span id="errorText"></span>
</div>
</div>
</div>
<script>
let ws = null;
const steps = new Map();
let isComplete = false;
const stepOrder = [
'series_sync',
'nfo_scan',
'media_scan'
];
const stepTitles = {
'series_sync': 'Syncing Series Database',
'nfo_scan': 'Processing NFO Metadata',
'media_scan': 'Scanning Media Files'
};
function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/progress`;
ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('WebSocket connected');
updateConnectionStatus(true);
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
console.log('Progress update:', data);
handleProgressUpdate(data);
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
updateConnectionStatus(false);
};
ws.onclose = () => {
console.log('WebSocket disconnected');
updateConnectionStatus(false);
// Reconnect after delay if not complete
if (!isComplete) {
setTimeout(connectWebSocket, 3000);
}
};
}
function updateConnectionStatus(connected) {
const statusEl = document.getElementById('connectionStatus');
if (connected) {
statusEl.className = 'connection-status connected';
statusEl.innerHTML = '<i class="fas fa-check-circle"></i> Connected';
} else {
statusEl.className = 'connection-status disconnected';
statusEl.innerHTML = '<i class="fas fa-times-circle"></i> Disconnected - Reconnecting...';
}
}
function handleProgressUpdate(data) {
const { type, status, title, message, percent, current, total, metadata } = data;
// Determine step ID based on type and metadata
let stepId = metadata?.step_id || type;
// Update or create step
if (!steps.has(stepId)) {
createStep(stepId, title || stepTitles[stepId] || stepId);
}
updateStep(stepId, status, message, percent, current, total);
// Check for completion
if (metadata?.initialization_complete) {
showCompletion();
}
// Handle errors
if (status === 'failed') {
showError(message || 'An error occurred during initialization');
}
}
function createStep(stepId, title) {
const container = document.getElementById('progressContainer');
const stepEl = document.createElement('div');
stepEl.id = `step-${stepId}`;
stepEl.className = 'progress-step';
stepEl.innerHTML = `
<div class="step-header">
<i class="fas fa-circle-notch fa-spin step-icon loading"></i>
<span class="step-title">${title}</span>
<span class="step-status">Waiting...</span>
</div>
<div class="step-message"></div>
<div class="step-progress" style="display: none;">
<div class="progress-bar">
<div class="progress-bar-fill" style="width: 0%;"></div>
</div>
<div class="progress-text">0%</div>
</div>
`;
// Insert in correct order
const existingSteps = Array.from(steps.keys());
let insertBefore = null;
for (const orderId of stepOrder) {
if (orderId === stepId) break;
if (!existingSteps.includes(orderId)) {
continue;
}
insertBefore = null;
}
for (const orderId of stepOrder.slice(stepOrder.indexOf(stepId) + 1)) {
const el = document.getElementById(`step-${orderId}`);
if (el) {
insertBefore = el;
break;
}
}
if (insertBefore) {
container.insertBefore(stepEl, insertBefore);
} else {
container.appendChild(stepEl);
}
steps.set(stepId, stepEl);
}
function updateStep(stepId, status, message, percent, current, total) {
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');
// Update status
stepEl.className = 'progress-step';
if (status === 'started' || 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
if (message) {
messageEl.textContent = message;
messageEl.style.display = 'block';
}
// Update progress bar
if (percent > 0 || (current > 0 && total > 0)) {
const actualPercent = percent || (current / total * 100);
progressEl.style.display = 'block';
progressFillEl.style.width = `${actualPercent}%`;
progressTextEl.textContent = `${Math.round(actualPercent)}% (${current}/${total})`;
}
}
function showCompletion() {
isComplete = true;
document.getElementById('completionMessage').style.display = 'block';
document.getElementById('connectionStatus').style.display = 'none';
if (ws) {
ws.close();
}
}
function showError(message) {
const errorEl = document.getElementById('errorMessage');
const errorTextEl = document.getElementById('errorText');
errorTextEl.textContent = message;
errorEl.style.display = 'block';
}
function continueToApp() {
window.location.href = '/';
}
// Start WebSocket connection when page loads
document.addEventListener('DOMContentLoaded', () => {
connectWebSocket();
});
</script>
</body>
</html>

View File

@@ -703,10 +703,18 @@
const data = await response.json();
if (response.ok && data.status === 'ok') {
showMessage('Setup completed successfully! Redirecting to login...', 'success');
setTimeout(() => {
window.location.href = '/login';
}, 2000);
// Redirect to loading page if provided, otherwise go to login
if (data.redirect) {
showMessage('Setup saved! Initializing your anime library...', 'success');
setTimeout(() => {
window.location.href = data.redirect;
}, 500);
} else {
showMessage('Setup completed successfully! Redirecting to login...', 'success');
setTimeout(() => {
window.location.href = '/login';
}, 2000);
}
} else {
const errorMessage = data.detail || data.message || 'Setup failed';
showMessage(errorMessage, 'error');