Compare commits

..

12 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
11 changed files with 245 additions and 59 deletions

View File

@@ -1 +1 @@
v1.4.4 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.4", "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

@@ -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

@@ -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

@@ -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() {
@@ -479,32 +475,26 @@
} }
async function checkUnresolvedAndProceed() { async function checkUnresolvedAndProceed() {
// Fetch unresolved folders and only redirect if there are any
// Otherwise go directly to login
try { try {
const token = localStorage.getItem('auth_token'); const token = localStorage.getItem('auth_token');
if (!token) {
// No token, go to login
document.getElementById('completionMessage').style.display = 'block';
return;
}
const res = await fetch('/api/setup/unresolved', { const res = await fetch('/api/setup/unresolved', {
headers: { 'Authorization': `Bearer ${token}` } headers: { 'Authorization': `Bearer ${token}` }
}); });
if (res.ok) { if (res.ok) {
const unresolved = await res.json(); const folders = await res.json();
if (unresolved && unresolved.length > 0) { if (folders && folders.length > 0) {
// Has unresolved folders - redirect to unresolved page // Has unresolved folders - go to resolution page
window.location.href = '/setup/unresolved'; window.location.href = '/setup/unresolved';
return; return;
} }
} }
} catch (e) { } catch (err) {
console.error('Error checking unresolved folders:', e); console.error('Failed to check unresolved folders:', err);
} }
// No unresolved folders or error - go to login
// No unresolved folders or error - show completion message window.location.href = '/login';
document.getElementById('completionMessage').style.display = 'block';
} }
function showError(message) { function showError(message) {

View File

@@ -443,15 +443,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;
@@ -552,7 +550,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';
@@ -690,7 +688,7 @@
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);
} }
} }

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