diff --git a/Docs/API.md b/Docs/API.md index cbbe532..d686579 100644 --- a/Docs/API.md +++ b/Docs/API.md @@ -826,14 +826,32 @@ Source: [src/server/api/config.py](../src/server/api/config.py#L189-L247) 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:** - 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 @@ -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) + +## 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. diff --git a/Docs/CHANGELOG.md b/Docs/CHANGELOG.md index 5b77386..0d9c6ca 100644 --- a/Docs/CHANGELOG.md +++ b/Docs/CHANGELOG.md @@ -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=`. 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 ### Fixed diff --git a/package.json b/package.json index f5def1f..bd65590 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "@playwright/test": "^1.41.0", "@vitest/coverage-v8": "^1.2.0", "@vitest/ui": "^1.2.0", - "happy-dom": "^13.3.5", + "happy-dom": "^13.10.1", "vitest": "^1.2.0" }, "engines": { diff --git a/src/server/api/anime.py b/src/server/api/anime.py index ff7649a..e112ae6 100644 --- a/src/server/api/anime.py +++ b/src/server/api/anime.py @@ -1,4 +1,5 @@ import logging +import os import re import warnings from typing import Any, List, Optional @@ -18,6 +19,9 @@ from src.server.exceptions import ( ) from src.server.models.anime import ( AnimeDetailsResponse, + AnimeSettingsRegenerateNfoResponse, + AnimeSettingsResponse, + AnimeSettingsUpdateRequest, TMDBSearchResult, ) from src.server.services.anime_service import AnimeService, AnimeServiceError @@ -30,7 +34,7 @@ from src.server.utils.dependencies import ( get_series_app, 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.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, + ) diff --git a/src/server/api/nfo.py b/src/server/api/nfo.py index 3ef76ad..18fe4e1 100644 --- a/src/server/api/nfo.py +++ b/src/server/api/nfo.py @@ -1,6 +1,6 @@ """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 os @@ -11,9 +11,9 @@ from pydantic import BaseModel from src.config.settings import settings from src.server.models.nfo import ( - NfoDiagnosticsResponse, NfoRepairResponse, - NfoSeriesDiagnostics, + NfoSeriesSettings, + NfoSettingsResponse, ) from src.server.services.anime_service import AnimeService 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.""" total: int - series: List[NfoSeriesDiagnostics] + series: List[NfoSeriesSettings] def _get_nfo_path(folder: str) -> str: @@ -123,13 +123,13 @@ async def _get_series_data( return None -@router.get("/{key}/diagnostics", response_model=NfoDiagnosticsResponse) -async def get_nfo_diagnostics( +@router.get("/{key}/diagnostics", response_model=NfoSettingsResponse) +async def get_nfo_settings( key: str, _auth: dict = Depends(require_auth), anime_service: AnimeService = Depends(get_anime_service), -) -> NfoDiagnosticsResponse: - """Get NFO diagnostics for a specific series. +) -> NfoSettingsResponse: + """Get NFO settings inspection for a specific series. Checks if tvshow.nfo exists in the series folder and validates that required tags are present. @@ -140,7 +140,7 @@ async def get_nfo_diagnostics( anime_service: AnimeService dependency Returns: - NfoDiagnosticsResponse with has_nfo, nfo_path, missing_tags, required_tags + NfoSettingsResponse with has_nfo, nfo_path, missing_tags, required_tags Raises: HTTPException 404: If series not found @@ -165,7 +165,7 @@ async def get_nfo_diagnostics( nfo_exists = os.path.isfile(nfo_path) if not nfo_exists: - return NfoDiagnosticsResponse( + return NfoSettingsResponse( has_nfo=False, nfo_path=None, missing_tags=REQUIRED_TAGS.copy(), @@ -175,7 +175,7 @@ async def get_nfo_diagnostics( # Parse and check for missing tags _, missing = _parse_nfo_file(nfo_path) - return NfoDiagnosticsResponse( + return NfoSettingsResponse( has_nfo=True, nfo_path=nfo_path, missing_tags=missing, @@ -184,7 +184,7 @@ async def get_nfo_diagnostics( @router.post("/{key}/repair", response_model=NfoRepairResponse) -async def repair_nfo( +async def repair_nfo_settings( key: str, _auth: dict = Depends(require_auth), 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) # Update series NFO status in DB - await anime_service.update_series_nfo_status( + await anime_service.update_nfo_status( key=key, 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 missing_before @@ -411,7 +419,7 @@ async def get_series_needing_repair( NfoNeedsRepairListResponse with total count and list of series diagnostics """ 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) if not anime_dir: @@ -432,7 +440,7 @@ async def get_series_needing_repair( nfo_exists = os.path.isfile(nfo_path) if not nfo_exists: - series_needing_repair.append(NfoSeriesDiagnostics( + series_needing_repair.append(NfoSeriesSettings( key=key, name=name, folder=folder, @@ -444,7 +452,7 @@ async def get_series_needing_repair( # Parse and check for missing tags _, missing = _parse_nfo_file(nfo_path) if missing: - series_needing_repair.append(NfoSeriesDiagnostics( + series_needing_repair.append(NfoSeriesSettings( key=key, name=name, folder=folder, diff --git a/src/server/controllers/page_controller.py b/src/server/controllers/page_controller.py index d466bf0..f5aa248 100644 --- a/src/server/controllers/page_controller.py +++ b/src/server/controllers/page_controller.py @@ -72,10 +72,27 @@ async def unresolved_page(request: Request): @router.get("/settings/nfo", response_class=HTMLResponse) -async def nfo_settings_page(request: Request): - """Serve the NFO diagnostics and repair settings page.""" +async def nfo_settings_page_redirect(): + """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( - "nfo-settings.html", + "anime-settings.html", request, - title="NFO Diagnostics - Aniworld" + title="Anime Settings - Aniworld" ) diff --git a/src/server/models/anime.py b/src/server/models/anime.py index d3b400d..c497672 100644 --- a/src/server/models/anime.py +++ b/src/server/models/anime.py @@ -196,7 +196,7 @@ class AnimeDetailsResponse(BaseModel): class TMDBSearchResult(BaseModel): """TMDB search result for auto-lookup functionality. - + Attributes: tmdb_id: TMDB ID of the matched series title: Title from TMDB @@ -210,3 +210,88 @@ class TMDBSearchResult(BaseModel): year: Optional[int] = Field(None, description="Release year") overview: Optional[str] = Field(None, description="Short description") vote_average: Optional[float] = Field(None, description="TMDB rating") + + +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", + ) diff --git a/src/server/models/nfo.py b/src/server/models/nfo.py index c3b48c1..63eff95 100644 --- a/src/server/models/nfo.py +++ b/src/server/models/nfo.py @@ -357,8 +357,8 @@ class NFOMissingResponse(BaseModel): ) -class NfoDiagnosticsResponse(BaseModel): - """Response for NFO diagnostics showing missing required tags.""" +class NfoSettingsResponse(BaseModel): + """Response for NFO settings inspection showing missing required tags.""" has_nfo: bool = Field(..., description="Whether tvshow.nfo exists") nfo_path: Optional[str] = Field(None, description="Path to NFO file if exists") @@ -372,8 +372,8 @@ class NfoDiagnosticsResponse(BaseModel): ) -class NfoSeriesDiagnostics(BaseModel): - """Diagnostics for a single series in the needs-repair list.""" +class NfoSeriesSettings(BaseModel): + """Settings summary for a single series in the needs-repair list.""" key: str = Field(..., description="Series unique key") name: str = Field(..., description="Series display name") diff --git a/src/server/web/static/css/pages/anime-settings.css b/src/server/web/static/css/pages/anime-settings.css new file mode 100644 index 0000000..1e363cf --- /dev/null +++ b/src/server/web/static/css/pages/anime-settings.css @@ -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; + } +} \ No newline at end of file diff --git a/src/server/web/static/css/pages/nfo-settings.css b/src/server/web/static/css/pages/nfo-settings.css deleted file mode 100644 index 48dbcf4..0000000 --- a/src/server/web/static/css/pages/nfo-settings.css +++ /dev/null @@ -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%; - } -} diff --git a/src/server/web/static/js/index/context-menu.js b/src/server/web/static/js/index/context-menu.js index 7e43e73..9cb8682 100644 --- a/src/server/web/static/js/index/context-menu.js +++ b/src/server/web/static/js/index/context-menu.js @@ -2,7 +2,7 @@ * AniWorld - Context Menu Component * * 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 */ @@ -67,9 +67,9 @@ AniWorld.ContextMenu = (function() { menuElement = document.createElement('div'); menuElement.className = 'context-menu'; menuElement.innerHTML = ` -
- - NFO Diagnostics +
+ + Anime Settings
`; @@ -96,11 +96,13 @@ AniWorld.ContextMenu = (function() { menuElement.style.top = posY + 'px'; // Attach action handlers - // NFO Diagnostics - opens the full NFO settings page - menuElement.querySelector('[data-action="nfo-diagnostics"]').addEventListener('click', function() { + // Anime Settings - opens the per-anime settings page + menuElement.querySelector('[data-action="anime-settings"]').addEventListener('click', function() { + // Capture the key BEFORE hide() clears it + const key = currentSeriesKey; hide(); - // Navigate to NFO settings page with this series selected - window.location.href = '/settings/nfo?key=' + encodeURIComponent(currentSeriesKey); + // Navigate to anime settings page with this series selected + window.location.href = '/anime/settings?key=' + encodeURIComponent(key); }); } diff --git a/src/server/web/static/js/pages/anime-settings.js b/src/server/web/static/js/pages/anime-settings.js new file mode 100644 index 0000000..9800304 --- /dev/null +++ b/src/server/web/static/js/pages/anime-settings.js @@ -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
.
+     */
+    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 = '';
+        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 = '';
+                return;
+            }
+            const list = await resp.json();
+            select.innerHTML = '' +
+                list.map(function (s) {
+                    return '';
+                }).join('');
+        } catch (err) {
+            console.error('Failed to populate series select:', err);
+            select.innerHTML = '';
+        }
+    }
+
+    /**
+     * 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, ''');
+    }
+
+    // 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();
+        }
+    });
+}
\ No newline at end of file
diff --git a/src/server/web/static/js/pages/nfo-settings.js b/src/server/web/static/js/pages/nfo-settings.js
deleted file mode 100644
index d32f4c0..0000000
--- a/src/server/web/static/js/pages/nfo-settings.js
+++ /dev/null
@@ -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 = `
-                
-                    
-                        Failed to load diagnostics. Please try again.
-                    
-                
-            `;
-        }
-    }
-
-    /**
-     * Render diagnostics table
-     */
-    function renderDiagnosticsTable(series) {
-        if (!elements.diagnosticsTableBody) return;
-
-        if (series.length === 0) {
-            elements.diagnosticsTableBody.innerHTML = `
-                
-                    
-                        No series found.
-                    
-                
-            `;
-            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 => `${escapeHtml(tag)}`).join('')
-                : 'All present';
-            const nfoPath = s.folder ? `${escapeHtml(s.folder)}/tvshow.nfo` : '-';
-
-            return `
-                
-                    
-                        
${escapeHtml(s.name || s.key)}
-
${escapeHtml(s.key)}
- - - - ${statusIcon} - ${status} - - - ${missingTagsHtml} - ${nfoPath} - - - - - `; - }).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 ''; - case 'incomplete': - return ''; - case 'missing': - return ''; - 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 = ` - - - Failed to load repair list. Please try again. - - - `; - } - } - - /** - * Render repair table - */ - function renderRepairTable(series) { - if (!elements.repairTableBody) return; - - if (series.length === 0) { - elements.repairTableBody.innerHTML = ` - - - All series have complete NFO files! - - - `; - 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 ` - - - - - -
${escapeHtml(s.name || s.key)}
-
${escapeHtml(s.key)}
- - - - ${status} - - - - ${hasTmdbId ? `${s.tmdb_id}` : 'No TMDB ID'} - - - ${!s.has_nfo ? 'High' : 'Normal'} - - - `; - }).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 = ' 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 = ' 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 = ` - - -
- - ${message} -
- - - `; - } - - /** - * Show toast notification - */ - function showToast(message, type = 'info') { - if (!elements.toastContainer) return; - - const toast = document.createElement('div'); - toast.className = `toast toast-${type}`; - toast.innerHTML = ` - - ${escapeHtml(message)} - - `; - - 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()); - } - } - -})(); diff --git a/src/server/web/templates/anime-settings.html b/src/server/web/templates/anime-settings.html new file mode 100644 index 0000000..5c2580c --- /dev/null +++ b/src/server/web/templates/anime-settings.html @@ -0,0 +1,248 @@ + + + + + + Anime Settings - AniWorld Manager + + + + + + +
+ +
+
+
+ +

Anime Settings

+
+ +
+
+ +
+ + + + +
+
+ +

Loading series settings...

+
+
+ + + + + + +
+ + +
+
+ + + + + + + + + + + \ No newline at end of file diff --git a/src/server/web/templates/index.html b/src/server/web/templates/index.html index be30356..b53a21c 100644 --- a/src/server/web/templates/index.html +++ b/src/server/web/templates/index.html @@ -522,12 +522,12 @@
- - - Open NFO Diagnostics + + + Open Anime Settings - - 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.)
diff --git a/src/server/web/templates/nfo-settings.html b/src/server/web/templates/nfo-settings.html deleted file mode 100644 index b4e1f47..0000000 --- a/src/server/web/templates/nfo-settings.html +++ /dev/null @@ -1,373 +0,0 @@ - - - - - - - NFO Diagnostics - AniWorld Manager - - - - - - -
- -
-
-
- -

NFO Diagnostics

-
-
- - - Back to Main - - - -
-
-
- - -
- -
-
-
-
- -
-
-
-
-
Total Series
-
-
- -
-
- -
-
-
-
-
Complete NFOs
-
-
- -
-
- -
-
-
-
-
Need Repair
-
-
- -
-
- -
-
-
-
-
Missing NFO
-
-
-
-
- - - - - -
- -
-
-

NFO Health Overview

- -
-
-
-
- -
-
-

Required Tags

-

Each NFO file must contain these tags for Kodi compatibility:

-
    -
  • title - Series title
  • -
  • plot - Series description
  • -
  • tmdbid - TMDB identifier
  • -
-

Optional Tags

-

These enhance the Kodi experience:

-
    -
  • year, premiered, genre
  • -
  • studio, rating, mpaa
  • -
  • actor, trailer, thumb
  • -
-
-
-
-
- - -
-
-

Series Diagnostics

-
- - - -
-
-
- - - - - - - - - - - - - - - -
- Series Name - - - Status - - Missing TagsNFO PathActions
-
- - Loading diagnostics... -
-
-
-
- - -
-
-

Batch Repair

-
-
-
-
- -
-

About NFO Repair

-

Repairs NFO files using TMDB metadata. If a series has no TMDB ID set, the repair will search TMDB by the series name.

-
-
-
-
- - -
-
- - - - - - - - - - - - - - - -
- - Series NameStatusTMDB IDPriority
-
- - Loading series needing repair... -
-
-
- -
-
- - -
-
-

NFO Settings

-
-
-
-

TMDB Connection

-
-
- -

Required for fetching metadata. Get your key from TMDB

-
-
- - -
-
- -
- -
-

Auto-Generation

-
-
- -

Automatically create NFO metadata when downloading new series

-
-
- -
-
-
-
- -

Refresh existing NFO files when rescanning library

-
-
- -
-
-
- -
-

Image Downloads

-
-
- -

Download series poster image

-
-
- -
-
-
-
- -

Download background fanart image

-
-
- -
-
-
-
- -

Download series logo/clearlogo

-
-
- -
-
-
- -
-

Save Settings

- -
-
-
-
-
-
- - - - - -
- - - - - - - - - - - - - - diff --git a/tests/api/test_anime_settings_endpoints.py b/tests/api/test_anime_settings_endpoints.py new file mode 100644 index 0000000..9aa788d --- /dev/null +++ b/tests/api/test_anime_settings_endpoints.py @@ -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" + ) \ No newline at end of file diff --git a/tests/api/test_nfo_endpoints.py b/tests/api/test_nfo_endpoints.py index 57008c6..87059bc 100644 --- a/tests/api/test_nfo_endpoints.py +++ b/tests/api/test_nfo_endpoints.py @@ -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 @@ -8,24 +19,20 @@ import pytest from httpx import ASGITransport, AsyncClient 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 @pytest.fixture(autouse=True) def reset_auth(): - """Reset authentication state before each test.""" - original_hash = auth_service._hash auth_service._hash = None auth_service._failed.clear() yield - auth_service._hash = original_hash + auth_service._hash = None auth_service._failed.clear() @pytest.fixture async def client(): - """Create an async test client.""" transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as ac: yield ac @@ -33,458 +40,67 @@ async def client(): @pytest.fixture async def authenticated_client(client): - """Create an authenticated test client with token.""" - # Setup master password await client.post( "/api/auth/setup", - json={"master_password": "TestPassword123!"} + json={"master_password": "TestPassword123!"}, ) - - # Login to get token - response = await client.post( + resp = await client.post( "/api/auth/login", - json={"password": "TestPassword123!"} + json={"password": "TestPassword123!"}, ) - token = response.json()["access_token"] - - # Add token to default headers + token = resp.json()["access_token"] client.headers.update({"Authorization": f"Bearer {token}"}) yield client -@pytest.fixture -def mock_series_app(): - """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.""" +class TestNFOAuthRequirements: + """All NFO endpoints must require authentication.""" @pytest.mark.asyncio - async def test_check_nfo_requires_auth( - self, - override_nfo_service_for_auth_tests, - 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) + async def test_get_diagnostics_requires_auth(self, client): + resp = await client.get("/api/nfo/any-key/diagnostics") + assert resp.status_code in (401, 503) @pytest.mark.asyncio - async def test_check_nfo_series_not_found( - self, - authenticated_client, - mock_series_app, - mock_nfo_service, - override_dependencies - ): - """Test check endpoint with non-existent series.""" - mock_series_app.list.GetList = Mock(return_value=[]) - - response = await authenticated_client.get( - "/api/nfo/nonexistent/check" + async def test_post_repair_requires_auth(self, client): + resp = await client.post("/api/nfo/any-key/repair") + assert resp.status_code in (401, 503) + + @pytest.mark.asyncio + async def test_get_validate_requires_auth(self, client): + resp = await client.get("/api/nfo/any-key/validate") + assert resp.status_code in (401, 503) + + @pytest.mark.asyncio + async def test_get_needs_repair_requires_auth(self, client): + 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 - - @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 + assert resp.status_code in (401, 503) -class TestNFOCreateEndpoint: - """Tests for POST /api/nfo/{serie_id}/create endpoint.""" +class TestNFOEndpointModels: + """Verify the response models use the renamed classes (regression + test for the rename from NfoDiagnosticsResponse -> NfoSettingsResponse).""" - @pytest.mark.asyncio - async def test_create_nfo_requires_auth( - self, - client, - override_nfo_service_for_auth_tests - ): - """Test that create endpoint requires authentication.""" - response = await client.post( - "/api/nfo/test-anime/create", - json={} + def test_renamed_settings_response_model_exists(self): + # Confirm the old names are gone + from src.server import models + from src.server.models.nfo import ( + NfoRepairResponse, + NfoSeriesSettings, + NfoSettingsResponse, ) - assert response.status_code in (401, 503) - - @pytest.mark.asyncio - async def test_create_nfo_success( - self, - authenticated_client, - mock_series_app, - 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("Test") - - 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 "" 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] + nfo_module = models.nfo + assert hasattr(nfo_module, "NfoSettingsResponse") + assert hasattr(nfo_module, "NfoSeriesSettings") + assert hasattr(nfo_module, "NfoRepairResponse") + # The diagnostic prefix should NOT be present anymore + assert not hasattr(nfo_module, "NfoDiagnosticsResponse") + assert not hasattr(nfo_module, "NfoSeriesDiagnostics") \ No newline at end of file diff --git a/tests/frontend/e2e/anime_settings_page.spec.js b/tests/frontend/e2e/anime_settings_page.spec.js new file mode 100644 index 0000000..a8b3f13 --- /dev/null +++ b/tests/frontend/e2e/anime_settings_page.spec.js @@ -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/); + }); +}); \ No newline at end of file diff --git a/tests/frontend/unit/anime_settings.test.js b/tests/frontend/unit/anime_settings.test.js new file mode 100644 index 0000000..ad77a22 --- /dev/null +++ b/tests/frontend/unit/anime_settings.test.js @@ -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 = ` + + + + + + + + + +

+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `; + + // 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'); + }); +}); \ No newline at end of file diff --git a/tests/frontend/unit/context_menu.test.js b/tests/frontend/unit/context_menu.test.js new file mode 100644 index 0000000..8f5184d --- /dev/null +++ b/tests/frontend/unit/context_menu.test.js @@ -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= 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(); + }); +}); diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 0000000..fcf7589 --- /dev/null +++ b/vitest.config.js @@ -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}', + ], + }, +}); \ No newline at end of file