66import tkinter as tk
77from collections import Counter
88from collections .abc import Callable
9+ from concurrent .futures import ThreadPoolExecutor
910from tkinter import ttk , messagebox , filedialog
1011from urllib .parse import urlsplit , urlunsplit
1112from xml .etree import ElementTree
@@ -228,7 +229,7 @@ def show_parse_progress(current: int, total: int) -> None: # 后台解析大量
228229 ui_call (progress_label .config , text = f"正在解析链接 { current } /{ total } " )
229230
230231def refresh_download_progress () -> None : # 汇总全部任务状态刷新进度条与标签,没有 Content-Length 或出现失败时也能看到进展
231- states = list (download_states ) # 下载线程会并发追加状态,先取快照避免遍历时被修改
232+ states = list (download_states )
232233 all_downloaded_size = sum (state ["downloaded_size" ] for state in states )
233234 all_total_size = sum (state ["total_size" ] for state in states )
234235 finished_number = len ([state for state in states if state ["finished" ]])
@@ -368,19 +369,56 @@ def restore_download_btn() -> None: # 未产生下载任务时恢复界面状态
368369 messagebox .showwarning ("警告" , "以下 “行” 无法解析:\n " + "\n " .join (failed_urls )) # 显示警告对话框
369370 return
370371
371- for resource , save_path in download_targets :
372- thread_it (download_file , resource .url , save_path , resource .chapters ) # 开始下载(多线程,防止窗口卡死)
373-
374372 progress_label .config (text = f"正在下载 { len (download_targets )} 个文件" )
373+ directory = dir_path if len (resources_info_list ) > 1 else os .path .dirname (download_targets [0 ][1 ])
374+ start_download_batch (download_targets , directory )
375375
376376 if failed_urls :
377377 messagebox .showwarning ("警告" , "以下 “行” 无法解析:\n " + "\n " .join (failed_urls )) # 显示警告对话框
378378
379379 parse_urls_in_background (list (urls ), bookmark_var .get (), start_downloads )
380380
381- def download_file (url : str , save_path : str , chapters : list [dict ] | None = None ) -> None : # 下载文件
382- current_state = { "download_url" : url , "save_path" : save_path , "downloaded_size" : 0 , "total_size" : 0 , "finished" : False , "failed_reason" : None }
383- download_states .append (current_state )
381+ def create_download_state (url : str , save_path : str ) -> dict :
382+ return { "download_url" : url , "save_path" : save_path , "downloaded_size" : 0 , "total_size" : 0 , "finished" : False , "failed_reason" : None }
383+
384+ def start_download_batch (targets : list [tuple [ResourceInfo , str ]], directory : str ) -> None :
385+ global download_states
386+ # 所有排队任务先登记,快速失败或完成的线程也不会漏算尚未启动的任务。
387+ states = [create_download_state (resource .url , save_path ) for resource , save_path in targets ]
388+ download_states = states
389+
390+ def worker () -> None :
391+ # 批量勾选可能产生数千个文件,仅保留少量工作线程,其余任务排队。
392+ with ThreadPoolExecutor (max_workers = 3 ) as executor :
393+ futures = [
394+ executor .submit (download_file , resource .url , save_path , resource .chapters , state )
395+ for (resource , save_path ), state in zip (targets , states )
396+ ]
397+ for future in futures :
398+ future .result ()
399+ ui_call (finish_download_batch , states , directory ) # 全部线程退出后,仅由批次通知一次
400+
401+ thread_it (worker )
402+
403+ def finish_download_batch (states : list [dict ], directory : str ) -> None : # 在主线程统一恢复控件并显示整批结果
404+ download_progress_bar .config (value = 0 )
405+ progress_label .config (text = "等待下载" )
406+ download_btn .config (state = "normal" )
407+
408+ failed_states = [state for state in states if state ["failed_reason" ]]
409+ if failed_states :
410+ failed_message = "\n \n " .join (
411+ f"{ os .path .relpath (state ['save_path' ], directory )} \n { state ['failed_reason' ]} "
412+ for state in failed_states
413+ )
414+ messagebox .showwarning ("下载完成" , f"文件已下载到:{ directory } \n 以下文件下载失败:\n { failed_message } " )
415+ else :
416+ messagebox .showinfo ("下载完成" , f"文件已下载到:{ directory } " )
417+
418+ def download_file (url : str , save_path : str , chapters : list [dict ] | None = None , current_state : dict | None = None ) -> None : # 下载文件
419+ if current_state is None : # 保留单独下载文件的调用方式
420+ current_state = create_download_state (url , save_path )
421+ download_states .append (current_state )
384422 temp_path = f"{ save_path } .tmp"
385423
386424 response = None
@@ -389,7 +427,6 @@ def download_file(url: str, save_path: str, chapters: list[dict] | None = None)
389427 response , attempted_urls = request_download (url )
390428
391429 if not response .ok : # 服务器返回表示错误的 HTTP 状态码
392- current_state ["finished" ] = True
393430 current_state ["failed_reason" ] = download_failure_reason (response , attempted_urls )
394431 else :
395432 current_state ["total_size" ] = int (response .headers .get ("Content-Length" , 0 ))
@@ -407,7 +444,6 @@ def download_file(url: str, save_path: str, chapters: list[dict] | None = None)
407444 if current_state ["total_size" ] > 0 and current_state ["downloaded_size" ] != current_state ["total_size" ]: # 文件下载不完整
408445 current_state ["failed_reason" ] = f"文件下载不完整,需下载 { current_state ['total_size' ]} 字节,实际下载 { current_state ['downloaded_size' ]} 字节"
409446 current_state ["downloaded_size" ], current_state ["total_size" ] = 0 , 0
410- current_state ["finished" ] = True
411447 try :
412448 os .remove (temp_path )
413449 except Exception :
@@ -418,12 +454,10 @@ def download_file(url: str, save_path: str, chapters: list[dict] | None = None)
418454 add_bookmarks (temp_path , chapters )
419455
420456 os .replace (temp_path , save_path ) # 重命名临时文件为目标文件
421- current_state ["finished" ] = True
422457
423458 except Exception as e :
424459 print_error (e )
425460 current_state ["downloaded_size" ], current_state ["total_size" ] = 0 , 0
426- current_state ["finished" ] = True
427461 current_state ["failed_reason" ] = redact_access_token (traceback .format_exc ().rstrip ())
428462 try :
429463 os .remove (temp_path )
@@ -432,28 +466,10 @@ def download_file(url: str, save_path: str, chapters: list[dict] | None = None)
432466 finally :
433467 if response is not None :
434468 response .close ()
469+ current_state ["finished" ] = True
435470
436471 refresh_download_progress () # 每个任务结束时刷新一次,重试等待期间也能看到完成数与失败数
437472
438- if all (state ["finished" ] for state in download_states ): # 所有文件下载完成
439- ui_call (download_progress_bar .config , value = 0 ) # 重置进度条
440- ui_call (progress_label .config , text = "等待下载" ) # 清空进度标签
441- ui_call (download_btn .config , state = "normal" ) # 设置下载按钮为启用状态
442-
443- failed_states = [state for state in download_states if state ["failed_reason" ]]
444- if failed_states : # 存在下载失败的文件
445- failed_message = "\n \n " .join (
446- f"{ os .path .basename (state ['save_path' ])} \n { state ['failed_reason' ]} "
447- for state in failed_states
448- )
449- ui_call (
450- messagebox .showwarning ,
451- "下载完成" ,
452- f"文件已下载到:{ os .path .dirname (save_path )} \n 以下文件下载失败:\n { failed_message } " ,
453- )
454- else :
455- ui_call (messagebox .showinfo , "下载完成" , f"文件已下载到:{ os .path .dirname (save_path )} " )
456-
457473def format_bytes (size : float ) -> str : # 将数据单位进行格式化,返回以 KB、MB、GB、TB、PB 为单位的数据大小
458474 for x in ["字节" , "KB" , "MB" , "GB" , "TB" ]:
459475 if size < 1024.0 :
0 commit comments