1313import hashlib
1414import atexit
1515import json
16+ import math
1617from typing import Union
1718from pathlib import Path
1819
@@ -188,6 +189,11 @@ def __init__(self, url: str, path: str, size:str=None, resume_download: bool = T
188189 self .downloaded_file_size : float = 0.0
189190 self .downloaded_file_offset : float = 0.0
190191 self .start_time : float = time .time ()
192+ self .multipart_threshold : float = 1024 * 1024 * 50
193+ self .chunk_count : int = 16
194+ self .part_paths : list [Path ] = []
195+ self .part_errors : list [str ] = []
196+ self ._download_lock = threading .Lock ()
191197
192198 self .error : bool = False
193199 self .should_stop : bool = False
@@ -403,6 +409,115 @@ def _clear_progress(self) -> None:
403409 except Exception as e :
404410 logging .warning (self .trans ["Failed to clear progress file: {0}" ].format (str (e )))
405411
412+ def _supports_range_download (self ) -> bool :
413+ try :
414+ result = SESSION .head (self .url , allow_redirects = True , timeout = 5 )
415+ if result .headers .get ("Accept-Ranges" , "" ).lower () == "bytes" :
416+ return True
417+
418+ probe = NetworkUtilities ().get (self .url , stream = True , timeout = 10 , headers = {"Range" : "bytes=0-0" })
419+ return probe .status_code == 206
420+ except Exception :
421+ return False
422+
423+ def _should_use_multipart_download (self ) -> bool :
424+ if self .resume_download and self .downloaded_file_offset > 0 :
425+ return False
426+ if self .total_file_size == 0.0 :
427+ return False
428+ if self .total_file_size < self .multipart_threshold :
429+ return False
430+ if self .chunk_count <= 1 :
431+ return False
432+ return self ._supports_range_download ()
433+
434+ def _build_download_ranges (self ) -> list [tuple [int , int ]]:
435+ part_size = max (1 , math .ceil (self .total_file_size / self .chunk_count ))
436+ ranges = []
437+ start = 0
438+
439+ while start < self .total_file_size :
440+ end = min (start + part_size - 1 , int (self .total_file_size ) - 1 )
441+ ranges .append ((start , end ))
442+ start = end + 1
443+
444+ return ranges
445+
446+ def _download_part (self , part_index : int , start : int , end : int ) -> None :
447+ part_path = Path (f"{ self .filepath } .part{ part_index } " )
448+ self .part_paths [part_index ] = part_path
449+ headers = {"Range" : f"bytes={ start } -{ end } " }
450+ logging .info (f"- Download part { part_index + 1 } /{ len (self .part_paths )} : bytes={ start } -{ end } " )
451+ response = NetworkUtilities ().get (self .url , stream = True , timeout = 100 , headers = headers )
452+
453+ if response .status_code != 206 :
454+ raise Exception (f"Unexpected status code for part { part_index } : { response .status_code } " )
455+
456+ with open (part_path , "wb" ) as file :
457+ for chunk in response .iter_content (1024 * 1024 * 4 ):
458+ if self .should_stop :
459+ raise Exception (self .trans ["Download stopped" ])
460+ if chunk :
461+ file .write (chunk )
462+ with self ._download_lock :
463+ self .downloaded_file_size += len (chunk )
464+
465+ logging .info (f"- Completed part { part_index + 1 } /{ len (self .part_paths )} : { part_path } " )
466+
467+ def _merge_download_parts (self ) -> None :
468+ with open (self .filepath , "wb" ) as destination :
469+ for part_path in self .part_paths :
470+ with open (part_path , "rb" ) as source :
471+ while True :
472+ chunk = source .read (1024 * 1024 * 4 )
473+ if not chunk :
474+ break
475+ destination .write (chunk )
476+ if self .should_checksum :
477+ self ._update_checksum (chunk )
478+
479+ for part_path in self .part_paths :
480+ if part_path and part_path .exists ():
481+ part_path .unlink ()
482+
483+ def _download_multipart (self ) -> None :
484+ ranges = self ._build_download_ranges ()
485+ self .part_paths = [None ] * len (ranges )
486+ self .part_errors = []
487+ threads = []
488+
489+ logging .info (f"- Using multipart download with { len (ranges )} parts" )
490+
491+ def _worker (index : int , start : int , end : int ) -> None :
492+ try :
493+ self ._download_part (index , start , end )
494+ except Exception as error :
495+ self .part_errors .append (str (error ))
496+ self .should_stop = True
497+
498+ for index , (start , end ) in enumerate (ranges ):
499+ thread = threading .Thread (target = _worker , args = (index , start , end ), name = f"DownloadPart-{ index } " )
500+ thread .start ()
501+ threads .append (thread )
502+
503+ for thread in threads :
504+ thread .join ()
505+
506+ if self .part_errors :
507+ logging .error (f"- Multipart download failed with { len (self .part_errors )} part error(s)" )
508+ self .delete_temp_files ()
509+ raise Exception (self .part_errors [0 ])
510+
511+ self ._merge_download_parts ()
512+ self .download_complete = True
513+ self ._clear_progress ()
514+ logging .info (self .trans ["Download complete: {0}" ].format (self .filename ))
515+ logging .info (self .trans ["Stats:" ])
516+ logging .info (self .trans ["- Downloaded size: {0}" ].format (utilities .human_fmt (self .downloaded_file_size )))
517+ logging .info (self .trans ["- Time elapsed: {0:.2f} seconds" ].format ((time .time () - self .start_time )))
518+ logging .info (self .trans ["- Speed: {0}/s" ].format (utilities .human_fmt (self .downloaded_file_size / (time .time () - self .start_time ))))
519+ logging .info (self .trans ["- Location: {0}" ].format (self .filepath ))
520+
406521
407522 def _download (self , display_progress : bool = False ) -> None :
408523 """
@@ -423,13 +538,17 @@ def _download(self, display_progress: bool = False) -> None:
423538 if self ._prepare_working_directory (self .filepath ) is False :
424539 raise Exception (self .error_msg )
425540
541+ if self ._should_use_multipart_download ():
542+ self ._download_multipart ()
543+ return
544+
426545 headers = {}
427546 if self .resume_download and self .downloaded_file_offset > 0 :
428547 headers ['Range' ] = f'bytes={ self .downloaded_file_offset } -'
429548 logging .info (self .trans ["Resuming download from byte {0}" ].format (self .downloaded_file_offset ))
430549
431550 response = NetworkUtilities ().get (self .url , stream = True , timeout = 100 , headers = headers )
432-
551+ logging . info ( f"- Download URL: { self . url } " )
433552 mode = 'ab' if self .resume_download and self .downloaded_file_offset > 0 else 'wb'
434553 with open (self .filepath , mode ) as file :
435554 atexit .register (self .stop )
@@ -467,9 +586,10 @@ def _download(self, display_progress: bool = False) -> None:
467586 self .error_msg = str (e )
468587 self .status = DownloadStatus .ERROR
469588 logging .error (self .trans ["Error downloading {0}: {1}" ].format (self .url , self .error_msg ))
470-
471- self .status = DownloadStatus .COMPLETE
472- utilities .enable_sleep_after_running ()
589+ else :
590+ self .status = DownloadStatus .COMPLETE
591+ finally :
592+ utilities .enable_sleep_after_running ()
473593
474594
475595 def get_percent (self ) -> float :
@@ -548,8 +668,13 @@ def delete_temp_files(self) -> None:
548668 if self .progress_file .exists ():
549669 self .progress_file .unlink ()
550670 logging .info (self .trans ["Deleted progress file: {0}" ].format (self .progress_file ))
671+
672+ for part_path in self .part_paths :
673+ if part_path and part_path .exists ():
674+ part_path .unlink ()
675+ logging .info (self .trans ["Deleted partially downloaded file: {0}" ].format (part_path ))
551676 except Exception as e :
552- logging .warning (self .trans ["Failed to delete temporary files: {0}" ].format (str (e )))
677+ logging .warning (self .trans ["Failed to delete temporary files: {0}" ].format (str (e )))
553678
554679 def stop (self ) -> None :
555680 """
0 commit comments