From ff7c1aa373431cc9ad548e63c3de10b2d58dd698 Mon Sep 17 00:00:00 2001 From: Ignacio Serantes Date: Sat, 28 Mar 2026 07:54:59 +0100 Subject: [PATCH] v0.9.15 --- bagheeraview.py | 106 ++++++++++++++++++++++++++++++++---------------- constants.py | 6 ++- imagescanner.py | 74 ++++++++++++++++++++++++++++++--- 3 files changed, 143 insertions(+), 43 deletions(-) diff --git a/bagheeraview.py b/bagheeraview.py index a024463..9447fa8 100755 --- a/bagheeraview.py +++ b/bagheeraview.py @@ -837,27 +837,70 @@ class ThumbnailSortFilterProxyModel(QSortFilterProxyModel): self.group_by_rating = False self.collapsed_groups = set() + # Optimization: Pre-calculate tag filter results + self._tag_matching_paths = set() + self._tag_filter_active = False + self._last_tag_criteria = None # (frozenset(inc), frozenset(exc), mode) + def prepare_filter(self): - """Builds a cache of paths to tags and names for faster filtering.""" - if self.main_win: - # found_items_data: list of (path, qi, mtime, tags, rating, inode, dev) - # We pre-calculate sets and lowercase names for O(1) access - self._data_cache = { - item[0]: (set(item[3]) if item[3] else set(), - os.path.basename(item[0]).lower()) - for item in self.main_win.found_items_data - } - else: + """Updates the filter matching set if criteria have changed.""" + if not self.main_win: self._data_cache = {} + return + + # Optimization: Pre-calculate which paths match the current tag criteria. + current_criteria = (frozenset(self.include_tags), + frozenset(self.exclude_tags), + self.match_mode) + + if current_criteria != self._last_tag_criteria: + # Criteria changed: Full O(N) re-evaluation required for the whole cache. + self._last_tag_criteria = current_criteria + self._tag_matching_paths.clear() + self._tag_filter_active = bool(self.include_tags or self.exclude_tags) + + if self._tag_filter_active: + for path, (tags, _) in self._data_cache.items(): + if self._matches_tags(tags): + self._tag_matching_paths.add(path) + + def _matches_tags(self, tags): + """Internal helper to check if a set of tags matches current criteria.""" + show = False + if not self.include_tags: + show = True + elif self.match_mode == "AND": + show = self.include_tags.issubset(tags) + else: # OR mode + show = not self.include_tags.isdisjoint(tags) + + if show and self.exclude_tags: + if not self.exclude_tags.isdisjoint(tags): + show = False + return show def clear_cache(self): """Clears the internal filter data cache.""" self._data_cache = {} + self._tag_matching_paths.clear() + self._last_tag_criteria = None def add_to_cache(self, path, tags): """Adds a single item to the filter cache incrementally.""" - self._data_cache[path] = (set(tags) if tags else set(), - os.path.basename(path).lower()) + t_set = set(tags) if tags else set() + self._data_cache[path] = (t_set, os.path.basename(path).lower()) + + # Incremental update of matching paths avoids full cache scan in prepare_filter + if self._tag_filter_active: + if self._matches_tags(t_set): + self._tag_matching_paths.add(path) + else: + self._tag_matching_paths.discard(path) + + def remove_from_cache(self, path): + """Removes an item from the cache and tracking sets.""" + self._data_cache.pop(path, None) + self._tag_matching_paths.discard(path) def filterAcceptsRow(self, source_row, source_parent): """Determines if a row should be visible based on current filters.""" @@ -872,9 +915,9 @@ class ThumbnailSortFilterProxyModel(QSortFilterProxyModel): self.group_by_year or self.group_by_rating) return False - # Use cached data if available, otherwise fallback to model data - tags, name_lower = self._data_cache.get( - path, (set(index.data(TAGS_ROLE) or []), os.path.basename(path).lower())) + # 1. Optimization: Check tags first using pre-calculated set (O(1) lookup) + if self._tag_filter_active and path not in self._tag_matching_paths: + return False # Filter collapsed groups if self.main_win and (self.group_by_folder or self.group_by_day or @@ -886,25 +929,15 @@ class ThumbnailSortFilterProxyModel(QSortFilterProxyModel): if group_name in self.collapsed_groups: return False + # Get cached lowercase name for remaining checks + cached_data = self._data_cache.get(path) + name_lower = cached_data[1] if cached_data else os.path.basename(path).lower() + # Filter by filename if self.name_filter and self.name_filter not in name_lower: return False - # Filter by tags - show = False - if not self.include_tags: - show = True - elif self.match_mode == "AND": - show = self.include_tags.issubset(tags) - else: # OR mode - show = not self.include_tags.isdisjoint(tags) - - # Apply exclusion filter - if show and self.exclude_tags: - if not self.exclude_tags.isdisjoint(tags): - show = False - - return show + return True def lessThan(self, left, right): """Custom sorting logic for name and date.""" @@ -3416,6 +3449,10 @@ class MainWindow(QMainWindow): self.proxy_model.match_mode = "AND" \ if self.filter_mode_group.buttons()[0].isChecked() else "OR" + # Optimization: Warm the filter cache before invalidating. + # This ensures filterAcceptsRow uses O(1) lookups for all items. + self.proxy_model.prepare_filter() + # Invalidate the model to force a re-filter self.proxy_model.invalidate() self._visible_paths_cache = None @@ -4519,7 +4556,7 @@ class MainWindow(QMainWindow): self.found_items_data = [item for item in self.found_items_data if item[0] != path] self._known_paths.discard(path) - self.proxy_model._data_cache.pop(path, None) + self.proxy_model.remove_from_cache(path) self.cache.invalidate_path(path) # Clear from cache # Update any open viewers @@ -4634,12 +4671,9 @@ class MainWindow(QMainWindow): break # Update proxy model cache to avoid stale entries - if old_path in self.proxy_model._data_cache: - del self.proxy_model._data_cache[old_path] + self.proxy_model.remove_from_cache(old_path) if current_tags is not None: - self.proxy_model._data_cache[new_path] = ( - set(current_tags) if current_tags else set(), - os.path.basename(new_path).lower()) + self.proxy_model.add_to_cache(new_path, current_tags) # Update the main model if old_path in self._path_to_model_index: diff --git a/constants.py b/constants.py index f1fba68..3ff63ad 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.15-dev" +PROG_VERSION = "0.9.15" PROG_AUTHOR = "Ignacio Serantes" # --- CACHE SETTINGS --- @@ -47,6 +47,10 @@ except (ImportError, Exception): # Fallback to a safe 256MB if psutil is missing or fails CACHE_MAX_RAM_BYTES = 256 * 1024 * 1024 +# Minimum percentage of free system RAM required. +# Aggressive cache pruning will trigger if available memory falls below this. +MIN_FREE_RAM_PERCENT = 5.0 + # Maximum size of the persistent disk cache file. # 10 GB limit for persistent cache file DISK_CACHE_MAX_BYTES = 10 * 1024 * 1024 * 1024 diff --git a/imagescanner.py b/imagescanner.py index 100fb47..8f1093c 100644 --- a/imagescanner.py +++ b/imagescanner.py @@ -36,7 +36,7 @@ from PySide6.QtGui import QImage, QImageReader, QImageIOHandler from constants import ( APP_CONFIG, CACHE_PATH, CACHE_MAX_SIZE, CACHE_MAX_RAM_BYTES, CONFIG_DIR, - DISK_CACHE_MAX_BYTES, HAVE_BAGHEERASEARCH_LIB, IMAGE_EXTENSIONS, + MIN_FREE_RAM_PERCENT, DISK_CACHE_MAX_BYTES, HAVE_BAGHEERASEARCH_LIB, IMAGE_EXTENSIONS, SCANNER_SETTINGS_DEFAULTS, SEARCH_CMD, THUMBNAIL_SIZES, UITexts ) @@ -644,18 +644,80 @@ class ThumbnailCache(QObject): self._cache_lock.unlock() def _ensure_cache_limit(self): - """Enforces cache size limit by evicting oldest entries. + """Enforces cache size limit and reacts to system memory pressure. Must be called with a write lock held.""" + # 1. Enforce internal limits using tiered strategy while len(self._thumbnail_cache) > 0 and ( len(self._thumbnail_cache) >= CACHE_MAX_SIZE or self._cache_bytes_size > CACHE_MAX_RAM_BYTES): - oldest_path = next(iter(self._thumbnail_cache)) - cached_sizes = self._thumbnail_cache.pop(oldest_path) - for img, _ in cached_sizes.values(): + self._evict_tiered() + + # Check system-wide memory pressure (Low RAM fallback) + try: + import psutil + mem = psutil.virtual_memory() + if (mem.available / mem.total) * 100 < MIN_FREE_RAM_PERCENT: + logger.warning(f"Low system memory detected (< {MIN_FREE_RAM_PERCENT}%). " + "Applying aggressive tiered pruning.") + + # Strategy: first clear ALL cached high-res tiers to free space quickly + # while keeping the 128px grid thumbnails intact. + for tier in [512, 256]: + self._prune_tier(tier) + + # Re-check if pressure relieved + mem = psutil.virtual_memory() + if (mem.available / mem.total) * 100 >= MIN_FREE_RAM_PERCENT: + return + + # If still under pressure, remove oldest 10% of remaining entries + items_to_prune = max(1, len(self._thumbnail_cache) // 10) + for _ in range(items_to_prune): + if not self._thumbnail_cache: + break + self._evict_oldest_path_entirely() + except (ImportError, Exception): + pass + + def _evict_tiered(self): + """ + Removes content from cache targeting larger tiers first from the oldest entries. + Must be called with write lock held. + """ + # We check the oldest 100 entries for large tiers to avoid O(N) scans + # on every call while still being very effective for LRU behavior. + check_limit = 100 + for tier in [512, 256]: + count = 0 + for path, sizes in self._thumbnail_cache.items(): + if tier in sizes: + img, _ = sizes.pop(tier) + if img: + self._cache_bytes_size -= img.sizeInBytes() + return + count += 1 + if count >= check_limit: + break + + self._evict_oldest_path_entirely() + + def _prune_tier(self, tier): + """Removes all thumbnails of a specific tier from cache to free RAM quickly.""" + for path, sizes in self._thumbnail_cache.items(): + if tier in sizes: + img, _ = sizes.pop(tier) if img: self._cache_bytes_size -= img.sizeInBytes() - self._path_to_inode.pop(oldest_path, None) + + def _evict_oldest_path_entirely(self): + """Removes the oldest cache entry completely. Must be called with write lock.""" + oldest_path = next(iter(self._thumbnail_cache)) + cached_sizes = self._thumbnail_cache.pop(oldest_path) + for img, _ in cached_sizes.values(): + if img: + self._cache_bytes_size -= img.sizeInBytes() + self._path_to_inode.pop(oldest_path, None) def _get_tier_for_size(self, requested_size): """Determines the ideal thumbnail tier based on the requested size."""