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
530 lines
15 KiB
Python
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
|