Compare commits

..

19 Commits

Author SHA1 Message Date
288b03cbb4 chore: bump version 2026-06-09 20:50:06 +02:00
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
4e0c66ea9e chore: bump version 2026-06-07 17:43:01 +02:00
07c311c1cd feat(setup): separate NFO scan into dedicated phase
- Add /nfo-scan-phase endpoint to trigger NFO scan independently
- Move NFO scan out of initial setup into separate post-unresolved phase
- Add phase query param handling for /loading page (?phase=initial, ?phase=nfo)
- Update setup redirect middleware to handle phase-based redirects
- Update auth setup to pass phase=initial to loading page
2026-06-07 17:37:32 +02:00
cf00c9f7c5 fix: keep search controls visible and enable suggestion click-to-resolve
- Search input and button now stay visible after Search Again for unlimited searches
- Clicking a suggestion populates provider key and triggers resolve, card disappears
- Added data-provider-key attribute to suggestion links for click handling
2026-06-07 16:18:50 +02:00
f3042206a8 chore: bump version 2026-06-07 16:01:01 +02:00
657e7f9bf5 fix: use correct get_anime_service in NFO scan
_execute_nfo_scan() was importing get_anime_service from anime_service.py
which is a factory requiring series_app argument. Changed to import from
dependencies.py which handles series_app internally and provides proper
dependency injection with caching.
2026-06-06 23:57:12 +02:00
fd3ec5df83 chore: bump version 2026-06-06 23:48:09 +02:00
275aeb4544 feat(setup): add done button and integrate NFO scan into initialization
- Add /api/setup/unresolved/done endpoint to mark phase complete
- NFO scan now runs after series sync during initialization
- Middleware redirects to /login after setup complete (was /loading)
- Done button allows skipping folder resolution with redirect to NFO scan phase
2026-06-06 23:47:48 +02:00
be7b210959 feat: add custom query support for unresolved folder re-search
- Add SearchFolderRequest model for optional custom search query
- Update search endpoint to use custom query if provided
- Add search-again input field in UI for custom queries
- Increment search_attempts counter on re-search
2026-06-06 23:31:25 +02:00
486c5440f2 docs: add comprehensive documentation files
Added documentation for API, architecture, configuration, database,
development guide, testing, and navigation. Includes helper scripts,
diagrams, and guides for NFO files and migration.
2026-06-06 23:15:46 +02:00
4076b9dd43 docs: add API key for documentation
Added key file to Docs directory for documentation purposes.
2026-06-06 23:15:20 +02:00
df93e8a81f backuo 2026-06-06 23:12:39 +02:00
40 changed files with 1494 additions and 464 deletions

View File

@@ -1 +1 @@
v1.4.10
v1.4.14

234
Docs/NAVIGATION.md Normal file
View File

@@ -0,0 +1,234 @@
# 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 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.
## State Machine
```
┌─────────────────────────────────────────────────────────────────────────┐
│ NAVIGATION STATES │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ NO_SETUP ──────────► SETUP_COMPLETE ──────────► UNRESOLVED_PENDING │
│ │ │ │ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ /setup /loading /setup/unresolved │
│ (series scan) (resolve folders) │
│ │
│ UNRESOLVED_DONE ───────┐
│ │ │
│ ▼ │
│ NFO_SCAN_PENDING │
│ │ │
│ ▼ │
│ /loading │
│ (NFO scan) │
│ │ │
│ ▼ │
│ COMPLETE │
│ │ │
│ ▼ │
│ /login │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
## State Definitions
| State | Condition | Target Page |
|-------|-----------|-------------|
| `NO_SETUP` | No master password configured | `/setup` |
| `SETUP_COMPLETE` | Initial config passed, loading not started | `/loading` |
| `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
**File:** `src/server/middleware/setup_redirect.py`
The middleware intercepts all requests and enforces the state machine.
### 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
The middleware checks the current state and redirects accordingly:
```
1. NO_SETUP state:
→ Redirect ALL requests to /setup
→ Exception: /setup itself is accessible
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
### 1. Setup Page (`/setup`)
**File:** `src/server/web/templates/setup.html`
Handles initial configuration:
- Master password creation
- Anime directory selection
- Database initialization
**Allowed in states:** `NO_SETUP`
**Post-completion:**
- Sets `setup_complete` flag
- Redirects to `/loading`
### 2. Loading Page (`/loading`)
**File:** `src/server/web/templates/loading.html`
Shows initialization progress via WebSocket:
- Series scanning
- Database population
- Logo/image loading
**Allowed in states:** `SETUP_COMPLETE`, `UNRESOLVED_DONE`, `NFO_SCAN_PENDING`
**Post-initialization (series scan complete):**
```javascript
async function checkUnresolvedAndProceed() {
const res = await fetch('/api/setup/unresolved', {
headers: { 'Authorization': `Bearer ${token}` }
});
const folders = await res.json();
if (folders.length > 0) {
window.location.href = '/setup/unresolved';
} else {
window.location.href = '/login';
}
}
```
**Post-NFO scan:**
- Sets `nfo_scan_complete` flag
- Redirects to `/login`
### 3. Unresolved Folders Page (`/setup/unresolved`)
**File:** `src/server/web/templates/unresolved.html`
Allows manual resolution of folders that couldn't be auto-matched:
- Shows list of unresolved folders
- Provides search suggestions
- Input field for entering provider key
- Resolve/delete actions
- **Done button** to complete the phase without resolving all folders
**Allowed in states:** `UNRESOLVED_PENDING`
**Done button behavior:**
- Sets `unresolved_completed` flag
- Redirects to `/loading` for final NFO scan
**After completion:**
- Any access redirects to `/loading`
### 4. Login Page (`/login`)
**File:** `src/server/web/templates/login.html`
Authentication page. After successful login → redirect to `/` (main app).
**Allowed in states:** `COMPLETE`
## API Endpoints
### Unresolved Folders API
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/setup/unresolved` | List all unresolved folders |
| `GET` | `/api/setup/unresolved/{folder_name}` | Get specific folder details |
| `POST` | `/api/setup/unresolved/{folder_name}/resolve` | Resolve with provider key |
| `POST` | `/api/setup/unresolved/{folder_name}/search` | Re-search for matches |
| `DELETE` | `/api/setup/unresolved/{folder_name}` | Remove folder from tracking |
| `POST` | `/api/setup/unresolved/done` | Mark unresolved phase as complete |
### Auth API
| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/api/auth/setup` | Create master password |
| `POST` | `/api/auth/login` | Authenticate |
| `POST` | `/api/auth/logout` | End session |
## Key Files
| File | Purpose |
|------|---------|
| `src/server/middleware/setup_redirect.py` | Redirect middleware (state machine) |
| `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 |
## Navigation Summary
| Current State | Access `/setup` | Access `/loading` | Access `/setup/unresolved` |
|--------------|-----------------|-------------------|---------------------------|
| NO_SETUP | ✅ Allowed | ❌ → `/setup` | ❌ → `/setup` |
| SETUP_COMPLETE | ❌ → `/loading` | ✅ Allowed | ❌ → `/loading` |
| UNRESOLVED_PENDING | ❌ → `/setup/unresolved` | ❌ → `/setup/unresolved` | ✅ Allowed |
| UNRESOLVED_DONE | ❌ → `/loading` | ✅ Allowed (NFO phase) | ❌ → `/loading` |
| NFO_SCAN_PENDING | ❌ → `/loading` | ✅ Allowed (NFO phase) | ❌ → `/loading` |
| COMPLETE | ❌ → `/login` | ❌ → `/login` | ❌ → `/login` |

7
Docs/key Normal file
View File

@@ -0,0 +1,7 @@
API key : 299ae8f630a31bda814263c551361448
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

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

@@ -1,174 +0,0 @@
# 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,51 +0,0 @@
API key : 299ae8f630a31bda814263c551361448
/mnt/server/serien/Serien/
{
"name": "Aniworld",
"data_dir": "data",
"scheduler": {
"enabled": true,
"interval_minutes": 60,
"schedule_time": "03:00",
"schedule_days": [
"mon",
"tue",
"wed",
"thu",
"fri",
"sat",
"sun"
],
"auto_download_after_rescan": true,
"folder_scan_enabled": true
},
"logging": {
"level": "INFO",
"file": null,
"max_bytes": null,
"backup_count": 3
},
"backup": {
"enabled": false,
"path": "data/backups",
"keep_days": 30
},
"nfo": {
"tmdb_api_key": "9bc3e547caff878615cbdba2cc421d37",
"auto_create": true,
"update_on_scan": true,
"download_poster": true,
"download_logo": true,
"download_fanart": true,
"image_size": "original"
},
"other": {
"master_password_hash": "$pbkdf2-sha256$29000$HQNASKk1xpgTAgAgJGRMaQ$73TOCCM0UEZONyNXQEPa3SmIoXeG6C1l5mMFDNgYfMQ",
"anime_directory": "/data"
},
"version": "1.0.0"
}

View File

@@ -1,6 +1,6 @@
{
"name": "aniworld-web",
"version": "1.4.10",
"version": "1.4.14",
"description": "Aniworld Anime Download Manager - Web Frontend",
"type": "module",
"scripts": {

View File

@@ -16,6 +16,7 @@ from src.server.models.auth import (
from src.server.models.config import AppConfig
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.progress_service import ProgressType
logger = structlog.get_logger(__name__)
@@ -117,6 +118,10 @@ async def setup_auth(req: SetupRequest):
# Store master password hash in config's other field
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
anime_directory = None
if req.anime_directory:
@@ -175,7 +180,6 @@ async def setup_auth(req: SetupRequest):
# Continue — scheduler failure should not break initialization
# Send completion event
from src.server.services.progress_service import ProgressType
await progress_service.start_progress(
progress_id="initialization_complete",
progress_type=ProgressType.SYSTEM,
@@ -191,7 +195,6 @@ async def setup_auth(req: SetupRequest):
)
except Exception as e:
# Send error event
from src.server.services.progress_service import ProgressType
await progress_service.start_progress(
progress_id="initialization_error",
progress_type=ProgressType.ERROR,
@@ -209,8 +212,9 @@ async def setup_auth(req: SetupRequest):
# Start initialization in background
asyncio.create_task(run_initialization())
# Return redirect to loading page
return {"status": "ok", "redirect": "/loading"}
# Return redirect to loading page with phase=initial
# The loading page will show ONLY series_sync step, then redirect to /setup/unresolved
return {"status": "ok", "redirect": "/loading?phase=initial"}
# Note: Media scan is skipped during setup as it requires
# background_loader service which is only available during
# application lifespan. It will run on first application startup.

View File

@@ -224,17 +224,25 @@ async def resolve_unresolved_folder(
)
class SearchFolderRequest(BaseModel):
"""Request model for searching an unresolved folder with custom query."""
query: Optional[str] = Field(None, description="Custom search query override")
@router.post("/unresolved/{folder_name}/search", response_model=UnresolvedFolderResponse)
async def search_unresolved_folder(
folder_name: str,
request: Optional[SearchFolderRequest] = None,
db=Depends(get_database_session),
) -> UnresolvedFolderResponse:
"""Re-search for a specific unresolved folder to get fresh suggestions.
Performs a new search using the folder's title and caches the results.
Performs a new search using the folder's title or a custom query.
Caches the results for subsequent display.
Args:
folder_name: URL-encoded folder name to search for
request: Optional SearchFolderRequest with custom query override
Returns:
UnresolvedFolderResponse with updated search suggestions
@@ -258,10 +266,13 @@ async def search_unresolved_folder(
detail=f"Folder already resolved: {folder_name}"
)
# Use custom query if provided, otherwise fall back to folder title
search_query = request.query if request and request.query else folder.title
# Perform search
series_app = get_series_app()
try:
results = await series_app.search(folder.title)
results = await series_app.search(search_query)
search_result_json = json.dumps(results) if results else "[]"
except Exception as e:
logger.warning(
@@ -278,7 +289,7 @@ async def search_unresolved_folder(
folder_name=folder.folder_name,
title=folder.title,
year=folder.year,
search_attempts=folder.search_attempts,
search_attempts=folder.search_attempts + 1,
search_suggestions=results,
)
@@ -310,4 +321,103 @@ async def delete_unresolved_folder(
detail=f"Unresolved folder not found: {folder_name}"
)
return {"status": "success", "message": f"Deleted unresolved folder: {folder_name}"}
return {"status": "success", "message": f"Deleted unresolved folder: {folder_name}"}
class DoneResponse(BaseModel):
"""Response model for completing unresolved folders."""
status: str = Field(..., description="Operation status")
message: str = Field(..., description="Human-readable message")
count: int = Field(..., description="Number of folders marked as done")
@router.post("/unresolved/done", response_model=DoneResponse)
async def complete_unresolved_folders(
db=Depends(get_database_session),
) -> DoneResponse:
"""Mark all unresolved folders as handled and complete the unresolved phase.
This endpoint:
1. Marks the unresolved phase as completed in config
2. Returns the count of folders that were handled
After this, /setup/unresolved will redirect to /loading.
Returns:
DoneResponse with status and count of handled folders
"""
from src.server.services.config_service import get_config_service
# Get all unresolved folders
folders = await UnresolvedFolderService.get_all_unresolved(db)
count = len(folders)
# Mark unresolved as completed in config
config_service = get_config_service()
try:
config = config_service.load_config()
if config.other is None:
config.other = {}
config.other['unresolved_completed'] = True
config_service.save_config(config, create_backup=False)
logger.info("Marked unresolved phase as completed")
except Exception as e:
logger.warning("Failed to save unresolved_completed flag: %s", e)
logger.info(
"Completed unresolved phase: %d folders handled",
count
)
return DoneResponse(
status="success",
message=f"Marked {count} folders as handled. Unresolved phase completed.",
count=count,
)
class NfoScanPhaseResponse(BaseModel):
"""Response model for NFO scan phase trigger."""
status: str = Field(..., description="Status of the operation")
message: str = Field(..., description="Human-readable message")
@router.post("/nfo-scan-phase", response_model=NfoScanPhaseResponse)
async def trigger_nfo_scan_phase() -> NfoScanPhaseResponse:
"""Trigger the NFO scan phase.
This endpoint is called by the loading page when accessed with ?phase=nfo.
It starts the NFO scan in the background and returns immediately.
The loading page then connects via WebSocket to receive progress updates.
Returns:
NfoScanPhaseResponse with status and message
"""
import asyncio
from src.server.services.initialization_service import perform_nfo_scan_phase
from src.server.services.progress_service import get_progress_service
progress_service = get_progress_service()
async def run_nfo_scan():
"""Run NFO scan phase with progress updates."""
try:
await perform_nfo_scan_phase(progress_service)
logger.info("NFO scan phase completed via API trigger")
except Exception as e:
logger.error("NFO scan phase failed: %s", e, exc_info=True)
if progress_service:
await progress_service.fail_progress(
progress_id="nfo_scan",
error_message=f"NFO scan failed: {str(e)}",
metadata={"step_id": "nfo_scan", "phase": "nfo"}
)
# Start NFO scan in background
asyncio.create_task(run_nfo_scan())
return NfoScanPhaseResponse(
status="started",
message="NFO scan phase started. Check progress via WebSocket."
)

View File

@@ -105,6 +105,34 @@ class SetupRedirectMiddleware(BaseHTTPMiddleware):
return False
def _is_unresolved_completed(self) -> bool:
"""Check if the unresolved phase has been completed.
Returns:
True if unresolved phase is complete, False otherwise
"""
try:
config_service = get_config_service()
config = config_service.load_config()
other = config.other or {}
return bool(other.get('unresolved_completed', False))
except Exception:
return False
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(
self, request: Request, call_next: Callable
) -> Response:
@@ -118,20 +146,34 @@ class SetupRedirectMiddleware(BaseHTTPMiddleware):
Either a redirect to /setup or the normal response
"""
path = request.url.path
query_params = request.query_params
# Check if trying to access setup or loading page after completion
if path in ("/setup", "/loading"):
if path in ("/setup", "/loading", "/setup/unresolved"):
if not self._needs_setup():
# Setup is complete, check loading status
if path == "/setup":
# Redirect to loading if initialization is in progress
# Otherwise redirect to login
# Redirect to login if setup is already complete
return RedirectResponse(url="/login", status_code=302)
elif path == "/setup/unresolved":
# Check if unresolved phase is already completed
if self._is_unresolved_completed():
# Redirect to loading - unresolved phase already done
return RedirectResponse(url="/loading?phase=nfo", status_code=302)
elif path == "/loading":
# Always allow access to loading page - it handles its own
# redirect flow via WebSocket events (initialization_complete
# event triggers redirect to /setup/unresolved)
pass
# Handle phase query parameter
phase = query_params.get("phase")
if phase == "initial":
# Only redirect if loading has actually completed
# 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)
# Otherwise, allow access to loading page (loading in progress)
elif not phase:
# No phase specified and loading is complete
if self._is_loading_complete():
return RedirectResponse(url="/login", status_code=302)
# phase=nfo is always allowed - it triggers the NFO scan phase
# Skip setup check for exempt paths
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...")
# 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:
await progress_service.update_progress(
progress_id="series_sync",
@@ -383,9 +397,22 @@ async def perform_initial_setup(progress_service=None):
# Mark the initial scan as 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
await _load_series_into_memory(progress_service)
# NOTE: NFO scan is NO longer run here - it runs in a separate phase
# after unresolved folders are completed (via /loading?phase=nfo)
return True
except (OSError, RuntimeError, ValueError) as e:
@@ -427,13 +454,46 @@ async def _is_nfo_scan_configured() -> bool:
async def _execute_nfo_scan(progress_service=None) -> None:
"""Execute the actual NFO scan with TMDB data.
Note: NFO service removed. This function is now a no-op stub.
Args:
progress_service: Unused. Kept to avoid breaking call-sites.
progress_service: Optional ProgressService for emitting updates
"""
logger.info("NFO scan skipped — NFO service removed")
return
from src.server.services.nfo_scan_service import NfoScanService
from src.server.utils.dependencies import get_anime_service
logger.info("Starting NFO scan...")
anime_service = get_anime_service()
nfo_service = NfoScanService()
# Subscribe to NFO events and forward to progress service
async def nfo_event_handler(event_data):
if event_data.get('type') == 'nfo_scan_progress':
data = event_data.get('data', {})
if progress_service:
await progress_service.update_progress(
progress_id="nfo_scan",
current=data.get('current', 0),
total=data.get('total', 100),
message=data.get('message', 'Scanning...'),
key=data.get('key'),
folder=data.get('folder'),
)
elif event_data.get('type') == 'nfo_scan_completed':
stats = event_data.get('statistics', {})
if progress_service:
await progress_service.complete_progress(
progress_id="nfo_scan",
message=f"NFO scan complete: {stats.get('created', 0)} created, {stats.get('updated', 0)} updated",
)
nfo_service.subscribe_to_scan_events(nfo_event_handler)
try:
# Run the scan
nfo_result = await nfo_service.scan_all(anime_service)
logger.info("NFO scan completed: %s", nfo_result)
finally:
nfo_service.unsubscribe_from_scan_events(nfo_event_handler)
async def perform_nfo_scan_if_needed(progress_service=None):
@@ -446,8 +506,8 @@ async def perform_nfo_scan_if_needed(progress_service=None):
from src.server.services.progress_service import ProgressType
await progress_service.start_progress(
progress_id="nfo_scan",
progress_type=ProgressType.SYSTEM,
title="Processing NFO Metadata",
progress_type=ProgressType.SCAN,
title="Scanning NFO Files",
total=100,
message="Checking NFO scan status...",
metadata={"step_id": "nfo_scan"}
@@ -498,6 +558,82 @@ async def perform_nfo_scan_if_needed(progress_service=None):
)
async def perform_nfo_scan_phase(progress_service=None):
"""Perform the NFO scan phase as part of the second loading page phase.
This is called when the loading page is accessed with ?phase=nfo query param.
It runs the NFO scan and emits progress updates via the progress service.
Args:
progress_service: Optional ProgressService for emitting updates
"""
logger.info("Starting NFO scan phase...")
if progress_service:
from src.server.services.progress_service import ProgressType
await progress_service.start_progress(
progress_id="nfo_scan",
progress_type=ProgressType.SCAN,
title="Scanning NFO Files",
total=100,
message="Starting NFO scan...",
metadata={"step_id": "nfo_scan", "phase": "nfo"}
)
# Check if NFO scan was already completed
is_nfo_scan_done = await _check_nfo_scan_status()
# Check if NFO features are configured
if not await _is_nfo_scan_configured():
message = (
"Skipped - TMDB API key not configured"
if not settings.tmdb_api_key
else "Skipped - NFO features disabled"
)
logger.info("NFO scan phase skipped: %s", message)
if progress_service:
await progress_service.complete_progress(
progress_id="nfo_scan",
message=message,
metadata={"step_id": "nfo_scan", "phase": "nfo", "nfo_scan_complete": True}
)
return
# Skip if already completed
if is_nfo_scan_done:
logger.info("Skipping NFO scan phase - already completed on previous run")
if progress_service:
await progress_service.complete_progress(
progress_id="nfo_scan",
message="Already completed",
metadata={"step_id": "nfo_scan", "phase": "nfo", "nfo_scan_complete": True}
)
return
# Execute the NFO scan
try:
await _execute_nfo_scan(progress_service)
await _mark_nfo_scan_completed()
# Send completion event
if progress_service:
await progress_service.complete_progress(
progress_id="nfo_scan",
message="NFO scan completed successfully",
metadata={"step_id": "nfo_scan", "phase": "nfo", "nfo_scan_complete": True}
)
logger.info("NFO scan phase completed successfully")
except Exception as e:
logger.error("Failed to complete NFO scan phase: %s", e, exc_info=True)
if progress_service:
await progress_service.fail_progress(
progress_id="nfo_scan",
error_message=f"NFO scan failed: {str(e)}",
metadata={"step_id": "nfo_scan", "phase": "nfo"}
)
async def _check_media_scan_status() -> bool:
"""Check if initial media scan has been completed.

View File

@@ -323,8 +323,18 @@ class SchedulerService:
async with get_db_session() as db:
settings = await SystemSettingsService.get_or_create(db)
last_scan = settings.last_scan_timestamp
initial_scan_done = settings.initial_scan_completed
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
logger.info("No previous scan recorded — triggering immediate 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
provide `folder` as display metadata only.
"""
import hashlib
import logging
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
@@ -27,10 +27,44 @@ logger = logging.getLogger(__name__)
# Configure templates directory
TEMPLATES_DIR = Path(__file__).parent.parent / "web" / "templates"
STATIC_DIR = Path(__file__).parent.parent / "web" / "static"
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
# Version token for static asset cache-busting; changes on every server start.
STATIC_VERSION: str = str(int(time.time()))
# Cache for static file hashes: {file_path: (mtime, hash)}
_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(
@@ -51,7 +85,7 @@ def get_base_context(
"title": title,
"app_name": "Aniworld Download Manager",
"version": APP_VERSION,
"static_v": STATIC_VERSION,
"static_version": get_static_version,
}

View File

@@ -5,11 +5,11 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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">
<!-- 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>
<body>
@@ -727,22 +727,22 @@
</div>
<!-- Shared Modules (load in dependency order) -->
<script src="/static/js/shared/constants.js?v={{ static_v }}"></script>
<script src="/static/js/shared/auth.js?v={{ static_v }}"></script>
<script src="/static/js/shared/api-client.js?v={{ static_v }}"></script>
<script src="/static/js/shared/theme.js?v={{ static_v }}"></script>
<script src="/static/js/shared/ui-utils.js?v={{ static_v }}"></script>
<script src="/static/js/shared/websocket-client.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_version('js/shared/auth.js') }}"></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_version('js/shared/theme.js') }}"></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_version('js/shared/websocket-client.js') }}"></script>
<!-- External modules -->
<script src="/static/js/localization.js?v={{ static_v }}"></script>
<script src="/static/js/user_preferences.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_version('js/user_preferences.js') }}"></script>
<!-- Index Page Modules -->
<script src="/static/js/index/context-menu.js?v={{ static_v }}"></script>
<script src="/static/js/index/edit-modal.js?v={{ static_v }}"></script>
<script src="/static/js/index/series-manager.js?v={{ static_v }}"></script>
<script src="/static/js/index/selection-manager.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_version('js/index/edit-modal.js') }}"></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_version('js/index/selection-manager.js') }}"></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/nfo-manager.js?v={{ static_v }}"></script>

View File

@@ -5,7 +5,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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">
<style>
.loading-container {
@@ -279,15 +279,83 @@
let ws = null;
const steps = new Map();
let isComplete = false;
// Get phase from URL query parameter
const urlParams = new URLSearchParams(window.location.search);
const currentPhase = urlParams.get('phase') || 'initial';
const stepOrder = [
'series_sync'
'series_sync',
'nfo_scan'
];
const stepTitles = {
'series_sync': 'Syncing Series Database'
'series_sync': 'Syncing Series Database',
'nfo_scan': 'Scanning NFO Files'
};
// State management for setup flow
const SETUP_STATES = {
INITIAL: 'initial',
UNRESOLVED: 'unresolved',
NFO: 'nfo'
};
function setSetupPhase(phase) {
sessionStorage.setItem('setup_phase', phase);
}
function getSetupPhase() {
return sessionStorage.getItem('setup_phase');
}
function clearSetupPhase() {
sessionStorage.removeItem('setup_phase');
}
function validateStateAndRedirect() {
const storedPhase = getSetupPhase();
if (storedPhase && storedPhase !== currentPhase) {
// State mismatch - redirect to correct page based on stored phase
if (storedPhase === SETUP_STATES.INITIAL) {
window.location.href = '/loading?phase=initial';
return false;
} else if (storedPhase === SETUP_STATES.UNRESOLVED) {
window.location.href = '/setup/unresolved';
return false;
} else if (storedPhase === SETUP_STATES.NFO) {
window.location.href = '/loading?phase=nfo';
return false;
}
}
return true;
}
// For initial phase, we only show series_sync step
// For nfo phase, we only show nfo_scan step
function getStepsForPhase(phase) {
if (phase === 'nfo') {
return ['nfo_scan'];
}
return ['series_sync'];
}
function triggerNfoScanPhase() {
// Call API to trigger NFO scan phase
fetch('/api/setup/nfo-scan-phase', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
}).then(res => {
if (!res.ok) {
console.error('Failed to trigger NFO scan phase');
}
}).catch(err => {
console.error('Error triggering NFO scan phase:', err);
});
}
function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/connect`;
@@ -298,13 +366,24 @@
console.log('WebSocket connected');
updateConnectionStatus(true);
// Subscribe to system room for progress updates
ws.send(JSON.stringify({
action: 'join',
data: {
room: 'system'
}
}));
// Subscribe to rooms based on phase
if (currentPhase === 'nfo') {
// For nfo phase, only subscribe to scan room
ws.send(JSON.stringify({
action: 'join',
data: {
room: 'scan'
}
}));
} else {
// For initial phase (series_sync), subscribe to system room
ws.send(JSON.stringify({
action: 'join',
data: {
room: 'system'
}
}));
}
};
ws.onmessage = (event) => {
@@ -349,6 +428,18 @@
const data = message.data || message;
const { type, status, title, message: msg, percent, current, total, metadata } = data;
// For NFO phase, all events go to handleNfoScanUpdate
if (currentPhase === 'nfo') {
handleNfoScanUpdate(data);
return;
}
// For initial phase (series_sync), skip NFO scan events
if (type === 'nfo_scan_started' || type === 'nfo_scan_progress' || type === 'nfo_scan_completed') {
// Ignore NFO scan events during initial phase
return;
}
// Determine step ID based on type and metadata
let stepId = metadata?.step_id || type;
@@ -359,9 +450,11 @@
updateStep(stepId, status, msg, percent, current, total);
// Check for completion
if (metadata?.initialization_complete) {
showCompletion();
// Check for completion of series_sync
// 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
handleSeriesSyncComplete();
}
// Handle errors
@@ -369,6 +462,104 @@
showError(msg || 'An error occurred during initialization');
}
}
function handleSeriesSyncComplete() {
isComplete = true;
document.getElementById('connectionStatus').style.display = 'none';
if (ws) {
ws.close();
}
// Clear the initial phase state
clearSetupPhase();
// For initial phase, series_sync completion always leads to /setup/unresolved
// The unresolved page will handle checking if there are folders or redirect to nfo phase
window.location.href = '/setup/unresolved';
}
function handleNfoScanUpdate(data) {
const stepId = 'nfo_scan';
if (!steps.has(stepId)) {
createStep(stepId, stepTitles[stepId] || 'Scanning NFO Files');
}
const stepEl = steps.get(stepId);
if (!stepEl) return;
const iconEl = stepEl.querySelector('.step-icon');
const statusEl = stepEl.querySelector('.step-status');
const messageEl = stepEl.querySelector('.step-message');
const progressEl = stepEl.querySelector('.step-progress');
const progressFillEl = stepEl.querySelector('.progress-bar-fill');
const progressTextEl = stepEl.querySelector('.progress-text');
const nfoData = data.data || data;
const { status, message, current, total, key, folder, metadata } = nfoData;
// Update status
stepEl.className = 'progress-step';
if (status === 'started') {
stepEl.classList.add('active');
iconEl.className = 'fas fa-circle-notch fa-spin step-icon loading';
statusEl.textContent = 'Starting...';
} else if (status === 'in_progress') {
stepEl.classList.add('active');
iconEl.className = 'fas fa-circle-notch fa-spin step-icon loading';
statusEl.textContent = 'In Progress...';
} else if (status === 'completed') {
stepEl.classList.add('completed');
iconEl.className = 'fas fa-check-circle step-icon completed';
statusEl.textContent = 'Complete';
} else if (status === 'failed') {
stepEl.classList.add('error');
iconEl.className = 'fas fa-exclamation-circle step-icon error';
statusEl.textContent = 'Failed';
}
// Update message - show current folder being processed
if (message) {
messageEl.textContent = message;
messageEl.style.display = 'block';
} else if (key && folder) {
messageEl.textContent = `Processing: ${folder}`;
messageEl.style.display = 'block';
}
// Update progress bar
if (current > 0 && total > 0) {
const actualPercent = (current / total) * 100;
progressEl.style.display = 'block';
progressFillEl.style.width = `${actualPercent}%`;
progressTextEl.textContent = `${current}/${total} series`;
} else if (percent > 0) {
progressEl.style.display = 'block';
progressFillEl.style.width = `${percent}%`;
progressTextEl.textContent = `${Math.round(percent)}%`;
}
// Check for completion - handle based on phase
if (data.type === 'nfo_scan_completed' || metadata?.nfo_scan_complete) {
handleNfoPhaseComplete();
}
}
function handleNfoPhaseComplete() {
isComplete = true;
document.getElementById('connectionStatus').style.display = 'none';
if (ws) {
ws.close();
}
// Clear the NFO phase state
clearSetupPhase();
// For NFO phase, completion always goes to login
window.location.href = '/login';
}
function createStep(stepId, title) {
const container = document.getElementById('progressContainer');
@@ -475,8 +666,8 @@
}
async function checkUnresolvedAndProceed() {
// Fetch unresolved folders and only redirect if there are any
// Otherwise go directly to login
// Always check for unresolved folders first
// After setup -> loading, always go through unresolved if there are any
try {
const token = localStorage.getItem('auth_token');
const res = await fetch('/api/setup/unresolved', {
@@ -493,7 +684,7 @@
} catch (err) {
console.error('Failed to check unresolved folders:', err);
}
// No unresolved folders or error - go to login
// No unresolved folders - go to login
window.location.href = '/login';
}
@@ -510,6 +701,27 @@
// Start WebSocket connection when page loads
document.addEventListener('DOMContentLoaded', () => {
// Validate state and redirect if there's a mismatch
if (!validateStateAndRedirect()) {
return; // Redirect in progress
}
// Set up the correct state for this phase
if (currentPhase === 'nfo') {
setSetupPhase(SETUP_STATES.NFO);
} else {
setSetupPhase(SETUP_STATES.INITIAL);
}
// Initialize the correct steps based on phase
const stepsForPhase = getStepsForPhase(currentPhase);
if (stepsForPhase.length === 1 && stepsForPhase[0] === 'nfo_scan') {
// For nfo phase, create the step and trigger the scan immediately
createStep('nfo_scan', stepTitles['nfo_scan']);
// Trigger NFO scan phase via API
triggerNfoScanPhase();
}
connectWebSocket();
});
</script>

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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">
<style>
.login-container {

View File

@@ -5,7 +5,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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">
</head>
@@ -234,19 +234,19 @@
</div>
<!-- Shared Modules (load in dependency order) -->
<script src="/static/js/shared/constants.js?v={{ static_v }}"></script>
<script src="/static/js/shared/auth.js?v={{ static_v }}"></script>
<script src="/static/js/shared/api-client.js?v={{ static_v }}"></script>
<script src="/static/js/shared/theme.js?v={{ static_v }}"></script>
<script src="/static/js/shared/ui-utils.js?v={{ static_v }}"></script>
<script src="/static/js/shared/websocket-client.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_version('js/shared/auth.js') }}"></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_version('js/shared/theme.js') }}"></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_version('js/shared/websocket-client.js') }}"></script>
<!-- Queue Page Modules -->
<script src="/static/js/queue/queue-api.js?v={{ static_v }}"></script>
<script src="/static/js/queue/queue-renderer.js?v={{ static_v }}"></script>
<script src="/static/js/queue/progress-handler.js?v={{ static_v }}"></script>
<script src="/static/js/queue/queue-socket-handler.js?v={{ static_v }}"></script>
<script src="/static/js/queue/queue-init.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_version('js/queue/queue-renderer.js') }}"></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_version('js/queue/queue-socket-handler.js') }}"></script>
<script src="/static/js/queue/queue-init.js?v={{ static_version('js/queue/queue-init.js') }}"></script>
</body>
</html>

View File

@@ -5,7 +5,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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">
<style>
.setup-container {
@@ -790,37 +790,14 @@
const data = await response.json();
if (response.ok && data.status === 'ok') {
// Redirect to loading page if provided, otherwise check for unresolved folders
if (data.redirect) {
showMessage('Setup saved! Initializing your anime library...', 'success');
setTimeout(() => {
window.location.href = data.redirect;
}, 500);
} else {
// Check for unresolved folders before redirecting
showMessage('Setup completed successfully! Checking for unresolved series...', 'success');
setTimeout(async () => {
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);
}
// Always redirect to loading page with initial phase
// The loading page will handle unresolved folder check
showMessage('Setup saved! Initializing your anime library...', 'success');
setTimeout(() => {
// Set session storage state before redirecting
sessionStorage.setItem('setup_phase', 'initial');
window.location.href = '/loading?phase=initial';
}, 500);
} else {
const errorMessage = data.detail || data.message || 'Setup failed';
showMessage(errorMessage, 'error');

View File

@@ -5,8 +5,8 @@
<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">
<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.6.0/css/all.min.css" rel="stylesheet">
<style>
.unresolved-container {
min-height: 100vh;
@@ -198,15 +198,27 @@
font-size: 0.8rem;
}
.suggestion-link {
.suggestion-btn {
background: none;
border: none;
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;
}
.suggestion-btn .key-label {
color: var(--color-text-secondary);
font-size: 0.8rem;
margin-left: 0.3rem;
}
.no-suggestions {
display: flex;
align-items: center;
@@ -238,6 +250,63 @@
opacity: 0.7;
}
.search-again-row {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
align-items: center;
}
.search-again-input {
flex: 1;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--border-radius-md);
font-size: 0.85rem;
background: var(--color-surface);
color: var(--color-text);
}
.search-again-input:focus {
outline: none;
border-color: var(--color-accent);
}
.search-again-row .search-again-btn {
margin-top: 0;
}
.search-again-btn.searching {
pointer-events: none;
opacity: 0.7;
}
.search-again-row {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
align-items: center;
}
.search-again-input {
flex: 1;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--border-radius-md);
font-size: 0.85rem;
background: var(--color-surface);
color: var(--color-text);
}
.search-again-input:focus {
outline: none;
border-color: var(--color-accent);
}
.search-again-row .search-again-btn {
margin-top: 0;
}
/* Empty state */
.empty-state {
text-align: center;
@@ -358,6 +427,36 @@
text-decoration: underline;
}
.done-btn {
background: var(--color-success);
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: var(--border-radius-md);
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all var(--transition-duration);
display: none;
}
.done-btn:hover:not(:disabled) {
background: #27ae60;
transform: translateY(-2px);
}
.done-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.header-actions {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 1.5rem;
}
@media (max-width: 600px) {
.folder-input-row {
flex-direction: column;
@@ -382,6 +481,11 @@
</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 class="header-actions">
<button class="done-btn" id="done-btn" onclick="handleDone()">
<i class="fas fa-check"></i> Done
</button>
</div>
</div>
<div id="loading-state" class="loading-state">
@@ -471,12 +575,17 @@
return res.json();
}
async function reSearchFolder(folderName) {
async function reSearchFolder(folderName, customQuery) {
const token = localStorage.getItem('auth_token');
const encodedName = encodeURIComponent(folderName);
const body = customQuery ? JSON.stringify({ query: customQuery }) : '{}';
const res = await fetch(`/api/setup/unresolved/${encodedName}/search`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` }
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: body
});
return res.json();
}
@@ -494,19 +603,29 @@
// Render functions
function renderFolderItem(folder) {
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">
<i class="fas fa-link"></i>
<a href="${s.link}" class="suggestion-link" target="_blank">${s.title}</a>
<i class="fas fa-hand-pointer"></i>
<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>
`).join('')
`;
}).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>`
: '';
// Always show search row so user can search multiple times
const searchAgainBtn = `<div class="search-again-row">
<input type="text" class="search-again-input"
placeholder="Custom search..."
value="${folder.title || ''}"
data-folder="${folder.folder_name}">
<button class="search-again-btn" data-folder="${folder.folder_name}">
<i class="fas fa-search"></i> Search Again
</button>
</div>`;
return `
<div class="folder-item" data-folder="${folder.folder_name}">
@@ -550,7 +669,11 @@
listEl.style.display = 'none';
emptyEl.style.display = 'block';
document.getElementById('skip-link').style.display = 'block';
setTimeout(() => { window.location.href = '/loading'; }, 2000);
// No unresolved folders - redirect to NFO scan phase
setTimeout(() => {
sessionStorage.setItem('setup_phase', 'nfo');
window.location.href = '/loading?phase=nfo';
}, 2000);
} else {
listEl.style.display = 'flex';
emptyEl.style.display = 'none';
@@ -559,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() {
// Input enable/disable resolve button
document.querySelectorAll('.folder-input').forEach(input => {
@@ -650,53 +832,150 @@
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}"]`);
const searchInput = item.querySelector('.search-again-input');
const customQuery = searchInput ? searchInput.value.trim() : null;
btn.classList.add('searching');
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Searching...';
try {
const result = await reSearchFolder(folder);
const result = await reSearchFolder(folder, customQuery);
// 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>
<i class="fas fa-hand-pointer"></i>
<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>
`).join('');
} else {
suggestionsEl.innerHTML = '<div class="no-suggestions"><i class="fas fa-info-circle"></i> No suggestions found</div>';
}
btn.remove();
// Keep search row visible for additional searches
btn.classList.remove('searching');
btn.innerHTML = '<i class="fas fa-search"></i> Search Again';
attachSuggestionLinkEvents();
} catch (err) {
showToast('Search failed', 'error');
btn.classList.remove('searching');
btn.innerHTML = '<i class="fas fa-search"></i> Search Again';
} finally {
btn.classList.remove('searching');
}
});
});
// Suggestion link click - populate input and resolve
attachSuggestionLinkEvents();
}
function checkEmptyList() {
const listEl = document.getElementById('folder-list');
const emptyEl = document.getElementById('empty-state');
const skipLink = document.getElementById('skip-link');
const doneBtn = document.getElementById('done-btn');
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);
// All folders resolved - redirect to NFO scan phase
setTimeout(() => {
sessionStorage.setItem('setup_phase', 'nfo');
window.location.href = '/loading?phase=nfo';
}, 2000);
}
}
async function completeUnresolved() {
const token = localStorage.getItem('auth_token');
const res = await fetch('/api/setup/unresolved/done', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
return res.json();
}
async function handleDone() {
const doneBtn = document.getElementById('done-btn');
doneBtn.disabled = true;
doneBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Processing...';
try {
const result = await completeUnresolved();
if (result.status === 'success') {
showToast(result.message, 'success');
// Clear unresolved state and set NFO phase before redirecting
clearSetupPhase();
setTimeout(() => {
sessionStorage.setItem('setup_phase', 'nfo');
window.location.href = '/loading?phase=nfo';
}, 1000);
} else {
showToast(result.message || 'Failed to complete', 'error');
doneBtn.disabled = false;
doneBtn.innerHTML = '<i class="fas fa-check"></i> Done';
}
} catch (err) {
showToast('Server error. Please try again.', 'error');
doneBtn.disabled = false;
doneBtn.innerHTML = '<i class="fas fa-check"></i> Done';
}
}
// Show Done button when there are folders
function showDoneButton() {
const doneBtn = document.getElementById('done-btn');
doneBtn.style.display = 'inline-flex';
}
// State management for setup flow
function setSetupPhase(phase) {
sessionStorage.setItem('setup_phase', phase);
}
function clearSetupPhase() {
sessionStorage.removeItem('setup_phase');
}
function validateStateAndRedirect() {
const storedPhase = sessionStorage.getItem('setup_phase');
// If we have a stored phase that isn't 'unresolved', redirect appropriately
if (storedPhase && storedPhase !== 'unresolved') {
if (storedPhase === 'initial') {
window.location.href = '/loading?phase=initial';
return false;
} else if (storedPhase === 'nfo') {
window.location.href = '/loading?phase=nfo';
return false;
}
}
return true;
}
// Init
(async function init() {
// Validate state and redirect if there's a mismatch
if (!validateStateAndRedirect()) {
return; // Redirect in progress
}
// Set the unresolved phase state
setSetupPhase('unresolved');
const folders = await fetchUnresolved();
if (folders !== null) {
renderFolders(folders);
if (folders.length > 0) {
showDoneButton();
}
}
})();
</script>

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
async def test_sync_anime_folders_without_progress(self):
"""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:
mock_settings.anime_directory = "/path/to/anime"
result = await _sync_anime_folders()
assert result == 42
@@ -172,8 +175,11 @@ class TestSyncAnimeFolders:
"""Test syncing anime folders with progress updates."""
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:
mock_settings.anime_directory = "/path/to/anime"
result = await _sync_anime_folders(progress_service=mock_progress)
assert result == 10
@@ -320,6 +326,8 @@ class TestPerformInitialSetup:
patch('src.server.services.initialization_service._mark_initial_scan_completed',
new_callable=AsyncMock), \
patch('src.server.services.initialization_service._load_series_into_memory',
new_callable=AsyncMock), \
patch('src.server.services.initialization_service.perform_nfo_scan_if_needed',
new_callable=AsyncMock):
result = await perform_initial_setup()
@@ -339,6 +347,8 @@ class TestPerformInitialSetup:
patch('src.server.services.initialization_service._mark_initial_scan_completed',
new_callable=AsyncMock), \
patch('src.server.services.initialization_service._load_series_into_memory',
new_callable=AsyncMock), \
patch('src.server.services.initialization_service.perform_nfo_scan_if_needed',
new_callable=AsyncMock):
result = await perform_initial_setup(progress_service=mock_progress)