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