Files
Aniworld/src/server/api/setup_endpoints.py
Lukas 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

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}"}