feat: Enhanced anime add flow with sanitized folders and targeted scan
- Add sanitize_folder_name utility for filesystem-safe folder names - Add sanitized_folder property to Serie entity - Update SerieList.add() to use sanitized display names for folders - Add scan_single_series() method for targeted episode scanning - Enhance add_series endpoint: DB save -> folder create -> targeted scan - Update response to include missing_episodes and total_missing - Add comprehensive unit tests for new functionality - Update API tests with proper mock support
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import os
|
||||
import warnings
|
||||
from typing import Any, List, Optional
|
||||
|
||||
@@ -21,6 +22,7 @@ from src.server.utils.dependencies import (
|
||||
get_series_app,
|
||||
require_auth,
|
||||
)
|
||||
from src.server.utils.filesystem import sanitize_folder_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -620,16 +622,20 @@ async def add_series(
|
||||
_auth: dict = Depends(require_auth),
|
||||
series_app: Any = Depends(get_series_app),
|
||||
db: Optional[AsyncSession] = Depends(get_optional_database_session),
|
||||
anime_service: AnimeService = Depends(get_anime_service),
|
||||
) -> dict:
|
||||
"""Add a new series to the library.
|
||||
"""Add a new series to the library with full initialization.
|
||||
|
||||
Extracts the series `key` from the provided link URL.
|
||||
The `key` is the URL-safe identifier used for all lookups.
|
||||
The `name` is stored as display metadata along with a
|
||||
filesystem-friendly `folder` name derived from the name.
|
||||
This endpoint performs the complete series addition flow:
|
||||
1. Validates inputs and extracts the series key from the link URL
|
||||
2. Creates a sanitized folder name from the display name
|
||||
3. Saves the series to the database (if available)
|
||||
4. Creates the folder on disk with the sanitized name
|
||||
5. Triggers a targeted scan for missing episodes (only this series)
|
||||
|
||||
Series are saved to the database using AnimeSeriesService when
|
||||
database is available, falling back to in-memory storage otherwise.
|
||||
The `key` is the URL-safe identifier used for all lookups.
|
||||
The `name` is stored as display metadata and used to derive
|
||||
the filesystem folder name (sanitized for filesystem safety).
|
||||
|
||||
Args:
|
||||
request: Request containing the series link and name.
|
||||
@@ -638,15 +644,23 @@ async def add_series(
|
||||
_auth: Ensures the caller is authenticated (value unused)
|
||||
series_app: Core `SeriesApp` instance provided via dependency
|
||||
db: Optional database session for async operations
|
||||
anime_service: AnimeService for scanning operations
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Status payload with success message, key, and db_id
|
||||
Dict[str, Any]: Status payload with:
|
||||
- status: "success" or "exists"
|
||||
- message: Human-readable status message
|
||||
- key: Series unique identifier
|
||||
- folder: Created folder path
|
||||
- db_id: Database ID (if saved to DB)
|
||||
- missing_episodes: Dict of missing episodes by season
|
||||
- total_missing: Total count of missing episodes
|
||||
|
||||
Raises:
|
||||
HTTPException: If adding the series fails or link is invalid
|
||||
"""
|
||||
try:
|
||||
# Validate inputs
|
||||
# Step A: Validate inputs
|
||||
if not request.link or not request.link.strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -679,28 +693,40 @@ async def add_series(
|
||||
detail="Could not extract series key from link",
|
||||
)
|
||||
|
||||
# Create folder from name (filesystem-friendly)
|
||||
folder = request.name.strip()
|
||||
db_id = None
|
||||
# Step B: Create sanitized folder name from display name
|
||||
name = request.name.strip()
|
||||
try:
|
||||
folder = sanitize_folder_name(name)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid series name for folder: {str(e)}",
|
||||
)
|
||||
|
||||
# Try to save to database if available
|
||||
db_id = None
|
||||
missing_episodes: dict = {}
|
||||
scan_error: Optional[str] = None
|
||||
|
||||
# Step C: Save to database if available
|
||||
if db is not None:
|
||||
# Check if series already exists in database
|
||||
existing = await AnimeSeriesService.get_by_key(db, key)
|
||||
if existing:
|
||||
return {
|
||||
"status": "exists",
|
||||
"message": f"Series already exists: {request.name}",
|
||||
"message": f"Series already exists: {name}",
|
||||
"key": key,
|
||||
"folder": existing.folder,
|
||||
"db_id": existing.id
|
||||
"db_id": existing.id,
|
||||
"missing_episodes": {},
|
||||
"total_missing": 0
|
||||
}
|
||||
|
||||
# Save to database using AnimeSeriesService
|
||||
anime_series = await AnimeSeriesService.create(
|
||||
db=db,
|
||||
key=key,
|
||||
name=request.name.strip(),
|
||||
name=name,
|
||||
site="aniworld.to",
|
||||
folder=folder,
|
||||
)
|
||||
@@ -708,41 +734,109 @@ async def add_series(
|
||||
|
||||
logger.info(
|
||||
"Added series to database: %s (key=%s, db_id=%d)",
|
||||
request.name,
|
||||
name,
|
||||
key,
|
||||
db_id
|
||||
)
|
||||
|
||||
# Also add to in-memory cache if series_app has the list attribute
|
||||
# Step D: Create folder on disk and add to SerieList
|
||||
folder_path = None
|
||||
if series_app and hasattr(series_app, "list"):
|
||||
serie = Serie(
|
||||
key=key,
|
||||
name=request.name.strip(),
|
||||
name=name,
|
||||
site="aniworld.to",
|
||||
folder=folder,
|
||||
episodeDict={}
|
||||
)
|
||||
# Add to in-memory cache
|
||||
if hasattr(series_app.list, 'keyDict'):
|
||||
# Direct update without file saving
|
||||
series_app.list.keyDict[key] = serie
|
||||
elif hasattr(series_app.list, 'add'):
|
||||
# Legacy: use add method (may create file with deprecation warning)
|
||||
|
||||
# Add to SerieList - this creates the folder with sanitized name
|
||||
if hasattr(series_app.list, 'add'):
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
series_app.list.add(serie)
|
||||
folder_path = series_app.list.add(serie, use_sanitized_folder=True)
|
||||
# Update folder to reflect what was actually created
|
||||
folder = serie.folder
|
||||
elif hasattr(series_app.list, 'keyDict'):
|
||||
# Manual folder creation and cache update
|
||||
if hasattr(series_app.list, 'directory'):
|
||||
folder_path = os.path.join(series_app.list.directory, folder)
|
||||
os.makedirs(folder_path, exist_ok=True)
|
||||
series_app.list.keyDict[key] = serie
|
||||
|
||||
logger.info(
|
||||
"Created folder for series: %s at %s",
|
||||
name,
|
||||
folder_path or folder
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Successfully added series: {request.name}",
|
||||
"key": key,
|
||||
"folder": folder,
|
||||
"db_id": db_id
|
||||
# Step E: Trigger targeted scan for missing episodes
|
||||
try:
|
||||
if series_app and hasattr(series_app, "scanner"):
|
||||
missing_episodes = series_app.scanner.scan_single_series(
|
||||
key=key,
|
||||
folder=folder
|
||||
)
|
||||
logger.info(
|
||||
"Targeted scan completed for %s: found %d missing episodes",
|
||||
key,
|
||||
sum(len(eps) for eps in missing_episodes.values())
|
||||
)
|
||||
|
||||
# Update the serie in keyDict with the missing episodes
|
||||
if hasattr(series_app, "list") and hasattr(series_app.list, "keyDict"):
|
||||
if key in series_app.list.keyDict:
|
||||
series_app.list.keyDict[key].episodeDict = missing_episodes
|
||||
elif anime_service:
|
||||
# Fallback to anime_service if scanner not directly available
|
||||
# Note: This is a lightweight scan, not a full rescan
|
||||
logger.info(
|
||||
"Scanner not directly available, "
|
||||
"skipping targeted scan for %s",
|
||||
key
|
||||
)
|
||||
except Exception as e:
|
||||
# Scan failure is not critical - series was still added
|
||||
scan_error = str(e)
|
||||
logger.warning(
|
||||
"Targeted scan failed for %s: %s (series still added)",
|
||||
key,
|
||||
e
|
||||
)
|
||||
|
||||
# Convert missing episodes keys to strings for JSON serialization
|
||||
missing_episodes_serializable = {
|
||||
str(season): episodes
|
||||
for season, episodes in missing_episodes.items()
|
||||
}
|
||||
|
||||
# Calculate total missing
|
||||
total_missing = sum(len(eps) for eps in missing_episodes.values())
|
||||
|
||||
# Step F: Return response
|
||||
response = {
|
||||
"status": "success",
|
||||
"message": f"Successfully added series: {name}",
|
||||
"key": key,
|
||||
"folder": folder_path or folder,
|
||||
"db_id": db_id,
|
||||
"missing_episodes": missing_episodes_serializable,
|
||||
"total_missing": total_missing
|
||||
}
|
||||
|
||||
if scan_error:
|
||||
response["scan_warning"] = f"Scan partially failed: {scan_error}"
|
||||
|
||||
return response
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error("Failed to add series: %s", exc, exc_info=True)
|
||||
|
||||
# Attempt to rollback database entry if folder creation failed
|
||||
# (This is a best-effort cleanup)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to add series: {str(exc)}",
|
||||
|
||||
Reference in New Issue
Block a user