Files
reader/docs/api.md
T
hectorqinandClaude Opus 5.5 842331684a feat(server): keep a per-book reading-progress history
reading_progress keeps one row per book and last-writer-wins, which answers
"where am I" and loses "where was I": a stale device, a mistapped 目录 entry
or a clock running ahead overwrote the place for good (P0: 进度历史).

Every valid progress write now also lands in reading_progress_history,
including a write that lost the merge. A device reading on through one
chapter updates its latest entry (same chapter, within 5% and 30 minutes)
instead of adding rows; another chapter, a jump, another device or an
out-of-order write is a new entry. The newest 50 per book are kept.
GET /api/v1/sync/progress/:bookId/history lists them, newest first, for the
signed-in account only.

The table is new (CREATE TABLE IF NOT EXISTS), so the schema change is
additive and older builds sharing the database ignore it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:51:42 +00:00

1197 lines
70 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# API 参考
基础路径 `/api/v1`。除标注「公开」外都需要 `Authorization: Bearer <accessToken>`。
错误响应统一格式(请求体 / 查询参数校验失败时 `code` 为 `BAD_REQUEST`,未处理的服务端错误为 `INTERNAL`):
```json
{ "error": { "code": "NOT_FOUND", "message": "book not found" } }
```
## 兼容性承诺
只做加法。已有字段的语义不会改,端点不会删。
客户端可以按 `apiVersion` 判断能力,但不需要为小版本做分支。
---
## 实例信息
### `GET /instance` — 公开
```json
{ "name": "reader", "apiVersion": 1, "registrationOpen": false,
"registrationMode": "closed", "invitationRequired": false, "userCount": 3 }
```
客户端用它区分「全新实例」(可以注册)和「已被占用」(需要管理员开账号)。
`apiVersion` 是**能力版本**,与包的版本号无关:只有在字段语义变化、客户端必须改行为时
才动它。加字段、加端点都不动——这正是「只做加法」能成立的原因。
### `GET /health` — 公开
```json
{ "status": "ok", "booksDir": "/books", "dataDir": "/data",
"version": "0.1.0", "dataDirIsInsideBooks": false }
```
`dataDirIsInsideBooks` 恒为 `false`——为 `true` 时服务端根本不会启动。
暴露出来是为了让运维一眼确认挂载点就是自己以为的那个。
---
## 书内搜索
### `GET /books/:id/search?q=关键词&cursor=上一页游标`
仅允许搜索当前用户书架中的书籍,支持 TXT 和 EPUB。关键词去除首尾空白后搜索,原始长度限 1–200 个字符。响应不缓存。
```json
{
"hits": [{ "title": "第一章", "excerpt": "匹配附近的文字", "anchor": {
"sectionId": "chapter:0", "start": 10, "end": 12,
"quote": "关键词", "prefix": "前文", "suffix": "后文"
}}],
"failures": [], "scanned": 8, "total": 30, "limited": false,
"nextCursor": "8.签名"
}
```
服务端按需扫描正文,每页本地最多 8 章、远程最多 1 章,每页最多返回 200 条匹配;`limited` 表示达到匹配上限。客户端连续请求 `nextCursor`,最多展示 200 条。游标绑定书籍目录、版本、关键词和个人整理版本,条件变化需重新搜索。此接口不是预建全文索引。
单章限制 8 MiB;失败章节在 `failures` 中返回 `{title, code}`,保留其他章节结果。客户端停止请求会取消后续扫描。返回定位包含当前用户的个人纠错。
使用自定义 TXT 标题规则时返回 `400 SEARCH_LOCAL_LAYOUT`,客户端使用本地目录和缓存搜索并明确提示。断网时同样仅搜索已缓存内容,缓存不完整时提示结果可能不完整,不下载整书到浏览器搜索。
## 认证
### `POST /auth/register` — 公开
```json
{ "username": "owner", "password": "至少8位", "displayName": "图书馆长" }
```
- 首个账号恒可注册,且自动成为 `admin`
- 之后按管理员设置的注册策略开放;首次初始化时从旧 `ALLOW_REGISTRATION` 导入,随后以数据库配置为准。
- 邀请模式需要提交 `inviteCode`,无效、过期、停用或用尽的邀请码返回 `400 INVITE_INVALID`,未填写返回 `400 INVITE_REQUIRED`。普通注册只创建普通用户。
- 关闭时返回 `400 REGISTRATION_DISABLED`
- 用户名 / 密码缺失或超长(用户名、显示名 ≤100,密码 ≤1024)返回 `400 BAD_REQUEST`;用户名少于 3 位返回 `409 USERNAME_TOO_SHORT`,密码少于 8 位返回 `409 PASSWORD_TOO_SHORT`,重名返回 `409 USERNAME_TAKEN`
返回 `201`:
```json
{ "user": { "id": "...", "username": "owner", "displayName": "图书馆长",
"role": "admin", "createdAt": 1789537801289 },
"session": { "user": {...}, "accessToken": "...", "accessTokenExpiresAt": 1789624201289,
"refreshToken": "...", "refreshTokenExpiresAt": 1821073801289 } }
```
### `POST /auth/login` — 公开
`{ "username": "...", "password": "..." }` → 同上(不含 `user` 外包一层)
失败恒为 `401 BAD_CREDENTIALS`,不区分「用户不存在」和「密码错误」。
账号停用返回 `403 ACCOUNT_DISABLED`。
### `POST /auth/refresh` — 公开
`{ "refreshToken": "..." }` → 新的 `session`。
**刷新令牌会轮转**:传入的那个立即作废。客户端必须持久化新的那个。
### `POST /auth/logout`
`{ "refreshToken": "..." }` → `{ "ok": true }`。幂等,重复调用不报错。
### `GET /auth/me`
→ `{ "user": { ... } }`
### `POST /auth/password`
`{ "currentPassword": "...", "newPassword": "..." }` → `{ "ok": true }`
当前密码错误返回 `401 BAD_CREDENTIALS`。会撤销该账号**所有**会话(含当前设备),需要重新登录。
---
## 书架
### `GET /books`
查询参数:
| 参数 | 说明 |
| --- | --- |
| `search` | 匹配书名 / 作者 / 系列 / ISBN |
| `author` `series` `tag` `format` | 精确过滤 |
| `path` | 只返回**这个目录下**(含子目录)有文件的书。库内相对路径,`''` 等于不过滤 |
| `scope` | `shelf`(默认)\| `library`。见下 |
| `status` | `reading` \| `want` \| `finished`,仅书架范围生效,见 [`PUT /books/:id/reading-status`](#put-booksidreading-status) |
| `sort` | `title` \| `author` \| `added` \| `updated`,默认 `added` |
| `order` | `asc` \| `desc`,默认 `desc` |
| `page` `pageSize` | 默认 1 / 50,`pageSize` 夹到 1–200 |
```json
{ "items": [ { "id": "...", "title": "三体", "author": "刘慈欣",
"series": "地球往事", "seriesIndex": 1, "tags": ["科幻"],
"format": "epub", "coverUrl": "/api/v1/books/<id>/cover",
"fileSize": 1843, "source": "embedded", "manualFields": [] } ],
"total": 1, "page": 1, "pageSize": 50 }
```
只返回当前账号可访问的书籍;本地文件被删的书不会留在书架上。
`path` 是对 `book_files.rel_path` 的**前缀匹配**,带上分隔符:`path=科幻` 匹配
`科幻/…`,不会匹配到旁边的 `科幻小说`。`%` 和 `_` 在文件名里合法、在 `LIKE` 里是通配符,
两个都转义。
它就是书库**浏览页**(`#/library/<路径>`)的数据源:那一页是「这个文件夹里的**书**」,
而书是这个 DTO 的形状——拿 `/library/browse` 的文件行去画网格,是把文件名画在书名的
位置上。浏览页的搜索框就用上面那个 `search` 参数,所以它是**这个文件夹内**的筛选
(`path` 和 `search` 同时生效),而不是整个库的搜索;整个库的搜索是书架的事。
### `scope`:书架,还是索引
默认的 `shelf` 是「**我能读什么**」:一行 `user_books`(`hidden = 0`)以及可用文件、已下载文件或属于当前用户的章节出版物。
它也是书架屏用的那个。
`scope=library` 是「**这个文件夹里有什么**」:每一个有活文件的书,**不管这本书在不在
调用者的书架上**,并且每一项多一个字段:
```json
{ "shelfState": "on" | "off" }
```
它是浏览页用的那个,因为浏览页的存在理由就是「把这本书加入书架」——问书架的话,
返回的每一本都已经在书架上,`shelfState` 恒为 `'on'`,**那个动作永远画不出来**。
一个商城只陈列你已经买过的东西,就没有东西可以加。
`shelfState` **只在 `scope=library` 时出现**。书架自己的列表里它恒为 `'on'`,
一个常量字段是一个客户端迟早会画出来的字段。缺省与 `'off'` 是两个不同的事实
(老服务端没有这个字段 / 有字段且明确不在),客户端对缺省不画控件。
`library` 作用域里的排序键 `added` 用 `COALESCE(ub.added_at, b.updated_at)`:
没有 `user_books` 行的书在 `DESC` 下会被 SQLite 排在**最前**,
那个顺序读起来就是「读者从没上架过的那本书是他最近拿到的」。
`BookDto.addedAt` 本来就按同一规则兜底,所以两处说的一样。
客户端只在 `path=''` 时省略这个参数(省略等于根目录),**不发 `path=` 空串**:
一个空路径是根,一个「我传了个空的」不是同一个请求。
`source` 是基础元数据的来源,`manualFields` 列出被手动改过的字段。
### `GET /books/:id`
→ `{ "book": {...}, "progress": {...} | null }`
### `GET /books/:id/manifest`
渲染器布局前需要的清单。**打开一本书就是这一次请求**,可寻址结构也在里面,
所以客户端拿到响应就能画出第一屏。**已经带上第一个窗口的可寻址结构**:
`?group=N` 只换窗口,`groups` 始终完整。
```json
{ "book": {...},
"contentUrl": "/api/v1/books/<id>/content",
"coverUrl": "...",
"files": [ { "rel_path": "小说/三体.epub", "size": 1843, "missing": 0 } ],
"content": { "kind": "reflowable", "total": 42, "groups": [...], "items": [...] },
"kind": "reflowable", "total": 42, "groups": [...], "items": [...] }
```
`kind` / `total` / `groups` / `items` 与 `content` 的内容相同,同时放在顶层是**冗余的
兼容层**:它们先于 `content` 存在,且 `kind` 是外壳判断「谁来渲染」的唯一开关
(见 `android/contract.md`)。新客户端读 `content`,老客户端读顶层字段,两者都不会错。
**`files` 让客户端能区分「另一份副本」和「文件真的没了」**:`missing: 1` 表示行还在、
磁盘上没了。它同时是 `/books/:id/file` 的**契约**——manifest 列出哪些路径构成这本书,
`/file` 就服务哪些路径,多一条也没有。两边的清单由**同一个页面遍历**生成,
所以不会互相漂移。
对单文件书,这里是文件表里的行(一般是 1 条)。对目录书,这里由格式处理器给出:
页面就是**页面自己的库内路径**,压缩包里的页面按**压缩包自己的路径**列出(一个压缩包算一页),
包内页码由同一条目上的 `ref` 寻址——`ref` 是不透明引用,客户端原样回传 `/assets?ref=` 即可,
不要自己拼。
`?group=N` 会把 `items` 收窄到第 N 组,`groups` 仍然完整——这样打开一本 40 卷的漫画
只传一卷的条目。目录客户端用 `/toc`,不要自己拼 `groups` 的标题。
### `GET /books/:id/items?group=N`
`group` 省略时返回完整条目;传入 `group=N` 时只返回对应窗口。首次打开时应优先使用
`/manifest` 返回的窗口;文件书的 `/manifest` 默认是第一个窗口,这是刻意的:
不带参数的调用是「打开这本书」,它必须便宜。要完整结构用 `?group=all`。
书里有哪些**可寻址的单元**:章节、页、卷。这是客户端构建目录和分页的地方。
契约对所有格式一致,`kind` 字段告诉客户端该按什么方式渲染:
| `kind` | 含义 | 典型格式 |
| --- | --- | --- |
| `reflowable` | 可重排文本,按章节加载 | epub |
| `paged` | 固定页序的图片 | cbz、漫画目录、单图 |
| `text` | 连续文本,可能带章节 | txt、chapters |
| `document` | 不透明文档,客户端自己渲染 | pdf |
| `single-image` | 单页图片 | 单个图片文件 |
**章节按包内路径寻址,不按下标。** `items[].href` 是不透明的格式私有引用
(`xhtml:OEBPS/ch1.xhtml`、`page:17`、`chapter:4`),客户端原样回传即可。
下标只在一个窗口内成立:分窗返回的是 spine 的前缀,所以按下标算出的章节
在完整 spine 里指向另一章。
```json
{ "kind": "paged", "total": 6,
"groups": [ { "id": "v0", "seq": 0, "title": "第01卷", "count": 3 },
{ "id": "v1", "seq": 1, "title": "第02卷", "count": 3 } ],
"items": [ { "id": "0:0", "seq": 0, "title": "001.jpg", "kind": "page",
"mediaType": "image/jpeg", "href": "page:0:0" } ] }
```
`groups` 是卷(漫画)或单一隐式分组(epub/txt)。`?group=N` 只返回第 N 组,
让客户端一次只取一卷而不是整个系列的几千页清单。
`total` 在格式无法廉价得知时为 `0`,`groups` 为空 —— 例如没有标题的纯文本。
不要把它当作错误。
### `GET /books/:id/toc`
书的目录。**与 `/items` 分开**,因为两者要的性质相反:`/items` 是传输边界,
一次给一点;目录必须完整,否则跳转是残的。合成一个接口会让目录显示成
「第 1 章 – 第 40 章」——服务端的分页边界漏进 UI。
```json
{ "toc": [ { "href": "xhtml:OEBPS/text/ch1.xhtml", "title": "第一章", "level": 0, "spine": 0 } ] }
```
`spine` 是全书下标,`/items?group=N` 的每个 group 都带 `offset`,所以客户端
拿到单窗也能把跳转落到正确章节,不需要把前面的 count 加一遍。
按格式:epub 取 spine 标题(NCX / nav),txt 取分章结果,漫画目录取卷,
pdf / 单图没有独立目录,回落到 items。来源章节出版物(`chapters`)的目录取自当前快照,
响应额外带 `revision`。
### `GET /books/:id/assets?ref=<ref>`
取单个资源。`ref` 是**不透明**的格式私有引用。客户端优先取 `items[].resourceRef`,字段不存在时取 `items[].href`,原样回传,不要自己拼。已有文件格式不需要 `resourceRef`。
| 格式 | ref 形态 |
| --- | --- |
| epub | `chapter:2`(第 3 章)、或包内资源路径 `OEBPS/images/pic.png` |
| cbz | `page:17` |
| 漫画目录 | `page:1:2`(第 2 卷的第 3 页) |
| txt | `chapter:4` 或 `chunk:262144`(字节偏移) |
| pdf | `document` |
| chapters | `chapter-resource:<revision>:<chapter-id-hash>`;稳定 href 不能用于取资源 |
响应带 `Cache-Control: private, max-age=31536000, immutable`:文件资源由内容身份寻址;章节资源引用同时包含目录 revision,所以同一个资源 URL 的内容保持不变。
### 文件资源的流式与子资源行为
**流式下发,支持 Range。** 响应带 `Accept-Ranges: bytes` 时可以用
`Range: bytes=0-1023` 取片段(PDF 跳页、漫画跳卷、断点续传)。压缩方式为
`deflate` 的 zip 条目无法字节定位,会诚实上报 `Accept-Ranges: none` ——
让客户端知道,而不是白跑一次再发现。
**尾部范围(`bytes=-N`)在超过 8MB 时退化为整包响应。** 一个前向流无法「跳到文件末尾」,
服务这条请求意味着把整个文件读完再丢掉——对 400MB 的漫画包就是拿三个字节换一次全量读。
所以服务端会说 `Accept-Ranges: none` 并回 200,让客户端能做计划,而不是付出一次它没
预期到的下载。
**子资源可以带 `?access_token=`。** 章节里的 `<img src>`、`<link href>` 是浏览器
自己发起的,没法带 `Authorization` 头。这类请求允许把令牌放进 query,
但**仅限** `GET` + 下列端点:
- `/api/v1/books/:id/assets`
- `/api/v1/books/:id/cover`
- `/api/v1/books/:id/content`
其他任何端点(含触发扫描、写元数据)都不接受 URL 里的令牌 —— URL 会进日志、
进历史、进 `Referer`,能改状态的令牌不该走那条路。
**epub 章节返回的是重写过的 HTML。** 章节内的相对资源引用(`images/pic.png`)已经被
服务端改写成指向本端点的绝对地址。客户端直接把 HTML 交给 WebView 即可,不需要自己
解析路径。绝对 URL、`data:` URI 和文内锚点(`#note7`)保持原样,所以脚注和外链仍然可用。
服务端重写出的地址带 `?__reader-book-resource__=1` 作为标记,客户端据此把它和书自己
写的外链区分开:带标记**且**同源的才允许取回,其余一律丢弃(书不能借阅读会话去访问
第三方)。标记是一本书也能写出来的字符串,所以它单独不构成许可,两个条件一起才是书
伪造不出来的组合。
**这三条允许名单决定的是「能不能取」,不是「怎么取」。** 重写出来的地址还要**带令牌**
才真的能取到:章节文档里的 `<img src>`、`<link href>` 和 CSS 的 `url(...)` 都是浏览器
自己发起的请求,用的是文档里的原始地址,读不到客户端内存里的会话。客户端在注入章节时
把令牌追加到这些地址上(`sanitiseInjectedContent` 的 `signAssetUrl`),**追加发生在
允许名单判定之后**——否则一本书只要写一个第三方地址,就能让读者把自己的令牌送出去。
这两半必须都在,缺一个的症状都是「图片加载不出来」而且**不会报错**:只丢属性不报错,
只回 401 也不报错。UI 评审的替身服务因此对 `/assets` 也做鉴权(`access_token` 或
`Authorization` 二者之一),否则它会替真实服务端放行,让「图片加载不出来」在评审里
永远查不出来。
### `GET /library/formats`
本实例支持的格式清单,由格式注册表生成:
```json
{ "formats": [ { "format": "epub", "kind": "reflowable", "label": "EPUB(精排渲染,保留出版方样式)",
"extensions": ["epub"], "directory": false } ] }
```
客户端用它决定显示哪些入口,不必硬编码格式列表。
### `GET /books/:id/content`
流式返回书文件,`Content-Type` 按格式给 `application/epub+zip` 或 `application/pdf`。
书内容按哈希不可变,所以带 `ETag`,客户端可以放心长缓存。支持
`Range: bytes=`,所以**离线下载可以续传**。
目录型书籍(漫画目录)没有单一文件,返回 `400 DIRECTORY_BOOK`;
这类书按 `items?group=N` 逐卷取图。来源章节出版物没有整书文件,返回 `400 CHAPTER_BOOK`;
文件已从磁盘消失返回 `404 FILE_MISSING`。
## 书库(文件管理器)
书架回答「我能读什么」,这一组端点回答「磁盘上有什么」。两者的差别不是冗余:
书籍 DTO 里从来不带路径,所以「刚扫完目录但书架上看不到这本书」在本组端点之外
没有任何地方能解释清楚。这也是会**写** `BOOKS_DIR` 的一组端点(前提是挂载可写)。
路径都是**书库内相对路径**,`''` 表示根目录;每一个都会过
`resolveInside`,逃逸直接被拒。
> **信任边界**:路径上,挂载点就是边界,不再叠一层按路径的 ACL。**谁能写**由角色决定:
>
> | 端点 | 管理员 | 成员 |
> | --- | --- | --- |
> | `GET /library/browse`、`POST /library/browse/shelf` | ✓ | ✓(`shelf` 只改自己的书架) |
> | `POST /library/browse/move` / `rename` / `mkdir` / `delete` / `metadata` | ✓ | `403 ADMIN_REQUIRED` |
> | `GET /library/upload`、`POST /library/upload` | ✓ | 业务设置「书库 → 允许成员上传书籍」开启时(默认开启)✓,只能新增书籍文件(见下);关闭时 `POST` 返回 `403 UPLOAD_DISABLED` |
> | `POST /library/scan` | ✓ | `403 ADMIN_REQUIRED` |
### `GET /library/browse?path=<相对路径>&page=<页>`
```json
{ "path": "科幻/已读",
"crumbs": [ { "name": "书库", "path": "" }, { "name": "科幻", "path": "科幻" } ],
"parent": "科幻",
"entries": [
{ "name": "三体.epub", "path": "科幻/已读/三体.epub", "type": "file",
"size": 1234567, "mtime": 1789537864820, "mode": 420,
"hidden": false, "hiddenByRule": false, "scanned": true,
"ext": "epub", "indexed": true, "shelfState": "on" }
],
"total": 1, "dirs": 0, "files": 1, "size": 1234567,
"writable": true, "name": "已读" }
```
`hidden` 是「以点开头」,`hiddenByRule` 是「落在扫描器跳过的目录里」,
`scanned` 是「扫描器会把它当一本书」。
`shelfState` 是「**调用者**的书架有没有这本书」:`"on"` / `"off"`,
目录和不是书的条目是 `null`。「不在书架」这个状态是这个接口能回答、
而其它任何接口都回答不了的问题——读者把一本书从书架上拿掉之后,
书库页和书架页说的是两件不同的事,而只有前者知道原因。
它是按调用者算的(服务端用当前登录用户解析),不是条目自身的属性。
在书库页面里给每一行都画「在书架上」等于用一整列噪音盖住唯一一行例外,
所以界面只标记 `"off"`。
**被跳过的条目照样列出来。** `.trash`、`@eaDir` 这些目录是设计上不被扫描的,
在这里过滤掉就正好藏住了读者要找的那批文件——在磁盘上、但不在书架上。标记出来,
让界面能解释,而不是替读者决定不用知道。
`entries` 是**这一页**,`total` / `dirs` / `files` 是**整个目录**的。两者来自同一次
`readdir`,因为分开算会让摘要、分页器和它下面那几行在任何一次磁盘变化之后互相矛盾,
而读者没有办法判断三个数字里哪个是旧的。一页默认 200 条,`pageSize` 可以调小,
上界是 1000:书库面对的是文件系统,一个放四千张扫描件的目录是真实存在的,
把四千行发给浏览器再丢掉 3940 行,就是那个让屏幕显得坏掉的请求。
页码是非法的(`?page=abc`)时返回第一页而不是 400:页码是「列表里的一个位置」,
对一个读者无法处理的参数报错没有意义。超界由服务端夹(见 `BrowseService.list`)。
`writable` 是实际探测出来的(真的建一个临时目录再删),不是读 mode 位:
`drwxr-xr-x` 的只读 bind mount 看起来可写,写起来 `EPERM`。
符号链接按链接本身显示(`lstat`),不跟随——扫描器也不跟随,跟随等于描述一棵
索引里并不存在的树,还会顺着链接走出挂载点。
### `POST /library/browse/move` — 管理员
```json
{ "paths": ["科幻/三体.epub"], "target": "科幻/已读" }
```
→ `{ "moved": 1, "merged": 1, "target": "科幻/已读" }`
`merged` 是随移动一起改写索引路径的条目数;当前实现中每移动一项计一次,与 `moved` 相同。
同挂载点内 `rename`,不复制删除:文件字节、`mtime`、`inode` 都不变,扫描器视为
无变化,书籍身份(`identifier + 内容哈希`)因此保持,进度和笔记都还在。
同时把索引指向新路径,不等下一次扫描——文件管理器关掉时就该是一致的。
整批一起校验、一起拒绝:一半成功的移动比拒绝难推理得多。
目标已有同名条目 → `409 DESTINATION_EXISTS`。合并还是报错只有调用方知道,
猜错就是数据丢失。把目录移进它自己 → `400 MOVE_INTO_SELF`。
### `POST /library/browse/rename` — 管理员
```json
{ "path": "科幻/三体.epub", "name": "三体(修订版).epub" }
```
`name` 是单个路径段,不含斜杠。→ `{ "path": "科幻/三体(修订版).epub" }`
### `POST /library/browse/mkdir` — 管理员
```json
{ "path": "科幻", "name": "待整理" }
```
→ `{ "path": "科幻/待整理" }`
### `POST /library/browse/delete` — 管理员
```json
{ "paths": ["科幻/其他/球状闪电.epub"] }
```
→ `{ "removed": 1 }`
递归删除,**不进回收站**。索引行不动,留给下一次扫描标记 `missing`:
删除本身已经是危险的那一半,再绑一次全量扫描会让界面等好几分钟才报告成功。
勾选过的那一行也会一直留着,和「共享目录没挂上」是同一种状态——
「文件没了但书还记着」,比书架突然少一本好。
`paths` 必须是**非空字符串数组**:`"a"` 和 `["a"]` 只差一个键,
但把字符串悄悄包成数组,会让「删了 1 项」看起来像「删了很多项」。
只读挂载上任何写操作 → `403 READ_ONLY_MOUNT`。
### `POST /library/upload`
`multipart/form-data`。重复的 `file` 字段各带一个文件,字段 `path` 指定目标目录
(书库内相对路径,缺省根目录),字段 `onConflict` 决定重名怎么办。
```json
{ "uploaded": [
{ "path": "科幻/三体.epub", "originalName": "三体.epub", "name": "三体.epub",
"size": 1234567, "kind": "file",
"bookId": "…", "title": "三体" } ],
"skipped": [ { "name": "坏.zip", "reason": "无法解压(METHOD_UNSUPPORTED)" } ],
"scan": { "running": false, "scanned": 1, "added": 1, "updated": 0, "unchanged": 0,
"removed": 0, "failed": 0, "lastError": null,
"startedAt": 1789537864820, "finishedAt": 1789537864831, "durationMs": 11 } }
```
**这是往 `/books` 写入新书籍字节的端点。** `BOOKS_DIR` 可以按需以只读方式挂载,
也可以以可写方式挂载以启用上传和管理员文件管理。改名、移动、删除和新建目录等端点同样会修改
可写书库中的目录项;只读挂载时所有这些写操作统一返回 `403 READ_ONLY_MOUNT`。
**成员只能新增书籍。** 管理员的上传不受限制。成员能否上传由业务设置 `library.memberUpload`
(「我的 → 系统设置 → 服务配置 → 书库 → 允许成员上传书籍」,默认开启)决定;关闭时成员的
`POST` 在读取请求体之前就返回 `403 UPLOAD_DISABLED`。开启时,成员(`role=member`)的上传:
- 只保存扫描器能识别为书籍的文件(扩展名在 `GET /library/formats` 里),其余文件记入
`skipped`,原因「不是支持的书籍格式」。
- `.zip` / `.cbz` 按漫画压缩包原样保存,**不解包**——解包会把压缩包里的任意内容写进书库,
那属于文件管理。
- `onConflict=overwrite` 返回 `403 ADMIN_REQUIRED`,书库保持不变;`rename`(默认)、`skip`、
`fail` 可用。可以指定任意书库内目录作为 `path`,但不能新建目录(新建目录要管理员)。
上传流程:
- **接收完成后才写入书库。** 请求体先流到 `DATA_DIR/uploads` 的暂存文件,
接收中断不会把未接收完整的文件写入 `BOOKS_DIR`。同文件系统通过 `rename` 移入;
不同文件系统遇到 `EXDEV` 时复制到目标,再删除暂存文件。跨文件系统复制不具备原子性。
- **名字不是路径。** 客户端送的是文件名,不是路径:斜杠、`..`、控制字符、会让扫描器
看不见的前导点,一律改写或拒绝,再过一遍 `resolveInside`。`C:\Users\me\我的书\三体.epub`
只会取最后一段。
- **写完就索引。** 文件在磁盘上、书架上看不到,正是文件管理器要解释的那种状态,
所以上传以一次增量扫描收尾,而不是等调度器。一次请求扫一次,不是每个文件扫一次。
`onConflict`:
- `rename`(默认)— 保留两份,新的是 `三体 (2).epub`
- `skip` — 保留磁盘上已有的那本,新来的记进 `skipped`
- `overwrite` — 同一个路径,索引看到的是「原位编辑」,书保持身份,进度和笔记都在
- `fail` — 整批 `409 DESTINATION_EXISTS`
其他取值返回 `400 BAD_CONFLICT_POLICY`。请求不是 multipart 返回 `400 NOT_MULTIPART`,没有文件返回
`400 NO_FILES`。单文件上限 4 GiB,单次请求最多 20 个文件。`bookId` / `title` 只在文件是可识别的
书籍格式并已被索引时出现。
`.zip` / `.cbz` 会被当**合集**解开(`kind: "archive-entry"`)。这是读者真正下载到的
东西:四十卷装在一个包里。单层包裹目录会被抹平(`系列/第01卷.epub` 落成 `第01卷.epub`),
更深的树保持原样——`系列/第01卷/001.jpg` 是一本**目录书**,抹平它正好毁掉扫描器要读的结构。
压缩包里的名字一个都不信:每个条目按自己的段重建成名字,`../../etc/passwd`、绝对路径、
控制字符全部落在目标目录里。条目解不开(加密、冷门压缩方法)只记 `skipped`,
不会因为一条坏条目丢掉另外三十九卷。
上传进的每一个字节都复制进了 `DATA_DIR`,请求结束(成功或失败)即删——
一个上传不进去第二次的书库比没有这个功能更糟。
挂载只读 → 写入 `BOOKS_DIR` 前返回 `403 READ_ONLY_MOUNT`;请求暂存位于 `DATA_DIR`。
`GET /library/upload`(任何已登录用户)返回 `{ "writable": true, "allowed": true }`,让客户端在开始传 400MB 之前先问一句:`writable` 是书库根目录是否可写,`allowed` 是当前用户能否上传(管理员恒为 `true`,成员取 `library.memberUpload`)。
### `POST /library/browse/metadata` — 管理员
```json
{ "paths": ["科幻/合集", "科幻/合集/第三卷.epub"],
"fields": { "author": "某某", "series": "合集", "tags": "科幻,合集" } }
```
→ `{ "applied": 3, "books": ["…"], "failed": [ { "path": "空目录", "reason": "NOT_A_BOOK" } ] }`
一次改一批。手动覆盖层是按**书**存的,而书库是按批整理的:四十个「佚名」要的是
一次填同一个作者,不是四十个对话框。
`paths` 里给目录就等于给目录里扫描出来的每一本书:系列文件夹本身没有 `book_files` 行,
问它自己会什么都找不到。`fields` 与单本 `/books/:id/metadata` 同一份白名单——
批量不是写那些接口从不暴露的列的许可证。
不是书的路径记进 `failed` 而不是整批失败:选目录时捎上一个 `.nfo` 很常见,
界面因此可以说「已更新 12 本,跳过 2 项」。
### `POST /library/browse/shelf`
```json
{ "paths": ["旧扫描"], "action": "remove" }
```
```json
{ "bookIds": ["b_9f2…"], "action": "remove" }
```
→ 同 `metadata` 的结构。`action` 取 `add` / `remove` / `hide` / `unhide`,
其中 `add` 与 `unhide` 是同一个写、`remove` 与 `hide` 是同一个写
(两套名字来自早期想区分「加进书架」和「别再隐藏」的打算;只有一个标志位,
所以只有一对操作。名字保留是因为删掉一个已文档化的取值是破坏性变更)。
**目标两种命名,只收一种**:`paths`(文件管理器手里是路径)或 `bookIds`(一张卡片手里是
书)。`action` 不是这四个之一返回 `400 BAD_ACTION`。两个都给、或都不给,都是 400 `AMBIGUOUS_TARGETS`——两份命名说的是同一个写,
同时带上就是「调用方自己不知道该说哪一个」,合并还会让同一本书被写两次、被数两次。
**`bookIds` 是「从书架移除时,找不到「xxx」在磁盘上的路径」的修法**(#40)。
被拿掉过的那本书叫「半小时漫画宇宙大爆炸(半小时读完138亿年宇宙史,一口气搞懂大爆炸、
奇点、黑洞、引力波、暗物质……混子哥陈磊新作!)」,而磁盘上的文件不叫这个——扫描器是从
文件里读出书名的。客户端当时是这么补上路径的:列出**书库根目录的第一页**,拿书名和文件名
对(去掉扩展名)。这个猜测有两处必然失手:**书名不等于文件名**(常态,不是例外),
以及**列表是分页的**(根目录第一页之外的书、任何子目录里的书,都不在那次查找里)。
两处都失手时读者被告知找不到这本书——在他要求把它从**自己的书架**上拿掉之后。
映射其实**已经在服务端**了(`book_files.book_id` → `rel_path`),客户端一直在猜一件
服务端已经知道的事。id 形式按 id 解析:书名和文件位置都不参与。
一个 id 指向的书**没有活下来的文件**(`book_files` 里没有 `missing = 0` 的行)
记进 `failed` 而不是猜一个路径——书的可见性判据本来就要求「存在一个未丢失的文件」,
给一本读者打不开的书写书架状态只是在那个判据之外留下一行。
**磁盘上什么都不会变**,这正是重点:「把这四十份扫描从书架上拿掉」和
「把这四十份扫描移进某个目录」在一行行列表里只差一个词,后果却完全不同,
所以是两个端点。`remove` 写的是 `hidden = 1` 而不是删行——书还在书库里、还在索引里,
读者放得回去;删行在书架上长得一样,却不可逆。
**`add` 必须清掉 `hidden`,不能只是「不存在才插入」。** 这是 #40 报的
「书库的书不再自动加入书架,需要手动加入」的服务端一半:被拿掉过的书**已经有**一行
`user_books`(就是 `remove` 为了可逆写下的那一行,`hidden = 1`),所以
`INSERT OR IGNORE` 会跳过它,书继续不在书架上——读者选中它、点「加入书架」、
然后什么都没发生。可逆性只存在于表结构里而不存在于端点里,就不是可逆性。
`add` 用 upsert:不存在则插入,存在则清标志(并在确实重新加入时刷新 `added_at`,
所以「最近入库」对一本没离开过的书仍然指它第一次入库的时间)。
**两个方向是同一对操作,所以「在不在书架上」只有一个定义。**
`GET /books` 的判据是「`user_books` 行存在且 `hidden = 0`」**且**「存在一个未丢失的文件」,
本端点写的就是这两件事里的第一件,`/library/browse` 的 `shelfState` 读的是**两件**。
一行 `hidden = 0` 但文件掉了的书因此**不在书架上**,而不是「在书架上但你打不开」——
把两种状态分开描述,会同时让书架和书库各自说一半真话。
`applied` 是这一批真正改动的行数,`failed` 是没能动的那几项——**用调用方自己那份
命名**(`paths` 进来的就是路径,`bookIds` 进来的就是 id),理由是调用方要能把它对回
自己手里那个东西。`reason` 有三种:`NOT_A_BOOK`(这个路径不是一本书,可能是目录或
`.nfo`)、`NO_LIVE_FILE`(这个 id 的书没有活文件)、`NO_PATHS` / `NO_BOOK_IDS`
是整批被拒而不是单条失败。
客户端把它当作**句子**的依据:「加入书架 0 本」紧挨着一个看起来能按的按钮,
就是「这个按钮坏了」的来源,所以不是书的那几项要单独报出来。
### `GET /books/:id/file?path=<rel_path>`
流式返回这本书的**某一个**文件(二进制流,不是 JSON)。
只有一种格式需要它:以目录形式存放的漫画。它没有一个「书文件」可以整体下载,
客户端按页取。`path` 必须是 `/manifest` 的 `files` 里出现过的那个 `rel_path`——
服务端只按这份清单精确匹配,**从不**用 query 拼路径,所以路径穿越只会匹配到空。
**`files` 是契约,不是清单。** 两边由同一个页面遍历生成:manifest 列出哪些路径构成
这本书,本端点就服务哪些路径。目录书的页面哪怕住在压缩包里,也按**压缩包自己的路径**
列出(`第01卷.cbz`),因为那才是能拿来发请求的路径;请求这个路径拿到的是**这一页的图**,
不是压缩包本身。压缩包内部的页码由 `/assets?ref=page:卷:页` 寻址,不是文件路径,
也永远不会出现在 `files` 里。
manifest 的每个条目还带 `ref`,即这个文件对应的**资源引用**。要按页取用 `/assets?ref=`,
引用**原样**回传——它不是路径,也不是下标:卷的边界只有格式处理器知道,
客户端从 `rel_path` 反推会让「第 1 页」指向另一页。
响应与 `/content` 一致,但 `ETag` 是按文件而非按书。
### `GET /books/:id/cover`
返回封面。无封面时 `404 NO_COVER`。缓存头是 `immutable`(封面随书籍主键变化)。
### `PATCH /books/:id/metadata`
```json
{ "title": "三体(修订版)", "tags": "科幻,雨果奖", "seriesIndex": 1 }
```
可改字段:`title` `author` `publisher` `language` `isbn` `description` `series`
`seriesIndex` `pubdate` `tags`(`tags` 用逗号分隔的字符串)。其他键被忽略;一个可改字段都没有时返回
`400 BAD_REQUEST`。→ `{ "book": {...} }`(应用覆盖后的书籍 DTO)
写入的是覆盖层,**扫描永远不会覆盖它**。
### `DELETE /books/:id/metadata/:field`
撤销单个字段,回落到内嵌值。可用来实现 UI 上的「还原」按钮。→ `{ "book": {...} }`
---
### `PUT /books/:id/reading-status`
把书架上的书标记为想读、在读或已读,或清除标记(`null`),返回 204。不在当前账号书架上的书返回 404。
```json
{ "status": "want" }
```
`GET /books` 在书架范围内(默认 `scope`)接受 `status=reading|want|finished` 筛选,并给每本书带上 `readingState`(`"reading" | "want" | "finished" | null`):
- 手动标记的在读、已读优先;
- 否则按进度推断:进度 ≥ 99% 为已读,已开始为在读;
- 想读只来自标记,开始阅读后让位于在读。
`scope=library` 和单本 `GET /books/:id` 不带这个字段。
## 朗读(HTTP TTS)
这两条路由只在服务端配置了 HTTP 朗读合成接口 时才有意义,并且分工明确:
一条回答「这里能用什么」,一条真正合成音频。
### `GET /tts/voices`
能力查询,朗读设置面板打开时调用一次。
→ `{ "http": false, "timeoutMs": 20000, "formats": ["audio/mpeg", ...], "maxLength": 800, "voices": [] }`
- 未配置 HTTP 朗读合成接口 时返回 `http: false` 而**不是 404**:客户端问的是「这里能用什么引擎」,
不是「这个接口存在吗」。一个 HTTP 朗读合成接口 没配的实例是完全正常的实例。
- `maxLength` 无论开关都返回:客户端会按它切句,这个决定发生在选引擎之前。
- 配置了之后 `voices` 来自 音色列表接口(或约定路径 `<合成接口>/voices`)。
### `GET /tts?text=<文本>&voice=<可选>&speed=<可选>&format=<可选>`
合成一条语句,返回音频字节流(`audio/*`)。
- **一次一条语句,不是一章**。客户端本来就是逐句朗读(理由见架构文档第 9 节),
而按请求切分正是让暂停、续读、下一句、改语速在远程引擎上也能工作的前提;
它同时把响应限制在手机可以边走边缓冲的大小,并让缓存以句为单位而不是以书为单位。
- `speed` 夹在 0.25–4;`text` 上限 800 字,超了返回 `TEXT_TOO_LONG`,
**不会转发给上游**。
- 上游返回非 `audio/*` 时返回 `TTS_CONTENT` 而不是把 HTML 当音频流出去:
一个配错的 HTTP 朗读合成接口(指到一个网页)会以 200 返回 HTML,塞进 `<audio>` 里
既没有声音也没有事件——这是最难排查的一种「成功」。
- 这是唯一接受**查询串令牌**的接口之一(另一个是 `/books/:id/assets`、`/cover`、`/content`):
`<audio src>` 无法携带 `Authorization` 头。因此它被精确地限制为 `GET /tts`,
能力查询 `/tts/voices` 不支持查询串令牌。
- 响应带 `cache-control: private, no-store`,避免服务配置更新后继续使用旧音频;服务端按上游配置缓存合成结果,
并用 `x-tts-cache: hit | miss` 标明本次是否命中缓存。
- 未配置时返回 `400 TTS_DISABLED`;`text` 为空返回 `400 BAD_REQUEST`。上游失败细分为 `TTS_AUTH`(401/403)、
`TTS_TIMEOUT`、`TTS_CONTENT`(非音频)、`TTS_EMPTY`(空音频)和 `TTS_UPSTREAM`(其余),均为 400。
在「系统设置 → 服务配置 → HTTP 朗读」修改以下配置:
| 配置项 | 说明 |
| --- | --- |
| HTTP 朗读合成接口 | 上游合成服务的地址,例如 `http://127.0.0.1:5002/tts`。不设则关闭 HTTP 朗读 |
| 认证令牌 | 以 `Authorization: Bearer` 转发给上游 |
| 音色列表接口 | 语音列表地址;默认 `<合成接口>/voices` |
| 请求超时(毫秒) | 单次合成超时,默认 20000 |
| 音频缓存上限(字节) | 音频磁盘缓存上限(`DATA_DIR/tts-cache`),默认 256MiB;0 关闭缓存 |
## 静态客户端
### `GET /` 与 `GET /*` — 公开
服务端会托管构建好的 H5 客户端(构建时打进镜像,或由 `WEB_DIR` 指定)。
- 找不到客户端产物时这些路由**不会注册**,纯服务端部署不受影响
- 都是公开的:它们只是静态资源,背后的 API 仍然要令牌
- 不存在的文件路径一律回落到 `index.html`;但 `/api/...` 下未匹配的路径仍返回 JSON `404 NOT_FOUND`
- 只有 `assets/` 下带内容哈希的文件名缓存一年(`immutable`);其余文件(含 `index.html`、固定名的
`assets/client.js`、`sw.js`)都是 `no-cache`,否则升级后拿不到新产物
## 书库
### `GET /library/facets`
→ `{ "authors": [...], "series": [...], "tags": [...], "formats": [...] }`
用来构建筛选界面。
### `GET /library/continue?limit=20`
继续阅读列表,按最近阅读时间倒序,`limit` 默认 20、上限 100,响应为 `{ "items": [...] }`。每一项是**一整本书**(与 `GET /books` 同样的字段)
加上 `percentage`、`chapterTitle` 和 `lastReadAt`。
**它是 `Book` 的扩展,不是另一套形状。** 这里曾经返回进度行自己的命名——
`bookId` / `updatedAt`——而客户端的 `ContinueReadingItem extends Book` 读的是
`id`,卡片上的 `title` 和 `coverUrl` 恰好两种拼法同名,所以卡片画得好好的,
点下去却把 `{ id: undefined }` 交给路由,也就是 `#/book/undefined`:404,
然后被报成「这本书不在书架上了」。两个手写类型的字段名漂移,中间没有任何一个
请求里出现过正确的那个名字。现在 `id` / `addedAt` / `manualFields` 都是必需字段,
服务端再退回窄形状会直接编译不过,而不是把失败推迟到一次点击。
选书这一列走的是 `ShelfService`,不是另写一条 `SELECT`:同一个 join、同一层手动覆盖、
同一个 `addedAt`。旧查询直接 join `books`,于是读者在书库管理页改过的书名,
书架上是新的、卡片上是旧的——同一屏上同一本书有两个名字。
**它只列出书架也会列出的书。** 可见性判据与 `GET /books` 一致:`hidden = 0`
**且具有可用的本地文件、下载文件或当前用户的章节出版物**。对本地文件书,少了可用文件判断,一份被删掉、或唯一副本在没插上的移动硬盘上的书
仍然出现在这一行里,而点开会得到 404 —— 客户端把它说成「这本书不在书架上了」。
那句话是真的,错的是那张卡片,而它正画在同一屏的「共 N 本」下面。
两个接口必须选同一个集合,修法是把判据做成同一句话,而不是教客户端先自己查一遍:
先查一遍只会把「读者点不开的书」修成「读者看不见的书」。
### `GET /library/stats`
→ `{ "myBooks": 3, "libraryBooks": 10, "files": 12, "missingFiles": 1,
"formats": [{"format":"epub","n":9}], "scan": {...} }`
### `POST /library/scan` — 管理员
触发一次扫描,同步等待结果。并发调用会共享同一次扫描。
→ `{ "result": { "running": false, "scanned": 12, "added": 1, "updated": 0, "unchanged": 11,
"removed": 0, "failed": 0, "lastError": null, "startedAt": ..., "finishedAt": ..., "durationMs": 840 } }`
### `GET /library/scan`
→ `{ "progress": { "running": false, "scanned": 12, "added": 1, ... } }`
---
## 同步
### `GET /sync?since=<ms>&bookId=<可选>`
`since` 是上次响应里的 `serverTime`(首次传 0)。
```json
{ "serverTime": 1789537864820,
"progress": [ { "bookId": "...", "locator": "epubcfi(/6/4!/4/2)", "percentage": 0.42,
"chapterTitle": "第七章", "device": "android-a", "updatedAt": 1000 } ],
"notes": [ { "id": "...", "bookId": "...", "type": "highlight", "locator": "...",
"text": "...", "comment": "...", "color": "yellow",
"updatedAt": 1789537864623, "deleted": false } ] }
```
**客户端必须保存返回的 `serverTime`**,下次带回来才能只拿增量。
### `POST /sync`
```json
{ "progress": [ { "bookId": "...", "locator": "...", "percentage": 0.42,
"chapterTitle": "第七章", "device": "android-a", "updatedAt": 1000 } ],
"notes": [ { "id": "...", "bookId": "...", "type": "highlight", "locator": "...",
"text": "...", "comment": "...", "color": "yellow",
"updatedAt": 1000, "deleted": false } ] }
```
两个数组至少给一个(都缺、或不是数组返回 `400 BAD_PAYLOAD`),总数上限 5000(超了返回 `400 BATCH_TOO_LARGE`)。
`updatedAt` 是**客户端时钟**的毫秒时间戳,决定 LWW 谁赢。
响应把合并后的当前状态(相当于 `since=0` 的全量拉取)一并返回,客户端不用再多发一次拉取:
```json
{ "accepted": 1, "rejected": 0, "serverTime": 1789537864820,
"progress": [...], "notes": [...] }
```
注意:`notes[].type` 只接受 `note` / `highlight` / `bookmark`,其他值整批返回 `400 BAD_NOTE_TYPE`。
`notes[].deleted: true` 是删除(写墓碑)。
### `GET /sync/progress/:bookId`
→ `{ "progress": {...} | null }`
### `GET /sync/progress/:bookId/history`
`?limit=`(默认 30,最大 50)→ `{ "history": [ {...}, ... ] }`,字段同上,按 `updatedAt` 倒序。
阅读记录:每次有效的进度写入都会记下,**包括在「后写者胜」合并中输掉的那次**(例如离线设备的旧位置)。
同一设备在同一章、半小时内、进度相差不到 5% 的连续写入合并成一条并向前移动;换章、跳转、换设备、乱序到达的写入各成一条。
每本书保留最新 50 条,只返回当前账号自己的记录。
### `PUT /sync/progress/:bookId`
单个进度上报的便捷写法:
```json
{ "locator": "epubcfi(/6/4!/4/2)", "percentage": 0.42,
"chapterTitle": "第七章", "device": "android-a", "updatedAt": 1789537864623 }
```
`percentage` 会被夹到 `[0, 1]`,`updatedAt` 缺省为服务端当前时间。→ `{ "progress": {...} }`
### `GET /notes?bookId=<可选>&since=<可选>`
→ `{ "notes": [...], "serverTime": 1789537864820 }`。已过滤掉墓碑,只返回可见笔记。
### `POST /notes`
```json
{ "bookId": "...", "type": "highlight", "locator": "...",
"text": "选中的原文", "comment": "我的批注", "color": "yellow" }
```
`bookId` 必填;`type` 缺省为 `note`。`id` 可省略或由客户端生成(推荐,便于离线创建)。返回 `201 { "note": {...} }`。
### `DELETE /notes/:id`
写墓碑。幂等:不存在也返回 `{ "ok": true }`。
---
## 本地与 OPDS 来源
以下接口需账号令牌。内置类型为 reader.local 和 reader.opds,另可安装扩展插件提供的来源类型;管理员配置来源,用户浏览、搜索和获取书籍。
| 接口 | 用途 |
| --- | --- |
| GET /sources/types | 查询已安装的来源类型及能力(`{types}`,含 `pluginId`、`builtin`) |
| GET /sources | 查询来源实例(`{sources}`);普通用户不返回管理配置 |
| POST /sources | 管理员创建来源 |
| PATCH /sources/:id | 管理员修改 `name`、`config`、`enabled` 或 `isDefault`,至少给一项 → `{source}` |
| DELETE /sources/:id | 管理员删除来源 → `{ok:true}` |
| GET /sources/:id/browse?ref=&cursor=&limit= | 浏览目录;`limit` 1–200 |
| POST /sources/:id/search | 搜索书籍(SSE,见下) |
| POST /sources/:id/search/cancel | body `{sessionId}`,取消进行中的搜索 → `{ok:true}` |
| GET /sources/:id/search-filters | 来源声明的搜索筛选项 |
| GET /sources/:id/entries?ref=条目引用 | 查询条目详情 |
| POST /sources/:id/acquire | body `{entryRef, optionId?}`,获取条目并加入当前账号书架 → `{kind:"ready", publicationId}` |
| GET /sources/:id/credentials | 当前账号在该来源的凭据状态:`available`、`fields:[{key,label,configured}]` 等,不含明文 |
| PUT /sources/:id/credentials/:key、DELETE 同路径 | 保存(body `{value}`)/ 删除当前账号的一项凭据 → `{ok:true}` |
| GET /sources/:id/publications/:publicationRef/manifest | 调试用:直接读取来源的章节目录 |
| GET /sources/:id/publications/:publicationRef/resource?ref= | 调试用:读取单个章节资源,只返回 JSON `{mediaType, text?, base64?}` |
| GET/POST /sources/:id/pages/:pageId | 管理员:来源自定义配置页面(POST body `{action, values}`) |
创建 OPDS 示例:
```json
{"pluginId":"reader.opds","sourceType":"opds","name":"家庭 OPDS","config":{"url":"https://books.example.test/opds","username":"reader"}}
```
pluginId 是接口兼容字段。创建返回 201 与 source 对象。本地来源使用 BOOKS_DIR;OPDS 文件下载到 DATA_DIR/acquired。来源浏览、搜索和详情的游标及条目引用以响应为准,不应从外部 URL 自行拼接。
搜索请求 body 必填 query 和 sessionId(16–80 位字母、数字、`_`、`-`),可选 cursor、limit(1–200)、resultLimit(1–10000)和 filters;响应以 text/event-stream 返回 results、done(`{reason: "complete" | "limit"}`)或 error 事件。获取请求 body 使用 entryRef。
当前账号可使用 PUT /sources/:id/credentials/password,body 为 {"value":"密码"},保存自己的 OPDS 密码;凭据不会通过普通来源列表回显。暂停来源不删除已获取的文件书。
### 来源书籍(章节出版物)
以下接口针对当前账号书架上、由来源获取的书:
| 接口 | 用途 |
| --- | --- |
| POST /books/:id/refresh | 立即检查远程章节目录更新;非来源章节书返回 `400 SOURCE_UNSUPPORTED` |
| POST /books/:id/refresh-chapter | body `{ref}`,绕过缓存重新取一章,直接返回章节内容(`no-store`) |
| GET /books/:id/source-options | → `{canSwitch}`,是否可以换源 |
| POST /books/:id/alternatives | body `{sessionId, cursor?, resultLimit?}`,以 SSE 返回可替换的来源结果(事件同搜索) |
| POST /books/:id/switch-preview | body `{entryRef}`,读取候选条目的目录 → `{chapters:[{id,title}], latestChapter}` |
| POST /books/:id/switch-source | body `{entryRef, chapterId, revision}`,换源 |
| POST /books/:id/switch-quality | body `{entryRef, chapterId}`,试读候选条目的一章用于比较(返回该章内容、`latestChapter` 和 `elapsedMs`),不改动书 |
| GET /subscriptions | 当前账号的章节更新订阅(`{subscriptions}`) |
| PATCH /books/:id/subscription | body `{enabled?, intervalMinutes?(15–10080), acknowledge?}` → `{subscription}` |
### 插件(管理员)
| 接口 | 用途 |
| --- | --- |
| GET /plugins | 已安装插件(`{plugins}`) |
| POST /plugins | body `{trusted: true, package}`(npm 包名)或 `{trusted: true, folder}` 安装 → `201 {plugin}`;缺 `trusted: true` 返回 `400 PLUGIN_TRUST_REQUIRED` |
| POST /plugins/upload | multipart:字段 `trusted=true` 与一个 `file`(`npm pack` 生成的 `.tgz`,≤100 MiB)→ `201 {plugin}` |
| PATCH /plugins/:id | body `{enabled: boolean}` → `{plugin}` |
| DELETE /plugins/:id | 卸载 → `{ok:true}` |
| GET/POST /plugins/:id/pages/:pageId | 插件自定义页面(POST body `{action, values}`) |
Reader 对外提供的只读 OPDS 服务(`/opds`,Basic 认证;客户端凭据由 `GET/POST /api/v1/opds/credentials`、`DELETE /api/v1/opds/credentials/:id` 管理)与 WebDAV 备份上传见[接入指南](opds-webdav.zh-CN.md)。
## 影音
接口前缀为 /api/v1/media,沿用账号令牌。用户只能访问已授权媒体库;建库、权限、扫描、刮削、识别规则、作品 / 版本整理和人工资料编辑需要管理员权限。准备中返回 503 MEDIA_STARTING,启动失败返回 503 MEDIA_UNAVAILABLE。阅读健康检查不能替代影音就绪检查。
影音运行在独立 worker 中,阅读进程只做转发:同时在途请求超过 64 个返回 503 MEDIA_BUSY;转发中断返回 503 MEDIA_REQUEST_UNCERTAIN(写操作请重新查询结果确认);目录查询期间账号或可访问媒体库发生变化返回 503 MEDIA_ACCESS_CHANGED。
| 接口(省略 /api/v1/media) | 用途 |
| --- | --- |
| GET /libraries | 当前账号可访问的媒体库 |
| POST /libraries | 创建本地或 OpenList 媒体库,返回 201;目录不可访问返回 400 MEDIA_DIRECTORY_UNAVAILABLE |
| GET /libraries/:id | 单个媒体库 |
| GET /library-summaries | 管理员:各媒体库统计摘要 |
| GET /libraries/:id/configuration | 管理员查看连接配置摘要,不回传凭据原值 |
| PATCH /libraries/:id | 修改名称或 OpenList 凭据 |
| PUT /libraries/:id/access | body `{access: "all" \| "restricted", userIds}`,返回 204 |
| POST /libraries/:id/scan | 提交扫描任务,返回 202 |
| GET /scan-jobs、POST /scan-jobs、DELETE /scan-jobs/:id | 管理员:各库最近扫描任务 / 扫描全部库(202)/ 删除任务记录(204) |
| POST /jobs/:id/cancel | 管理员取消扫描任务,返回 202 |
| GET /libraries/:id/missing-resources?path=库内相对目录 | 管理员预览当前目录及子目录的缺失资源清理范围;path 空字符串表示根目录 |
| POST /libraries/:id/missing-resources/cleanup | 管理员提交 {path,revision};revision 来自预览,范围变化返回 MEDIA_CLEANUP_CHANGED |
| GET /libraries/:id/jobs、GET /jobs/:id | 查看任务状态 |
| GET /libraries/:id/folders?path=&offset=&limit= | 按目录浏览媒体库(`limit` ≤200) |
| GET /libraries/:id/assets、GET /assets/:id、GET /assets/:id/catalog | 库内文件列表(分页)、单个文件、文件所关联的作品与版本 |
| GET /assets/:id/subtitles、GET /assets/:id/subtitles/:subtitleId | 字幕列表与字幕内容 |
| GET /parts/:id/lyrics | 歌词 |
| GET /browse、GET /libraries/:id/items | 按频道、分类或库浏览;`/browse` 必填 `channel`、`kind` |
| GET /narrators、GET /libraries/:id/narrators | 有声书演播者 |
| GET /items/:id | 作品、版本与章节详情 |
| GET /items/:id/cover | 作品封面 |
| GET /items/:id/album-playback、season-playback、series-playback | 专辑 / 季 / 剧集的连续播放列表 |
| GET /search、GET /favorites、GET /history、GET /queue | 搜索(必填 `query`)及当前账号收藏、历史与队列 |
| GET /items/:id/favorite、PUT /items/:id/favorite | 读取 / 设置收藏,body 为 {"favorite":true} 或 {"favorite":false} |
| POST /queue、DELETE /queue/:id、POST /queue/:id/move | 追加(body `{partIds}`)、移除、上下移动(body `{neighborId, direction: "up" \| "down"}`)队列项 |
| POST /queue/preview、PUT /queue/snapshot、POST /queue/clear | 预览队列、按 `expectedRevision` 整体替换队列、按频道清除指定条目(body `{channel, entryIds}`) |
| PATCH /items/:id/metadata | 管理员保存人工资料,返回作品详情 |
| POST/PUT /items/:id/hierarchy、POST/PUT /items/:id/parent | 管理员:调整视频所属剧集 / 季,或音乐所属专辑(均带 `expectedParentId` 并发校验) |
| PATCH /editions/:id、PUT /editions/:id/item、POST /editions/:id/new-item、POST /editions/:id/split、POST /editions/:id/merge、PUT /editions/:id/order | 管理员:重命名、改归属、拆出新作品、拆分、合并版本及调整分段顺序(带 expected 字段并发校验) |
| GET /metadata/providers | 管理员:可用在线资料提供者 |
| POST /items/:id/matches、GET /items/:id/matches、PUT /items/:id/match、DELETE /items/:id/match | 管理员:搜索候选(body `{provider, query, artist?}`)、读取已有候选、确认资料匹配(body `{candidateId}`)、清除匹配 |
| POST /items/:id/auto-match | 管理员:按 `provider` 自动匹配 |
| GET /scrape-jobs、POST /scrape-jobs、GET /scrape-jobs/:id、GET /scrape-jobs/:id/results | 管理员:刮削任务列表(`?summary=true` 只要摘要)、提交(body `{provider, itemIds, matchMode?}`,202)、详情、结果分页(`limit` ≤50) |
| POST /scrape-jobs/:id/retry、POST /scrape-jobs/:id/cancel、DELETE /scrape-jobs/:id、POST /items/:id/child-scrape-job | 管理员:重试(202)、取消、删除(204)、刮削剧集子项(202) |
| GET/PUT /libraries/:id/recognition-rule、POST /libraries/:id/recognition-preview、POST /libraries/:id/recognition-apply | 管理员:目录识别规则、预览识别结果、按预览应用到选中文件 |
| POST /ai-scan-jobs、GET /ai-scan-jobs、GET /ai-scan-jobs/:id、GET /ai-scan-jobs/:id/batches、DELETE /ai-scan-jobs/:id | 管理员:AI 识别任务(提交 body `{libraryId?, path?}`,202) |
| POST /playback | 根据 partId 创建播放会话,返回 201 |
| POST /playback/:id/renew | 续期播放会话 |
| POST /playback/recover | body `{partId, previousSessionId}`,按旧会话恢复播放,返回 201 |
| GET /parts/:id/progress、PUT /playback/:id/progress | 读取与保存播放进度(body `{sequence, revision, position, completed?}`) |
| POST /background-grants、DELETE /background-grants/current、/background/playback… | 后台播放授权及其专用播放、续期、进度、字幕接口,使用 `X-Media-Background` 头,见[后台播放](media-background-auth.md) |
| GET /streams/:id?ticket= | 不用 Bearer 令牌,凭查询串中的播放票据访问;OpenList 默认 307 跳转至临时下载地址,本地或代理模式返回字节流。`proxy=1` 请求代理回退,支持有效的字节 Range(无法满足返回 416 MEDIA_RANGE),文件缺失返回 404 MEDIA_MISSING;响应不缓存 |
本地建库请求示例:
```json
{"name":"家庭电影","kind":"video","storage":"local","root":"/media/video","access":"restricted"}
```
kind 为 video、music 或 audiobook;OpenList 建库和凭据更新见[OpenList API](media-openlist.md#api)。媒体路径属于服务端或容器,不是浏览器所在设备的路径。
播放会话使用具体 partId,不以作品名称或文件路径代替。流地址、有效期及进度并发字段以服务端响应为准;客户端不持久保存临时下载链接。权限撤销和资源失效需重新处理,不能绕过鉴权重用旧链接。后台专用授权见[后台播放](media-background-auth.md)。
完整请求 schema 及响应以[当前路由](../server/src/http/routes/media.ts)为准,使用流程见[影音手册](media-user-guide.zh-CN.md),数据隔离见[影音架构](media-implementation.md)。
## 管理员
### `GET /admin/users`
→ `{ "users": [ { "id": "...", "username": "...", "displayName": "...",
"role": "member", "disabled": false, "createdAt": 0 } ] }`
### `POST /admin/users`
```json
{ "username": "family", "password": "familypass123",
"displayName": "家人", "role": "member" }
```
返回 `201 { "user": {...} }`。`role` 缺省为 `member`。不受注册策略限制——管理员本就该能加家庭成员。
### `PATCH /admin/users/:id`
`{ "disabled": true }` 或 `{ "role": "admin" }` → 更新后的完整列表 `{ "users": [...] }`。用户不存在返回 `404`。
不能停用自己或移除自己的管理员角色(`400 SELF_LOCKOUT`),并须保留至少一名启用的管理员(`400 LAST_ADMIN`)。停用立即撤销旧会话,重新启用仍需重新登录。
### `POST /admin/users/:id/password`
`{ "password": "至少8位的新密码" }` → `{ "ok": true }`。重置其他用户的密码,同时撤销其访问令牌和续期令牌。本人请使用 `/auth/password`,对自己调用返回 `400 SELF_LOCKOUT`。
### `GET /admin/registration`、`PATCH /admin/registration`
GET 返回 `{ "mode": "closed", "invites": [...] }`;PATCH 接受 `{ "mode": "closed" | "open" | "invite" }` 并返回 `{ "mode": ... }`,对应关闭、开放、仅邀请码注册。数据库设置优先于环境变量,重启后保留。首个账号仍可直接注册为管理员。
`GET /instance` 额外返回 `registrationMode` 和 `invitationRequired`,不暴露邀请码;`registrationOpen` 为开放或邀请模式时均为 true。
### `POST /admin/invites`、`DELETE /admin/invites/:id`
创建参数 `{ "label": "家人", "maxUses": 1, "days": 7 }`,备注最多 80 字、次数 1–1000、有效期 1–365 天。返回 `201 { "code": "新邀请码", "invite": {...} }`。明文仅创建时返回,数据库只保存摘要。
列表条目含 `id/label/maxUses/usedCount/expiresAt/disabled/createdAt`。DELETE 停用邀请码(返回 `{ "ok": true }`),不删除已注册用户。邀请码消费与账号创建在同一事务内,失败不扣次数,并发不超额。上述管理接口均仅管理员可用。
### `GET /providers`
→ `{ "providers": [ { "id": "none", "displayName": "...", "enabled": true } ] }`
本地书籍元数据提供者目前只有 `none`;影音的在线资料提供者使用 `/media/metadata/providers`。
## AI
AI 配置在业务设置的 `ai` 分组(见下文 Administrator business settings)。
### `POST /admin/ai/models` — 管理员
`{ "baseUrl": "可选", "apiKey": "可选" }` → `{ "models": ["..."] }`。未传的字段使用已保存配置,所以可以在保存之前用草稿获取模型列表;
两者都缺返回 `400`,上游失败返回 `502 AI_MODELS_FAILED`。
### `GET /ai/capabilities`
任何已登录账号可读 → `{ "summary": true }`。`summary` 为 AI 与章节总结都已启用且地址、密钥、模型齐全;不返回任何配置值。
阅读器据此决定是否显示「AI 总结」按钮;旧服务端没有此接口(404)时客户端照旧显示。
### `POST /ai/summary`
`{ "bookId": "...", "chapterId": "...", "content": "章节正文" }` → `{ "summary": "...", "cached": false }`。
正文按配置的 `maxInputChars` 截断;结果按账号、书、章节、正文哈希和提示词哈希缓存。AI 或章节总结未启用返回 `403 AI_DISABLED`,
地址 / 密钥 / 模型未配置完整返回 `400`。
---
## 错误码
| 码 | HTTP | 含义 |
| --- | --- | --- |
| `BAD_REQUEST` | 400 | 参数不合法 |
| `BAD_PAYLOAD` | 400 | 请求体结构错误 |
| `BATCH_TOO_LARGE` | 400 | 同步批次超过 5000 |
| `BAD_NOTE_TYPE` | 400 | 笔记 `type` 不是 `note` / `highlight` / `bookmark` |
| `REGISTRATION_DISABLED` | 400 | 实例已关闭公开注册 |
| `INVITE_REQUIRED` | 400 | 邀请模式下未填写邀请码 |
| `INVITE_INVALID` | 400 | 邀请码无效、过期、停用或已用完 |
| `SELF_LOCKOUT` | 400 | 不能停用自己、移除自己的管理员角色或经管理员接口重置自己的密码 |
| `LAST_ADMIN` | 400 | 必须保留至少一名启用的管理员 |
| `NO_TOKEN` | 401 | 缺少 Bearer 令牌 |
| `TOKEN_INVALID` | 401 | 签名错误 / 载荷被改 / 类型不对 |
| `TOKEN_EXPIRED` | 401 | 访问令牌过期,用 refresh 换新 |
| `REFRESH_INVALID` | 401 | 刷新令牌无效 / 已使用 |
| `REFRESH_EXPIRED` | 401 | 刷新令牌过期,需要重新登录 |
| `BAD_CREDENTIALS` | 401 | 用户名或口令错误 |
| `ACCOUNT_DISABLED` | 403 | 账号被停用 |
| `FORBIDDEN` | 403 | 越权访问,或请求来源不在 CORS 允许列表中 |
| `PATH_TRAVERSAL` | 403 | 路径逃逸出挂载点 |
| `DIRECTORY_BOOK` | 400 | 这本书由目录承载,按页取而不是整本下载 |
| `UNSUPPORTED_FORMAT` | 400 | 该格式不提供可寻址结构 |
| `EMPTY_ASSET` | 400 | 资源既没有数据也没有流 |
| `ADMIN_REQUIRED` | 403 | 需要管理员权限 |
| `UPLOAD_DISABLED` | 403 | 成员上传已在业务设置中关闭(`library.memberUpload`) |
| `READ_ONLY_MOUNT` | 403 | 书库目录不可写,无法上传 / 改名 / 移动 / 删除 / 新建目录 |
| `DIR_NOT_FOUND` | 404 | 目录不存在 |
| `NOT_A_DIRECTORY` | 400 | 目标不是目录 |
| `PATH_NOT_FOUND` | 404 | 路径不存在 |
| `DESTINATION_EXISTS` | 409 | 目标位置已有同名条目 |
| `MOVE_INTO_SELF` | 400 | 不能把目录移动到它自己里面 |
| `BAD_NAME` | 400 | 名称含斜杠或非法 |
| `NAME_REQUIRED` | 400 | 名称为空 |
| `NO_PATHS` | 400 | 没有给出路径 |
| `NO_BOOK_IDS` | 400 | 没有给出书籍 id |
| `BAD_BOOK_ID` | 400 | `bookIds` 中含非字符串 |
| `BAD_ACTION` | 400 | 书架批量操作的 `action` 不合法 |
| `AMBIGUOUS_TARGETS` | 400 | `paths` 与 `bookIds` 都给或都没给 |
| `NOT_MULTIPART` | 400 | 上传请求不是 `multipart/form-data` |
| `NO_FILES` | 400 | 上传请求里没有文件 |
| `EMPTY_UPLOAD` | 400 | 上传的文件为空 |
| `EMPTY_ARCHIVE` | 400 | 上传的压缩包里没有文件 |
| `BAD_CONFLICT_POLICY` | 400 | `onConflict` 取值不合法 |
| `SEARCH_LOCAL_LAYOUT` | 400 | 使用自定义 TXT 标题规则的书须在客户端本地搜索 |
| `NOT_FOUND` | 404 | 资源不存在 |
| `NO_COVER` | 404 | 这本书没有封面 |
| `FILE_MISSING` | 404 | 文件已从磁盘消失 |
| `USERNAME_TAKEN` | 409 | 用户名已占用 |
| `USERNAME_TOO_SHORT` | 409 | 用户名少于 3 位 |
| `PASSWORD_TOO_SHORT` | 409 | 口令不在 8–1024 位之间 |
| `CONFLICT` | 409 | 个人纠错版本已过期 |
| `SETTINGS_CONFLICT` | 409 | 业务设置 revision 已过期 |
| `SETTINGS_TEST_BUSY` | 409 | 同一分组的连接测试正在进行 |
| `BACKUP_BUSY` | 409 | 已有 WebDAV 备份正在上传 |
| `SETTINGS_TEST_FAILED` | 502 | 业务设置连接测试失败 |
| `AI_DISABLED` | 403 | AI 或对应 AI 功能未启用 |
| `AI_MODELS_FAILED` | 502 | 获取 AI 模型列表失败 |
| `TTS_DISABLED` | 400 | 该实例没有配置 HTTP 朗读合成接口,无法使用 HTTP 朗读 |
| `TTS_UPSTREAM` | 400 | 上游合成服务不可达或报错 |
| `TTS_AUTH` | 400 | 上游合成服务拒绝认证(401/403) |
| `TTS_TIMEOUT` | 400 | 上游合成超时 |
| `TTS_CONTENT` | 400 | 上游返回的不是音频 |
| `TTS_EMPTY` | 400 | 上游返回空音频 |
| `TEXT_TOO_LONG` | 400 | 单条语句超过 800 字,请客户端先切句 |
| `INVALID_SOURCE_CONFIG` | 400 | 来源配置不符合内置来源要求 |
| `SOURCE_UNSUPPORTED` | 400 | 来源未声明或未实现请求能力 |
| `SOURCE_DISABLED` | 400 | 来源实例已停用 |
| `SOURCE_NOT_FOUND` | 404 | 来源实例不存在 |
| `SOURCE_TYPE_NOT_FOUND` | 404 | 来源类型未安装 |
| `PLUGIN_UNAVAILABLE` | 404 | 来源实例所属插件未安装或未加载 |
| `PLUGIN_TRUST_REQUIRED` | 400 | 安装插件时未确认 `trusted: true` |
| `PLUGIN_INVALID_PACKAGE` | 400 | 上传的插件不是 `.tgz` 安装包 |
| `PLUGIN_PACKAGE_TOO_LARGE` | 400 | 插件安装包超过 100 MiB |
| `ENTRY_NOT_FOUND` | 404 | 来源条目不存在 |
| `AUTH_REQUIRED` | 401 | 来源需要凭据或凭据无法解密 |
| `OPDS_UNSAFE_URL` | 400 | OPDS 链接跳转到未允许的 origin 或包含 URL 凭据 |
| `RESOURCE_TOO_LARGE` | 413 | OPDS Feed 或资源超过大小限制 |
| `ACQUISITION_TOO_LARGE` | 413 | 获取文件超过 256 MiB 上限 |
| `CHAPTER_BOOK` | 400 | 章节出版物没有整书文件,请按 manifest 读取章节 |
| `CHAPTER_SNAPSHOT_EXPIRED` | 409 | 旧目录的请求章节未缓存,无法再取得该版本 |
| `CHAPTER_TOO_LARGE` | 413 | 章节正文或声明大小超过 2 MiB;HTML 章节内嵌图片超过 32 张或展开后超过 8 MiB |
| `MANIFEST_TOO_LARGE` | 413 | 章节目录超过 10,000 项或 2 MiB |
| `INVALID_MANIFEST` | 400 | 章节目录版本字段不合法 |
| `INVALID_RESOURCE` | 400 | 获取内容为空、媒体类型不匹配或解析失败 |
| `RATE_LIMITED` | 429 | 上游限流或宿主来源并发达到上限 |
| `SOURCE_TIMEOUT` | 504 | 宿主来源调用超过 60 秒 |
| `SOURCE_CANCELLED` | 499 | 客户端断开或主动取消来源调用 |
| `MEDIA_STARTING` | 503 | 影音服务正在准备 |
| `MEDIA_UNAVAILABLE` | 503 | 影音服务启动失败或不可用 |
| `MEDIA_BUSY` | 503 | 影音转发的在途请求已满 |
| `MEDIA_REQUEST_UNCERTAIN` | 503 | 影音请求转发中断,写入结果需重新查询确认 |
| `INTERNAL` | 500 | 服务端错误 |
## 客户端应当遵守的约定
这几条不是服务端强制,而是客户端行为的一部分,写在这里以免下一个客户端重新踩:
1. **保存每次响应里的 `serverTime`**,下次带回来。从 0 拉全量在移动网络上很贵。
2. **`locator` 服务端不解释**,格式由客户端定义,但一旦发布就要向后兼容——
用户不会及时升级客户端,和自部署用户不会及时升级服务端是同一个问题。
当前客户端用的是 `r1:<偏移>:<章节 id>`,偏移是章节内的比例而非像素或列号,
这样换字号、换屏幕宽度、换主题都不会跳位置。
3. **拉取回来的 `deleted: true` 是墓碑**,本地也要删;不要因为它「看起来是空的」就丢弃。
4. **推送是合并而不是覆盖**,按 `updatedAt` 做 LWW。所以离线队列可以放心重放。
5. **403 不等于会话失效**。服务端用 403 表达两类事:「你的账号/令牌不行了」
(`ACCOUNT_DISABLED`、`TOKEN_INVALID`),和「这个操作被拒绝」
(`ADMIN_REQUIRED`、`PATH_TRAVERSAL`、`READ_ONLY_MOUNT`)。
只有前者该清掉凭据跳登录页;把后者也当会话失效,会把读者在读只读挂载上
改个文件名时踢下线,顺手把他存的令牌也删了。按 `error.code` 判断,不要只看状态码。
## Personal reading overrides and TTS diagnostics
### Administrator business settings
All `/api/v1/admin/settings` routes require an administrator bearer token. Responses are not cacheable. Configuration is persisted in `reader.db` and applied without restarting.
- `GET /api/v1/admin/settings` returns `{groups:[{group,label,fields,values,secrets,revision}]}`. Secret values are omitted; `secrets` contains presence flags only.
- `PATCH /api/v1/admin/settings/:group` accepts `{values,revision}`. Omitted fields retain their values; an explicit empty secret clears it. Stale revisions return `409 SETTINGS_CONFLICT`. Unknown fields and invalid values return 400.
- `PATCH` returns the updated group view. For `access`, a request carrying an `Origin` header is rejected with 400 if the new CORS origin list is non-empty and does not include that origin.
- Groups: `ai`, `playback`, `tmdb`, `musicbrainz`, `tts`, `scanning`, `scraping`, `access`, `webdav`, `library` (`memberUpload`, default `true`: whether members may upload books). Unknown groups return 400. Registration continues using its existing API.
- `POST /api/v1/admin/settings/:group/test` accepts `{values}` for TMDB, MusicBrainz, TTS or WebDAV (other groups return 400). It tests the draft without saving it and returns `{ok,elapsedMs}`; TTS additionally returns `bytes` and `voices`. A test already running for the same group returns `409 SETTINGS_TEST_BUSY`; an upstream failure returns `502 SETTINGS_TEST_FAILED`.
- `POST /api/v1/admin/settings/tts/preview` accepts `{values,voice?}` and returns sample audio. `POST /api/v1/admin/settings/tts/clear-cache` clears cached audio and returns `{ok:true}`.
- `POST /api/v1/admin/settings/webdav/upload` accepts `{directory,name}` and uploads a verified offline backup using saved WebDAV settings. It never creates an online snapshot or overwrites a remote backup. A concurrent upload returns `409 BACKUP_BUSY`; any upload failure returns 400.
All endpoints require the account access token in the Authorization header. Reading overrides additionally check book access.
- GET /api/v1/books/:id/reading-overrides returns { version, headingPrefix, corrections }.
- PUT /api/v1/books/:id/reading-overrides accepts the same object. A correction contains id, anchor (sectionId, start, end, quote, prefix, suffix), and replacement. The submitted version must match the latest version; stale writes return `409 CONFLICT`. The response is the saved object with `version` incremented by one. Up to 500 corrections are allowed, with each quote/replacement at most 4,000 characters. headingPrefix is a literal single-line prefix of at most 60 characters.
- POST /api/v1/books/:id/reading-overrides/undo accepts { version } and saves the preceding snapshot as a new version. History is scoped to account/book and stored in DATA_DIR/reader.db; it never rewrites library files.
- POST /api/v1/tts/test accepts { voice?, speed? }, synthesizes a fixed sample and returns { bytes, contentType, elapsedMs, cached }. Failure codes include TTS_DISABLED, TTS_AUTH, TTS_TIMEOUT, TTS_CONTENT, TTS_EMPTY and TTS_UPSTREAM. It does not accept an upstream URL. Audio playback remains GET /api/v1/tts.