Readest Calibre 插件推送协议深度解析:从图书哈希、去重策略到 OAuth 中继与存储校验
【免费下载链接】readestReadest is a modern, feature-rich ebook reader designed for avid readers offering seamless cross-platform access, powerful tools, and an intuitive interface to elevate your reading experience.项目地址: https://gitcode.com/gh_mirrors/re/readest
本篇文章聚焦readest-calibre-plugin(对应 Issue #4863)的完整技术实现:一个运行在 calibre GUI 内、把选中图书及其元数据推入 Readest 云端书库的插件。文章以仓库中的协议设计文档为骨架,结合 apps/readest-calibre-plugin/ 下api.py、wire.py、oauth.py、worker.py、ui.py等源码与 56 个单元测试,系统讲解图书身份哈希算法、OPF 元数据嵌入与 uuid 去重、POST /sync的显式 null 语义、基于 localhost 的 OAuth 登录中继,以及uploaded_at不可信场景下的存储校验兜底。读完你将掌握该插件“推书不重复、改文件即替换、改元数据只更新行”的核心机制,以及它在实际版本迭代中踩过并修复的关键协议坑。
一、插件定位与功能全景
readest-calibre-plugin是 Readest 生态中与 readest.koplugin(KOReader 插件)并列的桌面端桥梁,目标用户是重度使用 calibre 管理书库的读者。它的设计原则是选择性、手动推送:在 calibre 里选中任意数量的书,点击工具栏Readest按钮,即把图书文件连同元数据推入 Readest 云端;后台没有任何自动同步。
核心功能点(见 README.md):
- 元数据随书走:标题、作者、丛书(series)、标签、简介、出版社、语言、标识符,以及可选的 calibre 自定义列(custom columns)一并写入云端书库条目(自定义列进入
customColumns),同时嵌入上传文件自身的 OPF(以calibre:user_metadata形式),且绝不修改 calibre 本地库文件——嵌入发生在临时副本上。 - 重推即更新:已在 Readest 中的书通过 calibre uuid 识别,仅当内容发生变化时才重写条目;未变化的书直接跳过;文件变化则以新文件替换旧条目而非产生重复。
- 逐书状态报告:uploaded / updated / up to date / failed,并在配额耗尽时干净地中止推送。
- 与应用一致的登录方式:邮箱 + 密码,或 Google / Apple / GitHub / Discord 浏览器 OAuth(通过临时 localhost 回调,与桌面应用同一条流程)。
插件在仓库中的文件布局为:init.py(calibre 插件入口)、api.py(HTTP 客户端 + 内容哈希)、wire.py(calibre 元数据 → wire 记录与推送规划)、oauth.py(localhost OAuth 回调)、worker.py(后台推送 QThread)、ui.py(calibre 界面动作)、config.py 与 dialogs.py(配置与对话框)、sync_version.py(版本同步)。
二、图书身份体系:partialMD5 与 metaHash
云端去重的第一块基石是确定性的内容哈希。api.py实现了两个关键哈希,均与 Readest 应用侧保持字节级一致(注释明确镜像apps/readest-app/src/utils/md5.ts::partialMD5与utils/book.ts的规范化逻辑):
2.1 Book.hash:KOReader 兼容的 partial MD5
partial_md5不计算整个文件的 MD5,而是采样若干 1024 字节块。块偏移序列来自 api.py 的_partial_md5_ranges:
for i in range(-1, 11): offset = 0 if i == -1 else 1024 << (2 * i) ...即偏移量为0, 1024, 4096, 16384, ...,一直到1024 << 20。这里有一个协议层细节:JS 侧1024 << -2在 32 位移位运算下会回绕为 0,所以 Python 实现里i == -1时显式取 0,从而与 JS 的循环(i in -1..10)完全对齐。这个算法源自 KOReader,Book.hash就是它的十六进制输出。
2.2 metaHash:标题 + 作者 + 标识符的指纹
meta_hash(title, authors, identifiers)计算:
md5(NFC("title|authors,|ids,"))其中作者以逗号拼接,标识符按uuid > calibre > isbn的优先级取首选标识符(_identifiers_list镜像getIdentifiersList/getPreferredIdentifier),并对urn:/scheme:前缀做规范化剥离(_normalize_identifier)。字符串先做 NFC 规范化再以 UTF-8 编码进 MD5——记忆文档强调,该 Python 实现已用js-md5输出做过逐字节比对验证。
2.3 双重哈希的分工
bookHash(=Book.hash)= 上传 blob 的 partialMD5,随文件字节变化而变;metaHash= 元数据指纹,用于应用侧识别“同一本书的不同版本”。
而原始 calibre 库文件的 partialMD5被单独保存为元数据字段calibreSourceHash(见 wire.py 注释),理由是:上传 blob 嵌入了元数据,其book_hash会随每次嵌入而漂移,只有对“未加工的原始库文件”的哈希才能作为稳定指纹,用于检测“文件本身是否变化”,且无需任何本机状态。
三、上传与去重:OPF 嵌入 + uuid 双键机制
3.1 临时副本嵌入元数据
worker.py 的_embed_metadata_copy先shutil.copyfile复制库文件到临时路径(前缀readest-),再调用calibre.ebooks.metadata.meta.set_metadata(stream, mi, ext)把元数据写入副本。对 EPUB 而言 calibre 的嵌入是确定性的;自定义列会写入calibre:user_metadata。没有元数据写入器(或写入失败)的格式则回退为未修改的副本。临时副本用完即删,本地库文件始终只读。
3.2 双键去重
推送的书由两个键共同追踪(README.md):
- calibre 书 uuid:写进行条目的
metadata.identifier(urn:uuid:...),即使文件字节变化也能跨推送识别“这本 calibre 书已在 Readest”; - calibreSourceHash:原始库文件 partialMD5,用于检测文件自上次推送后是否变化。
为什么 uuid 能扛住字节变化?因为book_hash随内容漂移,但 uuid 在元数据里是稳定的。服务端行的匹配在 wire.py 的index_rows_by_uuid中建立:把拉取的行按 uuid 索引,同一 uuid 多行时优先存活行(deleted_at为空),再取updated_at更新的。旧版本插件推送的行(v1)没有calibreSourceHash字段,此时row_source_hash回退到row.get('book_hash')——因为 v1 上传的是未改动的原始文件,其book_hash就等于原始文件哈希,恰好补上了指纹(wire.py)。
3.3 云端文件命名
blob 的存储 key 是Readest/Books/{hash}/{hash}.{ext}(wire.py 的book_file_name,与getRemoteBookFilename一致),封面固定为Readest/Books/{hash}/cover.png。应用侧{title}.{ext}形式的下载通过 download API 的“hash + 扩展名”回退解析。封面以原始字节上传(应用从不转换格式,参考apps/readest-app/src/services/bookService.ts),因此 calibre 的cover.jpg字节原样上传,coverHash就是这些字节的 partialMD5。
四、推送规划:plan_push 的四种动作
推送决策集中在 wire.py 的plan_push,输入是服务端行、本次 wire 记录、封面哈希、原始文件指纹,以及“blob 是否真实存在于存储”的判定。输出四种动作:
| 动作 | 触发条件 | 行为 |
|---|---|---|
new | 无对应服务端行 | 嵌入元数据 → 上传文件 → 插入行 |
replace | 原始文件指纹变化,或行没有 blob | 上传带新元数据的新 blob(新 hash 命名空间),旧行 tombstone |
update | 文件未变,但元数据/封面/墓碑状态有差异 | 仅更新行,不重新上传 |
skip | 文件 + 元数据 + 封面全部未变 | 跳过 |
关键点:blob_present被放在最后检查(_resolve_blob_present),只有当前面条件都通过、结果仍可能改变时才发起一次存储查询,保证new/replace路径永远不多花一次请求(wire.py 注释)。server_row is None时直接new;若uploaded_at为空、或row_source_hash != source_hash、或 blob 缺失,则降级为replace——这正是“存储校验”能纠偏旧逻辑的原因(见第七节)。
plan_push的可测试性也体现在 tests/test_wire.py:测试以构造的server_row()(含 group、progress、reading_status、cover 等字段)驱动四种动作分支,SRC = 's' * 32模拟原始文件 partialMD5。
五、wire 协议:POST /sync 的显式 null 语义
推送的最终负载由 wire.py 的merge_for_push生成,它对应应用侧apps/readest-app/src/utils/transform.ts::transformBookToDB的逆过程。这里有一条血泪协议事实:
服务端对 wire 记录中缺席的字段做显式置 null(explicit-nulls),因此一次
update必须把服务端行里的groupId/groupName/progress/readingStatus/uploadedAt/coverHash原样搬运过来,否则这些字段会被清掉。KOReader 插件syncbooks.lua早已踩过同样的坑。
merge_for_push的具体搬运逻辑:
record['deletedAt'] = None:一次显式推送会(重新)激活被 tombstone 的书;createdAt沿用原行created_at(没有则用本次时间);- 组信息、进度、阅读状态仅在服务端行有值时携带;
uploadedAt优先取调用方显式传入的uploaded_at_ms(上传路径传now_ms),否则沿用行的uploaded_at;- 封面:本次推送了封面则写新
coverHash+coverUpdatedAt,否则沿用行值; metadataUpdatedAt:应用侧按字段级 LWW(metadata_updated_at)裁决 title/author/tags/metadata,所以本次推送若改变了元数据/组才盖新时间戳,否则沿用行的时间戳——避免一次仅封面的推送覆盖掉并发的 Readest 端编辑(对应 readest#5438 的讨论)。
另一条易错点被同步修复:_remember曾把哨兵字符串'uploaded_at': 'pushed'存进内存行,而merge_for_push后续会把它喂给iso_to_ms触发ValueError,现改为ms_to_iso(record['uploadedAt'])(worker.py)。
替换流程(_push_one中replace分支)一次POST /sync提交两条记录:新书行 + 旧行tombstone_record(deletedAt = now_ms的软删除记录,见 wire.py),随后 best-effort 地list_files+delete_file清理旧 hash 命名空间的云端文件以回收配额。
六、非应用客户端的 OAuth:localhost 片段中继
从非 Web 客户端(本插件、Flatpak 桌面版的自定义 OAuth 路径)完成浏览器登录的关键事实(oauth.py):
Supabase 允许把 OAuth 重定向到白名单地址
{supabase}/auth/v1/authorize?provider=X&redirect_to=http://localhost:PORT,但 token 出现在 URL 的fragment(#之后)里,而 fragment 永远不会到达 HTTP 服务器。
因此插件起一个绑定127.0.0.1:0(临时端口)的HTTPServer,首次响应返回一段内嵌脚本的着陆页(LANDING_PAGE):
<script> var hash = window.location.hash.replace(/^#/, ''); window.location.replace('/callback?' + hash); </script>脚本把location.hash改写成查询参数转发到/callback,parse_callback_query再从查询里提取access_token/refresh_token/expires_at/expires_in/error,写入服务器状态并通过threading.Event唤醒等待线程——这正是 tauri-plugin-oauth 在 Readest 桌面应用里使用的同一招数。PROVIDERS = ('google', 'apple', 'github', 'discord')。
认证令牌的持久化与刷新由 api.py 承担:sign_in_password走/auth/v1/token?grant_type=password;ensure_fresh_token镜像readest_syncauth.lua的策略——令牌剩余寿命不足max(60, expires_in / 2)毫秒即刷新;on_tokens回调把每次变化的令牌写回 calibre 配置。
七、存储校验:uploaded_at 并不等于 blob 存在
这是插件迭代中最重要的一次协议修正(2026-07-25 用户报告驱动)。此前books.uploaded_at是插件判断“是否已在云端”的唯一信号,但有三条路径会让它陈旧地保持为 true:
- Manage Storage 删除文件:
apps/readest-app/src/pages/api/storage/delete.ts+purge.ts删除了对象与files行,却从不触碰books表; - 应用内选择“本地”方式删除书:
apps/readest-app/src/services/cloudService.ts只对deleteAction为cloud/both时清除uploadedAt; - 登出发生在行变更同步之前。
后果链条:plan_push因uploaded_at为真而选了update(只改行、不上传);对被 tombstone 但uploaded_at仍为真的行,pick_server_row的复活路径 +merge_for_push的deletedAt = None会把一本book_hash对应文件早已消失的书重新发布——书在书库可见,下载却 404(download.ts 找不到files行)。更糟的是无法通过重推修复:calibre 文件从未变化,row_source_hash == source_hash永远成立。
修复方案是对存储做真实核验(api.py 的list_all_files):
- 分页遍历
/storage/list,分页依据是totalPages而非批次长度——该端点会用同一本书的兄弟文件把每页填充完整,导致批次可能大于pageSize; - wire.py 的
cloud_book_hashes解析{user_id}/Readest/Books/{hash}/{name}形式的file_key提取仍有真实 blob 的 book hash,忽略cover.png——因为files.book_hash只在上传方显式传入时才被设置; plan_push(..., blob_present)据此把update/skip降级为replace;- 列出失败 ⇒
cloud_hashes = None⇒ 回退到旧的信任uploaded_at行为,推送照常运行,保证可用性优先。
配套的分页选择策略(wire.py 的should_bulk_list):分页整体列表每页一次请求、与选中数量无关;逐书查询每本一次请求。实测两种方式都在 1 秒左右,所以total_pages <= max(1, 选中书数)时走整体分页,否则对每本书list_files(hash)(按 run 缓存)。第 1 页反正已经请求了,故用<=。_blob_present在 worker.py 中优先查整体集合,否则查缓存,查询失败时保守地按 blob 存在处理。
八、状态标记与性能:marked:readest_missing
“Check Readest status” 功能(worker.py 的StatusWorker)对选中的书只做plan_push而不上传,然后把结果写成 calibre 的标记(mark),label 前缀统一为readest_(wire.py):
| plan 动作 | 标记 | 含义 |
|---|---|---|
new | readest_missing | 不在 Readest(还需推送) |
replace | readest_outdated | 文件过时 |
update | readest_metadata | 元数据有差异 |
skip | readest_synced | 已同步 |
因此 calibre 内可以用marked:readest_missing直接选中所有待推的书。标记合并在 wire.py 的merge_marks:只整体替换readest_前缀的标记,用户手设的标记原样保留;推送完成后用PUSHED_MARK(readest_synced)重标,避免状态检查留下的旧标记过期。
从 calibre 实测得出的两个 GUI 事实(写死在 ui.pyapply_marks):
View.set_marked_ids不会触发add_marked_listener,所以必须手动调用library_view.model().refresh_ids(ids)重绘(该方法能容忍 id 已被过滤出视图);marked_text_icon_for能渲染任意自定义 label。
性能优化同样是硬数据:整体list_all_files(1580 个文件)需 22.4 秒,而pull_books只需 3.2 秒;/storage/list单次请求约 1 秒,几乎与行数无关(1 个文件 0.93s,100 个文件 1.23s)。因此服务端把MAX_PAGE_SIZE从 100 提到 1000(apps/readest-app/src/pages/api/storage/list.ts),客户端LIST_PAGE_SIZE = 1000同步跟进;.in()分组扩展因 Supabase 会把它渲染进查询字符串,必须用chunkIds按 100/批分块。加上上述“page 1 决策 + 每书查询缓存 +blob_present最后检查”,单本书从 25.6 秒降到 4.8 秒。
九、构建、测试与版本同步
9.1 纯逻辑模块与 56 个单元测试
api.py、wire.py、oauth.py三个模块零 calibre / Qt 依赖(纯标准库),因此可以直接在 calibre 之外单测:
make test # python3 -m unittest discover -s testsmake test运行 56 个单元测试(tests/ 下test_client.py、test_hashes.py、test_oauth.py、test_version.py、test_wire.py)。在 calibre 内做冒烟测试则用:
calibre-debug -c "from calibre.customize.ui import find_plugin; ..."其中from calibre.customize.ui import find_plugin会初始化calibre_plugins命名空间,这是包导入正确工作的前提。
9.2 打包与版本单源
Makfile 的make zip构建dist/Readest-<version>.calibre-plugin.zip,而<version>由 sync_version.py 从apps/readest-app/package.json读取——应用 package.json 是版本的唯一事实来源(release.yml 的build-calibre-pluginjob 同样读取它)。因为插件以独立 zip 安装进 calibre,版本必须是init.py 里的字面量PLUGIN_VERSION,git 中提交的(0, 1, 0)只是开发占位符。sync_version.py的sync()仅在版本漂移时才重写文件(避免无谓触发重建),并且配套一个测试断言两者永不漂移;旧占位符的隐患是本地构建会以错误的版本号安装,导致 calibre 的 Preferences > Plugins 显示异常。make zip把version作为 order-only 前置依赖,make install则用calibre-customize -a装入本地 calibre。
十、安装与日常使用
从 release 资产下载Readest-<version>.calibre-plugin.zip,或自行构建:
make zip # 产出 dist/Readest-<version>.calibre-plugin.zip calibre-customize -a dist/Readest-*.calibre-plugin.zip # 或 make install也可以在 calibre 里通过Preferences → Plugins → Load plugin from file加载,重启后若工具栏未见Readest按钮则手动添加。日常使用三步(README.md):
- 点击Readest工具栏按钮菜单 →Log in to Readest…(邮箱密码或浏览器 OAuth);
- 选中任意数量的书;
- 点击Readest按钮(或菜单 →Push selected books to Readest)。
每本书会推送 Readest 支持的最佳格式,优先级为EPUB > PDF > AZW3 > MOBI > AZW > FB2 > FBZ > CBZ > TXT > MD(wire.py 的FORMAT_PRIORITY)。重推行为完全由第四节的四动作规划决定:无变化跳过、仅元数据变化原地更新行、文件变化则上传新 blob 替换旧条目并保留阅读进度/分组/状态/入库日期,标题与作者未变时笔记和阅读位置还会重新挂接(Readest 按元数据身份匹配书版本)。旧版本插件推送过的书(其条目哈希即原始文件指纹)同样能被识别,不会产生重复。
结语:一条可复用的“跨端写入云端书库”协议模板
回看整个插件的设计,最值得借鉴的不是某个具体函数,而是一组可迁移的工程原则:确定性内容哈希(partialMD5 + metaHash)让去重不依赖本机状态;双键身份(uuid 抗字节变化 + 原始文件指纹检测变化)让“更新”与“替换”边界清晰;显式 null 语义要求每次写入必须搬运服务端字段,否则静默清空;信任边界则提醒所有客户端——uploaded_at只是数据库字段,真实存在性必须向存储层求证。这些协议事实全部沉淀在 wire.py 与 api.py 的注释里,并被 tests/ 的 56 个测试锁死,是阅读源码时最值得逐行对照的部分。
【免费下载链接】readestReadest is a modern, feature-rich ebook reader designed for avid readers offering seamless cross-platform access, powerful tools, and an intuitive interface to elevate your reading experience.项目地址: https://gitcode.com/gh_mirrors/re/readest
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考