- Add /api/setup/unresolved/done endpoint to mark phase complete - NFO scan now runs after series sync during initialization - Middleware redirects to /login after setup complete (was /loading) - Done button allows skipping folder resolution with redirect to NFO scan phase
376 lines
12 KiB
Python
376 lines
12 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,
|
|
)
|
|
|
|
|
|
class SearchFolderRequest(BaseModel):
|
|
"""Request model for searching an unresolved folder with custom query."""
|
|
query: Optional[str] = Field(None, description="Custom search query override")
|
|
|
|
|
|
@router.post("/unresolved/{folder_name}/search", response_model=UnresolvedFolderResponse)
|
|
async def search_unresolved_folder(
|
|
folder_name: str,
|
|
request: Optional[SearchFolderRequest] = None,
|
|
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 or a custom query.
|
|
Caches the results for subsequent display.
|
|
|
|
Args:
|
|
folder_name: URL-encoded folder name to search for
|
|
request: Optional SearchFolderRequest with custom query override
|
|
|
|
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}"
|
|
)
|
|
|
|
# Use custom query if provided, otherwise fall back to folder title
|
|
search_query = request.query if request and request.query else folder.title
|
|
|
|
# Perform search
|
|
series_app = get_series_app()
|
|
try:
|
|
results = await series_app.search(search_query)
|
|
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 + 1,
|
|
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}"}
|
|
|
|
|
|
class DoneResponse(BaseModel):
|
|
"""Response model for completing unresolved folders."""
|
|
status: str = Field(..., description="Operation status")
|
|
message: str = Field(..., description="Human-readable message")
|
|
count: int = Field(..., description="Number of folders marked as done")
|
|
|
|
|
|
@router.post("/unresolved/done", response_model=DoneResponse)
|
|
async def complete_unresolved_folders(
|
|
db=Depends(get_database_session),
|
|
) -> DoneResponse:
|
|
"""Mark all unresolved folders as handled and complete the unresolved phase.
|
|
|
|
This endpoint:
|
|
1. Marks the unresolved phase as completed in config
|
|
2. Returns the count of folders that were handled
|
|
|
|
After this, /setup/unresolved will redirect to /loading.
|
|
|
|
Returns:
|
|
DoneResponse with status and count of handled folders
|
|
"""
|
|
from src.server.services.config_service import get_config_service
|
|
|
|
# Get all unresolved folders
|
|
folders = await UnresolvedFolderService.get_all_unresolved(db)
|
|
count = len(folders)
|
|
|
|
# Mark unresolved as completed in config
|
|
config_service = get_config_service()
|
|
try:
|
|
config = config_service.load_config()
|
|
if config.other is None:
|
|
config.other = {}
|
|
config.other['unresolved_completed'] = True
|
|
config_service.save_config(config, create_backup=False)
|
|
logger.info("Marked unresolved phase as completed")
|
|
except Exception as e:
|
|
logger.warning("Failed to save unresolved_completed flag: %s", e)
|
|
|
|
logger.info(
|
|
"Completed unresolved phase: %d folders handled",
|
|
count
|
|
)
|
|
|
|
return DoneResponse(
|
|
status="success",
|
|
message=f"Marked {count} folders as handled. Unresolved phase completed.",
|
|
count=count,
|
|
) |