Several fixes
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "bagheeraview"
|
||||
version = "0.9.16"
|
||||
version = "0.9.17"
|
||||
authors = [
|
||||
{ name = "Ignacio Serantes" }
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user