kmoe-manga-downloader 1.2.4b0__tar.gz → 1.2.6__tar.gz

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.
Files changed (64) hide show
  1. kmoe_manga_downloader-1.2.6/.github/ISSUE_TEMPLATE/bug_report.yml +81 -0
  2. kmoe_manga_downloader-1.2.6/.github/ISSUE_TEMPLATE/feature_request.yml +55 -0
  3. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/.github/workflows/unit-test.yml +3 -0
  4. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/PKG-INFO +1 -1
  5. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/src/kmdr/_version.py +3 -3
  6. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/src/kmdr/core/bases.py +6 -7
  7. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/src/kmdr/core/console.py +8 -4
  8. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/src/kmdr/core/context.py +1 -1
  9. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/src/kmdr/core/defaults.py +5 -8
  10. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/src/kmdr/core/error.py +15 -0
  11. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/src/kmdr/core/protocol.py +4 -0
  12. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/src/kmdr/core/registry.py +2 -0
  13. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/src/kmdr/core/session.py +26 -3
  14. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/src/kmdr/core/utils.py +24 -2
  15. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/src/kmdr/main.py +2 -6
  16. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/src/kmdr/module/configurer/BaseUrlUpdator.py +1 -1
  17. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/src/kmdr/module/configurer/ConfigClearer.py +2 -2
  18. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/src/kmdr/module/configurer/option_validate.py +7 -5
  19. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/src/kmdr/module/downloader/DirectDownloader.py +3 -2
  20. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/src/kmdr/module/downloader/ReferViaDownloader.py +16 -4
  21. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/src/kmdr/module/downloader/download_utils.py +72 -27
  22. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/src/kmdr/module/downloader/misc.py +14 -1
  23. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/src/kmdr/module/lister/FollowedBookLister.py +2 -2
  24. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/src/kmdr/module/lister/utils.py +7 -0
  25. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/src/kmoe_manga_downloader.egg-info/PKG-INFO +1 -1
  26. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/src/kmoe_manga_downloader.egg-info/SOURCES.txt +2 -0
  27. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/tests/test_kmdr_download.py +50 -1
  28. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/.github/workflows/release-package.yml +0 -0
  29. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/.gitignore +0 -0
  30. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/LICENSE +0 -0
  31. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/README.md +0 -0
  32. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/assets/kmdr-demo.gif +0 -0
  33. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/assets/kmdr-log-demo.gif +0 -0
  34. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/mirror/mirrors.json +0 -0
  35. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/pyproject.toml +0 -0
  36. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/setup.cfg +0 -0
  37. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/src/kmdr/__init__.py +0 -0
  38. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/src/kmdr/core/__init__.py +0 -0
  39. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/src/kmdr/core/constants.py +0 -0
  40. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/src/kmdr/core/structure.py +0 -0
  41. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/src/kmdr/module/__init__.py +0 -0
  42. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/src/kmdr/module/authenticator/CookieAuthenticator.py +0 -0
  43. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/src/kmdr/module/authenticator/LoginAuthenticator.py +0 -0
  44. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/src/kmdr/module/authenticator/__init__.py +0 -0
  45. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/src/kmdr/module/authenticator/utils.py +0 -0
  46. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/src/kmdr/module/configurer/ConfigUnsetter.py +0 -0
  47. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/src/kmdr/module/configurer/OptionLister.py +0 -0
  48. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/src/kmdr/module/configurer/OptionSetter.py +0 -0
  49. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/src/kmdr/module/configurer/__init__.py +0 -0
  50. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/src/kmdr/module/downloader/__init__.py +0 -0
  51. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/src/kmdr/module/lister/BookUrlLister.py +0 -0
  52. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/src/kmdr/module/lister/__init__.py +0 -0
  53. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/src/kmdr/module/picker/ArgsFilterPicker.py +0 -0
  54. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/src/kmdr/module/picker/DefaultVolPicker.py +0 -0
  55. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/src/kmdr/module/picker/__init__.py +0 -0
  56. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/src/kmdr/module/picker/utils.py +0 -0
  57. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/src/kmoe_manga_downloader.egg-info/dependency_links.txt +0 -0
  58. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/src/kmoe_manga_downloader.egg-info/entry_points.txt +0 -0
  59. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/src/kmoe_manga_downloader.egg-info/requires.txt +0 -0
  60. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/src/kmoe_manga_downloader.egg-info/top_level.txt +0 -0
  61. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/tests/__init__.py +0 -0
  62. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/tests/test_async_retry_decorator.py +0 -0
  63. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/tests/test_kmdr_config_option.py +0 -0
  64. {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.6}/tests/test_utils_resolve_volme.py +0 -0
@@ -0,0 +1,81 @@
1
+ name: Bug 报告
2
+ description: 提交一个 Bug 帮助我们改进
3
+ labels: ['bug']
4
+
5
+ body:
6
+ - type: markdown
7
+ attributes:
8
+ value: |
9
+ 感谢您提交 Bug!
10
+ 请确保已阅读相关文档,并按照模版提供信息,否则 issue 可能会被关闭。
11
+
12
+ - type: input
13
+ id: app-version
14
+ attributes:
15
+ label: 应用版本
16
+ description: 请提供您使用的应用版本号,可以通过 `kmdr --version` 获取。
17
+ validations:
18
+ required: true
19
+
20
+ - type: textarea
21
+ id: actual-command
22
+ attributes:
23
+ label: 实际执行的命令
24
+ description: 请提供您实际执行的命令。
25
+ placeholder: kmdr download -l <book_url> -v <volume_selected>
26
+ validations:
27
+ required: true
28
+
29
+ - type: textarea
30
+ id: what-expected
31
+ attributes:
32
+ label: 预期是什么?
33
+ description: 您期望发生什么?
34
+ validations:
35
+ required: true
36
+
37
+ - type: textarea
38
+ id: actual-happened
39
+ attributes:
40
+ label: 实际发生了什么?
41
+ description: 实际发生了什么,可以附带截图等说明问题。
42
+ validations:
43
+ required: true
44
+
45
+ - type: textarea
46
+ id: logs
47
+ attributes:
48
+ label: 应用输出信息
49
+ description: 请在此处粘贴任何相关的日志、报错信息或堆栈跟踪。
50
+ render: shell
51
+ validations:
52
+ required: true
53
+
54
+ - type: textarea
55
+ id: debug-logs
56
+ attributes:
57
+ label: 调试输出信息
58
+ description: |
59
+ 如果问题可以复现,请启用调试模式(添加 `--verbose` 参数)并粘贴调试日志。
60
+ 如 `kmdr --verbose status`。
61
+ render: shell
62
+ validations:
63
+ required: false
64
+
65
+ - type: textarea
66
+ id: additional-context
67
+ attributes:
68
+ label: 其他上下文信息
69
+ description: |
70
+ 请提供任何其他有助于理解问题的上下文信息,例如操作系统、使用的 shell 等。
71
+ placeholder: "OS: ..., Shell: ..."
72
+ validations:
73
+ required: false
74
+
75
+ - type: checkboxes
76
+ id: terms
77
+ attributes:
78
+ label: 这不是重复的 issue
79
+ options:
80
+ - label: 我已经搜索了现有 issue,以确保该错误尚未被报告。
81
+ required: true
@@ -0,0 +1,55 @@
1
+ name: 功能请求
2
+ description: 建议一个新功能或改进
3
+ labels: ['enhancement']
4
+
5
+ body:
6
+ - type: markdown
7
+ attributes:
8
+ value: |
9
+ 感谢您抽出宝贵时间提出功能请求!
10
+ 请详细描述您的想法,以便我们更好地理解它。
11
+
12
+ - type: textarea
13
+ id: problem-description
14
+ attributes:
15
+ label: 您的功能请求是否与某个问题/痛点相关?
16
+ description: 请详细描述您遇到的问题或痛点。
17
+ placeholder: "当我...时,我希望能..."
18
+ validations:
19
+ required: true
20
+
21
+ - type: textarea
22
+ id: solution-description
23
+ attributes:
24
+ label: 您希望的解决方案是什么?
25
+ description: |
26
+ 请清晰简洁地描述您希望发生什么。
27
+ 如果是 CLI 相关的,可以提供理想中的命令用法。
28
+ placeholder: |
29
+ 我希望 `kmdr download` 增加一个 `--format epub` 标志...
30
+ validations:
31
+ required: true
32
+
33
+ - type: textarea
34
+ id: alternatives-considered
35
+ attributes:
36
+ label: 您是否考虑过其他替代方案?
37
+ description: 请描述您可能考虑过的任何替代解决方案或功能,以及为什么提议的方案更好。
38
+ validations:
39
+ required: false
40
+
41
+ - type: textarea
42
+ id: additional-context
43
+ attributes:
44
+ label: 额外信息
45
+ description: 在这里添加任何其他关于功能请求的信息,例如伪代码、流程图、截图或外部链接。
46
+ validations:
47
+ required: false
48
+
49
+ - type: checkboxes
50
+ id: terms
51
+ attributes:
52
+ label: 确认
53
+ options:
54
+ - label: 我已经搜索了现有 issue,以确保这个功能尚未被提议。
55
+ required: true
@@ -3,6 +3,9 @@ name: Unit Tests
3
3
  on:
4
4
  push:
5
5
  branches: [ main ]
6
+ paths:
7
+ - 'src/**'
8
+ - 'tests/**'
6
9
 
7
10
  jobs:
8
11
  test:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kmoe-manga-downloader
3
- Version: 1.2.4b0
3
+ Version: 1.2.6
4
4
  Summary: A CLI-downloader for site @kox.moe.
5
5
  Author-email: Chris Zheng <chrisis58@outlook.com>
6
6
  License: MIT License
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '1.2.4b0'
32
- __version_tuple__ = version_tuple = (1, 2, 4, 'b0')
31
+ __version__ = version = '1.2.6'
32
+ __version_tuple__ = version_tuple = (1, 2, 6)
33
33
 
34
- __commit_id__ = commit_id = 'g4623b0002'
34
+ __commit_id__ = commit_id = 'ge80e6fa7b'
@@ -9,6 +9,7 @@ from .error import LoginError
9
9
  from .registry import Registry
10
10
  from .structure import VolInfo, BookInfo
11
11
  from .utils import construct_callback, async_retry
12
+ from .protocol import AsyncCtxManager
12
13
 
13
14
  from .context import TerminalContext, SessionContext, UserProfileContext, ConfigContext
14
15
 
@@ -26,7 +27,7 @@ class SessionManager(SessionContext, ConfigContext, TerminalContext):
26
27
  super().__init__(*args, **kwargs)
27
28
 
28
29
  @abstractmethod
29
- async def session(self) -> ClientSession: ...
30
+ async def session(self) -> AsyncCtxManager[ClientSession]: ...
30
31
 
31
32
  class Authenticator(SessionContext, ConfigContext, UserProfileContext, TerminalContext):
32
33
 
@@ -44,7 +45,6 @@ class Authenticator(SessionContext, ConfigContext, UserProfileContext, TerminalC
44
45
  except LoginError as e:
45
46
  info(f"[yellow]详细信息:{e}[/yellow]")
46
47
  info("[red]认证失败。请检查您的登录凭据或会话 cookie。[/red]")
47
- exit(1)
48
48
 
49
49
  @abstractmethod
50
50
  async def _authenticate(self) -> bool: ...
@@ -84,7 +84,7 @@ class Downloader(SessionContext, UserProfileContext, TerminalContext):
84
84
  async def download(self, book: BookInfo, volumes: list[VolInfo]):
85
85
  if not volumes:
86
86
  info("没有可下载的卷。", style="blue")
87
- exit(0)
87
+ return
88
88
 
89
89
  try:
90
90
  with self._progress:
@@ -97,11 +97,10 @@ class Downloader(SessionContext, UserProfileContext, TerminalContext):
97
97
  for exc in exceptions:
98
98
  info(f"[red]- {exc}[/red]")
99
99
  exception(exc)
100
- exit(1)
101
100
 
102
- except KeyboardInterrupt:
103
- info("\n操作已取消(KeyboardInterrupt)")
104
- exit(130)
101
+ except asyncio.CancelledError:
102
+ await asyncio.sleep(0.01)
103
+ raise
105
104
 
106
105
  @abstractmethod
107
106
  async def _download(self, book: BookInfo, volume: VolInfo): ...
@@ -10,8 +10,6 @@ import io
10
10
  from rich.console import Console
11
11
  from rich.traceback import Traceback
12
12
 
13
- from kmdr.core.defaults import is_verbose
14
-
15
13
  _console_config = dict[str, Any](
16
14
  log_time_format="[%Y-%m-%d %H:%M:%S]",
17
15
  )
@@ -24,6 +22,12 @@ except io.UnsupportedOperation:
24
22
 
25
23
  _console = Console(**_console_config)
26
24
 
25
+ _is_verbose = False
26
+
27
+ def _update_verbose_setting(value: bool):
28
+ global _is_verbose
29
+ _is_verbose = value
30
+
27
31
  def info(*args, **kwargs):
28
32
  """
29
33
  在终端中输出信息
@@ -41,7 +45,7 @@ def debug(*args, **kwargs):
41
45
 
42
46
  `info` 的条件版本,仅当启用详细模式时才会输出。
43
47
  """
44
- if is_verbose():
48
+ if _is_verbose:
45
49
  if _console.is_interactive:
46
50
  _console.print("[dim]DEBUG:[/]", *args, **kwargs)
47
51
  else:
@@ -57,7 +61,7 @@ def log(*args, debug=False, **kwargs):
57
61
  # 如果是交互式终端,则不记录日志
58
62
  return
59
63
 
60
- if debug and is_verbose():
64
+ if debug and _is_verbose:
61
65
  # 仅在调试模式和启用详细模式时记录调试日志
62
66
  _console.log("DEBUG:", *args, **kwargs, _stack_offset=2)
63
67
  else:
@@ -18,7 +18,7 @@ class TerminalContext:
18
18
  def _progress(self) -> Progress:
19
19
  global _lazy_progress
20
20
  if _lazy_progress is None:
21
- _lazy_progress = Progress(*progress_definition, console=self._console)
21
+ _lazy_progress = Progress(*progress_definition, console=self._console, refresh_per_second=4)
22
22
  return _lazy_progress
23
23
 
24
24
  class UserProfileContext:
@@ -5,6 +5,7 @@ import json
5
5
  from typing import Optional, Any
6
6
  import argparse
7
7
  from contextvars import ContextVar
8
+
8
9
  from rich.console import Console
9
10
  from rich.progress import (
10
11
  Progress,
@@ -18,6 +19,7 @@ from rich.progress import (
18
19
  from .utils import singleton
19
20
  from .structure import Config
20
21
  from .constants import BASE_URL
22
+ from .console import _update_verbose_setting
21
23
 
22
24
  HEADERS = {
23
25
  'User-Agent': 'kmdr/1.0 (https://github.com/chrisis58/kmoe-manga-downloader)'
@@ -68,6 +70,7 @@ def argument_parser():
68
70
  download_parser.add_argument('-r', '--retry', type=int, help='网络请求失败时的重试次数', required=False)
69
71
  download_parser.add_argument('-c', '--callback', type=str, help='每个卷下载完成后执行的回调脚本,例如: `echo {v.name} downloaded!`', required=False)
70
72
  download_parser.add_argument('-m', '--method', type=int, help='下载方法,对应网站上的不同下载方式', required=False, choices=[1, 2], default=1)
73
+ download_parser.add_argument('--vip', action='store_true', help='尝试使用 VIP 链接进行下载(下载速度可能不及 CDN 方式)')
71
74
 
72
75
  login_parser = subparsers.add_parser('login', help='登录到 Kmoe')
73
76
  login_parser.add_argument('-u', '--username', type=str, help='用户名', required=True)
@@ -95,7 +98,6 @@ def parse_args():
95
98
 
96
99
  if args.command is None:
97
100
  parser.print_help()
98
- exit(1)
99
101
 
100
102
  return args
101
103
 
@@ -241,11 +243,6 @@ def combine_args(dest: argparse.Namespace) -> argparse.Namespace:
241
243
 
242
244
  base_url_var = ContextVar('base_url', default=Configurer().base_url)
243
245
 
244
- _verbose = False
245
-
246
- def is_verbose() -> bool:
247
- return _verbose
248
-
249
246
  def post_init(args) -> None:
250
- global _verbose
251
- _verbose = getattr(args, 'verbose', False)
247
+ _verbose = getattr(args, 'verbose', False)
248
+ _update_verbose_setting(_verbose)
@@ -7,6 +7,9 @@ class KmdrError(RuntimeError):
7
7
 
8
8
  self._solution = "" if solution is None else "\n[bold cyan]推荐解决方法:[/bold cyan] \n" + "\n".join(f"[cyan]>>> {sol}[/cyan]" for sol in solution)
9
9
 
10
+ def __str__(self):
11
+ return f"{self.message}\n{self._solution}"
12
+
10
13
  class InitializationError(KmdrError):
11
14
  def __init__(self, message, solution: Optional[list[str]] = None):
12
15
  super().__init__(message, solution)
@@ -36,6 +39,18 @@ class RedirectError(KmdrError):
36
39
  def __str__(self):
37
40
  return f"{self.message} 新的地址: {self.new_base_url}"
38
41
 
42
+ class ValidationError(KmdrError):
43
+ def __init__(self, message, field: str):
44
+ super().__init__(message)
45
+ self.field = field
46
+
47
+ def __str__(self):
48
+ return f"{self.message} (字段: {self.field})"
49
+
50
+ class EmptyResultError(KmdrError):
51
+ def __init__(self, message):
52
+ super().__init__(message)
53
+
39
54
  class ResponseError(KmdrError):
40
55
  def __init__(self, message, status_code: int):
41
56
  super().__init__(message)
@@ -8,3 +8,7 @@ class Supplier(Protocol[S]):
8
8
 
9
9
  class Consumer(Protocol[T]):
10
10
  def __call__(self, value: T) -> None: ...
11
+
12
+ class AsyncCtxManager(Protocol[S]):
13
+ async def __aenter__(self) -> S: ...
14
+ async def __aexit__(self, exc_type, exc_value, traceback) -> None: ...
@@ -3,6 +3,7 @@ from dataclasses import dataclass, field
3
3
  from argparse import Namespace
4
4
 
5
5
  from .defaults import combine_args
6
+ from .console import debug
6
7
 
7
8
  T = TypeVar('T')
8
9
 
@@ -76,6 +77,7 @@ class Registry(Generic[T]):
76
77
  def get(self, condition: Namespace) -> T:
77
78
  if self._combine_args:
78
79
  condition = combine_args(condition)
80
+ debug("合并默认参数后,条件为:", condition)
79
81
  return self._get(condition)
80
82
 
81
83
  def _get(self, condition: Namespace) -> T:
@@ -1,6 +1,9 @@
1
1
  from typing import Optional
2
2
  from urllib.parse import urlsplit, urljoin
3
+ from typing import Type
4
+ from types import TracebackType
3
5
 
6
+ import asyncio
4
7
  from aiohttp import ClientSession
5
8
 
6
9
  from .constants import BASE_URL, API_ROUTE
@@ -10,6 +13,7 @@ from .defaults import HEADERS
10
13
  from .error import InitializationError, RedirectError
11
14
  from .protocol import Supplier
12
15
  from .console import *
16
+ from .protocol import AsyncCtxManager
13
17
 
14
18
 
15
19
 
@@ -38,11 +42,11 @@ class KmdrSessionManager(SessionManager):
38
42
  self._sorter.incr(primary_base_url, 10)
39
43
  debug("镜像地址优先级排序:", self._sorter)
40
44
 
41
- async def session(self) -> ClientSession:
45
+ async def session(self) -> AsyncCtxManager[ClientSession]:
42
46
  try:
43
47
  if self._session is not None and not self._session.closed:
44
48
  # 幂等性检查:如果 session 已经存在且未关闭,直接返回
45
- return self._session
49
+ return SessionCtxManager(self._session)
46
50
  except LookupError:
47
51
  # session_var 尚未设置
48
52
  pass
@@ -62,7 +66,7 @@ class KmdrSessionManager(SessionManager):
62
66
  headers=HEADERS,
63
67
  )
64
68
 
65
- return self._session
69
+ return SessionCtxManager(self._session)
66
70
 
67
71
  async def validate_url(self, session: ClientSession, url_supplier: Supplier[str]) -> bool:
68
72
  try:
@@ -118,3 +122,22 @@ class KmdrSessionManager(SessionManager):
118
122
 
119
123
  raise InitializationError(f"所有镜像均不可用,请检查您的网络连接或使用其他镜像。\n详情参考:https://github.com/chrisis58/kmoe-manga-downloader/blob/main/mirror/mirrors.json")
120
124
 
125
+ class SessionCtxManager:
126
+ def __init__(self, session: ClientSession):
127
+ self._session = session
128
+
129
+ async def __aenter__(self) -> ClientSession:
130
+ await self._session.__aenter__()
131
+ return self._session
132
+
133
+ async def __aexit__(
134
+ self,
135
+ exc_type: Optional[Type[BaseException]],
136
+ exc_value: Optional[BaseException],
137
+ traceback: Optional[TracebackType]
138
+ ):
139
+ await self._session.__aexit__(exc_type, exc_value, traceback)
140
+
141
+ if exc_type in (KeyboardInterrupt, asyncio.CancelledError):
142
+ debug("任务被取消,正在清理资源")
143
+ await asyncio.sleep(0.01)
@@ -1,6 +1,7 @@
1
1
  import functools
2
2
  from typing import Optional, Callable, TypeVar, Hashable, Generic
3
3
  import asyncio
4
+ from asyncio.proactor_events import _ProactorBasePipeTransport
4
5
 
5
6
  import aiohttp
6
7
 
@@ -9,6 +10,7 @@ import subprocess
9
10
  from .structure import BookInfo, VolInfo
10
11
  from .error import RedirectError
11
12
  from .protocol import Consumer
13
+ from .console import debug
12
14
 
13
15
 
14
16
  def singleton(cls):
@@ -68,7 +70,7 @@ def async_retry(
68
70
  except RedirectError as e:
69
71
  if base_url_setter:
70
72
  base_url_setter(e.new_base_url)
71
- print(f"检测到重定向,已自动更新 base url 为: {e.new_base_url}。立即重试...")
73
+ debug("检测到重定向,已自动更新 base url 为", e.new_base_url)
72
74
  continue
73
75
  else:
74
76
  raise
@@ -124,4 +126,24 @@ class PrioritySorter(Generic[H]):
124
126
 
125
127
  def sort(self) -> list[H]:
126
128
  """返回根据优先级排序后的元素列表,优先级高的元素排在前面"""
127
- return [k for k, v in sorted(self._items.items(), key=lambda item: item[1], reverse=True)]
129
+ return [k for k, v in sorted(self._items.items(), key=lambda item: item[1], reverse=True)]
130
+
131
+ def _silence_event_loop_closed(func):
132
+ """
133
+ 用于静默处理 'Event loop is closed' 异常的装饰器。
134
+ 该异常在某些情况下(如 Windows 平台使用 Proactor 事件循环)会在对象销毁时抛出,
135
+ 导致程序输出不必要的错误信息。此装饰器捕获该异常并忽略它。
136
+
137
+ @see https://github.com/aio-libs/aiohttp/issues/4324
138
+ """
139
+ @functools.wraps(func)
140
+ def wrapper(self, *args, **kwargs):
141
+ try:
142
+ return func(self, *args, **kwargs)
143
+ except RuntimeError as e:
144
+ if str(e) != 'Event loop is closed':
145
+ raise
146
+
147
+ return wrapper
148
+
149
+ _ProactorBasePipeTransport.__del__ = _silence_event_loop_closed(_ProactorBasePipeTransport.__del__)
@@ -1,9 +1,8 @@
1
- from kmdr import __version__
2
-
3
1
  from typing import Callable
4
2
  from argparse import Namespace
5
3
  import asyncio
6
4
 
5
+ from kmdr import __version__
7
6
  from kmdr.core import *
8
7
  from kmdr.module import *
9
8
 
@@ -51,17 +50,14 @@ def entry_point():
51
50
  parser = argument_parser()
52
51
  args = parser.parse_args()
53
52
 
54
- main_coro = main(args, lambda: parser.print_help())
53
+ main_coro = main(args, parser.print_help)
55
54
  asyncio.run(main_coro)
56
55
  except KmdrError as e:
57
56
  info(f"[red]错误: {e}[/red]")
58
- exit(1)
59
57
  except KeyboardInterrupt:
60
58
  info("\n操作已取消(KeyboardInterrupt)", style="yellow")
61
- exit(130)
62
59
  except Exception as e:
63
60
  exception(e)
64
- exit(1)
65
61
  finally:
66
62
  log('[Lifecycle:End] 运行结束,kmdr 已退出')
67
63
 
@@ -12,6 +12,6 @@ class BaseUrlUpdator(Configurer):
12
12
  self._configurer.set_base_url(self._base_url)
13
13
  except KeyError as e:
14
14
  info(f"[red]{e.args[0]}[/red]")
15
- exit(1)
15
+ return
16
16
 
17
17
  info(f"已设置基础 URL: {self._base_url}")
@@ -12,6 +12,6 @@ class ConfigClearer(Configurer):
12
12
  self._configurer.clear(self._clear)
13
13
  except KeyError as e:
14
14
  info(f"[red]{e.args[0]}[/red]")
15
- exit(1)
15
+ return
16
16
 
17
- info(f"Cleared configuration: {self._clear}")
17
+ info(f"[green]已清除: {self._clear}[/green]")
@@ -3,6 +3,7 @@ from functools import wraps
3
3
  import os
4
4
 
5
5
  from kmdr.core.console import info
6
+ from kmdr.core.error import ValidationError
6
7
 
7
8
  __OPTIONS_VALIDATOR = {}
8
9
 
@@ -20,18 +21,19 @@ def validate(key: str, value: str) -> Optional[object]:
20
21
  info(f"[red]不支持的配置项: {key}。可用配置项:{', '.join(__OPTIONS_VALIDATOR.keys())}[/red]")
21
22
  return None
22
23
 
23
- def check_key(key: str, exit_if_invalid: bool = True) -> None:
24
+ def check_key(key: str, raise_if_invalid: bool = True) -> None:
24
25
  """
25
26
  供外部调用的验证函数,用于检查配置项的键名是否有效。
26
27
  如果键名无效,函数会打印错误信息并退出程序。
27
28
 
28
29
  :param key: 配置项的键名
29
- :param exit_if_invalid: 如果键名无效,是否退出程序
30
+ :param raise_if_invalid: 如果键名无效,是否抛出异常
30
31
  """
31
32
  if key not in __OPTIONS_VALIDATOR:
32
- info(f"[red]未知配置项: {key}。可用配置项:{', '.join(__OPTIONS_VALIDATOR.keys())}[/red]")
33
- if exit_if_invalid:
34
- exit(1)
33
+ if raise_if_invalid:
34
+ raise ValidationError(f"未知配置项: {key}。可用配置项:{', '.join(__OPTIONS_VALIDATOR.keys())}", field=key)
35
+ else:
36
+ info(f"[red]未知配置项: {key}。可用配置项:{', '.join(__OPTIONS_VALIDATOR.keys())}[/red]")
35
37
 
36
38
  def register_validator(arg_name):
37
39
  """
@@ -11,8 +11,9 @@ from .download_utils import download_file_multipart, readable_safe_filename
11
11
  }
12
12
  )
13
13
  class DirectDownloader(Downloader):
14
- def __init__(self, dest='.', callback=None, retry=3, num_workers=8, proxy=None, *args, **kwargs):
14
+ def __init__(self, dest='.', callback=None, retry=3, num_workers=8, proxy=None, vip=False, *args, **kwargs):
15
15
  super().__init__(dest, callback, retry, num_workers, proxy, *args, **kwargs)
16
+ self._use_vip = vip
16
17
 
17
18
  async def _download(self, book: BookInfo, volume: VolInfo):
18
19
  sub_dir = readable_safe_filename(book.name)
@@ -33,5 +34,5 @@ class DirectDownloader(Downloader):
33
34
  return API_ROUTE.DOWNLOAD.format(
34
35
  book_id=book.id,
35
36
  volume_id=volume.id,
36
- is_vip=self._profile.is_vip
37
+ is_vip=self._profile.is_vip if self._use_vip else 0
37
38
  )
@@ -1,11 +1,13 @@
1
1
  from functools import partial
2
2
 
3
3
  import json
4
+ import aiohttp
4
5
  from async_lru import alru_cache
5
6
 
6
7
  from kmdr.core import Downloader, VolInfo, DOWNLOADER, BookInfo
7
8
  from kmdr.core.constants import API_ROUTE
8
9
  from kmdr.core.error import ResponseError
10
+ from kmdr.core.utils import async_retry
9
11
  from kmdr.core.console import debug
10
12
 
11
13
  from .download_utils import download_file_multipart, readable_safe_filename
@@ -13,9 +15,9 @@ from .download_utils import download_file_multipart, readable_safe_filename
13
15
 
14
16
  @DOWNLOADER.register(order=10)
15
17
  class ReferViaDownloader(Downloader):
16
- def __init__(self, dest='.', callback=None, retry=3, num_workers=8, proxy=None, *args, **kwargs):
18
+ def __init__(self, dest='.', callback=None, retry=3, num_workers=8, proxy=None, vip=False, *args, **kwargs):
17
19
  super().__init__(dest, callback, retry, num_workers, proxy, *args, **kwargs)
18
-
20
+ self._use_vip = vip
19
21
 
20
22
  async def _download(self, book: BookInfo, volume: VolInfo):
21
23
  sub_dir = readable_safe_filename(book.name)
@@ -36,13 +38,14 @@ class ReferViaDownloader(Downloader):
36
38
  )
37
39
 
38
40
  @alru_cache(maxsize=128)
41
+ @async_retry()
39
42
  async def fetch_download_url(self, book_id: str, volume_id: str) -> str:
40
43
 
41
44
  async with self._session.get(
42
45
  API_ROUTE.GETDOWNURL.format(
43
46
  book_id=book_id,
44
47
  volume_id=volume_id,
45
- is_vip=self._profile.is_vip
48
+ is_vip=self._profile.is_vip if self._use_vip else 0
46
49
  )
47
50
  ) as response:
48
51
  response.raise_for_status()
@@ -50,6 +53,15 @@ class ReferViaDownloader(Downloader):
50
53
  data = json.loads(data)
51
54
  debug("获取下载链接响应数据:", data)
52
55
  if (code := data.get('code')) != 200:
53
- raise ResponseError(f"Failed to fetch download URL: {data.get('msg', 'Unknown error')}", code)
56
+
57
+ if code in {401, 403, 404}:
58
+ raise ResponseError("无法获取下载链接" + data.get('msg', 'Unknown error'), code)
59
+
60
+ raise aiohttp.ClientResponseError(
61
+ response.request_info,
62
+ history=response.history,
63
+ status=code,
64
+ message=data.get('msg', 'Unknown error')
65
+ )
54
66
 
55
67
  return data['url']
@@ -14,12 +14,25 @@ from aiohttp.client_exceptions import ClientPayloadError
14
14
 
15
15
  from kmdr.core.console import info, log, debug
16
16
  from kmdr.core.error import ResponseError
17
+ from kmdr.core.utils import async_retry
17
18
 
18
19
  from .misc import STATUS, StateManager
19
20
 
20
21
  BLOCK_SIZE_REDUCTION_FACTOR = 0.75
21
22
  MIN_BLOCK_SIZE = 2048
22
23
 
24
+ _HEAD_SEMAPHORE_VALUE = 3
25
+ _HEAD_SEMAPHORE: Optional[asyncio.Semaphore] = None
26
+ """定义的用于 HEAD 请求的信号量,限制并发数量以避免触发服务器限流。"""
27
+
28
+ def _get_head_request_semaphore() -> asyncio.Semaphore:
29
+ """惰性初始化 HEAD 请求信号量。"""
30
+ global _HEAD_SEMAPHORE
31
+
32
+ if _HEAD_SEMAPHORE is None:
33
+ _HEAD_SEMAPHORE = asyncio.Semaphore(_HEAD_SEMAPHORE_VALUE)
34
+ return _HEAD_SEMAPHORE
35
+
23
36
 
24
37
  @deprecated("本函数可能不会积极维护,请改用 'download_file_multipart'")
25
38
  async def download_file(
@@ -171,17 +184,10 @@ async def download_file_multipart(
171
184
  try:
172
185
  current_url = await fetch_url(url)
173
186
 
174
- async with semaphore:
187
+ async with _get_head_request_semaphore():
175
188
  # 获取文件信息,请求以获取文件大小
176
- # 复用 semaphore 以控制并发,避免过多并发请求触发服务器限流
177
- async with session.head(current_url, headers=headers, allow_redirects=True) as response:
178
- # 注意:这个请求完成后,服务器就会记录这次下载,并消耗对应的流量配额,详细的规则请参考网站说明:
179
- # 注 1 : 訂閱連載中的漫畫,有更新時自動推送的卷(冊),暫不計算在使用額度中,不扣減使用額度。
180
- # 注 2 : 對同一卷(冊)書在 12 小時內重複*下載*,不會重複扣減額度。但重復推送是會扣減的。
181
- response.raise_for_status()
182
- if 'Content-Length' not in response.headers:
183
- raise ResponseError("无法从服务器获取文件大小,请检查网络连接或稍后重试。", status_code=response.status)
184
- total_size = int(response.headers['Content-Length'])
189
+ # 控制并发,避免过多并发请求触发服务器限流
190
+ total_size = await _fetch_content_length(session, current_url, headers=headers)
185
191
 
186
192
  chunk_size = chunk_size_mb * 1024 * 1024
187
193
  num_chunks = math.ceil(total_size / chunk_size)
@@ -241,6 +247,41 @@ async def download_file_multipart(
241
247
  if task_id is not None and state_manager is not None:
242
248
  await state_manager.request_status_update(part_id=StateManager.PARENT_ID, status=STATUS.FAILED)
243
249
 
250
+ @async_retry()
251
+ async def _fetch_content_length(
252
+ session: aiohttp.ClientSession,
253
+ url: str,
254
+ headers: Optional[dict] = None,
255
+ ) -> int:
256
+ """
257
+ 获取文件的内容长度(字节数)
258
+
259
+ :note: 这个请求完成后,服务器就会记录这次下载,并消耗对应的流量配额,详细的规则请参考网站说明:
260
+ - 注 1 : 訂閱連載中的漫畫,有更新時自動推送的卷(冊),暫不計算在使用額度中,不扣減使用額度。
261
+ - 注 2 : 對同一卷(冊)書在 12 小時內重複*下載*,不會重複扣減額度。但重復推送是會扣減的。
262
+
263
+ :param session: aiohttp.ClientSession 对象
264
+ :param url: 文件 URL
265
+ :param headers: 请求头
266
+ :return: 文件大小(字节数)
267
+ """
268
+ if headers is None:
269
+ headers = {}
270
+
271
+ async with session.head(url, headers=headers, allow_redirects=True) as response:
272
+ response.raise_for_status()
273
+ if 'Content-Length' in response.headers:
274
+ return int(response.headers['Content-Length'])
275
+
276
+ async with session.get(url, headers=headers, allow_redirects=True) as response:
277
+ # 普通下载链接可能不支持 HEAD 请求,尝试使用 GET 请求获取文件大小
278
+ # see: https://github.com/chrisis58/kmoe-manga-downloader/issues/25
279
+ debug("HEAD 请求未返回 Content-Length,尝试使用 GET 请求获取文件大小")
280
+ response.raise_for_status()
281
+ if 'Content-Length' not in response.headers:
282
+ raise ResponseError("无法从服务器获取文件大小,请检查网络连接或稍后重试。", status_code=response.status)
283
+ return int(response.headers['Content-Length'])
284
+
244
285
  async def _download_part(
245
286
  session: aiohttp.ClientSession,
246
287
  semaphore: asyncio.Semaphore,
@@ -283,6 +324,7 @@ async def _download_part(
283
324
  if chunk:
284
325
  await f.write(chunk)
285
326
  state_manager.advance(len(chunk))
327
+ await state_manager.pop_part(part_id=start)
286
328
  log("分片", os.path.basename(part_path), "下载完成。")
287
329
  return
288
330
 
@@ -294,8 +336,8 @@ async def _download_part(
294
336
  except Exception as e:
295
337
  if attempts_left > 0:
296
338
  debug("分片", os.path.basename(part_path), "下载出错:", e, ",正在重试... 剩余重试次数:", attempts_left)
297
- await asyncio.sleep(3)
298
339
  await state_manager.request_status_update(part_id=start, status=STATUS.WAITING)
340
+ await asyncio.sleep(3)
299
341
  else:
300
342
  # console.print(f"[red]分片 {os.path.basename(part_path)} 下载失败: {e}[/red]")
301
343
  debug("分片", os.path.basename(part_path), "下载失败:", e)
@@ -355,19 +397,22 @@ def safe_filename(name: str) -> str:
355
397
  """
356
398
  return re.sub(r'[\\/:*?"<>|]', '_', name)
357
399
 
358
- async def fetch_url(url: Union[str, Callable[[], str], Callable[[], Awaitable[str]]], retry_times: int = 3) -> str:
359
- while retry_times >= 0:
360
- try:
361
- if callable(url):
362
- result = url()
363
- if asyncio.iscoroutine(result) or isinstance(result, Awaitable):
364
- return await result
365
- return result
366
- elif isinstance(url, str):
367
- return url
368
- except Exception as e:
369
- retry_times -= 1
370
- if retry_times < 0:
371
- raise e
372
- await asyncio.sleep(2)
373
- raise RuntimeError("Max retries exceeded")
400
+ async def fetch_url(url: Union[str, Callable[[], str], Callable[[], Awaitable[str]]]) -> str:
401
+ """
402
+ 获取下载链接的包装函数,支持直接传入字符串或异步/同步的 Supplier 函数。
403
+
404
+ :note: 不包含重试机制,调用方需自行处理。
405
+ :param url: 下载链接或其 Supplier
406
+ :return: 下载链接
407
+ """
408
+
409
+ if callable(url):
410
+ result = url()
411
+ if asyncio.iscoroutine(result) or isinstance(result, Awaitable):
412
+ # 如果 url() 是一个异步函数,等待它
413
+ return await result
414
+ # 如果 url() 是一个同步函数,直接返回
415
+ return result
416
+ elif isinstance(url, str):
417
+ # 如果 url 只是个字符串,直接返回
418
+ return url
@@ -3,6 +3,8 @@ import asyncio
3
3
 
4
4
  from rich.progress import Progress, TaskID
5
5
 
6
+ from kmdr.core.console import debug
7
+
6
8
 
7
9
  class STATUS(Enum):
8
10
  WAITING='[blue]等待中[/blue]'
@@ -56,9 +58,20 @@ class StateManager:
56
58
  highest_status = max(self._part_states.values())
57
59
  if highest_status != self._current_status:
58
60
  self._current_status = highest_status
59
- self._progress.update(self._task_id, status=highest_status.value, refresh=True)
61
+ self._progress.update(self._task_id, status=highest_status.value)
62
+
63
+ async def pop_part(self, part_id: int):
64
+ """
65
+ 下载完成后移除分片状态记录,不再参与状态计算
66
+
67
+ :note: 为避免状态闪烁,调用后不会更新状态
68
+ """
69
+ async with self._lock:
70
+ if part_id in self._part_states:
71
+ self._part_states.pop(part_id)
60
72
 
61
73
  async def request_status_update(self, part_id: int, status: STATUS):
62
74
  async with self._lock:
75
+ debug("分片", part_id, "请求状态更新为", status)
63
76
  self._part_states[part_id] = status
64
77
  self._update_status()
@@ -8,6 +8,7 @@ from kmdr.core import Lister, LISTERS, BookInfo, VolInfo
8
8
  from kmdr.core.utils import async_retry
9
9
  from kmdr.core.constants import API_ROUTE
10
10
  from kmdr.core.console import info
11
+ from kmdr.core.error import EmptyResultError
11
12
 
12
13
  from .utils import extract_book_info_and_volumes
13
14
 
@@ -24,8 +25,7 @@ class FollowedBookLister(Lister):
24
25
  books = await self._list_followed_books()
25
26
 
26
27
  if not books:
27
- info("[yellow]关注列表为空。[/yellow]")
28
- exit(0)
28
+ raise EmptyResultError("关注列表为空。")
29
29
 
30
30
  table = Table(title="关注的书籍列表", show_header=True, header_style="bold blue")
31
31
  table.add_column("序号", style="dim", width=4, justify="center")
@@ -8,6 +8,7 @@ from aiohttp import ClientSession as Session
8
8
  from kmdr.core import BookInfo, VolInfo, VolumeType
9
9
  from kmdr.core.utils import async_retry
10
10
  from kmdr.core.console import debug
11
+ from kmdr.core.error import KmdrError
11
12
 
12
13
  @async_retry()
13
14
  async def extract_book_info_and_volumes(session: Session, url: str, book_info: Optional[BookInfo] = None) -> tuple[BookInfo, list[VolInfo]]:
@@ -41,6 +42,12 @@ async def extract_book_info_and_volumes(session: Session, url: str, book_info: O
41
42
  def __extract_book_info(url: str, book_page: BeautifulSoup, book_info: Optional[BookInfo]) -> BookInfo:
42
43
  book_name = book_page.find('font', class_='text_bglight_big').text
43
44
 
45
+ if '為符合要求,此書內容已屏蔽' in book_name:
46
+ raise KmdrError(
47
+ "[yellow]该书籍内容已被屏蔽,请检查代理配置。[/yellow]",
48
+ solution=["kmdr config -s proxy=<your_proxy> # 设置可用的代理地址"]
49
+ )
50
+
44
51
  id = book_page.find('input', attrs={'name': 'bookid'})['value']
45
52
 
46
53
  return BookInfo(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kmoe-manga-downloader
3
- Version: 1.2.4b0
3
+ Version: 1.2.6
4
4
  Summary: A CLI-downloader for site @kox.moe.
5
5
  Author-email: Chris Zheng <chrisis58@outlook.com>
6
6
  License: MIT License
@@ -2,6 +2,8 @@
2
2
  LICENSE
3
3
  README.md
4
4
  pyproject.toml
5
+ .github/ISSUE_TEMPLATE/bug_report.yml
6
+ .github/ISSUE_TEMPLATE/feature_request.yml
5
7
  .github/workflows/release-package.yml
6
8
  .github/workflows/unit-test.yml
7
9
  assets/kmdr-demo.gif
@@ -129,6 +129,55 @@ class TestKmdrDownload(unittest.TestCase):
129
129
  os.path.getsize(os.path.join(dest, book_dir, f)) for f in os.listdir(os.path.join(dest, book_dir)) if os.path.isfile(os.path.join(dest, book_dir, f))
130
130
  )
131
131
  assert total_size < 1 * 0.6 * 1024 * 1024, "Total size of downloaded files exceeds 0.6 MB"
132
+
133
+ def test_download_single_volume_use_vip(self):
134
+ dest = f'{BASE_DIR}/{self.test_download_single_volume_use_vip.__name__}'
135
+
136
+ kmdr_main(
137
+ Namespace(
138
+ command='download',
139
+ dest=dest,
140
+ book_url=f'{DEFAULT_BASE_URL}/c/51044.htm',
141
+ vol_type='extra',
142
+ volume='all',
143
+ max_size=0.6,
144
+ limit=1,
145
+ retry=3,
146
+ vip=True
147
+ )
148
+ )
149
+
150
+ assert len(sub_dir := os.listdir(dest)) == 1, "Expected one subdirectory in the destination"
151
+ assert os.path.isdir(os.path.join(dest, book_dir := sub_dir[0])), "Expected the subdirectory to be a directory"
152
+ assert len(os.listdir(os.path.join(dest, book_dir))) == 1, "Expected 1 volume to be downloaded"
153
+
154
+ total_size = sum(
155
+ os.path.getsize(os.path.join(dest, book_dir, f)) for f in os.listdir(os.path.join(dest, book_dir)) if os.path.isfile(os.path.join(dest, book_dir, f))
156
+ )
157
+ assert total_size < 1 * 0.6 * 1024 * 1024, "Total size of downloaded files exceeds 0.6 MB"
158
+
159
+ def test_download_volume_with_direct_downloader_and_use_vip(self):
160
+ dest = f'{BASE_DIR}/{self.test_download_volume_with_direct_downloader_and_use_vip.__name__}'
161
+
162
+ kmdr_main(
163
+ Namespace(
164
+ command='download',
165
+ dest=dest,
166
+ book_url=f'{DEFAULT_BASE_URL}/c/51043.htm',
167
+ vol_type='extra',
168
+ volume='all',
169
+ max_size=0.4,
170
+ method=2, # use direct download method
171
+ limit=1,
172
+ retry=3,
173
+ num_workers=1,
174
+ vip=True
175
+ )
176
+ )
177
+
178
+ assert len(sub_dir := os.listdir(dest)) == 1, "Expected one subdirectory in the destination"
179
+ assert os.path.isdir(os.path.join(dest, book_dir := sub_dir[0])), "Expected the subdirectory to be a directory"
180
+ assert len(os.listdir(os.path.join(dest, book_dir))) == 1, "Expected 1 volume to be downloaded"
132
181
 
133
182
  def test_download_multiple_volumes_with_multiple_workers(self):
134
183
  dest = f'{BASE_DIR}/{self.test_download_multiple_volumes_with_multiple_workers.__name__}'
@@ -175,7 +224,7 @@ class TestKmdrDownload(unittest.TestCase):
175
224
 
176
225
  assert len(files := os.listdir(dest)) == 2, "Expected one subdirectory and one callback log file in the destination"
177
226
  assert 'callback.log' in files, "Expected callback log file to be present"
178
- with open(os.path.join(dest, 'callback.log'), 'r', encoding='utf-8') as f:
227
+ with open(os.path.join(dest, 'callback.log'), 'r') as f:
179
228
  log_content = f.read()
180
229
  assert "CALLBACK:" in log_content, "Expected callback log to contain the correct message"
181
230
  files.remove('callback.log')