Improve stability issues

This commit is contained in:
Ignacio Serantes
2026-04-03 18:41:52 +02:00
parent ae00235db8
commit ca260d4219
4 changed files with 109 additions and 16 deletions

View File

@@ -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.

View File

@@ -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"

View File

@@ -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:

View File

@@ -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)