Compare commits

...

17 Commits

Author SHA1 Message Date
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
2876cef24b chore: bump version 2026-06-05 22:10:56 +02:00
6a402623c4 feat(setup): add unresolved folders GUI for manual series resolution
- Add /setup/unresolved page for manual provider key resolution
- Integrate unresolved check into setup wizard flow
- Auto-redirect to unresolved page if folders need resolution

After initial setup scan, folders that couldn't be auto-resolved
are now tracked and can be resolved manually via the GUI.

Endpoints:
- GET /api/setup/unresolved - list unresolved folders
- POST /api/setup/unresolved/{folder}/resolve - resolve with provider key
- POST /api/setup/unresolved/{folder}/search - re-search for suggestions
- DELETE /api/setup/unresolved/{folder} - delete without adding
2026-06-05 22:06:55 +02:00
ebfbec1225 fix: resolve series key from direct link format
When the search provider returns a link like 'shinobi-no-ittoki' instead of
'/anime/stream/shinobi-no-ittoki', the key was not being extracted and all
folders were marked as unresolved.

Now handles both link formats:
- URL format: '/anime/stream/key' -> extract key
- Direct format: 'key' -> use as-is

Also added debug logging for both resolution paths to aid troubleshooting.
2026-06-05 21:21:39 +02:00
13 changed files with 997 additions and 41 deletions

View File

@@ -1 +1 @@
v1.4.2 v1.4.10

174
docs/NAVIGATION.md Normal file
View File

@@ -0,0 +1,174 @@
# 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 │
│ │ │ │ │ │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ (first time) (WebSocket) (has folders) (all resolved) │
│ │ │ │
│ ▼ │ │
│ /login ◄───────────────────┴──────────────────────┤
│ │
└─────────────────────────────────────────────────────────────────────┘
```
## 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 `/loading`
3. **Setup complete, accessing `/loading`** → Allow access (page handles its own redirect)
4. **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
**Post-resolution flow:**
```javascript
function checkEmptyList() {
if (listEl.children.length === 0) {
// All folders resolved → return to loading
setTimeout(() => { window.location.href = '/loading'; }, 2000);
}
}
```
### 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 |
### 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.

View File

@@ -1,6 +1,6 @@
{ {
"name": "aniworld-web", "name": "aniworld-web",
"version": "1.4.2", "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 (

View File

@@ -59,3 +59,13 @@ async def loading_page(request: Request):
request, request,
title="Initializing - Aniworld" title="Initializing - Aniworld"
) )
@router.get("/setup/unresolved", response_class=HTMLResponse)
async def unresolved_page(request: Request):
"""Serve the unresolved folders resolution page."""
return render_template(
"unresolved.html",
request,
title="Resolve Series - Aniworld"
)

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
@@ -126,21 +128,10 @@ class SetupRedirectMiddleware(BaseHTTPMiddleware):
# Otherwise redirect to login # Otherwise redirect to login
return RedirectResponse(url="/login", status_code=302) return RedirectResponse(url="/login", 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():

View File

@@ -154,6 +154,9 @@ class SetupService:
if SetupService._titles_match(result_name, title): if SetupService._titles_match(result_name, title):
if result_link and '/anime/stream/' in result_link: if result_link and '/anime/stream/' in result_link:
return result_link.split('/anime/stream/')[-1].split('/')[0] return result_link.split('/anime/stream/')[-1].split('/')[0]
elif result_link:
# Link is already the key (e.g., "shinobi-no-ittoki")
return result_link
else: else:
logger.debug( logger.debug(
"Series key resolved but link format unexpected", "Series key resolved but link format unexpected",
@@ -375,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

@@ -281,15 +281,11 @@
let isComplete = false; let isComplete = false;
const stepOrder = [ const stepOrder = [
'series_sync', 'series_sync'
'nfo_scan',
'media_scan'
]; ];
const stepTitles = { const stepTitles = {
'series_sync': 'Syncing Series Database', 'series_sync': 'Syncing Series Database'
'nfo_scan': 'Processing NFO Metadata',
'media_scan': 'Scanning Media Files'
}; };
function connectWebSocket() { function connectWebSocket() {
@@ -468,12 +464,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() {
// Fetch unresolved folders and only redirect if there are any
// Otherwise go directly to login
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 or error - go to login
window.location.href = '/login';
} }
function showError(message) { function showError(message) {

View File

@@ -790,17 +790,36 @@
const data = await response.json(); const data = await response.json();
if (response.ok && data.status === 'ok') { if (response.ok && data.status === 'ok') {
// Redirect to loading page if provided, otherwise go to login // Redirect to loading page if provided, otherwise check for unresolved folders
if (data.redirect) { if (data.redirect) {
showMessage('Setup saved! Initializing your anime library...', 'success'); showMessage('Setup saved! Initializing your anime library...', 'success');
setTimeout(() => { setTimeout(() => {
window.location.href = data.redirect; window.location.href = data.redirect;
}, 500); }, 500);
} else { } else {
showMessage('Setup completed successfully! Redirecting to login...', 'success'); // Check for unresolved folders before redirecting
setTimeout(() => { showMessage('Setup completed successfully! Checking for unresolved series...', 'success');
window.location.href = '/login'; setTimeout(async () => {
}, 2000); 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);
} }
} else { } else {
const errorMessage = data.detail || data.message || 'Setup failed'; const errorMessage = data.detail || data.message || 'Setup failed';

View File

@@ -0,0 +1,705 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AniWorld Manager - Resolve Series</title>
<link rel="stylesheet" href="/static/css/styles.css?v={{ static_v }}">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style>
.unresolved-container {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
background: linear-gradient(135deg, var(--color-primary-light) 0%, var(--color-primary) 100%);
padding: 2rem 1rem;
}
.unresolved-card {
background: var(--color-surface);
border-radius: 16px;
padding: 2rem;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 700px;
border: 1px solid var(--color-border);
max-height: 90vh;
overflow-y: auto;
}
.unresolved-header {
text-align: center;
margin-bottom: 2rem;
}
.unresolved-header .icon {
font-size: 3rem;
color: var(--color-warning);
margin-bottom: 0.5rem;
}
.unresolved-header h1 {
margin: 0;
color: var(--color-text);
font-size: 1.8rem;
font-weight: 600;
}
.unresolved-header p {
margin: 1rem 0 0 0;
color: var(--color-text-secondary);
font-size: 1rem;
line-height: 1.5;
}
.folder-list {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.folder-item {
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-lg);
padding: 1.25rem;
transition: all var(--transition-duration) var(--transition-easing);
}
.folder-item.resolving {
opacity: 0.7;
pointer-events: none;
}
.folder-item.resolved {
animation: fadeSlideOut 0.4s ease forwards;
}
@keyframes fadeSlideOut {
0% { opacity: 1; transform: translateY(0); max-height: 500px; }
100% { opacity: 0; transform: translateY(-10px); max-height: 0; padding: 0; margin: 0; border: 0; }
}
.folder-item-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
}
.folder-title {
font-size: 1.1rem;
font-weight: 600;
color: var(--color-text-primary);
}
.folder-year {
color: var(--color-text-secondary);
font-weight: normal;
}
.folder-attempts {
font-size: 0.8rem;
color: var(--color-text-tertiary);
margin-top: 0.25rem;
}
.folder-delete-btn {
background: none;
border: none;
color: var(--color-text-tertiary);
cursor: pointer;
padding: 0.25rem;
border-radius: var(--border-radius-sm);
transition: color var(--transition-duration);
}
.folder-delete-btn:hover {
color: var(--color-error);
}
.folder-input-row {
display: flex;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.folder-input {
flex: 1;
padding: 0.75rem 1rem;
border: 2px solid var(--color-border);
border-radius: var(--border-radius-md);
font-size: 0.95rem;
background: var(--color-surface);
color: var(--color-text);
transition: border-color var(--transition-duration);
}
.folder-input:focus {
outline: none;
border-color: var(--color-accent);
}
.folder-input::placeholder {
color: var(--color-text-tertiary);
}
.resolve-btn {
padding: 0.75rem 1.5rem;
background: var(--color-accent);
color: white;
border: none;
border-radius: var(--border-radius-md);
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-duration);
white-space: nowrap;
}
.resolve-btn:hover:not(:disabled) {
background: var(--color-accent-hover);
}
.resolve-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.folder-suggestions {
margin-top: 0.75rem;
}
.suggestions-label {
font-size: 0.85rem;
color: var(--color-text-secondary);
margin-bottom: 0.5rem;
font-weight: 500;
}
.suggestion-list {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.suggestion-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
color: var(--color-text-primary);
}
.suggestion-item i {
color: var(--color-accent);
font-size: 0.8rem;
}
.suggestion-link {
color: var(--color-accent);
text-decoration: none;
}
.suggestion-link:hover {
text-decoration: underline;
}
.no-suggestions {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: var(--color-text-tertiary);
}
.search-again-btn {
background: none;
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
padding: 0.4rem 0.8rem;
border-radius: var(--border-radius-md);
font-size: 0.8rem;
cursor: pointer;
transition: all var(--transition-duration);
margin-top: 0.5rem;
}
.search-again-btn:hover {
background: var(--color-surface-hover);
border-color: var(--color-accent);
color: var(--color-accent);
}
.search-again-btn.searching {
pointer-events: none;
opacity: 0.7;
}
/* Empty state */
.empty-state {
text-align: center;
padding: 3rem 2rem;
}
.empty-state .icon {
font-size: 4rem;
color: var(--color-success);
margin-bottom: 1rem;
}
.empty-state h2 {
color: var(--color-success);
margin-bottom: 0.5rem;
}
.empty-state p {
color: var(--color-text-secondary);
}
/* Loading state */
.loading-state {
text-align: center;
padding: 2rem;
}
.spinner {
width: 2rem;
height: 2rem;
border: 3px solid var(--color-border);
border-top-color: var(--color-accent);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 1rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Error state */
.folder-error {
color: var(--color-error);
font-size: 0.85rem;
margin-top: 0.5rem;
display: none;
}
.folder-error.visible {
display: block;
}
/* Toast container */
.toast-container {
position: fixed;
top: var(--spacing-xl);
right: var(--spacing-xl);
z-index: 1100;
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.toast {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-lg);
padding: var(--spacing-md) var(--spacing-lg);
box-shadow: var(--shadow-elevated);
min-width: 280px;
animation: slideIn 0.2s ease;
}
.toast.success { border-left: 4px solid var(--color-success); }
.toast.error { border-left: 4px solid var(--color-error); }
.toast.warning { border-left: 4px solid var(--color-warning); }
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
.theme-toggle {
position: absolute;
top: 1rem;
right: 1rem;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: white;
padding: 0.5rem;
border-radius: 50%;
cursor: pointer;
width: 2.5rem;
height: 2.5rem;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.theme-toggle:hover {
background: rgba(255, 255, 255, 0.2);
transform: scale(1.1);
}
.skip-link {
display: block;
text-align: center;
margin-top: 1.5rem;
color: var(--color-text-secondary);
font-size: 0.9rem;
text-decoration: none;
}
.skip-link:hover {
color: var(--color-text-primary);
text-decoration: underline;
}
@media (max-width: 600px) {
.folder-input-row {
flex-direction: column;
}
.resolve-btn {
width: 100%;
}
}
</style>
</head>
<body>
<div class="unresolved-container">
<button class="theme-toggle" id="theme-toggle" title="Toggle theme">
<i class="fas fa-moon"></i>
</button>
<div class="unresolved-card">
<div class="unresolved-header">
<div class="icon">
<i class="fas fa-folder-question"></i>
</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>
<div id="loading-state" class="loading-state">
<div class="spinner"></div>
<p>Loading unresolved folders...</p>
</div>
<div id="empty-state" class="empty-state" style="display: none;">
<div class="icon">
<i class="fas fa-check-circle"></i>
</div>
<h2>All Series Configured!</h2>
<p>Redirecting to your anime library...</p>
</div>
<div id="folder-list" class="folder-list" style="display: none;"></div>
<a href="/" id="skip-link" class="skip-link" style="display: none;">
Skip and go to main app
</a>
</div>
</div>
<div id="toast-container" class="toast-container"></div>
<script>
// Theme toggle
const themeToggle = document.getElementById('theme-toggle');
const htmlElement = document.documentElement;
const savedTheme = localStorage.getItem('theme') || 'light';
htmlElement.setAttribute('data-theme', savedTheme);
updateThemeIcon(savedTheme);
themeToggle.addEventListener('click', () => {
const currentTheme = htmlElement.getAttribute('data-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
htmlElement.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
updateThemeIcon(newTheme);
});
function updateThemeIcon(theme) {
const icon = themeToggle.querySelector('i');
icon.className = theme === 'dark' ? 'fas fa-sun' : 'fas fa-moon';
}
// Toast notification
function showToast(message, type = 'info') {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'slideIn 0.2s ease reverse';
setTimeout(() => toast.remove(), 200);
}, 3000);
}
// 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');
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;
}
return res.json();
}
async function resolveFolder(folderName, providerKey) {
const token = localStorage.getItem('auth_token');
const encodedName = encodeURIComponent(folderName);
const res = await fetch(`/api/setup/unresolved/${encodedName}/resolve`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ provider_key: providerKey })
});
return res.json();
}
async function reSearchFolder(folderName) {
const token = localStorage.getItem('auth_token');
const encodedName = encodeURIComponent(folderName);
const res = await fetch(`/api/setup/unresolved/${encodedName}/search`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` }
});
return res.json();
}
async function deleteFolder(folderName) {
const token = localStorage.getItem('auth_token');
const encodedName = encodeURIComponent(folderName);
const res = await fetch(`/api/setup/unresolved/${encodedName}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
return res.json();
}
// Render functions
function renderFolderItem(folder) {
const suggestionsHtml = folder.search_suggestions && folder.search_suggestions.length > 0
? 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>
</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>`
: '';
return `
<div class="folder-item" data-folder="${folder.folder_name}">
<div class="folder-item-header">
<div>
<div class="folder-title">${folder.title}${folder.year ? ` <span class="folder-year">(${folder.year})</span>` : ''}</div>
<div class="folder-attempts">${folder.search_attempts} search attempt${folder.search_attempts !== 1 ? 's' : ''}</div>
</div>
<button class="folder-delete-btn" data-folder="${folder.folder_name}" title="Remove without adding">
<i class="fas fa-times"></i>
</button>
</div>
<div class="folder-input-row">
<input type="text" class="folder-input"
placeholder="Enter provider key (e.g., ooku-the-inner-chambers)"
data-folder="${folder.folder_name}">
<button class="resolve-btn" data-folder="${folder.folder_name}" disabled>
Resolve
</button>
</div>
<div class="folder-error" data-folder="${folder.folder_name}"></div>
<div class="folder-suggestions">
<div class="suggestions-label">Suggestions:</div>
<div class="suggestion-list">
${suggestionsHtml}
</div>
${searchAgainBtn}
</div>
</div>
`;
}
function renderFolders(folders) {
const listEl = document.getElementById('folder-list');
const loadingEl = document.getElementById('loading-state');
const emptyEl = document.getElementById('empty-state');
loadingEl.style.display = 'none';
if (folders.length === 0) {
listEl.style.display = 'none';
emptyEl.style.display = 'block';
document.getElementById('skip-link').style.display = 'block';
setTimeout(() => { window.location.href = '/loading'; }, 2000);
} else {
listEl.style.display = 'flex';
emptyEl.style.display = 'none';
listEl.innerHTML = folders.map(renderFolderItem).join('');
attachFolderEvents();
}
}
function attachFolderEvents() {
// Input enable/disable resolve button
document.querySelectorAll('.folder-input').forEach(input => {
input.addEventListener('input', (e) => {
const folder = e.target.dataset.folder;
const btn = document.querySelector(`.resolve-btn[data-folder="${folder}"]`);
btn.disabled = !e.target.value.trim();
// Clear error
const errEl = document.querySelector(`.folder-error[data-folder="${folder}"]`);
errEl.classList.remove('visible');
errEl.textContent = '';
});
// Enter key triggers resolve
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
const folder = e.target.dataset.folder;
const btn = document.querySelector(`.resolve-btn[data-folder="${folder}"]`);
if (!btn.disabled) btn.click();
}
});
});
// Resolve button
document.querySelectorAll('.resolve-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
const folder = e.target.dataset.folder;
const input = document.querySelector(`.folder-input[data-folder="${folder}"]`);
const item = document.querySelector(`.folder-item[data-folder="${folder}"]`);
const errEl = document.querySelector(`.folder-error[data-folder="${folder}"]`);
const providerKey = input.value.trim();
if (!providerKey) return;
item.classList.add('resolving');
btn.disabled = true;
btn.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');
btn.disabled = false;
btn.innerHTML = 'Resolve';
}
} catch (err) {
errEl.textContent = 'Server error. Please try again.';
errEl.classList.add('visible');
btn.disabled = false;
btn.innerHTML = 'Resolve';
} finally {
item.classList.remove('resolving');
}
});
});
// Delete button
document.querySelectorAll('.folder-delete-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
const folder = e.currentTarget.dataset.folder;
const item = document.querySelector(`.folder-item[data-folder="${folder}"]`);
if (!confirm('Remove this unresolved folder? You can add the series manually later.')) return;
try {
await deleteFolder(folder);
item.classList.add('resolved');
setTimeout(() => {
item.remove();
checkEmptyList();
}, 400);
} catch (err) {
showToast('Failed to remove folder', 'error');
}
});
});
// Search again button
document.querySelectorAll('.search-again-btn').forEach(btn => {
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}"]`);
btn.classList.add('searching');
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Searching...';
try {
const result = await reSearchFolder(folder);
// 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>
</div>
`).join('');
} else {
suggestionsEl.innerHTML = '<div class="no-suggestions"><i class="fas fa-info-circle"></i> No suggestions found</div>';
}
btn.remove();
} catch (err) {
showToast('Search failed', 'error');
} finally {
btn.classList.remove('searching');
}
});
});
}
function checkEmptyList() {
const listEl = document.getElementById('folder-list');
const emptyEl = document.getElementById('empty-state');
const skipLink = document.getElementById('skip-link');
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 = '/loading'; }, 2000);
}
}
// Init
(async function init() {
const folders = await fetchUnresolved();
if (folders !== null) {
renderFolders(folders);
}
})();
</script>
</body>
</html>

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