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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user