Add Ice Age: Continental Drift (2012) BDRip file to test filenames

This commit is contained in:
sHa
2025-12-30 23:40:48 +00:00
parent c4777352e9
commit 6121311444
11 changed files with 88 additions and 84 deletions

BIN
dist/renamer-0.5.10-py3-none-any.whl vendored Normal file

Binary file not shown.

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "renamer" name = "renamer"
version = "0.5.9" version = "0.5.10"
description = "Terminal-based media file renamer and metadata viewer" description = "Terminal-based media file renamer and metadata viewer"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"

View File

@@ -17,7 +17,6 @@ from .formatters.proposed_name_formatter import ProposedNameFormatter
from .formatters.text_formatter import TextFormatter from .formatters.text_formatter import TextFormatter
from .formatters.catalog_formatter import CatalogFormatter from .formatters.catalog_formatter import CatalogFormatter
from .settings import Settings from .settings import Settings
from .cache import Cache
# Set up logging conditionally # Set up logging conditionally
@@ -57,7 +56,6 @@ class RenamerApp(App):
self.scan_dir = Path(scan_dir) if scan_dir else None self.scan_dir = Path(scan_dir) if scan_dir else None
self.tree_expanded = False self.tree_expanded = False
self.settings = Settings() self.settings = Settings()
self.cache = Cache()
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
with Horizontal(): with Horizontal():
@@ -148,10 +146,9 @@ class RenamerApp(App):
).start() ).start()
def _extract_and_show_details(self, file_path: Path): def _extract_and_show_details(self, file_path: Path):
time.sleep(1) # Minimum delay to show loading
try: try:
# Initialize extractors and formatters # Initialize extractors and formatters
extractor = MediaExtractor.create(file_path, self.cache, self.settings.get("cache_ttl_extractors")) extractor = MediaExtractor(file_path)
mode = self.settings.get("mode") mode = self.settings.get("mode")
if mode == "technical": if mode == "technical":
@@ -205,11 +202,6 @@ class RenamerApp(App):
tree = self.query_one("#file_tree", Tree) tree = self.query_one("#file_tree", Tree)
node = tree.cursor_node node = tree.cursor_node
if node and node.data and isinstance(node.data, Path) and node.data.is_file(): if node and node.data and isinstance(node.data, Path) and node.data.is_file():
# Clear cache for this file
cache_key_base = str(node.data)
# Invalidate all keys for this file (we can improve this later)
for key in ["title", "year", "source", "extension", "video_tracks", "audio_tracks", "subtitle_tracks"]:
self.cache.invalidate(f"{cache_key_base}_{key}")
self._start_loading_animation() self._start_loading_animation()
threading.Thread( threading.Thread(
target=self._extract_and_show_details, args=(node.data,) target=self._extract_and_show_details, args=(node.data,)
@@ -240,7 +232,7 @@ class RenamerApp(App):
node = tree.cursor_node node = tree.cursor_node
if node and node.data and isinstance(node.data, Path) and node.data.is_file(): if node and node.data and isinstance(node.data, Path) and node.data.is_file():
# Get the proposed name from the extractor # Get the proposed name from the extractor
extractor = MediaExtractor.create(node.data, self.cache, self.settings.get("cache_ttl_extractors")) extractor = MediaExtractor(node.data)
proposed_formatter = ProposedNameFormatter(extractor) proposed_formatter = ProposedNameFormatter(extractor)
new_name = str(proposed_formatter) new_name = str(proposed_formatter)
logging.info(f"Proposed new name: {new_name!r} for file: {node.data}") logging.info(f"Proposed new name: {new_name!r} for file: {node.data}")
@@ -273,11 +265,6 @@ class RenamerApp(App):
"""Update the tree node for a renamed file.""" """Update the tree node for a renamed file."""
logging.info(f"update_renamed_file called with old_path={old_path}, new_path={new_path}") logging.info(f"update_renamed_file called with old_path={old_path}, new_path={new_path}")
# Clear cache for old file
cache_key_base = str(old_path)
for key in ["title", "year", "source", "extension", "video_tracks", "audio_tracks", "subtitle_tracks"]:
self.cache.invalidate(f"{cache_key_base}_{key}")
tree = self.query_one("#file_tree", Tree) tree = self.query_one("#file_tree", Tree)
logging.info(f"Before update: cursor_node.data = {tree.cursor_node.data if tree.cursor_node else None}") logging.info(f"Before update: cursor_node.data = {tree.cursor_node.data if tree.cursor_node else None}")

View File

@@ -11,13 +11,16 @@ class Cache:
"""File-based cache with TTL support.""" """File-based cache with TTL support."""
def __init__(self, cache_dir: Optional[Path] = None): def __init__(self, cache_dir: Optional[Path] = None):
if cache_dir is None: # Always use the default cache dir to avoid creating cache in scan dir
cache_dir = Path.home() / ".cache" / "renamer" cache_dir = Path.home() / ".cache" / "renamer"
self.cache_dir = cache_dir self.cache_dir = cache_dir
self.cache_dir.mkdir(parents=True, exist_ok=True) self.cache_dir.mkdir(parents=True, exist_ok=True)
self._memory_cache = {} # In-memory cache for faster access
def _get_cache_file(self, key: str) -> Path: def _get_cache_file(self, key: str) -> Path:
"""Get cache file path with hashed filename and subdirs.""" """Get cache file path with hashed filename and subdirs."""
import logging
logging.info(f"Cache _get_cache_file called with key: {key!r}")
# Parse key format: ClassName.method_name.param_hash # Parse key format: ClassName.method_name.param_hash
if '.' in key: if '.' in key:
parts = key.split('.') parts = key.split('.')
@@ -26,12 +29,27 @@ class Cache:
method_name = parts[1] method_name = parts[1]
param_hash = parts[2] param_hash = parts[2]
# Use class name as subdir, but if it contains '/', use general to avoid creating nested dirs
if '/' in class_name or '\\' in class_name:
subdir = "general"
subkey = key
file_ext = "json"
else:
subdir = class_name
file_ext = "pkl"
# Use class name as subdir # Use class name as subdir
cache_subdir = self.cache_dir / class_name cache_subdir = self.cache_dir / subdir
logging.info(f"Cache parsed key, class_name: {class_name!r}, cache_subdir: {cache_subdir!r}")
cache_subdir.mkdir(parents=True, exist_ok=True) cache_subdir.mkdir(parents=True, exist_ok=True)
# Use method_name.param_hash as filename if file_ext == "pkl":
return cache_subdir / f"{method_name}.{param_hash}.pkl" # Use method_name.param_hash as filename
return cache_subdir / f"{method_name}.{param_hash}.pkl"
else:
# Hash the subkey for filename
key_hash = hashlib.md5(subkey.encode('utf-8')).hexdigest()
return cache_subdir / f"{key_hash}.json"
# Fallback for old keys (tmdb_, poster_, etc.) # Fallback for old keys (tmdb_, poster_, etc.)
if key.startswith("tmdb_"): if key.startswith("tmdb_"):
@@ -40,12 +58,16 @@ class Cache:
elif key.startswith("poster_"): elif key.startswith("poster_"):
subdir = "posters" subdir = "posters"
subkey = key[7:] # Remove "poster_" prefix subkey = key[7:] # Remove "poster_" prefix
elif key.startswith("extractor_"):
subdir = "extractors"
subkey = key[10:] # Remove "extractor_" prefix
else: else:
subdir = "general" subdir = "general"
subkey = key subkey = key
# Create subdir # Create subdir
cache_subdir = self.cache_dir / subdir cache_subdir = self.cache_dir / subdir
logging.info(f"Cache fallback, subdir: {subdir!r}, cache_subdir: {cache_subdir!r}")
cache_subdir.mkdir(parents=True, exist_ok=True) cache_subdir.mkdir(parents=True, exist_ok=True)
# Hash the subkey for filename # Hash the subkey for filename
@@ -54,6 +76,14 @@ class Cache:
def get(self, key: str) -> Optional[Any]: def get(self, key: str) -> Optional[Any]:
"""Get cached value if not expired.""" """Get cached value if not expired."""
# Check memory cache first
if key in self._memory_cache:
data = self._memory_cache[key]
if time.time() > data.get('expires', 0):
del self._memory_cache[key]
return None
return data.get('value')
cache_file = self._get_cache_file(key) cache_file = self._get_cache_file(key)
if not cache_file.exists(): if not cache_file.exists():
return None return None
@@ -67,6 +97,8 @@ class Cache:
cache_file.unlink(missing_ok=True) cache_file.unlink(missing_ok=True)
return None return None
# Store in memory cache
self._memory_cache[key] = data
return data.get('value') return data.get('value')
except (json.JSONDecodeError, IOError): except (json.JSONDecodeError, IOError):
# Corrupted, remove # Corrupted, remove
@@ -75,11 +107,14 @@ class Cache:
def set(self, key: str, value: Any, ttl_seconds: int) -> None: def set(self, key: str, value: Any, ttl_seconds: int) -> None:
"""Set cached value with TTL.""" """Set cached value with TTL."""
cache_file = self._get_cache_file(key)
data = { data = {
'value': value, 'value': value,
'expires': time.time() + ttl_seconds 'expires': time.time() + ttl_seconds
} }
# Store in memory cache
self._memory_cache[key] = data
cache_file = self._get_cache_file(key)
try: try:
with open(cache_file, 'w') as f: with open(cache_file, 'w') as f:
json.dump(data, f) json.dump(data, f)
@@ -154,6 +189,14 @@ class Cache:
def get_object(self, key: str) -> Optional[Any]: def get_object(self, key: str) -> Optional[Any]:
"""Get pickled object from cache if not expired.""" """Get pickled object from cache if not expired."""
# Check memory cache first
if key in self._memory_cache:
data = self._memory_cache[key]
if time.time() > data.get('expires', 0):
del self._memory_cache[key]
return None
return data.get('value')
cache_file = self._get_cache_file(key) cache_file = self._get_cache_file(key)
if not cache_file.exists(): if not cache_file.exists():
return None return None
@@ -167,6 +210,8 @@ class Cache:
cache_file.unlink(missing_ok=True) cache_file.unlink(missing_ok=True)
return None return None
# Store in memory cache
self._memory_cache[key] = data
return data.get('value') return data.get('value')
except (pickle.PickleError, IOError): except (pickle.PickleError, IOError):
# Corrupted, remove # Corrupted, remove
@@ -175,11 +220,14 @@ class Cache:
def set_object(self, key: str, obj: Any, ttl_seconds: int) -> None: def set_object(self, key: str, obj: Any, ttl_seconds: int) -> None:
"""Pickle and cache object with TTL.""" """Pickle and cache object with TTL."""
cache_file = self._get_cache_file(key)
data = { data = {
'value': obj, 'value': obj,
'expires': time.time() + ttl_seconds 'expires': time.time() + ttl_seconds
} }
# Store in memory cache
self._memory_cache[key] = data
cache_file = self._get_cache_file(key)
try: try:
with open(cache_file, 'wb') as f: with open(cache_file, 'wb') as f:
pickle.dump(data, f) pickle.dump(data, f)

View File

@@ -31,12 +31,17 @@ def cached_method(ttl_seconds: int = 3600) -> Callable:
# Use instance identifier (file_path for extractors) # Use instance identifier (file_path for extractors)
instance_id = getattr(self, 'file_path', str(id(self))) instance_id = getattr(self, 'file_path', str(id(self)))
# If instance_id contains path separators, hash it to avoid creating subdirs
if '/' in str(instance_id) or '\\' in str(instance_id):
instance_id = hashlib.md5(str(instance_id).encode('utf-8')).hexdigest()
# Create hash from args and kwargs (excluding self) # Create hash from args and kwargs only if they exist (excluding self)
param_str = json.dumps((args, kwargs), sort_keys=True, default=str) if args or kwargs:
param_hash = hashlib.md5(param_str.encode('utf-8')).hexdigest() param_str = json.dumps((args, kwargs), sort_keys=True, default=str)
param_hash = hashlib.md5(param_str.encode('utf-8')).hexdigest()
cache_key = f"{class_name}.{method_name}.{instance_id}.{param_hash}" cache_key = f"{class_name}.{method_name}.{instance_id}.{param_hash}"
else:
cache_key = f"{class_name}.{method_name}.{instance_id}"
# Try to get from cache # Try to get from cache
cached_result = _cache.get_object(cache_key) cached_result = _cache.get_object(cache_key)

View File

@@ -10,38 +10,14 @@ from .default_extractor import DefaultExtractor
class MediaExtractor: class MediaExtractor:
"""Class to extract various metadata from media files using specialized extractors""" """Class to extract various metadata from media files using specialized extractors"""
@classmethod def __init__(self, file_path: Path):
def create(cls, file_path: Path, cache=None, ttl_seconds: int = 21600):
"""Factory method that returns cached object if available, else creates new."""
if cache:
cache_key = f"extractor_{file_path}"
cached_obj = cache.get_object(cache_key)
if cached_obj:
print(f"Loaded MediaExtractor object from cache for {file_path.name}")
return cached_obj
# Create new instance
instance = cls(file_path, cache, ttl_seconds)
# Cache the object
if cache:
cache_key = f"extractor_{file_path}"
cache.set_object(cache_key, instance, ttl_seconds)
print(f"Cached MediaExtractor object for {file_path.name}")
return instance
def __init__(self, file_path: Path, cache=None, ttl_seconds: int = 21600):
self.file_path = file_path self.file_path = file_path
self.cache = cache
self.ttl_seconds = ttl_seconds
self.cache_key = f"file_data_{file_path}"
self.filename_extractor = FilenameExtractor(file_path) self.filename_extractor = FilenameExtractor(file_path)
self.metadata_extractor = MetadataExtractor(file_path) self.metadata_extractor = MetadataExtractor(file_path)
self.mediainfo_extractor = MediaInfoExtractor(file_path) self.mediainfo_extractor = MediaInfoExtractor(file_path)
self.fileinfo_extractor = FileInfoExtractor(file_path) self.fileinfo_extractor = FileInfoExtractor(file_path)
self.tmdb_extractor = TMDBExtractor(file_path, cache, ttl_seconds) self.tmdb_extractor = TMDBExtractor(file_path)
self.default_extractor = DefaultExtractor() self.default_extractor = DefaultExtractor()
# Extractor mapping # Extractor mapping
@@ -190,16 +166,9 @@ class MediaExtractor:
], ],
}, },
} }
# No caching logic here - handled in create() method
def get(self, key: str, source: str | None = None): def get(self, key: str, source: str | None = None):
"""Get extracted data by key, optionally from specific source""" """Get extracted data by key, optionally from specific source"""
print(f"Extracting real data for key '{key}' in {self.file_path.name}")
return self._get_uncached(key, source)
def _get_uncached(self, key: str, source: str | None = None):
"""Original get logic without caching"""
if source: if source:
# Specific source requested - find the extractor and call the method directly # Specific source requested - find the extractor and call the method directly
for extractor_name, extractor in self._extractors.items(): for extractor_name, extractor in self._extractors.items():

View File

@@ -3,30 +3,34 @@ import os
import time import time
import hashlib import hashlib
import requests import requests
import logging
from pathlib import Path from pathlib import Path
from typing import Dict, Optional, Tuple, Any from typing import Dict, Optional, Tuple, Any
from ..secrets import TMDB_API_KEY, TMDB_ACCESS_TOKEN from ..secrets import TMDB_API_KEY, TMDB_ACCESS_TOKEN
from ..cache import Cache
from ..settings import Settings
class TMDBExtractor: class TMDBExtractor:
"""Class to extract TMDB movie information""" """Class to extract TMDB movie information"""
def __init__(self, file_path: Path, cache=None, ttl_seconds: int = 21600): def __init__(self, file_path: Path):
self.file_path = file_path self.file_path = file_path
self.cache = cache self.cache = Cache()
self.ttl_seconds = ttl_seconds self.ttl_seconds = Settings().get("cache_ttl_extractors", 21600)
self._movie_db_info = None self._movie_db_info = None
def _get_cached_data(self, cache_key: str) -> Optional[Dict[str, Any]]: def _get_cached_data(self, cache_key: str) -> Optional[Dict[str, Any]]:
"""Get data from cache if valid""" """Get data from cache if valid"""
if self.cache: if self.cache:
return self.cache.get(f"tmdb_{cache_key}") return self.cache.get_object(f"tmdb_{cache_key}")
return None return None
def _set_cached_data(self, cache_key: str, data: Dict[str, Any]): def _set_cached_data(self, cache_key: str, data: Dict[str, Any]):
"""Store data in cache""" """Store data in cache"""
if self.cache: if self.cache:
self.cache.set(f"tmdb_{cache_key}", data, self.ttl_seconds) self.cache.set_object(f"tmdb_{cache_key}", data, self.ttl_seconds)
def _make_tmdb_request(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]: def _make_tmdb_request(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
"""Make a request to TMDB API""" """Make a request to TMDB API"""
@@ -56,8 +60,10 @@ class TMDBExtractor:
# Check cache first # Check cache first
cached = self._get_cached_data(cache_key) cached = self._get_cached_data(cache_key)
if cached is not None: if cached is not None:
logging.info(f"TMDB cache hit for search: {title} ({year})")
return cached return cached
logging.info(f"TMDB cache miss for search: {title} ({year}), making request")
params = {'query': title} params = {'query': title}
if year: if year:
params['year'] = year params['year'] = year
@@ -95,8 +101,10 @@ class TMDBExtractor:
# Check cache first # Check cache first
cached = self._get_cached_data(cache_key) cached = self._get_cached_data(cache_key)
if cached is not None: if cached is not None:
logging.info(f"TMDB cache hit for movie details: {movie_id}")
return cached return cached
logging.info(f"TMDB cache miss for movie details: {movie_id}, making request")
result = self._make_tmdb_request(f'/movie/{movie_id}') result = self._make_tmdb_request(f'/movie/{movie_id}')
if result: if result:
# Cache the result # Cache the result

View File

@@ -74,18 +74,6 @@ class FormatterApplier:
# Sort formatters according to the global order # Sort formatters according to the global order
ordered_formatters = sorted(formatters, key=lambda f: FormatterApplier.FORMATTER_ORDER.index(f) if f in FormatterApplier.FORMATTER_ORDER else len(FormatterApplier.FORMATTER_ORDER)) ordered_formatters = sorted(formatters, key=lambda f: FormatterApplier.FORMATTER_ORDER.index(f) if f in FormatterApplier.FORMATTER_ORDER else len(FormatterApplier.FORMATTER_ORDER))
# Get caller info
frame = inspect.currentframe()
if frame and frame.f_back:
caller = f"{frame.f_back.f_code.co_filename}:{frame.f_back.f_lineno} in {frame.f_back.f_code.co_name}"
else:
caller = "Unknown"
logging.info(f"Caller: {caller}")
logging.info(f"Original formatters: {[f.__name__ if hasattr(f, '__name__') else str(f) for f in formatters]}")
logging.info(f"Ordered formatters: {[f.__name__ if hasattr(f, '__name__') else str(f) for f in ordered_formatters]}")
logging.info(f"Input value: {repr(value)}")
# Apply in the ordered sequence # Apply in the ordered sequence
for formatter in ordered_formatters: for formatter in ordered_formatters:
try: try:
@@ -96,7 +84,6 @@ class FormatterApplier:
logging.error(f"Error applying {formatter.__name__ if hasattr(formatter, '__name__') else str(formatter)}: {e}") logging.error(f"Error applying {formatter.__name__ if hasattr(formatter, '__name__') else str(formatter)}: {e}")
value = "Unknown" value = "Unknown"
logging.info(f"Final value: {repr(value)}")
return value return value
@staticmethod @staticmethod

View File

@@ -51,9 +51,9 @@ class Settings:
except IOError as e: except IOError as e:
print(f"Error: Could not save settings: {e}") print(f"Error: Could not save settings: {e}")
def get(self, key: str) -> Any: def get(self, key: str, default: Any = None) -> Any:
"""Get a setting value.""" """Get a setting value."""
return self._settings.get(key, self.DEFAULTS.get(key)) return self._settings.get(key, self.DEFAULTS.get(key, default))
def set(self, key: str, value: Any) -> None: def set(self, key: str, value: Any) -> None:
"""Set a setting value and save.""" """Set a setting value and save."""

2
uv.lock generated
View File

@@ -342,7 +342,7 @@ wheels = [
[[package]] [[package]]
name = "renamer" name = "renamer"
version = "0.5.9" version = "0.5.10"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "langcodes" }, { name = "langcodes" },