From a7ce2ceb751033a81fcd9a7d7cc91a96679077aa Mon Sep 17 00:00:00 2001 From: Ignacio Serantes Date: Thu, 26 Mar 2026 09:01:41 +0100 Subject: [PATCH] v0.9.14 --- bagheeraview.py | 60 +++++++++++---------- changelog.txt | 2 + constants.py | 2 +- imagecontroller.py | 16 +++--- imagescanner.py | 128 ++++++++++++++++++++++++++++----------------- imageviewer.py | 23 ++++++-- metadatamanager.py | 8 ++- pyproject.toml | 2 +- settings.py | 48 +++++++++-------- setup.py | 2 +- 10 files changed, 178 insertions(+), 113 deletions(-) diff --git a/bagheeraview.py b/bagheeraview.py index 5cc6910..476d038 100755 --- a/bagheeraview.py +++ b/bagheeraview.py @@ -14,7 +14,7 @@ Classes: MainWindow: The main application window containing the thumbnail grid and docks. """ __appname__ = "BagheeraView" -__version__ = "0.9.13" +__version__ = "0.9.14" __author__ = "Ignacio Serantes" __email__ = "kde@aynoa.net" __license__ = "LGPL" @@ -77,7 +77,7 @@ from widgets import ( TagEditWidget, LayoutsWidget, HistoryWidget, RatingWidget, CommentWidget, FavoritesWidget ) -from metadatamanager import XattrManager +from metadatamanager import load_common_metadata class ShortcutHelpDialog(QDialog): @@ -626,19 +626,20 @@ class ThumbnailDelegate(QStyledItemDelegate): # Optimization: Use QPixmapCache to avoid expensive QImage->QPixmap # conversion on every paint event. - cache_key = f"thumb_{path}_{mtime}_{thumb_size}" + actual_tier = self.main_win.cache.get_available_tier(path, thumb_size, mtime) + cache_key = f"thumb_{path}_{mtime}_{thumb_size}_{actual_tier}" source_pixmap = QPixmapCache.find(cache_key) if not source_pixmap or source_pixmap.isNull(): # Not in UI cache, try to get from main thumbnail cache (Memory/LMDB) inode = index.data(INODE_ROLE) device_id = index.data(DEVICE_ROLE) - img, _ = self.main_win.cache.get_thumbnail( + res = self.main_win.cache.get_thumbnail( path, requested_size=thumb_size, curr_mtime=mtime, inode=inode, device_id=device_id, async_load=True) - if img and not img.isNull(): - source_pixmap = QPixmap.fromImage(img) + if res.image and not res.image.isNull(): + source_pixmap = QPixmap.fromImage(res.image) QPixmapCache.insert(cache_key, source_pixmap) else: # Fallback: Check a separate cache key for the placeholder to avoid @@ -3458,26 +3459,27 @@ class MainWindow(QMainWindow): if not path: return + # Determine tags and rating based on provided metadata or disk read + if metadata: + tags = metadata.get('tags', []) + rating = metadata.get('rating', 0) + else: + res = load_common_metadata(path) + tags, rating = res.tags, res.rating + # Find the item in the source model and update its data for row in range(self.thumbnail_model.rowCount()): item = self.thumbnail_model.item(row) if item and item.data(PATH_ROLE) == path: - # Reload metadata for this item from xattr - try: - if metadata and 'tags' in metadata: - tags = metadata['tags'] - else: # Fallback to reading from disk if not provided - raw = XattrManager.get_attribute(path, XATTR_NAME) - tags = sorted(list(set(t.strip() for t in raw.split(',') - if t.strip()))) if raw else [] - item.setData(tags, TAGS_ROLE) - except Exception: - item.setData([], TAGS_ROLE) - try: - item.setData(metadata.get('rating', 0) - if metadata else 0, RATING_ROLE) - except Exception: - item.setData(0, RATING_ROLE) # Default to 0 if error + item.setData(tags, TAGS_ROLE) + item.setData(rating, RATING_ROLE) + + tooltip_text = f"{os.path.basename(path)}\n{path}" + if tags: + display_tags = [t.split('/')[-1] for t in tags] + tooltip_text += f"\n{UITexts.TAGS_TAB}: {', '.join( + display_tags)}" + item.setToolTip(tooltip_text) # Notify the view that the data has changed source_idx = self.thumbnail_model.indexFromItem(item) @@ -3485,13 +3487,10 @@ class MainWindow(QMainWindow): source_idx, source_idx, [TAGS_ROLE, RATING_ROLE]) # Update internal data structure to prevent stale data on rebuild - current_tags = item.data(TAGS_ROLE) - current_rating = item.data(RATING_ROLE) - self._update_internal_data(path, tags=current_tags, - rating=current_rating) + self._update_internal_data(path, tags=tags, rating=rating) # Update proxy filter cache to prevent stale filtering - self.proxy_model.add_to_cache(path, current_tags) + self.proxy_model.add_to_cache(path, tags) break if self.main_dock.isVisible(): @@ -3594,8 +3593,13 @@ class MainWindow(QMainWindow): if not paths: return + # Prioritize visible paths + visible = self.get_visible_image_paths() + visible_set = set(visible) + ordered_paths = visible + [p for p in paths if p not in visible_set] + self.thumbnail_generator = ThumbnailGenerator( - self.cache, paths, size, self.thread_pool_manager) + self.cache, ordered_paths, size, self.thread_pool_manager) self.thumbnail_generator.generation_complete.connect( self.on_high_res_generation_finished) self.thumbnail_generator.progress.connect( diff --git a/changelog.txt b/changelog.txt index e73edf1..c6067b2 100644 --- a/changelog.txt +++ b/changelog.txt @@ -62,6 +62,8 @@ Ahora que la carga es rápida, ¿cómo puedo implementar una precarga inteligent ¿Cómo puedo hacer que la selección de archivos sea persistente incluso después de recargar o filtrar la vista? +v0.9.14 - +· Corregido el problema de resolución de los thumbnails v0.9.13 - · Añadida la opción de favoritos. diff --git a/constants.py b/constants.py index 8659ab8..bfbaaa0 100644 --- a/constants.py +++ b/constants.py @@ -29,7 +29,7 @@ if FORCE_X11: # --- CONFIGURATION --- PROG_NAME = "Bagheera Image Viewer" PROG_ID = "bagheeraview" -PROG_VERSION = "0.9.13" +PROG_VERSION = "0.9.14" PROG_AUTHOR = "Ignacio Serantes" # --- CACHE SETTINGS --- diff --git a/imagecontroller.py b/imagecontroller.py index b10eddf..63949ea 100644 --- a/imagecontroller.py +++ b/imagecontroller.py @@ -112,8 +112,8 @@ class ImagePreloader(QThread): img = reader.read() if not img.isNull(): # Load tags and rating here to avoid re-reading in main thread - tags, rating = load_common_metadata(path) - self.image_ready.emit(idx, path, img, tags, rating) + res = load_common_metadata(path) + self.image_ready.emit(idx, path, img, res.tags, res.rating) except Exception as e: logger.warning(f"ImagePreloader failed to load {path}: {e}") @@ -220,7 +220,8 @@ class ImageController(QObject): if path and self._loaded_path == path and not self.pixmap_original.isNull(): # Ensure metadata is consistent with current path if self._current_metadata_path != path: - self._current_tags, self._current_rating = load_common_metadata(path) + res = load_common_metadata(path) + self._current_tags, self._current_rating = res.tags, res.rating self._current_metadata_path = path self._trigger_preload() @@ -258,7 +259,8 @@ class ImageController(QObject): # Load tags and rating if not already set for this path if self._current_metadata_path != path: - self._current_tags, self._current_rating = load_common_metadata(path) + res = load_common_metadata(path) + self._current_tags, self._current_rating = res.tags, res.rating self._current_metadata_path = path self._loaded_path = path @@ -778,7 +780,8 @@ class ImageController(QObject): # Reload from disk if not provided to ensure consistency path = self.get_current_path() if path: - self._current_tags, self._current_rating = load_common_metadata(path) + res = load_common_metadata(path) + self._current_tags, self._current_rating = res.tags, res.rating self._current_metadata_path = path else: self._current_tags = [] @@ -810,7 +813,8 @@ class ImageController(QObject): # Reload metadata for the current image to avoid stale/empty state path = self.get_current_path() if path: - self._current_tags, self._current_rating = load_common_metadata(path) + res = load_common_metadata(path) + self._current_tags, self._current_rating = res.tags, res.rating self._current_metadata_path = path else: self._current_tags = [] diff --git a/imagescanner.py b/imagescanner.py index b183868..03a0a6d 100644 --- a/imagescanner.py +++ b/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}") diff --git a/imageviewer.py b/imageviewer.py index 1925f84..65d1432 100644 --- a/imageviewer.py +++ b/imageviewer.py @@ -34,6 +34,7 @@ from constants import ( ICON_THEME_VIEWER_FALLBACK, KSCREEN_DOCTOR_MARGIN, KWINOUTPUTCONFIG_PATH, VIEWER_AUTO_RESIZE_WINDOW_DEFAULT, VIEWER_FORM_MARGIN, VIEWER_LABEL, VIEWER_WHEEL_SPEED_DEFAULT, XATTR_NAME, ZOOM_DESKTOP_RATIO, UITexts, + AVAILABLE_FACE_ENGINES, AVAILABLE_PET_ENGINES ) from imagecontroller import ImageController from widgets import FaceNameInputWidget @@ -156,9 +157,9 @@ class FilmstripLoader(QThread): # Small sleep to prevent UI freezing during heavy IO bursts self.msleep(1) try: - img, _ = self.cache.get_thumbnail(path, self.icon_size) - if img and not img.isNull(): - self.thumbnail_loaded.emit(index, img) + res = self.cache.get_thumbnail(path, self.icon_size) + if res.image and not res.image.isNull(): + self.thumbnail_loaded.emit(index, res.image) except Exception: pass @@ -2535,6 +2536,13 @@ class ImageViewer(QWidget): action.setCheckable(True) action.setChecked(item.get("checked", False)) + if "enabled" in item: + action.setEnabled(item["enabled"]) + + if "tooltip" in item: + action.setToolTip(item["tooltip"]) + action.setStatusTip(item["tooltip"]) + def restore_scroll_for_pane(self, pane, config): """ Applies the saved scrollbar positions from a layout configuration. @@ -2802,8 +2810,13 @@ class ImageViewer(QWidget): {"text": UITexts.VIEWER_MENU_DETECT_AREAS, "icon": "edit-image-face-recognize", "submenu": [ {"text": UITexts.VIEWER_MENU_DETECT_FACES, - "action": "detect_faces"}, - {"text": UITexts.VIEWER_MENU_DETECT_PETS, "action": "detect_pets"}, + "action": "detect_faces", + "enabled": bool(AVAILABLE_FACE_ENGINES), + "tooltip": "" if AVAILABLE_FACE_ENGINES else UITexts.NO_FACE_LIBS}, + {"text": UITexts.VIEWER_MENU_DETECT_PETS, + "action": "detect_pets", + "enabled": bool(AVAILABLE_PET_ENGINES), + "tooltip": "" if AVAILABLE_PET_ENGINES else UITexts.NO_FACE_LIBS}, ]}, "separator", {"text": UITexts.VIEWER_MENU_MANIPULATE, diff --git a/metadatamanager.py b/metadatamanager.py index d3757de..443f505 100644 --- a/metadatamanager.py +++ b/metadatamanager.py @@ -8,6 +8,7 @@ Classes: MetadataManager: A class with static methods to read metadata from files. """ import os +import collections from PySide6.QtDBus import QDBusConnection, QDBusMessage, QDBus try: import exiv2 @@ -18,6 +19,9 @@ except ImportError: from utils import preserve_mtime from constants import RATING_XATTR_NAME, XATTR_NAME +MetadataResult = collections.namedtuple('MetadataResult', ['tags', 'rating']) +EMPTY_METADATA = MetadataResult([], 0) + def notify_baloo(path): """ @@ -54,9 +58,9 @@ def load_common_metadata(path): raw_rating = XattrManager.get_attribute(path, RATING_XATTR_NAME, "0") try: rating = int(raw_rating) - except ValueError: + except (ValueError, TypeError): rating = 0 - return tags, rating + return MetadataResult(tags, rating) class MetadataManager: diff --git a/pyproject.toml b/pyproject.toml index 8e4f91c..b83943e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "bagheeraview" -version = "0.9.13" +version = "0.9.14" authors = [ { name = "Ignacio Serantes" } ] diff --git a/settings.py b/settings.py index ab71d76..1435949 100644 --- a/settings.py +++ b/settings.py @@ -958,37 +958,41 @@ class SettingsDialog(QDialog): def update_mediapipe_status(self): """Checks for MediaPipe model file and updates UI accordingly.""" - if "mediapipe" not in AVAILABLE_FACE_ENGINES or not self.face_engine_combo: + # --- Rostros --- + if self.face_engine_combo and "mediapipe" in AVAILABLE_FACE_ENGINES: + model_exists = os.path.exists(MEDIAPIPE_FACE_MODEL_PATH) + mediapipe_index = self.face_engine_combo.findText("mediapipe") + + if mediapipe_index != -1: + item = self.face_engine_combo.model().item(mediapipe_index) + if item: + item.setEnabled(model_exists) + if self.download_model_btn: - self.download_model_btn.hide() - return + self.download_model_btn.setVisible(not model_exists) - model_exists = os.path.exists(MEDIAPIPE_FACE_MODEL_PATH) - mediapipe_index = self.face_engine_combo.findText("mediapipe") - - if mediapipe_index != -1: - item = self.face_engine_combo.model().item(mediapipe_index) - if item: - item.setEnabled(model_exists) - - if self.download_model_btn: - self.download_model_btn.setVisible(not model_exists) - - if self.face_engine_combo.currentText() == "mediapipe" and not model_exists: - if self.face_engine_combo.count() > 1: + if self.face_engine_combo.currentText() == "mediapipe" and not model_exists: for i in range(self.face_engine_combo.count()): if self.face_engine_combo.model().item(i).isEnabled(): self.face_engine_combo.setCurrentIndex(i) break + elif self.download_model_btn: + self.download_model_btn.hide() - if "mediapipe" not in AVAILABLE_PET_ENGINES or not self.pet_engine_combo: + # --- Mascotas (Pets) --- + if not AVAILABLE_PET_ENGINES: + self.pet_engine_combo.setEnabled(False) + self.pet_tags_edit.setEnabled(False) + self.pet_history_spin.setEnabled(False) + self.pet_color_btn.setEnabled(False) if self.download_pet_model_btn: self.download_pet_model_btn.hide() - return - - pet_model_exists = os.path.exists(MEDIAPIPE_OBJECT_MODEL_PATH) - if self.download_pet_model_btn: - self.download_pet_model_btn.setVisible(not pet_model_exists) + elif "mediapipe" in AVAILABLE_PET_ENGINES: + pet_model_exists = os.path.exists(MEDIAPIPE_OBJECT_MODEL_PATH) + if self.download_pet_model_btn: + self.download_pet_model_btn.setVisible(not pet_model_exists) + elif self.download_pet_model_btn: + self.download_pet_model_btn.hide() def start_model_download(self): """Starts the background thread to download the MediaPipe model.""" diff --git a/setup.py b/setup.py index da0df5f..e6f5932 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages setup( name="bagheeraview", - version="0.9.13", + version="0.9.14", author="Ignacio Serantes", description="Bagheera Image Viewer - An image viewer for KDE with Baloo in mind", long_description="A fast image viewer built with PySide6, featuring search and "