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 ## 🚀 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. - **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. - **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 - **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) ## 🌐 Internationalization (i18n)
@@ -36,7 +35,6 @@ BagheeraView is designed for a global audience with localized interface support.
- **Galician** - **Galician**
- **Spanish** - **Spanish**
> **Note:** Following internal configuration standards, all source code labels and developer logs are maintained in English for technical consistency. > **Note:** Following internal configuration standards, all source code labels and developer logs are maintained in English for technical consistency.
## ⚙️ Configuration & Persistence ## ⚙️ 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. - **Interface Language:** The application automatically detects the system locale and applies the corresponding translation on startup or user can decide main language.
## 📥 Installation (Development) ## 📥 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 Bash
@@ -73,7 +70,7 @@ BagheeraSearch tool and librery are available at https://git.aynoa.net/ignacio/B
## 📥 Installation (Production with BagheeraSearch) ## 📥 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 Bash
@@ -83,6 +80,7 @@ cd /tmp
git clone https://git.aynoa.net/ignacio/BagheeraSearch.git git clone https://git.aynoa.net/ignacio/BagheeraSearch.git
git clone https://git.aynoa.net/ignacio/BagheeraView.git git clone https://git.aynoa.net/ignacio/BagheeraView.git
# Create an installation directory
mkdir <a path you like/bagheeraview> mkdir <a path you like/bagheeraview>
cd <a path you like/bagheeraview> cd <a path you like/bagheeraview>
python -m venv --system-site-packages .venv python -m venv --system-site-packages .venv
@@ -98,7 +96,7 @@ python bagheeraview.py
## 📥 Installation (Production without BagheeraSearch) ## 📥 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 Bash
@@ -107,6 +105,7 @@ Bash
cd /tmp cd /tmp
git clone https://git.aynoa.net/ignacio/BagheeraView.git git clone https://git.aynoa.net/ignacio/BagheeraView.git
# Create an installation directory
mkdir <a path you like/bagheeraview> mkdir <a path you like/bagheeraview>
cd <a path you like/bagheeraview> cd <a path you like/bagheeraview>
python -m venv --system-site-packages .venv 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. MainWindow: The main application window containing the thumbnail grid and docks.
""" """
__appname__ = "BagheeraView" __appname__ = "BagheeraView"
__version__ = "0.9.14" __version__ = "0.9.15"
__author__ = "Ignacio Serantes" __author__ = "Ignacio Serantes"
__email__ = "kde@aynoa.net" __email__ = "kde@aynoa.net"
__license__ = "LGPL" __license__ = "LGPL"
@@ -53,7 +53,7 @@ from PySide6.QtDBus import QDBusConnection, QDBusMessage, QDBus
from pathlib import Path from pathlib import Path
from constants import ( 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, DEFAULT_VIEWER_SHORTCUTS, GLOBAL_ACTIONS, HISTORY_PATH, ICON_THEME,
ICON_THEME_FALLBACK, IMAGE_MIME_TYPES, LAYOUTS_DIR, FAVORITES_PATH, PROG_AUTHOR, ICON_THEME_FALLBACK, IMAGE_MIME_TYPES, LAYOUTS_DIR, FAVORITES_PATH, PROG_AUTHOR,
PROG_NAME, PROG_VERSION, RATING_XATTR_NAME, SCANNER_GENERATE_SIZES, PROG_NAME, PROG_VERSION, RATING_XATTR_NAME, SCANNER_GENERATE_SIZES,
@@ -78,6 +78,7 @@ from widgets import (
FavoritesWidget FavoritesWidget
) )
from metadatamanager import load_common_metadata from metadatamanager import load_common_metadata
from filesystemwatcher import FileSystemWatcher
class ShortcutHelpDialog(QDialog): class ShortcutHelpDialog(QDialog):
@@ -836,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."""
@@ -871,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
@@ -885,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."""
@@ -1067,6 +1101,11 @@ class MainWindow(QMainWindow):
self.progress_bar.hide() self.progress_bar.hide()
bot.addWidget(self.progress_bar) 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 # Timer to hide progress bar with delay
self.hide_progress_timer = QTimer(self) self.hide_progress_timer = QTimer(self)
self.hide_progress_timer.setSingleShot(True) self.hide_progress_timer.setSingleShot(True)
@@ -1283,6 +1322,30 @@ class MainWindow(QMainWindow):
self.thumbnail_view.verticalScrollBar().valueChanged.connect( self.thumbnail_view.verticalScrollBar().valueChanged.connect(
self._on_scroll_interaction) 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 # Initial configuration loading
self.load_config() self.load_config()
self.load_full_history() self.load_full_history()
@@ -1601,15 +1664,17 @@ class MainWindow(QMainWindow):
# 5. Start scanning all parent directories of the images in the layout # 5. Start scanning all parent directories of the images in the layout
unique_dirs = list({str(Path(p).parent) for p in paths}) unique_dirs = list({str(Path(p).parent) for p in paths})
for d in unique_dirs: for d in unique_dirs:
paths.append(d) if d not in paths:
paths.append(d)
self.start_scan([p.strip() for p in paths if p.strip() self.start_scan([p.strip() for p in paths if p.strip()
and os.path.exists(os.path.expanduser(p.strip()))], and os.path.exists(os.path.expanduser(p.strip()))],
select_paths=select_paths) select_paths=select_paths)
if search_text: if search_text:
self.search_input.setEditText(search_text) self.search_input.setEditText(search_text)
# --- UI and Menu Logic --- # --- UI and Menu Logic ---
def show_main_menu(self): def show_main_menu(self):
"""Displays the main application menu.""" """Displays the main application menu."""
menu = QMenu(self) menu = QMenu(self)
@@ -1687,7 +1752,7 @@ class MainWindow(QMainWindow):
for code, name in SUPPORTED_LANGUAGES.items(): for code, name in SUPPORTED_LANGUAGES.items():
action = QAction(name, self, checkable=True) action = QAction(name, self, checkable=True)
action.setData(code) action.setData(code)
if code == CURRENT_LANGUAGE: if code == APP_CONFIG.get("language", DEFAULT_LANGUAGE):
action.setChecked(True) action.setChecked(True)
language_menu.addAction(action) language_menu.addAction(action)
lang_group.addAction(action) lang_group.addAction(action)
@@ -1811,6 +1876,7 @@ class MainWindow(QMainWindow):
def perform_shutdown(self): def perform_shutdown(self):
"""Performs cleanup operations before the application closes.""" """Performs cleanup operations before the application closes."""
self.is_cleaning = True self.is_cleaning = True
self.fs_watcher.stop()
# 1. Stop all worker threads interacting with the cache # 1. Stop all worker threads interacting with the cache
# Signal all threads to stop first # Signal all threads to stop first
@@ -2053,7 +2119,13 @@ class MainWindow(QMainWindow):
def find_and_select_path(self, path_to_select): def find_and_select_path(self, path_to_select):
"""Finds an item by its path in the model and selects it using a cache.""" """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 return False
persistent_index = self._path_to_model_index[path_to_select] 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) proxy_index = self.proxy_model.mapFromSource(source_index)
if proxy_index.isValid(): 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) self.set_selection(proxy_index)
return True return True
@@ -2362,16 +2439,16 @@ class MainWindow(QMainWindow):
inode=new_inode, device_id=new_dev) inode=new_inode, device_id=new_dev)
# Update model item # Update model item
for row in range(self.thumbnail_model.rowCount()): if path in self._path_to_model_index:
item = self.thumbnail_model.item(row) p_idx = self._path_to_model_index[path]
if item and item.data(PATH_ROLE) == path: if p_idx.isValid():
item = self.thumbnail_model.itemFromIndex(QModelIndex(p_idx))
item.setIcon(QIcon(QPixmap.fromImage(thumb_img))) item.setIcon(QIcon(QPixmap.fromImage(thumb_img)))
item.setData(new_mtime, MTIME_ROLE) item.setData(new_mtime, MTIME_ROLE)
item.setData(new_inode, INODE_ROLE) item.setData(new_inode, INODE_ROLE)
item.setData(new_dev, DEVICE_ROLE) item.setData(new_dev, DEVICE_ROLE)
self._update_internal_data(path, qi=thumb_img, mtime=new_mtime, self._update_internal_data(path, qi=thumb_img, mtime=new_mtime,
inode=new_inode, dev=new_dev) inode=new_inode, dev=new_dev)
break
except Exception: except Exception:
pass pass
@@ -2412,6 +2489,7 @@ class MainWindow(QMainWindow):
self.proxy_model.clear_cache() self.proxy_model.clear_cache()
self._model_update_queue.clear() self._model_update_queue.clear()
self._model_update_timer.stop() self._model_update_timer.stop()
self.fs_watcher.clear_paths()
# Stop any pending hide action from previous scan # Stop any pending hide action from previous scan
self.hide_progress_timer.stop() self.hide_progress_timer.stop()
@@ -2431,6 +2509,12 @@ class MainWindow(QMainWindow):
self.scanner.set_auto_load(True) self.scanner.set_auto_load(True)
self._is_loading = True self._is_loading = True
self.scanner.images_found.connect(self.collect_found_images) 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_percent.connect(self.update_progress_bar)
self.scanner.progress_msg.connect(self.status_lbl.setText) self.scanner.progress_msg.connect(self.status_lbl.setText)
self.scanner.more_files_available.connect(self.more_files_available) self.scanner.more_files_available.connect(self.more_files_available)
@@ -2619,12 +2703,9 @@ class MainWindow(QMainWindow):
# Check for Header match # Check for Header match
# target format: ('HEADER', (key, header_text, count)) # target format: ('HEADER', (key, header_text, count))
if isinstance(target, tuple) and len(target) == 2 and target[0] == 'HEADER': 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] target_group_name = target[1][0]
return (item.data(ITEM_TYPE_ROLE) == 'header' and return (item.data(ITEM_TYPE_ROLE) == 'header' and
item.data(GROUP_NAME_ROLE) == target_group_name and item.data(GROUP_NAME_ROLE) == target_group_name)
item.data(DIR_ROLE) == header_text)
# Check for Thumbnail match # Check for Thumbnail match
# target format: (path, qi, mtime, tags, rating, inode, dev) # target format: (path, qi, mtime, tags, rating, inode, dev)
@@ -2635,48 +2716,94 @@ class MainWindow(QMainWindow):
return False return False
def _get_group_info(self, path, mtime, rating): def _get_group_info(self, path, mtime, rating):
"""Calculates the grouping key and display name for a file. """Calculates the grouping key and display name for a file with optimized
caching."""
Args: # Determine resolution criteria for shared caching across all files in same
path (str): File path. # group
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
if self.proxy_model.group_by_folder: if self.proxy_model.group_by_folder:
stable_group_key = display_name = os.path.dirname(path) crit = os.path.dirname(path)
elif self.proxy_model.group_by_day: mode = 'F'
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")
elif self.proxy_model.group_by_rating: elif self.proxy_model.group_by_rating:
r = rating if rating is not None else 0 crit = (rating + 1) // 2 if rating is not None else 0
stars = (r + 1) // 2 mode = 'R'
stable_group_key = str(stars) else:
display_name = UITexts.GROUP_BY_RATING_FORMAT.format(stars=stars) # 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) # Shared cache by criteria ensures expensive formatting happens only once per
return stable_group_key, display_name # 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): def rebuild_view(self, full_reset=False):
""" """
@@ -2773,13 +2900,26 @@ class MainWindow(QMainWindow):
self.thumbnail_model.clear() self.thumbnail_model.clear()
self._path_to_model_index.clear() self._path_to_model_index.clear()
# Optimize grouped insertion: Decorate-Sort-Group # 1. Decorate: Calculate group info once per item with local memoization
# 1. Decorate: Calculate group info once per item
decorated_data = [] 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: for item in self.found_items_data:
# item structure: (path, qi, mtime, tags, rating, inode, dev) # item structure: (path, qi, mtime, tags, rating, inode, dev)
stable_key, display_name = self._get_group_info( path, _, mtime, _, rating, _, _ = item
item[0], item[2], item[4])
# 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(
path, mtime, rating)
local_memo[m_key] = (stable_key, display_name)
# Use empty string for None keys to ensure sortability # Use empty string for None keys to ensure sortability
sort_key = stable_key if stable_key is not None else "" sort_key = stable_key if stable_key is not None else ""
decorated_data.append((sort_key, display_name, item)) decorated_data.append((sort_key, display_name, item))
@@ -2818,13 +2958,82 @@ class MainWindow(QMainWindow):
total_targets = len(target_structure) total_targets = len(target_structure)
new_items_batch = [] 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: while target_idx < total_targets:
target = target_structure[target_idx] target = target_structure[target_idx]
current_item = self.thumbnail_model.item(model_idx) current_item = self.thumbnail_model.item(model_idx)
if self._match_item(target, current_item): 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 model_idx += 1
target_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: else:
# Prepare new item # Prepare new item
if isinstance(target, tuple) and len(target) == 2 \ if isinstance(target, tuple) and len(target) == 2 \
@@ -2851,12 +3060,21 @@ class MainWindow(QMainWindow):
# recalculations # recalculations
while target_idx < total_targets: while target_idx < total_targets:
next_target = target_structure[target_idx] next_target = target_structure[target_idx]
# Check if next_target matches current model position # Check if next_target matches current model position
# (re-sync) # (re-sync)
if self._match_item( if self._match_item(
next_target, self.thumbnail_model.item(model_idx)): next_target, self.thumbnail_model.item(model_idx)):
break 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 not matching, it's another new item to insert
if isinstance(next_target, tuple) \ if isinstance(next_target, tuple) \
and len(next_target) == 2 and next_target[0] == 'HEADER': and len(next_target) == 2 and next_target[0] == 'HEADER':
@@ -3231,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
@@ -3467,22 +3689,23 @@ class MainWindow(QMainWindow):
res = load_common_metadata(path) res = load_common_metadata(path)
tags, rating = res.tags, res.rating tags, rating = res.tags, res.rating
# Find the item in the source model and update its data # Use cache for O(1) lookup in the source model
for row in range(self.thumbnail_model.rowCount()): path = os.path.abspath(os.path.expanduser(path))
item = self.thumbnail_model.item(row) if path in self._path_to_model_index:
if item and item.data(PATH_ROLE) == path: 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(tags, TAGS_ROLE)
item.setData(rating, RATING_ROLE) item.setData(rating, RATING_ROLE)
tooltip_text = f"{os.path.basename(path)}\n{path}" tooltip_text = f"{os.path.basename(path)}\n{path}"
if tags: if tags:
display_tags = [t.split('/')[-1] for t in tags] display_tags = [t.split('/')[-1] for t in tags]
tooltip_text += f"\n{UITexts.TAGS_TAB}: {', '.join( tooltip_text += f"\n{UITexts.TAGS_TAB}: {', '.join(display_tags)}"
display_tags)}"
item.setToolTip(tooltip_text) item.setToolTip(tooltip_text)
# Notify the view that the data has changed # 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( self.thumbnail_model.dataChanged.emit(
source_idx, source_idx, [TAGS_ROLE, RATING_ROLE]) source_idx, source_idx, [TAGS_ROLE, RATING_ROLE])
@@ -3491,7 +3714,6 @@ class MainWindow(QMainWindow):
# Update proxy filter cache to prevent stale filtering # Update proxy filter cache to prevent stale filtering
self.proxy_model.add_to_cache(path, tags) self.proxy_model.add_to_cache(path, tags)
break
if self.main_dock.isVisible(): if self.main_dock.isVisible():
self.on_tags_tab_changed(self.tags_tabs.currentIndex()) self.on_tags_tab_changed(self.tags_tabs.currentIndex())
@@ -3522,14 +3744,14 @@ class MainWindow(QMainWindow):
self.thumbnail_view.setGridSize(QSize()) self.thumbnail_view.setGridSize(QSize())
else: else:
self.thumbnail_view.setGridSize(self.delegate.sizeHint(None, None)) self.thumbnail_view.setGridSize(self.delegate.sizeHint(None, None))
self.rebuild_view(full_reset=True) self.rebuild_view()
self.save_config() self.save_config()
self.setFocus() self.setFocus()
def on_sort_changed(self): def on_sort_changed(self):
"""Callback for when the sort order dropdown changes.""" """Callback for when the sort order dropdown changes."""
self.rebuild_view(full_reset=True) self.rebuild_view()
self.save_config() self.save_config()
if hasattr(self, 'history_tab'): if hasattr(self, 'history_tab'):
self.history_tab.refresh_list() self.history_tab.refresh_list()
@@ -4268,8 +4490,169 @@ class MainWindow(QMainWindow):
self.cache.clear_cache() self.cache.clear_cache()
self.status_lbl.setText(UITexts.CACHE_CLEARED) 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): def propagate_rename(self, old_path, new_path, source_viewer=None):
"""Propagates a file rename across the application.""" """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 self._visible_paths_cache = None
# Update found_items_data to ensure consistency on future rebuilds # Update found_items_data to ensure consistency on future rebuilds
current_tags = None current_tags = None
@@ -4288,23 +4671,21 @@ 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
for row in range(self.thumbnail_model.rowCount()): if old_path in self._path_to_model_index:
item = self.thumbnail_model.item(row) p_idx = self._path_to_model_index.pop(old_path)
if item and item.data(PATH_ROLE) == old_path: if p_idx.isValid():
item.setData(new_path, PATH_ROLE) item = self.thumbnail_model.itemFromIndex(QModelIndex(p_idx))
item.setText(os.path.basename(new_path)) if item:
# No need to update the icon, it's the same image data item.setData(new_path, PATH_ROLE)
source_index = self.thumbnail_model.indexFromItem(item) item.setText(os.path.basename(new_path))
self.thumbnail_model.dataChanged.emit(source_index, source_index) self._path_to_model_index[new_path] = p_idx
break source_index = QModelIndex(p_idx)
self.thumbnail_model.dataChanged.emit(source_index, source_index)
# Update the cache entry # Update the cache entry
self.cache.rename_entry(old_path, new_path) self.cache.rename_entry(old_path, new_path)
@@ -4429,7 +4810,7 @@ class MainWindow(QMainWindow):
for code, name in SUPPORTED_LANGUAGES.items(): for code, name in SUPPORTED_LANGUAGES.items():
action = QAction(name, self, checkable=True) action = QAction(name, self, checkable=True)
action.setData(code) action.setData(code)
if code == CURRENT_LANGUAGE: if code == APP_CONFIG.get("language", DEFAULT_LANGUAGE):
action.setChecked(True) action.setChecked(True)
language_menu.addAction(action) language_menu.addAction(action)
lang_group.addAction(action) lang_group.addAction(action)
@@ -4438,7 +4819,7 @@ class MainWindow(QMainWindow):
"""Handles language change, saves config, and prompts for restart.""" """Handles language change, saves config, and prompts for restart."""
new_lang = action.data() new_lang = action.data()
# Only save and show message if the language actually changed # 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 APP_CONFIG["language"] = new_lang
constants.save_app_config() constants.save_app_config()
@@ -4457,7 +4838,7 @@ def main():
app = QApplication(sys.argv) app = QApplication(sys.argv)
# Increase QPixmapCache limit (default is usually small, ~10MB) to ~100MB # Increase QPixmapCache limit (default is usually small, ~10MB) to ~100MB
QPixmapCache.setCacheLimit(102400) QPixmapCache.setCacheLimit(104857600) # Old value: 102400
thread_pool_manager = ThreadPoolManager() thread_pool_manager = ThreadPoolManager()
cache = ThumbnailCache() cache = ThumbnailCache()

View File

@@ -29,12 +29,28 @@ 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.14" PROG_VERSION = "0.9.15"
PROG_AUTHOR = "Ignacio Serantes" PROG_AUTHOR = "Ignacio Serantes"
# --- CACHE SETTINGS --- # --- CACHE SETTINGS ---
# Maximum number of thumbnails to keep in the in-memory cache. # Maximum number of paths to track in the in-memory cache.
CACHE_MAX_SIZE = 20000 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. # 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
@@ -341,23 +357,16 @@ DEFAULT_VIEWER_SHORTCUTS = {
# Supported languages # Supported languages
SUPPORTED_LANGUAGES = { SUPPORTED_LANGUAGES = {
"system": "System",
"en": "English", "en": "English",
"es": "Español", "es": "Español",
"gl": "Galego" "gl": "Galego"
} }
# Default language # Default language for configuration
DEFAULT_LANGUAGE = "en" DEFAULT_LANGUAGE = "system"
# Determine current language: # Fallback language for translations
# 1. Environment variable (for debugging/override) FALLBACK_LANGUAGE = "en"
# 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
_UI_TEXTS = { _UI_TEXTS = {
"en": { "en": {
@@ -821,6 +830,7 @@ _UI_TEXTS = {
"ERROR_MOVE_FILE": "Could not move file: {}", "ERROR_MOVE_FILE": "Could not move file: {}",
"ERROR_COPY_FILE": "Could not copy file: {}", "ERROR_COPY_FILE": "Could not copy file: {}",
"MOVED_TO": "Moved to {}", "MOVED_TO": "Moved to {}",
"FS_WATCHER_TOOLTIP": "File System Watcher (monitoring active directories)",
"COPIED_TO": "Copied to {}", "COPIED_TO": "Copied to {}",
"ERROR_ROTATE_IMAGE": "Could not rotate image: {}", "ERROR_ROTATE_IMAGE": "Could not rotate image: {}",
}, },
@@ -1292,6 +1302,8 @@ _UI_TEXTS = {
"ERROR_MOVE_FILE": "No se pudo mover el archivo: {}", "ERROR_MOVE_FILE": "No se pudo mover el archivo: {}",
"ERROR_COPY_FILE": "No se pudo copiar el archivo: {}", "ERROR_COPY_FILE": "No se pudo copiar el archivo: {}",
"MOVED_TO": "Movido a {}", "MOVED_TO": "Movido a {}",
"FS_WATCHER_TOOLTIP": "Monitor de Sistema de Archivos (monitoreando "
"directorios activos)",
"COPIED_TO": "Copiado a {}", "COPIED_TO": "Copiado a {}",
"ERROR_ROTATE_IMAGE": "No se pudo girar la imagen: {}", "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_MOVE_FILE": "Non se puido mover o ficheiro: {}",
"ERROR_COPY_FILE": "Non se puido copiar o ficheiro: {}", "ERROR_COPY_FILE": "Non se puido copiar o ficheiro: {}",
"MOVED_TO": "Movido a {}", "MOVED_TO": "Movido a {}",
"FS_WATCHER_TOOLTIP": "Monitor do Sistema de Ficheiros (monitoreando "
"directorios activos)",
"COPIED_TO": "Copiado a {}", "COPIED_TO": "Copiado a {}",
"ERROR_ROTATE_IMAGE": "Non se puido xirar a imaxe: {}", "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: class _UITextsProxy:
""" """
A proxy class to access UI strings from the _UI_TEXTS dictionary. A proxy class to access UI strings from the _UI_TEXTS dictionary.
@@ -1781,12 +1815,12 @@ class _UITextsProxy:
""" """
def __getattr__(self, name): def __getattr__(self, name):
# Get the dictionary for the current language, or fallback to the default. # 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, # Get the specific string. If not found in the current language,
# try the default language. # try the default language.
text = lang_texts.get(name) text = lang_texts.get(name)
if text is None: if text is None:
default_texts = _UI_TEXTS[DEFAULT_LANGUAGE] default_texts = _UI_TEXTS[FALLBACK_LANGUAGE]
# Return a placeholder if not found anywhere # Return a placeholder if not found anywhere
text = default_texts.get(name, f"_{name}_") text = default_texts.get(name, f"_{name}_")
return text 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 PySide6.QtGui import QImage, QImageReader, QImageIOHandler
from constants import ( from constants import (
APP_CONFIG, CACHE_PATH, CACHE_MAX_SIZE, CONFIG_DIR, DISK_CACHE_MAX_BYTES, APP_CONFIG, CACHE_PATH, CACHE_MAX_SIZE, CACHE_MAX_RAM_BYTES, CONFIG_DIR,
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,20 +644,80 @@ 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."""
# 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 ( 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 > MAX_RAM_BYTES): self._cache_bytes_size > CACHE_MAX_RAM_BYTES):
oldest_path = next(iter(self._thumbnail_cache)) self._evict_tiered()
cached_sizes = self._thumbnail_cache.pop(oldest_path)
for img, _ in cached_sizes.values(): # 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: if img:
self._cache_bytes_size -= img.sizeInBytes() 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): def _get_tier_for_size(self, requested_size):
"""Determines the ideal thumbnail tier based on the requested size.""" """Determines the ideal thumbnail tier based on the requested size."""
@@ -1752,6 +1812,10 @@ class ImageScanner(QThread):
batch.append(r.result) batch.append(r.result)
self.count += 1 self.count += 1
images_loaded += 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 # Clean up runnables
runnables.clear() runnables.clear()
@@ -1796,12 +1860,11 @@ class ImageScanner(QThread):
"scan_batch_size"])) "scan_batch_size"]))
return return
if self.count % 10 == 0: # Update progress less frequently # Emit progress message less frequently, e.g., every 50 images or at batch
self.progress_msg.emit( # end
UITexts.LOADING_SCAN.format(self.count, len(self.all_files))) if self.count % 50 == 0 or images_loaded >= to_load:
if len(self.all_files) > 0: self.progress_msg.emit(UITexts.LOADING_SCAN.format(
percent = int((self.count / len(self.all_files)) * 100) self.count, len(self.all_files)))
self.progress_percent.emit(percent)
self.index = len(self.all_files) self.index = len(self.all_files)
if batch: if batch:

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
setup( setup(
name="bagheeraview", name="bagheeraview",
version="0.9.14", version="0.9.15",
author="Ignacio Serantes", author="Ignacio Serantes",
description="Bagheera Image Viewer - An image viewer for KDE with Baloo in mind", 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 " long_description="A fast image viewer built with PySide6, featuring search and "
@@ -14,6 +14,8 @@ setup(
"PySide6", "PySide6",
"lmdb", "lmdb",
"exiv2", "exiv2",
"psutil",
"watchdog",
"mediapipe", "mediapipe",
"face_recognition", "face_recognition",
"face_recognition_models", "face_recognition_models",
@@ -33,6 +35,7 @@ setup(
"imagescanner", "imagescanner",
"imageviewer", "imageviewer",
"imagecontroller", "imagecontroller",
"filesystemwatcher",
"metadatamanager", "metadatamanager",
"propertiesdialog", "propertiesdialog",
"thumbnailwidget", "thumbnailwidget",