A bunch of changes

This commit is contained in:
Ignacio Serantes
2026-03-23 22:50:02 +01:00
parent 547bfbf760
commit 291f2f9e47
8 changed files with 401 additions and 250 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,18 +1631,23 @@ class ImageViewer(QWidget):
Args:
restore_config (dict, optional): State dictionary to restore from.
"""
success, reloaded = self.controller.load_image()
if not success:
if self.movie:
self.movie.stop()
self.movie = None
if not self.controller.load_image():
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 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:
@@ -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()

View File

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

View File

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

View File

@@ -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 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,6 +1024,7 @@ 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()
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()