1# Copyright (C) 2011-2020 Damon Lynch <damonlynch@gmail.com>
2
3# This file is part of Rapid Photo Downloader.
4#
5# Rapid Photo Downloader is free software: you can redistribute it and/or
6# modify it under the terms of the GNU General Public License as published by
7# the Free Software Foundation, either version 3 of the License, or
8# (at your option) any later version.
9#
10# Rapid Photo Downloader is distributed in the hope that it will be useful,
11# but WITHOUT ANY WARRANTY; without even the implied warranty of
12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13# GNU General Public License for more details.
14#
15# You should have received a copy of the GNU General Public License
16# along with Rapid Photo Downloader.  If not,
17# see <http://www.gnu.org/licenses/>.
18
19__author__ = 'Damon Lynch'
20__copyright__ = "Copyright 2011-2020, Damon Lynch"
21
22from collections import defaultdict
23import time
24import math
25import locale
26import logging
27from typing import Optional, Dict, List, Tuple, Set
28
29from raphodo.constants import DownloadStatus, FileType, DownloadUpdateSeconds
30from raphodo.thumbnaildisplay import DownloadStats
31from raphodo.rpdfile import RPDFile
32
33try:
34    Infinity = math.inf
35except AttributeError:
36    Infinity = float("inf")
37
38
39class DownloadTracker:
40    """
41    Track file downloads - their size, number, and any problems
42    """
43    # TODO: refactor this class to make it more pythonic
44    # contemplate using settrs
45
46    def __init__(self):
47        self.file_types_present_by_scan_id = dict()  # type: Dict[int, str]
48        self._refresh_values()
49
50    def _refresh_values(self):
51        """
52        Reset values when a download is completed
53        """
54
55        self.size_of_download_in_bytes_by_scan_id = dict()  # type: Dict[int, int]
56        self.total_bytes_backed_up_by_scan_id = dict()  # type: Dict[int, int]
57        self.size_of_photo_backup_in_bytes_by_scan_id = dict()  # type: Dict[int, int]
58        self.size_of_video_backup_in_bytes_by_scan_id = dict()  # type: Dict[int, int]
59        self.raw_size_of_download_in_bytes_by_scan_id = dict()  # type: Dict[int, int]
60        self.total_bytes_copied_by_scan_id = dict()  # type: Dict[int, int]
61        self.total_bytes_video_backed_up_by_scan_id = dict()  # type: Dict[int, int]
62        self.no_files_in_download_by_scan_id = dict()  # type: Dict[int, int]
63        self.no_photos_in_download_by_scan_id = dict()  # type: Dict[int, int]
64        self.no_videos_in_download_by_scan_id = dict()  # type: Dict[int, int]
65        self.no_post_download_thumb_generation_by_scan_id = dict()  # type: Dict[int, int]
66
67        # 'Download count' tracks the index of the file being downloaded
68        # into the list of files that need to be downloaded -- much like
69        # a counter in a for loop, e.g. 'for i in list', where i is the counter
70        self.download_count_for_file_by_uid = dict()  # type: Dict[bytes, int]
71        self.download_count_by_scan_id = dict()  # type: Dict[int, int]
72        self.rename_chunk = dict()  # type: Dict[int, int]
73        self.files_downloaded = dict()  # type: Dict[int, int]
74        self.photos_downloaded = dict()  # type: Dict[int, int]
75        self.videos_downloaded = dict()  # type: Dict[int, int]
76        self.photo_failures = dict()  # type: Dict[int, int]
77        self.video_failures = dict()  # type: Dict[int, int]
78        self.warnings = dict()  # type: Dict[int, int]
79        self.post_download_thumb_generation = dict()  # type: Dict[int, int]
80        self.total_photos_downloaded = 0  # type: int
81        self.total_photo_failures = 0  # type: int
82        self.total_videos_downloaded = 0  # type: int
83        self.total_video_failures = 0  # type: int
84        self.total_warnings = 0  # type: int
85        self.total_bytes_to_download = 0  # type: int
86        self.total_bytes_to_backup = 0  # type: int
87        self.backups_performed_by_uid = defaultdict(int)  # type: Dict[bytes, List[int,...]]
88        self.backups_performed_by_scan_id = defaultdict(int)  # type: Dict[int, List[int,...]]
89        self.no_backups_to_perform_by_scan_id = dict()  # type: Dict[int, int]
90        self.auto_delete = defaultdict(list)
91        self._devices_removed_mid_download = set()  # type: Set[int]
92
93    def set_no_backup_devices(self, no_photo_backup_devices: int,
94                              no_video_backup_devices: int) -> None:
95        self.no_photo_backup_devices = no_photo_backup_devices
96        self.no_video_backup_devices = no_video_backup_devices
97
98    def init_stats(self, scan_id: int, stats: DownloadStats) -> None:
99        no_files = stats.no_photos + stats.no_videos
100        self.no_files_in_download_by_scan_id[scan_id] = no_files
101        self.no_photos_in_download_by_scan_id[scan_id] = stats.no_photos
102        self.no_videos_in_download_by_scan_id[scan_id] = stats.no_videos
103        self.size_of_photo_backup_in_bytes_by_scan_id[scan_id] = \
104            stats.photos_size_in_bytes * self.no_photo_backup_devices
105        self.size_of_video_backup_in_bytes_by_scan_id[scan_id] = \
106            stats.videos_size_in_bytes * self.no_video_backup_devices
107        self.no_backups_to_perform_by_scan_id[scan_id] = \
108            stats.no_photos * self.no_photo_backup_devices + \
109            stats.no_videos * self.no_video_backup_devices
110        total_bytes = stats.photos_size_in_bytes + stats.videos_size_in_bytes
111        self.no_post_download_thumb_generation_by_scan_id[scan_id] =  \
112            stats.post_download_thumb_generation
113
114        # rename_chunk is used to account for the time it takes to rename a
115        # file, and potentially to generate thumbnails after it has renamed.
116        # rename_chunk makes a notable difference to the user when they're
117        # downloading from a a high speed source.
118        # Determine the value by calculating how many files need a thumbnail
119        # generated after they've been downloaded and renamed.
120        chunk_weight = (stats.post_download_thumb_generation * 60 + (
121            no_files - stats.post_download_thumb_generation) * 5) / no_files
122        self.rename_chunk[scan_id] = int((total_bytes / no_files) * (chunk_weight / 100))
123        self.size_of_download_in_bytes_by_scan_id[scan_id] = total_bytes + \
124                    self.rename_chunk[scan_id] * no_files
125        self.raw_size_of_download_in_bytes_by_scan_id[scan_id] = total_bytes
126        self.total_bytes_to_download += self.size_of_download_in_bytes_by_scan_id[scan_id]
127        self.total_bytes_to_backup += self.size_of_photo_backup_in_bytes_by_scan_id[scan_id] + \
128                                      self.size_of_video_backup_in_bytes_by_scan_id[scan_id]
129        self.files_downloaded[scan_id] = 0
130        self.photos_downloaded[scan_id] = 0
131        self.videos_downloaded[scan_id] = 0
132        self.photo_failures[scan_id] = 0
133        self.video_failures[scan_id] = 0
134        self.warnings[scan_id] = 0
135        self.post_download_thumb_generation[scan_id] = 0
136        self.total_bytes_backed_up_by_scan_id[scan_id] = 0
137
138    def get_no_files_in_download(self, scan_id: int) -> int:
139        return self.no_files_in_download_by_scan_id[scan_id]
140
141    def get_no_files_downloaded(self, scan_id: int, file_type: FileType) -> int:
142        if file_type == FileType.photo:
143            return self.photos_downloaded.get(scan_id, 0)
144        else:
145            return self.videos_downloaded.get(scan_id, 0)
146
147    def get_no_files_failed(self, scan_id: int, file_type: FileType) -> int:
148        if file_type == FileType.photo:
149            return self.photo_failures.get(scan_id, 0)
150        else:
151            return self.video_failures.get(scan_id, 0)
152
153    def get_no_warnings(self, scan_id: int) -> int:
154        return self.warnings.get(scan_id, 0)
155
156    def add_to_auto_delete(self, rpd_file: RPDFile) -> None:
157        self.auto_delete[rpd_file.scan_id].append(rpd_file.full_file_name)
158
159    def get_files_to_auto_delete(self, scan_id: int) -> int:
160        return self.auto_delete[scan_id]
161
162    def clear_auto_delete(self, scan_id: int) -> None:
163        if scan_id in self.auto_delete:
164            del self.auto_delete[scan_id]
165
166    def thumbnail_generated_post_download(self, scan_id: int) -> None:
167        """
168        Increment the number of files that have had their thumbnail
169        generated after they were downloaded
170        :param scan_id: the device from which the file came
171        """
172
173        if scan_id in self._devices_removed_mid_download:
174            return
175
176        self.post_download_thumb_generation[scan_id] += 1
177
178    def file_backed_up(self, scan_id: int, uid: bytes) -> None:
179
180        if scan_id in self._devices_removed_mid_download:
181            return
182
183        self.backups_performed_by_uid[uid] += 1
184        self.backups_performed_by_scan_id[scan_id] += 1
185
186    def file_backed_up_to_all_locations(self, uid: bytes, file_type: FileType) -> bool:
187        """
188        Determine if this particular file has been backed up to all
189        locations it should be
190        :param uid: unique id of the file
191        :param file_type: photo or video
192        :return: True if backups for this particular file have completed, else
193        False
194        """
195
196        if uid in self.backups_performed_by_uid:
197            if file_type == FileType.photo:
198                return self.backups_performed_by_uid[uid] == self.no_photo_backup_devices
199            else:
200                return self.backups_performed_by_uid[uid] == self.no_video_backup_devices
201        else:
202            logging.critical("Unexpected uid in self.backups_performed_by_uid")
203            return True
204
205    def all_files_backed_up(self, scan_id: Optional[int]=None) -> bool:
206        """
207        Determine if all backups have finished in the download
208        :param scan_id: scan id of the download. If None, then all
209         scans will be checked
210        :return: True if all backups finished, else False
211        """
212
213        if scan_id is None:
214            for scan_id in self.no_backups_to_perform_by_scan_id:
215                if (self.no_backups_to_perform_by_scan_id[scan_id] !=
216                        self.backups_performed_by_scan_id[scan_id] and
217                        scan_id not in self._devices_removed_mid_download):
218                    return False
219            return True
220        else:
221            return (self.no_backups_to_perform_by_scan_id[scan_id] ==
222                    self.backups_performed_by_scan_id[scan_id] or
223                    scan_id in self._devices_removed_mid_download)
224
225    def file_downloaded_increment(self, scan_id: int,
226                                  file_type: FileType,
227                                  status: DownloadStatus) -> None:
228
229        if scan_id in self._devices_removed_mid_download:
230            return
231
232        self.files_downloaded[scan_id] += 1
233
234        if status in (DownloadStatus.download_failed, DownloadStatus.download_and_backup_failed):
235            if file_type == FileType.photo:
236                self.photo_failures[scan_id] += 1
237                self.total_photo_failures += 1
238            else:
239                self.video_failures[scan_id] += 1
240                self.total_video_failures += 1
241        else:
242            if file_type == FileType.photo:
243                self.photos_downloaded[scan_id] += 1
244                self.total_photos_downloaded += 1
245            else:
246                self.videos_downloaded[scan_id] += 1
247                self.total_videos_downloaded += 1
248
249            if status in (DownloadStatus.downloaded_with_warning, DownloadStatus.backup_problem):
250                self.warnings[scan_id] += 1
251                self.total_warnings += 1
252
253    def device_removed_mid_download(self, scan_id: int, display_name: str) -> None:
254        """
255        Adjust the the tracking to account for a device being removed as a download
256        was occurring.
257
258        :param scan_id: scan id of the device that has been removed
259        """
260
261        logging.debug("Adjusting download tracking to account for removed device %s",
262                      display_name)
263
264        self._devices_removed_mid_download.add(scan_id)
265
266        photos_downloaded = self.photo_failures[scan_id] + self.photos_downloaded[scan_id]
267        failures = self.no_photos_in_download_by_scan_id[scan_id] - photos_downloaded
268        self.photo_failures[scan_id] += failures
269        self.total_photo_failures += failures
270
271        videos_downloaded = self.video_failures[scan_id] + self.videos_downloaded[scan_id]
272        failures = self.no_videos_in_download_by_scan_id[scan_id] - videos_downloaded
273        self.video_failures[scan_id] += failures
274        self.total_video_failures += failures
275
276        self.download_count_by_scan_id[scan_id] = self.no_files_in_download_by_scan_id[scan_id]
277        self.files_downloaded[scan_id] = self.no_files_in_download_by_scan_id[scan_id]
278
279        self.total_bytes_copied_by_scan_id[scan_id] = \
280            self.size_of_download_in_bytes_by_scan_id[scan_id]
281
282        self.total_bytes_backed_up_by_scan_id[scan_id] = \
283            self.size_of_photo_backup_in_bytes_by_scan_id[scan_id] + \
284            self.size_of_video_backup_in_bytes_by_scan_id[scan_id]
285
286    def get_percent_complete(self, scan_id: int) -> float:
287        """
288        Returns a float representing how much of the download
289        has been completed for one particular device
290
291        :return a value between 0.0 and 1.0
292        """
293
294        # when calculating the percentage, there are three components:
295        # copy (download), rename ('rename_chunk'), and backup
296        percent_complete = (((
297                  self.total_bytes_copied_by_scan_id[scan_id]
298                + self.rename_chunk[scan_id] * self.files_downloaded[scan_id])
299                + self.total_bytes_backed_up_by_scan_id[scan_id])
300                / (self.size_of_download_in_bytes_by_scan_id[scan_id] +
301                   self.size_of_photo_backup_in_bytes_by_scan_id[scan_id] +
302                   self.size_of_video_backup_in_bytes_by_scan_id[scan_id]
303                   ))
304
305        return  percent_complete
306
307    def get_overall_percent_complete(self) -> float:
308        """
309        Returns a float representing how much of the download from one
310        or more devices
311        :return: a value between 0.0 and 1.0
312        """
313
314        total = sum(self.total_bytes_copied_by_scan_id[scan_id] +
315                    self.rename_chunk[scan_id] * self.files_downloaded[scan_id] +
316                    self.total_bytes_backed_up_by_scan_id[scan_id]
317                    for scan_id in self.total_bytes_copied_by_scan_id)
318
319        p = total / (self.total_bytes_to_download + self.total_bytes_to_backup)
320        # round the number down, e.g. 0.997 becomes 0.99
321        return math.floor(p * 100) / 100
322
323    def all_post_download_thumbs_generated_for_scan(self, scan_id: int) -> bool:
324        return self.no_post_download_thumb_generation_by_scan_id[scan_id] == \
325               self.post_download_thumb_generation[scan_id]
326
327    def all_files_downloaded_by_scan_id(self, scan_id: int) -> bool:
328        return self.files_downloaded[scan_id] == self.no_files_in_download_by_scan_id[scan_id]
329
330    def set_total_bytes_copied(self, scan_id: int, total_bytes: int) -> None:
331        if scan_id in self._devices_removed_mid_download:
332            return
333        assert total_bytes >= 0
334        self.total_bytes_copied_by_scan_id[scan_id] = total_bytes
335
336    def increment_bytes_backed_up(self, scan_id: int, chunk_downloaded: int) -> None:
337
338        if scan_id in self._devices_removed_mid_download:
339            return
340
341        self.total_bytes_backed_up_by_scan_id[scan_id] += chunk_downloaded
342
343    def set_download_count_for_file(self, uid: bytes, download_count: int) -> None:
344        self.download_count_for_file_by_uid[uid] = download_count
345
346    def get_download_count_for_file(self, uid: bytes) -> None:
347        return self.download_count_for_file_by_uid[uid]
348
349    def set_download_count(self, scan_id: int, download_count: int) -> None:
350        if scan_id in self._devices_removed_mid_download:
351            return
352        self.download_count_by_scan_id[scan_id] = download_count
353
354    def get_file_types_present(self, scan_id: int) -> str:
355        return self.file_types_present_by_scan_id[scan_id]
356
357    def set_file_types_present(self, scan_id: int, file_types_present: str) -> None:
358        self.file_types_present_by_scan_id[scan_id] = file_types_present
359
360    def no_errors_or_warnings(self) -> bool:
361        """
362        :return: True if there were no errors or warnings in the download
363         else return False
364        """
365
366        return (self.total_warnings == 0 and
367                self.total_photo_failures == 0 and
368                self.total_video_failures == 0)
369
370    def purge(self, scan_id):
371        del self.no_files_in_download_by_scan_id[scan_id]
372        del self.size_of_download_in_bytes_by_scan_id[scan_id]
373        del self.raw_size_of_download_in_bytes_by_scan_id[scan_id]
374        del self.photos_downloaded[scan_id]
375        del self.videos_downloaded[scan_id]
376        del self.files_downloaded[scan_id]
377        del self.photo_failures[scan_id]
378        del self.video_failures[scan_id]
379        del self.warnings[scan_id]
380        del self.no_backups_to_perform_by_scan_id[scan_id]
381
382    def purge_all(self):
383        self._refresh_values()
384
385
386class TimeCheck:
387    """
388    Record times downloads commence and pause - used in calculating time
389    remaining.
390
391    Also tracks and reports download speed for the entire download, in sum, i.e.
392    for all the devices and all backups as one.
393
394    Note: Times here are completely independent of the file / subfolder naming
395    preference "download start time"
396    """
397
398    def __init__(self):
399        # set the number of seconds gap with which to measure download time remaing
400        self.reset()
401        self.mpbs = _("MB/sec")
402        self.time_gap = DownloadUpdateSeconds / 2
403
404    def reset(self):
405        self.mark_set = False
406        self.total_downloaded_so_far = 0
407        self.total_download_size = 0
408        self.size_mark = 0
409        self.smoothed_speed = None  # type: Optional[float]
410
411    def increment(self, bytes_downloaded):
412        self.total_downloaded_so_far += bytes_downloaded
413
414    def set_download_mark(self):
415        if not self.mark_set:
416            self.mark_set = True
417            self.time_mark = time.time()
418
419    def pause(self):
420        self.mark_set = False
421
422    def update_download_speed(self) -> Tuple[bool, str]:
423        now = time.time()
424        updated = now > (self.time_gap + self.time_mark)
425
426        if updated:
427            amt_time = now - self.time_mark
428            self.time_mark = now
429            amt_downloaded = self.total_downloaded_so_far - self.size_mark
430            self.size_mark = self.total_downloaded_so_far
431            speed = amt_downloaded / 1048576 / amt_time
432            if self.smoothed_speed is None:
433                self.smoothed_speed = speed
434            else:
435                # smooth speed across fifteen readings
436                self.smoothed_speed = (self.smoothed_speed * 14 + speed) / 15
437            download_speed = "%1.1f %s" % (self.smoothed_speed, self.mpbs)
438        else:
439            download_speed = None
440
441        return (updated, download_speed)
442
443
444class TimeForDownload:
445    def __init__(self, size: int) -> None:
446        self.time_remaining = Infinity  # type: float
447
448        self.total_downloaded_so_far = 0   # type: int
449        self.total_download_size = size  # type: int
450        self.size_mark = 0  # type: int
451        self.smoothed_speed = None  # type: Optional[float]
452
453        self.time_mark = time.time()  # type: float
454        self.smoothed_speed = None  # type: Optional[float]
455
456
457class TimeRemaining:
458    """
459    Calculate how much time is remaining to finish a download
460
461    Runs in tandem with TimeCheck, above.
462
463    The smoothed speed for each device is independent of the smoothed
464    speed for the download as a whole.
465    """
466
467    def __init__(self) -> None:
468        self.clear()
469
470    def __setitem__(self, scan_id: int, size: int) -> None:
471        t = TimeForDownload(size)
472        self.times[scan_id] = t
473
474    def update(self, scan_id, bytes_downloaded) -> None:
475
476        if not scan_id in self.times:
477            return
478
479        t = self.times[scan_id]  # type: TimeForDownload
480
481        t.total_downloaded_so_far += bytes_downloaded
482        now = time.time()
483        tm = t.time_mark
484        amt_time = now - tm
485
486        if amt_time > DownloadUpdateSeconds:
487
488            amt_downloaded = t.total_downloaded_so_far - t.size_mark
489            t.size_mark = t.total_downloaded_so_far
490            t.time_mark = now
491
492            speed = amt_downloaded / amt_time
493
494            if t.smoothed_speed is None:
495                t.smoothed_speed = speed
496            else:
497                # smooth speed across ten readings
498                t.smoothed_speed = t.smoothed_speed * .9 + speed * .1
499
500            amt_to_download = t.total_download_size - t.total_downloaded_so_far
501
502            if not t.smoothed_speed:
503                t.time_remaining = Infinity
504            else:
505                time_remaining = amt_to_download / t.smoothed_speed
506                # Use the previous value to help determine the current value,
507                # which avoids values that jump around
508                if math.isinf(t.time_remaining):
509                    t.time_remaining = time_remaining
510                else:
511                    t.time_remaining = get_time_left(time_remaining, t.time_remaining)
512
513    def time_remaining(self, detailed_time_remaining: bool) -> Optional[str]:
514        """
515        Return the time remaining to download by taking the largest
516        value of all the devices being downloaded from.
517
518        :param detailed_time_remaining: if True, don't limit the precision
519         of the result return
520        :return: Time remaining in string format. Returns None if the
521        time remaining is unknown.
522        """
523
524        time_remaining = max(t.time_remaining for t in self.times.values())
525        if math.isinf(time_remaining):
526            return None
527
528        time_remaining =  round(time_remaining)  # type: int
529        if time_remaining < 4:
530            # Be friendly in the last few seconds
531            return _('A few seconds')
532        else:
533            # Format the string using the one or two largest units
534            return formatTime(time_remaining, limit_precision=not detailed_time_remaining)
535
536    def set_time_mark(self, scan_id):
537        if scan_id in self.times:
538            self.times[scan_id].time_mark = time.time()
539
540    def clear(self):
541        self.times = {}
542
543    def __delitem__(self, scan_id):
544        del self.times[scan_id]
545
546
547def get_time_left(aSeconds: float, aLastSec: Optional[float]=None) -> float:
548    """
549    Generate a "time left" string given an estimate on the time left and the
550    last time. The extra time is used to give a better estimate on the time to
551    show. Both the time values are floats instead of integers to help get
552    sub-second accuracy for current and future estimates.
553
554    Closely adapted from Mozilla's getTimeLeft function:
555    https://dxr.mozilla.org/mozilla-central/source/toolkit/mozapps/downloads/DownloadUtils.jsm
556
557    :param aSeconds: Current estimate on number of seconds left for the download
558    :param aLastSec: Last time remaining in seconds or None or infinity for unknown
559    :return: time left text, new value of "last seconds"
560    """
561
562    if aLastSec is None:
563        aLastSec = Infinity
564
565    if aSeconds < 0:
566      return aLastSec
567
568    # Apply smoothing only if the new time isn't a huge change -- e.g., if the
569    # new time is more than half the previous time; this is useful for
570    # downloads that start/resume slowly
571    if aSeconds > aLastSec / 2:
572        # Apply hysteresis to favor downward over upward swings
573        # 30% of down and 10% of up (exponential smoothing)
574        diff = aSeconds - aLastSec
575        aSeconds = aLastSec + (0.3 if diff < 0 else 0.1) * diff
576
577        # If the new time is similar, reuse something close to the last seconds,
578        # but subtract a little to provide forward progress
579        diffPct = diff / aLastSec * 100
580        if abs(diff) < 5 or abs(diffPct) < 5:
581            aSeconds = aLastSec - (0.4 if diff < 0 else 0.2)
582
583    return aSeconds
584
585def _seconds(seconds: int) -> str:
586    if seconds == 1:
587        return _('1 second')
588    else:
589        return _('%d seconds') % seconds
590
591
592def _minutes(minutes: int) -> str:
593    if minutes == 1:
594        return _('1 minute')
595    else:
596        return _('%d minutes') % minutes
597
598
599def _hours(hours: int) -> str:
600    if hours == 1:
601        return _('1 hour')
602    else:
603        return _('%d hours') % hours
604
605
606def _days(days: int) -> str:
607    if days == 1:
608        return _('1 day')
609    else:
610        return _('%d days') % days
611
612
613def formatTime(seconds: int, limit_precision=False) -> str:
614    r"""
615    >>> locale.setlocale(locale.LC_ALL, ('en_US', 'utf-8'))
616    'en_US.UTF-8'
617    >>> formatTime(0)
618    '0 seconds'
619    >>> formatTime(1)
620    '1 second'
621    >>> formatTime(2)
622    '2 seconds'
623    >>> formatTime(59)
624    '59 seconds'
625    >>> formatTime(60)
626    '1 minute'
627    >>> formatTime(61)
628    '1 minute, 1 second'
629    >>> formatTime(62)
630    '1 minute, 2 seconds'
631    >>> formatTime(60 + 59)
632    '1 minute, 59 seconds'
633    >>> formatTime(60 * 2)
634    '2 minutes'
635    >>> formatTime(60 * 2 + 1)
636    '2 minutes, 1 second'
637    >>> formatTime(60 * 2 + 2)
638    '2 minutes, 2 seconds'
639    >>> formatTime(60 * 3 + 25)
640    '3 minutes, 25 seconds'
641    >>> formatTime(60 * 3 + 25, limit_precision=True)
642    '3 minutes'
643    >>> formatTime(60 * 3 + 30)
644    '3 minutes, 30 seconds'
645    >>> formatTime(60 * 3 + 30, limit_precision=True)
646    '4 minutes'
647    >>> formatTime(60 * 45)
648    '45 minutes'
649    >>> formatTime(60 * 60 - 30)
650    '59 minutes, 30 seconds'
651    >>> formatTime(60 * 60 - 30, limit_precision=True)
652    '1 hour'
653    >>> formatTime(60 * 60 - 1)
654    '59 minutes, 59 seconds'
655    >>> formatTime(60 * 60)
656    '1 hour'
657    >>> formatTime(60 * 60 + 1)
658    '1 hour'
659    >>> formatTime(60 * 60 + 29)
660    '1 hour'
661    >>> formatTime(60 * 60 + 30)
662    '1 hour, 1 minute'
663    >>> formatTime(60 * 60 + 59)
664    '1 hour, 1 minute'
665    >>> formatTime(60 * 61)
666    '1 hour, 1 minute'
667    >>> formatTime(60 * 61 + 29)
668    '1 hour, 1 minute'
669    >>> formatTime(60 * 61 + 30)
670    '1 hour, 2 minutes'
671    >>> formatTime(60 * 60 * 2)
672    '2 hours'
673    >>> formatTime(60 * 60 * 2 + 45)
674    '2 hours, 1 minute'
675    >>> formatTime(60 * 60 * 2 + 60 * 29)
676    '2 hours, 29 minutes'
677    >>> formatTime(60 * 60 * 2 + 60 * 29 + 29)
678    '2 hours, 29 minutes'
679    >>> formatTime(60 * 60 * 2 + 60 * 29 + 29, limit_precision=True)
680    '2 hours'
681    >>> formatTime(60 * 60 * 2 + 60 * 29 + 30)
682    '2 hours, 30 minutes'
683    >>> formatTime(60 * 60 * 2 + 60 * 29 + 30, limit_precision=True)
684    '2 hours'
685    >>> formatTime(60 * 60 * 2 + 60 * 30)
686    '2 hours, 30 minutes'
687    >>> formatTime(60 * 60 * 2 + 60 * 30, limit_precision=True)
688    '3 hours'
689    >>> formatTime(60 * 60 * 2 + 60 * 59)
690    '2 hours, 59 minutes'
691    >>> formatTime(60 * 60 * 2 + 60 * 59 + 30)
692    '3 hours'
693    >>> formatTime(60 * 60 * 3 + 29)
694    '3 hours'
695    >>> formatTime(60 * 60 * 3 + 30)
696    '3 hours, 1 minute'
697    >>> formatTime(60 * 60 * 23 + 60 * 29)
698    '23 hours, 29 minutes'
699    >>> formatTime(60 * 60 * 23 + 60 * 29 + 29)
700    '23 hours, 29 minutes'
701    >>> formatTime(60 * 60 * 23 + 60 * 29 + 30)
702    '23 hours, 30 minutes'
703    >>> formatTime(60 * 60 * 23 + 60 * 29 + 30)
704    '23 hours, 30 minutes'
705    >>> formatTime(60 * 60 * 23 + 60 * 59)
706    '23 hours, 59 minutes'
707    >>> formatTime(60 * 60 * 23 + 60 * 59 + 20)
708    '23 hours, 59 minutes'
709    >>> formatTime(60 * 60 * 23 + 60 * 59 + 40)
710    '1 day'
711    >>> formatTime(60 * 60 * 24)
712    '1 day'
713    >>> formatTime(60 * 60 * 24 + 60 * 29)
714    '1 day'
715    >>> formatTime(60 * 60 * 24 + 60 * 29 + 59)
716    '1 day'
717    >>> formatTime(60 * 60 * 24 + 60 * 30)
718    '1 day, 1 hour'
719    >>> formatTime(60 * 60 * 24 * 2 + 60 * 30)
720    '2 days, 1 hour'
721    >>> formatTime(60 * 60 * 24 * 2 + 60 * 60 * 3)
722    '2 days, 3 hours'
723    >>> formatTime(60 * 60 * 24 * 24 + 60 * 60 * 3)
724    '24 days, 3 hours'
725    >>> formatTime(60 * 60 * 24 * 24 + 60 * 60 * 3 + 59)
726    '24 days, 3 hours'
727    >>> formatTime(60 * 60 * 24 * 24 + 60 * 60 * 3 + 59, limit_precision=True)
728    '24 days'
729    >>> formatTime(60 * 60 * 24 * 24 + 60 * 60 * 18, limit_precision=True)
730    '25 days'
731
732    When passed n number of seconds, return a translated string
733    that indicates using up to two units of time how much time is left.
734
735    Times are rounded up or down.
736
737    The highest unit of time used is days.
738    :param seconds: the number of seconds
739    :param limit_precision: if True, for any time >= three minutes, the
740     time string will be limited to only 1 unit, e.g. 3 minutes, 4 minutes etc
741    :return: the translated string
742    """
743
744    parts = []
745    for idx, mul in enumerate((86400, 3600, 60, 1)):
746        if seconds / mul >= 1 or mul == 1:
747            if mul > 1:
748                n = int(math.floor(seconds / mul))
749                seconds -= n * mul
750            else:
751                n = seconds
752            parts.append((idx, n))
753
754    # take the parts, and if necessary add new parts that indicate zero hours or minutes
755
756    parts2 = []
757    i = 0
758    for idx in range(parts[0][0], 4):
759        part_idx = parts[i][0]
760        if part_idx == idx:
761            parts2.append(parts[i])
762            i += 1
763        else:
764            parts2.append((idx, 0))
765
766    # what remains is a consistent and predictable set of time components to work with:
767
768    if len(parts2) == 1:
769        assert parts2[0][0] == 3
770        seconds = parts2[0][1]
771        return _seconds(seconds)
772
773    elif len(parts2) == 2:
774        assert parts2[0][0] == 2
775        assert parts2[0][1] > 0
776        minutes = parts2[0][1]
777        seconds = parts2[1][1]
778
779        if limit_precision and minutes > 2:
780            if seconds >= 30:
781                minutes += 1
782                if minutes == 60:
783                    return _('1 hour')
784            seconds = 0
785
786        if seconds:
787            if minutes == 1:
788                if seconds == 1:
789                    return _('1 minute, 1 second')
790                else:
791                    return _('1 minute, %d seconds') % seconds
792            else:
793                if seconds == 1:
794                    return _('%d minutes, 1 second') % minutes
795                else:
796                    return _('%(minutes)d minutes, %(seconds)d seconds') % dict(
797                        minutes=minutes, seconds=seconds)
798        else:
799            return _minutes(minutes)
800
801    elif len(parts2) == 3:
802        assert parts2[0][0] == 1
803        assert parts2[0][1] > 0
804        hours = parts2[0][1]
805        minutes = parts2[1][1]
806        seconds = parts2[2][1]
807
808        if limit_precision:
809            if minutes >= 30:
810                hours += 1
811                if hours == 24:
812                    return _('1 day')
813            minutes = 0
814        # round up the minutes if needed
815        elif seconds >= 30:
816            if minutes == 59:
817                minutes = 0
818                hours += 1
819                if hours == 24:
820                    return _('1 day')
821            else:
822                minutes += 1
823
824        if minutes:
825            if hours == 1:
826                if minutes == 1:
827                    return _('1 hour, 1 minute')
828                else:
829                    return _('1 hour, %d minutes') % minutes
830            else:
831                if minutes == 1:
832                    return _('%d hours, 1 minute') % hours
833                else:
834                    return _('%(hours)d hours, %(minutes)d minutes') % dict(hours=hours,
835                                                                               minutes=minutes)
836        else:
837            return _hours(hours)
838    else:
839        assert len(parts2) == 4
840        assert parts2[0][0] == 0
841        assert parts2[0][1] > 0
842        days = parts2[0][1]
843        hours = parts2[1][1]
844        minutes = parts2[2][1]
845
846        if limit_precision:
847            if hours >= 12:
848                days += 1
849            hours = 0
850        elif minutes >= 30:
851            if hours == 23:
852                hours = 0
853                days += 1
854            else:
855                hours += 1
856
857        if hours:
858            if days == 1:
859                if hours == 1:
860                    return _('1 day, 1 hour')
861                else:
862                    return _('1 day, %d hours') % hours
863            else:
864                if hours == 1:
865                    return _('%d days, 1 hour') % days
866                else:
867                    return _('%(days)d days, %(hours)d hours') % dict(days=days, hours=hours)
868        else:
869            return _days(days)