Several fixes

This commit is contained in:
Ignacio Serantes
2026-04-06 20:44:49 +02:00
parent ca260d4219
commit a717acef87
8 changed files with 151 additions and 98 deletions

View File

@@ -34,9 +34,9 @@ from itertools import groupby
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication, QMainWindow, QWidget, QLabel, QVBoxLayout, QHBoxLayout, QLineEdit, QApplication, QMainWindow, QWidget, QLabel, QVBoxLayout, QHBoxLayout, QLineEdit,
QTextEdit, QPushButton, QFileDialog, QComboBox, QSlider, QMessageBox, QSizePolicy, QTextEdit, QPushButton, QFileDialog, QComboBox, QSlider, QMessageBox, QSizePolicy,
QMenu, QInputDialog, QTableWidget, QTableWidgetItem, QHeaderView, QTabWidget, QProgressDialog, QMenu, QInputDialog, QTableWidget, QTableWidgetItem, QHeaderView, QTabWidget,
QDockWidget, QAbstractItemView, QRadioButton, QButtonGroup, QListView, QProgressDialog, QDockWidget, QAbstractItemView, QRadioButton, QButtonGroup,
QStyledItemDelegate, QStyle, QDialog, QKeySequenceEdit, QDialogButtonBox QListView, QStyledItemDelegate, QStyle, QDialog, QKeySequenceEdit, QDialogButtonBox
) )
from PySide6.QtGui import ( from PySide6.QtGui import (
QDesktopServices, QFont, QFontMetrics, QIcon, QTransform, QImageReader, QPalette, QDesktopServices, QFont, QFontMetrics, QIcon, QTransform, QImageReader, QPalette,
@@ -1290,12 +1290,6 @@ class MainWindow(QMainWindow):
self.history_tab = HistoryWidget(self) self.history_tab = HistoryWidget(self)
self.tags_tabs.addTab(self.history_tab, UITexts.HISTORY_TAB) 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.main_dock.setWidget(self.tags_tabs)
self.addDockWidget(Qt.RightDockWidgetArea, self.main_dock) self.addDockWidget(Qt.RightDockWidgetArea, self.main_dock)
@@ -1758,32 +1752,36 @@ class MainWindow(QMainWindow):
menu.addSeparator() 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) 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_current_action.triggered.connect(self.start_duplicate_detection)
detect_all_action = duplicates_menu.addAction(UITexts.MENU_DETECT_ALL) detect_all_action = duplicates_menu.addAction(UITexts.MENU_DETECT_ALL)
detect_all_action.triggered.connect(self.detect_all_duplicates) detect_all_action.triggered.connect(self.detect_all_duplicates)
force_full_action = duplicates_menu.addAction(UITexts.MENU_FORCE_FULL_ANALYSIS) 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 = duplicates_menu.addAction(UITexts.MENU_REVIEW_IGNORED)
review_ignored_action.triggered.connect(self.review_ignored_duplicates) review_ignored_action.triggered.connect(self.review_ignored_duplicates)
duplicates_menu.addSeparator() duplicates_menu.addSeparator()
clean_hashes_action = duplicates_menu.addAction(QIcon.fromTheme("edit-clear-all"), clean_hashes_action = duplicates_menu.addAction(
UITexts.MENU_CLEAN_UP_HASHES) QIcon.fromTheme("edit-clear-all"), UITexts.MENU_CLEAN_UP_HASHES)
clean_hashes_action.triggered.connect(self.clean_duplicate_hashes) clean_hashes_action.triggered.connect(self.clean_duplicate_hashes)
if self.duplicate_cache: if self.duplicate_cache:
count, size_bytes = self.duplicate_cache.get_hash_stats() count, size_bytes = self.duplicate_cache.get_hash_stats()
size_mb = size_bytes / (1024 * 1024) size_mb = size_bytes / (1024 * 1024)
clear_hashes_action = duplicates_menu.addAction(QIcon.fromTheme("user-trash-full"), clear_hashes_action = duplicates_menu.addAction(
UITexts.MENU_CLEAR_HASHES.format(count, size_mb)) QIcon.fromTheme("user-trash-full"),
UITexts.MENU_CLEAR_HASHES.format(count, size_mb))
clear_hashes_action.triggered.connect(self.clear_duplicate_hashes) clear_hashes_action.triggered.connect(self.clear_duplicate_hashes)
menu.addSeparator() menu.addSeparator()
@@ -1831,22 +1829,28 @@ class MainWindow(QMainWindow):
QApplication.restoreOverrideCursor() QApplication.restoreOverrideCursor()
if paths is None: 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 return
if not paths: 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 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): def _gather_files_for_duplicates(self):
"""Helper to collect image paths based on whitelist and blacklist settings.""" """Helper to collect image paths based on whitelist and blacklist settings."""
whitelist_str = APP_CONFIG.get("duplicate_whitelist", "") whitelist_str = APP_CONFIG.get("duplicate_whitelist", "")
blacklist_str = APP_CONFIG.get("duplicate_blacklist", "") 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()] whitelist = [os.path.abspath(os.path.expanduser(p.strip()))
blacklist = [os.path.abspath(os.path.expanduser(p.strip())) for p in blacklist_str.split(',') if 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: if not whitelist:
return None return None
@@ -1861,7 +1865,8 @@ class MainWindow(QMainWindow):
for root, dirs, files in os.walk(root_path): for root, dirs, files in os.walk(root_path):
abs_root = os.path.abspath(root) abs_root = os.path.abspath(root)
# Prune dirs to stop walking into blacklisted paths # 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: if abs_root in blacklist_set:
continue continue
@@ -1900,9 +1905,11 @@ class MainWindow(QMainWindow):
return return
ignored = self.duplicate_cache.get_all_exceptions() ignored = self.duplicate_cache.get_all_exceptions()
if not ignored: 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 return
dialog = DuplicateManagerDialog(ignored, self.duplicate_cache, self, review_mode=True) dialog = DuplicateManagerDialog(
ignored, self.duplicate_cache, self, review_mode=True)
dialog.show() dialog.show()
def show_about_dialog(self): def show_about_dialog(self):
@@ -2428,7 +2435,8 @@ class MainWindow(QMainWindow):
confirm.setInformativeText( confirm.setInformativeText(
UITexts.CONFIRM_DELETE_INFO.format(os.path.basename(paths[0]))) UITexts.CONFIRM_DELETE_INFO.format(os.path.basename(paths[0])))
else: 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.setInformativeText("This action CANNOT be undone.")
confirm.setStandardButtons(QMessageBox.Yes | QMessageBox.No) confirm.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
confirm.setDefaultButton(QMessageBox.No) confirm.setDefaultButton(QMessageBox.No)
@@ -3401,7 +3409,7 @@ class MainWindow(QMainWindow):
"""Updates the circular progress bar value.""" """Updates the circular progress bar value."""
self.progress_bar.setValue(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.""" """Called when a thumbnail has been loaded asynchronously from DB."""
self.thumbnail_view.viewport().update() self.thumbnail_view.viewport().update()
@@ -3856,7 +3864,8 @@ class MainWindow(QMainWindow):
self._setup_viewer_sync(viewer) self._setup_viewer_sync(viewer)
self.viewers.append(viewer) self.viewers.append(viewer)
viewer.destroyed.connect( 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: if len(paths) > 1:
viewer.set_comparison_mode(len(paths)) viewer.set_comparison_mode(len(paths))
@@ -4440,7 +4449,8 @@ class MainWindow(QMainWindow):
action_open_fullscreen = open_submenu.addAction( action_open_fullscreen = open_submenu.addAction(
QIcon.fromTheme("view-fullscreen"), QIcon.fromTheme("view-fullscreen"),
UITexts.CONTEXT_MENU_OPEN_FULLSCREEN_VIEWER) 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) path = self.proxy_model.data(selected_indexes[0], PATH_ROLE)
action_open_location = menu.addAction(QIcon.fromTheme("folder-search"), action_open_location = menu.addAction(QIcon.fromTheme("folder-search"),
@@ -5089,7 +5099,8 @@ class MainWindow(QMainWindow):
return return
# Get all image paths currently known to the application or provided list # 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: if not paths_to_scan:
QMessageBox.information(self, UITexts.DUPLICATE_DETECTION_TITLE, QMessageBox.information(self, UITexts.DUPLICATE_DETECTION_TITLE,
UITexts.DUPLICATE_NO_IMAGES) UITexts.DUPLICATE_NO_IMAGES)
@@ -5100,11 +5111,15 @@ class MainWindow(QMainWindow):
threshold = APP_CONFIG.get("duplicate_threshold", 90) threshold = APP_CONFIG.get("duplicate_threshold", 90)
self.duplicate_detector = DuplicateDetector( 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.progress_update.connect(
self.duplicate_detector.duplicates_found.connect(self.on_duplicates_found) self.on_duplicate_detection_progress)
self.duplicate_detector.detection_finished.connect(self.on_duplicate_detection_finished) 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.setValue(0)
self.progress_bar.setCustomColor(None) self.progress_bar.setCustomColor(None)
@@ -5172,11 +5187,6 @@ def main():
thread_pool_manager = ThreadPoolManager() thread_pool_manager = ThreadPoolManager()
cache = ThumbnailCache() cache = ThumbnailCache()
args = [a for a in sys.argv[1:] if a != "--x11"] 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) win = MainWindow(cache, args, thread_pool_manager, duplicate_cache)
app.installEventFilter(win.shortcut_controller) app.installEventFilter(win.shortcut_controller)

View File

@@ -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. 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. Add a visual indicator (e.g., an icon in the status bar) for the "Link Panes" status instead of text.

View File

@@ -561,6 +561,7 @@ _UI_TEXTS = {
"VIEWER_MENU_LINK_PANES": "Link Panes", "VIEWER_MENU_LINK_PANES": "Link Panes",
"DUPLICATE_OPEN_COMPARISON": "Open Comparison", "DUPLICATE_OPEN_COMPARISON": "Open Comparison",
"DUPLICATE_LIST_HEADER": "Duplicate Pairs", "DUPLICATE_LIST_HEADER": "Duplicate Pairs",
"IGNORED_DATE": "Ignored Date",
"SETTINGS_GROUP_SCANNER": "Scanner", "SETTINGS_GROUP_SCANNER": "Scanner",
"SETTINGS_GROUP_AREAS": "Areas", "SETTINGS_GROUP_AREAS": "Areas",
"SETTINGS_GROUP_THUMBNAILS": "Thumbnails", "SETTINGS_GROUP_THUMBNAILS": "Thumbnails",
@@ -1080,6 +1081,7 @@ _UI_TEXTS = {
"VIEWER_MENU_LINK_PANES": "Vincular Paneles", "VIEWER_MENU_LINK_PANES": "Vincular Paneles",
"DUPLICATE_OPEN_COMPARISON": "Abrir Comparación", "DUPLICATE_OPEN_COMPARISON": "Abrir Comparación",
"DUPLICATE_LIST_HEADER": "Parejas Duplicadas", "DUPLICATE_LIST_HEADER": "Parejas Duplicadas",
"IGNORED_DATE": "Fecha Ignorado",
"SETTINGS_GROUP_SCANNER": "Escáner", "SETTINGS_GROUP_SCANNER": "Escáner",
"SETTINGS_GROUP_AREAS": "Áreas", "SETTINGS_GROUP_AREAS": "Áreas",
"SETTINGS_GROUP_THUMBNAILS": "Miniaturas", "SETTINGS_GROUP_THUMBNAILS": "Miniaturas",
@@ -1608,6 +1610,7 @@ _UI_TEXTS = {
"VIEWER_MENU_LINK_PANES": "Vincular Paneis", "VIEWER_MENU_LINK_PANES": "Vincular Paneis",
"DUPLICATE_OPEN_COMPARISON": "Abrir Comparación", "DUPLICATE_OPEN_COMPARISON": "Abrir Comparación",
"DUPLICATE_LIST_HEADER": "Parellas Duplicadas", "DUPLICATE_LIST_HEADER": "Parellas Duplicadas",
"IGNORED_DATE": "Data Ignorado",
"SETTINGS_GROUP_SCANNER": "Escáner", "SETTINGS_GROUP_SCANNER": "Escáner",
"SETTINGS_GROUP_AREAS": "Áreas", "SETTINGS_GROUP_AREAS": "Áreas",
"SETTINGS_GROUP_THUMBNAILS": "Miniaturas", "SETTINGS_GROUP_THUMBNAILS": "Miniaturas",

View File

@@ -24,7 +24,7 @@ from constants import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Result structure for duplicate detection # 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: class BKTree:
@@ -241,7 +241,15 @@ class DuplicateCache(QObject):
self._hash_cache[(dev_id, inode_key_bytes)] = (hash_value, mtime, path) self._hash_cache[(dev_id, inode_key_bytes)] = (hash_value, mtime, path)
return True 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) dev_id, inode_key_bytes = self._get_inode_info(path)
if not inode_key_bytes or not self._lmdb_env: if not inode_key_bytes or not self._lmdb_env:
return False return False
@@ -255,8 +263,9 @@ class DuplicateCache(QObject):
self._hash_cache.pop((dev_id, inode_key_bytes), None) self._hash_cache.pop((dev_id, inode_key_bytes), None)
# Also remove any exceptions involving this path # Also remove any exceptions involving this path
self._remove_pair_entries_for_path(dev_id, inode_key_bytes, self._exceptions_db) if clear_relationships:
self._remove_pair_entries_for_path(dev_id, inode_key_bytes, self._pending_db) 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 return True
def _get_pair_lmdb_key_from_ids(self, dev1, inode1, dev2, inode2): def _get_pair_lmdb_key_from_ids(self, dev1, inode1, dev2, inode2):
@@ -271,7 +280,7 @@ class DuplicateCache(QObject):
return None return None
return self._get_pair_lmdb_key_from_ids(dev1, inode1, dev2, inode2) 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: if not self._lmdb_env:
return False return False
@@ -285,9 +294,8 @@ class DuplicateCache(QObject):
return False return False
# Store paths in value to make exception recovery independent of hash DB # Store paths in value to make exception recovery independent of hash DB
val_str = f"{path1}|{path2}" ts = timestamp if timestamp is not None else int(time.time())
if similarity is not None: val_str = f"{path1}|{path2}|{similarity if similarity is not None else ''}|{ts}"
val_str += f"|{similarity}"
value = val_str.encode('utf-8') value = val_str.encode('utf-8')
with QMutexLocker(self._db_lock): with QMutexLocker(self._db_lock):
@@ -315,34 +323,35 @@ class DuplicateCache(QObject):
with self._lmdb_env.begin(write=False) as txn: with self._lmdb_env.begin(write=False) as txn:
return txn.get(exception_key, db=self._exceptions_db) is not None 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.""" """Removes all entries involving a specific (dev, inode) pair from a pair-based DB."""
if not self._lmdb_env: if not self._lmdb_env:
return return
target_inode_hex = target_inode.hex() 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]) def do_remove(t):
inode1_hex = parts[1] cursor = t.cursor(db=db_handle)
dev2 = int(parts[2]) keys_to_delete = []
inode2_hex = parts[3] 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 \ if txn:
(dev2 == target_dev and inode2_hex == target_inode_hex): do_remove(txn)
keys_to_delete.append(key_bytes) else:
with QMutexLocker(self._db_lock):
with self._lmdb_env.begin(write=True) as t:
do_remove(t)
for key in keys_to_delete: def mark_as_pending(self, path1, path2, is_pending=True, similarity=None, timestamp=None):
txn.delete(key, db=db_handle)
def mark_as_pending(self, path1, path2, is_pending=True, similarity=None):
"""Marks a pair as pending review.""" """Marks a pair as pending review."""
if not self._lmdb_env or self._pending_db is None: if not self._lmdb_env or self._pending_db is None:
return False return False
@@ -352,9 +361,8 @@ class DuplicateCache(QObject):
return False return False
# Store paths in value to allow reconstruction without scanning # Store paths in value to allow reconstruction without scanning
val_str = f"{path1}|{path2}" ts = timestamp if timestamp is not None else int(time.time())
if similarity is not None: val_str = f"{path1}|{path2}|{similarity if similarity is not None else ''}|{ts}"
val_str += f"|{similarity}"
value = val_str.encode('utf-8') value = val_str.encode('utf-8')
with QMutexLocker(self._db_lock): with QMutexLocker(self._db_lock):
@@ -381,9 +389,10 @@ class DuplicateCache(QObject):
try: try:
parts = value_bytes.decode('utf-8').split('|') parts = value_bytes.decode('utf-8').split('|')
p1, p2 = parts[0], parts[1] 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): 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: else:
keys_to_delete.append(key) keys_to_delete.append(key)
except Exception: except Exception:
@@ -414,6 +423,7 @@ class DuplicateCache(QObject):
try: try:
p1, p2 = None, None p1, p2 = None, None
sim = None sim = None
ts = 0
val_str = value_bytes.decode('utf-8') val_str = value_bytes.decode('utf-8')
if '|' in val_str: if '|' in val_str:
@@ -421,8 +431,12 @@ class DuplicateCache(QObject):
parts = val_str.split('|') parts = val_str.split('|')
if len(parts) >= 2: if len(parts) >= 2:
p1, p2 = parts[0], parts[1] p1, p2 = parts[0], parts[1]
if len(parts) > 2: if len(parts) > 2 and parts[2]:
sim = int(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: if not p1 or not p2:
# Legacy format fallback: lookup paths in hash db # Legacy format fallback: lookup paths in hash db
@@ -438,7 +452,7 @@ class DuplicateCache(QObject):
if p1 and p2: if p1 and p2:
if os.path.exists(p1) and os.path.exists(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: except Exception:
continue continue
return results return results
@@ -606,7 +620,7 @@ class DuplicateDetector(QThread):
mtime = stat_info.st_mtime mtime = stat_info.st_mtime
dev, inode = stat_info.st_dev, struct.pack('Q', stat_info.st_ino) 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) self.duplicate_cache.get_hash_for_path(path, mtime, dev, inode)
if cached_h: if cached_h:
@@ -658,13 +672,6 @@ class DuplicateDetector(QThread):
# Signal phase transition to exactly 50% # Signal phase transition to exactly 50%
self.progress_update.emit(total_files, total_files * 2, UITexts.DUPLICATE_MSG_ANALYZING.format(filename="...")) 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) # 3. Phase 2: Comparison (Optimized with BK-Tree)
hash_map = collections.defaultdict(list) hash_map = collections.defaultdict(list)
bk_tree = BKTree(lambda a, b: a - b) bk_tree = BKTree(lambda a, b: a - b)
@@ -715,10 +722,11 @@ class DuplicateDetector(QThread):
if canonical not in unique_duplicate_pairs: if canonical not in unique_duplicate_pairs:
if not self.duplicate_cache.is_exception(p1, p2): if not self.duplicate_cache.is_exception(p1, p2):
sim = int((1.0 - (distance / MAX_DHASH_DISTANCE)) * 100) 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) found_duplicates.append(res)
unique_duplicate_pairs.add(canonical) 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.duplicates_found.emit(found_duplicates)
self.detection_finished.emit() self.detection_finished.emit()

View File

@@ -1,4 +1,5 @@
import os import os
from datetime import datetime
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QFrame, QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QFrame,
QSplitter, QWidget, QMessageBox, QApplication, QMenu, QSplitter, QWidget, QMessageBox, QApplication, QMenu,
@@ -56,10 +57,18 @@ class DuplicateManagerDialog(QDialog):
left_layout.addLayout(header_layout) left_layout.addLayout(header_layout)
self.table_widget = QTableWidget() self.table_widget = QTableWidget()
self.table_widget.setColumnCount(2) if self.review_mode:
self.table_widget.setHorizontalHeaderLabels(["%", UITexts.CONTEXT_MENU_OPEN]) # Usamos una cadena existente o genérica self.table_widget.setColumnCount(3)
self.table_widget.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents) self.table_widget.setHorizontalHeaderLabels([UITexts.IGNORED_DATE, "%", UITexts.CONTEXT_MENU_OPEN])
self.table_widget.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch) 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.verticalHeader().setVisible(False)
self.table_widget.setSelectionBehavior(QAbstractItemView.SelectRows) self.table_widget.setSelectionBehavior(QAbstractItemView.SelectRows)
self.table_widget.setSelectionMode(QAbstractItemView.SingleSelection) self.table_widget.setSelectionMode(QAbstractItemView.SingleSelection)
@@ -311,17 +320,30 @@ class DuplicateManagerDialog(QDialog):
row = self.table_widget.rowCount() row = self.table_widget.rowCount()
self.table_widget.insertRow(row) 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 = QTableWidgetItem()
sim_item.setData(Qt.DisplayRole, dup.similarity if dup.similarity is not None else 0) sim_item.setData(Qt.DisplayRole, dup.similarity if dup.similarity is not None else 0)
sim_item.setTextAlignment(Qt.AlignCenter) 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 # Columna 1: Nombres de ficheros
names_item = QTableWidgetItem(f"{name1}{name2}") names_item = QTableWidgetItem(f"{name1}{name2}")
self.table_widget.setItem(row, 0, sim_item) self.table_widget.setItem(row, col_offset, sim_item)
self.table_widget.setItem(row, 1, names_item) self.table_widget.setItem(row, col_offset + 1, names_item)
self.counter_lbl.setText(str(len(self.duplicates))) self.counter_lbl.setText(str(len(self.duplicates)))
self.table_widget.blockSignals(False) self.table_widget.blockSignals(False)
@@ -661,6 +683,11 @@ class DuplicateManagerDialog(QDialog):
def _skip(self): def _skip(self):
if self.review_mode and self.current_dup_pair: 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) 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) self._handle_action(skip=False, permanent=False)
else: else:
self._handle_action(skip=True) self._handle_action(skip=True)

View File

@@ -286,7 +286,7 @@ class CacheWriter(QThread):
self._condition_new_data = QWaitCondition() self._condition_new_data = QWaitCondition()
self._condition_space_available = QWaitCondition() self._condition_space_available = QWaitCondition()
# Soft limit for blocking producers (background threads) # Soft limit for blocking producers (background threads)
self.setObjectName("CacheWriterThread") # Add this line self.setObjectName("CacheWriterThread") # Add this line
self._max_size = 50 self._max_size = 50
self._running = True self._running = True
@@ -758,7 +758,8 @@ class ThumbnailCache(QObject):
self._broken_cache[key] = (mtime, error_msg) self._broken_cache[key] = (mtime, error_msg)
def get_broken_info(self, path, size, mtime, inode, dev_id): 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) key = (dev_id, struct.pack('Q', inode) if inode is not None else None, size)
with self._read_lock(): with self._read_lock():
info = self._broken_cache.get(key) info = self._broken_cache.get(key)
@@ -889,7 +890,8 @@ class ThumbnailCache(QObject):
# Check if known to be broken # Check if known to be broken
broken_msg = self.get_broken_info(path, target_tier, mtime, inode, device_id) broken_msg = self.get_broken_info(path, target_tier, mtime, inode, device_id)
if broken_msg: 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 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, def __init__(self, cache, paths, is_file_list=False, viewers=None,
thread_pool_manager=None, target_sizes=None): thread_pool_manager=None, target_sizes=None):
# is_file_list is not used
if not paths or not isinstance(paths, (list, tuple)): if not paths or not isinstance(paths, (list, tuple)):
logger.warning("ImageScanner initialized with empty or invalid paths") logger.warning("ImageScanner initialized with empty or invalid paths")
paths = [] paths = []
@@ -1851,7 +1852,8 @@ class ImageScanner(QThread):
return return
for f_path, _ in tasks: 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) r.setAutoDelete(False)
runnables.append(r) runnables.append(r)
self._current_workers.append(r) self._current_workers.append(r)

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "bagheeraview" name = "bagheeraview"
version = "0.9.16" version = "0.9.17"
authors = [ authors = [
{ name = "Ignacio Serantes" } { name = "Ignacio Serantes" }
] ]

View File

@@ -649,7 +649,6 @@ class LayoutsWidget(QWidget):
item_name = QTableWidgetItem(name) item_name = QTableWidgetItem(name)
item_name.setData(Qt.UserRole, f_path) item_name.setData(Qt.UserRole, f_path)
item_name.setData(Qt.UserRole, f_path) # Store full path in item
item_date = QTableWidgetItem(dt) item_date = QTableWidgetItem(dt)
self.table.setItem(i, 0, item_name) self.table.setItem(i, 0, item_name)