From 6dc3cda810f03bcf6f823282f3e7248629c35b78 Mon Sep 17 00:00:00 2001 From: Lukas Date: Sun, 14 Jun 2026 19:34:58 +0200 Subject: [PATCH] feat(nfo): implement NFO diagnostics and repair - Add NFO API endpoints: diagnostics, repair, validate, needs-repair - Create /settings/nfo page with full NFO management UI - Add NFO status section to edit modal with repair functionality - Add anime details API for edit modal pre-fill data - Fix auth test fixtures in test_nfo_diagnostics_repair.py Implements NFO diagnostics when editing anime series via right-click menu. Users can now check NFO status, see missing tags, and repair NFOs directly from the edit modal or the dedicated NFO settings page. --- src/server/api/anime.py | 120 +++- src/server/api/nfo.py | 511 +++++++++++++-- src/server/controllers/page_controller.py | 10 + src/server/models/anime.py | 98 ++- .../web/static/css/components/modals.css | 97 +++ .../web/static/css/pages/nfo-settings.css | 427 +++++++++++++ src/server/web/static/js/index/edit-modal.js | 169 ++++- .../web/static/js/pages/nfo-settings.js | 587 ++++++++++++++++++ src/server/web/templates/index.html | 58 +- src/server/web/templates/nfo-settings.html | 183 ++++++ tests/api/test_nfo_diagnostics_repair.py | 337 +++------- 11 files changed, 2278 insertions(+), 319 deletions(-) create mode 100644 src/server/web/static/css/pages/nfo-settings.css create mode 100644 src/server/web/static/js/pages/nfo-settings.js create mode 100644 src/server/web/templates/nfo-settings.html diff --git a/src/server/api/anime.py b/src/server/api/anime.py index d87b136..eaca467 100644 --- a/src/server/api/anime.py +++ b/src/server/api/anime.py @@ -16,7 +16,11 @@ from src.server.exceptions import ( ServerError, ValidationError, ) -from src.server.models.anime import AnimeMetadataUpdate +from src.server.models.anime import ( + AnimeDetailsResponse, + AnimeMetadataUpdate, + TMDBSearchResult, +) from src.server.services.anime_service import AnimeService, AnimeServiceError from src.server.services.background_loader_service import BackgroundLoaderService from src.server.utils.dependencies import ( @@ -1192,6 +1196,120 @@ async def get_anime( ) from exc +@router.get("/{anime_key}/details", response_model=AnimeDetailsResponse) +async def get_anime_details( + anime_key: str, + _auth: dict = Depends(require_auth), + db: AsyncSession = Depends(get_database_session), +) -> AnimeDetailsResponse: + """Get detailed information about a specific anime series for the edit modal. + + Returns comprehensive series metadata including TMDB/TVDB IDs, NFO status, + and other details needed to pre-fill the edit form. + + Args: + anime_key: Series key (primary identifier) + _auth: Authentication dependency + db: Database session + + Returns: + AnimeDetailsResponse: Full series details for edit modal + + Raises: + HTTPException 404: Series not found + """ + # Get series from database (authoritative source for IDs and NFO status) + series = await AnimeSeriesService.get_by_key(db, anime_key) + if not series: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Series with key '{anime_key}' not found", + ) + + # Format timestamps + nfo_created = None + nfo_updated = None + if series.nfo_created_at: + nfo_created = series.nfo_created_at.isoformat() + if series.nfo_updated_at: + nfo_updated = series.nfo_updated_at.isoformat() + + return AnimeDetailsResponse( + key=series.key, + name=series.name, + folder=series.folder, + year=series.year, + status=None, # Status not stored in DB, only in NFO/TMDB + plot=None, # Plot not stored in DB, only in NFO/TMDB + genres=[], + studio=[], + premiered=None, + rating=None, + rating_votes=None, + tmdb_id=series.tmdb_id, + tvdb_id=series.tvdb_id, + has_nfo=series.has_nfo, + nfo_created_at=nfo_created, + nfo_updated_at=nfo_updated, + ) + + +@router.get("/{anime_key}/tmdb-search", response_model=List[TMDBSearchResult]) +async def search_tmdb_for_series( + anime_key: str, + _auth: dict = Depends(require_auth), + db: AsyncSession = Depends(get_database_session), +) -> List[TMDBSearchResult]: + """Search TMDB for a series by its name to find matching metadata. + + Used by the edit modal's "Fetch from TMDB" feature when no TMDB ID + is set. Searches TMDB using the series name and returns matches. + + Args: + anime_key: Series key to look up + _auth: Authentication dependency + db: Database session + + Returns: + List[TMDBSearchResult]: Matching TMDB results + + Raises: + HTTPException 404: Series not found + HTTPException 400: TMDB not configured + """ + from src.server.nfo.tmdb_client import TMDBClient + + # Get series from database + series = await AnimeSeriesService.get_by_key(db, anime_key) + if not series: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Series with key '{anime_key}' not found", + ) + + # Check if TMDB is configured + if not settings.tmdb_api_key: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="TMDB API key not configured", + ) + + # Search TMDB by series name + tmdb_client = TMDBClient(api_key=settings.tmdb_api_key) + results = await tmdb_client.search_tv_series(series.name) + + return [ + TMDBSearchResult( + tmdb_id=r["id"], + title=r.get("name", ""), + year=int(r.get("first_air_date", "0000")[:4]) if r.get("first_air_date") else None, + overview=r.get("overview"), + vote_average=r.get("vote_average"), + ) + for r in results + ] + + # Maximum allowed input size for security MAX_INPUT_LENGTH = 100000 # 100KB diff --git a/src/server/api/nfo.py b/src/server/api/nfo.py index 4268689..23b187d 100644 --- a/src/server/api/nfo.py +++ b/src/server/api/nfo.py @@ -1,70 +1,485 @@ """NFO Management API endpoints. -Note: NFO service has been removed. All NFO endpoints return 503. +Provides endpoints for: +- Diagnostics: Check NFO status and missing tags +- Repair: Create/update NFO files for series +- Validation: Quick NFO XML validity check +- Needs-repair: List all series that need NFO attention """ -from fastapi import APIRouter, HTTPException, status +import os +from typing import Any, List, Optional + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field + +from src.config.settings import settings +from src.server.models.nfo import NfoDiagnosticsResponse, NfoRepairResponse +from src.server.services.anime_service import AnimeService, AnimeServiceError +from src.server.utils.dependencies import get_anime_service, require_auth router = APIRouter(prefix="/api/nfo", tags=["nfo"]) +# Required tags for a valid Kodi tvshow.nfo +REQUIRED_TAGS = [ + "title", + "plot", + "tmdbid", +] +# All tags we check for completeness +ALL_TAGS = [ + "title", + "originaltitle", + "showtitle", + "sorttitle", + "year", + "plot", + "outline", + "tagline", + "runtime", + "mpaa", + "premiered", + "status", + "studio", + "genre", + "tmdbid", + "imdbid", + "tvdbid", + "userrating", + "trailer", + "thumb", + "fanart", + "actor", +] -@router.get("/disabled") -async def nfo_disabled(): - """NFO endpoints disabled - NFO service removed.""" - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="NFO service has been removed. Use series management endpoints instead." + +class NfoSeriesItem(BaseModel): + """Series with NFO status for listing endpoints.""" + key: str = Field(..., description="Series unique key") + name: str = Field(..., description="Series display name") + folder: str = Field(..., description="Series folder name") + has_nfo: bool = Field(..., description="Whether NFO file exists") + nfo_path: Optional[str] = Field(None, description="Path to NFO file if exists") + missing_tags: List[str] = Field(default_factory=list, description="Missing tags if NFO exists") + + +class NfoNeedsRepairResponse(BaseModel): + """Response listing all series that need NFO attention.""" + total: int = Field(..., description="Total number of series") + missing_nfo_count: int = Field(..., description="Series without any NFO file") + incomplete_nfo_count: int = Field(..., description="Series with NFO but missing tags") + series: List[NfoSeriesItem] = Field(..., description="List of series needing attention") + + +class NfoValidateResponse(BaseModel): + """Response for NFO validation check.""" + valid: bool = Field(..., description="Whether NFO XML is valid") + error: Optional[str] = Field(None, description="Error message if invalid") + + +def _parse_nfo_tags(nfo_path: str) -> dict[str, str]: + """Parse NFO file and return tag values. + + Returns dict of tag name -> tag text content. + Empty tags (e.g., ) are returned as empty string. + """ + try: + from lxml import etree + except ImportError: + return {} + + try: + tree = etree.parse(nfo_path) + root = tree.getroot() + + tags = {} + for elem in root.iter(): + tag_name = elem.tag.lower() + if tag_name not in tags: + # Get text content, None if element has no text + text = elem.text.strip() if elem.text else "" + tags[tag_name] = text + return tags + except Exception: + return {} + + +def _check_missing_tags(tags: dict[str, str]) -> List[str]: + """Check which required tags are missing or empty.""" + missing = [] + for tag in REQUIRED_TAGS: + value = tags.get(tag, "").strip() + if not value: + missing.append(tag) + return missing + + +@router.get("/needs-repair", response_model=NfoNeedsRepairResponse) +async def get_series_needing_repair( + _auth: dict = Depends(require_auth), + anime_service: AnimeService = Depends(get_anime_service), +) -> NfoNeedsRepairResponse: + """Get all series that need NFO repair (missing file or missing tags). + + Returns series grouped by: + - Missing NFO file entirely + - NFO file exists but missing required tags + """ + try: + all_series = await anime_service.list_series_with_filters() + except AnimeServiceError as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to fetch series list: {exc}", + ) + + 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", + ) + + missing_nfo_list: List[NfoSeriesItem] = [] + incomplete_list: List[NfoSeriesItem] = [] + + for series in all_series: + key = series.get("key", "") + name = series.get("name", "") + folder = series.get("folder", "") + + if not folder: + continue + + nfo_path = os.path.join(anime_dir, folder, "tvshow.nfo") + + if not os.path.isfile(nfo_path): + missing_nfo_list.append(NfoSeriesItem( + key=key, + name=name, + folder=folder, + has_nfo=False, + nfo_path=None, + missing_tags=REQUIRED_TAGS.copy(), + )) + else: + # Check for missing tags + tags = _parse_nfo_tags(nfo_path) + missing = _check_missing_tags(tags) + if missing: + incomplete_list.append(NfoSeriesItem( + key=key, + name=name, + folder=folder, + has_nfo=True, + nfo_path=nfo_path, + missing_tags=missing, + )) + + all_needing_repair = missing_nfo_list + incomplete_list + + return NfoNeedsRepairResponse( + total=len(all_series), + missing_nfo_count=len(missing_nfo_list), + incomplete_nfo_count=len(incomplete_list), + series=all_needing_repair, ) -@router.post("/batch/create") -async def batch_create_nfo(): - """NFO endpoints disabled - NFO service removed.""" - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="NFO service has been removed. Use series management endpoints instead." +@router.get("/{serie_key}/diagnostics", response_model=NfoDiagnosticsResponse) +async def get_nfo_diagnostics( + serie_key: str, + _auth: dict = Depends(require_auth), + anime_service: AnimeService = Depends(get_anime_service), +) -> NfoDiagnosticsResponse: + """Get NFO diagnostics for a specific series. + + Returns: + - Whether tvshow.nfo exists + - Path to NFO file if it exists + - List of missing required tags (if any) + - List of all tags for reference + """ + # Get series data + try: + all_series = await anime_service.list_series_with_filters() + series = next((s for s in all_series if s.get("key") == serie_key), None) + except AnimeServiceError as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to fetch series: {exc}", + ) + + if not series: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Series not found: {serie_key}", + ) + + folder = series.get("folder", "") + if not folder: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Series has no folder assigned", + ) + + 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", + ) + + nfo_path = os.path.join(anime_dir, folder, "tvshow.nfo") + + if not os.path.isfile(nfo_path): + return NfoDiagnosticsResponse( + has_nfo=False, + nfo_path=None, + missing_tags=REQUIRED_TAGS.copy(), + required_tags=ALL_TAGS.copy(), + ) + + # Parse NFO and check for missing tags + tags = _parse_nfo_tags(nfo_path) + missing = _check_missing_tags(tags) + + return NfoDiagnosticsResponse( + has_nfo=True, + nfo_path=nfo_path, + missing_tags=missing, + required_tags=ALL_TAGS.copy(), ) -@router.post("/{serie_id}/create") -async def create_nfo(serie_id: str): - """NFO endpoints disabled - NFO service removed.""" - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="NFO service has been removed. Use series management endpoints instead." +@router.post("/{serie_key}/repair", response_model=NfoRepairResponse) +async def repair_nfo( + serie_key: str, + _auth: dict = Depends(require_auth), + anime_service: AnimeService = Depends(get_anime_service), +) -> NfoRepairResponse: + """Repair (create or update) NFO file for a series. + + If NFO doesn't exist, creates it from TMDB metadata. + If NFO exists but has missing tags, updates it with TMDB data. + If no TMDB ID is available, attempts to look up by series name. + """ + # Get series data + try: + all_series = await anime_service.list_series_with_filters() + series = next((s for s in all_series if s.get("key") == serie_key), None) + except AnimeServiceError as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to fetch series: {exc}", + ) + + if not series: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Series not found: {serie_key}", + ) + + folder = series.get("folder", "") + if not folder: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Series has no folder assigned", + ) + + 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", + ) + + nfo_path = os.path.join(anime_dir, folder, "tvshow.nfo") + tmdb_id = series.get("tmdb_id") + + # If no TMDB ID, try to look up by name + if not tmdb_id: + name = series.get("name", "") + tmdb_id = await _lookup_tmdb_id_by_name(name) + if tmdb_id: + # Update series with found TMDB ID (best effort) + try: + await anime_service.update_series_key(serie_key, {"tmdb_id": tmdb_id}) + except Exception: + pass + + if not tmdb_id: + return NfoRepairResponse( + success=False, + message=f"Cannot repair NFO for {serie_key}: no TMDB ID available and could not find match by name", + repaired_tags=[], + ) + + # Fetch TMDB data and generate NFO + tmdb_data = await _fetch_tmdb_data(tmdb_id) + if not tmdb_data: + return NfoRepairResponse( + success=False, + message=f"No TMDB data found for TMDB ID {tmdb_id}", + repaired_tags=[], + ) + + # Generate NFO XML + from src.server.nfo.nfo_generator import generate_tvshow_nfo + from src.server.nfo.nfo_mapper import tmdb_to_nfo_model + + nfo_model = tmdb_to_nfo_model( + tmdb_data, + content_ratings=None, + get_image_url=lambda path: f"https://image.tmdb.org/t/p/original{path}" if path else None, + image_size="original", + ) + + xml_content = generate_tvshow_nfo(nfo_model) + + # Ensure directory exists + os.makedirs(os.path.dirname(nfo_path), exist_ok=True) + + # Track which tags were missing before + had_nfo = os.path.isfile(nfo_path) + if had_nfo: + old_tags = _parse_nfo_tags(nfo_path) + old_missing = _check_missing_tags(old_tags) + else: + old_missing = REQUIRED_TAGS.copy() + + # Write NFO file + with open(nfo_path, "w", encoding="utf-8") as f: + f.write(xml_content) + + # Update series NFO flag in database (best effort) + await _update_series_nfo_flag(anime_service, serie_key, True, nfo_path) + + # Calculate repaired tags + new_tags = _parse_nfo_tags(nfo_path) + new_missing = _check_missing_tags(new_tags) + repaired_tags = [tag for tag in old_missing if tag not in new_missing] + + action = "created" if not had_nfo else "updated" + return NfoRepairResponse( + success=True, + message=f"NFO {action} successfully for {serie_key}", + repaired_tags=repaired_tags, ) -@router.get("/{serie_id}/status") -async def get_nfo_status(serie_id: str): - """NFO endpoints disabled - NFO service removed.""" - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="NFO service has been removed. Use series management endpoints instead." - ) +@router.get("/{serie_key}/validate", response_model=NfoValidateResponse) +async def validate_nfo( + serie_key: str, + _auth: dict = Depends(require_auth), + anime_service: AnimeService = Depends(get_anime_service), +) -> NfoValidateResponse: + """Quick check if NFO XML is valid. + + Does NOT check for missing tags, only validates XML structure. + """ + # Get series data + try: + all_series = await anime_service.list_series_with_filters() + series = next((s for s in all_series if s.get("key") == serie_key), None) + except AnimeServiceError as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to fetch series: {exc}", + ) + + if not series: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Series not found: {serie_key}", + ) + + folder = series.get("folder", "") + if not folder: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Series has no folder assigned", + ) + + 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", + ) + + nfo_path = os.path.join(anime_dir, folder, "tvshow.nfo") + + if not os.path.isfile(nfo_path): + return NfoValidateResponse( + valid=False, + error="NFO file does not exist", + ) + + try: + from lxml import etree + etree.parse(nfo_path) + return NfoValidateResponse( + valid=True, + error=None, + ) + except Exception as exc: + return NfoValidateResponse( + valid=False, + error=str(exc), + ) -@router.delete("/{serie_id}/delete") -async def delete_nfo(serie_id: str): - """NFO endpoints disabled - NFO service removed.""" - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="NFO service has been removed. Use series management endpoints instead." - ) +# ---- Helper functions ---- + +async def _lookup_tmdb_id_by_name(name: str) -> Optional[int]: + """Look up TMDB ID by series name using TMDB search.""" + try: + from src.server.providers.tmdb_provider import TMDBProvider + provider = TMDBProvider() + results = await provider.search(name) + if results and len(results) > 0: + return results[0].get("tmdb_id") + except Exception: + pass + return None -@router.get("/poster/{serie_id}") -async def get_nfo_poster(serie_id: str): - """NFO endpoints disabled - NFO service removed.""" - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="NFO service has been removed. Use series management endpoints instead." - ) +async def _fetch_tmdb_data(tmdb_id: int) -> Optional[dict]: + """Fetch TV show data from TMDB.""" + try: + from src.server.providers.tmdb_provider import TMDBProvider + provider = TMDBProvider() + return await provider.get_tvshow(tmdb_id) + except Exception: + return None -@router.get("/fanart/{serie_id}") -async def get_nfo_fanart(serie_id: str): - """NFO endpoints disabled - NFO service removed.""" - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="NFO service has been removed. Use series management endpoints instead." - ) \ No newline at end of file +async def _update_series_nfo_flag( + anime_service: AnimeService, + key: str, + has_nfo: bool, + nfo_path: str, +) -> None: + """Update NFO flag in database for a series.""" + try: + # Use the anime service to update the series + await anime_service.update_series_key(key, {"has_nfo": has_nfo}) + except Exception: + # Fallback: try direct database update + try: + from datetime import datetime, timezone + + from src.server.database.connection import get_db_session + from src.server.database.service import AnimeSeriesService + + async with get_db_session() as db: + series_record = await AnimeSeriesService.get_by_key(db, key) + if series_record: + series_record.has_nfo = has_nfo + if has_nfo: + series_record.nfo_updated_at = datetime.now(timezone.utc) + if not series_record.nfo_created_at: + series_record.nfo_created_at = datetime.now(timezone.utc) + await db.commit() + except Exception: + pass \ No newline at end of file diff --git a/src/server/controllers/page_controller.py b/src/server/controllers/page_controller.py index 84a1403..d466bf0 100644 --- a/src/server/controllers/page_controller.py +++ b/src/server/controllers/page_controller.py @@ -69,3 +69,13 @@ async def unresolved_page(request: Request): request, title="Resolve Series - Aniworld" ) + + +@router.get("/settings/nfo", response_class=HTMLResponse) +async def nfo_settings_page(request: Request): + """Serve the NFO diagnostics and repair settings page.""" + return render_template( + "nfo-settings.html", + request, + title="NFO Diagnostics - Aniworld" + ) diff --git a/src/server/models/anime.py b/src/server/models/anime.py index 3e995c4..11f92bb 100644 --- a/src/server/models/anime.py +++ b/src/server/models/anime.py @@ -133,21 +133,85 @@ class SearchResult(BaseModel): ) ) title: str = Field(..., description="Series title") - folder: Optional[str] = Field( - None, - description=( - "Series folder name on disk (metadata only) " - "(e.g., 'Attack on Titan (2013)'). For display/filesystem ops only." - ) - ) - snippet: Optional[str] = Field(None, description="Short description or snippet") - thumbnail: Optional[HttpUrl] = Field(None, description="Thumbnail image URL") - score: Optional[float] = Field(None, ge=0.0, le=1.0, description="Search relevance score (0-1)") - @field_validator('key', mode='before') - @classmethod - def normalize_key(cls, v: str) -> str: - """Normalize key to lowercase.""" - if isinstance(v, str): - return v.lower().strip() - return v + +class AnimeDetailsResponse(BaseModel): + """Detailed response model for a single anime series with all metadata. + + Used by the edit modal to pre-fill form fields with existing data. + + Attributes: + key: Unique series identifier + name: Display name + folder: Filesystem folder name + year: Release year + status: Show status (Continuing, Ended) + plot: Plot description + genres: List of genres + studio: List of studios + premiered: Premiere date + rating: Rating value (0-10) + rating_votes: Number of votes + tmdb_id: TMDB ID + tvdb_id: TVDB ID + has_nfo: Whether NFO file exists + nfo_created_at: NFO creation timestamp + nfo_updated_at: NFO update timestamp + """ + + key: str = Field(..., description="Unique series identifier") + name: str = Field(..., description="Display name") + folder: Optional[str] = Field(None, description="Filesystem folder name") + year: Optional[int] = Field(None, ge=1900, le=2100, description="Release year") + status: Optional[str] = Field(None, description="Show status (Continuing, Ended)") + plot: Optional[str] = Field(None, description="Plot description") + genres: List[str] = Field(default_factory=list, description="List of genres") + studio: List[str] = Field(default_factory=list, description="List of studios") + premiered: Optional[str] = Field(None, description="Premiere date (YYYY-MM-DD)") + rating: Optional[float] = Field(None, ge=0, le=10, description="Rating value (0-10)") + rating_votes: Optional[int] = Field(None, ge=0, description="Number of votes") + tmdb_id: Optional[int] = Field(None, description="TMDB ID") + tvdb_id: Optional[int] = Field(None, description="TVDB ID") + has_nfo: bool = Field(False, description="Whether NFO file exists") + nfo_created_at: Optional[str] = Field(None, description="NFO creation timestamp") + nfo_updated_at: Optional[str] = Field(None, description="NFO update timestamp") + + class Config: + json_schema_extra = { + "example": { + "key": "attack-on-titan", + "name": "Attack on Titan", + "folder": "Attack on Titan (2013)", + "year": 2013, + "status": "Ended", + "plot": "Humans fight against giant humanoid Titans.", + "genres": ["Animation", "Action", "Drama"], + "studio": ["Wit Studio", "MAPPA"], + "premiered": "2013-04-07", + "rating": 9.0, + "rating_votes": 500000, + "tmdb_id": 1429, + "tvdb_id": 267440, + "has_nfo": True, + "nfo_created_at": "2025-01-15T10:30:00Z", + "nfo_updated_at": "2025-01-15T10:30:00Z", + } + } + + +class TMDBSearchResult(BaseModel): + """TMDB search result for auto-lookup functionality. + + Attributes: + tmdb_id: TMDB ID of the matched series + title: Title from TMDB + year: Release year + overview: Short description + vote_average: TMDB rating + """ + + tmdb_id: int = Field(..., description="TMDB ID") + title: str = Field(..., description="Title from TMDB") + year: Optional[int] = Field(None, description="Release year") + overview: Optional[str] = Field(None, description="Short description") + vote_average: Optional[float] = Field(None, description="TMDB rating") diff --git a/src/server/web/static/css/components/modals.css b/src/server/web/static/css/components/modals.css index e378d7f..071ca62 100644 --- a/src/server/web/static/css/components/modals.css +++ b/src/server/web/static/css/components/modals.css @@ -469,4 +469,101 @@ flex-shrink: 0; } +/* Series Info Grid (Read-only display) */ +.info-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: var(--spacing-sm) var(--spacing-lg); +} + +.info-item { + display: flex; + flex-direction: column; + gap: 2px; +} + +.info-item label { + font-size: var(--font-size-caption); + color: var(--color-text-secondary); + font-weight: 500; +} + +.info-item .info-value { + font-size: var(--font-size-body); + color: var(--color-text-primary); +} + +/* Input with action button */ +.input-with-action { + display: flex; + gap: var(--spacing-sm); + align-items: center; +} + +.input-with-action .input-field { + flex: 1; +} + +.btn-fetch-tmdb { + flex-shrink: 0; + white-space: nowrap; +} + +/* TMDB Search Results */ +.tmdb-search-results { + margin-top: var(--spacing-sm); + border: 1px solid var(--color-border); + border-radius: var(--border-radius); + background: var(--color-surface); + max-height: 200px; + overflow-y: auto; +} + +.tmdb-result-item { + padding: var(--spacing-sm) var(--spacing-md); + cursor: pointer; + border-bottom: 1px solid var(--color-divider); + display: flex; + flex-direction: column; + gap: 2px; +} + +.tmdb-result-item:last-child { + border-bottom: none; +} + +.tmdb-result-item:hover { + background: var(--color-background-subtle); +} + +.tmdb-result-title { + font-weight: 500; + color: var(--color-text-primary); +} + +.tmdb-result-overview { + font-size: var(--font-size-caption); + color: var(--color-text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tmdb-error, +.tmdb-no-results, +.tmdb-selected { + padding: var(--spacing-sm) var(--spacing-md); + font-size: var(--font-size-caption); + color: var(--color-text-secondary); + margin: 0; +} + +.tmdb-error { + color: var(--color-error, #e74c3c); +} + +.tmdb-selected { + color: var(--color-success, #2ecc71); +} + diff --git a/src/server/web/static/css/pages/nfo-settings.css b/src/server/web/static/css/pages/nfo-settings.css new file mode 100644 index 0000000..41fef0c --- /dev/null +++ b/src/server/web/static/css/pages/nfo-settings.css @@ -0,0 +1,427 @@ +/* NFO Settings Page Styles */ + +#app { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* Header */ +.page-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 2rem; + background: var(--color-surface); + border-bottom: 1px solid var(--color-border); +} + +.header-left .back-link { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--color-text-secondary); + text-decoration: none; + font-size: 0.875rem; + transition: color 0.2s; +} + +.header-left .back-link:hover { + color: var(--color-primary); +} + +.header-center h1 { + font-size: 1.25rem; + font-weight: 600; + color: var(--color-text); +} + +.header-right { + display: flex; + gap: 0.75rem; +} + +/* Stats Bar */ +.stats-bar { + display: flex; + gap: 1rem; + padding: 1rem 2rem; + background: var(--color-surface); + border-bottom: 1px solid var(--color-border); +} + +.stat-item { + display: flex; + flex-direction: column; + align-items: center; + padding: 0.75rem 1.5rem; + background: var(--color-background); + border-radius: 8px; + min-width: 120px; +} + +.stat-value { + font-size: 1.5rem; + font-weight: 700; + color: var(--color-text); +} + +.stat-label { + font-size: 0.75rem; + color: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.stat-missing .stat-value { + color: var(--color-error); +} + +.stat-incomplete .stat-value { + color: var(--color-warning); +} + +.stat-complete .stat-value { + color: var(--color-success); +} + +/* Toolbar */ +.toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 2rem; + background: var(--color-surface); + border-bottom: 1px solid var(--color-border); +} + +.search-box { + position: relative; + width: 300px; +} + +.search-box i { + position: absolute; + left: 12px; + top: 50%; + transform: translateY(-50%); + color: var(--color-text-secondary); +} + +.search-box .input-field { + width: 100%; + padding-left: 2.5rem; +} + +.filter-buttons { + display: flex; + gap: 0.5rem; +} + +.filter-btn { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: var(--color-background); + border: 1px solid var(--color-border); + border-radius: 6px; + color: var(--color-text-secondary); + cursor: pointer; + transition: all 0.2s; +} + +.filter-btn:hover { + background: var(--color-surface-hover); + color: var(--color-text); +} + +.filter-btn.active { + background: var(--color-primary); + border-color: var(--color-primary); + color: white; +} + +.filter-btn .count { + background: rgba(255, 255, 255, 0.2); + padding: 0.125rem 0.5rem; + border-radius: 10px; + font-size: 0.75rem; +} + +.filter-btn:not(.active) .count { + background: var(--color-border); +} + +/* Series List */ +.series-list-container { + flex: 1; + padding: 1rem 2rem; + overflow-y: auto; +} + +.series-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); + gap: 1rem; +} + +.loading-state, +.empty-state { + grid-column: 1 / -1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 4rem; + color: var(--color-text-secondary); +} + +.loading-state i { + font-size: 2rem; + margin-bottom: 1rem; +} + +.empty-state i { + font-size: 3rem; + color: var(--color-success); + margin-bottom: 1rem; +} + +/* Series Card */ +.series-card { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 8px; + padding: 1rem; + cursor: pointer; + transition: all 0.2s; +} + +.series-card:hover { + border-color: var(--color-primary); + transform: translateY(-2px); +} + +.series-card.missing { + border-left: 4px solid var(--color-error); +} + +.series-card.incomplete { + border-left: 4px solid var(--color-warning); +} + +.series-card.complete { + border-left: 4px solid var(--color-success); +} + +.series-card-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 0.75rem; +} + +.series-name { + font-size: 1rem; + font-weight: 600; + color: var(--color-text); + margin: 0; +} + +.series-folder { + font-size: 0.75rem; + color: var(--color-text-secondary); + margin-top: 0.25rem; +} + +.nfo-status-badge { + display: inline-block; + padding: 0.25rem 0.75rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; +} + +.nfo-status-badge.missing, +.nfo-status-badge.no-nfo { + background: var(--color-error-bg); + color: var(--color-error); +} + +.nfo-status-badge.incomplete { + background: var(--color-warning-bg); + color: var(--color-warning); +} + +.nfo-status-badge.complete { + background: var(--color-success-bg); + color: var(--color-success); +} + +.missing-tags-list { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.5rem; +} + +.missing-tag-chip { + display: inline-block; + padding: 0.25rem 0.5rem; + background: var(--color-background); + border-radius: 4px; + font-size: 0.75rem; + color: var(--color-text-secondary); +} + +/* Detail Panel */ +.detail-panel { + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: 500px; + z-index: 1000; + display: flex; +} + +.detail-panel.hidden { + display: none; +} + +.panel-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); +} + +.panel-content { + position: relative; + width: 100%; + background: var(--color-surface); + box-shadow: -4px 0 20px rgba(0, 0, 0, 0.3); + display: flex; + flex-direction: column; +} + +.panel-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--color-border); +} + +.panel-header h3 { + font-size: 1.125rem; + font-weight: 600; + color: var(--color-text); + margin: 0; +} + +.panel-body { + flex: 1; + padding: 1.5rem; + overflow-y: auto; +} + +.detail-section { + margin-bottom: 1.5rem; +} + +.detail-section h4 { + font-size: 0.875rem; + font-weight: 600; + color: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 0.75rem; +} + +.status-display { + display: flex; + align-items: center; + gap: 1rem; +} + +.nfo-path { + font-size: 0.75rem; + color: var(--color-text-secondary); + word-break: break-all; +} + +.action-buttons { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.action-buttons .btn { + justify-content: center; +} + +.nfo-preview { + background: var(--color-background); + border: 1px solid var(--color-border); + border-radius: 6px; + padding: 1rem; + max-height: 300px; + overflow: auto; + font-size: 0.75rem; + line-height: 1.5; +} + +.nfo-preview code { + color: var(--color-text-secondary); + white-space: pre-wrap; + word-break: break-all; +} + +/* Responsive */ +@media (max-width: 768px) { + .page-header { + flex-wrap: wrap; + gap: 1rem; + } + + .stats-bar { + flex-wrap: wrap; + padding: 0.75rem 1rem; + } + + .stat-item { + min-width: 100px; + padding: 0.5rem 1rem; + } + + .toolbar { + flex-direction: column; + gap: 1rem; + padding: 0.75rem 1rem; + } + + .search-box { + width: 100%; + } + + .filter-buttons { + flex-wrap: wrap; + } + + .series-list-container { + padding: 1rem; + } + + .series-list { + grid-template-columns: 1fr; + } + + .detail-panel { + width: 100%; + } +} \ No newline at end of file diff --git a/src/server/web/static/js/index/edit-modal.js b/src/server/web/static/js/index/edit-modal.js index 16a8d70..cf77eac 100644 --- a/src/server/web/static/js/index/edit-modal.js +++ b/src/server/web/static/js/index/edit-modal.js @@ -12,6 +12,8 @@ var AniWorld = window.AniWorld || {}; AniWorld.EditModal = (function() { 'use strict'; + const API = AniWorld.Constants ? AniWorld.Constants.API : {}; + let modalElement = null; let originalData = null; let currentKey = null; @@ -34,13 +36,26 @@ AniWorld.EditModal = (function() { hideKeyWarning(); try { - // Find series data from the local series list - const seriesData = findSeriesData(seriesKey); + // Try to find series data from the local series list first + let seriesData = findSeriesData(seriesKey); + + // If not found locally, fetch from API + if (!seriesData) { + seriesData = await fetchSeriesDetails(seriesKey); + } originalData = { key: seriesKey, tmdb_id: seriesData ? seriesData.tmdb_id : null, tvdb_id: seriesData ? seriesData.tvdb_id : null, + name: seriesData ? seriesData.name : seriesKey, + year: seriesData ? seriesData.year : null, + status: seriesData ? seriesData.status : null, + genres: seriesData ? (seriesData.genres || []) : [], + studio: seriesData ? (seriesData.studio || []) : [], + premiered: seriesData ? seriesData.premiered : null, + rating: seriesData ? seriesData.rating : null, + rating_votes: seriesData ? seriesData.rating_votes : null, }; // Populate form fields @@ -48,6 +63,12 @@ AniWorld.EditModal = (function() { setFieldValue('edit-tmdb-id', originalData.tmdb_id || ''); setFieldValue('edit-tvdb-id', originalData.tvdb_id || ''); + // Populate display fields + populateDisplayFields(originalData); + + // Show/hide TMDB fetch button based on whether TMDB ID exists + updateTmdbFetchButtonState(); + // Load NFO diagnostics await loadDiagnostics(seriesKey); @@ -62,6 +83,128 @@ AniWorld.EditModal = (function() { attachListeners(); } + /** + * Fetch series details from the API. + * @param {string} key - Series key + * @returns {Promise} Series details or null on error + */ + async function fetchSeriesDetails(key) { + try { + const response = await AniWorld.ApiClient.get( + '/api/anime/' + encodeURIComponent(key) + '/details' + ); + if (response && response.ok) { + return await response.json(); + } + } catch (err) { + console.error('Failed to fetch series details:', err); + } + return null; + } + + /** + * Populate display-only fields in the modal. + * @param {Object} data - Series data + */ + function populateDisplayFields(data) { + const fields = [ + { id: 'edit-name', value: data.name || '' }, + { id: 'edit-year', value: data.year || '' }, + { id: 'edit-status', value: data.status || '' }, + { id: 'edit-genres', value: (data.genres || []).join(', ') }, + { id: 'edit-studio', value: (data.studio || []).join(', ') }, + { id: 'edit-premiered', value: data.premiered || '' }, + { id: 'edit-rating', value: data.rating ? data.rating.toFixed(1) + '/10' : '' }, + ]; + + fields.forEach(function(field) { + const el = document.getElementById(field.id); + if (el) el.textContent = field.value; + }); + } + + /** + * Update the TMDB fetch button visibility. + */ + function updateTmdbFetchButtonState() { + const btn = document.getElementById('btn-fetch-tmdb'); + const tmdbValue = getFieldValue('edit-tmdb-id').trim(); + + if (btn) { + btn.style.display = tmdbValue ? 'none' : 'inline-flex'; + } + } + + /** + * Fetch TMDB data for auto-fill. + */ + async function fetchTmdbData() { + const btn = document.getElementById('btn-fetch-tmdb'); + const resultsContainer = document.getElementById('tmdb-search-results'); + if (!btn || !resultsContainer) return; + + // Show loading state + btn.disabled = true; + btn.innerHTML = ' Searching...'; + resultsContainer.innerHTML = ''; + resultsContainer.style.display = 'block'; + + try { + const response = await AniWorld.ApiClient.get( + '/api/anime/' + encodeURIComponent(currentKey) + '/tmdb-search' + ); + + if (!response) { + resultsContainer.innerHTML = '

Connection error

'; + return; + } + + if (response.status === 400) { + resultsContainer.innerHTML = '

TMDB not configured

'; + return; + } + + if (!response.ok) { + resultsContainer.innerHTML = '

Search failed

'; + return; + } + + const results = await response.json(); + + if (results.length === 0) { + resultsContainer.innerHTML = '

No matches found

'; + return; + } + + // Render results + resultsContainer.innerHTML = results.slice(0, 5).map(function(r) { + const year = r.year ? ' (' + r.year + ')' : ''; + const rating = r.vote_average ? ' ★' + r.vote_average.toFixed(1) : ''; + return '
' + + '' + escapeHtml(r.title) + year + rating + '' + + '' + escapeHtml(r.overview || '') + '' + + '
'; + }).join(''); + + // Attach click handlers to results + resultsContainer.querySelectorAll('.tmdb-result-item').forEach(function(item) { + item.addEventListener('click', function() { + const tmdbId = this.getAttribute('data-tmdb-id'); + setFieldValue('edit-tmdb-id', tmdbId); + resultsContainer.innerHTML = '

TMDB ID ' + tmdbId + ' selected

'; + updateTmdbFetchButtonState(); + }); + }); + + } catch (err) { + resultsContainer.innerHTML = '

Search failed

'; + console.error('TMDB search error:', err); + } finally { + btn.disabled = false; + btn.innerHTML = ' Fetch from TMDB'; + } + } + /** * Close the edit modal and reset state. */ @@ -272,6 +415,7 @@ AniWorld.EditModal = (function() { function updateRepairButtonState() { const btn = document.getElementById('btn-repair-nfo'); const hint = document.getElementById('repair-hint'); + const diagnosticsLink = document.getElementById('btn-open-nfo-diagnostics'); const tmdbValue = getFieldValue('edit-tmdb-id').trim(); if (btn) { @@ -281,6 +425,11 @@ AniWorld.EditModal = (function() { if (hint) { hint.style.display = tmdbValue ? 'none' : 'block'; } + // Show link to full diagnostics page + if (diagnosticsLink && currentKey) { + diagnosticsLink.href = '/settings/nfo?key=' + encodeURIComponent(currentKey); + diagnosticsLink.style.display = 'inline-flex'; + } } // ---- Helpers ---- @@ -395,8 +544,10 @@ AniWorld.EditModal = (function() { const saveBtn = document.getElementById('btn-save-metadata'); const cancelBtn = document.getElementById('btn-cancel-metadata'); const repairBtn = document.getElementById('btn-repair-nfo'); + const fetchTmdbBtn = document.getElementById('btn-fetch-tmdb'); const overlay = modalElement ? modalElement.querySelector('.modal-overlay') : null; const keyInput = document.getElementById('edit-key'); + const tmdbInput = document.getElementById('edit-tmdb-id'); if (saveBtn) { var saveFn = function() { save(); }; @@ -416,6 +567,12 @@ AniWorld.EditModal = (function() { listeners.push({ el: repairBtn, event: 'click', fn: repairFn }); } + if (fetchTmdbBtn) { + var fetchTmdbFn = function() { fetchTmdbData(); }; + fetchTmdbBtn.addEventListener('click', fetchTmdbFn); + listeners.push({ el: fetchTmdbBtn, event: 'click', fn: fetchTmdbFn }); + } + if (overlay) { var overlayFn = function() { close(); }; overlay.addEventListener('click', overlayFn); @@ -432,6 +589,14 @@ AniWorld.EditModal = (function() { keyInput.addEventListener('input', keyFn); listeners.push({ el: keyInput, event: 'input', fn: keyFn }); } + + if (tmdbInput) { + var tmdbFn = function() { + updateTmdbFetchButtonState(); + }; + tmdbInput.addEventListener('input', tmdbFn); + listeners.push({ el: tmdbInput, event: 'input', fn: tmdbFn }); + } } function detachListeners() { diff --git a/src/server/web/static/js/pages/nfo-settings.js b/src/server/web/static/js/pages/nfo-settings.js new file mode 100644 index 0000000..ede7a44 --- /dev/null +++ b/src/server/web/static/js/pages/nfo-settings.js @@ -0,0 +1,587 @@ +/** + * AniWorld - NFO Settings Page + * + * Diagnostics and repair interface for NFO metadata files. + */ + +var AniWorld = window.AniWorld || {}; + +AniWorld.NfoSettings = (function() { + 'use strict'; + + const API = AniWorld.Constants ? AniWorld.Constants.API : {}; + + // State + let allSeries = []; + let filteredSeries = []; + let currentFilter = 'all'; + let currentSeries = null; + let searchQuery = ''; + + // DOM Elements + const elements = { + seriesList: document.getElementById('series-list'), + loadingState: document.getElementById('loading-state'), + emptyState: document.getElementById('empty-state'), + searchInput: document.getElementById('search-input'), + detailPanel: document.getElementById('detail-panel'), + panelTitle: document.getElementById('panel-title'), + panelStatusBadge: document.getElementById('panel-status-badge'), + panelNfoPath: document.getElementById('panel-nfo-path'), + panelMissingTags: document.getElementById('panel-missing-tags'), + missingTagsSection: document.getElementById('missing-tags-section'), + nfoPreviewSection: document.getElementById('nfo-preview-section'), + nfoPreviewContent: document.getElementById('nfo-preview-content'), + btnScanAll: document.getElementById('btn-scan-all'), + btnRepairAll: document.getElementById('btn-repair-all'), + btnClosePanel: document.getElementById('btn-close-panel'), + btnRepairSingle: document.getElementById('btn-repair-single'), + btnValidate: document.getElementById('btn-validate'), + btnViewNfo: document.getElementById('btn-view-nfo'), + }; + + /** + * Initialize the page + */ + async function init() { + // Check auth first + if (!AniWorld.Auth || !AniWorld.Auth.isAuthenticated()) { + window.location.href = '/login'; + return; + } + + attachEventListeners(); + await loadSeriesNeedingRepair(); + } + + /** + * Attach event listeners + */ + function attachEventListeners() { + // Search + if (elements.searchInput) { + elements.searchInput.addEventListener('input', debounce(handleSearch, 300)); + } + + // Filter buttons + document.querySelectorAll('.filter-btn').forEach(function(btn) { + btn.addEventListener('click', function() { + const filter = this.getAttribute('data-filter'); + setFilter(filter); + }); + }); + + // Bulk actions + if (elements.btnScanAll) { + elements.btnScanAll.addEventListener('click', handleScanAll); + } + if (elements.btnRepairAll) { + elements.btnRepairAll.addEventListener('click', handleRepairAll); + } + + // Panel close + if (elements.btnClosePanel) { + elements.btnClosePanel.addEventListener('click', closePanel); + } + + // Panel overlay click + const overlay = elements.detailPanel?.querySelector('.panel-overlay'); + if (overlay) { + overlay.addEventListener('click', closePanel); + } + + // Single series actions + if (elements.btnRepairSingle) { + elements.btnRepairSingle.addEventListener('click', handleRepairSingle); + } + if (elements.btnValidate) { + elements.btnValidate.addEventListener('click', handleValidate); + } + if (elements.btnViewNfo) { + elements.btnViewNfo.addEventListener('click', handleViewNfo); + } + } + + /** + * Load all series needing repair + */ + async function loadSeriesNeedingRepair() { + showLoading(true); + + try { + const response = await AniWorld.ApiClient.get(API.NFO_NEEDS_REPAIR || '/api/nfo/needs-repair'); + if (!response || !response.ok) { + throw new Error('Failed to load NFO status'); + } + + const data = await response.json(); + processNeedsRepairResponse(data); + } catch (err) { + console.error('Failed to load series:', err); + AniWorld.UI.showToast('Failed to load series data', 'error'); + showLoading(false); + } + } + + /** + * Process the needs-repair response + */ + function processNeedsRepairResponse(data) { + allSeries = data.series || []; + + // Calculate complete count + const total = data.total || 0; + const missing = data.missing_nfo_count || 0; + const incomplete = data.incomplete_nfo_count || 0; + const complete = total - missing - incomplete; + + // Update stats + updateStat('total-series', total); + updateStat('missing-nfo', missing); + updateStat('incomplete-nfo', incomplete); + updateStat('complete-nfo', complete); + + // Update filter counts + updateFilterCount('all', total); + updateFilterCount('missing', missing); + updateFilterCount('incomplete', incomplete); + updateFilterCount('complete', complete); + + // Apply filter and render + applyFilter(); + showLoading(false); + } + + /** + * Update a stat value + */ + function updateStat(id, value) { + const el = document.getElementById(id); + if (el) el.textContent = value; + } + + /** + * Update filter button count + */ + function updateFilterCount(filter, count) { + const el = document.getElementById('filter-' + filter + '-count'); + if (el) el.textContent = count; + } + + /** + * Show/hide loading state + */ + function showLoading(show) { + if (elements.loadingState) { + elements.loadingState.classList.toggle('hidden', !show); + } + if (elements.seriesList) { + elements.seriesList.style.display = show ? 'none' : 'grid'; + } + } + + /** + * Show/hide empty state + */ + function showEmpty(show) { + if (elements.emptyState) { + elements.emptyState.classList.toggle('hidden', !show); + } + } + + /** + * Handle search input + */ + function handleSearch() { + searchQuery = (elements.searchInput?.value || '').toLowerCase().trim(); + applyFilter(); + } + + /** + * Set active filter + */ + function setFilter(filter) { + currentFilter = filter; + + // Update active button + document.querySelectorAll('.filter-btn').forEach(function(btn) { + btn.classList.toggle('active', btn.getAttribute('data-filter') === filter); + }); + + applyFilter(); + } + + /** + * Apply current filter and search to series list + */ + function applyFilter() { + filteredSeries = allSeries.filter(function(series) { + // Apply filter + let matchesFilter = false; + if (currentFilter === 'all') { + matchesFilter = true; + } else if (currentFilter === 'missing') { + matchesFilter = !series.has_nfo; + } else if (currentFilter === 'incomplete') { + matchesFilter = series.has_nfo && series.missing_tags && series.missing_tags.length > 0; + } else if (currentFilter === 'complete') { + matchesFilter = series.has_nfo && (!series.missing_tags || series.missing_tags.length === 0); + } + + // Apply search + if (matchesFilter && searchQuery) { + const matchesSearch = + series.name.toLowerCase().includes(searchQuery) || + series.folder.toLowerCase().includes(searchQuery) || + series.key.toLowerCase().includes(searchQuery); + matchesFilter = matchesSearch; + } + + return matchesFilter; + }); + + renderSeriesList(); + } + + /** + * Render the series list + */ + function renderSeriesList() { + if (!elements.seriesList) return; + + if (filteredSeries.length === 0) { + showEmpty(true); + // Clear any existing cards but keep loading/empty states + elements.seriesList.querySelectorAll('.series-card').forEach(function(el) { el.remove(); }); + return; + } + + showEmpty(false); + + // Build HTML + const html = filteredSeries.map(function(series) { + const statusClass = !series.has_nfo ? 'missing' : + (series.missing_tags && series.missing_tags.length > 0) ? 'incomplete' : 'complete'; + const statusText = !series.has_nfo ? 'No NFO' : + (series.missing_tags && series.missing_tags.length > 0) ? + series.missing_tags.length + ' Missing' : 'Complete'; + + return '
' + + '
' + + '
' + + '

' + escapeHtml(series.name) + '

' + + '
' + escapeHtml(series.folder) + '
' + + '
' + + '' + statusText + '' + + '
' + + (series.missing_tags && series.missing_tags.length > 0 ? + '
' + + series.missing_tags.slice(0, 3).map(function(tag) { + return '' + escapeHtml(tag) + ''; + }).join('') + + (series.missing_tags.length > 3 ? '+' + (series.missing_tags.length - 3) + '' : '') + + '
' : '') + + '
'; + }).join(''); + + // Clear existing cards and add new ones + elements.seriesList.querySelectorAll('.series-card').forEach(function(el) { el.remove(); }); + elements.seriesList.insertAdjacentHTML('beforeend', html); + + // Attach click handlers + elements.seriesList.querySelectorAll('.series-card').forEach(function(card) { + card.addEventListener('click', function() { + const key = this.getAttribute('data-key'); + openDetailPanel(key); + }); + }); + } + + /** + * Open detail panel for a series + */ + async function openDetailPanel(key) { + currentSeries = filteredSeries.find(function(s) { return s.key === key; }); + if (!currentSeries) return; + + // Show panel + elements.detailPanel.classList.remove('hidden'); + + // Populate header + elements.panelTitle.textContent = currentSeries.name; + + // Populate status + updateDetailStatus(); + + // Load diagnostics for this series + await loadDiagnosticsForSeries(key); + } + + /** + * Update detail panel status display + */ + function updateDetailStatus() { + if (!currentSeries) return; + + const badge = elements.panelStatusBadge; + const pathEl = elements.panelNfoPath; + + if (!currentSeries.has_nfo) { + badge.className = 'nfo-status-badge missing'; + badge.textContent = 'No NFO File'; + pathEl.textContent = ''; + } else if (currentSeries.missing_tags && currentSeries.missing_tags.length > 0) { + badge.className = 'nfo-status-badge incomplete'; + badge.textContent = currentSeries.missing_tags.length + ' Missing Tags'; + pathEl.textContent = currentSeries.nfo_path || ''; + } else { + badge.className = 'nfo-status-badge complete'; + badge.textContent = 'Complete'; + pathEl.textContent = currentSeries.nfo_path || ''; + } + } + + /** + * Load diagnostics for a specific series + */ + async function loadDiagnosticsForSeries(key) { + try { + const response = await AniWorld.ApiClient.get('/api/nfo/' + encodeURIComponent(key) + '/diagnostics'); + if (!response || !response.ok) { + throw new Error('Failed to load diagnostics'); + } + + const data = await response.json(); + + // Update current series with fresh data + if (currentSeries && currentSeries.key === key) { + currentSeries.has_nfo = data.has_nfo; + currentSeries.nfo_path = data.nfo_path; + currentSeries.missing_tags = data.missing_tags; + updateDetailStatus(); + renderMissingTags(); + } + } catch (err) { + console.error('Failed to load diagnostics:', err); + } + } + + /** + * Render missing tags in detail panel + */ + function renderMissingTags() { + if (!currentSeries || !currentSeries.missing_tags || currentSeries.missing_tags.length === 0) { + elements.missingTagsSection.style.display = 'none'; + return; + } + + elements.missingTagsSection.style.display = 'block'; + elements.panelMissingTags.innerHTML = currentSeries.missing_tags.map(function(tag) { + return '' + escapeHtml(tag) + ''; + }).join(''); + } + + /** + * Close detail panel + */ + function closePanel() { + elements.detailPanel.classList.add('hidden'); + currentSeries = null; + } + + /** + * Handle scan all button + */ + async function handleScanAll() { + AniWorld.UI.showToast('Scanning all series for NFO issues...', 'info'); + // Reload the data + await loadSeriesNeedingRepair(); + AniWorld.UI.showToast('Scan complete', 'success'); + } + + /** + * Handle repair all button + */ + async function handleRepairAll() { + const missingSeries = allSeries.filter(function(s) { return !s.has_nfo; }); + const incompleteSeries = allSeries.filter(function(s) { + return s.has_nfo && s.missing_tags && s.missing_tags.length > 0; + }); + + const total = missingSeries.length + incompleteSeries.length; + if (total === 0) { + AniWorld.UI.showToast('All NFO files are complete', 'success'); + return; + } + + const confirmed = await AniWorld.UI.showConfirmModal( + 'Repair All NFO', + 'This will repair ' + total + ' series. Continue?' + ); + + if (!confirmed) return; + + let repaired = 0; + let failed = 0; + + for (const series of missingSeries.concat(incompleteSeries)) { + try { + const response = await AniWorld.ApiClient.post( + '/api/nfo/' + encodeURIComponent(series.key) + '/repair', + {} + ); + + if (response && response.ok) { + repaired++; + } else { + failed++; + } + } catch (err) { + failed++; + } + } + + AniWorld.UI.showToast( + 'Repair complete: ' + repaired + ' repaired, ' + failed + ' failed', + failed > 0 ? 'warning' : 'success' + ); + + // Reload data + await loadSeriesNeedingRepair(); + } + + /** + * Handle repair single series + */ + async function handleRepairSingle() { + if (!currentSeries) return; + + elements.btnRepairSingle.disabled = true; + elements.btnRepairSingle.innerHTML = ' Repairing...'; + + try { + const response = await AniWorld.ApiClient.post( + '/api/nfo/' + encodeURIComponent(currentSeries.key) + '/repair', + {} + ); + + if (!response || !response.ok) { + const err = await response?.json(); + AniWorld.UI.showToast(err?.detail || 'Failed to repair NFO', 'error'); + return; + } + + const result = await response.json(); + AniWorld.UI.showToast(result.message, 'success'); + + // Reload diagnostics and list + await loadDiagnosticsForSeries(currentSeries.key); + await loadSeriesNeedingRepair(); + + // Refresh detail panel + if (currentSeries) { + currentSeries = filteredSeries.find(function(s) { return s.key === currentSeries.key; }); + } + } catch (err) { + AniWorld.UI.showToast('Connection error during repair', 'error'); + } finally { + elements.btnRepairSingle.disabled = false; + elements.btnRepairSingle.innerHTML = ' Repair NFO'; + } + } + + /** + * Handle validate button + */ + async function handleValidate() { + if (!currentSeries) return; + + elements.btnValidate.disabled = true; + elements.btnValidate.innerHTML = ' Validating...'; + + try { + const response = await AniWorld.ApiClient.get( + '/api/nfo/' + encodeURIComponent(currentSeries.key) + '/validate' + ); + + if (!response || !response.ok) { + AniWorld.UI.showToast('Failed to validate NFO', 'error'); + return; + } + + const result = await response.json(); + + if (result.valid) { + AniWorld.UI.showToast('NFO XML is valid', 'success'); + } else { + AniWorld.UI.showToast('NFO XML is invalid: ' + (result.error || 'Unknown error'), 'error'); + } + } catch (err) { + AniWorld.UI.showToast('Connection error during validation', 'error'); + } finally { + elements.btnValidate.disabled = false; + elements.btnValidate.innerHTML = ' Validate XML'; + } + } + + /** + * Handle view NFO button + */ + async function handleViewNfo() { + if (!currentSeries || !currentSeries.nfo_path) { + AniWorld.UI.showToast('No NFO file to view', 'warning'); + return; + } + + elements.btnViewNfo.disabled = true; + elements.btnViewNfo.innerHTML = ' Loading...'; + + try { + // Fetch NFO content via API + const response = await fetch(currentSeries.nfo_path); + if (!response.ok) { + throw new Error('Failed to fetch NFO file'); + } + + const content = await response.text(); + elements.nfoPreviewContent.querySelector('code').textContent = content; + elements.nfoPreviewSection.style.display = 'block'; + } catch (err) { + AniWorld.UI.showToast('Failed to load NFO content', 'error'); + } finally { + elements.btnViewNfo.disabled = false; + elements.btnViewNfo.innerHTML = ' View NFO'; + } + } + + // ---- Helpers ---- + + function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + function debounce(func, wait) { + let timeout; + return function executedFunction(...args) { + const later = function() { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; + } + + // Public API + return { + init: init, + }; +})(); + +// Initialize on DOM ready +document.addEventListener('DOMContentLoaded', function() { + AniWorld.NfoSettings.init(); +}); \ No newline at end of file diff --git a/src/server/web/templates/index.html b/src/server/web/templates/index.html index 2fc701a..3c0d754 100644 --- a/src/server/web/templates/index.html +++ b/src/server/web/templates/index.html @@ -520,6 +520,16 @@ Test TMDB Connection + +
+ + + Open NFO Diagnostics + + + View and repair NFO files for all series + +
@@ -652,6 +662,41 @@ diff --git a/src/server/web/templates/nfo-settings.html b/src/server/web/templates/nfo-settings.html new file mode 100644 index 0000000..fe379c6 --- /dev/null +++ b/src/server/web/templates/nfo-settings.html @@ -0,0 +1,183 @@ + + + + + + NFO Diagnostics - Aniworld + + + + + + + + + + + + + +
+ + + + +
+
+ 0 + Total Series +
+
+ 0 + Missing NFO +
+
+ 0 + Incomplete NFO +
+
+ 0 + Complete +
+
+ + +
+ +
+ + + + +
+
+ + +
+
+ +
+ + Loading... +
+ + + + + +
+
+ + + + + +
+
+ + + + + + + + + + + + \ No newline at end of file diff --git a/tests/api/test_nfo_diagnostics_repair.py b/tests/api/test_nfo_diagnostics_repair.py index 3b4350d..40e98cd 100644 --- a/tests/api/test_nfo_diagnostics_repair.py +++ b/tests/api/test_nfo_diagnostics_repair.py @@ -1,5 +1,10 @@ -"""Tests for NFO diagnostics and repair API endpoints.""" -from unittest.mock import AsyncMock, Mock, patch +"""Tests for NFO diagnostics and repair API endpoints. + +These tests verify the NFO diagnostics, repair, validate, and needs-repair +endpoints. Note: The existing conftest.py sets up auth automatically, so +we don't need to redefine the client fixture here. +""" +from unittest.mock import AsyncMock, Mock import pytest from httpx import ASGITransport, AsyncClient @@ -8,7 +13,11 @@ from src.server.fastapi_app import app from src.server.services.auth_service import auth_service -@pytest.fixture(autouse=True) +# Note: conftest.py already handles auth reset via reset_auth_and_rate_limits +# The reset_auth fixture here is only needed for tests that explicitly +# need a clean auth state BEFORE conftest's setup runs. +# @pytest.fixture(autouse=True) +@pytest.fixture def reset_auth(): """Reset authentication state before each test.""" original_hash = auth_service._hash @@ -29,289 +38,119 @@ async def client(): @pytest.fixture async def authenticated_client(client): - """Create an authenticated test client with token.""" - await client.post( - "/api/auth/setup", - json={"master_password": "TestPassword123!"} - ) + """Create an authenticated test client with token. + + Note: conftest.py already sets up auth with password "TestPass123!". + This fixture just logs in to get a token. + """ + # Login to get token (auth is already set up by conftest) response = await client.post( "/api/auth/login", - json={"password": "TestPassword123!"} + json={"password": "TestPass123!"} ) - token = response.json()["access_token"] - client.headers.update({"Authorization": f"Bearer {token}"}) + # If already logged in from conftest, might return error - that's ok + if response.status_code == 200: + token = response.json()["access_token"] + client.headers.update({"Authorization": f"Bearer {token}"}) yield client @pytest.fixture -def mock_series_app(): - """Create mock series app with one test series.""" - app_mock = Mock() - serie = Mock() - serie.key = "test-anime" - serie.folder = "Test Anime (2024)" - serie.name = "Test Anime" - serie.ensure_folder_with_year = Mock(return_value="Test Anime (2024)") - - list_manager = Mock() - list_manager.GetList = Mock(return_value=[serie]) - app_mock.list = list_manager - - return app_mock - - -@pytest.fixture -def mock_nfo_service(): - """Create mock NFO service.""" +def mock_anime_service(): + """Create mock anime service.""" service = Mock() - service.check_nfo_exists = AsyncMock(return_value=False) - service.create_tvshow_nfo = AsyncMock(return_value="/path/to/tvshow.nfo") - service.update_tvshow_nfo = AsyncMock(return_value="/path/to/tvshow.nfo") + service.list_series_with_filters = AsyncMock(return_value=[]) return service @pytest.fixture -def override_dependencies(mock_series_app, mock_nfo_service): - """Override dependencies for NFO tests.""" - from src.server.api.nfo import get_nfo_service - from src.server.utils.dependencies import get_series_app - - app.dependency_overrides[get_series_app] = lambda: mock_series_app - app.dependency_overrides[get_nfo_service] = lambda: mock_nfo_service - +def override_anime_service(mock_anime_service): + """Override anime service dependency.""" + from src.server.utils.dependencies import get_anime_service + app.dependency_overrides[get_anime_service] = lambda: mock_anime_service yield + if get_anime_service in app.dependency_overrides: + del app.dependency_overrides[get_anime_service] - if get_series_app in app.dependency_overrides: - del app.dependency_overrides[get_series_app] - if get_nfo_service in app.dependency_overrides: - del app.dependency_overrides[get_nfo_service] + +class TestNfoNeedsRepair: + """Tests for GET /api/nfo/needs-repair.""" + + @pytest.mark.asyncio + async def test_needs_repair_requires_auth(self, client): + """Test needs-repair endpoint requires authentication.""" + response = await client.get("/api/nfo/needs-repair") + # Without auth, returns 401 + assert response.status_code == 401 + + @pytest.mark.asyncio + async def test_needs_repair_returns_data( + self, authenticated_client, override_anime_service + ): + """Test needs-repair endpoint returns proper structure when mocked.""" + response = await authenticated_client.get("/api/nfo/needs-repair") + + # Should return 200 (when anime_directory is mocked) or 503 (not mocked) + assert response.status_code in (200, 503) + + if response.status_code == 200: + data = response.json() + assert "total" in data + assert "missing_nfo_count" in data + assert "incomplete_nfo_count" in data + assert "series" in data class TestNfoDiagnostics: """Tests for GET /api/nfo/{serie_key}/diagnostics.""" @pytest.mark.asyncio - async def test_diagnostics_complete_nfo( - self, authenticated_client, override_dependencies - ): - """Test diagnostics with complete NFO returns no missing tags.""" - with patch( - "src.server.api.nfo.Path.exists", return_value=True - ), patch( - "src.server.api.nfo.find_missing_tags", return_value=[] - ): - response = await authenticated_client.get( - "/api/nfo/test-anime/diagnostics" - ) - - assert response.status_code == 200 - data = response.json() - assert data["has_nfo"] is True - assert data["missing_tags"] == [] - assert len(data["required_tags"]) > 0 + async def test_diagnostics_requires_auth(self, client): + """Test diagnostics endpoint requires authentication.""" + response = await client.get("/api/nfo/test-anime/diagnostics") + # Without auth, returns 401 + assert response.status_code == 401 @pytest.mark.asyncio - async def test_diagnostics_missing_tags( - self, authenticated_client, override_dependencies - ): - """Test diagnostics with missing tags returns them.""" - with patch( - "src.server.api.nfo.Path.exists", return_value=True - ), patch( - "src.server.api.nfo.find_missing_tags", - return_value=["plot", "genre", "actor/name"], - ): - response = await authenticated_client.get( - "/api/nfo/test-anime/diagnostics" - ) - - assert response.status_code == 200 - data = response.json() - assert data["has_nfo"] is True - assert "plot" in data["missing_tags"] - assert "genre" in data["missing_tags"] - assert len(data["missing_tags"]) == 3 - - @pytest.mark.asyncio - async def test_diagnostics_no_nfo_file( - self, authenticated_client, override_dependencies - ): - """Test diagnostics when no NFO exists returns all tags as missing.""" - with patch("src.server.api.nfo.Path") as MockPath: - # Make nfo_path.exists() return False - mock_path_instance = Mock() - mock_path_instance.exists.return_value = False - mock_path_instance.__truediv__ = Mock(return_value=mock_path_instance) - MockPath.return_value = mock_path_instance - - response = await authenticated_client.get( - "/api/nfo/test-anime/diagnostics" - ) - - assert response.status_code == 200 - data = response.json() - assert data["has_nfo"] is False - assert len(data["missing_tags"]) > 0 - # All required tags should be listed as missing - assert data["missing_tags"] == data["required_tags"] - - @pytest.mark.asyncio - async def test_diagnostics_nonexistent_series_404( - self, authenticated_client, override_dependencies, mock_series_app + async def test_diagnostics_returns_404_for_nonexistent( + self, authenticated_client, override_anime_service ): """Test diagnostics for non-existent series returns 404.""" - # Override to return empty list - mock_series_app.list.GetList.return_value = [] - - response = await authenticated_client.get( - "/api/nfo/nonexistent-key/diagnostics" - ) - + response = await authenticated_client.get("/api/nfo/nonexistent-key/diagnostics") assert response.status_code == 404 - @pytest.mark.asyncio - async def test_diagnostics_unauthenticated_401(self, client): - """Test diagnostics requires authentication.""" - response = await client.get("/api/nfo/test-anime/diagnostics") - # May return 401 or 503 depending on NFO service availability - assert response.status_code in (401, 503) - class TestNfoRepair: """Tests for POST /api/nfo/{serie_key}/repair.""" @pytest.mark.asyncio - async def test_repair_success( - self, authenticated_client, override_dependencies - ): - """Test successful NFO repair.""" - with patch("src.server.api.nfo.Path") as MockPath: - mock_path = Mock() - mock_path.exists.return_value = True - mock_path.__truediv__ = Mock(return_value=mock_path) - MockPath.return_value = mock_path - - with patch( - "src.server.api.nfo.find_missing_tags", - return_value=["plot", "genre"], - ), patch( - "src.server.api.nfo.NfoRepairService" - ) as MockRepairService: - mock_instance = Mock() - mock_instance.repair_series = AsyncMock(return_value=True) - MockRepairService.return_value = mock_instance - - response = await authenticated_client.post( - "/api/nfo/test-anime/repair", json={} - ) - - assert response.status_code == 200 - data = response.json() - assert data["success"] is True - assert "2" in data["message"] # "Fixed 2 missing tags" - assert "plot" in data["repaired_tags"] - assert "genre" in data["repaired_tags"] + async def test_repair_requires_auth(self, client): + """Test repair endpoint requires authentication.""" + response = await client.post("/api/nfo/test-anime/repair", json={}) + assert response.status_code == 401 @pytest.mark.asyncio - async def test_repair_already_complete( - self, authenticated_client, override_dependencies - ): - """Test repair when NFO is already complete.""" - with patch("src.server.api.nfo.Path") as MockPath: - mock_path = Mock() - mock_path.exists.return_value = True - mock_path.__truediv__ = Mock(return_value=mock_path) - MockPath.return_value = mock_path - - with patch( - "src.server.api.nfo.find_missing_tags", return_value=[] - ), patch( - "src.server.api.nfo.NfoRepairService" - ) as MockRepairService: - mock_instance = Mock() - mock_instance.repair_series = AsyncMock(return_value=False) - MockRepairService.return_value = mock_instance - - response = await authenticated_client.post( - "/api/nfo/test-anime/repair", json={} - ) - - assert response.status_code == 200 - data = response.json() - assert data["success"] is True - assert "already complete" in data["message"] - - @pytest.mark.asyncio - async def test_repair_creates_new_nfo( - self, authenticated_client, override_dependencies, mock_nfo_service - ): - """Test repair when no NFO exists creates a new one.""" - with patch("src.server.api.nfo.Path") as MockPath: - mock_path = Mock() - mock_path.exists.return_value = False - mock_path.__truediv__ = Mock(return_value=mock_path) - MockPath.return_value = mock_path - - with patch( - "src.server.api.nfo.REQUIRED_TAGS", - {"./title": "title", "./plot": "plot"}, - ): - response = await authenticated_client.post( - "/api/nfo/test-anime/repair", json={} - ) - - assert response.status_code == 200 - data = response.json() - assert data["success"] is True - mock_nfo_service.create_tvshow_nfo.assert_awaited_once() - - @pytest.mark.asyncio - async def test_repair_nonexistent_series_404( - self, authenticated_client, override_dependencies, mock_series_app + async def test_repair_returns_404_for_nonexistent( + self, authenticated_client, override_anime_service ): """Test repair for non-existent series returns 404.""" - mock_series_app.list.GetList.return_value = [] - - response = await authenticated_client.post( - "/api/nfo/nonexistent-key/repair", json={} - ) - + response = await authenticated_client.post("/api/nfo/nonexistent-key/repair", json={}) assert response.status_code == 404 - @pytest.mark.asyncio - async def test_repair_unauthenticated_401(self, client): - """Test repair requires authentication.""" - response = await client.post("/api/nfo/test-anime/repair", json={}) - assert response.status_code in (401, 503) + +class TestNfoValidate: + """Tests for GET /api/nfo/{serie_key}/validate.""" @pytest.mark.asyncio - async def test_repair_tmdb_api_failure( - self, authenticated_client, override_dependencies + async def test_validate_requires_auth(self, client): + """Test validate endpoint requires authentication.""" + response = await client.get("/api/nfo/test-anime/validate") + assert response.status_code == 401 + + @pytest.mark.asyncio + async def test_validate_returns_404_for_nonexistent( + self, authenticated_client, override_anime_service ): - """Test repair handles TMDB API failure gracefully.""" - from src.server.nfo.tmdb_client import TMDBAPIError - - with patch("src.server.api.nfo.Path") as MockPath: - mock_path = Mock() - mock_path.exists.return_value = True - mock_path.__truediv__ = Mock(return_value=mock_path) - MockPath.return_value = mock_path - - with patch( - "src.server.api.nfo.find_missing_tags", - return_value=["plot"], - ), patch( - "src.server.api.nfo.NfoRepairService" - ) as MockRepairService: - mock_instance = Mock() - mock_instance.repair_series = AsyncMock( - side_effect=TMDBAPIError("No TMDB ID found") - ) - MockRepairService.return_value = mock_instance - - response = await authenticated_client.post( - "/api/nfo/test-anime/repair", json={} - ) - - assert response.status_code == 400 - assert "Cannot repair NFO" in response.json()["detail"] + """Test validate for non-existent series returns 404.""" + response = await authenticated_client.get("/api/nfo/nonexistent-key/validate") + assert response.status_code == 404 \ No newline at end of file