POST /api/anime/{key}/regenerate-nfo and PUT /api/anime/{key}/settings
(both of which can regenerate NFO when apply_to_nfo=True) were calling
_create_or_update_nfo without the required anime_service argument,
producing HTTP 500 with 'missing 1 required positional argument:
anime_service' on the anime-settings page.
Also drops the unused series_data parameter from
_create_or_update_nfo's signature — the value was constructed at every
call site but never read inside the function body. All four callers
in anime.py and nfo.py are updated to match.
635 lines
18 KiB
Python
635 lines
18 KiB
Python
"""NFO Management API endpoints.
|
|
|
|
Provides endpoints for NFO settings, repair, and validation for anime series.
|
|
"""
|
|
import logging
|
|
import os
|
|
from datetime import datetime
|
|
from typing import Any, List, Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from pydantic import BaseModel
|
|
|
|
from src.config.settings import settings
|
|
from src.server.models.nfo import (
|
|
NfoContentResponse,
|
|
NfoRepairResponse,
|
|
NfoSeriesSettings,
|
|
NfoSettingsResponse,
|
|
)
|
|
from src.server.services.anime_service import AnimeService
|
|
from src.server.services.nfo_scan_service import get_nfo_scan_service
|
|
from src.server.utils.dependencies import get_anime_service, require_auth
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/api/nfo", tags=["nfo"])
|
|
|
|
# Required tags for a valid Kodi tvshow.nfo
|
|
REQUIRED_TAGS = [
|
|
"title",
|
|
"plot",
|
|
"tmdbid",
|
|
]
|
|
OPTIONAL_TAGS = [
|
|
"year",
|
|
"premiered",
|
|
"genre",
|
|
"studio",
|
|
"rating",
|
|
"mpaa",
|
|
"outline",
|
|
"tagline",
|
|
"runtime",
|
|
"status",
|
|
"id",
|
|
"imdb_id",
|
|
"tvdbid",
|
|
"imdbid",
|
|
"uniqueid",
|
|
"thumb",
|
|
"fanart",
|
|
"actor",
|
|
"trailer",
|
|
]
|
|
|
|
|
|
class NfoValidateResponse(BaseModel):
|
|
"""Response for NFO XML validation."""
|
|
|
|
valid: bool
|
|
error: Optional[str] = None
|
|
|
|
|
|
class NfoNeedsRepairListResponse(BaseModel):
|
|
"""Response listing series that need NFO repair."""
|
|
|
|
total: int
|
|
series: List[NfoSeriesSettings]
|
|
|
|
|
|
def _get_nfo_path(folder: str) -> str:
|
|
"""Get the full path to a series' tvshow.nfo file."""
|
|
anime_dir = getattr(settings, "anime_directory", None)
|
|
if not anime_dir:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail="Anime directory not configured",
|
|
)
|
|
return os.path.join(anime_dir, folder, "tvshow.nfo")
|
|
|
|
|
|
def _parse_nfo_file(nfo_path: str) -> tuple[Optional[Any], List[str]]:
|
|
"""Parse an NFO file and return (xml_root, missing_tags).
|
|
|
|
Returns tuple of (xml_root element or None, list of missing required tags).
|
|
If file cannot be read/parsed, returns (None, all_required_tags).
|
|
"""
|
|
from lxml import etree
|
|
|
|
missing: List[str] = []
|
|
|
|
if not os.path.isfile(nfo_path):
|
|
return None, REQUIRED_TAGS.copy()
|
|
|
|
try:
|
|
tree = etree.parse(nfo_path)
|
|
root = tree.getroot()
|
|
except Exception as exc:
|
|
logger.warning("Failed to parse NFO file %s: %s", nfo_path, exc)
|
|
return None, REQUIRED_TAGS.copy()
|
|
|
|
# Check for required tags
|
|
for tag in REQUIRED_TAGS:
|
|
elements = root.findall(tag)
|
|
# Check if tag exists and has non-empty text
|
|
found = False
|
|
for elem in elements:
|
|
if elem.text and elem.text.strip():
|
|
found = True
|
|
break
|
|
if not found:
|
|
missing.append(tag)
|
|
|
|
return root, missing
|
|
|
|
|
|
async def _get_series_data(
|
|
anime_service: AnimeService, key: str
|
|
) -> Optional[dict]:
|
|
"""Get series data by key from anime_service."""
|
|
# Get all series and find by key
|
|
all_series = await anime_service.list_series_with_filters()
|
|
for series in all_series:
|
|
if series.get("key") == key:
|
|
return series
|
|
return None
|
|
|
|
|
|
@router.get("/{key}/diagnostics", response_model=NfoSettingsResponse)
|
|
async def get_nfo_settings(
|
|
key: str,
|
|
_auth: dict = Depends(require_auth),
|
|
anime_service: AnimeService = Depends(get_anime_service),
|
|
) -> NfoSettingsResponse:
|
|
"""Get NFO settings inspection for a specific series.
|
|
|
|
Checks if tvshow.nfo exists in the series folder and validates
|
|
that required tags are present.
|
|
|
|
Args:
|
|
key: Series unique key (provider-assigned, URL-safe identifier)
|
|
_auth: Authentication dependency
|
|
anime_service: AnimeService dependency
|
|
|
|
Returns:
|
|
NfoSettingsResponse with has_nfo, nfo_path, missing_tags, required_tags
|
|
|
|
Raises:
|
|
HTTPException 404: If series not found
|
|
HTTPException 503: If anime directory not configured
|
|
"""
|
|
# Get series data
|
|
series_data = await _get_series_data(anime_service, key)
|
|
if not series_data:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Series not found: {key}",
|
|
)
|
|
|
|
folder = series_data.get("folder", "")
|
|
if not folder:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Series has no folder configured: {key}",
|
|
)
|
|
|
|
nfo_path = _get_nfo_path(folder)
|
|
nfo_exists = os.path.isfile(nfo_path)
|
|
|
|
if not nfo_exists:
|
|
return NfoSettingsResponse(
|
|
has_nfo=False,
|
|
nfo_path=None,
|
|
missing_tags=REQUIRED_TAGS.copy(),
|
|
required_tags=REQUIRED_TAGS.copy(),
|
|
)
|
|
|
|
# Parse and check for missing tags
|
|
_, missing = _parse_nfo_file(nfo_path)
|
|
|
|
return NfoSettingsResponse(
|
|
has_nfo=True,
|
|
nfo_path=nfo_path,
|
|
missing_tags=missing,
|
|
required_tags=REQUIRED_TAGS.copy(),
|
|
)
|
|
|
|
|
|
@router.post("/{key}/repair", response_model=NfoRepairResponse)
|
|
async def repair_nfo_settings(
|
|
key: str,
|
|
_auth: dict = Depends(require_auth),
|
|
anime_service: AnimeService = Depends(get_anime_service),
|
|
) -> NfoRepairResponse:
|
|
"""Repair NFO for a specific series.
|
|
|
|
Creates or updates the tvshow.nfo file using TMDB metadata.
|
|
|
|
Args:
|
|
key: Series unique key
|
|
_auth: Authentication dependency
|
|
anime_service: AnimeService dependency
|
|
|
|
Returns:
|
|
NfoRepairResponse with success status, message, and repaired_tags
|
|
|
|
Raises:
|
|
HTTPException 404: If series not found
|
|
HTTPException 400: If no TMDB ID available and cannot lookup by name
|
|
HTTPException 503: If anime directory not configured
|
|
"""
|
|
# Get series data
|
|
series_data = await _get_series_data(anime_service, key)
|
|
if not series_data:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Series not found: {key}",
|
|
)
|
|
|
|
folder = series_data.get("folder", "")
|
|
if not folder:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Series has no folder configured: {key}",
|
|
)
|
|
|
|
tmdb_id = series_data.get("tmdb_id")
|
|
name = series_data.get("name", "")
|
|
|
|
if not tmdb_id:
|
|
logger.info("No TMDB ID for %s, attempting lookup by name: %s", key, name)
|
|
# Try to lookup TMDB ID by series name
|
|
tmdb_id = await _lookup_tmdb_id_by_name(anime_service, name)
|
|
if not tmdb_id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"No TMDB ID available for {key} and could not find match for '{name}'",
|
|
)
|
|
|
|
# Fetch TMDB data and create NFO
|
|
try:
|
|
repaired_tags = await _create_or_update_nfo(
|
|
key=key,
|
|
folder=folder,
|
|
tmdb_id=tmdb_id,
|
|
anime_service=anime_service,
|
|
)
|
|
except Exception as exc:
|
|
logger.error("Failed to repair NFO for %s: %s", key, exc)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to repair NFO: {str(exc)}",
|
|
)
|
|
|
|
if repaired_tags:
|
|
return NfoRepairResponse(
|
|
success=True,
|
|
message=f"NFO repaired successfully. {len(repaired_tags)} tags updated.",
|
|
repaired_tags=repaired_tags,
|
|
)
|
|
else:
|
|
return NfoRepairResponse(
|
|
success=True,
|
|
message="NFO is already complete, no changes needed.",
|
|
repaired_tags=[],
|
|
)
|
|
|
|
|
|
async def _lookup_tmdb_id_by_name(anime_service: AnimeService, name: str) -> Optional[int]:
|
|
"""Try to lookup a TMDB ID by series name using TMDB API."""
|
|
if not name:
|
|
return None
|
|
try:
|
|
from src.server.nfo.tmdb_client import get_tmdb_client
|
|
|
|
async with get_tmdb_client() as client:
|
|
results = await client.search_tv_show(name)
|
|
if results and results.get("results"):
|
|
return results["results"][0].get("id")
|
|
except Exception as exc:
|
|
logger.warning("TMDB lookup failed for %s: %s", name, exc)
|
|
return None
|
|
|
|
|
|
async def _create_or_update_nfo(
|
|
key: str,
|
|
folder: str,
|
|
tmdb_id: int,
|
|
anime_service: AnimeService,
|
|
) -> List[str]:
|
|
"""Create or update NFO file for a series.
|
|
|
|
Returns list of tags that were repaired/added.
|
|
"""
|
|
from src.server.nfo.nfo_generator import generate_tvshow_nfo
|
|
from src.server.nfo.nfo_mapper import tmdb_to_nfo_model
|
|
from src.server.nfo.tmdb_client import get_tmdb_client
|
|
|
|
anime_dir = getattr(settings, "anime_directory", None)
|
|
if not anime_dir:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail="Anime directory not configured",
|
|
)
|
|
|
|
series_path = os.path.join(anime_dir, folder)
|
|
nfo_path = os.path.join(series_path, "tvshow.nfo")
|
|
|
|
# Fetch TMDB data
|
|
async with get_tmdb_client() as client:
|
|
tmdb_data = await client.get_tv_show_details(tmdb_id)
|
|
if not tmdb_data:
|
|
raise Exception(f"No TMDB data returned for TMDB ID {tmdb_id}")
|
|
|
|
# Convert to NFO model
|
|
nfo_model = tmdb_to_nfo_model(
|
|
tmdb_data,
|
|
content_ratings=None,
|
|
get_image_url=client.get_image_url,
|
|
image_size="original",
|
|
)
|
|
|
|
# Generate XML
|
|
xml_content = generate_tvshow_nfo(nfo_model)
|
|
|
|
# Ensure directory exists
|
|
os.makedirs(series_path, exist_ok=True)
|
|
|
|
# Check existing NFO for missing tags before overwriting
|
|
_, missing_before = _parse_nfo_file(nfo_path)
|
|
|
|
# Write NFO file
|
|
with open(nfo_path, "w", encoding="utf-8") as f:
|
|
f.write(xml_content)
|
|
|
|
logger.info("Created/updated tvshow.nfo for %s at %s", key, nfo_path)
|
|
|
|
# Update series NFO status in DB
|
|
await anime_service.update_nfo_status(
|
|
key=key,
|
|
has_nfo=True,
|
|
)
|
|
|
|
# Also update nfo_path in DB (not part of update_nfo_status signature)
|
|
from src.server.database.connection import get_db_session
|
|
from src.server.database.service import AnimeSeriesService
|
|
|
|
async with get_db_session() as db:
|
|
series = await AnimeSeriesService.get_by_key(db, key)
|
|
if series is not None:
|
|
await AnimeSeriesService.update(db, series.id, nfo_path=nfo_path)
|
|
|
|
# Return list of repaired tags (all tags that were missing before)
|
|
return missing_before
|
|
|
|
|
|
@router.get("/{key}/validate", response_model=NfoValidateResponse)
|
|
async def validate_nfo(
|
|
key: str,
|
|
_auth: dict = Depends(require_auth),
|
|
anime_service: AnimeService = Depends(get_anime_service),
|
|
) -> NfoValidateResponse:
|
|
"""Validate NFO XML structure for a series.
|
|
|
|
Checks if the tvshow.nfo file is valid XML.
|
|
|
|
Args:
|
|
key: Series unique key
|
|
_auth: Authentication dependency
|
|
anime_service: AnimeService dependency
|
|
|
|
Returns:
|
|
NfoValidateResponse with valid=True/False and optional error message
|
|
"""
|
|
series_data = await _get_series_data(anime_service, key)
|
|
if not series_data:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Series not found: {key}",
|
|
)
|
|
|
|
folder = series_data.get("folder", "")
|
|
if not folder:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Series has no folder configured: {key}",
|
|
)
|
|
|
|
nfo_path = _get_nfo_path(folder)
|
|
|
|
if not os.path.isfile(nfo_path):
|
|
return NfoValidateResponse(
|
|
valid=False,
|
|
error="No NFO file found",
|
|
)
|
|
|
|
try:
|
|
from lxml import etree
|
|
|
|
etree.parse(nfo_path)
|
|
return NfoValidateResponse(valid=True)
|
|
except Exception as exc:
|
|
return NfoValidateResponse(
|
|
valid=False,
|
|
error=str(exc),
|
|
)
|
|
|
|
|
|
@router.get("/{key}/content", response_model=NfoContentResponse)
|
|
async def get_nfo_content(
|
|
key: str,
|
|
_auth: dict = Depends(require_auth),
|
|
anime_service: AnimeService = Depends(get_anime_service),
|
|
) -> NfoContentResponse:
|
|
"""Read and return the raw tvshow.nfo XML for a series.
|
|
|
|
Used by the Anime Settings page's "View NFO XML" button to display the
|
|
on-disk NFO contents inside a ``<pre>`` block. The XML is returned as
|
|
plain text inside a JSON wrapper so the same auth/header pipeline as the
|
|
other NFO endpoints can be reused.
|
|
|
|
Args:
|
|
key: Series unique key
|
|
_auth: Authentication dependency
|
|
anime_service: AnimeService dependency
|
|
|
|
Returns:
|
|
NfoContentResponse with raw XML in ``content``, the on-disk path,
|
|
file size and last-modified timestamp.
|
|
|
|
Raises:
|
|
HTTPException 404: If the series or its tvshow.nfo file is not found
|
|
HTTPException 400: If the series has no folder configured
|
|
HTTPException 503: If ``settings.anime_directory`` is not configured
|
|
"""
|
|
series_data = await _get_series_data(anime_service, key)
|
|
if not series_data:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Series not found: {key}",
|
|
)
|
|
|
|
folder = series_data.get("folder", "")
|
|
if not folder:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Series has no folder configured: {key}",
|
|
)
|
|
|
|
nfo_path = _get_nfo_path(folder)
|
|
if not os.path.isfile(nfo_path):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"No tvshow.nfo file found for series '{key}'",
|
|
)
|
|
|
|
try:
|
|
stat = os.stat(nfo_path)
|
|
with open(nfo_path, "r", encoding="utf-8") as f:
|
|
xml_text = f.read()
|
|
except OSError as exc:
|
|
logger.error("Failed to read NFO file %s: %s", nfo_path, exc)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to read NFO file: {exc}",
|
|
) from exc
|
|
|
|
return NfoContentResponse(
|
|
key=key,
|
|
folder=folder,
|
|
content=xml_text,
|
|
file_size=stat.st_size,
|
|
last_modified=datetime.fromtimestamp(stat.st_mtime),
|
|
)
|
|
|
|
|
|
@router.get("/needs-repair", response_model=NfoNeedsRepairListResponse)
|
|
async def get_series_needing_repair(
|
|
_auth: dict = Depends(require_auth),
|
|
anime_service: AnimeService = Depends(get_anime_service),
|
|
) -> NfoNeedsRepairListResponse:
|
|
"""Get list of all series that need NFO repair.
|
|
|
|
Returns series that either have no NFO file or have missing required tags.
|
|
|
|
Args:
|
|
_auth: Authentication dependency
|
|
anime_service: AnimeService dependency
|
|
|
|
Returns:
|
|
NfoNeedsRepairListResponse with total count and list of series diagnostics
|
|
"""
|
|
all_series = await anime_service.list_series_with_filters()
|
|
series_needing_repair: List[NfoSeriesSettings] = []
|
|
|
|
anime_dir = getattr(settings, "anime_directory", None)
|
|
if not anime_dir:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail="Anime directory not configured",
|
|
)
|
|
|
|
for series in all_series:
|
|
key = series.get("key", "")
|
|
folder = series.get("folder", "")
|
|
name = series.get("name", "")
|
|
|
|
if not folder:
|
|
continue
|
|
|
|
nfo_path = os.path.join(anime_dir, folder, "tvshow.nfo")
|
|
nfo_exists = os.path.isfile(nfo_path)
|
|
|
|
if not nfo_exists:
|
|
series_needing_repair.append(NfoSeriesSettings(
|
|
key=key,
|
|
name=name,
|
|
folder=folder,
|
|
has_nfo=False,
|
|
missing_tags=REQUIRED_TAGS.copy(),
|
|
))
|
|
continue
|
|
|
|
# Parse and check for missing tags
|
|
_, missing = _parse_nfo_file(nfo_path)
|
|
if missing:
|
|
series_needing_repair.append(NfoSeriesSettings(
|
|
key=key,
|
|
name=name,
|
|
folder=folder,
|
|
has_nfo=True,
|
|
missing_tags=missing,
|
|
))
|
|
|
|
return NfoNeedsRepairListResponse(
|
|
total=len(series_needing_repair),
|
|
series=series_needing_repair,
|
|
)
|
|
|
|
|
|
@router.post("/batch/repair")
|
|
async def batch_repair_nfo(
|
|
keys: List[str],
|
|
_auth: dict = Depends(require_auth),
|
|
anime_service: AnimeService = Depends(get_anime_service),
|
|
) -> dict:
|
|
"""Repair NFO for multiple series at once.
|
|
|
|
Args:
|
|
keys: List of series keys to repair
|
|
_auth: Authentication dependency
|
|
anime_service: AnimeService dependency
|
|
|
|
Returns:
|
|
Summary dict with success count, failure count, and errors
|
|
"""
|
|
results = {
|
|
"total": len(keys),
|
|
"success": 0,
|
|
"failed": 0,
|
|
"errors": [],
|
|
}
|
|
|
|
for key in keys:
|
|
try:
|
|
# Get series data
|
|
series_data = await _get_series_data(anime_service, key)
|
|
if not series_data:
|
|
results["failed"] += 1
|
|
results["errors"].append(f"{key}: Series not found")
|
|
continue
|
|
|
|
folder = series_data.get("folder", "")
|
|
if not folder:
|
|
results["failed"] += 1
|
|
results["errors"].append(f"{key}: No folder configured")
|
|
continue
|
|
|
|
tmdb_id = series_data.get("tmdb_id")
|
|
name = series_data.get("name", "")
|
|
|
|
if not tmdb_id:
|
|
tmdb_id = await _lookup_tmdb_id_by_name(anime_service, name)
|
|
if not tmdb_id:
|
|
results["failed"] += 1
|
|
results["errors"].append(f"{key}: No TMDB ID and lookup failed")
|
|
continue
|
|
|
|
await _create_or_update_nfo(
|
|
key=key,
|
|
folder=folder,
|
|
tmdb_id=tmdb_id,
|
|
anime_service=anime_service,
|
|
)
|
|
results["success"] += 1
|
|
|
|
except Exception as exc:
|
|
results["failed"] += 1
|
|
results["errors"].append(f"{key}: {str(exc)}")
|
|
|
|
return results
|
|
|
|
|
|
class NfoScanResponse(BaseModel):
|
|
"""Response for the NFO scan endpoint."""
|
|
|
|
total: int
|
|
created: int
|
|
updated: int
|
|
errors_count: int
|
|
scan_id: str
|
|
duration_seconds: float
|
|
|
|
|
|
@router.post("/scan", response_model=NfoScanResponse)
|
|
async def scan_nfo(
|
|
_auth: dict = Depends(require_auth),
|
|
anime_service: AnimeService = Depends(get_anime_service),
|
|
) -> NfoScanResponse:
|
|
"""Run an NFO scan across all series.
|
|
|
|
Triggers validation and creation of tvshow.nfo files for all series
|
|
in the anime library.
|
|
|
|
Args:
|
|
_auth: Authentication dependency
|
|
anime_service: AnimeService dependency
|
|
|
|
Returns:
|
|
NfoScanResponse with summary of scan results
|
|
"""
|
|
nfo_scan_service = get_nfo_scan_service()
|
|
result = await nfo_scan_service.scan_all(anime_service)
|
|
return NfoScanResponse(**result)
|