Compare commits

...

19 Commits

Author SHA1 Message Date
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
8bb8c6aa64 chore: bump version 2026-06-06 21:53:57 +02:00
109d3c8ac9 fix: streamline initialization flow after setup
- Remove nfo_scan and media_scan from loading page steps (no longer shown in UI)
- Remove perform_nfo_scan_if_needed calls from fastapi_app and auth.py
- Always redirect to /setup/unresolved after initialization completes
  instead of conditionally checking for unresolved folders
- Fix middleware to allow access to /loading page - let it handle
  its own redirect flow via WebSocket events

This ensures users always reach the unresolved folders page after
initial setup to manually configure any unmatched anime series.
2026-06-06 21:33:41 +02:00
6a934db8ac chore: bump version 2026-06-06 20:38:21 +02:00
ac7302b1dd fix: add /setup/unresolved to exempt paths and improve error handling
- Add /setup/unresolved to EXEMPT_PATHS to allow access after initial setup
- Handle 401 Unauthorized response in loading page (clear invalid token)
- Add console.log statements for debugging setup flow issues
2026-06-06 20:37:11 +02:00
ac5ee3bb27 chore: bump version 2026-06-06 20:08:05 +02:00
a9084202e3 fixed missing import 2026-06-06 20:07:45 +02:00
be9f2a4c0c chore: bump version 2026-06-06 19:40:21 +02:00
53fe09351f fix: prevent duplicate series when same anime key exists in different folder
- Add check for existing series by key in SetupService.run to skip duplicates
- Fix Path construction in initialization_service.py cleanup function
- Update unit tests to mock get_by_key and get_series_app
2026-06-06 19:39:32 +02:00
dc7d9ee5f7 chore: bump version 2026-06-05 22:34:09 +02:00
da3cae2812 fix: redirect to unresolved page after setup if needed
After initial setup completes, the loading page now checks for unresolved
folders before showing completion. If any unresolved exist, redirects
to /setup/unresolved so users can manually resolve provider keys.

Without this fix, users with unresolved folders only saw the loading
screen with no way to access the unresolved page.
2026-06-05 22:33:40 +02:00
33 changed files with 670 additions and 117 deletions

View File

@@ -1 +1 @@
v1.4.3 v1.4.10

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", "name": "aniworld-web",
"version": "1.4.3", "version": "1.4.10",
"description": "Aniworld Anime Download Manager - Web Frontend", "description": "Aniworld Anime Download Manager - Web Frontend",
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -1,6 +1,7 @@
"""Authentication API endpoints for Aniworld.""" """Authentication API endpoints for Aniworld."""
from typing import Optional from typing import Optional
import structlog
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from fastapi import status as http_status from fastapi import status as http_status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
@@ -16,6 +17,8 @@ from src.server.models.config import AppConfig
from src.server.services.auth_service import AuthError, LockedOutError, auth_service from src.server.services.auth_service import AuthError, LockedOutError, auth_service
from src.server.services.config_service import get_config_service from src.server.services.config_service import get_config_service
logger = structlog.get_logger(__name__)
# NOTE: import dependencies (optional_auth, security) lazily inside handlers # NOTE: import dependencies (optional_auth, security) lazily inside handlers
# to avoid importing heavyweight modules (e.g. sqlalchemy) at import time. # to avoid importing heavyweight modules (e.g. sqlalchemy) at import time.
@@ -144,10 +147,7 @@ async def setup_auth(req: SetupRequest):
# Trigger initialization in background task # Trigger initialization in background task
import asyncio import asyncio
from src.server.services.initialization_service import ( from src.server.services.initialization_service import perform_initial_setup
perform_initial_setup,
perform_nfo_scan_if_needed,
)
from src.server.services.progress_service import get_progress_service from src.server.services.progress_service import get_progress_service
progress_service = get_progress_service() progress_service = get_progress_service()
@@ -158,9 +158,6 @@ async def setup_auth(req: SetupRequest):
# Perform the initial series sync and mark as completed # Perform the initial series sync and mark as completed
await perform_initial_setup(progress_service) await perform_initial_setup(progress_service)
# Perform NFO scan if configured
await perform_nfo_scan_if_needed(progress_service)
# Start scheduler if anime_directory is now set # Start scheduler if anime_directory is now set
try: try:
from src.server.services.scheduler.scheduler_service import ( from src.server.services.scheduler.scheduler_service import (
@@ -178,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

@@ -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) @router.post("/unresolved/{folder_name}/search", response_model=UnresolvedFolderResponse)
async def search_unresolved_folder( async def search_unresolved_folder(
folder_name: str, folder_name: str,
request: Optional[SearchFolderRequest] = None,
db=Depends(get_database_session), db=Depends(get_database_session),
) -> UnresolvedFolderResponse: ) -> UnresolvedFolderResponse:
"""Re-search for a specific unresolved folder to get fresh suggestions. """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: Args:
folder_name: URL-encoded folder name to search for folder_name: URL-encoded folder name to search for
request: Optional SearchFolderRequest with custom query override
Returns: Returns:
UnresolvedFolderResponse with updated search suggestions UnresolvedFolderResponse with updated search suggestions
@@ -258,10 +266,13 @@ async def search_unresolved_folder(
detail=f"Folder already resolved: {folder_name}" 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 # Perform search
series_app = get_series_app() series_app = get_series_app()
try: try:
results = await series_app.search(folder.title) results = await series_app.search(search_query)
search_result_json = json.dumps(results) if results else "[]" search_result_json = json.dumps(results) if results else "[]"
except Exception as e: except Exception as e:
logger.warning( logger.warning(
@@ -278,7 +289,7 @@ async def search_unresolved_folder(
folder_name=folder.folder_name, folder_name=folder.folder_name,
title=folder.title, title=folder.title,
year=folder.year, year=folder.year,
search_attempts=folder.search_attempts, search_attempts=folder.search_attempts + 1,
search_suggestions=results, search_suggestions=results,
) )
@@ -311,3 +322,55 @@ async def delete_unresolved_folder(
) )
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

@@ -344,7 +344,6 @@ async def lifespan(_application: FastAPI):
from src.server.services.initialization_service import ( from src.server.services.initialization_service import (
perform_initial_setup, perform_initial_setup,
perform_media_scan_if_needed, perform_media_scan_if_needed,
perform_nfo_scan_if_needed,
) )
try: try:
@@ -373,9 +372,6 @@ async def lifespan(_application: FastAPI):
"exist yet): %s", e "exist yet): %s", e
) )
# Run NFO scan only on first run (if configured)
await perform_nfo_scan_if_needed()
# Initialize download service # Initialize download service
try: try:
from src.server.utils.dependencies import get_download_service from src.server.utils.dependencies import get_download_service

View File

@@ -32,10 +32,12 @@ class SetupRedirectMiddleware(BaseHTTPMiddleware):
# Paths that should always be accessible, even without setup # Paths that should always be accessible, even without setup
EXEMPT_PATHS = { EXEMPT_PATHS = {
"/setup", # Setup page itself "/setup", # Setup page itself
"/setup/unresolved", # Unresolved folders page (after setup)
"/loading", # Loading page (initialization progress) "/loading", # Loading page (initialization progress)
"/login", # Login page (needs to be accessible after setup) "/login", # Login page (needs to be accessible after setup)
"/queue", # Queue page (for initial load) "/queue", # Queue page (for initial load)
"/api/auth/", # All auth endpoints (setup, login, logout, register) "/api/auth/", # All auth endpoints (setup, login, logout, register)
"/api/setup/", # Setup API (unresolved folders, etc.)
"/ws/connect", # WebSocket connection (needed for loading page) "/ws/connect", # WebSocket connection (needed for loading page)
"/api/queue/", # Queue API endpoints "/api/queue/", # Queue API endpoints
"/api/downloads/", # Download API endpoints "/api/downloads/", # Download API endpoints
@@ -103,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:
@@ -118,29 +134,22 @@ 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":
# Check if initialization is complete # Always allow access to loading page - it handles its own
try: # redirect flow via WebSocket events (initialization_complete
from src.server.database.connection import get_db_session # event triggers redirect to /setup/unresolved)
from src.server.database.system_settings_service import ( pass
SystemSettingsService,
)
async with get_db_session() as db:
is_complete = await SystemSettingsService.is_initial_scan_completed(db)
if is_complete:
# Initialization complete, redirect to login
return RedirectResponse(url="/login", status_code=302)
except Exception:
# If we can't check, allow access to loading page
pass
# Skip setup check for exempt paths # Skip setup check for exempt paths
if self._is_path_exempt(path): if self._is_path_exempt(path):

View File

@@ -165,7 +165,7 @@ async def _cleanup_legacy_key_files() -> int:
db_folders: set[str] = {series.folder for series in all_series if series.folder} db_folders: set[str] = {series.folder for series in all_series if series.folder}
for folder_name in db_folders: for folder_name in db_folders:
folder_path = settings.anime_directory / folder_name folder_path = Path(settings.anime_directory) / folder_name
key_file = folder_path / "key" key_file = folder_path / "key"
if not key_file.exists(): if not key_file.exists():
@@ -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

@@ -378,6 +378,18 @@ class SetupService:
) )
continue continue
# Also check if a series with this key already exists (different folder, same anime)
existing_by_key = await AnimeSeriesService.get_by_key(db, resolved_key)
if existing_by_key:
logger.debug(
"Series with key already exists, skipping",
folder=folder_name,
key=resolved_key,
existing_folder=existing_by_key.folder
)
skipped_existing += 1
continue
# Check filesystem properties # Check filesystem properties
props = cls._get_series_properties(folder) props = cls._get_series_properties(folder)

View File

@@ -282,14 +282,12 @@
const stepOrder = [ const stepOrder = [
'series_sync', 'series_sync',
'nfo_scan', 'nfo_scan'
'media_scan'
]; ];
const stepTitles = { const stepTitles = {
'series_sync': 'Syncing Series Database', 'series_sync': 'Syncing Series Database',
'nfo_scan': 'Processing NFO Metadata', 'nfo_scan': 'Scanning NFO Files'
'media_scan': 'Scanning Media Files'
}; };
function connectWebSocket() { function connectWebSocket() {
@@ -309,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) => {
@@ -353,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;
@@ -374,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');
@@ -468,12 +549,37 @@
function showCompletion() { function showCompletion() {
isComplete = true; isComplete = true;
document.getElementById('completionMessage').style.display = 'block';
document.getElementById('connectionStatus').style.display = 'none'; document.getElementById('connectionStatus').style.display = 'none';
if (ws) { if (ws) {
ws.close(); ws.close();
} }
// Check for unresolved folders before showing completion
checkUnresolvedAndProceed();
}
async function checkUnresolvedAndProceed() {
// 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) { function showError(message) {

View File

@@ -238,6 +238,63 @@
opacity: 0.7; 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 */
.empty-state { .empty-state {
text-align: center; text-align: center;
@@ -358,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;
@@ -382,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">
@@ -443,15 +535,13 @@
// API client helpers // API client helpers
async function fetchUnresolved() { 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'); const token = localStorage.getItem('auth_token');
if (!token) { const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
window.location.href = '/login'; const res = await fetch('/api/setup/unresolved', { headers });
return null;
}
const res = await fetch('/api/setup/unresolved', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.status === 401) { if (res.status === 401) {
// Redirect to login only if we had a token but it expired
localStorage.removeItem('auth_token'); localStorage.removeItem('auth_token');
window.location.href = '/login'; window.location.href = '/login';
return null; return null;
@@ -473,12 +563,17 @@
return res.json(); return res.json();
} }
async function reSearchFolder(folderName) { async function reSearchFolder(folderName, customQuery) {
const token = localStorage.getItem('auth_token'); const token = localStorage.getItem('auth_token');
const encodedName = encodeURIComponent(folderName); const encodedName = encodeURIComponent(folderName);
const body = customQuery ? JSON.stringify({ query: customQuery }) : '{}';
const res = await fetch(`/api/setup/unresolved/${encodedName}/search`, { const res = await fetch(`/api/setup/unresolved/${encodedName}/search`, {
method: 'POST', method: 'POST',
headers: { 'Authorization': `Bearer ${token}` } headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: body
}); });
return res.json(); return res.json();
} }
@@ -499,15 +594,21 @@
? folder.search_suggestions.map(s => ` ? folder.search_suggestions.map(s => `
<div class="suggestion-item"> <div class="suggestion-item">
<i class="fas fa-link"></i> <i class="fas fa-link"></i>
<a href="${s.link}" class="suggestion-link" target="_blank">${s.title}</a> <a href="${s.link}" class="suggestion-link" target="_blank">${s.name || s.title}</a>
</div> </div>
`).join('') `).join('')
: '<div class="no-suggestions"><i class="fas fa-info-circle"></i> No suggestions found</div>'; : '<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) const searchAgainBtn = (folder.search_suggestions && folder.search_suggestions.length === 0)
? `<button class="search-again-btn" data-folder="${folder.folder_name}"> ? `<div class="search-again-row">
<i class="fas fa-search"></i> Search Again <input type="text" class="search-again-input"
</button>` 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 ` return `
@@ -552,7 +653,7 @@
listEl.style.display = 'none'; listEl.style.display = 'none';
emptyEl.style.display = 'block'; emptyEl.style.display = 'block';
document.getElementById('skip-link').style.display = 'block'; document.getElementById('skip-link').style.display = 'block';
setTimeout(() => { window.location.href = '/'; }, 2000); setTimeout(() => { window.location.href = '/loading'; }, 2000);
} else { } else {
listEl.style.display = 'flex'; listEl.style.display = 'flex';
emptyEl.style.display = 'none'; emptyEl.style.display = 'none';
@@ -652,25 +753,29 @@
btn.addEventListener('click', async (e) => { btn.addEventListener('click', async (e) => {
const folder = e.target.dataset.folder || e.target.closest('button').dataset.folder; const folder = e.target.dataset.folder || e.target.closest('button').dataset.folder;
const item = document.querySelector(`.folder-item[data-folder="${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.classList.add('searching');
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Searching...'; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Searching...';
try { try {
const result = await reSearchFolder(folder); const result = await reSearchFolder(folder, customQuery);
// Update suggestions in place // Update suggestions in place
const suggestionsEl = item.querySelector('.suggestion-list'); const suggestionsEl = item.querySelector('.suggestion-list');
if (result.search_suggestions && result.search_suggestions.length > 0) { if (result.search_suggestions && result.search_suggestions.length > 0) {
suggestionsEl.innerHTML = result.search_suggestions.map(s => ` suggestionsEl.innerHTML = result.search_suggestions.map(s => `
<div class="suggestion-item"> <div class="suggestion-item">
<i class="fas fa-link"></i> <i class="fas fa-link"></i>
<a href="${s.link}" class="suggestion-link" target="_blank">${s.title}</a> <a href="${s.link}" class="suggestion-link" target="_blank">${s.name || s.title}</a>
</div> </div>
`).join(''); `).join('');
} else { } else {
suggestionsEl.innerHTML = '<div class="no-suggestions"><i class="fas fa-info-circle"></i> No suggestions found</div>'; suggestionsEl.innerHTML = '<div class="no-suggestions"><i class="fas fa-info-circle"></i> No suggestions found</div>';
} }
btn.remove(); // Remove the search row since we now have suggestions (or none)
const searchRow = item.querySelector('.search-again-row');
if (searchRow) searchRow.remove();
} catch (err) { } catch (err) {
showToast('Search failed', 'error'); showToast('Search failed', 'error');
} finally { } finally {
@@ -684,21 +789,65 @@
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';
emptyEl.style.display = 'block'; emptyEl.style.display = 'block';
skipLink.style.display = 'block'; skipLink.style.display = 'block';
showToast('All series configured!', 'success'); showToast('All series configured!', 'success');
setTimeout(() => { window.location.href = '/'; }, 2000); setTimeout(() => { window.location.href = '/loading'; }, 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');
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>

View File

@@ -167,10 +167,16 @@ class TestSetupServiceRun:
mock_get_db = MagicMock() mock_get_db = MagicMock()
mock_get_db.__aenter__.return_value = mock_db mock_get_db.__aenter__.return_value = mock_db
mock_get_db.__aexit__.return_value = None mock_get_db.__aexit__.return_value = None
mock_series_app = AsyncMock()
mock_series_app.search.return_value = []
with patch( with patch(
'src.server.services.setup_service.settings' 'src.server.services.setup_service.settings'
) as mock_settings, \ ) as mock_settings, \
patch(
'src.server.services.setup_service.get_series_app',
return_value=mock_series_app
), \
patch( patch(
'src.server.services.setup_service.get_db_session', 'src.server.services.setup_service.get_db_session',
return_value=mock_get_db return_value=mock_get_db
@@ -179,6 +185,10 @@ class TestSetupServiceRun:
'src.server.services.setup_service.AnimeSeriesService.get_by_folder', 'src.server.services.setup_service.AnimeSeriesService.get_by_folder',
new_callable=AsyncMock, return_value=None new_callable=AsyncMock, return_value=None
), \ ), \
patch(
'src.server.services.setup_service.AnimeSeriesService.get_by_key',
new_callable=AsyncMock, return_value=None
), \
patch( patch(
'src.server.services.setup_service.UnresolvedFolderService.get_by_folder_name', 'src.server.services.setup_service.UnresolvedFolderService.get_by_folder_name',
new_callable=AsyncMock, return_value=None new_callable=AsyncMock, return_value=None
@@ -258,10 +268,16 @@ class TestSetupServiceRun:
mock_get_db = MagicMock() mock_get_db = MagicMock()
mock_get_db.__aenter__.return_value = mock_db mock_get_db.__aenter__.return_value = mock_db
mock_get_db.__aexit__.return_value = None mock_get_db.__aexit__.return_value = None
mock_series_app = AsyncMock()
mock_series_app.search.return_value = []
with patch( with patch(
'src.server.services.setup_service.settings' 'src.server.services.setup_service.settings'
) as mock_settings, \ ) as mock_settings, \
patch(
'src.server.services.setup_service.get_series_app',
return_value=mock_series_app
), \
patch( patch(
'src.server.services.setup_service.get_db_session', 'src.server.services.setup_service.get_db_session',
return_value=mock_get_db return_value=mock_get_db
@@ -270,6 +286,10 @@ class TestSetupServiceRun:
'src.server.services.setup_service.AnimeSeriesService.get_by_folder', 'src.server.services.setup_service.AnimeSeriesService.get_by_folder',
new_callable=AsyncMock, return_value=None new_callable=AsyncMock, return_value=None
), \ ), \
patch(
'src.server.services.setup_service.AnimeSeriesService.get_by_key',
new_callable=AsyncMock, return_value=None
), \
patch( patch(
'src.server.services.setup_service.UnresolvedFolderService.get_by_folder_name', 'src.server.services.setup_service.UnresolvedFolderService.get_by_folder_name',
new_callable=AsyncMock, return_value=None new_callable=AsyncMock, return_value=None
@@ -323,6 +343,10 @@ class TestSetupServiceRun:
'src.server.services.setup_service.AnimeSeriesService.get_by_folder', 'src.server.services.setup_service.AnimeSeriesService.get_by_folder',
new_callable=AsyncMock, return_value=None new_callable=AsyncMock, return_value=None
), \ ), \
patch(
'src.server.services.setup_service.AnimeSeriesService.get_by_key',
new_callable=AsyncMock, return_value=None
), \
patch( patch(
'src.server.services.setup_service.UnresolvedFolderService.get_by_folder_name', 'src.server.services.setup_service.UnresolvedFolderService.get_by_folder_name',
new_callable=AsyncMock, return_value=None new_callable=AsyncMock, return_value=None
@@ -401,6 +425,10 @@ class TestSetupServiceRun:
'src.server.services.setup_service.AnimeSeriesService.get_by_folder', 'src.server.services.setup_service.AnimeSeriesService.get_by_folder',
new_callable=AsyncMock, return_value=None new_callable=AsyncMock, return_value=None
), \ ), \
patch(
'src.server.services.setup_service.AnimeSeriesService.get_by_key',
new_callable=AsyncMock, return_value=None
), \
patch( patch(
'src.server.services.setup_service.UnresolvedFolderService.get_by_folder_name', 'src.server.services.setup_service.UnresolvedFolderService.get_by_folder_name',
new_callable=AsyncMock, return_value=None new_callable=AsyncMock, return_value=None