给 哔哩哔哩视频下载器(lanyeeee/bilibili-video-downloader)写的插件:下载时自动把集数补零。
第1话 羽丘的不可思议女孩→第01话 羽丘的不可思议女孩- 目录格式只写
{episode_order}时的裸数字1→01 - 补几位不用你填——插件自己从下载任务里推断合集集数
⚠️ 关于本仓库 代码与文档由 AI 撰写,但已经过实测可用。下面把验证项目逐项列出, 而不是笼统写一句「测过」—— 你可以照着命令自己复核任何一项:
| 验证项 | v0.1.2(上一发布版) | 本仓库 v1.0.0 |
|---|---|---|
| 单元 / 集成测试 | 36 项 | 74 项 |
| 补零行为等价性 | 人工对照 336×4 = 1344 组 | 自动差分 138,618 次逐字节比对 |
| 端到端 hook(P/Invoke,含真实数据) | 3 组 55 项 | 9 组 186 项 |
| 宿主加载序列仿真 | 无 | 18 项(复刻 load_plugin_from_path / execute_hook / apply_output) |
| 导出 ABI 对照 | — | 5 个符号与上一版逐字一致 |
| 静态检查 | 未配置 | clippy 零告警(panic 类 lint 为 deny) |
| 在下载器 GUI 内加载 | ✅ 已在 bilibili-video-downloader v0.2.1 中加载成功 | ✅ v0.2.1 实测加载成功(宿主日志原文见下) |
在 GUI 内触发真实下载(AfterPrepare 被调用) |
✅ | ⏳ 需要一次真实下载才能触发(见下) |
已在真实宿主里跑通加载。把
dist\bdp_autopad.dll装进插件目录后启动 bilibili-video-downloader v0.2.1,宿主自己写下:2026-10-20T11:03:38.270 INFO 插件加载成功: plugin_name=自动补零 第01话 (bdp-autopad, v1.0.0), plugin_path=C:\Users\Admin\Videos\bilibili-plugin\bdp_autopad.dll target=bilibili_video_downloader_lib::plugin::plugin_manager (plugin_manager.rs:60)全程无 error / warn;宿主随后把
plugin.json的 descriptor 更新为version: 1.0.0。 除加载外,前六项验证也都做到了,且比上一版严格得多 —— 尤其「宿主加载序列仿真」逐句复刻了 宿主的plugin_loader.rs/plugin_executor.rs/hook_context.rs,包含上一版测试没覆盖的set_host_apirc 校验与task_id不被改动的校验。唯一还没做的就是「在 GUI 里真的下载一集」(
AfterPrepare只在 prepare 成功后才触发, 需要一次真实下载)。做法:在下载器里粘任意一个 B 站视频链接开始下载,第一集 prepare 完就会走到插件; 装好后的目录/文件名会直接体现补零结果。v0.1.3 是行为等价重构(内部结构、性能、健壮性全面提升,外部行为逐字节不变); v1.0.0 在 v0.1.2 的基础上完成重构与验证补强,并修掉代码审查发现的 9 处缺陷, 详见下方更新记录 与怎么证明没有改坏。
不用编译,直接用:dist/bdp_autopad.dll 就是编译好的成品,
也可以在 Releases 里直接下载。
下载器自带的目录格式模板做不到这件事。它用的 strfmt 格式化库明确不支持零填充:
{episode_order:03} → error: sign aware zero padding and Align '=' not yet supported
而 {episode_title} 只是普通字符串,模板引擎不会去解析里面的 第1话 再补零。所以在模板层是无解的。
好在宿主暴露了 AfterPrepare 钩子,会在创建目录之前把 episode_dir / filename 交给插件改写——本插件就是在这里下手。
-
把
dist/bdp_autopad.dll放到一个不会被清理的固定目录,例如C:\Users\<你>\Videos\bilibili-plugin\bdp_autopad.dll(后端要求绝对路径且文件必须存在,别放临时目录或下载文件夹) -
打开下载器 → 设置 → 插件面板 → 点 「添加插件」 → 选中该 dll
-
确认状态显示 「已加载」,然后开始下载
插件出错时按
FailOpen记录日志并继续,不会中断你的下载任务。
插件读取 %APPDATA%\com.lanyeeee.bilibili-video-downloader\.下载任务\*.json,按 collection_title 聚合出 episode_order 的最大值:
| 合集集数 | 输出宽度 | 例 |
|---|---|---|
| ≤ 99 | 2 位 | 第01话 |
| ≤ 999 | 3 位 | 第001话 |
| ≤ 9999 | 4 位 | 第0001话 |
| 输入 | 12 集合集的输出 |
|---|---|
第1话 羽丘的不可思议女孩 |
第01话 羽丘的不可思议女孩 |
第12话 风吹浪打,亦不沉没 |
第12话 风吹浪打,亦不沉没(够长就不动) |
第1話 羽丘的不可思议女孩(U+8A71) |
第01話 羽丘的不可思议女孩 |
第1巻 序章(U+5D29) |
第01巻 序章 |
EP1 标题 / Ep 1 / ep.1 |
EP01 标题 / Ep 01 / ep.01 |
[1] 标题 / 【2】标题 |
[01] 标题 / 【02】标题 |
1(纯数字) |
01 |
2. |
02. |
1 - 标题 |
01 - 标题 |
1 第100话 x |
01 第100话 x(前导数字照常补,右侧够长不动) |
第1话 标题-P2 分P名 |
第01话 标题-P02 分P名 |
1080p / 128kbps / 2024 |
原样不动 |
正片 / 原版 / 第1.5话 特别篇 |
原样不动 |
OAD 感冒综合征 / 特别篇 拈花夜话 |
原样不动(无集数就不动) |
DEEP1 / STEP3 / HDD2 |
原样不动(EP 守卫) |
[1080p] / [AB12] |
原样不动(方括号里不是集数) |
-P1080 / -P1080p / -p2 |
原样不动(分P 守卫) |
- 主路径:
第N话/第N集/第N期/第N話/第N巻 - 也支持
EP1、Ep 1、ep.1前缀写法 - 也支持行首方括号编号
[1]、【2】 - 也支持纯数字开头的文件名(
1、2.、1 - 标题) - 也支持分P 序号后缀(
-P2→-P02) - 以上规则可叠加:一个名字里出现多种写法时各补各的; 只有当所有规则都没产生实际改动时,才退回「开头纯数字」兜底
.mp4扩展名始终原样保留- 只补位、不截断,补过零的不会叠加
regexcrate 不支持环视,以上所有"不该动"的边界判断都是用代码手工做的(取匹配后看前后字符), 所以每条守卫都有对应测试覆盖,见下方tools/smoke-test.ps1与差分测试。
首次加载会在下载器数据目录自动生成:
%APPDATA%\com.lanyeeee.bilibili-video-downloader\autopad.toml
enabled = true # 总开关
min_width = 2 # 宽度下限(2 = 12 集的季番也写成 第01话)
max_width = 4 # 宽度上限
fixed_width = false # true = 忽略自动探测,一律用 min_width
pad_filename = true # 文件名里的集数是否补零
pad_episode_dir = true
verbose = false # true = 往控制台打探测日志改完需要重启下载器(配置只在进程启动后读一次)。
也可以用环境变量临时覆盖:AUTOPAD_MIN_WIDTH=3、AUTOPAD_ENABLED=0。
配置文本读取有 64 KiB 上限,超限视同读失败并回落默认值(不截断解析)。
| 维度 | 情况 |
|---|---|
| 运行位置 | 只在下载器进程内,靠 AfterPrepare 钩子调用 |
| 改什么 | 只改本次任务的 episode_dir / filename |
| 老文件 | 不会动已经下载好的文件(钩子只在下载流程中触发) |
| 其他软件 | 完全不碰,不注册全局钩子、不装服务、不开机自启 |
先说清楚:宿主的插件系统没有任何沙箱或权限约束——插件是进程内动态库,与下载器同权限运行,官方 README 也明确警告过这一点。
所以下面这些不是系统强制的最小权限,而是本插件代码自身的实际接触面:
- 只声明了 3 个钩子中的 1 个(
AfterPrepare),BeforeVideoProcess/OnCompleted完全不参与 - 从未调用宿主提供的唯一 Host API(
host::get_config()),因此接触不到sessdata等敏感配置 - 静态导入表里没有网络组件——没有
ws2_32(socket)、winhttp、wininet;也没有advapi32(注册表)、shell32(COM/shell),只有KERNEL32/ntdll/ UCRT 系列 /bcryptprimitives - 文件系统只碰三处:读
.下载任务\*.json、读autopad.toml、仅当配置不存在时写一份默认autopad.toml - 环境变量只读
APPDATA/HOME,以及可选的AUTOPAD_MIN_WIDTH/AUTOPAD_ENABLED - 代码零
unsafe,且用 lint 把「不许新增 unsafe」固化(见下)
自查导入表:
objdump -p dist/bdp_autopad.dll | findstr "DLL Name"诚实补充:没有沙箱意味着技术上插件可以调用更多 API(例如动态
LoadLibrary),上表只说明它现在没有。要长期安心,请自行审阅 src/ —— 一共 6 个生产文件、约 1650 行(不含测试代码)。
工具链:Rust (GNU target) + mingw-w64(提供 gcc / as / dlltool,缺了会在 parking_lot_core 处报 dlltool could not create import library)。
cd bdp-autopad
cargo test --release # 74 个单元/集成测试
cargo build --release # 产物 target\release\bdp_autopad.dll
⚠️ 本机若同时装了 scoop 版(MSVC)与 rustup 版(GNU)两套 Rust,cargo会按PATH顺序取到其中一个。必须用 GNU 那条,否则编出来的 dll 会链接到 MSVC 运行时,与仓库文档和既有成品不一致。确认方式:cargo -vV的第一行host:应是x86_64-pc-windows-gnu; 不是的话把%USERPROFILE%\.cargo\bin放到PATH最前面再执行。
cargo test 只能证明补零函数写对了,证明不了宿主真的会这么调用。
tools\smoke-test.ps1 用 P/Invoke 直接加载 dll,按宿主 v0.2.1 真实的
extern "C" 符号 + HookInputV1 JSON 协议喂输入,逐项核对输出。
v0.1.3 起脚本自带合成数据集(临时 APPDATA + 12/100/1000 集三个合集),
不再依赖本机是否下载过某个番剧,结果完全确定:
# 必须 -ExecutionPolicy Bypass(系统默认禁止运行脚本)
powershell -NoProfile -ExecutionPolicy Bypass -File tools\smoke-test.ps1
# 或直接测刚编出来的:
powershell -NoProfile -ExecutionPolicy Bypass -File tools\smoke-test.ps1 -DllPath target\release\bdp_autopad.dll9 个断言组,覆盖配置面的每一种组合(每个 -Expect 必须单独开进程,因为配置是进程级单例):
-Expect |
验证什么 | 断言数 |
|---|---|---|
default(不传) |
全套补零行为 + 宽度推导(2/3/4 位)+ 默认配置自动生成 | 49 |
NoPad |
设 AUTOPAD_ENABLED=0 后必须一个都不改 |
32 |
Disabled |
配置文件 enabled = false,同样一个都不改 |
32 |
BadConfig |
配置文件塞满非法值 / 未知键 / 全角等号:必须回落默认值、照常工作、不覆写用户文件 | 11 |
Width3 |
AUTOPAD_MIN_WIDTH=3 后宽度必须变 3 |
18 |
FixedWidth |
fixed_width = true + min_width = 3,忽略探测 |
18 |
FileOnly |
pad_episode_dir = false,只改文件名 |
8 |
DirOnly |
pad_filename = false,只改目录(且父级不动) |
8 |
RealData |
对本机真实 .下载任务 数据只读抽查 |
9 |
powershell -NoProfile -ExecutionPolicy Bypass -File tools\smoke-test.ps1 -Expect Width3
powershell -NoProfile -ExecutionPolicy Bypass -File tools\smoke-test.ps1 -Expect RealData只读,不碰任何下载文件与 autopad.toml;临时目录用完即删。
脚本文件头必须是 UTF-8 带 BOM,否则 Windows PowerShell 5.1 会按 GBK 解码, 中文字符串全乱(这是踩过的坑之一)。
冒烟测试只证明补零行为对;这个脚本证明宿主能加载它并按协议调用。
它逐句复刻宿主源码 src-tauri/src/plugin/ 下的三条路径,而不是凭协议猜:
| 阶段 | 复刻的宿主代码 | 检查点 |
|---|---|---|
| 加载 | plugin_loader.rs :: load_plugin_from_path |
绝对路径 / 文件存在 / dlopen 全部 5 个导出符号 / sdk_api_version==1 / id 非空 / hooks 非空 / set_host_api_v1 返回 rc=0 |
| 调用 | plugin_executor.rs :: execute_hook、call_on_hook_blocking |
on_hook_v1 rc=0、输出缓冲非空、free_buffer 正常 |
| 应用 | hook_context.rs :: apply_output |
payload 变体与 hook_point 匹配、task_id 未被插件改动 |
| 探针 | — | 注入带计数的 get_config_json 回调,断言插件一次都没调用宿主 Host API |
| 失败路径 | plugin_manager.rs :: run_hook 的 FailOpen 分支 |
输入畸形时 rc≠0 且 last_error 可读 |
& .\tools\host-load-test.ps1
& .\tools\host-load-test.ps1 -DllPath <别的 dll 做对照>
18 项断言。实测 v1.0.0 与本仓库里的 v0.1.2 成品 dll 结果逐项一致。
其中
set_host_apirc 校验是 v0.1.2 时期的测试没有覆盖的一步 —— 宿主在这里 rc≠0 会直接拒绝加载,补上这步才算真正对齐load_plugin_from_path。
# 默认测 dist\bdp_autopad.dll;加 -DllPath 可测别的(如旧版本做对照)
powershell -NoProfile -ExecutionPolicy Bypass -File tools\bench-scan.ps1造 400 个 ~14 KB 的任务文件(贴近真实规模),分别测「冷启动 / 目录未变 / 新增 1 个文件」三档耗时, 并抽查结果正确性。当前实测(每档 5 轮取中位):
| 场景 | v0.1.2 | v0.1.3 | 说明 |
|---|---|---|---|
| 冷启动(首次 hook) | 260 ms | 253 ms | 等价,都被文件读取与杀软扫描主导 |
| 目录未变 | 0.61 ms | 0.65 ms | 等价,指纹命中、零文件内容读取 |
| 新增 1 个任务文件 | 78.8 ms | 1.95 ms | 约 40 倍 —— 批量下载的稳态路径 |
├── Cargo.toml # cdylib;release 关掉了 panic=abort(见下);生产代码禁 unwrap/panic
├── src/
│ ├── lib.rs # 插件协议编排:descriptor + on_hook + handle_hook(策略本体)
│ ├── pad.rs # 补零规则(纯函数,零 IO)+ 单元测试
│ ├── config.rs # autopad.toml 解析(零依赖 key=value)+ 环境变量覆盖
│ ├── episode_scan.rs # 扫 .下载任务/*.json 聚合集数(目录指纹 + 逐文件增量缓存)
│ ├── paths.rs # 宿主数据目录定位
│ ├── legacy_reference.rs # 【仅测试】v0.1.2 补零逻辑逐字副本,差分对照用
│ └── equiv_tests.rs # 【仅测试】差分测试(新旧实现逐字节对照)
├── tests/data/episode_titles.txt # 336 条真实 episode_title 语料(差分测试输入)
├── tools/
│ ├── smoke-test.ps1 # P/Invoke 端到端冒烟测试(9 组 186 项断言)
│ ├── host-load-test.ps1 # 宿主加载序列仿真(18 项,复刻 plugin_loader/executor)
│ └── bench-scan.ps1 # 集数探测性能基准
├── vendor/ # 宿主 v0.2.1 的 plugin-api / plugin-sdk(MIT)
└── dist/bdp_autopad.dll # 编译产物
模块划分不只是"好看":补零规则必须可被穷举验证,所以它被隔离成零 IO 的纯函数
(pad.rs 不知道磁盘、配置、锁的存在);所有可能失败的 IO 都集中在 config / episode_scan,
失败时一律回落安全默认值。
重构的头号约束是「不得造成任何功能缺失或行为改变」。口头保证没有价值,所以给了三层证据:
-
差分测试(
cargo test,最强的一层)src/legacy_reference.rs里放着 v0.1.2 补零逻辑的逐字副本(连\d的 Unicode 语义都保留), 与pad.rs在同一个测试进程里对同一批输入跑两遍、逐字节比对:语料 来源 条数 比对次数 真实标题 本机 415 个任务文件里的 336 条 episode_title336 9072 原测试用例 v0.1.2 单元测试里手写过的全部用例 91 2457 边界输入 空串、控制字符、全角/阿拉伯数字、超长数字、1 万字符长串…… 99 2673 片段组合 24 种格式片段两两/三三拼接的组合爆炸 4608 124416 合计 5134 138618 每条输入都按 9 种宽度(0/1/2/3/4/5/6/16/64)× 3 种形态(纯文件名、裸路径、完整路径)比对。
-
五条规则各自的见证用例 —— 差分测试只能证明「新旧一致」,证明不了「规则还在工作」 (若某条规则被整体删掉且语料恰好不覆盖,差分同样会通过)。因此
all_five_rules_are_individually_exercised为每条规则钉了一个「只有它才会产生该输出」的输入。 -
同一套冒烟断言跑两个 dll ——
tools\smoke-test.ps1的 9 组 186 项断言 分别对 v0.1.2 与 v1.0.0 的成品 dll 各跑一遍,结果完全一致(9/9 组、0 失败)。 -
宿主加载序列仿真跑两个 dll ——
tools\host-load-test.ps1的 18 项断言 同样对新旧 dll 各跑一遍,逐项一致;导出 ABI(5 个符号)也逐字一致。 -
原项目自带的验收脚本原样复跑 —— 未改动的
tools\smoke-test.ps1(v0.1.2 版, 3 组 55 项)直接指向新 dll 运行,55/55 全通过,与指向 v0.1.2 成品 dll 的结果相同。
regexcrate 不支持环视((?=...)、(?!...)),写进去会直接编译失败。 「数字后面不能是字母」这条规则只能取出来用代码判断。Cargo.toml里绝不能写panic = "abort"。SDK 的export_plugin_v1!靠catch_unwind把插件异常转成错误码,配合FailOpen让宿主记日志继续跑; 改成 abort 会让插件里任何 panic 直接崩掉整个下载器进程。- 不能
use std::sync::LazyLock——export_plugin_v1!宏展开时也导入了它, 同作用域重复导入报 E0252,改用全路径引用。 - 宿主只校验
task_id不可改;episode_dir/filename可自由修改, 而且create_dir_all在 hook 返回之后才执行,所以改动会真正生效。 - 写
plugin.json必须用无 BOM 的 UTF-8——宿主是serde_json::from_str(...).unwrap_or_default(),带 BOM 会静默解析失败变成空表, 插件看着写进去了却根本不加载。 - 别用
LazyLock承载可能 panic 的初始化。LazyLock的初始化体一旦 panic, 该实例就永久中毒:之后每次访问都重新 panic,且初始化体不会再被调用, 没有任何自愈途径。Config::get()原来正是这么写的,而Config::load()里的eprintln!在 stderr 不可写时(例如输出端管道已关闭)会 panic —— 结果是插件在该进程内彻底失效且永不恢复。 现改为OnceLock(初始化失败可重试)+ 吞掉写错误的log_err()。 - 日志不要用
eprintln!,理由同上:插件里的日志失败绝不该升级成功能失败。 统一走config::log_err(),它丢弃所有写入错误。 - 宽度必须夹到
1..=64。宽度会进format!("{:0>width$}"),Rust 的格式化填充 宽度有内部上限,usize::MAX会直接 panic。pad.rs的MAX_WIDTH是单一事实来源,Config::normalize与pad_text入口各夹一次。 Take<T>不实现size_hint。用file.take(max)做读取上限后, 若不给String::with_capacity,一个 14 KB 的文件要从 32 字节反复倍增约 10 次 (实测冷启动因此慢约 35%)。用已打开句柄的metadata()定容即可—— 这不是多一次打开,std::fs::read_to_string内部本来也这么做。regex的\d是 Unicode 语义,能匹配٣、1这类字符,但它们永远无法被str::parse::<u32>()解析(Rust 整数解析只认 ASCII),所以命中后也只会原样返回。 收窄成[0-9]输出逐字节不变,还能顺带做「没有 ASCII 数字就直接短路」—— v0.1.3 正是这么改的,差分测试覆盖了这批输入。
与 v0.1.2(上一发布版)相比,对外行为逐字节不变。这一版做的是内部重构与验证补强, 不是功能升级:没有新增补零格式、没有新增配置项、没有改变任何输出。
| 方向 | 变化 |
|---|---|
| 性能 | 批量下载时单个任务的集数探测 55.9 ms → 1.49 ms(约 37×)(400 文件数据集,9 轮取中位) |
| 可验证性 | 单元/集成测试 36 → 74;端到端冒烟 3 组 55 项 → 9 组 186 项;新增 138,618 次自动差分比对与 18 项宿主加载序列仿真 |
| 可维护性 | 单个 720 行 lib.rs 拆成 6 个模块;补零规则独立为零 IO 纯函数,可被穷举验证 |
| 健壮性 | 配置 64 KiB / 任务文件 8 MiB 读取上限(超限明确失败,不截断解析);逐文件缓存 20,000 条上限;unwrap/expect/panic 在 Cargo.toml 里设为 deny |
| 可诊断性 | 默认配置写入失败、配置项非法值不再静默,改为打日志 |
| 实测 | 已在 bilibili-video-downloader v0.2.1 中加载成功(宿主 plugin.json 与日志为证) |
版本号从 0.1.x 跳到 1.0.0 是稳定性声明:行为已在真实下载数据上验证 (本机 354 条可比对条目中,需要补零的 164 条 100% 正确),接口与配置格式就此冻结。
升级方式不变:替换 dll 即可,无需改任何设置。
审查方式:逐文件人工审查 + 用探针工程把每个可疑点写成可执行断言,先复现、再修。 只列实际改动,行为兼容性单独说明。
| # | 缺陷 | 触发条件 | 后果 | 修法 |
|---|---|---|---|---|
| B1 | 超尺寸文件被当成「读失败」 | .下载任务 里存在 > 8 MiB 的 .json |
stamp_after 把目录指纹置为永久无效,之后每一次 hook 都放弃「目录未变 → 零成本」的指纹短路、重建整张映射(实测 0.65 ms → 1.95 ms,约 3 倍),且用户无从知晓 |
新增 ParseOutcome::Skipped:尺寸超限是确定性失败,照常写缓存、不计入 read_failed、不让指纹失效;首次出现时打一条日志提醒 |
| B2 | 基准脚本把 APPDATA 还原成 %USERPROFILE% |
tools\bench-scan.ps1 每次运行 |
环境变量被改成错误的值(脚本进程结束即退,无实际后果;但同进程后续逻辑会读错目录) | 开头留底 $realAppData,结束原样还原 |
| B3 | 两个测试脚本无异常清理 | 脚本中途 throw(例如 hook 返回非 0) |
临时目录残留、APPDATA 停留在沙箱路径 |
冒烟脚本加 trap + Clear-Sandbox;基准脚本加 try/finally |
| B4 | 默认配置写入失败被静默吞掉 | 宿主数据目录不存在 / 无写权限 | 用户按文档去目录里找不到 autopad.toml,且没有任何提示可定位 |
写失败时打日志(附路径与错误) |
| B5 | 配置文件里的非法值静默忽略 | enabled = ture、min_width = 三个 等 |
配置「看起来写进去了、实际完全没生效」;而同样非法的值走环境变量却会打日志,两条路径行为不一致 | 抽出 set_bool / set_usize,无法识别时保留原值并打日志 |
| B6 | README 声称 v0.1.3 已在宿主实测 | — | 文档与实际验证范围不符(v0.1.1/v0.1.2 确实在 v0.2.1 里跑过,v0.1.3 没有) | 改成按版本分列的验证程度表,明确标出「未在 GUI 内跑过真实下载」 |
| B7 | 扫描状态非原子提交(预防性) | 将来在 build_map 与 refresh 之间引入任何 panic 源 |
会留下「缓存是新的、指纹还是旧的」的不一致状态,下次刷新直接跳过重扫并沿用过期映射 → 补零位数静默算小 | build_map 改为只读缓存、返回新缓存;refresh 拿到返回值后四个字段连续一次性提交 |
| B9 | 逐文件缓存无容量上限 | .下载任务 长期累积(宿主不自动清理) |
常驻内存随目录文件数线性增长 | 新增 MAX_CACHE_ENTRIES = 20_000,超过则整体停用缓存(退回全量扫描语义,结果不变,内存封顶) |
| B12 | config 与 episode_scan 各有一份限额读取实现 |
— | 重复代码;更关键的是「尺寸超限」没有独立类型,B1 因此无从区分 | 抽成 src/text_file.rs,ReadTextError::TooLarge 让「确定性失败」与「可能自愈的失败」在类型层面分开 |
兼容性:功能行为完全不变(差分测试 13.8 万次比对仍全一致,9 组冒烟断言全通过)。
唯一新增的可观测输出是日志(更啰嗦了一点,仅在有异常输入时出现),以及 verbose 日志多两个字段。
「修复前必然失败」的证据:B1 / B9 的回归测试在回退修复的副本上实测失败
(regression_b1_oversized_file_does_not_invalidate_stamp、regression_b1_stable_state_survives_oversized_file、
regression_b9_cache_is_capped 三项),加回修复后通过。B7 是预防性改动,无法用运行时断言复现(当前代码里没有 panic 源),
其契约由 regression_b7_build_map_does_not_mutate_caller_cache 钉住。
审查中确认「不是 bug」的项(已记录,避免后人重复怀疑):
pad_path对C:\dl\1\(尾部分隔符)、C:(裸盘符)、\\、UNC 路径的处理均正确;尾部\丢失不影响语义。extract_json_string/extract_order对本机 415 个真实任务文件的抽取结果与完整 JSON 解析逐条一致(0 处差异),且两个 key 在每个文件里都只出现一次。list_json_files的指纹依赖read_dir顺序,顺序变化只会触发一次多余重建,不影响结果。json_unescape的全部切片都有长度前置检查,无越界风险。- 方括号规则只认行首编号(
[字幕组][1]不补)是 v0.1.0 起的既有设计,非回归。 - 宽度按「已出现的最大集数」推导,因此同一合集补下更高编号的集数后位数可能变大(
第01话vs第100话)——同理是既有设计。
外部行为逐字节不变(见怎么证明没有改坏),改动全在内部:
| 方向 | 改动 | 效果 |
|---|---|---|
| 性能 | 集数探测改为逐文件增量缓存:指纹变化时不再整目录重读,只读新增/改动的文件 | 批量下载稳态单次刷新 78.8 ms → 1.95 ms(约 40×) |
| 性能 | 补零各规则改为「零改动不分配」(返回 Option<String>,仅在确有改动时构造新串) |
一个普通文件名从 5~6 次堆分配降到 1 次 |
| 性能 | 无 ASCII 数字时直接短路,跳过 5 趟正则;merge_max 不再为每个文件重复分配键 |
见基准 |
| 稳定 | 正则改 OnceLock<Option<Regex>>,编译失败视为「永不匹配」——既不会 LazyLock 中毒,也不会把 panic 抛过 extern "C" 边界 |
消除一类「进程内永久失效」 |
| 稳定 | 宽度硬上限收敛到 pad::MAX_WIDTH 单一常量,pad_text / pad_path 入口各夹一次 |
绕过配置也不会 panic |
| 安全 | 配置文件与任务文件读取加字节上限(64 KiB / 8 MiB),超限不截断而是按读失败处理 | 异常文件撑不爆内存,也不会抽出错位的标题 |
| 安全 | Cargo.toml 里把 unwrap / expect / panic / todo / dbg / print_std* 全部设为 deny(仅 cfg(test) 局部放开) |
生产代码里 panic 变成编译错误 |
| 可维护 | 拆成 5 个生产模块(pad / config / episode_scan / paths / lib),pad 是零 IO 纯函数 |
补零规则可被穷举验证 |
| 可维护 | handle_hook(cfg, input) 与 on_hook 解耦:策略本体不再依赖进程级单例配置 |
钩子策略可确定性测试(新增 9 项) |
| 可维护 | 新增差分测试框架(含 336 条真实语料),测试从 36 项增至 66 项 | 行为等价有硬证据 |
| 可维护 | 冒烟测试改为自带合成数据集,不再依赖本机下载记录;断言组从 3 组 55 项增至 9 组 186 项 | 覆盖配置面全部组合 |
| 可维护 | 新增 tools/bench-scan.ps1 性能基准 |
性能结论可复现 |
| 兼容 | edition 2021 → 2024;rust-version = "1.85" |
— |
兼容性:插件 id、descriptor、钩子点、失败策略、目录格式、配置项与环境变量全部不变, 直接替换 dll 即可,无需改任何设置。已在同一套 174 项冒烟断言下与 v0.1.2 逐项对齐。
每项都先做了 5 轮重复测试确认稳定复现(非偶发),修复后再做四层回归验证。
| 缺陷 | 实际后果 |
|---|---|
| 补零兜底被静默吞掉 | 1 第100话 x 里的前导 1 不补零——第100话 已够长无需改动,却因此让整个文件名的兜底补零被跳过 |
| 配置读取 panic 后永久失效 | stderr 不可写(如输出端管道已关闭)时,插件在该进程内彻底失效,后续所有任务都不再补零且无法恢复 |
| 集数探测不容忍冒号后空格 | {"episode_order": 42} 这种合法 JSON 解析失败 → 该任务集数漏统计 → 推断出的位数可能偏小 |
| 空目录与「无数据」指纹撞值 | 「读失败后自动重扫」的自愈逻辑在空目录场景下失效,过期映射可能被一直沿用 |
| 两处内部状态误报「已处理」 | 与第一条同源,同样会让兜底补零被跳过 |
兼容性:用 336 条真实 episode_title × 4 种宽度共 1344 组数据逐字节对照,
输出与 v0.1.1 完全一致;上述变化只发生在旧版本本来就会处理错的输入上。
安装方式不变,直接替换 dll 即可。
- 扩展补零格式:
EP1/Ep 1/ep.1、行首[1]/【2】、分P-P2、日文第1話/第1巻 - 重写集数探测:一次性建立「合集 → 最大集数」全局映射 + 目录指纹失效,
读取量从
合集数 × 文件数降到至多文件数 - 新增
tools/smoke-test.ps1端到端冒烟测试(55 项断言) - 修复并行审计发现的 6 处隐患:宽度硬顶、
descriptor()纯函数化(去掉其中的磁盘 IO)、 读失败不再盖有效指纹、AUTOPAD_ENABLED=off语义修正等
首个版本:第N话 补零 + 从 .下载任务/*.json 自动推断位数。
MIT,与原项目相同。
vendor/ 目录下的 plugin-api / plugin-sdk 取自
lanyeeee/bilibili-video-downloader(MIT),
原始版权声明见 THIRD-PARTY-NOTICES.md。
该插件系统是实验性 v1:插件以进程内动态库形式运行,与宿主同权限、无沙箱。 装第三方插件时请自行评估代码。