""" 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, and error handling. """ import asyncio import logging from typing import Any, Dict, List, Optional from events import Events from src.core.entities.SerieList import SerieList from src.core.providers.provider_factory import Loaders from src.core.SerieScanner import SerieScanner logger = logging.getLogger(__name__) class DownloadStatusEventArgs: """Event arguments for download status events.""" def __init__( self, serie_folder: str, season: int, episode: int, status: str, progress: float = 0.0, message: Optional[str] = None, error: Optional[Exception] = None, eta: Optional[int] = None, mbper_sec: Optional[float] = None, ): """ Initialize download status event arguments. Args: serie_folder: Serie folder name season: Season number episode: Episode number status: Status message (e.g., "started", "progress", "completed", "failed") progress: Download progress (0.0 to 1.0) message: Optional status message error: Optional error if status is "failed" eta: Estimated time remaining in seconds mbper_sec: Download speed in MB/s """ self.serie_folder = serie_folder self.season = season self.episode = episode self.status = status self.progress = progress self.message = message self.error = error self.eta = eta self.mbper_sec = mbper_sec class ScanStatusEventArgs: """Event arguments for scan status events.""" def __init__( self, current: int, total: int, folder: str, status: str, progress: float = 0.0, message: Optional[str] = None, error: Optional[Exception] = None, ): """ Initialize scan status event arguments. Args: current: Current item being scanned total: Total items to scan folder: Current folder being scanned status: Status message (e.g., "started", "progress", "completed", "failed", "cancelled") progress: Scan progress (0.0 to 1.0) message: Optional status message error: Optional error if status is "failed" """ self.current = current self.total = total self.folder = folder self.status = status self.progress = progress self.message = message self.error = error 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. Events: download_status: Raised when download status changes. Handler signature: def handler(args: DownloadStatusEventArgs) scan_status: Raised when scan status changes. Handler signature: def handler(args: ScanStatusEventArgs) """ def __init__( self, directory_to_search: str, ): """ Initialize SeriesApp. Args: directory_to_search: Base directory for anime series """ self.directory_to_search = directory_to_search # Initialize events self._events = Events() self._events.download_status = None self._events.scan_status = None self.loaders = Loaders() self.loader = self.loaders.GetLoader(key="aniworld.to") self.serie_scanner = SerieScanner(directory_to_search, self.loader) self.list = SerieList(self.directory_to_search) # Synchronous init used during constructor to avoid awaiting in __init__ self._init_list_sync() logger.info("SeriesApp initialized for directory: %s", directory_to_search) @property def download_status(self): """ Event raised when download status changes. Subscribe using: app.download_status += handler """ return self._events.download_status @download_status.setter def download_status(self, value): """Set download_status event handler.""" self._events.download_status = value @property def scan_status(self): """ Event raised when scan status changes. Subscribe using: app.scan_status += handler """ return self._events.scan_status @scan_status.setter def scan_status(self, value): """Set scan_status event handler.""" self._events.scan_status = value def _init_list_sync(self) -> None: """Synchronous initialization helper for constructor.""" self.series_list = self.list.GetMissingEpisode() logger.debug("Loaded %d series with missing episodes", len(self.series_list)) async def _init_list(self) -> None: """Initialize the series list with missing episodes (async).""" self.series_list = await asyncio.to_thread(self.list.GetMissingEpisode) logger.debug("Loaded %d series with missing episodes", len(self.series_list)) async def search(self, words: str) -> List[Dict[str, Any]]: """ Search for anime series (async). Args: words: Search query Returns: List of search results Raises: RuntimeError: If search fails """ logger.info("Searching for: %s", words) results = await asyncio.to_thread(self.loader.search, words) logger.info("Found %d results", len(results)) return results async def download( self, serie_folder: str, season: int, episode: int, key: str, language: str = "German Dub", ) -> bool: """ Download an episode (async). Args: serie_folder: Serie folder name season: Season number episode: Episode number key: Serie key language: Language preference Returns: True if download succeeded, False otherwise """ logger.info("Starting download: %s S%02dE%02d", serie_folder, season, episode) # Fire download started event self._events.download_status( DownloadStatusEventArgs( serie_folder=serie_folder, season=season, episode=episode, status="started", message="Download started", ) ) try: def download_callback(progress_info): logger.debug(f"wrapped_callback called with: {progress_info}") downloaded = progress_info.get('downloaded_bytes', 0) total_bytes = ( progress_info.get('total_bytes') or progress_info.get('total_bytes_estimate', 0) ) speed = progress_info.get('speed', 0) # bytes/sec eta = progress_info.get('eta') # seconds mbper_sec = speed / (1024 * 1024) if speed else None self._events.download_status( DownloadStatusEventArgs( serie_folder=serie_folder, season=season, episode=episode, status="progress", message="Download progress", progress=(downloaded / total_bytes) * 100 if total_bytes else 0, eta=eta, mbper_sec=mbper_sec, ) ) # Perform download in thread to avoid blocking event loop download_success = await asyncio.to_thread( self.loader.download, self.directory_to_search, serie_folder, season, episode, key, language, download_callback ) if download_success: logger.info( "Download completed: %s S%02dE%02d", serie_folder, season, episode ) # Fire download completed event self._events.download_status( DownloadStatusEventArgs( serie_folder=serie_folder, season=season, episode=episode, status="completed", progress=1.0, message="Download completed successfully", ) ) else: logger.warning( "Download failed: %s S%02dE%02d", serie_folder, season, episode ) # Fire download failed event self._events.download_status( DownloadStatusEventArgs( serie_folder=serie_folder, season=season, episode=episode, status="failed", message="Download failed", ) ) return download_success except Exception as e: logger.error( "Download error: %s S%02dE%02d - %s", serie_folder, season, episode, str(e), exc_info=True, ) # Fire download error event self._events.download_status( DownloadStatusEventArgs( serie_folder=serie_folder, season=season, episode=episode, status="failed", error=e, message=f"Download error: {str(e)}", ) ) return False async def re_scan(self) -> int: """ Rescan directory for missing episodes (async). Returns: Number of series with missing episodes after rescan. """ logger.info("Starting directory rescan") try: # Get total items to scan total_to_scan = await asyncio.to_thread(self.serie_scanner.get_total_to_scan) logger.info("Total folders to scan: %d", total_to_scan) # Fire scan started event self._events.scan_status( ScanStatusEventArgs( current=0, total=total_to_scan, folder="", status="started", progress=0.0, message="Scan started", ) ) # Reinitialize scanner await asyncio.to_thread(self.serie_scanner.reinit) def scan_callback(folder: str, current: int): # Calculate progress if total_to_scan > 0: progress = current / total_to_scan else: progress = 0.0 # Fire scan progress event self._events.scan_status( ScanStatusEventArgs( current=current, total=total_to_scan, folder=folder, status="progress", progress=progress, message=f"Scanning: {folder}", ) ) # Perform scan await asyncio.to_thread(self.serie_scanner.scan, scan_callback) # Reinitialize list self.list = SerieList(self.directory_to_search) await self._init_list() logger.info("Directory rescan completed successfully") # Fire scan completed event self._events.scan_status( ScanStatusEventArgs( current=total_to_scan, total=total_to_scan, folder="", status="completed", progress=1.0, message=f"Scan completed. Found {len(self.series_list)} series with missing episodes.", ) ) return len(self.series_list) except InterruptedError: logger.warning("Scan cancelled by user") # Fire scan cancelled event self._events.scan_status( ScanStatusEventArgs( current=0, total=total_to_scan if 'total_to_scan' in locals() else 0, folder="", status="cancelled", message="Scan cancelled by user", ) ) raise except Exception as e: logger.error("Scan error: %s", str(e), exc_info=True) # Fire scan failed event self._events.scan_status( ScanStatusEventArgs( current=0, total=total_to_scan if 'total_to_scan' in locals() else 0, folder="", status="failed", error=e, message=f"Scan error: {str(e)}", ) ) raise async def get_series_list(self) -> List[Any]: """ Get the current series list (async). Returns: List of series with missing episodes """ return self.series_list async def refresh_series_list(self) -> None: """Reload the cached series list from the underlying data store (async).""" await self._init_list()