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