Added watchdog support

This commit is contained in:
Ignacio Serantes
2026-03-28 07:13:16 +01:00
parent 096cee6ca3
commit d4f3732aa4
7 changed files with 661 additions and 92 deletions

View File

@@ -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):
@@ -1067,6 +1068,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)
@@ -1236,12 +1242,6 @@ class MainWindow(QMainWindow):
self.favorites_tab.favorites_changed.connect( self.favorites_tab.favorites_changed.connect(
self.shortcut_controller.refresh_favorite_shortcuts) self.shortcut_controller.refresh_favorite_shortcuts)
# 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.main_dock.setWidget(self.tags_tabs) self.main_dock.setWidget(self.tags_tabs)
self.addDockWidget(Qt.RightDockWidgetArea, self.main_dock) self.addDockWidget(Qt.RightDockWidgetArea, self.main_dock)
@@ -1289,6 +1289,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()
@@ -1607,15 +1631,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)
@@ -1817,6 +1843,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
@@ -2059,7 +2086,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]
@@ -2072,6 +2105,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
@@ -2368,16 +2406,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
@@ -2418,6 +2456,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()
@@ -2437,6 +2476,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)
@@ -2625,12 +2670,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)
@@ -2641,48 +2683,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):
""" """
@@ -2779,13 +2867,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))
@@ -2824,13 +2925,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 \
@@ -2857,12 +3027,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':
@@ -3473,22 +3652,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])
@@ -3497,7 +3677,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())
@@ -3528,14 +3707,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()
@@ -4274,8 +4453,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._data_cache.pop(path, None)
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
@@ -4302,15 +4642,16 @@ class MainWindow(QMainWindow):
os.path.basename(new_path).lower()) 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)

View File

@@ -33,8 +33,20 @@ PROG_VERSION = "0.9.15-dev"
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
# 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
@@ -814,6 +826,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: {}",
}, },
@@ -1285,6 +1298,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: {}",
}, },
@@ -1757,6 +1772,8 @@ _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: {}",
} }

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, 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
) )
@@ -646,12 +646,10 @@ class ThumbnailCache(QObject):
def _ensure_cache_limit(self): def _ensure_cache_limit(self):
"""Enforces cache size limit by evicting oldest entries. """Enforces cache size limit by evicting oldest entries.
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
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)) 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 +1750,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 +1798,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

@@ -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

@@ -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",