Compare commits
25 Commits
ff7c1aa373
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3bc2f1e0a | ||
|
|
dffc414182 | ||
|
|
0d3d5ffa11 | ||
|
|
8025bef8d3 | ||
|
|
28b120c9e9 | ||
|
|
a824a01579 | ||
|
|
b5b70326b1 | ||
|
|
9d286112b6 | ||
|
|
b253b6d6e7 | ||
|
|
8ade5fde54 | ||
|
|
1508e629c0 | ||
|
|
07afab6ca3 | ||
|
|
bff99226b0 | ||
|
|
9685c01760 | ||
|
|
3e374a5871 | ||
|
|
964974431c | ||
|
|
45c95c1bb1 | ||
|
|
a717acef87 | ||
|
|
ca260d4219 | ||
|
|
ae00235db8 | ||
|
|
2fbf04fdb8 | ||
|
|
415400c30a | ||
|
|
cb751b2970 | ||
| 3706d404f4 | |||
| 2ae8ba9d9a |
34
README.md
34
README.md
@@ -6,15 +6,25 @@ BagheeraView is an image viewer specifically designed for the KDE ecosystem. Bui
|
||||
|
||||
- **Enhanced Baloo Search:** Blazing fast image retrieval using the KDE Baloo indexing framework, featuring advanced capabilities not natively available in Baloo, such as **folder recursive search** and results **text exclusion**, if BagheeraSearch library is available.
|
||||
|
||||
- **Versatile Thumbnail Grid:** A fluid and responsive browser for large collections, offering both **Flat View**, several **Date View** modes, **Rating View** and **Folder View** modes.
|
||||
- **Versatile Thumbnail Grid:** A fluid and responsive browser for large collections, offering **Flat View**, several **Date View** modes, **Rating View** and **Folder View** modes.
|
||||
|
||||
- **Face & Pet Detection:** Integrated computer vision to detect faces and pets within your photos and assign names. Body, Object and Landmark tags are supported too but without computer vision detection.
|
||||
- **Areas Management:** Integrated computer vision to detect faces and pets within your photos and assign tag names. Body, Object and Landmark areas are supported too but without computer vision detection.
|
||||
|
||||
- **Metadata:** A basic viewer for **EXIF, IPTC, and XMP** data.
|
||||
|
||||
- **Tagging & Rating & Comments System:** Effortlessly manage tags, ratings and comments that integrate directly with your filesystem's extended attributes.
|
||||
- **Tagging, Rating & Comments System:** Effortlessly manage tags, ratings and comments that integrate directly with your filesystem's extended attributes and used by Baloo.
|
||||
|
||||
- **Smart State Persistence:** The application remembers your workflow. Your **last used sort order** and view settings are automatically saved and restored upon startup.
|
||||
- **Filter resuls:** Results can be filtered using tags or file name.
|
||||
|
||||
- **Duplicates Management:** A system to detect and manage duplicates using percentual hashing or OpenCV including a images comparison form and ignore list.
|
||||
|
||||
- **Smart State Persistence:** The application remembers your workflow. Your **last used search**, **last used sort order** and view settings are automatically saved and restored upon startup.
|
||||
|
||||
- **Favorites:** Favorite searchs can be saved and reused.
|
||||
|
||||
- **Cache:** Thumbnails cache and image hashes arte cached on LMDB databases.
|
||||
|
||||
- **Window Manager Support:** X11 and Wayland are supported.
|
||||
|
||||
## 🛠 Technical Stack
|
||||
|
||||
@@ -26,6 +36,8 @@ BagheeraView is an image viewer specifically designed for the KDE ecosystem. Bui
|
||||
|
||||
- **Metadata Handling:** Advanced image header manipulation to store faces, pets, body, objects and landmarks and support to file extended attributes
|
||||
|
||||
- **Duplicate Detection:** Two methods to detect duplicates, hashing and open vision
|
||||
|
||||
## 🌐 Internationalization (i18n)
|
||||
|
||||
BagheeraView is designed for a global audience with localized interface support. Initial supported languages include:
|
||||
@@ -41,7 +53,7 @@ BagheeraView is designed for a global audience with localized interface support.
|
||||
|
||||
BagheeraView is built for workflow continuity. The application stores the user's environment state in the local configuration:
|
||||
|
||||
- **Restore Last Layout:** Last layout and preferences are automatically saved and restored every time you launch it.
|
||||
- **Restore Last Layout:** Last layout and preferences are automatically saved and restored every time you launch it.
|
||||
|
||||
- **Keyboard configuration:** All hotkeys can be parametriced by the user.
|
||||
|
||||
@@ -49,7 +61,7 @@ BagheeraView is built for workflow continuity. The application stores the user's
|
||||
|
||||
## 📥 Installation (Development)
|
||||
|
||||
Ensure you have at least Python 3.13, the necessary PySide6 dependencies and KDE development headers for Baloo installed on your system. For best integration install at least PySide6 in your system but for better results install all required libraries if they are available in your Linux distribution.
|
||||
Ensure you have at least Python 3.13, the necessary PySide6, LMDB and Exiv2 dependencies and KDE development headers for Baloo installed on your system. For best integration install at least PySide6 in your system, but for better results install all required libraries on your system if they are available in your Linux distribution.
|
||||
|
||||
Bash
|
||||
|
||||
@@ -65,12 +77,12 @@ pip install -r requirements.txt
|
||||
python bagheeraview.py
|
||||
```
|
||||
|
||||
BagheeraSearch tool and librery are available at https://git.aynoa.net/ignacio/BagheeraSearch.git
|
||||
BagheeraSearch tool and library are available at https://git.aynoa.net/ignacio/BagheeraSearch.git
|
||||
|
||||
|
||||
## 📥 Installation (Production with BagheeraSearch)
|
||||
## 📥 Recomended Installation (Production with BagheeraSearch)
|
||||
|
||||
Ensure you have at least Python 3.13, the necessary PySide6 dependencies and KDE development headers for Baloo installed on your system. For best integration install at least PySide6 in your system but for better results install all required libraries if they are available in your Linux distribution.
|
||||
Ensure you have at least Python 3.13, the necessary PySide6, LMDB and Exiv2 dependencies and KDE development headers for Baloo installed on your system. For best integration install at least PySide6 in your system, but for better results install all required libraries on your system if they are available in your Linux distribution.
|
||||
|
||||
Bash
|
||||
|
||||
@@ -94,9 +106,9 @@ pip install . /tmp/BagheeraView
|
||||
python bagheeraview.py
|
||||
```
|
||||
|
||||
## 📥 Installation (Production without BagheeraSearch)
|
||||
## 📥 Alternative Installation (Production without BagheeraSearch)
|
||||
|
||||
Ensure you have at least Python 3.13, the necessary PySide6 dependencies and KDE development headers for Baloo installed on your system. For best integration install at least PySide6 in your system but for better results install all required libraries if they are available in your Linux distribution.
|
||||
Ensure you have at least Python 3.13, the necessary PySide6, LMDB and Exiv2 dependencies and KDE development headers for Baloo installed on your system. For best integration install at least PySide6 in your system, but for better results install all required libraries on your system if they are available in your Linux distribution.
|
||||
|
||||
Bash
|
||||
|
||||
|
||||
647
bagheeraview.py
647
bagheeraview.py
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,7 @@
|
||||
¿Sería posible añadir una opción para limpiar automáticamente los hashes de archivos que ya no existen sin borrar toda la base de datos?
|
||||
|
||||
¿Podrías optimizar el proceso de borrado en lote para que sea más eficiente si hay miles de entradas que limpiar?
|
||||
|
||||
Implement a bulk rename feature for the selected pet or face tags.
|
||||
Add a visual indicator (e.g., an icon in the status bar) for the "Link Panes" status instead of text.
|
||||
|
||||
@@ -51,6 +55,7 @@ How can I implement a bulk rename feature for the selected pet or face tags?
|
||||
¿Cómo puedo añadir una opción "Abrir con otra aplicación..." al final del submenú que lance el selector de aplicaciones del sistema?
|
||||
|
||||
¿Cómo puedo añadir soporte para arrastrar y soltar imágenes desde el visor (ImageViewer) a otras aplicaciones?
|
||||
· Añadir una opción al menú de contexto para "Abrir con el visor estándar de Bagheera" para ver la imagen a pantalla completa.
|
||||
|
||||
¿Por qué la selección de imágenes se pierde o cambia incorrectamente al cambiar el modo de agrupación?
|
||||
|
||||
@@ -62,6 +67,18 @@ Ahora que la carga es rápida, ¿cómo puedo implementar una precarga inteligent
|
||||
|
||||
¿Cómo puedo hacer que la selección de archivos sea persistente incluso después de recargar o filtrar la vista?
|
||||
|
||||
v0.9.18 -
|
||||
· Better messages
|
||||
|
||||
v0.9.17 -
|
||||
· Fixes
|
||||
|
||||
v0.9.16 -
|
||||
· Fixes
|
||||
|
||||
v0.9.15 -
|
||||
· Duplicates
|
||||
|
||||
v0.9.14 -
|
||||
· Corregido el problema de resolución de los thumbnails
|
||||
|
||||
|
||||
331
constants.py
331
constants.py
@@ -29,7 +29,7 @@ if FORCE_X11:
|
||||
# --- CONFIGURATION ---
|
||||
PROG_NAME = "Bagheera Image Viewer"
|
||||
PROG_ID = "bagheeraview"
|
||||
PROG_VERSION = "0.9.15"
|
||||
PROG_VERSION = "0.9.26"
|
||||
PROG_AUTHOR = "Ignacio Serantes"
|
||||
|
||||
# --- CACHE SETTINGS ---
|
||||
@@ -57,16 +57,26 @@ DISK_CACHE_MAX_BYTES = 10 * 1024 * 1024 * 1024
|
||||
|
||||
# --- PATHS ---
|
||||
CONFIG_FILE = f"{PROG_ID}rc"
|
||||
CONFIG_LOCATION = '.config/iserantes'
|
||||
CONFIG_DIR = os.path.join(os.path.expanduser("~"), CONFIG_LOCATION, PROG_ID)
|
||||
CONFIG_LOCATION = os.environ.get('XDG_CONFIG_HOME')
|
||||
CONFIG_DIR = os.path.join(CONFIG_LOCATION, 'iserantes', PROG_ID)
|
||||
CONFIG_PATH = os.path.join(CONFIG_DIR, CONFIG_FILE)
|
||||
CACHE_PATH = os.path.join(CONFIG_DIR, "thumbnails_lmdb")
|
||||
|
||||
APP_DATA_LOCATION = os.path.expanduser('~/.local/share')
|
||||
APP_DATA_DIR = os.path.join(APP_DATA_LOCATION, 'iserantes', PROG_ID)
|
||||
|
||||
CACHE_PATH = os.path.join(APP_DATA_DIR, "thumbnails")
|
||||
|
||||
HISTORY_FILE = "history.json"
|
||||
HISTORY_PATH = os.path.join(CONFIG_DIR, HISTORY_FILE)
|
||||
LAYOUTS_DIR = os.path.join(CONFIG_DIR, "layouts") # Layouts saving directory
|
||||
HISTORY_PATH = os.path.join(APP_DATA_DIR, HISTORY_FILE)
|
||||
LAYOUTS_DIR = os.path.join(APP_DATA_DIR, "layouts") # Layouts saving directory
|
||||
FAVORITES_FILE = "favorites.json"
|
||||
FAVORITES_PATH = os.path.join(CONFIG_DIR, FAVORITES_FILE)
|
||||
FAVORITES_PATH = os.path.join(APP_DATA_DIR, FAVORITES_FILE)
|
||||
DUPLICATE_CACHE_PATH = os.path.join(APP_DATA_DIR, "duplicates")
|
||||
DUPLICATE_HASH_DB_NAME = b"hashes"
|
||||
DUPLICATE_EXCEPTIONS_DB_NAME = b"exceptions"
|
||||
DUPLICATE_PENDING_DB_NAME = b"pending"
|
||||
DUPLICATE_BKTREE_DB_NAME = b"bktree"
|
||||
DUPLICATE_HASH_TO_FILES_DB_NAME = b"hash_to_files"
|
||||
|
||||
|
||||
def save_app_config():
|
||||
@@ -76,9 +86,8 @@ def save_app_config():
|
||||
with open(CONFIG_PATH, 'w', encoding='utf-8') as f:
|
||||
# Use APP_CONFIG global
|
||||
json.dump(APP_CONFIG, f, indent=4)
|
||||
except OSError:
|
||||
# Silently fail for now, but could log this
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"CRITICAL: Failed to save configuration to {CONFIG_PATH}: {e}")
|
||||
|
||||
|
||||
# --- CONFIGURATION LOADING ---
|
||||
@@ -133,7 +142,18 @@ SCANNER_SETTINGS_DEFAULTS = {
|
||||
"scan_full_on_start": True,
|
||||
"person_tags": "",
|
||||
"generation_threads": 4,
|
||||
"search_engine": ""
|
||||
"search_engine": "",
|
||||
"face_use_last_name": False,
|
||||
"pet_use_last_name": False,
|
||||
"body_use_last_name": False,
|
||||
"object_use_last_name": False,
|
||||
"landmark_use_last_name": False,
|
||||
"duplicate_threshold": 90, # Similarity percentage (50-100)
|
||||
"duplicate_method": "histogram_hashing",
|
||||
"duplicate_confirm_delete": True,
|
||||
"default_delete_to_trash": True,
|
||||
"duplicate_whitelist": "",
|
||||
"duplicate_blacklist": ""
|
||||
}
|
||||
|
||||
# --- IMAGE VIEWER DEFAULTS ---
|
||||
@@ -190,15 +210,15 @@ if importlib.util.find_spec("mediapipe") is not None:
|
||||
pass
|
||||
HAVE_FACE_RECOGNITION = importlib.util.find_spec("face_recognition") is not None
|
||||
|
||||
MEDIAPIPE_FACE_MODEL_PATH = os.path.join(CONFIG_DIR,
|
||||
"blaze_face_short_range.tflite")
|
||||
MEDIAPIPE_FACE_MODEL_PATH = os.path.join(
|
||||
APP_DATA_DIR, "blaze_face_short_range.tflite")
|
||||
MEDIAPIPE_FACE_MODEL_URL = (
|
||||
"https://storage.googleapis.com/mediapipe-models/face_detector/"
|
||||
"blaze_face_short_range/float16/1/blaze_face_short_range.tflite"
|
||||
)
|
||||
|
||||
MEDIAPIPE_OBJECT_MODEL_PATH = os.path.join(CONFIG_DIR,
|
||||
"efficientdet_lite0.tflite")
|
||||
MEDIAPIPE_OBJECT_MODEL_PATH = os.path.join(
|
||||
APP_DATA_DIR, "efficientdet_lite0.tflite")
|
||||
MEDIAPIPE_OBJECT_MODEL_URL = (
|
||||
"https://storage.googleapis.com/mediapipe-models/object_detector/"
|
||||
"efficientdet_lite0/float16/1/efficientdet_lite0.tflite"
|
||||
@@ -224,6 +244,16 @@ if HAVE_MEDIAPIPE:
|
||||
DEFAULT_FACE_ENGINE = AVAILABLE_FACE_ENGINES[0] if AVAILABLE_FACE_ENGINES else None
|
||||
DEFAULT_PET_ENGINE = AVAILABLE_PET_ENGINES[0] if AVAILABLE_PET_ENGINES else None
|
||||
|
||||
HAVE_IMAGEHASH = importlib.util.find_spec("imagehash") is not None
|
||||
|
||||
# --- DUPLICATE DETECTION ---
|
||||
HAVE_DUPLICATE_RESNET_LIBS = all(
|
||||
importlib.util.find_spec(lib) is not None
|
||||
for lib in ["torch", "torchvision", "numpy", "sklearn"]
|
||||
)
|
||||
|
||||
MAX_DHASH_DISTANCE = 64 # For 64-bit dHash
|
||||
|
||||
DEFAULT_FACE_BOX_COLOR = "#FFFFFF"
|
||||
# Load preferred engine from config, or use the default.
|
||||
FACE_DETECTION_ENGINE = APP_CONFIG.get("face_detection_engine",
|
||||
@@ -374,11 +404,13 @@ _UI_TEXTS = {
|
||||
"SEARCH": "Search",
|
||||
"SELECT": "Select",
|
||||
"ERROR": "Error",
|
||||
"FILE_NOT_FOUND": "File not found",
|
||||
"WARNING": "Warning",
|
||||
"INFO": "Info",
|
||||
"LOAD": "Load",
|
||||
"SAVE": "Save",
|
||||
"CREATE": "Create",
|
||||
"CANCEL": "Cancel",
|
||||
"RENAME": "Rename",
|
||||
"COPY": "Copy",
|
||||
"DELETE": "Delete",
|
||||
@@ -489,6 +521,80 @@ _UI_TEXTS = {
|
||||
"MENU_SHOW_LAYOUTS": "Show Layouts",
|
||||
"MENU_SHOW_HISTORY": "Show History",
|
||||
"MENU_SETTINGS": "Settings",
|
||||
"SETTINGS_GROUP_DUPLICATES": "Duplicates",
|
||||
"MENU_DUPLICATES": "Duplicates",
|
||||
"MENU_DETECT_CURRENT_SEARCH": "Detect in current search",
|
||||
"MENU_DETECT_ALL": "Detect all",
|
||||
"MENU_FORCE_FULL_ALL_ANALYSIS": "Force full all analysis",
|
||||
"MENU_FORCE_FULL_ANALYSIS": "Force full analysis",
|
||||
"MENU_REVIEW_IGNORED": "Review ignored",
|
||||
"MENU_CLEAN_UP_HASHES": "Clean up",
|
||||
"MENU_REPAIR_DATABASE": "Repair index",
|
||||
"MENU_CLEAR_EXCEPTIONS": "Clear ignored pairs",
|
||||
"CONFIRM_CLEAR_EXCEPTIONS_TITLE": "Confirm Clear Ignored Pairs",
|
||||
"CONFIRM_CLEAR_EXCEPTIONS_TEXT": "Are you sure you want to clear all "
|
||||
"ignored duplicate pairs? They will be detected again in the next scan.",
|
||||
"REPAIRING_DATABASE": "Repairing duplicate index...",
|
||||
"MENU_CLEAR_HASHES": "Clear hashes ({} items, {:.1f} MB on disk)",
|
||||
"CONFIRM_CLEAR_HASHES_TITLE": "Confirm Clear Hashes",
|
||||
"CONFIRM_CLEAR_HASHES_TEXT": "Are you sure you want to permanently delete "
|
||||
"the entire hash database?",
|
||||
"CONFIRM_CLEAR_HASHES_INFO": "This will remove all calculated image hashes. "
|
||||
"They will be recalculated as you detect duplicates, which may be slow. This "
|
||||
"action cannot be undone.",
|
||||
"SETTINGS_DUPLICATE_METHOD_LABEL": "Method:",
|
||||
"SETTINGS_DUPLICATE_METHOD_TOOLTIP": "Select the method for duplicate "
|
||||
"detection.",
|
||||
"METHOD_HISTOGRAM_HASHING": "Histogram + Hashing",
|
||||
"METHOD_RESNET": "ResNet (AI Based)",
|
||||
"SETTINGS_DUPLICATE_CONFIRM_DELETE_LABEL": "Confirm before deleting duplicates",
|
||||
"SETTINGS_DUPLICATE_WHITELIST_LABEL": "Whitelist (folders to include):",
|
||||
"SETTINGS_DUPLICATE_WHITELIST_TOOLTIP": "Comma-separated paths of folders to "
|
||||
"scan when using 'Detect all'.",
|
||||
"SETTINGS_DUPLICATE_BLACKLIST_LABEL": "Blacklist (folders to exclude):",
|
||||
"SETTINGS_DUPLICATE_BLACKLIST_TOOLTIP": "Comma-separated paths of folders to "
|
||||
"ignore during 'Detect all' scans.",
|
||||
"SETTINGS_DUPLICATE_SCAN_COUNT_LABEL": "Images found for 'Detect all': {}",
|
||||
"SETTINGS_DEFAULT_DELETE_TO_TRASH_LABEL": "Delete key sends to trash by "
|
||||
"default",
|
||||
"SETTINGS_DEFAULT_DELETE_TO_TRASH_TOOLTIP": "If checked, pressing the Delete "
|
||||
"key will move files to trash. If unchecked, it will permanently delete them.",
|
||||
"SETTINGS_DUPLICATE_CONFIRM_DELETE_TOOLTIP": "Show a confirmation dialog "
|
||||
"before moving a duplicate image to the trash.",
|
||||
"SETTINGS_DUPLICATE_THRESHOLD_LABEL": "Similarity Threshold:",
|
||||
"SETTINGS_DUPLICATE_THRESHOLD_TOOLTIP": "Set the similarity threshold 2 "
|
||||
"(50-100%). Higher values mean images must be more similar to be considered "
|
||||
"duplicates.",
|
||||
"SETTINGS_DUPLICATE_MISSING_LIBS": "The 'imagehash' library is required for "
|
||||
"duplicate detection but was not found. This feature is disabled.",
|
||||
"MENU_DETECT_DUPLICATES": "Detect Duplicates",
|
||||
"DUPLICATE_WHITELIST_EMPTY": "Whitelist is empty. Please configure it "
|
||||
"in Settings.",
|
||||
"DUPLICATE_DETECTION_TITLE": "Duplicate Detection",
|
||||
"DUPLICATE_ALREADY_RUNNING": "Duplicate detection is already in progress.",
|
||||
"DUPLICATE_NO_IMAGES": "No images loaded to detect duplicates.",
|
||||
"DUPLICATE_STARTING": "Starting duplicate detection...",
|
||||
"DUPLICATE_PROGRESS": "Duplicate detection: {message} ({current}/{total})",
|
||||
"DUPLICATE_NONE_FOUND": "No duplicates found.",
|
||||
"DUPLICATE_FOUND_TITLE": "Duplicates Found",
|
||||
"DUPLICATE_FOUND_MSG": "The following duplicates were found:\n",
|
||||
"DUPLICATE_FOUND_MORE": "... and {count} more.",
|
||||
"DUPLICATE_FINISHED": "Duplicate detection finished.",
|
||||
"DUPLICATE_MSG_HASHING": "Hashing {filename}",
|
||||
"DUPLICATE_MSG_ANALYZING": "Analyzing {filename}",
|
||||
"DUPLICATE_MANAGER_TITLE": "Manage Duplicate Images",
|
||||
"DUPLICATE_DELETE_LEFT": "Trash Left",
|
||||
"DUPLICATE_DELETE_RIGHT": "Trash Right",
|
||||
"CONFIRM_TRASH_TITLE": "Move to Trash",
|
||||
"CONFIRM_TRASH_TEXT": "Do you want to move this image to the trash?",
|
||||
"DUPLICATE_KEEP_BOTH": "Keep Both (Ignore)",
|
||||
"DUPLICATE_SKIP": "Skip",
|
||||
"DUPLICATE_REMOVE_IGNORED": "Remove from ignored",
|
||||
"DUPLICATE_INFO_FORMAT": "{size} - {width}x{height}",
|
||||
"VIEWER_MENU_LINK_PANES": "Link Panes",
|
||||
"DUPLICATE_OPEN_COMPARISON": "Open Comparison",
|
||||
"DUPLICATE_LIST_HEADER": "Duplicate Pairs",
|
||||
"IGNORED_DATE": "Ignored Date",
|
||||
"SETTINGS_GROUP_SCANNER": "Scanner",
|
||||
"SETTINGS_GROUP_AREAS": "Areas",
|
||||
"SETTINGS_GROUP_THUMBNAILS": "Thumbnails",
|
||||
@@ -544,6 +650,11 @@ _UI_TEXTS = {
|
||||
"landmarks.",
|
||||
"SETTINGS_LANDMARK_HISTORY_TOOLTIP": "Maximum number of recently used "
|
||||
"landmark names to remember.",
|
||||
"SETTINGS_PATH_NOT_FOUND_WARNING": "Warning: Path not found or is not "
|
||||
"a directory: {}",
|
||||
"SETTINGS_USE_LAST_NAME_LABEL": "Use last name by default",
|
||||
"SETTINGS_USE_LAST_NAME_TOOLTIP": "Automatically fill the assignment window "
|
||||
"with the last used name.",
|
||||
"SETTINGS_FACE_HISTORY_COUNT_LABEL": "Max face history:",
|
||||
"SETTINGS_THUMBS_REFRESH_LABEL": "Thumbs refresh interval (ms):",
|
||||
"MENU_VIEWER_SETTINGS": "Viewer Settings",
|
||||
@@ -684,6 +795,8 @@ _UI_TEXTS = {
|
||||
"RENAME_ERROR_EXISTS": "File '{}' already exists.",
|
||||
"FILE_RENAMED": "File renamed to {}",
|
||||
"ERROR_RENAME": "Could not rename file: {}",
|
||||
"ERROR_JPEG_METADATA_LIMIT": "Metadata size limit exceeded for '{}'. This "
|
||||
"JPEG file has too much existing metadata (XMP) to save more.",
|
||||
"MAIN_DOCK_TITLE": "",
|
||||
"LAYOUTS_TAB": "Layouts",
|
||||
"LAYOUTS_TABLE_HEADER": ["Name", "Last Modified"],
|
||||
@@ -720,6 +833,8 @@ _UI_TEXTS = {
|
||||
"TAG_ALL_TAGS": "📂 ALL TAGS",
|
||||
"TAG_NEW_TAG_TITLE": "New Tag",
|
||||
"SEARCH_BY_TAG": "Search by this tag",
|
||||
"TAG_ADD_TOOLTIP": "Create a new tag",
|
||||
"TAG_REFRESH_TOOLTIP": "Refresh available tags from Baloo database",
|
||||
"TAG_NEW_TAG_TEXT": "Enter tag name (use / for hierarchy):",
|
||||
"SEARCH_ADD_AND": "Add AND this tag to search",
|
||||
"SEARCH_ADD_OR": "Add OR this tag to search",
|
||||
@@ -754,6 +869,7 @@ _UI_TEXTS = {
|
||||
"PROPERTIES_TABLE_HEADER": ["Property", "Value"],
|
||||
"PROPERTIES_ADD_ATTR": "Add Attribute",
|
||||
"PROPERTIES_ADD_ATTR_NAME": "Attribute Name (e.g. user.comment):",
|
||||
"PROPERTIES_DELETE_ALL": "Delete All",
|
||||
"PROPERTIES_ADD_ATTR_VALUE": "Value for {}:",
|
||||
"PROPERTIES_ERROR_SET_ATTR": "Failed to set xattr: {}",
|
||||
"PROPERTIES_ERROR_ADD_ATTR": "Failed to add xattr: {}",
|
||||
@@ -806,6 +922,7 @@ _UI_TEXTS = {
|
||||
"CONTEXT_MENU_OPEN": "Open",
|
||||
"CONTEXT_MENU_OPEN_SEARCH_LOCATION": "Open and search location",
|
||||
"CONTEXT_MENU_OPEN_DEFAULT_APP": "Open location with default application",
|
||||
"CONTEXT_MENU_FULLSCREEN_VIEWER": "Open in Fullscreen Viewer",
|
||||
"CONTEXT_MENU_MOVE_TO": "Move to...",
|
||||
"CONTEXT_MENU_COPY_TO": "Copy to...",
|
||||
"CONTEXT_MENU_ROTATE": "Rotate",
|
||||
@@ -839,11 +956,13 @@ _UI_TEXTS = {
|
||||
"SEARCH": "Buscar",
|
||||
"SELECT": "Seleccionar",
|
||||
"ERROR": "Error",
|
||||
"FILE_NOT_FOUND": "Archivo no encontrado",
|
||||
"WARNING": "Advertencia",
|
||||
"INFO": "Información",
|
||||
"LOAD": "Cargar",
|
||||
"SAVE": "Guardar",
|
||||
"CREATE": "Crear",
|
||||
"CANCEL": "Cancelar",
|
||||
"RENAME": "Renombrar",
|
||||
"COPY": "Copiar",
|
||||
"DELETE": "Eliminar",
|
||||
@@ -954,6 +1073,84 @@ _UI_TEXTS = {
|
||||
"MENU_SHOW_LAYOUTS": "Mostrar Diseños",
|
||||
"MENU_SHOW_HISTORY": "Mostrar Historial",
|
||||
"MENU_SETTINGS": "Opciones",
|
||||
"SETTINGS_GROUP_DUPLICATES": "Duplicados",
|
||||
"MENU_DUPLICATES": "Duplicados",
|
||||
"MENU_DETECT_CURRENT_SEARCH": "Detectar en búsqueda actual",
|
||||
"MENU_DETECT_ALL": "Detectar todos",
|
||||
"MENU_FORCE_FULL_ALL_ANALYSIS": "Forzar análisis completo de todo",
|
||||
"MENU_FORCE_FULL_ANALYSIS": "Forzar análisis completo",
|
||||
"MENU_REVIEW_IGNORED": "Revisar ignorados",
|
||||
"MENU_CLEAN_UP_HASHES": "Limpiar",
|
||||
"MENU_REPAIR_DATABASE": "Reparar índice",
|
||||
"MENU_CLEAR_EXCEPTIONS": "Limpiar parejas ignoradas",
|
||||
"CONFIRM_CLEAR_EXCEPTIONS_TITLE": "Confirmar Limpieza de Ignorados",
|
||||
"CONFIRM_CLEAR_EXCEPTIONS_TEXT": "¿Seguro que quieres borrar todas las parejas "
|
||||
"de duplicados ignoradas? Se volverán a detectar en el próximo escaneo.",
|
||||
"REPAIRING_DATABASE": "Reparando índice de duplicados...",
|
||||
"MENU_CLEAR_HASHES": "Limpiar hashes ({} ítems, {:.1f} MB en disco)",
|
||||
"CONFIRM_CLEAR_HASHES_TITLE": "Confirmar Limpieza de Hashes",
|
||||
"CONFIRM_CLEAR_HASHES_TEXT": "¿Seguro que quieres eliminar permanentemente "
|
||||
"toda la base de datos de hashes?",
|
||||
"CONFIRM_CLEAR_HASHES_INFO": "Esto eliminará todos los hashes de imágenes "
|
||||
"calculados. Se recalcularán a medida que detectes duplicados, lo que puede "
|
||||
"ser lento. Esta acción no se puede deshacer.",
|
||||
"SETTINGS_DUPLICATE_METHOD_LABEL": "Método:",
|
||||
"SETTINGS_DUPLICATE_METHOD_TOOLTIP": "Selecciona el método para la detección "
|
||||
"de duplicados.",
|
||||
"METHOD_HISTOGRAM_HASHING": "Histograma + Hashing",
|
||||
"METHOD_RESNET": "ResNet (Basado en IA)",
|
||||
"SETTINGS_DUPLICATE_CONFIRM_DELETE_LABEL": "Confirmar antes de borrar "
|
||||
"duplicados",
|
||||
"SETTINGS_DUPLICATE_WHITELIST_LABEL": "Lista blanca (carpetas a incluir):",
|
||||
"SETTINGS_DUPLICATE_WHITELIST_TOOLTIP": "Rutas de carpetas separadas por comas "
|
||||
"para escanear al usar 'Detectar todos'.",
|
||||
"SETTINGS_DUPLICATE_BLACKLIST_LABEL": "Lista negra (carpetas a excluir):",
|
||||
"SETTINGS_DUPLICATE_BLACKLIST_TOOLTIP": "Rutas de carpetas separadas por comas "
|
||||
"para ignorar durante escaneos de 'Detectar todos'.",
|
||||
"SETTINGS_DUPLICATE_SCAN_COUNT_LABEL": "Imágenes encontradas para 'Detectar "
|
||||
"todos': {}",
|
||||
"SETTINGS_DEFAULT_DELETE_TO_TRASH_LABEL": "La tecla Supr envía a la papelera "
|
||||
"por defecto",
|
||||
"SETTINGS_DEFAULT_DELETE_TO_TRASH_TOOLTIP": "Si está marcada, al pulsar la "
|
||||
"tecla Supr se moverán los archivos a la papelera. Si no, se eliminarán "
|
||||
"permanentemente.",
|
||||
"SETTINGS_DUPLICATE_CONFIRM_DELETE_TOOLTIP": "Muestra un diálogo de "
|
||||
"confirmación antes de mover una imagen duplicada a la papelera.",
|
||||
"SETTINGS_DUPLICATE_THRESHOLD_LABEL": "Umbral de Similitud:",
|
||||
"SETTINGS_DUPLICATE_THRESHOLD_TOOLTIP": "Establece el umbral de similitud "
|
||||
"(50-100%). Valores más altos significan que las imágenes deben ser más "
|
||||
"parecidas para considerarse duplicadas.",
|
||||
"SETTINGS_DUPLICATE_MISSING_LIBS": "La librería 'imagehash' es necesaria "
|
||||
"para la detección de duplicados pero no se ha encontrado. Esta función "
|
||||
"está desactivada.",
|
||||
"MENU_DETECT_DUPLICATES": "Detectar Duplicados",
|
||||
"DUPLICATE_WHITELIST_EMPTY": "La lista blanca está vacía. Por favor, "
|
||||
"configúrela en Opciones.",
|
||||
"DUPLICATE_DETECTION_TITLE": "Detección de Duplicados",
|
||||
"DUPLICATE_ALREADY_RUNNING": "La detección de duplicados ya está en curso.",
|
||||
"DUPLICATE_NO_IMAGES": "No hay imágenes cargadas para detectar duplicados.",
|
||||
"DUPLICATE_STARTING": "Iniciando detección de duplicados...",
|
||||
"DUPLICATE_PROGRESS": "Detección de duplicados: {message} ({current}/{total})",
|
||||
"DUPLICATE_NONE_FOUND": "No se encontraron duplicados.",
|
||||
"DUPLICATE_FOUND_TITLE": "Duplicados Encontrados",
|
||||
"DUPLICATE_FOUND_MSG": "Se encontraron los siguientes duplicados:\n",
|
||||
"DUPLICATE_FOUND_MORE": "... y {count} más.",
|
||||
"DUPLICATE_FINISHED": "Detección de duplicados finalizada.",
|
||||
"DUPLICATE_MSG_HASHING": "Procesando {filename}",
|
||||
"DUPLICATE_MSG_ANALYZING": "Analizando {filename}",
|
||||
"DUPLICATE_MANAGER_TITLE": "Gestionar Imágenes Duplicadas",
|
||||
"DUPLICATE_DELETE_LEFT": "Papelera Izquierda",
|
||||
"DUPLICATE_DELETE_RIGHT": "Papelera Derecha",
|
||||
"CONFIRM_TRASH_TITLE": "Mover a la papelera",
|
||||
"CONFIRM_TRASH_TEXT": "¿Deseas mover esta imagen a la papelera?",
|
||||
"DUPLICATE_KEEP_BOTH": "Mantener Ambas (Ignorar)",
|
||||
"DUPLICATE_SKIP": "Omitir",
|
||||
"DUPLICATE_REMOVE_IGNORED": "Eliminar de ignorados",
|
||||
"DUPLICATE_INFO_FORMAT": "{size} - {width}x{height}",
|
||||
"VIEWER_MENU_LINK_PANES": "Vincular Paneles",
|
||||
"DUPLICATE_OPEN_COMPARISON": "Abrir Comparación",
|
||||
"DUPLICATE_LIST_HEADER": "Parejas Duplicadas",
|
||||
"IGNORED_DATE": "Fecha Ignorado",
|
||||
"SETTINGS_GROUP_SCANNER": "Escáner",
|
||||
"SETTINGS_GROUP_AREAS": "Áreas",
|
||||
"SETTINGS_GROUP_THUMBNAILS": "Miniaturas",
|
||||
@@ -1015,6 +1212,11 @@ _UI_TEXTS = {
|
||||
"alrededor de los lugares.",
|
||||
"SETTINGS_LANDMARK_HISTORY_TOOLTIP": "Número máximo de nombres de lugares "
|
||||
"usados recientemente para recordar.",
|
||||
"SETTINGS_PATH_NOT_FOUND_WARNING": "Advertencia: La ruta no existe o "
|
||||
"no es un directorio: {}",
|
||||
"SETTINGS_USE_LAST_NAME_LABEL": "Usar último nombre por defecto",
|
||||
"SETTINGS_USE_LAST_NAME_TOOLTIP": "Rellena automáticamente la ventana de "
|
||||
"asignación con el último nombre utilizado.",
|
||||
"SETTINGS_FACE_HISTORY_COUNT_LABEL": "Máximo historial de caras:",
|
||||
"SETTINGS_THUMBS_REFRESH_LABEL": "Intervalo refresco miniaturas (ms):",
|
||||
"SETTINGS_THUMBS_BG_COLOR_LABEL": "Color de fondo de miniaturas:",
|
||||
@@ -1155,6 +1357,8 @@ _UI_TEXTS = {
|
||||
"RENAME_ERROR_EXISTS": "El archivo '{}' ya existe.",
|
||||
"FILE_RENAMED": "Archivo renombrado a {}",
|
||||
"ERROR_RENAME": "No se pudo renombrar el archivo: {}",
|
||||
"ERROR_JPEG_METADATA_LIMIT": "Límite de metadatos excedido para '{}'. Este "
|
||||
"archivo JPEG ya tiene demasiados metadatos (XMP) para guardar más.",
|
||||
"MAIN_DOCK_TITLE": "Panel principal",
|
||||
"LAYOUTS_TAB": "Diseños",
|
||||
"LAYOUTS_TABLE_HEADER": ["Nombre", "Última Modificación"],
|
||||
@@ -1191,6 +1395,9 @@ _UI_TEXTS = {
|
||||
"TAG_ALL_TAGS": "📂 TODAS LAS ETIQUETAS",
|
||||
"TAG_NEW_TAG_TITLE": "Nueva Etiqueta",
|
||||
"SEARCH_BY_TAG": "Buscar por esta etiqueta",
|
||||
"TAG_ADD_TOOLTIP": "Crear una nueva etiqueta",
|
||||
"TAG_REFRESH_TOOLTIP": "Refrescar etiquetas disponibles desde el base de datos "
|
||||
"de Baloo",
|
||||
"TAG_NEW_TAG_TEXT": "Introduce el nombre de la etiqueta (usa / para "
|
||||
"jerarquía):",
|
||||
"SEARCH_ADD_AND": "Añadir AND esta etiqueta a la búsqueda",
|
||||
@@ -1226,6 +1433,7 @@ _UI_TEXTS = {
|
||||
"PROPERTIES_TABLE_HEADER": ["Propiedad", "Valor"],
|
||||
"PROPERTIES_ADD_ATTR": "Añadir Atributo",
|
||||
"PROPERTIES_ADD_ATTR_NAME": "Nombre del Atributo (ej. user.comment):",
|
||||
"PROPERTIES_DELETE_ALL": "Borrar Todo",
|
||||
"PROPERTIES_ADD_ATTR_VALUE": "Valor para {}:",
|
||||
"PROPERTIES_ERROR_SET_ATTR": "Fallo al establecer xattr: {}",
|
||||
"PROPERTIES_ERROR_ADD_ATTR": "Fallo al añadir xattr: {}",
|
||||
@@ -1278,6 +1486,7 @@ _UI_TEXTS = {
|
||||
"CONTEXT_MENU_OPEN": "Abrir",
|
||||
"CONTEXT_MENU_OPEN_SEARCH_LOCATION": "Abrir y buscar en ubicación",
|
||||
"CONTEXT_MENU_OPEN_DEFAULT_APP": "Abrir ubicación con aplicación por defecto",
|
||||
"CONTEXT_MENU_FULLSCREEN_VIEWER": "Abrir con Visor a Pantalla Completa",
|
||||
"CONTEXT_MENU_MOVE_TO": "Mover a...",
|
||||
"CONTEXT_MENU_COPY_TO": "Copiar a...",
|
||||
"CONTEXT_MENU_ROTATE": "Girar",
|
||||
@@ -1312,11 +1521,13 @@ _UI_TEXTS = {
|
||||
"SEARCH": "Buscar",
|
||||
"SELECT": "Seleccionar",
|
||||
"ERROR": "Erro",
|
||||
"FILE_NOT_FOUND": "Ficheiro non atopado",
|
||||
"WARNING": "Advertencia",
|
||||
"INFO": "Información",
|
||||
"LOAD": "Cargar",
|
||||
"SAVE": "Gardar",
|
||||
"CREATE": "Crear",
|
||||
"CANCEL": "Cancelar",
|
||||
"RENAME": "Renomear",
|
||||
"COPY": "Copiar",
|
||||
"DELETE": "Eliminar",
|
||||
@@ -1428,6 +1639,83 @@ _UI_TEXTS = {
|
||||
"MENU_SHOW_LAYOUTS": "Amosar Deseños",
|
||||
"MENU_SHOW_HISTORY": "Amosar Historial",
|
||||
"MENU_SETTINGS": "Opcións",
|
||||
"SETTINGS_GROUP_DUPLICATES": "Duplicados",
|
||||
"MENU_DUPLICATES": "Duplicados",
|
||||
"MENU_DETECT_CURRENT_SEARCH": "Detectar na busca actual",
|
||||
"MENU_DETECT_ALL": "Detectar todos",
|
||||
"MENU_FORCE_FULL_ALL_ANALYSIS": "Forzar análise completa de todo",
|
||||
"MENU_FORCE_FULL_ANALYSIS": "Forzar análise completa",
|
||||
"MENU_REVIEW_IGNORED": "Revisar ignorados",
|
||||
"MENU_CLEAN_UP_HASHES": "Limpar",
|
||||
"MENU_REPAIR_DATABASE": "Reparar índice",
|
||||
"MENU_CLEAR_EXCEPTIONS": "Limpar parellas ignoradas",
|
||||
"CONFIRM_CLEAR_EXCEPTIONS_TITLE": "Confirmar Limpeza de Ignorados",
|
||||
"CONFIRM_CLEAR_EXCEPTIONS_TEXT": "Seguro que queres borrar todas as parellas "
|
||||
"de duplicados ignoradas? Volveranse detectar no vindeiro escaneo.",
|
||||
"REPAIRING_DATABASE": "Reparando índice de duplicados...",
|
||||
"MENU_CLEAR_HASHES": "Limpar hashes ({} elementos, {:.1f} MB en disco)",
|
||||
"CONFIRM_CLEAR_HASHES_TITLE": "Confirmar Limpeza de Hashes",
|
||||
"CONFIRM_CLEAR_HASHES_TEXT": "Seguro que queres eliminar permanentemente toda "
|
||||
"a base de datos de hashes?",
|
||||
"CONFIRM_CLEAR_HASHES_INFO": "Isto eliminará todos os hashes de imaxes "
|
||||
"calculados. Rexeneraranse a medida que detectes duplicados, o que pode ser "
|
||||
"lento. Esta acción non se pode deshacer.",
|
||||
"SETTINGS_DUPLICATE_METHOD_LABEL": "Método:",
|
||||
"SETTINGS_DUPLICATE_METHOD_TOOLTIP": "Selecciona o método para a detección "
|
||||
"de duplicados.",
|
||||
"METHOD_HISTOGRAM_HASHING": "Histograma + Hashing",
|
||||
"METHOD_RESNET": "ResNet (Baseado en IA)",
|
||||
"SETTINGS_DUPLICATE_CONFIRM_DELETE_LABEL": "Confirmar antes de borrar "
|
||||
"duplicados",
|
||||
"SETTINGS_DUPLICATE_WHITELIST_LABEL": "Lista branca (cartafoles a incluír):",
|
||||
"SETTINGS_DUPLICATE_WHITELIST_TOOLTIP": "Rutas de cartafoles separadas por "
|
||||
"comas para escanear ao usar 'Detectar todos'.",
|
||||
"SETTINGS_DUPLICATE_BLACKLIST_LABEL": "Lista negra (cartafoles a excluír):",
|
||||
"SETTINGS_DUPLICATE_BLACKLIST_TOOLTIP": "Rutas de cartafoles separadas por "
|
||||
"comas para ignorar durante escaneos de 'Detectar todos'.",
|
||||
"SETTINGS_DUPLICATE_SCAN_COUNT_LABEL": "Imaxes atopadas para 'Detectar "
|
||||
"todos': {}",
|
||||
"SETTINGS_DEFAULT_DELETE_TO_TRASH_LABEL": "A tecla Supr envía á papeleira por "
|
||||
"defecto",
|
||||
"SETTINGS_DEFAULT_DELETE_TO_TRASH_TOOLTIP": "Se está marcada, ao premer a "
|
||||
"tecla Supr moveranse os ficheiros á papeleira. Se non, eliminaranse "
|
||||
"permanentemente.",
|
||||
"SETTINGS_DUPLICATE_CONFIRM_DELETE_TOOLTIP": "Amosa un diálogo de confirmación "
|
||||
"antes de mover unha imaxe duplicada á papeleira.",
|
||||
"SETTINGS_DUPLICATE_THRESHOLD_LABEL": "Umbral de Similitude:",
|
||||
"SETTINGS_DUPLICATE_THRESHOLD_TOOLTIP": "Establece o umbral de similitude "
|
||||
"(50-100%). Valores máis altos significan que as imaxes deben ser máis "
|
||||
"parecidas para considerarse duplicadas.",
|
||||
"SETTINGS_DUPLICATE_MISSING_LIBS": "A librería 'imagehash' é necesaria para a "
|
||||
"detección de duplicados pero non se atopou. Esta función está desactivada.",
|
||||
"MENU_DETECT_DUPLICATES": "Detectar Duplicados",
|
||||
"DUPLICATE_WHITELIST_EMPTY": "A lista branca está baleira. Por favor, "
|
||||
"configúrea en Opcións.",
|
||||
"DUPLICATE_DETECTION_TITLE": "Detección de Duplicados",
|
||||
"DUPLICATE_ALREADY_RUNNING": "A detección de duplicados xa está en curso.",
|
||||
"DUPLICATE_NO_IMAGES": "Non hai imaxes cargadas para detectar duplicados.",
|
||||
"DUPLICATE_STARTING": "Iniciando detección de duplicados...",
|
||||
"DUPLICATE_PROGRESS": "Detección de duplicados: {message} ({current}/{total})",
|
||||
"DUPLICATE_NONE_FOUND": "Non se atoparon duplicados.",
|
||||
"DUPLICATE_FOUND_TITLE": "Duplicados Atopados",
|
||||
"DUPLICATE_FOUND_MSG": "Atopáronse os seguintes duplicados:\n",
|
||||
"DUPLICATE_FOUND_MORE": "... e {count} máis.",
|
||||
"DUPLICATE_FINISHED": "Detección de duplicados finalizada.",
|
||||
"DUPLICATE_MSG_HASHING": "Procesando {filename}",
|
||||
"DUPLICATE_MSG_ANALYZING": "Analizando {filename}",
|
||||
"DUPLICATE_MANAGER_TITLE": "Xestionar Imaxes Duplicadas",
|
||||
"DUPLICATE_DELETE_LEFT": "Papeleira Esquerda",
|
||||
"DUPLICATE_DELETE_RIGHT": "Papeleira Dereita",
|
||||
"CONFIRM_TRASH_TITLE": "Mover á papeleira",
|
||||
"CONFIRM_TRASH_TEXT": "Desexas mover esta imaxe á papeleira?",
|
||||
"DUPLICATE_KEEP_BOTH": "Manter Ambas (Ignorar)",
|
||||
"DUPLICATE_SKIP": "Omitir",
|
||||
"DUPLICATE_REMOVE_IGNORED": "Eliminar de ignorados",
|
||||
"DUPLICATE_INFO_FORMAT": "{size} - {width}x{height}",
|
||||
"VIEWER_MENU_LINK_PANES": "Vincular Paneis",
|
||||
"DUPLICATE_OPEN_COMPARISON": "Abrir Comparación",
|
||||
"DUPLICATE_LIST_HEADER": "Parellas Duplicadas",
|
||||
"IGNORED_DATE": "Data Ignorado",
|
||||
"SETTINGS_GROUP_SCANNER": "Escáner",
|
||||
"SETTINGS_GROUP_AREAS": "Áreas",
|
||||
"SETTINGS_GROUP_THUMBNAILS": "Miniaturas",
|
||||
@@ -1489,6 +1777,11 @@ _UI_TEXTS = {
|
||||
"arredor dos lugares.",
|
||||
"SETTINGS_LANDMARK_HISTORY_TOOLTIP": "Número máximo de nomes de lugares "
|
||||
"usados recentemente para lembrar.",
|
||||
"SETTINGS_PATH_NOT_FOUND_WARNING": "Advertencia: A ruta non existe ou "
|
||||
"non é un directorio: {}",
|
||||
"SETTINGS_USE_LAST_NAME_LABEL": "Usar o último nome por defecto",
|
||||
"SETTINGS_USE_LAST_NAME_TOOLTIP": "Rechea automáticamente a ventá de "
|
||||
"asignación có último nome utilizado.",
|
||||
"SETTINGS_FACE_HISTORY_COUNT_LABEL": "Máximo historial de caras:",
|
||||
"SETTINGS_THUMBS_REFRESH_LABEL": "Intervalo refresco miniaturas (ms):",
|
||||
"SETTINGS_THUMBS_BG_COLOR_LABEL": "Cor de fondo de miniaturas:",
|
||||
@@ -1628,6 +1921,8 @@ _UI_TEXTS = {
|
||||
"RENAME_ERROR_EXISTS": "O ficheiro '{}' xa existe.",
|
||||
"FILE_RENAMED": "Ficheiro renomeado a {}",
|
||||
"ERROR_RENAME": "Non se puido renomear o ficheiro: {}",
|
||||
"ERROR_JPEG_METADATA_LIMIT": "Límite de metadatos excedido para '{}'. Este "
|
||||
"ficheiro JPEG xa ten demasiados metadatos (XMP) para gardar máis.",
|
||||
"MAIN_DOCK_TITLE": "Panel principal",
|
||||
"LAYOUTS_TAB": "Deseños",
|
||||
"LAYOUTS_TABLE_HEADER": ["Nome", "Última Modificación"],
|
||||
@@ -1664,6 +1959,9 @@ _UI_TEXTS = {
|
||||
"TAG_ALL_TAGS": "📂 TÓDALAS ETIQUETAS",
|
||||
"TAG_NEW_TAG_TITLE": "Nova Etiqueta",
|
||||
"SEARCH_BY_TAG": "Buscar por esta etiqueta",
|
||||
"TAG_ADD_TOOLTIP": "Crear unha nova etiqueta",
|
||||
"TAG_REFRESH_TOOLTIP": "Refrescar etiquetas dispoñibles dende a base de datos "
|
||||
"de Baloo",
|
||||
"TAG_NEW_TAG_TEXT": "Introduce o nome da etiqueta (usa / para "
|
||||
"xerarquía):",
|
||||
"SEARCH_ADD_AND": "Engadir AND esta etiqueta á busca",
|
||||
@@ -1699,6 +1997,7 @@ _UI_TEXTS = {
|
||||
"PROPERTIES_TABLE_HEADER": ["Propiedade", "Valor"],
|
||||
"PROPERTIES_ADD_ATTR": "Engadir Atributo",
|
||||
"PROPERTIES_ADD_ATTR_NAME": "Nome do Atributo (ex. user.comment):",
|
||||
"PROPERTIES_DELETE_ALL": "Borrar Todo",
|
||||
"PROPERTIES_ADD_ATTR_VALUE": "Valor para {}:",
|
||||
"PROPERTIES_ERROR_SET_ATTR": "Fallo ao establecer xattr: {}",
|
||||
"PROPERTIES_ERROR_ADD_ATTR": "Fallo ao engadir xattr: {}",
|
||||
@@ -1762,6 +2061,7 @@ _UI_TEXTS = {
|
||||
"CONTEXT_MENU_COPY_FILE": "Copiar URL do Ficheiro",
|
||||
"CONTEXT_MENU_COPY_DIR": "Copiar Ruta do Directorio",
|
||||
"CONTEXT_MENU_PROPERTIES": "Propiedades",
|
||||
"CONTEXT_MENU_FULLSCREEN_VIEWER": "Abrir con Visor a Pantalla Completa",
|
||||
"CONTEXT_MENU_NO_APPS_FOUND": "Non se atoparon aplicacións",
|
||||
"CONTEXT_MENU_REGENERATE": "Rexenerar Miniatura",
|
||||
"CONTEXT_MENU_ERROR_LISTING_APPS": "Erro listando aplicacións",
|
||||
@@ -1786,6 +2086,7 @@ _UI_TEXTS = {
|
||||
|
||||
# Determine which language to use for UI strings
|
||||
def _get_current_language():
|
||||
"""Determines the language to use for UI strings based on environment."""
|
||||
lang = os.getenv("BAGHEERA_LANG") or APP_CONFIG.get("language", DEFAULT_LANGUAGE)
|
||||
|
||||
if lang == "system":
|
||||
|
||||
1247
duplicatecache.py
Normal file
1247
duplicatecache.py
Normal file
File diff suppressed because it is too large
Load Diff
1106
duplicatedialog.py
Normal file
1106
duplicatedialog.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,14 @@
|
||||
"""
|
||||
File System Watcher Module for Bagheera Image Viewer.
|
||||
|
||||
This module provides functionality to monitor file system changes in real-time
|
||||
using the watchdog library. It notifies the application about new, deleted, or
|
||||
modified image files within watched directories, handling debouncing to ensure
|
||||
stability during rapid file operations.
|
||||
|
||||
Classes:
|
||||
FileSystemWatcher: Coordinates file system monitoring and emits Qt signals.
|
||||
"""
|
||||
import os
|
||||
try:
|
||||
from watchdog.observers import Observer
|
||||
@@ -14,20 +25,32 @@ class FileSystemWatcher(QObject):
|
||||
Monitors file system events (created, deleted, modified) for specified directories.
|
||||
Emits signals to notify the main application thread of changes.
|
||||
"""
|
||||
|
||||
# Signals emitted to the rest of the application
|
||||
# ---------------------------------------------
|
||||
|
||||
file_created = Signal(str)
|
||||
file_deleted = Signal(str)
|
||||
file_modified = Signal(str)
|
||||
_file_modified_from_handler = Signal(str) # Internal signal from handler thread
|
||||
file_moved = Signal(str, str)
|
||||
monitoring_status_changed = Signal(bool) # Nuevo: Señal para el estado de monitoreo
|
||||
monitoring_status_changed = Signal(bool) # New: Signal for monitoring status
|
||||
directory_moved = Signal(str, str)
|
||||
directory_modified = Signal(str) # For changes that might not be specific files
|
||||
|
||||
_modified_events_queue = {} # {path: QTimer}
|
||||
"""Queue to manage debouncing of modification events."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
"""
|
||||
Initializes the FileSystemWatcher.
|
||||
|
||||
Args:
|
||||
parent (QObject, optional): The parent object. Defaults to None.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self._watched_directories = set()
|
||||
self._debounce_interval = 500 # milliseconds
|
||||
|
||||
if HAVE_WATCHDOG:
|
||||
self._observer = Observer()
|
||||
@@ -36,16 +59,21 @@ class FileSystemWatcher(QObject):
|
||||
else:
|
||||
self._observer = None # Keep observer as None if watchdog is not available
|
||||
|
||||
# Debounce timer for modified events to avoid multiple signals for a single save
|
||||
self._debounce_interval = 500 # milliseconds
|
||||
|
||||
# Connect the internal signal to the debouncing slot
|
||||
if HAVE_WATCHDOG:
|
||||
self._file_modified_from_handler.connect(self._on_file_modified_debounced)
|
||||
|
||||
def _on_file_modified_debounced(self, path):
|
||||
"""Slot to handle modified events from the watchdog thread, debounced in the
|
||||
main thread."""
|
||||
"""
|
||||
Slot to handle modified events from the watchdog thread.
|
||||
|
||||
Implements a debouncing mechanism: if multiple modification events
|
||||
arrive for the same path within the interval, previous timers are
|
||||
reset to avoid redundant UI updates or heavy disk operations.
|
||||
|
||||
Args:
|
||||
path (str): The path of the modified file.
|
||||
"""
|
||||
# Debounce timer for modified events to avoid multiple signals for a single save
|
||||
if path in self._modified_events_queue:
|
||||
self._modified_events_queue[path].stop()
|
||||
@@ -59,7 +87,12 @@ class FileSystemWatcher(QObject):
|
||||
self._modified_events_queue[path].start()
|
||||
|
||||
def _emit_modified_after_debounce(self, path):
|
||||
"""Emits the file_modified signal after the debounce period."""
|
||||
"""
|
||||
Emits the file_modified signal after the debounce period.
|
||||
|
||||
Args:
|
||||
path (str): The path of the modified file.
|
||||
"""
|
||||
self.file_modified.emit(path)
|
||||
if path in self._modified_events_queue:
|
||||
# Safely delete the QTimer object when done
|
||||
@@ -67,7 +100,16 @@ class FileSystemWatcher(QObject):
|
||||
del self._modified_events_queue[path]
|
||||
|
||||
def add_path(self, path):
|
||||
"""Adds a directory to be monitored."""
|
||||
"""
|
||||
Adds a directory to be monitored.
|
||||
|
||||
This method ensures that redundant watches are avoided by checking if
|
||||
the path is already covered by an existing watch or if it should
|
||||
consolidate multiple sub-watches into a single parent watch.
|
||||
|
||||
Args:
|
||||
path (str): The directory path to monitor.
|
||||
"""
|
||||
if not HAVE_WATCHDOG or self._observer is None:
|
||||
return
|
||||
|
||||
@@ -111,7 +153,12 @@ class FileSystemWatcher(QObject):
|
||||
self.monitoring_status_changed.emit(True)
|
||||
|
||||
def remove_path(self, path):
|
||||
"""Removes a directory from monitoring."""
|
||||
"""
|
||||
Removes a directory from monitoring.
|
||||
|
||||
Args:
|
||||
path (str): The directory path to stop monitoring.
|
||||
"""
|
||||
if not HAVE_WATCHDOG or self._observer is None:
|
||||
return
|
||||
abs_path = os.path.normpath(os.path.abspath(os.path.expanduser(path)))
|
||||
@@ -138,11 +185,12 @@ class FileSystemWatcher(QObject):
|
||||
self.monitoring_status_changed.emit(False)
|
||||
|
||||
def stop(self):
|
||||
"""Stops the file system observer."""
|
||||
"""
|
||||
Stops the file system observer and cleans up active timers.
|
||||
"""
|
||||
if HAVE_WATCHDOG and self._observer:
|
||||
self._observer.stop()
|
||||
self._observer.join()
|
||||
|
||||
for timer in self._modified_events_queue.values():
|
||||
timer.stop()
|
||||
|
||||
@@ -151,14 +199,24 @@ class FileSystemWatcher(QObject):
|
||||
|
||||
if HAVE_WATCHDOG:
|
||||
class _Handler(FileSystemEventHandler):
|
||||
"""
|
||||
Custom event handler for watchdog events.
|
||||
|
||||
Translates low-level file system events into high-level application
|
||||
signals, filtering for supported image types.
|
||||
"""
|
||||
# Signal to communicate to main thread
|
||||
file_modified_from_thread = Signal(str)
|
||||
"""Custom event handler for watchdog events."""
|
||||
|
||||
def __init__(self, watcher):
|
||||
"""
|
||||
Initializes the handler with a reference to the main watcher.
|
||||
"""
|
||||
super().__init__()
|
||||
self.watcher = watcher
|
||||
|
||||
def on_created(self, event):
|
||||
"""Called when a file or directory is created."""
|
||||
if event.is_directory:
|
||||
self.watcher.directory_modified.emit(event.src_path)
|
||||
return
|
||||
@@ -166,6 +224,7 @@ class FileSystemWatcher(QObject):
|
||||
self.watcher.file_created.emit(event.src_path)
|
||||
|
||||
def on_deleted(self, event):
|
||||
"""Called when a file or directory is deleted."""
|
||||
if event.is_directory:
|
||||
self.watcher.directory_modified.emit(event.src_path)
|
||||
return
|
||||
@@ -173,6 +232,7 @@ class FileSystemWatcher(QObject):
|
||||
self.watcher.file_deleted.emit(event.src_path)
|
||||
|
||||
def on_moved(self, event):
|
||||
"""Called when a file or directory is moved or renamed."""
|
||||
if event.is_directory:
|
||||
self.watcher.directory_moved.emit(event.src_path, event.dest_path)
|
||||
self.watcher.directory_modified.emit(event.src_path)
|
||||
@@ -181,6 +241,7 @@ class FileSystemWatcher(QObject):
|
||||
self.watcher.file_moved.emit(event.src_path, event.dest_path)
|
||||
|
||||
def on_closed(self, event):
|
||||
"""Called when a file is closed."""
|
||||
if event.is_directory:
|
||||
self.watcher.directory_modified.emit(event.src_path)
|
||||
return
|
||||
@@ -188,6 +249,7 @@ class FileSystemWatcher(QObject):
|
||||
self.watcher.file_modified.emit(event.src_path)
|
||||
|
||||
def on_modified(self, event):
|
||||
"""Called when a file or directory is modified."""
|
||||
if event.is_directory:
|
||||
self.watcher.directory_modified.emit(event.src_path)
|
||||
return
|
||||
@@ -195,9 +257,21 @@ class FileSystemWatcher(QObject):
|
||||
self.watcher._file_modified_from_handler.emit(event.src_path)
|
||||
|
||||
def _emit_modified(self, path):
|
||||
"""
|
||||
Internal helper to emit the modified signal.
|
||||
|
||||
Args:
|
||||
path (str): The modified path.
|
||||
"""
|
||||
self.watcher.file_modified.emit(path)
|
||||
if path in self.watcher._modified_events_queue:
|
||||
del self.watcher._modified_events_queue[path]
|
||||
|
||||
def _is_image_file(self, path):
|
||||
"""
|
||||
Checks if a given path has a supported image extension.
|
||||
|
||||
Args:
|
||||
path (str): The file path to check.
|
||||
"""
|
||||
return os.path.splitext(path)[1].lower() in IMAGE_EXTENSIONS
|
||||
|
||||
@@ -13,7 +13,7 @@ Classes:
|
||||
import os
|
||||
import logging
|
||||
import math
|
||||
from PySide6.QtCore import QThread, Signal, QMutex, QWaitCondition, QObject, Qt
|
||||
from PySide6.QtCore import QThread, Signal, QMutex, QWaitCondition, QObject, Qt, QSize
|
||||
from PySide6.QtGui import QImage, QImageReader, QPixmap, QTransform
|
||||
from xmpmanager import XmpManager
|
||||
from constants import (
|
||||
@@ -42,6 +42,7 @@ class ImagePreloader(QThread):
|
||||
def __init__(self):
|
||||
"""Initializes the preloader thread."""
|
||||
super().__init__()
|
||||
self.setObjectName("ImagePreloaderThread")
|
||||
self.path = None
|
||||
self.index = -1
|
||||
self.mutex = QMutex()
|
||||
@@ -344,7 +345,7 @@ class ImageController(QObject):
|
||||
|
||||
faces_to_save.append(face_copy)
|
||||
|
||||
XmpManager.save_faces(path, faces_to_save)
|
||||
return XmpManager.save_faces(path, faces_to_save)
|
||||
|
||||
def add_face(self, name, x, y, w, h, region_type="Face"):
|
||||
"""Adds a new face. The full tag path should be passed as 'name'."""
|
||||
@@ -389,8 +390,8 @@ class ImageController(QObject):
|
||||
self.metadata_changed.emit(current_path,
|
||||
{'tags': new_tags_list,
|
||||
'rating': self._current_rating})
|
||||
except IOError as e:
|
||||
print(f"Error setting tags for {current_path}: {e}")
|
||||
except Exception:
|
||||
raise
|
||||
|
||||
def set_rating(self, new_rating):
|
||||
current_path = self.get_current_path()
|
||||
@@ -688,21 +689,36 @@ class ImageController(QObject):
|
||||
if self.pixmap_original.isNull():
|
||||
return QPixmap()
|
||||
|
||||
transform = QTransform().rotate(self.rotation)
|
||||
transformed_pixmap = self.pixmap_original.transformed(
|
||||
transform,
|
||||
Qt.SmoothTransformation
|
||||
)
|
||||
new_size = transformed_pixmap.size() * self.zoom_factor
|
||||
scaled_pixmap = transformed_pixmap.scaled(new_size, Qt.KeepAspectRatio,
|
||||
Qt.SmoothTransformation)
|
||||
# Start with an identity transform
|
||||
transform = QTransform()
|
||||
|
||||
# Apply rotation
|
||||
if self.rotation != 0:
|
||||
transform.rotate(float(self.rotation))
|
||||
|
||||
# Apply flips
|
||||
if self.flip_h:
|
||||
scaled_pixmap = scaled_pixmap.transformed(QTransform().scale(-1, 1))
|
||||
transform.scale(-1, 1)
|
||||
if self.flip_v:
|
||||
scaled_pixmap = scaled_pixmap.transformed(QTransform().scale(1, -1))
|
||||
transform.scale(1, -1)
|
||||
|
||||
# Apply the cumulative transform to the original pixmap
|
||||
transformed_pixmap = self.pixmap_original.transformed(
|
||||
transform, Qt.TransformationMode.SmoothTransformation)
|
||||
|
||||
# Apply scaling (zoom) separately after rotation and flips,
|
||||
# as scaling should be based on the *transformed* dimensions.
|
||||
# This is important: if you scale before rotation, the scaling
|
||||
# factors might be applied to the wrong axes.
|
||||
if self.zoom_factor != 1.0:
|
||||
new_size_f = transformed_pixmap.size() * self.zoom_factor
|
||||
new_size = QSize(int(new_size_f.width()), int(new_size_f.height()))
|
||||
scaled_pixmap = transformed_pixmap.scaled(
|
||||
new_size, Qt.AspectRatioMode.KeepAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation)
|
||||
return scaled_pixmap
|
||||
else:
|
||||
return transformed_pixmap
|
||||
|
||||
def rotate(self, angle):
|
||||
"""
|
||||
|
||||
121
imagescanner.py
121
imagescanner.py
@@ -32,11 +32,13 @@ from PySide6.QtCore import (
|
||||
QObject, QThread, Signal, QMutex, QReadWriteLock, QSize, QSemaphore, QWaitCondition,
|
||||
QByteArray, QBuffer, QIODevice, Qt, QTimer, QRunnable, QThreadPool, QFile
|
||||
)
|
||||
from PySide6.QtGui import QImage, QImageReader, QImageIOHandler
|
||||
from PySide6.QtGui import QImage, QImageReader, QImageIOHandler, QIcon
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from constants import (
|
||||
APP_CONFIG, CACHE_PATH, CACHE_MAX_SIZE, CACHE_MAX_RAM_BYTES, CONFIG_DIR,
|
||||
MIN_FREE_RAM_PERCENT, DISK_CACHE_MAX_BYTES, HAVE_BAGHEERASEARCH_LIB, IMAGE_EXTENSIONS,
|
||||
APP_CONFIG, CACHE_PATH, CACHE_MAX_SIZE, CACHE_MAX_RAM_BYTES,
|
||||
APP_DATA_DIR, MIN_FREE_RAM_PERCENT, DISK_CACHE_MAX_BYTES,
|
||||
HAVE_BAGHEERASEARCH_LIB, IMAGE_EXTENSIONS,
|
||||
SCANNER_SETTINGS_DEFAULTS, SEARCH_CMD, THUMBNAIL_SIZES,
|
||||
UITexts
|
||||
)
|
||||
@@ -132,13 +134,11 @@ class ScannerWorker(QRunnable):
|
||||
sizes_to_check = self.target_sizes if self.target_sizes is not None \
|
||||
else SCANNER_GENERATE_SIZES
|
||||
|
||||
if self._is_cancelled:
|
||||
if self.semaphore:
|
||||
self.semaphore.release()
|
||||
return
|
||||
|
||||
fd = None
|
||||
try:
|
||||
if self._is_cancelled:
|
||||
return
|
||||
|
||||
# Optimize: Open file once to reuse FD for stat and xattrs
|
||||
fd = os.open(self.path, os.O_RDONLY)
|
||||
stat_res = os.fstat(fd)
|
||||
@@ -196,8 +196,11 @@ class ScannerWorker(QRunnable):
|
||||
tags, rating = res_meta.tags, res_meta.rating
|
||||
self.result = (self.path, smallest_thumb_for_signal,
|
||||
curr_mtime, tags, rating, curr_inode, curr_dev)
|
||||
except (FileNotFoundError, PermissionError) as e:
|
||||
logger.debug(f"Skipping {self.path} due to access issue: {e}")
|
||||
self.result = None
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing image {self.path}: {e}")
|
||||
logger.warning(f"Unexpected error processing image {self.path}: {e}")
|
||||
self.result = None
|
||||
finally:
|
||||
if fd is not None:
|
||||
@@ -265,7 +268,7 @@ def generate_thumbnail(path, size, fd=None):
|
||||
# better quality for upscaling.
|
||||
return img.scaled(size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation)
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating thumbnail for {path}: {e}")
|
||||
logger.debug(f"Could not generate thumbnail for {path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
@@ -283,6 +286,7 @@ class CacheWriter(QThread):
|
||||
self._condition_new_data = QWaitCondition()
|
||||
self._condition_space_available = QWaitCondition()
|
||||
# Soft limit for blocking producers (background threads)
|
||||
self.setObjectName("CacheWriterThread") # Add this line
|
||||
self._max_size = 50
|
||||
self._running = True
|
||||
|
||||
@@ -332,9 +336,9 @@ class CacheWriter(QThread):
|
||||
self._running = False
|
||||
# Do not clear the queue here; let the run loop drain it to prevent data loss.
|
||||
self._condition_new_data.wakeAll()
|
||||
logger.debug(f"{self.objectName()} stop requested, waking all.")
|
||||
self._condition_space_available.wakeAll()
|
||||
self._mutex.unlock()
|
||||
self.wait()
|
||||
|
||||
def run(self):
|
||||
self.setPriority(QThread.IdlePriority)
|
||||
@@ -379,6 +383,7 @@ class CacheWriter(QThread):
|
||||
self.cache._batch_write_to_lmdb(batch)
|
||||
except Exception as e:
|
||||
logger.error(f"CacheWriter batch write error: {e}")
|
||||
logger.debug(f"{self.objectName()} run method exiting.")
|
||||
|
||||
|
||||
class CacheLoader(QThread):
|
||||
@@ -442,7 +447,6 @@ class CacheLoader(QThread):
|
||||
self._mutex.lock()
|
||||
self._condition.wakeAll()
|
||||
self._mutex.unlock()
|
||||
self.wait()
|
||||
|
||||
def run(self):
|
||||
self.setPriority(QThread.IdlePriority)
|
||||
@@ -522,15 +526,24 @@ class ThumbnailCache(QObject):
|
||||
self._db_lock = QMutex() # Lock specifically for _db_handles access
|
||||
self._db_handles = {} # Cache for LMDB database handles (dbi)
|
||||
self._cancel_loading = False
|
||||
self._broken_cache = {} # (dev, inode, size) -> (mtime, error_msg)
|
||||
self._cache_bytes_size = 0
|
||||
self._cache_writer = None
|
||||
self._cache_loader = None
|
||||
|
||||
# Pre-generate broken images for standard tiers in the main thread
|
||||
self._broken_images = {}
|
||||
for size in THUMBNAIL_SIZES:
|
||||
icon = QIcon.fromTheme("image-missing",
|
||||
QIcon.fromTheme("broken-image",
|
||||
QIcon.fromTheme("dialog-error")))
|
||||
self._broken_images[size] = icon.pixmap(size, size).toImage()
|
||||
|
||||
self.lmdb_open()
|
||||
|
||||
def lmdb_open(self):
|
||||
# Initialize LMDB environment
|
||||
cache_dir = Path(CONFIG_DIR)
|
||||
cache_dir = Path(APP_DATA_DIR)
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
@@ -558,12 +571,22 @@ class ThumbnailCache(QObject):
|
||||
self._lmdb_env = None
|
||||
|
||||
def lmdb_close(self):
|
||||
# Stop and wait for worker threads to ensure they are not accessing
|
||||
# the LMDB environment while it's being closed.
|
||||
if hasattr(self, '_cache_writer') and self._cache_writer:
|
||||
self._cache_writer.stop()
|
||||
while self._cache_writer.isRunning():
|
||||
if QApplication.instance(): # Check if QApplication is still valid
|
||||
QApplication.processEvents() # Keep UI responsive
|
||||
QThread.msleep(50)
|
||||
self._cache_writer = None
|
||||
|
||||
if hasattr(self, '_cache_loader') and self._cache_loader:
|
||||
self._cache_loader.stop()
|
||||
while self._cache_loader.isRunning():
|
||||
if QApplication.instance(): # Check if QApplication is still valid
|
||||
QApplication.processEvents() # Keep UI responsive
|
||||
QThread.msleep(50)
|
||||
self._cache_loader = None
|
||||
self._loading_set.clear()
|
||||
self._futures.clear()
|
||||
@@ -658,8 +681,9 @@ class ThumbnailCache(QObject):
|
||||
import psutil
|
||||
mem = psutil.virtual_memory()
|
||||
if (mem.available / mem.total) * 100 < MIN_FREE_RAM_PERCENT:
|
||||
logger.warning(f"Low system memory detected (< {MIN_FREE_RAM_PERCENT}%). "
|
||||
"Applying aggressive tiered pruning.")
|
||||
logger.warning(f"Low system memory detected "
|
||||
f"(< {MIN_FREE_RAM_PERCENT}%). "
|
||||
f"Applying aggressive tiered pruning.")
|
||||
|
||||
# Strategy: first clear ALL cached high-res tiers to free space quickly
|
||||
# while keeping the 128px grid thumbnails intact.
|
||||
@@ -721,12 +745,28 @@ class ThumbnailCache(QObject):
|
||||
|
||||
def _get_tier_for_size(self, requested_size):
|
||||
"""Determines the ideal thumbnail tier based on the requested size."""
|
||||
if requested_size < 192:
|
||||
if requested_size <= 128:
|
||||
return 128
|
||||
if requested_size < 320:
|
||||
if requested_size <= 256:
|
||||
return 256
|
||||
return 512
|
||||
|
||||
def mark_broken(self, path, size, mtime, inode, dev_id, error_msg):
|
||||
"""Marks a thumbnail load as failed with a message."""
|
||||
key = (dev_id, struct.pack('Q', inode) if inode is not None else None, size)
|
||||
with self._write_lock():
|
||||
self._broken_cache[key] = (mtime, error_msg)
|
||||
|
||||
def get_broken_info(self, path, size, mtime, inode, dev_id):
|
||||
"""Returns the error message if a thumbnail is known to have failed, else
|
||||
None."""
|
||||
key = (dev_id, struct.pack('Q', inode) if inode is not None else None, size)
|
||||
with self._read_lock():
|
||||
info = self._broken_cache.get(key)
|
||||
if info and info[0] == mtime:
|
||||
return info[1]
|
||||
return None
|
||||
|
||||
def _resolve_file_identity(self, path, curr_mtime, inode, device_id):
|
||||
"""Helper to resolve file mtime, device, and inode."""
|
||||
mtime = curr_mtime
|
||||
@@ -847,6 +887,12 @@ class ThumbnailCache(QObject):
|
||||
if mtime is None:
|
||||
return EMPTY_THUMBNAIL
|
||||
|
||||
# Check if known to be broken
|
||||
broken_msg = self.get_broken_info(path, target_tier, mtime, inode, device_id)
|
||||
if broken_msg:
|
||||
return ThumbnailResult(
|
||||
self._broken_images.get(target_tier), mtime, target_tier)
|
||||
|
||||
best_img, best_mtime, best_tier = None, 0, 0
|
||||
|
||||
with self._read_lock():
|
||||
@@ -1189,6 +1235,12 @@ class ThumbnailCache(QObject):
|
||||
return None
|
||||
|
||||
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")
|
||||
return None
|
||||
return ba.data()
|
||||
@@ -1311,6 +1363,7 @@ class CacheCleaner(QThread):
|
||||
def stop(self):
|
||||
"""Signals the thread to stop."""
|
||||
self._is_running = False
|
||||
self.wait()
|
||||
|
||||
def run(self):
|
||||
self.setPriority(QThread.IdlePriority)
|
||||
@@ -1382,8 +1435,18 @@ class ThumbnailGenerator(QThread):
|
||||
# The signal/slot mechanism handles thread safety automatically.
|
||||
emitter.progress_tick.connect(on_tick, Qt.QueuedConnection)
|
||||
|
||||
started_count = 0
|
||||
for path in self.paths:
|
||||
# Process in batches to avoid saturating the global thread pool queue.
|
||||
# This allows the application to respond to stop() signals almost immediately.
|
||||
batch_size = max(4, pool.maxThreadCount() * 2)
|
||||
|
||||
for i in range(0, len(self.paths), batch_size):
|
||||
if self._abort:
|
||||
break
|
||||
|
||||
batch_slice = self.paths[i : i + batch_size]
|
||||
started_in_batch = 0
|
||||
|
||||
for path in batch_slice:
|
||||
if self._abort:
|
||||
break
|
||||
runnable = ScannerWorker(self.cache, path, target_sizes=[self.size],
|
||||
@@ -1392,17 +1455,18 @@ class ThumbnailGenerator(QThread):
|
||||
runnable.setAutoDelete(False)
|
||||
|
||||
self._workers_mutex.lock()
|
||||
if self._abort:
|
||||
self._workers_mutex.unlock()
|
||||
break
|
||||
self._workers.append(runnable)
|
||||
self._workers_mutex.unlock()
|
||||
|
||||
pool.start(runnable)
|
||||
started_count += 1
|
||||
started_in_batch += 1
|
||||
|
||||
if started_count > 0:
|
||||
sem.acquire(started_count)
|
||||
if started_in_batch > 0:
|
||||
# Wait for the current batch to finish before queuing more
|
||||
sem.acquire(started_in_batch)
|
||||
self._workers_mutex.lock()
|
||||
self._workers.clear()
|
||||
self._workers_mutex.unlock()
|
||||
|
||||
self._workers_mutex.lock()
|
||||
self._workers.clear()
|
||||
@@ -1425,13 +1489,13 @@ class ImageScanner(QThread):
|
||||
more_files_available = Signal(int, int) # Last loaded index, remainder
|
||||
|
||||
def __init__(self, cache, paths, is_file_list=False, viewers=None,
|
||||
thread_pool_manager=None):
|
||||
# is_file_list is not used
|
||||
thread_pool_manager=None, target_sizes=None):
|
||||
if not paths or not isinstance(paths, (list, tuple)):
|
||||
logger.warning("ImageScanner initialized with empty or invalid paths")
|
||||
paths = []
|
||||
super().__init__()
|
||||
self.cache = cache
|
||||
self.target_sizes = target_sizes
|
||||
self.all_files = []
|
||||
self.thread_pool_manager = thread_pool_manager
|
||||
self._viewers = viewers
|
||||
@@ -1788,7 +1852,8 @@ class ImageScanner(QThread):
|
||||
return
|
||||
|
||||
for f_path, _ in tasks:
|
||||
r = ScannerWorker(self.cache, f_path, semaphore=sem)
|
||||
r = ScannerWorker(
|
||||
self.cache, f_path, semaphore=sem, target_sizes=self.target_sizes)
|
||||
r.setAutoDelete(False)
|
||||
runnables.append(r)
|
||||
self._current_workers.append(r)
|
||||
|
||||
177
imageviewer.py
177
imageviewer.py
@@ -26,6 +26,7 @@ from PySide6.QtCore import (
|
||||
Signal, QPoint, QSize, Qt, QMimeData, QUrl, QTimer, QEvent, QRect, Slot, QRectF,
|
||||
QThread, QObject
|
||||
)
|
||||
from PySide6.QtDBus import QDBusConnection, QDBusMessage, QDBus
|
||||
|
||||
from constants import (
|
||||
APP_CONFIG, DEFAULT_FACE_BOX_COLOR, DEFAULT_PET_BOX_COLOR, DEFAULT_VIEWER_SHORTCUTS,
|
||||
@@ -238,7 +239,10 @@ class FastTagManager:
|
||||
current_path = controller.get_current_path() if controller else None
|
||||
if not current_path:
|
||||
return
|
||||
try:
|
||||
controller.toggle_tag(tag_name, is_checked)
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self.viewer, UITexts.ERROR, str(e))
|
||||
self.viewer.update_status_bar()
|
||||
if self.main_win:
|
||||
if is_checked:
|
||||
@@ -419,11 +423,22 @@ class FaceCanvas(QLabel):
|
||||
self.edit_handle = None
|
||||
self.edit_start_rect = QRect()
|
||||
self.resize_margin = 8
|
||||
|
||||
# Zoom indicator
|
||||
self.zoom_indicator_point = None
|
||||
self.zoom_indicator_timer = QTimer(self)
|
||||
self.zoom_indicator_timer.setSingleShot(True)
|
||||
self.zoom_indicator_timer.setInterval(500) # Show for 500ms
|
||||
self.zoom_indicator_timer.timeout.connect(self._clear_zoom_indicator)
|
||||
self.crop_rect = QRect()
|
||||
self.crop_handle = None
|
||||
self.crop_start_pos = QPoint()
|
||||
self.crop_start_rect = QRect()
|
||||
|
||||
def _clear_zoom_indicator(self):
|
||||
self.zoom_indicator_point = None
|
||||
self.update()
|
||||
|
||||
def map_from_source(self, face_data):
|
||||
"""Maps original normalized face data to current canvas QRect."""
|
||||
nx = face_data.get('x', 0)
|
||||
@@ -623,6 +638,18 @@ class FaceCanvas(QLabel):
|
||||
painter.drawRect(pt.x() - offset, pt.y() - offset,
|
||||
handle_size, handle_size)
|
||||
|
||||
# Draw zoom indicator
|
||||
if self.zoom_indicator_point:
|
||||
painter.setPen(QPen(QColor(255, 255, 0), 2)) # Yellow crosshair
|
||||
painter.drawLine(self.zoom_indicator_point.x() - 10,
|
||||
self.zoom_indicator_point.y(),
|
||||
self.zoom_indicator_point.x() + 10,
|
||||
self.zoom_indicator_point.y())
|
||||
painter.drawLine(self.zoom_indicator_point.x(),
|
||||
self.zoom_indicator_point.y() - 10,
|
||||
self.zoom_indicator_point.x(),
|
||||
self.zoom_indicator_point.y() + 10)
|
||||
|
||||
def _hit_test(self, pos):
|
||||
"""Determines if the mouse is over a name, handle, or body."""
|
||||
if not self.controller.show_faces:
|
||||
@@ -990,8 +1017,12 @@ class FaceCanvas(QLabel):
|
||||
history = history_list \
|
||||
if self.viewer.main_win else []
|
||||
|
||||
setting_key = f"{region_type.lower()}_use_last_name"
|
||||
suggested = history[0] if history and APP_CONFIG.get(
|
||||
setting_key, False) else ""
|
||||
|
||||
full_tag, updated_history, ok = FaceNameDialog.get_name(
|
||||
self.viewer, history,
|
||||
self.viewer, history, current_name=suggested,
|
||||
main_win=self.viewer.main_win, region_type=region_type)
|
||||
|
||||
if ok and full_tag:
|
||||
@@ -1122,18 +1153,62 @@ class ZoomManager(QObject):
|
||||
super().__init__(viewer)
|
||||
self.viewer = viewer
|
||||
|
||||
def zoom(self, factor, reset=False):
|
||||
"""Applies zoom to the image."""
|
||||
def zoom(self, factor=1.1, reset=False, focus_point=None, absolute_factor=None):
|
||||
"""Applies zoom to the image, centering on focus_point if provided."""
|
||||
if not self.viewer.controller or \
|
||||
self.viewer.controller.pixmap_original.isNull():
|
||||
return
|
||||
|
||||
c_point = None
|
||||
|
||||
if reset:
|
||||
self.viewer.controller.zoom_factor = 1.0
|
||||
self.viewer.update_view(resize_win=True)
|
||||
if self.viewer.canvas:
|
||||
c_point = self.viewer.canvas.rect().center()
|
||||
elif absolute_factor is not None: # New: set absolute zoom factor
|
||||
self.viewer.controller.zoom_factor = absolute_factor
|
||||
# Don't resize window for sync zoom
|
||||
self.viewer.update_view(resize_win=False)
|
||||
if focus_point is not None and self.viewer.canvas:
|
||||
scroll_area = self.viewer.scroll_area
|
||||
viewport = scroll_area.viewport()
|
||||
v_point = viewport.mapFrom(self.viewer, focus_point)
|
||||
c_point = self.viewer.canvas.mapFrom(viewport, v_point)
|
||||
else:
|
||||
# 1. Determine focus point in viewport coordinates
|
||||
scroll_area = self.viewer.scroll_area
|
||||
viewport = scroll_area.viewport()
|
||||
|
||||
if focus_point is None:
|
||||
v_point = viewport.rect().center()
|
||||
else:
|
||||
# focus_point is relative to the self.viewer widget
|
||||
# (ImageViewer or ImagePane)
|
||||
v_point = viewport.mapFrom(self.viewer, focus_point)
|
||||
|
||||
# 2. Map focus point to canvas coordinates before zoom
|
||||
c_point = self.viewer.canvas.mapFrom(viewport, v_point)
|
||||
|
||||
self.viewer.controller.zoom_factor *= factor
|
||||
self.viewer.update_view(resize_win=True)
|
||||
# Apply update (this resizes the canvas)
|
||||
self.viewer.update_view(resize_win=(not self.viewer.isFullScreen()))
|
||||
|
||||
# 3. Adjust scrollbars to maintain pixel under cursor
|
||||
scroll_area.horizontalScrollBar().setValue(
|
||||
int(c_point.x() * factor - v_point.x()))
|
||||
scroll_area.verticalScrollBar().setValue(
|
||||
int(c_point.y() * factor - v_point.y()))
|
||||
|
||||
# Notify the main window that the image (and possibly index) has changed
|
||||
# so it can update its selection.
|
||||
self.viewer.index_changed.emit(self.viewer.controller.index)
|
||||
|
||||
if focus_point is not None and self.viewer.canvas:
|
||||
self.viewer.canvas.zoom_indicator_point = c_point
|
||||
self.viewer.canvas.zoom_indicator_timer.start()
|
||||
self.viewer.canvas.update()
|
||||
|
||||
self.zoomed.emit(self.viewer.controller.zoom_factor)
|
||||
if hasattr(self.viewer, 'sync_filmstrip_selection'):
|
||||
self.viewer.sync_filmstrip_selection(self.viewer.controller.index)
|
||||
@@ -1645,16 +1720,23 @@ class ImageViewer(QWidget):
|
||||
if pane != self.active_pane:
|
||||
pane.controller.zoom_factor = factor
|
||||
pane.update_view(resize_win=False)
|
||||
|
||||
# Re-apply relative scroll after zoom changes bounds
|
||||
# We defer this to the next event loop iteration to ensure
|
||||
# that QScrollArea has updated its scrollbar maximums.
|
||||
if self.active_pane:
|
||||
h_bar = self.active_pane.scroll_area.horizontalScrollBar()
|
||||
v_bar = self.active_pane.scroll_area.verticalScrollBar()
|
||||
h_max = h_bar.maximum()
|
||||
v_max = v_bar.maximum()
|
||||
if h_max > 0 or v_max > 0:
|
||||
x_pct = h_bar.value() / h_max if h_max > 0 else 0
|
||||
y_pct = v_bar.value() / v_max if v_max > 0 else 0
|
||||
pane.set_scroll_relative(x_pct, y_pct)
|
||||
|
||||
for pane in self.panes:
|
||||
if pane != self.active_pane:
|
||||
QTimer.singleShot(
|
||||
0, lambda p=pane, x=x_pct,
|
||||
y=y_pct: p.set_scroll_relative(x, y))
|
||||
|
||||
def update_grid_layout(self):
|
||||
# Clear layout
|
||||
@@ -1693,6 +1775,9 @@ class ImageViewer(QWidget):
|
||||
for i in range(count - current_panes):
|
||||
new_idx = (start_idx + i + 1) % len(img_list)
|
||||
pane = self.add_pane(img_list, new_idx, None, 0) # Metadata will load
|
||||
if self.panes_linked and self.active_pane:
|
||||
pane.controller.zoom_factor = \
|
||||
self.active_pane.controller.zoom_factor
|
||||
pane.load_and_fit_image()
|
||||
else:
|
||||
# Remove panes (keep active if possible, else keep first)
|
||||
@@ -1710,10 +1795,13 @@ class ImageViewer(QWidget):
|
||||
# sizing
|
||||
QTimer.singleShot(
|
||||
0, lambda: self.active_pane.update_view(resize_win=True))
|
||||
self.adjustSize()
|
||||
|
||||
def toggle_link_panes(self):
|
||||
"""Toggles the synchronized zoom/scroll for comparison mode."""
|
||||
self.panes_linked = not self.panes_linked
|
||||
if self.panes_linked and self.active_pane:
|
||||
self._sync_zoom(self.active_pane.controller.zoom_factor)
|
||||
self.update_status_bar()
|
||||
|
||||
def update_highlight(self):
|
||||
@@ -1731,6 +1819,9 @@ class ImageViewer(QWidget):
|
||||
|
||||
def reset_inactivity_timer(self):
|
||||
"""Resets the inactivity timer and restores controls visibility."""
|
||||
if self.active_pane and self.active_pane.canvas:
|
||||
self.active_pane.canvas._clear_zoom_indicator()
|
||||
|
||||
if self.isFullScreen():
|
||||
self.unsetCursor()
|
||||
if self.main_win and self.main_win.show_viewer_status_bar:
|
||||
@@ -2110,7 +2201,11 @@ class ImageViewer(QWidget):
|
||||
available_h -= self.status_bar_container.sizeHint().height()
|
||||
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.update_view(resize_win=should_resize)
|
||||
@@ -2744,8 +2839,11 @@ class ImageViewer(QWidget):
|
||||
self.main_win.face_names_history = updated_history
|
||||
|
||||
# Save changes and add new tag
|
||||
try:
|
||||
self.controller.save_faces()
|
||||
self.controller.toggle_tag(new_full_tag, True)
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, UITexts.ERROR, str(e))
|
||||
if self.canvas:
|
||||
self.canvas.update()
|
||||
|
||||
@@ -3027,8 +3125,10 @@ class ImageViewer(QWidget):
|
||||
QApplication.processEvents()
|
||||
|
||||
history = self.main_win.face_names_history if self.main_win else []
|
||||
suggested = history[0] if history and APP_CONFIG.get(
|
||||
"face_use_last_name", False) else ""
|
||||
full_tag, updated_history, ok = FaceNameDialog.get_name(
|
||||
self, history, main_win=self.main_win)
|
||||
self, history, current_name=suggested, main_win=self.main_win)
|
||||
|
||||
if ok and full_tag:
|
||||
new_face['name'] = full_tag
|
||||
@@ -3043,7 +3143,10 @@ class ImageViewer(QWidget):
|
||||
self.canvas.update()
|
||||
|
||||
if added_count > 0:
|
||||
try:
|
||||
self.controller.save_faces()
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, UITexts.ERROR, str(e))
|
||||
|
||||
def run_pet_detection(self):
|
||||
"""Runs pet detection on the current image."""
|
||||
@@ -3084,8 +3187,11 @@ class ImageViewer(QWidget):
|
||||
QApplication.processEvents()
|
||||
|
||||
history = self.main_win.pet_names_history if self.main_win else []
|
||||
suggested = history[0] if history and APP_CONFIG.get(
|
||||
"pet_use_last_name", False) else ""
|
||||
full_tag, updated_history, ok = FaceNameDialog.get_name(
|
||||
self, history, main_win=self.main_win, region_type="Pet")
|
||||
self, history, current_name=suggested, main_win=self.main_win,
|
||||
region_type="Pet")
|
||||
|
||||
if ok and full_tag:
|
||||
new_pet['name'] = full_tag
|
||||
@@ -3099,7 +3205,10 @@ class ImageViewer(QWidget):
|
||||
self.canvas.update()
|
||||
|
||||
if added_count > 0:
|
||||
try:
|
||||
self.controller.save_faces()
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, UITexts.ERROR, str(e))
|
||||
|
||||
def run_body_detection(self):
|
||||
"""Runs body detection on the current image."""
|
||||
@@ -3142,8 +3251,11 @@ class ImageViewer(QWidget):
|
||||
# For bodies, we typically don't ask for a name immediately unless desired
|
||||
# Or we can treat it like pets/faces and ask. Let's ask.
|
||||
history = self.main_win.body_names_history if self.main_win else []
|
||||
suggested = history[0] if history and APP_CONFIG.get(
|
||||
"body_use_last_name", False) else ""
|
||||
full_tag, updated_history, ok = FaceNameDialog.get_name(
|
||||
self, history, main_win=self.main_win, region_type="Body")
|
||||
self, history, current_name=suggested, main_win=self.main_win,
|
||||
region_type="Body")
|
||||
|
||||
if ok and full_tag:
|
||||
new_body['name'] = full_tag
|
||||
@@ -3157,7 +3269,10 @@ class ImageViewer(QWidget):
|
||||
self.canvas.update()
|
||||
|
||||
if added_count > 0:
|
||||
try:
|
||||
self.controller.save_faces()
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, UITexts.ERROR, str(e))
|
||||
|
||||
def toggle_filmstrip(self):
|
||||
"""Shows or hides the filmstrip widget."""
|
||||
@@ -3219,17 +3334,19 @@ class ImageViewer(QWidget):
|
||||
self.reset_inactivity_timer()
|
||||
if event.modifiers() & Qt.ControlModifier:
|
||||
# Zoom with Ctrl + Wheel
|
||||
focus_pos = event.position().toPoint()
|
||||
if event.angleDelta().y() > 0:
|
||||
self.zoom_manager.zoom(1.1)
|
||||
self.zoom_manager.zoom(1.1, focus_point=focus_pos)
|
||||
else:
|
||||
self.zoom_manager.zoom(0.9)
|
||||
self.zoom_manager.zoom(0.9, focus_point=focus_pos)
|
||||
else:
|
||||
# Navigate next/previous based on configurable speed
|
||||
speed = APP_CONFIG.get("viewer_wheel_speed", VIEWER_WHEEL_SPEED_DEFAULT)
|
||||
# A standard tick is 120. We define a threshold based on speed.
|
||||
# Speed 1 (slowest) requires a full 120 delta.
|
||||
# Speed 10 (fastest) requires 120/10 = 12 delta.
|
||||
threshold = 120 / speed
|
||||
# Still too fast so speed / 2.
|
||||
threshold = 120 / speed * 2
|
||||
|
||||
self._wheel_scroll_accumulator += event.angleDelta().y()
|
||||
|
||||
@@ -3336,17 +3453,18 @@ class ImageViewer(QWidget):
|
||||
service, which is common on Linux desktops.
|
||||
"""
|
||||
try:
|
||||
cmd = [
|
||||
"dbus-send", "--session", "--print-reply",
|
||||
"--dest=org.freedesktop.ScreenSaver",
|
||||
msg = QDBusMessage.createMethodCall(
|
||||
"org.freedesktop.ScreenSaver",
|
||||
"/org/freedesktop/ScreenSaver",
|
||||
"org.freedesktop.ScreenSaver.Inhibit",
|
||||
"string:bagheeraview", # Application name
|
||||
"string:Viewing images" # Reason for inhibition
|
||||
]
|
||||
output = subprocess.check_output(cmd, text=True)
|
||||
# Extract the cookie from the output (e.g., "uint32 12345")
|
||||
self.inhibit_cookie = int(output.split()[-1])
|
||||
"org.freedesktop.ScreenSaver",
|
||||
"Inhibit"
|
||||
)
|
||||
msg.setArguments(["bagheeraview", "Viewing images"])
|
||||
reply = QDBusConnection.sessionBus().call(msg)
|
||||
if reply.type() == QDBusMessage.ReplyMessage:
|
||||
self.inhibit_cookie = reply.arguments()[0]
|
||||
else:
|
||||
self.inhibit_cookie = None
|
||||
except Exception as e:
|
||||
print(f"{UITexts.ERROR} inhibiting power management: {e}")
|
||||
self.inhibit_cookie = None
|
||||
@@ -3360,13 +3478,14 @@ class ImageViewer(QWidget):
|
||||
"""
|
||||
if hasattr(self, 'inhibit_cookie') and self.inhibit_cookie is not None:
|
||||
try:
|
||||
subprocess.Popen([
|
||||
"dbus-send", "--session",
|
||||
"--dest=org.freedesktop.ScreenSaver",
|
||||
msg = QDBusMessage.createMethodCall(
|
||||
"org.freedesktop.ScreenSaver",
|
||||
"/org/freedesktop/ScreenSaver",
|
||||
"org.freedesktop.ScreenSaver.UnInhibit",
|
||||
f"uint32:{self.inhibit_cookie}"
|
||||
])
|
||||
"org.freedesktop.ScreenSaver",
|
||||
"UnInhibit"
|
||||
)
|
||||
msg.setArguments([self.inhibit_cookie])
|
||||
QDBusConnection.sessionBus().call(msg, QDBus.NoBlock)
|
||||
self.inhibit_cookie = None
|
||||
except Exception as e:
|
||||
print(f"{UITexts.ERROR} uninhibiting: {e}")
|
||||
|
||||
@@ -9,6 +9,7 @@ Classes:
|
||||
"""
|
||||
import os
|
||||
import collections
|
||||
import logging
|
||||
from PySide6.QtDBus import QDBusConnection, QDBusMessage, QDBus
|
||||
try:
|
||||
import exiv2
|
||||
@@ -16,13 +17,31 @@ try:
|
||||
except ImportError:
|
||||
exiv2 = None
|
||||
HAVE_EXIV2 = False
|
||||
|
||||
|
||||
from utils import preserve_mtime
|
||||
from constants import RATING_XATTR_NAME, XATTR_NAME
|
||||
from constants import RATING_XATTR_NAME, XATTR_NAME, UITexts
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_app_modified_callback = None
|
||||
|
||||
MetadataResult = collections.namedtuple('MetadataResult', ['tags', 'rating'])
|
||||
EMPTY_METADATA = MetadataResult([], 0)
|
||||
|
||||
|
||||
def set_app_modified_callback(callback):
|
||||
global _app_modified_callback
|
||||
_app_modified_callback = callback
|
||||
|
||||
|
||||
def mark_app_modified(path):
|
||||
"""Triggers the application-modified callback for a path."""
|
||||
if _app_modified_callback:
|
||||
_app_modified_callback(path)
|
||||
|
||||
|
||||
def notify_baloo(path):
|
||||
"""
|
||||
Notifies the Baloo file indexer about a file change using DBus.
|
||||
@@ -106,6 +125,74 @@ class MetadataManager:
|
||||
|
||||
return all_metadata
|
||||
|
||||
@staticmethod
|
||||
def write_metadata(path, metadata_dict):
|
||||
"""
|
||||
Writes EXIF, IPTC, and XMP metadata back to a file.
|
||||
|
||||
Args:
|
||||
path (str): The path to the image file.
|
||||
metadata_dict (dict): A dictionary of metadata keys and values.
|
||||
"""
|
||||
if not HAVE_EXIV2:
|
||||
return
|
||||
|
||||
try:
|
||||
image = exiv2.ImageFactory.open(path)
|
||||
image.readMetadata()
|
||||
|
||||
exif = image.exifData()
|
||||
iptc = image.iptcData()
|
||||
xmp = image.xmpData()
|
||||
|
||||
# Remove keys that are no longer in the dictionary
|
||||
containers = [
|
||||
(exif, exiv2.ExifKey, "Exif."),
|
||||
(iptc, exiv2.IptcKey, "Iptc."),
|
||||
(xmp, exiv2.XmpKey, "Xmp.")
|
||||
]
|
||||
|
||||
for container, key_class, prefix in containers:
|
||||
keys_to_remove = []
|
||||
for datum in container:
|
||||
k = datum.key()
|
||||
# Only consider keys belonging to this specific container
|
||||
if k.startswith(prefix) and k not in metadata_dict:
|
||||
keys_to_remove.append(k)
|
||||
|
||||
for key in keys_to_remove:
|
||||
try:
|
||||
x_key = key_class(key)
|
||||
it = container.findKey(x_key)
|
||||
if it != container.end():
|
||||
container.erase(it)
|
||||
except Exception as e:
|
||||
print(f"Error removing metadata key {key}: {e}")
|
||||
|
||||
# Set or update values from the dictionary
|
||||
for key, value in metadata_dict.items():
|
||||
try:
|
||||
if key.startswith("Exif."):
|
||||
exif[key] = str(value)
|
||||
elif key.startswith("Iptc."):
|
||||
iptc[key] = str(value)
|
||||
elif key.startswith("Xmp."):
|
||||
xmp[key] = str(value)
|
||||
except Exception as e:
|
||||
print(f"Error setting metadata key {key}: {e}")
|
||||
|
||||
image.writeMetadata()
|
||||
notify_baloo(path)
|
||||
mark_app_modified(path)
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
if "kerTooLargeJpegSegment" in error_msg or "38" in error_msg:
|
||||
msg = UITexts.ERROR_JPEG_METADATA_LIMIT.format(os.path.basename(path))
|
||||
logger.error(msg)
|
||||
raise IOError(msg) from e
|
||||
logger.error(f"Error writing metadata for {path}: {e}")
|
||||
raise
|
||||
|
||||
|
||||
class XattrManager:
|
||||
"""A manager class to handle reading and writing extended attributes (xattrs)."""
|
||||
@@ -148,6 +235,7 @@ class XattrManager:
|
||||
return
|
||||
try:
|
||||
with preserve_mtime(file_path):
|
||||
mark_app_modified(file_path)
|
||||
if value:
|
||||
os.setxattr(file_path, attr_name, str(value).encode('utf-8'))
|
||||
else:
|
||||
|
||||
@@ -12,7 +12,7 @@ Classes:
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QLabel, QVBoxLayout, QMessageBox, QMenu, QInputDialog,
|
||||
QDialog, QTableWidget, QTableWidgetItem, QHeaderView, QTabWidget,
|
||||
QFormLayout, QDialogButtonBox, QApplication
|
||||
QFormLayout, QDialogButtonBox, QApplication, QToolBar, QAbstractItemView
|
||||
)
|
||||
from PySide6.QtGui import (
|
||||
QImageReader, QIcon, QColor
|
||||
@@ -76,6 +76,8 @@ class PropertiesDialog(QDialog):
|
||||
self.setWindowTitle(UITexts.PROPERTIES_TITLE)
|
||||
self._initial_tags = initial_tags if initial_tags is not None else []
|
||||
self._initial_rating = initial_rating
|
||||
self.original_xattrs = {}
|
||||
self.original_exif = {}
|
||||
self.loader = None
|
||||
self.resize(400, 500)
|
||||
self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint)
|
||||
@@ -136,21 +138,25 @@ class PropertiesDialog(QDialog):
|
||||
meta_widget = QWidget()
|
||||
meta_layout = QVBoxLayout(meta_widget)
|
||||
|
||||
self.meta_toolbar = QToolBar()
|
||||
self._setup_table_toolbar(
|
||||
self.meta_toolbar, self.on_add_meta, self.on_delete_meta,
|
||||
self.on_delete_all_meta, self.on_save_meta, self.on_cancel_meta)
|
||||
meta_layout.addWidget(self.meta_toolbar)
|
||||
|
||||
self.table = QTableWidget()
|
||||
self.table.setColumnCount(2)
|
||||
self.table.setHorizontalHeaderLabels(UITexts.PROPERTIES_TABLE_HEADER)
|
||||
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Interactive)
|
||||
self.table.horizontalHeader().setSectionResizeMode(1,
|
||||
QHeaderView.ResizeToContents)
|
||||
self.table.setColumnWidth(0, self.width() * 0.4)
|
||||
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
|
||||
self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
|
||||
self.table.verticalHeader().setVisible(False)
|
||||
self.table.setAlternatingRowColors(True)
|
||||
self.table.setEditTriggers(QTableWidget.DoubleClicked |
|
||||
QTableWidget.EditKeyPressed |
|
||||
QTableWidget.SelectedClicked)
|
||||
self.table.setEditTriggers(QAbstractItemView.DoubleClicked |
|
||||
QAbstractItemView.EditKeyPressed |
|
||||
QAbstractItemView.AnyKeyPressed)
|
||||
self.table.setSelectionBehavior(QTableWidget.SelectRows)
|
||||
self.table.setSelectionMode(QAbstractItemView.ExtendedSelection)
|
||||
|
||||
self.table.itemChanged.connect(self.on_item_changed)
|
||||
self.table.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||
self.table.customContextMenuRequested.connect(self.show_context_menu)
|
||||
|
||||
@@ -164,6 +170,12 @@ class PropertiesDialog(QDialog):
|
||||
exif_widget = QWidget()
|
||||
exif_layout = QVBoxLayout(exif_widget)
|
||||
|
||||
self.exif_toolbar = QToolBar()
|
||||
self._setup_table_toolbar(
|
||||
self.exif_toolbar, self.on_add_exif, self.on_delete_exif,
|
||||
self.on_delete_all_exif, self.on_save_exif, self.on_cancel_exif)
|
||||
exif_layout.addWidget(self.exif_toolbar)
|
||||
|
||||
self.exif_table = QTableWidget()
|
||||
# This table will display EXIF/XMP/IPTC data.
|
||||
# Reading this data involves opening the file with exiv2, which is a disk read.
|
||||
@@ -174,14 +186,15 @@ class PropertiesDialog(QDialog):
|
||||
# without a significant architectural change (e.g., a dedicated metadata DB).
|
||||
self.exif_table.setColumnCount(2)
|
||||
self.exif_table.setHorizontalHeaderLabels(UITexts.PROPERTIES_TABLE_HEADER)
|
||||
self.exif_table.horizontalHeader().setSectionResizeMode(
|
||||
0, QHeaderView.ResizeToContents)
|
||||
self.exif_table.horizontalHeader().setSectionResizeMode(
|
||||
1, QHeaderView.ResizeToContents)
|
||||
self.exif_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
|
||||
self.exif_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
|
||||
self.exif_table.verticalHeader().setVisible(False)
|
||||
self.exif_table.setAlternatingRowColors(True)
|
||||
self.exif_table.setEditTriggers(QTableWidget.NoEditTriggers)
|
||||
self.exif_table.setEditTriggers(QAbstractItemView.DoubleClicked |
|
||||
QAbstractItemView.EditKeyPressed |
|
||||
QAbstractItemView.AnyKeyPressed)
|
||||
self.exif_table.setSelectionBehavior(QTableWidget.SelectRows)
|
||||
self.exif_table.setSelectionMode(QAbstractItemView.ExtendedSelection)
|
||||
self.exif_table.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||
# This is a disk read.
|
||||
self.exif_table.customContextMenuRequested.connect(self.show_exif_context_menu)
|
||||
@@ -204,6 +217,100 @@ class PropertiesDialog(QDialog):
|
||||
# Start background loading
|
||||
self.reload_metadata()
|
||||
|
||||
def _setup_table_toolbar(self, toolbar, add_slot, del_slot, del_all_slot, save_slot,
|
||||
cancel_slot):
|
||||
"""Helper to populate toolbars with buttons."""
|
||||
toolbar.addAction(QIcon.fromTheme("list-add"), UITexts.CREATE, add_slot)
|
||||
toolbar.addAction(QIcon.fromTheme("list-remove"), UITexts.DELETE, del_slot)
|
||||
toolbar.addAction(
|
||||
QIcon.fromTheme("edit-clear-all"), UITexts.PROPERTIES_DELETE_ALL,
|
||||
del_all_slot)
|
||||
toolbar.addSeparator()
|
||||
toolbar.addAction(QIcon.fromTheme("document-save"), UITexts.SAVE, save_slot)
|
||||
toolbar.addAction(QIcon.fromTheme("edit-undo"), UITexts.CANCEL, cancel_slot)
|
||||
|
||||
def on_add_meta(self):
|
||||
key, ok = QInputDialog.getText(
|
||||
self, UITexts.PROPERTIES_ADD_ATTR, UITexts.PROPERTIES_ADD_ATTR_NAME)
|
||||
if ok and key:
|
||||
row = self.table.rowCount()
|
||||
self.table.insertRow(row)
|
||||
self.table.setItem(row, 0, QTableWidgetItem(key))
|
||||
v_item = QTableWidgetItem("")
|
||||
self.table.setItem(row, 1, v_item)
|
||||
self.table.setCurrentItem(v_item)
|
||||
self.table.editItem(v_item)
|
||||
|
||||
def on_delete_meta(self):
|
||||
rows = sorted(set(index.row() for index in self.table.selectedIndexes()),
|
||||
reverse=True)
|
||||
for row in rows:
|
||||
self.table.removeRow(row)
|
||||
|
||||
def on_delete_all_meta(self):
|
||||
self.table.setRowCount(0)
|
||||
|
||||
def on_save_meta(self):
|
||||
new_attrs = {}
|
||||
for r in range(self.table.rowCount()):
|
||||
k_item, v_item = self.table.item(r, 0), self.table.item(r, 1)
|
||||
if k_item and v_item:
|
||||
new_attrs[k_item.text()] = v_item.text()
|
||||
try:
|
||||
for k in self.original_xattrs:
|
||||
if k not in new_attrs:
|
||||
XattrManager.set_attribute(self.path, k, None)
|
||||
for k, v in new_attrs.items():
|
||||
XattrManager.set_attribute(self.path, k, v)
|
||||
self.reload_metadata()
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, UITexts.ERROR, str(e))
|
||||
|
||||
def on_cancel_meta(self):
|
||||
self.update_metadata_table(self.original_xattrs)
|
||||
|
||||
def on_add_exif(self):
|
||||
key, ok = QInputDialog.getText(
|
||||
self, UITexts.PROPERTIES_ADD_ATTR, UITexts.PROPERTIES_ADD_ATTR_NAME)
|
||||
if ok and key:
|
||||
row = self.exif_table.rowCount()
|
||||
self.exif_table.insertRow(row)
|
||||
self.exif_table.setItem(row, 0, QTableWidgetItem(key))
|
||||
v_item = QTableWidgetItem("")
|
||||
self.exif_table.setItem(row, 1, v_item)
|
||||
self.exif_table.setCurrentItem(v_item)
|
||||
self.exif_table.editItem(v_item)
|
||||
|
||||
def on_delete_exif(self):
|
||||
rows = sorted(
|
||||
set(index.row() for index in self.exif_table.selectedIndexes()),
|
||||
reverse=True)
|
||||
for row in rows:
|
||||
self.exif_table.removeRow(row)
|
||||
|
||||
def on_delete_all_exif(self):
|
||||
self.exif_table.setRowCount(0)
|
||||
|
||||
def on_save_exif(self):
|
||||
new_exif = {}
|
||||
for r in range(self.exif_table.rowCount()):
|
||||
k_item, v_item = self.exif_table.item(r, 0), self.exif_table.item(r, 1)
|
||||
if k_item and v_item:
|
||||
new_exif[k_item.text()] = v_item.text()
|
||||
try:
|
||||
MetadataManager.write_metadata(self.path, new_exif)
|
||||
self.reload_metadata()
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, UITexts.ERROR, str(e))
|
||||
|
||||
def on_cancel_exif(self):
|
||||
self.update_exif_table(self.original_exif)
|
||||
|
||||
def done(self, r):
|
||||
if self.loader and self.loader.isRunning():
|
||||
self.loader.stop()
|
||||
super().done(r)
|
||||
|
||||
def closeEvent(self, event):
|
||||
if self.loader and self.loader.isRunning():
|
||||
self.loader.stop()
|
||||
@@ -227,6 +334,7 @@ class PropertiesDialog(QDialog):
|
||||
# Combine preloaded and newly read xattrs
|
||||
all_xattrs = preloaded_xattrs.copy()
|
||||
if not initial_only and disk_xattrs:
|
||||
self.original_xattrs = disk_xattrs.copy()
|
||||
# Disk data takes precedence or adds to it
|
||||
all_xattrs.update(disk_xattrs)
|
||||
|
||||
@@ -237,9 +345,9 @@ class PropertiesDialog(QDialog):
|
||||
for key, val in all_xattrs.items():
|
||||
# QImageReader.textKeys() is not used here as it's not xattr.
|
||||
k_item = QTableWidgetItem(key)
|
||||
k_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
|
||||
k_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsEditable)
|
||||
v_item = QTableWidgetItem(val)
|
||||
v_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
|
||||
v_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsEditable)
|
||||
self.table.setItem(row, 0, k_item)
|
||||
self.table.setItem(row, 1, v_item)
|
||||
row += 1
|
||||
@@ -298,6 +406,7 @@ class PropertiesDialog(QDialog):
|
||||
self.exif_table.blockSignals(False)
|
||||
return
|
||||
|
||||
self.original_exif = exif_data.copy()
|
||||
self.exif_table.setRowCount(len(exif_data))
|
||||
error_color = QColor("red")
|
||||
error_text_lower = UITexts.ERROR.lower()
|
||||
@@ -305,9 +414,9 @@ class PropertiesDialog(QDialog):
|
||||
|
||||
for row, (key, value) in enumerate(sorted(exif_data.items())):
|
||||
k_item = QTableWidgetItem(str(key))
|
||||
k_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
|
||||
k_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsEditable)
|
||||
v_item = QTableWidgetItem(str(value))
|
||||
v_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
|
||||
v_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsEditable)
|
||||
|
||||
key_str_lower = str(key).lower()
|
||||
val_str_lower = str(value).lower()
|
||||
@@ -323,25 +432,6 @@ class PropertiesDialog(QDialog):
|
||||
|
||||
self.exif_table.blockSignals(False)
|
||||
|
||||
def on_item_changed(self, item):
|
||||
"""
|
||||
Slot that triggers when an item in the metadata table is changed.
|
||||
|
||||
Args:
|
||||
item (QTableWidgetItem): The item that was changed.
|
||||
"""
|
||||
if item.column() == 1:
|
||||
key = self.table.item(item.row(), 0).text()
|
||||
val = item.text()
|
||||
# Treat empty or whitespace-only values as removal to match previous
|
||||
# behavior
|
||||
val_to_set = val if val.strip() else None
|
||||
try:
|
||||
XattrManager.set_attribute(self.path, key, val_to_set)
|
||||
except Exception as e:
|
||||
QMessageBox.warning(self, UITexts.ERROR,
|
||||
UITexts.PROPERTIES_ERROR_SET_ATTR.format(e))
|
||||
|
||||
def show_context_menu(self, pos):
|
||||
"""
|
||||
Displays a context menu in the metadata table.
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "bagheeraview"
|
||||
version = "0.9.15"
|
||||
version = "0.9.26"
|
||||
authors = [
|
||||
{ name = "Ignacio Serantes" }
|
||||
]
|
||||
@@ -25,6 +25,7 @@ dependencies = [
|
||||
"exiv2",
|
||||
"psutil",
|
||||
"watchdog",
|
||||
"imagehash",
|
||||
"mediapipe",
|
||||
"face_recognition",
|
||||
"face_recognition_models",
|
||||
@@ -55,8 +56,11 @@ py-modules = [
|
||||
"imagecontroller",
|
||||
"metadatamanager",
|
||||
"propertiesdialog",
|
||||
"thumbnailwidget",
|
||||
#"thumbnailwidget",
|
||||
"duplicatecache",
|
||||
"duplicatedialog",
|
||||
"widgets",
|
||||
"filesystemwatcher",
|
||||
"xmpmanager",
|
||||
"utils"
|
||||
]
|
||||
|
||||
@@ -3,6 +3,7 @@ lmdb
|
||||
exiv2
|
||||
psutil
|
||||
watchdog
|
||||
imagehash
|
||||
mediapipe
|
||||
face_recognition
|
||||
face_recognition_models
|
||||
|
||||
406
settings.py
406
settings.py
@@ -14,12 +14,13 @@ import os
|
||||
import shutil
|
||||
import urllib.request
|
||||
|
||||
from PySide6.QtCore import Qt, QThread, Signal
|
||||
from PySide6.QtCore import Qt, QThread, Signal, QTimer
|
||||
from PySide6.QtGui import QColor, QIcon, QFont
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox, QColorDialog, QComboBox, QDialog, QDialogButtonBox, QHBoxLayout,
|
||||
QLabel, QLineEdit, QMessageBox, QProgressDialog, QPushButton, QSpinBox,
|
||||
QTabWidget, QVBoxLayout, QWidget
|
||||
QTabWidget, QVBoxLayout, QWidget, QSlider, QFileDialog, QListWidget,
|
||||
QListWidgetItem, QProgressBar
|
||||
)
|
||||
from constants import (
|
||||
APP_CONFIG, AVAILABLE_FACE_ENGINES, DEFAULT_FACE_BOX_COLOR,
|
||||
@@ -27,7 +28,7 @@ from constants import (
|
||||
FACES_MENU_MAX_ITEMS_DEFAULT, MEDIAPIPE_FACE_MODEL_PATH, MEDIAPIPE_FACE_MODEL_URL,
|
||||
AVAILABLE_PET_ENGINES, DEFAULT_BODY_BOX_COLOR,
|
||||
MEDIAPIPE_OBJECT_MODEL_PATH, MEDIAPIPE_OBJECT_MODEL_URL,
|
||||
HAVE_BAGHEERASEARCH_LIB,
|
||||
HAVE_BAGHEERASEARCH_LIB, IMAGE_EXTENSIONS,
|
||||
SCANNER_SETTINGS_DEFAULTS, SEARCH_CMD, TAGS_MENU_MAX_ITEMS_DEFAULT,
|
||||
THUMBNAILS_FILENAME_LINES_DEFAULT,
|
||||
THUMBNAILS_REFRESH_INTERVAL_DEFAULT, THUMBNAILS_BG_COLOR_DEFAULT,
|
||||
@@ -36,10 +37,74 @@ from constants import (
|
||||
THUMBNAILS_TOOLTIP_FG_COLOR_DEFAULT, THUMBNAILS_FILENAME_FONT_SIZE_DEFAULT,
|
||||
THUMBNAILS_TAGS_LINES_DEFAULT, THUMBNAILS_TAGS_FONT_SIZE_DEFAULT,
|
||||
VIEWER_AUTO_RESIZE_WINDOW_DEFAULT, VIEWER_WHEEL_SPEED_DEFAULT,
|
||||
UITexts, save_app_config
|
||||
UITexts, save_app_config, HAVE_DUPLICATE_RESNET_LIBS, HAVE_IMAGEHASH
|
||||
)
|
||||
|
||||
|
||||
class DuplicateFileCounter(QThread):
|
||||
"""Thread to count images in whitelist/blacklist without freezing UI."""
|
||||
count_updated = Signal(int)
|
||||
finished = Signal(int)
|
||||
|
||||
def __init__(self, whitelist, blacklist, extensions):
|
||||
super().__init__()
|
||||
self.whitelist = whitelist
|
||||
self.blacklist = blacklist
|
||||
self.extensions = extensions
|
||||
self._abort = False
|
||||
|
||||
def stop(self):
|
||||
self._abort = True
|
||||
self.wait()
|
||||
|
||||
def run(self):
|
||||
count = 0
|
||||
for root_path in self.whitelist:
|
||||
if self._abort:
|
||||
break
|
||||
if not os.path.exists(root_path):
|
||||
continue
|
||||
for root, dirs, files in os.walk(root_path):
|
||||
if self._abort:
|
||||
break
|
||||
abs_root = os.path.abspath(root)
|
||||
dirs[:] = [d for d in dirs
|
||||
if os.path.join(abs_root, d) not in self.blacklist]
|
||||
if abs_root in self.blacklist:
|
||||
continue
|
||||
for f in files:
|
||||
if self._abort:
|
||||
break
|
||||
if os.path.splitext(f)[1].lower() in self.extensions:
|
||||
if os.path.join(abs_root, f) not in self.blacklist:
|
||||
count += 1
|
||||
self.count_updated.emit(count)
|
||||
self.finished.emit(count)
|
||||
|
||||
|
||||
class PathListWidget(QListWidget):
|
||||
"""A QListWidget that accepts folder drops from external file explorers."""
|
||||
def __init__(self, add_callback, parent=None):
|
||||
super().__init__(parent)
|
||||
self.add_callback = add_callback
|
||||
self.setAcceptDrops(True)
|
||||
|
||||
def dragEnterEvent(self, event):
|
||||
if event.mimeData().hasUrls():
|
||||
event.acceptProposedAction()
|
||||
|
||||
def dragMoveEvent(self, event):
|
||||
if event.mimeData().hasUrls():
|
||||
event.acceptProposedAction()
|
||||
|
||||
def dropEvent(self, event):
|
||||
for url in event.mimeData().urls():
|
||||
path = url.toLocalFile()
|
||||
if path and os.path.isdir(path):
|
||||
self.add_callback(self, path)
|
||||
event.acceptProposedAction()
|
||||
|
||||
|
||||
class ModelDownloader(QThread):
|
||||
"""A thread to download the MediaPipe model file without freezing the UI."""
|
||||
download_complete = Signal(bool, str) # success (bool), message (str)
|
||||
@@ -93,6 +158,7 @@ class SettingsDialog(QDialog):
|
||||
self.current_thumbs_tooltip_bg_color = THUMBNAILS_TOOLTIP_BG_COLOR_DEFAULT
|
||||
self.current_thumbs_tooltip_fg_color = THUMBNAILS_TOOLTIP_FG_COLOR_DEFAULT
|
||||
self.downloader_thread = None
|
||||
self.counter_thread = None
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
@@ -112,6 +178,9 @@ class SettingsDialog(QDialog):
|
||||
scanner_tab = QWidget()
|
||||
scanner_layout = QVBoxLayout(scanner_tab)
|
||||
|
||||
duplicates_tab = QWidget()
|
||||
duplicates_layout = QVBoxLayout(duplicates_tab)
|
||||
|
||||
# --- Thumbnails Tab ---
|
||||
|
||||
mru_tags_layout = QHBoxLayout()
|
||||
@@ -344,6 +413,142 @@ class SettingsDialog(QDialog):
|
||||
scanner_layout.addLayout(scan_full_on_start_layout)
|
||||
scanner_layout.addStretch()
|
||||
|
||||
# --- Duplicates Tab ---
|
||||
if not HAVE_IMAGEHASH:
|
||||
warning_lbl = QLabel(UITexts.SETTINGS_DUPLICATE_MISSING_LIBS)
|
||||
warning_lbl.setStyleSheet("color: #e74c3c; font-weight: bold;")
|
||||
warning_lbl.setWordWrap(True)
|
||||
duplicates_layout.addWidget(warning_lbl)
|
||||
|
||||
method_layout = QHBoxLayout()
|
||||
method_label = QLabel(UITexts.SETTINGS_DUPLICATE_METHOD_LABEL)
|
||||
self.duplicate_method_combo = QComboBox()
|
||||
self.duplicate_method_combo.addItem(
|
||||
UITexts.METHOD_HISTOGRAM_HASHING, "histogram_hashing")
|
||||
self.duplicate_method_combo.addItem(UITexts.METHOD_RESNET, "resnet")
|
||||
|
||||
self.duplicate_method_combo.setEnabled(HAVE_IMAGEHASH)
|
||||
|
||||
if not HAVE_DUPLICATE_RESNET_LIBS:
|
||||
resnet_idx = self.duplicate_method_combo.findData("resnet")
|
||||
if resnet_idx != -1:
|
||||
item = self.duplicate_method_combo.model().item(resnet_idx)
|
||||
if item:
|
||||
item.setEnabled(False)
|
||||
|
||||
method_layout.addWidget(method_label)
|
||||
method_layout.addWidget(self.duplicate_method_combo)
|
||||
method_label.setToolTip(UITexts.SETTINGS_DUPLICATE_METHOD_TOOLTIP)
|
||||
self.duplicate_method_combo.setToolTip(
|
||||
UITexts.SETTINGS_DUPLICATE_METHOD_TOOLTIP)
|
||||
duplicates_layout.addLayout(method_layout)
|
||||
|
||||
threshold_layout = QHBoxLayout()
|
||||
threshold_label = QLabel(UITexts.SETTINGS_DUPLICATE_THRESHOLD_LABEL)
|
||||
self.duplicate_threshold_slider = QSlider(Qt.Horizontal)
|
||||
self.duplicate_threshold_slider.setRange(50, 100)
|
||||
self.duplicate_threshold_value_label = QLabel("0%")
|
||||
|
||||
self.duplicate_threshold_slider.setEnabled(HAVE_IMAGEHASH)
|
||||
self.duplicate_threshold_value_label.setFixedWidth(40)
|
||||
|
||||
threshold_layout.addWidget(threshold_label)
|
||||
threshold_layout.addWidget(self.duplicate_threshold_slider)
|
||||
threshold_layout.addWidget(self.duplicate_threshold_value_label)
|
||||
|
||||
threshold_label.setToolTip(UITexts.SETTINGS_DUPLICATE_THRESHOLD_TOOLTIP)
|
||||
self.duplicate_threshold_slider.setToolTip(
|
||||
UITexts.SETTINGS_DUPLICATE_THRESHOLD_TOOLTIP)
|
||||
|
||||
self.duplicate_threshold_slider.valueChanged.connect(
|
||||
lambda v: self.duplicate_threshold_value_label.setText(f"{v}%"))
|
||||
|
||||
def create_path_list_ui(label_text, tooltip):
|
||||
container = QWidget()
|
||||
v_layout = QVBoxLayout(container)
|
||||
v_layout.setContentsMargins(0, 0, 0, 0)
|
||||
v_layout.addWidget(QLabel(label_text))
|
||||
h_layout = QHBoxLayout()
|
||||
lst = PathListWidget(self._add_path_to_list)
|
||||
lst.setToolTip(tooltip)
|
||||
lst.setMinimumHeight(100)
|
||||
h_layout.addWidget(lst)
|
||||
btn_vbox = QVBoxLayout()
|
||||
add_btn = QPushButton()
|
||||
add_btn.setIcon(QIcon.fromTheme("list-add"))
|
||||
add_btn.setFixedWidth(30)
|
||||
rem_btn = QPushButton()
|
||||
rem_btn.setIcon(QIcon.fromTheme("list-remove"))
|
||||
rem_btn.setFixedWidth(30)
|
||||
btn_vbox.addWidget(add_btn)
|
||||
btn_vbox.addWidget(rem_btn)
|
||||
btn_vbox.addStretch()
|
||||
h_layout.addLayout(btn_vbox)
|
||||
v_layout.addLayout(h_layout)
|
||||
return container, lst, add_btn, rem_btn
|
||||
|
||||
# Whitelist
|
||||
wl_cont, self.duplicate_whitelist_list, wl_add, wl_rem = create_path_list_ui(
|
||||
UITexts.SETTINGS_DUPLICATE_WHITELIST_LABEL,
|
||||
UITexts.SETTINGS_DUPLICATE_WHITELIST_TOOLTIP)
|
||||
wl_add.clicked.connect(self.add_whitelist_path)
|
||||
wl_rem.clicked.connect(self.remove_whitelist_path)
|
||||
duplicates_layout.addWidget(wl_cont)
|
||||
|
||||
# Blacklist
|
||||
bl_cont, self.duplicate_blacklist_list, bl_add, bl_rem = create_path_list_ui(
|
||||
UITexts.SETTINGS_DUPLICATE_BLACKLIST_LABEL,
|
||||
UITexts.SETTINGS_DUPLICATE_BLACKLIST_TOOLTIP)
|
||||
bl_add.clicked.connect(self.add_blacklist_path)
|
||||
bl_rem.clicked.connect(self.remove_blacklist_path)
|
||||
duplicates_layout.addWidget(bl_cont)
|
||||
|
||||
# Image Count Layout
|
||||
count_layout = QHBoxLayout()
|
||||
self.duplicate_scan_count_label = QLabel()
|
||||
self.duplicate_scan_count_label.setStyleSheet(
|
||||
"color: #3498db; font-weight: bold;")
|
||||
self.duplicate_scan_progress = QProgressBar()
|
||||
self.duplicate_scan_progress.setRange(0, 0) # Indeterminate mode
|
||||
self.duplicate_scan_progress.setFixedHeight(10)
|
||||
self.duplicate_scan_progress.setFixedWidth(100)
|
||||
self.duplicate_scan_progress.hide()
|
||||
count_layout.addWidget(self.duplicate_scan_count_label)
|
||||
count_layout.addWidget(self.duplicate_scan_progress)
|
||||
count_layout.addStretch()
|
||||
duplicates_layout.addLayout(count_layout)
|
||||
|
||||
# Timer for debounced count update
|
||||
self.count_update_timer = QTimer(self)
|
||||
self.count_update_timer.setSingleShot(True)
|
||||
self.count_update_timer.setInterval(500)
|
||||
self.count_update_timer.timeout.connect(self.update_duplicate_scan_count)
|
||||
|
||||
self.duplicate_whitelist_list.model().rowsInserted.connect(
|
||||
lambda *args: self.count_update_timer.start())
|
||||
self.duplicate_whitelist_list.model().rowsRemoved.connect(
|
||||
lambda *args: self.count_update_timer.start())
|
||||
self.duplicate_blacklist_list.model().rowsInserted.connect(
|
||||
lambda *args: self.count_update_timer.start())
|
||||
self.duplicate_blacklist_list.model().rowsRemoved.connect(
|
||||
lambda *args: self.count_update_timer.start())
|
||||
|
||||
self.default_delete_to_trash_checkbox = QCheckBox(
|
||||
UITexts.SETTINGS_DEFAULT_DELETE_TO_TRASH_LABEL)
|
||||
self.default_delete_to_trash_checkbox.setToolTip(
|
||||
UITexts.SETTINGS_DEFAULT_DELETE_TO_TRASH_TOOLTIP)
|
||||
duplicates_layout.addWidget(self.default_delete_to_trash_checkbox)
|
||||
|
||||
duplicates_layout.addLayout(threshold_layout)
|
||||
|
||||
self.duplicate_confirm_delete_checkbox = QCheckBox(
|
||||
UITexts.SETTINGS_DUPLICATE_CONFIRM_DELETE_LABEL)
|
||||
self.duplicate_confirm_delete_checkbox.setToolTip(
|
||||
UITexts.SETTINGS_DUPLICATE_CONFIRM_DELETE_TOOLTIP)
|
||||
duplicates_layout.addWidget(self.duplicate_confirm_delete_checkbox)
|
||||
|
||||
duplicates_layout.addStretch()
|
||||
|
||||
# --- Faces & People Tab ---
|
||||
faces_tab = QWidget()
|
||||
faces_layout = QVBoxLayout(faces_tab)
|
||||
@@ -409,6 +614,10 @@ class SettingsDialog(QDialog):
|
||||
self.face_history_spin.setToolTip(UITexts.SETTINGS_FACE_HISTORY_TOOLTIP)
|
||||
faces_layout.addLayout(face_history_layout)
|
||||
|
||||
self.face_use_last_name_check = QCheckBox(UITexts.SETTINGS_USE_LAST_NAME_LABEL)
|
||||
self.face_use_last_name_check.setToolTip(UITexts.SETTINGS_USE_LAST_NAME_TOOLTIP)
|
||||
faces_layout.addWidget(self.face_use_last_name_check)
|
||||
|
||||
# --- Pets Section ---
|
||||
faces_layout.addSpacing(10)
|
||||
pets_header = QLabel(UITexts.TYPE_PET)
|
||||
@@ -465,6 +674,10 @@ class SettingsDialog(QDialog):
|
||||
self.pet_history_spin.setToolTip(UITexts.SETTINGS_PET_HISTORY_TOOLTIP)
|
||||
faces_layout.addLayout(pet_history_layout)
|
||||
|
||||
self.pet_use_last_name_check = QCheckBox(UITexts.SETTINGS_USE_LAST_NAME_LABEL)
|
||||
self.pet_use_last_name_check.setToolTip(UITexts.SETTINGS_USE_LAST_NAME_TOOLTIP)
|
||||
faces_layout.addWidget(self.pet_use_last_name_check)
|
||||
|
||||
# --- Body Section ---
|
||||
faces_layout.addSpacing(10)
|
||||
body_header = QLabel(UITexts.TYPE_BODY)
|
||||
@@ -512,6 +725,10 @@ class SettingsDialog(QDialog):
|
||||
self.body_history_spin.setToolTip(UITexts.SETTINGS_BODY_HISTORY_TOOLTIP)
|
||||
faces_layout.addLayout(body_history_layout)
|
||||
|
||||
self.body_use_last_name_check = QCheckBox(UITexts.SETTINGS_USE_LAST_NAME_LABEL)
|
||||
self.body_use_last_name_check.setToolTip(UITexts.SETTINGS_USE_LAST_NAME_TOOLTIP)
|
||||
faces_layout.addWidget(self.body_use_last_name_check)
|
||||
|
||||
# --- Object Section ---
|
||||
faces_layout.addSpacing(10)
|
||||
object_header = QLabel(UITexts.TYPE_OBJECT)
|
||||
@@ -558,6 +775,12 @@ class SettingsDialog(QDialog):
|
||||
self.object_history_spin.setToolTip(UITexts.SETTINGS_OBJECT_HISTORY_TOOLTIP)
|
||||
faces_layout.addLayout(object_history_layout)
|
||||
|
||||
self.object_use_last_name_check = QCheckBox(
|
||||
UITexts.SETTINGS_USE_LAST_NAME_LABEL)
|
||||
self.object_use_last_name_check.setToolTip(
|
||||
UITexts.SETTINGS_USE_LAST_NAME_TOOLTIP)
|
||||
faces_layout.addWidget(self.object_use_last_name_check)
|
||||
|
||||
# --- Landmark Section ---
|
||||
faces_layout.addSpacing(10)
|
||||
landmark_header = QLabel(UITexts.TYPE_LANDMARK)
|
||||
@@ -605,6 +828,12 @@ class SettingsDialog(QDialog):
|
||||
faces_layout.addLayout(landmark_history_layout)
|
||||
faces_layout.addStretch()
|
||||
|
||||
self.landmark_use_last_name_check = QCheckBox(
|
||||
UITexts.SETTINGS_USE_LAST_NAME_LABEL)
|
||||
self.landmark_use_last_name_check.setToolTip(
|
||||
UITexts.SETTINGS_USE_LAST_NAME_TOOLTIP)
|
||||
faces_layout.addWidget(self.landmark_use_last_name_check)
|
||||
|
||||
# --- Viewer Tab ---
|
||||
viewer_wheel_layout = QHBoxLayout()
|
||||
viewer_wheel_label = QLabel(UITexts.SETTINGS_VIEWER_WHEEL_SPEED_LABEL)
|
||||
@@ -645,6 +874,7 @@ class SettingsDialog(QDialog):
|
||||
tabs.addTab(viewer_tab, UITexts.SETTINGS_GROUP_VIEWER)
|
||||
tabs.addTab(faces_tab, UITexts.SETTINGS_GROUP_AREAS)
|
||||
tabs.addTab(scanner_tab, UITexts.SETTINGS_GROUP_SCANNER)
|
||||
tabs.addTab(duplicates_tab, UITexts.SETTINGS_GROUP_DUPLICATES)
|
||||
|
||||
# --- Button Box ---
|
||||
button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
@@ -705,6 +935,12 @@ class SettingsDialog(QDialog):
|
||||
landmark_history_count = APP_CONFIG.get(
|
||||
"landmark_menu_max_items", FACES_MENU_MAX_ITEMS_DEFAULT)
|
||||
|
||||
face_use_last_name = APP_CONFIG.get("face_use_last_name", False)
|
||||
pet_use_last_name = APP_CONFIG.get("pet_use_last_name", False)
|
||||
body_use_last_name = APP_CONFIG.get("body_use_last_name", False)
|
||||
object_use_last_name = APP_CONFIG.get("object_use_last_name", False)
|
||||
landmark_use_last_name = APP_CONFIG.get("landmark_use_last_name", False)
|
||||
|
||||
thumbs_refresh_interval = APP_CONFIG.get(
|
||||
"thumbnails_refresh_interval", THUMBNAILS_REFRESH_INTERVAL_DEFAULT)
|
||||
thumbs_bg_color = APP_CONFIG.get(
|
||||
@@ -737,6 +973,31 @@ class SettingsDialog(QDialog):
|
||||
show_tags = APP_CONFIG.get("thumbnails_show_tags", True)
|
||||
filmstrip_position = APP_CONFIG.get("filmstrip_position", "bottom")
|
||||
|
||||
duplicate_method = APP_CONFIG.get("duplicate_method", "histogram_hashing")
|
||||
method_idx = self.duplicate_method_combo.findData(duplicate_method)
|
||||
if method_idx != -1:
|
||||
self.duplicate_method_combo.setCurrentIndex(method_idx)
|
||||
|
||||
duplicate_threshold = APP_CONFIG.get(
|
||||
"duplicate_threshold", SCANNER_SETTINGS_DEFAULTS["duplicate_threshold"])
|
||||
self.duplicate_threshold_slider.setValue(duplicate_threshold)
|
||||
self.duplicate_threshold_value_label.setText(f"{duplicate_threshold}%")
|
||||
|
||||
default_delete_to_trash = APP_CONFIG.get("default_delete_to_trash", True)
|
||||
self.default_delete_to_trash_checkbox.setChecked(default_delete_to_trash)
|
||||
|
||||
duplicate_confirm_delete = APP_CONFIG.get("duplicate_confirm_delete", True)
|
||||
self.duplicate_confirm_delete_checkbox.setChecked(duplicate_confirm_delete)
|
||||
|
||||
duplicate_whitelist = APP_CONFIG.get(
|
||||
"duplicate_whitelist", SCANNER_SETTINGS_DEFAULTS["duplicate_whitelist"])
|
||||
for p in [x.strip() for x in duplicate_whitelist.split(",") if x.strip()]:
|
||||
self._add_path_to_list(self.duplicate_whitelist_list, p)
|
||||
duplicate_blacklist = APP_CONFIG.get(
|
||||
"duplicate_blacklist", SCANNER_SETTINGS_DEFAULTS["duplicate_blacklist"])
|
||||
for p in [x.strip() for x in duplicate_blacklist.split(",") if x.strip()]:
|
||||
self._add_path_to_list(self.duplicate_blacklist_list, p)
|
||||
|
||||
self.scan_max_level_spin.setValue(scan_max_level)
|
||||
self.scan_batch_size_spin.setValue(scan_batch_size)
|
||||
self.threads_spin.setValue(scan_threads)
|
||||
@@ -795,6 +1056,12 @@ class SettingsDialog(QDialog):
|
||||
self.object_history_spin.setValue(object_history_count)
|
||||
self.landmark_history_spin.setValue(landmark_history_count)
|
||||
|
||||
self.face_use_last_name_check.setChecked(face_use_last_name)
|
||||
self.pet_use_last_name_check.setChecked(pet_use_last_name)
|
||||
self.body_use_last_name_check.setChecked(body_use_last_name)
|
||||
self.object_use_last_name_check.setChecked(object_use_last_name)
|
||||
self.landmark_use_last_name_check.setChecked(landmark_use_last_name)
|
||||
|
||||
self.thumbs_refresh_spin.setValue(thumbs_refresh_interval)
|
||||
self.set_thumbs_bg_button_color(thumbs_bg_color)
|
||||
self.set_thumbs_filename_button_color(thumbs_filename_color)
|
||||
@@ -821,6 +1088,7 @@ class SettingsDialog(QDialog):
|
||||
self.filmstrip_pos_combo.setCurrentText(
|
||||
pos_map.get(filmstrip_position, UITexts.FILMSTRIP_BOTTOM))
|
||||
self.update_mediapipe_status()
|
||||
self.update_duplicate_scan_count()
|
||||
|
||||
def set_button_color(self, color_str):
|
||||
"""Sets the background color of the button and stores the value."""
|
||||
@@ -979,7 +1247,7 @@ class SettingsDialog(QDialog):
|
||||
elif self.download_model_btn:
|
||||
self.download_model_btn.hide()
|
||||
|
||||
# --- Mascotas (Pets) ---
|
||||
# --- Pets ---
|
||||
if not AVAILABLE_PET_ENGINES:
|
||||
self.pet_engine_combo.setEnabled(False)
|
||||
self.pet_tags_edit.setEnabled(False)
|
||||
@@ -1050,6 +1318,13 @@ class SettingsDialog(QDialog):
|
||||
APP_CONFIG["landmark_menu_max_items"] = self.landmark_history_spin.value()
|
||||
|
||||
APP_CONFIG["thumbnails_refresh_interval"] = self.thumbs_refresh_spin.value()
|
||||
APP_CONFIG["face_use_last_name"] = self.face_use_last_name_check.isChecked()
|
||||
APP_CONFIG["pet_use_last_name"] = self.pet_use_last_name_check.isChecked()
|
||||
APP_CONFIG["body_use_last_name"] = self.body_use_last_name_check.isChecked()
|
||||
APP_CONFIG["object_use_last_name"] = self.object_use_last_name_check.isChecked()
|
||||
APP_CONFIG["landmark_use_last_name"] = \
|
||||
self.landmark_use_last_name_check.isChecked()
|
||||
|
||||
APP_CONFIG["thumbnails_bg_color"] = self.current_thumbs_bg_color
|
||||
APP_CONFIG["thumbnails_filename_color"] = self.current_thumbs_filename_color
|
||||
APP_CONFIG["thumbnails_tags_color"] = self.current_thumbs_tags_color
|
||||
@@ -1068,6 +1343,19 @@ class SettingsDialog(QDialog):
|
||||
APP_CONFIG["thumbnails_show_rating"] = self.show_rating_check.isChecked()
|
||||
APP_CONFIG["thumbnails_show_tags"] = self.show_tags_check.isChecked()
|
||||
APP_CONFIG["viewer_wheel_speed"] = self.viewer_wheel_spin.value()
|
||||
APP_CONFIG["duplicate_method"] = self.duplicate_method_combo.currentData()
|
||||
APP_CONFIG["duplicate_threshold"] = self.duplicate_threshold_slider.value()
|
||||
APP_CONFIG["default_delete_to_trash"] = \
|
||||
self.default_delete_to_trash_checkbox.isChecked()
|
||||
APP_CONFIG["duplicate_confirm_delete"] = \
|
||||
self.duplicate_confirm_delete_checkbox.isChecked()
|
||||
wl_paths = [self.duplicate_whitelist_list.item(i).text()
|
||||
for i in range(self.duplicate_whitelist_list.count())]
|
||||
APP_CONFIG["duplicate_whitelist"] = ",".join(wl_paths)
|
||||
bl_paths = [self.duplicate_blacklist_list.item(i).text()
|
||||
for i in range(self.duplicate_blacklist_list.count())]
|
||||
APP_CONFIG["duplicate_blacklist"] = ",".join(bl_paths)
|
||||
|
||||
APP_CONFIG["viewer_auto_resize_window"] = \
|
||||
self.viewer_auto_resize_check.isChecked()
|
||||
APP_CONFIG["face_detection_engine"] = self.face_engine_combo.currentText()
|
||||
@@ -1108,3 +1396,111 @@ class SettingsDialog(QDialog):
|
||||
|
||||
def _on_downloader_finished(self):
|
||||
self.downloader_thread = None
|
||||
|
||||
def _stop_downloader_thread(self):
|
||||
if self.downloader_thread and self.downloader_thread.isRunning():
|
||||
self.downloader_thread.stop()
|
||||
self.downloader_thread.wait()
|
||||
self.downloader_thread = None
|
||||
|
||||
def done(self, r):
|
||||
self._stop_downloader_thread() # Ensure downloader thread stops and waits.
|
||||
if self.counter_thread and self.counter_thread.isRunning():
|
||||
self.counter_thread.stop()
|
||||
self.counter_thread.wait()
|
||||
super().done(r)
|
||||
|
||||
def closeEvent(self, event):
|
||||
self._stop_downloader_thread() # Ensure downloader thread stops and waits.
|
||||
if self.counter_thread and self.counter_thread.isRunning():
|
||||
self.counter_thread.stop()
|
||||
self.counter_thread.wait()
|
||||
super().closeEvent(event)
|
||||
|
||||
def _add_path_to_list(self, list_widget, path):
|
||||
"""Adds a path to a QListWidget with existence validation."""
|
||||
path = os.path.abspath(os.path.expanduser(path.strip()))
|
||||
if not path:
|
||||
return
|
||||
|
||||
to_remove = []
|
||||
for i in range(list_widget.count()):
|
||||
existing_p = list_widget.item(i).text()
|
||||
if existing_p == path:
|
||||
return
|
||||
|
||||
# If a parent folder already exists, do not add this subfolder.
|
||||
if path.startswith(existing_p + os.sep):
|
||||
return
|
||||
|
||||
# If the new path is a parent of an existing one, mark it for removal.
|
||||
if existing_p.startswith(path + os.sep):
|
||||
to_remove.append(i)
|
||||
|
||||
# Remove unnecessary subfolders (reverse order to not alter indices).
|
||||
for i in sorted(to_remove, reverse=True):
|
||||
list_widget.takeItem(i)
|
||||
|
||||
item = QListWidgetItem(path)
|
||||
if not os.path.isdir(path):
|
||||
item.setForeground(QColor("red"))
|
||||
item.setToolTip(
|
||||
UITexts.SETTINGS_PATH_NOT_FOUND_WARNING.format(path))
|
||||
list_widget.addItem(item)
|
||||
|
||||
def add_whitelist_path(self):
|
||||
"""Opens a directory dialog to add a folder to the whitelist."""
|
||||
dir_path = QFileDialog.getExistingDirectory(self, UITexts.SELECT)
|
||||
if dir_path:
|
||||
self._add_path_to_list(self.duplicate_whitelist_list, dir_path)
|
||||
|
||||
def remove_whitelist_path(self):
|
||||
"""Removes the selected folders from the whitelist list."""
|
||||
for item in self.duplicate_whitelist_list.selectedItems():
|
||||
self.duplicate_whitelist_list.takeItem(
|
||||
self.duplicate_whitelist_list.row(item))
|
||||
|
||||
def add_blacklist_path(self):
|
||||
"""Opens a directory dialog to add a folder to the blacklist."""
|
||||
dir_path = QFileDialog.getExistingDirectory(self, UITexts.SELECT)
|
||||
if dir_path:
|
||||
self._add_path_to_list(self.duplicate_blacklist_list, dir_path)
|
||||
|
||||
def remove_blacklist_path(self):
|
||||
"""Removes the selected folders from the blacklist list."""
|
||||
for item in self.duplicate_blacklist_list.selectedItems():
|
||||
self.duplicate_blacklist_list.takeItem(
|
||||
self.duplicate_blacklist_list.row(item))
|
||||
|
||||
def update_duplicate_scan_count(self):
|
||||
"""Calculates and updates the count of images in whitelist/blacklist
|
||||
using a background thread."""
|
||||
if self.counter_thread and self.counter_thread.isRunning():
|
||||
self.counter_thread.stop()
|
||||
self.counter_thread.wait()
|
||||
|
||||
whitelist_paths = [self.duplicate_whitelist_list.item(i).text()
|
||||
for i in range(self.duplicate_whitelist_list.count())]
|
||||
blacklist_paths = [self.duplicate_blacklist_list.item(i).text()
|
||||
for i in range(self.duplicate_blacklist_list.count())]
|
||||
|
||||
whitelist = [os.path.abspath(os.path.expanduser(p.strip()))
|
||||
for p in whitelist_paths if p.strip()]
|
||||
blacklist = {os.path.abspath(os.path.expanduser(p.strip()))
|
||||
for p in blacklist_paths if p.strip()}
|
||||
|
||||
if not whitelist:
|
||||
self.duplicate_scan_count_label.setText(
|
||||
UITexts.SETTINGS_DUPLICATE_SCAN_COUNT_LABEL.format(0))
|
||||
self.duplicate_scan_progress.hide()
|
||||
return
|
||||
|
||||
self.duplicate_scan_progress.show()
|
||||
self.counter_thread = DuplicateFileCounter(
|
||||
whitelist, blacklist, IMAGE_EXTENSIONS)
|
||||
self.counter_thread.count_updated.connect(
|
||||
lambda c: self.duplicate_scan_count_label.setText(
|
||||
UITexts.SETTINGS_DUPLICATE_SCAN_COUNT_LABEL.format(c)))
|
||||
self.counter_thread.finished.connect(
|
||||
lambda: self.duplicate_scan_progress.hide())
|
||||
self.counter_thread.start()
|
||||
|
||||
8
setup.py
8
setup.py
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
|
||||
|
||||
setup(
|
||||
name="bagheeraview",
|
||||
version="0.9.15",
|
||||
version="0.9.26",
|
||||
author="Ignacio Serantes",
|
||||
description="Bagheera Image Viewer - An image viewer for KDE with Baloo in mind",
|
||||
long_description="A fast image viewer built with PySide6, featuring search and "
|
||||
@@ -16,6 +16,7 @@ setup(
|
||||
"exiv2",
|
||||
"psutil",
|
||||
"watchdog",
|
||||
"imagehash", # Added for perceptual hashing
|
||||
"mediapipe",
|
||||
"face_recognition",
|
||||
"face_recognition_models",
|
||||
@@ -38,8 +39,11 @@ setup(
|
||||
"filesystemwatcher",
|
||||
"metadatamanager",
|
||||
"propertiesdialog",
|
||||
"thumbnailwidget",
|
||||
#"thumbnailwidget",
|
||||
"duplicatecache",
|
||||
"duplicatedialog",
|
||||
"widgets",
|
||||
"filesystemwatcher",
|
||||
"xmpmanager",
|
||||
"utils"
|
||||
],
|
||||
|
||||
26
widgets.py
26
widgets.py
@@ -129,11 +129,19 @@ class TagEditWidget(QWidget):
|
||||
search_layout = QHBoxLayout()
|
||||
self.search_bar = QLineEdit()
|
||||
self.search_bar.setPlaceholderText(UITexts.TAG_SEARCH_PLACEHOLDER)
|
||||
# Obtener la altura preferida del QLineEdit para usarla en los botones
|
||||
line_edit_height = self.search_bar.sizeHint().height()
|
||||
self.search_bar.setClearButtonEnabled(True)
|
||||
self.btn_add_tag = QPushButton("+")
|
||||
self.btn_add_tag.setFixedWidth(30)
|
||||
self.btn_add_tag.setFixedSize(30, line_edit_height)
|
||||
self.btn_add_tag.setToolTip(UITexts.TAG_ADD_TOOLTIP)
|
||||
self.btn_refresh_tags = QPushButton()
|
||||
self.btn_refresh_tags.setIcon(QIcon.fromTheme("view-refresh"))
|
||||
self.btn_refresh_tags.setFixedSize(30, line_edit_height)
|
||||
self.btn_refresh_tags.setToolTip(UITexts.TAG_REFRESH_TOOLTIP)
|
||||
search_layout.addWidget(self.search_bar)
|
||||
search_layout.addWidget(self.btn_add_tag)
|
||||
search_layout.addWidget(self.btn_refresh_tags)
|
||||
layout.addLayout(search_layout)
|
||||
|
||||
# Tag tree view setup
|
||||
@@ -159,6 +167,7 @@ class TagEditWidget(QWidget):
|
||||
# Connect signals to slots
|
||||
self.btn_apply.clicked.connect(self.save_changes)
|
||||
self.btn_add_tag.clicked.connect(self.create_new_tag)
|
||||
self.btn_refresh_tags.clicked.connect(self.refresh_available_tags)
|
||||
self.search_bar.textChanged.connect(self.handle_search)
|
||||
self.source_model.itemChanged.connect(self.sync_tags)
|
||||
self.tree_view.search_requested.connect(self.on_search_requested)
|
||||
@@ -177,6 +186,12 @@ class TagEditWidget(QWidget):
|
||||
tags in files_data.items()}
|
||||
self.refresh_ui()
|
||||
|
||||
def refresh_available_tags(self):
|
||||
"""Manual refresh of available tags from Baloo."""
|
||||
self.load_available_tags()
|
||||
self._load_all = True
|
||||
self.init_data()
|
||||
|
||||
def load_available_tags(self):
|
||||
"""Loads all known tags from the Baloo index database."""
|
||||
db_path = os.path.expanduser("~/.local/share/baloo/index")
|
||||
@@ -399,7 +414,7 @@ class TagEditWidget(QWidget):
|
||||
if not full_path:
|
||||
return ""
|
||||
words = full_path.replace('/', ' ').split()
|
||||
search_terms = [f"tags:'{word}'" for word in words if word]
|
||||
search_terms = [f"tags='{word}'" for word in words if word]
|
||||
return " ".join(search_terms)
|
||||
|
||||
def _get_current_query_text(self):
|
||||
@@ -649,7 +664,6 @@ class LayoutsWidget(QWidget):
|
||||
|
||||
item_name = QTableWidgetItem(name)
|
||||
item_name.setData(Qt.UserRole, f_path)
|
||||
item_name.setData(Qt.UserRole, f_path) # Store full path in item
|
||||
item_date = QTableWidgetItem(dt)
|
||||
|
||||
self.table.setItem(i, 0, item_name)
|
||||
@@ -1342,8 +1356,8 @@ class FaceNameInputWidget(QWidget):
|
||||
super().__init__(parent)
|
||||
self.main_win = main_win
|
||||
self.region_type = region_type
|
||||
# Usamos deque para gestionar el historial de forma eficiente con un máximo
|
||||
# configurable de elementos.
|
||||
# Use deque to manage history efficiently with a configurable maximum
|
||||
# number of items.
|
||||
max_items = APP_CONFIG.get("faces_menu_max_items",
|
||||
FACES_MENU_MAX_ITEMS_DEFAULT)
|
||||
if self.region_type == "Pet":
|
||||
@@ -1373,7 +1387,7 @@ class FaceNameInputWidget(QWidget):
|
||||
self.name_combo.setToolTip(UITexts.FACE_NAME_TOOLTIP)
|
||||
self.name_combo.lineEdit().setClearButtonEnabled(True)
|
||||
|
||||
# 2. Completer para la funcionalidad de autocompletado.
|
||||
# 2. Completer for autocomplete functionality.
|
||||
self.completer = QCompleter(self)
|
||||
self.completer.setCaseSensitivity(Qt.CaseInsensitive)
|
||||
self.completer.setFilterMode(Qt.MatchContains)
|
||||
|
||||
@@ -17,13 +17,17 @@ Dependencies:
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import logging
|
||||
from utils import preserve_mtime
|
||||
from metadatamanager import notify_baloo
|
||||
from metadatamanager import notify_baloo, mark_app_modified
|
||||
from constants import UITexts
|
||||
try:
|
||||
import exiv2
|
||||
except ImportError:
|
||||
exiv2 = None
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class XmpManager:
|
||||
"""
|
||||
@@ -38,8 +42,9 @@ class XmpManager:
|
||||
|
||||
This method parses the XMP data structure for a `mwg-rs:RegionList`,
|
||||
extracts all regions of type 'Face', and returns them as a list of
|
||||
dictionaries. Each dictionary contains the face's name and its
|
||||
normalized coordinates (center x, center y, width, height).
|
||||
dictionaries.
|
||||
Each dictionary contains the face's name and its normalized coordinates
|
||||
(center x, center y, width, height).
|
||||
|
||||
Args:
|
||||
path (str): The path to the image file.
|
||||
@@ -161,8 +166,15 @@ class XmpManager:
|
||||
xmp[f"{area_base}/stArea:unit"] = 'normalized'
|
||||
|
||||
img.writeMetadata()
|
||||
|
||||
notify_baloo(path)
|
||||
mark_app_modified(path)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error saving faces to XMP: {e}")
|
||||
return False
|
||||
error_msg = str(e)
|
||||
if "kerTooLargeJpegSegment" in error_msg or "38" in error_msg:
|
||||
msg = UITexts.ERROR_JPEG_METADATA_LIMIT.format(os.path.basename(path))
|
||||
logger.error(msg)
|
||||
raise IOError(msg) from e
|
||||
logger.error(f"Error saving faces to XMP: {e}")
|
||||
raise
|
||||
|
||||
Reference in New Issue
Block a user