diff --git a/bagheeraview.py b/bagheeraview.py index f87f4ae..a024463 100755 --- a/bagheeraview.py +++ b/bagheeraview.py @@ -78,6 +78,7 @@ from widgets import ( FavoritesWidget ) from metadatamanager import load_common_metadata +from filesystemwatcher import FileSystemWatcher class ShortcutHelpDialog(QDialog): @@ -1067,6 +1068,11 @@ class MainWindow(QMainWindow): self.progress_bar.hide() bot.addWidget(self.progress_bar) + self.fs_watcher_status_lbl = QLabel() + self.fs_watcher_status_lbl.setToolTip(UITexts.FS_WATCHER_TOOLTIP) + self.fs_watcher_status_lbl.hide() + bot.addWidget(self.fs_watcher_status_lbl) + # Timer to hide progress bar with delay self.hide_progress_timer = QTimer(self) self.hide_progress_timer.setSingleShot(True) @@ -1236,12 +1242,6 @@ class MainWindow(QMainWindow): self.favorites_tab.favorites_changed.connect( 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.addDockWidget(Qt.RightDockWidgetArea, self.main_dock) @@ -1289,6 +1289,30 @@ class MainWindow(QMainWindow): self.thumbnail_view.verticalScrollBar().valueChanged.connect( self._on_scroll_interaction) + # Initialize FileSystemWatcher + self.fs_watcher = FileSystemWatcher() + self.fs_watcher.file_created.connect(self.on_fs_file_created) + self.fs_watcher.file_deleted.connect(self.on_fs_file_deleted) + self.fs_watcher.file_modified.connect(self.on_fs_file_modified) + self.fs_watcher.directory_modified.connect(self.on_fs_directory_modified) + self.fs_watcher.file_moved.connect(self.on_fs_file_moved) + self.fs_watcher.directory_moved.connect(self.on_fs_directory_moved) + self.fs_watcher.monitoring_status_changed.connect( + self.on_fs_watcher_status_changed) + + # Batching for file creation events + self._fs_created_queue = set() + self._fs_created_timer = QTimer(self) + self._fs_created_timer.setSingleShot(True) + self._fs_created_timer.setInterval(1000) # 1 second debounce + self._fs_created_timer.timeout.connect(self._process_fs_created_batch) + + # Debounce timer for full refreshes on directory modifications + self._fs_dir_refresh_timer = QTimer(self) + self._fs_dir_refresh_timer.setSingleShot(True) + self._fs_dir_refresh_timer.setInterval(2500) # 2.5 seconds debounce + self._fs_dir_refresh_timer.timeout.connect(self.refresh_content) + # Initial configuration loading self.load_config() self.load_full_history() @@ -1607,15 +1631,17 @@ class MainWindow(QMainWindow): # 5. Start scanning all parent directories of the images in the layout unique_dirs = list({str(Path(p).parent) for p in paths}) for d in unique_dirs: - paths.append(d) + if d not in paths: + paths.append(d) + self.start_scan([p.strip() for p in paths if p.strip() and os.path.exists(os.path.expanduser(p.strip()))], select_paths=select_paths) + if search_text: self.search_input.setEditText(search_text) # --- UI and Menu Logic --- - def show_main_menu(self): """Displays the main application menu.""" menu = QMenu(self) @@ -1817,6 +1843,7 @@ class MainWindow(QMainWindow): def perform_shutdown(self): """Performs cleanup operations before the application closes.""" self.is_cleaning = True + self.fs_watcher.stop() # 1. Stop all worker threads interacting with the cache # Signal all threads to stop first @@ -2059,7 +2086,13 @@ class MainWindow(QMainWindow): def find_and_select_path(self, path_to_select): """Finds an item by its path in the model and selects it using a cache.""" - if not path_to_select or path_to_select not in self._path_to_model_index: + if not path_to_select: + return False + + # Ensure path is normalized for reliable cache lookup + path_to_select = os.path.abspath(os.path.expanduser(path_to_select)) + + if path_to_select not in self._path_to_model_index: return False persistent_index = self._path_to_model_index[path_to_select] @@ -2072,6 +2105,11 @@ class MainWindow(QMainWindow): proxy_index = self.proxy_model.mapFromSource(source_index) if proxy_index.isValid(): + # Optimization: skip if already selected and current to avoid + # unnecessary signals and view updates. + if self.thumbnail_view.currentIndex() == proxy_index and \ + self.thumbnail_view.selectionModel().isSelected(proxy_index): + return True self.set_selection(proxy_index) return True @@ -2368,16 +2406,16 @@ class MainWindow(QMainWindow): inode=new_inode, device_id=new_dev) # Update model item - for row in range(self.thumbnail_model.rowCount()): - item = self.thumbnail_model.item(row) - if item and item.data(PATH_ROLE) == path: + if path in self._path_to_model_index: + p_idx = self._path_to_model_index[path] + if p_idx.isValid(): + item = self.thumbnail_model.itemFromIndex(QModelIndex(p_idx)) item.setIcon(QIcon(QPixmap.fromImage(thumb_img))) item.setData(new_mtime, MTIME_ROLE) item.setData(new_inode, INODE_ROLE) item.setData(new_dev, DEVICE_ROLE) self._update_internal_data(path, qi=thumb_img, mtime=new_mtime, inode=new_inode, dev=new_dev) - break except Exception: pass @@ -2418,6 +2456,7 @@ class MainWindow(QMainWindow): self.proxy_model.clear_cache() self._model_update_queue.clear() self._model_update_timer.stop() + self.fs_watcher.clear_paths() # Stop any pending hide action from previous scan self.hide_progress_timer.stop() @@ -2437,6 +2476,12 @@ class MainWindow(QMainWindow): self.scanner.set_auto_load(True) self._is_loading = True self.scanner.images_found.connect(self.collect_found_images) + + # Add directories to file system watcher + for p in paths: + if os.path.isdir(os.path.abspath(os.path.expanduser(p))): + self.fs_watcher.add_path(os.path.abspath(os.path.expanduser(p))) + self.scanner.progress_percent.connect(self.update_progress_bar) self.scanner.progress_msg.connect(self.status_lbl.setText) self.scanner.more_files_available.connect(self.more_files_available) @@ -2625,12 +2670,9 @@ class MainWindow(QMainWindow): # Check for Header match # target format: ('HEADER', (key, header_text, count)) if isinstance(target, tuple) and len(target) == 2 and target[0] == 'HEADER': - _, (_, header_text, _) = target - # Strict match including group name to ensure roles are updated target_group_name = target[1][0] return (item.data(ITEM_TYPE_ROLE) == 'header' and - item.data(GROUP_NAME_ROLE) == target_group_name and - item.data(DIR_ROLE) == header_text) + item.data(GROUP_NAME_ROLE) == target_group_name) # Check for Thumbnail match # target format: (path, qi, mtime, tags, rating, inode, dev) @@ -2641,48 +2683,94 @@ class MainWindow(QMainWindow): return False def _get_group_info(self, path, mtime, rating): - """Calculates the grouping key and display name for a file. - - Args: - path (str): File path. - mtime (float): Modification time. - rating (int): Rating value. - - Returns: - tuple: (stable_group_key, display_name) - """ - cache_key = (path, mtime, rating) - if cache_key in self._group_info_cache: - return self._group_info_cache[cache_key] - - stable_group_key = None - display_name = None - + """Calculates the grouping key and display name for a file with optimized + caching.""" + # Determine resolution criteria for shared caching across all files in same + # group if self.proxy_model.group_by_folder: - stable_group_key = display_name = os.path.dirname(path) - elif self.proxy_model.group_by_day: - stable_group_key = display_name = datetime.fromtimestamp( - mtime).strftime("%Y-%m-%d") - elif self.proxy_model.group_by_week: - dt = datetime.fromtimestamp(mtime) - stable_group_key = dt.strftime("%Y-%W") - display_name = UITexts.GROUP_BY_WEEK_FORMAT.format( - year=dt.strftime("%Y"), week=dt.strftime("%W")) - elif self.proxy_model.group_by_month: - dt = datetime.fromtimestamp(mtime) - stable_group_key = dt.strftime("%Y-%m") - display_name = dt.strftime("%B %Y").capitalize() - elif self.proxy_model.group_by_year: - stable_group_key = display_name = datetime.fromtimestamp( - mtime).strftime("%Y") + crit = os.path.dirname(path) + mode = 'F' elif self.proxy_model.group_by_rating: - r = rating if rating is not None else 0 - stars = (r + 1) // 2 - stable_group_key = str(stars) - display_name = UITexts.GROUP_BY_RATING_FORMAT.format(stars=stars) + crit = (rating + 1) // 2 if rating is not None else 0 + mode = 'R' + else: + # Date modes: use datetime object parts as hashable criteria + dt = datetime.fromtimestamp(int(mtime) if mtime else 0) + if self.proxy_model.group_by_day: + crit = (dt.year, dt.month, dt.day) + mode = 'D' + elif self.proxy_model.group_by_week: + crit = (dt.year, int(dt.strftime("%W"))) + mode = 'W' + elif self.proxy_model.group_by_month: + crit = (dt.year, dt.month) + mode = 'M' + elif self.proxy_model.group_by_year: + crit = dt.year + mode = 'Y' + else: + return (None, None) - self._group_info_cache[cache_key] = (stable_group_key, display_name) - return stable_group_key, display_name + # Shared cache by criteria ensures expensive formatting happens only once per + # group + shared_key = (crit, mode) + if shared_key in self._group_info_cache: + return self._group_info_cache[shared_key] + + # Perform actual calculation + if mode == 'F': + res = (crit, crit) + elif mode == 'R': + res = (str(crit), UITexts.GROUP_BY_RATING_FORMAT.format(stars=crit)) + else: + if mode == 'D': + sk = dn = dt.strftime("%Y-%m-%d") + elif mode == 'W': + sk = dt.strftime("%Y-%W") + dn = UITexts.GROUP_BY_WEEK_FORMAT.format( + year=dt.strftime("%Y"), week=dt.strftime("%W")) + elif mode == 'M': + sk = dt.strftime("%Y-%m") + dn = dt.strftime("%B %Y").capitalize() + else: # Year + sk = dn = dt.strftime("%Y") + res = (sk, dn) + + self._group_info_cache[shared_key] = res + return res + + def _find_sorted_index_in_data(self, new_item_data): + """Finds the correct index to insert an item to keep found_items_data sorted.""" + mode = self.sort_combo.currentText() + rev = "↓" in mode + sort_by_name = "Name" in mode + + def get_key(item): + # item structure: (path, qi, mtime, tags, rating, inode, dev) + path, _, mtime, _, _, _, _ = item + if sort_by_name: + return os.path.basename(path).lower() + return mtime if mtime is not None else 0 + + target_key = get_key(new_item_data) + + # Binary search for the insertion point (O(log N)) + lo = 0 + hi = len(self.found_items_data) + while lo < hi: + mid = (lo + hi) // 2 + mid_key = get_key(self.found_items_data[mid]) + if not rev: + if mid_key < target_key: + lo = mid + 1 + else: + hi = mid + else: + if mid_key > target_key: + lo = mid + 1 + else: + hi = mid + return lo def rebuild_view(self, full_reset=False): """ @@ -2779,13 +2867,26 @@ class MainWindow(QMainWindow): self.thumbnail_model.clear() self._path_to_model_index.clear() - # Optimize grouped insertion: Decorate-Sort-Group - # 1. Decorate: Calculate group info once per item + # 1. Decorate: Calculate group info once per item with local memoization decorated_data = [] + local_memo = {} + # Cache grouping flags to avoid property lookups in loop + g_folder = self.proxy_model.group_by_folder + for item in self.found_items_data: # item structure: (path, qi, mtime, tags, rating, inode, dev) - stable_key, display_name = self._get_group_info( - item[0], item[2], item[4]) + 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( + path, mtime, rating) + local_memo[m_key] = (stable_key, display_name) + # Use empty string for None keys to ensure sortability sort_key = stable_key if stable_key is not None else "" decorated_data.append((sort_key, display_name, item)) @@ -2824,13 +2925,82 @@ class MainWindow(QMainWindow): total_targets = len(target_structure) new_items_batch = [] + # Optimization: Pre-calculate sets for fast lookup of needed items + target_paths_set = {t[0] for t in target_structure + if isinstance(t, tuple) and len(t) >= 5} + target_headers_set = {t[1][0] for t in target_structure + if isinstance(t, tuple) + and len(t) == 2 and t[0] == 'HEADER'} + while target_idx < total_targets: target = target_structure[target_idx] current_item = self.thumbnail_model.item(model_idx) if self._match_item(target, current_item): + # Si es una cabecera, actualizamos el texto por si cambió el + # contador + if isinstance(target, tuple) and target[0] == 'HEADER': + _, (_, header_text, _) = target + if current_item.data(DIR_ROLE) != header_text: + current_item.setData(header_text, DIR_ROLE) + model_idx += 1 target_idx += 1 + continue + + # 1. Identify and remove stale items (items in model but not in + # target structure) + if current_item: + is_needed = False + if current_item.data(ITEM_TYPE_ROLE) == 'thumbnail': + is_needed = current_item.data(PATH_ROLE) in target_paths_set + else: # header + is_needed = current_item.data(GROUP_NAME_ROLE) \ + in target_headers_set + + if not is_needed: + path = current_item.data(PATH_ROLE) + if path and path in self._path_to_model_index: + del self._path_to_model_index[path] + self.thumbnail_model.removeRow(model_idx) + # Stay at same model_idx, check next model item against + # same target + continue + + # 2. Try to MOVE target from later in the model (reordering + # optimization) + found_model_row = -1 + if isinstance(target, tuple) and len(target) >= 5: # Thumbnail + path = target[0] + p_idx = self._path_to_model_index.get(path) + if p_idx and p_idx.isValid() and p_idx.row() > model_idx: + found_model_row = p_idx.row() + elif isinstance(target, tuple) and target[0] == 'HEADER': + target_group_name = target[1][0] + for r in range(model_idx + 1, self.thumbnail_model.rowCount()): + it = self.thumbnail_model.item(r) + if it and it.data(ITEM_TYPE_ROLE) == 'header' and \ + it.data(GROUP_NAME_ROLE) == target_group_name: + found_model_row = r + break + + if found_model_row != -1: + # Move existing row to current position. + # Persistent indices and selection model will update + # automatically. + self.thumbnail_model.moveRow(QModelIndex(), found_model_row, + QModelIndex(), model_idx) + if isinstance(target, tuple) and target[0] == 'HEADER': + _, (_, header_text, _) = target + moved_item = self.thumbnail_model.item(model_idx) + if moved_item and moved_item.data(DIR_ROLE) != header_text: + moved_item.setData(header_text, DIR_ROLE) + + model_idx += 1 + target_idx += 1 + continue + + # 3. Target is truly NEW - use batch insertion else: # Prepare new item if isinstance(target, tuple) and len(target) == 2 \ @@ -2857,12 +3027,21 @@ class MainWindow(QMainWindow): # recalculations while target_idx < total_targets: next_target = target_structure[target_idx] + # Check if next_target matches current model position # (re-sync) if self._match_item( next_target, self.thumbnail_model.item(model_idx)): break + # Check if next_target is a MOVE (exists elsewhere) + if isinstance(next_target, tuple) and len(next_target) >= 5: + n_p_idx = self._path_to_model_index.get(next_target[0]) + if n_p_idx and n_p_idx.isValid() \ + and n_p_idx.row() > model_idx: + break + # (simplified lookahead: headers always break batch) + # If not matching, it's another new item to insert if isinstance(next_target, tuple) \ and len(next_target) == 2 and next_target[0] == 'HEADER': @@ -3473,22 +3652,23 @@ class MainWindow(QMainWindow): res = load_common_metadata(path) tags, rating = res.tags, res.rating - # Find the item in the source model and update its data - for row in range(self.thumbnail_model.rowCount()): - item = self.thumbnail_model.item(row) - if item and item.data(PATH_ROLE) == path: + # Use cache for O(1) lookup in the source model + path = os.path.abspath(os.path.expanduser(path)) + if path in self._path_to_model_index: + p_idx = self._path_to_model_index[path] + if p_idx.isValid(): + item = self.thumbnail_model.itemFromIndex(QModelIndex(p_idx)) item.setData(tags, TAGS_ROLE) item.setData(rating, RATING_ROLE) tooltip_text = f"{os.path.basename(path)}\n{path}" if tags: display_tags = [t.split('/')[-1] for t in tags] - tooltip_text += f"\n{UITexts.TAGS_TAB}: {', '.join( - display_tags)}" + tooltip_text += f"\n{UITexts.TAGS_TAB}: {', '.join(display_tags)}" item.setToolTip(tooltip_text) # Notify the view that the data has changed - source_idx = self.thumbnail_model.indexFromItem(item) + source_idx = QModelIndex(p_idx) self.thumbnail_model.dataChanged.emit( source_idx, source_idx, [TAGS_ROLE, RATING_ROLE]) @@ -3497,7 +3677,6 @@ class MainWindow(QMainWindow): # Update proxy filter cache to prevent stale filtering self.proxy_model.add_to_cache(path, tags) - break if self.main_dock.isVisible(): self.on_tags_tab_changed(self.tags_tabs.currentIndex()) @@ -3528,14 +3707,14 @@ class MainWindow(QMainWindow): self.thumbnail_view.setGridSize(QSize()) else: self.thumbnail_view.setGridSize(self.delegate.sizeHint(None, None)) - self.rebuild_view(full_reset=True) + self.rebuild_view() self.save_config() self.setFocus() def on_sort_changed(self): """Callback for when the sort order dropdown changes.""" - self.rebuild_view(full_reset=True) + self.rebuild_view() self.save_config() if hasattr(self, 'history_tab'): self.history_tab.refresh_list() @@ -4274,8 +4453,169 @@ class MainWindow(QMainWindow): self.cache.clear_cache() self.status_lbl.setText(UITexts.CACHE_CLEARED) + def on_fs_file_created(self, path): + """Handles a new file being created in a monitored directory.""" + # Add to batch queue and (re)start the debounce timer + self._fs_created_queue.add(path) + self._fs_created_timer.start() + + def _process_fs_created_batch(self): + """Processes all accumulated file creation events at once.""" + paths = list(self._fs_created_queue) + self._fs_created_queue.clear() + + valid_new_items = [] + for p in paths: + p = os.path.abspath(p) + if os.path.exists(p) and p not in self._known_paths: + if os.path.splitext(p)[1].lower() in constants.IMAGE_EXTENSIONS: + valid_new_items.append(p) + + if not valid_new_items: + return + + # If the batch is very large, a full scan is more efficient than individual + # stats + if len(valid_new_items) > 50: + self.refresh_content() + return + + # For smaller batches, process metadata and update model + for path in valid_new_items: + try: + res = load_common_metadata(path) + stat_res = os.stat(path) + mtime = stat_res.st_mtime + inode = stat_res.st_ino + dev = stat_res.st_dev + + # tuple: (path, qi, mtime, tags, rating, inode, dev) + new_item_data = (path, None, mtime, res.tags, res.rating, inode, dev) + self.found_items_data.append(new_item_data) + self._known_paths.add(path) + self.proxy_model.add_to_cache(path, res.tags) + except Exception: + continue + + # Trigger an incremental rebuild of the view + self.rebuild_view() + + # Start background generation for the new items + self.generate_missing_thumbnails(self._current_thumb_tier) + + if len(valid_new_items) == 1: + msg = f"New file detected: {os.path.basename(valid_new_items[0])}" + else: + msg = f"Detected {len(valid_new_items)} new files" + self.status_lbl.setText(msg) + + def on_fs_file_deleted(self, path): + """Handles a file being deleted from a monitored directory.""" + path = os.path.abspath(path) + if path not in self._known_paths: + return # Not a file we're tracking + + # Remove from internal data structures + self.found_items_data = [item for item in self.found_items_data + if item[0] != path] + self._known_paths.discard(path) + self.proxy_model._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): """Propagates a file rename across the application.""" + old_path = os.path.abspath(old_path) + new_path = os.path.abspath(new_path) + self._visible_paths_cache = None # Update found_items_data to ensure consistency on future rebuilds current_tags = None @@ -4302,15 +4642,16 @@ class MainWindow(QMainWindow): os.path.basename(new_path).lower()) # Update the main model - for row in range(self.thumbnail_model.rowCount()): - item = self.thumbnail_model.item(row) - if item and item.data(PATH_ROLE) == old_path: - item.setData(new_path, PATH_ROLE) - item.setText(os.path.basename(new_path)) - # No need to update the icon, it's the same image data - source_index = self.thumbnail_model.indexFromItem(item) - self.thumbnail_model.dataChanged.emit(source_index, source_index) - break + if old_path in self._path_to_model_index: + p_idx = self._path_to_model_index.pop(old_path) + if p_idx.isValid(): + item = self.thumbnail_model.itemFromIndex(QModelIndex(p_idx)) + if item: + item.setData(new_path, PATH_ROLE) + item.setText(os.path.basename(new_path)) + self._path_to_model_index[new_path] = p_idx + source_index = QModelIndex(p_idx) + self.thumbnail_model.dataChanged.emit(source_index, source_index) # Update the cache entry self.cache.rename_entry(old_path, new_path) diff --git a/constants.py b/constants.py index 9621458..f1fba68 100644 --- a/constants.py +++ b/constants.py @@ -33,8 +33,20 @@ PROG_VERSION = "0.9.15-dev" PROG_AUTHOR = "Ignacio Serantes" # --- CACHE SETTINGS --- -# Maximum number of thumbnails to keep in the in-memory cache. -CACHE_MAX_SIZE = 20000 +# Maximum number of paths to track in the in-memory cache. +CACHE_MAX_SIZE = 10000 + +# Dynamic RAM limit for thumbnails to avoid swapping on low-end systems. +try: + import psutil + _total_ram_bytes = psutil.virtual_memory().total + # Use 10% of system RAM, clamped between 128MB and 512MB + CACHE_MAX_RAM_BYTES = int(max(128 * 1024 * 1024, + min(512 * 1024 * 1024, _total_ram_bytes * 0.10))) +except (ImportError, Exception): + # Fallback to a safe 256MB if psutil is missing or fails + CACHE_MAX_RAM_BYTES = 256 * 1024 * 1024 + # Maximum size of the persistent disk cache file. # 10 GB limit for persistent cache file DISK_CACHE_MAX_BYTES = 10 * 1024 * 1024 * 1024 @@ -814,6 +826,7 @@ _UI_TEXTS = { "ERROR_MOVE_FILE": "Could not move file: {}", "ERROR_COPY_FILE": "Could not copy file: {}", "MOVED_TO": "Moved to {}", + "FS_WATCHER_TOOLTIP": "File System Watcher (monitoring active directories)", "COPIED_TO": "Copied to {}", "ERROR_ROTATE_IMAGE": "Could not rotate image: {}", }, @@ -1285,6 +1298,8 @@ _UI_TEXTS = { "ERROR_MOVE_FILE": "No se pudo mover el archivo: {}", "ERROR_COPY_FILE": "No se pudo copiar el archivo: {}", "MOVED_TO": "Movido a {}", + "FS_WATCHER_TOOLTIP": "Monitor de Sistema de Archivos (monitoreando " + "directorios activos)", "COPIED_TO": "Copiado a {}", "ERROR_ROTATE_IMAGE": "No se pudo girar la imagen: {}", }, @@ -1757,6 +1772,8 @@ _UI_TEXTS = { "ERROR_MOVE_FILE": "Non se puido mover o ficheiro: {}", "ERROR_COPY_FILE": "Non se puido copiar o ficheiro: {}", "MOVED_TO": "Movido a {}", + "FS_WATCHER_TOOLTIP": "Monitor do Sistema de Ficheiros (monitoreando " + "directorios activos)", "COPIED_TO": "Copiado a {}", "ERROR_ROTATE_IMAGE": "Non se puido xirar a imaxe: {}", } diff --git a/filesystemwatcher.py b/filesystemwatcher.py new file mode 100644 index 0000000..dc70a25 --- /dev/null +++ b/filesystemwatcher.py @@ -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 diff --git a/imagescanner.py b/imagescanner.py index 03a0a6d..100fb47 100644 --- a/imagescanner.py +++ b/imagescanner.py @@ -35,8 +35,8 @@ from PySide6.QtCore import ( from PySide6.QtGui import QImage, QImageReader, QImageIOHandler from constants import ( - APP_CONFIG, CACHE_PATH, CACHE_MAX_SIZE, CONFIG_DIR, DISK_CACHE_MAX_BYTES, - HAVE_BAGHEERASEARCH_LIB, IMAGE_EXTENSIONS, + APP_CONFIG, CACHE_PATH, CACHE_MAX_SIZE, CACHE_MAX_RAM_BYTES, CONFIG_DIR, + DISK_CACHE_MAX_BYTES, HAVE_BAGHEERASEARCH_LIB, IMAGE_EXTENSIONS, SCANNER_SETTINGS_DEFAULTS, SEARCH_CMD, THUMBNAIL_SIZES, UITexts ) @@ -646,12 +646,10 @@ class ThumbnailCache(QObject): def _ensure_cache_limit(self): """Enforces cache size limit by evicting oldest entries. 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 ( 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)) cached_sizes = self._thumbnail_cache.pop(oldest_path) for img, _ in cached_sizes.values(): @@ -1752,6 +1750,10 @@ class ImageScanner(QThread): batch.append(r.result) self.count += 1 images_loaded += 1 + # Emit progress every time an image is loaded + if len(self.all_files) > 0: + percent = int((self.count / len(self.all_files)) * 100) + self.progress_percent.emit(percent) # Clean up runnables runnables.clear() @@ -1796,12 +1798,11 @@ class ImageScanner(QThread): "scan_batch_size"])) return - if self.count % 10 == 0: # Update progress less frequently - self.progress_msg.emit( - UITexts.LOADING_SCAN.format(self.count, len(self.all_files))) - if len(self.all_files) > 0: - percent = int((self.count / len(self.all_files)) * 100) - self.progress_percent.emit(percent) + # Emit progress message less frequently, e.g., every 50 images or at batch + # end + if self.count % 50 == 0 or images_loaded >= to_load: + self.progress_msg.emit(UITexts.LOADING_SCAN.format( + self.count, len(self.all_files))) self.index = len(self.all_files) if batch: diff --git a/pyproject.toml b/pyproject.toml index 473c37a..dbfc2e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,8 @@ dependencies = [ "PySide6", "lmdb", "exiv2", + "psutil", + "watchdog", "mediapipe", "face_recognition", "face_recognition_models", diff --git a/requirements.txt b/requirements.txt index eb3c416..686371a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,8 @@ PySide6 lmdb exiv2 +psutil +watchdog mediapipe face_recognition face_recognition_models diff --git a/setup.py b/setup.py index f3569fc..f9662eb 100644 --- a/setup.py +++ b/setup.py @@ -14,6 +14,8 @@ setup( "PySide6", "lmdb", "exiv2", + "psutil", + "watchdog", "mediapipe", "face_recognition", "face_recognition_models", @@ -33,6 +35,7 @@ setup( "imagescanner", "imageviewer", "imagecontroller", + "filesystemwatcher", "metadatamanager", "propertiesdialog", "thumbnailwidget",