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:
@@ -461,3 +461,188 @@ class SerieScanner:
|
||||
episodes_dict[season] = missing_episodes
|
||||
|
||||
return episodes_dict, "aniworld.to"
|
||||
|
||||
def scan_single_series(
|
||||
self,
|
||||
key: str,
|
||||
folder: str,
|
||||
) -> dict[int, list[int]]:
|
||||
"""
|
||||
Scan a single series for missing episodes.
|
||||
|
||||
This method performs a targeted scan for only the specified series,
|
||||
without triggering a full library rescan. It fetches available
|
||||
episodes from the provider and compares with local files.
|
||||
|
||||
Args:
|
||||
key: The unique provider key for the series
|
||||
folder: The filesystem folder name where the series is stored
|
||||
|
||||
Returns:
|
||||
dict[int, list[int]]: Dictionary mapping season numbers to lists
|
||||
of missing episode numbers. Empty dict if no missing episodes.
|
||||
|
||||
Raises:
|
||||
ValueError: If key or folder is empty
|
||||
|
||||
Example:
|
||||
>>> scanner = SerieScanner("/path/to/anime", loader)
|
||||
>>> missing = scanner.scan_single_series(
|
||||
... "attack-on-titan",
|
||||
... "Attack on Titan"
|
||||
... )
|
||||
>>> print(missing)
|
||||
{1: [5, 6, 7], 2: [1, 2]}
|
||||
"""
|
||||
if not key or not key.strip():
|
||||
raise ValueError("Series key cannot be empty")
|
||||
if not folder or not folder.strip():
|
||||
raise ValueError("Series folder cannot be empty")
|
||||
|
||||
logger.info(
|
||||
"Starting targeted scan for series: %s (folder: %s)",
|
||||
key,
|
||||
folder
|
||||
)
|
||||
|
||||
# Generate unique operation ID for this targeted scan
|
||||
operation_id = str(uuid.uuid4())
|
||||
|
||||
# Notify scan starting
|
||||
self._callback_manager.notify_progress(
|
||||
ProgressContext(
|
||||
operation_type=OperationType.SCAN,
|
||||
operation_id=operation_id,
|
||||
phase=ProgressPhase.STARTING,
|
||||
current=0,
|
||||
total=1,
|
||||
percentage=0.0,
|
||||
message=f"Scanning series: {folder}",
|
||||
details=f"Key: {key}"
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
# Get the folder path
|
||||
folder_path = os.path.join(self.directory, folder)
|
||||
|
||||
# Check if folder exists
|
||||
if not os.path.isdir(folder_path):
|
||||
logger.info(
|
||||
"Series folder does not exist yet: %s - "
|
||||
"will scan for available episodes from provider",
|
||||
folder_path
|
||||
)
|
||||
mp4_files: list[str] = []
|
||||
else:
|
||||
# Find existing MP4 files in the folder
|
||||
mp4_files = []
|
||||
for root, _, files in os.walk(folder_path):
|
||||
for file in files:
|
||||
if file.endswith(".mp4"):
|
||||
mp4_files.append(os.path.join(root, file))
|
||||
|
||||
logger.debug(
|
||||
"Found %d existing MP4 files in folder %s",
|
||||
len(mp4_files),
|
||||
folder
|
||||
)
|
||||
|
||||
# Get missing episodes from provider
|
||||
missing_episodes, site = self.__get_missing_episodes_and_season(
|
||||
key, mp4_files
|
||||
)
|
||||
|
||||
# Update progress
|
||||
self._callback_manager.notify_progress(
|
||||
ProgressContext(
|
||||
operation_type=OperationType.SCAN,
|
||||
operation_id=operation_id,
|
||||
phase=ProgressPhase.IN_PROGRESS,
|
||||
current=1,
|
||||
total=1,
|
||||
percentage=100.0,
|
||||
message=f"Scanned: {folder}",
|
||||
details=f"Found {sum(len(eps) for eps in missing_episodes.values())} missing episodes"
|
||||
)
|
||||
)
|
||||
|
||||
# Create or update Serie in keyDict
|
||||
if key in self.keyDict:
|
||||
# Update existing serie
|
||||
self.keyDict[key].episodeDict = missing_episodes
|
||||
logger.debug(
|
||||
"Updated existing series %s with %d missing episodes",
|
||||
key,
|
||||
sum(len(eps) for eps in missing_episodes.values())
|
||||
)
|
||||
else:
|
||||
# Create new serie entry
|
||||
serie = Serie(
|
||||
key=key,
|
||||
name="", # Will be populated by caller if needed
|
||||
site=site,
|
||||
folder=folder,
|
||||
episodeDict=missing_episodes
|
||||
)
|
||||
self.keyDict[key] = serie
|
||||
logger.debug(
|
||||
"Created new series entry for %s with %d missing episodes",
|
||||
key,
|
||||
sum(len(eps) for eps in missing_episodes.values())
|
||||
)
|
||||
|
||||
# Notify completion
|
||||
self._callback_manager.notify_completion(
|
||||
CompletionContext(
|
||||
operation_type=OperationType.SCAN,
|
||||
operation_id=operation_id,
|
||||
success=True,
|
||||
message=f"Scan completed for {folder}",
|
||||
statistics={
|
||||
"missing_episodes": sum(
|
||||
len(eps) for eps in missing_episodes.values()
|
||||
),
|
||||
"seasons_with_missing": len(missing_episodes)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Targeted scan completed for %s: %d missing episodes across %d seasons",
|
||||
key,
|
||||
sum(len(eps) for eps in missing_episodes.values()),
|
||||
len(missing_episodes)
|
||||
)
|
||||
|
||||
return missing_episodes
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Failed to scan series {key}: {e}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
|
||||
# Notify error
|
||||
self._callback_manager.notify_error(
|
||||
ErrorContext(
|
||||
operation_type=OperationType.SCAN,
|
||||
operation_id=operation_id,
|
||||
error=e,
|
||||
message=error_msg,
|
||||
recoverable=True,
|
||||
metadata={"key": key, "folder": folder}
|
||||
)
|
||||
)
|
||||
|
||||
# Notify completion with failure
|
||||
self._callback_manager.notify_completion(
|
||||
CompletionContext(
|
||||
operation_type=OperationType.SCAN,
|
||||
operation_id=operation_id,
|
||||
success=False,
|
||||
message=error_msg
|
||||
)
|
||||
)
|
||||
|
||||
# Return empty dict on error (scan failed but not critical)
|
||||
return {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user