Refactor: Replace CallbackManager with Events pattern

- Replace callback system with events library in SerieScanner
- Update SeriesApp to subscribe to loader and scanner events
- Refactor ScanService to use Events instead of CallbackManager
- Remove CallbackManager imports and callback classes
- Add safe event calling with error handling in SerieScanner
- Update AniworldLoader to use Events for download progress
- Remove progress_callback parameter from download methods
- Update all affected tests for Events pattern
- Fix test_series_app.py for new event subscription model
- Comment out obsolete callback tests in test_scan_service.py

All core tests passing. Events provide cleaner event-driven architecture.
This commit is contained in:
2025-12-30 21:04:45 +01:00
parent ff9dea0488
commit b1726968e5
8 changed files with 381 additions and 631 deletions

View File

@@ -15,18 +15,12 @@ import os
import re
import traceback
import uuid
from typing import Callable, Iterable, Iterator, Optional
from typing import Iterable, Iterator, Optional
from events import Events
from src.core.entities.series import Serie
from src.core.exceptions.Exceptions import MatchNotFoundError, NoKeyFoundException
from src.core.interfaces.callbacks import (
CallbackManager,
CompletionContext,
ErrorContext,
OperationType,
ProgressContext,
ProgressPhase,
)
from src.core.providers.base_provider import Loader
logger = logging.getLogger(__name__)
@@ -55,7 +49,6 @@ class SerieScanner:
self,
basePath: str,
loader: Loader,
callback_manager: Optional[CallbackManager] = None,
) -> None:
"""
Initialize the SerieScanner.
@@ -82,18 +75,76 @@ class SerieScanner:
self.directory: str = abs_path
self.keyDict: dict[str, Serie] = {}
self.loader: Loader = loader
self._callback_manager: CallbackManager = (
callback_manager or CallbackManager()
)
self._current_operation_id: Optional[str] = None
self.events = Events()
self.events.on_progress = None
self.events.on_error = None
self.events.on_completion = None
logger.info("Initialized SerieScanner with base path: %s", abs_path)
def _safe_call_event(self, event_handler, data: dict) -> None:
"""Safely call an event handler if it exists.
Args:
event_handler: Event handler attribute (e.g., self.events.on_progress)
data: Data dictionary to pass to the event handler
"""
if event_handler:
try:
event_handler(data)
except Exception as e:
logger.error("Error calling event handler: %s", e, exc_info=True)
@property
def callback_manager(self) -> CallbackManager:
"""Get the callback manager instance."""
return self._callback_manager
def subscribe_on_progress(self, handler):
"""
Subscribe a handler to an event.
Args:
handler: Callable to handle the event
"""
self.events.on_progress += handler
def unsubscribe_on_progress(self, handler):
"""
Unsubscribe a handler from an event.
Args:
handler: Callable to remove
"""
self.events.on_progress += handler
def subscribe_on_error(self, handler):
"""
Subscribe a handler to an event.
Args:
handler: Callable to handle the event
"""
self.events.on_error += handler
def unsubscribe_on_error(self, handler):
"""
Unsubscribe a handler from an event.
Args:
handler: Callable to remove
"""
self.events.on_error += handler
def subscribe_on_completion(self, handler):
"""
Subscribe a handler to an event.
Args:
handler: Callable to handle the event
"""
self.events.on_completion += handler
def unsubscribe_on_completion(self, handler):
"""
Unsubscribe a handler from an event.
Args:
handler: Callable to remove
"""
self.events.on_completion += handler
def reinit(self) -> None:
"""Reinitialize the series dictionary (keyed by serie.key)."""
self.keyDict: dict[str, Serie] = {}
@@ -107,20 +158,13 @@ class SerieScanner:
result = self.__find_mp4_files()
return sum(1 for _ in result)
def scan(
self,
callback: Optional[Callable[[str, int], None]] = None
) -> None:
def scan(self) -> None:
"""
Scan directories for anime series and missing episodes.
Results are stored in self.keyDict and can be retrieved after
scanning. Data files are also saved to disk for persistence.
Args:
callback: Optional callback function (folder, count) for
progress updates
Raises:
Exception: If scan fails critically
"""
@@ -130,16 +174,16 @@ class SerieScanner:
logger.info("Starting scan for missing episodes")
# Notify scan starting
self._callback_manager.notify_progress(
ProgressContext(
operation_type=OperationType.SCAN,
operation_id=self._current_operation_id,
phase=ProgressPhase.STARTING,
current=0,
total=0,
percentage=0.0,
message="Initializing scan"
)
self._safe_call_event(
self.events.on_progress,
{
"operation_id": self._current_operation_id,
"phase": "STARTING",
"current": 0,
"total": 0,
"percentage": 0.0,
"message": "Initializing scan"
}
)
try:
@@ -163,27 +207,20 @@ class SerieScanner:
else:
percentage = 0.0
# Progress is surfaced both through the callback manager
# (for the web/UI layer) and, for compatibility, through a
# legacy callback that updates CLI progress bars.
# Notify progress
self._callback_manager.notify_progress(
ProgressContext(
operation_type=OperationType.SCAN,
operation_id=self._current_operation_id,
phase=ProgressPhase.IN_PROGRESS,
current=counter,
total=total_to_scan,
percentage=percentage,
message=f"Scanning: {folder}",
details=f"Found {len(mp4_files)} episodes"
)
self._safe_call_event(
self.events.on_progress,
{
"operation_id": self._current_operation_id,
"phase": "IN_PROGRESS",
"current": counter,
"total": total_to_scan,
"percentage": percentage,
"message": f"Scanning: {folder}",
"details": f"Found {len(mp4_files)} episodes"
}
)
# Call legacy callback if provided
if callback:
callback(folder, counter)
serie = self.__read_data_from_file(folder)
if (
serie is not None
@@ -230,15 +267,15 @@ class SerieScanner:
error_msg = f"Error processing folder '{folder}': {nkfe}"
logger.error(error_msg)
self._callback_manager.notify_error(
ErrorContext(
operation_type=OperationType.SCAN,
operation_id=self._current_operation_id,
error=nkfe,
message=error_msg,
recoverable=True,
metadata={"folder": folder, "key": None}
)
self._safe_call_event(
self.events.on_error,
{
"operation_id": self._current_operation_id,
"error": nkfe,
"message": error_msg,
"recoverable": True,
"metadata": {"folder": folder, "key": None}
}
)
except Exception as e:
# Log error and notify via callback
@@ -252,30 +289,30 @@ class SerieScanner:
traceback.format_exc()
)
self._callback_manager.notify_error(
ErrorContext(
operation_type=OperationType.SCAN,
operation_id=self._current_operation_id,
error=e,
message=error_msg,
recoverable=True,
metadata={"folder": folder, "key": None}
)
self._safe_call_event(
self.events.on_error,
{
"operation_id": self._current_operation_id,
"error": e,
"message": error_msg,
"recoverable": True,
"metadata": {"folder": folder, "key": None}
}
)
continue
# Notify scan completion
self._callback_manager.notify_completion(
CompletionContext(
operation_type=OperationType.SCAN,
operation_id=self._current_operation_id,
success=True,
message=f"Scan completed. Processed {counter} folders.",
statistics={
self._safe_call_event(
self.events.on_completion,
{
"operation_id": self._current_operation_id,
"success": True,
"message": f"Scan completed. Processed {counter} folders.",
"statistics": {
"total_folders": counter,
"series_found": len(self.keyDict)
}
)
}
)
logger.info(
@@ -289,23 +326,23 @@ class SerieScanner:
error_msg = f"Critical scan error: {e}"
logger.error("%s\n%s", error_msg, traceback.format_exc())
self._callback_manager.notify_error(
ErrorContext(
operation_type=OperationType.SCAN,
operation_id=self._current_operation_id,
error=e,
message=error_msg,
recoverable=False
)
self._safe_call_event(
self.events.on_error,
{
"operation_id": self._current_operation_id,
"error": e,
"message": error_msg,
"recoverable": False
}
)
self._callback_manager.notify_completion(
CompletionContext(
operation_type=OperationType.SCAN,
operation_id=self._current_operation_id,
success=False,
message=error_msg
)
self._safe_call_event(
self.events.on_completion,
{
"operation_id": self._current_operation_id,
"success": False,
"message": error_msg
}
)
raise
@@ -325,16 +362,6 @@ class SerieScanner:
has_files = True
yield anime_name, mp4_files if has_files else []
def __remove_year(self, input_string: str) -> str:
"""Remove year information from input string."""
cleaned_string = re.sub(r'\(\d{4}\)', '', input_string).strip()
logger.debug(
"Removed year from '%s' -> '%s'",
input_string,
cleaned_string
)
return cleaned_string
def __read_data_from_file(self, folder_name: str) -> Optional[Serie]:
"""Read serie data from file or key file.
@@ -507,19 +534,18 @@ class SerieScanner:
# Generate unique operation ID for this targeted scan
operation_id = str(uuid.uuid4())
# Notify scan starting
self._callback_manager.notify_progress(
ProgressContext(
operation_type=OperationType.SCAN,
operation_id=operation_id,
phase=ProgressPhase.STARTING,
current=0,
total=1,
percentage=0.0,
message=f"Scanning series: {folder}",
details=f"Key: {key}"
)
self._safe_call_event(
self.events.on_progress,
{
"operation_id": operation_id,
"phase": "STARTING",
"current": 0,
"total": 1,
"percentage": 0.0,
"message": f"Scanning series: {folder}",
"details": f"Key: {key}"
}
)
try:
@@ -554,17 +580,17 @@ class SerieScanner:
)
# Update progress
self._callback_manager.notify_progress(
ProgressContext(
operation_type=OperationType.SCAN,
operation_id=operation_id,
phase=ProgressPhase.IN_PROGRESS,
current=1,
total=1,
percentage=100.0,
message=f"Scanned: {folder}",
details=f"Found {sum(len(eps) for eps in missing_episodes.values())} missing episodes"
)
self._safe_call_event(
self.events.on_progress,
{
"operation_id": operation_id,
"phase": "IN_PROGRESS",
"current": 1,
"total": 1,
"percentage": 100.0,
"message": f"Scanned: {folder}",
"details": f"Found {sum(len(eps) for eps in missing_episodes.values())} missing episodes"
}
)
# Create or update Serie in keyDict
@@ -593,19 +619,19 @@ class SerieScanner:
)
# Notify completion
self._callback_manager.notify_completion(
CompletionContext(
operation_type=OperationType.SCAN,
operation_id=operation_id,
success=True,
message=f"Scan completed for {folder}",
statistics={
self._safe_call_event(
self.events.on_completion,
{
"operation_id": operation_id,
"success": True,
"message": f"Scan completed for {folder}",
"statistics": {
"missing_episodes": sum(
len(eps) for eps in missing_episodes.values()
),
"seasons_with_missing": len(missing_episodes)
}
)
}
)
logger.info(
@@ -622,27 +648,25 @@ class SerieScanner:
logger.error(error_msg, exc_info=True)
# Notify error
self._callback_manager.notify_error(
ErrorContext(
operation_type=OperationType.SCAN,
operation_id=operation_id,
error=e,
message=error_msg,
recoverable=True,
metadata={"key": key, "folder": folder}
)
self._safe_call_event(
self.events.on_error,
{
"operation_id": operation_id,
"error": e,
"message": error_msg,
"recoverable": True,
"metadata": {"key": key, "folder": folder}
}
)
# Notify completion with failure
self._callback_manager.notify_completion(
CompletionContext(
operation_type=OperationType.SCAN,
operation_id=operation_id,
success=False,
message=error_msg
)
self._safe_call_event(
self.events.on_completion,
{
"operation_id": operation_id,
"success": False,
"message": error_msg
}
)
# Return empty dict on error (scan failed but not critical)
return {}

View File

@@ -309,9 +309,10 @@ class SeriesApp:
)
try:
def download_callback(progress_info):
def download_progress_handler(progress_info):
"""Handle download progress events from loader."""
logger.debug(
"wrapped_callback called with: %s", progress_info
"download_progress_handler called with: %s", progress_info
)
downloaded = progress_info.get('downloaded_bytes', 0)
@@ -341,17 +342,26 @@ class SeriesApp:
item_id=item_id,
)
)
# 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
)
# Subscribe to loader's download progress events
self.loader.subscribe_download_progress(download_progress_handler)
try:
# 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
)
finally:
# Always unsubscribe after download completes or fails
self.loader.unsubscribe_download_progress(
download_progress_handler
)
if download_success:
logger.info(
@@ -495,29 +505,35 @@ class SeriesApp:
# 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
def scan_progress_handler(progress_data):
"""Handle scan progress events from scanner."""
# Fire scan progress event
message = progress_data.get('message', '')
folder = message.replace('Scanning: ', '')
self._events.scan_status(
ScanStatusEventArgs(
current=current,
total=total_to_scan,
current=progress_data.get('current', 0),
total=progress_data.get('total', total_to_scan),
folder=folder,
status="progress",
progress=progress,
message=f"Scanning: {folder}",
progress=(
progress_data.get('percentage', 0.0) / 100.0
),
message=message,
)
)
# Perform scan (file-based, returns results in scanner.keyDict)
await asyncio.to_thread(
self.serie_scanner.scan, scan_callback
)
# Subscribe to scanner's progress events
self.serie_scanner.subscribe_on_progress(scan_progress_handler)
try:
# Perform scan (file-based, returns results in scanner.keyDict)
await asyncio.to_thread(self.serie_scanner.scan)
finally:
# Always unsubscribe after scan completes or fails
self.serie_scanner.unsubscribe_on_progress(
scan_progress_handler
)
# Get scanned series from scanner
scanned_series = list(self.serie_scanner.keyDict.values())

View File

@@ -1,17 +1,17 @@
import html
import json
import logging
import os
import re
import shutil
import signal
import sys
import threading
from pathlib import Path
from urllib.parse import quote
import requests
from bs4 import BeautifulSoup
from events import Events
from fake_useragent import UserAgent
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
@@ -74,7 +74,7 @@ class AniworldLoader(Loader):
}
self.ANIWORLD_TO = "https://aniworld.to"
self.session = requests.Session()
# Cancellation flag for graceful shutdown
self._cancel_flag = threading.Event()
@@ -98,6 +98,25 @@ class AniworldLoader(Loader):
self._EpisodeHTMLDict = {}
self.Providers = Providers()
# Events: download_progress is triggered with progress dict
self.events = Events()
self.events.download_progress = None
def subscribe_download_progress(self, handler):
"""Subscribe a handler to the download_progress event.
Args:
handler: Callable to be called with progress dict.
"""
self.events.download_progress += handler
def unsubscribe_download_progress(self, handler):
"""Unsubscribe a handler from the download_progress event.
Args:
handler: Callable previously subscribed.
"""
self.events.download_progress -= handler
def clear_cache(self):
"""Clear the cached HTML data."""
logging.debug("Clearing HTML cache")
@@ -203,7 +222,7 @@ class AniworldLoader(Loader):
is_available = language_code in languages
logging.debug(f"Available languages for S{season:02}E{episode:03}: {languages}, requested: {language_code}, available: {is_available}")
return is_available
return is_available
def download(
self,
@@ -212,8 +231,7 @@ class AniworldLoader(Loader):
season: int,
episode: int,
key: str,
language: str = "German Dub",
progress_callback=None
language: str = "German Dub"
) -> bool:
"""Download episode to specified directory.
@@ -226,19 +244,9 @@ class AniworldLoader(Loader):
key: Series unique identifier from provider (used for
identification and API calls)
language: Audio language preference (default: German Dub)
progress_callback: Optional callback for download progress
Returns:
bool: True if download succeeded, False otherwise
Raises:
asyncio.CancelledError: If download was cancelled via request_cancel()
"""
# Check cancellation before starting
if self.is_cancelled():
logging.info("Download cancelled before starting")
raise InterruptedError("Download cancelled")
logging.info(
f"Starting download for S{season:02}E{episode:03} "
f"({key}) in {language}"
@@ -276,31 +284,21 @@ class AniworldLoader(Loader):
logging.debug(f"Temporary path: {temp_path}")
for provider in self.SUPPORTED_PROVIDERS:
# Check cancellation before each provider attempt
if self.is_cancelled():
logging.info("Download cancelled during provider selection")
raise InterruptedError("Download cancelled")
logging.debug(f"Attempting download with provider: {provider}")
link, header = self._get_direct_link_from_provider(
season, episode, key, language
)
logging.debug("Direct link obtained from provider")
# Create a cancellation-aware progress hook using DownloadCancelled
# which YT-DLP properly handles
cancel_flag = self._cancel_flag
def cancellation_check_hook(d):
"""Progress hook that checks for cancellation.
Uses yt_dlp.utils.DownloadCancelled which is properly
handled by YT-DLP to abort downloads immediately.
"""
def events_progress_hook(d):
if cancel_flag.is_set():
logging.info("Cancellation detected in progress hook")
raise DownloadCancelled("Download cancelled by user")
# Fire the event for progress
self.events.download_progress(d)
ydl_opts = {
'fragment_retries': float('inf'),
'outtmpl': temp_path,
@@ -308,36 +306,18 @@ class AniworldLoader(Loader):
'no_warnings': True,
'progress_with_newline': False,
'nocheckcertificate': True,
# Add cancellation check as a progress hook
'progress_hooks': [cancellation_check_hook],
'progress_hooks': [events_progress_hook],
}
if header:
ydl_opts['http_headers'] = header
logging.debug("Using custom headers for download")
if progress_callback:
# Wrap the callback to add logging and keep cancellation check
def logged_progress_callback(d):
# Check cancellation first - use DownloadCancelled
if cancel_flag.is_set():
logging.info("Cancellation detected in progress callback")
raise DownloadCancelled("Download cancelled by user")
logging.debug(
f"YT-DLP progress: status={d.get('status')}, "
f"downloaded={d.get('downloaded_bytes')}, "
f"total={d.get('total_bytes')}, "
f"speed={d.get('speed')}"
)
progress_callback(d)
ydl_opts['progress_hooks'] = [logged_progress_callback]
logging.debug("Progress callback registered with YT-DLP")
try:
logging.debug("Starting YoutubeDL download")
logging.debug(f"Download link: {link[:100]}...")
logging.debug(f"YDL options: {ydl_opts}")
with YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(link, download=True)
logging.debug(
@@ -346,14 +326,6 @@ class AniworldLoader(Loader):
f"filesize={info.get('filesize')}"
)
# Check cancellation after download completes
if self.is_cancelled():
logging.info("Download cancelled after completion")
# Clean up temp file if exists
if os.path.exists(temp_path):
os.remove(temp_path)
raise InterruptedError("Download cancelled")
if os.path.exists(temp_path):
logging.debug("Moving file from temp to final destination")
shutil.copy(temp_path, output_path)
@@ -369,44 +341,20 @@ class AniworldLoader(Loader):
)
self.clear_cache()
return False
except (InterruptedError, DownloadCancelled) as e:
# Re-raise cancellation errors
logging.info(
"Download cancelled: %s, propagating cancellation",
type(e).__name__
)
# Clean up temp file if exists
if os.path.exists(temp_path):
try:
os.remove(temp_path)
except OSError:
pass
raise InterruptedError("Download cancelled") from e
except BrokenPipeError as e:
logging.error(
f"Broken pipe error with provider {provider}: {e}. "
f"This usually means the stream connection was closed."
)
# Try next provider if available
continue
except Exception as e:
# Check if this is a cancellation wrapped in another exception
if self.is_cancelled():
logging.info("Download cancelled (detected in exception handler)")
if os.path.exists(temp_path):
try:
os.remove(temp_path)
except OSError:
pass
raise InterruptedError("Download cancelled") from e
logging.error(
f"YoutubeDL download failed with provider {provider}: "
f"{type(e).__name__}: {e}"
)
# Try next provider if available
continue
break
# If we get here, all providers failed
logging.error("All download providers failed")
self.clear_cache()

View File

@@ -1,20 +1,20 @@
from abc import ABC, abstractmethod
from typing import Any, Callable, Dict, List, Optional
from typing import Any, Dict, List
class Loader(ABC):
"""Abstract base class for anime data loaders/providers."""
@abstractmethod
def subscribe_download_progress(self, handler):
"""Subscribe a handler to the download_progress event.
Args:
handler: Callable to be called with progress dict.
def subscribe_download_progress(self, handler):
"""Subscribe a handler to the download_progress event.
Args:
handler: Callable to be called with progress dict.
"""
@abstractmethod
def unsubscribe_download_progress(self, handler):
"""Unsubscribe a handler from the download_progress event.
Args:
handler: Callable previously subscribed.
def unsubscribe_download_progress(self, handler):
"""Unsubscribe a handler from the download_progress event.
Args:
handler: Callable previously subscribed.
"""
@abstractmethod
@@ -56,8 +56,7 @@ class Loader(ABC):
season: int,
episode: int,
key: str,
language: str = "German Dub",
progress_callback: Optional[Callable[[str, Dict], None]] = None,
language: str = "German Dub"
) -> bool:
"""Download episode to specified directory.
@@ -68,8 +67,6 @@ class Loader(ABC):
episode: Episode number within season
key: Unique series identifier/key
language: Language version to download (default: German Dub)
progress_callback: Optional callback for progress updates
called with (event_type: str, data: Dict)
Returns:
True if download successful, False otherwise