Fixed thumbnail reload on metadata change

This commit is contained in:
Ignacio Serantes
2026-04-06 22:09:13 +02:00
parent a717acef87
commit 45c95c1bb1
4 changed files with 108 additions and 36 deletions

View File

@@ -1037,6 +1037,7 @@ class MainWindow(QMainWindow):
self._group_info_cache = {} self._group_info_cache = {}
self._visible_paths_cache = None # Cache for visible image paths self._visible_paths_cache = None # Cache for visible image paths
self._path_to_model_index = {} self._path_to_model_index = {}
self._paths_being_modified_by_app = set() # For ignoring FS events
# Keep references to open viewers to manage their lifecycle # Keep references to open viewers to manage their lifecycle
self.viewers = [] self.viewers = []
@@ -1349,6 +1350,10 @@ class MainWindow(QMainWindow):
self.fs_watcher.monitoring_status_changed.connect( self.fs_watcher.monitoring_status_changed.connect(
self.on_fs_watcher_status_changed) self.on_fs_watcher_status_changed)
# Set up callback for metadata managers
from metadatamanager import set_app_modified_callback
set_app_modified_callback(self._mark_path_as_app_modified)
# Batching for file creation events # Batching for file creation events
self._fs_created_queue = set() self._fs_created_queue = set()
self._fs_created_timer = QTimer(self) self._fs_created_timer = QTimer(self)
@@ -4863,22 +4868,52 @@ class MainWindow(QMainWindow):
if path not in self._known_paths: if path not in self._known_paths:
return # Not a file we're tracking return # Not a file we're tracking
# Invalidate cache and trigger a refresh of its metadata and thumbnail # If this modification was initiated by the app, ignore it
self.cache.invalidate_path(path) if path in self._paths_being_modified_by_app:
return
# Re-read metadata and thumbnail # External modification: check if it's metadata-only or content change
res = load_common_metadata(path) try:
mtime = os.path.getmtime(path) new_stat = os.stat(path)
stat_res = os.stat(path) new_mtime = new_stat.st_mtime
inode = stat_res.st_ino new_size = new_stat.st_size
dev = stat_res.st_dev
# Update internal data and model # Find old data from internal list
self._update_internal_data(path, mtime=mtime, tags=res.tags, rating=res.rating, old_item_data = next((item for item in self.found_items_data if item[0] == path), None)
inode=inode, dev=dev) old_mtime = old_item_data[2] if old_item_data else 0
self.proxy_model.add_to_cache(path, res.tags) old_size = os.path.getsize(path) if old_item_data else 0 # Re-read size from disk for comparison
self.rebuild_view()
self.status_lbl.setText(f"File modified: {os.path.basename(path)}") if new_size == old_size and new_mtime != old_mtime:
# Likely metadata-only change (size unchanged, mtime changed)
res = load_common_metadata(path)
self._update_internal_data(path, mtime=new_mtime, tags=res.tags, rating=res.rating,
inode=new_stat.st_ino, dev=new_stat.st_dev)
self.proxy_model.add_to_cache(path, res.tags)
self.thumbnail_view.viewport().update() # Force repaint
self.status_lbl.setText(f"Metadata updated: {os.path.basename(path)}")
else:
# Content or size changed, invalidate thumbnail and rebuild view
self.cache.invalidate_path(path)
res = load_common_metadata(path) # Re-read metadata as well
self._update_internal_data(path, mtime=new_mtime, tags=res.tags, rating=res.rating,
inode=new_stat.st_ino, dev=new_stat.st_dev)
self.proxy_model.add_to_cache(path, res.tags)
self.rebuild_view()
self.status_lbl.setText(f"File modified: {os.path.basename(path)}")
except Exception:
# Fallback to full refresh if error occurs
self.refresh_content()
def _mark_path_as_app_modified(self, path):
"""Marks a path as being modified by the application to ignore FS events."""
abs_path = os.path.abspath(path)
parent_path = os.path.dirname(abs_path)
self._paths_being_modified_by_app.add(abs_path)
self._paths_being_modified_by_app.add(parent_path)
# Schedule removal after a delay to allow all FS events to propagate
QTimer.singleShot(1000, lambda: self._paths_being_modified_by_app.discard(abs_path))
QTimer.singleShot(1000, lambda: self._paths_being_modified_by_app.discard(parent_path))
def on_fs_watcher_status_changed(self, is_monitoring): def on_fs_watcher_status_changed(self, is_monitoring):
"""Updates the UI indicator for the FileSystemWatcher.""" """Updates the UI indicator for the FileSystemWatcher."""
@@ -4893,6 +4928,9 @@ class MainWindow(QMainWindow):
"""Handles a directory being modified (e.g., new subfolder, mass changes).""" """Handles a directory being modified (e.g., new subfolder, mass changes)."""
path = os.path.abspath(path) path = os.path.abspath(path)
if path in self._paths_being_modified_by_app:
return
# Trigger a debounced full refresh. This is useful for syncing large # Trigger a debounced full refresh. This is useful for syncing large
# external changes (bulk operations, directory deletions) that are # external changes (bulk operations, directory deletions) that are
# more robustly handled by a full scan than incremental updates. # more robustly handled by a full scan than incremental updates.

View File

@@ -1,16 +1,16 @@
import os import os
from datetime import datetime from datetime import datetime
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QFrame, QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QLabel,
QSplitter, QWidget, QMessageBox, QApplication, QMenu, QSplitter, QWidget, QMessageBox, QApplication, QMenu,
QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView
) )
from PySide6.QtGui import QPixmap, QIcon, QImageReader, QImage, QDesktopServices from PySide6.QtGui import QIcon, QImage, QDesktopServices
from PySide6.QtCore import Qt, QSize, QTimer, QUrl from PySide6.QtCore import Qt, QTimer, QUrl
from imageviewer import ImagePane from imageviewer import ImagePane
from imagecontroller import ImageController
from constants import UITexts, APP_CONFIG
from propertiesdialog import PropertiesDialog from propertiesdialog import PropertiesDialog
from constants import APP_CONFIG, UITexts
class DuplicateManagerDialog(QDialog): class DuplicateManagerDialog(QDialog):
""" """
@@ -18,7 +18,7 @@ class DuplicateManagerDialog(QDialog):
""" """
def __init__(self, duplicates, duplicate_cache, main_win, review_mode=False): def __init__(self, duplicates, duplicate_cache, main_win, review_mode=False):
super().__init__(main_win) super().__init__(main_win)
self.duplicates = duplicates # List of DuplicateResult self.duplicates = duplicates # List of DuplicateResult
self.cache = duplicate_cache self.cache = duplicate_cache
self.main_win = main_win self.main_win = main_win
self.review_mode = review_mode self.review_mode = review_mode
@@ -35,7 +35,8 @@ class DuplicateManagerDialog(QDialog):
self._populate_list() self._populate_list()
if self.main_win and hasattr(self.main_win, 'fs_watcher'): 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_deleted.connect(
self._on_file_deleted_externally)
self.main_win.fs_watcher.file_moved.connect(self._on_file_moved_externally) self.main_win.fs_watcher.file_moved.connect(self._on_file_moved_externally)
if self.duplicates: if self.duplicates:
@@ -59,15 +60,22 @@ class DuplicateManagerDialog(QDialog):
self.table_widget = QTableWidget() self.table_widget = QTableWidget()
if self.review_mode: if self.review_mode:
self.table_widget.setColumnCount(3) self.table_widget.setColumnCount(3)
self.table_widget.setHorizontalHeaderLabels([UITexts.IGNORED_DATE, "%", UITexts.CONTEXT_MENU_OPEN]) self.table_widget.setHorizontalHeaderLabels(
self.table_widget.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents) [UITexts.IGNORED_DATE, "%", UITexts.CONTEXT_MENU_OPEN])
self.table_widget.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents) self.table_widget.horizontalHeader().setSectionResizeMode(
self.table_widget.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch) 0, QHeaderView.ResizeToContents)
self.table_widget.horizontalHeader().setSectionResizeMode(
1, QHeaderView.ResizeToContents)
self.table_widget.horizontalHeader().setSectionResizeMode(
2, QHeaderView.Stretch)
else: else:
self.table_widget.setColumnCount(2) self.table_widget.setColumnCount(2)
self.table_widget.setHorizontalHeaderLabels(["%", UITexts.CONTEXT_MENU_OPEN]) # Usamos una cadena existente o genérica self.table_widget.setHorizontalHeaderLabels(
self.table_widget.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents) ["%", UITexts.CONTEXT_MENU_OPEN]) # Usamos una cadena existente o genérica
self.table_widget.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch) 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.verticalHeader().setVisible(False)
self.table_widget.setSelectionBehavior(QAbstractItemView.SelectRows) self.table_widget.setSelectionBehavior(QAbstractItemView.SelectRows)
@@ -268,11 +276,20 @@ class DuplicateManagerDialog(QDialog):
pane.zoom_manager.calculate_initial_zoom(w, h, True) pane.zoom_manager.calculate_initial_zoom(w, h, True)
self.update_view_for_pane(pane) self.update_view_for_pane(pane)
def reset_inactivity_timer(self): pass def reset_inactivity_timer(self):
def sync_filmstrip_selection(self, index): pass pass
def _get_clicked_face_for_pane(self, pane, pos): return None
def rename_face(self, face): pass def sync_filmstrip_selection(self, index):
def toggle_fullscreen(self): pass 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): def _create_comparison_pane_widget(self):
widget = QWidget() widget = QWidget()

View File

@@ -16,6 +16,9 @@ try:
except ImportError: except ImportError:
exiv2 = None exiv2 = None
HAVE_EXIV2 = False HAVE_EXIV2 = False
_app_modified_callback = None
from utils import preserve_mtime from utils import preserve_mtime
from constants import RATING_XATTR_NAME, XATTR_NAME from constants import RATING_XATTR_NAME, XATTR_NAME
@@ -23,6 +26,15 @@ MetadataResult = collections.namedtuple('MetadataResult', ['tags', 'rating'])
EMPTY_METADATA = MetadataResult([], 0) EMPTY_METADATA = MetadataResult([], 0)
def set_app_modified_callback(callback):
global _app_modified_callback
_app_modified_callback = callback
def mark_app_modified(path):
"""Triggers the application-modified callback for a path."""
if _app_modified_callback:
_app_modified_callback(path)
def notify_baloo(path): def notify_baloo(path):
""" """
Notifies the Baloo file indexer about a file change using DBus. Notifies the Baloo file indexer about a file change using DBus.
@@ -148,6 +160,7 @@ class XattrManager:
return return
try: try:
with preserve_mtime(file_path): with preserve_mtime(file_path):
mark_app_modified(file_path)
if value: if value:
os.setxattr(file_path, attr_name, str(value).encode('utf-8')) os.setxattr(file_path, attr_name, str(value).encode('utf-8'))
else: else:

View File

@@ -15,10 +15,11 @@ Dependencies:
- utils.preserve_mtime: A utility to prevent file modification times from - utils.preserve_mtime: A utility to prevent file modification times from
changing during metadata writes. changing during metadata writes.
""" """
from importlib.resources import path
import os import os
import re import re
from utils import preserve_mtime from utils import preserve_mtime
from metadatamanager import notify_baloo from metadatamanager import notify_baloo, mark_app_modified
try: try:
import exiv2 import exiv2
except ImportError: except ImportError:
@@ -38,8 +39,9 @@ class XmpManager:
This method parses the XMP data structure for a `mwg-rs:RegionList`, This method parses the XMP data structure for a `mwg-rs:RegionList`,
extracts all regions of type 'Face', and returns them as a list of extracts all regions of type 'Face', and returns them as a list of
dictionaries. Each dictionary contains the face's name and its dictionaries.
normalized coordinates (center x, center y, width, height). Each dictionary contains the face's name and its normalized coordinates
(center x, center y, width, height).
Args: Args:
path (str): The path to the image file. path (str): The path to the image file.
@@ -161,7 +163,9 @@ class XmpManager:
xmp[f"{area_base}/stArea:unit"] = 'normalized' xmp[f"{area_base}/stArea:unit"] = 'normalized'
img.writeMetadata() img.writeMetadata()
notify_baloo(path) notify_baloo(path)
mark_app_modified(path)
return True return True
except Exception as e: except Exception as e:
print(f"Error saving faces to XMP: {e}") print(f"Error saving faces to XMP: {e}")