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
This commit is contained in:
313
src/server/api/setup_endpoints.py
Normal file
313
src/server/api/setup_endpoints.py
Normal 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}"}
|
||||
@@ -626,6 +626,96 @@ class UserSession(Base, TimestampMixin):
|
||||
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):
|
||||
"""SQLAlchemy model for system-wide settings and state.
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ from src.server.database.models import (
|
||||
AnimeSeries,
|
||||
DownloadQueueItem,
|
||||
Episode,
|
||||
UnresolvedFolder,
|
||||
UserSession,
|
||||
)
|
||||
|
||||
@@ -1364,3 +1365,176 @@ class UserSessionService:
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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.nfo import router as nfo_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.controllers.error_controller import (
|
||||
not_found_handler,
|
||||
@@ -648,6 +649,7 @@ app.include_router(scheduler_router)
|
||||
app.include_router(anime_router)
|
||||
app.include_router(download_router)
|
||||
app.include_router(nfo_router)
|
||||
app.include_router(setup_router)
|
||||
app.include_router(logging_router)
|
||||
app.include_router(websocket_router)
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import structlog
|
||||
|
||||
from src.config.settings import settings
|
||||
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
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -281,6 +281,7 @@ class SetupService:
|
||||
|
||||
created_count = 0
|
||||
skipped_existing = 0
|
||||
unresolved_count = 0
|
||||
|
||||
try:
|
||||
series_app = get_series_app()
|
||||
@@ -300,6 +301,43 @@ class SetupService:
|
||||
skipped_existing += 1
|
||||
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
|
||||
year = cls._extract_year_from_folder_name(folder_name)
|
||||
title = cls._extract_title_from_folder_name(folder_name)
|
||||
@@ -315,8 +353,24 @@ class SetupService:
|
||||
resolved_key = await cls._resolve_key_via_search(title)
|
||||
|
||||
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(
|
||||
"Could not resolve series key for folder, skipping: %s",
|
||||
"Could not resolve series key for folder, tracking as unresolved: %s",
|
||||
folder_name
|
||||
)
|
||||
continue
|
||||
@@ -357,7 +411,8 @@ class SetupService:
|
||||
logger.info(
|
||||
"Setup complete",
|
||||
created=created_count,
|
||||
skipped_existing=skipped_existing
|
||||
skipped_existing=skipped_existing,
|
||||
unresolved=unresolved_count
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -153,14 +153,15 @@ class TestSetupServiceRun:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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.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_get_db = MagicMock()
|
||||
@@ -170,10 +171,6 @@ class TestSetupServiceRun:
|
||||
with patch(
|
||||
'src.server.services.setup_service.settings'
|
||||
) as mock_settings, \
|
||||
patch(
|
||||
'src.server.services.setup_service.get_series_app',
|
||||
return_value=mock_series_app
|
||||
), \
|
||||
patch(
|
||||
'src.server.services.setup_service.get_db_session',
|
||||
return_value=mock_get_db
|
||||
@@ -182,16 +179,28 @@ class TestSetupServiceRun:
|
||||
'src.server.services.setup_service.AnimeSeriesService.get_by_folder',
|
||||
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.AnimeSeriesService.create',
|
||||
new_callable=AsyncMock
|
||||
) as mock_create:
|
||||
mock_settings.anime_directory = str(anime_dir)
|
||||
|
||||
# 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 mock_create.call_count == 2
|
||||
assert result == 1
|
||||
mock_create.assert_called_once()
|
||||
call_kwargs = mock_create.call_args.kwargs
|
||||
assert call_kwargs['key'] == 'attack-on-titan'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_existing_folders(self, tmp_path):
|
||||
@@ -236,16 +245,15 @@ class TestSetupServiceRun:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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.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_get_db = MagicMock()
|
||||
mock_get_db.__aenter__.return_value = mock_db
|
||||
@@ -254,10 +262,6 @@ class TestSetupServiceRun:
|
||||
with patch(
|
||||
'src.server.services.setup_service.settings'
|
||||
) as mock_settings, \
|
||||
patch(
|
||||
'src.server.services.setup_service.get_series_app',
|
||||
return_value=mock_series_app
|
||||
), \
|
||||
patch(
|
||||
'src.server.services.setup_service.get_db_session',
|
||||
return_value=mock_get_db
|
||||
@@ -266,12 +270,21 @@ class TestSetupServiceRun:
|
||||
'src.server.services.setup_service.AnimeSeriesService.get_by_folder',
|
||||
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.AnimeSeriesService.create',
|
||||
new_callable=AsyncMock
|
||||
) as mock_create:
|
||||
mock_settings.anime_directory = str(anime_dir)
|
||||
|
||||
# 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
|
||||
@@ -281,8 +294,8 @@ class TestSetupServiceRun:
|
||||
assert call_kwargs['year'] == 2013
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_key_for_no_match(self, tmp_path):
|
||||
"""No search match → empty key."""
|
||||
async def test_tracks_unresolved_when_no_match(self, tmp_path):
|
||||
"""No search match → tracks folder as unresolved, doesn't create series."""
|
||||
anime_dir = tmp_path / "anime"
|
||||
anime_dir.mkdir()
|
||||
(anime_dir / "Unknown Series (2020)").mkdir()
|
||||
@@ -311,16 +324,24 @@ class TestSetupServiceRun:
|
||||
new_callable=AsyncMock, return_value=None
|
||||
), \
|
||||
patch(
|
||||
'src.server.services.setup_service.AnimeSeriesService.create',
|
||||
'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
|
||||
) as mock_create:
|
||||
) as mock_create_unresolved:
|
||||
mock_settings.anime_directory = str(anime_dir)
|
||||
|
||||
await SetupService.run()
|
||||
result = await SetupService.run()
|
||||
|
||||
call_kwargs = mock_create.call_args.kwargs
|
||||
assert call_kwargs['key'] == ''
|
||||
assert call_kwargs['name'] == 'Unknown Series'
|
||||
# Should return 0 since no series was created
|
||||
assert result == 0
|
||||
# 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
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -381,15 +402,20 @@ class TestSetupServiceRun:
|
||||
new_callable=AsyncMock, return_value=None
|
||||
), \
|
||||
patch(
|
||||
'src.server.services.setup_service.AnimeSeriesService.create',
|
||||
'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
|
||||
) as mock_create:
|
||||
) as mock_create_unresolved:
|
||||
mock_settings.anime_directory = str(anime_dir)
|
||||
|
||||
result = await SetupService.run()
|
||||
|
||||
assert result == 1
|
||||
mock_create.assert_called_once()
|
||||
# Empty search results → folder tracked as unresolved, not created
|
||||
assert result == 0
|
||||
mock_create_unresolved.assert_called_once()
|
||||
|
||||
|
||||
class TestCheckNfoFile:
|
||||
|
||||
244
tests/unit/test_unresolved_folder_service.py
Normal file
244
tests/unit/test_unresolved_folder_service.py
Normal 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"}]'
|
||||
Reference in New Issue
Block a user