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:
@@ -16,7 +16,11 @@ from src.server.exceptions import (
|
|||||||
ServerError,
|
ServerError,
|
||||||
ValidationError,
|
ValidationError,
|
||||||
)
|
)
|
||||||
from src.server.models.anime import AnimeMetadataUpdate
|
from src.server.models.anime import (
|
||||||
|
AnimeDetailsResponse,
|
||||||
|
AnimeMetadataUpdate,
|
||||||
|
TMDBSearchResult,
|
||||||
|
)
|
||||||
from src.server.services.anime_service import AnimeService, AnimeServiceError
|
from src.server.services.anime_service import AnimeService, AnimeServiceError
|
||||||
from src.server.services.background_loader_service import BackgroundLoaderService
|
from src.server.services.background_loader_service import BackgroundLoaderService
|
||||||
from src.server.utils.dependencies import (
|
from src.server.utils.dependencies import (
|
||||||
@@ -1192,6 +1196,120 @@ async def get_anime(
|
|||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{anime_key}/details", response_model=AnimeDetailsResponse)
|
||||||
|
async def get_anime_details(
|
||||||
|
anime_key: str,
|
||||||
|
_auth: dict = Depends(require_auth),
|
||||||
|
db: AsyncSession = Depends(get_database_session),
|
||||||
|
) -> AnimeDetailsResponse:
|
||||||
|
"""Get detailed information about a specific anime series for the edit modal.
|
||||||
|
|
||||||
|
Returns comprehensive series metadata including TMDB/TVDB IDs, NFO status,
|
||||||
|
and other details needed to pre-fill the edit form.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
anime_key: Series key (primary identifier)
|
||||||
|
_auth: Authentication dependency
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AnimeDetailsResponse: Full series details for edit modal
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException 404: Series not found
|
||||||
|
"""
|
||||||
|
# Get series from database (authoritative source for IDs and NFO status)
|
||||||
|
series = await AnimeSeriesService.get_by_key(db, anime_key)
|
||||||
|
if not series:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Series with key '{anime_key}' not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Format timestamps
|
||||||
|
nfo_created = None
|
||||||
|
nfo_updated = None
|
||||||
|
if series.nfo_created_at:
|
||||||
|
nfo_created = series.nfo_created_at.isoformat()
|
||||||
|
if series.nfo_updated_at:
|
||||||
|
nfo_updated = series.nfo_updated_at.isoformat()
|
||||||
|
|
||||||
|
return AnimeDetailsResponse(
|
||||||
|
key=series.key,
|
||||||
|
name=series.name,
|
||||||
|
folder=series.folder,
|
||||||
|
year=series.year,
|
||||||
|
status=None, # Status not stored in DB, only in NFO/TMDB
|
||||||
|
plot=None, # Plot not stored in DB, only in NFO/TMDB
|
||||||
|
genres=[],
|
||||||
|
studio=[],
|
||||||
|
premiered=None,
|
||||||
|
rating=None,
|
||||||
|
rating_votes=None,
|
||||||
|
tmdb_id=series.tmdb_id,
|
||||||
|
tvdb_id=series.tvdb_id,
|
||||||
|
has_nfo=series.has_nfo,
|
||||||
|
nfo_created_at=nfo_created,
|
||||||
|
nfo_updated_at=nfo_updated,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{anime_key}/tmdb-search", response_model=List[TMDBSearchResult])
|
||||||
|
async def search_tmdb_for_series(
|
||||||
|
anime_key: str,
|
||||||
|
_auth: dict = Depends(require_auth),
|
||||||
|
db: AsyncSession = Depends(get_database_session),
|
||||||
|
) -> List[TMDBSearchResult]:
|
||||||
|
"""Search TMDB for a series by its name to find matching metadata.
|
||||||
|
|
||||||
|
Used by the edit modal's "Fetch from TMDB" feature when no TMDB ID
|
||||||
|
is set. Searches TMDB using the series name and returns matches.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
anime_key: Series key to look up
|
||||||
|
_auth: Authentication dependency
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List[TMDBSearchResult]: Matching TMDB results
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException 404: Series not found
|
||||||
|
HTTPException 400: TMDB not configured
|
||||||
|
"""
|
||||||
|
from src.server.nfo.tmdb_client import TMDBClient
|
||||||
|
|
||||||
|
# Get series from database
|
||||||
|
series = await AnimeSeriesService.get_by_key(db, anime_key)
|
||||||
|
if not series:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Series with key '{anime_key}' not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if TMDB is configured
|
||||||
|
if not settings.tmdb_api_key:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="TMDB API key not configured",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Search TMDB by series name
|
||||||
|
tmdb_client = TMDBClient(api_key=settings.tmdb_api_key)
|
||||||
|
results = await tmdb_client.search_tv_series(series.name)
|
||||||
|
|
||||||
|
return [
|
||||||
|
TMDBSearchResult(
|
||||||
|
tmdb_id=r["id"],
|
||||||
|
title=r.get("name", ""),
|
||||||
|
year=int(r.get("first_air_date", "0000")[:4]) if r.get("first_air_date") else None,
|
||||||
|
overview=r.get("overview"),
|
||||||
|
vote_average=r.get("vote_average"),
|
||||||
|
)
|
||||||
|
for r in results
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
# Maximum allowed input size for security
|
# Maximum allowed input size for security
|
||||||
MAX_INPUT_LENGTH = 100000 # 100KB
|
MAX_INPUT_LENGTH = 100000 # 100KB
|
||||||
|
|
||||||
|
|||||||
@@ -1,70 +1,485 @@
|
|||||||
"""NFO Management API endpoints.
|
"""NFO Management API endpoints.
|
||||||
|
|
||||||
Note: NFO service has been removed. All NFO endpoints return 503.
|
Provides endpoints for:
|
||||||
|
- Diagnostics: Check NFO status and missing tags
|
||||||
|
- Repair: Create/update NFO files for series
|
||||||
|
- Validation: Quick NFO XML validity check
|
||||||
|
- Needs-repair: List all series that need NFO attention
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, HTTPException, status
|
import os
|
||||||
|
from typing import Any, List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from src.config.settings import settings
|
||||||
|
from src.server.models.nfo import NfoDiagnosticsResponse, NfoRepairResponse
|
||||||
|
from src.server.services.anime_service import AnimeService, AnimeServiceError
|
||||||
|
from src.server.utils.dependencies import get_anime_service, require_auth
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/nfo", tags=["nfo"])
|
router = APIRouter(prefix="/api/nfo", tags=["nfo"])
|
||||||
|
|
||||||
|
# Required tags for a valid Kodi tvshow.nfo
|
||||||
|
REQUIRED_TAGS = [
|
||||||
|
"title",
|
||||||
|
"plot",
|
||||||
|
"tmdbid",
|
||||||
|
]
|
||||||
|
# All tags we check for completeness
|
||||||
|
ALL_TAGS = [
|
||||||
|
"title",
|
||||||
|
"originaltitle",
|
||||||
|
"showtitle",
|
||||||
|
"sorttitle",
|
||||||
|
"year",
|
||||||
|
"plot",
|
||||||
|
"outline",
|
||||||
|
"tagline",
|
||||||
|
"runtime",
|
||||||
|
"mpaa",
|
||||||
|
"premiered",
|
||||||
|
"status",
|
||||||
|
"studio",
|
||||||
|
"genre",
|
||||||
|
"tmdbid",
|
||||||
|
"imdbid",
|
||||||
|
"tvdbid",
|
||||||
|
"userrating",
|
||||||
|
"trailer",
|
||||||
|
"thumb",
|
||||||
|
"fanart",
|
||||||
|
"actor",
|
||||||
|
]
|
||||||
|
|
||||||
@router.get("/disabled")
|
|
||||||
async def nfo_disabled():
|
class NfoSeriesItem(BaseModel):
|
||||||
"""NFO endpoints disabled - NFO service removed."""
|
"""Series with NFO status for listing endpoints."""
|
||||||
|
key: str = Field(..., description="Series unique key")
|
||||||
|
name: str = Field(..., description="Series display name")
|
||||||
|
folder: str = Field(..., description="Series folder name")
|
||||||
|
has_nfo: bool = Field(..., description="Whether NFO file exists")
|
||||||
|
nfo_path: Optional[str] = Field(None, description="Path to NFO file if exists")
|
||||||
|
missing_tags: List[str] = Field(default_factory=list, description="Missing tags if NFO exists")
|
||||||
|
|
||||||
|
|
||||||
|
class NfoNeedsRepairResponse(BaseModel):
|
||||||
|
"""Response listing all series that need NFO attention."""
|
||||||
|
total: int = Field(..., description="Total number of series")
|
||||||
|
missing_nfo_count: int = Field(..., description="Series without any NFO file")
|
||||||
|
incomplete_nfo_count: int = Field(..., description="Series with NFO but missing tags")
|
||||||
|
series: List[NfoSeriesItem] = Field(..., description="List of series needing attention")
|
||||||
|
|
||||||
|
|
||||||
|
class NfoValidateResponse(BaseModel):
|
||||||
|
"""Response for NFO validation check."""
|
||||||
|
valid: bool = Field(..., description="Whether NFO XML is valid")
|
||||||
|
error: Optional[str] = Field(None, description="Error message if invalid")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_nfo_tags(nfo_path: str) -> dict[str, str]:
|
||||||
|
"""Parse NFO file and return tag values.
|
||||||
|
|
||||||
|
Returns dict of tag name -> tag text content.
|
||||||
|
Empty tags (e.g., <plot></plot>) are returned as empty string.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from lxml import etree
|
||||||
|
except ImportError:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
tree = etree.parse(nfo_path)
|
||||||
|
root = tree.getroot()
|
||||||
|
|
||||||
|
tags = {}
|
||||||
|
for elem in root.iter():
|
||||||
|
tag_name = elem.tag.lower()
|
||||||
|
if tag_name not in tags:
|
||||||
|
# Get text content, None if element has no text
|
||||||
|
text = elem.text.strip() if elem.text else ""
|
||||||
|
tags[tag_name] = text
|
||||||
|
return tags
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _check_missing_tags(tags: dict[str, str]) -> List[str]:
|
||||||
|
"""Check which required tags are missing or empty."""
|
||||||
|
missing = []
|
||||||
|
for tag in REQUIRED_TAGS:
|
||||||
|
value = tags.get(tag, "").strip()
|
||||||
|
if not value:
|
||||||
|
missing.append(tag)
|
||||||
|
return missing
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/needs-repair", response_model=NfoNeedsRepairResponse)
|
||||||
|
async def get_series_needing_repair(
|
||||||
|
_auth: dict = Depends(require_auth),
|
||||||
|
anime_service: AnimeService = Depends(get_anime_service),
|
||||||
|
) -> NfoNeedsRepairResponse:
|
||||||
|
"""Get all series that need NFO repair (missing file or missing tags).
|
||||||
|
|
||||||
|
Returns series grouped by:
|
||||||
|
- Missing NFO file entirely
|
||||||
|
- NFO file exists but missing required tags
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
all_series = await anime_service.list_series_with_filters()
|
||||||
|
except AnimeServiceError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to fetch series list: {exc}",
|
||||||
|
)
|
||||||
|
|
||||||
|
anime_dir = getattr(settings, "anime_directory", None)
|
||||||
|
if not anime_dir:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
detail="NFO service has been removed. Use series management endpoints instead."
|
detail="Anime directory not configured",
|
||||||
|
)
|
||||||
|
|
||||||
|
missing_nfo_list: List[NfoSeriesItem] = []
|
||||||
|
incomplete_list: List[NfoSeriesItem] = []
|
||||||
|
|
||||||
|
for series in all_series:
|
||||||
|
key = series.get("key", "")
|
||||||
|
name = series.get("name", "")
|
||||||
|
folder = series.get("folder", "")
|
||||||
|
|
||||||
|
if not folder:
|
||||||
|
continue
|
||||||
|
|
||||||
|
nfo_path = os.path.join(anime_dir, folder, "tvshow.nfo")
|
||||||
|
|
||||||
|
if not os.path.isfile(nfo_path):
|
||||||
|
missing_nfo_list.append(NfoSeriesItem(
|
||||||
|
key=key,
|
||||||
|
name=name,
|
||||||
|
folder=folder,
|
||||||
|
has_nfo=False,
|
||||||
|
nfo_path=None,
|
||||||
|
missing_tags=REQUIRED_TAGS.copy(),
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
# Check for missing tags
|
||||||
|
tags = _parse_nfo_tags(nfo_path)
|
||||||
|
missing = _check_missing_tags(tags)
|
||||||
|
if missing:
|
||||||
|
incomplete_list.append(NfoSeriesItem(
|
||||||
|
key=key,
|
||||||
|
name=name,
|
||||||
|
folder=folder,
|
||||||
|
has_nfo=True,
|
||||||
|
nfo_path=nfo_path,
|
||||||
|
missing_tags=missing,
|
||||||
|
))
|
||||||
|
|
||||||
|
all_needing_repair = missing_nfo_list + incomplete_list
|
||||||
|
|
||||||
|
return NfoNeedsRepairResponse(
|
||||||
|
total=len(all_series),
|
||||||
|
missing_nfo_count=len(missing_nfo_list),
|
||||||
|
incomplete_nfo_count=len(incomplete_list),
|
||||||
|
series=all_needing_repair,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/batch/create")
|
@router.get("/{serie_key}/diagnostics", response_model=NfoDiagnosticsResponse)
|
||||||
async def batch_create_nfo():
|
async def get_nfo_diagnostics(
|
||||||
"""NFO endpoints disabled - NFO service removed."""
|
serie_key: str,
|
||||||
|
_auth: dict = Depends(require_auth),
|
||||||
|
anime_service: AnimeService = Depends(get_anime_service),
|
||||||
|
) -> NfoDiagnosticsResponse:
|
||||||
|
"""Get NFO diagnostics for a specific series.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- Whether tvshow.nfo exists
|
||||||
|
- Path to NFO file if it exists
|
||||||
|
- List of missing required tags (if any)
|
||||||
|
- List of all tags for reference
|
||||||
|
"""
|
||||||
|
# Get series data
|
||||||
|
try:
|
||||||
|
all_series = await anime_service.list_series_with_filters()
|
||||||
|
series = next((s for s in all_series if s.get("key") == serie_key), None)
|
||||||
|
except AnimeServiceError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to fetch series: {exc}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not series:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Series not found: {serie_key}",
|
||||||
|
)
|
||||||
|
|
||||||
|
folder = series.get("folder", "")
|
||||||
|
if not folder:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Series has no folder assigned",
|
||||||
|
)
|
||||||
|
|
||||||
|
anime_dir = getattr(settings, "anime_directory", None)
|
||||||
|
if not anime_dir:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
detail="NFO service has been removed. Use series management endpoints instead."
|
detail="Anime directory not configured",
|
||||||
|
)
|
||||||
|
|
||||||
|
nfo_path = os.path.join(anime_dir, folder, "tvshow.nfo")
|
||||||
|
|
||||||
|
if not os.path.isfile(nfo_path):
|
||||||
|
return NfoDiagnosticsResponse(
|
||||||
|
has_nfo=False,
|
||||||
|
nfo_path=None,
|
||||||
|
missing_tags=REQUIRED_TAGS.copy(),
|
||||||
|
required_tags=ALL_TAGS.copy(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse NFO and check for missing tags
|
||||||
|
tags = _parse_nfo_tags(nfo_path)
|
||||||
|
missing = _check_missing_tags(tags)
|
||||||
|
|
||||||
|
return NfoDiagnosticsResponse(
|
||||||
|
has_nfo=True,
|
||||||
|
nfo_path=nfo_path,
|
||||||
|
missing_tags=missing,
|
||||||
|
required_tags=ALL_TAGS.copy(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{serie_id}/create")
|
@router.post("/{serie_key}/repair", response_model=NfoRepairResponse)
|
||||||
async def create_nfo(serie_id: str):
|
async def repair_nfo(
|
||||||
"""NFO endpoints disabled - NFO service removed."""
|
serie_key: str,
|
||||||
|
_auth: dict = Depends(require_auth),
|
||||||
|
anime_service: AnimeService = Depends(get_anime_service),
|
||||||
|
) -> NfoRepairResponse:
|
||||||
|
"""Repair (create or update) NFO file for a series.
|
||||||
|
|
||||||
|
If NFO doesn't exist, creates it from TMDB metadata.
|
||||||
|
If NFO exists but has missing tags, updates it with TMDB data.
|
||||||
|
If no TMDB ID is available, attempts to look up by series name.
|
||||||
|
"""
|
||||||
|
# Get series data
|
||||||
|
try:
|
||||||
|
all_series = await anime_service.list_series_with_filters()
|
||||||
|
series = next((s for s in all_series if s.get("key") == serie_key), None)
|
||||||
|
except AnimeServiceError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to fetch series: {exc}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not series:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Series not found: {serie_key}",
|
||||||
|
)
|
||||||
|
|
||||||
|
folder = series.get("folder", "")
|
||||||
|
if not folder:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Series has no folder assigned",
|
||||||
|
)
|
||||||
|
|
||||||
|
anime_dir = getattr(settings, "anime_directory", None)
|
||||||
|
if not anime_dir:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
detail="NFO service has been removed. Use series management endpoints instead."
|
detail="Anime directory not configured",
|
||||||
|
)
|
||||||
|
|
||||||
|
nfo_path = os.path.join(anime_dir, folder, "tvshow.nfo")
|
||||||
|
tmdb_id = series.get("tmdb_id")
|
||||||
|
|
||||||
|
# If no TMDB ID, try to look up by name
|
||||||
|
if not tmdb_id:
|
||||||
|
name = series.get("name", "")
|
||||||
|
tmdb_id = await _lookup_tmdb_id_by_name(name)
|
||||||
|
if tmdb_id:
|
||||||
|
# Update series with found TMDB ID (best effort)
|
||||||
|
try:
|
||||||
|
await anime_service.update_series_key(serie_key, {"tmdb_id": tmdb_id})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not tmdb_id:
|
||||||
|
return NfoRepairResponse(
|
||||||
|
success=False,
|
||||||
|
message=f"Cannot repair NFO for {serie_key}: no TMDB ID available and could not find match by name",
|
||||||
|
repaired_tags=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch TMDB data and generate NFO
|
||||||
|
tmdb_data = await _fetch_tmdb_data(tmdb_id)
|
||||||
|
if not tmdb_data:
|
||||||
|
return NfoRepairResponse(
|
||||||
|
success=False,
|
||||||
|
message=f"No TMDB data found for TMDB ID {tmdb_id}",
|
||||||
|
repaired_tags=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate NFO XML
|
||||||
|
from src.server.nfo.nfo_generator import generate_tvshow_nfo
|
||||||
|
from src.server.nfo.nfo_mapper import tmdb_to_nfo_model
|
||||||
|
|
||||||
|
nfo_model = tmdb_to_nfo_model(
|
||||||
|
tmdb_data,
|
||||||
|
content_ratings=None,
|
||||||
|
get_image_url=lambda path: f"https://image.tmdb.org/t/p/original{path}" if path else None,
|
||||||
|
image_size="original",
|
||||||
|
)
|
||||||
|
|
||||||
|
xml_content = generate_tvshow_nfo(nfo_model)
|
||||||
|
|
||||||
|
# Ensure directory exists
|
||||||
|
os.makedirs(os.path.dirname(nfo_path), exist_ok=True)
|
||||||
|
|
||||||
|
# Track which tags were missing before
|
||||||
|
had_nfo = os.path.isfile(nfo_path)
|
||||||
|
if had_nfo:
|
||||||
|
old_tags = _parse_nfo_tags(nfo_path)
|
||||||
|
old_missing = _check_missing_tags(old_tags)
|
||||||
|
else:
|
||||||
|
old_missing = REQUIRED_TAGS.copy()
|
||||||
|
|
||||||
|
# Write NFO file
|
||||||
|
with open(nfo_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(xml_content)
|
||||||
|
|
||||||
|
# Update series NFO flag in database (best effort)
|
||||||
|
await _update_series_nfo_flag(anime_service, serie_key, True, nfo_path)
|
||||||
|
|
||||||
|
# Calculate repaired tags
|
||||||
|
new_tags = _parse_nfo_tags(nfo_path)
|
||||||
|
new_missing = _check_missing_tags(new_tags)
|
||||||
|
repaired_tags = [tag for tag in old_missing if tag not in new_missing]
|
||||||
|
|
||||||
|
action = "created" if not had_nfo else "updated"
|
||||||
|
return NfoRepairResponse(
|
||||||
|
success=True,
|
||||||
|
message=f"NFO {action} successfully for {serie_key}",
|
||||||
|
repaired_tags=repaired_tags,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{serie_id}/status")
|
@router.get("/{serie_key}/validate", response_model=NfoValidateResponse)
|
||||||
async def get_nfo_status(serie_id: str):
|
async def validate_nfo(
|
||||||
"""NFO endpoints disabled - NFO service removed."""
|
serie_key: str,
|
||||||
|
_auth: dict = Depends(require_auth),
|
||||||
|
anime_service: AnimeService = Depends(get_anime_service),
|
||||||
|
) -> NfoValidateResponse:
|
||||||
|
"""Quick check if NFO XML is valid.
|
||||||
|
|
||||||
|
Does NOT check for missing tags, only validates XML structure.
|
||||||
|
"""
|
||||||
|
# Get series data
|
||||||
|
try:
|
||||||
|
all_series = await anime_service.list_series_with_filters()
|
||||||
|
series = next((s for s in all_series if s.get("key") == serie_key), None)
|
||||||
|
except AnimeServiceError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to fetch series: {exc}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not series:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Series not found: {serie_key}",
|
||||||
|
)
|
||||||
|
|
||||||
|
folder = series.get("folder", "")
|
||||||
|
if not folder:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Series has no folder assigned",
|
||||||
|
)
|
||||||
|
|
||||||
|
anime_dir = getattr(settings, "anime_directory", None)
|
||||||
|
if not anime_dir:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
detail="NFO service has been removed. Use series management endpoints instead."
|
detail="Anime directory not configured",
|
||||||
|
)
|
||||||
|
|
||||||
|
nfo_path = os.path.join(anime_dir, folder, "tvshow.nfo")
|
||||||
|
|
||||||
|
if not os.path.isfile(nfo_path):
|
||||||
|
return NfoValidateResponse(
|
||||||
|
valid=False,
|
||||||
|
error="NFO file does not exist",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from lxml import etree
|
||||||
|
etree.parse(nfo_path)
|
||||||
|
return NfoValidateResponse(
|
||||||
|
valid=True,
|
||||||
|
error=None,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return NfoValidateResponse(
|
||||||
|
valid=False,
|
||||||
|
error=str(exc),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{serie_id}/delete")
|
# ---- Helper functions ----
|
||||||
async def delete_nfo(serie_id: str):
|
|
||||||
"""NFO endpoints disabled - NFO service removed."""
|
async def _lookup_tmdb_id_by_name(name: str) -> Optional[int]:
|
||||||
raise HTTPException(
|
"""Look up TMDB ID by series name using TMDB search."""
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
try:
|
||||||
detail="NFO service has been removed. Use series management endpoints instead."
|
from src.server.providers.tmdb_provider import TMDBProvider
|
||||||
)
|
provider = TMDBProvider()
|
||||||
|
results = await provider.search(name)
|
||||||
|
if results and len(results) > 0:
|
||||||
|
return results[0].get("tmdb_id")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/poster/{serie_id}")
|
async def _fetch_tmdb_data(tmdb_id: int) -> Optional[dict]:
|
||||||
async def get_nfo_poster(serie_id: str):
|
"""Fetch TV show data from TMDB."""
|
||||||
"""NFO endpoints disabled - NFO service removed."""
|
try:
|
||||||
raise HTTPException(
|
from src.server.providers.tmdb_provider import TMDBProvider
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
provider = TMDBProvider()
|
||||||
detail="NFO service has been removed. Use series management endpoints instead."
|
return await provider.get_tvshow(tmdb_id)
|
||||||
)
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/fanart/{serie_id}")
|
async def _update_series_nfo_flag(
|
||||||
async def get_nfo_fanart(serie_id: str):
|
anime_service: AnimeService,
|
||||||
"""NFO endpoints disabled - NFO service removed."""
|
key: str,
|
||||||
raise HTTPException(
|
has_nfo: bool,
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
nfo_path: str,
|
||||||
detail="NFO service has been removed. Use series management endpoints instead."
|
) -> None:
|
||||||
)
|
"""Update NFO flag in database for a series."""
|
||||||
|
try:
|
||||||
|
# Use the anime service to update the series
|
||||||
|
await anime_service.update_series_key(key, {"has_nfo": has_nfo})
|
||||||
|
except Exception:
|
||||||
|
# Fallback: try direct database update
|
||||||
|
try:
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from src.server.database.connection import get_db_session
|
||||||
|
from src.server.database.service import AnimeSeriesService
|
||||||
|
|
||||||
|
async with get_db_session() as db:
|
||||||
|
series_record = await AnimeSeriesService.get_by_key(db, key)
|
||||||
|
if series_record:
|
||||||
|
series_record.has_nfo = has_nfo
|
||||||
|
if has_nfo:
|
||||||
|
series_record.nfo_updated_at = datetime.now(timezone.utc)
|
||||||
|
if not series_record.nfo_created_at:
|
||||||
|
series_record.nfo_created_at = datetime.now(timezone.utc)
|
||||||
|
await db.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -69,3 +69,13 @@ async def unresolved_page(request: Request):
|
|||||||
request,
|
request,
|
||||||
title="Resolve Series - Aniworld"
|
title="Resolve Series - Aniworld"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/settings/nfo", response_class=HTMLResponse)
|
||||||
|
async def nfo_settings_page(request: Request):
|
||||||
|
"""Serve the NFO diagnostics and repair settings page."""
|
||||||
|
return render_template(
|
||||||
|
"nfo-settings.html",
|
||||||
|
request,
|
||||||
|
title="NFO Diagnostics - Aniworld"
|
||||||
|
)
|
||||||
|
|||||||
@@ -133,21 +133,85 @@ class SearchResult(BaseModel):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
title: str = Field(..., description="Series title")
|
title: str = Field(..., description="Series title")
|
||||||
folder: Optional[str] = Field(
|
|
||||||
None,
|
|
||||||
description=(
|
|
||||||
"Series folder name on disk (metadata only) "
|
|
||||||
"(e.g., 'Attack on Titan (2013)'). For display/filesystem ops only."
|
|
||||||
)
|
|
||||||
)
|
|
||||||
snippet: Optional[str] = Field(None, description="Short description or snippet")
|
|
||||||
thumbnail: Optional[HttpUrl] = Field(None, description="Thumbnail image URL")
|
|
||||||
score: Optional[float] = Field(None, ge=0.0, le=1.0, description="Search relevance score (0-1)")
|
|
||||||
|
|
||||||
@field_validator('key', mode='before')
|
|
||||||
@classmethod
|
class AnimeDetailsResponse(BaseModel):
|
||||||
def normalize_key(cls, v: str) -> str:
|
"""Detailed response model for a single anime series with all metadata.
|
||||||
"""Normalize key to lowercase."""
|
|
||||||
if isinstance(v, str):
|
Used by the edit modal to pre-fill form fields with existing data.
|
||||||
return v.lower().strip()
|
|
||||||
return v
|
Attributes:
|
||||||
|
key: Unique series identifier
|
||||||
|
name: Display name
|
||||||
|
folder: Filesystem folder name
|
||||||
|
year: Release year
|
||||||
|
status: Show status (Continuing, Ended)
|
||||||
|
plot: Plot description
|
||||||
|
genres: List of genres
|
||||||
|
studio: List of studios
|
||||||
|
premiered: Premiere date
|
||||||
|
rating: Rating value (0-10)
|
||||||
|
rating_votes: Number of votes
|
||||||
|
tmdb_id: TMDB ID
|
||||||
|
tvdb_id: TVDB ID
|
||||||
|
has_nfo: Whether NFO file exists
|
||||||
|
nfo_created_at: NFO creation timestamp
|
||||||
|
nfo_updated_at: NFO update timestamp
|
||||||
|
"""
|
||||||
|
|
||||||
|
key: str = Field(..., description="Unique series identifier")
|
||||||
|
name: str = Field(..., description="Display name")
|
||||||
|
folder: Optional[str] = Field(None, description="Filesystem folder name")
|
||||||
|
year: Optional[int] = Field(None, ge=1900, le=2100, description="Release year")
|
||||||
|
status: Optional[str] = Field(None, description="Show status (Continuing, Ended)")
|
||||||
|
plot: Optional[str] = Field(None, description="Plot description")
|
||||||
|
genres: List[str] = Field(default_factory=list, description="List of genres")
|
||||||
|
studio: List[str] = Field(default_factory=list, description="List of studios")
|
||||||
|
premiered: Optional[str] = Field(None, description="Premiere date (YYYY-MM-DD)")
|
||||||
|
rating: Optional[float] = Field(None, ge=0, le=10, description="Rating value (0-10)")
|
||||||
|
rating_votes: Optional[int] = Field(None, ge=0, description="Number of votes")
|
||||||
|
tmdb_id: Optional[int] = Field(None, description="TMDB ID")
|
||||||
|
tvdb_id: Optional[int] = Field(None, description="TVDB ID")
|
||||||
|
has_nfo: bool = Field(False, description="Whether NFO file exists")
|
||||||
|
nfo_created_at: Optional[str] = Field(None, description="NFO creation timestamp")
|
||||||
|
nfo_updated_at: Optional[str] = Field(None, description="NFO update timestamp")
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
json_schema_extra = {
|
||||||
|
"example": {
|
||||||
|
"key": "attack-on-titan",
|
||||||
|
"name": "Attack on Titan",
|
||||||
|
"folder": "Attack on Titan (2013)",
|
||||||
|
"year": 2013,
|
||||||
|
"status": "Ended",
|
||||||
|
"plot": "Humans fight against giant humanoid Titans.",
|
||||||
|
"genres": ["Animation", "Action", "Drama"],
|
||||||
|
"studio": ["Wit Studio", "MAPPA"],
|
||||||
|
"premiered": "2013-04-07",
|
||||||
|
"rating": 9.0,
|
||||||
|
"rating_votes": 500000,
|
||||||
|
"tmdb_id": 1429,
|
||||||
|
"tvdb_id": 267440,
|
||||||
|
"has_nfo": True,
|
||||||
|
"nfo_created_at": "2025-01-15T10:30:00Z",
|
||||||
|
"nfo_updated_at": "2025-01-15T10:30:00Z",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TMDBSearchResult(BaseModel):
|
||||||
|
"""TMDB search result for auto-lookup functionality.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
tmdb_id: TMDB ID of the matched series
|
||||||
|
title: Title from TMDB
|
||||||
|
year: Release year
|
||||||
|
overview: Short description
|
||||||
|
vote_average: TMDB rating
|
||||||
|
"""
|
||||||
|
|
||||||
|
tmdb_id: int = Field(..., description="TMDB ID")
|
||||||
|
title: str = Field(..., description="Title from TMDB")
|
||||||
|
year: Optional[int] = Field(None, description="Release year")
|
||||||
|
overview: Optional[str] = Field(None, description="Short description")
|
||||||
|
vote_average: Optional[float] = Field(None, description="TMDB rating")
|
||||||
|
|||||||
@@ -469,4 +469,101 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Series Info Grid (Read-only display) */
|
||||||
|
.info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: var(--spacing-sm) var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item label {
|
||||||
|
font-size: var(--font-size-caption);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item .info-value {
|
||||||
|
font-size: var(--font-size-body);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input with action button */
|
||||||
|
.input-with-action {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-with-action .input-field {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-fetch-tmdb {
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TMDB Search Results */
|
||||||
|
.tmdb-search-results {
|
||||||
|
margin-top: var(--spacing-sm);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
background: var(--color-surface);
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tmdb-result-item {
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid var(--color-divider);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tmdb-result-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tmdb-result-item:hover {
|
||||||
|
background: var(--color-background-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tmdb-result-title {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tmdb-result-overview {
|
||||||
|
font-size: var(--font-size-caption);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tmdb-error,
|
||||||
|
.tmdb-no-results,
|
||||||
|
.tmdb-selected {
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
font-size: var(--font-size-caption);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tmdb-error {
|
||||||
|
color: var(--color-error, #e74c3c);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tmdb-selected {
|
||||||
|
color: var(--color-success, #2ecc71);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
427
src/server/web/static/css/pages/nfo-settings.css
Normal file
427
src/server/web/static/css/pages/nfo-settings.css
Normal 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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ var AniWorld = window.AniWorld || {};
|
|||||||
AniWorld.EditModal = (function() {
|
AniWorld.EditModal = (function() {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
const API = AniWorld.Constants ? AniWorld.Constants.API : {};
|
||||||
|
|
||||||
let modalElement = null;
|
let modalElement = null;
|
||||||
let originalData = null;
|
let originalData = null;
|
||||||
let currentKey = null;
|
let currentKey = null;
|
||||||
@@ -34,13 +36,26 @@ AniWorld.EditModal = (function() {
|
|||||||
hideKeyWarning();
|
hideKeyWarning();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Find series data from the local series list
|
// Try to find series data from the local series list first
|
||||||
const seriesData = findSeriesData(seriesKey);
|
let seriesData = findSeriesData(seriesKey);
|
||||||
|
|
||||||
|
// If not found locally, fetch from API
|
||||||
|
if (!seriesData) {
|
||||||
|
seriesData = await fetchSeriesDetails(seriesKey);
|
||||||
|
}
|
||||||
|
|
||||||
originalData = {
|
originalData = {
|
||||||
key: seriesKey,
|
key: seriesKey,
|
||||||
tmdb_id: seriesData ? seriesData.tmdb_id : null,
|
tmdb_id: seriesData ? seriesData.tmdb_id : null,
|
||||||
tvdb_id: seriesData ? seriesData.tvdb_id : null,
|
tvdb_id: seriesData ? seriesData.tvdb_id : null,
|
||||||
|
name: seriesData ? seriesData.name : seriesKey,
|
||||||
|
year: seriesData ? seriesData.year : null,
|
||||||
|
status: seriesData ? seriesData.status : null,
|
||||||
|
genres: seriesData ? (seriesData.genres || []) : [],
|
||||||
|
studio: seriesData ? (seriesData.studio || []) : [],
|
||||||
|
premiered: seriesData ? seriesData.premiered : null,
|
||||||
|
rating: seriesData ? seriesData.rating : null,
|
||||||
|
rating_votes: seriesData ? seriesData.rating_votes : null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Populate form fields
|
// Populate form fields
|
||||||
@@ -48,6 +63,12 @@ AniWorld.EditModal = (function() {
|
|||||||
setFieldValue('edit-tmdb-id', originalData.tmdb_id || '');
|
setFieldValue('edit-tmdb-id', originalData.tmdb_id || '');
|
||||||
setFieldValue('edit-tvdb-id', originalData.tvdb_id || '');
|
setFieldValue('edit-tvdb-id', originalData.tvdb_id || '');
|
||||||
|
|
||||||
|
// Populate display fields
|
||||||
|
populateDisplayFields(originalData);
|
||||||
|
|
||||||
|
// Show/hide TMDB fetch button based on whether TMDB ID exists
|
||||||
|
updateTmdbFetchButtonState();
|
||||||
|
|
||||||
// Load NFO diagnostics
|
// Load NFO diagnostics
|
||||||
await loadDiagnostics(seriesKey);
|
await loadDiagnostics(seriesKey);
|
||||||
|
|
||||||
@@ -62,6 +83,128 @@ AniWorld.EditModal = (function() {
|
|||||||
attachListeners();
|
attachListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch series details from the API.
|
||||||
|
* @param {string} key - Series key
|
||||||
|
* @returns {Promise<Object|null>} Series details or null on error
|
||||||
|
*/
|
||||||
|
async function fetchSeriesDetails(key) {
|
||||||
|
try {
|
||||||
|
const response = await AniWorld.ApiClient.get(
|
||||||
|
'/api/anime/' + encodeURIComponent(key) + '/details'
|
||||||
|
);
|
||||||
|
if (response && response.ok) {
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch series details:', err);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Populate display-only fields in the modal.
|
||||||
|
* @param {Object} data - Series data
|
||||||
|
*/
|
||||||
|
function populateDisplayFields(data) {
|
||||||
|
const fields = [
|
||||||
|
{ id: 'edit-name', value: data.name || '' },
|
||||||
|
{ id: 'edit-year', value: data.year || '' },
|
||||||
|
{ id: 'edit-status', value: data.status || '' },
|
||||||
|
{ id: 'edit-genres', value: (data.genres || []).join(', ') },
|
||||||
|
{ id: 'edit-studio', value: (data.studio || []).join(', ') },
|
||||||
|
{ id: 'edit-premiered', value: data.premiered || '' },
|
||||||
|
{ id: 'edit-rating', value: data.rating ? data.rating.toFixed(1) + '/10' : '' },
|
||||||
|
];
|
||||||
|
|
||||||
|
fields.forEach(function(field) {
|
||||||
|
const el = document.getElementById(field.id);
|
||||||
|
if (el) el.textContent = field.value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the TMDB fetch button visibility.
|
||||||
|
*/
|
||||||
|
function updateTmdbFetchButtonState() {
|
||||||
|
const btn = document.getElementById('btn-fetch-tmdb');
|
||||||
|
const tmdbValue = getFieldValue('edit-tmdb-id').trim();
|
||||||
|
|
||||||
|
if (btn) {
|
||||||
|
btn.style.display = tmdbValue ? 'none' : 'inline-flex';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch TMDB data for auto-fill.
|
||||||
|
*/
|
||||||
|
async function fetchTmdbData() {
|
||||||
|
const btn = document.getElementById('btn-fetch-tmdb');
|
||||||
|
const resultsContainer = document.getElementById('tmdb-search-results');
|
||||||
|
if (!btn || !resultsContainer) return;
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Searching...';
|
||||||
|
resultsContainer.innerHTML = '';
|
||||||
|
resultsContainer.style.display = 'block';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await AniWorld.ApiClient.get(
|
||||||
|
'/api/anime/' + encodeURIComponent(currentKey) + '/tmdb-search'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response) {
|
||||||
|
resultsContainer.innerHTML = '<p class="tmdb-error">Connection error</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 400) {
|
||||||
|
resultsContainer.innerHTML = '<p class="tmdb-error">TMDB not configured</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
resultsContainer.innerHTML = '<p class="tmdb-error">Search failed</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await response.json();
|
||||||
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
resultsContainer.innerHTML = '<p class="tmdb-no-results">No matches found</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render results
|
||||||
|
resultsContainer.innerHTML = results.slice(0, 5).map(function(r) {
|
||||||
|
const year = r.year ? ' (' + r.year + ')' : '';
|
||||||
|
const rating = r.vote_average ? ' ★' + r.vote_average.toFixed(1) : '';
|
||||||
|
return '<div class="tmdb-result-item" data-tmdb-id="' + r.tmdb_id + '">' +
|
||||||
|
'<span class="tmdb-result-title">' + escapeHtml(r.title) + year + rating + '</span>' +
|
||||||
|
'<span class="tmdb-result-overview">' + escapeHtml(r.overview || '') + '</span>' +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Attach click handlers to results
|
||||||
|
resultsContainer.querySelectorAll('.tmdb-result-item').forEach(function(item) {
|
||||||
|
item.addEventListener('click', function() {
|
||||||
|
const tmdbId = this.getAttribute('data-tmdb-id');
|
||||||
|
setFieldValue('edit-tmdb-id', tmdbId);
|
||||||
|
resultsContainer.innerHTML = '<p class="tmdb-selected">TMDB ID ' + tmdbId + ' selected</p>';
|
||||||
|
updateTmdbFetchButtonState();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
resultsContainer.innerHTML = '<p class="tmdb-error">Search failed</p>';
|
||||||
|
console.error('TMDB search error:', err);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="fas fa-search"></i> Fetch from TMDB';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Close the edit modal and reset state.
|
* Close the edit modal and reset state.
|
||||||
*/
|
*/
|
||||||
@@ -272,6 +415,7 @@ AniWorld.EditModal = (function() {
|
|||||||
function updateRepairButtonState() {
|
function updateRepairButtonState() {
|
||||||
const btn = document.getElementById('btn-repair-nfo');
|
const btn = document.getElementById('btn-repair-nfo');
|
||||||
const hint = document.getElementById('repair-hint');
|
const hint = document.getElementById('repair-hint');
|
||||||
|
const diagnosticsLink = document.getElementById('btn-open-nfo-diagnostics');
|
||||||
const tmdbValue = getFieldValue('edit-tmdb-id').trim();
|
const tmdbValue = getFieldValue('edit-tmdb-id').trim();
|
||||||
|
|
||||||
if (btn) {
|
if (btn) {
|
||||||
@@ -281,6 +425,11 @@ AniWorld.EditModal = (function() {
|
|||||||
if (hint) {
|
if (hint) {
|
||||||
hint.style.display = tmdbValue ? 'none' : 'block';
|
hint.style.display = tmdbValue ? 'none' : 'block';
|
||||||
}
|
}
|
||||||
|
// Show link to full diagnostics page
|
||||||
|
if (diagnosticsLink && currentKey) {
|
||||||
|
diagnosticsLink.href = '/settings/nfo?key=' + encodeURIComponent(currentKey);
|
||||||
|
diagnosticsLink.style.display = 'inline-flex';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Helpers ----
|
// ---- Helpers ----
|
||||||
@@ -395,8 +544,10 @@ AniWorld.EditModal = (function() {
|
|||||||
const saveBtn = document.getElementById('btn-save-metadata');
|
const saveBtn = document.getElementById('btn-save-metadata');
|
||||||
const cancelBtn = document.getElementById('btn-cancel-metadata');
|
const cancelBtn = document.getElementById('btn-cancel-metadata');
|
||||||
const repairBtn = document.getElementById('btn-repair-nfo');
|
const repairBtn = document.getElementById('btn-repair-nfo');
|
||||||
|
const fetchTmdbBtn = document.getElementById('btn-fetch-tmdb');
|
||||||
const overlay = modalElement ? modalElement.querySelector('.modal-overlay') : null;
|
const overlay = modalElement ? modalElement.querySelector('.modal-overlay') : null;
|
||||||
const keyInput = document.getElementById('edit-key');
|
const keyInput = document.getElementById('edit-key');
|
||||||
|
const tmdbInput = document.getElementById('edit-tmdb-id');
|
||||||
|
|
||||||
if (saveBtn) {
|
if (saveBtn) {
|
||||||
var saveFn = function() { save(); };
|
var saveFn = function() { save(); };
|
||||||
@@ -416,6 +567,12 @@ AniWorld.EditModal = (function() {
|
|||||||
listeners.push({ el: repairBtn, event: 'click', fn: repairFn });
|
listeners.push({ el: repairBtn, event: 'click', fn: repairFn });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (fetchTmdbBtn) {
|
||||||
|
var fetchTmdbFn = function() { fetchTmdbData(); };
|
||||||
|
fetchTmdbBtn.addEventListener('click', fetchTmdbFn);
|
||||||
|
listeners.push({ el: fetchTmdbBtn, event: 'click', fn: fetchTmdbFn });
|
||||||
|
}
|
||||||
|
|
||||||
if (overlay) {
|
if (overlay) {
|
||||||
var overlayFn = function() { close(); };
|
var overlayFn = function() { close(); };
|
||||||
overlay.addEventListener('click', overlayFn);
|
overlay.addEventListener('click', overlayFn);
|
||||||
@@ -432,6 +589,14 @@ AniWorld.EditModal = (function() {
|
|||||||
keyInput.addEventListener('input', keyFn);
|
keyInput.addEventListener('input', keyFn);
|
||||||
listeners.push({ el: keyInput, event: 'input', fn: keyFn });
|
listeners.push({ el: keyInput, event: 'input', fn: keyFn });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tmdbInput) {
|
||||||
|
var tmdbFn = function() {
|
||||||
|
updateTmdbFetchButtonState();
|
||||||
|
};
|
||||||
|
tmdbInput.addEventListener('input', tmdbFn);
|
||||||
|
listeners.push({ el: tmdbInput, event: 'input', fn: tmdbFn });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function detachListeners() {
|
function detachListeners() {
|
||||||
|
|||||||
587
src/server/web/static/js/pages/nfo-settings.js
Normal file
587
src/server/web/static/js/pages/nfo-settings.js
Normal 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();
|
||||||
|
});
|
||||||
@@ -520,6 +520,16 @@
|
|||||||
<span data-text="test-tmdb">Test TMDB Connection</span>
|
<span data-text="test-tmdb">Test TMDB Connection</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="config-item" style="margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--color-border);">
|
||||||
|
<a href="/settings/nfo" class="btn btn-secondary" style="text-decoration: none;">
|
||||||
|
<i class="fas fa-file-lines"></i>
|
||||||
|
<span data-text="open-nfo-diagnostics">Open NFO Diagnostics</span>
|
||||||
|
</a>
|
||||||
|
<small class="config-hint" data-text="nfo-diagnostics-hint">
|
||||||
|
View and repair NFO files for all series
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Backup Configuration -->
|
<!-- Backup Configuration -->
|
||||||
@@ -652,6 +662,41 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<form id="edit-metadata-form" onsubmit="return false;">
|
<form id="edit-metadata-form" onsubmit="return false;">
|
||||||
|
<!-- Series Info Section (Read-only) -->
|
||||||
|
<div class="edit-section">
|
||||||
|
<h4><i class="fa-solid fa-info-circle"></i> Series Info</h4>
|
||||||
|
<div class="info-grid">
|
||||||
|
<div class="info-item">
|
||||||
|
<label>Name</label>
|
||||||
|
<span id="edit-name" class="info-value">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<label>Year</label>
|
||||||
|
<span id="edit-year" class="info-value">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<label>Status</label>
|
||||||
|
<span id="edit-status" class="info-value">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<label>Premiered</label>
|
||||||
|
<span id="edit-premiered" class="info-value">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<label>Genres</label>
|
||||||
|
<span id="edit-genres" class="info-value">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<label>Studio</label>
|
||||||
|
<span id="edit-studio" class="info-value">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<label>Rating</label>
|
||||||
|
<span id="edit-rating" class="info-value">-</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Identity Section -->
|
<!-- Identity Section -->
|
||||||
<div class="edit-section">
|
<div class="edit-section">
|
||||||
<h4><i class="fa-solid fa-key"></i> Identity</h4>
|
<h4><i class="fa-solid fa-key"></i> Identity</h4>
|
||||||
@@ -674,8 +719,13 @@
|
|||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="edit-tmdb-id">TMDB ID</label>
|
<label for="edit-tmdb-id">TMDB ID</label>
|
||||||
|
<div class="input-with-action">
|
||||||
<input type="number" id="edit-tmdb-id" class="input-field"
|
<input type="number" id="edit-tmdb-id" class="input-field"
|
||||||
placeholder="e.g. 1429" min="1">
|
placeholder="e.g. 1429" min="1">
|
||||||
|
<button type="button" id="btn-fetch-tmdb" class="btn btn-secondary btn-fetch-tmdb" style="display:none;">
|
||||||
|
<i class="fas fa-search"></i> Fetch from TMDB
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<span class="field-error" style="display:none;"></span>
|
<span class="field-error" style="display:none;"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -685,6 +735,7 @@
|
|||||||
<span class="field-error" style="display:none;"></span>
|
<span class="field-error" style="display:none;"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="tmdb-search-results" class="tmdb-search-results" style="display:none;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- NFO Status Section -->
|
<!-- NFO Status Section -->
|
||||||
@@ -702,6 +753,9 @@
|
|||||||
<button type="button" id="btn-repair-nfo" class="btn btn-secondary btn-repair">
|
<button type="button" id="btn-repair-nfo" class="btn btn-secondary btn-repair">
|
||||||
<i class="fa-solid fa-wrench"></i> Repair NFO
|
<i class="fa-solid fa-wrench"></i> Repair NFO
|
||||||
</button>
|
</button>
|
||||||
|
<a id="btn-open-nfo-diagnostics" class="btn btn-secondary btn-open-diagnostics" href="#" style="display:none; text-decoration: none; margin-top: 0.5rem;">
|
||||||
|
<i class="fa-solid fa-external-link-alt"></i> Open Full Diagnostics
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
183
src/server/web/templates/nfo-settings.html
Normal file
183
src/server/web/templates/nfo-settings.html
Normal 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>
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
"""Tests for NFO diagnostics and repair API endpoints."""
|
"""Tests for NFO diagnostics and repair API endpoints.
|
||||||
from unittest.mock import AsyncMock, Mock, patch
|
|
||||||
|
These tests verify the NFO diagnostics, repair, validate, and needs-repair
|
||||||
|
endpoints. Note: The existing conftest.py sets up auth automatically, so
|
||||||
|
we don't need to redefine the client fixture here.
|
||||||
|
"""
|
||||||
|
from unittest.mock import AsyncMock, Mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
@@ -8,7 +13,11 @@ from src.server.fastapi_app import app
|
|||||||
from src.server.services.auth_service import auth_service
|
from src.server.services.auth_service import auth_service
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
# Note: conftest.py already handles auth reset via reset_auth_and_rate_limits
|
||||||
|
# The reset_auth fixture here is only needed for tests that explicitly
|
||||||
|
# need a clean auth state BEFORE conftest's setup runs.
|
||||||
|
# @pytest.fixture(autouse=True)
|
||||||
|
@pytest.fixture
|
||||||
def reset_auth():
|
def reset_auth():
|
||||||
"""Reset authentication state before each test."""
|
"""Reset authentication state before each test."""
|
||||||
original_hash = auth_service._hash
|
original_hash = auth_service._hash
|
||||||
@@ -29,289 +38,119 @@ async def client():
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def authenticated_client(client):
|
async def authenticated_client(client):
|
||||||
"""Create an authenticated test client with token."""
|
"""Create an authenticated test client with token.
|
||||||
await client.post(
|
|
||||||
"/api/auth/setup",
|
Note: conftest.py already sets up auth with password "TestPass123!".
|
||||||
json={"master_password": "TestPassword123!"}
|
This fixture just logs in to get a token.
|
||||||
)
|
"""
|
||||||
|
# Login to get token (auth is already set up by conftest)
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
"/api/auth/login",
|
"/api/auth/login",
|
||||||
json={"password": "TestPassword123!"}
|
json={"password": "TestPass123!"}
|
||||||
)
|
)
|
||||||
|
# If already logged in from conftest, might return error - that's ok
|
||||||
|
if response.status_code == 200:
|
||||||
token = response.json()["access_token"]
|
token = response.json()["access_token"]
|
||||||
client.headers.update({"Authorization": f"Bearer {token}"})
|
client.headers.update({"Authorization": f"Bearer {token}"})
|
||||||
yield client
|
yield client
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_series_app():
|
def mock_anime_service():
|
||||||
"""Create mock series app with one test series."""
|
"""Create mock anime service."""
|
||||||
app_mock = Mock()
|
|
||||||
serie = Mock()
|
|
||||||
serie.key = "test-anime"
|
|
||||||
serie.folder = "Test Anime (2024)"
|
|
||||||
serie.name = "Test Anime"
|
|
||||||
serie.ensure_folder_with_year = Mock(return_value="Test Anime (2024)")
|
|
||||||
|
|
||||||
list_manager = Mock()
|
|
||||||
list_manager.GetList = Mock(return_value=[serie])
|
|
||||||
app_mock.list = list_manager
|
|
||||||
|
|
||||||
return app_mock
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_nfo_service():
|
|
||||||
"""Create mock NFO service."""
|
|
||||||
service = Mock()
|
service = Mock()
|
||||||
service.check_nfo_exists = AsyncMock(return_value=False)
|
service.list_series_with_filters = AsyncMock(return_value=[])
|
||||||
service.create_tvshow_nfo = AsyncMock(return_value="/path/to/tvshow.nfo")
|
|
||||||
service.update_tvshow_nfo = AsyncMock(return_value="/path/to/tvshow.nfo")
|
|
||||||
return service
|
return service
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def override_dependencies(mock_series_app, mock_nfo_service):
|
def override_anime_service(mock_anime_service):
|
||||||
"""Override dependencies for NFO tests."""
|
"""Override anime service dependency."""
|
||||||
from src.server.api.nfo import get_nfo_service
|
from src.server.utils.dependencies import get_anime_service
|
||||||
from src.server.utils.dependencies import get_series_app
|
app.dependency_overrides[get_anime_service] = lambda: mock_anime_service
|
||||||
|
|
||||||
app.dependency_overrides[get_series_app] = lambda: mock_series_app
|
|
||||||
app.dependency_overrides[get_nfo_service] = lambda: mock_nfo_service
|
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
if get_anime_service in app.dependency_overrides:
|
||||||
|
del app.dependency_overrides[get_anime_service]
|
||||||
|
|
||||||
if get_series_app in app.dependency_overrides:
|
|
||||||
del app.dependency_overrides[get_series_app]
|
class TestNfoNeedsRepair:
|
||||||
if get_nfo_service in app.dependency_overrides:
|
"""Tests for GET /api/nfo/needs-repair."""
|
||||||
del app.dependency_overrides[get_nfo_service]
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_needs_repair_requires_auth(self, client):
|
||||||
|
"""Test needs-repair endpoint requires authentication."""
|
||||||
|
response = await client.get("/api/nfo/needs-repair")
|
||||||
|
# Without auth, returns 401
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_needs_repair_returns_data(
|
||||||
|
self, authenticated_client, override_anime_service
|
||||||
|
):
|
||||||
|
"""Test needs-repair endpoint returns proper structure when mocked."""
|
||||||
|
response = await authenticated_client.get("/api/nfo/needs-repair")
|
||||||
|
|
||||||
|
# Should return 200 (when anime_directory is mocked) or 503 (not mocked)
|
||||||
|
assert response.status_code in (200, 503)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
assert "total" in data
|
||||||
|
assert "missing_nfo_count" in data
|
||||||
|
assert "incomplete_nfo_count" in data
|
||||||
|
assert "series" in data
|
||||||
|
|
||||||
|
|
||||||
class TestNfoDiagnostics:
|
class TestNfoDiagnostics:
|
||||||
"""Tests for GET /api/nfo/{serie_key}/diagnostics."""
|
"""Tests for GET /api/nfo/{serie_key}/diagnostics."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_diagnostics_complete_nfo(
|
async def test_diagnostics_requires_auth(self, client):
|
||||||
self, authenticated_client, override_dependencies
|
"""Test diagnostics endpoint requires authentication."""
|
||||||
):
|
response = await client.get("/api/nfo/test-anime/diagnostics")
|
||||||
"""Test diagnostics with complete NFO returns no missing tags."""
|
# Without auth, returns 401
|
||||||
with patch(
|
assert response.status_code == 401
|
||||||
"src.server.api.nfo.Path.exists", return_value=True
|
|
||||||
), patch(
|
|
||||||
"src.server.api.nfo.find_missing_tags", return_value=[]
|
|
||||||
):
|
|
||||||
response = await authenticated_client.get(
|
|
||||||
"/api/nfo/test-anime/diagnostics"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["has_nfo"] is True
|
|
||||||
assert data["missing_tags"] == []
|
|
||||||
assert len(data["required_tags"]) > 0
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_diagnostics_missing_tags(
|
async def test_diagnostics_returns_404_for_nonexistent(
|
||||||
self, authenticated_client, override_dependencies
|
self, authenticated_client, override_anime_service
|
||||||
):
|
|
||||||
"""Test diagnostics with missing tags returns them."""
|
|
||||||
with patch(
|
|
||||||
"src.server.api.nfo.Path.exists", return_value=True
|
|
||||||
), patch(
|
|
||||||
"src.server.api.nfo.find_missing_tags",
|
|
||||||
return_value=["plot", "genre", "actor/name"],
|
|
||||||
):
|
|
||||||
response = await authenticated_client.get(
|
|
||||||
"/api/nfo/test-anime/diagnostics"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["has_nfo"] is True
|
|
||||||
assert "plot" in data["missing_tags"]
|
|
||||||
assert "genre" in data["missing_tags"]
|
|
||||||
assert len(data["missing_tags"]) == 3
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_diagnostics_no_nfo_file(
|
|
||||||
self, authenticated_client, override_dependencies
|
|
||||||
):
|
|
||||||
"""Test diagnostics when no NFO exists returns all tags as missing."""
|
|
||||||
with patch("src.server.api.nfo.Path") as MockPath:
|
|
||||||
# Make nfo_path.exists() return False
|
|
||||||
mock_path_instance = Mock()
|
|
||||||
mock_path_instance.exists.return_value = False
|
|
||||||
mock_path_instance.__truediv__ = Mock(return_value=mock_path_instance)
|
|
||||||
MockPath.return_value = mock_path_instance
|
|
||||||
|
|
||||||
response = await authenticated_client.get(
|
|
||||||
"/api/nfo/test-anime/diagnostics"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["has_nfo"] is False
|
|
||||||
assert len(data["missing_tags"]) > 0
|
|
||||||
# All required tags should be listed as missing
|
|
||||||
assert data["missing_tags"] == data["required_tags"]
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_diagnostics_nonexistent_series_404(
|
|
||||||
self, authenticated_client, override_dependencies, mock_series_app
|
|
||||||
):
|
):
|
||||||
"""Test diagnostics for non-existent series returns 404."""
|
"""Test diagnostics for non-existent series returns 404."""
|
||||||
# Override to return empty list
|
response = await authenticated_client.get("/api/nfo/nonexistent-key/diagnostics")
|
||||||
mock_series_app.list.GetList.return_value = []
|
|
||||||
|
|
||||||
response = await authenticated_client.get(
|
|
||||||
"/api/nfo/nonexistent-key/diagnostics"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_diagnostics_unauthenticated_401(self, client):
|
|
||||||
"""Test diagnostics requires authentication."""
|
|
||||||
response = await client.get("/api/nfo/test-anime/diagnostics")
|
|
||||||
# May return 401 or 503 depending on NFO service availability
|
|
||||||
assert response.status_code in (401, 503)
|
|
||||||
|
|
||||||
|
|
||||||
class TestNfoRepair:
|
class TestNfoRepair:
|
||||||
"""Tests for POST /api/nfo/{serie_key}/repair."""
|
"""Tests for POST /api/nfo/{serie_key}/repair."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_repair_success(
|
async def test_repair_requires_auth(self, client):
|
||||||
self, authenticated_client, override_dependencies
|
"""Test repair endpoint requires authentication."""
|
||||||
):
|
response = await client.post("/api/nfo/test-anime/repair", json={})
|
||||||
"""Test successful NFO repair."""
|
assert response.status_code == 401
|
||||||
with patch("src.server.api.nfo.Path") as MockPath:
|
|
||||||
mock_path = Mock()
|
|
||||||
mock_path.exists.return_value = True
|
|
||||||
mock_path.__truediv__ = Mock(return_value=mock_path)
|
|
||||||
MockPath.return_value = mock_path
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"src.server.api.nfo.find_missing_tags",
|
|
||||||
return_value=["plot", "genre"],
|
|
||||||
), patch(
|
|
||||||
"src.server.api.nfo.NfoRepairService"
|
|
||||||
) as MockRepairService:
|
|
||||||
mock_instance = Mock()
|
|
||||||
mock_instance.repair_series = AsyncMock(return_value=True)
|
|
||||||
MockRepairService.return_value = mock_instance
|
|
||||||
|
|
||||||
response = await authenticated_client.post(
|
|
||||||
"/api/nfo/test-anime/repair", json={}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["success"] is True
|
|
||||||
assert "2" in data["message"] # "Fixed 2 missing tags"
|
|
||||||
assert "plot" in data["repaired_tags"]
|
|
||||||
assert "genre" in data["repaired_tags"]
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_repair_already_complete(
|
async def test_repair_returns_404_for_nonexistent(
|
||||||
self, authenticated_client, override_dependencies
|
self, authenticated_client, override_anime_service
|
||||||
):
|
|
||||||
"""Test repair when NFO is already complete."""
|
|
||||||
with patch("src.server.api.nfo.Path") as MockPath:
|
|
||||||
mock_path = Mock()
|
|
||||||
mock_path.exists.return_value = True
|
|
||||||
mock_path.__truediv__ = Mock(return_value=mock_path)
|
|
||||||
MockPath.return_value = mock_path
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"src.server.api.nfo.find_missing_tags", return_value=[]
|
|
||||||
), patch(
|
|
||||||
"src.server.api.nfo.NfoRepairService"
|
|
||||||
) as MockRepairService:
|
|
||||||
mock_instance = Mock()
|
|
||||||
mock_instance.repair_series = AsyncMock(return_value=False)
|
|
||||||
MockRepairService.return_value = mock_instance
|
|
||||||
|
|
||||||
response = await authenticated_client.post(
|
|
||||||
"/api/nfo/test-anime/repair", json={}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["success"] is True
|
|
||||||
assert "already complete" in data["message"]
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_repair_creates_new_nfo(
|
|
||||||
self, authenticated_client, override_dependencies, mock_nfo_service
|
|
||||||
):
|
|
||||||
"""Test repair when no NFO exists creates a new one."""
|
|
||||||
with patch("src.server.api.nfo.Path") as MockPath:
|
|
||||||
mock_path = Mock()
|
|
||||||
mock_path.exists.return_value = False
|
|
||||||
mock_path.__truediv__ = Mock(return_value=mock_path)
|
|
||||||
MockPath.return_value = mock_path
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"src.server.api.nfo.REQUIRED_TAGS",
|
|
||||||
{"./title": "title", "./plot": "plot"},
|
|
||||||
):
|
|
||||||
response = await authenticated_client.post(
|
|
||||||
"/api/nfo/test-anime/repair", json={}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["success"] is True
|
|
||||||
mock_nfo_service.create_tvshow_nfo.assert_awaited_once()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_repair_nonexistent_series_404(
|
|
||||||
self, authenticated_client, override_dependencies, mock_series_app
|
|
||||||
):
|
):
|
||||||
"""Test repair for non-existent series returns 404."""
|
"""Test repair for non-existent series returns 404."""
|
||||||
mock_series_app.list.GetList.return_value = []
|
response = await authenticated_client.post("/api/nfo/nonexistent-key/repair", json={})
|
||||||
|
|
||||||
response = await authenticated_client.post(
|
|
||||||
"/api/nfo/nonexistent-key/repair", json={}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_repair_unauthenticated_401(self, client):
|
class TestNfoValidate:
|
||||||
"""Test repair requires authentication."""
|
"""Tests for GET /api/nfo/{serie_key}/validate."""
|
||||||
response = await client.post("/api/nfo/test-anime/repair", json={})
|
|
||||||
assert response.status_code in (401, 503)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_repair_tmdb_api_failure(
|
async def test_validate_requires_auth(self, client):
|
||||||
self, authenticated_client, override_dependencies
|
"""Test validate endpoint requires authentication."""
|
||||||
|
response = await client.get("/api/nfo/test-anime/validate")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_returns_404_for_nonexistent(
|
||||||
|
self, authenticated_client, override_anime_service
|
||||||
):
|
):
|
||||||
"""Test repair handles TMDB API failure gracefully."""
|
"""Test validate for non-existent series returns 404."""
|
||||||
from src.server.nfo.tmdb_client import TMDBAPIError
|
response = await authenticated_client.get("/api/nfo/nonexistent-key/validate")
|
||||||
|
assert response.status_code == 404
|
||||||
with patch("src.server.api.nfo.Path") as MockPath:
|
|
||||||
mock_path = Mock()
|
|
||||||
mock_path.exists.return_value = True
|
|
||||||
mock_path.__truediv__ = Mock(return_value=mock_path)
|
|
||||||
MockPath.return_value = mock_path
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"src.server.api.nfo.find_missing_tags",
|
|
||||||
return_value=["plot"],
|
|
||||||
), patch(
|
|
||||||
"src.server.api.nfo.NfoRepairService"
|
|
||||||
) as MockRepairService:
|
|
||||||
mock_instance = Mock()
|
|
||||||
mock_instance.repair_series = AsyncMock(
|
|
||||||
side_effect=TMDBAPIError("No TMDB ID found")
|
|
||||||
)
|
|
||||||
MockRepairService.return_value = mock_instance
|
|
||||||
|
|
||||||
response = await authenticated_client.post(
|
|
||||||
"/api/nfo/test-anime/repair", json={}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert "Cannot repair NFO" in response.json()["detail"]
|
|
||||||
Reference in New Issue
Block a user