按产品设计 v0.2 落地服务端,覆盖第一版范围中的扫描、元数据、权限与同步。 核心决策: - 书籍主键用 dc:identifier + 内容哈希,不用文件路径。 重命名/移动不丢进度,副本自动合并,重新编码的文件保持独立。 - 库目录只读挂载,写操作全部落到 DATA_DIR。 启动时断言 DATA_DIR 不在 BOOKS_DIR 内,路径解析统一走 resolveInside 拦截逃逸,扫描不跟随符号链接。 - 元数据优先级:手动修改 > 内嵌 > provider > 文件名解析。 手动修改独立成表,扫描永远碰不到,可单字段撤销。 - 中文文件名解析采取保守策略,无证据时不猜测作者方向。 - 离线合并用 LWW,笔记删除写墓碑,批次单事务。 - 无原生编译依赖:node:sqlite 与 scrypt 均为内置模块。 交付: - Fastify + TypeScript 服务端,Docker 单容器 - 27 个自动化测试用例全部通过 - AGPL-3.0,README 做到一条命令跑起来 - CNB 流水线:typecheck / test / build
reader
自部署精品书库阅读器。 把你自己磁盘上的电子书挂载成书库,用移动端以「精品级排版」阅读。
书文件始终留在你自己的磁盘上,不经过任何厂商服务器。开源免费,AGPL-3.0。
一条命令跑起来
git clone https://cnb.cool/hectorqin/reader.git
cd reader
# 把 /path/to/your/books 换成你自己的书库目录
sed -i 's#/path/to/your/books#/home/me/books#' docker-compose.yml
docker compose up -d
打开 http://<你的主机>:8080,第一个注册的账号自动成为管理员。
只有第一次注册是开放的。之后新账号由管理员在后台创建(默认关闭公开注册), 这样你的实例不会被陌生人占用。想让家人自行注册,在
docker-compose.yml里 把ALLOW_REGISTRATION设为"true"。
最小化运行(不用 compose)
docker run -d --name reader \
-p 8080:8080 \
-v /path/to/your/books:/books:ro \
-v ./data:/data \
cnb.cool/hectorqin/reader:latest
书库目录:只读,永不写回
volumes:
- /path/to/your/books:/books:ro # 你的书,只读挂载
- ./data:/data # 服务端写的一切
服务端绝不写入 /books。所有写操作——SQLite 数据库、封面缓存、扫描记录——都在 /data。
这一条是硬约束,代码里有断言保护:DATA_DIR 落在 BOOKS_DIR 里面时服务会直接拒绝启动。
你的书籍目录不会被污染:不会多出封面文件、.calibre 之类的元数据文件、转换产物。
支持格式
| 格式 | 第一版状态 |
|---|---|
| EPUB | 精排支持(内嵌元数据、封面、calibre 系列、dc:identifier) |
| 保底可读(页数、基础内嵌元数据) |
书库扫描
- 增量扫描:先用
mtime + size快速判断,再用内容哈希确认。绝大多数文件在第一阶段就被跳过。 - 目录挂载:递归扫描挂载目录,自动跳过
.git、@eaDir、#recycle等目录,不跟随符号链接。 - 自动扫描:默认每 30 分钟全量、每 60 秒增量轮询。设
SCAN_INTERVAL=0/WATCH_INTERVAL=0可关闭。 - 手动触发:
POST /api/v1/library/scan(管理员)。
书籍主键:为什么不按文件路径
书籍主键是 sha256(dc:identifier + 文件内容哈希),不是文件路径。
用路径做主键的话,用户重命名或移动一下文件,阅读进度和笔记就会全部丢失—— 这是自建书库产品最常见的差评来源。当前设计下:
- 重命名 / 移动文件 → 主键不变,进度和笔记保留 ✅
- 同一本书的多份副本 → 自动合并成一条书架记录 ✅
- 重新编码过的同名文件 → 视为不同副本,不会被误认 ✅
元数据优先级
1. 用户手动修改 最高,永不自动覆盖
2. EPUB 内嵌元数据 次高,扫描不会覆盖手动修改
3. 在线刮削 provider 只填补空缺字段,标注来源,可一键撤销
4. 文件名解析 最弱,仅在以上都缺时使用
在线刮削是可插拔的 provider 机制。第一版只做内嵌元数据 + 文件名解析 + 手动补全,
不接任何在线源——避免引入网络依赖与限流问题,也避开豆瓣的合规风险。
后续接入 Google Books、Open Library 时,实现 MetadataProvider 接口注册即可,索引层不用改。
手动修改存在独立的覆盖层,扫描永远碰不到它。撤销单个字段用
DELETE /api/v1/books/:id/metadata/:field,会回落到内嵌值。
多用户
单个部署实例内的多账号,适合家庭或小圈子。两级权限:
- admin:创建/停用账号、触发扫描、查看实例信息
- member:只能看自己的书架、进度和笔记
每个账号有独立的书架、阅读进度、笔记与高亮。新账号注册后自动看到整个共享书库, 不需要逐本添加。
客户端三态
服务端是唯一真相源,客户端只做「渲染 + 缓存」。客户端有三种状态:
| 状态 | 行为 |
|---|---|
| 局域网直连 | 正常同步 |
| 公网可达 | 正常同步 |
| 完全离线 | 读本地缓存,恢复后合并进度 |
同步的只有轻量数据:书架元数据、阅读进度、笔记、高亮。书文件不走同步。
离线合并语义
进度和笔记都带 updatedAt,冲突按 last-writer-wins 解决:
- 离线设备重连后推的旧数据不会回滚你在别处读到的新进度
- 笔记删除是**墓碑(tombstone)**而不是硬删除,所以离线设备无法让已删除的笔记复活
GET /api/v1/sync?since=<上次的 serverTime>只返回增量,重连很便宜
配置
| 环境变量 | 默认值 | 说明 |
|---|---|---|
BOOKS_DIR |
/books |
书库目录,只读挂载 |
DATA_DIR |
./data |
服务端写入目录,不能在 BOOKS_DIR 内 |
PORT |
8080 |
监听端口 |
PUBLIC_URL |
空 | 对外地址,反代后设置 |
SCAN_INTERVAL |
1800 |
全量扫描间隔(秒),0 关闭 |
WATCH_INTERVAL |
60 |
增量轮询间隔(秒),0 关闭 |
ALLOW_REGISTRATION |
false |
是否允许公开注册 |
ACCESS_TOKEN_TTL |
86400 |
访问令牌有效期(秒) |
REFRESH_TOKEN_TTL |
31536000 |
刷新令牌有效期(秒) |
READER_TOKEN_SECRET |
自动生成 | 签名密钥。留空则生成并持久化到 /data |
LOG_LEVEL |
info |
日志级别 |
READER_TOKEN_SECRET留空时会在首次启动生成并存到DATA_DIR/token.secret。 只要/data还在,重启不会踢掉所有客户端。删掉/data会清空所有账号和进度。
让外网访问(内网穿透 / 域名 / HTTPS)
自部署最大的门槛在这里,所以单独说清楚。三种常见做法:
1. 只在局域网用(最简单)
不用做任何事,直接访问 http://<NAS的局域网IP>:8080。
2. 反向代理 + HTTPS(推荐) 用 Caddy 最省事,自动申请证书:
reader.example.com {
reverse_proxy 127.0.0.1:8080
}
然后设置 PUBLIC_URL=https://reader.example.com,下载链接才会正确。
3. 内网穿透(没有公网 IP) 用 frp / Tailscale / Cloudflare Tunnel 任选一个。Tailscale 最简单: 在 NAS 和手机上装 Tailscale,直接用 Tailscale 分配的 IP 访问,不用配证书。
服务端设计上不依赖域名和 HTTPS,所以三种方式都能工作。但公网暴露时 务必套一层 HTTPS,否则令牌是明文传输的。
从源码开发
cd server
npm install
npm test # 27 个测试用例
npm run typecheck
npm run dev # 开发模式,热重载
# 指向一个测试书库
BOOKS_DIR=/tmp/books DATA_DIR=/tmp/data npm run dev
项目结构
server/src/
config/ 运行时配置(含「DATA_DIR 不得在 BOOKS_DIR 内」的启动断言)
db/ SQLite schema 与访问层(node:sqlite,无原生依赖)
indexer/
identity.ts 书籍主键:dc:identifier + 内容哈希
filename.ts 文件名解析(保守策略,宁可留空不猜错)
metadata.ts EPUB/PDF 元数据与封面提取
scanner.ts 增量扫描、变更检测、清理
providers/ 可插拔刮削 provider 接口
services/
users.ts 账号、角色、令牌
shelf.ts 书架查询、手动覆盖层
sync.ts 进度/笔记同步与冲突合并
merge.ts 元数据优先级链
http/ Fastify 路由
技术选型说明
- Node.js 24 + TypeScript + Fastify:单容器体积可控(约 150MB),迭代最快,
node:sqlite与scrypt都是内置模块,没有原生编译依赖——这让镜像构建稳定, 也兑现了「一条命令跑起来」。 - 不用 better-sqlite3 / argon2 / bcrypt:它们需要构建时工具链(
make、gcc), 会把镜像撑大并让安装变脆。 - SQLite 存 metadata,不存书:schema 里的
books表可以随时重建,书本身才是真相源。
API
所有业务接口在 /api/v1 下,用 Authorization: Bearer <token> 认证。
响应错误统一为 { "error": { "code": "...", "message": "..." } }。
服务端 API 承诺长期向后兼容:自部署用户不会及时升级,破坏性变更只允许 「加新字段 / 加新端点」,不允许改已有字段语义。
端点列表
公开
GET /api/v1/health— 健康检查GET /api/v1/instance— 实例信息(是否开放注册、账号数)POST /api/v1/auth/register— 注册(仅首次或开启 ALLOW_REGISTRATION)POST /api/v1/auth/login— 登录POST /api/v1/auth/refresh— 刷新令牌
需认证
GET /api/v1/auth/mePOST /api/v1/auth/passwordPOST /api/v1/auth/logoutGET /api/v1/books— 书架列表,支持searchauthorseriestagformatsortpageGET /api/v1/books/:idGET /api/v1/books/:id/manifest— 渲染器需要的清单GET /api/v1/books/:id/content— 书文件流GET /api/v1/books/:id/cover— 封面PATCH /api/v1/books/:id/metadata— 手动补全元数据DELETE /api/v1/books/:id/metadata/:field— 撤销单个字段GET /api/v1/library/facets— 作者/系列/标签/格式聚合GET /api/v1/library/continue— 继续阅读GET /api/v1/sync?since=<ms>— 增量拉取POST /api/v1/sync— 批量合并GET/PUT /api/v1/sync/progress/:bookIdGET /api/v1/notes/POST /api/v1/notes/DELETE /api/v1/notes/:id
管理员
POST /api/v1/library/scanGET /api/v1/library/scanGET /api/v1/library/statsGET/POST/PATCH /api/v1/admin/usersGET /api/v1/providers
范围边界(第一版)
做:目录挂载扫描、元数据刮削、两级权限、同步 API、Docker 单容器、Android 客户端、EPUB 精排、PDF 保底、按书离线缓存。
不做:在线书城、iOS、桌面端、社交、AI 问答、全格式转换、移动端批注输入。
许可
AGPL-3.0。选择 AGPL 而非 GPL:自部署产品最怕别人拿去做闭源付费服务, AGPL 的网络分发条款正好卡住这条路径(Kavita、Audiobookshelf 用的 GPL-3.0 更松一档)。
贡献
欢迎提交 Issue 和 PR。请先跑通 npm test,并为新行为补测试。