1#!/usr/bin/env python3 2 3# Copyright (C) 2011-2020 Damon Lynch <damonlynch@gmail.com> 4 5# This file is part of Rapid Photo Downloader. 6# 7# Rapid Photo Downloader is free software: you can redistribute it and/or 8# modify it under the terms of the GNU General Public License as published by 9# the Free Software Foundation, either version 3 of the License, or 10# (at your option) any later version. 11# 12# Rapid Photo Downloader is distributed in the hope that it will be useful, 13# but WITHOUT ANY WARRANTY; without even the implied warranty of 14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15# GNU General Public License for more details. 16# 17# You should have received a copy of the GNU General Public License 18# along with Rapid Photo Downloader. If not, 19# see <http://www.gnu.org/licenses/>. 20 21""" 22Primary logic for Rapid Photo Downloader. 23 24Qt related class method and variable names use CamelCase. 25Everything else should follow PEP 8. 26Project line length: 100 characters (i.e. word wrap at 99) 27 28"Hamburger" Menu Icon by Daniel Bruce -- www.entypo.com 29""" 30 31__author__ = 'Damon Lynch' 32__copyright__ = "Copyright 2011-2020, Damon Lynch" 33 34import sys 35import logging 36 37import shutil 38import datetime 39import locale 40 41try: 42 # Use the default locale as defined by the LANG variable 43 locale.setlocale(locale.LC_ALL, '') 44except locale.Error: 45 pass 46 47from collections import namedtuple, defaultdict 48import platform 49import argparse 50from typing import Optional, Tuple, List, Sequence, Dict, Set, Any, DefaultDict 51import faulthandler 52import pkg_resources as pkgr 53import webbrowser 54import time 55import shlex 56import subprocess 57from urllib.request import pathname2url 58import inspect 59 60import dateutil 61 62import gi 63gi.require_version('Notify', '0.7') 64from gi.repository import Notify 65 66try: 67 gi.require_version('Unity', '7.0') 68 from gi.repository import Unity 69 launcher = 'net.damonlynch.rapid_photo_downloader.desktop' 70 Unity.LauncherEntry.get_for_desktop_id(launcher) 71 have_unity = True 72except (ImportError, ValueError, gi.repository.GLib.GError): 73 have_unity = False 74 75import zmq 76import psutil 77import arrow 78import gphoto2 as gp 79from PyQt5 import QtCore 80from PyQt5.QtCore import ( 81 QThread, Qt, QStorageInfo, QSettings, QPoint, QSize, QTimer, QTextStream, QModelIndex, 82 pyqtSlot, QRect, pyqtSignal, QObject, QEvent, QLocale, 83) 84from PyQt5.QtGui import ( 85 QIcon, QPixmap, QImage, QColor, QPalette, QFontMetrics, QFont, QPainter, QMoveEvent, QBrush, 86 QPen, QColor, QScreen, QDesktopServices 87) 88from PyQt5.QtWidgets import ( 89 QAction, QApplication, QMainWindow, QMenu, QWidget, QDialogButtonBox, 90 QProgressBar, QSplitter, QHBoxLayout, QVBoxLayout, QDialog, QLabel, QComboBox, QGridLayout, 91 QCheckBox, QSizePolicy, QMessageBox, QSplashScreen, QStackedWidget, QScrollArea, 92 QStyledItemDelegate, QPushButton, QDesktopWidget 93) 94from PyQt5.QtNetwork import QLocalSocket, QLocalServer 95 96# PyQt 5.11 introduces from PyQt5 import sip i.e. from a 'private' sip, unique 97# to PyQt5. However we cannot assume that distros will follow this mechanism. 98# So as a defensive measure, merely import sip, doing this only after Qt has 99# already been imported. See: 100# http://pyqt.sourceforge.net/Docs/PyQt5/incompatibilities.html#importing-the-sip-module 101import sip 102 103from raphodo.storage import ( 104 ValidMounts, CameraHotplug, GVolumeMonitor, have_gio, 105 has_one_or_more_folders, mountPaths, get_desktop_environment, get_desktop, 106 gvfs_controls_mounts, get_default_file_manager, validate_download_folder, 107 validate_source_folder, get_fdo_cache_thumb_base_directory, WatchDownloadDirs, get_media_dir, 108 StorageSpace, gvfs_gphoto2_path, get_uri 109) 110from raphodo.interprocess import ( 111 ScanArguments, CopyFilesArguments, RenameAndMoveFileData, BackupArguments, 112 BackupFileData, OffloadData, ProcessLoggingManager, ThumbnailDaemonData, ThreadNames, 113 OffloadManager, CopyFilesManager, ThumbnailDaemonManager, 114 ScanManager, BackupManager, stop_process_logging_manager, RenameMoveFileManager, 115 create_inproc_msg) 116from raphodo.devices import ( 117 Device, DeviceCollection, BackupDevice, BackupDeviceCollection, FSMetadataErrors 118) 119from raphodo.preferences import Preferences 120from raphodo.constants import ( 121 BackupLocationType, DeviceType, ErrorType, FileType, DownloadStatus, RenameAndMoveStatus, 122 ApplicationState, CameraErrorCode, TemporalProximityState, ThumbnailBackgroundName, 123 Desktop, BackupFailureType, DeviceState, Sort, Show, DestinationDisplayType, 124 DisplayingFilesOfType, DownloadingFileTypes, RememberThisMessage, RightSideButton, 125 CheckNewVersionDialogState, CheckNewVersionDialogResult, RememberThisButtons, 126 BackupStatus, CompletedDownloads, disable_version_check, FileManagerType, ScalingAction, 127 ScalingDetected 128) 129from raphodo.thumbnaildisplay import ( 130 ThumbnailView, ThumbnailListModel, ThumbnailDelegate, DownloadStats, MarkedSummary 131) 132from raphodo.devicedisplay import (DeviceModel, DeviceView, DeviceDelegate) 133from raphodo.proximity import (TemporalProximityGroups, TemporalProximity) 134from raphodo.utilities import ( 135 same_device, make_internationalized_list, thousands, addPushButtonLabelSpacer, 136 make_html_path_non_breaking, prefs_list_from_gconftool2_string, 137 pref_bool_from_gconftool2_string, extract_file_from_tar, format_size_for_user, 138 is_snap, version_check_disabled, installed_using_pip, getQtSystemTranslation 139) 140from raphodo.rememberthisdialog import RememberThisDialog 141import raphodo.utilities 142from raphodo.rpdfile import ( 143 RPDFile, file_types_by_number, FileTypeCounter, Video, Photo, FileSizeSum 144) 145import raphodo.fileformats as fileformats 146import raphodo.downloadtracker as downloadtracker 147from raphodo.cache import ThumbnailCacheSql 148from raphodo.programversions import gexiv2_version, exiv2_version, EXIFTOOL_VERSION 149from raphodo.metadatavideo import pymedia_version_info, libmediainfo_missing 150from raphodo.camera import ( 151 gphoto2_version, python_gphoto2_version, dump_camera_details, gphoto2_python_logging, 152 autodetect_cameras 153) 154from raphodo.rpdsql import DownloadedSQL 155from raphodo.generatenameconfig import * 156from raphodo.rotatedpushbutton import RotatedButton, FlatButton 157from raphodo.primarybutton import TopPushButton, DownloadButton 158from raphodo.filebrowse import ( 159 FileSystemView, FileSystemModel, FileSystemFilter, FileSystemDelegate 160) 161from raphodo.toggleview import QToggleView 162import raphodo.__about__ as __about__ 163import raphodo.iplogging as iplogging 164import raphodo.excepthook as excepthook 165from raphodo.panelview import QPanelView 166from raphodo.computerview import ComputerWidget 167from raphodo.folderspreview import DownloadDestination, FoldersPreview 168from raphodo.destinationdisplay import DestinationDisplay 169from raphodo.aboutdialog import AboutDialog 170import raphodo.constants as constants 171from raphodo.menubutton import MenuButton 172from raphodo.renamepanel import RenamePanel 173from raphodo.jobcodepanel import JobCodePanel 174from raphodo.backuppanel import BackupPanel 175import raphodo 176import raphodo.exiftool as exiftool 177from raphodo.newversion import ( 178 NewVersion, NewVersionCheckDialog, version_details, DownloadNewVersionDialog 179) 180from raphodo.chevroncombo import ChevronCombo 181from raphodo.preferencedialog import PreferencesDialog 182from raphodo.errorlog import ErrorReport, SpeechBubble 183from raphodo.problemnotification import ( 184 FsMetadataWriteProblem, Problem, Problems, CopyingProblems, RenamingProblems, BackingUpProblems 185) 186from raphodo.viewutils import ( 187 standardIconSize, qt5_screen_scale_environment_variable, QT5_VERSION, validateWindowSizeLimit, 188 validateWindowPosition, scaledIcon, any_screen_scaled, standardMessageBox 189) 190from raphodo import viewutils 191import raphodo.didyouknow as didyouknow 192from raphodo.thumbnailextractor import gst_version, libraw_version, rawkit_version 193from raphodo.heif import have_heif_module, pyheif_version, libheif_version 194from raphodo.filesystemurl import FileSystemUrlHandler 195 196 197# Avoid segfaults at exit: 198# http://pyqt.sourceforge.net/Docs/PyQt5/gotchas.html#crashes-on-exit 199app = None # type: 'QtSingleApplication' 200 201faulthandler.enable() 202logger = None 203sys.excepthook = excepthook.excepthook 204 205 206class FolderPreviewManager(QObject): 207 """ 208 Manages sending FoldersPreview() off to the offload process to 209 generate new provisional download subfolders, and removing provisional download subfolders 210 in the main process, using QFileSystemModel. 211 212 Queues operations if they need to be, or runs them immediately when it can. 213 214 Sadly we must delete provisional download folders only in the main process, using 215 QFileSystemModel. Otherwise the QFileSystemModel is liable to issue a large number of 216 messages like this: 217 218 QInotifyFileSystemWatcherEngine::addPaths: inotify_add_watch failed: No such file or directory 219 220 Yet we must generate and create folders in the offload process, because that 221 can be expensive for a large number of rpd_files. 222 223 New for PyQt 5.7: Inherits from QObject to allow for Qt signals and slots using PyQt slot 224 decorator. 225 """ 226 227 def __init__(self, fsmodel: FileSystemModel, 228 prefs: Preferences, 229 photoDestinationFSView: FileSystemView, 230 videoDestinationFSView: FileSystemView, 231 devices: DeviceCollection, 232 rapidApp: 'RapidWindow') -> None: 233 """ 234 235 :param fsmodel: FileSystemModel powering the destination and this computer views 236 :param prefs: program preferences 237 :param photoDestinationFSView: photo destination view 238 :param videoDestinationFSView: video destination view 239 :param devices: the device collection 240 :param rapidApp: main application window 241 """ 242 243 super().__init__() 244 245 self.rpd_files_queue = [] # type: List[RPDFile] 246 self.clean_for_scan_id_queue = [] # type: List[int] 247 self.change_destination_queued = False # type: bool 248 self.subfolder_rebuild_queued = False # type: bool 249 250 self.offloaded = False 251 self.process_destination = False 252 self.fsmodel = fsmodel 253 self.prefs = prefs 254 self.devices = devices 255 self.rapidApp = rapidApp 256 257 self.photoDestinationFSView = photoDestinationFSView 258 self.videoDestinationFSView = videoDestinationFSView 259 260 self.folders_preview = FoldersPreview() 261 # Set the initial download destination values, using the values 262 # in the program prefs: 263 self._change_destination() 264 265 def add_rpd_files(self, rpd_files: List[RPDFile]) -> None: 266 """ 267 Generate new provisional download folders for the rpd_files, either 268 by sending them off for generation to the offload process, or if some 269 are already being generated, queueing the operation 270 271 :param rpd_files: the list of rpd files 272 """ 273 274 if self.offloaded: 275 self.rpd_files_queue.extend(rpd_files) 276 else: 277 if self.rpd_files_queue: 278 rpd_files = rpd_files + self.rpd_files_queue 279 self.rpd_files_queue = [] # type: List[RPDFile] 280 self._generate_folders(rpd_files=rpd_files) 281 282 def _generate_folders(self, rpd_files: List[RPDFile]) -> None: 283 if not self.devices.scanning or self.rapidApp.downloadIsRunning(): 284 logging.info("Generating provisional download folders for %s files", len(rpd_files)) 285 data = OffloadData( 286 rpd_files=rpd_files, strip_characters=self.prefs.strip_characters, 287 folders_preview=self.folders_preview 288 ) 289 self.offloaded = True 290 self.rapidApp.sendToOffload(data=data) 291 292 def change_destination(self) -> None: 293 if self.offloaded: 294 self.change_destination_queued = True 295 else: 296 self._change_destination() 297 self._update_model_and_views() 298 299 def change_subfolder_structure(self) -> None: 300 self.change_destination() 301 if self.offloaded: 302 assert self.change_destination_queued == True 303 self.subfolder_rebuild_queued = True 304 else: 305 self._change_subfolder_structure() 306 307 def _change_destination(self) -> None: 308 destination = DownloadDestination( 309 photo_download_folder=self.prefs.photo_download_folder, 310 video_download_folder=self.prefs.video_download_folder, 311 photo_subfolder=self.prefs.photo_subfolder, 312 video_subfolder=self.prefs.video_subfolder 313 ) 314 self.folders_preview.process_destination( 315 destination=destination, fsmodel=self.fsmodel 316 ) 317 318 def _change_subfolder_structure(self) -> None: 319 rpd_files = self.rapidApp.thumbnailModel.getAllDownloadableRPDFiles() 320 if rpd_files: 321 self.add_rpd_files(rpd_files=rpd_files) 322 323 @pyqtSlot(FoldersPreview) 324 def folders_generated(self, folders_preview: FoldersPreview) -> None: 325 """ 326 Receive the folders_preview from the offload process, and 327 handle any tasks that may have been queued in the time it was 328 being processed in the offload process 329 330 :param folders_preview: the folders_preview as worked on by the 331 offload process 332 """ 333 334 logging.debug("Provisional download folders received") 335 self.offloaded = False 336 self.folders_preview = folders_preview 337 338 dirty = self.folders_preview.dirty 339 self.folders_preview.dirty = False 340 if dirty: 341 logging.debug("Provisional download folders change detected") 342 343 if not self.rapidApp.downloadIsRunning(): 344 for scan_id in self.clean_for_scan_id_queue: 345 dirty = True 346 self._remove_provisional_folders_for_device(scan_id=scan_id) 347 348 self.clean_for_scan_id_queue = [] # type: List[int] 349 350 if self.change_destination_queued: 351 self.change_destination_queued = False 352 dirty = True 353 logging.debug("Changing destination of provisional download folders") 354 self._change_destination() 355 356 if self.subfolder_rebuild_queued: 357 self.subfolder_rebuild_queued = False 358 logging.debug("Rebuilding provisional download folders") 359 self._change_subfolder_structure() 360 else: 361 logging.debug( 362 "Not removing or moving provisional download folders because a download is running" 363 ) 364 365 if dirty: 366 self._update_model_and_views() 367 368 if self.rpd_files_queue: 369 logging.debug("Assigning queued provisional download folders to be generated") 370 self._generate_folders(rpd_files=self.rpd_files_queue) 371 self.rpd_files_queue = [] # type: List[RPDFile] 372 373 # self.folders_preview.dump() 374 375 def _update_model_and_views(self): 376 logging.debug("Updating file system model and views") 377 self.fsmodel.preview_subfolders = self.folders_preview.preview_subfolders() 378 self.fsmodel.download_subfolders = self.folders_preview.download_subfolders() 379 # Update the view 380 self.photoDestinationFSView.reset() 381 self.videoDestinationFSView.reset() 382 # Ensure the file system model caches are refreshed: 383 self.fsmodel.setRootPath(self.folders_preview.photo_download_folder) 384 self.fsmodel.setRootPath(self.folders_preview.video_download_folder) 385 self.fsmodel.setRootPath('/') 386 self.photoDestinationFSView.expandPreviewFolders(self.prefs.photo_download_folder) 387 self.videoDestinationFSView.expandPreviewFolders(self.prefs.video_download_folder) 388 389 # self.photoDestinationFSView.update() 390 # self.videoDestinationFSView.update() 391 392 def remove_folders_for_device(self, scan_id: int) -> None: 393 """ 394 Remove provisional download folders unique to this scan_id 395 using the offload process. 396 397 :param scan_id: scan id of the device 398 """ 399 400 if self.offloaded: 401 self.clean_for_scan_id_queue.append(scan_id) 402 else: 403 self._remove_provisional_folders_for_device(scan_id=scan_id) 404 self._update_model_and_views() 405 406 def queue_folder_removal_for_device(self, scan_id: int) -> None: 407 """ 408 Queues provisional download files for removal after 409 all files have been downloaded for a device. 410 411 :param scan_id: scan id of the device 412 """ 413 414 self.clean_for_scan_id_queue.append(scan_id) 415 416 def remove_folders_for_queued_devices(self) -> None: 417 """ 418 Once all files have been downloaded (i.e. no more remain 419 to be downloaded) and there was a disparity between 420 modification times and creation times that was discovered during 421 the download, clean any provisional download folders now that the 422 download has finished. 423 """ 424 425 for scan_id in self.clean_for_scan_id_queue: 426 self._remove_provisional_folders_for_device(scan_id=scan_id) 427 self.clean_for_scan_id_queue = [] # type: List[int] 428 self._update_model_and_views() 429 430 def _remove_provisional_folders_for_device(self, scan_id: int) -> None: 431 if scan_id in self.devices: 432 logging.info( 433 "Cleaning provisional download folders for %s", self.devices[scan_id].display_name 434 ) 435 else: 436 logging.info("Cleaning provisional download folders for device %d", scan_id) 437 self.folders_preview.clean_generated_folders_for_scan_id( 438 scan_id=scan_id, fsmodel=self.fsmodel 439 ) 440 441 def remove_preview_folders(self) -> None: 442 """ 443 Called when application is exiting. 444 """ 445 446 self.folders_preview.clean_all_generated_folders(fsmodel=self.fsmodel) 447 448 449class RapidWindow(QMainWindow): 450 """ 451 Main application window, and primary controller of program logic 452 453 Such attributes unfortunately make it very complex. 454 455 For better or worse, Qt's state machine technology is not used. 456 State indicating whether a download or scan is occurring is 457 thus kept in the device collection, self.devices 458 """ 459 460 checkForNewVersionRequest = pyqtSignal() 461 downloadNewVersionRequest = pyqtSignal(str, str) 462 reverifyDownloadedTar = pyqtSignal(str) 463 udisks2Unmount = pyqtSignal(str) 464 465 def __init__(self, splash: 'SplashScreen', 466 fractional_scaling: str, 467 scaling_set: str, 468 scaling_action: ScalingAction, 469 scaling_detected: ScalingDetected, 470 xsetting_running: bool, 471 photo_rename: Optional[bool]=None, 472 video_rename: Optional[bool]=None, 473 auto_detect: Optional[bool]=None, 474 this_computer_source: Optional[str]=None, 475 this_computer_location: Optional[str]=None, 476 photo_download_folder: Optional[str]=None, 477 video_download_folder: Optional[str]=None, 478 backup: Optional[bool]=None, 479 backup_auto_detect: Optional[bool]=None, 480 photo_backup_identifier: Optional[str]=None, 481 video_backup_identifier: Optional[str]=None, 482 photo_backup_location: Optional[str]=None, 483 video_backup_location: Optional[str]=None, 484 ignore_other_photo_types: Optional[bool]=None, 485 thumb_cache: Optional[bool]=None, 486 auto_download_startup: Optional[bool]=None, 487 auto_download_insertion: Optional[bool]=None, 488 log_gphoto2: Optional[bool]=None) -> None: 489 490 super().__init__() 491 self.splash = splash 492 if splash.isVisible(): 493 self.screen = splash.windowHandle().screen() # type: QScreen 494 else: 495 self.screen = None 496 497 self.fractional_scaling_message = fractional_scaling 498 self.scaling_set_message = scaling_set 499 500 # Process Qt events - in this case, possible closing of splash screen 501 app.processEvents() 502 503 # Three values to handle window position quirks under X11: 504 self.window_show_requested_time = None # type: Optional[datetime.datetime] 505 self.window_move_triggered_count = 0 506 self.windowPositionDelta = QPoint(0, 0) 507 508 self.setFocusPolicy(Qt.StrongFocus) 509 510 self.ignore_other_photo_types = ignore_other_photo_types 511 self.application_state = ApplicationState.normal 512 self.prompting_for_user_action = {} # type: Dict[Device, QMessageBox] 513 514 self.close_event_run = False 515 516 self.file_manager, self.file_manager_type = get_default_file_manager() 517 518 self.fileSystemUrlHandler = FileSystemUrlHandler(self.file_manager, self.file_manager_type) 519 QDesktopServices.setUrlHandler("file", self.fileSystemUrlHandler, "openFileBrowser") 520 521 for version in get_versions( 522 self.file_manager, self.file_manager_type, scaling_action, 523 scaling_detected, xsetting_running): 524 logging.info('%s', version) 525 526 if disable_version_check: 527 logging.debug("Version checking disabled via code") 528 529 if is_snap(): 530 logging.debug("Version checking disabled because running in a snap") 531 532 if EXIFTOOL_VERSION is None: 533 logging.error("ExifTool is either missing or has a problem") 534 535 if pymedia_version_info() is None: 536 if libmediainfo_missing: 537 logging.error( 538 "pymediainfo is installed, but the library libmediainfo appears to be missing" 539 ) 540 541 self.log_gphoto2 = log_gphoto2 == True 542 543 self.setWindowTitle(_("Rapid Photo Downloader")) 544 # app is a module level global 545 self.readWindowSettings(app) 546 self.prefs = Preferences() 547 self.checkPrefsUpgrade() 548 self.prefs.program_version = __about__.__version__ 549 550 if self.prefs.force_exiftool: 551 logging.debug("ExifTool and not Exiv2 will be used to read photo metadata") 552 553 # track devices on which there was an error setting a file's filesystem metadata 554 self.copy_metadata_errors = FSMetadataErrors() 555 self.backup_metadata_errors = FSMetadataErrors() 556 557 if thumb_cache is not None: 558 logging.debug("Use thumbnail cache: %s", thumb_cache) 559 self.prefs.use_thumbnail_cache = thumb_cache 560 561 self.setupWindow() 562 563 splash.setProgress(10) 564 565 if photo_rename is not None: 566 if photo_rename: 567 self.prefs.photo_rename = PHOTO_RENAME_SIMPLE 568 else: 569 self.prefs.photo_rename = self.prefs.rename_defaults['photo_rename'] 570 571 if video_rename is not None: 572 if video_rename: 573 self.prefs.video_rename = VIDEO_RENAME_SIMPLE 574 else: 575 self.prefs.video_rename = self.prefs.rename_defaults['video_rename'] 576 577 if auto_detect is not None: 578 self.prefs.device_autodetection = auto_detect 579 else: 580 logging.info("Device autodetection: %s", self.prefs.device_autodetection) 581 582 if self.prefs.device_autodetection: 583 if not self.prefs.scan_specific_folders: 584 logging.info("Devices do not need specific folders to be scanned") 585 else: 586 logging.info( 587 "For automatically detected devices, only the contents the following " 588 "folders will be scanned: %s", ', '.join(self.prefs.folders_to_scan) 589 ) 590 591 if this_computer_source is not None: 592 self.prefs.this_computer_source = this_computer_source 593 594 if this_computer_location is not None: 595 self.prefs.this_computer_path = this_computer_location 596 597 if self.prefs.this_computer_source: 598 if self.prefs.this_computer_path: 599 logging.info( 600 "This Computer is set to be used as a download source, using: %s", 601 self.prefs.this_computer_path 602 ) 603 else: 604 logging.info( 605 "This Computer is set to be used as a download source, but the location is " 606 "not yet set" 607 ) 608 else: 609 logging.info("This Computer is not used as a download source") 610 611 if photo_download_folder is not None: 612 self.prefs.photo_download_folder = photo_download_folder 613 logging.info("Photo download location: %s", self.prefs.photo_download_folder) 614 if video_download_folder is not None: 615 self.prefs.video_download_folder = video_download_folder 616 logging.info("Video download location: %s", self.prefs.video_download_folder) 617 618 if backup is not None: 619 self.prefs.backup_files = backup 620 else: 621 logging.info("Backing up files: %s", self.prefs.backup_files) 622 623 if backup_auto_detect is not None: 624 self.prefs.backup_device_autodetection = backup_auto_detect 625 elif self.prefs.backup_files: 626 logging.info("Backup device auto detection: %s", self.prefs.backup_device_autodetection) 627 628 if photo_backup_identifier is not None: 629 self.prefs.photo_backup_identifier = photo_backup_identifier 630 elif self.prefs.backup_files and self.prefs.backup_device_autodetection: 631 logging.info("Photo backup identifier: %s", self.prefs.photo_backup_identifier) 632 633 if video_backup_identifier is not None: 634 self.prefs.video_backup_identifier = video_backup_identifier 635 elif self.prefs.backup_files and self.prefs.backup_device_autodetection: 636 logging.info("video backup identifier: %s", self.prefs.video_backup_identifier) 637 638 if photo_backup_location is not None: 639 self.prefs.backup_photo_location = photo_backup_location 640 elif self.prefs.backup_files and not self.prefs.backup_device_autodetection: 641 logging.info("Photo backup location: %s", self.prefs.backup_photo_location) 642 643 if video_backup_location is not None: 644 self.prefs.backup_video_location = video_backup_location 645 elif self.prefs.backup_files and not self.prefs.backup_device_autodetection: 646 logging.info("video backup location: %s", self.prefs.backup_video_location) 647 648 if auto_download_startup is not None: 649 self.prefs.auto_download_at_startup = auto_download_startup 650 elif self.prefs.auto_download_at_startup: 651 logging.info("Auto download at startup is on") 652 653 if auto_download_insertion is not None: 654 self.prefs.auto_download_upon_device_insertion = auto_download_insertion 655 elif self.prefs.auto_download_upon_device_insertion: 656 logging.info("Auto download upon device insertion is on") 657 658 if self.prefs.list_not_empty('volume_whitelist'): 659 logging.info("Whitelisted devices: %s", " ; ".join(self.prefs.volume_whitelist)) 660 661 if self.prefs.list_not_empty('volume_blacklist'): 662 logging.info("Blacklisted devices: %s", " ; ".join(self.prefs.volume_blacklist)) 663 664 if self.prefs.list_not_empty('camera_blacklist'): 665 logging.info("Blacklisted cameras: %s", " ; ".join(self.prefs.camera_blacklist)) 666 667 self.prefs.verify_file = False 668 669 logging.debug("Starting main ExifTool process") 670 self.exiftool_process = exiftool.ExifTool() 671 self.exiftool_process.start() 672 673 self.prefs.validate_max_CPU_cores() 674 self.prefs.validate_ignore_unhandled_file_exts() 675 676 # Don't call processEvents() after initiating 0MQ, as it can 677 # cause "Interrupted system call" errors 678 app.processEvents() 679 680 self.download_paused = False 681 682 self.startThreadControlSockets() 683 self.startProcessLogger() 684 685 def checkPrefsUpgrade(self) -> None: 686 if self.prefs.program_version != __about__.__version__: 687 previous_version = self.prefs.program_version 688 if not len(previous_version): 689 logging.debug("Initial program run detected") 690 else: 691 pv = pkgr.parse_version(previous_version) 692 rv = pkgr.parse_version(__about__.__version__) 693 if pv < rv: 694 logging.info( 695 "Version upgrade detected, from %s to %s", 696 previous_version, __about__.__version__ 697 ) 698 self.prefs.upgrade_prefs(pv) 699 elif pv > rv: 700 logging.info( 701 "Version downgrade detected, from %s to %s", 702 previous_version, __about__.__version__ 703 ) 704 if pv < pkgr.parse_version('0.9.7b1'): 705 # Remove any duplicate subfolder generation or file renaming custom presets 706 self.prefs.filter_duplicate_generation_prefs() 707 708 def startThreadControlSockets(self) -> None: 709 """ 710 Create and bind inproc sockets to communicate with threads that 711 handle inter process communication via zmq. 712 713 See 'Signaling Between Threads (PAIR Sockets)' in 'ØMQ - The Guide' 714 http://zguide.zeromq.org/page:all#toc46 715 """ 716 717 context = zmq.Context.instance() 718 inproc = "inproc://{}" 719 720 self.logger_controller = context.socket(zmq.PAIR) 721 self.logger_controller.bind(inproc.format(ThreadNames.logger)) 722 723 self.rename_controller = context.socket(zmq.PAIR) 724 self.rename_controller.bind(inproc.format(ThreadNames.rename)) 725 726 self.scan_controller = context.socket(zmq.PAIR) 727 self.scan_controller.bind(inproc.format(ThreadNames.scan)) 728 729 self.copy_controller = context.socket(zmq.PAIR) 730 self.copy_controller.bind(inproc.format(ThreadNames.copy)) 731 732 self.backup_controller = context.socket(zmq.PAIR) 733 self.backup_controller.bind(inproc.format(ThreadNames.backup)) 734 735 self.thumbnail_deamon_controller = context.socket(zmq.PAIR) 736 self.thumbnail_deamon_controller.bind(inproc.format(ThreadNames.thumbnail_daemon)) 737 738 self.offload_controller = context.socket(zmq.PAIR) 739 self.offload_controller.bind(inproc.format(ThreadNames.offload)) 740 741 self.new_version_controller = context.socket(zmq.PAIR) 742 self.new_version_controller.bind(inproc.format(ThreadNames.new_version)) 743 744 def sendStopToThread(self, socket: zmq.Socket) -> None: 745 socket.send_multipart(create_inproc_msg(b'STOP')) 746 747 def sendTerminateToThread(self, socket: zmq.Socket) -> None: 748 socket.send_multipart(create_inproc_msg(b'TERMINATE')) 749 750 def sendStopWorkerToThread(self, socket: zmq.Socket, worker_id: int) -> None: 751 socket.send_multipart(create_inproc_msg(b'STOP_WORKER', worker_id=worker_id)) 752 753 def sendStartToThread(self, socket: zmq.Socket) -> None: 754 socket.send_multipart(create_inproc_msg(b'START')) 755 756 def sendStartWorkerToThread(self, socket: zmq.Socket, worker_id: int, data: Any) -> None: 757 socket.send_multipart(create_inproc_msg(b'START_WORKER', worker_id=worker_id, data=data)) 758 759 def sendResumeToThread(self, socket: zmq.Socket, worker_id: Optional[int]=None) -> None: 760 socket.send_multipart(create_inproc_msg(b'RESUME', worker_id=worker_id)) 761 762 def sendPauseToThread(self, socket: zmq.Socket) -> None: 763 socket.send_multipart(create_inproc_msg(b'PAUSE')) 764 765 def sendDataMessageToThread(self, socket: zmq.Socket, 766 data: Any, 767 worker_id: Optional[int]=None) -> None: 768 socket.send_multipart(create_inproc_msg(b'SEND_TO_WORKER', worker_id=worker_id, data=data)) 769 770 def sendToOffload(self, data: Any) -> None: 771 self.offload_controller.send_multipart( 772 create_inproc_msg(b'SEND_TO_WORKER', worker_id=None, data=data) 773 ) 774 775 def startProcessLogger(self) -> None: 776 self.loggermq = ProcessLoggingManager() 777 self.loggermqThread = QThread() 778 self.loggermq.moveToThread(self.loggermqThread) 779 780 self.loggermqThread.started.connect(self.loggermq.startReceiver) 781 self.loggermq.ready.connect(self.initStage2) 782 logging.debug("Starting logging subscription manager...") 783 QTimer.singleShot(0, self.loggermqThread.start) 784 785 @pyqtSlot(int) 786 def initStage2(self, logging_port: int) -> None: 787 logging.debug("...logging subscription manager started") 788 self.logging_port = logging_port 789 790 self.splash.setProgress(20) 791 792 logging.debug("Stage 2 initialization") 793 794 if self.prefs.purge_thumbnails: 795 cache = ThumbnailCacheSql(create_table_if_not_exists=False) 796 logging.info("Purging thumbnail cache...") 797 cache.purge_cache() 798 logging.info("...thumbnail Cache has been purged") 799 self.prefs.purge_thumbnails = False 800 # Recreate the cache on the file system 801 ThumbnailCacheSql(create_table_if_not_exists=True) 802 elif self.prefs.optimize_thumbnail_db: 803 cache = ThumbnailCacheSql(create_table_if_not_exists=True) 804 logging.info("Optimizing thumbnail cache...") 805 db, fs, size = cache.optimize() 806 logging.info("...thumbnail cache has been optimized.") 807 808 if db: 809 logging.info("Removed %s files from thumbnail database", db) 810 if fs: 811 logging.info("Removed %s thumbnails from file system", fs) 812 if size: 813 logging.info("Thumbnail database size reduction: %s", format_size_for_user(size)) 814 815 self.prefs.optimize_thumbnail_db = False 816 else: 817 # Recreate the cache on the file system 818 t = ThumbnailCacheSql(create_table_if_not_exists=True) 819 820 # For meaning of 'Devices', see devices.py 821 self.devices = DeviceCollection(self.exiftool_process, self) 822 self.backup_devices = BackupDeviceCollection(rapidApp=self) 823 824 logging.debug("Starting thumbnail daemon model") 825 826 self.thumbnaildaemonmqThread = QThread() 827 self.thumbnaildaemonmq = ThumbnailDaemonManager(logging_port=logging_port) 828 self.thumbnaildaemonmq.moveToThread(self.thumbnaildaemonmqThread) 829 self.thumbnaildaemonmqThread.started.connect(self.thumbnaildaemonmq.run_sink) 830 self.thumbnaildaemonmq.message.connect(self.thumbnailReceivedFromDaemon) 831 self.thumbnaildaemonmq.sinkStarted.connect(self.initStage3) 832 833 QTimer.singleShot(0, self.thumbnaildaemonmqThread.start) 834 835 @pyqtSlot() 836 def initStage3(self) -> None: 837 logging.debug("Stage 3 initialization") 838 839 self.splash.setProgress(30) 840 841 self.sendStartToThread(self.thumbnail_deamon_controller) 842 logging.debug("...thumbnail daemon model started") 843 844 self.thumbnailView = ThumbnailView(self) 845 self.thumbnailModel = ThumbnailListModel( 846 parent=self, logging_port=self.logging_port, log_gphoto2=self.log_gphoto2 847 ) 848 849 self.thumbnailView.setModel(self.thumbnailModel) 850 self.thumbnailView.setItemDelegate(ThumbnailDelegate(rapidApp=self)) 851 852 @pyqtSlot(int) 853 def initStage4(self, frontend_port: int) -> None: 854 logging.debug("Stage 4 initialization") 855 856 self.splash.setProgress(40) 857 858 self.sendDataMessageToThread( 859 self.thumbnail_deamon_controller, worker_id=None, 860 data=ThumbnailDaemonData(frontend_port=frontend_port) 861 ) 862 863 centralWidget = QWidget() 864 self.setCentralWidget(centralWidget) 865 866 self.temporalProximity = TemporalProximity(rapidApp=self, prefs=self.prefs) 867 868 # Respond to the user selecting / deslecting temporal proximity (timeline) cells: 869 self.temporalProximity.proximitySelectionHasChanged.connect( 870 self.updateThumbnailModelAfterProximityChange 871 ) 872 self.temporalProximity.temporalProximityView.proximitySelectionHasChanged.connect( 873 self.updateThumbnailModelAfterProximityChange 874 ) 875 876 # Setup notification system 877 try: 878 self.have_libnotify = Notify.init(_('Rapid Photo Downloader')) 879 except: 880 logging.error("Notification intialization problem") 881 self.have_libnotify = False 882 883 logging.debug("Locale directory: %s", raphodo.localedir) 884 885 # Initialise use of libgphoto2 886 logging.debug("Getting gphoto2 context") 887 try: 888 self.gp_context = gp.Context() 889 except: 890 logging.critical("Error getting gphoto2 context") 891 self.gp_context = None 892 893 logging.debug("Probing for valid mounts") 894 self.validMounts = ValidMounts(onlyExternalMounts=self.prefs.only_external_mounts) 895 896 logging.debug( 897 "Freedesktop.org thumbnails location: %s", get_fdo_cache_thumb_base_directory() 898 ) 899 900 logging.debug("Probing desktop environment") 901 desktop_env = get_desktop_environment() 902 903 self.unity_progress = False 904 self.desktop_launchers = [] 905 906 if have_unity: 907 logging.info("Unity LauncherEntry API installed") 908 launchers = ( 909 'net.damonlynch.rapid_photo_downloader.desktop', 910 ) 911 for launcher in launchers: 912 desktop_launcher = Unity.LauncherEntry.get_for_desktop_id(launcher) 913 if desktop_launcher is not None: 914 self.desktop_launchers.append(desktop_launcher) 915 self.unity_progress = True 916 917 if not self.desktop_launchers: 918 logging.warning( 919 "Desktop environment is Unity Launcher API compatible, but could not " 920 "find program's .desktop file" 921 ) 922 else: 923 logging.debug( 924 "Unity progress indicator found, using %s launcher(s)", 925 len(self.desktop_launchers) 926 ) 927 928 self.createPathViews() 929 930 self.createActions() 931 logging.debug("Laying out main window") 932 self.createMenus() 933 self.createLayoutAndButtons(centralWidget) 934 935 logging.debug("Have GIO module: %s", have_gio) 936 self.gvfsControlsMounts = gvfs_controls_mounts() and have_gio 937 if have_gio: 938 logging.debug("GVFS (GIO) controls mounts: %s", self.gvfsControlsMounts) 939 940 if not self.gvfsControlsMounts: 941 # Monitor when the user adds or removes a camera 942 self.cameraHotplug = CameraHotplug() 943 self.cameraHotplugThread = QThread() 944 self.cameraHotplugThread.started.connect(self.cameraHotplug.startMonitor) 945 self.cameraHotplug.moveToThread(self.cameraHotplugThread) 946 self.cameraHotplug.cameraAdded.connect(self.cameraAdded) 947 self.cameraHotplug.cameraRemoved.connect(self.cameraRemoved) 948 # Start the monitor only on the thread it will be running on 949 logging.debug("Starting camera hotplug monitor...") 950 QTimer.singleShot(0, self.cameraHotplugThread.start) 951 952 if self.gvfsControlsMounts: 953 # Gio.VolumeMonitor must be in the main thread, according to 954 # Gnome documentation 955 956 logging.debug("Starting GVolumeMonitor...") 957 self.gvolumeMonitor = GVolumeMonitor(self.validMounts) 958 logging.debug("...GVolumeMonitor started") 959 self.gvolumeMonitor.cameraUnmounted.connect(self.cameraUnmounted) 960 self.gvolumeMonitor.cameraMounted.connect(self.cameraMounted) 961 self.gvolumeMonitor.partitionMounted.connect(self.partitionMounted) 962 self.gvolumeMonitor.partitionUnmounted.connect(self.partitionUmounted) 963 self.gvolumeMonitor.volumeAddedNoAutomount.connect(self.noGVFSAutoMount) 964 self.gvolumeMonitor.cameraPossiblyRemoved.connect(self.cameraRemoved) 965 self.gvolumeMonitor.cameraVolumeAdded.connect(self.cameraVolumeAdded) 966 967 if version_check_disabled(): 968 logging.debug("Version check disabled") 969 else: 970 logging.debug("Starting version check") 971 self.newVersion = NewVersion(self) 972 self.newVersionThread = QThread() 973 self.newVersionThread.started.connect(self.newVersion.start) 974 self.newVersion.checkMade.connect(self.newVersionCheckMade) 975 self.newVersion.bytesDownloaded.connect(self.newVersionBytesDownloaded) 976 self.newVersion.fileDownloaded.connect(self.newVersionDownloaded) 977 self.reverifyDownloadedTar.connect(self.newVersion.reVerifyDownload) 978 self.newVersion.downloadSize.connect(self.newVersionDownloadSize) 979 self.newVersion.reverified.connect(self.installNewVersion) 980 self.newVersion.moveToThread(self.newVersionThread) 981 982 QTimer.singleShot(0, self.newVersionThread.start) 983 984 self.newVersionCheckDialog = NewVersionCheckDialog(self) 985 self.newVersionCheckDialog.finished.connect(self.newVersionCheckDialogFinished) 986 987 # if values set, indicates the latest version of the program, and the main 988 # download page on the Rapid Photo Downloader website 989 self.latest_version = None # type: version_details 990 self.latest_version_download_page = None # type: str 991 992 # Track the creation of temporary directories 993 self.temp_dirs_by_scan_id = {} 994 995 # Track the time a download commences - used in file renaming 996 self.download_start_datetime = None # type: Optional[datetime.datetime] 997 # The timestamp for when a download started / resumed after a pause 998 self.download_start_time = None # type: Optional[float] 999 1000 logging.debug("Starting download tracker") 1001 self.download_tracker = downloadtracker.DownloadTracker() 1002 1003 # Values used to display how much longer a download will take 1004 self.time_remaining = downloadtracker.TimeRemaining() 1005 self.time_check = downloadtracker.TimeCheck() 1006 1007 logging.debug("Setting up download update timer") 1008 self.dl_update_timer = QTimer(self) 1009 self.dl_update_timer.setInterval(constants.DownloadUpdateMilliseconds) 1010 self.dl_update_timer.timeout.connect(self.displayDownloadRunningInStatusBar) 1011 1012 # Offload process is used to offload work that could otherwise 1013 # cause this process and thus the GUI to become unresponsive 1014 logging.debug("Starting offload manager...") 1015 1016 self.offloadThread = QThread() 1017 self.offloadmq = OffloadManager(logging_port=self.logging_port) 1018 self.offloadThread.started.connect(self.offloadmq.run_sink) 1019 self.offloadmq.sinkStarted.connect(self.initStage5) 1020 self.offloadmq.message.connect(self.proximityGroupsGenerated) 1021 self.offloadmq.moveToThread(self.offloadThread) 1022 1023 QTimer.singleShot(0, self.offloadThread.start) 1024 1025 1026 @pyqtSlot() 1027 def initStage5(self) -> None: 1028 logging.debug("...offload manager started") 1029 self.sendStartToThread(self.offload_controller) 1030 1031 self.splash.setProgress(50) 1032 1033 self.folder_preview_manager = FolderPreviewManager( 1034 fsmodel=self.fileSystemModel, 1035 prefs=self.prefs, 1036 photoDestinationFSView=self.photoDestinationFSView, 1037 videoDestinationFSView=self.videoDestinationFSView, 1038 devices=self.devices, 1039 rapidApp=self 1040 ) 1041 1042 self.offloadmq.downloadFolders.connect(self.folder_preview_manager.folders_generated) 1043 1044 self.renameThread = QThread() 1045 self.renamemq = RenameMoveFileManager(logging_port=self.logging_port) 1046 self.renameThread.started.connect(self.renamemq.run_sink) 1047 self.renamemq.sinkStarted.connect(self.initStage6) 1048 self.renamemq.message.connect(self.fileRenamedAndMoved) 1049 self.renamemq.sequencesUpdate.connect(self.updateSequences) 1050 self.renamemq.renameProblems.connect(self.addErrorLogMessage) 1051 self.renamemq.moveToThread(self.renameThread) 1052 1053 logging.debug("Starting rename manager...") 1054 QTimer.singleShot(0, self.renameThread.start) 1055 1056 @pyqtSlot() 1057 def initStage6(self) -> None: 1058 logging.debug("...rename manager started") 1059 1060 self.splash.setProgress(60) 1061 1062 self.sendStartToThread(self.rename_controller) 1063 1064 # Setup the scan processes 1065 self.scanThread = QThread() 1066 self.scanmq = ScanManager(logging_port=self.logging_port) 1067 1068 self.scanThread.started.connect(self.scanmq.run_sink) 1069 self.scanmq.sinkStarted.connect(self.initStage7) 1070 self.scanmq.scannedFiles.connect(self.scanFilesReceived) 1071 self.scanmq.deviceError.connect(self.scanErrorReceived) 1072 self.scanmq.deviceDetails.connect(self.scanDeviceDetailsReceived) 1073 self.scanmq.scanProblems.connect(self.scanProblemsReceived) 1074 self.scanmq.workerFinished.connect(self.scanFinished) 1075 self.scanmq.fatalError.connect(self.scanFatalError) 1076 self.scanmq.cameraRemovedDuringScan.connect(self.cameraRemovedDuringScan) 1077 1078 self.scanmq.moveToThread(self.scanThread) 1079 1080 logging.debug("Starting scan manager...") 1081 QTimer.singleShot(0, self.scanThread.start) 1082 1083 @pyqtSlot() 1084 def initStage7(self) -> None: 1085 logging.debug("...scan manager started") 1086 1087 self.splash.setProgress(70) 1088 1089 # Setup the copyfiles process 1090 self.copyfilesThread = QThread() 1091 self.copyfilesmq = CopyFilesManager(logging_port=self.logging_port) 1092 1093 self.copyfilesThread.started.connect(self.copyfilesmq.run_sink) 1094 self.copyfilesmq.sinkStarted.connect(self.initStage8) 1095 self.copyfilesmq.message.connect(self.copyfilesDownloaded) 1096 self.copyfilesmq.bytesDownloaded.connect(self.copyfilesBytesDownloaded) 1097 self.copyfilesmq.tempDirs.connect(self.tempDirsReceivedFromCopyFiles) 1098 self.copyfilesmq.copyProblems.connect(self.copyfilesProblems) 1099 self.copyfilesmq.workerFinished.connect(self.copyfilesFinished) 1100 self.copyfilesmq.cameraRemoved.connect(self.cameraRemovedWhileCopyingFiles) 1101 1102 self.copyfilesmq.moveToThread(self.copyfilesThread) 1103 1104 logging.debug("Starting copy files manager...") 1105 QTimer.singleShot(0, self.copyfilesThread.start) 1106 1107 @pyqtSlot() 1108 def initStage8(self) -> None: 1109 logging.debug("...copy files manager started") 1110 1111 self.splash.setProgress(80) 1112 1113 self.backupThread = QThread() 1114 self.backupmq = BackupManager(logging_port=self.logging_port) 1115 1116 self.backupThread.started.connect(self.backupmq.run_sink) 1117 self.backupmq.sinkStarted.connect(self.initStage9) 1118 self.backupmq.message.connect(self.fileBackedUp) 1119 self.backupmq.bytesBackedUp.connect(self.backupFileBytesBackedUp) 1120 self.backupmq.backupProblems.connect(self.backupFileProblems) 1121 1122 self.backupmq.moveToThread(self.backupThread) 1123 1124 logging.debug("Starting backup manager ...") 1125 QTimer.singleShot(0, self.backupThread.start) 1126 1127 @pyqtSlot() 1128 def initStage9(self) -> None: 1129 logging.debug("...backup manager started") 1130 1131 self.splash.setProgress(90) 1132 1133 if self.prefs.backup_files: 1134 self.setupBackupDevices() 1135 else: 1136 self.download_tracker.set_no_backup_devices(0, 0) 1137 1138 settings = QSettings() 1139 settings.beginGroup("MainWindow") 1140 1141 self.proximityButton.setChecked(settings.value("proximityButtonPressed", True, bool)) 1142 self.proximityButtonClicked() 1143 1144 self.sourceButton.setChecked(settings.value("sourceButtonPressed", True, bool)) 1145 self.sourceButtonClicked() 1146 1147 # Default to displaying the destination panels if the value has never been 1148 # set 1149 index = settings.value("rightButtonPressed", 0, int) 1150 if index >= 0: 1151 try: 1152 button = self.rightSideButtonMapper[index] 1153 except ValueError: 1154 logging.error("Unexpected preference value for right side button") 1155 index = RightSideButton.destination 1156 button = self.rightSideButtonMapper[index] 1157 button.setChecked(True) 1158 self.setRightPanelsAndButtons(RightSideButton(index)) 1159 else: 1160 # For some unknown reason, under some sessions need to explicitly set this to False, 1161 # or else it shows and no button is pressed. 1162 self.rightPanels.setVisible(False) 1163 1164 settings.endGroup() 1165 1166 prefs_valid, msg = self.prefs.check_prefs_for_validity() 1167 1168 self.setupErrorLogWindow(settings=settings) 1169 1170 self.setDownloadCapabilities() 1171 self.searchForCameras(on_startup=True) 1172 self.setupNonCameraDevices(on_startup=True) 1173 self.splash.setProgress(100) 1174 self.setupManualPath(on_startup=True) 1175 self.updateSourceButton() 1176 self.displayMessageInStatusBar() 1177 1178 self.showMainWindow() 1179 1180 if not EXIFTOOL_VERSION and self.prefs.warn_broken_or_missing_libraries: 1181 message = _( 1182 '<b>ExifTool has a problem</b><br><br> ' 1183 'Rapid Photo Downloader uses ExifTool to get metadata from videos and photos. ' 1184 'The program will run without it, but installing it is <b>highly</b> recommended.' 1185 ) 1186 warning = RememberThisDialog( 1187 message=message, 1188 icon=':/rapid-photo-downloader.svg', 1189 remember=RememberThisMessage.do_not_warn_again_about_missing_libraries, 1190 parent=self, 1191 buttons=RememberThisButtons.ok, 1192 title=_('Problem with ExifTool') 1193 ) 1194 1195 warning.exec_() 1196 if warning.remember: 1197 self.prefs.warn_broken_or_missing_libraries = False 1198 1199 if libmediainfo_missing and self.prefs.warn_broken_or_missing_libraries: 1200 message = _( 1201 '<b>The library libmediainfo appears to be missing</b><br><br> ' 1202 'Rapid Photo Downloader uses libmediainfo to get the date and time a video was ' 1203 'shot. The program will run without it, but installing it is recommended.' 1204 ) 1205 1206 warning = RememberThisDialog( 1207 message=message, 1208 icon=':/rapid-photo-downloader.svg', 1209 remember=RememberThisMessage.do_not_warn_again_about_missing_libraries, 1210 parent=self, 1211 buttons=RememberThisButtons.ok, 1212 title=_('Problem with libmediainfo') 1213 ) 1214 1215 warning.exec_() 1216 if warning.remember: 1217 self.prefs.warn_broken_or_missing_libraries = False 1218 1219 self.tip = didyouknow.DidYouKnowDialog(self.prefs, self) 1220 if self.prefs.did_you_know_on_startup: 1221 self.tip.activate() 1222 1223 if not prefs_valid: 1224 self.notifyPrefsAreInvalid(details=msg) 1225 else: 1226 self.checkForNewVersionRequest.emit() 1227 1228 logging.debug("Completed stage 9 initializing main window") 1229 1230 def showMainWindow(self) -> None: 1231 if not self.isVisible(): 1232 self.splash.finish(self) 1233 1234 self.window_show_requested_time = datetime.datetime.now() 1235 self.show() 1236 if self.deferred_resize_and_move_until_after_show: 1237 self.resizeAndMoveMainWindow() 1238 1239 self.errorLog.setVisible(self.errorLogAct.isChecked()) 1240 1241 def mapModel(self, scan_id: int) -> DeviceModel: 1242 """ 1243 Map a scan_id onto Devices' or This Computer's device model. 1244 :param scan_id: scan id of the device 1245 :return: relevant device model 1246 """ 1247 1248 return self._mapModel[self.devices[scan_id].device_type] 1249 1250 def mapView(self, scan_id: int) -> DeviceView: 1251 """ 1252 Map a scan_id onto Devices' or This Computer's device view. 1253 :param scan_id: scan id of the device 1254 :return: relevant device view 1255 """ 1256 1257 return self._mapView[self.devices[scan_id].device_type] 1258 1259 def setupErrorLogWindow(self, settings: QSettings) -> None: 1260 """ 1261 Creates, moves and resizes error log window, but does not show it. 1262 """ 1263 1264 default_x = self.pos().x() 1265 default_y = self.pos().y() 1266 default_width = int(self.size().width() * 0.5) 1267 default_height = int(self.size().height() * 0.5) 1268 1269 settings.beginGroup("ErrorLog") 1270 pos = settings.value("windowPosition", QPoint(default_x, default_y)) 1271 size = settings.value("windowSize", QSize(default_width, default_height)) 1272 visible = settings.value('visible', False, type=bool) 1273 settings.endGroup() 1274 1275 self.errorLog = ErrorReport(rapidApp=self) 1276 self.errorLogAct.setChecked(visible) 1277 self.errorLog.move(pos) 1278 self.errorLog.resize(size) 1279 self.errorLog.finished.connect(self.setErrorLogAct) 1280 self.errorLog.dialogShown.connect(self.setErrorLogAct) 1281 self.errorLog.dialogActivated.connect(self.errorsPending.reset) 1282 self.errorsPending.clicked.connect(self.errorLog.activate) 1283 1284 def resizeAndMoveMainWindow(self) -> None: 1285 """ 1286 Load window settings from last application run, after validating they 1287 will fit on the screen 1288 """ 1289 1290 if self.deferred_resize_and_move_until_after_show: 1291 logging.debug("Resizing and moving main window after it was deferred") 1292 1293 assert self.isVisible() 1294 1295 self.screen = self.windowHandle().screen() # type: QScreen 1296 1297 assert self.screen is not None 1298 1299 available = self.screen.availableGeometry() # type: QRect 1300 display = self.screen.size() # type: QSize 1301 1302 default_width = max(960, available.width() // 2) 1303 default_width = min(default_width, available.width()) 1304 default_x = display.width() - default_width 1305 default_height = int(available.height() * .85) 1306 default_y = display.height() - default_height 1307 1308 logging.debug( 1309 "Available screen geometry: %sx%s on %sx%s display. Default window size: %sx%s.", 1310 available.width(), available.height(), display.width(), display.height(), 1311 default_width, default_height 1312 ) 1313 1314 settings = QSettings() 1315 settings.beginGroup("MainWindow") 1316 1317 try: 1318 scaling = self.devicePixelRatioF() 1319 except AttributeError: 1320 scaling = self.devicePixelRatio() 1321 1322 logging.info("%s", self.scaling_set_message) 1323 logging.info('Desktop scaling set to %s', scaling) 1324 logging.debug("%s", self.fractional_scaling_message) 1325 1326 maximized = settings.value("maximized", False, type=bool) 1327 logging.debug("Window maximized when last run: %s", maximized) 1328 1329 # Even if window is maximized, must restore saved window size and position for when the user 1330 # unmaximizes the window 1331 1332 pos = settings.value("windowPosition", QPoint(default_x, default_y)) 1333 size = settings.value("windowSize", QSize(default_width, default_height)) 1334 settings.endGroup() 1335 1336 was_valid, validatedSize = validateWindowSizeLimit(available.size(), size) 1337 if not was_valid: 1338 logging.debug( 1339 "Windows size %sx%s was invalid. Value was reset to %sx%s.", 1340 size.width(), size.height(), validatedSize.width(), validatedSize.height() 1341 ) 1342 logging.debug( 1343 "Window size: %sx%s", validatedSize.width(), validatedSize.height() 1344 ) 1345 was_valid, validatedPos = validateWindowPosition(pos, available.size(), validatedSize) 1346 if not was_valid: 1347 logging.debug("Window position %s,%s was invalid", pos.x(), pos.y()) 1348 1349 self.resize(validatedSize) 1350 self.move(validatedPos) 1351 1352 if maximized: 1353 logging.debug("Setting window to maximized state") 1354 self.setWindowState(Qt.WindowMaximized) 1355 1356 def readWindowSettings(self, app: 'QtSingleApplication'): 1357 self.deferred_resize_and_move_until_after_show = False 1358 1359 # Calculate window sizes 1360 if self.screen is None: 1361 self.deferred_resize_and_move_until_after_show = True 1362 else: 1363 self.resizeAndMoveMainWindow() 1364 1365 def writeWindowSettings(self): 1366 logging.debug("Writing window settings") 1367 settings = QSettings() 1368 settings.beginGroup("MainWindow") 1369 windowPos = self.pos() + self.windowPositionDelta 1370 if windowPos.x() < 0: 1371 windowPos.setX(0) 1372 if windowPos.y() < 0: 1373 windowPos.setY(0) 1374 settings.setValue("windowPosition", windowPos) 1375 settings.setValue("windowSize", self.size()) 1376 # Alternative to position and size: 1377 # settings.setValue("geometry", self.saveGeometry()) 1378 state = self.windowState() 1379 maximized = bool(state & Qt.WindowMaximized) 1380 settings.setValue("maximized", maximized) 1381 settings.setValue("centerSplitterSizes", self.centerSplitter.saveState()) 1382 settings.setValue("sourceButtonPressed", self.sourceButton.isChecked()) 1383 settings.setValue("rightButtonPressed", self.rightSideButtonPressed()) 1384 settings.setValue("proximityButtonPressed", self.proximityButton.isChecked()) 1385 settings.setValue("leftPanelSplitterSizes", self.leftPanelSplitter.saveState()) 1386 settings.setValue("rightPanelSplitterSizes", self.rightPanelSplitter.saveState()) 1387 settings.endGroup() 1388 1389 settings.beginGroup("ErrorLog") 1390 settings.setValue("windowPosition", self.errorLog.pos()) 1391 settings.setValue("windowSize", self.errorLog.size()) 1392 settings.setValue('visible', self.errorLog.isVisible()) 1393 settings.endGroup() 1394 1395 def moveEvent(self, event: QMoveEvent) -> None: 1396 """ 1397 Handle quirks in window positioning. 1398 1399 X11 has a feature where the window managager can decorate the 1400 windows. A side effect of this is that the position returned by 1401 window.pos() can be different between restoring the position 1402 from the settings, and saving the position at application exit, even if 1403 the user never moved the window. 1404 """ 1405 1406 super().moveEvent(event) 1407 self.window_move_triggered_count += 1 1408 1409 if self.window_show_requested_time is None: 1410 pass 1411 # self.windowPositionDelta = QPoint(0, 0) 1412 elif self.window_move_triggered_count == 2: 1413 if (datetime.datetime.now() - self.window_show_requested_time).total_seconds() < 1.0: 1414 self.windowPositionDelta = event.oldPos() - self.pos() 1415 logging.debug("Window position quirk delta: %s", self.windowPositionDelta) 1416 self.window_show_requested_time = None 1417 1418 def setupWindow(self) -> None: 1419 status = self.statusBar() 1420 status.setStyleSheet("QStatusBar::item { border: 0px solid black }; ") 1421 self.downloadProgressBar = QProgressBar() 1422 self.downloadProgressBar.setMaximumWidth(QFontMetrics(QFont()).height() * 9) 1423 self.errorsPending = SpeechBubble(self) 1424 self.errorsPending.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum) 1425 status.addPermanentWidget(self.errorsPending) 1426 status.addPermanentWidget(self.downloadProgressBar, 1) 1427 1428 def anyFilesSelected(self) -> bool: 1429 """ 1430 :return: True if any files are selected 1431 """ 1432 1433 return self.thumbnailView.selectionModel().hasSelection() 1434 1435 def applyJobCode(self, job_code: str) -> None: 1436 """ 1437 Apply job code to all selected photos/videos. 1438 1439 :param job_code: job code to apply 1440 """ 1441 1442 delegate = self.thumbnailView.itemDelegate() # type: ThumbnailDelegate 1443 delegate.applyJobCode(job_code=job_code) 1444 1445 @pyqtSlot(bool, version_details, version_details, str, bool, bool, bool) 1446 def newVersionCheckMade(self, success: bool, 1447 stable_version: version_details, 1448 dev_version: version_details, 1449 download_page: str, 1450 no_upgrade: bool, 1451 pip_install: bool, 1452 is_venv: bool) -> None: 1453 """ 1454 Respond to a version check, either initiated at program startup, or from the 1455 application's main menu. 1456 1457 If the check was initiated at program startup, then the new version dialog box 1458 will not be showing. 1459 1460 :param success: whether the version check was successful or not 1461 :param stable_version: latest stable version 1462 :param dev_version: latest development version 1463 :param download_page: url of the download page on the Rapid 1464 Photo Downloader website 1465 :param no_upgrade: if True, don't offer to do an inplace upgrade 1466 :param pip_install: whether pip was used to install this 1467 program version 1468 :param is_venv: whether the program is running in a python virtual 1469 environment 1470 """ 1471 1472 if success: 1473 self.latest_version = None 1474 current_version = pkgr.parse_version(__about__.__version__) 1475 1476 check_dev_version = (current_version.is_prerelease or 1477 self.prefs.include_development_release) 1478 1479 if current_version < stable_version.version: 1480 self.latest_version = stable_version 1481 1482 if check_dev_version and ( 1483 current_version < dev_version.version or 1484 current_version < stable_version.version 1485 ): 1486 if dev_version.version > stable_version.version: 1487 self.latest_version = dev_version 1488 else: 1489 self.latest_version = stable_version 1490 1491 if ( 1492 self.latest_version is not None and str(self.latest_version.version) not in 1493 self.prefs.ignore_versions): 1494 1495 version = str(self.latest_version.version) 1496 changelog_url = self.latest_version.changelog_url 1497 1498 if pip_install: 1499 logging.debug("Installation performed via pip") 1500 if is_venv: 1501 logging.info( 1502 "Cannot use in-program update to upgrade program from within virtual " 1503 "environment" 1504 ) 1505 state = CheckNewVersionDialogState.open_website 1506 elif no_upgrade: 1507 logging.info("Cannot perform in-place upgrade to this version") 1508 state = CheckNewVersionDialogState.open_website 1509 else: 1510 download_page = None 1511 state = CheckNewVersionDialogState.prompt_for_download 1512 else: 1513 logging.debug("Installation not performed via pip") 1514 state = CheckNewVersionDialogState.open_website 1515 1516 self.latest_version_download_page = download_page 1517 1518 self.newVersionCheckDialog.displayUserMessage( 1519 new_state=state, 1520 version=version, 1521 download_page=download_page, 1522 changelog_url=changelog_url 1523 ) 1524 if not self.newVersionCheckDialog.isVisible(): 1525 self.newVersionCheckDialog.show() 1526 1527 elif self.newVersionCheckDialog.isVisible(): 1528 self.newVersionCheckDialog.displayUserMessage( 1529 CheckNewVersionDialogState.have_latest_version) 1530 1531 elif self.newVersionCheckDialog.isVisible(): 1532 # Failed to reach update server 1533 self.newVersionCheckDialog.displayUserMessage( 1534 CheckNewVersionDialogState.failed_to_contact) 1535 1536 @pyqtSlot(int) 1537 def newVersionCheckDialogFinished(self, result: int) -> None: 1538 current_state = self.newVersionCheckDialog.current_state 1539 if current_state in ( 1540 CheckNewVersionDialogState.prompt_for_download, 1541 CheckNewVersionDialogState.open_website): 1542 if self.newVersionCheckDialog.dialog_detailed_result == \ 1543 CheckNewVersionDialogResult.skip: 1544 version = str(self.latest_version.version) 1545 logging.info( 1546 "Adding version %s to the list of program versions to ignore", version 1547 ) 1548 self.prefs.add_list_value(key='ignore_versions', value=version) 1549 elif self.newVersionCheckDialog.dialog_detailed_result == \ 1550 CheckNewVersionDialogResult.open_website: 1551 webbrowser.open_new_tab(self.latest_version_download_page) 1552 elif self.newVersionCheckDialog.dialog_detailed_result == \ 1553 CheckNewVersionDialogResult.download: 1554 url = self.latest_version.url 1555 md5 = self.latest_version.md5 1556 self.downloadNewVersionRequest.emit(url, md5) 1557 self.downloadNewVersionDialog = DownloadNewVersionDialog(parent=self) 1558 self.downloadNewVersionDialog.rejected.connect(self.newVersionDownloadCancelled) 1559 self.downloadNewVersionDialog.show() 1560 1561 @pyqtSlot('PyQt_PyObject') 1562 def newVersionBytesDownloaded(self, bytes_downloaded: int) -> None: 1563 if self.downloadNewVersionDialog.isVisible(): 1564 self.downloadNewVersionDialog.updateProgress(bytes_downloaded) 1565 1566 @pyqtSlot('PyQt_PyObject') 1567 def newVersionDownloadSize(self, download_size: int) -> None: 1568 if self.downloadNewVersionDialog.isVisible(): 1569 self.downloadNewVersionDialog.setDownloadSize(download_size) 1570 1571 @pyqtSlot(str, bool) 1572 def newVersionDownloaded(self, path: str, download_cancelled: bool) -> None: 1573 self.downloadNewVersionDialog.accept() 1574 if not path and not download_cancelled: 1575 msgBox = QMessageBox(parent=self) 1576 msgBox.setIcon(QMessageBox.Warning) 1577 msgBox.setWindowTitle(_("Download failed")) 1578 msgBox.setText( 1579 _('Sorry, the download of the new version of Rapid Photo Downloader failed.') 1580 ) 1581 msgBox.exec_() 1582 elif path: 1583 logging.info("New program version downloaded to %s", path) 1584 1585 message = _( 1586 'The new version was successfully downloaded. Do you want to ' 1587 'close Rapid Photo Downloader and install it now?' 1588 ) 1589 msgBox = QMessageBox(parent=self) 1590 msgBox.setWindowTitle(_('Update Rapid Photo Downloader')) 1591 msgBox.setText(message) 1592 msgBox.setIcon(QMessageBox.Question) 1593 msgBox.setStandardButtons(QMessageBox.Cancel) 1594 installButton = msgBox.addButton(_('Install'), QMessageBox.AcceptRole) 1595 msgBox.setDefaultButton(installButton) 1596 if msgBox.exec_() == QMessageBox.AcceptRole: 1597 self.reverifyDownloadedTar.emit(path) 1598 else: 1599 # extract the install.py script and move it to the correct location 1600 # for testing: 1601 # path = '/home/damon/rapid090a7/dist/rapid-photo-downloader-0.9.0a7.tar.gz' 1602 extract_file_from_tar(full_tar_path=path, member_filename='install.py') 1603 installer_dir = os.path.dirname(path) 1604 if self.file_manager: 1605 uri = pathname2url(path) 1606 cmd = '{} {}'.format(self.file_manager, uri) 1607 logging.debug("Launching: %s", cmd) 1608 args = shlex.split(cmd) 1609 subprocess.Popen(args) 1610 else: 1611 msgBox = QMessageBox(parent=self) 1612 msgBox.setWindowTitle(_('New version saved')) 1613 message = _( 1614 'The tar file and installer script are saved at:\n\n %s' 1615 ) % installer_dir 1616 msgBox.setText(message) 1617 msgBox.setIcon(QMessageBox.Information) 1618 msgBox.exec_() 1619 1620 @pyqtSlot(bool, str) 1621 def installNewVersion(self, reverified: bool, full_tar_path: str) -> None: 1622 """ 1623 Launch script to install new version of Rapid Photo Downloader 1624 via upgrade.py. 1625 :param reverified: whether file has been reverified or not 1626 :param full_tar_path: path to the tarball 1627 """ 1628 if not reverified: 1629 msgBox = QMessageBox(parent=self) 1630 msgBox.setIcon(QMessageBox.Warning) 1631 msgBox.setWindowTitle(_("Upgrade failed")) 1632 msgBox.setText( 1633 _( 1634 'Sorry, upgrading Rapid Photo Downloader failed because there was ' 1635 'an error opening the installer.' 1636 ) 1637 ) 1638 msgBox.exec_() 1639 else: 1640 # for testing: 1641 # full_tar_path = '/home/damon/rapid090a7/dist/rapid-photo-downloader-0.9.0a7.tar.gz' 1642 upgrade_py = 'upgrade.py' 1643 installer_dir = os.path.dirname(full_tar_path) 1644 if extract_file_from_tar(full_tar_path, upgrade_py): 1645 upgrade_script = os.path.join(installer_dir, upgrade_py) 1646 cmd = shlex.split('{} {} {}'.format(sys.executable, upgrade_script, full_tar_path)) 1647 subprocess.Popen(cmd) 1648 self.quit() 1649 1650 @pyqtSlot() 1651 def newVersionDownloadCancelled(self) -> None: 1652 logging.info("Download of new program version cancelled") 1653 self.new_version_controller.send(b'STOP') 1654 1655 def updateProgressBarState(self, thumbnail_generated: bool=None) -> None: 1656 """ 1657 Updates the state of the ProgessBar in the main window's lower right corner. 1658 1659 If any device is downloading, the progress bar displays 1660 download progress. 1661 1662 Else, if any device is thumbnailing, the progress bar 1663 displays thumbnailing progress. 1664 1665 Else, if any device is scanning, the progress bar shows a busy status. 1666 1667 Else, the progress bar is set to an idle status. 1668 """ 1669 1670 if self.downloadIsRunning(): 1671 logging.debug("Setting progress bar to show download progress") 1672 self.downloadProgressBar.setMaximum(100) 1673 return 1674 1675 if self.unity_progress: 1676 for launcher in self.desktop_launchers: 1677 launcher.set_property('progress_visible', False) 1678 1679 if len(self.devices.thumbnailing): 1680 if self.downloadProgressBar.maximum() != self.thumbnailModel.total_thumbs_to_generate: 1681 logging.debug( 1682 "Setting progress bar maximum to %s", 1683 self.thumbnailModel.total_thumbs_to_generate 1684 ) 1685 self.downloadProgressBar.setMaximum(self.thumbnailModel.total_thumbs_to_generate) 1686 if thumbnail_generated: 1687 self.downloadProgressBar.setValue(self.thumbnailModel.thumbnails_generated) 1688 elif len(self.devices.scanning): 1689 logging.debug("Setting progress bar to show scanning activity") 1690 self.downloadProgressBar.setMaximum(0) 1691 else: 1692 logging.debug("Resetting progress bar") 1693 self.downloadProgressBar.reset() 1694 self.downloadProgressBar.setMaximum(100) 1695 1696 def updateSourceButton(self) -> None: 1697 text, icon = self.devices.get_main_window_display_name_and_icon() 1698 self.sourceButton.setText(addPushButtonLabelSpacer(text)) 1699 self.sourceButton.setIcon(icon) 1700 1701 def setLeftPanelVisibility(self) -> None: 1702 self.leftPanelSplitter.setVisible( 1703 self.sourceButton.isChecked() or self.proximityButton.isChecked() 1704 ) 1705 1706 def setRightPanelsAndButtons(self, buttonPressed: RightSideButton) -> None: 1707 """ 1708 Set visibility of right panel based on which right bar buttons 1709 is pressed, and ensure only one button is pressed at any one time. 1710 1711 Cannot use exclusive QButtonGroup because with that, one button needs to be 1712 pressed. We allow no button to be pressed. 1713 """ 1714 1715 widget = self.rightSideButtonMapper[buttonPressed] # type: RotatedButton 1716 1717 if widget.isChecked(): 1718 self.rightPanels.setVisible(True) 1719 for button in RightSideButton: 1720 if button == buttonPressed: 1721 self.rightPanels.setCurrentIndex(buttonPressed.value) 1722 else: 1723 self.rightSideButtonMapper[button].setChecked(False) 1724 else: 1725 self.rightPanels.setVisible(False) 1726 1727 def rightSideButtonPressed(self) -> int: 1728 """ 1729 Determine which right side button is currently pressed, if any. 1730 :return: -1 if no button is pressed, else the index into 1731 RightSideButton 1732 """ 1733 1734 for button in RightSideButton: 1735 widget = self.rightSideButtonMapper[button] 1736 if widget.isChecked(): 1737 return int(button.value) 1738 return -1 1739 1740 @pyqtSlot() 1741 def sourceButtonClicked(self) -> None: 1742 self.deviceToggleView.setVisible(self.sourceButton.isChecked()) 1743 self.thisComputerToggleView.setVisible(self.sourceButton.isChecked()) 1744 self.setLeftPanelVisibility() 1745 1746 @pyqtSlot() 1747 def destinationButtonClicked(self) -> None: 1748 self.setRightPanelsAndButtons(RightSideButton.destination) 1749 1750 @pyqtSlot() 1751 def renameButtonClicked(self) -> None: 1752 self.setRightPanelsAndButtons(RightSideButton.rename) 1753 1754 @pyqtSlot() 1755 def backupButtonClicked(self) -> None: 1756 self.setRightPanelsAndButtons(RightSideButton.backup) 1757 1758 @pyqtSlot() 1759 def jobcodButtonClicked(self) -> None: 1760 self.jobCodePanel.updateDefaultMessage() 1761 self.setRightPanelsAndButtons(RightSideButton.jobcode) 1762 1763 @pyqtSlot() 1764 def proximityButtonClicked(self) -> None: 1765 self.temporalProximity.setVisible(self.proximityButton.isChecked()) 1766 self.setLeftPanelVisibility() 1767 self.adjustLeftPanelSliderHandles() 1768 1769 def adjustLeftPanelSliderHandles(self): 1770 """ 1771 Move left panel splitter handles in response to devices / this computer 1772 changes. 1773 """ 1774 1775 preferred_devices_height = self.deviceToggleView.minimumHeight() 1776 min_this_computer_height = self.thisComputerToggleView.minimumHeight() 1777 1778 if self.thisComputerToggleView.on(): 1779 this_computer_height = max( 1780 min_this_computer_height, self.centerSplitter.height() - preferred_devices_height 1781 ) 1782 else: 1783 this_computer_height = min_this_computer_height 1784 1785 if self.proximityButton.isChecked(): 1786 if not self.thisComputerToggleView.on(): 1787 proximity_height = ( 1788 self.centerSplitter.height() - this_computer_height - preferred_devices_height 1789 ) 1790 else: 1791 proximity_height = this_computer_height // 2 1792 this_computer_height = this_computer_height // 2 1793 else: 1794 proximity_height = 0 1795 self.leftPanelSplitter.setSizes( 1796 [preferred_devices_height, this_computer_height, proximity_height] 1797 ) 1798 1799 @pyqtSlot(int) 1800 def showComboChanged(self, index: int) -> None: 1801 self.sortComboChanged(index=-1) 1802 self.thumbnailModel.updateAllDeviceDisplayCheckMarks() 1803 1804 def showOnlyNewFiles(self) -> bool: 1805 """ 1806 User can use combo switch to show only so-called "hew" files, i.e. files that 1807 have not been previously downloaded. 1808 1809 :return: True if only new files are shown 1810 """ 1811 return self.showCombo.currentData() == Show.new_only 1812 1813 @pyqtSlot(int) 1814 def sortComboChanged(self, index: int) -> None: 1815 sort = self.sortCombo.currentData() 1816 order = self.sortOrder.currentData() 1817 show = self.showCombo.currentData() 1818 self.thumbnailModel.setFileSort(sort=sort, order=order, show=show) 1819 1820 @pyqtSlot(int) 1821 def sortOrderChanged(self, index: int) -> None: 1822 self.sortComboChanged(index=-1) 1823 1824 @pyqtSlot(int) 1825 def selectAllPhotosCheckboxChanged(self, state: int) -> None: 1826 select_all = state == Qt.Checked 1827 self.thumbnailModel.selectAll(select_all=select_all, file_type=FileType.photo) 1828 1829 @pyqtSlot(int) 1830 def selectAllVideosCheckboxChanged(self, state: int) -> None: 1831 select_all = state == Qt.Checked 1832 self.thumbnailModel.selectAll(select_all=select_all, file_type=FileType.video) 1833 1834 @pyqtSlot() 1835 def setErrorLogAct(self) -> None: 1836 self.errorLogAct.setChecked(self.errorLog.isVisible()) 1837 1838 def createActions(self) -> None: 1839 self.downloadAct = QAction( 1840 _("Download"), self, shortcut="Ctrl+Return", triggered=self.doDownloadAction 1841 ) 1842 1843 self.refreshAct = QAction( 1844 _("&Refresh..."), self, shortcut="Ctrl+R", triggered=self.doRefreshAction 1845 ) 1846 1847 self.preferencesAct = QAction( 1848 _("&Preferences"), self, shortcut="Ctrl+P", triggered=self.doPreferencesAction 1849 ) 1850 1851 self.quitAct = QAction( 1852 _("&Quit"), self, shortcut="Ctrl+Q", triggered=self.close 1853 ) 1854 1855 self.errorLogAct = QAction( 1856 _("Error &Reports"), self, enabled=True, checkable=True, triggered=self.doErrorLogAction 1857 ) 1858 1859 self.clearDownloadsAct = QAction( 1860 _("Clear Completed Downloads"), self, triggered=self.doClearDownloadsAction 1861 ) 1862 1863 self.helpAct = QAction( 1864 _("Get Help Online..."), self, shortcut="F1", triggered=self.doHelpAction 1865 ) 1866 1867 self.didYouKnowAct = QAction( 1868 _("&Tip of the Day..."), self, triggered=self.doDidYouKnowAction 1869 ) 1870 1871 self.reportProblemAct = QAction( 1872 _("Report a Problem..."), self, triggered=self.doReportProblemAction 1873 ) 1874 1875 self.makeDonationAct = QAction( 1876 _("Make a Donation..."), self, triggered=self.doMakeDonationAction 1877 ) 1878 1879 self.translateApplicationAct = QAction( 1880 _("Translate this Application..."), self, triggered=self.doTranslateApplicationAction 1881 ) 1882 1883 self.aboutAct = QAction( 1884 _("&About..."), self, triggered=self.doAboutAction 1885 ) 1886 1887 self.newVersionAct = QAction( 1888 _("Check for Updates..."), self, triggered=self.doCheckForNewVersion 1889 ) 1890 1891 def createLayoutAndButtons(self, centralWidget) -> None: 1892 """ 1893 Create widgets used to display the GUI. 1894 :param centralWidget: the widget in which to layout the new widgets 1895 """ 1896 1897 settings = QSettings() 1898 settings.beginGroup("MainWindow") 1899 1900 verticalLayout = QVBoxLayout() 1901 verticalLayout.setContentsMargins(0, 0, 0, 0) 1902 centralWidget.setLayout(verticalLayout) 1903 self.standard_spacing = verticalLayout.spacing() 1904 1905 self.topBar = self.createTopBar() 1906 verticalLayout.addLayout(self.topBar) 1907 1908 centralLayout = QHBoxLayout() 1909 centralLayout.setContentsMargins(0, 0, 0, 0) 1910 1911 self.leftBar = self.createLeftBar() 1912 self.rightBar = self.createRightBar() 1913 1914 self.createCenterPanels() 1915 self.createDeviceThisComputerViews() 1916 self.createDestinationViews() 1917 self.createRenamePanels() 1918 self.createJobCodePanel() 1919 self.createBackupPanel() 1920 self.configureCenterPanels(settings) 1921 self.createBottomControls() 1922 1923 centralLayout.addLayout(self.leftBar) 1924 centralLayout.addWidget(self.centerSplitter) 1925 centralLayout.addLayout(self.rightBar) 1926 1927 verticalLayout.addLayout(centralLayout) 1928 verticalLayout.addWidget(self.thumbnailControl) 1929 1930 def createTopBar(self) -> QHBoxLayout: 1931 topBar = QHBoxLayout() 1932 menu_margin = int(QFontMetrics(QFont()).height() / 3) 1933 topBar.setContentsMargins(0, 0, menu_margin, 0) 1934 1935 topBar.setSpacing(int(QFontMetrics(QFont()).height() / 2)) 1936 1937 self.sourceButton = TopPushButton( 1938 addPushButtonLabelSpacer(_('Select Source')), 1939 parent=self, extra_top=self.standard_spacing 1940 ) 1941 self.sourceButton.clicked.connect(self.sourceButtonClicked) 1942 1943 vlayout = QVBoxLayout() 1944 vlayout.setContentsMargins(0, 0, 0, 0) 1945 vlayout.setSpacing(0) 1946 vlayout.addSpacing(self.standard_spacing) 1947 hlayout = QHBoxLayout() 1948 hlayout.setContentsMargins(0, 0, 0, 0) 1949 hlayout.setSpacing(menu_margin) 1950 vlayout.addLayout(hlayout) 1951 1952 self.downloadButton = DownloadButton(self.downloadAct.text(), parent=self) 1953 self.downloadButton.addAction(self.downloadAct) 1954 self.downloadButton.setDefault(True) 1955 self.downloadButton.clicked.connect(self.downloadButtonClicked) 1956 1957 self.menuButton.setIconSize( 1958 QSize(self.sourceButton.top_row_icon_size, self.sourceButton.top_row_icon_size) 1959 ) 1960 1961 topBar.addWidget(self.sourceButton) 1962 topBar.addStretch() 1963 topBar.addLayout(vlayout) 1964 hlayout.addWidget(self.downloadButton) 1965 hlayout.addWidget(self.menuButton) 1966 return topBar 1967 1968 def createLeftBar(self) -> QVBoxLayout: 1969 leftBar = QVBoxLayout() 1970 leftBar.setContentsMargins(0, 0, 0, 0) 1971 1972 self.proximityButton = RotatedButton(_('Timeline'), RotatedButton.leftSide) 1973 self.proximityButton.clicked.connect(self.proximityButtonClicked) 1974 leftBar.addWidget(self.proximityButton) 1975 leftBar.addStretch() 1976 return leftBar 1977 1978 def createRightBar(self) -> QVBoxLayout: 1979 rightBar = QVBoxLayout() 1980 rightBar.setContentsMargins(0, 0, 0, 0) 1981 1982 self.destinationButton = RotatedButton(_('Destination'), RotatedButton.rightSide) 1983 self.renameButton = RotatedButton(_('Rename'), RotatedButton.rightSide) 1984 self.jobcodeButton = RotatedButton(_('Job Code'), RotatedButton.rightSide) 1985 self.backupButton = RotatedButton(_('Back Up'), RotatedButton.rightSide) 1986 1987 self.destinationButton.clicked.connect(self.destinationButtonClicked) 1988 self.renameButton.clicked.connect(self.renameButtonClicked) 1989 self.jobcodeButton.clicked.connect(self.jobcodButtonClicked) 1990 self.backupButton.clicked.connect(self.backupButtonClicked) 1991 1992 self.rightSideButtonMapper = { 1993 RightSideButton.destination: self.destinationButton, 1994 RightSideButton.rename: self.renameButton, 1995 RightSideButton.jobcode: self.jobcodeButton, 1996 RightSideButton.backup: self.backupButton 1997 } 1998 1999 rightBar.addWidget(self.destinationButton) 2000 rightBar.addWidget(self.renameButton) 2001 rightBar.addWidget(self.jobcodeButton) 2002 rightBar.addWidget(self.backupButton) 2003 rightBar.addStretch() 2004 return rightBar 2005 2006 def createPathViews(self) -> None: 2007 self.deviceView = DeviceView(rapidApp=self) 2008 self.deviceModel = DeviceModel(self, "Devices") 2009 self.deviceView.setModel(self.deviceModel) 2010 self.deviceView.setItemDelegate(DeviceDelegate(rapidApp=self)) 2011 2012 # This computer is any local path 2013 self.thisComputerView = DeviceView(rapidApp=self) 2014 self.thisComputerModel = DeviceModel(self, "This Computer") 2015 self.thisComputerView.setModel(self.thisComputerModel) 2016 self.thisComputerView.setItemDelegate(DeviceDelegate(self)) 2017 2018 # Map different device types onto their appropriate view and model 2019 self._mapModel = { 2020 DeviceType.path: self.thisComputerModel, 2021 DeviceType.camera: self.deviceModel, 2022 DeviceType.volume: self.deviceModel 2023 } 2024 self._mapView = { 2025 DeviceType.path: self.thisComputerView, 2026 DeviceType.camera: self.deviceView, 2027 DeviceType.volume: self.deviceView 2028 } 2029 2030 # Be cautious: validate paths. The settings file can alwasy be edited by hand, and 2031 # the user can set it to whatever value they want using the command line options. 2032 logging.debug("Checking path validity") 2033 this_computer_sf = validate_source_folder(self.prefs.this_computer_path) 2034 if this_computer_sf.valid: 2035 if this_computer_sf.absolute_path != self.prefs.this_computer_path: 2036 self.prefs.this_computer_path = this_computer_sf.absolute_path 2037 elif self.prefs.this_computer_source and self.prefs.this_computer_path != '': 2038 logging.warning( 2039 "Ignoring invalid 'This Computer' path: %s", self.prefs.this_computer_path 2040 ) 2041 self.prefs.this_computer_path = '' 2042 2043 photo_df = validate_download_folder(self.prefs.photo_download_folder) 2044 if photo_df.valid: 2045 if photo_df.absolute_path != self.prefs.photo_download_folder: 2046 self.prefs.photo_download_folder = photo_df.absolute_path 2047 else: 2048 if self.prefs.photo_download_folder: 2049 logging.error( 2050 "Ignoring invalid Photo Destination path: %s", self.prefs.photo_download_folder 2051 ) 2052 self.prefs.photo_download_folder = '' 2053 2054 video_df = validate_download_folder(self.prefs.video_download_folder) 2055 if video_df.valid: 2056 if video_df.absolute_path != self.prefs.video_download_folder: 2057 self.prefs.video_download_folder = video_df.absolute_path 2058 else: 2059 if self.prefs.video_download_folder: 2060 logging.error( 2061 "Ignoring invalid Video Destination path: %s", self.prefs.video_download_folder 2062 ) 2063 self.prefs.video_download_folder = '' 2064 2065 self.watchedDownloadDirs = WatchDownloadDirs() 2066 self.watchedDownloadDirs.updateWatchPathsFromPrefs(self.prefs) 2067 self.watchedDownloadDirs.directoryChanged.connect(self.watchedFolderChange) 2068 2069 self.fileSystemModel = FileSystemModel(parent=self) 2070 self.fileSystemFilter = FileSystemFilter(self) 2071 self.fileSystemFilter.setSourceModel(self.fileSystemModel) 2072 self.fileSystemDelegate = FileSystemDelegate() 2073 2074 index = self.fileSystemFilter.mapFromSource(self.fileSystemModel.index('/')) 2075 2076 self.thisComputerFSView = FileSystemView(model=self.fileSystemModel, rapidApp=self) 2077 self.thisComputerFSView.setModel(self.fileSystemFilter) 2078 self.thisComputerFSView.setItemDelegate(self.fileSystemDelegate) 2079 self.thisComputerFSView.hideColumns() 2080 self.thisComputerFSView.setRootIndex(index) 2081 if this_computer_sf.valid: 2082 self.thisComputerFSView.goToPath(self.prefs.this_computer_path) 2083 self.thisComputerFSView.activated.connect(self.thisComputerPathChosen) 2084 self.thisComputerFSView.clicked.connect(self.thisComputerPathChosen) 2085 2086 self.photoDestinationFSView = FileSystemView(model=self.fileSystemModel, rapidApp=self) 2087 self.photoDestinationFSView.setModel(self.fileSystemFilter) 2088 self.photoDestinationFSView.setItemDelegate(self.fileSystemDelegate) 2089 self.photoDestinationFSView.hideColumns() 2090 self.photoDestinationFSView.setRootIndex(index) 2091 if photo_df.valid: 2092 self.photoDestinationFSView.goToPath(self.prefs.photo_download_folder) 2093 self.photoDestinationFSView.activated.connect(self.photoDestinationPathChosen) 2094 self.photoDestinationFSView.clicked.connect(self.photoDestinationPathChosen) 2095 2096 self.videoDestinationFSView = FileSystemView(model=self.fileSystemModel, rapidApp=self) 2097 self.videoDestinationFSView.setModel(self.fileSystemFilter) 2098 self.videoDestinationFSView.setItemDelegate(self.fileSystemDelegate) 2099 self.videoDestinationFSView.hideColumns() 2100 self.videoDestinationFSView.setRootIndex(index) 2101 if video_df.valid: 2102 self.videoDestinationFSView.goToPath(self.prefs.video_download_folder) 2103 self.videoDestinationFSView.activated.connect(self.videoDestinationPathChosen) 2104 self.videoDestinationFSView.clicked.connect(self.videoDestinationPathChosen) 2105 2106 def createDeviceThisComputerViews(self) -> None: 2107 2108 # Devices Header and View 2109 tip = _('Turn on or off the use of devices attached to this computer as download sources') 2110 self.deviceToggleView = QToggleView( 2111 label=_('Devices'), 2112 display_alternate=True, 2113 toggleToolTip=tip, 2114 headerColor=QColor(ThumbnailBackgroundName), 2115 headerFontColor=QColor(Qt.white), 2116 on=self.prefs.device_autodetection 2117 ) 2118 self.deviceToggleView.addWidget(self.deviceView) 2119 self.deviceToggleView.valueChanged.connect(self.deviceToggleViewValueChange) 2120 self.deviceToggleView.setSizePolicy( 2121 QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding 2122 ) 2123 2124 # This Computer Header and View 2125 2126 tip = _('Turn on or off the use of a folder on this computer as a download source') 2127 self.thisComputerToggleView = QToggleView( 2128 label=_('This Computer'), 2129 display_alternate=True, 2130 toggleToolTip=tip, 2131 headerColor=QColor(ThumbnailBackgroundName), 2132 headerFontColor=QColor(Qt.white), 2133 on=bool(self.prefs.this_computer_source) 2134 ) 2135 self.thisComputerToggleView.valueChanged.connect(self.thisComputerToggleValueChanged) 2136 2137 self.thisComputer = ComputerWidget( 2138 objectName='thisComputer', 2139 view=self.thisComputerView, 2140 fileSystemView=self.thisComputerFSView, 2141 select_text=_('Select a source folder') 2142 ) 2143 if self.prefs.this_computer_source: 2144 self.thisComputer.setViewVisible(self.prefs.this_computer_source) 2145 2146 self.thisComputerToggleView.addWidget(self.thisComputer) 2147 2148 def createDestinationViews(self) -> None: 2149 """ 2150 Create the widgets that let the user choose where to download photos and videos to, 2151 and that show them how much storage space there is available for their files. 2152 """ 2153 2154 self.photoDestination = QPanelView( 2155 label=_('Photos'), 2156 headerColor=QColor(ThumbnailBackgroundName), 2157 headerFontColor=QColor(Qt.white) 2158 ) 2159 self.videoDestination = QPanelView( 2160 label=_('Videos'), 2161 headerColor=QColor(ThumbnailBackgroundName), 2162 headerFontColor=QColor(Qt.white) 2163 ) 2164 2165 # Display storage space when photos and videos are being downloaded to the same 2166 # partition 2167 2168 self.combinedDestinationDisplay = DestinationDisplay(parent=self) 2169 self.combinedDestinationDisplayContainer = QPanelView( 2170 _('Projected Storage Use'), 2171 headerColor=QColor(ThumbnailBackgroundName), 2172 headerFontColor=QColor(Qt.white) 2173 ) 2174 self.combinedDestinationDisplayContainer.addWidget(self.combinedDestinationDisplay) 2175 2176 # Display storage space when photos and videos are being downloaded to different 2177 # partitions. 2178 # Also display the file system folder chooser for both destinations. 2179 2180 self.photoDestinationDisplay = DestinationDisplay( 2181 menu=True, file_type=FileType.photo, parent=self 2182 ) 2183 self.photoDestinationDisplay.setDestination(self.prefs.photo_download_folder) 2184 self.photoDestinationWidget = ComputerWidget( 2185 objectName='photoDestination', 2186 view=self.photoDestinationDisplay, 2187 fileSystemView=self.photoDestinationFSView, 2188 select_text=_('Select a destination folder') 2189 ) 2190 self.photoDestination.addWidget(self.photoDestinationWidget) 2191 2192 self.videoDestinationDisplay = DestinationDisplay( 2193 menu=True, file_type=FileType.video, parent=self 2194 ) 2195 self.videoDestinationDisplay.setDestination(self.prefs.video_download_folder) 2196 self.videoDestinationWidget = ComputerWidget( 2197 objectName='videoDestination', 2198 view=self.videoDestinationDisplay, 2199 fileSystemView=self.videoDestinationFSView, 2200 select_text=_('Select a destination folder') 2201 ) 2202 self.videoDestination.addWidget(self.videoDestinationWidget) 2203 2204 self.photoDestinationContainer = QWidget() 2205 layout = QVBoxLayout() 2206 layout.setContentsMargins(0, 0, 0, 0) 2207 self.photoDestinationContainer.setLayout(layout) 2208 layout.addWidget(self.combinedDestinationDisplayContainer) 2209 layout.addWidget(self.photoDestination) 2210 2211 def createRenamePanels(self) -> None: 2212 """ 2213 Create the file renaming panel 2214 """ 2215 2216 self.renamePanel = RenamePanel(parent=self) 2217 2218 def createJobCodePanel(self) -> None: 2219 """ 2220 Create the job code panel 2221 """ 2222 2223 self.jobCodePanel = JobCodePanel(parent=self) 2224 2225 def createBackupPanel(self) -> None: 2226 """ 2227 Create the backup options panel 2228 """ 2229 2230 self.backupPanel = BackupPanel(parent=self) 2231 2232 def createBottomControls(self) -> None: 2233 self.thumbnailControl = QWidget() 2234 layout = QHBoxLayout() 2235 2236 # left and right align at edge of left & right bar 2237 hmargin = self.proximityButton.sizeHint().width() 2238 hmargin += self.standard_spacing 2239 vmargin = int(QFontMetrics(QFont()).height() / 2 ) 2240 2241 layout.setContentsMargins(hmargin, vmargin, hmargin, vmargin) 2242 layout.setSpacing(self.standard_spacing) 2243 self.thumbnailControl.setLayout(layout) 2244 2245 font = self.font() # type: QFont 2246 font.setPointSize(font.pointSize() - 2) 2247 2248 self.showCombo = ChevronCombo() 2249 self.showCombo.addItem(_('All'), Show.all) 2250 self.showCombo.addItem(_('New'), Show.new_only) 2251 self.showCombo.currentIndexChanged.connect(self.showComboChanged) 2252 self.showLabel = self.showCombo.makeLabel(_("Show:")) 2253 2254 self.sortCombo = ChevronCombo() 2255 self.sortCombo.addItem(_("Modification Time"), Sort.modification_time) 2256 self.sortCombo.addItem(_("Checked State"), Sort.checked_state) 2257 self.sortCombo.addItem(_("Filename"), Sort.filename) 2258 self.sortCombo.addItem(_("Extension"), Sort.extension) 2259 self.sortCombo.addItem(_("File Type"), Sort.file_type) 2260 self.sortCombo.addItem(_("Device"), Sort.device) 2261 self.sortCombo.currentIndexChanged.connect(self.sortComboChanged) 2262 self.sortLabel= self.sortCombo.makeLabel(_("Sort:")) 2263 2264 self.sortOrder = ChevronCombo() 2265 self.sortOrder.addItem(_("Ascending"), Qt.AscendingOrder) 2266 self.sortOrder.addItem(_("Descending"), Qt.DescendingOrder) 2267 self.sortOrder.currentIndexChanged.connect(self.sortOrderChanged) 2268 2269 for widget in ( 2270 self.showLabel, self.sortLabel, self.sortCombo, self.showCombo, self.sortOrder): 2271 widget.setFont(font) 2272 2273 self.checkAllLabel = QLabel(_('Select All:')) 2274 2275 # Remove the border when the widget is highlighted 2276 style = """ 2277 QCheckBox { 2278 border: none; 2279 outline: none; 2280 spacing: %(spacing)d; 2281 } 2282 """ % dict(spacing=self.standard_spacing // 2) 2283 self.selectAllPhotosCheckbox = QCheckBox(_("Photos") + " ") 2284 self.selectAllVideosCheckbox = QCheckBox(_("Videos")) 2285 self.selectAllPhotosCheckbox.setStyleSheet(style) 2286 self.selectAllVideosCheckbox.setStyleSheet(style) 2287 2288 for widget in (self.checkAllLabel, self.selectAllPhotosCheckbox, 2289 self.selectAllVideosCheckbox): 2290 widget.setFont(font) 2291 2292 self.selectAllPhotosCheckbox.stateChanged.connect(self.selectAllPhotosCheckboxChanged) 2293 self.selectAllVideosCheckbox.stateChanged.connect(self.selectAllVideosCheckboxChanged) 2294 2295 layout.addWidget(self.showLabel) 2296 layout.addWidget(self.showCombo) 2297 layout.addSpacing(QFontMetrics(QFont()).height() * 2) 2298 layout.addWidget(self.sortLabel) 2299 layout.addWidget(self.sortCombo) 2300 layout.addWidget(self.sortOrder) 2301 layout.addStretch() 2302 layout.addWidget(self.checkAllLabel) 2303 layout.addWidget(self.selectAllPhotosCheckbox) 2304 layout.addWidget(self.selectAllVideosCheckbox) 2305 2306 def createCenterPanels(self) -> None: 2307 self.centerSplitter = QSplitter() 2308 self.centerSplitter.setOrientation(Qt.Horizontal) 2309 self.leftPanelSplitter = QSplitter() 2310 self.leftPanelSplitter.setOrientation(Qt.Vertical) 2311 self.rightPanelSplitter = QSplitter() 2312 self.rightPanelSplitter.setOrientation(Qt.Vertical) 2313 self.rightPanels = QStackedWidget() 2314 2315 def configureCenterPanels(self, settings: QSettings) -> None: 2316 self.leftPanelSplitter.addWidget(self.deviceToggleView) 2317 self.leftPanelSplitter.addWidget(self.thisComputerToggleView) 2318 self.leftPanelSplitter.addWidget(self.temporalProximity) 2319 2320 self.rightPanelSplitter.addWidget(self.photoDestinationContainer) 2321 self.rightPanelSplitter.addWidget(self.videoDestination) 2322 2323 self.leftPanelSplitter.setCollapsible(0, False) 2324 self.leftPanelSplitter.setCollapsible(1, False) 2325 self.leftPanelSplitter.setCollapsible(2, False) 2326 self.leftPanelSplitter.setStretchFactor(0, 0) 2327 self.leftPanelSplitter.setStretchFactor(1, 1) 2328 self.leftPanelSplitter.setStretchFactor(2, 1) 2329 2330 self.rightPanels.addWidget(self.rightPanelSplitter) 2331 self.rightPanels.addWidget(self.renamePanel) 2332 self.rightPanels.addWidget(self.jobCodePanel) 2333 self.rightPanels.addWidget(self.backupPanel) 2334 2335 self.centerSplitter.addWidget(self.leftPanelSplitter) 2336 self.centerSplitter.addWidget(self.thumbnailView) 2337 self.centerSplitter.addWidget(self.rightPanels) 2338 self.centerSplitter.setStretchFactor(0, 0) 2339 self.centerSplitter.setStretchFactor(1, 2) 2340 self.centerSplitter.setStretchFactor(2, 0) 2341 self.centerSplitter.setCollapsible(0, False) 2342 self.centerSplitter.setCollapsible(1, False) 2343 self.centerSplitter.setCollapsible(2, False) 2344 2345 self.rightPanelSplitter.setCollapsible(0, False) 2346 self.rightPanelSplitter.setCollapsible(1, False) 2347 2348 splitterSetting = settings.value("centerSplitterSizes") 2349 if splitterSetting is not None: 2350 self.centerSplitter.restoreState(splitterSetting) 2351 else: 2352 self.centerSplitter.setSizes([200, 400, 200]) 2353 2354 splitterSetting = settings.value("leftPanelSplitterSizes") 2355 if splitterSetting is not None: 2356 self.leftPanelSplitter.restoreState(splitterSetting) 2357 else: 2358 self.leftPanelSplitter.setSizes([200, 200, 400]) 2359 2360 splitterSetting = settings.value("rightPanelSplitterSizes") 2361 if splitterSetting is not None: 2362 self.rightPanelSplitter.restoreState(splitterSetting) 2363 else: 2364 self.rightPanelSplitter.setSizes([200,200]) 2365 2366 def setDownloadCapabilities(self) -> bool: 2367 """ 2368 Update the destination displays and download button 2369 2370 :return: True if download destinations are capable of having 2371 all marked files downloaded to them 2372 """ 2373 marked_summary = self.thumbnailModel.getMarkedSummary() 2374 if self.prefs.backup_files: 2375 downloading_to = self.backup_devices.get_download_backup_device_overlap( 2376 photo_download_folder=self.prefs.photo_download_folder, 2377 video_download_folder=self.prefs.video_download_folder 2378 ) 2379 self.backupPanel.setDownloadingTo(downloading_to=downloading_to) 2380 backups_good = self.updateBackupView(marked_summary=marked_summary) 2381 else: 2382 backups_good = True 2383 downloading_to = defaultdict(set) 2384 2385 destinations_good = self.updateDestinationViews( 2386 marked_summary=marked_summary, downloading_to=downloading_to 2387 ) 2388 2389 download_good = destinations_good and backups_good 2390 self.setDownloadActionState(download_good) 2391 self.destinationButton.setHighlighted(not destinations_good) 2392 self.backupButton.setHighlighted(not backups_good) 2393 return download_good 2394 2395 def updateDestinationViews(self, 2396 marked_summary: MarkedSummary, 2397 downloading_to: Optional[DefaultDict[int, Set[FileType]]]=None) -> bool: 2398 """ 2399 Updates the the header bar and storage space view for the 2400 photo and video download destinations. 2401 2402 :return True if destinations required for the download exist, 2403 and there is sufficient space on them, else False. 2404 """ 2405 2406 size_photos_marked = marked_summary.size_photos_marked 2407 size_videos_marked = marked_summary.size_videos_marked 2408 marked = marked_summary.marked 2409 2410 if self.unity_progress: 2411 available = self.thumbnailModel.getNoFilesMarkedForDownload() 2412 for launcher in self.desktop_launchers: 2413 if available: 2414 launcher.set_property("count", available) 2415 launcher.set_property("count_visible", True) 2416 else: 2417 launcher.set_property("count_visible", False) 2418 2419 destinations_good = True 2420 2421 # Assume that invalid destination folders have already been reset to '' 2422 if self.prefs.photo_download_folder and self.prefs.video_download_folder: 2423 same_dev = same_device(self.prefs.photo_download_folder, 2424 self.prefs.video_download_folder) 2425 else: 2426 same_dev = False 2427 2428 merge = self.downloadIsRunning() 2429 2430 if same_dev: 2431 files_to_display = DisplayingFilesOfType.photos_and_videos 2432 self.combinedDestinationDisplay.downloading_to = downloading_to 2433 self.combinedDestinationDisplay.setDestination(self.prefs.photo_download_folder) 2434 self.combinedDestinationDisplay.setDownloadAttributes( 2435 marked=marked, 2436 photos_size=size_photos_marked, 2437 videos_size=size_videos_marked, 2438 files_to_display=files_to_display, 2439 display_type=DestinationDisplayType.usage_only, 2440 merge=merge 2441 ) 2442 display_type = DestinationDisplayType.folder_only 2443 self.combinedDestinationDisplayContainer.setVisible(True) 2444 destinations_good = self.combinedDestinationDisplay.sufficientSpaceAvailable() 2445 else: 2446 files_to_display = DisplayingFilesOfType.photos 2447 display_type = DestinationDisplayType.folders_and_usage 2448 self.combinedDestinationDisplayContainer.setVisible(False) 2449 2450 if self.prefs.photo_download_folder: 2451 self.photoDestinationDisplay.downloading_to = downloading_to 2452 self.photoDestinationDisplay.setDownloadAttributes( 2453 marked=marked, 2454 photos_size=size_photos_marked, 2455 videos_size=0, 2456 files_to_display=files_to_display, 2457 display_type=display_type, 2458 merge=merge 2459 ) 2460 self.photoDestinationWidget.setViewVisible(True) 2461 if display_type == DestinationDisplayType.folders_and_usage: 2462 destinations_good = self.photoDestinationDisplay.sufficientSpaceAvailable() 2463 else: 2464 # Photo download folder was invalid or simply not yet set 2465 self.photoDestinationWidget.setViewVisible(False) 2466 if size_photos_marked: 2467 destinations_good = False 2468 2469 if not same_dev: 2470 files_to_display = DisplayingFilesOfType.videos 2471 if self.prefs.video_download_folder: 2472 self.videoDestinationDisplay.downloading_to = downloading_to 2473 self.videoDestinationDisplay.setDownloadAttributes( 2474 marked=marked, 2475 photos_size=0, 2476 videos_size=size_videos_marked, 2477 files_to_display=files_to_display, 2478 display_type=display_type, 2479 merge=merge 2480 ) 2481 self.videoDestinationWidget.setViewVisible(True) 2482 if display_type == DestinationDisplayType.folders_and_usage: 2483 destinations_good = ( 2484 self.videoDestinationDisplay.sufficientSpaceAvailable() and destinations_good 2485 ) 2486 else: 2487 # Video download folder was invalid or simply not yet set 2488 self.videoDestinationWidget.setViewVisible(False) 2489 if size_videos_marked: 2490 destinations_good = False 2491 2492 return destinations_good 2493 2494 @pyqtSlot() 2495 def updateThumbnailModelAfterProximityChange(self) -> None: 2496 """ 2497 Respond to the user selecting / deslecting temporal proximity 2498 cells 2499 """ 2500 2501 self.thumbnailModel.updateAllDeviceDisplayCheckMarks() 2502 self.thumbnailModel.updateSelectionAfterProximityChange() 2503 self.thumbnailModel.resetHighlighting() 2504 2505 def updateBackupView(self, marked_summary: MarkedSummary) -> bool: 2506 merge = self.downloadIsRunning() 2507 self.backupPanel.setDownloadAttributes( 2508 marked=marked_summary.marked, 2509 photos_size=marked_summary.size_photos_marked, 2510 videos_size=marked_summary.size_videos_marked, 2511 merge=merge 2512 ) 2513 return self.backupPanel.sufficientSpaceAvailable() 2514 2515 def setDownloadActionState(self, download_destinations_good: bool) -> None: 2516 """ 2517 Sets sensitivity of Download action to enable or disable it. 2518 Affects download button and menu item. 2519 2520 :param download_destinations_good: whether the download destinations 2521 are valid and contain sufficient space for the download to proceed 2522 """ 2523 2524 if not self.downloadIsRunning(): 2525 files_marked = False 2526 # Don't enable starting a download while devices are being scanned 2527 if len(self.devices.scanning) == 0: 2528 files_marked = self.thumbnailModel.filesAreMarkedForDownload() 2529 2530 enabled = files_marked and download_destinations_good 2531 2532 self.downloadAct.setEnabled(enabled) 2533 self.downloadButton.setEnabled(enabled) 2534 if files_marked: 2535 marked = self.thumbnailModel.getNoFilesAndTypesMarkedForDownload() 2536 files = marked.file_types_present_details() 2537 # Translators: %(variable)s represents Python code, not a plural of the term 2538 # variable. You must keep the %(variable)s untranslated, or the program will 2539 # crash. 2540 text = _("Download %(files)s") % dict(files=files) # type: str 2541 self.downloadButton.setText(text) 2542 else: 2543 self.downloadButton.setText(self.downloadAct.text()) 2544 else: 2545 self.downloadAct.setEnabled(True) 2546 self.downloadButton.setEnabled(True) 2547 2548 def setDownloadActionLabel(self) -> None: 2549 """ 2550 Sets download action and download button text to correct value, depending on 2551 whether a download is occurring or not, including whether it is paused 2552 """ 2553 2554 if self.devices.downloading: 2555 if self.download_paused: 2556 text = _("Resume Download") 2557 else: 2558 text = _("Pause") 2559 else: 2560 text = _("Download") 2561 2562 self.downloadAct.setText(text) 2563 self.downloadButton.setText(text) 2564 2565 def createMenus(self) -> None: 2566 self.menu = QMenu() 2567 self.menu.addAction(self.downloadAct) 2568 self.menu.addAction(self.preferencesAct) 2569 self.menu.addSeparator() 2570 self.menu.addAction(self.errorLogAct) 2571 self.menu.addAction(self.clearDownloadsAct) 2572 self.menu.addSeparator() 2573 self.menu.addAction(self.helpAct) 2574 self.menu.addAction(self.didYouKnowAct) 2575 if not version_check_disabled(): 2576 self.menu.addAction(self.newVersionAct) 2577 self.menu.addAction(self.reportProblemAct) 2578 self.menu.addAction(self.makeDonationAct) 2579 self.menu.addAction(self.translateApplicationAct) 2580 self.menu.addAction(self.aboutAct) 2581 self.menu.addAction(self.quitAct) 2582 2583 self.menuButton = MenuButton(icon=':/icons/menu.svg', menu=self.menu) 2584 2585 def doCheckForNewVersion(self) -> None: 2586 """Check online for a new program version""" 2587 if not version_check_disabled(): 2588 self.newVersionCheckDialog.reset() 2589 self.newVersionCheckDialog.show() 2590 self.checkForNewVersionRequest.emit() 2591 2592 def doSourceAction(self) -> None: 2593 self.sourceButton.animateClick() 2594 2595 def doDownloadAction(self) -> None: 2596 self.downloadButton.animateClick() 2597 2598 def doRefreshAction(self) -> None: 2599 pass 2600 2601 def doPreferencesAction(self) -> None: 2602 self.scan_all_again = self.scan_non_camera_devices_again = False 2603 self.search_for_devices_again = False 2604 2605 dialog = PreferencesDialog(prefs=self.prefs, parent=self) 2606 dialog.exec() 2607 self.prefs.sync() 2608 2609 if self.scan_all_again or self.scan_non_camera_devices_again: 2610 self.rescanDevicesAndComputer( 2611 ignore_cameras=not self.scan_all_again, 2612 rescan_path=self.scan_all_again 2613 ) 2614 2615 if self.search_for_devices_again: 2616 # Update the list of valid mounts 2617 logging.debug( 2618 "Updating the list of valid mounts after preference change to only_external_mounts" 2619 ) 2620 self.validMounts = ValidMounts(onlyExternalMounts=self.prefs.only_external_mounts) 2621 self.searchForDevicesAgain() 2622 2623 # Just to be extra safe, reset these values to their 'off' state: 2624 self.scan_all_again = self.scan_non_camera_devices_again = False 2625 self.search_for_devices_again = False 2626 2627 def doErrorLogAction(self) -> None: 2628 self.errorLog.setVisible(self.errorLogAct.isChecked()) 2629 2630 def doClearDownloadsAction(self): 2631 self.thumbnailModel.clearCompletedDownloads() 2632 2633 def doHelpAction(self) -> None: 2634 webbrowser.open_new_tab("http://www.damonlynch.net/rapid/help.html") 2635 2636 def doDidYouKnowAction(self) -> None: 2637 try: 2638 self.tip.activate() 2639 except AttributeError: 2640 self.tip = didyouknow.DidYouKnowDialog(self.prefs, self) 2641 self.tip.activate() 2642 2643 def makeProblemReportDialog(self, header: str, title: Optional[str]=None) -> None: 2644 """ 2645 Create the dialog window to guide the user in reporting a bug 2646 :param header: text at the top of the dialog window 2647 :param title: optional title 2648 """ 2649 2650 body = excepthook.please_report_problem_body.format( 2651 website='https://bugs.launchpad.net/rapid' 2652 ) 2653 2654 message = '{header}<br><br>{body}'.format(header=header, body=body) 2655 2656 errorbox = standardMessageBox( 2657 message=message, rich_text=True, title=title, 2658 standardButtons=QMessageBox.Save | QMessageBox.Cancel, 2659 defaultButton=QMessageBox.Save 2660 ) 2661 if errorbox.exec_() == QMessageBox.Save: 2662 excepthook.save_bug_report_tar( 2663 config_file=self.prefs.settings_path(), 2664 full_log_file_path=iplogging.full_log_file_path() 2665 ) 2666 2667 def doReportProblemAction(self) -> None: 2668 header = _('Thank you for reporting a problem in Rapid Photo Downloader') 2669 header = '<b>{}</b>'.format(header) 2670 self.makeProblemReportDialog(header) 2671 2672 def doMakeDonationAction(self) -> None: 2673 webbrowser.open_new_tab("http://www.damonlynch.net/rapid/donate.html") 2674 2675 def doTranslateApplicationAction(self) -> None: 2676 webbrowser.open_new_tab("http://www.damonlynch.net/rapid/translate.html") 2677 2678 def doAboutAction(self) -> None: 2679 about = AboutDialog(self) 2680 about.exec() 2681 2682 @pyqtSlot(bool) 2683 def thisComputerToggleValueChanged(self, on: bool) -> None: 2684 """ 2685 Respond to This Computer Toggle Switch 2686 2687 :param on: whether switch is on or off 2688 """ 2689 2690 if on: 2691 self.thisComputer.setViewVisible(bool(self.prefs.this_computer_path)) 2692 self.prefs.this_computer_source = on 2693 if not on: 2694 if len(self.devices.this_computer) > 0: 2695 scan_id = list(self.devices.this_computer)[0] 2696 self.removeDevice(scan_id=scan_id) 2697 self.prefs.this_computer_path = '' 2698 self.thisComputerFSView.clearSelection() 2699 2700 self.adjustLeftPanelSliderHandles() 2701 2702 @pyqtSlot(bool) 2703 def deviceToggleViewValueChange(self, on: bool) -> None: 2704 """ 2705 Respond to Devices Toggle Switch 2706 2707 :param on: whether switch is on or off 2708 """ 2709 2710 self.prefs.device_autodetection = on 2711 if not on: 2712 for scan_id in list(self.devices.volumes_and_cameras): 2713 self.removeDevice(scan_id=scan_id, adjust_temporal_proximity=False) 2714 state = self.proximityStatePostDeviceRemoval() 2715 if state == TemporalProximityState.empty: 2716 self.temporalProximity.setState(TemporalProximityState.empty) 2717 else: 2718 self.generateTemporalProximityTableData("devices were removed as a download source") 2719 else: 2720 # This is a real hack -- but I don't know a better way to let the 2721 # slider redraw itself 2722 QTimer.singleShot(100, self.devicesViewToggledOn) 2723 self.adjustLeftPanelSliderHandles() 2724 2725 def proximityStatePostDeviceRemoval(self) -> TemporalProximityState: 2726 """ 2727 :return: set correct proximity state after a device is removed 2728 """ 2729 2730 # ignore devices that are scanning - we don't care about them, because the scan 2731 # could take a long time, especially with phones 2732 if len(self.devices) - len(self.devices.scanning) > 0: 2733 # Other already scanned devices are present 2734 return TemporalProximityState.regenerate 2735 else: 2736 return TemporalProximityState.empty 2737 2738 @pyqtSlot() 2739 def devicesViewToggledOn(self) -> None: 2740 self.searchForCameras() 2741 self.setupNonCameraDevices() 2742 2743 @pyqtSlot(QModelIndex) 2744 def thisComputerPathChosen(self, index: QModelIndex) -> None: 2745 """ 2746 Handle user selecting new device location path. 2747 2748 Called after single click or folder being activated. 2749 2750 :param index: cell clicked 2751 """ 2752 2753 path = self.fileSystemModel.filePath(index.model().mapToSource(index)) 2754 2755 if self.downloadIsRunning() and self.prefs.this_computer_path: 2756 # Translators: %(variable)s represents Python code, not a plural of the term 2757 # variable. You must keep the %(variable)s untranslated, or the program will 2758 # crash. 2759 # Translators: please do not change HTML codes like <br>, <i>, </i>, or <b>, </b> etc. 2760 message = _( 2761 "<b>Changing This Computer source path</b><br><br>Do you really want to " 2762 "change the source path to %(new_path)s?<br><br>You are currently " 2763 "downloading from %(source_path)s.<br><br>" 2764 "If you do change the path, the current download from This Computer " 2765 "will be cancelled." 2766 ) % dict( 2767 new_path=make_html_path_non_breaking(path), 2768 source_path=make_html_path_non_breaking(self.prefs.this_computer_path) 2769 ) 2770 2771 msgbox = standardMessageBox( 2772 message=message, rich_text=True, standardButtons=QMessageBox.Yes | QMessageBox.No, 2773 ) 2774 if msgbox.exec() == QMessageBox.No: 2775 self.thisComputerFSView.goToPath(self.prefs.this_computer_path) 2776 return 2777 2778 if path != self.prefs.this_computer_path: 2779 if self.prefs.this_computer_path: 2780 scan_id = self.devices.scan_id_from_path( 2781 self.prefs.this_computer_path, DeviceType.path 2782 ) 2783 if scan_id is not None: 2784 logging.debug( 2785 "Removing path from device view %s", self.prefs.this_computer_path 2786 ) 2787 self.removeDevice(scan_id=scan_id) 2788 self.prefs.this_computer_path = path 2789 self.thisComputer.setViewVisible(True) 2790 self.setupManualPath() 2791 2792 @pyqtSlot(QModelIndex) 2793 def photoDestinationPathChosen(self, index: QModelIndex) -> None: 2794 """ 2795 Handle user setting new photo download location 2796 2797 Called after single click or folder being activated. 2798 2799 :param index: cell clicked 2800 """ 2801 2802 path = self.fileSystemModel.filePath(index.model().mapToSource(index)) 2803 2804 if not self.checkChosenDownloadDestination(path, FileType.photo): 2805 return 2806 2807 if validate_download_folder(path).valid: 2808 if path != self.prefs.photo_download_folder: 2809 self.prefs.photo_download_folder = path 2810 self.watchedDownloadDirs.updateWatchPathsFromPrefs(self.prefs) 2811 self.folder_preview_manager.change_destination() 2812 self.photoDestinationDisplay.setDestination(path=path) 2813 self.setDownloadCapabilities() 2814 else: 2815 logging.error("Invalid photo download destination chosen: %s", path) 2816 self.handleInvalidDownloadDestination(file_type=FileType.photo) 2817 2818 def checkChosenDownloadDestination(self, path: str, file_type: FileType) -> bool: 2819 """ 2820 Check the path the user has chosen to ensure it's not a provisional 2821 download subfolder. If it is a download subfolder that already existed, 2822 confirm with the user that they did in fact want to use that destination. 2823 2824 :param path: path chosen 2825 :param file_type: whether for photos or videos 2826 :return: False if the path is problematic and should be ignored, else True 2827 """ 2828 2829 problematic = self.downloadIsRunning() 2830 if problematic: 2831 message = _("You cannot change the download destination while downloading.") 2832 msgbox = standardMessageBox( 2833 message=message, rich_text=False, standardButtons=QMessageBox.Ok, 2834 iconType=QMessageBox.Warning 2835 ) 2836 msgbox.exec() 2837 2838 else: 2839 problematic = path in self.fileSystemModel.preview_subfolders 2840 2841 if not problematic and path in self.fileSystemModel.download_subfolders: 2842 message = _( 2843 "<b>Confirm Download Destination</b><br><br>Are you sure you want to set " 2844 "the %(file_type)s download destination to %(path)s?" 2845 ) % dict( 2846 file_type=file_type.name, path=make_html_path_non_breaking(path) 2847 ) 2848 msgbox = standardMessageBox( 2849 message=message, rich_text=True, 2850 standardButtons=QMessageBox.Yes | QMessageBox.No, 2851 ) 2852 problematic = msgbox.exec() == QMessageBox.No 2853 2854 if problematic: 2855 if file_type == FileType.photo and self.prefs.photo_download_folder: 2856 self.photoDestinationFSView.goToPath(self.prefs.photo_download_folder) 2857 elif file_type == FileType.video and self.prefs.video_download_folder: 2858 self.videoDestinationFSView.goToPath(self.prefs.video_download_folder) 2859 return False 2860 2861 return True 2862 2863 def handleInvalidDownloadDestination(self, file_type: FileType, do_update: bool=True) -> None: 2864 """ 2865 Handle cases where user clicked on an invalid download directory, 2866 or the directory simply having disappeared 2867 2868 :param file_type: type of destination to work on 2869 :param do_update: if True, update watched folders, provisional 2870 download folders and update the UI to reflect new download 2871 capabilities 2872 """ 2873 2874 if file_type == FileType.photo: 2875 self.prefs.photo_download_folder = '' 2876 self.photoDestinationWidget.setViewVisible(False) 2877 else: 2878 self.prefs.video_download_folder = '' 2879 self.videoDestinationWidget.setViewVisible(False) 2880 2881 if do_update: 2882 self.watchedDownloadDirs.updateWatchPathsFromPrefs(self.prefs) 2883 self.folder_preview_manager.change_destination() 2884 self.setDownloadCapabilities() 2885 2886 @pyqtSlot(QModelIndex) 2887 def videoDestinationPathChosen(self, index: QModelIndex) -> None: 2888 """ 2889 Handle user setting new video download location 2890 2891 Called after single click or folder being activated. 2892 2893 :param index: cell clicked 2894 """ 2895 2896 path = self.fileSystemModel.filePath(index.model().mapToSource(index)) 2897 2898 if not self.checkChosenDownloadDestination(path, FileType.video): 2899 return 2900 2901 if validate_download_folder(path).valid: 2902 if path != self.prefs.video_download_folder: 2903 self.prefs.video_download_folder = path 2904 self.watchedDownloadDirs.updateWatchPathsFromPrefs(self.prefs) 2905 self.folder_preview_manager.change_destination() 2906 self.videoDestinationDisplay.setDestination(path=path) 2907 self.setDownloadCapabilities() 2908 else: 2909 logging.error("Invalid video download destination chosen: %s", path) 2910 self.handleInvalidDownloadDestination(file_type=FileType.video) 2911 2912 @pyqtSlot() 2913 def downloadButtonClicked(self) -> None: 2914 if self.download_paused: 2915 logging.debug("Download resumed") 2916 self.resumeDownload() 2917 else: 2918 if self.downloadIsRunning(): 2919 self.pauseDownload() 2920 else: 2921 start_download = True 2922 if self.prefs.warn_downloading_all and \ 2923 self.thumbnailModel.anyCheckedFilesFiltered(): 2924 message = _( 2925 """ 2926<b>Downloading all files</b><br><br> 2927A download always includes all files that are checked for download, 2928including those that are not currently displayed because the Timeline 2929is being used or because only new files are being shown.<br><br> 2930Do you want to proceed with the download? 2931 """ 2932 ) 2933 2934 warning = RememberThisDialog( 2935 message=message, 2936 icon=':/rapid-photo-downloader.svg', 2937 remember=RememberThisMessage.do_not_ask_again, 2938 parent=self 2939 ) 2940 2941 start_download = warning.exec_() 2942 if warning.remember: 2943 self.prefs.warn_downloading_all = False 2944 2945 if start_download: 2946 logging.debug("Download activated") 2947 2948 if self.jobCodePanel.needToPromptForJobCode(): 2949 if self.jobCodePanel.getJobCodeBeforeDownload(): 2950 self.startDownload() 2951 else: 2952 self.startDownload() 2953 2954 def pauseDownload(self) -> None: 2955 """ 2956 Pause the copy files processes 2957 """ 2958 2959 self.dl_update_timer.stop() 2960 self.download_paused = True 2961 self.sendPauseToThread(self.copy_controller) 2962 self.setDownloadActionLabel() 2963 self.time_check.pause() 2964 self.displayMessageInStatusBar() 2965 2966 def resumeDownload(self) -> None: 2967 """ 2968 Resume a download after it has been paused, and start 2969 downloading from any queued auto-start downloads 2970 """ 2971 2972 for scan_id in self.devices.downloading: 2973 self.time_remaining.set_time_mark(scan_id) 2974 2975 self.time_check.set_download_mark() 2976 self.sendResumeToThread(self.copy_controller) 2977 self.download_paused = False 2978 self.dl_update_timer.start() 2979 self.download_start_time = time.time() 2980 self.setDownloadActionLabel() 2981 self.immediatelyDisplayDownloadRunningInStatusBar() 2982 for scan_id in self.devices.queued_to_download: 2983 self.startDownload(scan_id=scan_id) 2984 self.devices.queued_to_download = set() # type: Set[int] 2985 2986 def downloadIsRunning(self) -> bool: 2987 """ 2988 :return True if a file is currently being downloaded, renamed 2989 or backed up, else False 2990 """ 2991 if not self.devices.downloading: 2992 if self.prefs.backup_files: 2993 return not self.download_tracker.all_files_backed_up() 2994 else: 2995 return False 2996 else: 2997 return True 2998 2999 def startDownload(self, scan_id: int=None) -> None: 3000 """ 3001 Start download, renaming and backup of files. 3002 3003 :param scan_id: if specified, only files matching it will be 3004 downloaded 3005 """ 3006 logging.debug("Start Download phase 1 has started") 3007 3008 if self.prefs.backup_files: 3009 self.initializeBackupThumbCache() 3010 3011 self.download_files = self.thumbnailModel.getFilesMarkedForDownload(scan_id) 3012 3013 # model, port 3014 camera_unmounts_called = set() # type: Set[Tuple[str, str]] 3015 stop_thumbnailing_cmd_issued = False 3016 3017 stop_thumbnailing = [scan_id for scan_id in self.download_files.camera_access_needed 3018 if scan_id in self.devices.thumbnailing] 3019 for scan_id in stop_thumbnailing: 3020 device = self.devices[scan_id] 3021 if scan_id not in self.thumbnailModel.generating_thumbnails: 3022 logging.debug( 3023 "Not terminating thumbnailing of %s because it's not in the thumbnail manager", 3024 device.display_name 3025 ) 3026 else: 3027 logging.debug( 3028 "Terminating thumbnailing for %s because a download is starting", 3029 device.display_name 3030 ) 3031 self.thumbnailModel.terminateThumbnailGeneration(scan_id) 3032 self.devices.cameras_to_stop_thumbnailing.add(scan_id) 3033 stop_thumbnailing_cmd_issued = True 3034 3035 if self.gvfsControlsMounts: 3036 mount_points = {} 3037 # If a device was being thumbnailed, then it wasn't mounted by GVFS 3038 # Therefore filter out the cameras we've already requested their 3039 # thumbnailing be stopped 3040 still_to_check = [ 3041 scan_id for scan_id in self.download_files.camera_access_needed 3042 if scan_id not in stop_thumbnailing 3043 ] 3044 for scan_id in still_to_check: 3045 # This next value is likely *always* True, but check nonetheless 3046 if self.download_files.camera_access_needed[scan_id]: 3047 device = self.devices[scan_id] 3048 model = device.camera_model 3049 port = device.camera_port 3050 mount_point = self.gvolumeMonitor.ptpCameraMountPoint(model, port) 3051 if mount_point is not None: 3052 self.devices.cameras_to_gvfs_unmount_for_download.add(scan_id) 3053 camera_unmounts_called.add((model, port)) 3054 mount_points[(model, port)] = mount_point 3055 if len(camera_unmounts_called): 3056 logging.info( 3057 "%s camera(s) need to be unmounted by GVFS before the download begins", 3058 len(camera_unmounts_called) 3059 ) 3060 for model, port in camera_unmounts_called: 3061 self.gvolumeMonitor.unmountCamera( 3062 model, port, download_starting=True, mount_point=mount_points[(model, port)] 3063 ) 3064 3065 if not camera_unmounts_called and not stop_thumbnailing_cmd_issued: 3066 self.startDownloadPhase2() 3067 3068 def startDownloadPhase2(self) -> None: 3069 logging.debug("Start Download phase 2 has started") 3070 download_files = self.download_files 3071 3072 invalid_dirs = self.invalidDownloadFolders(download_files.download_types) 3073 3074 if invalid_dirs: 3075 if len(invalid_dirs) > 1: 3076 # Translators: %(variable)s represents Python code, not a plural of the term 3077 # variable. You must keep the %(variable)s untranslated, or the program will 3078 # crash. 3079 msg = _( 3080 "These download folders are invalid:\n%(folder1)s\n%(folder2)s" 3081 ) % {'folder1': invalid_dirs[0], 'folder2': invalid_dirs[1]} 3082 else: 3083 msg = _("This download folder is invalid:\n%s") % invalid_dirs[0] 3084 msgBox = QMessageBox(self) 3085 msgBox.setIcon(QMessageBox.Critical) 3086 msgBox.setWindowTitle(_("Download Failure")) 3087 msgBox.setText(_("The download cannot proceed.")) 3088 msgBox.setInformativeText(msg) 3089 msgBox.exec() 3090 else: 3091 missing_destinations = self.backup_devices.backup_destinations_missing( 3092 download_files.download_types 3093 ) 3094 if missing_destinations is not None: 3095 # Warn user that they have specified that they want to 3096 # backup a file type, but no such folder exists on backup 3097 # devices 3098 if self.prefs.backup_device_autodetection: 3099 if missing_destinations == BackupFailureType.photos_and_videos: 3100 logging.warning( 3101 "Photos and videos will not be backed up because there " 3102 "is nowhere to back them up" 3103 ) 3104 msg = _( 3105 "Photos and videos will not be backed up because there is nowhere " 3106 "to back them up. Do you still want to start the download?" 3107 ) 3108 elif missing_destinations == BackupFailureType.photos: 3109 logging.warning("No backup device exists for backing up photos") 3110 # Translators: filetype will be replaced with 'photos' or 'videos' 3111 # Translators: %(variable)s represents Python code, not a plural of the term 3112 # variable. You must keep the %(variable)s untranslated, or the program will 3113 # crash. 3114 msg = _( 3115 "No backup device exists for backing up %(filetype)s. Do you " 3116 "still want to start the download?" 3117 ) % {'filetype': _('photos')} 3118 3119 else: 3120 logging.warning( 3121 "No backup device contains a valid folder for backing up videos" 3122 ) 3123 # Translators: filetype will be replaced with 'photos' or 'videos' 3124 # Translators: %(variable)s represents Python code, not a plural of the term 3125 # variable. You must keep the %(variable)s untranslated, or the program will 3126 # crash. 3127 msg = _( 3128 "No backup device exists for backing up %(filetype)s. Do you " 3129 "still want to start the download?" 3130 ) % {'filetype': _('videos')} 3131 else: 3132 if missing_destinations == BackupFailureType.photos_and_videos: 3133 logging.warning( 3134 "The manually specified photo and videos backup paths do " 3135 "not exist or are not writable" 3136 ) 3137 # Translators: please do not change HTML codes like <br>, <i>, </i>, or 3138 # <b>, </b> etc. 3139 msg = _( 3140 "<b>The photo and video backup destinations do not exist or cannot " 3141 "be written to.</b><br><br>Do you still want to start the download?" 3142 ) 3143 elif missing_destinations == BackupFailureType.photos: 3144 logging.warning( 3145 "The manually specified photo backup path does not exist " 3146 "or is not writable" 3147 ) 3148 # Translators: filetype will be replaced by either 'photo' or 'video' 3149 # Translators: %(variable)s represents Python code, not a plural of the term 3150 # variable. You must keep the %(variable)s untranslated, or the program will 3151 # crash. 3152 # Translators: please do not change HTML codes like <br>, <i>, </i>, or 3153 # <b>, </b> etc. 3154 msg = _( 3155 "<b>The %(filetype)s backup destination does not exist or cannot be " 3156 "written to.</b><br><br>Do you still want to start the download?" 3157 ) % {'filetype': _('photo')} 3158 else: 3159 logging.warning( 3160 "The manually specified video backup path does not exist " 3161 "or is not writable" 3162 ) 3163 # Translators: filetype will be replaced by either 'photo' or 'video' 3164 # Translators: %(variable)s represents Python code, not a plural of the term 3165 # variable. You must keep the %(variable)s untranslated, or the program will 3166 # crash. 3167 # Translators: please do not change HTML codes like <br>, <i>, </i>, or 3168 # <b>, </b> etc. 3169 msg = _( 3170 "<b>The %(filetype)s backup destination does not exist or cannot be " 3171 "written to.</b><br><br>Do you still want to start the download?" 3172 ) % {'filetype': _('video')} 3173 3174 if self.prefs.warn_backup_problem: 3175 warning = RememberThisDialog( 3176 message=msg, 3177 icon=':/rapid-photo-downloader.svg', 3178 remember=RememberThisMessage.do_not_ask_again, 3179 parent=self, 3180 title=_("Backup problem") 3181 ) 3182 do_download = warning.exec() 3183 if warning.remember: 3184 self.prefs.warn_backup_problem = False 3185 if not do_download: 3186 return 3187 3188 # Set time download is starting if it is not already set 3189 # it is unset when all downloads are completed 3190 # It is used in file renaming 3191 if self.download_start_datetime is None: 3192 self.download_start_datetime = datetime.datetime.now() 3193 # The download start time (not datetime) is used to determine 3194 # when to show the time remaining and download speed in the status bar 3195 if self.download_start_time is None: 3196 self.download_start_time = time.time() 3197 3198 # Set status to download pending 3199 self.thumbnailModel.markDownloadPending(download_files.files) 3200 3201 # disable refresh and the changing of various preferences while 3202 # the download is occurring 3203 self.enablePrefsAndRefresh(enabled=False) 3204 3205 # notify renameandmovefile process to read any necessary values 3206 # from the program preferences 3207 data = RenameAndMoveFileData(message=RenameAndMoveStatus.download_started) 3208 self.sendDataMessageToThread(self.rename_controller, data=data) 3209 3210 # notify backup processes to reset their problem reports 3211 self.sendBackupStartFinishMessageToWorkers(BackupStatus.backup_started) 3212 3213 # Maximum value of progress bar may have been set to the number 3214 # of thumbnails being generated. Reset it to use a percentage. 3215 self.downloadProgressBar.setMaximum(100) 3216 3217 for scan_id in download_files.files: 3218 files = download_files.files[scan_id] 3219 # if generating thumbnails for this scan_id, stop it 3220 if self.thumbnailModel.terminateThumbnailGeneration(scan_id): 3221 generate_thumbnails = self.thumbnailModel.markThumbnailsNeeded(files) 3222 else: 3223 generate_thumbnails = False 3224 3225 self.downloadFiles( 3226 files=files, 3227 scan_id=scan_id, 3228 download_stats=download_files.download_stats[scan_id], 3229 generate_thumbnails=generate_thumbnails 3230 ) 3231 3232 self.setDownloadActionLabel() 3233 3234 def downloadFiles(self, files: List[RPDFile], 3235 scan_id: int, 3236 download_stats: DownloadStats, 3237 generate_thumbnails: bool) -> None: 3238 """ 3239 3240 :param files: list of the files to download 3241 :param scan_id: the device from which to download the files 3242 :param download_stats: count of files and their size 3243 :param generate_thumbnails: whether thumbnails must be 3244 generated in the copy files process. 3245 """ 3246 3247 model = self.mapModel(scan_id) 3248 model.setSpinnerState(scan_id, DeviceState.downloading) 3249 3250 if download_stats.no_photos > 0: 3251 photo_download_folder = self.prefs.photo_download_folder 3252 else: 3253 photo_download_folder = None 3254 3255 if download_stats.no_videos > 0: 3256 video_download_folder = self.prefs.video_download_folder 3257 else: 3258 video_download_folder = None 3259 3260 self.download_tracker.init_stats(scan_id=scan_id, stats=download_stats) 3261 download_size = download_stats.photos_size_in_bytes + \ 3262 download_stats.videos_size_in_bytes 3263 3264 if self.prefs.backup_files: 3265 download_size += ( 3266 ( 3267 len(self.backup_devices.photo_backup_devices) * 3268 download_stats.photos_size_in_bytes 3269 ) + ( 3270 len(self.backup_devices.video_backup_devices) * 3271 download_stats.videos_size_in_bytes 3272 ) 3273 ) 3274 3275 self.time_remaining[scan_id] = download_size 3276 self.time_check.set_download_mark() 3277 3278 self.devices.set_device_state(scan_id, DeviceState.downloading) 3279 self.updateProgressBarState() 3280 self.immediatelyDisplayDownloadRunningInStatusBar() 3281 self.setDownloadActionState(True) 3282 3283 if not self.dl_update_timer.isActive(): 3284 self.dl_update_timer.start() 3285 3286 if self.autoStart(scan_id) and self.prefs.generate_thumbnails: 3287 for rpd_file in files: 3288 rpd_file.generate_thumbnail = True 3289 generate_thumbnails = True 3290 3291 verify_file = self.prefs.verify_file 3292 3293 # Initiate copy files process 3294 3295 device = self.devices[scan_id] 3296 copyfiles_args = CopyFilesArguments( 3297 scan_id=scan_id, 3298 device=device, 3299 photo_download_folder=photo_download_folder, 3300 video_download_folder=video_download_folder, 3301 files=files, 3302 verify_file=verify_file, 3303 generate_thumbnails=generate_thumbnails, 3304 log_gphoto2=self.log_gphoto2 3305 ) 3306 3307 self.sendStartWorkerToThread(self.copy_controller, worker_id=scan_id, data=copyfiles_args) 3308 3309 @pyqtSlot(int, str, str) 3310 def tempDirsReceivedFromCopyFiles(self, scan_id: int, 3311 photo_temp_dir: str, 3312 video_temp_dir: str) -> None: 3313 self.fileSystemFilter.setTempDirs([photo_temp_dir, video_temp_dir]) 3314 self.temp_dirs_by_scan_id[scan_id] = list( 3315 filter(None,[photo_temp_dir, video_temp_dir]) 3316 ) 3317 3318 def cleanAllTempDirs(self): 3319 """ 3320 Deletes temporary files and folders used in all downloads. 3321 """ 3322 if self.temp_dirs_by_scan_id: 3323 logging.debug("Cleaning temporary directories") 3324 for scan_id in self.temp_dirs_by_scan_id: 3325 self.cleanTempDirsForScanId(scan_id, remove_entry=False) 3326 self.temp_dirs_by_scan_id = {} 3327 3328 def cleanTempDirsForScanId(self, scan_id: int, remove_entry: bool=True): 3329 """ 3330 Deletes temporary files and folders used in download. 3331 3332 :param scan_id: the scan id associated with the temporary 3333 directory 3334 :param remove_entry: if True, remove the scan_id from the 3335 dictionary tracking temporary directories 3336 """ 3337 3338 home_dir = os.path.expanduser("~") 3339 for d in self.temp_dirs_by_scan_id[scan_id]: 3340 assert d != home_dir 3341 if os.path.isdir(d): 3342 try: 3343 shutil.rmtree(d, ignore_errors=True) 3344 except: 3345 logging.error("Unknown error deleting temporary directory %s", d) 3346 if remove_entry: 3347 del self.temp_dirs_by_scan_id[scan_id] 3348 3349 @pyqtSlot(bool, RPDFile, int, 'PyQt_PyObject') 3350 def copyfilesDownloaded(self, download_succeeded: bool, 3351 rpd_file: RPDFile, 3352 download_count: int, 3353 mdata_exceptions: Optional[Tuple[Exception]]) -> None: 3354 3355 scan_id = rpd_file.scan_id 3356 3357 if scan_id not in self.devices: 3358 logging.debug( 3359 "Ignoring file %s because its device has been removed", rpd_file.full_file_name 3360 ) 3361 return 3362 3363 self.download_tracker.set_download_count_for_file(rpd_file.uid, download_count) 3364 self.download_tracker.set_download_count(scan_id, download_count) 3365 rpd_file.download_start_time = self.download_start_datetime 3366 if rpd_file.file_type == FileType.photo: 3367 rpd_file.generate_extension_case = self.prefs.photo_extension 3368 else: 3369 rpd_file.generate_extension_case = self.prefs.video_extension 3370 3371 if mdata_exceptions is not None and self.prefs.warn_fs_metadata_error: 3372 self.copy_metadata_errors.add_problem( 3373 worker_id=scan_id, path=rpd_file.temp_full_file_name, 3374 mdata_exceptions=mdata_exceptions 3375 ) 3376 3377 self.sendDataMessageToThread( 3378 self.rename_controller, 3379 data=RenameAndMoveFileData(rpd_file=rpd_file, 3380 download_count=download_count, 3381 download_succeeded=download_succeeded) 3382 ) 3383 3384 @pyqtSlot(int, 'PyQt_PyObject', 'PyQt_PyObject') 3385 def copyfilesBytesDownloaded(self, scan_id: int, 3386 total_downloaded: int, 3387 chunk_downloaded: int) -> None: 3388 """ 3389 Update the tracking and display of how many bytes have been 3390 downloaded / copied. 3391 """ 3392 3393 if scan_id not in self.devices: 3394 return 3395 3396 try: 3397 assert total_downloaded >= 0 3398 assert chunk_downloaded >= 0 3399 except AssertionError: 3400 logging.critical( 3401 "Unexpected negative values for total / chunk downloaded: %s %s ", 3402 total_downloaded, chunk_downloaded 3403 ) 3404 3405 self.download_tracker.set_total_bytes_copied(scan_id, total_downloaded) 3406 if len(self.devices.have_downloaded_from) > 1: 3407 model = self.mapModel(scan_id) 3408 model.percent_complete[scan_id] = self.download_tracker.get_percent_complete(scan_id) 3409 self.time_check.increment(bytes_downloaded=chunk_downloaded) 3410 self.time_remaining.update(scan_id, bytes_downloaded=chunk_downloaded) 3411 self.updateFileDownloadDeviceProgress() 3412 3413 @pyqtSlot(int, 'PyQt_PyObject') 3414 def copyfilesProblems(self, scan_id: int, problems: CopyingProblems) -> None: 3415 for problem in self.copy_metadata_errors.problems(worker_id=scan_id): 3416 problems.append(problem) 3417 3418 if problems: 3419 try: 3420 device = self.devices[scan_id] 3421 problems.name = device.display_name 3422 problems.uri=device.uri 3423 except KeyError: 3424 # Device has already been removed 3425 logging.error("Device with scan id %s unexpectedly removed", scan_id) 3426 device_archive = self.devices.device_archive[scan_id] 3427 problems.name = device_archive.name 3428 problems.uri = device_archive.uri 3429 finally: 3430 self.addErrorLogMessage(problems=problems) 3431 3432 @pyqtSlot(int) 3433 def copyfilesFinished(self, scan_id: int) -> None: 3434 if scan_id in self.devices: 3435 logging.debug("All files finished copying for %s", self.devices[scan_id].display_name) 3436 3437 @pyqtSlot(bool, RPDFile, int) 3438 def fileRenamedAndMoved(self, move_succeeded: bool, 3439 rpd_file: RPDFile, 3440 download_count: int) -> None: 3441 """ 3442 Called after a file has been renamed -- that is, moved from the 3443 temp dir it was downloaded into, and renamed using the file 3444 renaming rules 3445 """ 3446 3447 scan_id = rpd_file.scan_id 3448 3449 if scan_id not in self.devices: 3450 logging.debug( 3451 "Ignoring file %s because its device has been removed", 3452 rpd_file.download_full_file_name or rpd_file.full_file_name 3453 ) 3454 return 3455 3456 if rpd_file.mdatatime_caused_ctime_change and scan_id not in \ 3457 self.thumbnailModel.ctimes_differ: 3458 self.thumbnailModel.addCtimeDisparity(rpd_file=rpd_file) 3459 3460 if self.thumbnailModel.sendToDaemonThumbnailer(rpd_file=rpd_file): 3461 if rpd_file.status in constants.Downloaded: 3462 logging.debug( 3463 "Assigning daemon thumbnailer to work on %s", rpd_file.download_full_file_name 3464 ) 3465 self.sendDataMessageToThread( 3466 self.thumbnail_deamon_controller, 3467 data=ThumbnailDaemonData( 3468 rpd_file=rpd_file, 3469 write_fdo_thumbnail=self.prefs.save_fdo_thumbnails, 3470 use_thumbnail_cache=self.prefs.use_thumbnail_cache, 3471 force_exiftool=self.prefs.force_exiftool, 3472 ) 3473 ) 3474 else: 3475 logging.debug( 3476 '%s was not downloaded, so adjusting download tracking', rpd_file.full_file_name 3477 ) 3478 self.download_tracker.thumbnail_generated_post_download(scan_id) 3479 3480 if rpd_file.status in constants.Downloaded and \ 3481 self.fileSystemModel.add_subfolder_downloaded_into( 3482 path=rpd_file.download_path, download_folder=rpd_file.download_folder): 3483 if rpd_file.file_type == FileType.photo: 3484 self.photoDestinationFSView.expandPath(rpd_file.download_path) 3485 self.photoDestinationFSView.update() 3486 else: 3487 self.videoDestinationFSView.expandPath(rpd_file.download_path) 3488 self.videoDestinationFSView.update() 3489 3490 if self.prefs.backup_files: 3491 if self.backup_devices.backup_possible(rpd_file.file_type): 3492 self.backupFile(rpd_file, move_succeeded, download_count) 3493 else: 3494 self.fileDownloadFinished(move_succeeded, rpd_file) 3495 else: 3496 self.fileDownloadFinished(move_succeeded, rpd_file) 3497 3498 @pyqtSlot(RPDFile, QPixmap) 3499 def thumbnailReceivedFromDaemon(self, rpd_file: RPDFile, thumbnail: QPixmap) -> None: 3500 """ 3501 A thumbnail will be received directly from the daemon process when 3502 it was able to get a thumbnail from the FreeDesktop.org 256x256 3503 cache, and there was thus no need write another 3504 3505 :param rpd_file: rpd_file details of the file the thumbnail was 3506 generated for 3507 :param thumbnail: a thumbnail for display in the thumbnail view, 3508 """ 3509 3510 self.thumbnailModel.thumbnailReceived(rpd_file=rpd_file, thumbnail=thumbnail) 3511 3512 def thumbnailGeneratedPostDownload(self, rpd_file: RPDFile) -> None: 3513 """ 3514 Adjust download tracking to note that a thumbnail was generated 3515 after a file was downloaded. Possibly handle situation where 3516 all files have been downloaded. 3517 3518 A thumbnail will be generated post download if 3519 the sole task of the thumbnail extractors was to write out the 3520 FreeDesktop.org thumbnails, and/or if we didn't generate it before 3521 the download started. 3522 3523 :param rpd_file: details of the file 3524 """ 3525 3526 uid = rpd_file.uid 3527 scan_id = rpd_file.scan_id 3528 if self.prefs.backup_files and rpd_file.fdo_thumbnail_128_name: 3529 self.generated_fdo_thumbnails[uid] = rpd_file.fdo_thumbnail_128_name 3530 if uid in self.backup_fdo_thumbnail_cache: 3531 self.sendDataMessageToThread( 3532 self.thumbnail_deamon_controller, 3533 data=ThumbnailDaemonData( 3534 rpd_file=rpd_file, 3535 write_fdo_thumbnail=True, 3536 backup_full_file_names=self.backup_fdo_thumbnail_cache[uid], 3537 fdo_name=rpd_file.fdo_thumbnail_128_name, 3538 force_exiftool=self.prefs.force_exiftool 3539 ) 3540 ) 3541 del self.backup_fdo_thumbnail_cache[uid] 3542 self.download_tracker.thumbnail_generated_post_download(scan_id=scan_id) 3543 completed, files_remaining = self.isDownloadCompleteForScan(scan_id) 3544 if completed: 3545 self.fileDownloadCompleteFromDevice(scan_id=scan_id, files_remaining=files_remaining) 3546 3547 def thumbnailGenerationStopped(self, scan_id: int) -> None: 3548 """ 3549 Slot for when a the thumbnail worker has been forcefully stopped, 3550 rather than merely finished in its work 3551 3552 :param scan_id: scan_id of the device that was being thumbnailed 3553 """ 3554 if scan_id not in self.devices: 3555 logging.debug( 3556 "Ignoring scan_id %s from terminated thumbailing, as its device does " 3557 "not exist anymore", scan_id 3558 ) 3559 else: 3560 device = self.devices[scan_id] 3561 if scan_id in self.devices.cameras_to_stop_thumbnailing: 3562 self.devices.cameras_to_stop_thumbnailing.remove(scan_id) 3563 logging.debug("Thumbnailing successfully terminated for %s", device.display_name) 3564 if not self.devices.download_start_blocked(): 3565 self.startDownloadPhase2() 3566 else: 3567 logging.debug( 3568 "Ignoring the termination of thumbnailing from %s, as it's " 3569 "not for a camera from which a download was waiting to be started", 3570 device.display_name 3571 ) 3572 3573 @pyqtSlot(int, 'PyQt_PyObject') 3574 def backupFileProblems(self, device_id: int, problems: BackingUpProblems) -> None: 3575 for problem in self.backup_metadata_errors.problems(worker_id=device_id): 3576 problems.append(problem) 3577 3578 if problems: 3579 self.addErrorLogMessage(problems=problems) 3580 3581 def sendBackupStartFinishMessageToWorkers(self, message: BackupStatus) -> None: 3582 if self.prefs.backup_files: 3583 download_types = self.download_files.download_types 3584 for path in self.backup_devices: 3585 backup_type = self.backup_devices[path].backup_type 3586 if ( 3587 ( 3588 backup_type == BackupLocationType.photos_and_videos or 3589 download_types == DownloadingFileTypes.photos_and_videos 3590 ) or backup_type == download_types): 3591 device_id = self.backup_devices.device_id(path) 3592 data = BackupFileData(message=message) 3593 self.sendDataMessageToThread( 3594 self.backup_controller, worker_id=device_id, data=data 3595 ) 3596 3597 def backupFile(self, rpd_file: RPDFile, move_succeeded: bool, download_count: int) -> None: 3598 if self.prefs.backup_device_autodetection: 3599 if rpd_file.file_type == FileType.photo: 3600 path_suffix = self.prefs.photo_backup_identifier 3601 else: 3602 path_suffix = self.prefs.video_backup_identifier 3603 else: 3604 path_suffix = None 3605 3606 if rpd_file.file_type == FileType.photo: 3607 logging.debug("Backing up photo %s", rpd_file.download_name) 3608 else: 3609 logging.debug("Backing up video %s", rpd_file.download_name) 3610 3611 for path in self.backup_devices: 3612 backup_type = self.backup_devices[path].backup_type 3613 do_backup = ( 3614 (backup_type == BackupLocationType.photos_and_videos) or 3615 ( 3616 rpd_file.file_type == FileType.photo and backup_type == 3617 BackupLocationType.photos 3618 ) or ( 3619 rpd_file.file_type == FileType.video and backup_type == 3620 BackupLocationType.videos 3621 ) 3622 ) 3623 if do_backup: 3624 logging.debug("Backing up to %s", path) 3625 else: 3626 logging.debug("Not backing up to %s", path) 3627 # Even if not going to backup to this device, need to send it 3628 # anyway so progress bar can be updated. Not this most efficient 3629 # but the code is more simpler 3630 # TODO: investigate a more optimal approach! 3631 3632 device_id = self.backup_devices.device_id(path) 3633 data = BackupFileData( 3634 rpd_file=rpd_file, 3635 move_succeeded=move_succeeded, 3636 do_backup=do_backup, 3637 path_suffix=path_suffix, 3638 backup_duplicate_overwrite=self.prefs.backup_duplicate_overwrite, 3639 verify_file=self.prefs.verify_file, 3640 download_count=download_count, 3641 save_fdo_thumbnail=self.prefs.save_fdo_thumbnails 3642 ) 3643 self.sendDataMessageToThread(self.backup_controller, worker_id=device_id, data=data) 3644 3645 @pyqtSlot(int, bool, bool, RPDFile, str, 'PyQt_PyObject') 3646 def fileBackedUp(self, device_id: int, 3647 backup_succeeded: bool, 3648 do_backup: bool, 3649 rpd_file: RPDFile, 3650 backup_full_file_name: str, 3651 mdata_exceptions: Optional[Tuple[Exception]]) -> None: 3652 3653 if do_backup: 3654 if self.prefs.generate_thumbnails and self.prefs.save_fdo_thumbnails and \ 3655 rpd_file.should_write_fdo() and backup_succeeded: 3656 self.backupGenerateFdoThumbnail( 3657 rpd_file=rpd_file, backup_full_file_name=backup_full_file_name 3658 ) 3659 3660 self.download_tracker.file_backed_up(rpd_file.scan_id, rpd_file.uid) 3661 3662 if mdata_exceptions is not None and self.prefs.warn_fs_metadata_error: 3663 self.backup_metadata_errors.add_problem( 3664 worker_id=device_id, path=backup_full_file_name, 3665 mdata_exceptions=mdata_exceptions 3666 ) 3667 3668 if self.download_tracker.file_backed_up_to_all_locations( 3669 rpd_file.uid, rpd_file.file_type): 3670 logging.debug( 3671 "File %s will not be backed up to any more locations", rpd_file.download_name 3672 ) 3673 self.fileDownloadFinished(backup_succeeded, rpd_file) 3674 3675 @pyqtSlot('PyQt_PyObject', 'PyQt_PyObject') 3676 def backupFileBytesBackedUp(self, scan_id: int, chunk_downloaded: int) -> None: 3677 self.download_tracker.increment_bytes_backed_up(scan_id, chunk_downloaded) 3678 self.time_check.increment(bytes_downloaded=chunk_downloaded) 3679 self.time_remaining.update(scan_id, bytes_downloaded=chunk_downloaded) 3680 self.updateFileDownloadDeviceProgress() 3681 3682 def initializeBackupThumbCache(self) -> None: 3683 """ 3684 Prepare tracking of thumbnail generation for backed up files 3685 """ 3686 3687 # indexed by uid, deque of full backup paths 3688 self.generated_fdo_thumbnails = dict() # type: Dict[str] 3689 self.backup_fdo_thumbnail_cache = defaultdict(list) # type: Dict[List[str]] 3690 3691 def backupGenerateFdoThumbnail(self, rpd_file: RPDFile, backup_full_file_name: str) -> None: 3692 uid = rpd_file.uid 3693 if uid not in self.generated_fdo_thumbnails: 3694 logging.debug( 3695 "Caching FDO thumbnail creation for backup %s", backup_full_file_name 3696 ) 3697 self.backup_fdo_thumbnail_cache[uid].append(backup_full_file_name) 3698 else: 3699 # An FDO thumbnail has already been generated for the downloaded file 3700 assert uid not in self.backup_fdo_thumbnail_cache 3701 logging.debug( 3702 "Assigning daemon thumbnailer to create FDO thumbnail for %s", backup_full_file_name 3703 ) 3704 self.sendDataMessageToThread( 3705 self.thumbnail_deamon_controller, 3706 data=ThumbnailDaemonData( 3707 rpd_file=rpd_file, 3708 write_fdo_thumbnail=True, 3709 backup_full_file_names=[backup_full_file_name], 3710 fdo_name=self.generated_fdo_thumbnails[uid], 3711 force_exiftool=self.prefs.force_exiftool, 3712 ) 3713 ) 3714 3715 @pyqtSlot(int, list) 3716 def updateSequences(self, stored_sequence_no: int, downloads_today: List[str]) -> None: 3717 """ 3718 Called at conclusion of a download, with values coming from 3719 renameandmovefile process 3720 """ 3721 3722 self.prefs.stored_sequence_no = stored_sequence_no 3723 self.prefs.downloads_today = downloads_today 3724 self.prefs.sync() 3725 logging.debug("Saved sequence values to preferences") 3726 if self.application_state == ApplicationState.exiting: 3727 self.close() 3728 else: 3729 self.renamePanel.updateSequences( 3730 downloads_today=downloads_today, stored_sequence_no=stored_sequence_no 3731 ) 3732 3733 @pyqtSlot() 3734 def fileRenamedAndMovedFinished(self) -> None: 3735 """Currently not called""" 3736 pass 3737 3738 def isDownloadCompleteForScan(self, scan_id: int) -> Tuple[bool, int]: 3739 """ 3740 Determine if all files have been downloaded and backed up for a device 3741 3742 :param scan_id: device's scan id 3743 :return: True if the download is completed for that scan_id, 3744 and the number of files remaining for the scan_id, BUT 3745 the files remaining value is valid ONLY if the download is 3746 completed 3747 """ 3748 3749 completed = self.download_tracker.all_files_downloaded_by_scan_id(scan_id) 3750 if completed: 3751 logging.debug("All files downloaded for %s", self.devices[scan_id].display_name) 3752 if self.download_tracker.no_post_download_thumb_generation_by_scan_id[scan_id]: 3753 logging.debug( 3754 "Thumbnails generated for %s thus far during download: %s of %s", 3755 self.devices[scan_id].display_name, 3756 self.download_tracker.post_download_thumb_generation[scan_id], 3757 self.download_tracker.no_post_download_thumb_generation_by_scan_id[scan_id] 3758 ) 3759 completed = completed and \ 3760 self.download_tracker.all_post_download_thumbs_generated_for_scan(scan_id) 3761 3762 if completed and self.prefs.backup_files: 3763 completed = self.download_tracker.all_files_backed_up(scan_id) 3764 3765 if completed: 3766 files_remaining = self.thumbnailModel.getNoFilesRemaining(scan_id) 3767 else: 3768 files_remaining = 0 3769 3770 return completed, files_remaining 3771 3772 def updateFileDownloadDeviceProgress(self): 3773 """ 3774 Updates progress bar and optionally the Unity progress bar 3775 """ 3776 3777 percent_complete = self.download_tracker.get_overall_percent_complete() 3778 self.downloadProgressBar.setValue(round(percent_complete * 100)) 3779 if self.unity_progress: 3780 for launcher in self.desktop_launchers: 3781 launcher.set_property('progress', percent_complete) 3782 launcher.set_property('progress_visible', True) 3783 3784 def fileDownloadFinished(self, succeeded: bool, rpd_file: RPDFile) -> None: 3785 """ 3786 Called when a file has been downloaded i.e. copied, renamed, 3787 and backed up 3788 """ 3789 scan_id = rpd_file.scan_id 3790 3791 if self.prefs.move: 3792 # record which files to automatically delete when download 3793 # completes 3794 self.download_tracker.add_to_auto_delete(rpd_file) 3795 3796 self.thumbnailModel.updateStatusPostDownload(rpd_file) 3797 self.download_tracker.file_downloaded_increment( 3798 scan_id, rpd_file.file_type, rpd_file.status 3799 ) 3800 3801 device = self.devices[scan_id] 3802 device.download_statuses.add(rpd_file.status) 3803 3804 completed, files_remaining = self.isDownloadCompleteForScan(scan_id) 3805 if completed: 3806 self.fileDownloadCompleteFromDevice(scan_id=scan_id, files_remaining=files_remaining) 3807 3808 def fileDownloadCompleteFromDevice(self, scan_id: int, files_remaining: int) -> None: 3809 3810 device = self.devices[scan_id] 3811 3812 device_finished = files_remaining == 0 3813 if device_finished: 3814 logging.debug("All files from %s are downloaded; none remain", device.display_name) 3815 state = DeviceState.finished 3816 else: 3817 logging.debug( 3818 "Download finished from %s; %s remain be be potentially downloaded", 3819 device.display_name, files_remaining 3820 ) 3821 state = DeviceState.idle 3822 3823 self.devices.set_device_state(scan_id=scan_id, state=state) 3824 self.mapModel(scan_id).setSpinnerState(scan_id, state) 3825 3826 # Rebuild temporal proximity if it needs it 3827 if scan_id in self.thumbnailModel.ctimes_differ and not \ 3828 self.thumbnailModel.filesRemainToDownload(scan_id=scan_id): 3829 self.thumbnailModel.processCtimeDisparity(scan_id=scan_id) 3830 self.folder_preview_manager.queue_folder_removal_for_device(scan_id=scan_id) 3831 3832 # Last file for this scan id has been downloaded, so clean temp 3833 # directory 3834 logging.debug("Purging temp directories") 3835 self.cleanTempDirsForScanId(scan_id) 3836 if self.prefs.move: 3837 logging.debug("Deleting downloaded source files") 3838 self.deleteSourceFiles(scan_id) 3839 self.download_tracker.clear_auto_delete(scan_id) 3840 self.updateProgressBarState() 3841 self.thumbnailModel.updateDeviceDisplayCheckMark(scan_id=scan_id) 3842 3843 del self.time_remaining[scan_id] 3844 self.notifyDownloadedFromDevice(scan_id) 3845 if files_remaining == 0 and self.prefs.auto_unmount: 3846 self.unmountVolume(scan_id) 3847 3848 if not self.downloadIsRunning(): 3849 logging.debug("Download completed") 3850 self.dl_update_timer.stop() 3851 self.enablePrefsAndRefresh(enabled=True) 3852 self.notifyDownloadComplete() 3853 self.downloadProgressBar.reset() 3854 if self.prefs.backup_files: 3855 self.initializeBackupThumbCache() 3856 self.backupPanel.updateLocationCombos() 3857 3858 if self.unity_progress: 3859 for launcher in self.desktop_launchers: 3860 launcher.set_property('progress_visible', False) 3861 3862 self.folder_preview_manager.remove_folders_for_queued_devices() 3863 3864 # Update prefs with stored sequence number and downloads today 3865 # values 3866 data = RenameAndMoveFileData(message=RenameAndMoveStatus.download_completed) 3867 self.sendDataMessageToThread(self.rename_controller, data=data) 3868 3869 # Ask backup processes to send problem reports 3870 self.sendBackupStartFinishMessageToWorkers(message=BackupStatus.backup_completed) 3871 3872 if ((self.prefs.auto_exit and self.download_tracker.no_errors_or_warnings()) 3873 or self.prefs.auto_exit_force): 3874 3875 if not self.thumbnailModel.filesRemainToDownload(): 3876 logging.debug("Auto exit is initiated") 3877 self.close() 3878 3879 self.download_tracker.purge_all() 3880 3881 self.setDownloadActionLabel() 3882 self.setDownloadCapabilities() 3883 3884 self.download_start_datetime = None 3885 self.download_start_time = None 3886 3887 @pyqtSlot('PyQt_PyObject') 3888 def addErrorLogMessage(self, problems: Problems) -> None: 3889 3890 self.errorLog.addProblems(problems) 3891 increment = len(problems) 3892 if not self.errorLog.isActiveWindow(): 3893 self.errorsPending.incrementCounter(increment=increment) 3894 3895 def immediatelyDisplayDownloadRunningInStatusBar(self): 3896 """ 3897 Without any delay, immediately change the status bar message so the 3898 user knows the download has started. 3899 """ 3900 3901 self.statusBar().showMessage(self.devices.downloading_from()) 3902 3903 @pyqtSlot() 3904 def displayDownloadRunningInStatusBar(self): 3905 """ 3906 Display a message in the status bar about the current download 3907 """ 3908 if not self.downloadIsRunning(): 3909 self.dl_update_timer.stop() 3910 self.displayMessageInStatusBar() 3911 return 3912 3913 updated, download_speed = self.time_check.update_download_speed() 3914 if updated: 3915 3916 downloading = self.devices.downloading_from() 3917 3918 time_remaining = self.time_remaining.time_remaining(self.prefs.detailed_time_remaining) 3919 if (time_remaining is None or 3920 time.time() < self.download_start_time + constants.ShowTimeAndSpeedDelay): 3921 message = downloading 3922 else: 3923 # Translators - in the middle is a unicode em dash - please retain it 3924 # This string is displayed in the status bar when the download is running 3925 # Translators: %(variable)s represents Python code, not a plural of the term 3926 # variable. You must keep the %(variable)s untranslated, or the program will 3927 # crash. 3928 message = _( 3929 '%(downloading_from)s — %(time_left)s left (%(speed)s)' 3930 ) % dict( 3931 downloading_from=downloading, 3932 time_left=time_remaining, 3933 speed=download_speed 3934 ) 3935 self.statusBar().showMessage(message) 3936 3937 def enablePrefsAndRefresh(self, enabled: bool) -> None: 3938 """ 3939 Disable the user being to access the refresh command or change various 3940 program preferences while a download is occurring. 3941 3942 :param enabled: if True, then the user is able to activate the 3943 preferences and refresh commands. 3944 """ 3945 3946 self.refreshAct.setEnabled(enabled) 3947 self.preferencesAct.setEnabled(enabled) 3948 self.renamePanel.setEnabled(enabled) 3949 self.backupPanel.setEnabled(enabled) 3950 self.jobCodePanel.setEnabled(enabled) 3951 3952 def unmountVolume(self, scan_id: int) -> None: 3953 """ 3954 Cameras are already unmounted, so no need to unmount them! 3955 :param scan_id: the scan id of the device to be umounted 3956 """ 3957 3958 device = self.devices[scan_id] # type: Device 3959 3960 if device.device_type == DeviceType.volume: 3961 if self.gvfsControlsMounts: 3962 self.gvolumeMonitor.unmountVolume(path=device.path) 3963 else: 3964 self.udisks2Unmount.emit(device.path) 3965 3966 def deleteSourceFiles(self, scan_id: int) -> None: 3967 """ 3968 Delete files from download device at completion of download 3969 """ 3970 # TODO delete from cameras and from other devices 3971 # TODO should assign this to a process or a thread, and delete then 3972 to_delete = self.download_tracker.get_files_to_auto_delete(scan_id) 3973 3974 def notifyDownloadedFromDevice(self, scan_id: int) -> None: 3975 """ 3976 Display a system notification to the user using libnotify 3977 that the files have been downloaded from the device 3978 :param scan_id: identifies which device 3979 """ 3980 3981 device = self.devices[scan_id] 3982 3983 notification_name = device.display_name 3984 3985 no_photos_downloaded = self.download_tracker.get_no_files_downloaded( 3986 scan_id, FileType.photo 3987 ) 3988 no_videos_downloaded = self.download_tracker.get_no_files_downloaded( 3989 scan_id, FileType.video 3990 ) 3991 no_photos_failed = self.download_tracker.get_no_files_failed(scan_id, FileType.photo) 3992 no_videos_failed = self.download_tracker.get_no_files_failed(scan_id, FileType.video) 3993 no_files_downloaded = no_photos_downloaded + no_videos_downloaded 3994 no_files_failed = no_photos_failed + no_videos_failed 3995 no_warnings = self.download_tracker.get_no_warnings(scan_id) 3996 3997 file_types = file_types_by_number(no_photos_downloaded, no_videos_downloaded) 3998 file_types_failed = file_types_by_number(no_photos_failed, no_videos_failed) 3999 # Translators: e.g. 23 photos downloaded 4000 # Translators: %(variable)s represents Python code, not a plural of the term 4001 # variable. You must keep the %(variable)s untranslated, or the program will 4002 # crash. 4003 message = _( 4004 "%(noFiles)s %(filetypes)s downloaded" 4005 ) % { 4006 'noFiles': thousands(no_files_downloaded), 'filetypes': file_types 4007 } 4008 4009 if no_files_failed: 4010 # Translators: e.g. 2 videos failed to download 4011 # Translators: %(variable)s represents Python code, not a plural of the term 4012 # variable. You must keep the %(variable)s untranslated, or the program will 4013 # crash. 4014 message += "\n" + _( 4015 "%(noFiles)s %(filetypes)s failed to download" 4016 ) % { 4017 'noFiles': thousands(no_files_failed), 'filetypes': file_types_failed 4018 } 4019 4020 if no_warnings: 4021 message = "%s\n%s " % (message, no_warnings) + _("warnings") 4022 4023 message_shown = False 4024 if self.have_libnotify: 4025 n = Notify.Notification.new(notification_name, message, 'rapid-photo-downloader') 4026 try: 4027 message_shown = n.show() 4028 except: 4029 logging.error( 4030 "Unable to display downloaded from device message using notification system" 4031 ) 4032 if not message_shown: 4033 logging.error( 4034 "Unable to display downloaded from device message using notification system" 4035 ) 4036 logging.info("{}: {}".format(notification_name, message)) 4037 4038 def notifyDownloadComplete(self) -> None: 4039 """ 4040 Notify all downloads are complete 4041 4042 If having downloaded from more than one device, display a 4043 system notification to the user using libnotify that all files 4044 have been downloaded. 4045 4046 Regardless of how many downloads have been downloaded 4047 from, display message in status bar. 4048 """ 4049 4050 show_notification = len(self.devices.have_downloaded_from) > 1 4051 4052 n_message = _("All downloads complete") 4053 4054 # photo downloads 4055 photo_downloads = self.download_tracker.total_photos_downloaded 4056 if photo_downloads and show_notification: 4057 filetype = file_types_by_number(photo_downloads, 0) 4058 # Translators: e.g. 23 photos downloaded 4059 # Translators: %(variable)s represents Python code, not a plural of the term 4060 # variable. You must keep the %(variable)s untranslated, or the program will 4061 # crash. 4062 n_message += "\n" + _( 4063 "%(number)s %(numberdownloaded)s" 4064 ) % dict( 4065 number=thousands(photo_downloads), 4066 # Translators: %(variable)s represents Python code, not a plural of the term 4067 # variable. You must keep the %(variable)s untranslated, or the program will 4068 # crash. 4069 numberdownloaded=_("%(filetype)s downloaded") % dict(filetype=filetype) 4070 ) 4071 4072 # photo failures 4073 photo_failures = self.download_tracker.total_photo_failures 4074 if photo_failures and show_notification: 4075 filetype = file_types_by_number(photo_failures, 0) 4076 # Translators: %(variable)s represents Python code, not a plural of the term 4077 # variable. You must keep the %(variable)s untranslated, or the program will 4078 # crash. 4079 n_message += "\n" + _( 4080 "%(number)s %(numberdownloaded)s" 4081 ) % dict( 4082 number=thousands(photo_failures), 4083 # Translators: %(variable)s represents Python code, not a plural of the term 4084 # variable. You must keep the %(variable)s untranslated, or the program will 4085 # crash. 4086 numberdownloaded=_("%(filetype)s failed to download") % dict(filetype=filetype) 4087 ) 4088 4089 # video downloads 4090 video_downloads = self.download_tracker.total_videos_downloaded 4091 if video_downloads and show_notification: 4092 filetype = file_types_by_number(0, video_downloads) 4093 # Translators: %(variable)s represents Python code, not a plural of the term 4094 # variable. You must keep the %(variable)s untranslated, or the program will 4095 # crash. 4096 n_message += "\n" + _( 4097 "%(number)s %(numberdownloaded)s" 4098 ) % dict( 4099 number=thousands(video_downloads), 4100 # Translators: %(variable)s represents Python code, not a plural of the term 4101 # variable. You must keep the %(variable)s untranslated, or the program will 4102 # crash. 4103 numberdownloaded=_("%(filetype)s downloaded") % dict(filetype=filetype) 4104 ) 4105 4106 # video failures 4107 video_failures = self.download_tracker.total_video_failures 4108 if video_failures and show_notification: 4109 filetype = file_types_by_number(0, video_failures) 4110 # Translators: %(variable)s represents Python code, not a plural of the term 4111 # variable. You must keep the %(variable)s untranslated, or the program will 4112 # crash. 4113 n_message += "\n" + _( 4114 "%(number)s %(numberdownloaded)s" 4115 ) % dict( 4116 number=thousands(video_failures), 4117 # Translators: %(variable)s represents Python code, not a plural of the term 4118 # variable. You must keep the %(variable)s untranslated, or the program will 4119 # crash. 4120 numberdownloaded=_("%(filetype)s failed to download") % dict(filetype=filetype) 4121 ) 4122 4123 # warnings 4124 warnings = self.download_tracker.total_warnings 4125 if warnings and show_notification: 4126 # Translators: %(variable)s represents Python code, not a plural of the term 4127 # variable. You must keep the %(variable)s untranslated, or the program will 4128 # crash. 4129 n_message += "\n" + _( 4130 "%(number)s %(numberdownloaded)s" 4131 ) % dict( 4132 number=thousands(warnings), 4133 numberdownloaded=_("warnings") 4134 ) 4135 4136 if show_notification: 4137 message_shown = False 4138 if self.have_libnotify: 4139 n = Notify.Notification.new( 4140 _('Rapid Photo Downloader'), n_message, 'rapid-photo-downloader' 4141 ) 4142 try: 4143 message_shown = n.show() 4144 except Exception: 4145 logging.error( 4146 "Unable to display download complete message using notification system" 4147 ) 4148 if not message_shown: 4149 logging.error( 4150 "Unable to display download complete message using notification system" 4151 ) 4152 4153 failures = photo_failures + video_failures 4154 4155 if failures == 1: 4156 f = _('1 failure') 4157 elif failures > 1: 4158 f = _('%d failures') % failures 4159 else: 4160 f = '' 4161 4162 if warnings == 1: 4163 w = _('1 warning') 4164 elif warnings > 1: 4165 w = _('%d warnings') % warnings 4166 else: 4167 w = '' 4168 4169 if f and w: 4170 fw = make_internationalized_list((f, w)) 4171 elif f: 4172 fw = f 4173 elif w: 4174 fw = w 4175 else: 4176 fw = '' 4177 4178 devices = self.devices.reset_and_return_have_downloaded_from() 4179 if photo_downloads + video_downloads: 4180 ftc = FileTypeCounter( 4181 {FileType.photo: photo_downloads, FileType.video: video_downloads} 4182 ) 4183 no_files_and_types = ftc.file_types_present_details().lower() 4184 4185 if not fw: 4186 # Translators: %(variable)s represents Python code, not a plural of the term 4187 # variable. You must keep the %(variable)s untranslated, or the program will 4188 # crash. 4189 downloaded = _( 4190 'Downloaded %(no_files_and_types)s from %(devices)s' 4191 ) % dict(no_files_and_types=no_files_and_types, devices=devices) 4192 else: 4193 # Translators: %(variable)s represents Python code, not a plural of the term 4194 # variable. You must keep the %(variable)s untranslated, or the program will 4195 # crash. 4196 downloaded = _( 4197 'Downloaded %(no_files_and_types)s from %(devices)s — %(failures)s' 4198 ) % dict(no_files_and_types=no_files_and_types, devices=devices, failures=fw) 4199 else: 4200 if fw: 4201 # Translators: %(variable)s represents Python code, not a plural of the term 4202 # variable. You must keep the %(variable)s untranslated, or the program will 4203 # crash. 4204 downloaded = _('No files downloaded — %(failures)s') % dict(failures=fw) 4205 else: 4206 downloaded = _('No files downloaded') 4207 logging.info('%s', downloaded) 4208 self.statusBar().showMessage(downloaded) 4209 4210 def invalidDownloadFolders(self, downloading: DownloadingFileTypes) -> List[str]: 4211 """ 4212 Checks validity of download folders based on the file types the 4213 user is attempting to download. 4214 4215 :return list of the invalid directories, if any, or empty list. 4216 """ 4217 4218 invalid_dirs = [] 4219 4220 # sadly this causes an exception on python 3.4: 4221 # downloading.photos or downloading.photos_and_videos 4222 4223 if downloading in (DownloadingFileTypes.photos, DownloadingFileTypes.photos_and_videos): 4224 if not validate_download_folder(self.prefs.photo_download_folder).valid: 4225 invalid_dirs.append(self.prefs.photo_download_folder) 4226 if downloading in (DownloadingFileTypes.videos, DownloadingFileTypes.photos_and_videos): 4227 if not validate_download_folder(self.prefs.video_download_folder).valid: 4228 invalid_dirs.append(self.prefs.video_download_folder) 4229 return invalid_dirs 4230 4231 def notifyPrefsAreInvalid(self, details: str) -> None: 4232 """ 4233 Notifies the user that the preferences are invalid. 4234 4235 Assumes that the main window is already showing 4236 :param details: preference error details 4237 """ 4238 4239 logging.error("Program preferences are invalid: %s", details) 4240 title = _("Program preferences are invalid") 4241 # Translators: %(variable)s represents Python code, not a plural of the term 4242 # variable. You must keep the %(variable)s untranslated, or the program will 4243 # crash. 4244 # Translators: please do not change HTML codes like <br>, <i>, </i>, or <b>, </b> etc. 4245 message = "<b>%(title)s</b><br><br>%(details)s" % dict(title=title, details=details) 4246 msgBox = standardMessageBox( 4247 message=message, rich_text=True, standardButtons=QMessageBox.Ok, 4248 iconType=QMessageBox.Warning 4249 ) 4250 msgBox.exec() 4251 4252 def deviceState(self, scan_id: int) -> DeviceState: 4253 """ 4254 What the device is being used for at the present moment. 4255 4256 :param scan_id: device to check 4257 :return: DeviceState 4258 """ 4259 4260 return self.devices.device_state[scan_id] 4261 4262 @pyqtSlot('PyQt_PyObject', 'PyQt_PyObject', FileTypeCounter, 'PyQt_PyObject', bool, bool) 4263 def scanFilesReceived(self, rpd_files: List[RPDFile], 4264 sample_files: List[RPDFile], 4265 file_type_counter: FileTypeCounter, 4266 file_size_sum: FileSizeSum, 4267 entire_video_required: Optional[bool], 4268 entire_photo_required: Optional[bool]) -> None: 4269 """ 4270 Process scanned file information received from the scan process 4271 """ 4272 4273 # Update scan running totals 4274 scan_id = rpd_files[0].scan_id 4275 if scan_id not in self.devices: 4276 return 4277 device = self.devices[scan_id] 4278 4279 sample_photo, sample_video = sample_files 4280 if sample_photo is not None: 4281 logging.info( 4282 "Updating example file name using sample photo from %s", device.display_name 4283 ) 4284 self.devices.sample_photo = sample_photo # type: Photo 4285 self.renamePanel.setSamplePhoto(self.devices.sample_photo) 4286 # sample required for editing download subfolder generation 4287 self.photoDestinationDisplay.sample_rpd_file = self.devices.sample_photo 4288 4289 if sample_video is not None: 4290 logging.info( 4291 "Updating example file name using sample video from %s", device.display_name 4292 ) 4293 self.devices.sample_video = sample_video # type: Video 4294 self.renamePanel.setSampleVideo(self.devices.sample_video) 4295 # sample required for editing download subfolder generation 4296 self.videoDestinationDisplay.sample_rpd_file = self.devices.sample_video 4297 4298 if device.device_type == DeviceType.camera: 4299 if entire_video_required is not None: 4300 device.entire_video_required = entire_video_required 4301 if entire_photo_required is not None: 4302 device.entire_photo_required = entire_photo_required 4303 4304 device.file_type_counter = file_type_counter 4305 device.file_size_sum = file_size_sum 4306 4307 self.mapModel(scan_id).updateDeviceScan(scan_id) 4308 4309 self.thumbnailModel.addFiles( 4310 scan_id=scan_id, rpd_files=rpd_files, generate_thumbnail=not self.autoStart(scan_id) 4311 ) 4312 self.folder_preview_manager.add_rpd_files(rpd_files=rpd_files) 4313 4314 @pyqtSlot(int, CameraErrorCode) 4315 def scanErrorReceived(self, scan_id: int, error_code: CameraErrorCode) -> None: 4316 """ 4317 Notify the user their camera/phone is inaccessible. 4318 4319 :param scan_id: scan id of the device 4320 :param error_code: the specific libgphoto2 error, mapped onto our own 4321 enum 4322 """ 4323 4324 if scan_id not in self.devices: 4325 return 4326 4327 # During program startup, the main window may not yet be showing 4328 self.showMainWindow() 4329 4330 # An error occurred 4331 device = self.devices[scan_id] 4332 camera_model = device.display_name 4333 if error_code == CameraErrorCode.locked: 4334 title =_('Rapid Photo Downloader') 4335 # Translators: %(variable)s represents Python code, not a plural of the term 4336 # variable. You must keep the %(variable)s untranslated, or the program will 4337 # crash. 4338 # Translators: please do not change HTML codes like <br>, <i>, </i>, or <b>, </b> etc. 4339 message = _( 4340 '<b>All files on the %(camera)s are inaccessible</b>.<br><br>It ' 4341 'may be locked or not configured for file transfers using USB. ' 4342 'You can unlock it and try again.<br><br>On some models you also ' 4343 'need to change the setting to allow the use of USB for ' 4344 '<i>File Transfer</i>.<br><br>' 4345 'Learn more about ' 4346 '<a href="https://damonlynch.net/rapid/documentation/#downloadingfromcameras">' 4347 'downloading from cameras</a> and ' 4348 '<a href="https://damonlynch.net/rapid/documentation/#downloadingfromphones">' 4349 'enabling downloading from phones</a>. <br><br>' 4350 'Alternatively, you can ignore the %(camera)s.' 4351 ) % {'camera': camera_model} 4352 else: 4353 assert error_code == CameraErrorCode.inaccessible 4354 title = _('Rapid Photo Downloader') 4355 # Translators: %(variable)s represents Python code, not a plural of the term 4356 # variable. You must keep the %(variable)s untranslated, or the program will 4357 # crash. 4358 # Translators: please do not change HTML codes like <br>, <i>, </i>, or <b>, </b> etc. 4359 message = _( 4360 '<b>The %(camera)s appears to be in use by another ' 4361 'application.</b><br><br>Rapid Photo Downloader cannnot access a phone or camera ' 4362 'that is being used by another program like a file manager.<br><br>' 4363 'If the device is mounted in your file manager, you must first "eject" ' 4364 'it from the other program while keeping the %(camera)s plugged in.<br><br>' 4365 'If that does not work, unplug the ' 4366 '%(camera)s from the computer and plug it in again.<br><br>' 4367 'Learn more about ' 4368 '<a href="https://damonlynch.net/rapid/documentation/#downloadingfromcameras">' 4369 'downloading from cameras</a> and ' 4370 '<a href="https://damonlynch.net/rapid/documentation/#downloadingfromphones">' 4371 'enabling downloading from phones</a>. <br><br>' 4372 'Alternatively, you can ignore the %(camera)s.' 4373 ) % {'camera':camera_model} 4374 4375 msgBox = QMessageBox( 4376 QMessageBox.Warning, title, message, QMessageBox.NoButton, self 4377 ) 4378 msgBox.setIconPixmap(self.devices[scan_id].get_pixmap()) 4379 msgBox.addButton(_("&Try Again"), QMessageBox.AcceptRole) 4380 msgBox.addButton(_("&Ignore This Device"), QMessageBox.RejectRole) 4381 self.prompting_for_user_action[device] = msgBox 4382 role = msgBox.exec_() 4383 if role == QMessageBox.AcceptRole: 4384 self.sendResumeToThread(self.scan_controller, worker_id=scan_id) 4385 else: 4386 self.removeDevice(scan_id=scan_id, show_warning=False) 4387 del self.prompting_for_user_action[device] 4388 4389 @pyqtSlot(int, 'PyQt_PyObject', 'PyQt_PyObject', str) 4390 def scanDeviceDetailsReceived(self, scan_id: int, 4391 storage_space: List[StorageSpace], 4392 storage_descriptions: List[str], 4393 optimal_display_name: str) -> None: 4394 """ 4395 Update GUI display and rows DB with definitive camera display name 4396 4397 :param scan_id: scan id of the device 4398 :param storage_space: storage information on the device e.g. 4399 memory card(s) capacity and use 4400 :param storage_desctriptions: names of storage on a camera 4401 :param optimal_display_name: canonical name of the device, as 4402 reported by libgphoto2 4403 """ 4404 4405 if scan_id in self.devices: 4406 device = self.devices[scan_id] 4407 logging.debug( 4408 '%s with scan id %s is now known as %s', 4409 device.display_name, scan_id, optimal_display_name 4410 ) 4411 4412 if len(storage_space) > 1: 4413 logging.debug( 4414 '%s has %s storage devices', optimal_display_name, len(storage_space) 4415 ) 4416 4417 if not storage_descriptions: 4418 logging.warning("No storage descriptors available for %s", optimal_display_name) 4419 else: 4420 if len(storage_descriptions) == 1: 4421 msg = 'description' 4422 else: 4423 msg = 'descriptions' 4424 logging.debug("Storage %s: %s", msg, ', '.join(storage_descriptions)) 4425 4426 device.update_camera_attributes( 4427 display_name=optimal_display_name, storage_space=storage_space, 4428 storage_descriptions=storage_descriptions 4429 ) 4430 self.updateSourceButton() 4431 self.deviceModel.updateDeviceNameAndStorage(scan_id, device) 4432 self.thumbnailModel.addOrUpdateDevice(scan_id=scan_id) 4433 self.adjustLeftPanelSliderHandles() 4434 else: 4435 logging.debug( 4436 "Ignoring optimal display name %s and other details because that device was " 4437 "removed", optimal_display_name 4438 ) 4439 4440 @pyqtSlot(int, 'PyQt_PyObject') 4441 def scanProblemsReceived(self, scan_id: int, problems: Problems) -> None: 4442 self.addErrorLogMessage(problems=problems) 4443 4444 @pyqtSlot(int) 4445 def scanFatalError(self, scan_id: int) -> None: 4446 try: 4447 device = self.devices[scan_id] 4448 except KeyError: 4449 logging.debug("Got scan error from device that no longer exists (scan_id %s)", scan_id) 4450 return 4451 4452 h1 = _('Sorry, an unexpected problem occurred while scanning %s.') % device.display_name 4453 h2 = _('Unfortunately you cannot download from this device.') 4454 header = '<b>{}</b><br><br>{}'.format(h1, h2) 4455 if device.device_type == DeviceType.camera and not device.is_mtp_device: 4456 h3 = _( 4457 "A possible workaround for the problem might be downloading from the camera's " 4458 "memory card using a card reader." 4459 ) 4460 header = '{}<br><br><i>{}</i>'.format(header, h3) 4461 4462 title = _('Device scan failed') 4463 self.makeProblemReportDialog(header=header, title=title) 4464 4465 self.removeDevice(scan_id=scan_id, show_warning=False) 4466 4467 @pyqtSlot(int) 4468 def cameraRemovedDuringScan(self, scan_id: int) -> None: 4469 """ 4470 Scenarios: a camera was physically removed, or file transfer was disabled on an MTP device. 4471 4472 If disabled, a problem is that the device has not yet been removed from the system. 4473 4474 But in any case, sometimes camera removal is not picked up by the system while it's being 4475 accessed. So let's remove it ourselves. 4476 4477 :param scan_id: device that was removed / disabled 4478 """ 4479 4480 try: 4481 device = self.devices[scan_id] 4482 except KeyError: 4483 logging.debug("Got scan error from device that no longer exists (scan id %s)", scan_id) 4484 return 4485 4486 logging.debug("Camera %s was removed during a scan", device.display_name) 4487 self.removeDevice(scan_id=scan_id) 4488 4489 @pyqtSlot(int) 4490 def cameraRemovedWhileThumbnailing(self, scan_id: int) -> None: 4491 """ 4492 Scenarios: a camera was physically removed, or file transfer was disabled on an MTP device. 4493 4494 If disabled, a problem is that the device has not yet been removed from the system. 4495 4496 But in any case, sometimes camera removal is not picked up by the system while it's being 4497 accessed. So let's remove it ourselves. 4498 4499 :param scan_id: device that was removed / disabled 4500 """ 4501 4502 try: 4503 device = self.devices[scan_id] 4504 except KeyError: 4505 logging.debug( 4506 "Got thumbnailing error from a camera that no longer exists (scan id %s)", scan_id 4507 ) 4508 return 4509 4510 logging.debug( 4511 "Camera %s was removed while thumbnails were being generated", device.display_name 4512 ) 4513 self.removeDevice(scan_id=scan_id) 4514 4515 @pyqtSlot(int) 4516 def cameraRemovedWhileCopyingFiles(self, scan_id: int) -> None: 4517 """ 4518 Scenarios: a camera was physically removed, or file transfer was disabled on an MTP device. 4519 4520 If disabled, a problem is that the device has not yet been removed from the system. 4521 4522 But in any case, sometimes camera removal is not picked up by the system while it's being 4523 accessed. So let's remove it ourselves. 4524 4525 :param scan_id: device that was removed / disabled 4526 """ 4527 4528 try: 4529 device = self.devices[scan_id] 4530 except KeyError: 4531 logging.debug( 4532 "Got copy files error from a camera that no longer exists (scan id %s)", scan_id 4533 ) 4534 return 4535 4536 logging.debug( 4537 "Camera %s was removed while filed were being copied from it", device.display_name 4538 ) 4539 self.removeDevice(scan_id=scan_id) 4540 4541 @pyqtSlot(int) 4542 def scanFinished(self, scan_id: int) -> None: 4543 """ 4544 A single device has finished its scan. Other devices can be in any 4545 one of a number of states. 4546 4547 :param scan_id: scan id of the device that finished scanning 4548 """ 4549 4550 if scan_id not in self.devices: 4551 return 4552 device = self.devices[scan_id] 4553 self.devices.set_device_state(scan_id, DeviceState.idle) 4554 self.thumbnailModel.flushAddBuffer() 4555 4556 self.updateProgressBarState() 4557 self.thumbnailModel.updateAllDeviceDisplayCheckMarks() 4558 results_summary, file_types_present = device.file_type_counter.summarize_file_count() 4559 self.download_tracker.set_file_types_present(scan_id, file_types_present) 4560 model = self.mapModel(scan_id) 4561 model.updateDeviceScan(scan_id) 4562 destinations_good = self.setDownloadCapabilities() 4563 4564 self.logState() 4565 4566 if len(self.devices.scanning) == 0: 4567 self.generateTemporalProximityTableData("a download source has finished being scanned") 4568 else: 4569 self.temporalProximity.setState(TemporalProximityState.pending) 4570 4571 if not destinations_good: 4572 auto_start = False 4573 else: 4574 auto_start = self.autoStart(scan_id) 4575 4576 if not auto_start and self.prefs.generate_thumbnails: 4577 # Generate thumbnails for finished scan 4578 model.setSpinnerState(scan_id, DeviceState.idle) 4579 if scan_id in self.thumbnailModel.no_thumbnails_by_scan: 4580 self.devices.set_device_state(scan_id, DeviceState.thumbnailing) 4581 self.updateProgressBarState() 4582 self.thumbnailModel.generateThumbnails(scan_id, self.devices[scan_id]) 4583 self.displayMessageInStatusBar() 4584 elif auto_start: 4585 self.displayMessageInStatusBar() 4586 if self.jobCodePanel.needToPromptForJobCode(): 4587 self.showMainWindow() 4588 model.setSpinnerState(scan_id, DeviceState.idle) 4589 start_download = self.jobCodePanel.getJobCodeBeforeDownload() 4590 if not start_download: 4591 logging.debug( 4592 "Not auto-starting download, because a job code is already being " 4593 "prompted for." 4594 ) 4595 else: 4596 start_download = True 4597 if start_download: 4598 if self.download_paused: 4599 self.devices.queued_to_download.add(scan_id) 4600 else: 4601 self.startDownload(scan_id=scan_id) 4602 else: 4603 # not generating thumbnails, and auto start is not on 4604 model.setSpinnerState(scan_id, DeviceState.idle) 4605 self.displayMessageInStatusBar() 4606 4607 def autoStart(self, scan_id: int) -> bool: 4608 """ 4609 Determine if the download for this device should start automatically 4610 :param scan_id: scan id of the device 4611 :return: True if the should start automatically, else False, 4612 """ 4613 4614 prefs_valid, msg = self.prefs.check_prefs_for_validity() 4615 if not prefs_valid: 4616 return False 4617 4618 if not self.thumbnailModel.filesAreMarkedForDownload(scan_id): 4619 logging.debug( 4620 "No files are marked for download for %s", self.devices[scan_id].display_name 4621 ) 4622 return False 4623 4624 if scan_id in self.devices.startup_devices: 4625 return self.prefs.auto_download_at_startup 4626 else: 4627 return self.prefs.auto_download_upon_device_insertion 4628 4629 def quit(self) -> None: 4630 """ 4631 Convenience function to quit the application. 4632 4633 Issues a signal to initiate the quit. The signal will be acted 4634 on when Qt gets the chance. 4635 """ 4636 4637 QTimer.singleShot(0, self.close) 4638 4639 def generateTemporalProximityTableData(self, reason: str) -> None: 4640 """ 4641 Initiate Timeline generation if it's right to do so 4642 """ 4643 4644 if self.temporalProximity.state == TemporalProximityState.ctime_rebuild: 4645 logging.info( 4646 "Was tasked to generate Timeline because %s, but ignoring request " 4647 "because a rebuild is required ", reason 4648 ) 4649 return 4650 4651 rows = self.thumbnailModel.dataForProximityGeneration() 4652 if rows: 4653 logging.info("Generating Timeline because %s", reason) 4654 4655 self.temporalProximity.setState(TemporalProximityState.generating) 4656 data = OffloadData(thumbnail_rows=rows, proximity_seconds=self.prefs.proximity_seconds) 4657 self.sendToOffload(data=data) 4658 else: 4659 logging.info( 4660 "Was tasked to generate Timeline because %s, but there is nothing to generate", 4661 reason 4662 ) 4663 4664 4665 @pyqtSlot(TemporalProximityGroups) 4666 def proximityGroupsGenerated(self, proximity_groups: TemporalProximityGroups) -> None: 4667 if self.temporalProximity.setGroups(proximity_groups=proximity_groups): 4668 self.thumbnailModel.assignProximityGroups(proximity_groups.col1_col2_uid) 4669 4670 def closeEvent(self, event) -> None: 4671 logging.debug("Close event activated") 4672 4673 if self.close_event_run: 4674 logging.debug("Close event already run: accepting close event") 4675 event.accept() 4676 return 4677 4678 if self.application_state == ApplicationState.normal: 4679 self.application_state = ApplicationState.exiting 4680 self.sendStopToThread(self.scan_controller) 4681 self.thumbnailModel.stopThumbnailer() 4682 self.sendStopToThread(self.copy_controller) 4683 4684 if self.downloadIsRunning(): 4685 logging.debug("Exiting while download is running. Cleaning up...") 4686 # Update prefs with stored sequence number and downloads today 4687 # values 4688 data = RenameAndMoveFileData(message=RenameAndMoveStatus.download_completed) 4689 self.sendDataMessageToThread(self.rename_controller, data=data) 4690 # renameandmovefile process will send a message with the 4691 # updated sequence values. When that occurs, 4692 # this application will save the sequence values to the 4693 # program preferences, resume closing and this close event 4694 # will again be called, but this time the application state 4695 # flag will indicate the need to resume below. 4696 logging.debug("Ignoring close event") 4697 event.ignore() 4698 return 4699 # Incidentally, it's the renameandmovefile process that 4700 # updates the SQL database with the file downloads, 4701 # so no need to update or close it in this main process 4702 4703 if self.unity_progress: 4704 for launcher in self.desktop_launchers: 4705 launcher.set_property("count", 0) 4706 launcher.set_property("count_visible", False) 4707 launcher.set_property('progress_visible', False) 4708 4709 self.writeWindowSettings() 4710 logging.debug("Cleaning up provisional download folders") 4711 self.folder_preview_manager.remove_preview_folders() 4712 4713 # write settings before closing error log window 4714 self.errorLog.done(0) 4715 4716 logging.debug("Terminating main ExifTool process") 4717 self.exiftool_process.terminate() 4718 4719 self.sendStopToThread(self.offload_controller) 4720 self.offloadThread.quit() 4721 if not self.offloadThread.wait(500): 4722 self.sendTerminateToThread(self.offload_controller) 4723 4724 self.sendStopToThread(self.rename_controller) 4725 self.renameThread.quit() 4726 if not self.renameThread.wait(500): 4727 self.sendTerminateToThread(self.rename_controller) 4728 4729 self.scanThread.quit() 4730 if not self.scanThread.wait(2000): 4731 self.sendTerminateToThread(self.scan_controller) 4732 4733 self.copyfilesThread.quit() 4734 if not self.copyfilesThread.wait(1000): 4735 self.sendTerminateToThread(self.copy_controller) 4736 4737 self.sendStopToThread(self.backup_controller) 4738 self.backupThread.quit() 4739 if not self.backupThread.wait(1000): 4740 self.sendTerminateToThread(self.backup_controller) 4741 4742 if not self.gvfsControlsMounts: 4743 self.cameraHotplugThread.quit() 4744 self.cameraHotplugThread.wait() 4745 else: 4746 del self.gvolumeMonitor 4747 4748 if not version_check_disabled(): 4749 self.newVersionThread.quit() 4750 self.newVersionThread.wait(100) 4751 4752 self.sendStopToThread(self.thumbnail_deamon_controller) 4753 self.thumbnaildaemonmqThread.quit() 4754 if not self.thumbnaildaemonmqThread.wait(2000): 4755 self.sendTerminateToThread(self.thumbnail_deamon_controller) 4756 4757 # Tell logging thread to stop: uses slightly different approach 4758 # than other threads 4759 stop_process_logging_manager(info_port=self.logging_port) 4760 self.loggermqThread.quit() 4761 self.loggermqThread.wait() 4762 4763 self.watchedDownloadDirs.closeWatch() 4764 4765 self.cleanAllTempDirs() 4766 logging.debug("Cleaning any device cache dirs and sample video") 4767 self.devices.delete_cache_dirs_and_sample_video() 4768 tc = ThumbnailCacheSql(create_table_if_not_exists=False) 4769 logging.debug("Cleaning up Thumbnail cache") 4770 tc.cleanup_cache(days=self.prefs.keep_thumbnails_days) 4771 4772 Notify.uninit() 4773 4774 self.close_event_run = True 4775 4776 logging.debug("Accepting close event") 4777 event.accept() 4778 4779 def getIconsAndEjectableForMount(self, mount: QStorageInfo) -> Tuple[List[str], bool]: 4780 """ 4781 Given a mount, get the icon names suggested by udev or 4782 GVFS, and determine whether the mount is ejectable or not. 4783 :param mount: the mount to check 4784 :return: icon names and eject boolean 4785 :rtype Tuple[str, bool] 4786 """ 4787 if self.gvfsControlsMounts: 4788 iconNames, canEject = self.gvolumeMonitor.getProps(mount.rootPath()) 4789 else: 4790 # get the system device e.g. /dev/sdc1 4791 systemDevice = bytes(mount.device()).decode() 4792 iconNames, canEject = self.udisks2Monitor.get_device_props(systemDevice) 4793 return iconNames, canEject 4794 4795 def addToDeviceDisplay(self, device: Device, scan_id: int) -> None: 4796 self.mapModel(scan_id).addDevice(scan_id, device) 4797 self.adjustLeftPanelSliderHandles() 4798 # Resize the "This Computer" view after a device has been added 4799 # If not done, the widget geometry will not be updated to reflect 4800 # the new view. 4801 if device.device_type == DeviceType.path: 4802 self.thisComputerView.updateGeometry() 4803 4804 @pyqtSlot() 4805 def cameraAdded(self) -> None: 4806 if not self.prefs.device_autodetection: 4807 logging.debug("Ignoring camera as device auto detection is off") 4808 else: 4809 logging.debug("Assuming camera will not be mounted: immediately proceeding with scan") 4810 self.searchForCameras() 4811 4812 @pyqtSlot() 4813 def cameraRemoved(self) -> None: 4814 """ 4815 Handle the possible removal of a camera by comparing the 4816 cameras the OS knows about compared to the cameras we are 4817 tracking. Remove tracked cameras if they are not on the OS. 4818 4819 We need this brute force method because I don't know if it's 4820 possible to query GIO or udev to return the info needed by 4821 libgphoto2 4822 """ 4823 4824 logging.debug("Examining system for removed camera") 4825 sc = autodetect_cameras(self.gp_context) 4826 system_cameras = ((model, port) for model, port in sc if not port.startswith('disk:')) 4827 kc = self.devices.cameras.items() 4828 known_cameras = ((model, port) for port, model in kc) 4829 removed_cameras = set(known_cameras) - set(system_cameras) 4830 for model, port in removed_cameras: 4831 scan_id = self.devices.scan_id_from_camera_model_port(model, port) 4832 if scan_id is None: 4833 logging.debug("The camera with scan id %s was already removed", scan_id) 4834 else: 4835 device = self.devices[scan_id] 4836 # Don't log a warning when the camera was removed while the user was being 4837 # informed it was locked or inaccessible 4838 show_warning = not device in self.prompting_for_user_action 4839 self.removeDevice(scan_id=scan_id, show_warning=show_warning) 4840 4841 if removed_cameras: 4842 self.setDownloadCapabilities() 4843 4844 @pyqtSlot() 4845 def noGVFSAutoMount(self) -> None: 4846 """ 4847 In Gnome like environment we rely on Gnome automatically 4848 mounting cameras and devices with file systems. But sometimes 4849 it will not automatically mount them, for whatever reason. 4850 Try to handle those cases. 4851 """ 4852 #TODO Implement noGVFSAutoMount() 4853 # however, I have no idea under what circumstances it is called 4854 logging.error("Implement noGVFSAutoMount()") 4855 4856 @pyqtSlot() 4857 def cameraMounted(self) -> None: 4858 if have_gio: 4859 self.searchForCameras() 4860 4861 @pyqtSlot(str) 4862 def cameraVolumeAdded(self, path): 4863 assert self.gvfsControlsMounts 4864 self.searchForCameras() 4865 4866 def unmountCameraToEnableScan(self, model: str, 4867 port: str, 4868 on_startup: bool) -> bool: 4869 """ 4870 Possibly "unmount" a camera or phone controlled by GVFS so it can be scanned 4871 4872 :param model: camera model 4873 :param port: port used by camera 4874 :param on_startup: if True, the unmount is occurring during 4875 the program's startup phase 4876 :return: True if unmount operation initiated, else False 4877 """ 4878 4879 if self.gvfsControlsMounts: 4880 self.devices.cameras_to_gvfs_unmount_for_scan[port] = model 4881 unmounted = self.gvolumeMonitor.unmountCamera( 4882 model=model, port=port, on_startup=on_startup 4883 ) 4884 if unmounted: 4885 logging.debug("Successfully unmounted %s", model) 4886 return True 4887 else: 4888 logging.debug("%s was not already mounted", model) 4889 del self.devices.cameras_to_gvfs_unmount_for_scan[port] 4890 return False 4891 4892 @pyqtSlot(bool, str, str, bool, bool) 4893 def cameraUnmounted(self, result: bool, 4894 model: str, 4895 port: str, 4896 download_started: bool, 4897 on_startup: bool) -> None: 4898 """ 4899 Handle the attempt to unmount a GVFS mounted camera. 4900 4901 Note: cameras that have not yet been scanned do not yet have a scan_id assigned! 4902 An obvious point, but easy to forget. 4903 4904 :param result: result from the GVFS operation 4905 :param model: camera model 4906 :param port: camera port 4907 :param download_started: whether the unmount happened because a download 4908 was initiated 4909 :param on_startup: if the unmount happened on a device during program startup 4910 """ 4911 4912 if not download_started: 4913 assert self.devices.cameras_to_gvfs_unmount_for_scan[port] == model 4914 del self.devices.cameras_to_gvfs_unmount_for_scan[port] 4915 if result: 4916 self.startCameraScan(model=model, port=port, on_startup=on_startup) 4917 else: 4918 # Get the camera's short model name, instead of using the exceptionally 4919 # long name that gphoto2 can sometimes use. Get the icon too. 4920 camera = Device() 4921 camera.set_download_from_camera(model, port) 4922 4923 logging.debug( 4924 "Not scanning %s because it could not be unmounted", camera.display_name 4925 ) 4926 4927 # Translators: %(variable)s represents Python code, not a plural of the term 4928 # variable. You must keep the %(variable)s untranslated, or the program will 4929 # crash. 4930 # Translators: please do not change HTML codes like <br>, <i>, </i>, or <b>, </b> 4931 # etc. 4932 message = _( 4933 '<b>The %(camera)s cannot be scanned because it cannot be ' 4934 'unmounted.</b><br><br>You can close any other application (such as a ' 4935 'file browser) that is using it and try again. If that does not work, ' 4936 'unplug the %(camera)s from the computer and plug it in again.' 4937 ) % dict(camera=camera.display_name) 4938 4939 # Show the main window if it's not yet visible 4940 self.showMainWindow() 4941 msgBox = standardMessageBox( 4942 message=message, rich_text=True, standardButtons=QMessageBox.Ok, 4943 iconPixmap=camera.get_pixmap() 4944 ) 4945 msgBox.exec() 4946 else: 4947 # A download was initiated 4948 4949 scan_id = self.devices.scan_id_from_camera_model_port(model, port) 4950 self.devices.cameras_to_gvfs_unmount_for_download.remove(scan_id) 4951 if result: 4952 if not self.devices.download_start_blocked(): 4953 self.startDownloadPhase2() 4954 else: 4955 camera = self.devices[scan_id] 4956 display_name = camera.display_name 4957 4958 title = _('Rapid Photo Downloader') 4959 # Translators: %(variable)s represents Python code, not a plural of the term 4960 # variable. You must keep the %(variable)s untranslated, or the program will 4961 # crash. 4962 # Translators: please do not change HTML codes like <br>, <i>, </i>, or <b>, </b> 4963 # etc. 4964 message = _( 4965 '<b>The download cannot start because the %(camera)s cannot be ' 4966 'unmounted.</b><br><br>You ' 4967 'can close any other application (such as a file browser) that is ' 4968 'using it and try again. If that ' 4969 'does not work, unplug the %(camera)s from the computer and plug ' 4970 'it in again, and choose which files you want to download from it.' 4971 ) % dict(camera=display_name) 4972 msgBox = QMessageBox(QMessageBox.Warning, title, message, QMessageBox.Ok) 4973 msgBox.setIconPixmap(camera.get_pixmap()) 4974 msgBox.exec_() 4975 4976 def searchForCameras(self, on_startup: bool=False) -> None: 4977 """ 4978 Detect using gphoto2 any cameras attached to the computer. 4979 4980 Initiates unmount of cameras that are mounted by GIO/GVFS. 4981 4982 :param on_startup: if True, the search is occurring during 4983 the program's startup phase 4984 """ 4985 4986 if self.prefs.device_autodetection: 4987 cameras = autodetect_cameras(self.gp_context) 4988 for model, port in cameras: 4989 if port in self.devices.cameras_to_gvfs_unmount_for_scan: 4990 assert self.devices.cameras_to_gvfs_unmount_for_scan[port] == model 4991 logging.debug("Already unmounting %s", model) 4992 elif self.devices.known_camera(model, port): 4993 logging.debug("Camera %s is known", model) 4994 elif self.devices.user_marked_camera_as_ignored(model, port): 4995 logging.debug("Ignoring camera marked as removed by user %s", model) 4996 elif not port.startswith('disk:'): 4997 device = Device() 4998 device.set_download_from_camera(model, port) 4999 if device.udev_name in self.prefs.camera_blacklist: 5000 logging.debug("Ignoring blacklisted camera %s", model) 5001 else: 5002 logging.debug("Detected %s on port %s", model, port) 5003 # almost always, libgphoto2 cannot access a camera when 5004 # it is mounted by another process, like Gnome's GVFS 5005 # or any other system. Before attempting to scan the 5006 # camera, check to see if it's mounted and if so, 5007 # unmount it. Unmounting is asynchronous. 5008 if not self.unmountCameraToEnableScan( 5009 model=model, port=port, on_startup=on_startup): 5010 self.startCameraScan(model=model, port=port, on_startup=on_startup) 5011 5012 def startCameraScan(self, model: str, 5013 port: str, 5014 on_startup: bool=False) -> None: 5015 """ 5016 Initiate the scan of an unmounted camera 5017 5018 :param model: camera model 5019 :param port: camera port 5020 :param on_startup: if True, the scan is occurring during 5021 the program's startup phase 5022 """ 5023 5024 device = Device() 5025 device.set_download_from_camera(model, port) 5026 self.startDeviceScan(device=device, on_startup=on_startup) 5027 5028 def startDeviceScan(self, device: Device, on_startup: bool=False) -> None: 5029 """ 5030 Initiate the scan of a device (camera, this computer path, or external device) 5031 5032 :param device: device to scan 5033 :param on_startup: if True, the scan is occurring during 5034 the program's startup phase 5035 """ 5036 5037 scan_id = self.devices.add_device(device=device, on_startup=on_startup) 5038 logging.debug("Assigning scan id %s to %s", scan_id, device.name()) 5039 self.thumbnailModel.addOrUpdateDevice(scan_id) 5040 self.addToDeviceDisplay(device, scan_id) 5041 self.updateSourceButton() 5042 scan_arguments = ScanArguments( 5043 device=device, 5044 ignore_other_types=self.ignore_other_photo_types, 5045 log_gphoto2=self.log_gphoto2, 5046 ) 5047 self.sendStartWorkerToThread(self.scan_controller, worker_id=scan_id, data=scan_arguments) 5048 self.devices.set_device_state(scan_id, DeviceState.scanning) 5049 self.setDownloadCapabilities() 5050 self.updateProgressBarState() 5051 self.displayMessageInStatusBar() 5052 5053 if not on_startup and self.thumbnailModel.anyCompletedDownloads(): 5054 5055 if self.prefs.completed_downloads == int(CompletedDownloads.prompt): 5056 logging.info("Querying whether to clear completed downloads") 5057 counter = self.thumbnailModel.getFileDownloadsCompleted() 5058 5059 numbers = counter.file_types_present_details(singular_natural=True).capitalize() 5060 plural = sum(counter.values()) > 1 5061 if plural: 5062 title = _('Completed Downloads Present') 5063 body = _( 5064 '%s whose download have completed are displayed.' 5065 ) % numbers 5066 question = _('Do you want to clear the completed downloads?') 5067 else: 5068 title = _('Completed Download Present') 5069 body = _( 5070 '%s whose download has completed is displayed.' 5071 ) % numbers 5072 question = _('Do you want to clear the completed download?') 5073 message = "<b>{}</b><br><br>{}<br><br>{}".format(title, body, question) 5074 5075 questionDialog = RememberThisDialog( 5076 message=message, 5077 icon=':/rapid-photo-downloader.svg', 5078 remember=RememberThisMessage.do_not_ask_again, 5079 parent=self 5080 ) 5081 5082 clear = questionDialog.exec_() 5083 if clear: 5084 self.thumbnailModel.clearCompletedDownloads() 5085 5086 if questionDialog.remember: 5087 if clear: 5088 self.prefs.completed_downloads = int(CompletedDownloads.clear) 5089 else: 5090 self.prefs.completed_downloads = int(CompletedDownloads.keep) 5091 5092 elif self.prefs.completed_downloads == int(CompletedDownloads.clear): 5093 logging.info("Clearing completed downloads") 5094 self.thumbnailModel.clearCompletedDownloads() 5095 else: 5096 logging.info("Keeping completed downloads") 5097 5098 def partitionValid(self, mount: QStorageInfo) -> bool: 5099 """ 5100 A valid partition is one that is: 5101 1) available 5102 2) the mount name should not be blacklisted 5103 :param mount: the mount point to check 5104 :return: True if valid, False otherwise 5105 """ 5106 if mount.isValid() and mount.isReady(): 5107 if mount.displayName() in self.prefs.volume_blacklist: 5108 logging.info("blacklisted device %s ignored", mount.displayName()) 5109 return False 5110 else: 5111 return True 5112 return False 5113 5114 def shouldScanMount(self, mount: QStorageInfo) -> bool: 5115 if self.prefs.device_autodetection: 5116 path = mount.rootPath() 5117 if (not self.prefs.scan_specific_folders or has_one_or_more_folders( 5118 path=path, folders=self.prefs.folders_to_scan)): 5119 if not self.devices.user_marked_volume_as_ignored(path): 5120 return True 5121 else: 5122 logging.debug( 5123 'Not scanning volume with path %s because it was set to be temporarily ' 5124 'ignored', path 5125 ) 5126 else: 5127 logging.debug( 5128 'Not scanning volume with path %s because it lacks a folder at the base ' 5129 'level that indicates it should be scanned', path 5130 ) 5131 return False 5132 5133 def prepareNonCameraDeviceScan(self, device: Device, on_startup: bool=False) -> None: 5134 """ 5135 Initiates a device scan for volume. 5136 5137 If non-DCIM device scans are enabled, and the device is not whitelisted 5138 (determined by the display name), then the user is prompted whether to download 5139 from the device. 5140 5141 :param device: device to scan 5142 :param on_startup: if True, the search is occurring during 5143 the program's startup phase 5144 """ 5145 5146 if not self.devices.known_device(device): 5147 if (self.scanEvenIfNoFoldersLikeDCIM() and 5148 not device.display_name in self.prefs.volume_whitelist): 5149 logging.debug("Prompting whether to use device %s", device.display_name) 5150 # prompt user to see if device should be used or not 5151 self.showMainWindow() 5152 message = _( 5153 'Do you want to download photos and videos from the device <i>%(' 5154 'device)s</i>?' 5155 ) % dict(device=device.display_name) 5156 use = RememberThisDialog( 5157 message=message, icon=device.get_pixmap(), 5158 remember=RememberThisMessage.remember_choice, 5159 parent=self, title=device.display_name 5160 ) 5161 if use.exec(): 5162 if use.remember: 5163 logging.debug("Whitelisting device %s", device.display_name) 5164 self.prefs.add_list_value(key='volume_whitelist', value=device.display_name) 5165 self.startDeviceScan(device=device, on_startup=on_startup) 5166 else: 5167 logging.debug("Device %s rejected as a download device", device.display_name) 5168 if use.remember and device.display_name not in self.prefs.volume_blacklist: 5169 logging.debug("Blacklisting device %s", device.display_name) 5170 self.prefs.add_list_value(key='volume_blacklist', value=device.display_name) 5171 else: 5172 self.startDeviceScan(device=device, on_startup=on_startup) 5173 5174 @pyqtSlot(str, list, bool) 5175 def partitionMounted(self, path: str, iconNames: List[str], canEject: bool) -> None: 5176 """ 5177 Setup devices from which to download from and backup to, and 5178 if relevant start scanning them 5179 5180 :param path: the path of the mounted partition 5181 :param iconNames: a list of names of icons used in themed icons 5182 associated with this partition 5183 :param canEject: whether the partition can be ejected or not 5184 """ 5185 5186 assert path in mountPaths() 5187 5188 if self.monitorPartitionChanges(): 5189 mount = QStorageInfo(path) 5190 if self.partitionValid(mount): 5191 backup_file_type = self.isBackupPath(path) 5192 5193 if backup_file_type is not None: 5194 if path not in self.backup_devices: 5195 device = BackupDevice(mount=mount, backup_type=backup_file_type) 5196 self.backup_devices[path] = device 5197 self.addDeviceToBackupManager(path) 5198 self.download_tracker.set_no_backup_devices( 5199 len(self.backup_devices.photo_backup_devices), 5200 len(self.backup_devices.video_backup_devices) 5201 ) 5202 self.displayMessageInStatusBar() 5203 self.backupPanel.addBackupVolume( 5204 mount_details=self.backup_devices.get_backup_volume_details(path) 5205 ) 5206 if self.prefs.backup_device_autodetection: 5207 self.backupPanel.updateExample() 5208 5209 elif self.shouldScanMount(mount): 5210 device = Device() 5211 device.set_download_from_volume( 5212 path, mount.displayName(), iconNames, canEject, mount 5213 ) 5214 self.prepareNonCameraDeviceScan(device) 5215 5216 @pyqtSlot(str) 5217 def partitionUmounted(self, path: str) -> None: 5218 """ 5219 Handle the unmounting of partitions by the system / user. 5220 5221 :param path: the path of the partition just unmounted 5222 """ 5223 if not path: 5224 return 5225 5226 if self.devices.known_path(path, DeviceType.volume): 5227 # four scenarios - 5228 # the mount is being scanned 5229 # the mount has been scanned but downloading has not yet started 5230 # files are being downloaded from mount 5231 # files have finished downloading from mount 5232 scan_id = self.devices.scan_id_from_path(path, DeviceType.volume) 5233 self.removeDevice(scan_id=scan_id) 5234 5235 elif path in self.backup_devices: 5236 self.removeBackupDevice(path) 5237 self.backupPanel.removeBackupVolume(path=path) 5238 self.displayMessageInStatusBar() 5239 self.download_tracker.set_no_backup_devices( 5240 len(self.backup_devices.photo_backup_devices), 5241 len(self.backup_devices.video_backup_devices) 5242 ) 5243 if self.prefs.backup_device_autodetection: 5244 self.backupPanel.updateExample() 5245 5246 self.setDownloadCapabilities() 5247 5248 def removeDevice(self, scan_id: int, 5249 show_warning: bool=True, 5250 adjust_temporal_proximity: bool=True, 5251 ignore_in_this_program_instantiation: bool=False) -> None: 5252 """ 5253 Remove a device from internal tracking and display. 5254 5255 :param scan_id: scan id of device to remove 5256 :param show_warning: log warning if the device was having 5257 something done to it e.g. scan 5258 :param adjust_temporal_proximity: if True, update the temporal 5259 proximity table to reflect device removal 5260 :param ignore_in_this_program_instantiation: don't scan this 5261 device again during this instance of the program being run 5262 """ 5263 5264 assert scan_id is not None 5265 5266 if scan_id in self.devices: 5267 device = self.devices[scan_id] 5268 device_state = self.deviceState(scan_id) 5269 5270 if show_warning: 5271 if device_state == DeviceState.scanning: 5272 logging.warning("Removed device %s was being scanned", device.name()) 5273 elif device_state == DeviceState.downloading: 5274 logging.error("Removed device %s was being downloaded from", device.name()) 5275 elif device_state == DeviceState.thumbnailing: 5276 logging.warning( 5277 "Removed device %s was having thumbnails generated", device.name() 5278 ) 5279 else: 5280 logging.info("Device removed: %s", device.name()) 5281 else: 5282 logging.debug("Device removed: %s", device.name()) 5283 5284 if device in self.prompting_for_user_action: 5285 self.prompting_for_user_action[device].reject() 5286 5287 files_removed = self.thumbnailModel.clearAll( 5288 scan_id=scan_id, keep_downloaded_files=True 5289 ) 5290 self.mapModel(scan_id).removeDevice(scan_id) 5291 5292 was_downloading = self.downloadIsRunning() 5293 5294 if device_state == DeviceState.scanning: 5295 self.sendStopWorkerToThread(self.scan_controller, scan_id) 5296 elif device_state == DeviceState.downloading: 5297 self.sendStopWorkerToThread(self.copy_controller, scan_id) 5298 self.download_tracker.device_removed_mid_download(scan_id, device.display_name) 5299 del self.time_remaining[scan_id] 5300 self.notifyDownloadedFromDevice(scan_id=scan_id) 5301 # TODO need correct check for "is thumbnailing", given is now asynchronous 5302 elif device_state == DeviceState.thumbnailing: 5303 self.thumbnailModel.terminateThumbnailGeneration(scan_id) 5304 5305 if ignore_in_this_program_instantiation: 5306 self.devices.ignore_device(scan_id=scan_id) 5307 5308 self.folder_preview_manager.remove_folders_for_device(scan_id=scan_id) 5309 5310 del self.devices[scan_id] 5311 self.adjustLeftPanelSliderHandles() 5312 5313 if device.device_type == DeviceType.path: 5314 self.thisComputer.setViewVisible(False) 5315 5316 self.updateSourceButton() 5317 self.setDownloadCapabilities() 5318 5319 if adjust_temporal_proximity: 5320 state = self.proximityStatePostDeviceRemoval() 5321 if state == TemporalProximityState.empty: 5322 self.temporalProximity.setState(TemporalProximityState.empty) 5323 elif files_removed: 5324 self.generateTemporalProximityTableData("a download source was removed") 5325 elif self.temporalProximity.state == TemporalProximityState.pending: 5326 self.generateTemporalProximityTableData( 5327 "a download source was removed and a build is pending" 5328 ) 5329 5330 self.logState() 5331 self.updateProgressBarState() 5332 self.displayMessageInStatusBar() 5333 5334 # Reset Download button from "Pause" to "Download" 5335 if was_downloading and not self.downloadIsRunning(): 5336 self.setDownloadActionLabel() 5337 5338 def rescanDevice(self, scan_id: int) -> None: 5339 """ 5340 Remove a device and scan it again. 5341 5342 :param scan_id: scan id of the device 5343 """ 5344 5345 device = self.devices[scan_id] 5346 logging.debug("Rescanning %s", device.display_name) 5347 self.removeDevice(scan_id=scan_id) 5348 if device.device_type == DeviceType.camera: 5349 self.startCameraScan(device.camera_model, device.camera_port) 5350 else: 5351 if device.device_type == DeviceType.path: 5352 self.thisComputer.setViewVisible(True) 5353 self.startDeviceScan(device=device) 5354 5355 def rescanDevicesAndComputer(self, ignore_cameras: bool, rescan_path: bool) -> None: 5356 """ 5357 After a preference change, rescan already scanned devices 5358 :param ignore_cameras: if True, don't rescan cameras 5359 :param rescan_path: if True, include manually specified paths 5360 (i.e. This Computer) 5361 """ 5362 5363 if rescan_path: 5364 logging.info("Rescanning all paths and devices") 5365 if ignore_cameras: 5366 logging.info("Rescanning non camera devices") 5367 5368 # Collect the scan ids to work on - don't modify the 5369 # collection of devices in place! 5370 scan_ids = [] 5371 for scan_id in self.devices: 5372 device = self.devices[scan_id] 5373 if not ignore_cameras or device.device_type == DeviceType.volume: 5374 scan_ids.append(scan_id) 5375 elif rescan_path and device.device_type == DeviceType.path: 5376 scan_ids.append(scan_id) 5377 5378 for scan_id in scan_ids: 5379 self.rescanDevice(scan_id=scan_id) 5380 5381 def searchForDevicesAgain(self) -> None: 5382 """ 5383 Called after a preference change to only_external_mounts 5384 """ 5385 5386 # only scan again if the new pref value is more permissive than the former 5387 # (don't remove existing devices) 5388 if not self.prefs.only_external_mounts: 5389 logging.debug("Searching for new volumes to scan...") 5390 self.setupNonCameraDevices(scanning_again=True) 5391 logging.debug("... finished searching for volumes to scan") 5392 5393 5394 def blacklistDevice(self, scan_id: int) -> None: 5395 """ 5396 Query user if they really want to to permanently ignore a camera or 5397 volume. If they do, the device is removed and blacklisted. 5398 5399 :param scan_id: scan id of the device 5400 """ 5401 5402 device = self.devices[scan_id] 5403 if device.device_type == DeviceType.camera: 5404 text = _("<b>Do you want to ignore the %s whenever this program is run?</b>") 5405 text = text % device.display_name 5406 info_text = _( 5407 "All cameras, phones and tablets with the same model name will be ignored." 5408 ) 5409 else: 5410 assert device.device_type == DeviceType.volume 5411 text = _("<b>Do you want to ignore the device %s whenever this program is run?</b>") 5412 text = text % device.display_name 5413 info_text = _("Any device with the same name will be ignored.") 5414 5415 msgbox = QMessageBox() 5416 msgbox.setWindowTitle(_("Rapid Photo Downloader")) 5417 msgbox.setIcon(QMessageBox.Question) 5418 msgbox.setText(text) 5419 msgbox.setTextFormat(Qt.RichText) 5420 msgbox.setInformativeText(info_text) 5421 msgbox.setStandardButtons(QMessageBox.Yes|QMessageBox.No) 5422 if msgbox.exec() == QMessageBox.Yes: 5423 if device.device_type == DeviceType.camera: 5424 self.prefs.add_list_value(key='camera_blacklist', value=device.udev_name) 5425 logging.debug('Added %s to camera blacklist',device.udev_name) 5426 else: 5427 self.prefs.add_list_value(key='volume_blacklist', value=device.display_name) 5428 logging.debug('Added %s to volume blacklist', device.display_name) 5429 self.removeDevice(scan_id=scan_id) 5430 5431 def logState(self) -> None: 5432 self.devices.logState() 5433 self.thumbnailModel.logState() 5434 self.deviceModel.logState() 5435 self.thisComputerModel.logState() 5436 5437 def setupBackupDevices(self) -> None: 5438 """ 5439 Setup devices to back up to. 5440 5441 Includes both auto detected back up devices, and manually 5442 specified paths. 5443 """ 5444 if self.prefs.backup_device_autodetection: 5445 for mount in self.validMounts.mountedValidMountPoints(): 5446 if self.partitionValid(mount): 5447 path = mount.rootPath() 5448 backup_type = self.isBackupPath(path) 5449 if backup_type is not None: 5450 self.backup_devices[path] = BackupDevice( 5451 mount=mount, backup_type=backup_type 5452 ) 5453 self.addDeviceToBackupManager(path) 5454 self.backupPanel.updateExample() 5455 else: 5456 self.setupManualBackup() 5457 for path in self.backup_devices: 5458 self.addDeviceToBackupManager(path) 5459 5460 self.download_tracker.set_no_backup_devices( 5461 len(self.backup_devices.photo_backup_devices), 5462 len(self.backup_devices.video_backup_devices)) 5463 5464 self.backupPanel.setupBackupDisplay() 5465 5466 def removeBackupDevice(self, path: str) -> None: 5467 device_id = self.backup_devices.device_id(path) 5468 self.sendStopWorkerToThread(self.backup_controller, worker_id=device_id) 5469 del self.backup_devices[path] 5470 5471 def resetupBackupDevices(self) -> None: 5472 """ 5473 Change backup preferences in response to preference change. 5474 5475 Assumes backups may have already been setup. 5476 """ 5477 5478 try: 5479 assert not self.downloadIsRunning() 5480 except AssertionError: 5481 logging.critical("Backup devices should never be reset when a download is occurring") 5482 return 5483 5484 logging.info("Resetting backup devices configuration...") 5485 # Clear all existing backup devices 5486 for path in self.backup_devices.all_paths(): 5487 self.removeBackupDevice(path) 5488 self.download_tracker.set_no_backup_devices(0, 0) 5489 self.backupPanel.resetBackupDisplay() 5490 5491 self.setupBackupDevices() 5492 self.setDownloadCapabilities() 5493 logging.info("...backup devices configuration is reset") 5494 5495 def setupNonCameraDevices(self, on_startup: bool=False, scanning_again: bool=False) -> None: 5496 """ 5497 Setup devices from which to download and initiates their scan. 5498 5499 :param on_startup: if True, the search is occurring during 5500 the program's startup phase 5501 :param scanning_again: if True, the search is occurring after a preference 5502 value change, where devices may have already been scanned. 5503 """ 5504 5505 if not self.prefs.device_autodetection: 5506 return 5507 5508 mounts = [] # type: List[QStorageInfo] 5509 for mount in self.validMounts.mountedValidMountPoints(): 5510 if self.partitionValid(mount): 5511 path = mount.rootPath() 5512 5513 if scanning_again and \ 5514 self.devices.known_path(path=path, device_type=DeviceType.volume): 5515 logging.debug( 5516 "Will not scan %s, because it's associated with an existing device", 5517 mount.displayName() 5518 ) 5519 continue 5520 5521 if path not in self.backup_devices and self.shouldScanMount(mount): 5522 logging.debug("Will scan %s", mount.displayName()) 5523 mounts.append(mount) 5524 else: 5525 logging.debug("Will not scan %s", mount.displayName()) 5526 5527 for mount in mounts: 5528 icon_names, can_eject = self.getIconsAndEjectableForMount(mount) 5529 device = Device() 5530 device.set_download_from_volume( 5531 mount.rootPath(), mount.displayName(), icon_names, can_eject, mount 5532 ) 5533 self.prepareNonCameraDeviceScan(device=device, on_startup=on_startup) 5534 5535 def setupManualPath(self, on_startup: bool=False) -> None: 5536 """ 5537 Setup This Computer path from which to download and initiates scan. 5538 5539 :param on_startup: if True, the setup is occurring during 5540 the program's startup phase 5541 """ 5542 5543 if not self.prefs.this_computer_source: 5544 return 5545 5546 if self.prefs.this_computer_path: 5547 if not self.confirmManualDownloadLocation(): 5548 logging.debug( 5549 "This Computer path %s rejected as download source", 5550 self.prefs.this_computer_path 5551 ) 5552 self.prefs.this_computer_path = '' 5553 self.thisComputer.setViewVisible(False) 5554 return 5555 5556 # user manually specified the path from which to download 5557 path = self.prefs.this_computer_path 5558 5559 if path: 5560 if os.path.isdir(path) and os.access(path, os.R_OK): 5561 logging.debug("Using This Computer path %s", path) 5562 device = Device() 5563 device.set_download_from_path(path) 5564 self.startDeviceScan(device=device, on_startup=on_startup) 5565 else: 5566 logging.error("This Computer download path is invalid: %s", path) 5567 else: 5568 logging.warning("This Computer download path is not specified") 5569 5570 def addDeviceToBackupManager(self, path: str) -> None: 5571 device_id = self.backup_devices.device_id(path) 5572 self.backup_controller.send_multipart(create_inproc_msg(b'START_WORKER', 5573 worker_id=device_id, 5574 data=BackupArguments(path, self.backup_devices.name(path)))) 5575 5576 def setupManualBackup(self) -> None: 5577 """ 5578 Setup backup devices that the user has manually specified. 5579 5580 Depending on the folder the user has chosen, the paths for 5581 photo and video backup will either be the same or they will 5582 differ. 5583 5584 Because the paths are manually specified, there is no mount 5585 associated with them. 5586 """ 5587 5588 backup_photo_location = self.prefs.backup_photo_location 5589 backup_video_location = self.prefs.backup_video_location 5590 5591 if not self.manualBackupPathAvailable(backup_photo_location): 5592 logging.warning("Photo backup path unavailable: %s", backup_photo_location) 5593 if not self.manualBackupPathAvailable(backup_video_location): 5594 logging.warning("Video backup path unavailable: %s", backup_video_location) 5595 5596 if backup_photo_location != backup_video_location: 5597 backup_photo_device = BackupDevice(mount=None, backup_type=BackupLocationType.photos) 5598 backup_video_device = BackupDevice(mount=None, backup_type=BackupLocationType.videos) 5599 self.backup_devices[backup_photo_location] = backup_photo_device 5600 self.backup_devices[backup_video_location] = backup_video_device 5601 5602 logging.info("Backing up photos to %s", backup_photo_location) 5603 logging.info("Backing up videos to %s", backup_video_location) 5604 else: 5605 # videos and photos are being backed up to the same location 5606 backup_device = BackupDevice(mount=None, 5607 backup_type=BackupLocationType.photos_and_videos) 5608 self.backup_devices[backup_photo_location] = backup_device 5609 5610 logging.info("Backing up photos and videos to %s", backup_photo_location) 5611 5612 def isBackupPath(self, path: str) -> Optional[BackupLocationType]: 5613 """ 5614 Checks to see if backups are enabled and path represents a 5615 valid backup location. It must be writeable. 5616 5617 Checks against user preferences. 5618 5619 :return The type of file that should be backed up to the path, 5620 else if nothing should be, None 5621 """ 5622 5623 if self.prefs.backup_files: 5624 if self.prefs.backup_device_autodetection: 5625 # Determine if the auto-detected backup device is 5626 # to be used to backup only photos, or videos, or both. 5627 # Use the presence of a corresponding directory to 5628 # determine this. 5629 # The directory must be writable. 5630 photo_path = os.path.join(path, self.prefs.photo_backup_identifier) 5631 p_backup = os.path.isdir(photo_path) and os.access(photo_path, os.W_OK) 5632 video_path = os.path.join(path, self.prefs.video_backup_identifier) 5633 v_backup = os.path.isdir(video_path) and os.access(video_path, os.W_OK) 5634 if p_backup and v_backup: 5635 logging.info("Photos and videos will be backed up to %s", path) 5636 return BackupLocationType.photos_and_videos 5637 elif p_backup: 5638 logging.info("Photos will be backed up to %s", path) 5639 return BackupLocationType.photos 5640 elif v_backup: 5641 logging.info("Videos will be backed up to %s", path) 5642 return BackupLocationType.videos 5643 elif path == self.prefs.backup_photo_location: 5644 # user manually specified the path 5645 if self.manualBackupPathAvailable(path): 5646 return BackupLocationType.photos 5647 elif path == self.prefs.backup_video_location: 5648 # user manually specified the path 5649 if self.manualBackupPathAvailable(path): 5650 return BackupLocationType.videos 5651 return None 5652 5653 def manualBackupPathAvailable(self, path: str) -> bool: 5654 return os.access(path, os.W_OK) 5655 5656 def monitorPartitionChanges(self) -> bool: 5657 """ 5658 If the user is downloading from a manually specified location, 5659 and is not using any automatically detected backup devices, 5660 then there is no need to monitor for devices with filesystems 5661 being added or removed 5662 :return: True if should monitor, False otherwise 5663 """ 5664 return (self.prefs.device_autodetection or 5665 self.prefs.backup_device_autodetection) 5666 5667 @pyqtSlot(str) 5668 def watchedFolderChange(self, path: str) -> None: 5669 """ 5670 Handle case where a download folder has been removed or altered 5671 5672 :param path: watched path 5673 """ 5674 5675 logging.debug("Change in watched folder %s; validating download destinations", path) 5676 valid = True 5677 if self.prefs.photo_download_folder and not validate_download_folder( 5678 self.prefs.photo_download_folder).valid: 5679 valid = False 5680 logging.debug( 5681 "Photo download destination %s is now invalid", self.prefs.photo_download_folder 5682 ) 5683 self.handleInvalidDownloadDestination(file_type=FileType.photo, do_update=False) 5684 5685 if self.prefs.video_download_folder and not validate_download_folder( 5686 self.prefs.video_download_folder).valid: 5687 valid = False 5688 logging.debug( 5689 "Video download destination %s is now invalid", self.prefs.video_download_folder 5690 ) 5691 self.handleInvalidDownloadDestination(file_type=FileType.video, do_update=False) 5692 5693 if not valid: 5694 self.watchedDownloadDirs.updateWatchPathsFromPrefs(self.prefs) 5695 self.folder_preview_manager.change_destination() 5696 self.setDownloadCapabilities() 5697 5698 def confirmManualDownloadLocation(self) -> bool: 5699 """ 5700 Queries the user to ask if they really want to download from locations 5701 that could take a very long time to scan. They can choose yes or no. 5702 5703 Returns True if yes or there was no need to ask the user, False if the 5704 user said no. 5705 """ 5706 5707 self.showMainWindow() 5708 path = self.prefs.this_computer_path 5709 if path in ( 5710 '/media', '/run', os.path.expanduser('~'), '/', '/bin', '/boot', '/dev', 5711 '/lib', '/lib32', '/lib64', '/mnt', '/opt', '/sbin', '/snap', '/sys', '/tmp', 5712 '/usr', '/var', '/proc'): 5713 5714 # Translators: %(variable)s represents Python code, not a plural of the term 5715 # variable. You must keep the %(variable)s untranslated, or the program will 5716 # crash. 5717 message = "<b>" + _( 5718 "Downloading from %(location)s on This Computer." 5719 ) % dict(location=make_html_path_non_breaking(path) 5720 ) + "</b><br><br>" + _( 5721 "Do you really want to download from here?<br><br>On some systems, scanning this " 5722 "location can take a very long time." 5723 ) 5724 msgbox = standardMessageBox( 5725 message=message, rich_text=True, 5726 standardButtons=QMessageBox.Yes | QMessageBox.No, 5727 ) 5728 return msgbox.exec() == QMessageBox.Yes 5729 return True 5730 5731 def scanEvenIfNoFoldersLikeDCIM(self) -> bool: 5732 """ 5733 Determines if partitions should be scanned even if there is 5734 no specific folder like a DCIM folder present in the base folder of the file system. 5735 5736 :return: True if scans of such partitions should occur, else 5737 False 5738 """ 5739 5740 return self.prefs.device_autodetection and not self.prefs.scan_specific_folders 5741 5742 def displayMessageInStatusBar(self) -> None: 5743 """ 5744 Displays message on status bar. 5745 5746 Notifies user if scanning or thumbnailing. 5747 5748 If neither scanning or thumbnailing, displays: 5749 1. files checked for download 5750 2. total number files available 5751 3. how many not shown (user chose to show only new files) 5752 """ 5753 5754 if self.downloadIsRunning(): 5755 if self.download_paused: 5756 downloading = self.devices.downloading_from() 5757 # Translators - in the middle is a unicode em dash - please retain it 5758 # This string is displayed in the status bar when the download is paused 5759 # Translators: %(variable)s represents Python code, not a plural of the term 5760 # variable. You must keep the %(variable)s untranslated, or the program will 5761 # crash. 5762 msg = '%(downloading_from)s — download paused' % dict(downloading_from=downloading) 5763 else: 5764 # status message updates while downloading are handled in another function 5765 return 5766 elif self.devices.thumbnailing: 5767 devices = [self.devices[scan_id].display_name for scan_id in self.devices.thumbnailing] 5768 msg = _("Generating thumbnails for %s") % make_internationalized_list(devices) 5769 elif self.devices.scanning: 5770 devices = [self.devices[scan_id].display_name for scan_id in self.devices.scanning] 5771 msg = _("Scanning %s") % make_internationalized_list(devices) 5772 else: 5773 files_avilable = self.thumbnailModel.getNoFilesAvailableForDownload() 5774 5775 if sum(files_avilable.values()) != 0: 5776 files_to_download = self.thumbnailModel.getNoFilesMarkedForDownload() 5777 files_avilable_sum = files_avilable.summarize_file_count()[0] 5778 files_hidden = self.thumbnailModel.getNoHiddenFiles() 5779 5780 if files_hidden: 5781 # Translators: %(variable)s represents Python code, not a plural of the term 5782 # variable. You must keep the %(variable)s untranslated, or the program will 5783 # crash. 5784 files_checked = _( 5785 '%(number)s of %(available files)s checked for download (%(hidden)s hidden)' 5786 ) % { 5787 'number': thousands(files_to_download), 5788 'available files': files_avilable_sum, 5789 'hidden': files_hidden 5790 } 5791 else: 5792 # Translators: %(variable)s represents Python code, not a plural of the term 5793 # variable. You must keep the %(variable)s untranslated, or the program will 5794 # crash. 5795 files_checked = _( 5796 '%(number)s of %(available files)s checked for download' 5797 ) % { 5798 'number': thousands(files_to_download), 5799 'available files': files_avilable_sum 5800 } 5801 msg = files_checked 5802 else: 5803 msg = '' 5804 self.statusBar().showMessage(msg) 5805 5806 5807class QtSingleApplication(QApplication): 5808 """ 5809 Taken from 5810 http://stackoverflow.com/questions/12712360/qtsingleapplication 5811 -for-pyside-or-pyqt 5812 """ 5813 5814 messageReceived = pyqtSignal(str) 5815 5816 def __init__(self, programId: str, *argv) -> None: 5817 super().__init__(*argv) 5818 self._id = programId 5819 self._activationWindow = None # type: RapidWindow 5820 self._activateOnMessage = False # type: bool 5821 5822 # Is there another instance running? 5823 self._outSocket = QLocalSocket() # type: QLocalSocket 5824 self._outSocket.connectToServer(self._id) 5825 self._isRunning = self._outSocket.waitForConnected() # type: bool 5826 5827 self._outStream = None # type: QTextStream 5828 self._inSocket = None 5829 self._inStream = None # type: QTextStream 5830 self._server = None 5831 5832 if self._isRunning: 5833 # Yes, there is. 5834 self._outStream = QTextStream(self._outSocket) 5835 self._outStream.setCodec('UTF-8') 5836 else: 5837 # No, there isn't, at least not properly. 5838 # Cleanup any past, crashed server. 5839 error = self._outSocket.error() 5840 if error == QLocalSocket.ConnectionRefusedError: 5841 self.close() 5842 QLocalServer.removeServer(self._id) 5843 self._outSocket = None 5844 self._server = QLocalServer() 5845 self._server.listen(self._id) 5846 self._server.newConnection.connect(self._onNewConnection) 5847 5848 def close(self) -> None: 5849 if self._inSocket: 5850 self._inSocket.disconnectFromServer() 5851 if self._outSocket: 5852 self._outSocket.disconnectFromServer() 5853 if self._server: 5854 self._server.close() 5855 5856 def isRunning(self) -> bool: 5857 return self._isRunning 5858 5859 def id(self) -> str: 5860 return self._id 5861 5862 def activationWindow(self) -> RapidWindow: 5863 return self._activationWindow 5864 5865 def setActivationWindow(self, activationWindow: RapidWindow, 5866 activateOnMessage: bool = True) -> None: 5867 self._activationWindow = activationWindow 5868 self._activateOnMessage = activateOnMessage 5869 5870 def activateWindow(self) -> None: 5871 if not self._activationWindow: 5872 return 5873 self._activationWindow.setWindowState( 5874 self._activationWindow.windowState() & ~Qt.WindowMinimized) 5875 self._activationWindow.raise_() 5876 self._activationWindow.activateWindow() 5877 5878 def sendMessage(self, msg) -> bool: 5879 if not self._outStream: 5880 return False 5881 self._outStream << msg << '\n' 5882 self._outStream.flush() 5883 return self._outSocket.waitForBytesWritten() 5884 5885 def _onNewConnection(self) -> None: 5886 if self._inSocket: 5887 self._inSocket.readyRead.disconnect(self._onReadyRead) 5888 self._inSocket = self._server.nextPendingConnection() 5889 if not self._inSocket: 5890 return 5891 self._inStream = QTextStream(self._inSocket) 5892 self._inStream.setCodec('UTF-8') 5893 self._inSocket.readyRead.connect(self._onReadyRead) 5894 if self._activateOnMessage: 5895 self.activateWindow() 5896 5897 def _onReadyRead(self) -> None: 5898 while True: 5899 msg = self._inStream.readLine() 5900 if not msg: break 5901 self.messageReceived.emit(msg) 5902 5903 5904def python_package_source(package: str) -> str: 5905 """ 5906 Return package installation source for Python package 5907 :param package: package name 5908 :return: 5909 """ 5910 5911 pip_install = '(installed using pip)' 5912 system_package = '(system package)' 5913 return pip_install if installed_using_pip(package) else system_package 5914 5915def get_versions(file_manager: Optional[str], 5916 file_manager_type: Optional[FileManagerType], 5917 scaling_action: ScalingAction, 5918 scaling_detected: ScalingDetected, 5919 xsetting_running: bool) -> List[str]: 5920 if 'cython' in zmq.zmq_version_info.__module__: 5921 pyzmq_backend = 'cython' 5922 else: 5923 pyzmq_backend = 'cffi' 5924 try: 5925 ram = psutil.virtual_memory() 5926 total = format_size_for_user(ram.total) 5927 used = format_size_for_user(ram.used) 5928 except Exception: 5929 total = used = 'unknown' 5930 5931 rpd_pip_install = installed_using_pip('rapid-photo-downloader') 5932 5933 versions = [ 5934 'Rapid Photo Downloader: {}'.format(__about__.__version__), 5935 'Platform: {}'.format(platform.platform()), 5936 'Memory: {} used of {}'.format(used, total), 5937 'Confinement: {}'.format('snap' if is_snap() else 'none'), 5938 'Installed using pip: {}'.format('yes' if rpd_pip_install else 'no'), 5939 'Python: {}'.format(platform.python_version()), 5940 'Python executable: {}'.format(sys.executable), 5941 'Qt: {}'.format(QtCore.QT_VERSION_STR), 5942 'PyQt: {} {}'.format(QtCore.PYQT_VERSION_STR, python_package_source('PyQt5')), 5943 'SIP: {}'.format(sip.SIP_VERSION_STR), 5944 'ZeroMQ: {}'.format(zmq.zmq_version()), 5945 'Python ZeroMQ: {} ({} backend)'.format(zmq.pyzmq_version(), pyzmq_backend), 5946 'gPhoto2: {}'.format(gphoto2_version()), 5947 'Python gPhoto2: {} {}'.format( 5948 python_gphoto2_version(), python_package_source('gphoto2') 5949 ), 5950 'ExifTool: {}'.format(EXIFTOOL_VERSION), 5951 'pymediainfo: {}'.format(pymedia_version_info()), 5952 'GExiv2: {}'.format(gexiv2_version()), 5953 'Gstreamer: {}'.format(gst_version()), 5954 'PyGObject: {}'.format('.'.join(map(str, gi.version_info))), 5955 'libraw: {}'.format(libraw_version() or 'not installed'), 5956 'rawkit: {}'.format(rawkit_version() or 'not installed'), 5957 'psutil: {}'.format('.'.join(map(str, psutil.version_info))) 5958 ] 5959 v = exiv2_version() 5960 if v: 5961 versions.append('Exiv2: {}'.format(v)) 5962 try: 5963 versions.append('{}: {}'.format(*platform.libc_ver())) 5964 except: 5965 pass 5966 try: 5967 versions.append('Arrow: {} {}'.format(arrow.__version__, python_package_source('arrow'))) 5968 versions.append('dateutil: {}'.format(dateutil.__version__)) 5969 except AttributeError: 5970 pass 5971 try: 5972 import tornado 5973 versions.append('Tornado: {}'.format(tornado.version)) 5974 except ImportError: 5975 pass 5976 versions.append( 5977 "Can read HEIF/HEIC metadata: {}".format('yes' if fileformats.heif_capable() else 'no') 5978 ) 5979 if have_heif_module: 5980 versions.append('Pyheif: {}'.format(pyheif_version())) 5981 v = libheif_version() 5982 if v: 5983 versions.append('libheif: {}'.format(v)) 5984 for display in ('XDG_SESSION_TYPE', 'WAYLAND_DISPLAY'): 5985 session = os.getenv(display, '') 5986 if session.find('wayland') >= 0: 5987 wayland_platform = os.getenv('QT_QPA_PLATFORM', '') 5988 if wayland_platform != 'wayland': 5989 session = 'wayland desktop (but this application might be running in XWayland)' 5990 break 5991 else: 5992 session = 'wayland desktop (with wayland enabled for this application)' 5993 elif session: 5994 break 5995 if session: 5996 versions.append('Session: {}'.format(session)) 5997 5998 versions.append('Desktop scaling: {}'.format(scaling_action.name.replace('_', ' '))) 5999 versions.append( 6000 'Desktop scaling detection: {}{}'.format( 6001 scaling_detected.name.replace('_', ' '), 6002 '' if xsetting_running else ' (xsetting not running)' 6003 ) 6004 ) 6005 6006 try: 6007 versions.append("Desktop: {} ({})".format(get_desktop_environment(), get_desktop().name)) 6008 except Exception: 6009 pass 6010 6011 if file_manager: 6012 file_manager_details = "{} ({})".format(file_manager, file_manager_type.name) 6013 else: 6014 file_manager_details = "Unknown" 6015 6016 versions.append("Default file manager: {}".format(file_manager_details)) 6017 6018 return versions 6019 6020# def darkFusion(app: QApplication): 6021# app.setStyle("Fusion") 6022# 6023# dark_palette = QPalette() 6024# 6025# dark_palette.setColor(QPalette.Window, QColor(53, 53, 53)) 6026# dark_palette.setColor(QPalette.WindowText, Qt.white) 6027# dark_palette.setColor(QPalette.Base, QColor(25, 25, 25)) 6028# dark_palette.setColor(QPalette.AlternateBase, QColor(53, 53, 53)) 6029# dark_palette.setColor(QPalette.ToolTipBase, Qt.white) 6030# dark_palette.setColor(QPalette.ToolTipText, Qt.white) 6031# dark_palette.setColor(QPalette.Text, Qt.white) 6032# dark_palette.setColor(QPalette.Button, QColor(53, 53, 53)) 6033# dark_palette.setColor(QPalette.ButtonText, Qt.white) 6034# dark_palette.setColor(QPalette.BrightText, Qt.red) 6035# dark_palette.setColor(QPalette.Link, QColor(42, 130, 218)) 6036# dark_palette.setColor(QPalette.Highlight, QColor(42, 130, 218)) 6037# dark_palette.setColor(QPalette.HighlightedText, Qt.black) 6038# 6039# app.setPalette(dark_palette) 6040# style = """ 6041# QToolTip { color: #ffffff; background-color: #2a82da; border: 1px solid white; } 6042# """ 6043# app.setStyleSheet(style) 6044 6045 6046class SplashScreen(QSplashScreen): 6047 def __init__(self, pixmap: QPixmap, flags) -> None: 6048 super().__init__(pixmap, flags) 6049 self.progress = 0 6050 try: 6051 self.image_width = pixmap.width() / pixmap.devicePixelRatioF() 6052 except AttributeError: 6053 self.image_width = pixmap.width() / pixmap.devicePixelRatio() 6054 6055 self.progressBarPen = QPen(QBrush(QColor(Qt.white)), 2.0) 6056 6057 def drawContents(self, painter: QPainter): 6058 painter.save() 6059 painter.setPen(QColor(Qt.black)) 6060 painter.drawText(18, 64, __about__.__version__) 6061 if self.progress: 6062 painter.setPen(self.progressBarPen) 6063 x = int(self.progress / 100 * self.image_width) 6064 painter.drawLine(0, 360, x, 360) 6065 painter.restore() 6066 6067 def setProgress(self, value: int) -> None: 6068 """ 6069 Update splash screen progress bar 6070 :param value: percent done, between 0 and 100 6071 """ 6072 6073 self.progress = value 6074 self.repaint() 6075 6076 6077def parser_options(formatter_class=argparse.HelpFormatter): 6078 parser = argparse.ArgumentParser( 6079 prog=__about__.__title__, description=__about__.__summary__, formatter_class=formatter_class 6080 ) 6081 6082 parser.add_argument( 6083 '--version', action='version', version='%(prog)s {}'.format(__about__.__version__) 6084 ) 6085 parser.add_argument( 6086 '--detailed-version', action='store_true', 6087 help=_("Show version numbers of program and its libraries and exit.") 6088 ) 6089 parser.add_argument( 6090 "-v", "--verbose", action="store_true", dest="verbose", 6091 help=_("Display program information when run from the command line.") 6092 ) 6093 parser.add_argument( 6094 "--debug", action="store_true", dest="debug", 6095 help=_("Display debugging information when run from the command line.") 6096 ) 6097 parser.add_argument( 6098 "-e", "--extensions", action="store_true", dest="extensions", 6099 help=_("List photo and video file extensions the program recognizes and exit.") 6100 ) 6101 parser.add_argument( 6102 "--photo-renaming", choices=['on','off'], dest="photo_renaming", 6103 help=_("Turn on or off the the renaming of photos.") 6104 ) 6105 parser.add_argument( 6106 "--video-renaming", choices=['on','off'], dest="video_renaming", 6107 help=_("Turn on or off the the renaming of videos.") 6108 ) 6109 parser.add_argument( 6110 "-a", "--auto-detect", choices=['on','off'], dest="auto_detect", 6111 help=_("Turn on or off the automatic detection of devices from which to download.") 6112 ) 6113 parser.add_argument( 6114 "-t", "--this-computer", choices=['on','off'], dest="this_computer_source", 6115 help=_("Turn on or off downloading from this computer.") 6116 ) 6117 parser.add_argument( 6118 "--this-computer-location", type=str, metavar=_("PATH"), dest="this_computer_location", 6119 help=_("The PATH on this computer from which to download.") 6120 ) 6121 parser.add_argument( 6122 "--photo-destination", type=str, metavar=_("PATH"), dest="photo_location", 6123 help=_("The PATH where photos will be downloaded to.") 6124 ) 6125 parser.add_argument( 6126 "--video-destination", type=str, metavar=_("PATH"), dest="video_location", 6127 help=_("The PATH where videos will be downloaded to.") 6128 ) 6129 parser.add_argument( 6130 "-b", "--backup", choices=['on','off'], dest="backup", 6131 help=_("Turn on or off the backing up of photos and videos while downloading.") 6132 ) 6133 parser.add_argument( 6134 "--backup-auto-detect", choices=['on','off'], dest="backup_auto_detect", 6135 help=_("Turn on or off the automatic detection of backup devices.") 6136 ) 6137 parser.add_argument( 6138 "--photo-backup-identifier", type=str, metavar=_("FOLDER"), dest="photo_backup_identifier", 6139 help=_( 6140 "The FOLDER in which backups are stored on the automatically detected photo backup " 6141 "device, with the folder's name being used to identify whether or not the device " 6142 "is used for backups. For each device you wish to use for backing photos up to, " 6143 "create a folder on it with this name." 6144 ) 6145 ) 6146 parser.add_argument( 6147 "--video-backup-identifier", type=str, metavar=_("FOLDER"), dest="video_backup_identifier", 6148 help=_( 6149 "The FOLDER in which backups are stored on the automatically detected video backup " 6150 "device, with the folder's name being used to identify whether or not the device " 6151 "is used for backups. For each device you wish to use for backing up videos to, " 6152 "create a folder on it with this name." 6153 ) 6154 ) 6155 parser.add_argument( 6156 "--photo-backup-location", type=str, metavar=_("PATH"), dest="photo_backup_location", 6157 help=_( 6158 "The PATH where photos will be backed up when automatic detection of backup devices is " 6159 "turned off." 6160 ) 6161 ) 6162 parser.add_argument( 6163 "--video-backup-location", type=str, metavar=_("PATH"), dest="video_backup_location", 6164 help=_( 6165 "The PATH where videos will be backed up when automatic detection of backup devices " 6166 "is turned off." 6167 ) 6168 ) 6169 parser.add_argument( 6170 "--ignore-other-photo-file-types", action="store_true", dest="ignore_other", 6171 help=_('Ignore photos with the following extensions: %s') % 6172 make_internationalized_list([s.upper() for s in fileformats.OTHER_PHOTO_EXTENSIONS]) 6173 ) 6174 parser.add_argument( 6175 "--auto-download-startup", dest="auto_download_startup", 6176 choices=['on', 'off'], 6177 help=_("Turn on or off starting downloads as soon as the program itself starts.") 6178 ) 6179 parser.add_argument( 6180 "--auto-download-device-insertion", dest="auto_download_insertion", 6181 choices=['on', 'off'], 6182 help=_("Turn on or off starting downloads as soon as a device is inserted.") 6183 ) 6184 parser.add_argument( 6185 "--thumbnail-cache", dest="thumb_cache", 6186 choices=['on','off'], 6187 help=_( 6188 "Turn on or off use of the Rapid Photo Downloader Thumbnail Cache. " 6189 "Turning it off does not delete existing cache contents." 6190 ) 6191 ) 6192 parser.add_argument( 6193 "--delete-thumbnail-cache", dest="delete_thumb_cache", action="store_true", 6194 help=_("Delete all thumbnails in the Rapid Photo Downloader Thumbnail Cache, and exit.") 6195 ) 6196 parser.add_argument( 6197 "--forget-remembered-files", dest="forget_files", action="store_true", 6198 help=_("Forget which files have been previously downloaded, and exit.") 6199 ) 6200 parser.add_argument( 6201 "--import-old-version-preferences", action="store_true", dest="import_prefs", 6202 help=_( 6203 "Import preferences from an old program version and exit. Requires the " 6204 "command line program gconftool-2." 6205 ) 6206 ) 6207 parser.add_argument( 6208 "--reset", action="store_true", dest="reset", 6209 help=_( 6210 "Reset all program settings to their default values, delete all thumbnails " 6211 "in the Thumbnail cache, forget which files have been previously downloaded, and exit." 6212 ) 6213 ) 6214 parser.add_argument( 6215 "--log-gphoto2", action="store_true", 6216 help=_("Include gphoto2 debugging information in log files.") 6217 ) 6218 6219 parser.add_argument( 6220 "--camera-info", action="store_true", 6221 help=_("Print information to the terminal about attached cameras and exit.") 6222 ) 6223 6224 parser.add_argument('path', nargs='?') 6225 6226 return parser 6227 6228 6229def import_prefs() -> None: 6230 """ 6231 Import program preferences from the Gtk+ 2 version of the program. 6232 6233 Requires the command line program gconftool-2. 6234 """ 6235 6236 def run_cmd(k: str) -> str: 6237 command_line = '{} --get /apps/rapid-photo-downloader/{}'.format(cmd, k) 6238 args = shlex.split(command_line) 6239 try: 6240 return subprocess.check_output(args=args).decode().strip() 6241 except subprocess.SubprocessError: 6242 return '' 6243 6244 6245 cmd = shutil.which('gconftool-2') 6246 keys = (('image_rename', 'photo_rename', prefs_list_from_gconftool2_string), 6247 ('video_rename', 'video_rename', prefs_list_from_gconftool2_string), 6248 ('subfolder', 'photo_subfolder', prefs_list_from_gconftool2_string), 6249 ('video_subfolder', 'video_subfolder', prefs_list_from_gconftool2_string), 6250 ('download_folder', 'photo_download_folder', str), 6251 ('video_download_folder','video_download_folder', str), 6252 ('device_autodetection', 'device_autodetection', pref_bool_from_gconftool2_string), 6253 ('device_location', 'this_computer_path', str), 6254 ('device_autodetection_psd', 'scan_specific_folders', 6255 pref_bool_from_gconftool2_string), 6256 ('ignored_paths', 'ignored_paths', prefs_list_from_gconftool2_string), 6257 ('use_re_ignored_paths', 'use_re_ignored_paths', pref_bool_from_gconftool2_string), 6258 ('backup_images', 'backup_files', pref_bool_from_gconftool2_string), 6259 ('backup_device_autodetection', 'backup_device_autodetection', 6260 pref_bool_from_gconftool2_string), 6261 ('backup_identifier', 'photo_backup_identifier', str), 6262 ('video_backup_identifier', 'video_backup_identifier', str), 6263 ('backup_location', 'backup_photo_location', str), 6264 ('backup_video_location', 'backup_video_location', str), 6265 ('strip_characters', 'strip_characters', pref_bool_from_gconftool2_string), 6266 ('synchronize_raw_jpg', 'synchronize_raw_jpg', pref_bool_from_gconftool2_string), 6267 ('auto_download_at_startup', 'auto_download_at_startup', 6268 pref_bool_from_gconftool2_string), 6269 ('auto_download_upon_device_insertion', 'auto_download_upon_device_insertion', 6270 pref_bool_from_gconftool2_string), 6271 ('auto_unmount', 'auto_unmount', pref_bool_from_gconftool2_string), 6272 ('auto_exit', 'auto_exit', pref_bool_from_gconftool2_string), 6273 ('auto_exit_force', 'auto_exit_force', pref_bool_from_gconftool2_string), 6274 ('verify_file', 'verify_file', pref_bool_from_gconftool2_string), 6275 ('job_codes', 'job_codes', prefs_list_from_gconftool2_string), 6276 ('generate_thumbnails', 'generate_thumbnails', pref_bool_from_gconftool2_string), 6277 ('download_conflict_resolution', 'conflict_resolution', str), 6278 ('backup_duplicate_overwrite', 'backup_duplicate_overwrite', 6279 pref_bool_from_gconftool2_string)) 6280 6281 if cmd is None: 6282 print(_("To import preferences from the old version of Rapid Photo Downloader, you must " 6283 "install the program gconftool-2.")) 6284 return 6285 6286 prefs = Preferences() 6287 6288 with raphodo.utilities.stdchannel_redirected(sys.stderr, os.devnull): 6289 value = run_cmd('program_version') 6290 if not value: 6291 print(_("No prior program preferences detected: exiting.")) 6292 return 6293 else: 6294 print( 6295 # Translators: %(variable)s represents Python code, not a plural of the term 6296 # variable. You must keep the %(variable)s untranslated, or the program will 6297 # crash. 6298 _( 6299 "Importing preferences from Rapid Photo Downloader %(version)s" 6300 ) % dict(version=value) 6301 ) 6302 print() 6303 6304 for key_triplet in keys: 6305 key = key_triplet[0] 6306 value = run_cmd(key) 6307 if value: 6308 try: 6309 new_value = key_triplet[2](value) 6310 except: 6311 print("Skipping malformed value for key {}".format(key)) 6312 else: 6313 if key == 'device_autodetection': 6314 if new_value: 6315 print("Setting device_autodetection to True") 6316 print("Setting this_computer_source to False") 6317 prefs.device_autodetection = True 6318 prefs.this_computer_source = False 6319 else: 6320 print("Setting device_autodetection to False") 6321 print("Setting this_computer_source to True") 6322 prefs.device_autodetection = False 6323 prefs.this_computer_source = True 6324 elif key == 'device_autodetection_psd': 6325 print("Setting scan_specific_folders to", not new_value) 6326 prefs.scan_specific_folders = not new_value 6327 elif key == 'device_location' and prefs.this_computer_source: 6328 print("Setting this_computer_path to", new_value) 6329 prefs.this_computer_path = new_value 6330 elif key == 'download_conflict_resolution': 6331 if new_value == "skip download": 6332 prefs.conflict_resolution = int(constants.ConflictResolution.skip) 6333 else: 6334 prefs.conflict_resolution = \ 6335 int(constants.ConflictResolution.add_identifier) 6336 else: 6337 new_key = key_triplet[1] 6338 if new_key in ('photo_rename', 'video_rename'): 6339 pref_list, case = upgrade_pre090a4_rename_pref(new_value) 6340 print("Setting", new_key, "to", pref_list) 6341 setattr(prefs, new_key, pref_list) 6342 if case is not None: 6343 if new_key == 'photo_rename': 6344 ext_key = 'photo_extension' 6345 else: 6346 ext_key = 'video_extension' 6347 print("Setting", ext_key, "to", case) 6348 setattr(prefs, ext_key, case) 6349 else: 6350 print("Setting", new_key, "to", new_value) 6351 setattr(prefs, new_key, new_value) 6352 6353 key = 'stored_sequence_no' 6354 with raphodo.utilities.stdchannel_redirected(sys.stderr, os.devnull): 6355 value = run_cmd(key) 6356 if value: 6357 try: 6358 new_value = int(value) 6359 # we need to add 1 to the number for historic reasons 6360 new_value += 1 6361 except ValueError: 6362 print("Skipping malformed value for key stored_sequence_no") 6363 else: 6364 if new_value and raphodo.utilities.confirm( 6365 '\n' + _( 6366 'Do you want to copy the stored sequence number, which has the value %d?' 6367 ) % new_value, resp=False): 6368 prefs.stored_sequence_no = new_value 6369 6370 6371def critical_startup_error(message: str) -> None: 6372 errorapp = QApplication(sys.argv) 6373 msg = QMessageBox() 6374 msg.setWindowTitle(_("Rapid Photo Downloader")) 6375 msg.setIcon(QMessageBox.Critical) 6376 msg.setText('<b>%s</b>' % message) 6377 msg.setInformativeText(_('Program aborting.')) 6378 msg.setStandardButtons(QMessageBox.Ok) 6379 msg.show() 6380 errorapp.exec_() 6381 6382 6383def main(): 6384 scaling_action = ScalingAction.not_set 6385 6386 scaling_detected, xsetting_running = any_screen_scaled() 6387 6388 if scaling_detected == ScalingDetected.undetected: 6389 scaling_set = 'High DPI scaling disabled because no scaled screen was detected' 6390 fractional_scaling = 'Fractional scaling not set' 6391 else: 6392 # Set Qt 5 screen scaling if it is not already set in an environment variable 6393 qt5_variable = qt5_screen_scale_environment_variable() 6394 scaling_variables = {qt5_variable, 'QT_SCALE_FACTOR', 'QT_SCREEN_SCALE_FACTORS'} 6395 if not scaling_variables & set(os.environ): 6396 scaling_set = 'High DPI scaling automatically set to ON because one of the ' \ 6397 'following environment variables not already ' \ 6398 'set: {}'.format(', '.join(scaling_variables)) 6399 scaling_action = ScalingAction.turned_on 6400 if pkgr.parse_version(QtCore.QT_VERSION_STR) >= pkgr.parse_version('5.6.0'): 6401 QApplication.setAttribute(Qt.AA_EnableHighDpiScaling) 6402 else: 6403 os.environ[qt5_variable] = '1' 6404 else: 6405 scaling_set = 'High DPI scaling not automatically set to ON because environment ' \ 6406 'variable(s) already ' \ 6407 'set: {}'.format(', '.join(scaling_variables & set(os.environ))) 6408 scaling_action = ScalingAction.already_set 6409 6410 QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps) 6411 6412 try: 6413 # Enable fractional scaling support on Qt 5.14 or above 6414 # Doesn't seem to be working on Gnome X11, however :-/ 6415 # Works on KDE Neon 6416 if pkgr.parse_version(QtCore.QT_VERSION_STR) >= pkgr.parse_version('5.14.0'): 6417 QApplication.setHighDpiScaleFactorRoundingPolicy( 6418 Qt.HighDpiScaleFactorRoundingPolicy.PassThrough 6419 ) 6420 fractional_scaling = 'Fractional scaling set to pass through' 6421 else: 6422 fractional_scaling = 'Fractional scaling unable to be set because Qt version is ' \ 6423 'older than 5.14' 6424 except Exception: 6425 fractional_scaling = 'Error setting fractional scaling' 6426 logging.warning(fractional_scaling) 6427 6428 if sys.platform.startswith('linux') and os.getuid() == 0: 6429 sys.stderr.write("Never run this program as the sudo / root user.\n") 6430 critical_startup_error(_("Never run this program as the sudo / root user.")) 6431 sys.exit(1) 6432 6433 if not shutil.which('exiftool'): 6434 critical_startup_error(_('You must install ExifTool to run Rapid Photo Downloader.')) 6435 sys.exit(1) 6436 6437 rapid_path = os.path.realpath(os.path.dirname(inspect.getfile(inspect.currentframe()))) 6438 import_path = os.path.realpath(os.path.dirname(inspect.getfile(downloadtracker))) 6439 if rapid_path != import_path: 6440 sys.stderr.write( 6441 "Rapid Photo Downloader is installed in multiple locations. Uninstall all copies " 6442 "except the version you want to run.\n" 6443 ) 6444 critical_startup_error( 6445 _( 6446 "Rapid Photo Downloader is installed in multiple locations.\n\nUninstall all " 6447 "copies except the version you want to run." 6448 ) 6449 ) 6450 6451 sys.exit(1) 6452 6453 parser = parser_options() 6454 6455 args = parser.parse_args() 6456 if args.detailed_version: 6457 file_manager, file_manager_type = get_default_file_manager() 6458 print( 6459 '\n'.join( 6460 get_versions( 6461 file_manager, file_manager_type, scaling_action, scaling_detected, 6462 xsetting_running 6463 ) 6464 ) 6465 ) 6466 sys.exit(0) 6467 6468 if args.extensions: 6469 photos = list((ext.upper() for ext in fileformats.PHOTO_EXTENSIONS)) 6470 videos = list((ext.upper() for ext in fileformats.VIDEO_EXTENSIONS)) 6471 extensions = ((photos, _("Photos")), (videos, _("Videos"))) 6472 for exts, file_type in extensions: 6473 extensions = make_internationalized_list(exts) 6474 print('{}: {}'.format(file_type, extensions)) 6475 sys.exit(0) 6476 6477 if args.debug: 6478 logging_level = logging.DEBUG 6479 elif args.verbose: 6480 logging_level = logging.INFO 6481 else: 6482 logging_level = logging.ERROR 6483 6484 global logger 6485 logger = iplogging.setup_main_process_logging(logging_level=logging_level) 6486 6487 logging.info("Rapid Photo Downloader is starting") 6488 6489 if args.photo_renaming: 6490 photo_rename = args.photo_renaming == 'on' 6491 if photo_rename: 6492 logging.info("Photo renaming turned on from command line") 6493 else: 6494 logging.info("Photo renaming turned off from command line") 6495 else: 6496 photo_rename = None 6497 6498 if args.video_renaming: 6499 video_rename = args.video_renaming == 'on' 6500 if video_rename: 6501 logging.info("Video renaming turned on from command line") 6502 else: 6503 logging.info("Video renaming turned off from command line") 6504 else: 6505 video_rename = None 6506 6507 if args.path: 6508 if args.auto_detect or args.this_computer_source: 6509 msg = _( 6510 'When specifying a path on the command line, do not also specify an\n' 6511 'option for device auto detection or a path on "This Computer".' 6512 ) 6513 print(msg) 6514 critical_startup_error(msg.replace('\n', ' ')) 6515 sys.exit(1) 6516 6517 media_dir = get_media_dir() 6518 auto_detect = args.path.startswith(media_dir) or gvfs_gphoto2_path(args.path) 6519 if auto_detect: 6520 this_computer_source = False 6521 this_computer_location = None 6522 logging.info( 6523 "Device auto detection turned on from command line using positional PATH argument" 6524 ) 6525 6526 if not auto_detect: 6527 this_computer_source = True 6528 this_computer_location = os.path.abspath(args.path) 6529 logging.info( 6530 "Downloading from This Computer turned on from command line using positional " 6531 "PATH argument" 6532 ) 6533 6534 else: 6535 if args.auto_detect: 6536 auto_detect= args.auto_detect == 'on' 6537 if auto_detect: 6538 logging.info("Device auto detection turned on from command line") 6539 else: 6540 logging.info("Device auto detection turned off from command line") 6541 else: 6542 auto_detect=None 6543 6544 if args.this_computer_source: 6545 this_computer_source = args.this_computer_source == 'on' 6546 if this_computer_source: 6547 logging.info("Downloading from This Computer turned on from command line") 6548 else: 6549 logging.info("Downloading from This Computer turned off from command line") 6550 else: 6551 this_computer_source=None 6552 6553 if args.this_computer_location: 6554 this_computer_location = os.path.abspath(args.this_computer_location) 6555 logging.info("This Computer path set from command line: %s", this_computer_location) 6556 else: 6557 this_computer_location=None 6558 6559 if args.photo_location: 6560 photo_location = os.path.abspath(args.photo_location) 6561 logging.info("Photo location set from command line: %s", photo_location) 6562 else: 6563 photo_location=None 6564 6565 if args.video_location: 6566 video_location = os.path.abspath(args.video_location) 6567 logging.info("video location set from command line: %s", video_location) 6568 else: 6569 video_location=None 6570 6571 if args.backup: 6572 backup = args.backup == 'on' 6573 if backup: 6574 logging.info("Backup turned on from command line") 6575 else: 6576 logging.info("Backup turned off from command line") 6577 else: 6578 backup=None 6579 6580 if args.backup_auto_detect: 6581 backup_auto_detect = args.backup_auto_detect == 'on' 6582 if backup_auto_detect: 6583 logging.info("Automatic detection of backup devices turned on from command line") 6584 else: 6585 logging.info("Automatic detection of backup devices turned off from command line") 6586 else: 6587 backup_auto_detect=None 6588 6589 if args.photo_backup_identifier: 6590 photo_backup_identifier = args.photo_backup_identifier 6591 logging.info("Photo backup identifier set from command line: %s", photo_backup_identifier) 6592 else: 6593 photo_backup_identifier=None 6594 6595 if args.video_backup_identifier: 6596 video_backup_identifier = args.video_backup_identifier 6597 logging.info("Video backup identifier set from command line: %s", video_backup_identifier) 6598 else: 6599 video_backup_identifier=None 6600 6601 if args.photo_backup_location: 6602 photo_backup_location = os.path.abspath(args.photo_backup_location) 6603 logging.info("Photo backup location set from command line: %s", photo_backup_location) 6604 else: 6605 photo_backup_location=None 6606 6607 if args.video_backup_location: 6608 video_backup_location = os.path.abspath(args.video_backup_location) 6609 logging.info("Video backup location set from command line: %s", video_backup_location) 6610 else: 6611 video_backup_location=None 6612 6613 if args.thumb_cache: 6614 thumb_cache = args.thumb_cache == 'on' 6615 else: 6616 thumb_cache = None 6617 6618 if args.auto_download_startup: 6619 auto_download_startup = args.auto_download_startup == 'on' 6620 if auto_download_startup: 6621 logging.info("Automatic download at startup turned on from command line") 6622 else: 6623 logging.info("Automatic download at startup turned off from command line") 6624 else: 6625 auto_download_startup=None 6626 6627 if args.auto_download_insertion: 6628 auto_download_insertion = args.auto_download_insertion == 'on' 6629 if auto_download_insertion: 6630 logging.info("Automatic download upon device insertion turned on from command line") 6631 else: 6632 logging.info("Automatic download upon device insertion turned off from command line") 6633 else: 6634 auto_download_insertion=None 6635 6636 if args.log_gphoto2: 6637 gphoto_logging = gphoto2_python_logging() 6638 6639 if args.camera_info: 6640 dump_camera_details() 6641 sys.exit(0) 6642 6643 # keep appGuid value in sync with value in upgrade.py 6644 appGuid = '8dbfb490-b20f-49d3-9b7d-2016012d2aa8' 6645 6646 # See note at top regarding avoiding crashes 6647 global app 6648 app = QtSingleApplication(appGuid, sys.argv) 6649 if app.isRunning(): 6650 print('Rapid Photo Downloader is already running') 6651 sys.exit(0) 6652 6653 app.setOrganizationName("Rapid Photo Downloader") 6654 app.setOrganizationDomain("damonlynch.net") 6655 app.setApplicationName("Rapid Photo Downloader") 6656 app.setWindowIcon(QIcon(':/rapid-photo-downloader.svg')) 6657 6658 # Determine the system locale as reported by Qt. Use it to 6659 # see if Qt has a base translation available, which allows 6660 # automatic translation of QMessageBox buttons 6661 try: 6662 locale = QLocale.system() 6663 if locale: 6664 locale_name = locale.name() 6665 if not locale_name: 6666 logging.debug("Could not determine system locale using Qt") 6667 elif locale_name.startswith('en'): 6668 # Set module level variable indicating there is no need to translate 6669 # the buttons because language is English 6670 viewutils.Do_Message_And_Dialog_Box_Button_Translation = False 6671 else: 6672 qtTranslator = getQtSystemTranslation(locale_name) 6673 if qtTranslator: 6674 app.installTranslator(qtTranslator) 6675 # Set module level variable indicating there is no need to translate 6676 # the buttons because Qt does the translation 6677 viewutils.Do_Message_And_Dialog_Box_Button_Translation = False 6678 except Exception: 6679 logging.error('Error determining locale via Qt') 6680 6681 # darkFusion(app) 6682 # app.setStyle('Fusion') 6683 6684 # Resetting preferences must occur after QApplication is instantiated 6685 if args.reset: 6686 prefs = Preferences() 6687 prefs.reset() 6688 prefs.sync() 6689 d = DownloadedSQL() 6690 d.update_table(reset=True) 6691 cache = ThumbnailCacheSql(create_table_if_not_exists=False) 6692 cache.purge_cache() 6693 print(_("All settings and caches have been reset.")) 6694 logging.debug("Exiting immediately after full reset") 6695 sys.exit(0) 6696 6697 if args.delete_thumb_cache or args.forget_files or args.import_prefs: 6698 if args.delete_thumb_cache: 6699 cache = ThumbnailCacheSql(create_table_if_not_exists=False) 6700 cache.purge_cache() 6701 print(_("Thumbnail Cache has been reset.")) 6702 logging.debug("Thumbnail Cache has been reset") 6703 6704 if args.forget_files: 6705 d = DownloadedSQL() 6706 d.update_table(reset=True) 6707 print(_("Remembered files have been forgotten.")) 6708 logging.debug("Remembered files have been forgotten") 6709 6710 if args.import_prefs: 6711 import_prefs() 6712 logging.debug("Exiting immediately after thumbnail cache / remembered files reset") 6713 sys.exit(0) 6714 6715 # Use QIcon to render so we get the high DPI version automatically 6716 size = QSize(600, 400) 6717 pixmap = scaledIcon(':/splashscreen.png', size).pixmap(size) 6718 6719 splash = SplashScreen(pixmap, Qt.WindowStaysOnTopHint) 6720 splash.show() 6721 app.processEvents() 6722 6723 rw = RapidWindow( 6724 photo_rename=photo_rename, 6725 video_rename=video_rename, 6726 auto_detect=auto_detect, 6727 this_computer_source=this_computer_source, 6728 this_computer_location=this_computer_location, 6729 photo_download_folder=photo_location, 6730 video_download_folder=video_location, 6731 backup=backup, 6732 backup_auto_detect=backup_auto_detect, 6733 photo_backup_identifier=photo_backup_identifier, 6734 video_backup_identifier=video_backup_identifier, 6735 photo_backup_location=photo_backup_location, 6736 video_backup_location=video_backup_location, 6737 ignore_other_photo_types=args.ignore_other, 6738 thumb_cache=thumb_cache, 6739 auto_download_startup=auto_download_startup, 6740 auto_download_insertion=auto_download_insertion, 6741 log_gphoto2=args.log_gphoto2, 6742 splash=splash, 6743 fractional_scaling=fractional_scaling, 6744 scaling_set=scaling_set, 6745 scaling_action=scaling_action, 6746 scaling_detected=scaling_detected, 6747 xsetting_running=xsetting_running, 6748 ) 6749 6750 app.setActivationWindow(rw) 6751 code = app.exec_() 6752 logging.debug("Exiting") 6753 sys.exit(code) 6754 6755 6756if __name__ == "__main__": 6757 main() 6758