ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

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

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.py、wire.py、oauth.py、worker.py、ui.py等源码与 56 个单元测试系统讲解图书身份哈希算法、OPF 元数据嵌入与 uuid 去重、POST /sync的显式 null 语义、基于 localhost 的 OAuth 登录中继以及uploaded_at不可信场景下的存储校验兜底。读完你将掌握该插件“推书不重复、改文件即替换、改元数据只更新行”的核心机制以及它在实际版本迭代中踩过并修复的关键协议坑。一、插件定位与功能全景readest-calibre-plugin是 Readest 生态中与 readest.kopluginKOReader 插件并列的桌面端桥梁目标用户是重度使用 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.pycalibre 插件入口、api.pyHTTP 客户端 内容哈希、wire.pycalibre 元数据 → wire 记录与推送规划、oauth.pylocalhost OAuth 回调、worker.py后台推送 QThread、ui.pycalibre 界面动作、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.hashKOReader 兼容的 partial MD5partial_md5不计算整个文件的 MD5而是采样若干 1024 字节块。块偏移序列来自 api.py 的_partial_md5_rangesfor 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完全对齐。这个算法源自 KOReaderBook.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 双重哈希的分工bookHashBook.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.mdcalibre 书 uuid写进行条目的metadata.identifierurn: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 命名空间旧行 tombstoneupdate文件未变但元数据/封面/墓碑状态有差异仅更新行不重新上传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 记录中缺席的字段做显式置 nullexplicit-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封面本次推送了封面则写新coverHashcoverUpdatedAt否则沿用行值metadataUpdatedAt应用侧按字段级 LWWmetadata_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_recorddeletedAt now_ms的软删除记录见 wire.py随后 best-effort 地list_filesdelete_file清理旧 hash 命名空间的云端文件以回收配额。六、非应用客户端的 OAuthlocalhost 片段中继从非 Web 客户端本插件、Flatpak 桌面版的自定义 OAuth 路径完成浏览器登录的关键事实oauth.pySupabase 允许把 OAuth 重定向到白名单地址{supabase}/auth/v1/authorize?providerXredirect_tohttp://localhost:PORT但 token 出现在 URL 的fragment#之后里而 fragment 永远不会到达 HTTP 服务器。因此插件起一个绑定127.0.0.1:0临时端口的HTTPServer首次响应返回一段内嵌脚本的着陆页LANDING_PAGEscript 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_typepasswordensure_fresh_token镜像readest_syncauth.lua的策略——令牌剩余寿命不足max(60, expires_in / 2)毫秒即刷新on_tokens回调把每次变化的令牌写回 calibre 配置。七、存储校验uploaded_at 并不等于 blob 存在这是插件迭代中最重要的一次协议修正2026-07-25 用户报告驱动。此前books.uploaded_at是插件判断“是否已在云端”的唯一信号但有三条路径会让它陈旧地保持为 trueManage Storage 删除文件apps/readest-app/src/pages/api/storage/delete.tspurge.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对应文件早已消失的书重新发布——书在书库可见下载却 404download.ts 找不到files行。更糟的是无法通过重推修复calibre 文件从未变化row_source_hash source_hash永远成立。修复方案是对存储做真实核验api.py 的list_all_files分页遍历/storage/list分页依据是totalPages而非批次长度——该端点会用同一本书的兄弟文件把每页填充完整导致批次可能大于pageSizewire.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 的标记marklabel 前缀统一为readest_wire.pyplan 动作标记含义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_marksView.set_marked_ids不会触发add_marked_listener所以必须手动调用library_view.model().refresh_ids(ids)重绘该方法能容忍 id 已被过滤出视图marked_text_icon_for能渲染任意自定义 label。性能优化同样是硬数据整体list_all_files1580 个文件需 22.4 秒而pull_books只需 3.2 秒/storage/list单次请求约 1 秒几乎与行数无关1 个文件 0.93s100 个文件 1.23s。因此服务端把MAX_PAGE_SIZE从 100 提到 1000apps/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_VERSIONgit 中提交的(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 MDwire.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),仅供参考
返回列表