Compare commits

...

22 Commits

Author SHA1 Message Date
576d9f7a7b chore: bump version 2026-06-06 23:09:47 +02:00
af93daeddc fix: allow unresolved page access during setup flow
- Remove premature auth redirect in unresolved.html fetchUnresolved()
- Add /api/setup/ to middleware exempt paths
- Unresolved page now loads without auth token (part of setup flow)
- Only redirect to login on 401 (expired token) or when all folders resolved
2026-06-06 23:08:54 +02:00
a05795bb35 chore: bump version 2026-06-06 22:47:56 +02:00
d22df947e4 feat(setup): redirect to /loading instead of / after setup flow
- loading.html: check for unresolved folders before redirecting, go to /login if none
- unresolved.html: redirect to /loading instead of / after skip/timeout
- add docs/NAVIGATION.md navigation flow documentation
2026-06-06 22:46:02 +02:00
8bb8c6aa64 chore: bump version 2026-06-06 21:53:57 +02:00
109d3c8ac9 fix: streamline initialization flow after setup
- Remove nfo_scan and media_scan from loading page steps (no longer shown in UI)
- Remove perform_nfo_scan_if_needed calls from fastapi_app and auth.py
- Always redirect to /setup/unresolved after initialization completes
  instead of conditionally checking for unresolved folders
- Fix middleware to allow access to /loading page - let it handle
  its own redirect flow via WebSocket events

This ensures users always reach the unresolved folders page after
initial setup to manually configure any unmatched anime series.
2026-06-06 21:33:41 +02:00
6a934db8ac chore: bump version 2026-06-06 20:38:21 +02:00
ac7302b1dd fix: add /setup/unresolved to exempt paths and improve error handling
- Add /setup/unresolved to EXEMPT_PATHS to allow access after initial setup
- Handle 401 Unauthorized response in loading page (clear invalid token)
- Add console.log statements for debugging setup flow issues
2026-06-06 20:37:11 +02:00
ac5ee3bb27 chore: bump version 2026-06-06 20:08:05 +02:00
a9084202e3 fixed missing import 2026-06-06 20:07:45 +02:00
be9f2a4c0c chore: bump version 2026-06-06 19:40:21 +02:00
53fe09351f fix: prevent duplicate series when same anime key exists in different folder
- Add check for existing series by key in SetupService.run to skip duplicates
- Fix Path construction in initialization_service.py cleanup function
- Update unit tests to mock get_by_key and get_series_app
2026-06-06 19:39:32 +02:00
dc7d9ee5f7 chore: bump version 2026-06-05 22:34:09 +02:00
da3cae2812 fix: redirect to unresolved page after setup if needed
After initial setup completes, the loading page now checks for unresolved
folders before showing completion. If any unresolved exist, redirects
to /setup/unresolved so users can manually resolve provider keys.

Without this fix, users with unresolved folders only saw the loading
screen with no way to access the unresolved page.
2026-06-05 22:33:40 +02:00
2876cef24b chore: bump version 2026-06-05 22:10:56 +02:00
6a402623c4 feat(setup): add unresolved folders GUI for manual series resolution
- Add /setup/unresolved page for manual provider key resolution
- Integrate unresolved check into setup wizard flow
- Auto-redirect to unresolved page if folders need resolution

After initial setup scan, folders that couldn't be auto-resolved
are now tracked and can be resolved manually via the GUI.

Endpoints:
- GET /api/setup/unresolved - list unresolved folders
- POST /api/setup/unresolved/{folder}/resolve - resolve with provider key
- POST /api/setup/unresolved/{folder}/search - re-search for suggestions
- DELETE /api/setup/unresolved/{folder} - delete without adding
2026-06-05 22:06:55 +02:00
ebfbec1225 fix: resolve series key from direct link format
When the search provider returns a link like 'shinobi-no-ittoki' instead of
'/anime/stream/shinobi-no-ittoki', the key was not being extracted and all
folders were marked as unresolved.

Now handles both link formats:
- URL format: '/anime/stream/key' -> extract key
- Direct format: 'key' -> use as-is

Also added debug logging for both resolution paths to aid troubleshooting.
2026-06-05 21:21:39 +02:00
01e4dec8d7 chore: bump version 2026-06-05 21:08:23 +02:00
ecef21eec4 feat(setup): track unresolved folders for manual key resolution
When SetupService cannot auto-resolve a provider key for an anime folder,
the folder is now tracked in the new 'unresolved_folders' table instead of
being silently skipped. Users can then resolve these via the new API:

- GET /api/setup/unresolved - list unresolved folders with search suggestions
- POST /api/setup/unresolved/{folder}/resolve - provide key to resolve folder

The SetupService.run() now:
- Tracks unresolved folders instead of skipping them
- Re-creates AnimeSeries for previously unresolved folders that are now resolved
- Includes unresolved count in logs

New files:
- src/server/api/setup_endpoints.py - API endpoints for unresolved management
- tests/unit/test_unresolved_folder_service.py - service and model tests

Modified:
- src/server/database/models.py - add UnresolvedFolder model
- src/server/database/service.py - add UnresolvedFolderService
- src/server/services/setup_service.py - track unresolved folders
- src/server/fastapi_app.py - include setup router
2026-06-05 21:07:52 +02:00
d9738ffb78 docs: add fuzzy series key resolution to features.md
- Add Folder Management section with fuzzy title matching feature
- Tolerates title variations like (TV), (OVA), (Movie) suffixes during library setup
2026-06-05 20:49:27 +02:00
6aec2a1733 docs: add SetupService to architecture, update changelog and testing docs
- ARCHITECTURE.md: add setup_service.py to services list
- CHANGELOG.md: add Unreleased section with folder scan key resolution fix
- TESTING.md: add SetupService testing section with example tests
2026-06-05 20:42:26 +02:00
84487d7571 fix: use fuzzy title matching in _resolve_key_via_search
- Add _normalize_title() to strip anime suffixes (TV, OVA, Movie, etc.)
- Add _titles_match() using SequenceMatcher for similarity (threshold 0.85)
- Replace exact string match with fuzzy match to fix skipped folders
- Add debug logging for title mismatches and multiple results
- Set LOG_LEVEL=DEBUG in docker-compose.yml
2026-06-05 20:37:06 +02:00
22 changed files with 2054 additions and 110 deletions

View File

@@ -1 +1 @@
v1.4.1 v1.4.10

View File

@@ -38,6 +38,7 @@ services:
condition: service_healthy condition: service_healthy
environment: environment:
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
- LOG_LEVEL=DEBUG
volumes: volumes:
- app-data:/app/data - app-data:/app/data
- app-logs:/app/logs - app-logs:/app/logs

View File

@@ -81,6 +81,7 @@ src/server/
| +-- websocket_service.py# WebSocket broadcasting | +-- websocket_service.py# WebSocket broadcasting
| +-- queue_repository.py # Database persistence | +-- queue_repository.py # Database persistence
| +-- nfo_service.py # NFO metadata management | +-- nfo_service.py # NFO metadata management
| +-- setup_service.py # Series key resolution from folder names
| +-- folder_scan_service.py # Daily folder maintenance scan | +-- folder_scan_service.py # Daily folder maintenance scan
+-- models/ # Pydantic models +-- models/ # Pydantic models
| +-- auth.py # Auth request/response models | +-- auth.py # Auth request/response models

View File

@@ -37,6 +37,17 @@ This changelog follows [Keep a Changelog](https://keepachangelog.com/) principle
--- ---
## [Unreleased] - 2026-06-05
### Fixed
- **Folder scan series key resolution**: Fixed "Could not resolve series key for folder, skipping" warnings during library setup. `_resolve_key_via_search()` now uses fuzzy title matching instead of exact string comparison.
- Added `_normalize_title()` to strip anime suffixes: `(TV)`, `(Anime)`, `(OAD)`, `(OVA)`, `(Special)`, `(Movie)`, `(Spin-Off)`
- Added `_titles_match()` using `difflib.SequenceMatcher` with 0.85 similarity threshold for tolerance of minor title variations
- Added debug logging for title mismatches and multiple search results
---
## [1.3.1] - 2026-02-22 ## [1.3.1] - 2026-02-22
### Added ### Added

174
docs/NAVIGATION.md Normal file
View File

@@ -0,0 +1,174 @@
# Navigation & Redirect Logic
This document describes the setup flow navigation, covering how users progress from initial setup through to the main application.
## Overview
The application uses a middleware-based redirect system to ensure users complete setup before accessing the main app. The flow involves multiple pages handling setup completion, unresolved folder detection, and initialization.
## Setup Flow
```
┌─────────────────────────────────────────────────────────────────────┐
│ SETUP FLOW │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ /setup ──► /loading ──┬──► /setup/unresolved ──► /loading │
│ │ │ │ │ │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ (first time) (WebSocket) (has folders) (all resolved) │
│ │ │ │
│ ▼ │ │
│ /login ◄───────────────────┴──────────────────────┤
│ │
└─────────────────────────────────────────────────────────────────────┘
```
## Middleware: SetupRedirectMiddleware
**File:** `src/server/middleware/setup_redirect.py`
The middleware intercepts all requests and redirects to `/setup` if:
- No master password is configured
- Configuration file is missing or invalid
### Exempt Paths (always accessible)
| Path | Purpose |
|------|---------|
| `/setup` | Initial setup page |
| `/setup/unresolved` | Unresolved folder resolution |
| `/loading` | Initialization progress page |
| `/login` | Authentication |
| `/api/auth/*` | Auth endpoints |
| `/api/config/*` | Config API |
| `/api/health` | Health check |
| `/static/*` | Static assets |
### Middleware Logic
1. **Setup incomplete** → Redirect to `/setup`
2. **Setup complete, accessing `/setup`** → Redirect to `/loading`
3. **Setup complete, accessing `/loading`** → Allow access (page handles its own redirect)
4. **API requests during setup** → Return 503 with `setup_url`
## Pages
### 1. Setup Page (`/setup`)
**File:** `src/server/web/templates/setup.html`
Handles initial configuration:
- Master password creation
- Anime directory selection
- Database initialization
**Post-completion flow:**
- Redirects to `/loading` to begin initialization
### 2. Loading Page (`/loading`)
**File:** `src/server/web/templates/loading.html`
Shows initialization progress via WebSocket:
- Series scanning
- Database population
- Logo/image loading
**Post-initialization flow:**
```javascript
async function checkUnresolvedAndProceed() {
// Fetch unresolved folders via API
const res = await fetch('/api/setup/unresolved', {
headers: { 'Authorization': `Bearer ${token}` }
});
const folders = await res.json();
if (folders.length > 0) {
// Has unresolved folders → go to resolution page
window.location.href = '/setup/unresolved';
} else {
// No unresolved folders → go to login
window.location.href = '/login';
}
}
```
### 3. Unresolved Folders Page (`/setup/unresolved`)
**File:** `src/server/web/templates/unresolved.html`
Allows manual resolution of folders that couldn't be auto-matched:
- Shows list of unresolved folders
- Provides search suggestions
- Input field for entering provider key
- Resolve/delete actions
**Post-resolution flow:**
```javascript
function checkEmptyList() {
if (listEl.children.length === 0) {
// All folders resolved → return to loading
setTimeout(() => { window.location.href = '/loading'; }, 2000);
}
}
```
### 4. Login Page (`/login`)
**File:** `src/server/web/templates/login.html`
Authentication page. After successful login → redirect to `/` (main app).
## API Endpoints
### Unresolved Folders API
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/setup/unresolved` | List all unresolved folders |
| `GET` | `/api/setup/unresolved/{folder_name}` | Get specific folder details |
| `POST` | `/api/setup/unresolved/{folder_name}/resolve` | Resolve with provider key |
| `POST` | `/api/setup/unresolved/{folder_name}/search` | Re-search for matches |
| `DELETE` | `/api/setup/unresolved/{folder_name}` | Remove folder from tracking |
### Auth API
| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/api/auth/setup` | Create master password |
| `POST` | `/api/auth/login` | Authenticate |
| `POST` | `/api/auth/logout` | End session |
## Key Files
| File | Purpose |
|------|---------|
| `src/server/middleware/setup_redirect.py` | Redirect middleware |
| `src/server/controllers/page_controller.py` | Page route handlers |
| `src/server/web/templates/setup.html` | Setup template |
| `src/server/web/templates/loading.html` | Loading template |
| `src/server/web/templates/unresolved.html` | Unresolved folders template |
| `src/server/api/setup_endpoints.py` | Unresolved folders API |
| `src/server/database/service.py` | UnresolvedFolderService |
## Common Issues
### Redirect Loop
**Symptom:** Browser keeps redirecting between pages.
**Causes:**
1. `loading.html` always redirected to `/setup/unresolved` without checking if any exist
2. `unresolved.html` redirected to `/` which middleware redirected back to `/login`
**Fix:** See the navigation logic updates in loading.html and unresolved.html.
### Can't Access Unresolved Page After Setup
**Symptom:** Middleware redirects to `/login` instead of allowing access to `/setup/unresolved`.
**Cause:** `/setup/unresolved` is in the exempt paths but the request may not be reaching it due to completion check timing.
**Fix:** The middleware allows access to `/loading` which handles the redirect to `/setup/unresolved` after initialization.

View File

@@ -73,40 +73,31 @@ from src.server.models.download import DownloadItem, EpisodeIdentifier
class MockQueueRepository: class MockQueueRepository:
def __init__(self): def __init__(self):
self._items: Dict[str, DownloadItem] = {} self._items: Dict[str, DownloadItem] = {}
async def save_item(self, item: DownloadItem) -> DownloadItem:
self._items[item.id] = item
return item
async def get_item(self, item_id: str) -> Optional[DownloadItem]:
return self._items.get(item_id)
async def get_all_items(self) -> List[DownloadItem]:
return list(self._items.values())
async def set_error(self, item_id: str, error: str) -> bool:
if item_id in self._items:
self._items[item_id].error = error
return True
return False
async def delete_item(self, item_id: str) -> bool:
if item_id in self._items:
del self._items[item_id]
return True
return False
async def clear_all(self) -> int:
count = len(self._items)
self._items.clear()
return count
``` ```
**Key points:** ### Testing SetupService
- The mock uses in-memory storage, no database required
- All async methods are implemented (even if just pass-through) SetupService handles series key resolution from folder names during library setup. Test file: `tests/unit/test_setup_service.py`.
- `save_item` uses `item.id` as key (must be set before calling)
- Suitable for unit tests only (no persistence) Key methods tested:
- `_extract_year_from_folder_name()` — parses `(YYYY)` suffix
- `_extract_title_from_folder_name()` — strips year suffix
- `_resolve_key_via_search()` — resolves provider key via fuzzy title matching
```python
@pytest.mark.asyncio
async def test_returns_key_when_single_exact_match(self):
"""Search returns 1 result with same name → returns key."""
mock_series_app = AsyncMock()
mock_series_app.search.return_value = [
{'title': 'Attack on Titan', 'link': '/anime/stream/attack-on-titan'}
]
with patch('src.server.services.setup_service.get_series_app', return_value=mock_series_app):
result = await SetupService._resolve_key_via_search("Attack on Titan")
assert result == 'attack-on-titan'
```
### Mocking aiohttp Sessions ### Mocking aiohttp Sessions

View File

@@ -107,6 +107,10 @@ The application now features a comprehensive configuration system that allows us
- **Progress Tracking**: Live progress updates for downloads and scans - **Progress Tracking**: Live progress updates for downloads and scans
- **System Notifications**: Real-time system messages and alerts - **System Notifications**: Real-time system messages and alerts
## Folder Management
- **Fuzzy Series Key Resolution**: Automatic series key resolution from folder names using fuzzy title matching — tolerates title variations like `(TV)`, `(OVA)`, `(Movie)` suffixes and uses similarity matching to resolve provider keys during library setup
## Core Functionality Overview ## Core Functionality Overview
The web application provides a complete interface for managing anime downloads with user-friendly pages for configuration, library management, search capabilities, and download monitoring. All operations are tracked in real-time with comprehensive progress reporting and error handling. The web application provides a complete interface for managing anime downloads with user-friendly pages for configuration, library management, search capabilities, and download monitoring. All operations are tracked in real-time with comprehensive progress reporting and error handling.

View File

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

View File

@@ -1,6 +1,7 @@
"""Authentication API endpoints for Aniworld.""" """Authentication API endpoints for Aniworld."""
from typing import Optional from typing import Optional
import structlog
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from fastapi import status as http_status from fastapi import status as http_status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
@@ -16,6 +17,8 @@ from src.server.models.config import AppConfig
from src.server.services.auth_service import AuthError, LockedOutError, auth_service from src.server.services.auth_service import AuthError, LockedOutError, auth_service
from src.server.services.config_service import get_config_service from src.server.services.config_service import get_config_service
logger = structlog.get_logger(__name__)
# NOTE: import dependencies (optional_auth, security) lazily inside handlers # NOTE: import dependencies (optional_auth, security) lazily inside handlers
# to avoid importing heavyweight modules (e.g. sqlalchemy) at import time. # to avoid importing heavyweight modules (e.g. sqlalchemy) at import time.
@@ -144,10 +147,7 @@ async def setup_auth(req: SetupRequest):
# Trigger initialization in background task # Trigger initialization in background task
import asyncio import asyncio
from src.server.services.initialization_service import ( from src.server.services.initialization_service import perform_initial_setup
perform_initial_setup,
perform_nfo_scan_if_needed,
)
from src.server.services.progress_service import get_progress_service from src.server.services.progress_service import get_progress_service
progress_service = get_progress_service() progress_service = get_progress_service()
@@ -158,9 +158,6 @@ async def setup_auth(req: SetupRequest):
# Perform the initial series sync and mark as completed # Perform the initial series sync and mark as completed
await perform_initial_setup(progress_service) await perform_initial_setup(progress_service)
# Perform NFO scan if configured
await perform_nfo_scan_if_needed(progress_service)
# Start scheduler if anime_directory is now set # Start scheduler if anime_directory is now set
try: try:
from src.server.services.scheduler.scheduler_service import ( from src.server.services.scheduler.scheduler_service import (

View File

@@ -0,0 +1,313 @@
"""API endpoints for setup and unresolved folder management.
Provides endpoints to:
- List unresolved folders that couldn't be auto-resolved during setup
- Get suggestions/search results for an unresolved folder
- Resolve an unresolved folder by providing a provider key
"""
import json
import logging
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from src.server.database.connection import get_db_session
from src.server.database.service import AnimeSeriesService, UnresolvedFolderService
from src.server.utils.dependencies import (
get_database_session,
get_series_app,
require_auth,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/setup", tags=["setup"])
class UnresolvedFolderResponse(BaseModel):
"""Response model for an unresolved folder."""
folder_name: str = Field(..., description="Original filesystem folder name")
title: str = Field(..., description="Extracted title from folder name")
year: Optional[int] = Field(None, description="Extracted release year")
search_attempts: int = Field(..., description="Number of search attempts made")
search_suggestions: list[dict[str, Any]] = Field(
default_factory=list,
description="Cached search results for potential matches"
)
class Config:
from_attributes = True
class ResolveFolderRequest(BaseModel):
"""Request model for resolving an unresolved folder."""
provider_key: str = Field(
...,
min_length=1,
max_length=255,
description="Provider key to associate with this folder"
)
class ResolveFolderResponse(BaseModel):
"""Response model for resolving an unresolved folder."""
status: str = Field(..., description="Operation status")
message: str = Field(..., description="Human-readable message")
folder_name: str = Field(..., description="Folder name that was resolved")
key: str = Field(..., description="Provider key that was used")
series_id: int = Field(..., description="Database ID of the created series")
@router.get("/unresolved", response_model=list[UnresolvedFolderResponse])
async def list_unresolved_folders(
db=Depends(get_database_session),
) -> list[UnresolvedFolderResponse]:
"""List all unresolved folders that need manual key resolution.
Returns folders that couldn't be auto-resolved during setup,
including cached search suggestions when available.
Returns:
List of UnresolvedFolderResponse objects
"""
folders = await UnresolvedFolderService.get_all_unresolved(db)
result = []
for folder in folders:
suggestions = []
if folder.last_search_result:
try:
suggestions = json.loads(folder.last_search_result)
except json.JSONDecodeError:
logger.warning(
"Failed to parse search result for folder: %s",
folder.folder_name
)
result.append(UnresolvedFolderResponse(
folder_name=folder.folder_name,
title=folder.title,
year=folder.year,
search_attempts=folder.search_attempts,
search_suggestions=suggestions,
))
return result
@router.get("/unresolved/{folder_name}", response_model=UnresolvedFolderResponse)
async def get_unresolved_folder(
folder_name: str,
db=Depends(get_database_session),
) -> UnresolvedFolderResponse:
"""Get details for a specific unresolved folder.
Args:
folder_name: URL-encoded folder name to look up
Returns:
UnresolvedFolderResponse for the specified folder
Raises:
HTTPException: 404 if folder not found or already resolved
"""
folder = await UnresolvedFolderService.get_by_folder_name(db, folder_name)
if not folder:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Unresolved folder not found: {folder_name}"
)
if folder.is_resolved:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Folder already resolved: {folder_name}"
)
suggestions = []
if folder.last_search_result:
try:
suggestions = json.loads(folder.last_search_result)
except json.JSONDecodeError:
pass
return UnresolvedFolderResponse(
folder_name=folder.folder_name,
title=folder.title,
year=folder.year,
search_attempts=folder.search_attempts,
search_suggestions=suggestions,
)
@router.post("/unresolved/{folder_name}/resolve", response_model=ResolveFolderResponse)
async def resolve_unresolved_folder(
folder_name: str,
request: ResolveFolderRequest,
db=Depends(get_database_session),
) -> ResolveFolderResponse:
"""Resolve an unresolved folder by providing the correct provider key.
This endpoint:
1. Validates the provider key format
2. Updates the UnresolvedFolder record as resolved
3. Creates the AnimeSeries record in the database
4. Returns the created series information
Args:
folder_name: URL-encoded folder name to resolve
request: ResolveFolderRequest with the provider_key
Returns:
ResolveFolderResponse with created series details
Raises:
HTTPException: 404 if folder not found
HTTPException: 400 if key is invalid or series already exists
"""
# Check if folder exists and is unresolved
unresolved = await UnresolvedFolderService.get_by_folder_name(db, folder_name)
if not unresolved:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Unresolved folder not found: {folder_name}"
)
if unresolved.is_resolved:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Folder already resolved: {folder_name}"
)
# Check if a series with this key already exists
existing_series = await AnimeSeriesService.get_by_key(db, request.provider_key)
if existing_series:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Series with key '{request.provider_key}' already exists"
)
# Mark as resolved
await UnresolvedFolderService.resolve(db, folder_name, request.provider_key)
# Create the AnimeSeries record
series = await AnimeSeriesService.create(
db=db,
key=request.provider_key,
name=unresolved.title,
site="https://aniworld.to",
folder=folder_name,
year=unresolved.year,
loading_status="pending",
episodes_loaded=False,
logo_loaded=False,
images_loaded=False,
)
logger.info(
"Resolved unresolved folder via API: %s -> key=%s (series_id=%d)",
folder_name, request.provider_key, series.id
)
return ResolveFolderResponse(
status="success",
message=f"Successfully resolved and added series: {unresolved.title}",
folder_name=folder_name,
key=request.provider_key,
series_id=series.id,
)
@router.post("/unresolved/{folder_name}/search", response_model=UnresolvedFolderResponse)
async def search_unresolved_folder(
folder_name: str,
db=Depends(get_database_session),
) -> UnresolvedFolderResponse:
"""Re-search for a specific unresolved folder to get fresh suggestions.
Performs a new search using the folder's title and caches the results.
Args:
folder_name: URL-encoded folder name to search for
Returns:
UnresolvedFolderResponse with updated search suggestions
Raises:
HTTPException: 404 if folder not found or already resolved
"""
from pathlib import Path
folder = await UnresolvedFolderService.get_by_folder_name(db, folder_name)
if not folder:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Unresolved folder not found: {folder_name}"
)
if folder.is_resolved:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Folder already resolved: {folder_name}"
)
# Perform search
series_app = get_series_app()
try:
results = await series_app.search(folder.title)
search_result_json = json.dumps(results) if results else "[]"
except Exception as e:
logger.warning(
"Search failed for unresolved folder: %s, error: %s",
folder_name, str(e)
)
search_result_json = "[]"
results = []
# Update the folder with new search results
await UnresolvedFolderService.update_search_result(db, folder_name, search_result_json)
return UnresolvedFolderResponse(
folder_name=folder.folder_name,
title=folder.title,
year=folder.year,
search_attempts=folder.search_attempts,
search_suggestions=results,
)
@router.delete("/unresolved/{folder_name}")
async def delete_unresolved_folder(
folder_name: str,
db=Depends(get_database_session),
) -> dict[str, str]:
"""Delete an unresolved folder tracking record.
Use this when you've manually added the series outside of this flow
(e.g., via POST /api/anime/add) to clean up the unresolved tracker.
Args:
folder_name: URL-encoded folder name to delete
Returns:
Dict with status message
Raises:
HTTPException: 404 if folder not found
"""
deleted = await UnresolvedFolderService.delete(db, folder_name)
if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Unresolved folder not found: {folder_name}"
)
return {"status": "success", "message": f"Deleted unresolved folder: {folder_name}"}

View File

@@ -59,3 +59,13 @@ async def loading_page(request: Request):
request, request,
title="Initializing - Aniworld" title="Initializing - Aniworld"
) )
@router.get("/setup/unresolved", response_class=HTMLResponse)
async def unresolved_page(request: Request):
"""Serve the unresolved folders resolution page."""
return render_template(
"unresolved.html",
request,
title="Resolve Series - Aniworld"
)

View File

@@ -626,6 +626,96 @@ class UserSession(Base, TimestampMixin):
self.is_active = False self.is_active = False
class UnresolvedFolder(Base, TimestampMixin):
"""SQLAlchemy model for folders that couldn't be resolved during setup.
Tracks anime folders whose provider key couldn't be auto-resolved
during the initial setup scan. Users can provide the correct key
via the API to complete the series registration.
Attributes:
id: Primary key
folder_name: Original filesystem folder name
title: Extracted title from folder name
year: Extracted release year (optional)
provider_key: User-provided provider key to resolve this folder
search_attempts: Number of auto-search attempts made
last_search_result: Cached search results (JSON string) for UI suggestions
resolved_at: Timestamp when provider_key was provided
created_at: Creation timestamp (from TimestampMixin)
updated_at: Last update timestamp (from TimestampMixin)
"""
__tablename__ = "unresolved_folders"
# Primary key
id: Mapped[int] = mapped_column(
Integer, primary_key=True, autoincrement=True
)
# Folder metadata
folder_name: Mapped[str] = mapped_column(
String(1000), unique=True, nullable=False, index=True,
doc="Original filesystem folder name"
)
title: Mapped[str] = mapped_column(
String(500), nullable=False,
doc="Extracted title from folder name"
)
year: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True,
doc="Extracted release year"
)
# Resolution data
provider_key: Mapped[Optional[str]] = mapped_column(
String(255), nullable=True,
doc="User-provided provider key to resolve this folder"
)
search_attempts: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default="0",
doc="Number of auto-search attempts made"
)
last_search_result: Mapped[Optional[str]] = mapped_column(
Text, nullable=True,
doc="Cached search results (JSON) for UI display"
)
resolved_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True,
doc="Timestamp when this folder was resolved"
)
@validates('folder_name')
def validate_folder_name(self, key: str, value: str) -> str:
"""Validate folder name is not empty."""
if not value or not value.strip():
raise ValueError("Folder name cannot be empty")
if len(value) > 1000:
raise ValueError("Folder name must be 1000 characters or less")
return value.strip()
@validates('title')
def validate_title(self, key: str, value: str) -> str:
"""Validate title is not empty."""
if not value or not value.strip():
raise ValueError("Title cannot be empty")
if len(value) > 500:
raise ValueError("Title must be 500 characters or less")
return value.strip()
@property
def is_resolved(self) -> bool:
"""Check if this folder has been resolved with a provider key."""
return self.provider_key is not None and self.resolved_at is not None
def __repr__(self) -> str:
return (
f"<UnresolvedFolder(id={self.id}, "
f"folder_name='{self.folder_name}', "
f"title='{self.title}', "
f"resolved={self.is_resolved})>"
)
class SystemSettings(Base, TimestampMixin): class SystemSettings(Base, TimestampMixin):
"""SQLAlchemy model for system-wide settings and state. """SQLAlchemy model for system-wide settings and state.

View File

@@ -34,6 +34,7 @@ from src.server.database.models import (
AnimeSeries, AnimeSeries,
DownloadQueueItem, DownloadQueueItem,
Episode, Episode,
UnresolvedFolder,
UserSession, UserSession,
) )
@@ -1364,3 +1365,176 @@ class UserSessionService:
return new_session return new_session
# ============================================================================
# Unresolved Folder Service
# ============================================================================
class UnresolvedFolderService:
"""Service for tracking and resolving folders that couldn't be auto-resolved.
During initial setup, some folders may not resolve to a provider key
(no search match or multiple ambiguous matches). These are tracked as
UnresolvedFolder records and can later be resolved by the user providing
the correct provider key.
"""
@staticmethod
async def create(
db: AsyncSession,
folder_name: str,
title: str,
year: int | None = None,
search_attempts: int = 1,
last_search_result: str | None = None,
) -> UnresolvedFolder:
"""Create a new unresolved folder tracking record.
Args:
db: Database session
folder_name: Original filesystem folder name
title: Extracted title from folder name
year: Extracted release year (optional)
search_attempts: Number of search attempts made (default: 1)
last_search_result: JSON string of search results for UI (optional)
Returns:
Created UnresolvedFolder instance
"""
folder = UnresolvedFolder(
folder_name=folder_name,
title=title,
year=year,
search_attempts=search_attempts,
last_search_result=last_search_result,
)
db.add(folder)
await db.flush()
await db.refresh(folder)
logger.info(
"Created unresolved folder tracking: %s (title=%s, year=%s)",
folder_name, title, year
)
return folder
@staticmethod
async def get_by_folder_name(
db: AsyncSession,
folder_name: str,
) -> Optional[UnresolvedFolder]:
"""Get unresolved folder by folder name.
Args:
db: Database session
folder_name: Filesystem folder name to look up
Returns:
UnresolvedFolder instance or None if not found
"""
result = await db.execute(
select(UnresolvedFolder).where(
UnresolvedFolder.folder_name == folder_name
)
)
return result.scalar_one_or_none()
@staticmethod
async def get_all_unresolved(
db: AsyncSession,
) -> list[UnresolvedFolder]:
"""Get all unresolved folders that haven't been resolved yet.
Args:
db: Database session
Returns:
List of unresolved UnresolvedFolder instances
"""
result = await db.execute(
select(UnresolvedFolder)
.where(UnresolvedFolder.provider_key.is_(None))
.order_by(UnresolvedFolder.created_at)
)
return list(result.scalars().all())
@staticmethod
async def resolve(
db: AsyncSession,
folder_name: str,
provider_key: str,
) -> Optional[UnresolvedFolder]:
"""Mark an unresolved folder as resolved with the given provider key.
Args:
db: Database session
folder_name: Filesystem folder name to resolve
provider_key: Provider key to associate with this folder
Returns:
Updated UnresolvedFolder instance or None if not found
"""
from datetime import datetime, timezone
folder = await UnresolvedFolderService.get_by_folder_name(db, folder_name)
if not folder:
return None
folder.provider_key = provider_key
folder.resolved_at = datetime.now(timezone.utc)
await db.flush()
await db.refresh(folder)
logger.info(
"Resolved unresolved folder: %s -> key=%s",
folder_name, provider_key
)
return folder
@staticmethod
async def delete(
db: AsyncSession,
folder_name: str,
) -> bool:
"""Delete an unresolved folder record (e.g., after manual add).
Args:
db: Database session
folder_name: Filesystem folder name to delete
Returns:
True if deleted, False if not found
"""
folder = await UnresolvedFolderService.get_by_folder_name(db, folder_name)
if not folder:
return False
await db.delete(folder)
await db.flush()
return True
@staticmethod
async def update_search_result(
db: AsyncSession,
folder_name: str,
search_result: str,
) -> Optional[UnresolvedFolder]:
"""Update the cached search result for an unresolved folder.
Args:
db: Database session
folder_name: Filesystem folder name to update
search_result: JSON string of search results
Returns:
Updated UnresolvedFolder instance or None if not found
"""
folder = await UnresolvedFolderService.get_by_folder_name(db, folder_name)
if not folder:
return None
folder.search_attempts += 1
folder.last_search_result = search_result
await db.flush()
await db.refresh(folder)
return folder

View File

@@ -27,6 +27,7 @@ from src.server.api.health import router as health_router
from src.server.api.logging import router as logging_router from src.server.api.logging import router as logging_router
from src.server.api.nfo import router as nfo_router from src.server.api.nfo import router as nfo_router
from src.server.api.scheduler import router as scheduler_router from src.server.api.scheduler import router as scheduler_router
from src.server.api.setup_endpoints import router as setup_router
from src.server.api.websocket import router as websocket_router from src.server.api.websocket import router as websocket_router
from src.server.controllers.error_controller import ( from src.server.controllers.error_controller import (
not_found_handler, not_found_handler,
@@ -343,7 +344,6 @@ async def lifespan(_application: FastAPI):
from src.server.services.initialization_service import ( from src.server.services.initialization_service import (
perform_initial_setup, perform_initial_setup,
perform_media_scan_if_needed, perform_media_scan_if_needed,
perform_nfo_scan_if_needed,
) )
try: try:
@@ -372,9 +372,6 @@ async def lifespan(_application: FastAPI):
"exist yet): %s", e "exist yet): %s", e
) )
# Run NFO scan only on first run (if configured)
await perform_nfo_scan_if_needed()
# Initialize download service # Initialize download service
try: try:
from src.server.utils.dependencies import get_download_service from src.server.utils.dependencies import get_download_service
@@ -648,6 +645,7 @@ app.include_router(scheduler_router)
app.include_router(anime_router) app.include_router(anime_router)
app.include_router(download_router) app.include_router(download_router)
app.include_router(nfo_router) app.include_router(nfo_router)
app.include_router(setup_router)
app.include_router(logging_router) app.include_router(logging_router)
app.include_router(websocket_router) app.include_router(websocket_router)

View File

@@ -32,10 +32,12 @@ class SetupRedirectMiddleware(BaseHTTPMiddleware):
# Paths that should always be accessible, even without setup # Paths that should always be accessible, even without setup
EXEMPT_PATHS = { EXEMPT_PATHS = {
"/setup", # Setup page itself "/setup", # Setup page itself
"/setup/unresolved", # Unresolved folders page (after setup)
"/loading", # Loading page (initialization progress) "/loading", # Loading page (initialization progress)
"/login", # Login page (needs to be accessible after setup) "/login", # Login page (needs to be accessible after setup)
"/queue", # Queue page (for initial load) "/queue", # Queue page (for initial load)
"/api/auth/", # All auth endpoints (setup, login, logout, register) "/api/auth/", # All auth endpoints (setup, login, logout, register)
"/api/setup/", # Setup API (unresolved folders, etc.)
"/ws/connect", # WebSocket connection (needed for loading page) "/ws/connect", # WebSocket connection (needed for loading page)
"/api/queue/", # Queue API endpoints "/api/queue/", # Queue API endpoints
"/api/downloads/", # Download API endpoints "/api/downloads/", # Download API endpoints
@@ -126,21 +128,10 @@ class SetupRedirectMiddleware(BaseHTTPMiddleware):
# Otherwise redirect to login # Otherwise redirect to login
return RedirectResponse(url="/login", status_code=302) return RedirectResponse(url="/login", status_code=302)
elif path == "/loading": elif path == "/loading":
# Check if initialization is complete # Always allow access to loading page - it handles its own
try: # redirect flow via WebSocket events (initialization_complete
from src.server.database.connection import get_db_session # event triggers redirect to /setup/unresolved)
from src.server.database.system_settings_service import ( pass
SystemSettingsService,
)
async with get_db_session() as db:
is_complete = await SystemSettingsService.is_initial_scan_completed(db)
if is_complete:
# Initialization complete, redirect to login
return RedirectResponse(url="/login", status_code=302)
except Exception:
# If we can't check, allow access to loading page
pass
# Skip setup check for exempt paths # Skip setup check for exempt paths
if self._is_path_exempt(path): if self._is_path_exempt(path):

View File

@@ -165,7 +165,7 @@ async def _cleanup_legacy_key_files() -> int:
db_folders: set[str] = {series.folder for series in all_series if series.folder} db_folders: set[str] = {series.folder for series in all_series if series.folder}
for folder_name in db_folders: for folder_name in db_folders:
folder_path = settings.anime_directory / folder_name folder_path = Path(settings.anime_directory) / folder_name
key_file = folder_path / "key" key_file = folder_path / "key"
if not key_file.exists(): if not key_file.exists():

View File

@@ -20,7 +20,7 @@ import structlog
from src.config.settings import settings from src.config.settings import settings
from src.server.database.connection import get_db_session from src.server.database.connection import get_db_session
from src.server.database.service import AnimeSeriesService from src.server.database.service import AnimeSeriesService, UnresolvedFolderService
from src.server.utils.dependencies import get_series_app from src.server.utils.dependencies import get_series_app
logger = structlog.get_logger(__name__) logger = structlog.get_logger(__name__)
@@ -74,6 +74,61 @@ class SetupService:
""" """
return re.sub(r'\s*\(\d{4}\)\s*$', '', folder_name).strip() return re.sub(r'\s*\(\d{4}\)\s*$', '', folder_name).strip()
@staticmethod
def _normalize_title(title: str) -> str:
"""Normalize title for fuzzy matching.
Strips common suffixes and lowercases for comparison.
Args:
title: The title to normalize
Returns:
Normalized title string
"""
# Remove common anime suffixes (case-insensitive)
suffixes = [
r'\s*\(TV\)\s*$',
r'\s*\(Anime\)\s*$',
r'\s*\(OAD\)\s*$',
r'\s*\(OVA\)\s*$',
r'\s*\(Special\)\s*$',
r'\s*\(Movie\)\s*$',
r'\s*\(Spin-Off\)\s*$',
]
normalized = title.lower().strip()
for suffix_pattern in suffixes:
normalized = re.sub(suffix_pattern, '', normalized, flags=re.IGNORECASE).strip()
return normalized
@staticmethod
def _titles_match(title1: str, title2: str, threshold: float = 0.85) -> bool:
"""Check if two titles match using fuzzy comparison.
Args:
title1: First title
title2: Second title
threshold: Similarity threshold (0.0 to 1.0)
Returns:
True if titles match within threshold
"""
norm1 = SetupService._normalize_title(title1)
norm2 = SetupService._normalize_title(title2)
# Direct match after normalization
if norm1 == norm2:
return True
# Containment check (e.g., "Attack on Titan" in "Attack on Titan (TV)")
if norm1 in norm2 or norm2 in norm1:
return True
# Similarity ratio check using SequenceMatcher
from difflib import SequenceMatcher
ratio = SequenceMatcher(None, norm1, norm2).ratio()
return ratio >= threshold
@staticmethod @staticmethod
async def _resolve_key_via_search(title: str) -> str: async def _resolve_key_via_search(title: str) -> str:
"""Resolve provider key by searching for the title. """Resolve provider key by searching for the title.
@@ -93,11 +148,35 @@ class SetupService:
results = await series_app.search(title) results = await series_app.search(title)
if len(results) == 1: if len(results) == 1:
result_name = results[0].get('title', '').lower() result_name = results[0].get('title', '')
if result_name == title.lower(): result_link = results[0].get('link', '')
link = results[0].get('link', '')
if link and '/anime/stream/' in link: if SetupService._titles_match(result_name, title):
return link.split('/anime/stream/')[-1].split('/')[0] if result_link and '/anime/stream/' in result_link:
return result_link.split('/anime/stream/')[-1].split('/')[0]
elif result_link:
# Link is already the key (e.g., "shinobi-no-ittoki")
return result_link
else:
logger.debug(
"Series key resolved but link format unexpected",
folder_title=title,
result_title=result_name,
link=result_link
)
else:
logger.debug(
"Series search result title mismatch",
folder_title=title,
result_title=result_name,
link=result_link
)
elif len(results) > 1:
logger.debug(
"Multiple search results for title, skipping fuzzy match",
title=title,
result_count=len(results)
)
except Exception as e: except Exception as e:
logger.warning( logger.warning(
"Provider search failed for folder", "Provider search failed for folder",
@@ -205,6 +284,7 @@ class SetupService:
created_count = 0 created_count = 0
skipped_existing = 0 skipped_existing = 0
unresolved_count = 0
try: try:
series_app = get_series_app() series_app = get_series_app()
@@ -224,6 +304,43 @@ class SetupService:
skipped_existing += 1 skipped_existing += 1
continue continue
# Check if already tracked as unresolved
existing_unresolved = await UnresolvedFolderService.get_by_folder_name(
db, folder_name
)
if existing_unresolved and existing_unresolved.is_resolved:
# Was previously unresolved but now resolved - create the series
resolved_key = existing_unresolved.provider_key
year = cls._extract_year_from_folder_name(folder_name)
title = cls._extract_title_from_folder_name(folder_name)
props = cls._get_series_properties(folder)
series = await AnimeSeriesService.create(
db=db,
key=resolved_key,
name=title,
site="https://aniworld.to",
folder=folder_name,
year=year,
loading_status="completed",
episodes_loaded=True,
logo_loaded=props.logo_loaded,
images_loaded=props.images_loaded,
has_nfo=props.has_nfo,
nfo_path=props.nfo_path,
nfo_created_at=props.nfo_created_at,
nfo_updated_at=props.nfo_updated_at,
)
created_count += 1
# Delete the unresolved tracking now that series is created
await UnresolvedFolderService.delete(db, folder_name)
continue
elif existing_unresolved:
# Already tracked as unresolved, skip
unresolved_count += 1
continue
# Extract title and year from folder name # Extract title and year from folder name
year = cls._extract_year_from_folder_name(folder_name) year = cls._extract_year_from_folder_name(folder_name)
title = cls._extract_title_from_folder_name(folder_name) title = cls._extract_title_from_folder_name(folder_name)
@@ -239,12 +356,40 @@ class SetupService:
resolved_key = await cls._resolve_key_via_search(title) resolved_key = await cls._resolve_key_via_search(title)
if not resolved_key: if not resolved_key:
# Track unresolved folder for later manual resolution
import json
try:
series_results = await series_app.search(title)
search_result_json = json.dumps(series_results) if series_results else None
except Exception:
search_result_json = None
await UnresolvedFolderService.create(
db=db,
folder_name=folder_name,
title=title,
year=year,
search_attempts=1,
last_search_result=search_result_json,
)
logger.warning( logger.warning(
"Could not resolve series key for folder, skipping: %s", "Could not resolve series key for folder, tracking as unresolved: %s",
folder_name folder_name
) )
continue continue
# Also check if a series with this key already exists (different folder, same anime)
existing_by_key = await AnimeSeriesService.get_by_key(db, resolved_key)
if existing_by_key:
logger.debug(
"Series with key already exists, skipping",
folder=folder_name,
key=resolved_key,
existing_folder=existing_by_key.folder
)
skipped_existing += 1
continue
# Check filesystem properties # Check filesystem properties
props = cls._get_series_properties(folder) props = cls._get_series_properties(folder)
@@ -281,7 +426,8 @@ class SetupService:
logger.info( logger.info(
"Setup complete", "Setup complete",
created=created_count, created=created_count,
skipped_existing=skipped_existing skipped_existing=skipped_existing,
unresolved=unresolved_count
) )
except Exception as e: except Exception as e:

View File

@@ -281,15 +281,11 @@
let isComplete = false; let isComplete = false;
const stepOrder = [ const stepOrder = [
'series_sync', 'series_sync'
'nfo_scan',
'media_scan'
]; ];
const stepTitles = { const stepTitles = {
'series_sync': 'Syncing Series Database', 'series_sync': 'Syncing Series Database'
'nfo_scan': 'Processing NFO Metadata',
'media_scan': 'Scanning Media Files'
}; };
function connectWebSocket() { function connectWebSocket() {
@@ -468,12 +464,37 @@
function showCompletion() { function showCompletion() {
isComplete = true; isComplete = true;
document.getElementById('completionMessage').style.display = 'block';
document.getElementById('connectionStatus').style.display = 'none'; document.getElementById('connectionStatus').style.display = 'none';
if (ws) { if (ws) {
ws.close(); ws.close();
} }
// Check for unresolved folders before showing completion
checkUnresolvedAndProceed();
}
async function checkUnresolvedAndProceed() {
// Fetch unresolved folders and only redirect if there are any
// Otherwise go directly to login
try {
const token = localStorage.getItem('auth_token');
const res = await fetch('/api/setup/unresolved', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
const folders = await res.json();
if (folders && folders.length > 0) {
// Has unresolved folders - go to resolution page
window.location.href = '/setup/unresolved';
return;
}
}
} catch (err) {
console.error('Failed to check unresolved folders:', err);
}
// No unresolved folders or error - go to login
window.location.href = '/login';
} }
function showError(message) { function showError(message) {

View File

@@ -790,17 +790,36 @@
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 go to login // Redirect to loading page if provided, otherwise check for unresolved folders
if (data.redirect) { if (data.redirect) {
showMessage('Setup saved! Initializing your anime library...', 'success'); showMessage('Setup saved! Initializing your anime library...', 'success');
setTimeout(() => { setTimeout(() => {
window.location.href = data.redirect; window.location.href = data.redirect;
}, 500); }, 500);
} else { } else {
showMessage('Setup completed successfully! Redirecting to login...', 'success'); // Check for unresolved folders before redirecting
setTimeout(() => { showMessage('Setup completed successfully! Checking for unresolved series...', 'success');
window.location.href = '/login'; setTimeout(async () => {
}, 2000); 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';

View File

@@ -0,0 +1,705 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AniWorld Manager - Resolve Series</title>
<link rel="stylesheet" href="/static/css/styles.css?v={{ static_v }}">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style>
.unresolved-container {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
background: linear-gradient(135deg, var(--color-primary-light) 0%, var(--color-primary) 100%);
padding: 2rem 1rem;
}
.unresolved-card {
background: var(--color-surface);
border-radius: 16px;
padding: 2rem;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 700px;
border: 1px solid var(--color-border);
max-height: 90vh;
overflow-y: auto;
}
.unresolved-header {
text-align: center;
margin-bottom: 2rem;
}
.unresolved-header .icon {
font-size: 3rem;
color: var(--color-warning);
margin-bottom: 0.5rem;
}
.unresolved-header h1 {
margin: 0;
color: var(--color-text);
font-size: 1.8rem;
font-weight: 600;
}
.unresolved-header p {
margin: 1rem 0 0 0;
color: var(--color-text-secondary);
font-size: 1rem;
line-height: 1.5;
}
.folder-list {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.folder-item {
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-lg);
padding: 1.25rem;
transition: all var(--transition-duration) var(--transition-easing);
}
.folder-item.resolving {
opacity: 0.7;
pointer-events: none;
}
.folder-item.resolved {
animation: fadeSlideOut 0.4s ease forwards;
}
@keyframes fadeSlideOut {
0% { opacity: 1; transform: translateY(0); max-height: 500px; }
100% { opacity: 0; transform: translateY(-10px); max-height: 0; padding: 0; margin: 0; border: 0; }
}
.folder-item-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
}
.folder-title {
font-size: 1.1rem;
font-weight: 600;
color: var(--color-text-primary);
}
.folder-year {
color: var(--color-text-secondary);
font-weight: normal;
}
.folder-attempts {
font-size: 0.8rem;
color: var(--color-text-tertiary);
margin-top: 0.25rem;
}
.folder-delete-btn {
background: none;
border: none;
color: var(--color-text-tertiary);
cursor: pointer;
padding: 0.25rem;
border-radius: var(--border-radius-sm);
transition: color var(--transition-duration);
}
.folder-delete-btn:hover {
color: var(--color-error);
}
.folder-input-row {
display: flex;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.folder-input {
flex: 1;
padding: 0.75rem 1rem;
border: 2px solid var(--color-border);
border-radius: var(--border-radius-md);
font-size: 0.95rem;
background: var(--color-surface);
color: var(--color-text);
transition: border-color var(--transition-duration);
}
.folder-input:focus {
outline: none;
border-color: var(--color-accent);
}
.folder-input::placeholder {
color: var(--color-text-tertiary);
}
.resolve-btn {
padding: 0.75rem 1.5rem;
background: var(--color-accent);
color: white;
border: none;
border-radius: var(--border-radius-md);
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-duration);
white-space: nowrap;
}
.resolve-btn:hover:not(:disabled) {
background: var(--color-accent-hover);
}
.resolve-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.folder-suggestions {
margin-top: 0.75rem;
}
.suggestions-label {
font-size: 0.85rem;
color: var(--color-text-secondary);
margin-bottom: 0.5rem;
font-weight: 500;
}
.suggestion-list {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.suggestion-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
color: var(--color-text-primary);
}
.suggestion-item i {
color: var(--color-accent);
font-size: 0.8rem;
}
.suggestion-link {
color: var(--color-accent);
text-decoration: none;
}
.suggestion-link:hover {
text-decoration: underline;
}
.no-suggestions {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: var(--color-text-tertiary);
}
.search-again-btn {
background: none;
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
padding: 0.4rem 0.8rem;
border-radius: var(--border-radius-md);
font-size: 0.8rem;
cursor: pointer;
transition: all var(--transition-duration);
margin-top: 0.5rem;
}
.search-again-btn:hover {
background: var(--color-surface-hover);
border-color: var(--color-accent);
color: var(--color-accent);
}
.search-again-btn.searching {
pointer-events: none;
opacity: 0.7;
}
/* Empty state */
.empty-state {
text-align: center;
padding: 3rem 2rem;
}
.empty-state .icon {
font-size: 4rem;
color: var(--color-success);
margin-bottom: 1rem;
}
.empty-state h2 {
color: var(--color-success);
margin-bottom: 0.5rem;
}
.empty-state p {
color: var(--color-text-secondary);
}
/* Loading state */
.loading-state {
text-align: center;
padding: 2rem;
}
.spinner {
width: 2rem;
height: 2rem;
border: 3px solid var(--color-border);
border-top-color: var(--color-accent);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 1rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Error state */
.folder-error {
color: var(--color-error);
font-size: 0.85rem;
margin-top: 0.5rem;
display: none;
}
.folder-error.visible {
display: block;
}
/* Toast container */
.toast-container {
position: fixed;
top: var(--spacing-xl);
right: var(--spacing-xl);
z-index: 1100;
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.toast {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-lg);
padding: var(--spacing-md) var(--spacing-lg);
box-shadow: var(--shadow-elevated);
min-width: 280px;
animation: slideIn 0.2s ease;
}
.toast.success { border-left: 4px solid var(--color-success); }
.toast.error { border-left: 4px solid var(--color-error); }
.toast.warning { border-left: 4px solid var(--color-warning); }
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
.theme-toggle {
position: absolute;
top: 1rem;
right: 1rem;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: white;
padding: 0.5rem;
border-radius: 50%;
cursor: pointer;
width: 2.5rem;
height: 2.5rem;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.theme-toggle:hover {
background: rgba(255, 255, 255, 0.2);
transform: scale(1.1);
}
.skip-link {
display: block;
text-align: center;
margin-top: 1.5rem;
color: var(--color-text-secondary);
font-size: 0.9rem;
text-decoration: none;
}
.skip-link:hover {
color: var(--color-text-primary);
text-decoration: underline;
}
@media (max-width: 600px) {
.folder-input-row {
flex-direction: column;
}
.resolve-btn {
width: 100%;
}
}
</style>
</head>
<body>
<div class="unresolved-container">
<button class="theme-toggle" id="theme-toggle" title="Toggle theme">
<i class="fas fa-moon"></i>
</button>
<div class="unresolved-card">
<div class="unresolved-header">
<div class="icon">
<i class="fas fa-folder-question"></i>
</div>
<h1>Resolve Unresolved Series</h1>
<p>Some series couldn't be found automatically. Enter the provider key for each folder to complete setup.</p>
</div>
<div id="loading-state" class="loading-state">
<div class="spinner"></div>
<p>Loading unresolved folders...</p>
</div>
<div id="empty-state" class="empty-state" style="display: none;">
<div class="icon">
<i class="fas fa-check-circle"></i>
</div>
<h2>All Series Configured!</h2>
<p>Redirecting to your anime library...</p>
</div>
<div id="folder-list" class="folder-list" style="display: none;"></div>
<a href="/" id="skip-link" class="skip-link" style="display: none;">
Skip and go to main app
</a>
</div>
</div>
<div id="toast-container" class="toast-container"></div>
<script>
// Theme toggle
const themeToggle = document.getElementById('theme-toggle');
const htmlElement = document.documentElement;
const savedTheme = localStorage.getItem('theme') || 'light';
htmlElement.setAttribute('data-theme', savedTheme);
updateThemeIcon(savedTheme);
themeToggle.addEventListener('click', () => {
const currentTheme = htmlElement.getAttribute('data-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
htmlElement.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
updateThemeIcon(newTheme);
});
function updateThemeIcon(theme) {
const icon = themeToggle.querySelector('i');
icon.className = theme === 'dark' ? 'fas fa-sun' : 'fas fa-moon';
}
// Toast notification
function showToast(message, type = 'info') {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'slideIn 0.2s ease reverse';
setTimeout(() => toast.remove(), 200);
}, 3000);
}
// API client helpers
async function fetchUnresolved() {
// Note: /api/setup/unresolved does not require auth
// It's accessible during the initial setup flow
const token = localStorage.getItem('auth_token');
const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
const res = await fetch('/api/setup/unresolved', { headers });
if (res.status === 401) {
// Redirect to login only if we had a token but it expired
localStorage.removeItem('auth_token');
window.location.href = '/login';
return null;
}
return res.json();
}
async function resolveFolder(folderName, providerKey) {
const token = localStorage.getItem('auth_token');
const encodedName = encodeURIComponent(folderName);
const res = await fetch(`/api/setup/unresolved/${encodedName}/resolve`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ provider_key: providerKey })
});
return res.json();
}
async function reSearchFolder(folderName) {
const token = localStorage.getItem('auth_token');
const encodedName = encodeURIComponent(folderName);
const res = await fetch(`/api/setup/unresolved/${encodedName}/search`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` }
});
return res.json();
}
async function deleteFolder(folderName) {
const token = localStorage.getItem('auth_token');
const encodedName = encodeURIComponent(folderName);
const res = await fetch(`/api/setup/unresolved/${encodedName}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
return res.json();
}
// Render functions
function renderFolderItem(folder) {
const suggestionsHtml = folder.search_suggestions && folder.search_suggestions.length > 0
? folder.search_suggestions.map(s => `
<div class="suggestion-item">
<i class="fas fa-link"></i>
<a href="${s.link}" class="suggestion-link" target="_blank">${s.title}</a>
</div>
`).join('')
: '<div class="no-suggestions"><i class="fas fa-info-circle"></i> No suggestions found</div>';
const searchAgainBtn = (folder.search_suggestions && folder.search_suggestions.length === 0)
? `<button class="search-again-btn" data-folder="${folder.folder_name}">
<i class="fas fa-search"></i> Search Again
</button>`
: '';
return `
<div class="folder-item" data-folder="${folder.folder_name}">
<div class="folder-item-header">
<div>
<div class="folder-title">${folder.title}${folder.year ? ` <span class="folder-year">(${folder.year})</span>` : ''}</div>
<div class="folder-attempts">${folder.search_attempts} search attempt${folder.search_attempts !== 1 ? 's' : ''}</div>
</div>
<button class="folder-delete-btn" data-folder="${folder.folder_name}" title="Remove without adding">
<i class="fas fa-times"></i>
</button>
</div>
<div class="folder-input-row">
<input type="text" class="folder-input"
placeholder="Enter provider key (e.g., ooku-the-inner-chambers)"
data-folder="${folder.folder_name}">
<button class="resolve-btn" data-folder="${folder.folder_name}" disabled>
Resolve
</button>
</div>
<div class="folder-error" data-folder="${folder.folder_name}"></div>
<div class="folder-suggestions">
<div class="suggestions-label">Suggestions:</div>
<div class="suggestion-list">
${suggestionsHtml}
</div>
${searchAgainBtn}
</div>
</div>
`;
}
function renderFolders(folders) {
const listEl = document.getElementById('folder-list');
const loadingEl = document.getElementById('loading-state');
const emptyEl = document.getElementById('empty-state');
loadingEl.style.display = 'none';
if (folders.length === 0) {
listEl.style.display = 'none';
emptyEl.style.display = 'block';
document.getElementById('skip-link').style.display = 'block';
setTimeout(() => { window.location.href = '/loading'; }, 2000);
} else {
listEl.style.display = 'flex';
emptyEl.style.display = 'none';
listEl.innerHTML = folders.map(renderFolderItem).join('');
attachFolderEvents();
}
}
function attachFolderEvents() {
// Input enable/disable resolve button
document.querySelectorAll('.folder-input').forEach(input => {
input.addEventListener('input', (e) => {
const folder = e.target.dataset.folder;
const btn = document.querySelector(`.resolve-btn[data-folder="${folder}"]`);
btn.disabled = !e.target.value.trim();
// Clear error
const errEl = document.querySelector(`.folder-error[data-folder="${folder}"]`);
errEl.classList.remove('visible');
errEl.textContent = '';
});
// Enter key triggers resolve
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
const folder = e.target.dataset.folder;
const btn = document.querySelector(`.resolve-btn[data-folder="${folder}"]`);
if (!btn.disabled) btn.click();
}
});
});
// Resolve button
document.querySelectorAll('.resolve-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
const folder = e.target.dataset.folder;
const input = document.querySelector(`.folder-input[data-folder="${folder}"]`);
const item = document.querySelector(`.folder-item[data-folder="${folder}"]`);
const errEl = document.querySelector(`.folder-error[data-folder="${folder}"]`);
const providerKey = input.value.trim();
if (!providerKey) return;
item.classList.add('resolving');
btn.disabled = true;
btn.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');
btn.disabled = false;
btn.innerHTML = 'Resolve';
}
} catch (err) {
errEl.textContent = 'Server error. Please try again.';
errEl.classList.add('visible');
btn.disabled = false;
btn.innerHTML = 'Resolve';
} finally {
item.classList.remove('resolving');
}
});
});
// Delete button
document.querySelectorAll('.folder-delete-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
const folder = e.currentTarget.dataset.folder;
const item = document.querySelector(`.folder-item[data-folder="${folder}"]`);
if (!confirm('Remove this unresolved folder? You can add the series manually later.')) return;
try {
await deleteFolder(folder);
item.classList.add('resolved');
setTimeout(() => {
item.remove();
checkEmptyList();
}, 400);
} catch (err) {
showToast('Failed to remove folder', 'error');
}
});
});
// Search again button
document.querySelectorAll('.search-again-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
const folder = e.target.dataset.folder || e.target.closest('button').dataset.folder;
const item = document.querySelector(`.folder-item[data-folder="${folder}"]`);
btn.classList.add('searching');
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Searching...';
try {
const result = await reSearchFolder(folder);
// Update suggestions in place
const suggestionsEl = item.querySelector('.suggestion-list');
if (result.search_suggestions && result.search_suggestions.length > 0) {
suggestionsEl.innerHTML = result.search_suggestions.map(s => `
<div class="suggestion-item">
<i class="fas fa-link"></i>
<a href="${s.link}" class="suggestion-link" target="_blank">${s.title}</a>
</div>
`).join('');
} else {
suggestionsEl.innerHTML = '<div class="no-suggestions"><i class="fas fa-info-circle"></i> No suggestions found</div>';
}
btn.remove();
} catch (err) {
showToast('Search failed', 'error');
} finally {
btn.classList.remove('searching');
}
});
});
}
function checkEmptyList() {
const listEl = document.getElementById('folder-list');
const emptyEl = document.getElementById('empty-state');
const skipLink = document.getElementById('skip-link');
if (listEl.children.length === 0) {
listEl.style.display = 'none';
emptyEl.style.display = 'block';
skipLink.style.display = 'block';
showToast('All series configured!', 'success');
setTimeout(() => { window.location.href = '/loading'; }, 2000);
}
}
// Init
(async function init() {
const folders = await fetchUnresolved();
if (folders !== null) {
renderFolders(folders);
}
})();
</script>
</body>
</html>

View File

@@ -153,19 +153,22 @@ class TestSetupServiceRun:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_creates_series_for_new_folders(self, tmp_path): async def test_creates_series_for_new_folders(self, tmp_path):
"""Folders without DB entries → creates AnimeSeries records.""" """Folders without DB entries and single search match → creates AnimeSeries records.
Note: This test verifies the logic flow when search returns a single match.
The actual search call goes through SeriesApp which uses run_in_executor,
so we test the flow with a resolved key being passed through.
"""
anime_dir = tmp_path / "anime" anime_dir = tmp_path / "anime"
anime_dir.mkdir() anime_dir.mkdir()
(anime_dir / "Attack on Titan (2013)").mkdir() (anime_dir / "Attack on Titan (2013)").mkdir()
(anime_dir / "OnePiece").mkdir()
mock_series_app = AsyncMock()
mock_series_app.search.return_value = []
mock_db = AsyncMock() mock_db = AsyncMock()
mock_get_db = MagicMock() mock_get_db = MagicMock()
mock_get_db.__aenter__.return_value = mock_db mock_get_db.__aenter__.return_value = mock_db
mock_get_db.__aexit__.return_value = None mock_get_db.__aexit__.return_value = None
mock_series_app = AsyncMock()
mock_series_app.search.return_value = []
with patch( with patch(
'src.server.services.setup_service.settings' 'src.server.services.setup_service.settings'
@@ -182,16 +185,32 @@ class TestSetupServiceRun:
'src.server.services.setup_service.AnimeSeriesService.get_by_folder', 'src.server.services.setup_service.AnimeSeriesService.get_by_folder',
new_callable=AsyncMock, return_value=None new_callable=AsyncMock, return_value=None
), \ ), \
patch(
'src.server.services.setup_service.AnimeSeriesService.get_by_key',
new_callable=AsyncMock, return_value=None
), \
patch(
'src.server.services.setup_service.UnresolvedFolderService.get_by_folder_name',
new_callable=AsyncMock, return_value=None
), \
patch( patch(
'src.server.services.setup_service.AnimeSeriesService.create', 'src.server.services.setup_service.AnimeSeriesService.create',
new_callable=AsyncMock new_callable=AsyncMock
) as mock_create: ) as mock_create:
mock_settings.anime_directory = str(anime_dir) mock_settings.anime_directory = str(anime_dir)
result = await SetupService.run() # Directly test the flow by patching _resolve_key_via_search
# to return a key (simulating successful search)
with patch.object(
SetupService, '_resolve_key_via_search',
new_callable=AsyncMock, return_value='attack-on-titan'
):
result = await SetupService.run()
assert result == 2 assert result == 1
assert mock_create.call_count == 2 mock_create.assert_called_once()
call_kwargs = mock_create.call_args.kwargs
assert call_kwargs['key'] == 'attack-on-titan'
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_skips_existing_folders(self, tmp_path): async def test_skips_existing_folders(self, tmp_path):
@@ -236,20 +255,21 @@ class TestSetupServiceRun:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_resolves_key_for_single_match(self, tmp_path): async def test_resolves_key_for_single_match(self, tmp_path):
"""Single search match with same name → uses that key.""" """Single search match with same name → uses that key.
This tests that when _resolve_key_via_search returns a key,
the series is created with that key.
"""
anime_dir = tmp_path / "anime" anime_dir = tmp_path / "anime"
anime_dir.mkdir() anime_dir.mkdir()
(anime_dir / "Attack on Titan (2013)").mkdir() (anime_dir / "Attack on Titan (2013)").mkdir()
mock_series_app = AsyncMock()
mock_series_app.search.return_value = [
{'title': 'Attack on Titan', 'link': '/anime/stream/attack-on-titan'}
]
mock_db = AsyncMock() mock_db = AsyncMock()
mock_get_db = MagicMock() mock_get_db = MagicMock()
mock_get_db.__aenter__.return_value = mock_db mock_get_db.__aenter__.return_value = mock_db
mock_get_db.__aexit__.return_value = None mock_get_db.__aexit__.return_value = None
mock_series_app = AsyncMock()
mock_series_app.search.return_value = []
with patch( with patch(
'src.server.services.setup_service.settings' 'src.server.services.setup_service.settings'
@@ -266,13 +286,26 @@ class TestSetupServiceRun:
'src.server.services.setup_service.AnimeSeriesService.get_by_folder', 'src.server.services.setup_service.AnimeSeriesService.get_by_folder',
new_callable=AsyncMock, return_value=None new_callable=AsyncMock, return_value=None
), \ ), \
patch(
'src.server.services.setup_service.AnimeSeriesService.get_by_key',
new_callable=AsyncMock, return_value=None
), \
patch(
'src.server.services.setup_service.UnresolvedFolderService.get_by_folder_name',
new_callable=AsyncMock, return_value=None
), \
patch( patch(
'src.server.services.setup_service.AnimeSeriesService.create', 'src.server.services.setup_service.AnimeSeriesService.create',
new_callable=AsyncMock new_callable=AsyncMock
) as mock_create: ) as mock_create:
mock_settings.anime_directory = str(anime_dir) mock_settings.anime_directory = str(anime_dir)
await SetupService.run() # Simulate successful search returning a key
with patch.object(
SetupService, '_resolve_key_via_search',
new_callable=AsyncMock, return_value='attack-on-titan'
):
await SetupService.run()
# Verify create was called with resolved key # Verify create was called with resolved key
call_kwargs = mock_create.call_args.kwargs call_kwargs = mock_create.call_args.kwargs
@@ -281,8 +314,8 @@ class TestSetupServiceRun:
assert call_kwargs['year'] == 2013 assert call_kwargs['year'] == 2013
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_empty_key_for_no_match(self, tmp_path): async def test_tracks_unresolved_when_no_match(self, tmp_path):
"""No search match → empty key.""" """No search match → tracks folder as unresolved, doesn't create series."""
anime_dir = tmp_path / "anime" anime_dir = tmp_path / "anime"
anime_dir.mkdir() anime_dir.mkdir()
(anime_dir / "Unknown Series (2020)").mkdir() (anime_dir / "Unknown Series (2020)").mkdir()
@@ -311,16 +344,28 @@ class TestSetupServiceRun:
new_callable=AsyncMock, return_value=None new_callable=AsyncMock, return_value=None
), \ ), \
patch( patch(
'src.server.services.setup_service.AnimeSeriesService.create', 'src.server.services.setup_service.AnimeSeriesService.get_by_key',
new_callable=AsyncMock, return_value=None
), \
patch(
'src.server.services.setup_service.UnresolvedFolderService.get_by_folder_name',
new_callable=AsyncMock, return_value=None
), \
patch(
'src.server.services.setup_service.UnresolvedFolderService.create',
new_callable=AsyncMock new_callable=AsyncMock
) as mock_create: ) as mock_create_unresolved:
mock_settings.anime_directory = str(anime_dir) mock_settings.anime_directory = str(anime_dir)
await SetupService.run() result = await SetupService.run()
call_kwargs = mock_create.call_args.kwargs # Should return 0 since no series was created
assert call_kwargs['key'] == '' assert result == 0
assert call_kwargs['name'] == 'Unknown Series' # Should track as unresolved instead of creating series
mock_create_unresolved.assert_called_once()
call_kwargs = mock_create_unresolved.call_args.kwargs
assert call_kwargs['folder_name'] == 'Unknown Series (2020)'
assert call_kwargs['title'] == 'Unknown Series'
assert call_kwargs['year'] == 2020 assert call_kwargs['year'] == 2020
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -381,15 +426,24 @@ class TestSetupServiceRun:
new_callable=AsyncMock, return_value=None new_callable=AsyncMock, return_value=None
), \ ), \
patch( patch(
'src.server.services.setup_service.AnimeSeriesService.create', 'src.server.services.setup_service.AnimeSeriesService.get_by_key',
new_callable=AsyncMock, return_value=None
), \
patch(
'src.server.services.setup_service.UnresolvedFolderService.get_by_folder_name',
new_callable=AsyncMock, return_value=None
), \
patch(
'src.server.services.setup_service.UnresolvedFolderService.create',
new_callable=AsyncMock new_callable=AsyncMock
) as mock_create: ) as mock_create_unresolved:
mock_settings.anime_directory = str(anime_dir) mock_settings.anime_directory = str(anime_dir)
result = await SetupService.run() result = await SetupService.run()
assert result == 1 # Empty search results → folder tracked as unresolved, not created
mock_create.assert_called_once() assert result == 0
mock_create_unresolved.assert_called_once()
class TestCheckNfoFile: class TestCheckNfoFile:

View File

@@ -0,0 +1,244 @@
"""Tests for UnresolvedFolderService and UnresolvedFolder model."""
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from src.server.database.models import UnresolvedFolder
from src.server.database.service import UnresolvedFolderService
class TestUnresolvedFolderModel:
"""Test UnresolvedFolder model."""
def test_is_resolved_false_when_no_key(self):
"""provider_key is None → is_resolved is False."""
folder = UnresolvedFolder(
folder_name="Test (2020)",
title="Test",
year=2020,
provider_key=None,
resolved_at=None,
)
assert folder.is_resolved is False
def test_is_resolved_false_when_key_but_no_timestamp(self):
"""provider_key set but resolved_at is None → is_resolved is False."""
folder = UnresolvedFolder(
folder_name="Test (2020)",
title="Test",
year=2020,
provider_key="test-key",
resolved_at=None,
)
assert folder.is_resolved is False
def test_is_resolved_true_when_both_set(self):
"""Both provider_key and resolved_at set → is_resolved is True."""
folder = UnresolvedFolder(
folder_name="Test (2020)",
title="Test",
year=2020,
provider_key="test-key",
resolved_at=datetime.now(timezone.utc),
)
assert folder.is_resolved is True
def test_validate_folder_name_empty_raises(self):
"""Empty folder_name → raises ValueError during construction."""
with pytest.raises(ValueError, match="Folder name cannot be empty"):
UnresolvedFolder(
folder_name="",
title="Test",
)
def test_validate_folder_name_too_long_raises(self):
"""Folder name > 1000 chars → raises ValueError during construction."""
long_name = "x" * 1001
with pytest.raises(ValueError, match="Folder name must be 1000 characters"):
UnresolvedFolder(
folder_name=long_name,
title="Test",
)
def test_validate_title_empty_raises(self):
"""Empty title → raises ValueError during construction."""
with pytest.raises(ValueError, match="Title cannot be empty"):
UnresolvedFolder(
folder_name="Test (2020)",
title="",
)
class TestUnresolvedFolderService:
"""Test UnresolvedFolderService methods."""
@pytest.mark.asyncio
async def test_create(self):
"""Creates a new unresolved folder record."""
mock_db = AsyncMock()
mock_db.flush = AsyncMock()
mock_db.refresh = AsyncMock()
folder = await UnresolvedFolderService.create(
db=mock_db,
folder_name="Test (2020)",
title="Test",
year=2020,
search_attempts=1,
)
assert folder.folder_name == "Test (2020)"
assert folder.title == "Test"
assert folder.year == 2020
assert folder.search_attempts == 1
mock_db.add.assert_called_once()
mock_db.flush.assert_called_once()
@pytest.mark.asyncio
async def test_get_by_folder_name_found(self):
"""Found → returns UnresolvedFolder."""
mock_db = AsyncMock()
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = UnresolvedFolder(
folder_name="Test (2020)",
title="Test",
year=2020,
)
mock_db.execute.return_value = mock_result
folder = await UnresolvedFolderService.get_by_folder_name(mock_db, "Test (2020)")
assert folder is not None
assert folder.folder_name == "Test (2020)"
@pytest.mark.asyncio
async def test_get_by_folder_name_not_found(self):
"""Not found → returns None."""
mock_db = AsyncMock()
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = None
mock_db.execute.return_value = mock_result
folder = await UnresolvedFolderService.get_by_folder_name(mock_db, "Unknown")
assert folder is None
@pytest.mark.asyncio
async def test_get_all_unresolved(self):
"""Returns only unresolved folders (no provider_key)."""
mock_db = AsyncMock()
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = [
UnresolvedFolder(folder_name="Folder1", title="Title1", year=2020),
UnresolvedFolder(folder_name="Folder2", title="Title2", year=2021),
]
mock_db.execute.return_value = mock_result
folders = await UnresolvedFolderService.get_all_unresolved(mock_db)
assert len(folders) == 2
mock_db.execute.assert_called_once()
@pytest.mark.asyncio
async def test_resolve(self):
"""Marks folder as resolved with provider_key."""
mock_db = AsyncMock()
mock_db.flush = AsyncMock()
mock_db.refresh = AsyncMock()
existing = UnresolvedFolder(
folder_name="Test (2020)",
title="Test",
year=2020,
provider_key=None,
resolved_at=None,
)
with patch.object(
UnresolvedFolderService, 'get_by_folder_name',
new_callable=AsyncMock, return_value=existing
):
result = await UnresolvedFolderService.resolve(
mock_db, "Test (2020)", "test-key"
)
assert result.provider_key == "test-key"
assert result.resolved_at is not None
mock_db.flush.assert_called_once()
@pytest.mark.asyncio
async def test_resolve_not_found(self):
"""Folder not found → returns None."""
mock_db = AsyncMock()
with patch.object(
UnresolvedFolderService, 'get_by_folder_name',
new_callable=AsyncMock, return_value=None
):
result = await UnresolvedFolderService.resolve(
mock_db, "Unknown", "test-key"
)
assert result is None
@pytest.mark.asyncio
async def test_delete(self):
"""Deletes unresolved folder record."""
mock_db = AsyncMock()
mock_db.flush = AsyncMock()
existing = UnresolvedFolder(
folder_name="Test (2020)",
title="Test",
year=2020,
)
with patch.object(
UnresolvedFolderService, 'get_by_folder_name',
new_callable=AsyncMock, return_value=existing
):
result = await UnresolvedFolderService.delete(mock_db, "Test (2020)")
assert result is True
mock_db.delete.assert_called_once_with(existing)
mock_db.flush.assert_called_once()
@pytest.mark.asyncio
async def test_delete_not_found(self):
"""Folder not found → returns False."""
mock_db = AsyncMock()
with patch.object(
UnresolvedFolderService, 'get_by_folder_name',
new_callable=AsyncMock, return_value=None
):
result = await UnresolvedFolderService.delete(mock_db, "Unknown")
assert result is False
@pytest.mark.asyncio
async def test_update_search_result(self):
"""Increments search_attempts and updates last_search_result."""
mock_db = AsyncMock()
mock_db.flush = AsyncMock()
mock_db.refresh = AsyncMock()
existing = UnresolvedFolder(
folder_name="Test (2020)",
title="Test",
year=2020,
search_attempts=1,
last_search_result=None,
)
with patch.object(
UnresolvedFolderService, 'get_by_folder_name',
new_callable=AsyncMock, return_value=existing
):
result = await UnresolvedFolderService.update_search_result(
mock_db, "Test (2020)", '[{"title": "Test"}]'
)
assert result.search_attempts == 2
assert result.last_search_result == '[{"title": "Test"}]'