Compare commits

..

23 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
17 changed files with 4284 additions and 295 deletions

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. 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. 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 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? ¿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? ¿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? ¿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 - v0.9.14 -
· Corregido el problema de resolución de los thumbnails · Corregido el problema de resolución de los thumbnails

View File

@@ -29,7 +29,7 @@ if FORCE_X11:
# --- CONFIGURATION --- # --- CONFIGURATION ---
PROG_NAME = "Bagheera Image Viewer" PROG_NAME = "Bagheera Image Viewer"
PROG_ID = "bagheeraview" PROG_ID = "bagheeraview"
PROG_VERSION = "0.9.15" PROG_VERSION = "0.9.26"
PROG_AUTHOR = "Ignacio Serantes" PROG_AUTHOR = "Ignacio Serantes"
# --- CACHE SETTINGS --- # --- CACHE SETTINGS ---
@@ -57,16 +57,26 @@ DISK_CACHE_MAX_BYTES = 10 * 1024 * 1024 * 1024
# --- PATHS --- # --- PATHS ---
CONFIG_FILE = f"{PROG_ID}rc" CONFIG_FILE = f"{PROG_ID}rc"
CONFIG_LOCATION = '.config/iserantes' CONFIG_LOCATION = os.environ.get('XDG_CONFIG_HOME')
CONFIG_DIR = os.path.join(os.path.expanduser("~"), CONFIG_LOCATION, PROG_ID) CONFIG_DIR = os.path.join(CONFIG_LOCATION, 'iserantes', PROG_ID)
CONFIG_PATH = os.path.join(CONFIG_DIR, CONFIG_FILE) 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_FILE = "history.json"
HISTORY_PATH = os.path.join(CONFIG_DIR, HISTORY_FILE) HISTORY_PATH = os.path.join(APP_DATA_DIR, HISTORY_FILE)
LAYOUTS_DIR = os.path.join(CONFIG_DIR, "layouts") # Layouts saving directory LAYOUTS_DIR = os.path.join(APP_DATA_DIR, "layouts") # Layouts saving directory
FAVORITES_FILE = "favorites.json" 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(): def save_app_config():
@@ -76,9 +86,8 @@ def save_app_config():
with open(CONFIG_PATH, 'w', encoding='utf-8') as f: with open(CONFIG_PATH, 'w', encoding='utf-8') as f:
# Use APP_CONFIG global # Use APP_CONFIG global
json.dump(APP_CONFIG, f, indent=4) json.dump(APP_CONFIG, f, indent=4)
except OSError: except Exception as e:
# Silently fail for now, but could log this print(f"CRITICAL: Failed to save configuration to {CONFIG_PATH}: {e}")
pass
# --- CONFIGURATION LOADING --- # --- CONFIGURATION LOADING ---
@@ -133,7 +142,18 @@ SCANNER_SETTINGS_DEFAULTS = {
"scan_full_on_start": True, "scan_full_on_start": True,
"person_tags": "", "person_tags": "",
"generation_threads": 4, "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 --- # --- IMAGE VIEWER DEFAULTS ---
@@ -190,15 +210,15 @@ if importlib.util.find_spec("mediapipe") is not None:
pass pass
HAVE_FACE_RECOGNITION = importlib.util.find_spec("face_recognition") is not None HAVE_FACE_RECOGNITION = importlib.util.find_spec("face_recognition") is not None
MEDIAPIPE_FACE_MODEL_PATH = os.path.join(CONFIG_DIR, MEDIAPIPE_FACE_MODEL_PATH = os.path.join(
"blaze_face_short_range.tflite") APP_DATA_DIR, "blaze_face_short_range.tflite")
MEDIAPIPE_FACE_MODEL_URL = ( MEDIAPIPE_FACE_MODEL_URL = (
"https://storage.googleapis.com/mediapipe-models/face_detector/" "https://storage.googleapis.com/mediapipe-models/face_detector/"
"blaze_face_short_range/float16/1/blaze_face_short_range.tflite" "blaze_face_short_range/float16/1/blaze_face_short_range.tflite"
) )
MEDIAPIPE_OBJECT_MODEL_PATH = os.path.join(CONFIG_DIR, MEDIAPIPE_OBJECT_MODEL_PATH = os.path.join(
"efficientdet_lite0.tflite") APP_DATA_DIR, "efficientdet_lite0.tflite")
MEDIAPIPE_OBJECT_MODEL_URL = ( MEDIAPIPE_OBJECT_MODEL_URL = (
"https://storage.googleapis.com/mediapipe-models/object_detector/" "https://storage.googleapis.com/mediapipe-models/object_detector/"
"efficientdet_lite0/float16/1/efficientdet_lite0.tflite" "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_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 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" DEFAULT_FACE_BOX_COLOR = "#FFFFFF"
# Load preferred engine from config, or use the default. # Load preferred engine from config, or use the default.
FACE_DETECTION_ENGINE = APP_CONFIG.get("face_detection_engine", FACE_DETECTION_ENGINE = APP_CONFIG.get("face_detection_engine",
@@ -374,11 +404,13 @@ _UI_TEXTS = {
"SEARCH": "Search", "SEARCH": "Search",
"SELECT": "Select", "SELECT": "Select",
"ERROR": "Error", "ERROR": "Error",
"FILE_NOT_FOUND": "File not found",
"WARNING": "Warning", "WARNING": "Warning",
"INFO": "Info", "INFO": "Info",
"LOAD": "Load", "LOAD": "Load",
"SAVE": "Save", "SAVE": "Save",
"CREATE": "Create", "CREATE": "Create",
"CANCEL": "Cancel",
"RENAME": "Rename", "RENAME": "Rename",
"COPY": "Copy", "COPY": "Copy",
"DELETE": "Delete", "DELETE": "Delete",
@@ -489,6 +521,80 @@ _UI_TEXTS = {
"MENU_SHOW_LAYOUTS": "Show Layouts", "MENU_SHOW_LAYOUTS": "Show Layouts",
"MENU_SHOW_HISTORY": "Show History", "MENU_SHOW_HISTORY": "Show History",
"MENU_SETTINGS": "Settings", "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_SCANNER": "Scanner",
"SETTINGS_GROUP_AREAS": "Areas", "SETTINGS_GROUP_AREAS": "Areas",
"SETTINGS_GROUP_THUMBNAILS": "Thumbnails", "SETTINGS_GROUP_THUMBNAILS": "Thumbnails",
@@ -544,6 +650,11 @@ _UI_TEXTS = {
"landmarks.", "landmarks.",
"SETTINGS_LANDMARK_HISTORY_TOOLTIP": "Maximum number of recently used " "SETTINGS_LANDMARK_HISTORY_TOOLTIP": "Maximum number of recently used "
"landmark names to remember.", "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_FACE_HISTORY_COUNT_LABEL": "Max face history:",
"SETTINGS_THUMBS_REFRESH_LABEL": "Thumbs refresh interval (ms):", "SETTINGS_THUMBS_REFRESH_LABEL": "Thumbs refresh interval (ms):",
"MENU_VIEWER_SETTINGS": "Viewer Settings", "MENU_VIEWER_SETTINGS": "Viewer Settings",
@@ -684,6 +795,8 @@ _UI_TEXTS = {
"RENAME_ERROR_EXISTS": "File '{}' already exists.", "RENAME_ERROR_EXISTS": "File '{}' already exists.",
"FILE_RENAMED": "File renamed to {}", "FILE_RENAMED": "File renamed to {}",
"ERROR_RENAME": "Could not rename file: {}", "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": "", "MAIN_DOCK_TITLE": "",
"LAYOUTS_TAB": "Layouts", "LAYOUTS_TAB": "Layouts",
"LAYOUTS_TABLE_HEADER": ["Name", "Last Modified"], "LAYOUTS_TABLE_HEADER": ["Name", "Last Modified"],
@@ -720,6 +833,8 @@ _UI_TEXTS = {
"TAG_ALL_TAGS": "📂 ALL TAGS", "TAG_ALL_TAGS": "📂 ALL TAGS",
"TAG_NEW_TAG_TITLE": "New Tag", "TAG_NEW_TAG_TITLE": "New Tag",
"SEARCH_BY_TAG": "Search by this 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):", "TAG_NEW_TAG_TEXT": "Enter tag name (use / for hierarchy):",
"SEARCH_ADD_AND": "Add AND this tag to search", "SEARCH_ADD_AND": "Add AND this tag to search",
"SEARCH_ADD_OR": "Add OR 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_TABLE_HEADER": ["Property", "Value"],
"PROPERTIES_ADD_ATTR": "Add Attribute", "PROPERTIES_ADD_ATTR": "Add Attribute",
"PROPERTIES_ADD_ATTR_NAME": "Attribute Name (e.g. user.comment):", "PROPERTIES_ADD_ATTR_NAME": "Attribute Name (e.g. user.comment):",
"PROPERTIES_DELETE_ALL": "Delete All",
"PROPERTIES_ADD_ATTR_VALUE": "Value for {}:", "PROPERTIES_ADD_ATTR_VALUE": "Value for {}:",
"PROPERTIES_ERROR_SET_ATTR": "Failed to set xattr: {}", "PROPERTIES_ERROR_SET_ATTR": "Failed to set xattr: {}",
"PROPERTIES_ERROR_ADD_ATTR": "Failed to add xattr: {}", "PROPERTIES_ERROR_ADD_ATTR": "Failed to add xattr: {}",
@@ -806,6 +922,7 @@ _UI_TEXTS = {
"CONTEXT_MENU_OPEN": "Open", "CONTEXT_MENU_OPEN": "Open",
"CONTEXT_MENU_OPEN_SEARCH_LOCATION": "Open and search location", "CONTEXT_MENU_OPEN_SEARCH_LOCATION": "Open and search location",
"CONTEXT_MENU_OPEN_DEFAULT_APP": "Open location with default application", "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_MOVE_TO": "Move to...",
"CONTEXT_MENU_COPY_TO": "Copy to...", "CONTEXT_MENU_COPY_TO": "Copy to...",
"CONTEXT_MENU_ROTATE": "Rotate", "CONTEXT_MENU_ROTATE": "Rotate",
@@ -839,11 +956,13 @@ _UI_TEXTS = {
"SEARCH": "Buscar", "SEARCH": "Buscar",
"SELECT": "Seleccionar", "SELECT": "Seleccionar",
"ERROR": "Error", "ERROR": "Error",
"FILE_NOT_FOUND": "Archivo no encontrado",
"WARNING": "Advertencia", "WARNING": "Advertencia",
"INFO": "Información", "INFO": "Información",
"LOAD": "Cargar", "LOAD": "Cargar",
"SAVE": "Guardar", "SAVE": "Guardar",
"CREATE": "Crear", "CREATE": "Crear",
"CANCEL": "Cancelar",
"RENAME": "Renombrar", "RENAME": "Renombrar",
"COPY": "Copiar", "COPY": "Copiar",
"DELETE": "Eliminar", "DELETE": "Eliminar",
@@ -954,6 +1073,84 @@ _UI_TEXTS = {
"MENU_SHOW_LAYOUTS": "Mostrar Diseños", "MENU_SHOW_LAYOUTS": "Mostrar Diseños",
"MENU_SHOW_HISTORY": "Mostrar Historial", "MENU_SHOW_HISTORY": "Mostrar Historial",
"MENU_SETTINGS": "Opciones", "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_SCANNER": "Escáner",
"SETTINGS_GROUP_AREAS": "Áreas", "SETTINGS_GROUP_AREAS": "Áreas",
"SETTINGS_GROUP_THUMBNAILS": "Miniaturas", "SETTINGS_GROUP_THUMBNAILS": "Miniaturas",
@@ -1015,6 +1212,11 @@ _UI_TEXTS = {
"alrededor de los lugares.", "alrededor de los lugares.",
"SETTINGS_LANDMARK_HISTORY_TOOLTIP": "Número máximo de nombres de lugares " "SETTINGS_LANDMARK_HISTORY_TOOLTIP": "Número máximo de nombres de lugares "
"usados recientemente para recordar.", "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_FACE_HISTORY_COUNT_LABEL": "Máximo historial de caras:",
"SETTINGS_THUMBS_REFRESH_LABEL": "Intervalo refresco miniaturas (ms):", "SETTINGS_THUMBS_REFRESH_LABEL": "Intervalo refresco miniaturas (ms):",
"SETTINGS_THUMBS_BG_COLOR_LABEL": "Color de fondo de miniaturas:", "SETTINGS_THUMBS_BG_COLOR_LABEL": "Color de fondo de miniaturas:",
@@ -1155,6 +1357,8 @@ _UI_TEXTS = {
"RENAME_ERROR_EXISTS": "El archivo '{}' ya existe.", "RENAME_ERROR_EXISTS": "El archivo '{}' ya existe.",
"FILE_RENAMED": "Archivo renombrado a {}", "FILE_RENAMED": "Archivo renombrado a {}",
"ERROR_RENAME": "No se pudo renombrar el archivo: {}", "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", "MAIN_DOCK_TITLE": "Panel principal",
"LAYOUTS_TAB": "Diseños", "LAYOUTS_TAB": "Diseños",
"LAYOUTS_TABLE_HEADER": ["Nombre", "Última Modificación"], "LAYOUTS_TABLE_HEADER": ["Nombre", "Última Modificación"],
@@ -1191,6 +1395,9 @@ _UI_TEXTS = {
"TAG_ALL_TAGS": "📂 TODAS LAS ETIQUETAS", "TAG_ALL_TAGS": "📂 TODAS LAS ETIQUETAS",
"TAG_NEW_TAG_TITLE": "Nueva Etiqueta", "TAG_NEW_TAG_TITLE": "Nueva Etiqueta",
"SEARCH_BY_TAG": "Buscar por esta 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 " "TAG_NEW_TAG_TEXT": "Introduce el nombre de la etiqueta (usa / para "
"jerarquía):", "jerarquía):",
"SEARCH_ADD_AND": "Añadir AND esta etiqueta a la búsqueda", "SEARCH_ADD_AND": "Añadir AND esta etiqueta a la búsqueda",
@@ -1226,6 +1433,7 @@ _UI_TEXTS = {
"PROPERTIES_TABLE_HEADER": ["Propiedad", "Valor"], "PROPERTIES_TABLE_HEADER": ["Propiedad", "Valor"],
"PROPERTIES_ADD_ATTR": "Añadir Atributo", "PROPERTIES_ADD_ATTR": "Añadir Atributo",
"PROPERTIES_ADD_ATTR_NAME": "Nombre del Atributo (ej. user.comment):", "PROPERTIES_ADD_ATTR_NAME": "Nombre del Atributo (ej. user.comment):",
"PROPERTIES_DELETE_ALL": "Borrar Todo",
"PROPERTIES_ADD_ATTR_VALUE": "Valor para {}:", "PROPERTIES_ADD_ATTR_VALUE": "Valor para {}:",
"PROPERTIES_ERROR_SET_ATTR": "Fallo al establecer xattr: {}", "PROPERTIES_ERROR_SET_ATTR": "Fallo al establecer xattr: {}",
"PROPERTIES_ERROR_ADD_ATTR": "Fallo al añadir xattr: {}", "PROPERTIES_ERROR_ADD_ATTR": "Fallo al añadir xattr: {}",
@@ -1278,6 +1486,7 @@ _UI_TEXTS = {
"CONTEXT_MENU_OPEN": "Abrir", "CONTEXT_MENU_OPEN": "Abrir",
"CONTEXT_MENU_OPEN_SEARCH_LOCATION": "Abrir y buscar en ubicación", "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_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_MOVE_TO": "Mover a...",
"CONTEXT_MENU_COPY_TO": "Copiar a...", "CONTEXT_MENU_COPY_TO": "Copiar a...",
"CONTEXT_MENU_ROTATE": "Girar", "CONTEXT_MENU_ROTATE": "Girar",
@@ -1312,11 +1521,13 @@ _UI_TEXTS = {
"SEARCH": "Buscar", "SEARCH": "Buscar",
"SELECT": "Seleccionar", "SELECT": "Seleccionar",
"ERROR": "Erro", "ERROR": "Erro",
"FILE_NOT_FOUND": "Ficheiro non atopado",
"WARNING": "Advertencia", "WARNING": "Advertencia",
"INFO": "Información", "INFO": "Información",
"LOAD": "Cargar", "LOAD": "Cargar",
"SAVE": "Gardar", "SAVE": "Gardar",
"CREATE": "Crear", "CREATE": "Crear",
"CANCEL": "Cancelar",
"RENAME": "Renomear", "RENAME": "Renomear",
"COPY": "Copiar", "COPY": "Copiar",
"DELETE": "Eliminar", "DELETE": "Eliminar",
@@ -1428,6 +1639,83 @@ _UI_TEXTS = {
"MENU_SHOW_LAYOUTS": "Amosar Deseños", "MENU_SHOW_LAYOUTS": "Amosar Deseños",
"MENU_SHOW_HISTORY": "Amosar Historial", "MENU_SHOW_HISTORY": "Amosar Historial",
"MENU_SETTINGS": "Opcións", "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_SCANNER": "Escáner",
"SETTINGS_GROUP_AREAS": "Áreas", "SETTINGS_GROUP_AREAS": "Áreas",
"SETTINGS_GROUP_THUMBNAILS": "Miniaturas", "SETTINGS_GROUP_THUMBNAILS": "Miniaturas",
@@ -1489,6 +1777,11 @@ _UI_TEXTS = {
"arredor dos lugares.", "arredor dos lugares.",
"SETTINGS_LANDMARK_HISTORY_TOOLTIP": "Número máximo de nomes de lugares " "SETTINGS_LANDMARK_HISTORY_TOOLTIP": "Número máximo de nomes de lugares "
"usados recentemente para lembrar.", "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_FACE_HISTORY_COUNT_LABEL": "Máximo historial de caras:",
"SETTINGS_THUMBS_REFRESH_LABEL": "Intervalo refresco miniaturas (ms):", "SETTINGS_THUMBS_REFRESH_LABEL": "Intervalo refresco miniaturas (ms):",
"SETTINGS_THUMBS_BG_COLOR_LABEL": "Cor de fondo de miniaturas:", "SETTINGS_THUMBS_BG_COLOR_LABEL": "Cor de fondo de miniaturas:",
@@ -1628,6 +1921,8 @@ _UI_TEXTS = {
"RENAME_ERROR_EXISTS": "O ficheiro '{}' xa existe.", "RENAME_ERROR_EXISTS": "O ficheiro '{}' xa existe.",
"FILE_RENAMED": "Ficheiro renomeado a {}", "FILE_RENAMED": "Ficheiro renomeado a {}",
"ERROR_RENAME": "Non se puido renomear o ficheiro: {}", "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", "MAIN_DOCK_TITLE": "Panel principal",
"LAYOUTS_TAB": "Deseños", "LAYOUTS_TAB": "Deseños",
"LAYOUTS_TABLE_HEADER": ["Nome", "Última Modificación"], "LAYOUTS_TABLE_HEADER": ["Nome", "Última Modificación"],
@@ -1664,6 +1959,9 @@ _UI_TEXTS = {
"TAG_ALL_TAGS": "📂 TÓDALAS ETIQUETAS", "TAG_ALL_TAGS": "📂 TÓDALAS ETIQUETAS",
"TAG_NEW_TAG_TITLE": "Nova Etiqueta", "TAG_NEW_TAG_TITLE": "Nova Etiqueta",
"SEARCH_BY_TAG": "Buscar por esta 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 " "TAG_NEW_TAG_TEXT": "Introduce o nome da etiqueta (usa / para "
"xerarquía):", "xerarquía):",
"SEARCH_ADD_AND": "Engadir AND esta etiqueta á busca", "SEARCH_ADD_AND": "Engadir AND esta etiqueta á busca",
@@ -1699,6 +1997,7 @@ _UI_TEXTS = {
"PROPERTIES_TABLE_HEADER": ["Propiedade", "Valor"], "PROPERTIES_TABLE_HEADER": ["Propiedade", "Valor"],
"PROPERTIES_ADD_ATTR": "Engadir Atributo", "PROPERTIES_ADD_ATTR": "Engadir Atributo",
"PROPERTIES_ADD_ATTR_NAME": "Nome do Atributo (ex. user.comment):", "PROPERTIES_ADD_ATTR_NAME": "Nome do Atributo (ex. user.comment):",
"PROPERTIES_DELETE_ALL": "Borrar Todo",
"PROPERTIES_ADD_ATTR_VALUE": "Valor para {}:", "PROPERTIES_ADD_ATTR_VALUE": "Valor para {}:",
"PROPERTIES_ERROR_SET_ATTR": "Fallo ao establecer xattr: {}", "PROPERTIES_ERROR_SET_ATTR": "Fallo ao establecer xattr: {}",
"PROPERTIES_ERROR_ADD_ATTR": "Fallo ao engadir 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_FILE": "Copiar URL do Ficheiro",
"CONTEXT_MENU_COPY_DIR": "Copiar Ruta do Directorio", "CONTEXT_MENU_COPY_DIR": "Copiar Ruta do Directorio",
"CONTEXT_MENU_PROPERTIES": "Propiedades", "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_NO_APPS_FOUND": "Non se atoparon aplicacións",
"CONTEXT_MENU_REGENERATE": "Rexenerar Miniatura", "CONTEXT_MENU_REGENERATE": "Rexenerar Miniatura",
"CONTEXT_MENU_ERROR_LISTING_APPS": "Erro listando aplicacións", "CONTEXT_MENU_ERROR_LISTING_APPS": "Erro listando aplicacións",
@@ -1786,6 +2086,7 @@ _UI_TEXTS = {
# Determine which language to use for UI strings # Determine which language to use for UI strings
def _get_current_language(): 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) lang = os.getenv("BAGHEERA_LANG") or APP_CONFIG.get("language", DEFAULT_LANGUAGE)
if lang == "system": 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 import os
try: try:
from watchdog.observers import Observer from watchdog.observers import Observer
@@ -14,20 +25,32 @@ class FileSystemWatcher(QObject):
Monitors file system events (created, deleted, modified) for specified directories. Monitors file system events (created, deleted, modified) for specified directories.
Emits signals to notify the main application thread of changes. Emits signals to notify the main application thread of changes.
""" """
# Signals emitted to the rest of the application
# ---------------------------------------------
file_created = Signal(str) file_created = Signal(str)
file_deleted = Signal(str) file_deleted = Signal(str)
file_modified = Signal(str) file_modified = Signal(str)
_file_modified_from_handler = Signal(str) # Internal signal from handler thread _file_modified_from_handler = Signal(str) # Internal signal from handler thread
file_moved = Signal(str, str) 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_moved = Signal(str, str)
directory_modified = Signal(str) # For changes that might not be specific files directory_modified = Signal(str) # For changes that might not be specific files
_modified_events_queue = {} # {path: QTimer} _modified_events_queue = {} # {path: QTimer}
"""Queue to manage debouncing of modification events."""
def __init__(self, parent=None): def __init__(self, parent=None):
"""
Initializes the FileSystemWatcher.
Args:
parent (QObject, optional): The parent object. Defaults to None.
"""
super().__init__(parent) super().__init__(parent)
self._watched_directories = set() self._watched_directories = set()
self._debounce_interval = 500 # milliseconds
if HAVE_WATCHDOG: if HAVE_WATCHDOG:
self._observer = Observer() self._observer = Observer()
@@ -36,16 +59,21 @@ class FileSystemWatcher(QObject):
else: else:
self._observer = None # Keep observer as None if watchdog is not available 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 # Connect the internal signal to the debouncing slot
if HAVE_WATCHDOG: if HAVE_WATCHDOG:
self._file_modified_from_handler.connect(self._on_file_modified_debounced) self._file_modified_from_handler.connect(self._on_file_modified_debounced)
def _on_file_modified_debounced(self, path): 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 # Debounce timer for modified events to avoid multiple signals for a single save
if path in self._modified_events_queue: if path in self._modified_events_queue:
self._modified_events_queue[path].stop() self._modified_events_queue[path].stop()
@@ -59,7 +87,12 @@ class FileSystemWatcher(QObject):
self._modified_events_queue[path].start() self._modified_events_queue[path].start()
def _emit_modified_after_debounce(self, path): 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) self.file_modified.emit(path)
if path in self._modified_events_queue: if path in self._modified_events_queue:
# Safely delete the QTimer object when done # Safely delete the QTimer object when done
@@ -67,7 +100,16 @@ class FileSystemWatcher(QObject):
del self._modified_events_queue[path] del self._modified_events_queue[path]
def add_path(self, 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: if not HAVE_WATCHDOG or self._observer is None:
return return
@@ -111,7 +153,12 @@ class FileSystemWatcher(QObject):
self.monitoring_status_changed.emit(True) self.monitoring_status_changed.emit(True)
def remove_path(self, path): 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: if not HAVE_WATCHDOG or self._observer is None:
return return
abs_path = os.path.normpath(os.path.abspath(os.path.expanduser(path))) 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) self.monitoring_status_changed.emit(False)
def stop(self): def stop(self):
"""Stops the file system observer.""" """
Stops the file system observer and cleans up active timers.
"""
if HAVE_WATCHDOG and self._observer: if HAVE_WATCHDOG and self._observer:
self._observer.stop() self._observer.stop()
self._observer.join() self._observer.join()
for timer in self._modified_events_queue.values(): for timer in self._modified_events_queue.values():
timer.stop() timer.stop()
@@ -151,14 +199,24 @@ class FileSystemWatcher(QObject):
if HAVE_WATCHDOG: if HAVE_WATCHDOG:
class _Handler(FileSystemEventHandler): 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 # Signal to communicate to main thread
file_modified_from_thread = Signal(str) file_modified_from_thread = Signal(str)
"""Custom event handler for watchdog events."""
def __init__(self, watcher): def __init__(self, watcher):
"""
Initializes the handler with a reference to the main watcher.
"""
super().__init__() super().__init__()
self.watcher = watcher self.watcher = watcher
def on_created(self, event): def on_created(self, event):
"""Called when a file or directory is created."""
if event.is_directory: if event.is_directory:
self.watcher.directory_modified.emit(event.src_path) self.watcher.directory_modified.emit(event.src_path)
return return
@@ -166,6 +224,7 @@ class FileSystemWatcher(QObject):
self.watcher.file_created.emit(event.src_path) self.watcher.file_created.emit(event.src_path)
def on_deleted(self, event): def on_deleted(self, event):
"""Called when a file or directory is deleted."""
if event.is_directory: if event.is_directory:
self.watcher.directory_modified.emit(event.src_path) self.watcher.directory_modified.emit(event.src_path)
return return
@@ -173,6 +232,7 @@ class FileSystemWatcher(QObject):
self.watcher.file_deleted.emit(event.src_path) self.watcher.file_deleted.emit(event.src_path)
def on_moved(self, event): def on_moved(self, event):
"""Called when a file or directory is moved or renamed."""
if event.is_directory: if event.is_directory:
self.watcher.directory_moved.emit(event.src_path, event.dest_path) self.watcher.directory_moved.emit(event.src_path, event.dest_path)
self.watcher.directory_modified.emit(event.src_path) self.watcher.directory_modified.emit(event.src_path)
@@ -181,6 +241,7 @@ class FileSystemWatcher(QObject):
self.watcher.file_moved.emit(event.src_path, event.dest_path) self.watcher.file_moved.emit(event.src_path, event.dest_path)
def on_closed(self, event): def on_closed(self, event):
"""Called when a file is closed."""
if event.is_directory: if event.is_directory:
self.watcher.directory_modified.emit(event.src_path) self.watcher.directory_modified.emit(event.src_path)
return return
@@ -188,6 +249,7 @@ class FileSystemWatcher(QObject):
self.watcher.file_modified.emit(event.src_path) self.watcher.file_modified.emit(event.src_path)
def on_modified(self, event): def on_modified(self, event):
"""Called when a file or directory is modified."""
if event.is_directory: if event.is_directory:
self.watcher.directory_modified.emit(event.src_path) self.watcher.directory_modified.emit(event.src_path)
return return
@@ -195,9 +257,21 @@ class FileSystemWatcher(QObject):
self.watcher._file_modified_from_handler.emit(event.src_path) self.watcher._file_modified_from_handler.emit(event.src_path)
def _emit_modified(self, 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) self.watcher.file_modified.emit(path)
if path in self.watcher._modified_events_queue: if path in self.watcher._modified_events_queue:
del self.watcher._modified_events_queue[path] del self.watcher._modified_events_queue[path]
def _is_image_file(self, 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 return os.path.splitext(path)[1].lower() in IMAGE_EXTENSIONS

View File

@@ -13,7 +13,7 @@ Classes:
import os import os
import logging import logging
import math 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 PySide6.QtGui import QImage, QImageReader, QPixmap, QTransform
from xmpmanager import XmpManager from xmpmanager import XmpManager
from constants import ( from constants import (
@@ -42,6 +42,7 @@ class ImagePreloader(QThread):
def __init__(self): def __init__(self):
"""Initializes the preloader thread.""" """Initializes the preloader thread."""
super().__init__() super().__init__()
self.setObjectName("ImagePreloaderThread")
self.path = None self.path = None
self.index = -1 self.index = -1
self.mutex = QMutex() self.mutex = QMutex()
@@ -344,7 +345,7 @@ class ImageController(QObject):
faces_to_save.append(face_copy) 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"): 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'.""" """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, self.metadata_changed.emit(current_path,
{'tags': new_tags_list, {'tags': new_tags_list,
'rating': self._current_rating}) 'rating': self._current_rating})
except IOError as e: except Exception:
print(f"Error setting tags for {current_path}: {e}") raise
def set_rating(self, new_rating): def set_rating(self, new_rating):
current_path = self.get_current_path() current_path = self.get_current_path()
@@ -688,21 +689,36 @@ class ImageController(QObject):
if self.pixmap_original.isNull(): if self.pixmap_original.isNull():
return QPixmap() return QPixmap()
transform = QTransform().rotate(self.rotation) # Start with an identity transform
transformed_pixmap = self.pixmap_original.transformed( transform = QTransform()
transform,
Qt.SmoothTransformation
)
new_size = transformed_pixmap.size() * self.zoom_factor
scaled_pixmap = transformed_pixmap.scaled(new_size, Qt.KeepAspectRatio,
Qt.SmoothTransformation)
# Apply rotation
if self.rotation != 0:
transform.rotate(float(self.rotation))
# Apply flips
if self.flip_h: if self.flip_h:
scaled_pixmap = scaled_pixmap.transformed(QTransform().scale(-1, 1)) transform.scale(-1, 1)
if self.flip_v: if self.flip_v:
scaled_pixmap = scaled_pixmap.transformed(QTransform().scale(1, -1)) transform.scale(1, -1)
# 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 return scaled_pixmap
else:
return transformed_pixmap
def rotate(self, angle): def rotate(self, angle):
""" """

View File

@@ -32,11 +32,13 @@ from PySide6.QtCore import (
QObject, QThread, Signal, QMutex, QReadWriteLock, QSize, QSemaphore, QWaitCondition, QObject, QThread, Signal, QMutex, QReadWriteLock, QSize, QSemaphore, QWaitCondition,
QByteArray, QBuffer, QIODevice, Qt, QTimer, QRunnable, QThreadPool, QFile 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 ( from constants import (
APP_CONFIG, CACHE_PATH, CACHE_MAX_SIZE, CACHE_MAX_RAM_BYTES, CONFIG_DIR, APP_CONFIG, CACHE_PATH, CACHE_MAX_SIZE, CACHE_MAX_RAM_BYTES,
MIN_FREE_RAM_PERCENT, DISK_CACHE_MAX_BYTES, HAVE_BAGHEERASEARCH_LIB, IMAGE_EXTENSIONS, APP_DATA_DIR, MIN_FREE_RAM_PERCENT, DISK_CACHE_MAX_BYTES,
HAVE_BAGHEERASEARCH_LIB, IMAGE_EXTENSIONS,
SCANNER_SETTINGS_DEFAULTS, SEARCH_CMD, THUMBNAIL_SIZES, SCANNER_SETTINGS_DEFAULTS, SEARCH_CMD, THUMBNAIL_SIZES,
UITexts UITexts
) )
@@ -132,13 +134,11 @@ class ScannerWorker(QRunnable):
sizes_to_check = self.target_sizes if self.target_sizes is not None \ sizes_to_check = self.target_sizes if self.target_sizes is not None \
else SCANNER_GENERATE_SIZES else SCANNER_GENERATE_SIZES
if self._is_cancelled:
if self.semaphore:
self.semaphore.release()
return
fd = None fd = None
try: try:
if self._is_cancelled:
return
# Optimize: Open file once to reuse FD for stat and xattrs # Optimize: Open file once to reuse FD for stat and xattrs
fd = os.open(self.path, os.O_RDONLY) fd = os.open(self.path, os.O_RDONLY)
stat_res = os.fstat(fd) stat_res = os.fstat(fd)
@@ -196,8 +196,11 @@ class ScannerWorker(QRunnable):
tags, rating = res_meta.tags, res_meta.rating tags, rating = res_meta.tags, res_meta.rating
self.result = (self.path, smallest_thumb_for_signal, self.result = (self.path, smallest_thumb_for_signal,
curr_mtime, tags, rating, curr_inode, curr_dev) 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: 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 self.result = None
finally: finally:
if fd is not None: if fd is not None:
@@ -265,7 +268,7 @@ def generate_thumbnail(path, size, fd=None):
# better quality for upscaling. # better quality for upscaling.
return img.scaled(size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation) return img.scaled(size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation)
except Exception as e: 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 return None
@@ -283,6 +286,7 @@ class CacheWriter(QThread):
self._condition_new_data = QWaitCondition() self._condition_new_data = QWaitCondition()
self._condition_space_available = QWaitCondition() self._condition_space_available = QWaitCondition()
# Soft limit for blocking producers (background threads) # Soft limit for blocking producers (background threads)
self.setObjectName("CacheWriterThread") # Add this line
self._max_size = 50 self._max_size = 50
self._running = True self._running = True
@@ -332,9 +336,9 @@ class CacheWriter(QThread):
self._running = False self._running = False
# Do not clear the queue here; let the run loop drain it to prevent data loss. # Do not clear the queue here; let the run loop drain it to prevent data loss.
self._condition_new_data.wakeAll() self._condition_new_data.wakeAll()
logger.debug(f"{self.objectName()} stop requested, waking all.")
self._condition_space_available.wakeAll() self._condition_space_available.wakeAll()
self._mutex.unlock() self._mutex.unlock()
self.wait()
def run(self): def run(self):
self.setPriority(QThread.IdlePriority) self.setPriority(QThread.IdlePriority)
@@ -379,6 +383,7 @@ class CacheWriter(QThread):
self.cache._batch_write_to_lmdb(batch) self.cache._batch_write_to_lmdb(batch)
except Exception as e: except Exception as e:
logger.error(f"CacheWriter batch write error: {e}") logger.error(f"CacheWriter batch write error: {e}")
logger.debug(f"{self.objectName()} run method exiting.")
class CacheLoader(QThread): class CacheLoader(QThread):
@@ -442,7 +447,6 @@ class CacheLoader(QThread):
self._mutex.lock() self._mutex.lock()
self._condition.wakeAll() self._condition.wakeAll()
self._mutex.unlock() self._mutex.unlock()
self.wait()
def run(self): def run(self):
self.setPriority(QThread.IdlePriority) self.setPriority(QThread.IdlePriority)
@@ -522,15 +526,24 @@ class ThumbnailCache(QObject):
self._db_lock = QMutex() # Lock specifically for _db_handles access self._db_lock = QMutex() # Lock specifically for _db_handles access
self._db_handles = {} # Cache for LMDB database handles (dbi) self._db_handles = {} # Cache for LMDB database handles (dbi)
self._cancel_loading = False self._cancel_loading = False
self._broken_cache = {} # (dev, inode, size) -> (mtime, error_msg)
self._cache_bytes_size = 0 self._cache_bytes_size = 0
self._cache_writer = None self._cache_writer = None
self._cache_loader = 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() self.lmdb_open()
def lmdb_open(self): def lmdb_open(self):
# Initialize LMDB environment # Initialize LMDB environment
cache_dir = Path(CONFIG_DIR) cache_dir = Path(APP_DATA_DIR)
cache_dir.mkdir(parents=True, exist_ok=True) cache_dir.mkdir(parents=True, exist_ok=True)
try: try:
@@ -558,12 +571,22 @@ class ThumbnailCache(QObject):
self._lmdb_env = None self._lmdb_env = None
def lmdb_close(self): 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: if hasattr(self, '_cache_writer') and self._cache_writer:
self._cache_writer.stop() 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 self._cache_writer = None
if hasattr(self, '_cache_loader') and self._cache_loader: if hasattr(self, '_cache_loader') and self._cache_loader:
self._cache_loader.stop() 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._cache_loader = None
self._loading_set.clear() self._loading_set.clear()
self._futures.clear() self._futures.clear()
@@ -658,8 +681,9 @@ class ThumbnailCache(QObject):
import psutil import psutil
mem = psutil.virtual_memory() mem = psutil.virtual_memory()
if (mem.available / mem.total) * 100 < MIN_FREE_RAM_PERCENT: if (mem.available / mem.total) * 100 < MIN_FREE_RAM_PERCENT:
logger.warning(f"Low system memory detected (< {MIN_FREE_RAM_PERCENT}%). " logger.warning(f"Low system memory detected "
"Applying aggressive tiered pruning.") f"(< {MIN_FREE_RAM_PERCENT}%). "
f"Applying aggressive tiered pruning.")
# Strategy: first clear ALL cached high-res tiers to free space quickly # Strategy: first clear ALL cached high-res tiers to free space quickly
# while keeping the 128px grid thumbnails intact. # while keeping the 128px grid thumbnails intact.
@@ -721,12 +745,28 @@ class ThumbnailCache(QObject):
def _get_tier_for_size(self, requested_size): def _get_tier_for_size(self, requested_size):
"""Determines the ideal thumbnail tier based on the requested size.""" """Determines the ideal thumbnail tier based on the requested size."""
if requested_size < 192: if requested_size <= 128:
return 128 return 128
if requested_size < 320: if requested_size <= 256:
return 256 return 256
return 512 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): def _resolve_file_identity(self, path, curr_mtime, inode, device_id):
"""Helper to resolve file mtime, device, and inode.""" """Helper to resolve file mtime, device, and inode."""
mtime = curr_mtime mtime = curr_mtime
@@ -847,6 +887,12 @@ class ThumbnailCache(QObject):
if mtime is None: if mtime is None:
return EMPTY_THUMBNAIL 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 best_img, best_mtime, best_tier = None, 0, 0
with self._read_lock(): with self._read_lock():
@@ -1189,6 +1235,12 @@ class ThumbnailCache(QObject):
return None return None
if not img.save(buf, "PNG"): if not img.save(buf, "PNG"):
# 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") logger.error("Failed to save image to buffer")
return None return None
return ba.data() return ba.data()
@@ -1311,6 +1363,7 @@ class CacheCleaner(QThread):
def stop(self): def stop(self):
"""Signals the thread to stop.""" """Signals the thread to stop."""
self._is_running = False self._is_running = False
self.wait()
def run(self): def run(self):
self.setPriority(QThread.IdlePriority) self.setPriority(QThread.IdlePriority)
@@ -1382,8 +1435,18 @@ class ThumbnailGenerator(QThread):
# The signal/slot mechanism handles thread safety automatically. # The signal/slot mechanism handles thread safety automatically.
emitter.progress_tick.connect(on_tick, Qt.QueuedConnection) emitter.progress_tick.connect(on_tick, Qt.QueuedConnection)
started_count = 0 # Process in batches to avoid saturating the global thread pool queue.
for path in self.paths: # 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
batch_slice = self.paths[i : i + batch_size]
started_in_batch = 0
for path in batch_slice:
if self._abort: if self._abort:
break break
runnable = ScannerWorker(self.cache, path, target_sizes=[self.size], runnable = ScannerWorker(self.cache, path, target_sizes=[self.size],
@@ -1392,17 +1455,18 @@ class ThumbnailGenerator(QThread):
runnable.setAutoDelete(False) runnable.setAutoDelete(False)
self._workers_mutex.lock() self._workers_mutex.lock()
if self._abort:
self._workers_mutex.unlock()
break
self._workers.append(runnable) self._workers.append(runnable)
self._workers_mutex.unlock() self._workers_mutex.unlock()
pool.start(runnable) pool.start(runnable)
started_count += 1 started_in_batch += 1
if started_count > 0: if started_in_batch > 0:
sem.acquire(started_count) # 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_mutex.lock()
self._workers.clear() self._workers.clear()
@@ -1425,13 +1489,13 @@ class ImageScanner(QThread):
more_files_available = Signal(int, int) # Last loaded index, remainder more_files_available = Signal(int, int) # Last loaded index, remainder
def __init__(self, cache, paths, is_file_list=False, viewers=None, def __init__(self, cache, paths, is_file_list=False, viewers=None,
thread_pool_manager=None): thread_pool_manager=None, target_sizes=None):
# is_file_list is not used
if not paths or not isinstance(paths, (list, tuple)): if not paths or not isinstance(paths, (list, tuple)):
logger.warning("ImageScanner initialized with empty or invalid paths") logger.warning("ImageScanner initialized with empty or invalid paths")
paths = [] paths = []
super().__init__() super().__init__()
self.cache = cache self.cache = cache
self.target_sizes = target_sizes
self.all_files = [] self.all_files = []
self.thread_pool_manager = thread_pool_manager self.thread_pool_manager = thread_pool_manager
self._viewers = viewers self._viewers = viewers
@@ -1788,7 +1852,8 @@ class ImageScanner(QThread):
return return
for f_path, _ in tasks: 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) r.setAutoDelete(False)
runnables.append(r) runnables.append(r)
self._current_workers.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, Signal, QPoint, QSize, Qt, QMimeData, QUrl, QTimer, QEvent, QRect, Slot, QRectF,
QThread, QObject QThread, QObject
) )
from PySide6.QtDBus import QDBusConnection, QDBusMessage, QDBus
from constants import ( from constants import (
APP_CONFIG, DEFAULT_FACE_BOX_COLOR, DEFAULT_PET_BOX_COLOR, DEFAULT_VIEWER_SHORTCUTS, 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 current_path = controller.get_current_path() if controller else None
if not current_path: if not current_path:
return return
try:
controller.toggle_tag(tag_name, is_checked) controller.toggle_tag(tag_name, is_checked)
except Exception as e:
QMessageBox.critical(self.viewer, UITexts.ERROR, str(e))
self.viewer.update_status_bar() self.viewer.update_status_bar()
if self.main_win: if self.main_win:
if is_checked: if is_checked:
@@ -419,11 +423,22 @@ class FaceCanvas(QLabel):
self.edit_handle = None self.edit_handle = None
self.edit_start_rect = QRect() self.edit_start_rect = QRect()
self.resize_margin = 8 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_rect = QRect()
self.crop_handle = None self.crop_handle = None
self.crop_start_pos = QPoint() self.crop_start_pos = QPoint()
self.crop_start_rect = QRect() self.crop_start_rect = QRect()
def _clear_zoom_indicator(self):
self.zoom_indicator_point = None
self.update()
def map_from_source(self, face_data): def map_from_source(self, face_data):
"""Maps original normalized face data to current canvas QRect.""" """Maps original normalized face data to current canvas QRect."""
nx = face_data.get('x', 0) nx = face_data.get('x', 0)
@@ -623,6 +638,18 @@ class FaceCanvas(QLabel):
painter.drawRect(pt.x() - offset, pt.y() - offset, painter.drawRect(pt.x() - offset, pt.y() - offset,
handle_size, handle_size) 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): def _hit_test(self, pos):
"""Determines if the mouse is over a name, handle, or body.""" """Determines if the mouse is over a name, handle, or body."""
if not self.controller.show_faces: if not self.controller.show_faces:
@@ -990,8 +1017,12 @@ class FaceCanvas(QLabel):
history = history_list \ history = history_list \
if self.viewer.main_win else [] 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( 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) main_win=self.viewer.main_win, region_type=region_type)
if ok and full_tag: if ok and full_tag:
@@ -1122,18 +1153,62 @@ class ZoomManager(QObject):
super().__init__(viewer) super().__init__(viewer)
self.viewer = viewer self.viewer = viewer
def zoom(self, factor, reset=False): def zoom(self, factor=1.1, reset=False, focus_point=None, absolute_factor=None):
"""Applies zoom to the image.""" """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: if reset:
self.viewer.controller.zoom_factor = 1.0 self.viewer.controller.zoom_factor = 1.0
self.viewer.update_view(resize_win=True) 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: 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.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 # Notify the main window that the image (and possibly index) has changed
# so it can update its selection. # so it can update its selection.
self.viewer.index_changed.emit(self.viewer.controller.index) 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) self.zoomed.emit(self.viewer.controller.zoom_factor)
if hasattr(self.viewer, 'sync_filmstrip_selection'): if hasattr(self.viewer, 'sync_filmstrip_selection'):
self.viewer.sync_filmstrip_selection(self.viewer.controller.index) self.viewer.sync_filmstrip_selection(self.viewer.controller.index)
@@ -1645,16 +1720,23 @@ class ImageViewer(QWidget):
if pane != self.active_pane: if pane != self.active_pane:
pane.controller.zoom_factor = factor pane.controller.zoom_factor = factor
pane.update_view(resize_win=False) pane.update_view(resize_win=False)
# Re-apply relative scroll after zoom changes bounds # 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: if self.active_pane:
h_bar = self.active_pane.scroll_area.horizontalScrollBar() h_bar = self.active_pane.scroll_area.horizontalScrollBar()
v_bar = self.active_pane.scroll_area.verticalScrollBar() v_bar = self.active_pane.scroll_area.verticalScrollBar()
h_max = h_bar.maximum() h_max = h_bar.maximum()
v_max = v_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 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 y_pct = v_bar.value() / v_max if v_max > 0 else 0
pane.set_scroll_relative(x_pct, y_pct)
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): def update_grid_layout(self):
# Clear layout # Clear layout
@@ -1693,6 +1775,9 @@ class ImageViewer(QWidget):
for i in range(count - current_panes): for i in range(count - current_panes):
new_idx = (start_idx + i + 1) % len(img_list) new_idx = (start_idx + i + 1) % len(img_list)
pane = self.add_pane(img_list, new_idx, None, 0) # Metadata will load 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() pane.load_and_fit_image()
else: else:
# Remove panes (keep active if possible, else keep first) # Remove panes (keep active if possible, else keep first)
@@ -1710,10 +1795,13 @@ class ImageViewer(QWidget):
# sizing # sizing
QTimer.singleShot( QTimer.singleShot(
0, lambda: self.active_pane.update_view(resize_win=True)) 0, lambda: self.active_pane.update_view(resize_win=True))
self.adjustSize()
def toggle_link_panes(self): def toggle_link_panes(self):
"""Toggles the synchronized zoom/scroll for comparison mode.""" """Toggles the synchronized zoom/scroll for comparison mode."""
self.panes_linked = not self.panes_linked 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() self.update_status_bar()
def update_highlight(self): def update_highlight(self):
@@ -1731,6 +1819,9 @@ class ImageViewer(QWidget):
def reset_inactivity_timer(self): def reset_inactivity_timer(self):
"""Resets the inactivity timer and restores controls visibility.""" """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(): if self.isFullScreen():
self.unsetCursor() self.unsetCursor()
if self.main_win and self.main_win.show_viewer_status_bar: if self.main_win and self.main_win.show_viewer_status_bar:
@@ -2110,7 +2201,11 @@ class ImageViewer(QWidget):
available_h -= self.status_bar_container.sizeHint().height() available_h -= self.status_bar_container.sizeHint().height()
should_resize = True should_resize = True
self.zoom_manager.calculate_initial_zoom(available_w, available_h, 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.isFullScreen())
self.update_view(resize_win=should_resize) self.update_view(resize_win=should_resize)
@@ -2744,8 +2839,11 @@ class ImageViewer(QWidget):
self.main_win.face_names_history = updated_history self.main_win.face_names_history = updated_history
# Save changes and add new tag # Save changes and add new tag
try:
self.controller.save_faces() self.controller.save_faces()
self.controller.toggle_tag(new_full_tag, True) self.controller.toggle_tag(new_full_tag, True)
except Exception as e:
QMessageBox.critical(self, UITexts.ERROR, str(e))
if self.canvas: if self.canvas:
self.canvas.update() self.canvas.update()
@@ -3027,8 +3125,10 @@ class ImageViewer(QWidget):
QApplication.processEvents() QApplication.processEvents()
history = self.main_win.face_names_history if self.main_win else [] 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( 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: if ok and full_tag:
new_face['name'] = full_tag new_face['name'] = full_tag
@@ -3043,7 +3143,10 @@ class ImageViewer(QWidget):
self.canvas.update() self.canvas.update()
if added_count > 0: if added_count > 0:
try:
self.controller.save_faces() self.controller.save_faces()
except Exception as e:
QMessageBox.critical(self, UITexts.ERROR, str(e))
def run_pet_detection(self): def run_pet_detection(self):
"""Runs pet detection on the current image.""" """Runs pet detection on the current image."""
@@ -3084,8 +3187,11 @@ class ImageViewer(QWidget):
QApplication.processEvents() QApplication.processEvents()
history = self.main_win.pet_names_history if self.main_win else [] 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( 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: if ok and full_tag:
new_pet['name'] = full_tag new_pet['name'] = full_tag
@@ -3099,7 +3205,10 @@ class ImageViewer(QWidget):
self.canvas.update() self.canvas.update()
if added_count > 0: if added_count > 0:
try:
self.controller.save_faces() self.controller.save_faces()
except Exception as e:
QMessageBox.critical(self, UITexts.ERROR, str(e))
def run_body_detection(self): def run_body_detection(self):
"""Runs body detection on the current image.""" """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 # 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. # 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 [] 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( 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: if ok and full_tag:
new_body['name'] = full_tag new_body['name'] = full_tag
@@ -3157,7 +3269,10 @@ class ImageViewer(QWidget):
self.canvas.update() self.canvas.update()
if added_count > 0: if added_count > 0:
try:
self.controller.save_faces() self.controller.save_faces()
except Exception as e:
QMessageBox.critical(self, UITexts.ERROR, str(e))
def toggle_filmstrip(self): def toggle_filmstrip(self):
"""Shows or hides the filmstrip widget.""" """Shows or hides the filmstrip widget."""
@@ -3219,17 +3334,19 @@ class ImageViewer(QWidget):
self.reset_inactivity_timer() self.reset_inactivity_timer()
if event.modifiers() & Qt.ControlModifier: if event.modifiers() & Qt.ControlModifier:
# Zoom with Ctrl + Wheel # Zoom with Ctrl + Wheel
focus_pos = event.position().toPoint()
if event.angleDelta().y() > 0: if event.angleDelta().y() > 0:
self.zoom_manager.zoom(1.1) self.zoom_manager.zoom(1.1, focus_point=focus_pos)
else: else:
self.zoom_manager.zoom(0.9) self.zoom_manager.zoom(0.9, focus_point=focus_pos)
else: else:
# Navigate next/previous based on configurable speed # Navigate next/previous based on configurable speed
speed = APP_CONFIG.get("viewer_wheel_speed", VIEWER_WHEEL_SPEED_DEFAULT) speed = APP_CONFIG.get("viewer_wheel_speed", VIEWER_WHEEL_SPEED_DEFAULT)
# A standard tick is 120. We define a threshold based on speed. # A standard tick is 120. We define a threshold based on speed.
# Speed 1 (slowest) requires a full 120 delta. # Speed 1 (slowest) requires a full 120 delta.
# Speed 10 (fastest) requires 120/10 = 12 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() self._wheel_scroll_accumulator += event.angleDelta().y()
@@ -3336,17 +3453,18 @@ class ImageViewer(QWidget):
service, which is common on Linux desktops. service, which is common on Linux desktops.
""" """
try: try:
cmd = [ msg = QDBusMessage.createMethodCall(
"dbus-send", "--session", "--print-reply", "org.freedesktop.ScreenSaver",
"--dest=org.freedesktop.ScreenSaver",
"/org/freedesktop/ScreenSaver", "/org/freedesktop/ScreenSaver",
"org.freedesktop.ScreenSaver.Inhibit", "org.freedesktop.ScreenSaver",
"string:bagheeraview", # Application name "Inhibit"
"string:Viewing images" # Reason for inhibition )
] msg.setArguments(["bagheeraview", "Viewing images"])
output = subprocess.check_output(cmd, text=True) reply = QDBusConnection.sessionBus().call(msg)
# Extract the cookie from the output (e.g., "uint32 12345") if reply.type() == QDBusMessage.ReplyMessage:
self.inhibit_cookie = int(output.split()[-1]) self.inhibit_cookie = reply.arguments()[0]
else:
self.inhibit_cookie = None
except Exception as e: except Exception as e:
print(f"{UITexts.ERROR} inhibiting power management: {e}") print(f"{UITexts.ERROR} inhibiting power management: {e}")
self.inhibit_cookie = None self.inhibit_cookie = None
@@ -3360,13 +3478,14 @@ class ImageViewer(QWidget):
""" """
if hasattr(self, 'inhibit_cookie') and self.inhibit_cookie is not None: if hasattr(self, 'inhibit_cookie') and self.inhibit_cookie is not None:
try: try:
subprocess.Popen([ msg = QDBusMessage.createMethodCall(
"dbus-send", "--session", "org.freedesktop.ScreenSaver",
"--dest=org.freedesktop.ScreenSaver",
"/org/freedesktop/ScreenSaver", "/org/freedesktop/ScreenSaver",
"org.freedesktop.ScreenSaver.UnInhibit", "org.freedesktop.ScreenSaver",
f"uint32:{self.inhibit_cookie}" "UnInhibit"
]) )
msg.setArguments([self.inhibit_cookie])
QDBusConnection.sessionBus().call(msg, QDBus.NoBlock)
self.inhibit_cookie = None self.inhibit_cookie = None
except Exception as e: except Exception as e:
print(f"{UITexts.ERROR} uninhibiting: {e}") print(f"{UITexts.ERROR} uninhibiting: {e}")

View File

@@ -9,6 +9,7 @@ Classes:
""" """
import os import os
import collections import collections
import logging
from PySide6.QtDBus import QDBusConnection, QDBusMessage, QDBus from PySide6.QtDBus import QDBusConnection, QDBusMessage, QDBus
try: try:
import exiv2 import exiv2
@@ -16,13 +17,31 @@ try:
except ImportError: except ImportError:
exiv2 = None exiv2 = None
HAVE_EXIV2 = False HAVE_EXIV2 = False
from utils import preserve_mtime from utils import preserve_mtime
from constants import RATING_XATTR_NAME, XATTR_NAME from constants import RATING_XATTR_NAME, XATTR_NAME, UITexts
logger = logging.getLogger(__name__)
_app_modified_callback = None
MetadataResult = collections.namedtuple('MetadataResult', ['tags', 'rating']) MetadataResult = collections.namedtuple('MetadataResult', ['tags', 'rating'])
EMPTY_METADATA = MetadataResult([], 0) EMPTY_METADATA = MetadataResult([], 0)
def set_app_modified_callback(callback):
global _app_modified_callback
_app_modified_callback = callback
def mark_app_modified(path):
"""Triggers the application-modified callback for a path."""
if _app_modified_callback:
_app_modified_callback(path)
def notify_baloo(path): def notify_baloo(path):
""" """
Notifies the Baloo file indexer about a file change using DBus. Notifies the Baloo file indexer about a file change using DBus.
@@ -106,6 +125,74 @@ class MetadataManager:
return all_metadata 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: class XattrManager:
"""A manager class to handle reading and writing extended attributes (xattrs).""" """A manager class to handle reading and writing extended attributes (xattrs)."""
@@ -148,6 +235,7 @@ class XattrManager:
return return
try: try:
with preserve_mtime(file_path): with preserve_mtime(file_path):
mark_app_modified(file_path)
if value: if value:
os.setxattr(file_path, attr_name, str(value).encode('utf-8')) os.setxattr(file_path, attr_name, str(value).encode('utf-8'))
else: else:

View File

@@ -12,7 +12,7 @@ Classes:
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QWidget, QLabel, QVBoxLayout, QMessageBox, QMenu, QInputDialog, QWidget, QLabel, QVBoxLayout, QMessageBox, QMenu, QInputDialog,
QDialog, QTableWidget, QTableWidgetItem, QHeaderView, QTabWidget, QDialog, QTableWidget, QTableWidgetItem, QHeaderView, QTabWidget,
QFormLayout, QDialogButtonBox, QApplication QFormLayout, QDialogButtonBox, QApplication, QToolBar, QAbstractItemView
) )
from PySide6.QtGui import ( from PySide6.QtGui import (
QImageReader, QIcon, QColor QImageReader, QIcon, QColor
@@ -76,6 +76,8 @@ class PropertiesDialog(QDialog):
self.setWindowTitle(UITexts.PROPERTIES_TITLE) self.setWindowTitle(UITexts.PROPERTIES_TITLE)
self._initial_tags = initial_tags if initial_tags is not None else [] self._initial_tags = initial_tags if initial_tags is not None else []
self._initial_rating = initial_rating self._initial_rating = initial_rating
self.original_xattrs = {}
self.original_exif = {}
self.loader = None self.loader = None
self.resize(400, 500) self.resize(400, 500)
self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint) self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint)
@@ -136,21 +138,25 @@ class PropertiesDialog(QDialog):
meta_widget = QWidget() meta_widget = QWidget()
meta_layout = QVBoxLayout(meta_widget) 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 = QTableWidget()
self.table.setColumnCount(2) self.table.setColumnCount(2)
self.table.setHorizontalHeaderLabels(UITexts.PROPERTIES_TABLE_HEADER) self.table.setHorizontalHeaderLabels(UITexts.PROPERTIES_TABLE_HEADER)
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Interactive) self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
self.table.horizontalHeader().setSectionResizeMode(1, self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
QHeaderView.ResizeToContents)
self.table.setColumnWidth(0, self.width() * 0.4)
self.table.verticalHeader().setVisible(False) self.table.verticalHeader().setVisible(False)
self.table.setAlternatingRowColors(True) self.table.setAlternatingRowColors(True)
self.table.setEditTriggers(QTableWidget.DoubleClicked | self.table.setEditTriggers(QAbstractItemView.DoubleClicked |
QTableWidget.EditKeyPressed | QAbstractItemView.EditKeyPressed |
QTableWidget.SelectedClicked) QAbstractItemView.AnyKeyPressed)
self.table.setSelectionBehavior(QTableWidget.SelectRows) 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.setContextMenuPolicy(Qt.CustomContextMenu)
self.table.customContextMenuRequested.connect(self.show_context_menu) self.table.customContextMenuRequested.connect(self.show_context_menu)
@@ -164,6 +170,12 @@ class PropertiesDialog(QDialog):
exif_widget = QWidget() exif_widget = QWidget()
exif_layout = QVBoxLayout(exif_widget) 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() self.exif_table = QTableWidget()
# This table will display EXIF/XMP/IPTC data. # This table will display EXIF/XMP/IPTC data.
# Reading this data involves opening the file with exiv2, which is a disk read. # 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). # without a significant architectural change (e.g., a dedicated metadata DB).
self.exif_table.setColumnCount(2) self.exif_table.setColumnCount(2)
self.exif_table.setHorizontalHeaderLabels(UITexts.PROPERTIES_TABLE_HEADER) self.exif_table.setHorizontalHeaderLabels(UITexts.PROPERTIES_TABLE_HEADER)
self.exif_table.horizontalHeader().setSectionResizeMode( self.exif_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
0, QHeaderView.ResizeToContents) self.exif_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
self.exif_table.horizontalHeader().setSectionResizeMode(
1, QHeaderView.ResizeToContents)
self.exif_table.verticalHeader().setVisible(False) self.exif_table.verticalHeader().setVisible(False)
self.exif_table.setAlternatingRowColors(True) 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.setSelectionBehavior(QTableWidget.SelectRows)
self.exif_table.setSelectionMode(QAbstractItemView.ExtendedSelection)
self.exif_table.setContextMenuPolicy(Qt.CustomContextMenu) self.exif_table.setContextMenuPolicy(Qt.CustomContextMenu)
# This is a disk read. # This is a disk read.
self.exif_table.customContextMenuRequested.connect(self.show_exif_context_menu) self.exif_table.customContextMenuRequested.connect(self.show_exif_context_menu)
@@ -204,6 +217,100 @@ class PropertiesDialog(QDialog):
# Start background loading # Start background loading
self.reload_metadata() 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): def closeEvent(self, event):
if self.loader and self.loader.isRunning(): if self.loader and self.loader.isRunning():
self.loader.stop() self.loader.stop()
@@ -227,6 +334,7 @@ class PropertiesDialog(QDialog):
# Combine preloaded and newly read xattrs # Combine preloaded and newly read xattrs
all_xattrs = preloaded_xattrs.copy() all_xattrs = preloaded_xattrs.copy()
if not initial_only and disk_xattrs: if not initial_only and disk_xattrs:
self.original_xattrs = disk_xattrs.copy()
# Disk data takes precedence or adds to it # Disk data takes precedence or adds to it
all_xattrs.update(disk_xattrs) all_xattrs.update(disk_xattrs)
@@ -237,9 +345,9 @@ class PropertiesDialog(QDialog):
for key, val in all_xattrs.items(): for key, val in all_xattrs.items():
# QImageReader.textKeys() is not used here as it's not xattr. # QImageReader.textKeys() is not used here as it's not xattr.
k_item = QTableWidgetItem(key) 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 = 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, 0, k_item)
self.table.setItem(row, 1, v_item) self.table.setItem(row, 1, v_item)
row += 1 row += 1
@@ -298,6 +406,7 @@ class PropertiesDialog(QDialog):
self.exif_table.blockSignals(False) self.exif_table.blockSignals(False)
return return
self.original_exif = exif_data.copy()
self.exif_table.setRowCount(len(exif_data)) self.exif_table.setRowCount(len(exif_data))
error_color = QColor("red") error_color = QColor("red")
error_text_lower = UITexts.ERROR.lower() error_text_lower = UITexts.ERROR.lower()
@@ -305,9 +414,9 @@ class PropertiesDialog(QDialog):
for row, (key, value) in enumerate(sorted(exif_data.items())): for row, (key, value) in enumerate(sorted(exif_data.items())):
k_item = QTableWidgetItem(str(key)) 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 = 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() key_str_lower = str(key).lower()
val_str_lower = str(value).lower() val_str_lower = str(value).lower()
@@ -323,25 +432,6 @@ class PropertiesDialog(QDialog):
self.exif_table.blockSignals(False) 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): def show_context_menu(self, pos):
""" """
Displays a context menu in the metadata table. Displays a context menu in the metadata table.

View File

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

View File

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

View File

@@ -14,12 +14,13 @@ import os
import shutil import shutil
import urllib.request 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.QtGui import QColor, QIcon, QFont
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QCheckBox, QColorDialog, QComboBox, QDialog, QDialogButtonBox, QHBoxLayout, QCheckBox, QColorDialog, QComboBox, QDialog, QDialogButtonBox, QHBoxLayout,
QLabel, QLineEdit, QMessageBox, QProgressDialog, QPushButton, QSpinBox, QLabel, QLineEdit, QMessageBox, QProgressDialog, QPushButton, QSpinBox,
QTabWidget, QVBoxLayout, QWidget QTabWidget, QVBoxLayout, QWidget, QSlider, QFileDialog, QListWidget,
QListWidgetItem, QProgressBar
) )
from constants import ( from constants import (
APP_CONFIG, AVAILABLE_FACE_ENGINES, DEFAULT_FACE_BOX_COLOR, 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, FACES_MENU_MAX_ITEMS_DEFAULT, MEDIAPIPE_FACE_MODEL_PATH, MEDIAPIPE_FACE_MODEL_URL,
AVAILABLE_PET_ENGINES, DEFAULT_BODY_BOX_COLOR, AVAILABLE_PET_ENGINES, DEFAULT_BODY_BOX_COLOR,
MEDIAPIPE_OBJECT_MODEL_PATH, MEDIAPIPE_OBJECT_MODEL_URL, 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, SCANNER_SETTINGS_DEFAULTS, SEARCH_CMD, TAGS_MENU_MAX_ITEMS_DEFAULT,
THUMBNAILS_FILENAME_LINES_DEFAULT, THUMBNAILS_FILENAME_LINES_DEFAULT,
THUMBNAILS_REFRESH_INTERVAL_DEFAULT, THUMBNAILS_BG_COLOR_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_TOOLTIP_FG_COLOR_DEFAULT, THUMBNAILS_FILENAME_FONT_SIZE_DEFAULT,
THUMBNAILS_TAGS_LINES_DEFAULT, THUMBNAILS_TAGS_FONT_SIZE_DEFAULT, THUMBNAILS_TAGS_LINES_DEFAULT, THUMBNAILS_TAGS_FONT_SIZE_DEFAULT,
VIEWER_AUTO_RESIZE_WINDOW_DEFAULT, VIEWER_WHEEL_SPEED_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): class ModelDownloader(QThread):
"""A thread to download the MediaPipe model file without freezing the UI.""" """A thread to download the MediaPipe model file without freezing the UI."""
download_complete = Signal(bool, str) # success (bool), message (str) 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_bg_color = THUMBNAILS_TOOLTIP_BG_COLOR_DEFAULT
self.current_thumbs_tooltip_fg_color = THUMBNAILS_TOOLTIP_FG_COLOR_DEFAULT self.current_thumbs_tooltip_fg_color = THUMBNAILS_TOOLTIP_FG_COLOR_DEFAULT
self.downloader_thread = None self.downloader_thread = None
self.counter_thread = None
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
@@ -112,6 +178,9 @@ class SettingsDialog(QDialog):
scanner_tab = QWidget() scanner_tab = QWidget()
scanner_layout = QVBoxLayout(scanner_tab) scanner_layout = QVBoxLayout(scanner_tab)
duplicates_tab = QWidget()
duplicates_layout = QVBoxLayout(duplicates_tab)
# --- Thumbnails Tab --- # --- Thumbnails Tab ---
mru_tags_layout = QHBoxLayout() mru_tags_layout = QHBoxLayout()
@@ -344,6 +413,142 @@ class SettingsDialog(QDialog):
scanner_layout.addLayout(scan_full_on_start_layout) scanner_layout.addLayout(scan_full_on_start_layout)
scanner_layout.addStretch() 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 & People Tab ---
faces_tab = QWidget() faces_tab = QWidget()
faces_layout = QVBoxLayout(faces_tab) faces_layout = QVBoxLayout(faces_tab)
@@ -409,6 +614,10 @@ class SettingsDialog(QDialog):
self.face_history_spin.setToolTip(UITexts.SETTINGS_FACE_HISTORY_TOOLTIP) self.face_history_spin.setToolTip(UITexts.SETTINGS_FACE_HISTORY_TOOLTIP)
faces_layout.addLayout(face_history_layout) 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 --- # --- Pets Section ---
faces_layout.addSpacing(10) faces_layout.addSpacing(10)
pets_header = QLabel(UITexts.TYPE_PET) pets_header = QLabel(UITexts.TYPE_PET)
@@ -465,6 +674,10 @@ class SettingsDialog(QDialog):
self.pet_history_spin.setToolTip(UITexts.SETTINGS_PET_HISTORY_TOOLTIP) self.pet_history_spin.setToolTip(UITexts.SETTINGS_PET_HISTORY_TOOLTIP)
faces_layout.addLayout(pet_history_layout) 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 --- # --- Body Section ---
faces_layout.addSpacing(10) faces_layout.addSpacing(10)
body_header = QLabel(UITexts.TYPE_BODY) body_header = QLabel(UITexts.TYPE_BODY)
@@ -512,6 +725,10 @@ class SettingsDialog(QDialog):
self.body_history_spin.setToolTip(UITexts.SETTINGS_BODY_HISTORY_TOOLTIP) self.body_history_spin.setToolTip(UITexts.SETTINGS_BODY_HISTORY_TOOLTIP)
faces_layout.addLayout(body_history_layout) 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 --- # --- Object Section ---
faces_layout.addSpacing(10) faces_layout.addSpacing(10)
object_header = QLabel(UITexts.TYPE_OBJECT) object_header = QLabel(UITexts.TYPE_OBJECT)
@@ -558,6 +775,12 @@ class SettingsDialog(QDialog):
self.object_history_spin.setToolTip(UITexts.SETTINGS_OBJECT_HISTORY_TOOLTIP) self.object_history_spin.setToolTip(UITexts.SETTINGS_OBJECT_HISTORY_TOOLTIP)
faces_layout.addLayout(object_history_layout) 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 --- # --- Landmark Section ---
faces_layout.addSpacing(10) faces_layout.addSpacing(10)
landmark_header = QLabel(UITexts.TYPE_LANDMARK) landmark_header = QLabel(UITexts.TYPE_LANDMARK)
@@ -605,6 +828,12 @@ class SettingsDialog(QDialog):
faces_layout.addLayout(landmark_history_layout) faces_layout.addLayout(landmark_history_layout)
faces_layout.addStretch() 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 Tab ---
viewer_wheel_layout = QHBoxLayout() viewer_wheel_layout = QHBoxLayout()
viewer_wheel_label = QLabel(UITexts.SETTINGS_VIEWER_WHEEL_SPEED_LABEL) 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(viewer_tab, UITexts.SETTINGS_GROUP_VIEWER)
tabs.addTab(faces_tab, UITexts.SETTINGS_GROUP_AREAS) tabs.addTab(faces_tab, UITexts.SETTINGS_GROUP_AREAS)
tabs.addTab(scanner_tab, UITexts.SETTINGS_GROUP_SCANNER) tabs.addTab(scanner_tab, UITexts.SETTINGS_GROUP_SCANNER)
tabs.addTab(duplicates_tab, UITexts.SETTINGS_GROUP_DUPLICATES)
# --- Button Box --- # --- Button Box ---
button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
@@ -705,6 +935,12 @@ class SettingsDialog(QDialog):
landmark_history_count = APP_CONFIG.get( landmark_history_count = APP_CONFIG.get(
"landmark_menu_max_items", FACES_MENU_MAX_ITEMS_DEFAULT) "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( thumbs_refresh_interval = APP_CONFIG.get(
"thumbnails_refresh_interval", THUMBNAILS_REFRESH_INTERVAL_DEFAULT) "thumbnails_refresh_interval", THUMBNAILS_REFRESH_INTERVAL_DEFAULT)
thumbs_bg_color = APP_CONFIG.get( thumbs_bg_color = APP_CONFIG.get(
@@ -737,6 +973,31 @@ class SettingsDialog(QDialog):
show_tags = APP_CONFIG.get("thumbnails_show_tags", True) show_tags = APP_CONFIG.get("thumbnails_show_tags", True)
filmstrip_position = APP_CONFIG.get("filmstrip_position", "bottom") 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_max_level_spin.setValue(scan_max_level)
self.scan_batch_size_spin.setValue(scan_batch_size) self.scan_batch_size_spin.setValue(scan_batch_size)
self.threads_spin.setValue(scan_threads) self.threads_spin.setValue(scan_threads)
@@ -795,6 +1056,12 @@ class SettingsDialog(QDialog):
self.object_history_spin.setValue(object_history_count) self.object_history_spin.setValue(object_history_count)
self.landmark_history_spin.setValue(landmark_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.thumbs_refresh_spin.setValue(thumbs_refresh_interval)
self.set_thumbs_bg_button_color(thumbs_bg_color) self.set_thumbs_bg_button_color(thumbs_bg_color)
self.set_thumbs_filename_button_color(thumbs_filename_color) self.set_thumbs_filename_button_color(thumbs_filename_color)
@@ -821,6 +1088,7 @@ class SettingsDialog(QDialog):
self.filmstrip_pos_combo.setCurrentText( self.filmstrip_pos_combo.setCurrentText(
pos_map.get(filmstrip_position, UITexts.FILMSTRIP_BOTTOM)) pos_map.get(filmstrip_position, UITexts.FILMSTRIP_BOTTOM))
self.update_mediapipe_status() self.update_mediapipe_status()
self.update_duplicate_scan_count()
def set_button_color(self, color_str): def set_button_color(self, color_str):
"""Sets the background color of the button and stores the value.""" """Sets the background color of the button and stores the value."""
@@ -979,7 +1247,7 @@ class SettingsDialog(QDialog):
elif self.download_model_btn: elif self.download_model_btn:
self.download_model_btn.hide() self.download_model_btn.hide()
# --- Mascotas (Pets) --- # --- Pets ---
if not AVAILABLE_PET_ENGINES: if not AVAILABLE_PET_ENGINES:
self.pet_engine_combo.setEnabled(False) self.pet_engine_combo.setEnabled(False)
self.pet_tags_edit.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["landmark_menu_max_items"] = self.landmark_history_spin.value()
APP_CONFIG["thumbnails_refresh_interval"] = self.thumbs_refresh_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_bg_color"] = self.current_thumbs_bg_color
APP_CONFIG["thumbnails_filename_color"] = self.current_thumbs_filename_color APP_CONFIG["thumbnails_filename_color"] = self.current_thumbs_filename_color
APP_CONFIG["thumbnails_tags_color"] = self.current_thumbs_tags_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_rating"] = self.show_rating_check.isChecked()
APP_CONFIG["thumbnails_show_tags"] = self.show_tags_check.isChecked() APP_CONFIG["thumbnails_show_tags"] = self.show_tags_check.isChecked()
APP_CONFIG["viewer_wheel_speed"] = self.viewer_wheel_spin.value() 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"] = \ APP_CONFIG["viewer_auto_resize_window"] = \
self.viewer_auto_resize_check.isChecked() self.viewer_auto_resize_check.isChecked()
APP_CONFIG["face_detection_engine"] = self.face_engine_combo.currentText() APP_CONFIG["face_detection_engine"] = self.face_engine_combo.currentText()
@@ -1108,3 +1396,111 @@ class SettingsDialog(QDialog):
def _on_downloader_finished(self): def _on_downloader_finished(self):
self.downloader_thread = None 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( setup(
name="bagheeraview", name="bagheeraview",
version="0.9.15", version="0.9.26",
author="Ignacio Serantes", author="Ignacio Serantes",
description="Bagheera Image Viewer - An image viewer for KDE with Baloo in mind", 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 " long_description="A fast image viewer built with PySide6, featuring search and "
@@ -16,6 +16,7 @@ setup(
"exiv2", "exiv2",
"psutil", "psutil",
"watchdog", "watchdog",
"imagehash", # Added for perceptual hashing
"mediapipe", "mediapipe",
"face_recognition", "face_recognition",
"face_recognition_models", "face_recognition_models",
@@ -38,8 +39,11 @@ setup(
"filesystemwatcher", "filesystemwatcher",
"metadatamanager", "metadatamanager",
"propertiesdialog", "propertiesdialog",
"thumbnailwidget", #"thumbnailwidget",
"duplicatecache",
"duplicatedialog",
"widgets", "widgets",
"filesystemwatcher",
"xmpmanager", "xmpmanager",
"utils" "utils"
], ],

View File

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

View File

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