This commit is contained in:
Ignacio Serantes
2026-03-31 23:35:57 +02:00
parent ff7c1aa373
commit cb751b2970
14 changed files with 2431 additions and 119 deletions

View File

@@ -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():
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)
if len(paths) == 1:
confirm.setText(UITexts.CONFIRM_DELETE_TEXT)
confirm.setInformativeText(
UITexts.CONFIRM_DELETE_INFO.format(os.path.basename(path)))
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()}
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())

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,6 +1200,12 @@ class ThumbnailCache(QObject):
return None
if not img.save(buf, "PNG"):
# 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()
@@ -1382,8 +1399,18 @@ 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
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],
@@ -1392,17 +1419,18 @@ class ThumbnailGenerator(QThread):
runnable.setAutoDelete(False)
self._workers_mutex.lock()
if self._abort:
self._workers_mutex.unlock()
break
self._workers.append(runnable)
self._workers_mutex.unlock()
pool.start(runnable)
started_count += 1
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()

View File

@@ -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
# 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()
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)
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,7 +2185,11 @@ class ImageViewer(QWidget):
available_h -= self.status_bar_container.sizeHint().height()
should_resize = True
self.zoom_manager.calculate_initial_zoom(available_w, available_h,
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)
@@ -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)

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ lmdb
exiv2
psutil
watchdog
imagehash
mediapipe
face_recognition
face_recognition_models

View File

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

View File

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