Compare commits
2 Commits
3706d404f4
...
415400c30a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
415400c30a | ||
|
|
cb751b2970 |
405
bagheeraview.py
405
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())
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
194
constants.py
194
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",
|
||||
|
||||
723
duplicatecache.py
Normal file
723
duplicatecache.py
Normal file
@@ -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()
|
||||
654
duplicatedialog.py
Normal file
654
duplicatedialog.py
Normal file
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
114
imageviewer.py
114
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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3,6 +3,7 @@ lmdb
|
||||
exiv2
|
||||
psutil
|
||||
watchdog
|
||||
imagehash
|
||||
mediapipe
|
||||
face_recognition
|
||||
face_recognition_models
|
||||
|
||||
330
settings.py
330
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()
|
||||
|
||||
3
setup.py
3
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",
|
||||
|
||||
Reference in New Issue
Block a user