Added watchdog support
This commit is contained in:
489
bagheeraview.py
489
bagheeraview.py
@@ -78,6 +78,7 @@ from widgets import (
|
|||||||
FavoritesWidget
|
FavoritesWidget
|
||||||
)
|
)
|
||||||
from metadatamanager import load_common_metadata
|
from metadatamanager import load_common_metadata
|
||||||
|
from filesystemwatcher import FileSystemWatcher
|
||||||
|
|
||||||
|
|
||||||
class ShortcutHelpDialog(QDialog):
|
class ShortcutHelpDialog(QDialog):
|
||||||
@@ -1067,6 +1068,11 @@ class MainWindow(QMainWindow):
|
|||||||
self.progress_bar.hide()
|
self.progress_bar.hide()
|
||||||
bot.addWidget(self.progress_bar)
|
bot.addWidget(self.progress_bar)
|
||||||
|
|
||||||
|
self.fs_watcher_status_lbl = QLabel()
|
||||||
|
self.fs_watcher_status_lbl.setToolTip(UITexts.FS_WATCHER_TOOLTIP)
|
||||||
|
self.fs_watcher_status_lbl.hide()
|
||||||
|
bot.addWidget(self.fs_watcher_status_lbl)
|
||||||
|
|
||||||
# Timer to hide progress bar with delay
|
# Timer to hide progress bar with delay
|
||||||
self.hide_progress_timer = QTimer(self)
|
self.hide_progress_timer = QTimer(self)
|
||||||
self.hide_progress_timer.setSingleShot(True)
|
self.hide_progress_timer.setSingleShot(True)
|
||||||
@@ -1236,12 +1242,6 @@ class MainWindow(QMainWindow):
|
|||||||
self.favorites_tab.favorites_changed.connect(
|
self.favorites_tab.favorites_changed.connect(
|
||||||
self.shortcut_controller.refresh_favorite_shortcuts)
|
self.shortcut_controller.refresh_favorite_shortcuts)
|
||||||
|
|
||||||
# Initialize FileSystemWatcher
|
|
||||||
self.fs_watcher = FileSystemWatcher()
|
|
||||||
self.fs_watcher.file_created.connect(self.on_fs_file_created)
|
|
||||||
self.fs_watcher.file_deleted.connect(self.on_fs_file_deleted)
|
|
||||||
self.fs_watcher.file_modified.connect(self.on_fs_file_modified)
|
|
||||||
|
|
||||||
self.main_dock.setWidget(self.tags_tabs)
|
self.main_dock.setWidget(self.tags_tabs)
|
||||||
self.addDockWidget(Qt.RightDockWidgetArea, self.main_dock)
|
self.addDockWidget(Qt.RightDockWidgetArea, self.main_dock)
|
||||||
|
|
||||||
@@ -1289,6 +1289,30 @@ class MainWindow(QMainWindow):
|
|||||||
self.thumbnail_view.verticalScrollBar().valueChanged.connect(
|
self.thumbnail_view.verticalScrollBar().valueChanged.connect(
|
||||||
self._on_scroll_interaction)
|
self._on_scroll_interaction)
|
||||||
|
|
||||||
|
# Initialize FileSystemWatcher
|
||||||
|
self.fs_watcher = FileSystemWatcher()
|
||||||
|
self.fs_watcher.file_created.connect(self.on_fs_file_created)
|
||||||
|
self.fs_watcher.file_deleted.connect(self.on_fs_file_deleted)
|
||||||
|
self.fs_watcher.file_modified.connect(self.on_fs_file_modified)
|
||||||
|
self.fs_watcher.directory_modified.connect(self.on_fs_directory_modified)
|
||||||
|
self.fs_watcher.file_moved.connect(self.on_fs_file_moved)
|
||||||
|
self.fs_watcher.directory_moved.connect(self.on_fs_directory_moved)
|
||||||
|
self.fs_watcher.monitoring_status_changed.connect(
|
||||||
|
self.on_fs_watcher_status_changed)
|
||||||
|
|
||||||
|
# Batching for file creation events
|
||||||
|
self._fs_created_queue = set()
|
||||||
|
self._fs_created_timer = QTimer(self)
|
||||||
|
self._fs_created_timer.setSingleShot(True)
|
||||||
|
self._fs_created_timer.setInterval(1000) # 1 second debounce
|
||||||
|
self._fs_created_timer.timeout.connect(self._process_fs_created_batch)
|
||||||
|
|
||||||
|
# Debounce timer for full refreshes on directory modifications
|
||||||
|
self._fs_dir_refresh_timer = QTimer(self)
|
||||||
|
self._fs_dir_refresh_timer.setSingleShot(True)
|
||||||
|
self._fs_dir_refresh_timer.setInterval(2500) # 2.5 seconds debounce
|
||||||
|
self._fs_dir_refresh_timer.timeout.connect(self.refresh_content)
|
||||||
|
|
||||||
# Initial configuration loading
|
# Initial configuration loading
|
||||||
self.load_config()
|
self.load_config()
|
||||||
self.load_full_history()
|
self.load_full_history()
|
||||||
@@ -1607,15 +1631,17 @@ class MainWindow(QMainWindow):
|
|||||||
# 5. Start scanning all parent directories of the images in the layout
|
# 5. Start scanning all parent directories of the images in the layout
|
||||||
unique_dirs = list({str(Path(p).parent) for p in paths})
|
unique_dirs = list({str(Path(p).parent) for p in paths})
|
||||||
for d in unique_dirs:
|
for d in unique_dirs:
|
||||||
|
if d not in paths:
|
||||||
paths.append(d)
|
paths.append(d)
|
||||||
|
|
||||||
self.start_scan([p.strip() for p in paths if p.strip()
|
self.start_scan([p.strip() for p in paths if p.strip()
|
||||||
and os.path.exists(os.path.expanduser(p.strip()))],
|
and os.path.exists(os.path.expanduser(p.strip()))],
|
||||||
select_paths=select_paths)
|
select_paths=select_paths)
|
||||||
|
|
||||||
if search_text:
|
if search_text:
|
||||||
self.search_input.setEditText(search_text)
|
self.search_input.setEditText(search_text)
|
||||||
|
|
||||||
# --- UI and Menu Logic ---
|
# --- UI and Menu Logic ---
|
||||||
|
|
||||||
def show_main_menu(self):
|
def show_main_menu(self):
|
||||||
"""Displays the main application menu."""
|
"""Displays the main application menu."""
|
||||||
menu = QMenu(self)
|
menu = QMenu(self)
|
||||||
@@ -1817,6 +1843,7 @@ class MainWindow(QMainWindow):
|
|||||||
def perform_shutdown(self):
|
def perform_shutdown(self):
|
||||||
"""Performs cleanup operations before the application closes."""
|
"""Performs cleanup operations before the application closes."""
|
||||||
self.is_cleaning = True
|
self.is_cleaning = True
|
||||||
|
self.fs_watcher.stop()
|
||||||
# 1. Stop all worker threads interacting with the cache
|
# 1. Stop all worker threads interacting with the cache
|
||||||
|
|
||||||
# Signal all threads to stop first
|
# Signal all threads to stop first
|
||||||
@@ -2059,7 +2086,13 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
def find_and_select_path(self, path_to_select):
|
def find_and_select_path(self, path_to_select):
|
||||||
"""Finds an item by its path in the model and selects it using a cache."""
|
"""Finds an item by its path in the model and selects it using a cache."""
|
||||||
if not path_to_select or path_to_select not in self._path_to_model_index:
|
if not path_to_select:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Ensure path is normalized for reliable cache lookup
|
||||||
|
path_to_select = os.path.abspath(os.path.expanduser(path_to_select))
|
||||||
|
|
||||||
|
if path_to_select not in self._path_to_model_index:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
persistent_index = self._path_to_model_index[path_to_select]
|
persistent_index = self._path_to_model_index[path_to_select]
|
||||||
@@ -2072,6 +2105,11 @@ class MainWindow(QMainWindow):
|
|||||||
proxy_index = self.proxy_model.mapFromSource(source_index)
|
proxy_index = self.proxy_model.mapFromSource(source_index)
|
||||||
|
|
||||||
if proxy_index.isValid():
|
if proxy_index.isValid():
|
||||||
|
# Optimization: skip if already selected and current to avoid
|
||||||
|
# unnecessary signals and view updates.
|
||||||
|
if self.thumbnail_view.currentIndex() == proxy_index and \
|
||||||
|
self.thumbnail_view.selectionModel().isSelected(proxy_index):
|
||||||
|
return True
|
||||||
self.set_selection(proxy_index)
|
self.set_selection(proxy_index)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -2368,16 +2406,16 @@ class MainWindow(QMainWindow):
|
|||||||
inode=new_inode, device_id=new_dev)
|
inode=new_inode, device_id=new_dev)
|
||||||
|
|
||||||
# Update model item
|
# Update model item
|
||||||
for row in range(self.thumbnail_model.rowCount()):
|
if path in self._path_to_model_index:
|
||||||
item = self.thumbnail_model.item(row)
|
p_idx = self._path_to_model_index[path]
|
||||||
if item and item.data(PATH_ROLE) == path:
|
if p_idx.isValid():
|
||||||
|
item = self.thumbnail_model.itemFromIndex(QModelIndex(p_idx))
|
||||||
item.setIcon(QIcon(QPixmap.fromImage(thumb_img)))
|
item.setIcon(QIcon(QPixmap.fromImage(thumb_img)))
|
||||||
item.setData(new_mtime, MTIME_ROLE)
|
item.setData(new_mtime, MTIME_ROLE)
|
||||||
item.setData(new_inode, INODE_ROLE)
|
item.setData(new_inode, INODE_ROLE)
|
||||||
item.setData(new_dev, DEVICE_ROLE)
|
item.setData(new_dev, DEVICE_ROLE)
|
||||||
self._update_internal_data(path, qi=thumb_img, mtime=new_mtime,
|
self._update_internal_data(path, qi=thumb_img, mtime=new_mtime,
|
||||||
inode=new_inode, dev=new_dev)
|
inode=new_inode, dev=new_dev)
|
||||||
break
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -2418,6 +2456,7 @@ class MainWindow(QMainWindow):
|
|||||||
self.proxy_model.clear_cache()
|
self.proxy_model.clear_cache()
|
||||||
self._model_update_queue.clear()
|
self._model_update_queue.clear()
|
||||||
self._model_update_timer.stop()
|
self._model_update_timer.stop()
|
||||||
|
self.fs_watcher.clear_paths()
|
||||||
|
|
||||||
# Stop any pending hide action from previous scan
|
# Stop any pending hide action from previous scan
|
||||||
self.hide_progress_timer.stop()
|
self.hide_progress_timer.stop()
|
||||||
@@ -2437,6 +2476,12 @@ class MainWindow(QMainWindow):
|
|||||||
self.scanner.set_auto_load(True)
|
self.scanner.set_auto_load(True)
|
||||||
self._is_loading = True
|
self._is_loading = True
|
||||||
self.scanner.images_found.connect(self.collect_found_images)
|
self.scanner.images_found.connect(self.collect_found_images)
|
||||||
|
|
||||||
|
# Add directories to file system watcher
|
||||||
|
for p in paths:
|
||||||
|
if os.path.isdir(os.path.abspath(os.path.expanduser(p))):
|
||||||
|
self.fs_watcher.add_path(os.path.abspath(os.path.expanduser(p)))
|
||||||
|
|
||||||
self.scanner.progress_percent.connect(self.update_progress_bar)
|
self.scanner.progress_percent.connect(self.update_progress_bar)
|
||||||
self.scanner.progress_msg.connect(self.status_lbl.setText)
|
self.scanner.progress_msg.connect(self.status_lbl.setText)
|
||||||
self.scanner.more_files_available.connect(self.more_files_available)
|
self.scanner.more_files_available.connect(self.more_files_available)
|
||||||
@@ -2625,12 +2670,9 @@ class MainWindow(QMainWindow):
|
|||||||
# Check for Header match
|
# Check for Header match
|
||||||
# target format: ('HEADER', (key, header_text, count))
|
# target format: ('HEADER', (key, header_text, count))
|
||||||
if isinstance(target, tuple) and len(target) == 2 and target[0] == 'HEADER':
|
if isinstance(target, tuple) and len(target) == 2 and target[0] == 'HEADER':
|
||||||
_, (_, header_text, _) = target
|
|
||||||
# Strict match including group name to ensure roles are updated
|
|
||||||
target_group_name = target[1][0]
|
target_group_name = target[1][0]
|
||||||
return (item.data(ITEM_TYPE_ROLE) == 'header' and
|
return (item.data(ITEM_TYPE_ROLE) == 'header' and
|
||||||
item.data(GROUP_NAME_ROLE) == target_group_name and
|
item.data(GROUP_NAME_ROLE) == target_group_name)
|
||||||
item.data(DIR_ROLE) == header_text)
|
|
||||||
|
|
||||||
# Check for Thumbnail match
|
# Check for Thumbnail match
|
||||||
# target format: (path, qi, mtime, tags, rating, inode, dev)
|
# target format: (path, qi, mtime, tags, rating, inode, dev)
|
||||||
@@ -2641,48 +2683,94 @@ class MainWindow(QMainWindow):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def _get_group_info(self, path, mtime, rating):
|
def _get_group_info(self, path, mtime, rating):
|
||||||
"""Calculates the grouping key and display name for a file.
|
"""Calculates the grouping key and display name for a file with optimized
|
||||||
|
caching."""
|
||||||
Args:
|
# Determine resolution criteria for shared caching across all files in same
|
||||||
path (str): File path.
|
# group
|
||||||
mtime (float): Modification time.
|
|
||||||
rating (int): Rating value.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
tuple: (stable_group_key, display_name)
|
|
||||||
"""
|
|
||||||
cache_key = (path, mtime, rating)
|
|
||||||
if cache_key in self._group_info_cache:
|
|
||||||
return self._group_info_cache[cache_key]
|
|
||||||
|
|
||||||
stable_group_key = None
|
|
||||||
display_name = None
|
|
||||||
|
|
||||||
if self.proxy_model.group_by_folder:
|
if self.proxy_model.group_by_folder:
|
||||||
stable_group_key = display_name = os.path.dirname(path)
|
crit = os.path.dirname(path)
|
||||||
elif self.proxy_model.group_by_day:
|
mode = 'F'
|
||||||
stable_group_key = display_name = datetime.fromtimestamp(
|
|
||||||
mtime).strftime("%Y-%m-%d")
|
|
||||||
elif self.proxy_model.group_by_week:
|
|
||||||
dt = datetime.fromtimestamp(mtime)
|
|
||||||
stable_group_key = dt.strftime("%Y-%W")
|
|
||||||
display_name = UITexts.GROUP_BY_WEEK_FORMAT.format(
|
|
||||||
year=dt.strftime("%Y"), week=dt.strftime("%W"))
|
|
||||||
elif self.proxy_model.group_by_month:
|
|
||||||
dt = datetime.fromtimestamp(mtime)
|
|
||||||
stable_group_key = dt.strftime("%Y-%m")
|
|
||||||
display_name = dt.strftime("%B %Y").capitalize()
|
|
||||||
elif self.proxy_model.group_by_year:
|
|
||||||
stable_group_key = display_name = datetime.fromtimestamp(
|
|
||||||
mtime).strftime("%Y")
|
|
||||||
elif self.proxy_model.group_by_rating:
|
elif self.proxy_model.group_by_rating:
|
||||||
r = rating if rating is not None else 0
|
crit = (rating + 1) // 2 if rating is not None else 0
|
||||||
stars = (r + 1) // 2
|
mode = 'R'
|
||||||
stable_group_key = str(stars)
|
else:
|
||||||
display_name = UITexts.GROUP_BY_RATING_FORMAT.format(stars=stars)
|
# Date modes: use datetime object parts as hashable criteria
|
||||||
|
dt = datetime.fromtimestamp(int(mtime) if mtime else 0)
|
||||||
|
if self.proxy_model.group_by_day:
|
||||||
|
crit = (dt.year, dt.month, dt.day)
|
||||||
|
mode = 'D'
|
||||||
|
elif self.proxy_model.group_by_week:
|
||||||
|
crit = (dt.year, int(dt.strftime("%W")))
|
||||||
|
mode = 'W'
|
||||||
|
elif self.proxy_model.group_by_month:
|
||||||
|
crit = (dt.year, dt.month)
|
||||||
|
mode = 'M'
|
||||||
|
elif self.proxy_model.group_by_year:
|
||||||
|
crit = dt.year
|
||||||
|
mode = 'Y'
|
||||||
|
else:
|
||||||
|
return (None, None)
|
||||||
|
|
||||||
self._group_info_cache[cache_key] = (stable_group_key, display_name)
|
# Shared cache by criteria ensures expensive formatting happens only once per
|
||||||
return stable_group_key, display_name
|
# group
|
||||||
|
shared_key = (crit, mode)
|
||||||
|
if shared_key in self._group_info_cache:
|
||||||
|
return self._group_info_cache[shared_key]
|
||||||
|
|
||||||
|
# Perform actual calculation
|
||||||
|
if mode == 'F':
|
||||||
|
res = (crit, crit)
|
||||||
|
elif mode == 'R':
|
||||||
|
res = (str(crit), UITexts.GROUP_BY_RATING_FORMAT.format(stars=crit))
|
||||||
|
else:
|
||||||
|
if mode == 'D':
|
||||||
|
sk = dn = dt.strftime("%Y-%m-%d")
|
||||||
|
elif mode == 'W':
|
||||||
|
sk = dt.strftime("%Y-%W")
|
||||||
|
dn = UITexts.GROUP_BY_WEEK_FORMAT.format(
|
||||||
|
year=dt.strftime("%Y"), week=dt.strftime("%W"))
|
||||||
|
elif mode == 'M':
|
||||||
|
sk = dt.strftime("%Y-%m")
|
||||||
|
dn = dt.strftime("%B %Y").capitalize()
|
||||||
|
else: # Year
|
||||||
|
sk = dn = dt.strftime("%Y")
|
||||||
|
res = (sk, dn)
|
||||||
|
|
||||||
|
self._group_info_cache[shared_key] = res
|
||||||
|
return res
|
||||||
|
|
||||||
|
def _find_sorted_index_in_data(self, new_item_data):
|
||||||
|
"""Finds the correct index to insert an item to keep found_items_data sorted."""
|
||||||
|
mode = self.sort_combo.currentText()
|
||||||
|
rev = "↓" in mode
|
||||||
|
sort_by_name = "Name" in mode
|
||||||
|
|
||||||
|
def get_key(item):
|
||||||
|
# item structure: (path, qi, mtime, tags, rating, inode, dev)
|
||||||
|
path, _, mtime, _, _, _, _ = item
|
||||||
|
if sort_by_name:
|
||||||
|
return os.path.basename(path).lower()
|
||||||
|
return mtime if mtime is not None else 0
|
||||||
|
|
||||||
|
target_key = get_key(new_item_data)
|
||||||
|
|
||||||
|
# Binary search for the insertion point (O(log N))
|
||||||
|
lo = 0
|
||||||
|
hi = len(self.found_items_data)
|
||||||
|
while lo < hi:
|
||||||
|
mid = (lo + hi) // 2
|
||||||
|
mid_key = get_key(self.found_items_data[mid])
|
||||||
|
if not rev:
|
||||||
|
if mid_key < target_key:
|
||||||
|
lo = mid + 1
|
||||||
|
else:
|
||||||
|
hi = mid
|
||||||
|
else:
|
||||||
|
if mid_key > target_key:
|
||||||
|
lo = mid + 1
|
||||||
|
else:
|
||||||
|
hi = mid
|
||||||
|
return lo
|
||||||
|
|
||||||
def rebuild_view(self, full_reset=False):
|
def rebuild_view(self, full_reset=False):
|
||||||
"""
|
"""
|
||||||
@@ -2779,13 +2867,26 @@ class MainWindow(QMainWindow):
|
|||||||
self.thumbnail_model.clear()
|
self.thumbnail_model.clear()
|
||||||
self._path_to_model_index.clear()
|
self._path_to_model_index.clear()
|
||||||
|
|
||||||
# Optimize grouped insertion: Decorate-Sort-Group
|
# 1. Decorate: Calculate group info once per item with local memoization
|
||||||
# 1. Decorate: Calculate group info once per item
|
|
||||||
decorated_data = []
|
decorated_data = []
|
||||||
|
local_memo = {}
|
||||||
|
# Cache grouping flags to avoid property lookups in loop
|
||||||
|
g_folder = self.proxy_model.group_by_folder
|
||||||
|
|
||||||
for item in self.found_items_data:
|
for item in self.found_items_data:
|
||||||
# item structure: (path, qi, mtime, tags, rating, inode, dev)
|
# item structure: (path, qi, mtime, tags, rating, inode, dev)
|
||||||
|
path, _, mtime, _, rating, _, _ = item
|
||||||
|
|
||||||
|
# Local cache key: path for folders, (int_mtime, rating) for others
|
||||||
|
m_key = path if g_folder else (int(mtime) if mtime else 0, rating)
|
||||||
|
|
||||||
|
if m_key in local_memo:
|
||||||
|
stable_key, display_name = local_memo[m_key]
|
||||||
|
else:
|
||||||
stable_key, display_name = self._get_group_info(
|
stable_key, display_name = self._get_group_info(
|
||||||
item[0], item[2], item[4])
|
path, mtime, rating)
|
||||||
|
local_memo[m_key] = (stable_key, display_name)
|
||||||
|
|
||||||
# Use empty string for None keys to ensure sortability
|
# Use empty string for None keys to ensure sortability
|
||||||
sort_key = stable_key if stable_key is not None else ""
|
sort_key = stable_key if stable_key is not None else ""
|
||||||
decorated_data.append((sort_key, display_name, item))
|
decorated_data.append((sort_key, display_name, item))
|
||||||
@@ -2824,13 +2925,82 @@ class MainWindow(QMainWindow):
|
|||||||
total_targets = len(target_structure)
|
total_targets = len(target_structure)
|
||||||
new_items_batch = []
|
new_items_batch = []
|
||||||
|
|
||||||
|
# Optimization: Pre-calculate sets for fast lookup of needed items
|
||||||
|
target_paths_set = {t[0] for t in target_structure
|
||||||
|
if isinstance(t, tuple) and len(t) >= 5}
|
||||||
|
target_headers_set = {t[1][0] for t in target_structure
|
||||||
|
if isinstance(t, tuple)
|
||||||
|
and len(t) == 2 and t[0] == 'HEADER'}
|
||||||
|
|
||||||
while target_idx < total_targets:
|
while target_idx < total_targets:
|
||||||
target = target_structure[target_idx]
|
target = target_structure[target_idx]
|
||||||
current_item = self.thumbnail_model.item(model_idx)
|
current_item = self.thumbnail_model.item(model_idx)
|
||||||
|
|
||||||
if self._match_item(target, current_item):
|
if self._match_item(target, current_item):
|
||||||
|
# Si es una cabecera, actualizamos el texto por si cambió el
|
||||||
|
# contador
|
||||||
|
if isinstance(target, tuple) and target[0] == 'HEADER':
|
||||||
|
_, (_, header_text, _) = target
|
||||||
|
if current_item.data(DIR_ROLE) != header_text:
|
||||||
|
current_item.setData(header_text, DIR_ROLE)
|
||||||
|
|
||||||
model_idx += 1
|
model_idx += 1
|
||||||
target_idx += 1
|
target_idx += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 1. Identify and remove stale items (items in model but not in
|
||||||
|
# target structure)
|
||||||
|
if current_item:
|
||||||
|
is_needed = False
|
||||||
|
if current_item.data(ITEM_TYPE_ROLE) == 'thumbnail':
|
||||||
|
is_needed = current_item.data(PATH_ROLE) in target_paths_set
|
||||||
|
else: # header
|
||||||
|
is_needed = current_item.data(GROUP_NAME_ROLE) \
|
||||||
|
in target_headers_set
|
||||||
|
|
||||||
|
if not is_needed:
|
||||||
|
path = current_item.data(PATH_ROLE)
|
||||||
|
if path and path in self._path_to_model_index:
|
||||||
|
del self._path_to_model_index[path]
|
||||||
|
self.thumbnail_model.removeRow(model_idx)
|
||||||
|
# Stay at same model_idx, check next model item against
|
||||||
|
# same target
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 2. Try to MOVE target from later in the model (reordering
|
||||||
|
# optimization)
|
||||||
|
found_model_row = -1
|
||||||
|
if isinstance(target, tuple) and len(target) >= 5: # Thumbnail
|
||||||
|
path = target[0]
|
||||||
|
p_idx = self._path_to_model_index.get(path)
|
||||||
|
if p_idx and p_idx.isValid() and p_idx.row() > model_idx:
|
||||||
|
found_model_row = p_idx.row()
|
||||||
|
elif isinstance(target, tuple) and target[0] == 'HEADER':
|
||||||
|
target_group_name = target[1][0]
|
||||||
|
for r in range(model_idx + 1, self.thumbnail_model.rowCount()):
|
||||||
|
it = self.thumbnail_model.item(r)
|
||||||
|
if it and it.data(ITEM_TYPE_ROLE) == 'header' and \
|
||||||
|
it.data(GROUP_NAME_ROLE) == target_group_name:
|
||||||
|
found_model_row = r
|
||||||
|
break
|
||||||
|
|
||||||
|
if found_model_row != -1:
|
||||||
|
# Move existing row to current position.
|
||||||
|
# Persistent indices and selection model will update
|
||||||
|
# automatically.
|
||||||
|
self.thumbnail_model.moveRow(QModelIndex(), found_model_row,
|
||||||
|
QModelIndex(), model_idx)
|
||||||
|
if isinstance(target, tuple) and target[0] == 'HEADER':
|
||||||
|
_, (_, header_text, _) = target
|
||||||
|
moved_item = self.thumbnail_model.item(model_idx)
|
||||||
|
if moved_item and moved_item.data(DIR_ROLE) != header_text:
|
||||||
|
moved_item.setData(header_text, DIR_ROLE)
|
||||||
|
|
||||||
|
model_idx += 1
|
||||||
|
target_idx += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 3. Target is truly NEW - use batch insertion
|
||||||
else:
|
else:
|
||||||
# Prepare new item
|
# Prepare new item
|
||||||
if isinstance(target, tuple) and len(target) == 2 \
|
if isinstance(target, tuple) and len(target) == 2 \
|
||||||
@@ -2857,12 +3027,21 @@ class MainWindow(QMainWindow):
|
|||||||
# recalculations
|
# recalculations
|
||||||
while target_idx < total_targets:
|
while target_idx < total_targets:
|
||||||
next_target = target_structure[target_idx]
|
next_target = target_structure[target_idx]
|
||||||
|
|
||||||
# Check if next_target matches current model position
|
# Check if next_target matches current model position
|
||||||
# (re-sync)
|
# (re-sync)
|
||||||
if self._match_item(
|
if self._match_item(
|
||||||
next_target, self.thumbnail_model.item(model_idx)):
|
next_target, self.thumbnail_model.item(model_idx)):
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# Check if next_target is a MOVE (exists elsewhere)
|
||||||
|
if isinstance(next_target, tuple) and len(next_target) >= 5:
|
||||||
|
n_p_idx = self._path_to_model_index.get(next_target[0])
|
||||||
|
if n_p_idx and n_p_idx.isValid() \
|
||||||
|
and n_p_idx.row() > model_idx:
|
||||||
|
break
|
||||||
|
# (simplified lookahead: headers always break batch)
|
||||||
|
|
||||||
# If not matching, it's another new item to insert
|
# If not matching, it's another new item to insert
|
||||||
if isinstance(next_target, tuple) \
|
if isinstance(next_target, tuple) \
|
||||||
and len(next_target) == 2 and next_target[0] == 'HEADER':
|
and len(next_target) == 2 and next_target[0] == 'HEADER':
|
||||||
@@ -3473,22 +3652,23 @@ class MainWindow(QMainWindow):
|
|||||||
res = load_common_metadata(path)
|
res = load_common_metadata(path)
|
||||||
tags, rating = res.tags, res.rating
|
tags, rating = res.tags, res.rating
|
||||||
|
|
||||||
# Find the item in the source model and update its data
|
# Use cache for O(1) lookup in the source model
|
||||||
for row in range(self.thumbnail_model.rowCount()):
|
path = os.path.abspath(os.path.expanduser(path))
|
||||||
item = self.thumbnail_model.item(row)
|
if path in self._path_to_model_index:
|
||||||
if item and item.data(PATH_ROLE) == path:
|
p_idx = self._path_to_model_index[path]
|
||||||
|
if p_idx.isValid():
|
||||||
|
item = self.thumbnail_model.itemFromIndex(QModelIndex(p_idx))
|
||||||
item.setData(tags, TAGS_ROLE)
|
item.setData(tags, TAGS_ROLE)
|
||||||
item.setData(rating, RATING_ROLE)
|
item.setData(rating, RATING_ROLE)
|
||||||
|
|
||||||
tooltip_text = f"{os.path.basename(path)}\n{path}"
|
tooltip_text = f"{os.path.basename(path)}\n{path}"
|
||||||
if tags:
|
if tags:
|
||||||
display_tags = [t.split('/')[-1] for t in tags]
|
display_tags = [t.split('/')[-1] for t in tags]
|
||||||
tooltip_text += f"\n{UITexts.TAGS_TAB}: {', '.join(
|
tooltip_text += f"\n{UITexts.TAGS_TAB}: {', '.join(display_tags)}"
|
||||||
display_tags)}"
|
|
||||||
item.setToolTip(tooltip_text)
|
item.setToolTip(tooltip_text)
|
||||||
|
|
||||||
# Notify the view that the data has changed
|
# Notify the view that the data has changed
|
||||||
source_idx = self.thumbnail_model.indexFromItem(item)
|
source_idx = QModelIndex(p_idx)
|
||||||
self.thumbnail_model.dataChanged.emit(
|
self.thumbnail_model.dataChanged.emit(
|
||||||
source_idx, source_idx, [TAGS_ROLE, RATING_ROLE])
|
source_idx, source_idx, [TAGS_ROLE, RATING_ROLE])
|
||||||
|
|
||||||
@@ -3497,7 +3677,6 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
# Update proxy filter cache to prevent stale filtering
|
# Update proxy filter cache to prevent stale filtering
|
||||||
self.proxy_model.add_to_cache(path, tags)
|
self.proxy_model.add_to_cache(path, tags)
|
||||||
break
|
|
||||||
|
|
||||||
if self.main_dock.isVisible():
|
if self.main_dock.isVisible():
|
||||||
self.on_tags_tab_changed(self.tags_tabs.currentIndex())
|
self.on_tags_tab_changed(self.tags_tabs.currentIndex())
|
||||||
@@ -3528,14 +3707,14 @@ class MainWindow(QMainWindow):
|
|||||||
self.thumbnail_view.setGridSize(QSize())
|
self.thumbnail_view.setGridSize(QSize())
|
||||||
else:
|
else:
|
||||||
self.thumbnail_view.setGridSize(self.delegate.sizeHint(None, None))
|
self.thumbnail_view.setGridSize(self.delegate.sizeHint(None, None))
|
||||||
self.rebuild_view(full_reset=True)
|
self.rebuild_view()
|
||||||
|
|
||||||
self.save_config()
|
self.save_config()
|
||||||
self.setFocus()
|
self.setFocus()
|
||||||
|
|
||||||
def on_sort_changed(self):
|
def on_sort_changed(self):
|
||||||
"""Callback for when the sort order dropdown changes."""
|
"""Callback for when the sort order dropdown changes."""
|
||||||
self.rebuild_view(full_reset=True)
|
self.rebuild_view()
|
||||||
self.save_config()
|
self.save_config()
|
||||||
if hasattr(self, 'history_tab'):
|
if hasattr(self, 'history_tab'):
|
||||||
self.history_tab.refresh_list()
|
self.history_tab.refresh_list()
|
||||||
@@ -4274,8 +4453,169 @@ class MainWindow(QMainWindow):
|
|||||||
self.cache.clear_cache()
|
self.cache.clear_cache()
|
||||||
self.status_lbl.setText(UITexts.CACHE_CLEARED)
|
self.status_lbl.setText(UITexts.CACHE_CLEARED)
|
||||||
|
|
||||||
|
def on_fs_file_created(self, path):
|
||||||
|
"""Handles a new file being created in a monitored directory."""
|
||||||
|
# Add to batch queue and (re)start the debounce timer
|
||||||
|
self._fs_created_queue.add(path)
|
||||||
|
self._fs_created_timer.start()
|
||||||
|
|
||||||
|
def _process_fs_created_batch(self):
|
||||||
|
"""Processes all accumulated file creation events at once."""
|
||||||
|
paths = list(self._fs_created_queue)
|
||||||
|
self._fs_created_queue.clear()
|
||||||
|
|
||||||
|
valid_new_items = []
|
||||||
|
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:
|
||||||
|
valid_new_items.append(p)
|
||||||
|
|
||||||
|
if not valid_new_items:
|
||||||
|
return
|
||||||
|
|
||||||
|
# If the batch is very large, a full scan is more efficient than individual
|
||||||
|
# stats
|
||||||
|
if len(valid_new_items) > 50:
|
||||||
|
self.refresh_content()
|
||||||
|
return
|
||||||
|
|
||||||
|
# For smaller batches, process metadata and update model
|
||||||
|
for path in valid_new_items:
|
||||||
|
try:
|
||||||
|
res = load_common_metadata(path)
|
||||||
|
stat_res = os.stat(path)
|
||||||
|
mtime = stat_res.st_mtime
|
||||||
|
inode = stat_res.st_ino
|
||||||
|
dev = stat_res.st_dev
|
||||||
|
|
||||||
|
# tuple: (path, qi, mtime, tags, rating, inode, dev)
|
||||||
|
new_item_data = (path, None, mtime, res.tags, res.rating, inode, dev)
|
||||||
|
self.found_items_data.append(new_item_data)
|
||||||
|
self._known_paths.add(path)
|
||||||
|
self.proxy_model.add_to_cache(path, res.tags)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Trigger an incremental rebuild of the view
|
||||||
|
self.rebuild_view()
|
||||||
|
|
||||||
|
# Start background generation for the new items
|
||||||
|
self.generate_missing_thumbnails(self._current_thumb_tier)
|
||||||
|
|
||||||
|
if len(valid_new_items) == 1:
|
||||||
|
msg = f"New file detected: {os.path.basename(valid_new_items[0])}"
|
||||||
|
else:
|
||||||
|
msg = f"Detected {len(valid_new_items)} new files"
|
||||||
|
self.status_lbl.setText(msg)
|
||||||
|
|
||||||
|
def on_fs_file_deleted(self, path):
|
||||||
|
"""Handles a file being deleted from a monitored directory."""
|
||||||
|
path = os.path.abspath(path)
|
||||||
|
if path not in self._known_paths:
|
||||||
|
return # Not a file we're tracking
|
||||||
|
|
||||||
|
# Remove from internal data structures
|
||||||
|
self.found_items_data = [item for item in self.found_items_data
|
||||||
|
if item[0] != path]
|
||||||
|
self._known_paths.discard(path)
|
||||||
|
self.proxy_model._data_cache.pop(path, None)
|
||||||
|
self.cache.invalidate_path(path) # Clear from cache
|
||||||
|
|
||||||
|
# Update any open viewers
|
||||||
|
for w in QApplication.topLevelWidgets():
|
||||||
|
if isinstance(w, ImageViewer):
|
||||||
|
if path in w.controller.image_list:
|
||||||
|
try:
|
||||||
|
deleted_idx = w.controller.image_list.index(path)
|
||||||
|
new_list = list(w.controller.image_list)
|
||||||
|
new_list.remove(path)
|
||||||
|
w.refresh_after_delete(new_list, deleted_idx)
|
||||||
|
except (ValueError, RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Invalidate caches that might hold references to the deleted path
|
||||||
|
self._visible_paths_cache = None
|
||||||
|
keys_to_remove = [k for k in self._group_info_cache if k[0] == path]
|
||||||
|
for k in keys_to_remove:
|
||||||
|
del self._group_info_cache[k]
|
||||||
|
self.rebuild_view()
|
||||||
|
self.status_lbl.setText(f"File deleted: {os.path.basename(path)}")
|
||||||
|
|
||||||
|
def on_fs_file_moved(self, old_path, new_path):
|
||||||
|
"""Handles a file being renamed or moved."""
|
||||||
|
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
|
||||||
|
|
||||||
|
if is_old_img and is_new_img:
|
||||||
|
if old_path in self._known_paths:
|
||||||
|
self.propagate_rename(old_path, new_path)
|
||||||
|
else:
|
||||||
|
self.on_fs_file_created(new_path)
|
||||||
|
elif is_old_img:
|
||||||
|
# Moved out or renamed to non-image
|
||||||
|
self.on_fs_file_deleted(old_path)
|
||||||
|
elif is_new_img:
|
||||||
|
# Moved in from outside
|
||||||
|
self.on_fs_file_created(new_path)
|
||||||
|
|
||||||
|
def on_fs_file_modified(self, path):
|
||||||
|
"""Handles a file being modified in a monitored directory."""
|
||||||
|
path = os.path.abspath(path)
|
||||||
|
if path not in self._known_paths:
|
||||||
|
return # Not a file we're tracking
|
||||||
|
|
||||||
|
# Invalidate cache and trigger a refresh of its metadata and thumbnail
|
||||||
|
self.cache.invalidate_path(path)
|
||||||
|
|
||||||
|
# Re-read metadata and thumbnail
|
||||||
|
res = load_common_metadata(path)
|
||||||
|
mtime = os.path.getmtime(path)
|
||||||
|
stat_res = os.stat(path)
|
||||||
|
inode = stat_res.st_ino
|
||||||
|
dev = stat_res.st_dev
|
||||||
|
|
||||||
|
# Update internal data and model
|
||||||
|
self._update_internal_data(path, mtime=mtime, tags=res.tags, rating=res.rating,
|
||||||
|
inode=inode, dev=dev)
|
||||||
|
self.proxy_model.add_to_cache(path, res.tags)
|
||||||
|
self.rebuild_view()
|
||||||
|
self.status_lbl.setText(f"File modified: {os.path.basename(path)}")
|
||||||
|
|
||||||
|
def on_fs_watcher_status_changed(self, is_monitoring):
|
||||||
|
"""Updates the UI indicator for the FileSystemWatcher."""
|
||||||
|
if is_monitoring:
|
||||||
|
self.fs_watcher_status_lbl.setPixmap(
|
||||||
|
QIcon.fromTheme("folder-open").pixmap(16, 16))
|
||||||
|
self.fs_watcher_status_lbl.show()
|
||||||
|
else:
|
||||||
|
self.fs_watcher_status_lbl.hide()
|
||||||
|
|
||||||
|
def on_fs_directory_modified(self, path):
|
||||||
|
"""Handles a directory being modified (e.g., new subfolder, mass changes)."""
|
||||||
|
path = os.path.abspath(path)
|
||||||
|
|
||||||
|
# Trigger a debounced full refresh. This is useful for syncing large
|
||||||
|
# external changes (bulk operations, directory deletions) that are
|
||||||
|
# more robustly handled by a full scan than incremental updates.
|
||||||
|
if not self._is_loading and not self.is_cleaning:
|
||||||
|
self._fs_dir_refresh_timer.start()
|
||||||
|
|
||||||
|
def on_fs_directory_moved(self, old_path, new_path):
|
||||||
|
"""Handles a directory being renamed or moved."""
|
||||||
|
# For directory moves, a full refresh is the most reliable way to sync
|
||||||
|
# since all child paths have changed and individual file move signals
|
||||||
|
# might not be emitted for every item inside.
|
||||||
|
self.on_fs_directory_modified(new_path)
|
||||||
|
|
||||||
def propagate_rename(self, old_path, new_path, source_viewer=None):
|
def propagate_rename(self, old_path, new_path, source_viewer=None):
|
||||||
"""Propagates a file rename across the application."""
|
"""Propagates a file rename across the application."""
|
||||||
|
old_path = os.path.abspath(old_path)
|
||||||
|
new_path = os.path.abspath(new_path)
|
||||||
|
|
||||||
self._visible_paths_cache = None
|
self._visible_paths_cache = None
|
||||||
# Update found_items_data to ensure consistency on future rebuilds
|
# Update found_items_data to ensure consistency on future rebuilds
|
||||||
current_tags = None
|
current_tags = None
|
||||||
@@ -4302,15 +4642,16 @@ class MainWindow(QMainWindow):
|
|||||||
os.path.basename(new_path).lower())
|
os.path.basename(new_path).lower())
|
||||||
|
|
||||||
# Update the main model
|
# Update the main model
|
||||||
for row in range(self.thumbnail_model.rowCount()):
|
if old_path in self._path_to_model_index:
|
||||||
item = self.thumbnail_model.item(row)
|
p_idx = self._path_to_model_index.pop(old_path)
|
||||||
if item and item.data(PATH_ROLE) == old_path:
|
if p_idx.isValid():
|
||||||
|
item = self.thumbnail_model.itemFromIndex(QModelIndex(p_idx))
|
||||||
|
if item:
|
||||||
item.setData(new_path, PATH_ROLE)
|
item.setData(new_path, PATH_ROLE)
|
||||||
item.setText(os.path.basename(new_path))
|
item.setText(os.path.basename(new_path))
|
||||||
# No need to update the icon, it's the same image data
|
self._path_to_model_index[new_path] = p_idx
|
||||||
source_index = self.thumbnail_model.indexFromItem(item)
|
source_index = QModelIndex(p_idx)
|
||||||
self.thumbnail_model.dataChanged.emit(source_index, source_index)
|
self.thumbnail_model.dataChanged.emit(source_index, source_index)
|
||||||
break
|
|
||||||
|
|
||||||
# Update the cache entry
|
# Update the cache entry
|
||||||
self.cache.rename_entry(old_path, new_path)
|
self.cache.rename_entry(old_path, new_path)
|
||||||
|
|||||||
21
constants.py
21
constants.py
@@ -33,8 +33,20 @@ PROG_VERSION = "0.9.15-dev"
|
|||||||
PROG_AUTHOR = "Ignacio Serantes"
|
PROG_AUTHOR = "Ignacio Serantes"
|
||||||
|
|
||||||
# --- CACHE SETTINGS ---
|
# --- CACHE SETTINGS ---
|
||||||
# Maximum number of thumbnails to keep in the in-memory cache.
|
# Maximum number of paths to track in the in-memory cache.
|
||||||
CACHE_MAX_SIZE = 20000
|
CACHE_MAX_SIZE = 10000
|
||||||
|
|
||||||
|
# Dynamic RAM limit for thumbnails to avoid swapping on low-end systems.
|
||||||
|
try:
|
||||||
|
import psutil
|
||||||
|
_total_ram_bytes = psutil.virtual_memory().total
|
||||||
|
# Use 10% of system RAM, clamped between 128MB and 512MB
|
||||||
|
CACHE_MAX_RAM_BYTES = int(max(128 * 1024 * 1024,
|
||||||
|
min(512 * 1024 * 1024, _total_ram_bytes * 0.10)))
|
||||||
|
except (ImportError, Exception):
|
||||||
|
# Fallback to a safe 256MB if psutil is missing or fails
|
||||||
|
CACHE_MAX_RAM_BYTES = 256 * 1024 * 1024
|
||||||
|
|
||||||
# Maximum size of the persistent disk cache file.
|
# Maximum size of the persistent disk cache file.
|
||||||
# 10 GB limit for persistent cache file
|
# 10 GB limit for persistent cache file
|
||||||
DISK_CACHE_MAX_BYTES = 10 * 1024 * 1024 * 1024
|
DISK_CACHE_MAX_BYTES = 10 * 1024 * 1024 * 1024
|
||||||
@@ -814,6 +826,7 @@ _UI_TEXTS = {
|
|||||||
"ERROR_MOVE_FILE": "Could not move file: {}",
|
"ERROR_MOVE_FILE": "Could not move file: {}",
|
||||||
"ERROR_COPY_FILE": "Could not copy file: {}",
|
"ERROR_COPY_FILE": "Could not copy file: {}",
|
||||||
"MOVED_TO": "Moved to {}",
|
"MOVED_TO": "Moved to {}",
|
||||||
|
"FS_WATCHER_TOOLTIP": "File System Watcher (monitoring active directories)",
|
||||||
"COPIED_TO": "Copied to {}",
|
"COPIED_TO": "Copied to {}",
|
||||||
"ERROR_ROTATE_IMAGE": "Could not rotate image: {}",
|
"ERROR_ROTATE_IMAGE": "Could not rotate image: {}",
|
||||||
},
|
},
|
||||||
@@ -1285,6 +1298,8 @@ _UI_TEXTS = {
|
|||||||
"ERROR_MOVE_FILE": "No se pudo mover el archivo: {}",
|
"ERROR_MOVE_FILE": "No se pudo mover el archivo: {}",
|
||||||
"ERROR_COPY_FILE": "No se pudo copiar el archivo: {}",
|
"ERROR_COPY_FILE": "No se pudo copiar el archivo: {}",
|
||||||
"MOVED_TO": "Movido a {}",
|
"MOVED_TO": "Movido a {}",
|
||||||
|
"FS_WATCHER_TOOLTIP": "Monitor de Sistema de Archivos (monitoreando "
|
||||||
|
"directorios activos)",
|
||||||
"COPIED_TO": "Copiado a {}",
|
"COPIED_TO": "Copiado a {}",
|
||||||
"ERROR_ROTATE_IMAGE": "No se pudo girar la imagen: {}",
|
"ERROR_ROTATE_IMAGE": "No se pudo girar la imagen: {}",
|
||||||
},
|
},
|
||||||
@@ -1757,6 +1772,8 @@ _UI_TEXTS = {
|
|||||||
"ERROR_MOVE_FILE": "Non se puido mover o ficheiro: {}",
|
"ERROR_MOVE_FILE": "Non se puido mover o ficheiro: {}",
|
||||||
"ERROR_COPY_FILE": "Non se puido copiar o ficheiro: {}",
|
"ERROR_COPY_FILE": "Non se puido copiar o ficheiro: {}",
|
||||||
"MOVED_TO": "Movido a {}",
|
"MOVED_TO": "Movido a {}",
|
||||||
|
"FS_WATCHER_TOOLTIP": "Monitor do Sistema de Ficheiros (monitoreando "
|
||||||
|
"directorios activos)",
|
||||||
"COPIED_TO": "Copiado a {}",
|
"COPIED_TO": "Copiado a {}",
|
||||||
"ERROR_ROTATE_IMAGE": "Non se puido xirar a imaxe: {}",
|
"ERROR_ROTATE_IMAGE": "Non se puido xirar a imaxe: {}",
|
||||||
}
|
}
|
||||||
|
|||||||
203
filesystemwatcher.py
Normal file
203
filesystemwatcher.py
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import os
|
||||||
|
try:
|
||||||
|
from watchdog.observers import Observer
|
||||||
|
from watchdog.events import FileSystemEventHandler
|
||||||
|
HAVE_WATCHDOG = True
|
||||||
|
except ImportError:
|
||||||
|
HAVE_WATCHDOG = False
|
||||||
|
from PySide6.QtCore import QObject, Signal, QTimer
|
||||||
|
from constants import IMAGE_EXTENSIONS
|
||||||
|
|
||||||
|
|
||||||
|
class FileSystemWatcher(QObject):
|
||||||
|
"""
|
||||||
|
Monitors file system events (created, deleted, modified) for specified directories.
|
||||||
|
Emits signals to notify the main application thread of changes.
|
||||||
|
"""
|
||||||
|
file_created = Signal(str)
|
||||||
|
file_deleted = Signal(str)
|
||||||
|
file_modified = Signal(str)
|
||||||
|
_file_modified_from_handler = Signal(str) # Internal signal from handler thread
|
||||||
|
file_moved = Signal(str, str)
|
||||||
|
monitoring_status_changed = Signal(bool) # Nuevo: Señal para el estado de monitoreo
|
||||||
|
directory_moved = Signal(str, str)
|
||||||
|
directory_modified = Signal(str) # For changes that might not be specific files
|
||||||
|
|
||||||
|
_modified_events_queue = {} # {path: QTimer}
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._watched_directories = set()
|
||||||
|
|
||||||
|
if HAVE_WATCHDOG:
|
||||||
|
self._observer = Observer()
|
||||||
|
self._event_handler = self._Handler(self)
|
||||||
|
self._observer.start()
|
||||||
|
else:
|
||||||
|
self._observer = None # Keep observer as None if watchdog is not available
|
||||||
|
|
||||||
|
# Debounce timer for modified events to avoid multiple signals for a single save
|
||||||
|
self._debounce_interval = 500 # milliseconds
|
||||||
|
|
||||||
|
# Connect the internal signal to the debouncing slot
|
||||||
|
if HAVE_WATCHDOG:
|
||||||
|
self._file_modified_from_handler.connect(self._on_file_modified_debounced)
|
||||||
|
|
||||||
|
def _on_file_modified_debounced(self, path):
|
||||||
|
"""Slot to handle modified events from the watchdog thread, debounced in the
|
||||||
|
main thread."""
|
||||||
|
# Debounce timer for modified events to avoid multiple signals for a single save
|
||||||
|
if path in self._modified_events_queue:
|
||||||
|
self._modified_events_queue[path].stop()
|
||||||
|
else:
|
||||||
|
# Ensure timer lives in the main thread (parent is self)
|
||||||
|
timer = QTimer(self)
|
||||||
|
timer.setSingleShot(True)
|
||||||
|
timer.setInterval(self._debounce_interval)
|
||||||
|
timer.timeout.connect(lambda p=path: self._emit_modified_after_debounce(p))
|
||||||
|
self._modified_events_queue[path] = timer
|
||||||
|
self._modified_events_queue[path].start()
|
||||||
|
|
||||||
|
def _emit_modified_after_debounce(self, path):
|
||||||
|
"""Emits the file_modified signal after the debounce period."""
|
||||||
|
self.file_modified.emit(path)
|
||||||
|
if path in self._modified_events_queue:
|
||||||
|
# Safely delete the QTimer object when done
|
||||||
|
self._modified_events_queue[path].deleteLater()
|
||||||
|
del self._modified_events_queue[path]
|
||||||
|
|
||||||
|
def add_path(self, path):
|
||||||
|
"""Adds a directory to be monitored."""
|
||||||
|
if not HAVE_WATCHDOG or self._observer is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Normalize and expand path to ensure consistent comparison
|
||||||
|
abs_path = os.path.normpath(os.path.abspath(os.path.expanduser(path)))
|
||||||
|
|
||||||
|
# 1. Check if path is already covered by an existing watch (exact or parent)
|
||||||
|
for watched in self._watched_directories:
|
||||||
|
if abs_path == watched:
|
||||||
|
return
|
||||||
|
parent_prefix = watched if watched.endswith(os.sep) else watched + os.sep
|
||||||
|
if abs_path.startswith(parent_prefix):
|
||||||
|
return # Path is a subdirectory of an already watched directory
|
||||||
|
|
||||||
|
old_monitoring_state = bool(self._watched_directories)
|
||||||
|
|
||||||
|
# 2. Check if this new path covers existing watches (is a parent of them)
|
||||||
|
# If so, consolidate them into this single parent watch
|
||||||
|
child_prefix = abs_path if abs_path.endswith(os.sep) else abs_path + os.sep
|
||||||
|
covered_children = [w for w in self._watched_directories
|
||||||
|
if w.startswith(child_prefix)]
|
||||||
|
|
||||||
|
try:
|
||||||
|
if covered_children:
|
||||||
|
self._observer.unschedule_all()
|
||||||
|
for child in covered_children:
|
||||||
|
self._watched_directories.remove(child)
|
||||||
|
self._watched_directories.add(abs_path)
|
||||||
|
for p in self._watched_directories:
|
||||||
|
self._observer.schedule(self._event_handler, p, recursive=True)
|
||||||
|
print(f"Consolidated monitoring at parent: {abs_path}")
|
||||||
|
else:
|
||||||
|
self._observer.schedule(self._event_handler, abs_path, recursive=True)
|
||||||
|
self._watched_directories.add(abs_path)
|
||||||
|
print(f"Monitoring: {abs_path}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error scheduling watchdog for {abs_path}: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not old_monitoring_state and self._watched_directories:
|
||||||
|
self.monitoring_status_changed.emit(True)
|
||||||
|
|
||||||
|
def remove_path(self, path):
|
||||||
|
"""Removes a directory from monitoring."""
|
||||||
|
if not HAVE_WATCHDOG or self._observer is None:
|
||||||
|
return
|
||||||
|
abs_path = os.path.normpath(os.path.abspath(os.path.expanduser(path)))
|
||||||
|
if abs_path in self._watched_directories:
|
||||||
|
old_monitoring_state = bool(self._watched_directories)
|
||||||
|
self._observer.unschedule_all() # Simpler to unschedule all and re-add
|
||||||
|
self._watched_directories.remove(abs_path)
|
||||||
|
for p in list(self._watched_directories): # Iterate over a copy
|
||||||
|
self._observer.schedule(self._event_handler, p, recursive=True)
|
||||||
|
print(f"Stopped monitoring: {abs_path}")
|
||||||
|
if HAVE_WATCHDOG and old_monitoring_state and not self._watched_directories:
|
||||||
|
self.monitoring_status_changed.emit(False)
|
||||||
|
|
||||||
|
def clear_paths(self):
|
||||||
|
"""Clears all monitored paths."""
|
||||||
|
if not HAVE_WATCHDOG or not self._observer:
|
||||||
|
return
|
||||||
|
|
||||||
|
old_monitoring_state = bool(self._watched_directories)
|
||||||
|
self._observer.unschedule_all()
|
||||||
|
self._watched_directories.clear()
|
||||||
|
print("Cleared all monitored paths.")
|
||||||
|
if old_monitoring_state:
|
||||||
|
self.monitoring_status_changed.emit(False)
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""Stops the file system observer."""
|
||||||
|
if HAVE_WATCHDOG and self._observer:
|
||||||
|
self._observer.stop()
|
||||||
|
self._observer.join()
|
||||||
|
|
||||||
|
for timer in self._modified_events_queue.values():
|
||||||
|
timer.stop()
|
||||||
|
|
||||||
|
if HAVE_WATCHDOG:
|
||||||
|
print("FileSystemWatcher stopped.")
|
||||||
|
|
||||||
|
if HAVE_WATCHDOG:
|
||||||
|
class _Handler(FileSystemEventHandler):
|
||||||
|
# Signal to communicate to main thread
|
||||||
|
file_modified_from_thread = Signal(str)
|
||||||
|
"""Custom event handler for watchdog events."""
|
||||||
|
def __init__(self, watcher):
|
||||||
|
super().__init__()
|
||||||
|
self.watcher = watcher
|
||||||
|
|
||||||
|
def on_created(self, event):
|
||||||
|
if event.is_directory:
|
||||||
|
self.watcher.directory_modified.emit(event.src_path)
|
||||||
|
return
|
||||||
|
if self._is_image_file(event.src_path):
|
||||||
|
self.watcher.file_created.emit(event.src_path)
|
||||||
|
|
||||||
|
def on_deleted(self, event):
|
||||||
|
if event.is_directory:
|
||||||
|
self.watcher.directory_modified.emit(event.src_path)
|
||||||
|
return
|
||||||
|
if self._is_image_file(event.src_path):
|
||||||
|
self.watcher.file_deleted.emit(event.src_path)
|
||||||
|
|
||||||
|
def on_moved(self, event):
|
||||||
|
if event.is_directory:
|
||||||
|
self.watcher.directory_moved.emit(event.src_path, event.dest_path)
|
||||||
|
self.watcher.directory_modified.emit(event.src_path)
|
||||||
|
self.watcher.directory_modified.emit(event.dest_path)
|
||||||
|
return
|
||||||
|
self.watcher.file_moved.emit(event.src_path, event.dest_path)
|
||||||
|
|
||||||
|
def on_closed(self, event):
|
||||||
|
if event.is_directory:
|
||||||
|
self.watcher.directory_modified.emit(event.src_path)
|
||||||
|
return
|
||||||
|
if self._is_image_file(event.src_path):
|
||||||
|
self.watcher.file_modified.emit(event.src_path)
|
||||||
|
|
||||||
|
def on_modified(self, event):
|
||||||
|
if event.is_directory:
|
||||||
|
self.watcher.directory_modified.emit(event.src_path)
|
||||||
|
return
|
||||||
|
if self._is_image_file(event.src_path):
|
||||||
|
self.watcher._file_modified_from_handler.emit(event.src_path)
|
||||||
|
|
||||||
|
def _emit_modified(self, path):
|
||||||
|
self.watcher.file_modified.emit(path)
|
||||||
|
if path in self.watcher._modified_events_queue:
|
||||||
|
del self.watcher._modified_events_queue[path]
|
||||||
|
|
||||||
|
def _is_image_file(self, path):
|
||||||
|
return os.path.splitext(path)[1].lower() in IMAGE_EXTENSIONS
|
||||||
@@ -35,8 +35,8 @@ from PySide6.QtCore import (
|
|||||||
from PySide6.QtGui import QImage, QImageReader, QImageIOHandler
|
from PySide6.QtGui import QImage, QImageReader, QImageIOHandler
|
||||||
|
|
||||||
from constants import (
|
from constants import (
|
||||||
APP_CONFIG, CACHE_PATH, CACHE_MAX_SIZE, CONFIG_DIR, DISK_CACHE_MAX_BYTES,
|
APP_CONFIG, CACHE_PATH, CACHE_MAX_SIZE, CACHE_MAX_RAM_BYTES, CONFIG_DIR,
|
||||||
HAVE_BAGHEERASEARCH_LIB, IMAGE_EXTENSIONS,
|
DISK_CACHE_MAX_BYTES, HAVE_BAGHEERASEARCH_LIB, IMAGE_EXTENSIONS,
|
||||||
SCANNER_SETTINGS_DEFAULTS, SEARCH_CMD, THUMBNAIL_SIZES,
|
SCANNER_SETTINGS_DEFAULTS, SEARCH_CMD, THUMBNAIL_SIZES,
|
||||||
UITexts
|
UITexts
|
||||||
)
|
)
|
||||||
@@ -646,12 +646,10 @@ class ThumbnailCache(QObject):
|
|||||||
def _ensure_cache_limit(self):
|
def _ensure_cache_limit(self):
|
||||||
"""Enforces cache size limit by evicting oldest entries.
|
"""Enforces cache size limit by evicting oldest entries.
|
||||||
Must be called with a write lock held."""
|
Must be called with a write lock held."""
|
||||||
# Safety limit: 512MB for thumbnails in RAM to prevent system freeze
|
|
||||||
MAX_RAM_BYTES = 512 * 1024 * 1024
|
|
||||||
|
|
||||||
while len(self._thumbnail_cache) > 0 and (
|
while len(self._thumbnail_cache) > 0 and (
|
||||||
len(self._thumbnail_cache) >= CACHE_MAX_SIZE or
|
len(self._thumbnail_cache) >= CACHE_MAX_SIZE or
|
||||||
self._cache_bytes_size > MAX_RAM_BYTES):
|
self._cache_bytes_size > CACHE_MAX_RAM_BYTES):
|
||||||
oldest_path = next(iter(self._thumbnail_cache))
|
oldest_path = next(iter(self._thumbnail_cache))
|
||||||
cached_sizes = self._thumbnail_cache.pop(oldest_path)
|
cached_sizes = self._thumbnail_cache.pop(oldest_path)
|
||||||
for img, _ in cached_sizes.values():
|
for img, _ in cached_sizes.values():
|
||||||
@@ -1752,6 +1750,10 @@ class ImageScanner(QThread):
|
|||||||
batch.append(r.result)
|
batch.append(r.result)
|
||||||
self.count += 1
|
self.count += 1
|
||||||
images_loaded += 1
|
images_loaded += 1
|
||||||
|
# Emit progress every time an image is loaded
|
||||||
|
if len(self.all_files) > 0:
|
||||||
|
percent = int((self.count / len(self.all_files)) * 100)
|
||||||
|
self.progress_percent.emit(percent)
|
||||||
|
|
||||||
# Clean up runnables
|
# Clean up runnables
|
||||||
runnables.clear()
|
runnables.clear()
|
||||||
@@ -1796,12 +1798,11 @@ class ImageScanner(QThread):
|
|||||||
"scan_batch_size"]))
|
"scan_batch_size"]))
|
||||||
return
|
return
|
||||||
|
|
||||||
if self.count % 10 == 0: # Update progress less frequently
|
# Emit progress message less frequently, e.g., every 50 images or at batch
|
||||||
self.progress_msg.emit(
|
# end
|
||||||
UITexts.LOADING_SCAN.format(self.count, len(self.all_files)))
|
if self.count % 50 == 0 or images_loaded >= to_load:
|
||||||
if len(self.all_files) > 0:
|
self.progress_msg.emit(UITexts.LOADING_SCAN.format(
|
||||||
percent = int((self.count / len(self.all_files)) * 100)
|
self.count, len(self.all_files)))
|
||||||
self.progress_percent.emit(percent)
|
|
||||||
|
|
||||||
self.index = len(self.all_files)
|
self.index = len(self.all_files)
|
||||||
if batch:
|
if batch:
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ dependencies = [
|
|||||||
"PySide6",
|
"PySide6",
|
||||||
"lmdb",
|
"lmdb",
|
||||||
"exiv2",
|
"exiv2",
|
||||||
|
"psutil",
|
||||||
|
"watchdog",
|
||||||
"mediapipe",
|
"mediapipe",
|
||||||
"face_recognition",
|
"face_recognition",
|
||||||
"face_recognition_models",
|
"face_recognition_models",
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
PySide6
|
PySide6
|
||||||
lmdb
|
lmdb
|
||||||
exiv2
|
exiv2
|
||||||
|
psutil
|
||||||
|
watchdog
|
||||||
mediapipe
|
mediapipe
|
||||||
face_recognition
|
face_recognition
|
||||||
face_recognition_models
|
face_recognition_models
|
||||||
|
|||||||
3
setup.py
3
setup.py
@@ -14,6 +14,8 @@ setup(
|
|||||||
"PySide6",
|
"PySide6",
|
||||||
"lmdb",
|
"lmdb",
|
||||||
"exiv2",
|
"exiv2",
|
||||||
|
"psutil",
|
||||||
|
"watchdog",
|
||||||
"mediapipe",
|
"mediapipe",
|
||||||
"face_recognition",
|
"face_recognition",
|
||||||
"face_recognition_models",
|
"face_recognition_models",
|
||||||
@@ -33,6 +35,7 @@ setup(
|
|||||||
"imagescanner",
|
"imagescanner",
|
||||||
"imageviewer",
|
"imageviewer",
|
||||||
"imagecontroller",
|
"imagecontroller",
|
||||||
|
"filesystemwatcher",
|
||||||
"metadatamanager",
|
"metadatamanager",
|
||||||
"propertiesdialog",
|
"propertiesdialog",
|
||||||
"thumbnailwidget",
|
"thumbnailwidget",
|
||||||
|
|||||||
Reference in New Issue
Block a user