Compare commits
3 Commits
a7ce2ceb75
...
ff7c1aa373
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff7c1aa373 | ||
|
|
d4f3732aa4 | ||
|
|
096cee6ca3 |
17
README.md
17
README.md
@@ -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
|
||||||
|
|||||||
599
bagheeraview.py
599
bagheeraview.py
@@ -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:
|
||||||
|
if d not in paths:
|
||||||
paths.append(d)
|
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)
|
||||||
|
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(
|
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
|
# 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 = self.thumbnail_model.itemFromIndex(QModelIndex(p_idx))
|
||||||
|
if item:
|
||||||
item.setData(new_path, PATH_ROLE)
|
item.setData(new_path, PATH_ROLE)
|
||||||
item.setText(os.path.basename(new_path))
|
item.setText(os.path.basename(new_path))
|
||||||
# No need to update the icon, it's the same image data
|
self._path_to_model_index[new_path] = p_idx
|
||||||
source_index = self.thumbnail_model.indexFromItem(item)
|
source_index = QModelIndex(p_idx)
|
||||||
self.thumbnail_model.dataChanged.emit(source_index, source_index)
|
self.thumbnail_model.dataChanged.emit(source_index, source_index)
|
||||||
break
|
|
||||||
|
|
||||||
# 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()
|
||||||
|
|||||||
68
constants.py
68
constants.py
@@ -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
203
filesystemwatcher.py
Normal 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
|
||||||
@@ -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,14 +644,74 @@ class ThumbnailCache(QObject):
|
|||||||
self._cache_lock.unlock()
|
self._cache_lock.unlock()
|
||||||
|
|
||||||
def _ensure_cache_limit(self):
|
def _ensure_cache_limit(self):
|
||||||
"""Enforces cache size limit by evicting oldest entries.
|
"""Enforces cache size limit and reacts to system memory pressure.
|
||||||
Must be called with a write lock held."""
|
Must be called with a write lock held."""
|
||||||
# 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):
|
||||||
|
self._evict_tiered()
|
||||||
|
|
||||||
|
# Check system-wide memory pressure (Low RAM fallback)
|
||||||
|
try:
|
||||||
|
import psutil
|
||||||
|
mem = psutil.virtual_memory()
|
||||||
|
if (mem.available / mem.total) * 100 < MIN_FREE_RAM_PERCENT:
|
||||||
|
logger.warning(f"Low system memory detected (< {MIN_FREE_RAM_PERCENT}%). "
|
||||||
|
"Applying aggressive tiered pruning.")
|
||||||
|
|
||||||
|
# Strategy: first clear ALL cached high-res tiers to free space quickly
|
||||||
|
# while keeping the 128px grid thumbnails intact.
|
||||||
|
for tier in [512, 256]:
|
||||||
|
self._prune_tier(tier)
|
||||||
|
|
||||||
|
# Re-check if pressure relieved
|
||||||
|
mem = psutil.virtual_memory()
|
||||||
|
if (mem.available / mem.total) * 100 >= MIN_FREE_RAM_PERCENT:
|
||||||
|
return
|
||||||
|
|
||||||
|
# If still under pressure, remove oldest 10% of remaining entries
|
||||||
|
items_to_prune = max(1, len(self._thumbnail_cache) // 10)
|
||||||
|
for _ in range(items_to_prune):
|
||||||
|
if not self._thumbnail_cache:
|
||||||
|
break
|
||||||
|
self._evict_oldest_path_entirely()
|
||||||
|
except (ImportError, Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _evict_tiered(self):
|
||||||
|
"""
|
||||||
|
Removes content from cache targeting larger tiers first from the oldest entries.
|
||||||
|
Must be called with write lock held.
|
||||||
|
"""
|
||||||
|
# We check the oldest 100 entries for large tiers to avoid O(N) scans
|
||||||
|
# on every call while still being very effective for LRU behavior.
|
||||||
|
check_limit = 100
|
||||||
|
for tier in [512, 256]:
|
||||||
|
count = 0
|
||||||
|
for path, sizes in self._thumbnail_cache.items():
|
||||||
|
if tier in sizes:
|
||||||
|
img, _ = sizes.pop(tier)
|
||||||
|
if img:
|
||||||
|
self._cache_bytes_size -= img.sizeInBytes()
|
||||||
|
return
|
||||||
|
count += 1
|
||||||
|
if count >= check_limit:
|
||||||
|
break
|
||||||
|
|
||||||
|
self._evict_oldest_path_entirely()
|
||||||
|
|
||||||
|
def _prune_tier(self, tier):
|
||||||
|
"""Removes all thumbnails of a specific tier from cache to free RAM quickly."""
|
||||||
|
for path, sizes in self._thumbnail_cache.items():
|
||||||
|
if tier in sizes:
|
||||||
|
img, _ = sizes.pop(tier)
|
||||||
|
if img:
|
||||||
|
self._cache_bytes_size -= img.sizeInBytes()
|
||||||
|
|
||||||
|
def _evict_oldest_path_entirely(self):
|
||||||
|
"""Removes the oldest cache entry completely. Must be called with write lock."""
|
||||||
oldest_path = next(iter(self._thumbnail_cache))
|
oldest_path = next(iter(self._thumbnail_cache))
|
||||||
cached_sizes = self._thumbnail_cache.pop(oldest_path)
|
cached_sizes = self._thumbnail_cache.pop(oldest_path)
|
||||||
for img, _ in cached_sizes.values():
|
for img, _ in cached_sizes.values():
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
PySide6
|
PySide6
|
||||||
lmdb
|
lmdb
|
||||||
exiv2
|
exiv2
|
||||||
|
psutil
|
||||||
|
watchdog
|
||||||
mediapipe
|
mediapipe
|
||||||
face_recognition
|
face_recognition
|
||||||
face_recognition_models
|
face_recognition_models
|
||||||
|
|||||||
5
setup.py
5
setup.py
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user