diff --git a/bagheeraview.py b/bagheeraview.py index 3ed19fb..1225520 100755 --- a/bagheeraview.py +++ b/bagheeraview.py @@ -2722,7 +2722,8 @@ class MainWindow(QMainWindow): self.is_cleaning = False self.scanner = ImageScanner(self.cache, paths, is_file_list=self._scan_all, thread_pool_manager=self.thread_pool_manager, - viewers=self.viewers) + viewers=self.viewers, + target_sizes=[self._current_thumb_tier]) if self._is_loading_all: self.scanner.set_auto_load(True) self._is_loading = True @@ -4012,10 +4013,9 @@ class MainWindow(QMainWindow): if new_tier != self._current_thumb_tier: self._current_thumb_tier = new_tier - # 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. - # SCANNER_GENERATE_SIZES = [new_tier] + # Update scanner if running to use the new tier for upcoming batches + if self.scanner and self.scanner.isRunning(): + self.scanner.target_sizes = [new_tier] # 2. For all images ALREADY loaded, start a background job to # generate the newly required thumbnail size. This is interruptible. diff --git a/constants.py b/constants.py index 988c7a3..fbf1cc8 100644 --- a/constants.py +++ b/constants.py @@ -67,7 +67,6 @@ 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" diff --git a/duplicatedialog.py b/duplicatedialog.py index 6195c4f..f8ea915 100644 --- a/duplicatedialog.py +++ b/duplicatedialog.py @@ -176,16 +176,34 @@ class DuplicateManagerDialog(QDialog): super().wheelEvent(event) def keyPressEvent(self, event): - """Handles keyboard shortcuts for zooming.""" + """Handles keyboard shortcuts for zooming and duplicate management.""" + key = event.key() + if key == Qt.Key_U: + self._delete_left() + event.accept() + return + elif key == Qt.Key_I: + self._delete_right() + event.accept() + return + elif key == Qt.Key_O: + self._keep_both() + event.accept() + return + elif key == Qt.Key_P: + self._skip() + event.accept() + return + if not self.active_pane: super().keyPressEvent(event) return - if event.key() == Qt.Key_Plus or event.key() == Qt.Key_Equal: + if key == Qt.Key_Plus or key == Qt.Key_Equal: self.active_pane.zoom_manager.zoom(1.1) - elif event.key() == Qt.Key_Minus: + elif key == Qt.Key_Minus: self.active_pane.zoom_manager.zoom(0.9) - elif event.key() == Qt.Key_Z: + elif key == Qt.Key_Z: self.active_pane.zoom_manager.zoom(reset=True) else: super().keyPressEvent(event) @@ -260,6 +278,7 @@ class DuplicateManagerDialog(QDialog): # Create ImagePane pane = ImagePane(self, self.main_win.cache, [], 0, None, 0) pane.setContextMenuPolicy(Qt.CustomContextMenu) + pane.controller.show_faces = False # Disable showing and adding areas pane.customContextMenuRequested.connect(self._show_pane_context_menu) v_layout.addWidget(pane) @@ -367,6 +386,44 @@ class DuplicateManagerDialog(QDialog): 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) + # Compare resolutions and highlight the best one + p_l = self.left_pane.controller.pixmap_original + p_r = self.right_pane.controller.pixmap_original + if not p_l.isNull() and not p_r.isNull(): + res_l = p_l.width() * p_l.height() + res_r = p_r.width() * p_r.height() + + winner = 0 # 0: none, 1: left, 2: right + if res_l > res_r: + winner = 1 + elif res_r > res_l: + winner = 2 + else: + # Same resolution, compare file sizes + try: + path_l = self.left_pane.controller.get_current_path() + path_r = self.right_pane.controller.get_current_path() + size_l = os.path.getsize(path_l) + size_r = os.path.getsize(path_r) + if size_l > size_r: winner = 1 + elif size_r > size_l: winner = 2 + except (OSError, AttributeError): pass + + if winner == 1: + self.left_pane_widget.info_lbl.setStyleSheet("font-weight: bold; color: #2ecc71;") + self.left_pane_widget.info_lbl.setText("✓ " + self.left_pane_widget.info_lbl.text()) + self.right_pane_widget.info_lbl.setStyleSheet("font-weight: bold; color: #aaa;") + elif winner == 2: + self.right_pane_widget.info_lbl.setStyleSheet("font-weight: bold; color: #2ecc71;") + self.right_pane_widget.info_lbl.setText("✓ " + self.right_pane_widget.info_lbl.text()) + self.left_pane_widget.info_lbl.setStyleSheet("font-weight: bold; color: #aaa;") + else: + self.left_pane_widget.info_lbl.setStyleSheet("font-weight: bold; color: #aaa;") + self.right_pane_widget.info_lbl.setStyleSheet("font-weight: bold; color: #aaa;") + else: + self.left_pane_widget.info_lbl.setStyleSheet("font-weight: bold; color: #aaa;") + self.right_pane_widget.info_lbl.setStyleSheet("font-weight: bold; color: #aaa;") + 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 @@ -636,6 +693,10 @@ class DuplicateManagerDialog(QDialog): self.cache.mark_as_pending(p.path1, p.path2, False) self.main_win.delete_file_by_path(delete_path, permanent=permanent) # Use default setting + if os.path.exists(delete_path): + QMessageBox.warning(self, UITexts.ERROR, UITexts.ERROR_DELETING_FILE.format(delete_path)) + return + # Remove all pairs containing this path because it no longer exists self.duplicates = [d for d in self.duplicates if d.path1 != delete_path and d.path2 != delete_path] else: diff --git a/imagescanner.py b/imagescanner.py index 2e71463..d72bf38 100644 --- a/imagescanner.py +++ b/imagescanner.py @@ -32,7 +32,7 @@ from PySide6.QtCore import ( QObject, QThread, Signal, QMutex, QReadWriteLock, QSize, QSemaphore, QWaitCondition, QByteArray, QBuffer, QIODevice, Qt, QTimer, QRunnable, QThreadPool, QFile ) -from PySide6.QtGui import QImage, QImageReader, QImageIOHandler +from PySide6.QtGui import QImage, QImageReader, QImageIOHandler, QIcon from PySide6.QtWidgets import QApplication from constants import ( @@ -134,11 +134,11 @@ class ScannerWorker(QRunnable): sizes_to_check = self.target_sizes if self.target_sizes is not None \ else SCANNER_GENERATE_SIZES + fd = None try: if self._is_cancelled: return - fd = None # Optimize: Open file once to reuse FD for stat and xattrs fd = os.open(self.path, os.O_RDONLY) stat_res = os.fstat(fd) @@ -196,8 +196,11 @@ class ScannerWorker(QRunnable): tags, rating = res_meta.tags, res_meta.rating self.result = (self.path, smallest_thumb_for_signal, curr_mtime, tags, rating, curr_inode, curr_dev) + except (FileNotFoundError, PermissionError) as e: + logger.debug(f"Skipping {self.path} due to access issue: {e}") + self.result = None except Exception as e: - logger.error(f"Error processing image {self.path}: {e}") + logger.warning(f"Unexpected error processing image {self.path}: {e}") self.result = None finally: if fd is not None: @@ -265,7 +268,7 @@ def generate_thumbnail(path, size, fd=None): # better quality for upscaling. return img.scaled(size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation) except Exception as e: - logger.error(f"Error generating thumbnail for {path}: {e}") + logger.debug(f"Could not generate thumbnail for {path}: {e}") return None @@ -523,10 +526,19 @@ class ThumbnailCache(QObject): self._db_lock = QMutex() # Lock specifically for _db_handles access self._db_handles = {} # Cache for LMDB database handles (dbi) self._cancel_loading = False + self._broken_cache = {} # (dev, inode, size) -> (mtime, error_msg) self._cache_bytes_size = 0 self._cache_writer = None self._cache_loader = None + # Pre-generate broken images for standard tiers in the main thread + self._broken_images = {} + for size in THUMBNAIL_SIZES: + icon = QIcon.fromTheme("image-missing", + QIcon.fromTheme("broken-image", + QIcon.fromTheme("dialog-error"))) + self._broken_images[size] = icon.pixmap(size, size).toImage() + self.lmdb_open() def lmdb_open(self): @@ -739,6 +751,21 @@ class ThumbnailCache(QObject): return 256 return 512 + def mark_broken(self, path, size, mtime, inode, dev_id, error_msg): + """Marks a thumbnail load as failed with a message.""" + key = (dev_id, struct.pack('Q', inode) if inode is not None else None, size) + with self._write_lock(): + self._broken_cache[key] = (mtime, error_msg) + + def get_broken_info(self, path, size, mtime, inode, dev_id): + """Returns the error message if a thumbnail is known to have failed, else None.""" + key = (dev_id, struct.pack('Q', inode) if inode is not None else None, size) + with self._read_lock(): + info = self._broken_cache.get(key) + if info and info[0] == mtime: + return info[1] + return None + def _resolve_file_identity(self, path, curr_mtime, inode, device_id): """Helper to resolve file mtime, device, and inode.""" mtime = curr_mtime @@ -859,6 +886,11 @@ class ThumbnailCache(QObject): if mtime is None: return EMPTY_THUMBNAIL + # Check if known to be broken + broken_msg = self.get_broken_info(path, target_tier, mtime, inode, device_id) + if broken_msg: + return ThumbnailResult(self._broken_images.get(target_tier), mtime, target_tier) + best_img, best_mtime, best_tier = None, 0, 0 with self._read_lock(): @@ -1455,13 +1487,14 @@ class ImageScanner(QThread): more_files_available = Signal(int, int) # Last loaded index, remainder def __init__(self, cache, paths, is_file_list=False, viewers=None, - thread_pool_manager=None): + thread_pool_manager=None, target_sizes=None): # is_file_list is not used if not paths or not isinstance(paths, (list, tuple)): logger.warning("ImageScanner initialized with empty or invalid paths") paths = [] super().__init__() self.cache = cache + self.target_sizes = target_sizes self.all_files = [] self.thread_pool_manager = thread_pool_manager self._viewers = viewers @@ -1818,7 +1851,7 @@ class ImageScanner(QThread): return for f_path, _ in tasks: - r = ScannerWorker(self.cache, f_path, semaphore=sem) + r = ScannerWorker(self.cache, f_path, semaphore=sem, target_sizes=self.target_sizes) r.setAutoDelete(False) runnables.append(r) self._current_workers.append(r)