kmoe-manga-downloader 1.1.0__py3-none-any.whl → 1.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
kmdr/core/bases.py CHANGED
@@ -107,16 +107,26 @@ class Downloader(SessionContext, UserProfileContext):
107
107
  def _download(self, book: BookInfo, volume: VolInfo, retry: int): ...
108
108
 
109
109
  def _download_with_multiple_workers(self, book: BookInfo, volumes: list[VolInfo], retry: int):
110
- from concurrent.futures import ThreadPoolExecutor
111
-
112
- max_workers = min(self._num_workers, len(volumes))
113
- with ThreadPoolExecutor(max_workers=max_workers) as executor:
114
- futures = [
115
- executor.submit(self._download, book, volume, retry)
116
- for volume in volumes
117
- ]
110
+ from concurrent.futures import ThreadPoolExecutor, wait, FIRST_EXCEPTION
111
+
112
+ try:
113
+ max_workers = min(self._num_workers, len(volumes))
114
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
115
+ futures = [
116
+ executor.submit(self._download, book, volume, retry)
117
+ for volume in volumes
118
+ ]
119
+ wait(futures, return_when=FIRST_EXCEPTION)
118
120
  for future in futures:
119
121
  future.result()
122
+ except KeyboardInterrupt:
123
+ print("\n操作已取消(KeyboardInterrupt)")
124
+ try:
125
+ executor.shutdown(wait=False, cancel_futures=True)
126
+ except NameError:
127
+ pass
128
+ finally:
129
+ exit(130)
120
130
 
121
131
  AUTHENTICATOR = Registry[Authenticator]('Authenticator')
122
132
  LISTERS = Registry[Lister]('Lister')
kmdr/main.py CHANGED
@@ -28,9 +28,13 @@ def main(args: Namespace, fallback: Callable[[], None] = lambda: print('NOT IMPL
28
28
  fallback()
29
29
 
30
30
  def entry_point():
31
- parser = argument_parser()
32
- args = parser.parse_args()
33
- main(args, lambda: parser.print_help())
31
+ try:
32
+ parser = argument_parser()
33
+ args = parser.parse_args()
34
+ main(args, lambda: parser.print_help())
35
+ except KeyboardInterrupt:
36
+ print("\n操作已取消(KeyboardInterrupt)")
37
+ exit(130)
34
38
 
35
39
  if __name__ == '__main__':
36
40
  entry_point()
@@ -17,9 +17,9 @@ class DirectDownloader(Downloader):
17
17
 
18
18
  download_file(
19
19
  self._session,
20
- self.construct_download_url(book, volume),
20
+ lambda: self.construct_download_url(book, volume),
21
21
  download_path,
22
- f'[Kmoe][{book.name}][{volume.name}].epub',
22
+ safe_filename(f'[Kmoe][{book.name}][{volume.name}].epub'),
23
23
  retry,
24
24
  callback=lambda: self._callback(book, volume) if self._callback else None
25
25
  )
@@ -23,9 +23,9 @@ class ReferViaDownloader(Downloader):
23
23
 
24
24
  download_file(
25
25
  self._session if not self._scraper else self._scraper,
26
- self.fetch_download_url(book_id=book.id, volume_id=volume.id),
26
+ lambda: self.fetch_download_url(book_id=book.id, volume_id=volume.id),
27
27
  download_path,
28
- f'[Kmoe][{book.name}][{volume.name}].epub',
28
+ safe_filename(f'[Kmoe][{book.name}][{volume.name}].epub'),
29
29
  retry,
30
30
  headers={
31
31
  "X-Km-From": "kb_http_down"
@@ -1,6 +1,7 @@
1
- from typing import Callable, Optional
1
+ from typing import Callable, Optional, Union
2
2
  import os
3
3
  import time
4
+ import threading
4
5
  from functools import wraps
5
6
 
6
7
  from requests import Session, HTTPError
@@ -12,71 +13,100 @@ BLOCK_SIZE_REDUCTION_FACTOR = 0.75
12
13
  MIN_BLOCK_SIZE = 2048
13
14
 
14
15
  def download_file(
15
- session: Session,
16
- url: str,
17
- dest_path: str,
18
- filename: str,
19
- retry_times: int = 0,
20
- headers: Optional[dict] = None,
21
- callback: Optional[Callable] = None,
22
- block_size: int = 8192
23
- ):
16
+ session: Session,
17
+ url: Union[str, Callable[[], str]],
18
+ dest_path: str,
19
+ filename: str,
20
+ retry_times: int = 3,
21
+ headers: Optional[dict] = None,
22
+ callback: Optional[Callable] = None,
23
+ ):
24
+ """
25
+ 下载文件
26
+
27
+ :param session: requests.Session 对象
28
+ :param url: 下载链接或者其 Supplier
29
+ :param dest_path: 目标路径
30
+ :param filename: 文件名
31
+ :param retry_times: 重试次数
32
+ :param headers: 请求头
33
+ :param callback: 下载完成后的回调函数
34
+ """
24
35
  if headers is None:
25
36
  headers = {}
26
37
  filename_downloading = f'{filename}.downloading'
27
-
28
- file_path = f'{dest_path}/{filename}'
29
- tmp_file_path = f'{dest_path}/{filename_downloading}'
30
-
38
+ file_path = os.path.join(dest_path, filename)
39
+ tmp_file_path = os.path.join(dest_path, filename_downloading)
40
+
31
41
  if not os.path.exists(dest_path):
32
42
  os.makedirs(dest_path, exist_ok=True)
33
-
43
+
34
44
  if os.path.exists(file_path):
35
- tqdm.write(f"{filename} already exists.")
45
+ tqdm.write(f"{filename} 已经存在")
36
46
  return
37
47
 
38
- resume_from = 0
39
- total_size_in_bytes = 0
40
-
41
- if os.path.exists(tmp_file_path):
42
- resume_from = os.path.getsize(tmp_file_path)
48
+ block_size = 8192
43
49
 
44
- if resume_from:
45
- headers['Range'] = f'bytes={resume_from}-'
50
+ attempts_left = retry_times + 1
51
+ progress_bar = tqdm(
52
+ total=0, unit='B', unit_scale=True,
53
+ desc=f'{filename} (连接中...)',
54
+ leave=False,
55
+ dynamic_ncols=True
56
+ )
46
57
 
47
58
  try:
48
- with session.get(url = url, stream=True, headers=headers) as r:
49
- r.raise_for_status()
59
+ while attempts_left > 0:
60
+ attempts_left -= 1
50
61
 
51
- total_size_in_bytes = int(r.headers.get('content-length', 0)) + resume_from
62
+ resume_from = os.path.getsize(tmp_file_path) if os.path.exists(tmp_file_path) else 0
52
63
 
53
- with open(tmp_file_path, 'ab') as f:
54
- with tqdm(total=total_size_in_bytes, unit='B', unit_scale=True, desc=f'{filename}', initial=resume_from) as progress_bar:
55
- for chunk in r.iter_content(chunk_size=block_size):
56
- if chunk:
57
- f.write(chunk)
58
- progress_bar.update(len(chunk))
59
-
60
- if (os.path.getsize(tmp_file_path) == total_size_in_bytes):
61
- os.rename(tmp_file_path, file_path)
62
-
63
- if callback:
64
- callback()
65
- except Exception as e:
66
- prefix = f"{type(e).__name__} occurred while downloading {filename}. "
67
-
68
- new_block_size = block_size
69
- if isinstance(e, ChunkedEncodingError):
70
- new_block_size = max(int(block_size * BLOCK_SIZE_REDUCTION_FACTOR), MIN_BLOCK_SIZE)
71
-
72
- if retry_times > 0:
73
- # 重试下载
74
- tqdm.write(f"{prefix} Retry after 3 seconds...")
75
- time.sleep(3) # 等待3秒后重试,避免触发限流
76
- download_file(session, url, dest_path, filename, retry_times - 1, headers, callback, new_block_size)
77
- else:
78
- tqdm.write(f"{prefix} Meet max retry times, download failed.")
79
- raise e
64
+ if resume_from:
65
+ headers['Range'] = f'bytes={resume_from}-'
66
+
67
+ try:
68
+ current_url = url() if callable(url) else url
69
+ with session.get(url=current_url, stream=True, headers=headers) as r:
70
+ r.raise_for_status()
71
+
72
+ total_size_in_bytes = int(r.headers.get('content-length', 0)) + resume_from
73
+
74
+ progress_bar.set_description(f'{filename}')
75
+ progress_bar.total = total_size_in_bytes
76
+ progress_bar.n = resume_from
77
+ progress_bar.refresh()
78
+
79
+ with open(tmp_file_path, 'ab') as f:
80
+ for chunk in r.iter_content(chunk_size=block_size):
81
+ if chunk:
82
+ f.write(chunk)
83
+ progress_bar.update(len(chunk))
84
+
85
+ if os.path.getsize(tmp_file_path) >= total_size_in_bytes:
86
+ os.rename(tmp_file_path, file_path)
87
+ if callback:
88
+ callback()
89
+ return
90
+
91
+ except Exception as e:
92
+ if attempts_left > 0:
93
+ progress_bar.set_description(f'{filename} (重试中...)')
94
+ if isinstance(e, ChunkedEncodingError):
95
+ new_block_size = max(int(block_size * BLOCK_SIZE_REDUCTION_FACTOR), MIN_BLOCK_SIZE)
96
+ if new_block_size < block_size:
97
+ block_size = new_block_size
98
+
99
+ # 避免限流
100
+ time.sleep(3)
101
+ else:
102
+ raise e
103
+ finally:
104
+ if progress_bar.total and progress_bar.n >= progress_bar.total:
105
+ tqdm.write(f"{filename} 下载完成")
106
+ elif progress_bar.total is not None:
107
+ tqdm.write(f"{filename} 下载失败")
108
+ progress_bar.close()
109
+
80
110
 
81
111
  def safe_filename(name: str) -> str:
82
112
  """
@@ -124,4 +154,4 @@ def clear_cache(func):
124
154
  wrapped = func.__wrapped__
125
155
 
126
156
  if wrapped in function_cache:
127
- function_cache[wrapped] = {}
157
+ function_cache[wrapped] = {}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kmoe-manga-downloader
3
- Version: 1.1.0
3
+ Version: 1.1.2
4
4
  Summary: A CLI-downloader for site @kox.moe.
5
5
  Author-email: Chris Zheng <chrisis58@outlook.com>
6
6
  License: MIT License
@@ -47,6 +47,10 @@ Dynamic: license-file
47
47
 
48
48
  `kmdr (Kmoe Manga Downloader)` 是一个 Python 应用,用于从 [Kmoe](https://kox.moe/) 网站下载漫画。它支持在终端环境下的登录、下载指定书籍及其卷,并支持回调脚本执行。
49
49
 
50
+ <p align="center">
51
+ <img src="assets/kmdr-demo.gif" alt="kmdr 使用演示" width="720">
52
+ </p>
53
+
50
54
  ## ✨功能特性
51
55
 
52
56
  - **凭证管理**: 命令行登录并持久化会话
@@ -1,7 +1,7 @@
1
1
  kmdr/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- kmdr/main.py,sha256=f3bHGfrVs_ZEydj03YOWz2ATrNH7pHQs8GLDgKea-48,891
2
+ kmdr/main.py,sha256=F_zXjRr2QIBdNrqvCqpSNkFSxVY6ySfm-mk-Gg9qy7g,1018
3
3
  kmdr/core/__init__.py,sha256=WT4MHsww1KOcIlf3z_AK5-4mXNFNBoCE1REQ0dvf9DE,281
4
- kmdr/core/bases.py,sha256=nlrMNq_GsJmZtBnOSR-VP8LgJ9ZOHBWH4Sj8aqACx9E,4091
4
+ kmdr/core/bases.py,sha256=uDLamjuRfwcVhbGI99aSUZBi-fN32OM2fl91t0bbVog,4484
5
5
  kmdr/core/defaults.py,sha256=9vVN0xywLOHm3tcNf1GGJ9RTXB38YLTvBMqxmp0jEAE,6582
6
6
  kmdr/core/error.py,sha256=ZXsiD-Ihe0zo5wdNgd7Rg-ISZifsiU5C6FeHesurfy4,597
7
7
  kmdr/core/registry.py,sha256=KYjvww5WRuUg5SeIeWZb96803-kgrTIKYvFTj_7bPfo,5560
@@ -16,18 +16,18 @@ kmdr/module/configurer/ConfigUnsetter.py,sha256=ZjySNAQj4-j9k68_tbXa2Z9G0TqGgfKP
16
16
  kmdr/module/configurer/OptionLister.py,sha256=qc-nWPft_EtbEgJnz32h3nwGyGbe9oabKZpm6dIJi_o,516
17
17
  kmdr/module/configurer/OptionSetter.py,sha256=9MIkWZb-aFUTqpVCVrnri3JZTE0zO3CIYuN_nz5yzzY,842
18
18
  kmdr/module/configurer/option_validate.py,sha256=DdtUG0E-LgjjHQgPBSyRZ_ID40QRRFSS_FEQHdK94hM,3327
19
- kmdr/module/downloader/DirectDownloader.py,sha256=3U9I9Uz1Zw5qYgje0BrhzIB3-Hff5sPBfw4JIxcBHEM,1037
20
- kmdr/module/downloader/ReferViaDownloader.py,sha256=g_6MAzl_HLmXlMoIHpxDE6W7-EIq--HZeM-BUbgzJw8,1671
21
- kmdr/module/downloader/utils.py,sha256=zBR6NSYetQdWblcA84rZp1EGVdOPwv-1eJNVrZcGyP0,3908
19
+ kmdr/module/downloader/DirectDownloader.py,sha256=5ny3or9fj2rb3qGxEWBmiEs5vlIfvCSGFiMoFISs_J8,1060
20
+ kmdr/module/downloader/ReferViaDownloader.py,sha256=_x-hniGmIqc_7OFY-kQUrOIH0TwDcf-_NCsmVql2pp0,1694
21
+ kmdr/module/downloader/utils.py,sha256=qGp_c0coVENDKPNUpA7uZoHoDxBIj5jQHlw8qf4NRpQ,4814
22
22
  kmdr/module/lister/BookUrlLister.py,sha256=Mq7EBXWSKd-6cygfkTjmOvgcUUaJI4NMQiaEIv9VDSk,470
23
23
  kmdr/module/lister/FollowedBookLister.py,sha256=_fShmCAsZQqboeuRtLBjo6d9CkGpat8xNKC3COtKllc,1537
24
24
  kmdr/module/lister/utils.py,sha256=0EHQIA05EZ1V0_SfkJ8demjzMRmFh9Q5qPvE6jvBqSU,2560
25
25
  kmdr/module/picker/ArgsFilterPicker.py,sha256=f3suMPPeFEtB3u7aUY63k_sGIOP196R-VviQ9RfDBTA,1756
26
26
  kmdr/module/picker/DefaultVolPicker.py,sha256=kpG5dvv1UKMhSA01VKGNB59zM5uszspyUVfRlL9aQA0,750
27
27
  kmdr/module/picker/utils.py,sha256=lpxM7q9BJeupFQy8glBrHu1o4E38dk7iLexzKytAE6g,1222
28
- kmoe_manga_downloader-1.1.0.dist-info/licenses/LICENSE,sha256=bKQlsXu8mAYKRZyoZKOEqMcCc8YjT5Q3Hgr21e0yU4E,1068
29
- kmoe_manga_downloader-1.1.0.dist-info/METADATA,sha256=uCbTxKLFbNyhIURQMOBJxdtmOhRw4oWeLW9Lc2XRgT0,7773
30
- kmoe_manga_downloader-1.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
31
- kmoe_manga_downloader-1.1.0.dist-info/entry_points.txt,sha256=DGMytQAhx4uNuKQL7BPkiWESHLXkH-2KSEqwHdygNPA,47
32
- kmoe_manga_downloader-1.1.0.dist-info/top_level.txt,sha256=e0qxOgWp0tl3GLpmXGjZv3--q_TLoJ7GztM48Ov27wM,5
33
- kmoe_manga_downloader-1.1.0.dist-info/RECORD,,
28
+ kmoe_manga_downloader-1.1.2.dist-info/licenses/LICENSE,sha256=bKQlsXu8mAYKRZyoZKOEqMcCc8YjT5Q3Hgr21e0yU4E,1068
29
+ kmoe_manga_downloader-1.1.2.dist-info/METADATA,sha256=vPMrYaRtOC0rTenqPS2QWj5Inq1X1gYlMbSZ2HANAPQ,7869
30
+ kmoe_manga_downloader-1.1.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
31
+ kmoe_manga_downloader-1.1.2.dist-info/entry_points.txt,sha256=DGMytQAhx4uNuKQL7BPkiWESHLXkH-2KSEqwHdygNPA,47
32
+ kmoe_manga_downloader-1.1.2.dist-info/top_level.txt,sha256=e0qxOgWp0tl3GLpmXGjZv3--q_TLoJ7GztM48Ov27wM,5
33
+ kmoe_manga_downloader-1.1.2.dist-info/RECORD,,