rework of SeriesApp.py

This commit is contained in:
Lukas 2025-11-02 10:20:10 +01:00
parent 2de3317aee
commit 8a49db2a10

View File

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