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:
@@ -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,
|
||||
|
||||
@@ -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}"}
|
||||
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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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 @@
|
||||
</div>
|
||||
<h1>Resolve Unresolved Series</h1>
|
||||
<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 id="loading-state" class="loading-state">
|
||||
@@ -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 = '<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
|
||||
(async function init() {
|
||||
const folders = await fetchUnresolved();
|
||||
if (folders !== null) {
|
||||
renderFolders(folders);
|
||||
if (folders.length > 0) {
|
||||
showDoneButton();
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user