1414import atexit
1515import json
1616import math
17+ import io
1718from typing import Union
1819from pathlib import Path
1920
@@ -191,8 +192,9 @@ def __init__(self, url: str, path: str, size:str=None, resume_download: bool = T
191192 self .start_time : float = time .time ()
192193 self .multipart_threshold : float = 1024 * 1024 * 50
193194 self .chunk_count : int = 16
194- self .part_paths : list [Path ] = []
195+ self .part_buffers : list [io . BytesIO ] = []
195196 self .part_errors : list [str ] = []
197+ self .part_progress : dict [int , dict ] = {}
196198 self ._download_lock = threading .Lock ()
197199
198200 self .error : bool = False
@@ -443,46 +445,91 @@ def _build_download_ranges(self) -> list[tuple[int, int]]:
443445
444446 return ranges
445447
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
448+ def _download_part (self , part_index : int , start : int , end : int , max_retries : int = 3 ) -> None :
449+ buffer = io .BytesIO ()
450+ self .part_buffers [part_index ] = buffer
451+ part_size = end - start + 1
452+
453+ with self ._download_lock :
454+ self .part_progress [part_index ] = {
455+ 'status' : 'pending' ,
456+ 'downloaded' : 0 ,
457+ 'total' : part_size ,
458+ 'percent' : 0.0 ,
459+ 'attempt' : 0
460+ }
461+
449462 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 )
463+
464+ for attempt in range (max_retries ):
465+ try :
466+ with self ._download_lock :
467+ self .part_progress [part_index ]['status' ] = 'downloading'
468+ self .part_progress [part_index ]['attempt' ] = attempt + 1
469+
470+ if attempt > 0 :
471+ logging .info (f"- Retry part { part_index + 1 } (attempt { attempt + 1 } /{ max_retries } )" )
472+ buffer .seek (0 )
473+ buffer .truncate (0 )
474+ with self ._download_lock :
475+ self .part_progress [part_index ]['downloaded' ] = 0
476+ self .part_progress [part_index ]['percent' ] = 0.0
477+
478+ logging .info (f"- Download part { part_index + 1 } /{ len (self .part_buffers )} : bytes={ start } -{ end } " )
479+ response = NetworkUtilities ().get (self .url , stream = True , timeout = 100 , headers = headers )
452480
453- if response .status_code != 206 :
454- raise Exception (f"Unexpected status code for part { part_index } : { response .status_code } " )
481+ if response .status_code != 206 :
482+ raise Exception (f"Unexpected status code for part { part_index } : { response .status_code } " )
455483
456- with open (part_path , "wb" ) as file :
457- for chunk in response .iter_content (1024 * 1024 * 4 ):
484+ for chunk in response .iter_content (1024 * 1024 * 4 ):
485+ if self .should_stop :
486+ raise Exception (self .trans ["Download stopped" ])
487+ if chunk :
488+ buffer .write (chunk )
489+ with self ._download_lock :
490+ self .downloaded_file_size += len (chunk )
491+ self .part_progress [part_index ]['downloaded' ] = len (buffer .getvalue ())
492+ self .part_progress [part_index ]['percent' ] = (
493+ self .part_progress [part_index ]['downloaded' ] /
494+ self .part_progress [part_index ]['total' ] * 100
495+ )
496+
497+ with self ._download_lock :
498+ self .part_progress [part_index ]['status' ] = 'complete'
499+ logging .info (f"- Completed part { part_index + 1 } /{ len (self .part_buffers )} : { len (buffer .getvalue ())} bytes in memory" )
500+ return
501+ except Exception as e :
458502 if self .should_stop :
459- raise Exception (self .trans ["Download stopped" ])
460- if chunk :
461- file .write (chunk )
462503 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 } " )
504+ self .part_progress [part_index ]['status' ] = 'cancelled'
505+ raise e
506+ with self ._download_lock :
507+ self .part_progress [part_index ]['status' ] = 'error'
508+ if attempt < max_retries - 1 :
509+ logging .warning (f"- Part { part_index + 1 } failed: { e } , retrying..." )
510+ time .sleep (2 ** attempt )
511+ else :
512+ raise e
466513
467514 def _merge_download_parts (self ) -> None :
468515 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 ()
516+ for buffer in self .part_buffers :
517+ if buffer is None :
518+ continue
519+ buffer . seek ( 0 )
520+ while True :
521+ chunk = buffer . read ( 1024 * 1024 * 4 )
522+ if not chunk :
523+ break
524+ destination . write (chunk )
525+ if self . should_checksum :
526+ self ._update_checksum ( chunk )
527+
528+ self . part_buffers . clear ()
482529
483530 def _download_multipart (self ) -> None :
484531 ranges = self ._build_download_ranges ()
485- self .part_paths = [None ] * len (ranges )
532+ self .part_buffers = [None ] * len (ranges )
486533 self .part_errors = []
487534 threads = []
488535
@@ -600,10 +647,10 @@ def get_percent(self) -> float:
600647 Returns:
601648 float: The download percent, or -1 if unknown
602649 """
603-
604- if self .total_file_size == 0.0 :
605- return - 1
606- return self .downloaded_file_size / self .total_file_size * 100
650+ with self . _download_lock :
651+ if self .total_file_size == 0.0 :
652+ return - 1
653+ return self .downloaded_file_size / self .total_file_size * 100
607654
608655
609656 def get_speed (self ) -> float :
@@ -613,8 +660,11 @@ def get_speed(self) -> float:
613660 Returns:
614661 float: The download speed in bytes per second
615662 """
616-
617- return self .downloaded_file_size / (time .time () - self .start_time )
663+ with self ._download_lock :
664+ elapsed = time .time () - self .start_time
665+ if elapsed <= 0 :
666+ return 0
667+ return self .downloaded_file_size / elapsed
618668
619669
620670 def get_time_remaining (self ) -> float :
@@ -624,13 +674,16 @@ def get_time_remaining(self) -> float:
624674 Returns:
625675 float: The time remaining in seconds, or -1 if unknown
626676 """
627-
628- if self .total_file_size == 0.0 :
629- return - 1
630- speed = self .get_speed ()
631- if speed <= 0 :
632- return - 1
633- return (self .total_file_size - self .downloaded_file_size ) / speed
677+ with self ._download_lock :
678+ if self .total_file_size == 0.0 :
679+ return - 1
680+ elapsed = time .time () - self .start_time
681+ if elapsed <= 0 :
682+ return - 1
683+ speed = self .downloaded_file_size / elapsed
684+ if speed <= 0 :
685+ return - 1
686+ return (self .total_file_size - self .downloaded_file_size ) / speed
634687
635688
636689 def get_file_size (self ) -> float :
@@ -640,7 +693,56 @@ def get_file_size(self) -> float:
640693 Returns:
641694 float: The file size in bytes, or 0.0 if unknown
642695 """
643- return self .total_file_size
696+ with self ._download_lock :
697+ return self .total_file_size
698+
699+
700+ def get_downloaded_size (self ) -> float :
701+ """
702+ Query the downloaded file size
703+
704+ Returns:
705+ float: The downloaded file size in bytes
706+ """
707+ with self ._download_lock :
708+ return self .downloaded_file_size
709+
710+
711+ def get_part_progress (self ) -> dict [int , dict ]:
712+ """
713+ Query the progress of each download part
714+
715+ Returns:
716+ dict: Dictionary with part index as key and progress info as value
717+ Each entry contains: status, downloaded, total, percent, attempt
718+ """
719+ with self ._download_lock :
720+ return dict (self .part_progress )
721+
722+
723+ def print_part_progress (self ) -> None :
724+ """
725+ Print the progress of all parts to console
726+ """
727+ progress = self .get_part_progress ()
728+ if not progress :
729+ return
730+
731+ print (f"\n --- Part Progress ({ len (progress )} parts) ---" )
732+ for idx , info in sorted (progress .items ()):
733+ status_icon = {
734+ 'pending' : '⏳' ,
735+ 'downloading' : '⬇️' ,
736+ 'complete' : '✅' ,
737+ 'error' : '❌' ,
738+ 'cancelled' : '🚫'
739+ }.get (info ['status' ], '❓' )
740+
741+ retry_info = f" [retry { info ['attempt' ]} ]" if info ['attempt' ] > 1 else ""
742+ print (f"Part { idx + 1 :2d} : { status_icon } { info ['status' ]:12s} "
743+ f"{ info ['percent' ]:6.1f} % ({ utilities .human_fmt (info ['downloaded' ])} /{ utilities .human_fmt (info ['total' ])} )"
744+ f"{ retry_info } " )
745+ print ("----------------------------------------" )
644746
645747
646748 def is_active (self ) -> bool :
@@ -670,10 +772,9 @@ def delete_temp_files(self) -> None:
670772 self .progress_file .unlink ()
671773 logging .info (self .trans ["Deleted progress file: {0}" ].format (self .progress_file ))
672774
673- for part_path in self .part_paths :
674- if part_path and part_path .exists ():
675- part_path .unlink ()
676- logging .info (self .trans ["Deleted partially downloaded file: {0}" ].format (part_path ))
775+ # Clear memory buffers
776+ self .part_buffers .clear ()
777+ logging .info (self .trans ["Cleared download memory buffers" ])
677778 except Exception as e :
678779 logging .warning (self .trans ["Failed to delete temporary files: {0}" ].format (str (e )))
679780
0 commit comments