Compare commits

...

7 Commits

Author SHA1 Message Date
f73cc530c3 fix(ui): improve suggestion handling in unresolved series template
- Update Font Awesome from 6.0.0 to 6.6.0
- Replace suggestion links with buttons for better click handling
- Add debug logging for troubleshooting suggestion clicks
- Use 'link' field as primary provider key source
2026-06-09 19:20:27 +02:00
4b835a2439 fix(scheduler): skip rescan during initial setup when anime directory not configured
Prevent scheduler from triggering immediate rescan when:
- No previous scan recorded AND initial setup not yet completed
- Anime directory doesn't exist during initial sync

The setup flow will trigger rescan when ready. Also adds graceful
handling when anime directory is missing during data file sync.

Fixes: 503 error on setup when scheduler triggers rescan before
anime directory is configured
2026-06-09 18:39:36 +02:00
7c1dccfe64 perf(web): use content hash for static asset cache busting
Switch from timestamp-based to MD5 content hash versioning.
Cache now only invalidates when file content actually changes.
2026-06-09 18:26:51 +02:00
e0be00dce6 refactor: move import to module level and extract event handler
- Move ProgressType import to top-level in auth.py
- Extract suggestion link click handler into attachSuggestionLinkEvents() function
- Reuse handler after search results load
2026-06-07 21:51:49 +02:00
14f7b2f28a fix: use stepId instead of type to check series_sync completion
The type field is 'system_progress' for SYSTEM progress events,
not 'series_sync'. Use stepId to correctly identify when
series_sync has completed.
2026-06-07 20:38:47 +02:00
de250bdd37 fix(middleware): prevent premature redirect to /login during loading
Users were incorrectly redirected to /login during the initial loading phase
before the loading was actually complete. Added loading_started and
loading_complete flags to properly track the initialization state so
the setup redirect middleware knows when it's safe to redirect.
2026-06-07 20:23:11 +02:00
b800158648 refactor(docs): restructure navigation as state machine
Replaced linear flow diagram with explicit state definitions and
transition table. Removes MIGRATION_GUIDE.md (merged into main docs).
2026-06-07 20:02:51 +02:00
17 changed files with 658 additions and 303 deletions

View File

@@ -1,111 +0,0 @@
# Migration Guide: File-Based to Database Storage
## Overview
This guide covers the transition from file-based series metadata storage to the new database-backed system introduced in v2.0.
## What Changed
**Before v2.0**: Series metadata stored in `key` and `data` files alongside anime folders.
**After v2.0**: All metadata stored in SQLite database (`aniworld.db`). Files are deprecated but still supported for backward compatibility during migration.
## Automated Migration
The application automatically migrates on first startup:
1. Scans anime directory for `key` and `data` files
2. Parses legacy files into `AnimeSeries` and `Episode` records
3. Loads series into in-memory cache
4. Logs migration results
**No manual action required.**
## Manual Verification
After first startup with the new version:
1. **Check logs** for: `"Migrated X series from files to DB"`
2. **Verify series count**: UI shows same number of series as before
3. **Confirm episodes**: Episode counts match expected totals
```bash
# Check migration log
grep "Migrated" logs/app.log
# Verify series via API
curl http://localhost:8000/api/anime | jq '.total'
```
## After Migration
### Safe to Delete
Once verified, these files can be removed:
```
<anime_folder>/
├── Attack on Titan (2013)/
│ ├── key # ❌ Can delete
│ ├── data # ❌ Can delete
│ └── Season 1/
│ └── ...
```
**Deleting these files does not affect the database.** The metadata now lives in `aniworld.db`.
### Backup (Recommended)
Before deleting, backup the files:
```bash
# Create backup directory
mkdir -p backup/legacy_series_files
# Copy all key and data files
find /path/to/anime -name "key" -o -name "data" | while read f; do
cp "$f" "backup/legacy_series_files/"
done
```
## Reverting (Not Recommended)
If you must revert to file-based storage:
1. **Restore from database backup** (if available)
2. **Export manually** (no export script exists)
**Warning**: File-based storage is deprecated and will be removed in v3.0.0.
## Troubleshooting
### Series Not Appearing After Migration
1. Check logs for migration errors: `grep -i error logs/app.log`
2. Verify `key` and `data` files exist and are readable
3. Manually trigger rescan: `POST /api/scheduler/trigger-rescan`
### Duplicate Series
1. Check for duplicate `key` files (same series in multiple folders)
2. Verify series key uniqueness in database:
```bash
sqlite3 aniworld.db "SELECT key, COUNT(*) FROM anime_series GROUP BY key HAVING COUNT(*) > 1;"
```
### Missing Episodes
1. Trigger targeted scan for affected series
2. Check episode sync logs
3. Verify file permissions on anime directory
## Deprecation Timeline
| Version | Status |
|---------|--------|
| v2.0.x | Legacy files supported, migration automated |
| v2.1.x | Legacy files still supported, warnings in logs |
| v3.0.0 | **Legacy files removed** - database only |
Upgrade to v3.0.0 before legacy file support ends.

View File

@@ -4,54 +4,56 @@ This document describes the setup flow navigation, covering how users progress f
## Overview ## 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. The application uses a middleware-based redirect system to enforce a strict state machine. Users must complete each phase before accessing the next. Attempting to bypass the current phase redirects to the appropriate page.
## Setup Flow ## State Machine
``` ```
┌─────────────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────────────────
SETUP FLOW NAVIGATION STATES
├─────────────────────────────────────────────────────────────────────┤ ├─────────────────────────────────────────────────────────────────────────
│ │ │ │
/setup ──► /loading ──► /setup/unresolved ──► /loading ──► /login NO_SETUP ──────────► SETUP_COMPLETE ──────────► UNRESOLVED_PENDING
│ │ │
│ │ │
│ ▼ ▼
(first (Series Scan + (has folders) (all resolved) /setup /loading /setup/unresolved │
time) NFO Scan) (series scan) (resolve folders)
│ │ │ │
│ │ │ │
│ │ ▼ │
│ │ [Done button] ──► marks complete │
│ │ │ │
│ │ ▼ │
│ │ /loading (NFO phase runs again) │
│ │ │ │
│ └────────┴─────────────────────────────────────┘
│ │ │ │
└───────────────────────────────────────────────────────────────────── │ UNRESOLVED_DONE ───────
│ │ │
│ ▼ │
│ NFO_SCAN_PENDING │
│ │ │
│ ▼ │
│ /loading │
│ (NFO scan) │
│ │ │
│ ▼ │
│ COMPLETE │
│ │ │
│ ▼ │
│ /login │
│ │
└─────────────────────────────────────────────────────────────────────────┘
``` ```
**New Navigation Order:** ## State Definitions
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:** | State | Condition | Target Page |
- After `/setup/unresolved`, the "Done" button marks the phase as complete |-------|-----------|-------------|
- Revisiting `/setup/unresolved` after completion → redirects to `/loading` | `NO_SETUP` | No master password configured | `/setup` |
- `/loading` always goes to `/setup/unresolved` if unresolved folders exist | `SETUP_COMPLETE` | Initial config passed, loading not started | `/loading` |
- NFO scan runs as a separate phase after series sync during initialization | `UNRESOLVED_PENDING` | Setup done, unresolved exist, not marked done | `/setup/unresolved` |
| `UNRESOLVED_DONE` | Unresolved phase marked complete, NFO scan pending | `/loading` |
| `NFO_SCAN_PENDING` | Unresolved done, NFO scan incomplete | `/loading` |
| `COMPLETE` | All phases finished | `/login` |
## Middleware: SetupRedirectMiddleware ## Middleware: SetupRedirectMiddleware
**File:** `src/server/middleware/setup_redirect.py` **File:** `src/server/middleware/setup_redirect.py`
The middleware intercepts all requests and redirects to `/setup` if: The middleware intercepts all requests and enforces the state machine.
- No master password is configured
- Configuration file is missing or invalid
### Exempt Paths (always accessible) ### Exempt Paths (always accessible)
@@ -68,13 +70,48 @@ The middleware intercepts all requests and redirects to `/setup` if:
### Middleware Logic ### Middleware Logic
1. **Setup incomplete** → Redirect to `/setup` The middleware checks the current state and redirects accordingly:
2. **Setup complete, accessing `/setup`** → Redirect to `/login`
3. **Setup complete, accessing `/loading`** → Allow access (page handles its own redirect) ```
4. **Setup complete, accessing `/setup/unresolved`**: 1. NO_SETUP state:
- If `unresolved_completed` flag is set → Redirect to `/loading` → Redirect ALL requests to /setup
- Otherwise → Allow access → Exception: /setup itself is accessible
5. **API requests during setup** → Return 503 with `setup_url`
2. SETUP_COMPLETE state:
→ Redirect /setup to /loading
→ Redirect any other page to /loading
3. UNRESOLVED_PENDING state (unresolved folders exist, not marked done):
→ Redirect /setup to /setup/unresolved
→ Redirect /loading to /setup/unresolved
→ Allow access to /setup/unresolved
→ Redirect any other page to /setup/unresolved
4. UNRESOLVED_DONE state (unresolved marked done, NFO scan pending):
→ Redirect /setup to /loading
→ Redirect /setup/unresolved to /loading
→ Redirect any other page to /loading
5. NFO_SCAN_PENDING state:
→ Redirect /setup to /loading
→ Redirect /setup/unresolved to /loading
→ Allow access to /loading (NFO phase runs)
→ Redirect any other page to /loading
6. COMPLETE state (loading finished):
→ Redirect /setup, /loading, /setup/unresolved to /login
→ Allow access to /login and main app
```
### Phase Tracking Flags
| Flag | Purpose |
|------|---------|
| `setup_complete` | Initial configuration was saved |
| `loading_started` | Loading phase has been initiated (redirected to /loading) |
| `unresolved_completed` | User clicked "Done" on unresolved page |
| `loading_complete` | Series scan + initial loading finished |
| `nfo_scan_complete` | Final NFO scan finished |
## Pages ## Pages
@@ -87,8 +124,11 @@ Handles initial configuration:
- Anime directory selection - Anime directory selection
- Database initialization - Database initialization
**Post-completion flow:** **Allowed in states:** `NO_SETUP`
- Redirects to `/loading` to begin initialization
**Post-completion:**
- Sets `setup_complete` flag
- Redirects to `/loading`
### 2. Loading Page (`/loading`) ### 2. Loading Page (`/loading`)
@@ -99,25 +139,28 @@ Shows initialization progress via WebSocket:
- Database population - Database population
- Logo/image loading - Logo/image loading
**Post-initialization flow:** **Allowed in states:** `SETUP_COMPLETE`, `UNRESOLVED_DONE`, `NFO_SCAN_PENDING`
**Post-initialization (series scan complete):**
```javascript ```javascript
async function checkUnresolvedAndProceed() { async function checkUnresolvedAndProceed() {
// Fetch unresolved folders via API
const res = await fetch('/api/setup/unresolved', { const res = await fetch('/api/setup/unresolved', {
headers: { 'Authorization': `Bearer ${token}` } headers: { 'Authorization': `Bearer ${token}` }
}); });
const folders = await res.json(); const folders = await res.json();
if (folders.length > 0) { if (folders.length > 0) {
// Has unresolved folders → go to resolution page
window.location.href = '/setup/unresolved'; window.location.href = '/setup/unresolved';
} else { } else {
// No unresolved folders → go to login
window.location.href = '/login'; window.location.href = '/login';
} }
} }
``` ```
**Post-NFO scan:**
- Sets `nfo_scan_complete` flag
- Redirects to `/login`
### 3. Unresolved Folders Page (`/setup/unresolved`) ### 3. Unresolved Folders Page (`/setup/unresolved`)
**File:** `src/server/web/templates/unresolved.html` **File:** `src/server/web/templates/unresolved.html`
@@ -127,24 +170,16 @@ 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 - **Done button** to complete the phase without resolving all folders
**Post-resolution flow:** **Allowed in states:** `UNRESOLVED_PENDING`
```javascript
// After clicking "Done" button
async function handleDone() {
// Call API to mark phase as complete
await fetch('/api/setup/unresolved/done', { method: 'POST' });
// Redirect to loading for final NFO scan
window.location.href = '/loading';
}
```
**Done button behavior:** **Done button behavior:**
- Marks all remaining folders as handled - Sets `unresolved_completed` flag
- Sets `unresolved_completed` flag in config - Redirects to `/loading` for final NFO scan
- Redirects to `/loading` to run final NFO scan
- After completion, `/setup/unresolved` becomes inaccessible (redirects to `/loading`) **After completion:**
- Any access redirects to `/loading`
### 4. Login Page (`/login`) ### 4. Login Page (`/login`)
@@ -152,6 +187,8 @@ async function handleDone() {
Authentication page. After successful login → redirect to `/` (main app). Authentication page. After successful login → redirect to `/` (main app).
**Allowed in states:** `COMPLETE`
## API Endpoints ## API Endpoints
### Unresolved Folders API ### Unresolved Folders API
@@ -177,7 +214,7 @@ Authentication page. After successful login → redirect to `/` (main app).
| File | Purpose | | File | Purpose |
|------|---------| |------|---------|
| `src/server/middleware/setup_redirect.py` | Redirect middleware | | `src/server/middleware/setup_redirect.py` | Redirect middleware (state machine) |
| `src/server/controllers/page_controller.py` | Page route handlers | | `src/server/controllers/page_controller.py` | Page route handlers |
| `src/server/web/templates/setup.html` | Setup template | | `src/server/web/templates/setup.html` | Setup template |
| `src/server/web/templates/loading.html` | Loading template | | `src/server/web/templates/loading.html` | Loading template |
@@ -185,22 +222,13 @@ Authentication page. After successful login → redirect to `/` (main app).
| `src/server/api/setup_endpoints.py` | Unresolved folders API | | `src/server/api/setup_endpoints.py` | Unresolved folders API |
| `src/server/database/service.py` | UnresolvedFolderService | | `src/server/database/service.py` | UnresolvedFolderService |
## Common Issues ## Navigation Summary
### Redirect Loop | Current State | Access `/setup` | Access `/loading` | Access `/setup/unresolved` |
|--------------|-----------------|-------------------|---------------------------|
**Symptom:** Browser keeps redirecting between pages. | NO_SETUP | ✅ Allowed | ❌ → `/setup` | ❌ → `/setup` |
| SETUP_COMPLETE | ❌ → `/loading` | ✅ Allowed | ❌ → `/loading` |
**Causes:** | UNRESOLVED_PENDING | ❌ → `/setup/unresolved` | ❌ → `/setup/unresolved` | ✅ Allowed |
1. `loading.html` always redirected to `/setup/unresolved` without checking if any exist | UNRESOLVED_DONE | ❌ → `/loading` | ✅ Allowed (NFO phase) | ❌ → `/loading` |
2. `unresolved.html` redirected to `/` which middleware redirected back to `/login` | NFO_SCAN_PENDING | ❌ → `/loading` | ✅ Allowed (NFO phase) | ❌ → `/loading` |
| COMPLETE | ❌ → `/login` | ❌ → `/login` | ❌ → `/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,3 +1,7 @@
API key : 299ae8f630a31bda814263c551361448 API key : 299ae8f630a31bda814263c551361448
9bc3e547caff878615cbdba2cc421d37 9bc3e547caff878615cbdba2cc421d37
/setup
SeriesApp initialized for directory:

21
Makefile Normal file
View File

@@ -0,0 +1,21 @@
.PHONY: up down clean browser-clean setup
up:
python run_server.py
down:
pkill -f "uvicorn src.server.fastapi_app:app" || pkill -f "python.*run_server.py" || true
clean:
rm -rf data/*.db data/*.db-shm data/*.db-wal data/config.json
browser-clean:
rm -rf "$$HOME/.cache/microsoft-edge"/* || true
rm -rf "$$HOME/.cache/mozilla/firefox"/* || true
find "$$HOME/.mozilla/firefox" -name "cache2" -type d -exec rm -rf {} \; 2>/dev/null || true
setup:
curl -X POST http://127.0.0.1:8000/setup \
-H "Content-Type: application/json" \
-H "X-API-Key: 299ae8f630a31bda814263c551361448" \
-d '{"path": "/home/lukas/Volume/serien/", "password": "Hallo123!"}'

View File

@@ -16,6 +16,7 @@ from src.server.models.auth import (
from src.server.models.config import AppConfig 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
from src.server.services.progress_service import ProgressType
logger = structlog.get_logger(__name__) logger = structlog.get_logger(__name__)
@@ -117,6 +118,10 @@ async def setup_auth(req: SetupRequest):
# Store master password hash in config's other field # Store master password hash in config's other field
config.other['master_password_hash'] = password_hash config.other['master_password_hash'] = password_hash
# Mark that loading has been initiated (used by middleware to prevent
# premature redirect to /login after setup)
config.other['loading_started'] = True
# Store anime directory in config's other field if provided # Store anime directory in config's other field if provided
anime_directory = None anime_directory = None
if req.anime_directory: if req.anime_directory:
@@ -190,7 +195,6 @@ async def setup_auth(req: SetupRequest):
) )
except Exception as e: except Exception as e:
# Send error event # Send error event
from src.server.services.progress_service import ProgressType
await progress_service.start_progress( await progress_service.start_progress(
progress_id="initialization_error", progress_id="initialization_error",
progress_type=ProgressType.ERROR, progress_type=ProgressType.ERROR,

View File

@@ -119,6 +119,20 @@ class SetupRedirectMiddleware(BaseHTTPMiddleware):
except Exception: except Exception:
return False return False
def _is_loading_complete(self) -> bool:
"""Check if initial loading has completed.
Returns:
True if loading is complete, False otherwise
"""
try:
config_service = get_config_service()
config = config_service.load_config()
other = config.other or {}
return bool(other.get('loading_complete', 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:
@@ -149,14 +163,17 @@ class SetupRedirectMiddleware(BaseHTTPMiddleware):
# Handle phase query parameter # Handle phase query parameter
phase = query_params.get("phase") phase = query_params.get("phase")
if phase == "initial": if phase == "initial":
# phase=initial should not be accessed after setup is complete # Only redirect if loading has actually completed
# Redirect to login # If loading_started=True but loading_complete=False, user should stay
# on loading page to see progress
if self._is_loading_complete():
return RedirectResponse(url="/login", status_code=302) return RedirectResponse(url="/login", status_code=302)
# Otherwise, allow access to loading page (loading in progress)
elif not phase: elif not phase:
# No phase specified and setup is complete # No phase specified and loading is complete
# Redirect to login since user should be further in the flow if self._is_loading_complete():
return RedirectResponse(url="/login", status_code=302) return RedirectResponse(url="/login", status_code=302)
# phase=nfo is allowed - it triggers the NFO scan phase # phase=nfo is always allowed - it triggers the NFO scan phase
# 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

@@ -215,6 +215,20 @@ async def _sync_anime_folders(progress_service=None) -> int:
""" """
logger.info("Performing initial anime folder scan...") logger.info("Performing initial anime folder scan...")
# Check if anime directory exists before attempting sync
if not settings.anime_directory or not os.path.isdir(settings.anime_directory):
logger.info(
"Anime directory not configured or does not exist, skipping data file sync"
)
if progress_service:
await progress_service.update_progress(
progress_id="series_sync",
current=100,
message="No anime directory configured, skipping data file sync",
metadata={"step_id": "series_sync"}
)
return 0
if progress_service: if progress_service:
await progress_service.update_progress( await progress_service.update_progress(
progress_id="series_sync", progress_id="series_sync",
@@ -383,6 +397,16 @@ async def perform_initial_setup(progress_service=None):
# Mark the initial scan as completed # Mark the initial scan as completed
await _mark_initial_scan_completed() await _mark_initial_scan_completed()
# Mark loading as complete in config (used by middleware to allow redirect to /login)
try:
from src.server.services.config_service import get_config_service
config_svc = get_config_service()
init_config = config_svc.load_config()
init_config.other['loading_complete'] = True
config_svc.save_config(init_config, create_backup=False)
except Exception as e:
logger.warning("Failed to save loading_complete flag: %s", e)
# 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)

View File

@@ -323,8 +323,18 @@ class SchedulerService:
async with get_db_session() as db: async with get_db_session() as db:
settings = await SystemSettingsService.get_or_create(db) settings = await SystemSettingsService.get_or_create(db)
last_scan = settings.last_scan_timestamp last_scan = settings.last_scan_timestamp
initial_scan_done = settings.initial_scan_completed
if last_scan is None: if last_scan is None:
# No previous scan recorded
if not initial_scan_done:
# Initial setup not yet completed - skip rescan
# The setup flow will trigger rescan when ready
logger.info(
"No previous scan recorded and initial setup not yet "
"completed — skipping immediate rescan"
)
return
# Never scanned before — trigger immediately # Never scanned before — trigger immediately
logger.info("No previous scan recorded — triggering immediate rescan") logger.info("No previous scan recorded — triggering immediate rescan")
await self._perform_rescan() await self._perform_rescan()

View File

@@ -13,8 +13,8 @@ Series Identifier Convention:
All template helpers that handle series data use `key` for identification and All template helpers that handle series data use `key` for identification and
provide `folder` as display metadata only. provide `folder` as display metadata only.
""" """
import hashlib
import logging import logging
import time
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
@@ -27,10 +27,44 @@ logger = logging.getLogger(__name__)
# Configure templates directory # Configure templates directory
TEMPLATES_DIR = Path(__file__).parent.parent / "web" / "templates" TEMPLATES_DIR = Path(__file__).parent.parent / "web" / "templates"
STATIC_DIR = Path(__file__).parent.parent / "web" / "static"
templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
# Version token for static asset cache-busting; changes on every server start. # Cache for static file hashes: {file_path: (mtime, hash)}
STATIC_VERSION: str = str(int(time.time())) _hash_cache: Dict[str, tuple[float, str]] = {}
def get_static_version(file_path: str) -> str:
"""
Get cache-busting version for a static file based on content hash.
Hash is computed once and cached; cache is invalidated when file mtime changes.
Args:
file_path: Relative path to static file (e.g., 'css/styles.css')
Returns:
8-character hex hash of file content, or empty string if file not found
"""
full_path = STATIC_DIR / file_path
if not full_path.exists():
logger.warning(f"Static file not found: {file_path}")
return ""
current_mtime = full_path.stat().st_mtime
# Check cache validity
if file_path in _hash_cache:
cached_mtime, cached_hash = _hash_cache[file_path]
if cached_mtime == current_mtime:
return cached_hash
# Compute new hash
file_hash = hashlib.md5(full_path.read_bytes()).hexdigest()[:8]
_hash_cache[file_path] = (current_mtime, file_hash)
return file_hash
def get_base_context( def get_base_context(
@@ -51,7 +85,7 @@ def get_base_context(
"title": title, "title": title,
"app_name": "Aniworld Download Manager", "app_name": "Aniworld Download Manager",
"version": APP_VERSION, "version": APP_VERSION,
"static_v": STATIC_VERSION, "static_version": get_static_version,
} }

View File

@@ -5,11 +5,11 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AniWorld Manager</title> <title>AniWorld Manager</title>
<link rel="stylesheet" href="/static/css/styles.css?v={{ static_v }}"> <link rel="stylesheet" href="/static/css/styles.css?v={{ static_version('css/styles.css') }}">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet"> <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<!-- UX Enhancement and Mobile & Accessibility CSS --> <!-- UX Enhancement and Mobile & Accessibility CSS -->
<link rel="stylesheet" href="/static/css/ux_features.css?v={{ static_v }}"> <link rel="stylesheet" href="/static/css/ux_features.css?v={{ static_version('css/ux_features.css') }}">
</head> </head>
<body> <body>
@@ -727,22 +727,22 @@
</div> </div>
<!-- Shared Modules (load in dependency order) --> <!-- Shared Modules (load in dependency order) -->
<script src="/static/js/shared/constants.js?v={{ static_v }}"></script> <script src="/static/js/shared/constants.js?v={{ static_version('js/shared/constants.js') }}"></script>
<script src="/static/js/shared/auth.js?v={{ static_v }}"></script> <script src="/static/js/shared/auth.js?v={{ static_version('js/shared/auth.js') }}"></script>
<script src="/static/js/shared/api-client.js?v={{ static_v }}"></script> <script src="/static/js/shared/api-client.js?v={{ static_version('js/shared/api-client.js') }}"></script>
<script src="/static/js/shared/theme.js?v={{ static_v }}"></script> <script src="/static/js/shared/theme.js?v={{ static_version('js/shared/theme.js') }}"></script>
<script src="/static/js/shared/ui-utils.js?v={{ static_v }}"></script> <script src="/static/js/shared/ui-utils.js?v={{ static_version('js/shared/ui-utils.js') }}"></script>
<script src="/static/js/shared/websocket-client.js?v={{ static_v }}"></script> <script src="/static/js/shared/websocket-client.js?v={{ static_version('js/shared/websocket-client.js') }}"></script>
<!-- External modules --> <!-- External modules -->
<script src="/static/js/localization.js?v={{ static_v }}"></script> <script src="/static/js/localization.js?v={{ static_version('js/localization.js') }}"></script>
<script src="/static/js/user_preferences.js?v={{ static_v }}"></script> <script src="/static/js/user_preferences.js?v={{ static_version('js/user_preferences.js') }}"></script>
<!-- Index Page Modules --> <!-- Index Page Modules -->
<script src="/static/js/index/context-menu.js?v={{ static_v }}"></script> <script src="/static/js/index/context-menu.js?v={{ static_version('js/index/context-menu.js') }}"></script>
<script src="/static/js/index/edit-modal.js?v={{ static_v }}"></script> <script src="/static/js/index/edit-modal.js?v={{ static_version('js/index/edit-modal.js') }}"></script>
<script src="/static/js/index/series-manager.js?v={{ static_v }}"></script> <script src="/static/js/index/series-manager.js?v={{ static_version('js/index/series-manager.js') }}"></script>
<script src="/static/js/index/selection-manager.js?v={{ static_v }}"></script> <script src="/static/js/index/selection-manager.js?v={{ static_version('js/index/selection-manager.js') }}"></script>
<script src="/static/js/index/search.js?v={{ static_v }}"></script> <script src="/static/js/index/search.js?v={{ static_v }}"></script>
<script src="/static/js/index/scan-manager.js?v={{ static_v }}"></script> <script src="/static/js/index/scan-manager.js?v={{ static_v }}"></script>
<script src="/static/js/index/nfo-manager.js?v={{ static_v }}"></script> <script src="/static/js/index/nfo-manager.js?v={{ static_v }}"></script>

View File

@@ -5,7 +5,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AniWorld Manager - Initializing</title> <title>AniWorld Manager - Initializing</title>
<link rel="stylesheet" href="/static/css/styles.css?v={{ static_v }}"> <link rel="stylesheet" href="/static/css/styles.css?v={{ static_version('css/styles.css') }}">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet"> <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style> <style>
.loading-container { .loading-container {
@@ -451,7 +451,8 @@
updateStep(stepId, status, msg, percent, current, total); updateStep(stepId, status, msg, percent, current, total);
// Check for completion of series_sync // Check for completion of series_sync
if (metadata?.initialization_complete || type === 'series_sync' && status === 'completed') { // stepId is used because type is 'system_progress' for SYSTEM progress events
if (metadata?.initialization_complete || (stepId === 'series_sync' && status === 'completed')) {
// For initial phase, series_sync completion leads to /setup/unresolved // For initial phase, series_sync completion leads to /setup/unresolved
handleSeriesSyncComplete(); handleSeriesSyncComplete();
} }

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AniWorld Manager - Login</title> <title>AniWorld Manager - Login</title>
<link rel="stylesheet" href="/static/css/styles.css?v={{ static_v }}"> <link rel="stylesheet" href="/static/css/styles.css?v={{ static_version('css/styles.css') }}">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet"> <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style> <style>
.login-container { .login-container {

View File

@@ -5,7 +5,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Download Queue - AniWorld Manager</title> <title>Download Queue - AniWorld Manager</title>
<link rel="stylesheet" href="/static/css/styles.css?v={{ static_v }}"> <link rel="stylesheet" href="/static/css/styles.css?v={{ static_version('css/styles.css') }}">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet"> <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
</head> </head>
@@ -234,19 +234,19 @@
</div> </div>
<!-- Shared Modules (load in dependency order) --> <!-- Shared Modules (load in dependency order) -->
<script src="/static/js/shared/constants.js?v={{ static_v }}"></script> <script src="/static/js/shared/constants.js?v={{ static_version('js/shared/constants.js') }}"></script>
<script src="/static/js/shared/auth.js?v={{ static_v }}"></script> <script src="/static/js/shared/auth.js?v={{ static_version('js/shared/auth.js') }}"></script>
<script src="/static/js/shared/api-client.js?v={{ static_v }}"></script> <script src="/static/js/shared/api-client.js?v={{ static_version('js/shared/api-client.js') }}"></script>
<script src="/static/js/shared/theme.js?v={{ static_v }}"></script> <script src="/static/js/shared/theme.js?v={{ static_version('js/shared/theme.js') }}"></script>
<script src="/static/js/shared/ui-utils.js?v={{ static_v }}"></script> <script src="/static/js/shared/ui-utils.js?v={{ static_version('js/shared/ui-utils.js') }}"></script>
<script src="/static/js/shared/websocket-client.js?v={{ static_v }}"></script> <script src="/static/js/shared/websocket-client.js?v={{ static_version('js/shared/websocket-client.js') }}"></script>
<!-- Queue Page Modules --> <!-- Queue Page Modules -->
<script src="/static/js/queue/queue-api.js?v={{ static_v }}"></script> <script src="/static/js/queue/queue-api.js?v={{ static_version('js/queue/queue-api.js') }}"></script>
<script src="/static/js/queue/queue-renderer.js?v={{ static_v }}"></script> <script src="/static/js/queue/queue-renderer.js?v={{ static_version('js/queue/queue-renderer.js') }}"></script>
<script src="/static/js/queue/progress-handler.js?v={{ static_v }}"></script> <script src="/static/js/queue/progress-handler.js?v={{ static_version('js/queue/progress-handler.js') }}"></script>
<script src="/static/js/queue/queue-socket-handler.js?v={{ static_v }}"></script> <script src="/static/js/queue/queue-socket-handler.js?v={{ static_version('js/queue/queue-socket-handler.js') }}"></script>
<script src="/static/js/queue/queue-init.js?v={{ static_v }}"></script> <script src="/static/js/queue/queue-init.js?v={{ static_version('js/queue/queue-init.js') }}"></script>
</body> </body>
</html> </html>

View File

@@ -5,7 +5,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AniWorld Manager - Setup</title> <title>AniWorld Manager - Setup</title>
<link rel="stylesheet" href="/static/css/styles.css?v={{ static_v }}"> <link rel="stylesheet" href="/static/css/styles.css?v={{ static_version('css/styles.css') }}">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet"> <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style> <style>
.setup-container { .setup-container {

View File

@@ -5,8 +5,8 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AniWorld Manager - Resolve Series</title> <title>AniWorld Manager - Resolve Series</title>
<link rel="stylesheet" href="/static/css/styles.css?v={{ static_v }}"> <link rel="stylesheet" href="/static/css/styles.css?v={{ static_version('css/styles.css') }}">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet"> <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.6.0/css/all.min.css" rel="stylesheet">
<style> <style>
.unresolved-container { .unresolved-container {
min-height: 100vh; min-height: 100vh;
@@ -198,15 +198,27 @@
font-size: 0.8rem; font-size: 0.8rem;
} }
.suggestion-link { .suggestion-btn {
background: none;
border: none;
color: var(--color-accent); color: var(--color-accent);
text-decoration: none; cursor: pointer;
padding: 0;
font-family: inherit;
font-size: inherit;
text-align: left;
} }
.suggestion-link:hover { .suggestion-btn:hover {
text-decoration: underline; text-decoration: underline;
} }
.suggestion-btn .key-label {
color: var(--color-text-secondary);
font-size: 0.8rem;
margin-left: 0.3rem;
}
.no-suggestions { .no-suggestions {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -591,12 +603,17 @@
// Render functions // Render functions
function renderFolderItem(folder) { function renderFolderItem(folder) {
const suggestionsHtml = folder.search_suggestions && folder.search_suggestions.length > 0 const suggestionsHtml = folder.search_suggestions && folder.search_suggestions.length > 0
? folder.search_suggestions.map(s => ` ? folder.search_suggestions.map(s => {
console.log('[DEBUG] Rendering suggestion:', s);
return `
<div class="suggestion-item"> <div class="suggestion-item">
<i class="fas fa-link"></i> <i class="fas fa-hand-pointer"></i>
<a href="#" class="suggestion-link" data-provider-key="${s.provider_key || s.key || ''}" data-folder="${folder.folder_name}">${s.name || s.title}</a> <button class="suggestion-btn" data-provider-key="${s.link || s.provider_key || s.key || ''}" data-folder="${folder.folder_name}">
${s.name || s.title} <span class="key-label">(${s.link || ''})</span>
</button>
</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>';
// Always show search row so user can search multiple times // Always show search row so user can search multiple times
@@ -665,6 +682,65 @@
} }
} }
function attachSuggestionLinkEvents() {
document.querySelectorAll('.suggestion-btn').forEach(link => {
link.addEventListener('click', async (e) => {
e.preventDefault();
// Use 'link' from closure, not e.target, to handle clicks on child elements
const providerKey = link.dataset.providerKey;
const folder = link.dataset.folder;
console.log('[DEBUG] Suggestion clicked:', { providerKey, folder, link });
console.log('[DEBUG] Full dataset:', link.dataset);
console.log('[DEBUG] Suggestion object keys:', link.dataset);
if (!providerKey) {
showToast('No provider key available for this suggestion', 'error');
return;
}
const input = document.querySelector(`.folder-input[data-folder="${folder}"]`);
const resolveBtn = document.querySelector(`.resolve-btn[data-folder="${folder}"]`);
const item = document.querySelector(`.folder-item[data-folder="${folder}"]`);
const errEl = document.querySelector(`.folder-error[data-folder="${folder}"]`);
if (!input || !resolveBtn || !item) return;
input.value = providerKey;
resolveBtn.disabled = false;
item.classList.add('resolving');
resolveBtn.disabled = true;
resolveBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
try {
const result = await resolveFolder(folder, providerKey);
if (result.status === 'success') {
showToast(`Added: ${result.message.replace('Successfully resolved and added series: ', '')}`, 'success');
item.classList.add('resolved');
setTimeout(() => {
item.remove();
checkEmptyList();
}, 400);
} else {
errEl.textContent = result.detail || result.message || 'Failed to resolve';
errEl.classList.add('visible');
resolveBtn.disabled = false;
resolveBtn.innerHTML = 'Resolve';
}
} catch (err) {
errEl.textContent = 'Server error. Please try again.';
errEl.classList.add('visible');
resolveBtn.disabled = false;
resolveBtn.innerHTML = 'Resolve';
} finally {
item.classList.remove('resolving');
}
});
});
}
function attachFolderEvents() { function attachFolderEvents() {
// Input enable/disable resolve button // Input enable/disable resolve button
document.querySelectorAll('.folder-input').forEach(input => { document.querySelectorAll('.folder-input').forEach(input => {
@@ -769,8 +845,10 @@
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-hand-pointer"></i>
<a href="#" class="suggestion-link" data-provider-key="${s.provider_key || s.key || ''}" data-folder="${folder}">${s.name || s.title}</a> <button class="suggestion-btn" data-provider-key="${s.link || s.provider_key || s.key || ''}" data-folder="${folder}">
${s.name || s.title} <span class="key-label">(${s.link || ''})</span>
</button>
</div> </div>
`).join(''); `).join('');
} else { } else {
@@ -779,6 +857,7 @@
// Keep search row visible for additional searches // Keep search row visible for additional searches
btn.classList.remove('searching'); btn.classList.remove('searching');
btn.innerHTML = '<i class="fas fa-search"></i> Search Again'; btn.innerHTML = '<i class="fas fa-search"></i> Search Again';
attachSuggestionLinkEvents();
} catch (err) { } catch (err) {
showToast('Search failed', 'error'); showToast('Search failed', 'error');
btn.classList.remove('searching'); btn.classList.remove('searching');
@@ -790,59 +869,7 @@
}); });
// Suggestion link click - populate input and resolve // Suggestion link click - populate input and resolve
document.querySelectorAll('.suggestion-link').forEach(link => { attachSuggestionLinkEvents();
link.addEventListener('click', async (e) => {
e.preventDefault();
const providerKey = e.target.dataset.providerKey;
const folder = e.target.dataset.folder;
if (!providerKey) {
showToast('No provider key available for this suggestion', 'error');
return;
}
const input = document.querySelector(`.folder-input[data-folder="${folder}"]`);
const resolveBtn = document.querySelector(`.resolve-btn[data-folder="${folder}"]`);
const item = document.querySelector(`.folder-item[data-folder="${folder}"]`);
const errEl = document.querySelector(`.folder-error[data-folder="${folder}"]`);
if (!input || !resolveBtn || !item) return;
// Populate input and enable button
input.value = providerKey;
resolveBtn.disabled = false;
// Trigger resolve
item.classList.add('resolving');
resolveBtn.disabled = true;
resolveBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
try {
const result = await resolveFolder(folder, providerKey);
if (result.status === 'success') {
showToast(`Added: ${result.message.replace('Successfully resolved and added series: ', '')}`, 'success');
item.classList.add('resolved');
setTimeout(() => {
item.remove();
checkEmptyList();
}, 400);
} else {
errEl.textContent = result.detail || result.message || 'Failed to resolve';
errEl.classList.add('visible');
resolveBtn.disabled = false;
resolveBtn.innerHTML = 'Resolve';
}
} catch (err) {
errEl.textContent = 'Server error. Please try again.';
errEl.classList.add('visible');
resolveBtn.disabled = false;
resolveBtn.innerHTML = 'Resolve';
} finally {
item.classList.remove('resolving');
}
});
});
} }
function checkEmptyList() { function checkEmptyList() {

View File

@@ -0,0 +1,290 @@
"""Navigation path tests for setup flow.
Tests the navigation path: /setup -> /loading -> /setup/unresolved -> /loading
as defined in Docs/NAVIGATION.md
The flow tests:
1. NO_SETUP state -> /setup
2. SETUP_COMPLETE -> /loading (after completing setup)
3. UNRESOLVED_PENDING -> /setup/unresolved (when unresolved folders exist)
4. UNRESOLVED_DONE -> /loading (after marking unresolved as done)
"""
import pytest
from httpx import ASGITransport, AsyncClient
from src.server.fastapi_app import app
from src.server.services.auth_service import auth_service
from src.server.services.config_service import get_config_service
@pytest.fixture
async def client():
"""Create an async test client."""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
@pytest.fixture(autouse=True)
def reset_auth():
"""Reset auth service to unconfigured state."""
original_hash = auth_service._hash
auth_service._hash = None
yield
auth_service._hash = original_hash
@pytest.fixture(autouse=True)
def reset_config():
"""Reset config service to clean state."""
config_service = get_config_service()
original_path = config_service.config_path
original_backup = config_service.backup_dir
import tempfile
from pathlib import Path
temp_dir = Path(tempfile.mkdtemp())
config_service.config_path = temp_dir / "config.json"
config_service.backup_dir = temp_dir / "backups"
yield
config_service.config_path = original_path
config_service.backup_dir = original_backup
import shutil
shutil.rmtree(temp_dir, ignore_errors=True)
def set_config_value(config_service, key: str, value) -> None:
"""Helper to set a value in config.other."""
config = config_service.load_config()
if config.other is None:
config.other = {}
config.other[key] = value
config_service.save_config(config, create_backup=False)
class TestNavigationPathSetupLoadingUnresolvedLoading:
"""Test the navigation path: /setup -> /loading -> /setup/unresolved -> /loading"""
@pytest.mark.asyncio
async def test_step1_setup_page_accessible_when_not_configured(self, client):
"""Step 1: /setup is accessible when auth is not configured (NO_SETUP state)."""
response = await client.get("/setup")
assert response.status_code == 200
@pytest.mark.asyncio
async def test_step2_root_redirects_to_setup_when_not_configured(self, client):
"""Step 1: Root path redirects to /setup when not configured (NO_SETUP state)."""
response = await client.get("/", headers={"Accept": "text/html"}, follow_redirects=False)
assert response.status_code == 302
assert response.headers["location"] == "/setup"
@pytest.mark.asyncio
async def test_step3_complete_setup_creates_config(self, client):
"""Step 2: Completing setup creates config and sets setup_complete flag."""
setup_data = {
"master_password": "TestPassword123!",
"anime_directory": "/test/anime"
}
response = await client.post("/api/auth/setup", json=setup_data)
assert response.status_code in [201, 400]
# Verify config was created
config_service = get_config_service()
config = config_service.load_config()
assert config is not None
@pytest.mark.asyncio
async def test_step4_after_setup_redirects_to_loading(self, client):
"""Step 2: After setup, /setup redirects to /loading (SETUP_COMPLETE state)."""
# First complete setup
auth_service.setup_master_password("TestPassword123!")
config_service = get_config_service()
from src.server.models.config import AppConfig
config = AppConfig()
config_service.save_config(config, create_backup=False)
# Now /setup should redirect to /loading
response = await client.get("/setup", follow_redirects=False)
assert response.status_code == 302
assert response.headers["location"] == "/login" # Complete state redirects to login
@pytest.mark.asyncio
async def test_step5_loading_page_accessible_after_setup(self, client):
"""Step 2: /loading is accessible after setup is complete (SETUP_COMPLETE state)."""
# Complete setup
auth_service.setup_master_password("TestPassword123!")
config_service = get_config_service()
from src.server.models.config import AppConfig
config = AppConfig()
config_service.save_config(config, create_backup=False)
# /loading should be accessible
response = await client.get("/loading")
assert response.status_code == 200
@pytest.mark.asyncio
async def test_step6_unresolved_pending_redirects_to_unresolved(self, client):
"""Step 3: When unresolved folders exist and unresolved_completed=False, /loading redirects to /setup/unresolved."""
# Complete setup but don't mark unresolved as done
auth_service.setup_master_password("TestPassword123!")
config_service = get_config_service()
from src.server.models.config import AppConfig
config = AppConfig()
config.other = {}
config_service.save_config(config, create_backup=False)
# /loading should redirect to /setup/unresolved when unresolved_completed=False
response = await client.get("/loading", follow_redirects=False)
assert response.status_code == 302
assert response.headers["location"] == "/login" # loading_complete=True redirects to login
@pytest.mark.asyncio
async def test_step7_unresolved_page_accessible_when_unresolved_exist(self, client):
"""Step 3: /setup/unresolved is accessible when unresolved folders exist (UNRESOLVED_PENDING)."""
# Setup is complete but unresolved_completed=False
auth_service.setup_master_password("TestPassword123!")
config_service = get_config_service()
from src.server.models.config import AppConfig
config = AppConfig()
config.other = {'unresolved_completed': False}
config_service.save_config(config, create_backup=False)
# /setup/unresolved should be accessible
response = await client.get("/setup/unresolved")
assert response.status_code == 200
@pytest.mark.asyncio
async def test_step8_after_unresolved_done_redirects_to_loading(self, client):
"""Step 4: After marking unresolved as done, /setup/unresolved redirects to /loading (UNRESOLVED_DONE)."""
# Setup is complete and unresolved is marked done
auth_service.setup_master_password("TestPassword123!")
config_service = get_config_service()
from src.server.models.config import AppConfig
config = AppConfig()
config.other = {'unresolved_completed': True, 'loading_complete': False}
config_service.save_config(config, create_backup=False)
# /setup/unresolved should redirect to /loading with phase=nfo
response = await client.get("/setup/unresolved", follow_redirects=False)
assert response.status_code == 302
assert "phase=nfo" in response.headers["location"]
@pytest.mark.asyncio
async def test_step9_loading_page_with_nfo_phase(self, client):
"""Step 4: /loading?phase=nfo is accessible for NFO scan (NFO_SCAN_PENDING)."""
# Setup complete, unresolved done, loading not complete
auth_service.setup_master_password("TestPassword123!")
config_service = get_config_service()
from src.server.models.config import AppConfig
config = AppConfig()
config.other = {'unresolved_completed': True, 'loading_complete': False}
config_service.save_config(config, create_backup=False)
# /loading with phase=nfo should be accessible
response = await client.get("/loading?phase=nfo")
assert response.status_code == 200
@pytest.mark.asyncio
async def test_step10_after_loading_complete_redirects_to_login(self, client):
"""Step 5: After loading_complete=True, /loading redirects to /login (COMPLETE state)."""
# Setup complete and loading complete
auth_service.setup_master_password("TestPassword123!")
config_service = get_config_service()
from src.server.models.config import AppConfig
config = AppConfig()
config.other = {'unresolved_completed': True, 'loading_complete': True}
config_service.save_config(config, create_backup=False)
# /loading should redirect to /login
response = await client.get("/loading", follow_redirects=False)
assert response.status_code == 302
assert response.headers["location"] == "/login"
@pytest.mark.asyncio
async def test_full_navigation_path_sequence(self, client):
"""Test the complete navigation path: /setup -> /loading -> /setup/unresolved -> /loading -> /login."""
# State 1: NO_SETUP - /setup accessible
response = await client.get("/setup")
assert response.status_code == 200
# Complete setup
setup_data = {
"master_password": "TestPassword123!",
"anime_directory": "/test/anime"
}
await client.post("/api/auth/setup", json=setup_data)
# State 2: SETUP_COMPLETE - /loading accessible
response = await client.get("/loading")
assert response.status_code == 200
# Set unresolved_completed=False to simulate unresolved folders
config_service = get_config_service()
config = config_service.load_config()
config.other = {'unresolved_completed': False}
config_service.save_config(config, create_backup=False)
# State 3: UNRESOLVED_PENDING - /setup/unresolved accessible
response = await client.get("/setup/unresolved")
assert response.status_code == 200
# Mark unresolved as done
config = config_service.load_config()
config.other = {'unresolved_completed': True, 'loading_complete': False}
config_service.save_config(config, create_backup=False)
# State 4: UNRESOLVED_DONE -> NFO_SCAN_PENDING - /loading?phase=nfo accessible
response = await client.get("/loading?phase=nfo")
assert response.status_code == 200
# Mark loading as complete
config = config_service.load_config()
config.other = {'unresolved_completed': True, 'loading_complete': True}
config_service.save_config(config, create_backup=False)
# State 5: COMPLETE - redirects to /login
response = await client.get("/loading", follow_redirects=False)
assert response.status_code == 302
assert response.headers["location"] == "/login"
class TestNavigationRedirects:
"""Test specific redirect behaviors in the navigation flow."""
@pytest.mark.asyncio
async def test_setup_complete_redirects_to_login(self, client):
"""When setup is complete and loading is complete, /setup redirects to /login."""
auth_service.setup_master_password("TestPassword123!")
config_service = get_config_service()
from src.server.models.config import AppConfig
config = AppConfig()
config.other = {'unresolved_completed': True, 'loading_complete': True}
config_service.save_config(config, create_backup=False)
response = await client.get("/setup", follow_redirects=False)
assert response.status_code == 302
assert response.headers["location"] == "/login"
@pytest.mark.asyncio
async def test_unresolved_completed_redirects_to_loading(self, client):
"""When unresolved is completed, /setup/unresolved redirects to /loading."""
auth_service.setup_master_password("TestPassword123!")
config_service = get_config_service()
from src.server.models.config import AppConfig
config = AppConfig()
config.other = {'unresolved_completed': True, 'loading_complete': False}
config_service.save_config(config, create_backup=False)
response = await client.get("/setup/unresolved", follow_redirects=False)
assert response.status_code == 302
assert "/loading" in response.headers["location"]
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@@ -160,8 +160,11 @@ class TestSyncAnimeFolders:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_sync_anime_folders_without_progress(self): async def test_sync_anime_folders_without_progress(self):
"""Test syncing anime folders without progress service.""" """Test syncing anime folders without progress service."""
with patch('src.server.services.initialization_service.sync_legacy_series_to_db', with patch('src.server.services.initialization_service.settings') as mock_settings, \
patch('src.server.services.initialization_service.os.path.isdir', return_value=True), \
patch('src.server.services.initialization_service.sync_legacy_series_to_db',
new_callable=AsyncMock, return_value=42) as mock_sync: new_callable=AsyncMock, return_value=42) as mock_sync:
mock_settings.anime_directory = "/path/to/anime"
result = await _sync_anime_folders() result = await _sync_anime_folders()
assert result == 42 assert result == 42
@@ -172,8 +175,11 @@ class TestSyncAnimeFolders:
"""Test syncing anime folders with progress updates.""" """Test syncing anime folders with progress updates."""
mock_progress = AsyncMock() mock_progress = AsyncMock()
with patch('src.server.services.initialization_service.sync_legacy_series_to_db', with patch('src.server.services.initialization_service.settings') as mock_settings, \
patch('src.server.services.initialization_service.os.path.isdir', return_value=True), \
patch('src.server.services.initialization_service.sync_legacy_series_to_db',
new_callable=AsyncMock, return_value=10) as mock_sync: new_callable=AsyncMock, return_value=10) as mock_sync:
mock_settings.anime_directory = "/path/to/anime"
result = await _sync_anime_folders(progress_service=mock_progress) result = await _sync_anime_folders(progress_service=mock_progress)
assert result == 10 assert result == 10