diff --git a/bagheeraview.py b/bagheeraview.py index 9447fa8..a1c9c80 100755 --- a/bagheeraview.py +++ b/bagheeraview.py @@ -14,7 +14,7 @@ Classes: MainWindow: The main application window containing the thumbnail grid and docks. """ __appname__ = "BagheeraView" -__version__ = "0.9.15" +__version__ = "0.9.16" __author__ = "Ignacio Serantes" __email__ = "kde@aynoa.net" __license__ = "LGPL" @@ -53,21 +53,28 @@ from PySide6.QtDBus import QDBusConnection, QDBusMessage, QDBus from pathlib import Path from constants import ( - APP_CONFIG, CONFIG_PATH, DEFAULT_GLOBAL_SHORTCUTS, DEFAULT_LANGUAGE, + APP_CONFIG, CACHE_PATH, CONFIG_PATH, DEFAULT_GLOBAL_SHORTCUTS, DEFAULT_LANGUAGE, DEFAULT_VIEWER_SHORTCUTS, GLOBAL_ACTIONS, HISTORY_PATH, ICON_THEME, - ICON_THEME_FALLBACK, IMAGE_MIME_TYPES, LAYOUTS_DIR, FAVORITES_PATH, PROG_AUTHOR, - PROG_NAME, PROG_VERSION, RATING_XATTR_NAME, SCANNER_GENERATE_SIZES, + ICON_THEME_FALLBACK, SCANNER_GENERATE_SIZES, IMAGE_MIME_TYPES, IMAGE_EXTENSIONS, + LAYOUTS_DIR, FAVORITES_PATH, PROG_AUTHOR, + PROG_NAME, PROG_VERSION, RATING_XATTR_NAME, SCANNER_SETTINGS_DEFAULTS, SUPPORTED_LANGUAGES, TAGS_MENU_MAX_ITEMS_DEFAULT, THUMBNAILS_BG_COLOR_DEFAULT, THUMBNAILS_DEFAULT_SIZE, VIEWER_ACTIONS, THUMBNAILS_FILENAME_COLOR_DEFAULT, THUMBNAILS_TOOLTIP_BG_COLOR_DEFAULT, + HAVE_IMAGEHASH, FACES_MENU_MAX_ITEMS_DEFAULT, THUMBNAILS_TOOLTIP_FG_COLOR_DEFAULT, THUMBNAILS_FILENAME_LINES_DEFAULT, THUMBNAILS_FILENAME_FONT_SIZE_DEFAULT, THUMBNAILS_TAGS_LINES_DEFAULT, THUMBNAILS_TAGS_COLOR_DEFAULT, THUMBNAILS_RATING_COLOR_DEFAULT, THUMBNAILS_TAGS_FONT_SIZE_DEFAULT, THUMBNAILS_REFRESH_INTERVAL_DEFAULT, - THUMBNAIL_SIZES, XATTR_NAME, UITexts + THUMBNAIL_SIZES, XATTR_NAME, UITexts, save_app_config ) -import constants from settings import SettingsDialog +if HAVE_IMAGEHASH: + from duplicatecache import DuplicateCache, DuplicateDetector + from duplicatedialog import DuplicateManagerDialog +else: + DuplicateCache = None + DuplicateDetector = None from imagescanner import (CacheCleaner, ImageScanner, ThumbnailCache, ThumbnailGenerator, ThreadPoolManager) from imageviewer import ImageViewer @@ -367,8 +374,8 @@ class AppShortcutController(QObject): "save_layout": self.main_win.save_layout, "load_layout": self.main_win.load_layout_dialog, "open_folder": self.main_win.open_current_folder, - "move_to_trash": lambda: - self.main_win.delete_current_image(permanent=False), + "move_to_trash": + lambda: self.main_win.delete_current_image(permanent=None), "delete_permanently": lambda: self.main_win.delete_current_image(permanent=True), "rename_image": self._rename_image, @@ -975,24 +982,25 @@ class ThumbnailSortFilterProxyModel(QSortFilterProxyModel): class MainWindow(QMainWindow): """ The main application window, which serves as the central hub for browsing - and managing images. + and managing images, including duplicate detection. It features a virtualized thumbnail grid for performance, a dockable sidebar for metadata editing and filtering, and manages the lifecycle of background scanners and individual image viewer windows. """ - def __init__(self, cache, args, thread_pool_manager): - """ - Initializes the MainWindow. + def __init__(self, cache, args, thread_pool_manager, duplicate_cache): + """Initializes the MainWindow. Args: cache (ThumbnailCache): The shared thumbnail cache instance. args (list): Command-line arguments passed to the application. thread_pool_manager (ThreadPoolManager): The shared thread pool manager. + duplicate_cache (DuplicateCache): The shared duplicate cache instance. """ super().__init__() self.cache = cache + self.duplicate_cache = duplicate_cache self.setWindowTitle(f"{PROG_NAME} v{PROG_VERSION}") self.set_app_icon() @@ -1094,13 +1102,26 @@ class MainWindow(QMainWindow): # Bottom bar with status and controls bot = QHBoxLayout() - self.status_lbl = QLabel(UITexts.READY) - bot.addWidget(self.status_lbl) + self.btn_cancel_duplicates = QPushButton() + self.btn_cancel_duplicates.setIcon(QIcon.fromTheme("process-stop")) + self.btn_cancel_duplicates.setFixedSize(22, 22) + self.btn_cancel_duplicates.setToolTip(UITexts.CANCEL) + self.btn_cancel_duplicates.setFocusPolicy(Qt.NoFocus) + self.btn_cancel_duplicates.hide() + self.btn_cancel_duplicates.clicked.connect(self.cancel_duplicate_detection) + bot.addWidget(self.btn_cancel_duplicates) self.progress_bar = CircularProgressBar(self) self.progress_bar.hide() bot.addWidget(self.progress_bar) + self.status_counter_lbl = QLabel("") + self.status_counter_lbl.hide() + bot.addWidget(self.status_counter_lbl) + + self.status_lbl = QLabel(UITexts.READY) + bot.addWidget(self.status_lbl) + self.fs_watcher_status_lbl = QLabel() self.fs_watcher_status_lbl.setToolTip(UITexts.FS_WATCHER_TOOLTIP) self.fs_watcher_status_lbl.hide() @@ -1306,6 +1327,7 @@ class MainWindow(QMainWindow): self.rebuild_timer.timeout.connect(self.rebuild_view) # Timer to resume scanning after user interaction stops + self.duplicate_detector = None # Worker for duplicate detection self.resume_scan_timer = QTimer(self) self.resume_scan_timer.setSingleShot(True) self.resume_scan_timer.setInterval(400) @@ -1358,7 +1380,7 @@ class MainWindow(QMainWindow): self._apply_global_stylesheet() # Set the initial thumbnail generation tier based on the loaded config size self._current_thumb_tier = self._get_tier_for_size(self.current_thumb_size) - constants.SCANNER_GENERATE_SIZES = [self._current_thumb_tier] + # SCANNER_GENERATE_SIZES = [self._current_thumb_tier] if hasattr(self, 'history_tab'): self.history_tab.refresh_list() @@ -1718,7 +1740,7 @@ class MainWindow(QMainWindow): size_mb = size / (1024 * 1024) disk_cache_size_mb = 0 - disk_cache_path = os.path.join(constants.CACHE_PATH, "data.mdb") + disk_cache_path = os.path.join(CACHE_PATH, "data.mdb") if os.path.exists(disk_cache_path): disk_cache_size_bytes = os.path.getsize(disk_cache_path) disk_cache_size_mb = disk_cache_size_bytes / (1024 * 1024) @@ -1736,6 +1758,36 @@ class MainWindow(QMainWindow): menu.addSeparator() + 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.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)) + + 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.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.triggered.connect(self.clear_duplicate_hashes) + + menu.addSeparator() + show_shortcuts_action = menu.addAction(QIcon.fromTheme("help-keys"), UITexts.MENU_SHOW_SHORTCUTS) show_shortcuts_action.triggered.connect(self.show_shortcuts_help) @@ -1770,6 +1822,89 @@ class MainWindow(QMainWindow): menu.exec(self.menu_btn.mapToGlobal(QPoint(0, self.menu_btn.height()))) + def detect_all_duplicates(self): + """Gathers files from whitelist (respecting blacklist) and runs detector.""" + QApplication.setOverrideCursor(Qt.WaitCursor) + try: + paths = self._gather_files_for_duplicates() + finally: + QApplication.restoreOverrideCursor() + + if paths is None: + 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) + return + + self.start_duplicate_detection(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()] + + if not whitelist: + return None + + all_paths = [] + blacklist_set = set(blacklist) + + for root_path in whitelist: + if not os.path.exists(root_path): + continue + + 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] + + if abs_root in blacklist_set: + continue + + for f in files: + if os.path.splitext(f)[1].lower() in IMAGE_EXTENSIONS: + full_p = os.path.join(abs_root, f) + if full_p not in blacklist_set: + all_paths.append(full_p) + return all_paths + + def clean_duplicate_hashes(self): + if self.duplicate_cache: + count = self.duplicate_cache.clean_stale_hashes() + self.status_lbl.setText(f"Cleaned up {count} stale hash entries.") + + def clear_duplicate_hashes(self): + if not self.duplicate_cache: + return + + confirm = QMessageBox(self) + confirm.setIcon(QMessageBox.Warning) + confirm.setWindowTitle(UITexts.CONFIRM_CLEAR_HASHES_TITLE) + confirm.setText(UITexts.CONFIRM_CLEAR_HASHES_TEXT) + confirm.setInformativeText(UITexts.CONFIRM_CLEAR_HASHES_INFO) + confirm.setStandardButtons(QMessageBox.Yes | QMessageBox.No) + confirm.setDefaultButton(QMessageBox.No) + if confirm.exec() != QMessageBox.Yes: + return + + self.duplicate_cache.clear_hashes() + self.status_lbl.setText("Duplicate hash cache cleared.") + + def review_ignored_duplicates(self): + if not self.duplicate_cache: + return + ignored = self.duplicate_cache.get_all_exceptions() + if not ignored: + QMessageBox.information(self, UITexts.DUPLICATE_DETECTION_TITLE, UITexts.DUPLICATE_NONE_FOUND) + return + dialog = DuplicateManagerDialog(ignored, self.duplicate_cache, self, review_mode=True) + dialog.show() + def show_about_dialog(self): """Shows the 'About' dialog box.""" QMessageBox.about(self, UITexts.MENU_ABOUT_TITLE.format(PROG_NAME), @@ -1785,27 +1920,27 @@ class MainWindow(QMainWindow): if dlg.exec(): # Update settings that affect the main window immediately new_interval = APP_CONFIG.get("thumbnails_refresh_interval", - constants.THUMBNAILS_REFRESH_INTERVAL_DEFAULT) + THUMBNAILS_REFRESH_INTERVAL_DEFAULT) self.thumbnails_refresh_timer.setInterval(new_interval) new_max_tags = APP_CONFIG.get("tags_menu_max_items", - constants.TAGS_MENU_MAX_ITEMS_DEFAULT) + TAGS_MENU_MAX_ITEMS_DEFAULT) if self.mru_tags.maxlen != new_max_tags: # Recreate deque with new size, preserving content self.mru_tags = deque(self.mru_tags, maxlen=new_max_tags) new_max_faces = APP_CONFIG.get("faces_menu_max_items", - constants.FACES_MENU_MAX_ITEMS_DEFAULT) + FACES_MENU_MAX_ITEMS_DEFAULT) if len(self.face_names_history) > new_max_faces: self.face_names_history = self.face_names_history[:new_max_faces] new_max_bodies = APP_CONFIG.get("body_menu_max_items", - constants.FACES_MENU_MAX_ITEMS_DEFAULT) + FACES_MENU_MAX_ITEMS_DEFAULT) if len(self.body_names_history) > new_max_bodies: self.body_names_history = self.body_names_history[:new_max_bodies] new_bg_color = APP_CONFIG.get("thumbnails_bg_color", - constants.THUMBNAILS_BG_COLOR_DEFAULT) + THUMBNAILS_BG_COLOR_DEFAULT) self.thumbnail_view.setStyleSheet(f"background-color: {new_bg_color};") # Reload filmstrip position so it applies to new viewers @@ -1876,6 +2011,12 @@ class MainWindow(QMainWindow): def perform_shutdown(self): """Performs cleanup operations before the application closes.""" self.is_cleaning = True + + # Save configuration early if visible, as per user request. + # This ensures persistence even if subsequent cleanup hangs. + if self.isVisible(): + self.save_config() + self.fs_watcher.stop() # 1. Stop all worker threads interacting with the cache @@ -1884,6 +2025,8 @@ class MainWindow(QMainWindow): self.scanner.stop() if self.thumbnail_generator and self.thumbnail_generator.isRunning(): self.thumbnail_generator.stop() + if self.duplicate_detector and self.duplicate_detector.isRunning(): + self.duplicate_detector.stop() # Create a list of threads to wait for threads_to_wait = [] @@ -1891,10 +2034,11 @@ class MainWindow(QMainWindow): threads_to_wait.append(self.scanner) if self.thumbnail_generator and self.thumbnail_generator.isRunning(): threads_to_wait.append(self.thumbnail_generator) - if hasattr(self, 'cache_cleaner') and self.cache_cleaner and \ - self.cache_cleaner.isRunning(): - self.cache_cleaner.stop() + if hasattr(self, 'cache_cleaner') and self.cache_cleaner \ + and self.cache_cleaner.isRunning(): threads_to_wait.append(self.cache_cleaner) + if self.duplicate_detector and self.duplicate_detector.isRunning(): + threads_to_wait.append(self.duplicate_detector) # Wait for them to finish while keeping the UI responsive if threads_to_wait: @@ -1903,14 +2047,20 @@ class MainWindow(QMainWindow): for thread in threads_to_wait: while thread.isRunning(): - QApplication.processEvents() + if QApplication.instance(): # Check if QApplication is still valid + QApplication.processEvents() QThread.msleep(50) # Prevent high CPU usage + # Ensure all QRunnables in the shared thread pool are finished + if self.thread_pool_manager: + self.thread_pool_manager.get_pool().waitForDone() + + if self.duplicate_cache: + self.duplicate_cache.lmdb_close() QApplication.restoreOverrideCursor() # 2. Close the cache safely now that no threads are using it self.cache.lmdb_close() - self.save_config() def closeEvent(self, event): """Handles the main window close event to ensure graceful shutdown.""" @@ -2224,30 +2374,63 @@ class MainWindow(QMainWindow): if not selected_indexes: return - # For now, only handle single deletion - path = self.proxy_model.data(selected_indexes[0], PATH_ROLE) + paths = [] + for idx in selected_indexes: + path = self.proxy_model.data(idx, PATH_ROLE) + if path and path not in paths: + paths.append(path) - if permanent: - # Confirm permanent deletion + if not paths: + return + + # Determine actual permanent status based on setting if not explicitly passed + _permanent = permanent if permanent is not None \ + else not APP_CONFIG.get("default_delete_to_trash", True) + + if _permanent: confirm = QMessageBox(self) confirm.setIcon(QMessageBox.Warning) - confirm.setWindowTitle(UITexts.CONFIRM_DELETE_TITLE) - confirm.setText(UITexts.CONFIRM_DELETE_TEXT) - confirm.setInformativeText( - UITexts.CONFIRM_DELETE_INFO.format(os.path.basename(path))) + if len(paths) == 1: + confirm.setText(UITexts.CONFIRM_DELETE_TEXT) + 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.setInformativeText("This action CANNOT be undone.") confirm.setStandardButtons(QMessageBox.Yes | QMessageBox.No) confirm.setDefaultButton(QMessageBox.No) if confirm.exec() != QMessageBox.Yes: return + self.thumbnail_view.setUpdatesEnabled(False) try: - if permanent: + for path in paths: + self.delete_file_by_path(path, _permanent) + finally: + self.thumbnail_view.setUpdatesEnabled(True) + self.rebuild_view() + + def delete_file_by_path(self, path, permanent=None): + """ + Deletes a file and updates the application state. + Logic extracted from delete_current_image for reuse. + + Args: + path (str): The path to the file to delete. + permanent (bool, optional): If True, deletes permanently. If False, + sends to trash. If None, uses the + 'default_delete_to_trash' setting. + Defaults to None. + """ + _permanent = permanent if permanent is not None \ + else not APP_CONFIG.get("default_delete_to_trash", True) + try: + if _permanent: os.remove(path) else: # Use 'gio trash' for moving to trash can on Linux subprocess.run(["gio", "trash", path]) - # TODO: Handle multi-selection delete # Notify open viewers of the deletion for w in QApplication.topLevelWidgets(): if isinstance(w, ImageViewer): @@ -2260,13 +2443,15 @@ class MainWindow(QMainWindow): except (ValueError, RuntimeError): pass # Viewer might be closing or list out of sync - source_index = self.proxy_model.mapToSource(selected_indexes[0]) - if source_index.isValid(): - self.thumbnail_model.removeRow(source_index.row()) + if path in self._path_to_model_index: + p_idx = self._path_to_model_index[path] + if p_idx.isValid(): + self.thumbnail_model.removeRow(p_idx.row()) if path in self._path_to_model_index: del self._path_to_model_index[path] + self.duplicate_cache.remove_hash_for_path(path) # Remove from found_items_data to ensure consistency self.found_items_data = [x for x in self.found_items_data if x[0] != path] self._known_paths.discard(path) @@ -3626,6 +3811,24 @@ class MainWindow(QMainWindow): viewer.show() return viewer + def open_comparison_viewer(self, paths): + """ + Opens an ImageViewer specifically for comparing a set of paths. + """ + if not paths: + return + + viewer = ImageViewer(self.cache, paths, 0, None, 0, self) + 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) + + if len(paths) > 1: + viewer.set_comparison_mode(len(paths)) + viewer.show() + return viewer + def load_full_history(self): """Loads the persistent browsing/search history from its JSON file.""" if os.path.exists(HISTORY_PATH): @@ -3779,7 +3982,7 @@ class MainWindow(QMainWindow): # 1. Update the list of sizes for the main scanner to generate for # any NEW images (e.g., from scrolling down). It will now only # generate the tier needed for the current view. - constants.SCANNER_GENERATE_SIZES = [new_tier] + # SCANNER_GENERATE_SIZES = [new_tier] # 2. For all images ALREADY loaded, start a background job to # generate the newly required thumbnail size. This is interruptible. @@ -3961,8 +4164,10 @@ class MainWindow(QMainWindow): try: with open(CONFIG_PATH, 'r') as f: d = json.load(f) - except Exception: - pass # Ignore errors in config file + except Exception as e: + # Log the error to help diagnose why config might not be loading + print(f"Error loading config file {CONFIG_PATH}: {e}") + # import traceback; traceback.print_exc() # Uncomment for full traceback self.history = d.get("history", []) self.current_thumb_size = d.get("thumb_size", @@ -4074,12 +4279,10 @@ class MainWindow(QMainWindow): g_shortcuts_list.append([[k, mod_int], [act, ignore, desc, cat]]) APP_CONFIG["global_shortcuts"] = g_shortcuts_list - # Save geometry only if the window is visible - if self.isVisible(): - APP_CONFIG["geometry"] = {"x": self.x(), "y": self.y(), - "w": self.width(), "h": self.height()} + APP_CONFIG["geometry"] = {"x": self.x(), "y": self.y(), + "w": self.width(), "h": self.height()} - constants.save_app_config() + save_app_config() def resizeEvent(self, e): """Handles window resize events to trigger a debounced grid refresh.""" @@ -4200,6 +4403,12 @@ class MainWindow(QMainWindow): self.proxy_model.data(selected_indexes[0], PATH_ROLE)) self.populate_open_with_submenu(open_submenu, full_path) + # New action: Open in Fullscreen Viewer + 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])) + path = self.proxy_model.data(selected_indexes[0], PATH_ROLE) action_open_location = menu.addAction(QIcon.fromTheme("folder-search"), UITexts.CONTEXT_MENU_OPEN_SEARCH_LOCATION) @@ -4239,10 +4448,10 @@ class MainWindow(QMainWindow): action_rotate_cw.triggered.connect(lambda: self.rotate_current_image(90)) menu.addSeparator() - + # The 'move_to_trash' action now uses the configurable default behavior add_action_with_shortcut(menu, UITexts.CONTEXT_MENU_TRASH, "user-trash", "move_to_trash", - lambda: self.delete_current_image(permanent=False)) + lambda: self.delete_current_image(permanent=None)) add_action_with_shortcut(menu, UITexts.CONTEXT_MENU_DELETE, "edit-delete", "delete_permanently", lambda: self.delete_current_image(permanent=True)) @@ -4475,6 +4684,12 @@ class MainWindow(QMainWindow): full_path, initial_tags=tags, initial_rating=rating, parent=self) dlg.exec() + def open_in_fullscreen_viewer(self, proxy_index): + """Opens the selected image in a new ImageViewer in fullscreen mode.""" + viewer = self.open_viewer(proxy_index) + if viewer: + viewer.toggle_fullscreen() + def clear_thumbnail_cache(self): """Clears the entire in-memory and on-disk thumbnail cache.""" confirm = QMessageBox(self) @@ -4505,7 +4720,7 @@ class MainWindow(QMainWindow): for p in paths: p = os.path.abspath(p) if os.path.exists(p) and p not in self._known_paths: - if os.path.splitext(p)[1].lower() in constants.IMAGE_EXTENSIONS: + if os.path.splitext(p)[1].lower() in IMAGE_EXTENSIONS: valid_new_items.append(p) if not valid_new_items: @@ -4584,8 +4799,8 @@ class MainWindow(QMainWindow): old_path = os.path.abspath(old_path) new_path = os.path.abspath(new_path) - is_old_img = os.path.splitext(old_path)[1].lower() in constants.IMAGE_EXTENSIONS - is_new_img = os.path.splitext(new_path)[1].lower() in constants.IMAGE_EXTENSIONS + is_old_img = os.path.splitext(old_path)[1].lower() in IMAGE_EXTENSIONS + is_new_img = os.path.splitext(new_path)[1].lower() in IMAGE_EXTENSIONS if is_old_img and is_new_img: if old_path in self._known_paths: @@ -4665,6 +4880,7 @@ class MainWindow(QMainWindow): self._known_paths.add(new_path) # Clean up group cache since the key (path) has changed + self.duplicate_cache.rename_entry(old_path, new_path) cache_key = (old_path, item_data[2], item_data[4]) if cache_key in self._group_info_cache: del self._group_info_cache[cache_key] @@ -4821,7 +5037,7 @@ class MainWindow(QMainWindow): # Only save and show message if the language actually changed if new_lang != APP_CONFIG.get("language", DEFAULT_LANGUAGE): APP_CONFIG["language"] = new_lang - constants.save_app_config() + save_app_config() # Inform user that a restart is needed for the change to take effect msg_box = QMessageBox(self) @@ -4832,6 +5048,85 @@ class MainWindow(QMainWindow): msg_box.setStandardButtons(QMessageBox.Ok) msg_box.exec() + def start_duplicate_detection(self, force_full=False, custom_paths=None): + """Initiates the duplicate image detection process.""" + if self.duplicate_detector and self.duplicate_detector.isRunning(): + QMessageBox.information(self, UITexts.DUPLICATE_DETECTION_TITLE, + UITexts.DUPLICATE_ALREADY_RUNNING) + 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() + if not paths_to_scan: + QMessageBox.information(self, UITexts.DUPLICATE_DETECTION_TITLE, + UITexts.DUPLICATE_NO_IMAGES) + return + + # Get settings from APP_CONFIG + method = APP_CONFIG.get("duplicate_method", "histogram_hashing") + 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) + + 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) + self.progress_bar.show() + self.btn_cancel_duplicates.show() + self.status_counter_lbl.show() + self.status_lbl.setText(UITexts.DUPLICATE_STARTING) + + self.duplicate_detector.start() + + def on_duplicate_detection_progress(self, current, total, message): + """Updates the UI with progress during duplicate detection.""" + percent = int((current / total) * 100) if total > 0 else 0 + + # Visual differentiation of detection phases using colors: + if percent < 50: + # Phase 1: Hashing images (Blue) + self.progress_bar.setCustomColor(QColor("#3498db")) + else: + # Phase 2: Mathematical comparison (Orange/Amber) + self.progress_bar.setCustomColor(QColor("#f39c12")) + + self.progress_bar.setValue(percent) + self.status_counter_lbl.setText(f"[{current}/{total}]") + self.status_lbl.setText(message) + + def on_duplicates_found(self, duplicates): + """Handles the list of found duplicate image pairs.""" + if not duplicates: + QMessageBox.information(self, UITexts.DUPLICATE_DETECTION_TITLE, + UITexts.DUPLICATE_NONE_FOUND) + return + + dialog = DuplicateManagerDialog(duplicates, self.duplicate_cache, self) + dialog.show() + + def on_duplicate_detection_finished(self): + """Cleans up after duplicate detection is complete.""" + self.progress_bar.setValue(100) + self.progress_bar.setCustomColor(QColor("#2ecc71")) # Green for success + self.hide_progress_timer.start(2000) # Hide after 2 seconds + self.btn_cancel_duplicates.hide() + self.status_counter_lbl.hide() + self.status_lbl.setText(UITexts.DUPLICATE_FINISHED) + self.duplicate_detector = None + + def cancel_duplicate_detection(self): + """Stops the duplicate detection thread.""" + if self.duplicate_detector and self.duplicate_detector.isRunning(): + self.duplicate_detector.stop() + self.duplicate_detector.wait() + self.status_lbl.setText(UITexts.CANCEL) + self.btn_cancel_duplicates.hide() + self.status_counter_lbl.hide() + def main(): """The main entry point for the Bagheera Image Viewer application.""" @@ -4840,16 +5135,16 @@ def main(): # Increase QPixmapCache limit (default is usually small, ~10MB) to ~100MB QPixmapCache.setCacheLimit(104857600) # Old value: 102400 + duplicate_cache = DuplicateCache() if HAVE_IMAGEHASH else None 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) + win = MainWindow(cache, args, thread_pool_manager, duplicate_cache) app.installEventFilter(win.shortcut_controller) sys.exit(app.exec()) diff --git a/changelog.txt b/changelog.txt index c6067b2..20296ba 100644 --- a/changelog.txt +++ b/changelog.txt @@ -51,6 +51,7 @@ How can I implement a bulk rename feature for the selected pet or face tags? ¿Cómo puedo añadir una opción "Abrir con otra aplicación..." al final del submenú que lance el selector de aplicaciones del sistema? ¿Cómo puedo añadir soporte para arrastrar y soltar imágenes desde el visor (ImageViewer) a otras aplicaciones? +· Añadir una opción al menú de contexto para "Abrir con el visor estándar de Bagheera" para ver la imagen a pantalla completa. ¿Por qué la selección de imágenes se pierde o cambia incorrectamente al cambiar el modo de agrupación? @@ -62,6 +63,9 @@ Ahora que la carga es rápida, ¿cómo puedo implementar una precarga inteligent ¿Cómo puedo hacer que la selección de archivos sea persistente incluso después de recargar o filtrar la vista? +v0.9.15 - +· Duplicates + v0.9.14 - · Corregido el problema de resolución de los thumbnails diff --git a/constants.py b/constants.py index 3ff63ad..988c7a3 100644 --- a/constants.py +++ b/constants.py @@ -29,7 +29,7 @@ if FORCE_X11: # --- CONFIGURATION --- PROG_NAME = "Bagheera Image Viewer" PROG_ID = "bagheeraview" -PROG_VERSION = "0.9.15" +PROG_VERSION = "0.9.16" PROG_AUTHOR = "Ignacio Serantes" # --- CACHE SETTINGS --- @@ -60,13 +60,18 @@ CONFIG_FILE = f"{PROG_ID}rc" CONFIG_LOCATION = '.config/iserantes' CONFIG_DIR = os.path.join(os.path.expanduser("~"), CONFIG_LOCATION, PROG_ID) CONFIG_PATH = os.path.join(CONFIG_DIR, CONFIG_FILE) -CACHE_PATH = os.path.join(CONFIG_DIR, "thumbnails_lmdb") +CACHE_PATH = os.path.join(CONFIG_DIR, "thumbnails") HISTORY_FILE = "history.json" HISTORY_PATH = os.path.join(CONFIG_DIR, HISTORY_FILE) LAYOUTS_DIR = os.path.join(CONFIG_DIR, "layouts") # Layouts saving directory FAVORITES_FILE = "favorites.json" FAVORITES_PATH = os.path.join(CONFIG_DIR, FAVORITES_FILE) +FAVORITES_PATH = os.path.join(CONFIG_DIR, FAVORITES_FILE) +DUPLICATE_CACHE_PATH = os.path.join(CONFIG_DIR, "duplicates") +DUPLICATE_HASH_DB_NAME = b"hashes" +DUPLICATE_EXCEPTIONS_DB_NAME = b"exceptions" +DUPLICATE_PENDING_DB_NAME = b"pending" def save_app_config(): @@ -76,9 +81,8 @@ def save_app_config(): with open(CONFIG_PATH, 'w', encoding='utf-8') as f: # Use APP_CONFIG global json.dump(APP_CONFIG, f, indent=4) - except OSError: - # Silently fail for now, but could log this - pass + except Exception as e: + print(f"CRITICAL: Failed to save configuration to {CONFIG_PATH}: {e}") # --- CONFIGURATION LOADING --- @@ -133,7 +137,13 @@ SCANNER_SETTINGS_DEFAULTS = { "scan_full_on_start": True, "person_tags": "", "generation_threads": 4, - "search_engine": "" + "search_engine": "", + "duplicate_threshold": 90, # Similarity percentage (50-100) + "duplicate_method": "histogram_hashing", + "duplicate_confirm_delete": True, + "default_delete_to_trash": True, + "duplicate_whitelist": "", + "duplicate_blacklist": "" } # --- IMAGE VIEWER DEFAULTS --- @@ -224,6 +234,16 @@ if HAVE_MEDIAPIPE: DEFAULT_FACE_ENGINE = AVAILABLE_FACE_ENGINES[0] if AVAILABLE_FACE_ENGINES else None DEFAULT_PET_ENGINE = AVAILABLE_PET_ENGINES[0] if AVAILABLE_PET_ENGINES else None +HAVE_IMAGEHASH = importlib.util.find_spec("imagehash") is not None + +# --- DUPLICATE DETECTION --- +HAVE_DUPLICATE_RESNET_LIBS = all( + importlib.util.find_spec(lib) is not None + for lib in ["torch", "torchvision", "numpy", "sklearn"] +) + +MAX_DHASH_DISTANCE = 64 # For 64-bit dHash + DEFAULT_FACE_BOX_COLOR = "#FFFFFF" # Load preferred engine from config, or use the default. FACE_DETECTION_ENGINE = APP_CONFIG.get("face_detection_engine", @@ -379,6 +399,7 @@ _UI_TEXTS = { "LOAD": "Load", "SAVE": "Save", "CREATE": "Create", + "CANCEL": "Cancel", "RENAME": "Rename", "COPY": "Copy", "DELETE": "Delete", @@ -489,6 +510,58 @@ _UI_TEXTS = { "MENU_SHOW_LAYOUTS": "Show Layouts", "MENU_SHOW_HISTORY": "Show History", "MENU_SETTINGS": "Settings", + "SETTINGS_GROUP_DUPLICATES": "Duplicates", + "MENU_DUPLICATES": "Duplicates", + "MENU_DETECT_CURRENT_SEARCH": "Detect in current search", + "MENU_DETECT_ALL": "Detect all", + "MENU_FORCE_FULL_ANALYSIS": "Force full analysis", + "MENU_REVIEW_IGNORED": "Review ignored", + "MENU_CLEAN_UP_HASHES": "Clean up", + "MENU_CLEAR_HASHES": "Clear hashes ({} items, {:.1f} MB on disk)", + "CONFIRM_CLEAR_HASHES_TITLE": "Confirm Clear Hashes", + "CONFIRM_CLEAR_HASHES_TEXT": "Are you sure you want to permanently delete the entire hash database?", + "CONFIRM_CLEAR_HASHES_INFO": "This will remove all calculated image hashes. They will be recalculated as you detect duplicates, which may be slow. This action cannot be undone.", + "SETTINGS_DUPLICATE_METHOD_LABEL": "Method:", + "SETTINGS_DUPLICATE_METHOD_TOOLTIP": "Select the method for duplicate detection.", + "METHOD_HISTOGRAM_HASHING": "Histogram + Hashing", + "METHOD_RESNET": "ResNet (AI Based)", + "SETTINGS_DUPLICATE_CONFIRM_DELETE_LABEL": "Confirm before deleting duplicates", + "SETTINGS_DUPLICATE_WHITELIST_LABEL": "Whitelist (folders to include):", + "SETTINGS_DUPLICATE_WHITELIST_TOOLTIP": "Comma-separated paths of folders to scan when using 'Detect all'.", + "SETTINGS_DUPLICATE_BLACKLIST_LABEL": "Blacklist (folders to exclude):", + "SETTINGS_DUPLICATE_BLACKLIST_TOOLTIP": "Comma-separated paths of folders to ignore during 'Detect all' scans.", + "SETTINGS_DUPLICATE_SCAN_COUNT_LABEL": "Images found for 'Detect all': {}", + "SETTINGS_DEFAULT_DELETE_TO_TRASH_LABEL": "Delete key sends to trash by default", + "SETTINGS_DEFAULT_DELETE_TO_TRASH_TOOLTIP": "If checked, pressing the Delete key will move files to trash. If unchecked, it will permanently delete them.", + "SETTINGS_DUPLICATE_CONFIRM_DELETE_TOOLTIP": "Show a confirmation dialog before moving a duplicate image to the trash.", + "SETTINGS_DUPLICATE_THRESHOLD_LABEL": "Similarity Threshold:", + "SETTINGS_DUPLICATE_THRESHOLD_TOOLTIP": "Set the similarity threshold (50-100%). Higher values mean images must be more similar to be considered duplicates.", + "SETTINGS_DUPLICATE_MISSING_LIBS": "The 'imagehash' library is required for duplicate detection but was not found. This feature is disabled.", + "MENU_DETECT_DUPLICATES": "Detect Duplicates", + "DUPLICATE_DETECTION_TITLE": "Duplicate Detection", + "DUPLICATE_ALREADY_RUNNING": "Duplicate detection is already in progress.", + "DUPLICATE_NO_IMAGES": "No images loaded to detect duplicates.", + "DUPLICATE_STARTING": "Starting duplicate detection...", + "DUPLICATE_PROGRESS": "Duplicate detection: {message} ({current}/{total})", + "DUPLICATE_NONE_FOUND": "No duplicates found.", + "DUPLICATE_FOUND_TITLE": "Duplicates Found", + "DUPLICATE_FOUND_MSG": "The following duplicates were found:\n", + "DUPLICATE_FOUND_MORE": "... and {count} more.", + "DUPLICATE_FINISHED": "Duplicate detection finished.", + "DUPLICATE_MSG_HASHING": "Hashing {filename}", + "DUPLICATE_MSG_ANALYZING": "Analyzing {filename}", + "DUPLICATE_MANAGER_TITLE": "Manage Duplicate Images", + "DUPLICATE_DELETE_LEFT": "Trash Left", + "DUPLICATE_DELETE_RIGHT": "Trash Right", + "CONFIRM_TRASH_TITLE": "Move to Trash", + "CONFIRM_TRASH_TEXT": "Do you want to move this image to the trash?", + "DUPLICATE_KEEP_BOTH": "Keep Both (Ignore)", + "DUPLICATE_SKIP": "Skip", + "DUPLICATE_REMOVE_IGNORED": "Remove from ignored", + "DUPLICATE_INFO_FORMAT": "{size} - {width}x{height}", + "VIEWER_MENU_LINK_PANES": "Link Panes", + "DUPLICATE_OPEN_COMPARISON": "Open Comparison", + "DUPLICATE_LIST_HEADER": "Duplicate Pairs", "SETTINGS_GROUP_SCANNER": "Scanner", "SETTINGS_GROUP_AREAS": "Areas", "SETTINGS_GROUP_THUMBNAILS": "Thumbnails", @@ -806,6 +879,7 @@ _UI_TEXTS = { "CONTEXT_MENU_OPEN": "Open", "CONTEXT_MENU_OPEN_SEARCH_LOCATION": "Open and search location", "CONTEXT_MENU_OPEN_DEFAULT_APP": "Open location with default application", + "CONTEXT_MENU_OPEN_FULLSCREEN_VIEWER": "Open in Fullscreen Viewer", "CONTEXT_MENU_MOVE_TO": "Move to...", "CONTEXT_MENU_COPY_TO": "Copy to...", "CONTEXT_MENU_ROTATE": "Rotate", @@ -844,6 +918,7 @@ _UI_TEXTS = { "LOAD": "Cargar", "SAVE": "Guardar", "CREATE": "Crear", + "CANCEL": "Cancelar", "RENAME": "Renombrar", "COPY": "Copiar", "DELETE": "Eliminar", @@ -954,6 +1029,58 @@ _UI_TEXTS = { "MENU_SHOW_LAYOUTS": "Mostrar Diseños", "MENU_SHOW_HISTORY": "Mostrar Historial", "MENU_SETTINGS": "Opciones", + "SETTINGS_GROUP_DUPLICATES": "Duplicados", + "MENU_DUPLICATES": "Duplicados", + "MENU_DETECT_CURRENT_SEARCH": "Detectar en búsqueda actual", + "MENU_DETECT_ALL": "Detectar todos", + "MENU_FORCE_FULL_ANALYSIS": "Forzar análisis completo", + "MENU_REVIEW_IGNORED": "Revisar ignorados", + "MENU_CLEAN_UP_HASHES": "Limpiar", + "MENU_CLEAR_HASHES": "Limpiar hashes ({} ítems, {:.1f} MB en disco)", + "CONFIRM_CLEAR_HASHES_TITLE": "Confirmar Limpieza de Hashes", + "CONFIRM_CLEAR_HASHES_TEXT": "¿Seguro que quieres eliminar permanentemente toda la base de datos de hashes?", + "CONFIRM_CLEAR_HASHES_INFO": "Esto eliminará todos los hashes de imágenes calculados. Se recalcularán a medida que detectes duplicados, lo que puede ser lento. Esta acción no se puede deshacer.", + "SETTINGS_DUPLICATE_METHOD_LABEL": "Método:", + "SETTINGS_DUPLICATE_METHOD_TOOLTIP": "Selecciona el método para la detección de duplicados.", + "METHOD_HISTOGRAM_HASHING": "Histograma + Hashing", + "METHOD_RESNET": "ResNet (Basado en IA)", + "SETTINGS_DUPLICATE_CONFIRM_DELETE_LABEL": "Confirmar antes de borrar duplicados", + "SETTINGS_DUPLICATE_WHITELIST_LABEL": "Lista blanca (carpetas a incluir):", + "SETTINGS_DUPLICATE_WHITELIST_TOOLTIP": "Rutas de carpetas separadas por comas para escanear al usar 'Detectar todos'.", + "SETTINGS_DUPLICATE_BLACKLIST_LABEL": "Lista negra (carpetas a excluir):", + "SETTINGS_DUPLICATE_BLACKLIST_TOOLTIP": "Rutas de carpetas separadas por comas para ignorar durante escaneos de 'Detectar todos'.", + "SETTINGS_DUPLICATE_SCAN_COUNT_LABEL": "Imágenes encontradas para 'Detectar todos': {}", + "SETTINGS_DEFAULT_DELETE_TO_TRASH_LABEL": "La tecla Supr envía a la papelera por defecto", + "SETTINGS_DEFAULT_DELETE_TO_TRASH_TOOLTIP": "Si está marcada, al pulsar la tecla Supr se moverán los archivos a la papelera. Si no, se eliminarán permanentemente.", + "SETTINGS_DUPLICATE_CONFIRM_DELETE_TOOLTIP": "Muestra un diálogo de confirmación antes de mover una imagen duplicada a la papelera.", + "SETTINGS_DUPLICATE_THRESHOLD_LABEL": "Umbral de Similitud:", + "SETTINGS_DUPLICATE_THRESHOLD_TOOLTIP": "Establece el umbral de similitud (50-100%). Valores más altos significan que las imágenes deben ser más parecidas para considerarse duplicadas.", + "SETTINGS_DUPLICATE_MISSING_LIBS": "La librería 'imagehash' es necesaria para la detección de duplicados pero no se ha encontrado. Esta función está desactivada.", + "MENU_DETECT_DUPLICATES": "Detectar Duplicados", + "DUPLICATE_DETECTION_TITLE": "Detección de Duplicados", + "DUPLICATE_ALREADY_RUNNING": "La detección de duplicados ya está en curso.", + "DUPLICATE_NO_IMAGES": "No hay imágenes cargadas para detectar duplicados.", + "DUPLICATE_STARTING": "Iniciando detección de duplicados...", + "DUPLICATE_PROGRESS": "Detección de duplicados: {message} ({current}/{total})", + "DUPLICATE_NONE_FOUND": "No se encontraron duplicados.", + "DUPLICATE_FOUND_TITLE": "Duplicados Encontrados", + "DUPLICATE_FOUND_MSG": "Se encontraron los siguientes duplicados:\n", + "DUPLICATE_FOUND_MORE": "... y {count} más.", + "DUPLICATE_FINISHED": "Detección de duplicados finalizada.", + "DUPLICATE_MSG_HASHING": "Procesando {filename}", + "DUPLICATE_MSG_ANALYZING": "Analizando {filename}", + "DUPLICATE_MANAGER_TITLE": "Gestionar Imágenes Duplicadas", + "DUPLICATE_DELETE_LEFT": "Papelera Izquierda", + "DUPLICATE_DELETE_RIGHT": "Papelera Derecha", + "CONFIRM_TRASH_TITLE": "Mover a la papelera", + "CONFIRM_TRASH_TEXT": "¿Deseas mover esta imagen a la papelera?", + "DUPLICATE_KEEP_BOTH": "Mantener Ambas (Ignorar)", + "DUPLICATE_SKIP": "Omitir", + "DUPLICATE_REMOVE_IGNORED": "Eliminar de ignorados", + "DUPLICATE_INFO_FORMAT": "{size} - {width}x{height}", + "VIEWER_MENU_LINK_PANES": "Vincular Paneles", + "DUPLICATE_OPEN_COMPARISON": "Abrir Comparación", + "DUPLICATE_LIST_HEADER": "Parejas Duplicadas", "SETTINGS_GROUP_SCANNER": "Escáner", "SETTINGS_GROUP_AREAS": "Áreas", "SETTINGS_GROUP_THUMBNAILS": "Miniaturas", @@ -1278,6 +1405,7 @@ _UI_TEXTS = { "CONTEXT_MENU_OPEN": "Abrir", "CONTEXT_MENU_OPEN_SEARCH_LOCATION": "Abrir y buscar en ubicación", "CONTEXT_MENU_OPEN_DEFAULT_APP": "Abrir ubicación con aplicación por defecto", + "CONTEXT_MENU_OPEN_FULLSCREEN_VIEWER": "Abrir con Visor a Pantalla Completa", "CONTEXT_MENU_MOVE_TO": "Mover a...", "CONTEXT_MENU_COPY_TO": "Copiar a...", "CONTEXT_MENU_ROTATE": "Girar", @@ -1317,6 +1445,7 @@ _UI_TEXTS = { "LOAD": "Cargar", "SAVE": "Gardar", "CREATE": "Crear", + "CANCEL": "Cancelar", "RENAME": "Renomear", "COPY": "Copiar", "DELETE": "Eliminar", @@ -1428,6 +1557,58 @@ _UI_TEXTS = { "MENU_SHOW_LAYOUTS": "Amosar Deseños", "MENU_SHOW_HISTORY": "Amosar Historial", "MENU_SETTINGS": "Opcións", + "SETTINGS_GROUP_DUPLICATES": "Duplicados", + "MENU_DUPLICATES": "Duplicados", + "MENU_DETECT_CURRENT_SEARCH": "Detectar na busca actual", + "MENU_DETECT_ALL": "Detectar todos", + "MENU_FORCE_FULL_ANALYSIS": "Forzar análise completa", + "MENU_REVIEW_IGNORED": "Revisar ignorados", + "MENU_CLEAN_UP_HASHES": "Limpar", + "MENU_CLEAR_HASHES": "Limpar hashes ({} elementos, {:.1f} MB en disco)", + "CONFIRM_CLEAR_HASHES_TITLE": "Confirmar Limpeza de Hashes", + "CONFIRM_CLEAR_HASHES_TEXT": "Seguro que queres eliminar permanentemente toda a base de datos de hashes?", + "CONFIRM_CLEAR_HASHES_INFO": "Isto eliminará todos os hashes de imaxes calculados. Rexeneraranse a medida que detectes duplicados, o que pode ser lento. Esta acción non se pode deshacer.", + "SETTINGS_DUPLICATE_METHOD_LABEL": "Método:", + "SETTINGS_DUPLICATE_METHOD_TOOLTIP": "Selecciona o método para a detección de duplicados.", + "METHOD_HISTOGRAM_HASHING": "Histograma + Hashing", + "METHOD_RESNET": "ResNet (Baseado en IA)", + "SETTINGS_DUPLICATE_CONFIRM_DELETE_LABEL": "Confirmar antes de borrar duplicados", + "SETTINGS_DUPLICATE_WHITELIST_LABEL": "Lista branca (cartafoles a incluír):", + "SETTINGS_DUPLICATE_WHITELIST_TOOLTIP": "Rutas de cartafoles separadas por comas para escanear ao usar 'Detectar todos'.", + "SETTINGS_DUPLICATE_BLACKLIST_LABEL": "Lista negra (cartafoles a excluír):", + "SETTINGS_DUPLICATE_BLACKLIST_TOOLTIP": "Rutas de cartafoles separadas por comas para ignorar durante escaneos de 'Detectar todos'.", + "SETTINGS_DUPLICATE_SCAN_COUNT_LABEL": "Imaxes atopadas para 'Detectar todos': {}", + "SETTINGS_DEFAULT_DELETE_TO_TRASH_LABEL": "A tecla Supr envía á papeleira por defecto", + "SETTINGS_DEFAULT_DELETE_TO_TRASH_TOOLTIP": "Se está marcada, ao premer a tecla Supr moveranse os ficheiros á papeleira. Se non, eliminaranse permanentemente.", + "SETTINGS_DUPLICATE_CONFIRM_DELETE_TOOLTIP": "Amosa un diálogo de confirmación antes de mover unha imaxe duplicada á papeleira.", + "SETTINGS_DUPLICATE_THRESHOLD_LABEL": "Umbral de Similitude:", + "SETTINGS_DUPLICATE_THRESHOLD_TOOLTIP": "Establece o umbral de similitude (50-100%). Valores máis altos significan que as imaxes deben ser máis parecidas para considerarse duplicadas.", + "SETTINGS_DUPLICATE_MISSING_LIBS": "A librería 'imagehash' é necesaria para a detección de duplicados pero non se atopou. Esta función está desactivada.", + "MENU_DETECT_DUPLICATES": "Detectar Duplicados", + "DUPLICATE_DETECTION_TITLE": "Detección de Duplicados", + "DUPLICATE_ALREADY_RUNNING": "A detección de duplicados xa está en curso.", + "DUPLICATE_NO_IMAGES": "Non hai imaxes cargadas para detectar duplicados.", + "DUPLICATE_STARTING": "Iniciando detección de duplicados...", + "DUPLICATE_PROGRESS": "Detección de duplicados: {message} ({current}/{total})", + "DUPLICATE_NONE_FOUND": "Non se atoparon duplicados.", + "DUPLICATE_FOUND_TITLE": "Duplicados Atopados", + "DUPLICATE_FOUND_MSG": "Atopáronse os seguintes duplicados:\n", + "DUPLICATE_FOUND_MORE": "... e {count} máis.", + "DUPLICATE_FINISHED": "Detección de duplicados finalizada.", + "DUPLICATE_MSG_HASHING": "Procesando {filename}", + "DUPLICATE_MSG_ANALYZING": "Analizando {filename}", + "DUPLICATE_MANAGER_TITLE": "Xestionar Imaxes Duplicadas", + "DUPLICATE_DELETE_LEFT": "Papeleira Esquerda", + "DUPLICATE_DELETE_RIGHT": "Papeleira Dereita", + "CONFIRM_TRASH_TITLE": "Mover á papeleira", + "CONFIRM_TRASH_TEXT": "Desexas mover esta imaxe á papeleira?", + "DUPLICATE_KEEP_BOTH": "Manter Ambas (Ignorar)", + "DUPLICATE_SKIP": "Omitir", + "DUPLICATE_REMOVE_IGNORED": "Eliminar de ignorados", + "DUPLICATE_INFO_FORMAT": "{size} - {width}x{height}", + "VIEWER_MENU_LINK_PANES": "Vincular Paneis", + "DUPLICATE_OPEN_COMPARISON": "Abrir Comparación", + "DUPLICATE_LIST_HEADER": "Parellas Duplicadas", "SETTINGS_GROUP_SCANNER": "Escáner", "SETTINGS_GROUP_AREAS": "Áreas", "SETTINGS_GROUP_THUMBNAILS": "Miniaturas", @@ -1762,6 +1943,7 @@ _UI_TEXTS = { "CONTEXT_MENU_COPY_FILE": "Copiar URL do Ficheiro", "CONTEXT_MENU_COPY_DIR": "Copiar Ruta do Directorio", "CONTEXT_MENU_PROPERTIES": "Propiedades", + "CONTEXT_MENU_OPEN_FULLSCREEN_VIEWER": "Abrir con Visor a Pantalla Completa", "CONTEXT_MENU_NO_APPS_FOUND": "Non se atoparon aplicacións", "CONTEXT_MENU_REGENERATE": "Rexenerar Miniatura", "CONTEXT_MENU_ERROR_LISTING_APPS": "Erro listando aplicacións", diff --git a/duplicatecache.py b/duplicatecache.py new file mode 100644 index 0000000..4655246 --- /dev/null +++ b/duplicatecache.py @@ -0,0 +1,723 @@ +import os +import logging +import struct +import time +import collections +import shutil +import lmdb +from pathlib import Path +import PIL.Image + +from PySide6.QtCore import ( + QObject, QThread, Signal, QMutex, QSemaphore, QReadWriteLock, + QMutexLocker, QReadLocker, QWriteLocker, QRunnable +) + +import imagehash # For perceptual hashing + +from constants import ( + DUPLICATE_CACHE_PATH, DUPLICATE_HASH_DB_NAME, + DUPLICATE_EXCEPTIONS_DB_NAME, DUPLICATE_PENDING_DB_NAME, + MAX_DHASH_DISTANCE, UITexts +) + +logger = logging.getLogger(__name__) + +# Result structure for duplicate detection +DuplicateResult = collections.namedtuple('DuplicateResult', ['path1', 'path2', 'hash_value', 'is_exception', 'similarity']) + + +class BKTree: + """A Burkhard-Keller tree for efficient similarity searching using Hamming distance.""" + def __init__(self, distance_func): + self.distance_func = distance_func + self.tree = None + + def add(self, item): + if self.tree is None: + self.tree = (item, {}) + return + node = self.tree + while True: + val, children = node + dist = self.distance_func(item, val) + if dist == 0: + return + if dist in children: + node = children[dist] + else: + children[dist] = (item, {}) + break + + def query(self, item, max_dist): + if self.tree is None: + return [] + results = [] + candidates = [self.tree] + while candidates: + val, children = candidates.pop() + dist = self.distance_func(item, val) + if dist <= max_dist: + results.append((val, dist)) + for d in range(max(0, dist - max_dist), dist + max_dist + 1): + if d in children: + candidates.append(children[d]) + return results + + +class HashWorker(QRunnable): + """Worker to calculate image hash in a thread pool.""" + def __init__(self, path, detector, result_dict, mutex, semaphore): + super().__init__() + self.path = path + self.detector = detector + self.result_dict = result_dict + self.mutex = mutex + self.semaphore = semaphore + + def run(self): + if self.detector._is_running: + try: + # imagehash requires a PIL/Pillow image object. + with PIL.Image.open(self.path) as pil_img: + # Using dHash from imagehash library as default + h = str(imagehash.dhash(pil_img)) + with QMutexLocker(self.mutex): + self.result_dict[self.path] = h + except Exception as e: + logger.warning(f"HashWorker failed for {self.path}: {e}") + + self.semaphore.release() + + +class DuplicateCache(QObject): + """ + Manages a persistent LMDB cache for perceptual hashes and duplicate relationships. + Uses (device_id, inode) as primary keys for robustness against file renames/moves. + """ + def __init__(self): + super().__init__() + self._lmdb_env = None + self._hash_db = None + self._exceptions_db = None + self._pending_db = None + self._db_lock = QMutex() # Protects LMDB transactions + # In-memory cache for hashes: (dev, inode) -> (hash_value, path) + self._hash_cache = {} + self._hash_cache_lock = QReadWriteLock() + + self.lmdb_open() + + def lmdb_open(self): + cache_dir = Path(DUPLICATE_CACHE_PATH) + cache_dir.mkdir(parents=True, exist_ok=True) + + try: + self._lmdb_env = lmdb.open( + DUPLICATE_CACHE_PATH, + map_size=10 * 1024 * 1024 * 1024, # 10GB default + max_dbs=3, # For hashes, exceptions and pending + readonly=False, + create=True + ) + self._hash_db = self._lmdb_env.open_db(DUPLICATE_HASH_DB_NAME) + self._exceptions_db = self._lmdb_env.open_db(DUPLICATE_EXCEPTIONS_DB_NAME) + self._pending_db = self._lmdb_env.open_db(DUPLICATE_PENDING_DB_NAME) + logger.info(f"Duplicate LMDB cache opened: {DUPLICATE_CACHE_PATH}") + except Exception as e: + logger.error(f"Failed to open duplicate LMDB cache: {e}") + self._lmdb_env = None + + def lmdb_close(self): + if self._lmdb_env: + self._lmdb_env.close() + self._lmdb_env = None + self._hash_db = None + self._exceptions_db = None + self._pending_db = None + + def get_hash_stats(self): + """Returns (count, size_bytes) for the hash database.""" + count = 0 + if not self._lmdb_env: + return 0, 0 + + with QMutexLocker(self._db_lock): + with self._lmdb_env.begin(write=False) as txn: + count = txn.stat(db=self._hash_db)['entries'] + + size = 0 + disk_path = os.path.join(DUPLICATE_CACHE_PATH, "data.mdb") + if os.path.exists(disk_path): + size = os.path.getsize(disk_path) + + return count, size + + def clear_hashes(self): + """Clears all hashes from the database by recreating the environment.""" + with QWriteLocker(self._hash_cache_lock): + self._hash_cache.clear() + + self.lmdb_close() + try: + if os.path.exists(DUPLICATE_CACHE_PATH): + shutil.rmtree(DUPLICATE_CACHE_PATH) + self.lmdb_open() + logger.info("Duplicate hash cache cleared.") + except Exception as e: + logger.error(f"Error clearing duplicate LMDB: {e}") + + def __del__(self): + self.lmdb_close() + + @staticmethod + def _get_inode_info(path): + try: + stat_info = os.stat(path) + return stat_info.st_dev, struct.pack('Q', stat_info.st_ino) + except OSError: + return 0, None + + def _get_lmdb_key(self, dev_id, inode_key_bytes): + return f"{dev_id}-{inode_key_bytes.hex()}".encode('utf-8') + + def get_hash_and_path(self, dev_id, inode_key_bytes): + """Retrieves hash, mtime and path for a given (dev_id, inode_key_bytes).""" + # Check in-memory cache first + with QReadLocker(self._hash_cache_lock): + cached_data = self._hash_cache.get((dev_id, inode_key_bytes)) + if cached_data: + return cached_data # (hash_value, mtime, path) + + # Check LMDB + if not self._lmdb_env: + return None, 0, None + + with QMutexLocker(self._db_lock): + with self._lmdb_env.begin(write=False) as txn: + lmdb_key = self._get_lmdb_key(dev_id, inode_key_bytes) + value_bytes = txn.get(lmdb_key, db=self._hash_db) + if value_bytes: + # Handle format "hash_value_str|mtime|path_str" or old "hash|path" + parts = value_bytes.decode('utf-8').split('|', 2) + if len(parts) == 3: + hash_str, mtime_str, path_str = parts + mtime = float(mtime_str) + elif len(parts) == 2: + hash_str, path_str = parts + mtime = 0.0 # Force re-hash + else: + return None, 0, None + + with QWriteLocker(self._hash_cache_lock): + self._hash_cache[(dev_id, inode_key_bytes)] = (hash_str, mtime, path_str) + return hash_str, mtime, path_str + return None, 0, None + + def get_hash_for_path(self, path, current_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: + return None + hash_value, cached_mtime, _ = self.get_hash_and_path(dev_id, inode_key_bytes) + # Return hash only if mtime matches (with small float tolerance) + if hash_value and abs(cached_mtime - current_mtime) < 0.001: + return hash_value + return 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: + return False + + value_str = f"{hash_value}|{mtime}|{path}" + with QMutexLocker(self._db_lock): + with self._lmdb_env.begin(write=True) as txn: + lmdb_key = self._get_lmdb_key(dev_id, inode_key_bytes) + txn.put(lmdb_key, value_str.encode('utf-8'), db=self._hash_db) + + with QWriteLocker(self._hash_cache_lock): + self._hash_cache[(dev_id, inode_key_bytes)] = (hash_value, mtime, path) + return True + + def remove_hash_for_path(self, path): + dev_id, inode_key_bytes = self._get_inode_info(path) + if not inode_key_bytes or not self._lmdb_env: + return False + + with QMutexLocker(self._db_lock): + with self._lmdb_env.begin(write=True) as txn: + lmdb_key = self._get_lmdb_key(dev_id, inode_key_bytes) + txn.delete(lmdb_key, db=self._hash_db) + + with QWriteLocker(self._hash_cache_lock): + 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) + return True + + def _get_pair_lmdb_key_from_ids(self, dev1, inode1, dev2, inode2): + # Ensure canonical order for exception keys + key_parts = sorted([f"{dev1}-{inode1.hex()}", f"{dev2}-{inode2.hex()}"]) + return f"{key_parts[0]}-{key_parts[1]}".encode('utf-8') + + def _get_pair_lmdb_key(self, path1, path2): + dev1, inode1 = self._get_inode_info(path1) + dev2, inode2 = self._get_inode_info(path2) + if not inode1 or not inode2: + 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): + if not self._lmdb_env: + return False + + dev1, inode1 = self._get_inode_info(path1) + dev2, inode2 = self._get_inode_info(path2) + if not inode1 or not inode2: + return False + + exception_key = self._get_pair_lmdb_key_from_ids(dev1, inode1, dev2, inode2) + if not exception_key: + 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}" + value = val_str.encode('utf-8') + + with QMutexLocker(self._db_lock): + with self._lmdb_env.begin(write=True) as txn: + if is_exception: + txn.put(exception_key, value, db=self._exceptions_db) + else: + txn.delete(exception_key, db=self._exceptions_db) + return True + + def is_exception(self, path1, path2): + if not self._lmdb_env: + return False + + dev1, inode1 = self._get_inode_info(path1) + dev2, inode2 = self._get_inode_info(path2) + if not inode1 or not inode2: + return False + + exception_key = self._get_pair_lmdb_key_from_ids(dev1, inode1, dev2, inode2) + if not exception_key: + return False + + with QMutexLocker(self._db_lock): + 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): + """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] + + 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: + txn.delete(key, db=db_handle) + + def mark_as_pending(self, path1, path2, is_pending=True, similarity=None): + """Marks a pair as pending review.""" + if not self._lmdb_env or self._pending_db is None: + return False + + key = self._get_pair_lmdb_key(path1, path2) + if not key: + 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}" + value = val_str.encode('utf-8') + + with QMutexLocker(self._db_lock): + with self._lmdb_env.begin(write=True) as txn: + if is_pending: + txn.put(key, value, db=self._pending_db) + else: + # Check if it exists before deleting to avoid errors + if txn.get(key, db=self._pending_db): + txn.delete(key, db=self._pending_db) + return True + + def get_all_pending_duplicates(self): + """Retrieves all pending duplicate pairs from the database.""" + results = [] + if not self._lmdb_env or self._pending_db is None: + return results + + keys_to_delete = [] + with QMutexLocker(self._db_lock): + with self._lmdb_env.begin(write=False) as txn: + cursor = txn.cursor(db=self._pending_db) + for key, value_bytes in cursor: + try: + parts = value_bytes.decode('utf-8').split('|') + p1, p2 = parts[0], parts[1] + sim = int(parts[2]) if len(parts) > 2 else None + if os.path.exists(p1) and os.path.exists(p2): + results.append(DuplicateResult(p1, p2, None, False, sim)) + else: + keys_to_delete.append(key) + except Exception: + keys_to_delete.append(key) + continue + + if keys_to_delete: + try: + 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)") + except Exception as e: + logger.error(f"Error cleaning up pending duplicates from DB: {e}") + + return results + + def get_all_exceptions(self): + """Retrieves all duplicate pairs marked as exceptions from the database.""" + results = [] + if not self._lmdb_env or self._exceptions_db is None: + return results + + with QMutexLocker(self._db_lock): + with self._lmdb_env.begin(write=False) as txn: + cursor = txn.cursor(db=self._exceptions_db) + for key_bytes, value_bytes in cursor: + try: + p1, p2 = None, None + sim = None + val_str = value_bytes.decode('utf-8') + + if '|' in val_str: + # New format: paths are stored in the value + parts = val_str.split('|') + if len(parts) >= 2: + p1, p2 = parts[0], parts[1] + if len(parts) > 2: + sim = int(parts[2]) + + 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) + if v1 and v2: + # 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)) + except Exception: + continue + return results + + def clean_stale_hashes(self): + """ + Removes hash entries from the database for files that no longer exist on disk. + """ + if not self._lmdb_env or self._hash_db is None: + return 0 + + keys_to_delete = [] + with QMutexLocker(self._db_lock): + with self._lmdb_env.begin(write=False) as txn: + cursor = txn.cursor(db=self._hash_db) + for key, value_bytes in cursor: + try: + # value_bytes is "hash|mtime|path|last_dist" + parts = value_bytes.decode('utf-8').split('|') + if len(parts) >= 3: + path = parts[2] + if not os.path.exists(path): + keys_to_delete.append(key) + except Exception: + keys_to_delete.append(key) # Corrupted entry + continue + + if keys_to_delete: + 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)") + 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.""" + # hash_value -> [(path, dev_id, inode_key_bytes)] + all_hashes = collections.defaultdict(list) + if not self._lmdb_env: + return all_hashes + + with QMutexLocker(self._db_lock): + with self._lmdb_env.begin(write=False) as txn: + cursor = txn.cursor(db=self._hash_db) + for key_bytes, value_bytes in cursor: + # key_bytes is like "dev_id-inode_hex" + key_str = key_bytes.decode('utf-8') + parts = key_str.split('-') + dev_id = int(parts[0]) + inode_key_bytes = bytes.fromhex(parts[1]) + + # value_bytes is "hash|mtime|path|last_dist" + parts_val = value_bytes.decode('utf-8').split('|') + if len(parts_val) >= 3: + hash_value = parts_val[0] + path = parts_val[2] + else: + continue + + all_hashes[hash_value].append((path, dev_id, inode_key_bytes)) + return all_hashes + + def rename_entry(self, old_path, new_path): + """ + Updates the cache entry for a file that has been renamed or moved. + This involves deleting the old (dev, inode) entry and adding a new one + with the new (dev, inode) and path, preserving the hash value. + """ + old_dev, old_inode_key_bytes = self._get_inode_info(old_path) + new_dev, new_inode_key_bytes = self._get_inode_info(new_path) + + 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. + # 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) + if hash_value: + 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 + + # If (dev, inode) changed (cross-filesystem move), we need to: + # 1. Get the hash from the old entry. + # 2. Remove the old entry. + # 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 + self._update_pair_paths(old_path, new_path, self._pending_db) + return True + return False + + def _update_pair_paths(self, old_path, new_path, db_handle): + """Updates stored paths in a pair-based DB value when a file is renamed.""" + if not self._lmdb_env or db_handle is None: + return + with QMutexLocker(self._db_lock): + with self._lmdb_env.begin(write=True) as txn: + cursor = txn.cursor(db=db_handle) + for key, value_bytes in cursor: + val_str = value_bytes.decode('utf-8') + if old_path in val_str: + p1, p2 = val_str.split('|') + np1 = new_path if p1 == old_path else p1 + np2 = new_path if p2 == old_path else p2 + txn.put(key, f"{np1}|{np2}".encode('utf-8'), db=db_handle) + + +class DuplicateDetector(QThread): + """ + Worker thread for detecting duplicate images using perceptual hashing. + """ + progress_update = Signal(int, int, str) # current, total, message + 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): + super().__init__() + self.paths_to_scan = paths_to_scan + self.duplicate_cache = duplicate_cache + self.pool_manager = pool_manager + self.method = method + self.threshold = threshold # Similarity percentage (50-100) + self.force_full = force_full + self._is_running = True + + def stop(self): + self._is_running = False + + def run(self): + total_files = len(self.paths_to_scan) + found_duplicates = [] + unique_duplicate_pairs = set() # To store frozenset((path1, path2)) for uniqueness + last_update_time = 0 + + pool = self.pool_manager.get_pool() + + # 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: + if p.path1 in self.paths_to_scan and p.path2 in self.paths_to_scan: + if p.similarity is None or p.similarity >= self.threshold: + found_duplicates.append(p) + unique_duplicate_pairs.add(frozenset((p.path1, p.path2))) + + # 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}") + + # 2. Phase 1: Hash Collection (Parallelized) + path_to_hash = {} + dirty_hashes_objs = set() + dirty_paths = set() + paths_to_hash_parallel = [] + + for path in self.paths_to_scan: + try: + stat_info = os.stat(path) + 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 \ + self.duplicate_cache.get_hash_for_path(path, mtime, dev, inode) + + if cached_h: + path_to_hash[path] = (cached_h, dev, inode) + else: + dirty_paths.add(path) + paths_to_hash_parallel.append((path, mtime, dev, inode)) + except OSError: + continue + + # Phase 1 starts with files already found in cache or skipped + processed_hashing = total_files - len(paths_to_hash_parallel) + + if paths_to_hash_parallel and self._is_running: + batch_size = pool.maxThreadCount() * 2 + results_mutex = QMutex() + new_hashes = {} + sem = QSemaphore(0) + + for i in range(0, len(paths_to_hash_parallel), batch_size): + if not self._is_running: + 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)) + + for _ in range(len(current_batch)): + while not sem.tryAcquire(1, 100): + if not self._is_running: + break + if not self._is_running: + 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="...")) + last_update_time = time.perf_counter() + + for p, mtime, dev, inode in paths_to_hash_parallel: + h = new_hashes.get(p) + if h: + path_to_hash[p] = (h, dev, inode) + dirty_hashes_objs.add(imagehash.hex_to_hash(h)) + self.duplicate_cache.add_hash_for_path(p, h, mtime, dev, inode) + + if not self._is_running: + self.detection_finished.emit() + return + + # 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) + + for p, (h_str, dev, inode) in path_to_hash.items(): + h_obj = imagehash.hex_to_hash(h_str) + if h_obj not in hash_map: + bk_tree.add(h_obj) + hash_map[h_obj].append((p, dev, inode)) + 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()) + total_queries = len(hashes_to_query) + + for i, h1 in enumerate(hashes_to_query): + if not self._is_running: + break + + items1 = hash_map[h1] + + 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="...")) + last_update_time = time.perf_counter() + + # Query tree for similar hashes + for h2, distance in bk_tree.query(h1, distance_threshold): + items2 = hash_map[h2] + + for p1, dev1, ino1 in items1: + for p2, dev2, ino2 in items2: + if not self._is_running: + break + if (dev1, ino1) == (dev2, ino2): + 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: + continue + + canonical = frozenset((p1, p2)) + if not self._is_running: + break + 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) + found_duplicates.append(res) + unique_duplicate_pairs.add(canonical) + self.duplicate_cache.mark_as_pending(p1, p2, True, similarity=sim) + + self.duplicates_found.emit(found_duplicates) + self.detection_finished.emit() diff --git a/duplicatedialog.py b/duplicatedialog.py new file mode 100644 index 0000000..46e5f9e --- /dev/null +++ b/duplicatedialog.py @@ -0,0 +1,654 @@ +import os +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QFrame, + QSplitter, QWidget, QMessageBox, QApplication, QMenu, + QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView +) +from PySide6.QtGui import QPixmap, QIcon, QImageReader, QImage, QDesktopServices +from PySide6.QtCore import Qt, QSize, QTimer, QUrl +from imageviewer import ImagePane +from imagecontroller import ImageController +from constants import UITexts, APP_CONFIG +from propertiesdialog import PropertiesDialog + +class DuplicateManagerDialog(QDialog): + """ + A dialog to review and manage duplicate image pairs found by the detector. + """ + def __init__(self, duplicates, duplicate_cache, main_win, review_mode=False): + super().__init__(main_win) + self.duplicates = duplicates # List of DuplicateResult + self.cache = duplicate_cache + self.main_win = main_win + self.review_mode = review_mode + + self.active_pane = None + self.current_dup_pair = None # Stores the current DuplicateResult object + self.panes_linked = True # Default to linked + self._is_syncing = False # Guard to prevent recursion during synchronization + + self.setWindowTitle(UITexts.DUPLICATE_MANAGER_TITLE) + self.resize(1000, 700) + + self._setup_ui() + self._populate_list() + + 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) + + if self.duplicates: + self.table_widget.setCurrentCell(0, 0) + + def _setup_ui(self): + layout = QHBoxLayout(self) + + # Left side: List of pairs + left_panel = QWidget() + left_layout = QVBoxLayout(left_panel) + + header_layout = QHBoxLayout() + header_layout.addWidget(QLabel(UITexts.DUPLICATE_LIST_HEADER)) + self.counter_lbl = QLabel() + self.counter_lbl.setStyleSheet("color: #3498db; font-weight: bold;") + header_layout.addStretch() + header_layout.addWidget(self.counter_lbl) + 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) + self.table_widget.verticalHeader().setVisible(False) + self.table_widget.setSelectionBehavior(QAbstractItemView.SelectRows) + self.table_widget.setSelectionMode(QAbstractItemView.SingleSelection) + self.table_widget.currentCellChanged.connect(self._on_cell_changed) + self.table_widget.setSortingEnabled(True) + left_layout.addWidget(self.table_widget) + + # Right side: Comparison area + self.splitter = QSplitter(Qt.Vertical) + + # Top area: Side by side images + self.comparison_widget = QWidget() + comp_layout = QHBoxLayout(self.comparison_widget) + + # Left Image Panel + self.left_pane_widget = self._create_comparison_pane_widget() + comp_layout.addWidget(self.left_pane_widget) + + # Right Image Panel + self.right_pane_widget = self._create_comparison_pane_widget() + comp_layout.addWidget(self.right_pane_widget) + + # Buttons Area + 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.clicked.connect(self._delete_left) + + 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.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.clicked.connect(self._keep_both) + + self.btn_skip = QPushButton(UITexts.DUPLICATE_SKIP) + self.btn_skip.clicked.connect(self._skip) + + btn_layout.addWidget(self.btn_del_left) + btn_layout.addWidget(self.btn_del_right) + btn_layout.addWidget(self.btn_link_panes) + btn_layout.addStretch() + btn_layout.addWidget(self.btn_keep_both) + btn_layout.addWidget(self.btn_skip) + + if self.review_mode: + self.btn_keep_both.hide() + self.btn_skip.setText(UITexts.DUPLICATE_REMOVE_IGNORED) + self.btn_skip.setIcon(QIcon.fromTheme("list-remove")) + + 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;") + + main_right_layout = QVBoxLayout() + main_right_layout.addWidget(self.comparison_widget, 1) + main_right_layout.addWidget(self.similarity_lbl) + main_right_layout.addWidget(button_widget) + + right_container = QWidget() + right_container.setLayout(main_right_layout) + + layout.addWidget(left_panel, 1) + layout.addWidget(right_container, 4) + + # Store references to the actual ImagePane instances + self.left_pane = self.left_pane_widget.pane + self.right_pane = self.right_pane_widget.pane + + def closeEvent(self, event): + """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) + except (RuntimeError, TypeError): + pass + super().closeEvent(event) + + def resizeEvent(self, event): + """Resizes the images to fill available space when the dialog is resized.""" + super().resizeEvent(event) + if hasattr(self, 'left_pane') and self.left_pane and \ + hasattr(self, 'right_pane') and self.right_pane: + self._is_syncing = True + try: + self.load_and_fit_image_for_pane(self.left_pane) + self.load_and_fit_image_for_pane(self.right_pane) + finally: + self._is_syncing = False + + def wheelEvent(self, event): + """Handles mouse wheel events for zooming (with Ctrl).""" + if event.modifiers() & Qt.ControlModifier and self.active_pane: + # Calcular el punto de enfoque relativo al pane activo + focus_pos = self.active_pane.mapFromGlobal(event.globalPosition().toPoint()) + if event.angleDelta().y() > 0: + self.active_pane.zoom_manager.zoom(1.1, focus_point=focus_pos) + else: + self.active_pane.zoom_manager.zoom(0.9, focus_point=focus_pos) + event.accept() + else: + super().wheelEvent(event) + + def keyPressEvent(self, event): + """Handles keyboard shortcuts for zooming.""" + if not self.active_pane: + super().keyPressEvent(event) + return + + if event.key() == Qt.Key_Plus or event.key() == Qt.Key_Equal: + self.active_pane.zoom_manager.zoom(1.1) + elif event.key() == Qt.Key_Minus: + self.active_pane.zoom_manager.zoom(0.9) + elif event.key() == Qt.Key_Z: + self.active_pane.zoom_manager.zoom(reset=True) + else: + super().keyPressEvent(event) + + # --- Viewer API Implementation for ImagePane --- + + def set_active_pane(self, pane): + """Sets the currently focused pane for synchronization reference.""" + self.active_pane = pane + self.update_highlight() + + def update_highlight(self): + """Visual feedback for the active pane.""" + for pw in [self.left_pane_widget, self.right_pane_widget]: + pw.setStyleSheet("border: 2px solid #3498db;" if pw.pane == self.active_pane + else "border: 2px solid transparent;") + + def on_metadata_changed(self, path, metadata=None): + """Updates labels when image metadata (like tags) is modified.""" + # Find the widget displaying this path and update its info + for pw in [self.left_pane_widget, self.right_pane_widget]: + if pw.pane.controller.get_current_path() == path: + size_str = self._format_size(os.path.getsize(path)) + pw.info_lbl.setText(UITexts.DUPLICATE_INFO_FORMAT.format( + size=size_str, + width=pw.pane.controller.pixmap_original.width(), + height=pw.pane.controller.pixmap_original.height())) + + if self.main_win: + self.main_win.update_metadata_for_path(path, metadata) + + def on_controller_list_updated(self, index): + """Required by ImagePane API, no-op in dialog context.""" + pass + + def update_view_for_pane(self, pane, resize_win=False): + """Refreshes the canvas for a specific pane.""" + pixmap = pane.controller.get_display_pixmap() + if not pixmap.isNull(): + pane.canvas.setPixmap(pixmap) + pane.canvas.adjustSize() + + def load_and_fit_image_for_pane(self, pane, restore_config=None): + """Loads and calculates initial zoom to fit the pane viewport.""" + success, _ = pane.controller.load_image() + if success: + viewport = pane.scroll_area.viewport() + w, h = viewport.width(), viewport.height() + # If not yet laid out, defer to next event loop + if w <= 1 or h <= 1: + QTimer.singleShot(0, lambda: self.load_and_fit_image_for_pane(pane)) + return + pane.zoom_manager.calculate_initial_zoom(w, h, True) + self.update_view_for_pane(pane) + + def reset_inactivity_timer(self): pass + def sync_filmstrip_selection(self, index): pass + def _get_clicked_face_for_pane(self, pane, pos): return None + def rename_face(self, face): pass + def toggle_fullscreen(self): pass + + def _create_comparison_pane_widget(self): + widget = QWidget() + v_layout = QVBoxLayout(widget) + v_layout.setContentsMargins(0, 0, 0, 0) + + info_lbl = QLabel() + info_lbl.setAlignment(Qt.AlignCenter) + info_lbl.setStyleSheet("font-weight: bold; color: #aaa;") + v_layout.addWidget(info_lbl) + + # Create ImagePane + pane = ImagePane(self, self.main_win.cache, [], 0, None, 0) + pane.setContextMenuPolicy(Qt.CustomContextMenu) + pane.customContextMenuRequested.connect(self._show_pane_context_menu) + v_layout.addWidget(pane) + + # Attach references + widget.info_lbl = info_lbl + widget.pane = pane + widget.filename_lbl = QLabel() + widget.filename_lbl.setAlignment(Qt.AlignCenter) + widget.filename_lbl.setStyleSheet("font-size: 11px; font-weight: bold;") + v_layout.addWidget(widget.filename_lbl) + widget.dir_lbl = QLabel() + widget.dir_lbl.setAlignment(Qt.AlignCenter) + widget.dir_lbl.setStyleSheet("font-size: 9px; color: #888;") + v_layout.addWidget(widget.dir_lbl) + + # Connect signals for synchronization + pane.scrolled.connect(self._sync_scroll) + pane.zoom_manager.zoomed.connect(self._sync_zoom) + pane.activated.connect(self._on_pane_activated) + return widget + + def _populate_list(self): + self.table_widget.setSortingEnabled(False) + self.table_widget.blockSignals(True) + self.table_widget.setRowCount(0) + for i, dup in enumerate(self.duplicates): + name1 = os.path.basename(dup.path1) + name2 = os.path.basename(dup.path2) + + row = self.table_widget.rowCount() + self.table_widget.insertRow(row) + + # Columna 0: Porcentaje (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 + + # 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.counter_lbl.setText(str(len(self.duplicates))) + self.table_widget.blockSignals(False) + self.table_widget.setSortingEnabled(True) + self.table_widget.sortItems(0, Qt.DescendingOrder) + + def _on_cell_changed(self, row, col, prev_row, prev_col): + if row >= 0: + self._load_pair(row) + + def _load_pair(self, row): + if row < 0 or row >= self.table_widget.rowCount(): + return + + # 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 + original_index = item.data(Qt.UserRole) + dup = self.duplicates[original_index] + self.current_dup_pair = dup # Store the original pair + + # Update similarity label + similarity_color = "#f39c12" # Default (amber) + if dup.similarity is not None: + if dup.similarity == 100: + similarity_color = "#2ecc71" # Green + elif dup.similarity < 80: + 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.show() + else: + self.similarity_lbl.hide() + + # Get paths and their components + path_left = dup.path1 + path_right = dup.path2 + + filename_left = os.path.basename(path_left) + dir_left = os.path.dirname(path_left) + filename_right = os.path.basename(path_right) + dir_right = os.path.dirname(path_right) + + # Determine colors for comparison + green_color = "#2ecc71" # Green for match + red_color = "#e74c3c" # Red for mismatch + + filename_color = green_color if filename_left == filename_right else red_color + dir_color = green_color if dir_left == dir_right else red_color + + # Determine which path goes to which pane based on mtime + 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 + 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) + 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) + + def _set_pane_data(self, pane_widget, path, filename_color, dir_color, filename_text, dir_text): + pane = pane_widget.pane + info_lbl = pane_widget.info_lbl + filename_lbl = pane_widget.filename_lbl + dir_lbl = pane_widget.dir_lbl + + if not os.path.exists(path): + info_lbl.setText("FILE NOT FOUND") + pane.controller.update_list([], 0) # Clear pane + pane.load_and_fit_image() + filename_lbl.setText("N/A") + dir_lbl.setText("N/A") + return + + # Metadatos + size_bytes = os.path.getsize(path) + size_str = self._format_size(size_bytes) + + # Load image into pane's controller + pane.controller.update_list([path], 0) + pane.load_and_fit_image() + + # Update info labels + if not pane.controller.pixmap_original.isNull(): + info_lbl.setText(UITexts.DUPLICATE_INFO_FORMAT.format( + size=size_str, + width=pane.controller.pixmap_original.width(), + height=pane.controller.pixmap_original.height())) + else: + 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};") + dir_lbl.setText(dir_text) + dir_lbl.setStyleSheet(f"font-size: 9px; color: {dir_color};") + + def _show_pane_context_menu(self, pos): + pane = self.sender() + path = pane.controller.get_current_path() + if not path or not os.path.exists(path): + return + + menu = QMenu(self) + + # Open with... + open_menu = menu.addMenu(QIcon.fromTheme("document-open"), UITexts.CONTEXT_MENU_OPEN) + self.main_win.populate_open_with_submenu(open_menu, path) + + # Open location + action_open_default_app = menu.addAction( + QIcon.fromTheme("system-run"), + UITexts.CONTEXT_MENU_OPEN_DEFAULT_APP) + action_open_default_app.triggered.connect( + lambda: QDesktopServices.openUrl(QUrl.fromLocalFile(os.path.dirname(path)))) + + menu.addSeparator() + + # 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_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_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)) + + menu.exec(pane.mapToGlobal(pos)) + + def _handle_permanent_delete(self, path): + 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.setStandardButtons(QMessageBox.Yes | QMessageBox.No) + confirm.setDefaultButton(QMessageBox.No) + if confirm.exec() == QMessageBox.Yes: + self._handle_action(delete_path=path, permanent=True) + + 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.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 + 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() + v_bar = active_pane.scroll_area.verticalScrollBar() + 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 + other_pane.set_scroll_relative(x_pct, y_pct) + + def _sync_scroll(self, x_pct, y_pct): + if not self.panes_linked: + return + source_pane = self.sender() + if source_pane == self.left_pane: + self.right_pane.set_scroll_relative(x_pct, y_pct) + elif source_pane == self.right_pane: + self.left_pane.set_scroll_relative(x_pct, y_pct) + + def _sync_zoom(self, factor, source_pane=None): + if not self.panes_linked or self._is_syncing: + return + if source_pane is None: + # El emisor es el ZoomManager, su padre es el ImagePane + sender = self.sender() + source_pane = sender.parent() if sender else None + + if not source_pane: + return + + self._is_syncing = True + try: + # Capture current scroll percentage from source to apply to target + h_bar = source_pane.scroll_area.horizontalScrollBar() + v_bar = source_pane.scroll_area.verticalScrollBar() + 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.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)) + 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}" + size /= 1024 + return f"{size:.1f} TiB" + + def _delete_left(self): + path_to_delete = self.left_pane.controller.get_current_path() + if path_to_delete: + self._handle_action(delete_path=path_to_delete) + + def _delete_right(self): + path_to_delete = self.right_pane.controller.get_current_path() + if path_to_delete: + self._handle_action(delete_path=path_to_delete) + + def _toggle_link_panes(self): + self.panes_linked = self.btn_link_panes.isChecked() + 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 + active_pane = self.left_pane + self._sync_zoom(active_pane.controller.zoom_factor, source_pane=active_pane) + h_bar = active_pane.scroll_area.horizontalScrollBar() + v_bar = active_pane.scroll_area.verticalScrollBar() + 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 + self.right_pane.set_scroll_relative(x_pct, y_pct) + + def _on_file_deleted_externally(self, path): + """Handles file deletion events from the FileSystemWatcher.""" + 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] + if not pairs_to_remove: + return + + for p in pairs_to_remove: + self.cache.mark_as_pending(p.path1, p.path2, False) + + # 2. Update the local list + self.duplicates = [d for d in self.duplicates if d not in pairs_to_remove] + + # 3. Refresh UI + self._populate_list() + if not self.duplicates: + self.close() + else: + current_row = self.table_widget.currentRow() + new_row = min(max(0, current_row), self.table_widget.rowCount() - 1) + self.table_widget.selectRow(new_row) + self.table_widget.setCurrentCell(new_row, 0) + + def _on_file_moved_externally(self, old_path, new_path): + """Handles file move/rename events from the FileSystemWatcher.""" + old_path = os.path.abspath(old_path) + new_path = os.path.abspath(new_path) + + updated = False + for i, d in enumerate(self.duplicates): + if d.path1 == old_path or d.path2 == old_path: + p1 = new_path if d.path1 == old_path else d.path1 + p2 = new_path if d.path2 == old_path else d.path2 + # Actualizamos la tupla con nombre usando _replace + self.duplicates[i] = d._replace(path1=p1, path2=p2) + updated = True + + if updated: + current_row = self.table_widget.currentRow() + self._populate_list() + if current_row >= 0: + new_row = min(current_row, self.table_widget.rowCount() - 1) + self.table_widget.selectRow(new_row) + self.table_widget.setCurrentCell(new_row, 0) + + def _keep_both(self): + if self.current_dup_pair: + self.cache.mark_as_exception( + self.current_dup_pair.path1, + self.current_dup_pair.path2, + True, + similarity=self.current_dup_pair.similarity + ) + self._handle_action(skip=False, permanent=False) + + 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._handle_action(skip=False, permanent=False) + else: + self._handle_action(skip=True) + + def _handle_action(self, delete_path=None, skip=False, permanent=None): + current_row = self.table_widget.currentRow() + if current_row < 0: + return + + item = self.table_widget.item(current_row, 0) + original_index = item.data(Qt.UserRole) + + # Get the pair before potentially popping it + current_pair = self.duplicates[original_index] + + if delete_path: + if permanent is not True: + if APP_CONFIG.get("duplicate_confirm_delete", True): + reply = QMessageBox.question( + self, UITexts.CONFIRM_TRASH_TITLE, + UITexts.CONFIRM_TRASH_TEXT, + QMessageBox.Yes | QMessageBox.No, QMessageBox.No) + if reply != QMessageBox.Yes: + return + + # 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] + 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 + # 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] + 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 0 <= original_index < len(self.duplicates): + self.duplicates.pop(original_index) + + # 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) + if self.duplicates: + new_row = min(current_row, self.table_widget.rowCount() - 1) + self.table_widget.selectRow(new_row) + self.table_widget.setCurrentCell(new_row, 0) + else: + self.close() diff --git a/filesystemwatcher.py b/filesystemwatcher.py index dc70a25..da69eb9 100644 --- a/filesystemwatcher.py +++ b/filesystemwatcher.py @@ -142,7 +142,6 @@ class FileSystemWatcher(QObject): if HAVE_WATCHDOG and self._observer: self._observer.stop() self._observer.join() - for timer in self._modified_events_queue.values(): timer.stop() diff --git a/imagecontroller.py b/imagecontroller.py index 63949ea..4cc5f21 100644 --- a/imagecontroller.py +++ b/imagecontroller.py @@ -13,7 +13,7 @@ Classes: import os import logging import math -from PySide6.QtCore import QThread, Signal, QMutex, QWaitCondition, QObject, Qt +from PySide6.QtCore import QThread, Signal, QMutex, QWaitCondition, QObject, Qt, QSize from PySide6.QtGui import QImage, QImageReader, QPixmap, QTransform from xmpmanager import XmpManager from constants import ( @@ -688,19 +688,37 @@ class ImageController(QObject): if self.pixmap_original.isNull(): return QPixmap() - transform = QTransform().rotate(self.rotation) - transformed_pixmap = self.pixmap_original.transformed( - transform, - Qt.SmoothTransformation - ) - new_size = transformed_pixmap.size() * self.zoom_factor - scaled_pixmap = transformed_pixmap.scaled(new_size, Qt.KeepAspectRatio, - Qt.SmoothTransformation) + # Ensure pixmap_original is a valid, independent copy before transforming + temp_pixmap = QPixmap(self.pixmap_original) + if temp_pixmap.isNull(): + return QPixmap() + + # Use rotated() which returns a new QTransform, potentially safer + transform = QTransform() # Initialize to identity transform + if self.rotation != 0: + transform = QTransform().rotated(float(self.rotation)) + + transformed_pixmap = temp_pixmap.transformed( + transform, Qt.TransformationMode.SmoothTransformation) + + # Calculate new size, explicitly converting QSizeF to QSize + new_size_f = transformed_pixmap.size() * self.zoom_factor + new_size = QSize(int(new_size_f.width()), int(new_size_f.height())) + + scaled_pixmap = transformed_pixmap.scaled( + new_size, Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation) if self.flip_h: - scaled_pixmap = scaled_pixmap.transformed(QTransform().scale(-1, 1)) + t_flip_h = QTransform() + t_flip_h.scale(-1, 1) + scaled_pixmap = scaled_pixmap.transformed( + t_flip_h, Qt.TransformationMode.SmoothTransformation) if self.flip_v: - scaled_pixmap = scaled_pixmap.transformed(QTransform().scale(1, -1)) + t_flip_v = QTransform() + t_flip_v.scale(1, -1) + scaled_pixmap = scaled_pixmap.transformed( + t_flip_v, Qt.TransformationMode.SmoothTransformation) return scaled_pixmap diff --git a/imagescanner.py b/imagescanner.py index 8f1093c..e6db0a0 100644 --- a/imagescanner.py +++ b/imagescanner.py @@ -33,10 +33,12 @@ from PySide6.QtCore import ( QByteArray, QBuffer, QIODevice, Qt, QTimer, QRunnable, QThreadPool, QFile ) from PySide6.QtGui import QImage, QImageReader, QImageIOHandler +from PySide6.QtWidgets import QApplication from constants import ( APP_CONFIG, CACHE_PATH, CACHE_MAX_SIZE, CACHE_MAX_RAM_BYTES, CONFIG_DIR, - MIN_FREE_RAM_PERCENT, DISK_CACHE_MAX_BYTES, HAVE_BAGHEERASEARCH_LIB, IMAGE_EXTENSIONS, + MIN_FREE_RAM_PERCENT, DISK_CACHE_MAX_BYTES, HAVE_BAGHEERASEARCH_LIB, + IMAGE_EXTENSIONS, SCANNER_SETTINGS_DEFAULTS, SEARCH_CMD, THUMBNAIL_SIZES, UITexts ) @@ -334,7 +336,6 @@ class CacheWriter(QThread): self._condition_new_data.wakeAll() self._condition_space_available.wakeAll() self._mutex.unlock() - self.wait() def run(self): self.setPriority(QThread.IdlePriority) @@ -442,7 +443,6 @@ class CacheLoader(QThread): self._mutex.lock() self._condition.wakeAll() self._mutex.unlock() - self.wait() def run(self): self.setPriority(QThread.IdlePriority) @@ -558,12 +558,22 @@ class ThumbnailCache(QObject): self._lmdb_env = None def lmdb_close(self): + # Stop and wait for worker threads to ensure they are not accessing + # the LMDB environment while it's being closed. if hasattr(self, '_cache_writer') and self._cache_writer: self._cache_writer.stop() + while self._cache_writer.isRunning(): + if QApplication.instance(): # Check if QApplication is still valid + QApplication.processEvents() # Keep UI responsive + QThread.msleep(50) self._cache_writer = None if hasattr(self, '_cache_loader') and self._cache_loader: self._cache_loader.stop() + while self._cache_loader.isRunning(): + if QApplication.instance(): # Check if QApplication is still valid + QApplication.processEvents() # Keep UI responsive + QThread.msleep(50) self._cache_loader = None self._loading_set.clear() self._futures.clear() @@ -658,8 +668,9 @@ class ThumbnailCache(QObject): import psutil mem = psutil.virtual_memory() if (mem.available / mem.total) * 100 < MIN_FREE_RAM_PERCENT: - logger.warning(f"Low system memory detected (< {MIN_FREE_RAM_PERCENT}%). " - "Applying aggressive tiered pruning.") + logger.warning(f"Low system memory detected " + f"(< {MIN_FREE_RAM_PERCENT}%). " + f"Applying aggressive tiered pruning.") # Strategy: first clear ALL cached high-res tiers to free space quickly # while keeping the 128px grid thumbnails intact. @@ -1189,8 +1200,14 @@ class ThumbnailCache(QObject): return None if not img.save(buf, "PNG"): - logger.error("Failed to save image to buffer") - return None + # libpng errors (like "Incorrect data in iCCP") can cause save() topi + # fail. + # Converting to a standard format strips problematic metadata/profiles. + ba.clear() + buf.seek(0) + if not img.convertToFormat(QImage.Format_ARGB32).save(buf, "PNG"): + logger.error("Failed to save image to buffer") + return None return ba.data() except Exception as e: logger.error(f"Error converting image to bytes: {e}") @@ -1382,27 +1399,38 @@ class ThumbnailGenerator(QThread): # The signal/slot mechanism handles thread safety automatically. emitter.progress_tick.connect(on_tick, Qt.QueuedConnection) - started_count = 0 - for path in self.paths: + # Process in batches to avoid saturating the global thread pool queue. + # This allows the application to respond to stop() signals almost immediately. + batch_size = max(4, pool.maxThreadCount() * 2) + + for i in range(0, len(self.paths), batch_size): if self._abort: break - runnable = ScannerWorker(self.cache, path, target_sizes=[self.size], - load_metadata=False, signal_emitter=emitter, - semaphore=sem) - runnable.setAutoDelete(False) - self._workers_mutex.lock() - if self._abort: + batch_slice = self.paths[i : i + batch_size] + started_in_batch = 0 + + for path in batch_slice: + if self._abort: + break + runnable = ScannerWorker(self.cache, path, target_sizes=[self.size], + load_metadata=False, signal_emitter=emitter, + semaphore=sem) + runnable.setAutoDelete(False) + + self._workers_mutex.lock() + self._workers.append(runnable) self._workers_mutex.unlock() - break - self._workers.append(runnable) - self._workers_mutex.unlock() - pool.start(runnable) - started_count += 1 + pool.start(runnable) + started_in_batch += 1 - if started_count > 0: - sem.acquire(started_count) + if started_in_batch > 0: + # Wait for the current batch to finish before queuing more + sem.acquire(started_in_batch) + self._workers_mutex.lock() + self._workers.clear() + self._workers_mutex.unlock() self._workers_mutex.lock() self._workers.clear() @@ -1886,4 +1914,3 @@ class ImageScanner(QThread): self.mutex.lock() self.condition.wakeAll() self.mutex.unlock() - self.wait() diff --git a/imageviewer.py b/imageviewer.py index 65d1432..cc3d11b 100644 --- a/imageviewer.py +++ b/imageviewer.py @@ -419,11 +419,22 @@ class FaceCanvas(QLabel): self.edit_handle = None self.edit_start_rect = QRect() self.resize_margin = 8 + + # Zoom indicator + 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.timeout.connect(self._clear_zoom_indicator) self.crop_rect = QRect() self.crop_handle = None self.crop_start_pos = QPoint() self.crop_start_rect = QRect() + def _clear_zoom_indicator(self): + self.zoom_indicator_point = None + self.update() + def map_from_source(self, face_data): """Maps original normalized face data to current canvas QRect.""" nx = face_data.get('x', 0) @@ -623,6 +634,16 @@ class FaceCanvas(QLabel): painter.drawRect(pt.x() - offset, pt.y() - offset, handle_size, handle_size) + # Draw zoom indicator + if self.zoom_indicator_point: + 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) + def _hit_test(self, pos): """Determines if the mouse is over a name, handle, or body.""" if not self.controller.show_faces: @@ -1122,18 +1143,59 @@ class ZoomManager(QObject): super().__init__(viewer) self.viewer = viewer - def zoom(self, factor, reset=False): - """Applies zoom to the image.""" + 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(): + return + + c_point = None + if reset: self.viewer.controller.zoom_factor = 1.0 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 + self.viewer.controller.zoom_factor = absolute_factor + self.viewer.update_view(resize_win=False) # Don't resize window for sync zoom + if focus_point is not None and self.viewer.canvas: + scroll_area = self.viewer.scroll_area + viewport = scroll_area.viewport() + v_point = viewport.mapFrom(self.viewer, focus_point) + c_point = self.viewer.canvas.mapFrom(viewport, v_point) else: + # 1. Determinar el punto de enfoque en coordenadas del viewport + scroll_area = self.viewer.scroll_area + viewport = scroll_area.viewport() + + if focus_point is None: + v_point = viewport.rect().center() + else: + # 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 + c_point = self.viewer.canvas.mapFrom(viewport, v_point) + self.viewer.controller.zoom_factor *= factor - self.viewer.update_view(resize_win=True) + # 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 + scroll_area.horizontalScrollBar().setValue( + int(c_point.x() * factor - v_point.x())) + scroll_area.verticalScrollBar().setValue( + int(c_point.y() * factor - v_point.y())) + # Notify the main window that the image (and possibly index) has changed # so it can update its selection. self.viewer.index_changed.emit(self.viewer.controller.index) + if focus_point is not None and self.viewer.canvas: + self.viewer.canvas.zoom_indicator_point = c_point + self.viewer.canvas.zoom_indicator_timer.start() + self.viewer.canvas.update() + self.zoomed.emit(self.viewer.controller.zoom_factor) if hasattr(self.viewer, 'sync_filmstrip_selection'): self.viewer.sync_filmstrip_selection(self.viewer.controller.index) @@ -1645,16 +1707,21 @@ class ImageViewer(QWidget): if pane != self.active_pane: pane.controller.zoom_factor = factor pane.update_view(resize_win=False) - # Re-apply relative scroll after zoom changes bounds - if self.active_pane: - h_bar = self.active_pane.scroll_area.horizontalScrollBar() - v_bar = self.active_pane.scroll_area.verticalScrollBar() - h_max = h_bar.maximum() - v_max = v_bar.maximum() - if h_max > 0 or v_max > 0: - x_pct = h_bar.value() / h_max if h_max > 0 else 0 - y_pct = v_bar.value() / v_max if v_max > 0 else 0 - pane.set_scroll_relative(x_pct, y_pct) + + # Re-apply relative scroll after zoom changes bounds + # We defer this to the next event loop iteration to ensure + # that QScrollArea has updated its scrollbar maximums. + if self.active_pane: + h_bar = self.active_pane.scroll_area.horizontalScrollBar() + v_bar = self.active_pane.scroll_area.verticalScrollBar() + h_max = h_bar.maximum() + v_max = v_bar.maximum() + x_pct = h_bar.value() / h_max if h_max > 0 else 0 + y_pct = v_bar.value() / v_max if v_max > 0 else 0 + + 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)) def update_grid_layout(self): # Clear layout @@ -1693,6 +1760,8 @@ class ImageViewer(QWidget): for i in range(count - current_panes): 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.load_and_fit_image() else: # Remove panes (keep active if possible, else keep first) @@ -1710,10 +1779,13 @@ 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 def toggle_link_panes(self): """Toggles the synchronized zoom/scroll for comparison mode.""" self.panes_linked = not self.panes_linked + if self.panes_linked and self.active_pane: + self._sync_zoom(self.active_pane.controller.zoom_factor) self.update_status_bar() def update_highlight(self): @@ -1731,6 +1803,9 @@ class ImageViewer(QWidget): def reset_inactivity_timer(self): """Resets the inactivity timer and restores controls visibility.""" + if self.active_pane and self.active_pane.canvas: + self.active_pane.canvas._clear_zoom_indicator() + if self.isFullScreen(): self.unsetCursor() if self.main_win and self.main_win.show_viewer_status_bar: @@ -2110,8 +2185,12 @@ class ImageViewer(QWidget): available_h -= self.status_bar_container.sizeHint().height() should_resize = True - self.zoom_manager.calculate_initial_zoom(available_w, available_h, - self.isFullScreen()) + if self.panes_linked and self.active_pane and pane != self.active_pane: + # Inherit zoom from active pane instead of recalculating + pane.controller.zoom_factor = self.active_pane.controller.zoom_factor + else: + pane.zoom_manager.calculate_initial_zoom(available_w, available_h, + self.isFullScreen()) self.update_view(resize_win=should_resize) else: @@ -3219,10 +3298,11 @@ class ImageViewer(QWidget): self.reset_inactivity_timer() if event.modifiers() & Qt.ControlModifier: # Zoom with Ctrl + Wheel + focus_pos = event.position().toPoint() if event.angleDelta().y() > 0: - self.zoom_manager.zoom(1.1) + self.zoom_manager.zoom(1.1, focus_point=focus_pos) else: - self.zoom_manager.zoom(0.9) + self.zoom_manager.zoom(0.9, focus_point=focus_pos) else: # Navigate next/previous based on configurable speed speed = APP_CONFIG.get("viewer_wheel_speed", VIEWER_WHEEL_SPEED_DEFAULT) diff --git a/propertiesdialog.py b/propertiesdialog.py index 0e0f6c4..cd3d96b 100644 --- a/propertiesdialog.py +++ b/propertiesdialog.py @@ -204,6 +204,11 @@ class PropertiesDialog(QDialog): # Start background loading self.reload_metadata() + def done(self, r): + if self.loader and self.loader.isRunning(): + self.loader.stop() + super().done(r) + def closeEvent(self, event): if self.loader and self.loader.isRunning(): self.loader.stop() diff --git a/pyproject.toml b/pyproject.toml index dbfc2e9..83dbb4b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "bagheeraview" -version = "0.9.15" +version = "0.9.16" authors = [ { name = "Ignacio Serantes" } ] @@ -25,6 +25,7 @@ dependencies = [ "exiv2", "psutil", "watchdog", + "imagehash", "mediapipe", "face_recognition", "face_recognition_models", diff --git a/requirements.txt b/requirements.txt index 686371a..5ca0dcc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ lmdb exiv2 psutil watchdog +imagehash mediapipe face_recognition face_recognition_models diff --git a/settings.py b/settings.py index 1435949..0347894 100644 --- a/settings.py +++ b/settings.py @@ -14,12 +14,13 @@ import os import shutil import urllib.request -from PySide6.QtCore import Qt, QThread, Signal +from PySide6.QtCore import Qt, QThread, Signal, QTimer from PySide6.QtGui import QColor, QIcon, QFont from PySide6.QtWidgets import ( QCheckBox, QColorDialog, QComboBox, QDialog, QDialogButtonBox, QHBoxLayout, QLabel, QLineEdit, QMessageBox, QProgressDialog, QPushButton, QSpinBox, - QTabWidget, QVBoxLayout, QWidget + QTabWidget, QVBoxLayout, QWidget, QSlider, QFileDialog, QListWidget, + QListWidgetItem, QProgressBar ) from constants import ( APP_CONFIG, AVAILABLE_FACE_ENGINES, DEFAULT_FACE_BOX_COLOR, @@ -27,7 +28,7 @@ from constants import ( FACES_MENU_MAX_ITEMS_DEFAULT, MEDIAPIPE_FACE_MODEL_PATH, MEDIAPIPE_FACE_MODEL_URL, AVAILABLE_PET_ENGINES, DEFAULT_BODY_BOX_COLOR, MEDIAPIPE_OBJECT_MODEL_PATH, MEDIAPIPE_OBJECT_MODEL_URL, - HAVE_BAGHEERASEARCH_LIB, + HAVE_BAGHEERASEARCH_LIB, IMAGE_EXTENSIONS, SCANNER_SETTINGS_DEFAULTS, SEARCH_CMD, TAGS_MENU_MAX_ITEMS_DEFAULT, THUMBNAILS_FILENAME_LINES_DEFAULT, THUMBNAILS_REFRESH_INTERVAL_DEFAULT, THUMBNAILS_BG_COLOR_DEFAULT, @@ -36,10 +37,72 @@ from constants import ( THUMBNAILS_TOOLTIP_FG_COLOR_DEFAULT, THUMBNAILS_FILENAME_FONT_SIZE_DEFAULT, THUMBNAILS_TAGS_LINES_DEFAULT, THUMBNAILS_TAGS_FONT_SIZE_DEFAULT, VIEWER_AUTO_RESIZE_WINDOW_DEFAULT, VIEWER_WHEEL_SPEED_DEFAULT, - UITexts, save_app_config + UITexts, save_app_config, HAVE_DUPLICATE_RESNET_LIBS, HAVE_IMAGEHASH ) +class DuplicateFileCounter(QThread): + """Thread to count images in whitelist/blacklist without freezing UI.""" + count_updated = Signal(int) + finished = Signal(int) + + def __init__(self, whitelist, blacklist, extensions): + super().__init__() + self.whitelist = whitelist + self.blacklist = blacklist + self.extensions = extensions + self._abort = False + + def stop(self): + self._abort = True + + def run(self): + count = 0 + for root_path in self.whitelist: + if self._abort: + break + if not os.path.exists(root_path): + continue + for root, dirs, files in os.walk(root_path): + if self._abort: + break + abs_root = os.path.abspath(root) + dirs[:] = [d for d in dirs if os.path.join(abs_root, d) not in self.blacklist] + if abs_root in self.blacklist: + continue + for f in files: + if self._abort: + break + if os.path.splitext(f)[1].lower() in self.extensions: + if os.path.join(abs_root, f) not in self.blacklist: + count += 1 + self.count_updated.emit(count) + self.finished.emit(count) + + +class PathListWidget(QListWidget): + """A QListWidget that accepts folder drops from external file explorers.""" + def __init__(self, add_callback, parent=None): + super().__init__(parent) + self.add_callback = add_callback + self.setAcceptDrops(True) + + def dragEnterEvent(self, event): + if event.mimeData().hasUrls(): + event.acceptProposedAction() + + def dragMoveEvent(self, event): + if event.mimeData().hasUrls(): + event.acceptProposedAction() + + def dropEvent(self, event): + for url in event.mimeData().urls(): + path = url.toLocalFile() + if path and os.path.isdir(path): + self.add_callback(self, path) + event.acceptProposedAction() + + class ModelDownloader(QThread): """A thread to download the MediaPipe model file without freezing the UI.""" download_complete = Signal(bool, str) # success (bool), message (str) @@ -93,6 +156,7 @@ class SettingsDialog(QDialog): self.current_thumbs_tooltip_bg_color = THUMBNAILS_TOOLTIP_BG_COLOR_DEFAULT self.current_thumbs_tooltip_fg_color = THUMBNAILS_TOOLTIP_FG_COLOR_DEFAULT self.downloader_thread = None + self.counter_thread = None layout = QVBoxLayout(self) @@ -112,6 +176,9 @@ class SettingsDialog(QDialog): scanner_tab = QWidget() scanner_layout = QVBoxLayout(scanner_tab) + duplicates_tab = QWidget() + duplicates_layout = QVBoxLayout(duplicates_tab) + # --- Thumbnails Tab --- mru_tags_layout = QHBoxLayout() @@ -344,6 +411,129 @@ class SettingsDialog(QDialog): scanner_layout.addLayout(scan_full_on_start_layout) scanner_layout.addStretch() + # --- Duplicates Tab --- + if not HAVE_IMAGEHASH: + warning_lbl = QLabel(UITexts.SETTINGS_DUPLICATE_MISSING_LIBS) + warning_lbl.setStyleSheet("color: #e74c3c; font-weight: bold;") + warning_lbl.setWordWrap(True) + duplicates_layout.addWidget(warning_lbl) + + method_layout = QHBoxLayout() + method_label = QLabel(UITexts.SETTINGS_DUPLICATE_METHOD_LABEL) + self.duplicate_method_combo = QComboBox() + self.duplicate_method_combo.addItem(UITexts.METHOD_HISTOGRAM_HASHING, "histogram_hashing") + self.duplicate_method_combo.addItem(UITexts.METHOD_RESNET, "resnet") + + self.duplicate_method_combo.setEnabled(HAVE_IMAGEHASH) + + if not HAVE_DUPLICATE_RESNET_LIBS: + resnet_idx = self.duplicate_method_combo.findData("resnet") + if resnet_idx != -1: + item = self.duplicate_method_combo.model().item(resnet_idx) + if item: + item.setEnabled(False) + + method_layout.addWidget(method_label) + method_layout.addWidget(self.duplicate_method_combo) + method_label.setToolTip(UITexts.SETTINGS_DUPLICATE_METHOD_TOOLTIP) + self.duplicate_method_combo.setToolTip(UITexts.SETTINGS_DUPLICATE_METHOD_TOOLTIP) + duplicates_layout.addLayout(method_layout) + + threshold_layout = QHBoxLayout() + threshold_label = QLabel(UITexts.SETTINGS_DUPLICATE_THRESHOLD_LABEL) + self.duplicate_threshold_slider = QSlider(Qt.Horizontal) + self.duplicate_threshold_slider.setRange(50, 100) + self.duplicate_threshold_value_label = QLabel("0%") + + self.duplicate_threshold_slider.setEnabled(HAVE_IMAGEHASH) + self.duplicate_threshold_value_label.setFixedWidth(40) + + threshold_layout.addWidget(threshold_label) + threshold_layout.addWidget(self.duplicate_threshold_slider) + threshold_layout.addWidget(self.duplicate_threshold_value_label) + + threshold_label.setToolTip(UITexts.SETTINGS_DUPLICATE_THRESHOLD_TOOLTIP) + self.duplicate_threshold_slider.setToolTip(UITexts.SETTINGS_DUPLICATE_THRESHOLD_TOOLTIP) + + self.duplicate_threshold_slider.valueChanged.connect( + lambda v: self.duplicate_threshold_value_label.setText(f"{v}%")) + + def create_path_list_ui(label_text, tooltip): + container = QWidget() + v_layout = QVBoxLayout(container) + v_layout.setContentsMargins(0, 0, 0, 0) + v_layout.addWidget(QLabel(label_text)) + h_layout = QHBoxLayout() + lst = PathListWidget(self._add_path_to_list) + lst.setToolTip(tooltip) + lst.setMinimumHeight(100) + h_layout.addWidget(lst) + btn_vbox = QVBoxLayout() + add_btn = QPushButton() + add_btn.setIcon(QIcon.fromTheme("list-add")) + add_btn.setFixedWidth(30) + rem_btn = QPushButton() + rem_btn.setIcon(QIcon.fromTheme("list-remove")) + rem_btn.setFixedWidth(30) + btn_vbox.addWidget(add_btn) + btn_vbox.addWidget(rem_btn) + btn_vbox.addStretch() + h_layout.addLayout(btn_vbox) + v_layout.addLayout(h_layout) + return container, lst, add_btn, rem_btn + + # Whitelist + wl_cont, self.duplicate_whitelist_list, wl_add, wl_rem = create_path_list_ui( + UITexts.SETTINGS_DUPLICATE_WHITELIST_LABEL, UITexts.SETTINGS_DUPLICATE_WHITELIST_TOOLTIP) + wl_add.clicked.connect(self.add_whitelist_path) + wl_rem.clicked.connect(self.remove_whitelist_path) + duplicates_layout.addWidget(wl_cont) + + # Blacklist + bl_cont, self.duplicate_blacklist_list, bl_add, bl_rem = create_path_list_ui( + UITexts.SETTINGS_DUPLICATE_BLACKLIST_LABEL, UITexts.SETTINGS_DUPLICATE_BLACKLIST_TOOLTIP) + bl_add.clicked.connect(self.add_blacklist_path) + bl_rem.clicked.connect(self.remove_blacklist_path) + duplicates_layout.addWidget(bl_cont) + + # Image Count Layout + count_layout = QHBoxLayout() + self.duplicate_scan_count_label = QLabel() + self.duplicate_scan_count_label.setStyleSheet("color: #3498db; font-weight: bold;") + self.duplicate_scan_progress = QProgressBar() + self.duplicate_scan_progress.setRange(0, 0) # Indeterminate mode + self.duplicate_scan_progress.setFixedHeight(10) + self.duplicate_scan_progress.setFixedWidth(100) + self.duplicate_scan_progress.hide() + count_layout.addWidget(self.duplicate_scan_count_label) + count_layout.addWidget(self.duplicate_scan_progress) + count_layout.addStretch() + duplicates_layout.addLayout(count_layout) + + # Timer for debounced count update + self.count_update_timer = QTimer(self) + self.count_update_timer.setSingleShot(True) + self.count_update_timer.setInterval(500) + self.count_update_timer.timeout.connect(self.update_duplicate_scan_count) + + self.duplicate_whitelist_list.model().rowsInserted.connect(lambda *args: self.count_update_timer.start()) + self.duplicate_whitelist_list.model().rowsRemoved.connect(lambda *args: self.count_update_timer.start()) + self.duplicate_blacklist_list.model().rowsInserted.connect(lambda *args: self.count_update_timer.start()) + self.duplicate_blacklist_list.model().rowsRemoved.connect(lambda *args: self.count_update_timer.start()) + + self.default_delete_to_trash_checkbox = QCheckBox(UITexts.SETTINGS_DEFAULT_DELETE_TO_TRASH_LABEL) + self.default_delete_to_trash_checkbox.setToolTip(UITexts.SETTINGS_DEFAULT_DELETE_TO_TRASH_TOOLTIP) + duplicates_layout.addWidget(self.default_delete_to_trash_checkbox) + + + duplicates_layout.addLayout(threshold_layout) + + self.duplicate_confirm_delete_checkbox = QCheckBox(UITexts.SETTINGS_DUPLICATE_CONFIRM_DELETE_LABEL) + self.duplicate_confirm_delete_checkbox.setToolTip(UITexts.SETTINGS_DUPLICATE_CONFIRM_DELETE_TOOLTIP) + duplicates_layout.addWidget(self.duplicate_confirm_delete_checkbox) + + duplicates_layout.addStretch() + # --- Faces & People Tab --- faces_tab = QWidget() faces_layout = QVBoxLayout(faces_tab) @@ -645,6 +835,7 @@ class SettingsDialog(QDialog): tabs.addTab(viewer_tab, UITexts.SETTINGS_GROUP_VIEWER) tabs.addTab(faces_tab, UITexts.SETTINGS_GROUP_AREAS) tabs.addTab(scanner_tab, UITexts.SETTINGS_GROUP_SCANNER) + tabs.addTab(duplicates_tab, UITexts.SETTINGS_GROUP_DUPLICATES) # --- Button Box --- button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) @@ -737,6 +928,29 @@ class SettingsDialog(QDialog): show_tags = APP_CONFIG.get("thumbnails_show_tags", True) filmstrip_position = APP_CONFIG.get("filmstrip_position", "bottom") + duplicate_method = APP_CONFIG.get("duplicate_method", "histogram_hashing") + method_idx = self.duplicate_method_combo.findData(duplicate_method) + if method_idx != -1: + self.duplicate_method_combo.setCurrentIndex(method_idx) + + duplicate_threshold = APP_CONFIG.get( + "duplicate_threshold", SCANNER_SETTINGS_DEFAULTS["duplicate_threshold"]) + self.duplicate_threshold_slider.setValue(duplicate_threshold) + self.duplicate_threshold_value_label.setText(f"{duplicate_threshold}%") + + default_delete_to_trash = APP_CONFIG.get("default_delete_to_trash", True) + self.default_delete_to_trash_checkbox.setChecked(default_delete_to_trash) + + duplicate_confirm_delete = APP_CONFIG.get("duplicate_confirm_delete", True) + self.duplicate_confirm_delete_checkbox.setChecked(duplicate_confirm_delete) + + duplicate_whitelist = APP_CONFIG.get("duplicate_whitelist", SCANNER_SETTINGS_DEFAULTS["duplicate_whitelist"]) + for p in [x.strip() for x in duplicate_whitelist.split(",") if x.strip()]: + self._add_path_to_list(self.duplicate_whitelist_list, p) + duplicate_blacklist = APP_CONFIG.get("duplicate_blacklist", SCANNER_SETTINGS_DEFAULTS["duplicate_blacklist"]) + for p in [x.strip() for x in duplicate_blacklist.split(",") if x.strip()]: + self._add_path_to_list(self.duplicate_blacklist_list, p) + self.scan_max_level_spin.setValue(scan_max_level) self.scan_batch_size_spin.setValue(scan_batch_size) self.threads_spin.setValue(scan_threads) @@ -821,6 +1035,7 @@ class SettingsDialog(QDialog): self.filmstrip_pos_combo.setCurrentText( pos_map.get(filmstrip_position, UITexts.FILMSTRIP_BOTTOM)) self.update_mediapipe_status() + self.update_duplicate_scan_count() def set_button_color(self, color_str): """Sets the background color of the button and stores the value.""" @@ -1068,6 +1283,15 @@ class SettingsDialog(QDialog): APP_CONFIG["thumbnails_show_rating"] = self.show_rating_check.isChecked() APP_CONFIG["thumbnails_show_tags"] = self.show_tags_check.isChecked() APP_CONFIG["viewer_wheel_speed"] = self.viewer_wheel_spin.value() + APP_CONFIG["duplicate_method"] = self.duplicate_method_combo.currentData() + APP_CONFIG["duplicate_threshold"] = self.duplicate_threshold_slider.value() + APP_CONFIG["default_delete_to_trash"] = self.default_delete_to_trash_checkbox.isChecked() + APP_CONFIG["duplicate_confirm_delete"] = self.duplicate_confirm_delete_checkbox.isChecked() + wl_paths = [self.duplicate_whitelist_list.item(i).text() for i in range(self.duplicate_whitelist_list.count())] + APP_CONFIG["duplicate_whitelist"] = ",".join(wl_paths) + bl_paths = [self.duplicate_blacklist_list.item(i).text() for i in range(self.duplicate_blacklist_list.count())] + APP_CONFIG["duplicate_blacklist"] = ",".join(bl_paths) + APP_CONFIG["viewer_auto_resize_window"] = \ self.viewer_auto_resize_check.isChecked() APP_CONFIG["face_detection_engine"] = self.face_engine_combo.currentText() @@ -1108,3 +1332,101 @@ class SettingsDialog(QDialog): def _on_downloader_finished(self): self.downloader_thread = None + + def _stop_downloader_thread(self): + if self.downloader_thread and self.downloader_thread.isRunning(): + self.downloader_thread.stop() + self.downloader_thread.wait() + self.downloader_thread = None + + def done(self, r): + self._stop_downloader_thread() # Asegura que el hilo de descarga se detenga y espere + if self.counter_thread and self.counter_thread.isRunning(): + self.counter_thread.stop() + self.counter_thread.wait() + super().done(r) + + def closeEvent(self, event): + self._stop_downloader_thread() # Asegura que el hilo de descarga se detenga y espere + if self.counter_thread and self.counter_thread.isRunning(): + self.counter_thread.stop() + self.counter_thread.wait() + super().closeEvent(event) + + def _add_path_to_list(self, list_widget, path): + """Adds a path to a QListWidget with existence validation.""" + path = os.path.abspath(os.path.expanduser(path.strip())) + if not path: + return + + to_remove = [] + for i in range(list_widget.count()): + existing_p = list_widget.item(i).text() + if existing_p == path: + return + + # Si una carpeta padre ya existe, no añadimos esta subcarpeta + if path.startswith(existing_p + os.sep): + return + + # Si la nueva ruta es padre de una existente, marcamos la existente para borrar + if existing_p.startswith(path + os.sep): + to_remove.append(i) + + # Borramos las subcarpetas innecesarias (en orden inverso para no alterar los índices) + for i in sorted(to_remove, reverse=True): + list_widget.takeItem(i) + + item = QListWidgetItem(path) + if not os.path.isdir(path): + item.setForeground(QColor("red")) + item.setToolTip(f"Warning: Path not found or is not a directory: {path}") + list_widget.addItem(item) + + def add_whitelist_path(self): + """Opens a directory dialog to add a folder to the whitelist.""" + dir_path = QFileDialog.getExistingDirectory(self, UITexts.SELECT) + if dir_path: + self._add_path_to_list(self.duplicate_whitelist_list, dir_path) + + def remove_whitelist_path(self): + """Removes the selected folders from the whitelist list.""" + for item in self.duplicate_whitelist_list.selectedItems(): + self.duplicate_whitelist_list.takeItem(self.duplicate_whitelist_list.row(item)) + + def add_blacklist_path(self): + """Opens a directory dialog to add a folder to the blacklist.""" + dir_path = QFileDialog.getExistingDirectory(self, UITexts.SELECT) + if dir_path: + self._add_path_to_list(self.duplicate_blacklist_list, dir_path) + + def remove_blacklist_path(self): + """Removes the selected folders from the blacklist list.""" + for item in self.duplicate_blacklist_list.selectedItems(): + self.duplicate_blacklist_list.takeItem(self.duplicate_blacklist_list.row(item)) + + def update_duplicate_scan_count(self): + """Calculates and updates the count of images in whitelist/blacklist using a background thread.""" + if self.counter_thread and self.counter_thread.isRunning(): + self.counter_thread.stop() + self.counter_thread.wait() + + whitelist_paths = [self.duplicate_whitelist_list.item(i).text() + for i in range(self.duplicate_whitelist_list.count())] + blacklist_paths = [self.duplicate_blacklist_list.item(i).text() + for i in range(self.duplicate_blacklist_list.count())] + + whitelist = [os.path.abspath(os.path.expanduser(p.strip())) for p in whitelist_paths if p.strip()] + blacklist = {os.path.abspath(os.path.expanduser(p.strip())) for p in blacklist_paths if p.strip()} + + if not whitelist: + self.duplicate_scan_count_label.setText(UITexts.SETTINGS_DUPLICATE_SCAN_COUNT_LABEL.format(0)) + self.duplicate_scan_progress.hide() + return + + self.duplicate_scan_progress.show() + self.counter_thread = DuplicateFileCounter(whitelist, blacklist, IMAGE_EXTENSIONS) + self.counter_thread.count_updated.connect( + lambda c: self.duplicate_scan_count_label.setText(UITexts.SETTINGS_DUPLICATE_SCAN_COUNT_LABEL.format(c))) + self.counter_thread.finished.connect(lambda: self.duplicate_scan_progress.hide()) + self.counter_thread.start() diff --git a/setup.py b/setup.py index f9662eb..e8377ef 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages setup( name="bagheeraview", - version="0.9.15", + version="0.9.16", author="Ignacio Serantes", description="Bagheera Image Viewer - An image viewer for KDE with Baloo in mind", long_description="A fast image viewer built with PySide6, featuring search and " @@ -16,6 +16,7 @@ setup( "exiv2", "psutil", "watchdog", + "imagehash", # Added for perceptual hashing "mediapipe", "face_recognition", "face_recognition_models",