"""Scan service for managing anime library scan operations. This module provides a service layer for scanning the anime library directory, identifying missing episodes, and broadcasting scan progress updates. All scan operations use 'key' as the primary series identifier. """ from __future__ import annotations import asyncio import uuid from datetime import datetime, timezone from typing import Any, Callable, Dict, List, Optional import structlog from src.server.services.progress_service import ( ProgressService, ProgressType, get_progress_service, ) logger = structlog.get_logger(__name__) class ScanServiceError(Exception): """Service-level exception for scan operations.""" class ScanProgress: """Represents the current state of a scan operation. Attributes: scan_id: Unique identifier for this scan operation status: Current status (started, in_progress, completed, failed) current: Number of folders processed total: Total number of folders to process percentage: Completion percentage message: Human-readable progress message key: Current series key being scanned (if applicable) folder: Current folder being scanned (metadata only) started_at: When the scan started updated_at: When the progress was last updated series_found: Number of series found with missing episodes errors: List of error messages encountered """ def __init__(self, scan_id: str): """Initialize scan progress. Args: scan_id: Unique identifier for this scan """ self.scan_id = scan_id self.status = "started" self.current = 0 self.total = 0 self.percentage = 0.0 self.message = "Initializing scan..." self.key: Optional[str] = None self.folder: Optional[str] = None self.started_at = datetime.now(timezone.utc) self.updated_at = datetime.now(timezone.utc) self.series_found = 0 self.errors: List[str] = [] def to_dict(self) -> Dict[str, Any]: """Convert to dictionary for serialization. Returns: Dictionary representation with 'key' as primary identifier and 'folder' as metadata only. """ result = { "scan_id": self.scan_id, "status": self.status, "current": self.current, "total": self.total, "percentage": round(self.percentage, 2), "message": self.message, "started_at": self.started_at.isoformat(), "updated_at": self.updated_at.isoformat(), "series_found": self.series_found, "errors": self.errors, } # Include optional series identifiers if self.key is not None: result["key"] = self.key if self.folder is not None: result["folder"] = self.folder return result class ScanService: """Manages anime library scan operations. Features: - Trigger library scans - Track scan progress in real-time - Use 'key' as primary series identifier - Broadcast scan progress via WebSocket - Handle scan errors gracefully - Provide scan history and statistics All operations use 'key' (provider-assigned, URL-safe identifier) as the primary series identifier. 'folder' is used only as metadata for display and filesystem operations. """ def __init__( self, progress_service: Optional[ProgressService] = None, ): """Initialize the scan service. Args: progress_service: Optional progress service for tracking """ self._progress_service = progress_service or get_progress_service() # Current scan state self._current_scan: Optional[ScanProgress] = None self._is_scanning = False # Scan history (limited size) self._scan_history: List[ScanProgress] = [] self._max_history_size = 10 # Event handlers for scan events self._scan_event_handlers: List[ Callable[[Dict[str, Any]], None] ] = [] # Lock for thread-safe operations self._lock = asyncio.Lock() logger.info("ScanService initialized") def subscribe_to_scan_events( self, handler: Callable[[Dict[str, Any]], None], ) -> None: """Subscribe to scan events. Args: handler: Function to call when scan events occur. Receives a dictionary with event data including 'key' as the primary identifier. """ self._scan_event_handlers.append(handler) logger.debug("Scan event handler subscribed") def unsubscribe_from_scan_events( self, handler: Callable[[Dict[str, Any]], None], ) -> None: """Unsubscribe from scan events. Args: handler: Handler function to remove """ try: self._scan_event_handlers.remove(handler) logger.debug("Scan event handler unsubscribed") except ValueError: logger.warning("Handler not found for unsubscribe") async def _emit_scan_event(self, event_data: Dict[str, Any]) -> None: """Emit scan event to all subscribers. Args: event_data: Event data to broadcast, includes 'key' as primary identifier and 'folder' as metadata """ for handler in self._scan_event_handlers: try: if asyncio.iscoroutinefunction(handler): await handler(event_data) else: handler(event_data) except Exception as e: logger.error( "Scan event handler error", error=str(e), ) @property def is_scanning(self) -> bool: """Check if a scan is currently in progress.""" return self._is_scanning @property def current_scan(self) -> Optional[ScanProgress]: """Get the current scan progress.""" return self._current_scan async def start_scan( self, scanner: Any, # SerieScanner instance ) -> str: """Start a new library scan. Args: scanner: SerieScanner instance to use for scanning. The service will subscribe to its events. Returns: Scan ID for tracking Raises: ScanServiceError: If a scan is already in progress Note: The scan uses 'key' as the primary identifier for all series. The 'folder' field is included only as metadata for display. """ async with self._lock: if self._is_scanning: raise ScanServiceError("A scan is already in progress") self._is_scanning = True scan_id = str(uuid.uuid4()) scan_progress = ScanProgress(scan_id) self._current_scan = scan_progress logger.info("Starting library scan", scan_id=scan_id) # Start progress tracking try: await self._progress_service.start_progress( progress_id=f"scan_{scan_id}", progress_type=ProgressType.SCAN, title="Library Scan", message="Initializing scan...", ) except Exception as e: logger.error("Failed to start progress tracking: %s", e) # Emit scan started event await self._emit_scan_event({ "type": "scan_started", "scan_id": scan_id, "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 async def _handle_progress_update( self, scan_progress: ScanProgress, ) -> None: """Handle a scan progress update. Args: scan_progress: Updated scan progress with 'key' as identifier """ # Update progress service try: await self._progress_service.update_progress( progress_id=f"scan_{scan_progress.scan_id}", current=scan_progress.current, total=scan_progress.total, message=scan_progress.message, ) except Exception as e: logger.debug("Progress update skipped: %s", e) # Emit progress event with key as primary identifier await self._emit_scan_event({ "type": "scan_progress", "data": scan_progress.to_dict(), }) async def _handle_scan_error( self, scan_progress: ScanProgress, error_data: Dict[str, Any], ) -> None: """Handle a scan error. Args: scan_progress: Current scan progress error_data: Error data dictionary with error info """ # Emit error event with key as primary identifier await self._emit_scan_event({ "type": "scan_error", "scan_id": scan_progress.scan_id, "error": str(error_data.get('error')), "message": error_data.get('message', 'Unknown error'), "recoverable": error_data.get('recoverable', True), }) async def _handle_scan_completion( self, scan_progress: ScanProgress, completion_data: Dict[str, Any], ) -> None: """Handle scan completion. Args: scan_progress: Final scan progress 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: self._is_scanning = False # Add to history self._scan_history.append(scan_progress) if len(self._scan_history) > self._max_history_size: self._scan_history.pop(0) # Complete progress tracking try: if success: await self._progress_service.complete_progress( progress_id=f"scan_{scan_progress.scan_id}", message=message, ) else: await self._progress_service.fail_progress( progress_id=f"scan_{scan_progress.scan_id}", error_message=message, ) except Exception as e: logger.debug("Progress completion skipped: %s", e) # Emit completion event await self._emit_scan_event({ "type": "scan_completed" if success else "scan_failed", "scan_id": scan_progress.scan_id, "success": success, "message": message, "statistics": statistics, "data": scan_progress.to_dict(), }) logger.info( "Scan completed", scan_id=scan_progress.scan_id, success=success, series_found=scan_progress.series_found, errors_count=len(scan_progress.errors), ) async def cancel_scan(self) -> bool: """Cancel the current scan if one is in progress. Returns: True if scan was cancelled, False if no scan in progress """ async with self._lock: if not self._is_scanning: return False self._is_scanning = False if self._current_scan: self._current_scan.status = "cancelled" self._current_scan.message = "Scan cancelled by user" self._current_scan.updated_at = datetime.now(timezone.utc) # Add to history self._scan_history.append(self._current_scan) if len(self._scan_history) > self._max_history_size: self._scan_history.pop(0) # Emit cancellation event if self._current_scan: await self._emit_scan_event({ "type": "scan_cancelled", "scan_id": self._current_scan.scan_id, "message": "Scan cancelled by user", }) # Update progress service try: await self._progress_service.fail_progress( progress_id=f"scan_{self._current_scan.scan_id}", error_message="Scan cancelled by user", ) except Exception as e: logger.debug("Progress cancellation skipped: %s", e) logger.info("Scan cancelled") return True async def get_scan_status(self) -> Dict[str, Any]: """Get the current scan status. Returns: Dictionary with scan status information, including 'key' as the primary series identifier for any current scan. """ return { "is_scanning": self._is_scanning, "current_scan": ( self._current_scan.to_dict() if self._current_scan else None ), } async def get_scan_history( self, limit: int = 10, ) -> List[Dict[str, Any]]: """Get scan history. Args: limit: Maximum number of history entries to return Returns: List of scan history entries, newest first. Each entry includes 'key' as the primary identifier. """ history = self._scan_history[-limit:] history.reverse() # Newest first return [scan.to_dict() for scan in history] # Module-level singleton instance _scan_service: Optional[ScanService] = None def get_scan_service() -> ScanService: """Get the singleton ScanService instance. Returns: The ScanService singleton """ global _scan_service if _scan_service is None: _scan_service = ScanService() return _scan_service def reset_scan_service() -> None: """Reset the singleton ScanService instance. Primarily used for testing to ensure clean state. """ global _scan_service _scan_service = None