Files
Aniworld/src/server/api/nfo.py
Lukas 491daa2e50 Fix NFO folder naming to include year
- Add Serie.ensure_folder_with_year() method to ensure folder names include year
- Update all NFO API endpoints to call ensure_folder_with_year() before operations
- Folder format is now 'Name (Year)' when year is available
- Add comprehensive tests for ensure_folder_with_year() method
- All 5 tests passing
2026-01-18 12:28:38 +01:00

708 lines
22 KiB
Python

"""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
"""
# Check if TMDB API key is in settings
tmdb_api_key = settings.tmdb_api_key
# If not in settings, try to load from config.json
if not tmdb_api_key:
try:
from src.server.services.config_service import get_config_service
config_service = get_config_service()
config = config_service.load_config()
if config.nfo and config.nfo.tmdb_api_key:
tmdb_api_key = config.nfo.tmdb_api_key
except Exception:
pass # Config loading failed, tmdb_api_key remains None
if not 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=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}"
)
# Ensure folder name includes year if available
serie_folder = serie.ensure_folder_with_year()
# 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}"
)
# Ensure folder name includes year if available
serie_folder = serie.ensure_folder_with_year()
# If year not provided in request but serie has year, use it
year = request.year or serie.year
# 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=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}"
)
# Ensure folder name includes year if available
serie_folder = serie.ensure_folder_with_year()
# 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}"
)
# Ensure folder name includes year if available
serie_folder = serie.ensure_folder_with_year()
# 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}"
)
# Ensure folder name includes year if available
serie_folder = serie.ensure_folder_with_year()
# 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"
)
# Ensure folder name includes year if available
serie_folder = serie.ensure_folder_with_year()
# 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
# Ensure folder name includes year if available
serie_folder = serie.ensure_folder_with_year()
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