feat: Implement comprehensive progress callback system

- Created callback interfaces (ProgressCallback, ErrorCallback, CompletionCallback)
- Defined rich context objects (ProgressContext, ErrorContext, CompletionContext)
- Implemented CallbackManager for managing multiple callbacks
- Integrated callbacks into SerieScanner for scan progress reporting
- Enhanced SeriesApp with download progress tracking via callbacks
- Added error and completion notifications throughout core operations
- Maintained backward compatibility with legacy callback system
- Created 22 comprehensive unit tests with 100% pass rate
- Updated infrastructure.md with callback system documentation
- Removed completed tasks from instructions.md

The callback system provides:
- Real-time progress updates with percentage and phase tracking
- Comprehensive error reporting with recovery information
- Operation completion notifications with statistics
- Thread-safe callback execution with exception handling
- Support for multiple simultaneous callbacks per type
This commit is contained in:
2025-10-17 20:05:57 +02:00
parent 59edf6bd50
commit a0f32b1a00
6 changed files with 1300 additions and 63 deletions

View File

@@ -8,11 +8,20 @@ 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
@@ -66,15 +75,17 @@ class SeriesApp:
self,
directory_to_search: str,
progress_callback: Optional[Callable[[ProgressInfo], None]] = None,
error_callback: Optional[Callable[[Exception], 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 callback for progress updates
error_callback: Optional callback for error notifications
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
@@ -86,9 +97,13 @@ class SeriesApp:
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
@@ -96,7 +111,9 @@ class SeriesApp:
self.Loaders = Loaders()
self.loader = self.Loaders.GetLoader(key="aniworld.to")
self.SerieScanner = SerieScanner(
directory_to_search, self.loader
directory_to_search,
self.loader,
self._callback_manager
)
self.List = SerieList(self.directory_to_search)
self.__InitList__()
@@ -110,6 +127,11 @@ class SeriesApp:
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:
@@ -163,13 +185,14 @@ class SeriesApp:
season: Season number
episode: Episode number
key: Serie key
callback: Optional progress callback
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
@@ -179,19 +202,81 @@ class SeriesApp:
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 check for cancellation
# Wrap callback to check for cancellation and report progress
def wrapped_callback(progress: float):
if self._is_cancelled():
raise InterruptedError("Download cancelled by user")
# 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 legacy callback if provided
if callback:
callback(progress)
# 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
self.loader.Download(
@@ -210,7 +295,22 @@ class SeriesApp:
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
@@ -219,6 +319,17 @@ class SeriesApp:
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",
@@ -227,14 +338,43 @@ class SeriesApp:
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=f"Download failed: {str(e)}",
message=error_msg,
error=e
)
finally:
self._current_operation = None
self._current_operation_id = None
def ReScan(
self,