Files
reader/docs/api.md
T
hectorqinandClaude Opus 5.5 c1056743ad feat: the reader shows AI 总结 only when the server can answer it
The top bar always drew AI 总结, although AI is off by default, so most
readers pressed it only to be told AI_DISABLED (ui.md §4/§5: a control the
reader cannot use is not drawn). GET /api/v1/ai/capabilities tells any signed-in
account whether chapter summaries are enabled and configured, without exposing
the configuration; the reader asks once per book and leaves the button out
when the answer is no. Against an older server (404) the button stays.

The busy line while a summary is generated is a global notice like every other
one, instead of a hand-drawn .floating-notice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 04:36:01 +00:00

70 KiB
Raw Blame History

API 参考

基础路径 /api/v1。除标注「公开」外都需要 Authorization: Bearer <accessToken>。

错误响应统一格式(请求体 / 查询参数校验失败时 code 为 BAD_REQUEST,未处理的服务端错误为 INTERNAL):

{ "error": { "code": "NOT_FOUND", "message": "book not found" } }

兼容性承诺

只做加法。已有字段的语义不会改,端点不会删。 客户端可以按 apiVersion 判断能力,但不需要为小版本做分支。


实例信息

GET /instance — 公开

{ "name": "reader", "apiVersion": 1, "registrationOpen": false,
  "registrationMode": "closed", "invitationRequired": false, "userCount": 3 }

客户端用它区分「全新实例」(可以注册)和「已被占用」(需要管理员开账号)。

apiVersion 是能力版本,与包的版本号无关:只有在字段语义变化、客户端必须改行为时 才动它。加字段、加端点都不动——这正是「只做加法」能成立的原因。

GET /health — 公开

{ "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 个字符。响应不缓存。

{
  "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 — 公开

{ "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:

{ "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
sort title | author | added | updated,默认 added
order asc | desc,默认 desc
page pageSize 默认 1 / 50,pageSize 夹到 1–200
{ "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 是「这个文件夹里有什么」:每一个有活文件的书,不管这本书在不在 调用者的书架上,并且每一项多一个字段:

{ "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 始终完整。

{ "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 里指向另一章。

{ "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。

{ "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

本实例支持的格式清单,由格式注册表生成:

{ "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=<页>

{ "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 — 管理员

{ "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 — 管理员

{ "path": "科幻/三体.epub", "name": "三体(修订版).epub" }

name 是单个路径段,不含斜杠。→ { "path": "科幻/三体(修订版).epub" }

POST /library/browse/mkdir — 管理员

{ "path": "科幻", "name": "待整理" }

→ { "path": "科幻/待整理" }

POST /library/browse/delete — 管理员

{ "paths": ["科幻/其他/球状闪电.epub"] }

→ { "removed": 1 }

递归删除,不进回收站。索引行不动,留给下一次扫描标记 missing: 删除本身已经是危险的那一半,再绑一次全量扫描会让界面等好几分钟才报告成功。 勾选过的那一行也会一直留着,和「共享目录没挂上」是同一种状态—— 「文件没了但书还记着」,比书架突然少一本好。

paths 必须是非空字符串数组:"a" 和 ["a"] 只差一个键, 但把字符串悄悄包成数组,会让「删了 1 项」看起来像「删了很多项」。

只读挂载上任何写操作 → 403 READ_ONLY_MOUNT。

POST /library/upload

multipart/form-data。重复的 file 字段各带一个文件,字段 path 指定目标目录 (书库内相对路径,缺省根目录),字段 onConflict 决定重名怎么办。

{ "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 — 管理员

{ "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

{ "paths": ["旧扫描"], "action": "remove" }
{ "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

{ "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。

{ "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)。

{ "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

{ "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 的全量拉取)一并返回,客户端不用再多发一次拉取:

{ "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 }

PUT /sync/progress/:bookId

单个进度上报的便捷写法:

{ "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

{ "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 示例:

{"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 备份上传见接入指南。

影音

接口前缀为 /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 头,见后台播放
GET /streams/:id?ticket= 不用 Bearer 令牌,凭查询串中的播放票据访问;OpenList 默认 307 跳转至临时下载地址,本地或代理模式返回字节流。proxy=1 请求代理回退,支持有效的字节 Range(无法满足返回 416 MEDIA_RANGE),文件缺失返回 404 MEDIA_MISSING;响应不缓存

本地建库请求示例:

{"name":"家庭电影","kind":"video","storage":"local","root":"/media/video","access":"restricted"}

kind 为 video、music 或 audiobook;OpenList 建库和凭据更新见OpenList API。媒体路径属于服务端或容器,不是浏览器所在设备的路径。

播放会话使用具体 partId,不以作品名称或文件路径代替。流地址、有效期及进度并发字段以服务端响应为准;客户端不持久保存临时下载链接。权限撤销和资源失效需重新处理,不能绕过鉴权重用旧链接。后台专用授权见后台播放。

完整请求 schema 及响应以当前路由为准,使用流程见影音手册,数据隔离见影音架构。

管理员

GET /admin/users

→ { "users": [ { "id": "...", "username": "...", "displayName": "...", "role": "member", "disabled": false, "createdAt": 0 } ] }

POST /admin/users

{ "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.