@@ -460,50 +460,158 @@ def _handle_peer_list(self, fname, peer_list):
460460 self .fetch_button .config (state = tk .NORMAL )
461461 return
462462
463- chosen_peer = peer_list [ 0 ]
464- if len ( peer_list ) > 1 :
465- peer_lines = [
466- f" { idx + 1 } . { peer . get ( 'hostname' ) } ( { peer . get ( 'ip' ) } : { peer . get ( 'port' ) } )"
467- for idx , peer in enumerate ( peer_list )
468- ]
469- prompt = "Select a peer to download from: \n " + " \n " . join ( peer_lines )
470- choice = simpledialog . askinteger (
471- "Choose Peer" ,
472- prompt ,
473- parent = self . root ,
474- minvalue = 1 ,
475- maxvalue = len ( peer_list ) ,
463+ selected_indices = self . _show_peer_selection ( fname , peer_list )
464+ if selected_indices is None :
465+ logging . info ( "Fetch cancelled; no peer selected." )
466+ self . fetch_button . config ( state = tk . NORMAL )
467+ return
468+
469+ if len ( selected_indices ) == 1 :
470+ chosen_peer = peer_list [ selected_indices [ 0 ]]
471+ default_name = self . _get_preferred_filename ( chosen_peer , fname )
472+ save_path = filedialog . asksaveasfilename (
473+ title = "Save Downloaded File" ,
474+ initialfile = default_name ,
475+ defaultextension = os . path . splitext ( default_name )[ 1 ] ,
476476 )
477- if choice is None :
478- logging .info ("Fetch cancelled; no peer selected." )
477+ if not save_path :
478+ logging .info ("Fetch cancelled; no destination selected." )
479479 self .fetch_button .config (state = tk .NORMAL )
480480 return
481- chosen_peer = peer_list [choice - 1 ]
482481
483- default_name = fname
484- save_path = filedialog .asksaveasfilename (
485- title = "Save Downloaded File" ,
486- initialfile = default_name ,
487- defaultextension = os .path .splitext (default_name )[1 ],
482+ if os .path .exists (save_path ):
483+ overwrite = messagebox .askyesno ("Overwrite?" , f"File '{ save_path } ' exists. Overwrite?" )
484+ if not overwrite :
485+ logging .info ("Fetch cancelled; user chose not to overwrite %s." , save_path )
486+ self .fetch_button .config (state = tk .NORMAL )
487+ return
488+
489+ threading .Thread (
490+ target = self ._download_task ,
491+ args = (chosen_peer , save_path ),
492+ daemon = True ,
493+ ).start ()
494+ return
495+
496+ target_directory = filedialog .askdirectory (
497+ title = "Select Destination Directory for Downloads" ,
498+ mustexist = True ,
488499 )
489- if not save_path :
490- logging .info ("Fetch cancelled; no destination selected." )
500+ if not target_directory :
501+ logging .info ("Fetch cancelled; no destination directory selected." )
491502 self .fetch_button .config (state = tk .NORMAL )
492503 return
493504
494- if os . path . exists ( save_path ):
495- overwrite = messagebox . askyesno ( "Overwrite?" , f"File ' { save_path } ' exists. Overwrite?" )
496- if not overwrite :
497- logging . info ( "Fetch cancelled; user chose not to overwrite %s." , save_path )
498- self .fetch_button . config ( state = tk . NORMAL )
499- return
505+ download_tasks = []
506+ for index in selected_indices :
507+ peer = peer_list [ index ]
508+ filename = self . _get_preferred_filename ( peer , fname )
509+ destination = self ._unique_destination_path ( target_directory , filename )
510+ download_tasks . append (( peer , destination ))
500511
512+ logging .info ("Starting batch download for %d peer(s)." , len (download_tasks ))
501513 threading .Thread (
502- target = self ._download_task ,
503- args = (chosen_peer , save_path ),
514+ target = self ._download_multiple_task ,
515+ args = (download_tasks , ),
504516 daemon = True ,
505517 ).start ()
506518
519+ def _show_peer_selection (self , fname , peer_list ):
520+ if len (peer_list ) == 1 :
521+ return [0 ]
522+
523+ dialog = tk .Toplevel (self .root )
524+ dialog .title ("Select Peer(s)" )
525+ dialog .transient (self .root )
526+ dialog .grab_set ()
527+
528+ instruction = tk .Label (
529+ dialog ,
530+ text = "Choose peer(s) to download from.\n "
531+ "Use Ctrl/Shift for multi-select or pick an option below." ,
532+ justify = tk .LEFT ,
533+ )
534+ instruction .pack (padx = 10 , pady = (10 , 5 ), anchor = "w" )
535+
536+ listbox = tk .Listbox (dialog , selectmode = tk .MULTIPLE , width = 60 , height = min (10 , len (peer_list )))
537+ listbox .pack (padx = 10 , pady = 5 , fill = tk .BOTH , expand = True )
538+
539+ for idx , peer in enumerate (peer_list , start = 1 ):
540+ hostname = peer .get ("hostname" ) or "Unknown"
541+ original_name = os .path .basename (peer .get ("lname" ) or fname )
542+ listbox .insert (
543+ tk .END ,
544+ f"{ idx } . { hostname } ({ original_name } )" ,
545+ )
546+
547+ button_frame = tk .Frame (dialog )
548+ button_frame .pack (padx = 10 , pady = (5 , 10 ), fill = tk .X )
549+ button_frame .columnconfigure ((0 , 1 , 2 , 3 ), weight = 1 )
550+
551+ result = {"indices" : None }
552+
553+ def on_select ():
554+ selection = listbox .curselection ()
555+ if not selection :
556+ messagebox .showinfo ("Selection required" , "Select at least one peer." , parent = dialog )
557+ return
558+ result ["indices" ] = list (selection )
559+ dialog .destroy ()
560+
561+ def on_select_all ():
562+ result ["indices" ] = list (range (len (peer_list )))
563+ dialog .destroy ()
564+
565+ def on_custom ():
566+ raw = simpledialog .askstring (
567+ "Custom selection" ,
568+ "Enter peer numbers separated by commas (e.g. 1,3,4):" ,
569+ parent = dialog ,
570+ )
571+ if raw is None :
572+ return
573+ try :
574+ indices = []
575+ for chunk in raw .replace (" " , "" ).split ("," ):
576+ if not chunk :
577+ continue
578+ value = int (chunk )
579+ if value < 1 or value > len (peer_list ):
580+ raise ValueError (f"Peer number { value } is out of range." )
581+ zero_based = value - 1
582+ if zero_based not in indices :
583+ indices .append (zero_based )
584+ except ValueError as exc :
585+ messagebox .showerror ("Invalid input" , str (exc ), parent = dialog )
586+ return
587+ if not indices :
588+ messagebox .showinfo ("Selection required" , "Provide at least one valid peer number." , parent = dialog )
589+ return
590+ result ["indices" ] = indices
591+ dialog .destroy ()
592+
593+ def on_cancel ():
594+ result ["indices" ] = None
595+ dialog .destroy ()
596+
597+ select_button = tk .Button (button_frame , text = "Download Selected" , command = on_select , bg = PASTEL_BUTTON )
598+ select_button .grid (row = 0 , column = 0 , padx = 5 , sticky = "ew" )
599+
600+ all_button = tk .Button (button_frame , text = "Download All" , command = on_select_all , bg = PASTEL_BUTTON )
601+ all_button .grid (row = 0 , column = 1 , padx = 5 , sticky = "ew" )
602+
603+ custom_button = tk .Button (button_frame , text = "Custom..." , command = on_custom , bg = PASTEL_BUTTON )
604+ custom_button .grid (row = 0 , column = 2 , padx = 5 , sticky = "ew" )
605+
606+ cancel_button = tk .Button (button_frame , text = "Cancel" , command = on_cancel , bg = PASTEL_BUTTON )
607+ cancel_button .grid (row = 0 , column = 3 , padx = 5 , sticky = "ew" )
608+
609+ dialog .protocol ("WM_DELETE_WINDOW" , on_cancel )
610+ dialog .resizable (False , False )
611+ dialog .focus_set ()
612+ self .root .wait_window (dialog )
613+ return result ["indices" ]
614+
507615 def _download_task (self , peer_info , save_path ):
508616 try :
509617 self .controller .download_from_peer (peer_info , save_path )
@@ -529,6 +637,52 @@ def _on_download_finished(self, success, error_message, peer_info, save_path):
529637 messagebox .showerror ("Download error" , error_message )
530638 self .fetch_button .config (state = tk .NORMAL )
531639
640+ def _download_multiple_task (self , download_tasks ):
641+ successes = []
642+ failures = []
643+ for peer_info , save_path in download_tasks :
644+ try :
645+ self .controller .download_from_peer (peer_info , save_path )
646+ except Exception as exc :
647+ logging .error ("Download failed for %s: %s" , save_path , exc )
648+ failures .append ((peer_info , save_path , str (exc )))
649+ else :
650+ successes .append ((peer_info , save_path ))
651+ self .root .after (
652+ 0 ,
653+ lambda : self ._on_multi_download_finished (successes , failures ),
654+ )
655+
656+ def _on_multi_download_finished (self , successes , failures ):
657+ messages = []
658+ if successes :
659+ success_lines = "\n " .join (f"- { path } " for _ , path in successes )
660+ messages .append (f"Downloaded { len (successes )} file(s):\n { success_lines } " )
661+ if failures :
662+ failure_lines = "\n " .join (
663+ f"- { os .path .basename (path )} ({ err } )" for _ , path , err in failures
664+ )
665+ messages .append (f"Failed downloads:\n { failure_lines } " )
666+
667+ summary = "\n \n " .join (messages ) if messages else "No downloads were completed."
668+ messagebox .showinfo ("Fetch summary" , summary )
669+ self .fetch_button .config (state = tk .NORMAL )
670+
671+ def _get_preferred_filename (self , peer_info , fallback_name ):
672+ original = peer_info .get ("lname" )
673+ if original :
674+ return os .path .basename (original )
675+ return os .path .basename (fallback_name )
676+
677+ def _unique_destination_path (self , directory , filename ):
678+ base , ext = os .path .splitext (filename )
679+ candidate = os .path .join (directory , filename )
680+ counter = 1
681+ while os .path .exists (candidate ):
682+ candidate = os .path .join (directory , f"{ base } _{ counter } { ext } " )
683+ counter += 1
684+ return candidate
685+
532686 def clear_log (self ):
533687 self .log_text .configure (state = tk .NORMAL )
534688 self .log_text .delete (1.0 , tk .END )
0 commit comments