feat(anime): rename NFO Diagnostics to Anime Settings + add edit endpoints
Replaces the read-only 'NFO Diagnostics' page with a full per-anime
Settings page reached from the right-click context menu on series cards.
Users can now view and edit key, name, folder, tmdb_id, tvdb_id and site
for each anime; changes are persisted to the DB and optionally written
back to the NFO file or used to regenerate it.
Backend
- Rename NfoDiagnosticsResponse -> NfoSettingsResponse,
NfoSeriesDiagnostics -> NfoSeriesSettings
- Rename get_nfo_diagnostics -> get_nfo_settings,
repair_nfo -> repair_nfo_settings
- Fix nfo.py bug: repair was calling non-existent
update_series_nfo_status(); now uses update_nfo_status() and an
explicit AnimeSeriesService.update(nfo_path=...)
- New endpoints on /api/anime/{key}:
GET /settings -> AnimeSettingsResponse
PUT /settings -> AnimeSettingsResponse
(body: name/folder/tmdb_id/tvdb_id/site,
options: apply_to_nfo, rename_disk)
POST /regenerate-nfo -> AnimeSettingsRegenerateNfoResponse
- New Pydantic models: AnimeSettingsResponse,
AnimeSettingsUpdateRequest, AnimeSettingsRegenerateNfoResponse
- /anime/settings page route; /settings/nfo now 301-redirects to it
Frontend
- New AniWorld.AnimeSettingsManager JS module (single-page form,
no tabs) with public API init/loadSeries/saveSettings/regenerateNfo/
validateField/populateForm/showSaveSuccess/showError
- New anime-settings.html template + anime-settings.css
- Right-click menu: data-action 'nfo-diagnostics' replaced by
'anime-settings' (label 'Anime Settings'), navigates to
/anime/settings?key=...
- Library 'Open NFO Diagnostics' link renamed to 'Open Anime Settings'
Bug fix
- context-menu click handler was calling hide() BEFORE building the
navigation URL, which cleared currentSeriesKey to null and produced
/anime/settings?key=null. Captures the key into a local const first.
Regression-locked by tests/frontend/unit/context_menu.test.js.
Tests
- 21 new pytest tests in tests/api/test_anime_settings_endpoints.py
(GET/PUT/regenerate-nfo, auth, validation, nfo-repair bug regression)
- tests/api/test_nfo_endpoints.py trimmed to 6 focused tests
- 31 new Vitest unit tests for AnimeSettingsManager
- 5 new Vitest unit tests for ContextMenu (incl. source-invariant
regression guard for the hide()-before-key bug)
- 5 new Playwright E2E tests covering right-click, direct nav,
legacy /settings/nfo redirect, and context-menu labels
- New vitest.config.js (environment: happy-dom)
Docs
- Docs/API.md: new section 'Anime Settings Endpoints'
- Docs/CHANGELOG.md: documents the rename and the context-menu bug fix
Verified
- pytest: 27/27 (21 new + 6 trimmed nfo)
- vitest: 36/36 (31 anime-settings + 5 context-menu)
- playwright e2e: 5/5
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user