Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 84b24ed79e | |||
| bf3954587a | |||
| ed8f5cae10 | |||
| a54c285994 | |||
| c58b42dfa5 | |||
| 6dfb24de7e | |||
| 6021cdef28 | |||
| 5517ccbab0 | |||
| 94ed013172 | |||
| 76b849fc91 | |||
| 00b26c8cbc |
@@ -1 +1 @@
|
|||||||
v1.2.1
|
v1.3.2
|
||||||
|
|||||||
@@ -293,7 +293,7 @@ The FastAPI lifespan function (`src/server/fastapi_app.py`) runs the following s
|
|||||||
9. Scheduler service started
|
9. Scheduler service started
|
||||||
+-- Cron-based library rescans configured
|
+-- Cron-based library rescans configured
|
||||||
+-- Optional: auto-download missing episodes after rescan
|
+-- Optional: auto-download missing episodes after rescan
|
||||||
+-- Optional: folder maintenance (NFO repair, renaming, poster checks) during scheduled runs
|
+-- Optional: folder maintenance (NFO repair, key resolution, renaming, poster checks) during scheduled runs
|
||||||
```
|
```
|
||||||
|
|
||||||
### 12.2 Temp Folder Guarantee
|
### 12.2 Temp Folder Guarantee
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aniworld-web",
|
"name": "aniworld-web",
|
||||||
"version": "1.2.1",
|
"version": "1.3.2",
|
||||||
"description": "Aniworld Anime Download Manager - Web Frontend",
|
"description": "Aniworld Anime Download Manager - Web Frontend",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ from pathlib import Path
|
|||||||
from typing import Any, List, Optional
|
from typing import Any, List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.config.settings import settings
|
from src.config.settings import settings
|
||||||
|
from src.core.entities.series import Serie
|
||||||
from src.core.utils.key_utils import generate_key_from_folder, is_valid_key
|
from src.core.utils.key_utils import generate_key_from_folder, is_valid_key
|
||||||
from src.server.database.service import AnimeSeriesService
|
from src.server.database.service import AnimeSeriesService
|
||||||
from src.server.exceptions import (
|
from src.server.exceptions import (
|
||||||
@@ -16,20 +17,20 @@ from src.server.exceptions import (
|
|||||||
ServerError,
|
ServerError,
|
||||||
ValidationError,
|
ValidationError,
|
||||||
)
|
)
|
||||||
|
from src.server.models.anime import AnimeMetadataUpdate
|
||||||
from src.server.services.anime_service import AnimeService, AnimeServiceError
|
from src.server.services.anime_service import AnimeService, AnimeServiceError
|
||||||
from src.server.services.background_loader_service import BackgroundLoaderService
|
from src.server.services.background_loader_service import BackgroundLoaderService
|
||||||
|
from src.server.services.folder_rename_service import _scan_for_pre_existing_duplicates
|
||||||
from src.server.utils.dependencies import (
|
from src.server.utils.dependencies import (
|
||||||
get_anime_service,
|
get_anime_service,
|
||||||
get_background_loader_service,
|
get_background_loader_service,
|
||||||
|
get_database_session,
|
||||||
get_optional_database_session,
|
get_optional_database_session,
|
||||||
get_series_app,
|
get_series_app,
|
||||||
require_auth,
|
require_auth,
|
||||||
)
|
)
|
||||||
from src.server.utils.filesystem import sanitize_folder_name
|
from src.server.utils.filesystem import sanitize_folder_name
|
||||||
from src.server.utils.validators import validate_filter_value, validate_search_query
|
from src.server.utils.validators import validate_filter_value, validate_search_query
|
||||||
from src.server.services.folder_rename_service import (
|
|
||||||
_scan_for_pre_existing_duplicates,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -232,14 +233,6 @@ class AnimeSummary(BaseModel):
|
|||||||
default=None,
|
default=None,
|
||||||
description="ISO timestamp when NFO was last updated"
|
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(
|
tmdb_id: Optional[int] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="The Movie Database (TMDB) ID"
|
description="The Movie Database (TMDB) ID"
|
||||||
@@ -438,8 +431,6 @@ async def list_anime(
|
|||||||
nfo_updated_at=series_dict.get("nfo_updated_at"),
|
nfo_updated_at=series_dict.get("nfo_updated_at"),
|
||||||
tmdb_id=series_dict.get("tmdb_id"),
|
tmdb_id=series_dict.get("tmdb_id"),
|
||||||
tvdb_id=series_dict.get("tvdb_id"),
|
tvdb_id=series_dict.get("tvdb_id"),
|
||||||
loading_status=series_dict.get("loading_status"),
|
|
||||||
loading_error=series_dict.get("loading_error"),
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1194,346 +1185,78 @@ async def get_anime(
|
|||||||
) from exc
|
) 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
|
# Maximum allowed input size for security
|
||||||
MAX_INPUT_LENGTH = 100000 # 100KB
|
MAX_INPUT_LENGTH = 100000 # 100KB
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{anime_key}")
|
||||||
|
async def update_anime_metadata(
|
||||||
|
anime_key: str,
|
||||||
|
body: AnimeMetadataUpdate,
|
||||||
|
_auth: dict = Depends(require_auth),
|
||||||
|
db: AsyncSession = Depends(get_database_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Update anime metadata (key, tmdb_id, tvdb_id).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
anime_key: Current series key to update
|
||||||
|
body: Fields to update (all optional)
|
||||||
|
_auth: Authentication dependency
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated series metadata
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException 404: Series not found
|
||||||
|
HTTPException 409: Key conflict (new key already exists)
|
||||||
|
HTTPException 422: Validation error
|
||||||
|
"""
|
||||||
|
series = await AnimeSeriesService.get_by_key(db, anime_key)
|
||||||
|
if not series:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Series with key '{anime_key}' not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
updates = {}
|
||||||
|
|
||||||
|
if body.key is not None and body.key != anime_key:
|
||||||
|
existing = await AnimeSeriesService.get_by_key(db, body.key)
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"A series with key '{body.key}' already exists",
|
||||||
|
)
|
||||||
|
updates["key"] = body.key
|
||||||
|
|
||||||
|
if body.tmdb_id is not None:
|
||||||
|
updates["tmdb_id"] = body.tmdb_id
|
||||||
|
|
||||||
|
if body.tvdb_id is not None:
|
||||||
|
updates["tvdb_id"] = body.tvdb_id
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
return {
|
||||||
|
"key": series.key,
|
||||||
|
"tmdb_id": series.tmdb_id,
|
||||||
|
"tvdb_id": series.tvdb_id,
|
||||||
|
"message": "No changes",
|
||||||
|
}
|
||||||
|
|
||||||
|
updated = await AnimeSeriesService.update(db, series.id, **updates)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Updated metadata for '%s': %s",
|
||||||
|
anime_key,
|
||||||
|
updates,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"key": updated.key,
|
||||||
|
"tmdb_id": updated.tmdb_id,
|
||||||
|
"tvdb_id": updated.tvdb_id,
|
||||||
|
"message": "Metadata updated successfully",
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ from src.core.entities.series import Serie
|
|||||||
from src.core.SeriesApp import SeriesApp
|
from src.core.SeriesApp import SeriesApp
|
||||||
from src.core.services.nfo_factory import get_nfo_factory
|
from src.core.services.nfo_factory import get_nfo_factory
|
||||||
from src.core.services.nfo_service import NFOService
|
from src.core.services.nfo_service import NFOService
|
||||||
|
from src.core.services.nfo_repair_service import (
|
||||||
|
REQUIRED_TAGS,
|
||||||
|
NfoRepairService,
|
||||||
|
find_missing_tags,
|
||||||
|
)
|
||||||
from src.core.services.tmdb_client import TMDBAPIError
|
from src.core.services.tmdb_client import TMDBAPIError
|
||||||
from src.server.models.nfo import (
|
from src.server.models.nfo import (
|
||||||
MediaDownloadRequest,
|
MediaDownloadRequest,
|
||||||
@@ -27,8 +32,10 @@ from src.server.models.nfo import (
|
|||||||
NFOContentResponse,
|
NFOContentResponse,
|
||||||
NFOCreateRequest,
|
NFOCreateRequest,
|
||||||
NFOCreateResponse,
|
NFOCreateResponse,
|
||||||
|
NfoDiagnosticsResponse,
|
||||||
NFOMissingResponse,
|
NFOMissingResponse,
|
||||||
NFOMissingSeries,
|
NFOMissingSeries,
|
||||||
|
NfoRepairResponse,
|
||||||
)
|
)
|
||||||
from src.server.utils.dependencies import get_series_app, require_auth
|
from src.server.utils.dependencies import get_series_app, require_auth
|
||||||
from src.server.utils.media import check_media_files, get_media_file_paths
|
from src.server.utils.media import check_media_files, get_media_file_paths
|
||||||
@@ -808,3 +815,142 @@ async def download_media(
|
|||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail=f"Failed to download media: {str(e)}"
|
detail=f"Failed to download media: {str(e)}"
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{serie_key}/diagnostics", response_model=NfoDiagnosticsResponse)
|
||||||
|
async def get_nfo_diagnostics(
|
||||||
|
serie_key: str,
|
||||||
|
_auth: dict = Depends(require_auth),
|
||||||
|
series_app: SeriesApp = Depends(get_series_app),
|
||||||
|
) -> NfoDiagnosticsResponse:
|
||||||
|
"""Get NFO diagnostics showing missing required tags.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
serie_key: Series key identifier
|
||||||
|
_auth: Authentication dependency
|
||||||
|
series_app: SeriesApp instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
NfoDiagnosticsResponse with has_nfo, missing_tags, required_tags
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException 404: Series not found
|
||||||
|
"""
|
||||||
|
serie = None
|
||||||
|
for s in series_app.list.GetList():
|
||||||
|
if getattr(s, "key", None) == serie_key:
|
||||||
|
serie = s
|
||||||
|
break
|
||||||
|
|
||||||
|
if not serie:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Series with key '{serie_key}' not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
serie_folder = serie.ensure_folder_with_year()
|
||||||
|
folder_path = Path(settings.anime_directory) / serie_folder
|
||||||
|
nfo_path = folder_path / "tvshow.nfo"
|
||||||
|
|
||||||
|
required_tag_names = list(REQUIRED_TAGS.values())
|
||||||
|
|
||||||
|
if not nfo_path.exists():
|
||||||
|
return NfoDiagnosticsResponse(
|
||||||
|
has_nfo=False,
|
||||||
|
nfo_path=None,
|
||||||
|
missing_tags=required_tag_names,
|
||||||
|
required_tags=required_tag_names,
|
||||||
|
)
|
||||||
|
|
||||||
|
missing = find_missing_tags(nfo_path)
|
||||||
|
|
||||||
|
return NfoDiagnosticsResponse(
|
||||||
|
has_nfo=True,
|
||||||
|
nfo_path=str(nfo_path),
|
||||||
|
missing_tags=missing,
|
||||||
|
required_tags=required_tag_names,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{serie_key}/repair", response_model=NfoRepairResponse)
|
||||||
|
async def repair_nfo(
|
||||||
|
serie_key: str,
|
||||||
|
_auth: dict = Depends(require_auth),
|
||||||
|
series_app: SeriesApp = Depends(get_series_app),
|
||||||
|
nfo_service: NFOService = Depends(get_nfo_service),
|
||||||
|
) -> NfoRepairResponse:
|
||||||
|
"""Repair or recreate NFO file for a series.
|
||||||
|
|
||||||
|
Detects missing required tags and re-fetches metadata from TMDB.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
serie_key: Series key identifier
|
||||||
|
_auth: Authentication dependency
|
||||||
|
series_app: SeriesApp instance
|
||||||
|
nfo_service: NFO service for TMDB operations
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
NfoRepairResponse with success status and details
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException 404: Series not found
|
||||||
|
HTTPException 400: Cannot repair (e.g., no TMDB data available)
|
||||||
|
"""
|
||||||
|
serie = None
|
||||||
|
for s in series_app.list.GetList():
|
||||||
|
if getattr(s, "key", None) == serie_key:
|
||||||
|
serie = s
|
||||||
|
break
|
||||||
|
|
||||||
|
if not serie:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Series with key '{serie_key}' not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
serie_folder = serie.ensure_folder_with_year()
|
||||||
|
folder_path = Path(settings.anime_directory) / serie_folder
|
||||||
|
nfo_path = folder_path / "tvshow.nfo"
|
||||||
|
|
||||||
|
# Get missing tags before repair for reporting
|
||||||
|
missing_before = find_missing_tags(nfo_path) if nfo_path.exists() else list(REQUIRED_TAGS.values())
|
||||||
|
|
||||||
|
try:
|
||||||
|
repair_service = NfoRepairService(nfo_service)
|
||||||
|
|
||||||
|
if nfo_path.exists():
|
||||||
|
repaired = await repair_service.repair_series(folder_path, serie_folder)
|
||||||
|
if not repaired:
|
||||||
|
return NfoRepairResponse(
|
||||||
|
success=True,
|
||||||
|
message="NFO is already complete, no repair needed",
|
||||||
|
repaired_tags=[],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# No NFO exists — create new one
|
||||||
|
await nfo_service.create_tvshow_nfo(
|
||||||
|
serie_name=serie.name,
|
||||||
|
serie_folder=serie_folder,
|
||||||
|
download_poster=True,
|
||||||
|
download_logo=True,
|
||||||
|
download_fanart=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return NfoRepairResponse(
|
||||||
|
success=True,
|
||||||
|
message=f"NFO repaired successfully. Fixed {len(missing_before)} missing tags.",
|
||||||
|
repaired_tags=missing_before,
|
||||||
|
)
|
||||||
|
|
||||||
|
except TMDBAPIError as e:
|
||||||
|
logger.warning("NFO repair failed for '%s': %s", serie_key, e)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Cannot repair NFO: {str(e)}. Ensure TMDB ID is set.",
|
||||||
|
) from e
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("NFO repair error for '%s': %s", serie_key, e, exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to repair NFO: {str(e)}",
|
||||||
|
) from e
|
||||||
|
|||||||
@@ -83,6 +83,30 @@ class AnimeSeriesResponse(BaseModel):
|
|||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class AnimeMetadataUpdate(BaseModel):
|
||||||
|
"""Request model for updating anime metadata (key, tmdb_id, tvdb_id)."""
|
||||||
|
|
||||||
|
key: Optional[str] = Field(None, description="New series key (URL-safe, lowercase)")
|
||||||
|
tmdb_id: Optional[int] = Field(None, ge=1, description="TMDB ID (positive integer)")
|
||||||
|
tvdb_id: Optional[int] = Field(None, ge=1, description="TVDB ID (positive integer)")
|
||||||
|
|
||||||
|
@field_validator('key', mode='before')
|
||||||
|
@classmethod
|
||||||
|
def validate_key_format(cls, v: Optional[str]) -> Optional[str]:
|
||||||
|
"""Validate key is URL-safe lowercase with hyphens only."""
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
v = v.strip().lower()
|
||||||
|
if not v:
|
||||||
|
raise ValueError("Key cannot be empty")
|
||||||
|
if not KEY_PATTERN.match(v):
|
||||||
|
raise ValueError(
|
||||||
|
"Key must contain only lowercase letters, numbers, and hyphens. "
|
||||||
|
"Cannot start or end with a hyphen."
|
||||||
|
)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
class SearchRequest(BaseModel):
|
class SearchRequest(BaseModel):
|
||||||
"""Request payload for searching series."""
|
"""Request payload for searching series."""
|
||||||
|
|
||||||
|
|||||||
@@ -355,3 +355,29 @@ class NFOMissingResponse(BaseModel):
|
|||||||
...,
|
...,
|
||||||
description="List of series missing NFO"
|
description="List of series missing NFO"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NfoDiagnosticsResponse(BaseModel):
|
||||||
|
"""Response for NFO diagnostics 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")
|
||||||
|
missing_tags: List[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="List of missing required tag names"
|
||||||
|
)
|
||||||
|
required_tags: List[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="All required tag names for reference"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NfoRepairResponse(BaseModel):
|
||||||
|
"""Response after NFO repair attempt."""
|
||||||
|
|
||||||
|
success: bool = Field(..., description="Whether repair succeeded")
|
||||||
|
message: str = Field(..., description="Human-readable result message")
|
||||||
|
repaired_tags: List[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Tags that were missing before repair"
|
||||||
|
)
|
||||||
|
|||||||
@@ -403,7 +403,7 @@ async def _update_database_paths(
|
|||||||
|
|
||||||
async with get_db_session() as db:
|
async with get_db_session() as db:
|
||||||
# 1. Update AnimeSeries.folder
|
# 1. Update AnimeSeries.folder
|
||||||
series = await AnimeSeriesService.get_by_key(db, old_folder)
|
series = await AnimeSeriesService.get_by_folder(db, old_folder)
|
||||||
if series is None:
|
if series is None:
|
||||||
# Fallback: try to find by folder name
|
# Fallback: try to find by folder name
|
||||||
all_series = await AnimeSeriesService.get_all(db)
|
all_series = await AnimeSeriesService.get_all(db)
|
||||||
@@ -602,6 +602,11 @@ async def validate_and_rename_series_folders(dry_run: bool = False) -> Dict[str,
|
|||||||
try:
|
try:
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
"Removing source duplicate folder '%s' — target '%s' already exists",
|
||||||
|
current_name,
|
||||||
|
expected_name,
|
||||||
|
)
|
||||||
shutil.rmtree(series_dir)
|
shutil.rmtree(series_dir)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Removed source folder '%s' — series already exists at target",
|
"Removed source folder '%s' — series already exists at target",
|
||||||
@@ -610,7 +615,7 @@ async def validate_and_rename_series_folders(dry_run: bool = False) -> Dict[str,
|
|||||||
|
|
||||||
# Delete source DB record (cascades to episodes and download items)
|
# Delete source DB record (cascades to episodes and download items)
|
||||||
async with get_db_session() as db:
|
async with get_db_session() as db:
|
||||||
source_series = await AnimeSeriesService.get_by_key(db, current_name)
|
source_series = await AnimeSeriesService.get_by_folder(db, current_name)
|
||||||
if source_series is None:
|
if source_series is None:
|
||||||
# Fallback: find by folder name
|
# Fallback: find by folder name
|
||||||
all_series = await AnimeSeriesService.get_all(db)
|
all_series = await AnimeSeriesService.get_all(db)
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ async def perform_nfo_repair_scan(background_loader=None) -> None:
|
|||||||
queued = 0
|
queued = 0
|
||||||
total = 0
|
total = 0
|
||||||
missing_nfo_count = 0
|
missing_nfo_count = 0
|
||||||
|
repair_tasks: list[asyncio.Task] = []
|
||||||
for series_dir in sorted(anime_dir.iterdir()):
|
for series_dir in sorted(anime_dir.iterdir()):
|
||||||
if not series_dir.is_dir():
|
if not series_dir.is_dir():
|
||||||
continue
|
continue
|
||||||
@@ -137,19 +138,31 @@ async def perform_nfo_repair_scan(background_loader=None) -> None:
|
|||||||
if not nfo_path.exists():
|
if not nfo_path.exists():
|
||||||
# Create minimal NFO for series without one
|
# Create minimal NFO for series without one
|
||||||
missing_nfo_count += 1
|
missing_nfo_count += 1
|
||||||
asyncio.create_task(
|
repair_tasks.append(
|
||||||
_create_missing_nfo(series_dir, series_name),
|
asyncio.create_task(
|
||||||
name=f"nfo_create:{series_name}",
|
_create_missing_nfo(series_dir, series_name),
|
||||||
|
name=f"nfo_create:{series_name}",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
total += 1
|
total += 1
|
||||||
if nfo_needs_repair(nfo_path):
|
if nfo_needs_repair(nfo_path):
|
||||||
queued += 1
|
queued += 1
|
||||||
asyncio.create_task(
|
repair_tasks.append(
|
||||||
_repair_one_series(series_dir, series_name),
|
asyncio.create_task(
|
||||||
name=f"nfo_repair:{series_name}",
|
_repair_one_series(series_dir, series_name),
|
||||||
|
name=f"nfo_repair:{series_name}",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if repair_tasks:
|
||||||
|
logger.info(
|
||||||
|
"NFO repair scan: waiting for %d repair/create tasks to complete",
|
||||||
|
len(repair_tasks),
|
||||||
|
)
|
||||||
|
await asyncio.gather(*repair_tasks, return_exceptions=True)
|
||||||
|
logger.info("NFO repair scan tasks completed")
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"NFO repair scan complete: %d of %d series queued for repair, %d missing NFOs queued for creation",
|
"NFO repair scan complete: %d of %d series queued for repair, %d missing NFOs queued for creation",
|
||||||
queued,
|
queued,
|
||||||
@@ -182,10 +195,10 @@ class FolderScanService:
|
|||||||
if not self._prerequisites_met():
|
if not self._prerequisites_met():
|
||||||
return
|
return
|
||||||
|
|
||||||
# 1.3 — Repair incomplete NFO files in the background.
|
# 1.3 — Repair incomplete NFO files (synchronous, waits for completion).
|
||||||
logger.info("Starting NFO repair scan as part of folder scan")
|
logger.info("Starting NFO repair scan as part of folder scan")
|
||||||
await perform_nfo_repair_scan(background_loader=None)
|
await perform_nfo_repair_scan(background_loader=None)
|
||||||
logger.info("NFO repair scan queued; repairs will continue in background")
|
logger.info("NFO repair scan complete")
|
||||||
|
|
||||||
# 1.4 — Validate and rename series folders after NFO repair.
|
# 1.4 — Validate and rename series folders after NFO repair.
|
||||||
logger.info("Starting folder rename validation")
|
logger.info("Starting folder rename validation")
|
||||||
|
|||||||
317
src/server/services/key_resolution_service.py
Normal file
317
src/server/services/key_resolution_service.py
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
"""Key resolution service for orphaned anime folders.
|
||||||
|
|
||||||
|
Attempts to resolve provider keys for anime folders that have no key/data
|
||||||
|
file and no database entry, by searching the anime provider and matching
|
||||||
|
folder names to search results.
|
||||||
|
|
||||||
|
This service runs after nfo_repair_service during the daily folder scan.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from src.config.settings import settings as _settings
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
# Limit concurrent provider searches to avoid rate-limiting.
|
||||||
|
_SEARCH_SEMAPHORE: asyncio.Semaphore = asyncio.Semaphore(2)
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_year_from_folder(folder_name: str) -> str:
|
||||||
|
"""Remove trailing year suffix like ' (2020)' from folder name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder_name: Folder name, e.g. 'Rent-A-Girlfriend (2020)'
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Name without year, e.g. 'Rent-A-Girlfriend'
|
||||||
|
"""
|
||||||
|
return re.sub(r"\s*\(\d{4}\)\s*$", "", folder_name).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_year_from_folder(folder_name: str) -> Optional[int]:
|
||||||
|
"""Extract year from folder name like 'Anime Name (2020)'.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Year as int or None if not present.
|
||||||
|
"""
|
||||||
|
match = re.search(r"\((\d{4})\)$", folder_name.strip())
|
||||||
|
if match:
|
||||||
|
return int(match.group(1))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_key_from_link(link: str) -> Optional[str]:
|
||||||
|
"""Extract provider key from search result link.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
link: Link like '/anime/stream/rent-a-girlfriend' or full URL.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Key slug like 'rent-a-girlfriend' or None.
|
||||||
|
"""
|
||||||
|
if not link:
|
||||||
|
return None
|
||||||
|
if "/anime/stream/" in link:
|
||||||
|
parts = link.split("/anime/stream/")[-1].split("/")
|
||||||
|
key = parts[0].strip()
|
||||||
|
return key if key else None
|
||||||
|
# If link is just a slug
|
||||||
|
if "/" not in link and link.strip():
|
||||||
|
return link.strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_for_comparison(text: str) -> str:
|
||||||
|
"""Normalize text for case-insensitive comparison.
|
||||||
|
|
||||||
|
Strips whitespace, lowercases, and removes common punctuation
|
||||||
|
differences that shouldn't affect matching.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Raw text string.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Normalized lowercase string.
|
||||||
|
"""
|
||||||
|
normalized = text.strip().lower()
|
||||||
|
# Remove common punctuation that varies between sources
|
||||||
|
normalized = re.sub(r"[:\-–—]", " ", normalized)
|
||||||
|
# Collapse multiple spaces
|
||||||
|
normalized = re.sub(r"\s+", " ", normalized)
|
||||||
|
return normalized.strip()
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_key_for_folder(folder_name: str) -> Optional[str]:
|
||||||
|
"""Attempt to resolve the provider key for a single folder.
|
||||||
|
|
||||||
|
Strategy:
|
||||||
|
1. Strip year suffix from folder name to get search query.
|
||||||
|
2. Search the anime provider with that query.
|
||||||
|
3. If exactly ONE result matches the folder name (case-insensitive),
|
||||||
|
return the key extracted from the result link.
|
||||||
|
4. If zero or multiple matches, return None (not confident enough).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder_name: The anime folder name, e.g. 'Rent-A-Girlfriend (2020)'.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The provider key string, or None if resolution is not confident.
|
||||||
|
"""
|
||||||
|
search_query = _strip_year_from_folder(folder_name)
|
||||||
|
if not search_query:
|
||||||
|
logger.debug("Empty search query after stripping year from '%s'", folder_name)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async with _SEARCH_SEMAPHORE:
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
results = await loop.run_in_executor(None, _search_provider, search_query)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Provider search failed for '%s': %s", search_query, exc
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
logger.debug("No search results for folder '%s'", folder_name)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Filter results: find exact name matches (case-insensitive)
|
||||||
|
normalized_query = _normalize_for_comparison(search_query)
|
||||||
|
exact_matches = []
|
||||||
|
|
||||||
|
for result in results:
|
||||||
|
title = result.get("title") or result.get("name") or ""
|
||||||
|
normalized_title = _normalize_for_comparison(title)
|
||||||
|
|
||||||
|
if normalized_title == normalized_query:
|
||||||
|
key = _extract_key_from_link(result.get("link", ""))
|
||||||
|
if key:
|
||||||
|
exact_matches.append((key, title))
|
||||||
|
|
||||||
|
if len(exact_matches) == 1:
|
||||||
|
resolved_key, matched_title = exact_matches[0]
|
||||||
|
logger.info(
|
||||||
|
"Resolved key for folder '%s': key='%s' (matched title: '%s')",
|
||||||
|
folder_name,
|
||||||
|
resolved_key,
|
||||||
|
matched_title,
|
||||||
|
)
|
||||||
|
return resolved_key
|
||||||
|
|
||||||
|
if len(exact_matches) > 1:
|
||||||
|
logger.info(
|
||||||
|
"Multiple exact matches for folder '%s' (%d matches), skipping",
|
||||||
|
folder_name,
|
||||||
|
len(exact_matches),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
"No exact title match for folder '%s' in %d results",
|
||||||
|
folder_name,
|
||||||
|
len(results),
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _search_provider(query: str) -> list:
|
||||||
|
"""Call the anime provider search synchronously.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search term.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of search result dicts with 'link' and 'title'/'name' fields.
|
||||||
|
"""
|
||||||
|
from src.core.providers.provider_factory import Loaders
|
||||||
|
|
||||||
|
loader = Loaders().GetLoader("aniworld.to")
|
||||||
|
return loader.search(query)
|
||||||
|
|
||||||
|
|
||||||
|
async def perform_key_resolution_scan() -> dict[str, int]:
|
||||||
|
"""Scan all anime folders and resolve missing keys.
|
||||||
|
|
||||||
|
Iterates over all subfolders of the anime directory. For each folder
|
||||||
|
that has no corresponding database entry, attempts to resolve the
|
||||||
|
provider key via provider search and saves it to the database.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with counts:
|
||||||
|
- 'scanned': total folders checked
|
||||||
|
- 'resolved': keys successfully resolved and saved
|
||||||
|
- 'skipped': folders already in DB or resolution uncertain
|
||||||
|
- 'errors': folders that caused errors during resolution
|
||||||
|
"""
|
||||||
|
from src.server.database.connection import get_db_session
|
||||||
|
from src.server.database.service import AnimeSeriesService
|
||||||
|
|
||||||
|
stats = {"scanned": 0, "resolved": 0, "skipped": 0, "errors": 0}
|
||||||
|
|
||||||
|
if not _settings.anime_directory:
|
||||||
|
logger.warning("Key resolution scan skipped — anime directory not configured")
|
||||||
|
return stats
|
||||||
|
|
||||||
|
anime_dir = Path(_settings.anime_directory)
|
||||||
|
if not anime_dir.is_dir():
|
||||||
|
logger.warning(
|
||||||
|
"Key resolution scan skipped — anime directory not found: %s",
|
||||||
|
anime_dir,
|
||||||
|
)
|
||||||
|
return stats
|
||||||
|
|
||||||
|
# Collect folders that need resolution
|
||||||
|
folders_to_resolve: list[str] = []
|
||||||
|
|
||||||
|
async with get_db_session() as db:
|
||||||
|
for series_dir in sorted(anime_dir.iterdir()):
|
||||||
|
if not series_dir.is_dir():
|
||||||
|
continue
|
||||||
|
folder_name = series_dir.name
|
||||||
|
stats["scanned"] += 1
|
||||||
|
|
||||||
|
# Check if already in database
|
||||||
|
existing = await AnimeSeriesService.get_by_folder(db, folder_name)
|
||||||
|
if existing:
|
||||||
|
stats["skipped"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
folders_to_resolve.append(folder_name)
|
||||||
|
|
||||||
|
if not folders_to_resolve:
|
||||||
|
logger.info("Key resolution scan: all folders already have DB entries")
|
||||||
|
return stats
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Key resolution scan: %d folders need resolution", len(folders_to_resolve)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Resolve keys one by one (provider search is rate-limited)
|
||||||
|
for folder_name in folders_to_resolve:
|
||||||
|
try:
|
||||||
|
key = await resolve_key_for_folder(folder_name)
|
||||||
|
if key:
|
||||||
|
# Save to database
|
||||||
|
await _save_resolved_key(folder_name, key)
|
||||||
|
stats["resolved"] += 1
|
||||||
|
else:
|
||||||
|
stats["skipped"] += 1
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
"Error resolving key for folder '%s': %s",
|
||||||
|
folder_name,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
stats["errors"] += 1
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Key resolution scan complete: scanned=%d, resolved=%d, skipped=%d, errors=%d",
|
||||||
|
stats["scanned"],
|
||||||
|
stats["resolved"],
|
||||||
|
stats["skipped"],
|
||||||
|
stats["errors"],
|
||||||
|
)
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
async def _save_resolved_key(folder_name: str, key: str) -> None:
|
||||||
|
"""Save a resolved key to the database.
|
||||||
|
|
||||||
|
Creates a new AnimeSeries entry with the resolved key and folder name.
|
||||||
|
Does NOT write any key/data file to disk.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder_name: The anime folder name (e.g. 'Rent-A-Girlfriend (2020)').
|
||||||
|
key: The resolved provider key (e.g. 'rent-a-girlfriend').
|
||||||
|
"""
|
||||||
|
from src.server.database.connection import get_db_session
|
||||||
|
from src.server.database.service import AnimeSeriesService
|
||||||
|
|
||||||
|
name = _strip_year_from_folder(folder_name)
|
||||||
|
year = _extract_year_from_folder(folder_name)
|
||||||
|
|
||||||
|
async with get_db_session() as db:
|
||||||
|
# Double-check: another task might have resolved it concurrently
|
||||||
|
existing = await AnimeSeriesService.get_by_folder(db, folder_name)
|
||||||
|
if existing:
|
||||||
|
logger.debug(
|
||||||
|
"Folder '%s' already in DB (resolved concurrently), skipping",
|
||||||
|
folder_name,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Also check if a series with this key already exists
|
||||||
|
existing_key = await AnimeSeriesService.get_by_key(db, key)
|
||||||
|
if existing_key:
|
||||||
|
logger.warning(
|
||||||
|
"Key '%s' already exists in DB for folder '%s', "
|
||||||
|
"cannot assign to folder '%s'",
|
||||||
|
key,
|
||||||
|
existing_key.folder,
|
||||||
|
folder_name,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await AnimeSeriesService.create(
|
||||||
|
db,
|
||||||
|
key=key,
|
||||||
|
name=name,
|
||||||
|
site="aniworld.to",
|
||||||
|
folder=folder_name,
|
||||||
|
year=year,
|
||||||
|
loading_status="pending",
|
||||||
|
episodes_loaded=False,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Saved resolved key '%s' for folder '%s' to database",
|
||||||
|
key,
|
||||||
|
folder_name,
|
||||||
|
)
|
||||||
@@ -316,11 +316,9 @@ class SchedulerService:
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from src.server.database.connection import ( # noqa: PLC0415
|
from src.server.database.connection import get_db_session # noqa: PLC0415
|
||||||
get_db_session,
|
from src.server.database.system_settings_service import (
|
||||||
)
|
SystemSettingsService, # noqa: PLC0415
|
||||||
from src.server.database.system_settings_service import ( # noqa: PLC0415
|
|
||||||
SystemSettingsService,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async with get_db_session() as db:
|
async with get_db_session() as db:
|
||||||
@@ -367,8 +365,8 @@ class SchedulerService:
|
|||||||
async def _broadcast(self, event_type: str, data: dict) -> None:
|
async def _broadcast(self, event_type: str, data: dict) -> None:
|
||||||
"""Broadcast a WebSocket event to all connected clients."""
|
"""Broadcast a WebSocket event to all connected clients."""
|
||||||
try:
|
try:
|
||||||
from src.server.services.websocket_service import ( # noqa: PLC0415
|
from src.server.services.websocket_service import (
|
||||||
get_websocket_service,
|
get_websocket_service, # noqa: PLC0415
|
||||||
)
|
)
|
||||||
|
|
||||||
ws_service = get_websocket_service()
|
ws_service = get_websocket_service()
|
||||||
@@ -503,8 +501,8 @@ class SchedulerService:
|
|||||||
if self._config and self._config.folder_scan_enabled:
|
if self._config and self._config.folder_scan_enabled:
|
||||||
logger.info("Folder scan is enabled — starting")
|
logger.info("Folder scan is enabled — starting")
|
||||||
try:
|
try:
|
||||||
from src.server.services.folder_scan_service import ( # noqa: PLC0415
|
from src.server.services.folder_scan_service import (
|
||||||
FolderScanService,
|
FolderScanService, # noqa: PLC0415
|
||||||
)
|
)
|
||||||
|
|
||||||
folder_scan_service = FolderScanService()
|
folder_scan_service = FolderScanService()
|
||||||
@@ -519,6 +517,26 @@ class SchedulerService:
|
|||||||
await self._broadcast(
|
await self._broadcast(
|
||||||
"folder_scan_error", {"error": str(fs_exc)}
|
"folder_scan_error", {"error": str(fs_exc)}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Key resolution scan (resolve orphaned folders)
|
||||||
|
try:
|
||||||
|
from src.server.services.key_resolution_service import (
|
||||||
|
perform_key_resolution_scan, # noqa: PLC0415
|
||||||
|
)
|
||||||
|
|
||||||
|
key_stats = await perform_key_resolution_scan()
|
||||||
|
logger.info(
|
||||||
|
"Key resolution scan completed: resolved=%d, skipped=%d, errors=%d",
|
||||||
|
key_stats["resolved"],
|
||||||
|
key_stats["skipped"],
|
||||||
|
key_stats["errors"],
|
||||||
|
)
|
||||||
|
except Exception as kr_exc: # pylint: disable=broad-exception-caught
|
||||||
|
logger.error(
|
||||||
|
"Key resolution scan failed: %s",
|
||||||
|
kr_exc,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
logger.debug("Folder scan is disabled — skipping")
|
logger.debug("Folder scan is disabled — skipping")
|
||||||
|
|
||||||
|
|||||||
@@ -268,3 +268,205 @@
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Context Menu
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
.context-menu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1500;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
box-shadow: var(--shadow-elevated);
|
||||||
|
min-width: 180px;
|
||||||
|
padding: var(--spacing-xs) 0;
|
||||||
|
animation: contextMenuFadeIn 0.12s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes contextMenuFadeIn {
|
||||||
|
from { opacity: 0; transform: scale(0.95); }
|
||||||
|
to { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: var(--font-size-body);
|
||||||
|
transition: background-color 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-item:hover {
|
||||||
|
background-color: var(--color-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-item i {
|
||||||
|
width: 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Edit Metadata Modal
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
.edit-modal-content {
|
||||||
|
max-width: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-section {
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
padding-bottom: var(--spacing-lg);
|
||||||
|
border-bottom: 1px solid var(--color-divider);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-section:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-section h4 {
|
||||||
|
margin: 0 0 var(--spacing-md) 0;
|
||||||
|
font-size: var(--font-size-body);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-section h4 i {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
font-size: var(--font-size-caption);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-error {
|
||||||
|
display: block;
|
||||||
|
margin-top: var(--spacing-xs);
|
||||||
|
font-size: var(--font-size-caption);
|
||||||
|
color: var(--color-error, #e74c3c);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-error {
|
||||||
|
border-color: var(--color-error, #e74c3c) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-warning {
|
||||||
|
background: rgba(255, 193, 7, 0.1);
|
||||||
|
border: 1px solid rgba(255, 193, 7, 0.3);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
margin-top: var(--spacing-sm);
|
||||||
|
font-size: var(--font-size-caption);
|
||||||
|
color: var(--color-warning, #f39c12);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* NFO Diagnostics */
|
||||||
|
.nfo-diagnostics {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nfo-status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: var(--font-size-caption);
|
||||||
|
font-weight: 600;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nfo-status-badge.nfo-complete {
|
||||||
|
background: rgba(46, 204, 113, 0.15);
|
||||||
|
color: var(--color-success, #2ecc71);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nfo-status-badge.nfo-incomplete {
|
||||||
|
background: rgba(243, 156, 18, 0.15);
|
||||||
|
color: var(--color-warning, #f39c12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nfo-status-badge.nfo-missing {
|
||||||
|
background: rgba(231, 76, 60, 0.15);
|
||||||
|
color: var(--color-error, #e74c3c);
|
||||||
|
}
|
||||||
|
|
||||||
|
.missing-tags-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
margin-top: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.missing-tag-chip {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: var(--color-background-subtle);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: var(--font-size-caption);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nfo-all-good {
|
||||||
|
color: var(--color-success, #2ecc71);
|
||||||
|
font-size: var(--font-size-caption);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nfo-error {
|
||||||
|
color: var(--color-error, #e74c3c);
|
||||||
|
font-size: var(--font-size-caption);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repair-hint {
|
||||||
|
font-size: var(--font-size-caption);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-repair {
|
||||||
|
align-self: flex-start;
|
||||||
|
margin-top: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: var(--spacing-md) var(--spacing-lg);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -442,23 +442,6 @@ class AniWorldApp {
|
|||||||
this.hideConfigModal();
|
this.hideConfigModal();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Edit key modal
|
|
||||||
document.getElementById('close-edit-key').addEventListener('click', () => {
|
|
||||||
this.hideEditKeyModal();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('cancel-edit-key').addEventListener('click', () => {
|
|
||||||
this.hideEditKeyModal();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelector('#edit-key-modal .modal-overlay').addEventListener('click', () => {
|
|
||||||
this.hideEditKeyModal();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('save-edit-key').addEventListener('click', () => {
|
|
||||||
this.saveManualKey();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Scheduler configuration
|
// Scheduler configuration
|
||||||
document.getElementById('scheduled-rescan-enabled').addEventListener('change', () => {
|
document.getElementById('scheduled-rescan-enabled').addEventListener('change', () => {
|
||||||
this.toggleSchedulerTimeInput();
|
this.toggleSchedulerTimeInput();
|
||||||
@@ -1564,72 +1547,6 @@ class AniWorldApp {
|
|||||||
document.getElementById('config-modal').classList.add('hidden');
|
document.getElementById('config-modal').classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
showEditKeyModal(key, folder) {
|
|
||||||
this._currentEditKey = key;
|
|
||||||
document.getElementById('edit-key-folder').textContent = folder;
|
|
||||||
document.getElementById('edit-key-input').value = '';
|
|
||||||
document.getElementById('edit-key-error').classList.add('hidden');
|
|
||||||
document.getElementById('edit-key-modal').classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
hideEditKeyModal() {
|
|
||||||
document.getElementById('edit-key-modal').classList.add('hidden');
|
|
||||||
this._currentEditKey = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveManualKey() {
|
|
||||||
const oldKey = this._currentEditKey;
|
|
||||||
const newKey = document.getElementById('edit-key-input').value.trim();
|
|
||||||
const errorEl = document.getElementById('edit-key-error');
|
|
||||||
|
|
||||||
if (!newKey || newKey.length < 2) {
|
|
||||||
errorEl.textContent = 'Key must be at least 2 characters';
|
|
||||||
errorEl.classList.remove('hidden');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate key format (URL-safe)
|
|
||||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(newKey)) {
|
|
||||||
errorEl.textContent = 'Key must be URL-safe (alphanumeric, hyphens, underscores only)';
|
|
||||||
errorEl.classList.remove('hidden');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await this.makeAuthenticatedRequest(
|
|
||||||
`/api/anime/${encodeURIComponent(oldKey)}/manual-key`,
|
|
||||||
{
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ key: newKey })
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response) {
|
|
||||||
errorEl.textContent = 'Failed to update key';
|
|
||||||
errorEl.classList.remove('hidden');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
this.hideEditKeyModal();
|
|
||||||
this.showToast(`Key updated: ${oldKey} → ${newKey}`, 'success');
|
|
||||||
// Reload series list
|
|
||||||
if (typeof AniWorld.SeriesManager !== 'undefined') {
|
|
||||||
AniWorld.SeriesManager.loadSeries();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const data = await response.json().catch(() => ({ detail: 'Update failed' }));
|
|
||||||
errorEl.textContent = data.detail || 'Failed to update key';
|
|
||||||
errorEl.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error saving manual key:', err);
|
|
||||||
errorEl.textContent = 'Error updating key';
|
|
||||||
errorEl.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadSchedulerConfig() {
|
async loadSchedulerConfig() {
|
||||||
try {
|
try {
|
||||||
const response = await this.makeAuthenticatedRequest('/api/scheduler/config');
|
const response = await this.makeAuthenticatedRequest('/api/scheduler/config');
|
||||||
@@ -2427,336 +2344,4 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Global functions for inline event handlers
|
// Global functions for inline event handlers
|
||||||
window.app = null;
|
window.app = null;
|
||||||
|
|
||||||
/**
|
|
||||||
* Show the edit key modal
|
|
||||||
* @param {string} currentKey - The current series key
|
|
||||||
* @param {string} folderName - The folder name
|
|
||||||
*/
|
|
||||||
function showEditKeyModal(currentKey, folderName) {
|
|
||||||
const modal = document.getElementById('edit-key-modal');
|
|
||||||
const overlay = document.getElementById('edit-key-overlay');
|
|
||||||
const folderSpan = document.getElementById('edit-key-folder');
|
|
||||||
const keyInput = document.getElementById('edit-key-input');
|
|
||||||
const errorSpan = document.getElementById('edit-key-error');
|
|
||||||
const saveBtn = document.getElementById('edit-key-save');
|
|
||||||
|
|
||||||
if (!modal || !overlay || !folderSpan || !keyInput) {
|
|
||||||
console.error('Edit key modal elements not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset state
|
|
||||||
folderSpan.textContent = folderName;
|
|
||||||
keyInput.value = currentKey;
|
|
||||||
keyInput.dataset.originalKey = currentKey;
|
|
||||||
errorSpan.textContent = '';
|
|
||||||
errorSpan.style.display = 'none';
|
|
||||||
saveBtn.disabled = false;
|
|
||||||
|
|
||||||
// Show modal
|
|
||||||
modal.classList.remove('hidden');
|
|
||||||
overlay.classList.remove('hidden');
|
|
||||||
keyInput.focus();
|
|
||||||
keyInput.select();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hide the edit key modal
|
|
||||||
*/
|
|
||||||
function hideEditKeyModal() {
|
|
||||||
const modal = document.getElementById('edit-key-modal');
|
|
||||||
const overlay = document.getElementById('edit-key-overlay');
|
|
||||||
|
|
||||||
if (modal) {
|
|
||||||
modal.classList.add('hidden');
|
|
||||||
}
|
|
||||||
if (overlay) {
|
|
||||||
overlay.classList.add('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save the manual key for a series
|
|
||||||
* @param {string} oldKey - The original key
|
|
||||||
* @param {string} newKey - The new key to set
|
|
||||||
*/
|
|
||||||
async function saveManualKey(oldKey, newKey) {
|
|
||||||
const errorSpan = document.getElementById('edit-key-error');
|
|
||||||
const saveBtn = document.getElementById('edit-key-save');
|
|
||||||
|
|
||||||
if (!errorSpan || !saveBtn) {
|
|
||||||
console.error('Edit key modal elements not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
errorSpan.textContent = '';
|
|
||||||
errorSpan.style.display = 'none';
|
|
||||||
saveBtn.disabled = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/anime/${encodeURIComponent(oldKey)}/manual-key`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ key: newKey }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
errorSpan.textContent = data.detail || 'Failed to update key';
|
|
||||||
errorSpan.style.display = 'block';
|
|
||||||
saveBtn.disabled = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Success - hide modal and reload
|
|
||||||
hideEditKeyModal();
|
|
||||||
showToast('Key updated successfully', 'success');
|
|
||||||
|
|
||||||
// Reload series list
|
|
||||||
if (window.app && window.app.loadSeries) {
|
|
||||||
window.app.loadSeries();
|
|
||||||
} else if (typeof loadSeries === 'function') {
|
|
||||||
loadSeries();
|
|
||||||
} else {
|
|
||||||
location.reload();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving manual key:', error);
|
|
||||||
errorSpan.textContent = 'Network error: ' + error.message;
|
|
||||||
errorSpan.style.display = 'block';
|
|
||||||
saveBtn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Current metadata edit state
|
|
||||||
let _currentEditMetadataKey = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show the edit metadata IDs modal
|
|
||||||
* @param {string} key - The series key
|
|
||||||
* @param {string} name - The series name
|
|
||||||
* @param {number|null} currentTmdbId - Current TMDB ID
|
|
||||||
* @param {number|null} currentTvdbId - Current TVDB ID
|
|
||||||
*/
|
|
||||||
function showEditMetadataModal(key, name, currentTmdbId, currentTvdbId) {
|
|
||||||
const modal = document.getElementById('edit-metadata-modal');
|
|
||||||
const overlay = modal ? modal.querySelector('.modal-overlay') : null;
|
|
||||||
const nameSpan = document.getElementById('edit-metadata-series-name');
|
|
||||||
const tmdbInput = document.getElementById('edit-metadata-tmdb');
|
|
||||||
const tvdbInput = document.getElementById('edit-metadata-tvdb');
|
|
||||||
const errorSpan = document.getElementById('edit-metadata-error');
|
|
||||||
const saveBtn = document.getElementById('save-edit-metadata');
|
|
||||||
const cancelBtn = document.getElementById('cancel-edit-metadata');
|
|
||||||
|
|
||||||
if (!modal || !nameSpan || !tmdbInput || !tvdbInput) {
|
|
||||||
console.error('Edit metadata modal elements not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store current key
|
|
||||||
_currentEditMetadataKey = key;
|
|
||||||
|
|
||||||
// Reset state
|
|
||||||
nameSpan.textContent = name;
|
|
||||||
tmdbInput.value = currentTmdbId || '';
|
|
||||||
tvdbInput.value = currentTvdbId || '';
|
|
||||||
if (errorSpan) {
|
|
||||||
errorSpan.textContent = '';
|
|
||||||
errorSpan.classList.add('hidden');
|
|
||||||
}
|
|
||||||
if (saveBtn) saveBtn.disabled = false;
|
|
||||||
|
|
||||||
// Show modal
|
|
||||||
modal.classList.remove('hidden');
|
|
||||||
if (overlay) overlay.classList.remove('hidden');
|
|
||||||
tmdbInput.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hide the edit metadata modal
|
|
||||||
*/
|
|
||||||
function hideEditMetadataModal() {
|
|
||||||
const modal = document.getElementById('edit-metadata-modal');
|
|
||||||
if (modal) {
|
|
||||||
modal.classList.add('hidden');
|
|
||||||
}
|
|
||||||
_currentEditMetadataKey = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save metadata IDs for a series
|
|
||||||
* @param {string} key - The series key
|
|
||||||
* @param {number|null} tmdbId - TMDB ID (null to clear)
|
|
||||||
* @param {number|null} tvdbId - TVDB ID (null to clear)
|
|
||||||
*/
|
|
||||||
async function saveMetadataIds(key, tmdbId, tvdbId) {
|
|
||||||
const errorSpan = document.getElementById('edit-metadata-error');
|
|
||||||
const saveBtn = document.getElementById('save-edit-metadata');
|
|
||||||
|
|
||||||
if (!errorSpan || !saveBtn) {
|
|
||||||
console.error('Edit metadata modal elements not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
errorSpan.textContent = '';
|
|
||||||
errorSpan.classList.add('hidden');
|
|
||||||
saveBtn.disabled = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const body = {};
|
|
||||||
if (tmdbId !== '') body.tmdb_id = parseInt(tmdbId, 10) || null;
|
|
||||||
if (tvdbId !== '') body.tvdb_id = parseInt(tvdbId, 10) || null;
|
|
||||||
|
|
||||||
const response = await fetch(`/api/anime/${encodeURIComponent(key)}/metadata-ids`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
errorSpan.textContent = data.detail || 'Failed to update metadata IDs';
|
|
||||||
errorSpan.classList.remove('hidden');
|
|
||||||
saveBtn.disabled = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Success - hide modal and show toast
|
|
||||||
hideEditMetadataModal();
|
|
||||||
showToast('Metadata IDs updated. NFO refresh queued.', 'success');
|
|
||||||
|
|
||||||
// Reload series list to reflect changes
|
|
||||||
if (window.app && window.app.loadSeries) {
|
|
||||||
window.app.loadSeries();
|
|
||||||
} else if (typeof loadSeries === 'function') {
|
|
||||||
loadSeries();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving metadata IDs:', error);
|
|
||||||
errorSpan.textContent = 'Network error: ' + error.message;
|
|
||||||
errorSpan.classList.remove('hidden');
|
|
||||||
saveBtn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh NFO for a series
|
|
||||||
* @param {string} key - The series key
|
|
||||||
*/
|
|
||||||
async function refreshSeriesNfo(key) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/anime/${encodeURIComponent(key)}/refresh-nfo`, {
|
|
||||||
method: 'POST',
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
showToast('Failed to refresh NFO: ' + (data.detail || 'Unknown error'), 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
showToast('NFO refresh queued', 'success');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error refreshing NFO:', error);
|
|
||||||
showToast('Network error: ' + error.message, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bind edit metadata modal events if modal exists
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
const modal = document.getElementById('edit-metadata-modal');
|
|
||||||
const overlay = modal ? modal.querySelector('.modal-overlay') : null;
|
|
||||||
const cancelBtn = document.getElementById('cancel-edit-metadata');
|
|
||||||
const saveBtn = document.getElementById('save-edit-metadata');
|
|
||||||
const tmdbInput = document.getElementById('edit-metadata-tmdb');
|
|
||||||
const tvdbInput = document.getElementById('edit-metadata-tvdb');
|
|
||||||
|
|
||||||
if (cancelBtn) {
|
|
||||||
cancelBtn.addEventListener('click', hideEditMetadataModal);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (overlay) {
|
|
||||||
overlay.addEventListener('click', hideEditMetadataModal);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (saveBtn && tmdbInput && tvdbInput) {
|
|
||||||
saveBtn.addEventListener('click', () => {
|
|
||||||
if (_currentEditMetadataKey) {
|
|
||||||
saveMetadataIds(
|
|
||||||
_currentEditMetadataKey,
|
|
||||||
tmdbInput.value,
|
|
||||||
tvdbInput.value
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tmdbInput) {
|
|
||||||
tmdbInput.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
saveBtn.click();
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
hideEditMetadataModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tvdbInput) {
|
|
||||||
tvdbInput.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
saveBtn.click();
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
hideEditMetadataModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Bind edit key modal events if modal exists
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
const modal = document.getElementById('edit-key-modal');
|
|
||||||
const overlay = document.getElementById('edit-key-overlay');
|
|
||||||
const cancelBtn = document.getElementById('edit-key-cancel');
|
|
||||||
const saveBtn = document.getElementById('edit-key-save');
|
|
||||||
const keyInput = document.getElementById('edit-key-input');
|
|
||||||
|
|
||||||
if (cancelBtn) {
|
|
||||||
cancelBtn.addEventListener('click', hideEditKeyModal);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (overlay) {
|
|
||||||
overlay.addEventListener('click', hideEditKeyModal);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (saveBtn && keyInput) {
|
|
||||||
saveBtn.addEventListener('click', () => {
|
|
||||||
const originalKey = keyInput.dataset.originalKey;
|
|
||||||
const newKey = keyInput.value.trim();
|
|
||||||
if (newKey && newKey !== originalKey) {
|
|
||||||
saveManualKey(originalKey, newKey);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (keyInput) {
|
|
||||||
keyInput.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
saveBtn.click();
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
hideEditKeyModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -45,6 +45,7 @@ AniWorld.IndexApp = (function() {
|
|||||||
AniWorld.Search.init();
|
AniWorld.Search.init();
|
||||||
AniWorld.ScanManager.init();
|
AniWorld.ScanManager.init();
|
||||||
AniWorld.ConfigManager.init();
|
AniWorld.ConfigManager.init();
|
||||||
|
AniWorld.ContextMenu.init();
|
||||||
|
|
||||||
// Bind global events
|
// Bind global events
|
||||||
bindGlobalEvents();
|
bindGlobalEvents();
|
||||||
|
|||||||
123
src/server/web/static/js/index/context-menu.js
Normal file
123
src/server/web/static/js/index/context-menu.js
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* AniWorld - Context Menu Component
|
||||||
|
*
|
||||||
|
* Right-click context menu for anime series cards.
|
||||||
|
* Provides quick access to edit metadata.
|
||||||
|
*
|
||||||
|
* Dependencies: ui-utils.js, edit-modal.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
var AniWorld = window.AniWorld || {};
|
||||||
|
|
||||||
|
AniWorld.ContextMenu = (function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
let menuElement = null;
|
||||||
|
let currentSeriesKey = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the context menu system.
|
||||||
|
* Attaches global dismissal listeners.
|
||||||
|
*/
|
||||||
|
function init() {
|
||||||
|
// Dismiss on click outside
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (menuElement && !menuElement.contains(e.target)) {
|
||||||
|
hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dismiss on Escape
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dismiss on scroll or resize
|
||||||
|
window.addEventListener('scroll', hide, true);
|
||||||
|
window.addEventListener('resize', hide);
|
||||||
|
|
||||||
|
// Attach context menu via event delegation on the series grid
|
||||||
|
const grid = document.getElementById('series-grid');
|
||||||
|
if (grid) {
|
||||||
|
grid.addEventListener('contextmenu', function(e) {
|
||||||
|
const card = e.target.closest('.series-card');
|
||||||
|
if (card) {
|
||||||
|
e.preventDefault();
|
||||||
|
const key = card.getAttribute('data-key');
|
||||||
|
if (key) {
|
||||||
|
show(e, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show context menu at cursor position.
|
||||||
|
* @param {MouseEvent} event - The contextmenu event
|
||||||
|
* @param {string} seriesKey - The series key to operate on
|
||||||
|
*/
|
||||||
|
function show(event, seriesKey) {
|
||||||
|
hide(); // Remove any existing menu first
|
||||||
|
|
||||||
|
currentSeriesKey = seriesKey;
|
||||||
|
|
||||||
|
menuElement = document.createElement('div');
|
||||||
|
menuElement.className = 'context-menu';
|
||||||
|
menuElement.innerHTML = `
|
||||||
|
<div class="context-menu-item" data-action="edit">
|
||||||
|
<i class="fa-solid fa-pen-to-square"></i>
|
||||||
|
<span>Edit Metadata</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(menuElement);
|
||||||
|
|
||||||
|
// Position within viewport bounds
|
||||||
|
const x = event.clientX;
|
||||||
|
const y = event.clientY;
|
||||||
|
const menuRect = menuElement.getBoundingClientRect();
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
|
||||||
|
let posX = x;
|
||||||
|
let posY = y;
|
||||||
|
|
||||||
|
if (x + menuRect.width > viewportWidth) {
|
||||||
|
posX = viewportWidth - menuRect.width - 8;
|
||||||
|
}
|
||||||
|
if (y + menuRect.height > viewportHeight) {
|
||||||
|
posY = viewportHeight - menuRect.height - 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
menuElement.style.left = posX + 'px';
|
||||||
|
menuElement.style.top = posY + 'px';
|
||||||
|
|
||||||
|
// Attach action handlers
|
||||||
|
menuElement.querySelector('[data-action="edit"]').addEventListener('click', function() {
|
||||||
|
hide();
|
||||||
|
if (AniWorld.EditModal) {
|
||||||
|
AniWorld.EditModal.open(currentSeriesKey);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide and remove the context menu from DOM.
|
||||||
|
*/
|
||||||
|
function hide() {
|
||||||
|
if (menuElement) {
|
||||||
|
menuElement.remove();
|
||||||
|
menuElement = null;
|
||||||
|
currentSeriesKey = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
init: init,
|
||||||
|
show: show,
|
||||||
|
hide: hide
|
||||||
|
};
|
||||||
|
})();
|
||||||
450
src/server/web/static/js/index/edit-modal.js
Normal file
450
src/server/web/static/js/index/edit-modal.js
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
/**
|
||||||
|
* AniWorld - Edit Modal Component
|
||||||
|
*
|
||||||
|
* Modal dialog for viewing/editing anime metadata (key, tmdb_id, tvdb_id)
|
||||||
|
* and NFO diagnostics with repair functionality.
|
||||||
|
*
|
||||||
|
* Dependencies: api-client.js, ui-utils.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
var AniWorld = window.AniWorld || {};
|
||||||
|
|
||||||
|
AniWorld.EditModal = (function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
let modalElement = null;
|
||||||
|
let originalData = null;
|
||||||
|
let currentKey = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the edit modal for a specific anime series.
|
||||||
|
* @param {string} seriesKey - The series key to edit
|
||||||
|
*/
|
||||||
|
async function open(seriesKey) {
|
||||||
|
currentKey = seriesKey;
|
||||||
|
modalElement = document.getElementById('edit-metadata-modal');
|
||||||
|
if (!modalElement) return;
|
||||||
|
|
||||||
|
// Show modal
|
||||||
|
modalElement.classList.remove('hidden');
|
||||||
|
|
||||||
|
// Reset form state
|
||||||
|
setLoading(true);
|
||||||
|
clearErrors();
|
||||||
|
hideKeyWarning();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Find series data from the local series list
|
||||||
|
const seriesData = findSeriesData(seriesKey);
|
||||||
|
|
||||||
|
originalData = {
|
||||||
|
key: seriesKey,
|
||||||
|
tmdb_id: seriesData ? seriesData.tmdb_id : null,
|
||||||
|
tvdb_id: seriesData ? seriesData.tvdb_id : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Populate form fields
|
||||||
|
setFieldValue('edit-key', originalData.key);
|
||||||
|
setFieldValue('edit-tmdb-id', originalData.tmdb_id || '');
|
||||||
|
setFieldValue('edit-tvdb-id', originalData.tvdb_id || '');
|
||||||
|
|
||||||
|
// Load NFO diagnostics
|
||||||
|
await loadDiagnostics(seriesKey);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
AniWorld.UI.showToast('Failed to load series data', 'error');
|
||||||
|
console.error('Edit modal load error:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach event listeners
|
||||||
|
attachListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the edit modal and reset state.
|
||||||
|
*/
|
||||||
|
function close() {
|
||||||
|
if (modalElement) {
|
||||||
|
modalElement.classList.add('hidden');
|
||||||
|
}
|
||||||
|
originalData = null;
|
||||||
|
currentKey = null;
|
||||||
|
detachListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save changed metadata to the backend.
|
||||||
|
*/
|
||||||
|
async function save() {
|
||||||
|
clearErrors();
|
||||||
|
|
||||||
|
const newKey = getFieldValue('edit-key').trim().toLowerCase();
|
||||||
|
const tmdbIdStr = getFieldValue('edit-tmdb-id').trim();
|
||||||
|
const tvdbIdStr = getFieldValue('edit-tvdb-id').trim();
|
||||||
|
|
||||||
|
// Validate key
|
||||||
|
if (!newKey) {
|
||||||
|
showFieldError('edit-key', 'Key cannot be empty');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(newKey)) {
|
||||||
|
showFieldError('edit-key', 'Key must contain only lowercase letters, numbers, and hyphens');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate IDs
|
||||||
|
const tmdbId = tmdbIdStr ? parseInt(tmdbIdStr, 10) : null;
|
||||||
|
const tvdbId = tvdbIdStr ? parseInt(tvdbIdStr, 10) : null;
|
||||||
|
|
||||||
|
if (tmdbIdStr && (isNaN(tmdbId) || tmdbId < 1)) {
|
||||||
|
showFieldError('edit-tmdb-id', 'TMDB ID must be a positive number');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (tvdbIdStr && (isNaN(tvdbId) || tvdbId < 1)) {
|
||||||
|
showFieldError('edit-tvdb-id', 'TVDB ID must be a positive number');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if key changed — show confirmation
|
||||||
|
if (newKey !== originalData.key) {
|
||||||
|
const confirmed = await AniWorld.UI.showConfirmModal(
|
||||||
|
'Rename Series Key',
|
||||||
|
`Changing the key from "${originalData.key}" to "${newKey}" will update the primary identifier. ` +
|
||||||
|
'This may affect provider linkage. Are you sure?'
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build update payload (only changed fields)
|
||||||
|
const payload = {};
|
||||||
|
if (newKey !== originalData.key) payload.key = newKey;
|
||||||
|
if (tmdbId !== originalData.tmdb_id) payload.tmdb_id = tmdbId;
|
||||||
|
if (tvdbId !== originalData.tvdb_id) payload.tvdb_id = tvdbId;
|
||||||
|
|
||||||
|
if (Object.keys(payload).length === 0) {
|
||||||
|
AniWorld.UI.showToast('No changes to save', 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send update
|
||||||
|
setSaveLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await AniWorld.ApiClient.put(
|
||||||
|
'/api/anime/' + encodeURIComponent(currentKey),
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response) return;
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
AniWorld.UI.showToast('Metadata updated successfully', 'success');
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
const oldKey = currentKey;
|
||||||
|
currentKey = result.key;
|
||||||
|
originalData = {
|
||||||
|
key: result.key,
|
||||||
|
tmdb_id: result.tmdb_id,
|
||||||
|
tvdb_id: result.tvdb_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update the card in the DOM
|
||||||
|
updateCardAfterSave(oldKey, result);
|
||||||
|
|
||||||
|
// Update repair button state
|
||||||
|
updateRepairButtonState();
|
||||||
|
|
||||||
|
} else if (response.status === 409) {
|
||||||
|
showFieldError('edit-key', 'A series with this key already exists');
|
||||||
|
} else if (response.status === 422) {
|
||||||
|
const err = await response.json();
|
||||||
|
AniWorld.UI.showToast('Validation error: ' + (err.detail || 'Invalid input'), 'error');
|
||||||
|
} else {
|
||||||
|
AniWorld.UI.showToast('Failed to update metadata', 'error');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
AniWorld.UI.showToast('Connection error. Check your network.', 'error');
|
||||||
|
console.error('Save error:', err);
|
||||||
|
} finally {
|
||||||
|
setSaveLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger NFO repair for the current series.
|
||||||
|
*/
|
||||||
|
async function repairNfo() {
|
||||||
|
setRepairLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await AniWorld.ApiClient.post(
|
||||||
|
'/api/nfo/' + encodeURIComponent(currentKey) + '/repair',
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response) return;
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
AniWorld.UI.showToast(result.message, 'success');
|
||||||
|
|
||||||
|
// Refresh diagnostics
|
||||||
|
await loadDiagnostics(currentKey);
|
||||||
|
} else if (response.status === 400) {
|
||||||
|
const err = await response.json();
|
||||||
|
AniWorld.UI.showToast(err.detail || 'Cannot repair NFO', 'error');
|
||||||
|
} else {
|
||||||
|
AniWorld.UI.showToast('Failed to repair NFO', 'error');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
AniWorld.UI.showToast('Connection error during repair', 'error');
|
||||||
|
console.error('Repair error:', err);
|
||||||
|
} finally {
|
||||||
|
setRepairLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load NFO diagnostics for the current series.
|
||||||
|
* @param {string} key - Series key
|
||||||
|
*/
|
||||||
|
async function loadDiagnostics(key) {
|
||||||
|
const container = document.getElementById('nfo-diagnostics-container');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await AniWorld.ApiClient.get(
|
||||||
|
'/api/nfo/' + encodeURIComponent(key) + '/diagnostics'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response || !response.ok) {
|
||||||
|
container.innerHTML = '<p class="nfo-error">Failed to load NFO diagnostics</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
renderDiagnostics(data);
|
||||||
|
updateRepairButtonState();
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
container.innerHTML = '<p class="nfo-error">Error loading diagnostics</p>';
|
||||||
|
console.error('Diagnostics error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render NFO diagnostics data into the modal.
|
||||||
|
* @param {Object} data - NfoDiagnosticsResponse
|
||||||
|
*/
|
||||||
|
function renderDiagnostics(data) {
|
||||||
|
const badge = document.getElementById('nfo-status-badge');
|
||||||
|
const tagsList = document.getElementById('nfo-missing-tags');
|
||||||
|
|
||||||
|
if (badge) {
|
||||||
|
if (!data.has_nfo) {
|
||||||
|
badge.className = 'nfo-status-badge nfo-missing';
|
||||||
|
badge.textContent = 'No NFO File';
|
||||||
|
} else if (data.missing_tags.length === 0) {
|
||||||
|
badge.className = 'nfo-status-badge nfo-complete';
|
||||||
|
badge.textContent = 'Complete';
|
||||||
|
} else {
|
||||||
|
badge.className = 'nfo-status-badge nfo-incomplete';
|
||||||
|
badge.textContent = data.missing_tags.length + ' Missing';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tagsList) {
|
||||||
|
if (data.missing_tags.length === 0) {
|
||||||
|
tagsList.innerHTML = '<p class="nfo-all-good">All required tags present</p>';
|
||||||
|
} else {
|
||||||
|
tagsList.innerHTML = data.missing_tags.map(function(tag) {
|
||||||
|
return '<span class="missing-tag-chip">' + escapeHtml(tag) + '</span>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update repair button disabled state based on tmdb_id field.
|
||||||
|
*/
|
||||||
|
function updateRepairButtonState() {
|
||||||
|
const btn = document.getElementById('btn-repair-nfo');
|
||||||
|
const hint = document.getElementById('repair-hint');
|
||||||
|
const tmdbValue = getFieldValue('edit-tmdb-id').trim();
|
||||||
|
|
||||||
|
if (btn) {
|
||||||
|
// Enable repair even without tmdb_id — the service can search by name
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
if (hint) {
|
||||||
|
hint.style.display = tmdbValue ? 'none' : 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Helpers ----
|
||||||
|
|
||||||
|
function findSeriesData(key) {
|
||||||
|
// Access the series data from the series manager if available
|
||||||
|
if (AniWorld.SeriesManager && AniWorld.SeriesManager.getSeriesData) {
|
||||||
|
const allSeries = AniWorld.SeriesManager.getSeriesData();
|
||||||
|
if (allSeries) {
|
||||||
|
return allSeries.find(function(s) { return s.key === key; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCardAfterSave(oldKey, result) {
|
||||||
|
const card = document.querySelector('[data-series-id="' + oldKey + '"]');
|
||||||
|
if (card) {
|
||||||
|
card.setAttribute('data-key', result.key);
|
||||||
|
card.setAttribute('data-series-id', result.key);
|
||||||
|
// Update checkbox data-key
|
||||||
|
const checkbox = card.querySelector('.series-checkbox');
|
||||||
|
if (checkbox) {
|
||||||
|
checkbox.setAttribute('data-key', result.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update local series data array
|
||||||
|
if (AniWorld.SeriesManager && AniWorld.SeriesManager.updateSeriesKey) {
|
||||||
|
AniWorld.SeriesManager.updateSeriesKey(oldKey, result.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFieldValue(id, value) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.value = value !== null && value !== undefined ? value : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFieldValue(id) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
return el ? el.value : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showFieldError(fieldId, message) {
|
||||||
|
const el = document.getElementById(fieldId);
|
||||||
|
if (el) {
|
||||||
|
const errorEl = el.parentElement.querySelector('.field-error');
|
||||||
|
if (errorEl) {
|
||||||
|
errorEl.textContent = message;
|
||||||
|
errorEl.style.display = 'block';
|
||||||
|
}
|
||||||
|
el.classList.add('input-error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearErrors() {
|
||||||
|
if (!modalElement) return;
|
||||||
|
modalElement.querySelectorAll('.field-error').forEach(function(el) {
|
||||||
|
el.style.display = 'none';
|
||||||
|
el.textContent = '';
|
||||||
|
});
|
||||||
|
modalElement.querySelectorAll('.input-error').forEach(function(el) {
|
||||||
|
el.classList.remove('input-error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideKeyWarning() {
|
||||||
|
const warning = document.getElementById('key-change-warning');
|
||||||
|
if (warning) warning.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLoading(loading) {
|
||||||
|
const form = document.getElementById('edit-metadata-form');
|
||||||
|
if (form) {
|
||||||
|
form.style.opacity = loading ? '0.5' : '1';
|
||||||
|
form.style.pointerEvents = loading ? 'none' : 'auto';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSaveLoading(loading) {
|
||||||
|
const btn = document.getElementById('btn-save-metadata');
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = loading;
|
||||||
|
btn.innerHTML = loading
|
||||||
|
? '<i class="fa-solid fa-spinner fa-spin"></i> Saving...'
|
||||||
|
: '<i class="fa-solid fa-floppy-disk"></i> Save';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRepairLoading(loading) {
|
||||||
|
const btn = document.getElementById('btn-repair-nfo');
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = loading;
|
||||||
|
btn.innerHTML = loading
|
||||||
|
? '<i class="fa-solid fa-spinner fa-spin"></i> Repairing...'
|
||||||
|
: '<i class="fa-solid fa-wrench"></i> Repair NFO';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
var div = document.createElement('div');
|
||||||
|
div.textContent = str;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event listener management
|
||||||
|
let listeners = [];
|
||||||
|
|
||||||
|
function attachListeners() {
|
||||||
|
detachListeners();
|
||||||
|
|
||||||
|
const saveBtn = document.getElementById('btn-save-metadata');
|
||||||
|
const cancelBtn = document.getElementById('btn-cancel-metadata');
|
||||||
|
const repairBtn = document.getElementById('btn-repair-nfo');
|
||||||
|
const overlay = modalElement ? modalElement.querySelector('.modal-overlay') : null;
|
||||||
|
const keyInput = document.getElementById('edit-key');
|
||||||
|
|
||||||
|
if (saveBtn) {
|
||||||
|
var saveFn = function() { save(); };
|
||||||
|
saveBtn.addEventListener('click', saveFn);
|
||||||
|
listeners.push({ el: saveBtn, event: 'click', fn: saveFn });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelBtn) {
|
||||||
|
var cancelFn = function() { close(); };
|
||||||
|
cancelBtn.addEventListener('click', cancelFn);
|
||||||
|
listeners.push({ el: cancelBtn, event: 'click', fn: cancelFn });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (repairBtn) {
|
||||||
|
var repairFn = function() { repairNfo(); };
|
||||||
|
repairBtn.addEventListener('click', repairFn);
|
||||||
|
listeners.push({ el: repairBtn, event: 'click', fn: repairFn });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overlay) {
|
||||||
|
var overlayFn = function() { close(); };
|
||||||
|
overlay.addEventListener('click', overlayFn);
|
||||||
|
listeners.push({ el: overlay, event: 'click', fn: overlayFn });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyInput) {
|
||||||
|
var keyFn = function() {
|
||||||
|
var warning = document.getElementById('key-change-warning');
|
||||||
|
if (warning) {
|
||||||
|
warning.style.display = keyInput.value !== originalData.key ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
keyInput.addEventListener('input', keyFn);
|
||||||
|
listeners.push({ el: keyInput, event: 'input', fn: keyFn });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function detachListeners() {
|
||||||
|
listeners.forEach(function(l) {
|
||||||
|
l.el.removeEventListener(l.event, l.fn);
|
||||||
|
});
|
||||||
|
listeners = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
open: open,
|
||||||
|
close: close,
|
||||||
|
save: save,
|
||||||
|
repairNfo: repairNfo
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -40,31 +40,6 @@ AniWorld.SeriesManager = (function() {
|
|||||||
if (sortBtn) {
|
if (sortBtn) {
|
||||||
sortBtn.addEventListener('click', toggleAlphabeticalSort);
|
sortBtn.addEventListener('click', toggleAlphabeticalSort);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event delegation for dynamically created edit-key buttons
|
|
||||||
document.addEventListener('click', function(e) {
|
|
||||||
const editKeyBtn = e.target.closest('.edit-key-btn');
|
|
||||||
if (editKeyBtn) {
|
|
||||||
e.preventDefault();
|
|
||||||
const key = editKeyBtn.dataset.key;
|
|
||||||
const folder = editKeyBtn.dataset.folder;
|
|
||||||
if (window.showEditKeyModal) {
|
|
||||||
window.showEditKeyModal(key, folder);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const editMetadataBtn = e.target.closest('.edit-metadata-btn');
|
|
||||||
if (editMetadataBtn) {
|
|
||||||
e.preventDefault();
|
|
||||||
const key = editMetadataBtn.dataset.key;
|
|
||||||
const name = editMetadataBtn.dataset.name;
|
|
||||||
const tmdbId = editMetadataBtn.dataset.tmdbId || null;
|
|
||||||
const tvdbId = editMetadataBtn.dataset.tvdbId || null;
|
|
||||||
if (window.showEditMetadataModal) {
|
|
||||||
window.showEditMetadataModal(key, name, tmdbId, tvdbId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -368,8 +343,7 @@ AniWorld.SeriesManager = (function() {
|
|||||||
const canBeSelected = hasMissingEpisodes;
|
const canBeSelected = hasMissingEpisodes;
|
||||||
const hasNfo = serie.has_nfo || false;
|
const hasNfo = serie.has_nfo || false;
|
||||||
const isLoading = serie.loading_status && serie.loading_status !== 'completed' && serie.loading_status !== 'failed';
|
const isLoading = serie.loading_status && serie.loading_status !== 'completed' && serie.loading_status !== 'failed';
|
||||||
const hasKeyError = serie.loading_error && serie.loading_error.includes('key cannot be None or empty');
|
|
||||||
|
|
||||||
// Debug logging for troubleshooting
|
// Debug logging for troubleshooting
|
||||||
if (serie.key === 'so-im-a-spider-so-what') {
|
if (serie.key === 'so-im-a-spider-so-what') {
|
||||||
console.log('[createSerieCard] Spider series:', {
|
console.log('[createSerieCard] Spider series:', {
|
||||||
@@ -382,12 +356,6 @@ AniWorld.SeriesManager = (function() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const editKeyBtn = hasKeyError
|
|
||||||
? '<button class="btn btn-icon edit-key-btn" title="Fix key error" data-key="' + serie.key + '" data-folder="' + serie.folder + '"><i class="fas fa-key"></i></button>'
|
|
||||||
: '';
|
|
||||||
|
|
||||||
const editMetadataBtn = '<button class="btn btn-icon edit-metadata-btn" title="Edit Metadata IDs" data-key="' + serie.key + '" data-name="' + AniWorld.UI.escapeHtml(serie.name) + '" data-tmdb-id="' + (serie.tmdb_id || '') + '" data-tvdb-id="' + (serie.tvdb_id || '') + '"><i class="fas fa-database"></i></button>';
|
|
||||||
|
|
||||||
return '<div class="series-card ' + (isSelected ? 'selected' : '') + ' ' +
|
return '<div class="series-card ' + (isSelected ? 'selected' : '') + ' ' +
|
||||||
(hasMissingEpisodes ? 'has-missing' : 'complete') + ' ' +
|
(hasMissingEpisodes ? 'has-missing' : 'complete') + ' ' +
|
||||||
(isLoading ? 'loading' : '') + '" ' +
|
(isLoading ? 'loading' : '') + '" ' +
|
||||||
@@ -400,12 +368,9 @@ AniWorld.SeriesManager = (function() {
|
|||||||
'<div class="series-folder">' + AniWorld.UI.escapeHtml(serie.folder) + '</div>' +
|
'<div class="series-folder">' + AniWorld.UI.escapeHtml(serie.folder) + '</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="series-status">' +
|
'<div class="series-status">' +
|
||||||
(hasKeyError ? '<i class="fas fa-exclamation-triangle key-error-badge" title="Key error: ' + serie.loading_error + '"></i>' : '') +
|
|
||||||
(hasMissingEpisodes ? '' : '<i class="fas fa-check-circle status-complete" title="Complete"></i>') +
|
(hasMissingEpisodes ? '' : '<i class="fas fa-check-circle status-complete" title="Complete"></i>') +
|
||||||
(hasNfo ? '<i class="fas fa-file-alt nfo-badge nfo-exists" title="NFO metadata available"></i>' :
|
(hasNfo ? '<i class="fas fa-file-alt nfo-badge nfo-exists" title="NFO metadata available"></i>' :
|
||||||
'<i class="fas fa-file-alt nfo-badge nfo-missing" title="No NFO metadata"></i>') +
|
'<i class="fas fa-file-alt nfo-badge nfo-missing" title="No NFO metadata"></i>') +
|
||||||
editMetadataBtn +
|
|
||||||
editKeyBtn +
|
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="series-stats">' +
|
'<div class="series-stats">' +
|
||||||
@@ -427,6 +392,22 @@ AniWorld.SeriesManager = (function() {
|
|||||||
return seriesData;
|
return seriesData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a series key in the local data arrays after rename.
|
||||||
|
* @param {string} oldKey - The previous key
|
||||||
|
* @param {string} newKey - The new key
|
||||||
|
*/
|
||||||
|
function updateSeriesKey(oldKey, newKey) {
|
||||||
|
if (seriesData) {
|
||||||
|
var s = seriesData.find(function(item) { return item.key === oldKey; });
|
||||||
|
if (s) s.key = newKey;
|
||||||
|
}
|
||||||
|
if (filteredSeriesData) {
|
||||||
|
var fs = filteredSeriesData.find(function(item) { return item.key === oldKey; });
|
||||||
|
if (fs) fs.key = newKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get filtered series data
|
* Get filtered series data
|
||||||
* @returns {Array} Filtered series data array
|
* @returns {Array} Filtered series data array
|
||||||
@@ -578,6 +559,7 @@ AniWorld.SeriesManager = (function() {
|
|||||||
getFilteredSeriesData: getFilteredSeriesData,
|
getFilteredSeriesData: getFilteredSeriesData,
|
||||||
findByKey: findByKey,
|
findByKey: findByKey,
|
||||||
updateSeriesLoadingStatus: updateSeriesLoadingStatus,
|
updateSeriesLoadingStatus: updateSeriesLoadingStatus,
|
||||||
updateSingleSeries: updateSingleSeries
|
updateSingleSeries: updateSingleSeries,
|
||||||
|
updateSeriesKey: updateSeriesKey
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -640,83 +640,75 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Edit Key Modal -->
|
<!-- Edit Metadata Modal -->
|
||||||
<div id="edit-key-modal" class="modal hidden">
|
|
||||||
<div class="modal-overlay"></div>
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h3 data-text="edit-key-title">Edit Series Key</h3>
|
|
||||||
<button id="close-edit-key" class="btn btn-icon">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="config-item">
|
|
||||||
<label data-text="current-folder">Folder Name:</label>
|
|
||||||
<span id="edit-key-folder" class="config-value"></span>
|
|
||||||
</div>
|
|
||||||
<div class="config-item">
|
|
||||||
<label for="edit-key-input" data-text="new-key">New Key:</label>
|
|
||||||
<input type="text" id="edit-key-input" class="input-field"
|
|
||||||
placeholder="e.g., attack-on-titan" minlength="2">
|
|
||||||
<small class="config-hint" data-text="key-format-hint">
|
|
||||||
URL-safe key (alphanumeric, hyphens, underscores)
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
<div id="edit-key-error" class="config-error hidden"></div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button id="save-edit-key" class="btn btn-primary">
|
|
||||||
<i class="fas fa-save"></i>
|
|
||||||
<span data-text="save">Save</span>
|
|
||||||
</button>
|
|
||||||
<button id="cancel-edit-key" class="btn btn-secondary">
|
|
||||||
<span data-text="cancel">Cancel</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Edit Metadata IDs Modal -->
|
|
||||||
<div id="edit-metadata-modal" class="modal hidden">
|
<div id="edit-metadata-modal" class="modal hidden">
|
||||||
<div class="modal-overlay"></div>
|
<div class="modal-overlay"></div>
|
||||||
<div class="modal-content">
|
<div class="modal-content edit-modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3 data-text="edit-metadata-title">Edit Metadata IDs</h3>
|
<h3>Edit Metadata</h3>
|
||||||
<button id="close-edit-metadata" class="btn btn-icon">
|
<button id="btn-cancel-metadata" class="btn btn-icon">
|
||||||
<i class="fas fa-times"></i>
|
<i class="fas fa-times"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="config-item">
|
<form id="edit-metadata-form" onsubmit="return false;">
|
||||||
<label data-text="series-name">Series:</label>
|
<!-- Identity Section -->
|
||||||
<span id="edit-metadata-series-name" class="config-value"></span>
|
<div class="edit-section">
|
||||||
</div>
|
<h4><i class="fa-solid fa-key"></i> Identity</h4>
|
||||||
<div class="config-item">
|
<div class="form-group">
|
||||||
<label for="edit-metadata-tmdb" data-text="tmdb-id">TMDB ID:</label>
|
<label for="edit-key">Series Key</label>
|
||||||
<input type="number" id="edit-metadata-tmdb" class="input-field"
|
<input type="text" id="edit-key" class="input-field"
|
||||||
placeholder="e.g., 12345" min="1" step="1">
|
placeholder="e.g. attack-on-titan"
|
||||||
<small class="config-hint" data-text="tmdb-id-hint">
|
pattern="[a-z0-9][a-z0-9-]*[a-z0-9]">
|
||||||
Leave blank to clear. Find IDs at themoviedb.org
|
<span class="field-error" style="display:none;"></span>
|
||||||
</small>
|
</div>
|
||||||
</div>
|
<div id="key-change-warning" class="key-warning" style="display:none;">
|
||||||
<div class="config-item">
|
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||||
<label for="edit-metadata-tvdb" data-text="tvdb-id">TVDB ID:</label>
|
Changing the key will update the primary identifier. This may affect provider linkage.
|
||||||
<input type="number" id="edit-metadata-tvdb" class="input-field"
|
</div>
|
||||||
placeholder="e.g., 67890" min="1" step="1">
|
</div>
|
||||||
<small class="config-hint" data-text="tvdb-id-hint">
|
|
||||||
Leave blank to clear. Find IDs at thetvdb.com
|
<!-- External IDs Section -->
|
||||||
</small>
|
<div class="edit-section">
|
||||||
</div>
|
<h4><i class="fa-solid fa-database"></i> External IDs</h4>
|
||||||
<div id="edit-metadata-error" class="config-error hidden"></div>
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-tmdb-id">TMDB ID</label>
|
||||||
|
<input type="number" id="edit-tmdb-id" class="input-field"
|
||||||
|
placeholder="e.g. 1429" min="1">
|
||||||
|
<span class="field-error" style="display:none;"></span>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-tvdb-id">TVDB ID</label>
|
||||||
|
<input type="number" id="edit-tvdb-id" class="input-field"
|
||||||
|
placeholder="e.g. 267440" min="1">
|
||||||
|
<span class="field-error" style="display:none;"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- NFO Status Section -->
|
||||||
|
<div class="edit-section">
|
||||||
|
<h4><i class="fa-solid fa-file-lines"></i> NFO Status</h4>
|
||||||
|
<div class="nfo-diagnostics">
|
||||||
|
<div id="nfo-status-badge" class="nfo-status-badge">Loading...</div>
|
||||||
|
<div id="nfo-diagnostics-container">
|
||||||
|
<div id="nfo-missing-tags" class="missing-tags-list"></div>
|
||||||
|
</div>
|
||||||
|
<p id="repair-hint" class="repair-hint" style="display:none;">
|
||||||
|
<i class="fa-solid fa-circle-info"></i>
|
||||||
|
No TMDB ID set. Repair will search TMDB by series name.
|
||||||
|
</p>
|
||||||
|
<button type="button" id="btn-repair-nfo" class="btn btn-secondary btn-repair">
|
||||||
|
<i class="fa-solid fa-wrench"></i> Repair NFO
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button id="save-edit-metadata" class="btn btn-primary">
|
<button type="button" id="btn-save-metadata" class="btn btn-primary">
|
||||||
<i class="fas fa-save"></i>
|
<i class="fa-solid fa-floppy-disk"></i> Save
|
||||||
<span data-text="save-refresh">Save & Refresh NFO</span>
|
|
||||||
</button>
|
|
||||||
<button id="cancel-edit-metadata" class="btn btn-secondary">
|
|
||||||
<span data-text="cancel">Cancel</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -747,6 +739,8 @@
|
|||||||
<script src="/static/js/user_preferences.js?v={{ static_v }}"></script>
|
<script src="/static/js/user_preferences.js?v={{ static_v }}"></script>
|
||||||
|
|
||||||
<!-- Index Page Modules -->
|
<!-- Index Page Modules -->
|
||||||
|
<script src="/static/js/index/context-menu.js?v={{ static_v }}"></script>
|
||||||
|
<script src="/static/js/index/edit-modal.js?v={{ static_v }}"></script>
|
||||||
<script src="/static/js/index/series-manager.js?v={{ static_v }}"></script>
|
<script src="/static/js/index/series-manager.js?v={{ static_v }}"></script>
|
||||||
<script src="/static/js/index/selection-manager.js?v={{ static_v }}"></script>
|
<script src="/static/js/index/selection-manager.js?v={{ static_v }}"></script>
|
||||||
<script src="/static/js/index/search.js?v={{ static_v }}"></script>
|
<script src="/static/js/index/search.js?v={{ static_v }}"></script>
|
||||||
|
|||||||
255
tests/api/test_anime_edit_endpoints.py
Normal file
255
tests/api/test_anime_edit_endpoints.py
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
"""Tests for anime metadata edit (PUT /api/anime/{anime_key}) endpoint."""
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
from src.server.fastapi_app import app
|
||||||
|
from src.server.services.auth_service import auth_service
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def reset_auth():
|
||||||
|
"""Reset auth state before each test."""
|
||||||
|
auth_service._hash = None
|
||||||
|
auth_service._failed = {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def client():
|
||||||
|
"""Create async test 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):
|
||||||
|
"""Get authenticated client with Bearer token."""
|
||||||
|
# Setup auth
|
||||||
|
await client.post("/api/auth/setup", json={"master_password": "TestPass123!"})
|
||||||
|
response = await client.post(
|
||||||
|
"/api/auth/login", json={"password": "TestPass123!"}
|
||||||
|
)
|
||||||
|
token = response.json()["access_token"]
|
||||||
|
client.headers["Authorization"] = f"Bearer {token}"
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_db_session():
|
||||||
|
"""Create a mock async database session."""
|
||||||
|
session = AsyncMock()
|
||||||
|
session.commit = AsyncMock()
|
||||||
|
session.flush = AsyncMock()
|
||||||
|
session.refresh = AsyncMock()
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_series_in_db():
|
||||||
|
"""Create a mock AnimeSeries DB record."""
|
||||||
|
series = MagicMock()
|
||||||
|
series.id = 1
|
||||||
|
series.key = "test-anime"
|
||||||
|
series.name = "Test Anime"
|
||||||
|
series.tmdb_id = 1234
|
||||||
|
series.tvdb_id = 5678
|
||||||
|
series.folder = "Test Anime (2023)"
|
||||||
|
return series
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def override_db_dependency(mock_db_session):
|
||||||
|
"""Override database session dependency."""
|
||||||
|
from src.server.utils.dependencies import get_database_session
|
||||||
|
|
||||||
|
app.dependency_overrides[get_database_session] = lambda: mock_db_session
|
||||||
|
yield mock_db_session
|
||||||
|
app.dependency_overrides.pop(get_database_session, None)
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpdateAnimeMetadata:
|
||||||
|
"""Tests for PUT /api/anime/{anime_key}."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_tmdb_id_success(
|
||||||
|
self, reset_auth, authenticated_client, override_db_dependency, mock_series_in_db
|
||||||
|
):
|
||||||
|
"""Test successful tmdb_id update."""
|
||||||
|
with patch(
|
||||||
|
"src.server.api.anime.AnimeSeriesService.get_by_key",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=mock_series_in_db,
|
||||||
|
), patch(
|
||||||
|
"src.server.api.anime.AnimeSeriesService.update",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
) as mock_update:
|
||||||
|
mock_series_in_db.tmdb_id = 9999
|
||||||
|
mock_update.return_value = mock_series_in_db
|
||||||
|
|
||||||
|
response = await authenticated_client.put(
|
||||||
|
"/api/anime/test-anime",
|
||||||
|
json={"tmdb_id": 9999},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["tmdb_id"] == 9999
|
||||||
|
assert data["message"] == "Metadata updated successfully"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_tvdb_id_success(
|
||||||
|
self, reset_auth, authenticated_client, override_db_dependency, mock_series_in_db
|
||||||
|
):
|
||||||
|
"""Test successful tvdb_id update."""
|
||||||
|
with patch(
|
||||||
|
"src.server.api.anime.AnimeSeriesService.get_by_key",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=mock_series_in_db,
|
||||||
|
), patch(
|
||||||
|
"src.server.api.anime.AnimeSeriesService.update",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
) as mock_update:
|
||||||
|
mock_series_in_db.tvdb_id = 7777
|
||||||
|
mock_update.return_value = mock_series_in_db
|
||||||
|
|
||||||
|
response = await authenticated_client.put(
|
||||||
|
"/api/anime/test-anime",
|
||||||
|
json={"tvdb_id": 7777},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["tvdb_id"] == 7777
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_key_success(
|
||||||
|
self, reset_auth, authenticated_client, override_db_dependency, mock_series_in_db
|
||||||
|
):
|
||||||
|
"""Test successful key rename."""
|
||||||
|
with patch(
|
||||||
|
"src.server.api.anime.AnimeSeriesService.get_by_key",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
) as mock_get:
|
||||||
|
# First call finds the series, second call checks uniqueness (returns None)
|
||||||
|
mock_get.side_effect = [mock_series_in_db, None]
|
||||||
|
|
||||||
|
mock_series_in_db.key = "new-anime-key"
|
||||||
|
with patch(
|
||||||
|
"src.server.api.anime.AnimeSeriesService.update",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=mock_series_in_db,
|
||||||
|
):
|
||||||
|
response = await authenticated_client.put(
|
||||||
|
"/api/anime/test-anime",
|
||||||
|
json={"key": "new-anime-key"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["key"] == "new-anime-key"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_key_conflict_409(
|
||||||
|
self, reset_auth, authenticated_client, override_db_dependency, mock_series_in_db
|
||||||
|
):
|
||||||
|
"""Test key rename conflict returns 409."""
|
||||||
|
existing_series = MagicMock()
|
||||||
|
existing_series.key = "existing-key"
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.server.api.anime.AnimeSeriesService.get_by_key",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
) as mock_get:
|
||||||
|
# First call finds original series, second call finds conflict
|
||||||
|
mock_get.side_effect = [mock_series_in_db, existing_series]
|
||||||
|
|
||||||
|
response = await authenticated_client.put(
|
||||||
|
"/api/anime/test-anime",
|
||||||
|
json={"key": "existing-key"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 409
|
||||||
|
assert "already exists" in response.json()["detail"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_key_invalid_chars_422(
|
||||||
|
self, reset_auth, authenticated_client, override_db_dependency
|
||||||
|
):
|
||||||
|
"""Test key with invalid characters returns 422."""
|
||||||
|
response = await authenticated_client.put(
|
||||||
|
"/api/anime/test-anime",
|
||||||
|
json={"key": "Invalid Key With Spaces!"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_key_empty_422(
|
||||||
|
self, reset_auth, authenticated_client, override_db_dependency
|
||||||
|
):
|
||||||
|
"""Test empty key returns 422."""
|
||||||
|
response = await authenticated_client.put(
|
||||||
|
"/api/anime/test-anime",
|
||||||
|
json={"key": ""},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_unauthenticated_401(self, reset_auth, client):
|
||||||
|
"""Test unauthenticated access returns 401."""
|
||||||
|
response = await client.put(
|
||||||
|
"/api/anime/test-anime",
|
||||||
|
json={"tmdb_id": 1234},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_nonexistent_anime_404(
|
||||||
|
self, reset_auth, authenticated_client, override_db_dependency
|
||||||
|
):
|
||||||
|
"""Test update of non-existent anime returns 404."""
|
||||||
|
with patch(
|
||||||
|
"src.server.api.anime.AnimeSeriesService.get_by_key",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=None,
|
||||||
|
):
|
||||||
|
response = await authenticated_client.put(
|
||||||
|
"/api/anime/nonexistent-key",
|
||||||
|
json={"tmdb_id": 1234},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_no_changes(
|
||||||
|
self, reset_auth, authenticated_client, override_db_dependency, mock_series_in_db
|
||||||
|
):
|
||||||
|
"""Test sending empty body returns no-op response."""
|
||||||
|
with patch(
|
||||||
|
"src.server.api.anime.AnimeSeriesService.get_by_key",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=mock_series_in_db,
|
||||||
|
):
|
||||||
|
response = await authenticated_client.put(
|
||||||
|
"/api/anime/test-anime",
|
||||||
|
json={},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["message"] == "No changes"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_negative_tmdb_id_422(
|
||||||
|
self, reset_auth, authenticated_client, override_db_dependency
|
||||||
|
):
|
||||||
|
"""Test negative TMDB ID returns 422."""
|
||||||
|
response = await authenticated_client.put(
|
||||||
|
"/api/anime/test-anime",
|
||||||
|
json={"tmdb_id": -5},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
317
tests/api/test_nfo_diagnostics_repair.py
Normal file
317
tests/api/test_nfo_diagnostics_repair.py
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
"""Tests for NFO diagnostics and repair API endpoints."""
|
||||||
|
from unittest.mock import AsyncMock, Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
from src.server.fastapi_app import app
|
||||||
|
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._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
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def authenticated_client(client):
|
||||||
|
"""Create an authenticated test client with token."""
|
||||||
|
await client.post(
|
||||||
|
"/api/auth/setup",
|
||||||
|
json={"master_password": "TestPassword123!"}
|
||||||
|
)
|
||||||
|
response = await client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"password": "TestPassword123!"}
|
||||||
|
)
|
||||||
|
token = response.json()["access_token"]
|
||||||
|
client.headers.update({"Authorization": f"Bearer {token}"})
|
||||||
|
yield client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_series_app():
|
||||||
|
"""Create mock series app with one test series."""
|
||||||
|
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)")
|
||||||
|
|
||||||
|
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_dependencies(mock_series_app, mock_nfo_service):
|
||||||
|
"""Override dependencies for 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
|
||||||
|
|
||||||
|
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 TestNfoDiagnostics:
|
||||||
|
"""Tests for GET /api/nfo/{serie_key}/diagnostics."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_diagnostics_complete_nfo(
|
||||||
|
self, authenticated_client, override_dependencies
|
||||||
|
):
|
||||||
|
"""Test diagnostics with complete NFO returns no missing tags."""
|
||||||
|
with patch(
|
||||||
|
"src.server.api.nfo.Path.exists", return_value=True
|
||||||
|
), patch(
|
||||||
|
"src.server.api.nfo.find_missing_tags", return_value=[]
|
||||||
|
):
|
||||||
|
response = await authenticated_client.get(
|
||||||
|
"/api/nfo/test-anime/diagnostics"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["has_nfo"] is True
|
||||||
|
assert data["missing_tags"] == []
|
||||||
|
assert len(data["required_tags"]) > 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_diagnostics_missing_tags(
|
||||||
|
self, authenticated_client, override_dependencies
|
||||||
|
):
|
||||||
|
"""Test diagnostics with missing tags returns them."""
|
||||||
|
with patch(
|
||||||
|
"src.server.api.nfo.Path.exists", return_value=True
|
||||||
|
), patch(
|
||||||
|
"src.server.api.nfo.find_missing_tags",
|
||||||
|
return_value=["plot", "genre", "actor/name"],
|
||||||
|
):
|
||||||
|
response = await authenticated_client.get(
|
||||||
|
"/api/nfo/test-anime/diagnostics"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["has_nfo"] is True
|
||||||
|
assert "plot" in data["missing_tags"]
|
||||||
|
assert "genre" in data["missing_tags"]
|
||||||
|
assert len(data["missing_tags"]) == 3
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_diagnostics_no_nfo_file(
|
||||||
|
self, authenticated_client, override_dependencies
|
||||||
|
):
|
||||||
|
"""Test diagnostics when no NFO exists returns all tags as missing."""
|
||||||
|
with patch("src.server.api.nfo.Path") as MockPath:
|
||||||
|
# Make nfo_path.exists() return False
|
||||||
|
mock_path_instance = Mock()
|
||||||
|
mock_path_instance.exists.return_value = False
|
||||||
|
mock_path_instance.__truediv__ = Mock(return_value=mock_path_instance)
|
||||||
|
MockPath.return_value = mock_path_instance
|
||||||
|
|
||||||
|
response = await authenticated_client.get(
|
||||||
|
"/api/nfo/test-anime/diagnostics"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["has_nfo"] is False
|
||||||
|
assert len(data["missing_tags"]) > 0
|
||||||
|
# All required tags should be listed as missing
|
||||||
|
assert data["missing_tags"] == data["required_tags"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_diagnostics_nonexistent_series_404(
|
||||||
|
self, authenticated_client, override_dependencies, mock_series_app
|
||||||
|
):
|
||||||
|
"""Test diagnostics for non-existent series returns 404."""
|
||||||
|
# Override to return empty list
|
||||||
|
mock_series_app.list.GetList.return_value = []
|
||||||
|
|
||||||
|
response = await authenticated_client.get(
|
||||||
|
"/api/nfo/nonexistent-key/diagnostics"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_diagnostics_unauthenticated_401(self, client):
|
||||||
|
"""Test diagnostics requires authentication."""
|
||||||
|
response = await client.get("/api/nfo/test-anime/diagnostics")
|
||||||
|
# May return 401 or 503 depending on NFO service availability
|
||||||
|
assert response.status_code in (401, 503)
|
||||||
|
|
||||||
|
|
||||||
|
class TestNfoRepair:
|
||||||
|
"""Tests for POST /api/nfo/{serie_key}/repair."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_repair_success(
|
||||||
|
self, authenticated_client, override_dependencies
|
||||||
|
):
|
||||||
|
"""Test successful NFO repair."""
|
||||||
|
with patch("src.server.api.nfo.Path") as MockPath:
|
||||||
|
mock_path = Mock()
|
||||||
|
mock_path.exists.return_value = True
|
||||||
|
mock_path.__truediv__ = Mock(return_value=mock_path)
|
||||||
|
MockPath.return_value = mock_path
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.server.api.nfo.find_missing_tags",
|
||||||
|
return_value=["plot", "genre"],
|
||||||
|
), patch(
|
||||||
|
"src.server.api.nfo.NfoRepairService"
|
||||||
|
) as MockRepairService:
|
||||||
|
mock_instance = Mock()
|
||||||
|
mock_instance.repair_series = AsyncMock(return_value=True)
|
||||||
|
MockRepairService.return_value = mock_instance
|
||||||
|
|
||||||
|
response = await authenticated_client.post(
|
||||||
|
"/api/nfo/test-anime/repair", json={}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["success"] is True
|
||||||
|
assert "2" in data["message"] # "Fixed 2 missing tags"
|
||||||
|
assert "plot" in data["repaired_tags"]
|
||||||
|
assert "genre" in data["repaired_tags"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_repair_already_complete(
|
||||||
|
self, authenticated_client, override_dependencies
|
||||||
|
):
|
||||||
|
"""Test repair when NFO is already complete."""
|
||||||
|
with patch("src.server.api.nfo.Path") as MockPath:
|
||||||
|
mock_path = Mock()
|
||||||
|
mock_path.exists.return_value = True
|
||||||
|
mock_path.__truediv__ = Mock(return_value=mock_path)
|
||||||
|
MockPath.return_value = mock_path
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.server.api.nfo.find_missing_tags", return_value=[]
|
||||||
|
), patch(
|
||||||
|
"src.server.api.nfo.NfoRepairService"
|
||||||
|
) as MockRepairService:
|
||||||
|
mock_instance = Mock()
|
||||||
|
mock_instance.repair_series = AsyncMock(return_value=False)
|
||||||
|
MockRepairService.return_value = mock_instance
|
||||||
|
|
||||||
|
response = await authenticated_client.post(
|
||||||
|
"/api/nfo/test-anime/repair", json={}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["success"] is True
|
||||||
|
assert "already complete" in data["message"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_repair_creates_new_nfo(
|
||||||
|
self, authenticated_client, override_dependencies, mock_nfo_service
|
||||||
|
):
|
||||||
|
"""Test repair when no NFO exists creates a new one."""
|
||||||
|
with patch("src.server.api.nfo.Path") as MockPath:
|
||||||
|
mock_path = Mock()
|
||||||
|
mock_path.exists.return_value = False
|
||||||
|
mock_path.__truediv__ = Mock(return_value=mock_path)
|
||||||
|
MockPath.return_value = mock_path
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.server.api.nfo.REQUIRED_TAGS",
|
||||||
|
{"./title": "title", "./plot": "plot"},
|
||||||
|
):
|
||||||
|
response = await authenticated_client.post(
|
||||||
|
"/api/nfo/test-anime/repair", json={}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["success"] is True
|
||||||
|
mock_nfo_service.create_tvshow_nfo.assert_awaited_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_repair_nonexistent_series_404(
|
||||||
|
self, authenticated_client, override_dependencies, mock_series_app
|
||||||
|
):
|
||||||
|
"""Test repair for non-existent series returns 404."""
|
||||||
|
mock_series_app.list.GetList.return_value = []
|
||||||
|
|
||||||
|
response = await authenticated_client.post(
|
||||||
|
"/api/nfo/nonexistent-key/repair", json={}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_repair_unauthenticated_401(self, client):
|
||||||
|
"""Test repair requires authentication."""
|
||||||
|
response = await client.post("/api/nfo/test-anime/repair", json={})
|
||||||
|
assert response.status_code in (401, 503)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_repair_tmdb_api_failure(
|
||||||
|
self, authenticated_client, override_dependencies
|
||||||
|
):
|
||||||
|
"""Test repair handles TMDB API failure gracefully."""
|
||||||
|
from src.core.services.tmdb_client import TMDBAPIError
|
||||||
|
|
||||||
|
with patch("src.server.api.nfo.Path") as MockPath:
|
||||||
|
mock_path = Mock()
|
||||||
|
mock_path.exists.return_value = True
|
||||||
|
mock_path.__truediv__ = Mock(return_value=mock_path)
|
||||||
|
MockPath.return_value = mock_path
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.server.api.nfo.find_missing_tags",
|
||||||
|
return_value=["plot"],
|
||||||
|
), patch(
|
||||||
|
"src.server.api.nfo.NfoRepairService"
|
||||||
|
) as MockRepairService:
|
||||||
|
mock_instance = Mock()
|
||||||
|
mock_instance.repair_series = AsyncMock(
|
||||||
|
side_effect=TMDBAPIError("No TMDB ID found")
|
||||||
|
)
|
||||||
|
MockRepairService.return_value = mock_instance
|
||||||
|
|
||||||
|
response = await authenticated_client.post(
|
||||||
|
"/api/nfo/test-anime/repair", json={}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "Cannot repair NFO" in response.json()["detail"]
|
||||||
115
tests/frontend/test_edit_modal.py
Normal file
115
tests/frontend/test_edit_modal.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"""Frontend tests for the edit metadata modal HTML structure."""
|
||||||
|
from unittest.mock import AsyncMock, Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
from src.server.fastapi_app import app
|
||||||
|
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._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
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def authenticated_client(client):
|
||||||
|
"""Create authenticated client to access index page."""
|
||||||
|
await client.post(
|
||||||
|
"/api/auth/setup",
|
||||||
|
json={"master_password": "TestPassword123!"}
|
||||||
|
)
|
||||||
|
response = await client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"password": "TestPassword123!"}
|
||||||
|
)
|
||||||
|
token = response.json()["access_token"]
|
||||||
|
client.headers.update({"Authorization": f"Bearer {token}"})
|
||||||
|
# Set cookie for page access
|
||||||
|
client.cookies.set("access_token", token)
|
||||||
|
yield client
|
||||||
|
|
||||||
|
|
||||||
|
class TestEditModalHtmlPresence:
|
||||||
|
"""Tests verifying edit modal HTML elements exist in index page."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_index_page_contains_edit_modal(self, authenticated_client):
|
||||||
|
"""Verify #edit-metadata-modal exists in rendered index page."""
|
||||||
|
response = await authenticated_client.get("/")
|
||||||
|
|
||||||
|
# Page may redirect or require different auth for HTML pages
|
||||||
|
if response.status_code == 200:
|
||||||
|
html = response.text
|
||||||
|
assert 'id="edit-metadata-modal"' in html
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_index_page_loads_context_menu_script(self, authenticated_client):
|
||||||
|
"""Verify context-menu.js script tag is present."""
|
||||||
|
response = await authenticated_client.get("/")
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
html = response.text
|
||||||
|
assert "context-menu.js" in html
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_index_page_loads_edit_modal_script(self, authenticated_client):
|
||||||
|
"""Verify edit-modal.js script tag is present."""
|
||||||
|
response = await authenticated_client.get("/")
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
html = response.text
|
||||||
|
assert "edit-modal.js" in html
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_modal_form_fields_present(self, authenticated_client):
|
||||||
|
"""Verify key, tmdb_id, tvdb_id input fields exist in modal."""
|
||||||
|
response = await authenticated_client.get("/")
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
html = response.text
|
||||||
|
assert 'id="edit-key"' in html
|
||||||
|
assert 'id="edit-tmdb-id"' in html
|
||||||
|
assert 'id="edit-tvdb-id"' in html
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_nfo_repair_button_present(self, authenticated_client):
|
||||||
|
"""Verify repair NFO button exists in modal."""
|
||||||
|
response = await authenticated_client.get("/")
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
html = response.text
|
||||||
|
assert 'id="btn-repair-nfo"' in html
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_save_button_present(self, authenticated_client):
|
||||||
|
"""Verify save button exists in modal."""
|
||||||
|
response = await authenticated_client.get("/")
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
html = response.text
|
||||||
|
assert 'id="btn-save-metadata"' in html
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_modal_starts_hidden(self, authenticated_client):
|
||||||
|
"""Verify modal has hidden class by default."""
|
||||||
|
response = await authenticated_client.get("/")
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
html = response.text
|
||||||
|
assert 'id="edit-metadata-modal" class="modal hidden"' in html
|
||||||
161
tests/unit/test_anime_key_rename.py
Normal file
161
tests/unit/test_anime_key_rename.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
"""Unit tests for anime key rename logic and validation."""
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from src.server.models.anime import AnimeMetadataUpdate, KEY_PATTERN
|
||||||
|
|
||||||
|
|
||||||
|
class TestKeyValidation:
|
||||||
|
"""Tests for AnimeMetadataUpdate key validation."""
|
||||||
|
|
||||||
|
def test_valid_key_simple(self):
|
||||||
|
"""Test simple valid key."""
|
||||||
|
model = AnimeMetadataUpdate(key="attack-on-titan")
|
||||||
|
assert model.key == "attack-on-titan"
|
||||||
|
|
||||||
|
def test_valid_key_single_char(self):
|
||||||
|
"""Test single character key is valid."""
|
||||||
|
model = AnimeMetadataUpdate(key="a")
|
||||||
|
assert model.key == "a"
|
||||||
|
|
||||||
|
def test_valid_key_numbers(self):
|
||||||
|
"""Test key with numbers."""
|
||||||
|
model = AnimeMetadataUpdate(key="86-eighty-six")
|
||||||
|
assert model.key == "86-eighty-six"
|
||||||
|
|
||||||
|
def test_valid_key_allows_hyphens(self):
|
||||||
|
"""Test hyphens in key are allowed."""
|
||||||
|
model = AnimeMetadataUpdate(key="my-anime-key")
|
||||||
|
assert model.key == "my-anime-key"
|
||||||
|
|
||||||
|
def test_valid_key_normalizes_to_lowercase(self):
|
||||||
|
"""Test key is normalized to lowercase."""
|
||||||
|
model = AnimeMetadataUpdate(key="Attack-On-Titan")
|
||||||
|
assert model.key == "attack-on-titan"
|
||||||
|
|
||||||
|
def test_valid_key_strips_whitespace(self):
|
||||||
|
"""Test key strips leading/trailing whitespace."""
|
||||||
|
model = AnimeMetadataUpdate(key=" my-key ")
|
||||||
|
assert model.key == "my-key"
|
||||||
|
|
||||||
|
def test_invalid_key_spaces(self):
|
||||||
|
"""Test key with spaces is rejected."""
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
AnimeMetadataUpdate(key="my anime key")
|
||||||
|
assert "Key must contain only" in str(exc_info.value)
|
||||||
|
|
||||||
|
def test_invalid_key_uppercase_special(self):
|
||||||
|
"""Test key with special characters is rejected."""
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
AnimeMetadataUpdate(key="anime!@#key")
|
||||||
|
assert "Key must contain only" in str(exc_info.value)
|
||||||
|
|
||||||
|
def test_invalid_key_empty(self):
|
||||||
|
"""Test empty key is rejected."""
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
AnimeMetadataUpdate(key="")
|
||||||
|
assert "cannot be empty" in str(exc_info.value)
|
||||||
|
|
||||||
|
def test_invalid_key_only_whitespace(self):
|
||||||
|
"""Test whitespace-only key is rejected."""
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
AnimeMetadataUpdate(key=" ")
|
||||||
|
assert "cannot be empty" in str(exc_info.value)
|
||||||
|
|
||||||
|
def test_invalid_key_starts_with_hyphen(self):
|
||||||
|
"""Test key starting with hyphen is rejected."""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
AnimeMetadataUpdate(key="-my-key")
|
||||||
|
|
||||||
|
def test_invalid_key_ends_with_hyphen(self):
|
||||||
|
"""Test key ending with hyphen is rejected."""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
AnimeMetadataUpdate(key="my-key-")
|
||||||
|
|
||||||
|
def test_key_none_is_allowed(self):
|
||||||
|
"""Test None key (no change requested) is allowed."""
|
||||||
|
model = AnimeMetadataUpdate(key=None)
|
||||||
|
assert model.key is None
|
||||||
|
|
||||||
|
def test_key_omitted_is_allowed(self):
|
||||||
|
"""Test omitting key entirely is allowed."""
|
||||||
|
model = AnimeMetadataUpdate(tmdb_id=1234)
|
||||||
|
assert model.key is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestTmdbIdValidation:
|
||||||
|
"""Tests for tmdb_id validation."""
|
||||||
|
|
||||||
|
def test_valid_tmdb_id(self):
|
||||||
|
"""Test valid positive TMDB ID."""
|
||||||
|
model = AnimeMetadataUpdate(tmdb_id=1429)
|
||||||
|
assert model.tmdb_id == 1429
|
||||||
|
|
||||||
|
def test_tmdb_id_none(self):
|
||||||
|
"""Test None tmdb_id is allowed."""
|
||||||
|
model = AnimeMetadataUpdate(tmdb_id=None)
|
||||||
|
assert model.tmdb_id is None
|
||||||
|
|
||||||
|
def test_tmdb_id_negative_rejected(self):
|
||||||
|
"""Test negative tmdb_id is rejected."""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
AnimeMetadataUpdate(tmdb_id=-1)
|
||||||
|
|
||||||
|
def test_tmdb_id_zero_rejected(self):
|
||||||
|
"""Test zero tmdb_id is rejected."""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
AnimeMetadataUpdate(tmdb_id=0)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTvdbIdValidation:
|
||||||
|
"""Tests for tvdb_id validation."""
|
||||||
|
|
||||||
|
def test_valid_tvdb_id(self):
|
||||||
|
"""Test valid positive TVDB ID."""
|
||||||
|
model = AnimeMetadataUpdate(tvdb_id=267440)
|
||||||
|
assert model.tvdb_id == 267440
|
||||||
|
|
||||||
|
def test_tvdb_id_none(self):
|
||||||
|
"""Test None tvdb_id is allowed."""
|
||||||
|
model = AnimeMetadataUpdate(tvdb_id=None)
|
||||||
|
assert model.tvdb_id is None
|
||||||
|
|
||||||
|
def test_tvdb_id_negative_rejected(self):
|
||||||
|
"""Test negative tvdb_id is rejected."""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
AnimeMetadataUpdate(tvdb_id=-5)
|
||||||
|
|
||||||
|
def test_tvdb_id_zero_rejected(self):
|
||||||
|
"""Test zero tvdb_id is rejected."""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
AnimeMetadataUpdate(tvdb_id=0)
|
||||||
|
|
||||||
|
|
||||||
|
class TestKeyPattern:
|
||||||
|
"""Tests for the KEY_PATTERN regex directly."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("key", [
|
||||||
|
"a",
|
||||||
|
"abc",
|
||||||
|
"attack-on-titan",
|
||||||
|
"86-eighty-six",
|
||||||
|
"a1b2c3",
|
||||||
|
"x",
|
||||||
|
"1",
|
||||||
|
])
|
||||||
|
def test_valid_patterns(self, key):
|
||||||
|
"""Test keys that should match the pattern."""
|
||||||
|
assert KEY_PATTERN.match(key) is not None
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("key", [
|
||||||
|
"-start",
|
||||||
|
"end-",
|
||||||
|
"has space",
|
||||||
|
"UPPER",
|
||||||
|
"special!char",
|
||||||
|
"under_score",
|
||||||
|
"",
|
||||||
|
])
|
||||||
|
def test_invalid_patterns(self, key):
|
||||||
|
"""Test keys that should not match the pattern."""
|
||||||
|
assert KEY_PATTERN.match(key) is None
|
||||||
@@ -816,11 +816,14 @@ class TestPerformNfoRepairScan:
|
|||||||
return_value=mock_repair_service,
|
return_value=mock_repair_service,
|
||||||
), patch(
|
), patch(
|
||||||
"asyncio.create_task"
|
"asyncio.create_task"
|
||||||
) as mock_create_task:
|
) as mock_create_task, patch(
|
||||||
|
"asyncio.gather", new_callable=AsyncMock
|
||||||
|
) as mock_gather:
|
||||||
mock_factory_cls.return_value.create.return_value = MagicMock()
|
mock_factory_cls.return_value.create.return_value = MagicMock()
|
||||||
await perform_nfo_repair_scan(background_loader=AsyncMock())
|
await perform_nfo_repair_scan(background_loader=AsyncMock())
|
||||||
|
|
||||||
mock_create_task.assert_called_once()
|
mock_create_task.assert_called_once()
|
||||||
|
mock_gather.assert_called_once()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_skips_complete_series(self, tmp_path):
|
async def test_skips_complete_series(self, tmp_path):
|
||||||
@@ -876,8 +879,11 @@ class TestPerformNfoRepairScan:
|
|||||||
return_value=mock_repair_service,
|
return_value=mock_repair_service,
|
||||||
), patch(
|
), patch(
|
||||||
"asyncio.create_task"
|
"asyncio.create_task"
|
||||||
) as mock_create_task:
|
) as mock_create_task, patch(
|
||||||
|
"asyncio.gather", new_callable=AsyncMock
|
||||||
|
) as mock_gather:
|
||||||
mock_factory_cls.return_value.create.return_value = MagicMock()
|
mock_factory_cls.return_value.create.return_value = MagicMock()
|
||||||
await perform_nfo_repair_scan(background_loader=None)
|
await perform_nfo_repair_scan(background_loader=None)
|
||||||
|
|
||||||
mock_create_task.assert_called_once()
|
mock_create_task.assert_called_once()
|
||||||
|
mock_gather.assert_called_once()
|
||||||
|
|||||||
218
tests/unit/test_key_resolution_service.py
Normal file
218
tests/unit/test_key_resolution_service.py
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
"""Unit tests for key_resolution_service."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.server.services.key_resolution_service import (
|
||||||
|
_extract_key_from_link,
|
||||||
|
_extract_year_from_folder,
|
||||||
|
_normalize_for_comparison,
|
||||||
|
_strip_year_from_folder,
|
||||||
|
resolve_key_for_folder,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestStripYearFromFolder:
|
||||||
|
"""Tests for _strip_year_from_folder."""
|
||||||
|
|
||||||
|
def test_removes_year_suffix(self):
|
||||||
|
assert _strip_year_from_folder("Rent-A-Girlfriend (2020)") == "Rent-A-Girlfriend"
|
||||||
|
|
||||||
|
def test_removes_year_suffix_with_spaces(self):
|
||||||
|
assert _strip_year_from_folder("Attack on Titan (2013)") == "Attack on Titan"
|
||||||
|
|
||||||
|
def test_no_year_returns_original(self):
|
||||||
|
assert _strip_year_from_folder("Naruto") == "Naruto"
|
||||||
|
|
||||||
|
def test_year_in_middle_not_stripped(self):
|
||||||
|
assert _strip_year_from_folder("2024 Anime (2024)") == "2024 Anime"
|
||||||
|
|
||||||
|
def test_empty_string(self):
|
||||||
|
assert _strip_year_from_folder("") == ""
|
||||||
|
|
||||||
|
def test_only_year(self):
|
||||||
|
assert _strip_year_from_folder("(2020)") == ""
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractYearFromFolder:
|
||||||
|
"""Tests for _extract_year_from_folder."""
|
||||||
|
|
||||||
|
def test_extracts_year(self):
|
||||||
|
assert _extract_year_from_folder("Rent-A-Girlfriend (2020)") == 2020
|
||||||
|
|
||||||
|
def test_no_year_returns_none(self):
|
||||||
|
assert _extract_year_from_folder("Naruto") is None
|
||||||
|
|
||||||
|
def test_year_in_middle_not_extracted(self):
|
||||||
|
# Only trailing year is extracted
|
||||||
|
assert _extract_year_from_folder("2024 Anime") is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractKeyFromLink:
|
||||||
|
"""Tests for _extract_key_from_link."""
|
||||||
|
|
||||||
|
def test_relative_link(self):
|
||||||
|
assert _extract_key_from_link("/anime/stream/rent-a-girlfriend") == "rent-a-girlfriend"
|
||||||
|
|
||||||
|
def test_full_url(self):
|
||||||
|
assert (
|
||||||
|
_extract_key_from_link("https://aniworld.to/anime/stream/attack-on-titan")
|
||||||
|
== "attack-on-titan"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_link_with_trailing_slash(self):
|
||||||
|
assert _extract_key_from_link("/anime/stream/naruto/") == "naruto"
|
||||||
|
|
||||||
|
def test_empty_link(self):
|
||||||
|
assert _extract_key_from_link("") is None
|
||||||
|
|
||||||
|
def test_none_link(self):
|
||||||
|
assert _extract_key_from_link(None) is None
|
||||||
|
|
||||||
|
def test_slug_only(self):
|
||||||
|
assert _extract_key_from_link("one-piece") == "one-piece"
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizeForComparison:
|
||||||
|
"""Tests for _normalize_for_comparison."""
|
||||||
|
|
||||||
|
def test_case_insensitive(self):
|
||||||
|
assert _normalize_for_comparison("Rent-A-Girlfriend") == _normalize_for_comparison(
|
||||||
|
"rent-a-girlfriend"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_strips_whitespace(self):
|
||||||
|
assert _normalize_for_comparison(" Naruto ") == "naruto"
|
||||||
|
|
||||||
|
def test_normalizes_dashes(self):
|
||||||
|
assert _normalize_for_comparison("Rent-A-Girlfriend") == "rent a girlfriend"
|
||||||
|
|
||||||
|
def test_collapses_spaces(self):
|
||||||
|
assert _normalize_for_comparison("Attack on Titan") == "attack on titan"
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveKeyForFolder:
|
||||||
|
"""Tests for resolve_key_for_folder."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_single_exact_match_returns_key(self):
|
||||||
|
"""When provider returns exactly one exact-name match, key is resolved."""
|
||||||
|
search_results = [
|
||||||
|
{"link": "/anime/stream/rent-a-girlfriend", "title": "Rent-A-Girlfriend"},
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.server.services.key_resolution_service._search_provider",
|
||||||
|
return_value=search_results,
|
||||||
|
):
|
||||||
|
key = await resolve_key_for_folder("Rent-A-Girlfriend (2020)")
|
||||||
|
assert key == "rent-a-girlfriend"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_results_returns_none(self):
|
||||||
|
"""When provider returns no results, returns None."""
|
||||||
|
with patch(
|
||||||
|
"src.server.services.key_resolution_service._search_provider",
|
||||||
|
return_value=[],
|
||||||
|
):
|
||||||
|
key = await resolve_key_for_folder("Unknown Anime (2020)")
|
||||||
|
assert key is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_multiple_exact_matches_returns_none(self):
|
||||||
|
"""When multiple results match the same name exactly, returns None."""
|
||||||
|
search_results = [
|
||||||
|
{"link": "/anime/stream/my-anime", "title": "My Anime"},
|
||||||
|
{"link": "/anime/stream/my-anime-2", "title": "My Anime"},
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.server.services.key_resolution_service._search_provider",
|
||||||
|
return_value=search_results,
|
||||||
|
):
|
||||||
|
key = await resolve_key_for_folder("My Anime (2022)")
|
||||||
|
assert key is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_exact_match_returns_none(self):
|
||||||
|
"""When results exist but none match the folder name, returns None."""
|
||||||
|
search_results = [
|
||||||
|
{"link": "/anime/stream/rent-a-girlfriend-2", "title": "Rent-A-Girlfriend 2nd Season"},
|
||||||
|
{"link": "/anime/stream/rent-a-girlfriend-3", "title": "Rent-A-Girlfriend 3rd Season"},
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.server.services.key_resolution_service._search_provider",
|
||||||
|
return_value=search_results,
|
||||||
|
):
|
||||||
|
key = await resolve_key_for_folder("Rent-A-Girlfriend (2020)")
|
||||||
|
assert key is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_case_insensitive_match(self):
|
||||||
|
"""Matching is case-insensitive."""
|
||||||
|
search_results = [
|
||||||
|
{"link": "/anime/stream/naruto", "title": "NARUTO"},
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.server.services.key_resolution_service._search_provider",
|
||||||
|
return_value=search_results,
|
||||||
|
):
|
||||||
|
key = await resolve_key_for_folder("Naruto (2002)")
|
||||||
|
assert key == "naruto"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_provider_error_returns_none(self):
|
||||||
|
"""When provider search raises an exception, returns None gracefully."""
|
||||||
|
with patch(
|
||||||
|
"src.server.services.key_resolution_service._search_provider",
|
||||||
|
side_effect=RuntimeError("Network error"),
|
||||||
|
):
|
||||||
|
key = await resolve_key_for_folder("Some Anime (2020)")
|
||||||
|
assert key is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_result_with_name_field_instead_of_title(self):
|
||||||
|
"""Search results using 'name' field instead of 'title' work."""
|
||||||
|
search_results = [
|
||||||
|
{"link": "/anime/stream/one-piece", "name": "One Piece"},
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.server.services.key_resolution_service._search_provider",
|
||||||
|
return_value=search_results,
|
||||||
|
):
|
||||||
|
key = await resolve_key_for_folder("One Piece (1999)")
|
||||||
|
assert key == "one-piece"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_folder_without_year(self):
|
||||||
|
"""Folders without year suffix still work."""
|
||||||
|
search_results = [
|
||||||
|
{"link": "/anime/stream/naruto", "title": "Naruto"},
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.server.services.key_resolution_service._search_provider",
|
||||||
|
return_value=search_results,
|
||||||
|
):
|
||||||
|
key = await resolve_key_for_folder("Naruto")
|
||||||
|
assert key == "naruto"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_exact_match_among_partial_matches(self):
|
||||||
|
"""Only exact matches count, partial matches are ignored."""
|
||||||
|
search_results = [
|
||||||
|
{"link": "/anime/stream/dororo", "title": "Dororo"},
|
||||||
|
{"link": "/anime/stream/dororo-to-hyakkimaru", "title": "Dororo to Hyakkimaru"},
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.server.services.key_resolution_service._search_provider",
|
||||||
|
return_value=search_results,
|
||||||
|
):
|
||||||
|
key = await resolve_key_for_folder("Dororo (2019)")
|
||||||
|
assert key == "dororo"
|
||||||
Reference in New Issue
Block a user