A bunch of changes
This commit is contained in:
@@ -68,7 +68,8 @@ from constants import (
|
|||||||
)
|
)
|
||||||
import constants
|
import constants
|
||||||
from settings import SettingsDialog
|
from settings import SettingsDialog
|
||||||
from imagescanner import CacheCleaner, ImageScanner, ThumbnailCache, ThumbnailGenerator
|
from imagescanner import (CacheCleaner, ImageScanner, ThumbnailCache,
|
||||||
|
ThumbnailGenerator, ThreadPoolManager)
|
||||||
from imageviewer import ImageViewer
|
from imageviewer import ImageViewer
|
||||||
from propertiesdialog import PropertiesDialog
|
from propertiesdialog import PropertiesDialog
|
||||||
from widgets import (
|
from widgets import (
|
||||||
@@ -903,13 +904,14 @@ class MainWindow(QMainWindow):
|
|||||||
scanners and individual image viewer windows.
|
scanners and individual image viewer windows.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, cache, args):
|
def __init__(self, cache, args, thread_pool_manager):
|
||||||
"""
|
"""
|
||||||
Initializes the MainWindow.
|
Initializes the MainWindow.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
cache (ThumbnailCache): The shared thumbnail cache instance.
|
cache (ThumbnailCache): The shared thumbnail cache instance.
|
||||||
args (list): Command-line arguments passed to the application.
|
args (list): Command-line arguments passed to the application.
|
||||||
|
thread_pool_manager (ThreadPoolManager): The shared thread pool manager.
|
||||||
"""
|
"""
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.cache = cache
|
self.cache = cache
|
||||||
@@ -917,6 +919,7 @@ class MainWindow(QMainWindow):
|
|||||||
self.set_app_icon()
|
self.set_app_icon()
|
||||||
|
|
||||||
self.viewer_shortcuts = {}
|
self.viewer_shortcuts = {}
|
||||||
|
self.thread_pool_manager = thread_pool_manager
|
||||||
self.full_history = []
|
self.full_history = []
|
||||||
self.history = []
|
self.history = []
|
||||||
self.current_thumb_size = THUMBNAILS_DEFAULT_SIZE
|
self.current_thumb_size = THUMBNAILS_DEFAULT_SIZE
|
||||||
@@ -1320,12 +1323,14 @@ class MainWindow(QMainWindow):
|
|||||||
def _on_scroll_interaction(self, value):
|
def _on_scroll_interaction(self, value):
|
||||||
"""Pauses scanning during scroll to keep UI fluid."""
|
"""Pauses scanning during scroll to keep UI fluid."""
|
||||||
if self.scanner and self.scanner.isRunning():
|
if self.scanner and self.scanner.isRunning():
|
||||||
|
self.thread_pool_manager.set_user_active(True)
|
||||||
self.scanner.set_paused(True)
|
self.scanner.set_paused(True)
|
||||||
self.resume_scan_timer.start()
|
self.resume_scan_timer.start()
|
||||||
|
|
||||||
def _resume_scanning(self):
|
def _resume_scanning(self):
|
||||||
"""Resumes scanning after interaction pause."""
|
"""Resumes scanning after interaction pause."""
|
||||||
if self.scanner:
|
if self.scanner:
|
||||||
|
self.thread_pool_manager.set_user_active(False)
|
||||||
# Prioritize currently visible images
|
# Prioritize currently visible images
|
||||||
visible_paths = self.get_visible_image_paths()
|
visible_paths = self.get_visible_image_paths()
|
||||||
self.scanner.prioritize(visible_paths)
|
self.scanner.prioritize(visible_paths)
|
||||||
@@ -1676,6 +1681,7 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
# Trigger a repaint to apply other color changes like filename color
|
# Trigger a repaint to apply other color changes like filename color
|
||||||
self._apply_global_stylesheet()
|
self._apply_global_stylesheet()
|
||||||
|
self.thread_pool_manager.update_default_thread_count()
|
||||||
self.thumbnail_view.updateGeometries()
|
self.thumbnail_view.updateGeometries()
|
||||||
self.thumbnail_view.viewport().update()
|
self.thumbnail_view.viewport().update()
|
||||||
|
|
||||||
@@ -2352,6 +2358,7 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
self.is_cleaning = False
|
self.is_cleaning = False
|
||||||
self.scanner = ImageScanner(self.cache, paths, is_file_list=self._scan_all,
|
self.scanner = ImageScanner(self.cache, paths, is_file_list=self._scan_all,
|
||||||
|
thread_pool_manager=self.thread_pool_manager,
|
||||||
viewers=self.viewers)
|
viewers=self.viewers)
|
||||||
if self._is_loading_all:
|
if self._is_loading_all:
|
||||||
self.scanner.set_auto_load(True)
|
self.scanner.set_auto_load(True)
|
||||||
@@ -3516,7 +3523,8 @@ class MainWindow(QMainWindow):
|
|||||||
if not paths:
|
if not paths:
|
||||||
return
|
return
|
||||||
|
|
||||||
self.thumbnail_generator = ThumbnailGenerator(self.cache, paths, size)
|
self.thumbnail_generator = ThumbnailGenerator(
|
||||||
|
self.cache, paths, size, self.thread_pool_manager)
|
||||||
self.thumbnail_generator.generation_complete.connect(
|
self.thumbnail_generator.generation_complete.connect(
|
||||||
self.on_high_res_generation_finished)
|
self.on_high_res_generation_finished)
|
||||||
self.thumbnail_generator.progress.connect(
|
self.thumbnail_generator.progress.connect(
|
||||||
@@ -3983,7 +3991,8 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
# Create a ThumbnailGenerator to regenerate the thumbnail
|
# Create a ThumbnailGenerator to regenerate the thumbnail
|
||||||
size = self._get_tier_for_size(self.current_thumb_size)
|
size = self._get_tier_for_size(self.current_thumb_size)
|
||||||
self.thumbnail_generator = ThumbnailGenerator(self.cache, [path], size)
|
self.thumbnail_generator = ThumbnailGenerator(
|
||||||
|
self.cache, [path], size, self.thread_pool_manager)
|
||||||
self.thumbnail_generator.generation_complete.connect(
|
self.thumbnail_generator.generation_complete.connect(
|
||||||
self.on_high_res_generation_finished)
|
self.on_high_res_generation_finished)
|
||||||
self.thumbnail_generator.progress.connect(
|
self.thumbnail_generator.progress.connect(
|
||||||
@@ -4362,6 +4371,7 @@ def main():
|
|||||||
# Increase QPixmapCache limit (default is usually small, ~10MB) to ~100MB
|
# Increase QPixmapCache limit (default is usually small, ~10MB) to ~100MB
|
||||||
QPixmapCache.setCacheLimit(102400)
|
QPixmapCache.setCacheLimit(102400)
|
||||||
|
|
||||||
|
thread_pool_manager = ThreadPoolManager()
|
||||||
cache = ThumbnailCache()
|
cache = ThumbnailCache()
|
||||||
|
|
||||||
args = [a for a in sys.argv[1:] if a != "--x11"]
|
args = [a for a in sys.argv[1:] if a != "--x11"]
|
||||||
@@ -4370,7 +4380,7 @@ def main():
|
|||||||
if path.startswith("file:/"):
|
if path.startswith("file:/"):
|
||||||
path = path[6:]
|
path = path[6:]
|
||||||
|
|
||||||
win = MainWindow(cache, args)
|
win = MainWindow(cache, args, thread_pool_manager)
|
||||||
shortcut_controller = AppShortcutController(win)
|
shortcut_controller = AppShortcutController(win)
|
||||||
win.shortcut_controller = shortcut_controller
|
win.shortcut_controller = shortcut_controller
|
||||||
app.installEventFilter(shortcut_controller)
|
app.installEventFilter(shortcut_controller)
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ v0.9.11 -
|
|||||||
· Añadida una nueva área llamada Body.
|
· Añadida una nueva área llamada Body.
|
||||||
· Refactorizaciones, optimizaciones y cambios a saco.
|
· Refactorizaciones, optimizaciones y cambios a saco.
|
||||||
|
|
||||||
|
Add a `shutdown` signal or method to `ScannerWorker` to allow cleaner cancellation of long-running tasks like `generate_thumbnail`.
|
||||||
|
Implement a mechanism to dynamically adjust the thread pool size based on system load or user activity.
|
||||||
|
|
||||||
|
Implement a mechanism to monitor system CPU load and adjust the thread pool size accordingly.
|
||||||
|
Refactor the `ThreadPoolManager` to be a QObject and emit signals when the thread count changes.
|
||||||
Refactor the `ImageScanner` to use a thread pool for parallel thumbnail generation for faster loading.
|
|
||||||
|
|
||||||
Implement a "Comparison" mode to view 2 or 4 images side-by-side in the viewer.
|
Implement a "Comparison" mode to view 2 or 4 images side-by-side in the viewer.
|
||||||
|
|
||||||
|
|||||||
@@ -168,11 +168,6 @@ if importlib.util.find_spec("mediapipe") is not None:
|
|||||||
HAVE_FACE_RECOGNITION = importlib.util.find_spec("face_recognition") is not None
|
HAVE_FACE_RECOGNITION = importlib.util.find_spec("face_recognition") is not None
|
||||||
|
|
||||||
HAVE_BAGHEERASEARCH_LIB = False
|
HAVE_BAGHEERASEARCH_LIB = False
|
||||||
try:
|
|
||||||
import bagheera_search_lib
|
|
||||||
HAVE_BAGHEERASEARCH_LIB = True
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
MEDIAPIPE_FACE_MODEL_PATH = os.path.join(CONFIG_DIR,
|
MEDIAPIPE_FACE_MODEL_PATH = os.path.join(CONFIG_DIR,
|
||||||
"blaze_face_short_range.tflite")
|
"blaze_face_short_range.tflite")
|
||||||
|
|||||||
539
imagescanner.py
539
imagescanner.py
@@ -28,9 +28,10 @@ import collections
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
import lmdb
|
import lmdb
|
||||||
from PySide6.QtCore import (QObject, QThread, Signal, QMutex, QReadWriteLock, QSize,
|
from PySide6.QtCore import (
|
||||||
QWaitCondition, QByteArray, QBuffer, QIODevice, Qt, QTimer,
|
QObject, QThread, Signal, QMutex, QReadWriteLock, QSize, QSemaphore, QWaitCondition,
|
||||||
QRunnable, QThreadPool)
|
QByteArray, QBuffer, QIODevice, Qt, QTimer, QRunnable, QThreadPool, QFile
|
||||||
|
)
|
||||||
from PySide6.QtGui import QImage, QImageReader, QImageIOHandler
|
from PySide6.QtGui import QImage, QImageReader, QImageIOHandler
|
||||||
|
|
||||||
from constants import (
|
from constants import (
|
||||||
@@ -49,10 +50,193 @@ if HAVE_BAGHEERASEARCH_LIB:
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def generate_thumbnail(path, size):
|
class ThreadPoolManager:
|
||||||
|
"""Manages a global QThreadPool to dynamically adjust thread count."""
|
||||||
|
def __init__(self):
|
||||||
|
self.pool = QThreadPool()
|
||||||
|
self.default_thread_count = APP_CONFIG.get(
|
||||||
|
"generation_threads",
|
||||||
|
SCANNER_SETTINGS_DEFAULTS.get("generation_threads", 4)
|
||||||
|
)
|
||||||
|
self.pool.setMaxThreadCount(self.default_thread_count)
|
||||||
|
self.is_user_active = False
|
||||||
|
logger.info(f"ThreadPoolManager initialized with {self.default_thread_count} threads.")
|
||||||
|
|
||||||
|
def get_pool(self):
|
||||||
|
"""Returns the managed QThreadPool instance."""
|
||||||
|
return self.pool
|
||||||
|
|
||||||
|
def set_user_active(self, active):
|
||||||
|
"""
|
||||||
|
Adjusts thread count based on user activity.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
active (bool): True if the user is interacting with the UI.
|
||||||
|
"""
|
||||||
|
if active == self.is_user_active:
|
||||||
|
return
|
||||||
|
self.is_user_active = active
|
||||||
|
if active:
|
||||||
|
# User is active, reduce threads to 1 to prioritize UI responsiveness.
|
||||||
|
self.pool.setMaxThreadCount(1)
|
||||||
|
logger.debug("User is active, reducing thread pool to 1.")
|
||||||
|
else:
|
||||||
|
# User is idle, restore to default thread count.
|
||||||
|
self.pool.setMaxThreadCount(self.default_thread_count)
|
||||||
|
logger.debug(f"User is idle, restoring thread pool to {self.default_thread_count}.")
|
||||||
|
|
||||||
|
def update_default_thread_count(self):
|
||||||
|
"""Updates the default thread count from application settings."""
|
||||||
|
self.default_thread_count = APP_CONFIG.get(
|
||||||
|
"generation_threads",
|
||||||
|
SCANNER_SETTINGS_DEFAULTS.get("generation_threads", 4)
|
||||||
|
)
|
||||||
|
# Only apply if not in a user-active (low-thread) state.
|
||||||
|
if not self.is_user_active:
|
||||||
|
self.pool.setMaxThreadCount(self.default_thread_count)
|
||||||
|
logger.info(f"Default thread count updated to {self.default_thread_count}.")
|
||||||
|
|
||||||
|
|
||||||
|
class ScannerWorker(QRunnable):
|
||||||
|
"""
|
||||||
|
Worker to process a single image in a thread pool.
|
||||||
|
Handles thumbnail retrieval/generation and metadata loading.
|
||||||
|
"""
|
||||||
|
def __init__(self, cache, path, target_sizes=None, load_metadata=True,
|
||||||
|
signal_emitter=None, semaphore=None):
|
||||||
|
super().__init__()
|
||||||
|
self.cache = cache
|
||||||
|
self.path = path
|
||||||
|
self.target_sizes = target_sizes
|
||||||
|
self.load_metadata_flag = load_metadata
|
||||||
|
self.emitter = signal_emitter
|
||||||
|
self.semaphore = semaphore
|
||||||
|
self._is_cancelled = False
|
||||||
|
# Result will be (path, thumb, mtime, tags, rating, inode, dev) or None
|
||||||
|
self.result = None
|
||||||
|
|
||||||
|
def shutdown(self):
|
||||||
|
"""Marks the worker as cancelled."""
|
||||||
|
self._is_cancelled = True
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
from constants import SCANNER_GENERATE_SIZES
|
||||||
|
|
||||||
|
sizes_to_check = self.target_sizes if self.target_sizes is not None \
|
||||||
|
else SCANNER_GENERATE_SIZES
|
||||||
|
|
||||||
|
if self._is_cancelled:
|
||||||
|
if self.semaphore:
|
||||||
|
self.semaphore.release()
|
||||||
|
return
|
||||||
|
|
||||||
|
fd = None
|
||||||
|
try:
|
||||||
|
# Optimize: Open file once to reuse FD for stat and xattrs
|
||||||
|
fd = os.open(self.path, os.O_RDONLY)
|
||||||
|
stat_res = os.fstat(fd)
|
||||||
|
curr_mtime = stat_res.st_mtime
|
||||||
|
curr_inode = stat_res.st_ino
|
||||||
|
curr_dev = stat_res.st_dev
|
||||||
|
|
||||||
|
smallest_thumb_for_signal = None
|
||||||
|
min_size = min(sizes_to_check) if sizes_to_check else 0
|
||||||
|
|
||||||
|
# Ensure required thumbnails exist
|
||||||
|
for size in sizes_to_check:
|
||||||
|
if self._is_cancelled:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if a valid thumbnail for this size exists
|
||||||
|
thumb, mtime = self.cache.get_thumbnail(self.path, size,
|
||||||
|
curr_mtime=curr_mtime,
|
||||||
|
inode=curr_inode,
|
||||||
|
device_id=curr_dev)
|
||||||
|
if not thumb or mtime != curr_mtime:
|
||||||
|
# Use generation lock to prevent multiple threads generating
|
||||||
|
with self.cache.generation_lock(
|
||||||
|
self.path, size, curr_mtime,
|
||||||
|
curr_inode, curr_dev) as should_gen:
|
||||||
|
if self._is_cancelled:
|
||||||
|
return
|
||||||
|
|
||||||
|
if should_gen:
|
||||||
|
# I am the owner, I generate the thumbnail
|
||||||
|
new_thumb = generate_thumbnail(self.path, size, fd=fd)
|
||||||
|
if self._is_cancelled:
|
||||||
|
return
|
||||||
|
if new_thumb and not new_thumb.isNull():
|
||||||
|
self.cache.set_thumbnail(
|
||||||
|
self.path, new_thumb, curr_mtime, size,
|
||||||
|
inode=curr_inode, device_id=curr_dev, block=True)
|
||||||
|
if size == min_size:
|
||||||
|
smallest_thumb_for_signal = new_thumb
|
||||||
|
else:
|
||||||
|
# Another thread generated it, re-fetch
|
||||||
|
if size == min_size:
|
||||||
|
re_thumb, _ = self.cache.get_thumbnail(
|
||||||
|
self.path, size, curr_mtime=curr_mtime,
|
||||||
|
inode=curr_inode, device_id=curr_dev,
|
||||||
|
async_load=False)
|
||||||
|
smallest_thumb_for_signal = re_thumb
|
||||||
|
elif size == min_size:
|
||||||
|
# valid thumb exists, use it for signal
|
||||||
|
smallest_thumb_for_signal = thumb
|
||||||
|
|
||||||
|
tags = []
|
||||||
|
rating = 0
|
||||||
|
if self.load_metadata_flag:
|
||||||
|
tags, rating = self._load_metadata(fd)
|
||||||
|
self.result = (self.path, smallest_thumb_for_signal,
|
||||||
|
curr_mtime, tags, rating, curr_inode, curr_dev)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing image {self.path}: {e}")
|
||||||
|
self.result = None
|
||||||
|
finally:
|
||||||
|
if fd is not None:
|
||||||
|
try:
|
||||||
|
os.close(fd)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if self.emitter:
|
||||||
|
self.emitter.emit_progress()
|
||||||
|
if self.semaphore:
|
||||||
|
self.semaphore.release()
|
||||||
|
|
||||||
|
def _load_metadata(self, path_or_fd):
|
||||||
|
"""Loads tag and rating data for a path or file descriptor."""
|
||||||
|
tags = []
|
||||||
|
raw_tags = XattrManager.get_attribute(path_or_fd, XATTR_NAME)
|
||||||
|
if raw_tags:
|
||||||
|
tags = sorted(list(set(t.strip()
|
||||||
|
for t in raw_tags.split(',') if t.strip())))
|
||||||
|
|
||||||
|
raw_rating = XattrManager.get_attribute(path_or_fd, RATING_XATTR_NAME, "0")
|
||||||
|
try:
|
||||||
|
rating = int(raw_rating)
|
||||||
|
except ValueError:
|
||||||
|
rating = 0
|
||||||
|
return tags, rating
|
||||||
|
|
||||||
|
|
||||||
|
def generate_thumbnail(path, size, fd=None):
|
||||||
"""Generates a QImage thumbnail for a given path and size."""
|
"""Generates a QImage thumbnail for a given path and size."""
|
||||||
try:
|
try:
|
||||||
reader = QImageReader(path)
|
qfile = None
|
||||||
|
if fd is not None:
|
||||||
|
try:
|
||||||
|
# Ensure we are at the beginning of the file
|
||||||
|
os.lseek(fd, 0, os.SEEK_SET)
|
||||||
|
qfile = QFile()
|
||||||
|
if qfile.open(fd, QIODevice.ReadOnly, QFile.DontCloseHandle):
|
||||||
|
reader = QImageReader(qfile)
|
||||||
|
else:
|
||||||
|
qfile = None
|
||||||
|
reader = QImageReader(path)
|
||||||
|
except OSError:
|
||||||
|
reader = QImageReader(path)
|
||||||
|
else:
|
||||||
|
reader = QImageReader(path)
|
||||||
|
|
||||||
# Optimization: Instruct the image decoder to scale while reading.
|
# Optimization: Instruct the image decoder to scale while reading.
|
||||||
# This drastically reduces memory usage and CPU time for large images
|
# This drastically reduces memory usage and CPU time for large images
|
||||||
@@ -1046,45 +1230,6 @@ class CacheCleaner(QThread):
|
|||||||
self.finished_clean.emit(removed_count)
|
self.finished_clean.emit(removed_count)
|
||||||
|
|
||||||
|
|
||||||
class ThumbnailRunnable(QRunnable):
|
|
||||||
"""Runnable task to generate a single thumbnail."""
|
|
||||||
def __init__(self, cache, path, size, signal_emitter):
|
|
||||||
super().__init__()
|
|
||||||
self.cache = cache
|
|
||||||
self.path = path
|
|
||||||
self.size = size
|
|
||||||
self.emitter = signal_emitter
|
|
||||||
|
|
||||||
def run(self):
|
|
||||||
try:
|
|
||||||
# Optimization: Single stat call per file
|
|
||||||
stat_res = os.stat(self.path)
|
|
||||||
curr_mtime = stat_res.st_mtime
|
|
||||||
inode = stat_res.st_ino
|
|
||||||
dev = stat_res.st_dev
|
|
||||||
|
|
||||||
# Check cache first to avoid expensive generation
|
|
||||||
thumb, mtime = self.cache.get_thumbnail(
|
|
||||||
self.path, self.size, curr_mtime=curr_mtime,
|
|
||||||
inode=inode, device_id=dev, async_load=False)
|
|
||||||
|
|
||||||
if not thumb or mtime != curr_mtime:
|
|
||||||
# Use the generation lock to coordinate
|
|
||||||
with self.cache.generation_lock(
|
|
||||||
self.path, self.size, curr_mtime, inode, dev) as should_gen:
|
|
||||||
if should_gen:
|
|
||||||
# I am the owner, I generate the thumbnail
|
|
||||||
new_thumb = generate_thumbnail(self.path, self.size)
|
|
||||||
if new_thumb and not new_thumb.isNull():
|
|
||||||
self.cache.set_thumbnail(
|
|
||||||
self.path, new_thumb, curr_mtime, self.size,
|
|
||||||
inode=inode, device_id=dev, block=True)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error generating thumbnail for {self.path}: {e}")
|
|
||||||
finally:
|
|
||||||
self.emitter.emit_progress()
|
|
||||||
|
|
||||||
|
|
||||||
class ThumbnailGenerator(QThread):
|
class ThumbnailGenerator(QThread):
|
||||||
"""
|
"""
|
||||||
Background thread to generate thumbnails for a specific size for a list of
|
Background thread to generate thumbnails for a specific size for a list of
|
||||||
@@ -1097,34 +1242,38 @@ class ThumbnailGenerator(QThread):
|
|||||||
"""Helper to emit signals from runnables to the main thread."""
|
"""Helper to emit signals from runnables to the main thread."""
|
||||||
progress_tick = Signal()
|
progress_tick = Signal()
|
||||||
|
|
||||||
def emit_progress(self):
|
def emit_progress(self):
|
||||||
self.progress_tick.emit()
|
self.progress_tick.emit()
|
||||||
|
|
||||||
def __init__(self, cache, paths, size):
|
def __init__(self, cache, paths, size, thread_pool_manager):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.cache = cache
|
self.cache = cache
|
||||||
self.paths = paths
|
self.paths = paths
|
||||||
self.size = size
|
self.size = size
|
||||||
self._abort = False
|
self._abort = False
|
||||||
|
self.thread_pool_manager = thread_pool_manager
|
||||||
|
self._workers = []
|
||||||
|
self._workers_mutex = QMutex()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
"""Stops the worker thread gracefully."""
|
"""Stops the worker thread gracefully."""
|
||||||
self._abort = True
|
self._abort = True
|
||||||
|
self._workers_mutex.lock()
|
||||||
|
for worker in self._workers:
|
||||||
|
worker.shutdown()
|
||||||
|
self._workers_mutex.unlock()
|
||||||
self.wait()
|
self.wait()
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
"""
|
"""
|
||||||
Main execution loop. Uses a thread pool to process paths in parallel.
|
Main execution loop. Uses a thread pool to process paths in parallel.
|
||||||
"""
|
"""
|
||||||
pool = QThreadPool()
|
pool = self.thread_pool_manager.get_pool()
|
||||||
max_threads = APP_CONFIG.get(
|
|
||||||
"generation_threads",
|
|
||||||
SCANNER_SETTINGS_DEFAULTS.get("generation_threads", 4))
|
|
||||||
pool.setMaxThreadCount(max_threads)
|
|
||||||
|
|
||||||
emitter = self.SignalEmitter()
|
emitter = self.SignalEmitter()
|
||||||
processed_count = 0
|
processed_count = 0
|
||||||
total = len(self.paths)
|
total = len(self.paths)
|
||||||
|
sem = QSemaphore(0)
|
||||||
|
|
||||||
def on_tick():
|
def on_tick():
|
||||||
nonlocal processed_count
|
nonlocal processed_count
|
||||||
@@ -1138,14 +1287,34 @@ class ThumbnailGenerator(QThread):
|
|||||||
# The signal/slot mechanism handles thread safety automatically.
|
# The signal/slot mechanism handles thread safety automatically.
|
||||||
emitter.progress_tick.connect(on_tick, Qt.QueuedConnection)
|
emitter.progress_tick.connect(on_tick, Qt.QueuedConnection)
|
||||||
|
|
||||||
|
started_count = 0
|
||||||
for path in self.paths:
|
for path in self.paths:
|
||||||
if self._abort:
|
if self._abort:
|
||||||
break
|
break
|
||||||
runnable = ThumbnailRunnable(self.cache, path, self.size, emitter)
|
runnable = ScannerWorker(self.cache, path, target_sizes=[self.size],
|
||||||
pool.start(runnable)
|
load_metadata=False, signal_emitter=emitter,
|
||||||
|
semaphore=sem)
|
||||||
|
runnable.setAutoDelete(False)
|
||||||
|
|
||||||
pool.waitForDone()
|
self._workers_mutex.lock()
|
||||||
self.generation_complete.emit()
|
if self._abort:
|
||||||
|
self._workers_mutex.unlock()
|
||||||
|
break
|
||||||
|
self._workers.append(runnable)
|
||||||
|
self._workers_mutex.unlock()
|
||||||
|
|
||||||
|
pool.start(runnable)
|
||||||
|
started_count += 1
|
||||||
|
|
||||||
|
if started_count > 0:
|
||||||
|
sem.acquire(started_count)
|
||||||
|
|
||||||
|
self._workers_mutex.lock()
|
||||||
|
self._workers.clear()
|
||||||
|
self._workers_mutex.unlock()
|
||||||
|
|
||||||
|
if not self._abort:
|
||||||
|
self.generation_complete.emit()
|
||||||
|
|
||||||
|
|
||||||
class ImageScanner(QThread):
|
class ImageScanner(QThread):
|
||||||
@@ -1159,8 +1328,8 @@ class ImageScanner(QThread):
|
|||||||
progress_percent = Signal(int)
|
progress_percent = Signal(int)
|
||||||
finished_scan = Signal(int) # Total images found
|
finished_scan = Signal(int) # Total images found
|
||||||
more_files_available = Signal(int, int) # Last loaded index, remainder
|
more_files_available = Signal(int, int) # Last loaded index, remainder
|
||||||
|
def __init__(self, cache, paths, is_file_list=False, viewers=None,
|
||||||
def __init__(self, cache, paths, is_file_list=False, viewers=None):
|
thread_pool_manager=None):
|
||||||
# is_file_list is not used
|
# is_file_list is not used
|
||||||
if not paths or not isinstance(paths, (list, tuple)):
|
if not paths or not isinstance(paths, (list, tuple)):
|
||||||
logger.warning("ImageScanner initialized with empty or invalid paths")
|
logger.warning("ImageScanner initialized with empty or invalid paths")
|
||||||
@@ -1168,6 +1337,7 @@ class ImageScanner(QThread):
|
|||||||
super().__init__()
|
super().__init__()
|
||||||
self.cache = cache
|
self.cache = cache
|
||||||
self.all_files = []
|
self.all_files = []
|
||||||
|
self.thread_pool_manager = thread_pool_manager
|
||||||
self._viewers = viewers
|
self._viewers = viewers
|
||||||
self._seen_files = set()
|
self._seen_files = set()
|
||||||
self._is_file_list = is_file_list
|
self._is_file_list = is_file_list
|
||||||
@@ -1196,12 +1366,23 @@ class ImageScanner(QThread):
|
|||||||
self.pending_tasks = []
|
self.pending_tasks = []
|
||||||
self._priority_queue = collections.deque()
|
self._priority_queue = collections.deque()
|
||||||
self._processed_paths = set()
|
self._processed_paths = set()
|
||||||
|
self._current_workers = []
|
||||||
|
self._current_workers_mutex = QMutex()
|
||||||
|
|
||||||
# Initial load
|
# Initial load
|
||||||
self.pending_tasks.append((0, APP_CONFIG.get(
|
self.pending_tasks.append((0, APP_CONFIG.get(
|
||||||
"scan_batch_size", SCANNER_SETTINGS_DEFAULTS["scan_batch_size"])))
|
"scan_batch_size", SCANNER_SETTINGS_DEFAULTS["scan_batch_size"])))
|
||||||
self._last_update_time = 0
|
self._last_update_time = 0
|
||||||
|
|
||||||
|
if self.thread_pool_manager:
|
||||||
|
self.pool = self.thread_pool_manager.get_pool()
|
||||||
|
else:
|
||||||
|
self.pool = QThreadPool()
|
||||||
|
max_threads = APP_CONFIG.get(
|
||||||
|
"generation_threads",
|
||||||
|
SCANNER_SETTINGS_DEFAULTS.get("generation_threads", 4))
|
||||||
|
self.pool.setMaxThreadCount(max_threads)
|
||||||
|
|
||||||
logger.info(f"ImageScanner initialized with {len(paths)} paths")
|
logger.info(f"ImageScanner initialized with {len(paths)} paths")
|
||||||
|
|
||||||
def set_auto_load(self, enabled):
|
def set_auto_load(self, enabled):
|
||||||
@@ -1455,84 +1636,129 @@ class ImageScanner(QThread):
|
|||||||
self.finished_scan.emit(self.count)
|
self.finished_scan.emit(self.count)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if self.thread_pool_manager:
|
||||||
|
max_threads = self.thread_pool_manager.default_thread_count
|
||||||
|
else:
|
||||||
|
max_threads = APP_CONFIG.get(
|
||||||
|
"generation_threads",
|
||||||
|
SCANNER_SETTINGS_DEFAULTS.get("generation_threads", 4))
|
||||||
|
self.pool.setMaxThreadCount(max_threads)
|
||||||
|
|
||||||
images_loaded = 0
|
images_loaded = 0
|
||||||
batch = []
|
batch = []
|
||||||
while i < len(self.all_files):
|
while i < len(self.all_files):
|
||||||
|
|
||||||
if not self._is_running:
|
if not self._is_running:
|
||||||
return
|
return
|
||||||
self.msleep(1) # Force yield to UI thread per item
|
|
||||||
|
|
||||||
while self._paused and self._is_running:
|
while self._paused and self._is_running:
|
||||||
self.msleep(100)
|
self.msleep(100)
|
||||||
|
|
||||||
# 1. Check priority queue first
|
# Collect paths for this chunk to process in parallel
|
||||||
priority_path = None
|
chunk_size = max_threads * 2
|
||||||
|
tasks = [] # List of (path, is_from_priority_queue)
|
||||||
|
|
||||||
|
# 1. Drain priority queue up to chunk size
|
||||||
self.mutex.lock()
|
self.mutex.lock()
|
||||||
while self._priority_queue:
|
while len(tasks) < chunk_size and self._priority_queue:
|
||||||
p = self._priority_queue.popleft()
|
p = self._priority_queue.popleft()
|
||||||
if p not in self._processed_paths and p in self._seen_files:
|
if p not in self._processed_paths and p in self._seen_files:
|
||||||
priority_path = p
|
tasks.append((p, True))
|
||||||
break
|
|
||||||
self.mutex.unlock()
|
self.mutex.unlock()
|
||||||
|
|
||||||
# 2. Determine file to process
|
# 2. Fill remaining chunk space with sequential files
|
||||||
if priority_path:
|
temp_i = i
|
||||||
f_path = priority_path
|
while len(tasks) < chunk_size and temp_i < len(self.all_files):
|
||||||
# Don't increment 'i' yet, we are processing out of order
|
p = self.all_files[temp_i]
|
||||||
else:
|
# Skip if already processed (e.g. via priority earlier)
|
||||||
f_path = self.all_files[i]
|
if p not in self._processed_paths \
|
||||||
i += 1 # Only advance sequential index if processing sequentially
|
and Path(p).suffix.lower() in IMAGE_EXTENSIONS:
|
||||||
|
tasks.append((p, False))
|
||||||
|
temp_i += 1
|
||||||
|
|
||||||
if f_path not in self._processed_paths \
|
if not tasks:
|
||||||
and Path(f_path).suffix.lower() in IMAGE_EXTENSIONS:
|
# If no tasks found but still have files (e.g. all skipped extensions),
|
||||||
# Pass the batch list to store result instead of emitting immediately
|
# update index and continue loop
|
||||||
was_loaded = self._process_single_image(f_path, batch)
|
i = temp_i
|
||||||
|
continue
|
||||||
|
|
||||||
# Emit batch if size is enough (responsiveness optimization)
|
# Submit tasks to thread pool
|
||||||
# Dynamic batching: Start small for instant feedback.
|
sem = QSemaphore(0)
|
||||||
# Keep batches small enough to prevent UI starvation during rapid cache
|
runnables = []
|
||||||
# reads.
|
|
||||||
if self.count <= 100:
|
|
||||||
target_batch_size = 20
|
|
||||||
else:
|
|
||||||
target_batch_size = 200
|
|
||||||
|
|
||||||
if len(batch) >= target_batch_size:
|
self._current_workers_mutex.lock()
|
||||||
|
if not self._is_running:
|
||||||
|
self._current_workers_mutex.unlock()
|
||||||
|
return
|
||||||
|
|
||||||
self.images_found.emit(batch)
|
for f_path, _ in tasks:
|
||||||
batch = []
|
r = ScannerWorker(self.cache, f_path, semaphore=sem)
|
||||||
# Yield briefly to let the main thread process the emitted batch
|
r.setAutoDelete(False)
|
||||||
# (update UI), preventing UI freeze during fast cache reading.
|
runnables.append(r)
|
||||||
self.msleep(10)
|
self._current_workers.append(r)
|
||||||
|
self.pool.start(r)
|
||||||
|
self._current_workers_mutex.unlock()
|
||||||
|
|
||||||
if was_loaded:
|
# Wait only for this chunk to finish using semaphore
|
||||||
self._processed_paths.add(f_path)
|
sem.acquire(len(runnables))
|
||||||
|
|
||||||
|
self._current_workers_mutex.lock()
|
||||||
|
self._current_workers.clear()
|
||||||
|
self._current_workers_mutex.unlock()
|
||||||
|
|
||||||
|
if not self._is_running:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Process results
|
||||||
|
for r in runnables:
|
||||||
|
if r.result:
|
||||||
|
self._processed_paths.add(r.path)
|
||||||
|
batch.append(r.result)
|
||||||
|
self.count += 1
|
||||||
images_loaded += 1
|
images_loaded += 1
|
||||||
if images_loaded >= to_load and to_load > 0:
|
|
||||||
if batch: # Emit remaining items
|
|
||||||
self.images_found.emit(batch)
|
|
||||||
|
|
||||||
next_index = i + 1
|
# Clean up runnables
|
||||||
total_files = len(self.all_files)
|
runnables.clear()
|
||||||
self.index = next_index
|
|
||||||
self.progress_msg.emit(UITexts.LOADED_PARTIAL.format(
|
|
||||||
self.count, total_files - next_index))
|
|
||||||
|
|
||||||
if total_files > 0:
|
# Advance sequential index
|
||||||
percent = int((self.count / total_files) * 100)
|
i = temp_i
|
||||||
self.progress_percent.emit(percent)
|
|
||||||
|
|
||||||
self.more_files_available.emit(next_index, total_files)
|
# Emit batch if size is enough (responsiveness optimization)
|
||||||
# This loads all images continuously without pausing only if
|
if self.count <= 100:
|
||||||
# explicitly requested
|
target_batch_size = 20
|
||||||
if self._auto_load_enabled:
|
else:
|
||||||
self.load_images(
|
target_batch_size = 200
|
||||||
next_index,
|
|
||||||
APP_CONFIG.get("scan_batch_size",
|
if len(batch) >= target_batch_size:
|
||||||
SCANNER_SETTINGS_DEFAULTS[
|
self.images_found.emit(batch)
|
||||||
"scan_batch_size"]))
|
batch = []
|
||||||
return
|
self.msleep(10) # Yield to UI
|
||||||
|
|
||||||
|
# Check if loading limit reached
|
||||||
|
if images_loaded >= to_load and to_load > 0:
|
||||||
|
if batch: # Emit remaining items
|
||||||
|
self.images_found.emit(batch)
|
||||||
|
|
||||||
|
next_index = i
|
||||||
|
total_files = len(self.all_files)
|
||||||
|
self.index = next_index
|
||||||
|
self.progress_msg.emit(UITexts.LOADED_PARTIAL.format(
|
||||||
|
self.count, total_files - next_index))
|
||||||
|
|
||||||
|
if total_files > 0:
|
||||||
|
percent = int((self.count / total_files) * 100)
|
||||||
|
self.progress_percent.emit(percent)
|
||||||
|
|
||||||
|
self.more_files_available.emit(next_index, total_files)
|
||||||
|
# This loads all images continuously without pausing only if
|
||||||
|
# explicitly requested
|
||||||
|
if self._auto_load_enabled:
|
||||||
|
self.load_images(
|
||||||
|
next_index,
|
||||||
|
APP_CONFIG.get("scan_batch_size",
|
||||||
|
SCANNER_SETTINGS_DEFAULTS[
|
||||||
|
"scan_batch_size"]))
|
||||||
|
return
|
||||||
|
|
||||||
if self.count % 10 == 0: # Update progress less frequently
|
if self.count % 10 == 0: # Update progress less frequently
|
||||||
self.progress_msg.emit(
|
self.progress_msg.emit(
|
||||||
@@ -1547,88 +1773,17 @@ class ImageScanner(QThread):
|
|||||||
self.progress_percent.emit(100)
|
self.progress_percent.emit(100)
|
||||||
self.finished_scan.emit(self.count)
|
self.finished_scan.emit(self.count)
|
||||||
|
|
||||||
def _load_metadata(self, path_or_fd):
|
|
||||||
"""Loads tag and rating data for a path or file descriptor."""
|
|
||||||
tags = []
|
|
||||||
|
|
||||||
raw_tags = XattrManager.get_attribute(path_or_fd, XATTR_NAME)
|
|
||||||
if raw_tags:
|
|
||||||
tags = sorted(list(set(t.strip()
|
|
||||||
for t in raw_tags.split(',') if t.strip())))
|
|
||||||
|
|
||||||
raw_rating = XattrManager.get_attribute(path_or_fd, RATING_XATTR_NAME, "0")
|
|
||||||
try:
|
|
||||||
rating = int(raw_rating)
|
|
||||||
except ValueError:
|
|
||||||
rating = 0
|
|
||||||
return tags, rating
|
|
||||||
|
|
||||||
def _process_single_image(self, f_path, batch_list):
|
|
||||||
from constants import SCANNER_GENERATE_SIZES
|
|
||||||
|
|
||||||
fd = None
|
|
||||||
try:
|
|
||||||
# Optimize: Open file once to reuse FD for stat and xattrs
|
|
||||||
fd = os.open(f_path, os.O_RDONLY)
|
|
||||||
stat_res = os.fstat(fd)
|
|
||||||
curr_mtime = stat_res.st_mtime
|
|
||||||
curr_inode = stat_res.st_ino
|
|
||||||
curr_dev = stat_res.st_dev
|
|
||||||
|
|
||||||
smallest_thumb_for_signal = None
|
|
||||||
|
|
||||||
# Ensure required thumbnails exist
|
|
||||||
for size in SCANNER_GENERATE_SIZES:
|
|
||||||
# Check if a valid thumbnail for this size exists
|
|
||||||
thumb, mtime = self.cache.get_thumbnail(f_path, size,
|
|
||||||
curr_mtime=curr_mtime,
|
|
||||||
inode=curr_inode,
|
|
||||||
device_id=curr_dev)
|
|
||||||
if not thumb or mtime != curr_mtime:
|
|
||||||
# Use generation lock to prevent multiple threads generating the
|
|
||||||
# same thumb
|
|
||||||
with self.cache.generation_lock(
|
|
||||||
f_path, size, curr_mtime,
|
|
||||||
curr_inode, curr_dev) as should_gen:
|
|
||||||
if should_gen:
|
|
||||||
# I am the owner, I generate the thumbnail
|
|
||||||
new_thumb = generate_thumbnail(f_path, size)
|
|
||||||
if new_thumb and not new_thumb.isNull():
|
|
||||||
self.cache.set_thumbnail(
|
|
||||||
f_path, new_thumb, curr_mtime, size,
|
|
||||||
inode=curr_inode, device_id=curr_dev, block=True)
|
|
||||||
if size == min(SCANNER_GENERATE_SIZES):
|
|
||||||
smallest_thumb_for_signal = new_thumb
|
|
||||||
else:
|
|
||||||
# Another thread generated it, re-fetch to use it for the
|
|
||||||
# signal
|
|
||||||
if size == min(SCANNER_GENERATE_SIZES):
|
|
||||||
re_thumb, _ = self.cache.get_thumbnail(
|
|
||||||
f_path, size, curr_mtime=curr_mtime,
|
|
||||||
inode=curr_inode, device_id=curr_dev,
|
|
||||||
async_load=False)
|
|
||||||
smallest_thumb_for_signal = re_thumb
|
|
||||||
elif size == min(SCANNER_GENERATE_SIZES):
|
|
||||||
# valid thumb exists, use it for signal
|
|
||||||
smallest_thumb_for_signal = thumb
|
|
||||||
|
|
||||||
tags, rating = self._load_metadata(fd)
|
|
||||||
batch_list.append((f_path, smallest_thumb_for_signal,
|
|
||||||
curr_mtime, tags, rating, curr_inode, curr_dev))
|
|
||||||
self.count += 1
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error processing image {f_path}: {e}")
|
|
||||||
return False
|
|
||||||
finally:
|
|
||||||
if fd is not None:
|
|
||||||
try:
|
|
||||||
os.close(fd)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
|
logger.info("ImageScanner stop requested")
|
||||||
self._is_running = False
|
self._is_running = False
|
||||||
|
|
||||||
|
# Cancel currently running workers in the active batch
|
||||||
|
self._current_workers_mutex.lock()
|
||||||
|
for worker in self._current_workers:
|
||||||
|
worker.shutdown()
|
||||||
|
self._current_workers_mutex.unlock()
|
||||||
|
|
||||||
|
# Wake up the condition variable
|
||||||
self.mutex.lock()
|
self.mutex.lock()
|
||||||
self.condition.wakeAll()
|
self.condition.wakeAll()
|
||||||
self.mutex.unlock()
|
self.mutex.unlock()
|
||||||
|
|||||||
Reference in New Issue
Block a user