cnb.dQC5J6ohAGA 2233b295bb feat: 初始化自部署书库服务端
按产品设计 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
2026-09-16 14:07:25 +08:00

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)
PDF 保底可读(页数、基础内嵌元数据)

书库扫描

  • 增量扫描:先用 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/me
  • POST /api/v1/auth/password
  • POST /api/v1/auth/logout
  • GET /api/v1/books — 书架列表,支持 search author series tag format sort page
  • GET /api/v1/books/:id
  • GET /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/:bookId
  • GET /api/v1/notes / POST /api/v1/notes / DELETE /api/v1/notes/:id

管理员

  • POST /api/v1/library/scan
  • GET /api/v1/library/scan
  • GET /api/v1/library/stats
  • GET/POST/PATCH /api/v1/admin/users
  • GET /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,并为新行为补测试。

S
Description
Reader - book source reading service
Readme AGPL-3.0
26 MiB
0 Stars 1 Watchers 0 Forks
Languages
TypeScript 77.7%
JavaScript 11.2%
CSS 7.4%
Kotlin 3.2%
Shell 0.2%
Other 0.2%