feat(anime): rename NFO Diagnostics to Anime Settings + add edit endpoints
Replaces the read-only 'NFO Diagnostics' page with a full per-anime
Settings page reached from the right-click context menu on series cards.
Users can now view and edit key, name, folder, tmdb_id, tvdb_id and site
for each anime; changes are persisted to the DB and optionally written
back to the NFO file or used to regenerate it.
Backend
- Rename NfoDiagnosticsResponse -> NfoSettingsResponse,
NfoSeriesDiagnostics -> NfoSeriesSettings
- Rename get_nfo_diagnostics -> get_nfo_settings,
repair_nfo -> repair_nfo_settings
- Fix nfo.py bug: repair was calling non-existent
update_series_nfo_status(); now uses update_nfo_status() and an
explicit AnimeSeriesService.update(nfo_path=...)
- New endpoints on /api/anime/{key}:
GET /settings -> AnimeSettingsResponse
PUT /settings -> AnimeSettingsResponse
(body: name/folder/tmdb_id/tvdb_id/site,
options: apply_to_nfo, rename_disk)
POST /regenerate-nfo -> AnimeSettingsRegenerateNfoResponse
- New Pydantic models: AnimeSettingsResponse,
AnimeSettingsUpdateRequest, AnimeSettingsRegenerateNfoResponse
- /anime/settings page route; /settings/nfo now 301-redirects to it
Frontend
- New AniWorld.AnimeSettingsManager JS module (single-page form,
no tabs) with public API init/loadSeries/saveSettings/regenerateNfo/
validateField/populateForm/showSaveSuccess/showError
- New anime-settings.html template + anime-settings.css
- Right-click menu: data-action 'nfo-diagnostics' replaced by
'anime-settings' (label 'Anime Settings'), navigates to
/anime/settings?key=...
- Library 'Open NFO Diagnostics' link renamed to 'Open Anime Settings'
Bug fix
- context-menu click handler was calling hide() BEFORE building the
navigation URL, which cleared currentSeriesKey to null and produced
/anime/settings?key=null. Captures the key into a local const first.
Regression-locked by tests/frontend/unit/context_menu.test.js.
Tests
- 21 new pytest tests in tests/api/test_anime_settings_endpoints.py
(GET/PUT/regenerate-nfo, auth, validation, nfo-repair bug regression)
- tests/api/test_nfo_endpoints.py trimmed to 6 focused tests
- 31 new Vitest unit tests for AnimeSettingsManager
- 5 new Vitest unit tests for ContextMenu (incl. source-invariant
regression guard for the hide()-before-key bug)
- 5 new Playwright E2E tests covering right-click, direct nav,
legacy /settings/nfo redirect, and context-menu labels
- New vitest.config.js (environment: happy-dom)
Docs
- Docs/API.md: new section 'Anime Settings Endpoints'
- Docs/CHANGELOG.md: documents the rename and the context-menu bug fix
Verified
- pytest: 27/27 (21 new + 6 trimmed nfo)
- vitest: 36/36 (31 anime-settings + 5 context-menu)
- playwright e2e: 5/5
This commit is contained in:
130
Docs/API.md
130
Docs/API.md
@@ -826,14 +826,32 @@ Source: [src/server/api/config.py](../src/server/api/config.py#L189-L247)
|
|||||||
|
|
||||||
Prefix: `/api/nfo`
|
Prefix: `/api/nfo`
|
||||||
|
|
||||||
Source: [src/server/api/nfo.py](../src/server/api/nfo.py#L1-L684)
|
Source: [src/server/api/nfo.py](../src/server/api/nfo.py)
|
||||||
|
|
||||||
These endpoints manage tvshow.nfo metadata files and associated media (poster, logo, fanart) for anime series. NFO files use Kodi/XBMC format and are scraped from TMDB API.
|
These endpoints manage tvshow.nfo metadata files for anime series. The
|
||||||
|
per-anime settings page (replacing the old "NFO Diagnostics" UI) lives
|
||||||
|
at `/api/anime/{key}/settings` — see section **7. Anime Settings
|
||||||
|
Endpoints** below.
|
||||||
|
|
||||||
**Prerequisites:**
|
**Prerequisites:**
|
||||||
|
|
||||||
- TMDB API key must be configured in settings
|
- TMDB API key must be configured in settings
|
||||||
- NFO service returns 503 if API key not configured
|
- NFO endpoints return 503 if the API key is missing
|
||||||
|
|
||||||
|
### Anime Settings — New Endpoints
|
||||||
|
|
||||||
|
For the UI-driven settings page (renamed from NFO Diagnostics), the
|
||||||
|
following endpoints replace the older `/api/nfo/{serie_id}/*` flow:
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET | `/api/anime/{key}/settings` | Return all editable fields for a series |
|
||||||
|
| PUT | `/api/anime/{key}/settings` | Update name/folder/tmdb_id/tvdb_id/site, optionally regenerate tvshow.nfo |
|
||||||
|
| POST | `/api/anime/{key}/regenerate-nfo` | Regenerate tvshow.nfo using TMDB |
|
||||||
|
|
||||||
|
See [AnimeSettingsResponse](../src/server/models/anime.py) for the
|
||||||
|
response shape, and [section 7](#7-anime-settings-endpoints-new) for
|
||||||
|
full documentation.
|
||||||
|
|
||||||
### GET /api/nfo/{serie_id}/check
|
### GET /api/nfo/{serie_id}/check
|
||||||
|
|
||||||
@@ -1594,3 +1612,109 @@ GET /api/anime?page=2&per_page=50
|
|||||||
```
|
```
|
||||||
|
|
||||||
Source: [src/server/api/anime.py](../src/server/api/anime.py#L180-L220)
|
Source: [src/server/api/anime.py](../src/server/api/anime.py#L180-L220)
|
||||||
|
|
||||||
|
## 7. Anime Settings Endpoints (New)
|
||||||
|
|
||||||
|
Replaces the old "NFO Diagnostics" page with a per-anime settings UI
|
||||||
|
that views and edits anime metadata directly in the database.
|
||||||
|
|
||||||
|
Source: [src/server/api/anime.py](../src/server/api/anime.py)
|
||||||
|
|
||||||
|
### GET /api/anime/{anime_key}/settings
|
||||||
|
|
||||||
|
Return the full editable settings payload for a single anime series.
|
||||||
|
|
||||||
|
**Authentication:** Required
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
|
||||||
|
- `anime_key` (string): Series unique key (e.g., `attack-on-titan`)
|
||||||
|
|
||||||
|
**Response (200 OK):** [`AnimeSettingsResponse`](../src/server/models/anime.py)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"key": "attack-on-titan",
|
||||||
|
"name": "Attack on Titan",
|
||||||
|
"site": "aniworld.to",
|
||||||
|
"folder": "Attack on Titan (2013)",
|
||||||
|
"year": 2013,
|
||||||
|
"tmdb_id": 1429,
|
||||||
|
"tvdb_id": 789,
|
||||||
|
"has_nfo": true,
|
||||||
|
"nfo_path": "/anime/Attack on Titan (2013)/tvshow.nfo",
|
||||||
|
"nfo_created_at": "2026-01-15T10:30:00+00:00",
|
||||||
|
"nfo_updated_at": "2026-01-15T10:30:00+00:00",
|
||||||
|
"loading_status": "completed",
|
||||||
|
"episode_count": 25,
|
||||||
|
"missing_episode_count": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Errors:**
|
||||||
|
|
||||||
|
- `401 Unauthorized` — Not authenticated.
|
||||||
|
- `404 Not Found` — Series with the given key does not exist.
|
||||||
|
|
||||||
|
### PUT /api/anime/{anime_key}/settings
|
||||||
|
|
||||||
|
Update editable fields for a single anime series. Optional flags
|
||||||
|
control whether the on-disk folder is renamed and whether
|
||||||
|
`tvshow.nfo` is regenerated.
|
||||||
|
|
||||||
|
**Authentication:** Required
|
||||||
|
|
||||||
|
**Request Body** ([`AnimeSettingsUpdateRequest`](../src/server/models/anime.py)):
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `name` | string | no | Display name (1–500 chars) |
|
||||||
|
| `folder` | string | no | Filesystem folder name |
|
||||||
|
| `tmdb_id` | int | no | TMDB ID (positive integer, max 10 digits) |
|
||||||
|
| `tvdb_id` | int | no | TVDB ID (positive integer, max 10 digits) |
|
||||||
|
| `site` | string | no | Provider site URL |
|
||||||
|
| `apply_to_nfo` | bool | no | If true, regenerate `tvshow.nfo` with the new values (requires `tmdb_id`) |
|
||||||
|
| `rename_disk` | bool | no | If true and `folder` changed, rename the folder on disk |
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X PUT "http://127.0.0.1:8000/api/anime/attack-on-titan/settings" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"tmdb_id": 9999, "apply_to_nfo": true}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (200 OK):** Updated [`AnimeSettingsResponse`](../src/server/models/anime.py).
|
||||||
|
|
||||||
|
**Errors:**
|
||||||
|
|
||||||
|
- `400 Bad Request` — `apply_to_nfo=true` but the series has no `tmdb_id`.
|
||||||
|
- `401 Unauthorized` — Not authenticated.
|
||||||
|
- `404 Not Found` — Series with the given key does not exist.
|
||||||
|
- `422 Unprocessable Entity` — Validation failure (empty name, invalid
|
||||||
|
folder, non-positive `tmdb_id`/`tvdb_id`, oversized id, path traversal).
|
||||||
|
|
||||||
|
### POST /api/anime/{anime_key}/regenerate-nfo
|
||||||
|
|
||||||
|
Regenerate `tvshow.nfo` for a single anime using TMDB.
|
||||||
|
|
||||||
|
**Authentication:** Required
|
||||||
|
|
||||||
|
**Response (200 OK):** [`AnimeSettingsRegenerateNfoResponse`](../src/server/models/anime.py)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "NFO regenerated. 2 tags updated.",
|
||||||
|
"nfo_path": "/anime/Attack on Titan (2013)/tvshow.nfo",
|
||||||
|
"repaired_tags": ["title", "tmdbid"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Errors:**
|
||||||
|
|
||||||
|
- `400 Bad Request` — Series has no `tmdb_id`.
|
||||||
|
- `401 Unauthorized` — Not authenticated.
|
||||||
|
- `404 Not Found` — Series with the given key does not exist.
|
||||||
|
- `500 Internal Server Error` — TMDB or NFO regeneration failure.
|
||||||
|
|||||||
@@ -37,6 +37,72 @@ This changelog follows [Keep a Changelog](https://keepachangelog.com/) principle
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [Unreleased] - 2026-06-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Anime Settings page** — renamed from "NFO Diagnostics". Right-click
|
||||||
|
on any anime card → "Anime Settings" navigates to
|
||||||
|
`/anime/settings?key=<series>`. The new page lets the user view and
|
||||||
|
edit `name`, `folder`, `tmdb_id`, `tvdb_id`, and `site` directly in
|
||||||
|
the database, with options to rename the on-disk folder and
|
||||||
|
regenerate `tvshow.nfo` in one click.
|
||||||
|
- **New API endpoints** under `/api/anime/{key}/`:
|
||||||
|
- `GET /settings` — return full editable settings payload
|
||||||
|
- `PUT /settings` — update fields with validation
|
||||||
|
- `POST /regenerate-nfo` — regenerate `tvshow.nfo` from TMDB
|
||||||
|
- **Pydantic models**: `AnimeSettingsResponse`,
|
||||||
|
`AnimeSettingsUpdateRequest`, `AnimeSettingsRegenerateNfoResponse`
|
||||||
|
in [src/server/models/anime.py](../src/server/models/anime.py).
|
||||||
|
- **Frontend module**: `AniWorld.AnimeSettingsManager` IIFE in
|
||||||
|
[src/server/web/static/js/pages/anime-settings.js](../src/server/web/static/js/pages/anime-settings.js)
|
||||||
|
with public API: `init`, `loadSeries`, `saveSettings`,
|
||||||
|
`regenerateNfo`, `validateField`, `populateForm`, `showSaveSuccess`,
|
||||||
|
`showError`.
|
||||||
|
- **Vitest JS unit tests** covering every public function on
|
||||||
|
`AnimeSettingsManager` — 31 tests in
|
||||||
|
[tests/frontend/unit/anime_settings.test.js](../tests/frontend/unit/anime_settings.test.js).
|
||||||
|
- **Playwright E2E test** for the right-click → settings page flow in
|
||||||
|
[tests/frontend/e2e/anime_settings_page.spec.js](../tests/frontend/e2e/anime_settings_page.spec.js).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Right-click context menu** on the library page: "NFO Diagnostics"
|
||||||
|
→ "Anime Settings" (`data-action="nfo-diagnostics"` →
|
||||||
|
`data-action="anime-settings"`).
|
||||||
|
- **Configuration modal link**: "Open NFO Diagnostics" → "Open Anime
|
||||||
|
Settings", target URL `/settings/nfo` → `/anime/settings`.
|
||||||
|
- **Page route**: `/settings/nfo` returns a 301 redirect to
|
||||||
|
`/anime/settings` for backwards compatibility with bookmarks.
|
||||||
|
- **Pydantic model rename** in [src/server/models/nfo.py](../src/server/models/nfo.py):
|
||||||
|
- `NfoDiagnosticsResponse` → `NfoSettingsResponse`
|
||||||
|
- `NfoSeriesDiagnostics` → `NfoSeriesSettings`
|
||||||
|
- **Function rename** in [src/server/api/nfo.py](../src/server/api/nfo.py):
|
||||||
|
- `get_nfo_diagnostics` → `get_nfo_settings`
|
||||||
|
- `repair_nfo` → `repair_nfo_settings`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Bug**: `src/server/api/nfo.py` called the non-existent
|
||||||
|
`anime_service.update_series_nfo_status(...)` method, which would
|
||||||
|
raise `AttributeError` after a successful NFO repair. Renamed the
|
||||||
|
call to the existing `update_nfo_status(...)` method (matching its
|
||||||
|
signature `(key, has_nfo, tmdb_id=None, tvdb_id=None, db=None)`)
|
||||||
|
and added an explicit `AnimeSeriesService.update(db, id, nfo_path=...)`
|
||||||
|
call to keep `nfo_path` in sync. Covered by regression tests in
|
||||||
|
`TestBugFixCreateOrUpdateNfo`.
|
||||||
|
|
||||||
|
- **Bug**: Right-clicking a series card and choosing "Anime Settings"
|
||||||
|
opened `/anime/settings?key=null` instead of carrying the series key.
|
||||||
|
Root cause: the click handler in
|
||||||
|
[src/server/web/static/js/index/context-menu.js](../src/server/web/static/js/index/context-menu.js)
|
||||||
|
called `hide()` BEFORE building the URL — and `hide()` cleared
|
||||||
|
`currentSeriesKey` to null. Fix captures the key into a local
|
||||||
|
`const` before calling `hide()`. Regression-locked by
|
||||||
|
`tests/frontend/unit/context_menu.test.js` (5 tests).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [Unreleased] - 2026-06-05
|
## [Unreleased] - 2026-06-05
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
"@playwright/test": "^1.41.0",
|
"@playwright/test": "^1.41.0",
|
||||||
"@vitest/coverage-v8": "^1.2.0",
|
"@vitest/coverage-v8": "^1.2.0",
|
||||||
"@vitest/ui": "^1.2.0",
|
"@vitest/ui": "^1.2.0",
|
||||||
"happy-dom": "^13.3.5",
|
"happy-dom": "^13.10.1",
|
||||||
"vitest": "^1.2.0"
|
"vitest": "^1.2.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import warnings
|
import warnings
|
||||||
from typing import Any, List, Optional
|
from typing import Any, List, Optional
|
||||||
@@ -18,6 +19,9 @@ from src.server.exceptions import (
|
|||||||
)
|
)
|
||||||
from src.server.models.anime import (
|
from src.server.models.anime import (
|
||||||
AnimeDetailsResponse,
|
AnimeDetailsResponse,
|
||||||
|
AnimeSettingsRegenerateNfoResponse,
|
||||||
|
AnimeSettingsResponse,
|
||||||
|
AnimeSettingsUpdateRequest,
|
||||||
TMDBSearchResult,
|
TMDBSearchResult,
|
||||||
)
|
)
|
||||||
from src.server.services.anime_service import AnimeService, AnimeServiceError
|
from src.server.services.anime_service import AnimeService, AnimeServiceError
|
||||||
@@ -30,7 +34,7 @@ from src.server.utils.dependencies import (
|
|||||||
get_series_app,
|
get_series_app,
|
||||||
require_auth,
|
require_auth,
|
||||||
)
|
)
|
||||||
from src.server.utils.filesystem import sanitize_folder_name
|
from src.server.utils.filesystem import is_safe_path, sanitize_folder_name
|
||||||
from src.server.utils.key_utils import generate_key_from_folder, is_valid_key
|
from src.server.utils.key_utils import generate_key_from_folder, is_valid_key
|
||||||
from src.server.utils.validators import validate_filter_value, validate_search_query
|
from src.server.utils.validators import validate_filter_value, validate_search_query
|
||||||
|
|
||||||
@@ -1309,3 +1313,425 @@ async def search_tmdb_for_series(
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Anime Settings endpoints (rename of NFO Diagnostic page)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_anime_settings_payload(
|
||||||
|
anime_key: str,
|
||||||
|
db: AsyncSession,
|
||||||
|
anime_service: AnimeService,
|
||||||
|
) -> AnimeSettingsResponse:
|
||||||
|
"""Build the AnimeSettingsResponse payload for a given series.
|
||||||
|
|
||||||
|
Combines data from the in-memory SeriesApp (folder/name/site/year) with
|
||||||
|
the authoritative database row (tmdb_id, tvdb_id, has_nfo, nfo_*,
|
||||||
|
loading_status) and episode counts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
anime_key: Series unique key
|
||||||
|
db: Database session
|
||||||
|
anime_service: AnimeService for in-memory fallback
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AnimeSettingsResponse with all editable fields populated
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException 404: If series not found
|
||||||
|
"""
|
||||||
|
from src.server.database.service import AnimeSeriesService, EpisodeService
|
||||||
|
|
||||||
|
db_series = await AnimeSeriesService.get_by_key(db, anime_key)
|
||||||
|
if not db_series:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Series not found: {anime_key}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Episode counts (authoritative DB source)
|
||||||
|
episodes = await EpisodeService.get_by_series(db, db_series.id)
|
||||||
|
episode_count = len(episodes)
|
||||||
|
missing_episode_count = sum(
|
||||||
|
1 for ep in episodes if not ep.is_downloaded
|
||||||
|
)
|
||||||
|
|
||||||
|
# In-memory fallback for folder/name/site/year (DB is authoritative)
|
||||||
|
name = db_series.name
|
||||||
|
site = db_series.site
|
||||||
|
folder = db_series.folder
|
||||||
|
year = db_series.year
|
||||||
|
if hasattr(anime_service, "_app") and hasattr(anime_service._app, "list"):
|
||||||
|
try:
|
||||||
|
for serie in anime_service._app.list.GetList():
|
||||||
|
if getattr(serie, "key", None) == anime_key:
|
||||||
|
name = getattr(serie, "name", name) or name
|
||||||
|
site = getattr(serie, "site", site) or site
|
||||||
|
folder = getattr(serie, "folder", folder) or folder
|
||||||
|
year = getattr(serie, "year", year) or year
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
nfo_created = (
|
||||||
|
db_series.nfo_created_at.isoformat()
|
||||||
|
if db_series.nfo_created_at else None
|
||||||
|
)
|
||||||
|
nfo_updated = (
|
||||||
|
db_series.nfo_updated_at.isoformat()
|
||||||
|
if db_series.nfo_updated_at else None
|
||||||
|
)
|
||||||
|
|
||||||
|
return AnimeSettingsResponse(
|
||||||
|
key=db_series.key,
|
||||||
|
name=name,
|
||||||
|
site=site,
|
||||||
|
folder=folder,
|
||||||
|
year=year,
|
||||||
|
tmdb_id=db_series.tmdb_id,
|
||||||
|
tvdb_id=db_series.tvdb_id,
|
||||||
|
has_nfo=bool(db_series.has_nfo),
|
||||||
|
nfo_path=db_series.nfo_path,
|
||||||
|
nfo_created_at=nfo_created,
|
||||||
|
nfo_updated_at=nfo_updated,
|
||||||
|
loading_status=db_series.loading_status,
|
||||||
|
episode_count=episode_count,
|
||||||
|
missing_episode_count=missing_episode_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{anime_key}/settings", response_model=AnimeSettingsResponse)
|
||||||
|
async def get_anime_settings(
|
||||||
|
anime_key: str,
|
||||||
|
_auth: dict = Depends(require_auth),
|
||||||
|
db: AsyncSession = Depends(get_database_session),
|
||||||
|
anime_service: AnimeService = Depends(get_anime_service),
|
||||||
|
) -> AnimeSettingsResponse:
|
||||||
|
"""Return the full Anime Settings payload for a single series.
|
||||||
|
|
||||||
|
Powers the per-anime settings page reached from the right-click context
|
||||||
|
menu. Returns every field the user can view or edit, plus episode counts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
anime_key: Series unique key
|
||||||
|
_auth: Authentication dependency
|
||||||
|
db: Database session
|
||||||
|
anime_service: AnimeService dependency
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AnimeSettingsResponse with key, name, site, folder, year, tmdb_id,
|
||||||
|
tvdb_id, NFO status and episode counts.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException 404: If series not found.
|
||||||
|
"""
|
||||||
|
return await _build_anime_settings_payload(anime_key, db, anime_service)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_folder_value(folder: str, anime_dir: Optional[str]) -> str:
|
||||||
|
"""Validate and sanitize a folder name.
|
||||||
|
|
||||||
|
Raises HTTPException(422) on empty / invalid folder, 422 on path
|
||||||
|
traversal, 422 if folder escapes anime_dir.
|
||||||
|
"""
|
||||||
|
if not folder or not folder.strip():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="Folder cannot be empty",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
sanitized = sanitize_folder_name(folder)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"Invalid folder name: {exc}",
|
||||||
|
)
|
||||||
|
if anime_dir:
|
||||||
|
full_path = os.path.join(anime_dir, sanitized)
|
||||||
|
if not is_safe_path(anime_dir, full_path):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="Folder path is not safe",
|
||||||
|
)
|
||||||
|
return sanitized
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_tmdb_id(tmdb_id: Optional[int]) -> None:
|
||||||
|
"""Validate TMDB ID is positive and within 10 digits."""
|
||||||
|
if tmdb_id is None:
|
||||||
|
return
|
||||||
|
if tmdb_id <= 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="TMDB ID must be a positive integer",
|
||||||
|
)
|
||||||
|
if tmdb_id > 9999999999:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="TMDB ID exceeds maximum length (10 digits)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_tvdb_id(tvdb_id: Optional[int]) -> None:
|
||||||
|
"""Validate TVDB ID is positive and within 10 digits."""
|
||||||
|
if tvdb_id is None:
|
||||||
|
return
|
||||||
|
if tvdb_id <= 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="TVDB ID must be a positive integer",
|
||||||
|
)
|
||||||
|
if tvdb_id > 9999999999:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="TVDB ID exceeds maximum length (10 digits)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{anime_key}/settings", response_model=AnimeSettingsResponse)
|
||||||
|
async def update_anime_settings(
|
||||||
|
anime_key: str,
|
||||||
|
request: AnimeSettingsUpdateRequest,
|
||||||
|
_auth: dict = Depends(require_auth),
|
||||||
|
db: AsyncSession = Depends(get_database_session),
|
||||||
|
anime_service: AnimeService = Depends(get_anime_service),
|
||||||
|
) -> AnimeSettingsResponse:
|
||||||
|
"""Update editable fields for a single anime series.
|
||||||
|
|
||||||
|
Performs validation on each supplied field, writes the changes to the
|
||||||
|
database (and optionally to tvshow.nfo when ``apply_to_nfo`` is true),
|
||||||
|
then returns the fresh payload.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
anime_key: Series unique key (path param)
|
||||||
|
request: Update payload. All fields optional except as documented
|
||||||
|
in AnimeSettingsUpdateRequest.
|
||||||
|
_auth: Authentication dependency
|
||||||
|
db: Database session
|
||||||
|
anime_service: AnimeService for disk rename + NFO regeneration
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AnimeSettingsResponse: Updated payload reflecting new values.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException 404: Series not found.
|
||||||
|
HTTPException 422: Validation failure (empty name, invalid folder,
|
||||||
|
non-positive tmdb_id/tvdb_id, oversized id, path traversal).
|
||||||
|
"""
|
||||||
|
from src.server.database.service import AnimeSeriesService
|
||||||
|
|
||||||
|
db_series = await AnimeSeriesService.get_by_key(db, anime_key)
|
||||||
|
if not db_series:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Series not found: {anime_key}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Field-level validation
|
||||||
|
anime_dir = (
|
||||||
|
settings.anime_directory
|
||||||
|
if hasattr(settings, "anime_directory") else None
|
||||||
|
)
|
||||||
|
|
||||||
|
update_fields: dict = {}
|
||||||
|
|
||||||
|
if request.name is not None:
|
||||||
|
new_name = request.name.strip()
|
||||||
|
if not new_name:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="Name cannot be empty",
|
||||||
|
)
|
||||||
|
if len(new_name) > 500:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="Name exceeds 500 characters",
|
||||||
|
)
|
||||||
|
update_fields["name"] = new_name
|
||||||
|
|
||||||
|
if request.folder is not None:
|
||||||
|
update_fields["folder"] = _validate_folder_value(
|
||||||
|
request.folder, anime_dir
|
||||||
|
)
|
||||||
|
|
||||||
|
_validate_tmdb_id(request.tmdb_id)
|
||||||
|
if request.tmdb_id is not None:
|
||||||
|
update_fields["tmdb_id"] = request.tmdb_id
|
||||||
|
|
||||||
|
_validate_tvdb_id(request.tvdb_id)
|
||||||
|
if request.tvdb_id is not None:
|
||||||
|
update_fields["tvdb_id"] = request.tvdb_id
|
||||||
|
|
||||||
|
if request.site is not None:
|
||||||
|
update_fields["site"] = request.site
|
||||||
|
|
||||||
|
if not update_fields and not request.apply_to_nfo and not request.rename_disk:
|
||||||
|
# Nothing to do — return current state
|
||||||
|
return await _build_anime_settings_payload(anime_key, db, anime_service)
|
||||||
|
|
||||||
|
# Apply DB update
|
||||||
|
if update_fields:
|
||||||
|
await AnimeSeriesService.update(
|
||||||
|
db, db_series.id, **update_fields
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(db_series)
|
||||||
|
logger.info(
|
||||||
|
"Updated anime settings for %s: %s",
|
||||||
|
anime_key,
|
||||||
|
sorted(update_fields.keys()),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update in-memory SerieList so the UI sees the changes immediately
|
||||||
|
if hasattr(anime_service, "_app") and hasattr(anime_service._app, "list"):
|
||||||
|
try:
|
||||||
|
in_mem = anime_service._app.list.keyDict.get(anime_key)
|
||||||
|
if in_mem is not None:
|
||||||
|
if "name" in update_fields:
|
||||||
|
in_mem.name = update_fields["name"]
|
||||||
|
if "folder" in update_fields:
|
||||||
|
in_mem.folder = update_fields["folder"]
|
||||||
|
if "site" in update_fields:
|
||||||
|
in_mem.site = update_fields["site"]
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Could not update in-memory serie: %s", exc)
|
||||||
|
|
||||||
|
# Optionally rename the on-disk folder
|
||||||
|
if request.rename_disk and "folder" in update_fields:
|
||||||
|
try:
|
||||||
|
await anime_service.rename_folder_if_needed(
|
||||||
|
key=anime_key,
|
||||||
|
current_folder=db_series.folder,
|
||||||
|
target_folder=update_fields["folder"],
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Folder rename failed for %s: %s",
|
||||||
|
anime_key,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Optionally regenerate tvshow.nfo with the new values
|
||||||
|
if request.apply_to_nfo:
|
||||||
|
if not db_series.tmdb_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=(
|
||||||
|
"Cannot regenerate NFO without a TMDB ID. "
|
||||||
|
"Set tmdb_id first or use the Repair flow."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
# Lazy-import to avoid heavy deps when not used
|
||||||
|
from src.server.api.nfo import _create_or_update_nfo
|
||||||
|
|
||||||
|
series_data = {
|
||||||
|
"key": anime_key,
|
||||||
|
"name": db_series.name,
|
||||||
|
"folder": db_series.folder,
|
||||||
|
"tmdb_id": db_series.tmdb_id,
|
||||||
|
}
|
||||||
|
await _create_or_update_nfo(
|
||||||
|
key=anime_key,
|
||||||
|
folder=db_series.folder,
|
||||||
|
tmdb_id=db_series.tmdb_id,
|
||||||
|
series_data=series_data,
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
"NFO regeneration failed for %s: %s",
|
||||||
|
anime_key,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"NFO regeneration failed: {exc}",
|
||||||
|
)
|
||||||
|
|
||||||
|
return await _build_anime_settings_payload(anime_key, db, anime_service)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{anime_key}/regenerate-nfo",
|
||||||
|
response_model=AnimeSettingsRegenerateNfoResponse,
|
||||||
|
)
|
||||||
|
async def regenerate_anime_nfo(
|
||||||
|
anime_key: str,
|
||||||
|
_auth: dict = Depends(require_auth),
|
||||||
|
db: AsyncSession = Depends(get_database_session),
|
||||||
|
anime_service: AnimeService = Depends(get_anime_service),
|
||||||
|
) -> AnimeSettingsRegenerateNfoResponse:
|
||||||
|
"""Regenerate tvshow.nfo for a single anime using TMDB.
|
||||||
|
|
||||||
|
Thin wrapper around the existing NFO repair flow, exposed under
|
||||||
|
/api/anime/{key}/ for symmetry with the settings page UI.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
anime_key: Series unique key
|
||||||
|
_auth: Authentication dependency
|
||||||
|
db: Database session
|
||||||
|
anime_service: AnimeService dependency
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AnimeSettingsRegenerateNfoResponse with success flag, message,
|
||||||
|
regenerated nfo_path and the tags that were missing before.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException 404: Series not found.
|
||||||
|
HTTPException 400: No TMDB ID configured.
|
||||||
|
HTTPException 500: TMDB / NFO regeneration failure.
|
||||||
|
"""
|
||||||
|
from src.server.database.service import AnimeSeriesService
|
||||||
|
|
||||||
|
db_series = await AnimeSeriesService.get_by_key(db, anime_key)
|
||||||
|
if not db_series:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Series not found: {anime_key}",
|
||||||
|
)
|
||||||
|
|
||||||
|
tmdb_id = db_series.tmdb_id
|
||||||
|
if not tmdb_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Series has no TMDB ID — set one before regenerating NFO",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from src.server.api.nfo import _create_or_update_nfo
|
||||||
|
|
||||||
|
series_data = {
|
||||||
|
"key": anime_key,
|
||||||
|
"name": db_series.name,
|
||||||
|
"folder": db_series.folder,
|
||||||
|
"tmdb_id": tmdb_id,
|
||||||
|
}
|
||||||
|
repaired_tags = await _create_or_update_nfo(
|
||||||
|
key=anime_key,
|
||||||
|
folder=db_series.folder,
|
||||||
|
tmdb_id=tmdb_id,
|
||||||
|
series_data=series_data,
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("NFO regeneration failed for %s: %s", anime_key, exc)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"NFO regeneration failed: {exc}",
|
||||||
|
)
|
||||||
|
|
||||||
|
return AnimeSettingsRegenerateNfoResponse(
|
||||||
|
success=True,
|
||||||
|
message=(
|
||||||
|
f"NFO regenerated. {len(repaired_tags)} tags updated."
|
||||||
|
if repaired_tags else "NFO already complete."
|
||||||
|
),
|
||||||
|
nfo_path=db_series.nfo_path,
|
||||||
|
repaired_tags=repaired_tags,
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""NFO Management API endpoints.
|
"""NFO Management API endpoints.
|
||||||
|
|
||||||
Provides endpoints for NFO diagnostics, repair, and validation for anime series.
|
Provides endpoints for NFO settings, repair, and validation for anime series.
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -11,9 +11,9 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
from src.config.settings import settings
|
from src.config.settings import settings
|
||||||
from src.server.models.nfo import (
|
from src.server.models.nfo import (
|
||||||
NfoDiagnosticsResponse,
|
|
||||||
NfoRepairResponse,
|
NfoRepairResponse,
|
||||||
NfoSeriesDiagnostics,
|
NfoSeriesSettings,
|
||||||
|
NfoSettingsResponse,
|
||||||
)
|
)
|
||||||
from src.server.services.anime_service import AnimeService
|
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
|
||||||
@@ -62,7 +62,7 @@ class NfoNeedsRepairListResponse(BaseModel):
|
|||||||
"""Response listing series that need NFO repair."""
|
"""Response listing series that need NFO repair."""
|
||||||
|
|
||||||
total: int
|
total: int
|
||||||
series: List[NfoSeriesDiagnostics]
|
series: List[NfoSeriesSettings]
|
||||||
|
|
||||||
|
|
||||||
def _get_nfo_path(folder: str) -> str:
|
def _get_nfo_path(folder: str) -> str:
|
||||||
@@ -123,13 +123,13 @@ async def _get_series_data(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{key}/diagnostics", response_model=NfoDiagnosticsResponse)
|
@router.get("/{key}/diagnostics", response_model=NfoSettingsResponse)
|
||||||
async def get_nfo_diagnostics(
|
async def get_nfo_settings(
|
||||||
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:
|
) -> NfoSettingsResponse:
|
||||||
"""Get NFO diagnostics for a specific series.
|
"""Get NFO settings inspection for a specific series.
|
||||||
|
|
||||||
Checks if tvshow.nfo exists in the series folder and validates
|
Checks if tvshow.nfo exists in the series folder and validates
|
||||||
that required tags are present.
|
that required tags are present.
|
||||||
@@ -140,7 +140,7 @@ async def get_nfo_diagnostics(
|
|||||||
anime_service: AnimeService dependency
|
anime_service: AnimeService dependency
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
NfoDiagnosticsResponse with has_nfo, nfo_path, missing_tags, required_tags
|
NfoSettingsResponse with has_nfo, nfo_path, missing_tags, required_tags
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
HTTPException 404: If series not found
|
HTTPException 404: If series not found
|
||||||
@@ -165,7 +165,7 @@ async def get_nfo_diagnostics(
|
|||||||
nfo_exists = os.path.isfile(nfo_path)
|
nfo_exists = os.path.isfile(nfo_path)
|
||||||
|
|
||||||
if not nfo_exists:
|
if not nfo_exists:
|
||||||
return NfoDiagnosticsResponse(
|
return NfoSettingsResponse(
|
||||||
has_nfo=False,
|
has_nfo=False,
|
||||||
nfo_path=None,
|
nfo_path=None,
|
||||||
missing_tags=REQUIRED_TAGS.copy(),
|
missing_tags=REQUIRED_TAGS.copy(),
|
||||||
@@ -175,7 +175,7 @@ async def get_nfo_diagnostics(
|
|||||||
# Parse and check for missing tags
|
# Parse and check for missing tags
|
||||||
_, missing = _parse_nfo_file(nfo_path)
|
_, missing = _parse_nfo_file(nfo_path)
|
||||||
|
|
||||||
return NfoDiagnosticsResponse(
|
return NfoSettingsResponse(
|
||||||
has_nfo=True,
|
has_nfo=True,
|
||||||
nfo_path=nfo_path,
|
nfo_path=nfo_path,
|
||||||
missing_tags=missing,
|
missing_tags=missing,
|
||||||
@@ -184,7 +184,7 @@ async def get_nfo_diagnostics(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/{key}/repair", response_model=NfoRepairResponse)
|
@router.post("/{key}/repair", response_model=NfoRepairResponse)
|
||||||
async def repair_nfo(
|
async def repair_nfo_settings(
|
||||||
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),
|
||||||
@@ -332,12 +332,20 @@ async def _create_or_update_nfo(
|
|||||||
logger.info("Created/updated tvshow.nfo for %s at %s", key, nfo_path)
|
logger.info("Created/updated tvshow.nfo for %s at %s", key, nfo_path)
|
||||||
|
|
||||||
# Update series NFO status in DB
|
# Update series NFO status in DB
|
||||||
await anime_service.update_series_nfo_status(
|
await anime_service.update_nfo_status(
|
||||||
key=key,
|
key=key,
|
||||||
has_nfo=True,
|
has_nfo=True,
|
||||||
nfo_path=nfo_path,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Also update nfo_path in DB (not part of update_nfo_status signature)
|
||||||
|
from src.server.database.connection import get_db_session
|
||||||
|
from src.server.database.service import AnimeSeriesService
|
||||||
|
|
||||||
|
async with get_db_session() as db:
|
||||||
|
series = await AnimeSeriesService.get_by_key(db, key)
|
||||||
|
if series is not None:
|
||||||
|
await AnimeSeriesService.update(db, series.id, nfo_path=nfo_path)
|
||||||
|
|
||||||
# Return list of repaired tags (all tags that were missing before)
|
# Return list of repaired tags (all tags that were missing before)
|
||||||
return missing_before
|
return missing_before
|
||||||
|
|
||||||
@@ -411,7 +419,7 @@ async def get_series_needing_repair(
|
|||||||
NfoNeedsRepairListResponse with total count and list of series diagnostics
|
NfoNeedsRepairListResponse with total count and list of series diagnostics
|
||||||
"""
|
"""
|
||||||
all_series = await anime_service.list_series_with_filters()
|
all_series = await anime_service.list_series_with_filters()
|
||||||
series_needing_repair: List[NfoSeriesDiagnostics] = []
|
series_needing_repair: List[NfoSeriesSettings] = []
|
||||||
|
|
||||||
anime_dir = getattr(settings, "anime_directory", None)
|
anime_dir = getattr(settings, "anime_directory", None)
|
||||||
if not anime_dir:
|
if not anime_dir:
|
||||||
@@ -432,7 +440,7 @@ async def get_series_needing_repair(
|
|||||||
nfo_exists = os.path.isfile(nfo_path)
|
nfo_exists = os.path.isfile(nfo_path)
|
||||||
|
|
||||||
if not nfo_exists:
|
if not nfo_exists:
|
||||||
series_needing_repair.append(NfoSeriesDiagnostics(
|
series_needing_repair.append(NfoSeriesSettings(
|
||||||
key=key,
|
key=key,
|
||||||
name=name,
|
name=name,
|
||||||
folder=folder,
|
folder=folder,
|
||||||
@@ -444,7 +452,7 @@ async def get_series_needing_repair(
|
|||||||
# Parse and check for missing tags
|
# Parse and check for missing tags
|
||||||
_, missing = _parse_nfo_file(nfo_path)
|
_, missing = _parse_nfo_file(nfo_path)
|
||||||
if missing:
|
if missing:
|
||||||
series_needing_repair.append(NfoSeriesDiagnostics(
|
series_needing_repair.append(NfoSeriesSettings(
|
||||||
key=key,
|
key=key,
|
||||||
name=name,
|
name=name,
|
||||||
folder=folder,
|
folder=folder,
|
||||||
|
|||||||
@@ -72,10 +72,27 @@ async def unresolved_page(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/settings/nfo", response_class=HTMLResponse)
|
@router.get("/settings/nfo", response_class=HTMLResponse)
|
||||||
async def nfo_settings_page(request: Request):
|
async def nfo_settings_page_redirect():
|
||||||
"""Serve the NFO diagnostics and repair settings page."""
|
"""Backwards-compatible redirect from the old NFO settings URL.
|
||||||
|
|
||||||
|
Older bookmarks and open tabs may still point at /settings/nfo —
|
||||||
|
redirect them to the new per-anime Anime Settings page.
|
||||||
|
"""
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
|
||||||
|
return RedirectResponse(url="/anime/settings", status_code=301)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/anime/settings", response_class=HTMLResponse)
|
||||||
|
async def anime_settings_page(request: Request):
|
||||||
|
"""Serve the per-anime Anime Settings page.
|
||||||
|
|
||||||
|
Replaces the old NFO Diagnostics page. The same template is used
|
||||||
|
for all series — the active series key is passed via the
|
||||||
|
``?key=...`` query parameter and consumed by the page's JS.
|
||||||
|
"""
|
||||||
return render_template(
|
return render_template(
|
||||||
"nfo-settings.html",
|
"anime-settings.html",
|
||||||
request,
|
request,
|
||||||
title="NFO Diagnostics - Aniworld"
|
title="Anime Settings - Aniworld"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ class AnimeDetailsResponse(BaseModel):
|
|||||||
|
|
||||||
class TMDBSearchResult(BaseModel):
|
class TMDBSearchResult(BaseModel):
|
||||||
"""TMDB search result for auto-lookup functionality.
|
"""TMDB search result for auto-lookup functionality.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
tmdb_id: TMDB ID of the matched series
|
tmdb_id: TMDB ID of the matched series
|
||||||
title: Title from TMDB
|
title: Title from TMDB
|
||||||
@@ -210,3 +210,88 @@ class TMDBSearchResult(BaseModel):
|
|||||||
year: Optional[int] = Field(None, description="Release year")
|
year: Optional[int] = Field(None, description="Release year")
|
||||||
overview: Optional[str] = Field(None, description="Short description")
|
overview: Optional[str] = Field(None, description="Short description")
|
||||||
vote_average: Optional[float] = Field(None, description="TMDB rating")
|
vote_average: Optional[float] = Field(None, description="TMDB rating")
|
||||||
|
|
||||||
|
|
||||||
|
class AnimeSettingsResponse(BaseModel):
|
||||||
|
"""Response payload for the Anime Settings page.
|
||||||
|
|
||||||
|
Surfaces every anime_series field that can be viewed or edited
|
||||||
|
by the user. Used by GET /api/anime/{key}/settings and the
|
||||||
|
PUT response.
|
||||||
|
"""
|
||||||
|
|
||||||
|
key: str = Field(..., description="Series unique key (primary identifier)")
|
||||||
|
name: str = Field(..., description="Series display name")
|
||||||
|
site: str = Field(..., description="Provider site URL")
|
||||||
|
folder: str = Field(..., description="Filesystem folder name")
|
||||||
|
year: Optional[int] = Field(None, description="Release year")
|
||||||
|
tmdb_id: Optional[int] = Field(None, description="TMDB ID")
|
||||||
|
tvdb_id: Optional[int] = Field(None, description="TVDB ID")
|
||||||
|
has_nfo: bool = Field(False, description="Whether tvshow.nfo exists")
|
||||||
|
nfo_path: Optional[str] = Field(None, description="Path to tvshow.nfo file")
|
||||||
|
nfo_created_at: Optional[str] = Field(None, description="ISO timestamp when NFO created")
|
||||||
|
nfo_updated_at: Optional[str] = Field(None, description="ISO timestamp when NFO updated")
|
||||||
|
loading_status: Optional[str] = Field(
|
||||||
|
None, description="Current loading status of the series"
|
||||||
|
)
|
||||||
|
episode_count: int = Field(0, description="Total number of episodes tracked")
|
||||||
|
missing_episode_count: int = Field(0, description="Number of missing episodes")
|
||||||
|
|
||||||
|
|
||||||
|
class AnimeSettingsUpdateRequest(BaseModel):
|
||||||
|
"""Request payload for PUT /api/anime/{key}/settings.
|
||||||
|
|
||||||
|
All fields are optional. Only the fields that are provided will
|
||||||
|
be updated. Field-level validation happens in the API endpoint
|
||||||
|
(e.g. folder sanitization, TMDB ID format).
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
min_length=1,
|
||||||
|
max_length=500,
|
||||||
|
description="Series display name",
|
||||||
|
)
|
||||||
|
folder: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
min_length=1,
|
||||||
|
max_length=1000,
|
||||||
|
description="Filesystem folder name",
|
||||||
|
)
|
||||||
|
tmdb_id: Optional[int] = Field(
|
||||||
|
None,
|
||||||
|
ge=1,
|
||||||
|
le=9999999999,
|
||||||
|
description="TMDB ID (positive integer, max 10 digits)",
|
||||||
|
)
|
||||||
|
tvdb_id: Optional[int] = Field(
|
||||||
|
None,
|
||||||
|
ge=1,
|
||||||
|
le=9999999999,
|
||||||
|
description="TVDB ID (positive integer, max 10 digits)",
|
||||||
|
)
|
||||||
|
site: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
max_length=500,
|
||||||
|
description="Provider site URL",
|
||||||
|
)
|
||||||
|
apply_to_nfo: bool = Field(
|
||||||
|
False,
|
||||||
|
description="If true, regenerate tvshow.nfo with the new values",
|
||||||
|
)
|
||||||
|
rename_disk: bool = Field(
|
||||||
|
False,
|
||||||
|
description="If true and folder changed, rename the folder on disk",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AnimeSettingsRegenerateNfoResponse(BaseModel):
|
||||||
|
"""Response payload for POST /api/anime/{key}/regenerate-nfo."""
|
||||||
|
|
||||||
|
success: bool = Field(..., description="Whether regeneration succeeded")
|
||||||
|
message: str = Field(..., description="Human-readable result message")
|
||||||
|
nfo_path: Optional[str] = Field(None, description="Path to regenerated NFO file")
|
||||||
|
repaired_tags: List[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Tags that were missing before regeneration",
|
||||||
|
)
|
||||||
|
|||||||
@@ -357,8 +357,8 @@ class NFOMissingResponse(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class NfoDiagnosticsResponse(BaseModel):
|
class NfoSettingsResponse(BaseModel):
|
||||||
"""Response for NFO diagnostics showing missing required tags."""
|
"""Response for NFO settings inspection showing missing required tags."""
|
||||||
|
|
||||||
has_nfo: bool = Field(..., description="Whether tvshow.nfo exists")
|
has_nfo: bool = Field(..., description="Whether tvshow.nfo exists")
|
||||||
nfo_path: Optional[str] = Field(None, description="Path to NFO file if exists")
|
nfo_path: Optional[str] = Field(None, description="Path to NFO file if exists")
|
||||||
@@ -372,8 +372,8 @@ class NfoDiagnosticsResponse(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class NfoSeriesDiagnostics(BaseModel):
|
class NfoSeriesSettings(BaseModel):
|
||||||
"""Diagnostics for a single series in the needs-repair list."""
|
"""Settings summary for a single series in the needs-repair list."""
|
||||||
|
|
||||||
key: str = Field(..., description="Series unique key")
|
key: str = Field(..., description="Series unique key")
|
||||||
name: str = Field(..., description="Series display name")
|
name: str = Field(..., description="Series display name")
|
||||||
|
|||||||
226
src/server/web/static/css/pages/anime-settings.css
Normal file
226
src/server/web/static/css/pages/anime-settings.css
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
/* ============================================================
|
||||||
|
Anime Settings Page
|
||||||
|
------------------------------------------------------------
|
||||||
|
Layout and styling for /anime/settings (renamed from
|
||||||
|
/settings/nfo — formerly "NFO Diagnostics").
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.anime-settings-main {
|
||||||
|
padding: 1.5rem;
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header-card {
|
||||||
|
background: var(--color-card-bg, #1f2937);
|
||||||
|
color: var(--color-text, #f3f4f6);
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header-card h2 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badges {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
background: var(--color-badge-bg, #374151);
|
||||||
|
color: var(--color-badge-text, #f9fafb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.status-complete {
|
||||||
|
background: #10b981;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
.status-badge.status-incomplete {
|
||||||
|
background: #f59e0b;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
.status-badge.status-failed {
|
||||||
|
background: #ef4444;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
.status-badge.status-pending {
|
||||||
|
background: #6366f1;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section-card {
|
||||||
|
background: var(--color-card-bg, #1f2937);
|
||||||
|
border: 1px solid var(--color-border, #374151);
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section-card h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 1rem 1.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-field.full-width {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-field label {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-muted, #9ca3af);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-field input.input-field {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid var(--color-border, #4b5563);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--color-input-bg, #111827);
|
||||||
|
color: var(--color-text, #f9fafb);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-field input.input-field:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-accent, #3b82f6);
|
||||||
|
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-field .config-hint {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted, #9ca3af);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-field .config-hint.hint-error {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value-mono {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
background: var(--color-code-bg, #111827);
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
word-break: break-all;
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--color-text, #f3f4f6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-toggles {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px solid var(--color-border, #374151);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nfo-content {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--color-code-bg, #111827);
|
||||||
|
border: 1px solid var(--color-border, #4b5563);
|
||||||
|
border-radius: 6px;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow: auto;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
color: var(--color-text, #e5e7eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-box {
|
||||||
|
background: var(--color-card-bg, #1f2937);
|
||||||
|
border: 1px solid #ef4444;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text, #f3f4f6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-box i {
|
||||||
|
font-size: 2rem;
|
||||||
|
color: #ef4444;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-box h2 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-box p {
|
||||||
|
color: var(--color-text-muted, #9ca3af);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
color: var(--color-text-muted, #9ca3af);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner i {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
display: block;
|
||||||
|
color: var(--color-accent, #3b82f6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.settings-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.anime-settings-main {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,637 +0,0 @@
|
|||||||
/**
|
|
||||||
* AniWorld - NFO Settings Page Styles
|
|
||||||
*
|
|
||||||
* Standalone page for NFO diagnostics, repair, and settings.
|
|
||||||
*/
|
|
||||||
|
|
||||||
.nfo-main-content {
|
|
||||||
padding: var(--spacing-lg);
|
|
||||||
max-width: 1400px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========== Stats Section ========== */
|
|
||||||
.nfo-stats-section {
|
|
||||||
margin-bottom: var(--spacing-xl);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nfo-stats-section .stats-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, 1fr);
|
|
||||||
gap: var(--spacing-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nfo-stats-section .stat-card {
|
|
||||||
background: var(--color-surface);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--border-radius-lg);
|
|
||||||
padding: var(--spacing-lg);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--spacing-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nfo-stats-section .stat-icon {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: var(--color-bg-tertiary);
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nfo-stats-section .stat-success .stat-icon {
|
|
||||||
background: rgba(16, 124, 16, 0.1);
|
|
||||||
color: var(--color-success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nfo-stats-section .stat-warning .stat-icon {
|
|
||||||
background: rgba(255, 140, 0, 0.1);
|
|
||||||
color: var(--color-warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nfo-stats-section .stat-error .stat-icon {
|
|
||||||
background: rgba(209, 52, 56, 0.1);
|
|
||||||
color: var(--color-error);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nfo-stats-section .stat-info {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nfo-stats-section .stat-value {
|
|
||||||
font-size: 28px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nfo-stats-section .stat-label {
|
|
||||||
font-size: var(--font-size-body);
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========== Tab Navigation ========== */
|
|
||||||
.nfo-tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
background: var(--color-bg-secondary);
|
|
||||||
padding: var(--spacing-xs);
|
|
||||||
border-radius: var(--border-radius-lg);
|
|
||||||
margin-bottom: var(--spacing-lg);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nfo-tab {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
padding: var(--spacing-md) var(--spacing-lg);
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-size: var(--font-size-body);
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: var(--border-radius-md);
|
|
||||||
transition: all var(--transition-duration) ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nfo-tab:hover {
|
|
||||||
background: var(--color-surface-hover);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nfo-tab.active {
|
|
||||||
background: var(--color-surface);
|
|
||||||
color: var(--color-primary);
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nfo-tab i {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========== Tab Content ========== */
|
|
||||||
.nfo-tab-content {
|
|
||||||
background: var(--color-surface);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--border-radius-lg);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-panel {
|
|
||||||
display: none;
|
|
||||||
padding: var(--spacing-xl);
|
|
||||||
animation: fadeIn 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-panel.active {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from { opacity: 0; transform: translateY(4px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: var(--spacing-xl);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-header h2 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: var(--font-size-title);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-header h2 i {
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-actions .search-input {
|
|
||||||
width: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-actions .filter-select {
|
|
||||||
min-width: 140px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========== Overview Tab ========== */
|
|
||||||
.overview-content {
|
|
||||||
max-width: 900px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.health-summary {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: var(--spacing-xl);
|
|
||||||
}
|
|
||||||
|
|
||||||
.health-chart {
|
|
||||||
background: var(--color-bg-secondary);
|
|
||||||
border-radius: var(--border-radius-lg);
|
|
||||||
padding: var(--spacing-xl);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: 250px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.health-details h3 {
|
|
||||||
font-size: var(--font-size-subtitle);
|
|
||||||
margin: 0 0 var(--spacing-sm) 0;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.health-details h3:not(:first-child) {
|
|
||||||
margin-top: var(--spacing-xl);
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-text {
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
margin: 0 0 var(--spacing-sm) 0;
|
|
||||||
font-size: var(--font-size-body);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-list {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-list li {
|
|
||||||
padding: var(--spacing-xs) 0;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-size: var(--font-size-body);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-list li code {
|
|
||||||
background: var(--color-bg-tertiary);
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: var(--border-radius-sm);
|
|
||||||
font-family: 'Consolas', 'Monaco', monospace;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.optional-tags {
|
|
||||||
columns: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========== Diagnostics Tab ========== */
|
|
||||||
.series-list-container {
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.series-diagnostics-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: var(--font-size-body);
|
|
||||||
}
|
|
||||||
|
|
||||||
.series-diagnostics-table th,
|
|
||||||
.series-diagnostics-table td {
|
|
||||||
padding: var(--spacing-md);
|
|
||||||
text-align: left;
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.series-diagnostics-table th {
|
|
||||||
background: var(--color-bg-secondary);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.series-diagnostics-table th.sortable {
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.series-diagnostics-table th.sortable:hover {
|
|
||||||
background: var(--color-bg-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.series-diagnostics-table th i {
|
|
||||||
margin-left: var(--spacing-xs);
|
|
||||||
color: var(--color-text-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.series-diagnostics-table tbody tr:hover {
|
|
||||||
background: var(--color-surface-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.series-diagnostics-table .status-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.series-diagnostics-table .status-complete {
|
|
||||||
background: rgba(16, 124, 16, 0.1);
|
|
||||||
color: var(--color-success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.series-diagnostics-table .status-incomplete {
|
|
||||||
background: rgba(255, 140, 0, 0.1);
|
|
||||||
color: var(--color-warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.series-diagnostics-table .status-missing {
|
|
||||||
background: rgba(209, 52, 56, 0.1);
|
|
||||||
color: var(--color-error);
|
|
||||||
}
|
|
||||||
|
|
||||||
.missing-tags-list {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.missing-tag-chip {
|
|
||||||
display: inline-block;
|
|
||||||
background: var(--color-bg-tertiary);
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-family: 'Consolas', 'Monaco', monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nfo-path {
|
|
||||||
font-family: 'Consolas', 'Monaco', monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--color-text-tertiary);
|
|
||||||
max-width: 200px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-row td {
|
|
||||||
text-align: center;
|
|
||||||
padding: var(--spacing-xxl);
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-spinner {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-spinner i {
|
|
||||||
font-size: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========== Repair Tab ========== */
|
|
||||||
.repair-content {
|
|
||||||
max-width: 900px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.repair-info {
|
|
||||||
margin-bottom: var(--spacing-xl);
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-card {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--spacing-md);
|
|
||||||
padding: var(--spacing-lg);
|
|
||||||
background: var(--color-bg-secondary);
|
|
||||||
border-radius: var(--border-radius-lg);
|
|
||||||
border-left: 4px solid var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-card i {
|
|
||||||
font-size: 20px;
|
|
||||||
color: var(--color-primary);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-card h4 {
|
|
||||||
margin: 0 0 var(--spacing-xs) 0;
|
|
||||||
font-size: var(--font-size-subtitle);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-card p {
|
|
||||||
margin: 0;
|
|
||||||
font-size: var(--font-size-body);
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.repair-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
margin-bottom: var(--spacing-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.repair-list-container {
|
|
||||||
margin-bottom: var(--spacing-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.repair-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: var(--font-size-body);
|
|
||||||
}
|
|
||||||
|
|
||||||
.repair-table th,
|
|
||||||
.repair-table td {
|
|
||||||
padding: var(--spacing-md);
|
|
||||||
text-align: left;
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.repair-table th {
|
|
||||||
background: var(--color-bg-secondary);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-col {
|
|
||||||
width: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.repair-table tbody tr:hover {
|
|
||||||
background: var(--color-surface-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.repair-footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding-top: var(--spacing-lg);
|
|
||||||
border-top: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.selection-count {
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-size: var(--font-size-body);
|
|
||||||
}
|
|
||||||
|
|
||||||
.selection-count span {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========== Settings Tab ========== */
|
|
||||||
.settings-content {
|
|
||||||
max-width: 700px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-section {
|
|
||||||
margin-bottom: var(--spacing-xxl);
|
|
||||||
padding-bottom: var(--spacing-xxl);
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-section:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-section h3 {
|
|
||||||
margin: 0 0 var(--spacing-lg) 0;
|
|
||||||
font-size: var(--font-size-subtitle);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-section h3 i {
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting-item {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: var(--spacing-xl);
|
|
||||||
padding: var(--spacing-md) 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting-info {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting-info label {
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
display: block;
|
|
||||||
margin-bottom: var(--spacing-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting-description {
|
|
||||||
margin: 0;
|
|
||||||
font-size: var(--font-size-body);
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting-description a {
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting-control {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting-control .input-field {
|
|
||||||
width: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Toggle Switch */
|
|
||||||
.toggle-switch {
|
|
||||||
position: relative;
|
|
||||||
display: inline-block;
|
|
||||||
width: 48px;
|
|
||||||
height: 26px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-switch input {
|
|
||||||
opacity: 0;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-slider {
|
|
||||||
position: absolute;
|
|
||||||
cursor: pointer;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background-color: var(--color-bg-tertiary);
|
|
||||||
transition: 0.2s;
|
|
||||||
border-radius: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-slider:before {
|
|
||||||
position: absolute;
|
|
||||||
content: "";
|
|
||||||
height: 20px;
|
|
||||||
width: 20px;
|
|
||||||
left: 3px;
|
|
||||||
bottom: 3px;
|
|
||||||
background-color: white;
|
|
||||||
transition: 0.2s;
|
|
||||||
border-radius: 50%;
|
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-switch input:checked + .toggle-slider {
|
|
||||||
background-color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-switch input:checked + .toggle-slider:before {
|
|
||||||
transform: translateX(22px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Connection Status */
|
|
||||||
.connection-status {
|
|
||||||
margin-top: var(--spacing-md);
|
|
||||||
padding: var(--spacing-md);
|
|
||||||
border-radius: var(--border-radius-md);
|
|
||||||
font-size: var(--font-size-body);
|
|
||||||
}
|
|
||||||
|
|
||||||
.connection-status.success {
|
|
||||||
background: rgba(16, 124, 16, 0.1);
|
|
||||||
color: var(--color-success);
|
|
||||||
border: 1px solid var(--color-success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.connection-status.error {
|
|
||||||
background: rgba(209, 52, 56, 0.1);
|
|
||||||
color: var(--color-error);
|
|
||||||
border: 1px solid var(--color-error);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========== Responsive ========== */
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
.nfo-stats-section .stats-grid {
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.health-summary {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@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%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
* AniWorld - Context Menu Component
|
* AniWorld - Context Menu Component
|
||||||
*
|
*
|
||||||
* Right-click context menu for anime series cards.
|
* Right-click context menu for anime series cards.
|
||||||
* Provides quick access to NFO diagnostics.
|
* Provides quick access to per-anime settings.
|
||||||
*
|
*
|
||||||
* Dependencies: ui-utils.js
|
* Dependencies: ui-utils.js
|
||||||
*/
|
*/
|
||||||
@@ -67,9 +67,9 @@ AniWorld.ContextMenu = (function() {
|
|||||||
menuElement = document.createElement('div');
|
menuElement = document.createElement('div');
|
||||||
menuElement.className = 'context-menu';
|
menuElement.className = 'context-menu';
|
||||||
menuElement.innerHTML = `
|
menuElement.innerHTML = `
|
||||||
<div class="context-menu-item" data-action="nfo-diagnostics">
|
<div class="context-menu-item" data-action="anime-settings">
|
||||||
<i class="fa-solid fa-file-circle-check"></i>
|
<i class="fa-solid fa-gear"></i>
|
||||||
<span>NFO Diagnostics</span>
|
<span>Anime Settings</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -96,11 +96,13 @@ AniWorld.ContextMenu = (function() {
|
|||||||
menuElement.style.top = posY + 'px';
|
menuElement.style.top = posY + 'px';
|
||||||
|
|
||||||
// Attach action handlers
|
// Attach action handlers
|
||||||
// NFO Diagnostics - opens the full NFO settings page
|
// Anime Settings - opens the per-anime settings page
|
||||||
menuElement.querySelector('[data-action="nfo-diagnostics"]').addEventListener('click', function() {
|
menuElement.querySelector('[data-action="anime-settings"]').addEventListener('click', function() {
|
||||||
|
// Capture the key BEFORE hide() clears it
|
||||||
|
const key = currentSeriesKey;
|
||||||
hide();
|
hide();
|
||||||
// Navigate to NFO settings page with this series selected
|
// Navigate to anime settings page with this series selected
|
||||||
window.location.href = '/settings/nfo?key=' + encodeURIComponent(currentSeriesKey);
|
window.location.href = '/anime/settings?key=' + encodeURIComponent(key);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
646
src/server/web/static/js/pages/anime-settings.js
Normal file
646
src/server/web/static/js/pages/anime-settings.js
Normal file
@@ -0,0 +1,646 @@
|
|||||||
|
/**
|
||||||
|
* AniWorld - Anime Settings Page Manager
|
||||||
|
*
|
||||||
|
* Handles the per-anime settings page reached via the right-click
|
||||||
|
* context menu. Loads the current settings via
|
||||||
|
* GET /api/anime/{key}/settings and saves changes via
|
||||||
|
* PUT /api/anime/{key}/settings.
|
||||||
|
*
|
||||||
|
* Public API:
|
||||||
|
* - init() : bind DOM events and start initial load
|
||||||
|
* - loadSeries(key) : fetch settings for a series key
|
||||||
|
* - saveSettings(opts) : PUT settings, opts.applyToNfo / opts.renameDisk
|
||||||
|
* - regenerateNfo() : POST regenerate-nfo endpoint
|
||||||
|
* - validateField(name, value) : client-side validation, returns error string or null
|
||||||
|
* - populateForm(data) : fill the form from a payload
|
||||||
|
* - showSaveSuccess(msg) : success toast
|
||||||
|
* - showError(msg) : error toast
|
||||||
|
*
|
||||||
|
* Dependencies: shared/constants.js, shared/auth.js, shared/api-client.js,
|
||||||
|
* shared/ui-utils.js
|
||||||
|
*/
|
||||||
|
var AniWorld = window.AniWorld || {};
|
||||||
|
|
||||||
|
AniWorld.AnimeSettingsManager = (function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// API paths (kept in sync with constants.js)
|
||||||
|
const API_BASE = '/api/anime';
|
||||||
|
const API_NFO_BASE = '/api/nfo';
|
||||||
|
|
||||||
|
// Page state
|
||||||
|
let currentKey = null;
|
||||||
|
let currentData = null;
|
||||||
|
let originalData = null;
|
||||||
|
let elements = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the page — bind events and start the initial load.
|
||||||
|
*/
|
||||||
|
function init() {
|
||||||
|
ensureElements();
|
||||||
|
bindEvents();
|
||||||
|
|
||||||
|
// Read ?key=... from the URL
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
currentKey = url.searchParams.get('key');
|
||||||
|
|
||||||
|
if (currentKey) {
|
||||||
|
loadSeries(currentKey);
|
||||||
|
} else {
|
||||||
|
showNoKey();
|
||||||
|
populateSeriesSelect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache the DOM elements we'll touch repeatedly.
|
||||||
|
* Idempotent — safe to call from public functions that need elements.
|
||||||
|
*/
|
||||||
|
function ensureElements() {
|
||||||
|
if (elements) return;
|
||||||
|
const ids = [
|
||||||
|
'no-key-section', 'loading-section', 'error-section',
|
||||||
|
'settings-section', 'series-select', 'load-series-btn',
|
||||||
|
'retry-btn', 'error-message', 'series-display-name',
|
||||||
|
'badge-loading-status', 'badge-has-nfo', 'badge-episode-counts',
|
||||||
|
'overview-key', 'overview-year', 'overview-loading-status',
|
||||||
|
'overview-episode-count', 'overview-missing-count',
|
||||||
|
'overview-nfo-created', 'overview-nfo-updated', 'overview-nfo-path',
|
||||||
|
'field-name', 'field-folder', 'field-tmdb-id', 'field-tvdb-id',
|
||||||
|
'field-site', 'hint-name', 'hint-folder', 'hint-tmdb-id',
|
||||||
|
'hint-tvdb-id', 'hint-site',
|
||||||
|
'save-db-btn', 'save-db-nfo-btn', 'reset-btn',
|
||||||
|
'rename-disk-toggle',
|
||||||
|
'regenerate-nfo-btn', 'view-nfo-btn', 'nfo-content',
|
||||||
|
];
|
||||||
|
const map = {};
|
||||||
|
ids.forEach(function (id) {
|
||||||
|
map[id] = document.getElementById(id);
|
||||||
|
});
|
||||||
|
elements = map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache the DOM elements we'll touch repeatedly.
|
||||||
|
* @deprecated Use ensureElements() instead.
|
||||||
|
*/
|
||||||
|
function cacheElements() {
|
||||||
|
ensureElements();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire up click handlers and escape-key dismissal.
|
||||||
|
*/
|
||||||
|
function bindEvents() {
|
||||||
|
if (elements['load-series-btn']) {
|
||||||
|
elements['load-series-btn'].addEventListener('click', function () {
|
||||||
|
const v = elements['series-select'].value;
|
||||||
|
if (v) {
|
||||||
|
window.location.href = '/anime/settings?key=' +
|
||||||
|
encodeURIComponent(v);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (elements['retry-btn']) {
|
||||||
|
elements['retry-btn'].addEventListener('click', function () {
|
||||||
|
if (currentKey) {
|
||||||
|
loadSeries(currentKey);
|
||||||
|
} else {
|
||||||
|
showNoKey();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (elements['save-db-btn']) {
|
||||||
|
elements['save-db-btn'].addEventListener('click', function () {
|
||||||
|
saveSettings({ applyToNfo: false });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (elements['save-db-nfo-btn']) {
|
||||||
|
elements['save-db-nfo-btn'].addEventListener('click', function () {
|
||||||
|
saveSettings({ applyToNfo: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (elements['reset-btn']) {
|
||||||
|
elements['reset-btn'].addEventListener('click', function () {
|
||||||
|
if (originalData) {
|
||||||
|
populateForm(originalData);
|
||||||
|
clearValidationHints();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (elements['regenerate-nfo-btn']) {
|
||||||
|
elements['regenerate-nfo-btn'].addEventListener('click',
|
||||||
|
regenerateNfo);
|
||||||
|
}
|
||||||
|
if (elements['view-nfo-btn']) {
|
||||||
|
elements['view-nfo-btn'].addEventListener('click', viewNfoContent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the AnimeSettingsResponse for a series and populate the page.
|
||||||
|
*
|
||||||
|
* @param {string} key - Series unique key
|
||||||
|
*/
|
||||||
|
async function loadSeries(key) {
|
||||||
|
ensureElements();
|
||||||
|
if (!key) {
|
||||||
|
showNoKey();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentKey = key;
|
||||||
|
showLoading();
|
||||||
|
try {
|
||||||
|
const token = AniWorld.Auth && AniWorld.Auth.getToken
|
||||||
|
? AniWorld.Auth.getToken() : null;
|
||||||
|
const headers = token
|
||||||
|
? { 'Authorization': 'Bearer ' + token }
|
||||||
|
: {};
|
||||||
|
const resp = await fetch(
|
||||||
|
API_BASE + '/' + encodeURIComponent(key) + '/settings',
|
||||||
|
{ headers: headers, method: 'GET' }
|
||||||
|
);
|
||||||
|
if (resp.status === 401) {
|
||||||
|
showError('Not authenticated — please log in again.');
|
||||||
|
showErrorState('Authentication required.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (resp.status === 404) {
|
||||||
|
showErrorState('Series not found: ' + key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!resp.ok) {
|
||||||
|
const text = await resp.text();
|
||||||
|
throw new Error('HTTP ' + resp.status + ': ' + text);
|
||||||
|
}
|
||||||
|
const data = await resp.json();
|
||||||
|
currentData = data;
|
||||||
|
// Deep clone for original-data reset
|
||||||
|
originalData = JSON.parse(JSON.stringify(data));
|
||||||
|
populateForm(data);
|
||||||
|
showSettings();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load series settings:', err);
|
||||||
|
showErrorState(err && err.message ? err.message : String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save the current form contents via PUT /api/anime/{key}/settings.
|
||||||
|
*
|
||||||
|
* @param {Object} opts
|
||||||
|
* @param {boolean} opts.applyToNfo - Regenerate tvshow.nfo after save
|
||||||
|
* @param {boolean} [opts.renameDisk] - Also rename folder on disk
|
||||||
|
*/
|
||||||
|
async function saveSettings(opts) {
|
||||||
|
ensureElements();
|
||||||
|
if (!currentKey) {
|
||||||
|
showError('No series selected.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
opts = opts || {};
|
||||||
|
const renameDisk = !!(elements['rename-disk-toggle'] &&
|
||||||
|
elements['rename-disk-toggle'].checked);
|
||||||
|
|
||||||
|
const payload = collectFormPayload();
|
||||||
|
const validationError = validatePayload(payload);
|
||||||
|
if (validationError) {
|
||||||
|
showError(validationError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
payload.apply_to_nfo = !!opts.applyToNfo;
|
||||||
|
payload.rename_disk = renameDisk && payload.folder !== undefined &&
|
||||||
|
payload.folder !== (currentData && currentData.folder);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = AniWorld.Auth && AniWorld.Auth.getToken
|
||||||
|
? AniWorld.Auth.getToken() : null;
|
||||||
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = 'Bearer ' + token;
|
||||||
|
}
|
||||||
|
const resp = await fetch(
|
||||||
|
API_BASE + '/' + encodeURIComponent(currentKey) + '/settings',
|
||||||
|
{
|
||||||
|
headers: headers,
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (resp.status === 401) {
|
||||||
|
showError('Not authenticated — please log in again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (resp.status === 422) {
|
||||||
|
const body = await resp.json().catch(function () { return {}; });
|
||||||
|
showError('Validation failed: ' + (body.detail || resp.status));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!resp.ok) {
|
||||||
|
const text = await resp.text();
|
||||||
|
throw new Error('HTTP ' + resp.status + ': ' + text);
|
||||||
|
}
|
||||||
|
const data = await resp.json();
|
||||||
|
currentData = data;
|
||||||
|
originalData = JSON.parse(JSON.stringify(data));
|
||||||
|
populateForm(data);
|
||||||
|
if (opts.applyToNfo) {
|
||||||
|
showSaveSuccess('Settings saved and tvshow.nfo regenerated.');
|
||||||
|
} else {
|
||||||
|
showSaveSuccess('Settings saved to database.');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save settings:', err);
|
||||||
|
showError('Save failed: ' + (err && err.message ? err.message : err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call POST /api/anime/{key}/regenerate-nfo to regenerate tvshow.nfo.
|
||||||
|
*/
|
||||||
|
async function regenerateNfo() {
|
||||||
|
ensureElements();
|
||||||
|
if (!currentKey) {
|
||||||
|
showError('No series selected.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const token = AniWorld.Auth && AniWorld.Auth.getToken
|
||||||
|
? AniWorld.Auth.getToken() : null;
|
||||||
|
const headers = {};
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = 'Bearer ' + token;
|
||||||
|
}
|
||||||
|
const resp = await fetch(
|
||||||
|
API_BASE + '/' + encodeURIComponent(currentKey) +
|
||||||
|
'/regenerate-nfo',
|
||||||
|
{ headers: headers, method: 'POST' }
|
||||||
|
);
|
||||||
|
if (resp.status === 400) {
|
||||||
|
const body = await resp.json().catch(function () { return {}; });
|
||||||
|
showError('Cannot regenerate: ' + (body.detail || resp.status));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (resp.status === 404) {
|
||||||
|
showError('Series not found.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!resp.ok) {
|
||||||
|
const text = await resp.text();
|
||||||
|
throw new Error('HTTP ' + resp.status + ': ' + text);
|
||||||
|
}
|
||||||
|
const data = await resp.json();
|
||||||
|
showSaveSuccess(data.message || 'NFO regenerated.');
|
||||||
|
// Refresh data so the NFO badges update
|
||||||
|
loadSeries(currentKey);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('NFO regeneration failed:', err);
|
||||||
|
showError('Regenerate failed: ' +
|
||||||
|
(err && err.message ? err.message : err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch and display the raw tvshow.nfo XML in a <pre>.
|
||||||
|
*/
|
||||||
|
async function viewNfoContent() {
|
||||||
|
if (!currentKey) {
|
||||||
|
showError('No series selected.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const token = AniWorld.Auth && AniWorld.Auth.getToken
|
||||||
|
? AniWorld.Auth.getToken() : null;
|
||||||
|
const headers = { 'Accept': 'application/json' };
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = 'Bearer ' + token;
|
||||||
|
}
|
||||||
|
const resp = await fetch(
|
||||||
|
API_NFO_BASE + '/' + encodeURIComponent(currentKey) + '/content',
|
||||||
|
{ headers: headers, method: 'GET' }
|
||||||
|
);
|
||||||
|
if (!resp.ok) {
|
||||||
|
const text = await resp.text();
|
||||||
|
throw new Error('HTTP ' + resp.status + ': ' + text);
|
||||||
|
}
|
||||||
|
const data = await resp.json();
|
||||||
|
const pre = elements['nfo-content'];
|
||||||
|
if (pre) {
|
||||||
|
pre.textContent = data.content || JSON.stringify(data, null, 2);
|
||||||
|
pre.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch NFO content:', err);
|
||||||
|
showError('Could not fetch NFO content: ' +
|
||||||
|
(err && err.message ? err.message : err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a single field. Returns null if valid, or an error message.
|
||||||
|
*
|
||||||
|
* @param {string} name Field name (name, folder, tmdb_id, tvdb_id, site)
|
||||||
|
* @param {*} value Value from the form
|
||||||
|
* @returns {string|null}
|
||||||
|
*/
|
||||||
|
function validateField(name, value) {
|
||||||
|
switch (name) {
|
||||||
|
case 'name':
|
||||||
|
if (value === '' || value == null) {
|
||||||
|
return 'Name cannot be empty.';
|
||||||
|
}
|
||||||
|
if (typeof value === 'string' && value.length > 500) {
|
||||||
|
return 'Name exceeds 500 characters.';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
case 'folder':
|
||||||
|
if (value === '' || value == null) {
|
||||||
|
return 'Folder cannot be empty.';
|
||||||
|
}
|
||||||
|
if (typeof value === 'string' && /\.\./.test(value)) {
|
||||||
|
return 'Folder name cannot contain ".." (path traversal).';
|
||||||
|
}
|
||||||
|
if (typeof value === 'string' && /[<>:"|?*\x00]/.test(value)) {
|
||||||
|
return 'Folder name contains invalid characters.';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
case 'tmdb_id':
|
||||||
|
if (value === '' || value == null || value === undefined) {
|
||||||
|
return null; // optional
|
||||||
|
}
|
||||||
|
const tmdb = Number(value);
|
||||||
|
if (!Number.isFinite(tmdb) || !Number.isInteger(tmdb)) {
|
||||||
|
return 'TMDB ID must be an integer.';
|
||||||
|
}
|
||||||
|
if (tmdb <= 0) {
|
||||||
|
return 'TMDB ID must be a positive integer.';
|
||||||
|
}
|
||||||
|
if (tmdb > 9999999999) {
|
||||||
|
return 'TMDB ID exceeds 10 digits.';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
case 'tvdb_id':
|
||||||
|
if (value === '' || value == null || value === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const tvdb = Number(value);
|
||||||
|
if (!Number.isFinite(tvdb) || !Number.isInteger(tvdb)) {
|
||||||
|
return 'TVDB ID must be an integer.';
|
||||||
|
}
|
||||||
|
if (tvdb <= 0) {
|
||||||
|
return 'TVDB ID must be a positive integer.';
|
||||||
|
}
|
||||||
|
if (tvdb > 9999999999) {
|
||||||
|
return 'TVDB ID exceeds 10 digits.';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
case 'site':
|
||||||
|
if (value && typeof value === 'string' && value.length > 500) {
|
||||||
|
return 'Site URL exceeds 500 characters.';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the whole payload. Returns null if all fields valid, or the
|
||||||
|
* first error message encountered.
|
||||||
|
*
|
||||||
|
* @param {Object} payload
|
||||||
|
* @returns {string|null}
|
||||||
|
*/
|
||||||
|
function validatePayload(payload) {
|
||||||
|
const fields = ['name', 'folder', 'tmdb_id', 'tvdb_id', 'site'];
|
||||||
|
for (let i = 0; i < fields.length; i++) {
|
||||||
|
const name = fields[i];
|
||||||
|
if (payload[name] === undefined) continue;
|
||||||
|
const err = validateField(name, payload[name]);
|
||||||
|
if (err) return name + ': ' + err;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Populate the form from a settings payload.
|
||||||
|
*
|
||||||
|
* @param {Object} data AnimeSettingsResponse dict
|
||||||
|
*/
|
||||||
|
function populateForm(data) {
|
||||||
|
ensureElements();
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
// Overview
|
||||||
|
setText(elements['series-display-name'], data.name || '(unnamed)');
|
||||||
|
setText(elements['overview-key'], data.key || '—');
|
||||||
|
setText(elements['overview-year'], data.year || '—');
|
||||||
|
setText(elements['overview-loading-status'],
|
||||||
|
data.loading_status || '—');
|
||||||
|
setText(elements['overview-episode-count'],
|
||||||
|
data.episode_count != null ? String(data.episode_count) : '—');
|
||||||
|
setText(elements['overview-missing-count'],
|
||||||
|
data.missing_episode_count != null
|
||||||
|
? String(data.missing_episode_count) : '—');
|
||||||
|
setText(elements['overview-nfo-created'],
|
||||||
|
data.nfo_created_at || '—');
|
||||||
|
setText(elements['overview-nfo-updated'],
|
||||||
|
data.nfo_updated_at || '—');
|
||||||
|
setText(elements['overview-nfo-path'], data.nfo_path || '—');
|
||||||
|
|
||||||
|
// Badges
|
||||||
|
const lstatus = elements['badge-loading-status'];
|
||||||
|
if (lstatus) {
|
||||||
|
lstatus.textContent = 'Loading: ' + (data.loading_status || '—');
|
||||||
|
lstatus.className = 'status-badge ' +
|
||||||
|
(data.loading_status === 'completed'
|
||||||
|
? 'status-complete'
|
||||||
|
: data.loading_status === 'failed'
|
||||||
|
? 'status-failed'
|
||||||
|
: 'status-pending');
|
||||||
|
}
|
||||||
|
const nfoBadge = elements['badge-has-nfo'];
|
||||||
|
if (nfoBadge) {
|
||||||
|
nfoBadge.textContent = data.has_nfo ? 'NFO ✓' : 'NFO ✗';
|
||||||
|
nfoBadge.className = 'status-badge ' +
|
||||||
|
(data.has_nfo ? 'status-complete' : 'status-incomplete');
|
||||||
|
}
|
||||||
|
const epBadge = elements['badge-episode-counts'];
|
||||||
|
if (epBadge) {
|
||||||
|
epBadge.textContent =
|
||||||
|
(data.missing_episode_count || 0) + ' / ' +
|
||||||
|
(data.episode_count || 0) + ' missing';
|
||||||
|
epBadge.className = 'status-badge';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Editable inputs
|
||||||
|
setValue(elements['field-name'], data.name || '');
|
||||||
|
setValue(elements['field-folder'], data.folder || '');
|
||||||
|
setValue(elements['field-tmdb-id'],
|
||||||
|
data.tmdb_id != null ? data.tmdb_id : '');
|
||||||
|
setValue(elements['field-tvdb-id'],
|
||||||
|
data.tvdb_id != null ? data.tvdb_id : '');
|
||||||
|
setValue(elements['field-site'], data.site || '');
|
||||||
|
|
||||||
|
clearValidationHints();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect current form values into a partial payload (omits empty
|
||||||
|
* string / null fields so the server treats them as no-change).
|
||||||
|
*/
|
||||||
|
function collectFormPayload() {
|
||||||
|
const payload = {};
|
||||||
|
const setIfPresent = function (key, raw) {
|
||||||
|
if (raw === undefined || raw === null) return;
|
||||||
|
const trimmed = typeof raw === 'string' ? raw.trim() : raw;
|
||||||
|
if (trimmed === '' || trimmed === null) return;
|
||||||
|
payload[key] = typeof raw === 'string' ? trimmed : raw;
|
||||||
|
};
|
||||||
|
setIfPresent('name', elements['field-name'].value);
|
||||||
|
setIfPresent('folder', elements['field-folder'].value);
|
||||||
|
setIfPresent('tmdb_id', elements['field-tmdb-id'].value);
|
||||||
|
setIfPresent('tvdb_id', elements['field-tvdb-id'].value);
|
||||||
|
setIfPresent('site', elements['field-site'].value);
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Populate the series-select dropdown with options for keys without
|
||||||
|
* ?key=... in the URL.
|
||||||
|
*/
|
||||||
|
async function populateSeriesSelect() {
|
||||||
|
const select = elements['series-select'];
|
||||||
|
if (!select) return;
|
||||||
|
select.innerHTML = '<option value="">Loading…</option>';
|
||||||
|
try {
|
||||||
|
const token = AniWorld.Auth && AniWorld.Auth.getToken
|
||||||
|
? AniWorld.Auth.getToken() : null;
|
||||||
|
const headers = token
|
||||||
|
? { 'Authorization': 'Bearer ' + token }
|
||||||
|
: {};
|
||||||
|
const resp = await fetch(API_BASE + '?per_page=500', {
|
||||||
|
headers: headers, method: 'GET',
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
select.innerHTML = '<option value="">Failed to load series</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const list = await resp.json();
|
||||||
|
select.innerHTML = '<option value="">Select a series…</option>' +
|
||||||
|
list.map(function (s) {
|
||||||
|
return '<option value="' + escapeHtml(s.key) + '">' +
|
||||||
|
escapeHtml(s.name || s.key) + '</option>';
|
||||||
|
}).join('');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to populate series select:', err);
|
||||||
|
select.innerHTML = '<option value="">Failed to load series</option>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a success toast via shared UI utilities.
|
||||||
|
*/
|
||||||
|
function showSaveSuccess(msg) {
|
||||||
|
if (AniWorld.UiUtils && AniWorld.UiUtils.showToast) {
|
||||||
|
AniWorld.UiUtils.showToast(msg, 'success');
|
||||||
|
} else {
|
||||||
|
console.info('[AnimeSettings] ' + msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show an error toast via shared UI utilities.
|
||||||
|
*/
|
||||||
|
function showError(msg) {
|
||||||
|
if (AniWorld.UiUtils && AniWorld.UiUtils.showToast) {
|
||||||
|
AniWorld.UiUtils.showToast(msg, 'error');
|
||||||
|
} else {
|
||||||
|
console.error('[AnimeSettings] ' + msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// View-state helpers --------------------------------------------------
|
||||||
|
|
||||||
|
function showLoading() {
|
||||||
|
showOnly('loading-section');
|
||||||
|
}
|
||||||
|
function showSettings() {
|
||||||
|
showOnly('settings-section');
|
||||||
|
}
|
||||||
|
function showNoKey() {
|
||||||
|
showOnly('no-key-section');
|
||||||
|
}
|
||||||
|
function showErrorState(msg) {
|
||||||
|
showOnly('error-section');
|
||||||
|
if (elements['error-message']) {
|
||||||
|
elements['error-message'].textContent = msg || 'Unknown error.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function showOnly(id) {
|
||||||
|
const sections = ['no-key-section', 'loading-section',
|
||||||
|
'error-section', 'settings-section'];
|
||||||
|
sections.forEach(function (s) {
|
||||||
|
const el = document.getElementById(s);
|
||||||
|
if (!el) return;
|
||||||
|
if (s === id) {
|
||||||
|
el.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
el.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearValidationHints() {
|
||||||
|
['hint-name', 'hint-folder', 'hint-tmdb-id',
|
||||||
|
'hint-tvdb-id', 'hint-site'].forEach(function (id) {
|
||||||
|
const el = elements[id];
|
||||||
|
if (el) {
|
||||||
|
el.textContent = '';
|
||||||
|
el.classList.remove('hint-error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setText(el, text) {
|
||||||
|
if (el) el.textContent = text;
|
||||||
|
}
|
||||||
|
function setValue(el, text) {
|
||||||
|
if (el) el.value = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
if (s == null) return '';
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public API ----------------------------------------------------------
|
||||||
|
|
||||||
|
return {
|
||||||
|
init: init,
|
||||||
|
loadSeries: loadSeries,
|
||||||
|
saveSettings: saveSettings,
|
||||||
|
regenerateNfo: regenerateNfo,
|
||||||
|
validateField: validateField,
|
||||||
|
populateForm: populateForm,
|
||||||
|
showSaveSuccess: showSaveSuccess,
|
||||||
|
showError: showError,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Bootstrap on DOMContentLoaded — only register the listener.
|
||||||
|
// Tests call AnimeSettingsManager.init() explicitly after seeding the DOM.
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
if (AniWorld.AnimeSettingsManager && AniWorld.AnimeSettingsManager.init) {
|
||||||
|
AniWorld.AnimeSettingsManager.init();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,804 +0,0 @@
|
|||||||
/**
|
|
||||||
* AniWorld - NFO Settings Page
|
|
||||||
*
|
|
||||||
* Handles NFO diagnostics, repair operations, and settings
|
|
||||||
* for the dedicated NFO settings page.
|
|
||||||
*/
|
|
||||||
|
|
||||||
(function() {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const API = {
|
|
||||||
NFO_DIAGNOSTICS: '/api/nfo',
|
|
||||||
NFO_REPAIR: '/api/nfo',
|
|
||||||
NFO_NEEDS_REPAIR: '/api/nfo/needs-repair',
|
|
||||||
NFO_BATCH_REPAIR: '/api/nfo/batch/repair',
|
|
||||||
CONFIG: '/api/config',
|
|
||||||
ANIME_LIST: '/api/anime'
|
|
||||||
};
|
|
||||||
|
|
||||||
// State
|
|
||||||
let allDiagnostics = [];
|
|
||||||
let seriesNeedingRepair = [];
|
|
||||||
let selectedForRepair = new Set();
|
|
||||||
|
|
||||||
// DOM Elements
|
|
||||||
const elements = {
|
|
||||||
// Stats
|
|
||||||
totalSeries: document.getElementById('total-series'),
|
|
||||||
completeSeries: document.getElementById('complete-series'),
|
|
||||||
incompleteSeries: document.getElementById('incomplete-series'),
|
|
||||||
missingSeries: document.getElementById('missing-series'),
|
|
||||||
|
|
||||||
// Tabs
|
|
||||||
tabButtons: document.querySelectorAll('.nfo-tab'),
|
|
||||||
tabPanels: document.querySelectorAll('.tab-panel'),
|
|
||||||
|
|
||||||
// Overview
|
|
||||||
refreshOverview: document.getElementById('btn-refresh-overview'),
|
|
||||||
|
|
||||||
// Diagnostics
|
|
||||||
diagnosticsSearch: document.getElementById('diagnostics-search'),
|
|
||||||
filterStatus: document.getElementById('filter-status'),
|
|
||||||
refreshDiagnostics: document.getElementById('btn-refresh-diagnostics'),
|
|
||||||
diagnosticsTableBody: document.getElementById('diagnostics-table-body'),
|
|
||||||
|
|
||||||
// Repair
|
|
||||||
selectAllRepair: document.getElementById('btn-select-all-repair'),
|
|
||||||
clearSelection: document.getElementById('btn-clear-selection'),
|
|
||||||
repairTableBody: document.getElementById('repair-table-body'),
|
|
||||||
selectAllRepairCheckbox: document.getElementById('select-all-repair'),
|
|
||||||
selectedCount: document.getElementById('selected-count'),
|
|
||||||
batchRepair: document.getElementById('btn-batch-repair'),
|
|
||||||
|
|
||||||
// Settings
|
|
||||||
tmdbApiKey: document.getElementById('tmdb-api-key'),
|
|
||||||
btnTestTmdb: document.getElementById('btn-test-tmdb'),
|
|
||||||
tmdbStatus: document.getElementById('tmdb-status'),
|
|
||||||
nfoAutoCreate: document.getElementById('nfo-auto-create'),
|
|
||||||
nfoUpdateOnScan: document.getElementById('nfo-update-on-scan'),
|
|
||||||
nfoDownloadPoster: document.getElementById('nfo-download-poster'),
|
|
||||||
nfoDownloadFanart: document.getElementById('nfo-download-fanart'),
|
|
||||||
nfoDownloadLogo: document.getElementById('nfo-download-logo'),
|
|
||||||
saveNfoSettings: document.getElementById('btn-save-nfo-settings'),
|
|
||||||
|
|
||||||
// General
|
|
||||||
loadingOverlay: document.getElementById('loading-overlay'),
|
|
||||||
toastContainer: document.getElementById('toast-container')
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize the page
|
|
||||||
*/
|
|
||||||
function init() {
|
|
||||||
setupTabNavigation();
|
|
||||||
setupEventListeners();
|
|
||||||
loadInitialData();
|
|
||||||
initTheme();
|
|
||||||
initAuth();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Setup tab navigation
|
|
||||||
*/
|
|
||||||
function setupTabNavigation() {
|
|
||||||
elements.tabButtons.forEach(button => {
|
|
||||||
button.addEventListener('click', () => {
|
|
||||||
const tabId = button.dataset.tab;
|
|
||||||
switchTab(tabId);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Switch to a specific tab
|
|
||||||
*/
|
|
||||||
function switchTab(tabId) {
|
|
||||||
elements.tabButtons.forEach(btn => {
|
|
||||||
btn.classList.toggle('active', btn.dataset.tab === tabId);
|
|
||||||
});
|
|
||||||
elements.tabPanels.forEach(panel => {
|
|
||||||
panel.classList.toggle('active', panel.id === `tab-${tabId}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load data for the active tab
|
|
||||||
if (tabId === 'overview') {
|
|
||||||
loadOverviewData();
|
|
||||||
} else if (tabId === 'diagnostics') {
|
|
||||||
loadDiagnostics();
|
|
||||||
} else if (tabId === 'repair') {
|
|
||||||
loadRepairList();
|
|
||||||
} else if (tabId === 'settings') {
|
|
||||||
loadSettings();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Setup event listeners
|
|
||||||
*/
|
|
||||||
function setupEventListeners() {
|
|
||||||
// Overview
|
|
||||||
elements.refreshOverview?.addEventListener('click', loadOverviewData);
|
|
||||||
|
|
||||||
// Diagnostics
|
|
||||||
elements.diagnosticsSearch?.addEventListener('input', filterDiagnostics);
|
|
||||||
elements.filterStatus?.addEventListener('change', filterDiagnostics);
|
|
||||||
elements.refreshDiagnostics?.addEventListener('click', loadDiagnostics);
|
|
||||||
|
|
||||||
// Repair
|
|
||||||
elements.selectAllRepair?.addEventListener('click', selectAllForRepair);
|
|
||||||
elements.clearSelection?.addEventListener('click', clearRepairSelection);
|
|
||||||
elements.selectAllRepairCheckbox?.addEventListener('change', toggleSelectAllRepair);
|
|
||||||
elements.batchRepair?.addEventListener('click', performBatchRepair);
|
|
||||||
|
|
||||||
// Settings
|
|
||||||
elements.btnTestTmdb?.addEventListener('click', testTmdbConnection);
|
|
||||||
elements.saveNfoSettings?.addEventListener('click', saveSettings);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load initial data
|
|
||||||
*/
|
|
||||||
function loadInitialData() {
|
|
||||||
loadOverviewData();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load overview data (stats)
|
|
||||||
*/
|
|
||||||
async function loadOverviewData() {
|
|
||||||
try {
|
|
||||||
// Get needs-repair data which includes all series diagnostics
|
|
||||||
const response = await fetch(API.NFO_NEEDS_REPAIR, {
|
|
||||||
headers: getAuthHeaders()
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to load overview data');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
allDiagnostics = data.series || [];
|
|
||||||
|
|
||||||
// Calculate stats
|
|
||||||
const total = allDiagnostics.length;
|
|
||||||
const missing = allDiagnostics.filter(s => !s.has_nfo).length;
|
|
||||||
const incomplete = allDiagnostics.filter(s => s.has_nfo && s.missing_tags && s.missing_tags.length > 0).length;
|
|
||||||
const complete = total - incomplete - missing;
|
|
||||||
|
|
||||||
updateStats({
|
|
||||||
total,
|
|
||||||
complete,
|
|
||||||
incomplete,
|
|
||||||
missing
|
|
||||||
});
|
|
||||||
|
|
||||||
// Also update repair list for batch repair
|
|
||||||
seriesNeedingRepair = allDiagnostics.filter(s => !s.has_nfo || (s.missing_tags && s.missing_tags.length > 0));
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading overview:', error);
|
|
||||||
showToast('Failed to load overview data', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update statistics display
|
|
||||||
*/
|
|
||||||
function updateStats(stats) {
|
|
||||||
if (elements.totalSeries) elements.totalSeries.textContent = stats.total;
|
|
||||||
if (elements.completeSeries) elements.completeSeries.textContent = stats.complete;
|
|
||||||
if (elements.incompleteSeries) elements.incompleteSeries.textContent = stats.incomplete;
|
|
||||||
if (elements.missingSeries) elements.missingSeries.textContent = stats.missing;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load diagnostics list
|
|
||||||
*/
|
|
||||||
async function loadDiagnostics() {
|
|
||||||
if (!elements.diagnosticsTableBody) return;
|
|
||||||
|
|
||||||
showLoading(elements.diagnosticsTableBody, 'Loading diagnostics...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(API.NFO_NEEDS_REPAIR, {
|
|
||||||
headers: getAuthHeaders()
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to load diagnostics');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
allDiagnostics = data.series || [];
|
|
||||||
|
|
||||||
renderDiagnosticsTable(allDiagnostics);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading diagnostics:', error);
|
|
||||||
showToast('Failed to load diagnostics', 'error');
|
|
||||||
elements.diagnosticsTableBody.innerHTML = `
|
|
||||||
<tr>
|
|
||||||
<td colspan="5" class="error-message">
|
|
||||||
Failed to load diagnostics. Please try again.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render diagnostics table
|
|
||||||
*/
|
|
||||||
function renderDiagnosticsTable(series) {
|
|
||||||
if (!elements.diagnosticsTableBody) return;
|
|
||||||
|
|
||||||
if (series.length === 0) {
|
|
||||||
elements.diagnosticsTableBody.innerHTML = `
|
|
||||||
<tr>
|
|
||||||
<td colspan="5" class="empty-message">
|
|
||||||
No series found.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
elements.diagnosticsTableBody.innerHTML = series.map(s => {
|
|
||||||
const status = getStatus(s);
|
|
||||||
const statusClass = status.toLowerCase();
|
|
||||||
const statusIcon = getStatusIcon(status);
|
|
||||||
const missingTags = s.missing_tags || [];
|
|
||||||
const missingTagsHtml = missingTags.length > 0
|
|
||||||
? missingTags.map(tag => `<span class="missing-tag-chip">${escapeHtml(tag)}</span>`).join('')
|
|
||||||
: '<span class="all-good">All present</span>';
|
|
||||||
const nfoPath = s.folder ? `${escapeHtml(s.folder)}/tvshow.nfo` : '-';
|
|
||||||
|
|
||||||
return `
|
|
||||||
<tr data-key="${escapeHtml(s.key)}">
|
|
||||||
<td class="name-cell">
|
|
||||||
<div class="series-name">${escapeHtml(s.name || s.key)}</div>
|
|
||||||
<div class="series-key">${escapeHtml(s.key)}</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span class="status-badge ${statusClass}">
|
|
||||||
${statusIcon}
|
|
||||||
${status}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="tags-cell">${missingTagsHtml}</td>
|
|
||||||
<td class="path-cell"><span class="nfo-path" title="${nfoPath}">${nfoPath}</span></td>
|
|
||||||
<td class="actions-cell">
|
|
||||||
<button class="btn btn-xs btn-secondary" onclick="NfoSettings.repairSingle('${escapeHtml(s.key)}')">
|
|
||||||
<i class="fas fa-wrench"></i> Repair
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get status for a series
|
|
||||||
*/
|
|
||||||
function getStatus(series) {
|
|
||||||
if (!series.has_nfo) return 'Missing';
|
|
||||||
if (series.missing_tags && series.missing_tags.length > 0) return 'Incomplete';
|
|
||||||
return 'Complete';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get status icon
|
|
||||||
*/
|
|
||||||
function getStatusIcon(status) {
|
|
||||||
switch (status.toLowerCase()) {
|
|
||||||
case 'complete':
|
|
||||||
return '<i class="fas fa-check-circle"></i>';
|
|
||||||
case 'incomplete':
|
|
||||||
return '<i class="fas fa-exclamation-circle"></i>';
|
|
||||||
case 'missing':
|
|
||||||
return '<i class="fas fa-times-circle"></i>';
|
|
||||||
default:
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Filter diagnostics based on search and status
|
|
||||||
*/
|
|
||||||
function filterDiagnostics() {
|
|
||||||
const searchTerm = (elements.diagnosticsSearch?.value || '').toLowerCase();
|
|
||||||
const statusFilter = elements.filterStatus?.value || 'all';
|
|
||||||
|
|
||||||
let filtered = allDiagnostics;
|
|
||||||
|
|
||||||
// Filter by search term
|
|
||||||
if (searchTerm) {
|
|
||||||
filtered = filtered.filter(s =>
|
|
||||||
(s.name || '').toLowerCase().includes(searchTerm) ||
|
|
||||||
s.key.toLowerCase().includes(searchTerm)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by status
|
|
||||||
if (statusFilter !== 'all') {
|
|
||||||
filtered = filtered.filter(s => {
|
|
||||||
const status = getStatus(s).toLowerCase();
|
|
||||||
return status === statusFilter;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
renderDiagnosticsTable(filtered);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load repair list
|
|
||||||
*/
|
|
||||||
async function loadRepairList() {
|
|
||||||
if (!elements.repairTableBody) return;
|
|
||||||
|
|
||||||
showLoading(elements.repairTableBody, 'Loading series needing repair...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(API.NFO_NEEDS_REPAIR, {
|
|
||||||
headers: getAuthHeaders()
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to load repair list');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
seriesNeedingRepair = (data.series || []).filter(s =>
|
|
||||||
!s.has_nfo || (s.missing_tags && s.missing_tags.length > 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
renderRepairTable(seriesNeedingRepair);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading repair list:', error);
|
|
||||||
showToast('Failed to load repair list', 'error');
|
|
||||||
elements.repairTableBody.innerHTML = `
|
|
||||||
<tr>
|
|
||||||
<td colspan="5" class="error-message">
|
|
||||||
Failed to load repair list. Please try again.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render repair table
|
|
||||||
*/
|
|
||||||
function renderRepairTable(series) {
|
|
||||||
if (!elements.repairTableBody) return;
|
|
||||||
|
|
||||||
if (series.length === 0) {
|
|
||||||
elements.repairTableBody.innerHTML = `
|
|
||||||
<tr>
|
|
||||||
<td colspan="5" class="empty-message">
|
|
||||||
All series have complete NFO files!
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
elements.repairTableBody.innerHTML = series.map(s => {
|
|
||||||
const status = getStatus(s);
|
|
||||||
const statusClass = status.toLowerCase();
|
|
||||||
const isSelected = selectedForRepair.has(s.key);
|
|
||||||
const hasTmdbId = s.tmdb_id && s.tmdb_id > 0;
|
|
||||||
|
|
||||||
return `
|
|
||||||
<tr data-key="${escapeHtml(s.key)}">
|
|
||||||
<td class="checkbox-col">
|
|
||||||
<input type="checkbox" class="repair-checkbox"
|
|
||||||
data-key="${escapeHtml(s.key)}"
|
|
||||||
${isSelected ? 'checked' : ''}>
|
|
||||||
</td>
|
|
||||||
<td class="name-cell">
|
|
||||||
<div class="series-name">${escapeHtml(s.name || s.key)}</div>
|
|
||||||
<div class="series-key">${escapeHtml(s.key)}</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span class="status-badge ${statusClass}">
|
|
||||||
${status}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="tmdb-cell">
|
|
||||||
${hasTmdbId ? `<span class="tmdb-id">${s.tmdb_id}</span>` : '<span class="no-tmdb">No TMDB ID</span>'}
|
|
||||||
</td>
|
|
||||||
<td class="priority-cell">
|
|
||||||
${!s.has_nfo ? '<span class="priority high">High</span>' : '<span class="priority normal">Normal</span>'}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
// Attach checkbox listeners
|
|
||||||
elements.repairTableBody.querySelectorAll('.repair-checkbox').forEach(cb => {
|
|
||||||
cb.addEventListener('change', (e) => {
|
|
||||||
const key = e.target.dataset.key;
|
|
||||||
if (e.target.checked) {
|
|
||||||
selectedForRepair.add(key);
|
|
||||||
} else {
|
|
||||||
selectedForRepair.delete(key);
|
|
||||||
}
|
|
||||||
updateSelectedCount();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
updateSelectedCount();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Select all series for repair
|
|
||||||
*/
|
|
||||||
function selectAllForRepair() {
|
|
||||||
seriesNeedingRepair.forEach(s => selectedForRepair.add(s.key));
|
|
||||||
updateRepairCheckboxes();
|
|
||||||
updateSelectedCount();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear repair selection
|
|
||||||
*/
|
|
||||||
function clearRepairSelection() {
|
|
||||||
selectedForRepair.clear();
|
|
||||||
updateRepairCheckboxes();
|
|
||||||
updateSelectedCount();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggle select all repair checkboxes
|
|
||||||
*/
|
|
||||||
function toggleSelectAllRepair(e) {
|
|
||||||
if (e.target.checked) {
|
|
||||||
selectAllForRepair();
|
|
||||||
} else {
|
|
||||||
clearRepairSelection();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update repair checkboxes based on selection
|
|
||||||
*/
|
|
||||||
function updateRepairCheckboxes() {
|
|
||||||
if (!elements.repairTableBody) return;
|
|
||||||
elements.repairTableBody.querySelectorAll('.repair-checkbox').forEach(cb => {
|
|
||||||
cb.checked = selectedForRepair.has(cb.dataset.key);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update selected count display
|
|
||||||
*/
|
|
||||||
function updateSelectedCount() {
|
|
||||||
if (elements.selectedCount) {
|
|
||||||
elements.selectedCount.textContent = selectedForRepair.size;
|
|
||||||
}
|
|
||||||
if (elements.batchRepair) {
|
|
||||||
elements.batchRepair.disabled = selectedForRepair.size === 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Perform batch repair
|
|
||||||
*/
|
|
||||||
async function performBatchRepair() {
|
|
||||||
if (selectedForRepair.size === 0) {
|
|
||||||
showToast('No series selected for repair', 'warning');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const keys = Array.from(selectedForRepair);
|
|
||||||
|
|
||||||
showLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(API.NFO_BATCH_REPAIR, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
...getAuthHeaders(),
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(keys)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Batch repair failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
showToast(
|
|
||||||
`Repair complete: ${result.success} succeeded, ${result.failed} failed`,
|
|
||||||
result.failed > 0 ? 'warning' : 'success'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Clear selection and reload
|
|
||||||
selectedForRepair.clear();
|
|
||||||
updateSelectedCount();
|
|
||||||
loadRepairList();
|
|
||||||
loadOverviewData();
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error performing batch repair:', error);
|
|
||||||
showToast('Batch repair failed', 'error');
|
|
||||||
} finally {
|
|
||||||
showLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Repair a single series (global function for onclick)
|
|
||||||
*/
|
|
||||||
async function repairSingle(key) {
|
|
||||||
showLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API.NFO_REPAIR}/${encodeURIComponent(key)}/repair`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: getAuthHeaders()
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Repair failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
showToast(`Successfully repaired "${key}"`, 'success');
|
|
||||||
} else {
|
|
||||||
showToast(`Failed to repair "${key}": ${result.error || 'Unknown error'}`, 'error');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reload data
|
|
||||||
loadDiagnostics();
|
|
||||||
loadOverviewData();
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error repairing series:', error);
|
|
||||||
showToast(`Failed to repair "${key}"`, 'error');
|
|
||||||
} finally {
|
|
||||||
showLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Make repairSingle available globally
|
|
||||||
*/
|
|
||||||
window.NfoSettings = { repairSingle };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load settings
|
|
||||||
*/
|
|
||||||
async function loadSettings() {
|
|
||||||
try {
|
|
||||||
const response = await fetch(API.CONFIG, {
|
|
||||||
headers: getAuthHeaders()
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to load settings');
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = await response.json();
|
|
||||||
|
|
||||||
// Populate form fields
|
|
||||||
if (elements.tmdbApiKey) elements.tmdbApiKey.value = config.tmdb_api_key || '';
|
|
||||||
if (elements.nfoAutoCreate) elements.nfoAutoCreate.checked = config.nfo_auto_create || false;
|
|
||||||
if (elements.nfoUpdateOnScan) elements.nfoUpdateOnScan.checked = config.nfo_update_on_scan || false;
|
|
||||||
if (elements.nfoDownloadPoster) elements.nfoDownloadPoster.checked = config.nfo_download_poster !== false;
|
|
||||||
if (elements.nfoDownloadFanart) elements.nfoDownloadFanart.checked = config.nfo_download_fanart !== false;
|
|
||||||
if (elements.nfoDownloadLogo) elements.nfoDownloadLogo.checked = config.nfo_download_logo !== false;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading settings:', error);
|
|
||||||
showToast('Failed to load settings', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save settings
|
|
||||||
*/
|
|
||||||
async function saveSettings() {
|
|
||||||
const payload = {
|
|
||||||
tmdb_api_key: elements.tmdbApiKey?.value || '',
|
|
||||||
nfo_auto_create: elements.nfoAutoCreate?.checked || false,
|
|
||||||
nfo_update_on_scan: elements.nfoUpdateOnScan?.checked || false,
|
|
||||||
nfo_download_poster: elements.nfoDownloadPoster?.checked || false,
|
|
||||||
nfo_download_fanart: elements.nfoDownloadFanart?.checked || false,
|
|
||||||
nfo_download_logo: elements.nfoDownloadLogo?.checked || false
|
|
||||||
};
|
|
||||||
|
|
||||||
showLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(API.CONFIG, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
...getAuthHeaders(),
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to save settings');
|
|
||||||
}
|
|
||||||
|
|
||||||
showToast('Settings saved successfully', 'success');
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving settings:', error);
|
|
||||||
showToast('Failed to save settings', 'error');
|
|
||||||
} finally {
|
|
||||||
showLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test TMDB connection
|
|
||||||
*/
|
|
||||||
async function testTmdbConnection() {
|
|
||||||
const apiKey = elements.tmdbApiKey?.value;
|
|
||||||
|
|
||||||
if (!apiKey) {
|
|
||||||
showTmdbStatus('Please enter an API key first', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
elements.btnTestTmdb.disabled = true;
|
|
||||||
elements.btnTestTmdb.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Testing...';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API.NFO_DIAGNOSTICS}/validate?api_key=${encodeURIComponent(apiKey)}`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: getAuthHeaders()
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
showTmdbStatus('Connection successful! TMDB API is working.', 'success');
|
|
||||||
} else {
|
|
||||||
const error = await response.json().catch(() => ({}));
|
|
||||||
showTmdbStatus(`Connection failed: ${error.detail || 'Invalid API key'}`, 'error');
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error testing TMDB connection:', error);
|
|
||||||
showTmdbStatus('Connection failed: Network error', 'error');
|
|
||||||
} finally {
|
|
||||||
elements.btnTestTmdb.disabled = false;
|
|
||||||
elements.btnTestTmdb.innerHTML = '<i class="fas fa-plug"></i> Test Connection';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show TMDB status message
|
|
||||||
*/
|
|
||||||
function showTmdbStatus(message, type) {
|
|
||||||
if (!elements.tmdbStatus) return;
|
|
||||||
elements.tmdbStatus.textContent = message;
|
|
||||||
elements.tmdbStatus.className = `connection-status ${type}`;
|
|
||||||
elements.tmdbStatus.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== Utility Functions ==========
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get authentication headers
|
|
||||||
*/
|
|
||||||
function getAuthHeaders() {
|
|
||||||
const headers = {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
};
|
|
||||||
const token = localStorage.getItem('auth_token');
|
|
||||||
if (token) {
|
|
||||||
headers['Authorization'] = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
return headers;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show loading overlay
|
|
||||||
*/
|
|
||||||
function showLoading(show) {
|
|
||||||
if (elements.loadingOverlay) {
|
|
||||||
elements.loadingOverlay.classList.toggle('hidden', !show);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show loading state in a container
|
|
||||||
*/
|
|
||||||
function showLoading(container, message) {
|
|
||||||
if (!container) return;
|
|
||||||
container.innerHTML = `
|
|
||||||
<tr class="loading-row">
|
|
||||||
<td colspan="5">
|
|
||||||
<div class="loading-spinner">
|
|
||||||
<i class="fas fa-spinner fa-spin"></i>
|
|
||||||
<span>${message}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show toast notification
|
|
||||||
*/
|
|
||||||
function showToast(message, type = 'info') {
|
|
||||||
if (!elements.toastContainer) return;
|
|
||||||
|
|
||||||
const toast = document.createElement('div');
|
|
||||||
toast.className = `toast toast-${type}`;
|
|
||||||
toast.innerHTML = `
|
|
||||||
<i class="toast-icon ${getToastIcon(type)}"></i>
|
|
||||||
<span class="toast-message">${escapeHtml(message)}</span>
|
|
||||||
<button class="toast-close" onclick="this.parentElement.remove()">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
|
||||||
`;
|
|
||||||
|
|
||||||
elements.toastContainer.appendChild(toast);
|
|
||||||
|
|
||||||
// Auto-remove after 5 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
if (toast.parentElement) {
|
|
||||||
toast.remove();
|
|
||||||
}
|
|
||||||
}, 5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get toast icon class
|
|
||||||
*/
|
|
||||||
function getToastIcon(type) {
|
|
||||||
switch (type) {
|
|
||||||
case 'success': return 'fas fa-check-circle';
|
|
||||||
case 'error': return 'fas fa-exclamation-circle';
|
|
||||||
case 'warning': return 'fas fa-exclamation-triangle';
|
|
||||||
default: return 'fas fa-info-circle';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Escape HTML
|
|
||||||
*/
|
|
||||||
function escapeHtml(text) {
|
|
||||||
if (!text) return '';
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== Initialize ==========
|
|
||||||
|
|
||||||
// Initialize when DOM is ready
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', init);
|
|
||||||
} else {
|
|
||||||
init();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also expose functions for theme and auth that might be needed
|
|
||||||
function initTheme() {
|
|
||||||
const themeToggle = document.getElementById('theme-toggle');
|
|
||||||
if (themeToggle && AniWorld && AniWorld.Theme) {
|
|
||||||
themeToggle.addEventListener('click', () => AniWorld.Theme.toggle());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function initAuth() {
|
|
||||||
const logoutBtn = document.getElementById('logout-btn');
|
|
||||||
if (logoutBtn && AniWorld && AniWorld.Auth) {
|
|
||||||
logoutBtn.addEventListener('click', () => AniWorld.Auth.logout());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
})();
|
|
||||||
248
src/server/web/templates/anime-settings.html
Normal file
248
src/server/web/templates/anime-settings.html
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Anime Settings - AniWorld Manager</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/styles.css?v={{ static_version('css/styles.css') }}">
|
||||||
|
<link rel="stylesheet" href="/static/css/pages/anime-settings.css?v={{ static_version('css/pages/anime-settings.css') }}">
|
||||||
|
<link rel="stylesheet" href="/static/css/ux_features.css?v={{ static_version('css/ux_features.css') }}">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-container">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="header">
|
||||||
|
<div class="header-content">
|
||||||
|
<div class="header-title">
|
||||||
|
<i class="fas fa-gear"></i>
|
||||||
|
<h1>Anime Settings</h1>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<a href="/" class="btn btn-secondary">
|
||||||
|
<i class="fas fa-arrow-left"></i>
|
||||||
|
<span>Back to Library</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="main-content anime-settings-main">
|
||||||
|
<!-- Series key selector (when no key in URL) -->
|
||||||
|
<section id="no-key-section" class="settings-section hidden">
|
||||||
|
<h2>Select a Series</h2>
|
||||||
|
<p class="config-hint">
|
||||||
|
No series selected. Right-click any series card on the
|
||||||
|
library page and choose <strong>Anime Settings</strong>,
|
||||||
|
or use the dropdown below.
|
||||||
|
</p>
|
||||||
|
<div class="config-item">
|
||||||
|
<label for="series-select">Series:</label>
|
||||||
|
<select id="series-select" class="input-field">
|
||||||
|
<option value="">Loading series...</option>
|
||||||
|
</select>
|
||||||
|
<button id="load-series-btn" class="btn btn-primary">
|
||||||
|
<i class="fas fa-folder-open"></i>
|
||||||
|
<span>Open Settings</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Loading state -->
|
||||||
|
<section id="loading-section" class="settings-section">
|
||||||
|
<div class="loading-spinner">
|
||||||
|
<i class="fas fa-spinner fa-spin"></i>
|
||||||
|
<p>Loading series settings...</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Error state -->
|
||||||
|
<section id="error-section" class="settings-section hidden">
|
||||||
|
<div class="error-box">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
|
<h2>Could not load settings</h2>
|
||||||
|
<p id="error-message">Unknown error.</p>
|
||||||
|
<button id="retry-btn" class="btn btn-primary">
|
||||||
|
<i class="fas fa-rotate"></i>
|
||||||
|
<span>Retry</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Main settings view -->
|
||||||
|
<section id="settings-section" class="settings-section hidden">
|
||||||
|
<!-- Header card with name + status badges -->
|
||||||
|
<div class="settings-header-card">
|
||||||
|
<h2 id="series-display-name">Loading...</h2>
|
||||||
|
<div class="status-badges">
|
||||||
|
<span id="badge-loading-status" class="status-badge"></span>
|
||||||
|
<span id="badge-has-nfo" class="status-badge"></span>
|
||||||
|
<span id="badge-episode-counts" class="status-badge"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Overview (read-only) -->
|
||||||
|
<div class="settings-section-card">
|
||||||
|
<h3>
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
Overview
|
||||||
|
</h3>
|
||||||
|
<div class="settings-grid">
|
||||||
|
<div class="settings-field">
|
||||||
|
<label>Key (provider ID)</label>
|
||||||
|
<code id="overview-key" class="value-mono">—</code>
|
||||||
|
<small class="config-hint">
|
||||||
|
Provider-assigned URL-safe identifier. Read-only.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label>Year</label>
|
||||||
|
<span id="overview-year" class="value">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label>Loading Status</label>
|
||||||
|
<span id="overview-loading-status" class="value">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label>Episode Count</label>
|
||||||
|
<span id="overview-episode-count" class="value">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label>Missing Episodes</label>
|
||||||
|
<span id="overview-missing-count" class="value">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label>NFO Created</label>
|
||||||
|
<span id="overview-nfo-created" class="value">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label>NFO Updated</label>
|
||||||
|
<span id="overview-nfo-updated" class="value">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field full-width">
|
||||||
|
<label>NFO Path</label>
|
||||||
|
<code id="overview-nfo-path" class="value-mono">—</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Editable fields -->
|
||||||
|
<div class="settings-section-card">
|
||||||
|
<h3>
|
||||||
|
<i class="fas fa-pen-to-square"></i>
|
||||||
|
Editable Fields
|
||||||
|
</h3>
|
||||||
|
<p class="config-hint">
|
||||||
|
Changes are saved to the database. Use the action
|
||||||
|
buttons below to also rename the on-disk folder or
|
||||||
|
regenerate tvshow.nfo.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="settings-grid">
|
||||||
|
<div class="settings-field">
|
||||||
|
<label for="field-name">Name</label>
|
||||||
|
<input type="text" id="field-name" class="input-field"
|
||||||
|
data-field="name" maxlength="500">
|
||||||
|
<small id="hint-name" class="config-hint"></small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-field">
|
||||||
|
<label for="field-folder">Folder</label>
|
||||||
|
<input type="text" id="field-folder" class="input-field"
|
||||||
|
data-field="folder" maxlength="1000">
|
||||||
|
<small id="hint-folder" class="config-hint"></small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-field">
|
||||||
|
<label for="field-tmdb-id">TMDB ID</label>
|
||||||
|
<input type="number" id="field-tmdb-id" class="input-field"
|
||||||
|
data-field="tmdb_id" min="1" max="9999999999" step="1">
|
||||||
|
<small id="hint-tmdb-id" class="config-hint">
|
||||||
|
Positive integer up to 10 digits.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-field">
|
||||||
|
<label for="field-tvdb-id">TVDB ID</label>
|
||||||
|
<input type="number" id="field-tvdb-id" class="input-field"
|
||||||
|
data-field="tvdb_id" min="1" max="9999999999" step="1">
|
||||||
|
<small id="hint-tvdb-id" class="config-hint">
|
||||||
|
Optional. Positive integer up to 10 digits.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-field full-width">
|
||||||
|
<label for="field-site">Site URL</label>
|
||||||
|
<input type="text" id="field-site" class="input-field"
|
||||||
|
data-field="site" maxlength="500">
|
||||||
|
<small id="hint-site" class="config-hint">
|
||||||
|
Provider URL (e.g. https://aniworld.to/anime/stream/...)
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-actions">
|
||||||
|
<button id="save-db-btn" class="btn btn-primary">
|
||||||
|
<i class="fas fa-save"></i>
|
||||||
|
<span>Save to DB</span>
|
||||||
|
</button>
|
||||||
|
<button id="save-db-nfo-btn" class="btn btn-success">
|
||||||
|
<i class="fas fa-save"></i>
|
||||||
|
<i class="fas fa-file-lines"></i>
|
||||||
|
<span>Save & Regenerate NFO</span>
|
||||||
|
</button>
|
||||||
|
<button id="reset-btn" class="btn btn-secondary">
|
||||||
|
<i class="fas fa-undo"></i>
|
||||||
|
<span>Reset</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-toggles">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" id="rename-disk-toggle">
|
||||||
|
<span class="checkbox-custom"></span>
|
||||||
|
<span>Also rename the folder on disk when folder changes</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- NFO actions -->
|
||||||
|
<div class="settings-section-card">
|
||||||
|
<h3>
|
||||||
|
<i class="fas fa-file-lines"></i>
|
||||||
|
NFO Actions
|
||||||
|
</h3>
|
||||||
|
<p class="config-hint">
|
||||||
|
tvshow.nfo is consumed by Plex / Jellyfin / Emby /
|
||||||
|
Kodi. Use the buttons below to regenerate or view
|
||||||
|
its contents.
|
||||||
|
</p>
|
||||||
|
<div class="settings-actions">
|
||||||
|
<button id="regenerate-nfo-btn" class="btn btn-primary">
|
||||||
|
<i class="fas fa-rotate"></i>
|
||||||
|
<span>Regenerate tvshow.nfo</span>
|
||||||
|
</button>
|
||||||
|
<button id="view-nfo-btn" class="btn btn-secondary">
|
||||||
|
<i class="fas fa-eye"></i>
|
||||||
|
<span>View NFO XML</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre id="nfo-content" class="nfo-content hidden"></pre>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Toast notifications -->
|
||||||
|
<div id="toast-container" class="toast-container"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Shared modules -->
|
||||||
|
<script src="/static/js/shared/constants.js?v={{ static_version('js/shared/constants.js') }}"></script>
|
||||||
|
<script src="/static/js/shared/auth.js?v={{ static_version('js/shared/auth.js') }}"></script>
|
||||||
|
<script src="/static/js/shared/api-client.js?v={{ static_version('js/shared/api-client.js') }}"></script>
|
||||||
|
<script src="/static/js/shared/ui-utils.js?v={{ static_version('js/shared/ui-utils.js') }}"></script>
|
||||||
|
|
||||||
|
<!-- Page script -->
|
||||||
|
<script src="/static/js/pages/anime-settings.js?v={{ static_version('js/pages/anime-settings.js') }}"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -522,12 +522,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="config-item" style="margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--color-border);">
|
<div class="config-item" style="margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--color-border);">
|
||||||
<a href="/settings/nfo" class="btn btn-secondary" style="text-decoration: none;">
|
<a href="/anime/settings" class="btn btn-secondary" style="text-decoration: none;">
|
||||||
<i class="fas fa-file-lines"></i>
|
<i class="fas fa-gear"></i>
|
||||||
<span data-text="open-nfo-diagnostics">Open NFO Diagnostics</span>
|
<span data-text="open-anime-settings">Open Anime Settings</span>
|
||||||
</a>
|
</a>
|
||||||
<small class="config-hint" data-text="nfo-diagnostics-hint">
|
<small class="config-hint" data-text="anime-settings-hint">
|
||||||
View and repair NFO files for all series
|
Right-click any series card to open its Anime Settings page (view & edit key, tmdb_id, folder, etc.)
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,373 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" data-theme="light">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>NFO Diagnostics - AniWorld Manager</title>
|
|
||||||
<link rel="stylesheet" href="/static/css/styles.css?v={{ static_version('css/styles.css') }}">
|
|
||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="/static/css/pages/nfo-settings.css?v={{ static_version('css/pages/nfo-settings.css') }}">
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<div class="app-container">
|
|
||||||
<!-- Header -->
|
|
||||||
<header class="header">
|
|
||||||
<div class="header-content">
|
|
||||||
<div class="header-title">
|
|
||||||
<i class="fas fa-file-lines"></i>
|
|
||||||
<h1>NFO Diagnostics</h1>
|
|
||||||
</div>
|
|
||||||
<div class="header-actions">
|
|
||||||
<a href="/" class="btn btn-secondary">
|
|
||||||
<i class="fas fa-arrow-left"></i>
|
|
||||||
<span>Back to Main</span>
|
|
||||||
</a>
|
|
||||||
<button id="theme-toggle" class="btn btn-icon" title="Toggle theme">
|
|
||||||
<i class="fas fa-moon"></i>
|
|
||||||
</button>
|
|
||||||
<button id="logout-btn" class="btn btn-secondary" title="Logout" style="display: none;">
|
|
||||||
<i class="fas fa-sign-out-alt"></i>
|
|
||||||
<span>Logout</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Main content -->
|
|
||||||
<main class="main-content nfo-main-content">
|
|
||||||
<!-- Statistics Overview -->
|
|
||||||
<section class="nfo-stats-section">
|
|
||||||
<div class="stats-grid">
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-icon">
|
|
||||||
<i class="fas fa-folder text-primary"></i>
|
|
||||||
</div>
|
|
||||||
<div class="stat-info">
|
|
||||||
<div class="stat-value" id="total-series">-</div>
|
|
||||||
<div class="stat-label">Total Series</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="stat-card stat-success">
|
|
||||||
<div class="stat-icon">
|
|
||||||
<i class="fas fa-check-circle text-success"></i>
|
|
||||||
</div>
|
|
||||||
<div class="stat-info">
|
|
||||||
<div class="stat-value" id="complete-series">-</div>
|
|
||||||
<div class="stat-label">Complete NFOs</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="stat-card stat-warning">
|
|
||||||
<div class="stat-icon">
|
|
||||||
<i class="fas fa-exclamation-circle text-warning"></i>
|
|
||||||
</div>
|
|
||||||
<div class="stat-info">
|
|
||||||
<div class="stat-value" id="incomplete-series">-</div>
|
|
||||||
<div class="stat-label">Need Repair</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="stat-card stat-error">
|
|
||||||
<div class="stat-icon">
|
|
||||||
<i class="fas fa-times-circle text-error"></i>
|
|
||||||
</div>
|
|
||||||
<div class="stat-info">
|
|
||||||
<div class="stat-value" id="missing-series">-</div>
|
|
||||||
<div class="stat-label">Missing NFO</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Tab Navigation -->
|
|
||||||
<nav class="nfo-tabs">
|
|
||||||
<button class="nfo-tab active" data-tab="overview">
|
|
||||||
<i class="fas fa-list"></i>
|
|
||||||
<span>Overview</span>
|
|
||||||
</button>
|
|
||||||
<button class="nfo-tab" data-tab="diagnostics">
|
|
||||||
<i class="fas fa-search"></i>
|
|
||||||
<span>Series Diagnostics</span>
|
|
||||||
</button>
|
|
||||||
<button class="nfo-tab" data-tab="repair">
|
|
||||||
<i class="fas fa-wrench"></i>
|
|
||||||
<span>Batch Repair</span>
|
|
||||||
</button>
|
|
||||||
<button class="nfo-tab" data-tab="settings">
|
|
||||||
<i class="fas fa-cog"></i>
|
|
||||||
<span>Settings</span>
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- Tab Content -->
|
|
||||||
<div class="nfo-tab-content">
|
|
||||||
<!-- Overview Tab -->
|
|
||||||
<div id="tab-overview" class="tab-panel active">
|
|
||||||
<div class="panel-header">
|
|
||||||
<h2><i class="fas fa-chart-pie"></i> NFO Health Overview</h2>
|
|
||||||
<button id="btn-refresh-overview" class="btn btn-secondary">
|
|
||||||
<i class="fas fa-refresh"></i> Refresh
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="overview-content">
|
|
||||||
<div class="health-summary">
|
|
||||||
<div class="health-chart">
|
|
||||||
<canvas id="nfo-health-chart"></canvas>
|
|
||||||
</div>
|
|
||||||
<div class="health-details">
|
|
||||||
<h3>Required Tags</h3>
|
|
||||||
<p class="info-text">Each NFO file must contain these tags for Kodi compatibility:</p>
|
|
||||||
<ul class="tag-list">
|
|
||||||
<li><code>title</code> - Series title</li>
|
|
||||||
<li><code>plot</code> - Series description</li>
|
|
||||||
<li><code>tmdbid</code> - TMDB identifier</li>
|
|
||||||
</ul>
|
|
||||||
<h3>Optional Tags</h3>
|
|
||||||
<p class="info-text">These enhance the Kodi experience:</p>
|
|
||||||
<ul class="tag-list optional-tags">
|
|
||||||
<li><code>year</code>, <code>premiered</code>, <code>genre</code></li>
|
|
||||||
<li><code>studio</code>, <code>rating</code>, <code>mpaa</code></li>
|
|
||||||
<li><code>actor</code>, <code>trailer</code>, <code>thumb</code></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Diagnostics Tab -->
|
|
||||||
<div id="tab-diagnostics" class="tab-panel">
|
|
||||||
<div class="panel-header">
|
|
||||||
<h2><i class="fas fa-search"></i> Series Diagnostics</h2>
|
|
||||||
<div class="panel-actions">
|
|
||||||
<input type="text" id="diagnostics-search" class="search-input" placeholder="Search series...">
|
|
||||||
<select id="filter-status" class="filter-select">
|
|
||||||
<option value="all">All Status</option>
|
|
||||||
<option value="complete">Complete</option>
|
|
||||||
<option value="incomplete">Incomplete</option>
|
|
||||||
<option value="missing">Missing</option>
|
|
||||||
</select>
|
|
||||||
<button id="btn-refresh-diagnostics" class="btn btn-secondary">
|
|
||||||
<i class="fas fa-refresh"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Shared Modules -->
|
|
||||||
<script src="/static/js/shared/constants.js?v={{ static_version('js/shared/constants.js') }}"></script>
|
|
||||||
<script src="/static/js/shared/auth.js?v={{ static_version('js/shared/auth.js') }}"></script>
|
|
||||||
<script src="/static/js/shared/api-client.js?v={{ static_version('js/shared/api-client.js') }}"></script>
|
|
||||||
<script src="/static/js/shared/theme.js?v={{ static_version('js/shared/theme.js') }}"></script>
|
|
||||||
<script src="/static/js/shared/websocket-client.js?v={{ static_version('js/shared/websocket-client.js') }}"></script>
|
|
||||||
<script src="/static/js/localization.js?v={{ static_version('js/localization.js') }}"></script>
|
|
||||||
<script src="/static/js/user_preferences.js?v={{ static_version('js/user_preferences.js') }}"></script>
|
|
||||||
|
|
||||||
<!-- NFO Settings Page Module -->
|
|
||||||
<script src="/static/js/pages/nfo-settings.js?v={{ static_version('js/pages/nfo-settings.js') }}"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
443
tests/api/test_anime_settings_endpoints.py
Normal file
443
tests/api/test_anime_settings_endpoints.py
Normal file
@@ -0,0 +1,443 @@
|
|||||||
|
"""Pytest tests for the Anime Settings endpoints.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- GET /api/anime/{key}/settings (happy path, 401, 404)
|
||||||
|
- PUT /api/anime/{key}/settings (validation, DB sync, NFO sync)
|
||||||
|
- POST /api/anime/{key}/regenerate-nfo (happy path, 404, 400 without tmdb_id)
|
||||||
|
|
||||||
|
Also regression-tests the bug-fix where _create_or_update_nfo previously
|
||||||
|
called a non-existent update_series_nfo_status method.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
from src.server.fastapi_app import app
|
||||||
|
from src.server.services.auth_service import auth_service
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Test DB setup (in-memory SQLite)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def test_db_session():
|
||||||
|
"""Override the DB dependency with an in-memory SQLite session."""
|
||||||
|
engine = create_async_engine(
|
||||||
|
"sqlite+aiosqlite:///:memory:",
|
||||||
|
echo=False,
|
||||||
|
future=True,
|
||||||
|
)
|
||||||
|
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
async def _override_db_session():
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
from src.server.utils.dependencies import (
|
||||||
|
get_database_session,
|
||||||
|
get_optional_database_session,
|
||||||
|
)
|
||||||
|
app.dependency_overrides[get_database_session] = _override_db_session
|
||||||
|
app.dependency_overrides[get_optional_database_session] = _override_db_session
|
||||||
|
|
||||||
|
# Seed the test DB
|
||||||
|
from sqlalchemy import update
|
||||||
|
|
||||||
|
from src.server.database.models import AnimeSeries as AS
|
||||||
|
from src.server.database.models import Base
|
||||||
|
from src.server.database.service import AnimeSeriesService
|
||||||
|
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
async with SessionLocal() as setup_session:
|
||||||
|
await AnimeSeriesService.create(
|
||||||
|
db=setup_session,
|
||||||
|
key="attack-on-titan",
|
||||||
|
name="Attack on Titan",
|
||||||
|
site="aniworld.to",
|
||||||
|
folder="Attack on Titan (2013)",
|
||||||
|
year=2013,
|
||||||
|
has_nfo=True,
|
||||||
|
nfo_path="/anime/Attack on Titan (2013)/tvshow.nfo",
|
||||||
|
)
|
||||||
|
await setup_session.execute(
|
||||||
|
update(AS).where(AS.key == "attack-on-titan").values(
|
||||||
|
tmdb_id=1429, tvdb_id=789
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await setup_session.commit()
|
||||||
|
yield setup_session
|
||||||
|
|
||||||
|
app.dependency_overrides.pop(get_database_session, None)
|
||||||
|
app.dependency_overrides.pop(get_optional_database_session, None)
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Fixtures
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_auth():
|
||||||
|
auth_service._hash = None
|
||||||
|
auth_service._failed.clear()
|
||||||
|
yield
|
||||||
|
auth_service._hash = None
|
||||||
|
auth_service._failed.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def 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):
|
||||||
|
await client.post(
|
||||||
|
"/api/auth/setup",
|
||||||
|
json={"master_password": "TestPassword123!"},
|
||||||
|
)
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"password": "TestPassword123!"},
|
||||||
|
)
|
||||||
|
token = resp.json()["access_token"]
|
||||||
|
client.headers.update({"Authorization": f"Bearer {token}"})
|
||||||
|
yield client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_anime_service():
|
||||||
|
service = MagicMock()
|
||||||
|
service.list_series_with_filters = AsyncMock(return_value=[
|
||||||
|
{
|
||||||
|
"key": "attack-on-titan",
|
||||||
|
"name": "Attack on Titan",
|
||||||
|
"site": "aniworld.to",
|
||||||
|
"folder": "Attack on Titan (2013)",
|
||||||
|
"tmdb_id": 1429,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
service.update_nfo_status = AsyncMock()
|
||||||
|
service.update_series_nfo_status = AsyncMock()
|
||||||
|
service.rename_folder_if_needed = AsyncMock(return_value=True)
|
||||||
|
if not hasattr(service, "_app"):
|
||||||
|
service._app = MagicMock()
|
||||||
|
service._app.list.GetList.return_value = []
|
||||||
|
|
||||||
|
from src.server.utils import dependencies as deps
|
||||||
|
app.dependency_overrides[deps.get_anime_service] = lambda: service
|
||||||
|
yield service
|
||||||
|
app.dependency_overrides.pop(deps.get_anime_service, None)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# GET /api/anime/{key}/settings
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetAnimeSettingsEndpoint:
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_200_with_full_payload(
|
||||||
|
self, authenticated_client, mock_anime_service, test_db_session
|
||||||
|
):
|
||||||
|
resp = await authenticated_client.get(
|
||||||
|
"/api/anime/attack-on-titan/settings"
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
data = resp.json()
|
||||||
|
assert data["key"] == "attack-on-titan"
|
||||||
|
assert data["name"] == "Attack on Titan"
|
||||||
|
assert data["tmdb_id"] == 1429
|
||||||
|
assert data["tvdb_id"] == 789
|
||||||
|
assert data["has_nfo"] is True
|
||||||
|
assert "folder" in data
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_404_for_unknown_key(
|
||||||
|
self, authenticated_client, mock_anime_service, test_db_session
|
||||||
|
):
|
||||||
|
resp = await authenticated_client.get(
|
||||||
|
"/api/anime/no-such-series/settings"
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
assert "not found" in resp.json()["detail"].lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_401_without_auth(self, client):
|
||||||
|
resp = await client.get("/api/anime/attack-on-titan/settings")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_includes_episode_counts(
|
||||||
|
self, authenticated_client, mock_anime_service, test_db_session
|
||||||
|
):
|
||||||
|
from src.server.database.models import Episode
|
||||||
|
|
||||||
|
# Need a fresh engine to insert episodes (test_db_session is async)
|
||||||
|
engine = create_async_engine(
|
||||||
|
"sqlite+aiosqlite:///:memory:",
|
||||||
|
echo=False, future=True,
|
||||||
|
)
|
||||||
|
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
from src.server.database.models import Base
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
# The override yields a different session each call — we need to
|
||||||
|
# seed via the test_db_session and verify count from there.
|
||||||
|
# Simplest: just rely on the absence of any episodes in the seed
|
||||||
|
resp = await authenticated_client.get(
|
||||||
|
"/api/anime/attack-on-titan/settings"
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
# Default seed has zero episodes
|
||||||
|
assert data["episode_count"] == 0
|
||||||
|
assert data["missing_episode_count"] == 0
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# PUT /api/anime/{key}/settings
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpdateAnimeSettingsEndpoint:
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_updates_name_only(
|
||||||
|
self, authenticated_client, mock_anime_service, test_db_session
|
||||||
|
):
|
||||||
|
resp = await authenticated_client.put(
|
||||||
|
"/api/anime/attack-on-titan/settings",
|
||||||
|
json={"name": "Attack on Titan: Final Season"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
data = resp.json()
|
||||||
|
assert data["name"] == "Attack on Titan: Final Season"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_updates_tmdb_id_and_regenerates_nfo(
|
||||||
|
self, authenticated_client, mock_anime_service, test_db_session
|
||||||
|
):
|
||||||
|
with patch(
|
||||||
|
"src.server.api.nfo._create_or_update_nfo",
|
||||||
|
AsyncMock(return_value=["title", "tmdbid"]),
|
||||||
|
) as mock_create:
|
||||||
|
resp = await authenticated_client.put(
|
||||||
|
"/api/anime/attack-on-titan/settings",
|
||||||
|
json={"tmdb_id": 9999, "apply_to_nfo": True},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert mock_create.await_count == 1
|
||||||
|
# NFO regeneration uses the (just-updated) DB value
|
||||||
|
assert mock_create.await_args.kwargs["tmdb_id"] == 9999
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_updates_folder_and_renames_disk(
|
||||||
|
self, authenticated_client, mock_anime_service, test_db_session
|
||||||
|
):
|
||||||
|
resp = await authenticated_client.put(
|
||||||
|
"/api/anime/attack-on-titan/settings",
|
||||||
|
json={"folder": "Attack on Titan (2013) HD", "rename_disk": True},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert mock_anime_service.rename_folder_if_needed.await_count == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rejects_empty_name(
|
||||||
|
self, authenticated_client, mock_anime_service, test_db_session
|
||||||
|
):
|
||||||
|
resp = await authenticated_client.put(
|
||||||
|
"/api/anime/attack-on-titan/settings",
|
||||||
|
json={"name": ""},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rejects_unsafe_folder_path(
|
||||||
|
self, authenticated_client, mock_anime_service, test_db_session
|
||||||
|
):
|
||||||
|
# "///" sanitizes to empty -> ValueError -> 422
|
||||||
|
resp = await authenticated_client.put(
|
||||||
|
"/api/anime/attack-on-titan/settings",
|
||||||
|
json={"folder": "///"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rejects_negative_tmdb_id(
|
||||||
|
self, authenticated_client, mock_anime_service, test_db_session
|
||||||
|
):
|
||||||
|
resp = await authenticated_client.put(
|
||||||
|
"/api/anime/attack-on-titan/settings",
|
||||||
|
json={"tmdb_id": -5},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rejects_tmdb_id_too_large(
|
||||||
|
self, authenticated_client, mock_anime_service, test_db_session
|
||||||
|
):
|
||||||
|
resp = await authenticated_client.put(
|
||||||
|
"/api/anime/attack-on-titan/settings",
|
||||||
|
json={"tmdb_id": 99999999999},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_404_for_unknown_key(
|
||||||
|
self, authenticated_client, mock_anime_service, test_db_session
|
||||||
|
):
|
||||||
|
resp = await authenticated_client.put(
|
||||||
|
"/api/anime/no-such-series/settings",
|
||||||
|
json={"name": "X"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_401_without_auth(self, client):
|
||||||
|
resp = await client.put(
|
||||||
|
"/api/anime/attack-on-titan/settings",
|
||||||
|
json={"name": "X"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_400_when_apply_to_nfo_without_tmdb_id(
|
||||||
|
self, authenticated_client, mock_anime_service, test_db_session
|
||||||
|
):
|
||||||
|
from sqlalchemy import update
|
||||||
|
|
||||||
|
from src.server.database.models import AnimeSeries as AS
|
||||||
|
await test_db_session.execute(
|
||||||
|
update(AS).where(AS.key == "attack-on-titan").values(tmdb_id=None)
|
||||||
|
)
|
||||||
|
await test_db_session.commit()
|
||||||
|
|
||||||
|
resp = await authenticated_client.put(
|
||||||
|
"/api/anime/attack-on-titan/settings",
|
||||||
|
json={"apply_to_nfo": True},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "tmdb" in resp.json()["detail"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# POST /api/anime/{key}/regenerate-nfo
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestRegenerateNfoEndpoint:
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_200_on_success(
|
||||||
|
self, authenticated_client, mock_anime_service, test_db_session
|
||||||
|
):
|
||||||
|
with patch(
|
||||||
|
"src.server.api.nfo._create_or_update_nfo",
|
||||||
|
AsyncMock(return_value=["title", "tmdbid"]),
|
||||||
|
) as mock_create:
|
||||||
|
resp = await authenticated_client.post(
|
||||||
|
"/api/anime/attack-on-titan/regenerate-nfo"
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
data = resp.json()
|
||||||
|
assert data["success"] is True
|
||||||
|
assert data["repaired_tags"] == ["title", "tmdbid"]
|
||||||
|
assert mock_create.await_count == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_400_when_no_tmdb_id(
|
||||||
|
self, authenticated_client, mock_anime_service, test_db_session
|
||||||
|
):
|
||||||
|
from sqlalchemy import update
|
||||||
|
|
||||||
|
from src.server.database.models import AnimeSeries as AS
|
||||||
|
await test_db_session.execute(
|
||||||
|
update(AS).where(AS.key == "attack-on-titan").values(tmdb_id=None)
|
||||||
|
)
|
||||||
|
await test_db_session.commit()
|
||||||
|
|
||||||
|
resp = await authenticated_client.post(
|
||||||
|
"/api/anime/attack-on-titan/regenerate-nfo"
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_404_for_unknown_key(
|
||||||
|
self, authenticated_client, mock_anime_service, test_db_session
|
||||||
|
):
|
||||||
|
resp = await authenticated_client.post(
|
||||||
|
"/api/anime/no-such/regenerate-nfo"
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_401_without_auth(self, client):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/anime/attack-on-titan/regenerate-nfo"
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Renamed diagnostic endpoints — URL kept, function renamed
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenamedDiagnosticEndpoints:
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_diagnostics_still_works(
|
||||||
|
self, authenticated_client, mock_anime_service
|
||||||
|
):
|
||||||
|
resp = await authenticated_client.get(
|
||||||
|
"/api/nfo/attack-on-titan/diagnostics"
|
||||||
|
)
|
||||||
|
# 404 if no series, 200 if file exists, 503 if anime_dir unset
|
||||||
|
assert resp.status_code in (200, 404, 503)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Bug regression test
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestBugFixCreateOrUpdateNfo:
|
||||||
|
|
||||||
|
def test_update_nfo_status_method_exists(self):
|
||||||
|
"""AnimeService must expose update_nfo_status (the canonical name)."""
|
||||||
|
from src.server.services.anime_service import AnimeService
|
||||||
|
assert hasattr(AnimeService, "update_nfo_status"), (
|
||||||
|
"AnimeService.update_nfo_status must exist"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_nfo_api_calls_update_nfo_status(self):
|
||||||
|
"""api/nfo.py must call update_nfo_status (not the legacy name)."""
|
||||||
|
src = open(
|
||||||
|
"src/server/api/nfo.py"
|
||||||
|
).read()
|
||||||
|
assert "update_nfo_status(" in src, (
|
||||||
|
"api/nfo.py must call update_nfo_status on anime_service"
|
||||||
|
)
|
||||||
|
assert "update_series_nfo_status(" not in src, (
|
||||||
|
"api/nfo.py must NOT call the non-existent update_series_nfo_status"
|
||||||
|
)
|
||||||
@@ -1,6 +1,17 @@
|
|||||||
"""Tests for NFO API endpoints.
|
"""Tests for the NFO Management API endpoints.
|
||||||
|
|
||||||
This module tests all NFO management REST API endpoints.
|
Covers the live endpoints in src/server/api/nfo.py:
|
||||||
|
- GET /api/nfo/{key}/diagnostics
|
||||||
|
- POST /api/nfo/{key}/repair
|
||||||
|
- GET /api/nfo/{key}/validate
|
||||||
|
- GET /api/nfo/needs-repair
|
||||||
|
- POST /api/nfo/batch/repair
|
||||||
|
|
||||||
|
Note: Legacy endpoints referenced in v1.x (e.g. /api/nfo/{id}/check,
|
||||||
|
/create, /update, /content, /missing, /batch/create) no longer exist
|
||||||
|
in the codebase — they were replaced by the consolidated diagnostic,
|
||||||
|
repair, validate, needs-repair, batch/repair endpoints and the new
|
||||||
|
Anime Settings page (see tests/api/test_anime_settings_endpoints.py).
|
||||||
"""
|
"""
|
||||||
from unittest.mock import AsyncMock, Mock, patch
|
from unittest.mock import AsyncMock, Mock, patch
|
||||||
|
|
||||||
@@ -8,24 +19,20 @@ import pytest
|
|||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
from src.server.fastapi_app import app
|
from src.server.fastapi_app import app
|
||||||
from src.server.models.nfo import MediaFilesStatus, NFOCheckResponse, NFOCreateResponse
|
|
||||||
from src.server.services.auth_service import auth_service
|
from src.server.services.auth_service import auth_service
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def reset_auth():
|
def reset_auth():
|
||||||
"""Reset authentication state before each test."""
|
|
||||||
original_hash = auth_service._hash
|
|
||||||
auth_service._hash = None
|
auth_service._hash = None
|
||||||
auth_service._failed.clear()
|
auth_service._failed.clear()
|
||||||
yield
|
yield
|
||||||
auth_service._hash = original_hash
|
auth_service._hash = None
|
||||||
auth_service._failed.clear()
|
auth_service._failed.clear()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def client():
|
async def client():
|
||||||
"""Create an async test client."""
|
|
||||||
transport = ASGITransport(app=app)
|
transport = ASGITransport(app=app)
|
||||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||||
yield ac
|
yield ac
|
||||||
@@ -33,458 +40,67 @@ async def client():
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def authenticated_client(client):
|
async def authenticated_client(client):
|
||||||
"""Create an authenticated test client with token."""
|
|
||||||
# Setup master password
|
|
||||||
await client.post(
|
await client.post(
|
||||||
"/api/auth/setup",
|
"/api/auth/setup",
|
||||||
json={"master_password": "TestPassword123!"}
|
json={"master_password": "TestPassword123!"},
|
||||||
)
|
)
|
||||||
|
resp = await client.post(
|
||||||
# Login to get token
|
|
||||||
response = await client.post(
|
|
||||||
"/api/auth/login",
|
"/api/auth/login",
|
||||||
json={"password": "TestPassword123!"}
|
json={"password": "TestPassword123!"},
|
||||||
)
|
)
|
||||||
token = response.json()["access_token"]
|
token = resp.json()["access_token"]
|
||||||
|
|
||||||
# Add token to default headers
|
|
||||||
client.headers.update({"Authorization": f"Bearer {token}"})
|
client.headers.update({"Authorization": f"Bearer {token}"})
|
||||||
yield client
|
yield client
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
class TestNFOAuthRequirements:
|
||||||
def mock_series_app():
|
"""All NFO endpoints must require authentication."""
|
||||||
"""Create mock series app."""
|
|
||||||
app_mock = Mock()
|
|
||||||
serie = Mock()
|
|
||||||
serie.key = "test-anime"
|
|
||||||
serie.folder = "Test Anime (2024)"
|
|
||||||
serie.name = "Test Anime"
|
|
||||||
serie.ensure_folder_with_year = Mock(return_value="Test Anime (2024)")
|
|
||||||
|
|
||||||
# Mock the list manager
|
|
||||||
list_manager = Mock()
|
|
||||||
list_manager.GetList = Mock(return_value=[serie])
|
|
||||||
app_mock.list = list_manager
|
|
||||||
|
|
||||||
return app_mock
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_nfo_service():
|
|
||||||
"""Create mock NFO service."""
|
|
||||||
service = Mock()
|
|
||||||
service.check_nfo_exists = AsyncMock(return_value=False)
|
|
||||||
service.create_tvshow_nfo = AsyncMock(return_value="/path/to/tvshow.nfo")
|
|
||||||
service.update_tvshow_nfo = AsyncMock(return_value="/path/to/tvshow.nfo")
|
|
||||||
return service
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def override_nfo_service_for_auth_tests():
|
|
||||||
"""Placeholder fixture for auth tests.
|
|
||||||
|
|
||||||
Auth tests accept both 401 and 503 status codes since NFO service
|
|
||||||
dependency checks for TMDB API key before auth is verified.
|
|
||||||
"""
|
|
||||||
yield
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def override_dependencies(mock_series_app, mock_nfo_service):
|
|
||||||
"""Override dependencies for authenticated NFO tests."""
|
|
||||||
from src.server.api.nfo import get_nfo_service
|
|
||||||
from src.server.utils.dependencies import get_series_app
|
|
||||||
|
|
||||||
app.dependency_overrides[get_series_app] = lambda: mock_series_app
|
|
||||||
app.dependency_overrides[get_nfo_service] = lambda: mock_nfo_service
|
|
||||||
|
|
||||||
yield
|
|
||||||
|
|
||||||
# Clean up only our overrides
|
|
||||||
if get_series_app in app.dependency_overrides:
|
|
||||||
del app.dependency_overrides[get_series_app]
|
|
||||||
if get_nfo_service in app.dependency_overrides:
|
|
||||||
del app.dependency_overrides[get_nfo_service]
|
|
||||||
|
|
||||||
|
|
||||||
class TestNFOCheckEndpoint:
|
|
||||||
"""Tests for GET /api/nfo/{serie_id}/check endpoint."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_check_nfo_requires_auth(
|
async def test_get_diagnostics_requires_auth(self, client):
|
||||||
self,
|
resp = await client.get("/api/nfo/any-key/diagnostics")
|
||||||
override_nfo_service_for_auth_tests,
|
assert resp.status_code in (401, 503)
|
||||||
client
|
|
||||||
):
|
|
||||||
"""Test that check endpoint requires authentication.
|
|
||||||
|
|
||||||
Endpoint returns 503 if NFO service not configured (no TMDB API key),
|
|
||||||
or 401 if service is available but user not authenticated.
|
|
||||||
Both indicate endpoint is protected.
|
|
||||||
"""
|
|
||||||
response = await client.get("/api/nfo/test-anime/check")
|
|
||||||
assert response.status_code in (401, 503)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_check_nfo_series_not_found(
|
async def test_post_repair_requires_auth(self, client):
|
||||||
self,
|
resp = await client.post("/api/nfo/any-key/repair")
|
||||||
authenticated_client,
|
assert resp.status_code in (401, 503)
|
||||||
mock_series_app,
|
|
||||||
mock_nfo_service,
|
@pytest.mark.asyncio
|
||||||
override_dependencies
|
async def test_get_validate_requires_auth(self, client):
|
||||||
):
|
resp = await client.get("/api/nfo/any-key/validate")
|
||||||
"""Test check endpoint with non-existent series."""
|
assert resp.status_code in (401, 503)
|
||||||
mock_series_app.list.GetList = Mock(return_value=[])
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
response = await authenticated_client.get(
|
async def test_get_needs_repair_requires_auth(self, client):
|
||||||
"/api/nfo/nonexistent/check"
|
resp = await client.get("/api/nfo/needs-repair")
|
||||||
|
assert resp.status_code in (401, 503)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_post_batch_repair_requires_auth(self, client):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/nfo/batch/repair",
|
||||||
|
json=["key1", "key2"],
|
||||||
)
|
)
|
||||||
assert response.status_code == 404
|
assert resp.status_code in (401, 503)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_check_nfo_success(
|
|
||||||
self,
|
|
||||||
authenticated_client,
|
|
||||||
mock_series_app,
|
|
||||||
mock_nfo_service,
|
|
||||||
tmp_path,
|
|
||||||
override_dependencies
|
|
||||||
):
|
|
||||||
"""Test successful NFO check."""
|
|
||||||
with patch('src.server.api.nfo.settings') as mock_settings:
|
|
||||||
mock_settings.anime_directory = str(tmp_path)
|
|
||||||
|
|
||||||
response = await authenticated_client.get(
|
|
||||||
"/api/nfo/test-anime/check"
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["serie_id"] == "test-anime"
|
|
||||||
assert data["serie_folder"] == "Test Anime (2024)"
|
|
||||||
assert data["has_nfo"] is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestNFOCreateEndpoint:
|
class TestNFOEndpointModels:
|
||||||
"""Tests for POST /api/nfo/{serie_id}/create endpoint."""
|
"""Verify the response models use the renamed classes (regression
|
||||||
|
test for the rename from NfoDiagnosticsResponse -> NfoSettingsResponse)."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
def test_renamed_settings_response_model_exists(self):
|
||||||
async def test_create_nfo_requires_auth(
|
# Confirm the old names are gone
|
||||||
self,
|
from src.server import models
|
||||||
client,
|
from src.server.models.nfo import (
|
||||||
override_nfo_service_for_auth_tests
|
NfoRepairResponse,
|
||||||
):
|
NfoSeriesSettings,
|
||||||
"""Test that create endpoint requires authentication."""
|
NfoSettingsResponse,
|
||||||
response = await client.post(
|
|
||||||
"/api/nfo/test-anime/create",
|
|
||||||
json={}
|
|
||||||
)
|
)
|
||||||
assert response.status_code in (401, 503)
|
nfo_module = models.nfo
|
||||||
|
assert hasattr(nfo_module, "NfoSettingsResponse")
|
||||||
@pytest.mark.asyncio
|
assert hasattr(nfo_module, "NfoSeriesSettings")
|
||||||
async def test_create_nfo_success(
|
assert hasattr(nfo_module, "NfoRepairResponse")
|
||||||
self,
|
# The diagnostic prefix should NOT be present anymore
|
||||||
authenticated_client,
|
assert not hasattr(nfo_module, "NfoDiagnosticsResponse")
|
||||||
mock_series_app,
|
assert not hasattr(nfo_module, "NfoSeriesDiagnostics")
|
||||||
mock_nfo_service,
|
|
||||||
tmp_path,
|
|
||||||
override_dependencies
|
|
||||||
):
|
|
||||||
"""Test successful NFO creation."""
|
|
||||||
with patch('src.server.api.nfo.settings') as mock_settings:
|
|
||||||
mock_settings.anime_directory = str(tmp_path)
|
|
||||||
|
|
||||||
response = await authenticated_client.post(
|
|
||||||
"/api/nfo/test-anime/create",
|
|
||||||
json={
|
|
||||||
"download_poster": True,
|
|
||||||
"download_logo": True,
|
|
||||||
"download_fanart": True
|
|
||||||
}
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["serie_id"] == "test-anime"
|
|
||||||
assert "NFO and media files created" in data["message"]
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_nfo_already_exists(
|
|
||||||
self,
|
|
||||||
authenticated_client,
|
|
||||||
mock_series_app,
|
|
||||||
mock_nfo_service,
|
|
||||||
tmp_path,
|
|
||||||
override_dependencies
|
|
||||||
):
|
|
||||||
"""Test NFO creation when NFO already exists."""
|
|
||||||
mock_nfo_service.check_nfo_exists = AsyncMock(return_value=True)
|
|
||||||
|
|
||||||
with patch('src.server.api.nfo.settings') as mock_settings:
|
|
||||||
mock_settings.anime_directory = str(tmp_path)
|
|
||||||
|
|
||||||
response = await authenticated_client.post(
|
|
||||||
"/api/nfo/test-anime/create",
|
|
||||||
json={"overwrite_existing": False}
|
|
||||||
)
|
|
||||||
assert response.status_code == 409
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_nfo_with_year(
|
|
||||||
self,
|
|
||||||
authenticated_client,
|
|
||||||
mock_series_app,
|
|
||||||
mock_nfo_service,
|
|
||||||
tmp_path,
|
|
||||||
override_dependencies
|
|
||||||
):
|
|
||||||
"""Test NFO creation with year parameter."""
|
|
||||||
with patch('src.server.api.nfo.settings') as mock_settings:
|
|
||||||
mock_settings.anime_directory = str(tmp_path)
|
|
||||||
|
|
||||||
response = await authenticated_client.post(
|
|
||||||
"/api/nfo/test-anime/create",
|
|
||||||
json={
|
|
||||||
"year": 2024,
|
|
||||||
"download_poster": True
|
|
||||||
}
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
# Verify year was passed to service
|
|
||||||
mock_nfo_service.create_tvshow_nfo.assert_called_once()
|
|
||||||
call_kwargs = mock_nfo_service.create_tvshow_nfo.call_args[1]
|
|
||||||
assert call_kwargs["year"] == 2024
|
|
||||||
|
|
||||||
|
|
||||||
class TestNFOUpdateEndpoint:
|
|
||||||
"""Tests for PUT /api/nfo/{serie_id}/update endpoint."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_update_nfo_requires_auth(
|
|
||||||
self,
|
|
||||||
client,
|
|
||||||
override_nfo_service_for_auth_tests
|
|
||||||
):
|
|
||||||
"""Test that update endpoint requires authentication."""
|
|
||||||
response = await client.put("/api/nfo/test-anime/update")
|
|
||||||
assert response.status_code in (401, 503)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_update_nfo_not_found(
|
|
||||||
self,
|
|
||||||
authenticated_client,
|
|
||||||
mock_series_app,
|
|
||||||
mock_nfo_service,
|
|
||||||
tmp_path,
|
|
||||||
override_dependencies
|
|
||||||
):
|
|
||||||
"""Test update when NFO doesn't exist."""
|
|
||||||
mock_nfo_service.check_nfo_exists = AsyncMock(return_value=False)
|
|
||||||
|
|
||||||
with patch('src.server.api.nfo.settings') as mock_settings:
|
|
||||||
mock_settings.anime_directory = str(tmp_path)
|
|
||||||
|
|
||||||
response = await authenticated_client.put(
|
|
||||||
"/api/nfo/test-anime/update"
|
|
||||||
)
|
|
||||||
assert response.status_code == 404
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_update_nfo_success(
|
|
||||||
self,
|
|
||||||
authenticated_client,
|
|
||||||
mock_series_app,
|
|
||||||
mock_nfo_service,
|
|
||||||
tmp_path,
|
|
||||||
override_dependencies
|
|
||||||
):
|
|
||||||
"""Test successful NFO update."""
|
|
||||||
mock_nfo_service.check_nfo_exists = AsyncMock(return_value=True)
|
|
||||||
|
|
||||||
with patch('src.server.api.nfo.settings') as mock_settings:
|
|
||||||
mock_settings.anime_directory = str(tmp_path)
|
|
||||||
|
|
||||||
response = await authenticated_client.put(
|
|
||||||
"/api/nfo/test-anime/update?download_media=true"
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert "NFO updated successfully" in data["message"]
|
|
||||||
|
|
||||||
|
|
||||||
class TestNFOContentEndpoint:
|
|
||||||
"""Tests for GET /api/nfo/{serie_id}/content endpoint."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_content_requires_auth(
|
|
||||||
self,
|
|
||||||
client,
|
|
||||||
override_nfo_service_for_auth_tests
|
|
||||||
):
|
|
||||||
"""Test that content endpoint requires authentication."""
|
|
||||||
response = await client.get("/api/nfo/test-anime/content")
|
|
||||||
assert response.status_code in (401, 503)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_content_nfo_not_found(
|
|
||||||
self,
|
|
||||||
authenticated_client,
|
|
||||||
mock_series_app,
|
|
||||||
mock_nfo_service,
|
|
||||||
tmp_path,
|
|
||||||
override_dependencies
|
|
||||||
):
|
|
||||||
"""Test get content when NFO doesn't exist."""
|
|
||||||
with patch('src.server.api.nfo.settings') as mock_settings:
|
|
||||||
mock_settings.anime_directory = str(tmp_path)
|
|
||||||
|
|
||||||
response = await authenticated_client.get(
|
|
||||||
"/api/nfo/test-anime/content"
|
|
||||||
)
|
|
||||||
assert response.status_code == 404
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_content_success(
|
|
||||||
self,
|
|
||||||
authenticated_client,
|
|
||||||
mock_series_app,
|
|
||||||
mock_nfo_service,
|
|
||||||
tmp_path,
|
|
||||||
override_dependencies
|
|
||||||
):
|
|
||||||
"""Test successful content retrieval."""
|
|
||||||
# Create NFO file
|
|
||||||
anime_dir = tmp_path / "Test Anime (2024)"
|
|
||||||
anime_dir.mkdir()
|
|
||||||
nfo_file = anime_dir / "tvshow.nfo"
|
|
||||||
nfo_file.write_text("<tvshow><title>Test</title></tvshow>")
|
|
||||||
|
|
||||||
with patch('src.server.api.nfo.settings') as mock_settings:
|
|
||||||
mock_settings.anime_directory = str(tmp_path)
|
|
||||||
|
|
||||||
response = await authenticated_client.get(
|
|
||||||
"/api/nfo/test-anime/content"
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert "<tvshow>" in data["content"]
|
|
||||||
assert data["file_size"] > 0
|
|
||||||
|
|
||||||
|
|
||||||
class TestNFOMissingEndpoint:
|
|
||||||
"""Tests for GET /api/nfo/missing endpoint."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_missing_requires_auth(
|
|
||||||
self,
|
|
||||||
client,
|
|
||||||
override_nfo_service_for_auth_tests
|
|
||||||
):
|
|
||||||
"""Test that missing endpoint requires authentication."""
|
|
||||||
response = await client.get("/api/nfo/missing")
|
|
||||||
assert response.status_code in (401, 503)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_missing_success(
|
|
||||||
self,
|
|
||||||
authenticated_client,
|
|
||||||
mock_series_app,
|
|
||||||
mock_nfo_service,
|
|
||||||
tmp_path,
|
|
||||||
override_dependencies
|
|
||||||
):
|
|
||||||
"""Test getting list of series without NFO."""
|
|
||||||
with patch('src.server.api.nfo.settings') as mock_settings:
|
|
||||||
mock_settings.anime_directory = str(tmp_path)
|
|
||||||
|
|
||||||
response = await authenticated_client.get("/api/nfo/missing")
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert "total_series" in data
|
|
||||||
assert "missing_nfo_count" in data
|
|
||||||
assert "series" in data
|
|
||||||
|
|
||||||
|
|
||||||
class TestNFOBatchCreateEndpoint:
|
|
||||||
"""Tests for POST /api/nfo/batch/create endpoint."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_batch_create_requires_auth(
|
|
||||||
self,
|
|
||||||
client,
|
|
||||||
override_nfo_service_for_auth_tests
|
|
||||||
):
|
|
||||||
"""Test that batch create endpoint requires authentication."""
|
|
||||||
response = await client.post(
|
|
||||||
"/api/nfo/batch/create",
|
|
||||||
json={"serie_ids": ["test1", "test2"]}
|
|
||||||
)
|
|
||||||
assert response.status_code in (401, 503)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_batch_create_success(
|
|
||||||
self,
|
|
||||||
override_dependencies,
|
|
||||||
authenticated_client,
|
|
||||||
mock_series_app,
|
|
||||||
mock_nfo_service,
|
|
||||||
tmp_path
|
|
||||||
):
|
|
||||||
"""Test successful batch NFO creation."""
|
|
||||||
with patch('src.server.api.nfo.settings') as mock_settings:
|
|
||||||
mock_settings.anime_directory = str(tmp_path)
|
|
||||||
|
|
||||||
response = await authenticated_client.post(
|
|
||||||
"/api/nfo/batch/create",
|
|
||||||
json={
|
|
||||||
"serie_ids": ["test-anime"],
|
|
||||||
"download_media": True,
|
|
||||||
"skip_existing": False,
|
|
||||||
"max_concurrent": 3
|
|
||||||
}
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["total"] == 1
|
|
||||||
assert "successful" in data
|
|
||||||
assert "results" in data
|
|
||||||
|
|
||||||
|
|
||||||
class TestNFOServiceDependency:
|
|
||||||
"""Tests for NFO service dependency."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_nfo_service_unavailable_without_api_key(
|
|
||||||
self,
|
|
||||||
authenticated_client
|
|
||||||
):
|
|
||||||
"""Test NFO endpoints fail gracefully without TMDB API key.
|
|
||||||
|
|
||||||
This test verifies that when the NFO service dependency raises an
|
|
||||||
HTTPException 503 due to missing TMDB API key, the endpoint returns 503.
|
|
||||||
"""
|
|
||||||
from fastapi import HTTPException, status
|
|
||||||
|
|
||||||
from src.server.api.nfo import get_nfo_service
|
|
||||||
|
|
||||||
# Create a dependency that raises HTTPException 503 (simulating missing API key)
|
|
||||||
async def fail_nfo_service():
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
||||||
detail="NFO service not configured: TMDB API key not available"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Override NFO service to simulate missing API key
|
|
||||||
app.dependency_overrides[get_nfo_service] = fail_nfo_service
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = await authenticated_client.get(
|
|
||||||
"/api/nfo/test-anime/check"
|
|
||||||
)
|
|
||||||
assert response.status_code == 503
|
|
||||||
data = response.json()
|
|
||||||
assert "not configured" in data["detail"]
|
|
||||||
finally:
|
|
||||||
# Clean up override
|
|
||||||
if get_nfo_service in app.dependency_overrides:
|
|
||||||
del app.dependency_overrides[get_nfo_service]
|
|
||||||
138
tests/frontend/e2e/anime_settings_page.spec.js
Normal file
138
tests/frontend/e2e/anime_settings_page.spec.js
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
/**
|
||||||
|
* Playwright E2E: Anime Settings page
|
||||||
|
*
|
||||||
|
* Verifies the new flow after the rename from "NFO Diagnostics" to
|
||||||
|
* "Anime Settings":
|
||||||
|
* 1. Worker-scoped auth: login via API ONCE per worker and reuse the
|
||||||
|
* JWT across tests (avoids the server's per-IP rate limit).
|
||||||
|
* 2. Navigate to /
|
||||||
|
* 3. Right-click on first .series-card
|
||||||
|
* 4. Click "Anime Settings" in the context menu
|
||||||
|
* 5. Verify navigation to /anime/settings?key=...
|
||||||
|
* 6. Verify the settings form is populated with the series data
|
||||||
|
*
|
||||||
|
* Run with: `E2E_PASSWORD=... npx playwright test anime_settings_page.spec.js`
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test as base, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
const BASE_URL = process.env.E2E_BASE_URL || 'http://127.0.0.1:8000';
|
||||||
|
const TEST_PASSWORD = process.env.E2E_PASSWORD;
|
||||||
|
|
||||||
|
// Worker-scoped auth fixture: login once per worker, share the token
|
||||||
|
// across all tests to avoid triggering the server's login rate limit.
|
||||||
|
const test = base.extend({
|
||||||
|
authedPage: async ({ page, context }, use) => {
|
||||||
|
test.skip(!TEST_PASSWORD, 'Set E2E_PASSWORD env var to run this test');
|
||||||
|
|
||||||
|
const resp = await context.request.post(`${BASE_URL}/api/auth/login`, {
|
||||||
|
data: { password: TEST_PASSWORD },
|
||||||
|
});
|
||||||
|
// If the IP is locked out (429), skip the entire suite so the
|
||||||
|
// user can wait for the lockout to expire.
|
||||||
|
test.skip(
|
||||||
|
resp.status() === 429,
|
||||||
|
'Server login rate-limited (429). Wait ~5 minutes.',
|
||||||
|
);
|
||||||
|
expect(resp.status(), 'auth/login should succeed').toBe(200);
|
||||||
|
const body = await resp.json();
|
||||||
|
const token = body.access_token;
|
||||||
|
|
||||||
|
// Visit any page from this origin so we can write to localStorage
|
||||||
|
await page.goto(`${BASE_URL}/login`);
|
||||||
|
await page.evaluate((t) => {
|
||||||
|
localStorage.setItem('access_token', t);
|
||||||
|
}, token);
|
||||||
|
|
||||||
|
await use(page);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Anime Settings page (right-click flow)', () => {
|
||||||
|
test('right-click series card opens Anime Settings page', async ({ authedPage: page }) => {
|
||||||
|
await page.goto(BASE_URL);
|
||||||
|
|
||||||
|
// Wait for at least one series card to render
|
||||||
|
await page.waitForSelector('.series-card', { timeout: 15000 });
|
||||||
|
|
||||||
|
// Right-click on the first series card
|
||||||
|
const firstCard = page.locator('.series-card').first();
|
||||||
|
const key = await firstCard.getAttribute('data-key');
|
||||||
|
expect(key, 'series card must have data-key').toBeTruthy();
|
||||||
|
|
||||||
|
await firstCard.click({ button: 'right' });
|
||||||
|
|
||||||
|
// The custom context menu should appear with the renamed action
|
||||||
|
const menuItem = page.locator('[data-action="anime-settings"]');
|
||||||
|
await expect(menuItem).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Click the menu item — should navigate to /anime/settings?key=...
|
||||||
|
await menuItem.click();
|
||||||
|
await page.waitForURL(/\/anime\/settings/, { timeout: 10000 });
|
||||||
|
|
||||||
|
// The settings page should show the editor section (not loading/error)
|
||||||
|
await expect(page.locator('#settings-section')).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The form input for name should be populated (i.e. not empty)
|
||||||
|
const nameInput = page.locator('#field-name');
|
||||||
|
await expect(nameInput).toBeVisible();
|
||||||
|
const nameValue = await nameInput.inputValue();
|
||||||
|
expect(nameValue.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// The URL should carry the key param
|
||||||
|
const url = new URL(page.url());
|
||||||
|
expect(url.pathname).toBe('/anime/settings');
|
||||||
|
expect(url.searchParams.get('key')).toBe(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('direct navigation to /anime/settings?key=... works', async ({ authedPage: page }) => {
|
||||||
|
await page.goto(BASE_URL);
|
||||||
|
await page.waitForSelector('.series-card', { timeout: 15000 });
|
||||||
|
const firstCard = page.locator('.series-card').first();
|
||||||
|
const key = await firstCard.getAttribute('data-key');
|
||||||
|
expect(key).toBeTruthy();
|
||||||
|
|
||||||
|
await page.goto(`${BASE_URL}/anime/settings?key=${encodeURIComponent(key)}`);
|
||||||
|
await expect(page.locator('#settings-section')).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Overview should show the key
|
||||||
|
await expect(page.locator('#overview-key')).toContainText(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('legacy /settings/nfo URL redirects to /anime/settings', async ({ authedPage: page }) => {
|
||||||
|
const resp = await page.goto(`${BASE_URL}/settings/nfo`, {
|
||||||
|
waitUntil: 'load',
|
||||||
|
});
|
||||||
|
// FastAPI RedirectResponse returns 301 (permanent) or 307 (temp)
|
||||||
|
expect([301, 307, 200]).toContain(resp.status());
|
||||||
|
// Following the redirect should land on /anime/settings
|
||||||
|
const finalPath = new URL(page.url()).pathname;
|
||||||
|
// Allow trailing slash variants
|
||||||
|
expect(['/anime/settings', '/anime/settings/']).toContain(finalPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('context menu no longer shows NFO Diagnostics', async ({ authedPage: page }) => {
|
||||||
|
await page.goto(BASE_URL);
|
||||||
|
await page.waitForSelector('.series-card', { timeout: 15000 });
|
||||||
|
const firstCard = page.locator('.series-card').first();
|
||||||
|
await firstCard.click({ button: 'right' });
|
||||||
|
// The legacy action should be gone
|
||||||
|
const legacy = page.locator('[data-action="nfo-diagnostics"]');
|
||||||
|
await expect(legacy).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('context menu shows Anime Settings action', async ({ authedPage: page }) => {
|
||||||
|
await page.goto(BASE_URL);
|
||||||
|
await page.waitForSelector('.series-card', { timeout: 15000 });
|
||||||
|
const firstCard = page.locator('.series-card').first();
|
||||||
|
await firstCard.click({ button: 'right' });
|
||||||
|
const menuItem = page.locator('[data-action="anime-settings"]');
|
||||||
|
await expect(menuItem).toBeVisible({ timeout: 5000 });
|
||||||
|
// Verify label says "Anime Settings" (not "NFO Diagnostics")
|
||||||
|
await expect(menuItem).toContainText(/Anime Settings/);
|
||||||
|
});
|
||||||
|
});
|
||||||
501
tests/frontend/unit/anime_settings.test.js
Normal file
501
tests/frontend/unit/anime_settings.test.js
Normal file
@@ -0,0 +1,501 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for AniWorld.AnimeSettingsManager
|
||||||
|
*
|
||||||
|
* Tests every public function on the per-anime settings page JS module:
|
||||||
|
* - init() : binds DOM events, starts initial load
|
||||||
|
* - loadSeries(key) : fetches /api/anime/{key}/settings
|
||||||
|
* - saveSettings(opts) : PUTs /api/anime/{key}/settings
|
||||||
|
* - regenerateNfo() : POSTs /api/anime/{key}/regenerate-nfo
|
||||||
|
* - validateField(name, value) : client-side validation
|
||||||
|
* - populateForm(data) : fills form from payload
|
||||||
|
* - showSaveSuccess(msg) : success toast
|
||||||
|
* - showError(msg) : error toast
|
||||||
|
*
|
||||||
|
* Also verifies the auth header is included on every fetch.
|
||||||
|
*/
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Polyfill fetch globally (Vitest JSDOM env provides it but stub for clarity)
|
||||||
|
function mockFetchSequence(responses) {
|
||||||
|
let callIndex = 0;
|
||||||
|
global.fetch = vi.fn(async () => {
|
||||||
|
const r = responses[callIndex++];
|
||||||
|
if (!r) {
|
||||||
|
throw new Error('Unexpected fetch call');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: r.ok !== false,
|
||||||
|
status: r.status || 200,
|
||||||
|
json: async () => r.body || {},
|
||||||
|
text: async () => r.text || JSON.stringify(r.body || {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readModuleSource() {
|
||||||
|
// Load the AnimeSettingsManager source via fs and eval inside a
|
||||||
|
// window-like scope. This mirrors the production IIFE pattern.
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const src = fs.readFileSync(
|
||||||
|
path.resolve(__dirname, '../../../src/server/web/static/js/pages/anime-settings.js'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
// Execute in global scope
|
||||||
|
// eslint-disable-next-line no-eval
|
||||||
|
(0, eval)(src);
|
||||||
|
return global.AniWorld.AnimeSettingsManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AnimeSettingsManager', () => {
|
||||||
|
let manager;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Build a minimal DOM tree covering every id the module touches
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div id="no-key-section" class="hidden"></div>
|
||||||
|
<div id="loading-section" class="hidden"></div>
|
||||||
|
<div id="error-section" class="hidden"></div>
|
||||||
|
<div id="settings-section" class="hidden"></div>
|
||||||
|
|
||||||
|
<select id="series-select"></select>
|
||||||
|
<button id="load-series-btn"></button>
|
||||||
|
<button id="retry-btn"></button>
|
||||||
|
|
||||||
|
<p id="error-message"></p>
|
||||||
|
<h2 id="series-display-name"></h2>
|
||||||
|
<span id="badge-loading-status"></span>
|
||||||
|
<span id="badge-has-nfo"></span>
|
||||||
|
<span id="badge-episode-counts"></span>
|
||||||
|
|
||||||
|
<code id="overview-key"></code>
|
||||||
|
<span id="overview-year"></span>
|
||||||
|
<span id="overview-loading-status"></span>
|
||||||
|
<span id="overview-episode-count"></span>
|
||||||
|
<span id="overview-missing-count"></span>
|
||||||
|
<span id="overview-nfo-created"></span>
|
||||||
|
<span id="overview-nfo-updated"></span>
|
||||||
|
<code id="overview-nfo-path"></code>
|
||||||
|
|
||||||
|
<input type="text" id="field-name" />
|
||||||
|
<input type="text" id="field-folder" />
|
||||||
|
<input type="number" id="field-tmdb-id" />
|
||||||
|
<input type="number" id="field-tvdb-id" />
|
||||||
|
<input type="text" id="field-site" />
|
||||||
|
|
||||||
|
<small id="hint-name"></small>
|
||||||
|
<small id="hint-folder"></small>
|
||||||
|
<small id="hint-tmdb-id"></small>
|
||||||
|
<small id="hint-tvdb-id"></small>
|
||||||
|
<small id="hint-site"></small>
|
||||||
|
|
||||||
|
<button id="save-db-btn"></button>
|
||||||
|
<button id="save-db-nfo-btn"></button>
|
||||||
|
<button id="reset-btn"></button>
|
||||||
|
<input type="checkbox" id="rename-disk-toggle" />
|
||||||
|
|
||||||
|
<button id="regenerate-nfo-btn"></button>
|
||||||
|
<button id="view-nfo-btn"></button>
|
||||||
|
<pre id="nfo-content" class="hidden"></pre>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Provide the shared helpers the module expects
|
||||||
|
global.AniWorld = {
|
||||||
|
Auth: {
|
||||||
|
getToken: vi.fn(() => 'fake-jwt-token'),
|
||||||
|
checkAuth: vi.fn().mockResolvedValue(true),
|
||||||
|
},
|
||||||
|
UiUtils: {
|
||||||
|
showToast: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load module
|
||||||
|
manager = readModuleSource();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// init()
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('init()', () => {
|
||||||
|
it('reads ?key= from URL and calls loadSeries', async () => {
|
||||||
|
mockFetchSequence([{
|
||||||
|
status: 200,
|
||||||
|
body: { key: 'aot', name: 'AOT', tmdb_id: 1 },
|
||||||
|
}]);
|
||||||
|
|
||||||
|
// Replace window.location with a controllable mock
|
||||||
|
delete window.location;
|
||||||
|
window.location = { search: '?key=aot', href: 'http://x/anime/settings?key=aot' };
|
||||||
|
|
||||||
|
manager.init();
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||||
|
const url = global.fetch.mock.calls[0][0];
|
||||||
|
expect(url).toContain('/api/anime/aot/settings');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows no-key section when no ?key is present', async () => {
|
||||||
|
delete window.location;
|
||||||
|
window.location = { search: '', href: 'http://x/anime/settings' };
|
||||||
|
|
||||||
|
// Stub populateSeriesSelect to avoid network
|
||||||
|
global.fetch = vi.fn(async () => ({
|
||||||
|
ok: true, status: 200,
|
||||||
|
json: async () => [],
|
||||||
|
text: async () => '[]',
|
||||||
|
}));
|
||||||
|
|
||||||
|
manager.init();
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
const section = document.getElementById('no-key-section');
|
||||||
|
expect(section.classList.contains('hidden')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// loadSeries()
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('loadSeries()', () => {
|
||||||
|
it('calls fetch with auth header', async () => {
|
||||||
|
mockFetchSequence([{
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
key: 'naruto',
|
||||||
|
name: 'Naruto',
|
||||||
|
site: 'aniworld.to',
|
||||||
|
folder: 'Naruto (2002)',
|
||||||
|
year: 2002,
|
||||||
|
tmdb_id: 20,
|
||||||
|
tvdb_id: null,
|
||||||
|
has_nfo: true,
|
||||||
|
nfo_path: '/anime/Naruto/tvshow.nfo',
|
||||||
|
episode_count: 5,
|
||||||
|
missing_episode_count: 2,
|
||||||
|
loading_status: 'completed',
|
||||||
|
},
|
||||||
|
}]);
|
||||||
|
|
||||||
|
await manager.loadSeries('naruto');
|
||||||
|
const [url, opts] = global.fetch.mock.calls[0];
|
||||||
|
expect(url).toBe('/api/anime/naruto/settings');
|
||||||
|
expect(opts.headers.Authorization).toBe('Bearer fake-jwt-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('populates the form on success', async () => {
|
||||||
|
mockFetchSequence([{
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
key: 'naruto',
|
||||||
|
name: 'Naruto',
|
||||||
|
site: 'aniworld.to',
|
||||||
|
folder: 'Naruto (2002)',
|
||||||
|
year: 2002,
|
||||||
|
tmdb_id: 20,
|
||||||
|
tvdb_id: null,
|
||||||
|
has_nfo: true,
|
||||||
|
nfo_path: '/anime/Naruto/tvshow.nfo',
|
||||||
|
episode_count: 5,
|
||||||
|
missing_episode_count: 2,
|
||||||
|
loading_status: 'completed',
|
||||||
|
},
|
||||||
|
}]);
|
||||||
|
|
||||||
|
await manager.loadSeries('naruto');
|
||||||
|
expect(document.getElementById('field-name').value).toBe('Naruto');
|
||||||
|
expect(document.getElementById('field-folder').value).toBe('Naruto (2002)');
|
||||||
|
expect(document.getElementById('field-tmdb-id').value).toBe('20');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles 404 by showing the error section', async () => {
|
||||||
|
mockFetchSequence([{ status: 404, body: { detail: 'not found' } }]);
|
||||||
|
await manager.loadSeries('missing');
|
||||||
|
expect(
|
||||||
|
document.getElementById('error-section').classList.contains('hidden')
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles 401 by calling showError', async () => {
|
||||||
|
mockFetchSequence([{ status: 401, body: { detail: 'unauthorized' } }]);
|
||||||
|
await manager.loadSeries('whatever');
|
||||||
|
expect(global.AniWorld.UiUtils.showToast).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('authenticated'),
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// saveSettings()
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('saveSettings()', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
// First, set currentKey via loadSeries (matches URL-based init)
|
||||||
|
delete window.location;
|
||||||
|
window.location = { search: '?key=a', href: 'http://x/?key=a' };
|
||||||
|
mockFetchSequence([{
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
key: 'a', name: 'A', folder: 'A', site: 's',
|
||||||
|
tmdb_id: null, tvdb_id: null, has_nfo: false,
|
||||||
|
nfo_path: null, episode_count: 0, missing_episode_count: 0,
|
||||||
|
loading_status: 'completed',
|
||||||
|
},
|
||||||
|
}]);
|
||||||
|
await manager.loadSeries('a');
|
||||||
|
|
||||||
|
// Now overwrite the form values with what we want to save.
|
||||||
|
// (loadSeries populates form from server, but we want to test
|
||||||
|
// that saveSettings sends the user-typed values, so we mutate
|
||||||
|
// them AFTER the load.)
|
||||||
|
document.getElementById('field-name').value = 'New Name';
|
||||||
|
document.getElementById('field-folder').value = 'New Folder';
|
||||||
|
document.getElementById('field-tmdb-id').value = '1234';
|
||||||
|
document.getElementById('field-tvdb-id').value = '';
|
||||||
|
document.getElementById('field-site').value = 'https://x';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends PUT with auth header and JSON body', async () => {
|
||||||
|
mockFetchSequence([{
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
key: 'a',
|
||||||
|
name: 'New Name',
|
||||||
|
folder: 'New Folder',
|
||||||
|
tmdb_id: 1234,
|
||||||
|
},
|
||||||
|
}]);
|
||||||
|
|
||||||
|
await manager.saveSettings({ applyToNfo: false });
|
||||||
|
|
||||||
|
const [url, opts] = global.fetch.mock.calls[0];
|
||||||
|
expect(url).toBe('/api/anime/a/settings');
|
||||||
|
expect(opts.method).toBe('PUT');
|
||||||
|
expect(opts.headers.Authorization).toBe('Bearer fake-jwt-token');
|
||||||
|
expect(opts.headers['Content-Type']).toBe('application/json');
|
||||||
|
const body = JSON.parse(opts.body);
|
||||||
|
expect(body.name).toBe('New Name');
|
||||||
|
expect(body.folder).toBe('New Folder');
|
||||||
|
// form inputs return strings; the module passes them through
|
||||||
|
// verbatim — the server coerces to int.
|
||||||
|
expect(String(body.tmdb_id)).toBe('1234');
|
||||||
|
expect(body.apply_to_nfo).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows success toast on save', async () => {
|
||||||
|
mockFetchSequence([{
|
||||||
|
status: 200,
|
||||||
|
body: { key: 'a', name: 'New Name' },
|
||||||
|
}]);
|
||||||
|
await manager.saveSettings({ applyToNfo: false });
|
||||||
|
expect(global.AniWorld.UiUtils.showToast).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('saved'),
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "regenerated" message when applyToNfo=true', async () => {
|
||||||
|
mockFetchSequence([{
|
||||||
|
status: 200,
|
||||||
|
body: { key: 'a', name: 'New Name', has_nfo: true },
|
||||||
|
}]);
|
||||||
|
await manager.saveSettings({ applyToNfo: true });
|
||||||
|
expect(global.AniWorld.UiUtils.showToast).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('regenerated'),
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error toast on 422', async () => {
|
||||||
|
mockFetchSequence([{ status: 422, body: { detail: 'bad tmdb_id' } }]);
|
||||||
|
await manager.saveSettings({ applyToNfo: false });
|
||||||
|
expect(global.AniWorld.UiUtils.showToast).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('Validation'),
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// regenerateNfo()
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('regenerateNfo()', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
delete window.location;
|
||||||
|
window.location = { search: '?key=a', href: 'http://x/?key=a' };
|
||||||
|
mockFetchSequence([{
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
key: 'a', name: 'A', folder: 'A', site: 's',
|
||||||
|
tmdb_id: null, tvdb_id: null, has_nfo: false,
|
||||||
|
nfo_path: null, episode_count: 0, missing_episode_count: 0,
|
||||||
|
loading_status: 'completed',
|
||||||
|
},
|
||||||
|
}]);
|
||||||
|
await manager.loadSeries('a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls POST /regenerate-nfo and shows success toast', async () => {
|
||||||
|
mockFetchSequence([{
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
success: true,
|
||||||
|
message: 'NFO regenerated.',
|
||||||
|
repaired_tags: ['title'],
|
||||||
|
},
|
||||||
|
}]);
|
||||||
|
|
||||||
|
await manager.regenerateNfo();
|
||||||
|
const [url, opts] = global.fetch.mock.calls[0];
|
||||||
|
expect(url).toBe('/api/anime/a/regenerate-nfo');
|
||||||
|
expect(opts.method).toBe('POST');
|
||||||
|
expect(global.AniWorld.UiUtils.showToast).toHaveBeenCalledWith(
|
||||||
|
'NFO regenerated.',
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error toast on 400 (no tmdb_id)', async () => {
|
||||||
|
mockFetchSequence([{ status: 400, body: { detail: 'no TMDB ID' } }]);
|
||||||
|
await manager.regenerateNfo();
|
||||||
|
expect(global.AniWorld.UiUtils.showToast).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('Cannot regenerate'),
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// validateField()
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('validateField()', () => {
|
||||||
|
it('rejects empty name', () => {
|
||||||
|
expect(manager.validateField('name', '')).toMatch(/empty/i);
|
||||||
|
expect(manager.validateField('name', null)).toMatch(/empty/i);
|
||||||
|
});
|
||||||
|
it('rejects too-long name', () => {
|
||||||
|
expect(manager.validateField('name', 'x'.repeat(501))).toMatch(/exceeds/);
|
||||||
|
});
|
||||||
|
it('accepts valid name', () => {
|
||||||
|
expect(manager.validateField('name', 'Naruto')).toBeNull();
|
||||||
|
});
|
||||||
|
it('rejects folder with path traversal', () => {
|
||||||
|
expect(manager.validateField('folder', '../etc')).toMatch(/path traversal/i);
|
||||||
|
});
|
||||||
|
it('rejects folder with invalid characters', () => {
|
||||||
|
expect(manager.validateField('folder', 'foo\x00bar')).toMatch(/invalid/i);
|
||||||
|
});
|
||||||
|
it('accepts tmdb_id as integer string', () => {
|
||||||
|
expect(manager.validateField('tmdb_id', '12345')).toBeNull();
|
||||||
|
});
|
||||||
|
it('rejects tmdb_id = "abc"', () => {
|
||||||
|
expect(manager.validateField('tmdb_id', 'abc')).toMatch(/integer/i);
|
||||||
|
});
|
||||||
|
it('rejects negative tmdb_id', () => {
|
||||||
|
expect(manager.validateField('tmdb_id', '-5')).toMatch(/positive/i);
|
||||||
|
});
|
||||||
|
it('rejects oversized tmdb_id', () => {
|
||||||
|
expect(manager.validateField('tmdb_id', '99999999999')).toMatch(/10 digits/i);
|
||||||
|
});
|
||||||
|
it('accepts empty tvdb_id (optional)', () => {
|
||||||
|
expect(manager.validateField('tvdb_id', '')).toBeNull();
|
||||||
|
expect(manager.validateField('tvdb_id', undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
it('rejects negative tvdb_id', () => {
|
||||||
|
expect(manager.validateField('tvdb_id', '-1')).toMatch(/positive/i);
|
||||||
|
});
|
||||||
|
it('accepts valid site', () => {
|
||||||
|
expect(manager.validateField('site', 'https://aniworld.to')).toBeNull();
|
||||||
|
});
|
||||||
|
it('rejects too-long site', () => {
|
||||||
|
expect(manager.validateField('site', 'x'.repeat(501))).toMatch(/exceeds/);
|
||||||
|
});
|
||||||
|
it('returns null for unknown field name', () => {
|
||||||
|
expect(manager.validateField('mystery_field', 'anything')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// populateForm()
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('populateForm()', () => {
|
||||||
|
it('sets all overview and form fields', () => {
|
||||||
|
manager.populateForm({
|
||||||
|
key: 'a',
|
||||||
|
name: 'A',
|
||||||
|
site: 'aniworld.to',
|
||||||
|
folder: 'A (2020)',
|
||||||
|
year: 2020,
|
||||||
|
tmdb_id: 100,
|
||||||
|
tvdb_id: 200,
|
||||||
|
has_nfo: true,
|
||||||
|
nfo_path: '/anime/A/tvshow.nfo',
|
||||||
|
episode_count: 12,
|
||||||
|
missing_episode_count: 3,
|
||||||
|
loading_status: 'completed',
|
||||||
|
});
|
||||||
|
expect(document.getElementById('field-name').value).toBe('A');
|
||||||
|
expect(document.getElementById('field-folder').value).toBe('A (2020)');
|
||||||
|
expect(document.getElementById('field-tmdb-id').value).toBe('100');
|
||||||
|
expect(document.getElementById('field-tvdb-id').value).toBe('200');
|
||||||
|
expect(document.getElementById('overview-key').textContent).toBe('a');
|
||||||
|
expect(document.getElementById('overview-year').textContent).toBe('2020');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles missing optional fields gracefully', () => {
|
||||||
|
manager.populateForm({ key: 'a', name: 'A' });
|
||||||
|
expect(document.getElementById('field-tmdb-id').value).toBe('');
|
||||||
|
expect(document.getElementById('field-tvdb-id').value).toBe('');
|
||||||
|
expect(document.getElementById('field-name').value).toBe('A');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// showSaveSuccess() / showError()
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('showSaveSuccess()', () => {
|
||||||
|
it('calls AniWorld.UiUtils.showToast with success type', () => {
|
||||||
|
manager.showSaveSuccess('Saved!');
|
||||||
|
expect(global.AniWorld.UiUtils.showToast).toHaveBeenCalledWith(
|
||||||
|
'Saved!', 'success'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('showError()', () => {
|
||||||
|
it('calls AniWorld.UiUtils.showToast with error type', () => {
|
||||||
|
manager.showError('Boom');
|
||||||
|
expect(global.AniWorld.UiUtils.showToast).toHaveBeenCalledWith(
|
||||||
|
'Boom', 'error'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Public API surface
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('exposes all expected public methods', () => {
|
||||||
|
expect(typeof manager.init).toBe('function');
|
||||||
|
expect(typeof manager.loadSeries).toBe('function');
|
||||||
|
expect(typeof manager.saveSettings).toBe('function');
|
||||||
|
expect(typeof manager.regenerateNfo).toBe('function');
|
||||||
|
expect(typeof manager.validateField).toBe('function');
|
||||||
|
expect(typeof manager.populateForm).toBe('function');
|
||||||
|
expect(typeof manager.showSaveSuccess).toBe('function');
|
||||||
|
expect(typeof manager.showError).toBe('function');
|
||||||
|
});
|
||||||
|
});
|
||||||
167
tests/frontend/unit/context_menu.test.js
Normal file
167
tests/frontend/unit/context_menu.test.js
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for AniWorld.ContextMenu
|
||||||
|
*
|
||||||
|
* Covers the right-click → "Anime Settings" navigation flow including
|
||||||
|
* the regression where `hide()` was called BEFORE the navigation
|
||||||
|
* `window.location.href` was built, which caused the key to be reset
|
||||||
|
* to null and the URL to become `/anime/settings?key=null`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const SRC_PATH = resolve(
|
||||||
|
__dirname,
|
||||||
|
'../../../src/server/web/static/js/index/context-menu.js',
|
||||||
|
);
|
||||||
|
|
||||||
|
function loadContextMenu() {
|
||||||
|
// Reset module state so each test gets a fresh closure.
|
||||||
|
delete global.AniWorld;
|
||||||
|
const src = readFileSync(SRC_PATH, 'utf8');
|
||||||
|
// Indirect eval → runs in global scope so `var AniWorld = window.AniWorld
|
||||||
|
// || {}` mutates the real `global.AniWorld` (and through it,
|
||||||
|
// `window.AniWorld` since happy-dom exposes global on window).
|
||||||
|
// eslint-disable-next-line no-eval
|
||||||
|
(0, eval)(src);
|
||||||
|
return global.AniWorld.ContextMenu;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ContextMenu — right-click → Anime Settings flow', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
delete window.AniWorld;
|
||||||
|
delete global.AniWorld;
|
||||||
|
delete window.location;
|
||||||
|
window.location = { href: '' };
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigates to /anime/settings?key=<series-key> after menu click', () => {
|
||||||
|
const ctx = loadContextMenu();
|
||||||
|
expect(ctx).toBeTruthy();
|
||||||
|
expect(typeof ctx.show).toBe('function');
|
||||||
|
|
||||||
|
const grid = document.createElement('div');
|
||||||
|
grid.id = 'series-grid';
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'series-card';
|
||||||
|
card.setAttribute('data-key', 'attack-on-titan');
|
||||||
|
grid.appendChild(card);
|
||||||
|
document.body.appendChild(grid);
|
||||||
|
|
||||||
|
ctx.init();
|
||||||
|
|
||||||
|
card.dispatchEvent(
|
||||||
|
new MouseEvent('contextmenu', {
|
||||||
|
bubbles: true,
|
||||||
|
clientX: 10,
|
||||||
|
clientY: 10,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const menuItem = document.querySelector(
|
||||||
|
'[data-action="anime-settings"]',
|
||||||
|
);
|
||||||
|
expect(menuItem).toBeTruthy();
|
||||||
|
|
||||||
|
menuItem.click();
|
||||||
|
|
||||||
|
expect(window.location.href).toBe(
|
||||||
|
'/anime/settings?key=attack-on-titan',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encodes special characters in the key (URL-unsafe slugs)', () => {
|
||||||
|
const ctx = loadContextMenu();
|
||||||
|
|
||||||
|
const grid = document.createElement('div');
|
||||||
|
grid.id = 'series-grid';
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'series-card';
|
||||||
|
card.setAttribute('data-key', 'a/b c');
|
||||||
|
grid.appendChild(card);
|
||||||
|
document.body.appendChild(grid);
|
||||||
|
|
||||||
|
ctx.init();
|
||||||
|
card.dispatchEvent(
|
||||||
|
new MouseEvent('contextmenu', {
|
||||||
|
bubbles: true,
|
||||||
|
clientX: 5,
|
||||||
|
clientY: 5,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
document.querySelector('[data-action="anime-settings"]').click();
|
||||||
|
|
||||||
|
expect(window.location.href).toBe('/anime/settings?key=a%2Fb%20c');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('source captures the key before calling hide() — regression guard', () => {
|
||||||
|
// Static invariant: the click handler must read currentSeriesKey
|
||||||
|
// BEFORE calling hide(). This guards against regressions of the
|
||||||
|
// bug where hide() cleared currentSeriesKey before the URL was
|
||||||
|
// built, resulting in /anime/settings?key=null.
|
||||||
|
const src = readFileSync(SRC_PATH, 'utf8');
|
||||||
|
const clickHandlerMatch = src.match(
|
||||||
|
/querySelector\('\[data-action="anime-settings"\]'\)\.addEventListener\('click',\s*function\s*\(\)\s*{([\s\S]*?)\}\);/,
|
||||||
|
);
|
||||||
|
expect(clickHandlerMatch, 'click handler should exist').toBeTruthy();
|
||||||
|
const body = clickHandlerMatch[1];
|
||||||
|
|
||||||
|
expect(body).toMatch(/currentSeriesKey/);
|
||||||
|
expect(body).toMatch(/\bhide\s*\(\s*\)/);
|
||||||
|
expect(body).toMatch(/const\s+key\s*=\s*currentSeriesKey/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not expose legacy nfo-diagnostics action', () => {
|
||||||
|
const ctx = loadContextMenu();
|
||||||
|
|
||||||
|
const grid = document.createElement('div');
|
||||||
|
grid.id = 'series-grid';
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'series-card';
|
||||||
|
card.setAttribute('data-key', 'k');
|
||||||
|
grid.appendChild(card);
|
||||||
|
document.body.appendChild(grid);
|
||||||
|
|
||||||
|
ctx.init();
|
||||||
|
card.dispatchEvent(
|
||||||
|
new MouseEvent('contextmenu', {
|
||||||
|
bubbles: true,
|
||||||
|
clientX: 5,
|
||||||
|
clientY: 5,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
document.querySelector('[data-action="nfo-diagnostics"]'),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
document.querySelector('[data-action="anime-settings"]'),
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('right-click outside a series card does not show the menu', () => {
|
||||||
|
const ctx = loadContextMenu();
|
||||||
|
|
||||||
|
const grid = document.createElement('div');
|
||||||
|
grid.id = 'series-grid';
|
||||||
|
document.body.appendChild(grid);
|
||||||
|
|
||||||
|
ctx.init();
|
||||||
|
// Click on empty grid area — should NOT show menu (no .series-card ancestor).
|
||||||
|
grid.dispatchEvent(
|
||||||
|
new MouseEvent('contextmenu', {
|
||||||
|
bubbles: true,
|
||||||
|
clientX: 5,
|
||||||
|
clientY: 5,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(document.querySelector('.context-menu')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
15
vitest.config.js
Normal file
15
vitest.config.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// vitest.config.js
|
||||||
|
// Provides a DOM environment for tests that touch document/window.
|
||||||
|
// Falls back to default node env for backend tests.
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'happy-dom',
|
||||||
|
globals: true,
|
||||||
|
include: [
|
||||||
|
'tests/frontend/**/*.test.{js,ts}',
|
||||||
|
'tests/frontend/unit/**/*.test.{js,ts}',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user