mirror of
https://github.com/shadoll/moma.git
synced 2026-08-28 11:33:25 +00:00
Add singleton logging configuration for the renamer application
This commit introduces a new module `logging_config.py` that implements a singleton pattern for logging configuration. The logger is initialized only once and can be configured based on an environment variable to log to a file or to the console. This centralizes logging setup and ensures consistent logging behavior throughout the application.
This commit is contained in:
Vendored
+76
-8
@@ -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).
|
||||
|
||||
|
||||
Vendored
+20
-18
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user