Compare commits
26 Commits
576d9f7a7b
...
v1.4.16
| Author | SHA1 | Date | |
|---|---|---|---|
| cbc44491e7 | |||
| e319cfecb8 | |||
| 4f61ded92a | |||
| d6082b5cf6 | |||
| e76cd3a708 | |||
| 08f7f7453c | |||
| 023ddd182f | |||
| 288b03cbb4 | |||
| f73cc530c3 | |||
| 4b835a2439 | |||
| 7c1dccfe64 | |||
| e0be00dce6 | |||
| 14f7b2f28a | |||
| de250bdd37 | |||
| b800158648 | |||
| 4e0c66ea9e | |||
| 07c311c1cd | |||
| cf00c9f7c5 | |||
| f3042206a8 | |||
| 657e7f9bf5 | |||
| fd3ec5df83 | |||
| 275aeb4544 | |||
| be7b210959 | |||
| 486c5440f2 | |||
| 4076b9dd43 | |||
| df93e8a81f |
@@ -1 +1 @@
|
|||||||
v1.4.10
|
v1.4.16
|
||||||
|
|||||||
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",
|
"name": "aniworld-web",
|
||||||
"version": "1.4.10",
|
"version": "1.4.16",
|
||||||
"description": "Aniworld Anime Download Manager - Web Frontend",
|
"description": "Aniworld Anime Download Manager - Web Frontend",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from src.server.models.auth import (
|
|||||||
from src.server.models.config import AppConfig
|
from src.server.models.config import AppConfig
|
||||||
from src.server.services.auth_service import AuthError, LockedOutError, auth_service
|
from src.server.services.auth_service import AuthError, LockedOutError, auth_service
|
||||||
from src.server.services.config_service import get_config_service
|
from src.server.services.config_service import get_config_service
|
||||||
|
from src.server.services.progress_service import ProgressType
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
@@ -117,6 +118,10 @@ async def setup_auth(req: SetupRequest):
|
|||||||
# Store master password hash in config's other field
|
# Store master password hash in config's other field
|
||||||
config.other['master_password_hash'] = password_hash
|
config.other['master_password_hash'] = password_hash
|
||||||
|
|
||||||
|
# Mark that loading has been initiated (used by middleware to prevent
|
||||||
|
# premature redirect to /login after setup)
|
||||||
|
config.other['loading_started'] = True
|
||||||
|
|
||||||
# Store anime directory in config's other field if provided
|
# Store anime directory in config's other field if provided
|
||||||
anime_directory = None
|
anime_directory = None
|
||||||
if req.anime_directory:
|
if req.anime_directory:
|
||||||
@@ -175,7 +180,6 @@ async def setup_auth(req: SetupRequest):
|
|||||||
# Continue — scheduler failure should not break initialization
|
# Continue — scheduler failure should not break initialization
|
||||||
|
|
||||||
# Send completion event
|
# Send completion event
|
||||||
from src.server.services.progress_service import ProgressType
|
|
||||||
await progress_service.start_progress(
|
await progress_service.start_progress(
|
||||||
progress_id="initialization_complete",
|
progress_id="initialization_complete",
|
||||||
progress_type=ProgressType.SYSTEM,
|
progress_type=ProgressType.SYSTEM,
|
||||||
@@ -191,7 +195,6 @@ async def setup_auth(req: SetupRequest):
|
|||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Send error event
|
# Send error event
|
||||||
from src.server.services.progress_service import ProgressType
|
|
||||||
await progress_service.start_progress(
|
await progress_service.start_progress(
|
||||||
progress_id="initialization_error",
|
progress_id="initialization_error",
|
||||||
progress_type=ProgressType.ERROR,
|
progress_type=ProgressType.ERROR,
|
||||||
@@ -209,8 +212,9 @@ async def setup_auth(req: SetupRequest):
|
|||||||
# Start initialization in background
|
# Start initialization in background
|
||||||
asyncio.create_task(run_initialization())
|
asyncio.create_task(run_initialization())
|
||||||
|
|
||||||
# Return redirect to loading page
|
# Return redirect to loading page with phase=initial
|
||||||
return {"status": "ok", "redirect": "/loading"}
|
# 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
|
# Note: Media scan is skipped during setup as it requires
|
||||||
# background_loader service which is only available during
|
# background_loader service which is only available during
|
||||||
# application lifespan. It will run on first application startup.
|
# application lifespan. It will run on first application startup.
|
||||||
|
|||||||
@@ -279,30 +279,15 @@ async def update_directory(
|
|||||||
|
|
||||||
config_service.save_config(app_config)
|
config_service.save_config(app_config)
|
||||||
|
|
||||||
# Sync series from data files to database
|
# Series are now loaded directly from database, no sync needed
|
||||||
sync_count = 0
|
logger.info(
|
||||||
try:
|
"Directory updated successfully",
|
||||||
import structlog
|
directory=directory
|
||||||
|
)
|
||||||
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)
|
|
||||||
)
|
|
||||||
|
|
||||||
response: Dict[str, Any] = {
|
response: Dict[str, Any] = {
|
||||||
"message": "Anime directory updated successfully",
|
"message": "Anime directory updated successfully",
|
||||||
"synced_series": sync_count
|
"synced_series": 0
|
||||||
}
|
}
|
||||||
|
|
||||||
return response
|
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)
|
@router.post("/unresolved/{folder_name}/search", response_model=UnresolvedFolderResponse)
|
||||||
async def search_unresolved_folder(
|
async def search_unresolved_folder(
|
||||||
folder_name: str,
|
folder_name: str,
|
||||||
|
request: Optional[SearchFolderRequest] = None,
|
||||||
db=Depends(get_database_session),
|
db=Depends(get_database_session),
|
||||||
) -> UnresolvedFolderResponse:
|
) -> UnresolvedFolderResponse:
|
||||||
"""Re-search for a specific unresolved folder to get fresh suggestions.
|
"""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:
|
Args:
|
||||||
folder_name: URL-encoded folder name to search for
|
folder_name: URL-encoded folder name to search for
|
||||||
|
request: Optional SearchFolderRequest with custom query override
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
UnresolvedFolderResponse with updated search suggestions
|
UnresolvedFolderResponse with updated search suggestions
|
||||||
@@ -258,10 +266,13 @@ async def search_unresolved_folder(
|
|||||||
detail=f"Folder already resolved: {folder_name}"
|
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
|
# Perform search
|
||||||
series_app = get_series_app()
|
series_app = get_series_app()
|
||||||
try:
|
try:
|
||||||
results = await series_app.search(folder.title)
|
results = await series_app.search(search_query)
|
||||||
search_result_json = json.dumps(results) if results else "[]"
|
search_result_json = json.dumps(results) if results else "[]"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -278,7 +289,7 @@ async def search_unresolved_folder(
|
|||||||
folder_name=folder.folder_name,
|
folder_name=folder.folder_name,
|
||||||
title=folder.title,
|
title=folder.title,
|
||||||
year=folder.year,
|
year=folder.year,
|
||||||
search_attempts=folder.search_attempts,
|
search_attempts=folder.search_attempts + 1,
|
||||||
search_suggestions=results,
|
search_suggestions=results,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -310,4 +321,103 @@ async def delete_unresolved_folder(
|
|||||||
detail=f"Unresolved folder not found: {folder_name}"
|
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."
|
||||||
|
)
|
||||||
@@ -210,6 +210,15 @@ class AnimeSeries(Base, TimestampMixin):
|
|||||||
episode_dict[season].append(ep.episode_number or 0)
|
episode_dict[season].append(ep.episode_number or 0)
|
||||||
return episode_dict
|
return episode_dict
|
||||||
|
|
||||||
|
@episodeDict.setter
|
||||||
|
def episodeDict(self, value: dict[int, list[int]]) -> None:
|
||||||
|
"""Set the episode dictionary via private cache.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: Dictionary mapping season numbers to lists of episode numbers
|
||||||
|
"""
|
||||||
|
self._episode_dict_cache = value
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name_with_year(self) -> str:
|
def name_with_year(self) -> str:
|
||||||
"""Get series name with year appended if available.
|
"""Get series name with year appended if available.
|
||||||
|
|||||||
@@ -105,6 +105,34 @@ class SetupRedirectMiddleware(BaseHTTPMiddleware):
|
|||||||
|
|
||||||
return False
|
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(
|
async def dispatch(
|
||||||
self, request: Request, call_next: Callable
|
self, request: Request, call_next: Callable
|
||||||
) -> Response:
|
) -> Response:
|
||||||
@@ -118,20 +146,34 @@ class SetupRedirectMiddleware(BaseHTTPMiddleware):
|
|||||||
Either a redirect to /setup or the normal response
|
Either a redirect to /setup or the normal response
|
||||||
"""
|
"""
|
||||||
path = request.url.path
|
path = request.url.path
|
||||||
|
query_params = request.query_params
|
||||||
|
|
||||||
# Check if trying to access setup or loading page after completion
|
# 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():
|
if not self._needs_setup():
|
||||||
# Setup is complete, check loading status
|
|
||||||
if path == "/setup":
|
if path == "/setup":
|
||||||
# Redirect to loading if initialization is in progress
|
# Redirect to login if setup is already complete
|
||||||
# Otherwise redirect to login
|
|
||||||
return RedirectResponse(url="/login", status_code=302)
|
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":
|
elif path == "/loading":
|
||||||
# Always allow access to loading page - it handles its own
|
# Handle phase query parameter
|
||||||
# redirect flow via WebSocket events (initialization_complete
|
phase = query_params.get("phase")
|
||||||
# event triggers redirect to /setup/unresolved)
|
if phase == "initial":
|
||||||
pass
|
# 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
|
# Skip setup check for exempt paths
|
||||||
if self._is_path_exempt(path):
|
if self._is_path_exempt(path):
|
||||||
|
|||||||
@@ -422,3 +422,32 @@ class TMDBClient:
|
|||||||
if expired_keys:
|
if expired_keys:
|
||||||
logger.debug("Removed %d expired negative cache entries", len(expired_keys))
|
logger.debug("Removed %d expired negative cache entries", len(expired_keys))
|
||||||
return 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:
|
def get_anime_service(series_app: SeriesApp) -> AnimeService:
|
||||||
"""Factory used for creating AnimeService with a SeriesApp instance."""
|
"""Factory used for creating AnimeService with a SeriesApp instance."""
|
||||||
return AnimeService(series_app)
|
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.config.settings import settings
|
||||||
from src.server.database.service import AnimeSeriesService
|
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
|
from src.server.services.setup_service import SetupService
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
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...")
|
logger.info("Performing initial anime folder scan...")
|
||||||
|
|
||||||
|
# Check if anime directory exists before attempting sync
|
||||||
|
if not settings.anime_directory or not os.path.isdir(settings.anime_directory):
|
||||||
|
logger.info(
|
||||||
|
"Anime directory not configured or does not exist, skipping data file sync"
|
||||||
|
)
|
||||||
|
if progress_service:
|
||||||
|
await progress_service.update_progress(
|
||||||
|
progress_id="series_sync",
|
||||||
|
current=100,
|
||||||
|
message="No anime directory configured, skipping data file sync",
|
||||||
|
metadata={"step_id": "series_sync"}
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
if progress_service:
|
if progress_service:
|
||||||
await progress_service.update_progress(
|
await progress_service.update_progress(
|
||||||
progress_id="series_sync",
|
progress_id="series_sync",
|
||||||
@@ -223,14 +236,15 @@ async def _sync_anime_folders(progress_service=None) -> int:
|
|||||||
metadata={"step_id": "series_sync"}
|
metadata={"step_id": "series_sync"}
|
||||||
)
|
)
|
||||||
|
|
||||||
sync_count = await sync_legacy_series_to_db(settings.anime_directory)
|
# Legacy sync removed - series are loaded directly from database via _load_series_into_memory
|
||||||
logger.info("Data file sync complete. Added %d series.", sync_count)
|
sync_count = 0
|
||||||
|
logger.info("Data file sync skipped - series loaded directly from database")
|
||||||
|
|
||||||
if progress_service:
|
if progress_service:
|
||||||
await progress_service.update_progress(
|
await progress_service.update_progress(
|
||||||
progress_id="series_sync",
|
progress_id="series_sync",
|
||||||
current=75,
|
current=75,
|
||||||
message=f"Synced {sync_count} series from data files",
|
message=f"Series loaded directly from database",
|
||||||
metadata={"step_id": "series_sync"}
|
metadata={"step_id": "series_sync"}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -383,9 +397,22 @@ async def perform_initial_setup(progress_service=None):
|
|||||||
# Mark the initial scan as completed
|
# Mark the initial scan as completed
|
||||||
await _mark_initial_scan_completed()
|
await _mark_initial_scan_completed()
|
||||||
|
|
||||||
|
# Mark loading as complete in config (used by middleware to allow redirect to /login)
|
||||||
|
try:
|
||||||
|
from src.server.services.config_service import get_config_service
|
||||||
|
config_svc = get_config_service()
|
||||||
|
init_config = config_svc.load_config()
|
||||||
|
init_config.other['loading_complete'] = True
|
||||||
|
config_svc.save_config(init_config, create_backup=False)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to save loading_complete flag: %s", e)
|
||||||
|
|
||||||
# Load series into memory from database
|
# Load series into memory from database
|
||||||
await _load_series_into_memory(progress_service)
|
await _load_series_into_memory(progress_service)
|
||||||
|
|
||||||
|
# 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
|
return True
|
||||||
|
|
||||||
except (OSError, RuntimeError, ValueError) as e:
|
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:
|
async def _execute_nfo_scan(progress_service=None) -> None:
|
||||||
"""Execute the actual NFO scan with TMDB data.
|
"""Execute the actual NFO scan with TMDB data.
|
||||||
|
|
||||||
Note: NFO service removed. This function is now a no-op stub.
|
|
||||||
|
|
||||||
Args:
|
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")
|
from src.server.services.nfo_scan_service import NfoScanService
|
||||||
return
|
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):
|
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
|
from src.server.services.progress_service import ProgressType
|
||||||
await progress_service.start_progress(
|
await progress_service.start_progress(
|
||||||
progress_id="nfo_scan",
|
progress_id="nfo_scan",
|
||||||
progress_type=ProgressType.SYSTEM,
|
progress_type=ProgressType.SCAN,
|
||||||
title="Processing NFO Metadata",
|
title="Scanning NFO Files",
|
||||||
total=100,
|
total=100,
|
||||||
message="Checking NFO scan status...",
|
message="Checking NFO scan status...",
|
||||||
metadata={"step_id": "nfo_scan"}
|
metadata={"step_id": "nfo_scan"}
|
||||||
@@ -486,16 +541,111 @@ async def perform_nfo_scan_if_needed(progress_service=None):
|
|||||||
|
|
||||||
# Execute the NFO scan
|
# Execute the NFO scan
|
||||||
try:
|
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 _execute_nfo_scan(progress_service)
|
||||||
await _mark_nfo_scan_completed()
|
await _mark_nfo_scan_completed()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to complete NFO scan: %s", e, exc_info=True)
|
logger.error("Failed to complete NFO scan: %s", e, exc_info=True)
|
||||||
if progress_service:
|
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",
|
progress_id="nfo_scan",
|
||||||
error_message=f"NFO scan failed: {str(e)}",
|
message=message,
|
||||||
metadata={"step_id": "nfo_scan"}
|
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:
|
async def _check_media_scan_status() -> bool:
|
||||||
|
|||||||
@@ -326,6 +326,22 @@ class NfoScanService:
|
|||||||
|
|
||||||
nfo_exists = os.path.isfile(nfo_path)
|
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:
|
if not nfo_exists:
|
||||||
# Create new NFO
|
# Create new NFO
|
||||||
logger.info("Creating NFO for series: %s (%s)", key, folder)
|
logger.info("Creating NFO for series: %s (%s)", key, folder)
|
||||||
@@ -526,6 +542,53 @@ class NfoScanService:
|
|||||||
logger.info("Regenerated NFO for %s", key)
|
logger.info("Regenerated NFO for %s", key)
|
||||||
return True
|
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]]:
|
async def _fetch_tmdb_data(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
|
||||||
"""Fetch series metadata from TMDB API.
|
"""Fetch series metadata from TMDB API.
|
||||||
|
|
||||||
@@ -539,7 +602,7 @@ class NfoScanService:
|
|||||||
from src.server.nfo.tmdb_client import get_tmdb_client
|
from src.server.nfo.tmdb_client import get_tmdb_client
|
||||||
|
|
||||||
client = 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
|
return data
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("TMDB fetch failed for TMDB ID %s: %s", tmdb_id, 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:
|
async with get_db_session() as db:
|
||||||
settings = await SystemSettingsService.get_or_create(db)
|
settings = await SystemSettingsService.get_or_create(db)
|
||||||
last_scan = settings.last_scan_timestamp
|
last_scan = settings.last_scan_timestamp
|
||||||
|
initial_scan_done = settings.initial_scan_completed
|
||||||
|
|
||||||
if last_scan is None:
|
if last_scan is None:
|
||||||
|
# No previous scan recorded
|
||||||
|
if not initial_scan_done:
|
||||||
|
# Initial setup not yet completed - skip rescan
|
||||||
|
# The setup flow will trigger rescan when ready
|
||||||
|
logger.info(
|
||||||
|
"No previous scan recorded and initial setup not yet "
|
||||||
|
"completed — skipping immediate rescan"
|
||||||
|
)
|
||||||
|
return
|
||||||
# Never scanned before — trigger immediately
|
# Never scanned before — trigger immediately
|
||||||
logger.info("No previous scan recorded — triggering immediate rescan")
|
logger.info("No previous scan recorded — triggering immediate rescan")
|
||||||
await self._perform_rescan()
|
await self._perform_rescan()
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ Series Identifier Convention:
|
|||||||
All template helpers that handle series data use `key` for identification and
|
All template helpers that handle series data use `key` for identification and
|
||||||
provide `folder` as display metadata only.
|
provide `folder` as display metadata only.
|
||||||
"""
|
"""
|
||||||
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import time
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
@@ -27,10 +27,44 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# Configure templates directory
|
# Configure templates directory
|
||||||
TEMPLATES_DIR = Path(__file__).parent.parent / "web" / "templates"
|
TEMPLATES_DIR = Path(__file__).parent.parent / "web" / "templates"
|
||||||
|
STATIC_DIR = Path(__file__).parent.parent / "web" / "static"
|
||||||
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
||||||
|
|
||||||
# Version token for static asset cache-busting; changes on every server start.
|
# Cache for static file hashes: {file_path: (mtime, hash)}
|
||||||
STATIC_VERSION: str = str(int(time.time()))
|
_hash_cache: Dict[str, tuple[float, str]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_static_version(file_path: str) -> str:
|
||||||
|
"""
|
||||||
|
Get cache-busting version for a static file based on content hash.
|
||||||
|
|
||||||
|
Hash is computed once and cached; cache is invalidated when file mtime changes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Relative path to static file (e.g., 'css/styles.css')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
8-character hex hash of file content, or empty string if file not found
|
||||||
|
"""
|
||||||
|
full_path = STATIC_DIR / file_path
|
||||||
|
|
||||||
|
if not full_path.exists():
|
||||||
|
logger.warning(f"Static file not found: {file_path}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
current_mtime = full_path.stat().st_mtime
|
||||||
|
|
||||||
|
# Check cache validity
|
||||||
|
if file_path in _hash_cache:
|
||||||
|
cached_mtime, cached_hash = _hash_cache[file_path]
|
||||||
|
if cached_mtime == current_mtime:
|
||||||
|
return cached_hash
|
||||||
|
|
||||||
|
# Compute new hash
|
||||||
|
file_hash = hashlib.md5(full_path.read_bytes()).hexdigest()[:8]
|
||||||
|
_hash_cache[file_path] = (current_mtime, file_hash)
|
||||||
|
|
||||||
|
return file_hash
|
||||||
|
|
||||||
|
|
||||||
def get_base_context(
|
def get_base_context(
|
||||||
@@ -51,7 +85,7 @@ def get_base_context(
|
|||||||
"title": title,
|
"title": title,
|
||||||
"app_name": "Aniworld Download Manager",
|
"app_name": "Aniworld Download Manager",
|
||||||
"version": APP_VERSION,
|
"version": APP_VERSION,
|
||||||
"static_v": STATIC_VERSION,
|
"static_version": get_static_version,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,11 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>AniWorld Manager</title>
|
<title>AniWorld Manager</title>
|
||||||
<link rel="stylesheet" href="/static/css/styles.css?v={{ static_v }}">
|
<link rel="stylesheet" href="/static/css/styles.css?v={{ static_version('css/styles.css') }}">
|
||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||||
|
|
||||||
<!-- UX Enhancement and Mobile & Accessibility CSS -->
|
<!-- UX Enhancement and Mobile & Accessibility CSS -->
|
||||||
<link rel="stylesheet" href="/static/css/ux_features.css?v={{ static_v }}">
|
<link rel="stylesheet" href="/static/css/ux_features.css?v={{ static_version('css/ux_features.css') }}">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@@ -727,22 +727,22 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Shared Modules (load in dependency order) -->
|
<!-- Shared Modules (load in dependency order) -->
|
||||||
<script src="/static/js/shared/constants.js?v={{ static_v }}"></script>
|
<script src="/static/js/shared/constants.js?v={{ static_version('js/shared/constants.js') }}"></script>
|
||||||
<script src="/static/js/shared/auth.js?v={{ static_v }}"></script>
|
<script src="/static/js/shared/auth.js?v={{ static_version('js/shared/auth.js') }}"></script>
|
||||||
<script src="/static/js/shared/api-client.js?v={{ static_v }}"></script>
|
<script src="/static/js/shared/api-client.js?v={{ static_version('js/shared/api-client.js') }}"></script>
|
||||||
<script src="/static/js/shared/theme.js?v={{ static_v }}"></script>
|
<script src="/static/js/shared/theme.js?v={{ static_version('js/shared/theme.js') }}"></script>
|
||||||
<script src="/static/js/shared/ui-utils.js?v={{ static_v }}"></script>
|
<script src="/static/js/shared/ui-utils.js?v={{ static_version('js/shared/ui-utils.js') }}"></script>
|
||||||
<script src="/static/js/shared/websocket-client.js?v={{ static_v }}"></script>
|
<script src="/static/js/shared/websocket-client.js?v={{ static_version('js/shared/websocket-client.js') }}"></script>
|
||||||
|
|
||||||
<!-- External modules -->
|
<!-- External modules -->
|
||||||
<script src="/static/js/localization.js?v={{ static_v }}"></script>
|
<script src="/static/js/localization.js?v={{ static_version('js/localization.js') }}"></script>
|
||||||
<script src="/static/js/user_preferences.js?v={{ static_v }}"></script>
|
<script src="/static/js/user_preferences.js?v={{ static_version('js/user_preferences.js') }}"></script>
|
||||||
|
|
||||||
<!-- Index Page Modules -->
|
<!-- Index Page Modules -->
|
||||||
<script src="/static/js/index/context-menu.js?v={{ static_v }}"></script>
|
<script src="/static/js/index/context-menu.js?v={{ static_version('js/index/context-menu.js') }}"></script>
|
||||||
<script src="/static/js/index/edit-modal.js?v={{ static_v }}"></script>
|
<script src="/static/js/index/edit-modal.js?v={{ static_version('js/index/edit-modal.js') }}"></script>
|
||||||
<script src="/static/js/index/series-manager.js?v={{ static_v }}"></script>
|
<script src="/static/js/index/series-manager.js?v={{ static_version('js/index/series-manager.js') }}"></script>
|
||||||
<script src="/static/js/index/selection-manager.js?v={{ static_v }}"></script>
|
<script src="/static/js/index/selection-manager.js?v={{ static_version('js/index/selection-manager.js') }}"></script>
|
||||||
<script src="/static/js/index/search.js?v={{ static_v }}"></script>
|
<script src="/static/js/index/search.js?v={{ static_v }}"></script>
|
||||||
<script src="/static/js/index/scan-manager.js?v={{ static_v }}"></script>
|
<script src="/static/js/index/scan-manager.js?v={{ static_v }}"></script>
|
||||||
<script src="/static/js/index/nfo-manager.js?v={{ static_v }}"></script>
|
<script src="/static/js/index/nfo-manager.js?v={{ static_v }}"></script>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>AniWorld Manager - Initializing</title>
|
<title>AniWorld Manager - Initializing</title>
|
||||||
<link rel="stylesheet" href="/static/css/styles.css?v={{ static_v }}">
|
<link rel="stylesheet" href="/static/css/styles.css?v={{ static_version('css/styles.css') }}">
|
||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
.loading-container {
|
.loading-container {
|
||||||
@@ -279,15 +279,83 @@
|
|||||||
let ws = null;
|
let ws = null;
|
||||||
const steps = new Map();
|
const steps = new Map();
|
||||||
let isComplete = false;
|
let isComplete = false;
|
||||||
|
|
||||||
|
// Get phase from URL query parameter
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const currentPhase = urlParams.get('phase') || 'initial';
|
||||||
|
|
||||||
const stepOrder = [
|
const stepOrder = [
|
||||||
'series_sync'
|
'series_sync',
|
||||||
|
'nfo_scan'
|
||||||
];
|
];
|
||||||
|
|
||||||
const stepTitles = {
|
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() {
|
function connectWebSocket() {
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
const wsUrl = `${protocol}//${window.location.host}/ws/connect`;
|
const wsUrl = `${protocol}//${window.location.host}/ws/connect`;
|
||||||
@@ -298,13 +366,24 @@
|
|||||||
console.log('WebSocket connected');
|
console.log('WebSocket connected');
|
||||||
updateConnectionStatus(true);
|
updateConnectionStatus(true);
|
||||||
|
|
||||||
// Subscribe to system room for progress updates
|
// Subscribe to rooms based on phase
|
||||||
ws.send(JSON.stringify({
|
if (currentPhase === 'nfo') {
|
||||||
action: 'join',
|
// For nfo phase, only subscribe to scan room
|
||||||
data: {
|
ws.send(JSON.stringify({
|
||||||
room: 'system'
|
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) => {
|
ws.onmessage = (event) => {
|
||||||
@@ -349,6 +428,18 @@
|
|||||||
const data = message.data || message;
|
const data = message.data || message;
|
||||||
const { type, status, title, message: msg, percent, current, total, metadata } = data;
|
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
|
// Determine step ID based on type and metadata
|
||||||
let stepId = metadata?.step_id || type;
|
let stepId = metadata?.step_id || type;
|
||||||
|
|
||||||
@@ -359,9 +450,11 @@
|
|||||||
|
|
||||||
updateStep(stepId, status, msg, percent, current, total);
|
updateStep(stepId, status, msg, percent, current, total);
|
||||||
|
|
||||||
// Check for completion
|
// Check for completion of series_sync
|
||||||
if (metadata?.initialization_complete) {
|
// stepId is used because type is 'system_progress' for SYSTEM progress events
|
||||||
showCompletion();
|
if (metadata?.initialization_complete || (stepId === 'series_sync' && status === 'completed')) {
|
||||||
|
// For initial phase, series_sync completion leads to /setup/unresolved
|
||||||
|
handleSeriesSyncComplete();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle errors
|
// Handle errors
|
||||||
@@ -369,6 +462,104 @@
|
|||||||
showError(msg || 'An error occurred during initialization');
|
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) {
|
function createStep(stepId, title) {
|
||||||
const container = document.getElementById('progressContainer');
|
const container = document.getElementById('progressContainer');
|
||||||
@@ -475,8 +666,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function checkUnresolvedAndProceed() {
|
async function checkUnresolvedAndProceed() {
|
||||||
// Fetch unresolved folders and only redirect if there are any
|
// Always check for unresolved folders first
|
||||||
// Otherwise go directly to login
|
// After setup -> loading, always go through unresolved if there are any
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('auth_token');
|
const token = localStorage.getItem('auth_token');
|
||||||
const res = await fetch('/api/setup/unresolved', {
|
const res = await fetch('/api/setup/unresolved', {
|
||||||
@@ -493,7 +684,7 @@
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to check unresolved folders:', 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';
|
window.location.href = '/login';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -510,6 +701,27 @@
|
|||||||
|
|
||||||
// Start WebSocket connection when page loads
|
// Start WebSocket connection when page loads
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
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();
|
connectWebSocket();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>AniWorld Manager - Login</title>
|
<title>AniWorld Manager - Login</title>
|
||||||
<link rel="stylesheet" href="/static/css/styles.css?v={{ static_v }}">
|
<link rel="stylesheet" href="/static/css/styles.css?v={{ static_version('css/styles.css') }}">
|
||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
.login-container {
|
.login-container {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Download Queue - AniWorld Manager</title>
|
<title>Download Queue - AniWorld Manager</title>
|
||||||
<link rel="stylesheet" href="/static/css/styles.css?v={{ static_v }}">
|
<link rel="stylesheet" href="/static/css/styles.css?v={{ static_version('css/styles.css') }}">
|
||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -234,19 +234,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Shared Modules (load in dependency order) -->
|
<!-- Shared Modules (load in dependency order) -->
|
||||||
<script src="/static/js/shared/constants.js?v={{ static_v }}"></script>
|
<script src="/static/js/shared/constants.js?v={{ static_version('js/shared/constants.js') }}"></script>
|
||||||
<script src="/static/js/shared/auth.js?v={{ static_v }}"></script>
|
<script src="/static/js/shared/auth.js?v={{ static_version('js/shared/auth.js') }}"></script>
|
||||||
<script src="/static/js/shared/api-client.js?v={{ static_v }}"></script>
|
<script src="/static/js/shared/api-client.js?v={{ static_version('js/shared/api-client.js') }}"></script>
|
||||||
<script src="/static/js/shared/theme.js?v={{ static_v }}"></script>
|
<script src="/static/js/shared/theme.js?v={{ static_version('js/shared/theme.js') }}"></script>
|
||||||
<script src="/static/js/shared/ui-utils.js?v={{ static_v }}"></script>
|
<script src="/static/js/shared/ui-utils.js?v={{ static_version('js/shared/ui-utils.js') }}"></script>
|
||||||
<script src="/static/js/shared/websocket-client.js?v={{ static_v }}"></script>
|
<script src="/static/js/shared/websocket-client.js?v={{ static_version('js/shared/websocket-client.js') }}"></script>
|
||||||
|
|
||||||
<!-- Queue Page Modules -->
|
<!-- Queue Page Modules -->
|
||||||
<script src="/static/js/queue/queue-api.js?v={{ static_v }}"></script>
|
<script src="/static/js/queue/queue-api.js?v={{ static_version('js/queue/queue-api.js') }}"></script>
|
||||||
<script src="/static/js/queue/queue-renderer.js?v={{ static_v }}"></script>
|
<script src="/static/js/queue/queue-renderer.js?v={{ static_version('js/queue/queue-renderer.js') }}"></script>
|
||||||
<script src="/static/js/queue/progress-handler.js?v={{ static_v }}"></script>
|
<script src="/static/js/queue/progress-handler.js?v={{ static_version('js/queue/progress-handler.js') }}"></script>
|
||||||
<script src="/static/js/queue/queue-socket-handler.js?v={{ static_v }}"></script>
|
<script src="/static/js/queue/queue-socket-handler.js?v={{ static_version('js/queue/queue-socket-handler.js') }}"></script>
|
||||||
<script src="/static/js/queue/queue-init.js?v={{ static_v }}"></script>
|
<script src="/static/js/queue/queue-init.js?v={{ static_version('js/queue/queue-init.js') }}"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>AniWorld Manager - Setup</title>
|
<title>AniWorld Manager - Setup</title>
|
||||||
<link rel="stylesheet" href="/static/css/styles.css?v={{ static_v }}">
|
<link rel="stylesheet" href="/static/css/styles.css?v={{ static_version('css/styles.css') }}">
|
||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
.setup-container {
|
.setup-container {
|
||||||
@@ -790,37 +790,14 @@
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (response.ok && data.status === 'ok') {
|
if (response.ok && data.status === 'ok') {
|
||||||
// Redirect to loading page if provided, otherwise check for unresolved folders
|
// Always redirect to loading page with initial phase
|
||||||
if (data.redirect) {
|
// The loading page will handle unresolved folder check
|
||||||
showMessage('Setup saved! Initializing your anime library...', 'success');
|
showMessage('Setup saved! Initializing your anime library...', 'success');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = data.redirect;
|
// Set session storage state before redirecting
|
||||||
}, 500);
|
sessionStorage.setItem('setup_phase', 'initial');
|
||||||
} else {
|
window.location.href = '/loading?phase=initial';
|
||||||
// Check for unresolved folders before redirecting
|
}, 500);
|
||||||
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);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
const errorMessage = data.detail || data.message || 'Setup failed';
|
const errorMessage = data.detail || data.message || 'Setup failed';
|
||||||
showMessage(errorMessage, 'error');
|
showMessage(errorMessage, 'error');
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>AniWorld Manager - Resolve Series</title>
|
<title>AniWorld Manager - Resolve Series</title>
|
||||||
<link rel="stylesheet" href="/static/css/styles.css?v={{ static_v }}">
|
<link rel="stylesheet" href="/static/css/styles.css?v={{ static_version('css/styles.css') }}">
|
||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.6.0/css/all.min.css" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
.unresolved-container {
|
.unresolved-container {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@@ -198,15 +198,27 @@
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.suggestion-link {
|
.suggestion-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
text-decoration: none;
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.suggestion-link:hover {
|
.suggestion-btn:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.suggestion-btn .key-label {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-left: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
.no-suggestions {
|
.no-suggestions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -238,6 +250,63 @@
|
|||||||
opacity: 0.7;
|
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 */
|
||||||
.empty-state {
|
.empty-state {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -358,6 +427,36 @@
|
|||||||
text-decoration: underline;
|
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) {
|
@media (max-width: 600px) {
|
||||||
.folder-input-row {
|
.folder-input-row {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -382,6 +481,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<h1>Resolve Unresolved Series</h1>
|
<h1>Resolve Unresolved Series</h1>
|
||||||
<p>Some series couldn't be found automatically. Enter the provider key for each folder to complete setup.</p>
|
<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>
|
||||||
|
|
||||||
<div id="loading-state" class="loading-state">
|
<div id="loading-state" class="loading-state">
|
||||||
@@ -471,12 +575,17 @@
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reSearchFolder(folderName) {
|
async function reSearchFolder(folderName, customQuery) {
|
||||||
const token = localStorage.getItem('auth_token');
|
const token = localStorage.getItem('auth_token');
|
||||||
const encodedName = encodeURIComponent(folderName);
|
const encodedName = encodeURIComponent(folderName);
|
||||||
|
const body = customQuery ? JSON.stringify({ query: customQuery }) : '{}';
|
||||||
const res = await fetch(`/api/setup/unresolved/${encodedName}/search`, {
|
const res = await fetch(`/api/setup/unresolved/${encodedName}/search`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: body
|
||||||
});
|
});
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
@@ -494,19 +603,29 @@
|
|||||||
// Render functions
|
// Render functions
|
||||||
function renderFolderItem(folder) {
|
function renderFolderItem(folder) {
|
||||||
const suggestionsHtml = folder.search_suggestions && folder.search_suggestions.length > 0
|
const suggestionsHtml = folder.search_suggestions && folder.search_suggestions.length > 0
|
||||||
? folder.search_suggestions.map(s => `
|
? folder.search_suggestions.map(s => {
|
||||||
|
console.log('[DEBUG] Rendering suggestion:', s);
|
||||||
|
return `
|
||||||
<div class="suggestion-item">
|
<div class="suggestion-item">
|
||||||
<i class="fas fa-link"></i>
|
<i class="fas fa-hand-pointer"></i>
|
||||||
<a href="${s.link}" class="suggestion-link" target="_blank">${s.title}</a>
|
<button class="suggestion-btn" data-provider-key="${s.link || s.provider_key || s.key || ''}" data-folder="${folder.folder_name}">
|
||||||
|
${s.name || s.title} <span class="key-label">(${s.link || ''})</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
`).join('')
|
`;
|
||||||
|
}).join('')
|
||||||
: '<div class="no-suggestions"><i class="fas fa-info-circle"></i> No suggestions found</div>';
|
: '<div class="no-suggestions"><i class="fas fa-info-circle"></i> No suggestions found</div>';
|
||||||
|
|
||||||
const searchAgainBtn = (folder.search_suggestions && folder.search_suggestions.length === 0)
|
// Always show search row so user can search multiple times
|
||||||
? `<button class="search-again-btn" data-folder="${folder.folder_name}">
|
const searchAgainBtn = `<div class="search-again-row">
|
||||||
<i class="fas fa-search"></i> Search Again
|
<input type="text" class="search-again-input"
|
||||||
</button>`
|
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 `
|
return `
|
||||||
<div class="folder-item" data-folder="${folder.folder_name}">
|
<div class="folder-item" data-folder="${folder.folder_name}">
|
||||||
@@ -550,7 +669,11 @@
|
|||||||
listEl.style.display = 'none';
|
listEl.style.display = 'none';
|
||||||
emptyEl.style.display = 'block';
|
emptyEl.style.display = 'block';
|
||||||
document.getElementById('skip-link').style.display = 'block';
|
document.getElementById('skip-link').style.display = 'block';
|
||||||
setTimeout(() => { window.location.href = '/loading'; }, 2000);
|
// No unresolved folders - redirect to NFO scan phase
|
||||||
|
setTimeout(() => {
|
||||||
|
sessionStorage.setItem('setup_phase', 'nfo');
|
||||||
|
window.location.href = '/loading?phase=nfo';
|
||||||
|
}, 2000);
|
||||||
} else {
|
} else {
|
||||||
listEl.style.display = 'flex';
|
listEl.style.display = 'flex';
|
||||||
emptyEl.style.display = 'none';
|
emptyEl.style.display = 'none';
|
||||||
@@ -559,6 +682,65 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function attachSuggestionLinkEvents() {
|
||||||
|
document.querySelectorAll('.suggestion-btn').forEach(link => {
|
||||||
|
link.addEventListener('click', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
// Use 'link' from closure, not e.target, to handle clicks on child elements
|
||||||
|
const providerKey = link.dataset.providerKey;
|
||||||
|
const folder = link.dataset.folder;
|
||||||
|
|
||||||
|
console.log('[DEBUG] Suggestion clicked:', { providerKey, folder, link });
|
||||||
|
console.log('[DEBUG] Full dataset:', link.dataset);
|
||||||
|
console.log('[DEBUG] Suggestion object keys:', link.dataset);
|
||||||
|
|
||||||
|
if (!providerKey) {
|
||||||
|
showToast('No provider key available for this suggestion', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = document.querySelector(`.folder-input[data-folder="${folder}"]`);
|
||||||
|
const resolveBtn = document.querySelector(`.resolve-btn[data-folder="${folder}"]`);
|
||||||
|
const item = document.querySelector(`.folder-item[data-folder="${folder}"]`);
|
||||||
|
const errEl = document.querySelector(`.folder-error[data-folder="${folder}"]`);
|
||||||
|
|
||||||
|
if (!input || !resolveBtn || !item) return;
|
||||||
|
|
||||||
|
input.value = providerKey;
|
||||||
|
resolveBtn.disabled = false;
|
||||||
|
|
||||||
|
item.classList.add('resolving');
|
||||||
|
resolveBtn.disabled = true;
|
||||||
|
resolveBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await resolveFolder(folder, providerKey);
|
||||||
|
|
||||||
|
if (result.status === 'success') {
|
||||||
|
showToast(`Added: ${result.message.replace('Successfully resolved and added series: ', '')}`, 'success');
|
||||||
|
item.classList.add('resolved');
|
||||||
|
setTimeout(() => {
|
||||||
|
item.remove();
|
||||||
|
checkEmptyList();
|
||||||
|
}, 400);
|
||||||
|
} else {
|
||||||
|
errEl.textContent = result.detail || result.message || 'Failed to resolve';
|
||||||
|
errEl.classList.add('visible');
|
||||||
|
resolveBtn.disabled = false;
|
||||||
|
resolveBtn.innerHTML = 'Resolve';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errEl.textContent = 'Server error. Please try again.';
|
||||||
|
errEl.classList.add('visible');
|
||||||
|
resolveBtn.disabled = false;
|
||||||
|
resolveBtn.innerHTML = 'Resolve';
|
||||||
|
} finally {
|
||||||
|
item.classList.remove('resolving');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function attachFolderEvents() {
|
function attachFolderEvents() {
|
||||||
// Input enable/disable resolve button
|
// Input enable/disable resolve button
|
||||||
document.querySelectorAll('.folder-input').forEach(input => {
|
document.querySelectorAll('.folder-input').forEach(input => {
|
||||||
@@ -650,53 +832,150 @@
|
|||||||
btn.addEventListener('click', async (e) => {
|
btn.addEventListener('click', async (e) => {
|
||||||
const folder = e.target.dataset.folder || e.target.closest('button').dataset.folder;
|
const folder = e.target.dataset.folder || e.target.closest('button').dataset.folder;
|
||||||
const item = document.querySelector(`.folder-item[data-folder="${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.classList.add('searching');
|
||||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Searching...';
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Searching...';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await reSearchFolder(folder);
|
const result = await reSearchFolder(folder, customQuery);
|
||||||
// Update suggestions in place
|
// Update suggestions in place
|
||||||
const suggestionsEl = item.querySelector('.suggestion-list');
|
const suggestionsEl = item.querySelector('.suggestion-list');
|
||||||
if (result.search_suggestions && result.search_suggestions.length > 0) {
|
if (result.search_suggestions && result.search_suggestions.length > 0) {
|
||||||
suggestionsEl.innerHTML = result.search_suggestions.map(s => `
|
suggestionsEl.innerHTML = result.search_suggestions.map(s => `
|
||||||
<div class="suggestion-item">
|
<div class="suggestion-item">
|
||||||
<i class="fas fa-link"></i>
|
<i class="fas fa-hand-pointer"></i>
|
||||||
<a href="${s.link}" class="suggestion-link" target="_blank">${s.title}</a>
|
<button class="suggestion-btn" data-provider-key="${s.link || s.provider_key || s.key || ''}" data-folder="${folder}">
|
||||||
|
${s.name || s.title} <span class="key-label">(${s.link || ''})</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
} else {
|
} else {
|
||||||
suggestionsEl.innerHTML = '<div class="no-suggestions"><i class="fas fa-info-circle"></i> No suggestions found</div>';
|
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) {
|
} catch (err) {
|
||||||
showToast('Search failed', 'error');
|
showToast('Search failed', 'error');
|
||||||
|
btn.classList.remove('searching');
|
||||||
|
btn.innerHTML = '<i class="fas fa-search"></i> Search Again';
|
||||||
} finally {
|
} finally {
|
||||||
btn.classList.remove('searching');
|
btn.classList.remove('searching');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Suggestion link click - populate input and resolve
|
||||||
|
attachSuggestionLinkEvents();
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkEmptyList() {
|
function checkEmptyList() {
|
||||||
const listEl = document.getElementById('folder-list');
|
const listEl = document.getElementById('folder-list');
|
||||||
const emptyEl = document.getElementById('empty-state');
|
const emptyEl = document.getElementById('empty-state');
|
||||||
const skipLink = document.getElementById('skip-link');
|
const skipLink = document.getElementById('skip-link');
|
||||||
|
const doneBtn = document.getElementById('done-btn');
|
||||||
|
|
||||||
if (listEl.children.length === 0) {
|
if (listEl.children.length === 0) {
|
||||||
listEl.style.display = 'none';
|
listEl.style.display = 'none';
|
||||||
emptyEl.style.display = 'block';
|
emptyEl.style.display = 'block';
|
||||||
skipLink.style.display = 'block';
|
skipLink.style.display = 'block';
|
||||||
showToast('All series configured!', 'success');
|
showToast('All series configured!', 'success');
|
||||||
setTimeout(() => { window.location.href = '/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
|
// Init
|
||||||
(async function 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();
|
const folders = await fetchUnresolved();
|
||||||
if (folders !== null) {
|
if (folders !== null) {
|
||||||
renderFolders(folders);
|
renderFolders(folders);
|
||||||
|
if (folders.length > 0) {
|
||||||
|
showDoneButton();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</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
|
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:
|
class TestEndToEndSync:
|
||||||
"""End-to-end tests for the sync functionality."""
|
"""End-to-end tests for the sync functionality."""
|
||||||
|
|
||||||
|
|||||||
@@ -13,11 +13,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.server.services.anime_service import (
|
from src.server.services.anime_service import AnimeService, AnimeServiceError
|
||||||
AnimeService,
|
|
||||||
AnimeServiceError,
|
|
||||||
sync_legacy_series_to_db,
|
|
||||||
)
|
|
||||||
from src.server.services.progress_service import ProgressService
|
from src.server.services.progress_service import ProgressService
|
||||||
|
|
||||||
|
|
||||||
@@ -1302,142 +1298,3 @@ class TestGetNFOStatisticsSelfManaged:
|
|||||||
assert result["with_tmdb_id"] == 40
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_sync_anime_folders_without_progress(self):
|
async def test_sync_anime_folders_without_progress(self):
|
||||||
"""Test syncing anime folders without progress service."""
|
"""Test syncing anime folders without progress service."""
|
||||||
with patch('src.server.services.initialization_service.sync_legacy_series_to_db',
|
with patch('src.server.services.initialization_service.settings') as mock_settings, \
|
||||||
new_callable=AsyncMock, return_value=42) as mock_sync:
|
patch('src.server.services.initialization_service.os.path.isdir', return_value=True):
|
||||||
|
mock_settings.anime_directory = "/path/to/anime"
|
||||||
result = await _sync_anime_folders()
|
result = await _sync_anime_folders()
|
||||||
|
|
||||||
assert result == 42
|
assert result == 0
|
||||||
mock_sync.assert_called_once()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_sync_anime_folders_with_progress(self):
|
async def test_sync_anime_folders_with_progress(self):
|
||||||
"""Test syncing anime folders with progress updates."""
|
"""Test syncing anime folders with progress updates."""
|
||||||
mock_progress = AsyncMock()
|
mock_progress = AsyncMock()
|
||||||
|
|
||||||
with patch('src.server.services.initialization_service.sync_legacy_series_to_db',
|
with patch('src.server.services.initialization_service.settings') as mock_settings, \
|
||||||
new_callable=AsyncMock, return_value=10) as mock_sync:
|
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)
|
result = await _sync_anime_folders(progress_service=mock_progress)
|
||||||
|
|
||||||
assert result == 10
|
assert result == 0
|
||||||
# Verify progress updates were called
|
# Verify progress updates were called
|
||||||
assert mock_progress.update_progress.call_count == 2
|
assert mock_progress.update_progress.call_count == 2
|
||||||
mock_progress.update_progress.assert_any_call(
|
mock_progress.update_progress.assert_any_call(
|
||||||
@@ -188,7 +189,7 @@ class TestSyncAnimeFolders:
|
|||||||
mock_progress.update_progress.assert_any_call(
|
mock_progress.update_progress.assert_any_call(
|
||||||
progress_id="series_sync",
|
progress_id="series_sync",
|
||||||
current=75,
|
current=75,
|
||||||
message="Synced 10 series from data files",
|
message="Series loaded directly from database",
|
||||||
metadata={"step_id": "series_sync"}
|
metadata={"step_id": "series_sync"}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -320,6 +321,8 @@ class TestPerformInitialSetup:
|
|||||||
patch('src.server.services.initialization_service._mark_initial_scan_completed',
|
patch('src.server.services.initialization_service._mark_initial_scan_completed',
|
||||||
new_callable=AsyncMock), \
|
new_callable=AsyncMock), \
|
||||||
patch('src.server.services.initialization_service._load_series_into_memory',
|
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):
|
new_callable=AsyncMock):
|
||||||
result = await perform_initial_setup()
|
result = await perform_initial_setup()
|
||||||
|
|
||||||
@@ -339,6 +342,8 @@ class TestPerformInitialSetup:
|
|||||||
patch('src.server.services.initialization_service._mark_initial_scan_completed',
|
patch('src.server.services.initialization_service._mark_initial_scan_completed',
|
||||||
new_callable=AsyncMock), \
|
new_callable=AsyncMock), \
|
||||||
patch('src.server.services.initialization_service._load_series_into_memory',
|
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):
|
new_callable=AsyncMock):
|
||||||
result = await perform_initial_setup(progress_service=mock_progress)
|
result = await perform_initial_setup(progress_service=mock_progress)
|
||||||
|
|
||||||
@@ -527,6 +532,8 @@ class TestPerformNFOScan:
|
|||||||
new_callable=AsyncMock, return_value=False), \
|
new_callable=AsyncMock, return_value=False), \
|
||||||
patch('src.server.services.initialization_service._is_nfo_scan_configured',
|
patch('src.server.services.initialization_service._is_nfo_scan_configured',
|
||||||
new_callable=AsyncMock, return_value=True), \
|
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',
|
patch('src.server.services.initialization_service._execute_nfo_scan',
|
||||||
new_callable=AsyncMock), \
|
new_callable=AsyncMock), \
|
||||||
patch('src.server.services.initialization_service._mark_nfo_scan_completed',
|
patch('src.server.services.initialization_service._mark_nfo_scan_completed',
|
||||||
@@ -544,6 +551,8 @@ class TestPerformNFOScan:
|
|||||||
new_callable=AsyncMock, return_value=False), \
|
new_callable=AsyncMock, return_value=False), \
|
||||||
patch('src.server.services.initialization_service._is_nfo_scan_configured',
|
patch('src.server.services.initialization_service._is_nfo_scan_configured',
|
||||||
new_callable=AsyncMock, return_value=True), \
|
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',
|
patch('src.server.services.initialization_service._execute_nfo_scan',
|
||||||
new_callable=AsyncMock), \
|
new_callable=AsyncMock), \
|
||||||
patch('src.server.services.initialization_service._mark_nfo_scan_completed',
|
patch('src.server.services.initialization_service._mark_nfo_scan_completed',
|
||||||
|
|||||||
Reference in New Issue
Block a user