#!/usr/bin/env python3 # Copyright (C) 2011-2018 Damon Lynch # 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 . """ 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-2018, Damon Lynch" import sys import logging import shutil import datetime import locale # Use the default locale as defined by the LANG variable 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 import inspect 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 import sip from PyQt5 import QtCore 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_one_or_more_folders, 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, gvfs_gphoto2_path ) 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, CompletedDownloads, disable_version_check ) 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 becausea 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 not self.prefs.scan_specific_folders: logging.info("Devices do not need specific folders to be scanned") else: logging.info( "For automatically detected devices, only the contents the following " "folders will be scanned: %s", ', '.join(self.prefs.folders_to_scan) ) 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") if self.prefs.list_not_empty('volume_whitelist'): logging.info("Whitelisted devices: %s", " ; ".join(self.prefs.volume_whitelist)) if self.prefs.list_not_empty('volume_blacklist'): logging.info("Blacklisted devices: %s", " ; ".join(self.prefs.volume_blacklist)) if self.prefs.list_not_empty('camera_blacklist'): logging.info("Blacklisted cameras: %s", " ; ".join(self.prefs.camera_blacklist)) 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 ) if pv < pkg_resources.parse_version('0.9.7b1'): # Remove any duplicate subfolder generation or file renaming custom presets self.prefs.filter_duplicate_generation_prefs() 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(create_table_if_not_exists=False) logging.info("Purging thumbnail cache...") cache.purge_cache() logging.info("...thumbnail Cache has been purged") self.prefs.purge_thumbnails = False # Recreate the cache on the file system ThumbnailCacheSql(create_table_if_not_exists=True) elif self.prefs.optimize_thumbnail_db: cache = ThumbnailCacheSql(create_table_if_not_exists=True) 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 else: # Recreate the cache on the file system ThumbnailCacheSql(create_table_if_not_exists=True) # For meaning of 'Devices', see devices.py self.devices = DeviceCollection(self.exiftool_process, self) self.backup_devices = BackupDeviceCollection(rapidApp=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) 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) 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, self.file_manager_type = get_default_file_manager() if self.file_manager: logging.info("Default file manager: %s", self.file_manager) else: logging.warning("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() in (Desktop.unity, Desktop.ubuntugnome): if not have_unity: logging.warning( "Desktop environment is Unity Launcher API compatible, but could not load " "Unity 7.0 module" ) else: # Unity auto-generated desktop files use underscores, it seems launchers = ( 'net.damonlynch.rapid-photo-downloader.desktop', 'net.damonlynch.rapid_photo_downloader.desktop', ) for launcher in launchers: 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 Launcher API compatible, but could not " "find program's .desktop file" ) else: logging.debug( "Unity progress indicator found, using %s launcher(s)", len(self.desktop_launchers) ) self.createPathViews() self.createActions() logging.debug("Laying out main window") self.createMenus() self.createLayoutAndButtons(centralWidget) 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) if disable_version_check: logging.debug("Version check disabled") else: 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.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.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", True, 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 = _( 'ExifTool has a problem

' 'Rapid Photo Downloader uses ExifTool to get metadata from videos and photos. ' 'The program will run without it, but installing it is highly 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 = _( 'The library libmediainfo appears to be missing

' '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 or current_version < stable_version.version ): if dev_version.version > stable_version.version: self.latest_version = dev_version else: self.latest_version = stable_version if ( self.latest_version is not None and str(self.latest_version.version) not in self.prefs.ignore_versions): 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.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=marked, photos_size=size_photos_marked, videos_size=size_videos_marked, files_to_display=files_to_display, display_type=DestinationDisplayType.usage_only, merge=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=marked, photos_size=size_photos_marked, videos_size=0, files_to_display=files_to_display, display_type=display_type, merge=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=marked, photos_size=0, videos_size=size_videos_marked, files_to_display=files_to_display, display_type=display_type, merge=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) if not disable_version_check: 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""" if not disable_version_check: 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 {website}.

Attach the log file {log_file} to your report (click here to open the log directory). """ ).format( website='https://bugs.launchpad.net/rapid', log_path=log_uri, log_file=log_file ) message = '{header}

{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 = '{}'.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 switch 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 switch 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) state = self.proximityStatePostDeviceRemoval() if state == TemporalProximityState.empty: 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() def proximityStatePostDeviceRemoval(self) -> TemporalProximityState: """ :return: set correct proximity state after a device is removed """ # ignore devices that are scanning - we don't care about them, because the scan # could take a long time, especially with phones if len(self.devices) - len(self.devices.scanning) > 0: # Other already scanned devices are present return TemporalProximityState.regenerate else: return TemporalProximityState.empty @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 = _( "Changing This Computer source path

Do you really want to " "change the source path to %(new_path)s?

You are currently " "downloading from %(source_path)s.

" "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 = _( "Confirm Download Destination

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 = _( """ Downloading all files

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.

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 = _( "The photo and video backup destinations do not exist or cannot " "be written to.

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 = _( "The %(filetype)s backup destination does not exist or cannot be " "written to.

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 = _( "The %(filetype)s backup destination does not exist or cannot be " "written to.

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=files, scan_id=scan_id, download_stats=download_files.download_stats[scan_id], generate_thumbnails=generate_thumbnails ) self.setDownloadActionLabel() def downloadFiles(self, files: List[RPDFile], 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 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=rpd_file, move_succeeded=move_succeeded, do_backup=do_backup, path_suffix=path_suffix, backup_duplicate_overwrite=self.prefs.backup_duplicate_overwrite, verify_file=self.prefs.verify_file, download_count=download_count, save_fdo_thumbnail=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 Exception: 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 = "%(title)s

%(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', bool) def scanFilesReceived(self, rpd_files: List[RPDFile], sample_files: List[RPDFile], file_type_counter: FileTypeCounter, file_size_sum: int, entire_video_required: bool) -> 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 if device.device_type == DeviceType.camera: device.entire_video_required = entire_video_required 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 = _( 'All files on the %(camera)s are inaccessible.

It ' 'may be locked or not configured for file transfers using MTP. ' 'You can unlock it and try again.

On some models you also ' 'need to change the setting USB for charging to USB for ' 'file transfers.

Alternatively, you can ignore this ' 'device.' ) % {'camera': camera_model} else: assert error_code == CameraErrorCode.inaccessible title = _('Rapid Photo Downloader') message = _( 'The %(camera)s appears to be in use by another ' 'application.

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.

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: try: device = self.devices[scan_id] except KeyError: logging.debug("Got scan error from device that no longer exists (scan_id %s)", scan_id) return h1 = _('Sorry, an unexpected problem occurred while scanning %s.') % device.display_name h2 = _('Unfortunately you cannot download from this device.') header = '{}

{}'.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 = '{}

{}'.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 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 if not disable_version_check: 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(create_table_if_not_exists=False) 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 = _( 'The %(camera)s cannot be scanned because it cannot be ' 'unmounted.

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 = _( 'The download cannot start because the %(camera)s cannot be ' 'unmounted.

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() if not on_startup and self.thumbnailModel.anyCompletedDownloads(): if self.prefs.completed_downloads == int(CompletedDownloads.prompt): logging.info("Querying whether to clear completed downloads") counter = self.thumbnailModel.getFileDownloadsCompleted() numbers = counter.file_types_present_details(singular_natural=True).capitalize() plural = sum(counter.values()) > 1 if plural: title = _('Completed Downloads Present') body = _( '%s whose download have completed are displayed.' ) % numbers question = _('Do you want to clear the completed downloads?') else: title = _('Completed Download Present') body = _( '%s whose download has completed is displayed.' ) % numbers question = _('Do you want to clear the completed download?') message = "{}

{}

{}".format(title, body, question) questionDialog = RememberThisDialog( message=message, icon=':/rapid-photo-downloader.svg', remember=RememberThisMessage.do_not_ask_again, parent=self ) clear = questionDialog.exec_() if clear: self.thumbnailModel.clearCompletedDownloads() if questionDialog.remember: if clear: self.prefs.completed_downloads = int(CompletedDownloads.clear) else: self.prefs.completed_downloads = int(CompletedDownloads.keep) elif self.prefs.completed_downloads == int(CompletedDownloads.clear): logging.info("Clearing completed downloads") self.thumbnailModel.clearCompletedDownloads() else: logging.info("Keeping completed downloads") def partitionValid(self, mount: QStorageInfo) -> bool: """ A valid partition is one that is: 1) available 2) the mount name 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 (not self.prefs.scan_specific_folders or has_one_or_more_folders( path=path, folders=self.prefs.folders_to_scan)): 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 folder at the base ' 'level that indicates it should be scanned', 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.scanEvenIfNoFoldersLikeDCIM() 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 %(' 'device)s?' ) % 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: state = self.proximityStatePostDeviceRemoval() if state == TemporalProximityState.empty: 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 = _("Do you want to ignore the %s whenever this program is run?") 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 = _("Do you want to ignore the device %s whenever this program is run?") 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) -> Optional[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 = "" + _( "Downloading from %(location)s on This Computer." ) % dict( location=make_html_path_non_breaking(path) ) + "

" + _( "Do you really want to download from here?

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 scanEvenIfNoFoldersLikeDCIM(self) -> bool: """ Determines if partitions should be scanned even if there is no specific folder like a DCIM folder present in the base folder of the file system. :return: True if scans of such partitions should occur, else False """ return self.prefs.device_autodetection and not self.prefs.scan_specific_folders 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) class QtSingleApplication(QApplication): """ Taken from http://stackoverflow.com/questions/12712360/qtsingleapplication -for-pyside-or-pyqt """ messageReceived = 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' try: ram = psutil.virtual_memory() total = format_size_for_user(ram.total) used = format_size_for_user(ram.used) except Exception: total = used = 'unknown' versions = [ 'Rapid Photo Downloader: {}'.format(__about__.__version__), 'Platform: {}'.format(platform.platform()), 'Memory: {} used of {}'.format(used, total), 'Python: {}'.format(platform.python_version()), 'Python executable: {}'.format(sys.executable), 'Qt: {}'.format(QtCore.QT_VERSION_STR), 'PyQt: {}'.format(QtCore.PYQT_VERSION_STR), 'SIP: {}'.format(sip.SIP_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 try: import tornado versions.append('Tornado: {}'.format(tornado.version)) except ImportError: 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', 'scan_specific_folders', 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_autodetection_psd': print("Setting scan_specific_folders to", not new_value) prefs.scan_specific_folders = not new_value 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.setIcon(QMessageBox.Critical) msg.setText('%s' % 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) rapid_path = os.path.realpath(os.path.dirname(inspect.getfile(inspect.currentframe()))) import_path = os.path.realpath(os.path.dirname(inspect.getfile(downloadtracker))) if rapid_path != import_path: sys.stderr.write( "Rapid Photo Downloader is installed in multiple locations. Uninstall all copies " "except the version you want to run.\n" ) critical_startup_error( _( "Rapid Photo Downloader is installed in multiple locations.\n\nUninstall all " "copies except the version you want to run." ) ) 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) 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) or gvfs_gphoto2_path(args.path) 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(create_table_if_not_exists=False) 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(create_table_if_not_exists=False) 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()