Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a77bb371df | |||
| 420d10bb34 | |||
| e29918488c | |||
| 9c3f03d610 | |||
| 9d64241230 | |||
| 49cd84f3e5 | |||
| e46759347e | |||
| 75f743e6cc | |||
| 4dc5ffa19e | |||
| 1649a22418 | |||
| 246752e2fc | |||
| 84b24ed79e | |||
| bf3954587a | |||
| ed8f5cae10 | |||
| a54c285994 | |||
| c58b42dfa5 | |||
| 6dfb24de7e | |||
| 6021cdef28 | |||
| 5517ccbab0 | |||
| 94ed013172 | |||
| 76b849fc91 | |||
| 00b26c8cbc | |||
| a6f2399aca | |||
| cf001563b3 | |||
| 38c12638a4 | |||
| 765e43c684 |
@@ -17,6 +17,9 @@ __pycache__/
|
||||
# Docker files (not needed inside the image)
|
||||
Docker/
|
||||
|
||||
# Exception: VERSION is needed by Dockerfile.app
|
||||
!Docker/VERSION
|
||||
|
||||
# Test and dev files
|
||||
tests/
|
||||
Temp/
|
||||
|
||||
@@ -20,6 +20,7 @@ COPY src/ ./src/
|
||||
COPY run_server.py .
|
||||
COPY pyproject.toml .
|
||||
COPY data/config.json ./data/config.json
|
||||
COPY Docker/VERSION ./Docker/VERSION
|
||||
|
||||
# Create runtime directories
|
||||
RUN mkdir -p /app/data/config_backups /app/logs
|
||||
|
||||
@@ -1 +1 @@
|
||||
v1.2.0
|
||||
v1.3.5
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aniworld-web",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.5",
|
||||
"description": "Aniworld Anime Download Manager - Web Frontend",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -444,6 +454,24 @@ class SerieScanner:
|
||||
str(e)
|
||||
)
|
||||
|
||||
# Fetch series name from provider if not already set
|
||||
if not serie.name:
|
||||
try:
|
||||
fetched_name = self.loader.get_title(serie.key)
|
||||
if fetched_name:
|
||||
serie.name = fetched_name
|
||||
logger.info(
|
||||
"Fetched name from provider: %s (name=%s)",
|
||||
serie.key,
|
||||
serie.name
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not fetch name for %s: %s",
|
||||
serie.key,
|
||||
str(e)
|
||||
)
|
||||
|
||||
# Delegate the provider to compare local files with
|
||||
# remote metadata, yielding missing episodes per
|
||||
# season. Results are saved back to disk so that both
|
||||
@@ -619,7 +647,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 +721,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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(' ')
|
||||
|
||||
|
||||
@@ -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,22 @@ 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.scheduler.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 +235,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 +433,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 +1187,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",
|
||||
}
|
||||
|
||||
|
||||
@@ -165,7 +165,7 @@ async def setup_auth(req: SetupRequest):
|
||||
|
||||
# Start scheduler if anime_directory is now set
|
||||
try:
|
||||
from src.server.services.scheduler_service import (
|
||||
from src.server.services.scheduler.scheduler_service import (
|
||||
get_scheduler_service,
|
||||
)
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ async def update_config(
|
||||
# Start scheduler if anime_directory was just configured
|
||||
if anime_dir_changed:
|
||||
try:
|
||||
from src.server.services.scheduler_service import (
|
||||
from src.server.services.scheduler.scheduler_service import (
|
||||
get_scheduler_service,
|
||||
)
|
||||
|
||||
|
||||
@@ -17,12 +17,15 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/health", tags=["health"])
|
||||
|
||||
|
||||
from src.server.utils.version import APP_VERSION
|
||||
|
||||
|
||||
class HealthStatus(BaseModel):
|
||||
"""Basic health status response."""
|
||||
|
||||
status: str
|
||||
timestamp: str
|
||||
version: str = "1.0.1"
|
||||
version: str = APP_VERSION
|
||||
service: str = "aniworld-api"
|
||||
series_app_initialized: bool = False
|
||||
anime_directory_configured: bool = False
|
||||
@@ -63,7 +66,7 @@ class DetailedHealthStatus(BaseModel):
|
||||
|
||||
status: str
|
||||
timestamp: str
|
||||
version: str = "1.0.1"
|
||||
version: str = APP_VERSION
|
||||
dependencies: DependencyHealth
|
||||
startup_time: datetime
|
||||
|
||||
@@ -192,7 +195,9 @@ async def basic_health_check(request: Request) -> HealthStatus:
|
||||
# Get scheduler status for health monitoring
|
||||
scheduler_status: dict = {}
|
||||
try:
|
||||
from src.server.services.scheduler_service import get_scheduler_service
|
||||
from src.server.services.scheduler.scheduler_service import (
|
||||
get_scheduler_service,
|
||||
)
|
||||
scheduler_status = get_scheduler_service().get_status()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,7 +10,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from src.server.models.config import SchedulerConfig
|
||||
from src.server.services.config_service import ConfigServiceError, get_config_service
|
||||
from src.server.services.scheduler_service import get_scheduler_service
|
||||
from src.server.services.scheduler.scheduler_service import get_scheduler_service
|
||||
from src.server.utils.dependencies import require_auth
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -119,6 +119,11 @@ async def initialize_database(
|
||||
result["tables_created"] = tables
|
||||
logger.info("Created %s tables", len(tables))
|
||||
|
||||
# Migrate schema if needed (add missing columns to existing tables)
|
||||
migrations = await migrate_schema_if_needed(engine)
|
||||
if migrations:
|
||||
logger.info("Applied %s schema migrations", len(migrations))
|
||||
|
||||
# Validate schema if requested
|
||||
if validate_schema:
|
||||
validation = await validate_database_schema(engine)
|
||||
@@ -305,6 +310,66 @@ async def validate_database_schema(
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Schema Migration
|
||||
# =============================================================================
|
||||
|
||||
|
||||
async def migrate_schema_if_needed(
|
||||
engine: Optional[AsyncEngine] = None
|
||||
) -> List[str]:
|
||||
"""Migrate database schema to current version if needed.
|
||||
|
||||
Handles adding missing columns to existing tables for backward
|
||||
compatibility with older database schemas.
|
||||
|
||||
Args:
|
||||
engine: Optional database engine (uses default if not provided)
|
||||
|
||||
Returns:
|
||||
List of migration operations performed
|
||||
"""
|
||||
if engine is None:
|
||||
engine = get_engine()
|
||||
|
||||
migrations_applied = []
|
||||
|
||||
try:
|
||||
async with engine.connect() as conn:
|
||||
# Get existing columns in system_settings table
|
||||
existing_columns = await conn.run_sync(
|
||||
lambda sync_conn: [
|
||||
col["name"]
|
||||
for col in inspect(sync_conn).get_columns("system_settings")
|
||||
]
|
||||
)
|
||||
|
||||
# Migration: Add legacy_key_cleanup_completed column if missing
|
||||
if "legacy_key_cleanup_completed" not in existing_columns:
|
||||
logger.info(
|
||||
"Migrating system_settings table: "
|
||||
"adding legacy_key_cleanup_completed column"
|
||||
)
|
||||
await conn.execute(
|
||||
text("""
|
||||
ALTER TABLE system_settings
|
||||
ADD COLUMN legacy_key_cleanup_completed BOOLEAN
|
||||
NOT NULL DEFAULT 0
|
||||
""")
|
||||
)
|
||||
migrations_applied.append("added legacy_key_cleanup_completed")
|
||||
logger.info(
|
||||
"Migration complete: added legacy_key_cleanup_completed column"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Schema migration failed: %s", e)
|
||||
# Don't raise - migration failures shouldn't block startup
|
||||
# The missing column will be handled gracefully by the application
|
||||
|
||||
return migrations_applied
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Schema Version Management
|
||||
# =============================================================================
|
||||
|
||||
@@ -214,6 +214,7 @@ async def lifespan(_application: FastAPI):
|
||||
"""
|
||||
# Setup logging first with INFO level
|
||||
logger = setup_logging(log_level="INFO")
|
||||
logger.info("Starting FastAPI application v%s", APP_VERSION)
|
||||
|
||||
# Track successful initialization steps
|
||||
initialized = {
|
||||
@@ -410,7 +411,7 @@ async def lifespan(_application: FastAPI):
|
||||
# anime_directory may be configured there even if the env var is empty.
|
||||
try:
|
||||
logger.info("Initializing scheduler service...")
|
||||
from src.server.services.scheduler_service import (
|
||||
from src.server.services.scheduler.scheduler_service import (
|
||||
get_scheduler_service,
|
||||
)
|
||||
scheduler_service = get_scheduler_service()
|
||||
@@ -497,7 +498,9 @@ async def lifespan(_application: FastAPI):
|
||||
# 1. Stop scheduler service (only if initialized)
|
||||
if initialized['scheduler']:
|
||||
try:
|
||||
from src.server.services.scheduler_service import get_scheduler_service
|
||||
from src.server.services.scheduler.scheduler_service import (
|
||||
get_scheduler_service,
|
||||
)
|
||||
scheduler_service = get_scheduler_service()
|
||||
logger.info("Stopping scheduler service...")
|
||||
await asyncio.wait_for(
|
||||
@@ -542,8 +545,8 @@ async def lifespan(_application: FastAPI):
|
||||
|
||||
# 4. Shutdown download service and persist active downloads
|
||||
try:
|
||||
from src.server.services.download_service import ( # noqa: E501
|
||||
_download_service_instance,
|
||||
from src.server.services.download_service import (
|
||||
_download_service_instance, # noqa: E501
|
||||
)
|
||||
if _download_service_instance is not None:
|
||||
logger.info("Stopping download service...")
|
||||
@@ -600,11 +603,13 @@ async def lifespan(_application: FastAPI):
|
||||
raise startup_error
|
||||
|
||||
|
||||
from src.server.utils.version import APP_VERSION
|
||||
|
||||
# Initialize FastAPI app with lifespan
|
||||
app = FastAPI(
|
||||
title="Aniworld Download Manager",
|
||||
description="Modern web interface for Aniworld anime download management",
|
||||
version="1.0.1",
|
||||
version=APP_VERSION,
|
||||
docs_url="/api/docs",
|
||||
redoc_url="/api/redoc",
|
||||
lifespan=lifespan
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
291
src/server/services/rescan_service.py
Normal file
291
src/server/services/rescan_service.py
Normal file
@@ -0,0 +1,291 @@
|
||||
"""Rescan service — orchestrates library rescans.
|
||||
|
||||
This service handles the actual scan/rescan logic:
|
||||
|
||||
- Library rescan via anime_service
|
||||
- Auto-download of missing episodes (if enabled)
|
||||
- Folder maintenance scan (if enabled)
|
||||
- Orphaned folder key resolution
|
||||
|
||||
SchedulerService only calls RescanService.execute() — it does not
|
||||
know about the internal steps.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from src.server.models.config import SchedulerConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_AUTO_DOWNLOAD_COOLDOWN_SECONDS = 300 # 5 minutes
|
||||
|
||||
|
||||
class RescanService:
|
||||
"""Orchestrates all rescan-related operations.
|
||||
|
||||
Encapsulates the full post-rescan workflow so SchedulerService
|
||||
only needs to call a single execute() method.
|
||||
"""
|
||||
|
||||
def __init__(self, config: Optional[SchedulerConfig] = None) -> None:
|
||||
"""Initialize the rescan service.
|
||||
|
||||
Args:
|
||||
config: Optional scheduler config. If None, operations that depend
|
||||
on config flags (auto_download, folder_scan) will be skipped.
|
||||
"""
|
||||
self._config = config
|
||||
self._last_scan_time: Optional[datetime] = None
|
||||
self._last_auto_download_time: Optional[datetime] = None
|
||||
|
||||
@property
|
||||
def last_scan_time(self) -> Optional[datetime]:
|
||||
return self._last_scan_time
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public entry point
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def execute(self) -> dict:
|
||||
"""Execute the full rescan workflow.
|
||||
|
||||
Runs in order:
|
||||
1. anime_service.rescan()
|
||||
2. auto-download (if enabled)
|
||||
3. folder scan (if enabled)
|
||||
4. key resolution scan (always, if anime_directory configured)
|
||||
|
||||
Returns:
|
||||
Dict with duration and counts for each step.
|
||||
"""
|
||||
from src.server.services.websocket_service import get_websocket_service
|
||||
|
||||
scan_start = datetime.now(timezone.utc)
|
||||
results = {
|
||||
"started_at": scan_start.isoformat(),
|
||||
"duration_seconds": 0.0,
|
||||
"rescan_completed": False,
|
||||
"auto_download_queued": 0,
|
||||
"folder_scan_completed": False,
|
||||
"key_resolution": {"resolved": 0, "skipped": 0, "errors": 0},
|
||||
}
|
||||
|
||||
await self._broadcast("scheduled_rescan_started", {"timestamp": scan_start.isoformat()})
|
||||
|
||||
try:
|
||||
# 1. Main library rescan
|
||||
await self._run_rescan()
|
||||
results["rescan_completed"] = True
|
||||
|
||||
# 2. Auto-download
|
||||
if self._config and self._config.auto_download_after_rescan:
|
||||
try:
|
||||
queued = await self._run_auto_download()
|
||||
results["auto_download_queued"] = queued
|
||||
await self._broadcast("auto_download_started", {"queued_count": queued})
|
||||
except Exception as exc:
|
||||
logger.error("Auto-download failed: %s", exc, exc_info=True)
|
||||
await self._broadcast("auto_download_error", {"error": str(exc)})
|
||||
|
||||
# 3. Folder scan
|
||||
if self._config and self._config.folder_scan_enabled:
|
||||
try:
|
||||
await self._run_folder_scan()
|
||||
results["folder_scan_completed"] = True
|
||||
except Exception as exc:
|
||||
logger.error("Folder scan failed: %s", exc, exc_info=True)
|
||||
await self._broadcast("folder_scan_error", {"error": str(exc)})
|
||||
|
||||
# 4. Key resolution scan
|
||||
try:
|
||||
key_stats = await self._run_key_resolution()
|
||||
results["key_resolution"] = key_stats
|
||||
except Exception as exc:
|
||||
logger.error("Key resolution scan failed: %s", exc, exc_info=True)
|
||||
|
||||
self._last_scan_time = datetime.now(timezone.utc)
|
||||
results["duration_seconds"] = (self._last_scan_time - scan_start).total_seconds()
|
||||
|
||||
await self._broadcast(
|
||||
"scheduled_rescan_completed",
|
||||
{
|
||||
"timestamp": self._last_scan_time.isoformat(),
|
||||
"duration_seconds": results["duration_seconds"],
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Scheduled library rescan completed: duration=%.2fs",
|
||||
results["duration_seconds"],
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("Scheduled rescan failed: %s", exc, exc_info=True)
|
||||
await self._broadcast(
|
||||
"scheduled_rescan_error",
|
||||
{"error": str(exc), "timestamp": datetime.now(timezone.utc).isoformat()},
|
||||
)
|
||||
raise
|
||||
|
||||
return results
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step 1: Library rescan
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _run_rescan(self) -> None:
|
||||
"""Run the anime service rescan."""
|
||||
from src.server.utils.dependencies import get_anime_service
|
||||
|
||||
anime_service = get_anime_service()
|
||||
logger.info("Anime service obtained, calling anime_service.rescan()...")
|
||||
await anime_service.rescan()
|
||||
logger.info("anime_service.rescan() completed")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step 2: Auto-download
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _run_auto_download(self) -> int:
|
||||
"""Queue and start downloads for all series with missing episodes.
|
||||
|
||||
Returns:
|
||||
Number of episodes queued.
|
||||
"""
|
||||
from src.server.models.download import EpisodeIdentifier
|
||||
from src.server.utils.dependencies import (
|
||||
get_anime_service,
|
||||
get_download_service,
|
||||
)
|
||||
|
||||
# Cooldown check to prevent rapid re-triggers
|
||||
now = datetime.now(timezone.utc)
|
||||
if self._last_auto_download_time is not None:
|
||||
elapsed = now - self._last_auto_download_time
|
||||
if elapsed < timedelta(seconds=_AUTO_DOWNLOAD_COOLDOWN_SECONDS):
|
||||
logger.debug(
|
||||
"Auto-download skipped: cooldown active (elapsed=%.1fs cooldown=%ds)",
|
||||
elapsed.total_seconds(),
|
||||
_AUTO_DOWNLOAD_COOLDOWN_SECONDS,
|
||||
)
|
||||
return 0
|
||||
|
||||
anime_service = get_anime_service()
|
||||
download_service = get_download_service()
|
||||
|
||||
series_list = anime_service._cached_list_missing()
|
||||
queued_count = 0
|
||||
|
||||
for series in series_list:
|
||||
episode_dict: dict = series.get("episodeDict") or {}
|
||||
if not episode_dict:
|
||||
continue
|
||||
|
||||
episodes: List[EpisodeIdentifier] = []
|
||||
for season_str, ep_numbers in episode_dict.items():
|
||||
for ep_num in ep_numbers:
|
||||
episodes.append(
|
||||
EpisodeIdentifier(season=int(season_str), episode=int(ep_num))
|
||||
)
|
||||
|
||||
if not episodes:
|
||||
continue
|
||||
|
||||
await download_service.add_to_queue(
|
||||
serie_id=series.get("key", ""),
|
||||
serie_folder=series.get("folder", series.get("name", "")),
|
||||
serie_name=series.get("name", ""),
|
||||
episodes=episodes,
|
||||
)
|
||||
queued_count += len(episodes)
|
||||
logger.info(
|
||||
"Auto-download queued episodes for series=%s count=%d",
|
||||
series.get("key"),
|
||||
len(episodes),
|
||||
)
|
||||
|
||||
if queued_count:
|
||||
await download_service.start_queue_processing()
|
||||
logger.info("Auto-download queue processing started: queued=%d", queued_count)
|
||||
|
||||
self._last_auto_download_time = datetime.now(timezone.utc)
|
||||
logger.info("Auto-download completed: queued_count=%d", queued_count)
|
||||
return queued_count
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step 3: Folder scan
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _run_folder_scan(self) -> None:
|
||||
"""Run the folder scan maintenance task."""
|
||||
from src.server.services.scheduler.folder_scan_service import FolderScanService
|
||||
|
||||
folder_scan_service = FolderScanService()
|
||||
await folder_scan_service.run_folder_scan()
|
||||
logger.info("Folder scan completed successfully")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step 4: Key resolution
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _run_key_resolution(self) -> dict:
|
||||
"""Run the orphaned folder key resolution scan.
|
||||
|
||||
Returns:
|
||||
Dict with resolved/skipped/errors counts.
|
||||
"""
|
||||
from src.server.services.scheduler.key_resolution_service import (
|
||||
perform_key_resolution_scan,
|
||||
)
|
||||
|
||||
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"],
|
||||
)
|
||||
return key_stats
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Private helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
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 get_websocket_service
|
||||
|
||||
ws_service = get_websocket_service()
|
||||
await ws_service.manager.broadcast({"type": event_type, "data": data})
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"WebSocket broadcast failed: event=%s error=%s", event_type, exc
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level singleton
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_rescan_service: Optional[RescanService] = None
|
||||
|
||||
|
||||
def get_rescan_service(config: Optional[SchedulerConfig] = None) -> RescanService:
|
||||
"""Return a RescanService singleton (or create with optional config)."""
|
||||
global _rescan_service
|
||||
if _rescan_service is None or config is not None:
|
||||
_rescan_service = RescanService(config=config)
|
||||
logger.debug("Created new RescanService singleton")
|
||||
else:
|
||||
logger.debug("Returning existing RescanService singleton")
|
||||
return _rescan_service
|
||||
|
||||
|
||||
def reset_rescan_service() -> None:
|
||||
"""Reset the singleton (used in tests)."""
|
||||
global _rescan_service
|
||||
_rescan_service = None
|
||||
45
src/server/services/scheduler/__init__.py
Normal file
45
src/server/services/scheduler/__init__.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Scheduler services package.
|
||||
|
||||
Contains scheduler orchestration and rescan coordination:
|
||||
|
||||
- scheduler_service: Cron-based scheduler using APScheduler
|
||||
- rescan_orchestrator: Legacy alias for RescanService (for backward compatibility)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.server.services.rescan_service import (
|
||||
RescanService,
|
||||
get_rescan_service,
|
||||
reset_rescan_service,
|
||||
)
|
||||
|
||||
# Backward compatibility alias
|
||||
from src.server.services.scheduler.rescan_orchestrator import (
|
||||
RescanOrchestrator,
|
||||
get_rescan_orchestrator,
|
||||
reset_rescan_orchestrator,
|
||||
)
|
||||
from src.server.services.scheduler.scheduler_service import (
|
||||
SchedulerService,
|
||||
SchedulerServiceError,
|
||||
get_scheduler_service,
|
||||
reset_scheduler_service,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# RescanService (new location)
|
||||
"RescanService",
|
||||
"get_rescan_service",
|
||||
"reset_rescan_service",
|
||||
# Scheduler
|
||||
"SchedulerService",
|
||||
"SchedulerServiceError",
|
||||
"get_scheduler_service",
|
||||
"reset_scheduler_service",
|
||||
# Backward compatibility
|
||||
"RescanOrchestrator",
|
||||
"get_rescan_orchestrator",
|
||||
"reset_rescan_orchestrator",
|
||||
# Sub-services (still in scheduler folder)
|
||||
"folder_rename_service",
|
||||
]
|
||||
@@ -13,9 +13,12 @@ reflect the new paths.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Set, Tuple
|
||||
from typing import Optional
|
||||
|
||||
from lxml import etree
|
||||
|
||||
@@ -31,10 +34,11 @@ from src.server.utils.filesystem import sanitize_folder_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Characters that are invalid in filesystem paths across platforms
|
||||
INVALID_PATH_CHARS = '<>:"/\\|?*\x00'
|
||||
# Pre-compiled pattern for stripping existing year suffixes
|
||||
_YEAR_SUFFIX_PATTERN = re.compile(r'(\s*\(\d{4}\))+\s*$')
|
||||
|
||||
|
||||
@dataclass
|
||||
class DuplicateGroup:
|
||||
"""Represents a group of duplicate folders for the same series.
|
||||
|
||||
@@ -44,10 +48,9 @@ class DuplicateGroup:
|
||||
nfo_paths: List of corresponding NFO file paths.
|
||||
"""
|
||||
|
||||
def __init__(self, key: str, folders: List[str], nfo_paths: List[Path]):
|
||||
self.key = key
|
||||
self.folders = folders
|
||||
self.nfo_paths = nfo_paths
|
||||
key: str
|
||||
folders: list[str]
|
||||
nfo_paths: list[Path]
|
||||
|
||||
@property
|
||||
def count(self) -> int:
|
||||
@@ -57,7 +60,20 @@ class DuplicateGroup:
|
||||
return f"DuplicateGroup(key={self.key!r}, folders={self.folders})"
|
||||
|
||||
|
||||
def _scan_for_pre_existing_duplicates(anime_dir: Path) -> List[DuplicateGroup]:
|
||||
@dataclass
|
||||
class RenameStats:
|
||||
"""Statistics from a folder rename operation."""
|
||||
|
||||
scanned: int = 0
|
||||
renamed: int = 0
|
||||
skipped: int = 0
|
||||
errors: int = 0
|
||||
|
||||
def to_dict(self) -> dict[str, int]:
|
||||
return {"scanned": self.scanned, "renamed": self.renamed, "skipped": self.skipped, "errors": self.errors}
|
||||
|
||||
|
||||
def _scan_for_pre_existing_duplicates(anime_dir: Path) -> list[DuplicateGroup]:
|
||||
"""Scan anime directory for pre-existing duplicate folders.
|
||||
|
||||
Groups folders by the series key extracted from their NFO files.
|
||||
@@ -69,8 +85,7 @@ def _scan_for_pre_existing_duplicates(anime_dir: Path) -> List[DuplicateGroup]:
|
||||
Returns:
|
||||
List of DuplicateGroup objects, one per series with duplicate folders.
|
||||
"""
|
||||
# Group folders by their expected name (title+year from NFO)
|
||||
groups: Dict[str, List[Tuple[str, Path]]] = defaultdict(list)
|
||||
groups: dict[str, list[tuple[str, Path]]] = defaultdict(list)
|
||||
|
||||
for series_dir in anime_dir.iterdir():
|
||||
if not series_dir.is_dir():
|
||||
@@ -84,7 +99,6 @@ def _scan_for_pre_existing_duplicates(anime_dir: Path) -> List[DuplicateGroup]:
|
||||
expected_name = _compute_expected_folder_name(title, year)
|
||||
groups[expected_name].append((series_dir.name, nfo_path))
|
||||
|
||||
# Filter to only groups with more than one folder
|
||||
duplicates = []
|
||||
for key, items in groups.items():
|
||||
if len(items) > 1:
|
||||
@@ -111,16 +125,14 @@ def _try_merge_duplicate_group(group: DuplicateGroup, dry_run: bool = False) ->
|
||||
if len(group.folders) < 2:
|
||||
return True
|
||||
|
||||
# Keep first folder as canonical, mark others for removal
|
||||
canonical = group.folders[0]
|
||||
to_remove = group.folders[1:]
|
||||
|
||||
for folder in to_remove:
|
||||
folder_path = group.nfo_paths[0].parent.parent / folder # same parent dir
|
||||
folder_path = group.nfo_paths[0].parent.parent / folder
|
||||
if not folder_path.exists():
|
||||
continue
|
||||
|
||||
# Check if folder is empty or only has symlinks
|
||||
try:
|
||||
contents = list(folder_path.iterdir())
|
||||
except PermissionError:
|
||||
@@ -130,7 +142,6 @@ def _try_merge_duplicate_group(group: DuplicateGroup, dry_run: bool = False) ->
|
||||
return False
|
||||
|
||||
if not contents:
|
||||
# Empty folder - safe to remove
|
||||
if dry_run:
|
||||
logger.info("[DRY-RUN] Would delete empty duplicate folder: %s", folder_path)
|
||||
else:
|
||||
@@ -141,9 +152,9 @@ def _try_merge_duplicate_group(group: DuplicateGroup, dry_run: bool = False) ->
|
||||
return False
|
||||
continue
|
||||
|
||||
# Check if all contents are symlinks pointing to canonical
|
||||
canonical_path = folder_path.parent / canonical
|
||||
all_symlinks = all(
|
||||
item.is_symlink() and item.resolve() == (folder_path.parent / canonical).resolve()
|
||||
item.is_symlink() and item.resolve() == canonical_path.resolve()
|
||||
for item in contents
|
||||
)
|
||||
if all_symlinks:
|
||||
@@ -159,7 +170,6 @@ def _try_merge_duplicate_group(group: DuplicateGroup, dry_run: bool = False) ->
|
||||
return False
|
||||
continue
|
||||
|
||||
# Cannot auto-merge - requires manual intervention
|
||||
logger.warning(
|
||||
"Cannot auto-merge duplicate folders for '%s': %s (manual merge required)",
|
||||
group.key,
|
||||
@@ -170,7 +180,7 @@ def _try_merge_duplicate_group(group: DuplicateGroup, dry_run: bool = False) ->
|
||||
return True
|
||||
|
||||
|
||||
def _parse_nfo_title_and_year(nfo_path: Path) -> Tuple[Optional[str], Optional[str]]:
|
||||
def _parse_nfo_title_and_year(nfo_path: Path) -> tuple[Optional[str], Optional[str]]:
|
||||
"""Parse a tvshow.nfo and return (title, year) text values.
|
||||
|
||||
Args:
|
||||
@@ -194,7 +204,7 @@ def _parse_nfo_title_and_year(nfo_path: Path) -> Tuple[Optional[str], Optional[s
|
||||
except etree.XMLSyntaxError as exc:
|
||||
logger.warning("Malformed XML in %s: %s", nfo_path, exc)
|
||||
return None, None
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
except Exception as exc:
|
||||
logger.warning("Unexpected error parsing %s: %s", nfo_path, exc)
|
||||
return None, None
|
||||
|
||||
@@ -212,13 +222,7 @@ def _compute_expected_folder_name(title: str, year: str) -> str:
|
||||
Returns:
|
||||
Sanitised folder name in the format ``"{title} ({year})"``.
|
||||
"""
|
||||
import re
|
||||
|
||||
# Remove all trailing year suffixes to prevent duplication.
|
||||
# This handles cases where the title already contains one or more years.
|
||||
# Regex pattern: matches one or more " (YYYY)" at the end of the string
|
||||
clean_title = re.sub(r'(\s*\(\d{4}\))+\s*$', '', title).strip()
|
||||
|
||||
clean_title = _YEAR_SUFFIX_PATTERN.sub('', title).strip()
|
||||
year_suffix = f" ({year})"
|
||||
raw_name = f"{clean_title}{year_suffix}"
|
||||
return sanitize_folder_name(raw_name)
|
||||
@@ -236,42 +240,55 @@ def _is_series_being_downloaded(series_folder: str) -> bool:
|
||||
"""
|
||||
try:
|
||||
download_service = get_download_service()
|
||||
active = download_service._active_download # pylint: disable=protected-access
|
||||
active = download_service._active_download
|
||||
if active and active.serie_folder == series_folder:
|
||||
return True
|
||||
for item in download_service._pending_queue: # pylint: disable=protected-access
|
||||
for item in download_service._pending_queue:
|
||||
if item.serie_folder == series_folder:
|
||||
return True
|
||||
return False
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Could not check download status for %s: %s", series_folder, exc
|
||||
)
|
||||
# Safer to skip renaming if we can't verify download status.
|
||||
return True
|
||||
|
||||
|
||||
def _cleanup_stale_files_after_rename(new_path: Path, new_name: str) -> None:
|
||||
"""Remove legacy 'key' file after successful folder rename.
|
||||
|
||||
Also checks for orphaned folders with the same key that may have been
|
||||
left behind from previous rename operations.
|
||||
def _remove_key_file(path: Path) -> None:
|
||||
"""Remove legacy 'key' file from a series folder.
|
||||
|
||||
Args:
|
||||
new_path: The new folder path after rename.
|
||||
new_name: The new folder name.
|
||||
path: Path to the series folder.
|
||||
"""
|
||||
key_file = new_path / "key"
|
||||
key_file = path / "key"
|
||||
if key_file.exists():
|
||||
try:
|
||||
key_file.unlink()
|
||||
logger.info(
|
||||
"Removed legacy 'key' file after rename: %s", key_file
|
||||
)
|
||||
logger.info("Removed legacy 'key' file after rename: %s", key_file)
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"Could not remove legacy 'key' file %s: %s", key_file, exc
|
||||
)
|
||||
logger.warning("Could not remove legacy 'key' file %s: %s", key_file, exc)
|
||||
|
||||
|
||||
def _move_file(item: Path, dest: Path) -> bool:
|
||||
"""Move a single file or directory to destination.
|
||||
|
||||
Args:
|
||||
item: Source path to move.
|
||||
dest: Destination path.
|
||||
|
||||
Returns:
|
||||
True if move succeeded, False otherwise.
|
||||
"""
|
||||
try:
|
||||
item.rename(dest)
|
||||
logger.debug("Moved %s → %s", item, dest)
|
||||
return True
|
||||
except PermissionError as exc:
|
||||
logger.warning("Permission denied moving %s: %s", item, exc)
|
||||
return False
|
||||
except OSError as exc:
|
||||
logger.warning("OS error moving %s: %s", item, exc)
|
||||
return False
|
||||
|
||||
|
||||
def _cleanup_orphaned_folder(old_path: Path, new_path: Path, dry_run: bool = False) -> bool:
|
||||
@@ -291,53 +308,36 @@ def _cleanup_orphaned_folder(old_path: Path, new_path: Path, dry_run: bool = Fal
|
||||
False if old folder does not exist or cleanup failed.
|
||||
"""
|
||||
if not old_path.exists():
|
||||
logger.debug(
|
||||
"Old folder does not exist, no cleanup needed: %s", old_path
|
||||
)
|
||||
logger.debug("Old folder does not exist, no cleanup needed: %s", old_path)
|
||||
return False
|
||||
|
||||
# Check if folder is empty
|
||||
try:
|
||||
contents = list(old_path.iterdir())
|
||||
except PermissionError as exc:
|
||||
logger.warning(
|
||||
"Permission denied accessing old folder %s: %s", old_path, exc
|
||||
)
|
||||
logger.warning("Permission denied accessing old folder %s: %s", old_path, exc)
|
||||
return False
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"OS error accessing old folder %s: %s", old_path, exc
|
||||
)
|
||||
logger.warning("OS error accessing old folder %s: %s", old_path, exc)
|
||||
return False
|
||||
|
||||
if not contents:
|
||||
# Empty folder — delete it
|
||||
if dry_run:
|
||||
logger.info(
|
||||
"[DRY-RUN] Would delete empty orphaned folder: %s", old_path
|
||||
)
|
||||
logger.info("[DRY-RUN] Would delete empty orphaned folder: %s", old_path)
|
||||
return True
|
||||
try:
|
||||
old_path.rmdir()
|
||||
logger.info("Deleted empty orphaned folder: %s", old_path)
|
||||
return True
|
||||
except PermissionError as exc:
|
||||
logger.warning(
|
||||
"Permission denied deleting folder %s: %s", old_path, exc
|
||||
)
|
||||
logger.warning("Permission denied deleting folder %s: %s", old_path, exc)
|
||||
return False
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"OS error deleting folder %s: %s", old_path, exc
|
||||
)
|
||||
logger.warning("OS error deleting folder %s: %s", old_path, exc)
|
||||
return False
|
||||
|
||||
# Folder has contents — move files to new_path then delete
|
||||
if dry_run:
|
||||
logger.info(
|
||||
"[DRY-RUN] Would move %d files from orphaned folder %s to %s",
|
||||
len(contents), old_path, new_path
|
||||
)
|
||||
logger.info("[DRY-RUN] Would move %d files from orphaned folder %s to %s",
|
||||
len(contents), old_path, new_path)
|
||||
for item in contents:
|
||||
logger.info("[DRY-RUN] Would move: %s → %s", item, new_path / item.name)
|
||||
logger.info("[DRY-RUN] Would then delete orphaned folder: %s", old_path)
|
||||
@@ -346,41 +346,86 @@ def _cleanup_orphaned_folder(old_path: Path, new_path: Path, dry_run: bool = Fal
|
||||
files_moved = 0
|
||||
errors = 0
|
||||
for item in contents:
|
||||
try:
|
||||
dest = new_path / item.name
|
||||
item.rename(dest)
|
||||
logger.debug("Moved %s → %s", item, dest)
|
||||
if not _move_file(item, new_path / item.name):
|
||||
errors += 1
|
||||
else:
|
||||
files_moved += 1
|
||||
except PermissionError as exc:
|
||||
logger.warning(
|
||||
"Permission denied moving %s: %s", item, exc
|
||||
)
|
||||
errors += 1
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"OS error moving %s: %s", item, exc
|
||||
)
|
||||
errors += 1
|
||||
|
||||
if files_moved > 0:
|
||||
logger.info(
|
||||
"Moved %d files from orphaned folder to %s",
|
||||
files_moved, new_path
|
||||
)
|
||||
logger.info("Moved %d files from orphaned folder to %s", files_moved, new_path)
|
||||
|
||||
# Delete the now-empty old folder
|
||||
try:
|
||||
old_path.rmdir()
|
||||
logger.info("Deleted orphaned folder after moving contents: %s", old_path)
|
||||
return errors == 0
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"Could not delete orphaned folder %s (may not be empty): %s",
|
||||
old_path, exc
|
||||
)
|
||||
logger.warning("Could not delete orphaned folder %s (may not be empty): %s", old_path, exc)
|
||||
return False
|
||||
|
||||
|
||||
async def _update_series_folder(db, series, new_folder: str) -> None:
|
||||
"""Update AnimeSeries.folder in the database.
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
series: The AnimeSeries instance to update.
|
||||
new_folder: New folder name.
|
||||
"""
|
||||
if series is None:
|
||||
return
|
||||
|
||||
await AnimeSeriesService.update(db, series.id, folder=new_folder)
|
||||
logger.info("Updated AnimeSeries.folder: %s (id=%s)", new_folder, series.id)
|
||||
|
||||
|
||||
def _update_episode_paths(episodes, old_series_path: Path, new_series_path: Path) -> None:
|
||||
"""Update Episode.file_path for all episodes of a series.
|
||||
|
||||
Args:
|
||||
episodes: List of Episode instances.
|
||||
old_series_path: Path to the old series folder.
|
||||
new_series_path: Path to the new series folder.
|
||||
"""
|
||||
for episode in episodes:
|
||||
if not episode.file_path:
|
||||
continue
|
||||
old_file_path = Path(episode.file_path)
|
||||
try:
|
||||
old_file_path.relative_to(old_series_path)
|
||||
new_file_path = new_series_path / old_file_path.relative_to(old_series_path)
|
||||
episode.file_path = str(new_file_path)
|
||||
logger.debug("Updated Episode.file_path: %s → %s", old_file_path, new_file_path)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
def _update_queue_destinations(
|
||||
queue_items,
|
||||
series_id,
|
||||
old_series_path: Path,
|
||||
new_series_path: Path,
|
||||
) -> None:
|
||||
"""Update DownloadQueueItem.file_destination for pending items.
|
||||
|
||||
Args:
|
||||
queue_items: List of DownloadQueueItem instances.
|
||||
series_id: ID of the series to filter by.
|
||||
old_series_path: Path to the old series folder.
|
||||
new_series_path: Path to the new series folder.
|
||||
"""
|
||||
for item in queue_items:
|
||||
if item.series_id != series_id or not item.file_destination:
|
||||
continue
|
||||
old_dest = Path(item.file_destination)
|
||||
try:
|
||||
old_dest.relative_to(old_series_path)
|
||||
new_dest = new_series_path / old_dest.relative_to(old_series_path)
|
||||
item.file_destination = str(new_dest)
|
||||
logger.debug("Updated DownloadQueueItem.file_destination: %s → %s", old_dest, new_dest)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
async def _update_database_paths(
|
||||
old_folder: str,
|
||||
new_folder: str,
|
||||
@@ -402,82 +447,138 @@ async def _update_database_paths(
|
||||
new_series_path = anime_dir / new_folder
|
||||
|
||||
async with get_db_session() as db:
|
||||
# 1. Update AnimeSeries.folder
|
||||
series = await AnimeSeriesService.get_by_key(db, old_folder)
|
||||
series = await AnimeSeriesService.get_by_folder(db, old_folder)
|
||||
if series is None:
|
||||
# Fallback: try to find by folder name
|
||||
all_series = await AnimeSeriesService.get_all(db)
|
||||
for s in all_series:
|
||||
if s.folder == old_folder:
|
||||
series = s
|
||||
break
|
||||
|
||||
await _update_series_folder(db, series, new_folder)
|
||||
|
||||
if series is None:
|
||||
logger.warning(
|
||||
"No database record found for folder '%s', skipping DB update",
|
||||
old_folder,
|
||||
)
|
||||
return
|
||||
|
||||
await AnimeSeriesService.update(db, series.id, folder=new_folder)
|
||||
logger.info(
|
||||
"Updated AnimeSeries.folder: %s → %s (id=%s)",
|
||||
old_folder,
|
||||
new_folder,
|
||||
series.id,
|
||||
)
|
||||
|
||||
# 2. Update Episode.file_path for all episodes of this series
|
||||
episodes = await EpisodeService.get_by_series(db, series.id)
|
||||
for episode in episodes:
|
||||
if episode.file_path:
|
||||
old_file_path = Path(episode.file_path)
|
||||
# Only update if the path is under the old series folder
|
||||
try:
|
||||
old_file_path.relative_to(old_series_path)
|
||||
new_file_path = new_series_path / old_file_path.relative_to(
|
||||
old_series_path
|
||||
)
|
||||
episode.file_path = str(new_file_path)
|
||||
logger.debug(
|
||||
"Updated Episode.file_path: %s → %s",
|
||||
old_file_path,
|
||||
new_file_path,
|
||||
)
|
||||
except ValueError:
|
||||
# Path is not under old_series_path, skip
|
||||
pass
|
||||
_update_episode_paths(episodes, old_series_path, new_series_path)
|
||||
|
||||
await db.flush()
|
||||
|
||||
# 3. Update DownloadQueueItem.file_destination for pending items
|
||||
queue_items = await DownloadQueueService.get_all(db, with_series=True)
|
||||
for item in queue_items:
|
||||
if item.series_id == series.id and item.file_destination:
|
||||
old_dest = Path(item.file_destination)
|
||||
try:
|
||||
old_dest.relative_to(old_series_path)
|
||||
new_dest = new_series_path / old_dest.relative_to(
|
||||
old_series_path
|
||||
)
|
||||
item.file_destination = str(new_dest)
|
||||
logger.debug(
|
||||
"Updated DownloadQueueItem.file_destination: %s → %s",
|
||||
old_dest,
|
||||
new_dest,
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
_update_queue_destinations(queue_items, series.id, old_series_path, new_series_path)
|
||||
|
||||
await db.flush()
|
||||
logger.info("Database paths updated for series '%s' → '%s'", old_folder, new_folder)
|
||||
|
||||
|
||||
def _remove_duplicate_target_folder(
|
||||
series_dir: Path,
|
||||
current_name: str,
|
||||
expected_name: str,
|
||||
expected_path: Path,
|
||||
) -> bool:
|
||||
"""Handle the case where the target folder already exists.
|
||||
|
||||
Removes the source folder and its DB record to avoid orphaning
|
||||
episodes/downloads.
|
||||
|
||||
Args:
|
||||
series_dir: Path to the series directory being processed.
|
||||
current_name: Current folder name.
|
||||
expected_name: Expected folder name.
|
||||
expected_path: Path to the expected (target) folder.
|
||||
|
||||
Returns:
|
||||
True if folder was removed successfully, False otherwise.
|
||||
"""
|
||||
logger.warning(
|
||||
"Cannot rename '%s' → '%s' — target already exists",
|
||||
current_name,
|
||||
expected_name,
|
||||
)
|
||||
try:
|
||||
try:
|
||||
contents = list(series_dir.iterdir())
|
||||
logger.warning(
|
||||
"REMOVING folder '%s' with %d items — target '%s' already exists",
|
||||
current_name,
|
||||
len(contents),
|
||||
expected_name,
|
||||
)
|
||||
for item in contents:
|
||||
logger.warning(" Would remove: %s", item)
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"Could not list contents of folder '%s' before removal: %s",
|
||||
current_name,
|
||||
exc,
|
||||
)
|
||||
|
||||
shutil.rmtree(series_dir)
|
||||
logger.info(
|
||||
"Database paths updated for series '%s' → '%s'",
|
||||
old_folder,
|
||||
new_folder,
|
||||
"Removed source folder '%s' — series already exists at target",
|
||||
current_name,
|
||||
)
|
||||
|
||||
# Delete source DB record using synchronous helper
|
||||
_delete_series_db_record(current_name, expected_name)
|
||||
|
||||
return True
|
||||
except OSError as exc:
|
||||
logger.error("Failed to remove source folder '%s': %s", current_name, exc)
|
||||
return False
|
||||
|
||||
|
||||
def _delete_series_db_record(current_name: str, expected_name: str) -> None:
|
||||
"""Delete the series DB record for a folder that was removed.
|
||||
|
||||
Args:
|
||||
current_name: The folder name to look up in the DB.
|
||||
expected_name: The target folder name (for logging).
|
||||
"""
|
||||
try:
|
||||
import asyncio
|
||||
asyncio.run(_delete_series_db_record_async(current_name, expected_name))
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Could not delete DB record for '%s': %s",
|
||||
current_name,
|
||||
exc,
|
||||
)
|
||||
|
||||
|
||||
async def validate_and_rename_series_folders(dry_run: bool = False) -> Dict[str, int]:
|
||||
async def _delete_series_db_record_async(current_name: str, expected_name: str) -> None:
|
||||
"""Async helper to delete series DB record.
|
||||
|
||||
Args:
|
||||
current_name: The folder name to look up.
|
||||
expected_name: The target folder name (for logging).
|
||||
"""
|
||||
async with get_db_session() as db:
|
||||
source_series = await AnimeSeriesService.get_by_folder(db, current_name)
|
||||
if source_series is None:
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
async def validate_and_rename_series_folders(dry_run: bool = False) -> dict[str, int]:
|
||||
"""Validate and rename series folders to match NFO metadata.
|
||||
|
||||
Iterates over every subfolder in ``settings.anime_directory`` that
|
||||
@@ -505,25 +606,21 @@ async def validate_and_rename_series_folders(dry_run: bool = False) -> Dict[str,
|
||||
"""
|
||||
if not settings.anime_directory:
|
||||
logger.warning("Folder rename skipped — anime directory not configured")
|
||||
return {"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0}
|
||||
return RenameStats().to_dict()
|
||||
|
||||
anime_dir = Path(settings.anime_directory)
|
||||
if not anime_dir.is_dir():
|
||||
logger.warning(
|
||||
"Folder rename skipped — anime directory not found: %s", anime_dir
|
||||
)
|
||||
return {"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0}
|
||||
logger.warning("Folder rename skipped — anime directory not found: %s", anime_dir)
|
||||
return RenameStats().to_dict()
|
||||
|
||||
if dry_run:
|
||||
logger.info("Running in DRY-RUN mode — no changes will be made")
|
||||
|
||||
stats = {"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0}
|
||||
|
||||
# Detect pre-existing duplicates before rename loop
|
||||
pre_existing_duplicates: Set[str] = set()
|
||||
stats = RenameStats()
|
||||
pre_existing_duplicates: set[str] = set()
|
||||
duplicates = _scan_for_pre_existing_duplicates(anime_dir)
|
||||
|
||||
for dup_group in duplicates:
|
||||
# Try automatic merge first
|
||||
if _try_merge_duplicate_group(dup_group, dry_run=dry_run):
|
||||
logger.info(
|
||||
"Auto-merged duplicate group for '%s' (%d folders)",
|
||||
@@ -531,7 +628,6 @@ async def validate_and_rename_series_folders(dry_run: bool = False) -> Dict[str,
|
||||
dup_group.count,
|
||||
)
|
||||
else:
|
||||
# Flag all folders in this group as pre-existing duplicates
|
||||
for folder in dup_group.folders:
|
||||
pre_existing_duplicates.add(folder)
|
||||
logger.warning(
|
||||
@@ -549,7 +645,7 @@ async def validate_and_rename_series_folders(dry_run: bool = False) -> Dict[str,
|
||||
if not nfo_path.exists():
|
||||
continue
|
||||
|
||||
stats["scanned"] += 1
|
||||
stats.scanned += 1
|
||||
|
||||
title, year = _parse_nfo_title_and_year(nfo_path)
|
||||
if not title or not year:
|
||||
@@ -557,82 +653,63 @@ async def validate_and_rename_series_folders(dry_run: bool = False) -> Dict[str,
|
||||
"Skipping rename for '%s' — missing title or year in NFO",
|
||||
series_dir.name,
|
||||
)
|
||||
stats["skipped"] += 1
|
||||
stats.skipped += 1
|
||||
continue
|
||||
|
||||
expected_name = _compute_expected_folder_name(title, year)
|
||||
current_name = series_dir.name
|
||||
|
||||
if expected_name == current_name:
|
||||
logger.debug(
|
||||
"Folder name already correct: '%s'", current_name
|
||||
)
|
||||
logger.debug("Folder name already correct: '%s'", current_name)
|
||||
continue
|
||||
|
||||
# Check for active downloads
|
||||
if _is_series_being_downloaded(current_name):
|
||||
logger.info(
|
||||
"Skipping rename for '%s' — series has active or pending downloads",
|
||||
current_name,
|
||||
)
|
||||
stats["skipped"] += 1
|
||||
stats.skipped += 1
|
||||
continue
|
||||
|
||||
expected_path = anime_dir / expected_name
|
||||
|
||||
# Check for pre-existing duplicate
|
||||
if current_name in pre_existing_duplicates:
|
||||
logger.warning(
|
||||
"Skipping rename for '%s' — pre-existing duplicate folder detected",
|
||||
current_name,
|
||||
)
|
||||
stats["errors"] += 1
|
||||
stats.errors += 1
|
||||
continue
|
||||
|
||||
# Check for duplicate target
|
||||
if expected_path.exists():
|
||||
logger.warning(
|
||||
"Cannot rename '%s' → '%s' — target already exists",
|
||||
current_name,
|
||||
expected_name,
|
||||
)
|
||||
stats["errors"] += 1
|
||||
if _remove_duplicate_target_folder(series_dir, current_name, expected_name, expected_path):
|
||||
stats.renamed += 1
|
||||
else:
|
||||
stats.errors += 1
|
||||
continue
|
||||
|
||||
# Check path length limits
|
||||
if len(str(expected_path)) > 4096:
|
||||
logger.warning(
|
||||
"Cannot rename '%s' → '%s' — path exceeds OS limit",
|
||||
current_name,
|
||||
expected_name,
|
||||
)
|
||||
stats["errors"] += 1
|
||||
stats.errors += 1
|
||||
continue
|
||||
|
||||
if dry_run:
|
||||
logger.info(
|
||||
"[DRY-RUN] Would rename folder: '%s' → '%s'",
|
||||
current_name,
|
||||
expected_name,
|
||||
)
|
||||
stats["renamed"] += 1
|
||||
logger.info("[DRY-RUN] Would rename folder: '%s' → '%s'", current_name, expected_name)
|
||||
stats.renamed += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
old_path = series_dir
|
||||
series_dir.rename(expected_path)
|
||||
logger.info(
|
||||
"Renamed folder: '%s' → '%s'", current_name, expected_name
|
||||
)
|
||||
stats["renamed"] += 1
|
||||
logger.info("Renamed folder: '%s' → '%s'", current_name, expected_name)
|
||||
stats.renamed += 1
|
||||
|
||||
# Update database records
|
||||
await _update_database_paths(current_name, expected_name, anime_dir)
|
||||
|
||||
# Clean up stale/legacy files after successful rename
|
||||
_cleanup_stale_files_after_rename(expected_path, expected_name)
|
||||
|
||||
# Clean up orphaned folder if old path still exists
|
||||
_remove_key_file(expected_path)
|
||||
_cleanup_orphaned_folder(old_path, expected_path, dry_run=False)
|
||||
|
||||
except PermissionError as exc:
|
||||
@@ -642,7 +719,7 @@ async def validate_and_rename_series_folders(dry_run: bool = False) -> Dict[str,
|
||||
expected_name,
|
||||
exc,
|
||||
)
|
||||
stats["errors"] += 1
|
||||
stats.errors += 1
|
||||
except OSError as exc:
|
||||
logger.error(
|
||||
"OS error renaming '%s' → '%s': %s",
|
||||
@@ -650,13 +727,13 @@ async def validate_and_rename_series_folders(dry_run: bool = False) -> Dict[str,
|
||||
expected_name,
|
||||
exc,
|
||||
)
|
||||
stats["errors"] += 1
|
||||
stats.errors += 1
|
||||
|
||||
logger.info(
|
||||
"Folder rename scan complete: scanned=%d, renamed=%d, skipped=%d, errors=%d",
|
||||
stats["scanned"],
|
||||
stats["renamed"],
|
||||
stats["skipped"],
|
||||
stats["errors"],
|
||||
stats.scanned,
|
||||
stats.renamed,
|
||||
stats.skipped,
|
||||
stats.errors,
|
||||
)
|
||||
return stats
|
||||
return stats.to_dict()
|
||||
@@ -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,14 +195,14 @@ 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")
|
||||
from src.server.services.folder_rename_service import (
|
||||
from src.server.services.scheduler.folder_rename_service import (
|
||||
validate_and_rename_series_folders,
|
||||
)
|
||||
|
||||
317
src/server/services/scheduler/key_resolution_service.py
Normal file
317
src/server/services/scheduler/key_resolution_service.py
Normal file
@@ -0,0 +1,317 @@
|
||||
"""Key resolution service for orphaned anime folders.
|
||||
|
||||
Attempts to resolve provider keys for anime folders that have no key/data
|
||||
file and no database entry, by searching the anime provider and matching
|
||||
folder names to search results.
|
||||
|
||||
This service runs after nfo_repair_service during the daily folder scan.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import structlog
|
||||
|
||||
from src.config.settings import settings as _settings
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# Limit concurrent provider searches to avoid rate-limiting.
|
||||
_SEARCH_SEMAPHORE: asyncio.Semaphore = asyncio.Semaphore(2)
|
||||
|
||||
|
||||
def _strip_year_from_folder(folder_name: str) -> str:
|
||||
"""Remove trailing year suffix like ' (2020)' from folder name.
|
||||
|
||||
Args:
|
||||
folder_name: Folder name, e.g. 'Rent-A-Girlfriend (2020)'
|
||||
|
||||
Returns:
|
||||
Name without year, e.g. 'Rent-A-Girlfriend'
|
||||
"""
|
||||
return re.sub(r"\s*\(\d{4}\)\s*$", "", folder_name).strip()
|
||||
|
||||
|
||||
def _extract_year_from_folder(folder_name: str) -> Optional[int]:
|
||||
"""Extract year from folder name like 'Anime Name (2020)'.
|
||||
|
||||
Returns:
|
||||
Year as int or None if not present.
|
||||
"""
|
||||
match = re.search(r"\((\d{4})\)$", folder_name.strip())
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def _extract_key_from_link(link: str) -> Optional[str]:
|
||||
"""Extract provider key from search result link.
|
||||
|
||||
Args:
|
||||
link: Link like '/anime/stream/rent-a-girlfriend' or full URL.
|
||||
|
||||
Returns:
|
||||
Key slug like 'rent-a-girlfriend' or None.
|
||||
"""
|
||||
if not link:
|
||||
return None
|
||||
if "/anime/stream/" in link:
|
||||
parts = link.split("/anime/stream/")[-1].split("/")
|
||||
key = parts[0].strip()
|
||||
return key if key else None
|
||||
# If link is just a slug
|
||||
if "/" not in link and link.strip():
|
||||
return link.strip()
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_for_comparison(text: str) -> str:
|
||||
"""Normalize text for case-insensitive comparison.
|
||||
|
||||
Strips whitespace, lowercases, and removes common punctuation
|
||||
differences that shouldn't affect matching.
|
||||
|
||||
Args:
|
||||
text: Raw text string.
|
||||
|
||||
Returns:
|
||||
Normalized lowercase string.
|
||||
"""
|
||||
normalized = text.strip().lower()
|
||||
# Remove common punctuation that varies between sources
|
||||
normalized = re.sub(r"[:\-–—]", " ", normalized)
|
||||
# Collapse multiple spaces
|
||||
normalized = re.sub(r"\s+", " ", normalized)
|
||||
return normalized.strip()
|
||||
|
||||
|
||||
async def resolve_key_for_folder(folder_name: str) -> Optional[str]:
|
||||
"""Attempt to resolve the provider key for a single folder.
|
||||
|
||||
Strategy:
|
||||
1. Strip year suffix from folder name to get search query.
|
||||
2. Search the anime provider with that query.
|
||||
3. If exactly ONE result matches the folder name (case-insensitive),
|
||||
return the key extracted from the result link.
|
||||
4. If zero or multiple matches, return None (not confident enough).
|
||||
|
||||
Args:
|
||||
folder_name: The anime folder name, e.g. 'Rent-A-Girlfriend (2020)'.
|
||||
|
||||
Returns:
|
||||
The provider key string, or None if resolution is not confident.
|
||||
"""
|
||||
search_query = _strip_year_from_folder(folder_name)
|
||||
if not search_query:
|
||||
logger.debug("Empty search query after stripping year from '%s'", folder_name)
|
||||
return None
|
||||
|
||||
async with _SEARCH_SEMAPHORE:
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
results = await loop.run_in_executor(None, _search_provider, search_query)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Provider search failed for '%s': %s", search_query, exc
|
||||
)
|
||||
return None
|
||||
|
||||
if not results:
|
||||
logger.debug("No search results for folder '%s'", folder_name)
|
||||
return None
|
||||
|
||||
# Filter results: find exact name matches (case-insensitive)
|
||||
normalized_query = _normalize_for_comparison(search_query)
|
||||
exact_matches = []
|
||||
|
||||
for result in results:
|
||||
title = result.get("title") or result.get("name") or ""
|
||||
normalized_title = _normalize_for_comparison(title)
|
||||
|
||||
if normalized_title == normalized_query:
|
||||
key = _extract_key_from_link(result.get("link", ""))
|
||||
if key:
|
||||
exact_matches.append((key, title))
|
||||
|
||||
if len(exact_matches) == 1:
|
||||
resolved_key, matched_title = exact_matches[0]
|
||||
logger.info(
|
||||
"Resolved key for folder '%s': key='%s' (matched title: '%s')",
|
||||
folder_name,
|
||||
resolved_key,
|
||||
matched_title,
|
||||
)
|
||||
return resolved_key
|
||||
|
||||
if len(exact_matches) > 1:
|
||||
logger.info(
|
||||
"Multiple exact matches for folder '%s' (%d matches), skipping",
|
||||
folder_name,
|
||||
len(exact_matches),
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"No exact title match for folder '%s' in %d results",
|
||||
folder_name,
|
||||
len(results),
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _search_provider(query: str) -> list:
|
||||
"""Call the anime provider search synchronously.
|
||||
|
||||
Args:
|
||||
query: Search term.
|
||||
|
||||
Returns:
|
||||
List of search result dicts with 'link' and 'title'/'name' fields.
|
||||
"""
|
||||
from src.core.providers.provider_factory import Loaders
|
||||
|
||||
loader = Loaders().GetLoader("aniworld.to")
|
||||
return loader.search(query)
|
||||
|
||||
|
||||
async def perform_key_resolution_scan() -> dict[str, int]:
|
||||
"""Scan all anime folders and resolve missing keys.
|
||||
|
||||
Iterates over all subfolders of the anime directory. For each folder
|
||||
that has no corresponding database entry, attempts to resolve the
|
||||
provider key via provider search and saves it to the database.
|
||||
|
||||
Returns:
|
||||
Dictionary with counts:
|
||||
- 'scanned': total folders checked
|
||||
- 'resolved': keys successfully resolved and saved
|
||||
- 'skipped': folders already in DB or resolution uncertain
|
||||
- 'errors': folders that caused errors during resolution
|
||||
"""
|
||||
from src.server.database.connection import get_db_session
|
||||
from src.server.database.service import AnimeSeriesService
|
||||
|
||||
stats = {"scanned": 0, "resolved": 0, "skipped": 0, "errors": 0}
|
||||
|
||||
if not _settings.anime_directory:
|
||||
logger.warning("Key resolution scan skipped — anime directory not configured")
|
||||
return stats
|
||||
|
||||
anime_dir = Path(_settings.anime_directory)
|
||||
if not anime_dir.is_dir():
|
||||
logger.warning(
|
||||
"Key resolution scan skipped — anime directory not found: %s",
|
||||
anime_dir,
|
||||
)
|
||||
return stats
|
||||
|
||||
# Collect folders that need resolution
|
||||
folders_to_resolve: list[str] = []
|
||||
|
||||
async with get_db_session() as db:
|
||||
for series_dir in sorted(anime_dir.iterdir()):
|
||||
if not series_dir.is_dir():
|
||||
continue
|
||||
folder_name = series_dir.name
|
||||
stats["scanned"] += 1
|
||||
|
||||
# Check if already in database
|
||||
existing = await AnimeSeriesService.get_by_folder(db, folder_name)
|
||||
if existing:
|
||||
stats["skipped"] += 1
|
||||
continue
|
||||
|
||||
folders_to_resolve.append(folder_name)
|
||||
|
||||
if not folders_to_resolve:
|
||||
logger.info("Key resolution scan: all folders already have DB entries")
|
||||
return stats
|
||||
|
||||
logger.info(
|
||||
"Key resolution scan: %d folders need resolution", len(folders_to_resolve)
|
||||
)
|
||||
|
||||
# Resolve keys one by one (provider search is rate-limited)
|
||||
for folder_name in folders_to_resolve:
|
||||
try:
|
||||
key = await resolve_key_for_folder(folder_name)
|
||||
if key:
|
||||
# Save to database
|
||||
await _save_resolved_key(folder_name, key)
|
||||
stats["resolved"] += 1
|
||||
else:
|
||||
stats["skipped"] += 1
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Error resolving key for folder '%s': %s",
|
||||
folder_name,
|
||||
exc,
|
||||
)
|
||||
stats["errors"] += 1
|
||||
|
||||
logger.info(
|
||||
"Key resolution scan complete: scanned=%d, resolved=%d, skipped=%d, errors=%d",
|
||||
stats["scanned"],
|
||||
stats["resolved"],
|
||||
stats["skipped"],
|
||||
stats["errors"],
|
||||
)
|
||||
return stats
|
||||
|
||||
|
||||
async def _save_resolved_key(folder_name: str, key: str) -> None:
|
||||
"""Save a resolved key to the database.
|
||||
|
||||
Creates a new AnimeSeries entry with the resolved key and folder name.
|
||||
Does NOT write any key/data file to disk.
|
||||
|
||||
Args:
|
||||
folder_name: The anime folder name (e.g. 'Rent-A-Girlfriend (2020)').
|
||||
key: The resolved provider key (e.g. 'rent-a-girlfriend').
|
||||
"""
|
||||
from src.server.database.connection import get_db_session
|
||||
from src.server.database.service import AnimeSeriesService
|
||||
|
||||
name = _strip_year_from_folder(folder_name)
|
||||
year = _extract_year_from_folder(folder_name)
|
||||
|
||||
async with get_db_session() as db:
|
||||
# Double-check: another task might have resolved it concurrently
|
||||
existing = await AnimeSeriesService.get_by_folder(db, folder_name)
|
||||
if existing:
|
||||
logger.debug(
|
||||
"Folder '%s' already in DB (resolved concurrently), skipping",
|
||||
folder_name,
|
||||
)
|
||||
return
|
||||
|
||||
# Also check if a series with this key already exists
|
||||
existing_key = await AnimeSeriesService.get_by_key(db, key)
|
||||
if existing_key:
|
||||
logger.warning(
|
||||
"Key '%s' already exists in DB for folder '%s', "
|
||||
"cannot assign to folder '%s'",
|
||||
key,
|
||||
existing_key.folder,
|
||||
folder_name,
|
||||
)
|
||||
return
|
||||
|
||||
await AnimeSeriesService.create(
|
||||
db,
|
||||
key=key,
|
||||
name=name,
|
||||
site="aniworld.to",
|
||||
folder=folder_name,
|
||||
year=year,
|
||||
loading_status="pending",
|
||||
episodes_loaded=False,
|
||||
)
|
||||
logger.info(
|
||||
"Saved resolved key '%s' for folder '%s' to database",
|
||||
key,
|
||||
folder_name,
|
||||
)
|
||||
293
src/server/services/scheduler/rescan_orchestrator.py
Normal file
293
src/server/services/scheduler/rescan_orchestrator.py
Normal file
@@ -0,0 +1,293 @@
|
||||
"""Rescan orchestrator — coordinates all scan/cleanup operations during a rescan.
|
||||
|
||||
Extracts the rescan workflow from SchedulerService so scheduling and scan
|
||||
logic are cleanly separated.
|
||||
|
||||
Called by SchedulerService.trigger_rescan() and by _run_rescan_job().
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from src.server.models.config import SchedulerConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RescanOrchestrator:
|
||||
"""Coordinates rescan, auto-download, folder scan, and key resolution.
|
||||
|
||||
This class encapsulates the entire post-rescan workflow so SchedulerService
|
||||
only needs to call a single method.
|
||||
"""
|
||||
|
||||
def __init__(self, config: Optional[SchedulerConfig] = None) -> None:
|
||||
"""Initialize the orchestrator.
|
||||
|
||||
Args:
|
||||
config: Optional scheduler config. If None, operations that depend
|
||||
on config flags (auto_download, folder_scan) will be skipped.
|
||||
"""
|
||||
self._config = config
|
||||
self._last_scan_time: Optional[datetime] = None
|
||||
# Cooldown tracking for auto-download to prevent rapid re-triggers
|
||||
self._last_auto_download_time: Optional[datetime] = None
|
||||
self._auto_download_cooldown_seconds: int = 300 # 5 minutes default
|
||||
|
||||
@property
|
||||
def last_scan_time(self) -> Optional[datetime]:
|
||||
return self._last_scan_time
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Auto-download
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def run_auto_download(self) -> int:
|
||||
"""Queue and start downloads for all series with missing episodes.
|
||||
|
||||
Returns:
|
||||
Number of episodes queued.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
from src.server.models.download import EpisodeIdentifier
|
||||
from src.server.utils.dependencies import (
|
||||
get_anime_service,
|
||||
get_download_service,
|
||||
)
|
||||
|
||||
# Check cooldown to prevent rapid re-triggers
|
||||
now = datetime.now(timezone.utc)
|
||||
if self._last_auto_download_time is not None:
|
||||
elapsed = now - self._last_auto_download_time
|
||||
if elapsed < timedelta(seconds=self._auto_download_cooldown_seconds):
|
||||
logger.debug(
|
||||
"Auto-download skipped: cooldown active (elapsed=%.1fs cooldown=%ds)",
|
||||
elapsed.total_seconds(),
|
||||
self._auto_download_cooldown_seconds,
|
||||
)
|
||||
return 0
|
||||
|
||||
anime_service = get_anime_service()
|
||||
download_service = get_download_service()
|
||||
|
||||
series_list = anime_service._cached_list_missing()
|
||||
queued_count = 0
|
||||
|
||||
for series in series_list:
|
||||
episode_dict: dict = series.get("episodeDict") or {}
|
||||
if not episode_dict:
|
||||
continue
|
||||
|
||||
episodes: List[EpisodeIdentifier] = []
|
||||
for season_str, ep_numbers in episode_dict.items():
|
||||
for ep_num in ep_numbers:
|
||||
episodes.append(
|
||||
EpisodeIdentifier(season=int(season_str), episode=int(ep_num))
|
||||
)
|
||||
|
||||
if not episodes:
|
||||
continue
|
||||
|
||||
await download_service.add_to_queue(
|
||||
serie_id=series.get("key", ""),
|
||||
serie_folder=series.get("folder", series.get("name", "")),
|
||||
serie_name=series.get("name", ""),
|
||||
episodes=episodes,
|
||||
)
|
||||
queued_count += len(episodes)
|
||||
logger.info(
|
||||
"Auto-download queued episodes for series=%s count=%d",
|
||||
series.get("key"),
|
||||
len(episodes),
|
||||
)
|
||||
|
||||
if queued_count:
|
||||
await download_service.start_queue_processing()
|
||||
logger.info("Auto-download queue processing started: queued=%d", queued_count)
|
||||
|
||||
self._last_auto_download_time = datetime.now(timezone.utc)
|
||||
logger.info("Auto-download completed: queued_count=%d", queued_count)
|
||||
return queued_count
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Folder scan
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def run_folder_scan(self) -> None:
|
||||
"""Run the folder scan maintenance task."""
|
||||
from src.server.services.scheduler.folder_scan_service import FolderScanService
|
||||
|
||||
folder_scan_service = FolderScanService()
|
||||
await folder_scan_service.run_folder_scan()
|
||||
logger.info("Folder scan completed successfully")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Key resolution
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def run_key_resolution(self) -> dict:
|
||||
"""Run the orphaned folder key resolution scan.
|
||||
|
||||
Returns:
|
||||
Dict with resolved/skipped/errors counts.
|
||||
"""
|
||||
from src.server.services.scheduler.key_resolution_service import (
|
||||
perform_key_resolution_scan,
|
||||
)
|
||||
|
||||
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"],
|
||||
)
|
||||
return key_stats
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Main orchestrator entry point
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def execute(self) -> dict:
|
||||
"""Execute the full rescan workflow.
|
||||
|
||||
Runs in order:
|
||||
1. anime_service.rescan()
|
||||
2. auto-download (if enabled)
|
||||
3. folder scan (if enabled)
|
||||
4. key resolution scan (always, if anime_directory configured)
|
||||
|
||||
Returns:
|
||||
Dict with duration and counts for each step.
|
||||
"""
|
||||
scan_start = datetime.now(timezone.utc)
|
||||
results = {
|
||||
"started_at": scan_start.isoformat(),
|
||||
"duration_seconds": 0.0,
|
||||
"rescan_completed": False,
|
||||
"auto_download_queued": 0,
|
||||
"folder_scan_completed": False,
|
||||
"key_resolution": {"resolved": 0, "skipped": 0, "errors": 0},
|
||||
}
|
||||
|
||||
await self._broadcast(
|
||||
"scheduled_rescan_started",
|
||||
{"timestamp": scan_start.isoformat()},
|
||||
)
|
||||
|
||||
try:
|
||||
# 1. Main library rescan
|
||||
await self._run_rescan()
|
||||
results["rescan_completed"] = True
|
||||
|
||||
# 2. Auto-download
|
||||
if self._config and self._config.auto_download_after_rescan:
|
||||
try:
|
||||
queued = await self.run_auto_download()
|
||||
results["auto_download_queued"] = queued
|
||||
await self._broadcast(
|
||||
"auto_download_started", {"queued_count": queued}
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Auto-download failed: %s", exc, exc_info=True)
|
||||
await self._broadcast(
|
||||
"auto_download_error", {"error": str(exc)}
|
||||
)
|
||||
|
||||
# 3. Folder scan
|
||||
if self._config and self._config.folder_scan_enabled:
|
||||
try:
|
||||
await self.run_folder_scan()
|
||||
results["folder_scan_completed"] = True
|
||||
except Exception as exc:
|
||||
logger.error("Folder scan failed: %s", exc, exc_info=True)
|
||||
await self._broadcast("folder_scan_error", {"error": str(exc)})
|
||||
|
||||
# 4. Key resolution scan (always runs if anime_directory configured)
|
||||
try:
|
||||
key_stats = await self.run_key_resolution()
|
||||
results["key_resolution"] = key_stats
|
||||
except Exception as exc:
|
||||
logger.error("Key resolution scan failed: %s", exc, exc_info=True)
|
||||
|
||||
self._last_scan_time = datetime.now(timezone.utc)
|
||||
results["duration_seconds"] = (
|
||||
self._last_scan_time - scan_start
|
||||
).total_seconds()
|
||||
|
||||
await self._broadcast(
|
||||
"scheduled_rescan_completed",
|
||||
{
|
||||
"timestamp": self._last_scan_time.isoformat(),
|
||||
"duration_seconds": results["duration_seconds"],
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Scheduled library rescan completed: duration=%.2fs",
|
||||
results["duration_seconds"],
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("Scheduled rescan failed: %s", exc, exc_info=True)
|
||||
await self._broadcast(
|
||||
"scheduled_rescan_error",
|
||||
{"error": str(exc), "timestamp": datetime.now(timezone.utc).isoformat()},
|
||||
)
|
||||
raise
|
||||
|
||||
return results
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Private helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _run_rescan(self) -> None:
|
||||
"""Run the anime service rescan."""
|
||||
from src.server.utils.dependencies import get_anime_service
|
||||
|
||||
anime_service = get_anime_service()
|
||||
logger.info("Anime service obtained, calling anime_service.rescan()...")
|
||||
await anime_service.rescan()
|
||||
logger.info("anime_service.rescan() completed")
|
||||
|
||||
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 get_websocket_service
|
||||
|
||||
ws_service = get_websocket_service()
|
||||
await ws_service.manager.broadcast({"type": event_type, "data": data})
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"WebSocket broadcast failed: event=%s error=%s", event_type, exc
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level orchestrator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_orchestrator: Optional[RescanOrchestrator] = None
|
||||
|
||||
|
||||
def get_rescan_orchestrator(
|
||||
config: Optional[SchedulerConfig] = None,
|
||||
) -> RescanOrchestrator:
|
||||
"""Return a RescanOrchestrator singleton (or create with optional config)."""
|
||||
global _orchestrator
|
||||
if _orchestrator is None or config is not None:
|
||||
_orchestrator = RescanOrchestrator(config=config)
|
||||
logger.debug("Created new RescanOrchestrator singleton")
|
||||
else:
|
||||
logger.debug("Returning existing RescanOrchestrator singleton")
|
||||
return _orchestrator
|
||||
|
||||
|
||||
def reset_rescan_orchestrator() -> None:
|
||||
"""Reset the orchestrator singleton (used in tests)."""
|
||||
global _orchestrator
|
||||
_orchestrator = None
|
||||
@@ -1,18 +1,19 @@
|
||||
"""Scheduler service for automatic library rescans.
|
||||
|
||||
Uses APScheduler's AsyncIOScheduler with CronTrigger for precise
|
||||
cron-based scheduling. The legacy interval-based loop has been removed
|
||||
in favour of the cron approach.
|
||||
cron-based scheduling.
|
||||
|
||||
Jobs are held in memory (no separate scheduler database). On startup,
|
||||
if the last scan timestamp indicates a missed run (server was down at the
|
||||
scheduled cron time), a rescan is triggered immediately.
|
||||
|
||||
Actual rescan logic is delegated to RescanService.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import List, Optional
|
||||
from typing import Optional
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
@@ -42,7 +43,9 @@ class SchedulerService:
|
||||
- Cron-based scheduling (time of day + days of week)
|
||||
- Immediate manual trigger
|
||||
- Live config reloading without app restart
|
||||
- Auto-queueing downloads of missing episodes after rescan
|
||||
|
||||
Actual rescan/folder-scan/auto-download work is delegated to
|
||||
RescanService.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
@@ -50,11 +53,7 @@ class SchedulerService:
|
||||
self._is_running: bool = False
|
||||
self._scheduler: Optional[AsyncIOScheduler] = None
|
||||
self._config: Optional[SchedulerConfig] = None
|
||||
self._last_scan_time: Optional[datetime] = None
|
||||
self._scan_in_progress: bool = False
|
||||
# Cooldown tracking for auto-download to prevent rapid re-triggers
|
||||
self._last_auto_download_time: Optional[datetime] = None
|
||||
self._auto_download_cooldown_seconds: int = 300 # 5 minutes default
|
||||
logger.info("SchedulerService initialised")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -82,8 +81,6 @@ class SchedulerService:
|
||||
logger.error("Failed to load scheduler configuration: %s", exc)
|
||||
raise SchedulerServiceError(f"Failed to load config: {exc}") from exc
|
||||
|
||||
# Use in-memory job store — no separate scheduler.db needed.
|
||||
# Jobs are reconstructed from config on every startup.
|
||||
self._scheduler = AsyncIOScheduler()
|
||||
|
||||
if not self._config.enabled:
|
||||
@@ -133,8 +130,7 @@ class SchedulerService:
|
||||
)
|
||||
|
||||
# Startup misfire recovery: check if the last scan was missed while
|
||||
# the server was down. If overdue by more than one interval but within
|
||||
# the grace period, trigger an immediate rescan.
|
||||
# the server was down.
|
||||
await self._check_missed_run()
|
||||
|
||||
async def stop(self) -> None:
|
||||
@@ -251,6 +247,10 @@ class SchedulerService:
|
||||
Returns:
|
||||
Dict containing scheduler state and config fields.
|
||||
"""
|
||||
from src.server.services.rescan_service import get_rescan_service
|
||||
|
||||
rescan_service = get_rescan_service()
|
||||
|
||||
next_run: Optional[str] = None
|
||||
if self._scheduler and self._scheduler.running:
|
||||
job = self._scheduler.get_job(_JOB_ID)
|
||||
@@ -269,7 +269,11 @@ class SchedulerService:
|
||||
"folder_scan_enabled": (
|
||||
self._config.folder_scan_enabled if self._config else False
|
||||
),
|
||||
"last_run": self._last_scan_time.isoformat() if self._last_scan_time else None,
|
||||
"last_run": (
|
||||
rescan_service.last_scan_time.isoformat()
|
||||
if rescan_service.last_scan_time
|
||||
else None
|
||||
),
|
||||
"next_run": next_run,
|
||||
"scan_in_progress": self._scan_in_progress,
|
||||
}
|
||||
@@ -316,10 +320,8 @@ 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
|
||||
from src.server.database.connection import get_db_session
|
||||
from src.server.database.system_settings_service import (
|
||||
SystemSettingsService,
|
||||
)
|
||||
|
||||
@@ -343,7 +345,6 @@ class SchedulerService:
|
||||
# If last scan was more than 24h + grace period ago, don't trigger
|
||||
# (avoids surprise rescans after long downtime).
|
||||
max_overdue = timedelta(hours=24, seconds=_MISFIRE_GRACE_SECONDS)
|
||||
# If last scan was more than ~25h ago, skip (too stale)
|
||||
if elapsed > max_overdue:
|
||||
logger.info(
|
||||
"Last scan was %s ago (> %s) — skipping missed-run recovery",
|
||||
@@ -353,7 +354,6 @@ class SchedulerService:
|
||||
return
|
||||
|
||||
# Check if a run should have happened between last_scan and now.
|
||||
# Simple heuristic: if elapsed > 24h, we missed at least one daily run.
|
||||
if elapsed > timedelta(hours=23):
|
||||
logger.info(
|
||||
"Missed scheduled rescan detected (last scan %s ago) — triggering now",
|
||||
@@ -364,171 +364,22 @@ class SchedulerService:
|
||||
except Exception as exc: # pylint: disable=broad-exception-caught
|
||||
logger.warning("Missed-run check failed (non-fatal): %s", exc)
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
ws_service = get_websocket_service()
|
||||
await ws_service.manager.broadcast({"type": event_type, "data": data})
|
||||
except Exception as exc: # pylint: disable=broad-exception-caught
|
||||
logger.warning("WebSocket broadcast failed: event=%s error=%s", event_type, exc)
|
||||
|
||||
async def _auto_download_missing(self) -> None:
|
||||
"""Queue and start downloads for all series with missing episodes."""
|
||||
from datetime import timedelta # noqa: PLC0415
|
||||
|
||||
from src.server.models.download import EpisodeIdentifier # noqa: PLC0415
|
||||
from src.server.utils.dependencies import ( # noqa: PLC0415
|
||||
get_anime_service,
|
||||
get_download_service,
|
||||
)
|
||||
|
||||
# Check cooldown to prevent rapid re-triggers
|
||||
now = datetime.now(timezone.utc)
|
||||
if self._last_auto_download_time is not None:
|
||||
elapsed = now - self._last_auto_download_time
|
||||
if elapsed < timedelta(seconds=self._auto_download_cooldown_seconds):
|
||||
logger.debug(
|
||||
"Auto-download skipped: cooldown active (elapsed=%.1fs cooldown=%ds)",
|
||||
elapsed.total_seconds(),
|
||||
self._auto_download_cooldown_seconds,
|
||||
)
|
||||
return
|
||||
|
||||
anime_service = get_anime_service()
|
||||
download_service = get_download_service()
|
||||
|
||||
series_list = anime_service._cached_list_missing()
|
||||
queued_count = 0
|
||||
|
||||
for series in series_list:
|
||||
episode_dict: dict = series.get("episodeDict") or {}
|
||||
if not episode_dict:
|
||||
continue
|
||||
|
||||
episodes: List[EpisodeIdentifier] = []
|
||||
for season_str, ep_numbers in episode_dict.items():
|
||||
for ep_num in ep_numbers:
|
||||
episodes.append(
|
||||
EpisodeIdentifier(season=int(season_str), episode=int(ep_num))
|
||||
)
|
||||
|
||||
if not episodes:
|
||||
continue
|
||||
|
||||
await download_service.add_to_queue(
|
||||
serie_id=series.get("key", ""),
|
||||
serie_folder=series.get("folder", series.get("name", "")),
|
||||
serie_name=series.get("name", ""),
|
||||
episodes=episodes,
|
||||
)
|
||||
queued_count += len(episodes)
|
||||
logger.info(
|
||||
"Auto-download queued episodes for series=%s count=%d",
|
||||
series.get("key"),
|
||||
len(episodes),
|
||||
)
|
||||
|
||||
if queued_count:
|
||||
await download_service.start_queue_processing()
|
||||
logger.info("Auto-download queue processing started: queued=%d", queued_count)
|
||||
|
||||
await self._broadcast("auto_download_started", {"queued_count": queued_count})
|
||||
logger.info("Auto-download completed: queued_count=%d", queued_count)
|
||||
|
||||
# Update cooldown timestamp after successful auto-download
|
||||
self._last_auto_download_time = datetime.now(timezone.utc)
|
||||
|
||||
async def _perform_rescan(self) -> None:
|
||||
"""Execute a library rescan and optionally trigger auto-download."""
|
||||
logger.info("Scheduler _perform_rescan entered: scan_in_progress=%s", self._scan_in_progress)
|
||||
"""Execute a library rescan via RescanService."""
|
||||
from src.server.services.rescan_service import get_rescan_service
|
||||
|
||||
logger.info(
|
||||
"Scheduler _perform_rescan entered: scan_in_progress=%s",
|
||||
self._scan_in_progress,
|
||||
)
|
||||
if self._scan_in_progress:
|
||||
logger.warning("Skipping rescan: previous scan still in progress")
|
||||
return
|
||||
|
||||
self._scan_in_progress = True
|
||||
scan_start = datetime.now(timezone.utc)
|
||||
logger.info("Scheduled rescan started at %s", scan_start.isoformat())
|
||||
|
||||
try:
|
||||
logger.info("Starting scheduled library rescan")
|
||||
|
||||
from src.server.utils.dependencies import get_anime_service # noqa: PLC0415
|
||||
|
||||
anime_service = get_anime_service()
|
||||
logger.info("Anime service obtained for rescan")
|
||||
|
||||
await self._broadcast(
|
||||
"scheduled_rescan_started",
|
||||
{"timestamp": scan_start.isoformat()},
|
||||
)
|
||||
|
||||
logger.info("Calling anime_service.rescan()...")
|
||||
await anime_service.rescan()
|
||||
|
||||
self._last_scan_time = datetime.now(timezone.utc)
|
||||
duration = (self._last_scan_time - scan_start).total_seconds()
|
||||
|
||||
logger.info("Scheduled library rescan completed: duration=%.2fs", duration)
|
||||
|
||||
await self._broadcast(
|
||||
"scheduled_rescan_completed",
|
||||
{
|
||||
"timestamp": self._last_scan_time.isoformat(),
|
||||
"duration_seconds": duration,
|
||||
},
|
||||
)
|
||||
|
||||
# Auto-download after rescan
|
||||
if self._config and self._config.auto_download_after_rescan:
|
||||
logger.info("Auto-download after rescan is enabled — starting")
|
||||
try:
|
||||
await self._auto_download_missing()
|
||||
except Exception as dl_exc: # pylint: disable=broad-exception-caught
|
||||
logger.error(
|
||||
"Auto-download after rescan failed: %s",
|
||||
dl_exc,
|
||||
exc_info=True,
|
||||
)
|
||||
await self._broadcast(
|
||||
"auto_download_error", {"error": str(dl_exc)}
|
||||
)
|
||||
else:
|
||||
logger.debug("Auto-download after rescan is disabled — skipping")
|
||||
|
||||
# Folder scan (daily maintenance)
|
||||
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,
|
||||
)
|
||||
|
||||
folder_scan_service = FolderScanService()
|
||||
await folder_scan_service.run_folder_scan()
|
||||
logger.info("Folder scan completed successfully")
|
||||
except Exception as fs_exc: # pylint: disable=broad-exception-caught
|
||||
logger.error(
|
||||
"Folder scan failed: %s",
|
||||
fs_exc,
|
||||
exc_info=True,
|
||||
)
|
||||
await self._broadcast(
|
||||
"folder_scan_error", {"error": str(fs_exc)}
|
||||
)
|
||||
else:
|
||||
logger.debug("Folder scan is disabled — skipping")
|
||||
|
||||
except Exception as exc: # pylint: disable=broad-exception-caught
|
||||
logger.error("Scheduled rescan failed: %s", exc, exc_info=True)
|
||||
await self._broadcast(
|
||||
"scheduled_rescan_error",
|
||||
{"error": str(exc), "timestamp": datetime.now(timezone.utc).isoformat()},
|
||||
)
|
||||
|
||||
rescan_service = get_rescan_service(config=self._config)
|
||||
await rescan_service.execute()
|
||||
finally:
|
||||
self._scan_in_progress = False
|
||||
logger.info("Scheduled rescan finished: scan_in_progress reset to False")
|
||||
@@ -21,6 +21,8 @@ from typing import Any, Dict, List, Optional
|
||||
from fastapi import Request
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from src.server.utils.version import APP_VERSION
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Configure templates directory
|
||||
@@ -48,7 +50,7 @@ def get_base_context(
|
||||
"request": request,
|
||||
"title": title,
|
||||
"app_name": "Aniworld Download Manager",
|
||||
"version": "1.0.1",
|
||||
"version": APP_VERSION,
|
||||
"static_v": STATIC_VERSION,
|
||||
}
|
||||
|
||||
|
||||
26
src/server/utils/version.py
Normal file
26
src/server/utils/version.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""Version management utilities for Aniworld application."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def get_version() -> str:
|
||||
"""
|
||||
Get the current application version from Docker/VERSION file.
|
||||
|
||||
Returns:
|
||||
Version string from the VERSION file, or "unknown" if not found.
|
||||
"""
|
||||
version_file = Path(__file__).parent.parent.parent.parent / "Docker" / "VERSION"
|
||||
|
||||
try:
|
||||
if version_file.exists():
|
||||
return version_file.read_text().strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return "unknown"
|
||||
|
||||
|
||||
# Module-level version constant (loaded once at import)
|
||||
APP_VERSION: str = get_version()
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -45,6 +45,7 @@ AniWorld.IndexApp = (function() {
|
||||
AniWorld.Search.init();
|
||||
AniWorld.ScanManager.init();
|
||||
AniWorld.ConfigManager.init();
|
||||
AniWorld.ContextMenu.init();
|
||||
|
||||
// Bind global events
|
||||
bindGlobalEvents();
|
||||
|
||||
123
src/server/web/static/js/index/context-menu.js
Normal file
123
src/server/web/static/js/index/context-menu.js
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* AniWorld - Context Menu Component
|
||||
*
|
||||
* Right-click context menu for anime series cards.
|
||||
* Provides quick access to edit metadata.
|
||||
*
|
||||
* Dependencies: ui-utils.js, edit-modal.js
|
||||
*/
|
||||
|
||||
var AniWorld = window.AniWorld || {};
|
||||
|
||||
AniWorld.ContextMenu = (function() {
|
||||
'use strict';
|
||||
|
||||
let menuElement = null;
|
||||
let currentSeriesKey = null;
|
||||
|
||||
/**
|
||||
* Initialize the context menu system.
|
||||
* Attaches global dismissal listeners.
|
||||
*/
|
||||
function init() {
|
||||
// Dismiss on click outside
|
||||
document.addEventListener('click', function(e) {
|
||||
if (menuElement && !menuElement.contains(e.target)) {
|
||||
hide();
|
||||
}
|
||||
});
|
||||
|
||||
// Dismiss on Escape
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
hide();
|
||||
}
|
||||
});
|
||||
|
||||
// Dismiss on scroll or resize
|
||||
window.addEventListener('scroll', hide, true);
|
||||
window.addEventListener('resize', hide);
|
||||
|
||||
// Attach context menu via event delegation on the series grid
|
||||
const grid = document.getElementById('series-grid');
|
||||
if (grid) {
|
||||
grid.addEventListener('contextmenu', function(e) {
|
||||
const card = e.target.closest('.series-card');
|
||||
if (card) {
|
||||
e.preventDefault();
|
||||
const key = card.getAttribute('data-key');
|
||||
if (key) {
|
||||
show(e, key);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show context menu at cursor position.
|
||||
* @param {MouseEvent} event - The contextmenu event
|
||||
* @param {string} seriesKey - The series key to operate on
|
||||
*/
|
||||
function show(event, seriesKey) {
|
||||
hide(); // Remove any existing menu first
|
||||
|
||||
currentSeriesKey = seriesKey;
|
||||
|
||||
menuElement = document.createElement('div');
|
||||
menuElement.className = 'context-menu';
|
||||
menuElement.innerHTML = `
|
||||
<div class="context-menu-item" data-action="edit">
|
||||
<i class="fa-solid fa-pen-to-square"></i>
|
||||
<span>Edit Metadata</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(menuElement);
|
||||
|
||||
// Position within viewport bounds
|
||||
const x = event.clientX;
|
||||
const y = event.clientY;
|
||||
const menuRect = menuElement.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
let posX = x;
|
||||
let posY = y;
|
||||
|
||||
if (x + menuRect.width > viewportWidth) {
|
||||
posX = viewportWidth - menuRect.width - 8;
|
||||
}
|
||||
if (y + menuRect.height > viewportHeight) {
|
||||
posY = viewportHeight - menuRect.height - 8;
|
||||
}
|
||||
|
||||
menuElement.style.left = posX + 'px';
|
||||
menuElement.style.top = posY + 'px';
|
||||
|
||||
// Attach action handlers
|
||||
menuElement.querySelector('[data-action="edit"]').addEventListener('click', function() {
|
||||
hide();
|
||||
if (AniWorld.EditModal) {
|
||||
AniWorld.EditModal.open(currentSeriesKey);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide and remove the context menu from DOM.
|
||||
*/
|
||||
function hide() {
|
||||
if (menuElement) {
|
||||
menuElement.remove();
|
||||
menuElement = null;
|
||||
currentSeriesKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
init: init,
|
||||
show: show,
|
||||
hide: hide
|
||||
};
|
||||
})();
|
||||
450
src/server/web/static/js/index/edit-modal.js
Normal file
450
src/server/web/static/js/index/edit-modal.js
Normal file
@@ -0,0 +1,450 @@
|
||||
/**
|
||||
* AniWorld - Edit Modal Component
|
||||
*
|
||||
* Modal dialog for viewing/editing anime metadata (key, tmdb_id, tvdb_id)
|
||||
* and NFO diagnostics with repair functionality.
|
||||
*
|
||||
* Dependencies: api-client.js, ui-utils.js
|
||||
*/
|
||||
|
||||
var AniWorld = window.AniWorld || {};
|
||||
|
||||
AniWorld.EditModal = (function() {
|
||||
'use strict';
|
||||
|
||||
let modalElement = null;
|
||||
let originalData = null;
|
||||
let currentKey = null;
|
||||
|
||||
/**
|
||||
* Open the edit modal for a specific anime series.
|
||||
* @param {string} seriesKey - The series key to edit
|
||||
*/
|
||||
async function open(seriesKey) {
|
||||
currentKey = seriesKey;
|
||||
modalElement = document.getElementById('edit-metadata-modal');
|
||||
if (!modalElement) return;
|
||||
|
||||
// Show modal
|
||||
modalElement.classList.remove('hidden');
|
||||
|
||||
// Reset form state
|
||||
setLoading(true);
|
||||
clearErrors();
|
||||
hideKeyWarning();
|
||||
|
||||
try {
|
||||
// Find series data from the local series list
|
||||
const seriesData = findSeriesData(seriesKey);
|
||||
|
||||
originalData = {
|
||||
key: seriesKey,
|
||||
tmdb_id: seriesData ? seriesData.tmdb_id : null,
|
||||
tvdb_id: seriesData ? seriesData.tvdb_id : null,
|
||||
};
|
||||
|
||||
// Populate form fields
|
||||
setFieldValue('edit-key', originalData.key);
|
||||
setFieldValue('edit-tmdb-id', originalData.tmdb_id || '');
|
||||
setFieldValue('edit-tvdb-id', originalData.tvdb_id || '');
|
||||
|
||||
// Load NFO diagnostics
|
||||
await loadDiagnostics(seriesKey);
|
||||
|
||||
} catch (err) {
|
||||
AniWorld.UI.showToast('Failed to load series data', 'error');
|
||||
console.error('Edit modal load error:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
// Attach event listeners
|
||||
attachListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the edit modal and reset state.
|
||||
*/
|
||||
function close() {
|
||||
if (modalElement) {
|
||||
modalElement.classList.add('hidden');
|
||||
}
|
||||
originalData = null;
|
||||
currentKey = null;
|
||||
detachListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save changed metadata to the backend.
|
||||
*/
|
||||
async function save() {
|
||||
clearErrors();
|
||||
|
||||
const newKey = getFieldValue('edit-key').trim().toLowerCase();
|
||||
const tmdbIdStr = getFieldValue('edit-tmdb-id').trim();
|
||||
const tvdbIdStr = getFieldValue('edit-tvdb-id').trim();
|
||||
|
||||
// Validate key
|
||||
if (!newKey) {
|
||||
showFieldError('edit-key', 'Key cannot be empty');
|
||||
return;
|
||||
}
|
||||
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(newKey)) {
|
||||
showFieldError('edit-key', 'Key must contain only lowercase letters, numbers, and hyphens');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate IDs
|
||||
const tmdbId = tmdbIdStr ? parseInt(tmdbIdStr, 10) : null;
|
||||
const tvdbId = tvdbIdStr ? parseInt(tvdbIdStr, 10) : null;
|
||||
|
||||
if (tmdbIdStr && (isNaN(tmdbId) || tmdbId < 1)) {
|
||||
showFieldError('edit-tmdb-id', 'TMDB ID must be a positive number');
|
||||
return;
|
||||
}
|
||||
if (tvdbIdStr && (isNaN(tvdbId) || tvdbId < 1)) {
|
||||
showFieldError('edit-tvdb-id', 'TVDB ID must be a positive number');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if key changed — show confirmation
|
||||
if (newKey !== originalData.key) {
|
||||
const confirmed = await AniWorld.UI.showConfirmModal(
|
||||
'Rename Series Key',
|
||||
`Changing the key from "${originalData.key}" to "${newKey}" will update the primary identifier. ` +
|
||||
'This may affect provider linkage. Are you sure?'
|
||||
);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
// Build update payload (only changed fields)
|
||||
const payload = {};
|
||||
if (newKey !== originalData.key) payload.key = newKey;
|
||||
if (tmdbId !== originalData.tmdb_id) payload.tmdb_id = tmdbId;
|
||||
if (tvdbId !== originalData.tvdb_id) payload.tvdb_id = tvdbId;
|
||||
|
||||
if (Object.keys(payload).length === 0) {
|
||||
AniWorld.UI.showToast('No changes to save', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
// Send update
|
||||
setSaveLoading(true);
|
||||
try {
|
||||
const response = await AniWorld.ApiClient.put(
|
||||
'/api/anime/' + encodeURIComponent(currentKey),
|
||||
payload
|
||||
);
|
||||
|
||||
if (!response) return;
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
AniWorld.UI.showToast('Metadata updated successfully', 'success');
|
||||
|
||||
// Update local state
|
||||
const oldKey = currentKey;
|
||||
currentKey = result.key;
|
||||
originalData = {
|
||||
key: result.key,
|
||||
tmdb_id: result.tmdb_id,
|
||||
tvdb_id: result.tvdb_id,
|
||||
};
|
||||
|
||||
// Update the card in the DOM
|
||||
updateCardAfterSave(oldKey, result);
|
||||
|
||||
// Update repair button state
|
||||
updateRepairButtonState();
|
||||
|
||||
} else if (response.status === 409) {
|
||||
showFieldError('edit-key', 'A series with this key already exists');
|
||||
} else if (response.status === 422) {
|
||||
const err = await response.json();
|
||||
AniWorld.UI.showToast('Validation error: ' + (err.detail || 'Invalid input'), 'error');
|
||||
} else {
|
||||
AniWorld.UI.showToast('Failed to update metadata', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
AniWorld.UI.showToast('Connection error. Check your network.', 'error');
|
||||
console.error('Save error:', err);
|
||||
} finally {
|
||||
setSaveLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger NFO repair for the current series.
|
||||
*/
|
||||
async function repairNfo() {
|
||||
setRepairLoading(true);
|
||||
try {
|
||||
const response = await AniWorld.ApiClient.post(
|
||||
'/api/nfo/' + encodeURIComponent(currentKey) + '/repair',
|
||||
{}
|
||||
);
|
||||
|
||||
if (!response) return;
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
AniWorld.UI.showToast(result.message, 'success');
|
||||
|
||||
// Refresh diagnostics
|
||||
await loadDiagnostics(currentKey);
|
||||
} else if (response.status === 400) {
|
||||
const err = await response.json();
|
||||
AniWorld.UI.showToast(err.detail || 'Cannot repair NFO', 'error');
|
||||
} else {
|
||||
AniWorld.UI.showToast('Failed to repair NFO', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
AniWorld.UI.showToast('Connection error during repair', 'error');
|
||||
console.error('Repair error:', err);
|
||||
} finally {
|
||||
setRepairLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load NFO diagnostics for the current series.
|
||||
* @param {string} key - Series key
|
||||
*/
|
||||
async function loadDiagnostics(key) {
|
||||
const container = document.getElementById('nfo-diagnostics-container');
|
||||
if (!container) return;
|
||||
|
||||
try {
|
||||
const response = await AniWorld.ApiClient.get(
|
||||
'/api/nfo/' + encodeURIComponent(key) + '/diagnostics'
|
||||
);
|
||||
|
||||
if (!response || !response.ok) {
|
||||
container.innerHTML = '<p class="nfo-error">Failed to load NFO diagnostics</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
renderDiagnostics(data);
|
||||
updateRepairButtonState();
|
||||
|
||||
} catch (err) {
|
||||
container.innerHTML = '<p class="nfo-error">Error loading diagnostics</p>';
|
||||
console.error('Diagnostics error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render NFO diagnostics data into the modal.
|
||||
* @param {Object} data - NfoDiagnosticsResponse
|
||||
*/
|
||||
function renderDiagnostics(data) {
|
||||
const badge = document.getElementById('nfo-status-badge');
|
||||
const tagsList = document.getElementById('nfo-missing-tags');
|
||||
|
||||
if (badge) {
|
||||
if (!data.has_nfo) {
|
||||
badge.className = 'nfo-status-badge nfo-missing';
|
||||
badge.textContent = 'No NFO File';
|
||||
} else if (data.missing_tags.length === 0) {
|
||||
badge.className = 'nfo-status-badge nfo-complete';
|
||||
badge.textContent = 'Complete';
|
||||
} else {
|
||||
badge.className = 'nfo-status-badge nfo-incomplete';
|
||||
badge.textContent = data.missing_tags.length + ' Missing';
|
||||
}
|
||||
}
|
||||
|
||||
if (tagsList) {
|
||||
if (data.missing_tags.length === 0) {
|
||||
tagsList.innerHTML = '<p class="nfo-all-good">All required tags present</p>';
|
||||
} else {
|
||||
tagsList.innerHTML = data.missing_tags.map(function(tag) {
|
||||
return '<span class="missing-tag-chip">' + escapeHtml(tag) + '</span>';
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update repair button disabled state based on tmdb_id field.
|
||||
*/
|
||||
function updateRepairButtonState() {
|
||||
const btn = document.getElementById('btn-repair-nfo');
|
||||
const hint = document.getElementById('repair-hint');
|
||||
const tmdbValue = getFieldValue('edit-tmdb-id').trim();
|
||||
|
||||
if (btn) {
|
||||
// Enable repair even without tmdb_id — the service can search by name
|
||||
btn.disabled = false;
|
||||
}
|
||||
if (hint) {
|
||||
hint.style.display = tmdbValue ? 'none' : 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
function findSeriesData(key) {
|
||||
// Access the series data from the series manager if available
|
||||
if (AniWorld.SeriesManager && AniWorld.SeriesManager.getSeriesData) {
|
||||
const allSeries = AniWorld.SeriesManager.getSeriesData();
|
||||
if (allSeries) {
|
||||
return allSeries.find(function(s) { return s.key === key; });
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function updateCardAfterSave(oldKey, result) {
|
||||
const card = document.querySelector('[data-series-id="' + oldKey + '"]');
|
||||
if (card) {
|
||||
card.setAttribute('data-key', result.key);
|
||||
card.setAttribute('data-series-id', result.key);
|
||||
// Update checkbox data-key
|
||||
const checkbox = card.querySelector('.series-checkbox');
|
||||
if (checkbox) {
|
||||
checkbox.setAttribute('data-key', result.key);
|
||||
}
|
||||
}
|
||||
|
||||
// Update local series data array
|
||||
if (AniWorld.SeriesManager && AniWorld.SeriesManager.updateSeriesKey) {
|
||||
AniWorld.SeriesManager.updateSeriesKey(oldKey, result.key);
|
||||
}
|
||||
}
|
||||
|
||||
function setFieldValue(id, value) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = value !== null && value !== undefined ? value : '';
|
||||
}
|
||||
|
||||
function getFieldValue(id) {
|
||||
const el = document.getElementById(id);
|
||||
return el ? el.value : '';
|
||||
}
|
||||
|
||||
function showFieldError(fieldId, message) {
|
||||
const el = document.getElementById(fieldId);
|
||||
if (el) {
|
||||
const errorEl = el.parentElement.querySelector('.field-error');
|
||||
if (errorEl) {
|
||||
errorEl.textContent = message;
|
||||
errorEl.style.display = 'block';
|
||||
}
|
||||
el.classList.add('input-error');
|
||||
}
|
||||
}
|
||||
|
||||
function clearErrors() {
|
||||
if (!modalElement) return;
|
||||
modalElement.querySelectorAll('.field-error').forEach(function(el) {
|
||||
el.style.display = 'none';
|
||||
el.textContent = '';
|
||||
});
|
||||
modalElement.querySelectorAll('.input-error').forEach(function(el) {
|
||||
el.classList.remove('input-error');
|
||||
});
|
||||
}
|
||||
|
||||
function hideKeyWarning() {
|
||||
const warning = document.getElementById('key-change-warning');
|
||||
if (warning) warning.style.display = 'none';
|
||||
}
|
||||
|
||||
function setLoading(loading) {
|
||||
const form = document.getElementById('edit-metadata-form');
|
||||
if (form) {
|
||||
form.style.opacity = loading ? '0.5' : '1';
|
||||
form.style.pointerEvents = loading ? 'none' : 'auto';
|
||||
}
|
||||
}
|
||||
|
||||
function setSaveLoading(loading) {
|
||||
const btn = document.getElementById('btn-save-metadata');
|
||||
if (btn) {
|
||||
btn.disabled = loading;
|
||||
btn.innerHTML = loading
|
||||
? '<i class="fa-solid fa-spinner fa-spin"></i> Saving...'
|
||||
: '<i class="fa-solid fa-floppy-disk"></i> Save';
|
||||
}
|
||||
}
|
||||
|
||||
function setRepairLoading(loading) {
|
||||
const btn = document.getElementById('btn-repair-nfo');
|
||||
if (btn) {
|
||||
btn.disabled = loading;
|
||||
btn.innerHTML = loading
|
||||
? '<i class="fa-solid fa-spinner fa-spin"></i> Repairing...'
|
||||
: '<i class="fa-solid fa-wrench"></i> Repair NFO';
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
var div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Event listener management
|
||||
let listeners = [];
|
||||
|
||||
function attachListeners() {
|
||||
detachListeners();
|
||||
|
||||
const saveBtn = document.getElementById('btn-save-metadata');
|
||||
const cancelBtn = document.getElementById('btn-cancel-metadata');
|
||||
const repairBtn = document.getElementById('btn-repair-nfo');
|
||||
const overlay = modalElement ? modalElement.querySelector('.modal-overlay') : null;
|
||||
const keyInput = document.getElementById('edit-key');
|
||||
|
||||
if (saveBtn) {
|
||||
var saveFn = function() { save(); };
|
||||
saveBtn.addEventListener('click', saveFn);
|
||||
listeners.push({ el: saveBtn, event: 'click', fn: saveFn });
|
||||
}
|
||||
|
||||
if (cancelBtn) {
|
||||
var cancelFn = function() { close(); };
|
||||
cancelBtn.addEventListener('click', cancelFn);
|
||||
listeners.push({ el: cancelBtn, event: 'click', fn: cancelFn });
|
||||
}
|
||||
|
||||
if (repairBtn) {
|
||||
var repairFn = function() { repairNfo(); };
|
||||
repairBtn.addEventListener('click', repairFn);
|
||||
listeners.push({ el: repairBtn, event: 'click', fn: repairFn });
|
||||
}
|
||||
|
||||
if (overlay) {
|
||||
var overlayFn = function() { close(); };
|
||||
overlay.addEventListener('click', overlayFn);
|
||||
listeners.push({ el: overlay, event: 'click', fn: overlayFn });
|
||||
}
|
||||
|
||||
if (keyInput) {
|
||||
var keyFn = function() {
|
||||
var warning = document.getElementById('key-change-warning');
|
||||
if (warning) {
|
||||
warning.style.display = keyInput.value !== originalData.key ? 'block' : 'none';
|
||||
}
|
||||
};
|
||||
keyInput.addEventListener('input', keyFn);
|
||||
listeners.push({ el: keyInput, event: 'input', fn: keyFn });
|
||||
}
|
||||
}
|
||||
|
||||
function detachListeners() {
|
||||
listeners.forEach(function(l) {
|
||||
l.el.removeEventListener(l.event, l.fn);
|
||||
});
|
||||
listeners = [];
|
||||
}
|
||||
|
||||
return {
|
||||
open: open,
|
||||
close: close,
|
||||
save: save,
|
||||
repairNfo: repairNfo
|
||||
};
|
||||
})();
|
||||
@@ -40,31 +40,6 @@ AniWorld.SeriesManager = (function() {
|
||||
if (sortBtn) {
|
||||
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
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -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>
|
||||
|
||||
255
tests/api/test_anime_edit_endpoints.py
Normal file
255
tests/api/test_anime_edit_endpoints.py
Normal file
@@ -0,0 +1,255 @@
|
||||
"""Tests for anime metadata edit (PUT /api/anime/{anime_key}) endpoint."""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from src.server.fastapi_app import app
|
||||
from src.server.services.auth_service import auth_service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reset_auth():
|
||||
"""Reset auth state before each test."""
|
||||
auth_service._hash = None
|
||||
auth_service._failed = {}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
"""Create async test client."""
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def authenticated_client(client):
|
||||
"""Get authenticated client with Bearer token."""
|
||||
# Setup auth
|
||||
await client.post("/api/auth/setup", json={"master_password": "TestPass123!"})
|
||||
response = await client.post(
|
||||
"/api/auth/login", json={"password": "TestPass123!"}
|
||||
)
|
||||
token = response.json()["access_token"]
|
||||
client.headers["Authorization"] = f"Bearer {token}"
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db_session():
|
||||
"""Create a mock async database session."""
|
||||
session = AsyncMock()
|
||||
session.commit = AsyncMock()
|
||||
session.flush = AsyncMock()
|
||||
session.refresh = AsyncMock()
|
||||
return session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_series_in_db():
|
||||
"""Create a mock AnimeSeries DB record."""
|
||||
series = MagicMock()
|
||||
series.id = 1
|
||||
series.key = "test-anime"
|
||||
series.name = "Test Anime"
|
||||
series.tmdb_id = 1234
|
||||
series.tvdb_id = 5678
|
||||
series.folder = "Test Anime (2023)"
|
||||
return series
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def override_db_dependency(mock_db_session):
|
||||
"""Override database session dependency."""
|
||||
from src.server.utils.dependencies import get_database_session
|
||||
|
||||
app.dependency_overrides[get_database_session] = lambda: mock_db_session
|
||||
yield mock_db_session
|
||||
app.dependency_overrides.pop(get_database_session, None)
|
||||
|
||||
|
||||
class TestUpdateAnimeMetadata:
|
||||
"""Tests for PUT /api/anime/{anime_key}."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_tmdb_id_success(
|
||||
self, reset_auth, authenticated_client, override_db_dependency, mock_series_in_db
|
||||
):
|
||||
"""Test successful tmdb_id update."""
|
||||
with patch(
|
||||
"src.server.api.anime.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_series_in_db,
|
||||
), patch(
|
||||
"src.server.api.anime.AnimeSeriesService.update",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_update:
|
||||
mock_series_in_db.tmdb_id = 9999
|
||||
mock_update.return_value = mock_series_in_db
|
||||
|
||||
response = await authenticated_client.put(
|
||||
"/api/anime/test-anime",
|
||||
json={"tmdb_id": 9999},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["tmdb_id"] == 9999
|
||||
assert data["message"] == "Metadata updated successfully"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_tvdb_id_success(
|
||||
self, reset_auth, authenticated_client, override_db_dependency, mock_series_in_db
|
||||
):
|
||||
"""Test successful tvdb_id update."""
|
||||
with patch(
|
||||
"src.server.api.anime.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_series_in_db,
|
||||
), patch(
|
||||
"src.server.api.anime.AnimeSeriesService.update",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_update:
|
||||
mock_series_in_db.tvdb_id = 7777
|
||||
mock_update.return_value = mock_series_in_db
|
||||
|
||||
response = await authenticated_client.put(
|
||||
"/api/anime/test-anime",
|
||||
json={"tvdb_id": 7777},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["tvdb_id"] == 7777
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_key_success(
|
||||
self, reset_auth, authenticated_client, override_db_dependency, mock_series_in_db
|
||||
):
|
||||
"""Test successful key rename."""
|
||||
with patch(
|
||||
"src.server.api.anime.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get:
|
||||
# First call finds the series, second call checks uniqueness (returns None)
|
||||
mock_get.side_effect = [mock_series_in_db, None]
|
||||
|
||||
mock_series_in_db.key = "new-anime-key"
|
||||
with patch(
|
||||
"src.server.api.anime.AnimeSeriesService.update",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_series_in_db,
|
||||
):
|
||||
response = await authenticated_client.put(
|
||||
"/api/anime/test-anime",
|
||||
json={"key": "new-anime-key"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["key"] == "new-anime-key"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_key_conflict_409(
|
||||
self, reset_auth, authenticated_client, override_db_dependency, mock_series_in_db
|
||||
):
|
||||
"""Test key rename conflict returns 409."""
|
||||
existing_series = MagicMock()
|
||||
existing_series.key = "existing-key"
|
||||
|
||||
with patch(
|
||||
"src.server.api.anime.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get:
|
||||
# First call finds original series, second call finds conflict
|
||||
mock_get.side_effect = [mock_series_in_db, existing_series]
|
||||
|
||||
response = await authenticated_client.put(
|
||||
"/api/anime/test-anime",
|
||||
json={"key": "existing-key"},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert "already exists" in response.json()["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_key_invalid_chars_422(
|
||||
self, reset_auth, authenticated_client, override_db_dependency
|
||||
):
|
||||
"""Test key with invalid characters returns 422."""
|
||||
response = await authenticated_client.put(
|
||||
"/api/anime/test-anime",
|
||||
json={"key": "Invalid Key With Spaces!"},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_key_empty_422(
|
||||
self, reset_auth, authenticated_client, override_db_dependency
|
||||
):
|
||||
"""Test empty key returns 422."""
|
||||
response = await authenticated_client.put(
|
||||
"/api/anime/test-anime",
|
||||
json={"key": ""},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_unauthenticated_401(self, reset_auth, client):
|
||||
"""Test unauthenticated access returns 401."""
|
||||
response = await client.put(
|
||||
"/api/anime/test-anime",
|
||||
json={"tmdb_id": 1234},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_nonexistent_anime_404(
|
||||
self, reset_auth, authenticated_client, override_db_dependency
|
||||
):
|
||||
"""Test update of non-existent anime returns 404."""
|
||||
with patch(
|
||||
"src.server.api.anime.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
):
|
||||
response = await authenticated_client.put(
|
||||
"/api/anime/nonexistent-key",
|
||||
json={"tmdb_id": 1234},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_no_changes(
|
||||
self, reset_auth, authenticated_client, override_db_dependency, mock_series_in_db
|
||||
):
|
||||
"""Test sending empty body returns no-op response."""
|
||||
with patch(
|
||||
"src.server.api.anime.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_series_in_db,
|
||||
):
|
||||
response = await authenticated_client.put(
|
||||
"/api/anime/test-anime",
|
||||
json={},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["message"] == "No changes"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_negative_tmdb_id_422(
|
||||
self, reset_auth, authenticated_client, override_db_dependency
|
||||
):
|
||||
"""Test negative TMDB ID returns 422."""
|
||||
response = await authenticated_client.put(
|
||||
"/api/anime/test-anime",
|
||||
json={"tmdb_id": -5},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
@@ -216,7 +216,7 @@ async def test_update_config_with_anime_directory_starts_scheduler(
|
||||
"""PUT /api/config with anime_directory syncs and starts scheduler."""
|
||||
mock_scheduler = AsyncMock()
|
||||
|
||||
with patch("src.server.services.scheduler_service.get_scheduler_service") as mock_sched_fn:
|
||||
with patch("src.server.services.scheduler.scheduler_service.get_scheduler_service") as mock_sched_fn:
|
||||
mock_sched_fn.return_value = mock_scheduler
|
||||
|
||||
with patch("src.config.settings.settings") as mock_settings:
|
||||
@@ -238,7 +238,7 @@ async def test_update_config_without_anime_directory_does_not_start_scheduler(
|
||||
"""PUT /api/config without new anime_directory does not call scheduler.ensure_started()."""
|
||||
mock_scheduler = AsyncMock()
|
||||
|
||||
with patch("src.server.services.scheduler_service.get_scheduler_service") as mock_sched_fn:
|
||||
with patch("src.server.services.scheduler.scheduler_service.get_scheduler_service") as mock_sched_fn:
|
||||
mock_sched_fn.return_value = mock_scheduler
|
||||
|
||||
with patch("src.config.settings.settings") as mock_settings:
|
||||
|
||||
317
tests/api/test_nfo_diagnostics_repair.py
Normal file
317
tests/api/test_nfo_diagnostics_repair.py
Normal file
@@ -0,0 +1,317 @@
|
||||
"""Tests for NFO diagnostics and repair API endpoints."""
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from src.server.fastapi_app import app
|
||||
from src.server.services.auth_service import auth_service
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_auth():
|
||||
"""Reset authentication state before each test."""
|
||||
original_hash = auth_service._hash
|
||||
auth_service._hash = None
|
||||
auth_service._failed.clear()
|
||||
yield
|
||||
auth_service._hash = original_hash
|
||||
auth_service._failed.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
"""Create an async test client."""
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def authenticated_client(client):
|
||||
"""Create an authenticated test client with token."""
|
||||
await client.post(
|
||||
"/api/auth/setup",
|
||||
json={"master_password": "TestPassword123!"}
|
||||
)
|
||||
response = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"password": "TestPassword123!"}
|
||||
)
|
||||
token = response.json()["access_token"]
|
||||
client.headers.update({"Authorization": f"Bearer {token}"})
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_series_app():
|
||||
"""Create mock series app with one test series."""
|
||||
app_mock = Mock()
|
||||
serie = Mock()
|
||||
serie.key = "test-anime"
|
||||
serie.folder = "Test Anime (2024)"
|
||||
serie.name = "Test Anime"
|
||||
serie.ensure_folder_with_year = Mock(return_value="Test Anime (2024)")
|
||||
|
||||
list_manager = Mock()
|
||||
list_manager.GetList = Mock(return_value=[serie])
|
||||
app_mock.list = list_manager
|
||||
|
||||
return app_mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_nfo_service():
|
||||
"""Create mock NFO service."""
|
||||
service = Mock()
|
||||
service.check_nfo_exists = AsyncMock(return_value=False)
|
||||
service.create_tvshow_nfo = AsyncMock(return_value="/path/to/tvshow.nfo")
|
||||
service.update_tvshow_nfo = AsyncMock(return_value="/path/to/tvshow.nfo")
|
||||
return service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def override_dependencies(mock_series_app, mock_nfo_service):
|
||||
"""Override dependencies for NFO tests."""
|
||||
from src.server.api.nfo import get_nfo_service
|
||||
from src.server.utils.dependencies import get_series_app
|
||||
|
||||
app.dependency_overrides[get_series_app] = lambda: mock_series_app
|
||||
app.dependency_overrides[get_nfo_service] = lambda: mock_nfo_service
|
||||
|
||||
yield
|
||||
|
||||
if get_series_app in app.dependency_overrides:
|
||||
del app.dependency_overrides[get_series_app]
|
||||
if get_nfo_service in app.dependency_overrides:
|
||||
del app.dependency_overrides[get_nfo_service]
|
||||
|
||||
|
||||
class TestNfoDiagnostics:
|
||||
"""Tests for GET /api/nfo/{serie_key}/diagnostics."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diagnostics_complete_nfo(
|
||||
self, authenticated_client, override_dependencies
|
||||
):
|
||||
"""Test diagnostics with complete NFO returns no missing tags."""
|
||||
with patch(
|
||||
"src.server.api.nfo.Path.exists", return_value=True
|
||||
), patch(
|
||||
"src.server.api.nfo.find_missing_tags", return_value=[]
|
||||
):
|
||||
response = await authenticated_client.get(
|
||||
"/api/nfo/test-anime/diagnostics"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["has_nfo"] is True
|
||||
assert data["missing_tags"] == []
|
||||
assert len(data["required_tags"]) > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diagnostics_missing_tags(
|
||||
self, authenticated_client, override_dependencies
|
||||
):
|
||||
"""Test diagnostics with missing tags returns them."""
|
||||
with patch(
|
||||
"src.server.api.nfo.Path.exists", return_value=True
|
||||
), patch(
|
||||
"src.server.api.nfo.find_missing_tags",
|
||||
return_value=["plot", "genre", "actor/name"],
|
||||
):
|
||||
response = await authenticated_client.get(
|
||||
"/api/nfo/test-anime/diagnostics"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["has_nfo"] is True
|
||||
assert "plot" in data["missing_tags"]
|
||||
assert "genre" in data["missing_tags"]
|
||||
assert len(data["missing_tags"]) == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diagnostics_no_nfo_file(
|
||||
self, authenticated_client, override_dependencies
|
||||
):
|
||||
"""Test diagnostics when no NFO exists returns all tags as missing."""
|
||||
with patch("src.server.api.nfo.Path") as MockPath:
|
||||
# Make nfo_path.exists() return False
|
||||
mock_path_instance = Mock()
|
||||
mock_path_instance.exists.return_value = False
|
||||
mock_path_instance.__truediv__ = Mock(return_value=mock_path_instance)
|
||||
MockPath.return_value = mock_path_instance
|
||||
|
||||
response = await authenticated_client.get(
|
||||
"/api/nfo/test-anime/diagnostics"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["has_nfo"] is False
|
||||
assert len(data["missing_tags"]) > 0
|
||||
# All required tags should be listed as missing
|
||||
assert data["missing_tags"] == data["required_tags"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diagnostics_nonexistent_series_404(
|
||||
self, authenticated_client, override_dependencies, mock_series_app
|
||||
):
|
||||
"""Test diagnostics for non-existent series returns 404."""
|
||||
# Override to return empty list
|
||||
mock_series_app.list.GetList.return_value = []
|
||||
|
||||
response = await authenticated_client.get(
|
||||
"/api/nfo/nonexistent-key/diagnostics"
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diagnostics_unauthenticated_401(self, client):
|
||||
"""Test diagnostics requires authentication."""
|
||||
response = await client.get("/api/nfo/test-anime/diagnostics")
|
||||
# May return 401 or 503 depending on NFO service availability
|
||||
assert response.status_code in (401, 503)
|
||||
|
||||
|
||||
class TestNfoRepair:
|
||||
"""Tests for POST /api/nfo/{serie_key}/repair."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repair_success(
|
||||
self, authenticated_client, override_dependencies
|
||||
):
|
||||
"""Test successful NFO repair."""
|
||||
with patch("src.server.api.nfo.Path") as MockPath:
|
||||
mock_path = Mock()
|
||||
mock_path.exists.return_value = True
|
||||
mock_path.__truediv__ = Mock(return_value=mock_path)
|
||||
MockPath.return_value = mock_path
|
||||
|
||||
with patch(
|
||||
"src.server.api.nfo.find_missing_tags",
|
||||
return_value=["plot", "genre"],
|
||||
), patch(
|
||||
"src.server.api.nfo.NfoRepairService"
|
||||
) as MockRepairService:
|
||||
mock_instance = Mock()
|
||||
mock_instance.repair_series = AsyncMock(return_value=True)
|
||||
MockRepairService.return_value = mock_instance
|
||||
|
||||
response = await authenticated_client.post(
|
||||
"/api/nfo/test-anime/repair", json={}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert "2" in data["message"] # "Fixed 2 missing tags"
|
||||
assert "plot" in data["repaired_tags"]
|
||||
assert "genre" in data["repaired_tags"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repair_already_complete(
|
||||
self, authenticated_client, override_dependencies
|
||||
):
|
||||
"""Test repair when NFO is already complete."""
|
||||
with patch("src.server.api.nfo.Path") as MockPath:
|
||||
mock_path = Mock()
|
||||
mock_path.exists.return_value = True
|
||||
mock_path.__truediv__ = Mock(return_value=mock_path)
|
||||
MockPath.return_value = mock_path
|
||||
|
||||
with patch(
|
||||
"src.server.api.nfo.find_missing_tags", return_value=[]
|
||||
), patch(
|
||||
"src.server.api.nfo.NfoRepairService"
|
||||
) as MockRepairService:
|
||||
mock_instance = Mock()
|
||||
mock_instance.repair_series = AsyncMock(return_value=False)
|
||||
MockRepairService.return_value = mock_instance
|
||||
|
||||
response = await authenticated_client.post(
|
||||
"/api/nfo/test-anime/repair", json={}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert "already complete" in data["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repair_creates_new_nfo(
|
||||
self, authenticated_client, override_dependencies, mock_nfo_service
|
||||
):
|
||||
"""Test repair when no NFO exists creates a new one."""
|
||||
with patch("src.server.api.nfo.Path") as MockPath:
|
||||
mock_path = Mock()
|
||||
mock_path.exists.return_value = False
|
||||
mock_path.__truediv__ = Mock(return_value=mock_path)
|
||||
MockPath.return_value = mock_path
|
||||
|
||||
with patch(
|
||||
"src.server.api.nfo.REQUIRED_TAGS",
|
||||
{"./title": "title", "./plot": "plot"},
|
||||
):
|
||||
response = await authenticated_client.post(
|
||||
"/api/nfo/test-anime/repair", json={}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
mock_nfo_service.create_tvshow_nfo.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repair_nonexistent_series_404(
|
||||
self, authenticated_client, override_dependencies, mock_series_app
|
||||
):
|
||||
"""Test repair for non-existent series returns 404."""
|
||||
mock_series_app.list.GetList.return_value = []
|
||||
|
||||
response = await authenticated_client.post(
|
||||
"/api/nfo/nonexistent-key/repair", json={}
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repair_unauthenticated_401(self, client):
|
||||
"""Test repair requires authentication."""
|
||||
response = await client.post("/api/nfo/test-anime/repair", json={})
|
||||
assert response.status_code in (401, 503)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repair_tmdb_api_failure(
|
||||
self, authenticated_client, override_dependencies
|
||||
):
|
||||
"""Test repair handles TMDB API failure gracefully."""
|
||||
from src.core.services.tmdb_client import TMDBAPIError
|
||||
|
||||
with patch("src.server.api.nfo.Path") as MockPath:
|
||||
mock_path = Mock()
|
||||
mock_path.exists.return_value = True
|
||||
mock_path.__truediv__ = Mock(return_value=mock_path)
|
||||
MockPath.return_value = mock_path
|
||||
|
||||
with patch(
|
||||
"src.server.api.nfo.find_missing_tags",
|
||||
return_value=["plot"],
|
||||
), patch(
|
||||
"src.server.api.nfo.NfoRepairService"
|
||||
) as MockRepairService:
|
||||
mock_instance = Mock()
|
||||
mock_instance.repair_series = AsyncMock(
|
||||
side_effect=TMDBAPIError("No TMDB ID found")
|
||||
)
|
||||
MockRepairService.return_value = mock_instance
|
||||
|
||||
response = await authenticated_client.post(
|
||||
"/api/nfo/test-anime/repair", json={}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "Cannot repair NFO" in response.json()["detail"]
|
||||
115
tests/frontend/test_edit_modal.py
Normal file
115
tests/frontend/test_edit_modal.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""Frontend tests for the edit metadata modal HTML structure."""
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from src.server.fastapi_app import app
|
||||
from src.server.services.auth_service import auth_service
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_auth():
|
||||
"""Reset authentication state before each test."""
|
||||
original_hash = auth_service._hash
|
||||
auth_service._hash = None
|
||||
auth_service._failed.clear()
|
||||
yield
|
||||
auth_service._hash = original_hash
|
||||
auth_service._failed.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
"""Create an async test client."""
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def authenticated_client(client):
|
||||
"""Create authenticated client to access index page."""
|
||||
await client.post(
|
||||
"/api/auth/setup",
|
||||
json={"master_password": "TestPassword123!"}
|
||||
)
|
||||
response = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"password": "TestPassword123!"}
|
||||
)
|
||||
token = response.json()["access_token"]
|
||||
client.headers.update({"Authorization": f"Bearer {token}"})
|
||||
# Set cookie for page access
|
||||
client.cookies.set("access_token", token)
|
||||
yield client
|
||||
|
||||
|
||||
class TestEditModalHtmlPresence:
|
||||
"""Tests verifying edit modal HTML elements exist in index page."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_index_page_contains_edit_modal(self, authenticated_client):
|
||||
"""Verify #edit-metadata-modal exists in rendered index page."""
|
||||
response = await authenticated_client.get("/")
|
||||
|
||||
# Page may redirect or require different auth for HTML pages
|
||||
if response.status_code == 200:
|
||||
html = response.text
|
||||
assert 'id="edit-metadata-modal"' in html
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_index_page_loads_context_menu_script(self, authenticated_client):
|
||||
"""Verify context-menu.js script tag is present."""
|
||||
response = await authenticated_client.get("/")
|
||||
|
||||
if response.status_code == 200:
|
||||
html = response.text
|
||||
assert "context-menu.js" in html
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_index_page_loads_edit_modal_script(self, authenticated_client):
|
||||
"""Verify edit-modal.js script tag is present."""
|
||||
response = await authenticated_client.get("/")
|
||||
|
||||
if response.status_code == 200:
|
||||
html = response.text
|
||||
assert "edit-modal.js" in html
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modal_form_fields_present(self, authenticated_client):
|
||||
"""Verify key, tmdb_id, tvdb_id input fields exist in modal."""
|
||||
response = await authenticated_client.get("/")
|
||||
|
||||
if response.status_code == 200:
|
||||
html = response.text
|
||||
assert 'id="edit-key"' in html
|
||||
assert 'id="edit-tmdb-id"' in html
|
||||
assert 'id="edit-tvdb-id"' in html
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nfo_repair_button_present(self, authenticated_client):
|
||||
"""Verify repair NFO button exists in modal."""
|
||||
response = await authenticated_client.get("/")
|
||||
|
||||
if response.status_code == 200:
|
||||
html = response.text
|
||||
assert 'id="btn-repair-nfo"' in html
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_button_present(self, authenticated_client):
|
||||
"""Verify save button exists in modal."""
|
||||
response = await authenticated_client.get("/")
|
||||
|
||||
if response.status_code == 200:
|
||||
html = response.text
|
||||
assert 'id="btn-save-metadata"' in html
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modal_starts_hidden(self, authenticated_client):
|
||||
"""Verify modal has hidden class by default."""
|
||||
response = await authenticated_client.get("/")
|
||||
|
||||
if response.status_code == 200:
|
||||
html = response.text
|
||||
assert 'id="edit-metadata-modal" class="modal hidden"' in html
|
||||
@@ -17,7 +17,7 @@ class TestFolderRenameScanCalledInFolderScan:
|
||||
import importlib
|
||||
|
||||
source = importlib.util.find_spec(
|
||||
"src.server.services.folder_scan_service"
|
||||
"src.server.services.scheduler.folder_scan_service"
|
||||
).origin
|
||||
with open(source, "r", encoding="utf-8") as fh:
|
||||
content = fh.read()
|
||||
@@ -31,7 +31,7 @@ class TestFolderRenameScanCalledInFolderScan:
|
||||
import importlib
|
||||
|
||||
source = importlib.util.find_spec(
|
||||
"src.server.services.folder_scan_service"
|
||||
"src.server.services.scheduler.folder_scan_service"
|
||||
).origin
|
||||
with open(source, "r", encoding="utf-8") as fh:
|
||||
content = fh.read()
|
||||
@@ -52,7 +52,7 @@ class TestFolderRenameIntegration:
|
||||
@pytest.mark.asyncio
|
||||
async def test_folder_rename_runs_during_scan(self, tmp_path):
|
||||
"""When folder_scan_enabled is true, the scan renames mismatched folders."""
|
||||
from src.server.services.folder_scan_service import FolderScanService
|
||||
from src.server.services.scheduler.folder_scan_service import FolderScanService
|
||||
|
||||
anime_dir = tmp_path / "anime"
|
||||
anime_dir.mkdir()
|
||||
@@ -69,15 +69,15 @@ class TestFolderRenameIntegration:
|
||||
with patch(
|
||||
"src.config.settings.settings", mock_settings
|
||||
), patch(
|
||||
"src.server.services.folder_rename_service.settings", mock_settings
|
||||
"src.server.services.scheduler.folder_rename_service.settings", mock_settings
|
||||
), patch(
|
||||
"src.server.services.folder_scan_service.perform_nfo_repair_scan",
|
||||
"src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan",
|
||||
new_callable=AsyncMock,
|
||||
), patch(
|
||||
"src.server.services.folder_rename_service._is_series_being_downloaded",
|
||||
"src.server.services.scheduler.folder_rename_service._is_series_being_downloaded",
|
||||
return_value=False,
|
||||
), patch(
|
||||
"src.server.services.folder_rename_service._update_database_paths",
|
||||
"src.server.services.scheduler.folder_rename_service._update_database_paths",
|
||||
new_callable=AsyncMock,
|
||||
):
|
||||
service = FolderScanService()
|
||||
@@ -89,7 +89,7 @@ class TestFolderRenameIntegration:
|
||||
@pytest.mark.asyncio
|
||||
async def test_folder_rename_skipped_when_prerequisites_not_met(self, tmp_path):
|
||||
"""If anime directory is missing, rename logic is skipped gracefully."""
|
||||
from src.server.services.folder_scan_service import FolderScanService
|
||||
from src.server.services.scheduler.folder_scan_service import FolderScanService
|
||||
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.tmdb_api_key = "test-key"
|
||||
@@ -98,10 +98,10 @@ class TestFolderRenameIntegration:
|
||||
with patch(
|
||||
"src.config.settings.settings", mock_settings
|
||||
), patch(
|
||||
"src.server.services.folder_scan_service.perform_nfo_repair_scan",
|
||||
"src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan",
|
||||
new_callable=AsyncMock,
|
||||
), patch(
|
||||
"src.server.services.folder_rename_service.validate_and_rename_series_folders"
|
||||
"src.server.services.scheduler.folder_rename_service.validate_and_rename_series_folders"
|
||||
) as mock_rename:
|
||||
service = FolderScanService()
|
||||
await service.run_folder_scan()
|
||||
|
||||
@@ -34,7 +34,7 @@ class TestNfoRepairScanCalledInFolderScan:
|
||||
"""folder_scan_service.py imports perform_nfo_repair_scan."""
|
||||
import importlib
|
||||
|
||||
source = importlib.util.find_spec("src.server.services.folder_scan_service").origin
|
||||
source = importlib.util.find_spec("src.server.services.scheduler.folder_scan_service").origin
|
||||
with open(source, "r", encoding="utf-8") as fh:
|
||||
content = fh.read()
|
||||
|
||||
@@ -46,7 +46,7 @@ class TestNfoRepairScanCalledInFolderScan:
|
||||
"""perform_nfo_repair_scan must be called inside run_folder_scan."""
|
||||
import importlib
|
||||
|
||||
source = importlib.util.find_spec("src.server.services.folder_scan_service").origin
|
||||
source = importlib.util.find_spec("src.server.services.scheduler.folder_scan_service").origin
|
||||
with open(source, "r", encoding="utf-8") as fh:
|
||||
content = fh.read()
|
||||
|
||||
@@ -67,7 +67,9 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader:
|
||||
@pytest.mark.asyncio
|
||||
async def test_incomplete_nfo_series_scheduled_for_repair(self, tmp_path):
|
||||
"""Series whose tvshow.nfo is missing required tags are scheduled via asyncio.create_task."""
|
||||
from src.server.services.folder_scan_service import perform_nfo_repair_scan
|
||||
from src.server.services.scheduler.folder_scan_service import (
|
||||
perform_nfo_repair_scan,
|
||||
)
|
||||
|
||||
series_dir = tmp_path / "IncompleteAnime"
|
||||
series_dir.mkdir()
|
||||
@@ -83,7 +85,7 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader:
|
||||
mock_repair_service.repair_series = AsyncMock(return_value=True)
|
||||
|
||||
with patch(
|
||||
"src.server.services.folder_scan_service._settings", mock_settings
|
||||
"src.server.services.scheduler.folder_scan_service._settings", mock_settings
|
||||
), patch(
|
||||
"src.core.services.nfo_repair_service.nfo_needs_repair",
|
||||
return_value=True,
|
||||
@@ -103,7 +105,9 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader:
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_nfo_series_not_scheduled(self, tmp_path):
|
||||
"""Series whose tvshow.nfo has all required tags are not scheduled for repair."""
|
||||
from src.server.services.folder_scan_service import perform_nfo_repair_scan
|
||||
from src.server.services.scheduler.folder_scan_service import (
|
||||
perform_nfo_repair_scan,
|
||||
)
|
||||
|
||||
series_dir = tmp_path / "CompleteAnime"
|
||||
series_dir.mkdir()
|
||||
@@ -116,7 +120,7 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader:
|
||||
mock_settings.anime_directory = str(tmp_path)
|
||||
|
||||
with patch(
|
||||
"src.server.services.folder_scan_service._settings", mock_settings
|
||||
"src.server.services.scheduler.folder_scan_service._settings", mock_settings
|
||||
), patch(
|
||||
"src.core.services.nfo_repair_service.nfo_needs_repair",
|
||||
return_value=False,
|
||||
|
||||
@@ -19,7 +19,7 @@ class TestPosterCheckScanCalledInFolderScan:
|
||||
import importlib
|
||||
|
||||
source = importlib.util.find_spec(
|
||||
"src.server.services.folder_scan_service"
|
||||
"src.server.services.scheduler.folder_scan_service"
|
||||
).origin
|
||||
with open(source, "r", encoding="utf-8") as fh:
|
||||
content = fh.read()
|
||||
@@ -33,7 +33,7 @@ class TestPosterCheckScanCalledInFolderScan:
|
||||
import importlib
|
||||
|
||||
source = importlib.util.find_spec(
|
||||
"src.server.services.folder_scan_service"
|
||||
"src.server.services.scheduler.folder_scan_service"
|
||||
).origin
|
||||
with open(source, "r", encoding="utf-8") as fh:
|
||||
content = fh.read()
|
||||
@@ -54,7 +54,7 @@ class TestPosterCheckIntegration:
|
||||
@pytest.mark.asyncio
|
||||
async def test_poster_check_downloads_missing_poster(self, tmp_path):
|
||||
"""When poster.jpg is missing, the scan downloads it from the NFO thumb URL."""
|
||||
from src.server.services.folder_scan_service import FolderScanService
|
||||
from src.server.services.scheduler.folder_scan_service import FolderScanService
|
||||
|
||||
anime_dir = tmp_path / "anime"
|
||||
anime_dir.mkdir()
|
||||
@@ -91,14 +91,14 @@ class TestPosterCheckIntegration:
|
||||
with patch(
|
||||
"src.config.settings.settings", mock_settings
|
||||
), patch(
|
||||
"src.server.services.folder_scan_service.perform_nfo_repair_scan",
|
||||
"src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan",
|
||||
new_callable=AsyncMock,
|
||||
), patch(
|
||||
"src.server.services.folder_rename_service.validate_and_rename_series_folders",
|
||||
"src.server.services.scheduler.folder_rename_service.validate_and_rename_series_folders",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
|
||||
), patch(
|
||||
"src.server.services.folder_scan_service.ImageDownloader",
|
||||
"src.server.services.scheduler.folder_scan_service.ImageDownloader",
|
||||
new=MockDownloader,
|
||||
):
|
||||
service = FolderScanService()
|
||||
@@ -112,7 +112,7 @@ class TestPosterCheckIntegration:
|
||||
@pytest.mark.asyncio
|
||||
async def test_poster_check_skips_valid_poster(self, tmp_path):
|
||||
"""When poster.jpg exists and is large enough, the scan skips it."""
|
||||
from src.server.services.folder_scan_service import FolderScanService
|
||||
from src.server.services.scheduler.folder_scan_service import FolderScanService
|
||||
|
||||
anime_dir = tmp_path / "anime"
|
||||
anime_dir.mkdir()
|
||||
@@ -136,14 +136,14 @@ class TestPosterCheckIntegration:
|
||||
with patch(
|
||||
"src.config.settings.settings", mock_settings
|
||||
), patch(
|
||||
"src.server.services.folder_scan_service.perform_nfo_repair_scan",
|
||||
"src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan",
|
||||
new_callable=AsyncMock,
|
||||
), patch(
|
||||
"src.server.services.folder_rename_service.validate_and_rename_series_folders",
|
||||
"src.server.services.scheduler.folder_rename_service.validate_and_rename_series_folders",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
|
||||
), patch(
|
||||
"src.server.services.folder_scan_service.ImageDownloader"
|
||||
"src.server.services.scheduler.folder_scan_service.ImageDownloader"
|
||||
) as mock_downloader_cls:
|
||||
service = FolderScanService()
|
||||
await service.run_folder_scan()
|
||||
@@ -153,7 +153,7 @@ class TestPosterCheckIntegration:
|
||||
@pytest.mark.asyncio
|
||||
async def test_poster_check_skips_when_no_thumb_url(self, tmp_path):
|
||||
"""When NFO has no thumb URL, the scan skips the folder."""
|
||||
from src.server.services.folder_scan_service import FolderScanService
|
||||
from src.server.services.scheduler.folder_scan_service import FolderScanService
|
||||
|
||||
anime_dir = tmp_path / "anime"
|
||||
anime_dir.mkdir()
|
||||
@@ -173,14 +173,14 @@ class TestPosterCheckIntegration:
|
||||
with patch(
|
||||
"src.config.settings.settings", mock_settings
|
||||
), patch(
|
||||
"src.server.services.folder_scan_service.perform_nfo_repair_scan",
|
||||
"src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan",
|
||||
new_callable=AsyncMock,
|
||||
), patch(
|
||||
"src.server.services.folder_rename_service.validate_and_rename_series_folders",
|
||||
"src.server.services.scheduler.folder_rename_service.validate_and_rename_series_folders",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
|
||||
), patch(
|
||||
"src.server.services.folder_scan_service.ImageDownloader"
|
||||
"src.server.services.scheduler.folder_scan_service.ImageDownloader"
|
||||
) as mock_downloader_cls:
|
||||
service = FolderScanService()
|
||||
await service.run_folder_scan()
|
||||
@@ -190,7 +190,7 @@ class TestPosterCheckIntegration:
|
||||
@pytest.mark.asyncio
|
||||
async def test_poster_check_skipped_when_prerequisites_not_met(self, tmp_path):
|
||||
"""If anime directory is missing, poster check logic is skipped gracefully."""
|
||||
from src.server.services.folder_scan_service import FolderScanService
|
||||
from src.server.services.scheduler.folder_scan_service import FolderScanService
|
||||
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.tmdb_api_key = "test-key"
|
||||
@@ -199,12 +199,12 @@ class TestPosterCheckIntegration:
|
||||
with patch(
|
||||
"src.config.settings.settings", mock_settings
|
||||
), patch(
|
||||
"src.server.services.folder_scan_service.perform_nfo_repair_scan",
|
||||
"src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan",
|
||||
new_callable=AsyncMock,
|
||||
), patch(
|
||||
"src.server.services.folder_rename_service.validate_and_rename_series_folders"
|
||||
"src.server.services.scheduler.folder_rename_service.validate_and_rename_series_folders"
|
||||
) as mock_rename, patch(
|
||||
"src.server.services.folder_scan_service.ImageDownloader"
|
||||
"src.server.services.scheduler.folder_scan_service.ImageDownloader"
|
||||
) as mock_downloader_cls:
|
||||
service = FolderScanService()
|
||||
await service.run_folder_scan()
|
||||
@@ -220,7 +220,7 @@ class TestPosterCheckSemaphore:
|
||||
import importlib
|
||||
|
||||
source = importlib.util.find_spec(
|
||||
"src.server.services.folder_scan_service"
|
||||
"src.server.services.scheduler.folder_scan_service"
|
||||
).origin
|
||||
with open(source, "r", encoding="utf-8") as fh:
|
||||
content = fh.read()
|
||||
@@ -232,7 +232,7 @@ class TestPosterCheckSemaphore:
|
||||
@pytest.mark.asyncio
|
||||
async def test_poster_download_uses_semaphore(self, tmp_path):
|
||||
"""Poster downloads are gated by the semaphore."""
|
||||
from src.server.services.folder_scan_service import (
|
||||
from src.server.services.scheduler.folder_scan_service import (
|
||||
_POSTER_DOWNLOAD_SEMAPHORE,
|
||||
FolderScanService,
|
||||
)
|
||||
@@ -270,14 +270,14 @@ class TestPosterCheckSemaphore:
|
||||
with patch(
|
||||
"src.config.settings.settings", mock_settings
|
||||
), patch(
|
||||
"src.server.services.folder_scan_service.perform_nfo_repair_scan",
|
||||
"src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan",
|
||||
new_callable=AsyncMock,
|
||||
), patch(
|
||||
"src.server.services.folder_rename_service.validate_and_rename_series_folders",
|
||||
"src.server.services.scheduler.folder_rename_service.validate_and_rename_series_folders",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
|
||||
), patch(
|
||||
"src.server.services.folder_scan_service.ImageDownloader"
|
||||
"src.server.services.scheduler.folder_scan_service.ImageDownloader"
|
||||
) as mock_downloader_cls:
|
||||
mock_downloader = AsyncMock()
|
||||
mock_downloader.download_poster = AsyncMock(side_effect=tracked_download)
|
||||
|
||||
@@ -11,15 +11,14 @@ from unittest.mock import AsyncMock, Mock, patch
|
||||
import pytest
|
||||
|
||||
from src.server.models.config import AppConfig, SchedulerConfig
|
||||
from src.server.services.scheduler_service import (
|
||||
from src.server.services.scheduler.scheduler_service import (
|
||||
_JOB_ID,
|
||||
SchedulerService,
|
||||
SchedulerServiceError,
|
||||
_JOB_ID,
|
||||
get_scheduler_service,
|
||||
reset_scheduler_service,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -27,7 +26,7 @@ from src.server.services.scheduler_service import (
|
||||
@pytest.fixture
|
||||
def mock_config_service():
|
||||
"""Patch get_config_service used by SchedulerService.start()."""
|
||||
with patch("src.server.services.scheduler_service.get_config_service") as mock:
|
||||
with patch("src.server.services.scheduler.scheduler_service.get_config_service") as mock:
|
||||
config_service = Mock()
|
||||
app_config = AppConfig(
|
||||
scheduler=SchedulerConfig(
|
||||
|
||||
161
tests/unit/test_anime_key_rename.py
Normal file
161
tests/unit/test_anime_key_rename.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""Unit tests for anime key rename logic and validation."""
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from src.server.models.anime import AnimeMetadataUpdate, KEY_PATTERN
|
||||
|
||||
|
||||
class TestKeyValidation:
|
||||
"""Tests for AnimeMetadataUpdate key validation."""
|
||||
|
||||
def test_valid_key_simple(self):
|
||||
"""Test simple valid key."""
|
||||
model = AnimeMetadataUpdate(key="attack-on-titan")
|
||||
assert model.key == "attack-on-titan"
|
||||
|
||||
def test_valid_key_single_char(self):
|
||||
"""Test single character key is valid."""
|
||||
model = AnimeMetadataUpdate(key="a")
|
||||
assert model.key == "a"
|
||||
|
||||
def test_valid_key_numbers(self):
|
||||
"""Test key with numbers."""
|
||||
model = AnimeMetadataUpdate(key="86-eighty-six")
|
||||
assert model.key == "86-eighty-six"
|
||||
|
||||
def test_valid_key_allows_hyphens(self):
|
||||
"""Test hyphens in key are allowed."""
|
||||
model = AnimeMetadataUpdate(key="my-anime-key")
|
||||
assert model.key == "my-anime-key"
|
||||
|
||||
def test_valid_key_normalizes_to_lowercase(self):
|
||||
"""Test key is normalized to lowercase."""
|
||||
model = AnimeMetadataUpdate(key="Attack-On-Titan")
|
||||
assert model.key == "attack-on-titan"
|
||||
|
||||
def test_valid_key_strips_whitespace(self):
|
||||
"""Test key strips leading/trailing whitespace."""
|
||||
model = AnimeMetadataUpdate(key=" my-key ")
|
||||
assert model.key == "my-key"
|
||||
|
||||
def test_invalid_key_spaces(self):
|
||||
"""Test key with spaces is rejected."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
AnimeMetadataUpdate(key="my anime key")
|
||||
assert "Key must contain only" in str(exc_info.value)
|
||||
|
||||
def test_invalid_key_uppercase_special(self):
|
||||
"""Test key with special characters is rejected."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
AnimeMetadataUpdate(key="anime!@#key")
|
||||
assert "Key must contain only" in str(exc_info.value)
|
||||
|
||||
def test_invalid_key_empty(self):
|
||||
"""Test empty key is rejected."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
AnimeMetadataUpdate(key="")
|
||||
assert "cannot be empty" in str(exc_info.value)
|
||||
|
||||
def test_invalid_key_only_whitespace(self):
|
||||
"""Test whitespace-only key is rejected."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
AnimeMetadataUpdate(key=" ")
|
||||
assert "cannot be empty" in str(exc_info.value)
|
||||
|
||||
def test_invalid_key_starts_with_hyphen(self):
|
||||
"""Test key starting with hyphen is rejected."""
|
||||
with pytest.raises(ValidationError):
|
||||
AnimeMetadataUpdate(key="-my-key")
|
||||
|
||||
def test_invalid_key_ends_with_hyphen(self):
|
||||
"""Test key ending with hyphen is rejected."""
|
||||
with pytest.raises(ValidationError):
|
||||
AnimeMetadataUpdate(key="my-key-")
|
||||
|
||||
def test_key_none_is_allowed(self):
|
||||
"""Test None key (no change requested) is allowed."""
|
||||
model = AnimeMetadataUpdate(key=None)
|
||||
assert model.key is None
|
||||
|
||||
def test_key_omitted_is_allowed(self):
|
||||
"""Test omitting key entirely is allowed."""
|
||||
model = AnimeMetadataUpdate(tmdb_id=1234)
|
||||
assert model.key is None
|
||||
|
||||
|
||||
class TestTmdbIdValidation:
|
||||
"""Tests for tmdb_id validation."""
|
||||
|
||||
def test_valid_tmdb_id(self):
|
||||
"""Test valid positive TMDB ID."""
|
||||
model = AnimeMetadataUpdate(tmdb_id=1429)
|
||||
assert model.tmdb_id == 1429
|
||||
|
||||
def test_tmdb_id_none(self):
|
||||
"""Test None tmdb_id is allowed."""
|
||||
model = AnimeMetadataUpdate(tmdb_id=None)
|
||||
assert model.tmdb_id is None
|
||||
|
||||
def test_tmdb_id_negative_rejected(self):
|
||||
"""Test negative tmdb_id is rejected."""
|
||||
with pytest.raises(ValidationError):
|
||||
AnimeMetadataUpdate(tmdb_id=-1)
|
||||
|
||||
def test_tmdb_id_zero_rejected(self):
|
||||
"""Test zero tmdb_id is rejected."""
|
||||
with pytest.raises(ValidationError):
|
||||
AnimeMetadataUpdate(tmdb_id=0)
|
||||
|
||||
|
||||
class TestTvdbIdValidation:
|
||||
"""Tests for tvdb_id validation."""
|
||||
|
||||
def test_valid_tvdb_id(self):
|
||||
"""Test valid positive TVDB ID."""
|
||||
model = AnimeMetadataUpdate(tvdb_id=267440)
|
||||
assert model.tvdb_id == 267440
|
||||
|
||||
def test_tvdb_id_none(self):
|
||||
"""Test None tvdb_id is allowed."""
|
||||
model = AnimeMetadataUpdate(tvdb_id=None)
|
||||
assert model.tvdb_id is None
|
||||
|
||||
def test_tvdb_id_negative_rejected(self):
|
||||
"""Test negative tvdb_id is rejected."""
|
||||
with pytest.raises(ValidationError):
|
||||
AnimeMetadataUpdate(tvdb_id=-5)
|
||||
|
||||
def test_tvdb_id_zero_rejected(self):
|
||||
"""Test zero tvdb_id is rejected."""
|
||||
with pytest.raises(ValidationError):
|
||||
AnimeMetadataUpdate(tvdb_id=0)
|
||||
|
||||
|
||||
class TestKeyPattern:
|
||||
"""Tests for the KEY_PATTERN regex directly."""
|
||||
|
||||
@pytest.mark.parametrize("key", [
|
||||
"a",
|
||||
"abc",
|
||||
"attack-on-titan",
|
||||
"86-eighty-six",
|
||||
"a1b2c3",
|
||||
"x",
|
||||
"1",
|
||||
])
|
||||
def test_valid_patterns(self, key):
|
||||
"""Test keys that should match the pattern."""
|
||||
assert KEY_PATTERN.match(key) is not None
|
||||
|
||||
@pytest.mark.parametrize("key", [
|
||||
"-start",
|
||||
"end-",
|
||||
"has space",
|
||||
"UPPER",
|
||||
"special!char",
|
||||
"under_score",
|
||||
"",
|
||||
])
|
||||
def test_invalid_patterns(self, key):
|
||||
"""Test keys that should not match the pattern."""
|
||||
assert KEY_PATTERN.match(key) is None
|
||||
@@ -474,7 +474,7 @@ class TestSchedulerConcurrentScanPrevention:
|
||||
@pytest.mark.asyncio
|
||||
async def test_scheduler_skips_rescan_if_already_running(self):
|
||||
"""Test scheduler skips scheduled rescan if one is already running."""
|
||||
from src.server.services.scheduler_service import SchedulerService
|
||||
from src.server.services.scheduler.scheduler_service import SchedulerService
|
||||
|
||||
scheduler = SchedulerService()
|
||||
|
||||
@@ -495,7 +495,7 @@ class TestSchedulerConcurrentScanPrevention:
|
||||
@pytest.mark.asyncio
|
||||
async def test_scheduler_sets_flag_during_rescan(self):
|
||||
"""Test that scheduler properly sets scan_in_progress flag."""
|
||||
from src.server.services.scheduler_service import SchedulerService
|
||||
from src.server.services.scheduler.scheduler_service import SchedulerService
|
||||
|
||||
scheduler = SchedulerService()
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ class TestFfmpegHealthCheck:
|
||||
with patch("src.server.utils.dependencies.get_anime_service"):
|
||||
with patch("src.server.utils.dependencies.get_download_service"):
|
||||
with patch("src.server.utils.dependencies.get_background_loader_service"):
|
||||
with patch("src.server.services.scheduler_service.get_scheduler_service") as mock_get_sched:
|
||||
with patch("src.server.services.scheduler.scheduler_service.get_scheduler_service") as mock_get_sched:
|
||||
mock_sched = MagicMock()
|
||||
mock_sched.start = AsyncMock(return_value=None)
|
||||
mock_get_sched.return_value = mock_sched
|
||||
@@ -64,7 +64,7 @@ class TestFfmpegHealthCheck:
|
||||
with patch("src.server.utils.dependencies.get_anime_service"):
|
||||
with patch("src.server.utils.dependencies.get_download_service"):
|
||||
with patch("src.server.utils.dependencies.get_background_loader_service"):
|
||||
with patch("src.server.services.scheduler_service.get_scheduler_service") as mock_get_sched:
|
||||
with patch("src.server.services.scheduler.scheduler_service.get_scheduler_service") as mock_get_sched:
|
||||
mock_sched = MagicMock()
|
||||
mock_sched.start = AsyncMock(return_value=None)
|
||||
mock_get_sched.return_value = mock_sched
|
||||
|
||||
@@ -8,7 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.server.services.folder_rename_service import (
|
||||
from src.server.services.scheduler.folder_rename_service import (
|
||||
_cleanup_orphaned_folder,
|
||||
_compute_expected_folder_name,
|
||||
_is_series_being_downloaded,
|
||||
@@ -163,7 +163,7 @@ class TestIsSeriesBeingDownloaded:
|
||||
mock_service._active_download = None
|
||||
mock_service._pending_queue = []
|
||||
with patch(
|
||||
"src.server.services.folder_rename_service.get_download_service",
|
||||
"src.server.services.scheduler.folder_rename_service.get_download_service",
|
||||
return_value=mock_service,
|
||||
):
|
||||
assert _is_series_being_downloaded("Some Show") is False
|
||||
@@ -175,7 +175,7 @@ class TestIsSeriesBeingDownloaded:
|
||||
mock_service._active_download = mock_item
|
||||
mock_service._pending_queue = []
|
||||
with patch(
|
||||
"src.server.services.folder_rename_service.get_download_service",
|
||||
"src.server.services.scheduler.folder_rename_service.get_download_service",
|
||||
return_value=mock_service,
|
||||
):
|
||||
assert _is_series_being_downloaded("Some Show") is True
|
||||
@@ -187,14 +187,14 @@ class TestIsSeriesBeingDownloaded:
|
||||
mock_service._active_download = None
|
||||
mock_service._pending_queue = [mock_item]
|
||||
with patch(
|
||||
"src.server.services.folder_rename_service.get_download_service",
|
||||
"src.server.services.scheduler.folder_rename_service.get_download_service",
|
||||
return_value=mock_service,
|
||||
):
|
||||
assert _is_series_being_downloaded("Some Show") is True
|
||||
|
||||
def test_exception_returns_true_for_safety(self) -> None:
|
||||
with patch(
|
||||
"src.server.services.folder_rename_service.get_download_service",
|
||||
"src.server.services.scheduler.folder_rename_service.get_download_service",
|
||||
side_effect=RuntimeError("boom"),
|
||||
):
|
||||
assert _is_series_being_downloaded("Some Show") is True
|
||||
@@ -213,20 +213,20 @@ class TestUpdateDatabasePaths:
|
||||
mock_series.folder = "Old Name"
|
||||
|
||||
with patch(
|
||||
"src.server.services.folder_rename_service.get_db_session"
|
||||
"src.server.services.scheduler.folder_rename_service.get_db_session"
|
||||
) as mock_get_db, patch(
|
||||
"src.server.services.folder_rename_service.AnimeSeriesService"
|
||||
"src.server.services.scheduler.folder_rename_service.AnimeSeriesService"
|
||||
) as mock_series_svc, patch(
|
||||
"src.server.services.folder_rename_service.EpisodeService"
|
||||
"src.server.services.scheduler.folder_rename_service.EpisodeService"
|
||||
) as mock_episode_svc, patch(
|
||||
"src.server.services.folder_rename_service.DownloadQueueService"
|
||||
"src.server.services.scheduler.folder_rename_service.DownloadQueueService"
|
||||
) as mock_queue_svc:
|
||||
|
||||
mock_db = AsyncMock()
|
||||
mock_get_db.return_value.__aenter__ = AsyncMock(return_value=mock_db)
|
||||
mock_get_db.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
mock_series_svc.get_by_key = AsyncMock(return_value=mock_series)
|
||||
mock_series_svc.get_by_folder = AsyncMock(return_value=mock_series)
|
||||
mock_series_svc.get_all = AsyncMock(return_value=[])
|
||||
mock_series_svc.update = AsyncMock(return_value=mock_series)
|
||||
|
||||
@@ -254,20 +254,20 @@ class TestUpdateDatabasePaths:
|
||||
mock_episode.file_path = str(old_path)
|
||||
|
||||
with patch(
|
||||
"src.server.services.folder_rename_service.get_db_session"
|
||||
"src.server.services.scheduler.folder_rename_service.get_db_session"
|
||||
) as mock_get_db, patch(
|
||||
"src.server.services.folder_rename_service.AnimeSeriesService"
|
||||
"src.server.services.scheduler.folder_rename_service.AnimeSeriesService"
|
||||
) as mock_series_svc, patch(
|
||||
"src.server.services.folder_rename_service.EpisodeService"
|
||||
"src.server.services.scheduler.folder_rename_service.EpisodeService"
|
||||
) as mock_episode_svc, patch(
|
||||
"src.server.services.folder_rename_service.DownloadQueueService"
|
||||
"src.server.services.scheduler.folder_rename_service.DownloadQueueService"
|
||||
) as mock_queue_svc:
|
||||
|
||||
mock_db = AsyncMock()
|
||||
mock_get_db.return_value.__aenter__ = AsyncMock(return_value=mock_db)
|
||||
mock_get_db.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
mock_series_svc.get_by_key = AsyncMock(return_value=mock_series)
|
||||
mock_series_svc.get_by_folder = AsyncMock(return_value=mock_series)
|
||||
mock_series_svc.get_all = AsyncMock(return_value=[])
|
||||
mock_series_svc.update = AsyncMock(return_value=mock_series)
|
||||
|
||||
@@ -350,7 +350,7 @@ class TestValidateAndRenameSeriesFolders:
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_anime_directory(self) -> None:
|
||||
with patch(
|
||||
"src.server.services.folder_rename_service.settings.anime_directory",
|
||||
"src.server.services.scheduler.folder_rename_service.settings.anime_directory",
|
||||
"",
|
||||
):
|
||||
stats = await validate_and_rename_series_folders()
|
||||
@@ -367,13 +367,13 @@ class TestValidateAndRenameSeriesFolders:
|
||||
)
|
||||
|
||||
with patch(
|
||||
"src.server.services.folder_rename_service.settings.anime_directory",
|
||||
"src.server.services.scheduler.folder_rename_service.settings.anime_directory",
|
||||
str(anime_dir),
|
||||
), patch(
|
||||
"src.server.services.folder_rename_service._is_series_being_downloaded",
|
||||
"src.server.services.scheduler.folder_rename_service._is_series_being_downloaded",
|
||||
return_value=False,
|
||||
), patch(
|
||||
"src.server.services.folder_rename_service._update_database_paths",
|
||||
"src.server.services.scheduler.folder_rename_service._update_database_paths",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_update_db:
|
||||
stats = await validate_and_rename_series_folders()
|
||||
@@ -397,7 +397,7 @@ class TestValidateAndRenameSeriesFolders:
|
||||
)
|
||||
|
||||
with patch(
|
||||
"src.server.services.folder_rename_service.settings.anime_directory",
|
||||
"src.server.services.scheduler.folder_rename_service.settings.anime_directory",
|
||||
str(anime_dir),
|
||||
):
|
||||
stats = await validate_and_rename_series_folders()
|
||||
@@ -419,7 +419,7 @@ class TestValidateAndRenameSeriesFolders:
|
||||
)
|
||||
|
||||
with patch(
|
||||
"src.server.services.folder_rename_service.settings.anime_directory",
|
||||
"src.server.services.scheduler.folder_rename_service.settings.anime_directory",
|
||||
str(anime_dir),
|
||||
):
|
||||
stats = await validate_and_rename_series_folders()
|
||||
@@ -440,10 +440,10 @@ class TestValidateAndRenameSeriesFolders:
|
||||
)
|
||||
|
||||
with patch(
|
||||
"src.server.services.folder_rename_service.settings.anime_directory",
|
||||
"src.server.services.scheduler.folder_rename_service.settings.anime_directory",
|
||||
str(anime_dir),
|
||||
), patch(
|
||||
"src.server.services.folder_rename_service._is_series_being_downloaded",
|
||||
"src.server.services.scheduler.folder_rename_service._is_series_being_downloaded",
|
||||
return_value=True,
|
||||
):
|
||||
stats = await validate_and_rename_series_folders()
|
||||
@@ -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,22 +465,42 @@ 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",
|
||||
"src.server.services.scheduler.folder_rename_service.settings.anime_directory",
|
||||
str(anime_dir),
|
||||
), patch(
|
||||
"src.server.services.folder_rename_service._is_series_being_downloaded",
|
||||
"src.server.services.scheduler.folder_rename_service._is_series_being_downloaded",
|
||||
return_value=False,
|
||||
), patch(
|
||||
"src.server.services.scheduler.folder_rename_service.get_db_session",
|
||||
return_value=mock_db,
|
||||
), patch(
|
||||
"src.server.services.scheduler.folder_rename_service.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
), patch(
|
||||
"src.server.services.scheduler.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:
|
||||
@@ -506,13 +527,13 @@ class TestValidateAndRenameSeriesFolders:
|
||||
(d3 / "tvshow.nfo").write_text("<tvshow><title>Show C</title></tvshow>")
|
||||
|
||||
with patch(
|
||||
"src.server.services.folder_rename_service.settings.anime_directory",
|
||||
"src.server.services.scheduler.folder_rename_service.settings.anime_directory",
|
||||
str(anime_dir),
|
||||
), patch(
|
||||
"src.server.services.folder_rename_service._is_series_being_downloaded",
|
||||
"src.server.services.scheduler.folder_rename_service._is_series_being_downloaded",
|
||||
return_value=False,
|
||||
), patch(
|
||||
"src.server.services.folder_rename_service._update_database_paths",
|
||||
"src.server.services.scheduler.folder_rename_service._update_database_paths",
|
||||
new_callable=AsyncMock,
|
||||
):
|
||||
stats = await validate_and_rename_series_folders()
|
||||
@@ -537,10 +558,10 @@ class TestValidateAndRenameSeriesFolders:
|
||||
)
|
||||
|
||||
with patch(
|
||||
"src.server.services.folder_rename_service.settings.anime_directory",
|
||||
"src.server.services.scheduler.folder_rename_service.settings.anime_directory",
|
||||
str(anime_dir),
|
||||
), patch(
|
||||
"src.server.services.folder_rename_service._is_series_being_downloaded",
|
||||
"src.server.services.scheduler.folder_rename_service._is_series_being_downloaded",
|
||||
return_value=False,
|
||||
):
|
||||
stats = await validate_and_rename_series_folders(dry_run=True)
|
||||
|
||||
@@ -15,7 +15,7 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.server.services.folder_scan_service import (
|
||||
from src.server.services.scheduler.folder_scan_service import (
|
||||
_POSTER_DOWNLOAD_SEMAPHORE,
|
||||
_TMDB_SEMAPHORE,
|
||||
FolderScanService,
|
||||
@@ -97,7 +97,7 @@ class TestRunFolderScanPrerequisites:
|
||||
with patch.object(
|
||||
folder_scan_service, "_prerequisites_met", return_value=False
|
||||
), patch(
|
||||
"src.server.services.folder_scan_service.perform_nfo_repair_scan"
|
||||
"src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan"
|
||||
) as mock_repair:
|
||||
await folder_scan_service.run_folder_scan()
|
||||
mock_repair.assert_not_called()
|
||||
@@ -108,10 +108,10 @@ class TestRunFolderScanPrerequisites:
|
||||
with patch.object(
|
||||
folder_scan_service, "_prerequisites_met", return_value=True
|
||||
), patch(
|
||||
"src.server.services.folder_scan_service.perform_nfo_repair_scan",
|
||||
"src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan",
|
||||
new_callable=AsyncMock,
|
||||
), patch(
|
||||
"src.server.services.folder_rename_service.validate_and_rename_series_folders",
|
||||
"src.server.services.scheduler.folder_rename_service.validate_and_rename_series_folders",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
|
||||
), patch.object(
|
||||
@@ -148,10 +148,10 @@ class TestNfoRepairIntegration:
|
||||
with patch.object(
|
||||
folder_scan_service, "_prerequisites_met", return_value=True
|
||||
), patch(
|
||||
"src.server.services.folder_scan_service.perform_nfo_repair_scan",
|
||||
"src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_repair, patch(
|
||||
"src.server.services.folder_rename_service.validate_and_rename_series_folders",
|
||||
"src.server.services.scheduler.folder_rename_service.validate_and_rename_series_folders",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
|
||||
), patch.object(
|
||||
@@ -172,11 +172,11 @@ class TestNfoRepairIntegration:
|
||||
with patch.object(
|
||||
folder_scan_service, "_prerequisites_met", return_value=True
|
||||
), patch(
|
||||
"src.server.services.folder_scan_service.perform_nfo_repair_scan",
|
||||
"src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=RuntimeError("repair failed"),
|
||||
) as mock_repair, patch(
|
||||
"src.server.services.folder_rename_service.validate_and_rename_series_folders",
|
||||
"src.server.services.scheduler.folder_rename_service.validate_and_rename_series_folders",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
|
||||
) as mock_rename, patch.object(
|
||||
@@ -204,10 +204,10 @@ class TestFolderRenameIntegration:
|
||||
with patch.object(
|
||||
folder_scan_service, "_prerequisites_met", return_value=True
|
||||
), patch(
|
||||
"src.server.services.folder_scan_service.perform_nfo_repair_scan",
|
||||
"src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan",
|
||||
new_callable=AsyncMock,
|
||||
), patch(
|
||||
"src.server.services.folder_rename_service.validate_and_rename_series_folders",
|
||||
"src.server.services.scheduler.folder_rename_service.validate_and_rename_series_folders",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"scanned": 5, "renamed": 2, "skipped": 2, "errors": 1},
|
||||
) as mock_rename, patch.object(
|
||||
@@ -228,10 +228,10 @@ class TestFolderRenameIntegration:
|
||||
with patch.object(
|
||||
folder_scan_service, "_prerequisites_met", return_value=True
|
||||
), patch(
|
||||
"src.server.services.folder_scan_service.perform_nfo_repair_scan",
|
||||
"src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan",
|
||||
new_callable=AsyncMock,
|
||||
), patch(
|
||||
"src.server.services.folder_rename_service.validate_and_rename_series_folders",
|
||||
"src.server.services.scheduler.folder_rename_service.validate_and_rename_series_folders",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=RuntimeError("rename failed"),
|
||||
), patch.object(
|
||||
@@ -344,7 +344,7 @@ class TestPosterCheck:
|
||||
mock_settings.nfo_download_poster = True
|
||||
|
||||
with patch(
|
||||
"src.server.services.folder_scan_service.ImageDownloader",
|
||||
"src.server.services.scheduler.folder_scan_service.ImageDownloader",
|
||||
return_value=mock_downloader,
|
||||
):
|
||||
stats = await folder_scan_service.check_and_download_missing_posters()
|
||||
@@ -423,7 +423,7 @@ class TestPosterCheck:
|
||||
mock_settings.nfo_download_poster = True
|
||||
|
||||
with patch(
|
||||
"src.server.services.folder_scan_service.ImageDownloader",
|
||||
"src.server.services.scheduler.folder_scan_service.ImageDownloader",
|
||||
return_value=mock_downloader,
|
||||
):
|
||||
stats = await folder_scan_service.check_and_download_missing_posters()
|
||||
@@ -456,7 +456,7 @@ class TestPosterCheck:
|
||||
mock_settings.nfo_download_poster = True
|
||||
|
||||
with patch(
|
||||
"src.server.services.folder_scan_service.ImageDownloader",
|
||||
"src.server.services.scheduler.folder_scan_service.ImageDownloader",
|
||||
return_value=mock_downloader,
|
||||
):
|
||||
stats = await folder_scan_service.check_and_download_missing_posters()
|
||||
@@ -491,7 +491,7 @@ class TestPosterCheck:
|
||||
mock_settings.nfo_download_poster = True
|
||||
|
||||
with patch(
|
||||
"src.server.services.folder_scan_service.ImageDownloader",
|
||||
"src.server.services.scheduler.folder_scan_service.ImageDownloader",
|
||||
return_value=mock_downloader,
|
||||
):
|
||||
stats = await folder_scan_service.check_and_download_missing_posters()
|
||||
@@ -569,10 +569,10 @@ class TestRunFolderScanFull:
|
||||
with patch.object(
|
||||
folder_scan_service, "_prerequisites_met", return_value=True
|
||||
), patch(
|
||||
"src.server.services.folder_scan_service.perform_nfo_repair_scan",
|
||||
"src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_repair, patch(
|
||||
"src.server.services.folder_rename_service.validate_and_rename_series_folders",
|
||||
"src.server.services.scheduler.folder_rename_service.validate_and_rename_series_folders",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"scanned": 3, "renamed": 1, "skipped": 1, "errors": 1},
|
||||
) as mock_rename, patch.object(
|
||||
@@ -593,10 +593,10 @@ class TestRunFolderScanFull:
|
||||
with patch.object(
|
||||
folder_scan_service, "_prerequisites_met", return_value=True
|
||||
), patch(
|
||||
"src.server.services.folder_scan_service.perform_nfo_repair_scan",
|
||||
"src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan",
|
||||
new_callable=AsyncMock,
|
||||
), patch(
|
||||
"src.server.services.folder_rename_service.validate_and_rename_series_folders",
|
||||
"src.server.services.scheduler.folder_rename_service.validate_and_rename_series_folders",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
|
||||
), patch.object(
|
||||
|
||||
@@ -10,7 +10,6 @@ from unittest.mock import AsyncMock, MagicMock, call, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.server.services.folder_scan_service import perform_nfo_repair_scan
|
||||
from src.server.services.initialization_service import (
|
||||
_check_initial_scan_status,
|
||||
_check_media_scan_status,
|
||||
@@ -30,6 +29,7 @@ from src.server.services.initialization_service import (
|
||||
perform_media_scan_if_needed,
|
||||
perform_nfo_scan_if_needed,
|
||||
)
|
||||
from src.server.services.scheduler.folder_scan_service import perform_nfo_repair_scan
|
||||
|
||||
|
||||
class TestCheckScanStatus:
|
||||
@@ -771,7 +771,7 @@ class TestPerformNfoRepairScan:
|
||||
mock_settings.anime_directory = str(tmp_path)
|
||||
|
||||
with patch(
|
||||
"src.server.services.folder_scan_service._settings", mock_settings
|
||||
"src.server.services.scheduler.folder_scan_service._settings", mock_settings
|
||||
):
|
||||
await perform_nfo_repair_scan()
|
||||
|
||||
@@ -785,7 +785,7 @@ class TestPerformNfoRepairScan:
|
||||
mock_settings.anime_directory = ""
|
||||
|
||||
with patch(
|
||||
"src.server.services.folder_scan_service._settings", mock_settings
|
||||
"src.server.services.scheduler.folder_scan_service._settings", mock_settings
|
||||
):
|
||||
await perform_nfo_repair_scan()
|
||||
|
||||
@@ -805,7 +805,7 @@ class TestPerformNfoRepairScan:
|
||||
mock_repair_service.repair_series = AsyncMock(return_value=True)
|
||||
|
||||
with patch(
|
||||
"src.server.services.folder_scan_service._settings", mock_settings
|
||||
"src.server.services.scheduler.folder_scan_service._settings", mock_settings
|
||||
), patch(
|
||||
"src.core.services.nfo_repair_service.nfo_needs_repair",
|
||||
return_value=True,
|
||||
@@ -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):
|
||||
@@ -835,7 +838,7 @@ class TestPerformNfoRepairScan:
|
||||
mock_settings.anime_directory = str(tmp_path)
|
||||
|
||||
with patch(
|
||||
"src.server.services.folder_scan_service._settings", mock_settings
|
||||
"src.server.services.scheduler.folder_scan_service._settings", mock_settings
|
||||
), patch(
|
||||
"src.core.services.nfo_repair_service.nfo_needs_repair",
|
||||
return_value=False,
|
||||
@@ -865,7 +868,7 @@ class TestPerformNfoRepairScan:
|
||||
mock_repair_service.repair_series = AsyncMock(return_value=True)
|
||||
|
||||
with patch(
|
||||
"src.server.services.folder_scan_service._settings", mock_settings
|
||||
"src.server.services.scheduler.folder_scan_service._settings", mock_settings
|
||||
), patch(
|
||||
"src.core.services.nfo_repair_service.nfo_needs_repair",
|
||||
return_value=True,
|
||||
@@ -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()
|
||||
|
||||
218
tests/unit/test_key_resolution_service.py
Normal file
218
tests/unit/test_key_resolution_service.py
Normal file
@@ -0,0 +1,218 @@
|
||||
"""Unit tests for key_resolution_service."""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.server.services.scheduler.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.scheduler.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.scheduler.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.scheduler.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.scheduler.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.scheduler.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.scheduler.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.scheduler.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.scheduler.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.scheduler.key_resolution_service._search_provider",
|
||||
return_value=search_results,
|
||||
):
|
||||
key = await resolve_key_for_folder("Dororo (2019)")
|
||||
assert key == "dororo"
|
||||
@@ -15,7 +15,7 @@ import pytest
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
from src.server.models.config import AppConfig, SchedulerConfig
|
||||
from src.server.services.scheduler_service import (
|
||||
from src.server.services.scheduler.scheduler_service import (
|
||||
_JOB_ID,
|
||||
SchedulerService,
|
||||
SchedulerServiceError,
|
||||
@@ -36,7 +36,7 @@ def _make_app_config(**scheduler_kwargs) -> AppConfig:
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_service():
|
||||
with patch("src.server.services.scheduler_service.get_config_service") as mock:
|
||||
with patch("src.server.services.scheduler.scheduler_service.get_config_service") as mock:
|
||||
svc = Mock()
|
||||
svc.load_config.return_value = _make_app_config(
|
||||
enabled=True,
|
||||
@@ -105,7 +105,7 @@ class TestStart:
|
||||
self, scheduler_service, mock_config_service
|
||||
):
|
||||
with patch(
|
||||
"src.server.services.scheduler_service.AsyncIOScheduler"
|
||||
"src.server.services.scheduler.scheduler_service.AsyncIOScheduler"
|
||||
) as MockScheduler:
|
||||
mock_sched = MagicMock()
|
||||
mock_sched.running = False
|
||||
@@ -137,9 +137,9 @@ class TestStartEmptyDays:
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_job_added_when_days_empty(self, scheduler_service):
|
||||
with patch(
|
||||
"src.server.services.scheduler_service.get_config_service"
|
||||
"src.server.services.scheduler.scheduler_service.get_config_service"
|
||||
) as mock_cs, patch(
|
||||
"src.server.services.scheduler_service.AsyncIOScheduler"
|
||||
"src.server.services.scheduler.scheduler_service.AsyncIOScheduler"
|
||||
) as MockScheduler:
|
||||
svc = Mock()
|
||||
svc.load_config.return_value = _make_app_config(
|
||||
@@ -409,7 +409,7 @@ class TestPerformRescanFolderScan:
|
||||
|
||||
with patch("src.server.utils.dependencies.get_anime_service", return_value=mock_anime), \
|
||||
patch("src.server.services.websocket_service.get_websocket_service", return_value=mock_ws), \
|
||||
patch("src.server.services.folder_scan_service.FolderScanService") as MockFSS:
|
||||
patch("src.server.services.scheduler.folder_scan_service.FolderScanService") as MockFSS:
|
||||
MockFSS.return_value.run_folder_scan = mock_folder_scan
|
||||
await scheduler_service._perform_rescan()
|
||||
|
||||
@@ -434,7 +434,7 @@ class TestPerformRescanFolderScan:
|
||||
|
||||
with patch("src.server.utils.dependencies.get_anime_service", return_value=mock_anime), \
|
||||
patch("src.server.services.websocket_service.get_websocket_service", return_value=mock_ws), \
|
||||
patch("src.server.services.folder_scan_service.FolderScanService") as MockFSS:
|
||||
patch("src.server.services.scheduler.folder_scan_service.FolderScanService") as MockFSS:
|
||||
MockFSS.return_value.run_folder_scan = mock_folder_scan
|
||||
await scheduler_service._perform_rescan()
|
||||
|
||||
@@ -459,7 +459,7 @@ class TestPerformRescanFolderScan:
|
||||
|
||||
with patch("src.server.utils.dependencies.get_anime_service", return_value=mock_anime), \
|
||||
patch("src.server.services.websocket_service.get_websocket_service", return_value=mock_ws), \
|
||||
patch("src.server.services.folder_scan_service.FolderScanService") as MockFSS:
|
||||
patch("src.server.services.scheduler.folder_scan_service.FolderScanService") as MockFSS:
|
||||
MockFSS.return_value.run_folder_scan = mock_folder_scan
|
||||
# Should NOT raise
|
||||
await scheduler_service._perform_rescan()
|
||||
@@ -498,7 +498,7 @@ class TestInMemoryJobStore:
|
||||
self, scheduler_service, mock_config_service
|
||||
):
|
||||
with patch(
|
||||
"src.server.services.scheduler_service.AsyncIOScheduler"
|
||||
"src.server.services.scheduler.scheduler_service.AsyncIOScheduler"
|
||||
) as MockScheduler:
|
||||
mock_sched = MagicMock()
|
||||
mock_sched.running = False
|
||||
@@ -517,7 +517,7 @@ class TestInMemoryJobStore:
|
||||
self, scheduler_service, mock_config_service
|
||||
):
|
||||
with patch(
|
||||
"src.server.services.scheduler_service.AsyncIOScheduler"
|
||||
"src.server.services.scheduler.scheduler_service.AsyncIOScheduler"
|
||||
) as MockScheduler:
|
||||
mock_sched = MagicMock()
|
||||
mock_sched.running = False
|
||||
@@ -540,7 +540,7 @@ class TestStartupRecovery:
|
||||
self, scheduler_service, mock_config_service
|
||||
):
|
||||
with patch(
|
||||
"src.server.services.scheduler_service.AsyncIOScheduler"
|
||||
"src.server.services.scheduler.scheduler_service.AsyncIOScheduler"
|
||||
) as MockScheduler:
|
||||
mock_job = MagicMock()
|
||||
next_run_dt = datetime(2026, 5, 25, 3, 0, tzinfo=timezone.utc)
|
||||
@@ -551,7 +551,7 @@ class TestStartupRecovery:
|
||||
MockScheduler.return_value = mock_sched
|
||||
|
||||
with patch(
|
||||
"src.server.services.scheduler_service.logger"
|
||||
"src.server.services.scheduler.scheduler_service.logger"
|
||||
) as mock_logger:
|
||||
await scheduler_service.start()
|
||||
info_calls = [str(c) for c in mock_logger.info.call_args_list]
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user