Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,34 @@

条目分类参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/),
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。

## [2.7.9] - 2026-10-13

### Summary

本次更新支持基于 `DownloadControl` 的协作式取消下载与多层级共享 Runtime 调度,重构批量下载结果为保序检索集合,降低默认图片并发配置,并优化多任务进度界面。

### Added

- 新增 `JmTempRuntime`、`JmSyncRuntime` 与 `JmAsyncRuntime`;裸同步 Downloader 的局部调度使用临时单池 Runtime,顶层同步 API 或自定义同步调度可复用 `album/photo/image` 三层线程池,异步下载可复用 `decode` 线程池。
- 新增 `DownloadControl` 和 `DownloadCancelledException`,支持通过任务上下文协作式取消同步与异步下载;`DownloadCancelledException` 支持通过 `completed_results` 与 `unfinished_ids` 读取批量取消时的已完成成果与未完成 ID。
- `BatchResult` 重构为独立类,保持输入 FIFO 原序并支持车号检索与下标访问,不再继承 `set`。
- `DownloadResult` 新增 `album` 和 `photo` 快捷实体访问属性。
- 新增 `JTC` (Jm Task Context) 门面类,统一提供上下文、Runtime、Option 及取消控制器的快捷读取方法。
- `jmcomic --dry-run` 使用随机模拟任务预览多任务进度和重试日志,不请求网络、不下载图片、不写日志文件,也不加载 `--option` 配置;不能与 `--no-progress` 同用。

### Changed

- 默认图片层下载并发配置 `download.threading.image` 从 30 调整为 20。
- 下载调度统一使用标准库 Executor;顶层 API 和裸同步 Downloader 的临时调度显式关闭自己创建的 Runtime,`jm_task_context` 只传播字段;外部 Runtime 和 Executor 仍由调用方关闭。
- 顶层下载会把 Runtime 和 Option 作为公开字段直接放入任务上下文;Runtime 不依赖 Context 或 Option,未配置的层级由实际调用点传入默认 worker 数。

### Fixed

- 同步和异步下载器共用一个动态进度界面与日志区,已结束任务保留到最后一个活动下载器退出,再统一关闭界面。
- 界面启动或渲染失败时清理注册状态;界面退出失败时仍执行下载器资源释放,避免影响后续下载。


## [2.7.8] - 2026-10-07

### Summary
Expand Down
1 change: 1 addition & 0 deletions assets/docs/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ nav:
- tutorial/13_export_and_feature.md
- tutorial/14_async_usage.md
- tutorial/15_download_progress.md
- tutorial/16_shared_executors.md

plugins:
- search
Expand Down
34 changes: 34 additions & 0 deletions assets/docs/sources/api/download.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,37 @@
options:
members:
- JmAsyncDownloader

::: jmcomic.jm_downloader
options:
members:
- BaseDownloader
- JmDownloader

::: jmcomic.jm_runtime
options:
members:
- JmRuntime
- JmTempRuntime
- JmSyncRuntime
- JmAsyncRuntime

::: jmcomic.jm_exception
options:
members:
- DownloadCancelledException

::: jmcomic.jm_task_context
options:
members:
- DownloadControl
- jm_task_context
- bind_jm_task_context

::: jmcomic.jm_task_context.JTC
options:
members:
- get_runtime
- get_option
- get_control
- get_context
109 changes: 102 additions & 7 deletions assets/docs/sources/tutorial/0_common_usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -534,11 +534,103 @@ cl = JmApiClient(
```


## 取消下载

> 如果你想中途取消下载,最简单粗暴的方式是直接杀死进程,比如 `ctrl+c`,关闭终端窗口 等
>
> 但是在 GUI、Web服务这类场景里,就不适合这么关闭。
>
> 于是,jmcomic 提供了一种优雅停止的写法,可以使用 `DownloadControl` 这个类。
>
> 这种写法更加可控:
>
> - **不影响主程序**:只停当前下载,GUI 界面或 Web 服务依然正常运作。
> - **多任务统一取消**:多个本子一起下载时,可以被统一取消。
> - **可携带取消理由**:调用 `cancel()` 传入的原因可以直接捕获,方便做提示和日志。
写法就两步:

1. 创建 DownloadControl 对象,传入任务上下文
2. 调用 DownloadControl 类的 cancel() 方法

```python
from threading import Thread
from time import sleep
from jmcomic import DownloadCancelledException, DownloadControl, download_album, jm_task_context

# 创建取消控制器
my_control = DownloadControl()

def run_download():
try:
# 使用 with jm_task_context 创建任务上下文,并传入 control 参数
with jm_task_context(control=my_control):
# 支持传入单个本子或多个本子列表;多本子时中途取消会统一停止
download_album(['123456', '789012', '345678'])
except DownloadCancelledException as e:
print(f'下载已取消: {e.reason}')
# 可直接从异常对象读取已完成成果与未完成的 ID
print(f'已完成本子: {[res.album.id for res in e.completed_results]}')
print(f'未完成本子: {e.unfinished_ids}')

# 使用单独的下载线程执行下载
# 主线程负责取消
t = Thread(target=run_download)
t.start()

# 模拟一段时间后,需要取消下载
sleep(2)
# 可以传入取消原因,下载线程可通过上面的 e.reason 获取
my_control.cancel("不想要了,取消掉吧")
# 等待下载线程结束
t.join()
```

### 进阶:连用取消控制、共享线程池与任务上下文

若需同时**控制取消**、**限制并发(共享线程池)**与**绑定任务ID(日志追踪)**,使用 Python 原生复合 `with` 语法即可:

```python
from threading import Thread
from time import sleep
from jmcomic import (
download_album,
DownloadCancelledException,
DownloadControl,
JmSyncRuntime,
jm_task_context,
)

# 1. 创建取消控制器
control = DownloadControl()

def run_download():
try:
# 2. 复合使用三项功能:
# - JmSyncRuntime: 管理并发线程池,退出 with 作用域自动关闭并回收所有线程
# - jm_task_context: 绑定取消控制器 control 与业务追踪字段 task_id 到当前调用栈
with JmSyncRuntime(image_workers=10) as rt, jm_task_context(control=control, task_id='task_001'):
download_album(['123456', '789012'])
except DownloadCancelledException as e:
print(f'下载已取消: {e.reason}')
print(f'已完成本子: {[res.album.id for res in e.completed_results]}')
print(f'未完成本子: {e.unfinished_ids}')

# 在子线程中执行下载任务
t = Thread(target=run_download)
t.start()

# 模拟 2 秒后外部触发取消
sleep(2)
control.cancel("用户主动取消下载")
t.join()
```

## 下载返回值

`download_album` 和 `download_photo` 下载完成后,单个 ID 返回 `DownloadResult`,多个 ID 返回 `BatchResult`。

从 `result.detail` 可以取得下载的本子/章节的实体类:
从 `DownloadResult` 的 `detail` 字段可以取得下载的本子/章节的实体类:

```python
from jmcomic import download_album, download_photo
Expand Down Expand Up @@ -638,24 +730,26 @@ print('长图导出文件:', png_filepath_list)

### 批量下载的返回值

传入多个 ID 时,返回值是 `BatchResult`。每一项成功下载对应一个 `DownloadResult`,失败任务则记录在 `failed` 中:
传入多个 ID 时,返回值是 `BatchResult`,通过这个对象可以取得成功和失败的下载结果:

<details markdown="1">
<summary>完整示例:处理批量下载结果</summary>

```python
from jmcomic import download_album
from jmcomic import download_album, DownloadResult

# 同时下载多个本子
batch_result = download_album(['123', '456', '789'])

# BatchResult 继承 set,成功结果没有输入顺序保证
# BatchResult 按输入顺序保存成功结果,直接遍历只遍历成功结果;也支持 batch_result['123'] 按车号检索
for result in batch_result:
result: DownloadResult
# result 的用法同上
album = result.detail
# 通过实体 ID 识别当前结果,不要用遍历位置对应输入列表
print(f'JM{album.id} 下载到: {album.save_path}')

# failed 的键是下载失败的 ID,值是记录失败原因的异常对象
# failed 负责存放失败的下载结果,类型是dict
# key是下载失败的 ID,value是记录失败原因的异常对象
for album_id, error in batch_result.failed.items():
print(f'JM{album_id} 下载失败: {error}')

Expand All @@ -666,7 +760,8 @@ print('是否全部成功:', batch_result.all_succeeded)

</details>

下载单个 ID 时,请求本子失败会直接抛出异常;如果只有部分章节或图片失败,会在任务结束后汇总抛出 `PartialDownloadFailedException`,此时不会返回 `DownloadResult`。批量下载则继续执行其他任务,并把失败项集中放进 `batch_result.failed`。
---


### 速查表

Expand Down
76 changes: 59 additions & 17 deletions assets/docs/sources/tutorial/14_async_usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,9 +260,9 @@ client:
async_impl: async_api
```
## 10. 查看下载耗时
## 10. 下载返回值
异步下载完成后,可以直接查看自己总共等了多久,也可以继续查看具体是哪个本子、章节或图片比较慢。所有 `duration` 的单位都是秒。
用法与同步版本完全一致,返回的 `DownloadResult` 包含了本子实体、保存路径、耗时以及下载清单(`manifest`)等专属字段:

```python
import asyncio
Expand All @@ -273,25 +273,67 @@ async def main():
result = await jmcomic.download_album_async('438696')
album = result.detail
# 从调用 download_album_async 到返回,总共等了多久
print(f'总共等待: {result.duration:.3f} 秒')
# 1. 实体与保存路径
print(f'本子: JM{album.id} - {album.title}')
print(f'保存目录: {album.save_path}')
# 如果下载比较慢,可以继续查看具体慢在哪里
print(f'下载本子用了: {album.duration:.3f} 秒')
for photo in album:
print(f'下载章节 {photo.id} 用了: {photo.duration:.3f} 秒')
for image in photo:
print(f'处理图片 {image.img_file_name} 用了: {image.duration:.3f} 秒')
# 2. 耗时统计(单位:秒)
print(f'总耗时: {result.duration:.3f} 秒, 本子处理耗时: {album.duration:.3f} 秒')
# 3. 下载清单 manifest
manifest = result.manifest
print(f'全部本子图片的磁盘路径: {manifest.image_filepath_list}')
asyncio.run(main())
```

| 字段 | 它告诉你什么 |
| --- | --- |
| `result.duration` | 从调用异步下载方法到返回,你总共等了多久 |
| `album.duration` | 下载这个本子花了多久,包含获取本子信息和整理下载结果 |
| `photo.duration` | 下载这个章节花了多久,包含获取或补全章节信息 |
| `image.duration` | 处理这张图片花了多久,包含检查缓存、下载、解密和保存 |
## 11. 取消下载

下面示例代码模拟了一个 GUI 场景中,有两个按钮,一个开始下载,一个取消下载,基于 `DownloadControl` 实现。

```python
import asyncio
from jmcomic import DownloadCancelledException, DownloadControl, download_album_async, jm_task_context
# 界面持有的当前下载控制器与任务
current_control = None
current_task = None
# 按钮 1:点击【开始下载】
def on_btn_download_click(album_id: str) -> asyncio.Task:
global current_control, current_task
current_control = DownloadControl()
async def _run():
try:
with jm_task_context(control=current_control):
await download_album_async(album_id)
except DownloadCancelledException as e:
print(f'[UI 提示] 下载已取消: {e.reason}')
current_task = asyncio.create_task(_run())
return current_task
下载器可能同时处理多个章节或多张图片,所以把它们的耗时全部相加,不会得到本子的耗时,这是正常现象。同步下载中的这些字段含义相同。
# 按钮 2:点击【取消下载】
def on_btn_cancel_click():
if current_control:
current_control.cancel("用户点击了界面的取消按钮")
async def main():
# 模拟用户点击【开始下载】
task = on_btn_download_click('123456')
# 模拟用户在界面等待一段时间后,点击了【取消下载】
await asyncio.sleep(2)
on_btn_cancel_click()
# 等待下载任务响应取消并安全收尾
await task
asyncio.run(main())
```
2 changes: 2 additions & 0 deletions assets/docs/sources/tutorial/15_download_progress.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@
> - 不支持:启动时显示一次说明,下载期间不刷新,结束后输出一次本子、章节和图片数量汇总。
>
> 如果你使用 PyCharm,请在底部的 **Terminal** 中运行 `python script.py`,即可查看动态进度。
>
> 你也可以使用 `--dry-run` 参数来模拟运行,体验效果,不会真的触发下载:`jmcomic 123 --dry-run`

| 运行位置 | 下载期间 | 下载结束后 |
Expand Down
Loading
Loading