Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f61ded92a | |||
| d6082b5cf6 | |||
| e76cd3a708 | |||
| 08f7f7453c | |||
| 023ddd182f | |||
| 288b03cbb4 | |||
| f73cc530c3 | |||
| 4b835a2439 | |||
| 7c1dccfe64 | |||
| e0be00dce6 | |||
| 14f7b2f28a | |||
| de250bdd37 | |||
| b800158648 | |||
| 4e0c66ea9e | |||
| 07c311c1cd | |||
| cf00c9f7c5 | |||
| f3042206a8 | |||
| 657e7f9bf5 | |||
| fd3ec5df83 | |||
| 275aeb4544 | |||
| be7b210959 | |||
| 486c5440f2 | |||
| 4076b9dd43 | |||
| df93e8a81f | |||
| 576d9f7a7b | |||
| af93daeddc |
@@ -1 +1 @@
|
||||
v1.4.9
|
||||
v1.4.15
|
||||
|
||||
234
Docs/NAVIGATION.md
Normal file
234
Docs/NAVIGATION.md
Normal 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
7
Docs/key
Normal file
@@ -0,0 +1,7 @@
|
||||
API key : 299ae8f630a31bda814263c551361448
|
||||
9bc3e547caff878615cbdba2cc421d37
|
||||
|
||||
/setup
|
||||
|
||||
|
||||
SeriesApp initialized for directory:
|
||||
21
Makefile
Normal file
21
Makefile
Normal 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!"}'
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
51
docs/key
51
docs/key
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aniworld-web",
|
||||
"version": "1.4.9",
|
||||
"version": "1.4.15",
|
||||
"description": "Aniworld Anime Download Manager - Web Frontend",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -279,30 +279,15 @@ async def update_directory(
|
||||
|
||||
config_service.save_config(app_config)
|
||||
|
||||
# Sync series from data files to database
|
||||
sync_count = 0
|
||||
try:
|
||||
import structlog
|
||||
|
||||
from src.server.services.anime_service import sync_legacy_series_to_db
|
||||
logger = structlog.get_logger(__name__)
|
||||
sync_count = await sync_legacy_series_to_db(directory, logger)
|
||||
logger.info(
|
||||
"Directory updated: synced series from data files",
|
||||
directory=directory,
|
||||
count=sync_count
|
||||
)
|
||||
except Exception as e:
|
||||
# Log but don't fail the directory update if sync fails
|
||||
import structlog
|
||||
structlog.get_logger(__name__).warning(
|
||||
"Failed to sync series after directory update",
|
||||
error=str(e)
|
||||
)
|
||||
# Series are now loaded directly from database, no sync needed
|
||||
logger.info(
|
||||
"Directory updated successfully",
|
||||
directory=directory
|
||||
)
|
||||
|
||||
response: Dict[str, Any] = {
|
||||
"message": "Anime directory updated successfully",
|
||||
"synced_series": sync_count
|
||||
"synced_series": 0
|
||||
}
|
||||
|
||||
return response
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
@@ -37,6 +37,7 @@ class SetupRedirectMiddleware(BaseHTTPMiddleware):
|
||||
"/login", # Login page (needs to be accessible after setup)
|
||||
"/queue", # Queue page (for initial load)
|
||||
"/api/auth/", # All auth endpoints (setup, login, logout, register)
|
||||
"/api/setup/", # Setup API (unresolved folders, etc.)
|
||||
"/ws/connect", # WebSocket connection (needed for loading page)
|
||||
"/api/queue/", # Queue API endpoints
|
||||
"/api/downloads/", # Download API endpoints
|
||||
@@ -104,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:
|
||||
@@ -117,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):
|
||||
|
||||
@@ -422,3 +422,32 @@ class TMDBClient:
|
||||
if expired_keys:
|
||||
logger.debug("Removed %d expired negative cache entries", len(expired_keys))
|
||||
return len(expired_keys)
|
||||
|
||||
|
||||
def get_tmdb_client() -> TMDBClient:
|
||||
"""Factory function to create a TMDBClient with settings configuration.
|
||||
|
||||
Returns:
|
||||
TMDBClient instance configured with settings.tmdb_api_key
|
||||
|
||||
Raises:
|
||||
ValueError: If TMDB API key is not configured
|
||||
"""
|
||||
from src.config.settings import settings
|
||||
|
||||
if not settings.tmdb_api_key:
|
||||
raise ValueError("TMDB API key is not configured")
|
||||
|
||||
return TMDBClient(api_key=settings.tmdb_api_key)
|
||||
|
||||
|
||||
def get_tmdb_image_base_url(tmdb_id: int) -> str:
|
||||
"""Get the base URL for TMDB images.
|
||||
|
||||
Args:
|
||||
tmdb_id: TMDB show ID (used for account-specific URLs)
|
||||
|
||||
Returns:
|
||||
Base URL string for TMDB images
|
||||
"""
|
||||
return "https://image.tmdb.org/t/p/"
|
||||
|
||||
@@ -1618,139 +1618,3 @@ class AnimeService:
|
||||
def get_anime_service(series_app: SeriesApp) -> AnimeService:
|
||||
"""Factory used for creating AnimeService with a SeriesApp instance."""
|
||||
return AnimeService(series_app)
|
||||
|
||||
|
||||
async def sync_legacy_series_to_db(
|
||||
anime_directory: str,
|
||||
log_instance=None # pylint: disable=unused-argument
|
||||
) -> int:
|
||||
"""
|
||||
One-time legacy sync: import any series from 'data' files
|
||||
not already in the database.
|
||||
|
||||
Deprecated: Series are now loaded directly from the database.
|
||||
This function remains for backwards compatibility with legacy
|
||||
file-based data during migration.
|
||||
|
||||
Args:
|
||||
anime_directory: Path to the anime directory with data files
|
||||
log_instance: Optional logger instance (unused, kept for API
|
||||
compatibility). This function always uses structlog internally.
|
||||
|
||||
Returns:
|
||||
Number of new series added to the database
|
||||
"""
|
||||
# Always use structlog for structured logging with keyword arguments
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
log.warning(
|
||||
"sync_legacy_series_to_db is deprecated. "
|
||||
"Series are now loaded directly from database."
|
||||
)
|
||||
|
||||
try:
|
||||
from src.server.database.connection import get_db_session
|
||||
from src.server.database.service import AnimeSeriesService, EpisodeService
|
||||
|
||||
log.info(
|
||||
"Starting data file to database sync",
|
||||
directory=anime_directory
|
||||
)
|
||||
|
||||
# Get all series from data files using SeriesApp
|
||||
series_app = SeriesApp(anime_directory)
|
||||
all_series = await asyncio.to_thread(
|
||||
series_app.get_all_series_from_data_files
|
||||
)
|
||||
|
||||
if not all_series:
|
||||
log.info("No series found in data files to sync")
|
||||
return 0
|
||||
|
||||
log.info(
|
||||
"Found series in data files, syncing to database",
|
||||
count=len(all_series)
|
||||
)
|
||||
|
||||
async with get_db_session() as db:
|
||||
added_count = 0
|
||||
skipped_count = 0
|
||||
for serie in all_series:
|
||||
# Handle series with empty name - use folder as fallback
|
||||
if not serie.name or not serie.name.strip():
|
||||
if serie.folder and serie.folder.strip():
|
||||
serie.name = serie.folder.strip()
|
||||
log.debug(
|
||||
"Using folder as name fallback",
|
||||
key=serie.key,
|
||||
folder=serie.folder
|
||||
)
|
||||
else:
|
||||
log.warning(
|
||||
"Skipping series with empty name and folder",
|
||||
key=serie.key
|
||||
)
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
# Check if series already exists in DB
|
||||
existing = await AnimeSeriesService.get_by_key(db, serie.key)
|
||||
if existing:
|
||||
log.debug(
|
||||
"Series already exists in database",
|
||||
name=serie.name,
|
||||
key=serie.key
|
||||
)
|
||||
continue
|
||||
|
||||
# Create new series in database
|
||||
anime_series = await AnimeSeriesService.create(
|
||||
db=db,
|
||||
key=serie.key,
|
||||
name=serie.name,
|
||||
site=serie.site,
|
||||
folder=serie.folder,
|
||||
year=serie.year if hasattr(serie, 'year') else None,
|
||||
)
|
||||
|
||||
# Create Episode records for each episode in episodeDict
|
||||
if serie.episodeDict:
|
||||
for season, episode_numbers in serie.episodeDict.items():
|
||||
for episode_number in episode_numbers:
|
||||
await EpisodeService.create(
|
||||
db=db,
|
||||
series_id=anime_series.id,
|
||||
season=season,
|
||||
episode_number=episode_number,
|
||||
)
|
||||
|
||||
added_count += 1
|
||||
log.debug(
|
||||
"Added series to database",
|
||||
name=serie.name,
|
||||
key=serie.key
|
||||
)
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
log.warning(
|
||||
"Failed to add series to database",
|
||||
key=serie.key,
|
||||
name=serie.name,
|
||||
error=str(e)
|
||||
)
|
||||
skipped_count += 1
|
||||
|
||||
log.info(
|
||||
"Data file sync complete",
|
||||
added=added_count,
|
||||
skipped=len(all_series) - added_count
|
||||
)
|
||||
return added_count
|
||||
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
log.warning(
|
||||
"Failed to sync series to database",
|
||||
error=str(e),
|
||||
exc_info=True
|
||||
)
|
||||
return 0
|
||||
|
||||
@@ -9,7 +9,6 @@ import structlog
|
||||
|
||||
from src.config.settings import settings
|
||||
from src.server.database.service import AnimeSeriesService
|
||||
from src.server.services.anime_service import sync_legacy_series_to_db
|
||||
from src.server.services.setup_service import SetupService
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -215,6 +214,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",
|
||||
@@ -223,14 +236,15 @@ async def _sync_anime_folders(progress_service=None) -> int:
|
||||
metadata={"step_id": "series_sync"}
|
||||
)
|
||||
|
||||
sync_count = await sync_legacy_series_to_db(settings.anime_directory)
|
||||
logger.info("Data file sync complete. Added %d series.", sync_count)
|
||||
# Legacy sync removed - series are loaded directly from database via _load_series_into_memory
|
||||
sync_count = 0
|
||||
logger.info("Data file sync skipped - series loaded directly from database")
|
||||
|
||||
if progress_service:
|
||||
await progress_service.update_progress(
|
||||
progress_id="series_sync",
|
||||
current=75,
|
||||
message=f"Synced {sync_count} series from data files",
|
||||
message=f"Series loaded directly from database",
|
||||
metadata={"step_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,41 @@ 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'),
|
||||
)
|
||||
# Note: nfo_scan_completed event is NOT handled here because
|
||||
# perform_nfo_scan_phase handles completion after _execute_nfo_scan returns
|
||||
|
||||
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 +501,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"}
|
||||
@@ -486,16 +541,111 @@ async def perform_nfo_scan_if_needed(progress_service=None):
|
||||
|
||||
# Execute the NFO scan
|
||||
try:
|
||||
# Ensure any newly created series are loaded from DB into SeriesApp memory
|
||||
await _load_series_into_memory(progress_service=None)
|
||||
await _execute_nfo_scan(progress_service)
|
||||
await _mark_nfo_scan_completed()
|
||||
except Exception as e:
|
||||
logger.error("Failed to complete NFO scan: %s", e, exc_info=True)
|
||||
if progress_service:
|
||||
await progress_service.fail_progress(
|
||||
try:
|
||||
await progress_service.fail_progress(
|
||||
progress_id="nfo_scan",
|
||||
error_message=f"NFO scan failed: {str(e)}",
|
||||
metadata={"step_id": "nfo_scan"}
|
||||
)
|
||||
except Exception as fail_err:
|
||||
logger.warning(
|
||||
"Could not fail progress 'nfo_scan': %s",
|
||||
fail_err,
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
|
||||
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",
|
||||
error_message=f"NFO scan failed: {str(e)}",
|
||||
metadata={"step_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:
|
||||
# Ensure any newly created series (e.g., from resolving unresolved folders)
|
||||
# are loaded from DB into SeriesApp memory before scanning
|
||||
await _load_series_into_memory(progress_service=None)
|
||||
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:
|
||||
try:
|
||||
await progress_service.fail_progress(
|
||||
progress_id="nfo_scan",
|
||||
error_message=f"NFO scan failed: {str(e)}",
|
||||
metadata={"step_id": "nfo_scan", "phase": "nfo"}
|
||||
)
|
||||
except Exception as fail_err:
|
||||
logger.warning(
|
||||
"Could not fail progress 'nfo_scan': %s",
|
||||
fail_err,
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
|
||||
async def _check_media_scan_status() -> bool:
|
||||
|
||||
@@ -326,6 +326,22 @@ class NfoScanService:
|
||||
|
||||
nfo_exists = os.path.isfile(nfo_path)
|
||||
|
||||
# If tmdb_id is missing, try to look it up by series name
|
||||
if not series_data.get("tmdb_id"):
|
||||
logger.debug("No tmdb_id for %s — attempting TMDB lookup", key)
|
||||
name = series_data.get("name", "")
|
||||
found_tmdb_id = await self._lookup_tmdb_id_by_name(name)
|
||||
if found_tmdb_id:
|
||||
series_data["tmdb_id"] = found_tmdb_id
|
||||
await self._save_tmdb_id(key, found_tmdb_id)
|
||||
logger.info("Found and saved tmdb_id %s for %s", found_tmdb_id, key)
|
||||
else:
|
||||
logger.warning(
|
||||
"Could not resolve tmdb_id for %s (%s)",
|
||||
key,
|
||||
name,
|
||||
)
|
||||
|
||||
if not nfo_exists:
|
||||
# Create new NFO
|
||||
logger.info("Creating NFO for series: %s (%s)", key, folder)
|
||||
@@ -526,6 +542,53 @@ class NfoScanService:
|
||||
logger.info("Regenerated NFO for %s", key)
|
||||
return True
|
||||
|
||||
async def _save_tmdb_id(self, key: str, tmdb_id: int) -> None:
|
||||
"""Save tmdb_id to the database for a series.
|
||||
|
||||
Args:
|
||||
key: Series key (primary identifier)
|
||||
tmdb_id: TMDB series ID to save
|
||||
"""
|
||||
try:
|
||||
from src.server.database.connection import get_db_session
|
||||
from src.server.database.service import AnimeSeriesService
|
||||
|
||||
async with get_db_session() as db:
|
||||
series = await AnimeSeriesService.get_by_key(db, key)
|
||||
if series:
|
||||
series.tmdb_id = tmdb_id
|
||||
await db.flush()
|
||||
logger.debug("Saved tmdb_id %s for series: %s", tmdb_id, key)
|
||||
else:
|
||||
logger.warning("Series not found for tmdb_id save: %s", key)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to save tmdb_id for %s: %s", key, exc)
|
||||
|
||||
async def _lookup_tmdb_id_by_name(self, name: str) -> Optional[int]:
|
||||
"""Look up a TMDB series ID by series name.
|
||||
|
||||
Args:
|
||||
name: Series name to search for
|
||||
|
||||
Returns:
|
||||
TMDB series ID or None if not found.
|
||||
"""
|
||||
if not name:
|
||||
return None
|
||||
|
||||
try:
|
||||
from src.server.nfo.tmdb_client import get_tmdb_client
|
||||
|
||||
client = get_tmdb_client()
|
||||
results = await client.search_tv_show(name)
|
||||
if results and results.get("results"):
|
||||
first_result = results["results"][0]
|
||||
return first_result.get("id")
|
||||
return None
|
||||
except Exception as exc:
|
||||
logger.warning("TMDB lookup failed for %s: %s", name, exc)
|
||||
return None
|
||||
|
||||
async def _fetch_tmdb_data(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Fetch series metadata from TMDB API.
|
||||
|
||||
@@ -539,7 +602,7 @@ class NfoScanService:
|
||||
from src.server.nfo.tmdb_client import get_tmdb_client
|
||||
|
||||
client = get_tmdb_client()
|
||||
data = await client.get_series_details(tmdb_id)
|
||||
data = await client.get_tv_show_details(tmdb_id)
|
||||
return data
|
||||
except Exception as exc:
|
||||
logger.warning("TMDB fetch failed for TMDB ID %s: %s", tmdb_id, exc)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
|
||||
@@ -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">
|
||||
@@ -443,15 +547,13 @@
|
||||
|
||||
// API client helpers
|
||||
async function fetchUnresolved() {
|
||||
// Note: /api/setup/unresolved does not require auth
|
||||
// It's accessible during the initial setup flow
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (!token) {
|
||||
window.location.href = '/login';
|
||||
return null;
|
||||
}
|
||||
const res = await fetch('/api/setup/unresolved', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
|
||||
const res = await fetch('/api/setup/unresolved', { headers });
|
||||
if (res.status === 401) {
|
||||
// Redirect to login only if we had a token but it expired
|
||||
localStorage.removeItem('auth_token');
|
||||
window.location.href = '/login';
|
||||
return null;
|
||||
@@ -473,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();
|
||||
}
|
||||
@@ -496,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}">
|
||||
@@ -552,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';
|
||||
@@ -561,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 => {
|
||||
@@ -652,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>
|
||||
|
||||
290
tests/api/test_navigation_paths.py
Normal file
290
tests/api/test_navigation_paths.py
Normal 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"])
|
||||
@@ -110,81 +110,6 @@ class TestGetAllSeriesFromDataFiles:
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
class TestSyncSeriesToDatabase:
|
||||
"""Test sync_legacy_series_to_db function from anime_service."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_with_empty_directory(self):
|
||||
"""Test sync with empty anime directory."""
|
||||
from src.server.services.anime_service import sync_legacy_series_to_db
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
with patch('src.server.SeriesApp.Loaders'), \
|
||||
patch('src.server.SeriesApp.SerieScanner'):
|
||||
count = await sync_legacy_series_to_db(tmp_dir)
|
||||
|
||||
assert count == 0
|
||||
# Function should complete successfully with no series
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_adds_new_series_to_database(self):
|
||||
"""Test that sync adds new series to database.
|
||||
|
||||
This is a more realistic test that verifies series data is loaded
|
||||
from files and the sync function attempts to add them to the DB.
|
||||
The actual DB interaction is tested in test_add_to_db_creates_record.
|
||||
"""
|
||||
from src.server.services.anime_service import sync_legacy_series_to_db
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
# Create test data files
|
||||
_create_test_data_file(
|
||||
tmp_dir,
|
||||
folder="Sync Test Anime",
|
||||
key="sync-test-anime",
|
||||
name="Sync Test Anime",
|
||||
episodes={1: [1, 2]}
|
||||
)
|
||||
|
||||
# First verify that we can load the series from files
|
||||
with patch('src.server.SeriesApp.Loaders'), \
|
||||
patch('src.server.SeriesApp.SerieScanner'):
|
||||
app = SeriesApp(tmp_dir)
|
||||
series = app.get_all_series_from_data_files()
|
||||
assert len(series) == 1
|
||||
assert series[0].key == "sync-test-anime"
|
||||
|
||||
# Now test that the sync function loads series and handles DB
|
||||
# gracefully (even if DB operations fail, it should not crash)
|
||||
with patch('src.server.SeriesApp.Loaders'), \
|
||||
patch('src.server.SeriesApp.SerieScanner'):
|
||||
# The function should return 0 because DB isn't available
|
||||
# but should not crash
|
||||
count = await sync_legacy_series_to_db(tmp_dir)
|
||||
|
||||
# Since no real DB, it will fail gracefully
|
||||
# Function returns 0 when DB operations fail
|
||||
assert isinstance(count, int)
|
||||
assert count == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_handles_exceptions_gracefully(self):
|
||||
"""Test that sync handles exceptions without crashing."""
|
||||
from src.server.services.anime_service import sync_legacy_series_to_db
|
||||
|
||||
# Make SeriesApp raise an exception during initialization
|
||||
with patch('src.server.SeriesApp.Loaders'), \
|
||||
patch('src.server.SeriesApp.SerieScanner'), \
|
||||
patch(
|
||||
'src.server.SeriesApp.SerieList',
|
||||
side_effect=Exception("Test error")
|
||||
):
|
||||
count = await sync_legacy_series_to_db("/fake/path")
|
||||
|
||||
assert count == 0
|
||||
# Function should complete without crashing
|
||||
|
||||
|
||||
class TestEndToEndSync:
|
||||
"""End-to-end tests for the sync functionality."""
|
||||
|
||||
|
||||
@@ -13,11 +13,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.server.services.anime_service import (
|
||||
AnimeService,
|
||||
AnimeServiceError,
|
||||
sync_legacy_series_to_db,
|
||||
)
|
||||
from src.server.services.anime_service import AnimeService, AnimeServiceError
|
||||
from src.server.services.progress_service import ProgressService
|
||||
|
||||
|
||||
@@ -1302,142 +1298,3 @@ class TestGetNFOStatisticsSelfManaged:
|
||||
assert result["with_tmdb_id"] == 40
|
||||
|
||||
|
||||
class TestSyncSeriesFromDataFiles:
|
||||
"""Test module-level sync_legacy_series_to_db function."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_adds_new_series(self, tmp_path):
|
||||
"""Should create series for data files not in DB."""
|
||||
mock_serie = MagicMock()
|
||||
mock_serie.key = "new-series"
|
||||
mock_serie.name = "New Series"
|
||||
mock_serie.site = "aniworld.to"
|
||||
mock_serie.folder = "New Series"
|
||||
mock_serie.episodeDict = {1: [1]}
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_ctx = AsyncMock()
|
||||
mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_ctx.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch(
|
||||
"src.server.services.anime_service.SeriesApp"
|
||||
) as MockApp, patch(
|
||||
"src.server.database.connection.get_db_session",
|
||||
return_value=mock_ctx,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.create",
|
||||
new_callable=AsyncMock,
|
||||
return_value=MagicMock(id=1),
|
||||
) as mock_create, patch(
|
||||
"src.server.database.service.EpisodeService.create",
|
||||
new_callable=AsyncMock,
|
||||
):
|
||||
mock_app_instance = MagicMock()
|
||||
mock_app_instance.get_all_series_from_data_files.return_value = [
|
||||
mock_serie
|
||||
]
|
||||
MockApp.return_value = mock_app_instance
|
||||
|
||||
count = await sync_legacy_series_to_db(str(tmp_path))
|
||||
|
||||
assert count == 1
|
||||
mock_create.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_skips_existing(self, tmp_path):
|
||||
"""Already-existing series should be skipped."""
|
||||
mock_serie = MagicMock()
|
||||
mock_serie.key = "exists"
|
||||
mock_serie.name = "Exists"
|
||||
mock_serie.site = "x"
|
||||
mock_serie.folder = "Exists"
|
||||
mock_serie.episodeDict = {}
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_ctx = AsyncMock()
|
||||
mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_ctx.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch(
|
||||
"src.server.services.anime_service.SeriesApp"
|
||||
) as MockApp, patch(
|
||||
"src.server.database.connection.get_db_session",
|
||||
return_value=mock_ctx,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=MagicMock(),
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.create",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_create:
|
||||
mock_app_instance = MagicMock()
|
||||
mock_app_instance.get_all_series_from_data_files.return_value = [
|
||||
mock_serie
|
||||
]
|
||||
MockApp.return_value = mock_app_instance
|
||||
|
||||
count = await sync_legacy_series_to_db(str(tmp_path))
|
||||
|
||||
assert count == 0
|
||||
mock_create.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_no_data_files(self, tmp_path):
|
||||
"""Empty directory should return 0."""
|
||||
with patch(
|
||||
"src.server.services.anime_service.SeriesApp"
|
||||
) as MockApp:
|
||||
mock_app_instance = MagicMock()
|
||||
mock_app_instance.get_all_series_from_data_files.return_value = []
|
||||
MockApp.return_value = mock_app_instance
|
||||
|
||||
count = await sync_legacy_series_to_db(str(tmp_path))
|
||||
|
||||
assert count == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_handles_empty_name(self, tmp_path):
|
||||
"""Series with empty name should use folder as fallback."""
|
||||
mock_serie = MagicMock()
|
||||
mock_serie.key = "no-name"
|
||||
mock_serie.name = ""
|
||||
mock_serie.site = "x"
|
||||
mock_serie.folder = "FallbackFolder"
|
||||
mock_serie.episodeDict = {}
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_ctx = AsyncMock()
|
||||
mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_ctx.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch(
|
||||
"src.server.services.anime_service.SeriesApp"
|
||||
) as MockApp, patch(
|
||||
"src.server.database.connection.get_db_session",
|
||||
return_value=mock_ctx,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.create",
|
||||
new_callable=AsyncMock,
|
||||
return_value=MagicMock(id=1),
|
||||
) as mock_create:
|
||||
mock_app_instance = MagicMock()
|
||||
mock_app_instance.get_all_series_from_data_files.return_value = [
|
||||
mock_serie
|
||||
]
|
||||
MockApp.return_value = mock_app_instance
|
||||
|
||||
count = await sync_legacy_series_to_db(str(tmp_path))
|
||||
|
||||
assert count == 1
|
||||
# The name should have been set to folder
|
||||
assert mock_serie.name == "FallbackFolder"
|
||||
|
||||
@@ -160,23 +160,24 @@ 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',
|
||||
new_callable=AsyncMock, return_value=42) as mock_sync:
|
||||
with patch('src.server.services.initialization_service.settings') as mock_settings, \
|
||||
patch('src.server.services.initialization_service.os.path.isdir', return_value=True):
|
||||
mock_settings.anime_directory = "/path/to/anime"
|
||||
result = await _sync_anime_folders()
|
||||
|
||||
assert result == 42
|
||||
mock_sync.assert_called_once()
|
||||
assert result == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_anime_folders_with_progress(self):
|
||||
"""Test syncing anime folders with progress updates."""
|
||||
mock_progress = AsyncMock()
|
||||
|
||||
with patch('src.server.services.initialization_service.sync_legacy_series_to_db',
|
||||
new_callable=AsyncMock, return_value=10) as mock_sync:
|
||||
with patch('src.server.services.initialization_service.settings') as mock_settings, \
|
||||
patch('src.server.services.initialization_service.os.path.isdir', return_value=True):
|
||||
mock_settings.anime_directory = "/path/to/anime"
|
||||
result = await _sync_anime_folders(progress_service=mock_progress)
|
||||
|
||||
assert result == 10
|
||||
assert result == 0
|
||||
# Verify progress updates were called
|
||||
assert mock_progress.update_progress.call_count == 2
|
||||
mock_progress.update_progress.assert_any_call(
|
||||
@@ -188,7 +189,7 @@ class TestSyncAnimeFolders:
|
||||
mock_progress.update_progress.assert_any_call(
|
||||
progress_id="series_sync",
|
||||
current=75,
|
||||
message="Synced 10 series from data files",
|
||||
message="Series loaded directly from database",
|
||||
metadata={"step_id": "series_sync"}
|
||||
)
|
||||
|
||||
@@ -320,6 +321,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 +342,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)
|
||||
|
||||
@@ -527,6 +532,8 @@ class TestPerformNFOScan:
|
||||
new_callable=AsyncMock, return_value=False), \
|
||||
patch('src.server.services.initialization_service._is_nfo_scan_configured',
|
||||
new_callable=AsyncMock, return_value=True), \
|
||||
patch('src.server.services.initialization_service._load_series_into_memory',
|
||||
new_callable=AsyncMock), \
|
||||
patch('src.server.services.initialization_service._execute_nfo_scan',
|
||||
new_callable=AsyncMock), \
|
||||
patch('src.server.services.initialization_service._mark_nfo_scan_completed',
|
||||
@@ -544,6 +551,8 @@ class TestPerformNFOScan:
|
||||
new_callable=AsyncMock, return_value=False), \
|
||||
patch('src.server.services.initialization_service._is_nfo_scan_configured',
|
||||
new_callable=AsyncMock, return_value=True), \
|
||||
patch('src.server.services.initialization_service._load_series_into_memory',
|
||||
new_callable=AsyncMock), \
|
||||
patch('src.server.services.initialization_service._execute_nfo_scan',
|
||||
new_callable=AsyncMock), \
|
||||
patch('src.server.services.initialization_service._mark_nfo_scan_completed',
|
||||
|
||||
Reference in New Issue
Block a user