refactor: overhaul NFO settings UI and backend
- Rework nfo-settings page with improved styling and layout - Update edit-modal and context-menu with enhanced functionality - Refactor NFO API endpoints and models - Remove deprecated test_nfo_diagnostics_repair.py - Clean up tasks.md documentation
This commit is contained in:
178
Docs/tasks.md
178
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 `<title>` and `<year>` 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 `<title> (<year>)` 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 `<thumb aspect="poster">` or `<thumb>` 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.
|
|
||||||
|
|||||||
@@ -1,22 +1,25 @@
|
|||||||
"""NFO Management API endpoints.
|
"""NFO Management API endpoints.
|
||||||
|
|
||||||
Provides endpoints for:
|
Provides endpoints for NFO diagnostics, repair, and validation for anime series.
|
||||||
- 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
|
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
from typing import Any, List, Optional
|
from typing import Any, List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from src.config.settings import settings
|
from src.config.settings import settings
|
||||||
from src.server.models.nfo import NfoDiagnosticsResponse, NfoRepairResponse
|
from src.server.models.nfo import (
|
||||||
from src.server.services.anime_service import AnimeService, AnimeServiceError
|
NfoDiagnosticsResponse,
|
||||||
|
NfoRepairResponse,
|
||||||
|
NfoSeriesDiagnostics,
|
||||||
|
)
|
||||||
|
from src.server.services.anime_service import AnimeService
|
||||||
from src.server.utils.dependencies import get_anime_service, require_auth
|
from src.server.utils.dependencies import get_anime_service, require_auth
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/nfo", tags=["nfo"])
|
router = APIRouter(prefix="/api/nfo", tags=["nfo"])
|
||||||
|
|
||||||
# Required tags for a valid Kodi tvshow.nfo
|
# Required tags for a valid Kodi tvshow.nfo
|
||||||
@@ -25,267 +28,270 @@ REQUIRED_TAGS = [
|
|||||||
"plot",
|
"plot",
|
||||||
"tmdbid",
|
"tmdbid",
|
||||||
]
|
]
|
||||||
# All tags we check for completeness
|
OPTIONAL_TAGS = [
|
||||||
ALL_TAGS = [
|
|
||||||
"title",
|
|
||||||
"originaltitle",
|
|
||||||
"showtitle",
|
|
||||||
"sorttitle",
|
|
||||||
"year",
|
"year",
|
||||||
"plot",
|
"premiered",
|
||||||
|
"genre",
|
||||||
|
"studio",
|
||||||
|
"rating",
|
||||||
|
"mpaa",
|
||||||
"outline",
|
"outline",
|
||||||
"tagline",
|
"tagline",
|
||||||
"runtime",
|
"runtime",
|
||||||
"mpaa",
|
|
||||||
"premiered",
|
|
||||||
"status",
|
"status",
|
||||||
"studio",
|
"id",
|
||||||
"genre",
|
"imdb_id",
|
||||||
"tmdbid",
|
|
||||||
"imdbid",
|
|
||||||
"tvdbid",
|
"tvdbid",
|
||||||
"userrating",
|
"imdbid",
|
||||||
"trailer",
|
"uniqueid",
|
||||||
"thumb",
|
"thumb",
|
||||||
"fanart",
|
"fanart",
|
||||||
"actor",
|
"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):
|
class NfoValidateResponse(BaseModel):
|
||||||
"""Response for NFO validation check."""
|
"""Response for NFO XML validation."""
|
||||||
valid: bool = Field(..., description="Whether NFO XML is valid")
|
|
||||||
error: Optional[str] = Field(None, description="Error message if invalid")
|
valid: bool
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
def _parse_nfo_tags(nfo_path: str) -> dict[str, str]:
|
class NfoNeedsRepairListResponse(BaseModel):
|
||||||
"""Parse NFO file and return tag values.
|
"""Response listing series that need NFO repair."""
|
||||||
|
|
||||||
Returns dict of tag name -> tag text content.
|
total: int
|
||||||
Empty tags (e.g., <plot></plot>) are returned as empty string.
|
series: List[NfoSeriesDiagnostics]
|
||||||
"""
|
|
||||||
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]:
|
def _get_nfo_path(folder: str) -> str:
|
||||||
"""Check which required tags are missing or empty."""
|
"""Get the full path to a series' tvshow.nfo file."""
|
||||||
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)
|
anime_dir = getattr(settings, "anime_directory", None)
|
||||||
if not anime_dir:
|
if not anime_dir:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
detail="Anime directory not configured",
|
detail="Anime directory not configured",
|
||||||
)
|
)
|
||||||
|
return os.path.join(anime_dir, folder, "tvshow.nfo")
|
||||||
|
|
||||||
missing_nfo_list: List[NfoSeriesItem] = []
|
|
||||||
incomplete_list: List[NfoSeriesItem] = []
|
|
||||||
|
|
||||||
for series in all_series:
|
def _parse_nfo_file(nfo_path: str) -> tuple[Optional[Any], List[str]]:
|
||||||
key = series.get("key", "")
|
"""Parse an NFO file and return (xml_root, missing_tags).
|
||||||
name = series.get("name", "")
|
|
||||||
folder = series.get("folder", "")
|
|
||||||
|
|
||||||
if not folder:
|
Returns tuple of (xml_root element or None, list of missing required tags).
|
||||||
continue
|
If file cannot be read/parsed, returns (None, all_required_tags).
|
||||||
|
"""
|
||||||
|
from lxml import etree
|
||||||
|
|
||||||
nfo_path = os.path.join(anime_dir, folder, "tvshow.nfo")
|
missing: List[str] = []
|
||||||
|
|
||||||
if not os.path.isfile(nfo_path):
|
if not os.path.isfile(nfo_path):
|
||||||
missing_nfo_list.append(NfoSeriesItem(
|
return None, REQUIRED_TAGS.copy()
|
||||||
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
|
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()
|
||||||
|
|
||||||
return NfoNeedsRepairResponse(
|
# Check for required tags
|
||||||
total=len(all_series),
|
for tag in REQUIRED_TAGS:
|
||||||
missing_nfo_count=len(missing_nfo_list),
|
elements = root.findall(tag)
|
||||||
incomplete_nfo_count=len(incomplete_list),
|
# Check if tag exists and has non-empty text
|
||||||
series=all_needing_repair,
|
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
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{serie_key}/diagnostics", response_model=NfoDiagnosticsResponse)
|
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:
|
||||||
|
if series.get("key") == key:
|
||||||
|
return series
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{key}/diagnostics", response_model=NfoDiagnosticsResponse)
|
||||||
async def get_nfo_diagnostics(
|
async def get_nfo_diagnostics(
|
||||||
serie_key: str,
|
key: str,
|
||||||
_auth: dict = Depends(require_auth),
|
_auth: dict = Depends(require_auth),
|
||||||
anime_service: AnimeService = Depends(get_anime_service),
|
anime_service: AnimeService = Depends(get_anime_service),
|
||||||
) -> NfoDiagnosticsResponse:
|
) -> NfoDiagnosticsResponse:
|
||||||
"""Get NFO diagnostics for a specific series.
|
"""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:
|
Returns:
|
||||||
- Whether tvshow.nfo exists
|
NfoDiagnosticsResponse with has_nfo, nfo_path, missing_tags, required_tags
|
||||||
- Path to NFO file if it exists
|
|
||||||
- List of missing required tags (if any)
|
Raises:
|
||||||
- List of all tags for reference
|
HTTPException 404: If series not found
|
||||||
|
HTTPException 503: If anime directory not configured
|
||||||
"""
|
"""
|
||||||
# Get series data
|
# Get series data
|
||||||
try:
|
series_data = await _get_series_data(anime_service, key)
|
||||||
all_series = await anime_service.list_series_with_filters()
|
if not series_data:
|
||||||
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
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:
|
if not folder:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
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)
|
nfo_path = _get_nfo_path(folder)
|
||||||
if not anime_dir:
|
nfo_exists = os.path.isfile(nfo_path)
|
||||||
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 nfo_exists:
|
||||||
|
|
||||||
if not os.path.isfile(nfo_path):
|
|
||||||
return NfoDiagnosticsResponse(
|
return NfoDiagnosticsResponse(
|
||||||
has_nfo=False,
|
has_nfo=False,
|
||||||
nfo_path=None,
|
nfo_path=None,
|
||||||
missing_tags=REQUIRED_TAGS.copy(),
|
missing_tags=REQUIRED_TAGS.copy(),
|
||||||
required_tags=ALL_TAGS.copy(),
|
required_tags=REQUIRED_TAGS.copy(),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Parse NFO and check for missing tags
|
# Parse and check for missing tags
|
||||||
tags = _parse_nfo_tags(nfo_path)
|
_, missing = _parse_nfo_file(nfo_path)
|
||||||
missing = _check_missing_tags(tags)
|
|
||||||
|
|
||||||
return NfoDiagnosticsResponse(
|
return NfoDiagnosticsResponse(
|
||||||
has_nfo=True,
|
has_nfo=True,
|
||||||
nfo_path=nfo_path,
|
nfo_path=nfo_path,
|
||||||
missing_tags=missing,
|
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(
|
async def repair_nfo(
|
||||||
serie_key: str,
|
key: str,
|
||||||
_auth: dict = Depends(require_auth),
|
_auth: dict = Depends(require_auth),
|
||||||
anime_service: AnimeService = Depends(get_anime_service),
|
anime_service: AnimeService = Depends(get_anime_service),
|
||||||
) -> NfoRepairResponse:
|
) -> NfoRepairResponse:
|
||||||
"""Repair (create or update) NFO file for a series.
|
"""Repair NFO for a specific series.
|
||||||
|
|
||||||
If NFO doesn't exist, creates it from TMDB metadata.
|
Creates or updates the tvshow.nfo file using 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.
|
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
|
# Get series data
|
||||||
try:
|
series_data = await _get_series_data(anime_service, key)
|
||||||
all_series = await anime_service.list_series_with_filters()
|
if not series_data:
|
||||||
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
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:
|
if not folder:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
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)
|
anime_dir = getattr(settings, "anime_directory", None)
|
||||||
if not anime_dir:
|
if not anime_dir:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -293,135 +299,94 @@ async def repair_nfo(
|
|||||||
detail="Anime directory not configured",
|
detail="Anime directory not configured",
|
||||||
)
|
)
|
||||||
|
|
||||||
nfo_path = os.path.join(anime_dir, folder, "tvshow.nfo")
|
series_path = os.path.join(anime_dir, folder)
|
||||||
tmdb_id = series.get("tmdb_id")
|
nfo_path = os.path.join(series_path, "tvshow.nfo")
|
||||||
|
|
||||||
# If no TMDB ID, try to look up by name
|
# Fetch TMDB data
|
||||||
if not tmdb_id:
|
provider = TMDBProvider()
|
||||||
name = series.get("name", "")
|
tmdb_data = await provider.get_series_info(tmdb_id)
|
||||||
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:
|
if not tmdb_data:
|
||||||
return NfoRepairResponse(
|
raise Exception(f"No TMDB data returned for TMDB ID {tmdb_id}")
|
||||||
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
|
|
||||||
|
|
||||||
|
# Convert to NFO model
|
||||||
nfo_model = tmdb_to_nfo_model(
|
nfo_model = tmdb_to_nfo_model(
|
||||||
tmdb_data,
|
tmdb_data,
|
||||||
content_ratings=None,
|
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",
|
image_size="original",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Generate XML
|
||||||
xml_content = generate_tvshow_nfo(nfo_model)
|
xml_content = generate_tvshow_nfo(nfo_model)
|
||||||
|
|
||||||
# Ensure directory exists
|
# Ensure directory exists
|
||||||
os.makedirs(os.path.dirname(nfo_path), exist_ok=True)
|
os.makedirs(series_path, exist_ok=True)
|
||||||
|
|
||||||
# Track which tags were missing before
|
# Check existing NFO for missing tags before overwriting
|
||||||
had_nfo = os.path.isfile(nfo_path)
|
_, missing_before = _parse_nfo_file(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
|
# Write NFO file
|
||||||
with open(nfo_path, "w", encoding="utf-8") as f:
|
with open(nfo_path, "w", encoding="utf-8") as f:
|
||||||
f.write(xml_content)
|
f.write(xml_content)
|
||||||
|
|
||||||
# Update series NFO flag in database (best effort)
|
logger.info("Created/updated tvshow.nfo for %s at %s", key, nfo_path)
|
||||||
await _update_series_nfo_flag(anime_service, serie_key, True, nfo_path)
|
|
||||||
|
|
||||||
# Calculate repaired tags
|
# Update series NFO status in DB
|
||||||
new_tags = _parse_nfo_tags(nfo_path)
|
await anime_service.update_series_nfo_status(
|
||||||
new_missing = _check_missing_tags(new_tags)
|
key=key,
|
||||||
repaired_tags = [tag for tag in old_missing if tag not in new_missing]
|
has_nfo=True,
|
||||||
|
nfo_path=nfo_path,
|
||||||
action = "created" if not had_nfo else "updated"
|
|
||||||
return NfoRepairResponse(
|
|
||||||
success=True,
|
|
||||||
message=f"NFO {action} successfully for {serie_key}",
|
|
||||||
repaired_tags=repaired_tags,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 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(
|
async def validate_nfo(
|
||||||
serie_key: str,
|
key: str,
|
||||||
_auth: dict = Depends(require_auth),
|
_auth: dict = Depends(require_auth),
|
||||||
anime_service: AnimeService = Depends(get_anime_service),
|
anime_service: AnimeService = Depends(get_anime_service),
|
||||||
) -> NfoValidateResponse:
|
) -> NfoValidateResponse:
|
||||||
"""Quick check if NFO XML is valid.
|
"""Validate NFO XML structure for a series.
|
||||||
|
|
||||||
Does NOT check for missing tags, only validates XML structure.
|
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
|
series_data = await _get_series_data(anime_service, key)
|
||||||
try:
|
if not series_data:
|
||||||
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
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:
|
if not folder:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
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)
|
nfo_path = _get_nfo_path(folder)
|
||||||
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):
|
if not os.path.isfile(nfo_path):
|
||||||
return NfoValidateResponse(
|
return NfoValidateResponse(
|
||||||
valid=False,
|
valid=False,
|
||||||
error="NFO file does not exist",
|
error="No NFO file found",
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
|
|
||||||
etree.parse(nfo_path)
|
etree.parse(nfo_path)
|
||||||
return NfoValidateResponse(
|
return NfoValidateResponse(valid=True)
|
||||||
valid=True,
|
|
||||||
error=None,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return NfoValidateResponse(
|
return NfoValidateResponse(
|
||||||
valid=False,
|
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]:
|
Returns series that either have no NFO file or have missing required tags.
|
||||||
"""Look up TMDB ID by series name using TMDB search."""
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Summary dict with success count, failure count, and errors
|
||||||
|
"""
|
||||||
|
results = {
|
||||||
|
"total": len(keys),
|
||||||
|
"success": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"errors": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
for key in keys:
|
||||||
try:
|
try:
|
||||||
from src.server.providers.tmdb_provider import TMDBProvider
|
# Get series data
|
||||||
provider = TMDBProvider()
|
series_data = await _get_series_data(anime_service, key)
|
||||||
results = await provider.search(name)
|
if not series_data:
|
||||||
if results and len(results) > 0:
|
results["failed"] += 1
|
||||||
return results[0].get("tmdb_id")
|
results["errors"].append(f"{key}: Series not found")
|
||||||
except Exception:
|
continue
|
||||||
pass
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
folder = series_data.get("folder", "")
|
||||||
|
if not folder:
|
||||||
|
results["failed"] += 1
|
||||||
|
results["errors"].append(f"{key}: No folder configured")
|
||||||
|
continue
|
||||||
|
|
||||||
async def _fetch_tmdb_data(tmdb_id: int) -> Optional[dict]:
|
tmdb_id = series_data.get("tmdb_id")
|
||||||
"""Fetch TV show data from TMDB."""
|
name = series_data.get("name", "")
|
||||||
try:
|
|
||||||
from src.server.providers.tmdb_provider import TMDBProvider
|
|
||||||
provider = TMDBProvider()
|
|
||||||
return await provider.get_tvshow(tmdb_id)
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
async def _update_series_nfo_flag(
|
await _create_or_update_nfo(
|
||||||
anime_service: AnimeService,
|
key=key,
|
||||||
key: str,
|
folder=folder,
|
||||||
has_nfo: bool,
|
tmdb_id=tmdb_id,
|
||||||
nfo_path: str,
|
series_data=series_data,
|
||||||
) -> None:
|
)
|
||||||
"""Update NFO flag in database for a series."""
|
results["success"] += 1
|
||||||
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
|
except Exception as exc:
|
||||||
from src.server.database.service import AnimeSeriesService
|
results["failed"] += 1
|
||||||
|
results["errors"].append(f"{key}: {str(exc)}")
|
||||||
|
|
||||||
async with get_db_session() as db:
|
return results
|
||||||
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
|
|
||||||
|
|||||||
@@ -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):
|
class NfoRepairResponse(BaseModel):
|
||||||
"""Response after NFO repair attempt."""
|
"""Response after NFO repair attempt."""
|
||||||
|
|
||||||
|
|||||||
@@ -360,7 +360,21 @@
|
|||||||
.form-row {
|
.form-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
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 {
|
.field-error {
|
||||||
@@ -394,6 +408,22 @@
|
|||||||
gap: var(--spacing-sm);
|
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 {
|
.nfo-status-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
@@ -456,9 +486,14 @@
|
|||||||
gap: var(--spacing-xs);
|
gap: var(--spacing-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nfo-actions-row {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
margin-top: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
.btn-repair {
|
.btn-repair {
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
margin-top: var(--spacing-sm);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-footer {
|
.modal-footer {
|
||||||
|
|||||||
@@ -1,427 +1,637 @@
|
|||||||
/* NFO Settings Page Styles */
|
/**
|
||||||
|
* AniWorld - NFO Settings Page Styles
|
||||||
|
*
|
||||||
|
* Standalone page for NFO diagnostics, repair, and settings.
|
||||||
|
*/
|
||||||
|
|
||||||
#app {
|
.nfo-main-content {
|
||||||
min-height: 100vh;
|
padding: var(--spacing-lg);
|
||||||
display: flex;
|
max-width: 1400px;
|
||||||
flex-direction: column;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header */
|
/* ========== Stats Section ========== */
|
||||||
.page-header {
|
.nfo-stats-section {
|
||||||
display: flex;
|
margin-bottom: var(--spacing-xl);
|
||||||
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 {
|
.nfo-stats-section .stats-grid {
|
||||||
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;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
grid-template-columns: repeat(4, 1fr);
|
||||||
gap: 1rem;
|
gap: var(--spacing-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-state,
|
.nfo-stats-section .stat-card {
|
||||||
.empty-state {
|
background: var(--color-surface);
|
||||||
grid-column: 1 / -1;
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 4rem;
|
background: var(--color-bg-tertiary);
|
||||||
color: var(--color-text-secondary);
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-state i {
|
.nfo-stats-section .stat-success .stat-icon {
|
||||||
font-size: 2rem;
|
background: rgba(16, 124, 16, 0.1);
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state i {
|
|
||||||
font-size: 3rem;
|
|
||||||
color: var(--color-success);
|
color: var(--color-success);
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Series Card */
|
.nfo-stats-section .stat-warning .stat-icon {
|
||||||
.series-card {
|
background: rgba(255, 140, 0, 0.1);
|
||||||
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);
|
color: var(--color-warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nfo-status-badge.complete {
|
.nfo-stats-section .stat-error .stat-icon {
|
||||||
background: var(--color-success-bg);
|
background: rgba(209, 52, 56, 0.1);
|
||||||
color: var(--color-success);
|
color: var(--color-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
.missing-tags-list {
|
.nfo-stats-section .stat-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.missing-tag-chip {
|
.nfo-stats-section .stat-value {
|
||||||
display: inline-block;
|
font-size: 28px;
|
||||||
padding: 0.25rem 0.5rem;
|
font-weight: 600;
|
||||||
background: var(--color-background);
|
color: var(--color-text-primary);
|
||||||
border-radius: 4px;
|
line-height: 1.2;
|
||||||
font-size: 0.75rem;
|
}
|
||||||
|
|
||||||
|
.nfo-stats-section .stat-label {
|
||||||
|
font-size: var(--font-size-body);
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Detail Panel */
|
/* ========== Tab Navigation ========== */
|
||||||
.detail-panel {
|
.nfo-tabs {
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 500px;
|
|
||||||
z-index: 1000;
|
|
||||||
display: flex;
|
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 {
|
.nfo-tab {
|
||||||
display: none;
|
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 {
|
.nfo-tab:hover {
|
||||||
position: absolute;
|
background: var(--color-surface-hover);
|
||||||
top: 0;
|
color: var(--color-text-primary);
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-content {
|
.nfo-tab.active {
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
box-shadow: -4px 0 20px rgba(0, 0, 0, 0.3);
|
color: var(--color-primary);
|
||||||
display: flex;
|
box-shadow: var(--shadow-card);
|
||||||
flex-direction: column;
|
}
|
||||||
|
|
||||||
|
.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 {
|
.panel-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 1rem 1.5rem;
|
margin-bottom: var(--spacing-xl);
|
||||||
border-bottom: 1px solid var(--color-border);
|
flex-wrap: wrap;
|
||||||
|
gap: var(--spacing-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-header h3 {
|
.panel-header h2 {
|
||||||
font-size: 1.125rem;
|
margin: 0;
|
||||||
font-weight: 600;
|
font-size: var(--font-size-title);
|
||||||
color: var(--color-text);
|
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;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-body {
|
.tag-list li {
|
||||||
flex: 1;
|
padding: var(--spacing-xs) 0;
|
||||||
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);
|
color: var(--color-text-secondary);
|
||||||
text-transform: uppercase;
|
font-size: var(--font-size-body);
|
||||||
letter-spacing: 0.05em;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-display {
|
.tag-list li code {
|
||||||
display: flex;
|
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;
|
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 {
|
.nfo-path {
|
||||||
font-size: 0.75rem;
|
font-family: 'Consolas', 'Monaco', monospace;
|
||||||
color: var(--color-text-secondary);
|
font-size: 12px;
|
||||||
word-break: break-all;
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-buttons .btn {
|
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
gap: var(--spacing-sm);
|
||||||
|
|
||||||
.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);
|
color: var(--color-text-secondary);
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive */
|
.loading-spinner i {
|
||||||
@media (max-width: 768px) {
|
font-size: 24px;
|
||||||
.page-header {
|
}
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-bar {
|
/* ========== Repair Tab ========== */
|
||||||
flex-wrap: wrap;
|
.repair-content {
|
||||||
padding: 0.75rem 1rem;
|
max-width: 900px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-item {
|
.repair-info {
|
||||||
min-width: 100px;
|
margin-bottom: var(--spacing-xl);
|
||||||
padding: 0.5rem 1rem;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar {
|
.info-card {
|
||||||
flex-direction: column;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: var(--spacing-md);
|
||||||
padding: 0.75rem 1rem;
|
padding: var(--spacing-lg);
|
||||||
}
|
background: var(--color-bg-secondary);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
border-left: 4px solid var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.search-box {
|
.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%;
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-buttons {
|
.health-summary {
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.series-list-container {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.series-list {
|
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.detail-panel {
|
@media (max-width: 768px) {
|
||||||
|
.nfo-tabs {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nfo-tab {
|
||||||
|
flex: 1 1 45%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nfo-tab span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-actions .search-input,
|
||||||
|
.panel-actions .filter-select {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-item {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-control {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-control .input-field {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repair-footer {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.repair-footer .btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,6 +71,10 @@ AniWorld.ContextMenu = (function() {
|
|||||||
<i class="fa-solid fa-pen-to-square"></i>
|
<i class="fa-solid fa-pen-to-square"></i>
|
||||||
<span>Edit Metadata</span>
|
<span>Edit Metadata</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="context-menu-item" data-action="nfo-diagnostics">
|
||||||
|
<i class="fa-solid fa-file-circle-check"></i>
|
||||||
|
<span>NFO Diagnostics</span>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
document.body.appendChild(menuElement);
|
document.body.appendChild(menuElement);
|
||||||
@@ -102,6 +106,13 @@ AniWorld.ContextMenu = (function() {
|
|||||||
AniWorld.EditModal.open(currentSeriesKey);
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -36,12 +36,13 @@ AniWorld.EditModal = (function() {
|
|||||||
hideKeyWarning();
|
hideKeyWarning();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Try to find series data from the local series list first
|
// Always fetch fresh data from API for edit modal to ensure accuracy
|
||||||
let seriesData = findSeriesData(seriesKey);
|
// 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) {
|
if (!seriesData) {
|
||||||
seriesData = await fetchSeriesDetails(seriesKey);
|
seriesData = findSeriesData(seriesKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
originalData = {
|
originalData = {
|
||||||
@@ -384,6 +385,7 @@ AniWorld.EditModal = (function() {
|
|||||||
function renderDiagnostics(data) {
|
function renderDiagnostics(data) {
|
||||||
const badge = document.getElementById('nfo-status-badge');
|
const badge = document.getElementById('nfo-status-badge');
|
||||||
const tagsList = document.getElementById('nfo-missing-tags');
|
const tagsList = document.getElementById('nfo-missing-tags');
|
||||||
|
const pathDisplay = document.getElementById('nfo-path-display');
|
||||||
|
|
||||||
if (badge) {
|
if (badge) {
|
||||||
if (!data.has_nfo) {
|
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 (tagsList) {
|
||||||
if (data.missing_tags.length === 0) {
|
if (data.missing_tags.length === 0) {
|
||||||
tagsList.innerHTML = '<p class="nfo-all-good">All required tags present</p>';
|
tagsList.innerHTML = '<p class="nfo-all-good">All required tags present</p>';
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -742,7 +742,10 @@
|
|||||||
<div class="edit-section">
|
<div class="edit-section">
|
||||||
<h4><i class="fa-solid fa-file-lines"></i> NFO Status</h4>
|
<h4><i class="fa-solid fa-file-lines"></i> NFO Status</h4>
|
||||||
<div class="nfo-diagnostics">
|
<div class="nfo-diagnostics">
|
||||||
|
<div class="nfo-status-row">
|
||||||
<div id="nfo-status-badge" class="nfo-status-badge">Loading...</div>
|
<div id="nfo-status-badge" class="nfo-status-badge">Loading...</div>
|
||||||
|
<span id="nfo-path-display" class="nfo-path-display"></span>
|
||||||
|
</div>
|
||||||
<div id="nfo-diagnostics-container">
|
<div id="nfo-diagnostics-container">
|
||||||
<div id="nfo-missing-tags" class="missing-tags-list"></div>
|
<div id="nfo-missing-tags" class="missing-tags-list"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -750,14 +753,16 @@
|
|||||||
<i class="fa-solid fa-circle-info"></i>
|
<i class="fa-solid fa-circle-info"></i>
|
||||||
No TMDB ID set. Repair will search TMDB by series name.
|
No TMDB ID set. Repair will search TMDB by series name.
|
||||||
</p>
|
</p>
|
||||||
|
<div class="nfo-actions-row">
|
||||||
<button type="button" id="btn-repair-nfo" class="btn btn-secondary btn-repair">
|
<button type="button" id="btn-repair-nfo" class="btn btn-secondary btn-repair">
|
||||||
<i class="fa-solid fa-wrench"></i> Repair NFO
|
<i class="fa-solid fa-wrench"></i> Repair NFO
|
||||||
</button>
|
</button>
|
||||||
<a id="btn-open-nfo-diagnostics" class="btn btn-secondary btn-open-diagnostics" href="#" style="display:none; text-decoration: none; margin-top: 0.5rem;">
|
<a id="btn-open-nfo-diagnostics" class="btn btn-secondary btn-open-diagnostics" href="#" style="display:none; text-decoration: none;">
|
||||||
<i class="fa-solid fa-external-link-alt"></i> Open Full Diagnostics
|
<i class="fa-solid fa-external-link-alt"></i> Full Diagnostics
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
|
|||||||
@@ -1,183 +1,373 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en" data-theme="dark">
|
<html lang="en" data-theme="light">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title data-text="nfo-diagnostics-title">NFO Diagnostics - Aniworld</title>
|
<title>NFO Diagnostics - AniWorld Manager</title>
|
||||||
<link rel="stylesheet" href="/static/css/base/variables.css?v={{ static_version('css/base/variables.css') }}">
|
<link rel="stylesheet" href="/static/css/styles.css?v={{ static_version('css/styles.css') }}">
|
||||||
<link rel="stylesheet" href="/static/css/base/reset.css?v={{ static_version('css/base/reset.css') }}">
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="/static/css/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="/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>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div class="app-container">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="page-header">
|
<header class="header">
|
||||||
<div class="header-left">
|
<div class="header-content">
|
||||||
<a href="/" class="back-link">
|
<div class="header-title">
|
||||||
|
<i class="fas fa-file-lines"></i>
|
||||||
|
<h1>NFO Diagnostics</h1>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<a href="/" class="btn btn-secondary">
|
||||||
<i class="fas fa-arrow-left"></i>
|
<i class="fas fa-arrow-left"></i>
|
||||||
<span data-text="back-to-library">Back to Library</span>
|
<span>Back to Main</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
<button id="theme-toggle" class="btn btn-icon" title="Toggle theme">
|
||||||
<div class="header-center">
|
<i class="fas fa-moon"></i>
|
||||||
<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>
|
||||||
<button id="btn-repair-all" class="btn btn-primary">
|
<button id="logout-btn" class="btn btn-secondary" title="Logout" style="display: none;">
|
||||||
<i class="fas fa-wrench"></i>
|
<i class="fas fa-sign-out-alt"></i>
|
||||||
<span data-text="repair-all">Repair All</span>
|
<span>Logout</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Stats Bar -->
|
<!-- Main content -->
|
||||||
<div class="stats-bar">
|
<main class="main-content nfo-main-content">
|
||||||
<div class="stat-item">
|
<!-- Statistics Overview -->
|
||||||
<span class="stat-value" id="total-series">0</span>
|
<section class="nfo-stats-section">
|
||||||
<span class="stat-label" data-text="total-series">Total Series</span>
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<i class="fas fa-folder text-primary"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-item stat-missing">
|
<div class="stat-info">
|
||||||
<span class="stat-value" id="missing-nfo">0</span>
|
<div class="stat-value" id="total-series">-</div>
|
||||||
<span class="stat-label" data-text="missing-nfo">Missing NFO</span>
|
<div class="stat-label">Total Series</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search and Filter -->
|
<div class="stat-card stat-success">
|
||||||
<div class="toolbar">
|
<div class="stat-icon">
|
||||||
<div class="search-box">
|
<i class="fas fa-check-circle text-success"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<div class="stat-value" id="complete-series">-</div>
|
||||||
|
<div class="stat-label">Complete NFOs</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card stat-warning">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<i class="fas fa-exclamation-circle text-warning"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<div class="stat-value" id="incomplete-series">-</div>
|
||||||
|
<div class="stat-label">Need Repair</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card stat-error">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<i class="fas fa-times-circle text-error"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<div class="stat-value" id="missing-series">-</div>
|
||||||
|
<div class="stat-label">Missing NFO</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Tab Navigation -->
|
||||||
|
<nav class="nfo-tabs">
|
||||||
|
<button class="nfo-tab active" data-tab="overview">
|
||||||
|
<i class="fas fa-list"></i>
|
||||||
|
<span>Overview</span>
|
||||||
|
</button>
|
||||||
|
<button class="nfo-tab" data-tab="diagnostics">
|
||||||
<i class="fas fa-search"></i>
|
<i class="fas fa-search"></i>
|
||||||
<input type="text" id="search-input" class="input-field"
|
<span>Series Diagnostics</span>
|
||||||
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>
|
||||||
<button class="filter-btn" data-filter="missing">
|
<button class="nfo-tab" data-tab="repair">
|
||||||
<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>
|
<i class="fas fa-wrench"></i>
|
||||||
<span data-text="repair-nfo">Repair NFO</span>
|
<span>Batch Repair</span>
|
||||||
</button>
|
</button>
|
||||||
<button id="btn-validate" class="btn btn-secondary">
|
<button class="nfo-tab" data-tab="settings">
|
||||||
<i class="fas fa-check"></i>
|
<i class="fas fa-cog"></i>
|
||||||
<span data-text="validate-nfo">Validate XML</span>
|
<span>Settings</span>
|
||||||
</button>
|
</button>
|
||||||
<button id="btn-view-nfo" class="btn btn-secondary">
|
</nav>
|
||||||
<i class="fas fa-file-code"></i>
|
|
||||||
<span data-text="view-nfo">View NFO</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- NFO Content Preview -->
|
<!-- Tab Content -->
|
||||||
<div class="detail-section" id="nfo-preview-section">
|
<div class="nfo-tab-content">
|
||||||
<h4 data-text="nfo-preview">NFO Preview</h4>
|
<!-- Overview Tab -->
|
||||||
<pre id="nfo-preview-content" class="nfo-preview"><code></code></pre>
|
<div id="tab-overview" class="tab-panel active">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2><i class="fas fa-chart-pie"></i> NFO Health Overview</h2>
|
||||||
|
<button id="btn-refresh-overview" class="btn btn-secondary">
|
||||||
|
<i class="fas fa-refresh"></i> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="overview-content">
|
||||||
|
<div class="health-summary">
|
||||||
|
<div class="health-chart">
|
||||||
|
<canvas id="nfo-health-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="health-details">
|
||||||
|
<h3>Required Tags</h3>
|
||||||
|
<p class="info-text">Each NFO file must contain these tags for Kodi compatibility:</p>
|
||||||
|
<ul class="tag-list">
|
||||||
|
<li><code>title</code> - Series title</li>
|
||||||
|
<li><code>plot</code> - Series description</li>
|
||||||
|
<li><code>tmdbid</code> - TMDB identifier</li>
|
||||||
|
</ul>
|
||||||
|
<h3>Optional Tags</h3>
|
||||||
|
<p class="info-text">These enhance the Kodi experience:</p>
|
||||||
|
<ul class="tag-list optional-tags">
|
||||||
|
<li><code>year</code>, <code>premiered</code>, <code>genre</code></li>
|
||||||
|
<li><code>studio</code>, <code>rating</code>, <code>mpaa</code></li>
|
||||||
|
<li><code>actor</code>, <code>trailer</code>, <code>thumb</code></li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Toast Notifications -->
|
<!-- Diagnostics Tab -->
|
||||||
|
<div id="tab-diagnostics" class="tab-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2><i class="fas fa-search"></i> Series Diagnostics</h2>
|
||||||
|
<div class="panel-actions">
|
||||||
|
<input type="text" id="diagnostics-search" class="search-input" placeholder="Search series...">
|
||||||
|
<select id="filter-status" class="filter-select">
|
||||||
|
<option value="all">All Status</option>
|
||||||
|
<option value="complete">Complete</option>
|
||||||
|
<option value="incomplete">Incomplete</option>
|
||||||
|
<option value="missing">Missing</option>
|
||||||
|
</select>
|
||||||
|
<button id="btn-refresh-diagnostics" class="btn btn-secondary">
|
||||||
|
<i class="fas fa-refresh"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="series-list-container">
|
||||||
|
<table class="series-diagnostics-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="sortable" data-sort="name">
|
||||||
|
<span>Series Name</span>
|
||||||
|
<i class="fas fa-sort"></i>
|
||||||
|
</th>
|
||||||
|
<th class="sortable" data-sort="status">
|
||||||
|
<span>Status</span>
|
||||||
|
<i class="fas fa-sort"></i>
|
||||||
|
</th>
|
||||||
|
<th>Missing Tags</th>
|
||||||
|
<th>NFO Path</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="diagnostics-table-body">
|
||||||
|
<tr class="loading-row">
|
||||||
|
<td colspan="5">
|
||||||
|
<div class="loading-spinner">
|
||||||
|
<i class="fas fa-spinner fa-spin"></i>
|
||||||
|
<span>Loading diagnostics...</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Repair Tab -->
|
||||||
|
<div id="tab-repair" class="tab-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2><i class="fas fa-wrench"></i> Batch Repair</h2>
|
||||||
|
</div>
|
||||||
|
<div class="repair-content">
|
||||||
|
<div class="repair-info">
|
||||||
|
<div class="info-card">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
<div>
|
||||||
|
<h4>About NFO Repair</h4>
|
||||||
|
<p>Repairs NFO files using TMDB metadata. If a series has no TMDB ID set, the repair will search TMDB by the series name.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="repair-actions">
|
||||||
|
<button id="btn-select-all-repair" class="btn btn-secondary">
|
||||||
|
<i class="fas fa-check-square"></i> Select All Needing Repair
|
||||||
|
</button>
|
||||||
|
<button id="btn-clear-selection" class="btn btn-secondary">
|
||||||
|
<i class="fas fa-times"></i> Clear Selection
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="repair-list-container">
|
||||||
|
<table class="repair-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="checkbox-col">
|
||||||
|
<input type="checkbox" id="select-all-repair">
|
||||||
|
</th>
|
||||||
|
<th>Series Name</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>TMDB ID</th>
|
||||||
|
<th>Priority</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="repair-table-body">
|
||||||
|
<tr class="loading-row">
|
||||||
|
<td colspan="5">
|
||||||
|
<div class="loading-spinner">
|
||||||
|
<i class="fas fa-spinner fa-spin"></i>
|
||||||
|
<span>Loading series needing repair...</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="repair-footer">
|
||||||
|
<div class="selection-count">
|
||||||
|
<span id="selected-count">0</span> series selected
|
||||||
|
</div>
|
||||||
|
<button id="btn-batch-repair" class="btn btn-primary" disabled>
|
||||||
|
<i class="fas fa-wrench"></i> Repair Selected
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings Tab -->
|
||||||
|
<div id="tab-settings" class="tab-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2><i class="fas fa-cog"></i> NFO Settings</h2>
|
||||||
|
</div>
|
||||||
|
<div class="settings-content">
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3><i class="fas fa-plug"></i> TMDB Connection</h3>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label for="tmdb-api-key">TMDB API Key</label>
|
||||||
|
<p class="setting-description">Required for fetching metadata. Get your key from <a href="https://www.themoviedb.org/settings/api" target="_blank">TMDB</a></p>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<input type="password" id="tmdb-api-key" class="input-field" placeholder="Enter TMDB API key">
|
||||||
|
<button id="btn-test-tmdb" class="btn btn-secondary">
|
||||||
|
<i class="fas fa-plug"></i> Test Connection
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="tmdb-status" class="connection-status hidden"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3><i class="fas fa-file-code"></i> Auto-Generation</h3>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label for="nfo-auto-create">Auto-create NFO files</label>
|
||||||
|
<p class="setting-description">Automatically create NFO metadata when downloading new series</p>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="nfo-auto-create">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label for="nfo-update-on-scan">Update NFO on rescan</label>
|
||||||
|
<p class="setting-description">Refresh existing NFO files when rescanning library</p>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="nfo-update-on-scan">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3><i class="fas fa-image"></i> Image Downloads</h3>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label for="nfo-download-poster">Download poster.jpg</label>
|
||||||
|
<p class="setting-description">Download series poster image</p>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="nfo-download-poster" checked>
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label for="nfo-download-fanart">Download fanart.jpg</label>
|
||||||
|
<p class="setting-description">Download background fanart image</p>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="nfo-download-fanart" checked>
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label for="nfo-download-logo">Download logo.png</label>
|
||||||
|
<p class="setting-description">Download series logo/clearlogo</p>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="nfo-download-logo" checked>
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3><i class="fas fa-save"></i> Save Settings</h3>
|
||||||
|
<button id="btn-save-nfo-settings" class="btn btn-primary">
|
||||||
|
<i class="fas fa-save"></i> Save NFO Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading overlay -->
|
||||||
|
<div id="loading-overlay" class="loading-overlay hidden">
|
||||||
|
<div class="loading-spinner">
|
||||||
|
<i class="fas fa-spinner fa-spin"></i>
|
||||||
|
<p>Processing...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toast notifications -->
|
||||||
<div id="toast-container" class="toast-container"></div>
|
<div id="toast-container" class="toast-container"></div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Shared Modules -->
|
<!-- Shared Modules -->
|
||||||
<script src="/static/js/shared/constants.js?v={{ static_version('js/shared/constants.js') }}"></script>
|
<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/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/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/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>
|
<script src="/static/js/shared/websocket-client.js?v={{ static_version('js/shared/websocket-client.js') }}"></script>
|
||||||
|
<script src="/static/js/localization.js?v={{ static_version('js/localization.js') }}"></script>
|
||||||
|
<script src="/static/js/user_preferences.js?v={{ static_version('js/user_preferences.js') }}"></script>
|
||||||
|
|
||||||
<!-- Page Module -->
|
<!-- NFO Settings Page Module -->
|
||||||
<script src="/static/js/pages/nfo-settings.js?v={{ static_version('js/pages/nfo-settings.js') }}"></script>
|
<script src="/static/js/pages/nfo-settings.js?v={{ static_version('js/pages/nfo-settings.js') }}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
"""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
|
|
||||||
|
|
||||||
from src.server.fastapi_app import app
|
|
||||||
from src.server.services.auth_service import auth_service
|
|
||||||
|
|
||||||
|
|
||||||
# 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
|
|
||||||
auth_service._hash = None
|
|
||||||
auth_service._failed.clear()
|
|
||||||
yield
|
|
||||||
auth_service._hash = original_hash
|
|
||||||
auth_service._failed.clear()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
async def client():
|
|
||||||
"""Create an async test client."""
|
|
||||||
transport = ASGITransport(app=app)
|
|
||||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
|
||||||
yield ac
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
async def authenticated_client(client):
|
|
||||||
"""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": "TestPass123!"}
|
|
||||||
)
|
|
||||||
# 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_anime_service():
|
|
||||||
"""Create mock anime service."""
|
|
||||||
service = Mock()
|
|
||||||
service.list_series_with_filters = AsyncMock(return_value=[])
|
|
||||||
return service
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
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]
|
|
||||||
|
|
||||||
|
|
||||||
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_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_returns_404_for_nonexistent(
|
|
||||||
self, authenticated_client, override_anime_service
|
|
||||||
):
|
|
||||||
"""Test diagnostics for non-existent series returns 404."""
|
|
||||||
response = await authenticated_client.get("/api/nfo/nonexistent-key/diagnostics")
|
|
||||||
assert response.status_code == 404
|
|
||||||
|
|
||||||
|
|
||||||
class TestNfoRepair:
|
|
||||||
"""Tests for POST /api/nfo/{serie_key}/repair."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
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_returns_404_for_nonexistent(
|
|
||||||
self, authenticated_client, override_anime_service
|
|
||||||
):
|
|
||||||
"""Test repair for non-existent series returns 404."""
|
|
||||||
response = await authenticated_client.post("/api/nfo/nonexistent-key/repair", json={})
|
|
||||||
assert response.status_code == 404
|
|
||||||
|
|
||||||
|
|
||||||
class TestNfoValidate:
|
|
||||||
"""Tests for GET /api/nfo/{serie_key}/validate."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
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 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