Files
Aniworld/src/server/api/nfo.py
Lukas eabce18e41 feat(anime): rename NFO Diagnostics to Anime Settings + add edit endpoints
Replaces the read-only 'NFO Diagnostics' page with a full per-anime
Settings page reached from the right-click context menu on series cards.
Users can now view and edit key, name, folder, tmdb_id, tvdb_id and site
for each anime; changes are persisted to the DB and optionally written
back to the NFO file or used to regenerate it.

Backend
- Rename NfoDiagnosticsResponse -> NfoSettingsResponse,
  NfoSeriesDiagnostics -> NfoSeriesSettings
- Rename get_nfo_diagnostics -> get_nfo_settings,
  repair_nfo -> repair_nfo_settings
- Fix nfo.py bug: repair was calling non-existent
  update_series_nfo_status(); now uses update_nfo_status() and an
  explicit AnimeSeriesService.update(nfo_path=...)
- New endpoints on /api/anime/{key}:
    GET  /settings            -> AnimeSettingsResponse
    PUT  /settings            -> AnimeSettingsResponse
                                 (body: name/folder/tmdb_id/tvdb_id/site,
                                  options: apply_to_nfo, rename_disk)
    POST /regenerate-nfo      -> AnimeSettingsRegenerateNfoResponse
- New Pydantic models: AnimeSettingsResponse,
  AnimeSettingsUpdateRequest, AnimeSettingsRegenerateNfoResponse
- /anime/settings page route; /settings/nfo now 301-redirects to it

Frontend
- New AniWorld.AnimeSettingsManager JS module (single-page form,
  no tabs) with public API init/loadSeries/saveSettings/regenerateNfo/
  validateField/populateForm/showSaveSuccess/showError
- New anime-settings.html template + anime-settings.css
- Right-click menu: data-action 'nfo-diagnostics' replaced by
  'anime-settings' (label 'Anime Settings'), navigates to
  /anime/settings?key=...
- Library 'Open NFO Diagnostics' link renamed to 'Open Anime Settings'

Bug fix
- context-menu click handler was calling hide() BEFORE building the
  navigation URL, which cleared currentSeriesKey to null and produced
  /anime/settings?key=null. Captures the key into a local const first.
  Regression-locked by tests/frontend/unit/context_menu.test.js.

Tests
- 21 new pytest tests in tests/api/test_anime_settings_endpoints.py
  (GET/PUT/regenerate-nfo, auth, validation, nfo-repair bug regression)
- tests/api/test_nfo_endpoints.py trimmed to 6 focused tests
- 31 new Vitest unit tests for AnimeSettingsManager
- 5 new Vitest unit tests for ContextMenu (incl. source-invariant
  regression guard for the hide()-before-key bug)
- 5 new Playwright E2E tests covering right-click, direct nav,
  legacy /settings/nfo redirect, and context-menu labels
- New vitest.config.js (environment: happy-dom)

Docs
- Docs/API.md: new section 'Anime Settings Endpoints'
- Docs/CHANGELOG.md: documents the rename and the context-menu bug fix

Verified
- pytest: 27/27 (21 new + 6 trimmed nfo)
- vitest: 36/36 (31 anime-settings + 5 context-menu)
- playwright e2e: 5/5
2026-06-21 07:52:22 +02:00

530 lines
15 KiB
Python

"""NFO Management API endpoints.
Provides endpoints for NFO settings, repair, and validation for anime series.
"""
import logging
import os
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 (
NfoRepairResponse,
NfoSeriesSettings,
NfoSettingsResponse,
)
from src.server.services.anime_service import AnimeService
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,
series_data=series_data,
)
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 provider."""
try:
from src.server.providers.tmdb_provider import TMDBProvider
provider = TMDBProvider()
results = await provider.search(name)
if results:
return results[0].get("tmdb_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,
series_data: dict,
) -> List[str]:
"""Create or update NFO file for a series.
Returns list of tags that were repaired/added.
"""
from src.server.providers.tmdb_provider import TMDBProvider
from src.server.nfo.nfo_generator import generate_tvshow_nfo
from src.server.nfo.nfo_mapper import tmdb_to_nfo_model
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
provider = TMDBProvider()
tmdb_data = await provider.get_series_info(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=provider.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("/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,
series_data=series_data,
)
results["success"] += 1
except Exception as exc:
results["failed"] += 1
results["errors"].append(f"{key}: {str(exc)}")
return results