v0.9.15
This commit is contained in:
106
bagheeraview.py
106
bagheeraview.py
@@ -837,27 +837,70 @@ class ThumbnailSortFilterProxyModel(QSortFilterProxyModel):
|
|||||||
self.group_by_rating = False
|
self.group_by_rating = False
|
||||||
self.collapsed_groups = set()
|
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):
|
def prepare_filter(self):
|
||||||
"""Builds a cache of paths to tags and names for faster filtering."""
|
"""Updates the filter matching set if criteria have changed."""
|
||||||
if self.main_win:
|
if not 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:
|
|
||||||
self._data_cache = {}
|
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):
|
def clear_cache(self):
|
||||||
"""Clears the internal filter data cache."""
|
"""Clears the internal filter data cache."""
|
||||||
self._data_cache = {}
|
self._data_cache = {}
|
||||||
|
self._tag_matching_paths.clear()
|
||||||
|
self._last_tag_criteria = None
|
||||||
|
|
||||||
def add_to_cache(self, path, tags):
|
def add_to_cache(self, path, tags):
|
||||||
"""Adds a single item to the filter cache incrementally."""
|
"""Adds a single item to the filter cache incrementally."""
|
||||||
self._data_cache[path] = (set(tags) if tags else set(),
|
t_set = set(tags) if tags else set()
|
||||||
os.path.basename(path).lower())
|
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):
|
def filterAcceptsRow(self, source_row, source_parent):
|
||||||
"""Determines if a row should be visible based on current filters."""
|
"""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)
|
self.group_by_year or self.group_by_rating)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Use cached data if available, otherwise fallback to model data
|
# 1. Optimization: Check tags first using pre-calculated set (O(1) lookup)
|
||||||
tags, name_lower = self._data_cache.get(
|
if self._tag_filter_active and path not in self._tag_matching_paths:
|
||||||
path, (set(index.data(TAGS_ROLE) or []), os.path.basename(path).lower()))
|
return False
|
||||||
|
|
||||||
# Filter collapsed groups
|
# Filter collapsed groups
|
||||||
if self.main_win and (self.group_by_folder or self.group_by_day or
|
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:
|
if group_name in self.collapsed_groups:
|
||||||
return False
|
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
|
# Filter by filename
|
||||||
if self.name_filter and self.name_filter not in name_lower:
|
if self.name_filter and self.name_filter not in name_lower:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Filter by tags
|
return True
|
||||||
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
|
|
||||||
|
|
||||||
def lessThan(self, left, right):
|
def lessThan(self, left, right):
|
||||||
"""Custom sorting logic for name and date."""
|
"""Custom sorting logic for name and date."""
|
||||||
@@ -3416,6 +3449,10 @@ class MainWindow(QMainWindow):
|
|||||||
self.proxy_model.match_mode = "AND" \
|
self.proxy_model.match_mode = "AND" \
|
||||||
if self.filter_mode_group.buttons()[0].isChecked() else "OR"
|
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
|
# Invalidate the model to force a re-filter
|
||||||
self.proxy_model.invalidate()
|
self.proxy_model.invalidate()
|
||||||
self._visible_paths_cache = None
|
self._visible_paths_cache = None
|
||||||
@@ -4519,7 +4556,7 @@ class MainWindow(QMainWindow):
|
|||||||
self.found_items_data = [item for item in self.found_items_data
|
self.found_items_data = [item for item in self.found_items_data
|
||||||
if item[0] != path]
|
if item[0] != path]
|
||||||
self._known_paths.discard(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
|
self.cache.invalidate_path(path) # Clear from cache
|
||||||
|
|
||||||
# Update any open viewers
|
# Update any open viewers
|
||||||
@@ -4634,12 +4671,9 @@ class MainWindow(QMainWindow):
|
|||||||
break
|
break
|
||||||
|
|
||||||
# Update proxy model cache to avoid stale entries
|
# Update proxy model cache to avoid stale entries
|
||||||
if old_path in self.proxy_model._data_cache:
|
self.proxy_model.remove_from_cache(old_path)
|
||||||
del self.proxy_model._data_cache[old_path]
|
|
||||||
if current_tags is not None:
|
if current_tags is not None:
|
||||||
self.proxy_model._data_cache[new_path] = (
|
self.proxy_model.add_to_cache(new_path, current_tags)
|
||||||
set(current_tags) if current_tags else set(),
|
|
||||||
os.path.basename(new_path).lower())
|
|
||||||
|
|
||||||
# Update the main model
|
# Update the main model
|
||||||
if old_path in self._path_to_model_index:
|
if old_path in self._path_to_model_index:
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ if FORCE_X11:
|
|||||||
# --- CONFIGURATION ---
|
# --- CONFIGURATION ---
|
||||||
PROG_NAME = "Bagheera Image Viewer"
|
PROG_NAME = "Bagheera Image Viewer"
|
||||||
PROG_ID = "bagheeraview"
|
PROG_ID = "bagheeraview"
|
||||||
PROG_VERSION = "0.9.15-dev"
|
PROG_VERSION = "0.9.15"
|
||||||
PROG_AUTHOR = "Ignacio Serantes"
|
PROG_AUTHOR = "Ignacio Serantes"
|
||||||
|
|
||||||
# --- CACHE SETTINGS ---
|
# --- CACHE SETTINGS ---
|
||||||
@@ -47,6 +47,10 @@ except (ImportError, Exception):
|
|||||||
# Fallback to a safe 256MB if psutil is missing or fails
|
# Fallback to a safe 256MB if psutil is missing or fails
|
||||||
CACHE_MAX_RAM_BYTES = 256 * 1024 * 1024
|
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.
|
# Maximum size of the persistent disk cache file.
|
||||||
# 10 GB limit for persistent cache file
|
# 10 GB limit for persistent cache file
|
||||||
DISK_CACHE_MAX_BYTES = 10 * 1024 * 1024 * 1024
|
DISK_CACHE_MAX_BYTES = 10 * 1024 * 1024 * 1024
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ from PySide6.QtGui import QImage, QImageReader, QImageIOHandler
|
|||||||
|
|
||||||
from constants import (
|
from constants import (
|
||||||
APP_CONFIG, CACHE_PATH, CACHE_MAX_SIZE, CACHE_MAX_RAM_BYTES, CONFIG_DIR,
|
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,
|
SCANNER_SETTINGS_DEFAULTS, SEARCH_CMD, THUMBNAIL_SIZES,
|
||||||
UITexts
|
UITexts
|
||||||
)
|
)
|
||||||
@@ -644,12 +644,74 @@ class ThumbnailCache(QObject):
|
|||||||
self._cache_lock.unlock()
|
self._cache_lock.unlock()
|
||||||
|
|
||||||
def _ensure_cache_limit(self):
|
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."""
|
Must be called with a write lock held."""
|
||||||
|
|
||||||
|
# 1. Enforce internal limits using tiered strategy
|
||||||
while len(self._thumbnail_cache) > 0 and (
|
while len(self._thumbnail_cache) > 0 and (
|
||||||
len(self._thumbnail_cache) >= CACHE_MAX_SIZE or
|
len(self._thumbnail_cache) >= CACHE_MAX_SIZE or
|
||||||
self._cache_bytes_size > CACHE_MAX_RAM_BYTES):
|
self._cache_bytes_size > CACHE_MAX_RAM_BYTES):
|
||||||
|
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()
|
||||||
|
|
||||||
|
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))
|
oldest_path = next(iter(self._thumbnail_cache))
|
||||||
cached_sizes = self._thumbnail_cache.pop(oldest_path)
|
cached_sizes = self._thumbnail_cache.pop(oldest_path)
|
||||||
for img, _ in cached_sizes.values():
|
for img, _ in cached_sizes.values():
|
||||||
|
|||||||
Reference in New Issue
Block a user