news 2026/9/20 22:45:06

Readest 备份体积膨胀与孤儿书籍文件治理:从 issue 5837 到 `getOrphanedBookEntries` 的完整实战解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Readest 备份体积膨胀与孤儿书籍文件治理:从 issue 5837 到 `getOrphanedBookEntries` 的完整实战解析
  • 桌面应用
  • 跨平台
  • 前端

【免费下载链接】readest

Readest 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 仓库内.claude/memory/backup-orphan-book-files-5837.md为骨架,结合 备份服务 与 缓存工具 的源码与测试,完整还原 Android 备份 zip 携带大量"不在书库中的书籍文件"这一问题的根因、三条孤儿目录产生路径、以及备份导出与 Manage Cache 两侧的修复方案。读完你可以理解 Readest 的Books/<hash>/目录布局、deletedAt软删除语义,并掌握如何通过仓库源码定位与验证此类"磁盘与书库不一致"问题。

问题现场:备份 zip 里出现了书库里不存在的书

issue #5837 由一位 Android 16、Readest 0.12.1 的 OPDS + KOSync 用户报告:备份生成的 zip 中携带了数百个在书库界面中根本不存在的 EPUB/PDF 文件,且"刷新元数据"(Refresh Metadata)与"管理缓存"(Manage Cache)都无法让备份变小。

这个现象在 Readest 的文件模型下并不难理解:所有书籍内容都存放在用户数据目录的Books/<hash>/子目录下(hash 即书籍记录的主键),而备份逻辑在此之前的行为是直接遍历整个Books/树、把所有文件都打进 zip。只要磁盘上存在某个没有任何"存活"书库记录引用的Books/<hash>/目录,该目录就会静默进入备份。而由于恢复逻辑(restoreFromBackupZip,自 #3571 起)有意导入这些"孤儿 hash 目录"来抢救数据,这一隐患长期没有被发现——备份把垃圾带出去,恢复又把它带回来,两端的"宽容"互相掩盖。

备份侧根因:addBackupEntriesToZip从不询问书库

修复前的关键缺陷位于 addBackupEntriesToZip(对应 PR #5851 的改动说明):

  • 备份流程先通过appService.loadLibraryBooks()加载书库,但导出文件时完全没用这份数据
  • 它调用appService.readDirectory(booksDir, 'None')枚举Books/目录下的所有文件,仅排除一个硬编码的LIBRARY_META_FILES集合;
  • 结果就是:只要Books/<hash>/没有对应的活跃记录(deletedAt为空的行),该目录下的所有文件都会被导出。

这也是为什么用户观察到的 zip 里"数百个 EPUB/PDF"与书库 UI 完全对不上——它们本就不该出现在书库中。

孤儿目录的三条产生路径(均有代码依据)

根据文档与仓库代码交叉验证,Books/<hash>/中出现无主文件目录主要来自三条路径:

路径一:OPDS 自动下载"复活"墓碑行(最可能的主因)

OPDS 自动下载在重新拉取文件时,只在内存中复活一条已被墓碑化(tombstoned)的书籍记录,磁盘上的行仍保留deletedAt时间戳。相关的修复(#5665,commit34922b172不在 0.12.1 发布版中(文档明确记载git merge-base --is-ancestor验证为否),因此 0.12.1 用户仍会持续产生这类孤儿目录,报告中大概率走的正是这条路径。

路径二:导入中断的 kill window

importBook会先把文件复制进Books/<hash>/之后调用方才执行saveLibraryBooks保存记录。Android 进程在两步之间被系统杀死时,磁盘上留下完整文件、书库中却没有行。文档将其称为 "files-before-row" 窗口,OPDS 按目录批量持久化与备份恢复同样存在此窗口。

路径三:云同步墓碑永远不删本地文件

仓库中没有任何同步路径调用appService.deleteBook,真正删除书籍文件的只有三个入口:书库页面的用户操作、transferManager'cloud'删除动作)与deleteLibraryService'purge'清除动作)。因此从另一台设备拉回的deletedAt墓碑只会隐藏行、保留本地 EPUB。

文档还特别指出一个关键设计:普通"删除"('local'动作)只移除受管书文件,刻意保留cover.pngconfig.json,以便重新下载时无缝续传——所以每一个墓碑行都仍然"拥有"一个目录。deletedAt行始终留在 store 的library数组中,只是被visibleLibrary过滤掉(见 libraryStore.ts)。

备份侧修复:只导出活跃书籍的目录

PR #5851(2026-08-24 合入,squash commitded443512)对备份逻辑的核心改动如下,这些规则在 backupService.ts 中可直接看到:

// 只保留未删除(!deletedAt)书籍的 hash const liveHashes = new Set(books.filter((b) => !b.deletedAt).map((b) => b.hash)); const isExported = (path: string) => { const dir = getBookDirOfPath(path); return !!dir && (books.length === 0 || liveHashes.has(dir)); };
  • liveHashes白名单Books/下只有属于活跃(非deletedAt)书籍的<hash>/目录才会导出;
  • 移除LIBRARY_META_FILES硬编码:根级元数据文件(如library.json.bak)因getBookDirOfPath解析不到目录而自然被排除;
  • 空库回退:当书库一行都没有时(例如library.json损坏、safeLoadJSON返回[]),books.length === 0使isExported恒真,恢复导出所有目录——此时磁盘是唯一副本,恢复端的孤儿导入(restoreFromBackupZip)可以据此重建整个书库;
  • Windows 路径归一化readDirectory返回主机分隔符路径,Windows 下是反斜杠(hash\cover.png),导出前统一replace(/\\/g, '/')为 zip 条目名,保证备份可在任意平台恢复(issue #4703 的回归保障);
  • 排除离线 Audiobookshelf 音频abs-offline/下的音频(#6256)可重新下载且体积可达 GB 级,被isAbsOfflineEntry过滤,避免备份把可重建的音频逐文件读入内存。

配套的回归测试 backup-orphan-files.test.ts 用LIVE_HASH / DELETED_HASH / ORPHAN_HASH三个假 hash 验证:跳过无行目录与软删除书籍目录、空库时导出所有目录、全部软删除时导出空集、Windows 反斜杠路径正确匹配、离线音频不导出。同时 backup-windows-paths.test.ts 的 fixture 现在持有BOOK_HASH,防止路径解析回归。

恢复侧保持不变:旧备份中的孤儿 hash 目录仍会被导入(依据 zip 中library.json里不存在的 hash 判断),因此历史备份依旧可以完整恢复。

缓存侧修复:getOrphanedBookEntries的完整回收规则

备份侧"不再导出"解决了脏数据外流,但磁盘上的孤儿文件本身还需要一个回收入口。修复在 cache.ts 中新增了getOrphanedBookEntries(appService, books),返回CacheEntry[]base'Books'),把孤儿文件并入 Manage Cache 的扫描与清除。其规则集合是本次评审加固(review hardening)的重点,全部有测试覆盖:

1. 绝对路径扫描触发原生快路径

扫描Books/必须用appService.resolveFilePath('', 'Books')拿到绝对路径、再以 base'None'调用readDirectory。若按 base-relative 读取,会解析成 Tauri 的 baseDir,错过 Rust WalkDir 的原生快路径,退化为每个条目一次 IPC 往返。注释明确说明getCacheEntries(Cache/Temp 的小目录)仍保留慢路径是既有行为。

2. 副产物永不回收

KEPT_SIDECARS = new Set(['cover.png', 'config.json', 'nav.json']),加上audiobook/**目录下的配对音频:普通删除刻意保留封面、进度与笔记,临时"打开"的文件也需要config.json承载进度;config.json引用配对音频,只有removePairedAudiobook能清除该关联。因此任何无主目录中的这些文件都不可回收

3. 新鲜度守卫(ORPHAN_SETTLE_MS)

ORPHAN_SETTLE_MS = 60 * 60 * 1000(1 小时):目录 mtime 距今不足 1 小时则跳过。这覆盖importBook的 files-before-row 窗口、OPDS 按目录持久化与恢复的批量写入——没有任何导入器暴露 in-flight 信号,只能靠时间窗兜底。守卫通过新增的AppService.stats()实现(见 types/system.ts),每个候选目录只 stat 一次(测试断言stats被调用且仅调用一次);stat 失败、未知或空 mtime 一律按"未稳定"跳过,绝不靠猜测提供删除项

4. 零行书库 = UNKNOWN,而非 empty

library.loaded && library.books.length > 0才纳入孤儿扫描(getClearableEntries,cache.ts):未加载的书库会把每本磁盘书都误判为孤儿;library.json损坏时safeLoadJSON返回[],此时这些 hash 目录是唯一副本,Manage Cache 不提供任何孤儿项,而备份端回退导出所有目录。两端的"UNKNOWN 不动作"原则是一致的。

5. 活行保护优先于重复墓碑

liveHashesSet而非"last-wins Map":即便遗留的library.json对同一 hash 同时存在活行与墓碑行(加载时不去重),任何一条活行都保护整个目录。

6. 清除时二次校验与失败上报

清除使用扫描时快照(state)而非重新扫描;确认前若有同步/导入为某目录补上了活行,withoutLiveBookEntries会把这些条目从删除集合中剔除。删除逐个进行、失败计数不中断循环,failed > 0时复用既有 i18n keyFailed to delete {{count}} file(s)上报(CacheManagerWindow.tsx)。清除后遗留空的Books/<hash>/目录是无害的——AppService没有removeDir能力。

Manage Cache 的 UI 集成与用户提示

CacheManagerWindow(Advanced Settings > Manage Cache,移动端专用,由 SettingsMenu.tsx 打开)把孤儿文件折入扫描与清除流程:

  • 状态机scanning → idle → confirming → clearing → done/error,扫描与清除均走进度条;
  • 扫描时从useLibraryStore读取librarylibraryLoaded传入getClearableEntries,孤儿仅在库已加载且非空时计入;
  • 存在孤儿时额外显示"Includes {{count}} orphaned book file(s) not in your library",确认语句升级为更强的"This will delete all cached files and orphaned book files not in your library. This cannot be undone."(CacheManagerWindow.tsx);
  • 移动端来源:iOS/Android 均清除CacheTemp两个 base,iOS 额外清除Documents/Inbox("Open in Readest" 留下的已导入副本,否则永久残留);
  • i18n:2 个新 key 通过脚本按各 locale 的{{count}} files复数后缀集追加到 34 个语言文件,en 获得_one/_other变体。

组件级测试 cache-manager-window-orphans.test.tsx 与工具级测试 cache.test.ts(含getOrphanedBookEntrieswithoutLiveBookEntriesgetClearableEntries三组用例)共同锁定了上述行为。

修复的验证、状态与遗留问题

文档明确记载:PR #5851 于 2026-08-24 以ded443512(3 个 commit 的 squash:修复、评审加固、覆盖测试)合入,完整测试套件 809 个文件 / 9979 个测试全部通过;当时尚未进入任何发布 tag(最新为 v0.12.1),将随 #5665 一起发布,真机验证(Xiaomi)当时仍在进行中。这些属于文档记录的项目事实,发布情况请以仓库最新版本为准。

文档同时记录了三条未完成事项,可作为后续阅读源码时的线索:

  1. 没有启动时对账逻辑("磁盘Books/vs 书库"的应不应该长期存在的诉求)——孤儿回收仍依赖用户主动打开 Manage Cache;
  2. 桌面端没有 Manage Cache 入口,桌面用户暂时无法在应用内回收孤儿文件;
  3. 面向报告者的诊断方法:在备份 zip 的library.json中 grep 孤儿 hash——存在(带deletedAt)对应路径一/三(墓碑复活),不存在则对应路径二(导入中断)

小结:从一次备份事故读出的文件生命周期

issue #5837 的完整修复链条可以概括为三句话:备份端只带活数据(liveHashes白名单 + 空库回退),缓存端回收无主数据(getOrphanedBookEntries的五条保护规则),恢复端保持宽容(孤儿导入原样保留以兼容旧备份)。对 Readest 的使用者而言,若备份 zip 过大,升级到包含该修复的版本后,可在移动端通过"高级设置 → 管理缓存"安全回收孤儿文件;对想要深入代码的读者,backupService.ts、cache.ts 与两份配套测试是理解Books/<hash>/目录模型与deletedAt软删除语义的最佳入口。

  • 桌面应用
  • 跨平台
  • 前端

【免费下载链接】readest

Readest 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
点击查看免费下载
上一篇:微信聊天记录永久保存终极指南:WeChatMsg免费工具三步搞定
下一篇:零信任时代的LocalAI数据安全:从传输加密到存储防护全攻略

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

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

PixiEditor 自定义笔刷指南:3 步做出有呼吸感的粒子效果

PixiEditor 自定义笔刷指南&#xff1a;3 步做出有呼吸感的粒子效果 【免费下载链接】PixiEditor PixiEditor is a Universal Editor for all your 2D needs 项目地址: https://gitcode.com/GitHub_Trending/pi/PixiEditor PixiEditor 把笔刷当作可编辑的节点图来管理&a…

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

论文理论模型图怎么画 —— 一张图讲清楚你的研究框架

实证论文中&#xff0c;一张理论模型图胜过千言万语。把自变量、因变量、中介变量、调节变量用方框和箭头画出来&#xff0c;读者一眼就明白你在研究什么关系。汇写&#xff08;https://www.huixielunwen.com/tool/graduationThesis&#xff09;的科研绘图功能可以帮你生成理论…

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

BERT+BiLSTM+CRF实现医学命名实体识别与知识图谱构建实践

简介&#xff1a;面向医学知识图谱构建的实体识别综合资源&#xff0c;以BERTBiLSTMCRF三类模型融合为主线&#xff0c;配有完整数据与代码&#xff0c;适合自然语言处理初学者、进阶者、医疗AI研发人员以及想落地知识图谱的工程师。压缩包共1162个文件&#xff0c;约25.18MB&a…

作者头像 李华