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
313 lines
9.9 KiB
Python
313 lines
9.9 KiB
Python
"""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}"} |