kmoe-manga-downloader 1.2.4b0__tar.gz → 1.2.5__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.
- kmoe_manga_downloader-1.2.5/.github/ISSUE_TEMPLATE/bug_report.yml +81 -0
- kmoe_manga_downloader-1.2.5/.github/ISSUE_TEMPLATE/feature_request.yml +55 -0
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/.github/workflows/unit-test.yml +3 -0
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/PKG-INFO +1 -1
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmdr/_version.py +3 -3
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmdr/core/bases.py +6 -7
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmdr/core/console.py +8 -4
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmdr/core/context.py +1 -1
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmdr/core/defaults.py +5 -8
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmdr/core/error.py +15 -0
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmdr/core/protocol.py +4 -0
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmdr/core/registry.py +2 -0
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmdr/core/session.py +26 -3
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmdr/core/utils.py +24 -2
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmdr/main.py +2 -6
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmdr/module/configurer/BaseUrlUpdator.py +1 -1
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmdr/module/configurer/ConfigClearer.py +2 -2
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmdr/module/configurer/option_validate.py +7 -5
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmdr/module/downloader/DirectDownloader.py +3 -2
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmdr/module/downloader/ReferViaDownloader.py +5 -3
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmdr/module/downloader/download_utils.py +72 -27
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmdr/module/downloader/misc.py +14 -1
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmdr/module/lister/FollowedBookLister.py +2 -2
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmdr/module/lister/utils.py +7 -0
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmoe_manga_downloader.egg-info/PKG-INFO +1 -1
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmoe_manga_downloader.egg-info/SOURCES.txt +2 -0
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/tests/test_kmdr_download.py +50 -1
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/.github/workflows/release-package.yml +0 -0
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/.gitignore +0 -0
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/LICENSE +0 -0
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/README.md +0 -0
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/assets/kmdr-demo.gif +0 -0
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/assets/kmdr-log-demo.gif +0 -0
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/mirror/mirrors.json +0 -0
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/pyproject.toml +0 -0
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/setup.cfg +0 -0
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmdr/__init__.py +0 -0
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmdr/core/__init__.py +0 -0
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmdr/core/constants.py +0 -0
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmdr/core/structure.py +0 -0
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmdr/module/__init__.py +0 -0
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmdr/module/authenticator/CookieAuthenticator.py +0 -0
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmdr/module/authenticator/LoginAuthenticator.py +0 -0
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmdr/module/authenticator/__init__.py +0 -0
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmdr/module/authenticator/utils.py +0 -0
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmdr/module/configurer/ConfigUnsetter.py +0 -0
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmdr/module/configurer/OptionLister.py +0 -0
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmdr/module/configurer/OptionSetter.py +0 -0
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmdr/module/configurer/__init__.py +0 -0
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmdr/module/downloader/__init__.py +0 -0
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmdr/module/lister/BookUrlLister.py +0 -0
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmdr/module/lister/__init__.py +0 -0
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmdr/module/picker/ArgsFilterPicker.py +0 -0
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmdr/module/picker/DefaultVolPicker.py +0 -0
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmdr/module/picker/__init__.py +0 -0
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmdr/module/picker/utils.py +0 -0
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmoe_manga_downloader.egg-info/dependency_links.txt +0 -0
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmoe_manga_downloader.egg-info/entry_points.txt +0 -0
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmoe_manga_downloader.egg-info/requires.txt +0 -0
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmoe_manga_downloader.egg-info/top_level.txt +0 -0
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/tests/__init__.py +0 -0
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/tests/test_async_retry_decorator.py +0 -0
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/tests/test_kmdr_config_option.py +0 -0
- {kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/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
|
|
@@ -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.
|
|
32
|
-
__version_tuple__ = version_tuple = (1, 2,
|
|
31
|
+
__version__ = version = '1.2.5'
|
|
32
|
+
__version_tuple__ = version_tuple = (1, 2, 5)
|
|
33
33
|
|
|
34
|
-
__commit_id__ = commit_id = '
|
|
34
|
+
__commit_id__ = commit_id = 'gd3b720692'
|
|
@@ -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
|
-
|
|
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
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
251
|
-
_verbose
|
|
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)
|
|
@@ -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
|
-
|
|
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,
|
|
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
|
|
|
@@ -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,
|
|
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
|
|
30
|
+
:param raise_if_invalid: 如果键名无效,是否抛出异常
|
|
30
31
|
"""
|
|
31
32
|
if key not in __OPTIONS_VALIDATOR:
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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 not self._use_vip else 0
|
|
37
38
|
)
|
|
@@ -6,6 +6,7 @@ from async_lru import alru_cache
|
|
|
6
6
|
from kmdr.core import Downloader, VolInfo, DOWNLOADER, BookInfo
|
|
7
7
|
from kmdr.core.constants import API_ROUTE
|
|
8
8
|
from kmdr.core.error import ResponseError
|
|
9
|
+
from kmdr.core.utils import async_retry
|
|
9
10
|
from kmdr.core.console import debug
|
|
10
11
|
|
|
11
12
|
from .download_utils import download_file_multipart, readable_safe_filename
|
|
@@ -13,9 +14,9 @@ from .download_utils import download_file_multipart, readable_safe_filename
|
|
|
13
14
|
|
|
14
15
|
@DOWNLOADER.register(order=10)
|
|
15
16
|
class ReferViaDownloader(Downloader):
|
|
16
|
-
def __init__(self, dest='.', callback=None, retry=3, num_workers=8, proxy=None, *args, **kwargs):
|
|
17
|
+
def __init__(self, dest='.', callback=None, retry=3, num_workers=8, proxy=None, vip=False, *args, **kwargs):
|
|
17
18
|
super().__init__(dest, callback, retry, num_workers, proxy, *args, **kwargs)
|
|
18
|
-
|
|
19
|
+
self._use_vip = vip
|
|
19
20
|
|
|
20
21
|
async def _download(self, book: BookInfo, volume: VolInfo):
|
|
21
22
|
sub_dir = readable_safe_filename(book.name)
|
|
@@ -36,13 +37,14 @@ class ReferViaDownloader(Downloader):
|
|
|
36
37
|
)
|
|
37
38
|
|
|
38
39
|
@alru_cache(maxsize=128)
|
|
40
|
+
@async_retry()
|
|
39
41
|
async def fetch_download_url(self, book_id: str, volume_id: str) -> str:
|
|
40
42
|
|
|
41
43
|
async with self._session.get(
|
|
42
44
|
API_ROUTE.GETDOWNURL.format(
|
|
43
45
|
book_id=book_id,
|
|
44
46
|
volume_id=volume_id,
|
|
45
|
-
is_vip=self._profile.is_vip
|
|
47
|
+
is_vip=self._profile.is_vip if not self._use_vip else 0
|
|
46
48
|
)
|
|
47
49
|
) as response:
|
|
48
50
|
response.raise_for_status()
|
|
@@ -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
|
|
187
|
+
async with _get_head_request_semaphore():
|
|
175
188
|
# 获取文件信息,请求以获取文件大小
|
|
176
|
-
#
|
|
177
|
-
|
|
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]]]
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
{kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmdr/module/downloader/misc.py
RENAMED
|
@@ -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
|
|
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
|
-
|
|
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")
|
{kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmdr/module/lister/utils.py
RENAMED
|
@@ -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(
|
|
@@ -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'
|
|
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')
|
{kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/.github/workflows/release-package.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmdr/module/authenticator/utils.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmdr/module/configurer/__init__.py
RENAMED
|
File without changes
|
{kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmdr/module/downloader/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmdr/module/lister/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmdr/module/picker/__init__.py
RENAMED
|
File without changes
|
{kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/src/kmdr/module/picker/utils.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/tests/test_async_retry_decorator.py
RENAMED
|
File without changes
|
{kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/tests/test_kmdr_config_option.py
RENAMED
|
File without changes
|
{kmoe_manga_downloader-1.2.4b0 → kmoe_manga_downloader-1.2.5}/tests/test_utils_resolve_volme.py
RENAMED
|
File without changes
|