"""Metadata service for coordinating metadata extraction and caching. This service manages the extraction of metadata from media files with: - Thread pool for concurrent extraction - Cache integration for performance - Formatter coordination for display - Error handling and recovery """ import logging from pathlib import Path from typing import Optional, Callable from concurrent.futures import ThreadPoolExecutor, Future from threading import Lock from renamer.cache import Cache from renamer.settings import Settings from renamer.extractors.extractor import MediaExtractor from renamer.views import MediaPanelView, ProposedFilenameView from renamer.formatters.catalog_formatter import CatalogFormatter from renamer.formatters.text_formatter import TextFormatter logger = logging.getLogger(__name__) class MetadataService: """Service for managing metadata extraction and formatting. This service coordinates: - Metadata extraction from media files - Caching of extracted metadata - Thread pool management for concurrent operations - Formatting for different display modes (technical/catalog) - Proposed name generation The service uses a thread pool to extract metadata concurrently while maintaining thread safety with proper locking mechanisms. Example: cache = Cache() settings = Settings() service = MetadataService(cache, settings, max_workers=3) # Extract metadata result = service.extract_metadata(Path("/media/movie.mkv")) if result: print(result['formatted_info']) # Cleanup when done service.shutdown() """ def __init__( self, cache: Cache, settings: Settings, max_workers: int = 3 ): """Initialize the metadata service. Args: cache: Cache instance for storing extracted metadata settings: Settings instance for user preferences max_workers: Maximum number of concurrent extraction threads """ self.cache = cache self.settings = settings self.max_workers = max_workers # Thread pool for concurrent extraction self.executor = ThreadPoolExecutor( max_workers=max_workers, thread_name_prefix="metadata_" ) # Lock for thread-safe operations self._lock = Lock() # Track active futures for cancellation self._active_futures: dict[Path, Future] = {} logger.info(f"MetadataService initialized with {max_workers} workers") def extract_metadata( self, file_path: Path, callback: Optional[Callable] = None, error_callback: Optional[Callable] = None ) -> Optional[dict]: """Extract metadata from a media file. This method can be called synchronously (returns result immediately) or asynchronously (uses callbacks when complete). Args: file_path: Path to the media file callback: Optional callback(result_dict) called when extraction completes error_callback: Optional callback(error_message) called on error Returns: Dictionary with 'formatted_info' and 'proposed_name' if synchronous, None if using callbacks (async mode) Example: # Synchronous result = service.extract_metadata(path) print(result['formatted_info']) # Asynchronous service.extract_metadata( path, callback=lambda r: print(r['formatted_info']), error_callback=lambda e: print(f"Error: {e}") ) """ if callback or error_callback: # Asynchronous mode - submit to thread pool future = self.executor.submit( self._extract_metadata_internal, file_path ) # Track the future with self._lock: # Cancel any existing extraction for this file if file_path in self._active_futures: self._active_futures[file_path].cancel() self._active_futures[file_path] = future # Add callback handlers def done_callback(f: Future): with self._lock: # Remove from active futures self._active_futures.pop(file_path, None) try: result = f.result() if callback: callback(result) except Exception as e: logger.error(f"Error extracting metadata for {file_path}: {e}") if error_callback: error_callback(str(e)) future.add_done_callback(done_callback) return None else: # Synchronous mode - extract directly return self._extract_metadata_internal(file_path) def _extract_metadata_internal(self, file_path: Path) -> dict: """Internal method to extract and format metadata. Args: file_path: Path to the media file Returns: Dictionary with 'formatted_info' and 'proposed_name' Raises: Exception: If extraction fails """ try: # Initialize extractor (uses cache internally via decorators) extractor = MediaExtractor(file_path) # Get current mode from settings mode = self.settings.get("mode") # Format based on mode if mode == "technical": formatter = MediaPanelView(extractor) formatted_info = formatter.file_info_panel() else: # catalog formatter = CatalogFormatter(extractor, self.settings) formatted_info = formatter.format_catalog_info() # Generate proposed name proposed_formatter = ProposedFilenameView(extractor) proposed_name = proposed_formatter.rename_line_formatted(file_path) return { 'formatted_info': formatted_info, 'proposed_name': proposed_name, 'mode': mode, } except Exception as e: logger.error(f"Failed to extract metadata for {file_path}: {e}") return { 'formatted_info': TextFormatter.red(f"Error extracting details: {str(e)}"), 'proposed_name': "", 'mode': self.settings.get("mode"), } def extract_for_display( self, file_path: Path, display_callback: Callable[[str, str], None], error_callback: Optional[Callable[[str], None]] = None ): """Extract metadata and update display via callback. Convenience method that extracts metadata and calls the display callback with the formatted info and proposed name. Args: file_path: Path to the media file display_callback: Callback(formatted_info, proposed_name) to update UI error_callback: Optional callback(error_message) for errors Example: def update_ui(info, proposed): details_widget.update(info) proposed_widget.update(proposed) service.extract_for_display(path, update_ui) """ def on_success(result: dict): display_callback(result['formatted_info'], result['proposed_name']) def on_error(error_message: str): if error_callback: error_callback(error_message) else: display_callback( TextFormatter.red(f"Error: {error_message}"), "" ) self.extract_metadata(file_path, callback=on_success, error_callback=on_error) def cancel_extraction(self, file_path: Path) -> bool: """Cancel an ongoing extraction for a file. Args: file_path: Path to the file whose extraction should be canceled Returns: True if an extraction was canceled, False if none was active Example: # User selected a different file service.cancel_extraction(old_path) service.extract_metadata(new_path, callback=update_ui) """ with self._lock: future = self._active_futures.get(file_path) if future and not future.done(): future.cancel() self._active_futures.pop(file_path, None) logger.debug(f"Canceled extraction for {file_path}") return True return False def cancel_all_extractions(self): """Cancel all ongoing extractions. Useful when closing the application or switching directories. Example: # User closing app service.cancel_all_extractions() service.shutdown() """ with self._lock: canceled_count = 0 for file_path, future in list(self._active_futures.items()): if not future.done(): future.cancel() canceled_count += 1 self._active_futures.clear() if canceled_count > 0: logger.info(f"Canceled {canceled_count} active extractions") def get_active_extraction_count(self) -> int: """Get the number of currently active extractions. Returns: Number of extractions in progress Example: >>> count = service.get_active_extraction_count() >>> print(f"{count} extractions in progress") """ with self._lock: return sum(1 for f in self._active_futures.values() if not f.done()) def shutdown(self, wait: bool = True): """Shutdown the metadata service. Cancels all pending extractions and shuts down the thread pool. Should be called when the application is closing. Args: wait: If True, wait for all threads to complete. If False, cancel immediately. Example: # Clean shutdown service.shutdown(wait=True) # Force shutdown service.shutdown(wait=False) """ logger.info("Shutting down MetadataService") # Cancel all active extractions self.cancel_all_extractions() # Shutdown thread pool self.executor.shutdown(wait=wait) logger.info("MetadataService shutdown complete") def __enter__(self): """Context manager support.""" return self def __exit__(self, exc_type, exc_val, exc_tb): """Context manager cleanup.""" self.shutdown(wait=True) return False