From a717acef8785d8a560dd161b93d58ce380c3a2e1 Mon Sep 17 00:00:00 2001 From: Ignacio Serantes Date: Mon, 6 Apr 2026 20:44:49 +0200 Subject: [PATCH] Several fixes --- bagheeraview.py | 86 ++++++++++++++++++++++------------------ changelog.txt | 4 ++ constants.py | 3 ++ duplicatecache.py | 98 +++++++++++++++++++++++++--------------------- duplicatedialog.py | 43 ++++++++++++++++---- imagescanner.py | 12 +++--- pyproject.toml | 2 +- widgets.py | 1 - 8 files changed, 151 insertions(+), 98 deletions(-) diff --git a/bagheeraview.py b/bagheeraview.py index 1225520..1a0c6f1 100755 --- a/bagheeraview.py +++ b/bagheeraview.py @@ -34,9 +34,9 @@ from itertools import groupby from PySide6.QtWidgets import ( QApplication, QMainWindow, QWidget, QLabel, QVBoxLayout, QHBoxLayout, QLineEdit, QTextEdit, QPushButton, QFileDialog, QComboBox, QSlider, QMessageBox, QSizePolicy, - QMenu, QInputDialog, QTableWidget, QTableWidgetItem, QHeaderView, QTabWidget, QProgressDialog, - QDockWidget, QAbstractItemView, QRadioButton, QButtonGroup, QListView, - QStyledItemDelegate, QStyle, QDialog, QKeySequenceEdit, QDialogButtonBox + QMenu, QInputDialog, QTableWidget, QTableWidgetItem, QHeaderView, QTabWidget, + QProgressDialog, QDockWidget, QAbstractItemView, QRadioButton, QButtonGroup, + QListView, QStyledItemDelegate, QStyle, QDialog, QKeySequenceEdit, QDialogButtonBox ) from PySide6.QtGui import ( QDesktopServices, QFont, QFontMetrics, QIcon, QTransform, QImageReader, QPalette, @@ -1290,12 +1290,6 @@ class MainWindow(QMainWindow): self.history_tab = HistoryWidget(self) self.tags_tabs.addTab(self.history_tab, UITexts.HISTORY_TAB) - # Initialize the shortcut controller - self.shortcut_controller = AppShortcutController(self) - - self.favorites_tab.favorites_changed.connect( - self.shortcut_controller.refresh_favorite_shortcuts) - self.main_dock.setWidget(self.tags_tabs) self.addDockWidget(Qt.RightDockWidgetArea, self.main_dock) @@ -1758,32 +1752,36 @@ class MainWindow(QMainWindow): menu.addSeparator() - duplicates_menu = menu.addMenu(QIcon.fromTheme("edit-find-replace"), UITexts.MENU_DUPLICATES) + duplicates_menu = menu.addMenu( + QIcon.fromTheme("edit-find-replace"), UITexts.MENU_DUPLICATES) duplicates_menu.setEnabled(HAVE_IMAGEHASH) - detect_current_action = duplicates_menu.addAction(UITexts.MENU_DETECT_CURRENT_SEARCH) + detect_current_action = duplicates_menu.addAction( + UITexts.MENU_DETECT_CURRENT_SEARCH) detect_current_action.triggered.connect(self.start_duplicate_detection) detect_all_action = duplicates_menu.addAction(UITexts.MENU_DETECT_ALL) detect_all_action.triggered.connect(self.detect_all_duplicates) force_full_action = duplicates_menu.addAction(UITexts.MENU_FORCE_FULL_ANALYSIS) - force_full_action.triggered.connect(lambda: self.start_duplicate_detection(force_full=True)) + force_full_action.triggered.connect( + lambda: self.start_duplicate_detection(force_full=True)) review_ignored_action = duplicates_menu.addAction(UITexts.MENU_REVIEW_IGNORED) review_ignored_action.triggered.connect(self.review_ignored_duplicates) duplicates_menu.addSeparator() - clean_hashes_action = duplicates_menu.addAction(QIcon.fromTheme("edit-clear-all"), - UITexts.MENU_CLEAN_UP_HASHES) + clean_hashes_action = duplicates_menu.addAction( + QIcon.fromTheme("edit-clear-all"), UITexts.MENU_CLEAN_UP_HASHES) clean_hashes_action.triggered.connect(self.clean_duplicate_hashes) if self.duplicate_cache: count, size_bytes = self.duplicate_cache.get_hash_stats() size_mb = size_bytes / (1024 * 1024) - clear_hashes_action = duplicates_menu.addAction(QIcon.fromTheme("user-trash-full"), - UITexts.MENU_CLEAR_HASHES.format(count, size_mb)) + clear_hashes_action = duplicates_menu.addAction( + QIcon.fromTheme("user-trash-full"), + UITexts.MENU_CLEAR_HASHES.format(count, size_mb)) clear_hashes_action.triggered.connect(self.clear_duplicate_hashes) menu.addSeparator() @@ -1831,22 +1829,28 @@ class MainWindow(QMainWindow): QApplication.restoreOverrideCursor() if paths is None: - QMessageBox.warning(self, UITexts.WARNING, "Whitelist is empty. Please configure it in Settings.") + QMessageBox.warning( + self, UITexts.WARNING, + "Whitelist is empty. Please configure it in Settings.") return if not paths: - QMessageBox.information(self, UITexts.DUPLICATE_DETECTION_TITLE, UITexts.DUPLICATE_NONE_FOUND) + QMessageBox.information( + self, UITexts.DUPLICATE_DETECTION_TITLE, UITexts.DUPLICATE_NONE_FOUND) return - self.start_duplicate_detection(custom_paths=paths) + # Por defecto usamos el modo optimizado (incremental) para no repetir comparaciones + self.start_duplicate_detection(force_full=False, custom_paths=paths) def _gather_files_for_duplicates(self): """Helper to collect image paths based on whitelist and blacklist settings.""" whitelist_str = APP_CONFIG.get("duplicate_whitelist", "") blacklist_str = APP_CONFIG.get("duplicate_blacklist", "") - whitelist = [os.path.abspath(os.path.expanduser(p.strip())) for p in whitelist_str.split(',') if p.strip()] - blacklist = [os.path.abspath(os.path.expanduser(p.strip())) for p in blacklist_str.split(',') if p.strip()] + whitelist = [os.path.abspath(os.path.expanduser(p.strip())) + for p in whitelist_str.split(',') if p.strip()] + blacklist = [os.path.abspath(os.path.expanduser(p.strip())) + for p in blacklist_str.split(',') if p.strip()] if not whitelist: return None @@ -1861,7 +1865,8 @@ class MainWindow(QMainWindow): for root, dirs, files in os.walk(root_path): abs_root = os.path.abspath(root) # Prune dirs to stop walking into blacklisted paths - dirs[:] = [d for d in dirs if os.path.join(abs_root, d) not in blacklist_set] + dirs[:] = [d for d in dirs + if os.path.join(abs_root, d) not in blacklist_set] if abs_root in blacklist_set: continue @@ -1900,9 +1905,11 @@ class MainWindow(QMainWindow): return ignored = self.duplicate_cache.get_all_exceptions() if not ignored: - QMessageBox.information(self, UITexts.DUPLICATE_DETECTION_TITLE, UITexts.DUPLICATE_NONE_FOUND) + QMessageBox.information( + self, UITexts.DUPLICATE_DETECTION_TITLE, UITexts.DUPLICATE_NONE_FOUND) return - dialog = DuplicateManagerDialog(ignored, self.duplicate_cache, self, review_mode=True) + dialog = DuplicateManagerDialog( + ignored, self.duplicate_cache, self, review_mode=True) dialog.show() def show_about_dialog(self): @@ -2428,7 +2435,8 @@ class MainWindow(QMainWindow): confirm.setInformativeText( UITexts.CONFIRM_DELETE_INFO.format(os.path.basename(paths[0]))) else: - confirm.setText(f"Are you sure you want to permanently delete {len(paths)} images?") + confirm.setText( + f"Are you sure you want to permanently delete {len(paths)} images?") confirm.setInformativeText("This action CANNOT be undone.") confirm.setStandardButtons(QMessageBox.Yes | QMessageBox.No) confirm.setDefaultButton(QMessageBox.No) @@ -3401,7 +3409,7 @@ class MainWindow(QMainWindow): """Updates the circular progress bar value.""" self.progress_bar.setValue(value) - def on_thumbnail_loaded(self, path, size): + def on_thumbnail_loaded(self, _path, _size): """Called when a thumbnail has been loaded asynchronously from DB.""" self.thumbnail_view.viewport().update() @@ -3856,7 +3864,8 @@ class MainWindow(QMainWindow): self._setup_viewer_sync(viewer) self.viewers.append(viewer) viewer.destroyed.connect( - lambda obj=viewer: self.viewers.remove(obj) if obj in self.viewers else None) + lambda obj=viewer: self.viewers.remove(obj) + if obj in self.viewers else None) if len(paths) > 1: viewer.set_comparison_mode(len(paths)) @@ -4440,7 +4449,8 @@ class MainWindow(QMainWindow): action_open_fullscreen = open_submenu.addAction( QIcon.fromTheme("view-fullscreen"), UITexts.CONTEXT_MENU_OPEN_FULLSCREEN_VIEWER) - action_open_fullscreen.triggered.connect(lambda: self.open_in_fullscreen_viewer(selected_indexes[0])) + action_open_fullscreen.triggered.connect( + lambda: self.open_in_fullscreen_viewer(selected_indexes[0])) path = self.proxy_model.data(selected_indexes[0], PATH_ROLE) action_open_location = menu.addAction(QIcon.fromTheme("folder-search"), @@ -5089,7 +5099,8 @@ class MainWindow(QMainWindow): return # Get all image paths currently known to the application or provided list - paths_to_scan = custom_paths if custom_paths is not None else self.get_all_image_paths() + paths_to_scan = custom_paths \ + if custom_paths is not None else self.get_all_image_paths() if not paths_to_scan: QMessageBox.information(self, UITexts.DUPLICATE_DETECTION_TITLE, UITexts.DUPLICATE_NO_IMAGES) @@ -5100,11 +5111,15 @@ class MainWindow(QMainWindow): threshold = APP_CONFIG.get("duplicate_threshold", 90) self.duplicate_detector = DuplicateDetector( - paths_to_scan, self.duplicate_cache, self.thread_pool_manager, method, threshold, force_full=force_full) + paths_to_scan, self.duplicate_cache, + self.thread_pool_manager, method, threshold, force_full=force_full) - self.duplicate_detector.progress_update.connect(self.on_duplicate_detection_progress) - self.duplicate_detector.duplicates_found.connect(self.on_duplicates_found) - self.duplicate_detector.detection_finished.connect(self.on_duplicate_detection_finished) + self.duplicate_detector.progress_update.connect( + self.on_duplicate_detection_progress) + self.duplicate_detector.duplicates_found.connect( + self.on_duplicates_found) + self.duplicate_detector.detection_finished.connect( + self.on_duplicate_detection_finished) self.progress_bar.setValue(0) self.progress_bar.setCustomColor(None) @@ -5172,11 +5187,6 @@ def main(): thread_pool_manager = ThreadPoolManager() cache = ThumbnailCache() args = [a for a in sys.argv[1:] if a != "--x11"] - if args: - path = " ".join(args).strip() - if path.startswith("file:/"): - path = path[6:] - win = MainWindow(cache, args, thread_pool_manager, duplicate_cache) app.installEventFilter(win.shortcut_controller) diff --git a/changelog.txt b/changelog.txt index 20296ba..627411b 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,3 +1,7 @@ +¿Sería posible añadir una opción para limpiar automáticamente los hashes de archivos que ya no existen sin borrar toda la base de datos? + +¿Podrías optimizar el proceso de borrado en lote para que sea más eficiente si hay miles de entradas que limpiar? + Implement a bulk rename feature for the selected pet or face tags. Add a visual indicator (e.g., an icon in the status bar) for the "Link Panes" status instead of text. diff --git a/constants.py b/constants.py index fbf1cc8..cd04a01 100644 --- a/constants.py +++ b/constants.py @@ -561,6 +561,7 @@ _UI_TEXTS = { "VIEWER_MENU_LINK_PANES": "Link Panes", "DUPLICATE_OPEN_COMPARISON": "Open Comparison", "DUPLICATE_LIST_HEADER": "Duplicate Pairs", + "IGNORED_DATE": "Ignored Date", "SETTINGS_GROUP_SCANNER": "Scanner", "SETTINGS_GROUP_AREAS": "Areas", "SETTINGS_GROUP_THUMBNAILS": "Thumbnails", @@ -1080,6 +1081,7 @@ _UI_TEXTS = { "VIEWER_MENU_LINK_PANES": "Vincular Paneles", "DUPLICATE_OPEN_COMPARISON": "Abrir Comparación", "DUPLICATE_LIST_HEADER": "Parejas Duplicadas", + "IGNORED_DATE": "Fecha Ignorado", "SETTINGS_GROUP_SCANNER": "Escáner", "SETTINGS_GROUP_AREAS": "Áreas", "SETTINGS_GROUP_THUMBNAILS": "Miniaturas", @@ -1608,6 +1610,7 @@ _UI_TEXTS = { "VIEWER_MENU_LINK_PANES": "Vincular Paneis", "DUPLICATE_OPEN_COMPARISON": "Abrir Comparación", "DUPLICATE_LIST_HEADER": "Parellas Duplicadas", + "IGNORED_DATE": "Data Ignorado", "SETTINGS_GROUP_SCANNER": "Escáner", "SETTINGS_GROUP_AREAS": "Áreas", "SETTINGS_GROUP_THUMBNAILS": "Miniaturas", diff --git a/duplicatecache.py b/duplicatecache.py index b4ace7b..0e56822 100644 --- a/duplicatecache.py +++ b/duplicatecache.py @@ -24,7 +24,7 @@ from constants import ( logger = logging.getLogger(__name__) # Result structure for duplicate detection -DuplicateResult = collections.namedtuple('DuplicateResult', ['path1', 'path2', 'hash_value', 'is_exception', 'similarity']) +DuplicateResult = collections.namedtuple('DuplicateResult', ['path1', 'path2', 'hash_value', 'is_exception', 'similarity', 'timestamp']) class BKTree: @@ -241,7 +241,15 @@ class DuplicateCache(QObject): self._hash_cache[(dev_id, inode_key_bytes)] = (hash_value, mtime, path) return True - def remove_hash_for_path(self, path): + def remove_hash_for_path(self, path, clear_relationships=True): + """ + Removes the hash entry for a path. + + Args: + path: File path. + clear_relationships: If True, also wipes all entries in pending and + exceptions DBs involving this file. + """ dev_id, inode_key_bytes = self._get_inode_info(path) if not inode_key_bytes or not self._lmdb_env: return False @@ -255,8 +263,9 @@ class DuplicateCache(QObject): self._hash_cache.pop((dev_id, inode_key_bytes), None) # Also remove any exceptions involving this path - self._remove_pair_entries_for_path(dev_id, inode_key_bytes, self._exceptions_db) - self._remove_pair_entries_for_path(dev_id, inode_key_bytes, self._pending_db) + if clear_relationships: + self._remove_pair_entries_for_path(dev_id, inode_key_bytes, self._exceptions_db) + self._remove_pair_entries_for_path(dev_id, inode_key_bytes, self._pending_db) return True def _get_pair_lmdb_key_from_ids(self, dev1, inode1, dev2, inode2): @@ -271,7 +280,7 @@ class DuplicateCache(QObject): return None return self._get_pair_lmdb_key_from_ids(dev1, inode1, dev2, inode2) - def mark_as_exception(self, path1, path2, is_exception=True, similarity=None): + def mark_as_exception(self, path1, path2, is_exception=True, similarity=None, timestamp=None): if not self._lmdb_env: return False @@ -285,9 +294,8 @@ class DuplicateCache(QObject): return False # Store paths in value to make exception recovery independent of hash DB - val_str = f"{path1}|{path2}" - if similarity is not None: - val_str += f"|{similarity}" + ts = timestamp if timestamp is not None else int(time.time()) + val_str = f"{path1}|{path2}|{similarity if similarity is not None else ''}|{ts}" value = val_str.encode('utf-8') with QMutexLocker(self._db_lock): @@ -315,34 +323,35 @@ class DuplicateCache(QObject): with self._lmdb_env.begin(write=False) as txn: return txn.get(exception_key, db=self._exceptions_db) is not None - def _remove_pair_entries_for_path(self, target_dev, target_inode, db_handle): + def _remove_pair_entries_for_path(self, target_dev, target_inode, db_handle, txn=None): """Removes all entries involving a specific (dev, inode) pair from a pair-based DB.""" if not self._lmdb_env: return target_inode_hex = target_inode.hex() - with QMutexLocker(self._db_lock): - with self._lmdb_env.begin(write=True) as txn: - cursor = txn.cursor(db=db_handle) - keys_to_delete = [] - for key_bytes, _ in cursor: - key_str = key_bytes.decode('utf-8') - # Key format: "dev1-inode1_hex-dev2-inode2_hex" - parts = key_str.split('-') - dev1 = int(parts[0]) - inode1_hex = parts[1] - dev2 = int(parts[2]) - inode2_hex = parts[3] + def do_remove(t): + cursor = t.cursor(db=db_handle) + keys_to_delete = [] + for key_bytes, _ in cursor: + key_str = key_bytes.decode('utf-8') + parts = key_str.split('-') + if len(parts) < 4: continue + dev1, inode1_hex, dev2, inode2_hex = int(parts[0]), parts[1], int(parts[2]), parts[3] + if (dev1 == target_dev and inode1_hex == target_inode_hex) or \ + (dev2 == target_dev and inode2_hex == target_inode_hex): + keys_to_delete.append(key_bytes) + for key in keys_to_delete: + t.delete(key, db=db_handle) - if (dev1 == target_dev and inode1_hex == target_inode_hex) or \ - (dev2 == target_dev and inode2_hex == target_inode_hex): - keys_to_delete.append(key_bytes) + if txn: + do_remove(txn) + else: + with QMutexLocker(self._db_lock): + with self._lmdb_env.begin(write=True) as t: + do_remove(t) - for key in keys_to_delete: - txn.delete(key, db=db_handle) - - def mark_as_pending(self, path1, path2, is_pending=True, similarity=None): + def mark_as_pending(self, path1, path2, is_pending=True, similarity=None, timestamp=None): """Marks a pair as pending review.""" if not self._lmdb_env or self._pending_db is None: return False @@ -352,9 +361,8 @@ class DuplicateCache(QObject): return False # Store paths in value to allow reconstruction without scanning - val_str = f"{path1}|{path2}" - if similarity is not None: - val_str += f"|{similarity}" + ts = timestamp if timestamp is not None else int(time.time()) + val_str = f"{path1}|{path2}|{similarity if similarity is not None else ''}|{ts}" value = val_str.encode('utf-8') with QMutexLocker(self._db_lock): @@ -381,9 +389,10 @@ class DuplicateCache(QObject): try: parts = value_bytes.decode('utf-8').split('|') p1, p2 = parts[0], parts[1] - sim = int(parts[2]) if len(parts) > 2 else None + sim = int(parts[2]) if len(parts) > 2 and parts[2] else None + ts = int(parts[3]) if len(parts) > 3 else 0 if os.path.exists(p1) and os.path.exists(p2): - results.append(DuplicateResult(p1, p2, None, False, sim)) + results.append(DuplicateResult(p1, p2, None, False, sim, ts)) else: keys_to_delete.append(key) except Exception: @@ -414,6 +423,7 @@ class DuplicateCache(QObject): try: p1, p2 = None, None sim = None + ts = 0 val_str = value_bytes.decode('utf-8') if '|' in val_str: @@ -421,8 +431,12 @@ class DuplicateCache(QObject): parts = val_str.split('|') if len(parts) >= 2: p1, p2 = parts[0], parts[1] - if len(parts) > 2: + if len(parts) > 2 and parts[2]: sim = int(parts[2]) + if len(parts) > 3: + ts = int(parts[3]) + else: + ts = int(os.path.getmtime(p1)) if os.path.exists(p1) else 0 if not p1 or not p2: # Legacy format fallback: lookup paths in hash db @@ -438,7 +452,7 @@ class DuplicateCache(QObject): if p1 and p2: if os.path.exists(p1) and os.path.exists(p2): - results.append(DuplicateResult(p1, p2, None, True, sim)) + results.append(DuplicateResult(p1, p2, None, True, sim, ts)) except Exception: continue return results @@ -606,7 +620,7 @@ class DuplicateDetector(QThread): mtime = stat_info.st_mtime dev, inode = stat_info.st_dev, struct.pack('Q', stat_info.st_ino) - cached_h = None if self.force_full else \ + cached_h = \ self.duplicate_cache.get_hash_for_path(path, mtime, dev, inode) if cached_h: @@ -658,13 +672,6 @@ class DuplicateDetector(QThread): # Signal phase transition to exactly 50% self.progress_update.emit(total_files, total_files * 2, UITexts.DUPLICATE_MSG_ANALYZING.format(filename="...")) - if not self.force_full and not dirty_paths: - # No files changed and no re-scan forced. - # We can skip Phase 2 as all results were loaded from the pending cache. - self.duplicates_found.emit(found_duplicates) - self.detection_finished.emit() - return - # 3. Phase 2: Comparison (Optimized with BK-Tree) hash_map = collections.defaultdict(list) bk_tree = BKTree(lambda a, b: a - b) @@ -715,10 +722,11 @@ class DuplicateDetector(QThread): if canonical not in unique_duplicate_pairs: if not self.duplicate_cache.is_exception(p1, p2): sim = int((1.0 - (distance / MAX_DHASH_DISTANCE)) * 100) - res = DuplicateResult(p1, p2, str(h1), False, sim) + ts = int(time.time()) + res = DuplicateResult(p1, p2, str(h1), False, sim, ts) found_duplicates.append(res) unique_duplicate_pairs.add(canonical) - self.duplicate_cache.mark_as_pending(p1, p2, True, similarity=sim) + self.duplicate_cache.mark_as_pending(p1, p2, True, similarity=sim, timestamp=ts) self.duplicates_found.emit(found_duplicates) self.detection_finished.emit() diff --git a/duplicatedialog.py b/duplicatedialog.py index f8ea915..e59ead7 100644 --- a/duplicatedialog.py +++ b/duplicatedialog.py @@ -1,4 +1,5 @@ import os +from datetime import datetime from PySide6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QFrame, QSplitter, QWidget, QMessageBox, QApplication, QMenu, @@ -56,10 +57,18 @@ class DuplicateManagerDialog(QDialog): left_layout.addLayout(header_layout) self.table_widget = QTableWidget() - self.table_widget.setColumnCount(2) - self.table_widget.setHorizontalHeaderLabels(["%", UITexts.CONTEXT_MENU_OPEN]) # Usamos una cadena existente o genérica - self.table_widget.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents) - self.table_widget.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch) + if self.review_mode: + self.table_widget.setColumnCount(3) + self.table_widget.setHorizontalHeaderLabels([UITexts.IGNORED_DATE, "%", UITexts.CONTEXT_MENU_OPEN]) + self.table_widget.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents) + self.table_widget.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents) + self.table_widget.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch) + else: + self.table_widget.setColumnCount(2) + self.table_widget.setHorizontalHeaderLabels(["%", UITexts.CONTEXT_MENU_OPEN]) # Usamos una cadena existente o genérica + self.table_widget.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents) + self.table_widget.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch) + self.table_widget.verticalHeader().setVisible(False) self.table_widget.setSelectionBehavior(QAbstractItemView.SelectRows) self.table_widget.setSelectionMode(QAbstractItemView.SingleSelection) @@ -311,17 +320,30 @@ class DuplicateManagerDialog(QDialog): row = self.table_widget.rowCount() self.table_widget.insertRow(row) - # Columna 0: Porcentaje (usamos DisplayRole con int para que ordene numéricamente) + if self.review_mode: + # Column 0: Ignored Date + ts = dup.timestamp if hasattr(dup, 'timestamp') and dup.timestamp else 0 + date_str = datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M") if ts else "-" + date_item = QTableWidgetItem(date_str) + date_item.setData(Qt.UserRole, i) # Store original index here for _load_pair + date_item.setTextAlignment(Qt.AlignCenter) + self.table_widget.setItem(row, 0, date_item) + col_offset = 1 + else: + col_offset = 0 + + # Columna similarity (usamos DisplayRole con int para que ordene numéricamente) sim_item = QTableWidgetItem() sim_item.setData(Qt.DisplayRole, dup.similarity if dup.similarity is not None else 0) sim_item.setTextAlignment(Qt.AlignCenter) - sim_item.setData(Qt.UserRole, i) # Guardamos el índice original en la lista duplicates + if not self.review_mode: + sim_item.setData(Qt.UserRole, i) # Guardamos el índice original en la lista duplicates # Columna 1: Nombres de ficheros names_item = QTableWidgetItem(f"{name1} ↔ {name2}") - self.table_widget.setItem(row, 0, sim_item) - self.table_widget.setItem(row, 1, names_item) + self.table_widget.setItem(row, col_offset, sim_item) + self.table_widget.setItem(row, col_offset + 1, names_item) self.counter_lbl.setText(str(len(self.duplicates))) self.table_widget.blockSignals(False) @@ -661,6 +683,11 @@ class DuplicateManagerDialog(QDialog): def _skip(self): if self.review_mode and self.current_dup_pair: self.cache.mark_as_exception(self.current_dup_pair.path1, self.current_dup_pair.path2, False) + # Borramos los hashes para que el detector las trate como imágenes nuevas + # y fuerce una nueva comparación en el siguiente escaneo. + # Usamos clear_relationships=False para no perder otras posibles coincidencias ya marcadas. + self.cache.remove_hash_for_path(self.current_dup_pair.path1, clear_relationships=False) + self.cache.remove_hash_for_path(self.current_dup_pair.path2, clear_relationships=False) self._handle_action(skip=False, permanent=False) else: self._handle_action(skip=True) diff --git a/imagescanner.py b/imagescanner.py index d72bf38..a5bf30f 100644 --- a/imagescanner.py +++ b/imagescanner.py @@ -286,7 +286,7 @@ class CacheWriter(QThread): self._condition_new_data = QWaitCondition() self._condition_space_available = QWaitCondition() # Soft limit for blocking producers (background threads) - self.setObjectName("CacheWriterThread") # Add this line + self.setObjectName("CacheWriterThread") # Add this line self._max_size = 50 self._running = True @@ -758,7 +758,8 @@ class ThumbnailCache(QObject): self._broken_cache[key] = (mtime, error_msg) def get_broken_info(self, path, size, mtime, inode, dev_id): - """Returns the error message if a thumbnail is known to have failed, else None.""" + """Returns the error message if a thumbnail is known to have failed, else + None.""" key = (dev_id, struct.pack('Q', inode) if inode is not None else None, size) with self._read_lock(): info = self._broken_cache.get(key) @@ -889,7 +890,8 @@ class ThumbnailCache(QObject): # Check if known to be broken broken_msg = self.get_broken_info(path, target_tier, mtime, inode, device_id) if broken_msg: - return ThumbnailResult(self._broken_images.get(target_tier), mtime, target_tier) + return ThumbnailResult( + self._broken_images.get(target_tier), mtime, target_tier) best_img, best_mtime, best_tier = None, 0, 0 @@ -1488,7 +1490,6 @@ class ImageScanner(QThread): def __init__(self, cache, paths, is_file_list=False, viewers=None, thread_pool_manager=None, target_sizes=None): - # is_file_list is not used if not paths or not isinstance(paths, (list, tuple)): logger.warning("ImageScanner initialized with empty or invalid paths") paths = [] @@ -1851,7 +1852,8 @@ class ImageScanner(QThread): return for f_path, _ in tasks: - r = ScannerWorker(self.cache, f_path, semaphore=sem, target_sizes=self.target_sizes) + r = ScannerWorker( + self.cache, f_path, semaphore=sem, target_sizes=self.target_sizes) r.setAutoDelete(False) runnables.append(r) self._current_workers.append(r) diff --git a/pyproject.toml b/pyproject.toml index d32f23e..9d5de0c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "bagheeraview" -version = "0.9.16" +version = "0.9.17" authors = [ { name = "Ignacio Serantes" } ] diff --git a/widgets.py b/widgets.py index 2011cb0..05d5a78 100644 --- a/widgets.py +++ b/widgets.py @@ -649,7 +649,6 @@ class LayoutsWidget(QWidget): item_name = QTableWidgetItem(name) item_name.setData(Qt.UserRole, f_path) - item_name.setData(Qt.UserRole, f_path) # Store full path in item item_date = QTableWidgetItem(dt) self.table.setItem(i, 0, item_name)