Skip to content

Commit 0fe34d2

Browse files
authored
Merge pull request #368 from Ljzd-PRO/devel
Bump to v0.23.0
2 parents b42c090 + 64ee051 commit 0fe34d2

File tree

10 files changed

+534
-325
lines changed

10 files changed

+534
-325
lines changed

.github/workflows/codecov.yml

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,25 @@ jobs:
3232
cancel-in-progress: true
3333
strategy:
3434
fail-fast: false
35+
# macos-14 aka. macos-latest has switched to being an ARM runner, only supporting newer versions of Python.
36+
# https://github.com/actions/setup-python/issues/855#issuecomment-2096792205
3537
matrix:
36-
# macos-14 aka. macos-latest has switched to being an ARM runner, only supporting newer versions of Python.
37-
# https://github.com/actions/setup-python/issues/855#issuecomment-2096792205
38-
os: [ ubuntu-latest, windows-latest, macos-13 ]
39-
python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12", "3.13" ]
38+
os: [ ubuntu-latest, windows-latest, macos-15 ]
39+
python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14" ]
40+
exclude:
41+
- os: macos-15
42+
python-version: "3.8"
43+
- os: macos-15
44+
python-version: "3.9"
45+
- os: macos-15
46+
python-version: "3.10"
47+
include:
48+
- os: macos-15-intel
49+
python-version: "3.8"
50+
- os: macos-15-intel
51+
python-version: "3.9"
52+
- os: macos-15-intel
53+
python-version: "3.10"
4054
env:
4155
PYTEST_REPORT_FILENAME: report-${{ matrix.os }}-${{ matrix.python-version }}.html
4256

.github/workflows/python-package.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ jobs:
4242
- os: macos-latest
4343
arch: arm64
4444
python-arch: "x64"
45-
- os: macos-13
45+
- os: macos-15
4646
arch: x64
4747
python-arch: "x64"
4848

@@ -70,7 +70,7 @@ jobs:
7070
if: matrix.qemu != true
7171
uses: ./.github/actions/setup-python
7272
with:
73-
python-version: "3.13"
73+
python-version: "3.14"
7474
architecture: ${{ matrix.python-arch }}
7575

7676
- name: Create Executable (Native)

CHANGELOG.md

Lines changed: 12 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,86 +1,30 @@
11
## Changes
22

3-
![Downloads](https://img.shields.io/github/downloads/Ljzd-PRO/KToolBox/v0.23.0/total)
3+
![Downloads](https://img.shields.io/github/downloads/Ljzd-PRO/KToolBox/v0.24.0/total)
44

55
### ✨ Features
66

7-
- Added configuration options to support **filtering** downloads by **file size** - #343
8-
- You can set the minimum and maximum file size (in bytes) via `job.min_file_size` and `job.max_file_size`
9-
- Both options can be set together to define a file size range
10-
- Configure these options using the graphical config editor, or set them in the dotenv file `.env` or via system environment variables:
11-
```dotenv
12-
# Skip files smaller than 1 MB (to avoid downloading thumbnails)
13-
KTOOLBOX_JOB__MIN_FILE_SIZE=1048576
14-
15-
# Skip files larger than 50 MB (to save disk space)
16-
KTOOLBOX_JOB__MAX_FILE_SIZE=52428800
17-
```
18-
- 📖 More info: [Configuration-Reference-JobConfiguration](https://ktoolbox.readthedocs.io/latest/configuration/reference/#ktoolbox.configuration.JobConfiguration)
19-
- Improved progress bar output - #345
20-
- Fixed the issue of the download file progress bar **constantly reordering**
21-
- Added **visual overall progress bar**
22-
- Added display of **total download speed**
23-
- Enhanced the **color rendering** of the progress bar
24-
```
25-
🔄 [==>---------------------------] 9% | Jobs: 173/1870 | 3 running | 1694 waiting | 5.7MB/s
26-
27-
⠹ 0bh1EKTGt5Zg9nNaDAi25P... |███████████████████████████░░░| 3.7MB/4.0MB 92.5% ⚡ 1.9MB/s
28-
⠹ YV30J8ftUbE9dUkkJVCqvN... |███░░░░░░░░░░░░░░░░░░░░░░░░░░░| 527.0KB/4.1MB 12.5% ⚡ 1.9MB/s
29-
⠹ KvKMSpwB4rRknTPKhEiXle... |░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░| 95.0KB/3.8MB 2.5% ⚡ 1.9MB/s
30-
```
7+
- Track and **log files that already exist** during download progress, and add tests to cover file-exists handling
8+
- Throttle the progress display to **0.1s** and only refresh when content changes to reduce flicker and CPU usage
9+
- Add **Python 3.14** support and update CI/workflows to test against 3.14
3110

3211
### 🪲 Fixes
3312

34-
- **Increased** the default **tps limit** (maximum number of connections established per second)
35-
- This setting is optional. To **improve download efficiency** in general cases, the default value has been increased from `1.0` to `5.0`
36-
- If you frequently encounter **403** errors during downloads, try setting this value lower, such as `1.0`
37-
- Run `ktoolbox config-editor` to edit this setting (`Downloader -> tps_limit`)
38-
- Or manually edit the `KTOOLBOX_DOWNLOADER__TPS_LIMIT` in the `.env` file or set it via environment variables
39-
```dotenv
40-
KTOOLBOX_DOWNLOADER__TPS_LIMIT=1.0
41-
```
42-
- 📖 More info: [Configuration-Reference-DownloaderConfiguration](https://ktoolbox.readthedocs.io/latest/configuration/reference/#ktoolbox.configuration.DownloaderConfiguration)
13+
- Ensure that the program **can terminate immediately** when all downloads are complete, rather than having to wait for a period of time before ending.
14+
- Prevent **overcounting completed jobs** when multiple files already exist
4315

4416
- - -
4517

4618
### ✨ 新特性
4719

48-
- 增加配置项以支持**按文件大小过滤**下载 - #343
49-
- 通过配置 `job.min_file_size` 和 `job.max_file_size` 来设置最小和最大文件大小(单位:字节)
50-
- 你可以同时设置这两个选项来定义一个文件大小范围
51-
- 通过图形化配置编辑器或在 dotenv 文件 `.env` 或系统环境变量中设置这些配置:
52-
```dotenv
53-
# 跳过小于 1 MB 的文件 (避免下载到缩略图)
54-
KTOOLBOX_JOB__MIN_FILE_SIZE=1048576
55-
56-
# 跳过大于 50 MB 的文件 (节省磁盘空间)
57-
KTOOLBOX_JOB__MAX_FILE_SIZE=52428800
58-
```
59-
- 📖 更多信息:[Configuration-Reference-JobConfiguration](https://ktoolbox.readthedocs.io/latest/zh/configuration/reference/#ktoolbox._configuration_zh.JobConfiguration)
60-
- 改进进度条输出 - #345
61-
- 修复了下载文件进度条**不断重新排序**的问题
62-
- 增加了**可视化的总进度条**
63-
- 增加了下载**总速度**显示
64-
- 增加了进度条的**颜色渲染**
65-
```
66-
🔄 [==>---------------------------] 9% | Jobs: 173/1870 | 3 running | 1694 waiting | 5.7MB/s
67-
68-
⠹ 0bh1EKTGt5Zg9nNaDAi25P... |███████████████████████████░░░| 3.7MB/4.0MB 92.5% ⚡ 1.9MB/s
69-
⠹ YV30J8ftUbE9dUkkJVCqvN... |███░░░░░░░░░░░░░░░░░░░░░░░░░░░| 527.0KB/4.1MB 12.5% ⚡ 1.9MB/s
70-
⠹ KvKMSpwB4rRknTPKhEiXle... |░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░| 95.0KB/3.8MB 2.5% ⚡ 1.9MB/s
71-
```
20+
- 在下载进度中**记录已存在的文件**并在进度中反映,添加了针对文件已存在处理的测试
21+
- 将进度刷新节流为**0.1秒**且仅在内容变更时刷新,以减少抖动和 CPU 使用
22+
- 添加 **Python 3.14** 支持,并在 CI/workflow 中加入对 3.14 的测试
7223

7324
### 🪲 修复
7425

75-
- **提高**了默认的 **tps limit** (每秒最多建立的连接数)
76-
- 这项配置是可选的,为了**提高一般情况下的下载效率**,现在的默认值从 `1.0` 提升到了 `5.0`
77-
- 当下载频繁出现 **403** 错误时,可尝试将此设置改为较低值,如 `1.0`
78-
- 执行 `ktoolbox config-editor` 来编辑这项配置 (`Downloader -> tps_limit`)
79-
- 或手动编辑 `.env` 文件中的 `KTOOLBOX_DOWNLOADER__TPS_LIMIT` 或环境变量来设置这项配置
80-
```dotenv
81-
KTOOLBOX_DOWNLOADER__TPS_LIMIT=1.0
82-
```
83-
- 📖更多信息:[Configuration-Reference-DownloaderConfiguration](https://ktoolbox.readthedocs.io/latest/zh/configuration/reference/#ktoolbox._configuration_zh.DownloaderConfiguration)
26+
- 确保下载全部完成时程序**能即刻结束**,而不是要等待一段时间才能结束
27+
- 修复当多个文件已存在时**完成计数被重复计算**的问题
8428

8529
## Upgrade
8630

@@ -89,4 +33,4 @@ Use this command to upgrade if you are using **pipx**:
8933
pipx upgrade ktoolbox
9034
```
9135

92-
**Full Changelog**: https://github.com/Ljzd-PRO/KToolBox/compare/v0.22.0...v0.23.0
36+
**Full Changelog**: https://github.com/Ljzd-PRO/KToolBox/compare/v0.23.0...v0.24.0

SECURITY.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44

55
| Version | Supported |
66
|---------|--------------------|
7-
| 3.14.x | :x: |
7+
| 3.15.x | :x: |
8+
| 3.14.x | :white_check_mark: |
89
| 3.13.x | :white_check_mark: |
910
| 3.12.x | :white_check_mark: |
1011
| 3.11.x | :white_check_mark: |

ktoolbox/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
__title__ = "KToolBox"
22
# noinspection SpellCheckingInspection
33
__description__ = "A useful CLI tool for downloading posts in Kemono.cr / .su / .party"
4-
__version__ = "v0.23.0"
4+
__version__ = "v0.24.0"

ktoolbox/job/runner.py

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -139,11 +139,19 @@ async def processor(self) -> int:
139139
if not exception: # raise Exception when cancelled or other exceptions
140140
ret = task_done.result()
141141
if ret.code == RetCodeEnum.FileExisted:
142-
logger.info(ret.message)
143-
# Treat file existed as successful download
142+
logger.debug(ret.message)
143+
# Treat file existed as successful download but mark as existed
144144
if self._progress_manager:
145+
# Increment existed count atomically and update completed based on current done_size
146+
try:
147+
self._progress_manager.increment_existed(1)
148+
except AttributeError:
149+
# Fallback if older ProgressManager doesn't have increment_existed
150+
self._progress_manager.update_job_progress(
151+
existed=self._progress_manager._existed_jobs + 1
152+
)
145153
self._progress_manager.update_job_progress(
146-
completed=self.done_size + 1
154+
completed=self.done_size
147155
)
148156
elif ret.code != RetCodeEnum.Success:
149157
logger.error(ret.message)
@@ -157,7 +165,7 @@ async def processor(self) -> int:
157165
# Update progress manager with completed job
158166
if self._progress_manager:
159167
self._progress_manager.update_job_progress(
160-
completed=self.done_size + 1
168+
completed=self.done_size
161169
)
162170
elif isinstance(exception, CancelledError):
163171
logger.warning(
@@ -188,12 +196,21 @@ async def _watch_status(self):
188196
"""
189197
Watch running, completed, failed jobs
190198
"""
191-
while not self._job_queue.empty():
192-
await asyncio.sleep(30)
193-
logger.info(f"Waiting: {self.waiting_size} / "
194-
f"Running: {self.processing_size} / "
195-
f"Completed: {self.done_size} "
196-
f"({(self.done_size / (self.waiting_size + self.processing_size + self.done_size)) * 100:.2f}%)")
199+
try:
200+
while not self._job_queue.empty():
201+
await asyncio.sleep(30)
202+
existed = self._progress_manager._existed_jobs if self._progress_manager else 0
203+
total = (self.waiting_size + self.processing_size + self.done_size)
204+
percent = (self.done_size / total) * 100 if total > 0 else 0
205+
logger.info(
206+
f"Waiting: {self.waiting_size} / "
207+
f"Running: {self.processing_size} / "
208+
f"Completed: {self.done_size} "
209+
f"({percent:.2f}%) | Existed: {existed}"
210+
)
211+
except asyncio.CancelledError:
212+
# Exit promptly when cancelled to allow fast shutdown
213+
return
197214

198215
async def start(self) -> int:
199216
"""
@@ -224,10 +241,21 @@ async def start(self) -> int:
224241
if self._progress_manager:
225242
display_task = asyncio.create_task(self._update_display_loop())
226243

227-
_, (task_done_set, _) = await asyncio.gather(
228-
self._watch_status(),
229-
asyncio.wait(self._concurrent_tasks)
230-
)
244+
# Start watcher as a background task so we can cancel it promptly when downloads finish
245+
watch_task = None
246+
if self._progress_manager:
247+
watch_task = asyncio.create_task(self._watch_status())
248+
249+
# Wait for all concurrent processor tasks to finish
250+
task_done_set, _ = await asyncio.wait(self._concurrent_tasks)
251+
252+
# Cancel watcher promptly to avoid waiting for its sleep interval
253+
if watch_task:
254+
watch_task.cancel()
255+
try:
256+
await watch_task
257+
except asyncio.CancelledError:
258+
pass
231259

232260
if display_task:
233261
display_task.cancel()

ktoolbox/progress.py

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,10 @@ class ProgressState:
179179
unit_scale: bool = False
180180
rate: Optional[float] = None
181181
last_update: float = field(default_factory=time.time)
182+
# Tracks the last state timestamp that was actually rendered.
183+
# Used to avoid advancing animation frames or forcing redraws
184+
# when nothing meaningful has changed.
185+
_last_render_seen: float = 0.0
182186
finished: bool = False
183187
failed: bool = False
184188
paused: bool = False
@@ -191,7 +195,8 @@ class ProgressManager:
191195
"""
192196

193197
def __init__(self, max_workers: int = 5, file: Optional[TextIO] = None,
194-
use_colors: bool = True, use_emojis: bool = True):
198+
use_colors: bool = True, use_emojis: bool = True,
199+
update_interval: float = 0.1):
195200
"""
196201
Initialize the progress manager.
197202
@@ -212,29 +217,41 @@ def __init__(self, max_workers: int = 5, file: Optional[TextIO] = None,
212217
self._total_jobs = 0
213218
self._completed_jobs = 0
214219
self._failed_jobs = 0
220+
self._existed_jobs = 0
215221

216222
# Terminal control
217223
self._lines_written = 0
218224
self._last_display_time = 0
219-
self._update_interval = 0.1 # Update every 100ms
225+
# Update interval (seconds). When downloads change, refresh at most
226+
# once per `update_interval`. Default is 1.0s to avoid excessive redraws.
227+
self._update_interval = float(update_interval)
220228

221229
# Display deduplication
222230
self._last_display_content = ""
223231

224-
def set_job_totals(self, total: int, completed: int = 0, failed: int = 0):
232+
def set_job_totals(self, total: int, completed: int = 0, failed: int = 0, existed: int = 0):
225233
"""Set the total number of jobs for overall progress tracking"""
226234
with self._lock:
227235
self._total_jobs = total
228236
self._completed_jobs = completed
229237
self._failed_jobs = failed
238+
self._existed_jobs = existed
230239

231-
def update_job_progress(self, completed: int = None, failed: int = None):
240+
def update_job_progress(self, completed: int = None, failed: int = None, existed: int = None):
232241
"""Update overall job progress"""
233242
with self._lock:
234243
if completed is not None:
235244
self._completed_jobs = completed
236245
if failed is not None:
237246
self._failed_jobs = failed
247+
if existed is not None:
248+
self._existed_jobs = existed
249+
250+
def increment_existed(self, n: int = 1) -> int:
251+
"""Atomically increment the existed count by n and return the new value"""
252+
with self._lock:
253+
self._existed_jobs += n
254+
return self._existed_jobs
238255

239256
def create_progress_bar(self, desc: str, total: Optional[int] = None,
240257
unit: str = "B", unit_scale: bool = True) -> 'ManagedTqdm':
@@ -434,11 +451,13 @@ def _render_overall_progress(self) -> List[str]:
434451
total_colored = ColorTheme.colorize(str(self._total_jobs), ColorTheme.BRIGHT_WHITE)
435452
running_colored = ColorTheme.colorize(str(running), ColorTheme.BRIGHT_CYAN)
436453
waiting_colored = ColorTheme.colorize(str(waiting), ColorTheme.BRIGHT_YELLOW)
454+
existed_colored = ColorTheme.colorize(str(self._existed_jobs), ColorTheme.BRIGHT_WHITE)
437455
else:
438456
completed_colored = str(self._completed_jobs)
439457
total_colored = str(self._total_jobs)
440458
running_colored = str(running)
441459
waiting_colored = str(waiting)
460+
existed_colored = str(self._existed_jobs)
442461

443462
# Calculate overall download speed from active progress bars
444463
total_rate = 0
@@ -468,7 +487,8 @@ def _render_overall_progress(self) -> List[str]:
468487
pct_colored,
469488
f"| Jobs: {completed_colored}/{total_colored}",
470489
f"| {running_colored} running",
471-
f"| {waiting_colored} waiting"
490+
f"| {waiting_colored} waiting",
491+
f"| {existed_colored} existed"
472492
])
473493

474494
if speed_str:
@@ -521,10 +541,15 @@ def _render_single_progress_bar(self, state: ProgressState) -> str:
521541
status_emoji = ColorTheme.WAITING
522542
else:
523543
# Animated spinner for active downloads
544+
# Advance spinner only when this progress state's content changed
545+
# since the last render to avoid continuous terminal refreshes
546+
# caused solely by animation frames.
524547
current_time = time.time()
525-
if current_time - _animation_state['last_update'] > 0.1:
548+
last_seen = getattr(state, '_last_render_seen', 0.0)
549+
if state.last_update != last_seen:
526550
_animation_state['frame'] = (_animation_state['frame'] + 1) % len(ColorTheme.SPINNER_FRAMES)
527551
_animation_state['last_update'] = current_time
552+
state._last_render_seen = state.last_update
528553
status_emoji = ColorTheme.SPINNER_FRAMES[_animation_state['frame']]
529554
else:
530555
status_emoji = ""

0 commit comments

Comments
 (0)