refactor: overhaul NFO settings UI and backend

- Rework nfo-settings page with improved styling and layout
- Update edit-modal and context-menu with enhanced functionality
- Refactor NFO API endpoints and models
- Remove deprecated test_nfo_diagnostics_repair.py
- Clean up tasks.md documentation
This commit is contained in:
2026-06-20 20:32:46 +02:00
parent 7a1b2e565e
commit a8e54876e3
11 changed files with 2107 additions and 1708 deletions

View File

@@ -1,22 +1,25 @@
"""NFO Management API endpoints.
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
Provides endpoints for NFO diagnostics, repair, and validation for anime series.
"""
import logging
import os
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from pydantic import BaseModel
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.models.nfo import (
NfoDiagnosticsResponse,
NfoRepairResponse,
NfoSeriesDiagnostics,
)
from src.server.services.anime_service import AnimeService
from src.server.utils.dependencies import get_anime_service, require_auth
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/nfo", tags=["nfo"])
# Required tags for a valid Kodi tvshow.nfo
@@ -25,403 +28,365 @@ REQUIRED_TAGS = [
"plot",
"tmdbid",
]
# All tags we check for completeness
ALL_TAGS = [
"title",
"originaltitle",
"showtitle",
"sorttitle",
OPTIONAL_TAGS = [
"year",
"plot",
"premiered",
"genre",
"studio",
"rating",
"mpaa",
"outline",
"tagline",
"runtime",
"mpaa",
"premiered",
"status",
"studio",
"genre",
"tmdbid",
"imdbid",
"id",
"imdb_id",
"tvdbid",
"userrating",
"trailer",
"imdbid",
"uniqueid",
"thumb",
"fanart",
"actor",
"trailer",
]
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")
"""Response for NFO XML validation."""
valid: bool
error: Optional[str] = None
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 {}
class NfoNeedsRepairListResponse(BaseModel):
"""Response listing series that need NFO repair."""
total: int
series: List[NfoSeriesDiagnostics]
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}",
)
def _get_nfo_path(folder: str) -> str:
"""Get the full path to a series' tvshow.nfo file."""
anime_dir = getattr(settings, "anime_directory", None)
if not anime_dir:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Anime directory not configured",
)
missing_nfo_list: List[NfoSeriesItem] = []
incomplete_list: List[NfoSeriesItem] = []
return os.path.join(anime_dir, folder, "tvshow.nfo")
def _parse_nfo_file(nfo_path: str) -> tuple[Optional[Any], List[str]]:
"""Parse an NFO file and return (xml_root, missing_tags).
Returns tuple of (xml_root element or None, list of missing required tags).
If file cannot be read/parsed, returns (None, all_required_tags).
"""
from lxml import etree
missing: List[str] = []
if not os.path.isfile(nfo_path):
return None, REQUIRED_TAGS.copy()
try:
tree = etree.parse(nfo_path)
root = tree.getroot()
except Exception as exc:
logger.warning("Failed to parse NFO file %s: %s", nfo_path, exc)
return None, REQUIRED_TAGS.copy()
# Check for required tags
for tag in REQUIRED_TAGS:
elements = root.findall(tag)
# Check if tag exists and has non-empty text
found = False
for elem in elements:
if elem.text and elem.text.strip():
found = True
break
if not found:
missing.append(tag)
return root, missing
async def _get_series_data(
anime_service: AnimeService, key: str
) -> Optional[dict]:
"""Get series data by key from anime_service."""
# Get all series and find by key
all_series = await anime_service.list_series_with_filters()
for series in all_series:
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,
)
if series.get("key") == key:
return series
return None
@router.get("/{serie_key}/diagnostics", response_model=NfoDiagnosticsResponse)
@router.get("/{key}/diagnostics", response_model=NfoDiagnosticsResponse)
async def get_nfo_diagnostics(
serie_key: str,
key: str,
_auth: dict = Depends(require_auth),
anime_service: AnimeService = Depends(get_anime_service),
) -> NfoDiagnosticsResponse:
"""Get NFO diagnostics for a specific series.
Checks if tvshow.nfo exists in the series folder and validates
that required tags are present.
Args:
key: Series unique key (provider-assigned, URL-safe identifier)
_auth: Authentication dependency
anime_service: AnimeService dependency
Returns:
- Whether tvshow.nfo exists
- Path to NFO file if it exists
- List of missing required tags (if any)
- List of all tags for reference
NfoDiagnosticsResponse with has_nfo, nfo_path, missing_tags, required_tags
Raises:
HTTPException 404: If series not found
HTTPException 503: If anime directory not configured
"""
# Get series data
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:
series_data = await _get_series_data(anime_service, key)
if not series_data:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Series not found: {serie_key}",
detail=f"Series not found: {key}",
)
folder = series.get("folder", "")
folder = series_data.get("folder", "")
if not folder:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Series has no folder assigned",
detail=f"Series has no folder configured: {key}",
)
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):
nfo_path = _get_nfo_path(folder)
nfo_exists = os.path.isfile(nfo_path)
if not nfo_exists:
return NfoDiagnosticsResponse(
has_nfo=False,
nfo_path=None,
missing_tags=REQUIRED_TAGS.copy(),
required_tags=ALL_TAGS.copy(),
required_tags=REQUIRED_TAGS.copy(),
)
# Parse NFO and check for missing tags
tags = _parse_nfo_tags(nfo_path)
missing = _check_missing_tags(tags)
# Parse and check for missing tags
_, missing = _parse_nfo_file(nfo_path)
return NfoDiagnosticsResponse(
has_nfo=True,
nfo_path=nfo_path,
missing_tags=missing,
required_tags=ALL_TAGS.copy(),
required_tags=REQUIRED_TAGS.copy(),
)
@router.post("/{serie_key}/repair", response_model=NfoRepairResponse)
@router.post("/{key}/repair", response_model=NfoRepairResponse)
async def repair_nfo(
serie_key: str,
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.
"""Repair NFO for a specific series.
Creates or updates the tvshow.nfo file using TMDB metadata.
Args:
key: Series unique key
_auth: Authentication dependency
anime_service: AnimeService dependency
Returns:
NfoRepairResponse with success status, message, and repaired_tags
Raises:
HTTPException 404: If series not found
HTTPException 400: If no TMDB ID available and cannot lookup by name
HTTPException 503: If anime directory not configured
"""
# Get series data
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:
series_data = await _get_series_data(anime_service, key)
if not series_data:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Series not found: {serie_key}",
detail=f"Series not found: {key}",
)
folder = series.get("folder", "")
folder = series_data.get("folder", "")
if not folder:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Series has no folder assigned",
detail=f"Series has no folder configured: {key}",
)
tmdb_id = series_data.get("tmdb_id")
name = series_data.get("name", "")
if not tmdb_id:
logger.info("No TMDB ID for %s, attempting lookup by name: %s", key, name)
# Try to lookup TMDB ID by series name
tmdb_id = await _lookup_tmdb_id_by_name(anime_service, name)
if not tmdb_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"No TMDB ID available for {key} and could not find match for '{name}'",
)
# Fetch TMDB data and create NFO
try:
repaired_tags = await _create_or_update_nfo(
key=key,
folder=folder,
tmdb_id=tmdb_id,
series_data=series_data,
)
except Exception as exc:
logger.error("Failed to repair NFO for %s: %s", key, exc)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to repair NFO: {str(exc)}",
)
if repaired_tags:
return NfoRepairResponse(
success=True,
message=f"NFO repaired successfully. {len(repaired_tags)} tags updated.",
repaired_tags=repaired_tags,
)
else:
return NfoRepairResponse(
success=True,
message="NFO is already complete, no changes needed.",
repaired_tags=[],
)
async def _lookup_tmdb_id_by_name(anime_service: AnimeService, name: str) -> Optional[int]:
"""Try to lookup a TMDB ID by series name using TMDB provider."""
try:
from src.server.providers.tmdb_provider import TMDBProvider
provider = TMDBProvider()
results = await provider.search(name)
if results:
return results[0].get("tmdb_id")
except Exception as exc:
logger.warning("TMDB lookup failed for %s: %s", name, exc)
return None
async def _create_or_update_nfo(
key: str,
folder: str,
tmdb_id: int,
series_data: dict,
) -> List[str]:
"""Create or update NFO file for a series.
Returns list of tags that were repaired/added.
"""
from src.server.providers.tmdb_provider import TMDBProvider
from src.server.nfo.nfo_generator import generate_tvshow_nfo
from src.server.nfo.nfo_mapper import tmdb_to_nfo_model
anime_dir = getattr(settings, "anime_directory", None)
if not anime_dir:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Anime directory not configured",
)
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)
series_path = os.path.join(anime_dir, folder)
nfo_path = os.path.join(series_path, "tvshow.nfo")
# Fetch TMDB data
provider = TMDBProvider()
tmdb_data = await provider.get_series_info(tmdb_id)
if not tmdb_data:
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
raise Exception(f"No TMDB data returned for TMDB ID {tmdb_id}")
# Convert to NFO model
nfo_model = tmdb_to_nfo_model(
tmdb_data,
content_ratings=None,
get_image_url=lambda path: f"https://image.tmdb.org/t/p/original{path}" if path else None,
get_image_url=provider.get_image_url,
image_size="original",
)
# Generate XML
xml_content = generate_tvshow_nfo(nfo_model)
# Ensure directory exists
os.makedirs(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()
os.makedirs(series_path, exist_ok=True)
# Check existing NFO for missing tags before overwriting
_, missing_before = _parse_nfo_file(nfo_path)
# Write NFO file
with open(nfo_path, "w", encoding="utf-8") as f:
f.write(xml_content)
# 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,
logger.info("Created/updated tvshow.nfo for %s at %s", key, nfo_path)
# Update series NFO status in DB
await anime_service.update_series_nfo_status(
key=key,
has_nfo=True,
nfo_path=nfo_path,
)
# Return list of repaired tags (all tags that were missing before)
return missing_before
@router.get("/{serie_key}/validate", response_model=NfoValidateResponse)
@router.get("/{key}/validate", response_model=NfoValidateResponse)
async def validate_nfo(
serie_key: str,
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.
"""Validate NFO XML structure for a series.
Checks if the tvshow.nfo file is valid XML.
Args:
key: Series unique key
_auth: Authentication dependency
anime_service: AnimeService dependency
Returns:
NfoValidateResponse with valid=True/False and optional error message
"""
# 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:
series_data = await _get_series_data(anime_service, key)
if not series_data:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Series not found: {serie_key}",
detail=f"Series not found: {key}",
)
folder = series.get("folder", "")
folder = series_data.get("folder", "")
if not folder:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Series has no folder assigned",
detail=f"Series has no folder configured: {key}",
)
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")
nfo_path = _get_nfo_path(folder)
if not os.path.isfile(nfo_path):
return NfoValidateResponse(
valid=False,
error="NFO file does not exist",
error="No NFO file found",
)
try:
from lxml import etree
etree.parse(nfo_path)
return NfoValidateResponse(
valid=True,
error=None,
)
return NfoValidateResponse(valid=True)
except Exception as exc:
return NfoValidateResponse(
valid=False,
@@ -429,57 +394,128 @@ async def validate_nfo(
)
# ---- Helper functions ----
@router.get("/needs-repair", response_model=NfoNeedsRepairListResponse)
async def get_series_needing_repair(
_auth: dict = Depends(require_auth),
anime_service: AnimeService = Depends(get_anime_service),
) -> NfoNeedsRepairListResponse:
"""Get list of all series that need NFO repair.
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
Returns series that either have no NFO file or have missing required tags.
Args:
_auth: Authentication dependency
anime_service: AnimeService dependency
Returns:
NfoNeedsRepairListResponse with total count and list of series diagnostics
"""
all_series = await anime_service.list_series_with_filters()
series_needing_repair: List[NfoSeriesDiagnostics] = []
anime_dir = getattr(settings, "anime_directory", None)
if not anime_dir:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Anime directory not configured",
)
for series in all_series:
key = series.get("key", "")
folder = series.get("folder", "")
name = series.get("name", "")
if not folder:
continue
nfo_path = os.path.join(anime_dir, folder, "tvshow.nfo")
nfo_exists = os.path.isfile(nfo_path)
if not nfo_exists:
series_needing_repair.append(NfoSeriesDiagnostics(
key=key,
name=name,
folder=folder,
has_nfo=False,
missing_tags=REQUIRED_TAGS.copy(),
))
continue
# Parse and check for missing tags
_, missing = _parse_nfo_file(nfo_path)
if missing:
series_needing_repair.append(NfoSeriesDiagnostics(
key=key,
name=name,
folder=folder,
has_nfo=True,
missing_tags=missing,
))
return NfoNeedsRepairListResponse(
total=len(series_needing_repair),
series=series_needing_repair,
)
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.post("/batch/repair")
async def batch_repair_nfo(
keys: List[str],
_auth: dict = Depends(require_auth),
anime_service: AnimeService = Depends(get_anime_service),
) -> dict:
"""Repair NFO for multiple series at once.
Args:
keys: List of series keys to repair
_auth: Authentication dependency
anime_service: AnimeService dependency
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
Returns:
Summary dict with success count, failure count, and errors
"""
results = {
"total": len(keys),
"success": 0,
"failed": 0,
"errors": [],
}
for key in keys:
try:
from datetime import datetime, timezone
# Get series data
series_data = await _get_series_data(anime_service, key)
if not series_data:
results["failed"] += 1
results["errors"].append(f"{key}: Series not found")
continue
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
folder = series_data.get("folder", "")
if not folder:
results["failed"] += 1
results["errors"].append(f"{key}: No folder configured")
continue
tmdb_id = series_data.get("tmdb_id")
name = series_data.get("name", "")
if not tmdb_id:
tmdb_id = await _lookup_tmdb_id_by_name(anime_service, name)
if not tmdb_id:
results["failed"] += 1
results["errors"].append(f"{key}: No TMDB ID and lookup failed")
continue
await _create_or_update_nfo(
key=key,
folder=folder,
tmdb_id=tmdb_id,
series_data=series_data,
)
results["success"] += 1
except Exception as exc:
results["failed"] += 1
results["errors"].append(f"{key}: {str(exc)}")
return results

View File

@@ -372,6 +372,20 @@ class NfoDiagnosticsResponse(BaseModel):
)
class NfoSeriesDiagnostics(BaseModel):
"""Diagnostics for a single series in the needs-repair list."""
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 tvshow.nfo exists")
missing_tags: List[str] = Field(
default_factory=list,
description="List of missing required tag names"
)
tmdb_id: Optional[int] = Field(None, description="TMDB ID if available")
class NfoRepairResponse(BaseModel):
"""Response after NFO repair attempt."""

View File

@@ -360,7 +360,21 @@
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-md);
gap: var(--spacing-lg);
}
/* Edit modal specific input sizing */
.edit-modal-content .input-field {
width: 100%;
min-width: 140px;
}
.edit-modal-content .form-group {
min-width: 0;
}
.edit-modal-content .form-row .form-group {
min-width: 0;
}
.field-error {
@@ -394,6 +408,22 @@
gap: var(--spacing-sm);
}
.nfo-status-row {
display: flex;
align-items: center;
gap: var(--spacing-md);
}
.nfo-path-display {
font-size: var(--font-size-caption);
color: var(--color-text-tertiary);
font-family: 'Consolas', 'Monaco', monospace;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 200px;
}
.nfo-status-badge {
display: inline-block;
padding: 4px 12px;
@@ -456,9 +486,14 @@
gap: var(--spacing-xs);
}
.nfo-actions-row {
display: flex;
gap: var(--spacing-sm);
margin-top: var(--spacing-xs);
}
.btn-repair {
align-self: flex-start;
margin-top: var(--spacing-sm);
}
.modal-footer {

View File

@@ -1,427 +1,637 @@
/* NFO Settings Page Styles */
/**
* AniWorld - NFO Settings Page Styles
*
* Standalone page for NFO diagnostics, repair, and settings.
*/
#app {
min-height: 100vh;
display: flex;
flex-direction: column;
.nfo-main-content {
padding: var(--spacing-lg);
max-width: 1400px;
margin: 0 auto;
}
/* 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);
/* ========== Stats Section ========== */
.nfo-stats-section {
margin-bottom: var(--spacing-xl);
}
.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 {
.nfo-stats-section .stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 1rem;
grid-template-columns: repeat(4, 1fr);
gap: var(--spacing-lg);
}
.loading-state,
.empty-state {
grid-column: 1 / -1;
.nfo-stats-section .stat-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-lg);
padding: var(--spacing-lg);
display: flex;
align-items: center;
gap: var(--spacing-lg);
}
.nfo-stats-section .stat-icon {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem;
color: var(--color-text-secondary);
background: var(--color-bg-tertiary);
font-size: 20px;
}
.loading-state i {
font-size: 2rem;
margin-bottom: 1rem;
}
.empty-state i {
font-size: 3rem;
.nfo-stats-section .stat-success .stat-icon {
background: rgba(16, 124, 16, 0.1);
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);
.nfo-stats-section .stat-warning .stat-icon {
background: rgba(255, 140, 0, 0.1);
color: var(--color-warning);
}
.nfo-status-badge.complete {
background: var(--color-success-bg);
color: var(--color-success);
.nfo-stats-section .stat-error .stat-icon {
background: rgba(209, 52, 56, 0.1);
color: var(--color-error);
}
.missing-tags-list {
.nfo-stats-section .stat-info {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
flex-direction: column;
}
.missing-tag-chip {
display: inline-block;
padding: 0.25rem 0.5rem;
background: var(--color-background);
border-radius: 4px;
font-size: 0.75rem;
.nfo-stats-section .stat-value {
font-size: 28px;
font-weight: 600;
color: var(--color-text-primary);
line-height: 1.2;
}
.nfo-stats-section .stat-label {
font-size: var(--font-size-body);
color: var(--color-text-secondary);
}
/* Detail Panel */
.detail-panel {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 500px;
z-index: 1000;
/* ========== Tab Navigation ========== */
.nfo-tabs {
display: flex;
gap: var(--spacing-xs);
background: var(--color-bg-secondary);
padding: var(--spacing-xs);
border-radius: var(--border-radius-lg);
margin-bottom: var(--spacing-lg);
border: 1px solid var(--color-border);
}
.detail-panel.hidden {
display: none;
.nfo-tab {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-sm);
padding: var(--spacing-md) var(--spacing-lg);
border: none;
background: transparent;
color: var(--color-text-secondary);
font-size: var(--font-size-body);
font-weight: 500;
cursor: pointer;
border-radius: var(--border-radius-md);
transition: all var(--transition-duration) ease;
}
.panel-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
.nfo-tab:hover {
background: var(--color-surface-hover);
color: var(--color-text-primary);
}
.panel-content {
position: relative;
width: 100%;
.nfo-tab.active {
background: var(--color-surface);
box-shadow: -4px 0 20px rgba(0, 0, 0, 0.3);
display: flex;
flex-direction: column;
color: var(--color-primary);
box-shadow: var(--shadow-card);
}
.nfo-tab i {
font-size: 16px;
}
/* ========== Tab Content ========== */
.nfo-tab-content {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-lg);
overflow: hidden;
}
.tab-panel {
display: none;
padding: var(--spacing-xl);
animation: fadeIn 0.2s ease;
}
.tab-panel.active {
display: block;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--color-border);
margin-bottom: var(--spacing-xl);
flex-wrap: wrap;
gap: var(--spacing-md);
}
.panel-header h3 {
font-size: 1.125rem;
font-weight: 600;
color: var(--color-text);
.panel-header h2 {
margin: 0;
font-size: var(--font-size-title);
color: var(--color-text-primary);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.panel-header h2 i {
color: var(--color-primary);
}
.panel-actions {
display: flex;
gap: var(--spacing-sm);
align-items: center;
}
.panel-actions .search-input {
width: 200px;
}
.panel-actions .filter-select {
min-width: 140px;
}
/* ========== Overview Tab ========== */
.overview-content {
max-width: 900px;
}
.health-summary {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-xl);
}
.health-chart {
background: var(--color-bg-secondary);
border-radius: var(--border-radius-lg);
padding: var(--spacing-xl);
display: flex;
align-items: center;
justify-content: center;
min-height: 250px;
}
.health-details h3 {
font-size: var(--font-size-subtitle);
margin: 0 0 var(--spacing-sm) 0;
color: var(--color-text-primary);
}
.health-details h3:not(:first-child) {
margin-top: var(--spacing-xl);
}
.info-text {
color: var(--color-text-secondary);
margin: 0 0 var(--spacing-sm) 0;
font-size: var(--font-size-body);
}
.tag-list {
list-style: none;
padding: 0;
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;
.tag-list li {
padding: var(--spacing-xs) 0;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.75rem;
font-size: var(--font-size-body);
}
.status-display {
display: flex;
.tag-list li code {
background: var(--color-bg-tertiary);
padding: 2px 6px;
border-radius: var(--border-radius-sm);
font-family: 'Consolas', 'Monaco', monospace;
font-size: 13px;
color: var(--color-text-primary);
}
.optional-tags {
columns: 2;
}
/* ========== Diagnostics Tab ========== */
.series-list-container {
overflow-x: auto;
}
.series-diagnostics-table {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-body);
}
.series-diagnostics-table th,
.series-diagnostics-table td {
padding: var(--spacing-md);
text-align: left;
border-bottom: 1px solid var(--color-border);
}
.series-diagnostics-table th {
background: var(--color-bg-secondary);
font-weight: 600;
color: var(--color-text-primary);
white-space: nowrap;
}
.series-diagnostics-table th.sortable {
cursor: pointer;
user-select: none;
}
.series-diagnostics-table th.sortable:hover {
background: var(--color-bg-tertiary);
}
.series-diagnostics-table th i {
margin-left: var(--spacing-xs);
color: var(--color-text-tertiary);
}
.series-diagnostics-table tbody tr:hover {
background: var(--color-surface-hover);
}
.series-diagnostics-table .status-badge {
display: inline-flex;
align-items: center;
gap: 1rem;
gap: var(--spacing-xs);
padding: 4px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.series-diagnostics-table .status-complete {
background: rgba(16, 124, 16, 0.1);
color: var(--color-success);
}
.series-diagnostics-table .status-incomplete {
background: rgba(255, 140, 0, 0.1);
color: var(--color-warning);
}
.series-diagnostics-table .status-missing {
background: rgba(209, 52, 56, 0.1);
color: var(--color-error);
}
.missing-tags-list {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs);
}
.missing-tag-chip {
display: inline-block;
background: var(--color-bg-tertiary);
color: var(--color-text-secondary);
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-family: 'Consolas', 'Monaco', monospace;
}
.nfo-path {
font-size: 0.75rem;
color: var(--color-text-secondary);
word-break: break-all;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px;
color: var(--color-text-tertiary);
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.action-buttons {
.loading-row td {
text-align: center;
padding: var(--spacing-xxl);
}
.loading-spinner {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.action-buttons .btn {
align-items: center;
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 {
gap: var(--spacing-sm);
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;
.loading-spinner i {
font-size: 24px;
}
/* ========== Repair Tab ========== */
.repair-content {
max-width: 900px;
}
.repair-info {
margin-bottom: var(--spacing-xl);
}
.info-card {
display: flex;
gap: var(--spacing-md);
padding: var(--spacing-lg);
background: var(--color-bg-secondary);
border-radius: var(--border-radius-lg);
border-left: 4px solid var(--color-primary);
}
.info-card i {
font-size: 20px;
color: var(--color-primary);
flex-shrink: 0;
}
.info-card h4 {
margin: 0 0 var(--spacing-xs) 0;
font-size: var(--font-size-subtitle);
color: var(--color-text-primary);
}
.info-card p {
margin: 0;
font-size: var(--font-size-body);
color: var(--color-text-secondary);
}
.repair-actions {
display: flex;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-lg);
}
.repair-list-container {
margin-bottom: var(--spacing-lg);
}
.repair-table {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-body);
}
.repair-table th,
.repair-table td {
padding: var(--spacing-md);
text-align: left;
border-bottom: 1px solid var(--color-border);
}
.repair-table th {
background: var(--color-bg-secondary);
font-weight: 600;
color: var(--color-text-primary);
}
.checkbox-col {
width: 40px;
}
.repair-table tbody tr:hover {
background: var(--color-surface-hover);
}
.repair-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: var(--spacing-lg);
border-top: 1px solid var(--color-border);
}
.selection-count {
color: var(--color-text-secondary);
font-size: var(--font-size-body);
}
.selection-count span {
font-weight: 600;
color: var(--color-primary);
}
/* ========== Settings Tab ========== */
.settings-content {
max-width: 700px;
}
.settings-section {
margin-bottom: var(--spacing-xxl);
padding-bottom: var(--spacing-xxl);
border-bottom: 1px solid var(--color-border);
}
.settings-section:last-child {
border-bottom: none;
margin-bottom: 0;
}
.settings-section h3 {
margin: 0 0 var(--spacing-lg) 0;
font-size: var(--font-size-subtitle);
color: var(--color-text-primary);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.settings-section h3 i {
color: var(--color-primary);
}
.setting-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--spacing-xl);
padding: var(--spacing-md) 0;
}
.setting-info {
flex: 1;
}
.setting-info label {
font-weight: 500;
color: var(--color-text-primary);
display: block;
margin-bottom: var(--spacing-xs);
}
.setting-description {
margin: 0;
font-size: var(--font-size-body);
color: var(--color-text-secondary);
}
.setting-description a {
color: var(--color-primary);
}
.setting-control {
display: flex;
gap: var(--spacing-sm);
align-items: center;
}
.setting-control .input-field {
width: 300px;
}
/* Toggle Switch */
.toggle-switch {
position: relative;
display: inline-block;
width: 48px;
height: 26px;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--color-bg-tertiary);
transition: 0.2s;
border-radius: 13px;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 20px;
width: 20px;
left: 3px;
bottom: 3px;
background-color: white;
transition: 0.2s;
border-radius: 50%;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
.toggle-switch input:checked + .toggle-slider {
background-color: var(--color-primary);
}
.toggle-switch input:checked + .toggle-slider:before {
transform: translateX(22px);
}
/* Connection Status */
.connection-status {
margin-top: var(--spacing-md);
padding: var(--spacing-md);
border-radius: var(--border-radius-md);
font-size: var(--font-size-body);
}
.connection-status.success {
background: rgba(16, 124, 16, 0.1);
color: var(--color-success);
border: 1px solid var(--color-success);
}
.connection-status.error {
background: rgba(209, 52, 56, 0.1);
color: var(--color-error);
border: 1px solid var(--color-error);
}
/* ========== Responsive ========== */
@media (max-width: 1024px) {
.nfo-stats-section .stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.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 {
.health-summary {
grid-template-columns: 1fr;
}
.detail-panel {
}
@media (max-width: 768px) {
.nfo-tabs {
flex-wrap: wrap;
}
.nfo-tab {
flex: 1 1 45%;
}
.nfo-tab span {
display: none;
}
.panel-header {
flex-direction: column;
align-items: flex-start;
}
.panel-actions {
width: 100%;
}
}
.panel-actions .search-input,
.panel-actions .filter-select {
flex: 1;
}
.setting-item {
flex-direction: column;
gap: var(--spacing-md);
}
.setting-control {
width: 100%;
}
.setting-control .input-field {
width: 100%;
}
.repair-footer {
flex-direction: column;
gap: var(--spacing-md);
}
.repair-footer .btn {
width: 100%;
}
}

View File

@@ -71,6 +71,10 @@ AniWorld.ContextMenu = (function() {
<i class="fa-solid fa-pen-to-square"></i>
<span>Edit Metadata</span>
</div>
<div class="context-menu-item" data-action="nfo-diagnostics">
<i class="fa-solid fa-file-circle-check"></i>
<span>NFO Diagnostics</span>
</div>
`;
document.body.appendChild(menuElement);
@@ -102,6 +106,13 @@ AniWorld.ContextMenu = (function() {
AniWorld.EditModal.open(currentSeriesKey);
}
});
// NFO Diagnostics - opens the full NFO settings page
menuElement.querySelector('[data-action="nfo-diagnostics"]').addEventListener('click', function() {
hide();
// Navigate to NFO settings page with this series selected
window.location.href = '/settings/nfo?key=' + encodeURIComponent(currentSeriesKey);
});
}
/**

View File

@@ -36,12 +36,13 @@ AniWorld.EditModal = (function() {
hideKeyWarning();
try {
// Try to find series data from the local series list first
let seriesData = findSeriesData(seriesKey);
// Always fetch fresh data from API for edit modal to ensure accuracy
// This is more reliable than local cache which may be stale or missing
let seriesData = await fetchSeriesDetails(seriesKey);
// If not found locally, fetch from API
// Fallback: try local data if API fails
if (!seriesData) {
seriesData = await fetchSeriesDetails(seriesKey);
seriesData = findSeriesData(seriesKey);
}
originalData = {
@@ -384,6 +385,7 @@ AniWorld.EditModal = (function() {
function renderDiagnostics(data) {
const badge = document.getElementById('nfo-status-badge');
const tagsList = document.getElementById('nfo-missing-tags');
const pathDisplay = document.getElementById('nfo-path-display');
if (badge) {
if (!data.has_nfo) {
@@ -398,6 +400,19 @@ AniWorld.EditModal = (function() {
}
}
// Show NFO path if available
if (pathDisplay) {
if (data.nfo_path) {
// Extract just the relative path from the full path
const parts = data.nfo_path.split('/');
const relativePath = parts.slice(-3).join('/'); // folder/tvshow.nfo
pathDisplay.textContent = relativePath;
pathDisplay.title = data.nfo_path;
} else {
pathDisplay.textContent = '';
}
}
if (tagsList) {
if (data.missing_tags.length === 0) {
tagsList.innerHTML = '<p class="nfo-all-good">All required tags present</p>';

File diff suppressed because it is too large Load Diff

View File

@@ -742,7 +742,10 @@
<div class="edit-section">
<h4><i class="fa-solid fa-file-lines"></i> NFO Status</h4>
<div class="nfo-diagnostics">
<div id="nfo-status-badge" class="nfo-status-badge">Loading...</div>
<div class="nfo-status-row">
<div id="nfo-status-badge" class="nfo-status-badge">Loading...</div>
<span id="nfo-path-display" class="nfo-path-display"></span>
</div>
<div id="nfo-diagnostics-container">
<div id="nfo-missing-tags" class="missing-tags-list"></div>
</div>
@@ -750,12 +753,14 @@
<i class="fa-solid fa-circle-info"></i>
No TMDB ID set. Repair will search TMDB by series name.
</p>
<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 class="nfo-actions-row">
<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;">
<i class="fa-solid fa-external-link-alt"></i> Full Diagnostics
</a>
</div>
</div>
</div>
</form>

View File

@@ -1,183 +1,373 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<html lang="en" data-theme="light">
<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') }}">
<title>NFO Diagnostics - AniWorld Manager</title>
<link rel="stylesheet" href="/static/css/styles.css?v={{ static_version('css/styles.css') }}">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<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">
<div class="app-container">
<!-- 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>
<header class="header">
<div class="header-content">
<div class="header-title">
<i class="fas fa-file-lines"></i>
<h1>NFO Diagnostics</h1>
</div>
<div class="header-actions">
<a href="/" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i>
<span>Back to Main</span>
</a>
<button id="theme-toggle" class="btn btn-icon" title="Toggle theme">
<i class="fas fa-moon"></i>
</button>
<button id="logout-btn" class="btn btn-secondary" title="Logout" style="display: none;">
<i class="fas fa-sign-out-alt"></i>
<span>Logout</span>
</button>
</div>
</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>
<!-- Main content -->
<main class="main-content nfo-main-content">
<!-- Statistics Overview -->
<section class="nfo-stats-section">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon">
<i class="fas fa-folder text-primary"></i>
</div>
<div class="stat-info">
<div class="stat-value" id="total-series">-</div>
<div class="stat-label">Total Series</div>
</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 class="stat-card stat-success">
<div class="stat-icon">
<i class="fas fa-check-circle text-success"></i>
</div>
<div class="stat-info">
<div class="stat-value" id="complete-series">-</div>
<div class="stat-label">Complete NFOs</div>
</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>
<div class="stat-card stat-warning">
<div class="stat-icon">
<i class="fas fa-exclamation-circle text-warning"></i>
</div>
<div class="stat-info">
<div class="stat-value" id="incomplete-series">-</div>
<div class="stat-label">Need Repair</div>
</div>
</div>
<div class="stat-card stat-error">
<div class="stat-icon">
<i class="fas fa-times-circle text-error"></i>
</div>
<div class="stat-info">
<div class="stat-value" id="missing-series">-</div>
<div class="stat-label">Missing NFO</div>
</div>
</div>
</div>
</section>
<!-- Tab Navigation -->
<nav class="nfo-tabs">
<button class="nfo-tab active" data-tab="overview">
<i class="fas fa-list"></i>
<span>Overview</span>
</button>
<button class="nfo-tab" data-tab="diagnostics">
<i class="fas fa-search"></i>
<span>Series Diagnostics</span>
</button>
<button class="nfo-tab" data-tab="repair">
<i class="fas fa-wrench"></i>
<span>Batch Repair</span>
</button>
<button class="nfo-tab" data-tab="settings">
<i class="fas fa-cog"></i>
<span>Settings</span>
</button>
</nav>
<!-- Tab Content -->
<div class="nfo-tab-content">
<!-- Overview Tab -->
<div id="tab-overview" class="tab-panel active">
<div class="panel-header">
<h2><i class="fas fa-chart-pie"></i> NFO Health Overview</h2>
<button id="btn-refresh-overview" class="btn btn-secondary">
<i class="fas fa-refresh"></i> Refresh
</button>
</div>
<div class="overview-content">
<div class="health-summary">
<div class="health-chart">
<canvas id="nfo-health-chart"></canvas>
</div>
<div class="health-details">
<h3>Required Tags</h3>
<p class="info-text">Each NFO file must contain these tags for Kodi compatibility:</p>
<ul class="tag-list">
<li><code>title</code> - Series title</li>
<li><code>plot</code> - Series description</li>
<li><code>tmdbid</code> - TMDB identifier</li>
</ul>
<h3>Optional Tags</h3>
<p class="info-text">These enhance the Kodi experience:</p>
<ul class="tag-list optional-tags">
<li><code>year</code>, <code>premiered</code>, <code>genre</code></li>
<li><code>studio</code>, <code>rating</code>, <code>mpaa</code></li>
<li><code>actor</code>, <code>trailer</code>, <code>thumb</code></li>
</ul>
</div>
</div>
</div>
</div>
<!-- Diagnostics Tab -->
<div id="tab-diagnostics" class="tab-panel">
<div class="panel-header">
<h2><i class="fas fa-search"></i> Series Diagnostics</h2>
<div class="panel-actions">
<input type="text" id="diagnostics-search" class="search-input" placeholder="Search series...">
<select id="filter-status" class="filter-select">
<option value="all">All Status</option>
<option value="complete">Complete</option>
<option value="incomplete">Incomplete</option>
<option value="missing">Missing</option>
</select>
<button id="btn-refresh-diagnostics" class="btn btn-secondary">
<i class="fas fa-refresh"></i>
</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 class="series-list-container">
<table class="series-diagnostics-table">
<thead>
<tr>
<th class="sortable" data-sort="name">
<span>Series Name</span>
<i class="fas fa-sort"></i>
</th>
<th class="sortable" data-sort="status">
<span>Status</span>
<i class="fas fa-sort"></i>
</th>
<th>Missing Tags</th>
<th>NFO Path</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="diagnostics-table-body">
<tr class="loading-row">
<td colspan="5">
<div class="loading-spinner">
<i class="fas fa-spinner fa-spin"></i>
<span>Loading diagnostics...</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Repair Tab -->
<div id="tab-repair" class="tab-panel">
<div class="panel-header">
<h2><i class="fas fa-wrench"></i> Batch Repair</h2>
</div>
<div class="repair-content">
<div class="repair-info">
<div class="info-card">
<i class="fas fa-info-circle"></i>
<div>
<h4>About NFO Repair</h4>
<p>Repairs NFO files using TMDB metadata. If a series has no TMDB ID set, the repair will search TMDB by the series name.</p>
</div>
</div>
</div>
<div class="repair-actions">
<button id="btn-select-all-repair" class="btn btn-secondary">
<i class="fas fa-check-square"></i> Select All Needing Repair
</button>
<button id="btn-clear-selection" class="btn btn-secondary">
<i class="fas fa-times"></i> Clear Selection
</button>
</div>
<div class="repair-list-container">
<table class="repair-table">
<thead>
<tr>
<th class="checkbox-col">
<input type="checkbox" id="select-all-repair">
</th>
<th>Series Name</th>
<th>Status</th>
<th>TMDB ID</th>
<th>Priority</th>
</tr>
</thead>
<tbody id="repair-table-body">
<tr class="loading-row">
<td colspan="5">
<div class="loading-spinner">
<i class="fas fa-spinner fa-spin"></i>
<span>Loading series needing repair...</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="repair-footer">
<div class="selection-count">
<span id="selected-count">0</span> series selected
</div>
<button id="btn-batch-repair" class="btn btn-primary" disabled>
<i class="fas fa-wrench"></i> Repair Selected
</button>
</div>
</div>
</div>
<!-- Settings Tab -->
<div id="tab-settings" class="tab-panel">
<div class="panel-header">
<h2><i class="fas fa-cog"></i> NFO Settings</h2>
</div>
<div class="settings-content">
<div class="settings-section">
<h3><i class="fas fa-plug"></i> TMDB Connection</h3>
<div class="setting-item">
<div class="setting-info">
<label for="tmdb-api-key">TMDB API Key</label>
<p class="setting-description">Required for fetching metadata. Get your key from <a href="https://www.themoviedb.org/settings/api" target="_blank">TMDB</a></p>
</div>
<div class="setting-control">
<input type="password" id="tmdb-api-key" class="input-field" placeholder="Enter TMDB API key">
<button id="btn-test-tmdb" class="btn btn-secondary">
<i class="fas fa-plug"></i> Test Connection
</button>
</div>
</div>
<div id="tmdb-status" class="connection-status hidden"></div>
</div>
<div class="settings-section">
<h3><i class="fas fa-file-code"></i> Auto-Generation</h3>
<div class="setting-item">
<div class="setting-info">
<label for="nfo-auto-create">Auto-create NFO files</label>
<p class="setting-description">Automatically create NFO metadata when downloading new series</p>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input type="checkbox" id="nfo-auto-create">
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<label for="nfo-update-on-scan">Update NFO on rescan</label>
<p class="setting-description">Refresh existing NFO files when rescanning library</p>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input type="checkbox" id="nfo-update-on-scan">
<span class="toggle-slider"></span>
</label>
</div>
</div>
</div>
<div class="settings-section">
<h3><i class="fas fa-image"></i> Image Downloads</h3>
<div class="setting-item">
<div class="setting-info">
<label for="nfo-download-poster">Download poster.jpg</label>
<p class="setting-description">Download series poster image</p>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input type="checkbox" id="nfo-download-poster" checked>
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<label for="nfo-download-fanart">Download fanart.jpg</label>
<p class="setting-description">Download background fanart image</p>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input type="checkbox" id="nfo-download-fanart" checked>
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<label for="nfo-download-logo">Download logo.png</label>
<p class="setting-description">Download series logo/clearlogo</p>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input type="checkbox" id="nfo-download-logo" checked>
<span class="toggle-slider"></span>
</label>
</div>
</div>
</div>
<div class="settings-section">
<h3><i class="fas fa-save"></i> Save Settings</h3>
<button id="btn-save-nfo-settings" class="btn btn-primary">
<i class="fas fa-save"></i> Save NFO Settings
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Toast Notifications -->
<div id="toast-container" class="toast-container"></div>
</main>
</div>
<!-- Loading overlay -->
<div id="loading-overlay" class="loading-overlay hidden">
<div class="loading-spinner">
<i class="fas fa-spinner fa-spin"></i>
<p>Processing...</p>
</div>
</div>
<!-- Toast notifications -->
<div id="toast-container" class="toast-container"></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/shared/websocket-client.js?v={{ static_version('js/shared/websocket-client.js') }}"></script>
<script src="/static/js/localization.js?v={{ static_version('js/localization.js') }}"></script>
<script src="/static/js/user_preferences.js?v={{ static_version('js/user_preferences.js') }}"></script>
<!-- NFO Settings Page Module -->
<script src="/static/js/pages/nfo-settings.js?v={{ static_version('js/pages/nfo-settings.js') }}"></script>
</body>
</html>
</html>