Compare commits

..

3 Commits

Author SHA1 Message Date
Ignacio Serantes
ff7c1aa373 v0.9.15 2026-03-28 07:54:59 +01:00
Ignacio Serantes
d4f3732aa4 Added watchdog support 2026-03-28 07:13:16 +01:00
Ignacio Serantes
096cee6ca3 Added system language detection 2026-03-26 20:09:46 +01:00
8 changed files with 845 additions and 158 deletions

View File

@@ -4,11 +4,11 @@ BagheeraView is an image viewer specifically designed for the KDE ecosystem. Bui
## 🚀 Key Features
- **Enhanced Baloo Search:** Blazing fast image retrieval using the KDE Baloo indexing framework, featuring advanced capabilities not natively available in Baloo, such as **folder recursive search** and results **text exclusion**.
- **Enhanced Baloo Search:** Blazing fast image retrieval using the KDE Baloo indexing framework, featuring advanced capabilities not natively available in Baloo, such as **folder recursive search** and results **text exclusion**, if BagheeraSearch library is available.
- **Versatile Thumbnail Grid:** A fluid and responsive browser for large collections, offering both **Flat View**, several **Date View** modes, **Rating View** and **Folder View** modes.
- **Face & Pet Detection:** Integrated computer vision to detect faces and pets within your photos and assign names. Object and Landmark tags are supported to but without computer vision detection.
- **Face & Pet Detection:** Integrated computer vision to detect faces and pets within your photos and assign names. Body, Object and Landmark tags are supported too but without computer vision detection.
- **Metadata:** A basic viewer for **EXIF, IPTC, and XMP** data.
@@ -24,8 +24,7 @@ BagheeraView is an image viewer specifically designed for the KDE ecosystem. Bui
- **KDE Integration:** Baloo search and basic management
- **Metadata Handling:** Advanced image header manipulation to store faces, pets, objects and landmarks and support to file extended attributes
- **Metadata Handling:** Advanced image header manipulation to store faces, pets, body, objects and landmarks and support to file extended attributes
## 🌐 Internationalization (i18n)
@@ -36,7 +35,6 @@ BagheeraView is designed for a global audience with localized interface support.
- **Galician**
- **Spanish**
> **Note:** Following internal configuration standards, all source code labels and developer logs are maintained in English for technical consistency.
## ⚙️ Configuration & Persistence
@@ -49,10 +47,9 @@ BagheeraView is built for workflow continuity. The application stores the user's
- **Interface Language:** The application automatically detects the system locale and applies the corresponding translation on startup or user can decide main language.
## 📥 Installation (Development)
Ensure you have at least Python 3.13, the necessary PySide6 dependencies and KDE development headers for Baloo installed on your system. For best integration install at least PySide6 in your system but for better results install all required libraries if they are available in your distro.
Ensure you have at least Python 3.13, the necessary PySide6 dependencies and KDE development headers for Baloo installed on your system. For best integration install at least PySide6 in your system but for better results install all required libraries if they are available in your Linux distribution.
Bash
@@ -73,7 +70,7 @@ BagheeraSearch tool and librery are available at https://git.aynoa.net/ignacio/B
## 📥 Installation (Production with BagheeraSearch)
Ensure you have at least Python 3.13, the necessary PySide6 dependencies and KDE development headers for Baloo installed on your system. For best integration install at least PySide6 in your system but for better results install all required libraries if they are available in your distro.
Ensure you have at least Python 3.13, the necessary PySide6 dependencies and KDE development headers for Baloo installed on your system. For best integration install at least PySide6 in your system but for better results install all required libraries if they are available in your Linux distribution.
Bash
@@ -83,6 +80,7 @@ cd /tmp
git clone https://git.aynoa.net/ignacio/BagheeraSearch.git
git clone https://git.aynoa.net/ignacio/BagheeraView.git
# Create an installation directory
mkdir <a path you like/bagheeraview>
cd <a path you like/bagheeraview>
python -m venv --system-site-packages .venv
@@ -98,7 +96,7 @@ python bagheeraview.py
## 📥 Installation (Production without BagheeraSearch)
Ensure you have at least Python 3.13, the necessary PySide6 dependencies and KDE development headers for Baloo installed on your system. For best integration install at least PySide6 in your system but for better results install all required libraries if they are available in your distro.
Ensure you have at least Python 3.13, the necessary PySide6 dependencies and KDE development headers for Baloo installed on your system. For best integration install at least PySide6 in your system but for better results install all required libraries if they are available in your Linux distribution.
Bash
@@ -107,6 +105,7 @@ Bash
cd /tmp
git clone https://git.aynoa.net/ignacio/BagheeraView.git
# Create an installation directory
mkdir <a path you like/bagheeraview>
cd <a path you like/bagheeraview>
python -m venv --system-site-packages .venv

View File

@@ -14,7 +14,7 @@ Classes:
MainWindow: The main application window containing the thumbnail grid and docks.
"""
__appname__ = "BagheeraView"
__version__ = "0.9.14"
__version__ = "0.9.15"
__author__ = "Ignacio Serantes"
__email__ = "kde@aynoa.net"
__license__ = "LGPL"
@@ -53,7 +53,7 @@ from PySide6.QtDBus import QDBusConnection, QDBusMessage, QDBus
from pathlib import Path
from constants import (
APP_CONFIG, CONFIG_PATH, CURRENT_LANGUAGE, DEFAULT_GLOBAL_SHORTCUTS,
APP_CONFIG, CONFIG_PATH, DEFAULT_GLOBAL_SHORTCUTS, DEFAULT_LANGUAGE,
DEFAULT_VIEWER_SHORTCUTS, GLOBAL_ACTIONS, HISTORY_PATH, ICON_THEME,
ICON_THEME_FALLBACK, IMAGE_MIME_TYPES, LAYOUTS_DIR, FAVORITES_PATH, PROG_AUTHOR,
PROG_NAME, PROG_VERSION, RATING_XATTR_NAME, SCANNER_GENERATE_SIZES,
@@ -78,6 +78,7 @@ from widgets import (
FavoritesWidget
)
from metadatamanager import load_common_metadata
from filesystemwatcher import FileSystemWatcher
class ShortcutHelpDialog(QDialog):
@@ -836,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."""
@@ -871,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
@@ -885,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."""
@@ -1067,6 +1101,11 @@ class MainWindow(QMainWindow):
self.progress_bar.hide()
bot.addWidget(self.progress_bar)
self.fs_watcher_status_lbl = QLabel()
self.fs_watcher_status_lbl.setToolTip(UITexts.FS_WATCHER_TOOLTIP)
self.fs_watcher_status_lbl.hide()
bot.addWidget(self.fs_watcher_status_lbl)
# Timer to hide progress bar with delay
self.hide_progress_timer = QTimer(self)
self.hide_progress_timer.setSingleShot(True)
@@ -1283,6 +1322,30 @@ class MainWindow(QMainWindow):
self.thumbnail_view.verticalScrollBar().valueChanged.connect(
self._on_scroll_interaction)
# Initialize FileSystemWatcher
self.fs_watcher = FileSystemWatcher()
self.fs_watcher.file_created.connect(self.on_fs_file_created)
self.fs_watcher.file_deleted.connect(self.on_fs_file_deleted)
self.fs_watcher.file_modified.connect(self.on_fs_file_modified)
self.fs_watcher.directory_modified.connect(self.on_fs_directory_modified)
self.fs_watcher.file_moved.connect(self.on_fs_file_moved)
self.fs_watcher.directory_moved.connect(self.on_fs_directory_moved)
self.fs_watcher.monitoring_status_changed.connect(
self.on_fs_watcher_status_changed)
# Batching for file creation events
self._fs_created_queue = set()
self._fs_created_timer = QTimer(self)
self._fs_created_timer.setSingleShot(True)
self._fs_created_timer.setInterval(1000) # 1 second debounce
self._fs_created_timer.timeout.connect(self._process_fs_created_batch)
# Debounce timer for full refreshes on directory modifications
self._fs_dir_refresh_timer = QTimer(self)
self._fs_dir_refresh_timer.setSingleShot(True)
self._fs_dir_refresh_timer.setInterval(2500) # 2.5 seconds debounce
self._fs_dir_refresh_timer.timeout.connect(self.refresh_content)
# Initial configuration loading
self.load_config()
self.load_full_history()
@@ -1601,15 +1664,17 @@ class MainWindow(QMainWindow):
# 5. Start scanning all parent directories of the images in the layout
unique_dirs = list({str(Path(p).parent) for p in paths})
for d in unique_dirs:
if d not in paths:
paths.append(d)
self.start_scan([p.strip() for p in paths if p.strip()
and os.path.exists(os.path.expanduser(p.strip()))],
select_paths=select_paths)
if search_text:
self.search_input.setEditText(search_text)
# --- UI and Menu Logic ---
def show_main_menu(self):
"""Displays the main application menu."""
menu = QMenu(self)
@@ -1687,7 +1752,7 @@ class MainWindow(QMainWindow):
for code, name in SUPPORTED_LANGUAGES.items():
action = QAction(name, self, checkable=True)
action.setData(code)
if code == CURRENT_LANGUAGE:
if code == APP_CONFIG.get("language", DEFAULT_LANGUAGE):
action.setChecked(True)
language_menu.addAction(action)
lang_group.addAction(action)
@@ -1811,6 +1876,7 @@ class MainWindow(QMainWindow):
def perform_shutdown(self):
"""Performs cleanup operations before the application closes."""
self.is_cleaning = True
self.fs_watcher.stop()
# 1. Stop all worker threads interacting with the cache
# Signal all threads to stop first
@@ -2053,7 +2119,13 @@ class MainWindow(QMainWindow):
def find_and_select_path(self, path_to_select):
"""Finds an item by its path in the model and selects it using a cache."""
if not path_to_select or path_to_select not in self._path_to_model_index:
if not path_to_select:
return False
# Ensure path is normalized for reliable cache lookup
path_to_select = os.path.abspath(os.path.expanduser(path_to_select))
if path_to_select not in self._path_to_model_index:
return False
persistent_index = self._path_to_model_index[path_to_select]
@@ -2066,6 +2138,11 @@ class MainWindow(QMainWindow):
proxy_index = self.proxy_model.mapFromSource(source_index)
if proxy_index.isValid():
# Optimization: skip if already selected and current to avoid
# unnecessary signals and view updates.
if self.thumbnail_view.currentIndex() == proxy_index and \
self.thumbnail_view.selectionModel().isSelected(proxy_index):
return True
self.set_selection(proxy_index)
return True
@@ -2362,16 +2439,16 @@ class MainWindow(QMainWindow):
inode=new_inode, device_id=new_dev)
# Update model item
for row in range(self.thumbnail_model.rowCount()):
item = self.thumbnail_model.item(row)
if item and item.data(PATH_ROLE) == path:
if path in self._path_to_model_index:
p_idx = self._path_to_model_index[path]
if p_idx.isValid():
item = self.thumbnail_model.itemFromIndex(QModelIndex(p_idx))
item.setIcon(QIcon(QPixmap.fromImage(thumb_img)))
item.setData(new_mtime, MTIME_ROLE)
item.setData(new_inode, INODE_ROLE)
item.setData(new_dev, DEVICE_ROLE)
self._update_internal_data(path, qi=thumb_img, mtime=new_mtime,
inode=new_inode, dev=new_dev)
break
except Exception:
pass
@@ -2412,6 +2489,7 @@ class MainWindow(QMainWindow):
self.proxy_model.clear_cache()
self._model_update_queue.clear()
self._model_update_timer.stop()
self.fs_watcher.clear_paths()
# Stop any pending hide action from previous scan
self.hide_progress_timer.stop()
@@ -2431,6 +2509,12 @@ class MainWindow(QMainWindow):
self.scanner.set_auto_load(True)
self._is_loading = True
self.scanner.images_found.connect(self.collect_found_images)
# Add directories to file system watcher
for p in paths:
if os.path.isdir(os.path.abspath(os.path.expanduser(p))):
self.fs_watcher.add_path(os.path.abspath(os.path.expanduser(p)))
self.scanner.progress_percent.connect(self.update_progress_bar)
self.scanner.progress_msg.connect(self.status_lbl.setText)
self.scanner.more_files_available.connect(self.more_files_available)
@@ -2619,12 +2703,9 @@ class MainWindow(QMainWindow):
# Check for Header match
# target format: ('HEADER', (key, header_text, count))
if isinstance(target, tuple) and len(target) == 2 and target[0] == 'HEADER':
_, (_, header_text, _) = target
# Strict match including group name to ensure roles are updated
target_group_name = target[1][0]
return (item.data(ITEM_TYPE_ROLE) == 'header' and
item.data(GROUP_NAME_ROLE) == target_group_name and
item.data(DIR_ROLE) == header_text)
item.data(GROUP_NAME_ROLE) == target_group_name)
# Check for Thumbnail match
# target format: (path, qi, mtime, tags, rating, inode, dev)
@@ -2635,48 +2716,94 @@ class MainWindow(QMainWindow):
return False
def _get_group_info(self, path, mtime, rating):
"""Calculates the grouping key and display name for a file.
Args:
path (str): File path.
mtime (float): Modification time.
rating (int): Rating value.
Returns:
tuple: (stable_group_key, display_name)
"""
cache_key = (path, mtime, rating)
if cache_key in self._group_info_cache:
return self._group_info_cache[cache_key]
stable_group_key = None
display_name = None
"""Calculates the grouping key and display name for a file with optimized
caching."""
# Determine resolution criteria for shared caching across all files in same
# group
if self.proxy_model.group_by_folder:
stable_group_key = display_name = os.path.dirname(path)
elif self.proxy_model.group_by_day:
stable_group_key = display_name = datetime.fromtimestamp(
mtime).strftime("%Y-%m-%d")
elif self.proxy_model.group_by_week:
dt = datetime.fromtimestamp(mtime)
stable_group_key = dt.strftime("%Y-%W")
display_name = UITexts.GROUP_BY_WEEK_FORMAT.format(
year=dt.strftime("%Y"), week=dt.strftime("%W"))
elif self.proxy_model.group_by_month:
dt = datetime.fromtimestamp(mtime)
stable_group_key = dt.strftime("%Y-%m")
display_name = dt.strftime("%B %Y").capitalize()
elif self.proxy_model.group_by_year:
stable_group_key = display_name = datetime.fromtimestamp(
mtime).strftime("%Y")
crit = os.path.dirname(path)
mode = 'F'
elif self.proxy_model.group_by_rating:
r = rating if rating is not None else 0
stars = (r + 1) // 2
stable_group_key = str(stars)
display_name = UITexts.GROUP_BY_RATING_FORMAT.format(stars=stars)
crit = (rating + 1) // 2 if rating is not None else 0
mode = 'R'
else:
# Date modes: use datetime object parts as hashable criteria
dt = datetime.fromtimestamp(int(mtime) if mtime else 0)
if self.proxy_model.group_by_day:
crit = (dt.year, dt.month, dt.day)
mode = 'D'
elif self.proxy_model.group_by_week:
crit = (dt.year, int(dt.strftime("%W")))
mode = 'W'
elif self.proxy_model.group_by_month:
crit = (dt.year, dt.month)
mode = 'M'
elif self.proxy_model.group_by_year:
crit = dt.year
mode = 'Y'
else:
return (None, None)
self._group_info_cache[cache_key] = (stable_group_key, display_name)
return stable_group_key, display_name
# Shared cache by criteria ensures expensive formatting happens only once per
# group
shared_key = (crit, mode)
if shared_key in self._group_info_cache:
return self._group_info_cache[shared_key]
# Perform actual calculation
if mode == 'F':
res = (crit, crit)
elif mode == 'R':
res = (str(crit), UITexts.GROUP_BY_RATING_FORMAT.format(stars=crit))
else:
if mode == 'D':
sk = dn = dt.strftime("%Y-%m-%d")
elif mode == 'W':
sk = dt.strftime("%Y-%W")
dn = UITexts.GROUP_BY_WEEK_FORMAT.format(
year=dt.strftime("%Y"), week=dt.strftime("%W"))
elif mode == 'M':
sk = dt.strftime("%Y-%m")
dn = dt.strftime("%B %Y").capitalize()
else: # Year
sk = dn = dt.strftime("%Y")
res = (sk, dn)
self._group_info_cache[shared_key] = res
return res
def _find_sorted_index_in_data(self, new_item_data):
"""Finds the correct index to insert an item to keep found_items_data sorted."""
mode = self.sort_combo.currentText()
rev = "" in mode
sort_by_name = "Name" in mode
def get_key(item):
# item structure: (path, qi, mtime, tags, rating, inode, dev)
path, _, mtime, _, _, _, _ = item
if sort_by_name:
return os.path.basename(path).lower()
return mtime if mtime is not None else 0
target_key = get_key(new_item_data)
# Binary search for the insertion point (O(log N))
lo = 0
hi = len(self.found_items_data)
while lo < hi:
mid = (lo + hi) // 2
mid_key = get_key(self.found_items_data[mid])
if not rev:
if mid_key < target_key:
lo = mid + 1
else:
hi = mid
else:
if mid_key > target_key:
lo = mid + 1
else:
hi = mid
return lo
def rebuild_view(self, full_reset=False):
"""
@@ -2773,13 +2900,26 @@ class MainWindow(QMainWindow):
self.thumbnail_model.clear()
self._path_to_model_index.clear()
# Optimize grouped insertion: Decorate-Sort-Group
# 1. Decorate: Calculate group info once per item
# 1. Decorate: Calculate group info once per item with local memoization
decorated_data = []
local_memo = {}
# Cache grouping flags to avoid property lookups in loop
g_folder = self.proxy_model.group_by_folder
for item in self.found_items_data:
# item structure: (path, qi, mtime, tags, rating, inode, dev)
path, _, mtime, _, rating, _, _ = item
# Local cache key: path for folders, (int_mtime, rating) for others
m_key = path if g_folder else (int(mtime) if mtime else 0, rating)
if m_key in local_memo:
stable_key, display_name = local_memo[m_key]
else:
stable_key, display_name = self._get_group_info(
item[0], item[2], item[4])
path, mtime, rating)
local_memo[m_key] = (stable_key, display_name)
# Use empty string for None keys to ensure sortability
sort_key = stable_key if stable_key is not None else ""
decorated_data.append((sort_key, display_name, item))
@@ -2818,13 +2958,82 @@ class MainWindow(QMainWindow):
total_targets = len(target_structure)
new_items_batch = []
# Optimization: Pre-calculate sets for fast lookup of needed items
target_paths_set = {t[0] for t in target_structure
if isinstance(t, tuple) and len(t) >= 5}
target_headers_set = {t[1][0] for t in target_structure
if isinstance(t, tuple)
and len(t) == 2 and t[0] == 'HEADER'}
while target_idx < total_targets:
target = target_structure[target_idx]
current_item = self.thumbnail_model.item(model_idx)
if self._match_item(target, current_item):
# Si es una cabecera, actualizamos el texto por si cambió el
# contador
if isinstance(target, tuple) and target[0] == 'HEADER':
_, (_, header_text, _) = target
if current_item.data(DIR_ROLE) != header_text:
current_item.setData(header_text, DIR_ROLE)
model_idx += 1
target_idx += 1
continue
# 1. Identify and remove stale items (items in model but not in
# target structure)
if current_item:
is_needed = False
if current_item.data(ITEM_TYPE_ROLE) == 'thumbnail':
is_needed = current_item.data(PATH_ROLE) in target_paths_set
else: # header
is_needed = current_item.data(GROUP_NAME_ROLE) \
in target_headers_set
if not is_needed:
path = current_item.data(PATH_ROLE)
if path and path in self._path_to_model_index:
del self._path_to_model_index[path]
self.thumbnail_model.removeRow(model_idx)
# Stay at same model_idx, check next model item against
# same target
continue
# 2. Try to MOVE target from later in the model (reordering
# optimization)
found_model_row = -1
if isinstance(target, tuple) and len(target) >= 5: # Thumbnail
path = target[0]
p_idx = self._path_to_model_index.get(path)
if p_idx and p_idx.isValid() and p_idx.row() > model_idx:
found_model_row = p_idx.row()
elif isinstance(target, tuple) and target[0] == 'HEADER':
target_group_name = target[1][0]
for r in range(model_idx + 1, self.thumbnail_model.rowCount()):
it = self.thumbnail_model.item(r)
if it and it.data(ITEM_TYPE_ROLE) == 'header' and \
it.data(GROUP_NAME_ROLE) == target_group_name:
found_model_row = r
break
if found_model_row != -1:
# Move existing row to current position.
# Persistent indices and selection model will update
# automatically.
self.thumbnail_model.moveRow(QModelIndex(), found_model_row,
QModelIndex(), model_idx)
if isinstance(target, tuple) and target[0] == 'HEADER':
_, (_, header_text, _) = target
moved_item = self.thumbnail_model.item(model_idx)
if moved_item and moved_item.data(DIR_ROLE) != header_text:
moved_item.setData(header_text, DIR_ROLE)
model_idx += 1
target_idx += 1
continue
# 3. Target is truly NEW - use batch insertion
else:
# Prepare new item
if isinstance(target, tuple) and len(target) == 2 \
@@ -2851,12 +3060,21 @@ class MainWindow(QMainWindow):
# recalculations
while target_idx < total_targets:
next_target = target_structure[target_idx]
# Check if next_target matches current model position
# (re-sync)
if self._match_item(
next_target, self.thumbnail_model.item(model_idx)):
break
# Check if next_target is a MOVE (exists elsewhere)
if isinstance(next_target, tuple) and len(next_target) >= 5:
n_p_idx = self._path_to_model_index.get(next_target[0])
if n_p_idx and n_p_idx.isValid() \
and n_p_idx.row() > model_idx:
break
# (simplified lookahead: headers always break batch)
# If not matching, it's another new item to insert
if isinstance(next_target, tuple) \
and len(next_target) == 2 and next_target[0] == 'HEADER':
@@ -3231,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
@@ -3467,22 +3689,23 @@ class MainWindow(QMainWindow):
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:
# Use cache for O(1) lookup in the source model
path = os.path.abspath(os.path.expanduser(path))
if path in self._path_to_model_index:
p_idx = self._path_to_model_index[path]
if p_idx.isValid():
item = self.thumbnail_model.itemFromIndex(QModelIndex(p_idx))
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)}"
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)
source_idx = QModelIndex(p_idx)
self.thumbnail_model.dataChanged.emit(
source_idx, source_idx, [TAGS_ROLE, RATING_ROLE])
@@ -3491,7 +3714,6 @@ class MainWindow(QMainWindow):
# Update proxy filter cache to prevent stale filtering
self.proxy_model.add_to_cache(path, tags)
break
if self.main_dock.isVisible():
self.on_tags_tab_changed(self.tags_tabs.currentIndex())
@@ -3522,14 +3744,14 @@ class MainWindow(QMainWindow):
self.thumbnail_view.setGridSize(QSize())
else:
self.thumbnail_view.setGridSize(self.delegate.sizeHint(None, None))
self.rebuild_view(full_reset=True)
self.rebuild_view()
self.save_config()
self.setFocus()
def on_sort_changed(self):
"""Callback for when the sort order dropdown changes."""
self.rebuild_view(full_reset=True)
self.rebuild_view()
self.save_config()
if hasattr(self, 'history_tab'):
self.history_tab.refresh_list()
@@ -4268,8 +4490,169 @@ class MainWindow(QMainWindow):
self.cache.clear_cache()
self.status_lbl.setText(UITexts.CACHE_CLEARED)
def on_fs_file_created(self, path):
"""Handles a new file being created in a monitored directory."""
# Add to batch queue and (re)start the debounce timer
self._fs_created_queue.add(path)
self._fs_created_timer.start()
def _process_fs_created_batch(self):
"""Processes all accumulated file creation events at once."""
paths = list(self._fs_created_queue)
self._fs_created_queue.clear()
valid_new_items = []
for p in paths:
p = os.path.abspath(p)
if os.path.exists(p) and p not in self._known_paths:
if os.path.splitext(p)[1].lower() in constants.IMAGE_EXTENSIONS:
valid_new_items.append(p)
if not valid_new_items:
return
# If the batch is very large, a full scan is more efficient than individual
# stats
if len(valid_new_items) > 50:
self.refresh_content()
return
# For smaller batches, process metadata and update model
for path in valid_new_items:
try:
res = load_common_metadata(path)
stat_res = os.stat(path)
mtime = stat_res.st_mtime
inode = stat_res.st_ino
dev = stat_res.st_dev
# tuple: (path, qi, mtime, tags, rating, inode, dev)
new_item_data = (path, None, mtime, res.tags, res.rating, inode, dev)
self.found_items_data.append(new_item_data)
self._known_paths.add(path)
self.proxy_model.add_to_cache(path, res.tags)
except Exception:
continue
# Trigger an incremental rebuild of the view
self.rebuild_view()
# Start background generation for the new items
self.generate_missing_thumbnails(self._current_thumb_tier)
if len(valid_new_items) == 1:
msg = f"New file detected: {os.path.basename(valid_new_items[0])}"
else:
msg = f"Detected {len(valid_new_items)} new files"
self.status_lbl.setText(msg)
def on_fs_file_deleted(self, path):
"""Handles a file being deleted from a monitored directory."""
path = os.path.abspath(path)
if path not in self._known_paths:
return # Not a file we're tracking
# Remove from internal data structures
self.found_items_data = [item for item in self.found_items_data
if item[0] != path]
self._known_paths.discard(path)
self.proxy_model.remove_from_cache(path)
self.cache.invalidate_path(path) # Clear from cache
# Update any open viewers
for w in QApplication.topLevelWidgets():
if isinstance(w, ImageViewer):
if path in w.controller.image_list:
try:
deleted_idx = w.controller.image_list.index(path)
new_list = list(w.controller.image_list)
new_list.remove(path)
w.refresh_after_delete(new_list, deleted_idx)
except (ValueError, RuntimeError):
pass
# Invalidate caches that might hold references to the deleted path
self._visible_paths_cache = None
keys_to_remove = [k for k in self._group_info_cache if k[0] == path]
for k in keys_to_remove:
del self._group_info_cache[k]
self.rebuild_view()
self.status_lbl.setText(f"File deleted: {os.path.basename(path)}")
def on_fs_file_moved(self, old_path, new_path):
"""Handles a file being renamed or moved."""
old_path = os.path.abspath(old_path)
new_path = os.path.abspath(new_path)
is_old_img = os.path.splitext(old_path)[1].lower() in constants.IMAGE_EXTENSIONS
is_new_img = os.path.splitext(new_path)[1].lower() in constants.IMAGE_EXTENSIONS
if is_old_img and is_new_img:
if old_path in self._known_paths:
self.propagate_rename(old_path, new_path)
else:
self.on_fs_file_created(new_path)
elif is_old_img:
# Moved out or renamed to non-image
self.on_fs_file_deleted(old_path)
elif is_new_img:
# Moved in from outside
self.on_fs_file_created(new_path)
def on_fs_file_modified(self, path):
"""Handles a file being modified in a monitored directory."""
path = os.path.abspath(path)
if path not in self._known_paths:
return # Not a file we're tracking
# Invalidate cache and trigger a refresh of its metadata and thumbnail
self.cache.invalidate_path(path)
# Re-read metadata and thumbnail
res = load_common_metadata(path)
mtime = os.path.getmtime(path)
stat_res = os.stat(path)
inode = stat_res.st_ino
dev = stat_res.st_dev
# Update internal data and model
self._update_internal_data(path, mtime=mtime, tags=res.tags, rating=res.rating,
inode=inode, dev=dev)
self.proxy_model.add_to_cache(path, res.tags)
self.rebuild_view()
self.status_lbl.setText(f"File modified: {os.path.basename(path)}")
def on_fs_watcher_status_changed(self, is_monitoring):
"""Updates the UI indicator for the FileSystemWatcher."""
if is_monitoring:
self.fs_watcher_status_lbl.setPixmap(
QIcon.fromTheme("folder-open").pixmap(16, 16))
self.fs_watcher_status_lbl.show()
else:
self.fs_watcher_status_lbl.hide()
def on_fs_directory_modified(self, path):
"""Handles a directory being modified (e.g., new subfolder, mass changes)."""
path = os.path.abspath(path)
# Trigger a debounced full refresh. This is useful for syncing large
# external changes (bulk operations, directory deletions) that are
# more robustly handled by a full scan than incremental updates.
if not self._is_loading and not self.is_cleaning:
self._fs_dir_refresh_timer.start()
def on_fs_directory_moved(self, old_path, new_path):
"""Handles a directory being renamed or moved."""
# For directory moves, a full refresh is the most reliable way to sync
# since all child paths have changed and individual file move signals
# might not be emitted for every item inside.
self.on_fs_directory_modified(new_path)
def propagate_rename(self, old_path, new_path, source_viewer=None):
"""Propagates a file rename across the application."""
old_path = os.path.abspath(old_path)
new_path = os.path.abspath(new_path)
self._visible_paths_cache = None
# Update found_items_data to ensure consistency on future rebuilds
current_tags = None
@@ -4288,23 +4671,21 @@ 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
for row in range(self.thumbnail_model.rowCount()):
item = self.thumbnail_model.item(row)
if item and item.data(PATH_ROLE) == old_path:
if old_path in self._path_to_model_index:
p_idx = self._path_to_model_index.pop(old_path)
if p_idx.isValid():
item = self.thumbnail_model.itemFromIndex(QModelIndex(p_idx))
if item:
item.setData(new_path, PATH_ROLE)
item.setText(os.path.basename(new_path))
# No need to update the icon, it's the same image data
source_index = self.thumbnail_model.indexFromItem(item)
self._path_to_model_index[new_path] = p_idx
source_index = QModelIndex(p_idx)
self.thumbnail_model.dataChanged.emit(source_index, source_index)
break
# Update the cache entry
self.cache.rename_entry(old_path, new_path)
@@ -4429,7 +4810,7 @@ class MainWindow(QMainWindow):
for code, name in SUPPORTED_LANGUAGES.items():
action = QAction(name, self, checkable=True)
action.setData(code)
if code == CURRENT_LANGUAGE:
if code == APP_CONFIG.get("language", DEFAULT_LANGUAGE):
action.setChecked(True)
language_menu.addAction(action)
lang_group.addAction(action)
@@ -4438,7 +4819,7 @@ class MainWindow(QMainWindow):
"""Handles language change, saves config, and prompts for restart."""
new_lang = action.data()
# Only save and show message if the language actually changed
if new_lang != APP_CONFIG.get("language", CURRENT_LANGUAGE):
if new_lang != APP_CONFIG.get("language", DEFAULT_LANGUAGE):
APP_CONFIG["language"] = new_lang
constants.save_app_config()
@@ -4457,7 +4838,7 @@ def main():
app = QApplication(sys.argv)
# Increase QPixmapCache limit (default is usually small, ~10MB) to ~100MB
QPixmapCache.setCacheLimit(102400)
QPixmapCache.setCacheLimit(104857600) # Old value: 102400
thread_pool_manager = ThreadPoolManager()
cache = ThumbnailCache()

View File

@@ -29,12 +29,28 @@ if FORCE_X11:
# --- CONFIGURATION ---
PROG_NAME = "Bagheera Image Viewer"
PROG_ID = "bagheeraview"
PROG_VERSION = "0.9.14"
PROG_VERSION = "0.9.15"
PROG_AUTHOR = "Ignacio Serantes"
# --- CACHE SETTINGS ---
# Maximum number of thumbnails to keep in the in-memory cache.
CACHE_MAX_SIZE = 20000
# Maximum number of paths to track in the in-memory cache.
CACHE_MAX_SIZE = 10000
# Dynamic RAM limit for thumbnails to avoid swapping on low-end systems.
try:
import psutil
_total_ram_bytes = psutil.virtual_memory().total
# Use 10% of system RAM, clamped between 128MB and 512MB
CACHE_MAX_RAM_BYTES = int(max(128 * 1024 * 1024,
min(512 * 1024 * 1024, _total_ram_bytes * 0.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
@@ -341,23 +357,16 @@ DEFAULT_VIEWER_SHORTCUTS = {
# Supported languages
SUPPORTED_LANGUAGES = {
"system": "System",
"en": "English",
"es": "Español",
"gl": "Galego"
}
# Default language
DEFAULT_LANGUAGE = "en"
# Determine current language:
# 1. Environment variable (for debugging/override)
# 2. Saved configuration
# 3. Default
CURRENT_LANGUAGE = os.getenv("BAGHEERA_LANG") or \
APP_CONFIG.get("language", DEFAULT_LANGUAGE)
# Ensure the loaded language is supported, otherwise fallback to default
if CURRENT_LANGUAGE not in SUPPORTED_LANGUAGES:
CURRENT_LANGUAGE = DEFAULT_LANGUAGE
# Default language for configuration
DEFAULT_LANGUAGE = "system"
# Fallback language for translations
FALLBACK_LANGUAGE = "en"
_UI_TEXTS = {
"en": {
@@ -821,6 +830,7 @@ _UI_TEXTS = {
"ERROR_MOVE_FILE": "Could not move file: {}",
"ERROR_COPY_FILE": "Could not copy file: {}",
"MOVED_TO": "Moved to {}",
"FS_WATCHER_TOOLTIP": "File System Watcher (monitoring active directories)",
"COPIED_TO": "Copied to {}",
"ERROR_ROTATE_IMAGE": "Could not rotate image: {}",
},
@@ -1292,6 +1302,8 @@ _UI_TEXTS = {
"ERROR_MOVE_FILE": "No se pudo mover el archivo: {}",
"ERROR_COPY_FILE": "No se pudo copiar el archivo: {}",
"MOVED_TO": "Movido a {}",
"FS_WATCHER_TOOLTIP": "Monitor de Sistema de Archivos (monitoreando "
"directorios activos)",
"COPIED_TO": "Copiado a {}",
"ERROR_ROTATE_IMAGE": "No se pudo girar la imagen: {}",
},
@@ -1764,12 +1776,34 @@ _UI_TEXTS = {
"ERROR_MOVE_FILE": "Non se puido mover o ficheiro: {}",
"ERROR_COPY_FILE": "Non se puido copiar o ficheiro: {}",
"MOVED_TO": "Movido a {}",
"FS_WATCHER_TOOLTIP": "Monitor do Sistema de Ficheiros (monitoreando "
"directorios activos)",
"COPIED_TO": "Copiado a {}",
"ERROR_ROTATE_IMAGE": "Non se puido xirar a imaxe: {}",
}
}
# Determine which language to use for UI strings
def _get_current_language():
lang = os.getenv("BAGHEERA_LANG") or APP_CONFIG.get("language", DEFAULT_LANGUAGE)
if lang == "system":
sys_lang = os.getenv("LANG")
if sys_lang:
# LANG is usually something like 'en_US.UTF-8'
lang = sys_lang[0:2].lower()
else:
lang = FALLBACK_LANGUAGE
# If the resolved language is not supported by our translation dictionaries,
# fallback to English.
return lang if lang in _UI_TEXTS else FALLBACK_LANGUAGE
CURRENT_LANGUAGE = _get_current_language()
class _UITextsProxy:
"""
A proxy class to access UI strings from the _UI_TEXTS dictionary.
@@ -1781,12 +1815,12 @@ class _UITextsProxy:
"""
def __getattr__(self, name):
# Get the dictionary for the current language, or fallback to the default.
lang_texts = _UI_TEXTS.get(CURRENT_LANGUAGE, _UI_TEXTS[DEFAULT_LANGUAGE])
lang_texts = _UI_TEXTS.get(CURRENT_LANGUAGE, _UI_TEXTS[FALLBACK_LANGUAGE])
# Get the specific string. If not found in the current language,
# try the default language.
text = lang_texts.get(name)
if text is None:
default_texts = _UI_TEXTS[DEFAULT_LANGUAGE]
default_texts = _UI_TEXTS[FALLBACK_LANGUAGE]
# Return a placeholder if not found anywhere
text = default_texts.get(name, f"_{name}_")
return text

203
filesystemwatcher.py Normal file
View File

@@ -0,0 +1,203 @@
import os
try:
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
HAVE_WATCHDOG = True
except ImportError:
HAVE_WATCHDOG = False
from PySide6.QtCore import QObject, Signal, QTimer
from constants import IMAGE_EXTENSIONS
class FileSystemWatcher(QObject):
"""
Monitors file system events (created, deleted, modified) for specified directories.
Emits signals to notify the main application thread of changes.
"""
file_created = Signal(str)
file_deleted = Signal(str)
file_modified = Signal(str)
_file_modified_from_handler = Signal(str) # Internal signal from handler thread
file_moved = Signal(str, str)
monitoring_status_changed = Signal(bool) # Nuevo: Señal para el estado de monitoreo
directory_moved = Signal(str, str)
directory_modified = Signal(str) # For changes that might not be specific files
_modified_events_queue = {} # {path: QTimer}
def __init__(self, parent=None):
super().__init__(parent)
self._watched_directories = set()
if HAVE_WATCHDOG:
self._observer = Observer()
self._event_handler = self._Handler(self)
self._observer.start()
else:
self._observer = None # Keep observer as None if watchdog is not available
# Debounce timer for modified events to avoid multiple signals for a single save
self._debounce_interval = 500 # milliseconds
# Connect the internal signal to the debouncing slot
if HAVE_WATCHDOG:
self._file_modified_from_handler.connect(self._on_file_modified_debounced)
def _on_file_modified_debounced(self, path):
"""Slot to handle modified events from the watchdog thread, debounced in the
main thread."""
# Debounce timer for modified events to avoid multiple signals for a single save
if path in self._modified_events_queue:
self._modified_events_queue[path].stop()
else:
# Ensure timer lives in the main thread (parent is self)
timer = QTimer(self)
timer.setSingleShot(True)
timer.setInterval(self._debounce_interval)
timer.timeout.connect(lambda p=path: self._emit_modified_after_debounce(p))
self._modified_events_queue[path] = timer
self._modified_events_queue[path].start()
def _emit_modified_after_debounce(self, path):
"""Emits the file_modified signal after the debounce period."""
self.file_modified.emit(path)
if path in self._modified_events_queue:
# Safely delete the QTimer object when done
self._modified_events_queue[path].deleteLater()
del self._modified_events_queue[path]
def add_path(self, path):
"""Adds a directory to be monitored."""
if not HAVE_WATCHDOG or self._observer is None:
return
# Normalize and expand path to ensure consistent comparison
abs_path = os.path.normpath(os.path.abspath(os.path.expanduser(path)))
# 1. Check if path is already covered by an existing watch (exact or parent)
for watched in self._watched_directories:
if abs_path == watched:
return
parent_prefix = watched if watched.endswith(os.sep) else watched + os.sep
if abs_path.startswith(parent_prefix):
return # Path is a subdirectory of an already watched directory
old_monitoring_state = bool(self._watched_directories)
# 2. Check if this new path covers existing watches (is a parent of them)
# If so, consolidate them into this single parent watch
child_prefix = abs_path if abs_path.endswith(os.sep) else abs_path + os.sep
covered_children = [w for w in self._watched_directories
if w.startswith(child_prefix)]
try:
if covered_children:
self._observer.unschedule_all()
for child in covered_children:
self._watched_directories.remove(child)
self._watched_directories.add(abs_path)
for p in self._watched_directories:
self._observer.schedule(self._event_handler, p, recursive=True)
print(f"Consolidated monitoring at parent: {abs_path}")
else:
self._observer.schedule(self._event_handler, abs_path, recursive=True)
self._watched_directories.add(abs_path)
print(f"Monitoring: {abs_path}")
except Exception as e:
print(f"Error scheduling watchdog for {abs_path}: {e}")
return
if not old_monitoring_state and self._watched_directories:
self.monitoring_status_changed.emit(True)
def remove_path(self, path):
"""Removes a directory from monitoring."""
if not HAVE_WATCHDOG or self._observer is None:
return
abs_path = os.path.normpath(os.path.abspath(os.path.expanduser(path)))
if abs_path in self._watched_directories:
old_monitoring_state = bool(self._watched_directories)
self._observer.unschedule_all() # Simpler to unschedule all and re-add
self._watched_directories.remove(abs_path)
for p in list(self._watched_directories): # Iterate over a copy
self._observer.schedule(self._event_handler, p, recursive=True)
print(f"Stopped monitoring: {abs_path}")
if HAVE_WATCHDOG and old_monitoring_state and not self._watched_directories:
self.monitoring_status_changed.emit(False)
def clear_paths(self):
"""Clears all monitored paths."""
if not HAVE_WATCHDOG or not self._observer:
return
old_monitoring_state = bool(self._watched_directories)
self._observer.unschedule_all()
self._watched_directories.clear()
print("Cleared all monitored paths.")
if old_monitoring_state:
self.monitoring_status_changed.emit(False)
def stop(self):
"""Stops the file system observer."""
if HAVE_WATCHDOG and self._observer:
self._observer.stop()
self._observer.join()
for timer in self._modified_events_queue.values():
timer.stop()
if HAVE_WATCHDOG:
print("FileSystemWatcher stopped.")
if HAVE_WATCHDOG:
class _Handler(FileSystemEventHandler):
# Signal to communicate to main thread
file_modified_from_thread = Signal(str)
"""Custom event handler for watchdog events."""
def __init__(self, watcher):
super().__init__()
self.watcher = watcher
def on_created(self, event):
if event.is_directory:
self.watcher.directory_modified.emit(event.src_path)
return
if self._is_image_file(event.src_path):
self.watcher.file_created.emit(event.src_path)
def on_deleted(self, event):
if event.is_directory:
self.watcher.directory_modified.emit(event.src_path)
return
if self._is_image_file(event.src_path):
self.watcher.file_deleted.emit(event.src_path)
def on_moved(self, event):
if event.is_directory:
self.watcher.directory_moved.emit(event.src_path, event.dest_path)
self.watcher.directory_modified.emit(event.src_path)
self.watcher.directory_modified.emit(event.dest_path)
return
self.watcher.file_moved.emit(event.src_path, event.dest_path)
def on_closed(self, event):
if event.is_directory:
self.watcher.directory_modified.emit(event.src_path)
return
if self._is_image_file(event.src_path):
self.watcher.file_modified.emit(event.src_path)
def on_modified(self, event):
if event.is_directory:
self.watcher.directory_modified.emit(event.src_path)
return
if self._is_image_file(event.src_path):
self.watcher._file_modified_from_handler.emit(event.src_path)
def _emit_modified(self, path):
self.watcher.file_modified.emit(path)
if path in self.watcher._modified_events_queue:
del self.watcher._modified_events_queue[path]
def _is_image_file(self, path):
return os.path.splitext(path)[1].lower() in IMAGE_EXTENSIONS

View File

@@ -35,8 +35,8 @@ from PySide6.QtCore import (
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,
APP_CONFIG, CACHE_PATH, CACHE_MAX_SIZE, CACHE_MAX_RAM_BYTES, CONFIG_DIR,
MIN_FREE_RAM_PERCENT, DISK_CACHE_MAX_BYTES, HAVE_BAGHEERASEARCH_LIB, IMAGE_EXTENSIONS,
SCANNER_SETTINGS_DEFAULTS, SEARCH_CMD, THUMBNAIL_SIZES,
UITexts
)
@@ -644,14 +644,74 @@ 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."""
# Safety limit: 512MB for thumbnails in RAM to prevent system freeze
MAX_RAM_BYTES = 512 * 1024 * 1024
# 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 > 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))
cached_sizes = self._thumbnail_cache.pop(oldest_path)
for img, _ in cached_sizes.values():
@@ -1752,6 +1812,10 @@ class ImageScanner(QThread):
batch.append(r.result)
self.count += 1
images_loaded += 1
# Emit progress every time an image is loaded
if len(self.all_files) > 0:
percent = int((self.count / len(self.all_files)) * 100)
self.progress_percent.emit(percent)
# Clean up runnables
runnables.clear()
@@ -1796,12 +1860,11 @@ class ImageScanner(QThread):
"scan_batch_size"]))
return
if self.count % 10 == 0: # Update progress less frequently
self.progress_msg.emit(
UITexts.LOADING_SCAN.format(self.count, len(self.all_files)))
if len(self.all_files) > 0:
percent = int((self.count / len(self.all_files)) * 100)
self.progress_percent.emit(percent)
# Emit progress message less frequently, e.g., every 50 images or at batch
# end
if self.count % 50 == 0 or images_loaded >= to_load:
self.progress_msg.emit(UITexts.LOADING_SCAN.format(
self.count, len(self.all_files)))
self.index = len(self.all_files)
if batch:

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "bagheeraview"
version = "0.9.14"
version = "0.9.15"
authors = [
{ name = "Ignacio Serantes" }
]
@@ -23,6 +23,8 @@ dependencies = [
"PySide6",
"lmdb",
"exiv2",
"psutil",
"watchdog",
"mediapipe",
"face_recognition",
"face_recognition_models",

View File

@@ -1,6 +1,8 @@
PySide6
lmdb
exiv2
psutil
watchdog
mediapipe
face_recognition
face_recognition_models

View File

@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
setup(
name="bagheeraview",
version="0.9.14",
version="0.9.15",
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 "
@@ -14,6 +14,8 @@ setup(
"PySide6",
"lmdb",
"exiv2",
"psutil",
"watchdog",
"mediapipe",
"face_recognition",
"face_recognition_models",
@@ -33,6 +35,7 @@ setup(
"imagescanner",
"imageviewer",
"imagecontroller",
"filesystemwatcher",
"metadatamanager",
"propertiesdialog",
"thumbnailwidget",