diff --git a/changelog.txt b/changelog.txt index 8df3856..f4957a7 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,13 +1,14 @@ v0.9.11 - · Filmstrip fixed +· Añadida una nueva área llamada Body. +· Refactorizaciones, optimizaciones y cambios a saco. -HAVE_BAGHEERASEARCH_LIB -Refactor `load_image` to check if `pixmap_original` is already valid before reloading to optimize performance. -Check if the `ImagePreloader` handles file deletion correctly if the file is deleted while being preloaded. -Me gustaría implementar un modo de "Comparación" para ver 2 o 4 imágenes lado a lado en el visor. ¿Cómo podría abordarlo? +Refactor the `ImageScanner` to use a thread pool for parallel thumbnail generation for faster loading. + +Implement a "Comparison" mode to view 2 or 4 images side-by-side in the viewer. · La instalación no debe usar Bagheera como motor a no ser que esté instalado. · Hacer que el image viewer standalone admita múltiples sort diff --git a/constants.py b/constants.py index 232d3cb..1ad62a1 100644 --- a/constants.py +++ b/constants.py @@ -167,6 +167,13 @@ if importlib.util.find_spec("mediapipe") is not None: pass HAVE_FACE_RECOGNITION = importlib.util.find_spec("face_recognition") is not None +HAVE_BAGHEERASEARCH_LIB = False +try: + import bagheera_search_lib + HAVE_BAGHEERASEARCH_LIB = True +except ImportError: + pass + MEDIAPIPE_FACE_MODEL_PATH = os.path.join(CONFIG_DIR, "blaze_face_short_range.tflite") MEDIAPIPE_FACE_MODEL_URL = ( diff --git a/imagecontroller.py b/imagecontroller.py index 02b00b7..b10eddf 100644 --- a/imagecontroller.py +++ b/imagecontroller.py @@ -11,6 +11,7 @@ Classes: interacts with the ImagePreloader. """ import os +import logging import math from PySide6.QtCore import QThread, Signal, QMutex, QWaitCondition, QObject, Qt from PySide6.QtGui import QImage, QImageReader, QPixmap, QTransform @@ -22,6 +23,8 @@ from constants import ( ) from metadatamanager import XattrManager, load_common_metadata +logger = logging.getLogger(__name__) + class ImagePreloader(QThread): """ @@ -111,8 +114,8 @@ class ImagePreloader(QThread): # Load tags and rating here to avoid re-reading in main thread tags, rating = load_common_metadata(path) self.image_ready.emit(idx, path, img, tags, rating) - except Exception: - pass + except Exception as e: + logger.warning(f"ImagePreloader failed to load {path}: {e}") class ImageController(QObject): @@ -156,6 +159,12 @@ class ImageController(QObject): def cleanup(self): """Stops the background preloader thread.""" self.preloader.stop() + self._current_metadata_path = None + self._loaded_path = None + self._current_tags = [] + self._current_rating = 0 + self._cached_next_image = None + self._cached_next_index = -1 def _trigger_preload(self): """Identifies the next image in the list and asks the preloader to load it.""" @@ -209,19 +218,13 @@ class ImageController(QObject): # Optimization: Check if image is already loaded if path and self._loaded_path == path and not self.pixmap_original.isNull(): - self.rotation = 0 - self.flip_h = False - self.flip_v = False - self.faces = [] - # Ensure metadata is consistent with current path if self._current_metadata_path != path: self._current_tags, self._current_rating = load_common_metadata(path) self._current_metadata_path = path - self.load_faces() self._trigger_preload() - return True + return True, False self.pixmap_original = QPixmap() self._loaded_path = None @@ -231,7 +234,7 @@ class ImageController(QObject): self.faces = [] if not path: - return False + return False, False # Check cache if self.index == self._cached_next_index and self._cached_next_image: @@ -250,7 +253,7 @@ class ImageController(QObject): image = reader.read() if image.isNull(): self._trigger_preload() - return False + return False, False self.pixmap_original = QPixmap.fromImage(image) # Load tags and rating if not already set for this path @@ -261,7 +264,7 @@ class ImageController(QObject): self._loaded_path = path self.load_faces() self._trigger_preload() - return True + return True, True def load_faces(self): """ diff --git a/imagescanner.py b/imagescanner.py index 7f55adf..2699389 100644 --- a/imagescanner.py +++ b/imagescanner.py @@ -36,18 +36,14 @@ from PySide6.QtGui import QImage, QImageReader, QImageIOHandler from constants import ( APP_CONFIG, CACHE_PATH, CACHE_MAX_SIZE, CONFIG_DIR, DISK_CACHE_MAX_BYTES, IMAGE_EXTENSIONS, SEARCH_CMD, THUMBNAIL_SIZES, RATING_XATTR_NAME, XATTR_NAME, - UITexts, SCANNER_SETTINGS_DEFAULTS + UITexts, SCANNER_SETTINGS_DEFAULTS, HAVE_BAGHEERASEARCH_LIB ) from imageviewer import ImageViewer from metadatamanager import XattrManager -try: - # Attempt to import bagheerasearch for direct integration +if HAVE_BAGHEERASEARCH_LIB: from bagheera_search_lib import BagheeraSearcher - HAVE_BAGHEERASEARCH_LIB = True -except ImportError: - HAVE_BAGHEERASEARCH_LIB = False # Set up logging for better debugging logger = logging.getLogger(__name__) @@ -130,6 +126,10 @@ class CacheWriter(QThread): if not self._running: return + # Ensure we don't accept new items if stopping, especially when block=False + if not self._running: + return + # --- Soft Cleaning: Deduplication --- # Remove redundant pending updates for the same image/size (e.g. # rapid rotations) @@ -154,7 +154,7 @@ class CacheWriter(QThread): def stop(self): self._mutex.lock() self._running = False - self._queue.clear() + # Do not clear the queue here; let the run loop drain it to prevent data loss. self._condition_new_data.wakeAll() self._condition_space_available.wakeAll() self._mutex.unlock() diff --git a/imageviewer.py b/imageviewer.py index c45b8e7..0ccf1a7 100644 --- a/imageviewer.py +++ b/imageviewer.py @@ -24,7 +24,7 @@ from PySide6.QtGui import ( ) from PySide6.QtCore import ( Signal, QPoint, QSize, Qt, QMimeData, QUrl, QTimer, QEvent, QRect, Slot, QRectF, - QThread + QThread, QObject ) from constants import ( @@ -960,13 +960,203 @@ class FaceCanvas(QLabel): # The event position is already local to the canvas clicked_face = self.viewer._get_clicked_face(event.position().toPoint()) if clicked_face: - self.viewer.zoom_to_rect(clicked_face) + self.viewer.zoom_manager.zoom_to_rect(clicked_face) event.accept() return # If no face was double-clicked, pass the event on super().mouseDoubleClickEvent(event) +class SlideshowManager(QObject): + """ + Manages the slideshow functionality for the ImageViewer. + Separates the timing logic from the UI logic. + """ + def __init__(self, viewer): + super().__init__(viewer) + self.viewer = viewer + self.timer = QTimer(self) + self.timer.setInterval(3000) + self.timer.timeout.connect(self._on_timeout) + self._reverse = False + + def _on_timeout(self): + """Called when the timer fires to advance the slideshow.""" + if self._reverse: + self.viewer.prev_image() + else: + self.viewer.next_image() + + def start(self, reverse=False): + """Starts the slideshow in the specified direction.""" + self._reverse = reverse + self.timer.start() + + def stop(self): + """Stops the slideshow.""" + self.timer.stop() + + def toggle(self, reverse=False): + """Toggles the slideshow on/off or changes direction.""" + if self.timer.isActive(): + if self._reverse == reverse: + self.stop() + else: + self.start(reverse) + else: + self.start(reverse) + + def is_running(self): + """Returns whether the slideshow is currently active.""" + return self.timer.isActive() + + def is_forward(self): + """Returns whether the slideshow is running in forward mode.""" + return self.timer.isActive() and not self._reverse + + def is_reverse(self): + """Returns whether the slideshow is running in reverse mode.""" + return self.timer.isActive() and self._reverse + + def set_interval(self, ms): + """Sets the interval in milliseconds.""" + self.timer.setInterval(ms) + if self.timer.isActive(): + self.timer.start() + + def get_interval(self): + """Returns the current interval in milliseconds.""" + return self.timer.interval() + + +class ZoomManager(QObject): + """ + Manages zoom calculations and state for the ImageViewer. + """ + def __init__(self, viewer): + super().__init__(viewer) + self.viewer = viewer + + def zoom(self, factor, reset=False): + """Applies zoom to the image.""" + if reset: + self.viewer.controller.zoom_factor = 1.0 + self.viewer.update_view(resize_win=True) + else: + self.viewer.controller.zoom_factor *= factor + self.viewer.update_view(resize_win=True) + # 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) + + self.viewer.sync_filmstrip_selection(self.viewer.controller.index) + + def toggle_fit_to_screen(self): + """ + Toggles between fitting the image to the window and 100% actual size. + """ + # If close to 100%, fit to window. Otherwise 100%. + if abs(self.viewer.controller.zoom_factor - 1.0) < 0.01: + self.fit_to_window() + else: + self.zoom(1.0, reset=True) + + def fit_to_window(self): + """ + Calculates the zoom factor required to make the image fit perfectly + within the current viewport dimensions. + """ + if self.viewer.controller.pixmap_original.isNull(): + return + + viewport = self.viewer.scroll_area.viewport() + w_avail = viewport.width() + h_avail = viewport.height() + + transform = QTransform().rotate(self.viewer.controller.rotation) + transformed_pixmap = self.viewer.controller.pixmap_original.transformed( + transform, Qt.SmoothTransformation) + img_w = transformed_pixmap.width() + img_h = transformed_pixmap.height() + + if img_w == 0 or img_h == 0: + return + + self.viewer.controller.zoom_factor = min(w_avail / img_w, h_avail / img_h) + self.viewer.update_view(resize_win=False) + + def calculate_initial_zoom(self, available_w, available_h, is_fullscreen): + """Calculates and sets the initial zoom factor when loading an image.""" + orig_w = self.viewer.controller.pixmap_original.width() + orig_h = self.viewer.controller.pixmap_original.height() + + if orig_w > 0 and orig_h > 0: + factor = min(available_w / orig_w, available_h / orig_h) + if is_fullscreen: + self.viewer.controller.zoom_factor = factor + else: + self.viewer.controller.zoom_factor = min(1.0, factor) + else: + self.viewer.controller.zoom_factor = 1.0 + + def zoom_to_rect(self, face_rect): + """Zooms and pans the view to center on a given normalized rectangle.""" + if self.viewer.controller.pixmap_original.isNull(): + return + + viewport = self.viewer.scroll_area.viewport() + vp_w = viewport.width() + vp_h = viewport.height() + + # Use the original pixmap dimensions for zoom calculation + transform = QTransform().rotate(self.viewer.controller.rotation) + transformed_pixmap = self.viewer.controller.pixmap_original.transformed( + transform, Qt.SmoothTransformation) + img_w = transformed_pixmap.width() + img_h = transformed_pixmap.height() + + if img_w == 0 or img_h == 0: + return + + # Calculate the size of the face in original image pixels + face_pixel_w = face_rect['w'] * img_w + face_pixel_h = face_rect['h'] * img_h + + if face_pixel_w == 0 or face_pixel_h == 0: + return + + # Calculate zoom factor to make the face fill ~70% of the viewport + zoom_w = (vp_w * 0.7) / face_pixel_w + zoom_h = (vp_h * 0.7) / face_pixel_h + new_zoom = min(zoom_w, zoom_h) + + self.viewer.controller.zoom_factor = new_zoom + self.viewer.update_view(resize_win=False) + + # Defer centering until after the view has been updated + QTimer.singleShot(0, lambda: self._center_on_face(face_rect)) + + def _center_on_face(self, face_rect): + """Scrolls the viewport to center on the face.""" + canvas_w = self.viewer.canvas.width() + canvas_h = self.viewer.canvas.height() + + viewport = self.viewer.scroll_area.viewport() + vp_w = viewport.width() + vp_h = viewport.height() + + # Face center in the newly zoomed canvas coordinates + face_center_x_px = face_rect['x'] * canvas_w + face_center_y_px = face_rect['y'] * canvas_h + + # Calculate the target scrollbar value to center the point + scroll_x = face_center_x_px - (vp_w / 2) + scroll_y = face_center_y_px - (vp_h / 2) + + self.viewer.scroll_area.horizontalScrollBar().setValue(int(scroll_x)) + self.viewer.scroll_area.verticalScrollBar().setValue(int(scroll_y)) + + class ImageViewer(QWidget): """ A standalone window for viewing and manipulating a single image. @@ -1137,14 +1327,8 @@ class ImageViewer(QWidget): self.hide_controls_timer.timeout.connect(self.hide_controls) # Slideshow - self.slideshow_timer = QTimer(self) - self.slideshow_timer.setInterval(3000) - self.slideshow_timer.timeout.connect(self.next_image) - - # Slideshow - self.slideshow_reverse_timer = QTimer(self) - self.slideshow_reverse_timer.setInterval(3000) - self.slideshow_reverse_timer.timeout.connect(self.prev_image) + self.slideshow_manager = SlideshowManager(self) + self.zoom_manager = ZoomManager(self) # Load image if restore_config: @@ -1214,9 +1398,9 @@ class ImageViewer(QWidget): "fast_tag": self.show_fast_tag_menu, "rotate_right": lambda: self.apply_rotation(90, True), "rotate_left": lambda: self.apply_rotation(-90, True), - "zoom_in": lambda: self.zoom(1.1), - "zoom_out": lambda: self.zoom(0.9), - "reset_zoom": lambda: self.zoom(1.0, reset=True), + "zoom_in": lambda: self.zoom_manager.zoom(1.1), + "zoom_out": lambda: self.zoom_manager.zoom(0.9), + "reset_zoom": lambda: self.zoom_manager.zoom(1.0, reset=True), "toggle_animation": self.toggle_animation_pause, "properties": self.show_properties, "toggle_visibility": self.toggle_main_window_visibility, @@ -1447,25 +1631,30 @@ class ImageViewer(QWidget): Args: restore_config (dict, optional): State dictionary to restore from. """ - if self.movie: - self.movie.stop() - self.movie = None + success, reloaded = self.controller.load_image() - if not self.controller.load_image(): + if not success: + if self.movie: + self.movie.stop() + self.movie = None self.canvas.setPixmap(QPixmap()) self.update_status_bar() return path = self.controller.get_current_path() - self.canvas.crop_rect = QRect() # Clear crop rect on new image - if path: - reader = QImageReader(path) - if reader.supportsAnimation() and reader.imageCount() > 1: - self.movie = QMovie(path) - self.movie.setCacheMode(QMovie.CacheAll) - self.movie.frameChanged.connect(self._on_movie_frame) - self.movie.start() + if reloaded: + if self.movie: + self.movie.stop() + self.movie = None + self.canvas.crop_rect = QRect() # Clear crop rect on new image + if path: + reader = QImageReader(path) + if reader.supportsAnimation() and reader.imageCount() > 1: + self.movie = QMovie(path) + self.movie.setCacheMode(QMovie.CacheAll) + self.movie.frameChanged.connect(self._on_movie_frame) + self.movie.start() self.reset_inactivity_timer() if restore_config: @@ -1481,7 +1670,7 @@ class ImageViewer(QWidget): self.populate_filmstrip() self.update_view(resize_win=False) QTimer.singleShot(0, lambda: self.restore_scroll(restore_config)) - else: + elif reloaded: # Calculate zoom to fit the image on the screen if self.isFullScreen(): viewport = self.scroll_area.viewport() @@ -1521,19 +1710,14 @@ class ImageViewer(QWidget): available_h -= self.status_bar_container.sizeHint().height() should_resize = True - orig_w = self.controller.pixmap_original.width() - orig_h = self.controller.pixmap_original.height() - - if orig_w > 0 and orig_h > 0: - factor = min(available_w / orig_w, available_h / orig_h) - if self.isFullScreen(): - self.controller.zoom_factor = factor - else: - self.controller.zoom_factor = min(1.0, factor) - else: - self.controller.zoom_factor = 1.0 + self.zoom_manager.calculate_initial_zoom(available_w, available_h, + self.isFullScreen()) self.update_view(resize_win=should_resize) + else: + # Image was reused and no restore config; just refresh the view to ensure + # metadata/faces are up to date without resetting zoom/pan. + self.update_view(resize_win=False) # Defer sync to ensure layout and scroll area are ready, fixing navigation sync QTimer.singleShot( @@ -1608,78 +1792,6 @@ class ImageViewer(QWidget): self.movie.setPaused(not is_paused) self.update_title() - def zoom(self, factor, reset=False): - """Applies zoom to the image.""" - if reset: - self.controller.zoom_factor = 1.0 - self.update_view(resize_win=True) - else: - self.controller.zoom_factor *= factor - self.update_view(resize_win=True) - - # Notify the main window that the image (and possibly index) has changed - # so it can update its selection. - self.index_changed.emit(self.controller.index) - - self.sync_filmstrip_selection(self.controller.index) - - def zoom_to_rect(self, face_rect): - """Zooms and pans the view to center on a given normalized rectangle.""" - if self.controller.pixmap_original.isNull(): - return - - viewport = self.scroll_area.viewport() - vp_w = viewport.width() - vp_h = viewport.height() - - # Use the original pixmap dimensions for zoom calculation - transform = QTransform().rotate(self.controller.rotation) - transformed_pixmap = self.controller.pixmap_original.transformed( - transform, Qt.SmoothTransformation) - img_w = transformed_pixmap.width() - img_h = transformed_pixmap.height() - - if img_w == 0 or img_h == 0: - return - - # Calculate the size of the face in original image pixels - face_pixel_w = face_rect['w'] * img_w - face_pixel_h = face_rect['h'] * img_h - - if face_pixel_w == 0 or face_pixel_h == 0: - return - - # Calculate zoom factor to make the face fill ~70% of the viewport - zoom_w = (vp_w * 0.7) / face_pixel_w - zoom_h = (vp_h * 0.7) / face_pixel_h - new_zoom = min(zoom_w, zoom_h) - - self.controller.zoom_factor = new_zoom - self.update_view(resize_win=False) - - # Defer centering until after the view has been updated - QTimer.singleShot(0, lambda: self._center_on_face(face_rect)) - - def _center_on_face(self, face_rect): - """Scrolls the viewport to center on the face.""" - canvas_w = self.canvas.width() - canvas_h = self.canvas.height() - - viewport = self.scroll_area.viewport() - vp_w = viewport.width() - vp_h = viewport.height() - - # Face center in the newly zoomed canvas coordinates - face_center_x_px = face_rect['x'] * canvas_w - face_center_y_px = face_rect['y'] * canvas_h - - # Calculate the target scrollbar value to center the point - scroll_x = face_center_x_px - (vp_w / 2) - scroll_y = face_center_y_px - (vp_h / 2) - - self.scroll_area.horizontalScrollBar().setValue(int(scroll_x)) - self.scroll_area.verticalScrollBar().setValue(int(scroll_y)) - def apply_rotation(self, rotation, resize_win=False): """ Applies a rotation to the current image. @@ -1878,7 +1990,7 @@ class ImageViewer(QWidget): """Updates the window title with the current image name.""" title = f"{VIEWER_LABEL} - {os.path.basename( self.controller.get_current_path())}" - if self.slideshow_timer.isActive() or self.slideshow_reverse_timer.isActive(): + if self.slideshow_manager.is_running(): title += UITexts.VIEWER_TITLE_SLIDESHOW if self.movie and self.movie.state() == QMovie.Paused: title += UITexts.VIEWER_TITLE_PAUSED @@ -1993,24 +2105,12 @@ class ImageViewer(QWidget): def toggle_slideshow(self): """Starts or stops the automatic slideshow timer.""" - if self.slideshow_reverse_timer.isActive(): - self.slideshow_reverse_timer.stop() - - if self.slideshow_timer.isActive(): - self.slideshow_timer.stop() - else: - self.slideshow_timer.start() + self.slideshow_manager.toggle(reverse=False) self.update_view(resize_win=False) def toggle_slideshow_reverse(self): """Starts or stops the automatic reverse slideshow timer.""" - if self.slideshow_timer.isActive(): - self.slideshow_timer.stop() - - if self.slideshow_reverse_timer.isActive(): - self.slideshow_reverse_timer.stop() - else: - self.slideshow_reverse_timer.start() + self.slideshow_manager.toggle(reverse=True) self.update_view(resize_win=False) def set_slideshow_interval(self): @@ -2018,15 +2118,11 @@ class ImageViewer(QWidget): val, ok = QInputDialog.getInt(self, UITexts.SLIDESHOW_INTERVAL_TITLE, UITexts.SLIDESHOW_INTERVAL_TEXT, - self.slideshow_timer.interval() // 1000, 1, 3600) + self.slideshow_manager.get_interval() // 1000, + 1, 3600) if ok: new_interval_ms = val * 1000 - self.slideshow_timer.setInterval(new_interval_ms) - self.slideshow_reverse_timer.setInterval(new_interval_ms) - if self.slideshow_timer.isActive(): - self.slideshow_timer.start() - if self.slideshow_reverse_timer.isActive(): - self.slideshow_reverse_timer.start() + self.slideshow_manager.set_interval(new_interval_ms) def toggle_fullscreen(self): """Toggles the viewer window between fullscreen and normal states.""" @@ -2046,41 +2142,6 @@ class ImageViewer(QWidget): """Re-loads shortcuts from the main window configuration.""" self._setup_shortcuts() - def toggle_fit_to_screen(self): - """ - Toggles between fitting the image to the window and 100% actual size. - """ - # If close to 100%, fit to window. Otherwise 100%. - if abs(self.controller.zoom_factor - 1.0) < 0.01: - self.fit_to_window() - else: - self.controller.zoom_factor = 1.0 - self.update_view(resize_win=False) - - def fit_to_window(self): - """ - Calculates the zoom factor required to make the image fit perfectly - within the current viewport dimensions. - """ - if self.controller.pixmap_original.isNull(): - return - - viewport = self.scroll_area.viewport() - w_avail = viewport.width() - h_avail = viewport.height() - - transform = QTransform().rotate(self.controller.rotation) - transformed_pixmap = self.controller.pixmap_original.transformed( - transform, Qt.SmoothTransformation) - img_w = transformed_pixmap.width() - img_h = transformed_pixmap.height() - - if img_w == 0 or img_h == 0: - return - - self.controller.zoom_factor = min(w_avail / img_w, h_avail / img_h) - self.update_view(resize_win=False) - def _get_clicked_face(self, pos): """Checks if a click position is inside any face bounding box.""" for face in self.controller.faces: @@ -2145,7 +2206,7 @@ class ImageViewer(QWidget): new_full_tag, updated_history, ok = FaceNameDialog.get_name( self, history, current_name, main_win=self.main_win, - region_type=region_type, title=UITexts.RENAME_FACE_TITLE) + region_type=region_type, title=UITexts.RENAME_AREA_TITLE) if ok and new_full_tag and new_full_tag != current_name: # Remove old tag if it's not used by other faces @@ -2235,7 +2296,8 @@ class ImageViewer(QWidget): {"text": UITexts.VIEWER_MENU_RENAME, "action": "rename", "icon": "edit-rename"}, "separator", - {"text": UITexts.VIEWER_MENU_FIT_SCREEN, "slot": self.toggle_fit_to_screen, + {"text": UITexts.VIEWER_MENU_FIT_SCREEN, + "slot": self.zoom_manager.toggle_fit_to_screen, "icon": "zoom-fit-best"}, "separator", {"text": UITexts.VIEWER_MENU_CROP, "action": "toggle_crop", @@ -2253,8 +2315,8 @@ class ImageViewer(QWidget): menu_items.append({"text": pause_text, "action": "toggle_animation", "icon": pause_icon}) - is_fwd_slideshow = self.slideshow_timer.isActive() - is_rev_slideshow = self.slideshow_reverse_timer.isActive() + is_fwd_slideshow = self.slideshow_manager.is_forward() + is_rev_slideshow = self.slideshow_manager.is_reverse() slideshow_submenu = [ {"text": UITexts.VIEWER_MENU_STOP_SLIDESHOW if is_fwd_slideshow @@ -2722,8 +2784,7 @@ class ImageViewer(QWidget): """ if self.movie: self.movie.stop() - self.slideshow_timer.stop() - self.slideshow_reverse_timer.stop() + self.slideshow_manager.stop() if self.filmstrip_loader and self.filmstrip_loader.isRunning(): self.filmstrip_loader.stop() self.uninhibit_screensaver() diff --git a/metadatamanager.py b/metadatamanager.py index d7297e0..d3757de 100644 --- a/metadatamanager.py +++ b/metadatamanager.py @@ -155,3 +155,33 @@ class XattrManager: except Exception as e: raise IOError(f"Could not save xattr '{attr_name}' " "for {file_path}: {e}") from e + + @staticmethod + def get_all_attributes(path): + """ + Gets all extended attributes for a file as a dictionary. + + Args: + path (str): The path to the file. + + Returns: + dict: A dictionary mapping attribute names to values. + """ + attributes = {} + if not path: + return attributes + try: + keys = os.listxattr(path) + for key in keys: + try: + val = os.getxattr(path, key) + try: + val_str = val.decode('utf-8') + except UnicodeDecodeError: + val_str = str(val) + attributes[key] = val_str + except (OSError, AttributeError): + pass + except (OSError, AttributeError): + pass + return attributes diff --git a/propertiesdialog.py b/propertiesdialog.py index 3fd9017..a134c43 100644 --- a/propertiesdialog.py +++ b/propertiesdialog.py @@ -9,7 +9,6 @@ Classes: PropertiesDialog: A QDialog that presents file properties in a tabbed interface. """ -import os from PySide6.QtWidgets import ( QWidget, QLabel, QVBoxLayout, QMessageBox, QMenu, QInputDialog, QDialog, QTableWidget, QTableWidgetItem, QHeaderView, QTabWidget, @@ -18,14 +17,40 @@ from PySide6.QtWidgets import ( from PySide6.QtGui import ( QImageReader, QIcon, QColor ) -from PySide6.QtCore import ( - Qt, QFileInfo, QLocale -) +from PySide6.QtCore import (QThread, Signal, Qt, QFileInfo, QLocale) from constants import ( RATING_XATTR_NAME, XATTR_NAME, UITexts ) -from metadatamanager import MetadataManager, HAVE_EXIV2, notify_baloo -from utils import preserve_mtime +from metadatamanager import MetadataManager, HAVE_EXIV2, XattrManager + + +class PropertiesLoader(QThread): + """Background thread to load metadata (xattrs and EXIF) asynchronously.""" + loaded = Signal(dict, dict) + + def __init__(self, path, parent=None): + super().__init__(parent) + self.path = path + self._abort = False + + def stop(self): + """Signals the thread to stop and waits for it.""" + self._abort = True + self.wait() + + def run(self): + # Xattrs + if self._abort: + return + xattrs = XattrManager.get_all_attributes(self.path) + + if self._abort: + return + + # EXIF + exif_data = MetadataManager.read_all_metadata(self.path) + if not self._abort: + self.loaded.emit(xattrs, exif_data) class PropertiesDialog(QDialog): @@ -51,6 +76,7 @@ class PropertiesDialog(QDialog): self.setWindowTitle(UITexts.PROPERTIES_TITLE) self._initial_tags = initial_tags if initial_tags is not None else [] self._initial_rating = initial_rating + self.loader = None self.resize(400, 500) self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint) @@ -128,7 +154,8 @@ class PropertiesDialog(QDialog): self.table.setContextMenuPolicy(Qt.CustomContextMenu) self.table.customContextMenuRequested.connect(self.show_context_menu) - self.load_metadata() + # Initial partial load (synchronous, just passed args) + self.update_metadata_table({}, initial_only=True) meta_layout.addWidget(self.table) tabs.addTab(meta_widget, QIcon.fromTheme("document-properties"), UITexts.PROPERTIES_METADATA_TAB) @@ -159,7 +186,8 @@ class PropertiesDialog(QDialog): # This is a disk read. self.exif_table.customContextMenuRequested.connect(self.show_exif_context_menu) - self.load_exif_data() + # Placeholder for EXIF + self.update_exif_table(None) exif_layout.addWidget(self.exif_table) tabs.addTab(exif_widget, QIcon.fromTheme("view-details"), @@ -173,10 +201,18 @@ class PropertiesDialog(QDialog): btn_box.rejected.connect(self.close) layout.addWidget(btn_box) - def load_metadata(self): + # Start background loading + self.reload_metadata() + + def closeEvent(self, event): + if self.loader and self.loader.isRunning(): + self.loader.stop() + super().closeEvent(event) + + def update_metadata_table(self, disk_xattrs, initial_only=False): """ - Loads metadata from the file's text keys (via QImageReader) and - extended attributes (xattrs) into the metadata table. + Updates the metadata table with extended attributes. + Merges initial tags/rating with loaded xattrs. """ self.table.blockSignals(True) self.table.setRowCount(0) @@ -188,26 +224,11 @@ class PropertiesDialog(QDialog): if self._initial_rating > 0: preloaded_xattrs[RATING_XATTR_NAME] = str(self._initial_rating) - # Read other xattrs from disk - xattrs = {} - try: - for xkey in os.listxattr(self.path): - # Avoid re-reading already known attributes - if xkey not in preloaded_xattrs: - try: - val = os.getxattr(self.path, xkey) # This is a disk read - try: - val_str = val.decode('utf-8') - except UnicodeDecodeError: - val_str = str(val) - xattrs[xkey] = val_str - except Exception: - pass - except Exception: - pass - # Combine preloaded and newly read xattrs - all_xattrs = {**preloaded_xattrs, **xattrs} + all_xattrs = preloaded_xattrs.copy() + if not initial_only and disk_xattrs: + # Disk data takes precedence or adds to it + all_xattrs.update(disk_xattrs) self.table.setRowCount(len(all_xattrs)) @@ -224,11 +245,34 @@ class PropertiesDialog(QDialog): row += 1 self.table.blockSignals(False) - def load_exif_data(self): - """Loads EXIF, XMP, and IPTC metadata using the MetadataManager.""" + def reload_metadata(self): + """Starts the background thread to load metadata.""" + if self.loader and self.loader.isRunning(): + # Already running + return + self.loader = PropertiesLoader(self.path, self) + self.loader.loaded.connect(self.on_data_loaded) + self.loader.start() + + def on_data_loaded(self, xattrs, exif_data): + """Slot called when metadata is loaded from the thread.""" + self.update_metadata_table(xattrs, initial_only=False) + self.update_exif_table(exif_data) + + def update_exif_table(self, exif_data): + """Updates the EXIF table with loaded data.""" self.exif_table.blockSignals(True) self.exif_table.setRowCount(0) + if exif_data is None: + # Loading state + self.exif_table.setRowCount(1) + item = QTableWidgetItem("Loading data...") + item.setFlags(Qt.ItemIsEnabled) + self.exif_table.setItem(0, 0, item) + self.exif_table.blockSignals(False) + return + if not HAVE_EXIV2: self.exif_table.setRowCount(1) error_color = QColor("red") @@ -243,8 +287,6 @@ class PropertiesDialog(QDialog): self.exif_table.blockSignals(False) return - exif_data = MetadataManager.read_all_metadata(self.path) - if not exif_data: self.exif_table.setRowCount(1) item = QTableWidgetItem(UITexts.INFO) @@ -291,16 +333,11 @@ class PropertiesDialog(QDialog): if item.column() == 1: key = self.table.item(item.row(), 0).text() val = item.text() + # Treat empty or whitespace-only values as removal to match previous + # behavior + val_to_set = val if val.strip() else None try: - with preserve_mtime(self.path): - if not val.strip(): - try: - os.removexattr(self.path, key) - except OSError: - pass - else: - os.setxattr(self.path, key, val.encode('utf-8')) - notify_baloo(self.path) + XattrManager.set_attribute(self.path, key, val_to_set) except Exception as e: QMessageBox.warning(self, UITexts.ERROR, UITexts.PROPERTIES_ERROR_SET_ATTR.format(e)) @@ -361,10 +398,8 @@ class PropertiesDialog(QDialog): key)) if ok2: try: - with preserve_mtime(self.path): - os.setxattr(self.path, key, val.encode('utf-8')) - notify_baloo(self.path) - self.load_metadata() + XattrManager.set_attribute(self.path, key, val) + self.reload_metadata() except Exception as e: QMessageBox.warning(self, UITexts.ERROR, UITexts.PROPERTIES_ERROR_ADD_ATTR.format(e)) @@ -378,9 +413,7 @@ class PropertiesDialog(QDialog): """ key = self.table.item(row, 0).text() try: - with preserve_mtime(self.path): - os.removexattr(self.path, key) - notify_baloo(self.path) + XattrManager.set_attribute(self.path, key, None) self.table.removeRow(row) except Exception as e: QMessageBox.warning(self, UITexts.ERROR, diff --git a/settings.py b/settings.py index e5ac988..2553831 100644 --- a/settings.py +++ b/settings.py @@ -35,7 +35,7 @@ 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_BAGHEERASEARCH_LIB ) @@ -741,9 +741,24 @@ class SettingsDialog(QDialog): self.threads_spin.setValue(scan_threads) # Set search engine - index = self.search_engine_combo.findData(search_engine) - if index != -1: - self.search_engine_combo.setCurrentIndex(index) + if HAVE_BAGHEERASEARCH_LIB: + self.search_engine_combo.setEnabled(True) + if search_engine != "Baloo": + index = self.search_engine_combo.findData("Bagheera") + if index != -1: + self.search_engine_combo.setCurrentIndex(index) + else: + index = self.search_engine_combo.findData("Baloo") + if index != -1: + self.search_engine_combo.setCurrentIndex(index) + else: + self.search_engine_combo.setEnabled(False) + if SEARCH_CMD: + index = self.search_engine_combo.findData("Baloo") + if index != -1: + self.search_engine_combo.setCurrentIndex(index) + else: + self.search_engine_combo.setCurrentIndex(-1) self.scan_full_on_start_checkbox.setChecked(scan_full_on_start) @@ -1009,7 +1024,8 @@ class SettingsDialog(QDialog): APP_CONFIG["scan_max_level"] = self.scan_max_level_spin.value() APP_CONFIG["generation_threads"] = self.threads_spin.value() APP_CONFIG["scan_batch_size"] = self.scan_batch_size_spin.value() - APP_CONFIG["search_engine"] = self.search_engine_combo.currentData() + if HAVE_BAGHEERASEARCH_LIB: + APP_CONFIG["search_engine"] = self.search_engine_combo.currentData() APP_CONFIG["scan_full_on_start"] = self.scan_full_on_start_checkbox.isChecked() APP_CONFIG["person_tags"] = self.person_tags_edit.text() APP_CONFIG["pet_tags"] = self.pet_tags_edit.text()