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