Compare commits

..

15 Commits

Author SHA1 Message Date
4e0c66ea9e chore: bump version 2026-06-07 17:43:01 +02:00
07c311c1cd 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
2026-06-07 17:37:32 +02:00
cf00c9f7c5 fix: keep search controls visible and enable suggestion click-to-resolve
- Search input and button now stay visible after Search Again for unlimited searches
- Clicking a suggestion populates provider key and triggers resolve, card disappears
- Added data-provider-key attribute to suggestion links for click handling
2026-06-07 16:18:50 +02:00
f3042206a8 chore: bump version 2026-06-07 16:01:01 +02:00
657e7f9bf5 fix: use correct get_anime_service in NFO scan
_execute_nfo_scan() was importing get_anime_service from anime_service.py
which is a factory requiring series_app argument. Changed to import from
dependencies.py which handles series_app internally and provides proper
dependency injection with caching.
2026-06-06 23:57:12 +02:00
fd3ec5df83 chore: bump version 2026-06-06 23:48:09 +02:00
275aeb4544 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
2026-06-06 23:47:48 +02:00
be7b210959 feat: add custom query support for unresolved folder re-search
- Add SearchFolderRequest model for optional custom search query
- Update search endpoint to use custom query if provided
- Add search-again input field in UI for custom queries
- Increment search_attempts counter on re-search
2026-06-06 23:31:25 +02:00
486c5440f2 docs: add comprehensive documentation files
Added documentation for API, architecture, configuration, database,
development guide, testing, and navigation. Includes helper scripts,
diagrams, and guides for NFO files and migration.
2026-06-06 23:15:46 +02:00
4076b9dd43 docs: add API key for documentation
Added key file to Docs directory for documentation purposes.
2026-06-06 23:15:20 +02:00
df93e8a81f backuo 2026-06-06 23:12:39 +02:00
576d9f7a7b chore: bump version 2026-06-06 23:09:47 +02:00
af93daeddc fix: allow unresolved page access during setup flow
- Remove premature auth redirect in unresolved.html fetchUnresolved()
- Add /api/setup/ to middleware exempt paths
- Unresolved page now loads without auth token (part of setup flow)
- Only redirect to login on 401 (expired token) or when all folders resolved
2026-06-06 23:08:54 +02:00
a05795bb35 chore: bump version 2026-06-06 22:47:56 +02:00
d22df947e4 feat(setup): redirect to /loading instead of / after setup flow
- loading.html: check for unresolved folders before redirecting, go to /login if none
- unresolved.html: redirect to /loading instead of / after skip/timeout
- add docs/NAVIGATION.md navigation flow documentation
2026-06-06 22:46:02 +02:00
32 changed files with 1006 additions and 141 deletions

View File

@@ -1 +1 @@
v1.4.8
v1.4.13

206
Docs/NAVIGATION.md Normal file
View File

@@ -0,0 +1,206 @@
# Navigation & Redirect Logic
This document describes the setup flow navigation, covering how users progress from initial setup through to the main application.
## Overview
The application uses a middleware-based redirect system to ensure users complete setup before accessing the main app. The flow involves multiple pages handling setup completion, unresolved folder detection, and initialization.
## Setup Flow
```
┌─────────────────────────────────────────────────────────────────────┐
│ SETUP FLOW │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ /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`
The middleware intercepts all requests and redirects to `/setup` if:
- No master password is configured
- Configuration file is missing or invalid
### Exempt Paths (always accessible)
| Path | Purpose |
|------|---------|
| `/setup` | Initial setup page |
| `/setup/unresolved` | Unresolved folder resolution |
| `/loading` | Initialization progress page |
| `/login` | Authentication |
| `/api/auth/*` | Auth endpoints |
| `/api/config/*` | Config API |
| `/api/health` | Health check |
| `/static/*` | Static assets |
### Middleware Logic
1. **Setup incomplete** → Redirect to `/setup`
2. **Setup complete, accessing `/setup`** → Redirect to `/login`
3. **Setup complete, accessing `/loading`** → Allow access (page handles its own redirect)
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
### 1. Setup Page (`/setup`)
**File:** `src/server/web/templates/setup.html`
Handles initial configuration:
- Master password creation
- Anime directory selection
- Database initialization
**Post-completion flow:**
- Redirects to `/loading` to begin initialization
### 2. Loading Page (`/loading`)
**File:** `src/server/web/templates/loading.html`
Shows initialization progress via WebSocket:
- Series scanning
- Database population
- Logo/image loading
**Post-initialization flow:**
```javascript
async function checkUnresolvedAndProceed() {
// Fetch unresolved folders via API
const res = await fetch('/api/setup/unresolved', {
headers: { 'Authorization': `Bearer ${token}` }
});
const folders = await res.json();
if (folders.length > 0) {
// Has unresolved folders → go to resolution page
window.location.href = '/setup/unresolved';
} else {
// No unresolved folders → go to login
window.location.href = '/login';
}
}
```
### 3. Unresolved Folders Page (`/setup/unresolved`)
**File:** `src/server/web/templates/unresolved.html`
Allows manual resolution of folders that couldn't be auto-matched:
- Shows list of unresolved folders
- 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
// 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`
Authentication page. After successful login → redirect to `/` (main app).
## API Endpoints
### Unresolved Folders API
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/setup/unresolved` | List all unresolved folders |
| `GET` | `/api/setup/unresolved/{folder_name}` | Get specific folder details |
| `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
| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/api/auth/setup` | Create master password |
| `POST` | `/api/auth/login` | Authenticate |
| `POST` | `/api/auth/logout` | End session |
## Key Files
| File | Purpose |
|------|---------|
| `src/server/middleware/setup_redirect.py` | Redirect middleware |
| `src/server/controllers/page_controller.py` | Page route handlers |
| `src/server/web/templates/setup.html` | Setup template |
| `src/server/web/templates/loading.html` | Loading template |
| `src/server/web/templates/unresolved.html` | Unresolved folders template |
| `src/server/api/setup_endpoints.py` | Unresolved folders API |
| `src/server/database/service.py` | UnresolvedFolderService |
## Common Issues
### Redirect Loop
**Symptom:** Browser keeps redirecting between pages.
**Causes:**
1. `loading.html` always redirected to `/setup/unresolved` without checking if any exist
2. `unresolved.html` redirected to `/` which middleware redirected back to `/login`
**Fix:** See the navigation logic updates in loading.html and unresolved.html.
### Can't Access Unresolved Page After Setup
**Symptom:** Middleware redirects to `/login` instead of allowing access to `/setup/unresolved`.
**Cause:** `/setup/unresolved` is in the exempt paths but the request may not be reaching it due to completion check timing.
**Fix:** The middleware allows access to `/loading` which handles the redirect to `/setup/unresolved` after initialization.

3
Docs/key Normal file
View File

@@ -0,0 +1,3 @@
API key : 299ae8f630a31bda814263c551361448
9bc3e547caff878615cbdba2cc421d37

View File

@@ -1,51 +0,0 @@
API key : 299ae8f630a31bda814263c551361448
/mnt/server/serien/Serien/
{
"name": "Aniworld",
"data_dir": "data",
"scheduler": {
"enabled": true,
"interval_minutes": 60,
"schedule_time": "03:00",
"schedule_days": [
"mon",
"tue",
"wed",
"thu",
"fri",
"sat",
"sun"
],
"auto_download_after_rescan": true,
"folder_scan_enabled": true
},
"logging": {
"level": "INFO",
"file": null,
"max_bytes": null,
"backup_count": 3
},
"backup": {
"enabled": false,
"path": "data/backups",
"keep_days": 30
},
"nfo": {
"tmdb_api_key": "9bc3e547caff878615cbdba2cc421d37",
"auto_create": true,
"update_on_scan": true,
"download_poster": true,
"download_logo": true,
"download_fanart": true,
"image_size": "original"
},
"other": {
"master_password_hash": "$pbkdf2-sha256$29000$HQNASKk1xpgTAgAgJGRMaQ$73TOCCM0UEZONyNXQEPa3SmIoXeG6C1l5mMFDNgYfMQ",
"anime_directory": "/data"
},
"version": "1.0.0"
}

View File

@@ -1,6 +1,6 @@
{
"name": "aniworld-web",
"version": "1.4.8",
"version": "1.4.13",
"description": "Aniworld Anime Download Manager - Web Frontend",
"type": "module",
"scripts": {

View File

@@ -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,
@@ -209,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.

View File

@@ -224,17 +224,25 @@ async def resolve_unresolved_folder(
)
class SearchFolderRequest(BaseModel):
"""Request model for searching an unresolved folder with custom query."""
query: Optional[str] = Field(None, description="Custom search query override")
@router.post("/unresolved/{folder_name}/search", response_model=UnresolvedFolderResponse)
async def search_unresolved_folder(
folder_name: str,
request: Optional[SearchFolderRequest] = None,
db=Depends(get_database_session),
) -> UnresolvedFolderResponse:
"""Re-search for a specific unresolved folder to get fresh suggestions.
Performs a new search using the folder's title and caches the results.
Performs a new search using the folder's title or a custom query.
Caches the results for subsequent display.
Args:
folder_name: URL-encoded folder name to search for
request: Optional SearchFolderRequest with custom query override
Returns:
UnresolvedFolderResponse with updated search suggestions
@@ -258,10 +266,13 @@ async def search_unresolved_folder(
detail=f"Folder already resolved: {folder_name}"
)
# Use custom query if provided, otherwise fall back to folder title
search_query = request.query if request and request.query else folder.title
# Perform search
series_app = get_series_app()
try:
results = await series_app.search(folder.title)
results = await series_app.search(search_query)
search_result_json = json.dumps(results) if results else "[]"
except Exception as e:
logger.warning(
@@ -278,7 +289,7 @@ async def search_unresolved_folder(
folder_name=folder.folder_name,
title=folder.title,
year=folder.year,
search_attempts=folder.search_attempts,
search_attempts=folder.search_attempts + 1,
search_suggestions=results,
)
@@ -310,4 +321,103 @@ 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,
)
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."
)

View File

@@ -37,6 +37,7 @@ class SetupRedirectMiddleware(BaseHTTPMiddleware):
"/login", # Login page (needs to be accessible after setup)
"/queue", # Queue page (for initial load)
"/api/auth/", # All auth endpoints (setup, login, logout, register)
"/api/setup/", # Setup API (unresolved folders, etc.)
"/ws/connect", # WebSocket connection (needed for loading page)
"/api/queue/", # Queue API endpoints
"/api/downloads/", # Download API endpoints
@@ -104,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:
@@ -117,20 +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"):
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
# 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?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):

View File

@@ -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)
# 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
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.nfo_scan_service import NfoScanService
from src.server.utils.dependencies import get_anime_service
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"}
@@ -498,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.

View File

@@ -279,15 +279,83 @@
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'
'series_sync',
'nfo_scan'
];
const stepTitles = {
'series_sync': 'Syncing Series Database'
'series_sync': 'Syncing Series Database',
'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`;
@@ -298,13 +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 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) => {
@@ -349,6 +428,18 @@
const data = message.data || message;
const { type, status, title, message: msg, percent, current, total, metadata } = data;
// 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;
@@ -359,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
@@ -369,6 +461,104 @@
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';
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, metadata } = 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 - 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');
@@ -475,9 +665,26 @@
}
async function checkUnresolvedAndProceed() {
// Always redirect to /setup/unresolved after initialization
// so users can manually enter unresolved animes
window.location.href = '/setup/unresolved';
// 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', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
const folders = await res.json();
if (folders && folders.length > 0) {
// Has unresolved folders - go to resolution page
window.location.href = '/setup/unresolved';
return;
}
}
} catch (err) {
console.error('Failed to check unresolved folders:', err);
}
// No unresolved folders - go to login
window.location.href = '/login';
}
function showError(message) {
@@ -493,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();
});
</script>

View File

@@ -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');

View File

@@ -238,6 +238,63 @@
opacity: 0.7;
}
.search-again-row {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
align-items: center;
}
.search-again-input {
flex: 1;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--border-radius-md);
font-size: 0.85rem;
background: var(--color-surface);
color: var(--color-text);
}
.search-again-input:focus {
outline: none;
border-color: var(--color-accent);
}
.search-again-row .search-again-btn {
margin-top: 0;
}
.search-again-btn.searching {
pointer-events: none;
opacity: 0.7;
}
.search-again-row {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
align-items: center;
}
.search-again-input {
flex: 1;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--border-radius-md);
font-size: 0.85rem;
background: var(--color-surface);
color: var(--color-text);
}
.search-again-input:focus {
outline: none;
border-color: var(--color-accent);
}
.search-again-row .search-again-btn {
margin-top: 0;
}
/* Empty state */
.empty-state {
text-align: center;
@@ -358,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;
@@ -382,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">
@@ -443,15 +535,13 @@
// API client helpers
async function fetchUnresolved() {
// Note: /api/setup/unresolved does not require auth
// It's accessible during the initial setup flow
const token = localStorage.getItem('auth_token');
if (!token) {
window.location.href = '/login';
return null;
}
const res = await fetch('/api/setup/unresolved', {
headers: { 'Authorization': `Bearer ${token}` }
});
const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
const res = await fetch('/api/setup/unresolved', { headers });
if (res.status === 401) {
// Redirect to login only if we had a token but it expired
localStorage.removeItem('auth_token');
window.location.href = '/login';
return null;
@@ -473,12 +563,17 @@
return res.json();
}
async function reSearchFolder(folderName) {
async function reSearchFolder(folderName, customQuery) {
const token = localStorage.getItem('auth_token');
const encodedName = encodeURIComponent(folderName);
const body = customQuery ? JSON.stringify({ query: customQuery }) : '{}';
const res = await fetch(`/api/setup/unresolved/${encodedName}/search`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` }
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: body
});
return res.json();
}
@@ -499,16 +594,21 @@
? folder.search_suggestions.map(s => `
<div class="suggestion-item">
<i class="fas fa-link"></i>
<a href="${s.link}" class="suggestion-link" target="_blank">${s.title}</a>
<a href="#" class="suggestion-link" data-provider-key="${s.provider_key || s.key || ''}" data-folder="${folder.folder_name}">${s.name || s.title}</a>
</div>
`).join('')
: '<div class="no-suggestions"><i class="fas fa-info-circle"></i> No suggestions found</div>';
const searchAgainBtn = (folder.search_suggestions && folder.search_suggestions.length === 0)
? `<button class="search-again-btn" data-folder="${folder.folder_name}">
<i class="fas fa-search"></i> Search Again
</button>`
: '';
// Always show search row so user can search multiple times
const searchAgainBtn = `<div class="search-again-row">
<input type="text" class="search-again-input"
placeholder="Custom search..."
value="${folder.title || ''}"
data-folder="${folder.folder_name}">
<button class="search-again-btn" data-folder="${folder.folder_name}">
<i class="fas fa-search"></i> Search Again
</button>
</div>`;
return `
<div class="folder-item" data-folder="${folder.folder_name}">
@@ -552,7 +652,11 @@
listEl.style.display = 'none';
emptyEl.style.display = 'block';
document.getElementById('skip-link').style.display = 'block';
setTimeout(() => { window.location.href = '/'; }, 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';
@@ -652,53 +756,199 @@
btn.addEventListener('click', async (e) => {
const folder = e.target.dataset.folder || e.target.closest('button').dataset.folder;
const item = document.querySelector(`.folder-item[data-folder="${folder}"]`);
const searchInput = item.querySelector('.search-again-input');
const customQuery = searchInput ? searchInput.value.trim() : null;
btn.classList.add('searching');
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Searching...';
try {
const result = await reSearchFolder(folder);
const result = await reSearchFolder(folder, customQuery);
// Update suggestions in place
const suggestionsEl = item.querySelector('.suggestion-list');
if (result.search_suggestions && result.search_suggestions.length > 0) {
suggestionsEl.innerHTML = result.search_suggestions.map(s => `
<div class="suggestion-item">
<i class="fas fa-link"></i>
<a href="${s.link}" class="suggestion-link" target="_blank">${s.title}</a>
<a href="#" class="suggestion-link" data-provider-key="${s.provider_key || s.key || ''}" data-folder="${folder}">${s.name || s.title}</a>
</div>
`).join('');
} else {
suggestionsEl.innerHTML = '<div class="no-suggestions"><i class="fas fa-info-circle"></i> No suggestions found</div>';
}
btn.remove();
// Keep search row visible for additional searches
btn.classList.remove('searching');
btn.innerHTML = '<i class="fas fa-search"></i> Search Again';
} catch (err) {
showToast('Search failed', 'error');
btn.classList.remove('searching');
btn.innerHTML = '<i class="fas fa-search"></i> Search Again';
} finally {
btn.classList.remove('searching');
}
});
});
// Suggestion link click - populate input and resolve
document.querySelectorAll('.suggestion-link').forEach(link => {
link.addEventListener('click', async (e) => {
e.preventDefault();
const providerKey = e.target.dataset.providerKey;
const folder = e.target.dataset.folder;
if (!providerKey) {
showToast('No provider key available for this suggestion', 'error');
return;
}
const input = document.querySelector(`.folder-input[data-folder="${folder}"]`);
const resolveBtn = document.querySelector(`.resolve-btn[data-folder="${folder}"]`);
const item = document.querySelector(`.folder-item[data-folder="${folder}"]`);
const errEl = document.querySelector(`.folder-error[data-folder="${folder}"]`);
if (!input || !resolveBtn || !item) return;
// Populate input and enable button
input.value = providerKey;
resolveBtn.disabled = false;
// Trigger resolve
item.classList.add('resolving');
resolveBtn.disabled = true;
resolveBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
try {
const result = await resolveFolder(folder, providerKey);
if (result.status === 'success') {
showToast(`Added: ${result.message.replace('Successfully resolved and added series: ', '')}`, 'success');
item.classList.add('resolved');
setTimeout(() => {
item.remove();
checkEmptyList();
}, 400);
} else {
errEl.textContent = result.detail || result.message || 'Failed to resolve';
errEl.classList.add('visible');
resolveBtn.disabled = false;
resolveBtn.innerHTML = 'Resolve';
}
} catch (err) {
errEl.textContent = 'Server error. Please try again.';
errEl.classList.add('visible');
resolveBtn.disabled = false;
resolveBtn.innerHTML = 'Resolve';
} finally {
item.classList.remove('resolving');
}
});
});
}
function checkEmptyList() {
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';
emptyEl.style.display = 'block';
skipLink.style.display = 'block';
showToast('All series configured!', 'success');
setTimeout(() => { window.location.href = '/'; }, 2000);
// All folders resolved - redirect to NFO scan phase
setTimeout(() => {
sessionStorage.setItem('setup_phase', 'nfo');
window.location.href = '/loading?phase=nfo';
}, 2000);
}
}
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');
// 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;
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';
}
// 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);
if (folders.length > 0) {
showDoneButton();
}
}
})();
</script>

View File

@@ -320,6 +320,8 @@ class TestPerformInitialSetup:
patch('src.server.services.initialization_service._mark_initial_scan_completed',
new_callable=AsyncMock), \
patch('src.server.services.initialization_service._load_series_into_memory',
new_callable=AsyncMock), \
patch('src.server.services.initialization_service.perform_nfo_scan_if_needed',
new_callable=AsyncMock):
result = await perform_initial_setup()
@@ -339,6 +341,8 @@ class TestPerformInitialSetup:
patch('src.server.services.initialization_service._mark_initial_scan_completed',
new_callable=AsyncMock), \
patch('src.server.services.initialization_service._load_series_into_memory',
new_callable=AsyncMock), \
patch('src.server.services.initialization_service.perform_nfo_scan_if_needed',
new_callable=AsyncMock):
result = await perform_initial_setup(progress_service=mock_progress)