Aniworld/src/core/SeriesApp.py
2025-11-01 19:23:32 +01:00

683 lines
23 KiB
Python

"""
SeriesApp - Core application logic for anime series management.
This module provides the main application interface for searching,
downloading, and managing anime series with support for async callbacks,
progress reporting, error handling, and operation cancellation.
"""
import asyncio
import logging
import uuid
from dataclasses import dataclass
from enum import Enum
from typing import Any, Callable, Dict, List, Optional
from src.core.entities.SerieList import SerieList
from src.core.interfaces.callbacks import (
CallbackManager,
CompletionContext,
ErrorContext,
OperationType,
ProgressContext,
ProgressPhase,
)
from src.core.providers.provider_factory import Loaders
from src.core.SerieScanner import SerieScanner
logger = logging.getLogger(__name__)
class OperationStatus(Enum):
"""Status of an operation."""
IDLE = "idle"
RUNNING = "running"
COMPLETED = "completed"
CANCELLED = "cancelled"
FAILED = "failed"
@dataclass
class ProgressInfo:
"""Progress information for long-running operations."""
current: int
total: int
message: str
percentage: float
status: OperationStatus
@dataclass
class OperationResult:
"""Result of an operation."""
success: bool
message: str
data: Optional[Any] = None
error: Optional[Exception] = None
class SeriesApp:
"""
Main application class for anime series management.
Provides functionality for:
- Searching anime series
- Downloading episodes
- Scanning directories for missing episodes
- Managing series lists
Supports async callbacks for progress reporting and cancellation.
"""
_initialization_count = 0
def __init__(
self,
directory_to_search: str,
progress_callback: Optional[Callable[[ProgressInfo], None]] = None,
error_callback: Optional[Callable[[Exception], None]] = None,
callback_manager: Optional[CallbackManager] = None
):
"""
Initialize SeriesApp.
Args:
directory_to_search: Base directory for anime series
progress_callback: Optional legacy callback for progress updates
error_callback: Optional legacy callback for error notifications
callback_manager: Optional callback manager for new callback system
"""
SeriesApp._initialization_count += 1
# Only show initialization message for the first instance
if SeriesApp._initialization_count <= 1:
logger.info("Initializing SeriesApp...")
self.directory_to_search = directory_to_search
self.progress_callback = progress_callback
self.error_callback = error_callback
# Initialize new callback system
self._callback_manager = callback_manager or CallbackManager()
# Cancellation support
self._cancel_flag = False
self._current_operation: Optional[str] = None
self._current_operation_id: Optional[str] = None
self._operation_status = OperationStatus.IDLE
# Initialize components
try:
self.Loaders = Loaders()
self.loader = self.Loaders.GetLoader(key="aniworld.to")
self.SerieScanner = SerieScanner(
directory_to_search,
self.loader,
self._callback_manager
)
self.List = SerieList(self.directory_to_search)
self.__InitList__()
logger.info(
"SeriesApp initialized for directory: %s",
directory_to_search
)
except (IOError, OSError, RuntimeError) as e:
logger.error("Failed to initialize SeriesApp: %s", e)
self._handle_error(e)
raise
@property
def callback_manager(self) -> CallbackManager:
"""Get the callback manager instance."""
return self._callback_manager
def __InitList__(self):
"""Initialize the series list with missing episodes."""
try:
self.series_list = self.List.GetMissingEpisode()
logger.debug(
"Loaded %d series with missing episodes",
len(self.series_list)
)
except (IOError, OSError, RuntimeError) as e:
logger.error("Failed to initialize series list: %s", e)
self._handle_error(e)
raise
def search(self, words: str) -> List[Dict[str, Any]]:
"""
Search for anime series.
Args:
words: Search query
Returns:
List of search results
Raises:
RuntimeError: If search fails
"""
try:
logger.info("Searching for: %s", words)
results = self.loader.search(words)
logger.info("Found %d results", len(results))
return results
except (IOError, OSError, RuntimeError) as e:
logger.error("Search failed for '%s': %s", words, e)
self._handle_error(e)
raise
def download(
self,
serieFolder: str,
season: int,
episode: int,
key: str,
callback: Optional[Callable[[float], None]] = None,
language: str = "German Dub"
) -> OperationResult:
"""
Download an episode.
Args:
serieFolder: Serie folder name
season: Season number
episode: Episode number
key: Serie key
callback: Optional legacy progress callback
language: Language preference
Returns:
OperationResult with download status
"""
self._current_operation = f"download_S{season:02d}E{episode:02d}"
self._current_operation_id = str(uuid.uuid4())
self._operation_status = OperationStatus.RUNNING
self._cancel_flag = False
try:
logger.info(
"Starting download: %s S%02dE%02d",
serieFolder, season, episode
)
# Notify download starting
start_msg = (
f"Starting download: {serieFolder} "
f"S{season:02d}E{episode:02d}"
)
self._callback_manager.notify_progress(
ProgressContext(
operation_type=OperationType.DOWNLOAD,
operation_id=self._current_operation_id,
phase=ProgressPhase.STARTING,
current=0,
total=100,
percentage=0.0,
message=start_msg,
metadata={
"series": serieFolder,
"season": season,
"episode": episode,
"key": key,
"language": language
}
)
)
# Check for cancellation before starting
if self._is_cancelled():
self._callback_manager.notify_completion(
CompletionContext(
operation_type=OperationType.DOWNLOAD,
operation_id=self._current_operation_id,
success=False,
message="Download cancelled before starting"
)
)
return OperationResult(
success=False,
message="Download cancelled before starting"
)
# Wrap callback to enforce cancellation checks and bridge the new
# event-driven progress reporting with the legacy callback API that
# the CLI still relies on.
def wrapped_callback(progress_info):
logger.debug(f"wrapped_callback called with: {progress_info}")
if self._is_cancelled():
raise InterruptedError("Download cancelled by user")
# yt-dlp passes a dict with progress information
# Only process progress updates when status is 'downloading'
# (yt-dlp also sends 'finished', 'error', etc.)
if isinstance(progress_info, dict):
status = progress_info.get('status')
if status and status != 'downloading':
logger.debug(
f"Skipping progress update with status: {status}"
)
return
# Extract percentage from the dict
# Calculate percentage based on downloaded/total bytes
downloaded = progress_info.get('downloaded_bytes', 0)
total_bytes = (
progress_info.get('total_bytes')
or progress_info.get('total_bytes_estimate', 0)
)
if total_bytes > 0:
progress = (downloaded / total_bytes) * 100
else:
progress = 0
# Extract speed and ETA from yt-dlp progress dict
speed = progress_info.get('speed', 0) # bytes/sec
eta = progress_info.get('eta') # seconds
logger.debug(
f"Progress calculation: {downloaded}/{total_bytes} = "
f"{progress:.1f}%, speed={speed}, eta={eta}"
)
# Convert to expected format for web API callback
# Web API expects: percent, downloaded_mb, total_mb,
# speed_mbps, eta_seconds
web_progress_dict = {
'percent': progress,
# Convert bytes to MB
'downloaded_mb': downloaded / (1024 * 1024),
'total_mb': (
total_bytes / (1024 * 1024)
if total_bytes > 0
else None
),
# Convert bytes/sec to MB/sec
'speed_mbps': (
speed / (1024 * 1024) if speed else None
),
'eta_seconds': eta,
}
else:
# Fallback for old-style float progress
progress = float(progress_info)
web_progress_dict = {
'percent': progress,
'downloaded_mb': 0.0,
'total_mb': None,
'speed_mbps': None,
'eta_seconds': None,
}
logger.debug(f"Old-style progress: {progress}%")
# Notify progress via new callback system
self._callback_manager.notify_progress(
ProgressContext(
operation_type=OperationType.DOWNLOAD,
operation_id=self._current_operation_id,
phase=ProgressPhase.IN_PROGRESS,
current=int(progress),
total=100,
percentage=progress,
message=f"Downloading: {progress:.1f}%",
metadata={
"series": serieFolder,
"season": season,
"episode": episode
}
)
)
# Call callback with web API format
# (dict with detailed progress info)
if callback:
logger.debug(
f"Calling progress callback: {web_progress_dict}"
)
try:
callback(web_progress_dict)
logger.debug("Progress callback executed successfully")
except Exception as e:
logger.error(
f"Error in progress callback: {e}",
exc_info=True
)
# Propagate progress into the legacy callback chain so
# existing UI surfaces continue to receive updates without
# rewriting the old interfaces.
# Call legacy progress_callback if provided
if self.progress_callback:
self.progress_callback(ProgressInfo(
current=int(progress),
total=100,
message=f"Downloading S{season:02d}E{episode:02d}",
percentage=progress,
status=OperationStatus.RUNNING
))
# Perform download
download_success = self.loader.download(
self.directory_to_search,
serieFolder,
season,
episode,
key,
language,
wrapped_callback
)
# Check if download was successful
if not download_success:
raise RuntimeError(
f"Download failed for S{season:02d}E{episode:02d}"
)
self._operation_status = OperationStatus.COMPLETED
logger.info(
"Download completed: %s S%02dE%02d",
serieFolder, season, episode
)
# Notify completion
msg = f"Successfully downloaded S{season:02d}E{episode:02d}"
self._callback_manager.notify_completion(
CompletionContext(
operation_type=OperationType.DOWNLOAD,
operation_id=self._current_operation_id,
success=True,
message=msg,
statistics={
"series": serieFolder,
"season": season,
"episode": episode
}
)
)
return OperationResult(
success=True,
message=msg
)
except InterruptedError as e:
self._operation_status = OperationStatus.CANCELLED
logger.warning("Download cancelled: %s", e)
# Notify cancellation
self._callback_manager.notify_completion(
CompletionContext(
operation_type=OperationType.DOWNLOAD,
operation_id=self._current_operation_id,
success=False,
message="Download cancelled"
)
)
return OperationResult(
success=False,
message="Download cancelled",
error=e
)
except (IOError, OSError, RuntimeError) as e:
self._operation_status = OperationStatus.FAILED
logger.error("Download failed: %s", e)
# Notify error
error_msg = f"Download failed: {str(e)}"
self._callback_manager.notify_error(
ErrorContext(
operation_type=OperationType.DOWNLOAD,
operation_id=self._current_operation_id,
error=e,
message=error_msg,
recoverable=False,
metadata={
"series": serieFolder,
"season": season,
"episode": episode
}
)
)
# Notify completion with failure
self._callback_manager.notify_completion(
CompletionContext(
operation_type=OperationType.DOWNLOAD,
operation_id=self._current_operation_id,
success=False,
message=error_msg
)
)
self._handle_error(e)
return OperationResult(
success=False,
message=error_msg,
error=e
)
finally:
self._current_operation = None
self._current_operation_id = None
def ReScan(
self,
callback: Optional[Callable[[str, int], None]] = None
) -> OperationResult:
"""
Rescan directory for missing episodes.
Args:
callback: Optional progress callback (folder, current_count)
Returns:
OperationResult with scan status
"""
self._current_operation = "rescan"
self._operation_status = OperationStatus.RUNNING
self._cancel_flag = False
try:
logger.info("Starting directory rescan")
# Get total items to scan
total_to_scan = self.SerieScanner.get_total_to_scan()
logger.info("Total folders to scan: %d", total_to_scan)
# Reinitialize scanner
self.SerieScanner.reinit()
# Wrap the scanner callback so we can surface progress through the
# new ProgressInfo pipeline while maintaining backwards
# compatibility with the legacy tuple-based callback signature.
def wrapped_callback(folder: str, current: int):
if self._is_cancelled():
raise InterruptedError("Scan cancelled by user")
# Calculate progress
if total_to_scan > 0:
percentage = (current / total_to_scan * 100)
else:
percentage = 0
# Report progress
if self.progress_callback:
progress_info = ProgressInfo(
current=current,
total=total_to_scan,
message=f"Scanning: {folder}",
percentage=percentage,
status=OperationStatus.RUNNING
)
self.progress_callback(progress_info)
# Call original callback if provided
if callback:
callback(folder, current)
# Perform scan
self.SerieScanner.scan(wrapped_callback)
# Reinitialize list
self.List = SerieList(self.directory_to_search)
self.__InitList__()
self._operation_status = OperationStatus.COMPLETED
logger.info("Directory rescan completed successfully")
msg = (
f"Scan completed. Found {len(self.series_list)} "
f"series."
)
return OperationResult(
success=True,
message=msg,
data={"series_count": len(self.series_list)}
)
except InterruptedError as e:
self._operation_status = OperationStatus.CANCELLED
logger.warning("Scan cancelled: %s", e)
return OperationResult(
success=False,
message="Scan cancelled",
error=e
)
except (IOError, OSError, RuntimeError) as e:
self._operation_status = OperationStatus.FAILED
logger.error("Scan failed: %s", e)
self._handle_error(e)
return OperationResult(
success=False,
message=f"Scan failed: {str(e)}",
error=e
)
finally:
self._current_operation = None
async def async_download(
self,
serieFolder: str,
season: int,
episode: int,
key: str,
callback: Optional[Callable[[float], None]] = None,
language: str = "German Dub"
) -> OperationResult:
"""
Async version of download method.
Args:
serieFolder: Serie folder name
season: Season number
episode: Episode number
key: Serie key
callback: Optional progress callback
language: Language preference
Returns:
OperationResult with download status
"""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
None,
self.download,
serieFolder,
season,
episode,
key,
callback,
language
)
async def async_rescan(
self,
callback: Optional[Callable[[str, int], None]] = None
) -> OperationResult:
"""
Async version of ReScan method.
Args:
callback: Optional progress callback
Returns:
OperationResult with scan status
"""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
None,
self.ReScan,
callback
)
def cancel_operation(self) -> bool:
"""
Cancel the current operation.
Returns:
True if operation cancelled, False if no operation running
"""
if (self._current_operation and
self._operation_status == OperationStatus.RUNNING):
logger.info(
"Cancelling operation: %s",
self._current_operation
)
self._cancel_flag = True
return True
return False
def _is_cancelled(self) -> bool:
"""Check if the current operation has been cancelled."""
return self._cancel_flag
def _handle_error(self, error: Exception) -> None:
"""
Handle errors and notify via callback.
Args:
error: Exception that occurred
"""
if self.error_callback:
try:
self.error_callback(error)
except (RuntimeError, ValueError) as callback_error:
logger.error(
"Error in error callback: %s",
callback_error
)
def get_series_list(self) -> List[Any]:
"""
Get the current series list.
Returns:
List of series with missing episodes
"""
return self.series_list
def refresh_series_list(self) -> None:
"""Reload the cached series list from the underlying data store."""
self.__InitList__()
def get_operation_status(self) -> OperationStatus:
"""
Get the current operation status.
Returns:
Current operation status
"""
return self._operation_status
def get_current_operation(self) -> Optional[str]:
"""
Get the current operation name.
Returns:
Name of current operation or None
"""
return self._current_operation