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:
parent
ff9dea0488
commit
b1726968e5
@ -15,18 +15,12 @@ import os
|
|||||||
import re
|
import re
|
||||||
import traceback
|
import traceback
|
||||||
import uuid
|
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.entities.series import Serie
|
||||||
from src.core.exceptions.Exceptions import MatchNotFoundError, NoKeyFoundException
|
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
|
from src.core.providers.base_provider import Loader
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -55,7 +49,6 @@ class SerieScanner:
|
|||||||
self,
|
self,
|
||||||
basePath: str,
|
basePath: str,
|
||||||
loader: Loader,
|
loader: Loader,
|
||||||
callback_manager: Optional[CallbackManager] = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Initialize the SerieScanner.
|
Initialize the SerieScanner.
|
||||||
@ -82,17 +75,75 @@ class SerieScanner:
|
|||||||
self.directory: str = abs_path
|
self.directory: str = abs_path
|
||||||
self.keyDict: dict[str, Serie] = {}
|
self.keyDict: dict[str, Serie] = {}
|
||||||
self.loader: Loader = loader
|
self.loader: Loader = loader
|
||||||
self._callback_manager: CallbackManager = (
|
|
||||||
callback_manager or CallbackManager()
|
|
||||||
)
|
|
||||||
self._current_operation_id: Optional[str] = None
|
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)
|
logger.info("Initialized SerieScanner with base path: %s", abs_path)
|
||||||
|
|
||||||
@property
|
def _safe_call_event(self, event_handler, data: dict) -> None:
|
||||||
def callback_manager(self) -> CallbackManager:
|
"""Safely call an event handler if it exists.
|
||||||
"""Get the callback manager instance."""
|
|
||||||
return self._callback_manager
|
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)
|
||||||
|
|
||||||
|
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:
|
def reinit(self) -> None:
|
||||||
"""Reinitialize the series dictionary (keyed by serie.key)."""
|
"""Reinitialize the series dictionary (keyed by serie.key)."""
|
||||||
@ -107,20 +158,13 @@ class SerieScanner:
|
|||||||
result = self.__find_mp4_files()
|
result = self.__find_mp4_files()
|
||||||
return sum(1 for _ in result)
|
return sum(1 for _ in result)
|
||||||
|
|
||||||
def scan(
|
def scan(self) -> None:
|
||||||
self,
|
|
||||||
callback: Optional[Callable[[str, int], None]] = None
|
|
||||||
) -> None:
|
|
||||||
"""
|
"""
|
||||||
Scan directories for anime series and missing episodes.
|
Scan directories for anime series and missing episodes.
|
||||||
|
|
||||||
Results are stored in self.keyDict and can be retrieved after
|
Results are stored in self.keyDict and can be retrieved after
|
||||||
scanning. Data files are also saved to disk for persistence.
|
scanning. Data files are also saved to disk for persistence.
|
||||||
|
|
||||||
Args:
|
|
||||||
callback: Optional callback function (folder, count) for
|
|
||||||
progress updates
|
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
Exception: If scan fails critically
|
Exception: If scan fails critically
|
||||||
"""
|
"""
|
||||||
@ -130,16 +174,16 @@ class SerieScanner:
|
|||||||
logger.info("Starting scan for missing episodes")
|
logger.info("Starting scan for missing episodes")
|
||||||
|
|
||||||
# Notify scan starting
|
# Notify scan starting
|
||||||
self._callback_manager.notify_progress(
|
self._safe_call_event(
|
||||||
ProgressContext(
|
self.events.on_progress,
|
||||||
operation_type=OperationType.SCAN,
|
{
|
||||||
operation_id=self._current_operation_id,
|
"operation_id": self._current_operation_id,
|
||||||
phase=ProgressPhase.STARTING,
|
"phase": "STARTING",
|
||||||
current=0,
|
"current": 0,
|
||||||
total=0,
|
"total": 0,
|
||||||
percentage=0.0,
|
"percentage": 0.0,
|
||||||
message="Initializing scan"
|
"message": "Initializing scan"
|
||||||
)
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -163,26 +207,19 @@ class SerieScanner:
|
|||||||
else:
|
else:
|
||||||
percentage = 0.0
|
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
|
# Notify progress
|
||||||
self._callback_manager.notify_progress(
|
self._safe_call_event(
|
||||||
ProgressContext(
|
self.events.on_progress,
|
||||||
operation_type=OperationType.SCAN,
|
{
|
||||||
operation_id=self._current_operation_id,
|
"operation_id": self._current_operation_id,
|
||||||
phase=ProgressPhase.IN_PROGRESS,
|
"phase": "IN_PROGRESS",
|
||||||
current=counter,
|
"current": counter,
|
||||||
total=total_to_scan,
|
"total": total_to_scan,
|
||||||
percentage=percentage,
|
"percentage": percentage,
|
||||||
message=f"Scanning: {folder}",
|
"message": f"Scanning: {folder}",
|
||||||
details=f"Found {len(mp4_files)} episodes"
|
"details": f"Found {len(mp4_files)} episodes"
|
||||||
|
}
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|
||||||
# Call legacy callback if provided
|
|
||||||
if callback:
|
|
||||||
callback(folder, counter)
|
|
||||||
|
|
||||||
serie = self.__read_data_from_file(folder)
|
serie = self.__read_data_from_file(folder)
|
||||||
if (
|
if (
|
||||||
@ -230,15 +267,15 @@ class SerieScanner:
|
|||||||
error_msg = f"Error processing folder '{folder}': {nkfe}"
|
error_msg = f"Error processing folder '{folder}': {nkfe}"
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
|
|
||||||
self._callback_manager.notify_error(
|
self._safe_call_event(
|
||||||
ErrorContext(
|
self.events.on_error,
|
||||||
operation_type=OperationType.SCAN,
|
{
|
||||||
operation_id=self._current_operation_id,
|
"operation_id": self._current_operation_id,
|
||||||
error=nkfe,
|
"error": nkfe,
|
||||||
message=error_msg,
|
"message": error_msg,
|
||||||
recoverable=True,
|
"recoverable": True,
|
||||||
metadata={"folder": folder, "key": None}
|
"metadata": {"folder": folder, "key": None}
|
||||||
)
|
}
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Log error and notify via callback
|
# Log error and notify via callback
|
||||||
@ -252,30 +289,30 @@ class SerieScanner:
|
|||||||
traceback.format_exc()
|
traceback.format_exc()
|
||||||
)
|
)
|
||||||
|
|
||||||
self._callback_manager.notify_error(
|
self._safe_call_event(
|
||||||
ErrorContext(
|
self.events.on_error,
|
||||||
operation_type=OperationType.SCAN,
|
{
|
||||||
operation_id=self._current_operation_id,
|
"operation_id": self._current_operation_id,
|
||||||
error=e,
|
"error": e,
|
||||||
message=error_msg,
|
"message": error_msg,
|
||||||
recoverable=True,
|
"recoverable": True,
|
||||||
metadata={"folder": folder, "key": None}
|
"metadata": {"folder": folder, "key": None}
|
||||||
)
|
}
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Notify scan completion
|
# Notify scan completion
|
||||||
self._callback_manager.notify_completion(
|
self._safe_call_event(
|
||||||
CompletionContext(
|
self.events.on_completion,
|
||||||
operation_type=OperationType.SCAN,
|
{
|
||||||
operation_id=self._current_operation_id,
|
"operation_id": self._current_operation_id,
|
||||||
success=True,
|
"success": True,
|
||||||
message=f"Scan completed. Processed {counter} folders.",
|
"message": f"Scan completed. Processed {counter} folders.",
|
||||||
statistics={
|
"statistics": {
|
||||||
"total_folders": counter,
|
"total_folders": counter,
|
||||||
"series_found": len(self.keyDict)
|
"series_found": len(self.keyDict)
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@ -289,23 +326,23 @@ class SerieScanner:
|
|||||||
error_msg = f"Critical scan error: {e}"
|
error_msg = f"Critical scan error: {e}"
|
||||||
logger.error("%s\n%s", error_msg, traceback.format_exc())
|
logger.error("%s\n%s", error_msg, traceback.format_exc())
|
||||||
|
|
||||||
self._callback_manager.notify_error(
|
self._safe_call_event(
|
||||||
ErrorContext(
|
self.events.on_error,
|
||||||
operation_type=OperationType.SCAN,
|
{
|
||||||
operation_id=self._current_operation_id,
|
"operation_id": self._current_operation_id,
|
||||||
error=e,
|
"error": e,
|
||||||
message=error_msg,
|
"message": error_msg,
|
||||||
recoverable=False
|
"recoverable": False
|
||||||
)
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
self._callback_manager.notify_completion(
|
self._safe_call_event(
|
||||||
CompletionContext(
|
self.events.on_completion,
|
||||||
operation_type=OperationType.SCAN,
|
{
|
||||||
operation_id=self._current_operation_id,
|
"operation_id": self._current_operation_id,
|
||||||
success=False,
|
"success": False,
|
||||||
message=error_msg
|
"message": error_msg
|
||||||
)
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
raise
|
raise
|
||||||
@ -325,16 +362,6 @@ class SerieScanner:
|
|||||||
has_files = True
|
has_files = True
|
||||||
yield anime_name, mp4_files if has_files else []
|
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]:
|
def __read_data_from_file(self, folder_name: str) -> Optional[Serie]:
|
||||||
"""Read serie data from file or key file.
|
"""Read serie data from file or key file.
|
||||||
|
|
||||||
@ -507,19 +534,18 @@ class SerieScanner:
|
|||||||
|
|
||||||
# Generate unique operation ID for this targeted scan
|
# Generate unique operation ID for this targeted scan
|
||||||
operation_id = str(uuid.uuid4())
|
operation_id = str(uuid.uuid4())
|
||||||
|
|
||||||
# Notify scan starting
|
# Notify scan starting
|
||||||
self._callback_manager.notify_progress(
|
self._safe_call_event(
|
||||||
ProgressContext(
|
self.events.on_progress,
|
||||||
operation_type=OperationType.SCAN,
|
{
|
||||||
operation_id=operation_id,
|
"operation_id": operation_id,
|
||||||
phase=ProgressPhase.STARTING,
|
"phase": "STARTING",
|
||||||
current=0,
|
"current": 0,
|
||||||
total=1,
|
"total": 1,
|
||||||
percentage=0.0,
|
"percentage": 0.0,
|
||||||
message=f"Scanning series: {folder}",
|
"message": f"Scanning series: {folder}",
|
||||||
details=f"Key: {key}"
|
"details": f"Key: {key}"
|
||||||
)
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -554,17 +580,17 @@ class SerieScanner:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Update progress
|
# Update progress
|
||||||
self._callback_manager.notify_progress(
|
self._safe_call_event(
|
||||||
ProgressContext(
|
self.events.on_progress,
|
||||||
operation_type=OperationType.SCAN,
|
{
|
||||||
operation_id=operation_id,
|
"operation_id": operation_id,
|
||||||
phase=ProgressPhase.IN_PROGRESS,
|
"phase": "IN_PROGRESS",
|
||||||
current=1,
|
"current": 1,
|
||||||
total=1,
|
"total": 1,
|
||||||
percentage=100.0,
|
"percentage": 100.0,
|
||||||
message=f"Scanned: {folder}",
|
"message": f"Scanned: {folder}",
|
||||||
details=f"Found {sum(len(eps) for eps in missing_episodes.values())} missing episodes"
|
"details": f"Found {sum(len(eps) for eps in missing_episodes.values())} missing episodes"
|
||||||
)
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create or update Serie in keyDict
|
# Create or update Serie in keyDict
|
||||||
@ -593,19 +619,19 @@ class SerieScanner:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Notify completion
|
# Notify completion
|
||||||
self._callback_manager.notify_completion(
|
self._safe_call_event(
|
||||||
CompletionContext(
|
self.events.on_completion,
|
||||||
operation_type=OperationType.SCAN,
|
{
|
||||||
operation_id=operation_id,
|
"operation_id": operation_id,
|
||||||
success=True,
|
"success": True,
|
||||||
message=f"Scan completed for {folder}",
|
"message": f"Scan completed for {folder}",
|
||||||
statistics={
|
"statistics": {
|
||||||
"missing_episodes": sum(
|
"missing_episodes": sum(
|
||||||
len(eps) for eps in missing_episodes.values()
|
len(eps) for eps in missing_episodes.values()
|
||||||
),
|
),
|
||||||
"seasons_with_missing": len(missing_episodes)
|
"seasons_with_missing": len(missing_episodes)
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@ -622,27 +648,25 @@ class SerieScanner:
|
|||||||
logger.error(error_msg, exc_info=True)
|
logger.error(error_msg, exc_info=True)
|
||||||
|
|
||||||
# Notify error
|
# Notify error
|
||||||
self._callback_manager.notify_error(
|
self._safe_call_event(
|
||||||
ErrorContext(
|
self.events.on_error,
|
||||||
operation_type=OperationType.SCAN,
|
{
|
||||||
operation_id=operation_id,
|
"operation_id": operation_id,
|
||||||
error=e,
|
"error": e,
|
||||||
message=error_msg,
|
"message": error_msg,
|
||||||
recoverable=True,
|
"recoverable": True,
|
||||||
metadata={"key": key, "folder": folder}
|
"metadata": {"key": key, "folder": folder}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|
||||||
# Notify completion with failure
|
# Notify completion with failure
|
||||||
self._callback_manager.notify_completion(
|
self._safe_call_event(
|
||||||
CompletionContext(
|
self.events.on_completion,
|
||||||
operation_type=OperationType.SCAN,
|
{
|
||||||
operation_id=operation_id,
|
"operation_id": operation_id,
|
||||||
success=False,
|
"success": False,
|
||||||
message=error_msg
|
"message": error_msg
|
||||||
|
}
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|
||||||
# Return empty dict on error (scan failed but not critical)
|
# Return empty dict on error (scan failed but not critical)
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|||||||
@ -309,9 +309,10 @@ class SeriesApp:
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
def download_callback(progress_info):
|
def download_progress_handler(progress_info):
|
||||||
|
"""Handle download progress events from loader."""
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"wrapped_callback called with: %s", progress_info
|
"download_progress_handler called with: %s", progress_info
|
||||||
)
|
)
|
||||||
|
|
||||||
downloaded = progress_info.get('downloaded_bytes', 0)
|
downloaded = progress_info.get('downloaded_bytes', 0)
|
||||||
@ -341,6 +342,11 @@ class SeriesApp:
|
|||||||
item_id=item_id,
|
item_id=item_id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 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
|
# Perform download in thread to avoid blocking event loop
|
||||||
download_success = await asyncio.to_thread(
|
download_success = await asyncio.to_thread(
|
||||||
self.loader.download,
|
self.loader.download,
|
||||||
@ -349,8 +355,12 @@ class SeriesApp:
|
|||||||
season,
|
season,
|
||||||
episode,
|
episode,
|
||||||
key,
|
key,
|
||||||
language,
|
language
|
||||||
download_callback
|
)
|
||||||
|
finally:
|
||||||
|
# Always unsubscribe after download completes or fails
|
||||||
|
self.loader.unsubscribe_download_progress(
|
||||||
|
download_progress_handler
|
||||||
)
|
)
|
||||||
|
|
||||||
if download_success:
|
if download_success:
|
||||||
@ -495,28 +505,34 @@ class SeriesApp:
|
|||||||
# Reinitialize scanner
|
# Reinitialize scanner
|
||||||
await asyncio.to_thread(self.serie_scanner.reinit)
|
await asyncio.to_thread(self.serie_scanner.reinit)
|
||||||
|
|
||||||
def scan_callback(folder: str, current: int):
|
def scan_progress_handler(progress_data):
|
||||||
# Calculate progress
|
"""Handle scan progress events from scanner."""
|
||||||
if total_to_scan > 0:
|
|
||||||
progress = current / total_to_scan
|
|
||||||
else:
|
|
||||||
progress = 0.0
|
|
||||||
|
|
||||||
# Fire scan progress event
|
# Fire scan progress event
|
||||||
|
message = progress_data.get('message', '')
|
||||||
|
folder = message.replace('Scanning: ', '')
|
||||||
self._events.scan_status(
|
self._events.scan_status(
|
||||||
ScanStatusEventArgs(
|
ScanStatusEventArgs(
|
||||||
current=current,
|
current=progress_data.get('current', 0),
|
||||||
total=total_to_scan,
|
total=progress_data.get('total', total_to_scan),
|
||||||
folder=folder,
|
folder=folder,
|
||||||
status="progress",
|
status="progress",
|
||||||
progress=progress,
|
progress=(
|
||||||
message=f"Scanning: {folder}",
|
progress_data.get('percentage', 0.0) / 100.0
|
||||||
|
),
|
||||||
|
message=message,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 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)
|
# Perform scan (file-based, returns results in scanner.keyDict)
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(self.serie_scanner.scan)
|
||||||
self.serie_scanner.scan, scan_callback
|
finally:
|
||||||
|
# Always unsubscribe after scan completes or fails
|
||||||
|
self.serie_scanner.unsubscribe_on_progress(
|
||||||
|
scan_progress_handler
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get scanned series from scanner
|
# Get scanned series from scanner
|
||||||
|
|||||||
@ -1,17 +1,17 @@
|
|||||||
|
|
||||||
import html
|
import html
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import signal
|
|
||||||
import sys
|
|
||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
|
from events import Events
|
||||||
from fake_useragent import UserAgent
|
from fake_useragent import UserAgent
|
||||||
from requests.adapters import HTTPAdapter
|
from requests.adapters import HTTPAdapter
|
||||||
from urllib3.util.retry import Retry
|
from urllib3.util.retry import Retry
|
||||||
@ -98,6 +98,25 @@ class AniworldLoader(Loader):
|
|||||||
self._EpisodeHTMLDict = {}
|
self._EpisodeHTMLDict = {}
|
||||||
self.Providers = Providers()
|
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):
|
def clear_cache(self):
|
||||||
"""Clear the cached HTML data."""
|
"""Clear the cached HTML data."""
|
||||||
logging.debug("Clearing HTML cache")
|
logging.debug("Clearing HTML cache")
|
||||||
@ -212,8 +231,7 @@ class AniworldLoader(Loader):
|
|||||||
season: int,
|
season: int,
|
||||||
episode: int,
|
episode: int,
|
||||||
key: str,
|
key: str,
|
||||||
language: str = "German Dub",
|
language: str = "German Dub"
|
||||||
progress_callback=None
|
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Download episode to specified directory.
|
"""Download episode to specified directory.
|
||||||
|
|
||||||
@ -226,19 +244,9 @@ class AniworldLoader(Loader):
|
|||||||
key: Series unique identifier from provider (used for
|
key: Series unique identifier from provider (used for
|
||||||
identification and API calls)
|
identification and API calls)
|
||||||
language: Audio language preference (default: German Dub)
|
language: Audio language preference (default: German Dub)
|
||||||
progress_callback: Optional callback for download progress
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: True if download succeeded, False otherwise
|
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(
|
logging.info(
|
||||||
f"Starting download for S{season:02}E{episode:03} "
|
f"Starting download for S{season:02}E{episode:03} "
|
||||||
f"({key}) in {language}"
|
f"({key}) in {language}"
|
||||||
@ -276,30 +284,20 @@ class AniworldLoader(Loader):
|
|||||||
logging.debug(f"Temporary path: {temp_path}")
|
logging.debug(f"Temporary path: {temp_path}")
|
||||||
|
|
||||||
for provider in self.SUPPORTED_PROVIDERS:
|
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}")
|
logging.debug(f"Attempting download with provider: {provider}")
|
||||||
link, header = self._get_direct_link_from_provider(
|
link, header = self._get_direct_link_from_provider(
|
||||||
season, episode, key, language
|
season, episode, key, language
|
||||||
)
|
)
|
||||||
logging.debug("Direct link obtained from provider")
|
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
|
cancel_flag = self._cancel_flag
|
||||||
|
|
||||||
def cancellation_check_hook(d):
|
def events_progress_hook(d):
|
||||||
"""Progress hook that checks for cancellation.
|
|
||||||
|
|
||||||
Uses yt_dlp.utils.DownloadCancelled which is properly
|
|
||||||
handled by YT-DLP to abort downloads immediately.
|
|
||||||
"""
|
|
||||||
if cancel_flag.is_set():
|
if cancel_flag.is_set():
|
||||||
logging.info("Cancellation detected in progress hook")
|
logging.info("Cancellation detected in progress hook")
|
||||||
raise DownloadCancelled("Download cancelled by user")
|
raise DownloadCancelled("Download cancelled by user")
|
||||||
|
# Fire the event for progress
|
||||||
|
self.events.download_progress(d)
|
||||||
|
|
||||||
ydl_opts = {
|
ydl_opts = {
|
||||||
'fragment_retries': float('inf'),
|
'fragment_retries': float('inf'),
|
||||||
@ -308,30 +306,12 @@ class AniworldLoader(Loader):
|
|||||||
'no_warnings': True,
|
'no_warnings': True,
|
||||||
'progress_with_newline': False,
|
'progress_with_newline': False,
|
||||||
'nocheckcertificate': True,
|
'nocheckcertificate': True,
|
||||||
# Add cancellation check as a progress hook
|
'progress_hooks': [events_progress_hook],
|
||||||
'progress_hooks': [cancellation_check_hook],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if header:
|
if header:
|
||||||
ydl_opts['http_headers'] = header
|
ydl_opts['http_headers'] = header
|
||||||
logging.debug("Using custom headers for download")
|
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:
|
try:
|
||||||
logging.debug("Starting YoutubeDL download")
|
logging.debug("Starting YoutubeDL download")
|
||||||
@ -346,14 +326,6 @@ class AniworldLoader(Loader):
|
|||||||
f"filesize={info.get('filesize')}"
|
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):
|
if os.path.exists(temp_path):
|
||||||
logging.debug("Moving file from temp to final destination")
|
logging.debug("Moving file from temp to final destination")
|
||||||
shutil.copy(temp_path, output_path)
|
shutil.copy(temp_path, output_path)
|
||||||
@ -369,41 +341,17 @@ class AniworldLoader(Loader):
|
|||||||
)
|
)
|
||||||
self.clear_cache()
|
self.clear_cache()
|
||||||
return False
|
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:
|
except BrokenPipeError as e:
|
||||||
logging.error(
|
logging.error(
|
||||||
f"Broken pipe error with provider {provider}: {e}. "
|
f"Broken pipe error with provider {provider}: {e}. "
|
||||||
f"This usually means the stream connection was closed."
|
f"This usually means the stream connection was closed."
|
||||||
)
|
)
|
||||||
# Try next provider if available
|
|
||||||
continue
|
continue
|
||||||
except Exception as e:
|
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(
|
logging.error(
|
||||||
f"YoutubeDL download failed with provider {provider}: "
|
f"YoutubeDL download failed with provider {provider}: "
|
||||||
f"{type(e).__name__}: {e}"
|
f"{type(e).__name__}: {e}"
|
||||||
)
|
)
|
||||||
# Try next provider if available
|
|
||||||
continue
|
continue
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Any, Callable, Dict, List, Optional
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
|
||||||
class Loader(ABC):
|
class Loader(ABC):
|
||||||
@ -56,8 +56,7 @@ class Loader(ABC):
|
|||||||
season: int,
|
season: int,
|
||||||
episode: int,
|
episode: int,
|
||||||
key: str,
|
key: str,
|
||||||
language: str = "German Dub",
|
language: str = "German Dub"
|
||||||
progress_callback: Optional[Callable[[str, Dict], None]] = None,
|
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Download episode to specified directory.
|
"""Download episode to specified directory.
|
||||||
|
|
||||||
@ -68,8 +67,6 @@ class Loader(ABC):
|
|||||||
episode: Episode number within season
|
episode: Episode number within season
|
||||||
key: Unique series identifier/key
|
key: Unique series identifier/key
|
||||||
language: Language version to download (default: German Dub)
|
language: Language version to download (default: German Dub)
|
||||||
progress_callback: Optional callback for progress updates
|
|
||||||
called with (event_type: str, data: Dict)
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if download successful, False otherwise
|
True if download successful, False otherwise
|
||||||
|
|||||||
@ -1007,92 +1007,7 @@ class DownloadService:
|
|||||||
if self._active_download and self._active_download.id == item.id:
|
if self._active_download and self._active_download.id == item.id:
|
||||||
self._active_download = None
|
self._active_download = None
|
||||||
|
|
||||||
async def start(self) -> None:
|
|
||||||
"""Initialize the download queue service (compatibility method).
|
|
||||||
|
|
||||||
Note: Downloads are started manually via start_next_download().
|
|
||||||
"""
|
|
||||||
logger.info("Download queue service initialized")
|
|
||||||
|
|
||||||
async def stop(self, timeout: float = 10.0) -> None:
|
|
||||||
"""Stop the download queue service gracefully.
|
|
||||||
|
|
||||||
Persists in-progress downloads back to pending state, cancels active
|
|
||||||
tasks, and shuts down the thread pool with a timeout.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
timeout: Maximum time (seconds) to wait for executor shutdown
|
|
||||||
"""
|
|
||||||
logger.info("Stopping download queue service (timeout=%.1fs)...", timeout)
|
|
||||||
|
|
||||||
# Set shutdown flag first to prevent new downloads
|
|
||||||
self._is_shutting_down = True
|
|
||||||
self._is_stopped = True
|
|
||||||
|
|
||||||
# Request cancellation from AnimeService (signals the download thread)
|
|
||||||
try:
|
|
||||||
self._anime_service.request_download_cancel()
|
|
||||||
logger.info("Requested download cancellation from AnimeService")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Failed to request download cancellation: %s", e)
|
|
||||||
|
|
||||||
# Persist active download back to pending state if one exists
|
|
||||||
if self._active_download:
|
|
||||||
logger.info(
|
|
||||||
"Persisting active download to pending: item_id=%s",
|
|
||||||
self._active_download.id
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
# Reset status to pending so it can be resumed on restart
|
|
||||||
self._active_download.status = DownloadStatus.PENDING
|
|
||||||
self._active_download.completed_at = None
|
|
||||||
await self._save_to_database(self._active_download)
|
|
||||||
logger.info("Active download persisted to database as pending")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Failed to persist active download: %s", e)
|
|
||||||
|
|
||||||
# Cancel active download task if running
|
|
||||||
active_task = self._active_download_task
|
|
||||||
if active_task and not active_task.done():
|
|
||||||
logger.info("Cancelling active download task...")
|
|
||||||
active_task.cancel()
|
|
||||||
try:
|
|
||||||
# Wait briefly for cancellation to complete
|
|
||||||
await asyncio.wait_for(
|
|
||||||
asyncio.shield(active_task),
|
|
||||||
timeout=2.0
|
|
||||||
)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
logger.warning("Download task cancellation timed out")
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
logger.info("Active download task cancelled")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Error during task cancellation: %s", e)
|
|
||||||
|
|
||||||
# Shutdown executor with wait and timeout
|
|
||||||
logger.info("Shutting down thread pool executor...")
|
|
||||||
try:
|
|
||||||
# Run executor shutdown in thread to avoid blocking event loop
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
await asyncio.wait_for(
|
|
||||||
loop.run_in_executor(
|
|
||||||
None,
|
|
||||||
lambda: self._executor.shutdown(wait=True, cancel_futures=True)
|
|
||||||
),
|
|
||||||
timeout=timeout
|
|
||||||
)
|
|
||||||
logger.info("Thread pool executor shutdown complete")
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
logger.warning(
|
|
||||||
"Executor shutdown timed out after %.1fs, forcing shutdown",
|
|
||||||
timeout
|
|
||||||
)
|
|
||||||
# Force shutdown without waiting
|
|
||||||
self._executor.shutdown(wait=False, cancel_futures=True)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Error during executor shutdown: %s", e)
|
|
||||||
|
|
||||||
logger.info("Download queue service stopped")
|
|
||||||
|
|
||||||
|
|
||||||
# Singleton instance
|
# Singleton instance
|
||||||
|
|||||||
@ -13,20 +13,8 @@ from typing import Any, Callable, Dict, List, Optional
|
|||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
from src.core.interfaces.callbacks import (
|
|
||||||
CallbackManager,
|
|
||||||
CompletionCallback,
|
|
||||||
CompletionContext,
|
|
||||||
ErrorCallback,
|
|
||||||
ErrorContext,
|
|
||||||
OperationType,
|
|
||||||
ProgressCallback,
|
|
||||||
ProgressContext,
|
|
||||||
ProgressPhase,
|
|
||||||
)
|
|
||||||
from src.server.services.progress_service import (
|
from src.server.services.progress_service import (
|
||||||
ProgressService,
|
ProgressService,
|
||||||
ProgressStatus,
|
|
||||||
ProgressType,
|
ProgressType,
|
||||||
get_progress_service,
|
get_progress_service,
|
||||||
)
|
)
|
||||||
@ -104,173 +92,6 @@ class ScanProgress:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
class ScanServiceProgressCallback(ProgressCallback):
|
|
||||||
"""Callback implementation for forwarding scan progress to ScanService.
|
|
||||||
|
|
||||||
This callback receives progress events from SerieScanner and forwards
|
|
||||||
them to the ScanService for processing and broadcasting.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
service: "ScanService",
|
|
||||||
scan_progress: ScanProgress,
|
|
||||||
):
|
|
||||||
"""Initialize the callback.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
service: Parent ScanService instance
|
|
||||||
scan_progress: ScanProgress to update
|
|
||||||
"""
|
|
||||||
self._service = service
|
|
||||||
self._scan_progress = scan_progress
|
|
||||||
|
|
||||||
def on_progress(self, context: ProgressContext) -> None:
|
|
||||||
"""Handle progress update from SerieScanner.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
context: Progress context with key and folder information
|
|
||||||
"""
|
|
||||||
self._scan_progress.current = context.current
|
|
||||||
self._scan_progress.total = context.total
|
|
||||||
self._scan_progress.percentage = context.percentage
|
|
||||||
self._scan_progress.message = context.message
|
|
||||||
self._scan_progress.key = context.key
|
|
||||||
self._scan_progress.folder = context.folder
|
|
||||||
self._scan_progress.updated_at = datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
if context.phase == ProgressPhase.STARTING:
|
|
||||||
self._scan_progress.status = "started"
|
|
||||||
elif context.phase == ProgressPhase.IN_PROGRESS:
|
|
||||||
self._scan_progress.status = "in_progress"
|
|
||||||
elif context.phase == ProgressPhase.COMPLETED:
|
|
||||||
self._scan_progress.status = "completed"
|
|
||||||
elif context.phase == ProgressPhase.FAILED:
|
|
||||||
self._scan_progress.status = "failed"
|
|
||||||
|
|
||||||
# Forward to service for broadcasting
|
|
||||||
# Use run_coroutine_threadsafe if event loop is available
|
|
||||||
try:
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
asyncio.run_coroutine_threadsafe(
|
|
||||||
self._service._handle_progress_update(self._scan_progress),
|
|
||||||
loop
|
|
||||||
)
|
|
||||||
except RuntimeError:
|
|
||||||
# No running event loop - likely in test or sync context
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class ScanServiceErrorCallback(ErrorCallback):
|
|
||||||
"""Callback implementation for handling scan errors.
|
|
||||||
|
|
||||||
This callback receives error events from SerieScanner and forwards
|
|
||||||
them to the ScanService for processing and broadcasting.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
service: "ScanService",
|
|
||||||
scan_progress: ScanProgress,
|
|
||||||
):
|
|
||||||
"""Initialize the callback.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
service: Parent ScanService instance
|
|
||||||
scan_progress: ScanProgress to update
|
|
||||||
"""
|
|
||||||
self._service = service
|
|
||||||
self._scan_progress = scan_progress
|
|
||||||
|
|
||||||
def on_error(self, context: ErrorContext) -> None:
|
|
||||||
"""Handle error from SerieScanner.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
context: Error context with key and folder information
|
|
||||||
"""
|
|
||||||
error_msg = context.message
|
|
||||||
if context.folder:
|
|
||||||
error_msg = f"[{context.folder}] {error_msg}"
|
|
||||||
|
|
||||||
self._scan_progress.errors.append(error_msg)
|
|
||||||
self._scan_progress.updated_at = datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
logger.warning(
|
|
||||||
"Scan error",
|
|
||||||
key=context.key,
|
|
||||||
folder=context.folder,
|
|
||||||
error=str(context.error),
|
|
||||||
recoverable=context.recoverable,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Forward to service for broadcasting
|
|
||||||
# Use run_coroutine_threadsafe if event loop is available
|
|
||||||
try:
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
asyncio.run_coroutine_threadsafe(
|
|
||||||
self._service._handle_scan_error(
|
|
||||||
self._scan_progress,
|
|
||||||
context,
|
|
||||||
),
|
|
||||||
loop
|
|
||||||
)
|
|
||||||
except RuntimeError:
|
|
||||||
# No running event loop - likely in test or sync context
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class ScanServiceCompletionCallback(CompletionCallback):
|
|
||||||
"""Callback implementation for handling scan completion.
|
|
||||||
|
|
||||||
This callback receives completion events from SerieScanner and forwards
|
|
||||||
them to the ScanService for processing and broadcasting.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
service: "ScanService",
|
|
||||||
scan_progress: ScanProgress,
|
|
||||||
):
|
|
||||||
"""Initialize the callback.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
service: Parent ScanService instance
|
|
||||||
scan_progress: ScanProgress to update
|
|
||||||
"""
|
|
||||||
self._service = service
|
|
||||||
self._scan_progress = scan_progress
|
|
||||||
|
|
||||||
def on_completion(self, context: CompletionContext) -> None:
|
|
||||||
"""Handle completion from SerieScanner.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
context: Completion context with statistics
|
|
||||||
"""
|
|
||||||
self._scan_progress.status = "completed" if context.success else "failed"
|
|
||||||
self._scan_progress.message = context.message
|
|
||||||
self._scan_progress.updated_at = datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
if context.statistics:
|
|
||||||
self._scan_progress.series_found = context.statistics.get(
|
|
||||||
"series_found", 0
|
|
||||||
)
|
|
||||||
|
|
||||||
# Forward to service for broadcasting
|
|
||||||
# Use run_coroutine_threadsafe if event loop is available
|
|
||||||
try:
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
asyncio.run_coroutine_threadsafe(
|
|
||||||
self._service._handle_scan_completion(
|
|
||||||
self._scan_progress,
|
|
||||||
context,
|
|
||||||
),
|
|
||||||
loop
|
|
||||||
)
|
|
||||||
except RuntimeError:
|
|
||||||
# No running event loop - likely in test or sync context
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class ScanService:
|
class ScanService:
|
||||||
"""Manages anime library scan operations.
|
"""Manages anime library scan operations.
|
||||||
|
|
||||||
@ -376,13 +197,13 @@ class ScanService:
|
|||||||
|
|
||||||
async def start_scan(
|
async def start_scan(
|
||||||
self,
|
self,
|
||||||
scanner_factory: Callable[..., Any],
|
scanner: Any, # SerieScanner instance
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Start a new library scan.
|
"""Start a new library scan.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
scanner_factory: Factory function that creates a SerieScanner.
|
scanner: SerieScanner instance to use for scanning.
|
||||||
The factory should accept a callback_manager parameter.
|
The service will subscribe to its events.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Scan ID for tracking
|
Scan ID for tracking
|
||||||
@ -424,41 +245,81 @@ class ScanService:
|
|||||||
"message": "Library scan started",
|
"message": "Library scan started",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Create event handlers for the scanner
|
||||||
|
def on_progress_handler(progress_data: Dict[str, Any]) -> None:
|
||||||
|
"""Handle progress events from scanner."""
|
||||||
|
scan_progress.current = progress_data.get('current', 0)
|
||||||
|
scan_progress.total = progress_data.get('total', 0)
|
||||||
|
scan_progress.percentage = progress_data.get('percentage', 0.0)
|
||||||
|
scan_progress.message = progress_data.get('message', '')
|
||||||
|
scan_progress.updated_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
phase = progress_data.get('phase', '')
|
||||||
|
if phase == 'STARTING':
|
||||||
|
scan_progress.status = "started"
|
||||||
|
elif phase == 'IN_PROGRESS':
|
||||||
|
scan_progress.status = "in_progress"
|
||||||
|
|
||||||
|
# Schedule the progress update on the event loop
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
asyncio.run_coroutine_threadsafe(
|
||||||
|
self._handle_progress_update(scan_progress),
|
||||||
|
loop
|
||||||
|
)
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def on_error_handler(error_data: Dict[str, Any]) -> None:
|
||||||
|
"""Handle error events from scanner."""
|
||||||
|
error_msg = error_data.get('message', 'Unknown error')
|
||||||
|
scan_progress.errors.append(error_msg)
|
||||||
|
scan_progress.updated_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
"Scan error",
|
||||||
|
error=str(error_data.get('error')),
|
||||||
|
recoverable=error_data.get('recoverable', True),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Schedule the error handling on the event loop
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
asyncio.run_coroutine_threadsafe(
|
||||||
|
self._handle_scan_error(scan_progress, error_data),
|
||||||
|
loop
|
||||||
|
)
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def on_completion_handler(completion_data: Dict[str, Any]) -> None:
|
||||||
|
"""Handle completion events from scanner."""
|
||||||
|
success = completion_data.get('success', False)
|
||||||
|
scan_progress.status = "completed" if success else "failed"
|
||||||
|
scan_progress.message = completion_data.get('message', '')
|
||||||
|
scan_progress.updated_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
if 'statistics' in completion_data:
|
||||||
|
stats = completion_data['statistics']
|
||||||
|
scan_progress.series_found = stats.get('series_found', 0)
|
||||||
|
|
||||||
|
# Schedule the completion handling on the event loop
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
asyncio.run_coroutine_threadsafe(
|
||||||
|
self._handle_scan_completion(scan_progress, completion_data),
|
||||||
|
loop
|
||||||
|
)
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Subscribe to scanner events
|
||||||
|
scanner.subscribe_on_progress(on_progress_handler)
|
||||||
|
scanner.subscribe_on_error(on_error_handler)
|
||||||
|
scanner.subscribe_on_completion(on_completion_handler)
|
||||||
|
|
||||||
return scan_id
|
return scan_id
|
||||||
|
|
||||||
def create_callback_manager(
|
|
||||||
self,
|
|
||||||
scan_progress: Optional[ScanProgress] = None,
|
|
||||||
) -> CallbackManager:
|
|
||||||
"""Create a callback manager for scan operations.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
scan_progress: Optional scan progress to use. If None,
|
|
||||||
uses current scan progress.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
CallbackManager configured with scan callbacks
|
|
||||||
"""
|
|
||||||
progress = scan_progress or self._current_scan
|
|
||||||
if not progress:
|
|
||||||
progress = ScanProgress(str(uuid.uuid4()))
|
|
||||||
self._current_scan = progress
|
|
||||||
|
|
||||||
callback_manager = CallbackManager()
|
|
||||||
|
|
||||||
# Register callbacks
|
|
||||||
callback_manager.register_progress_callback(
|
|
||||||
ScanServiceProgressCallback(self, progress)
|
|
||||||
)
|
|
||||||
callback_manager.register_error_callback(
|
|
||||||
ScanServiceErrorCallback(self, progress)
|
|
||||||
)
|
|
||||||
callback_manager.register_completion_callback(
|
|
||||||
ScanServiceCompletionCallback(self, progress)
|
|
||||||
)
|
|
||||||
|
|
||||||
return callback_manager
|
|
||||||
|
|
||||||
async def _handle_progress_update(
|
async def _handle_progress_update(
|
||||||
self,
|
self,
|
||||||
scan_progress: ScanProgress,
|
scan_progress: ScanProgress,
|
||||||
@ -475,8 +336,6 @@ class ScanService:
|
|||||||
current=scan_progress.current,
|
current=scan_progress.current,
|
||||||
total=scan_progress.total,
|
total=scan_progress.total,
|
||||||
message=scan_progress.message,
|
message=scan_progress.message,
|
||||||
key=scan_progress.key,
|
|
||||||
folder=scan_progress.folder,
|
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Progress update skipped: %s", e)
|
logger.debug("Progress update skipped: %s", e)
|
||||||
@ -490,36 +349,38 @@ class ScanService:
|
|||||||
async def _handle_scan_error(
|
async def _handle_scan_error(
|
||||||
self,
|
self,
|
||||||
scan_progress: ScanProgress,
|
scan_progress: ScanProgress,
|
||||||
error_context: ErrorContext,
|
error_data: Dict[str, Any],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Handle a scan error.
|
"""Handle a scan error.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
scan_progress: Current scan progress
|
scan_progress: Current scan progress
|
||||||
error_context: Error context with key and folder metadata
|
error_data: Error data dictionary with error info
|
||||||
"""
|
"""
|
||||||
# Emit error event with key as primary identifier
|
# Emit error event with key as primary identifier
|
||||||
await self._emit_scan_event({
|
await self._emit_scan_event({
|
||||||
"type": "scan_error",
|
"type": "scan_error",
|
||||||
"scan_id": scan_progress.scan_id,
|
"scan_id": scan_progress.scan_id,
|
||||||
"key": error_context.key,
|
"error": str(error_data.get('error')),
|
||||||
"folder": error_context.folder,
|
"message": error_data.get('message', 'Unknown error'),
|
||||||
"error": str(error_context.error),
|
"recoverable": error_data.get('recoverable', True),
|
||||||
"message": error_context.message,
|
|
||||||
"recoverable": error_context.recoverable,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
async def _handle_scan_completion(
|
async def _handle_scan_completion(
|
||||||
self,
|
self,
|
||||||
scan_progress: ScanProgress,
|
scan_progress: ScanProgress,
|
||||||
completion_context: CompletionContext,
|
completion_data: Dict[str, Any],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Handle scan completion.
|
"""Handle scan completion.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
scan_progress: Final scan progress
|
scan_progress: Final scan progress
|
||||||
completion_context: Completion context with statistics
|
completion_data: Completion data dictionary with statistics
|
||||||
"""
|
"""
|
||||||
|
success = completion_data.get('success', False)
|
||||||
|
message = completion_data.get('message', '')
|
||||||
|
statistics = completion_data.get('statistics', {})
|
||||||
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
self._is_scanning = False
|
self._is_scanning = False
|
||||||
|
|
||||||
@ -530,33 +391,33 @@ class ScanService:
|
|||||||
|
|
||||||
# Complete progress tracking
|
# Complete progress tracking
|
||||||
try:
|
try:
|
||||||
if completion_context.success:
|
if success:
|
||||||
await self._progress_service.complete_progress(
|
await self._progress_service.complete_progress(
|
||||||
progress_id=f"scan_{scan_progress.scan_id}",
|
progress_id=f"scan_{scan_progress.scan_id}",
|
||||||
message=completion_context.message,
|
message=message,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
await self._progress_service.fail_progress(
|
await self._progress_service.fail_progress(
|
||||||
progress_id=f"scan_{scan_progress.scan_id}",
|
progress_id=f"scan_{scan_progress.scan_id}",
|
||||||
error_message=completion_context.message,
|
error_message=message,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Progress completion skipped: %s", e)
|
logger.debug("Progress completion skipped: %s", e)
|
||||||
|
|
||||||
# Emit completion event
|
# Emit completion event
|
||||||
await self._emit_scan_event({
|
await self._emit_scan_event({
|
||||||
"type": "scan_completed" if completion_context.success else "scan_failed",
|
"type": "scan_completed" if success else "scan_failed",
|
||||||
"scan_id": scan_progress.scan_id,
|
"scan_id": scan_progress.scan_id,
|
||||||
"success": completion_context.success,
|
"success": success,
|
||||||
"message": completion_context.message,
|
"message": message,
|
||||||
"statistics": completion_context.statistics,
|
"statistics": statistics,
|
||||||
"data": scan_progress.to_dict(),
|
"data": scan_progress.to_dict(),
|
||||||
})
|
})
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Scan completed",
|
"Scan completed",
|
||||||
scan_id=scan_progress.scan_id,
|
scan_id=scan_progress.scan_id,
|
||||||
success=completion_context.success,
|
success=success,
|
||||||
series_found=scan_progress.series_found,
|
series_found=scan_progress.series_found,
|
||||||
errors_count=len(scan_progress.errors),
|
errors_count=len(scan_progress.errors),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,29 +1,17 @@
|
|||||||
"""Unit tests for ScanService.
|
"""Unit tests for ScanService.
|
||||||
|
|
||||||
This module contains comprehensive tests for the scan service,
|
This module contains comprehensive tests for the scan service,
|
||||||
including scan lifecycle, progress callbacks, event handling,
|
including scan lifecycle, progress events, and key-based identification.
|
||||||
and key-based identification.
|
|
||||||
"""
|
"""
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock, Mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.core.interfaces.callbacks import (
|
|
||||||
CallbackManager,
|
|
||||||
CompletionContext,
|
|
||||||
ErrorContext,
|
|
||||||
OperationType,
|
|
||||||
ProgressContext,
|
|
||||||
ProgressPhase,
|
|
||||||
)
|
|
||||||
from src.server.services.scan_service import (
|
from src.server.services.scan_service import (
|
||||||
ScanProgress,
|
ScanProgress,
|
||||||
ScanService,
|
ScanService,
|
||||||
ScanServiceCompletionCallback,
|
|
||||||
ScanServiceError,
|
ScanServiceError,
|
||||||
ScanServiceErrorCallback,
|
|
||||||
ScanServiceProgressCallback,
|
|
||||||
get_scan_service,
|
get_scan_service,
|
||||||
reset_scan_service,
|
reset_scan_service,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -188,16 +188,17 @@ class TestSeriesAppDownload:
|
|||||||
|
|
||||||
app.loader.download = Mock(side_effect=mock_download_cancelled)
|
app.loader.download = Mock(side_effect=mock_download_cancelled)
|
||||||
|
|
||||||
# Perform download - should catch InterruptedError
|
# Perform download - should re-raise InterruptedError
|
||||||
result = await app.download(
|
with pytest.raises(InterruptedError):
|
||||||
|
await app.download(
|
||||||
"anime_folder",
|
"anime_folder",
|
||||||
season=1,
|
season=1,
|
||||||
episode=1,
|
episode=1,
|
||||||
key="anime_key"
|
key="anime_key"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify cancellation was handled (returns False on error)
|
# Verify cancellation event was fired
|
||||||
assert result is False
|
assert app._events.download_status.called
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@patch('src.core.SeriesApp.Loaders')
|
@patch('src.core.SeriesApp.Loaders')
|
||||||
@ -264,10 +265,10 @@ class TestSeriesAppReScan:
|
|||||||
@patch('src.core.SeriesApp.Loaders')
|
@patch('src.core.SeriesApp.Loaders')
|
||||||
@patch('src.core.SeriesApp.SerieScanner')
|
@patch('src.core.SeriesApp.SerieScanner')
|
||||||
@patch('src.core.SeriesApp.SerieList')
|
@patch('src.core.SeriesApp.SerieList')
|
||||||
async def test_rescan_with_callback(
|
async def test_rescan_with_events(
|
||||||
self, mock_serie_list, mock_scanner, mock_loaders
|
self, mock_serie_list, mock_scanner, mock_loaders
|
||||||
):
|
):
|
||||||
"""Test rescan with progress callbacks."""
|
"""Test rescan with event progress notifications."""
|
||||||
test_dir = "/test/anime"
|
test_dir = "/test/anime"
|
||||||
app = SeriesApp(test_dir)
|
app = SeriesApp(test_dir)
|
||||||
|
|
||||||
@ -278,19 +279,19 @@ class TestSeriesAppReScan:
|
|||||||
app.serie_scanner.get_total_to_scan = Mock(return_value=3)
|
app.serie_scanner.get_total_to_scan = Mock(return_value=3)
|
||||||
app.serie_scanner.reinit = Mock()
|
app.serie_scanner.reinit = Mock()
|
||||||
app.serie_scanner.keyDict = {}
|
app.serie_scanner.keyDict = {}
|
||||||
|
app.serie_scanner.scan = Mock() # Scan no longer takes callback
|
||||||
def mock_scan(callback):
|
app.serie_scanner.subscribe_on_progress = Mock()
|
||||||
callback("folder1", 1)
|
app.serie_scanner.unsubscribe_on_progress = Mock()
|
||||||
callback("folder2", 2)
|
|
||||||
callback("folder3", 3)
|
|
||||||
|
|
||||||
app.serie_scanner.scan = Mock(side_effect=mock_scan)
|
|
||||||
|
|
||||||
# Perform rescan
|
# Perform rescan
|
||||||
await app.rescan()
|
await app.rescan()
|
||||||
|
|
||||||
# Verify rescan completed
|
# Verify scanner methods were called correctly
|
||||||
|
app.serie_scanner.reinit.assert_called_once()
|
||||||
app.serie_scanner.scan.assert_called_once()
|
app.serie_scanner.scan.assert_called_once()
|
||||||
|
# Verify event subscription/unsubscription happened
|
||||||
|
app.serie_scanner.subscribe_on_progress.assert_called_once()
|
||||||
|
app.serie_scanner.unsubscribe_on_progress.assert_called_once()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@patch('src.core.SeriesApp.Loaders')
|
@patch('src.core.SeriesApp.Loaders')
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user