"""NFO Management API endpoints. This module provides REST API endpoints for managing tvshow.nfo files and associated media (poster, logo, fanart). """ import asyncio import logging from datetime import datetime from pathlib import Path from typing import List from fastapi import APIRouter, Depends, HTTPException, status from src.config.settings import settings from src.core.entities.series import Serie from src.core.SeriesApp import SeriesApp from src.core.services.nfo_service import NFOService from src.core.services.tmdb_client import TMDBAPIError from src.server.models.nfo import ( MediaDownloadRequest, MediaFilesStatus, NFOBatchCreateRequest, NFOBatchCreateResponse, NFOBatchResult, NFOCheckResponse, NFOContentResponse, NFOCreateRequest, NFOCreateResponse, NFOMissingResponse, NFOMissingSeries, ) from src.server.utils.dependencies import get_series_app, require_auth logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/nfo", tags=["nfo"]) async def get_nfo_service() -> NFOService: """Get NFO service dependency. Returns: NFOService instance Raises: HTTPException: If NFO service not configured """ if not settings.tmdb_api_key: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="NFO service not configured. TMDB API key required." ) return NFOService( tmdb_api_key=settings.tmdb_api_key, anime_directory=settings.anime_directory, image_size=settings.nfo_image_size, auto_create=settings.nfo_auto_create ) def check_media_files(serie_folder: str) -> MediaFilesStatus: """Check status of media files for a series. Args: serie_folder: Series folder name Returns: MediaFilesStatus with file existence info """ folder_path = Path(settings.anime_directory) / serie_folder poster_path = folder_path / "poster.jpg" logo_path = folder_path / "logo.png" fanart_path = folder_path / "fanart.jpg" return MediaFilesStatus( has_poster=poster_path.exists(), has_logo=logo_path.exists(), has_fanart=fanart_path.exists(), poster_path=str(poster_path) if poster_path.exists() else None, logo_path=str(logo_path) if logo_path.exists() else None, fanart_path=str(fanart_path) if fanart_path.exists() else None ) @router.get("/{serie_id}/check", response_model=NFOCheckResponse) async def check_nfo( serie_id: str, _auth: dict = Depends(require_auth), series_app: SeriesApp = Depends(get_series_app), nfo_service: NFOService = Depends(get_nfo_service) ) -> NFOCheckResponse: """Check if NFO and media files exist for a series. Args: serie_id: Series identifier _auth: Authentication dependency series_app: Series app dependency nfo_service: NFO service dependency Returns: NFOCheckResponse with NFO and media status Raises: HTTPException: If series not found """ try: # Get series info series_list = series_app.list.GetList() serie = next( (s for s in series_list if getattr(s, 'key', None) == serie_id), None ) if not serie: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Series not found: {serie_id}" ) serie_folder = serie.folder # Check NFO has_nfo = await nfo_service.check_nfo_exists(serie_folder) nfo_path = None if has_nfo: nfo_path = str( Path(settings.anime_directory) / serie_folder / "tvshow.nfo" ) # Check media files media_files = check_media_files(serie_folder) return NFOCheckResponse( serie_id=serie_id, serie_folder=serie_folder, has_nfo=has_nfo, nfo_path=nfo_path, media_files=media_files ) except HTTPException: raise except Exception as e: logger.error(f"Error checking NFO for {serie_id}: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to check NFO: {str(e)}" ) from e @router.post("/{serie_id}/create", response_model=NFOCreateResponse) async def create_nfo( serie_id: str, request: NFOCreateRequest, _auth: dict = Depends(require_auth), series_app: SeriesApp = Depends(get_series_app), nfo_service: NFOService = Depends(get_nfo_service) ) -> NFOCreateResponse: """Create NFO file and download media for a series. Args: serie_id: Series identifier request: NFO creation options _auth: Authentication dependency series_app: Series app dependency nfo_service: NFO service dependency Returns: NFOCreateResponse with creation result Raises: HTTPException: If series not found or creation fails """ try: # Get series info series_list = series_app.list.GetList() serie = next( (s for s in series_list if getattr(s, 'key', None) == serie_id), None ) if not serie: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Series not found: {serie_id}" ) serie_folder = serie.folder # Check if NFO already exists if not request.overwrite_existing: has_nfo = await nfo_service.check_nfo_exists(serie_folder) if has_nfo: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="NFO already exists. Use overwrite_existing=true" ) # Create NFO serie_name = request.serie_name or serie.name or serie_folder nfo_path = await nfo_service.create_tvshow_nfo( serie_name=serie_name, serie_folder=serie_folder, year=request.year, download_poster=request.download_poster, download_logo=request.download_logo, download_fanart=request.download_fanart ) # Check media files media_files = check_media_files(serie_folder) return NFOCreateResponse( serie_id=serie_id, serie_folder=serie_folder, nfo_path=str(nfo_path), media_files=media_files, message="NFO and media files created successfully" ) except HTTPException: raise except TMDBAPIError as e: logger.warning(f"TMDB API error creating NFO for {serie_id}: {e}") raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"TMDB API error: {str(e)}" ) from e except Exception as e: logger.error( f"Error creating NFO for {serie_id}: {e}", exc_info=True ) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to create NFO: {str(e)}" ) from e @router.put("/{serie_id}/update", response_model=NFOCreateResponse) async def update_nfo( serie_id: str, download_media: bool = True, _auth: dict = Depends(require_auth), series_app: SeriesApp = Depends(get_series_app), nfo_service: NFOService = Depends(get_nfo_service) ) -> NFOCreateResponse: """Update existing NFO file with fresh TMDB data. Args: serie_id: Series identifier download_media: Whether to re-download media files _auth: Authentication dependency series_app: Series app dependency nfo_service: NFO service dependency Returns: NFOCreateResponse with update result Raises: HTTPException: If series or NFO not found """ try: # Get series info series_list = series_app.list.GetList() serie = next( (s for s in series_list if getattr(s, 'key', None) == serie_id), None ) if not serie: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Series not found: {serie_id}" ) serie_folder = serie.folder # Check if NFO exists has_nfo = await nfo_service.check_nfo_exists(serie_folder) if not has_nfo: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="NFO file not found. Use create endpoint instead." ) # Update NFO nfo_path = await nfo_service.update_tvshow_nfo( serie_folder=serie_folder, download_media=download_media ) # Check media files media_files = check_media_files(serie_folder) return NFOCreateResponse( serie_id=serie_id, serie_folder=serie_folder, nfo_path=str(nfo_path), media_files=media_files, message="NFO updated successfully" ) except HTTPException: raise except TMDBAPIError as e: logger.warning(f"TMDB API error updating NFO for {serie_id}: {e}") raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"TMDB API error: {str(e)}" ) from e except Exception as e: logger.error( f"Error updating NFO for {serie_id}: {e}", exc_info=True ) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to update NFO: {str(e)}" ) from e @router.get("/{serie_id}/content", response_model=NFOContentResponse) async def get_nfo_content( serie_id: str, _auth: dict = Depends(require_auth), series_app: SeriesApp = Depends(get_series_app), nfo_service: NFOService = Depends(get_nfo_service) ) -> NFOContentResponse: """Get NFO file content for a series. Args: serie_id: Series identifier _auth: Authentication dependency series_app: Series app dependency nfo_service: NFO service dependency Returns: NFOContentResponse with NFO content Raises: HTTPException: If series or NFO not found """ try: # Get series info series_list = series_app.list.GetList() serie = next( (s for s in series_list if getattr(s, 'key', None) == serie_id), None ) if not serie: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Series not found: {serie_id}" ) serie_folder = serie.folder # Check if NFO exists nfo_path = ( Path(settings.anime_directory) / serie_folder / "tvshow.nfo" ) if not nfo_path.exists(): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="NFO file not found" ) # Read NFO content content = nfo_path.read_text(encoding="utf-8") file_size = nfo_path.stat().st_size last_modified = datetime.fromtimestamp(nfo_path.stat().st_mtime) return NFOContentResponse( serie_id=serie_id, serie_folder=serie_folder, content=content, file_size=file_size, last_modified=last_modified ) except HTTPException: raise except Exception as e: logger.error( f"Error reading NFO content for {serie_id}: {e}", exc_info=True ) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to read NFO content: {str(e)}" ) from e @router.get("/{serie_id}/media/status", response_model=MediaFilesStatus) async def get_media_status( serie_id: str, _auth: dict = Depends(require_auth), series_app: SeriesApp = Depends(get_series_app) ) -> MediaFilesStatus: """Get media files status for a series. Args: serie_id: Series identifier _auth: Authentication dependency series_app: Series app dependency Returns: MediaFilesStatus with file existence info Raises: HTTPException: If series not found """ try: # Get series info series_list = series_app.list.GetList() serie = next( (s for s in series_list if getattr(s, 'key', None) == serie_id), None ) if not serie: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Series not found: {serie_id}" ) return check_media_files(serie.folder) except HTTPException: raise except Exception as e: logger.error( f"Error checking media status for {serie_id}: {e}", exc_info=True ) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to check media status: {str(e)}" ) from e @router.post("/{serie_id}/media/download", response_model=MediaFilesStatus) async def download_media( serie_id: str, request: MediaDownloadRequest, _auth: dict = Depends(require_auth), series_app: SeriesApp = Depends(get_series_app), nfo_service: NFOService = Depends(get_nfo_service) ) -> MediaFilesStatus: """Download missing media files for a series. Args: serie_id: Series identifier request: Media download options _auth: Authentication dependency series_app: Series app dependency nfo_service: NFO service dependency Returns: MediaFilesStatus after download attempt Raises: HTTPException: If series or NFO not found """ try: # Get series info series_list = series_app.list.GetList() serie = next( (s for s in series_list if getattr(s, 'key', None) == serie_id), None ) if not serie: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Series not found: {serie_id}" ) serie_folder = serie.folder # Check if NFO exists (needed for TMDB ID) has_nfo = await nfo_service.check_nfo_exists(serie_folder) if not has_nfo: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="NFO required for media download. Create NFO first." ) # For now, update NFO which will re-download media # In future, could add standalone media download if (request.download_poster or request.download_logo or request.download_fanart): await nfo_service.update_tvshow_nfo( serie_folder=serie_folder, download_media=True ) return check_media_files(serie_folder) except HTTPException: raise except Exception as e: logger.error( f"Error downloading media for {serie_id}: {e}", exc_info=True ) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to download media: {str(e)}" ) from e @router.post("/batch/create", response_model=NFOBatchCreateResponse) async def batch_create_nfo( request: NFOBatchCreateRequest, _auth: dict = Depends(require_auth), series_app: SeriesApp = Depends(get_series_app), nfo_service: NFOService = Depends(get_nfo_service) ) -> NFOBatchCreateResponse: """Batch create NFO files for multiple series. Args: request: Batch creation options _auth: Authentication dependency series_app: Series app dependency nfo_service: NFO service dependency Returns: NFOBatchCreateResponse with results """ results: List[NFOBatchResult] = [] successful = 0 failed = 0 skipped = 0 # Get all series series_list = series_app.list.GetList() series_map = { getattr(s, 'key', None): s for s in series_list if getattr(s, 'key', None) } # Process each series semaphore = asyncio.Semaphore(request.max_concurrent) async def process_serie(serie_id: str) -> NFOBatchResult: """Process a single series.""" async with semaphore: try: serie = series_map.get(serie_id) if not serie: return NFOBatchResult( serie_id=serie_id, serie_folder="", success=False, message="Series not found" ) serie_folder = serie.folder # Check if NFO exists if request.skip_existing: has_nfo = await nfo_service.check_nfo_exists(serie_folder) if has_nfo: return NFOBatchResult( serie_id=serie_id, serie_folder=serie_folder, success=False, message="Skipped - NFO already exists" ) # Create NFO nfo_path = await nfo_service.create_tvshow_nfo( serie_name=serie.name or serie_folder, serie_folder=serie_folder, download_poster=request.download_media, download_logo=request.download_media, download_fanart=request.download_media ) return NFOBatchResult( serie_id=serie_id, serie_folder=serie_folder, success=True, message="NFO created successfully", nfo_path=str(nfo_path) ) except Exception as e: logger.error( f"Error creating NFO for {serie_id}: {e}", exc_info=True ) return NFOBatchResult( serie_id=serie_id, serie_folder=serie.folder if serie else "", success=False, message=f"Error: {str(e)}" ) # Process all series concurrently tasks = [process_serie(sid) for sid in request.serie_ids] results = await asyncio.gather(*tasks) # Count results for result in results: if result.success: successful += 1 elif "Skipped" in result.message: skipped += 1 else: failed += 1 return NFOBatchCreateResponse( total=len(request.serie_ids), successful=successful, failed=failed, skipped=skipped, results=results ) @router.get("/missing", response_model=NFOMissingResponse) async def get_missing_nfo( _auth: dict = Depends(require_auth), series_app: SeriesApp = Depends(get_series_app), nfo_service: NFOService = Depends(get_nfo_service) ) -> NFOMissingResponse: """Get list of series without NFO files. Args: _auth: Authentication dependency series_app: Series app dependency nfo_service: NFO service dependency Returns: NFOMissingResponse with series list """ try: series_list = series_app.list.GetList() missing_series: List[NFOMissingSeries] = [] for serie in series_list: serie_id = getattr(serie, 'key', None) if not serie_id: continue serie_folder = serie.folder has_nfo = await nfo_service.check_nfo_exists(serie_folder) if not has_nfo: media_files = check_media_files(serie_folder) has_media = ( media_files.has_poster or media_files.has_logo or media_files.has_fanart ) missing_series.append(NFOMissingSeries( serie_id=serie_id, serie_folder=serie_folder, serie_name=serie.name or serie_folder, has_media=has_media, media_files=media_files )) return NFOMissingResponse( total_series=len(series_list), missing_nfo_count=len(missing_series), series=missing_series ) except Exception as e: logger.error(f"Error getting missing NFOs: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to get missing NFOs: {str(e)}" ) from e