From 8a49db2a10e232f7f0685828382e88580847213d Mon Sep 17 00:00:00 2001 From: Lukas Date: Sun, 2 Nov 2025 10:20:10 +0100 Subject: [PATCH] rework of SeriesApp.py --- src/core/SeriesApp.py | 420 +++++++++++++++++++++++++++++++++--------- 1 file changed, 337 insertions(+), 83 deletions(-) diff --git a/src/core/SeriesApp.py b/src/core/SeriesApp.py index 031fa83..baff698 100644 --- a/src/core/SeriesApp.py +++ b/src/core/SeriesApp.py @@ -8,7 +8,9 @@ progress reporting, and error handling. import asyncio import logging -from typing import Any, Callable, Dict, List, Optional +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 @@ -17,20 +19,98 @@ 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) """ - - _initialization_count = 0 def __init__( self, @@ -38,59 +118,78 @@ class SeriesApp: ): """ Initialize SeriesApp. - + Args: directory_to_search: Base directory for anime series """ - 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 - + + # 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.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 - ) + + 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) - ) + 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) - ) + 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 """ @@ -105,81 +204,236 @@ class SeriesApp: season: int, episode: int, key: str, - language: str = "German Dub" + 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 - ) - # 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 + 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", + ) ) - logger.info( - "Download completed: %s S%02dE%02d", - serie_folder, - season, - episode - ) - - return download_success - + try: + def download_callback(progress_info): + logger.debug(f"wrapped_callback called with: {progress_info}") - async def re_scan( - self - ) -> int: + 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") - - # 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) - - # Reinitialize scanner - await asyncio.to_thread(self.serie_scanner.reinit) - # Perform scan - await asyncio.to_thread(self.serie_scanner.scan) - - # Reinitialize list - self.list = SerieList(self.directory_to_search) - await self._init_list() - - logger.info("Directory rescan completed successfully") - - return len(self.series_list) - + 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 """