diff --git a/Docs/tasks.md b/Docs/tasks.md
index 0dafa03..e69de29 100644
--- a/Docs/tasks.md
+++ b/Docs/tasks.md
@@ -1,178 +0,0 @@
-# Tasks
-
-## 1. Scheduled Folder Scan
-
-### Task 1.1: Add folder scan scheduler configuration
-
-**Where is that found**
-- `src/server/models/config.py` (`SchedulerConfig`)
-- `data/config.json` (example/default config)
-- `src/server/web/templates/setup.html` (setup UI)
-- `src/server/api/auth.py` (config save endpoint, if it validates scheduler fields)
-
-**Goal. How it should be**
-Add a new boolean field `folder_scan_enabled` (default `false`) to `SchedulerConfig`. When `true`, the scheduler will execute the folder maintenance routine during its scheduled run. Add the field to the setup page as a checkbox. Ensure existing configs without this field load successfully (Pydantic default handles this).
-
-**Possible traps and issues**
-- Backward compatibility: old `data/config.json` files must load without errors. Pydantic defaults solve this, but verify by loading an old config.
-- The setup page JavaScript must include the new field in the payload sent to `/api/config`.
-- Do not confuse this with `auto_download_after_rescan` — this is a separate toggle.
-
-**Docs changes needed**
-- `docs/CONFIGURATION.md`: Document the new `scheduler.folder_scan_enabled` option.
-- `docs/ARCHITECTURE.md`: Mention folder scan in the scheduler section.
-
-**Why this is needed**
-Users need an opt-in toggle to enable automatic daily folder maintenance (NFO repair, folder renaming, poster checks) without forcing it on everyone.
-
----
-
-### Task 1.2: Create FolderScanService skeleton
-
-**Where is that found**
-- New file: `src/server/services/folder_scan_service.py`
-- `src/server/services/scheduler_service.py` (to call it)
-
-**Goal. How it should be**
-Create a new `FolderScanService` class with a single async entry point `async def run_folder_scan(self) -> None`. The method should:
-1. Log start/completion with structlog.
-2. Check prerequisites (`settings.anime_directory` exists, `settings.tmdb_api_key` is set).
-3. Skip gracefully with a warning log if prerequisites are missing.
-4. Use a module-level semaphore (similar to `_NFO_REPAIR_SEMAPHORE`) to limit concurrent TMDB operations to 3.
-
-Keep the implementation empty for the sub-tasks (1.3–1.5) to fill in. Just add the skeleton and the semaphore.
-
-**Possible traps and issues**
-- Circular imports: `folder_scan_service.py` will import from `initialization_service`, `config.settings`, etc. Keep imports inside methods or at the bottom if circular issues arise.
-- The service should follow the singleton pattern like `SchedulerService` and `DownloadService` if it holds state, or be stateless. For simplicity, make it a plain class instantiated per call or a module-level function set.
-- Exception handling: any unhandled exception in the scheduled task should be caught and logged so it doesn't crash the scheduler.
-
-**Docs changes needed**
-- `docs/ARCHITECTURE.md`: Add `folder_scan_service.py` to the services list.
-
-**Why this is needed**
-Encapsulates the new daily maintenance logic in its own module, keeping `scheduler_service.py` clean and allowing the folder scan to be tested independently.
-
----
-
-### Task 1.3: Integrate NFO repair into folder scan
-
-**Where is that found**
-- `src/server/services/folder_scan_service.py`
-- `src/server/services/initialization_service.py` (`perform_nfo_repair_scan`)
-
-**Goal. How it should be**
-Inside `FolderScanService.run_folder_scan()`, call `perform_nfo_repair_scan(background_loader=None)` as the first step. Reuse the existing function exactly — do not copy its logic. Log a message before and after the call.
-
-**Possible traps and issues**
-- `perform_nfo_repair_scan` spawns `asyncio.create_task` for each repair. When called from the scheduler, these background tasks will still run after `run_folder_scan` returns. This is fine, but log that repairs are queued.
-- The function already handles missing `tmdb_api_key` and `anime_directory`, so the caller doesn't need to double-check, but the skeleton from Task 1.2 already checks prerequisites.
-- `perform_nfo_repair_scan` imports `nfo_needs_repair` and `NfoRepairService` inside the function, so no heavy import-time dependencies.
-
-**Docs changes needed**
-- `docs/NFO_GUIDE.md`: Update the "Automatic NFO Repair" section to state that repair now runs as part of the scheduled folder scan instead of every startup.
-
-**Why this is needed**
-Reuses the existing, tested NFO repair logic. Moves NFO repair from startup blocking to scheduled background maintenance.
-
----
-
-### Task 1.4: Validate and rename series folders
-
-**Where is that found**
-- `src/server/services/folder_scan_service.py`
-- `src/core/services/nfo_repair_service.py` (for `parse_nfo_tags` or similar NFO parsing)
-- `src/server/database/models.py` / `src/server/database/system_settings_service.py` (if folder paths are stored in DB)
-
-**Goal. How it should be**
-After NFO repair, iterate over every subfolder in `settings.anime_directory` that contains a `tvshow.nfo`. For each folder:
-1. Parse the NFO to extract `
` and `` text values.
-2. Compute the expected folder name: `f"{title} ({year})"`.
-3. Sanitize the expected name for filesystem safety (remove/replace illegal characters like `/`, `\`, `:`, etc.).
-4. Compare with the current folder name (`series_dir.name`).
-5. If different, rename the folder using `series_dir.rename(expected_path)`.
-6. If the series path is stored in the database (check `anime_service` or DB models), update the database record to point to the new path.
-
-Skip folders where title or year is missing/empty. Log every rename action.
-
-**Possible traps and issues**
-- **Database path consistency**: If `Series` or `Episode` models store absolute or relative paths, renaming the folder on disk without updating the DB will break downloads, NFO updates, and the web UI. Must verify whether paths are stored in the DB and update them.
-- **Active downloads**: A series currently being downloaded should not be renamed. Check the download queue or lock status before renaming. If no lock mechanism exists, this is a major trap — document it.
-- **Filesystem permissions**: The app may not have write permission to the anime directory. Catch `PermissionError` and `OSError` and log gracefully.
-- **Special characters**: Titles like `"A / B"` or `"Show: Subtitle"` contain characters illegal in folder names. Define a sanitization function (e.g., replace `/` with `-`, remove trailing dots on Windows, etc.).
-- **Duplicate names**: Two different series could sanitize to the same name. Check if target path already exists before renaming.
-- **Path length limits**: Very long titles might exceed OS path limits.
-
-**Docs changes needed**
-- `docs/NFO_GUIDE.md`: Add a section "Folder Naming Convention" explaining the ` ()` format.
-- `docs/CONFIGURATION.md`: Mention that enabling folder scan will rename folders.
-
-**Why this is needed**
-Enforces a consistent, predictable folder naming scheme across the library, making it easier for media center apps (Kodi, Jellyfin, Plex) to match metadata.
-
----
-
-### Task 1.5: Check and download missing poster.jpg
-
-**Where is that found**
-- `src/server/services/folder_scan_service.py`
-- `src/core/utils/image_downloader.py` (`ImageDownloader`)
-- `src/core/services/nfo_service.py` or `src/core/services/nfo_repair_service.py` (to get poster URL from NFO or TMDB)
-
-**Goal. How it should be**
-After folder renaming, iterate over series folders again (or combine with Task 1.4 loop). For each folder:
-1. Check if `poster.jpg` exists and has a size ≥ `ImageDownloader.min_file_size` (1 KB by default).
-2. If missing or too small:
- a. Parse `tvshow.nfo` for `` or `` URL.
- b. If no URL in NFO, skip (do not query TMDB again to keep tasks small; the NFO should already have it after repair).
- c. Use `ImageDownloader` (with context manager) to download the image to `series_dir / "poster.jpg"`.
- d. Validate the downloaded image with `ImageDownloader._validate_image` (or similar existing validation).
-3. Use the existing `_NFO_REPAIR_SEMAPHORE` or a new `POSTER_DOWNLOAD_SEMAPHORE` to limit concurrent downloads to 3.
-
-**Possible traps and issues**
-- **TMDB rate limiting**: Even downloading images hits TMDB CDN. The semaphore limits concurrency.
-- **Invalid images**: A download might produce a 0-byte or corrupted file. `ImageDownloader` already validates with PIL; reuse that.
-- **NFO without thumb URL**: If the NFO was created before thumb tags were added, there may be no URL. In that case, skip and log. A future task could query TMDB directly.
-- **Write permissions**: Same as Task 1.4.
-- **Async session sharing**: `ImageDownloader` manages its own `aiohttp` session. Use `async with ImageDownloader() as downloader:` to ensure cleanup.
-
-**Docs changes needed**
-- `docs/NFO_GUIDE.md`: Add "Poster Check" subsection under folder scan.
-- `docs/CONFIGURATION.md`: Mention that `nfo.download_poster` setting also affects scheduled poster checks.
-
-**Why this is needed**
-Ensures every series has artwork, which is required by most media center front-ends for a polished library view.
-
----
-
-## 2. Remove startup NFO repair
-
-### Task 2.1: Remove perform_nfo_repair_scan from startup lifespan
-
-**Where is that found**
-- `src/server/fastapi_app.py` (lifespan startup block, lines ~245 and ~319)
-- `src/server/services/initialization_service.py` (keep the function, just remove the call site)
-- `tests/integration/test_nfo_repair_startup.py`
-- `tests/unit/test_initialization_service.py` (tests that call `perform_nfo_repair_scan` directly can stay, but integration tests verifying startup wiring must change)
-
-**Goal. How it should be**
-1. In `src/server/fastapi_app.py`, remove the import of `perform_nfo_repair_scan` from the `initialization_service` import block.
-2. Remove the line `await perform_nfo_repair_scan(background_loader)` from the lifespan startup sequence.
-3. Update `tests/integration/test_nfo_repair_startup.py`:
- - Remove or modify `test_perform_nfo_repair_scan_imported_in_lifespan` and `test_perform_nfo_repair_scan_called_after_media_scan` since the startup wiring is gone.
- - Replace with a test that verifies `perform_nfo_repair_scan` is NOT called during startup (or simply delete the file if it has no other purpose).
-4. `tests/unit/test_initialization_service.py` tests for `perform_nfo_repair_scan` can remain because they test the function itself, not the startup wiring.
-
-**Possible traps and issues**
-- **Test failures**: `test_nfo_repair_startup.py` will fail immediately after the code change. It must be updated in the same PR.
-- **Documentation drift**: `docs/NFO_GUIDE.md`, `docs/CHANGELOG.md`, and `docs/ARCHITECTURE.md` all describe the startup NFO repair behavior. If docs are not updated, users will expect repair on every start.
-- **Background loader parameter**: The `background_loader` variable was created partly for `perform_nfo_repair_scan`. After removal, check if `background_loader` is still needed for other startup steps (yes — `perform_media_scan_if_needed` uses it). Do not remove `background_loader` entirely.
-- **Import cleanup**: Ensure no unused imports remain in `fastapi_app.py` after removal.
-
-**Docs changes needed**
-- `docs/NFO_GUIDE.md`: Update section 11 "Automatic NFO Repair" to remove startup references and state it runs via scheduler.
-- `docs/CHANGELOG.md`: Add an entry under "Changed" or "Removed" noting that startup NFO repair is replaced by scheduled folder scan.
-- `docs/ARCHITECTURE.md`: Update the startup sequence description.
-
-**Why this is needed**
-Running `perform_nfo_repair_scan` on every startup slows down server restarts, especially for large libraries. Moving it to a scheduled task keeps startup fast while still ensuring regular maintenance.
diff --git a/src/server/api/nfo.py b/src/server/api/nfo.py
index 23b187d..3ef76ad 100644
--- a/src/server/api/nfo.py
+++ b/src/server/api/nfo.py
@@ -1,22 +1,25 @@
"""NFO Management API endpoints.
-Provides endpoints for:
-- Diagnostics: Check NFO status and missing tags
-- Repair: Create/update NFO files for series
-- Validation: Quick NFO XML validity check
-- Needs-repair: List all series that need NFO attention
+Provides endpoints for NFO diagnostics, repair, and validation for anime series.
"""
+import logging
import os
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
-from pydantic import BaseModel, Field
+from pydantic import BaseModel
from src.config.settings import settings
-from src.server.models.nfo import NfoDiagnosticsResponse, NfoRepairResponse
-from src.server.services.anime_service import AnimeService, AnimeServiceError
+from src.server.models.nfo import (
+ NfoDiagnosticsResponse,
+ NfoRepairResponse,
+ NfoSeriesDiagnostics,
+)
+from src.server.services.anime_service import AnimeService
from src.server.utils.dependencies import get_anime_service, require_auth
+logger = logging.getLogger(__name__)
+
router = APIRouter(prefix="/api/nfo", tags=["nfo"])
# Required tags for a valid Kodi tvshow.nfo
@@ -25,403 +28,365 @@ REQUIRED_TAGS = [
"plot",
"tmdbid",
]
-# All tags we check for completeness
-ALL_TAGS = [
- "title",
- "originaltitle",
- "showtitle",
- "sorttitle",
+OPTIONAL_TAGS = [
"year",
- "plot",
+ "premiered",
+ "genre",
+ "studio",
+ "rating",
+ "mpaa",
"outline",
"tagline",
"runtime",
- "mpaa",
- "premiered",
"status",
- "studio",
- "genre",
- "tmdbid",
- "imdbid",
+ "id",
+ "imdb_id",
"tvdbid",
- "userrating",
- "trailer",
+ "imdbid",
+ "uniqueid",
"thumb",
"fanart",
"actor",
+ "trailer",
]
-class NfoSeriesItem(BaseModel):
- """Series with NFO status for listing endpoints."""
- key: str = Field(..., description="Series unique key")
- name: str = Field(..., description="Series display name")
- folder: str = Field(..., description="Series folder name")
- has_nfo: bool = Field(..., description="Whether NFO file exists")
- nfo_path: Optional[str] = Field(None, description="Path to NFO file if exists")
- missing_tags: List[str] = Field(default_factory=list, description="Missing tags if NFO exists")
-
-
-class NfoNeedsRepairResponse(BaseModel):
- """Response listing all series that need NFO attention."""
- total: int = Field(..., description="Total number of series")
- missing_nfo_count: int = Field(..., description="Series without any NFO file")
- incomplete_nfo_count: int = Field(..., description="Series with NFO but missing tags")
- series: List[NfoSeriesItem] = Field(..., description="List of series needing attention")
-
-
class NfoValidateResponse(BaseModel):
- """Response for NFO validation check."""
- valid: bool = Field(..., description="Whether NFO XML is valid")
- error: Optional[str] = Field(None, description="Error message if invalid")
+ """Response for NFO XML validation."""
+
+ valid: bool
+ error: Optional[str] = None
-def _parse_nfo_tags(nfo_path: str) -> dict[str, str]:
- """Parse NFO file and return tag values.
-
- Returns dict of tag name -> tag text content.
- Empty tags (e.g., ) are returned as empty string.
- """
- try:
- from lxml import etree
- except ImportError:
- return {}
-
- try:
- tree = etree.parse(nfo_path)
- root = tree.getroot()
-
- tags = {}
- for elem in root.iter():
- tag_name = elem.tag.lower()
- if tag_name not in tags:
- # Get text content, None if element has no text
- text = elem.text.strip() if elem.text else ""
- tags[tag_name] = text
- return tags
- except Exception:
- return {}
+class NfoNeedsRepairListResponse(BaseModel):
+ """Response listing series that need NFO repair."""
+
+ total: int
+ series: List[NfoSeriesDiagnostics]
-def _check_missing_tags(tags: dict[str, str]) -> List[str]:
- """Check which required tags are missing or empty."""
- missing = []
- for tag in REQUIRED_TAGS:
- value = tags.get(tag, "").strip()
- if not value:
- missing.append(tag)
- return missing
-
-
-@router.get("/needs-repair", response_model=NfoNeedsRepairResponse)
-async def get_series_needing_repair(
- _auth: dict = Depends(require_auth),
- anime_service: AnimeService = Depends(get_anime_service),
-) -> NfoNeedsRepairResponse:
- """Get all series that need NFO repair (missing file or missing tags).
-
- Returns series grouped by:
- - Missing NFO file entirely
- - NFO file exists but missing required tags
- """
- try:
- all_series = await anime_service.list_series_with_filters()
- except AnimeServiceError as exc:
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail=f"Failed to fetch series list: {exc}",
- )
-
+def _get_nfo_path(folder: str) -> str:
+ """Get the full path to a series' tvshow.nfo file."""
anime_dir = getattr(settings, "anime_directory", None)
if not anime_dir:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Anime directory not configured",
)
-
- missing_nfo_list: List[NfoSeriesItem] = []
- incomplete_list: List[NfoSeriesItem] = []
-
+ return os.path.join(anime_dir, folder, "tvshow.nfo")
+
+
+def _parse_nfo_file(nfo_path: str) -> tuple[Optional[Any], List[str]]:
+ """Parse an NFO file and return (xml_root, missing_tags).
+
+ Returns tuple of (xml_root element or None, list of missing required tags).
+ If file cannot be read/parsed, returns (None, all_required_tags).
+ """
+ from lxml import etree
+
+ missing: List[str] = []
+
+ if not os.path.isfile(nfo_path):
+ return None, REQUIRED_TAGS.copy()
+
+ try:
+ tree = etree.parse(nfo_path)
+ root = tree.getroot()
+ except Exception as exc:
+ logger.warning("Failed to parse NFO file %s: %s", nfo_path, exc)
+ return None, REQUIRED_TAGS.copy()
+
+ # Check for required tags
+ for tag in REQUIRED_TAGS:
+ elements = root.findall(tag)
+ # Check if tag exists and has non-empty text
+ found = False
+ for elem in elements:
+ if elem.text and elem.text.strip():
+ found = True
+ break
+ if not found:
+ missing.append(tag)
+
+ return root, missing
+
+
+async def _get_series_data(
+ anime_service: AnimeService, key: str
+) -> Optional[dict]:
+ """Get series data by key from anime_service."""
+ # Get all series and find by key
+ all_series = await anime_service.list_series_with_filters()
for series in all_series:
- key = series.get("key", "")
- name = series.get("name", "")
- folder = series.get("folder", "")
-
- if not folder:
- continue
-
- nfo_path = os.path.join(anime_dir, folder, "tvshow.nfo")
-
- if not os.path.isfile(nfo_path):
- missing_nfo_list.append(NfoSeriesItem(
- key=key,
- name=name,
- folder=folder,
- has_nfo=False,
- nfo_path=None,
- missing_tags=REQUIRED_TAGS.copy(),
- ))
- else:
- # Check for missing tags
- tags = _parse_nfo_tags(nfo_path)
- missing = _check_missing_tags(tags)
- if missing:
- incomplete_list.append(NfoSeriesItem(
- key=key,
- name=name,
- folder=folder,
- has_nfo=True,
- nfo_path=nfo_path,
- missing_tags=missing,
- ))
-
- all_needing_repair = missing_nfo_list + incomplete_list
-
- return NfoNeedsRepairResponse(
- total=len(all_series),
- missing_nfo_count=len(missing_nfo_list),
- incomplete_nfo_count=len(incomplete_list),
- series=all_needing_repair,
- )
+ if series.get("key") == key:
+ return series
+ return None
-@router.get("/{serie_key}/diagnostics", response_model=NfoDiagnosticsResponse)
+@router.get("/{key}/diagnostics", response_model=NfoDiagnosticsResponse)
async def get_nfo_diagnostics(
- serie_key: str,
+ key: str,
_auth: dict = Depends(require_auth),
anime_service: AnimeService = Depends(get_anime_service),
) -> NfoDiagnosticsResponse:
"""Get NFO diagnostics for a specific series.
-
+
+ Checks if tvshow.nfo exists in the series folder and validates
+ that required tags are present.
+
+ Args:
+ key: Series unique key (provider-assigned, URL-safe identifier)
+ _auth: Authentication dependency
+ anime_service: AnimeService dependency
+
Returns:
- - Whether tvshow.nfo exists
- - Path to NFO file if it exists
- - List of missing required tags (if any)
- - List of all tags for reference
+ NfoDiagnosticsResponse with has_nfo, nfo_path, missing_tags, required_tags
+
+ Raises:
+ HTTPException 404: If series not found
+ HTTPException 503: If anime directory not configured
"""
# Get series data
- try:
- all_series = await anime_service.list_series_with_filters()
- series = next((s for s in all_series if s.get("key") == serie_key), None)
- except AnimeServiceError as exc:
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail=f"Failed to fetch series: {exc}",
- )
-
- if not series:
+ series_data = await _get_series_data(anime_service, key)
+ if not series_data:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
- detail=f"Series not found: {serie_key}",
+ detail=f"Series not found: {key}",
)
-
- folder = series.get("folder", "")
+
+ folder = series_data.get("folder", "")
if not folder:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
- detail="Series has no folder assigned",
+ detail=f"Series has no folder configured: {key}",
)
-
- anime_dir = getattr(settings, "anime_directory", None)
- if not anime_dir:
- raise HTTPException(
- status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
- detail="Anime directory not configured",
- )
-
- nfo_path = os.path.join(anime_dir, folder, "tvshow.nfo")
-
- if not os.path.isfile(nfo_path):
+
+ nfo_path = _get_nfo_path(folder)
+ nfo_exists = os.path.isfile(nfo_path)
+
+ if not nfo_exists:
return NfoDiagnosticsResponse(
has_nfo=False,
nfo_path=None,
missing_tags=REQUIRED_TAGS.copy(),
- required_tags=ALL_TAGS.copy(),
+ required_tags=REQUIRED_TAGS.copy(),
)
-
- # Parse NFO and check for missing tags
- tags = _parse_nfo_tags(nfo_path)
- missing = _check_missing_tags(tags)
-
+
+ # Parse and check for missing tags
+ _, missing = _parse_nfo_file(nfo_path)
+
return NfoDiagnosticsResponse(
has_nfo=True,
nfo_path=nfo_path,
missing_tags=missing,
- required_tags=ALL_TAGS.copy(),
+ required_tags=REQUIRED_TAGS.copy(),
)
-@router.post("/{serie_key}/repair", response_model=NfoRepairResponse)
+@router.post("/{key}/repair", response_model=NfoRepairResponse)
async def repair_nfo(
- serie_key: str,
+ key: str,
_auth: dict = Depends(require_auth),
anime_service: AnimeService = Depends(get_anime_service),
) -> NfoRepairResponse:
- """Repair (create or update) NFO file for a series.
-
- If NFO doesn't exist, creates it from TMDB metadata.
- If NFO exists but has missing tags, updates it with TMDB data.
- If no TMDB ID is available, attempts to look up by series name.
+ """Repair NFO for a specific series.
+
+ Creates or updates the tvshow.nfo file using TMDB metadata.
+
+ Args:
+ key: Series unique key
+ _auth: Authentication dependency
+ anime_service: AnimeService dependency
+
+ Returns:
+ NfoRepairResponse with success status, message, and repaired_tags
+
+ Raises:
+ HTTPException 404: If series not found
+ HTTPException 400: If no TMDB ID available and cannot lookup by name
+ HTTPException 503: If anime directory not configured
"""
# Get series data
- try:
- all_series = await anime_service.list_series_with_filters()
- series = next((s for s in all_series if s.get("key") == serie_key), None)
- except AnimeServiceError as exc:
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail=f"Failed to fetch series: {exc}",
- )
-
- if not series:
+ series_data = await _get_series_data(anime_service, key)
+ if not series_data:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
- detail=f"Series not found: {serie_key}",
+ detail=f"Series not found: {key}",
)
-
- folder = series.get("folder", "")
+
+ folder = series_data.get("folder", "")
if not folder:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
- detail="Series has no folder assigned",
+ detail=f"Series has no folder configured: {key}",
)
-
+
+ tmdb_id = series_data.get("tmdb_id")
+ name = series_data.get("name", "")
+
+ if not tmdb_id:
+ logger.info("No TMDB ID for %s, attempting lookup by name: %s", key, name)
+ # Try to lookup TMDB ID by series name
+ tmdb_id = await _lookup_tmdb_id_by_name(anime_service, name)
+ if not tmdb_id:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=f"No TMDB ID available for {key} and could not find match for '{name}'",
+ )
+
+ # Fetch TMDB data and create NFO
+ try:
+ repaired_tags = await _create_or_update_nfo(
+ key=key,
+ folder=folder,
+ tmdb_id=tmdb_id,
+ series_data=series_data,
+ )
+ except Exception as exc:
+ logger.error("Failed to repair NFO for %s: %s", key, exc)
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail=f"Failed to repair NFO: {str(exc)}",
+ )
+
+ if repaired_tags:
+ return NfoRepairResponse(
+ success=True,
+ message=f"NFO repaired successfully. {len(repaired_tags)} tags updated.",
+ repaired_tags=repaired_tags,
+ )
+ else:
+ return NfoRepairResponse(
+ success=True,
+ message="NFO is already complete, no changes needed.",
+ repaired_tags=[],
+ )
+
+
+async def _lookup_tmdb_id_by_name(anime_service: AnimeService, name: str) -> Optional[int]:
+ """Try to lookup a TMDB ID by series name using TMDB provider."""
+ try:
+ from src.server.providers.tmdb_provider import TMDBProvider
+
+ provider = TMDBProvider()
+ results = await provider.search(name)
+ if results:
+ return results[0].get("tmdb_id")
+ except Exception as exc:
+ logger.warning("TMDB lookup failed for %s: %s", name, exc)
+ return None
+
+
+async def _create_or_update_nfo(
+ key: str,
+ folder: str,
+ tmdb_id: int,
+ series_data: dict,
+) -> List[str]:
+ """Create or update NFO file for a series.
+
+ Returns list of tags that were repaired/added.
+ """
+ from src.server.providers.tmdb_provider import TMDBProvider
+
+ from src.server.nfo.nfo_generator import generate_tvshow_nfo
+ from src.server.nfo.nfo_mapper import tmdb_to_nfo_model
+
anime_dir = getattr(settings, "anime_directory", None)
if not anime_dir:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Anime directory not configured",
)
-
- nfo_path = os.path.join(anime_dir, folder, "tvshow.nfo")
- tmdb_id = series.get("tmdb_id")
-
- # If no TMDB ID, try to look up by name
- if not tmdb_id:
- name = series.get("name", "")
- tmdb_id = await _lookup_tmdb_id_by_name(name)
- if tmdb_id:
- # Update series with found TMDB ID (best effort)
- try:
- await anime_service.update_series_key(serie_key, {"tmdb_id": tmdb_id})
- except Exception:
- pass
-
- if not tmdb_id:
- return NfoRepairResponse(
- success=False,
- message=f"Cannot repair NFO for {serie_key}: no TMDB ID available and could not find match by name",
- repaired_tags=[],
- )
-
- # Fetch TMDB data and generate NFO
- tmdb_data = await _fetch_tmdb_data(tmdb_id)
+
+ series_path = os.path.join(anime_dir, folder)
+ nfo_path = os.path.join(series_path, "tvshow.nfo")
+
+ # Fetch TMDB data
+ provider = TMDBProvider()
+ tmdb_data = await provider.get_series_info(tmdb_id)
if not tmdb_data:
- return NfoRepairResponse(
- success=False,
- message=f"No TMDB data found for TMDB ID {tmdb_id}",
- repaired_tags=[],
- )
-
- # Generate NFO XML
- from src.server.nfo.nfo_generator import generate_tvshow_nfo
- from src.server.nfo.nfo_mapper import tmdb_to_nfo_model
-
+ raise Exception(f"No TMDB data returned for TMDB ID {tmdb_id}")
+
+ # Convert to NFO model
nfo_model = tmdb_to_nfo_model(
tmdb_data,
content_ratings=None,
- get_image_url=lambda path: f"https://image.tmdb.org/t/p/original{path}" if path else None,
+ get_image_url=provider.get_image_url,
image_size="original",
)
-
+
+ # Generate XML
xml_content = generate_tvshow_nfo(nfo_model)
-
+
# Ensure directory exists
- os.makedirs(os.path.dirname(nfo_path), exist_ok=True)
-
- # Track which tags were missing before
- had_nfo = os.path.isfile(nfo_path)
- if had_nfo:
- old_tags = _parse_nfo_tags(nfo_path)
- old_missing = _check_missing_tags(old_tags)
- else:
- old_missing = REQUIRED_TAGS.copy()
-
+ os.makedirs(series_path, exist_ok=True)
+
+ # Check existing NFO for missing tags before overwriting
+ _, missing_before = _parse_nfo_file(nfo_path)
+
# Write NFO file
with open(nfo_path, "w", encoding="utf-8") as f:
f.write(xml_content)
-
- # Update series NFO flag in database (best effort)
- await _update_series_nfo_flag(anime_service, serie_key, True, nfo_path)
-
- # Calculate repaired tags
- new_tags = _parse_nfo_tags(nfo_path)
- new_missing = _check_missing_tags(new_tags)
- repaired_tags = [tag for tag in old_missing if tag not in new_missing]
-
- action = "created" if not had_nfo else "updated"
- return NfoRepairResponse(
- success=True,
- message=f"NFO {action} successfully for {serie_key}",
- repaired_tags=repaired_tags,
+
+ logger.info("Created/updated tvshow.nfo for %s at %s", key, nfo_path)
+
+ # Update series NFO status in DB
+ await anime_service.update_series_nfo_status(
+ key=key,
+ has_nfo=True,
+ nfo_path=nfo_path,
)
+ # Return list of repaired tags (all tags that were missing before)
+ return missing_before
-@router.get("/{serie_key}/validate", response_model=NfoValidateResponse)
+
+@router.get("/{key}/validate", response_model=NfoValidateResponse)
async def validate_nfo(
- serie_key: str,
+ key: str,
_auth: dict = Depends(require_auth),
anime_service: AnimeService = Depends(get_anime_service),
) -> NfoValidateResponse:
- """Quick check if NFO XML is valid.
-
- Does NOT check for missing tags, only validates XML structure.
+ """Validate NFO XML structure for a series.
+
+ Checks if the tvshow.nfo file is valid XML.
+
+ Args:
+ key: Series unique key
+ _auth: Authentication dependency
+ anime_service: AnimeService dependency
+
+ Returns:
+ NfoValidateResponse with valid=True/False and optional error message
"""
- # Get series data
- try:
- all_series = await anime_service.list_series_with_filters()
- series = next((s for s in all_series if s.get("key") == serie_key), None)
- except AnimeServiceError as exc:
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail=f"Failed to fetch series: {exc}",
- )
-
- if not series:
+ series_data = await _get_series_data(anime_service, key)
+ if not series_data:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
- detail=f"Series not found: {serie_key}",
+ detail=f"Series not found: {key}",
)
-
- folder = series.get("folder", "")
+
+ folder = series_data.get("folder", "")
if not folder:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
- detail="Series has no folder assigned",
+ detail=f"Series has no folder configured: {key}",
)
-
- anime_dir = getattr(settings, "anime_directory", None)
- if not anime_dir:
- raise HTTPException(
- status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
- detail="Anime directory not configured",
- )
-
- nfo_path = os.path.join(anime_dir, folder, "tvshow.nfo")
-
+
+ nfo_path = _get_nfo_path(folder)
+
if not os.path.isfile(nfo_path):
return NfoValidateResponse(
valid=False,
- error="NFO file does not exist",
+ error="No NFO file found",
)
-
+
try:
from lxml import etree
+
etree.parse(nfo_path)
- return NfoValidateResponse(
- valid=True,
- error=None,
- )
+ return NfoValidateResponse(valid=True)
except Exception as exc:
return NfoValidateResponse(
valid=False,
@@ -429,57 +394,128 @@ async def validate_nfo(
)
-# ---- Helper functions ----
+@router.get("/needs-repair", response_model=NfoNeedsRepairListResponse)
+async def get_series_needing_repair(
+ _auth: dict = Depends(require_auth),
+ anime_service: AnimeService = Depends(get_anime_service),
+) -> NfoNeedsRepairListResponse:
+ """Get list of all series that need NFO repair.
-async def _lookup_tmdb_id_by_name(name: str) -> Optional[int]:
- """Look up TMDB ID by series name using TMDB search."""
- try:
- from src.server.providers.tmdb_provider import TMDBProvider
- provider = TMDBProvider()
- results = await provider.search(name)
- if results and len(results) > 0:
- return results[0].get("tmdb_id")
- except Exception:
- pass
- return None
+ Returns series that either have no NFO file or have missing required tags.
+
+ Args:
+ _auth: Authentication dependency
+ anime_service: AnimeService dependency
+
+ Returns:
+ NfoNeedsRepairListResponse with total count and list of series diagnostics
+ """
+ all_series = await anime_service.list_series_with_filters()
+ series_needing_repair: List[NfoSeriesDiagnostics] = []
+
+ anime_dir = getattr(settings, "anime_directory", None)
+ if not anime_dir:
+ raise HTTPException(
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
+ detail="Anime directory not configured",
+ )
+
+ for series in all_series:
+ key = series.get("key", "")
+ folder = series.get("folder", "")
+ name = series.get("name", "")
+
+ if not folder:
+ continue
+
+ nfo_path = os.path.join(anime_dir, folder, "tvshow.nfo")
+ nfo_exists = os.path.isfile(nfo_path)
+
+ if not nfo_exists:
+ series_needing_repair.append(NfoSeriesDiagnostics(
+ key=key,
+ name=name,
+ folder=folder,
+ has_nfo=False,
+ missing_tags=REQUIRED_TAGS.copy(),
+ ))
+ continue
+
+ # Parse and check for missing tags
+ _, missing = _parse_nfo_file(nfo_path)
+ if missing:
+ series_needing_repair.append(NfoSeriesDiagnostics(
+ key=key,
+ name=name,
+ folder=folder,
+ has_nfo=True,
+ missing_tags=missing,
+ ))
+
+ return NfoNeedsRepairListResponse(
+ total=len(series_needing_repair),
+ series=series_needing_repair,
+ )
-async def _fetch_tmdb_data(tmdb_id: int) -> Optional[dict]:
- """Fetch TV show data from TMDB."""
- try:
- from src.server.providers.tmdb_provider import TMDBProvider
- provider = TMDBProvider()
- return await provider.get_tvshow(tmdb_id)
- except Exception:
- return None
+@router.post("/batch/repair")
+async def batch_repair_nfo(
+ keys: List[str],
+ _auth: dict = Depends(require_auth),
+ anime_service: AnimeService = Depends(get_anime_service),
+) -> dict:
+ """Repair NFO for multiple series at once.
+ Args:
+ keys: List of series keys to repair
+ _auth: Authentication dependency
+ anime_service: AnimeService dependency
-async def _update_series_nfo_flag(
- anime_service: AnimeService,
- key: str,
- has_nfo: bool,
- nfo_path: str,
-) -> None:
- """Update NFO flag in database for a series."""
- try:
- # Use the anime service to update the series
- await anime_service.update_series_key(key, {"has_nfo": has_nfo})
- except Exception:
- # Fallback: try direct database update
+ Returns:
+ Summary dict with success count, failure count, and errors
+ """
+ results = {
+ "total": len(keys),
+ "success": 0,
+ "failed": 0,
+ "errors": [],
+ }
+
+ for key in keys:
try:
- from datetime import datetime, timezone
+ # Get series data
+ series_data = await _get_series_data(anime_service, key)
+ if not series_data:
+ results["failed"] += 1
+ results["errors"].append(f"{key}: Series not found")
+ continue
- from src.server.database.connection import get_db_session
- from src.server.database.service import AnimeSeriesService
-
- async with get_db_session() as db:
- series_record = await AnimeSeriesService.get_by_key(db, key)
- if series_record:
- series_record.has_nfo = has_nfo
- if has_nfo:
- series_record.nfo_updated_at = datetime.now(timezone.utc)
- if not series_record.nfo_created_at:
- series_record.nfo_created_at = datetime.now(timezone.utc)
- await db.commit()
- except Exception:
- pass
\ No newline at end of file
+ folder = series_data.get("folder", "")
+ if not folder:
+ results["failed"] += 1
+ results["errors"].append(f"{key}: No folder configured")
+ continue
+
+ tmdb_id = series_data.get("tmdb_id")
+ name = series_data.get("name", "")
+
+ if not tmdb_id:
+ tmdb_id = await _lookup_tmdb_id_by_name(anime_service, name)
+ if not tmdb_id:
+ results["failed"] += 1
+ results["errors"].append(f"{key}: No TMDB ID and lookup failed")
+ continue
+
+ await _create_or_update_nfo(
+ key=key,
+ folder=folder,
+ tmdb_id=tmdb_id,
+ series_data=series_data,
+ )
+ results["success"] += 1
+
+ except Exception as exc:
+ results["failed"] += 1
+ results["errors"].append(f"{key}: {str(exc)}")
+
+ return results
diff --git a/src/server/models/nfo.py b/src/server/models/nfo.py
index 0839415..c3b48c1 100644
--- a/src/server/models/nfo.py
+++ b/src/server/models/nfo.py
@@ -372,6 +372,20 @@ class NfoDiagnosticsResponse(BaseModel):
)
+class NfoSeriesDiagnostics(BaseModel):
+ """Diagnostics for a single series in the needs-repair list."""
+
+ key: str = Field(..., description="Series unique key")
+ name: str = Field(..., description="Series display name")
+ folder: str = Field(..., description="Series folder name")
+ has_nfo: bool = Field(..., description="Whether tvshow.nfo exists")
+ missing_tags: List[str] = Field(
+ default_factory=list,
+ description="List of missing required tag names"
+ )
+ tmdb_id: Optional[int] = Field(None, description="TMDB ID if available")
+
+
class NfoRepairResponse(BaseModel):
"""Response after NFO repair attempt."""
diff --git a/src/server/web/static/css/components/modals.css b/src/server/web/static/css/components/modals.css
index 071ca62..79c0a1d 100644
--- a/src/server/web/static/css/components/modals.css
+++ b/src/server/web/static/css/components/modals.css
@@ -360,7 +360,21 @@
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
- gap: var(--spacing-md);
+ gap: var(--spacing-lg);
+}
+
+/* Edit modal specific input sizing */
+.edit-modal-content .input-field {
+ width: 100%;
+ min-width: 140px;
+}
+
+.edit-modal-content .form-group {
+ min-width: 0;
+}
+
+.edit-modal-content .form-row .form-group {
+ min-width: 0;
}
.field-error {
@@ -394,6 +408,22 @@
gap: var(--spacing-sm);
}
+.nfo-status-row {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-md);
+}
+
+.nfo-path-display {
+ font-size: var(--font-size-caption);
+ color: var(--color-text-tertiary);
+ font-family: 'Consolas', 'Monaco', monospace;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 200px;
+}
+
.nfo-status-badge {
display: inline-block;
padding: 4px 12px;
@@ -456,9 +486,14 @@
gap: var(--spacing-xs);
}
+.nfo-actions-row {
+ display: flex;
+ gap: var(--spacing-sm);
+ margin-top: var(--spacing-xs);
+}
+
.btn-repair {
align-self: flex-start;
- margin-top: var(--spacing-sm);
}
.modal-footer {
diff --git a/src/server/web/static/css/pages/nfo-settings.css b/src/server/web/static/css/pages/nfo-settings.css
index 41fef0c..48dbcf4 100644
--- a/src/server/web/static/css/pages/nfo-settings.css
+++ b/src/server/web/static/css/pages/nfo-settings.css
@@ -1,427 +1,637 @@
-/* NFO Settings Page Styles */
+/**
+ * AniWorld - NFO Settings Page Styles
+ *
+ * Standalone page for NFO diagnostics, repair, and settings.
+ */
-#app {
- min-height: 100vh;
- display: flex;
- flex-direction: column;
+.nfo-main-content {
+ padding: var(--spacing-lg);
+ max-width: 1400px;
+ margin: 0 auto;
}
-/* Header */
-.page-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 1rem 2rem;
- background: var(--color-surface);
- border-bottom: 1px solid var(--color-border);
+/* ========== Stats Section ========== */
+.nfo-stats-section {
+ margin-bottom: var(--spacing-xl);
}
-.header-left .back-link {
- display: flex;
- align-items: center;
- gap: 0.5rem;
- color: var(--color-text-secondary);
- text-decoration: none;
- font-size: 0.875rem;
- transition: color 0.2s;
-}
-
-.header-left .back-link:hover {
- color: var(--color-primary);
-}
-
-.header-center h1 {
- font-size: 1.25rem;
- font-weight: 600;
- color: var(--color-text);
-}
-
-.header-right {
- display: flex;
- gap: 0.75rem;
-}
-
-/* Stats Bar */
-.stats-bar {
- display: flex;
- gap: 1rem;
- padding: 1rem 2rem;
- background: var(--color-surface);
- border-bottom: 1px solid var(--color-border);
-}
-
-.stat-item {
- display: flex;
- flex-direction: column;
- align-items: center;
- padding: 0.75rem 1.5rem;
- background: var(--color-background);
- border-radius: 8px;
- min-width: 120px;
-}
-
-.stat-value {
- font-size: 1.5rem;
- font-weight: 700;
- color: var(--color-text);
-}
-
-.stat-label {
- font-size: 0.75rem;
- color: var(--color-text-secondary);
- text-transform: uppercase;
- letter-spacing: 0.05em;
-}
-
-.stat-missing .stat-value {
- color: var(--color-error);
-}
-
-.stat-incomplete .stat-value {
- color: var(--color-warning);
-}
-
-.stat-complete .stat-value {
- color: var(--color-success);
-}
-
-/* Toolbar */
-.toolbar {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 1rem 2rem;
- background: var(--color-surface);
- border-bottom: 1px solid var(--color-border);
-}
-
-.search-box {
- position: relative;
- width: 300px;
-}
-
-.search-box i {
- position: absolute;
- left: 12px;
- top: 50%;
- transform: translateY(-50%);
- color: var(--color-text-secondary);
-}
-
-.search-box .input-field {
- width: 100%;
- padding-left: 2.5rem;
-}
-
-.filter-buttons {
- display: flex;
- gap: 0.5rem;
-}
-
-.filter-btn {
- display: flex;
- align-items: center;
- gap: 0.5rem;
- padding: 0.5rem 1rem;
- background: var(--color-background);
- border: 1px solid var(--color-border);
- border-radius: 6px;
- color: var(--color-text-secondary);
- cursor: pointer;
- transition: all 0.2s;
-}
-
-.filter-btn:hover {
- background: var(--color-surface-hover);
- color: var(--color-text);
-}
-
-.filter-btn.active {
- background: var(--color-primary);
- border-color: var(--color-primary);
- color: white;
-}
-
-.filter-btn .count {
- background: rgba(255, 255, 255, 0.2);
- padding: 0.125rem 0.5rem;
- border-radius: 10px;
- font-size: 0.75rem;
-}
-
-.filter-btn:not(.active) .count {
- background: var(--color-border);
-}
-
-/* Series List */
-.series-list-container {
- flex: 1;
- padding: 1rem 2rem;
- overflow-y: auto;
-}
-
-.series-list {
+.nfo-stats-section .stats-grid {
display: grid;
- grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
- gap: 1rem;
+ grid-template-columns: repeat(4, 1fr);
+ gap: var(--spacing-lg);
}
-.loading-state,
-.empty-state {
- grid-column: 1 / -1;
+.nfo-stats-section .stat-card {
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius-lg);
+ padding: var(--spacing-lg);
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-lg);
+}
+
+.nfo-stats-section .stat-icon {
+ width: 48px;
+ height: 48px;
+ border-radius: 50%;
display: flex;
- flex-direction: column;
align-items: center;
justify-content: center;
- padding: 4rem;
- color: var(--color-text-secondary);
+ background: var(--color-bg-tertiary);
+ font-size: 20px;
}
-.loading-state i {
- font-size: 2rem;
- margin-bottom: 1rem;
-}
-
-.empty-state i {
- font-size: 3rem;
+.nfo-stats-section .stat-success .stat-icon {
+ background: rgba(16, 124, 16, 0.1);
color: var(--color-success);
- margin-bottom: 1rem;
}
-/* Series Card */
-.series-card {
- background: var(--color-surface);
- border: 1px solid var(--color-border);
- border-radius: 8px;
- padding: 1rem;
- cursor: pointer;
- transition: all 0.2s;
-}
-
-.series-card:hover {
- border-color: var(--color-primary);
- transform: translateY(-2px);
-}
-
-.series-card.missing {
- border-left: 4px solid var(--color-error);
-}
-
-.series-card.incomplete {
- border-left: 4px solid var(--color-warning);
-}
-
-.series-card.complete {
- border-left: 4px solid var(--color-success);
-}
-
-.series-card-header {
- display: flex;
- justify-content: space-between;
- align-items: flex-start;
- margin-bottom: 0.75rem;
-}
-
-.series-name {
- font-size: 1rem;
- font-weight: 600;
- color: var(--color-text);
- margin: 0;
-}
-
-.series-folder {
- font-size: 0.75rem;
- color: var(--color-text-secondary);
- margin-top: 0.25rem;
-}
-
-.nfo-status-badge {
- display: inline-block;
- padding: 0.25rem 0.75rem;
- border-radius: 4px;
- font-size: 0.75rem;
- font-weight: 600;
- text-transform: uppercase;
-}
-
-.nfo-status-badge.missing,
-.nfo-status-badge.no-nfo {
- background: var(--color-error-bg);
- color: var(--color-error);
-}
-
-.nfo-status-badge.incomplete {
- background: var(--color-warning-bg);
+.nfo-stats-section .stat-warning .stat-icon {
+ background: rgba(255, 140, 0, 0.1);
color: var(--color-warning);
}
-.nfo-status-badge.complete {
- background: var(--color-success-bg);
- color: var(--color-success);
+.nfo-stats-section .stat-error .stat-icon {
+ background: rgba(209, 52, 56, 0.1);
+ color: var(--color-error);
}
-.missing-tags-list {
+.nfo-stats-section .stat-info {
display: flex;
- flex-wrap: wrap;
- gap: 0.5rem;
- margin-top: 0.5rem;
+ flex-direction: column;
}
-.missing-tag-chip {
- display: inline-block;
- padding: 0.25rem 0.5rem;
- background: var(--color-background);
- border-radius: 4px;
- font-size: 0.75rem;
+.nfo-stats-section .stat-value {
+ font-size: 28px;
+ font-weight: 600;
+ color: var(--color-text-primary);
+ line-height: 1.2;
+}
+
+.nfo-stats-section .stat-label {
+ font-size: var(--font-size-body);
color: var(--color-text-secondary);
}
-/* Detail Panel */
-.detail-panel {
- position: fixed;
- top: 0;
- right: 0;
- bottom: 0;
- width: 500px;
- z-index: 1000;
+/* ========== Tab Navigation ========== */
+.nfo-tabs {
display: flex;
+ gap: var(--spacing-xs);
+ background: var(--color-bg-secondary);
+ padding: var(--spacing-xs);
+ border-radius: var(--border-radius-lg);
+ margin-bottom: var(--spacing-lg);
+ border: 1px solid var(--color-border);
}
-.detail-panel.hidden {
- display: none;
+.nfo-tab {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--spacing-sm);
+ padding: var(--spacing-md) var(--spacing-lg);
+ border: none;
+ background: transparent;
+ color: var(--color-text-secondary);
+ font-size: var(--font-size-body);
+ font-weight: 500;
+ cursor: pointer;
+ border-radius: var(--border-radius-md);
+ transition: all var(--transition-duration) ease;
}
-.panel-overlay {
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background: rgba(0, 0, 0, 0.5);
+.nfo-tab:hover {
+ background: var(--color-surface-hover);
+ color: var(--color-text-primary);
}
-.panel-content {
- position: relative;
- width: 100%;
+.nfo-tab.active {
background: var(--color-surface);
- box-shadow: -4px 0 20px rgba(0, 0, 0, 0.3);
- display: flex;
- flex-direction: column;
+ color: var(--color-primary);
+ box-shadow: var(--shadow-card);
+}
+
+.nfo-tab i {
+ font-size: 16px;
+}
+
+/* ========== Tab Content ========== */
+.nfo-tab-content {
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius-lg);
+ overflow: hidden;
+}
+
+.tab-panel {
+ display: none;
+ padding: var(--spacing-xl);
+ animation: fadeIn 0.2s ease;
+}
+
+.tab-panel.active {
+ display: block;
+}
+
+@keyframes fadeIn {
+ from { opacity: 0; transform: translateY(4px); }
+ to { opacity: 1; transform: translateY(0); }
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
- padding: 1rem 1.5rem;
- border-bottom: 1px solid var(--color-border);
+ margin-bottom: var(--spacing-xl);
+ flex-wrap: wrap;
+ gap: var(--spacing-md);
}
-.panel-header h3 {
- font-size: 1.125rem;
- font-weight: 600;
- color: var(--color-text);
+.panel-header h2 {
+ margin: 0;
+ font-size: var(--font-size-title);
+ color: var(--color-text-primary);
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-sm);
+}
+
+.panel-header h2 i {
+ color: var(--color-primary);
+}
+
+.panel-actions {
+ display: flex;
+ gap: var(--spacing-sm);
+ align-items: center;
+}
+
+.panel-actions .search-input {
+ width: 200px;
+}
+
+.panel-actions .filter-select {
+ min-width: 140px;
+}
+
+/* ========== Overview Tab ========== */
+.overview-content {
+ max-width: 900px;
+}
+
+.health-summary {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: var(--spacing-xl);
+}
+
+.health-chart {
+ background: var(--color-bg-secondary);
+ border-radius: var(--border-radius-lg);
+ padding: var(--spacing-xl);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 250px;
+}
+
+.health-details h3 {
+ font-size: var(--font-size-subtitle);
+ margin: 0 0 var(--spacing-sm) 0;
+ color: var(--color-text-primary);
+}
+
+.health-details h3:not(:first-child) {
+ margin-top: var(--spacing-xl);
+}
+
+.info-text {
+ color: var(--color-text-secondary);
+ margin: 0 0 var(--spacing-sm) 0;
+ font-size: var(--font-size-body);
+}
+
+.tag-list {
+ list-style: none;
+ padding: 0;
margin: 0;
}
-.panel-body {
- flex: 1;
- padding: 1.5rem;
- overflow-y: auto;
-}
-
-.detail-section {
- margin-bottom: 1.5rem;
-}
-
-.detail-section h4 {
- font-size: 0.875rem;
- font-weight: 600;
+.tag-list li {
+ padding: var(--spacing-xs) 0;
color: var(--color-text-secondary);
- text-transform: uppercase;
- letter-spacing: 0.05em;
- margin-bottom: 0.75rem;
+ font-size: var(--font-size-body);
}
-.status-display {
- display: flex;
+.tag-list li code {
+ background: var(--color-bg-tertiary);
+ padding: 2px 6px;
+ border-radius: var(--border-radius-sm);
+ font-family: 'Consolas', 'Monaco', monospace;
+ font-size: 13px;
+ color: var(--color-text-primary);
+}
+
+.optional-tags {
+ columns: 2;
+}
+
+/* ========== Diagnostics Tab ========== */
+.series-list-container {
+ overflow-x: auto;
+}
+
+.series-diagnostics-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: var(--font-size-body);
+}
+
+.series-diagnostics-table th,
+.series-diagnostics-table td {
+ padding: var(--spacing-md);
+ text-align: left;
+ border-bottom: 1px solid var(--color-border);
+}
+
+.series-diagnostics-table th {
+ background: var(--color-bg-secondary);
+ font-weight: 600;
+ color: var(--color-text-primary);
+ white-space: nowrap;
+}
+
+.series-diagnostics-table th.sortable {
+ cursor: pointer;
+ user-select: none;
+}
+
+.series-diagnostics-table th.sortable:hover {
+ background: var(--color-bg-tertiary);
+}
+
+.series-diagnostics-table th i {
+ margin-left: var(--spacing-xs);
+ color: var(--color-text-tertiary);
+}
+
+.series-diagnostics-table tbody tr:hover {
+ background: var(--color-surface-hover);
+}
+
+.series-diagnostics-table .status-badge {
+ display: inline-flex;
align-items: center;
- gap: 1rem;
+ gap: var(--spacing-xs);
+ padding: 4px 10px;
+ border-radius: 12px;
+ font-size: 12px;
+ font-weight: 500;
+}
+
+.series-diagnostics-table .status-complete {
+ background: rgba(16, 124, 16, 0.1);
+ color: var(--color-success);
+}
+
+.series-diagnostics-table .status-incomplete {
+ background: rgba(255, 140, 0, 0.1);
+ color: var(--color-warning);
+}
+
+.series-diagnostics-table .status-missing {
+ background: rgba(209, 52, 56, 0.1);
+ color: var(--color-error);
+}
+
+.missing-tags-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--spacing-xs);
+}
+
+.missing-tag-chip {
+ display: inline-block;
+ background: var(--color-bg-tertiary);
+ color: var(--color-text-secondary);
+ padding: 2px 8px;
+ border-radius: 4px;
+ font-size: 11px;
+ font-family: 'Consolas', 'Monaco', monospace;
}
.nfo-path {
- font-size: 0.75rem;
- color: var(--color-text-secondary);
- word-break: break-all;
+ font-family: 'Consolas', 'Monaco', monospace;
+ font-size: 12px;
+ color: var(--color-text-tertiary);
+ max-width: 200px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
-.action-buttons {
+.loading-row td {
+ text-align: center;
+ padding: var(--spacing-xxl);
+}
+
+.loading-spinner {
display: flex;
- flex-direction: column;
- gap: 0.5rem;
-}
-
-.action-buttons .btn {
+ align-items: center;
justify-content: center;
-}
-
-.nfo-preview {
- background: var(--color-background);
- border: 1px solid var(--color-border);
- border-radius: 6px;
- padding: 1rem;
- max-height: 300px;
- overflow: auto;
- font-size: 0.75rem;
- line-height: 1.5;
-}
-
-.nfo-preview code {
+ gap: var(--spacing-sm);
color: var(--color-text-secondary);
- white-space: pre-wrap;
- word-break: break-all;
}
-/* Responsive */
-@media (max-width: 768px) {
- .page-header {
- flex-wrap: wrap;
- gap: 1rem;
+.loading-spinner i {
+ font-size: 24px;
+}
+
+/* ========== Repair Tab ========== */
+.repair-content {
+ max-width: 900px;
+}
+
+.repair-info {
+ margin-bottom: var(--spacing-xl);
+}
+
+.info-card {
+ display: flex;
+ gap: var(--spacing-md);
+ padding: var(--spacing-lg);
+ background: var(--color-bg-secondary);
+ border-radius: var(--border-radius-lg);
+ border-left: 4px solid var(--color-primary);
+}
+
+.info-card i {
+ font-size: 20px;
+ color: var(--color-primary);
+ flex-shrink: 0;
+}
+
+.info-card h4 {
+ margin: 0 0 var(--spacing-xs) 0;
+ font-size: var(--font-size-subtitle);
+ color: var(--color-text-primary);
+}
+
+.info-card p {
+ margin: 0;
+ font-size: var(--font-size-body);
+ color: var(--color-text-secondary);
+}
+
+.repair-actions {
+ display: flex;
+ gap: var(--spacing-sm);
+ margin-bottom: var(--spacing-lg);
+}
+
+.repair-list-container {
+ margin-bottom: var(--spacing-lg);
+}
+
+.repair-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: var(--font-size-body);
+}
+
+.repair-table th,
+.repair-table td {
+ padding: var(--spacing-md);
+ text-align: left;
+ border-bottom: 1px solid var(--color-border);
+}
+
+.repair-table th {
+ background: var(--color-bg-secondary);
+ font-weight: 600;
+ color: var(--color-text-primary);
+}
+
+.checkbox-col {
+ width: 40px;
+}
+
+.repair-table tbody tr:hover {
+ background: var(--color-surface-hover);
+}
+
+.repair-footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding-top: var(--spacing-lg);
+ border-top: 1px solid var(--color-border);
+}
+
+.selection-count {
+ color: var(--color-text-secondary);
+ font-size: var(--font-size-body);
+}
+
+.selection-count span {
+ font-weight: 600;
+ color: var(--color-primary);
+}
+
+/* ========== Settings Tab ========== */
+.settings-content {
+ max-width: 700px;
+}
+
+.settings-section {
+ margin-bottom: var(--spacing-xxl);
+ padding-bottom: var(--spacing-xxl);
+ border-bottom: 1px solid var(--color-border);
+}
+
+.settings-section:last-child {
+ border-bottom: none;
+ margin-bottom: 0;
+}
+
+.settings-section h3 {
+ margin: 0 0 var(--spacing-lg) 0;
+ font-size: var(--font-size-subtitle);
+ color: var(--color-text-primary);
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-sm);
+}
+
+.settings-section h3 i {
+ color: var(--color-primary);
+}
+
+.setting-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: var(--spacing-xl);
+ padding: var(--spacing-md) 0;
+}
+
+.setting-info {
+ flex: 1;
+}
+
+.setting-info label {
+ font-weight: 500;
+ color: var(--color-text-primary);
+ display: block;
+ margin-bottom: var(--spacing-xs);
+}
+
+.setting-description {
+ margin: 0;
+ font-size: var(--font-size-body);
+ color: var(--color-text-secondary);
+}
+
+.setting-description a {
+ color: var(--color-primary);
+}
+
+.setting-control {
+ display: flex;
+ gap: var(--spacing-sm);
+ align-items: center;
+}
+
+.setting-control .input-field {
+ width: 300px;
+}
+
+/* Toggle Switch */
+.toggle-switch {
+ position: relative;
+ display: inline-block;
+ width: 48px;
+ height: 26px;
+}
+
+.toggle-switch input {
+ opacity: 0;
+ width: 0;
+ height: 0;
+}
+
+.toggle-slider {
+ position: absolute;
+ cursor: pointer;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: var(--color-bg-tertiary);
+ transition: 0.2s;
+ border-radius: 13px;
+}
+
+.toggle-slider:before {
+ position: absolute;
+ content: "";
+ height: 20px;
+ width: 20px;
+ left: 3px;
+ bottom: 3px;
+ background-color: white;
+ transition: 0.2s;
+ border-radius: 50%;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.2);
+}
+
+.toggle-switch input:checked + .toggle-slider {
+ background-color: var(--color-primary);
+}
+
+.toggle-switch input:checked + .toggle-slider:before {
+ transform: translateX(22px);
+}
+
+/* Connection Status */
+.connection-status {
+ margin-top: var(--spacing-md);
+ padding: var(--spacing-md);
+ border-radius: var(--border-radius-md);
+ font-size: var(--font-size-body);
+}
+
+.connection-status.success {
+ background: rgba(16, 124, 16, 0.1);
+ color: var(--color-success);
+ border: 1px solid var(--color-success);
+}
+
+.connection-status.error {
+ background: rgba(209, 52, 56, 0.1);
+ color: var(--color-error);
+ border: 1px solid var(--color-error);
+}
+
+/* ========== Responsive ========== */
+@media (max-width: 1024px) {
+ .nfo-stats-section .stats-grid {
+ grid-template-columns: repeat(2, 1fr);
}
-
- .stats-bar {
- flex-wrap: wrap;
- padding: 0.75rem 1rem;
- }
-
- .stat-item {
- min-width: 100px;
- padding: 0.5rem 1rem;
- }
-
- .toolbar {
- flex-direction: column;
- gap: 1rem;
- padding: 0.75rem 1rem;
- }
-
- .search-box {
- width: 100%;
- }
-
- .filter-buttons {
- flex-wrap: wrap;
- }
-
- .series-list-container {
- padding: 1rem;
- }
-
- .series-list {
+
+ .health-summary {
grid-template-columns: 1fr;
}
-
- .detail-panel {
+}
+
+@media (max-width: 768px) {
+ .nfo-tabs {
+ flex-wrap: wrap;
+ }
+
+ .nfo-tab {
+ flex: 1 1 45%;
+ }
+
+ .nfo-tab span {
+ display: none;
+ }
+
+ .panel-header {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .panel-actions {
width: 100%;
}
-}
\ No newline at end of file
+
+ .panel-actions .search-input,
+ .panel-actions .filter-select {
+ flex: 1;
+ }
+
+ .setting-item {
+ flex-direction: column;
+ gap: var(--spacing-md);
+ }
+
+ .setting-control {
+ width: 100%;
+ }
+
+ .setting-control .input-field {
+ width: 100%;
+ }
+
+ .repair-footer {
+ flex-direction: column;
+ gap: var(--spacing-md);
+ }
+
+ .repair-footer .btn {
+ width: 100%;
+ }
+}
diff --git a/src/server/web/static/js/index/context-menu.js b/src/server/web/static/js/index/context-menu.js
index 272dbfa..18b3a65 100644
--- a/src/server/web/static/js/index/context-menu.js
+++ b/src/server/web/static/js/index/context-menu.js
@@ -71,6 +71,10 @@ AniWorld.ContextMenu = (function() {
Edit Metadata
+
+
+ NFO Diagnostics
+
`;
document.body.appendChild(menuElement);
@@ -102,6 +106,13 @@ AniWorld.ContextMenu = (function() {
AniWorld.EditModal.open(currentSeriesKey);
}
});
+
+ // NFO Diagnostics - opens the full NFO settings page
+ menuElement.querySelector('[data-action="nfo-diagnostics"]').addEventListener('click', function() {
+ hide();
+ // Navigate to NFO settings page with this series selected
+ window.location.href = '/settings/nfo?key=' + encodeURIComponent(currentSeriesKey);
+ });
}
/**
diff --git a/src/server/web/static/js/index/edit-modal.js b/src/server/web/static/js/index/edit-modal.js
index cf77eac..f57fb17 100644
--- a/src/server/web/static/js/index/edit-modal.js
+++ b/src/server/web/static/js/index/edit-modal.js
@@ -36,12 +36,13 @@ AniWorld.EditModal = (function() {
hideKeyWarning();
try {
- // Try to find series data from the local series list first
- let seriesData = findSeriesData(seriesKey);
+ // Always fetch fresh data from API for edit modal to ensure accuracy
+ // This is more reliable than local cache which may be stale or missing
+ let seriesData = await fetchSeriesDetails(seriesKey);
- // If not found locally, fetch from API
+ // Fallback: try local data if API fails
if (!seriesData) {
- seriesData = await fetchSeriesDetails(seriesKey);
+ seriesData = findSeriesData(seriesKey);
}
originalData = {
@@ -384,6 +385,7 @@ AniWorld.EditModal = (function() {
function renderDiagnostics(data) {
const badge = document.getElementById('nfo-status-badge');
const tagsList = document.getElementById('nfo-missing-tags');
+ const pathDisplay = document.getElementById('nfo-path-display');
if (badge) {
if (!data.has_nfo) {
@@ -398,6 +400,19 @@ AniWorld.EditModal = (function() {
}
}
+ // Show NFO path if available
+ if (pathDisplay) {
+ if (data.nfo_path) {
+ // Extract just the relative path from the full path
+ const parts = data.nfo_path.split('/');
+ const relativePath = parts.slice(-3).join('/'); // folder/tvshow.nfo
+ pathDisplay.textContent = relativePath;
+ pathDisplay.title = data.nfo_path;
+ } else {
+ pathDisplay.textContent = '';
+ }
+ }
+
if (tagsList) {
if (data.missing_tags.length === 0) {
tagsList.innerHTML = '