diff --git a/dist/renamer-0.8.9-py3-none-any.whl b/dist/renamer-0.8.9-py3-none-any.whl new file mode 100644 index 0000000..0cbfcd5 Binary files /dev/null and b/dist/renamer-0.8.9-py3-none-any.whl differ diff --git a/pyproject.toml b/pyproject.toml index e5a041a..4a93792 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "renamer" -version = "0.8.8" +version = "0.8.9" description = "Terminal-based media file renamer and metadata viewer" readme = "README.md" requires-python = ">=3.11" diff --git a/renamer/app.py b/renamer/app.py index 6d27bf6..cb52c90 100644 --- a/renamer/app.py +++ b/renamer/app.py @@ -9,8 +9,8 @@ from functools import partial import threading import time import logging -import os +from .logging_config import LoggerConfig # Initialize logging singleton from .constants import MEDIA_TYPES from .screens import OpenScreen, HelpScreen, RenameConfirmScreen, SettingsScreen, ConvertConfirmScreen from .extractors.extractor import MediaExtractor @@ -22,14 +22,6 @@ from .cache import Cache, CacheManager from .services.conversion_service import ConversionService -# Set up logging conditionally -if os.getenv('FORMATTER_LOG', '0') == '1': - logging.basicConfig(filename='formatter.log', level=logging.INFO, - format='%(asctime)s - %(levelname)s - %(message)s') -else: - logging.basicConfig(level=logging.INFO) # Enable logging for debugging - - class CacheCommandProvider(Provider): """Command provider for cache management operations.""" @@ -431,6 +423,11 @@ class RenamerApp(App): try: if node.data.is_file(): + # Invalidate cache for this file before re-extracting + cache = Cache() + invalidated = cache.invalidate_file(node.data) + logging.info(f"Refresh: invalidated {invalidated} cache entries for {node.data.name}") + self._start_loading_animation() threading.Thread( target=self._extract_and_show_details, args=(node.data,) diff --git a/renamer/cache/core.py b/renamer/cache/core.py index 9f2551a..0692001 100644 --- a/renamer/cache/core.py +++ b/renamer/cache/core.py @@ -12,14 +12,30 @@ logger = logging.getLogger(__name__) class Cache: - """Thread-safe file-based cache with TTL support.""" + """Thread-safe file-based cache with TTL support (Singleton).""" + + _instance: Optional['Cache'] = None + _lock_init = threading.Lock() + + def __new__(cls, cache_dir: Optional[Path] = None): + """Create or return singleton instance.""" + if cls._instance is None: + with cls._lock_init: + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialized = False + return cls._instance def __init__(self, cache_dir: Optional[Path] = None): - """Initialize cache with optional custom directory. + """Initialize cache with optional custom directory (only once). Args: cache_dir: Optional cache directory path. Defaults to ~/.cache/renamer/ """ + # Only initialize once + if self._initialized: + return + # Always use the default cache dir to avoid creating cache in scan dir if cache_dir is None: cache_dir = Path.home() / ".cache" / "renamer" @@ -27,6 +43,7 @@ class Cache: self.cache_dir.mkdir(parents=True, exist_ok=True) self._memory_cache: Dict[str, Dict[str, Any]] = {} # In-memory cache for faster access self._lock = threading.RLock() # Reentrant lock for thread safety + self._initialized = True def _sanitize_key_component(self, component: str) -> str: """Sanitize a key component to prevent filesystem escaping. @@ -85,14 +102,15 @@ class Cache: # Use .json extension for all cache files (simplifies logic) return cache_subdir / f"{key_hash}.json" - def get(self, key: str) -> Optional[Any]: + def get(self, key: str, default: Any = None) -> Any: """Get cached value if not expired (thread-safe). Args: key: Cache key + default: Value to return if key not found or expired Returns: - Cached value or None if not found/expired + Cached value or default if not found/expired """ with self._lock: # Check memory cache first @@ -108,7 +126,7 @@ class Cache: # Check file cache cache_file = self._get_cache_file(key) if not cache_file.exists(): - return None + return default try: with open(cache_file, 'r') as f: @@ -118,7 +136,7 @@ class Cache: # Expired, remove file cache_file.unlink(missing_ok=True) logger.debug(f"File cache expired for key: {key}, removed {cache_file}") - return None + return default # Store in memory cache for faster future access self._memory_cache[key] = data @@ -128,11 +146,11 @@ class Cache: # Corrupted JSON, remove file logger.warning(f"Corrupted cache file {cache_file}: {e}") cache_file.unlink(missing_ok=True) - return None + return default except IOError as e: # File read error logger.error(f"Failed to read cache file {cache_file}: {e}") - return None + return default def set(self, key: str, value: Any, ttl_seconds: int) -> None: """Set cached value with TTL (thread-safe). @@ -177,6 +195,56 @@ class Cache: cache_file.unlink(missing_ok=True) logger.debug(f"Invalidated cache for key: {key}") + def invalidate_file(self, file_path: Path) -> int: + """Invalidate all cache entries for a specific file path. + + This invalidates all extractor method caches for the given file by: + 1. Clearing matching keys from memory cache + 2. Removing matching keys from file cache + + Args: + file_path: File path to invalidate cache for + + Returns: + Number of cache entries invalidated + """ + with self._lock: + # Generate the path hash used in cache keys + path_hash = hashlib.md5(str(file_path).encode()).hexdigest()[:12] + prefix = f"extractor_{path_hash}_" + + invalidated_count = 0 + + # Remove from memory cache (easy - just check prefix) + keys_to_remove = [k for k in self._memory_cache.keys() if k.startswith(prefix)] + for key in keys_to_remove: + del self._memory_cache[key] + invalidated_count += 1 + logger.debug(f"Invalidated memory cache for key: {key}") + + # For file cache, we need to invalidate all known extractor methods + # List of all cached extractor methods + extractor_methods = [ + 'extract_title', 'extract_year', 'extract_source', 'extract_video_codec', + 'extract_audio_codec', 'extract_frame_class', 'extract_hdr', 'extract_order', + 'extract_special_info', 'extract_movie_db', 'extract_extension', + 'extract_video_tracks', 'extract_audio_tracks', 'extract_subtitle_tracks', + 'extract_interlaced', 'extract_size', 'extract_duration', 'extract_bitrate', + 'extract_created', 'extract_modified' + ] + + # Invalidate each possible cache key + for method in extractor_methods: + cache_key = f"extractor_{path_hash}_{method}" + cache_file = self._get_cache_file(cache_key) + if cache_file.exists(): + cache_file.unlink(missing_ok=True) + invalidated_count += 1 + logger.debug(f"Invalidated file cache for key: {cache_key}") + + logger.info(f"Invalidated {invalidated_count} cache entries for file: {file_path.name}") + return invalidated_count + def get_image(self, key: str) -> Optional[Path]: """Get cached image path if not expired (thread-safe). diff --git a/renamer/cache/decorators.py b/renamer/cache/decorators.py index 0bc67a0..2d893fb 100644 --- a/renamer/cache/decorators.py +++ b/renamer/cache/decorators.py @@ -19,6 +19,9 @@ from .strategies import ( logger = logging.getLogger(__name__) +# Sentinel object to distinguish "not in cache" from "cached value is None" +_CACHE_MISS = object() + def cached( strategy: Optional[CacheKeyStrategy] = None, @@ -78,10 +81,10 @@ def cached( logger.warning(f"Failed to generate cache key: {e}, executing uncached") return func(self, *args, **kwargs) - # Check cache - cached_value = cache.get(cache_key) - if cached_value is not None: - logger.debug(f"Cache hit for {func.__name__}: {cache_key}") + # Check cache (use sentinel to distinguish "not in cache" from "cached None") + cached_value = cache.get(cache_key, _CACHE_MISS) + if cached_value is not _CACHE_MISS: + logger.debug(f"Cache hit for {func.__name__}: {cache_key} (value={cached_value!r})") return cached_value # Execute function @@ -91,10 +94,9 @@ def cached( # Determine TTL actual_ttl = _determine_ttl(self, ttl) - # Cache result (only if not None) - if result is not None: - cache.set(cache_key, result, actual_ttl) - logger.debug(f"Cached {func.__name__}: {cache_key} (TTL: {actual_ttl}s)") + # Cache result (including None - None is valid data meaning "not found") + cache.set(cache_key, result, actual_ttl) + logger.debug(f"Cached {func.__name__}: {cache_key} (TTL: {actual_ttl}s, value={result!r})") return result @@ -129,8 +131,9 @@ def _generate_cache_key( if not file_path: raise ValueError(f"{instance.__class__.__name__} missing file_path attribute") - instance_id = str(id(instance)) - return strategy.generate_key(file_path, func.__name__, instance_id) + # Cache by file_path + method_name only (no instance_id) + # This allows cache hits across different extractor instances for the same file + return strategy.generate_key(file_path, func.__name__) elif isinstance(strategy, APIRequestStrategy): # API pattern: expects service name in args or uses function name @@ -246,10 +249,10 @@ def cached_api(service: str, ttl: Optional[int] = None): strategy = APIRequestStrategy() cache_key = strategy.generate_key(service, func.__name__, {'params': args_repr}) - # Check cache - cached_value = cache.get(cache_key) - if cached_value is not None: - logger.debug(f"API cache hit for {service}.{func.__name__}") + # Check cache (use sentinel to distinguish "not in cache" from "cached None") + cached_value = cache.get(cache_key, _CACHE_MISS) + if cached_value is not _CACHE_MISS: + logger.debug(f"API cache hit for {service}.{func.__name__} (value={cached_value!r})") return cached_value # Execute function @@ -267,10 +270,9 @@ def cached_api(service: str, ttl: Optional[int] = None): else: actual_ttl = 21600 # Default 6 hours - # Cache result (only if not None) - if result is not None: - cache.set(cache_key, result, actual_ttl) - logger.debug(f"API cached {service}.{func.__name__} (TTL: {actual_ttl}s)") + # Cache result (including None - None is valid data) + cache.set(cache_key, result, actual_ttl) + logger.debug(f"API cached {service}.{func.__name__} (TTL: {actual_ttl}s, value={result!r})") return result diff --git a/renamer/constants/__init__.py b/renamer/constants/__init__.py index 468af84..e06d8bd 100644 --- a/renamer/constants/__init__.py +++ b/renamer/constants/__init__.py @@ -12,7 +12,7 @@ This package contains constants split into logical modules: """ # Import from all constant modules -from .media_constants import MEDIA_TYPES +from .media_constants import MEDIA_TYPES, META_TYPE_TO_EXTENSIONS from .source_constants import SOURCE_DICT from .frame_constants import FRAME_CLASSES, NON_STANDARD_QUALITY_INDICATORS from .moviedb_constants import MOVIE_DB_DICT @@ -24,6 +24,7 @@ from .cyrillic_constants import CYRILLIC_TO_ENGLISH __all__ = [ # Media types 'MEDIA_TYPES', + 'META_TYPE_TO_EXTENSIONS', # Source types 'SOURCE_DICT', # Frame classes diff --git a/renamer/constants/media_constants.py b/renamer/constants/media_constants.py index 23cddd9..830ba05 100644 --- a/renamer/constants/media_constants.py +++ b/renamer/constants/media_constants.py @@ -54,3 +54,13 @@ MEDIA_TYPES = { "mime": "video/mpeg", }, } + +# Reverse mapping: meta_type -> list of extensions +# Built once at module load instead of rebuilding in every extractor instance +META_TYPE_TO_EXTENSIONS = {} +for ext, info in MEDIA_TYPES.items(): + meta_type = info.get('meta_type') + if meta_type: + if meta_type not in META_TYPE_TO_EXTENSIONS: + META_TYPE_TO_EXTENSIONS[meta_type] = [] + META_TYPE_TO_EXTENSIONS[meta_type].append(ext) diff --git a/renamer/extractors/extractor.py b/renamer/extractors/extractor.py index 4028587..3e371e9 100644 --- a/renamer/extractors/extractor.py +++ b/renamer/extractors/extractor.py @@ -45,14 +45,15 @@ class MediaExtractor: >>> tracks = extractor.get("video_tracks") """ - def __init__(self, file_path: Path): + def __init__(self, file_path: Path, use_cache: bool = True): self.file_path = file_path - - self.filename_extractor = FilenameExtractor(file_path) - self.metadata_extractor = MetadataExtractor(file_path) - self.mediainfo_extractor = MediaInfoExtractor(file_path) - self.fileinfo_extractor = FileInfoExtractor(file_path) - self.tmdb_extractor = TMDBExtractor(file_path) + + # Initialize all extractors - they use singleton Cache internally + self.filename_extractor = FilenameExtractor(file_path, use_cache) + self.metadata_extractor = MetadataExtractor(file_path, use_cache) + self.mediainfo_extractor = MediaInfoExtractor(file_path, use_cache) + self.fileinfo_extractor = FileInfoExtractor(file_path, use_cache) + self.tmdb_extractor = TMDBExtractor(file_path, use_cache) self.default_extractor = DefaultExtractor() # Extractor mapping diff --git a/renamer/extractors/fileinfo_extractor.py b/renamer/extractors/fileinfo_extractor.py index 106d48b..4faf801 100644 --- a/renamer/extractors/fileinfo_extractor.py +++ b/renamer/extractors/fileinfo_extractor.py @@ -6,15 +6,8 @@ file system metadata such as size, timestamps, paths, and extensions. from pathlib import Path import logging -import os -from ..cache import cached_method - -# Set up logging conditionally -if os.getenv('FORMATTER_LOG', '0') == '1': - logging.basicConfig(filename='formatter.log', level=logging.INFO, - format='%(asctime)s - %(levelname)s - %(message)s') -else: - logging.basicConfig(level=logging.CRITICAL) # Disable logging +from ..cache import cached_method, Cache +from ..logging_config import LoggerConfig # Initialize logging singleton class FileInfoExtractor: @@ -39,13 +32,17 @@ class FileInfoExtractor: >>> name = extractor.extract_file_name() # Returns "movie.mkv" """ - def __init__(self, file_path: Path): + def __init__(self, file_path: Path, use_cache: bool = True): """Initialize the FileInfoExtractor. Args: file_path: Path object pointing to the file to extract info from + use_cache: Whether to use caching (default: True) """ self._file_path = file_path + self.file_path = file_path # Expose for cache key generation + self.cache = Cache() if use_cache else None # Singleton cache for @cached_method decorator + self.settings = None # Will be set by Settings singleton if needed self._stat = file_path.stat() self._cache: dict[str, any] = {} # Internal cache for method results diff --git a/renamer/extractors/filename_extractor.py b/renamer/extractors/filename_extractor.py index 6cc33ac..c416ec6 100644 --- a/renamer/extractors/filename_extractor.py +++ b/renamer/extractors/filename_extractor.py @@ -8,7 +8,7 @@ from ..constants import ( is_valid_year, CYRILLIC_TO_ENGLISH ) -from ..cache import cached_method +from ..cache import cached_method, Cache from ..utils.pattern_utils import PatternExtractor import langcodes @@ -18,7 +18,7 @@ logger = logging.getLogger(__name__) class FilenameExtractor: """Class to extract information from filename""" - def __init__(self, file_path: Path | str): + def __init__(self, file_path: Path | str, use_cache: bool = True): if isinstance(file_path, str): self.file_path = Path(file_path) self.file_name = file_path @@ -26,6 +26,9 @@ class FilenameExtractor: self.file_path = file_path self.file_name = file_path.name + self.cache = Cache() if use_cache else None # Singleton cache for @cached_method decorator + self.settings = None # Will be set by Settings singleton if needed + # Initialize utility helper self._pattern_extractor = PatternExtractor() diff --git a/renamer/extractors/mediainfo_extractor.py b/renamer/extractors/mediainfo_extractor.py index 9acdd7d..d1d0a65 100644 --- a/renamer/extractors/mediainfo_extractor.py +++ b/renamer/extractors/mediainfo_extractor.py @@ -1,8 +1,8 @@ from pathlib import Path from pymediainfo import MediaInfo from collections import Counter -from ..constants import FRAME_CLASSES, MEDIA_TYPES -from ..cache import cached_method +from ..constants import FRAME_CLASSES, META_TYPE_TO_EXTENSIONS +from ..cache import cached_method, Cache import langcodes import logging @@ -12,40 +12,35 @@ logger = logging.getLogger(__name__) class MediaInfoExtractor: """Class to extract information from MediaInfo""" - def __init__(self, file_path: Path): + def __init__(self, file_path: Path, use_cache: bool = True): self.file_path = file_path + self.cache = Cache() if use_cache else None # Singleton cache for @cached_method decorator + self.settings = None # Will be set by Settings singleton if needed self._cache = {} # Internal cache for method results - try: - self.media_info = MediaInfo.parse(file_path) + + # Parse media info - set to None on failure + self.media_info = MediaInfo.parse(file_path) if file_path.exists() else None + + # Extract tracks + if self.media_info: self.video_tracks = [t for t in self.media_info.tracks if t.track_type == 'Video'] self.audio_tracks = [t for t in self.media_info.tracks if t.track_type == 'Audio'] self.sub_tracks = [t for t in self.media_info.tracks if t.track_type == 'Text'] - except Exception as e: - logger.warning(f"Failed to parse media info for {file_path}: {e}") - self.media_info = None + else: self.video_tracks = [] self.audio_tracks = [] self.sub_tracks = [] - # Build mapping from meta_type to extensions - self._format_to_extensions = {} - for ext, info in MEDIA_TYPES.items(): - meta_type = info.get('meta_type') - if meta_type: - if meta_type not in self._format_to_extensions: - self._format_to_extensions[meta_type] = [] - self._format_to_extensions[meta_type].append(ext) - def _get_frame_class_from_height(self, height: int) -> str | None: """Get frame class from video height, finding closest match if exact not found""" if not height: return None - + # First try exact match for frame_class, info in FRAME_CLASSES.items(): if height == info['nominal_height']: return frame_class - + # If no exact match, find closest closest = None min_diff = float('inf') @@ -54,7 +49,7 @@ class MediaInfoExtractor: if diff < min_diff: min_diff = diff closest = frame_class - + # Only return if difference is reasonable (within 50 pixels) if min_diff <= 50: return closest @@ -77,30 +72,37 @@ class MediaInfoExtractor: width = getattr(self.video_tracks[0], 'width', None) if not height or not width: return None - + # Check if interlaced - try multiple attributes # PyMediaInfo may use different attribute names depending on version scan_type_attr = getattr(self.video_tracks[0], 'scan_type', None) interlaced = getattr(self.video_tracks[0], 'interlaced', None) + logger.debug(f"[{self.file_path.name}] Frame class detection - Resolution: {width}x{height}") + logger.debug(f"[{self.file_path.name}] scan_type attribute: {scan_type_attr!r} (type: {type(scan_type_attr).__name__})") + logger.debug(f"[{self.file_path.name}] interlaced attribute: {interlaced!r} (type: {type(interlaced).__name__})") + # Determine scan type from available attributes # Check scan_type first (e.g., "Interlaced", "Progressive", "MBAFF") if scan_type_attr and isinstance(scan_type_attr, str): scan_type = 'i' if 'interlaced' in scan_type_attr.lower() else 'p' + logger.debug(f"[{self.file_path.name}] Using scan_type: {scan_type_attr!r} -> scan_type={scan_type!r}") # Then check interlaced flag (e.g., "Yes", "No") elif interlaced and isinstance(interlaced, str): scan_type = 'i' if interlaced.lower() in ['yes', 'true', '1'] else 'p' + logger.debug(f"[{self.file_path.name}] Using interlaced: {interlaced!r} -> scan_type={scan_type!r}") else: # Default to progressive if no information available scan_type = 'p' - + logger.debug(f"[{self.file_path.name}] No scan type info, defaulting to progressive") + # Calculate effective height for frame class determination aspect_ratio = 16 / 9 if height > width: effective_height = height / aspect_ratio else: effective_height = height - + # First, try to match width to typical widths # Use a larger tolerance (10 pixels) to handle cinema/ultrawide aspect ratios width_matches = [] @@ -109,18 +111,21 @@ class MediaInfoExtractor: if abs(width - tw) <= 10 and frame_class.endswith(scan_type): diff = abs(height - info['nominal_height']) width_matches.append((frame_class, diff)) - + if width_matches: # Choose the frame class with the smallest height difference width_matches.sort(key=lambda x: x[1]) - return width_matches[0][0] - + result = width_matches[0][0] + logger.debug(f"[{self.file_path.name}] Result (width match): {result!r}") + return result + # If no width match, fall back to height-based matching # First try exact match with standard frame classes frame_class = f"{int(round(effective_height))}{scan_type}" if frame_class in FRAME_CLASSES: + logger.debug(f"[{self.file_path.name}] Result (exact height match): {frame_class!r}") return frame_class - + # Find closest standard height match closest_class = None min_diff = float('inf') @@ -130,12 +135,14 @@ class MediaInfoExtractor: if diff < min_diff: min_diff = diff closest_class = fc - + # Return closest standard match if within reasonable distance (20 pixels) if closest_class and min_diff <= 20: + logger.debug(f"[{self.file_path.name}] Result (closest match, diff={min_diff}): {closest_class!r}") return closest_class - + # For non-standard resolutions, create a custom frame class + logger.debug(f"[{self.file_path.name}] Result (custom/non-standard): {frame_class!r}") return frame_class @cached_method() @@ -148,7 +155,7 @@ class MediaInfoExtractor: if width and height: return width, height return None - + @cached_method() def extract_aspect_ratio(self) -> str | None: """Extract video aspect ratio from media info""" @@ -186,7 +193,7 @@ class MediaInfoExtractor: # If conversion fails, use the original code logger.debug(f"Invalid language code '{lang_code}': {e}") langs.append(lang_code.lower()[:3]) - + lang_counts = Counter(langs) audio_langs = [f"{count}{lang}" if count > 1 else lang for lang, count in lang_counts.items()] return ','.join(audio_langs) @@ -265,8 +272,8 @@ class MediaInfoExtractor: if not general_track: return None format_ = getattr(general_track, 'format', None) - if format_ in self._format_to_extensions: - exts = self._format_to_extensions[format_] + if format_ in META_TYPE_TO_EXTENSIONS: + exts = META_TYPE_TO_EXTENSIONS[format_] if format_ == 'Matroska': if self.is_3d() and 'mk3d' in exts: return 'mk3d' @@ -282,4 +289,50 @@ class MediaInfoExtractor: if not self.is_3d(): return None stereoscopic = getattr(self.video_tracks[0], 'stereoscopic', None) - return stereoscopic if stereoscopic else None \ No newline at end of file + return stereoscopic if stereoscopic else None + + @cached_method() + def extract_interlaced(self) -> bool | None: + """Determine if the video is interlaced. + + Returns: + True: Video is interlaced + False: Video is progressive (explicitly set) + None: Information not available in MediaInfo + """ + if not self.video_tracks: + logger.debug(f"[{self.file_path.name}] Interlaced detection: No video tracks") + return None + + scan_type_attr = getattr(self.video_tracks[0], 'scan_type', None) + interlaced = getattr(self.video_tracks[0], 'interlaced', None) + + logger.debug(f"[{self.file_path.name}] Interlaced detection:") + logger.debug(f"[{self.file_path.name}] scan_type: {scan_type_attr!r} (type: {type(scan_type_attr).__name__})") + logger.debug(f"[{self.file_path.name}] interlaced: {interlaced!r} (type: {type(interlaced).__name__})") + + # Check scan_type attribute first (e.g., "Interlaced", "Progressive", "MBAFF") + if scan_type_attr and isinstance(scan_type_attr, str): + scan_lower = scan_type_attr.lower() + if 'interlaced' in scan_lower or 'mbaff' in scan_lower: + logger.debug(f"[{self.file_path.name}] Result: True (from scan_type={scan_type_attr!r})") + return True + elif 'progressive' in scan_lower: + logger.debug(f"[{self.file_path.name}] Result: False (from scan_type={scan_type_attr!r})") + return False + # If scan_type has some other value, fall through to check interlaced + logger.debug(f"[{self.file_path.name}] scan_type unrecognized, checking interlaced attribute") + + # Check interlaced attribute (e.g., "Yes", "No") + if interlaced and isinstance(interlaced, str): + interlaced_lower = interlaced.lower() + if interlaced_lower in ['yes', 'true', '1']: + logger.debug(f"[{self.file_path.name}] Result: True (from interlaced={interlaced!r})") + return True + elif interlaced_lower in ['no', 'false', '0']: + logger.debug(f"[{self.file_path.name}] Result: False (from interlaced={interlaced!r})") + return False + + # No information available + logger.debug(f"[{self.file_path.name}] Result: None (no information available)") + return None diff --git a/renamer/extractors/metadata_extractor.py b/renamer/extractors/metadata_extractor.py index dd0a00d..d604131 100644 --- a/renamer/extractors/metadata_extractor.py +++ b/renamer/extractors/metadata_extractor.py @@ -8,7 +8,7 @@ import mutagen import logging from pathlib import Path from ..constants import MEDIA_TYPES -from ..cache import cached_method +from ..cache import cached_method, Cache logger = logging.getLogger(__name__) @@ -32,13 +32,16 @@ class MetadataExtractor: >>> duration = extractor.extract_duration() """ - def __init__(self, file_path: Path): + def __init__(self, file_path: Path, use_cache: bool = True): """Initialize the MetadataExtractor. Args: file_path: Path object pointing to the media file + use_cache: Whether to use caching (default: True) """ self.file_path = file_path + self.cache = Cache() if use_cache else None # Singleton cache for @cached_method decorator + self.settings = None # Will be set by Settings singleton if needed self._cache: dict[str, any] = {} # Internal cache for method results try: self.info = mutagen.File(file_path) # type: ignore diff --git a/renamer/extractors/tmdb_extractor.py b/renamer/extractors/tmdb_extractor.py index c5a4e38..d31613f 100644 --- a/renamer/extractors/tmdb_extractor.py +++ b/renamer/extractors/tmdb_extractor.py @@ -13,10 +13,11 @@ from ..settings import Settings class TMDBExtractor: """Class to extract TMDB movie information""" - def __init__(self, file_path: Path): + def __init__(self, file_path: Path, use_cache: bool = True): self.file_path = file_path - self.cache = Cache() - self.ttl_seconds = Settings().get("cache_ttl_extractors", 21600) + self.cache = Cache() if use_cache else None # Singleton cache + self.settings = Settings() # Singleton settings + self.ttl_seconds = self.settings.get("cache_ttl_extractors", 21600) self._movie_db_info = None def _get_cached_data(self, cache_key: str) -> Optional[Dict[str, Any]]: diff --git a/renamer/formatters/special_info_formatter.py b/renamer/formatters/special_info_formatter.py index b1f2d4b..e726259 100644 --- a/renamer/formatters/special_info_formatter.py +++ b/renamer/formatters/special_info_formatter.py @@ -15,21 +15,15 @@ class SpecialInfoFormatter: """Format database info dictionary or tuple/list into a string""" import logging import os - if os.getenv("FORMATTER_LOG"): - logging.info(f"format_database_info called with: {database_info!r} (type: {type(database_info)})") if isinstance(database_info, dict) and 'name' in database_info and 'id' in database_info: db_name = database_info['name'] db_id = database_info['id'] result = f"{db_name}id-{db_id}" - if os.getenv("FORMATTER_LOG"): - logging.info(f"Formatted dict to: {result!r}") return result elif isinstance(database_info, (tuple, list)) and len(database_info) == 2: db_name, db_id = database_info result = f"{db_name}id-{db_id}" - if os.getenv("FORMATTER_LOG"): - logging.info(f"Formatted tuple/list to: {result!r}") return result if os.getenv("FORMATTER_LOG"): logging.info("Returning None") - return None \ No newline at end of file + return None diff --git a/renamer/logging_config.py b/renamer/logging_config.py new file mode 100644 index 0000000..b971cbe --- /dev/null +++ b/renamer/logging_config.py @@ -0,0 +1,46 @@ +"""Singleton logging configuration for the renamer application. + +This module provides centralized logging configuration that is initialized +once and used throughout the application. +""" + +import logging +import os +import threading + + +class LoggerConfig: + """Singleton logger configuration.""" + + _instance = None + _lock = threading.Lock() + _initialized = False + + def __new__(cls): + """Create or return singleton instance.""" + if cls._instance is None: + with cls._lock: + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self): + """Initialize logging configuration (only once).""" + if LoggerConfig._initialized: + return + + # Check environment variable for formatter logging + if os.getenv('FORMATTER_LOG', '0') == '1': + logging.basicConfig( + filename='formatter.log', + level=logging.DEBUG, + format='%(asctime)s - %(levelname)s - %(message)s' + ) + else: + logging.basicConfig(level=logging.INFO) + + LoggerConfig._initialized = True + + +# Initialize logging on import +LoggerConfig() diff --git a/renamer/services/conversion_service.py b/renamer/services/conversion_service.py index e8df176..52046a6 100644 --- a/renamer/services/conversion_service.py +++ b/renamer/services/conversion_service.py @@ -11,6 +11,7 @@ This service manages the process of converting AVI/MPG/MPEG/WebM/MP4 files to MK import logging import subprocess import platform +import re from pathlib import Path from typing import Optional, List, Dict, Tuple @@ -216,6 +217,34 @@ class ConversionService: logger.debug(f"Found {len(subtitle_files)} subtitle files for {video_path.name}") return subtitle_files + def _expand_lang_counts(self, lang_str: str) -> List[str]: + """Expand language string with counts to individual languages. + + Handles formats like: + - "2ukr" -> ['ukr', 'ukr'] + - "ukr" -> ['ukr'] + - "3eng" -> ['eng', 'eng', 'eng'] + + Args: + lang_str: Language string possibly with numeric prefix + + Returns: + List of expanded language codes + + Example: + >>> service._expand_lang_counts("2ukr") + ['ukr', 'ukr'] + """ + # Match pattern: optional number + language code + match = re.match(r'^(\d+)?([a-z]{2,3})$', lang_str.lower()) + if match: + count = int(match.group(1)) if match.group(1) else 1 + lang = match.group(2) + return [lang] * count + else: + # No numeric prefix, return as-is + return [lang_str.lower()] + def map_audio_languages( self, extractor: MediaExtractor, @@ -227,6 +256,8 @@ class ConversionService: in order. If filename has fewer languages than tracks, remaining tracks get None. + Handles numeric prefixes like "2ukr,eng" -> ['ukr', 'ukr', 'eng'] + Args: extractor: MediaExtractor with filename data audio_track_count: Number of audio tracks in the file @@ -235,9 +266,10 @@ class ConversionService: List of language codes (or None) for each audio track Example: - >>> langs = service.map_audio_languages(extractor, 2) + >>> langs = service.map_audio_languages(extractor, 3) + >>> # For filename with [2ukr,eng] >>> print(langs) - ['ukr', 'eng'] + ['ukr', 'ukr', 'eng'] """ # Get audio_langs from filename extractor audio_langs_str = extractor.get('audio_langs', 'Filename') @@ -246,8 +278,13 @@ class ConversionService: logger.debug("No audio languages found in filename") return [None] * audio_track_count - # Split by comma and clean - langs = [lang.strip().lower() for lang in audio_langs_str.split(',')] + # Split by comma and expand numeric prefixes + lang_parts = [lang.strip() for lang in audio_langs_str.split(',')] + langs = [] + for part in lang_parts: + langs.extend(self._expand_lang_counts(part)) + + logger.debug(f"Expanded languages from '{audio_langs_str}' to: {langs}") # Map to tracks (pad with None if needed) result = [] diff --git a/renamer/settings.py b/renamer/settings.py index 1f8eab2..42899cb 100644 --- a/renamer/settings.py +++ b/renamer/settings.py @@ -1,11 +1,12 @@ import json import os +import threading from pathlib import Path -from typing import Dict, Any +from typing import Dict, Any, Optional class Settings: - """Manages application settings stored in a JSON file.""" + """Manages application settings stored in a JSON file (Singleton).""" DEFAULTS = { "mode": "technical", # "technical" or "catalog" @@ -17,12 +18,30 @@ class Settings: "cache_ttl_posters": 2592000, # 30 days in seconds } + _instance: Optional['Settings'] = None + _lock = threading.Lock() + + def __new__(cls, config_dir: Path | None = None): + """Create or return singleton instance.""" + if cls._instance is None: + with cls._lock: + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialized = False + return cls._instance + def __init__(self, config_dir: Path | None = None): + """Initialize settings (only once).""" + # Only initialize once + if self._initialized: + return + if config_dir is None: config_dir = Path.home() / ".config" / "renamer" self.config_dir = config_dir self.config_file = self.config_dir / "config.json" self._settings = self.DEFAULTS.copy() + self._initialized = True self.load() def load(self) -> None: diff --git a/renamer/views/media_panel.py b/renamer/views/media_panel.py index c5d25f4..3d1e47e 100644 --- a/renamer/views/media_panel.py +++ b/renamer/views/media_panel.py @@ -126,6 +126,7 @@ class MediaPanelView: self._props.title("Media Info Extraction"), self._props.mediainfo_duration, self._props.mediainfo_frame_class, + self._props.mediainfo_interlace, self._props.mediainfo_resolution, self._props.mediainfo_aspect_ratio, self._props.mediainfo_hdr, diff --git a/renamer/views/media_panel_properties.py b/renamer/views/media_panel_properties.py index ef81f9c..b3fc646 100644 --- a/renamer/views/media_panel_properties.py +++ b/renamer/views/media_panel_properties.py @@ -223,6 +223,14 @@ class MediaPanelProperties: """Get MediaInfo frame class formatted with label.""" return self._extractor.get("frame_class", "MediaInfo") + @property + @conditional_decorators.wrap("Interlaced: ") + @text_decorators.colour(name="grey") + @conditional_decorators.default("Not extracted") + def mediainfo_interlace(self) -> str: + """Get MediaInfo interlace formatted with label.""" + return self._extractor.get("interlaced", "MediaInfo") + @property @conditional_decorators.wrap("Resolution: ") @text_decorators.colour(name="grey") diff --git a/uv.lock b/uv.lock index 1fc889e..eb297de 100644 --- a/uv.lock +++ b/uv.lock @@ -462,7 +462,7 @@ wheels = [ [[package]] name = "renamer" -version = "0.8.8" +version = "0.8.9" source = { editable = "." } dependencies = [ { name = "langcodes" },