Fixed hang with gifs in duplicates form

This commit is contained in:
Ignacio Serantes
2026-04-06 23:20:27 +02:00
parent 45c95c1bb1
commit 964974431c
6 changed files with 298 additions and 143 deletions

View File

@@ -1037,7 +1037,7 @@ class MainWindow(QMainWindow):
self._group_info_cache = {}
self._visible_paths_cache = None # Cache for visible image paths
self._path_to_model_index = {}
self._paths_being_modified_by_app = set() # For ignoring FS events
self._paths_being_modified_by_app = set() # For ignoring FS events
# Keep references to open viewers to manage their lifecycle
self.viewers = []
@@ -1844,7 +1844,8 @@ class MainWindow(QMainWindow):
self, UITexts.DUPLICATE_DETECTION_TITLE, UITexts.DUPLICATE_NONE_FOUND)
return
# Por defecto usamos el modo optimizado (incremental) para no repetir comparaciones
# 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):
@@ -4879,24 +4880,28 @@ class MainWindow(QMainWindow):
new_size = new_stat.st_size
# Find old data from internal list
old_item_data = next((item for item in self.found_items_data if item[0] == path), None)
old_item_data = next((item for item in self.found_items_data
if item[0] == path), None)
old_mtime = old_item_data[2] if old_item_data else 0
old_size = os.path.getsize(path) if old_item_data else 0 # Re-read size from disk for comparison
# Re-read size from disk for comparison
old_size = os.path.getsize(path) if old_item_data else 0
if new_size == old_size and new_mtime != old_mtime:
# Likely metadata-only change (size unchanged, mtime changed)
res = load_common_metadata(path)
self._update_internal_data(path, mtime=new_mtime, tags=res.tags, rating=res.rating,
inode=new_stat.st_ino, dev=new_stat.st_dev)
self._update_internal_data(
path, mtime=new_mtime, tags=res.tags, rating=res.rating,
inode=new_stat.st_ino, dev=new_stat.st_dev)
self.proxy_model.add_to_cache(path, res.tags)
self.thumbnail_view.viewport().update() # Force repaint
self.thumbnail_view.viewport().update() # Force repaint
self.status_lbl.setText(f"Metadata updated: {os.path.basename(path)}")
else:
# Content or size changed, invalidate thumbnail and rebuild view
self.cache.invalidate_path(path)
res = load_common_metadata(path) # Re-read metadata as well
self._update_internal_data(path, mtime=new_mtime, tags=res.tags, rating=res.rating,
inode=new_stat.st_ino, dev=new_stat.st_dev)
res = load_common_metadata(path) # Re-read metadata as well
self._update_internal_data(
path, mtime=new_mtime, tags=res.tags, rating=res.rating,
inode=new_stat.st_ino, dev=new_stat.st_dev)
self.proxy_model.add_to_cache(path, res.tags)
self.rebuild_view()
self.status_lbl.setText(f"File modified: {os.path.basename(path)}")
@@ -4912,8 +4917,10 @@ class MainWindow(QMainWindow):
self._paths_being_modified_by_app.add(parent_path)
# Schedule removal after a delay to allow all FS events to propagate
QTimer.singleShot(1000, lambda: self._paths_being_modified_by_app.discard(abs_path))
QTimer.singleShot(1000, lambda: self._paths_being_modified_by_app.discard(parent_path))
QTimer.singleShot(
1000, lambda: self._paths_being_modified_by_app.discard(abs_path))
QTimer.singleShot(
1000, lambda: self._paths_being_modified_by_app.discard(parent_path))
def on_fs_watcher_status_changed(self, is_monitoring):
"""Updates the UI indicator for the FileSystemWatcher."""

View File

@@ -24,11 +24,14 @@ from constants import (
logger = logging.getLogger(__name__)
# Result structure for duplicate detection
DuplicateResult = collections.namedtuple('DuplicateResult', ['path1', 'path2', 'hash_value', 'is_exception', 'similarity', 'timestamp'])
DuplicateResult = collections.namedtuple(
'DuplicateResult',
['path1', 'path2', 'hash_value', 'is_exception', 'similarity', 'timestamp'])
class BKTree:
"""A Burkhard-Keller tree for efficient similarity searching using Hamming distance."""
"""A Burkhard-Keller tree for efficient similarity searching using Hamming
distance."""
def __init__(self, distance_func):
self.distance_func = distance_func
self.tree = None
@@ -210,7 +213,8 @@ class DuplicateCache(QObject):
return None, 0, None
with QWriteLocker(self._hash_cache_lock):
self._hash_cache[(dev_id, inode_key_bytes)] = (hash_str, mtime, path_str)
self._hash_cache[(dev_id, inode_key_bytes)] = (
hash_str, mtime, path_str)
return hash_str, mtime, path_str
return None, 0, None
@@ -225,7 +229,8 @@ class DuplicateCache(QObject):
return hash_value
return None
def add_hash_for_path(self, path, hash_value, mtime, dev_id=None, inode_key_bytes=None):
def add_hash_for_path(self,
path, hash_value, mtime, dev_id=None, inode_key_bytes=None):
if dev_id is None or inode_key_bytes is None:
dev_id, inode_key_bytes = self._get_inode_info(path)
if not inode_key_bytes or not self._lmdb_env:
@@ -264,8 +269,10 @@ class DuplicateCache(QObject):
# Also remove any exceptions involving this path
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)
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):
@@ -280,7 +287,9 @@ 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, timestamp=None):
def mark_as_exception(self,
path1, path2, is_exception=True, similarity=None,
timestamp=None):
if not self._lmdb_env:
return False
@@ -323,8 +332,10 @@ 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, txn=None):
"""Removes all entries involving a specific (dev, inode) pair from a pair-based DB."""
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
@@ -336,8 +347,10 @@ class DuplicateCache(QObject):
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 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)
@@ -351,7 +364,8 @@ class DuplicateCache(QObject):
with self._lmdb_env.begin(write=True) as t:
do_remove(t)
def mark_as_pending(self, path1, path2, is_pending=True, similarity=None, timestamp=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
@@ -392,7 +406,8 @@ class DuplicateCache(QObject):
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, ts))
results.append(
DuplicateResult(p1, p2, None, False, sim, ts))
else:
keys_to_delete.append(key)
except Exception:
@@ -404,7 +419,8 @@ class DuplicateCache(QObject):
with self._lmdb_env.begin(write=True) as txn:
for k in keys_to_delete:
txn.delete(k, db=self._pending_db)
logger.info(f"Cleaned up {len(keys_to_delete)} invalid pending duplicates (files deleted externally)")
logger.info(f"Cleaned up {len(keys_to_delete)} invalid "
"pending duplicates (files deleted externally)")
except Exception as e:
logger.error(f"Error cleaning up pending duplicates from DB: {e}")
@@ -436,23 +452,28 @@ class DuplicateCache(QObject):
if len(parts) > 3:
ts = int(parts[3])
else:
ts = int(os.path.getmtime(p1)) if os.path.exists(p1) else 0
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
key_str = key_bytes.decode('utf-8')
kp = key_str.split('-')
if len(kp) == 4:
k1, k2 = f"{kp[0]}-{kp[1]}".encode(), f"{kp[2]}-{kp[3]}".encode()
v1, v2 = txn.get(k1, db=self._hash_db), txn.get(k2, db=self._hash_db)
k1, k2 = f"{kp[0]}-{kp[1]}".encode(),
f"{kp[2]}-{kp[3]}".encode()
v1, v2 = txn.get(k1, db=self._hash_db), \
txn.get(k2, db=self._hash_db)
if v1 and v2:
# Format is hash|mtime|path|dist... path is always index 2
# Format is hash|mtime|path|dist... path is always
# index 2
p1 = v1.decode('utf-8').split('|')[2]
p2 = v2.decode('utf-8').split('|')[2]
if p1 and p2:
if os.path.exists(p1) and os.path.exists(p2):
results.append(DuplicateResult(p1, p2, None, True, sim, ts))
results.append(
DuplicateResult(p1, p2, None, True, sim, ts))
except Exception:
continue
return results
@@ -484,11 +505,13 @@ class DuplicateCache(QObject):
with self._lmdb_env.begin(write=True) as txn:
for k in keys_to_delete:
txn.delete(k, db=self._hash_db)
logger.info(f"Cleaned up {len(keys_to_delete)} stale hash entries (files deleted externally)")
logger.info(f"Cleaned up {len(keys_to_delete)} stale hash "
"entries (files deleted externally)")
return len(keys_to_delete)
def get_all_hashes_with_paths(self):
"""Retrieves all hashes from the database along with their associated paths and inode info."""
"""Retrieves all hashes from the database along with their associated paths and
inode info."""
# hash_value -> [(path, dev_id, inode_key_bytes)]
all_hashes = collections.defaultdict(list)
if not self._lmdb_env:
@@ -527,7 +550,8 @@ class DuplicateCache(QObject):
if not old_inode_key_bytes or not new_inode_key_bytes or not self._lmdb_env:
return False
# If the (dev, inode) pair is the same, only the path in the value needs updating.
# If the (dev, inode) pair is the same, only the path in the value needs
# updating.
# This happens if the file is renamed within the same filesystem.
if (old_dev, old_inode_key_bytes) == (new_dev, new_inode_key_bytes):
hash_value, mtime, _ = self.get_hash_and_path(old_dev, old_inode_key_bytes)
@@ -543,8 +567,10 @@ class DuplicateCache(QObject):
# 3. Add a new entry with the new (dev, inode) and path, using the old hash.
hash_value, mtime, _ = self.get_hash_and_path(old_dev, old_inode_key_bytes)
if hash_value:
self.remove_hash_for_path(old_path) # This removes the old (dev, inode) entry
self.add_hash_for_path(new_path, hash_value, mtime) # Adds new (dev, inode) entry
# This removes the old (dev, inode) entry
self.remove_hash_for_path(old_path)
# Adds new (dev, inode) entry
self.add_hash_for_path(new_path, hash_value, mtime)
self._update_pair_paths(old_path, new_path, self._pending_db)
return True
return False
@@ -573,7 +599,9 @@ class DuplicateDetector(QThread):
duplicates_found = Signal(list) # List of DuplicateResult
detection_finished = Signal()
def __init__(self, paths_to_scan, duplicate_cache, pool_manager, method="histogram_hashing", threshold=90, force_full=False):
def __init__(self,
paths_to_scan, duplicate_cache, pool_manager,
method="histogram_hashing", threshold=90, force_full=False):
super().__init__()
self.paths_to_scan = paths_to_scan
self.duplicate_cache = duplicate_cache
@@ -585,17 +613,19 @@ class DuplicateDetector(QThread):
def stop(self):
self._is_running = False
self.wait() # Add this line
self.wait() # Add this line
def run(self):
total_files = len(self.paths_to_scan)
found_duplicates = []
unique_duplicate_pairs = set() # To store frozenset((path1, path2)) for uniqueness
# To store frozenset((path1, path2)) for uniqueness
unique_duplicate_pairs = set()
last_update_time = 0
pool = self.pool_manager.get_pool()
# 1. Load existing pending duplicates from cache to avoid recalculation (unless force_full)
# 1. Load existing pending duplicates from cache to avoid recalculation (unless
# force_full)
if not self.force_full:
pending = self.duplicate_cache.get_all_pending_duplicates()
for p in pending:
@@ -606,7 +636,10 @@ class DuplicateDetector(QThread):
# Convert similarity threshold (percentage) to Hamming distance
distance_threshold = int(MAX_DHASH_DISTANCE * (100 - self.threshold) / 100)
logger.info(f"Duplicate detection: Method={self.method}, Similarity Threshold={self.threshold}%, Hamming Distance Threshold={distance_threshold}")
logger.info(
f"Duplicate detection: Method={self.method}, "
f"Similarity Threshold={self.threshold}%, Hamming "
f"Distance Threshold={distance_threshold}")
# 2. Phase 1: Hash Collection (Parallelized)
path_to_hash = {}
@@ -645,7 +678,8 @@ class DuplicateDetector(QThread):
break
current_batch = paths_to_hash_parallel[i : i + batch_size]
for p_data in current_batch:
pool.start(HashWorker(p_data[0], self, new_hashes, results_mutex, sem))
pool.start(HashWorker(
p_data[0], self, new_hashes, results_mutex, sem))
for _ in range(len(current_batch)):
while not sem.tryAcquire(1, 100):
@@ -655,7 +689,9 @@ class DuplicateDetector(QThread):
break
processed_hashing += 1
if time.perf_counter() - last_update_time > 0.05:
self.progress_update.emit(processed_hashing, total_files * 2, UITexts.DUPLICATE_MSG_HASHING.format(filename="..."))
self.progress_update.emit(
processed_hashing, total_files * 2,
UITexts.DUPLICATE_MSG_HASHING.format(filename="..."))
last_update_time = time.perf_counter()
for p, mtime, dev, inode in paths_to_hash_parallel:
@@ -670,7 +706,9 @@ class DuplicateDetector(QThread):
return
# 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="..."))
# 3. Phase 2: Comparison (Optimized with BK-Tree)
hash_map = collections.defaultdict(list)
@@ -684,9 +722,12 @@ class DuplicateDetector(QThread):
if self.force_full or p in dirty_paths:
dirty_hashes_objs.add(h_obj)
# Optimization: Only query the tree for hashes associated with new or modified files.
# This finds pairs (Dirty, Clean) and (Dirty, Dirty). (Clean, Clean) were handled in previous runs.
hashes_to_query = list(dirty_hashes_objs) if not self.force_full else list(hash_map.keys())
# Optimization: Only query the tree for hashes associated with new or modified
# files.
# This finds pairs (Dirty, Clean) and (Dirty, Dirty). (Clean, Clean) were
# handled in previous runs.
hashes_to_query = list(dirty_hashes_objs) \
if not self.force_full else list(hash_map.keys())
total_queries = len(hashes_to_query)
for i, h1 in enumerate(hashes_to_query):
@@ -697,8 +738,11 @@ class DuplicateDetector(QThread):
if time.perf_counter() - last_update_time > 0.1:
# Scale Phase 2 progress to the 50%-100% range
phase2_progress = int(((i + 1) / total_queries) * total_files) if total_queries > 0 else total_files
self.progress_update.emit(total_files + phase2_progress, total_files * 2, UITexts.DUPLICATE_MSG_ANALYZING.format(filename="..."))
phase2_progress = int(((i + 1) / total_queries) * total_files) \
if total_queries > 0 else total_files
self.progress_update.emit(
total_files + phase2_progress, total_files * 2,
UITexts.DUPLICATE_MSG_ANALYZING.format(filename="..."))
last_update_time = time.perf_counter()
# Query tree for similar hashes
@@ -713,7 +757,8 @@ class DuplicateDetector(QThread):
continue
# Optimization: Skip pair if BOTH were already verified
if not self.force_full and p1 not in dirty_paths and p2 not in dirty_paths:
if not self.force_full \
and p1 not in dirty_paths and p2 not in dirty_paths:
continue
canonical = frozenset((p1, p2))
@@ -726,7 +771,8 @@ class DuplicateDetector(QThread):
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, timestamp=ts)
self.duplicate_cache.mark_as_pending(
p1, p2, True, similarity=sim, timestamp=ts)
self.duplicates_found.emit(found_duplicates)
self.detection_finished.emit()

View File

@@ -5,11 +5,11 @@ from PySide6.QtWidgets import (
QSplitter, QWidget, QMessageBox, QApplication, QMenu,
QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView
)
from PySide6.QtGui import QIcon, QImage, QDesktopServices
from PySide6.QtGui import QIcon, QImageReader, QImage, QDesktopServices
from PySide6.QtCore import Qt, QTimer, QUrl
from imageviewer import ImagePane
from propertiesdialog import PropertiesDialog
from constants import APP_CONFIG, UITexts
from propertiesdialog import PropertiesDialog
class DuplicateManagerDialog(QDialog):
@@ -26,6 +26,7 @@ class DuplicateManagerDialog(QDialog):
self.active_pane = None
self.current_dup_pair = None # Stores the current DuplicateResult object
self.panes_linked = True # Default to linked
self._user_link_preference = True # Persiste la intención del usuario
self._is_syncing = False # Guard to prevent recursion during synchronization
self.setWindowTitle(UITexts.DUPLICATE_MANAGER_TITLE)
@@ -37,7 +38,8 @@ class DuplicateManagerDialog(QDialog):
if self.main_win and hasattr(self.main_win, 'fs_watcher'):
self.main_win.fs_watcher.file_deleted.connect(
self._on_file_deleted_externally)
self.main_win.fs_watcher.file_moved.connect(self._on_file_moved_externally)
self.main_win.fs_watcher.file_moved.connect(
self._on_file_moved_externally)
if self.duplicates:
self.table_widget.setCurrentCell(0, 0)
@@ -59,7 +61,8 @@ class DuplicateManagerDialog(QDialog):
self.table_widget = QTableWidget()
if self.review_mode:
self.table_widget.setColumnCount(3)
columns = 3
self.table_widget.setColumnCount(columns)
self.table_widget.setHorizontalHeaderLabels(
[UITexts.IGNORED_DATE, "%", UITexts.CONTEXT_MENU_OPEN])
self.table_widget.horizontalHeader().setSectionResizeMode(
@@ -69,9 +72,10 @@ class DuplicateManagerDialog(QDialog):
self.table_widget.horizontalHeader().setSectionResizeMode(
2, QHeaderView.Stretch)
else:
self.table_widget.setColumnCount(2)
columns = 2
self.table_widget.setColumnCount(columns)
self.table_widget.setHorizontalHeaderLabels(
["%", UITexts.CONTEXT_MENU_OPEN]) # Usamos una cadena existente o genérica
["%", UITexts.CONTEXT_MENU_OPEN])
self.table_widget.horizontalHeader().setSectionResizeMode(
0, QHeaderView.ResizeToContents)
self.table_widget.horizontalHeader().setSectionResizeMode(
@@ -103,18 +107,22 @@ class DuplicateManagerDialog(QDialog):
button_widget = QWidget()
btn_layout = QHBoxLayout(button_widget)
self.btn_del_left = QPushButton(QIcon.fromTheme("user-trash"), UITexts.DUPLICATE_DELETE_LEFT)
self.btn_del_left = QPushButton(
QIcon.fromTheme("user-trash"), UITexts.DUPLICATE_DELETE_LEFT)
self.btn_del_left.clicked.connect(self._delete_left)
self.btn_del_right = QPushButton(QIcon.fromTheme("user-trash"), UITexts.DUPLICATE_DELETE_RIGHT)
self.btn_del_right = QPushButton(
QIcon.fromTheme("user-trash"), UITexts.DUPLICATE_DELETE_RIGHT)
self.btn_del_right.clicked.connect(self._delete_right)
self.btn_link_panes = QPushButton(QIcon.fromTheme("object-link"), UITexts.VIEWER_MENU_LINK_PANES)
self.btn_link_panes = QPushButton(
QIcon.fromTheme("object-link"), UITexts.VIEWER_MENU_LINK_PANES)
self.btn_link_panes.setCheckable(True)
self.btn_link_panes.setChecked(self.panes_linked)
self.btn_link_panes.clicked.connect(self._toggle_link_panes)
self.btn_keep_both = QPushButton(QIcon.fromTheme("emblem-important"), UITexts.DUPLICATE_KEEP_BOTH)
self.btn_keep_both = QPushButton(
QIcon.fromTheme("emblem-important"), UITexts.DUPLICATE_KEEP_BOTH)
self.btn_keep_both.clicked.connect(self._keep_both)
self.btn_skip = QPushButton(UITexts.DUPLICATE_SKIP)
@@ -135,7 +143,9 @@ class DuplicateManagerDialog(QDialog):
self.similarity_lbl = QLabel()
self.similarity_lbl.setAlignment(Qt.AlignCenter)
self.similarity_lbl.setMinimumHeight(30)
self.similarity_lbl.setStyleSheet("font-weight: bold; color: #f39c12; font-size: 15px; background-color: #222; border: 1px solid #444; border-radius: 4px;")
self.similarity_lbl.setStyleSheet(
"font-weight: bold; color: #f39c12; font-size: 15px; "
"background-color: #222; border: 1px solid #444; border-radius: 4px;")
main_right_layout = QVBoxLayout()
main_right_layout.addWidget(self.comparison_widget, 1)
@@ -156,8 +166,10 @@ class DuplicateManagerDialog(QDialog):
"""Disconnects signals and performs cleanup when closing."""
if self.main_win and hasattr(self.main_win, 'fs_watcher'):
try:
self.main_win.fs_watcher.file_deleted.disconnect(self._on_file_deleted_externally)
self.main_win.fs_watcher.file_moved.disconnect(self._on_file_moved_externally)
self.main_win.fs_watcher.file_deleted.disconnect(
self._on_file_deleted_externally)
self.main_win.fs_watcher.file_moved.disconnect(
self._on_file_moved_externally)
except (RuntimeError, TypeError):
pass
@@ -340,21 +352,25 @@ class DuplicateManagerDialog(QDialog):
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_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
# Store original index here for _load_pair
date_item.setData(Qt.UserRole, i)
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)
# 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.setData(Qt.DisplayRole, dup.similarity
if dup.similarity is not None else 0)
sim_item.setTextAlignment(Qt.AlignCenter)
if not self.review_mode:
sim_item.setData(Qt.UserRole, i) # Guardamos el índice original en la lista duplicates
sim_item.setData(Qt.UserRole, i)
# Columna 1: Nombres de ficheros
names_item = QTableWidgetItem(f"{name1}{name2}")
@@ -375,7 +391,8 @@ class DuplicateManagerDialog(QDialog):
if row < 0 or row >= self.table_widget.rowCount():
return
# Obtenemos el índice real de la lista duplicates guardado en el UserRole del item
# Obtenemos el índice real de la lista duplicates guardado en el UserRole del
# item
item = self.table_widget.item(row, 0)
if not item:
return
@@ -387,12 +404,14 @@ class DuplicateManagerDialog(QDialog):
similarity_color = "#f39c12" # Default (amber)
if dup.similarity is not None:
if dup.similarity == 100:
similarity_color = "#2ecc71" # Green
similarity_color = "#2ecc71" # Green
elif dup.similarity < 80:
similarity_color = "#e74c3c" # Red
similarity_color = "#e74c3c" # Red
self.similarity_lbl.setText(f"{dup.similarity}% Similarity")
self.similarity_lbl.setStyleSheet(f"font-weight: bold; color: {similarity_color}; font-size: 12px; margin-top: 5px;")
self.similarity_lbl.setStyleSheet(
f"font-weight: bold; color: {similarity_color}; "
"font-size: 12px; margin-top: 5px;")
self.similarity_lbl.show()
else:
self.similarity_lbl.hide()
@@ -417,13 +436,26 @@ class DuplicateManagerDialog(QDialog):
mtime1 = os.path.getmtime(path_left) if os.path.exists(path_left) else 0
mtime2 = os.path.getmtime(path_right) if os.path.exists(path_right) else 0
# La imagen más reciente (mtime más alto) va a la izquierda
# Recent image to the left, older to the right
if mtime1 >= mtime2:
self._set_pane_data(self.left_pane_widget, path_left, filename_color, dir_color, filename_left, dir_left)
self._set_pane_data(self.right_pane_widget, path_right, filename_color, dir_color, filename_right, dir_right)
dis_l = self._set_pane_data(
self.left_pane_widget, path_left, filename_color,
dir_color, filename_left, dir_left)
dis_r = self._set_pane_data(
self.right_pane_widget, path_right, filename_color,
dir_color, filename_right, dir_right)
else:
self._set_pane_data(self.left_pane_widget, path_right, filename_color, dir_color, filename_right, dir_right)
self._set_pane_data(self.right_pane_widget, path_left, filename_color, dir_color, filename_left, dir_left)
dis_l = self._set_pane_data(
self.left_pane_widget, path_right, filename_color,
dir_color, filename_right, dir_right)
dis_r = self._set_pane_data(
self.right_pane_widget, path_left, filename_color,
dir_color, filename_left, dir_left)
can_link = not (dis_l or dis_r)
self.panes_linked = self._user_link_preference and can_link
self.btn_link_panes.setEnabled(can_link)
self.btn_link_panes.setChecked(self.panes_linked)
# Compare resolutions and highlight the best one
p_l = self.left_pane.controller.pixmap_original
@@ -432,7 +464,7 @@ class DuplicateManagerDialog(QDialog):
res_l = p_l.width() * p_l.height()
res_r = p_r.width() * p_r.height()
winner = 0 # 0: none, 1: left, 2: right
winner = 0 # 0: none, 1: left, 2: right
if res_l > res_r:
winner = 1
elif res_r > res_l:
@@ -444,26 +476,40 @@ class DuplicateManagerDialog(QDialog):
path_r = self.right_pane.controller.get_current_path()
size_l = os.path.getsize(path_l)
size_r = os.path.getsize(path_r)
if size_l > size_r: winner = 1
elif size_r > size_l: winner = 2
except (OSError, AttributeError): pass
if size_l > size_r:
winner = 1
elif size_r > size_l:
winner = 2
except (OSError, AttributeError):
pass
if winner == 1:
self.left_pane_widget.info_lbl.setStyleSheet("font-weight: bold; color: #2ecc71;")
self.left_pane_widget.info_lbl.setText("" + self.left_pane_widget.info_lbl.text())
self.right_pane_widget.info_lbl.setStyleSheet("font-weight: bold; color: #aaa;")
self.left_pane_widget.info_lbl.setStyleSheet(
"font-weight: bold; color: #2ecc71;")
self.left_pane_widget.info_lbl.setText(
"" + self.left_pane_widget.info_lbl.text())
self.right_pane_widget.info_lbl.setStyleSheet(
"font-weight: bold; color: #aaa;")
elif winner == 2:
self.right_pane_widget.info_lbl.setStyleSheet("font-weight: bold; color: #2ecc71;")
self.right_pane_widget.info_lbl.setText("" + self.right_pane_widget.info_lbl.text())
self.left_pane_widget.info_lbl.setStyleSheet("font-weight: bold; color: #aaa;")
self.right_pane_widget.info_lbl.setStyleSheet(
"font-weight: bold; color: #2ecc71;")
self.right_pane_widget.info_lbl.setText(
"" + self.right_pane_widget.info_lbl.text())
self.left_pane_widget.info_lbl.setStyleSheet(
"font-weight: bold; color: #aaa;")
else:
self.left_pane_widget.info_lbl.setStyleSheet("font-weight: bold; color: #aaa;")
self.right_pane_widget.info_lbl.setStyleSheet("font-weight: bold; color: #aaa;")
self.left_pane_widget.info_lbl.setStyleSheet(
"font-weight: bold; color: #aaa;")
self.right_pane_widget.info_lbl.setStyleSheet(
"font-weight: bold; color: #aaa;")
else:
self.left_pane_widget.info_lbl.setStyleSheet("font-weight: bold; color: #aaa;")
self.right_pane_widget.info_lbl.setStyleSheet("font-weight: bold; color: #aaa;")
self.left_pane_widget.info_lbl.setStyleSheet(
"font-weight: bold; color: #aaa;")
self.right_pane_widget.info_lbl.setStyleSheet(
"font-weight: bold; color: #aaa;")
def _set_pane_data(self, pane_widget, path, filename_color, dir_color, filename_text, dir_text):
def _set_pane_data(self, pane_widget, path, filename_color, dir_color,
filename_text, dir_text) -> bool:
pane = pane_widget.pane
info_lbl = pane_widget.info_lbl
filename_lbl = pane_widget.filename_lbl
@@ -471,16 +517,27 @@ class DuplicateManagerDialog(QDialog):
if not os.path.exists(path):
info_lbl.setText("FILE NOT FOUND")
pane.controller.update_list([], 0) # Clear pane
pane.controller.update_list([], 0) # Clear pane
pane.load_and_fit_image()
filename_lbl.setText("N/A")
dir_lbl.setText("N/A")
return
return True
# Metadatos
size_bytes = os.path.getsize(path)
size_str = self._format_size(size_bytes)
# Detección de imágenes animadas o resoluciones inválidas
reader = QImageReader(path)
is_animated = reader.supportsAnimation() and reader.imageCount() > 1
is_invalid = (pane.controller.pixmap_original.isNull() or
not pane.controller.pixmap_original.size().isValid())
disable_linking = is_animated or is_invalid
self.panes_linked = self._user_link_preference and disable_linking
self.btn_link_panes.setEnabled(disable_linking)
self.btn_link_panes.setChecked(self.panes_linked)
# Load image into pane's controller
pane.controller.update_list([path], 0)
pane.load_and_fit_image()
@@ -495,9 +552,11 @@ class DuplicateManagerDialog(QDialog):
info_lbl.setText(f"{size_str} - N/A")
filename_lbl.setText(filename_text)
filename_lbl.setStyleSheet(f"font-size: 11px; font-weight: bold; color: {filename_color};")
filename_lbl.setStyleSheet(
f"font-size: 11px; font-weight: bold; color: {filename_color};")
dir_lbl.setText(dir_text)
dir_lbl.setStyleSheet(f"font-size: 9px; color: {dir_color};")
return disable_linking
def _show_pane_context_menu(self, pos):
pane = self.sender()
@@ -508,7 +567,8 @@ class DuplicateManagerDialog(QDialog):
menu = QMenu(self)
# Open with...
open_menu = menu.addMenu(QIcon.fromTheme("document-open"), UITexts.CONTEXT_MENU_OPEN)
open_menu = menu.addMenu(
QIcon.fromTheme("document-open"), UITexts.CONTEXT_MENU_OPEN)
self.main_win.populate_open_with_submenu(open_menu, path)
# Open location
@@ -521,28 +581,39 @@ class DuplicateManagerDialog(QDialog):
menu.addSeparator()
# Clipboard
clip_menu = menu.addMenu(QIcon.fromTheme("edit-copy"), UITexts.CONTEXT_MENU_CLIPBOARD)
clip_menu = menu.addMenu(
QIcon.fromTheme("edit-copy"), UITexts.CONTEXT_MENU_CLIPBOARD)
action_copy_image = clip_menu.addAction(QIcon.fromTheme("image-x-generic"), UITexts.VIEWER_MENU_COPY_IMAGE)
action_copy_image.triggered.connect(lambda: QApplication.clipboard().setImage(QImage(path)))
action_copy_image = clip_menu.addAction(
QIcon.fromTheme("image-x-generic"), UITexts.VIEWER_MENU_COPY_IMAGE)
action_copy_image.triggered.connect(
lambda: QApplication.clipboard().setImage(QImage(path)))
action_copy_path = clip_menu.addAction(QIcon.fromTheme("document-properties"), UITexts.VIEWER_MENU_COPY_PATH)
action_copy_path.triggered.connect(lambda: QApplication.clipboard().setText(path))
action_copy_path = clip_menu.addAction(
QIcon.fromTheme("document-properties"), UITexts.VIEWER_MENU_COPY_PATH)
action_copy_path.triggered.connect(
lambda: QApplication.clipboard().setText(path))
menu.addSeparator()
# Trash / Delete
action_trash = menu.addAction(QIcon.fromTheme("user-trash"), UITexts.CONTEXT_MENU_TRASH)
action_trash.triggered.connect(lambda: self._handle_action(delete_path=path, permanent=False))
action_trash = menu.addAction(
QIcon.fromTheme("user-trash"), UITexts.CONTEXT_MENU_TRASH)
action_trash.triggered.connect(
lambda: self._handle_action(delete_path=path, permanent=False))
action_delete = menu.addAction(QIcon.fromTheme("edit-delete"), UITexts.CONTEXT_MENU_DELETE)
action_delete.triggered.connect(lambda: self._handle_permanent_delete(path))
action_delete = menu.addAction(
QIcon.fromTheme("edit-delete"), UITexts.CONTEXT_MENU_DELETE)
action_delete.triggered.connect(
lambda: self._handle_permanent_delete(path))
menu.addSeparator()
# Properties
action_props = menu.addAction(QIcon.fromTheme("document-properties"), UITexts.CONTEXT_MENU_PROPERTIES)
action_props.triggered.connect(lambda: self._show_properties(path, pane))
action_props = menu.addAction(
QIcon.fromTheme("document-properties"), UITexts.CONTEXT_MENU_PROPERTIES)
action_props.triggered.connect(
lambda: self._show_properties(path, pane))
menu.exec(pane.mapToGlobal(pos))
@@ -550,7 +621,8 @@ class DuplicateManagerDialog(QDialog):
confirm = QMessageBox(self)
confirm.setIcon(QMessageBox.Warning)
confirm.setText(UITexts.CONFIRM_DELETE_TEXT)
confirm.setInformativeText(UITexts.CONFIRM_DELETE_INFO.format(os.path.basename(path)))
confirm.setInformativeText(
UITexts.CONFIRM_DELETE_INFO.format(os.path.basename(path)))
confirm.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
confirm.setDefaultButton(QMessageBox.No)
if confirm.exec() == QMessageBox.Yes:
@@ -559,14 +631,16 @@ class DuplicateManagerDialog(QDialog):
def _show_properties(self, path, pane):
tags = pane.controller._current_tags
rating = pane.controller._current_rating
dlg = PropertiesDialog(path, initial_tags=tags, initial_rating=rating, parent=self)
dlg = PropertiesDialog(
path, initial_tags=tags, initial_rating=rating, parent=self)
dlg.exec()
def _on_pane_activated(self):
# When a pane is activated, ensure its zoom/scroll is the reference for linking
if self.panes_linked:
active_pane = self.sender() # The pane that emitted activated signal
other_pane = self.left_pane if active_pane == self.right_pane else self.right_pane
other_pane = self.left_pane \
if active_pane == self.right_pane else self.right_pane
self._sync_zoom(active_pane.controller.zoom_factor, source_pane=active_pane)
# Need to get scroll position from active_pane and apply to other
h_bar = active_pane.scroll_area.horizontalScrollBar()
@@ -603,17 +677,20 @@ class DuplicateManagerDialog(QDialog):
x_pct = h_bar.value() / h_bar.maximum() if h_bar.maximum() > 0 else 0
y_pct = v_bar.value() / v_bar.maximum() if v_bar.maximum() > 0 else 0
target_pane = self.left_pane if source_pane == self.right_pane else self.right_pane
target_pane = self.left_pane \
if source_pane == self.right_pane else self.right_pane
target_pane.zoom_manager.zoom(absolute_factor=factor)
# Re-apply relative scroll after zoom changes bounds
QTimer.singleShot(0, lambda p=target_pane, x=x_pct, y=y_pct: p.set_scroll_relative(x, y))
QTimer.singleShot(
0, lambda p=target_pane, x=x_pct, y=y_pct: p.set_scroll_relative(x, y))
finally:
self._is_syncing = False
def _format_size(self, size):
for unit in ['B', 'KiB', 'MiB', 'GiB']:
if size < 1024: return f"{size:.1f} {unit}"
if size < 1024:
return f"{size:.1f} {unit}"
size /= 1024
return f"{size:.1f} TiB"
@@ -628,7 +705,8 @@ class DuplicateManagerDialog(QDialog):
self._handle_action(delete_path=path_to_delete)
def _toggle_link_panes(self):
self.panes_linked = self.btn_link_panes.isChecked()
self._user_link_preference = self.btn_link_panes.isChecked()
self.panes_linked = self._user_link_preference
if self.panes_linked:
# When linking, synchronize the other pane to the active one
# For simplicity, let's always sync right to left if linking is enabled
@@ -645,7 +723,8 @@ class DuplicateManagerDialog(QDialog):
path = os.path.abspath(path)
# 1. Identify pairs to remove and clean up the pending DB
pairs_to_remove = [d for d in self.duplicates if d.path1 == path or d.path2 == path]
pairs_to_remove = [d for d in self.duplicates
if d.path1 == path or d.path2 == path]
if not pairs_to_remove:
return
@@ -699,12 +778,16 @@ 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)
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)
# 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)
@@ -732,27 +815,35 @@ class DuplicateManagerDialog(QDialog):
# Remove all pairs containing this path from the persistent pending DB
# because the file will be gone.
pairs_to_unmark = [d for d in self.duplicates if d.path1 == delete_path or d.path2 == delete_path]
pairs_to_unmark = [d for d in self.duplicates
if d.path1 == delete_path or d.path2 == delete_path]
for p in pairs_to_unmark:
self.cache.mark_as_pending(p.path1, p.path2, False)
self.main_win.delete_file_by_path(delete_path, permanent=permanent) # Use default setting
self.main_win.delete_file_by_path(delete_path, permanent=permanent)
if os.path.exists(delete_path):
QMessageBox.warning(self, UITexts.ERROR, UITexts.ERROR_DELETING_FILE.format(delete_path))
QMessageBox.warning(
self, UITexts.ERROR,
UITexts.ERROR_DELETING_FILE.format(delete_path))
return
# Remove all pairs containing this path because it no longer exists
self.duplicates = [d for d in self.duplicates if d.path1 != delete_path and d.path2 != delete_path]
self.duplicates = [d for d in self.duplicates
if d.path1 != delete_path and d.path2 != delete_path]
else:
# Skip or KeepBoth:
if not skip: # "Keep Both" case
# It's no longer pending, it's an exception (already marked in _keep_both)
self.cache.mark_as_pending(current_pair.path1, current_pair.path2, False)
# Note: if it's "Skip", we do NOT remove it from pending DB, so it stays there for next time.
if not skip: # "Keep Both" case
# It's no longer pending, it's an exception (already marked in
# _keep_both)
self.cache.mark_as_pending(
current_pair.path1, current_pair.path2, False)
# Note: if it's "Skip", we do NOT remove it from pending DB, so it stays
# there for next time.
if 0 <= original_index < len(self.duplicates):
self.duplicates.pop(original_index)
# Repopulate list widget to ensure all indices are correct and counter is updated
# Repopulate list widget to ensure all indices are correct and counter is
# updated
self._populate_list()
# Try to restore selection to same position (or last item)

View File

@@ -424,7 +424,7 @@ class FaceCanvas(QLabel):
self.zoom_indicator_point = None
self.zoom_indicator_timer = QTimer(self)
self.zoom_indicator_timer.setSingleShot(True)
self.zoom_indicator_timer.setInterval(500) # Show for 500ms
self.zoom_indicator_timer.setInterval(500) # Show for 500ms
self.zoom_indicator_timer.timeout.connect(self._clear_zoom_indicator)
self.crop_rect = QRect()
self.crop_handle = None
@@ -636,13 +636,15 @@ class FaceCanvas(QLabel):
# Draw zoom indicator
if self.zoom_indicator_point:
painter.setPen(QPen(QColor(255, 255, 0), 2)) # Yellow crosshair
painter.setPen(QPen(QColor(255, 255, 0), 2)) # Yellow crosshair
painter.drawLine(self.zoom_indicator_point.x() - 10,
self.zoom_indicator_point.y(),
self.zoom_indicator_point.x() + 10,
self.zoom_indicator_point.y())
painter.drawLine(self.zoom_indicator_point.x(), self.zoom_indicator_point.y() - 10,
self.zoom_indicator_point.x(), self.zoom_indicator_point.y() + 10)
painter.drawLine(self.zoom_indicator_point.x(),
self.zoom_indicator_point.y() - 10,
self.zoom_indicator_point.x(),
self.zoom_indicator_point.y() + 10)
def _hit_test(self, pos):
"""Determines if the mouse is over a name, handle, or body."""
@@ -1145,7 +1147,8 @@ class ZoomManager(QObject):
def zoom(self, factor=1.1, reset=False, focus_point=None, absolute_factor=None):
"""Applies zoom to the image, centering on focus_point if provided."""
if not self.viewer.controller or self.viewer.controller.pixmap_original.isNull():
if not self.viewer.controller or \
self.viewer.controller.pixmap_original.isNull():
return
c_point = None
@@ -1155,9 +1158,10 @@ class ZoomManager(QObject):
self.viewer.update_view(resize_win=True)
if self.viewer.canvas:
c_point = self.viewer.canvas.rect().center()
elif absolute_factor is not None: # New: set absolute zoom factor
elif absolute_factor is not None: # New: set absolute zoom factor
self.viewer.controller.zoom_factor = absolute_factor
self.viewer.update_view(resize_win=False) # Don't resize window for sync zoom
# Don't resize window for sync zoom
self.viewer.update_view(resize_win=False)
if focus_point is not None and self.viewer.canvas:
scroll_area = self.viewer.scroll_area
viewport = scroll_area.viewport()
@@ -1171,7 +1175,8 @@ class ZoomManager(QObject):
if focus_point is None:
v_point = viewport.rect().center()
else:
# focus_point es relativo al widget self.viewer (ImageViewer o ImagePane)
# focus_point es relativo al widget self.viewer (ImageViewer o
# ImagePane)
v_point = viewport.mapFrom(self.viewer, focus_point)
# 2. Mapear el punto de enfoque a coordenadas del canvas antes del zoom
@@ -1181,7 +1186,8 @@ class ZoomManager(QObject):
# Aplicar la actualización (esto redimensiona el canvas)
self.viewer.update_view(resize_win=(not self.viewer.isFullScreen()))
# 3. Ajustar las barras de desplazamiento para mantener el píxel bajo el cursor
# 3. Ajustar las barras de desplazamiento para mantener el píxel bajo el
# cursor
scroll_area.horizontalScrollBar().setValue(
int(c_point.x() * factor - v_point.x()))
scroll_area.verticalScrollBar().setValue(
@@ -1721,7 +1727,9 @@ class ImageViewer(QWidget):
for pane in self.panes:
if pane != self.active_pane:
QTimer.singleShot(0, lambda p=pane, x=x_pct, y=y_pct: p.set_scroll_relative(x, y))
QTimer.singleShot(
0, lambda p=pane, x=x_pct,
y=y_pct: p.set_scroll_relative(x, y))
def update_grid_layout(self):
# Clear layout
@@ -1761,7 +1769,8 @@ class ImageViewer(QWidget):
new_idx = (start_idx + i + 1) % len(img_list)
pane = self.add_pane(img_list, new_idx, None, 0) # Metadata will load
if self.panes_linked and self.active_pane:
pane.controller.zoom_factor = self.active_pane.controller.zoom_factor
pane.controller.zoom_factor = \
self.active_pane.controller.zoom_factor
pane.load_and_fit_image()
else:
# Remove panes (keep active if possible, else keep first)
@@ -1779,7 +1788,7 @@ class ImageViewer(QWidget):
# sizing
QTimer.singleShot(
0, lambda: self.active_pane.update_view(resize_win=True))
self.adjustSize() # Ajustar el tamaño de la ventana después de añadir/eliminar paneles
self.adjustSize()
def toggle_link_panes(self):
"""Toggles the synchronized zoom/scroll for comparison mode."""

View File

@@ -17,11 +17,12 @@ except ImportError:
exiv2 = None
HAVE_EXIV2 = False
_app_modified_callback = None
from utils import preserve_mtime
from constants import RATING_XATTR_NAME, XATTR_NAME
_app_modified_callback = None
MetadataResult = collections.namedtuple('MetadataResult', ['tags', 'rating'])
EMPTY_METADATA = MetadataResult([], 0)
@@ -30,11 +31,13 @@ def set_app_modified_callback(callback):
global _app_modified_callback
_app_modified_callback = callback
def mark_app_modified(path):
"""Triggers the application-modified callback for a path."""
if _app_modified_callback:
_app_modified_callback(path)
def notify_baloo(path):
"""
Notifies the Baloo file indexer about a file change using DBus.

View File

@@ -15,7 +15,6 @@ Dependencies:
- utils.preserve_mtime: A utility to prevent file modification times from
changing during metadata writes.
"""
from importlib.resources import path
import os
import re
from utils import preserve_mtime