Compare commits

...

12 Commits

Author SHA1 Message Date
a54c285994 fix(folder_scan): await NFO repair before folder rename
folder_rename_service depends on clean NFO files but repair tasks
were fire-and-forget. Now collect all repair tasks and await them
with asyncio.gather before validate_and_rename_series_folders runs.

Also update tests that mock asyncio.create_task to also mock
asyncio.gather since perform_nfo_repair_scan now awaits tasks.
2026-06-01 21:37:28 +02:00
c58b42dfa5 feat(services): add key resolution for orphaned anime folders
- Add key_resolution_service.py to resolve provider keys for folders without key/data files
- Search anime provider and match folder names (case-insensitive, exact match required)
- Only save to DB if exactly one match found; otherwise skip
- Add comprehensive unit tests (28 tests)
- Integrate into scheduler_service after nfo_repair scan
- Update ARCHITECTURE.md documentation
2026-06-01 20:43:13 +02:00
6dfb24de7e backup 2026-06-01 20:07:58 +02:00
6021cdef28 feat: add anime metadata editing and NFO diagnostics
- Add PUT /anime/{key} endpoint for updating anime key, tmdb_id, tvdb_id
- Add NFO diagnostics and repair endpoints (GET/POST /nfo/diagnostics)
- Add edit modal UI with context menu integration
- Add frontend JS modules for context-menu and edit-modal
- Add comprehensive tests for edit, rename, and NFO repair flows
2026-05-31 18:31:56 +02:00
5517ccbab0 style: reformat folder_rename_service import 2026-05-30 12:20:40 +02:00
94ed013172 Revert "feat: add manual TMDB/TVDB ID entry for failed lookups"
This reverts commit 30858f441c.
2026-05-30 12:17:48 +02:00
76b849fc91 chore: bump version 2026-05-30 12:02:48 +02:00
00b26c8cbc fix: validate generated keys before creating Serie objects
- Add is_valid_key check in SerieScanner._read_data_from_file() to prevent
  passing invalid keys to Serie constructor (caused ValueError)
- Improve error message for key generation failures
- Add warning log before removing duplicate source folders in rename service
2026-05-30 11:42:19 +02:00
a6f2399aca chore: bump version 2026-05-29 19:25:30 +02:00
cf001563b3 refactor: add folder rename configuration and service
Add configurable folder rename patterns via settings with anime_folder_rename_regex and custom_pattern options. Integrate into SerieScanner and SeriesApp for consistent episode organization.
2026-05-29 19:24:09 +02:00
38c12638a4 fix HLS stream warning by disabling native downloader and retrying with ffmpeg
- Set hls_prefer_native: False to skip yt-dlp's native HLS downloader which emits
  'Live HLS streams are not supported' warning
- Add retry logic that catches HLS-related exceptions and retries with
  downloader=ffmpeg and hls_use_mpegts=True
2026-05-29 18:53:47 +02:00
765e43c684 fix(key_utils): drop apostrophes in generate_key_from_folder 2026-05-29 18:20:20 +02:00
32 changed files with 2776 additions and 912 deletions

View File

@@ -1 +1 @@
v1.2.0
v1.3.0

View File

@@ -293,7 +293,7 @@ The FastAPI lifespan function (`src/server/fastapi_app.py`) runs the following s
9. Scheduler service started
+-- Cron-based library rescans configured
+-- 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

View File

@@ -1,6 +1,6 @@
{
"name": "aniworld-web",
"version": "1.2.0",
"version": "1.3.0",
"description": "Aniworld Anime Download Manager - Web Frontend",
"type": "module",
"scripts": {

View File

@@ -169,5 +169,23 @@ class Settings(BaseSettings):
]
return [origin.strip() for origin in raw.split(",") if origin.strip()]
@property
def scan_key_overrides(self) -> dict[str, str]:
"""Return scan key overrides from config.json.
Maps folder names to provider keys for cases where auto-generated
keys from folder names are incorrect.
Returns:
Dict mapping folder names to provider keys.
"""
from src.server.services.config_service import ConfigService
try:
config_service = ConfigService()
config = config_service.load_config()
return config.scan_key_overrides or {}
except Exception:
return {}
settings = Settings()

View File

@@ -24,10 +24,9 @@ from src.config.settings import settings
from src.core.entities.series import Serie
from src.core.exceptions.Exceptions import MatchNotFoundError, NoKeyFoundException
from src.core.providers.base_provider import Loader
from src.core.utils.key_utils import generate_key_from_folder
from src.server.database.connection import get_sync_session
from src.server.database.service import AnimeSeriesService, EpisodeService
from src.core.utils.key_utils import generate_key_from_folder
logger = logging.getLogger(__name__)
error_logger = logging.getLogger("error")
@@ -58,6 +57,11 @@ class SerieScanner:
# With DB lookup fallback:
scanner = SerieScanner("/path/to/anime", loader,
db_lookup=lambda folder: my_db.get_by_folder(folder))
# With scan key overrides:
overrides = {"Folder Name": "correct-provider-key"}
scanner = SerieScanner("/path/to/anime", loader,
scan_key_overrides=overrides)
"""
def __init__(
@@ -65,6 +69,7 @@ class SerieScanner:
basePath: str,
loader: Loader,
db_lookup: Optional[Callable[[str], Optional["Serie"]]] = None,
scan_key_overrides: Optional[dict[str, str]] = None,
) -> None:
"""
Initialize the SerieScanner.
@@ -77,6 +82,10 @@ class SerieScanner:
``key`` file nor a ``data`` file is found in the folder.
This allows the database to supply the series key for
folders that have never had a local key file.
scan_key_overrides: Optional dict mapping folder names to provider
keys. When a folder name is found in this dict, the override
key is used instead of auto-generating from folder name.
Format: {"Folder Name": "actual-provider-key"}
Raises:
ValueError: If basePath is invalid or doesn't exist
@@ -96,6 +105,7 @@ class SerieScanner:
self.keyDict: dict[str, Serie] = {}
self.loader: Loader = loader
self._db_lookup: Optional[Callable[[str], Optional[Serie]]] = db_lookup
self._scan_key_overrides: Optional[dict[str, str]] = scan_key_overrides
self._current_operation_id: Optional[str] = None
self.events = Events()
@@ -619,7 +629,8 @@ class SerieScanner:
2. If found, return cached Serie object
3. If not in DB, fall back to provider search via _db_lookup callback
4. If still not found, try reading 'data' file for legacy deployments
5. Generate key from folder name as last resort
5. Check user-provided key overrides in scan_key_overrides
6. Generate key from folder name as last resort
Args:
folder_name: Filesystem folder name
@@ -692,7 +703,25 @@ class SerieScanner:
)
return Serie.load_from_file(serie_file)
# Step 4: Generate key from folder name as last resort
# Step 4: Check for user-provided key overrides before generating
if self._scan_key_overrides and folder_name in self._scan_key_overrides:
override_key = self._scan_key_overrides[folder_name]
year_from_folder = self._extract_year_from_folder_name(folder_name)
logger.info(
"Using scan key override for folder '%s' -> key='%s'",
folder_name,
override_key
)
return Serie(
key=override_key,
name="", # Name will be fetched from provider if needed
site="aniworld.to",
folder=folder_name,
episodeDict=dict(),
year=year_from_folder
)
# Step 5: Generate key from folder name as last resort
# This handles edge cases like non-Latin characters or special symbols
try:
generated_key = generate_key_from_folder(folder_name)

View File

@@ -166,7 +166,10 @@ class SeriesApp:
self.loaders = Loaders()
self.loader = self.loaders.GetLoader(key="aniworld.to")
self.serie_scanner = SerieScanner(
directory_to_search, self.loader, db_lookup=db_lookup
directory_to_search,
self.loader,
db_lookup=db_lookup,
scan_key_overrides=settings.scan_key_overrides,
)
# Skip automatic loading from data files - series will be loaded
# from database by the service layer during application setup

View File

@@ -550,8 +550,10 @@ class AniworldLoader(Loader):
'nocheckcertificate': True,
'logger': logger,
'progress_hooks': [events_progress_hook],
'downloader': 'ffmpeg',
'hls_use_mpegts': True,
# yt-dlp defaults to native HLS downloader which warns about
# "Live HLS streams are not supported" - disable to go
# straight to ffmpeg, avoiding the warning
'hls_prefer_native': False,
}
if header:
@@ -597,6 +599,40 @@ class AniworldLoader(Loader):
_cleanup_temp_file(temp_path)
continue
except Exception as exc:
# Check if this is an HLS-related failure that might succeed
# with additional ffmpeg options
exc_str = str(exc).lower()
is_hls_related = (
'hls' in exc_str or
'live' in exc_str or
'native downloader' in exc_str
)
if is_hls_related and 'ffmpeg' not in str(ydl_opts.get('downloader', '')):
logger.info(
"HLS stream detected, retrying with ffmpeg options: %s",
output_file
)
# Retry with ffmpeg explicitly set
retry_opts = ydl_opts.copy()
retry_opts['downloader'] = 'ffmpeg'
retry_opts['hls_use_mpegts'] = True
try:
with YoutubeDL(retry_opts) as ydl:
info = ydl.extract_info(link, download=True)
if os.path.exists(temp_path):
shutil.copyfile(temp_path, output_path)
os.remove(temp_path)
logger.info(
"Download completed successfully (retry): %s",
output_file
)
self.clear_cache()
return True
except Exception:
_cleanup_temp_file(temp_path)
# Continue to next provider if retry also fails
continue
logger.error(
"YoutubeDL download failed with provider %s: %s: %s",
provider_name, type(exc).__name__, exc

View File

@@ -10,7 +10,6 @@ import unicodedata
import uuid
from typing import Optional
# Valid key pattern: alphanumeric, hyphens, underscores
# Must be at least 1 char, URL-safe
VALID_KEY_PATTERN = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9_-]*$')
@@ -102,6 +101,11 @@ def generate_key_from_folder(folder_name: str) -> str:
parts.append(char)
elif char.isspace():
parts.append(' ')
# Handle apostrophes - treat as part of word (remove, don't replace with space)
# This normalizes e.g., "Hell's" -> "Hells"
# Includes: ' (0x27), ' (0x2018), ' (0x2019), ' (0x02BC), ` (0x0060)
elif char in ("'", "'", "'", "'", "`", """, """):
pass # Skip - drop the apostrophe
else:
parts.append(' ')

View File

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

View File

@@ -16,6 +16,11 @@ from src.core.entities.series import Serie
from src.core.SeriesApp import SeriesApp
from src.core.services.nfo_factory import get_nfo_factory
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.server.models.nfo import (
MediaDownloadRequest,
@@ -27,8 +32,10 @@ from src.server.models.nfo import (
NFOContentResponse,
NFOCreateRequest,
NFOCreateResponse,
NfoDiagnosticsResponse,
NFOMissingResponse,
NFOMissingSeries,
NfoRepairResponse,
)
from src.server.utils.dependencies import get_series_app, require_auth
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,
detail=f"Failed to download media: {str(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

View File

@@ -83,6 +83,30 @@ class AnimeSeriesResponse(BaseModel):
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):
"""Request payload for searching series."""

View File

@@ -199,6 +199,12 @@ class AppConfig(BaseModel):
logging: LoggingConfig = Field(default_factory=LoggingConfig)
backup: BackupConfig = Field(default_factory=BackupConfig)
nfo: NFOConfig = Field(default_factory=NFOConfig)
scan_key_overrides: Dict[str, str] = Field(
default_factory=dict,
description="Map of folder names to provider keys for scan overrides. "
"Used when auto-generated keys from folder names are incorrect. "
"Format: {\"Folder Name\": \"actual-provider-key\"}"
)
other: Dict[str, object] = Field(
default_factory=dict, description="Arbitrary other settings"
)
@@ -237,6 +243,7 @@ class ConfigUpdate(BaseModel):
logging: Optional[LoggingConfig] = None
backup: Optional[BackupConfig] = None
nfo: Optional[NFOConfig] = None
scan_key_overrides: Optional[Dict[str, str]] = None
other: Optional[Dict[str, object]] = None
def apply_to(self, current: AppConfig) -> AppConfig:
@@ -253,6 +260,8 @@ class ConfigUpdate(BaseModel):
data["backup"] = self.backup.model_dump()
if self.nfo is not None:
data["nfo"] = self.nfo.model_dump()
if self.scan_key_overrides is not None:
data["scan_key_overrides"] = self.scan_key_overrides
if self.other is not None:
merged = dict(current.other or {})
merged.update(self.other)

View File

@@ -355,3 +355,29 @@ class NFOMissingResponse(BaseModel):
...,
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"
)

View File

@@ -596,7 +596,55 @@ async def validate_and_rename_series_folders(dry_run: bool = False) -> Dict[str,
current_name,
expected_name,
)
stats["errors"] += 1
# Target folder exists — remove source folder and delete its DB record
# (target folder's DB record survives, source folder's record must be removed
# to avoid orphaning episodes/downloads)
try:
import shutil
logger.warning(
"Removing source duplicate folder '%s' — target '%s' already exists",
current_name,
expected_name,
)
shutil.rmtree(series_dir)
logger.info(
"Removed source folder '%s' — series already exists at target",
current_name,
)
# Delete source DB record (cascades to episodes and download items)
async with get_db_session() as db:
source_series = await AnimeSeriesService.get_by_key(db, current_name)
if source_series is None:
# Fallback: find by folder name
all_series = await AnimeSeriesService.get_all(db)
for s in all_series:
if s.folder == current_name:
source_series = s
break
if source_series is not None:
await AnimeSeriesService.delete(db, source_series.id)
logger.info(
"Deleted source DB record for '%s' (id=%s) — target folder '%s' retains DB record",
current_name,
source_series.id,
expected_name,
)
else:
logger.info(
"No DB record found for source folder '%s' — folder removed only",
current_name,
)
stats["renamed"] += 1
except OSError as exc:
logger.error(
"Failed to remove source folder '%s': %s",
current_name,
exc,
)
stats["errors"] += 1
continue
# Check path length limits

View File

@@ -129,6 +129,7 @@ async def perform_nfo_repair_scan(background_loader=None) -> None:
queued = 0
total = 0
missing_nfo_count = 0
repair_tasks: list[asyncio.Task] = []
for series_dir in sorted(anime_dir.iterdir()):
if not series_dir.is_dir():
continue
@@ -137,19 +138,31 @@ async def perform_nfo_repair_scan(background_loader=None) -> None:
if not nfo_path.exists():
# Create minimal NFO for series without one
missing_nfo_count += 1
asyncio.create_task(
_create_missing_nfo(series_dir, series_name),
name=f"nfo_create:{series_name}",
repair_tasks.append(
asyncio.create_task(
_create_missing_nfo(series_dir, series_name),
name=f"nfo_create:{series_name}",
)
)
continue
total += 1
if nfo_needs_repair(nfo_path):
queued += 1
asyncio.create_task(
_repair_one_series(series_dir, series_name),
name=f"nfo_repair:{series_name}",
repair_tasks.append(
asyncio.create_task(
_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(
"NFO repair scan complete: %d of %d series queued for repair, %d missing NFOs queued for creation",
queued,
@@ -182,10 +195,10 @@ class FolderScanService:
if not self._prerequisites_met():
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")
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.
logger.info("Starting folder rename validation")

View 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,
)

View File

@@ -316,11 +316,9 @@ class SchedulerService:
return
try:
from src.server.database.connection import ( # noqa: PLC0415
get_db_session,
)
from src.server.database.system_settings_service import ( # noqa: PLC0415
SystemSettingsService,
from src.server.database.connection import get_db_session # noqa: PLC0415
from src.server.database.system_settings_service import (
SystemSettingsService, # noqa: PLC0415
)
async with get_db_session() as db:
@@ -367,8 +365,8 @@ class SchedulerService:
async def _broadcast(self, event_type: str, data: dict) -> None:
"""Broadcast a WebSocket event to all connected clients."""
try:
from src.server.services.websocket_service import ( # noqa: PLC0415
get_websocket_service,
from src.server.services.websocket_service import (
get_websocket_service, # noqa: PLC0415
)
ws_service = get_websocket_service()
@@ -503,8 +501,8 @@ class SchedulerService:
if self._config and self._config.folder_scan_enabled:
logger.info("Folder scan is enabled — starting")
try:
from src.server.services.folder_scan_service import ( # noqa: PLC0415
FolderScanService,
from src.server.services.folder_scan_service import (
FolderScanService, # noqa: PLC0415
)
folder_scan_service = FolderScanService()
@@ -519,6 +517,26 @@ class SchedulerService:
await self._broadcast(
"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:
logger.debug("Folder scan is disabled — skipping")

View File

@@ -268,3 +268,205 @@
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;
}

View File

@@ -442,23 +442,6 @@ class AniWorldApp {
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
document.getElementById('scheduled-rescan-enabled').addEventListener('change', () => {
this.toggleSchedulerTimeInput();
@@ -1564,72 +1547,6 @@ class AniWorldApp {
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() {
try {
const response = await this.makeAuthenticatedRequest('/api/scheduler/config');
@@ -2427,336 +2344,4 @@ document.addEventListener('DOMContentLoaded', () => {
});
// Global functions for inline event handlers
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();
}
});
}
});
window.app = null;

View File

@@ -45,6 +45,7 @@ AniWorld.IndexApp = (function() {
AniWorld.Search.init();
AniWorld.ScanManager.init();
AniWorld.ConfigManager.init();
AniWorld.ContextMenu.init();
// Bind global events
bindGlobalEvents();

View 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
};
})();

View 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
};
})();

View File

@@ -40,31 +40,6 @@ AniWorld.SeriesManager = (function() {
if (sortBtn) {
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 hasNfo = serie.has_nfo || false;
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
if (serie.key === 'so-im-a-spider-so-what') {
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' : '') + ' ' +
(hasMissingEpisodes ? 'has-missing' : 'complete') + ' ' +
(isLoading ? 'loading' : '') + '" ' +
@@ -400,12 +368,9 @@ AniWorld.SeriesManager = (function() {
'<div class="series-folder">' + AniWorld.UI.escapeHtml(serie.folder) + '</div>' +
'</div>' +
'<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>') +
(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>') +
editMetadataBtn +
editKeyBtn +
'</div>' +
'</div>' +
'<div class="series-stats">' +
@@ -427,6 +392,22 @@ AniWorld.SeriesManager = (function() {
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
* @returns {Array} Filtered series data array
@@ -578,6 +559,7 @@ AniWorld.SeriesManager = (function() {
getFilteredSeriesData: getFilteredSeriesData,
findByKey: findByKey,
updateSeriesLoadingStatus: updateSeriesLoadingStatus,
updateSingleSeries: updateSingleSeries
updateSingleSeries: updateSingleSeries,
updateSeriesKey: updateSeriesKey
};
})();

View File

@@ -640,83 +640,75 @@
</div>
</div>
<!-- Edit Key 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 -->
<!-- Edit Metadata Modal -->
<div id="edit-metadata-modal" class="modal hidden">
<div class="modal-overlay"></div>
<div class="modal-content">
<div class="modal-content edit-modal-content">
<div class="modal-header">
<h3 data-text="edit-metadata-title">Edit Metadata IDs</h3>
<button id="close-edit-metadata" class="btn btn-icon">
<h3>Edit Metadata</h3>
<button id="btn-cancel-metadata" class="btn btn-icon">
<i class="fas fa-times"></i>
</button>
</div>
<div class="modal-body">
<div class="config-item">
<label data-text="series-name">Series:</label>
<span id="edit-metadata-series-name" class="config-value"></span>
</div>
<div class="config-item">
<label for="edit-metadata-tmdb" data-text="tmdb-id">TMDB ID:</label>
<input type="number" id="edit-metadata-tmdb" class="input-field"
placeholder="e.g., 12345" min="1" step="1">
<small class="config-hint" data-text="tmdb-id-hint">
Leave blank to clear. Find IDs at themoviedb.org
</small>
</div>
<div class="config-item">
<label for="edit-metadata-tvdb" data-text="tvdb-id">TVDB ID:</label>
<input type="number" id="edit-metadata-tvdb" class="input-field"
placeholder="e.g., 67890" min="1" step="1">
<small class="config-hint" data-text="tvdb-id-hint">
Leave blank to clear. Find IDs at thetvdb.com
</small>
</div>
<div id="edit-metadata-error" class="config-error hidden"></div>
<form id="edit-metadata-form" onsubmit="return false;">
<!-- Identity Section -->
<div class="edit-section">
<h4><i class="fa-solid fa-key"></i> Identity</h4>
<div class="form-group">
<label for="edit-key">Series Key</label>
<input type="text" id="edit-key" class="input-field"
placeholder="e.g. attack-on-titan"
pattern="[a-z0-9][a-z0-9-]*[a-z0-9]">
<span class="field-error" style="display:none;"></span>
</div>
<div id="key-change-warning" class="key-warning" style="display:none;">
<i class="fa-solid fa-triangle-exclamation"></i>
Changing the key will update the primary identifier. This may affect provider linkage.
</div>
</div>
<!-- External IDs Section -->
<div class="edit-section">
<h4><i class="fa-solid fa-database"></i> External IDs</h4>
<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 class="modal-footer">
<button id="save-edit-metadata" class="btn btn-primary">
<i class="fas fa-save"></i>
<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 type="button" id="btn-save-metadata" class="btn btn-primary">
<i class="fa-solid fa-floppy-disk"></i> Save
</button>
</div>
</div>
@@ -747,6 +739,8 @@
<script src="/static/js/user_preferences.js?v={{ static_v }}"></script>
<!-- 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/selection-manager.js?v={{ static_v }}"></script>
<script src="/static/js/index/search.js?v={{ static_v }}"></script>

View 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

View 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"]

View 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

View 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

View File

@@ -455,7 +455,8 @@ class TestValidateAndRenameSeriesFolders:
assert series_dir.is_dir()
@pytest.mark.asyncio
async def test_errors_when_target_exists(self, tmp_path: Path) -> None:
async def test_duplicate_target_folder_source_removed_and_db_deleted(self, tmp_path: Path) -> None:
"""When target folder exists, source folder should be removed and its DB record deleted."""
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
series_dir = anime_dir / "Attack on Titan"
@@ -464,7 +465,13 @@ class TestValidateAndRenameSeriesFolders:
"<tvshow><title>Attack on Titan</title><year>2013</year></tvshow>"
)
# Pre-create the target folder to simulate a duplicate
(anime_dir / "Attack on Titan (2013)").mkdir()
target_dir = anime_dir / "Attack on Titan (2013)"
target_dir.mkdir()
mock_db = AsyncMock()
mock_session = AsyncMock()
mock_db.__aenter__.return_value = mock_session
mock_db.__aexit__.return_value = None
with patch(
"src.server.services.folder_rename_service.settings.anime_directory",
@@ -472,14 +479,28 @@ class TestValidateAndRenameSeriesFolders:
), patch(
"src.server.services.folder_rename_service._is_series_being_downloaded",
return_value=False,
), patch(
"src.server.services.folder_rename_service.get_db_session",
return_value=mock_db,
), patch(
"src.server.services.folder_rename_service.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
return_value=None,
), patch(
"src.server.services.folder_rename_service.AnimeSeriesService.get_all",
new_callable=AsyncMock,
return_value=[],
):
stats = await validate_and_rename_series_folders()
# Source folder removed, target survives
assert not series_dir.exists()
assert target_dir.is_dir()
# Duplicate resolved: counts as renamed (source removed, target kept)
assert stats["scanned"] == 1
assert stats["renamed"] == 0
assert stats["renamed"] == 1
assert stats["skipped"] == 0
assert stats["errors"] == 1
assert series_dir.is_dir()
assert stats["errors"] == 0
@pytest.mark.asyncio
async def test_counts_multiple_folders(self, tmp_path: Path) -> None:

View File

@@ -816,11 +816,14 @@ class TestPerformNfoRepairScan:
return_value=mock_repair_service,
), patch(
"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()
await perform_nfo_repair_scan(background_loader=AsyncMock())
mock_create_task.assert_called_once()
mock_gather.assert_called_once()
@pytest.mark.asyncio
async def test_skips_complete_series(self, tmp_path):
@@ -876,8 +879,11 @@ class TestPerformNfoRepairScan:
return_value=mock_repair_service,
), patch(
"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()
await perform_nfo_repair_scan(background_loader=None)
mock_create_task.assert_called_once()
mock_gather.assert_called_once()

View 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"

View File

@@ -547,11 +547,31 @@ class TestReadDataFromFile:
scanner = SerieScanner(tmpdir, mock_loader)
result = scanner._SerieScanner__read_data_from_file("Empty")
# Step 4 generates key from folder name when no files exist
# Step 5 (was Step 4) generates key from folder name when no files exist
assert result is not None
assert isinstance(result, Serie)
assert result.key == "empty"
def test_scan_key_override_used_instead_of_generated(self, mock_loader):
"""Should use override key when folder name matches override dict."""
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
anime_folder = os.path.join(tmpdir, "Anyway, I'm Falling in Love with You (2025)")
os.makedirs(anime_folder)
overrides = {
"Anyway, I'm Falling in Love with You (2025)": "anyway-im-falling-in-love-with-you-2025"
}
scanner = SerieScanner(tmpdir, mock_loader, scan_key_overrides=overrides)
result = scanner._SerieScanner__read_data_from_file(
"Anyway, I'm Falling in Love with You (2025)"
)
# Override key should be used instead of generated key
assert result is not None
assert isinstance(result, Serie)
assert result.key == "anyway-im-falling-in-love-with-you-2025"
class TestReinit:
"""Test reinit method."""