mirror of
https://github.com/shadoll/moma.git
synced 2026-08-28 11:33:25 +00:00
feat: Add OpenScreen for directory input and validation
feat: Introduce poster rendering views with multiple engines feat: Implement ASCII art poster renderer using PIL feat: Create base class for poster renderers feat: Add RichPixels renderer for high-quality terminal image display feat: Implement Viu terminal image viewer renderer feat: Add ProposedFilenameView for generating standardized filenames feat: Create RenameConfirmScreen for renaming files with confirmation feat: Implement SettingsScreen for configuring application settings feat: Add custom PosterWidget for rendering poster images
This commit is contained in:
@@ -0,0 +1,324 @@
|
||||
"""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 src.cache import Cache
|
||||
from src.settings import Settings
|
||||
from src.extractors.extractor import MediaExtractor
|
||||
from src.views import MediaPanelView, ProposedFilenameView
|
||||
from src.formatters.catalog_formatter import CatalogFormatter
|
||||
from src.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
|
||||
Reference in New Issue
Block a user