Added watchdog support
This commit is contained in:
499
bagheeraview.py
499
bagheeraview.py
@@ -78,6 +78,7 @@ from widgets import (
|
||||
FavoritesWidget
|
||||
)
|
||||
from metadatamanager import load_common_metadata
|
||||
from filesystemwatcher import FileSystemWatcher
|
||||
|
||||
|
||||
class ShortcutHelpDialog(QDialog):
|
||||
@@ -1067,6 +1068,11 @@ class MainWindow(QMainWindow):
|
||||
self.progress_bar.hide()
|
||||
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
|
||||
self.hide_progress_timer = QTimer(self)
|
||||
self.hide_progress_timer.setSingleShot(True)
|
||||
@@ -1236,12 +1242,6 @@ class MainWindow(QMainWindow):
|
||||
self.favorites_tab.favorites_changed.connect(
|
||||
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.addDockWidget(Qt.RightDockWidgetArea, self.main_dock)
|
||||
|
||||
@@ -1289,6 +1289,30 @@ class MainWindow(QMainWindow):
|
||||
self.thumbnail_view.verticalScrollBar().valueChanged.connect(
|
||||
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
|
||||
self.load_config()
|
||||
self.load_full_history()
|
||||
@@ -1607,15 +1631,17 @@ class MainWindow(QMainWindow):
|
||||
# 5. Start scanning all parent directories of the images in the layout
|
||||
unique_dirs = list({str(Path(p).parent) for p in paths})
|
||||
for d in unique_dirs:
|
||||
paths.append(d)
|
||||
if d not in paths:
|
||||
paths.append(d)
|
||||
|
||||
self.start_scan([p.strip() for p in paths if p.strip()
|
||||
and os.path.exists(os.path.expanduser(p.strip()))],
|
||||
select_paths=select_paths)
|
||||
|
||||
if search_text:
|
||||
self.search_input.setEditText(search_text)
|
||||
|
||||
# --- UI and Menu Logic ---
|
||||
|
||||
def show_main_menu(self):
|
||||
"""Displays the main application menu."""
|
||||
menu = QMenu(self)
|
||||
@@ -1817,6 +1843,7 @@ class MainWindow(QMainWindow):
|
||||
def perform_shutdown(self):
|
||||
"""Performs cleanup operations before the application closes."""
|
||||
self.is_cleaning = True
|
||||
self.fs_watcher.stop()
|
||||
# 1. Stop all worker threads interacting with the cache
|
||||
|
||||
# Signal all threads to stop first
|
||||
@@ -2059,7 +2086,13 @@ class MainWindow(QMainWindow):
|
||||
|
||||
def find_and_select_path(self, path_to_select):
|
||||
"""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
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
return True
|
||||
|
||||
@@ -2368,16 +2406,16 @@ class MainWindow(QMainWindow):
|
||||
inode=new_inode, device_id=new_dev)
|
||||
|
||||
# Update model item
|
||||
for row in range(self.thumbnail_model.rowCount()):
|
||||
item = self.thumbnail_model.item(row)
|
||||
if item and item.data(PATH_ROLE) == path:
|
||||
if path in self._path_to_model_index:
|
||||
p_idx = self._path_to_model_index[path]
|
||||
if p_idx.isValid():
|
||||
item = self.thumbnail_model.itemFromIndex(QModelIndex(p_idx))
|
||||
item.setIcon(QIcon(QPixmap.fromImage(thumb_img)))
|
||||
item.setData(new_mtime, MTIME_ROLE)
|
||||
item.setData(new_inode, INODE_ROLE)
|
||||
item.setData(new_dev, DEVICE_ROLE)
|
||||
self._update_internal_data(path, qi=thumb_img, mtime=new_mtime,
|
||||
inode=new_inode, dev=new_dev)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -2418,6 +2456,7 @@ class MainWindow(QMainWindow):
|
||||
self.proxy_model.clear_cache()
|
||||
self._model_update_queue.clear()
|
||||
self._model_update_timer.stop()
|
||||
self.fs_watcher.clear_paths()
|
||||
|
||||
# Stop any pending hide action from previous scan
|
||||
self.hide_progress_timer.stop()
|
||||
@@ -2437,6 +2476,12 @@ class MainWindow(QMainWindow):
|
||||
self.scanner.set_auto_load(True)
|
||||
self._is_loading = True
|
||||
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_msg.connect(self.status_lbl.setText)
|
||||
self.scanner.more_files_available.connect(self.more_files_available)
|
||||
@@ -2625,12 +2670,9 @@ class MainWindow(QMainWindow):
|
||||
# Check for Header match
|
||||
# target format: ('HEADER', (key, header_text, count))
|
||||
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]
|
||||
return (item.data(ITEM_TYPE_ROLE) == 'header' and
|
||||
item.data(GROUP_NAME_ROLE) == target_group_name and
|
||||
item.data(DIR_ROLE) == header_text)
|
||||
item.data(GROUP_NAME_ROLE) == target_group_name)
|
||||
|
||||
# Check for Thumbnail match
|
||||
# target format: (path, qi, mtime, tags, rating, inode, dev)
|
||||
@@ -2641,48 +2683,94 @@ class MainWindow(QMainWindow):
|
||||
return False
|
||||
|
||||
def _get_group_info(self, path, mtime, rating):
|
||||
"""Calculates the grouping key and display name for a file.
|
||||
|
||||
Args:
|
||||
path (str): File path.
|
||||
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
|
||||
|
||||
"""Calculates the grouping key and display name for a file with optimized
|
||||
caching."""
|
||||
# Determine resolution criteria for shared caching across all files in same
|
||||
# group
|
||||
if self.proxy_model.group_by_folder:
|
||||
stable_group_key = display_name = os.path.dirname(path)
|
||||
elif self.proxy_model.group_by_day:
|
||||
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")
|
||||
crit = os.path.dirname(path)
|
||||
mode = 'F'
|
||||
elif self.proxy_model.group_by_rating:
|
||||
r = rating if rating is not None else 0
|
||||
stars = (r + 1) // 2
|
||||
stable_group_key = str(stars)
|
||||
display_name = UITexts.GROUP_BY_RATING_FORMAT.format(stars=stars)
|
||||
crit = (rating + 1) // 2 if rating is not None else 0
|
||||
mode = 'R'
|
||||
else:
|
||||
# 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)
|
||||
return stable_group_key, display_name
|
||||
# Shared cache by criteria ensures expensive formatting happens only once per
|
||||
# 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):
|
||||
"""
|
||||
@@ -2779,13 +2867,26 @@ class MainWindow(QMainWindow):
|
||||
self.thumbnail_model.clear()
|
||||
self._path_to_model_index.clear()
|
||||
|
||||
# Optimize grouped insertion: Decorate-Sort-Group
|
||||
# 1. Decorate: Calculate group info once per item
|
||||
# 1. Decorate: Calculate group info once per item with local memoization
|
||||
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:
|
||||
# item structure: (path, qi, mtime, tags, rating, inode, dev)
|
||||
stable_key, display_name = self._get_group_info(
|
||||
item[0], item[2], item[4])
|
||||
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(
|
||||
path, mtime, rating)
|
||||
local_memo[m_key] = (stable_key, display_name)
|
||||
|
||||
# Use empty string for None keys to ensure sortability
|
||||
sort_key = stable_key if stable_key is not None else ""
|
||||
decorated_data.append((sort_key, display_name, item))
|
||||
@@ -2824,13 +2925,82 @@ class MainWindow(QMainWindow):
|
||||
total_targets = len(target_structure)
|
||||
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:
|
||||
target = target_structure[target_idx]
|
||||
current_item = self.thumbnail_model.item(model_idx)
|
||||
|
||||
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
|
||||
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:
|
||||
# Prepare new item
|
||||
if isinstance(target, tuple) and len(target) == 2 \
|
||||
@@ -2857,12 +3027,21 @@ class MainWindow(QMainWindow):
|
||||
# recalculations
|
||||
while target_idx < total_targets:
|
||||
next_target = target_structure[target_idx]
|
||||
|
||||
# Check if next_target matches current model position
|
||||
# (re-sync)
|
||||
if self._match_item(
|
||||
next_target, self.thumbnail_model.item(model_idx)):
|
||||
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 isinstance(next_target, tuple) \
|
||||
and len(next_target) == 2 and next_target[0] == 'HEADER':
|
||||
@@ -3473,22 +3652,23 @@ class MainWindow(QMainWindow):
|
||||
res = load_common_metadata(path)
|
||||
tags, rating = res.tags, res.rating
|
||||
|
||||
# Find the item in the source model and update its data
|
||||
for row in range(self.thumbnail_model.rowCount()):
|
||||
item = self.thumbnail_model.item(row)
|
||||
if item and item.data(PATH_ROLE) == path:
|
||||
# Use cache for O(1) lookup in the source model
|
||||
path = os.path.abspath(os.path.expanduser(path))
|
||||
if path in self._path_to_model_index:
|
||||
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(rating, RATING_ROLE)
|
||||
|
||||
tooltip_text = f"{os.path.basename(path)}\n{path}"
|
||||
if tags:
|
||||
display_tags = [t.split('/')[-1] for t in tags]
|
||||
tooltip_text += f"\n{UITexts.TAGS_TAB}: {', '.join(
|
||||
display_tags)}"
|
||||
tooltip_text += f"\n{UITexts.TAGS_TAB}: {', '.join(display_tags)}"
|
||||
item.setToolTip(tooltip_text)
|
||||
|
||||
# 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(
|
||||
source_idx, source_idx, [TAGS_ROLE, RATING_ROLE])
|
||||
|
||||
@@ -3497,7 +3677,6 @@ class MainWindow(QMainWindow):
|
||||
|
||||
# Update proxy filter cache to prevent stale filtering
|
||||
self.proxy_model.add_to_cache(path, tags)
|
||||
break
|
||||
|
||||
if self.main_dock.isVisible():
|
||||
self.on_tags_tab_changed(self.tags_tabs.currentIndex())
|
||||
@@ -3528,14 +3707,14 @@ class MainWindow(QMainWindow):
|
||||
self.thumbnail_view.setGridSize(QSize())
|
||||
else:
|
||||
self.thumbnail_view.setGridSize(self.delegate.sizeHint(None, None))
|
||||
self.rebuild_view(full_reset=True)
|
||||
self.rebuild_view()
|
||||
|
||||
self.save_config()
|
||||
self.setFocus()
|
||||
|
||||
def on_sort_changed(self):
|
||||
"""Callback for when the sort order dropdown changes."""
|
||||
self.rebuild_view(full_reset=True)
|
||||
self.rebuild_view()
|
||||
self.save_config()
|
||||
if hasattr(self, 'history_tab'):
|
||||
self.history_tab.refresh_list()
|
||||
@@ -4274,8 +4453,169 @@ class MainWindow(QMainWindow):
|
||||
self.cache.clear_cache()
|
||||
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):
|
||||
"""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
|
||||
# Update found_items_data to ensure consistency on future rebuilds
|
||||
current_tags = None
|
||||
@@ -4302,15 +4642,16 @@ class MainWindow(QMainWindow):
|
||||
os.path.basename(new_path).lower())
|
||||
|
||||
# Update the main model
|
||||
for row in range(self.thumbnail_model.rowCount()):
|
||||
item = self.thumbnail_model.item(row)
|
||||
if item and item.data(PATH_ROLE) == old_path:
|
||||
item.setData(new_path, PATH_ROLE)
|
||||
item.setText(os.path.basename(new_path))
|
||||
# No need to update the icon, it's the same image data
|
||||
source_index = self.thumbnail_model.indexFromItem(item)
|
||||
self.thumbnail_model.dataChanged.emit(source_index, source_index)
|
||||
break
|
||||
if old_path in self._path_to_model_index:
|
||||
p_idx = self._path_to_model_index.pop(old_path)
|
||||
if p_idx.isValid():
|
||||
item = self.thumbnail_model.itemFromIndex(QModelIndex(p_idx))
|
||||
if item:
|
||||
item.setData(new_path, PATH_ROLE)
|
||||
item.setText(os.path.basename(new_path))
|
||||
self._path_to_model_index[new_path] = p_idx
|
||||
source_index = QModelIndex(p_idx)
|
||||
self.thumbnail_model.dataChanged.emit(source_index, source_index)
|
||||
|
||||
# Update the cache entry
|
||||
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"
|
||||
|
||||
# --- CACHE SETTINGS ---
|
||||
# Maximum number of thumbnails to keep in the in-memory cache.
|
||||
CACHE_MAX_SIZE = 20000
|
||||
# Maximum number of paths to track in the in-memory cache.
|
||||
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.
|
||||
# 10 GB limit for persistent cache file
|
||||
DISK_CACHE_MAX_BYTES = 10 * 1024 * 1024 * 1024
|
||||
@@ -814,6 +826,7 @@ _UI_TEXTS = {
|
||||
"ERROR_MOVE_FILE": "Could not move file: {}",
|
||||
"ERROR_COPY_FILE": "Could not copy file: {}",
|
||||
"MOVED_TO": "Moved to {}",
|
||||
"FS_WATCHER_TOOLTIP": "File System Watcher (monitoring active directories)",
|
||||
"COPIED_TO": "Copied to {}",
|
||||
"ERROR_ROTATE_IMAGE": "Could not rotate image: {}",
|
||||
},
|
||||
@@ -1285,6 +1298,8 @@ _UI_TEXTS = {
|
||||
"ERROR_MOVE_FILE": "No se pudo mover el archivo: {}",
|
||||
"ERROR_COPY_FILE": "No se pudo copiar el archivo: {}",
|
||||
"MOVED_TO": "Movido a {}",
|
||||
"FS_WATCHER_TOOLTIP": "Monitor de Sistema de Archivos (monitoreando "
|
||||
"directorios activos)",
|
||||
"COPIED_TO": "Copiado a {}",
|
||||
"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_COPY_FILE": "Non se puido copiar o ficheiro: {}",
|
||||
"MOVED_TO": "Movido a {}",
|
||||
"FS_WATCHER_TOOLTIP": "Monitor do Sistema de Ficheiros (monitoreando "
|
||||
"directorios activos)",
|
||||
"COPIED_TO": "Copiado a {}",
|
||||
"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 constants import (
|
||||
APP_CONFIG, CACHE_PATH, CACHE_MAX_SIZE, CONFIG_DIR, DISK_CACHE_MAX_BYTES,
|
||||
HAVE_BAGHEERASEARCH_LIB, IMAGE_EXTENSIONS,
|
||||
APP_CONFIG, CACHE_PATH, CACHE_MAX_SIZE, CACHE_MAX_RAM_BYTES, CONFIG_DIR,
|
||||
DISK_CACHE_MAX_BYTES, HAVE_BAGHEERASEARCH_LIB, IMAGE_EXTENSIONS,
|
||||
SCANNER_SETTINGS_DEFAULTS, SEARCH_CMD, THUMBNAIL_SIZES,
|
||||
UITexts
|
||||
)
|
||||
@@ -646,12 +646,10 @@ class ThumbnailCache(QObject):
|
||||
def _ensure_cache_limit(self):
|
||||
"""Enforces cache size limit by evicting oldest entries.
|
||||
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 (
|
||||
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))
|
||||
cached_sizes = self._thumbnail_cache.pop(oldest_path)
|
||||
for img, _ in cached_sizes.values():
|
||||
@@ -1752,6 +1750,10 @@ class ImageScanner(QThread):
|
||||
batch.append(r.result)
|
||||
self.count += 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
|
||||
runnables.clear()
|
||||
@@ -1796,12 +1798,11 @@ class ImageScanner(QThread):
|
||||
"scan_batch_size"]))
|
||||
return
|
||||
|
||||
if self.count % 10 == 0: # Update progress less frequently
|
||||
self.progress_msg.emit(
|
||||
UITexts.LOADING_SCAN.format(self.count, len(self.all_files)))
|
||||
if len(self.all_files) > 0:
|
||||
percent = int((self.count / len(self.all_files)) * 100)
|
||||
self.progress_percent.emit(percent)
|
||||
# Emit progress message less frequently, e.g., every 50 images or at batch
|
||||
# end
|
||||
if self.count % 50 == 0 or images_loaded >= to_load:
|
||||
self.progress_msg.emit(UITexts.LOADING_SCAN.format(
|
||||
self.count, len(self.all_files)))
|
||||
|
||||
self.index = len(self.all_files)
|
||||
if batch:
|
||||
|
||||
@@ -23,6 +23,8 @@ dependencies = [
|
||||
"PySide6",
|
||||
"lmdb",
|
||||
"exiv2",
|
||||
"psutil",
|
||||
"watchdog",
|
||||
"mediapipe",
|
||||
"face_recognition",
|
||||
"face_recognition_models",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
PySide6
|
||||
lmdb
|
||||
exiv2
|
||||
psutil
|
||||
watchdog
|
||||
mediapipe
|
||||
face_recognition
|
||||
face_recognition_models
|
||||
|
||||
3
setup.py
3
setup.py
@@ -14,6 +14,8 @@ setup(
|
||||
"PySide6",
|
||||
"lmdb",
|
||||
"exiv2",
|
||||
"psutil",
|
||||
"watchdog",
|
||||
"mediapipe",
|
||||
"face_recognition",
|
||||
"face_recognition_models",
|
||||
@@ -33,6 +35,7 @@ setup(
|
||||
"imagescanner",
|
||||
"imageviewer",
|
||||
"imagecontroller",
|
||||
"filesystemwatcher",
|
||||
"metadatamanager",
|
||||
"propertiesdialog",
|
||||
"thumbnailwidget",
|
||||
|
||||
Reference in New Issue
Block a user