diff options
author | Julien Valroff <julien@kirya.net> | 2011-04-16 16:39:17 +0200 |
---|---|---|
committer | Julien Valroff <julien@kirya.net> | 2011-04-16 16:39:17 +0200 |
commit | 5539b9c5aa11891c66104fe51ebb5e9b4ad31538 (patch) | |
tree | a08efd10faf1ac622a62e29d17da6d4f1f0aeac3 /rapid | |
parent | 07934178861343ee213674120db367b8faeef1be (diff) | |
parent | 75b28642dd41fb4a7925b42cb24274de90a4f52c (diff) |
Merge commit 'upstream/0.4.0_beta1' into experimental
Diffstat (limited to 'rapid')
-rw-r--r-- | rapid/ChangeLog | 50 | ||||
-rw-r--r-- | rapid/config.py | 4 | ||||
-rw-r--r-- | rapid/copyfiles.py | 33 | ||||
-rw-r--r-- | rapid/downloadtracker.py | 199 | ||||
-rw-r--r-- | rapid/glade3/media-eject.png | bin | 0 -> 431 bytes | |||
-rw-r--r-- | rapid/glade3/rapid-photo-downloader-download-pending.png | bin | 0 -> 815 bytes | |||
-rw-r--r-- | rapid/glade3/rapid-photo-downloader-download-pending.svg | 187 | ||||
-rw-r--r-- | rapid/glade3/rapid.ui | 6 | ||||
-rw-r--r-- | rapid/preferencesdialog.py | 7 | ||||
-rw-r--r-- | rapid/prefsrapid.py | 35 | ||||
-rwxr-xr-x | rapid/rapid.py | 670 | ||||
-rw-r--r-- | rapid/rpdmultiprocessing.py | 1 | ||||
-rw-r--r-- | rapid/subfolderfile.py | 6 | ||||
-rw-r--r-- | rapid/thumbnail.py | 3 |
14 files changed, 833 insertions, 368 deletions
diff --git a/rapid/ChangeLog b/rapid/ChangeLog index 86037ed..c9437d5 100644 --- a/rapid/ChangeLog +++ b/rapid/ChangeLog @@ -1,3 +1,51 @@ +Version 0.4.0 beta 1 +-------------------- + +2011-04-10 + +Features added since alpha 4: + +* Job Code functionality, mimicking that found in version 0.2.3. +* Eject device button for each unmountable device in main window. +* When not all files have been downloaded from a device, the number remaining + is displayed in the device's progress bar +* Overall download progress is displayed in progress bar at bottom of window +* Time remaining and download speed are displayed in the status bar +* System notification messages +* Automation features: + * Automatically start a download at program startup or when a device + is inserted. When this is enabled, to optimize performance instead of + thumbnails being generated before the files are downloaded, they are + generated during the download. + * Eject a device when all files have been downloaded from it. + * Exit when all files have been downloaded. + +The automation feature to delete downloaded files from a device will be added +only when the non-alpha/beta of version 0.4.0 is released. + +The major feature currently not implemented is backups. + +Note: if videos are downloaded, the device may not be able to be unmounted +until Rapid Photo Downloader is exited. See bug #744012 for details. + +Bug fix: adjust vertical pane position when additional devices are inserted +Bug fix: display file and subfolder naming warnings in error log + +Updated Czech, French and Russian translations. + + +Version 0.3.6 +------------- + +2011-04-05 + +This release contains a minor fix to allow program preferences to be changed +on upcoming Linux distributions like Ubuntu 11.04 and Fedora 15. + +It also contains a minor packaging change so it can be installed in Ubuntu +11.04. + + Version 0.4.0 alpha 4 --------------------- @@ -86,7 +134,7 @@ program startup. Thanks go to Robert Park for refreshing the translations code. Added Romanian translation. - + Version 0.3.5 ------------- diff --git a/rapid/config.py b/rapid/config.py index d020562..da2f5c0 100644 --- a/rapid/config.py +++ b/rapid/config.py @@ -15,7 +15,7 @@ ### along with this program; if not, write to the Free Software ### Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -version = '0.4.0~a4' +version = '0.4.0~b1' GCONF_KEY="/apps/rapid-photo-downloader" @@ -51,7 +51,7 @@ STATUS_DOWNLOAD_FAILED = 6 # tried to download but failed STATUS_WARNING = 7 # warning (shown in pre-download preview) STATUS_CANNOT_DOWNLOAD = 8 # cannot be downloaded -DEFAULT_WINDOW_WIDTH = 730 +DEFAULT_WINDOW_WIDTH = 670 DEFAULT_WINDOW_HEIGHT = 650 diff --git a/rapid/copyfiles.py b/rapid/copyfiles.py index 78fe8a3..08daafe 100644 --- a/rapid/copyfiles.py +++ b/rapid/copyfiles.py @@ -30,6 +30,7 @@ import rpdmultiprocessing as rpdmp import rpdfile import problemnotification as pn import config +import thumbnail as tn from gettext import gettext as _ @@ -37,7 +38,7 @@ from gettext import gettext as _ class CopyFiles(multiprocessing.Process): def __init__(self, photo_download_folder, video_download_folder, files, - scan_pid, + generate_thumbnails, scan_pid, batch_size_MB, results_pipe, terminate_queue, run_event): multiprocessing.Process.__init__(self) @@ -47,6 +48,7 @@ class CopyFiles(multiprocessing.Process): self.photo_download_folder = photo_download_folder self.video_download_folder = video_download_folder self.files = files + self.generate_thumbnails = generate_thumbnails self.scan_pid = scan_pid self.no_files= len(self.files) self.run_event = run_event @@ -69,17 +71,17 @@ class CopyFiles(multiprocessing.Process): # it is - cancel the current copy self.cancel_copy.cancel() else: - if (amount_downloaded - self.bytes_downloaded > self.batch_size_bytes) or (amount_downloaded == total): - chunk_downloaded = amount_downloaded - self.bytes_downloaded + chunk_downloaded = amount_downloaded - self.bytes_downloaded + if (chunk_downloaded > self.batch_size_bytes) or (amount_downloaded == total): self.bytes_downloaded = amount_downloaded - self.results_pipe.send((rpdmp.CONN_PARTIAL, (rpdmp.MSG_BYTES, (self.scan_pid, self.total_downloaded + amount_downloaded)))) + if amount_downloaded == total: + # this function is called a couple of times when total is reached + chunk_downloaded = 0 + self.results_pipe.send((rpdmp.CONN_PARTIAL, (rpdmp.MSG_BYTES, (self.scan_pid, self.total_downloaded + amount_downloaded, chunk_downloaded)))) + if amount_downloaded == total: + self.bytes_downloaded = 0 def progress_callback(self, amount_downloaded, total): - - #~ if self.check_termination_request(): - #~ # FIXME: cancel copy - #~ pass - self.update_progress(amount_downloaded, total) @@ -102,6 +104,10 @@ class CopyFiles(multiprocessing.Process): self.video_temp_dir)))) if self.photo_temp_dir or self.video_temp_dir: + + if self.generate_thumbnails: + self.thumbnail_maker = tn.Thumbnail() + for i in range(len(self.files)): rpd_file = self.files[i] @@ -145,6 +151,15 @@ class CopyFiles(multiprocessing.Process): # succeeded or not. It's neccessary to keep the user informed. self.total_downloaded += rpd_file.size + if copy_succeeded and self.generate_thumbnails: + thumbnail, thumbnail_icon = self.thumbnail_maker.get_thumbnail( + temp_full_file_name, + rpd_file.file_type, + (160, 120), (100,100)) + self.results_pipe.send((rpdmp.CONN_PARTIAL, + (rpdmp.MSG_THUMB, (rpd_file.unique_id, + thumbnail_icon, thumbnail)))) + if rpd_file.metadata is not None: rpd_file.metadata = None diff --git a/rapid/downloadtracker.py b/rapid/downloadtracker.py index f2c80e2..309da71 100644 --- a/rapid/downloadtracker.py +++ b/rapid/downloadtracker.py @@ -17,29 +17,93 @@ ### along with this program; if not, write to the Free Software ### Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +import time +from rpdfile import FILE_TYPE_PHOTO, FILE_TYPE_VIDEO +from config import STATUS_DOWNLOAD_FAILED, STATUS_DOWNLOADED_WITH_WARNING + +from gettext import gettext as _ + class DownloadTracker: + """ + Track file downloads - their size, number, and any problems + """ def __init__(self): self.size_of_download_in_bytes_by_scan_pid = dict() self.total_bytes_copied_in_bytes_by_scan_pid = dict() self.no_files_in_download_by_scan_pid = dict() self.file_types_present_by_scan_pid = dict() + # 'Download count' tracks the index of the file being downloaded + # into the list of files that need to be downloaded -- much like + # a counter in a for loop, e.g. 'for i in list', where i is the counter self.download_count_for_file_by_unique_id = dict() self.download_count_by_scan_pid = dict() self.rename_chunk = dict() self.files_downloaded = dict() + self.photos_downloaded = dict() + self.videos_downloaded = dict() + self.photo_failures = dict() + self.video_failures = dict() + self.warnings = dict() + self.total_photos_downloaded = 0 + self.total_photo_failures = 0 + self.total_videos_downloaded = 0 + self.total_video_failures = 0 + self.total_warnings = 0 + self.total_bytes_to_download = 0 def init_stats(self, scan_pid, bytes, no_files): self.no_files_in_download_by_scan_pid[scan_pid] = no_files self.rename_chunk[scan_pid] = bytes / 10 / no_files self.size_of_download_in_bytes_by_scan_pid[scan_pid] = bytes + self.rename_chunk[scan_pid] * no_files + self.total_bytes_to_download += self.size_of_download_in_bytes_by_scan_pid[scan_pid] self.files_downloaded[scan_pid] = 0 + self.photos_downloaded[scan_pid] = 0 + self.videos_downloaded[scan_pid] = 0 + self.photo_failures[scan_pid] = 0 + self.video_failures[scan_pid] = 0 + self.warnings[scan_pid] = 0 def get_no_files_in_download(self, scan_pid): return self.no_files_in_download_by_scan_pid[scan_pid] - def file_downloaded_increment(self, scan_pid): + + def get_no_files_downloaded(self, scan_pid, file_type): + if file_type == FILE_TYPE_PHOTO: + return self.photos_downloaded.get(scan_pid, 0) + else: + return self.videos_downloaded.get(scan_pid, 0) + + def get_no_files_failed(self, scan_pid, file_type): + if file_type == FILE_TYPE_PHOTO: + return self.photo_failures.get(scan_pid, 0) + else: + return self.video_failures.get(scan_pid, 0) + + def get_no_warnings(self, scan_pid): + return self.warnings.get(scan_pid, 0) + + def file_downloaded_increment(self, scan_pid, file_type, status): self.files_downloaded[scan_pid] += 1 + if status <> STATUS_DOWNLOAD_FAILED: + if file_type == FILE_TYPE_PHOTO: + self.photos_downloaded[scan_pid] += 1 + self.total_photos_downloaded += 1 + else: + self.videos_downloaded[scan_pid] += 1 + self.total_videos_downloaded += 1 + + if status == STATUS_DOWNLOADED_WITH_WARNING: + self.warnings[scan_pid] += 1 + self.total_warnings += 1 + else: + if file_type == FILE_TYPE_PHOTO: + self.photo_failures[scan_pid] += 1 + self.total_photo_failures += 1 + else: + self.video_failures[scan_pid] += 1 + self.total_video_failures += 1 + def get_percent_complete(self, scan_pid): """ @@ -54,6 +118,16 @@ class DownloadTracker: / self.size_of_download_in_bytes_by_scan_pid[scan_pid]) * 100 return percent_complete + def get_overall_percent_complete(self): + total = 0 + for scan_pid in self.total_bytes_copied_in_bytes_by_scan_pid: + total += (self.total_bytes_copied_in_bytes_by_scan_pid[scan_pid] + + (self.rename_chunk[scan_pid] * + self.files_downloaded[scan_pid])) + + percent_complete = float(total) / self.total_bytes_to_download + return percent_complete + def set_total_bytes_copied(self, scan_pid, total_bytes): self.total_bytes_copied_in_bytes_by_scan_pid[scan_pid] = total_bytes @@ -72,7 +146,130 @@ class DownloadTracker: def set_file_types_present(self, scan_pid, file_types_present): self.file_types_present_by_scan_pid[scan_pid] = file_types_present + def no_errors_or_warnings(self): + """ + Return True if there were no errors or warnings in the download + else return False + """ + return (self.total_warnings == 0 and + self.photo_failures == 0 and + self.video_failures == 0) + def purge(self, scan_pid): del self.no_files_in_download_by_scan_pid[scan_pid] del self.size_of_download_in_bytes_by_scan_pid[scan_pid] + del self.photos_downloaded[scan_pid] + del self.videos_downloaded[scan_pid] + del self.files_downloaded[scan_pid] + del self.photo_failures[scan_pid] + del self.video_failures[scan_pid] + del self.warnings[scan_pid] + + def purge_all(self): + self.__init__() + + + +class TimeCheck: + """ + Record times downloads commmence and pause - used in calculating time + remaining. + + Also tracks and reports download speed. + + Note: This is completely independent of the file / subfolder naming + preference "download start time" + """ + + def __init__(self): + # set the number of seconds gap with which to measure download time remaing + self.download_time_gap = 3 + + self.reset() + + def reset(self): + self.mark_set = False + self.total_downloaded_so_far = 0 + self.total_download_size = 0 + self.size_mark = 0 + + def increment(self, bytes_downloaded): + self.total_downloaded_so_far += bytes_downloaded + + def set_download_mark(self): + if not self.mark_set: + self.mark_set = True + + self.time_mark = time.time() + + def pause(self): + self.mark_set = False + + def check_for_update(self): + now = time.time() + update = now > (self.download_time_gap + self.time_mark) + + if update: + amt_time = now - self.time_mark + self.time_mark = now + amt_downloaded = self.total_downloaded_so_far - self.size_mark + self.size_mark = self.total_downloaded_so_far + download_speed = "%1.1f" % (amt_downloaded / 1048576 / amt_time) +_("MB/s") + else: + download_speed = None + + return (update, download_speed) + +class TimeForDownload: + # used to store variables, see below + pass + +class TimeRemaining: + """ + Calculate how much time is remaining to finish a download + """ + gap = 3 + def __init__(self): + self.clear() + + def set(self, scan_pid, size): + t = TimeForDownload() + t.time_remaining = None + t.size = size + t.downloaded = 0 + t.size_mark = 0 + t.time_mark = time.time() + self.times[scan_pid] = t + + def update(self, scan_pid, total_size): + if scan_pid in self.times: + self.times[scan_pid].downloaded = total_size + now = time.time() + tm = self.times[scan_pid].time_mark + amt_time = now - tm + if amt_time > self.gap: + self.times[scan_pid].time_mark = now + amt_downloaded = self.times[scan_pid].downloaded - self.times[scan_pid].size_mark + self.times[scan_pid].size_mark = self.times[scan_pid].downloaded + timefraction = amt_downloaded / float(amt_time) + amt_to_download = float(self.times[scan_pid].size) - self.times[scan_pid].downloaded + if timefraction: + self.times[scan_pid].time_remaining = amt_to_download / timefraction + + def _time_estimates(self): + for t in self.times: + yield self.times[t].time_remaining + + def time_remaining(self): + return max(self._time_estimates()) + + def set_time_mark(self, scan_pid): + if scan_pid in self.times: + self.times[scan_pid].time_mark = time.time() + + def clear(self): + self.times = {} + def remove(self, scan_pid): + if scan_pid in self.times: + del self.times[scan_pid] diff --git a/rapid/glade3/media-eject.png b/rapid/glade3/media-eject.png Binary files differnew file mode 100644 index 0000000..0ff107e --- /dev/null +++ b/rapid/glade3/media-eject.png diff --git a/rapid/glade3/rapid-photo-downloader-download-pending.png b/rapid/glade3/rapid-photo-downloader-download-pending.png Binary files differnew file mode 100644 index 0000000..e12cc69 --- /dev/null +++ b/rapid/glade3/rapid-photo-downloader-download-pending.png diff --git a/rapid/glade3/rapid-photo-downloader-download-pending.svg b/rapid/glade3/rapid-photo-downloader-download-pending.svg deleted file mode 100644 index d6127b7..0000000 --- a/rapid/glade3/rapid-photo-downloader-download-pending.svg +++ /dev/null @@ -1,187 +0,0 @@ -<?xml version="1.0" encoding="UTF-8" standalone="no"?> -<!-- Created with Inkscape (http://www.inkscape.org/) --> - -<svg - xmlns:dc="http://purl.org/dc/elements/1.1/" - xmlns:cc="http://creativecommons.org/ns#" - xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" - xmlns:svg="http://www.w3.org/2000/svg" - xmlns="http://www.w3.org/2000/svg" - xmlns:xlink="http://www.w3.org/1999/xlink" - xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" - xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" - version="1.0" - width="16" - height="16" - id="svg4136" - inkscape:version="0.47 r22583" - sodipodi:docname="rapid-photo-downloader-image-loading.svg"> - <metadata - id="metadata33"> - <rdf:RDF> - <cc:Work - rdf:about=""> - <dc:format>image/svg+xml</dc:format> - <dc:type - rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> - </cc:Work> - </rdf:RDF> - </metadata> - <sodipodi:namedview - pagecolor="#ffffff" - bordercolor="#666666" - borderopacity="1" - objecttolerance="10" - gridtolerance="10" - guidetolerance="10" - inkscape:pageopacity="0" - inkscape:pageshadow="2" - inkscape:window-width="1920" - inkscape:window-height="1089" - id="namedview31" - showgrid="false" - inkscape:zoom="9.8333333" - inkscape:cx="12" - inkscape:cy="12" - inkscape:window-x="0" - inkscape:window-y="24" - inkscape:window-maximized="1" - inkscape:current-layer="svg4136" /> - <defs - id="defs4138"> - <inkscape:perspective - sodipodi:type="inkscape:persp3d" - inkscape:vp_x="0 : 12 : 1" - inkscape:vp_y="0 : 1000 : 0" - inkscape:vp_z="24 : 12 : 1" - inkscape:persp3d-origin="12 : 8 : 1" - id="perspective35" /> - <linearGradient - id="linearGradient8838"> - <stop - id="stop8840" - style="stop-color:black;stop-opacity:1" - offset="0" /> - <stop - id="stop8842" - style="stop-color:black;stop-opacity:0" - offset="1" /> - </linearGradient> - <radialGradient - cx="62.625" - cy="4.625" - r="10.625" - fx="62.625" - fy="4.625" - id="radialGradient5323" - xlink:href="#linearGradient8838" - gradientUnits="userSpaceOnUse" - gradientTransform="matrix(1,0,0,0.341176,0,3.047059)" /> - <linearGradient - id="linearGradient5354"> - <stop - id="stop5356" - style="stop-color:#3f3f3f;stop-opacity:1" - offset="0" /> - <stop - id="stop5358" - style="stop-color:black;stop-opacity:1" - offset="1" /> - </linearGradient> - <linearGradient - x1="19.176617" - y1="13.479795" - x2="19.176617" - y2="45.358662" - id="linearGradient5130" - xlink:href="#linearGradient5354" - gradientUnits="userSpaceOnUse" - gradientTransform="matrix(0.4916775,0,0,0.4916774,0.6986745,-0.3018277)" /> - <linearGradient - id="linearGradient37935"> - <stop - id="stop37937" - style="stop-color:#929292;stop-opacity:1" - offset="0" /> - <stop - id="stop37939" - style="stop-color:#4a4a4a;stop-opacity:1" - offset="1" /> - </linearGradient> - <linearGradient - x1="28.771276" - y1="12.91806" - x2="28.771276" - y2="45.347591" - id="linearGradient5128" - xlink:href="#linearGradient37935" - gradientUnits="userSpaceOnUse" - gradientTransform="matrix(0.4916775,0,0,0.4916774,0.6986745,-0.3018277)" /> - <linearGradient - id="linearGradient2145"> - <stop - id="stop2147" - style="stop-color:#fffffd;stop-opacity:1" - offset="0" /> - <stop - id="stop2149" - style="stop-color:#cbcbc9;stop-opacity:1" - offset="1" /> - </linearGradient> - <radialGradient - cx="11.901996" - cy="10.045444" - r="29.292715" - fx="11.901996" - fy="10.045444" - id="radialGradient5350" - xlink:href="#linearGradient2145" - gradientUnits="userSpaceOnUse" /> - </defs> - <g - id="layer1" - transform="matrix(0.652174,0,0,0.65491337,-0.15217376,0.2820791)"> - <path - d="m 73.25,4.625 a 10.625,3.625 0 1 1 -21.25,0 10.625,3.625 0 1 1 21.25,0 z" - transform="matrix(1.0823528,0,0,1.2906765,-55.282346,13.351919)" - id="path2774" - style="opacity:0.56043958;fill:url(#radialGradient5323);fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.99999988;marker:none;visibility:visible;display:inline;overflow:visible" /> - <path - d="m 12.492145,1.4999735 c -5.5190685,0 -9.9921705,4.473101 -9.9921705,9.9921715 0,5.519069 4.473102,10.007882 9.9921705,10.007881 5.519068,0 10.007887,-4.488812 10.007882,-10.007881 0,-5.5190705 -4.488814,-9.9921715 -10.007882,-9.9921715 z" - id="path2555" - style="fill:url(#linearGradient5128);fill-opacity:1;stroke:url(#linearGradient5130);stroke-width:0.99994898;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:0" /> - <path - d="m 31.160714,16.910715 a 14.910714,14.910714 0 1 1 -29.8214281,0 14.910714,14.910714 0 1 1 29.8214281,0 z" - transform="matrix(0.5700599,0,0,0.5700599,3.2365269,1.8598792)" - id="path35549" - style="fill:url(#radialGradient5350);fill-opacity:1;fill-rule:evenodd;stroke:none" /> - <path - d="m 12.5,6.4999999 c 0,-1.3926725 0,-1.5690116 0,-1.5690116" - id="path2308" - style="fill:#1f1f1f;fill-opacity:1;fill-rule:evenodd;stroke:#727272;stroke-width:1.00000012;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" /> - <path - d="M 12.194529,11.713855 17.596253,6.3122524" - id="path2312" - style="fill:none;stroke:#000000;stroke-width:0.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" /> - <path - d="M 13.071561,11.753718 9.4637681,8.1460064" - id="path2314" - style="fill:none;stroke:#000000;stroke-width:0.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" /> - <path - d="M 12.314125,11.434805 18.465311,9.7866365" - id="path2316" - style="fill:#ff0000;fill-rule:evenodd;stroke:#ff0000;stroke-width:0.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" /> - <path - d="m 12.5,18.284507 c 0,-1.392673 0,-1.569012 0,-1.569012" - id="path5368" - style="fill:#121212;fill-opacity:1;fill-rule:evenodd;stroke:#121212;stroke-width:1.00000012;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" /> - <path - d="m 17.5,11.5 c 1.392673,0 1.569013,0 1.569013,0" - id="path5370" - style="fill:#1f1f1f;fill-opacity:1;fill-rule:evenodd;stroke:#1f1f1f;stroke-width:1.00000012;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" /> - <path - d="m 5.7331351,11.5 c 1.3613563,0 1.5337301,0 1.5337301,0" - id="path5372" - style="fill:#1f1f1f;fill-opacity:1;fill-rule:evenodd;stroke:#5f5f5f;stroke-width:1.00000012;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" /> - </g> -</svg> diff --git a/rapid/glade3/rapid.ui b/rapid/glade3/rapid.ui index 1b587b4..bd9638e 100644 --- a/rapid/glade3/rapid.ui +++ b/rapid/glade3/rapid.ui @@ -253,8 +253,8 @@ <property name="use_action_appearance">False</property> <property name="use_underline">True</property> <property name="use_stock">True</property> - <accelerator key="equal" signal="activate" modifiers="GDK_CONTROL_MASK"/> <accelerator key="plus" signal="activate" modifiers="GDK_CONTROL_MASK"/> + <accelerator key="equal" signal="activate" modifiers="GDK_CONTROL_MASK"/> </object> </child> <child> @@ -405,8 +405,8 @@ <object class="GtkScrolledWindow" id="device_collection_scrolledwindow"> <property name="visible">True</property> <property name="can_focus">True</property> - <property name="hscrollbar_policy">automatic</property> - <property name="vscrollbar_policy">never</property> + <property name="hscrollbar_policy">never</property> + <property name="vscrollbar_policy">automatic</property> <child> <object class="GtkViewport" id="device_collection_viewport"> <property name="visible">True</property> diff --git a/rapid/preferencesdialog.py b/rapid/preferencesdialog.py index 4909820..0f46406 100644 --- a/rapid/preferencesdialog.py +++ b/rapid/preferencesdialog.py @@ -313,7 +313,6 @@ class PreferenceWidgets: Checks preferences validity """ - #~ print logger.debug(dir(self)) return check_pref_valid(self.pref_defn_L0, self.pref_list) class PhotoNamePrefs(PreferenceWidgets): @@ -911,6 +910,9 @@ class PreferencesDialog(): If use_dummy_data is True, then samples will not attempt to get data from actual download files """ + job_code = self.prefs.most_recent_job_code() + if job_code is None: + job_code = _("Job Code") self.downloads_today_tracker = DownloadsTodayTracker( day_start = self.prefs.day_start, downloads_today = self.prefs.downloads_today[1], @@ -934,6 +936,8 @@ class PreferencesDialog(): if self.sample_photo is None: self.sample_photo = rpdfile.SamplePhoto(sequences=self.sequences) + + self.sample_photo.job_code = job_code self.sample_video = None if metadatavideo.DOWNLOAD_VIDEO: @@ -945,6 +949,7 @@ class PreferencesDialog(): self.sample_video.download_start_time = datetime.datetime.now() if self.sample_video is None: self.sample_video = rpdfile.SampleVideo(sequences=self.sequences) + self.sample_video.job_code = job_code diff --git a/rapid/prefsrapid.py b/rapid/prefsrapid.py index 491d75d..81a425b 100644 --- a/rapid/prefsrapid.py +++ b/rapid/prefsrapid.py @@ -218,6 +218,12 @@ class RapidPreferences(prefs.Preferences): return True return False + def most_recent_job_code(self): + if len(self.job_codes) > 0: + return self.job_codes[0] + else: + return None + def get_pref_lists_by_file_type(self, file_type): """ Returns tuple of subfolder and file rename pref lists for the given @@ -355,20 +361,27 @@ def check_prefs_for_validity(prefs): """ Checks preferences for validity (called at program startup) - Returns true if the passed in preferences are valid, else returns False + Returns tuple with two values: + 1. true if the passed in preferences are valid, else returns False + 2. message if prefs are invalid """ - try: - tests = ((prefs.image_rename, pd.PhotoNamePrefs), - (prefs.subfolder, pd.PhotoSubfolderPrefs), - (prefs.video_rename, pd.VideoNamePrefs), - (prefs.video_subfolder, pd.VideoSubfolderPrefs)) - for pref, pref_widgets in tests: - p = pref_widgets(pref) + + msg = '' + valid = True + tests = ((prefs.image_rename, pd.PhotoNamePrefs), + (prefs.subfolder, pd.PhotoSubfolderPrefs), + (prefs.video_rename, pd.VideoNamePrefs), + (prefs.video_subfolder, pd.VideoSubfolderPrefs)) + for pref, pref_widgets in tests: + p = pref_widgets(pref) + try: p.check_prefs_for_validity() - except: - return False - return True + except pd.PrefError as e: + valid = False + msg += e.msg + "\n" + + return (valid, msg) def insert_pref_lists(prefs, rpd_file): """ diff --git a/rapid/rapid.py b/rapid/rapid.py index 3603025..df60790 100755 --- a/rapid/rapid.py +++ b/rapid/rapid.py @@ -36,6 +36,7 @@ import webbrowser import sys, time, types, os, datetime import gobject, pango, cairo, array, pangocairo, gio +import pynotify from multiprocessing import Process, Pipe, Queue, Event, Value, Array, current_process, log_to_stderr from ctypes import c_int, c_bool, c_char @@ -127,9 +128,12 @@ class DeviceCollection(gtk.TreeView): self.parent_app = parent_app # device icon & name, size of images on the device (human readable), - # copy progress (%), copy text - self.liststore = gtk.ListStore(gtk.gdk.Pixbuf, str, str, float, str) + # copy progress (%), copy text, eject button (None if irrelevant), + # process id + self.liststore = gtk.ListStore(gtk.gdk.Pixbuf, str, str, float, str, + gtk.gdk.Pixbuf, int) self.map_process_to_row = {} + self.devices_by_scan_pid = {} gtk.TreeView.__init__(self, self.liststore) @@ -145,11 +149,14 @@ class DeviceCollection(gtk.TreeView): pixbuf_renderer = gtk.CellRendererPixbuf() text_renderer = gtk.CellRendererText() text_renderer.props.ellipsize = pango.ELLIPSIZE_MIDDLE - text_renderer.set_fixed_size(160, -1) + text_renderer.set_fixed_size(160, -1) + eject_renderer = gtk.CellRendererPixbuf() column0.pack_start(pixbuf_renderer, expand=False) column0.pack_start(text_renderer, expand=True) + column0.pack_end(eject_renderer, expand=False) column0.add_attribute(pixbuf_renderer, 'pixbuf', 0) column0.add_attribute(text_renderer, 'text', 1) + column0.add_attribute(eject_renderer, 'pixbuf', 5) self.append_column(column0) @@ -165,19 +172,49 @@ class DeviceCollection(gtk.TreeView): self.append_column(column2) self.show_all() + icontheme = gtk.icon_theme_get_default() + try: + self.eject_pixbuf = icontheme.load_icon('media-eject', 16, + gtk.ICON_LOOKUP_USE_BUILTIN) + except: + self.eject_pixbuf = gtk.gdk.pixbuf_new_from_file( + paths.share_dir('glade3/media-eject.png')) + + self.add_events(gtk.gdk.BUTTON_PRESS_MASK) + self.connect('button-press-event', self.button_clicked) + + def add_device(self, process_id, device, progress_bar_text = ''): # add the row, and get a temporary pointer to the row size_files = '' progress = 0.0 + + if device.mount is None: + eject = None + else: + eject = self.eject_pixbuf + + self.devices_by_scan_pid[process_id] = device + iter = self.liststore.append((device.get_icon(), device.get_name(), size_files, progress, - progress_bar_text)) + progress_bar_text, + eject, + process_id)) self._set_process_map(process_id, iter) + + # adjust scrolled window height, based on row height and number of ready to start downloads + # please note, at program startup, self.row_height() will be less than it will be when already running + # e.g. when starting with 3 cards, it could be 18, but when adding 2 cards to the already running program + # (with one card at startup), it could be 21 + row_height = self.get_background_area(0, self.get_column(0))[3] + 1 + height = (len(self.map_process_to_row) + 1) * row_height + self.parent_app.device_collection_scrolledwindow.set_size_request(-1, height) def update_device(self, process_id, total_size_files): """ @@ -187,13 +224,17 @@ class DeviceCollection(gtk.TreeView): iter = self._get_process_map(process_id) self.liststore.set_value(iter, 2, total_size_files) else: - logger.error("This device is unknown") + logger.critical("This device is unknown") + + def get_device(self, process_id): + return self.devices_by_scan_pid.get(process_id) def remove_device(self, process_id): if process_id in self.map_process_to_row: iter = self._get_process_map(process_id) self.liststore.remove(iter) del self.map_process_to_row[process_id] + del self.devices_by_scan_pid[process_id] def get_all_displayed_processes(self): """ @@ -237,6 +278,44 @@ class DeviceCollection(gtk.TreeView): pass #~ logger.info("Implement update overall progress") + def button_clicked(self, widget, event): + """ + Look for left single click on eject button + """ + if event.button == 1: + x = int(event.x) + y = int(event.y) + path, column, cell_x, cell_y = self.get_path_at_pos(x, y) + if path is not None: + if column == self.get_column(0): + if cell_x >= column.get_width() - self.eject_pixbuf.get_width(): + iter = self.liststore.get_iter(path) + if self.liststore.get_value(iter, 5) is not None: + self.unmount(process_id = self.liststore.get_value(iter, 6)) + + def unmount(self, process_id): + device = self.devices_by_scan_pid[process_id] + if device.mount is not None: + logger.debug("Unmounting device with scan pid %s", process_id) + device.mount.unmount(self.unmount_callback) + + + def unmount_callback(self, mount, result): + name = mount.get_name() + + try: + mount.unmount_finish(result) + logger.debug("%s successfully unmounted" % name) + except gio.Error, inst: + logger.error("%s did not unmount: %s", name, inst) + + title = _("%(device)s did not unmount") % {'device': name} + message = '%s' % inst + + n = pynotify.Notification(title, message) + n.set_icon_from_pixbuf(self.parent_app.application_icon) + n.show() + def create_cairo_image_surface(pil_image, image_width, image_height): imgd = pil_image.tostring("raw","BGRA", 0, 1) @@ -283,7 +362,6 @@ class ThumbnailCellRenderer(gtk.CellRenderer): w = cell_area.width h = cell_area.height - #constrain operations to cell area, allowing for a 1 pixel border #either side #~ cairo_context.rectangle(x-1, y-1, w+2, h+2) @@ -316,9 +394,8 @@ class ThumbnailCellRenderer(gtk.CellRenderer): cairo_context.stroke() # draw a thin border around each cell - # ouch - nasty hardcoding :( - #~ cairo_context.set_source_rgb(0.33, 0.33, 0.33) - #~ cairo_context.rectangle(x-6.5, y-9.5, w+14, h+31) + #~ cairo_context.set_source_rgb(0.33,0.33,0.33) + #~ cairo_context.rectangle(x, y, w, h) #~ cairo_context.stroke() #place the image @@ -362,7 +439,6 @@ class ThumbnailCellRenderer(gtk.CellRenderer): cairo_context.paint() def do_get_size(self, widget, cell_area): - #~ return (0, 0, self.image_area_size, self.image_area_size + self.checkbutton_height + 10) return (0, 0, self.image_area_size, self.image_area_size + self.text_area_size - self.checkbutton_height + 4) @@ -372,6 +448,10 @@ gobject.type_register(ThumbnailCellRenderer) class ThumbnailDisplay(gtk.IconView): def __init__(self, parent_app): gtk.IconView.__init__(self) + self.set_spacing(0) + self.set_row_spacing(5) + self.set_margin(25) + self.rapid_app = parent_app self.batch_size = 10 @@ -384,7 +464,7 @@ class ThumbnailDisplay(gtk.IconView): self.rpd_files = {} - self.total_files = 0 + self.total_thumbs_to_generate = 0 self.thumbnails_generated = 0 self.thumbnails = {} @@ -414,16 +494,17 @@ class ThumbnailDisplay(gtk.IconView): gtk.gdk.Pixbuf, # 8 status icon ) - self.clear() self.set_model(self.liststore) + checkbutton = gtk.CellRendererToggle() checkbutton.set_radio(False) checkbutton.props.activatable = True checkbutton.props.xalign = 0.0 checkbutton.connect('toggled', self.on_checkbutton_toggled) self.pack_end(checkbutton, expand=False) + self.add_attribute(checkbutton, "active", 1) self.add_attribute(checkbutton, "visible", 6) @@ -431,29 +512,22 @@ class ThumbnailDisplay(gtk.IconView): checkbutton_height = checkbutton_size[3] checkbutton_width = checkbutton_size[2] - #~ status_icon = gtk.CellRendererPixbuf() - #~ self.pack_start(status_icon, expand=False) - #~ self.add_attribute(status_icon, "pixbuf", self.STATUS_ICON_COL) - image = ThumbnailCellRenderer(checkbutton_height) self.pack_start(image, expand=True) self.add_attribute(image, "image", 0) self.add_attribute(image, "filename", 3) self.add_attribute(image, "status", 8) + #set the background color to a darkish grey self.modify_base(gtk.STATE_NORMAL, gtk.gdk.Color('#444444')) - self.set_spacing(0) - #~ self.set_column_spacing(0) - self.set_row_spacing(5) - #~ self.set_row_spacing(0) - self.set_margin(25) + self.show_all() self._setup_icons() - - self.show_all() + + self.connect('item-activated', self.on_item_activated) @@ -474,7 +548,7 @@ class ThumbnailDisplay(gtk.IconView): paths.share_dir('glade3/rapid-photo-downloader-downloaded.svg'), size, size) self.download_pending_icon = gtk.gdk.pixbuf_new_from_file_at_size( - paths.share_dir('glade3/rapid-photo-downloader-download-pending.svg'), + paths.share_dir('glade3/rapid-photo-downloader-download-pending.png'), size, size) self.downloaded_with_warning_icon = gtk.gdk.pixbuf_new_from_file_at_size( paths.share_dir('glade3/rapid-photo-downloader-downloaded-with-warning.svg'), @@ -523,7 +597,7 @@ class ThumbnailDisplay(gtk.IconView): iter = self.get_iter_from_unique_id(unique_id) self.liststore.set_value(iter, self.SELECTED_COL, value) - def add_file(self, rpd_file): + def add_file(self, rpd_file, generate_thumbnail): thumbnail_icon = self.get_stock_icon(rpd_file.file_type) unique_id = rpd_file.unique_id @@ -553,7 +627,8 @@ class ThumbnailDisplay(gtk.IconView): self.treerow_index[unique_id] = treerowref self.rpd_files[unique_id] = rpd_file - self.total_files += 1 + if generate_thumbnail: + self.total_thumbs_to_generate += 1 def get_sample_file(self, file_type): """Returns an rpd_file for of a given file type, or None if it does @@ -701,24 +776,56 @@ class ThumbnailDisplay(gtk.IconView): return True return False - def get_files_checked_for_download(self): + def get_files_checked_for_download(self, scan_pid): """ Returns a dict of scan ids and associated files the user has indicated they want to download + + If scan_pid is not None, then returns only those files from that scan_pid """ files = dict() - for row in self.liststore: - if row[self.SELECTED_COL]: - rpd_file = self.rpd_files[row[self.UNIQUE_ID_COL]] + if scan_pid is None: + for row in self.liststore: + if row[self.SELECTED_COL]: + rpd_file = self.rpd_files[row[self.UNIQUE_ID_COL]] + if rpd_file.status not in DOWNLOADED: + scan_pid = rpd_file.scan_pid + if scan_pid in files: + files[scan_pid].append(rpd_file) + else: + files[scan_pid] = [rpd_file,] + else: + files[scan_pid] = [] + for unique_id in self.process_index[scan_pid]: + rpd_file = self.rpd_files[unique_id] if rpd_file.status not in DOWNLOADED: - scan_pid = rpd_file.scan_pid - if scan_pid in files: + iter = self.get_iter_from_unique_id(unique_id) + if self.liststore.get_value(iter, self.SELECTED_COL): files[scan_pid].append(rpd_file) - else: - files[scan_pid] = [rpd_file,] - return files + def get_no_files_remaining(self, scan_pid): + """ + Returns the number of files that have not yet been downloaded for the + scan_pid + """ + i = 0 + for unique_id in self.process_index[scan_pid]: + rpd_file = self.rpd_files[unique_id] + if rpd_file.status == STATUS_NOT_DOWNLOADED: + i += 1 + return i + + def files_remain_to_download(self): + """ + Returns True if any files remain that are not downloaded, else returns + False + """ + for row in self.liststore: + if row[self.DOWNLOAD_STATUS_COL] == STATUS_NOT_DOWNLOADED: + return True + return False + def mark_download_pending(self, files_by_scan_pid): """ @@ -729,7 +836,11 @@ class ThumbnailDisplay(gtk.IconView): unique_id = rpd_file.unique_id self.rpd_files[unique_id].status = STATUS_DOWNLOAD_PENDING iter = self.get_iter_from_unique_id(unique_id) - self.liststore.set_value(iter, self.CHECKBUTTON_VISIBLE_COL, False) + if not self.rapid_app.auto_start_is_on: + # don't make the checkbox invisible immediately when on auto start + # otherwise the box can be rendred at the wrong size, as it is + # realized after the checkbox has already been made invisible + self.liststore.set_value(iter, self.CHECKBUTTON_VISIBLE_COL, False) self.liststore.set_value(iter, self.SELECTED_COL, False) self.liststore.set_value(iter, self.DOWNLOAD_STATUS_COL, STATUS_DOWNLOAD_PENDING) icon = self.get_status_icon(STATUS_DOWNLOAD_PENDING) @@ -752,6 +863,7 @@ class ThumbnailDisplay(gtk.IconView): self.liststore.set_value(iter, self.DOWNLOAD_STATUS_COL, rpd_file.status) icon = self.get_status_icon(rpd_file.status) self.liststore.set_value(iter, self.STATUS_ICON_COL, icon) + self.liststore.set_value(iter, self.CHECKBUTTON_VISIBLE_COL, False) self.rpd_files[rpd_file.unique_id] = rpd_file def generate_thumbnails(self, scan_pid): @@ -803,12 +915,15 @@ class ThumbnailDisplay(gtk.IconView): # clear progress bar information if all thumbnails have been # extracted - if self.thumbnails_generated == self.total_files: + if self.thumbnails_generated == self.total_thumbs_to_generate: self.rapid_app.download_progressbar.set_fraction(0.0) self.rapid_app.download_progressbar.set_text('') + self.thumbnails_generated = 0 + self.total_thumbs_to_generate = 0 + else: self.rapid_app.download_progressbar.set_fraction( - float(self.thumbnails_generated) / self.total_files) + float(self.thumbnails_generated) / self.total_thumbs_to_generate) return True @@ -865,11 +980,13 @@ class TaskManager: self.batch_size = batch_size self.paused = False + self.no_tasks = 0 def add_task(self, task): pid = self._setup_task(task) logger.debug("TaskManager PID: %s", pid) + self.no_tasks += 1 return pid @@ -982,10 +1099,12 @@ class CopyFilesManager(TaskManager): video_download_folder = task[1] scan_pid = task[2] files = task[3] + generate_thumbnails = task[4] copy_files = copyfiles.CopyFiles(photo_download_folder, video_download_folder, - files, scan_pid, self.batch_size, + files, generate_thumbnails, + scan_pid, self.batch_size, task_process_conn, terminate_queue, run_event) copy_files.start() self._processes.append((copy_files, terminate_queue, run_event)) @@ -1188,6 +1307,7 @@ class RapidApp(dbus.service.Object): # Initialize widgets in the main window, and variables that point to them self._init_widgets() + self._init_pynotify() # Initialize job code handling self._init_job_code() @@ -1205,8 +1325,9 @@ class RapidApp(dbus.service.Object): self.rapidapp.show() # Check program preferences - don't allow auto start if there is a problem - prefs_valid = prefsrapid.check_prefs_for_validity(self.prefs) - do_not_allow_auto_start = prefs_valid + prefs_valid, msg = prefsrapid.check_prefs_for_validity(self.prefs) + if not prefs_valid: + self.notify_prefs_are_invalid(details=msg) # Initialize variables with which to track important downloads results self._init_download_tracking() @@ -1218,12 +1339,10 @@ class RapidApp(dbus.service.Object): # Setup devices from which to download from and backup to self.setup_devices(on_startup=True, on_preference_change=False, - do_not_allow_auto_start=do_not_allow_auto_start) + block_auto_start=not prefs_valid) # Ensure the device collection scrolled window is not too small self._set_device_collection_size() - - #~ preferencesdialog.PreferencesDialog(self) def on_rapidapp_destroy(self, widget, data=None): @@ -1312,7 +1431,7 @@ class RapidApp(dbus.service.Object): def on_refresh_action_activate(self, action): self.setup_devices(on_startup=False, on_preference_change=False, - do_not_allow_auto_start=True) + block_auto_start=True) def on_get_help_action_activate(self, action): webbrowser.open("http://www.damonlynch.net/rapid/help.html") @@ -1379,7 +1498,7 @@ class RapidApp(dbus.service.Object): self.vmonitor.connect("mount-removed", self.on_mount_removed) - def setup_devices(self, on_startup, on_preference_change, do_not_allow_auto_start): + def setup_devices(self, on_startup, on_preference_change, block_auto_start): """ Setup devices from which to download from and backup to @@ -1392,6 +1511,9 @@ class RapidApp(dbus.service.Object): on_preference_change should be True if this is being called as the result of a preference being changed + block_auto_start should be True if automation options to automatically + start a download should be ignored + Removes any image media that are currently not downloaded, or finished downloading """ @@ -1449,7 +1571,7 @@ class RapidApp(dbus.service.Object): # Display amount of free space in a status bar message self.display_free_space() - if do_not_allow_auto_start: + if block_auto_start: self.auto_start_is_on = False else: self.auto_start_is_on = ((not on_preference_change) and @@ -1475,6 +1597,8 @@ class RapidApp(dbus.service.Object): scan_pid = self.scan_manager.add_task(device) if mount is not None: self.mounts_by_path[path] = scan_pid + if not mounts: + self.set_download_action_sensitivity() def get_use_device(self, device): """ Prompt user whether or not to download from this device """ @@ -1553,28 +1677,30 @@ class RapidApp(dbus.service.Object): return path = mount.get_root().get_path() + if path is not None: - if path in self.prefs.device_blacklist and self.search_for_PSD(): - logger.info("Device %(device)s (%(path)s) ignored" % { - 'device': mount.get_name(), 'path': path}) - else: - is_backup_mount = self.check_if_backup_mount(path) - - if is_backup_mount: - if path not in self.backup_devices: - self.backup_devices[path] = mount - self.display_free_space() - - elif self.prefs.device_autodetection and (dv.is_DCIM_device(path) or - self.search_for_PSD()): - - device = dv.Device(path=path, mount=mount) - if self.search_for_PSD() and path not in self.prefs.device_whitelist: - # prompt user if device should be used or not - self.get_use_device(device) - else: - scan_pid = self.scan_manager.add_task(device) - self.mounts_by_path[path] = scan_pid + if path in self.prefs.device_blacklist and self.search_for_PSD(): + logger.info("Device %(device)s (%(path)s) ignored" % { + 'device': mount.get_name(), 'path': path}) + else: + is_backup_mount = self.check_if_backup_mount(path) + + if is_backup_mount: + if path not in self.backup_devices: + self.backup_devices[path] = mount + self.display_free_space() + + elif self.prefs.device_autodetection and (dv.is_DCIM_device(path) or + self.search_for_PSD()): + + self.auto_start_is_on = self.prefs.auto_download_upon_device_insertion + device = dv.Device(path=path, mount=mount) + if self.search_for_PSD() and path not in self.prefs.device_whitelist: + # prompt user if device should be used or not + self.get_use_device(device) + else: + scan_pid = self.scan_manager.add_task(device) + self.mounts_by_path[path] = scan_pid def on_mount_removed(self, vmonitor, mount): """ @@ -1643,7 +1769,10 @@ class RapidApp(dbus.service.Object): logger.debug("Download activated") if self.download_action_is_download: - self.start_download() + if self.need_job_code_for_naming and not self.prompting_for_job_code: + self.get_job_code() + else: + self.start_download() else: self.pause_download() @@ -1663,10 +1792,10 @@ class RapidApp(dbus.service.Object): """ if not self.download_is_occurring(): sensitivity = False - if self.scan_manager.get_no_active_processes() == 0: + if self.scan_manager.no_tasks == 0: if self.thumbnails.files_are_checked_to_download(): sensitivity = True - + self.download_action.set_sensitive(sensitivity) def set_download_action_label(self, is_download): @@ -1687,21 +1816,19 @@ class RapidApp(dbus.service.Object): def _init_job_code(self): - self.job_code = None + self.job_code = self.last_chosen_job_code = '' self.need_job_code_for_naming = self.prefs.any_pref_uses_job_code() - - def assign_job_code(self, code): - """ assign job code (which may be empty) to global variable and update user preferences + self.prompting_for_job_code = False + + def assign_job_code(self, code): + """ assign job code (which may be empty) to member variable and update user preferences Update preferences only if code is not empty. Do not duplicate job code. """ - # FIXME - #~ global job_code - if code == None: - code = '' - job_code = code + + self.job_code = code - if job_code: + if code: #add this value to job codes preferences #delete any existing value which is the same #(this way it comes to the front, which is where it should be) @@ -1713,7 +1840,7 @@ class RapidApp(dbus.service.Object): self.prefs.job_codes = [code] + jcs - def _get_job_code(self, post_job_code_entry_callback, autoStart, downloadSelected): + def _get_job_code(self, post_job_code_entry_callback): """ prompt for a job code """ if not self.prompting_for_job_code: @@ -1735,29 +1862,19 @@ class RapidApp(dbus.service.Object): self.prompting_for_job_code = False if user_chose_code: + if code is None: + code = '' self.assign_job_code(code) self.last_chosen_job_code = code - #~ self.selection_vbox.selection_treeview.apply_job_code(code, overwrite=False, to_all_rows = not downloadSelected) - #~ threads = self.selection_vbox.selection_treeview.set_status_to_download_pending(selected_only = downloadSelected) - #~ if downloadSelected or not autoStart: - #~ logger.debug("Starting downloads") - #~ self.startDownload(threads) - #~ else: - #~ # autostart is true - #~ logger.debug("Starting downloads that have been waiting for a Job Code") - #~ for w in workers.getWaitingForJobCodeWorkers(): - #~ w.startStop() + logger.debug("Job Code %s entered", self.job_code) + self.start_download() else: # user cancelled - pass - #~ logger.debug("No Job Code entered") - #~ for w in workers.getWaitingForJobCodeWorkers(): - #~ w.waitingForJobCode = False - #~ - #~ if autoStart: - #~ for w in workers.getAutoStartWorkers(): - #~ w.autoStart = False + logger.debug("No Job Code entered") + self.job_code = '' + self.auto_start_is_on = False + # # # # Download @@ -1780,13 +1897,14 @@ class RapidApp(dbus.service.Object): - def start_download(self): + def start_download(self, scan_pid=None): """ Start download, renaming and backup of files. + + If scan_pid is specified, only files matching it will be downloaded """ - self.download_start_time = datetime.datetime.now() - files_by_scan_pid = self.thumbnails.get_files_checked_for_download() + files_by_scan_pid = self.thumbnails.get_files_checked_for_download(scan_pid) folders_valid, invalid_dirs = self.check_download_folder_validity(files_by_scan_pid) if not folders_valid: @@ -1798,6 +1916,11 @@ class RapidApp(dbus.service.Object): self.log_error(config.CRITICAL_ERROR, _("Download cannot proceed"), msg) else: + # set time download is starting if it is not already set + # it is unset when all downloads are completed + if self.download_start_time is None: + self.download_start_time = datetime.datetime.now() + self.thumbnails.mark_download_pending(files_by_scan_pid) for scan_pid in files_by_scan_pid: files = files_by_scan_pid[scan_pid] @@ -1813,7 +1936,14 @@ class RapidApp(dbus.service.Object): if not self.download_action_is_download: self.set_download_action_label(is_download = True) + self.time_check.pause() + def resume_download(self): + for scan_pid in self.download_active_by_scan_pid: + self.time_remaining.set_time_mark(scan_pid) + + self.time_check.set_download_mark() + self.copy_files_manager.start() def download_files(self, files, scan_pid): @@ -1831,16 +1961,25 @@ class RapidApp(dbus.service.Object): video_download_folder = self.prefs.video_download_folder else: video_download_folder = None - + + download_size = self.size_files_to_be_downloaded(files) self.download_tracker.init_stats(scan_pid=scan_pid, - bytes=self.size_files_to_be_downloaded(files), + bytes=download_size, no_files=len(files)) + + self.time_remaining.set(scan_pid, download_size) + self.time_check.set_download_mark() self.download_active_by_scan_pid.append(scan_pid) + + + if len(self.download_active_by_scan_pid) > 1: + self.display_summary_notification = True + # Initiate copy files process self.copy_files_manager.add_task((photo_download_folder, video_download_folder, scan_pid, - files)) + files, self.auto_start_is_on)) def copy_files_results(self, source, condition): """ @@ -1856,12 +1995,14 @@ class RapidApp(dbus.service.Object): scan_pid, photo_temp_dir, video_temp_dir = data self.temp_dirs_by_scan_pid[scan_pid] = (photo_temp_dir, video_temp_dir) elif msg_type == rpdmp.MSG_BYTES: - scan_pid, total_downloaded = data + scan_pid, total_downloaded, chunk_downloaded = data self.download_tracker.set_total_bytes_copied(scan_pid, total_downloaded) + self.time_check.increment(bytes_downloaded=chunk_downloaded) percent_complete = self.download_tracker.get_percent_complete(scan_pid) self.device_collection.update_progress(scan_pid, percent_complete, None, None) + self.time_remaining.update(scan_pid, total_downloaded) elif msg_type == rpdmp.MSG_FILE: download_succeeded, rpd_file, download_count, temp_full_file_name = data @@ -1877,14 +2018,18 @@ class RapidApp(dbus.service.Object): rpd_file.strip_characters = self.prefs.strip_characters rpd_file.download_folder = self.prefs.get_download_folder_for_file_type(rpd_file.file_type) rpd_file.download_conflict_resolution = self.prefs.download_conflict_resolution - rpd_file.synchronize_raw_jpg = self.prefs.must_synchronize_raw_jpg() + rpd_file.synchronize_raw_jpg = self.prefs.must_synchronize_raw_jpg() + rpd_file.job_code = self.job_code self.subfolder_file_manager.rename_file_and_move_to_subfolder( download_succeeded, download_count, rpd_file ) - + elif msg_type == rpdmp.MSG_THUMB: + #~ unique_id, thumbnail, thumbnail_icon = data + #~ thumbnail_data = (unique_id + self.thumbnails.update_thumbnail(data) return True else: @@ -1897,7 +2042,9 @@ class RapidApp(dbus.service.Object): def download_is_occurring(self): """Returns True if a file is currently being downloaded or renamed """ - return not len(self.download_active_by_scan_pid) == 0 + v = not len(self.download_active_by_scan_pid) == 0 + #~ logger.info("Download is occurring: %s", v) + return v # # # # Create folder and file names for downloaded files @@ -1921,48 +2068,233 @@ class RapidApp(dbus.service.Object): self.log_error(config.WARNING, rpd_file.error_title, rpd_file.error_msg, rpd_file.error_extra_detail) - self.download_tracker.file_downloaded_increment(scan_pid) - self._update_file_download_device_progress(scan_pid, unique_id) + self.download_tracker.file_downloaded_increment(scan_pid, + rpd_file.file_type, + rpd_file.status) + + completed, files_remaining = self._update_file_download_device_progress(scan_pid, unique_id) + + if self.download_is_occurring(): + self.update_time_remaining() - download_count = self.download_tracker.get_download_count_for_file(unique_id) - if download_count == self.download_tracker.get_no_files_in_download(scan_pid): - # Last file has been downloaded, so clean temp directory + if completed: + # Last file for this scan pid has been downloaded, so clean temp directory logger.debug("Purging temp directories") self._clean_temp_dirs_for_scan_pid(scan_pid) - self.download_tracker.purge(scan_pid) self.download_active_by_scan_pid.remove(scan_pid) + self.time_remaining.remove(scan_pid) + self.notify_downloaded_from_device(scan_pid) + if files_remaining == 0 and self.prefs.auto_unmount: + self.device_collection.unmount(scan_pid) + if not self.download_is_occurring(): logger.debug("Download completed") + self.notify_download_complete() + self.download_progressbar.set_fraction(0.0) self.prefs.stored_sequence_no = self.stored_sequence_value.value self.downloads_today_tracker.set_raw_downloads_today_from_int(self.downloads_today_value.value) self.downloads_today_tracker.set_raw_downloads_today_date(self.downloads_today_date_value.value) self.prefs.set_downloads_today_from_tracker(self.downloads_today_tracker) - + + if ((self.prefs.auto_exit and self.download_tracker.no_errors_or_warnings()) + or self.prefs.auto_exit_force): + if not self.thumbnails.files_remain_to_download(): + gtk.main_quit() + + self.download_tracker.purge_all() + self.speed_label.set_label(" ") + self.display_free_space() self.set_download_action_label(is_download=True) self.set_download_action_sensitivity() + + self.job_code = '' + self.download_start_time = None + + + def update_time_remaining(self): + update, download_speed = self.time_check.check_for_update() + if update: + self.speed_label.set_text(download_speed) + + time_remaining = self.time_remaining.time_remaining() + if time_remaining: + secs = int(time_remaining) + + if secs == 0: + message = "" + elif secs == 1: + message = _("About 1 second remaining") + elif secs < 60: + message = _("About %i seconds remaining") % secs + elif secs == 60: + message = _("About 1 minute remaining") + else: + # Translators: in the text '%(minutes)i:%(seconds)02i', only the : should be translated, if needed. + # '%(minutes)i' and '%(seconds)02i' should not be modified or left out. They are used to format and display the amount + # of time the download has remainging, e.g. 'About 5:36 minutes remaining' + message = _("About %(minutes)i:%(seconds)02i minutes remaining") % {'minutes': secs / 60, 'seconds': secs % 60} + + self.rapid_statusbar.pop(self.statusbar_context_id) + self.rapid_statusbar.push(self.statusbar_context_id, message) + + def file_types_by_number(self, no_photos, no_videos): + """ + returns a string to be displayed to the user that can be used + to show if a value refers to photos or videos or both, or just one + of each + """ + if (no_videos > 0) and (no_photos > 0): + v = _('photos and videos') + elif (no_videos == 0) and (no_photos == 0): + v = _('photos or videos') + elif no_videos > 0: + if no_videos > 1: + v = _('videos') + else: + v = _('video') else: - pass - #~ logger.info("Download count: %s", download_count) - + if no_photos > 1: + v = _('photos') + else: + v = _('photo') + return v + def notify_downloaded_from_device(self, scan_pid): + device = self.device_collection.get_device(scan_pid) + + if device.mount is None: + notificationName = PROGRAM_NAME + else: + notificationName = device.get_name() + + no_photos_downloaded = self.download_tracker.get_no_files_downloaded( + scan_pid, rpdfile.FILE_TYPE_PHOTO) + no_videos_downloaded = self.download_tracker.get_no_files_downloaded( + scan_pid, rpdfile.FILE_TYPE_VIDEO) + no_photos_failed = self.download_tracker.get_no_files_failed( + scan_pid, rpdfile.FILE_TYPE_PHOTO) + no_videos_failed = self.download_tracker.get_no_files_failed( + scan_pid, rpdfile.FILE_TYPE_VIDEO) + no_files_downloaded = no_photos_downloaded + no_videos_downloaded + no_files_failed = no_photos_failed + no_videos_failed + no_warnings = self.download_tracker.get_no_warnings(scan_pid) + + file_types = self.file_types_by_number(no_photos_downloaded, no_videos_downloaded) + file_types_failed = self.file_types_by_number(no_photos_failed, no_videos_failed) + message = _("%(noFiles)s %(filetypes)s downloaded") % \ + {'noFiles':no_files_downloaded, 'filetypes': file_types} + + if no_files_failed: + message += "\n" + _("%(noFiles)s %(filetypes)s failed to download") % {'noFiles':no_files_failed, 'filetypes':file_types_failed} + + if no_warnings: + message = "%s\n%s " % (message, no_warnings) + _("warnings") + + n = pynotify.Notification(notificationName, message) + n.set_icon_from_pixbuf(device.get_icon(self.notification_icon_size)) + + n.show() + + def notify_download_complete(self): + if self.display_summary_notification: + message = _("All downloads complete") + + # photo downloads + photo_downloads = self.download_tracker.total_photos_downloaded + if photo_downloads: + filetype = self.file_types_by_number(photo_downloads, 0) + message += "\n" + _("%(number)s %(numberdownloaded)s") % \ + {'number': photo_downloads, + 'numberdownloaded': _("%(filetype)s downloaded") % \ + {'filetype': filetype}} + + # photo failures + photo_failures = self.download_tracker.total_photo_failures + if photo_failures: + filetype = self.file_types_by_number(photo_failures, 0) + message += "\n" + _("%(number)s %(numberdownloaded)s") % \ + {'number': photo_failures, + 'numberdownloaded': _("%(filetype)s failed to download") % \ + {'filetype': filetype}} + + # video downloads + video_downloads = self.download_tracker.total_videos_downloaded + if video_downloads: + filetype = self.file_types_by_number(0, video_downloads) + message += "\n" + _("%(number)s %(numberdownloaded)s") % \ + {'number': video_downloads, + 'numberdownloaded': _("%(filetype)s downloaded") % \ + {'filetype': filetype}} + + # video failures + video_failures = self.download_tracker.total_video_failures + if video_failures: + filetype = self.file_types_by_number(0, video_failures) + message += "\n" + _("%(number)s %(numberdownloaded)s") % \ + {'number': video_failures, + 'numberdownloaded': _("%(filetype)s failed to download") % \ + {'filetype': filetype}} + + # warnings + warnings = self.download_tracker.total_warnings + if warnings: + message += "\n" + _("%(number)s %(numberdownloaded)s") % \ + {'number': warnings, + 'numberdownloaded': _("warnings")} + + n = pynotify.Notification(PROGRAM_NAME, message) + n.set_icon_from_pixbuf(self.application_icon) + n.show() + self.display_summary_notification = False # don't show it again unless needed + def _update_file_download_device_progress(self, scan_pid, unique_id): """ Increments the progress bar for an individual device + + Returns if the download is completed for that scan_pid + It also returns the number of files remaining for the scan_pid, BUT + this value is valid ONLY if the download is completed """ - progress_bar_text = _("%(number)s of %(total)s %(filetypes)s") % \ - {'number': self.download_tracker.get_download_count_for_file(unique_id), - 'total': self.download_tracker.get_no_files_in_download(scan_pid), - 'filetypes': self.download_tracker.get_file_types_present(scan_pid)} + + files_downloaded = self.download_tracker.get_download_count_for_file(unique_id) + files_to_download = self.download_tracker.get_no_files_in_download(scan_pid) + file_types = self.download_tracker.get_file_types_present(scan_pid) + completed = files_downloaded == files_to_download + + if completed: + files_remaining = self.thumbnails.get_no_files_remaining(scan_pid) + else: + files_remaining = 0 + + if completed and files_remaining: + # e.g.: 3 of 205 photos and videos (202 remaining) + progress_bar_text = _("%(number)s of %(total)s %(filetypes)s (%(remaining)s remaining)") % { + 'number': files_downloaded, + 'total': files_to_download, + 'filetypes': file_types, + 'remaining': files_remaining} + else: + # e.g.: 205 of 205 photos and videos + progress_bar_text = _("%(number)s of %(total)s %(filetypes)s") % \ + {'number': files_downloaded, + 'total': files_to_download, + 'filetypes': file_types} percent_complete = self.download_tracker.get_percent_complete(scan_pid) self.device_collection.update_progress(scan_pid=scan_pid, percent_complete=percent_complete, progress_bar_text=progress_bar_text, - bytes_downloaded=None) + bytes_downloaded=None) + + percent_complete = self.download_tracker.get_overall_percent_complete() + self.download_progressbar.set_fraction(percent_complete) + + return (completed, files_remaining) + def _clean_all_temp_dirs(self): """ @@ -1999,7 +2331,6 @@ class RapidApp(dbus.service.Object): children = path.enumerate_children(file_attributes) for child in children: f = path.get_child(child.get_name()) - #~ logger.info("Deleting %s", child.get_name()) f.delete(cancellable=None) path.delete(cancellable=None) logger.debug("Deleted directory %s", directory) @@ -2011,20 +2342,6 @@ class RapidApp(dbus.service.Object): # # # # Preferences # # # - - def check_prefs_on_startup(self): - """ - Checks the image & video rename, and subfolder prefs for validity. - - Returns True if no problem, false otherwise. - """ - prefs_ok = prefsrapid.check_prefs_for_validity(self.prefs.image_rename, - self.prefs.subfolder, - self.prefs.video_rename, - self.prefs.video_subfolder) - if not prefs_ok: - logger.error("There is an error in the program preferences relating to file renaming and subfolder creation. Some preferences will be reset.") - return prefs_ok def _init_prefs(self): @@ -2115,12 +2432,13 @@ class RapidApp(dbus.service.Object): def post_preference_change(self): if self.rerun_setup_available_image_and_video_media: - if self.using_volume_monitor(): - self.start_volume_monitor() + logger.info("Download device settings preferences were changed.") self.thumbnails.clear_all() - self.setup_devices(on_startup = False, on_preference_change = True, do_not_allow_auto_start = True) + self.setup_devices(on_startup = False, on_preference_change = True, block_auto_start = True) + self._set_device_collection_size() + if self.main_notebook.get_current_page() == 1: # preview of file self.main_notebook.set_current_page(0) @@ -2148,6 +2466,36 @@ class RapidApp(dbus.service.Object): # Main app window management and setup # # # + def _init_pynotify(self): + """ + Initialize system notification messages + """ + + if not pynotify.init("TestCaps"): + logger.critical("Problem using pynotify.") + gtk.main_quit() + + do_not_size_icon = False + self.notification_icon_size = 48 + try: + info = pynotify.get_server_info() + except: + logger.warning("Desktop environment notification server is incorrectly configured.") + else: + try: + if info["name"] == 'notify-osd': + do_not_size_icon = True + except: + pass + + if do_not_size_icon: + self.application_icon = gtk.gdk.pixbuf_new_from_file( + paths.share_dir('glade3/rapid-photo-downloader.svg')) + else: + self.application_icon = gtk.gdk.pixbuf_new_from_file_at_size( + paths.share_dir('glade3/rapid-photo-downloader.svg'), + self.notification_icon_size, self.notification_icon_size) + def _init_widgets(self): """ Initialize widgets in the main window, and variables that point to them @@ -2167,6 +2515,7 @@ class RapidApp(dbus.service.Object): self.next_image_action = builder.get_object("next_image_action") self.prev_image_action = builder.get_object("prev_image_action") self.menu_log_window = builder.get_object("menu_log_window") + self.speed_label = builder.get_object("speed_label") # Only enable this action when actually displaying a preview self.next_image_action.set_sensitive(False) @@ -2200,9 +2549,17 @@ class RapidApp(dbus.service.Object): # Download action state self.download_action_is_download = True - #job code initialization - self.last_chosen_job_code = None - self.prompting_for_job_code = False + # Track the time a download commences + self.download_start_time = None + + # Whether a system wide notifcation message should be shown + # after a download has occurred in parallel + self.display_summary_notification = False + + # Values used to display how much longer a download will take + self.time_remaining = downloadtracker.TimeRemaining() + self.time_check = downloadtracker.TimeCheck() + def _set_window_size(self): """ @@ -2375,6 +2732,12 @@ class RapidApp(dbus.service.Object): self.error_log.widget.show() else: self.error_log.widget.hide() + + def notify_prefs_are_invalid(self, details): + title = _("Program preferences are invalid") + logger.critical(title) + self.log_error(severity=config.CRITICAL_ERROR, problem=title, + details=details) # # # @@ -2576,6 +2939,7 @@ class RapidApp(dbus.service.Object): if conn_type == rpdmp.CONN_COMPLETE: connection.close() + self.scan_manager.no_tasks -= 1 size, file_type_counter, scan_pid = data size = format_size_for_user(bytes=size) results_summary, file_types_present = file_type_counter.summarize_file_count() @@ -2585,13 +2949,20 @@ class RapidApp(dbus.service.Object): self.device_collection.update_device(scan_pid, size) self.device_collection.update_progress(scan_pid, 0.0, results_summary, 0) self.testing_auto_exit_trip_counter += 1 + self.set_download_action_sensitivity() + if self.testing_auto_exit_trip_counter == self.testing_auto_exit_trip and self.testing_auto_exit: self.on_rapidapp_destroy(self.rapidapp) else: - if not self.testing_auto_exit: + if not self.testing_auto_exit and not self.auto_start_is_on: self.download_progressbar.set_text(_("Thumbnails")) self.thumbnails.generate_thumbnails(scan_pid) - self.set_download_action_sensitivity() + elif self.auto_start_is_on: + if self.need_job_code_for_naming and not self.job_code: + self.get_job_code() + else: + self.start_download(scan_pid=scan_pid) + self.set_thumbnail_sort() # signal that no more data is coming, finishing io watch for this pipe @@ -2601,7 +2972,8 @@ class RapidApp(dbus.service.Object): logger.critical("incoming pipe length is unexpectedly long: %s" % len(data)) else: for rpd_file in data: - self.thumbnails.add_file(rpd_file) + self.thumbnails.add_file(rpd_file=rpd_file, + generate_thumbnail = not self.auto_start_is_on) # must return True for this method to be called again return True diff --git a/rapid/rpdmultiprocessing.py b/rapid/rpdmultiprocessing.py index 7fdd252..4a06fc9 100644 --- a/rapid/rpdmultiprocessing.py +++ b/rapid/rpdmultiprocessing.py @@ -21,5 +21,6 @@ CONN_COMPLETE = 1 MSG_BYTES = 0 MSG_FILE = 1 MSG_TEMP_DIRS = 2 +MSG_THUMB = 3 MSG_SEQUENCE_VALUE = 0 diff --git a/rapid/subfolderfile.py b/rapid/subfolderfile.py index c55b6d8..7e4a495 100644 --- a/rapid/subfolderfile.py +++ b/rapid/subfolderfile.py @@ -328,7 +328,11 @@ class SubfolderFile(multiprocessing.Process): if rpd_file.has_problem(): rpd_file.status = config.STATUS_DOWNLOADED_WITH_WARNING - + rpd_file.error_title = rpd_file.problem.get_title() + rpd_file.error_msg = _("%(problem)s\nFile: %(file)s") % \ + {'problem': rpd_file.problem.get_problems(), + 'file': rpd_file.full_file_name} + # Check for any errors if not rpd_file.download_subfolder or not rpd_file.download_name: if not rpd_file.download_subfolder and not rpd_file.download_name: diff --git a/rapid/thumbnail.py b/rapid/thumbnail.py index 9df2147..e9c7e66 100644 --- a/rapid/thumbnail.py +++ b/rapid/thumbnail.py @@ -374,11 +374,9 @@ class GenerateThumbnails(multiprocessing.Process): f.file_type, (160, 120), (100,100)) - #~ logger.debug("Appending results for %s" %f.full_file_name) self.results.append((f.unique_id, thumbnail_icon, thumbnail)) counter += 1 if counter == self.batch_size: - #~ logger.debug("Sending results....") self.results_pipe.send((rpdmp.CONN_PARTIAL, self.results)) self.results = [] counter = 0 @@ -386,7 +384,6 @@ class GenerateThumbnails(multiprocessing.Process): if counter > 0: # send any remaining results - #~ logger.debug("Sending final results....") self.results_pipe.send((rpdmp.CONN_PARTIAL, self.results)) self.results_pipe.send((rpdmp.CONN_COMPLETE, None)) self.results_pipe.close() |