Several fixes
This commit is contained in:
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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" }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user