我的 mixed sheets (书架显示, 系统设置, OPDS nested in the shelf sheet) with pages (外观与主题, 影音浏览, 播放), and the personal preferences were four separate rows. - 偏好设置 (#/me/settings) holds 外观与主题, 书架, 影音浏览 and 播放 as sections of one page, with a jump bar and ?section=; every control applies at once (影音浏览 loses its save button and unsaved-changes prompt). The shelf's gear still opens the same fields (ShelfSettingsFields) as a sheet shortcut. - 系统设置 becomes three admin pages of 我的 › 管理: 用户管理, 注册与邀请, 服务配置 (#/me/admin/*, lazily loaded). - 连接外部阅读器 (OPDS) is a page (#/me/opds) under 账号, also reached from the 书架 section and the shelf sheet; back returns to whoever opened it. - Old /me/theme|browse|playback and /media/:channel/settings[/:panel] redirect to the matching section; /me/account keeps its page. - Drops MediaSettingsPage and the CSS only it used; docs follow the new paths. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
57 KiB
架构与数据边界
本文记录为什么这样设计,尤其是那些一旦写错就很难回头的决策。 早期产品设计中的约束在这里落成了实现,逐条对应;当前行为以代码和 API 文档为准。
1. 数据边界
| 位置 | 内容 | 读写 |
|---|---|---|
BOOKS_DIR (/books) |
用户自己的电子书;管理员可执行文件管理操作,成员只能上传书籍 | 按挂载权限读写 |
DATA_DIR (/data) |
SQLite、封面缓存、扫描记录、上传暂存、令牌密钥和影音数据 | 读写 |
| 客户端本地 | 离线书文件缓存、本地进度 | 读写 |
服务端是唯一真相源。客户端只承担「渲染 + 缓存」。
数据目录边界
src/config/index.ts 在启动时断言 DATA_DIR 不在 BOOKS_DIR 内,否则拒绝启动。这是运行时硬约束。
BOOKS_DIR 是否只读由部署挂载权限决定:只读挂载适合浏览和阅读;可写挂载允许管理员文件管理器上传、改名、移动、删除和新建目录;成员只能在书库浏览页上传书籍文件(只收书籍格式、不解包、不覆盖),且可由业务设置 library.memberUpload 整体关闭,改动已有文件的端点都要求管理员(requireAdmin,见 API)。
扫描器与所有文件访问都经过 src/lib/paths.ts 的 resolveInside()——
唯一的路径解析出口,../ 逃逸会抛 PATH_TRAVERSAL。
扫描时也不跟随符号链接:指向挂载点外的软链会读到用户没打算共享的文件。
封面缓存写在 DATA_DIR/covers/,不写在书旁边;服务端也不会创建 .calibre 或其它书旁元数据文件。
2. 书籍主键
id = sha256( (dc:identifier ?? '').toLowerCase() + '\0' + sha256(fileBytes) )
为什么不用文件路径
用户的真实行为是持续整理书库:改名、分目录、加前缀。
| 场景 | 路径做主键 | 当前设计 |
|---|---|---|
重命名 三体.epub → 刘慈欣 - 三体 (修订版).epub |
进度全丢 | 进度保留 |
| 移动到另一个目录 | 进度全丢 | 进度保留 |
| 同一本书放两份 | 书架出现两条 | 合并成一条 |
| 重新编码的同名文件 | 误认为同一本 | 视为不同副本 |
第二列是自建书库产品最常见的差评来源,所以这条必须在上线前定死。
为什么哈希也要参与
只用 dc:identifier 不行:很多自制/盗版 EPUB 没有 identifier,或者多本书共用
一个占位 identifier。只用哈希也不行:同一本书不同来源的副本哈希不同,无法合并。
两者结合:identifier 提供稳定的锚点,哈希保证「字节不同就是不同的副本」。
identifier 缺失时退化为纯哈希,行为仍然正确(只是无法跨副本合并)。
文件表与书表的分离
book_files 记录磁盘上的文件(rel_path、size、mtime),books 记录书。
多对一:一本书可以有多份副本。
文件被删时 missing=1,不立即删记录——用户可能只是临时拔了移动硬盘。
但一旦这本书已经有了别的可用文件,旧的行就是纯噪声,会被清掉,
否则反复整理书库会让文件表无限增长。
只有「没有任何可用文件」且「用户没手动加进书架」的书才会被删除。 宁可显示一本暂时不可用的书,也不能删掉读者精心整理过的书架条目。
3. 增量扫描
两阶段变更检测(Scanner.indexFile):
mtime + size:便宜,跳过绝大多数文件- 内容哈希:权威,确认字节真的变了
只有第一阶段判断"可能变了"才读文件算哈希。所以每 60 秒轮询一次(默认值, 管理员可在「我的 → 服务配置」里改扫描间隔)也很轻。
Scanner.scan() 对并发调用做去重(共享同一个 Promise),
避免定时器和手动触发同时砸磁盘。
4. 元数据优先级链
1. 手动修改(metadata_overrides 表)
2. EPUB 内嵌(dc:* + calibre meta)
3. provider 补丁(只填空缺)
4. 文件名解析
关键实现细节
books 表存的是第 2/3/4 层的结果。第 1 层单独一张表,读取时叠加
(ShelfService.toDto → applyOverrides)。
这样做的原因:扫描器可以放心地刷新 books 表,因为手动修改根本不在那张表里,
不可能被覆盖。
books.source 记录的是基础值的来源(embedded / filename / provider:x),
不记录 manual。手动修改过的字段由 DTO 的 manualFields 单独返回。
保持来源干净,撤销单个字段时才能正确回落到原始值。
文件名解析的保守策略
中文电子书里 书名 - 作者 和 作者 - 书名 都极常见,没有标记时无法区分。
因此只在有证据时才切分:
《书名》→ 这一侧是书名著/编/译→ 这一侧是作者- 一侧是中文人名(2-4 字且首字为常见姓氏)→ 另一侧是书名
- 一侧是拉丁文人名 → 另一侧是书名
- 都不满足 → 整串保留为书名,作者留空
最后一条是刻意的:漏一个作者,读者手动补一下就好;猜错一个作者, 书就被归到错误的人名下,而且他未必会发现。
其余正则也踩过坑,值得记下来:
- 系列正则
[【\[]...[】\]]里\[在字符类中不转义,会把分隔符' - '吃掉, 导致金庸 - 射雕英雄传变成title=金庸, author=射雕英雄传。改成显式括号对匹配。 - 「(第1卷)」这种卷标,书名是作品本身,不是「第」。要先剥离再切分作者,
且剥离要按完整括号组做,否则会留下
射雕英雄传(第1卷这样的半截括号。
5. 同步与冲突合并
为什么是 last-writer-wins
一台设备属于一个读者。多设备之间不存在"编辑冲突",只存在"某台设备落后了"。 所以 LWW 是正确语义,不需要 CRDT 或向量时钟的复杂度。
墓碑
笔记删除写 deleted=1 而不是 DELETE。
否则:手机离线时删了一条笔记 → 平板(也离线,数据更旧)重连 → 把笔记推回来 → 已删除的笔记复活。墓碑让这个时序变得无害。
批次原子性
SyncService.push 整个批在一个事务里。断网不会留下写了一半的进度。
「最近阅读」是书架的一个排序,不是第二排封面
SyncService.recentlyRead 注入 ShelfService,把书行的组装交给它,自己只负责
「按最近阅读时间排序」和那两个进度字段。
理由是一次已经发生过的漂移。#40 的「书架上点击继续阅读,提示书本不在书架上」 的根因就在这条缝里,而且是两层:
- 可见性判据曾经是
hidden = 0就完了,而书架还要求「存在一个未丢失的文件」。 于是一本文件被删掉、或唯一副本在没插上的移动盘上的书,仍然被画在「继续阅读」 这一行里,点开必然 404 —— 那句话是真的,错的是那张卡片,而它正画在同一屏 「共 N 本」的下面。修法是把判据做成同一句话,而不是教客户端先查一遍: 先查一遍只会把「读者点不开的书」修成「读者看不见的书」。 - 字段形状:端点返回进度行自己的命名(
bookId/updatedAt),而客户端的ContinueReadingItem extends Book读id。title和coverUrl恰好两种拼法 同名,所以卡片画得好好的,点下去却把{ id: undefined }交给路由——#/book/undefined、404、然后被报成「这本书不在书架上了」。 两个手写类型之间的字段名漂移,中间没有任何一个请求里出现过正确的那个名字。
一条规则:一个屏幕上的同一本书,只能是同一个对象。 所以书行只在
ShelfService 里组装一次(toDto / getMany),其它接口要书行就调它;
「最近更新」被覆盖层影响,卡片也得跟着变。
那条「继续阅读」横排本身已经没有了,而它消失的方式正是上面这条规则的另一半:
十张卡片画的是下面网格里已经有的那十本。现在同一个 /library/continue 端点还在被调,
但它只是排序键——recent 这个顺序需要 lastReadAt,而那不在 BookDto 上。
于是「同一本书只有一个对象」推进了一步:同一本书在一屏上也只有一张封面,
而“我读到哪了”由那张封面自己的进度条回答。
「在不在书架上」只有一句话,两个屏幕都念它
一次报告(#40「补全完善书架的加入、移除逻辑」)把同一个问题从两端问了一遍, 两端答得不一样,而且各自看起来都对:
-
服务端:
/books说「user_books.hidden = 0且存在一个未丢失的文件」,/library/browse的shelfState只读hidden = 0。一行hidden = 0、 文件掉了的书于是同时是「不在书架上」(书架)和「在书架上」(书库文件页)。 把这两种状态分开描述,等于让两个屏幕各自说一半真话,而读者没有任何办法知道 哪一半是旧的。 现在shelfState走的是同一对条件,'off'就是「书架上没有它」。 -
客户端:书库浏览页和书架页各自实现了一遍「书 ↔ 文件」的 join(按文件主名匹配), 以及各自一套动作文案。两份实现就是两次漂移机会,而漂移的形态是同一屏上 同一本书两个名字——这不是假设,这个 bug 在「最近阅读」那条缝里已经发生过一次 (§5)。
那个 join 后来被整个删掉了,而且它应该更早就被删掉:它不是一个实现得不够好的 join,它是一个不需要存在的 join。卡片手里有
book.id,服务端的book_files早就知道这个 id 的文件在哪,客户端却拿书名去和文件名对——而书名不等于文件名是 常态(扫描器从文件里读书名),列表还是分页的。「找不到「xxx」在磁盘上的路径」 就是这样来的。写入现在带 id(docs/api.md§/library/browse/shelf), 两个方向的措辞留在web/src/ui/shelf-membership.ts,两个屏幕都调它, 而「书 ↔ 文件」的连接不在客户端。这条值得当成一条规则:客户端不应该重建服务端已经知道的事实。 一个 join 只要有一端是猜的,它的失败就长得像「这本书不存在」,而不是像 「这个 join 猜错了」——读者拿到的是一句关于他自己书架的错误陈述。
为什么「写完之后要重读」是这条规则的必然结果。 shelfState 是响应字段,
不是客户端的记忆:书库页写入之后如果只弹一句报告,卡片上的「加入书架」按钮就还在,
而它的下一次点击什么都不会发生。写入路径因此在同一个 try 里先重读、再报告——
顺序不能反,反了就是「重读把报告覆盖掉」的那个老问题(§5 里 manager-status
已经为此改过一次)。
「移除」这件事为什么必须在书架那一屏。 书库曾经能下架,但当时书库是管理员那一半,
成员打不开(现在书库浏览对所有人开放,只有 #/library/files 文件管理要求管理员)。
只有书库能下架,等于只有管理员能整理自己的书架。
所以书架上每张封面自己带一个「⋯」,写入前用一个确认框说清「磁盘上的文件一个都不会动」——
那句话才是读者真正在问的。docs/ui.md §3.5.2/§3.5.3 写的是它的形状与命中区。
两个方向现在只在读者那两屏上,而文件页只剩「写盘」。 #40 的下一轮把文件页的
「从书架拿掉」拿掉了,理由不是位置而是语义:文件页上其他每一个控件都改挂载盘,
而 下架 改的是一行 user_books、一个文件都不动。它因此是那个菜单里唯一后果和页面
承诺对不上的条目,而它挨着的就是「删除」。docs/ui.md §3.5.1。
浏览页问的是索引,不是读者自己的书架。 它原来读 /books(也就是书架),
于是返回的每一本都已经在书架上——这一页存在的那个动作(「加入书架」)永远画不出来:
一个商城只陈列你已经买过的东西。现在它读 scope=library(docs/api.md),
每一项带 shelfState,于是「这一页有 N 本还不在书架上」和封面上的角标说的是同一件事。
离线镜像是缓存,所以「少了一本书」它自己推不出来。 offline.replaceBooks
只会加:它拿到的是一页书,它没有任何办法知道「这一页里没有的那本书」是读者刚拿掉的、
还是这一次没请求到。于是书架在冷启动的第一帧画的是 offline.books(),
而那一帧里被拿掉的书还在——在线时下一帧会被服务端的答案替换掉,离线时永远不会。
所以移除路径显式调一次 offline.removeBooks(result.books),而且是在 refresh之后:
放在前面只有在「服务端已经不再返回这本书」时才正确,而那正好是把镜像写成
「一个带 bug 的缓存」的那个假设。result.books 是服务端自己说它改过的那几本,
两边都是它说了算。
增量拉取
GET /api/v1/sync?since=<上次的 serverTime>。客户端记住上次返回的 serverTime,
每次只拿增量。移动网络下重连很便宜。
一次写入只发一个请求
POST /api/v1/sync 的响应就是合并后的状态(服务端在两个接口上返回同一份
pull)。所以推完之后不要再拉一次——SyncEngine 直接拿 push 的返回值喂给
applyPulled,没有东西可推(空闲轮询)时才真的发 GET。
之前不是这样:push 的返回值被丢掉,然后为了同一份数据再发一个 GET。于是读者每
翻一页都发一个 POST 加 一个 GET,网络面板里就是成对出现的 sync ——这正是
「sync 接口在不停重复」的来源。
分页的位置:服务端切页,客户端算不了
两个列表的 total 和「这一页是哪些行」必须是同一次行走的结果。分开算
(第二次 readdir、或者一个缓存的计数)会让摘要、分页器和它下面那几行在任何一次
磁盘变化之后互相矛盾,而读者没有办法判断三个数字里哪个是旧的。所以
BrowseService.list 一次走完整个目录,用遍历的结果同时产出计数和这一页。
书库的分页上界(BROWSE_PAGE_SIZE_MAX)是刻意的:一个目录可以是文件系统里的
任何东西,把四千行发给浏览器再丢掉 3940 行是那个让屏幕显得坏掉的请求,而调用方
不应该能用一个参数换回它。
状态只在变化时广播
setState 先比对新旧 state/message,相同就直接返回。阅读页的状态监听会重绘整棵
chrome 树,而一个同步循环本来会通知三次(进门 syncing、出门 idle、再加一次
多余的 emit()),30 秒轮询让这件事在读者什么都不做时也持续发生。
时长统计:只记「真的在读、在看」的时间
阅读、观影、有声书、音乐的时长按天/月/年和按作品统计(「我的 → 时长统计」,管理员另有全部用户的汇总)。 没有历史数据可推算——进度表只存最后位置——所以从这个版本起开始记。
- 阅读由客户端记(
web/src/features/stats/reading-time.ts)。页面可见且三分钟内翻过页、滚过或点过才算; 朗读在播时也算(听书就是在读,息屏也算)。按书 × 15 分钟 UTC 时段累计,每个时段上报本设备的累计值, 设备用一个随机安装 id 区分,服务端写MAX(已存, 上报)(reading_time表)。所以重发、断网后补发、 响应丢失后的重试都不会重复计数;未发出的时段存在本地,下次打开阅读器时补发。 - 影音由服务端推算(
server/src/media/time-stats.ts,media_watch_time在 media.db)。网页播放器和 Android Media3 都已每几秒保存一次进度,同一会话相邻两次保存之间取「墙钟间隔」与「进度前进量」的较小者: 倍速时进度跑得比钟快、拖动时两者都快、暂停时进度不动;间隔超过 30 秒视为暂停或断线,不计。客户端与 Android 都不用改。 - 15 分钟时段是因为现行时区都是整刻钟偏移:按查看者的时区(
tz,分钟)分组时,一天、一月、一年正好是若干时段之和。 夏令时切换当天按查询时的偏移算,误差不超过一小时。 - 两半分开查询:阅读在主库,影音走媒体服务;影音还在启动(503)时页面只显示阅读部分并说明。 影音只统计当前仍有权限的媒体库;作品取条目树的顶层(剧集→剧、曲目→专辑)。
6. 认证与安全
- scrypt 做口令哈希(内置模块,无原生依赖)。比较用
timingSafeEqual。 - 登录时即便用户不存在也跑一次哈希比较,避免用时间差枚举用户名。
- 访问令牌是 HS256 签名的无状态令牌;刷新令牌是不透明随机串存库,可按设备撤销。
- 改口令 / 停用账号会撤销该用户所有会话。
- 每个请求都从库里重读用户,不信任令牌里的 role——停用账号立即失效。
- 刷新令牌轮转:用过即废,降低泄露窗口。
不要重新引入原生依赖
镜像构建的稳定性直接决定部署成功率,而部署门槛是最大的获客阻力。
better-sqlite3、argon2、bcrypt 都需要构建时的 make/gcc,
在部分 NAS 环境会直接装不上。
node:sqlite 和 node:crypto 的 scrypt 都是内置的,所以当前依赖树里
没有任何需要编译的包。
这也是发布镜像能做成多架构的前提:.github/workflows/release.yml(只在 v* 标签或手动运行时触发,
先跑完 CI)在 amd64 和 arm64 各自的原生 runner 上构建同一个单容器镜像,再合成多架构标签;
npm ci 在两个架构上都只是下载,不需要编译工具链。
7. 向后兼容承诺
自部署用户不会及时升级。所以:
- API 只做加法:加字段、加端点。不改已有字段语义、不删端点。
- 数据库 schema 用
CREATE TABLE IF NOT EXISTS,升级时新增列/表,不动旧的。 新列写进server/src/db/schema.ts的MIGRATIONS_SQL(ALTER TABLE … ADD COLUMN,必须带默认值), 启动时逐条执行、忽略「列已存在」——旧版本跑在新库上也不会因为多出来的列出错。 - 错误响应格式全站统一,客户端可以一直用同一套解析逻辑。
8. 客户端渲染
界面层的外观规范(图标、设计令牌、焦点、状态表达、文案)在
ui.md。§8 这一章讲的是渲染:分页、注入、量测、以及哪些界面 该用组件描述、哪些必须保持命令式。两者的边界是刻意分开的——排版层对书不动手 (见 §8 与 §9),界面层的外观则必须统一。
已落地。产品设计里的关键决策:不要直接套 Readium Mobile。 Readium 用 Readium CSS 主动接管排版、覆盖出版方样式,这与本产品 「忠实还原精排书设计」的核心差异化直接冲突。
实际方案:
- 原生外壳(Kotlin)只负责:文件与缓存、手势、账号、连通性
- 渲染层是 WebView + 自研 CSS 干预策略,保留作者样式、只做最小覆盖
- 参考 readium-css 的变量组织方式,但不引入它的默认接管行为
UI 框架:完整 React 应用
前端由 React 19 负责应用页面和页面生命周期,入口是
web/src/main.tsx 的 createRoot()。启动阶段先由
web/src/app/runtime.ts 组装平台、API、同步、离线、播放器和设置资源,再由
AppProviders 把它们注入 React 树:
react-router-dom的createHashRouter负责 Hash 路由、嵌套路由、参数、Outlet 和错误边界;@tanstack/react-query的QueryClientProvider负责服务端状态、缓存、加载、重试和失效;- Zustand stores 负责认证、设置、同步(
shared/stores/)和播放状态(features/media/stores/)等跨页面客户端状态; - 基础组件是项目自己的:
ui/toolkit.tsx(Button、IconButton、Segmented、Callout、SwitchRow等)、ui/sheet.tsx(唯一的弹层类型,设置面板、表单、选择器、确认框都是Sheet/ConfirmSheet)、ui/action-menu.tsx(每一个「⋯」菜单)、ui/dialog.tsx、ui/pager.tsx;全局提示在ui/notifications.ts,单个操作的失败用ui/floating-notice.tsx。图标来自 Lucide React 和项目自己的图标字体(ui/icon.tsx)。Mantine 已整体移除,组件只读本项目的设计令牌; RuntimeContext暴露 API、同步、播放、设置和平台能力,页面不自行组装这些依赖。
业务按 features/<domain> 拆分,每个领域按实际需要拥有 pages、components、
queries、mutations、services 和 styles。影音实现位于
web/src/features/media;登录(auth)、首页(home)、全局搜索(search)、书架、
书库、书源、「我的」(account)和阅读页面也通过各自 feature 接入同一棵路由树。
旧的 web/src/media、旧 Screen 页面、旧手写生产路由
和 mountUI 已从生产入口移除。web/src/app.ts 与
app/legacy-compat.ts 只保留 Android 诊断和旧测试需要的兼容形状,不负责页面调度。
少数人才打开的屏幕不进首屏。 阅读器(EPUB/PDF/ZIP 解析、分页、朗读)和管理员页面
(媒体库管理、扫描与刮削、条目元数据/匹配/章节/版本/结构、书源编辑、凭据、扩展页)曾占了
只看书架的人 1.5 MB 首屏加载的一半。它们在 web/src/app/router/lazy-pages.tsx 里
各自包成带 Suspense 的同名组件,路由表里仍写 ReaderPage、MediaLibrariesPage……
新的懒加载屏幕加在这里,不在页面里临时 import()。入口固定为 assets/client.js、
样式只有一份(cssCodeSplit: false),懒加载块带内容哈希;Service Worker 的预缓存清单由
web/tools/pwa/build.ts 列出全部产物,新块不需要手动登记。
React 组件只声明布局和状态,副作用放在 effect、query 或 service 中。这样登录、 书架、书库、书源、设置和影音页面共享同一套生命周期语义,加载、错误、空状态和 返回行为都能由路由与查询状态复现和测试。
路由:URL 就是屏幕
界面的第二个结构性问题不是「怎么画」,而是「在哪」。最初的 shell 用
showShelf() / openBook() / showManager() 加一个 pendingBook 字段来换屏,
URL 从头到尾不变。手机上这一点几乎看不见——直到读者按返回键:Android 的返回手势
交给 Activity,Activity 看到 WebView 没有历史,于是直接从书架上退出应用。
浏览器上则更糟,地址栏是自托管书库唯一的分发方式,而它既不能分享也不能收藏:
一本书链接不出去,刷新永远回到书架。
所以路由是值,不是调用:web/src/app/router/routes.tsx 声明
RouteObject,createHashRouter() 将 fragment 映射到嵌套 React 页面,导航就是
改 URL、让变化回流。
为什么用 fragment 而不是 history.pushState 的路径。 同一份产物要被两个宿主加载,
而两个宿主都服务不了任意路径:Android 从 https://appassets.androidplatform.net/
加载(那里没有服务端,除文档本身以外的路径全是 404),H5 由 reader 服务端托管,
而它没有「客户端路由回落到 index.html」这条规则。路径式路由会在 vite dev 里一切正常,
在两个生产宿主里 404。fragment 是 URL 里唯一保证属于客户端的一段,所以它是唯一
三处都成立的方案——代价是一个 #。
返回由 React Router 管理。 页面使用 Link、useNavigate 和路由层的父级关系,
不再由每个 Screen 自己维护一份 history。嵌套页面通过显式返回按钮回到父路由,浏览器
的 Back/Forward 和刷新都能重建同一页面状态。
当前路由树的稳定入口包括:
| 路由 | 页面 |
|---|---|
#/ |
首页 |
#/shelf |
书架 |
#/search?q= |
书籍与影音的全局搜索 |
#/library、#/library/books、#/library/files |
���库浏览、书籍列表、文件管理(仅管理员) |
#/sources 及 #/sources/... |
书源中心、浏览、书源编辑、凭据、扩展和插件管理 |
#/me、#/me/settings、#/me/account、#/me/opds、#/me/admin/* |
「我的」入口页;偏好设置(主题、书架、影音浏览、播放为同一页的分区);账号;外部阅读器(OPDS);管理员的用户管理、注册与邀请、服务配置。旧 #/me/:panel 跳到对应分区 |
#/settings |
兼容旧深链接,重定向到书架 |
#/book/:bookId |
阅读器 |
#/media/:channel |
影音频道目录 |
#/media/:channel/folders/:libraryId?、#/media/audiobook/narrators/... |
按文件夹浏览、演播者 |
#/media/:channel/items/:itemId |
影音详情及管理子页 |
#/media/:channel/player?item=&part= |
播放器 |
#/media/:channel/settings/libraries、.../tasks |
媒体库管理、扫描与刮削(旧的 #/media/:channel/settings[/:panel] 重定向到「我的」) |
/sources/search、旧的 /sources/:sourceId/:pageId 扩展页形状和
/media、/media/music、/media/audiobook 默认入口仍由路由表兼容;
新页面应使用当前命名空间和嵌套路由。未知路径回到 /media/video。影音内部按频道、
收藏、历史、队列、搜索、详情和管理功能拆成嵌套路由,分页、筛选和播放器参数放在
路由参数或 query 中。
Hash 路由适配 WebView 和静态服务器:宿主只需要返回同一个 index.html,客户端从
# 后解析页面,不依赖服务端为每个深层路径配置回退规则。登录是认证边界而不是业务
页面;未认证时由 AuthBoundary 显示登录表单,认证完成后 React Router 渲染目标地址。
导航只有一份:web/src/app/AppNav.tsx,手机上是底部标签栏,701px 起是侧栏(首页、书架、影音、我的)。
它显示与否也由 URL 决定(navMode()):阅读器和播放器是 hidden,整屏归内容;
顶层目的地是 top;从顶层进入的下一级页面是 push,手机上用自己的返回箭头替换标签栏,
桌面侧栏保留。
阅读器与长生命周期资源
阅读器是唯一的特殊生命周期边界:ReaderPage 在 effect 中取得书籍后创建
ReaderScreen,把它的 screen.element 放入 route host,并通过
runtime.registerReader() 注册进度刷新回调。页面离开时先注销回调,再调用
screen.dispose() 并清空 host。ReaderScreen 内部的章节加载、分页量测、手势、
朗读和 Shadow DOM 仍是命令式 DOM;其 chrome 使用 React,StageHost 只负责把
ReaderScreen 持有的舞台节点放到 chrome 树中的正确位置。React 不介入书籍正文的布局量测。
播放器是应用级资源。AppProviders 把 runtime.player.element 挂到
document.body,启动 PlaybackService 对 MediaPlayer 的监听。路由切换只卸载
控制页面,不销毁播放会话;播放器发出 open-controls 时由 AuthenticatedShell
导航到带 item 和 part 查询参数的播放器路由。Provider 卸载时停止监听并移除
播放器节点,应用隐藏或离开时统一调用 runtime flush,重新联网时续期会话。
应用主题由 AuthenticatedShell 创建的 ThemeController(web/src/shared/theme/theme.ts)
统一应用,作用域来自 runtime.mediaApi.preferenceScope()。控制器只在 <html> 上写
data-theme(warm、blue、green)和 data-mode(light、dark),颜色由
web/src/styles/theme-tokens.css 的 --color-* 语义令牌给出;系统深浅色、同页保存和
其他标签页的存储变化都会重新应用。阅读器选择黑白灰时只在自己的容器上写
data-theme="gray",视频和音乐播放页在自己的根节点上使用当前主题的深色取值。
主题偏好只经 readThemePreference / saveThemePreference / resolveAppTheme
(React 里用 shared/theme/use-theme.ts 的 useThemeState)读写,不直接碰存储。
令牌取值的出处是 docs/prototypes/Reader V3 主题语义令牌.md:theme.ts 里的调色板和样式表
是同一组值,web/test/theme-tokens.test.ts 保持两者一致并检查规范里的对比度配对。
字号和圆角同样是按角色命名的令牌(--text-display … --text-micro 七级、
--radius-xs … --radius-pill 六级,定义在 styles/reader.css 的 :root),
新的颜色、间距、时长也写成令牌而不是字面量,细则见 ui.md §2。
只有一份渲染层
Android 不重写渲染,它加载的是同一份 web/ 产物。这不是为了省事,
而是因为排版策略一旦有第二份实现就一定会分叉,而排版正是差异点所在。
两边共用的抽象在 web/src/core/platform.ts:传输、键值存储、二进制存储、连通性。
Android 只覆盖它真正能做得更好的部分——连通性和设备名——其余全部复用。
有一条退出通道:整页图与文档可以由原生画
「只有一份渲染层」是默认,不是教条。有一类内容在 WebView 里是不划算的: 整页就是一整张图。渲染一页漫画,WebView 要付:字节跨进渲染进程、在渲染进程里 解码成位图、跑一次样式与布局、再合成一次。对一章文字这些都是必要的;对 「把这张图画满屏幕」则全是白付的,在手机上就是一次翻页卡顿。
所以有一条明确划出的通道:固定版式的整页图由原生画。 规则写死在契约里,也是它能成立的原因:
- 只画整页图。 漫画页、单张扫描图。任何带文字排版的内容留在 WebView, 因为忠于精排是差异点,而它只有一份实现。
- 全有或全无。 要么解出位图并显示,要么返回
false让 WebView 照常渲染。 没有中间态,也不会出现「该有图的地方是空白」。宿主拒绝、格式不支持、 payload 坏了、桥接抛异常,四种情况都落到同一条回退路径上。 - 不在 UI 线程解码,位图有上限。 先读文件头拿真实尺寸,再按视口下采样分配。 一张 4000 万像素的扫描图如果先分配再缩放,就是把目标机型 OOM 掉。
契约横跨三种语言,所以谁也无法用类型系统保证它们一致,android/scripts/check-shell.sh
里加了静态校验:渲染模式的词表(RenderMode)必须覆盖壳里分支的那几个值,
原生侧的可解码媒体类型必须包含客户端会产出的那些。词表写错一边,
结果不是编译失败,而是所有页都静默走慢路径——所以这条检查不是锦上添花。
壳版本 2 引入这套接口。客户端把「壳版本 < 2」当作「没有原生渲染器」处理, 照常在 WebView 里画一切:为了一点性能就拒绝在旧 APK 上运行是错的交换。
Android 用 https 资产源,不用 file://
把打包好的 Web 应用塞进 assets,最直觉的加载方式是 file:///android_asset/...。
这条路对这个产品是走不通的:从 file:// 源发出的 fetch() 是不透明的跨源请求,
所有访问服务端的调用都会被拦,而通常的绕法是把 web security 关掉——
那就等于把 WebView 变成一扇敞开的门。
用 WebViewAssetLoader 从 https://appassets.androidplatform.net/ 提供同样的文件,
拿到的是一个正常的安全源:请求按普通 CORS 走,正好和服务端的 CORS 策略对上;
isSecureContext 为真,IndexedDB 等 API 与浏览器里行为一致。
这一个选择就是「一份 bundle 两个宿主」能成立的前提。
精排 CSS:干预策略
按重要程度排序的三条原则,写在 web/src/styles/reader.css 里:
- 不设任何属性,除非不设会坏。
- 每个可调项都是默认
inherit的可继承自定义属性——「关」的意思是「不动它」, 不是「用我们的默认值」。这一点很关键:给行距写死一个具体值,会把一本按章节 变行距的书压平。 - 任何改变作者意图的行为都是用户主动开启,且持久化,绝不是默认。
实际 override 只有七处,每处都在 CSS 注释里写了为什么:图片/表格/pre 在窄屏会撑破 布局、ruby 振假名在 16px 下按默认 50% 太小、长中文标题不换行会溢出。除此之外不动。
对比一下被删掉的做法:reset 样式表、统一的「阅读主题」、给 p 加边距。
它们都能让排版「看起来一致」,而一致性恰恰是这里要避免的东西。
书的样式隔离在 shadow root 里
书里一条 p { color: red } 不能改到书架,App 的 .topbar 也不能被书撞上。
iframe 同样能隔离,但每次换章要重建文档:滚动位置丢失、Android 上内存翻几倍。
shadow root 拿到完整样式隔离且没有文档边界,App 还能进去做分页和取选中。
关键是不往 shadow root 里注入任何 reset。
但隔离是双向的:文档里的选择器进不去 shadow root。这不是风格问题, 它决定了两条规则各自该写在哪儿,搞错了不会报错、只会静默失效:
.book-flow(正文列:版心宽度、页边距、字号缩放、分页列)、.fixed-page、 朗读高亮、翻页动画——这些元素在 shadow root 里,所以规则必须注入到 shadow root 里(见ui/shadow.ts的FLOW_STYLESHEET)。 写在styles/reader.css里等于删除:正文列会以浏览器默认样式渲染, 没有版心、没有边距、翻页模式没有列。而外围 chrome 在文档里、样式完全正常, 所以看上去不像样式表丢了,只像「阅读器没有页边距这个功能」。.book-host、亮度过场(::after伪元素)、book-content的display——这些是 shadow host 本身,属于文档树,所以规则写在styles/reader.css。- host 上的状态属性(
data-paginated、data-animating)只能用:host(...)选择:从 shadow root 内部无法用类选择器匹配 host。 写成.book-host[data-paginated] .book-flow永远不会匹配, 于是翻页模式静静地不产生任何列,翻页也就无处可翻。
两条规则里的值不重复:全部是自定义属性,而属性本身定义在 :root 上,
shadow root 会继承。
touch-action 是同一条边界上的又一个例子。不声明它,浏览器会认领每一次横向拖动
(触发 pointercancel):手机上滑动从来翻不了页,向右滑还会被当成浏览器的返回手势、
直接离开这本书。翻页和固定版式把所有移动交给手势层(touch-action: none),滚动模式只留
纵向平移(pan-y)。它必须同时写在 host(styles/reader.css)和 shadow root 里的分页列
(ui/shadow.ts)上:分页列才是滚动容器,手指落在文字上时由它往上的那些盒子决定归谁。
翻页模式只落在列边界上
翻页模式下,一个不在列边界上的 scrollLeft 就是「左半屏是这一页、右半屏是下一页」。
手机每次地址栏或键盘出没都会改视口高度,高度变了列数就变,于是恢复位置时的
max * fraction 几乎从来不是边界。所以 ReaderView 里所有翻页模式的 scrollLeft 写入
(恢复位置、朗读跟随、定位块、搜索)都经过同一个 scrollToColumnAt();浏览器自己发起的滚动
(聚焦、选区自动滚动)由 snapStrayScroll() 拉回边界,只有正在跟手的拖动例外。
用户高亮同理不是「量一次就完」:字号、页边距、模式切换、窗口尺寸或一张晚到的图片都会
挪动它下面的文字,所以每次重排之后重画。高亮的章节 id 以已加载文档自己的 section id
为准(ReaderScreen.localSectionId),不按格式猜 xhtml: 前缀——猜错的高亮能保存、能同步,
却永远画不出来。
窗口化的三个接缝
「一份产物两个宿主」和「一千二百章的书不能先下载整本」是同一件事的两面, 而窗口是这件事的传输边界。跨过它有三个必须写死的地方:
- 换窗口和落到章节是一次操作。 只换窗口会留下上一章在屏幕上:
宿主内容没动、
currentSectionIndex还指着旧窗口的下标、页脚还在报读者刚离开的章节。 这正是「点击章节没有反应」的症状。 - 窗口接口只有一个名字。 客户端建的
StagedDoc叫setWindow,StagedBook接口叫loadWindow,屏幕曾经用鸭子类型去猜——猜错了, 于是每次跨窗口跳转都是静默的空操作。现在由adoptWindow()一处识别。 - 窗口由 manifest 的
groups算出来,不是由「40 章」算出来。 漫画按卷开窗,而卷不是 40 页。spine / 40会请求一个不存在的组, 跳转就落到空处。
另外,位置必须按整书坐标判断,不能按窗口内的下标: 在窗口最后一章时下标是 39,拿它和窗口长度 40 比会得出「已经是最后一章」, 而后面还有八十章。窗口长度是传输细节,不该决定书在哪里结束。
书不能联网
加载时把章节里所有本地引用改写成内部 reader-res: 协议,渲染层从内存资源表解出;
绝对 URL 原样保留,然后被 CSP 挡掉。这样一本来路不明的书无法把阅读行为报到远端。
资源按需解出而不是预展开——不然打开一本带大图的 EPUB 会先把整个包塞进内存。
阅读位置格式
r1:<偏移>:<章节 id>。三点都是踩过之后定的:
- 版本前缀:服务端把 locator 当不透明字符串长期保存,客户端将来改格式时必须
能认出「这不是我认识的格式」。
r1给了这个能力。 - 章节 id 放最后:章节 id 是文件路径,会含冒号。只从前两个冒号切、剩下全保留, 是唯一不会丢数据的解析方式。
- 偏移是章节内的比例,不是像素、不是 CSS 列号。列号会随字号变化, 于是读者一调字号,位置就跳到另一个句子上——阅读 App 能犯的最让人恼火的错误之一。
待发送队列,而不是「每次都全发」
离线队列记的是脏集合:哪些记录还没送到服务端,而不是「当前全部状态」。
两者在一种情况下结果不同,而这种情况一定会发生:记录在请求发出之后、响应到达之前 被修改了(读者在同步进行时翻了一页)。脏集合下,这次更新仍然是脏的,要等它自己 的确认才清除;「全发」模式下,它会被那个更早的响应顺手清掉,然后永远送不到服务端—— 一个丢失的阅读位置,是这个产品最不能有的失败。
离线队列的抗崩
队列在本地镜像里,不在内存里。所以读到一半直接杀进程,位置不会丢—— 下一次启动接着送。笔记删除同样写墓碑后再入队,避免离线设备让已删笔记复活。
TXT 的编码与章节
本地书库里 TXT 占比很大,而它既没有结构也没有编码声明。
编码:先严格校验 UTF-8(自己实现,不用 TextDecoder 的宽松模式——宽松模式永远
「成功」,于是你永远发现不了自己解错了)。校验失败不是按顺序试
GB18030 再试 Big5,而是两种都解一遍再挑更干净的那个。原因值得写下来:
这两个编码的字节范围重叠得很厉害,同一串字节通常两边都能解出合法字符, 所以「先试 A 再试 B」永远不会走到 B,繁体 Big5 的书会以简体码表解出一整本乱码。 真正的证据在各自码表的空洞里——解出来变成私用区字符、替换字符、或者一片汉字里 冒出的假名。按这个打分选优,才能既救回繁体,也不牺牲占多数的简体。
结果里带 guessed 标记,界面上告诉用户选了哪个并允许手动覆盖。
章节:TXT 没有目录,只能从标题推断。策略是宁可少认,不要错认—— 一个 800 字的段落以「第三章」开头时不能当标题,而正文里提到「他在第二章读过」 更不能当标题。两条都有测试固定住。推断不出章节时按长度分段: 一本五千章的网文如果变成一整节,分页会直接卡死。
网页残留:从网站另存的小说常把 <div>、<br/>、 留在 TXT 里。转义后这些标签会被
当成文字画出来,整章还会连成一段。所以在推断段落和章节标题之前先过
cleanTextMarkup()(web/src/formats/segments.ts):块级标签变换行、行内标签去掉、常见实体解码、
只有空白的行算空行。只认真实的标签名——正文里的 a<b、<书名> 不动,仍按文字转义。
书源(format: 'chapters')的 text/html 章节是抓下来的网页片段,同样没有作者排版。
清洗由插件按书源声明的类型决定(文本源整理成段落,图片/漫画源原样保留);
阅读器只负责排版:只含文字和换行的章节(isTextOnlyMarkup)在读取时用 htmlToText()
读回成字符、走上面同一套 TXT 分段和缩进,含图片、表格、媒体的章节保留标记,EPUB 不进这条路径。
放在读取时而不是写入时,是为了让服务端早先缓存的章节也被修好。细则见 ui.md 的 TXT 排版一节。
漫画的页序
按自然序排(1, 2, 10 而不是 1, 10, 2)。排错不是观感问题,是故事被静默打乱。
文件名无信息量时用页序当标题,因为 a1b2c3.png 对读者没有意义。
目录书:什么算一页,谁说了算
一个目录变成一本书之后,「这本书有哪些文件」不能由 API 层临时拼:manifest 告诉客户端
哪些路径构成这本书,/books/:id/file 就服务哪些路径。两边必须是同一份清单。
这一条是被一个真实缺陷定出来的。此前 API 层自己拼路径,用的是 items[].title——
对散图恰好等于文件名,对压缩包里的页则是包内条目名(001.png),那不是一个库内路径。
于是这样一个卷的每一页都 404,漫画打开就是空白,而且服务端日志里只有 404,看不出原因。
现在清单由页面遍历给出,并且一个压缩包算一页:它的路径是 第01卷.cbz,
是客户端真能拿来发请求的东西;包内页码由 /assets?ref=page:卷:页 寻址,不是路径,
也就不该出现在 files 里。清单是契约,不是清单式的描述——多一条会被拒,少一条会 404。
顺带一个同类缺陷:第01卷.cbz 与 第02卷/ 并列时,整卷系列在书架上会整体消失。
.cbz 本身注册为一种书,于是这个目录被判成「书架」而不再交给漫画目录处理器;
卷子目录里只有图片、不构成书,所以也没有留下任何条目。两边都不认,书就不见了。
判据因此改成按上下文问:一个 zip 在书架语境里是书,在漫画目录候选里是页材料。
同一个布局还有两处更细的坑,都会让整卷系列消失或读错页,值得一并记下:
- 「卷」的判据不能在最后加一句
images === 0。 整卷系列里放一张封面扫描是常态, 而images === 0会把这种目录判成「不是漫画」——它就是上面那个消失缺陷的另一条路径。 判据改成逐个来源问:卷压缩包的页就在包里,一个压缩包本身就是一卷;卷子目录要往下看 才知道有没有页。一张散图既不能否定同级存在的整卷,也不该在只有它自己的时候被当成书 (那条由MIN_IMAGES管)。 - 页的引用是
page:<卷>:<页>,不是page:<下标>。 manifest 与/file都从同一次 页面遍历取引用(files[].ref),而不是让 API 层从路径或下标反推。「一个压缩包算一页」 意味着它在列表里的下标和它在卷里的位置无关:按page:1去问一卷,格式处理器解不出这个 引用,异常回退到「当普通文件发」的分支,于是客户端请求一页、拿到的是那个压缩包—— 状态码还是 200,只有翻页时才会发现内容不对。
一页一个 section
漫画和 PDF 是 layout: 'fixed',每页一个 section,由分页器整屏推进。
固定布局的宿主不做滚动容器——在横向分页器里嵌一个滚动区会产生
Android 上经典的「页面自己往边上飘」bug。
9. 界面设置与朗读
为什么设置不跟着账号同步
同一份「设置」里其实住着两类东西:
| 类别 | 例子 | 真相源 |
|---|---|---|
| 屏幕与环境的属性 | 字号、主题、亮度、页边距 | 本机 |
| 读者的属性 | 阅读位置、笔记、高亮 | 账号 |
把第一类推到服务端,结果就是把手机上的字号搬到平板上打开——这是所有人都讨厌的行为。
所以 SettingsStore 存在设备本地(reader.settings.v1),同步层完全不知道它存在。
新增的每一个排版开关,默认值都是「不动」
这条不是风格问题,是 §8 那个承诺的直接推论。「忠于精排」在实现上只有一种写法: 除非不设就会坏,否则不设。
所以行距的默认值是字符串 inherit,字体和文本对齐同样;它们经 CSS 自定义属性
继承进 shadow root,书里写死的值自然胜出。字号改的是容器的基准字号,不是每段的大小,
所以 font-size: 14px 的段落保持原样,1.2em 的段落跟着滑块走。
页边距是唯一一个「用户值必然生效」的排版项。它不是给书的,是给行宽的:
一行 40 个汉字在手机上没法读,而约束行宽不能靠改 p 的 margin——那正是要避免的编辑。
改成给容器加内边距,作者的样式一条都不用动。
亮度做成了 mix-blend-mode: multiply 的覆盖层而不是调色板:换主题应该保留
纸与字的对比关系,直接改颜色会把文字一起压暗,那正是「亮度」滑杆最常见的失败。
三个朗读引擎,一个队列
朗读有三条来源,而客户端的其余部分不需要知道正在用哪一条:
| 引擎 | 声音来自 | 什么时候用 |
|---|---|---|
native |
Android 外壳的 TextToSpeech |
有 speech 接口的外壳(版本 ≥ 3) |
system |
WebView / 浏览器的 speechSynthesis |
默认。免费、离线、无往返 |
http |
服务端配置的 HTTP 朗读服务(我的 → 服务配置;TTS_URL 环境变量只作初始值),经 /api/v1/tts 代理 |
前两者都不可用时 |
auto 的优先级是 native → system → http,理由不是「谁更好」而是谁知道得更多:
- native 排在 system 前面,尽管 WebView 自己也有合成器。因为那是两个不同的引擎:
手机系统装好的中文语音经常不在 WebView 的
getVoices()列表里, 于是读者听到英文或听不到声音,而 Web 层拿到的列表本身就是缺的,修不了。 这是「朗读没有中文声音」最常见的原因,唯一的解法是直接问平台。 - http 排最后,因为它的每一句都要一次请求,而且它需要一个配了 HTTP 朗读服务的服务端。
三者的暂停语义真的不同,所以它们不是同一个基类:
speechSynthesis能暂停,TextToSpeech不能(只能 stop),<audio>能暂停但要重播整句。- 改语速:
<audio>直接改playbackRate(不用重读),另两个必须重读当前句。 - 音调:
<audio>根本没有,所以设置面板在 HTTP 引擎下不显示这一行, 而不是显示一个按了没反应的滑块。
派生的差异一并被显式处理:HTTP 引擎的单句上限是 800 字,而一段没有标点的中文段落
可能有两千字——切分发生在 rememberSpeechChunks() 里,只对引擎生效,
高亮仍然锚定在原来的整句上(碎片带着原句的 start 偏移)。
Android 的原生朗读接口为什么被拆成第二个对象
addJavascriptInterface 只能注册对象,不能注册某个对象上的属性,
所以 window.ReaderAndroid.speech 实际上是 window.ReaderAndroidSpeech 这个独立注册的接口,
由 promoteSpeechInterface() 在 Web 层重新挂回第一个对象上。
契约(android-bridge.d.ts)对外只有一个嵌套对象,接线方式不出现在这一层之上。
和原生整页渲染一样,它按外壳版本判断而不是按方法存在与否判断:
版本低于 3 的外壳就是没有这个接口,客户端退回 speechSynthesis,
而不是到播放时才发现方法不存在。
朗读为什么按句排队
speechSynthesis 本身只有一行 API,难的是决定下一句说什么。按句而不是按段:
- 高亮能跟着声音走。
boundary事件(词级位置)在 Android WebView 上不存在, 在桌面 Chrome 上也不可靠,所以依赖它做高亮等于在目标平台上不做高亮。 - 一章拆成几十句,语速、音调、语音的修改只需重读当前句;按段修改则要重读整段。
- 章节切换发生在「一句读完」的回调里,是引擎唯一明确告诉我们「这句话说完了」 的时刻——除此之外的推进都可能把两句同时说出去。
切句规则刻意保守:. 后面不是空白就不算句末,Mr. / Dr. / 3.14 / e.g.
全部被这一条挡住。代价是 end.Next(句号后不加空格)只剩一句,这比把 Mr.
和它的名字切开要小得多。没有标点的长段落按 300 字硬切——Android 的合成器
遇到两千字的单条 utterance 会直接沉默。
高亮用 Range 画,不改书的 DOM
把正在朗读的句子包进 <span> 是最直觉的做法,也是最糟的:它改写了出版方的标记,
会让作者针对 p > span 的样式失效,并且因为行盒度量变化而重新分页——
读者一按朗读,页面就跳。所以高亮是 flow 里的一个绝对定位覆盖层,
位置来自 Range.getBoundingClientRect(),文字本身一个字节都没动。
代价是高亮必须自己跟随滚动、自己处理换行(一个跨行的句子只有一个矩形),
这比包 <span> 麻烦,但换来的是「按朗读不会动版」。
朗读队列与章节的边界
引擎只知道一件事:给我一批句子,说我读到哪里了。章节的事由屏幕层负责—— 队列耗尽时引擎再问一次,屏幕层决定是「取下一章」还是「到此为止」。 这个分工让「读到章末停下」成为设置项而不是引擎里的分支。
队列的推进由事件驱动,绝不靠计时器:speechSynthesis 的 onend、<audio> 的 ended、
原生外壳的 done。计时器会漂移,而一次漂移就意味着两句同时被说出来或一句被吞掉。
每个引擎还各有一个看门狗,只在引擎自己彻底安静了(长时间熄屏后的
Android WebView 是真实会发生的)才推进,不是为了给朗读计时。
原生引擎的事件带一个客户端自己生成的 utterance id,外壳原样回传。 没有它,就分不清「我正在读的这句读完了」和「读者已经跳过的那句读完了」—— 后者会让光标多走一格,也就是跳一句。
屏幕层还有一个必须走的公共路径:所有翻页都经过 turnPage()。原因是引擎锚定在
当前章节的 DOM 节点上,绕过这个方法的翻页会让引擎对着一个已经脱离文档的节点
继续朗读——听起来就像客户端在自言自语。
分窗是传输边界,目录是目录
/manifest 与 /items 都接受 ?group=N,一次一个窗口(epub 40 章、漫画一卷)。
省略 group 等价于 group=0——这个默认值是后来改的,值得记一笔:最初省略参数
意味着「发全部条目」,于是一本 1200 章的书在它最需要快的那个请求(打开书)里
收到 1200 条记录。分窗如果只对显式传参的客户端生效,等于没做。
目录则相反:/toc 必须完整。两者要的性质相反,合成一个接口必然做错一个——
第一版就是这么做的,结果是目录面板显示「第 1 章 – 第 40 章」,服务端的分页边界
漏进了 UI。
书自己的目录页也是一个章节,它的链接要当章节切换
一本 EPUB 的 nav/toc 文档是普通的 spine 条目,读者翻到它、点「第三章」,
要的是换章。但服务端把章节里的相对引用都改写成了 assets 端点的绝对 URL
(否则相对 href 从归档外面根本解析不到),于是那个 <a href> 默认是一个
资源请求:浏览器去下载另一章的文档,读者看到的不是第三章,而是一张坏掉的页面。
客户端因此要在点击这一层把它读回来:chapterRefFromLink() 认出
__reader-book-resource__=1&ref=OEBPS/ch3.xhtml 里的 ref,拼成 manifest 的
xhtml:<path>——和目录面板给的是同一种引用,所以它走的是同一条跳转路径
(goToChapterRef),跨窗口取窗也一样。
两条附带的规则同样重要:识别不出来的链接不放行(一本书里的站外链接不能让
读者离开阅读器,WebView 壳里更是回不来),而纯片段链接(#note3)交给浏览器
自己跳——脚注必须能用。边界写在 ReaderView.bindChapterLinks 里,因为它是唯一
同时看得见 shadow 树里真实节点和网络地址的地方。
章节的读取代价
一次章节读取 = 一次中央目录读(打开时)+ 一次条目的局部头 + 该条目的数据。
这是 zip-reader.ts 只解析中央目录、openStream 按偏移读单个条目的原因;
整包 loadAsync 会让「翻到下一章」变成「重新解压整本书」。
客户端不解析压缩包:分章的书按章向服务端要正文(formats/windowed.ts 的 loadSection),
所以浏览器端同样不必下整本书。
(注意:check-shell.sh 里新增的渲染模式词表校验,以及 colors.xml 里
-- 在 XML 注释中非法、Kotlin 侧 Uint8Array 不存在这两处,
都是第一次真正编译 Android 壳时才暴露的。写不能编译的代码等于没写。)
10. 书源与插件
书源是「书库之外的书从哪来」:本地书库、OPDS 目录和进程插件(server/src/sources/)。
宿主 SourceHost(server/src/services/source-host.ts)持有注册表、来源实例的生命周期和
凭据边界;路由在 http/routes/sources.ts,接口形状见 api.md。
- 插件是进程,不是模块。
ProcessPlugin以子进程运行受信任的 Node 插件,按行收发 JSON-RPC(stdio),有超时和单条消息大小上限,返回值经protocol.ts解码后才进入宿主。 这是故障隔离而不是沙箱:插件有服务进程同一个系统用户的权限,所以安装是管理员操作, 安装包必须留在DATA_DIR/plugins里(PLUGIN_PATH_ESCAPE)。一个插件崩了、卡了, 丢的是这一次调用,不是整个服务端。示例在examples/plugins/demo-chapters。 - 凭据加密存库。 来源凭据用从签名密钥派生的密钥加密,接口只回报「有没有」,不回原文。
- 搜索是一条流,失败按来源报告。
POST /sources/:id/search的响应是text/event-stream(http/search-stream.ts):同一个请求里依次推出每一页结果,宿主按ref去重、按resultLimit截断、校验游标不重复和批次进度不倒退。一个来源实例可能聚合了 很多书源(例如书源插件),其中几个超时或报错不应让整次搜索失败,所以每一页可以带errors[](source+code+message)。每一块只带自上一块以来的失败,客户端 (SourceCatalogPage的mergePages)必须按source:code累积,而不是用最新一块替换—— 否则读者只看得到最后一个出错的书源。同一用户、同一来源、同一sessionId的新连接会先中止旧连接,…/search/cancel显式结束会话。 - 书源的书也是书。 取得(acquire)一本书源书时,
publications/chapters.ts把它写进同一张books表(format: 'chapters')并缓存章节,书行仍只由ShelfService组装(§5 那条规则); 章节的清洗与排版边界见 §8「TXT 的编码与章节」。