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.
This commit is contained in:
2026-06-14 19:34:58 +02:00
parent 75084b3941
commit 6dc3cda810
11 changed files with 2278 additions and 319 deletions

View File

@@ -16,7 +16,11 @@ from src.server.exceptions import (
ServerError, ServerError,
ValidationError, 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.anime_service import AnimeService, AnimeServiceError
from src.server.services.background_loader_service import BackgroundLoaderService from src.server.services.background_loader_service import BackgroundLoaderService
from src.server.utils.dependencies import ( from src.server.utils.dependencies import (
@@ -1192,6 +1196,120 @@ async def get_anime(
) from exc ) 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 # Maximum allowed input size for security
MAX_INPUT_LENGTH = 100000 # 100KB MAX_INPUT_LENGTH = 100000 # 100KB

View File

@@ -1,70 +1,485 @@
"""NFO Management API endpoints. """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"]) 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(): class NfoSeriesItem(BaseModel):
"""NFO endpoints disabled - NFO service removed.""" """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., <plot></plot>) 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( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="NFO service has been removed. Use series management endpoints instead." 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") @router.get("/{serie_key}/diagnostics", response_model=NfoDiagnosticsResponse)
async def batch_create_nfo(): async def get_nfo_diagnostics(
"""NFO endpoints disabled - NFO service removed.""" 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( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="NFO service has been removed. Use series management endpoints instead." 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") @router.post("/{serie_key}/repair", response_model=NfoRepairResponse)
async def create_nfo(serie_id: str): async def repair_nfo(
"""NFO endpoints disabled - NFO service removed.""" 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( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="NFO service has been removed. Use series management endpoints instead." 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") @router.get("/{serie_key}/validate", response_model=NfoValidateResponse)
async def get_nfo_status(serie_id: str): async def validate_nfo(
"""NFO endpoints disabled - NFO service removed.""" 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( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="NFO service has been removed. Use series management endpoints instead." 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") # ---- Helper functions ----
async def delete_nfo(serie_id: str):
"""NFO endpoints disabled - NFO service removed.""" async def _lookup_tmdb_id_by_name(name: str) -> Optional[int]:
raise HTTPException( """Look up TMDB ID by series name using TMDB search."""
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, try:
detail="NFO service has been removed. Use series management endpoints instead." 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 _fetch_tmdb_data(tmdb_id: int) -> Optional[dict]:
async def get_nfo_poster(serie_id: str): """Fetch TV show data from TMDB."""
"""NFO endpoints disabled - NFO service removed.""" try:
raise HTTPException( from src.server.providers.tmdb_provider import TMDBProvider
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, provider = TMDBProvider()
detail="NFO service has been removed. Use series management endpoints instead." return await provider.get_tvshow(tmdb_id)
) except Exception:
return None
@router.get("/fanart/{serie_id}") async def _update_series_nfo_flag(
async def get_nfo_fanart(serie_id: str): anime_service: AnimeService,
"""NFO endpoints disabled - NFO service removed.""" key: str,
raise HTTPException( has_nfo: bool,
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, nfo_path: str,
detail="NFO service has been removed. Use series management endpoints instead." ) -> 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

View File

@@ -69,3 +69,13 @@ async def unresolved_page(request: Request):
request, request,
title="Resolve Series - Aniworld" 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"
)

View File

@@ -133,21 +133,85 @@ class SearchResult(BaseModel):
) )
) )
title: str = Field(..., description="Series title") 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 class AnimeDetailsResponse(BaseModel):
def normalize_key(cls, v: str) -> str: """Detailed response model for a single anime series with all metadata.
"""Normalize key to lowercase."""
if isinstance(v, str): Used by the edit modal to pre-fill form fields with existing data.
return v.lower().strip()
return v 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")

View File

@@ -469,4 +469,101 @@
flex-shrink: 0; 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);
}

View File

@@ -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%;
}
}

View File

@@ -12,6 +12,8 @@ var AniWorld = window.AniWorld || {};
AniWorld.EditModal = (function() { AniWorld.EditModal = (function() {
'use strict'; 'use strict';
const API = AniWorld.Constants ? AniWorld.Constants.API : {};
let modalElement = null; let modalElement = null;
let originalData = null; let originalData = null;
let currentKey = null; let currentKey = null;
@@ -34,13 +36,26 @@ AniWorld.EditModal = (function() {
hideKeyWarning(); hideKeyWarning();
try { try {
// Find series data from the local series list // Try to find series data from the local series list first
const seriesData = findSeriesData(seriesKey); let seriesData = findSeriesData(seriesKey);
// If not found locally, fetch from API
if (!seriesData) {
seriesData = await fetchSeriesDetails(seriesKey);
}
originalData = { originalData = {
key: seriesKey, key: seriesKey,
tmdb_id: seriesData ? seriesData.tmdb_id : null, tmdb_id: seriesData ? seriesData.tmdb_id : null,
tvdb_id: seriesData ? seriesData.tvdb_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 // Populate form fields
@@ -48,6 +63,12 @@ AniWorld.EditModal = (function() {
setFieldValue('edit-tmdb-id', originalData.tmdb_id || ''); setFieldValue('edit-tmdb-id', originalData.tmdb_id || '');
setFieldValue('edit-tvdb-id', originalData.tvdb_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 // Load NFO diagnostics
await loadDiagnostics(seriesKey); await loadDiagnostics(seriesKey);
@@ -62,6 +83,128 @@ AniWorld.EditModal = (function() {
attachListeners(); attachListeners();
} }
/**
* Fetch series details from the API.
* @param {string} key - Series key
* @returns {Promise<Object|null>} 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 = '<i class="fas fa-spinner fa-spin"></i> Searching...';
resultsContainer.innerHTML = '';
resultsContainer.style.display = 'block';
try {
const response = await AniWorld.ApiClient.get(
'/api/anime/' + encodeURIComponent(currentKey) + '/tmdb-search'
);
if (!response) {
resultsContainer.innerHTML = '<p class="tmdb-error">Connection error</p>';
return;
}
if (response.status === 400) {
resultsContainer.innerHTML = '<p class="tmdb-error">TMDB not configured</p>';
return;
}
if (!response.ok) {
resultsContainer.innerHTML = '<p class="tmdb-error">Search failed</p>';
return;
}
const results = await response.json();
if (results.length === 0) {
resultsContainer.innerHTML = '<p class="tmdb-no-results">No matches found</p>';
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 '<div class="tmdb-result-item" data-tmdb-id="' + r.tmdb_id + '">' +
'<span class="tmdb-result-title">' + escapeHtml(r.title) + year + rating + '</span>' +
'<span class="tmdb-result-overview">' + escapeHtml(r.overview || '') + '</span>' +
'</div>';
}).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 = '<p class="tmdb-selected">TMDB ID ' + tmdbId + ' selected</p>';
updateTmdbFetchButtonState();
});
});
} catch (err) {
resultsContainer.innerHTML = '<p class="tmdb-error">Search failed</p>';
console.error('TMDB search error:', err);
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-search"></i> Fetch from TMDB';
}
}
/** /**
* Close the edit modal and reset state. * Close the edit modal and reset state.
*/ */
@@ -272,6 +415,7 @@ AniWorld.EditModal = (function() {
function updateRepairButtonState() { function updateRepairButtonState() {
const btn = document.getElementById('btn-repair-nfo'); const btn = document.getElementById('btn-repair-nfo');
const hint = document.getElementById('repair-hint'); const hint = document.getElementById('repair-hint');
const diagnosticsLink = document.getElementById('btn-open-nfo-diagnostics');
const tmdbValue = getFieldValue('edit-tmdb-id').trim(); const tmdbValue = getFieldValue('edit-tmdb-id').trim();
if (btn) { if (btn) {
@@ -281,6 +425,11 @@ AniWorld.EditModal = (function() {
if (hint) { if (hint) {
hint.style.display = tmdbValue ? 'none' : 'block'; 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 ---- // ---- Helpers ----
@@ -395,8 +544,10 @@ AniWorld.EditModal = (function() {
const saveBtn = document.getElementById('btn-save-metadata'); const saveBtn = document.getElementById('btn-save-metadata');
const cancelBtn = document.getElementById('btn-cancel-metadata'); const cancelBtn = document.getElementById('btn-cancel-metadata');
const repairBtn = document.getElementById('btn-repair-nfo'); const repairBtn = document.getElementById('btn-repair-nfo');
const fetchTmdbBtn = document.getElementById('btn-fetch-tmdb');
const overlay = modalElement ? modalElement.querySelector('.modal-overlay') : null; const overlay = modalElement ? modalElement.querySelector('.modal-overlay') : null;
const keyInput = document.getElementById('edit-key'); const keyInput = document.getElementById('edit-key');
const tmdbInput = document.getElementById('edit-tmdb-id');
if (saveBtn) { if (saveBtn) {
var saveFn = function() { save(); }; var saveFn = function() { save(); };
@@ -416,6 +567,12 @@ AniWorld.EditModal = (function() {
listeners.push({ el: repairBtn, event: 'click', fn: repairFn }); 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) { if (overlay) {
var overlayFn = function() { close(); }; var overlayFn = function() { close(); };
overlay.addEventListener('click', overlayFn); overlay.addEventListener('click', overlayFn);
@@ -432,6 +589,14 @@ AniWorld.EditModal = (function() {
keyInput.addEventListener('input', keyFn); keyInput.addEventListener('input', keyFn);
listeners.push({ el: keyInput, event: 'input', fn: 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() { function detachListeners() {

View File

@@ -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 '<div class="series-card ' + statusClass + '" data-key="' + escapeHtml(series.key) + '">' +
'<div class="series-card-header">' +
'<div>' +
'<h4 class="series-name">' + escapeHtml(series.name) + '</h4>' +
'<div class="series-folder">' + escapeHtml(series.folder) + '</div>' +
'</div>' +
'<span class="nfo-status-badge ' + statusClass + '">' + statusText + '</span>' +
'</div>' +
(series.missing_tags && series.missing_tags.length > 0 ?
'<div class="missing-tags-list">' +
series.missing_tags.slice(0, 3).map(function(tag) {
return '<span class="missing-tag-chip">' + escapeHtml(tag) + '</span>';
}).join('') +
(series.missing_tags.length > 3 ? '<span class="missing-tag-chip">+' + (series.missing_tags.length - 3) + '</span>' : '') +
'</div>' : '') +
'</div>';
}).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 '<span class="missing-tag-chip">' + escapeHtml(tag) + '</span>';
}).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 = '<i class="fas fa-spinner fa-spin"></i> 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 = '<i class="fas fa-wrench"></i> Repair NFO';
}
}
/**
* Handle validate button
*/
async function handleValidate() {
if (!currentSeries) return;
elements.btnValidate.disabled = true;
elements.btnValidate.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 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 = '<i class="fas fa-check"></i> 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 = '<i class="fas fa-spinner fa-spin"></i> 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 = '<i class="fas fa-file-code"></i> 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();
});

View File

@@ -520,6 +520,16 @@
<span data-text="test-tmdb">Test TMDB Connection</span> <span data-text="test-tmdb">Test TMDB Connection</span>
</button> </button>
</div> </div>
<div class="config-item" style="margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--color-border);">
<a href="/settings/nfo" class="btn btn-secondary" style="text-decoration: none;">
<i class="fas fa-file-lines"></i>
<span data-text="open-nfo-diagnostics">Open NFO Diagnostics</span>
</a>
<small class="config-hint" data-text="nfo-diagnostics-hint">
View and repair NFO files for all series
</small>
</div>
</div> </div>
<!-- Backup Configuration --> <!-- Backup Configuration -->
@@ -652,6 +662,41 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form id="edit-metadata-form" onsubmit="return false;"> <form id="edit-metadata-form" onsubmit="return false;">
<!-- Series Info Section (Read-only) -->
<div class="edit-section">
<h4><i class="fa-solid fa-info-circle"></i> Series Info</h4>
<div class="info-grid">
<div class="info-item">
<label>Name</label>
<span id="edit-name" class="info-value">-</span>
</div>
<div class="info-item">
<label>Year</label>
<span id="edit-year" class="info-value">-</span>
</div>
<div class="info-item">
<label>Status</label>
<span id="edit-status" class="info-value">-</span>
</div>
<div class="info-item">
<label>Premiered</label>
<span id="edit-premiered" class="info-value">-</span>
</div>
<div class="info-item">
<label>Genres</label>
<span id="edit-genres" class="info-value">-</span>
</div>
<div class="info-item">
<label>Studio</label>
<span id="edit-studio" class="info-value">-</span>
</div>
<div class="info-item">
<label>Rating</label>
<span id="edit-rating" class="info-value">-</span>
</div>
</div>
</div>
<!-- Identity Section --> <!-- Identity Section -->
<div class="edit-section"> <div class="edit-section">
<h4><i class="fa-solid fa-key"></i> Identity</h4> <h4><i class="fa-solid fa-key"></i> Identity</h4>
@@ -674,8 +719,13 @@
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label for="edit-tmdb-id">TMDB ID</label> <label for="edit-tmdb-id">TMDB ID</label>
<div class="input-with-action">
<input type="number" id="edit-tmdb-id" class="input-field" <input type="number" id="edit-tmdb-id" class="input-field"
placeholder="e.g. 1429" min="1"> placeholder="e.g. 1429" min="1">
<button type="button" id="btn-fetch-tmdb" class="btn btn-secondary btn-fetch-tmdb" style="display:none;">
<i class="fas fa-search"></i> Fetch from TMDB
</button>
</div>
<span class="field-error" style="display:none;"></span> <span class="field-error" style="display:none;"></span>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -685,6 +735,7 @@
<span class="field-error" style="display:none;"></span> <span class="field-error" style="display:none;"></span>
</div> </div>
</div> </div>
<div id="tmdb-search-results" class="tmdb-search-results" style="display:none;"></div>
</div> </div>
<!-- NFO Status Section --> <!-- NFO Status Section -->
@@ -702,6 +753,9 @@
<button type="button" id="btn-repair-nfo" class="btn btn-secondary btn-repair"> <button type="button" id="btn-repair-nfo" class="btn btn-secondary btn-repair">
<i class="fa-solid fa-wrench"></i> Repair NFO <i class="fa-solid fa-wrench"></i> Repair NFO
</button> </button>
<a id="btn-open-nfo-diagnostics" class="btn btn-secondary btn-open-diagnostics" href="#" style="display:none; text-decoration: none; margin-top: 0.5rem;">
<i class="fa-solid fa-external-link-alt"></i> Open Full Diagnostics
</a>
</div> </div>
</div> </div>
</form> </form>

View File

@@ -0,0 +1,183 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title data-text="nfo-diagnostics-title">NFO Diagnostics - Aniworld</title>
<link rel="stylesheet" href="/static/css/base/variables.css?v={{ static_version('css/base/variables.css') }}">
<link rel="stylesheet" href="/static/css/base/reset.css?v={{ static_version('css/base/reset.css') }}">
<link rel="stylesheet" href="/static/css/base/typography.css?v={{ static_version('css/base/typography.css') }}">
<link rel="stylesheet" href="/static/css/components/buttons.css?v={{ static_version('css/components/buttons.css') }}">
<link rel="stylesheet" href="/static/css/components/modals.css?v={{ static_version('css/components/modals.css') }}">
<link rel="stylesheet" href="/static/css/components/forms.css?v={{ static_version('css/components/forms.css') }}">
<link rel="stylesheet" href="/static/css/components/cards.css?v={{ static_version('css/components/cards.css') }}">
<link rel="stylesheet" href="/static/css/components/notifications.css?v={{ static_version('css/components/notifications.css') }}">
<link rel="stylesheet" href="/static/css/layout/page.css?v={{ static_version('css/layout/page.css') }}">
<link rel="stylesheet" href="/static/css/pages/nfo-settings.css?v={{ static_version('css/pages/nfo-settings.css') }}">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
</head>
<body>
<div id="app">
<!-- Header -->
<header class="page-header">
<div class="header-left">
<a href="/" class="back-link">
<i class="fas fa-arrow-left"></i>
<span data-text="back-to-library">Back to Library</span>
</a>
</div>
<div class="header-center">
<h1 data-text="nfo-diagnostics-title">NFO Diagnostics & Repair</h1>
</div>
<div class="header-right">
<button id="btn-scan-all" class="btn btn-secondary">
<i class="fas fa-search"></i>
<span data-text="scan-all">Scan All</span>
</button>
<button id="btn-repair-all" class="btn btn-primary">
<i class="fas fa-wrench"></i>
<span data-text="repair-all">Repair All</span>
</button>
</div>
</header>
<!-- Stats Bar -->
<div class="stats-bar">
<div class="stat-item">
<span class="stat-value" id="total-series">0</span>
<span class="stat-label" data-text="total-series">Total Series</span>
</div>
<div class="stat-item stat-missing">
<span class="stat-value" id="missing-nfo">0</span>
<span class="stat-label" data-text="missing-nfo">Missing NFO</span>
</div>
<div class="stat-item stat-incomplete">
<span class="stat-value" id="incomplete-nfo">0</span>
<span class="stat-label" data-text="incomplete-nfo">Incomplete NFO</span>
</div>
<div class="stat-item stat-complete">
<span class="stat-value" id="complete-nfo">0</span>
<span class="stat-label" data-text="complete-nfo">Complete</span>
</div>
</div>
<!-- Search and Filter -->
<div class="toolbar">
<div class="search-box">
<i class="fas fa-search"></i>
<input type="text" id="search-input" class="input-field"
placeholder="Search series..." data-text="search-placeholder">
</div>
<div class="filter-buttons">
<button class="filter-btn active" data-filter="all">
<span data-text="filter-all">All</span>
<span class="count" id="filter-all-count">0</span>
</button>
<button class="filter-btn" data-filter="missing">
<i class="fas fa-exclamation-circle"></i>
<span data-text="filter-missing">Missing</span>
<span class="count" id="filter-missing-count">0</span>
</button>
<button class="filter-btn" data-filter="incomplete">
<i class="fas fa-exclamation-triangle"></i>
<span data-text="filter-incomplete">Incomplete</span>
<span class="count" id="filter-incomplete-count">0</span>
</button>
<button class="filter-btn" data-filter="complete">
<i class="fas fa-check-circle"></i>
<span data-text="filter-complete">Complete</span>
<span class="count" id="filter-complete-count">0</span>
</button>
</div>
</div>
<!-- Series List -->
<div class="series-list-container">
<div id="series-list" class="series-list">
<!-- Loading state -->
<div class="loading-state" id="loading-state">
<i class="fas fa-spinner fa-spin"></i>
<span data-text="loading">Loading...</span>
</div>
<!-- Empty state -->
<div class="empty-state hidden" id="empty-state">
<i class="fas fa-check-circle"></i>
<span data-text="all-nfo-complete">All NFO files are complete!</span>
</div>
<!-- Series cards will be rendered here -->
</div>
</div>
<!-- Detail Panel (slide-out) -->
<div id="detail-panel" class="detail-panel hidden">
<div class="panel-overlay"></div>
<div class="panel-content">
<div class="panel-header">
<h3 id="panel-title">Series Name</h3>
<button id="btn-close-panel" class="btn btn-icon">
<i class="fas fa-times"></i>
</button>
</div>
<div class="panel-body">
<!-- NFO Status -->
<div class="detail-section">
<h4 data-text="nfo-status">NFO Status</h4>
<div class="status-display">
<span id="panel-status-badge" class="nfo-status-badge">-</span>
<span id="panel-nfo-path" class="nfo-path"></span>
</div>
</div>
<!-- Missing Tags -->
<div class="detail-section" id="missing-tags-section">
<h4 data-text="missing-tags">Missing Tags</h4>
<div id="panel-missing-tags" class="missing-tags-list">
<!-- Tags will be rendered here -->
</div>
</div>
<!-- Actions -->
<div class="detail-section">
<h4 data-text="actions">Actions</h4>
<div class="action-buttons">
<button id="btn-repair-single" class="btn btn-primary">
<i class="fas fa-wrench"></i>
<span data-text="repair-nfo">Repair NFO</span>
</button>
<button id="btn-validate" class="btn btn-secondary">
<i class="fas fa-check"></i>
<span data-text="validate-nfo">Validate XML</span>
</button>
<button id="btn-view-nfo" class="btn btn-secondary">
<i class="fas fa-file-code"></i>
<span data-text="view-nfo">View NFO</span>
</button>
</div>
</div>
<!-- NFO Content Preview -->
<div class="detail-section" id="nfo-preview-section">
<h4 data-text="nfo-preview">NFO Preview</h4>
<pre id="nfo-preview-content" class="nfo-preview"><code></code></pre>
</div>
</div>
</div>
</div>
<!-- Toast Notifications -->
<div id="toast-container" class="toast-container"></div>
</div>
<!-- Shared Modules -->
<script src="/static/js/shared/constants.js?v={{ static_version('js/shared/constants.js') }}"></script>
<script src="/static/js/shared/auth.js?v={{ static_version('js/shared/auth.js') }}"></script>
<script src="/static/js/shared/api-client.js?v={{ static_version('js/shared/api-client.js') }}"></script>
<script src="/static/js/shared/theme.js?v={{ static_version('js/shared/theme.js') }}"></script>
<script src="/static/js/shared/ui-utils.js?v={{ static_version('js/shared/ui-utils.js') }}"></script>
<!-- Page Module -->
<script src="/static/js/pages/nfo-settings.js?v={{ static_version('js/pages/nfo-settings.js') }}"></script>
</body>
</html>

View File

@@ -1,5 +1,10 @@
"""Tests for NFO diagnostics and repair API endpoints.""" """Tests for NFO diagnostics and repair API endpoints.
from unittest.mock import AsyncMock, Mock, patch
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 import pytest
from httpx import ASGITransport, AsyncClient 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 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(): def reset_auth():
"""Reset authentication state before each test.""" """Reset authentication state before each test."""
original_hash = auth_service._hash original_hash = auth_service._hash
@@ -29,289 +38,119 @@ async def client():
@pytest.fixture @pytest.fixture
async def authenticated_client(client): async def authenticated_client(client):
"""Create an authenticated test client with token.""" """Create an authenticated test client with token.
await client.post(
"/api/auth/setup", Note: conftest.py already sets up auth with password "TestPass123!".
json={"master_password": "TestPassword123!"} This fixture just logs in to get a token.
) """
# Login to get token (auth is already set up by conftest)
response = await client.post( response = await client.post(
"/api/auth/login", "/api/auth/login",
json={"password": "TestPassword123!"} json={"password": "TestPass123!"}
) )
# If already logged in from conftest, might return error - that's ok
if response.status_code == 200:
token = response.json()["access_token"] token = response.json()["access_token"]
client.headers.update({"Authorization": f"Bearer {token}"}) client.headers.update({"Authorization": f"Bearer {token}"})
yield client yield client
@pytest.fixture @pytest.fixture
def mock_series_app(): def mock_anime_service():
"""Create mock series app with one test series.""" """Create mock anime service."""
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."""
service = Mock() service = Mock()
service.check_nfo_exists = AsyncMock(return_value=False) service.list_series_with_filters = AsyncMock(return_value=[])
service.create_tvshow_nfo = AsyncMock(return_value="/path/to/tvshow.nfo")
service.update_tvshow_nfo = AsyncMock(return_value="/path/to/tvshow.nfo")
return service return service
@pytest.fixture @pytest.fixture
def override_dependencies(mock_series_app, mock_nfo_service): def override_anime_service(mock_anime_service):
"""Override dependencies for NFO tests.""" """Override anime service dependency."""
from src.server.api.nfo import get_nfo_service from src.server.utils.dependencies import get_anime_service
from src.server.utils.dependencies import get_series_app app.dependency_overrides[get_anime_service] = lambda: mock_anime_service
app.dependency_overrides[get_series_app] = lambda: mock_series_app
app.dependency_overrides[get_nfo_service] = lambda: mock_nfo_service
yield 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] class TestNfoNeedsRepair:
if get_nfo_service in app.dependency_overrides: """Tests for GET /api/nfo/needs-repair."""
del app.dependency_overrides[get_nfo_service]
@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: class TestNfoDiagnostics:
"""Tests for GET /api/nfo/{serie_key}/diagnostics.""" """Tests for GET /api/nfo/{serie_key}/diagnostics."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_diagnostics_complete_nfo( async def test_diagnostics_requires_auth(self, client):
self, authenticated_client, override_dependencies """Test diagnostics endpoint requires authentication."""
): response = await client.get("/api/nfo/test-anime/diagnostics")
"""Test diagnostics with complete NFO returns no missing tags.""" # Without auth, returns 401
with patch( assert response.status_code == 401
"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
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_diagnostics_missing_tags( async def test_diagnostics_returns_404_for_nonexistent(
self, authenticated_client, override_dependencies self, authenticated_client, override_anime_service
):
"""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
): ):
"""Test diagnostics for non-existent series returns 404.""" """Test diagnostics for non-existent series returns 404."""
# Override to return empty list response = await authenticated_client.get("/api/nfo/nonexistent-key/diagnostics")
mock_series_app.list.GetList.return_value = []
response = await authenticated_client.get(
"/api/nfo/nonexistent-key/diagnostics"
)
assert response.status_code == 404 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: class TestNfoRepair:
"""Tests for POST /api/nfo/{serie_key}/repair.""" """Tests for POST /api/nfo/{serie_key}/repair."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_repair_success( async def test_repair_requires_auth(self, client):
self, authenticated_client, override_dependencies """Test repair endpoint requires authentication."""
): response = await client.post("/api/nfo/test-anime/repair", json={})
"""Test successful NFO repair.""" assert response.status_code == 401
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"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_repair_already_complete( async def test_repair_returns_404_for_nonexistent(
self, authenticated_client, override_dependencies self, authenticated_client, override_anime_service
):
"""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
): ):
"""Test repair for non-existent series returns 404.""" """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 assert response.status_code == 404
@pytest.mark.asyncio
async def test_repair_unauthenticated_401(self, client): class TestNfoValidate:
"""Test repair requires authentication.""" """Tests for GET /api/nfo/{serie_key}/validate."""
response = await client.post("/api/nfo/test-anime/repair", json={})
assert response.status_code in (401, 503)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_repair_tmdb_api_failure( async def test_validate_requires_auth(self, client):
self, authenticated_client, override_dependencies """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.""" """Test validate for non-existent series returns 404."""
from src.server.nfo.tmdb_client import TMDBAPIError response = await authenticated_client.get("/api/nfo/nonexistent-key/validate")
assert response.status_code == 404
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"]