feat: add manual TMDB/TVDB ID entry for failed lookups

- Add PATCH /api/anime/{key}/metadata-ids endpoint to update IDs
- Add POST /api/anime/{key}/refresh-nfo endpoint to force NFO regeneration
- Add Edit Metadata IDs modal in frontend
- Add showEditMetadataModal, saveMetadataIds, refreshSeriesNfo JS functions
- Add edit-metadata-btn to series cards with database icon
- IDs validated as positive integers or null

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-05-28 18:38:34 +02:00
parent 33f63ca304
commit 30858f441c
4 changed files with 886 additions and 3 deletions

View File

@@ -3,10 +3,11 @@ import warnings
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
from sqlalchemy.ext.asyncio import AsyncSession
from src.core.entities.series import Serie
from src.core.utils.key_utils import generate_key_from_folder, is_valid_key
from src.server.database.service import AnimeSeriesService
from src.server.exceptions import (
BadRequestError,
@@ -133,6 +134,14 @@ class AnimeSummary(BaseModel):
default=None,
description="ISO timestamp when NFO was last updated"
)
loading_status: Optional[str] = Field(
default=None,
description="Current loading status (e.g., 'completed', 'failed', 'in_progress')"
)
loading_error: Optional[str] = Field(
default=None,
description="Error message if loading failed (e.g., 'key cannot be None or empty')"
)
tmdb_id: Optional[int] = Field(
default=None,
description="The Movie Database (TMDB) ID"
@@ -331,6 +340,8 @@ async def list_anime(
nfo_updated_at=series_dict.get("nfo_updated_at"),
tmdb_id=series_dict.get("tmdb_id"),
tvdb_id=series_dict.get("tvdb_id"),
loading_status=series_dict.get("loading_status"),
loading_error=series_dict.get("loading_error"),
)
)
@@ -1085,6 +1096,346 @@ async def get_anime(
) from exc
class ManualKeyUpdate(BaseModel):
"""Request model for manually updating a series key."""
key: str = Field(
...,
min_length=2,
description="New URL-safe key for the series (alphanumeric, hyphens, underscores)"
)
@router.patch("/{anime_key}/manual-key", response_model=dict)
async def update_series_manual_key(
anime_key: str,
update_data: ManualKeyUpdate,
db: AsyncSession = Depends(get_optional_database_session),
series_app: Any = Depends(get_series_app),
) -> dict:
"""Manually update the key for a series.
This endpoint allows users to supply a key for folders that failed
automatic key generation (e.g., non-Latin characters, special symbols).
Args:
anime_key: Current series key
update_data: New key to assign
db: Database session
series_app: SeriesApp instance for in-memory updates
Returns:
Updated series info with new key
Raises:
HTTPException: If validation fails or series not found
"""
new_key = update_data.key.strip()
# Validate the new key format
if not is_valid_key(new_key):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid key format. Key must be URL-safe (alphanumeric, hyphens, underscores only)"
)
# Find the series - check DB first
series_db = None
if db:
series_db = await AnimeSeriesService.get_by_key(db, anime_key)
# Also check in-memory list if series_app available
found_in_memory = None
if series_app and hasattr(series_app, "list"):
for serie in series_app.list.GetList():
if getattr(serie, "key", None) == anime_key:
found_in_memory = serie
break
if not series_db and not found_in_memory:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Series with key '{anime_key}' not found"
)
# Check if new key is already in use
existing_keys = set()
if db:
all_series = await AnimeSeriesService.get_all(db)
existing_keys = {s.key for s in all_series if s.key != anime_key}
if series_app and hasattr(series_app, "list"):
for serie in series_app.list.GetList():
key = getattr(serie, "key", None)
if key and key != anime_key:
existing_keys.add(key)
if new_key in existing_keys:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Key '{new_key}' is already in use by another series"
)
old_key = anime_key
# Update in database if found
if series_db:
from src.server.database.connection import get_db
async with get_db() as session:
await AnimeSeriesService.update(
session,
series_db.id,
key=new_key,
loading_error=None # Clear error on successful key update
)
await session.commit()
# Update in-memory cache
if found_in_memory:
try:
found_in_memory.key = new_key
logger.info(
"Updated in-memory key for series: %s -> %s",
old_key,
new_key
)
except ValueError as ve:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(ve)
)
logger.info(
"Manual key update successful: %s -> %s",
old_key,
new_key
)
return {
"success": True,
"old_key": old_key,
"new_key": new_key,
"message": f"Key updated from '{old_key}' to '{new_key}'"
}
class MetadataIdsUpdate(BaseModel):
"""Request model for manually updating TMDB and TVDB IDs."""
tmdb_id: Optional[int] = Field(
default=None,
description="TMDB ID (positive integer, or null to clear)"
)
tvdb_id: Optional[int] = Field(
default=None,
description="TVDB ID (positive integer, or null to clear)"
)
@field_validator("tmdb_id", "tvdb_id")
@classmethod
def validate_positive_or_null(cls, v):
if v is not None and v <= 0:
raise ValueError("ID must be a positive integer or null")
return v
@router.patch("/{anime_key}/metadata-ids", response_model=dict)
async def update_series_metadata_ids(
anime_key: str,
update_data: MetadataIdsUpdate,
db: AsyncSession = Depends(get_optional_database_session),
series_app: Any = Depends(get_series_app),
) -> dict:
"""Manually update TMDB and TVDB IDs for a series.
This endpoint allows users to supply missing metadata IDs for series
that failed automatic TMDB lookup. After updating IDs, it triggers
a background NFO re-generation.
Args:
anime_key: Series key
update_data: TMDB and TVDB IDs to set
db: Database session
series_app: SeriesApp instance for in-memory updates
Returns:
Updated series info with new IDs
Raises:
HTTPException: If validation fails or series not found
"""
if update_data.tmdb_id is None and update_data.tvdb_id is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="At least one of tmdb_id or tvdb_id must be provided"
)
series_db = None
if db:
series_db = await AnimeSeriesService.get_by_key(db, anime_key)
if not series_db:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Series with key '{anime_key}' not found"
)
update_fields = {}
if update_data.tmdb_id is not None:
update_fields["tmdb_id"] = update_data.tmdb_id
if update_data.tvdb_id is not None:
update_fields["tvdb_id"] = update_data.tvdb_id
if db:
from datetime import datetime, timezone
update_fields["nfo_updated_at"] = datetime.now(timezone.utc)
update_fields["has_nfo"] = True
await AnimeSeriesService.update(
db,
series_db.id,
**update_fields
)
await db.commit()
# Update in-memory cache if available
if series_app and hasattr(series_app, "list"):
for serie in series_app.list.GetList():
if getattr(serie, "key", None) == anime_key:
if update_data.tmdb_id is not None:
serie.tmdb_id = update_data.tmdb_id
if update_data.tvdb_id is not None:
serie.tvdb_id = update_data.tvdb_id
break
# Trigger background NFO re-generation
background_loader = None
try:
background_loader = await get_background_loader_service()
except Exception:
pass
nfo_queued = False
if background_loader and db:
try:
from src.server.database.connection import get_db_session
async with get_db_session() as bg_db:
series_for_bg = await AnimeSeriesService.get_by_key(bg_db, anime_key)
if series_for_bg:
await background_loader.load_series_nfo(
series_for_bg.key,
series_for_bg.folder,
series_for_bg.name,
force_refresh=True
)
nfo_queued = True
except Exception as e:
logger.warning("Failed to queue NFO refresh for '%s': %s", anime_key, str(e))
logger.info(
"Metadata IDs updated for '%s': tmdb_id=%s, tvdb_id=%s, NFO_queued=%s",
anime_key,
update_data.tmdb_id,
update_data.tvdb_id,
nfo_queued
)
return {
"success": True,
"key": anime_key,
"tmdb_id": update_data.tmdb_id,
"tvdb_id": update_data.tvdb_id,
"nfo_refresh_queued": nfo_queued,
"message": "Metadata IDs updated. NFO refresh queued." if nfo_queued
else "Metadata IDs updated."
}
@router.post("/{anime_key}/refresh-nfo", response_model=dict)
async def refresh_series_nfo(
anime_key: str,
db: AsyncSession = Depends(get_optional_database_session),
series_app: Any = Depends(get_series_app),
) -> dict:
"""Force NFO re-generation for a series using current IDs.
This endpoint triggers a background NFO re-generation using the
existing TMDB/TVDB IDs (or creating minimal NFO if no IDs exist).
Args:
anime_key: Series key
db: Database session
series_app: SeriesApp instance
Returns:
Status of NFO refresh operation
Raises:
HTTPException: If series not found
"""
if db:
series_db = await AnimeSeriesService.get_by_key(db, anime_key)
if not db or not series_db:
# Check in-memory
found = None
if series_app and hasattr(series_app, "list"):
for serie in series_app.list.GetList():
if getattr(serie, "key", None) == anime_key:
found = serie
break
if not found:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Series with key '{anime_key}' not found"
)
background_loader = None
try:
background_loader = await get_background_loader_service()
except Exception:
pass
if not background_loader:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Background loader service not available"
)
series_for_bg = None
if db:
async with get_db_session() as bg_db:
series_for_bg = await AnimeSeriesService.get_by_key(bg_db, anime_key)
if not series_for_bg:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Series with key '{anime_key}' not found"
)
try:
await background_loader.load_series_nfo(
series_for_bg.key,
series_for_bg.folder,
series_for_bg.name,
force_refresh=True
)
nfo_queued = True
except Exception as e:
logger.error("Failed to queue NFO refresh for '%s': %s", anime_key, str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to queue NFO refresh: {str(e)}"
)
logger.info("NFO refresh queued for '%s'", anime_key)
return {
"success": True,
"key": anime_key,
"message": "NFO refresh queued"
}
# Maximum allowed input size for security
MAX_INPUT_LENGTH = 100000 # 100KB