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)