Compare commits

...

25 Commits

Author SHA1 Message Date
Ignacio Serantes
f3bc2f1e0a v0.9.26 2026-05-07 22:38:49 +02:00
Ignacio Serantes
dffc414182 v0.9.26 2026-05-07 21:44:19 +02:00
Ignacio Serantes
0d3d5ffa11 V0.9.26 2026-05-07 09:58:49 +02:00
Ignacio Serantes
8025bef8d3 v0.9.26 2026-05-03 13:31:48 +02:00
Ignacio Serantes
28b120c9e9 v0.9.25 2026-05-02 19:44:28 +02:00
Ignacio Serantes
a824a01579 v0.9.24 2026-04-19 17:39:01 +02:00
Ignacio Serantes
b5b70326b1 v0.9.23 2026-04-19 12:18:27 +02:00
Ignacio Serantes
9d286112b6 v0.9.22 2026-04-14 20:59:13 +02:00
Ignacio Serantes
b253b6d6e7 v0.9.21 2026-04-12 11:58:32 +02:00
Ignacio Serantes
8ade5fde54 v0.9.21 2026-04-12 11:56:39 +02:00
Ignacio Serantes
1508e629c0 v0.9.20 2026-04-12 08:39:07 +02:00
Ignacio Serantes
07afab6ca3 v0.9.19 2026-04-08 15:47:29 +02:00
Ignacio Serantes
bff99226b0 v0.9.18 2026-04-07 16:22:59 +02:00
Ignacio Serantes
9685c01760 Better status bar messages 2026-04-07 09:17:08 +02:00
Ignacio Serantes
3e374a5871 v0.9.17 2026-04-06 23:55:29 +02:00
Ignacio Serantes
964974431c Fixed hang with gifs in duplicates form 2026-04-06 23:20:27 +02:00
Ignacio Serantes
45c95c1bb1 Fixed thumbnail reload on metadata change 2026-04-06 22:09:13 +02:00
Ignacio Serantes
a717acef87 Several fixes 2026-04-06 20:44:49 +02:00
Ignacio Serantes
ca260d4219 Improve stability issues 2026-04-03 18:41:52 +02:00
Ignacio Serantes
ae00235db8 Fixed core dumped on close 2026-04-01 08:48:06 +02:00
Ignacio Serantes
2fbf04fdb8 Added missing libraries. 2026-03-31 23:40:29 +02:00
Ignacio Serantes
415400c30a Merge branch 'main' of ssh://git.aynoa.net/ignacio/bagheeraview 2026-03-31 23:36:14 +02:00
Ignacio Serantes
cb751b2970 v0.9.16 2026-03-31 23:35:57 +02:00
3706d404f4 Update README.md 2026-03-30 16:32:09 +02:00
2ae8ba9d9a Update information 2026-03-30 09:47:34 +02:00
18 changed files with 4307 additions and 306 deletions

View File

@@ -6,15 +6,25 @@ BagheeraView is an image viewer specifically designed for the KDE ecosystem. Bui
- **Enhanced Baloo Search:** Blazing fast image retrieval using the KDE Baloo indexing framework, featuring advanced capabilities not natively available in Baloo, such as **folder recursive search** and results **text exclusion**, if BagheeraSearch library is available.
- **Versatile Thumbnail Grid:** A fluid and responsive browser for large collections, offering both **Flat View**, several **Date View** modes, **Rating View** and **Folder View** modes.
- **Versatile Thumbnail Grid:** A fluid and responsive browser for large collections, offering **Flat View**, several **Date View** modes, **Rating View** and **Folder View** modes.
- **Face & Pet Detection:** Integrated computer vision to detect faces and pets within your photos and assign names. Body, Object and Landmark tags are supported too but without computer vision detection.
- **Areas Management:** Integrated computer vision to detect faces and pets within your photos and assign tag names. Body, Object and Landmark areas are supported too but without computer vision detection.
- **Metadata:** A basic viewer for **EXIF, IPTC, and XMP** data.
- **Tagging & Rating & Comments System:** Effortlessly manage tags, ratings and comments that integrate directly with your filesystem's extended attributes.
- **Tagging, Rating & Comments System:** Effortlessly manage tags, ratings and comments that integrate directly with your filesystem's extended attributes and used by Baloo.
- **Smart State Persistence:** The application remembers your workflow. Your **last used sort order** and view settings are automatically saved and restored upon startup.
- **Filter resuls:** Results can be filtered using tags or file name.
- **Duplicates Management:** A system to detect and manage duplicates using percentual hashing or OpenCV including a images comparison form and ignore list.
- **Smart State Persistence:** The application remembers your workflow. Your **last used search**, **last used sort order** and view settings are automatically saved and restored upon startup.
- **Favorites:** Favorite searchs can be saved and reused.
- **Cache:** Thumbnails cache and image hashes arte cached on LMDB databases.
- **Window Manager Support:** X11 and Wayland are supported.
## 🛠 Technical Stack
@@ -26,6 +36,8 @@ BagheeraView is an image viewer specifically designed for the KDE ecosystem. Bui
- **Metadata Handling:** Advanced image header manipulation to store faces, pets, body, objects and landmarks and support to file extended attributes
- **Duplicate Detection:** Two methods to detect duplicates, hashing and open vision
## 🌐 Internationalization (i18n)
BagheeraView is designed for a global audience with localized interface support. Initial supported languages include:
@@ -41,7 +53,7 @@ BagheeraView is designed for a global audience with localized interface support.
BagheeraView is built for workflow continuity. The application stores the user's environment state in the local configuration:
- **Restore Last Layout:** Last layout and preferences are automatically saved and restored every time you launch it.
- **Restore Last Layout:** Last layout and preferences are automatically saved and restored every  time you launch it.
- **Keyboard configuration:** All hotkeys can be parametriced by the user.
@@ -49,7 +61,7 @@ BagheeraView is built for workflow continuity. The application stores the user's
## 📥 Installation (Development)
Ensure you have at least Python 3.13, the necessary PySide6 dependencies and KDE development headers for Baloo installed on your system. For best integration install at least PySide6 in your system but for better results install all required libraries if they are available in your Linux distribution.
Ensure you have at least Python 3.13, the necessary PySide6, LMDB and Exiv2 dependencies and KDE development headers for Baloo installed on your system. For best integration install at least PySide6 in your system, but for better results install all required libraries on your system if they are available in your Linux distribution.
Bash
@@ -65,12 +77,12 @@ pip install -r requirements.txt
python bagheeraview.py
```
BagheeraSearch tool and librery are available at https://git.aynoa.net/ignacio/BagheeraSearch.git
BagheeraSearch tool and library are available at https://git.aynoa.net/ignacio/BagheeraSearch.git
## 📥 Installation (Production with BagheeraSearch)
## 📥 Recomended Installation (Production with BagheeraSearch)
Ensure you have at least Python 3.13, the necessary PySide6 dependencies and KDE development headers for Baloo installed on your system. For best integration install at least PySide6 in your system but for better results install all required libraries if they are available in your Linux distribution.
Ensure you have at least Python 3.13, the necessary PySide6, LMDB and Exiv2 dependencies and KDE development headers for Baloo installed on your system. For best integration install at least PySide6 in your system, but for better results install all required libraries on your system if they are available in your Linux distribution.
Bash
@@ -94,9 +106,9 @@ pip install . /tmp/BagheeraView
python bagheeraview.py
```
## 📥 Installation (Production without BagheeraSearch)
## 📥 Alternative Installation (Production without BagheeraSearch)
Ensure you have at least Python 3.13, the necessary PySide6 dependencies and KDE development headers for Baloo installed on your system. For best integration install at least PySide6 in your system but for better results install all required libraries if they are available in your Linux distribution.
Ensure you have at least Python 3.13, the necessary PySide6, LMDB and Exiv2 dependencies and KDE development headers for Baloo installed on your system. For best integration install at least PySide6 in your system, but for better results install all required libraries on your system if they are available in your Linux distribution.
Bash

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,7 @@
¿Sería posible añadir una opción para limpiar automáticamente los hashes de archivos que ya no existen sin borrar toda la base de datos?
¿Podrías optimizar el proceso de borrado en lote para que sea más eficiente si hay miles de entradas que limpiar?
Implement a bulk rename feature for the selected pet or face tags.
Add a visual indicator (e.g., an icon in the status bar) for the "Link Panes" status instead of text.
@@ -51,6 +55,7 @@ How can I implement a bulk rename feature for the selected pet or face tags?
¿Cómo puedo añadir una opción "Abrir con otra aplicación..." al final del submenú que lance el selector de aplicaciones del sistema?
¿Cómo puedo añadir soporte para arrastrar y soltar imágenes desde el visor (ImageViewer) a otras aplicaciones?
· Añadir una opción al menú de contexto para "Abrir con el visor estándar de Bagheera" para ver la imagen a pantalla completa.
¿Por qué la selección de imágenes se pierde o cambia incorrectamente al cambiar el modo de agrupación?
@@ -62,6 +67,18 @@ Ahora que la carga es rápida, ¿cómo puedo implementar una precarga inteligent
¿Cómo puedo hacer que la selección de archivos sea persistente incluso después de recargar o filtrar la vista?
v0.9.18 -
· Better messages
v0.9.17 -
· Fixes
v0.9.16 -
· Fixes
v0.9.15 -
· Duplicates
v0.9.14 -
· Corregido el problema de resolución de los thumbnails

View File

@@ -29,7 +29,7 @@ if FORCE_X11:
# --- CONFIGURATION ---
PROG_NAME = "Bagheera Image Viewer"
PROG_ID = "bagheeraview"
PROG_VERSION = "0.9.15"
PROG_VERSION = "0.9.26"
PROG_AUTHOR = "Ignacio Serantes"
# --- CACHE SETTINGS ---
@@ -57,16 +57,26 @@ DISK_CACHE_MAX_BYTES = 10 * 1024 * 1024 * 1024
# --- PATHS ---
CONFIG_FILE = f"{PROG_ID}rc"
CONFIG_LOCATION = '.config/iserantes'
CONFIG_DIR = os.path.join(os.path.expanduser("~"), CONFIG_LOCATION, PROG_ID)
CONFIG_LOCATION = os.environ.get('XDG_CONFIG_HOME')
CONFIG_DIR = os.path.join(CONFIG_LOCATION, 'iserantes', PROG_ID)
CONFIG_PATH = os.path.join(CONFIG_DIR, CONFIG_FILE)
CACHE_PATH = os.path.join(CONFIG_DIR, "thumbnails_lmdb")
APP_DATA_LOCATION = os.path.expanduser('~/.local/share')
APP_DATA_DIR = os.path.join(APP_DATA_LOCATION, 'iserantes', PROG_ID)
CACHE_PATH = os.path.join(APP_DATA_DIR, "thumbnails")
HISTORY_FILE = "history.json"
HISTORY_PATH = os.path.join(CONFIG_DIR, HISTORY_FILE)
LAYOUTS_DIR = os.path.join(CONFIG_DIR, "layouts") # Layouts saving directory
HISTORY_PATH = os.path.join(APP_DATA_DIR, HISTORY_FILE)
LAYOUTS_DIR = os.path.join(APP_DATA_DIR, "layouts") # Layouts saving directory
FAVORITES_FILE = "favorites.json"
FAVORITES_PATH = os.path.join(CONFIG_DIR, FAVORITES_FILE)
FAVORITES_PATH = os.path.join(APP_DATA_DIR, FAVORITES_FILE)
DUPLICATE_CACHE_PATH = os.path.join(APP_DATA_DIR, "duplicates")
DUPLICATE_HASH_DB_NAME = b"hashes"
DUPLICATE_EXCEPTIONS_DB_NAME = b"exceptions"
DUPLICATE_PENDING_DB_NAME = b"pending"
DUPLICATE_BKTREE_DB_NAME = b"bktree"
DUPLICATE_HASH_TO_FILES_DB_NAME = b"hash_to_files"
def save_app_config():
@@ -76,9 +86,8 @@ def save_app_config():
with open(CONFIG_PATH, 'w', encoding='utf-8') as f:
# Use APP_CONFIG global
json.dump(APP_CONFIG, f, indent=4)
except OSError:
# Silently fail for now, but could log this
pass
except Exception as e:
print(f"CRITICAL: Failed to save configuration to {CONFIG_PATH}: {e}")
# --- CONFIGURATION LOADING ---
@@ -133,7 +142,18 @@ SCANNER_SETTINGS_DEFAULTS = {
"scan_full_on_start": True,
"person_tags": "",
"generation_threads": 4,
"search_engine": ""
"search_engine": "",
"face_use_last_name": False,
"pet_use_last_name": False,
"body_use_last_name": False,
"object_use_last_name": False,
"landmark_use_last_name": False,
"duplicate_threshold": 90, # Similarity percentage (50-100)
"duplicate_method": "histogram_hashing",
"duplicate_confirm_delete": True,
"default_delete_to_trash": True,
"duplicate_whitelist": "",
"duplicate_blacklist": ""
}
# --- IMAGE VIEWER DEFAULTS ---
@@ -190,15 +210,15 @@ if importlib.util.find_spec("mediapipe") is not None:
pass
HAVE_FACE_RECOGNITION = importlib.util.find_spec("face_recognition") is not None
MEDIAPIPE_FACE_MODEL_PATH = os.path.join(CONFIG_DIR,
"blaze_face_short_range.tflite")
MEDIAPIPE_FACE_MODEL_PATH = os.path.join(
APP_DATA_DIR, "blaze_face_short_range.tflite")
MEDIAPIPE_FACE_MODEL_URL = (
"https://storage.googleapis.com/mediapipe-models/face_detector/"
"blaze_face_short_range/float16/1/blaze_face_short_range.tflite"
)
MEDIAPIPE_OBJECT_MODEL_PATH = os.path.join(CONFIG_DIR,
"efficientdet_lite0.tflite")
MEDIAPIPE_OBJECT_MODEL_PATH = os.path.join(
APP_DATA_DIR, "efficientdet_lite0.tflite")
MEDIAPIPE_OBJECT_MODEL_URL = (
"https://storage.googleapis.com/mediapipe-models/object_detector/"
"efficientdet_lite0/float16/1/efficientdet_lite0.tflite"
@@ -224,6 +244,16 @@ if HAVE_MEDIAPIPE:
DEFAULT_FACE_ENGINE = AVAILABLE_FACE_ENGINES[0] if AVAILABLE_FACE_ENGINES else None
DEFAULT_PET_ENGINE = AVAILABLE_PET_ENGINES[0] if AVAILABLE_PET_ENGINES else None
HAVE_IMAGEHASH = importlib.util.find_spec("imagehash") is not None
# --- DUPLICATE DETECTION ---
HAVE_DUPLICATE_RESNET_LIBS = all(
importlib.util.find_spec(lib) is not None
for lib in ["torch", "torchvision", "numpy", "sklearn"]
)
MAX_DHASH_DISTANCE = 64 # For 64-bit dHash
DEFAULT_FACE_BOX_COLOR = "#FFFFFF"
# Load preferred engine from config, or use the default.
FACE_DETECTION_ENGINE = APP_CONFIG.get("face_detection_engine",
@@ -374,11 +404,13 @@ _UI_TEXTS = {
"SEARCH": "Search",
"SELECT": "Select",
"ERROR": "Error",
"FILE_NOT_FOUND": "File not found",
"WARNING": "Warning",
"INFO": "Info",
"LOAD": "Load",
"SAVE": "Save",
"CREATE": "Create",
"CANCEL": "Cancel",
"RENAME": "Rename",
"COPY": "Copy",
"DELETE": "Delete",
@@ -489,6 +521,80 @@ _UI_TEXTS = {
"MENU_SHOW_LAYOUTS": "Show Layouts",
"MENU_SHOW_HISTORY": "Show History",
"MENU_SETTINGS": "Settings",
"SETTINGS_GROUP_DUPLICATES": "Duplicates",
"MENU_DUPLICATES": "Duplicates",
"MENU_DETECT_CURRENT_SEARCH": "Detect in current search",
"MENU_DETECT_ALL": "Detect all",
"MENU_FORCE_FULL_ALL_ANALYSIS": "Force full all analysis",
"MENU_FORCE_FULL_ANALYSIS": "Force full analysis",
"MENU_REVIEW_IGNORED": "Review ignored",
"MENU_CLEAN_UP_HASHES": "Clean up",
"MENU_REPAIR_DATABASE": "Repair index",
"MENU_CLEAR_EXCEPTIONS": "Clear ignored pairs",
"CONFIRM_CLEAR_EXCEPTIONS_TITLE": "Confirm Clear Ignored Pairs",
"CONFIRM_CLEAR_EXCEPTIONS_TEXT": "Are you sure you want to clear all "
"ignored duplicate pairs? They will be detected again in the next scan.",
"REPAIRING_DATABASE": "Repairing duplicate index...",
"MENU_CLEAR_HASHES": "Clear hashes ({} items, {:.1f} MB on disk)",
"CONFIRM_CLEAR_HASHES_TITLE": "Confirm Clear Hashes",
"CONFIRM_CLEAR_HASHES_TEXT": "Are you sure you want to permanently delete "
"the entire hash database?",
"CONFIRM_CLEAR_HASHES_INFO": "This will remove all calculated image hashes. "
"They will be recalculated as you detect duplicates, which may be slow. This "
"action cannot be undone.",
"SETTINGS_DUPLICATE_METHOD_LABEL": "Method:",
"SETTINGS_DUPLICATE_METHOD_TOOLTIP": "Select the method for duplicate "
"detection.",
"METHOD_HISTOGRAM_HASHING": "Histogram + Hashing",
"METHOD_RESNET": "ResNet (AI Based)",
"SETTINGS_DUPLICATE_CONFIRM_DELETE_LABEL": "Confirm before deleting duplicates",
"SETTINGS_DUPLICATE_WHITELIST_LABEL": "Whitelist (folders to include):",
"SETTINGS_DUPLICATE_WHITELIST_TOOLTIP": "Comma-separated paths of folders to "
"scan when using 'Detect all'.",
"SETTINGS_DUPLICATE_BLACKLIST_LABEL": "Blacklist (folders to exclude):",
"SETTINGS_DUPLICATE_BLACKLIST_TOOLTIP": "Comma-separated paths of folders to "
"ignore during 'Detect all' scans.",
"SETTINGS_DUPLICATE_SCAN_COUNT_LABEL": "Images found for 'Detect all': {}",
"SETTINGS_DEFAULT_DELETE_TO_TRASH_LABEL": "Delete key sends to trash by "
"default",
"SETTINGS_DEFAULT_DELETE_TO_TRASH_TOOLTIP": "If checked, pressing the Delete "
"key will move files to trash. If unchecked, it will permanently delete them.",
"SETTINGS_DUPLICATE_CONFIRM_DELETE_TOOLTIP": "Show a confirmation dialog "
"before moving a duplicate image to the trash.",
"SETTINGS_DUPLICATE_THRESHOLD_LABEL": "Similarity Threshold:",
"SETTINGS_DUPLICATE_THRESHOLD_TOOLTIP": "Set the similarity threshold 2 "
"(50-100%). Higher values mean images must be more similar to be considered "
"duplicates.",
"SETTINGS_DUPLICATE_MISSING_LIBS": "The 'imagehash' library is required for "
"duplicate detection but was not found. This feature is disabled.",
"MENU_DETECT_DUPLICATES": "Detect Duplicates",
"DUPLICATE_WHITELIST_EMPTY": "Whitelist is empty. Please configure it "
"in Settings.",
"DUPLICATE_DETECTION_TITLE": "Duplicate Detection",
"DUPLICATE_ALREADY_RUNNING": "Duplicate detection is already in progress.",
"DUPLICATE_NO_IMAGES": "No images loaded to detect duplicates.",
"DUPLICATE_STARTING": "Starting duplicate detection...",
"DUPLICATE_PROGRESS": "Duplicate detection: {message} ({current}/{total})",
"DUPLICATE_NONE_FOUND": "No duplicates found.",
"DUPLICATE_FOUND_TITLE": "Duplicates Found",
"DUPLICATE_FOUND_MSG": "The following duplicates were found:\n",
"DUPLICATE_FOUND_MORE": "... and {count} more.",
"DUPLICATE_FINISHED": "Duplicate detection finished.",
"DUPLICATE_MSG_HASHING": "Hashing {filename}",
"DUPLICATE_MSG_ANALYZING": "Analyzing {filename}",
"DUPLICATE_MANAGER_TITLE": "Manage Duplicate Images",
"DUPLICATE_DELETE_LEFT": "Trash Left",
"DUPLICATE_DELETE_RIGHT": "Trash Right",
"CONFIRM_TRASH_TITLE": "Move to Trash",
"CONFIRM_TRASH_TEXT": "Do you want to move this image to the trash?",
"DUPLICATE_KEEP_BOTH": "Keep Both (Ignore)",
"DUPLICATE_SKIP": "Skip",
"DUPLICATE_REMOVE_IGNORED": "Remove from ignored",
"DUPLICATE_INFO_FORMAT": "{size} - {width}x{height}",
"VIEWER_MENU_LINK_PANES": "Link Panes",
"DUPLICATE_OPEN_COMPARISON": "Open Comparison",
"DUPLICATE_LIST_HEADER": "Duplicate Pairs",
"IGNORED_DATE": "Ignored Date",
"SETTINGS_GROUP_SCANNER": "Scanner",
"SETTINGS_GROUP_AREAS": "Areas",
"SETTINGS_GROUP_THUMBNAILS": "Thumbnails",
@@ -544,6 +650,11 @@ _UI_TEXTS = {
"landmarks.",
"SETTINGS_LANDMARK_HISTORY_TOOLTIP": "Maximum number of recently used "
"landmark names to remember.",
"SETTINGS_PATH_NOT_FOUND_WARNING": "Warning: Path not found or is not "
"a directory: {}",
"SETTINGS_USE_LAST_NAME_LABEL": "Use last name by default",
"SETTINGS_USE_LAST_NAME_TOOLTIP": "Automatically fill the assignment window "
"with the last used name.",
"SETTINGS_FACE_HISTORY_COUNT_LABEL": "Max face history:",
"SETTINGS_THUMBS_REFRESH_LABEL": "Thumbs refresh interval (ms):",
"MENU_VIEWER_SETTINGS": "Viewer Settings",
@@ -684,6 +795,8 @@ _UI_TEXTS = {
"RENAME_ERROR_EXISTS": "File '{}' already exists.",
"FILE_RENAMED": "File renamed to {}",
"ERROR_RENAME": "Could not rename file: {}",
"ERROR_JPEG_METADATA_LIMIT": "Metadata size limit exceeded for '{}'. This "
"JPEG file has too much existing metadata (XMP) to save more.",
"MAIN_DOCK_TITLE": "",
"LAYOUTS_TAB": "Layouts",
"LAYOUTS_TABLE_HEADER": ["Name", "Last Modified"],
@@ -720,6 +833,8 @@ _UI_TEXTS = {
"TAG_ALL_TAGS": "📂 ALL TAGS",
"TAG_NEW_TAG_TITLE": "New Tag",
"SEARCH_BY_TAG": "Search by this tag",
"TAG_ADD_TOOLTIP": "Create a new tag",
"TAG_REFRESH_TOOLTIP": "Refresh available tags from Baloo database",
"TAG_NEW_TAG_TEXT": "Enter tag name (use / for hierarchy):",
"SEARCH_ADD_AND": "Add AND this tag to search",
"SEARCH_ADD_OR": "Add OR this tag to search",
@@ -754,6 +869,7 @@ _UI_TEXTS = {
"PROPERTIES_TABLE_HEADER": ["Property", "Value"],
"PROPERTIES_ADD_ATTR": "Add Attribute",
"PROPERTIES_ADD_ATTR_NAME": "Attribute Name (e.g. user.comment):",
"PROPERTIES_DELETE_ALL": "Delete All",
"PROPERTIES_ADD_ATTR_VALUE": "Value for {}:",
"PROPERTIES_ERROR_SET_ATTR": "Failed to set xattr: {}",
"PROPERTIES_ERROR_ADD_ATTR": "Failed to add xattr: {}",
@@ -806,6 +922,7 @@ _UI_TEXTS = {
"CONTEXT_MENU_OPEN": "Open",
"CONTEXT_MENU_OPEN_SEARCH_LOCATION": "Open and search location",
"CONTEXT_MENU_OPEN_DEFAULT_APP": "Open location with default application",
"CONTEXT_MENU_FULLSCREEN_VIEWER": "Open in Fullscreen Viewer",
"CONTEXT_MENU_MOVE_TO": "Move to...",
"CONTEXT_MENU_COPY_TO": "Copy to...",
"CONTEXT_MENU_ROTATE": "Rotate",
@@ -839,11 +956,13 @@ _UI_TEXTS = {
"SEARCH": "Buscar",
"SELECT": "Seleccionar",
"ERROR": "Error",
"FILE_NOT_FOUND": "Archivo no encontrado",
"WARNING": "Advertencia",
"INFO": "Información",
"LOAD": "Cargar",
"SAVE": "Guardar",
"CREATE": "Crear",
"CANCEL": "Cancelar",
"RENAME": "Renombrar",
"COPY": "Copiar",
"DELETE": "Eliminar",
@@ -954,6 +1073,84 @@ _UI_TEXTS = {
"MENU_SHOW_LAYOUTS": "Mostrar Diseños",
"MENU_SHOW_HISTORY": "Mostrar Historial",
"MENU_SETTINGS": "Opciones",
"SETTINGS_GROUP_DUPLICATES": "Duplicados",
"MENU_DUPLICATES": "Duplicados",
"MENU_DETECT_CURRENT_SEARCH": "Detectar en búsqueda actual",
"MENU_DETECT_ALL": "Detectar todos",
"MENU_FORCE_FULL_ALL_ANALYSIS": "Forzar análisis completo de todo",
"MENU_FORCE_FULL_ANALYSIS": "Forzar análisis completo",
"MENU_REVIEW_IGNORED": "Revisar ignorados",
"MENU_CLEAN_UP_HASHES": "Limpiar",
"MENU_REPAIR_DATABASE": "Reparar índice",
"MENU_CLEAR_EXCEPTIONS": "Limpiar parejas ignoradas",
"CONFIRM_CLEAR_EXCEPTIONS_TITLE": "Confirmar Limpieza de Ignorados",
"CONFIRM_CLEAR_EXCEPTIONS_TEXT": "¿Seguro que quieres borrar todas las parejas "
"de duplicados ignoradas? Se volverán a detectar en el próximo escaneo.",
"REPAIRING_DATABASE": "Reparando índice de duplicados...",
"MENU_CLEAR_HASHES": "Limpiar hashes ({} ítems, {:.1f} MB en disco)",
"CONFIRM_CLEAR_HASHES_TITLE": "Confirmar Limpieza de Hashes",
"CONFIRM_CLEAR_HASHES_TEXT": "¿Seguro que quieres eliminar permanentemente "
"toda la base de datos de hashes?",
"CONFIRM_CLEAR_HASHES_INFO": "Esto eliminará todos los hashes de imágenes "
"calculados. Se recalcularán a medida que detectes duplicados, lo que puede "
"ser lento. Esta acción no se puede deshacer.",
"SETTINGS_DUPLICATE_METHOD_LABEL": "Método:",
"SETTINGS_DUPLICATE_METHOD_TOOLTIP": "Selecciona el método para la detección "
"de duplicados.",
"METHOD_HISTOGRAM_HASHING": "Histograma + Hashing",
"METHOD_RESNET": "ResNet (Basado en IA)",
"SETTINGS_DUPLICATE_CONFIRM_DELETE_LABEL": "Confirmar antes de borrar "
"duplicados",
"SETTINGS_DUPLICATE_WHITELIST_LABEL": "Lista blanca (carpetas a incluir):",
"SETTINGS_DUPLICATE_WHITELIST_TOOLTIP": "Rutas de carpetas separadas por comas "
"para escanear al usar 'Detectar todos'.",
"SETTINGS_DUPLICATE_BLACKLIST_LABEL": "Lista negra (carpetas a excluir):",
"SETTINGS_DUPLICATE_BLACKLIST_TOOLTIP": "Rutas de carpetas separadas por comas "
"para ignorar durante escaneos de 'Detectar todos'.",
"SETTINGS_DUPLICATE_SCAN_COUNT_LABEL": "Imágenes encontradas para 'Detectar "
"todos': {}",
"SETTINGS_DEFAULT_DELETE_TO_TRASH_LABEL": "La tecla Supr envía a la papelera "
"por defecto",
"SETTINGS_DEFAULT_DELETE_TO_TRASH_TOOLTIP": "Si está marcada, al pulsar la "
"tecla Supr se moverán los archivos a la papelera. Si no, se eliminarán "
"permanentemente.",
"SETTINGS_DUPLICATE_CONFIRM_DELETE_TOOLTIP": "Muestra un diálogo de "
"confirmación antes de mover una imagen duplicada a la papelera.",
"SETTINGS_DUPLICATE_THRESHOLD_LABEL": "Umbral de Similitud:",
"SETTINGS_DUPLICATE_THRESHOLD_TOOLTIP": "Establece el umbral de similitud "
"(50-100%). Valores más altos significan que las imágenes deben ser más "
"parecidas para considerarse duplicadas.",
"SETTINGS_DUPLICATE_MISSING_LIBS": "La librería 'imagehash' es necesaria "
"para la detección de duplicados pero no se ha encontrado. Esta función "
"está desactivada.",
"MENU_DETECT_DUPLICATES": "Detectar Duplicados",
"DUPLICATE_WHITELIST_EMPTY": "La lista blanca está vacía. Por favor, "
"configúrela en Opciones.",
"DUPLICATE_DETECTION_TITLE": "Detección de Duplicados",
"DUPLICATE_ALREADY_RUNNING": "La detección de duplicados ya está en curso.",
"DUPLICATE_NO_IMAGES": "No hay imágenes cargadas para detectar duplicados.",
"DUPLICATE_STARTING": "Iniciando detección de duplicados...",
"DUPLICATE_PROGRESS": "Detección de duplicados: {message} ({current}/{total})",
"DUPLICATE_NONE_FOUND": "No se encontraron duplicados.",
"DUPLICATE_FOUND_TITLE": "Duplicados Encontrados",
"DUPLICATE_FOUND_MSG": "Se encontraron los siguientes duplicados:\n",
"DUPLICATE_FOUND_MORE": "... y {count} más.",
"DUPLICATE_FINISHED": "Detección de duplicados finalizada.",
"DUPLICATE_MSG_HASHING": "Procesando {filename}",
"DUPLICATE_MSG_ANALYZING": "Analizando {filename}",
"DUPLICATE_MANAGER_TITLE": "Gestionar Imágenes Duplicadas",
"DUPLICATE_DELETE_LEFT": "Papelera Izquierda",
"DUPLICATE_DELETE_RIGHT": "Papelera Derecha",
"CONFIRM_TRASH_TITLE": "Mover a la papelera",
"CONFIRM_TRASH_TEXT": "¿Deseas mover esta imagen a la papelera?",
"DUPLICATE_KEEP_BOTH": "Mantener Ambas (Ignorar)",
"DUPLICATE_SKIP": "Omitir",
"DUPLICATE_REMOVE_IGNORED": "Eliminar de ignorados",
"DUPLICATE_INFO_FORMAT": "{size} - {width}x{height}",
"VIEWER_MENU_LINK_PANES": "Vincular Paneles",
"DUPLICATE_OPEN_COMPARISON": "Abrir Comparación",
"DUPLICATE_LIST_HEADER": "Parejas Duplicadas",
"IGNORED_DATE": "Fecha Ignorado",
"SETTINGS_GROUP_SCANNER": "Escáner",
"SETTINGS_GROUP_AREAS": "Áreas",
"SETTINGS_GROUP_THUMBNAILS": "Miniaturas",
@@ -1015,6 +1212,11 @@ _UI_TEXTS = {
"alrededor de los lugares.",
"SETTINGS_LANDMARK_HISTORY_TOOLTIP": "Número máximo de nombres de lugares "
"usados recientemente para recordar.",
"SETTINGS_PATH_NOT_FOUND_WARNING": "Advertencia: La ruta no existe o "
"no es un directorio: {}",
"SETTINGS_USE_LAST_NAME_LABEL": "Usar último nombre por defecto",
"SETTINGS_USE_LAST_NAME_TOOLTIP": "Rellena automáticamente la ventana de "
"asignación con el último nombre utilizado.",
"SETTINGS_FACE_HISTORY_COUNT_LABEL": "Máximo historial de caras:",
"SETTINGS_THUMBS_REFRESH_LABEL": "Intervalo refresco miniaturas (ms):",
"SETTINGS_THUMBS_BG_COLOR_LABEL": "Color de fondo de miniaturas:",
@@ -1155,6 +1357,8 @@ _UI_TEXTS = {
"RENAME_ERROR_EXISTS": "El archivo '{}' ya existe.",
"FILE_RENAMED": "Archivo renombrado a {}",
"ERROR_RENAME": "No se pudo renombrar el archivo: {}",
"ERROR_JPEG_METADATA_LIMIT": "Límite de metadatos excedido para '{}'. Este "
"archivo JPEG ya tiene demasiados metadatos (XMP) para guardar más.",
"MAIN_DOCK_TITLE": "Panel principal",
"LAYOUTS_TAB": "Diseños",
"LAYOUTS_TABLE_HEADER": ["Nombre", "Última Modificación"],
@@ -1191,6 +1395,9 @@ _UI_TEXTS = {
"TAG_ALL_TAGS": "📂 TODAS LAS ETIQUETAS",
"TAG_NEW_TAG_TITLE": "Nueva Etiqueta",
"SEARCH_BY_TAG": "Buscar por esta etiqueta",
"TAG_ADD_TOOLTIP": "Crear una nueva etiqueta",
"TAG_REFRESH_TOOLTIP": "Refrescar etiquetas disponibles desde el base de datos "
"de Baloo",
"TAG_NEW_TAG_TEXT": "Introduce el nombre de la etiqueta (usa / para "
"jerarquía):",
"SEARCH_ADD_AND": "Añadir AND esta etiqueta a la búsqueda",
@@ -1226,6 +1433,7 @@ _UI_TEXTS = {
"PROPERTIES_TABLE_HEADER": ["Propiedad", "Valor"],
"PROPERTIES_ADD_ATTR": "Añadir Atributo",
"PROPERTIES_ADD_ATTR_NAME": "Nombre del Atributo (ej. user.comment):",
"PROPERTIES_DELETE_ALL": "Borrar Todo",
"PROPERTIES_ADD_ATTR_VALUE": "Valor para {}:",
"PROPERTIES_ERROR_SET_ATTR": "Fallo al establecer xattr: {}",
"PROPERTIES_ERROR_ADD_ATTR": "Fallo al añadir xattr: {}",
@@ -1278,6 +1486,7 @@ _UI_TEXTS = {
"CONTEXT_MENU_OPEN": "Abrir",
"CONTEXT_MENU_OPEN_SEARCH_LOCATION": "Abrir y buscar en ubicación",
"CONTEXT_MENU_OPEN_DEFAULT_APP": "Abrir ubicación con aplicación por defecto",
"CONTEXT_MENU_FULLSCREEN_VIEWER": "Abrir con Visor a Pantalla Completa",
"CONTEXT_MENU_MOVE_TO": "Mover a...",
"CONTEXT_MENU_COPY_TO": "Copiar a...",
"CONTEXT_MENU_ROTATE": "Girar",
@@ -1312,11 +1521,13 @@ _UI_TEXTS = {
"SEARCH": "Buscar",
"SELECT": "Seleccionar",
"ERROR": "Erro",
"FILE_NOT_FOUND": "Ficheiro non atopado",
"WARNING": "Advertencia",
"INFO": "Información",
"LOAD": "Cargar",
"SAVE": "Gardar",
"CREATE": "Crear",
"CANCEL": "Cancelar",
"RENAME": "Renomear",
"COPY": "Copiar",
"DELETE": "Eliminar",
@@ -1428,6 +1639,83 @@ _UI_TEXTS = {
"MENU_SHOW_LAYOUTS": "Amosar Deseños",
"MENU_SHOW_HISTORY": "Amosar Historial",
"MENU_SETTINGS": "Opcións",
"SETTINGS_GROUP_DUPLICATES": "Duplicados",
"MENU_DUPLICATES": "Duplicados",
"MENU_DETECT_CURRENT_SEARCH": "Detectar na busca actual",
"MENU_DETECT_ALL": "Detectar todos",
"MENU_FORCE_FULL_ALL_ANALYSIS": "Forzar análise completa de todo",
"MENU_FORCE_FULL_ANALYSIS": "Forzar análise completa",
"MENU_REVIEW_IGNORED": "Revisar ignorados",
"MENU_CLEAN_UP_HASHES": "Limpar",
"MENU_REPAIR_DATABASE": "Reparar índice",
"MENU_CLEAR_EXCEPTIONS": "Limpar parellas ignoradas",
"CONFIRM_CLEAR_EXCEPTIONS_TITLE": "Confirmar Limpeza de Ignorados",
"CONFIRM_CLEAR_EXCEPTIONS_TEXT": "Seguro que queres borrar todas as parellas "
"de duplicados ignoradas? Volveranse detectar no vindeiro escaneo.",
"REPAIRING_DATABASE": "Reparando índice de duplicados...",
"MENU_CLEAR_HASHES": "Limpar hashes ({} elementos, {:.1f} MB en disco)",
"CONFIRM_CLEAR_HASHES_TITLE": "Confirmar Limpeza de Hashes",
"CONFIRM_CLEAR_HASHES_TEXT": "Seguro que queres eliminar permanentemente toda "
"a base de datos de hashes?",
"CONFIRM_CLEAR_HASHES_INFO": "Isto eliminará todos os hashes de imaxes "
"calculados. Rexeneraranse a medida que detectes duplicados, o que pode ser "
"lento. Esta acción non se pode deshacer.",
"SETTINGS_DUPLICATE_METHOD_LABEL": "Método:",
"SETTINGS_DUPLICATE_METHOD_TOOLTIP": "Selecciona o método para a detección "
"de duplicados.",
"METHOD_HISTOGRAM_HASHING": "Histograma + Hashing",
"METHOD_RESNET": "ResNet (Baseado en IA)",
"SETTINGS_DUPLICATE_CONFIRM_DELETE_LABEL": "Confirmar antes de borrar "
"duplicados",
"SETTINGS_DUPLICATE_WHITELIST_LABEL": "Lista branca (cartafoles a incluír):",
"SETTINGS_DUPLICATE_WHITELIST_TOOLTIP": "Rutas de cartafoles separadas por "
"comas para escanear ao usar 'Detectar todos'.",
"SETTINGS_DUPLICATE_BLACKLIST_LABEL": "Lista negra (cartafoles a excluír):",
"SETTINGS_DUPLICATE_BLACKLIST_TOOLTIP": "Rutas de cartafoles separadas por "
"comas para ignorar durante escaneos de 'Detectar todos'.",
"SETTINGS_DUPLICATE_SCAN_COUNT_LABEL": "Imaxes atopadas para 'Detectar "
"todos': {}",
"SETTINGS_DEFAULT_DELETE_TO_TRASH_LABEL": "A tecla Supr envía á papeleira por "
"defecto",
"SETTINGS_DEFAULT_DELETE_TO_TRASH_TOOLTIP": "Se está marcada, ao premer a "
"tecla Supr moveranse os ficheiros á papeleira. Se non, eliminaranse "
"permanentemente.",
"SETTINGS_DUPLICATE_CONFIRM_DELETE_TOOLTIP": "Amosa un diálogo de confirmación "
"antes de mover unha imaxe duplicada á papeleira.",
"SETTINGS_DUPLICATE_THRESHOLD_LABEL": "Umbral de Similitude:",
"SETTINGS_DUPLICATE_THRESHOLD_TOOLTIP": "Establece o umbral de similitude "
"(50-100%). Valores máis altos significan que as imaxes deben ser máis "
"parecidas para considerarse duplicadas.",
"SETTINGS_DUPLICATE_MISSING_LIBS": "A librería 'imagehash' é necesaria para a "
"detección de duplicados pero non se atopou. Esta función está desactivada.",
"MENU_DETECT_DUPLICATES": "Detectar Duplicados",
"DUPLICATE_WHITELIST_EMPTY": "A lista branca está baleira. Por favor, "
"configúrea en Opcións.",
"DUPLICATE_DETECTION_TITLE": "Detección de Duplicados",
"DUPLICATE_ALREADY_RUNNING": "A detección de duplicados xa está en curso.",
"DUPLICATE_NO_IMAGES": "Non hai imaxes cargadas para detectar duplicados.",
"DUPLICATE_STARTING": "Iniciando detección de duplicados...",
"DUPLICATE_PROGRESS": "Detección de duplicados: {message} ({current}/{total})",
"DUPLICATE_NONE_FOUND": "Non se atoparon duplicados.",
"DUPLICATE_FOUND_TITLE": "Duplicados Atopados",
"DUPLICATE_FOUND_MSG": "Atopáronse os seguintes duplicados:\n",
"DUPLICATE_FOUND_MORE": "... e {count} máis.",
"DUPLICATE_FINISHED": "Detección de duplicados finalizada.",
"DUPLICATE_MSG_HASHING": "Procesando {filename}",
"DUPLICATE_MSG_ANALYZING": "Analizando {filename}",
"DUPLICATE_MANAGER_TITLE": "Xestionar Imaxes Duplicadas",
"DUPLICATE_DELETE_LEFT": "Papeleira Esquerda",
"DUPLICATE_DELETE_RIGHT": "Papeleira Dereita",
"CONFIRM_TRASH_TITLE": "Mover á papeleira",
"CONFIRM_TRASH_TEXT": "Desexas mover esta imaxe á papeleira?",
"DUPLICATE_KEEP_BOTH": "Manter Ambas (Ignorar)",
"DUPLICATE_SKIP": "Omitir",
"DUPLICATE_REMOVE_IGNORED": "Eliminar de ignorados",
"DUPLICATE_INFO_FORMAT": "{size} - {width}x{height}",
"VIEWER_MENU_LINK_PANES": "Vincular Paneis",
"DUPLICATE_OPEN_COMPARISON": "Abrir Comparación",
"DUPLICATE_LIST_HEADER": "Parellas Duplicadas",
"IGNORED_DATE": "Data Ignorado",
"SETTINGS_GROUP_SCANNER": "Escáner",
"SETTINGS_GROUP_AREAS": "Áreas",
"SETTINGS_GROUP_THUMBNAILS": "Miniaturas",
@@ -1489,6 +1777,11 @@ _UI_TEXTS = {
"arredor dos lugares.",
"SETTINGS_LANDMARK_HISTORY_TOOLTIP": "Número máximo de nomes de lugares "
"usados recentemente para lembrar.",
"SETTINGS_PATH_NOT_FOUND_WARNING": "Advertencia: A ruta non existe ou "
"non é un directorio: {}",
"SETTINGS_USE_LAST_NAME_LABEL": "Usar o último nome por defecto",
"SETTINGS_USE_LAST_NAME_TOOLTIP": "Rechea automáticamente a ventá de "
"asignación có último nome utilizado.",
"SETTINGS_FACE_HISTORY_COUNT_LABEL": "Máximo historial de caras:",
"SETTINGS_THUMBS_REFRESH_LABEL": "Intervalo refresco miniaturas (ms):",
"SETTINGS_THUMBS_BG_COLOR_LABEL": "Cor de fondo de miniaturas:",
@@ -1628,6 +1921,8 @@ _UI_TEXTS = {
"RENAME_ERROR_EXISTS": "O ficheiro '{}' xa existe.",
"FILE_RENAMED": "Ficheiro renomeado a {}",
"ERROR_RENAME": "Non se puido renomear o ficheiro: {}",
"ERROR_JPEG_METADATA_LIMIT": "Límite de metadatos excedido para '{}'. Este "
"ficheiro JPEG xa ten demasiados metadatos (XMP) para gardar máis.",
"MAIN_DOCK_TITLE": "Panel principal",
"LAYOUTS_TAB": "Deseños",
"LAYOUTS_TABLE_HEADER": ["Nome", "Última Modificación"],
@@ -1664,6 +1959,9 @@ _UI_TEXTS = {
"TAG_ALL_TAGS": "📂 TÓDALAS ETIQUETAS",
"TAG_NEW_TAG_TITLE": "Nova Etiqueta",
"SEARCH_BY_TAG": "Buscar por esta etiqueta",
"TAG_ADD_TOOLTIP": "Crear unha nova etiqueta",
"TAG_REFRESH_TOOLTIP": "Refrescar etiquetas dispoñibles dende a base de datos "
"de Baloo",
"TAG_NEW_TAG_TEXT": "Introduce o nome da etiqueta (usa / para "
"xerarquía):",
"SEARCH_ADD_AND": "Engadir AND esta etiqueta á busca",
@@ -1699,6 +1997,7 @@ _UI_TEXTS = {
"PROPERTIES_TABLE_HEADER": ["Propiedade", "Valor"],
"PROPERTIES_ADD_ATTR": "Engadir Atributo",
"PROPERTIES_ADD_ATTR_NAME": "Nome do Atributo (ex. user.comment):",
"PROPERTIES_DELETE_ALL": "Borrar Todo",
"PROPERTIES_ADD_ATTR_VALUE": "Valor para {}:",
"PROPERTIES_ERROR_SET_ATTR": "Fallo ao establecer xattr: {}",
"PROPERTIES_ERROR_ADD_ATTR": "Fallo ao engadir xattr: {}",
@@ -1762,6 +2061,7 @@ _UI_TEXTS = {
"CONTEXT_MENU_COPY_FILE": "Copiar URL do Ficheiro",
"CONTEXT_MENU_COPY_DIR": "Copiar Ruta do Directorio",
"CONTEXT_MENU_PROPERTIES": "Propiedades",
"CONTEXT_MENU_FULLSCREEN_VIEWER": "Abrir con Visor a Pantalla Completa",
"CONTEXT_MENU_NO_APPS_FOUND": "Non se atoparon aplicacións",
"CONTEXT_MENU_REGENERATE": "Rexenerar Miniatura",
"CONTEXT_MENU_ERROR_LISTING_APPS": "Erro listando aplicacións",
@@ -1786,6 +2086,7 @@ _UI_TEXTS = {
# Determine which language to use for UI strings
def _get_current_language():
"""Determines the language to use for UI strings based on environment."""
lang = os.getenv("BAGHEERA_LANG") or APP_CONFIG.get("language", DEFAULT_LANGUAGE)
if lang == "system":

1247
duplicatecache.py Normal file

File diff suppressed because it is too large Load Diff

1106
duplicatedialog.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,14 @@
"""
File System Watcher Module for Bagheera Image Viewer.
This module provides functionality to monitor file system changes in real-time
using the watchdog library. It notifies the application about new, deleted, or
modified image files within watched directories, handling debouncing to ensure
stability during rapid file operations.
Classes:
FileSystemWatcher: Coordinates file system monitoring and emits Qt signals.
"""
import os
try:
from watchdog.observers import Observer
@@ -14,20 +25,32 @@ class FileSystemWatcher(QObject):
Monitors file system events (created, deleted, modified) for specified directories.
Emits signals to notify the main application thread of changes.
"""
# Signals emitted to the rest of the application
# ---------------------------------------------
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
monitoring_status_changed = Signal(bool) # New: Signal for monitoring status
directory_moved = Signal(str, str)
directory_modified = Signal(str) # For changes that might not be specific files
_modified_events_queue = {} # {path: QTimer}
"""Queue to manage debouncing of modification events."""
def __init__(self, parent=None):
"""
Initializes the FileSystemWatcher.
Args:
parent (QObject, optional): The parent object. Defaults to None.
"""
super().__init__(parent)
self._watched_directories = set()
self._debounce_interval = 500 # milliseconds
if HAVE_WATCHDOG:
self._observer = Observer()
@@ -36,16 +59,21 @@ class FileSystemWatcher(QObject):
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."""
"""
Slot to handle modified events from the watchdog thread.
Implements a debouncing mechanism: if multiple modification events
arrive for the same path within the interval, previous timers are
reset to avoid redundant UI updates or heavy disk operations.
Args:
path (str): The path of the modified file.
"""
# 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()
@@ -59,7 +87,12 @@ class FileSystemWatcher(QObject):
self._modified_events_queue[path].start()
def _emit_modified_after_debounce(self, path):
"""Emits the file_modified signal after the debounce period."""
"""
Emits the file_modified signal after the debounce period.
Args:
path (str): The path of the modified file.
"""
self.file_modified.emit(path)
if path in self._modified_events_queue:
# Safely delete the QTimer object when done
@@ -67,7 +100,16 @@ class FileSystemWatcher(QObject):
del self._modified_events_queue[path]
def add_path(self, path):
"""Adds a directory to be monitored."""
"""
Adds a directory to be monitored.
This method ensures that redundant watches are avoided by checking if
the path is already covered by an existing watch or if it should
consolidate multiple sub-watches into a single parent watch.
Args:
path (str): The directory path to monitor.
"""
if not HAVE_WATCHDOG or self._observer is None:
return
@@ -111,7 +153,12 @@ class FileSystemWatcher(QObject):
self.monitoring_status_changed.emit(True)
def remove_path(self, path):
"""Removes a directory from monitoring."""
"""
Removes a directory from monitoring.
Args:
path (str): The directory path to stop monitoring.
"""
if not HAVE_WATCHDOG or self._observer is None:
return
abs_path = os.path.normpath(os.path.abspath(os.path.expanduser(path)))
@@ -138,11 +185,12 @@ class FileSystemWatcher(QObject):
self.monitoring_status_changed.emit(False)
def stop(self):
"""Stops the file system observer."""
"""
Stops the file system observer and cleans up active timers.
"""
if HAVE_WATCHDOG and self._observer:
self._observer.stop()
self._observer.join()
for timer in self._modified_events_queue.values():
timer.stop()
@@ -151,14 +199,24 @@ class FileSystemWatcher(QObject):
if HAVE_WATCHDOG:
class _Handler(FileSystemEventHandler):
"""
Custom event handler for watchdog events.
Translates low-level file system events into high-level application
signals, filtering for supported image types.
"""
# Signal to communicate to main thread
file_modified_from_thread = Signal(str)
"""Custom event handler for watchdog events."""
def __init__(self, watcher):
"""
Initializes the handler with a reference to the main watcher.
"""
super().__init__()
self.watcher = watcher
def on_created(self, event):
"""Called when a file or directory is created."""
if event.is_directory:
self.watcher.directory_modified.emit(event.src_path)
return
@@ -166,6 +224,7 @@ class FileSystemWatcher(QObject):
self.watcher.file_created.emit(event.src_path)
def on_deleted(self, event):
"""Called when a file or directory is deleted."""
if event.is_directory:
self.watcher.directory_modified.emit(event.src_path)
return
@@ -173,6 +232,7 @@ class FileSystemWatcher(QObject):
self.watcher.file_deleted.emit(event.src_path)
def on_moved(self, event):
"""Called when a file or directory is moved or renamed."""
if event.is_directory:
self.watcher.directory_moved.emit(event.src_path, event.dest_path)
self.watcher.directory_modified.emit(event.src_path)
@@ -181,6 +241,7 @@ class FileSystemWatcher(QObject):
self.watcher.file_moved.emit(event.src_path, event.dest_path)
def on_closed(self, event):
"""Called when a file is closed."""
if event.is_directory:
self.watcher.directory_modified.emit(event.src_path)
return
@@ -188,6 +249,7 @@ class FileSystemWatcher(QObject):
self.watcher.file_modified.emit(event.src_path)
def on_modified(self, event):
"""Called when a file or directory is modified."""
if event.is_directory:
self.watcher.directory_modified.emit(event.src_path)
return
@@ -195,9 +257,21 @@ class FileSystemWatcher(QObject):
self.watcher._file_modified_from_handler.emit(event.src_path)
def _emit_modified(self, path):
"""
Internal helper to emit the modified signal.
Args:
path (str): The modified 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):
"""
Checks if a given path has a supported image extension.
Args:
path (str): The file path to check.
"""
return os.path.splitext(path)[1].lower() in IMAGE_EXTENSIONS

View File

@@ -13,7 +13,7 @@ Classes:
import os
import logging
import math
from PySide6.QtCore import QThread, Signal, QMutex, QWaitCondition, QObject, Qt
from PySide6.QtCore import QThread, Signal, QMutex, QWaitCondition, QObject, Qt, QSize
from PySide6.QtGui import QImage, QImageReader, QPixmap, QTransform
from xmpmanager import XmpManager
from constants import (
@@ -42,6 +42,7 @@ class ImagePreloader(QThread):
def __init__(self):
"""Initializes the preloader thread."""
super().__init__()
self.setObjectName("ImagePreloaderThread")
self.path = None
self.index = -1
self.mutex = QMutex()
@@ -344,7 +345,7 @@ class ImageController(QObject):
faces_to_save.append(face_copy)
XmpManager.save_faces(path, faces_to_save)
return XmpManager.save_faces(path, faces_to_save)
def add_face(self, name, x, y, w, h, region_type="Face"):
"""Adds a new face. The full tag path should be passed as 'name'."""
@@ -389,8 +390,8 @@ class ImageController(QObject):
self.metadata_changed.emit(current_path,
{'tags': new_tags_list,
'rating': self._current_rating})
except IOError as e:
print(f"Error setting tags for {current_path}: {e}")
except Exception:
raise
def set_rating(self, new_rating):
current_path = self.get_current_path()
@@ -688,21 +689,36 @@ class ImageController(QObject):
if self.pixmap_original.isNull():
return QPixmap()
transform = QTransform().rotate(self.rotation)
transformed_pixmap = self.pixmap_original.transformed(
transform,
Qt.SmoothTransformation
)
new_size = transformed_pixmap.size() * self.zoom_factor
scaled_pixmap = transformed_pixmap.scaled(new_size, Qt.KeepAspectRatio,
Qt.SmoothTransformation)
# Start with an identity transform
transform = QTransform()
# Apply rotation
if self.rotation != 0:
transform.rotate(float(self.rotation))
# Apply flips
if self.flip_h:
scaled_pixmap = scaled_pixmap.transformed(QTransform().scale(-1, 1))
transform.scale(-1, 1)
if self.flip_v:
scaled_pixmap = scaled_pixmap.transformed(QTransform().scale(1, -1))
transform.scale(1, -1)
return scaled_pixmap
# Apply the cumulative transform to the original pixmap
transformed_pixmap = self.pixmap_original.transformed(
transform, Qt.TransformationMode.SmoothTransformation)
# Apply scaling (zoom) separately after rotation and flips,
# as scaling should be based on the *transformed* dimensions.
# This is important: if you scale before rotation, the scaling
# factors might be applied to the wrong axes.
if self.zoom_factor != 1.0:
new_size_f = transformed_pixmap.size() * self.zoom_factor
new_size = QSize(int(new_size_f.width()), int(new_size_f.height()))
scaled_pixmap = transformed_pixmap.scaled(
new_size, Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation)
return scaled_pixmap
else:
return transformed_pixmap
def rotate(self, angle):
"""

View File

@@ -32,11 +32,13 @@ from PySide6.QtCore import (
QObject, QThread, Signal, QMutex, QReadWriteLock, QSize, QSemaphore, QWaitCondition,
QByteArray, QBuffer, QIODevice, Qt, QTimer, QRunnable, QThreadPool, QFile
)
from PySide6.QtGui import QImage, QImageReader, QImageIOHandler
from PySide6.QtGui import QImage, QImageReader, QImageIOHandler, QIcon
from PySide6.QtWidgets import QApplication
from constants import (
APP_CONFIG, CACHE_PATH, CACHE_MAX_SIZE, CACHE_MAX_RAM_BYTES, CONFIG_DIR,
MIN_FREE_RAM_PERCENT, DISK_CACHE_MAX_BYTES, HAVE_BAGHEERASEARCH_LIB, IMAGE_EXTENSIONS,
APP_CONFIG, CACHE_PATH, CACHE_MAX_SIZE, CACHE_MAX_RAM_BYTES,
APP_DATA_DIR, MIN_FREE_RAM_PERCENT, DISK_CACHE_MAX_BYTES,
HAVE_BAGHEERASEARCH_LIB, IMAGE_EXTENSIONS,
SCANNER_SETTINGS_DEFAULTS, SEARCH_CMD, THUMBNAIL_SIZES,
UITexts
)
@@ -132,13 +134,11 @@ class ScannerWorker(QRunnable):
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:
if self._is_cancelled:
return
# Optimize: Open file once to reuse FD for stat and xattrs
fd = os.open(self.path, os.O_RDONLY)
stat_res = os.fstat(fd)
@@ -196,8 +196,11 @@ class ScannerWorker(QRunnable):
tags, rating = res_meta.tags, res_meta.rating
self.result = (self.path, smallest_thumb_for_signal,
curr_mtime, tags, rating, curr_inode, curr_dev)
except (FileNotFoundError, PermissionError) as e:
logger.debug(f"Skipping {self.path} due to access issue: {e}")
self.result = None
except Exception as e:
logger.error(f"Error processing image {self.path}: {e}")
logger.warning(f"Unexpected error processing image {self.path}: {e}")
self.result = None
finally:
if fd is not None:
@@ -265,7 +268,7 @@ def generate_thumbnail(path, size, fd=None):
# better quality for upscaling.
return img.scaled(size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation)
except Exception as e:
logger.error(f"Error generating thumbnail for {path}: {e}")
logger.debug(f"Could not generate thumbnail for {path}: {e}")
return None
@@ -283,6 +286,7 @@ class CacheWriter(QThread):
self._condition_new_data = QWaitCondition()
self._condition_space_available = QWaitCondition()
# Soft limit for blocking producers (background threads)
self.setObjectName("CacheWriterThread") # Add this line
self._max_size = 50
self._running = True
@@ -332,9 +336,9 @@ class CacheWriter(QThread):
self._running = False
# Do not clear the queue here; let the run loop drain it to prevent data loss.
self._condition_new_data.wakeAll()
logger.debug(f"{self.objectName()} stop requested, waking all.")
self._condition_space_available.wakeAll()
self._mutex.unlock()
self.wait()
def run(self):
self.setPriority(QThread.IdlePriority)
@@ -379,6 +383,7 @@ class CacheWriter(QThread):
self.cache._batch_write_to_lmdb(batch)
except Exception as e:
logger.error(f"CacheWriter batch write error: {e}")
logger.debug(f"{self.objectName()} run method exiting.")
class CacheLoader(QThread):
@@ -442,7 +447,6 @@ class CacheLoader(QThread):
self._mutex.lock()
self._condition.wakeAll()
self._mutex.unlock()
self.wait()
def run(self):
self.setPriority(QThread.IdlePriority)
@@ -522,15 +526,24 @@ class ThumbnailCache(QObject):
self._db_lock = QMutex() # Lock specifically for _db_handles access
self._db_handles = {} # Cache for LMDB database handles (dbi)
self._cancel_loading = False
self._broken_cache = {} # (dev, inode, size) -> (mtime, error_msg)
self._cache_bytes_size = 0
self._cache_writer = None
self._cache_loader = None
# Pre-generate broken images for standard tiers in the main thread
self._broken_images = {}
for size in THUMBNAIL_SIZES:
icon = QIcon.fromTheme("image-missing",
QIcon.fromTheme("broken-image",
QIcon.fromTheme("dialog-error")))
self._broken_images[size] = icon.pixmap(size, size).toImage()
self.lmdb_open()
def lmdb_open(self):
# Initialize LMDB environment
cache_dir = Path(CONFIG_DIR)
cache_dir = Path(APP_DATA_DIR)
cache_dir.mkdir(parents=True, exist_ok=True)
try:
@@ -558,12 +571,22 @@ class ThumbnailCache(QObject):
self._lmdb_env = None
def lmdb_close(self):
# Stop and wait for worker threads to ensure they are not accessing
# the LMDB environment while it's being closed.
if hasattr(self, '_cache_writer') and self._cache_writer:
self._cache_writer.stop()
while self._cache_writer.isRunning():
if QApplication.instance(): # Check if QApplication is still valid
QApplication.processEvents() # Keep UI responsive
QThread.msleep(50)
self._cache_writer = None
if hasattr(self, '_cache_loader') and self._cache_loader:
self._cache_loader.stop()
while self._cache_loader.isRunning():
if QApplication.instance(): # Check if QApplication is still valid
QApplication.processEvents() # Keep UI responsive
QThread.msleep(50)
self._cache_loader = None
self._loading_set.clear()
self._futures.clear()
@@ -658,8 +681,9 @@ class ThumbnailCache(QObject):
import psutil
mem = psutil.virtual_memory()
if (mem.available / mem.total) * 100 < MIN_FREE_RAM_PERCENT:
logger.warning(f"Low system memory detected (< {MIN_FREE_RAM_PERCENT}%). "
"Applying aggressive tiered pruning.")
logger.warning(f"Low system memory detected "
f"(< {MIN_FREE_RAM_PERCENT}%). "
f"Applying aggressive tiered pruning.")
# Strategy: first clear ALL cached high-res tiers to free space quickly
# while keeping the 128px grid thumbnails intact.
@@ -721,12 +745,28 @@ class ThumbnailCache(QObject):
def _get_tier_for_size(self, requested_size):
"""Determines the ideal thumbnail tier based on the requested size."""
if requested_size < 192:
if requested_size <= 128:
return 128
if requested_size < 320:
if requested_size <= 256:
return 256
return 512
def mark_broken(self, path, size, mtime, inode, dev_id, error_msg):
"""Marks a thumbnail load as failed with a message."""
key = (dev_id, struct.pack('Q', inode) if inode is not None else None, size)
with self._write_lock():
self._broken_cache[key] = (mtime, error_msg)
def get_broken_info(self, path, size, mtime, inode, dev_id):
"""Returns the error message if a thumbnail is known to have failed, else
None."""
key = (dev_id, struct.pack('Q', inode) if inode is not None else None, size)
with self._read_lock():
info = self._broken_cache.get(key)
if info and info[0] == mtime:
return info[1]
return None
def _resolve_file_identity(self, path, curr_mtime, inode, device_id):
"""Helper to resolve file mtime, device, and inode."""
mtime = curr_mtime
@@ -847,6 +887,12 @@ class ThumbnailCache(QObject):
if mtime is None:
return EMPTY_THUMBNAIL
# Check if known to be broken
broken_msg = self.get_broken_info(path, target_tier, mtime, inode, device_id)
if broken_msg:
return ThumbnailResult(
self._broken_images.get(target_tier), mtime, target_tier)
best_img, best_mtime, best_tier = None, 0, 0
with self._read_lock():
@@ -1189,8 +1235,14 @@ class ThumbnailCache(QObject):
return None
if not img.save(buf, "PNG"):
logger.error("Failed to save image to buffer")
return None
# libpng errors (like "Incorrect data in iCCP") can cause save() topi
# fail.
# Converting to a standard format strips problematic metadata/profiles.
ba.clear()
buf.seek(0)
if not img.convertToFormat(QImage.Format_ARGB32).save(buf, "PNG"):
logger.error("Failed to save image to buffer")
return None
return ba.data()
except Exception as e:
logger.error(f"Error converting image to bytes: {e}")
@@ -1311,6 +1363,7 @@ class CacheCleaner(QThread):
def stop(self):
"""Signals the thread to stop."""
self._is_running = False
self.wait()
def run(self):
self.setPriority(QThread.IdlePriority)
@@ -1382,27 +1435,38 @@ class ThumbnailGenerator(QThread):
# The signal/slot mechanism handles thread safety automatically.
emitter.progress_tick.connect(on_tick, Qt.QueuedConnection)
started_count = 0
for path in self.paths:
# Process in batches to avoid saturating the global thread pool queue.
# This allows the application to respond to stop() signals almost immediately.
batch_size = max(4, pool.maxThreadCount() * 2)
for i in range(0, len(self.paths), batch_size):
if self._abort:
break
runnable = ScannerWorker(self.cache, path, target_sizes=[self.size],
load_metadata=False, signal_emitter=emitter,
semaphore=sem)
runnable.setAutoDelete(False)
self._workers_mutex.lock()
if self._abort:
batch_slice = self.paths[i : i + batch_size]
started_in_batch = 0
for path in batch_slice:
if self._abort:
break
runnable = ScannerWorker(self.cache, path, target_sizes=[self.size],
load_metadata=False, signal_emitter=emitter,
semaphore=sem)
runnable.setAutoDelete(False)
self._workers_mutex.lock()
self._workers.append(runnable)
self._workers_mutex.unlock()
break
self._workers.append(runnable)
self._workers_mutex.unlock()
pool.start(runnable)
started_count += 1
pool.start(runnable)
started_in_batch += 1
if started_count > 0:
sem.acquire(started_count)
if started_in_batch > 0:
# Wait for the current batch to finish before queuing more
sem.acquire(started_in_batch)
self._workers_mutex.lock()
self._workers.clear()
self._workers_mutex.unlock()
self._workers_mutex.lock()
self._workers.clear()
@@ -1425,13 +1489,13 @@ class ImageScanner(QThread):
more_files_available = Signal(int, int) # Last loaded index, remainder
def __init__(self, cache, paths, is_file_list=False, viewers=None,
thread_pool_manager=None):
# is_file_list is not used
thread_pool_manager=None, target_sizes=None):
if not paths or not isinstance(paths, (list, tuple)):
logger.warning("ImageScanner initialized with empty or invalid paths")
paths = []
super().__init__()
self.cache = cache
self.target_sizes = target_sizes
self.all_files = []
self.thread_pool_manager = thread_pool_manager
self._viewers = viewers
@@ -1788,7 +1852,8 @@ class ImageScanner(QThread):
return
for f_path, _ in tasks:
r = ScannerWorker(self.cache, f_path, semaphore=sem)
r = ScannerWorker(
self.cache, f_path, semaphore=sem, target_sizes=self.target_sizes)
r.setAutoDelete(False)
runnables.append(r)
self._current_workers.append(r)

View File

@@ -26,6 +26,7 @@ from PySide6.QtCore import (
Signal, QPoint, QSize, Qt, QMimeData, QUrl, QTimer, QEvent, QRect, Slot, QRectF,
QThread, QObject
)
from PySide6.QtDBus import QDBusConnection, QDBusMessage, QDBus
from constants import (
APP_CONFIG, DEFAULT_FACE_BOX_COLOR, DEFAULT_PET_BOX_COLOR, DEFAULT_VIEWER_SHORTCUTS,
@@ -238,7 +239,10 @@ class FastTagManager:
current_path = controller.get_current_path() if controller else None
if not current_path:
return
controller.toggle_tag(tag_name, is_checked)
try:
controller.toggle_tag(tag_name, is_checked)
except Exception as e:
QMessageBox.critical(self.viewer, UITexts.ERROR, str(e))
self.viewer.update_status_bar()
if self.main_win:
if is_checked:
@@ -419,11 +423,22 @@ class FaceCanvas(QLabel):
self.edit_handle = None
self.edit_start_rect = QRect()
self.resize_margin = 8
# Zoom indicator
self.zoom_indicator_point = None
self.zoom_indicator_timer = QTimer(self)
self.zoom_indicator_timer.setSingleShot(True)
self.zoom_indicator_timer.setInterval(500) # Show for 500ms
self.zoom_indicator_timer.timeout.connect(self._clear_zoom_indicator)
self.crop_rect = QRect()
self.crop_handle = None
self.crop_start_pos = QPoint()
self.crop_start_rect = QRect()
def _clear_zoom_indicator(self):
self.zoom_indicator_point = None
self.update()
def map_from_source(self, face_data):
"""Maps original normalized face data to current canvas QRect."""
nx = face_data.get('x', 0)
@@ -623,6 +638,18 @@ class FaceCanvas(QLabel):
painter.drawRect(pt.x() - offset, pt.y() - offset,
handle_size, handle_size)
# Draw zoom indicator
if self.zoom_indicator_point:
painter.setPen(QPen(QColor(255, 255, 0), 2)) # Yellow crosshair
painter.drawLine(self.zoom_indicator_point.x() - 10,
self.zoom_indicator_point.y(),
self.zoom_indicator_point.x() + 10,
self.zoom_indicator_point.y())
painter.drawLine(self.zoom_indicator_point.x(),
self.zoom_indicator_point.y() - 10,
self.zoom_indicator_point.x(),
self.zoom_indicator_point.y() + 10)
def _hit_test(self, pos):
"""Determines if the mouse is over a name, handle, or body."""
if not self.controller.show_faces:
@@ -990,8 +1017,12 @@ class FaceCanvas(QLabel):
history = history_list \
if self.viewer.main_win else []
setting_key = f"{region_type.lower()}_use_last_name"
suggested = history[0] if history and APP_CONFIG.get(
setting_key, False) else ""
full_tag, updated_history, ok = FaceNameDialog.get_name(
self.viewer, history,
self.viewer, history, current_name=suggested,
main_win=self.viewer.main_win, region_type=region_type)
if ok and full_tag:
@@ -1122,18 +1153,62 @@ class ZoomManager(QObject):
super().__init__(viewer)
self.viewer = viewer
def zoom(self, factor, reset=False):
"""Applies zoom to the image."""
def zoom(self, factor=1.1, reset=False, focus_point=None, absolute_factor=None):
"""Applies zoom to the image, centering on focus_point if provided."""
if not self.viewer.controller or \
self.viewer.controller.pixmap_original.isNull():
return
c_point = None
if reset:
self.viewer.controller.zoom_factor = 1.0
self.viewer.update_view(resize_win=True)
if self.viewer.canvas:
c_point = self.viewer.canvas.rect().center()
elif absolute_factor is not None: # New: set absolute zoom factor
self.viewer.controller.zoom_factor = absolute_factor
# Don't resize window for sync zoom
self.viewer.update_view(resize_win=False)
if focus_point is not None and self.viewer.canvas:
scroll_area = self.viewer.scroll_area
viewport = scroll_area.viewport()
v_point = viewport.mapFrom(self.viewer, focus_point)
c_point = self.viewer.canvas.mapFrom(viewport, v_point)
else:
# 1. Determine focus point in viewport coordinates
scroll_area = self.viewer.scroll_area
viewport = scroll_area.viewport()
if focus_point is None:
v_point = viewport.rect().center()
else:
# focus_point is relative to the self.viewer widget
# (ImageViewer or ImagePane)
v_point = viewport.mapFrom(self.viewer, focus_point)
# 2. Map focus point to canvas coordinates before zoom
c_point = self.viewer.canvas.mapFrom(viewport, v_point)
self.viewer.controller.zoom_factor *= factor
self.viewer.update_view(resize_win=True)
# Apply update (this resizes the canvas)
self.viewer.update_view(resize_win=(not self.viewer.isFullScreen()))
# 3. Adjust scrollbars to maintain pixel under cursor
scroll_area.horizontalScrollBar().setValue(
int(c_point.x() * factor - v_point.x()))
scroll_area.verticalScrollBar().setValue(
int(c_point.y() * factor - v_point.y()))
# Notify the main window that the image (and possibly index) has changed
# so it can update its selection.
self.viewer.index_changed.emit(self.viewer.controller.index)
if focus_point is not None and self.viewer.canvas:
self.viewer.canvas.zoom_indicator_point = c_point
self.viewer.canvas.zoom_indicator_timer.start()
self.viewer.canvas.update()
self.zoomed.emit(self.viewer.controller.zoom_factor)
if hasattr(self.viewer, 'sync_filmstrip_selection'):
self.viewer.sync_filmstrip_selection(self.viewer.controller.index)
@@ -1645,16 +1720,23 @@ class ImageViewer(QWidget):
if pane != self.active_pane:
pane.controller.zoom_factor = factor
pane.update_view(resize_win=False)
# Re-apply relative scroll after zoom changes bounds
if self.active_pane:
h_bar = self.active_pane.scroll_area.horizontalScrollBar()
v_bar = self.active_pane.scroll_area.verticalScrollBar()
h_max = h_bar.maximum()
v_max = v_bar.maximum()
if h_max > 0 or v_max > 0:
x_pct = h_bar.value() / h_max if h_max > 0 else 0
y_pct = v_bar.value() / v_max if v_max > 0 else 0
pane.set_scroll_relative(x_pct, y_pct)
# Re-apply relative scroll after zoom changes bounds
# We defer this to the next event loop iteration to ensure
# that QScrollArea has updated its scrollbar maximums.
if self.active_pane:
h_bar = self.active_pane.scroll_area.horizontalScrollBar()
v_bar = self.active_pane.scroll_area.verticalScrollBar()
h_max = h_bar.maximum()
v_max = v_bar.maximum()
x_pct = h_bar.value() / h_max if h_max > 0 else 0
y_pct = v_bar.value() / v_max if v_max > 0 else 0
for pane in self.panes:
if pane != self.active_pane:
QTimer.singleShot(
0, lambda p=pane, x=x_pct,
y=y_pct: p.set_scroll_relative(x, y))
def update_grid_layout(self):
# Clear layout
@@ -1693,6 +1775,9 @@ class ImageViewer(QWidget):
for i in range(count - current_panes):
new_idx = (start_idx + i + 1) % len(img_list)
pane = self.add_pane(img_list, new_idx, None, 0) # Metadata will load
if self.panes_linked and self.active_pane:
pane.controller.zoom_factor = \
self.active_pane.controller.zoom_factor
pane.load_and_fit_image()
else:
# Remove panes (keep active if possible, else keep first)
@@ -1710,10 +1795,13 @@ class ImageViewer(QWidget):
# sizing
QTimer.singleShot(
0, lambda: self.active_pane.update_view(resize_win=True))
self.adjustSize()
def toggle_link_panes(self):
"""Toggles the synchronized zoom/scroll for comparison mode."""
self.panes_linked = not self.panes_linked
if self.panes_linked and self.active_pane:
self._sync_zoom(self.active_pane.controller.zoom_factor)
self.update_status_bar()
def update_highlight(self):
@@ -1731,6 +1819,9 @@ class ImageViewer(QWidget):
def reset_inactivity_timer(self):
"""Resets the inactivity timer and restores controls visibility."""
if self.active_pane and self.active_pane.canvas:
self.active_pane.canvas._clear_zoom_indicator()
if self.isFullScreen():
self.unsetCursor()
if self.main_win and self.main_win.show_viewer_status_bar:
@@ -2110,8 +2201,12 @@ class ImageViewer(QWidget):
available_h -= self.status_bar_container.sizeHint().height()
should_resize = True
self.zoom_manager.calculate_initial_zoom(available_w, available_h,
self.isFullScreen())
if self.panes_linked and self.active_pane and pane != self.active_pane:
# Inherit zoom from active pane instead of recalculating
pane.controller.zoom_factor = self.active_pane.controller.zoom_factor
else:
pane.zoom_manager.calculate_initial_zoom(available_w, available_h,
self.isFullScreen())
self.update_view(resize_win=should_resize)
else:
@@ -2744,8 +2839,11 @@ class ImageViewer(QWidget):
self.main_win.face_names_history = updated_history
# Save changes and add new tag
self.controller.save_faces()
self.controller.toggle_tag(new_full_tag, True)
try:
self.controller.save_faces()
self.controller.toggle_tag(new_full_tag, True)
except Exception as e:
QMessageBox.critical(self, UITexts.ERROR, str(e))
if self.canvas:
self.canvas.update()
@@ -3027,8 +3125,10 @@ class ImageViewer(QWidget):
QApplication.processEvents()
history = self.main_win.face_names_history if self.main_win else []
suggested = history[0] if history and APP_CONFIG.get(
"face_use_last_name", False) else ""
full_tag, updated_history, ok = FaceNameDialog.get_name(
self, history, main_win=self.main_win)
self, history, current_name=suggested, main_win=self.main_win)
if ok and full_tag:
new_face['name'] = full_tag
@@ -3043,7 +3143,10 @@ class ImageViewer(QWidget):
self.canvas.update()
if added_count > 0:
self.controller.save_faces()
try:
self.controller.save_faces()
except Exception as e:
QMessageBox.critical(self, UITexts.ERROR, str(e))
def run_pet_detection(self):
"""Runs pet detection on the current image."""
@@ -3084,8 +3187,11 @@ class ImageViewer(QWidget):
QApplication.processEvents()
history = self.main_win.pet_names_history if self.main_win else []
suggested = history[0] if history and APP_CONFIG.get(
"pet_use_last_name", False) else ""
full_tag, updated_history, ok = FaceNameDialog.get_name(
self, history, main_win=self.main_win, region_type="Pet")
self, history, current_name=suggested, main_win=self.main_win,
region_type="Pet")
if ok and full_tag:
new_pet['name'] = full_tag
@@ -3099,7 +3205,10 @@ class ImageViewer(QWidget):
self.canvas.update()
if added_count > 0:
self.controller.save_faces()
try:
self.controller.save_faces()
except Exception as e:
QMessageBox.critical(self, UITexts.ERROR, str(e))
def run_body_detection(self):
"""Runs body detection on the current image."""
@@ -3142,8 +3251,11 @@ class ImageViewer(QWidget):
# For bodies, we typically don't ask for a name immediately unless desired
# Or we can treat it like pets/faces and ask. Let's ask.
history = self.main_win.body_names_history if self.main_win else []
suggested = history[0] if history and APP_CONFIG.get(
"body_use_last_name", False) else ""
full_tag, updated_history, ok = FaceNameDialog.get_name(
self, history, main_win=self.main_win, region_type="Body")
self, history, current_name=suggested, main_win=self.main_win,
region_type="Body")
if ok and full_tag:
new_body['name'] = full_tag
@@ -3157,7 +3269,10 @@ class ImageViewer(QWidget):
self.canvas.update()
if added_count > 0:
self.controller.save_faces()
try:
self.controller.save_faces()
except Exception as e:
QMessageBox.critical(self, UITexts.ERROR, str(e))
def toggle_filmstrip(self):
"""Shows or hides the filmstrip widget."""
@@ -3219,17 +3334,19 @@ class ImageViewer(QWidget):
self.reset_inactivity_timer()
if event.modifiers() & Qt.ControlModifier:
# Zoom with Ctrl + Wheel
focus_pos = event.position().toPoint()
if event.angleDelta().y() > 0:
self.zoom_manager.zoom(1.1)
self.zoom_manager.zoom(1.1, focus_point=focus_pos)
else:
self.zoom_manager.zoom(0.9)
self.zoom_manager.zoom(0.9, focus_point=focus_pos)
else:
# Navigate next/previous based on configurable speed
speed = APP_CONFIG.get("viewer_wheel_speed", VIEWER_WHEEL_SPEED_DEFAULT)
# A standard tick is 120. We define a threshold based on speed.
# Speed 1 (slowest) requires a full 120 delta.
# Speed 10 (fastest) requires 120/10 = 12 delta.
threshold = 120 / speed
# Still too fast so speed / 2.
threshold = 120 / speed * 2
self._wheel_scroll_accumulator += event.angleDelta().y()
@@ -3336,17 +3453,18 @@ class ImageViewer(QWidget):
service, which is common on Linux desktops.
"""
try:
cmd = [
"dbus-send", "--session", "--print-reply",
"--dest=org.freedesktop.ScreenSaver",
msg = QDBusMessage.createMethodCall(
"org.freedesktop.ScreenSaver",
"/org/freedesktop/ScreenSaver",
"org.freedesktop.ScreenSaver.Inhibit",
"string:bagheeraview", # Application name
"string:Viewing images" # Reason for inhibition
]
output = subprocess.check_output(cmd, text=True)
# Extract the cookie from the output (e.g., "uint32 12345")
self.inhibit_cookie = int(output.split()[-1])
"org.freedesktop.ScreenSaver",
"Inhibit"
)
msg.setArguments(["bagheeraview", "Viewing images"])
reply = QDBusConnection.sessionBus().call(msg)
if reply.type() == QDBusMessage.ReplyMessage:
self.inhibit_cookie = reply.arguments()[0]
else:
self.inhibit_cookie = None
except Exception as e:
print(f"{UITexts.ERROR} inhibiting power management: {e}")
self.inhibit_cookie = None
@@ -3360,13 +3478,14 @@ class ImageViewer(QWidget):
"""
if hasattr(self, 'inhibit_cookie') and self.inhibit_cookie is not None:
try:
subprocess.Popen([
"dbus-send", "--session",
"--dest=org.freedesktop.ScreenSaver",
msg = QDBusMessage.createMethodCall(
"org.freedesktop.ScreenSaver",
"/org/freedesktop/ScreenSaver",
"org.freedesktop.ScreenSaver.UnInhibit",
f"uint32:{self.inhibit_cookie}"
])
"org.freedesktop.ScreenSaver",
"UnInhibit"
)
msg.setArguments([self.inhibit_cookie])
QDBusConnection.sessionBus().call(msg, QDBus.NoBlock)
self.inhibit_cookie = None
except Exception as e:
print(f"{UITexts.ERROR} uninhibiting: {e}")

View File

@@ -9,6 +9,7 @@ Classes:
"""
import os
import collections
import logging
from PySide6.QtDBus import QDBusConnection, QDBusMessage, QDBus
try:
import exiv2
@@ -16,13 +17,31 @@ try:
except ImportError:
exiv2 = None
HAVE_EXIV2 = False
from utils import preserve_mtime
from constants import RATING_XATTR_NAME, XATTR_NAME
from constants import RATING_XATTR_NAME, XATTR_NAME, UITexts
logger = logging.getLogger(__name__)
_app_modified_callback = None
MetadataResult = collections.namedtuple('MetadataResult', ['tags', 'rating'])
EMPTY_METADATA = MetadataResult([], 0)
def set_app_modified_callback(callback):
global _app_modified_callback
_app_modified_callback = callback
def mark_app_modified(path):
"""Triggers the application-modified callback for a path."""
if _app_modified_callback:
_app_modified_callback(path)
def notify_baloo(path):
"""
Notifies the Baloo file indexer about a file change using DBus.
@@ -106,6 +125,74 @@ class MetadataManager:
return all_metadata
@staticmethod
def write_metadata(path, metadata_dict):
"""
Writes EXIF, IPTC, and XMP metadata back to a file.
Args:
path (str): The path to the image file.
metadata_dict (dict): A dictionary of metadata keys and values.
"""
if not HAVE_EXIV2:
return
try:
image = exiv2.ImageFactory.open(path)
image.readMetadata()
exif = image.exifData()
iptc = image.iptcData()
xmp = image.xmpData()
# Remove keys that are no longer in the dictionary
containers = [
(exif, exiv2.ExifKey, "Exif."),
(iptc, exiv2.IptcKey, "Iptc."),
(xmp, exiv2.XmpKey, "Xmp.")
]
for container, key_class, prefix in containers:
keys_to_remove = []
for datum in container:
k = datum.key()
# Only consider keys belonging to this specific container
if k.startswith(prefix) and k not in metadata_dict:
keys_to_remove.append(k)
for key in keys_to_remove:
try:
x_key = key_class(key)
it = container.findKey(x_key)
if it != container.end():
container.erase(it)
except Exception as e:
print(f"Error removing metadata key {key}: {e}")
# Set or update values from the dictionary
for key, value in metadata_dict.items():
try:
if key.startswith("Exif."):
exif[key] = str(value)
elif key.startswith("Iptc."):
iptc[key] = str(value)
elif key.startswith("Xmp."):
xmp[key] = str(value)
except Exception as e:
print(f"Error setting metadata key {key}: {e}")
image.writeMetadata()
notify_baloo(path)
mark_app_modified(path)
except Exception as e:
error_msg = str(e)
if "kerTooLargeJpegSegment" in error_msg or "38" in error_msg:
msg = UITexts.ERROR_JPEG_METADATA_LIMIT.format(os.path.basename(path))
logger.error(msg)
raise IOError(msg) from e
logger.error(f"Error writing metadata for {path}: {e}")
raise
class XattrManager:
"""A manager class to handle reading and writing extended attributes (xattrs)."""
@@ -148,6 +235,7 @@ class XattrManager:
return
try:
with preserve_mtime(file_path):
mark_app_modified(file_path)
if value:
os.setxattr(file_path, attr_name, str(value).encode('utf-8'))
else:

View File

@@ -12,7 +12,7 @@ Classes:
from PySide6.QtWidgets import (
QWidget, QLabel, QVBoxLayout, QMessageBox, QMenu, QInputDialog,
QDialog, QTableWidget, QTableWidgetItem, QHeaderView, QTabWidget,
QFormLayout, QDialogButtonBox, QApplication
QFormLayout, QDialogButtonBox, QApplication, QToolBar, QAbstractItemView
)
from PySide6.QtGui import (
QImageReader, QIcon, QColor
@@ -76,6 +76,8 @@ class PropertiesDialog(QDialog):
self.setWindowTitle(UITexts.PROPERTIES_TITLE)
self._initial_tags = initial_tags if initial_tags is not None else []
self._initial_rating = initial_rating
self.original_xattrs = {}
self.original_exif = {}
self.loader = None
self.resize(400, 500)
self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint)
@@ -136,21 +138,25 @@ class PropertiesDialog(QDialog):
meta_widget = QWidget()
meta_layout = QVBoxLayout(meta_widget)
self.meta_toolbar = QToolBar()
self._setup_table_toolbar(
self.meta_toolbar, self.on_add_meta, self.on_delete_meta,
self.on_delete_all_meta, self.on_save_meta, self.on_cancel_meta)
meta_layout.addWidget(self.meta_toolbar)
self.table = QTableWidget()
self.table.setColumnCount(2)
self.table.setHorizontalHeaderLabels(UITexts.PROPERTIES_TABLE_HEADER)
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Interactive)
self.table.horizontalHeader().setSectionResizeMode(1,
QHeaderView.ResizeToContents)
self.table.setColumnWidth(0, self.width() * 0.4)
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
self.table.verticalHeader().setVisible(False)
self.table.setAlternatingRowColors(True)
self.table.setEditTriggers(QTableWidget.DoubleClicked |
QTableWidget.EditKeyPressed |
QTableWidget.SelectedClicked)
self.table.setEditTriggers(QAbstractItemView.DoubleClicked |
QAbstractItemView.EditKeyPressed |
QAbstractItemView.AnyKeyPressed)
self.table.setSelectionBehavior(QTableWidget.SelectRows)
self.table.setSelectionMode(QAbstractItemView.ExtendedSelection)
self.table.itemChanged.connect(self.on_item_changed)
self.table.setContextMenuPolicy(Qt.CustomContextMenu)
self.table.customContextMenuRequested.connect(self.show_context_menu)
@@ -164,6 +170,12 @@ class PropertiesDialog(QDialog):
exif_widget = QWidget()
exif_layout = QVBoxLayout(exif_widget)
self.exif_toolbar = QToolBar()
self._setup_table_toolbar(
self.exif_toolbar, self.on_add_exif, self.on_delete_exif,
self.on_delete_all_exif, self.on_save_exif, self.on_cancel_exif)
exif_layout.addWidget(self.exif_toolbar)
self.exif_table = QTableWidget()
# This table will display EXIF/XMP/IPTC data.
# Reading this data involves opening the file with exiv2, which is a disk read.
@@ -174,14 +186,15 @@ class PropertiesDialog(QDialog):
# without a significant architectural change (e.g., a dedicated metadata DB).
self.exif_table.setColumnCount(2)
self.exif_table.setHorizontalHeaderLabels(UITexts.PROPERTIES_TABLE_HEADER)
self.exif_table.horizontalHeader().setSectionResizeMode(
0, QHeaderView.ResizeToContents)
self.exif_table.horizontalHeader().setSectionResizeMode(
1, QHeaderView.ResizeToContents)
self.exif_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
self.exif_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
self.exif_table.verticalHeader().setVisible(False)
self.exif_table.setAlternatingRowColors(True)
self.exif_table.setEditTriggers(QTableWidget.NoEditTriggers)
self.exif_table.setEditTriggers(QAbstractItemView.DoubleClicked |
QAbstractItemView.EditKeyPressed |
QAbstractItemView.AnyKeyPressed)
self.exif_table.setSelectionBehavior(QTableWidget.SelectRows)
self.exif_table.setSelectionMode(QAbstractItemView.ExtendedSelection)
self.exif_table.setContextMenuPolicy(Qt.CustomContextMenu)
# This is a disk read.
self.exif_table.customContextMenuRequested.connect(self.show_exif_context_menu)
@@ -204,6 +217,100 @@ class PropertiesDialog(QDialog):
# Start background loading
self.reload_metadata()
def _setup_table_toolbar(self, toolbar, add_slot, del_slot, del_all_slot, save_slot,
cancel_slot):
"""Helper to populate toolbars with buttons."""
toolbar.addAction(QIcon.fromTheme("list-add"), UITexts.CREATE, add_slot)
toolbar.addAction(QIcon.fromTheme("list-remove"), UITexts.DELETE, del_slot)
toolbar.addAction(
QIcon.fromTheme("edit-clear-all"), UITexts.PROPERTIES_DELETE_ALL,
del_all_slot)
toolbar.addSeparator()
toolbar.addAction(QIcon.fromTheme("document-save"), UITexts.SAVE, save_slot)
toolbar.addAction(QIcon.fromTheme("edit-undo"), UITexts.CANCEL, cancel_slot)
def on_add_meta(self):
key, ok = QInputDialog.getText(
self, UITexts.PROPERTIES_ADD_ATTR, UITexts.PROPERTIES_ADD_ATTR_NAME)
if ok and key:
row = self.table.rowCount()
self.table.insertRow(row)
self.table.setItem(row, 0, QTableWidgetItem(key))
v_item = QTableWidgetItem("")
self.table.setItem(row, 1, v_item)
self.table.setCurrentItem(v_item)
self.table.editItem(v_item)
def on_delete_meta(self):
rows = sorted(set(index.row() for index in self.table.selectedIndexes()),
reverse=True)
for row in rows:
self.table.removeRow(row)
def on_delete_all_meta(self):
self.table.setRowCount(0)
def on_save_meta(self):
new_attrs = {}
for r in range(self.table.rowCount()):
k_item, v_item = self.table.item(r, 0), self.table.item(r, 1)
if k_item and v_item:
new_attrs[k_item.text()] = v_item.text()
try:
for k in self.original_xattrs:
if k not in new_attrs:
XattrManager.set_attribute(self.path, k, None)
for k, v in new_attrs.items():
XattrManager.set_attribute(self.path, k, v)
self.reload_metadata()
except Exception as e:
QMessageBox.critical(self, UITexts.ERROR, str(e))
def on_cancel_meta(self):
self.update_metadata_table(self.original_xattrs)
def on_add_exif(self):
key, ok = QInputDialog.getText(
self, UITexts.PROPERTIES_ADD_ATTR, UITexts.PROPERTIES_ADD_ATTR_NAME)
if ok and key:
row = self.exif_table.rowCount()
self.exif_table.insertRow(row)
self.exif_table.setItem(row, 0, QTableWidgetItem(key))
v_item = QTableWidgetItem("")
self.exif_table.setItem(row, 1, v_item)
self.exif_table.setCurrentItem(v_item)
self.exif_table.editItem(v_item)
def on_delete_exif(self):
rows = sorted(
set(index.row() for index in self.exif_table.selectedIndexes()),
reverse=True)
for row in rows:
self.exif_table.removeRow(row)
def on_delete_all_exif(self):
self.exif_table.setRowCount(0)
def on_save_exif(self):
new_exif = {}
for r in range(self.exif_table.rowCount()):
k_item, v_item = self.exif_table.item(r, 0), self.exif_table.item(r, 1)
if k_item and v_item:
new_exif[k_item.text()] = v_item.text()
try:
MetadataManager.write_metadata(self.path, new_exif)
self.reload_metadata()
except Exception as e:
QMessageBox.critical(self, UITexts.ERROR, str(e))
def on_cancel_exif(self):
self.update_exif_table(self.original_exif)
def done(self, r):
if self.loader and self.loader.isRunning():
self.loader.stop()
super().done(r)
def closeEvent(self, event):
if self.loader and self.loader.isRunning():
self.loader.stop()
@@ -227,6 +334,7 @@ class PropertiesDialog(QDialog):
# Combine preloaded and newly read xattrs
all_xattrs = preloaded_xattrs.copy()
if not initial_only and disk_xattrs:
self.original_xattrs = disk_xattrs.copy()
# Disk data takes precedence or adds to it
all_xattrs.update(disk_xattrs)
@@ -237,9 +345,9 @@ class PropertiesDialog(QDialog):
for key, val in all_xattrs.items():
# QImageReader.textKeys() is not used here as it's not xattr.
k_item = QTableWidgetItem(key)
k_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
k_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsEditable)
v_item = QTableWidgetItem(val)
v_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
v_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsEditable)
self.table.setItem(row, 0, k_item)
self.table.setItem(row, 1, v_item)
row += 1
@@ -298,6 +406,7 @@ class PropertiesDialog(QDialog):
self.exif_table.blockSignals(False)
return
self.original_exif = exif_data.copy()
self.exif_table.setRowCount(len(exif_data))
error_color = QColor("red")
error_text_lower = UITexts.ERROR.lower()
@@ -305,9 +414,9 @@ class PropertiesDialog(QDialog):
for row, (key, value) in enumerate(sorted(exif_data.items())):
k_item = QTableWidgetItem(str(key))
k_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
k_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsEditable)
v_item = QTableWidgetItem(str(value))
v_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
v_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsEditable)
key_str_lower = str(key).lower()
val_str_lower = str(value).lower()
@@ -323,25 +432,6 @@ class PropertiesDialog(QDialog):
self.exif_table.blockSignals(False)
def on_item_changed(self, item):
"""
Slot that triggers when an item in the metadata table is changed.
Args:
item (QTableWidgetItem): The item that was changed.
"""
if item.column() == 1:
key = self.table.item(item.row(), 0).text()
val = item.text()
# Treat empty or whitespace-only values as removal to match previous
# behavior
val_to_set = val if val.strip() else None
try:
XattrManager.set_attribute(self.path, key, val_to_set)
except Exception as e:
QMessageBox.warning(self, UITexts.ERROR,
UITexts.PROPERTIES_ERROR_SET_ATTR.format(e))
def show_context_menu(self, pos):
"""
Displays a context menu in the metadata table.

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "bagheeraview"
version = "0.9.15"
version = "0.9.26"
authors = [
{ name = "Ignacio Serantes" }
]
@@ -25,6 +25,7 @@ dependencies = [
"exiv2",
"psutil",
"watchdog",
"imagehash",
"mediapipe",
"face_recognition",
"face_recognition_models",
@@ -55,8 +56,11 @@ py-modules = [
"imagecontroller",
"metadatamanager",
"propertiesdialog",
"thumbnailwidget",
#"thumbnailwidget",
"duplicatecache",
"duplicatedialog",
"widgets",
"filesystemwatcher",
"xmpmanager",
"utils"
]

View File

@@ -3,6 +3,7 @@ lmdb
exiv2
psutil
watchdog
imagehash
mediapipe
face_recognition
face_recognition_models

View File

@@ -14,12 +14,13 @@ import os
import shutil
import urllib.request
from PySide6.QtCore import Qt, QThread, Signal
from PySide6.QtCore import Qt, QThread, Signal, QTimer
from PySide6.QtGui import QColor, QIcon, QFont
from PySide6.QtWidgets import (
QCheckBox, QColorDialog, QComboBox, QDialog, QDialogButtonBox, QHBoxLayout,
QLabel, QLineEdit, QMessageBox, QProgressDialog, QPushButton, QSpinBox,
QTabWidget, QVBoxLayout, QWidget
QTabWidget, QVBoxLayout, QWidget, QSlider, QFileDialog, QListWidget,
QListWidgetItem, QProgressBar
)
from constants import (
APP_CONFIG, AVAILABLE_FACE_ENGINES, DEFAULT_FACE_BOX_COLOR,
@@ -27,7 +28,7 @@ from constants import (
FACES_MENU_MAX_ITEMS_DEFAULT, MEDIAPIPE_FACE_MODEL_PATH, MEDIAPIPE_FACE_MODEL_URL,
AVAILABLE_PET_ENGINES, DEFAULT_BODY_BOX_COLOR,
MEDIAPIPE_OBJECT_MODEL_PATH, MEDIAPIPE_OBJECT_MODEL_URL,
HAVE_BAGHEERASEARCH_LIB,
HAVE_BAGHEERASEARCH_LIB, IMAGE_EXTENSIONS,
SCANNER_SETTINGS_DEFAULTS, SEARCH_CMD, TAGS_MENU_MAX_ITEMS_DEFAULT,
THUMBNAILS_FILENAME_LINES_DEFAULT,
THUMBNAILS_REFRESH_INTERVAL_DEFAULT, THUMBNAILS_BG_COLOR_DEFAULT,
@@ -36,10 +37,74 @@ from constants import (
THUMBNAILS_TOOLTIP_FG_COLOR_DEFAULT, THUMBNAILS_FILENAME_FONT_SIZE_DEFAULT,
THUMBNAILS_TAGS_LINES_DEFAULT, THUMBNAILS_TAGS_FONT_SIZE_DEFAULT,
VIEWER_AUTO_RESIZE_WINDOW_DEFAULT, VIEWER_WHEEL_SPEED_DEFAULT,
UITexts, save_app_config
UITexts, save_app_config, HAVE_DUPLICATE_RESNET_LIBS, HAVE_IMAGEHASH
)
class DuplicateFileCounter(QThread):
"""Thread to count images in whitelist/blacklist without freezing UI."""
count_updated = Signal(int)
finished = Signal(int)
def __init__(self, whitelist, blacklist, extensions):
super().__init__()
self.whitelist = whitelist
self.blacklist = blacklist
self.extensions = extensions
self._abort = False
def stop(self):
self._abort = True
self.wait()
def run(self):
count = 0
for root_path in self.whitelist:
if self._abort:
break
if not os.path.exists(root_path):
continue
for root, dirs, files in os.walk(root_path):
if self._abort:
break
abs_root = os.path.abspath(root)
dirs[:] = [d for d in dirs
if os.path.join(abs_root, d) not in self.blacklist]
if abs_root in self.blacklist:
continue
for f in files:
if self._abort:
break
if os.path.splitext(f)[1].lower() in self.extensions:
if os.path.join(abs_root, f) not in self.blacklist:
count += 1
self.count_updated.emit(count)
self.finished.emit(count)
class PathListWidget(QListWidget):
"""A QListWidget that accepts folder drops from external file explorers."""
def __init__(self, add_callback, parent=None):
super().__init__(parent)
self.add_callback = add_callback
self.setAcceptDrops(True)
def dragEnterEvent(self, event):
if event.mimeData().hasUrls():
event.acceptProposedAction()
def dragMoveEvent(self, event):
if event.mimeData().hasUrls():
event.acceptProposedAction()
def dropEvent(self, event):
for url in event.mimeData().urls():
path = url.toLocalFile()
if path and os.path.isdir(path):
self.add_callback(self, path)
event.acceptProposedAction()
class ModelDownloader(QThread):
"""A thread to download the MediaPipe model file without freezing the UI."""
download_complete = Signal(bool, str) # success (bool), message (str)
@@ -93,6 +158,7 @@ class SettingsDialog(QDialog):
self.current_thumbs_tooltip_bg_color = THUMBNAILS_TOOLTIP_BG_COLOR_DEFAULT
self.current_thumbs_tooltip_fg_color = THUMBNAILS_TOOLTIP_FG_COLOR_DEFAULT
self.downloader_thread = None
self.counter_thread = None
layout = QVBoxLayout(self)
@@ -112,6 +178,9 @@ class SettingsDialog(QDialog):
scanner_tab = QWidget()
scanner_layout = QVBoxLayout(scanner_tab)
duplicates_tab = QWidget()
duplicates_layout = QVBoxLayout(duplicates_tab)
# --- Thumbnails Tab ---
mru_tags_layout = QHBoxLayout()
@@ -344,6 +413,142 @@ class SettingsDialog(QDialog):
scanner_layout.addLayout(scan_full_on_start_layout)
scanner_layout.addStretch()
# --- Duplicates Tab ---
if not HAVE_IMAGEHASH:
warning_lbl = QLabel(UITexts.SETTINGS_DUPLICATE_MISSING_LIBS)
warning_lbl.setStyleSheet("color: #e74c3c; font-weight: bold;")
warning_lbl.setWordWrap(True)
duplicates_layout.addWidget(warning_lbl)
method_layout = QHBoxLayout()
method_label = QLabel(UITexts.SETTINGS_DUPLICATE_METHOD_LABEL)
self.duplicate_method_combo = QComboBox()
self.duplicate_method_combo.addItem(
UITexts.METHOD_HISTOGRAM_HASHING, "histogram_hashing")
self.duplicate_method_combo.addItem(UITexts.METHOD_RESNET, "resnet")
self.duplicate_method_combo.setEnabled(HAVE_IMAGEHASH)
if not HAVE_DUPLICATE_RESNET_LIBS:
resnet_idx = self.duplicate_method_combo.findData("resnet")
if resnet_idx != -1:
item = self.duplicate_method_combo.model().item(resnet_idx)
if item:
item.setEnabled(False)
method_layout.addWidget(method_label)
method_layout.addWidget(self.duplicate_method_combo)
method_label.setToolTip(UITexts.SETTINGS_DUPLICATE_METHOD_TOOLTIP)
self.duplicate_method_combo.setToolTip(
UITexts.SETTINGS_DUPLICATE_METHOD_TOOLTIP)
duplicates_layout.addLayout(method_layout)
threshold_layout = QHBoxLayout()
threshold_label = QLabel(UITexts.SETTINGS_DUPLICATE_THRESHOLD_LABEL)
self.duplicate_threshold_slider = QSlider(Qt.Horizontal)
self.duplicate_threshold_slider.setRange(50, 100)
self.duplicate_threshold_value_label = QLabel("0%")
self.duplicate_threshold_slider.setEnabled(HAVE_IMAGEHASH)
self.duplicate_threshold_value_label.setFixedWidth(40)
threshold_layout.addWidget(threshold_label)
threshold_layout.addWidget(self.duplicate_threshold_slider)
threshold_layout.addWidget(self.duplicate_threshold_value_label)
threshold_label.setToolTip(UITexts.SETTINGS_DUPLICATE_THRESHOLD_TOOLTIP)
self.duplicate_threshold_slider.setToolTip(
UITexts.SETTINGS_DUPLICATE_THRESHOLD_TOOLTIP)
self.duplicate_threshold_slider.valueChanged.connect(
lambda v: self.duplicate_threshold_value_label.setText(f"{v}%"))
def create_path_list_ui(label_text, tooltip):
container = QWidget()
v_layout = QVBoxLayout(container)
v_layout.setContentsMargins(0, 0, 0, 0)
v_layout.addWidget(QLabel(label_text))
h_layout = QHBoxLayout()
lst = PathListWidget(self._add_path_to_list)
lst.setToolTip(tooltip)
lst.setMinimumHeight(100)
h_layout.addWidget(lst)
btn_vbox = QVBoxLayout()
add_btn = QPushButton()
add_btn.setIcon(QIcon.fromTheme("list-add"))
add_btn.setFixedWidth(30)
rem_btn = QPushButton()
rem_btn.setIcon(QIcon.fromTheme("list-remove"))
rem_btn.setFixedWidth(30)
btn_vbox.addWidget(add_btn)
btn_vbox.addWidget(rem_btn)
btn_vbox.addStretch()
h_layout.addLayout(btn_vbox)
v_layout.addLayout(h_layout)
return container, lst, add_btn, rem_btn
# Whitelist
wl_cont, self.duplicate_whitelist_list, wl_add, wl_rem = create_path_list_ui(
UITexts.SETTINGS_DUPLICATE_WHITELIST_LABEL,
UITexts.SETTINGS_DUPLICATE_WHITELIST_TOOLTIP)
wl_add.clicked.connect(self.add_whitelist_path)
wl_rem.clicked.connect(self.remove_whitelist_path)
duplicates_layout.addWidget(wl_cont)
# Blacklist
bl_cont, self.duplicate_blacklist_list, bl_add, bl_rem = create_path_list_ui(
UITexts.SETTINGS_DUPLICATE_BLACKLIST_LABEL,
UITexts.SETTINGS_DUPLICATE_BLACKLIST_TOOLTIP)
bl_add.clicked.connect(self.add_blacklist_path)
bl_rem.clicked.connect(self.remove_blacklist_path)
duplicates_layout.addWidget(bl_cont)
# Image Count Layout
count_layout = QHBoxLayout()
self.duplicate_scan_count_label = QLabel()
self.duplicate_scan_count_label.setStyleSheet(
"color: #3498db; font-weight: bold;")
self.duplicate_scan_progress = QProgressBar()
self.duplicate_scan_progress.setRange(0, 0) # Indeterminate mode
self.duplicate_scan_progress.setFixedHeight(10)
self.duplicate_scan_progress.setFixedWidth(100)
self.duplicate_scan_progress.hide()
count_layout.addWidget(self.duplicate_scan_count_label)
count_layout.addWidget(self.duplicate_scan_progress)
count_layout.addStretch()
duplicates_layout.addLayout(count_layout)
# Timer for debounced count update
self.count_update_timer = QTimer(self)
self.count_update_timer.setSingleShot(True)
self.count_update_timer.setInterval(500)
self.count_update_timer.timeout.connect(self.update_duplicate_scan_count)
self.duplicate_whitelist_list.model().rowsInserted.connect(
lambda *args: self.count_update_timer.start())
self.duplicate_whitelist_list.model().rowsRemoved.connect(
lambda *args: self.count_update_timer.start())
self.duplicate_blacklist_list.model().rowsInserted.connect(
lambda *args: self.count_update_timer.start())
self.duplicate_blacklist_list.model().rowsRemoved.connect(
lambda *args: self.count_update_timer.start())
self.default_delete_to_trash_checkbox = QCheckBox(
UITexts.SETTINGS_DEFAULT_DELETE_TO_TRASH_LABEL)
self.default_delete_to_trash_checkbox.setToolTip(
UITexts.SETTINGS_DEFAULT_DELETE_TO_TRASH_TOOLTIP)
duplicates_layout.addWidget(self.default_delete_to_trash_checkbox)
duplicates_layout.addLayout(threshold_layout)
self.duplicate_confirm_delete_checkbox = QCheckBox(
UITexts.SETTINGS_DUPLICATE_CONFIRM_DELETE_LABEL)
self.duplicate_confirm_delete_checkbox.setToolTip(
UITexts.SETTINGS_DUPLICATE_CONFIRM_DELETE_TOOLTIP)
duplicates_layout.addWidget(self.duplicate_confirm_delete_checkbox)
duplicates_layout.addStretch()
# --- Faces & People Tab ---
faces_tab = QWidget()
faces_layout = QVBoxLayout(faces_tab)
@@ -409,6 +614,10 @@ class SettingsDialog(QDialog):
self.face_history_spin.setToolTip(UITexts.SETTINGS_FACE_HISTORY_TOOLTIP)
faces_layout.addLayout(face_history_layout)
self.face_use_last_name_check = QCheckBox(UITexts.SETTINGS_USE_LAST_NAME_LABEL)
self.face_use_last_name_check.setToolTip(UITexts.SETTINGS_USE_LAST_NAME_TOOLTIP)
faces_layout.addWidget(self.face_use_last_name_check)
# --- Pets Section ---
faces_layout.addSpacing(10)
pets_header = QLabel(UITexts.TYPE_PET)
@@ -465,6 +674,10 @@ class SettingsDialog(QDialog):
self.pet_history_spin.setToolTip(UITexts.SETTINGS_PET_HISTORY_TOOLTIP)
faces_layout.addLayout(pet_history_layout)
self.pet_use_last_name_check = QCheckBox(UITexts.SETTINGS_USE_LAST_NAME_LABEL)
self.pet_use_last_name_check.setToolTip(UITexts.SETTINGS_USE_LAST_NAME_TOOLTIP)
faces_layout.addWidget(self.pet_use_last_name_check)
# --- Body Section ---
faces_layout.addSpacing(10)
body_header = QLabel(UITexts.TYPE_BODY)
@@ -512,6 +725,10 @@ class SettingsDialog(QDialog):
self.body_history_spin.setToolTip(UITexts.SETTINGS_BODY_HISTORY_TOOLTIP)
faces_layout.addLayout(body_history_layout)
self.body_use_last_name_check = QCheckBox(UITexts.SETTINGS_USE_LAST_NAME_LABEL)
self.body_use_last_name_check.setToolTip(UITexts.SETTINGS_USE_LAST_NAME_TOOLTIP)
faces_layout.addWidget(self.body_use_last_name_check)
# --- Object Section ---
faces_layout.addSpacing(10)
object_header = QLabel(UITexts.TYPE_OBJECT)
@@ -558,6 +775,12 @@ class SettingsDialog(QDialog):
self.object_history_spin.setToolTip(UITexts.SETTINGS_OBJECT_HISTORY_TOOLTIP)
faces_layout.addLayout(object_history_layout)
self.object_use_last_name_check = QCheckBox(
UITexts.SETTINGS_USE_LAST_NAME_LABEL)
self.object_use_last_name_check.setToolTip(
UITexts.SETTINGS_USE_LAST_NAME_TOOLTIP)
faces_layout.addWidget(self.object_use_last_name_check)
# --- Landmark Section ---
faces_layout.addSpacing(10)
landmark_header = QLabel(UITexts.TYPE_LANDMARK)
@@ -605,6 +828,12 @@ class SettingsDialog(QDialog):
faces_layout.addLayout(landmark_history_layout)
faces_layout.addStretch()
self.landmark_use_last_name_check = QCheckBox(
UITexts.SETTINGS_USE_LAST_NAME_LABEL)
self.landmark_use_last_name_check.setToolTip(
UITexts.SETTINGS_USE_LAST_NAME_TOOLTIP)
faces_layout.addWidget(self.landmark_use_last_name_check)
# --- Viewer Tab ---
viewer_wheel_layout = QHBoxLayout()
viewer_wheel_label = QLabel(UITexts.SETTINGS_VIEWER_WHEEL_SPEED_LABEL)
@@ -645,6 +874,7 @@ class SettingsDialog(QDialog):
tabs.addTab(viewer_tab, UITexts.SETTINGS_GROUP_VIEWER)
tabs.addTab(faces_tab, UITexts.SETTINGS_GROUP_AREAS)
tabs.addTab(scanner_tab, UITexts.SETTINGS_GROUP_SCANNER)
tabs.addTab(duplicates_tab, UITexts.SETTINGS_GROUP_DUPLICATES)
# --- Button Box ---
button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
@@ -705,6 +935,12 @@ class SettingsDialog(QDialog):
landmark_history_count = APP_CONFIG.get(
"landmark_menu_max_items", FACES_MENU_MAX_ITEMS_DEFAULT)
face_use_last_name = APP_CONFIG.get("face_use_last_name", False)
pet_use_last_name = APP_CONFIG.get("pet_use_last_name", False)
body_use_last_name = APP_CONFIG.get("body_use_last_name", False)
object_use_last_name = APP_CONFIG.get("object_use_last_name", False)
landmark_use_last_name = APP_CONFIG.get("landmark_use_last_name", False)
thumbs_refresh_interval = APP_CONFIG.get(
"thumbnails_refresh_interval", THUMBNAILS_REFRESH_INTERVAL_DEFAULT)
thumbs_bg_color = APP_CONFIG.get(
@@ -737,6 +973,31 @@ class SettingsDialog(QDialog):
show_tags = APP_CONFIG.get("thumbnails_show_tags", True)
filmstrip_position = APP_CONFIG.get("filmstrip_position", "bottom")
duplicate_method = APP_CONFIG.get("duplicate_method", "histogram_hashing")
method_idx = self.duplicate_method_combo.findData(duplicate_method)
if method_idx != -1:
self.duplicate_method_combo.setCurrentIndex(method_idx)
duplicate_threshold = APP_CONFIG.get(
"duplicate_threshold", SCANNER_SETTINGS_DEFAULTS["duplicate_threshold"])
self.duplicate_threshold_slider.setValue(duplicate_threshold)
self.duplicate_threshold_value_label.setText(f"{duplicate_threshold}%")
default_delete_to_trash = APP_CONFIG.get("default_delete_to_trash", True)
self.default_delete_to_trash_checkbox.setChecked(default_delete_to_trash)
duplicate_confirm_delete = APP_CONFIG.get("duplicate_confirm_delete", True)
self.duplicate_confirm_delete_checkbox.setChecked(duplicate_confirm_delete)
duplicate_whitelist = APP_CONFIG.get(
"duplicate_whitelist", SCANNER_SETTINGS_DEFAULTS["duplicate_whitelist"])
for p in [x.strip() for x in duplicate_whitelist.split(",") if x.strip()]:
self._add_path_to_list(self.duplicate_whitelist_list, p)
duplicate_blacklist = APP_CONFIG.get(
"duplicate_blacklist", SCANNER_SETTINGS_DEFAULTS["duplicate_blacklist"])
for p in [x.strip() for x in duplicate_blacklist.split(",") if x.strip()]:
self._add_path_to_list(self.duplicate_blacklist_list, p)
self.scan_max_level_spin.setValue(scan_max_level)
self.scan_batch_size_spin.setValue(scan_batch_size)
self.threads_spin.setValue(scan_threads)
@@ -795,6 +1056,12 @@ class SettingsDialog(QDialog):
self.object_history_spin.setValue(object_history_count)
self.landmark_history_spin.setValue(landmark_history_count)
self.face_use_last_name_check.setChecked(face_use_last_name)
self.pet_use_last_name_check.setChecked(pet_use_last_name)
self.body_use_last_name_check.setChecked(body_use_last_name)
self.object_use_last_name_check.setChecked(object_use_last_name)
self.landmark_use_last_name_check.setChecked(landmark_use_last_name)
self.thumbs_refresh_spin.setValue(thumbs_refresh_interval)
self.set_thumbs_bg_button_color(thumbs_bg_color)
self.set_thumbs_filename_button_color(thumbs_filename_color)
@@ -821,6 +1088,7 @@ class SettingsDialog(QDialog):
self.filmstrip_pos_combo.setCurrentText(
pos_map.get(filmstrip_position, UITexts.FILMSTRIP_BOTTOM))
self.update_mediapipe_status()
self.update_duplicate_scan_count()
def set_button_color(self, color_str):
"""Sets the background color of the button and stores the value."""
@@ -979,7 +1247,7 @@ class SettingsDialog(QDialog):
elif self.download_model_btn:
self.download_model_btn.hide()
# --- Mascotas (Pets) ---
# --- Pets ---
if not AVAILABLE_PET_ENGINES:
self.pet_engine_combo.setEnabled(False)
self.pet_tags_edit.setEnabled(False)
@@ -1050,6 +1318,13 @@ class SettingsDialog(QDialog):
APP_CONFIG["landmark_menu_max_items"] = self.landmark_history_spin.value()
APP_CONFIG["thumbnails_refresh_interval"] = self.thumbs_refresh_spin.value()
APP_CONFIG["face_use_last_name"] = self.face_use_last_name_check.isChecked()
APP_CONFIG["pet_use_last_name"] = self.pet_use_last_name_check.isChecked()
APP_CONFIG["body_use_last_name"] = self.body_use_last_name_check.isChecked()
APP_CONFIG["object_use_last_name"] = self.object_use_last_name_check.isChecked()
APP_CONFIG["landmark_use_last_name"] = \
self.landmark_use_last_name_check.isChecked()
APP_CONFIG["thumbnails_bg_color"] = self.current_thumbs_bg_color
APP_CONFIG["thumbnails_filename_color"] = self.current_thumbs_filename_color
APP_CONFIG["thumbnails_tags_color"] = self.current_thumbs_tags_color
@@ -1068,6 +1343,19 @@ class SettingsDialog(QDialog):
APP_CONFIG["thumbnails_show_rating"] = self.show_rating_check.isChecked()
APP_CONFIG["thumbnails_show_tags"] = self.show_tags_check.isChecked()
APP_CONFIG["viewer_wheel_speed"] = self.viewer_wheel_spin.value()
APP_CONFIG["duplicate_method"] = self.duplicate_method_combo.currentData()
APP_CONFIG["duplicate_threshold"] = self.duplicate_threshold_slider.value()
APP_CONFIG["default_delete_to_trash"] = \
self.default_delete_to_trash_checkbox.isChecked()
APP_CONFIG["duplicate_confirm_delete"] = \
self.duplicate_confirm_delete_checkbox.isChecked()
wl_paths = [self.duplicate_whitelist_list.item(i).text()
for i in range(self.duplicate_whitelist_list.count())]
APP_CONFIG["duplicate_whitelist"] = ",".join(wl_paths)
bl_paths = [self.duplicate_blacklist_list.item(i).text()
for i in range(self.duplicate_blacklist_list.count())]
APP_CONFIG["duplicate_blacklist"] = ",".join(bl_paths)
APP_CONFIG["viewer_auto_resize_window"] = \
self.viewer_auto_resize_check.isChecked()
APP_CONFIG["face_detection_engine"] = self.face_engine_combo.currentText()
@@ -1108,3 +1396,111 @@ class SettingsDialog(QDialog):
def _on_downloader_finished(self):
self.downloader_thread = None
def _stop_downloader_thread(self):
if self.downloader_thread and self.downloader_thread.isRunning():
self.downloader_thread.stop()
self.downloader_thread.wait()
self.downloader_thread = None
def done(self, r):
self._stop_downloader_thread() # Ensure downloader thread stops and waits.
if self.counter_thread and self.counter_thread.isRunning():
self.counter_thread.stop()
self.counter_thread.wait()
super().done(r)
def closeEvent(self, event):
self._stop_downloader_thread() # Ensure downloader thread stops and waits.
if self.counter_thread and self.counter_thread.isRunning():
self.counter_thread.stop()
self.counter_thread.wait()
super().closeEvent(event)
def _add_path_to_list(self, list_widget, path):
"""Adds a path to a QListWidget with existence validation."""
path = os.path.abspath(os.path.expanduser(path.strip()))
if not path:
return
to_remove = []
for i in range(list_widget.count()):
existing_p = list_widget.item(i).text()
if existing_p == path:
return
# If a parent folder already exists, do not add this subfolder.
if path.startswith(existing_p + os.sep):
return
# If the new path is a parent of an existing one, mark it for removal.
if existing_p.startswith(path + os.sep):
to_remove.append(i)
# Remove unnecessary subfolders (reverse order to not alter indices).
for i in sorted(to_remove, reverse=True):
list_widget.takeItem(i)
item = QListWidgetItem(path)
if not os.path.isdir(path):
item.setForeground(QColor("red"))
item.setToolTip(
UITexts.SETTINGS_PATH_NOT_FOUND_WARNING.format(path))
list_widget.addItem(item)
def add_whitelist_path(self):
"""Opens a directory dialog to add a folder to the whitelist."""
dir_path = QFileDialog.getExistingDirectory(self, UITexts.SELECT)
if dir_path:
self._add_path_to_list(self.duplicate_whitelist_list, dir_path)
def remove_whitelist_path(self):
"""Removes the selected folders from the whitelist list."""
for item in self.duplicate_whitelist_list.selectedItems():
self.duplicate_whitelist_list.takeItem(
self.duplicate_whitelist_list.row(item))
def add_blacklist_path(self):
"""Opens a directory dialog to add a folder to the blacklist."""
dir_path = QFileDialog.getExistingDirectory(self, UITexts.SELECT)
if dir_path:
self._add_path_to_list(self.duplicate_blacklist_list, dir_path)
def remove_blacklist_path(self):
"""Removes the selected folders from the blacklist list."""
for item in self.duplicate_blacklist_list.selectedItems():
self.duplicate_blacklist_list.takeItem(
self.duplicate_blacklist_list.row(item))
def update_duplicate_scan_count(self):
"""Calculates and updates the count of images in whitelist/blacklist
using a background thread."""
if self.counter_thread and self.counter_thread.isRunning():
self.counter_thread.stop()
self.counter_thread.wait()
whitelist_paths = [self.duplicate_whitelist_list.item(i).text()
for i in range(self.duplicate_whitelist_list.count())]
blacklist_paths = [self.duplicate_blacklist_list.item(i).text()
for i in range(self.duplicate_blacklist_list.count())]
whitelist = [os.path.abspath(os.path.expanduser(p.strip()))
for p in whitelist_paths if p.strip()]
blacklist = {os.path.abspath(os.path.expanduser(p.strip()))
for p in blacklist_paths if p.strip()}
if not whitelist:
self.duplicate_scan_count_label.setText(
UITexts.SETTINGS_DUPLICATE_SCAN_COUNT_LABEL.format(0))
self.duplicate_scan_progress.hide()
return
self.duplicate_scan_progress.show()
self.counter_thread = DuplicateFileCounter(
whitelist, blacklist, IMAGE_EXTENSIONS)
self.counter_thread.count_updated.connect(
lambda c: self.duplicate_scan_count_label.setText(
UITexts.SETTINGS_DUPLICATE_SCAN_COUNT_LABEL.format(c)))
self.counter_thread.finished.connect(
lambda: self.duplicate_scan_progress.hide())
self.counter_thread.start()

View File

@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
setup(
name="bagheeraview",
version="0.9.15",
version="0.9.26",
author="Ignacio Serantes",
description="Bagheera Image Viewer - An image viewer for KDE with Baloo in mind",
long_description="A fast image viewer built with PySide6, featuring search and "
@@ -16,6 +16,7 @@ setup(
"exiv2",
"psutil",
"watchdog",
"imagehash", # Added for perceptual hashing
"mediapipe",
"face_recognition",
"face_recognition_models",
@@ -38,8 +39,11 @@ setup(
"filesystemwatcher",
"metadatamanager",
"propertiesdialog",
"thumbnailwidget",
#"thumbnailwidget",
"duplicatecache",
"duplicatedialog",
"widgets",
"filesystemwatcher",
"xmpmanager",
"utils"
],

View File

@@ -129,11 +129,19 @@ class TagEditWidget(QWidget):
search_layout = QHBoxLayout()
self.search_bar = QLineEdit()
self.search_bar.setPlaceholderText(UITexts.TAG_SEARCH_PLACEHOLDER)
# Obtener la altura preferida del QLineEdit para usarla en los botones
line_edit_height = self.search_bar.sizeHint().height()
self.search_bar.setClearButtonEnabled(True)
self.btn_add_tag = QPushButton("+")
self.btn_add_tag.setFixedWidth(30)
self.btn_add_tag.setFixedSize(30, line_edit_height)
self.btn_add_tag.setToolTip(UITexts.TAG_ADD_TOOLTIP)
self.btn_refresh_tags = QPushButton()
self.btn_refresh_tags.setIcon(QIcon.fromTheme("view-refresh"))
self.btn_refresh_tags.setFixedSize(30, line_edit_height)
self.btn_refresh_tags.setToolTip(UITexts.TAG_REFRESH_TOOLTIP)
search_layout.addWidget(self.search_bar)
search_layout.addWidget(self.btn_add_tag)
search_layout.addWidget(self.btn_refresh_tags)
layout.addLayout(search_layout)
# Tag tree view setup
@@ -159,6 +167,7 @@ class TagEditWidget(QWidget):
# Connect signals to slots
self.btn_apply.clicked.connect(self.save_changes)
self.btn_add_tag.clicked.connect(self.create_new_tag)
self.btn_refresh_tags.clicked.connect(self.refresh_available_tags)
self.search_bar.textChanged.connect(self.handle_search)
self.source_model.itemChanged.connect(self.sync_tags)
self.tree_view.search_requested.connect(self.on_search_requested)
@@ -177,6 +186,12 @@ class TagEditWidget(QWidget):
tags in files_data.items()}
self.refresh_ui()
def refresh_available_tags(self):
"""Manual refresh of available tags from Baloo."""
self.load_available_tags()
self._load_all = True
self.init_data()
def load_available_tags(self):
"""Loads all known tags from the Baloo index database."""
db_path = os.path.expanduser("~/.local/share/baloo/index")
@@ -399,7 +414,7 @@ class TagEditWidget(QWidget):
if not full_path:
return ""
words = full_path.replace('/', ' ').split()
search_terms = [f"tags:'{word}'" for word in words if word]
search_terms = [f"tags='{word}'" for word in words if word]
return " ".join(search_terms)
def _get_current_query_text(self):
@@ -649,7 +664,6 @@ class LayoutsWidget(QWidget):
item_name = QTableWidgetItem(name)
item_name.setData(Qt.UserRole, f_path)
item_name.setData(Qt.UserRole, f_path) # Store full path in item
item_date = QTableWidgetItem(dt)
self.table.setItem(i, 0, item_name)
@@ -1342,8 +1356,8 @@ class FaceNameInputWidget(QWidget):
super().__init__(parent)
self.main_win = main_win
self.region_type = region_type
# Usamos deque para gestionar el historial de forma eficiente con un máximo
# configurable de elementos.
# Use deque to manage history efficiently with a configurable maximum
# number of items.
max_items = APP_CONFIG.get("faces_menu_max_items",
FACES_MENU_MAX_ITEMS_DEFAULT)
if self.region_type == "Pet":
@@ -1373,7 +1387,7 @@ class FaceNameInputWidget(QWidget):
self.name_combo.setToolTip(UITexts.FACE_NAME_TOOLTIP)
self.name_combo.lineEdit().setClearButtonEnabled(True)
# 2. Completer para la funcionalidad de autocompletado.
# 2. Completer for autocomplete functionality.
self.completer = QCompleter(self)
self.completer.setCaseSensitivity(Qt.CaseInsensitive)
self.completer.setFilterMode(Qt.MatchContains)

View File

@@ -17,13 +17,17 @@ Dependencies:
"""
import os
import re
import logging
from utils import preserve_mtime
from metadatamanager import notify_baloo
from metadatamanager import notify_baloo, mark_app_modified
from constants import UITexts
try:
import exiv2
except ImportError:
exiv2 = None
logger = logging.getLogger(__name__)
class XmpManager:
"""
@@ -38,8 +42,9 @@ class XmpManager:
This method parses the XMP data structure for a `mwg-rs:RegionList`,
extracts all regions of type 'Face', and returns them as a list of
dictionaries. Each dictionary contains the face's name and its
normalized coordinates (center x, center y, width, height).
dictionaries.
Each dictionary contains the face's name and its normalized coordinates
(center x, center y, width, height).
Args:
path (str): The path to the image file.
@@ -161,8 +166,15 @@ class XmpManager:
xmp[f"{area_base}/stArea:unit"] = 'normalized'
img.writeMetadata()
notify_baloo(path)
mark_app_modified(path)
return True
except Exception as e:
print(f"Error saving faces to XMP: {e}")
return False
error_msg = str(e)
if "kerTooLargeJpegSegment" in error_msg or "38" in error_msg:
msg = UITexts.ERROR_JPEG_METADATA_LIMIT.format(os.path.basename(path))
logger.error(msg)
raise IOError(msg) from e
logger.error(f"Error saving faces to XMP: {e}")
raise