Improve stability issues
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user