This commit is contained in:
Ignacio Serantes
2026-03-26 09:01:41 +01:00
parent dfddfd17b3
commit a7ce2ceb75
10 changed files with 178 additions and 113 deletions

View File

@@ -36,13 +36,13 @@ from PySide6.QtGui import QImage, QImageReader, QImageIOHandler
from constants import (
APP_CONFIG, CACHE_PATH, CACHE_MAX_SIZE, CONFIG_DIR, DISK_CACHE_MAX_BYTES,
HAVE_BAGHEERASEARCH_LIB, IMAGE_EXTENSIONS, RATING_XATTR_NAME,
SCANNER_SETTINGS_DEFAULTS, SEARCH_CMD, THUMBNAIL_SIZES, XATTR_NAME,
HAVE_BAGHEERASEARCH_LIB, IMAGE_EXTENSIONS,
SCANNER_SETTINGS_DEFAULTS, SEARCH_CMD, THUMBNAIL_SIZES,
UITexts
)
from imageviewer import ImageViewer
from metadatamanager import XattrManager
from metadatamanager import load_common_metadata
if HAVE_BAGHEERASEARCH_LIB:
from bagheera_search_lib import BagheeraSearcher
@@ -50,6 +50,10 @@ if HAVE_BAGHEERASEARCH_LIB:
# Set up logging for better debugging
logger = logging.getLogger(__name__)
# Result structure for thumbnail retrieval to ensure API stability
ThumbnailResult = collections.namedtuple('ThumbnailResult', ['image', 'mtime', 'tier'])
EMPTY_THUMBNAIL = ThumbnailResult(None, 0, 0)
class ThreadPoolManager:
"""Manages a global QThreadPool to dynamically adjust thread count."""
@@ -151,11 +155,10 @@ class ScannerWorker(QRunnable):
return
# Check if a valid thumbnail for this size exists
thumb, mtime = self.cache.get_thumbnail(self.path, size,
curr_mtime=curr_mtime,
inode=curr_inode,
device_id=curr_dev)
if not thumb or mtime != curr_mtime:
res = self.cache.get_thumbnail(
self.path, size, curr_mtime=curr_mtime,
inode=curr_inode, device_id=curr_dev)
if not res.image or res.mtime != curr_mtime or res.tier != size:
# Use generation lock to prevent multiple threads generating
with self.cache.generation_lock(
self.path, size, curr_mtime,
@@ -177,19 +180,20 @@ class ScannerWorker(QRunnable):
else:
# Another thread generated it, re-fetch
if size == min_size:
re_thumb, _ = self.cache.get_thumbnail(
re_res = self.cache.get_thumbnail(
self.path, size, curr_mtime=curr_mtime,
inode=curr_inode, device_id=curr_dev,
async_load=False)
smallest_thumb_for_signal = re_thumb
smallest_thumb_for_signal = re_res.image
elif size == min_size:
# valid thumb exists, use it for signal
smallest_thumb_for_signal = thumb
smallest_thumb_for_signal = res.image
tags = []
rating = 0
if self.load_metadata_flag:
tags, rating = self._load_metadata(fd)
res_meta = load_common_metadata(fd)
tags, rating = res_meta.tags, res_meta.rating
self.result = (self.path, smallest_thumb_for_signal,
curr_mtime, tags, rating, curr_inode, curr_dev)
except Exception as e:
@@ -206,21 +210,6 @@ class ScannerWorker(QRunnable):
if self.semaphore:
self.semaphore.release()
def _load_metadata(self, path_or_fd):
"""Loads tag and rating data for a path or file descriptor."""
tags = []
raw_tags = XattrManager.get_attribute(path_or_fd, XATTR_NAME)
if raw_tags:
tags = sorted(list(set(t.strip()
for t in raw_tags.split(',') if t.strip())))
raw_rating = XattrManager.get_attribute(path_or_fd, RATING_XATTR_NAME, "0")
try:
rating = int(raw_rating)
except ValueError:
rating = 0
return tags, rating
def generate_thumbnail(path, size, fd=None):
"""Generates a QImage thumbnail for a given path and size."""
@@ -475,12 +464,12 @@ class CacheLoader(QThread):
# Call synchronous get_thumbnail to fetch and decode
# This puts the result into the RAM cache
img, _ = self.cache.get_thumbnail(
res = self.cache.get_thumbnail(
path, size, curr_mtime=mtime, inode=inode,
device_id=dev, async_load=False
)
if img:
if res.image:
self.cache.thumbnail_loaded.emit(path, size)
self.cache._mark_load_complete(path, size)
@@ -722,7 +711,7 @@ class ThumbnailCache(QObject):
def _check_disk_cache(self, path, search_order, mtime, dev_id, inode_key):
"""Helper to check LMDB synchronously."""
if not self._lmdb_env or not inode_key or dev_id == 0:
return None, 0
return EMPTY_THUMBNAIL
txn = None
try:
@@ -754,35 +743,52 @@ class ThumbnailCache(QObject):
self._thumbnail_cache[path][size] = (img, mtime)
self._cache_bytes_size += img.sizeInBytes()
self._path_to_inode[path] = (dev_id, inode_key)
return img, mtime
return ThumbnailResult(img, mtime, size)
except Exception as e:
logger.debug(f"Cache lookup error for {path}: {e}")
finally:
if txn:
txn.abort()
return None, 0
return EMPTY_THUMBNAIL
def get_available_tier(self, path, requested_size, mtime):
"""Returns the best available tier in memory for the given path."""
target_tier = self._get_tier_for_size(requested_size)
with self._read_lock():
cached_sizes = self._thumbnail_cache.get(path)
if not cached_sizes:
return 0
if target_tier in cached_sizes:
img, cached_mtime = cached_sizes[target_tier]
if img and not img.isNull() and cached_mtime == mtime:
return target_tier
for size in THUMBNAIL_SIZES:
if size in cached_sizes:
img, cached_mtime = cached_sizes[size]
if img and not img.isNull() and cached_mtime == mtime:
return size
return 0
def get_thumbnail(self, path, requested_size, curr_mtime=None,
inode=None, device_id=None, async_load=False):
"""
Safely retrieve a thumbnail from cache, finding the best available size.
Returns: tuple (QImage or None, mtime) or (None, 0) if not found.
Returns: ThumbnailResult object.
"""
# 1. Determine the ideal tier and create a prioritized search order.
target_tier = self._get_tier_for_size(requested_size)
search_order = [target_tier] + \
sorted([s for s in THUMBNAIL_SIZES if s > target_tier]) + \
sorted([s for s in THUMBNAIL_SIZES if s < target_tier], reverse=True)
# 2. Resolve file identity (mtime, dev, inode)
mtime, dev_id, inode_key = self._resolve_file_identity(
path, curr_mtime, inode, device_id)
if mtime is None:
return None, 0
return EMPTY_THUMBNAIL
best_img, best_mtime, best_tier = None, 0, 0
# 3. Check memory cache (fastest)
with self._read_lock():
cached_sizes = self._thumbnail_cache.get(path)
if cached_sizes:
@@ -790,15 +796,22 @@ class ThumbnailCache(QObject):
if size in cached_sizes:
img, cached_mtime = cached_sizes[size]
if img and not img.isNull() and cached_mtime == mtime:
return img, mtime
if size == target_tier:
return ThumbnailResult(img, mtime, size)
if not best_img:
best_img, best_mtime, best_tier = img, mtime, size
# 4. Handle Async Request
if async_load:
self._queue_async_load(path, target_tier, mtime, dev_id, inode_key)
return None, 0
return ThumbnailResult(best_img, best_mtime, best_tier)
# 5. Check Disk Cache (Sync fallback)
return self._check_disk_cache(path, search_order, mtime, dev_id, inode_key)
res = self._check_disk_cache(path, [target_tier], mtime, dev_id, inode_key)
if res.image:
return res
if best_img:
return ThumbnailResult(best_img, best_mtime, best_tier)
other_tiers = [s for s in search_order if s != target_tier]
return self._check_disk_cache(path, other_tiers, mtime, dev_id, inode_key)
def _mark_load_complete(self, path, size):
"""Remove item from pending loading set."""
@@ -849,6 +862,7 @@ class ThumbnailCache(QObject):
if self._cache_writer:
self._cache_writer.enqueue(
(dev_id, inode_key, img, mtime, size, path), block=block)
self.thumbnail_loaded.emit(path, size)
return True
def invalidate_path(self, path):
@@ -1074,12 +1088,32 @@ class ThumbnailCache(QObject):
for size in THUMBNAIL_SIZES:
try:
db = self._get_device_db(device_id, size, write=True)
if not db:
continue
with env.begin(write=True) as txn:
txn.delete(inode_key, db=db)
# Get DB handle using the current transaction and don't force create
db = self._get_device_db(device_id, size, write=False, txn=txn)
if not db:
continue
try:
txn.delete(inode_key, db=db)
except lmdb.Error as e:
if "Invalid argument" in str(e):
# Handle potential stale DB handles (EINVAL)
db_name = f"dev_{device_id}_{size}".encode('utf-8')
self._db_lock.lock()
self._db_handles.pop(db_name, None)
self._db_lock.unlock()
# Retry with a fresh handle within the same transaction
db = self._get_device_db(
device_id, size, write=False, txn=txn)
if db:
txn.delete(inode_key, db=db)
elif "not found" in str(e).lower():
pass
else:
raise
except lmdb.NotFoundError:
pass
except Exception as e:
logger.error(f"Error deleting from LMDB for size {size}: {e}")