news 2026/9/20 22:29:53

Readest Calibre 插件推送协议深度解析:从图书哈希、去重策略到 OAuth 中继与存储校验

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Readest Calibre 插件推送协议深度解析:从图书哈希、去重策略到 OAuth 中继与存储校验

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.pywire.pyoauth.pyworker.pyui.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::partialMD5utils/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_copyshutil.copyfile复制库文件到临时路径(前缀readest-),再调用calibre.ebooks.metadata.meta.set_metadata(stream, mi, ext)把元数据写入副本。对 EPUB 而言 calibre 的嵌入是确定性的;自定义列会写入calibre:user_metadata。没有元数据写入器(或写入失败)的格式则回退为未修改的副本。临时副本用完即删,本地库文件始终只读。

3.2 双键去重

推送的书由两个键共同追踪(README.md):

  1. calibre 书 uuid:写进行条目的metadata.identifierurn:uuid:...),即使文件字节变化也能跨推送识别“这本 calibre 书已在 Readest”;
  2. 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_onereplace分支)一次POST /sync提交两条记录:新书行 + 旧行tombstone_recorddeletedAt = 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改写成查询参数转发到/callbackparse_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=passwordensure_fresh_token镜像readest_syncauth.lua的策略——令牌剩余寿命不足max(60, expires_in / 2)毫秒即刷新;on_tokens回调把每次变化的令牌写回 calibre 配置。

七、存储校验:uploaded_at 并不等于 blob 存在

这是插件迭代中最重要的一次协议修正(2026-07-25 用户报告驱动)。此前books.uploaded_at是插件判断“是否已在云端”的唯一信号,但有三条路径会让它陈旧地保持为 true

  1. Manage Storage 删除文件apps/readest-app/src/pages/api/storage/delete.ts+purge.ts删除了对象与files行,却从不触碰books表;
  2. 应用内选择“本地”方式删除书apps/readest-app/src/services/cloudService.ts只对deleteActioncloud/both时清除uploadedAt
  3. 登出发生在行变更同步之前

后果链条:plan_pushuploaded_at为真而选了update(只改行、不上传);对被 tombstone 但uploaded_at仍为真的行,pick_server_row的复活路径 +merge_for_pushdeletedAt = 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 动作标记含义
newreadest_missing不在 Readest(还需推送)
replacereadest_outdated文件过时
updatereadest_metadata元数据有差异
skipreadest_synced已同步

因此 calibre 内可以用marked:readest_missing直接选中所有待推的书。标记合并在 wire.py 的merge_marks:只整体替换readest_前缀的标记,用户手设的标记原样保留;推送完成后用PUSHED_MARKreadest_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.pywire.pyoauth.py三个模块零 calibre / Qt 依赖(纯标准库),因此可以直接在 calibre 之外单测:

make test # python3 -m unittest discover -s tests

make test运行 56 个单元测试(tests/ 下test_client.pytest_hashes.pytest_oauth.pytest_version.pytest_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.pysync()仅在版本漂移时才重写文件(避免无谓触发重建),并且配套一个测试断言两者永不漂移;旧占位符的隐患是本地构建会以错误的版本号安装,导致 calibre 的 Preferences > Plugins 显示异常。make zipversion作为 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):

  1. 点击Readest工具栏按钮菜单 →Log in to Readest…(邮箱密码或浏览器 OAuth);
  2. 选中任意数量的书;
  3. 点击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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/20 22:29:32

STM32 ADC+DMA压力采集实战:XGZP6847A高精度采样方案

1. 为什么压力采集项目里ADC加DMA是绕不开的组合做过压力变送器、液位检测或者气路监控的朋友应该都有体会&#xff0c;传感器输出的模拟信号本身并不难读&#xff0c;难的是持续、稳定、不丢点地把数据搬进内存。XGZP6847A 这颗压力传感器在工业现场和消费类设备里出镜率很高&…

作者头像 李华
网站建设 2026/9/20 22:29:28

如何给OpenMMO贡献代码?CLA签署与CI检查新手完整指南

如何给OpenMMO贡献代码&#xff1f;CLA签署与CI检查新手完整指南 【免费下载链接】OpenMMO 项目地址: https://gitcode.com/GitHub_Trending/open/OpenMMO OpenMMO 是一个用 Rust 构建、AI 智能体与人类玩家平等游玩的开源 3D MMORPG。新手给 OpenMMO 贡献代码时&#…

作者头像 李华
网站建设 2026/9/20 22:27:25

VMware 16搭建Linux开发环境全攻略

1. 项目概述在Windows系统上搭建Linux开发环境是很多开发者的刚需。VMware Workstation 16作为目前最稳定的虚拟机平台之一&#xff0c;能够完美解决双系统切换麻烦、云服务器延迟高、Docker环境不完整等问题。我自己从2015年开始就坚持使用VMwareLinux的组合&#xff0c;累计创…

作者头像 李华
网站建设 2026/9/20 22:24:53

BoxMOT 多目标跟踪部署选型指南:CPU、GPU 与 TPU 怎么挑

BoxMOT 多目标跟踪部署选型指南&#xff1a;CPU、GPU 与 TPU 怎么挑 【免费下载链接】boxmot BoxMOT: Pluggable Python and C SOTA multi-object tracking modules with support for axis-aligned and oriented bounding boxes 项目地址: https://gitcode.com/GitHub_Trendi…

作者头像 李华
网站建设 2026/9/20 22:23:53

LeRobot 实战指南:一条命令链跑通机器人数据采集与策略训练

LeRobot 实战指南&#xff1a;一条命令链跑通机器人数据采集与策略训练 【免费下载链接】lerobot &#x1f917; LeRobot: Making AI for Robotics more accessible with end-to-end learning 项目地址: https://gitcode.com/GitHub_Trending/le/lerobot 当你需要让机械…

作者头像 李华