"""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, ) class NfoScanPhaseResponse(BaseModel): """Response model for NFO scan phase trigger.""" status: str = Field(..., description="Status of the operation") message: str = Field(..., description="Human-readable message") @router.post("/nfo-scan-phase", response_model=NfoScanPhaseResponse) async def trigger_nfo_scan_phase() -> NfoScanPhaseResponse: """Trigger the NFO scan phase. This endpoint is called by the loading page when accessed with ?phase=nfo. It starts the NFO scan in the background and returns immediately. The loading page then connects via WebSocket to receive progress updates. Returns: NfoScanPhaseResponse with status and message """ import asyncio from src.server.services.initialization_service import perform_nfo_scan_phase from src.server.services.progress_service import get_progress_service progress_service = get_progress_service() async def run_nfo_scan(): """Run NFO scan phase with progress updates.""" try: await perform_nfo_scan_phase(progress_service) logger.info("NFO scan phase completed via API trigger") except Exception as e: logger.error("NFO scan phase failed: %s", e, exc_info=True) if progress_service: await progress_service.fail_progress( progress_id="nfo_scan", error_message=f"NFO scan failed: {str(e)}", metadata={"step_id": "nfo_scan", "phase": "nfo"} ) # Start NFO scan in background asyncio.create_task(run_nfo_scan()) return NfoScanPhaseResponse( status="started", message="NFO scan phase started. Check progress via WebSocket." )