- 桌面应用
- 跨平台
- 前端
【免费下载链接】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.
本文以 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.png与config.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. 活行保护优先于重复墓碑
liveHashes用Set而非"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读取library与libraryLoaded传入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 均清除
Cache与Temp两个 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(含getOrphanedBookEntries、withoutLiveBookEntries、getClearableEntries三组用例)共同锁定了上述行为。
修复的验证、状态与遗留问题
文档明确记载:PR #5851 于 2026-08-24 以ded443512(3 个 commit 的 squash:修复、评审加固、覆盖测试)合入,完整测试套件 809 个文件 / 9979 个测试全部通过;当时尚未进入任何发布 tag(最新为 v0.12.1),将随 #5665 一起发布,真机验证(Xiaomi)当时仍在进行中。这些属于文档记录的项目事实,发布情况请以仓库最新版本为准。
文档同时记录了三条未完成事项,可作为后续阅读源码时的线索:
- 没有启动时对账逻辑("磁盘
Books/vs 书库"的应不应该长期存在的诉求)——孤儿回收仍依赖用户主动打开 Manage Cache; - 桌面端没有 Manage Cache 入口,桌面用户暂时无法在应用内回收孤儿文件;
- 面向报告者的诊断方法:在备份 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.
相关推荐
探索二进制体积膨胀:cargo-bloat
探索二进制体积膨胀:cargo bloat cargo bloat 是一个强大的工具,专门用于检测 Rust 应用程序可执行文件中的空间占用大户。它支持 ELF
开发工具告别Vue项目体积膨胀:webpack-bundle-analyzer组件级优化实战指南
告别Vue项目体积膨胀:webpack bundle analyzer组件级优化实战指南 你是否曾遇到Vue项目打包后体积暴增,首屏加载慢到让用户流失?是否在优
FlashAttention终极指南:5步搞定高性能注意力机制编译与优化
FlashAttention终极指南:5步搞定高性能注意力机制编译与优化 在当今大模型时代,Transformer架构已成为AI研究的核心支柱,然而其核心组件—
人工智能大模型算子库
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考