summaryrefslogtreecommitdiff
path: root/raphodo/rapid.py
diff options
context:
space:
mode:
Diffstat (limited to 'raphodo/rapid.py')
-rwxr-xr-xraphodo/rapid.py6005
1 files changed, 6005 insertions, 0 deletions
diff --git a/raphodo/rapid.py b/raphodo/rapid.py
new file mode 100755
index 0000000..0364fc0
--- /dev/null
+++ b/raphodo/rapid.py
@@ -0,0 +1,6005 @@
+#!/usr/bin/env python3
+
+# Copyright (C) 2011-2017 Damon Lynch <damonlynch@gmail.com>
+
+# This file is part of Rapid Photo Downloader.
+#
+# Rapid Photo Downloader is free software: you can redistribute it and/or
+# modify it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Rapid Photo Downloader is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Rapid Photo Downloader. If not,
+# see <http://www.gnu.org/licenses/>.
+
+"""
+Primary logic for Rapid Photo Downloader.
+
+Qt related class method and variable names use CamelCase.
+Everything else should follow PEP 8.
+Project line length: 100 characters (i.e. word wrap at 99)
+
+"Hamburger" Menu Icon by Daniel Bruce -- www.entypo.com
+"""
+
+__author__ = 'Damon Lynch'
+__copyright__ = "Copyright 2011-2017, Damon Lynch"
+
+import sys
+import logging
+
+import shutil
+import datetime
+import locale
+locale.setlocale(locale.LC_ALL, '')
+import pickle
+from collections import namedtuple, defaultdict
+import platform
+import argparse
+from typing import Optional, Tuple, List, Sequence, Dict, Set, Any, DefaultDict
+import faulthandler
+import pkg_resources
+import webbrowser
+import time
+import shlex
+import subprocess
+from urllib.request import pathname2url
+import tarfile
+
+from gettext import gettext as _
+
+
+import gi
+gi.require_version('Notify', '0.7')
+from gi.repository import Notify
+
+try:
+ gi.require_version('Unity', '7.0')
+ from gi.repository import Unity
+ have_unity = True
+except (ImportError, ValueError):
+ have_unity = False
+
+import zmq
+import psutil
+import gphoto2 as gp
+from PyQt5 import QtCore, QtGui
+from PyQt5.QtCore import (
+ QThread, Qt, QStorageInfo, QSettings, QPoint, QSize, QTimer, QTextStream, QModelIndex,
+ pyqtSlot, QRect, pyqtSignal, QObject
+)
+from PyQt5.QtGui import (
+ QIcon, QPixmap, QImage, QColor, QPalette, QFontMetrics, QFont, QPainter, QMoveEvent, QBrush,
+ QPen, QColor
+)
+from PyQt5.QtWidgets import (
+ QAction, QApplication, QMainWindow, QMenu, QWidget, QDialogButtonBox,
+ QProgressBar, QSplitter, QHBoxLayout, QVBoxLayout, QDialog, QLabel, QComboBox, QGridLayout,
+ QCheckBox, QSizePolicy, QMessageBox, QSplashScreen, QStackedWidget, QScrollArea,
+ QDesktopWidget, QStyledItemDelegate, QPushButton
+)
+from PyQt5.QtNetwork import QLocalSocket, QLocalServer
+
+from raphodo.storage import (
+ ValidMounts, CameraHotplug, UDisks2Monitor, GVolumeMonitor, have_gio,
+ has_non_empty_dcim_folder, mountPaths, get_desktop_environment, get_desktop,
+ gvfs_controls_mounts, get_default_file_manager, validate_download_folder,
+ validate_source_folder, get_fdo_cache_thumb_base_directory, WatchDownloadDirs, get_media_dir,
+ StorageSpace
+)
+from raphodo.interprocess import (
+ ScanArguments, CopyFilesArguments, RenameAndMoveFileData, BackupArguments,
+ BackupFileData, OffloadData, ProcessLoggingManager, ThumbnailDaemonData, ThreadNames,
+ OffloadManager, CopyFilesManager, ThumbnailDaemonManager,
+ ScanManager, BackupManager, stop_process_logging_manager, RenameMoveFileManager,
+ create_inproc_msg)
+from raphodo.devices import (
+ Device, DeviceCollection, BackupDevice, BackupDeviceCollection, FSMetadataErrors
+)
+from raphodo.preferences import Preferences
+from raphodo.constants import (
+ BackupLocationType, DeviceType, ErrorType, FileType, DownloadStatus, RenameAndMoveStatus,
+ ApplicationState, CameraErrorCode, TemporalProximityState, ThumbnailBackgroundName,
+ Desktop, BackupFailureType, DeviceState, Sort, Show, DestinationDisplayType,
+ DisplayingFilesOfType, DownloadingFileTypes, RememberThisMessage, RightSideButton,
+ CheckNewVersionDialogState, CheckNewVersionDialogResult, RememberThisButtons,
+ BackupStatus
+)
+from raphodo.thumbnaildisplay import (
+ ThumbnailView, ThumbnailListModel, ThumbnailDelegate, DownloadStats, MarkedSummary
+)
+from raphodo.devicedisplay import (DeviceModel, DeviceView, DeviceDelegate)
+from raphodo.proximity import (TemporalProximityGroups, TemporalProximity)
+from raphodo.utilities import (
+ same_device, make_internationalized_list, thousands, addPushButtonLabelSpacer,
+ make_html_path_non_breaking, prefs_list_from_gconftool2_string,
+ pref_bool_from_gconftool2_string, extract_file_from_tar, format_size_for_user
+)
+from raphodo.rememberthisdialog import RememberThisDialog
+import raphodo.utilities
+from raphodo.rpdfile import (
+ RPDFile, file_types_by_number, PHOTO_EXTENSIONS, VIDEO_EXTENSIONS, OTHER_PHOTO_EXTENSIONS,
+ FileTypeCounter, Video
+)
+import raphodo.downloadtracker as downloadtracker
+from raphodo.cache import ThumbnailCacheSql
+from raphodo.metadataphoto import exiv2_version, gexiv2_version
+from raphodo.metadatavideo import EXIFTOOL_VERSION, pymedia_version_info, libmediainfo_missing
+from raphodo.camera import gphoto2_version, python_gphoto2_version, dump_camera_details
+from raphodo.rpdsql import DownloadedSQL
+from raphodo.generatenameconfig import *
+from raphodo.rotatedpushbutton import RotatedButton, FlatButton
+from raphodo.primarybutton import TopPushButton, DownloadButton
+from raphodo.filebrowse import (
+ FileSystemView, FileSystemModel, FileSystemFilter, FileSystemDelegate
+)
+from raphodo.toggleview import QToggleView
+import raphodo.__about__ as __about__
+import raphodo.iplogging as iplogging
+import raphodo.excepthook
+from raphodo.panelview import QPanelView
+from raphodo.computerview import ComputerWidget
+from raphodo.folderspreview import DownloadDestination, FoldersPreview
+from raphodo.destinationdisplay import DestinationDisplay
+from raphodo.aboutdialog import AboutDialog
+import raphodo.constants as constants
+from raphodo.menubutton import MenuButton
+from raphodo.renamepanel import RenamePanel
+from raphodo.jobcodepanel import JobCodePanel
+from raphodo.backuppanel import BackupPanel
+import raphodo
+import raphodo.exiftool as exiftool
+from raphodo.newversion import (
+ NewVersion, NewVersionCheckDialog, version_details, DownloadNewVersionDialog
+)
+from raphodo.chevroncombo import ChevronCombo
+from raphodo.preferencedialog import PreferencesDialog
+from raphodo.errorlog import ErrorReport, SpeechBubble
+from raphodo.problemnotification import (
+ FsMetadataWriteProblem, Problem, Problems, CopyingProblems, RenamingProblems, BackingUpProblems
+)
+from raphodo.viewutils import standardIconSize
+import raphodo.didyouknow as didyouknow
+from raphodo.thumbnailextractor import gst_version, libraw_version, rawkit_version
+
+
+# Avoid segfaults at exit:
+# http://pyqt.sourceforge.net/Docs/PyQt5/gotchas.html#crashes-on-exit
+app = None # type: 'QtSingleApplication'
+
+faulthandler.enable()
+logger = None
+sys.excepthook = raphodo.excepthook.excepthook
+
+
+class FolderPreviewManager(QObject):
+ """
+ Manages sending FoldersPreview() off to the offload process to
+ generate new provisional download subfolders, and removing provisional download subfolders
+ in the main process, using QFileSystemModel.
+
+ Queues operations if they need to be, or runs them immediately when it can.
+
+ Sadly we must delete provisional download folders only in the main process, using
+ QFileSystemModel. Otherwise the QFileSystemModel is liable to issue a large number of
+ messages like this:
+
+ QInotifyFileSystemWatcherEngine::addPaths: inotify_add_watch failed: No such file or directory
+
+ Yet we must generate and create folders in the offload process, because that
+ can be expensive for a large number of rpd_files.
+
+ New for PyQt 5.7: Inherits from QObject to allow for Qt signals and slots using PyQt slot
+ decorator.
+ """
+
+ def __init__(self, fsmodel: FileSystemModel,
+ prefs: Preferences,
+ photoDestinationFSView: FileSystemView,
+ videoDestinationFSView: FileSystemView,
+ devices: DeviceCollection,
+ rapidApp: 'RapidWindow') -> None:
+ """
+
+ :param fsmodel: FileSystemModel powering the destination and this computer views
+ :param prefs: program preferences
+ :param photoDestinationFSView: photo destination view
+ :param videoDestinationFSView: video destination view
+ :param devices: the device collection
+ :param rapidApp: main application window
+ """
+
+ super().__init__()
+
+ self.rpd_files_queue = [] # type: List[RPDFile]
+ self.clean_for_scan_id_queue = [] # type: List[int]
+ self.change_destination_queued = False # type: bool
+ self.subfolder_rebuild_queued = False # type: bool
+
+ self.offloaded = False
+ self.process_destination = False
+ self.fsmodel = fsmodel
+ self.prefs = prefs
+ self.devices = devices
+ self.rapidApp = rapidApp
+
+ self.photoDestinationFSView = photoDestinationFSView
+ self.videoDestinationFSView = videoDestinationFSView
+
+ self.folders_preview = FoldersPreview()
+ # Set the initial download destination values, using the values
+ # in the program prefs:
+ self._change_destination()
+
+ def add_rpd_files(self, rpd_files: List[RPDFile]) -> None:
+ """
+ Generate new provisional download folders for the rpd_files, either
+ by sending them off for generation to the offload process, or if some
+ are already being generated, queueing the operation
+
+ :param rpd_files: the list of rpd files
+ """
+
+ if self.offloaded:
+ self.rpd_files_queue.extend(rpd_files)
+ else:
+ if self.rpd_files_queue:
+ rpd_files = rpd_files + self.rpd_files_queue
+ self.rpd_files_queue = [] # type: List[RPDFile]
+ self._generate_folders(rpd_files=rpd_files)
+
+ def _generate_folders(self, rpd_files: List[RPDFile]) -> None:
+ if not self.devices.scanning or self.rapidApp.downloadIsRunning():
+ logging.info("Generating provisional download folders for %s files", len(rpd_files))
+ data = OffloadData(rpd_files=rpd_files, strip_characters=self.prefs.strip_characters,
+ folders_preview=self.folders_preview)
+ self.offloaded = True
+ self.rapidApp.sendToOffload(data=data)
+
+ def change_destination(self) -> None:
+ if self.offloaded:
+ self.change_destination_queued = True
+ else:
+ self._change_destination()
+ self._update_model_and_views()
+
+ def change_subfolder_structure(self) -> None:
+ self.change_destination()
+ if self.offloaded:
+ assert self.change_destination_queued == True
+ self.subfolder_rebuild_queued = True
+ else:
+ self._change_subfolder_structure()
+
+ def _change_destination(self) -> None:
+ destination = DownloadDestination(
+ photo_download_folder=self.prefs.photo_download_folder,
+ video_download_folder=self.prefs.video_download_folder,
+ photo_subfolder=self.prefs.photo_subfolder,
+ video_subfolder=self.prefs.video_subfolder)
+ self.folders_preview.process_destination(destination=destination,
+ fsmodel=self.fsmodel)
+
+ def _change_subfolder_structure(self) -> None:
+ rpd_files = self.rapidApp.thumbnailModel.getAllDownloadableRPDFiles()
+ if rpd_files:
+ self.add_rpd_files(rpd_files=rpd_files)
+
+ @pyqtSlot(FoldersPreview)
+ def folders_generated(self, folders_preview: FoldersPreview) -> None:
+ """
+ Receive the folders_preview from the offload process, and
+ handle any tasks that may have been queued in the time it was
+ being processed in the offload process
+
+ :param folders_preview: the folders_preview as worked on by the
+ offload process
+ """
+
+ logging.debug("Provisional download folders received")
+ self.offloaded = False
+ self.folders_preview = folders_preview
+
+ dirty = self.folders_preview.dirty
+ self.folders_preview.dirty = False
+ if dirty:
+ logging.debug("Provisional download folders change detected")
+
+ if not self.rapidApp.downloadIsRunning():
+ for scan_id in self.clean_for_scan_id_queue:
+ dirty = True
+ self._remove_provisional_folders_for_device(scan_id=scan_id)
+
+ self.clean_for_scan_id_queue = [] # type: List[int]
+
+ if self.change_destination_queued:
+ self.change_destination_queued = False
+ dirty = True
+ logging.debug("Changing destination of provisional download folders")
+ self._change_destination()
+
+ if self.subfolder_rebuild_queued:
+ self.subfolder_rebuild_queued = False
+ logging.debug("Rebuilding provisional download folders")
+ self._change_subfolder_structure()
+ else:
+ logging.debug("Not removing or moving provisional download folders because"
+ "a download is running")
+
+ if dirty:
+ self._update_model_and_views()
+
+ if self.rpd_files_queue:
+ logging.debug("Assigning queued provisional download folders to be generated")
+ self._generate_folders(rpd_files=self.rpd_files_queue)
+ self.rpd_files_queue = [] # type: List[RPDFile]
+
+ # self.folders_preview.dump()
+
+ def _update_model_and_views(self):
+ logging.debug("Updating file system model and views")
+ self.fsmodel.preview_subfolders = self.folders_preview.preview_subfolders()
+ self.fsmodel.download_subfolders = self.folders_preview.download_subfolders()
+ # Update the view
+ self.photoDestinationFSView.reset()
+ self.videoDestinationFSView.reset()
+ # Ensure the file system model caches are refreshed:
+ self.fsmodel.setRootPath(self.folders_preview.photo_download_folder)
+ self.fsmodel.setRootPath(self.folders_preview.video_download_folder)
+ self.fsmodel.setRootPath('/')
+ self.photoDestinationFSView.expandPreviewFolders(self.prefs.photo_download_folder)
+ self.videoDestinationFSView.expandPreviewFolders(self.prefs.video_download_folder)
+
+ # self.photoDestinationFSView.update()
+ # self.videoDestinationFSView.update()
+
+ def remove_folders_for_device(self, scan_id: int) -> None:
+ """
+ Remove provisional download folders unique to this scan_id
+ using the offload process.
+
+ :param scan_id: scan id of the device
+ """
+
+ if self.offloaded:
+ self.clean_for_scan_id_queue.append(scan_id)
+ else:
+ self._remove_provisional_folders_for_device(scan_id=scan_id)
+ self._update_model_and_views()
+
+ def queue_folder_removal_for_device(self, scan_id: int) -> None:
+ """
+ Queues provisional download files for removal after
+ all files have been downloaded for a device.
+
+ :param scan_id: scan id of the device
+ """
+
+ self.clean_for_scan_id_queue.append(scan_id)
+
+ def remove_folders_for_queued_devices(self) -> None:
+ """
+ Once all files have been downloaded (i.e. no more remain
+ to be downloaded) and there was a disparity between
+ modification times and creation times that was discovered during
+ the download, clean any provisional download folders now that the
+ download has finished.
+ """
+
+ for scan_id in self.clean_for_scan_id_queue:
+ self._remove_provisional_folders_for_device(scan_id=scan_id)
+ self.clean_for_scan_id_queue = [] # type: List[int]
+ self._update_model_and_views()
+
+ def _remove_provisional_folders_for_device(self, scan_id: int) -> None:
+ if scan_id in self.devices:
+ logging.info("Cleaning provisional download folders for %s",
+ self.devices[scan_id].display_name)
+ else:
+ logging.info("Cleaning provisional download folders for device %d", scan_id)
+ self.folders_preview.clean_generated_folders_for_scan_id(scan_id=scan_id,
+ fsmodel=self.fsmodel)
+
+ def remove_preview_folders(self) -> None:
+ """
+ Called when application is exiting.
+ """
+
+ self.folders_preview.clean_all_generated_folders(fsmodel=self.fsmodel)
+
+
+class RapidWindow(QMainWindow):
+ """
+ Main application window, and primary controller of program logic
+
+ Such attributes unfortunately make it very complex.
+
+ For better or worse, Qt's state machine technology is not used.
+ State indicating whether a download or scan is occurring is
+ thus kept in the device collection, self.devices
+ """
+
+ checkForNewVersionRequest = pyqtSignal()
+ downloadNewVersionRequest = pyqtSignal(str, str)
+ reverifyDownloadedTar = pyqtSignal(str)
+ udisks2Unmount = pyqtSignal(str)
+
+ def __init__(self, splash: 'SplashScreen',
+ photo_rename: Optional[bool]=None,
+ video_rename: Optional[bool]=None,
+ auto_detect: Optional[bool]=None,
+ this_computer_source: Optional[str]=None,
+ this_computer_location: Optional[str]=None,
+ photo_download_folder: Optional[str]=None,
+ video_download_folder: Optional[str]=None,
+ backup: Optional[bool]=None,
+ backup_auto_detect: Optional[bool]=None,
+ photo_backup_identifier: Optional[str]=None,
+ video_backup_identifier: Optional[str]=None,
+ photo_backup_location: Optional[str]=None,
+ video_backup_location: Optional[str]=None,
+ ignore_other_photo_types: Optional[bool]=None,
+ thumb_cache: Optional[bool]=None,
+ auto_download_startup: Optional[bool]=None,
+ auto_download_insertion: Optional[bool]=None,
+ log_gphoto2: Optional[bool]=None) -> None:
+
+ super().__init__()
+ self.splash = splash
+ # Process Qt events - in this case, possible closing of splash screen
+ app.processEvents()
+
+ # Three values to handle window position quirks under X11:
+ self.window_show_requested_time = None # type: datetime.datetime
+ self.window_move_triggered_count = 0
+ self.windowPositionDelta = QPoint(0, 0)
+
+ self.setFocusPolicy(Qt.StrongFocus)
+
+ self.ignore_other_photo_types = ignore_other_photo_types
+ self.application_state = ApplicationState.normal
+ self.prompting_for_user_action = {} # type: Dict[Device, QMessageBox]
+
+ self.close_event_run = False
+
+ for version in get_versions():
+ logging.info('%s', version)
+
+ if EXIFTOOL_VERSION is None:
+ logging.error("ExifTool is either missing or has a problem")
+
+ if pymedia_version_info() is None:
+ if libmediainfo_missing:
+ logging.error(
+ "pymediainfo is installed, but the library libmediainfo appears to be missing"
+ )
+
+ self.log_gphoto2 = log_gphoto2 == True
+
+ self.setWindowTitle(_("Rapid Photo Downloader"))
+ # app is a module level global
+ self.readWindowSettings(app)
+ self.prefs = Preferences()
+ self.checkPrefsUpgrade()
+ self.prefs.program_version = __about__.__version__
+
+ # track devices on which there was an error setting a file's filesystem metadata
+ self.copy_metadata_errors = FSMetadataErrors()
+ self.backup_metadata_errors = FSMetadataErrors()
+
+ if thumb_cache is not None:
+ logging.debug("Use thumbnail cache: %s", thumb_cache)
+ self.prefs.use_thumbnail_cache = thumb_cache
+
+ self.setupWindow()
+
+ splash.setProgress(10)
+
+ if photo_rename is not None:
+ if photo_rename:
+ self.prefs.photo_rename = PHOTO_RENAME_SIMPLE
+ else:
+ self.prefs.photo_rename = self.prefs.rename_defaults['photo_rename']
+
+ if video_rename is not None:
+ if video_rename:
+ self.prefs.video_rename = VIDEO_RENAME_SIMPLE
+ else:
+ self.prefs.video_rename = self.prefs.rename_defaults['video_rename']
+
+ if auto_detect is not None:
+ self.prefs.device_autodetection = auto_detect
+ else:
+ logging.info("Device autodetection: %s", self.prefs.device_autodetection)
+
+ if self.prefs.device_autodetection:
+ if self.prefs.device_without_dcim_autodetection:
+ logging.info("Devices do not need a DCIM folder to be scanned")
+ else:
+ logging.info("For automatically detected devices, only the contents of their "
+ "DCIM folder will be scanned")
+
+ if this_computer_source is not None:
+ self.prefs.this_computer_source = this_computer_source
+
+ if this_computer_location is not None:
+ self.prefs.this_computer_path = this_computer_location
+
+ if self.prefs.this_computer_source:
+ if self.prefs.this_computer_path:
+ logging.info("This Computer is set to be used as a download source, "
+ "using: %s", self.prefs.this_computer_path)
+ else:
+ logging.info("This Computer is set to be used as a download source, "
+ "but the location is not yet set")
+ else:
+ logging.info("This Computer is not used as a download source")
+
+ if photo_download_folder is not None:
+ self.prefs.photo_download_folder = photo_download_folder
+ logging.info("Photo download location: %s", self.prefs.photo_download_folder)
+ if video_download_folder is not None:
+ self.prefs.video_download_folder = video_download_folder
+ logging.info("Video download location: %s", self.prefs.video_download_folder)
+
+ if backup is not None:
+ self.prefs.backup_files = backup
+ else:
+ logging.info("Backing up files: %s", self.prefs.backup_files)
+
+ if backup_auto_detect is not None:
+ self.prefs.backup_device_autodetection = backup_auto_detect
+ elif self.prefs.backup_files:
+ logging.info("Backup device auto detection: %s", self.prefs.backup_device_autodetection)
+
+ if photo_backup_identifier is not None:
+ self.prefs.photo_backup_identifier = photo_backup_identifier
+ elif self.prefs.backup_files and self.prefs.backup_device_autodetection:
+ logging.info("Photo backup identifier: %s", self.prefs.photo_backup_identifier)
+
+ if video_backup_identifier is not None:
+ self.prefs.video_backup_identifier = video_backup_identifier
+ elif self.prefs.backup_files and self.prefs.backup_device_autodetection:
+ logging.info("video backup identifier: %s", self.prefs.video_backup_identifier)
+
+ if photo_backup_location is not None:
+ self.prefs.backup_photo_location = photo_backup_location
+ elif self.prefs.backup_files and not self.prefs.backup_device_autodetection:
+ logging.info("Photo backup location: %s", self.prefs.backup_photo_location)
+
+ if video_backup_location is not None:
+ self.prefs.backup_video_location = video_backup_location
+ elif self.prefs.backup_files and not self.prefs.backup_device_autodetection:
+ logging.info("video backup location: %s", self.prefs.backup_video_location)
+
+ if auto_download_startup is not None:
+ self.prefs.auto_download_at_startup = auto_download_startup
+ elif self.prefs.auto_download_at_startup:
+ logging.info("Auto download at startup is on")
+
+ if auto_download_insertion is not None:
+ self.prefs.auto_download_upon_device_insertion = auto_download_insertion
+ elif self.prefs.auto_download_upon_device_insertion:
+ logging.info("Auto download upon device insertion is on")
+
+ self.prefs.verify_file = False
+
+ logging.debug("Starting main ExifTool process")
+ self.exiftool_process = exiftool.ExifTool()
+ self.exiftool_process.start()
+
+ self.prefs.validate_max_CPU_cores()
+ self.prefs.validate_ignore_unhandled_file_exts()
+
+ # Don't call processEvents() after initiating 0MQ, as it can
+ # cause "Interrupted system call" errors
+ app.processEvents()
+
+ self.download_paused = False
+
+ self.startThreadControlSockets()
+ self.startProcessLogger()
+
+ def checkPrefsUpgrade(self) -> None:
+ if self.prefs.program_version != __about__.__version__:
+ previous_version = self.prefs.program_version
+ if not len(previous_version):
+ logging.debug("Initial program run detected")
+ else:
+ pv = pkg_resources.parse_version(previous_version)
+ rv = pkg_resources.parse_version(__about__.__version__)
+ if pv < rv:
+ logging.info("Version upgrade detected, from %s to %s",
+ previous_version, __about__.__version__)
+ self.prefs.upgrade_prefs(pv)
+ elif pv > rv:
+ logging.info("Version downgrade detected, from %s to %s",
+ __about__.__version__, previous_version)
+
+ def startThreadControlSockets(self) -> None:
+ """
+ Create and bind inproc sockets to communicate with threads that
+ handle inter process communication via zmq.
+
+ See 'Signaling Between Threads (PAIR Sockets)' in 'ØMQ - The Guide'
+ http://zguide.zeromq.org/page:all#toc46
+ """
+
+ context = zmq.Context.instance()
+ inproc = "inproc://{}"
+
+ self.logger_controller = context.socket(zmq.PAIR)
+ self.logger_controller.bind(inproc.format(ThreadNames.logger))
+
+ self.rename_controller = context.socket(zmq.PAIR)
+ self.rename_controller.bind(inproc.format(ThreadNames.rename))
+
+ self.scan_controller = context.socket(zmq.PAIR)
+ self.scan_controller.bind(inproc.format(ThreadNames.scan))
+
+ self.copy_controller = context.socket(zmq.PAIR)
+ self.copy_controller.bind(inproc.format(ThreadNames.copy))
+
+ self.backup_controller = context.socket(zmq.PAIR)
+ self.backup_controller.bind(inproc.format(ThreadNames.backup))
+
+ self.thumbnail_deamon_controller = context.socket(zmq.PAIR)
+ self.thumbnail_deamon_controller.bind(inproc.format(ThreadNames.thumbnail_daemon))
+
+ self.offload_controller = context.socket(zmq.PAIR)
+ self.offload_controller.bind(inproc.format(ThreadNames.offload))
+
+ self.new_version_controller = context.socket(zmq.PAIR)
+ self.new_version_controller.bind(inproc.format(ThreadNames.new_version))
+
+ def sendStopToThread(self, socket: zmq.Socket) -> None:
+ socket.send_multipart(create_inproc_msg(b'STOP'))
+
+ def sendTerminateToThread(self, socket: zmq.Socket) -> None:
+ socket.send_multipart(create_inproc_msg(b'TERMINATE'))
+
+ def sendStopWorkerToThread(self, socket: zmq.Socket, worker_id: int) -> None:
+ socket.send_multipart(create_inproc_msg(b'STOP_WORKER', worker_id=worker_id))
+
+ def sendStartToThread(self, socket: zmq.Socket) -> None:
+ socket.send_multipart(create_inproc_msg(b'START'))
+
+ def sendStartWorkerToThread(self, socket: zmq.Socket, worker_id: int, data: Any) -> None:
+ socket.send_multipart(create_inproc_msg(b'START_WORKER', worker_id=worker_id, data=data))
+
+ def sendResumeToThread(self, socket: zmq.Socket, worker_id: Optional[int]=None) -> None:
+ socket.send_multipart(create_inproc_msg(b'RESUME', worker_id=worker_id))
+
+ def sendPauseToThread(self, socket: zmq.Socket) -> None:
+ socket.send_multipart(create_inproc_msg(b'PAUSE'))
+
+ def sendDataMessageToThread(self, socket: zmq.Socket,
+ data: Any,
+ worker_id: Optional[int]=None) -> None:
+ socket.send_multipart(create_inproc_msg(b'SEND_TO_WORKER', worker_id=worker_id, data=data))
+
+ def sendToOffload(self, data: Any) -> None:
+ self.offload_controller.send_multipart(create_inproc_msg(b'SEND_TO_WORKER',
+ worker_id=None, data=data))
+
+ def startProcessLogger(self) -> None:
+ self.loggermq = ProcessLoggingManager()
+ self.loggermqThread = QThread()
+ self.loggermq.moveToThread(self.loggermqThread)
+
+ self.loggermqThread.started.connect(self.loggermq.startReceiver)
+ self.loggermq.ready.connect(self.initStage2)
+ logging.debug("Starting logging subscription manager...")
+ QTimer.singleShot(0, self.loggermqThread.start)
+
+ @pyqtSlot(int)
+ def initStage2(self, logging_port: int) -> None:
+ logging.debug("...logging subscription manager started")
+ self.logging_port = logging_port
+
+ self.splash.setProgress(20)
+
+ logging.debug("Stage 2 initialization")
+
+ if self.prefs.purge_thumbnails:
+ cache = ThumbnailCacheSql()
+ logging.info("Purging thumbnail cache...")
+ cache.purge_cache()
+ logging.info("...thumbnail Cache has been purged")
+ self.prefs.purge_thumbnails = False
+ elif self.prefs.optimize_thumbnail_db:
+ cache = ThumbnailCacheSql()
+ logging.info("Optimizing thumbnail cache...")
+ db, fs, size = cache.optimize()
+ logging.info("...thumbnail cache has been optimized.")
+
+ if db:
+ logging.info("Removed %s files from thumbnail database", db)
+ if fs:
+ logging.info("Removed %s thumbnails from file system", fs)
+ if size:
+ logging.info("Thumbnail database size reduction: %s", format_size_for_user(size))
+
+ self.prefs.optimize_thumbnail_db = False
+
+ # For meaning of 'Devices', see devices.py
+ self.devices = DeviceCollection(self.exiftool_process, self)
+
+ logging.debug("Starting thumbnail daemon model")
+
+ self.thumbnaildaemonmqThread = QThread()
+ self.thumbnaildaemonmq = ThumbnailDaemonManager(logging_port=logging_port)
+ self.thumbnaildaemonmq.moveToThread(self.thumbnaildaemonmqThread)
+ self.thumbnaildaemonmqThread.started.connect(self.thumbnaildaemonmq.run_sink)
+ self.thumbnaildaemonmq.message.connect(self.thumbnailReceivedFromDaemon)
+ self.thumbnaildaemonmq.sinkStarted.connect(self.initStage3)
+
+ QTimer.singleShot(0, self.thumbnaildaemonmqThread.start)
+
+ @pyqtSlot()
+ def initStage3(self) -> None:
+ logging.debug("Stage 3 initialization")
+
+ self.splash.setProgress(30)
+
+ self.sendStartToThread(self.thumbnail_deamon_controller)
+ logging.debug("...thumbnail daemon model started")
+
+ self.thumbnailView = ThumbnailView(self)
+ # logging.debug("Starting thumbnail model and load balancer...")
+ self.thumbnailModel = ThumbnailListModel(parent=self, logging_port=self.logging_port,
+ log_gphoto2=self.log_gphoto2)
+
+ self.thumbnailView.setModel(self.thumbnailModel)
+ self.thumbnailView.setItemDelegate(ThumbnailDelegate(rapidApp=self))
+
+ @pyqtSlot(int)
+ def initStage4(self, frontend_port: int) -> None:
+ logging.debug("Stage 4 initialization")
+
+ self.splash.setProgress(40)
+
+ # logging.debug("...thumbnail model and load balancer started")
+
+ self.sendDataMessageToThread(self.thumbnail_deamon_controller, worker_id=None,
+ data=ThumbnailDaemonData(frontend_port=frontend_port))
+
+ centralWidget = QWidget()
+ self.setCentralWidget(centralWidget)
+
+ self.temporalProximity = TemporalProximity(rapidApp=self, prefs=self.prefs)
+
+ # Respond to the user selecting / deslecting temporal proximity (timeline) cells:
+ self.temporalProximity.proximitySelectionHasChanged.connect(
+ self.updateThumbnailModelAfterProximityChange)
+ self.temporalProximity.temporalProximityView.proximitySelectionHasChanged.connect(
+ self.updateThumbnailModelAfterProximityChange)
+
+ self.file_manager = get_default_file_manager()
+ if self.file_manager:
+ logging.debug("Default file manager: %s", self.file_manager)
+ else:
+ logging.debug("Default file manager could not be determined")
+
+ # Setup notification system
+ try:
+ self.have_libnotify = Notify.init('rapid-photo-downloader')
+ self.ctime_update_notification = None # type: Optional[Notify.Notification]
+ self.ctime_notification_issued = False
+ except:
+ logging.error("Notification intialization problem")
+ self.have_libnotify = False
+
+ logging.debug("Locale directory: %s", raphodo.localedir)
+
+ # Initialise use of libgphoto2
+ logging.debug("Getting gphoto2 context")
+ try:
+ self.gp_context = gp.Context()
+ except:
+ logging.critical("Error getting gphoto2 context")
+ self.gp_context = None
+
+ logging.debug("Probing for valid mounts")
+ self.validMounts = ValidMounts(onlyExternalMounts=self.prefs.only_external_mounts)
+
+ logging.debug("Freedesktop.org thumbnails location: %s",
+ get_fdo_cache_thumb_base_directory())
+
+ logging.debug("Probing desktop environment")
+ desktop_env = get_desktop_environment()
+ if desktop_env is not None:
+ logging.debug("Desktop environment: %s", desktop_env)
+ else:
+ logging.debug("Desktop environment variable not set")
+
+ self.unity_progress = False
+ self.desktop_launchers = []
+ if get_desktop() == Desktop.unity:
+ if not have_unity:
+ logging.warning("Desktop environment is Unity, but could not load Unity 7.0 module")
+ else:
+ # Unity auto-generated desktop files use underscores, it seems
+ for launcher in ('rapid_photo_downloader.desktop',
+ 'rapid-photo-downloader.desktop'):
+ desktop_launcher = Unity.LauncherEntry.get_for_desktop_id(launcher)
+ if desktop_launcher is not None:
+ self.desktop_launchers.append(desktop_launcher)
+ self.unity_progress = True
+
+ if not self.desktop_launchers:
+ logging.warning("Desktop environment is Unity 7.0, but could not find "
+ "program's .desktop file")
+ else:
+ logging.debug("Unity progress indicator found")
+
+ logging.debug("Have GIO module: %s", have_gio)
+ self.gvfsControlsMounts = gvfs_controls_mounts() and have_gio
+ if have_gio:
+ logging.debug("Using GIO: %s", self.gvfsControlsMounts)
+
+ if not self.gvfsControlsMounts:
+ # Monitor when the user adds or removes a camera
+ self.cameraHotplug = CameraHotplug()
+ self.cameraHotplugThread = QThread()
+ self.cameraHotplugThread.started.connect(self.cameraHotplug.startMonitor)
+ self.cameraHotplug.moveToThread(self.cameraHotplugThread)
+ self.cameraHotplug.cameraAdded.connect(self.cameraAdded)
+ self.cameraHotplug.cameraRemoved.connect(self.cameraRemoved)
+ # Start the monitor only on the thread it will be running on
+ logging.debug("Starting camera hotplug monitor...")
+ QTimer.singleShot(0, self.cameraHotplugThread.start)
+
+ # Monitor when the user adds or removes a partition
+ self.udisks2Monitor = UDisks2Monitor(self.validMounts)
+ self.udisks2MonitorThread = QThread()
+ self.udisks2MonitorThread.started.connect(self.udisks2Monitor.startMonitor)
+ self.udisks2Unmount.connect(self.udisks2Monitor.unmount_volume)
+ self.udisks2Monitor.moveToThread(self.udisks2MonitorThread)
+ self.udisks2Monitor.partitionMounted.connect(self.partitionMounted)
+ self.udisks2Monitor.partitionUnmounted.connect(self.partitionUmounted)
+ # Start the monitor only on the thread it will be running on
+ logging.debug("Starting UDisks2 monitor...")
+ QTimer.singleShot(0, self.udisks2MonitorThread.start)
+
+ if self.gvfsControlsMounts:
+ # Gio.VolumeMonitor must be in the main thread, according to
+ # Gnome documentation
+
+ logging.debug("Starting GVolumeMonitor...")
+ self.gvolumeMonitor = GVolumeMonitor(self.validMounts)
+ logging.debug("...GVolumeMonitor started")
+ self.gvolumeMonitor.cameraUnmounted.connect(self.cameraUnmounted)
+ self.gvolumeMonitor.cameraMounted.connect(self.cameraMounted)
+ self.gvolumeMonitor.partitionMounted.connect(self.partitionMounted)
+ self.gvolumeMonitor.partitionUnmounted.connect(self.partitionUmounted)
+ self.gvolumeMonitor.volumeAddedNoAutomount.connect(self.noGVFSAutoMount)
+ self.gvolumeMonitor.cameraPossiblyRemoved.connect(self.cameraRemoved)
+
+ self.createPathViews()
+
+ self.createActions()
+ logging.debug("Laying out main window")
+ self.createMenus()
+ self.createLayoutAndButtons(centralWidget)
+
+ logging.debug("Starting version check")
+ self.newVersion = NewVersion(self)
+ self.newVersionThread = QThread()
+ self.newVersionThread.started.connect(self.newVersion.start)
+ self.newVersion.checkMade.connect(self.newVersionCheckMade)
+ self.newVersion.bytesDownloaded.connect(self.newVersionBytesDownloaded)
+ self.newVersion.fileDownloaded.connect(self.newVersionDownloaded)
+ self.reverifyDownloadedTar.connect(self.newVersion.reVerifyDownload)
+ self.newVersion.downloadSize.connect(self.newVersionDownloadSize)
+ self.newVersion.reverified.connect(self.installNewVersion)
+ self.newVersion.moveToThread(self.newVersionThread)
+
+ QTimer.singleShot(0, self.newVersionThread.start)
+
+ self.newVersionCheckDialog = NewVersionCheckDialog(self)
+ self.newVersionCheckDialog.finished.connect(self.newVersionCheckDialogFinished)
+
+ # if values set, indicates the latest version of the program, and the main
+ # download page on the Rapid Photo Downloader website
+ self.latest_version = None # type: version_details
+ self.latest_version_download_page = None # type: str
+
+ # Track the creation of temporary directories
+ self.temp_dirs_by_scan_id = {}
+
+ # Track the time a download commences - used in file renaming
+ self.download_start_datetime = None # type: Optional[datetime.datetime]
+ # The timestamp for when a download started / resumed after a pause
+ self.download_start_time = None # type: Optional[float]
+
+ logging.debug("Starting download tracker")
+ self.download_tracker = downloadtracker.DownloadTracker()
+
+ # Values used to display how much longer a download will take
+ self.time_remaining = downloadtracker.TimeRemaining()
+ self.time_check = downloadtracker.TimeCheck()
+
+ logging.debug("Setting up download update timer")
+ self.dl_update_timer = QTimer(self)
+ self.dl_update_timer.setInterval(constants.DownloadUpdateMilliseconds)
+ self.dl_update_timer.timeout.connect(self.displayDownloadRunningInStatusBar)
+
+ # Offload process is used to offload work that could otherwise
+ # cause this process and thus the GUI to become unresponsive
+ logging.debug("Starting offload manager...")
+
+ self.offloadThread = QThread()
+ self.offloadmq = OffloadManager(logging_port=self.logging_port)
+ self.offloadThread.started.connect(self.offloadmq.run_sink)
+ self.offloadmq.sinkStarted.connect(self.initStage5)
+ self.offloadmq.message.connect(self.proximityGroupsGenerated)
+ self.offloadmq.moveToThread(self.offloadThread)
+
+ QTimer.singleShot(0, self.offloadThread.start)
+
+
+ @pyqtSlot()
+ def initStage5(self) -> None:
+ logging.debug("...offload manager started")
+ self.sendStartToThread(self.offload_controller)
+
+ self.splash.setProgress(50)
+
+ self.folder_preview_manager = FolderPreviewManager(fsmodel=self.fileSystemModel,
+ prefs=self.prefs,
+ photoDestinationFSView=self.photoDestinationFSView,
+ videoDestinationFSView=self.videoDestinationFSView,
+ devices=self.devices,
+ rapidApp=self)
+
+ self.offloadmq.downloadFolders.connect(self.folder_preview_manager.folders_generated)
+
+
+ self.renameThread = QThread()
+ self.renamemq = RenameMoveFileManager(logging_port=self.logging_port)
+ self.renameThread.started.connect(self.renamemq.run_sink)
+ self.renamemq.sinkStarted.connect(self.initStage6)
+ self.renamemq.message.connect(self.fileRenamedAndMoved)
+ self.renamemq.sequencesUpdate.connect(self.updateSequences)
+ self.renamemq.renameProblems.connect(self.addErrorLogMessage)
+ # self.renamemq.workerFinished.connect(self.fileRenamedAndMovedFinished)
+ self.renamemq.moveToThread(self.renameThread)
+
+ logging.debug("Starting rename manager...")
+ QTimer.singleShot(0, self.renameThread.start)
+
+ @pyqtSlot()
+ def initStage6(self) -> None:
+ logging.debug("...rename manager started")
+
+ self.splash.setProgress(60)
+
+ self.sendStartToThread(self.rename_controller)
+
+ # Setup the scan processes
+ self.scanThread = QThread()
+ self.scanmq = ScanManager(logging_port=self.logging_port)
+
+ self.scanThread.started.connect(self.scanmq.run_sink)
+ self.scanmq.sinkStarted.connect(self.initStage7)
+ self.scanmq.scannedFiles.connect(self.scanFilesReceived)
+ self.scanmq.deviceError.connect(self.scanErrorReceived)
+ self.scanmq.deviceDetails.connect(self.scanDeviceDetailsReceived)
+ self.scanmq.scanProblems.connect(self.scanProblemsReceived)
+ self.scanmq.workerFinished.connect(self.scanFinished)
+ self.scanmq.fatalError.connect(self.scanFatalError)
+
+ self.scanmq.moveToThread(self.scanThread)
+
+ logging.debug("Starting scan manager...")
+ QTimer.singleShot(0, self.scanThread.start)
+
+ @pyqtSlot()
+ def initStage7(self) -> None:
+ logging.debug("...scan manager started")
+
+ self.splash.setProgress(70)
+
+
+ # Setup the copyfiles process
+ self.copyfilesThread = QThread()
+ self.copyfilesmq = CopyFilesManager(logging_port=self.logging_port)
+
+ self.copyfilesThread.started.connect(self.copyfilesmq.run_sink)
+ self.copyfilesmq.sinkStarted.connect(self.initStage8)
+ self.copyfilesmq.message.connect(self.copyfilesDownloaded)
+ self.copyfilesmq.bytesDownloaded.connect(self.copyfilesBytesDownloaded)
+ self.copyfilesmq.tempDirs.connect(self.tempDirsReceivedFromCopyFiles)
+ self.copyfilesmq.copyProblems.connect(self.copyfilesProblems)
+ self.copyfilesmq.workerFinished.connect(self.copyfilesFinished)
+
+ self.copyfilesmq.moveToThread(self.copyfilesThread)
+
+ logging.debug("Starting copy files manager...")
+ QTimer.singleShot(0, self.copyfilesThread.start)
+
+ @pyqtSlot()
+ def initStage8(self) -> None:
+ logging.debug("...copy files manager started")
+
+ self.splash.setProgress(80)
+
+ self.backup_devices = BackupDeviceCollection(rapidApp=self)
+
+ self.backupThread = QThread()
+ self.backupmq = BackupManager(logging_port=self.logging_port)
+
+ self.backupThread.started.connect(self.backupmq.run_sink)
+ self.backupmq.sinkStarted.connect(self.initStage9)
+ self.backupmq.message.connect(self.fileBackedUp)
+ self.backupmq.bytesBackedUp.connect(self.backupFileBytesBackedUp)
+ self.backupmq.backupProblems.connect(self.backupFileProblems)
+
+ self.backupmq.moveToThread(self.backupThread)
+
+ logging.debug("Starting backup manager ...")
+ QTimer.singleShot(0, self.backupThread.start)
+
+ @pyqtSlot()
+ def initStage9(self) -> None:
+ logging.debug("...backup manager started")
+
+ self.splash.setProgress(90)
+
+ if self.prefs.backup_files:
+ self.setupBackupDevices()
+ else:
+ self.download_tracker.set_no_backup_devices(0, 0)
+
+ settings = QSettings()
+ settings.beginGroup("MainWindow")
+
+ self.proximityButton.setChecked(settings.value("proximityButtonPressed", False, bool))
+ self.proximityButtonClicked()
+
+ self.sourceButton.setChecked(settings.value("sourceButtonPressed", True, bool))
+ self.sourceButtonClicked()
+
+ # Default to displaying the destination panels if the value has never been
+ # set
+ index = settings.value("rightButtonPressed", 0, int)
+ if index >= 0:
+ try:
+ button = self.rightSideButtonMapper[index]
+ except ValueError:
+ logging.error("Unexpected preference value for right side button")
+ index = RightSideButton.destination
+ button = self.rightSideButtonMapper[index]
+ button.setChecked(True)
+ self.setRightPanelsAndButtons(RightSideButton(index))
+ settings.endGroup()
+
+ prefs_valid, msg = self.prefs.check_prefs_for_validity()
+
+ self.setupErrorLogWindow(settings=settings)
+
+
+ self.setDownloadCapabilities()
+ self.searchForCameras(on_startup=True)
+ self.setupNonCameraDevices(on_startup=True)
+ self.splash.setProgress(100)
+ self.setupManualPath(on_startup=True)
+ self.updateSourceButton()
+ self.displayMessageInStatusBar()
+
+ self.showMainWindow()
+
+ if EXIFTOOL_VERSION is None and self.prefs.warn_broken_or_missing_libraries:
+ message = _(
+ '<b>ExifTool has a problem</b><br><br> '
+ 'Rapid Photo Downloader uses ExifTool to get metadata from videos and photos. '
+ 'The program will run without it, but installing it is <b>highly</b> recommended.'
+ )
+ warning = RememberThisDialog(
+ message=message,
+ icon=':/rapid-photo-downloader.svg',
+ remember=RememberThisMessage.do_not_warn_again_about_missing_libraries,
+ parent=self,
+ buttons=RememberThisButtons.ok,
+ title=_('Problem with libmediainfo')
+ )
+
+ warning.exec_()
+ if warning.remember:
+ self.prefs.warn_broken_or_missing_libraries = False
+
+ if libmediainfo_missing and self.prefs.warn_broken_or_missing_libraries:
+ message = _(
+ '<b>The library libmediainfo appears to be missing</b><br><br> '
+ 'Rapid Photo Downloader uses libmediainfo to get the date and time a video was '
+ 'shot. The program will run without it, but installing it is recommended.'
+ )
+
+ warning = RememberThisDialog(
+ message=message,
+ icon=':/rapid-photo-downloader.svg',
+ remember=RememberThisMessage.do_not_warn_again_about_missing_libraries,
+ parent=self,
+ buttons=RememberThisButtons.ok,
+ title=_('Problem with libmediainfo')
+ )
+
+ warning.exec_()
+ if warning.remember:
+ self.prefs.warn_broken_or_missing_libraries = False
+
+ self.tip = didyouknow.DidYouKnowDialog(self.prefs, self)
+ if self.prefs.did_you_know_on_startup:
+ self.tip.activate()
+
+ if not prefs_valid:
+ self.notifyPrefsAreInvalid(details=msg)
+ else:
+ self.checkForNewVersionRequest.emit()
+
+ logging.debug("Completed stage 9 initializing main window")
+
+ def showMainWindow(self) -> None:
+ if not self.isVisible():
+ self.splash.finish(self)
+
+ self.window_show_requested_time = datetime.datetime.now()
+ self.show()
+
+ self.errorLog.setVisible(self.errorLogAct.isChecked())
+
+ def mapModel(self, scan_id: int) -> DeviceModel:
+ """
+ Map a scan_id onto Devices' or This Computer's device model.
+ :param scan_id: scan id of the device
+ :return: relevant device model
+ """
+
+ return self._mapModel[self.devices[scan_id].device_type]
+
+ def mapView(self, scan_id: int) -> DeviceView:
+ """
+ Map a scan_id onto Devices' or This Computer's device view.
+ :param scan_id: scan id of the device
+ :return: relevant device view
+ """
+
+ return self._mapView[self.devices[scan_id].device_type]
+
+ def setupErrorLogWindow(self, settings: QSettings) -> None:
+ """
+ Creates, moves and resizes error log window, but does not show it.
+ """
+
+ default_x = self.pos().x()
+ default_y = self.pos().y()
+ default_width = int(self.size().width() * 0.5)
+ default_height = int(self.size().height() * 0.5)
+
+ settings.beginGroup("ErrorLog")
+ pos = settings.value("windowPosition", QPoint(default_x, default_y))
+ size = settings.value("windowSize", QSize(default_width, default_height))
+ visible = settings.value('visible', False, type=bool)
+ settings.endGroup()
+
+ self.errorLog = ErrorReport(rapidApp=self)
+ self.errorLogAct.setChecked(visible)
+ self.errorLog.move(pos)
+ self.errorLog.resize(size)
+ self.errorLog.finished.connect(self.setErrorLogAct)
+ self.errorLog.dialogShown.connect(self.setErrorLogAct)
+ self.errorLog.dialogActivated.connect(self.errorsPending.reset)
+ self.errorsPending.clicked.connect(self.errorLog.activate)
+
+ def readWindowSettings(self, app: 'QtSingleApplication'):
+ settings = QSettings()
+ settings.beginGroup("MainWindow")
+ desktop = app.desktop() # type: QDesktopWidget
+
+ # Calculate window sizes
+ available = desktop.availableGeometry(desktop.primaryScreen()) # type: QRect
+ screen = desktop.screenGeometry(desktop.primaryScreen()) # type: QRect
+ default_width = max(960, available.width() // 2)
+ default_width = min(default_width, available.width())
+ default_x = screen.width() - default_width
+ default_height = available.height()
+ default_y = screen.height() - default_height
+ pos = settings.value("windowPosition", QPoint(default_x, default_y))
+ size = settings.value("windowSize", QSize(default_width, default_height))
+ settings.endGroup()
+ self.resize(size)
+ self.move(pos)
+
+ def writeWindowSettings(self):
+ logging.debug("Writing window settings")
+ settings = QSettings()
+ settings.beginGroup("MainWindow")
+ windowPos = self.pos() + self.windowPositionDelta
+ if windowPos.x() < 0:
+ windowPos.setX(0)
+ if windowPos.y() < 0:
+ windowPos.setY(0)
+ settings.setValue("windowPosition", windowPos)
+ settings.setValue("windowSize", self.size())
+ settings.setValue("centerSplitterSizes", self.centerSplitter.saveState())
+ settings.setValue("sourceButtonPressed", self.sourceButton.isChecked())
+ settings.setValue("rightButtonPressed", self.rightSideButtonPressed())
+ settings.setValue("proximityButtonPressed", self.proximityButton.isChecked())
+ settings.setValue("leftPanelSplitterSizes", self.leftPanelSplitter.saveState())
+ settings.setValue("rightPanelSplitterSizes", self.rightPanelSplitter.saveState())
+ settings.endGroup()
+
+ settings.beginGroup("ErrorLog")
+ settings.setValue("windowPosition", self.errorLog.pos())
+ settings.setValue("windowSize", self.errorLog.size())
+ settings.setValue('visible', self.errorLog.isVisible())
+ settings.endGroup()
+
+ def moveEvent(self, event: QMoveEvent) -> None:
+ """
+ Handle quirks in window positioning.
+
+ X11 has a feature where the window managager can decorate the
+ windows. A side effect of this is that the position returned by
+ window.pos() can be different between restoring the position
+ from the settings, and saving the position at application exit, even if
+ the user never moved the window.
+ """
+
+ super().moveEvent(event)
+ self.window_move_triggered_count += 1
+
+ if self.window_show_requested_time is None:
+ pass
+ # self.windowPositionDelta = QPoint(0, 0)
+ elif self.window_move_triggered_count == 2:
+ if (datetime.datetime.now() - self.window_show_requested_time).total_seconds() < 1.0:
+ self.windowPositionDelta = event.oldPos() - self.pos()
+ logging.debug("Window position quirk delta: %s", self.windowPositionDelta)
+ self.window_show_requested_time = None
+
+ def setupWindow(self):
+ status = self.statusBar()
+ status.setStyleSheet("QStatusBar::item { border: 0px solid black }; ")
+ self.downloadProgressBar = QProgressBar()
+ self.downloadProgressBar.setMaximumWidth(QFontMetrics(QFont()).height() * 9)
+ self.errorsPending = SpeechBubble(self)
+ self.errorsPending.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
+ status.addPermanentWidget(self.errorsPending)
+ status.addPermanentWidget(self.downloadProgressBar, 1)
+
+ def anyFilesSelected(self) -> bool:
+ """
+ :return: True if any files are selected
+ """
+
+ return self.thumbnailView.selectionModel().hasSelection()
+
+ def applyJobCode(self, job_code: str) -> None:
+ """
+ Apply job code to all selected photos/videos.
+
+ :param job_code: job code to apply
+ """
+
+ delegate = self.thumbnailView.itemDelegate() # type: ThumbnailDelegate
+ delegate.applyJobCode(job_code=job_code)
+
+ @pyqtSlot(bool, version_details, version_details, str, bool, bool)
+ def newVersionCheckMade(self, success: bool,
+ stable_version: version_details,
+ dev_version: version_details,
+ download_page: str,
+ no_upgrade: bool,
+ pip_install: bool) -> None:
+ """
+ Respond to a version check, either initiated at program startup, or from the
+ application's main menu.
+
+ If the check was initiated at program startup, then the new version dialog box
+ will not be showing.
+
+ :param success: whether the version check was successful or not
+ :param stable_version: latest stable version
+ :param dev_version: latest development version
+ :param download_page: url of the download page on the Rapid
+ Photo Downloader website
+ :param no_upgrade: if True, don't offer to do an inplace upgrade
+ :param pip_install: whether pip was used to install this
+ program version
+ """
+
+ if success:
+ self.latest_version = None
+ current_version = pkg_resources.parse_version(__about__.__version__)
+
+ check_dev_version = (current_version.is_prerelease or
+ self.prefs.include_development_release)
+
+ if current_version < stable_version.version:
+ self.latest_version = stable_version
+
+ if check_dev_version and current_version < dev_version.version:
+ if dev_version.version > stable_version.version:
+ self.latest_version = dev_version
+ else:
+ self.latest_version = stable_version
+
+ # remove in development testing code if in production!
+ if (
+ self.latest_version is not None and str(self.latest_version.version) not in
+ self.prefs.ignore_versions
+ ): # or True:
+
+ self.latest_version = dev_version
+
+ version = str(self.latest_version.version)
+ changelog_url = self.latest_version.changelog_url
+
+ if pip_install:
+ logging.debug("Installation performed via pip")
+ if no_upgrade:
+ logging.info("Cannot perform in-place upgrade to this version")
+ state = CheckNewVersionDialogState.open_website
+ else:
+ download_page = None
+ state = CheckNewVersionDialogState.prompt_for_download
+ else:
+ logging.debug("Installation not performed via pip")
+ state = CheckNewVersionDialogState.open_website
+
+ self.latest_version_download_page = download_page
+
+ self.newVersionCheckDialog.displayUserMessage(
+ new_state=state,
+ version=version,
+ download_page=download_page,
+ changelog_url=changelog_url
+ )
+ if not self.newVersionCheckDialog.isVisible():
+ self.newVersionCheckDialog.show()
+
+ elif self.newVersionCheckDialog.isVisible():
+ self.newVersionCheckDialog.displayUserMessage(
+ CheckNewVersionDialogState.have_latest_version)
+
+ elif self.newVersionCheckDialog.isVisible():
+ # Failed to reach update server
+ self.newVersionCheckDialog.displayUserMessage(
+ CheckNewVersionDialogState.failed_to_contact)
+
+ @pyqtSlot(int)
+ def newVersionCheckDialogFinished(self, result: int) -> None:
+ current_state = self.newVersionCheckDialog.current_state
+ if current_state in (
+ CheckNewVersionDialogState.prompt_for_download,
+ CheckNewVersionDialogState.open_website):
+ if self.newVersionCheckDialog.dialog_detailed_result == \
+ CheckNewVersionDialogResult.skip:
+ version = str(self.latest_version.version)
+ logging.info("Adding version %s to the list of program versions to ignore",
+ version)
+ self.prefs.add_list_value(key='ignore_versions', value=version)
+ elif self.newVersionCheckDialog.dialog_detailed_result == \
+ CheckNewVersionDialogResult.open_website:
+ webbrowser.open_new_tab(self.latest_version_download_page)
+ elif self.newVersionCheckDialog.dialog_detailed_result == \
+ CheckNewVersionDialogResult.download:
+ url = self.latest_version.url
+ md5 = self.latest_version.md5
+ self.downloadNewVersionRequest.emit(url, md5)
+ self.downloadNewVersionDialog = DownloadNewVersionDialog(parent=self)
+ self.downloadNewVersionDialog.rejected.connect(self.newVersionDownloadCancelled)
+ self.downloadNewVersionDialog.show()
+
+ @pyqtSlot('PyQt_PyObject')
+ def newVersionBytesDownloaded(self, bytes_downloaded: int) -> None:
+ if self.downloadNewVersionDialog.isVisible():
+ self.downloadNewVersionDialog.updateProgress(bytes_downloaded)
+
+ @pyqtSlot('PyQt_PyObject')
+ def newVersionDownloadSize(self, download_size: int) -> None:
+ if self.downloadNewVersionDialog.isVisible():
+ self.downloadNewVersionDialog.setDownloadSize(download_size)
+
+ @pyqtSlot(str, bool)
+ def newVersionDownloaded(self, path: str, download_cancelled: bool) -> None:
+ self.downloadNewVersionDialog.accept()
+ if not path and not download_cancelled:
+ msgBox = QMessageBox(parent=self)
+ msgBox.setIcon(QMessageBox.Warning)
+ msgBox.setWindowTitle(_("Download failed"))
+ msgBox.setText(
+ _('Sorry, the download of the new version of Rapid Photo Downloader failed.')
+ )
+ msgBox.exec_()
+ elif path:
+ logging.info("New program version downloaded to %s", path)
+
+ message = _('The new version was successfully downloaded. Do you want to '
+ 'close Rapid Photo Downloader and install it now?')
+ msgBox = QMessageBox(parent=self)
+ msgBox.setWindowTitle(_('Update Rapid Photo Downloader'))
+ msgBox.setText(message)
+ msgBox.setIcon(QMessageBox.Question)
+ msgBox.setStandardButtons(QMessageBox.Cancel)
+ installButton = msgBox.addButton(_('Install'), QMessageBox.AcceptRole)
+ msgBox.setDefaultButton(installButton)
+ if msgBox.exec_() == QMessageBox.AcceptRole:
+ self.reverifyDownloadedTar.emit(path)
+ else:
+ # extract the install.py script and move it to the correct location
+ # for testing:
+ # path = '/home/damon/rapid090a7/dist/rapid-photo-downloader-0.9.0a7.tar.gz'
+ extract_file_from_tar(full_tar_path=path, member_filename='install.py')
+ installer_dir = os.path.dirname(path)
+ if self.file_manager:
+ uri = pathname2url(path)
+ cmd = '{} {}'.format(self.file_manager, uri)
+ logging.debug("Launching: %s", cmd)
+ args = shlex.split(cmd)
+ subprocess.Popen(args)
+ else:
+ msgBox = QMessageBox(parent=self)
+ msgBox.setWindowTitle(_('New version saved'))
+ message = _('The tar file and installer script are saved '
+ 'at:\n\n %s') % installer_dir
+ msgBox.setText(message)
+ msgBox.setIcon(QMessageBox.Information)
+ msgBox.exec_()
+
+ @pyqtSlot(bool, str)
+ def installNewVersion(self, reverified: bool, full_tar_path: str) -> None:
+ """
+ Launch script to install new version of Rapid Photo Downloader
+ via upgrade.py.
+ :param reverified: whether file has been reverified or not
+ :param full_tar_path: path to the tarball
+ """
+ if not reverified:
+ msgBox = QMessageBox(parent=self)
+ msgBox.setIcon(QMessageBox.Warning)
+ msgBox.setWindowTitle(_("Upgrade failed"))
+ msgBox.setText(_('Sorry, upgrading Rapid Photo Downloader failed because there was '
+ 'an error opening the installer.'))
+ msgBox.exec_()
+ else:
+ # for testing:
+ # full_tar_path = '/home/damon/rapid090a7/dist/rapid-photo-downloader-0.9.0a7.tar.gz'
+ upgrade_py = 'upgrade.py'
+ installer_dir = os.path.dirname(full_tar_path)
+ if extract_file_from_tar(full_tar_path, upgrade_py):
+ upgrade_script = os.path.join(installer_dir, upgrade_py)
+ cmd = shlex.split('{} {} {}'.format(sys.executable, upgrade_script, full_tar_path))
+ subprocess.Popen(cmd)
+ self.quit()
+
+ @pyqtSlot()
+ def newVersionDownloadCancelled(self) -> None:
+ logging.info("Download of new program version cancelled")
+ self.new_version_controller.send(b'STOP')
+
+ def updateProgressBarState(self, thumbnail_generated: bool=None) -> None:
+ """
+ Updates the state of the ProgessBar in the main window's lower right corner.
+
+ If any device is downloading, the progress bar displays
+ download progress.
+
+ Else, if any device is thumbnailing, the progress bar
+ displays thumbnailing progress.
+
+ Else, if any device is scanning, the progress bar shows a busy status.
+
+ Else, the progress bar is set to an idle status.
+ """
+
+ if self.downloadIsRunning():
+ logging.debug("Setting progress bar to show download progress")
+ self.downloadProgressBar.setMaximum(100)
+ return
+
+ if self.unity_progress:
+ for launcher in self.desktop_launchers:
+ launcher.set_property('progress_visible', False)
+
+ if len(self.devices.thumbnailing):
+ if self.downloadProgressBar.maximum() != self.thumbnailModel.total_thumbs_to_generate:
+ logging.debug("Setting progress bar maximum to %s",
+ self.thumbnailModel.total_thumbs_to_generate)
+ self.downloadProgressBar.setMaximum(self.thumbnailModel.total_thumbs_to_generate)
+ if thumbnail_generated:
+ self.downloadProgressBar.setValue(self.thumbnailModel.thumbnails_generated)
+ elif len(self.devices.scanning):
+ logging.debug("Setting progress bar to show scanning activity")
+ self.downloadProgressBar.setMaximum(0)
+ else:
+ logging.debug("Resetting progress bar")
+ self.downloadProgressBar.reset()
+ self.downloadProgressBar.setMaximum(100)
+
+ def updateSourceButton(self) -> None:
+ text, icon = self.devices.get_main_window_display_name_and_icon()
+ self.sourceButton.setText(addPushButtonLabelSpacer(text))
+ self.sourceButton.setIcon(icon)
+
+ def setLeftPanelVisibility(self) -> None:
+ self.leftPanelSplitter.setVisible(self.sourceButton.isChecked() or
+ self.proximityButton.isChecked())
+
+ def setRightPanelsAndButtons(self, buttonPressed: RightSideButton) -> None:
+ """
+ Set visibility of right panel based on which right bar buttons
+ is pressed, and ensure only one button is pressed at any one time.
+
+ Cannot use exclusive QButtonGroup because with that, one button needs to be
+ pressed. We allow no button to be pressed.
+ """
+
+ widget = self.rightSideButtonMapper[buttonPressed] # type: RotatedButton
+
+ if widget.isChecked():
+ self.rightPanels.setVisible(True)
+ for button in RightSideButton:
+ if button == buttonPressed:
+ self.rightPanels.setCurrentIndex(buttonPressed.value)
+ else:
+ self.rightSideButtonMapper[button].setChecked(False)
+ else:
+ self.rightPanels.setVisible(False)
+
+ def rightSideButtonPressed(self) -> int:
+ """
+ Determine which right side button is currently pressed, if any.
+ :return: -1 if no button is pressed, else the index into
+ RightSideButton
+ """
+
+ for button in RightSideButton:
+ widget = self.rightSideButtonMapper[button]
+ if widget.isChecked():
+ return int(button.value)
+ return -1
+
+ @pyqtSlot()
+ def sourceButtonClicked(self) -> None:
+ self.deviceToggleView.setVisible(self.sourceButton.isChecked())
+ self.thisComputerToggleView.setVisible(self.sourceButton.isChecked())
+ self.setLeftPanelVisibility()
+
+ @pyqtSlot()
+ def destinationButtonClicked(self) -> None:
+ self.setRightPanelsAndButtons(RightSideButton.destination)
+
+ @pyqtSlot()
+ def renameButtonClicked(self) -> None:
+ self.setRightPanelsAndButtons(RightSideButton.rename)
+
+ @pyqtSlot()
+ def backupButtonClicked(self) -> None:
+ self.setRightPanelsAndButtons(RightSideButton.backup)
+
+ @pyqtSlot()
+ def jobcodButtonClicked(self) -> None:
+ self.jobCodePanel.updateDefaultMessage()
+ self.setRightPanelsAndButtons(RightSideButton.jobcode)
+
+ @pyqtSlot()
+ def proximityButtonClicked(self) -> None:
+ self.temporalProximity.setVisible(self.proximityButton.isChecked())
+ self.setLeftPanelVisibility()
+ self.adjustLeftPanelSliderHandles()
+
+ def adjustLeftPanelSliderHandles(self):
+ """
+ Move left panel splitter handles in response to devices / this computer
+ changes.
+ """
+
+ preferred_devices_height = self.deviceToggleView.minimumHeight()
+ min_this_computer_height = self.thisComputerToggleView.minimumHeight()
+
+ if self.thisComputerToggleView.on():
+ this_computer_height = max(min_this_computer_height, self.centerSplitter.height() -
+ preferred_devices_height)
+ else:
+ this_computer_height = min_this_computer_height
+
+ if self.proximityButton.isChecked():
+ if not self.thisComputerToggleView.on():
+ proximity_height = (self.centerSplitter.height() - this_computer_height -
+ preferred_devices_height)
+ else:
+ proximity_height = this_computer_height // 2
+ this_computer_height = this_computer_height // 2
+ else:
+ proximity_height = 0
+ self.leftPanelSplitter.setSizes([preferred_devices_height, this_computer_height,
+ proximity_height])
+
+ @pyqtSlot(int)
+ def showComboChanged(self, index: int) -> None:
+ self.sortComboChanged(index=-1)
+ self.thumbnailModel.updateAllDeviceDisplayCheckMarks()
+
+ def showOnlyNewFiles(self) -> bool:
+ """
+ User can use combo switch to show only so-called "hew" files, i.e. files that
+ have not been previously downloaded.
+
+ :return: True if only new files are shown
+ """
+ return self.showCombo.currentData() == Show.new_only
+
+ @pyqtSlot(int)
+ def sortComboChanged(self, index: int) -> None:
+ sort = self.sortCombo.currentData()
+ order = self.sortOrder.currentData()
+ show = self.showCombo.currentData()
+ self.thumbnailModel.setFileSort(sort=sort, order=order, show=show)
+
+ @pyqtSlot(int)
+ def sortOrderChanged(self, index: int) -> None:
+ self.sortComboChanged(index=-1)
+
+ @pyqtSlot(int)
+ def selectAllPhotosCheckboxChanged(self, state: int) -> None:
+ select_all = state == Qt.Checked
+ self.thumbnailModel.selectAll(select_all=select_all, file_type=FileType.photo)
+
+ @pyqtSlot(int)
+ def selectAllVideosCheckboxChanged(self, state: int) -> None:
+ select_all = state == Qt.Checked
+ self.thumbnailModel.selectAll(select_all=select_all, file_type=FileType.video)
+
+ @pyqtSlot()
+ def setErrorLogAct(self) -> None:
+ self.errorLogAct.setChecked(self.errorLog.isVisible())
+
+ def createActions(self) -> None:
+ self.downloadAct = QAction(
+ _("Download"), self, shortcut="Ctrl+Return", triggered=self.doDownloadAction
+ )
+
+ self.refreshAct = QAction(
+ _("&Refresh..."), self, shortcut="Ctrl+R", triggered=self.doRefreshAction
+ )
+
+ self.preferencesAct = QAction(
+ _("&Preferences"), self, shortcut="Ctrl+P", triggered=self.doPreferencesAction
+ )
+
+ self.quitAct = QAction(
+ _("&Quit"), self, shortcut="Ctrl+Q", triggered=self.close
+ )
+
+ self.errorLogAct = QAction(
+ _("Error &Reports"), self, enabled=True, checkable=True, triggered=self.doErrorLogAction
+ )
+
+ self.clearDownloadsAct = QAction(
+ _("Clear Completed Downloads"), self, triggered=self.doClearDownloadsAction
+ )
+
+ self.helpAct = QAction(
+ _("Get Help Online..."), self, shortcut="F1", triggered=self.doHelpAction
+ )
+
+ self.didYouKnowAct = QAction(
+ _("&Tip of the Day..."), self, triggered=self.doDidYouKnowAction
+ )
+
+ self.reportProblemAct = QAction(
+ _("Report a Problem..."), self, triggered=self.doReportProblemAction
+ )
+
+ self.makeDonationAct = QAction(
+ _("Make a Donation..."), self, triggered=self.doMakeDonationAction
+ )
+
+ self.translateApplicationAct = QAction(
+ _("Translate this Application..."), self, triggered=self.doTranslateApplicationAction
+ )
+
+ self.aboutAct = QAction(
+ _("&About..."), self, triggered=self.doAboutAction
+ )
+
+ self.newVersionAct = QAction(
+ _("Check for Updates..."), self, triggered=self.doCheckForNewVersion
+ )
+
+ def createLayoutAndButtons(self, centralWidget) -> None:
+ """
+ Create widgets used to display the GUI.
+ :param centralWidget: the widget in which to layout the new widgets
+ """
+
+ settings = QSettings()
+ settings.beginGroup("MainWindow")
+
+ verticalLayout = QVBoxLayout()
+ verticalLayout.setContentsMargins(0, 0, 0, 0)
+ centralWidget.setLayout(verticalLayout)
+ self.standard_spacing = verticalLayout.spacing()
+
+ topBar = self.createTopBar()
+ verticalLayout.addLayout(topBar)
+
+ centralLayout = QHBoxLayout()
+ centralLayout.setContentsMargins(0, 0, 0, 0)
+
+ self.leftBar = self.createLeftBar()
+ self.rightBar = self.createRightBar()
+
+ self.createCenterPanels()
+ self.createDeviceThisComputerViews()
+ self.createDestinationViews()
+ self.createRenamePanels()
+ self.createJobCodePanel()
+ self.createBackupPanel()
+ self.configureCenterPanels(settings)
+ self.createBottomControls()
+
+ centralLayout.addLayout(self.leftBar)
+ centralLayout.addWidget(self.centerSplitter)
+ centralLayout.addLayout(self.rightBar)
+
+ verticalLayout.addLayout(centralLayout)
+ verticalLayout.addWidget(self.thumbnailControl)
+
+ def createTopBar(self) -> QHBoxLayout:
+ topBar = QHBoxLayout()
+ menu_margin = int(QFontMetrics(QFont()).height() / 3)
+ topBar.setContentsMargins(0, 0, menu_margin, 0)
+
+ topBar.setSpacing(int(QFontMetrics(QFont()).height() / 2))
+
+ self.sourceButton = TopPushButton(addPushButtonLabelSpacer(_('Select Source')),
+ extra_top=self.standard_spacing)
+ self.sourceButton.clicked.connect(self.sourceButtonClicked)
+
+ vlayout = QVBoxLayout()
+ vlayout.setContentsMargins(0, 0, 0, 0)
+ vlayout.setSpacing(0)
+ vlayout.addSpacing(self.standard_spacing)
+ hlayout = QHBoxLayout()
+ hlayout.setContentsMargins(0, 0, 0, 0)
+ hlayout.setSpacing(menu_margin)
+ vlayout.addLayout(hlayout)
+
+ self.downloadButton = DownloadButton(self.downloadAct.text())
+ self.downloadButton.addAction(self.downloadAct)
+ self.downloadButton.setDefault(True)
+ self.downloadButton.clicked.connect(self.downloadButtonClicked)
+ # self.download_action_is_download = True
+
+ self.menuButton.setIconSize(QSize(self.sourceButton.top_row_icon_size,
+ self.sourceButton.top_row_icon_size))
+
+ topBar.addWidget(self.sourceButton)
+ topBar.addStretch()
+ topBar.addLayout(vlayout)
+ hlayout.addWidget(self.downloadButton)
+ hlayout.addWidget(self.menuButton)
+ return topBar
+
+ def createLeftBar(self) -> QVBoxLayout:
+ leftBar = QVBoxLayout()
+ leftBar.setContentsMargins(0, 0, 0, 0)
+
+ self.proximityButton = RotatedButton(_('Timeline'), RotatedButton.leftSide)
+ self.proximityButton.clicked.connect(self.proximityButtonClicked)
+ leftBar.addWidget(self.proximityButton)
+ leftBar.addStretch()
+ return leftBar
+
+ def createRightBar(self) -> QVBoxLayout:
+ rightBar = QVBoxLayout()
+ rightBar.setContentsMargins(0, 0, 0, 0)
+
+ self.destinationButton = RotatedButton(_('Destination'), RotatedButton.rightSide)
+ self.renameButton = RotatedButton(_('Rename'), RotatedButton.rightSide)
+ self.jobcodeButton = RotatedButton(_('Job Code'), RotatedButton.rightSide)
+ self.backupButton = RotatedButton(_('Back Up'), RotatedButton.rightSide)
+
+ self.destinationButton.clicked.connect(self.destinationButtonClicked)
+ self.renameButton.clicked.connect(self.renameButtonClicked)
+ self.jobcodeButton.clicked.connect(self.jobcodButtonClicked)
+ self.backupButton.clicked.connect(self.backupButtonClicked)
+
+ self.rightSideButtonMapper = {
+ RightSideButton.destination: self.destinationButton,
+ RightSideButton.rename: self.renameButton,
+ RightSideButton.jobcode: self.jobcodeButton,
+ RightSideButton.backup: self.backupButton}
+
+ rightBar.addWidget(self.destinationButton)
+ rightBar.addWidget(self.renameButton)
+ rightBar.addWidget(self.jobcodeButton)
+ rightBar.addWidget(self.backupButton)
+ rightBar.addStretch()
+ return rightBar
+
+ def createPathViews(self) -> None:
+ self.deviceView = DeviceView(rapidApp=self)
+ self.deviceModel = DeviceModel(self, "Devices")
+ self.deviceView.setModel(self.deviceModel)
+ self.deviceView.setItemDelegate(DeviceDelegate(rapidApp=self))
+
+ # This computer is any local path
+ self.thisComputerView = DeviceView(rapidApp=self)
+ self.thisComputerModel = DeviceModel(self, "This Computer")
+ self.thisComputerView.setModel(self.thisComputerModel)
+ self.thisComputerView.setItemDelegate(DeviceDelegate(self))
+
+ # Map different device types onto their appropriate view and model
+ self._mapModel = {DeviceType.path: self.thisComputerModel,
+ DeviceType.camera: self.deviceModel,
+ DeviceType.volume: self.deviceModel}
+ self._mapView = {DeviceType.path: self.thisComputerView,
+ DeviceType.camera: self.deviceView,
+ DeviceType.volume: self.deviceView}
+
+ # Be cautious: validate paths. The settings file can alwasy be edited by hand, and
+ # the user can set it to whatever value they want using the command line options.
+ logging.debug("Checking path validity")
+ this_computer_sf = validate_source_folder(self.prefs.this_computer_path)
+ if this_computer_sf.valid:
+ if this_computer_sf.absolute_path != self.prefs.this_computer_path:
+ self.prefs.this_computer_path = this_computer_sf.absolute_path
+ elif self.prefs.this_computer_source and self.prefs.this_computer_path != '':
+ logging.warning("Ignoring invalid 'This Computer' path: %s",
+ self.prefs.this_computer_path)
+ self.prefs.this_computer_path = ''
+
+ photo_df = validate_download_folder(self.prefs.photo_download_folder)
+ if photo_df.valid:
+ if photo_df.absolute_path != self.prefs.photo_download_folder:
+ self.prefs.photo_download_folder = photo_df.absolute_path
+ else:
+ if self.prefs.photo_download_folder:
+ logging.error("Ignoring invalid Photo Destination path: %s",
+ self.prefs.photo_download_folder)
+ self.prefs.photo_download_folder = ''
+
+ video_df = validate_download_folder(self.prefs.video_download_folder)
+ if video_df.valid:
+ if video_df.absolute_path != self.prefs.video_download_folder:
+ self.prefs.video_download_folder = video_df.absolute_path
+ else:
+ if self.prefs.video_download_folder:
+ logging.error("Ignoring invalid Video Destination path: %s",
+ self.prefs.video_download_folder)
+ self.prefs.video_download_folder = ''
+
+ self.watchedDownloadDirs = WatchDownloadDirs()
+ self.watchedDownloadDirs.updateWatchPathsFromPrefs(self.prefs)
+ self.watchedDownloadDirs.directoryChanged.connect(self.watchedFolderChange)
+
+ self.fileSystemModel = FileSystemModel(parent=self)
+ self.fileSystemFilter = FileSystemFilter(self)
+ self.fileSystemFilter.setSourceModel(self.fileSystemModel)
+ self.fileSystemDelegate = FileSystemDelegate()
+
+ index = self.fileSystemFilter.mapFromSource(self.fileSystemModel.index('/'))
+
+ self.thisComputerFSView = FileSystemView(model=self.fileSystemModel, rapidApp=self)
+ self.thisComputerFSView.setModel(self.fileSystemFilter)
+ self.thisComputerFSView.setItemDelegate(self.fileSystemDelegate)
+ self.thisComputerFSView.hideColumns()
+ self.thisComputerFSView.setRootIndex(index)
+ if this_computer_sf.valid:
+ self.thisComputerFSView.goToPath(self.prefs.this_computer_path)
+ self.thisComputerFSView.activated.connect(self.thisComputerPathChosen)
+ self.thisComputerFSView.clicked.connect(self.thisComputerPathChosen)
+
+ self.photoDestinationFSView = FileSystemView(model=self.fileSystemModel, rapidApp=self)
+ self.photoDestinationFSView.setModel(self.fileSystemFilter)
+ self.photoDestinationFSView.setItemDelegate(self.fileSystemDelegate)
+ self.photoDestinationFSView.hideColumns()
+ self.photoDestinationFSView.setRootIndex(index)
+ if photo_df.valid:
+ self.photoDestinationFSView.goToPath(self.prefs.photo_download_folder)
+ self.photoDestinationFSView.activated.connect(self.photoDestinationPathChosen)
+ self.photoDestinationFSView.clicked.connect(self.photoDestinationPathChosen)
+
+ self.videoDestinationFSView = FileSystemView(model=self.fileSystemModel, rapidApp=self)
+ self.videoDestinationFSView.setModel(self.fileSystemFilter)
+ self.videoDestinationFSView.setItemDelegate(self.fileSystemDelegate)
+ self.videoDestinationFSView.hideColumns()
+ self.videoDestinationFSView.setRootIndex(index)
+ if video_df.valid:
+ self.videoDestinationFSView.goToPath(self.prefs.video_download_folder)
+ self.videoDestinationFSView.activated.connect(self.videoDestinationPathChosen)
+ self.videoDestinationFSView.clicked.connect(self.videoDestinationPathChosen)
+
+ def createDeviceThisComputerViews(self) -> None:
+
+ # Devices Header and View
+ tip = _('Turn on or off the use of devices attached to this computer as download sources')
+ self.deviceToggleView = QToggleView(label=_('Devices'),
+ display_alternate=True,
+ toggleToolTip=tip,
+ headerColor=QColor(ThumbnailBackgroundName),
+ headerFontColor=QColor(Qt.white),
+ on=self.prefs.device_autodetection)
+ self.deviceToggleView.addWidget(self.deviceView)
+ self.deviceToggleView.valueChanged.connect(self.deviceToggleViewValueChange)
+ self.deviceToggleView.setSizePolicy(QSizePolicy.MinimumExpanding,
+ QSizePolicy.MinimumExpanding)
+
+ # This Computer Header and View
+
+ tip = _('Turn on or off the use of a folder on this computer as a download source')
+ self.thisComputerToggleView = QToggleView(label=_('This Computer'),
+ display_alternate=True,
+ toggleToolTip=tip,
+ headerColor=QColor(ThumbnailBackgroundName),
+ headerFontColor=QColor(Qt.white),
+ on=bool(self.prefs.this_computer_source))
+ self.thisComputerToggleView.valueChanged.connect(self.thisComputerToggleValueChanged)
+
+ self.thisComputer = ComputerWidget(objectName='thisComputer',
+ view=self.thisComputerView,
+ fileSystemView=self.thisComputerFSView,
+ select_text=_('Select a source folder'))
+ if self.prefs.this_computer_source:
+ self.thisComputer.setViewVisible(self.prefs.this_computer_source)
+
+ self.thisComputerToggleView.addWidget(self.thisComputer)
+
+ def createDestinationViews(self) -> None:
+ """
+ Create the widgets that let the user choose where to download photos and videos to,
+ and that show them how much storage space there is available for their files.
+ """
+
+ self.photoDestination = QPanelView(label=_('Photos'),
+ headerColor=QColor(ThumbnailBackgroundName),
+ headerFontColor=QColor(Qt.white))
+ self.videoDestination = QPanelView(label=_('Videos'),
+ headerColor=QColor(ThumbnailBackgroundName),
+ headerFontColor=QColor(Qt.white))
+
+ # Display storage space when photos and videos are being downloaded to the same
+ # partition
+
+ self.combinedDestinationDisplay = DestinationDisplay(parent=self)
+ self.combinedDestinationDisplayContainer = QPanelView(_('Projected Storage Use'),
+ headerColor=QColor(ThumbnailBackgroundName),
+ headerFontColor=QColor(Qt.white))
+ self.combinedDestinationDisplayContainer.addWidget(self.combinedDestinationDisplay)
+
+ # Display storage space when photos and videos are being downloaded to different
+ # partitions.
+ # Also display the file system folder chooser for both destinations.
+
+ self.photoDestinationDisplay = DestinationDisplay(menu=True, file_type=FileType.photo,
+ parent=self)
+ self.photoDestinationDisplay.setDestination(self.prefs.photo_download_folder)
+ self.photoDestinationWidget = ComputerWidget(objectName='photoDestination',
+ view=self.photoDestinationDisplay, fileSystemView=self.photoDestinationFSView,
+ select_text=_('Select a destination folder'))
+ self.photoDestination.addWidget(self.photoDestinationWidget)
+
+ self.videoDestinationDisplay = DestinationDisplay(menu=True, file_type=FileType.video,
+ parent=self)
+ self.videoDestinationDisplay.setDestination(self.prefs.video_download_folder)
+ self.videoDestinationWidget = ComputerWidget(objectName='videoDestination',
+ view=self.videoDestinationDisplay, fileSystemView=self.videoDestinationFSView,
+ select_text=_('Select a destination folder'))
+ self.videoDestination.addWidget(self.videoDestinationWidget)
+
+ self.photoDestinationContainer = QWidget()
+ layout = QVBoxLayout()
+ layout.setContentsMargins(0, 0, 0, 0)
+ self.photoDestinationContainer.setLayout(layout)
+ layout.addWidget(self.combinedDestinationDisplayContainer)
+ layout.addWidget(self.photoDestination)
+
+ def createRenamePanels(self) -> None:
+ """
+ Create the file renaming panel
+ """
+
+ self.renamePanel = RenamePanel(parent=self)
+
+ def createJobCodePanel(self) -> None:
+ """
+ Create the job code panel
+ """
+
+ self.jobCodePanel = JobCodePanel(parent=self)
+
+ def createBackupPanel(self) -> None:
+ """
+ Create the backup options panel
+ """
+
+ self.backupPanel = BackupPanel(parent=self)
+
+ def createBottomControls(self) -> None:
+ self.thumbnailControl = QWidget()
+ layout = QHBoxLayout()
+
+ # left and right align at edge of left & right bar
+ hmargin = self.proximityButton.sizeHint().width()
+ hmargin += self.standard_spacing
+ vmargin = int(QFontMetrics(QFont()).height() / 2 )
+
+ layout.setContentsMargins(hmargin, vmargin, hmargin, vmargin)
+ layout.setSpacing(self.standard_spacing)
+ self.thumbnailControl.setLayout(layout)
+
+ font = self.font() # type: QFont
+ font.setPointSize(font.pointSize() - 2)
+
+ self.showCombo = ChevronCombo()
+ self.showCombo.addItem(_('All'), Show.all)
+ self.showCombo.addItem(_('New'), Show.new_only)
+ self.showCombo.currentIndexChanged.connect(self.showComboChanged)
+ self.showLabel = self.showCombo.makeLabel(_("Show:"))
+
+ self.sortCombo = ChevronCombo()
+ self.sortCombo.addItem(_("Modification Time"), Sort.modification_time)
+ self.sortCombo.addItem(_("Checked State"), Sort.checked_state)
+ self.sortCombo.addItem(_("Filename"), Sort.filename)
+ self.sortCombo.addItem(_("Extension"), Sort.extension)
+ self.sortCombo.addItem(_("File Type"), Sort.file_type)
+ self.sortCombo.addItem(_("Device"), Sort.device)
+ self.sortCombo.currentIndexChanged.connect(self.sortComboChanged)
+ self.sortLabel= self.sortCombo.makeLabel(_("Sort:"))
+
+ self.sortOrder = ChevronCombo()
+ self.sortOrder.addItem(_("Ascending"), Qt.AscendingOrder)
+ self.sortOrder.addItem(_("Descending"), Qt.DescendingOrder)
+ self.sortOrder.currentIndexChanged.connect(self.sortOrderChanged)
+
+ for widget in (self.showLabel, self.sortLabel, self.sortCombo, self.showCombo,
+ self.sortOrder):
+ widget.setFont(font)
+
+ self.checkAllLabel = QLabel(_('Select All:'))
+
+ # Remove the border when the widget is highlighted
+ style = """
+ QCheckBox {
+ border: none;
+ outline: none;
+ spacing: %(spacing)d;
+ }
+ """ % dict(spacing=self.standard_spacing // 2)
+ self.selectAllPhotosCheckbox = QCheckBox(_("Photos") + " ")
+ self.selectAllVideosCheckbox = QCheckBox(_("Videos"))
+ self.selectAllPhotosCheckbox.setStyleSheet(style)
+ self.selectAllVideosCheckbox.setStyleSheet(style)
+
+ for widget in (self.checkAllLabel, self.selectAllPhotosCheckbox,
+ self.selectAllVideosCheckbox):
+ widget.setFont(font)
+
+ self.selectAllPhotosCheckbox.stateChanged.connect(self.selectAllPhotosCheckboxChanged)
+ self.selectAllVideosCheckbox.stateChanged.connect(self.selectAllVideosCheckboxChanged)
+
+ layout.addWidget(self.showLabel)
+ layout.addWidget(self.showCombo)
+ layout.addSpacing(QFontMetrics(QFont()).height() * 2)
+ layout.addWidget(self.sortLabel)
+ layout.addWidget(self.sortCombo)
+ layout.addWidget(self.sortOrder)
+ layout.addStretch()
+ layout.addWidget(self.checkAllLabel)
+ layout.addWidget(self.selectAllPhotosCheckbox)
+ layout.addWidget(self.selectAllVideosCheckbox)
+
+ def createCenterPanels(self) -> None:
+ self.centerSplitter = QSplitter()
+ self.centerSplitter.setOrientation(Qt.Horizontal)
+ self.leftPanelSplitter = QSplitter()
+ self.leftPanelSplitter.setOrientation(Qt.Vertical)
+ self.rightPanelSplitter = QSplitter()
+ self.rightPanelSplitter.setOrientation(Qt.Vertical)
+ self.rightPanels = QStackedWidget()
+
+ def configureCenterPanels(self, settings: QSettings) -> None:
+ self.leftPanelSplitter.addWidget(self.deviceToggleView)
+ self.leftPanelSplitter.addWidget(self.thisComputerToggleView)
+ self.leftPanelSplitter.addWidget(self.temporalProximity)
+
+ self.rightPanelSplitter.addWidget(self.photoDestinationContainer)
+ self.rightPanelSplitter.addWidget(self.videoDestination)
+
+ self.leftPanelSplitter.setCollapsible(0, False)
+ self.leftPanelSplitter.setCollapsible(1, False)
+ self.leftPanelSplitter.setCollapsible(2, False)
+ self.leftPanelSplitter.setStretchFactor(0, 0)
+ self.leftPanelSplitter.setStretchFactor(1, 1)
+ self.leftPanelSplitter.setStretchFactor(2, 1)
+
+ self.rightPanels.addWidget(self.rightPanelSplitter)
+ self.rightPanels.addWidget(self.renamePanel)
+ self.rightPanels.addWidget(self.jobCodePanel)
+ self.rightPanels.addWidget(self.backupPanel)
+
+ self.centerSplitter.addWidget(self.leftPanelSplitter)
+ self.centerSplitter.addWidget(self.thumbnailView)
+ self.centerSplitter.addWidget(self.rightPanels)
+ self.centerSplitter.setStretchFactor(0, 0)
+ self.centerSplitter.setStretchFactor(1, 2)
+ self.centerSplitter.setStretchFactor(2, 0)
+ self.centerSplitter.setCollapsible(0, False)
+ self.centerSplitter.setCollapsible(1, False)
+ self.centerSplitter.setCollapsible(2, False)
+
+ self.rightPanelSplitter.setCollapsible(0, False)
+ self.rightPanelSplitter.setCollapsible(1, False)
+
+ splitterSetting = settings.value("centerSplitterSizes")
+ if splitterSetting is not None:
+ self.centerSplitter.restoreState(splitterSetting)
+ else:
+ self.centerSplitter.setSizes([200, 400, 200])
+
+ splitterSetting = settings.value("leftPanelSplitterSizes")
+ if splitterSetting is not None:
+ self.leftPanelSplitter.restoreState(splitterSetting)
+ else:
+ self.leftPanelSplitter.setSizes([200, 200, 400])
+
+ splitterSetting = settings.value("rightPanelSplitterSizes")
+ if splitterSetting is not None:
+ self.rightPanelSplitter.restoreState(splitterSetting)
+ else:
+ self.rightPanelSplitter.setSizes([200,200])
+
+ def setDownloadCapabilities(self) -> bool:
+ """
+ Update the destination displays and download button
+
+ :return: True if download destinations are capable of having
+ all marked files downloaded to them
+ """
+ marked_summary = self.thumbnailModel.getMarkedSummary()
+ if self.prefs.backup_files:
+ downloading_to = self.backup_devices.get_download_backup_device_overlap(
+ photo_download_folder=self.prefs.photo_download_folder,
+ video_download_folder=self.prefs.video_download_folder)
+ self.backupPanel.setDownloadingTo(downloading_to=downloading_to)
+ backups_good = self.updateBackupView(marked_summary=marked_summary)
+ else:
+ backups_good = True
+ downloading_to = defaultdict(set)
+
+ destinations_good = self.updateDestinationViews(marked_summary=marked_summary,
+ downloading_to=downloading_to)
+
+ download_good = destinations_good and backups_good
+ self.setDownloadActionState(download_good)
+ self.destinationButton.setHighlighted(not destinations_good)
+ self.backupButton.setHighlighted(not backups_good)
+ return download_good
+
+ def updateDestinationViews(self,
+ marked_summary: MarkedSummary,
+ downloading_to: Optional[DefaultDict[int, Set[FileType]]]=None) -> bool:
+ """
+ Updates the the header bar and storage space view for the
+ photo and video download destinations.
+
+ :return True if destinations required for the download exist,
+ and there is sufficient space on them, else False.
+ """
+
+ size_photos_marked = marked_summary.size_photos_marked
+ size_videos_marked = marked_summary.size_videos_marked
+ marked = marked_summary.marked
+
+ if self.unity_progress:
+ available = self.thumbnailModel.getNoFilesMarkedForDownload()
+ for launcher in self.desktop_launchers:
+ if available:
+ launcher.set_property("count", available)
+ launcher.set_property("count_visible", True)
+ else:
+ launcher.set_property("count_visible", False)
+
+ destinations_good = True
+
+ # Assume that invalid destination folders have already been reset to ''
+ if self.prefs.photo_download_folder and self.prefs.video_download_folder:
+ same_dev = same_device(self.prefs.photo_download_folder,
+ self.prefs.video_download_folder)
+ else:
+ same_dev = False
+
+ merge = self.downloadIsRunning()
+
+ if same_dev:
+ files_to_display = DisplayingFilesOfType.photos_and_videos
+ self.combinedDestinationDisplay.downloading_to = downloading_to
+ self.combinedDestinationDisplay.setDestination(self.prefs.photo_download_folder)
+ self.combinedDestinationDisplay.setDownloadAttributes(marked, size_photos_marked,
+ size_videos_marked, files_to_display, DestinationDisplayType.usage_only,
+ merge)
+ display_type = DestinationDisplayType.folder_only
+ self.combinedDestinationDisplayContainer.setVisible(True)
+ destinations_good = self.combinedDestinationDisplay.sufficientSpaceAvailable()
+ else:
+ files_to_display = DisplayingFilesOfType.photos
+ display_type = DestinationDisplayType.folders_and_usage
+ self.combinedDestinationDisplayContainer.setVisible(False)
+
+ if self.prefs.photo_download_folder:
+ self.photoDestinationDisplay.downloading_to = downloading_to
+ self.photoDestinationDisplay.setDownloadAttributes(marked, size_photos_marked,
+ 0, files_to_display, display_type, merge)
+ self.photoDestinationWidget.setViewVisible(True)
+ if display_type == DestinationDisplayType.folders_and_usage:
+ destinations_good = self.photoDestinationDisplay.sufficientSpaceAvailable()
+ else:
+ # Photo download folder was invalid or simply not yet set
+ self.photoDestinationWidget.setViewVisible(False)
+ if size_photos_marked:
+ destinations_good = False
+
+ if not same_dev:
+ files_to_display = DisplayingFilesOfType.videos
+ if self.prefs.video_download_folder:
+ self.videoDestinationDisplay.downloading_to = downloading_to
+ self.videoDestinationDisplay.setDownloadAttributes(marked, 0,
+ size_videos_marked, files_to_display, display_type, merge)
+ self.videoDestinationWidget.setViewVisible(True)
+ if display_type == DestinationDisplayType.folders_and_usage:
+ destinations_good = (self.videoDestinationDisplay.sufficientSpaceAvailable() and
+ destinations_good)
+ else:
+ # Video download folder was invalid or simply not yet set
+ self.videoDestinationWidget.setViewVisible(False)
+ if size_videos_marked:
+ destinations_good = False
+
+ return destinations_good
+
+ @pyqtSlot()
+ def updateThumbnailModelAfterProximityChange(self) -> None:
+ """
+ Respond to the user selecting / deslecting temporal proximity
+ cells
+ """
+
+ self.thumbnailModel.updateAllDeviceDisplayCheckMarks()
+ self.thumbnailModel.updateSelectionAfterProximityChange()
+ self.thumbnailModel.resetHighlighting()
+
+ def updateBackupView(self, marked_summary: MarkedSummary) -> bool:
+ merge = self.downloadIsRunning()
+ self.backupPanel.setDownloadAttributes(marked=marked_summary.marked,
+ photos_size=marked_summary.size_photos_marked,
+ videos_size=marked_summary.size_videos_marked,
+ merge=merge)
+ return self.backupPanel.sufficientSpaceAvailable()
+
+ def setDownloadActionState(self, download_destinations_good: bool) -> None:
+ """
+ Sets sensitivity of Download action to enable or disable it.
+ Affects download button and menu item.
+
+ :param download_destinations_good: whether the download destinations
+ are valid and contain sufficient space for the download to proceed
+ """
+
+ if not self.downloadIsRunning():
+ files_marked = False
+ # Don't enable starting a download while devices are being scanned
+ if len(self.devices.scanning) == 0:
+ files_marked = self.thumbnailModel.filesAreMarkedForDownload()
+
+ enabled = files_marked and download_destinations_good
+
+ self.downloadAct.setEnabled(enabled)
+ self.downloadButton.setEnabled(enabled)
+ if files_marked:
+ marked = self.thumbnailModel.getNoFilesAndTypesMarkedForDownload()
+ files = marked.file_types_present_details()
+ text = _("Download %(files)s") % dict(files=files) # type: str
+ self.downloadButton.setText(text)
+ else:
+ self.downloadButton.setText(self.downloadAct.text())
+ else:
+ self.downloadAct.setEnabled(True)
+ self.downloadButton.setEnabled(True)
+
+ def setDownloadActionLabel(self) -> None:
+ """
+ Sets download action and download button text to correct value, depending on
+ whether a download is occurring or not, including whether it is paused
+ """
+
+ if self.devices.downloading:
+ if self.download_paused:
+ text = _("Resume Download")
+ else:
+ text = _("Pause")
+ else:
+ text = _("Download")
+
+ self.downloadAct.setText(text)
+ self.downloadButton.setText(text)
+
+ def createMenus(self) -> None:
+ self.menu = QMenu()
+ self.menu.addAction(self.downloadAct)
+ self.menu.addAction(self.preferencesAct)
+ self.menu.addSeparator()
+ self.menu.addAction(self.errorLogAct)
+ self.menu.addAction(self.clearDownloadsAct)
+ self.menu.addSeparator()
+ self.menu.addAction(self.helpAct)
+ self.menu.addAction(self.didYouKnowAct)
+ self.menu.addAction(self.newVersionAct)
+ self.menu.addAction(self.reportProblemAct)
+ self.menu.addAction(self.makeDonationAct)
+ self.menu.addAction(self.translateApplicationAct)
+ self.menu.addAction(self.aboutAct)
+ self.menu.addAction(self.quitAct)
+
+ self.menuButton = MenuButton(icon=QIcon(':/menu.svg'), menu=self.menu)
+
+ def doCheckForNewVersion(self) -> None:
+ """Check online for a new program version"""
+ self.newVersionCheckDialog.reset()
+ self.newVersionCheckDialog.show()
+ self.checkForNewVersionRequest.emit()
+
+ def doSourceAction(self) -> None:
+ self.sourceButton.animateClick()
+
+ def doDownloadAction(self) -> None:
+ self.downloadButton.animateClick()
+
+ def doRefreshAction(self) -> None:
+ pass
+
+ def doPreferencesAction(self) -> None:
+ self.scan_all_again = self.scan_non_camera_devices_again = False
+ self.search_for_devices_again = False
+
+ dialog = PreferencesDialog(prefs=self.prefs, parent=self)
+ dialog.exec()
+ self.prefs.sync()
+
+ if self.scan_all_again or self.scan_non_camera_devices_again:
+ self.rescanDevicesAndComputer(
+ ignore_cameras=not self.scan_all_again,
+ rescan_path=self.scan_all_again
+ )
+
+ if self.search_for_devices_again:
+ # Update the list of valid mounts
+ logging.debug(
+ "Updating the list of valid mounts after preference change to only_external_mounts"
+ )
+ self.validMounts = ValidMounts(onlyExternalMounts=self.prefs.only_external_mounts)
+ self.searchForDevicesAgain()
+
+ # Just to be extra safe, reset these values to their 'off' state:
+ self.scan_all_again = self.scan_non_camera_devices_again = False
+ self.search_for_devices_again = False
+
+ def doErrorLogAction(self) -> None:
+ self.errorLog.setVisible(self.errorLogAct.isChecked())
+
+ def doClearDownloadsAction(self):
+ self.thumbnailModel.clearCompletedDownloads()
+
+ def doHelpAction(self) -> None:
+ webbrowser.open_new_tab("http://www.damonlynch.net/rapid/help.html")
+
+ def doDidYouKnowAction(self) -> None:
+ try:
+ self.tip.activate()
+ except AttributeError:
+ self.tip = didyouknow.DidYouKnowDialog(self.prefs, self)
+ self.tip.activate()
+
+ def makeProblemReportDialog(self, header: str, title: Optional[str]=None) -> None:
+ log_path, log_file = os.path.split(iplogging.full_log_file_path())
+ log_uri = pathname2url(log_path)
+
+ body = _(
+ r"""Please report the problem at <a href="{website}">{website}</a>.<br><br>
+ Attach the log file <i>{log_file}</i> to your report (click
+ <a href="{log_path}">here</a> to open the log directory).
+ """
+ ).format(
+ website='https://bugs.launchpad.net/rapid', log_path=log_uri, log_file=log_file
+ )
+
+ message = '{header}<br><br>{body}'.format(header=header, body=body)
+
+ errorbox = self.standardMessageBox(message=message, rich_text=True, title=title)
+ errorbox.exec_()
+
+ def doReportProblemAction(self) -> None:
+ header = _('Thank you for reporting a problem in Rapid Photo Downloader')
+ header = '<b>{}</b>'.format(header)
+ self.makeProblemReportDialog(header)
+
+ def doMakeDonationAction(self) -> None:
+ webbrowser.open_new_tab("http://www.damonlynch.net/rapid/donate.html")
+
+ def doTranslateApplicationAction(self) -> None:
+ webbrowser.open_new_tab("http://www.damonlynch.net/rapid/translate.html")
+
+ def doAboutAction(self) -> None:
+ about = AboutDialog(self)
+ about.exec()
+
+ def standardMessageBox(self, message: str,
+ rich_text: bool,
+ title: Optional[str]=None) -> QMessageBox:
+ """
+ Create a standard messagebox to be displayed to the user
+
+ :param message: the text to display
+ :param rich_text: whether it text to display is in HTML format
+ :param title: optional title for message box, else defaults to
+ localized 'Rapid Photo Downloader'
+ :return: the message box
+ """
+
+ msgBox = QMessageBox()
+ icon = QIcon(':/rapid-photo-downloader.svg').pixmap(standardIconSize())
+ if title is None:
+ title = _("Rapid Photo Downloader")
+ if rich_text:
+ msgBox.setTextFormat(Qt.RichText)
+ msgBox.setIconPixmap(icon)
+ msgBox.setWindowTitle(title)
+ msgBox.setText(message)
+ return msgBox
+
+ @pyqtSlot(bool)
+ def thisComputerToggleValueChanged(self, on: bool) -> None:
+ """
+ Respond to This Computer Toggle Switch
+
+ :param on: whether swich is on or off
+ """
+
+ if on:
+ self.thisComputer.setViewVisible(bool(self.prefs.this_computer_path))
+ self.prefs.this_computer_source = on
+ if not on:
+ if len(self.devices.this_computer) > 0:
+ scan_id = list(self.devices.this_computer)[0]
+ self.removeDevice(scan_id=scan_id)
+ self.prefs.this_computer_path = ''
+ self.thisComputerFSView.clearSelection()
+
+ self.adjustLeftPanelSliderHandles()
+
+ @pyqtSlot(bool)
+ def deviceToggleViewValueChange(self, on: bool) -> None:
+ """
+ Respond to Devices Toggle Switch
+
+ :param on: whether swich is on or off
+ """
+
+ self.prefs.device_autodetection = on
+ if not on:
+ for scan_id in list(self.devices.volumes_and_cameras):
+ self.removeDevice(scan_id=scan_id, adjust_temporal_proximity=False)
+ if len(self.devices) == 0:
+ self.temporalProximity.setState(TemporalProximityState.empty)
+ else:
+ self.generateTemporalProximityTableData("devices were removed as a download source")
+ else:
+ # This is a real hack -- but I don't know a better way to let the
+ # slider redraw itself
+ QTimer.singleShot(100, self.devicesViewToggledOn)
+ self.adjustLeftPanelSliderHandles()
+
+ @pyqtSlot()
+ def devicesViewToggledOn(self) -> None:
+ self.searchForCameras()
+ self.setupNonCameraDevices()
+
+ @pyqtSlot(QModelIndex)
+ def thisComputerPathChosen(self, index: QModelIndex) -> None:
+ """
+ Handle user selecting new device location path.
+
+ Called after single click or folder being activated.
+
+ :param index: cell clicked
+ """
+
+ path = self.fileSystemModel.filePath(index.model().mapToSource(index))
+
+ if self.downloadIsRunning() and self.prefs.this_computer_path:
+ message = _("<b>Changing This Computer source path</b><br><br>Do you really want to "
+ "change the source path to %(new_path)s?<br><br>You are currently "
+ "downloading from %(source_path)s.<br><br>"
+ "If you do change the path, the current download from This Computer "
+ "will be cancelled.") % dict(new_path=make_html_path_non_breaking(path),
+ source_path=make_html_path_non_breaking(
+ self.prefs.this_computer_path))
+
+ msgbox = self.standardMessageBox(message=message, rich_text=True)
+ msgbox.setIcon(QMessageBox.Question)
+ msgbox.setStandardButtons(QMessageBox.Yes|QMessageBox.No)
+ if msgbox.exec() == QMessageBox.No:
+ self.thisComputerFSView.goToPath(self.prefs.this_computer_path)
+ return
+
+ if path != self.prefs.this_computer_path:
+ if self.prefs.this_computer_path:
+ scan_id = self.devices.scan_id_from_path(self.prefs.this_computer_path,
+ DeviceType.path)
+ if scan_id is not None:
+ logging.debug("Removing path from device view %s",
+ self.prefs.this_computer_path)
+ self.removeDevice(scan_id=scan_id)
+ self.prefs.this_computer_path = path
+ self.thisComputer.setViewVisible(True)
+ self.setupManualPath()
+
+ @pyqtSlot(QModelIndex)
+ def photoDestinationPathChosen(self, index: QModelIndex) -> None:
+ """
+ Handle user setting new photo download location
+
+ Called after single click or folder being activated.
+
+ :param index: cell clicked
+ """
+
+ path = self.fileSystemModel.filePath(index.model().mapToSource(index))
+
+ if not self.checkChosenDownloadDestination(path, FileType.photo):
+ return
+
+ if validate_download_folder(path).valid:
+ if path != self.prefs.photo_download_folder:
+ self.prefs.photo_download_folder = path
+ self.watchedDownloadDirs.updateWatchPathsFromPrefs(self.prefs)
+ self.folder_preview_manager.change_destination()
+ self.photoDestinationDisplay.setDestination(path=path)
+ self.setDownloadCapabilities()
+ else:
+ logging.error("Invalid photo download destination chosen: %s", path)
+ self.handleInvalidDownloadDestination(file_type=FileType.photo)
+
+ def checkChosenDownloadDestination(self, path: str, file_type: FileType) -> bool:
+ """
+ Check the path the user has chosen to ensure it's not a provisional
+ download subfolder. If it is a download subfolder that already existed,
+ confirm with the user that they did in fact want to use that destination.
+
+ :param path: path chosen
+ :param file_type: whether for photos or videos
+ :return: False if the path is problematic and should be ignored, else True
+ """
+
+ problematic = self.downloadIsRunning()
+ if problematic:
+ message = _("You cannot change the download destination while downloading.")
+ msgbox = self.standardMessageBox(message=message, rich_text=False)
+ msgbox.setIcon(QMessageBox.Warning)
+ msgbox.exec()
+
+ else:
+ problematic = path in self.fileSystemModel.preview_subfolders
+
+ if not problematic and path in self.fileSystemModel.download_subfolders:
+ message = _("<b>Confirm Download Destination</b><br><br>Are you sure you want to set "
+ "the %(file_type)s download destination to %(path)s?") % dict(
+ file_type=file_type.name, path=make_html_path_non_breaking(path))
+ msgbox = self.standardMessageBox(message=message, rich_text=True)
+ msgbox.setStandardButtons(QMessageBox.Yes|QMessageBox.No)
+ msgbox.setIcon(QMessageBox.Question)
+ problematic = msgbox.exec() == QMessageBox.No
+
+ if problematic:
+ if file_type == FileType.photo and self.prefs.photo_download_folder:
+ self.photoDestinationFSView.goToPath(self.prefs.photo_download_folder)
+ elif file_type == FileType.video and self.prefs.video_download_folder:
+ self.videoDestinationFSView.goToPath(self.prefs.video_download_folder)
+ return False
+
+ return True
+
+ def handleInvalidDownloadDestination(self, file_type: FileType, do_update: bool=True) -> None:
+ """
+ Handle cases where user clicked on an invalid download directory,
+ or the directory simply having disappeared
+
+ :param file_type: type of destination to work on
+ :param do_update: if True, update watched folders, provisional
+ download folders and update the UI to reflect new download
+ capabilities
+ """
+
+ if file_type == FileType.photo:
+ self.prefs.photo_download_folder = ''
+ self.photoDestinationWidget.setViewVisible(False)
+ else:
+ self.prefs.video_download_folder = ''
+ self.videoDestinationWidget.setViewVisible(False)
+
+ if do_update:
+ self.watchedDownloadDirs.updateWatchPathsFromPrefs(self.prefs)
+ self.folder_preview_manager.change_destination()
+ self.setDownloadCapabilities()
+
+ @pyqtSlot(QModelIndex)
+ def videoDestinationPathChosen(self, index: QModelIndex) -> None:
+ """
+ Handle user setting new video download location
+
+ Called after single click or folder being activated.
+
+ :param index: cell clicked
+ """
+
+ path = self.fileSystemModel.filePath(index.model().mapToSource(index))
+
+ if not self.checkChosenDownloadDestination(path, FileType.video):
+ return
+
+ if validate_download_folder(path).valid:
+ if path != self.prefs.video_download_folder:
+ self.prefs.video_download_folder = path
+ self.watchedDownloadDirs.updateWatchPathsFromPrefs(self.prefs)
+ self.folder_preview_manager.change_destination()
+ self.videoDestinationDisplay.setDestination(path=path)
+ self.setDownloadCapabilities()
+ else:
+ logging.error("Invalid video download destination chosen: %s", path)
+ self.handleInvalidDownloadDestination(file_type=FileType.video)
+
+ @pyqtSlot()
+ def downloadButtonClicked(self) -> None:
+ if self.download_paused:
+ logging.debug("Download resumed")
+ self.resumeDownload()
+ else:
+ if self.downloadIsRunning():
+ self.pauseDownload()
+ else:
+ start_download = True
+ if self.prefs.warn_downloading_all and \
+ self.thumbnailModel.anyCheckedFilesFiltered():
+ message = _("""<b>Downloading all files</b><br><br>
+ A download always includes all files that are checked for download,
+ including those that are not currently displayed because the Timeline
+ is being used or because only new files are being shown.<br><br>
+ Do you want to proceed with the download?""")
+
+ warning = RememberThisDialog(message=message,
+ icon=':/rapid-photo-downloader.svg',
+ remember=RememberThisMessage.do_not_ask_again,
+ parent=self)
+
+ start_download = warning.exec_()
+ if warning.remember:
+ self.prefs.warn_downloading_all = False
+
+ if start_download:
+ logging.debug("Download activated")
+
+ if self.jobCodePanel.needToPromptForJobCode():
+ if self.jobCodePanel.getJobCodeBeforeDownload():
+ self.startDownload()
+ else:
+ self.startDownload()
+
+ def pauseDownload(self) -> None:
+ """
+ Pause the copy files processes
+ """
+
+ self.dl_update_timer.stop()
+ self.download_paused = True
+ self.sendPauseToThread(self.copy_controller)
+ self.setDownloadActionLabel()
+ self.time_check.pause()
+ self.displayMessageInStatusBar()
+
+ def resumeDownload(self) -> None:
+ """
+ Resume a download after it has been paused, and start
+ downloading from any queued auto-start downloads
+ """
+
+ for scan_id in self.devices.downloading:
+ self.time_remaining.set_time_mark(scan_id)
+
+ self.time_check.set_download_mark()
+ self.sendResumeToThread(self.copy_controller)
+ self.download_paused = False
+ self.dl_update_timer.start()
+ self.download_start_time = time.time()
+ self.setDownloadActionLabel()
+ self.immediatelyDisplayDownloadRunningInStatusBar()
+ for scan_id in self.devices.queued_to_download:
+ self.startDownload(scan_id=scan_id)
+ self.devices.queued_to_download = set() # type: Set[int]
+
+ def downloadIsRunning(self) -> bool:
+ """
+ :return True if a file is currently being downloaded, renamed
+ or backed up, else False
+ """
+ if not self.devices.downloading:
+ if self.prefs.backup_files:
+ return not self.download_tracker.all_files_backed_up()
+ else:
+ return False
+ else:
+ return True
+
+ def startDownload(self, scan_id: int=None) -> None:
+ """
+ Start download, renaming and backup of files.
+
+ :param scan_id: if specified, only files matching it will be
+ downloaded
+ """
+ logging.debug("Start Download phase 1 has started")
+
+ if self.prefs.backup_files:
+ self.initializeBackupThumbCache()
+
+ self.download_files = self.thumbnailModel.getFilesMarkedForDownload(scan_id)
+
+ # model, port
+ camera_unmounts_called = set() # type: Set[Tuple(str, str)]
+ stop_thumbnailing_cmd_issued = False
+
+ stop_thumbnailing = [scan_id for scan_id in self.download_files.camera_access_needed
+ if scan_id in self.devices.thumbnailing]
+ for scan_id in stop_thumbnailing:
+ device = self.devices[scan_id]
+ if not scan_id in self.thumbnailModel.generating_thumbnails:
+ logging.debug("Not terminating thumbnailing of %s because it's not in the "
+ "thumbnail manager", device.display_name)
+ else:
+ logging.debug("Terminating thumbnailing for %s because a download is starting",
+ device.display_name)
+ self.thumbnailModel.terminateThumbnailGeneration(scan_id)
+ self.devices.cameras_to_stop_thumbnailing.add(scan_id)
+ stop_thumbnailing_cmd_issued = True
+
+ if self.gvfsControlsMounts:
+ mount_points = {}
+ # If a device was being thumbnailed, then it wasn't mounted by GVFS
+ # Therefore filter out the cameras we've already requested their
+ # thumbnailing be stopped
+ still_to_check = [scan_id for scan_id in self.download_files.camera_access_needed
+ if scan_id not in stop_thumbnailing]
+ for scan_id in still_to_check:
+ # This next value is likely *always* True, but check nonetheless
+ if self.download_files.camera_access_needed[scan_id]:
+ device = self.devices[scan_id]
+ model = device.camera_model
+ port = device.camera_port
+ mount_point = self.gvolumeMonitor.ptpCameraMountPoint(model, port)
+ if mount_point is not None:
+ self.devices.cameras_to_gvfs_unmount_for_download.add(scan_id)
+ camera_unmounts_called.add((model, port))
+ mount_points[(model, port)] = mount_point
+ if len(camera_unmounts_called):
+ logging.info("%s camera(s) need to be unmounted by GVFS before the download begins",
+ len(camera_unmounts_called))
+ for model, port in camera_unmounts_called:
+ self.gvolumeMonitor.unmountCamera(model, port,
+ download_starting=True,
+ mount_point=mount_points[(model, port)])
+
+ if not camera_unmounts_called and not stop_thumbnailing_cmd_issued:
+ self.startDownloadPhase2()
+
+ def startDownloadPhase2(self) -> None:
+ logging.debug("Start Download phase 2 has started")
+ download_files = self.download_files
+
+ invalid_dirs = self.invalidDownloadFolders(download_files.download_types)
+
+ if invalid_dirs:
+ if len(invalid_dirs) > 1:
+ msg = _("These download folders are invalid:\n%("
+ "folder1)s\n%(folder2)s") % {
+ 'folder1': invalid_dirs[0], 'folder2': invalid_dirs[1]}
+ else:
+ msg = _("This download folder is invalid:\n%s") % invalid_dirs[0]
+ msgBox = QMessageBox(self)
+ msgBox.setIcon(QMessageBox.Critical)
+ msgBox.setWindowTitle(_("Download Failure"))
+ msgBox.setText(_("The download cannot proceed."))
+ msgBox.setInformativeText(msg)
+ msgBox.exec()
+ else:
+ missing_destinations = self.backup_devices.backup_destinations_missing(
+ download_files.download_types)
+ if missing_destinations is not None:
+ # Warn user that they have specified that they want to
+ # backup a file type, but no such folder exists on backup
+ # devices
+ if self.prefs.backup_device_autodetection:
+ if missing_destinations == BackupFailureType.photos_and_videos:
+ logging.warning("Photos and videos will not be backed up because there "
+ "is nowhere to back them up")
+ msg = _("Photos and videos will not be backed up because there is nowhere "
+ "to back them up. Do you still want to start the download?")
+ elif missing_destinations == BackupFailureType.photos:
+ logging.warning("No backup device exists for backing up photos")
+ # Translators: filetype will be replaced with 'photos' or 'videos'
+ msg = _("No backup device exists for backing up %(filetype)s. Do you "
+ "still want to start the download?") % {'filetype': _('photos')}
+
+ else:
+ logging.warning("No backup device contains a valid folder for backing up "
+ "videos")
+ # Translators: filetype will be replaced with 'photos' or 'videos'
+ msg = _("No backup device exists for backing up %(filetype)s. Do you "
+ "still want to start the download?") % {'filetype': _('videos')}
+ else:
+ if missing_destinations == BackupFailureType.photos_and_videos:
+ logging.warning("The manually specified photo and videos backup paths do "
+ "not exist or are not writable")
+ msg = _(
+ "<b>The photo and video backup destinations do not exist or cannot "
+ "be written to.</b><br><br>Do you still want to start the download?"
+ )
+ elif missing_destinations == BackupFailureType.photos:
+ logging.warning("The manually specified photo backup path does not exist "
+ "or is not writable")
+ # Translators: filetype will be replaced by either 'photo' or 'video'
+ msg = _(
+ "<b>The %(filetype)s backup destination does not exist or cannot be "
+ "written to.</b><br><br>Do you still want to start the download?"
+ ) % {'filetype': _('photo')}
+ else:
+ logging.warning("The manually specified video backup path does not exist "
+ "or is not writable")
+ msg = _(
+ "<b>The %(filetype)s backup destination does not exist or cannot be "
+ "written to.</b><br><br>Do you still want to start the download?"
+ ) % {'filetype': _('video')}
+
+
+ if self.prefs.warn_backup_problem:
+ warning = RememberThisDialog(message=msg,
+ icon=':/rapid-photo-downloader.svg',
+ remember=RememberThisMessage.do_not_ask_again,
+ parent=self,
+ title= _("Backup problem"))
+ do_download = warning.exec()
+ if warning.remember:
+ self.prefs.warn_backup_problem = False
+ if not do_download:
+ return
+
+ # Suppress showing a notification message about any timeline
+ # and provisional folders rebuild - download takes priority
+ self.ctime_notification_issued = False
+
+ # Set time download is starting if it is not already set
+ # it is unset when all downloads are completed
+ # It is used in file renaming
+ if self.download_start_datetime is None:
+ self.download_start_datetime = datetime.datetime.now()
+ # The download start time (not datetime) is used to determine
+ # when to show the time remaining and download speed in the status bar
+ if self.download_start_time is None:
+ self.download_start_time = time.time()
+
+ # Set status to download pending
+ self.thumbnailModel.markDownloadPending(download_files.files)
+
+ # disable refresh and the changing of various preferences while
+ # the download is occurring
+ self.enablePrefsAndRefresh(enabled=False)
+
+ # notify renameandmovefile process to read any necessary values
+ # from the program preferences
+ data = RenameAndMoveFileData(message=RenameAndMoveStatus.download_started)
+ self.sendDataMessageToThread(self.rename_controller, data=data)
+
+ # notify backup processes to reset their problem reports
+ self.sendBackupStartFinishMessageToWorkers(BackupStatus.backup_started)
+
+ # Maximum value of progress bar may have been set to the number
+ # of thumbnails being generated. Reset it to use a percentage.
+ self.downloadProgressBar.setMaximum(100)
+
+ for scan_id in download_files.files:
+ files = download_files.files[scan_id]
+ # if generating thumbnails for this scan_id, stop it
+ if self.thumbnailModel.terminateThumbnailGeneration(scan_id):
+ generate_thumbnails = self.thumbnailModel.markThumbnailsNeeded(files)
+ else:
+ generate_thumbnails = False
+
+ self.downloadFiles(files, scan_id,
+ download_files.download_stats[scan_id],
+ generate_thumbnails)
+
+ self.setDownloadActionLabel()
+
+ def downloadFiles(self, files: list,
+ scan_id: int,
+ download_stats: DownloadStats,
+ generate_thumbnails: bool) -> None:
+ """
+
+ :param files: list of the files to download
+ :param scan_id: the device from which to download the files
+ :param download_stats: count of files and their size
+ :param generate_thumbnails: whether thumbnails must be
+ generated in the copy files process.
+ """
+
+ model = self.mapModel(scan_id)
+ model.setSpinnerState(scan_id, DeviceState.downloading)
+
+ if download_stats.no_photos > 0:
+ photo_download_folder = self.prefs.photo_download_folder
+ else:
+ photo_download_folder = None
+
+ if download_stats.no_videos > 0:
+ video_download_folder = self.prefs.video_download_folder
+ else:
+ video_download_folder = None
+
+ self.download_tracker.init_stats(scan_id=scan_id, stats=download_stats)
+ download_size = download_stats.photos_size_in_bytes + \
+ download_stats.videos_size_in_bytes
+
+ if self.prefs.backup_files:
+ download_size += ((len(self.backup_devices.photo_backup_devices) *
+ download_stats.photos_size_in_bytes) + (
+ len(self.backup_devices.video_backup_devices) *
+ download_stats.videos_size_in_bytes))
+
+ self.time_remaining[scan_id] = download_size
+ self.time_check.set_download_mark()
+
+ self.devices.set_device_state(scan_id, DeviceState.downloading)
+ self.updateProgressBarState()
+ self.immediatelyDisplayDownloadRunningInStatusBar()
+ self.setDownloadActionState(True)
+
+ if not self.dl_update_timer.isActive():
+ self.dl_update_timer.start()
+
+ if self.autoStart(scan_id) and self.prefs.generate_thumbnails:
+ for rpd_file in files:
+ rpd_file.generate_thumbnail = True
+ generate_thumbnails = True
+
+ verify_file = self.prefs.verify_file
+
+ # Initiate copy files process
+
+ device = self.devices[scan_id]
+ copyfiles_args = CopyFilesArguments(scan_id=scan_id,
+ device=device,
+ photo_download_folder=photo_download_folder,
+ video_download_folder=video_download_folder,
+ files=files,
+ verify_file=verify_file,
+ generate_thumbnails=generate_thumbnails,
+ log_gphoto2=self.log_gphoto2)
+
+ self.sendStartWorkerToThread(self.copy_controller, worker_id=scan_id, data=copyfiles_args)
+
+ @pyqtSlot(int, str, str)
+ def tempDirsReceivedFromCopyFiles(self, scan_id: int,
+ photo_temp_dir: str,
+ video_temp_dir: str) -> None:
+ self.fileSystemFilter.setTempDirs([photo_temp_dir, video_temp_dir])
+ self.temp_dirs_by_scan_id[scan_id] = list(filter(None,[photo_temp_dir,
+ video_temp_dir]))
+
+ def cleanAllTempDirs(self):
+ """
+ Deletes temporary files and folders used in all downloads.
+ """
+ if self.temp_dirs_by_scan_id:
+ logging.debug("Cleaning temporary directories")
+ for scan_id in self.temp_dirs_by_scan_id:
+ self.cleanTempDirsForScanId(scan_id, remove_entry=False)
+ self.temp_dirs_by_scan_id = {}
+
+ def cleanTempDirsForScanId(self, scan_id: int, remove_entry: bool=True):
+ """
+ Deletes temporary files and folders used in download.
+
+ :param scan_id: the scan id associated with the temporary
+ directory
+ :param remove_entry: if True, remove the scan_id from the
+ dictionary tracking temporary directories
+ """
+
+ home_dir = os.path.expanduser("~")
+ for d in self.temp_dirs_by_scan_id[scan_id]:
+ assert d != home_dir
+ if os.path.isdir(d):
+ try:
+ shutil.rmtree(d, ignore_errors=True)
+ except:
+ logging.error("Unknown error deleting temporary directory %s", d)
+ if remove_entry:
+ del self.temp_dirs_by_scan_id[scan_id]
+
+ @pyqtSlot(bool, RPDFile, int, 'PyQt_PyObject')
+ def copyfilesDownloaded(self, download_succeeded: bool,
+ rpd_file: RPDFile,
+ download_count: int,
+ mdata_exceptions: Optional[Tuple[Exception]]) -> None:
+
+ scan_id = rpd_file.scan_id
+
+ if scan_id not in self.devices:
+ logging.debug("Ignoring file %s because its device has been removed",
+ rpd_file.full_file_name)
+ return
+
+ self.download_tracker.set_download_count_for_file(rpd_file.uid, download_count)
+ self.download_tracker.set_download_count(scan_id, download_count)
+ rpd_file.download_start_time = self.download_start_datetime
+ if rpd_file.file_type == FileType.photo:
+ rpd_file.generate_extension_case = self.prefs.photo_extension
+ else:
+ rpd_file.generate_extension_case = self.prefs.video_extension
+
+ if mdata_exceptions is not None and self.prefs.warn_fs_metadata_error:
+ self.copy_metadata_errors.add_problem(
+ worker_id=scan_id, path=rpd_file.temp_full_file_name,
+ mdata_exceptions=mdata_exceptions
+ )
+
+ self.sendDataMessageToThread(self.rename_controller,
+ data=RenameAndMoveFileData(rpd_file=rpd_file,
+ download_count=download_count,
+ download_succeeded=download_succeeded))
+
+ @pyqtSlot(int, 'PyQt_PyObject', 'PyQt_PyObject')
+ def copyfilesBytesDownloaded(self, scan_id: int,
+ total_downloaded: int,
+ chunk_downloaded: int) -> None:
+ """
+ Update the tracking and display of how many bytes have been
+ downloaded / copied.
+ """
+
+ if scan_id not in self.devices:
+ return
+
+ try:
+ assert total_downloaded >= 0
+ assert chunk_downloaded >= 0
+ except AssertionError:
+ logging.critical("Unexpected negative values for total / chunk downloaded: %s %s ",
+ total_downloaded, chunk_downloaded)
+
+ self.download_tracker.set_total_bytes_copied(scan_id, total_downloaded)
+ if len(self.devices.have_downloaded_from) > 1:
+ model = self.mapModel(scan_id)
+ model.percent_complete[scan_id] = self.download_tracker.get_percent_complete(scan_id)
+ self.time_check.increment(bytes_downloaded=chunk_downloaded)
+ self.time_remaining.update(scan_id, bytes_downloaded=chunk_downloaded)
+ self.updateFileDownloadDeviceProgress()
+
+ @pyqtSlot(int, 'PyQt_PyObject')
+ def copyfilesProblems(self, scan_id: int, problems: CopyingProblems) -> None:
+ for problem in self.copy_metadata_errors.problems(worker_id=scan_id):
+ problems.append(problem)
+
+ if problems:
+ device = self.devices[scan_id]
+ problems.name = device.display_name
+ problems.uri=device.uri
+
+ self.addErrorLogMessage(problems=problems)
+
+ @pyqtSlot(int)
+ def copyfilesFinished(self, scan_id: int) -> None:
+ if scan_id in self.devices:
+ logging.debug("All files finished copying for %s", self.devices[scan_id].display_name)
+
+ @pyqtSlot(bool, RPDFile, int)
+ def fileRenamedAndMoved(self, move_succeeded: bool,
+ rpd_file: RPDFile,
+ download_count: int) -> None:
+ """
+ Called after a file has been renamed -- that is, moved from the
+ temp dir it was downloaded into, and renamed using the file
+ renaming rules
+ """
+
+ scan_id = rpd_file.scan_id
+
+ if scan_id not in self.devices:
+ logging.debug("Ignoring file %s because its device has been removed",
+ rpd_file.download_full_file_name or rpd_file.full_file_name)
+ return
+
+ if rpd_file.mdatatime_caused_ctime_change and scan_id not in \
+ self.thumbnailModel.ctimes_differ:
+ self.thumbnailModel.addCtimeDisparity(rpd_file=rpd_file)
+
+ if self.thumbnailModel.sendToDaemonThumbnailer(rpd_file=rpd_file):
+ if rpd_file.status in constants.Downloaded:
+ logging.debug("Assigning daemon thumbnailer to work on %s",
+ rpd_file.download_full_file_name)
+ self.sendDataMessageToThread(self.thumbnail_deamon_controller,
+ data=ThumbnailDaemonData(rpd_file=rpd_file,
+ write_fdo_thumbnail=self.prefs.save_fdo_thumbnails,
+ use_thumbnail_cache=self.prefs.use_thumbnail_cache))
+ else:
+ logging.debug('%s was not downloaded, so adjusting download tracking',
+ rpd_file.full_file_name)
+ self.download_tracker.thumbnail_generated_post_download(scan_id)
+
+ if rpd_file.status in constants.Downloaded and \
+ self.fileSystemModel.add_subfolder_downloaded_into(
+ path=rpd_file.download_path, download_folder=rpd_file.download_folder):
+ if rpd_file.file_type == FileType.photo:
+ self.photoDestinationFSView.expandPath(rpd_file.download_path)
+ self.photoDestinationFSView.update()
+ else:
+ self.videoDestinationFSView.expandPath(rpd_file.download_path)
+ self.videoDestinationFSView.update()
+
+ if rpd_file.status != DownloadStatus.downloaded:
+ pass
+ # self.addErrorLogMessage(rpd_file=rpd_file)
+
+ if self.prefs.backup_files:
+ if self.backup_devices.backup_possible(rpd_file.file_type):
+ self.backupFile(rpd_file, move_succeeded, download_count)
+ else:
+ self.fileDownloadFinished(move_succeeded, rpd_file)
+ else:
+ self.fileDownloadFinished(move_succeeded, rpd_file)
+
+ @pyqtSlot(RPDFile, QPixmap)
+ def thumbnailReceivedFromDaemon(self, rpd_file: RPDFile, thumbnail: QPixmap) -> None:
+ """
+ A thumbnail will be received directly from the daemon process when
+ it was able to get a thumbnail from the FreeDesktop.org 256x256
+ cache, and there was thus no need write another
+
+ :param rpd_file: rpd_file details of the file the thumbnail was
+ generated for
+ :param thumbnail: a thumbnail for display in the thumbnail view,
+ """
+
+ self.thumbnailModel.thumbnailReceived(rpd_file=rpd_file, thumbnail=thumbnail)
+
+ def thumbnailGeneratedPostDownload(self, rpd_file: RPDFile) -> None:
+ """
+ Adjust download tracking to note that a thumbnail was generated
+ after a file was downloaded. Possibly handle situation where
+ all files have been downloaded.
+
+ A thumbnail will be generated post download if
+ the sole task of the thumbnail extractors was to write out the
+ FreeDesktop.org thumbnails, and/or if we didn't generate it before
+ the download started.
+
+ :param rpd_file: details of the file
+ """
+
+ uid = rpd_file.uid
+ scan_id = rpd_file.scan_id
+ if self.prefs.backup_files and rpd_file.fdo_thumbnail_128_name:
+ self.generated_fdo_thumbnails[uid] = rpd_file.fdo_thumbnail_128_name
+ if uid in self.backup_fdo_thumbnail_cache:
+ self.sendDataMessageToThread(self.thumbnail_deamon_controller,
+ data=ThumbnailDaemonData(rpd_file=rpd_file,
+ write_fdo_thumbnail=True,
+ backup_full_file_names=self.backup_fdo_thumbnail_cache[uid],
+ fdo_name=rpd_file.fdo_thumbnail_128_name
+ ))
+ del self.backup_fdo_thumbnail_cache[uid]
+ self.download_tracker.thumbnail_generated_post_download(scan_id=scan_id)
+ completed, files_remaining = self.isDownloadCompleteForScan(scan_id)
+ if completed:
+ self.fileDownloadCompleteFromDevice(scan_id=scan_id, files_remaining=files_remaining)
+
+ def thumbnailGenerationStopped(self, scan_id: int) -> None:
+ """
+ Slot for when a the thumbnail worker has been forcefully stopped,
+ rather than merely finished in its work
+
+ :param scan_id: scan_id of the device that was being thumbnailed
+ """
+ if scan_id not in self.devices:
+ logging.debug("Ignoring scan_id %s from terminated thumbailing, as its device does "
+ "not exist anymore", scan_id)
+ else:
+ device = self.devices[scan_id]
+ if scan_id in self.devices.cameras_to_stop_thumbnailing:
+ self.devices.cameras_to_stop_thumbnailing.remove(scan_id)
+ logging.debug("Thumbnailing successfully terminated for %s", device.display_name)
+ if not self.devices.download_start_blocked():
+ self.startDownloadPhase2()
+ else:
+ logging.debug("Ignoring the termination of thumbnailing from %s, as it's "
+ "not for a camera from which a download was waiting to be started",
+ device.display_name)
+
+ @pyqtSlot(int, 'PyQt_PyObject')
+ def backupFileProblems(self, device_id: int, problems: BackingUpProblems) -> None:
+ for problem in self.backup_metadata_errors.problems(worker_id=device_id):
+ problems.append(problem)
+
+ if problems:
+ self.addErrorLogMessage(problems=problems)
+
+ def sendBackupStartFinishMessageToWorkers(self, message: BackupStatus) -> None:
+ if self.prefs.backup_files:
+ download_types = self.download_files.download_types
+ for path in self.backup_devices:
+ backup_type = self.backup_devices[path].backup_type
+ if ((backup_type == BackupLocationType.photos_and_videos or
+ download_types == DownloadingFileTypes.photos_and_videos) or
+ backup_type == download_types):
+ device_id = self.backup_devices.device_id(path)
+ data = BackupFileData(message=message)
+ self.sendDataMessageToThread(
+ self.backup_controller, worker_id=device_id, data=data
+ )
+
+ def backupFile(self, rpd_file: RPDFile, move_succeeded: bool, download_count: int) -> None:
+ if self.prefs.backup_device_autodetection:
+ if rpd_file.file_type == FileType.photo:
+ path_suffix = self.prefs.photo_backup_identifier
+ else:
+ path_suffix = self.prefs.video_backup_identifier
+ else:
+ path_suffix = None
+
+ if rpd_file.file_type == FileType.photo:
+ logging.debug("Backing up photo %s", rpd_file.download_name)
+ else:
+ logging.debug("Backing up video %s", rpd_file.download_name)
+
+ for path in self.backup_devices:
+ backup_type = self.backup_devices[path].backup_type
+ do_backup = (
+ (backup_type == BackupLocationType.photos_and_videos) or
+ (rpd_file.file_type == FileType.photo and backup_type ==
+ BackupLocationType.photos) or
+ (rpd_file.file_type == FileType.video and backup_type ==
+ BackupLocationType.videos))
+ if do_backup:
+ logging.debug("Backing up to %s", path)
+ else:
+ logging.debug("Not backing up to %s", path)
+ # Even if not going to backup to this device, need to send it
+ # anyway so progress bar can be updated. Not this most efficient
+ # but the code is more simpler
+ # TODO: investigate a more optimal approach!
+
+ device_id = self.backup_devices.device_id(path)
+ data = BackupFileData(rpd_file, move_succeeded, do_backup,
+ path_suffix,
+ self.prefs.backup_duplicate_overwrite,
+ self.prefs.verify_file, download_count,
+ self.prefs.save_fdo_thumbnails)
+ self.sendDataMessageToThread(self.backup_controller, worker_id=device_id, data=data)
+
+ @pyqtSlot(int, bool, bool, RPDFile, str, 'PyQt_PyObject')
+ def fileBackedUp(self, device_id: int,
+ backup_succeeded: bool,
+ do_backup: bool,
+ rpd_file: RPDFile,
+ backup_full_file_name: str,
+ mdata_exceptions: Optional[Tuple[Exception]]) -> None:
+
+ if do_backup:
+ if self.prefs.generate_thumbnails and self.prefs.save_fdo_thumbnails and \
+ rpd_file.should_write_fdo() and backup_succeeded:
+ self.backupGenerateFdoThumbnail(rpd_file=rpd_file,
+ backup_full_file_name=backup_full_file_name)
+
+ self.download_tracker.file_backed_up(rpd_file.scan_id, rpd_file.uid)
+
+ if mdata_exceptions is not None and self.prefs.warn_fs_metadata_error:
+ self.backup_metadata_errors.add_problem(
+ worker_id=device_id, path=backup_full_file_name,
+ mdata_exceptions=mdata_exceptions
+ )
+
+ if self.download_tracker.file_backed_up_to_all_locations(
+ rpd_file.uid, rpd_file.file_type):
+ logging.debug("File %s will not be backed up to any more locations",
+ rpd_file.download_name)
+ self.fileDownloadFinished(backup_succeeded, rpd_file)
+
+ @pyqtSlot('PyQt_PyObject', 'PyQt_PyObject')
+ def backupFileBytesBackedUp(self, scan_id: int, chunk_downloaded: int) -> None:
+ self.download_tracker.increment_bytes_backed_up(scan_id, chunk_downloaded)
+ self.time_check.increment(bytes_downloaded=chunk_downloaded)
+ self.time_remaining.update(scan_id, bytes_downloaded=chunk_downloaded)
+ self.updateFileDownloadDeviceProgress()
+
+ def initializeBackupThumbCache(self) -> None:
+ """
+ Prepare tracking of thumbnail generation for backed up files
+ """
+
+ # indexed by uid, deque of full backup paths
+ self.generated_fdo_thumbnails = dict() # type: Dict[str]
+ self.backup_fdo_thumbnail_cache = defaultdict(list) # type: Dict[List[str]]
+
+ def backupGenerateFdoThumbnail(self, rpd_file: RPDFile, backup_full_file_name: str) -> None:
+ uid = rpd_file.uid
+ if uid not in self.generated_fdo_thumbnails:
+ logging.debug("Caching FDO thumbnail creation for backup %s", backup_full_file_name)
+ self.backup_fdo_thumbnail_cache[uid].append(backup_full_file_name)
+ else:
+ # An FDO thumbnail has already been generated for the downloaded file
+ assert uid not in self.backup_fdo_thumbnail_cache
+ logging.debug("Assigning daemon thumbnailer to create FDO thumbnail for %s",
+ backup_full_file_name)
+ self.sendDataMessageToThread(self.thumbnail_deamon_controller,
+ data=ThumbnailDaemonData(rpd_file=rpd_file,
+ write_fdo_thumbnail=True,
+ backup_full_file_names=[backup_full_file_name],
+ fdo_name = self.generated_fdo_thumbnails[uid]))
+
+ @pyqtSlot(int, list)
+ def updateSequences(self, stored_sequence_no: int, downloads_today: List[str]) -> None:
+ """
+ Called at conclusion of a download, with values coming from
+ renameandmovefile process
+ """
+
+ self.prefs.stored_sequence_no = stored_sequence_no
+ self.prefs.downloads_today = downloads_today
+ self.prefs.sync()
+ logging.debug("Saved sequence values to preferences")
+ if self.application_state == ApplicationState.exiting:
+ self.close()
+ else:
+ self.renamePanel.updateSequences(downloads_today=downloads_today,
+ stored_sequence_no=stored_sequence_no)
+
+ @pyqtSlot()
+ def fileRenamedAndMovedFinished(self) -> None:
+ """Currently not called"""
+ pass
+
+ def isDownloadCompleteForScan(self, scan_id: int) -> Tuple[bool, int]:
+ """
+ Determine if all files have been downloaded and backed up for a device
+
+ :param scan_id: device's scan id
+ :return: True if the download is completed for that scan_id,
+ and the number of files remaining for the scan_id, BUT
+ the files remaining value is valid ONLY if the download is
+ completed
+ """
+
+ completed = self.download_tracker.all_files_downloaded_by_scan_id(scan_id)
+ if completed:
+ logging.debug("All files downloaded for %s", self.devices[scan_id].display_name)
+ if self.download_tracker.no_post_download_thumb_generation_by_scan_id[scan_id]:
+ logging.debug("Thumbnails generated for %s thus far during download: %s of %s",
+ self.devices[scan_id].display_name,
+ self.download_tracker.post_download_thumb_generation[scan_id],
+ self.download_tracker.no_post_download_thumb_generation_by_scan_id[scan_id])
+ completed = completed and \
+ self.download_tracker.all_post_download_thumbs_generated_for_scan(scan_id)
+
+ if completed and self.prefs.backup_files:
+ completed = self.download_tracker.all_files_backed_up(scan_id)
+
+ if completed:
+ files_remaining = self.thumbnailModel.getNoFilesRemaining(scan_id)
+ else:
+ files_remaining = 0
+
+ return completed, files_remaining
+
+ def updateFileDownloadDeviceProgress(self):
+ """
+ Updates progress bar and optionally the Unity progress bar
+ """
+
+ percent_complete = self.download_tracker.get_overall_percent_complete()
+ self.downloadProgressBar.setValue(round(percent_complete * 100))
+ if self.unity_progress:
+ for launcher in self.desktop_launchers:
+ launcher.set_property('progress', percent_complete)
+ launcher.set_property('progress_visible', True)
+
+ def fileDownloadFinished(self, succeeded: bool, rpd_file: RPDFile) -> None:
+ """
+ Called when a file has been downloaded i.e. copied, renamed,
+ and backed up
+ """
+ scan_id = rpd_file.scan_id
+
+ if self.prefs.move:
+ # record which files to automatically delete when download
+ # completes
+ self.download_tracker.add_to_auto_delete(rpd_file)
+
+ self.thumbnailModel.updateStatusPostDownload(rpd_file)
+ self.download_tracker.file_downloaded_increment(
+ scan_id, rpd_file.file_type, rpd_file.status
+ )
+
+ device = self.devices[scan_id]
+ device.download_statuses.add(rpd_file.status)
+
+ completed, files_remaining = self.isDownloadCompleteForScan(scan_id)
+ if completed:
+ self.fileDownloadCompleteFromDevice(scan_id=scan_id, files_remaining=files_remaining)
+
+ def fileDownloadCompleteFromDevice(self, scan_id: int, files_remaining: int) -> None:
+
+ device = self.devices[scan_id]
+
+ device_finished = files_remaining == 0
+ if device_finished:
+ logging.debug("All files from %s are downloaded; none remain", device.display_name)
+ state = DeviceState.finished
+ else:
+ logging.debug("Download finished from %s; %s remain be be potentially downloaded",
+ device.display_name, files_remaining)
+ state = DeviceState.idle
+
+ self.devices.set_device_state(scan_id=scan_id, state=state)
+ self.mapModel(scan_id).setSpinnerState(scan_id, state)
+
+ # Rebuild temporal proximity if it needs it
+ if scan_id in self.thumbnailModel.ctimes_differ and not \
+ self.thumbnailModel.filesRemainToDownload(scan_id=scan_id):
+ self.thumbnailModel.processCtimeDisparity(scan_id=scan_id)
+ self.folder_preview_manager.queue_folder_removal_for_device(scan_id=scan_id)
+
+ # Last file for this scan id has been downloaded, so clean temp
+ # directory
+ logging.debug("Purging temp directories")
+ self.cleanTempDirsForScanId(scan_id)
+ if self.prefs.move:
+ logging.debug("Deleting downloaded source files")
+ self.deleteSourceFiles(scan_id)
+ self.download_tracker.clear_auto_delete(scan_id)
+ self.updateProgressBarState()
+ self.thumbnailModel.updateDeviceDisplayCheckMark(scan_id=scan_id)
+
+ del self.time_remaining[scan_id]
+ self.notifyDownloadedFromDevice(scan_id)
+ if files_remaining == 0 and self.prefs.auto_unmount:
+ self.unmountVolume(scan_id)
+
+ if not self.downloadIsRunning():
+ logging.debug("Download completed")
+ self.dl_update_timer.stop()
+ self.enablePrefsAndRefresh(enabled=True)
+ self.notifyDownloadComplete()
+ self.downloadProgressBar.reset()
+ if self.prefs.backup_files:
+ self.initializeBackupThumbCache()
+ self.backupPanel.updateLocationCombos()
+
+ if self.unity_progress:
+ for launcher in self.desktop_launchers:
+ launcher.set_property('progress_visible', False)
+
+ self.folder_preview_manager.remove_folders_for_queued_devices()
+
+ # Update prefs with stored sequence number and downloads today
+ # values
+ data = RenameAndMoveFileData(message=RenameAndMoveStatus.download_completed)
+ self.sendDataMessageToThread(self.rename_controller, data=data)
+
+ # Ask backup processes to send problem reports
+ self.sendBackupStartFinishMessageToWorkers(message=BackupStatus.backup_completed)
+
+ if ((self.prefs.auto_exit and self.download_tracker.no_errors_or_warnings())
+ or self.prefs.auto_exit_force):
+ if not self.thumbnailModel.filesRemainToDownload():
+ logging.debug("Auto exit is initiated")
+ self.close()
+
+ self.download_tracker.purge_all()
+
+ self.setDownloadActionLabel()
+ self.setDownloadCapabilities()
+
+ self.download_start_datetime = None
+ self.download_start_time = None
+
+ @pyqtSlot('PyQt_PyObject')
+ def addErrorLogMessage(self, problems: Problems) -> None:
+
+ self.errorLog.addProblems(problems)
+ increment = len(problems)
+ if not self.errorLog.isActiveWindow():
+ self.errorsPending.incrementCounter(increment=increment)
+
+ def immediatelyDisplayDownloadRunningInStatusBar(self):
+ """
+ Without any delay, immediately change the status bar message so the
+ user knows the download has started.
+ """
+
+ self.statusBar().showMessage(self.devices.downloading_from())
+
+ @pyqtSlot()
+ def displayDownloadRunningInStatusBar(self):
+ """
+ Display a message in the status bar about the current download
+ """
+ if not self.downloadIsRunning():
+ self.dl_update_timer.stop()
+ self.displayMessageInStatusBar()
+ return
+
+ updated, download_speed = self.time_check.update_download_speed()
+ if updated:
+
+ downloading = self.devices.downloading_from()
+
+ time_remaining = self.time_remaining.time_remaining(self.prefs.detailed_time_remaining)
+ if (time_remaining is None or
+ time.time() < self.download_start_time + constants.ShowTimeAndSpeedDelay):
+ message = downloading
+ else:
+ # Translators - in the middle is a unicode em dash - please retain it
+ # This string is displayed in the status bar when the download is running
+ message = _('%(downloading_from)s — %(time_left)s left (%(speed)s)') % dict(
+ downloading_from = downloading, time_left=time_remaining, speed=download_speed)
+ self.statusBar().showMessage(message)
+
+ def enablePrefsAndRefresh(self, enabled: bool) -> None:
+ """
+ Disable the user being to access the refresh command or change various
+ program preferences while a download is occurring.
+
+ :param enabled: if True, then the user is able to activate the
+ preferences and refresh commands.
+ """
+
+ self.refreshAct.setEnabled(enabled)
+ self.preferencesAct.setEnabled(enabled)
+ self.renamePanel.setEnabled(enabled)
+ self.backupPanel.setEnabled(enabled)
+ self.jobCodePanel.setEnabled(enabled)
+
+ def unmountVolume(self, scan_id: int) -> None:
+ """
+ Cameras are already unmounted, so no need to unmount them!
+ :param scan_id: the scan id of the device to be umounted
+ """
+
+ device = self.devices[scan_id] # type: Device
+
+ if device.device_type == DeviceType.volume:
+ if self.gvfsControlsMounts:
+ self.gvolumeMonitor.unmountVolume(path=device.path)
+ else:
+ self.udisks2Unmount.emit(device.path)
+
+ def deleteSourceFiles(self, scan_id: int) -> None:
+ """
+ Delete files from download device at completion of download
+ """
+ # TODO delete from cameras and from other devices
+ # TODO should assign this to a process or a thread, and delete then
+ to_delete = self.download_tracker.get_files_to_auto_delete(scan_id)
+
+ def notifyDownloadedFromDevice(self, scan_id: int) -> None:
+ """
+ Display a system notification to the user using libnotify
+ that the files have been downloaded from the device
+ :param scan_id: identifies which device
+ """
+
+ device = self.devices[scan_id]
+
+ notification_name = device.display_name
+
+ no_photos_downloaded = self.download_tracker.get_no_files_downloaded(
+ scan_id, FileType.photo)
+ no_videos_downloaded = self.download_tracker.get_no_files_downloaded(
+ scan_id, FileType.video)
+ no_photos_failed = self.download_tracker.get_no_files_failed(
+ scan_id, FileType.photo)
+ no_videos_failed = self.download_tracker.get_no_files_failed(
+ scan_id, FileType.video)
+ no_files_downloaded = no_photos_downloaded + no_videos_downloaded
+ no_files_failed = no_photos_failed + no_videos_failed
+ no_warnings = self.download_tracker.get_no_warnings(scan_id)
+
+ file_types = file_types_by_number(no_photos_downloaded,
+ no_videos_downloaded)
+ file_types_failed = file_types_by_number(no_photos_failed,
+ no_videos_failed)
+ # Translators: e.g. 23 photos downloaded
+ message = _("%(noFiles)s %(filetypes)s downloaded") % {
+ 'noFiles': thousands(no_files_downloaded), 'filetypes': file_types}
+
+ if no_files_failed:
+ # Translators: e.g. 2 videos failed to download
+ message += "\n" + _("%(noFiles)s %(filetypes)s failed to download") % {
+ 'noFiles': thousands(no_files_failed),
+ 'filetypes': file_types_failed}
+
+ if no_warnings:
+ message = "%s\n%s " % (message, no_warnings) + _("warnings")
+
+ message_shown = False
+ if self.have_libnotify:
+ n = Notify.Notification.new(notification_name, message, 'rapid-photo-downloader')
+ try:
+ message_shown = n.show()
+ except:
+ logging.error("Unable to display downloaded from device message using notification "
+ "system")
+ if not message_shown:
+ logging.error("Unable to display downloaded from device message using notification "
+ "system")
+ logging.info("{}: {}".format(notification_name, message))
+
+ def notifyDownloadComplete(self) -> None:
+ """
+ Notify all downloads are complete
+
+ If having downloaded from more than one device, display a
+ system notification to the user using libnotify that all files
+ have been downloaded.
+
+ Regardless of how many downloads have been downloaded
+ from, display message in status bar.
+ """
+
+ show_notification = len(self.devices.have_downloaded_from) > 1
+
+ n_message = _("All downloads complete")
+
+ # photo downloads
+ photo_downloads = self.download_tracker.total_photos_downloaded
+ if photo_downloads and show_notification:
+ filetype = file_types_by_number(photo_downloads, 0)
+ # Translators: e.g. 23 photos downloaded
+ n_message += "\n" + _("%(number)s %(numberdownloaded)s") % dict(
+ number=thousands(photo_downloads),
+ numberdownloaded=_("%(filetype)s downloaded") % dict(
+ filetype=filetype))
+
+ # photo failures
+ photo_failures = self.download_tracker.total_photo_failures
+ if photo_failures and show_notification:
+ filetype = file_types_by_number(photo_failures, 0)
+ n_message += "\n" + _("%(number)s %(numberdownloaded)s") % dict(
+ number=thousands(photo_failures),
+ numberdownloaded=_("%(filetype)s failed to download") % dict(
+ filetype=filetype))
+
+ # video downloads
+ video_downloads = self.download_tracker.total_videos_downloaded
+ if video_downloads and show_notification:
+ filetype = file_types_by_number(0, video_downloads)
+ n_message += "\n" + _("%(number)s %(numberdownloaded)s") % dict(
+ number=thousands(video_downloads),
+ numberdownloaded=_("%(filetype)s downloaded") % dict(
+ filetype=filetype))
+
+ # video failures
+ video_failures = self.download_tracker.total_video_failures
+ if video_failures and show_notification:
+ filetype = file_types_by_number(0, video_failures)
+ n_message += "\n" + _("%(number)s %(numberdownloaded)s") % dict(
+ number=thousands(video_failures),
+ numberdownloaded=_("%(filetype)s failed to download") % dict(
+ filetype=filetype))
+
+ # warnings
+ warnings = self.download_tracker.total_warnings
+ if warnings and show_notification:
+ n_message += "\n" + _("%(number)s %(numberdownloaded)s") % dict(
+ number=thousands(warnings),
+ numberdownloaded=_("warnings"))
+
+ if show_notification:
+ message_shown = False
+ if self.have_libnotify:
+ n = Notify.Notification.new(_('Rapid Photo Downloader'), n_message,
+ 'rapid-photo-downloader')
+ try:
+ message_shown = n.show()
+ except:
+ logging.error("Unable to display download complete message using notification "
+ "system")
+ if not message_shown:
+ logging.error("Unable to display download complete message using notification "
+ "system")
+
+ failures = photo_failures + video_failures
+
+ if failures == 1:
+ f = _('1 failure')
+ elif failures > 1:
+ f = _('%d failures') % failures
+ else:
+ f = ''
+
+ if warnings == 1:
+ w = _('1 warning')
+ elif warnings > 1:
+ w = _('%d warnings') % warnings
+ else:
+ w = ''
+
+ if f and w:
+ fw = make_internationalized_list((f, w))
+ elif f:
+ fw = f
+ elif w:
+ fw = w
+ else:
+ fw = ''
+
+ devices = self.devices.reset_and_return_have_downloaded_from()
+ if photo_downloads + video_downloads:
+ ftc = FileTypeCounter(
+ {FileType.photo: photo_downloads, FileType.video: video_downloads})
+ no_files_and_types = ftc.file_types_present_details().lower()
+
+ if not fw:
+ downloaded = _('Downloaded %(no_files_and_types)s from %(devices)s') % dict(
+ no_files_and_types=no_files_and_types, devices=devices)
+ else:
+ downloaded = _('Downloaded %(no_files_and_types)s from %(devices)s — %(failures)s')\
+ % dict(no_files_and_types=no_files_and_types,
+ devices=devices, failures=fw)
+ else:
+ if fw:
+ downloaded = _('No files downloaded — %(failures)s') % dict(failures=fw)
+ else:
+ downloaded = _('No files downloaded')
+ logging.info('%s', downloaded)
+ self.statusBar().showMessage(downloaded)
+
+ def notifyFoldersProximityRebuild(self, scan_id) -> None:
+ """
+ Inform the user that a timeline rebuild and folder preview update is pending,
+ taking into account they may have already been notified.
+ """
+
+ if self.have_libnotify:
+ device = self.devices[scan_id]
+ notification_devices = self.thumbnailModel.ctimes_differ
+
+ logging.info("Need to rebuild timeline and subfolder previews for %s",
+ device.display_name)
+
+ simple_message = len(notification_devices) == 1
+
+ this_computer = len([scan_id for scan_id in notification_devices
+ if self.devices[scan_id].device_type == DeviceType.path]) > 0
+
+ if simple_message:
+ if device.device_type == DeviceType.camera:
+ message = _("The Destination subfolders and Timeline will be rebuilt after "
+ "all thumbnails have been generated for the %(camera)s"
+ ) % dict(camera=device.display_name)
+ elif this_computer:
+ message = _("The Destination subfolders and Timeline will be rebuilt after "
+ "all thumbnails have been generated for this computer")
+ else:
+ message = _("The Destination subfolders and Timeline will be rebuilt after "
+ "all thumbnails have been generated for %(device)s"
+ ) % dict(device=device.display_name)
+ else:
+ no_devices = len(notification_devices)
+ if this_computer:
+ no_devices -= 1
+ if no_devices > 1:
+ message = _("The Destination subfolders and Timeline will be rebuilt "
+ "after all thumbnails have been generated for "
+ "%(number_devices)s devices and this computer"
+ ) % dict(number_devices=no_devices)
+ else:
+ assert no_devices == 1
+ if device.device_type != DeviceType.path:
+ other_device = device
+ else:
+ # the other device must be the first one
+ other_device = self.devices[notification_devices[0]]
+ name = other_device.display_name
+ if other_device.device_type == DeviceType.camera:
+ message = _("The Destination subfolders and Timeline will be rebuilt "
+ "after all thumbnails have been generated for the "
+ "%(camera)s and this computer") % dict(camera=name)
+ else:
+ message = _("The Destination subfolders and Timeline will be rebuilt "
+ "after all thumbnails have been generated for "
+ "%(device)s and this computer") % dict(device=name)
+ else:
+ message = _("The Destination subfolders and Timeline will be rebuilt "
+ "after all thumbnails have been generated for "
+ "%(number_devices)s devices") % dict(number_devices=no_devices)
+
+ if self.ctime_update_notification is None:
+ notify = Notify.Notification.new(_('Rapid Photo Downloader'), message,
+ 'rapid-photo-downloader')
+ else:
+ notify = self.ctime_update_notification
+ notify.update(_('Rapid Photo Downloader'), message, 'rapid-photo-downloader')
+ try:
+ message_shown = notify.show()
+ if message_shown:
+ self.ctime_notification_issued = True
+ notify.connect('closed', self.notificationFoldersProximityRefreshClosed)
+ except:
+ logging.error("Unable to display message using notification system")
+ self.ctime_update_notification = notify
+
+ def notifyFoldersProximityRebuilt(self) -> None:
+ """
+ Inform the user that the the refresh has occurred, updating the existing
+ message if need be.
+ """
+
+ if self.have_libnotify:
+ message = _("The Destination subfolders and Timeline have been rebuilt")
+
+ if self.ctime_update_notification is None:
+ notify = Notify.Notification.new(_('Rapid Photo Downloader'), message,
+ 'rapid-photo-downloader')
+ else:
+ notify = self.ctime_update_notification
+ notify.update(_('Rapid Photo Downloader'), message, 'rapid-photo-downloader')
+ try:
+ message_shown = notify.show()
+ except:
+ logging.error("Unable to display message using notification system")
+
+ self.ctime_update_notification = None
+
+ def notificationFoldersProximityRefreshClosed(self, notify: Notify.Notification) -> None:
+ """
+ Delete our reference to the notification that was used to inform the user
+ that the timeline and preview folders will be. If it's not deleted, there will
+ be glib problems at program exit, when the reference is deleted.
+ :param notify: the notification itself
+ """
+
+ self.ctime_update_notification = None
+
+ def invalidDownloadFolders(self, downloading: DownloadingFileTypes) -> List[str]:
+ """
+ Checks validity of download folders based on the file types the
+ user is attempting to download.
+
+ :return list of the invalid directories, if any, or empty list.
+ """
+
+ invalid_dirs = []
+
+ # sadly this causes an exception on python 3.4:
+ # downloading.photos or downloading.photos_and_videos
+
+ if downloading in (DownloadingFileTypes.photos, DownloadingFileTypes.photos_and_videos):
+ if not validate_download_folder(self.prefs.photo_download_folder).valid:
+ invalid_dirs.append(self.prefs.photo_download_folder)
+ if downloading in (DownloadingFileTypes.videos, DownloadingFileTypes.photos_and_videos):
+ if not validate_download_folder(self.prefs.video_download_folder).valid:
+ invalid_dirs.append(self.prefs.video_download_folder)
+ return invalid_dirs
+
+ def notifyPrefsAreInvalid(self, details: str) -> None:
+ """
+ Notifies the user that the preferences are invalid.
+
+ Assumes that the main window is already showing
+ :param details: preference error details
+ """
+
+ logging.error("Program preferences are invalid: %s", details)
+ title = _("Program preferences are invalid")
+ message = "<b>%(title)s</b><br><br>%(details)s" % dict(title=title, details=details)
+ msgBox = self.standardMessageBox(message=message, rich_text=True)
+ msgBox.exec()
+
+ def deviceState(self, scan_id: int) -> DeviceState:
+ """
+ What the device is being used for at the present moment.
+
+ :param scan_id: device to check
+ :return: DeviceState
+ """
+
+ return self.devices.device_state[scan_id]
+
+ @pyqtSlot('PyQt_PyObject', 'PyQt_PyObject', FileTypeCounter, 'PyQt_PyObject')
+ def scanFilesReceived(self, rpd_files: List[RPDFile],
+ sample_files: List[RPDFile],
+ file_type_counter: FileTypeCounter,
+ file_size_sum: int) -> None:
+ """
+ Process scanned file information received from the scan process
+ """
+
+ # Update scan running totals
+ scan_id = rpd_files[0].scan_id
+ if scan_id not in self.devices:
+ return
+ device = self.devices[scan_id]
+
+ sample_photo, sample_video = sample_files
+ if sample_photo is not None:
+ logging.info("Updating example file name using sample photo from %s",
+ device.display_name)
+ self.devices.sample_photo = sample_photo
+ self.renamePanel.setSamplePhoto(self.devices.sample_photo)
+ # sample required for editing download subfolder generation
+ self.photoDestinationDisplay.sample_rpd_file = self.devices.sample_photo
+
+ if sample_video is not None:
+ logging.info("Updating example file name using sample video from %s",
+ device.display_name)
+ self.devices.sample_video = sample_video # type: Video
+ self.renamePanel.setSampleVideo(self.devices.sample_video)
+ # sample required for editing download subfolder generation
+ self.videoDestinationDisplay.sample_rpd_file = self.devices.sample_video
+
+ device.file_type_counter = file_type_counter
+ device.file_size_sum = file_size_sum
+ self.mapModel(scan_id).updateDeviceScan(scan_id)
+
+ self.thumbnailModel.addFiles(scan_id=scan_id,
+ rpd_files=rpd_files,
+ generate_thumbnail=not self.autoStart(scan_id))
+ self.folder_preview_manager.add_rpd_files(rpd_files=rpd_files)
+
+ @pyqtSlot(int, CameraErrorCode)
+ def scanErrorReceived(self, scan_id: int, error_code: CameraErrorCode) -> None:
+ """
+ Notify the user their camera/phone is inaccessible.
+
+ :param scan_id: scan id of the device
+ :param error_code: the specific libgphoto2 error, mapped onto our own
+ enum
+ """
+
+ if scan_id not in self.devices:
+ return
+
+ # During program startup, the main window may not yet be showing
+ self.showMainWindow()
+
+ # An error occurred
+ device = self.devices[scan_id]
+ camera_model = device.display_name
+ if error_code == CameraErrorCode.locked:
+ title =_('Rapid Photo Downloader')
+ message = _('<b>All files on the %(camera)s are inaccessible</b>.<br><br>It '
+ 'may be locked or not configured for file transfers using MTP. '
+ 'You can unlock it and try again.<br><br>On some models you also '
+ 'need to change the setting <i>USB for charging</i> to <i>USB for '
+ 'file transfers</i>.<br><br>Alternatively, you can ignore this '
+ 'device.') % {'camera': camera_model}
+ else:
+ assert error_code == CameraErrorCode.inaccessible
+ title = _('Rapid Photo Downloader')
+ message = _('<b>The %(camera)s appears to be in use by another '
+ 'application.</b><br><br>You '
+ 'can close any other application (such as a file browser) that is '
+ 'using it and try again. If that '
+ 'does not work, unplug the %(camera)s from the computer and plug '
+ 'it in again.<br><br>Alternatively, you can ignore '
+ 'this device.') % {'camera':camera_model}
+
+ msgBox = QMessageBox(QMessageBox.Warning, title, message,
+ QMessageBox.NoButton, self)
+ msgBox.setIconPixmap(self.devices[scan_id].get_pixmap())
+ msgBox.addButton(_("&Try Again"), QMessageBox.AcceptRole)
+ msgBox.addButton(_("&Ignore This Device"), QMessageBox.RejectRole)
+ self.prompting_for_user_action[device] = msgBox
+ role = msgBox.exec_()
+ if role == QMessageBox.AcceptRole:
+ self.sendResumeToThread(self.scan_controller, worker_id=scan_id)
+ else:
+ self.removeDevice(scan_id=scan_id, show_warning=False)
+ del self.prompting_for_user_action[device]
+
+ @pyqtSlot(int, 'PyQt_PyObject', 'PyQt_PyObject', str)
+ def scanDeviceDetailsReceived(self, scan_id: int,
+ storage_space: List[StorageSpace],
+ storage_descriptions: List[str],
+ optimal_display_name: str) -> None:
+ """
+ Update GUI display and rows DB with definitive camera display name
+
+ :param scan_id: scan id of the device
+ :param storage_space: storage information on the device e.g.
+ memory card(s) capacity and use
+ :param storage_desctriptions: names of storage on a camera
+ :param optimal_display_name: canonical name of the device, as
+ reported by libgphoto2
+ """
+
+ if scan_id in self.devices:
+ device = self.devices[scan_id]
+ logging.debug('%s with scan id %s is now known as %s',
+ device.display_name, scan_id, optimal_display_name)
+
+ if len(storage_space) > 1:
+ logging.debug(
+ '%s has %s storage devices', optimal_display_name, len(storage_space)
+ )
+
+ if not storage_descriptions:
+ logging.warning("No storage descriptors available for %s", optimal_display_name)
+ else:
+ if len(storage_descriptions) == 1:
+ msg = 'description'
+ else:
+ msg = 'descriptions'
+ logging.debug("Storage %s: %s", msg, ', '.join(storage_descriptions))
+
+ device.update_camera_attributes(
+ display_name=optimal_display_name, storage_space=storage_space,
+ storage_descriptions=storage_descriptions
+ )
+ self.updateSourceButton()
+ self.deviceModel.updateDeviceNameAndStorage(scan_id, device)
+ self.thumbnailModel.addOrUpdateDevice(scan_id=scan_id)
+ self.adjustLeftPanelSliderHandles()
+ else:
+ logging.debug(
+ "Ignoring optimal display name %s and other details because that device was "
+ "removed", optimal_display_name
+ )
+
+ @pyqtSlot(int, 'PyQt_PyObject')
+ def scanProblemsReceived(self, scan_id: int, problems: Problems) -> None:
+ self.addErrorLogMessage(problems=problems)
+
+ @pyqtSlot(int)
+ def scanFatalError(self, scan_id: int) -> None:
+ device = self.devices[scan_id]
+
+ h1 = _('Sorry, an unexpected problem occurred while scanning %s.') % device.display_name
+ h2 = _('Unfortunately you cannot download from this device.')
+ header = '<b>{}</b><br><br>{}'.format(h1, h2)
+ if device.device_type == DeviceType.camera and not device.is_mtp_device:
+ h3 = _(
+ "A possible workaround for the problem might be downloading from the camera's "
+ "memory card using a card reader."
+ )
+ header = '{}<br><br><i>{}</i>'.format(header, h3)
+
+ title = _('Device scan failed')
+ self.makeProblemReportDialog(header=header, title=title)
+
+ self.removeDevice(scan_id=scan_id, show_warning=False)
+
+ @pyqtSlot(int)
+ def scanFinished(self, scan_id: int) -> None:
+ """
+ A single device has finished its scan. Other devices can be in any
+ one of a number of states.
+
+ :param scan_id: scan id of the device that finished scanning
+ """
+
+ if scan_id not in self.devices:
+ return
+ device = self.devices[scan_id]
+ self.devices.set_device_state(scan_id, DeviceState.idle)
+ self.thumbnailModel.flushAddBuffer()
+
+ self.updateProgressBarState()
+ self.thumbnailModel.updateAllDeviceDisplayCheckMarks()
+ results_summary, file_types_present = device.file_type_counter.summarize_file_count()
+ self.download_tracker.set_file_types_present(scan_id, file_types_present)
+ model = self.mapModel(scan_id)
+ model.updateDeviceScan(scan_id)
+ destinations_good = self.setDownloadCapabilities()
+
+ self.logState()
+
+ if len(self.devices.scanning) == 0:
+ self.generateTemporalProximityTableData("a download source has finished being scanned")
+ else:
+ self.temporalProximity.setState(TemporalProximityState.pending)
+
+ if not destinations_good:
+ auto_start = False
+ else:
+ auto_start = self.autoStart(scan_id)
+
+ if not auto_start and self.prefs.generate_thumbnails:
+ # Generate thumbnails for finished scan
+ model.setSpinnerState(scan_id, DeviceState.idle)
+ if scan_id in self.thumbnailModel.no_thumbnails_by_scan:
+ self.devices.set_device_state(scan_id, DeviceState.thumbnailing)
+ self.updateProgressBarState()
+ self.thumbnailModel.generateThumbnails(scan_id, self.devices[scan_id])
+ self.displayMessageInStatusBar()
+ elif auto_start:
+ self.displayMessageInStatusBar()
+ if self.jobCodePanel.needToPromptForJobCode():
+ model.setSpinnerState(scan_id, DeviceState.idle)
+ start_download = self.jobCodePanel.getJobCodeBeforeDownload()
+ if not start_download:
+ logging.debug("Not auto-starting download, because a job code is already "
+ "being prompted for.")
+ else:
+ start_download = True
+ if start_download:
+ if self.download_paused:
+ self.devices.queued_to_download.add(scan_id)
+ else:
+ self.startDownload(scan_id=scan_id)
+ else:
+ # not generating thumbnails, and auto start is not on
+ model.setSpinnerState(scan_id, DeviceState.idle)
+ self.displayMessageInStatusBar()
+
+ def autoStart(self, scan_id: int) -> bool:
+ """
+ Determine if the download for this device should start automatically
+ :param scan_id: scan id of the device
+ :return: True if the should start automatically, else False,
+ """
+
+ prefs_valid, msg = self.prefs.check_prefs_for_validity()
+ if not prefs_valid:
+ return False
+
+ if not self.thumbnailModel.filesAreMarkedForDownload(scan_id):
+ logging.debug("No files are marked for download for %s",
+ self.devices[scan_id].display_name)
+ return False
+
+ if scan_id in self.devices.startup_devices:
+ return self.prefs.auto_download_at_startup
+ else:
+ return self.prefs.auto_download_upon_device_insertion
+
+ def quit(self) -> None:
+ """
+ Convenience function to quit the application.
+
+ Issues a signal to initiate the quit. The signal will be acted
+ on when Qt gets the chance.
+ """
+
+ QTimer.singleShot(0, self.close)
+
+ def generateTemporalProximityTableData(self, reason: str) -> None:
+ """
+ Initiate Timeline generation if it's right to do so
+ """
+
+ if len(self.devices.scanning):
+ logging.info("Was tasked to generate Timeline because %s, but ignoring request "
+ "because a scan is occurring", reason)
+ return
+
+ if self.temporalProximity.state == TemporalProximityState.ctime_rebuild:
+ logging.info("Was tasked to generate Timeline because %s, but ignoring request "
+ "because a rebuild is required ", reason)
+ return
+
+ rows = self.thumbnailModel.dataForProximityGeneration()
+ if rows:
+ logging.info("Generating Timeline because %s", reason)
+
+ self.temporalProximity.setState(TemporalProximityState.generating)
+ data = OffloadData(thumbnail_rows=rows, proximity_seconds=self.prefs.proximity_seconds)
+ self.sendToOffload(data=data)
+ else:
+ logging.info("Was tasked to generate Timeline because %s, but there is nothing to "
+ "generate", reason)
+
+ @pyqtSlot(TemporalProximityGroups)
+ def proximityGroupsGenerated(self, proximity_groups: TemporalProximityGroups) -> None:
+ if self.temporalProximity.setGroups(proximity_groups=proximity_groups):
+ self.thumbnailModel.assignProximityGroups(proximity_groups.col1_col2_uid)
+ if self.ctime_notification_issued:
+ self.notifyFoldersProximityRebuilt()
+ self.ctime_notification_issued = False
+
+ def closeEvent(self, event) -> None:
+ logging.debug("Close event activated")
+
+ if self.close_event_run:
+ logging.debug("Close event already run: accepting close event")
+ event.accept()
+ return
+
+ if self.application_state == ApplicationState.normal:
+ self.application_state = ApplicationState.exiting
+ self.sendStopToThread(self.scan_controller)
+ self.thumbnailModel.stopThumbnailer()
+ self.sendStopToThread(self.copy_controller)
+
+ if self.downloadIsRunning():
+ logging.debug("Exiting while download is running. Cleaning up...")
+ # Update prefs with stored sequence number and downloads today
+ # values
+ data = RenameAndMoveFileData(message=RenameAndMoveStatus.download_completed)
+ self.sendDataMessageToThread(self.rename_controller, data=data)
+ # renameandmovefile process will send a message with the
+ # updated sequence values. When that occurs,
+ # this application will save the sequence values to the
+ # program preferences, resume closing and this close event
+ # will again be called, but this time the application state
+ # flag will indicate the need to resume below.
+ logging.debug("Ignoring close event")
+ event.ignore()
+ return
+ # Incidentally, it's the renameandmovefile process that
+ # updates the SQL database with the file downloads,
+ # so no need to update or close it in this main process
+
+ self.writeWindowSettings()
+ logging.debug("Cleaning up provisional download folders")
+ self.folder_preview_manager.remove_preview_folders()
+
+ # write settings before closing error log window
+ self.errorLog.done(0)
+
+ logging.debug("Terminating main ExifTool process")
+ self.exiftool_process.terminate()
+
+ if self.ctime_update_notification is not None:
+ self.ctime_update_notification = None
+
+ self.sendStopToThread(self.offload_controller)
+ self.offloadThread.quit()
+ if not self.offloadThread.wait(500):
+ self.sendTerminateToThread(self.offload_controller)
+
+ self.sendStopToThread(self.rename_controller)
+ self.renameThread.quit()
+ if not self.renameThread.wait(500):
+ self.sendTerminateToThread(self.rename_controller)
+
+ self.scanThread.quit()
+ if not self.scanThread.wait(2000):
+ self.sendTerminateToThread(self.scan_controller)
+
+ self.copyfilesThread.quit()
+ if not self.copyfilesThread.wait(1000):
+ self.sendTerminateToThread(self.copy_controller)
+
+ self.sendStopToThread(self.backup_controller)
+ self.backupThread.quit()
+ if not self.backupThread.wait(1000):
+ self.sendTerminateToThread(self.backup_controller)
+
+ if not self.gvfsControlsMounts:
+ self.udisks2MonitorThread.quit()
+ self.udisks2MonitorThread.wait()
+ self.cameraHotplugThread.quit()
+ self.cameraHotplugThread.wait()
+ else:
+ del self.gvolumeMonitor
+
+ self.newVersionThread.quit()
+ self.newVersionThread.wait(100)
+
+ self.sendStopToThread(self.thumbnail_deamon_controller)
+ self.thumbnaildaemonmqThread.quit()
+ if not self.thumbnaildaemonmqThread.wait(2000):
+ self.sendTerminateToThread(self.thumbnail_deamon_controller)
+
+ # Tell logging thread to stop: uses slightly different approach
+ # than other threads
+ stop_process_logging_manager(info_port=self.logging_port)
+ self.loggermqThread.quit()
+ self.loggermqThread.wait()
+
+ self.watchedDownloadDirs.closeWatch()
+
+ self.cleanAllTempDirs()
+ logging.debug("Cleaning any device cache dirs and sample video")
+ self.devices.delete_cache_dirs_and_sample_video()
+ tc = ThumbnailCacheSql()
+ logging.debug("Cleaning up Thumbnail cache")
+ tc.cleanup_cache(days=self.prefs.keep_thumbnails_days)
+
+ Notify.uninit()
+
+ self.close_event_run = True
+
+ logging.debug("Accepting close event")
+ event.accept()
+
+ def getIconsAndEjectableForMount(self, mount: QStorageInfo) -> Tuple[List[str], bool]:
+ """
+ Given a mount, get the icon names suggested by udev or
+ GVFS, and determine whether the mount is ejectable or not.
+ :param mount: the mount to check
+ :return: icon names and eject boolean
+ :rtype Tuple[str, bool]
+ """
+ if self.gvfsControlsMounts:
+ iconNames, canEject = self.gvolumeMonitor.getProps(
+ mount.rootPath())
+ else:
+ # get the system device e.g. /dev/sdc1
+ systemDevice = bytes(mount.device()).decode()
+ iconNames, canEject = self.udisks2Monitor.get_device_props(
+ systemDevice)
+ return (iconNames, canEject)
+
+ def addToDeviceDisplay(self, device: Device, scan_id: int) -> None:
+ self.mapModel(scan_id).addDevice(scan_id, device)
+ self.adjustLeftPanelSliderHandles()
+ # Resize the "This Computer" view after a device has been added
+ # If not done, the widget geometry will not be updated to reflect
+ # the new view.
+ if device.device_type == DeviceType.path:
+ self.thisComputerView.updateGeometry()
+
+ @pyqtSlot()
+ def cameraAdded(self) -> None:
+ if not self.prefs.device_autodetection:
+ logging.debug("Ignoring camera as device auto detection is off")
+ else:
+ logging.debug("Assuming camera will not be mounted: immediately proceeding with scan")
+ self.searchForCameras()
+
+ @pyqtSlot()
+ def cameraRemoved(self) -> None:
+ """
+ Handle the possible removal of a camera by comparing the
+ cameras the OS knows about compared to the cameras we are
+ tracking. Remove tracked cameras if they are not on the OS.
+
+ We need this brute force method because I don't know if it's
+ possible to query GIO or udev to return the info needed by
+ libgphoto2
+ """
+
+ sc = self.gp_context.camera_autodetect()
+ system_cameras = ((model, port) for model, port in sc if not
+ port.startswith('disk:'))
+ kc = self.devices.cameras.items()
+ known_cameras = ((model, port) for port, model in kc)
+ removed_cameras = set(known_cameras) - set(system_cameras)
+ for model, port in removed_cameras:
+ scan_id = self.devices.scan_id_from_camera_model_port(model, port)
+ device = self.devices[scan_id]
+ # Don't log a warning when the camera was removed while the user was being
+ # informed it was locked or inaccessible
+ show_warning = not device in self.prompting_for_user_action
+ self.removeDevice(scan_id=scan_id, show_warning=show_warning)
+
+ if removed_cameras:
+ self.setDownloadCapabilities()
+
+ @pyqtSlot()
+ def noGVFSAutoMount(self) -> None:
+ """
+ In Gnome like environment we rely on Gnome automatically
+ mounting cameras and devices with file systems. But sometimes
+ it will not automatically mount them, for whatever reason.
+ Try to handle those cases.
+ """
+ #TODO Implement noGVFSAutoMount()
+ # however, I have no idea under what circumstances it is called
+ logging.error("Implement noGVFSAutoMount()")
+
+ @pyqtSlot()
+ def cameraMounted(self):
+ if have_gio:
+ self.searchForCameras()
+
+ def unmountCameraToEnableScan(self, model: str,
+ port: str,
+ on_startup: bool) -> bool:
+ """
+ Possibly "unmount" a camera or phone controlled by GVFS so it can be scanned
+
+ :param model: camera model
+ :param port: port used by camera
+ :param on_startup: if True, the unmount is occurring during
+ the program's startup phase
+ :return: True if unmount operation initiated, else False
+ """
+
+ if self.gvfsControlsMounts:
+ self.devices.cameras_to_gvfs_unmount_for_scan[port] = model
+ if self.gvolumeMonitor.unmountCamera(model=model, port=port, on_startup=on_startup):
+ return True
+ else:
+ del self.devices.cameras_to_gvfs_unmount_for_scan[port]
+ return False
+
+ @pyqtSlot(bool, str, str, bool, bool)
+ def cameraUnmounted(self, result: bool,
+ model: str,
+ port: str,
+ download_started: bool,
+ on_startup: bool) -> None:
+ """
+ Handle the attempt to unmount a GVFS mounted camera.
+
+ Note: cameras that have not yet been scanned do not yet have a scan_id assigned!
+ An obvious point, but easy to forget.
+
+ :param result: result from the GVFS operation
+ :param model: camera model
+ :param port: camera port
+ :param download_started: whether the unmount happened because a download
+ was initiated
+ :param on_startup: if the unmount happened on a device during program startup
+ """
+
+ if not download_started:
+ assert self.devices.cameras_to_gvfs_unmount_for_scan[port] == model
+ del self.devices.cameras_to_gvfs_unmount_for_scan[port]
+ if result:
+ self.startCameraScan(model=model, port=port, on_startup=on_startup)
+ else:
+ # Get the camera's short model name, instead of using the exceptionally
+ # long name that gphoto2 can sometimes use. Get the icon too.
+ camera = Device()
+ camera.set_download_from_camera(model, port)
+
+ logging.debug("Not scanning %s because it could not be unmounted",
+ camera.display_name)
+
+ message = _('<b>The %(camera)s cannot be scanned because it cannot be '
+ 'unmounted.</b><br><br>You can close any other application (such as a '
+ 'file browser) that is using it and try again. If that does not work, '
+ 'unplug the %(camera)s from the computer and plug it in again.') \
+ % dict(camera=camera.display_name)
+
+ # Show the main window if it's not yet visible
+ self.showMainWindow()
+ msgBox = self.standardMessageBox(message=message, rich_text=True)
+ msgBox.setIconPixmap(camera.get_pixmap())
+ msgBox.exec()
+ else:
+ # A download was initiated
+
+ scan_id = self.devices.scan_id_from_camera_model_port(model, port)
+ self.devices.cameras_to_gvfs_unmount_for_download.remove(scan_id)
+ if result:
+ if not self.devices.download_start_blocked():
+ self.startDownloadPhase2()
+ else:
+ camera = self.devices[scan_id]
+ display_name = camera.display_name
+
+ title = _('Rapid Photo Downloader')
+ message = _('<b>The download cannot start because the %(camera)s cannot be '
+ 'unmounted.</b><br><br>You '
+ 'can close any other application (such as a file browser) that is '
+ 'using it and try again. If that '
+ 'does not work, unplug the %(camera)s from the computer and plug '
+ 'it in again, and choose which files you want to download from it.') \
+ % dict(camera=display_name)
+ msgBox = QMessageBox(QMessageBox.Warning, title, message, QMessageBox.Ok)
+ msgBox.setIconPixmap(camera.get_pixmap())
+ msgBox.exec_()
+
+ def searchForCameras(self, on_startup: bool=False) -> None:
+ """
+ Detect using gphoto2 any cameras attached to the computer.
+
+ Initiates unmount of cameras that are mounted by GIO/GVFS.
+
+ :param on_startup: if True, the search is occurring during
+ the program's startup phase
+ """
+
+ if self.prefs.device_autodetection:
+ cameras = self.gp_context.camera_autodetect()
+ for model, port in cameras:
+ if port in self.devices.cameras_to_gvfs_unmount_for_scan:
+ assert self.devices.cameras_to_gvfs_unmount_for_scan[port] == model
+ logging.debug("Already unmounting %s", model)
+ elif self.devices.known_camera(model, port):
+ logging.debug("Camera %s is known", model)
+ elif self.devices.user_marked_camera_as_ignored(model, port):
+ logging.debug("Ignoring camera marked as removed by user %s", model)
+ elif not port.startswith('disk:'):
+ device = Device()
+ device.set_download_from_camera(model, port)
+ if device.udev_name in self.prefs.camera_blacklist:
+ logging.debug("Ignoring blacklisted camera %s", model)
+ else:
+ logging.debug("Detected %s on port %s", model, port)
+ # almost always, libgphoto2 cannot access a camera when
+ # it is mounted by another process, like Gnome's GVFS
+ # or any other system. Before attempting to scan the
+ # camera, check to see if it's mounted and if so,
+ # unmount it. Unmounting is asynchronous.
+ if not self.unmountCameraToEnableScan(
+ model=model, port=port, on_startup=on_startup
+ ):
+ self.startCameraScan(model=model, port=port, on_startup=on_startup)
+
+ def startCameraScan(self, model: str,
+ port: str,
+ on_startup: bool=False) -> None:
+ """
+ Initiate the scan of an unmounted camera
+
+ :param model: camera model
+ :param port: camera port
+ :param on_startup: if True, the scan is occurring during
+ the program's startup phase
+ """
+
+ device = Device()
+ device.set_download_from_camera(model, port)
+ self.startDeviceScan(device=device, on_startup=on_startup)
+
+ def startDeviceScan(self, device: Device, on_startup: bool=False) -> None:
+ """
+ Initiate the scan of a device (camera, this computer path, or external device)
+
+ :param device: device to scan
+ :param on_startup: if True, the scan is occurring during
+ the program's startup phase
+ """
+
+ scan_id = self.devices.add_device(device=device, on_startup=on_startup)
+ logging.debug("Assigning scan id %s to %s", scan_id, device.name())
+ self.thumbnailModel.addOrUpdateDevice(scan_id)
+ self.addToDeviceDisplay(device, scan_id)
+ self.updateSourceButton()
+ scan_arguments = ScanArguments(
+ device=device,
+ ignore_other_types=self.ignore_other_photo_types,
+ log_gphoto2=self.log_gphoto2,
+ )
+ self.sendStartWorkerToThread(self.scan_controller, worker_id=scan_id, data=scan_arguments)
+ self.devices.set_device_state(scan_id, DeviceState.scanning)
+ self.setDownloadCapabilities()
+ self.updateProgressBarState()
+ self.displayMessageInStatusBar()
+
+ def partitionValid(self, mount: QStorageInfo) -> bool:
+ """
+ A valid partition is one that is:
+ 1) available
+ 2) if devices without DCIM folders are to be scanned (e.g.
+ Portable Storage Devices), then the path should not be
+ blacklisted
+ :param mount: the mount point to check
+ :return: True if valid, False otherwise
+ """
+ if mount.isValid() and mount.isReady():
+ if mount.displayName() in self.prefs.volume_blacklist:
+ logging.info("blacklisted device %s ignored", mount.displayName())
+ return False
+ else:
+ return True
+ return False
+
+ def shouldScanMount(self, mount: QStorageInfo) -> bool:
+ if self.prefs.device_autodetection:
+ path = mount.rootPath()
+ if (self.prefs.device_without_dcim_autodetection or has_non_empty_dcim_folder(path)):
+ if not self.devices.user_marked_volume_as_ignored(path):
+ return True
+ else:
+ logging.debug('Not scanning volume with path %s because it was set '
+ 'to be temporarily ignored', path)
+ else:
+ logging.debug('Not scanning volume with path %s because it lacks a DCIM folder '
+ 'with at least one file or folder in it', path)
+ return False
+
+ def prepareNonCameraDeviceScan(self, device: Device, on_startup: bool=False) -> None:
+ """
+ Initiates a device scan for volume.
+
+ If non-DCIM device scans are enabled, and the device is not whitelisted
+ (determined by the display name), then the user is prompted whether to download
+ from the device.
+
+ :param device: device to scan
+ :param on_startup: if True, the search is occurring during
+ the program's startup phase
+ """
+
+ if not self.devices.known_device(device):
+ if (self.scanEvenIfNoDCIM() and not device.display_name in self.prefs.volume_whitelist):
+ logging.debug("Prompting whether to use device %s", device.display_name)
+ # prompt user to see if device should be used or not
+ self.showMainWindow()
+ message = _('Do you want to download photos and videos from the device <i>%('
+ 'device)s</i>?') % dict(device=device.display_name)
+ use = RememberThisDialog(message=message, icon=device.get_pixmap(),
+ remember=RememberThisMessage.remember_choice,
+ parent=self, title=device.display_name)
+ if use.exec():
+ if use.remember:
+ logging.debug("Whitelisting device %s", device.display_name)
+ self.prefs.add_list_value(key='volume_whitelist', value=device.display_name)
+ self.startDeviceScan(device=device, on_startup=on_startup)
+ else:
+ logging.debug("Device %s rejected as a download device", device.display_name)
+ if use.remember and device.display_name not in self.prefs.volume_blacklist:
+ logging.debug("Blacklisting device %s", device.display_name)
+ self.prefs.add_list_value(key='volume_blacklist', value=device.display_name)
+ else:
+ self.startDeviceScan(device=device, on_startup=on_startup)
+
+ @pyqtSlot(str, list, bool)
+ def partitionMounted(self, path: str, iconNames: List[str], canEject: bool) -> None:
+ """
+ Setup devices from which to download from and backup to, and
+ if relevant start scanning them
+
+ :param path: the path of the mounted partition
+ :param iconNames: a list of names of icons used in themed icons
+ associated with this partition
+ :param canEject: whether the partition can be ejected or not
+ """
+
+ assert path in mountPaths()
+
+ if self.monitorPartitionChanges():
+ mount = QStorageInfo(path)
+ if self.partitionValid(mount):
+ backup_file_type = self.isBackupPath(path)
+
+ if backup_file_type is not None:
+ if path not in self.backup_devices:
+ device = BackupDevice(mount=mount, backup_type=backup_file_type)
+ self.backup_devices[path] = device
+ self.addDeviceToBackupManager(path)
+ self.download_tracker.set_no_backup_devices(
+ len(self.backup_devices.photo_backup_devices),
+ len(self.backup_devices.video_backup_devices))
+ self.displayMessageInStatusBar()
+ self.backupPanel.addBackupVolume(
+ mount_details=self.backup_devices.get_backup_volume_details(path)
+ )
+ if self.prefs.backup_device_autodetection:
+ self.backupPanel.updateExample()
+
+ elif self.shouldScanMount(mount):
+ device = Device()
+ device.set_download_from_volume(
+ path, mount.displayName(), iconNames, canEject, mount
+ )
+ self.prepareNonCameraDeviceScan(device)
+
+ @pyqtSlot(str)
+ def partitionUmounted(self, path: str) -> None:
+ """
+ Handle the unmounting of partitions by the system / user.
+
+ :param path: the path of the partition just unmounted
+ """
+ if not path:
+ return
+
+ if self.devices.known_path(path, DeviceType.volume):
+ # four scenarios -
+ # the mount is being scanned
+ # the mount has been scanned but downloading has not yet started
+ # files are being downloaded from mount
+ # files have finished downloading from mount
+ scan_id = self.devices.scan_id_from_path(path, DeviceType.volume)
+ self.removeDevice(scan_id=scan_id)
+
+ elif path in self.backup_devices:
+ self.removeBackupDevice(path)
+ self.backupPanel.removeBackupVolume(path=path)
+ self.displayMessageInStatusBar()
+ self.download_tracker.set_no_backup_devices(
+ len(self.backup_devices.photo_backup_devices),
+ len(self.backup_devices.video_backup_devices)
+ )
+ if self.prefs.backup_device_autodetection:
+ self.backupPanel.updateExample()
+
+ self.setDownloadCapabilities()
+
+ def removeDevice(self, scan_id: int,
+ show_warning: bool=True,
+ adjust_temporal_proximity: bool=True,
+ ignore_in_this_program_instantiation: bool=False) -> None:
+ """
+ Remove a device from internal tracking and display.
+
+ :param scan_id: scan id of device to remove
+ :param show_warning: log warning if the device was having
+ something done to it e.g. scan
+ :param adjust_temporal_proximity: if True, update the temporal
+ proximity table to reflect device removal
+ :param ignore_in_this_program_instantiation: don't scan this
+ device again during this instance of the program being run
+ """
+
+ assert scan_id is not None
+
+ if scan_id in self.devices:
+ device = self.devices[scan_id]
+ device_state = self.deviceState(scan_id)
+
+ if show_warning:
+ if device_state == DeviceState.scanning:
+ logging.warning("Removed device %s was being scanned", device.name())
+ elif device_state == DeviceState.downloading:
+ logging.error("Removed device %s was being downloaded from", device.name())
+ elif device_state == DeviceState.thumbnailing:
+ logging.warning("Removed device %s was having thumbnails generated", device.name())
+ else:
+ logging.info("Device removed: %s", device.name())
+ else:
+ logging.debug("Device removed: %s", device.name())
+
+ if device in self.prompting_for_user_action:
+ self.prompting_for_user_action[device].reject()
+
+ files_removed = self.thumbnailModel.clearAll(scan_id=scan_id,
+ keep_downloaded_files=True)
+ self.mapModel(scan_id).removeDevice(scan_id)
+
+ was_downloading = self.downloadIsRunning()
+
+ if device_state == DeviceState.scanning:
+ self.sendStopWorkerToThread(self.scan_controller, scan_id)
+ elif device_state == DeviceState.downloading:
+ self.sendStopWorkerToThread(self.copy_controller, scan_id)
+ self.download_tracker.device_removed_mid_download(scan_id, device.display_name)
+ del self.time_remaining[scan_id]
+ self.notifyDownloadedFromDevice(scan_id=scan_id)
+ # TODO need correct check for "is thumbnailing", given is now asynchronous
+ elif device_state == DeviceState.thumbnailing:
+ self.thumbnailModel.terminateThumbnailGeneration(scan_id)
+
+ if ignore_in_this_program_instantiation:
+ self.devices.ignore_device(scan_id=scan_id)
+
+ self.folder_preview_manager.remove_folders_for_device(scan_id=scan_id)
+
+ del self.devices[scan_id]
+ self.adjustLeftPanelSliderHandles()
+
+ if device.device_type == DeviceType.path:
+ self.thisComputer.setViewVisible(False)
+
+ self.updateSourceButton()
+ self.setDownloadCapabilities()
+
+ if adjust_temporal_proximity:
+ if len(self.devices) == 0:
+ self.temporalProximity.setState(TemporalProximityState.empty)
+ elif files_removed:
+ self.generateTemporalProximityTableData("a download source was removed")
+ elif self.temporalProximity.state == TemporalProximityState.pending:
+ self.generateTemporalProximityTableData("a download source was removed and a "
+ "build is pending")
+
+ self.logState()
+ self.updateProgressBarState()
+ self.displayMessageInStatusBar()
+
+ # Reset Download button from "Pause" to "Download"
+ if was_downloading and not self.downloadIsRunning():
+ self.setDownloadActionLabel()
+
+ def rescanDevice(self, scan_id: int) -> None:
+ """
+ Remove a device and scan it again.
+
+ :param scan_id: scan id of the device
+ """
+
+ device = self.devices[scan_id]
+ logging.debug("Rescanning %s", device.display_name)
+ self.removeDevice(scan_id=scan_id)
+ if device.device_type == DeviceType.camera:
+ self.startCameraScan(device.camera_model, device.camera_port)
+ else:
+ if device.device_type == DeviceType.path:
+ self.thisComputer.setViewVisible(True)
+ self.startDeviceScan(device=device)
+
+ def rescanDevicesAndComputer(self, ignore_cameras: bool, rescan_path: bool) -> None:
+ """
+ After a preference change, rescan already scanned devices
+ :param ignore_cameras: if True, don't rescan cameras
+ :param rescan_path: if True, include manually specified paths
+ (i.e. This Computer)
+ """
+
+ if rescan_path:
+ logging.info("Rescanning all paths and devices")
+ if ignore_cameras:
+ logging.info("Rescanning non camera devices")
+
+ # Collect the scan ids to work on - don't modify the
+ # collection of devices in place!
+ scan_ids = []
+ for scan_id in self.devices:
+ device = self.devices[scan_id]
+ if not ignore_cameras or device.device_type == DeviceType.volume:
+ scan_ids.append(scan_id)
+ elif rescan_path and device.device_type == DeviceType.path:
+ scan_ids.append(scan_id)
+
+ for scan_id in scan_ids:
+ self.rescanDevice(scan_id=scan_id)
+
+ def searchForDevicesAgain(self) -> None:
+ """
+ Called after a preference change to only_external_mounts
+ """
+
+ # only scan again if the new pref value is more permissive than the former
+ # (don't remove existing devices)
+ if not self.prefs.only_external_mounts:
+ logging.debug("Searching for new volumes to scan...")
+ self.setupNonCameraDevices(scanning_again=True)
+ logging.debug("... finished searching for volumes to scan")
+
+
+ def blacklistDevice(self, scan_id: int) -> None:
+ """
+ Query user if they really want to to permanently ignore a camera or
+ volume. If they do, the device is removed and blacklisted.
+
+ :param scan_id: scan id of the device
+ """
+
+ device = self.devices[scan_id]
+ if device.device_type == DeviceType.camera:
+ text = _("<b>Do you want to ignore the %s whenever this program is run?</b>")
+ text = text % device.display_name
+ info_text = _("All cameras, phones and tablets with the same model "
+ "name will be ignored.")
+ else:
+ assert device.device_type == DeviceType.volume
+ text = _("<b>Do you want to ignore the device %s whenever this program is run?</b>")
+ text = text % device.display_name
+ info_text = _("Any device with the same name will be ignored.")
+
+ msgbox = QMessageBox()
+ msgbox.setWindowTitle(_("Rapid Photo Downloader"))
+ msgbox.setIcon(QMessageBox.Question)
+ msgbox.setText(text)
+ msgbox.setTextFormat(Qt.RichText)
+ msgbox.setInformativeText(info_text)
+ msgbox.setStandardButtons(QMessageBox.Yes|QMessageBox.No)
+ if msgbox.exec() == QMessageBox.Yes:
+ if device.device_type == DeviceType.camera:
+ self.prefs.add_list_value(key='camera_blacklist', value=device.udev_name)
+ logging.debug('Added %s to camera blacklist',device.udev_name)
+ else:
+ self.prefs.add_list_value(key='volume_blacklist', value=device.display_name)
+ logging.debug('Added %s to volume blacklist', device.display_name)
+ self.removeDevice(scan_id=scan_id)
+
+ def logState(self) -> None:
+ self.devices.logState()
+ self.thumbnailModel.logState()
+ self.deviceModel.logState()
+ self.thisComputerModel.logState()
+
+ def setupBackupDevices(self) -> None:
+ """
+ Setup devices to back up to.
+
+ Includes both auto detected back up devices, and manually
+ specified paths.
+ """
+ if self.prefs.backup_device_autodetection:
+ for mount in self.validMounts.mountedValidMountPoints():
+ if self.partitionValid(mount):
+ path = mount.rootPath()
+ backup_type = self.isBackupPath(path)
+ if backup_type is not None:
+ self.backup_devices[path] = BackupDevice(mount=mount,
+ backup_type=backup_type)
+ self.addDeviceToBackupManager(path)
+ self.backupPanel.updateExample()
+ else:
+ self.setupManualBackup()
+ for path in self.backup_devices:
+ self.addDeviceToBackupManager(path)
+
+ self.download_tracker.set_no_backup_devices(
+ len(self.backup_devices.photo_backup_devices),
+ len(self.backup_devices.video_backup_devices))
+
+ self.backupPanel.setupBackupDisplay()
+
+ def removeBackupDevice(self, path: str) -> None:
+ device_id = self.backup_devices.device_id(path)
+ self.sendStopWorkerToThread(self.backup_controller, worker_id=device_id)
+ del self.backup_devices[path]
+
+ def resetupBackupDevices(self) -> None:
+ """
+ Change backup preferences in response to preference change.
+
+ Assumes backups may have already been setup.
+ """
+
+ try:
+ assert not self.downloadIsRunning()
+ except AssertionError:
+ logging.critical("Backup devices should never be reset when a download is occurring")
+ return
+
+ logging.info("Resetting backup devices configuration...")
+ # Clear all existing backup devices
+ for path in self.backup_devices.all_paths():
+ self.removeBackupDevice(path)
+ self.download_tracker.set_no_backup_devices(0, 0)
+ self.backupPanel.resetBackupDisplay()
+
+ self.setupBackupDevices()
+ self.setDownloadCapabilities()
+ logging.info("...backup devices configuration is reset")
+
+ def setupNonCameraDevices(self, on_startup: bool=False, scanning_again: bool=False) -> None:
+ """
+ Setup devices from which to download and initiates their scan.
+
+ :param on_startup: if True, the search is occurring during
+ the program's startup phase
+ :param scanning_again: if True, the search is occurring after a preference
+ value change, where devices may have already been scanned.
+ """
+
+ if not self.prefs.device_autodetection:
+ return
+
+ mounts = [] # type: List[QStorageInfo]
+ for mount in self.validMounts.mountedValidMountPoints():
+ if self.partitionValid(mount):
+ path = mount.rootPath()
+
+ if scanning_again and \
+ self.devices.known_path(path=path, device_type=DeviceType.volume):
+ logging.debug(
+ "Will not scan %s, because it's associated with an existing device",
+ mount.displayName()
+ )
+ continue
+
+ if path not in self.backup_devices and self.shouldScanMount(mount):
+ logging.debug("Will scan %s", mount.displayName())
+ mounts.append(mount)
+ else:
+ logging.debug("Will not scan %s", mount.displayName())
+
+ for mount in mounts:
+ icon_names, can_eject = self.getIconsAndEjectableForMount(mount)
+ device = Device()
+ device.set_download_from_volume(
+ mount.rootPath(), mount.displayName(), icon_names, can_eject, mount
+ )
+ self.prepareNonCameraDeviceScan(device=device, on_startup=on_startup)
+
+ def setupManualPath(self, on_startup: bool=False) -> None:
+ """
+ Setup This Computer path from which to download and initiates scan.
+
+ :param on_startup: if True, the setup is occurring during
+ the program's startup phase
+ """
+
+ if not self.prefs.this_computer_source:
+ return
+
+ if self.prefs.this_computer_path:
+ if not self.confirmManualDownloadLocation():
+ logging.debug("This Computer path %s rejected as download source",
+ self.prefs.this_computer_path)
+ self.prefs.this_computer_path = ''
+ self.thisComputer.setViewVisible(False)
+ return
+
+ # user manually specified the path from which to download
+ path = self.prefs.this_computer_path
+
+ if path:
+ if os.path.isdir(path) and os.access(path, os.R_OK):
+ logging.debug("Using This Computer path %s", path)
+ device = Device()
+ device.set_download_from_path(path)
+ self.startDeviceScan(device=device, on_startup=on_startup)
+ else:
+ logging.error("This Computer download path is invalid: %s", path)
+ else:
+ logging.warning("This Computer download path is not specified")
+
+ def addDeviceToBackupManager(self, path: str) -> None:
+ device_id = self.backup_devices.device_id(path)
+ self.backup_controller.send_multipart(create_inproc_msg(b'START_WORKER',
+ worker_id=device_id,
+ data=BackupArguments(path, self.backup_devices.name(path))))
+
+ def setupManualBackup(self) -> None:
+ """
+ Setup backup devices that the user has manually specified.
+
+ Depending on the folder the user has chosen, the paths for
+ photo and video backup will either be the same or they will
+ differ.
+
+ Because the paths are manually specified, there is no mount
+ associated with them.
+ """
+
+ backup_photo_location = self.prefs.backup_photo_location
+ backup_video_location = self.prefs.backup_video_location
+
+ if not self.manualBackupPathAvailable(backup_photo_location):
+ logging.warning("Photo backup path unavailable: %s", backup_photo_location)
+ if not self.manualBackupPathAvailable(backup_video_location):
+ logging.warning("Video backup path unavailable: %s", backup_video_location)
+
+ if backup_photo_location != backup_video_location:
+ backup_photo_device = BackupDevice(mount=None, backup_type=BackupLocationType.photos)
+ backup_video_device = BackupDevice(mount=None, backup_type=BackupLocationType.videos)
+ self.backup_devices[backup_photo_location] = backup_photo_device
+ self.backup_devices[backup_video_location] = backup_video_device
+
+ logging.info("Backing up photos to %s", backup_photo_location)
+ logging.info("Backing up videos to %s", backup_video_location)
+ else:
+ # videos and photos are being backed up to the same location
+ backup_device = BackupDevice(mount=None,
+ backup_type=BackupLocationType.photos_and_videos)
+ self.backup_devices[backup_photo_location] = backup_device
+
+ logging.info("Backing up photos and videos to %s", backup_photo_location)
+
+ def isBackupPath(self, path: str) -> BackupLocationType:
+ """
+ Checks to see if backups are enabled and path represents a
+ valid backup location. It must be writeable.
+
+ Checks against user preferences.
+
+ :return The type of file that should be backed up to the path,
+ else if nothing should be, None
+ """
+
+ if self.prefs.backup_files:
+ if self.prefs.backup_device_autodetection:
+ # Determine if the auto-detected backup device is
+ # to be used to backup only photos, or videos, or both.
+ # Use the presence of a corresponding directory to
+ # determine this.
+ # The directory must be writable.
+ photo_path = os.path.join(path, self.prefs.photo_backup_identifier)
+ p_backup = os.path.isdir(photo_path) and os.access(photo_path, os.W_OK)
+ video_path = os.path.join(path, self.prefs.video_backup_identifier)
+ v_backup = os.path.isdir(video_path) and os.access(video_path, os.W_OK)
+ if p_backup and v_backup:
+ logging.info("Photos and videos will be backed up to %s", path)
+ return BackupLocationType.photos_and_videos
+ elif p_backup:
+ logging.info("Photos will be backed up to %s", path)
+ return BackupLocationType.photos
+ elif v_backup:
+ logging.info("Videos will be backed up to %s", path)
+ return BackupLocationType.videos
+ elif path == self.prefs.backup_photo_location:
+ # user manually specified the path
+ if self.manualBackupPathAvailable(path):
+ return BackupLocationType.photos
+ elif path == self.prefs.backup_video_location:
+ # user manually specified the path
+ if self.manualBackupPathAvailable(path):
+ return BackupLocationType.videos
+ return None
+
+ def manualBackupPathAvailable(self, path: str) -> bool:
+ return os.access(path, os.W_OK)
+
+ def monitorPartitionChanges(self) -> bool:
+ """
+ If the user is downloading from a manually specified location,
+ and is not using any automatically detected backup devices,
+ then there is no need to monitor for devices with filesystems
+ being added or removed
+ :return: True if should monitor, False otherwise
+ """
+ return (self.prefs.device_autodetection or
+ self.prefs.backup_device_autodetection)
+
+ @pyqtSlot(str)
+ def watchedFolderChange(self, path: str) -> None:
+ """
+ Handle case where a download folder has been removed or altered
+
+ :param path: watched path
+ """
+
+ logging.debug("Change in watched folder %s; validating download destinations", path)
+ valid = True
+ if self.prefs.photo_download_folder and not validate_download_folder(
+ self.prefs.photo_download_folder).valid:
+ valid = False
+ logging.debug("Photo download destination %s is now invalid",
+ self.prefs.photo_download_folder)
+ self.handleInvalidDownloadDestination(file_type=FileType.photo, do_update=False)
+
+ if self.prefs.video_download_folder and not validate_download_folder(
+ self.prefs.video_download_folder).valid:
+ valid = False
+ logging.debug("Video download destination %s is now invalid",
+ self.prefs.video_download_folder)
+ self.handleInvalidDownloadDestination(file_type=FileType.video, do_update=False)
+
+ if not valid:
+ self.watchedDownloadDirs.updateWatchPathsFromPrefs(self.prefs)
+ self.folder_preview_manager.change_destination()
+ self.setDownloadCapabilities()
+
+ def confirmManualDownloadLocation(self) -> bool:
+ """
+ Queries the user to ask if they really want to download from locations
+ that could take a very long time to scan. They can choose yes or no.
+
+ Returns True if yes or there was no need to ask the user, False if the
+ user said no.
+ """
+ self.showMainWindow()
+ path = self.prefs.this_computer_path
+ if path in ('/media', '/run', os.path.expanduser('~'), '/', '/bin', '/boot', '/dev',
+ '/lib', '/lib32', '/lib64', '/mnt', '/opt', '/sbin', '/snap', '/sys', '/tmp',
+ '/usr', '/var', '/proc'):
+ message = "<b>" + _("Downloading from %(location)s on This Computer.") % dict(
+ location=make_html_path_non_breaking(path)) + "</b><br><br>" + _(
+ "Do you really want to download from here?<br><br>On some systems, scanning this "
+ "location can take a very long time.")
+ msgbox = self.standardMessageBox(message=message, rich_text=True)
+ msgbox.setStandardButtons(QMessageBox.Yes|QMessageBox.No)
+ return msgbox.exec() == QMessageBox.Yes
+ return True
+
+ def scanEvenIfNoDCIM(self) -> bool:
+ """
+ Determines if partitions should be scanned even if there is
+ no DCIM folder present in the base folder of the file system.
+
+ This is necessary when both portable storage device automatic
+ detection is on, and downloading from automatically detected
+ partitions is on.
+ :return: True if scans of such partitions should occur, else
+ False
+ """
+ return (self.prefs.device_autodetection and
+ self.prefs.device_without_dcim_autodetection)
+
+ def displayMessageInStatusBar(self) -> None:
+ """
+ Displays message on status bar.
+
+ Notifies user if scanning or thumbnailing.
+
+ If neither scanning or thumbnailing, displays:
+ 1. files checked for download
+ 2. total number files available
+ 3. how many not shown (user chose to show only new files)
+ """
+
+ if self.downloadIsRunning():
+ if self.download_paused:
+ downloading = self.devices.downloading_from()
+ # Translators - in the middle is a unicode em dash - please retain it
+ # This string is displayed in the status bar when the download is paused
+ msg = '%(downloading_from)s — download paused' % dict(downloading_from=downloading)
+ else:
+ # status message updates while downloading are handled in another function
+ return
+ elif self.devices.thumbnailing:
+ devices = [self.devices[scan_id].display_name for scan_id in self.devices.thumbnailing]
+ msg = _("Generating thumbnails for %s") % make_internationalized_list(devices)
+ elif self.devices.scanning:
+ devices = [self.devices[scan_id].display_name for scan_id in self.devices.scanning]
+ msg = _("Scanning %s") % make_internationalized_list(devices)
+ else:
+ files_avilable = self.thumbnailModel.getNoFilesAvailableForDownload()
+
+ if sum(files_avilable.values()) != 0:
+ files_to_download = self.thumbnailModel.getNoFilesMarkedForDownload()
+ files_avilable_sum = files_avilable.summarize_file_count()[0]
+ files_hidden = self.thumbnailModel.getNoHiddenFiles()
+
+ if files_hidden:
+ files_checked = _('%(number)s of %(available files)s checked for download (%('
+ 'hidden)s hidden)') % {
+ 'number': thousands(files_to_download),
+ 'available files': files_avilable_sum,
+ 'hidden': files_hidden}
+ else:
+ files_checked = _('%(number)s of %(available files)s checked for download') % {
+ 'number': thousands(files_to_download),
+ 'available files': files_avilable_sum}
+ msg = files_checked
+ else:
+ msg = ''
+ self.statusBar().showMessage(msg)
+
+ def generateBasicStatusMessage(self) -> str:
+
+ # No longer used - candidate for deletion
+ msg = ''
+ if self.prefs.backup_files:
+ if not self.prefs.backup_device_autodetection:
+ if self.prefs.backup_photo_location == self.prefs.backup_video_location:
+ # user manually specified the same location for photos
+ # and video backups
+ pass
+ # msg = _('Backing up photos and videos to %(path)s') % {
+ # 'path':self.prefs.backup_photo_location}
+ else:
+ # user manually specified different locations for photo
+ # and video backups
+ pass
+ # msg = _('Backing up photos to %(path)s and videos to %(path2)s') % {
+ # 'path': self.prefs.backup_photo_location,
+ # 'path2': self.prefs.backup_video_location}
+ else:
+ msg = self.displayBackupMounts()
+ # msg = "%(backuppaths)s." % dict(backuppaths=msg)
+ return msg.rstrip()
+
+ def displayBackupMounts(self) -> str:
+ """
+ Create a message to be displayed to the user showing which
+ backup mounts will be used
+ :return the string to be displayed
+ """
+
+ # No longer used - candidate for deletion
+
+ message = ''
+
+ # backup_device_names = [self.backup_devices.name(path) for path in
+ # self.backup_devices]
+ # message = make_internationalized_list(backup_device_names)
+ #
+ # if len(backup_device_names) > 1:
+ # message = _("Using backup devices %(devices)s") % dict(
+ # devices=message)
+ # elif len(backup_device_names) == 1:
+ # message = _("Using backup device %(device)s") % dict(
+ # device=message)
+ # else:
+ # message = _("No backup devices detected")
+ # return message
+
+
+class QtSingleApplication(QApplication):
+ """
+ Taken from
+ http://stackoverflow.com/questions/12712360/qtsingleapplication
+ -for-pyside-or-pyqt
+ """
+
+ messageReceived = QtCore.pyqtSignal(str)
+
+ def __init__(self, programId: str, *argv) -> None:
+ super().__init__(*argv)
+ self._id = programId
+ self._activationWindow = None # type: RapidWindow
+ self._activateOnMessage = False # type: bool
+
+ # Is there another instance running?
+ self._outSocket = QLocalSocket() # type: QLocalSocket
+ self._outSocket.connectToServer(self._id)
+ self._isRunning = self._outSocket.waitForConnected() # type: bool
+
+ self._outStream = None # type: QTextStream
+ self._inSocket = None
+ self._inStream = None # type: QTextStream
+ self._server = None
+
+ if self._isRunning:
+ # Yes, there is.
+ self._outStream = QTextStream(self._outSocket)
+ self._outStream.setCodec('UTF-8')
+ else:
+ # No, there isn't, at least not properly.
+ # Cleanup any past, crashed server.
+ error = self._outSocket.error()
+ if error == QLocalSocket.ConnectionRefusedError:
+ self.close()
+ QLocalServer.removeServer(self._id)
+ self._outSocket = None
+ self._server = QLocalServer()
+ self._server.listen(self._id)
+ self._server.newConnection.connect(self._onNewConnection)
+
+ def close(self) -> None:
+ if self._inSocket:
+ self._inSocket.disconnectFromServer()
+ if self._outSocket:
+ self._outSocket.disconnectFromServer()
+ if self._server:
+ self._server.close()
+
+ def isRunning(self) -> bool:
+ return self._isRunning
+
+ def id(self) -> str:
+ return self._id
+
+ def activationWindow(self) -> RapidWindow:
+ return self._activationWindow
+
+ def setActivationWindow(self, activationWindow: RapidWindow,
+ activateOnMessage: bool = True) -> None:
+ self._activationWindow = activationWindow
+ self._activateOnMessage = activateOnMessage
+
+ def activateWindow(self) -> None:
+ if not self._activationWindow:
+ return
+ self._activationWindow.setWindowState(
+ self._activationWindow.windowState() & ~Qt.WindowMinimized)
+ self._activationWindow.raise_()
+ self._activationWindow.activateWindow()
+
+ def sendMessage(self, msg) -> bool:
+ if not self._outStream:
+ return False
+ self._outStream << msg << '\n'
+ self._outStream.flush()
+ return self._outSocket.waitForBytesWritten()
+
+ def _onNewConnection(self) -> None:
+ if self._inSocket:
+ self._inSocket.readyRead.disconnect(self._onReadyRead)
+ self._inSocket = self._server.nextPendingConnection()
+ if not self._inSocket:
+ return
+ self._inStream = QTextStream(self._inSocket)
+ self._inStream.setCodec('UTF-8')
+ self._inSocket.readyRead.connect(self._onReadyRead)
+ if self._activateOnMessage:
+ self.activateWindow()
+
+ def _onReadyRead(self) -> None:
+ while True:
+ msg = self._inStream.readLine()
+ if not msg: break
+ self.messageReceived.emit(msg)
+
+
+def get_versions() -> List[str]:
+ if 'cython' in zmq.zmq_version_info.__module__:
+ pyzmq_backend = 'cython'
+ else:
+ pyzmq_backend = 'cffi'
+ versions = [
+ 'Rapid Photo Downloader: {}'.format(__about__.__version__),
+ 'Platform: {}'.format(platform.platform()),
+ 'Python: {}'.format(platform.python_version()),
+ 'Python executable: {}'.format(sys.executable),
+ 'Qt: {}'.format(QtCore.QT_VERSION_STR),
+ 'PyQt: {}'.format(QtCore.PYQT_VERSION_STR),
+ 'ZeroMQ: {}'.format(zmq.zmq_version()),
+ 'Python ZeroMQ: {} ({} backend)'.format(zmq.pyzmq_version(), pyzmq_backend),
+ 'gPhoto2: {}'.format(gphoto2_version()),
+ 'Python gPhoto2: {}'.format(python_gphoto2_version()),
+ 'ExifTool: {}'.format(EXIFTOOL_VERSION),
+ 'pymediainfo: {}'.format(pymedia_version_info()),
+ 'GExiv2: {}'.format(gexiv2_version()),
+ 'Gstreamer: {}'.format(gst_version()),
+ 'PyGObject: {}'.format('.'.join(map(str, gi.version_info))),
+ 'libraw: {}'.format(libraw_version()),
+ 'rawkit: {}'.format(rawkit_version()),
+ 'psutil: {}'.format('.'.join(map(str, psutil.version_info)))]
+ v = exiv2_version()
+ if v:
+ versions.append('Exiv2: {}'.format(v))
+ try:
+ versions.append('{}: {}'.format(*platform.libc_ver()))
+ except:
+ pass
+ return versions
+
+# def darkFusion(app: QApplication):
+# app.setStyle("Fusion")
+#
+# dark_palette = QPalette()
+#
+# dark_palette.setColor(QPalette.Window, QColor(53, 53, 53))
+# dark_palette.setColor(QPalette.WindowText, Qt.white)
+# dark_palette.setColor(QPalette.Base, QColor(25, 25, 25))
+# dark_palette.setColor(QPalette.AlternateBase, QColor(53, 53, 53))
+# dark_palette.setColor(QPalette.ToolTipBase, Qt.white)
+# dark_palette.setColor(QPalette.ToolTipText, Qt.white)
+# dark_palette.setColor(QPalette.Text, Qt.white)
+# dark_palette.setColor(QPalette.Button, QColor(53, 53, 53))
+# dark_palette.setColor(QPalette.ButtonText, Qt.white)
+# dark_palette.setColor(QPalette.BrightText, Qt.red)
+# dark_palette.setColor(QPalette.Link, QColor(42, 130, 218))
+# dark_palette.setColor(QPalette.Highlight, QColor(42, 130, 218))
+# dark_palette.setColor(QPalette.HighlightedText, Qt.black)
+#
+# app.setPalette(dark_palette)
+# style = """
+# QToolTip { color: #ffffff; background-color: #2a82da; border: 1px solid white; }
+# """
+# app.setStyleSheet(style)
+
+
+class SplashScreen(QSplashScreen):
+ def __init__(self, pixmap: QPixmap, flags) -> None:
+ super().__init__(pixmap, flags)
+ self.progress = 0
+ self.image_width = pixmap.width()
+ self.progressBarPen = QPen(QBrush(QColor(Qt.white)), 2.0)
+
+ def drawContents(self, painter: QPainter):
+ painter.save()
+ painter.setPen(QColor(Qt.black))
+ painter.drawText(18, 64, __about__.__version__)
+ if self.progress:
+ painter.setPen(self.progressBarPen)
+ x = int(self.progress / 100 * self.image_width)
+ painter.drawLine(0, 360, x, 360)
+ painter.restore()
+
+ def setProgress(self, value: int) -> None:
+ """
+ Update splash screen progress bar
+ :param value: percent done, between 0 and 100
+ """
+
+ self.progress = value
+ self.repaint()
+
+
+def parser_options(formatter_class=argparse.HelpFormatter):
+ parser = argparse.ArgumentParser(prog=__about__.__title__,
+ description=__about__.__summary__,
+ formatter_class=formatter_class)
+
+ parser.add_argument('--version', action='version', version=
+ '%(prog)s {}'.format(__about__.__version__))
+ parser.add_argument('--detailed-version', action='store_true',
+ help="Show version numbers of program and its libraries and exit.")
+ parser.add_argument("-v", "--verbose", action="store_true", dest="verbose",
+ help=_("Display program information when run from the command line."))
+ parser.add_argument("--debug", action="store_true", dest="debug",
+ help=_("Display debugging information when run from the command line."))
+ parser.add_argument("-e", "--extensions", action="store_true",
+ dest="extensions",
+ help=_("List photo and video file extensions the program recognizes "
+ "and exit."))
+ parser.add_argument("--photo-renaming", choices=['on','off'],
+ dest="photo_renaming", help=_("Turn on or off the the renaming of photos."))
+ parser.add_argument("--video-renaming", choices=['on','off'],
+ dest="video_renaming", help=_("turn on or off the the renaming of videos."))
+ parser.add_argument("-a", "--auto-detect", choices=['on','off'],
+ dest="auto_detect", help=_("Turn on or off the automatic detection of devices from which "
+ "to download."))
+ parser.add_argument("-t", "--this-computer", choices=['on','off'],
+ dest="this_computer_source",
+ help=_("Turn on or off downloading from this computer."))
+ parser.add_argument("--this-computer-location", type=str,
+ metavar=_("PATH"), dest="this_computer_location",
+ help=_("The PATH on this computer from which to download."))
+ parser.add_argument("--photo-destination", type=str,
+ metavar=_("PATH"), dest="photo_location",
+ help=_("The PATH where photos will be downloaded to."))
+ parser.add_argument("--video-destination", type=str,
+ metavar=_("PATH"), dest="video_location",
+ help=_("The PATH where videos will be downloaded to."))
+ parser.add_argument("-b", "--backup", choices=['on','off'],
+ dest="backup", help=_("Turn on or off the backing up of photos and videos while "
+ "downloading."))
+ parser.add_argument("--backup-auto-detect", choices=['on','off'],
+ dest="backup_auto_detect",
+ help=_("Turn on or off the automatic detection of backup devices."))
+ parser.add_argument("--photo-backup-identifier", type=str,
+ metavar=_("FOLDER"), dest="photo_backup_identifier",
+ help=_("The FOLDER in which backups are stored on the automatically detected photo backup "
+ "device, with the folder's name being used to identify whether or not the device "
+ "is used for backups. For each device you wish to use for backing photos up to, "
+ "create a folder on it with this name."))
+ parser.add_argument("--video-backup-identifier", type=str,
+ metavar=_("FOLDER"), dest="video_backup_identifier",
+ help=_("The FOLDER in which backups are stored on the automatically detected video backup "
+ "device, with the folder's name being used to identify whether or not the device "
+ "is used for backups. For each device you wish to use for backing up videos to, "
+ "create a folder on it with this name."))
+ parser.add_argument("--photo-backup-location", type=str,
+ metavar=_("PATH"), dest="photo_backup_location",
+ help=_("The PATH where photos will be backed up when automatic "
+ "detection of backup devices is turned off."))
+ parser.add_argument("--video-backup-location", type=str,
+ metavar=_("PATH"), dest="video_backup_location",
+ help=_("The PATH where videos will be backed up when automatic "
+ "detection of backup devices is turned off."))
+ parser.add_argument("--ignore-other-photo-file-types", action="store_true", dest="ignore_other",
+ help=_('Ignore photos with the following extensions: %s') %
+ make_internationalized_list([s.upper() for s in OTHER_PHOTO_EXTENSIONS]))
+ parser.add_argument("--auto-download-startup", dest="auto_download_startup",
+ choices=['on', 'off'],
+ help=_("Turn on or off starting downloads as soon as the program itself starts."))
+ parser.add_argument("--auto-download-device-insertion", dest="auto_download_insertion",
+ choices=['on', 'off'],
+ help=_("Turn on or off starting downloads as soon as a device is inserted."))
+ parser.add_argument("--thumbnail-cache", dest="thumb_cache",
+ choices=['on','off'],
+ help=_("Turn on or off use of the Rapid Photo Downloader Thumbnail Cache. "
+ "Turning it off does not delete existing cache contents."))
+ parser.add_argument("--delete-thumbnail-cache", dest="delete_thumb_cache",
+ action="store_true",
+ help=_("Delete all thumbnails in the Rapid Photo Downloader Thumbnail "
+ "Cache, and exit."))
+ parser.add_argument("--forget-remembered-files", dest="forget_files",
+ action="store_true",
+ help=_("Forget which files have been previously downloaded, and exit."))
+ parser.add_argument("--import-old-version-preferences", action="store_true",
+ dest="import_prefs",
+ help=_("Import preferences from an old program version and exit. Requires the "
+ "command line program gconftool-2."))
+ parser.add_argument("--reset", action="store_true", dest="reset",
+ help=_("Reset all program settings to their default values, delete all thumbnails "
+ "in the Thumbnail cache, forget which files have been previously "
+ "downloaded, and exit."))
+ parser.add_argument("--log-gphoto2", action="store_true",
+ help=_("Include gphoto2 debugging information in log files."))
+
+ parser.add_argument(
+ "--camera-info", action="store_true", help=_(
+ "Print information to the terminal about attached cameras and exit."
+ )
+ )
+
+ parser.add_argument('path', nargs='?')
+
+ return parser
+
+def import_prefs() -> None:
+ """
+ Import program preferences from the Gtk+ 2 version of the program.
+
+ Requires the command line program gconftool-2.
+ """
+
+ def run_cmd(k: str) -> str:
+ command_line = '{} --get /apps/rapid-photo-downloader/{}'.format(cmd, k)
+ args = shlex.split(command_line)
+ try:
+ return subprocess.check_output(args=args).decode().strip()
+ except subprocess.SubprocessError:
+ return ''
+
+
+ cmd = shutil.which('gconftool-2')
+ keys = (('image_rename', 'photo_rename', prefs_list_from_gconftool2_string),
+ ('video_rename', 'video_rename', prefs_list_from_gconftool2_string),
+ ('subfolder', 'photo_subfolder', prefs_list_from_gconftool2_string),
+ ('video_subfolder', 'video_subfolder', prefs_list_from_gconftool2_string),
+ ('download_folder', 'photo_download_folder', str),
+ ('video_download_folder','video_download_folder', str),
+ ('device_autodetection', 'device_autodetection', pref_bool_from_gconftool2_string),
+ ('device_location', 'this_computer_path', str),
+ ('device_autodetection_psd', 'device_without_dcim_autodetection',
+ pref_bool_from_gconftool2_string),
+ ('ignored_paths', 'ignored_paths', prefs_list_from_gconftool2_string),
+ ('use_re_ignored_paths', 'use_re_ignored_paths', pref_bool_from_gconftool2_string),
+ ('backup_images', 'backup_files', pref_bool_from_gconftool2_string),
+ ('backup_device_autodetection', 'backup_device_autodetection',
+ pref_bool_from_gconftool2_string),
+ ('backup_identifier', 'photo_backup_identifier', str),
+ ('video_backup_identifier', 'video_backup_identifier', str),
+ ('backup_location', 'backup_photo_location', str),
+ ('backup_video_location', 'backup_video_location', str),
+ ('strip_characters', 'strip_characters', pref_bool_from_gconftool2_string),
+ ('synchronize_raw_jpg', 'synchronize_raw_jpg', pref_bool_from_gconftool2_string),
+ ('auto_download_at_startup', 'auto_download_at_startup',
+ pref_bool_from_gconftool2_string),
+ ('auto_download_upon_device_insertion', 'auto_download_upon_device_insertion',
+ pref_bool_from_gconftool2_string),
+ ('auto_unmount', 'auto_unmount', pref_bool_from_gconftool2_string),
+ ('auto_exit', 'auto_exit', pref_bool_from_gconftool2_string),
+ ('auto_exit_force', 'auto_exit_force', pref_bool_from_gconftool2_string),
+ ('verify_file', 'verify_file', pref_bool_from_gconftool2_string),
+ ('job_codes', 'job_codes', prefs_list_from_gconftool2_string),
+ ('generate_thumbnails', 'generate_thumbnails', pref_bool_from_gconftool2_string),
+ ('download_conflict_resolution', 'conflict_resolution', str),
+ ('backup_duplicate_overwrite', 'backup_duplicate_overwrite',
+ pref_bool_from_gconftool2_string))
+
+ if cmd is None:
+ print(_("To import preferences from the old version of Rapid Photo Downloader, you must "
+ "install the program gconftool-2."))
+ return
+
+ prefs = Preferences()
+
+ with raphodo.utilities.stdchannel_redirected(sys.stderr, os.devnull):
+ value = run_cmd('program_version')
+ if not value:
+ print(_("No prior program preferences detected: exiting"))
+ return
+ else:
+ print(_("Importing preferences from Rapid Photo Downloader %(version)s") % dict(
+ version=value))
+ print()
+
+ for key_triplet in keys:
+ key = key_triplet[0]
+ value = run_cmd(key)
+ if value:
+ try:
+ new_value = key_triplet[2](value)
+ except:
+ print("Skipping malformed value for key {}".format(key))
+ else:
+ if key == 'device_autodetection':
+ if new_value:
+ print("Setting device_autodetection to True")
+ print("Setting this_computer_source to False")
+ prefs.device_autodetection = True
+ prefs.this_computer_source = False
+ else:
+ print("Setting device_autodetection to False")
+ print("Setting this_computer_source to True")
+ prefs.device_autodetection = False
+ prefs.this_computer_source = True
+ elif key == 'device_location' and prefs.this_computer_source:
+ print("Setting this_computer_path to", new_value)
+ prefs.this_computer_path = new_value
+ elif key == 'download_conflict_resolution':
+ if new_value == "skip download":
+ prefs.conflict_resolution = int(constants.ConflictResolution.skip)
+ else:
+ prefs.conflict_resolution = \
+ int(constants.ConflictResolution.add_identifier)
+ else:
+ new_key = key_triplet[1]
+ if new_key in ('photo_rename', 'video_rename'):
+ pref_list, case = upgrade_pre090a4_rename_pref(new_value)
+ print("Setting", new_key, "to", pref_list)
+ setattr(prefs, new_key, pref_list)
+ if case is not None:
+ if new_key == 'photo_rename':
+ ext_key = 'photo_extension'
+ else:
+ ext_key = 'video_extension'
+ print("Setting", ext_key, "to", case)
+ setattr(prefs, ext_key, case)
+ else:
+ print("Setting", new_key, "to", new_value)
+ setattr(prefs, new_key, new_value)
+
+ key = 'stored_sequence_no'
+ with raphodo.utilities.stdchannel_redirected(sys.stderr, os.devnull):
+ value = run_cmd(key)
+ if value:
+ try:
+ new_value = int(value)
+ # we need to add 1 to the number for historic reasons
+ new_value += 1
+ except ValueError:
+ print("Skipping malformed value for key stored_sequence_no")
+ else:
+ if new_value and raphodo.utilities.confirm('\n' +
+ _('Do you want to copy the stored sequence number, which has the value %d?') %
+ new_value, resp=False):
+ prefs.stored_sequence_no = new_value
+
+
+def critical_startup_error(message: str) -> None:
+ errorapp = QApplication(sys.argv)
+ msg = QMessageBox()
+ msg.setWindowTitle(_("Rapid Photo Downloader"))
+ # msg.setIconPixmap(QPixmap(':/rapid-photo-downloader.svg'))
+ msg.setIcon(QMessageBox.Critical)
+ msg.setText('<b>%s</b>' % message)
+ msg.setInformativeText(_('Program aborting.'))
+ msg.setStandardButtons(QMessageBox.Ok)
+ msg.show()
+ errorapp.exec_()
+
+
+def main():
+
+ if sys.platform.startswith('linux') and os.getuid() == 0:
+ sys.stderr.write("Never run this program as the sudo / root user.\n")
+ critical_startup_error(_("Never run this program as the sudo / root user."))
+ sys.exit(1)
+
+ if not shutil.which('exiftool'):
+ critical_startup_error(_('You must install ExifTool to run Rapid Photo Downloader.'))
+ sys.exit(1)
+
+ parser = parser_options()
+
+ args = parser.parse_args()
+ if args.detailed_version:
+ print('\n'.join(get_versions()))
+ sys.exit(0)
+
+ if args.extensions:
+ photos = list((ext.upper() for ext in PHOTO_EXTENSIONS))
+ videos = list((ext.upper() for ext in VIDEO_EXTENSIONS))
+ extensions = ((photos, _("Photos")),
+ (videos, _("Videos")))
+ for exts, file_type in extensions:
+ extensions = make_internationalized_list(exts)
+ print('{}: {}'.format(file_type, extensions))
+ sys.exit(0)
+
+ global logging_level
+
+ if args.debug:
+ logging_level = logging.DEBUG
+ elif args.verbose:
+ logging_level = logging.INFO
+ else:
+ logging_level = logging.ERROR
+
+ global logger
+ logger = iplogging.setup_main_process_logging(logging_level=logging_level)
+
+ logging.info("Rapid Photo Downloader is starting")
+
+ if args.photo_renaming:
+ photo_rename = args.photo_renaming == 'on'
+ if photo_rename:
+ logging.info("Photo renaming turned on from command line")
+ else:
+ logging.info("Photo renaming turned off from command line")
+ else:
+ photo_rename = None
+
+ if args.video_renaming:
+ video_rename = args.video_renaming == 'on'
+ if video_rename:
+ logging.info("Video renaming turned on from command line")
+ else:
+ logging.info("Video renaming turned off from command line")
+ else:
+ video_rename = None
+
+ if args.path:
+ if args.auto_detect or args.this_computer_source:
+ msg = _('When specifying a path on the command line, do not also specify an\n'
+ 'option for device auto detection or a path on "This Computer".')
+ print(msg)
+ critical_startup_error(msg.replace('\n', ' '))
+ sys.exit(1)
+
+ media_dir = get_media_dir()
+ auto_detect = args.path.startswith(media_dir)
+ if auto_detect:
+ this_computer_source = False
+ this_computer_location = None
+ logging.info("Device auto detection turned on from command line using "
+ "positional PATH argument")
+
+ if not auto_detect:
+ this_computer_source = True
+ this_computer_location = os.path.abspath(args.path)
+ logging.info("Downloading from This Computer turned on from command line using "
+ "positional PATH argument")
+
+ else:
+ if args.auto_detect:
+ auto_detect= args.auto_detect == 'on'
+ if auto_detect:
+ logging.info("Device auto detection turned on from command line")
+ else:
+ logging.info("Device auto detection turned off from command line")
+ else:
+ auto_detect=None
+
+ if args.this_computer_source:
+ this_computer_source = args.this_computer_source == 'on'
+ if this_computer_source:
+ logging.info("Downloading from This Computer turned on from command line")
+ else:
+ logging.info("Downloading from This Computer turned off from command line")
+ else:
+ this_computer_source=None
+
+ if args.this_computer_location:
+ this_computer_location = os.path.abspath(args.this_computer_location)
+ logging.info("This Computer path set from command line: %s", this_computer_location)
+ else:
+ this_computer_location=None
+
+ if args.photo_location:
+ photo_location = os.path.abspath(args.photo_location)
+ logging.info("Photo location set from command line: %s", photo_location)
+ else:
+ photo_location=None
+
+ if args.video_location:
+ video_location = os.path.abspath(args.video_location)
+ logging.info("video location set from command line: %s", video_location)
+ else:
+ video_location=None
+
+ if args.backup:
+ backup = args.backup == 'on'
+ if backup:
+ logging.info("Backup turned on from command line")
+ else:
+ logging.info("Backup turned off from command line")
+ else:
+ backup=None
+
+ if args.backup_auto_detect:
+ backup_auto_detect = args.backup_auto_detect == 'on'
+ if backup_auto_detect:
+ logging.info("Automatic detection of backup devices turned on from command line")
+ else:
+ logging.info("Automatic detection of backup devices turned off from command line")
+ else:
+ backup_auto_detect=None
+
+ if args.photo_backup_identifier:
+ photo_backup_identifier = args.photo_backup_identifier
+ logging.info("Photo backup identifier set from command line: %s", photo_backup_identifier)
+ else:
+ photo_backup_identifier=None
+
+ if args.video_backup_identifier:
+ video_backup_identifier = args.video_backup_identifier
+ logging.info("Video backup identifier set from command line: %s", video_backup_identifier)
+ else:
+ video_backup_identifier=None
+
+ if args.photo_backup_location:
+ photo_backup_location = os.path.abspath(args.photo_backup_location)
+ logging.info("Photo backup location set from command line: %s", photo_backup_location)
+ else:
+ photo_backup_location=None
+
+ if args.video_backup_location:
+ video_backup_location = os.path.abspath(args.video_backup_location)
+ logging.info("Video backup location set from command line: %s", video_backup_location)
+ else:
+ video_backup_location=None
+
+ if args.thumb_cache:
+ thumb_cache = args.thumb_cache == 'on'
+ else:
+ thumb_cache = None
+
+ if args.auto_download_startup:
+ auto_download_startup = args.auto_download_startup == 'on'
+ if auto_download_startup:
+ logging.info("Automatic download at startup turned on from command line")
+ else:
+ logging.info("Automatic download at startup turned off from command line")
+ else:
+ auto_download_startup=None
+
+ if args.auto_download_insertion:
+ auto_download_insertion = args.auto_download_insertion == 'on'
+ if auto_download_insertion:
+ logging.info("Automatic download upon device insertion turned on from command line")
+ else:
+ logging.info("Automatic download upon device insertion turned off from command line")
+ else:
+ auto_download_insertion=None
+
+ if args.log_gphoto2:
+ gp.use_python_logging()
+
+ if args.camera_info:
+ dump_camera_details()
+ sys.exit(0)
+
+ # keep appGuid value in sync with value in upgrade.py
+ appGuid = '8dbfb490-b20f-49d3-9b7d-2016012d2aa8'
+
+ # See note at top regarding avoiding crashes
+ global app
+ app = QtSingleApplication(appGuid, sys.argv)
+ if app.isRunning():
+ print('Rapid Photo Downloader is already running')
+ sys.exit(0)
+
+ app.setOrganizationName("Rapid Photo Downloader")
+ app.setOrganizationDomain("damonlynch.net")
+ app.setApplicationName("Rapid Photo Downloader")
+ app.setWindowIcon(QIcon(':/rapid-photo-downloader.svg'))
+
+ # darkFusion(app)
+ # app.setStyle('Fusion')
+
+ # Resetting preferences must occur after QApplication is instantiated
+ if args.reset:
+ prefs = Preferences()
+ prefs.reset()
+ prefs.sync()
+ d = DownloadedSQL()
+ d.update_table(reset=True)
+ cache = ThumbnailCacheSql()
+ cache.purge_cache()
+ print(_("All settings and caches have been reset"))
+ logging.debug("Exiting immediately after full reset")
+ sys.exit(0)
+
+ if args.delete_thumb_cache or args.forget_files or args.import_prefs:
+ if args.delete_thumb_cache:
+ cache = ThumbnailCacheSql()
+ cache.purge_cache()
+ print(_("Thumbnail Cache has been reset"))
+ logging.debug("Thumbnail Cache has been reset")
+
+ if args.forget_files:
+ d = DownloadedSQL()
+ d.update_table(reset=True)
+ print(_("Remembered files have been forgotten"))
+ logging.debug("Remembered files have been forgotten")
+
+ if args.import_prefs:
+ import_prefs()
+ logging.debug("Exiting immediately after thumbnail cache / remembered files reset")
+ sys.exit(0)
+
+ splash = SplashScreen(QPixmap(':/splashscreen.png'), Qt.WindowStaysOnTopHint)
+ splash.show()
+ app.processEvents()
+
+ rw = RapidWindow(
+ photo_rename=photo_rename,
+ video_rename=video_rename,
+ auto_detect=auto_detect,
+ this_computer_source=this_computer_source,
+ this_computer_location=this_computer_location,
+ photo_download_folder=photo_location,
+ video_download_folder=video_location,
+ backup=backup,
+ backup_auto_detect=backup_auto_detect,
+ photo_backup_identifier=photo_backup_identifier,
+ video_backup_identifier=video_backup_identifier,
+ photo_backup_location=photo_backup_location,
+ video_backup_location=video_backup_location,
+ ignore_other_photo_types=args.ignore_other,
+ thumb_cache=thumb_cache,
+ auto_download_startup=auto_download_startup,
+ auto_download_insertion=auto_download_insertion,
+ log_gphoto2=args.log_gphoto2,
+ splash=splash
+ )
+
+ app.setActivationWindow(rw)
+ code = app.exec_()
+ logging.debug("Exiting")
+ sys.exit(code)
+
+if __name__ == "__main__":
+ main()