v0.9.14
This commit is contained in:
128
imagescanner.py
128
imagescanner.py
@@ -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}")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user