Compare commits

...

10 Commits

Author SHA1 Message Date
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
31 changed files with 442 additions and 103 deletions

View File

@@ -1 +1 @@
v1.4.9 v1.4.12

View File

@@ -13,18 +13,38 @@ The application uses a middleware-based redirect system to ensure users complete
│ SETUP FLOW │ │ SETUP FLOW │
├─────────────────────────────────────────────────────────────────────┤ ├─────────────────────────────────────────────────────────────────────┤
│ │ │ │
│ /setup ──► /loading ──┬──► /setup/unresolved ──► /loading │ /setup ──► /loading ──► /setup/unresolved ──► /loading ──► /login
│ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │
│ ▼ ▼ │ │ ▼ ▼ ▼
│ (first time) (WebSocket) (has folders) (all resolved) │ │ (first (Series Scan + (has folders) (all resolved)
time) NFO Scan) │ │
│ │
/login ◄───────────────────┴──────────────────────┤ │ │ │
│ │ ▼ │
│ │ [Done button] ──► marks complete │
│ │ │ │
│ │ ▼ │
│ │ /loading (NFO phase runs again) │
│ │ │ │
│ └────────┴─────────────────────────────────────┘
│ │ │ │
└─────────────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────────────┘
``` ```
**New Navigation Order:**
1. `/setup` → Initial configuration
2. `/loading` → Series scan + NFO scan
3. `/setup/unresolved` → Resolve folders (if any)
4. `/loading` → NFO scan runs again
5. `/login` → Authentication
**Key Changes:**
- After `/setup/unresolved`, the "Done" button marks the phase as complete
- Revisiting `/setup/unresolved` after completion → redirects to `/loading`
- `/loading` always goes to `/setup/unresolved` if unresolved folders exist
- NFO scan runs as a separate phase after series sync during initialization
## Middleware: SetupRedirectMiddleware ## Middleware: SetupRedirectMiddleware
**File:** `src/server/middleware/setup_redirect.py` **File:** `src/server/middleware/setup_redirect.py`
@@ -49,9 +69,12 @@ The middleware intercepts all requests and redirects to `/setup` if:
### Middleware Logic ### Middleware Logic
1. **Setup incomplete** → Redirect to `/setup` 1. **Setup incomplete** → Redirect to `/setup`
2. **Setup complete, accessing `/setup`** → Redirect to `/loading` 2. **Setup complete, accessing `/setup`** → Redirect to `/login`
3. **Setup complete, accessing `/loading`** → Allow access (page handles its own redirect) 3. **Setup complete, accessing `/loading`** → Allow access (page handles its own redirect)
4. **API requests during setup** → Return 503 with `setup_url` 4. **Setup complete, accessing `/setup/unresolved`**:
- If `unresolved_completed` flag is set → Redirect to `/loading`
- Otherwise → Allow access
5. **API requests during setup** → Return 503 with `setup_url`
## Pages ## Pages
@@ -104,17 +127,25 @@ Allows manual resolution of folders that couldn't be auto-matched:
- Provides search suggestions - Provides search suggestions
- Input field for entering provider key - Input field for entering provider key
- Resolve/delete actions - Resolve/delete actions
- **Done button** at top to complete the phase without resolving all folders
**Post-resolution flow:** **Post-resolution flow:**
```javascript ```javascript
function checkEmptyList() { // After clicking "Done" button
if (listEl.children.length === 0) { async function handleDone() {
// All folders resolved → return to loading // Call API to mark phase as complete
setTimeout(() => { window.location.href = '/loading'; }, 2000); await fetch('/api/setup/unresolved/done', { method: 'POST' });
} // Redirect to loading for final NFO scan
window.location.href = '/loading';
} }
``` ```
**Done button behavior:**
- Marks all remaining folders as handled
- Sets `unresolved_completed` flag in config
- Redirects to `/loading` to run final NFO scan
- After completion, `/setup/unresolved` becomes inaccessible (redirects to `/loading`)
### 4. Login Page (`/login`) ### 4. Login Page (`/login`)
**File:** `src/server/web/templates/login.html` **File:** `src/server/web/templates/login.html`
@@ -132,6 +163,7 @@ Authentication page. After successful login → redirect to `/` (main app).
| `POST` | `/api/setup/unresolved/{folder_name}/resolve` | Resolve with provider key | | `POST` | `/api/setup/unresolved/{folder_name}/resolve` | Resolve with provider key |
| `POST` | `/api/setup/unresolved/{folder_name}/search` | Re-search for matches | | `POST` | `/api/setup/unresolved/{folder_name}/search` | Re-search for matches |
| `DELETE` | `/api/setup/unresolved/{folder_name}` | Remove folder from tracking | | `DELETE` | `/api/setup/unresolved/{folder_name}` | Remove folder from tracking |
| `POST` | `/api/setup/unresolved/done` | Mark unresolved phase as complete |
### Auth API ### Auth API

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.9", "version": "1.4.12",
"description": "Aniworld Anime Download Manager - Web Frontend", "description": "Aniworld Anime Download Manager - Web Frontend",
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

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

View File

@@ -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,
) )
@@ -310,4 +321,56 @@ async def delete_unresolved_folder(
detail=f"Unresolved folder not found: {folder_name}" detail=f"Unresolved folder not found: {folder_name}"
) )
return {"status": "success", "message": f"Deleted unresolved folder: {folder_name}"} return {"status": "success", "message": f"Deleted unresolved folder: {folder_name}"}
class DoneResponse(BaseModel):
"""Response model for completing unresolved folders."""
status: str = Field(..., description="Operation status")
message: str = Field(..., description="Human-readable message")
count: int = Field(..., description="Number of folders marked as done")
@router.post("/unresolved/done", response_model=DoneResponse)
async def complete_unresolved_folders(
db=Depends(get_database_session),
) -> DoneResponse:
"""Mark all unresolved folders as handled and complete the unresolved phase.
This endpoint:
1. Marks the unresolved phase as completed in config
2. Returns the count of folders that were handled
After this, /setup/unresolved will redirect to /loading.
Returns:
DoneResponse with status and count of handled folders
"""
from src.server.services.config_service import get_config_service
# Get all unresolved folders
folders = await UnresolvedFolderService.get_all_unresolved(db)
count = len(folders)
# Mark unresolved as completed in config
config_service = get_config_service()
try:
config = config_service.load_config()
if config.other is None:
config.other = {}
config.other['unresolved_completed'] = True
config_service.save_config(config, create_backup=False)
logger.info("Marked unresolved phase as completed")
except Exception as e:
logger.warning("Failed to save unresolved_completed flag: %s", e)
logger.info(
"Completed unresolved phase: %d folders handled",
count
)
return DoneResponse(
status="success",
message=f"Marked {count} folders as handled. Unresolved phase completed.",
count=count,
)

View File

@@ -37,6 +37,7 @@ class SetupRedirectMiddleware(BaseHTTPMiddleware):
"/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
@@ -104,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:
@@ -119,13 +134,17 @@ class SetupRedirectMiddleware(BaseHTTPMiddleware):
path = request.url.path path = request.url.path
# Check if trying to access setup or loading page after completion # Check if trying to access setup or loading page after completion
if path in ("/setup", "/loading"): if path in ("/setup", "/loading", "/setup/unresolved"):
if not self._needs_setup(): if not self._needs_setup():
# Setup is complete, check loading status
if path == "/setup": if path == "/setup":
# Redirect to loading if initialization is in progress # Redirect to loading if initialization is in progress
# Otherwise redirect to login # Otherwise redirect to login
return RedirectResponse(url="/login", status_code=302) return RedirectResponse(url="/login", status_code=302)
elif path == "/setup/unresolved":
# Check if unresolved phase is already completed
if self._is_unresolved_completed():
# Redirect to loading - unresolved phase already done
return RedirectResponse(url="/loading", status_code=302)
elif path == "/loading": elif path == "/loading":
# Always allow access to loading page - it handles its own # Always allow access to loading page - it handles its own
# redirect flow via WebSocket events (initialization_complete # redirect flow via WebSocket events (initialization_complete

View File

@@ -386,6 +386,9 @@ async def perform_initial_setup(progress_service=None):
# Load series into memory from database # Load series into memory from database
await _load_series_into_memory(progress_service) await _load_series_into_memory(progress_service)
# Run NFO scan as part of initialization
await perform_nfo_scan_if_needed(progress_service)
return True return True
except (OSError, RuntimeError, ValueError) as e: except (OSError, RuntimeError, ValueError) as e:
@@ -427,13 +430,46 @@ async def _is_nfo_scan_configured() -> bool:
async def _execute_nfo_scan(progress_service=None) -> None: async def _execute_nfo_scan(progress_service=None) -> None:
"""Execute the actual NFO scan with TMDB data. """Execute the actual NFO scan with TMDB data.
Note: NFO service removed. This function is now a no-op stub.
Args: Args:
progress_service: Unused. Kept to avoid breaking call-sites. progress_service: Optional ProgressService for emitting updates
""" """
logger.info("NFO scan skipped — NFO service removed") from src.server.services.nfo_scan_service import NfoScanService
return 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): async def perform_nfo_scan_if_needed(progress_service=None):
@@ -446,8 +482,8 @@ async def perform_nfo_scan_if_needed(progress_service=None):
from src.server.services.progress_service import ProgressType from src.server.services.progress_service import ProgressType
await progress_service.start_progress( await progress_service.start_progress(
progress_id="nfo_scan", progress_id="nfo_scan",
progress_type=ProgressType.SYSTEM, progress_type=ProgressType.SCAN,
title="Processing NFO Metadata", title="Scanning NFO Files",
total=100, total=100,
message="Checking NFO scan status...", message="Checking NFO scan status...",
metadata={"step_id": "nfo_scan"} metadata={"step_id": "nfo_scan"}

View File

@@ -281,11 +281,13 @@
let isComplete = false; let isComplete = false;
const stepOrder = [ const stepOrder = [
'series_sync' 'series_sync',
'nfo_scan'
]; ];
const stepTitles = { const stepTitles = {
'series_sync': 'Syncing Series Database' 'series_sync': 'Syncing Series Database',
'nfo_scan': 'Scanning NFO Files'
}; };
function connectWebSocket() { function connectWebSocket() {
@@ -305,6 +307,14 @@
room: 'system' room: 'system'
} }
})); }));
// Subscribe to scan room for NFO scan progress
ws.send(JSON.stringify({
action: 'join',
data: {
room: 'scan'
}
}));
}; };
ws.onmessage = (event) => { ws.onmessage = (event) => {
@@ -349,6 +359,12 @@
const data = message.data || message; const data = message.data || message;
const { type, status, title, message: msg, percent, current, total, metadata } = data; const { type, status, title, message: msg, percent, current, total, metadata } = data;
// Handle NFO scan events
if (type === 'nfo_scan_started' || type === 'nfo_scan_progress' || type === 'nfo_scan_completed') {
handleNfoScanUpdate(data);
return;
}
// Determine step ID based on type and metadata // Determine step ID based on type and metadata
let stepId = metadata?.step_id || type; let stepId = metadata?.step_id || type;
@@ -370,6 +386,75 @@
} }
} }
function handleNfoScanUpdate(data) {
const stepId = 'nfo_scan';
if (!steps.has(stepId)) {
createStep(stepId, stepTitles[stepId] || 'Scanning NFO Files');
}
const stepEl = steps.get(stepId);
if (!stepEl) return;
const iconEl = stepEl.querySelector('.step-icon');
const statusEl = stepEl.querySelector('.step-status');
const messageEl = stepEl.querySelector('.step-message');
const progressEl = stepEl.querySelector('.step-progress');
const progressFillEl = stepEl.querySelector('.progress-bar-fill');
const progressTextEl = stepEl.querySelector('.progress-text');
const nfoData = data.data || data;
const { status, message, current, total, key, folder } = nfoData;
// Update status
stepEl.className = 'progress-step';
if (status === 'started') {
stepEl.classList.add('active');
iconEl.className = 'fas fa-circle-notch fa-spin step-icon loading';
statusEl.textContent = 'Starting...';
} else if (status === 'in_progress') {
stepEl.classList.add('active');
iconEl.className = 'fas fa-circle-notch fa-spin step-icon loading';
statusEl.textContent = 'In Progress...';
} else if (status === 'completed') {
stepEl.classList.add('completed');
iconEl.className = 'fas fa-check-circle step-icon completed';
statusEl.textContent = 'Complete';
} else if (status === 'failed') {
stepEl.classList.add('error');
iconEl.className = 'fas fa-exclamation-circle step-icon error';
statusEl.textContent = 'Failed';
}
// Update message - show current folder being processed
if (message) {
messageEl.textContent = message;
messageEl.style.display = 'block';
} else if (key && folder) {
messageEl.textContent = `Processing: ${folder}`;
messageEl.style.display = 'block';
}
// Update progress bar
if (current > 0 && total > 0) {
const actualPercent = (current / total) * 100;
progressEl.style.display = 'block';
progressFillEl.style.width = `${actualPercent}%`;
progressTextEl.textContent = `${current}/${total} series`;
} else if (percent > 0) {
progressEl.style.display = 'block';
progressFillEl.style.width = `${percent}%`;
progressTextEl.textContent = `${Math.round(percent)}%`;
}
// Check for completion
if (data.type === 'nfo_scan_completed') {
setTimeout(() => {
checkUnresolvedAndProceed();
}, 1000);
}
}
function createStep(stepId, title) { function createStep(stepId, title) {
const container = document.getElementById('progressContainer'); const container = document.getElementById('progressContainer');
@@ -475,8 +560,8 @@
} }
async function checkUnresolvedAndProceed() { async function checkUnresolvedAndProceed() {
// Fetch unresolved folders and only redirect if there are any // Always check for unresolved folders first
// Otherwise go directly to login // After setup -> loading, always go through unresolved if there are any
try { try {
const token = localStorage.getItem('auth_token'); const token = localStorage.getItem('auth_token');
const res = await fetch('/api/setup/unresolved', { const res = await fetch('/api/setup/unresolved', {
@@ -493,7 +578,7 @@
} catch (err) { } catch (err) {
console.error('Failed to check unresolved folders:', err); console.error('Failed to check unresolved folders:', err);
} }
// No unresolved folders or error - go to login // No unresolved folders - go to login
window.location.href = '/login'; window.location.href = '/login';
} }

View File

@@ -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 `
@@ -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,6 +789,7 @@
const listEl = document.getElementById('folder-list'); const listEl = document.getElementById('folder-list');
const emptyEl = document.getElementById('empty-state'); const emptyEl = document.getElementById('empty-state');
const skipLink = document.getElementById('skip-link'); const skipLink = document.getElementById('skip-link');
const doneBtn = document.getElementById('done-btn');
if (listEl.children.length === 0) { if (listEl.children.length === 0) {
listEl.style.display = 'none'; listEl.style.display = 'none';
@@ -694,11 +800,54 @@
} }
} }
async function completeUnresolved() {
const token = localStorage.getItem('auth_token');
const res = await fetch('/api/setup/unresolved/done', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
return res.json();
}
async function handleDone() {
const doneBtn = document.getElementById('done-btn');
doneBtn.disabled = true;
doneBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Processing...';
try {
const result = await completeUnresolved();
if (result.status === 'success') {
showToast(result.message, 'success');
setTimeout(() => { window.location.href = '/loading'; }, 1000);
} else {
showToast(result.message || 'Failed to complete', 'error');
doneBtn.disabled = false;
doneBtn.innerHTML = '<i class="fas fa-check"></i> Done';
}
} catch (err) {
showToast('Server error. Please try again.', 'error');
doneBtn.disabled = false;
doneBtn.innerHTML = '<i class="fas fa-check"></i> Done';
}
}
// Show Done button when there are folders
function showDoneButton() {
const doneBtn = document.getElementById('done-btn');
doneBtn.style.display = 'inline-flex';
}
// Init // Init
(async function init() { (async function init() {
const folders = await fetchUnresolved(); const folders = await fetchUnresolved();
if (folders !== null) { if (folders !== null) {
renderFolders(folders); renderFolders(folders);
if (folders.length > 0) {
showDoneButton();
}
} }
})(); })();
</script> </script>

View File

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