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,
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

View File

@@ -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., <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(
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."
)
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

View File

@@ -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"
)

View File

@@ -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")

View File

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

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() {
'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<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.
*/
@@ -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() {

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>
</button>
</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>
<!-- Backup Configuration -->
@@ -652,6 +662,41 @@
</div>
<div class="modal-body">
<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 -->
<div class="edit-section">
<h4><i class="fa-solid fa-key"></i> Identity</h4>
@@ -674,8 +719,13 @@
<div class="form-row">
<div class="form-group">
<label for="edit-tmdb-id">TMDB ID</label>
<input type="number" id="edit-tmdb-id" class="input-field"
placeholder="e.g. 1429" min="1">
<div class="input-with-action">
<input type="number" id="edit-tmdb-id" class="input-field"
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>
</div>
<div class="form-group">
@@ -685,6 +735,7 @@
<span class="field-error" style="display:none;"></span>
</div>
</div>
<div id="tmdb-search-results" class="tmdb-search-results" style="display:none;"></div>
</div>
<!-- NFO Status Section -->
@@ -702,6 +753,9 @@
<button type="button" id="btn-repair-nfo" class="btn btn-secondary btn-repair">
<i class="fa-solid fa-wrench"></i> Repair NFO
</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>
</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>