mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-10 13:04:08 +08:00
docs: bring every document in line with the code
Audit of README, docs/, android/README.md, android/contract.md and CLAUDE.md against the current server, web and Android code: V3 navigation and settings locations, Docker Hub release images, GitHub clone URL and CI, missing API endpoints and error codes, architecture of lazy chunks/themes/sources, media pages and routes, the Android bridge contract, and the new file-management and member-upload permissions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
415a9f5dd3
commit
120827bad6
27 files changed
+782
-407
No files matched your search
@@ -14,7 +14,7 @@ Docs (mostly Chinese) live in `docs/`; `docs/architecture.md` explains *why* thi
|
||||
|
||||
## Commands
|
||||
|
||||
Run from repo root (or `cd` into the package). CI runs install → typecheck → test → build per package: GitHub Actions `.github/workflows/ci.yml` (PRs and every branch push; also UI review, Android shell check + debug APK) and CNB `.cnb.yml`. `main` is the long-lived development branch and never releases on push: `.github/workflows/release.yml` runs only for `v*` tags or a manual run, runs CI first, then publishes the multi-arch (amd64 + arm64, native runners) image to Docker Hub `hectorqin/reader-pro` (`x.y.z`/`x.y`/`latest` + `sha-*` for tags; branch name + `sha-*` for manual runs); it needs the `DOCKER_USERNAME`/`DOCKER_PASSWORD` secrets. `android/scripts/check-shell.sh` checks the JS bridge contract: only `@JavascriptInterface` methods count, and each must be declared in `web/src/android-bridge.d.ts` (method syntax, `name?(): T` for optional).
|
||||
Run from repo root (or `cd` into the package). CI runs install → typecheck → test → build per package: GitHub Actions `.github/workflows/ci.yml` (PRs and every branch push; also UI review, Android shell check + debug APK). `main` is the long-lived development branch and never releases on push: `.github/workflows/release.yml` runs only for `v*` tags or a manual run, runs CI first, then publishes the multi-arch (amd64 + arm64, native runners) image to Docker Hub `hectorqin/reader-pro` (`x.y.z`/`x.y`/`latest` + `sha-*` for tags; branch name + `sha-*` for manual runs); it needs the `DOCKER_USERNAME`/`DOCKER_PASSWORD` secrets. `android/scripts/check-shell.sh` checks the JS bridge contract: only `@JavascriptInterface` methods count, and each must be declared in `web/src/android-bridge.d.ts` (method syntax, `name?(): T` for optional).
|
||||
|
||||
```sh
|
||||
npm ci --prefix server && npm ci --prefix web
|
||||
|
||||
+3
-3
@@ -32,11 +32,11 @@ A self-hosted reading and media library for browsers and Android, with EPUB typo
|
||||
|
||||
## Get started
|
||||
|
||||
Prepare Docker, Docker Compose, a book directory, and a separate data directory. Open `http://<your-host>:5888` after starting the service. The first registered account becomes the administrator. Follow the [getting started guide](docs/getting-started.en.md).
|
||||
Prepare Docker, Docker Compose, a book directory, and a separate data directory. Open `http://<your-host>:5888` after starting the service. The first registered account becomes the administrator. Release images are published on Docker Hub as `hectorqin/reader-pro` (amd64 / arm64). Follow the [getting started guide](docs/getting-started.en.md).
|
||||
|
||||
Reader opens on the video page (`#/media/video`) by default. Use the channel navigation to switch between video, music, audiobooks, and reading; the media entry on the shelf also opens the video page. Media settings include multiple themes; reading controls and bookshelf settings remain separate.
|
||||
Reader opens on Home (首页), where you can resume reading and playback. The bottom navigation (a sidebar on wide screens) has four tabs: Home, Shelf (书架), Media (影音), and Me (我的). On phones, swipe sideways to move between shelf states and media categories; the wide-screen sidebar has a search box covering both books and media. Me gathers every setting: appearance and theme (one theme shared by reading and media, with a separate reading paper colour), shelf display, media browsing, playback, and account.
|
||||
|
||||
From the video page, open More actions → Media settings → Media libraries to add a server directory or OpenList library. Mount local media into Docker first and enter its container path. Library creation starts a scan. See the [media guide (中文)](docs/media-user-guide.zh-CN.md) and [OpenList setup (中文)](docs/media-openlist.md).
|
||||
As an administrator, open Me → Media libraries (我的 → 媒体库), or More actions → Media library management (更多操作 → 媒体库管理) on the video page, to add a server directory or OpenList library. Mount local media into Docker first and enter its container path. Library creation starts a scan. System settings and Scan and scrape (扫描与刮削) are also under Me → Admin (管理). See the [media guide (中文)](docs/media-user-guide.zh-CN.md) and [OpenList setup (中文)](docs/media-openlist.md).
|
||||
|
||||
## Documentation
|
||||
|
||||
|
||||
@@ -32,11 +32,11 @@
|
||||
|
||||
## 开始使用
|
||||
|
||||
准备 Docker、Docker Compose、书籍目录和独立数据目录。启动后访问 `http://<你的主机>:5888`,首个注册账号成为管理员。部署步骤见[入门指南](docs/getting-started.zh-CN.md)。
|
||||
准备 Docker、Docker Compose、书籍目录和独立数据目录。启动后访问 `http://<你的主机>:5888`,首个注册账号成为管理员。正式版本镜像发布在 Docker Hub `hectorqin/reader-pro`(amd64 / arm64)。部署步骤见[入门指南](docs/getting-started.zh-CN.md)。
|
||||
|
||||
打开站点默认进入影视页(`#/media/video`)。可从频道导航切换影视、音乐、有声书,选择「阅读」返回书架;书架右上角的「影音」入口也会打开影视页。影音设置内置多种主题,阅读界面与书架设置保持独立。
|
||||
打开站点默认进入「首页」,可继续上次的阅读与播放。底部(宽屏为左侧)导航包含首页、书架、影音和我的四个标签;手机上可左右滑动切换书架状态与影音分类,宽屏侧栏提供跨书籍与影音的搜索。「我的」集中了全部设置:外观与主题(阅读与影音共用一套主题,阅读可单独选择纸色)、书架显示、影音浏览、播放和账号。
|
||||
|
||||
管理员打开影视页右上角「更多操作 → 影音设置 → 媒体库管理」,添加服务器目录或 OpenList。Docker 本地媒体目录需先挂载,再填写容器内路径;创建后自动扫描。详细步骤见[影音手册](docs/media-user-guide.zh-CN.md)和 [OpenList 接入](docs/media-openlist.md)。
|
||||
管理员打开「我的 → 媒体库」(或影视页右上角「更多操作 → 媒体库管理」),添加服务器目录或 OpenList。Docker 本地媒体目录需先挂载,再填写容器内路径;创建后自动扫描。系统设置、扫描与刮削同样在「我的 → 管理」中。详细步骤见[影音手册](docs/media-user-guide.zh-CN.md)和 [OpenList 接入](docs/media-openlist.md)。
|
||||
|
||||
## 文档
|
||||
|
||||
|
||||
+78
-93
@@ -1,109 +1,94 @@
|
||||
# Android 外壳
|
||||
|
||||
**状态:契约已定义,实现未开始。**
|
||||
Kotlin 写的 WebView 外壳:加载与 H5 **同一份** `web/` 构建产物,只补 WebView 做不到
|
||||
或做不好的部分——连接状态、设备名、原生整页图片、系统 TTS、Media3 后台播放与原生视频。
|
||||
排版的唯一实现在 `web/`,**外壳不重写排版**;两条实现只会让「最小覆盖 CSS」的策略分叉。
|
||||
|
||||
这个目录目前只有设计契约,没有可编译的代码。原因很直接:本次开发环境没有
|
||||
JDK 与 Android SDK,写一个编译不过、跑不起来的 APK 只是往仓库里堆未经验证
|
||||
的代码。宁可把边界写清楚,等有构建环境时一次写对。
|
||||
与 H5 的接口见 [contract.md](contract.md)。
|
||||
|
||||
下面是已经定死、并且**已被 H5 侧验证过**的部分。
|
||||
## 构建
|
||||
|
||||
需要 JDK 17+、Android SDK 35、Build Tools 35.0.1(AGP 8.7.3、Kotlin 2.0.21、
|
||||
Gradle Wrapper 8.11.1,`compileSdk`/`targetSdk` 35,`minSdk` 24)。从仓库根目录:
|
||||
|
||||
```sh
|
||||
sh android/scripts/build-web-assets.sh # 构建 web/ 并复制到 app/src/main/assets/web-assets/
|
||||
cd android
|
||||
./gradlew :app:assembleDebug # Windows 原生终端用 gradlew.bat
|
||||
```
|
||||
|
||||
产物为 `android/app/build/outputs/apk/debug/app-debug.apk`。编译通过不等于真机阅读、
|
||||
播放验证通过。
|
||||
|
||||
- **服务地址**:默认留空,首次启动在登录页填写并记住。个人构建可预设:
|
||||
`./gradlew :app:assembleDebug -PdefaultServerUrl=http://nas.local:5888`
|
||||
(写入 `BuildConfig.DEFAULT_SERVER_URL`,经桥接方法 `configuredServerUrl()` 交给 H5 预填)。
|
||||
- **资源目录**:`app/build.gradle.kts` 把 assets 根目录**替换**为 `src/main/assets/web-assets`,
|
||||
所以 `web/dist` 的内容平铺在 APK 的 `assets/` 下。该目录除 `README.txt` 占位外被 Git 忽略。
|
||||
- release 构建在没有提供 keystore 时使用 debug 签名。
|
||||
- **包名**:`applicationId` 与 `namespace` 均为 `com.htmake.readerpro`,Kotlin 源码在
|
||||
`app/src/main/java/com/htmake/readerpro/`(媒体快捷方式的 action 也以此为前缀)。旧包名
|
||||
`cool.cnb.reader` 的安装与新包是两个不同的应用,不能覆盖升级,也不迁移其本地数据。
|
||||
|
||||
### 不需要 JDK 的检查
|
||||
|
||||
```sh
|
||||
sh android/scripts/check-shell.sh # 秒级
|
||||
sh android/scripts/verify-web-assets.sh # 在 build-web-assets.sh 之后运行
|
||||
```
|
||||
|
||||
- `check-shell.sh`:包名与目录一致;`com.htmake.readerpro.*` 导入都能解析;JS 桥接契约双向一致
|
||||
(只把 `ReaderBridge.kt`、`SpeechBridge.kt` 里紧跟 `@JavascriptInterface` 的方法算作桥接方法,
|
||||
每个都必须在 `web/src/android-bridge.d.ts` 中声明,反之亦然;没有注解的 Kotlin 方法页面调不到,
|
||||
不应声明);`ReaderBridge.kt` 分支用到的渲染模式都在 `web/src/formats/types.ts` 的 `RenderMode` 中;
|
||||
`NativePageView` 能解码 JPEG/PNG/WebP。
|
||||
- `verify-web-assets.sh`:确认暂存的 Web 产物正是 Gradle 会打进 APK 的那份——否则 APK
|
||||
能构建成功却启动即白屏。
|
||||
- `scripts/verify-web-apk.ps1`(PowerShell):逐文件比对已构建 APK 的 `assets/` 与 `web/dist` 的哈希。
|
||||
|
||||
CI:GitHub Actions `ci.yml` 的 Android 任务依次运行 `check-shell.sh`、`build-web-assets.sh`、
|
||||
`verify-web-assets.sh` 和 `./gradlew --no-daemon :app:assembleDebug`,并上传 debug APK 制品;
|
||||
|
||||
---
|
||||
|
||||
## 宿主与来源
|
||||
|
||||
`WebHost.kt` 用 `WebViewAssetLoader` 把打包的客户端从
|
||||
`https://appassets.androidplatform.net/assets/index.html` 提供,而不是 `file://`:
|
||||
`file://` 下对服务端的 `fetch()` 是不透明跨域请求,常见绕法是关掉 Web 安全。HTTPS 资源来源让
|
||||
请求成为普通 CORS 请求(服务端 `server/src/http/cors.ts` 放行该来源),`isSecureContext` 为真,
|
||||
IndexedDB 等 API 与浏览器一致。因为资源加载器不能为任意路径回退到 `index.html`,客户端只用哈希路由。
|
||||
|
||||
- 登录、会话与离线数据都由 H5 自己管理(与浏览器相同的存储层);外壳不注入令牌。
|
||||
- 服务端通常是局域网 `http://`,所以清单允许明文流量,WebView 允许混合内容。
|
||||
- 书内外链交给系统浏览器打开;`reader-res:`、`blob:`、`data:`、`about:` 与资源来源留在 WebView 内。
|
||||
- 返回键先走 WebView 历史,没有历史时才退出。
|
||||
- `onPause` 调用 `window.__readerFlush()`,让阅读进度在切到后台前落盘。
|
||||
- 桌面快捷方式 `OPEN_VIDEO` / `OPEN_MUSIC` / `OPEN_AUDIOBOOK` 分别打开 `#/media/video`、
|
||||
`#/media/music`、`#/media/audiobook`。
|
||||
- debug 构建开启 WebView 远程调试;页面 console 输出到 logcat(标签 `ReaderWeb`)。
|
||||
|
||||
## 分工:谁渲染什么
|
||||
|
||||
用户的问题原话是:**「Android 端 epub 书籍可以使用 H5 渲染,但是其他类型的
|
||||
书籍需要使用原生来渲染,提升性能。」** 这条已经落进代码
|
||||
(`web/src/ui/reader-view.ts` 的 `capabilities()`),外壳只需要认这个契约:
|
||||
|
||||
| `kind` | 渲染方 | 为什么 |
|
||||
| 内容 | 渲染方 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `reflowable`(epub) | **WebView** | 设备上只有 WebView 实现了 CSS 多栏、内嵌字体、竖排、振假名。原生文本栈要把这些重做一遍,只会更差。 |
|
||||
| `text`(txt) | **WebView** | 同上,且它只是纯文本,没有原生收益。 |
|
||||
| `paged`(cbz / 漫画目录 / 单图) | **原生 Pager** | 一页是一个 JPEG。原生 `ImageView` 走硬件解码并复用 bitmap;WebView 每页一个合成层并在主线程解码。真正的原因是内存:300MB 的画册在 WebView 里是中端机 OOM,在原生里没事。 |
|
||||
| `document`(pdf) | **系统查看器** | 系统已经有一个了。在 WebView 里套 iframe 只是把同样的事做两遍。 |
|
||||
| EPUB、TXT 等可重排内容 | **WebView** | 只有 WebView 实现了 CSS 多栏、内嵌字体、竖排、振假名;原生重做只会更差。 |
|
||||
| 整页图片(漫画页、图片页,`RenderMode = 'image'`) | **原生 `NativePageView`**(shell ≥ 2) | 平台解码并按视口采样,避免 WebView 每页一次解码和布局。仅在「适应整页」(`contain`) 时使用;外壳返回 `false` 就由 WebView 照常绘制,没有第三种结果。 |
|
||||
| PDF(`document`) | **WebView** | `canOpenDocument()` 目前总是返回 `false`:系统查看器是独立页面,会把读者带出应用。 |
|
||||
|
||||
**API 已经提供了路由所需的全部信息**,不需要外壳自己嗅探扩展名或 UA:
|
||||
路由由 H5 决定(`web/src/ui/reader-view.ts` 通过 `pageHost` 询问外壳),外壳不嗅探扩展名或 UA。
|
||||
|
||||
```
|
||||
GET /api/v1/books/:id/manifest
|
||||
→ { content: { kind: "reflowable" | "paged" | "text" | "document" | "single-image", ... },
|
||||
kind: "reflowable" | ..., // 与 content.kind 相同,冗余保留给旧客户端
|
||||
files: [ { rel_path, size, missing } ] }
|
||||
```
|
||||
## 朗读与影音
|
||||
|
||||
`kind` 同时出现在 `content.kind` 和顶层,两边永远是同一个值。外壳读哪个都行,
|
||||
但**不要**改用扩展名或 UA 去猜——`kind` 就是那个唯一开关。
|
||||
|
||||
`files` 对 `paged` 的书是**逐页下载的路径清单**:漫画目录没有单一的书文件,
|
||||
外壳按这份清单取页。清单同时是 `/books/:id/file?path=` 的契约——
|
||||
manifest 列出的路径就是这个端点会服务的路径,多一条也没有。压缩包里的页面按压缩包
|
||||
自己的路径列出,包内页码走 `/assets?ref=page:卷:页`,不是文件路径。
|
||||
|
||||
H5 侧在没有外壳时的降级路径也已实测通过(`paged` → 内置翻页器、
|
||||
`document` → iframe),所以外壳缺失或被停用不会让 App 白屏。
|
||||
|
||||
---
|
||||
|
||||
## 桥接契约
|
||||
|
||||
外壳在 WebView 加载 assets 之前,往 `window` 上写一个对象
|
||||
(`web/src/bridge.ts` 是唯一的定义处):
|
||||
|
||||
```js
|
||||
window.readerBridge = {
|
||||
serverUrl: 'http://192.168.1.10:8080', // file:// 下没有 origin,必须给
|
||||
platform: 'android',
|
||||
deviceName: 'Pixel 8', // 写进进度,多设备同步时能看出是哪台
|
||||
onBookOpened(bookId) {}, // 外壳接管输入(硬件键、原生翻页)
|
||||
onTurnPage(turn) {}, // 外壳把翻页手势交回给 H5
|
||||
cacheBook(bookId) {}, // 请求离线缓存
|
||||
};
|
||||
```
|
||||
|
||||
三条约定:
|
||||
|
||||
1. **外壳只写这一个对象,不 eval 字符串。** 同进程,走对象比走
|
||||
`addJavascriptInterface` 的字符串协议简单,也不会因为转义出错。
|
||||
2. **`platform: 'android'` 是唯一的路由开关。** 不嗅探 UA —— UA 是猜的,
|
||||
桥是事实。
|
||||
3. **H5 不碰 refresh token。** 令牌由外壳的加密存储管理;WebView 的
|
||||
`localStorage` 里放长期凭证,任何被注入的脚本都能读。
|
||||
|
||||
`web/src/bridge.ts` 已实现读取侧,并且**无桥时正常工作**(纯 H5 部署即如此)。
|
||||
|
||||
---
|
||||
|
||||
## 离线缓存
|
||||
|
||||
服务端已经把「按书缓存」所需的接口准备好了,外壳侧直接可用:
|
||||
|
||||
- `GET /books/:id/content` — 整本书文件流式下载,带 `ETag` 与 `Range`。
|
||||
书内容按哈希不可变,所以缓存永不失效,**断点续传也是免费的**。
|
||||
- 目录型书籍(漫画目录)没有单一文件,返回 `400 DIRECTORY_BOOK`;
|
||||
这类书按 `items?group=N` 逐卷取图。
|
||||
|
||||
进度与笔记走 `GET /sync?since=<serverTime>` 增量拉取。服务端是唯一真相源,
|
||||
冲突是 last-writer-wins —— 一台设备属于一个读者,多设备之间不存在「编辑
|
||||
冲突」,只存在「某台设备落后了」。离线期间本地记录,恢复后合并。
|
||||
|
||||
---
|
||||
- **朗读**:`SpeechBridge.kt` 封装系统 `TextToSpeech`(shell ≥ 3),注册为独立接口
|
||||
`window.ReaderAndroidSpeech`,H5 把它挂到 `ReaderAndroid.speech`。队列和句子游标留在 JS;
|
||||
没有原生朗读时回退到 WebView 的 `speechSynthesis`。
|
||||
- **影音**:`media/` 下的 `MediaPlaybackService`(Media3 `MediaSessionService`,前台服务、通知与
|
||||
后台播放)、`MediaVideoActivity`(原生视频画面,支持画中画)和 `MediaBridge`。`MediaBridge` 不是
|
||||
`@JavascriptInterface`,而是只对资源来源、只对主框架开放的 `WebMessageListener`(`window.ReaderMedia`),
|
||||
需要 WebView 支持 `WEB_MESSAGE_LISTENER`,否则 H5 使用网页播放器。播放与朗读互相打断。
|
||||
|
||||
## 分发
|
||||
|
||||
AGPL 免费、无收入,所以不走应用商店:直接发 APK。
|
||||
|
||||
- 侧载 APK 到 GitHub Releases / CNB 制品库
|
||||
- 没有审核周期,没有 Apple 的 99 美元
|
||||
- iOS 按设计文档的判断等有赞助再说,本目录不涉及
|
||||
|
||||
## 开工前需要的东西
|
||||
|
||||
1. JDK 17+ 与 Android SDK(`ANDROID_HOME`)
|
||||
2. AGP + Kotlin 版本对齐(建议 AGP 8.x / Kotlin 2.x)
|
||||
3. `web/dist` 的产物打进 `app/src/main/assets/www/` —— 第一步是把
|
||||
`web/` 的构建产物复制进去,**不是**在 Android 侧重写渲染
|
||||
4. 一个可连的服务端实例(`docker compose up` 即可)
|
||||
|
||||
第 3 条是这个仓库的结构性约束:**外壳不重写排版**。排版的唯一实现在
|
||||
`web/`,两条实现只会让「最小覆盖 CSS」的策略分叉。
|
||||
AGPL 免费、无收入,不走应用商店,直接侧载 APK。当前 CI 只产出 debug APK 制品;Docker 镜像发布
|
||||
(`release.yml`)不包含 APK。iOS 不在本目录范围内。
|
||||
+80
-55
@@ -1,80 +1,105 @@
|
||||
# 外壳与 H5 的接口
|
||||
|
||||
这份文件是外壳实现时必须照抄的部分。它很短,因为两边只在一个对象上耦合。
|
||||
这份文件是外壳与 H5 之间的全部约定。两边分别用 Kotlin 和 TypeScript 写,互相无法类型检查,
|
||||
所以以 `web/src/android-bridge.d.ts` 为准,由 `android/scripts/check-shell.sh` 双向核对。
|
||||
改动任何一侧都要同步另一侧和本文件。
|
||||
|
||||
## 1. 启动流程
|
||||
|
||||
```
|
||||
外壳启动
|
||||
→ 读加密存储里的 serverUrl + refreshToken
|
||||
→ 通过 /auth/refresh 换一个 accessToken(失败则显示登录)
|
||||
→ 把 web/dist 的 index.html 加载进 WebView
|
||||
· 注入 window.readerBridge(见下)
|
||||
· 注入 accessToken 到 localStorage 的 `reader.session`
|
||||
→ H5 自己完成后续所有请求
|
||||
MainActivity 启动
|
||||
→ WebHost 配置 WebView,注册 JS 接口与媒体消息通道
|
||||
→ 加载 https://appassets.androidplatform.net/assets/index.html(+ 快捷方式对应的 #/media/... 片段)
|
||||
→ H5 检测 window.ReaderAndroid,选择 Android 平台
|
||||
→ 服务地址:用户已保存的地址 > configuredServerUrl()(构建时 -PdefaultServerUrl)> 留空待填
|
||||
→ H5 自己完成登录、会话保存(存储层与浏览器相同)和后续所有请求
|
||||
```
|
||||
|
||||
H5 只从 `localStorage['reader.session']` 读会话(`web/src/net/api.ts` 的
|
||||
`defaultStorage()`)。外壳必须在页面脚本执行前把它写好 —— 用
|
||||
`WebViewClient.onPageStarted` 或 `evaluateJavascript` 在 `loadUrl` 之前
|
||||
注入都可以,但**必须在 `index.html` 的 module script 之前**。
|
||||
外壳**不**读写令牌、**不**向 `localStorage` 注入会话,也不 eval 业务脚本。资源来源
|
||||
`https://appassets.androidplatform.net` 被 H5 视为无效的服务地址,服务端 CORS 单独放行它。
|
||||
|
||||
## 2. 路由
|
||||
## 2. JS 接口
|
||||
|
||||
H5 会自己解析 manifest 并决定谁来渲染。外壳只要监听:
|
||||
外壳用 `addJavascriptInterface` 注册两个对象。只有标注 `@JavascriptInterface` 的方法对页面可见。
|
||||
|
||||
```kotlin
|
||||
// 外壳侧:H5 请求原生接管分页内容
|
||||
webView.addJavascriptInterface(object {
|
||||
@JavascriptInterface fun openPaged(bookId: String) { /* 起原生 Pager */ }
|
||||
}, "readerNative")
|
||||
### `window.ReaderAndroid`(`bridge/ReaderBridge.kt`,当前 `SHELL_VERSION = 3`)
|
||||
|
||||
| 方法 | 返回 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `shellVersion()` | number | H5 要求 ≥ `MIN_SHELL_VERSION`(1),否则按普通浏览器运行 |
|
||||
| `configuredServerUrl()` | string | 构建时预设的服务地址,可为空;d.ts 中为可选方法 |
|
||||
| `deviceLabel()` | string | 写进进度记录的设备名 |
|
||||
| `connectivity()` | string | `online` / `offline`,来自系统连接管理器 |
|
||||
| `watchConnectivity(cb)` | void | 见下文「回调」 |
|
||||
| `toast(message)` | void | 原生 Toast |
|
||||
| `hasNetwork()` | boolean | 当前是否有网络 |
|
||||
| `viewport()` | string | JSON:物理像素视口与密度 |
|
||||
| `cacheUsage()` | string | JSON:`webViewBytes`、`dataBytes`、`availableBytes` |
|
||||
| `diagnostics()` | string | JSON:外壳版本、设备、系统、应用版本,用于问题报告 |
|
||||
| `renderPage(request)` | boolean | shell ≥ 2。见第 3 节 |
|
||||
| `hidePage()` | void | 移除原生页面,露出 WebView |
|
||||
| `canOpenDocument(mediaType)` | boolean | 目前总是 `false`,PDF 留在 WebView |
|
||||
| `speech()` | object | Kotlin 侧访问器;页面看到的是下面的独立接口 |
|
||||
|
||||
### `window.ReaderAndroidSpeech`(`bridge/SpeechBridge.kt`,shell ≥ 3)
|
||||
|
||||
`addJavascriptInterface` 只能暴露对象的方法,不能暴露属性,所以朗读接口单独注册;
|
||||
H5 的 `promoteSpeechInterface`(`web/src/core/android-platform.ts`)把它挂到
|
||||
`ReaderAndroid.speech`,d.ts 中声明为 `speech?: SpeechBridge`。
|
||||
|
||||
方法:`available()`、`init()`、`onSpeechEvent(cb)`、`onVoices(cb)`、`speak(text, utteranceId)`、
|
||||
`pause()`、`stop()`、`setRate(rate)`、`setPitch(pitch)`、`setVolume(volume)`、`setVoice(voiceId)`、`shutdown()`。
|
||||
朗读队列与游标在 JS;`utteranceId` 由 H5 生成,事件原样带回,用来丢弃过期句子的 `done`。
|
||||
`interruptForMedia()` 没有注解,只供 Kotlin 侧在开始播放影音时调用,不属于页面契约。
|
||||
|
||||
### 回调
|
||||
|
||||
JS 接口只能传原始类型,所以 Kotlin 端的 `watchConnectivity`、`onSpeechEvent`、`onVoices`
|
||||
接收的是**全局函数名**(须是安全标识符),之后用 `evaluateJavascript` 调用它。H5 包装层把函数注册为
|
||||
`window.__readerConnectivity`、`__readerSpeechEvent`、`__readerSpeechVoices` 再把名字传过去;
|
||||
连接状态传字符串,朗读事件与声音列表传 JSON。页面代码按 d.ts 直接传函数即可。
|
||||
|
||||
## 3. 原生整页图片
|
||||
|
||||
H5 在 `reader-view.ts` 中通过 `pageHost` 询问外壳,仅在页面为 `image` 模式且读者选择「适应整页」时:
|
||||
|
||||
```
|
||||
ReaderAndroid.renderPage(JSON.stringify({ sectionId, mode: 'image', mediaType, bytes: <base64>, fit: 'contain' }))
|
||||
→ true:NativePageView 已覆盖在 WebView 上绘制
|
||||
→ false:外壳拒绝(模式/适配方式不支持、无法解码、超时 2 秒),H5 在 WebView 中照常绘制
|
||||
```
|
||||
|
||||
H5 侧分发的时机在 `ReaderView.open()` 里(`caps.paged === 'native'` 分支),
|
||||
目前通过 `CustomEvent('reader:native-page')` 触发。外壳把它接成上面那个
|
||||
接口即可。
|
||||
外壳只解码请求里的 `bytes`(JPEG/PNG/WebP),不按 `path` 自行取图。离开该页时 H5 调用 `hidePage()`。
|
||||
外壳分支用到的模式名必须在 `web/src/formats/types.ts` 的 `RenderMode` 中,`check-shell.sh` 会检查。
|
||||
|
||||
## 3. 返回
|
||||
## 4. 影音通道 `window.ReaderMedia`
|
||||
|
||||
原生 Pager 退出时回到 WebView,并通知 H5 重新拉一次进度:
|
||||
`media/MediaBridge.kt` 用 `WebViewCompat.addWebMessageListener` 注册,只接受资源来源、只接受主框架,
|
||||
需要 WebView 支持 `WEB_MESSAGE_LISTENER`;不支持时不注册,H5 使用网页播放器。H5 发送 JSON 字符串
|
||||
`{ action, ... }`(`web/src/features/media/services/native-audio.ts`),外壳通过回复通道回传播放状态。
|
||||
|
||||
```js
|
||||
window.dispatchEvent(new CustomEvent('reader:native-return'))
|
||||
```
|
||||
`action`:`play`、`showVideo`、`pause`、`resume`、`stop`、`previous`、`next`、`selectQueue`、`editQueue`、
|
||||
`seek`、`speed`(0.5–3.0)、`audioTrack`、`sleep`、`reconnect`、`status`。播放由
|
||||
`MediaPlaybackService`(Media3)执行;视频画面在 `MediaVideoActivity` 中显示。消息超过约 900 KB 被忽略。
|
||||
|
||||
H5 侧的监听尚未实现(`reader-view.ts` 里留了位置),因为尚无外壳可测。
|
||||
第 1 条之所以只写「已实现的部分」,就是因为这类回调**没测过就不能算数**。
|
||||
## 5. 生命周期与系统键
|
||||
|
||||
## 4. 硬件键
|
||||
- `onPause` 时外壳执行 `window.__readerFlush && window.__readerFlush()`,H5 借此保存阅读位置。
|
||||
- 返回键:WebView 有历史则后退,否则退出。
|
||||
- 外壳**目前不拦截音量键**,也没有「原生翻页器退出后通知 H5」的事件;旧版文档中的
|
||||
`reader:native-page` / `reader:native-return` 事件并不存在。
|
||||
|
||||
音量键翻页是阅读器的基本期待。外壳把它转成:
|
||||
## 6. 离线
|
||||
|
||||
```js
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }))
|
||||
```
|
||||
|
||||
H5 的键盘处理已经被浏览器实测过(`bindGestures()`),所以这条路不用新增
|
||||
代码。
|
||||
|
||||
## 5. 离线
|
||||
离线下载与缓存由 H5 负责(与浏览器共用实现),外壳不另建书籍缓存。H5 使用的服务端接口:
|
||||
|
||||
```
|
||||
GET /api/v1/books/:id/content
|
||||
→ 200,带 ETag 与 Accept-Ranges: bytes
|
||||
→ 中断后用 Range: bytes=<已收字节>- 续传
|
||||
→ 整本书,带 ETag,可 Range 续传
|
||||
→ 目录书(漫画目录)返回 400 DIRECTORY_BOOK;分章书返回 400 CHAPTER_BOOK
|
||||
|
||||
GET /api/v1/books/:id/file?path=<manifest files 中列出的 rel_path>
|
||||
→ 单个文件;path 只能取 manifest 列出的值,其他值得不到别的文件
|
||||
```
|
||||
|
||||
目录书(漫画目录)没有单一文件,`/content` 会返回 `400 DIRECTORY_BOOK`。
|
||||
这类书要缓存的是 manifest 里 `files` 列出的**每一页**:
|
||||
|
||||
```
|
||||
GET /api/v1/books/:id/file?path=<manifest 里列出的 rel_path>
|
||||
→ 200,一张图;ETag 按文件算
|
||||
```
|
||||
|
||||
`path` 只能取 `files` 里出现过的值。服务端只按这份契约匹配,不会用 query 拼路径,
|
||||
所以乱填只会拿到 404,不会拿到别的书的东西。
|
||||
|
||||
`/data` 里的书架元数据由客户端自己缓存(`GET /api/v1/books` 的响应)。
|
||||
服务端离线时,外壳读本地缓存渲染书架,并静默降级 —— 设计文档 §8.2
|
||||
要求「客户端必须优雅处理服务端不可达」,这条不能靠报错弹窗实现。
|
||||
服务端不可达时客户端读本地缓存并静默降级,不能靠报错弹窗。
|
||||
+221
-74
@@ -2,10 +2,10 @@
|
||||
|
||||
基础路径 `/api/v1`。除标注「公开」外都需要 `Authorization: Bearer <accessToken>`。
|
||||
|
||||
错误响应统一格式:
|
||||
错误响应统一格式(请求体 / 查询参数校验失败时 `code` 为 `BAD_REQUEST`,未处理的服务端错误为 `INTERNAL`):
|
||||
|
||||
```json
|
||||
{ "error": { "code": "BOOK_NOT_FOUND", "message": "book not found" } }
|
||||
{ "error": { "code": "NOT_FOUND", "message": "book not found" } }
|
||||
```
|
||||
|
||||
## 兼容性承诺
|
||||
@@ -20,7 +20,8 @@
|
||||
### `GET /instance` — 公开
|
||||
|
||||
```json
|
||||
{ "name": "reader", "apiVersion": 1, "registrationOpen": false, "userCount": 3 }
|
||||
{ "name": "reader", "apiVersion": 1, "registrationOpen": false,
|
||||
"registrationMode": "closed", "invitationRequired": false, "userCount": 3 }
|
||||
```
|
||||
|
||||
客户端用它区分「全新实例」(可以注册)和「已被占用」(需要管理员开账号)。
|
||||
@@ -75,6 +76,7 @@
|
||||
- 之后按管理员设置的注册策略开放;首次初始化时从旧 `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`:
|
||||
|
||||
@@ -110,7 +112,7 @@
|
||||
|
||||
`{ "currentPassword": "...", "newPassword": "..." }` → `{ "ok": true }`
|
||||
|
||||
会撤销该账号**所有**会话(含当前设备),需要重新登录。
|
||||
当前密码错误返回 `401 BAD_CREDENTIALS`。会撤销该账号**所有**会话(含当前设备),需要重新登录。
|
||||
|
||||
---
|
||||
|
||||
@@ -126,9 +128,10 @@
|
||||
| `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` 上限 200 |
|
||||
| `page` `pageSize` | 默认 1 / 50,`pageSize` 夹到 1–200 |
|
||||
|
||||
```json
|
||||
{ "items": [ { "id": "...", "title": "三体", "author": "刘慈欣",
|
||||
@@ -268,7 +271,8 @@
|
||||
拿到单窗也能把跳转落到正确章节,不需要把前面的 count 加一遍。
|
||||
|
||||
按格式:epub 取 spine 标题(NCX / nav),txt 取分章结果,漫画目录取卷,
|
||||
pdf / 单图没有独立目录,回落到 items。
|
||||
pdf / 单图没有独立目录,回落到 items。来源章节出版物(`chapters`)的目录取自当前快照,
|
||||
响应额外带 `revision`。
|
||||
|
||||
### `GET /books/:id/assets?ref=<ref>`
|
||||
|
||||
@@ -347,7 +351,8 @@ pdf / 单图没有独立目录,回落到 items。
|
||||
`Range: bytes=`,所以**离线下载可以续传**。
|
||||
|
||||
目录型书籍(漫画目录)没有单一文件,返回 `400 DIRECTORY_BOOK`;
|
||||
这类书按 `items?group=N` 逐卷取图。
|
||||
这类书按 `items?group=N` 逐卷取图。来源章节出版物没有整书文件,返回 `400 CHAPTER_BOOK`;
|
||||
文件已从磁盘消失返回 `404 FILE_MISSING`。
|
||||
|
||||
## 书库(文件管理器)
|
||||
|
||||
@@ -358,11 +363,16 @@ pdf / 单图没有独立目录,回落到 items。
|
||||
路径都是**书库内相对路径**,`''` 表示根目录;每一个都会过
|
||||
`resolveInside`,逃逸直接被拒。
|
||||
|
||||
> **信任边界**:挂载点就是边界,没有第二道。能在这里改文件的人,能改书库里的
|
||||
> 任何文件——和 `POST /library/scan` 已有的信任级别一致。在这之上再叠一层按路径的
|
||||
> ACL 只是安全表演。
|
||||
> **信任边界**:路径上,挂载点就是边界,不再叠一层按路径的 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=<页>`(管理员页面用)
|
||||
### `GET /library/browse?path=<相对路径>&page=<页>`
|
||||
|
||||
```json
|
||||
{ "path": "科幻/已读",
|
||||
@@ -408,13 +418,15 @@ pdf / 单图没有独立目录,回落到 items。
|
||||
符号链接按链接本身显示(`lstat`),不跟随——扫描器也不跟随,跟随等于描述一棵
|
||||
索引里并不存在的树,还会顺着链接走出挂载点。
|
||||
|
||||
### `POST /library/browse/move`
|
||||
### `POST /library/browse/move` — 管理员
|
||||
|
||||
```json
|
||||
{ "paths": ["科幻/三体.epub"], "target": "科幻/已读" }
|
||||
```
|
||||
|
||||
→ `{ "moved": 1, "target": "科幻/已读" }`
|
||||
→ `{ "moved": 1, "merged": 1, "target": "科幻/已读" }`
|
||||
|
||||
`merged` 是随移动一起改写索引路径的条目数;当前实现中每移动一项计一次,与 `moved` 相同。
|
||||
|
||||
同挂载点内 `rename`,不复制删除:文件字节、`mtime`、`inode` 都不变,扫描器视为
|
||||
无变化,书籍身份(`identifier + 内容哈希`)因此保持,进度和笔记都还在。
|
||||
@@ -426,7 +438,7 @@ pdf / 单图没有独立目录,回落到 items。
|
||||
目标已有同名条目 → `409 DESTINATION_EXISTS`。合并还是报错只有调用方知道,
|
||||
猜错就是数据丢失。把目录移进它自己 → `400 MOVE_INTO_SELF`。
|
||||
|
||||
### `POST /library/browse/rename`
|
||||
### `POST /library/browse/rename` — 管理员
|
||||
|
||||
```json
|
||||
{ "path": "科幻/三体.epub", "name": "三体(修订版).epub" }
|
||||
@@ -434,7 +446,7 @@ pdf / 单图没有独立目录,回落到 items。
|
||||
|
||||
`name` 是单个路径段,不含斜杠。→ `{ "path": "科幻/三体(修订版).epub" }`
|
||||
|
||||
### `POST /library/browse/mkdir`
|
||||
### `POST /library/browse/mkdir` — 管理员
|
||||
|
||||
```json
|
||||
{ "path": "科幻", "name": "待整理" }
|
||||
@@ -442,7 +454,7 @@ pdf / 单图没有独立目录,回落到 items。
|
||||
|
||||
→ `{ "path": "科幻/待整理" }`
|
||||
|
||||
### `POST /library/browse/delete`
|
||||
### `POST /library/browse/delete` — 管理员
|
||||
|
||||
```json
|
||||
{ "paths": ["科幻/其他/球状闪电.epub"] }
|
||||
@@ -471,14 +483,26 @@ pdf / 单图没有独立目录,回落到 items。
|
||||
"size": 1234567, "kind": "file",
|
||||
"bookId": "…", "title": "三体" } ],
|
||||
"skipped": [ { "name": "坏.zip", "reason": "无法解压(METHOD_UNSUPPORTED)" } ],
|
||||
"scan": { "added": 1, "updated": 0, "scaned": 1, "removed": 0, "failed": 0,
|
||||
"startedAt": 1789537864820, "finishedAt": 1789537864831 } }
|
||||
"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` 的暂存文件,
|
||||
@@ -497,6 +521,10 @@ pdf / 单图没有独立目录,回落到 items。
|
||||
- `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` 是一本**目录书**,抹平它正好毁掉扫描器要读的结构。
|
||||
@@ -509,9 +537,9 @@ pdf / 单图没有独立目录,回落到 items。
|
||||
一个上传不进去第二次的书库比没有这个功能更糟。
|
||||
|
||||
挂载只读 → 写入 `BOOKS_DIR` 前返回 `403 READ_ONLY_MOUNT`;请求暂存位于 `DATA_DIR`。
|
||||
`GET /library/upload` 返回 `{ "writable": true }`,让客户端在开始传 400MB 之前先问一句。
|
||||
`GET /library/upload`(任何已登录用户)返回 `{ "writable": true, "allowed": true }`,让客户端在开始传 400MB 之前先问一句:`writable` 是书库根目录是否可写,`allowed` 是当前用户能否上传(管理员恒为 `true`,成员取 `library.memberUpload`)。
|
||||
|
||||
### `POST /library/browse/metadata`
|
||||
### `POST /library/browse/metadata` — 管理员
|
||||
|
||||
```json
|
||||
{ "paths": ["科幻/合集", "科幻/合集/第三卷.epub"],
|
||||
@@ -546,7 +574,7 @@ pdf / 单图没有独立目录,回落到 items。
|
||||
所以只有一对操作。名字保留是因为删掉一个已文档化的取值是破坏性变更)。
|
||||
|
||||
**目标两种命名,只收一种**:`paths`(文件管理器手里是路径)或 `bookIds`(一张卡片手里是
|
||||
书)。两个都给、或都不给,都是 400 `AMBIGUOUS_TARGETS`——两份命名说的是同一个写,
|
||||
书)。`action` 不是这四个之一返回 `400 BAD_ACTION`。两个都给、或都不给,都是 400 `AMBIGUOUS_TARGETS`——两份命名说的是同一个写,
|
||||
同时带上就是「调用方自己不知道该说哪一个」,合并还会让同一本书被写两次、被数两次。
|
||||
|
||||
**`bookIds` 是「从书架移除时,找不到「xxx」在磁盘上的路径」的修法**(#40)。
|
||||
@@ -622,13 +650,14 @@ manifest 的每个条目还带 `ref`,即这个文件对应的**资源引用**
|
||||
```
|
||||
|
||||
可改字段:`title` `author` `publisher` `language` `isbn` `description` `series`
|
||||
`seriesIndex` `pubdate` `tags`(`tags` 用逗号分隔的字符串)。
|
||||
`seriesIndex` `pubdate` `tags`(`tags` 用逗号分隔的字符串)。其他键被忽略;一个可改字段都没有时返回
|
||||
`400 BAD_REQUEST`。→ `{ "book": {...} }`(应用覆盖后的书籍 DTO)
|
||||
|
||||
写入的是覆盖层,**扫描永远不会覆盖它**。
|
||||
|
||||
### `DELETE /books/:id/metadata/:field`
|
||||
|
||||
撤销单个字段,回落到内嵌值。可用来实现 UI 上的「还原」按钮。
|
||||
撤销单个字段,回落到内嵌值。可用来实现 UI 上的「还原」按钮。→ `{ "book": {...} }`
|
||||
|
||||
---
|
||||
|
||||
@@ -657,7 +686,7 @@ manifest 的每个条目还带 `ref`,即这个文件对应的**资源引用**
|
||||
|
||||
能力查询,朗读设置面板打开时调用一次。
|
||||
|
||||
→ `{ "http": false, "formats": ["audio/mpeg", ...], "maxLength": 800, "voices": [] }`
|
||||
→ `{ "http": false, "timeoutMs": 20000, "formats": ["audio/mpeg", ...], "maxLength": 800, "voices": [] }`
|
||||
|
||||
- 未配置 HTTP 朗读合成接口 时返回 `http: false` 而**不是 404**:客户端问的是「这里能用什么引擎」,
|
||||
不是「这个接口存在吗」。一个 HTTP 朗读合成接口 没配的实例是完全正常的实例。
|
||||
@@ -673,13 +702,16 @@ manifest 的每个条目还带 `ref`,即这个文件对应的**资源引用**
|
||||
它同时把响应限制在手机可以边走边缓冲的大小,并让缓存以句为单位而不是以书为单位。
|
||||
- `speed` 夹在 0.25–4;`text` 上限 800 字,超了返回 `TEXT_TOO_LONG`,
|
||||
**不会转发给上游**。
|
||||
- 上游返回非 `audio/*` 时返回 `TTS_UPSTREAM` 而不是把 HTML 当音频流出去:
|
||||
- 上游返回非 `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`,避免服务配置更新后继续使用旧音频;服务端按上游配置缓存合成结果。
|
||||
- 响应带 `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 朗读」修改以下配置:
|
||||
|
||||
@@ -699,8 +731,9 @@ manifest 的每个条目还带 `ref`,即这个文件对应的**资源引用**
|
||||
|
||||
- 找不到客户端产物时这些路由**不会注册**,纯服务端部署不受影响
|
||||
- 都是公开的:它们只是静态资源,背后的 API 仍然要令牌
|
||||
- 不带扩展名的路径一律回落到 `index.html`,方便深链
|
||||
- `.html` 不缓存(否则升级后拿不到新产物),其余资源缓存 1 小时
|
||||
- 不存在的文件路径一律回落到 `index.html`;但 `/api/...` 下未匹配的路径仍返回 JSON `404 NOT_FOUND`
|
||||
- 只有 `assets/` 下带内容哈希的文件名缓存一年(`immutable`);其余文件(含 `index.html`、固定名的
|
||||
`assets/client.js`、`sw.js`)都是 `no-cache`,否则升级后拿不到新产物
|
||||
|
||||
## 书库
|
||||
|
||||
@@ -711,7 +744,7 @@ manifest 的每个条目还带 `ref`,即这个文件对应的**资源引用**
|
||||
|
||||
### `GET /library/continue?limit=20`
|
||||
|
||||
继续阅读列表,按最近阅读时间倒序。每一项是**一整本书**(与 `GET /books` 同样的字段)
|
||||
继续阅读列表,按最近阅读时间倒序,`limit` 默认 20、上限 100,响应为 `{ "items": [...] }`。每一项是**一整本书**(与 `GET /books` 同样的字段)
|
||||
加上 `percentage`、`chapterTitle` 和 `lastReadAt`。
|
||||
|
||||
**它是 `Book` 的扩展,不是另一套形状。** 这里曾经返回进度行自己的命名——
|
||||
@@ -742,6 +775,9 @@ manifest 的每个条目还带 `ref`,即这个文件对应的**资源引用**
|
||||
|
||||
触发一次扫描,同步等待结果。并发调用会共享同一次扫描。
|
||||
|
||||
→ `{ "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, ... } }`
|
||||
@@ -775,18 +811,18 @@ manifest 的每个条目还带 `ref`,即这个文件对应的**资源引用**
|
||||
"updatedAt": 1000, "deleted": false } ] }
|
||||
```
|
||||
|
||||
两个数组至少给一个,总数上限 5000(超了返回 `400 BATCH_TOO_LARGE`)。
|
||||
两个数组至少给一个(都缺、或不是数组返回 `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`。
|
||||
注意:`notes[].type` 只接受 `note` / `highlight` / `bookmark`,其他值整批返回 `400 BAD_NOTE_TYPE`。
|
||||
`notes[].deleted: true` 是删除(写墓碑)。
|
||||
|
||||
### `GET /sync/progress/:bookId`
|
||||
@@ -802,11 +838,11 @@ manifest 的每个条目还带 `ref`,即这个文件对应的**资源引用**
|
||||
"chapterTitle": "第七章", "device": "android-a", "updatedAt": 1789537864623 }
|
||||
```
|
||||
|
||||
`percentage` 会被夹到 `[0, 1]`。
|
||||
`percentage` 会被夹到 `[0, 1]`,`updatedAt` 缺省为服务端当前时间。→ `{ "progress": {...} }`
|
||||
|
||||
### `GET /notes?bookId=<可选>&since=<可选>`
|
||||
|
||||
已过滤掉墓碑,只返回可见笔记。
|
||||
→ `{ "notes": [...], "serverTime": 1789537864820 }`。已过滤掉墓碑,只返回可见笔记。
|
||||
|
||||
### `POST /notes`
|
||||
|
||||
@@ -815,7 +851,7 @@ manifest 的每个条目还带 `ref`,即这个文件对应的**资源引用**
|
||||
"text": "选中的原文", "comment": "我的批注", "color": "yellow" }
|
||||
```
|
||||
|
||||
`id` 可省略或由客户端生成(推荐,便于离线创建)。返回 `201`。
|
||||
`bookId` 必填;`type` 缺省为 `note`。`id` 可省略或由客户端生成(推荐,便于离线创建)。返回 `201 { "note": {...} }`。
|
||||
|
||||
### `DELETE /notes/:id`
|
||||
|
||||
@@ -825,18 +861,26 @@ manifest 的每个条目还带 `ref`,即这个文件对应的**资源引用**
|
||||
|
||||
## 本地与 OPDS 来源
|
||||
|
||||
以下接口需账号令牌。内置类型为 reader.local 和 reader.opds;管理员配置来源,用户浏览、搜索和获取书籍。
|
||||
以下接口需账号令牌。内置类型为 reader.local 和 reader.opds,另可安装扩展插件提供的来源类型;管理员配置来源,用户浏览、搜索和获取书籍。
|
||||
|
||||
| 接口 | 用途 |
|
||||
| --- | --- |
|
||||
| GET /sources/types | 查询内置来源类型及能力 |
|
||||
| GET /sources | 查询来源实例;普通用户不返回管理配置 |
|
||||
| GET /sources/types | 查询已安装的来源类型及能力(`{types}`,含 `pluginId`、`builtin`) |
|
||||
| GET /sources | 查询来源实例(`{sources}`);普通用户不返回管理配置 |
|
||||
| POST /sources | 管理员创建来源 |
|
||||
| PATCH /sources/:id | 管理员修改名称、配置、启停或默认搜索来源 |
|
||||
| GET /sources/:id/browse | 浏览目录 |
|
||||
| POST /sources/:id/search | 搜索书籍 |
|
||||
| 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 | 获取条目并加入当前账号书架 |
|
||||
| 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 示例:
|
||||
|
||||
@@ -846,38 +890,89 @@ manifest 的每个条目还带 `ref`,即这个文件对应的**资源引用**
|
||||
|
||||
pluginId 是接口兼容字段。创建返回 201 与 source 对象。本地来源使用 BOOKS_DIR;OPDS 文件下载到 DATA_DIR/acquired。来源浏览、搜索和详情的游标及条目引用以响应为准,不应从外部 URL 自行拼接。
|
||||
|
||||
搜索请求 body 包含 query 和 sessionId,响应以 text/event-stream 返回 results、done 或 error;获取请求 body 使用 entryRef。
|
||||
搜索请求 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 密码;凭据不会通过普通来源列表回显。暂停来源不删除已获取的文件书。
|
||||
|
||||
Reader 对外提供的只读 OPDS 服务与 WebDAV 备份上传见[接入指南](opds-webdav.zh-CN.md)。
|
||||
### 来源书籍(章节出版物)
|
||||
|
||||
以下接口针对当前账号书架上、由来源获取的书:
|
||||
|
||||
| 接口 | 用途 |
|
||||
| --- | --- |
|
||||
| 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,沿用账号令牌。用户只能访问已授权媒体库;建库、权限、扫描、刮削和人工资料编辑需要管理员权限。准备中返回 MEDIA_STARTING,启动失败返回 MEDIA_UNAVAILABLE。阅读健康检查不能替代影音就绪检查。
|
||||
接口前缀为 /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 媒体库 |
|
||||
| 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 | 设置访问范围与用户列表 |
|
||||
| POST /libraries/:id/scan | 提交扫描任务 |
|
||||
| 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 /browse、GET /libraries/:id/items | 按频道、分类或库浏览 |
|
||||
| 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 /search、GET /favorites、GET /history、GET /queue | 搜索及当前账号收藏、历史与队列 |
|
||||
| PUT /items/:id/favorite | 设置收藏,body 为 {"favorite":true} 或 {"favorite":false} |
|
||||
| PATCH /items/:id/metadata | 管理员保存人工资料 |
|
||||
| GET /metadata/providers | 可用在线资料提供者 |
|
||||
| POST /items/:id/matches、PUT /items/:id/match | 搜索候选、确认资料匹配 |
|
||||
| POST /playback | 根据 partId 创建播放会话 |
|
||||
| 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 | 续期播放会话 |
|
||||
| GET /parts/:id/progress、PUT /playback/:id/progress | 读取与保存播放进度 |
|
||||
| GET /streams/:id | 校验播放票据;OpenList 默认 307 跳转至临时下载地址,本地或代理模式返回字节流。`proxy=1` 请求代理回退,支持有效的字节 Range;响应不缓存 |
|
||||
| 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;响应不缓存 |
|
||||
|
||||
本地建库请求示例:
|
||||
|
||||
@@ -905,21 +1000,21 @@ kind 为 video、music 或 audiobook;OpenList 建库和凭据更新见[OpenLis
|
||||
"displayName": "家人", "role": "member" }
|
||||
```
|
||||
|
||||
不受注册策略限制——管理员本就该能加家庭成员。
|
||||
返回 `201 { "user": {...} }`。`role` 缺省为 `member`。不受注册策略限制——管理员本就该能加家庭成员。
|
||||
|
||||
### `PATCH /admin/users/:id`
|
||||
|
||||
`{ "disabled": true }` 或 `{ "role": "admin" }`。
|
||||
`{ "disabled": true }` 或 `{ "role": "admin" }` → 更新后的完整列表 `{ "users": [...] }`。用户不存在返回 `404`。
|
||||
|
||||
不能停用自己或移除自己的管理员角色(`400 SELF_LOCKOUT`),并须保留至少一名启用的管理员。停用立即撤销旧会话,重新启用仍需重新登录。
|
||||
不能停用自己或移除自己的管理员角色(`400 SELF_LOCKOUT`),并须保留至少一名启用的管理员(`400 LAST_ADMIN`)。停用立即撤销旧会话,重新启用仍需重新登录。
|
||||
|
||||
### `POST /admin/users/:id/password`
|
||||
|
||||
`{ "password": "至少8位的新密码" }` → `{ "ok": true }`。重置其他用户的密码,同时撤销其访问令牌和续期令牌。本人请使用 `/auth/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" }`,对应关闭、开放、仅邀请码注册。数据库设置优先于环境变量,重启后保留。首个账号仍可直接注册为管理员。
|
||||
GET 返回 `{ "mode": "closed", "invites": [...] }`;PATCH 接受 `{ "mode": "closed" | "open" | "invite" }` 并返回 `{ "mode": ... }`,对应关闭、开放、仅邀请码注册。数据库设置优先于环境变量,重启后保留。首个账号仍可直接注册为管理员。
|
||||
|
||||
`GET /instance` 额外返回 `registrationMode` 和 `invitationRequired`,不暴露邀请码;`registrationOpen` 为开放或邀请模式时均为 true。
|
||||
|
||||
@@ -927,7 +1022,7 @@ GET 返回 `{ "mode": "closed", "invites": [...] }`;PATCH 接受 `{ "mode": "c
|
||||
|
||||
创建参数 `{ "label": "家人", "maxUses": 1, "days": 7 }`,备注最多 80 字、次数 1–1000、有效期 1–365 天。返回 `201 { "code": "新邀请码", "invite": {...} }`。明文仅创建时返回,数据库只保存摘要。
|
||||
|
||||
列表条目含 `id/label/maxUses/usedCount/expiresAt/disabled/createdAt`。DELETE 停用邀请码,不删除已注册用户。邀请码消费与账号创建在同一事务内,失败不扣次数,并发不超额。上述管理接口均仅管理员可用。
|
||||
列表条目含 `id/label/maxUses/usedCount/expiresAt/disabled/createdAt`。DELETE 停用邀请码(返回 `{ "ok": true }`),不删除已注册用户。邀请码消费与账号创建在同一事务内,失败不扣次数,并发不超额。上述管理接口均仅管理员可用。
|
||||
|
||||
### `GET /providers`
|
||||
|
||||
@@ -935,6 +1030,22 @@ GET 返回 `{ "mode": "closed", "invites": [...] }`;PATCH 接受 `{ "mode": "c
|
||||
|
||||
本地书籍元数据提供者目前只有 `none`;影音的在线资料提供者使用 `/media/metadata/providers`。
|
||||
|
||||
## AI
|
||||
|
||||
AI 配置在业务设置的 `ai` 分组(见下文 Administrator business settings)。
|
||||
|
||||
### `POST /admin/ai/models` — 管理员
|
||||
|
||||
`{ "baseUrl": "可选", "apiKey": "可选" }` → `{ "models": ["..."] }`。未传的字段使用已保存配置,所以可以在保存之前用草稿获取模型列表;
|
||||
两者都缺返回 `400`,上游失败返回 `502 AI_MODELS_FAILED`。
|
||||
|
||||
### `POST /ai/summary`
|
||||
|
||||
`{ "bookId": "...", "chapterId": "...", "content": "章节正文" }` → `{ "summary": "...", "cached": false }`。
|
||||
|
||||
正文按配置的 `maxInputChars` 截断;结果按账号、书、章节、正文哈希和提示词哈希缓存。AI 或章节总结未启用返回 `403 AI_DISABLED`,
|
||||
地址 / 密钥 / 模型未配置完整返回 `400`。
|
||||
|
||||
---
|
||||
|
||||
## 错误码
|
||||
@@ -944,8 +1055,12 @@ GET 返回 `{ "mode": "closed", "invites": [...] }`;PATCH 接受 `{ "mode": "c
|
||||
| `BAD_REQUEST` | 400 | 参数不合法 |
|
||||
| `BAD_PAYLOAD` | 400 | 请求体结构错误 |
|
||||
| `BATCH_TOO_LARGE` | 400 | 同步批次超过 5000 |
|
||||
| `BAD_NOTE_TYPE` | 400 | 笔记 `type` 不是 `note` / `highlight` / `bookmark` |
|
||||
| `REGISTRATION_DISABLED` | 400 | 实例已关闭公开注册 |
|
||||
| `SELF_LOCKOUT` | 400 | 不能停用自己 |
|
||||
| `INVITE_REQUIRED` | 400 | 邀请模式下未填写邀请码 |
|
||||
| `INVITE_INVALID` | 400 | 邀请码无效、过期、停用或已用完 |
|
||||
| `SELF_LOCKOUT` | 400 | 不能停用自己、移除自己的管理员角色或经管理员接口重置自己的密码 |
|
||||
| `LAST_ADMIN` | 400 | 必须保留至少一名启用的管理员 |
|
||||
| `NO_TOKEN` | 401 | 缺少 Bearer 令牌 |
|
||||
| `TOKEN_INVALID` | 401 | 签名错误 / 载荷被改 / 类型不对 |
|
||||
| `TOKEN_EXPIRED` | 401 | 访问令牌过期,用 refresh 换新 |
|
||||
@@ -953,12 +1068,13 @@ GET 返回 `{ "mode": "closed", "invites": [...] }`;PATCH 接受 `{ "mode": "c
|
||||
| `REFRESH_EXPIRED` | 401 | 刷新令牌过期,需要重新登录 |
|
||||
| `BAD_CREDENTIALS` | 401 | 用户名或口令错误 |
|
||||
| `ACCOUNT_DISABLED` | 403 | 账号被停用 |
|
||||
| `FORBIDDEN` | 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 | 目标不是目录 |
|
||||
@@ -968,19 +1084,45 @@ GET 返回 `{ "mode": "closed", "invites": [...] }`;PATCH 接受 `{ "mode": "c
|
||||
| `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 | 用户名已占用 |
|
||||
| `PASSWORD_TOO_SHORT` | 409 | 口令少于 8 位 |
|
||||
| `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_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 凭据 |
|
||||
@@ -988,13 +1130,17 @@ GET 返回 `{ "mode": "closed", "invites": [...] }`;PATCH 接受 `{ "mode": "c
|
||||
| `ACQUISITION_TOO_LARGE` | 413 | 获取文件超过 256 MiB 上限 |
|
||||
| `CHAPTER_BOOK` | 400 | 章节出版物没有整书文件,请按 manifest 读取章节 |
|
||||
| `CHAPTER_SNAPSHOT_EXPIRED` | 409 | 旧目录的请求章节未缓存,无法再取得该版本 |
|
||||
| `CHAPTER_TOO_LARGE` | 413 | 章节正文或声明大小超过 2 MiB |
|
||||
| `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 | 服务端错误 |
|
||||
|
||||
## 客户端应当遵守的约定
|
||||
@@ -1023,14 +1169,15 @@ All `/api/v1/admin/settings` routes require an administrator bearer token. Respo
|
||||
|
||||
- `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.
|
||||
- Groups: `tmdb`, `musicbrainz`, `tts`, `scanning`, `scraping`, `access`, `webdav`. Registration continues using its existing API.
|
||||
- `POST /api/v1/admin/settings/:group/test` accepts `{values}` for TMDB, MusicBrainz, TTS or WebDAV. It tests the draft without saving it. TTS additionally returns voices.
|
||||
- `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.
|
||||
- `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.
|
||||
- `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. Up to 500 corrections are allowed, with each quote/replacement at most 4,000 characters. headingPrefix is a literal prefix of at most 60 characters.
|
||||
- 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.
|
||||
+106
-15
@@ -11,7 +11,7 @@
|
||||
|
||||
| 位置 | 内容 | 读写 |
|
||||
| --- | --- | --- |
|
||||
| `BOOKS_DIR` (`/books`) | 用户自己的电子书;管理员可执行文件管理操作 | 按挂载权限读写 |
|
||||
| `BOOKS_DIR` (`/books`) | 用户自己的电子书;管理员可执行文件管理操作,成员只能上传书籍 | 按挂载权限读写 |
|
||||
| `DATA_DIR` (`/data`) | SQLite、封面缓存、扫描记录、上传暂存、令牌密钥和影音数据 | 读写 |
|
||||
| 客户端本地 | 离线书文件缓存、本地进度 | 读写 |
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
### 数据目录边界
|
||||
|
||||
`src/config/index.ts` 在启动时断言 `DATA_DIR` 不在 `BOOKS_DIR` 内,否则拒绝启动。这是运行时硬约束。
|
||||
`BOOKS_DIR` 是否只读由部署挂载权限决定:只读挂载适合浏览和阅读;可写挂载允许管理员文件管理器上传、改名、移动、删除和新建目录。
|
||||
`BOOKS_DIR` 是否只读由部署挂载权限决定:只读挂载适合浏览和阅读;可写挂载允许管理员文件管理器上传、改名、移动、删除和新建目录;成员只能在书库浏览页上传书籍文件(只收书籍格式、不解包、不覆盖),且可由业务设置 `library.memberUpload` 整体关闭,改动已有文件的端点都要求管理员(`requireAdmin`,见 [API](api.md#书库文件管理器))。
|
||||
|
||||
扫描器与所有文件访问都经过 `src/lib/paths.ts` 的 `resolveInside()`——
|
||||
唯一的路径解析出口,`../` 逃逸会抛 `PATH_TRAVERSAL`。
|
||||
@@ -78,7 +78,8 @@ id = sha256( (dc:identifier ?? '').toLowerCase() + '\0' + sha256(fileBytes) )
|
||||
1. **`mtime + size`**:便宜,跳过绝大多数文件
|
||||
2. **内容哈希**:权威,确认字节真的变了
|
||||
|
||||
只有第一阶段判断"可能变了"才读文件算哈希。所以每 60 秒轮询一次也很轻。
|
||||
只有第一阶段判断"可能变了"才读文件算哈希。所以每 60 秒轮询一次(默认值,
|
||||
管理员可在「系统设置 → 服务配置」里改扫描间隔)也很轻。
|
||||
|
||||
`Scanner.scan()` 对并发调用做去重(共享同一个 Promise),
|
||||
避免定时器和手动触发同时砸磁盘。
|
||||
@@ -211,8 +212,9 @@ id = sha256( (dc:identifier ?? '').toLowerCase() + '\0' + sha256(fileBytes) )
|
||||
顺序不能反,反了就是「重读把报告覆盖掉」的那个老问题(§5 里 `manager-status`
|
||||
已经为此改过一次)。
|
||||
|
||||
**「移除」这件事为什么必须在书架那一屏。** 书库**曾经**能下架,但书库是**管理员那一半**
|
||||
(§7 的路由按角色守),成员打不开。只有书库能下架,等于只有管理员能整理自己的书架。
|
||||
**「移除」这件事为什么必须在书架那一屏。** 书库**曾经**能下架,但当时书库是**管理员那一半**,
|
||||
成员打不开(现在书库浏览对所有人开放,只有 `#/library/files` 文件管理要求管理员)。
|
||||
只有书库能下架,等于只有管理员能整理自己的书架。
|
||||
所以书架上每张封面自己带一个「⋯」,写入前用一个确认框说清「磁盘上的文件一个都不会动」——
|
||||
那句话才是读者真正在问的。`docs/ui.md` §3.5.2/§3.5.3 写的是它的形状与命中区。
|
||||
|
||||
@@ -287,6 +289,10 @@ chrome 树,而一个同步循环本来会通知三次(进门 `syncing`、出
|
||||
`node:sqlite` 和 `node:crypto` 的 `scrypt` 都是内置的,所以当前依赖树里
|
||||
**没有任何需要编译的包**。
|
||||
|
||||
这也是发布镜像能做成多架构的前提:`.github/workflows/release.yml`(只在 `v*` 标签或手动运行时触发,
|
||||
先跑完 CI)在 amd64 和 arm64 各自的原生 runner 上构建同一个单容器镜像,再合成多架构标签;
|
||||
`npm ci` 在两个架构上都只是下载,不需要编译工具链。
|
||||
|
||||
---
|
||||
|
||||
## 7. 向后兼容承诺
|
||||
@@ -295,6 +301,8 @@ chrome 树,而一个同步循环本来会通知三次(进门 `syncing`、出
|
||||
|
||||
- API 只做加法:加字段、加端点。不改已有字段语义、不删端点。
|
||||
- 数据库 schema 用 `CREATE TABLE IF NOT EXISTS`,升级时新增列/表,不动旧的。
|
||||
新列写进 `server/src/db/schema.ts` 的 `MIGRATIONS_SQL`(`ALTER TABLE … ADD COLUMN`,必须带默认值),
|
||||
启动时逐条执行、忽略「列已存在」——旧版本跑在新库上也不会因为多出来的列出错。
|
||||
- 错误响应格式全站统一,客户端可以一直用同一套解析逻辑。
|
||||
|
||||
---
|
||||
@@ -325,17 +333,30 @@ Readium 用 Readium CSS 主动接管排版、覆盖出版方样式,这与本
|
||||
|
||||
- `react-router-dom` 的 `createHashRouter` 负责 Hash 路由、嵌套路由、参数、Outlet 和错误边界;
|
||||
- `@tanstack/react-query` 的 `QueryClientProvider` 负责服务端状态、缓存、加载、重试和失效;
|
||||
- Zustand stores 负责认证、设置、同步和播放器等跨页面客户端状态;
|
||||
- Mantine 提供基础组件、主题和通知,Lucide React 提供统一图标;
|
||||
- Zustand stores 负责认证、设置、同步(`shared/stores/`)和播放状态(`features/media/stores/`)等跨页面客户端状态;
|
||||
- 基础组件是项目自己的:`ui/toolkit.tsx`(`Button`、`IconButton`、`Segmented`、`Callout`、`SwitchRow` 等)、`ui/sheet.tsx`
|
||||
(**唯一**的弹层类型,设置面板、表单、选择器、确认框都是 `Sheet`/`ConfirmSheet`)、
|
||||
`ui/action-menu.tsx`(每一个「⋯」菜单)、`ui/dialog.tsx`、`ui/pager.tsx`;全局提示在
|
||||
`ui/notifications.ts`,单个操作的失败用 `ui/floating-notice.tsx`。图标来自 Lucide React
|
||||
和项目自己的图标字体(`ui/icon.tsx`)。Mantine 已整体移除,组件只读本项目的设计令牌;
|
||||
- `RuntimeContext` 暴露 API、同步、播放、设置和平台能力,页面不自行组装这些依赖。
|
||||
|
||||
业务按 `features/<domain>` 拆分,每个领域按实际需要拥有 `pages`、`components`、
|
||||
`queries`、`mutations`、`services` 和 `styles`。影音实现位于
|
||||
`web/src/features/media`;登录、书架、书库、书源、设置和阅读页面也通过各自
|
||||
feature 接入同一棵路由树。旧的 `web/src/media`、旧 Screen 页面、旧手写生产路由
|
||||
`web/src/features/media`;登录(`auth`)、首页(`home`)、全局搜索(`search`)、书架、
|
||||
书库、书源、「我的」(`account`)和阅读页面也通过各自 feature 接入同一棵路由树。
|
||||
旧的 `web/src/media`、旧 Screen 页面、旧手写生产路由
|
||||
和 `mountUI` 已从生产入口移除。`web/src/app.ts` 与
|
||||
`app/legacy-compat.ts` 只保留 Android 诊断和旧测试需要的兼容形状,不负责页面调度。
|
||||
|
||||
**少数人才打开的屏幕不进首屏。** 阅读器(EPUB/PDF/ZIP 解析、分页、朗读)和管理员页面
|
||||
(媒体库管理、扫描与刮削、条目元数据/匹配/章节/版本/结构、书源编辑、凭据、扩展页)曾占了
|
||||
只看书架的人 1.5 MB 首屏加载的一半。它们在 `web/src/app/router/lazy-pages.tsx` 里
|
||||
各自包成带 `Suspense` 的同名组件,路由表里仍写 `ReaderPage`、`MediaLibrariesPage`……
|
||||
新的懒加载屏幕加在这里,不在页面里临时 `import()`。入口固定为 `assets/client.js`、
|
||||
样式只有一份(`cssCodeSplit: false`),懒加载块带内容哈希;Service Worker 的预缓存清单由
|
||||
`web/tools/pwa/build.ts` 列出全部产物,新块不需要手动登记。
|
||||
|
||||
React 组件只声明布局和状态,副作用放在 effect、query 或 service 中。这样登录、
|
||||
书架、书库、书源、设置和影音页面共享同一套生命周期语义,加载、错误、空状态和
|
||||
返回行为都能由路由与查询状态复现和测试。
|
||||
@@ -368,12 +389,16 @@ URL 从头到尾不变。手机上这一点几乎看不见——直到读者按
|
||||
|
||||
| 路由 | 页面 |
|
||||
| --- | --- |
|
||||
| `#/` | 首页 |
|
||||
| `#/shelf` | 书架 |
|
||||
| `#/library`、`#/library/books`、`#/library/files` | 书库浏览、书籍列表、文件管理 |
|
||||
| `#/sources` 及 `#/sources/...` | 书源中心、浏览、凭据、扩展和插件管理 |
|
||||
| `#/search?q=` | 书籍与影音的全局搜索 |
|
||||
| `#/library`、`#/library/books`、`#/library/files` | 书库浏览、书籍列表、文件管理(仅管理员) |
|
||||
| `#/sources` 及 `#/sources/...` | 书源中心、浏览、书源编辑、凭据、扩展和插件管理 |
|
||||
| `#/me`、`#/me/:panel` | 「我的」及其设置面板(主题、浏览、播放、账号等) |
|
||||
| `#/settings` | 兼容旧深链接,重定向到书架 |
|
||||
| `#/book/:bookId` | 阅读器 |
|
||||
| `#/media/:channel` | 影音频道目录 |
|
||||
| `#/media/:channel/folders/:libraryId?`、`#/media/audiobook/narrators/...` | 按文件夹浏览、演播者 |
|
||||
| `#/media/:channel/items/:itemId` | 影音详情及管理子页 |
|
||||
| `#/media/:channel/player?item=&part=` | 播放器 |
|
||||
| `#/media/:channel/settings/libraries`、`.../tasks` | 媒体库管理、扫描与刮削(旧的 `#/media/:channel/settings[/:panel]` 重定向到「我的」) |
|
||||
@@ -388,6 +413,11 @@ Hash 路由适配 WebView 和静态服务器:宿主只需要返回同一个 `i
|
||||
`#` 后解析页面,不依赖服务端为每个深层路径配置回退规则。登录是认证边界而不是业务
|
||||
页面;未认证时由 `AuthBoundary` 显示登录表单,认证完成后 React Router 渲染目标地址。
|
||||
|
||||
导航只有一份:`web/src/app/AppNav.tsx`,手机上是底部标签栏,701px 起是侧栏(首页、书架、影音、我的)。
|
||||
它显示与否也由 URL 决定(`navMode()`):阅读器和播放器是 `hidden`,整屏归内容;
|
||||
顶层目的地是 `top`;从顶层进入的下一级页面是 `push`,手机上用自己的返回箭头替换标签栏,
|
||||
桌面侧栏保留。
|
||||
|
||||
### 阅读器与长生命周期资源
|
||||
|
||||
阅读器是唯一的特殊生命周期边界:`ReaderPage` 在 effect 中取得书籍后创建
|
||||
@@ -409,6 +439,13 @@ ReaderScreen 持有的舞台节点放到 chrome 树中的正确位置。React
|
||||
`web/src/styles/theme-tokens.css` 的 `--color-*` 语义令牌给出;系统深浅色、同页保存和
|
||||
其他标签页的存储变化都会重新应用。阅读器选择黑白灰时只在自己的容器上写
|
||||
`data-theme="gray"`,视频和音乐播放页在自己的根节点上使用当前主题的深色取值。
|
||||
主题偏好只经 `readThemePreference` / `saveThemePreference` / `resolveAppTheme`
|
||||
(React 里用 `shared/theme/use-theme.ts` 的 `useThemeState`)读写,不直接碰存储。
|
||||
令牌取值的出处是 `docs/prototypes/Reader V3 主题语义令牌.md`:`theme.ts` 里的调色板和样式表
|
||||
是同一组值,`web/test/theme-tokens.test.ts` 保持两者一致并检查规范里的对比度配对。
|
||||
字号和圆角同样是按角色命名的令牌(`--text-display` … `--text-micro` 七级、
|
||||
`--radius-xs` … `--radius-pill` 六级,定义在 `styles/reader.css` 的 `:root`),
|
||||
新的颜色、间距、时长也写成令牌而不是字面量,细则见 `ui.md` §2。
|
||||
|
||||
### 只有一份渲染层
|
||||
|
||||
@@ -498,6 +535,25 @@ shadow root 拿到完整样式隔离且没有文档边界,App 还能进去做
|
||||
两条规则里的**值**不重复:全部是自定义属性,而属性本身定义在 `:root` 上,
|
||||
shadow root 会继承。
|
||||
|
||||
`touch-action` 是同一条边界上的又一个例子。不声明它,浏览器会认领每一次横向拖动
|
||||
(触发 `pointercancel`):手机上滑动从来翻不了页,向右滑还会被当成浏览器的返回手势、
|
||||
直接离开这本书。翻页和固定版式把所有移动交给手势层(`touch-action: none`),滚动模式只留
|
||||
纵向平移(`pan-y`)。它必须**同时**写在 host(`styles/reader.css`)和 shadow root 里的分页列
|
||||
(`ui/shadow.ts`)上:分页列才是滚动容器,手指落在文字上时由它往上的那些盒子决定归谁。
|
||||
|
||||
### 翻页模式只落在列边界上
|
||||
|
||||
翻页模式下,一个不在列边界上的 `scrollLeft` 就是「左半屏是这一页、右半屏是下一页」。
|
||||
手机每次地址栏或键盘出没都会改视口高度,高度变了列数就变,于是恢复位置时的
|
||||
`max * fraction` 几乎从来不是边界。所以 `ReaderView` 里所有翻页模式的 `scrollLeft` 写入
|
||||
(恢复位置、朗读跟随、定位块、搜索)都经过同一个 `scrollToColumnAt()`;浏览器自己发起的滚动
|
||||
(聚焦、选区自动滚动)由 `snapStrayScroll()` 拉回边界,只有正在跟手的拖动例外。
|
||||
|
||||
用户高亮同理不是「量一次就完」:字号、页边距、模式切换、窗口尺寸或一张晚到的图片都会
|
||||
挪动它下面的文字,所以每次重排之后重画。高亮的章节 id 以**已加载文档自己的** section id
|
||||
为准(`ReaderScreen.localSectionId`),不按格式猜 `xhtml:` 前缀——猜错的高亮能保存、能同步,
|
||||
却永远画不出来。
|
||||
|
||||
### 窗口化的三个接缝
|
||||
|
||||
「一份产物两个宿主」和「一千二百章的书不能先下载整本」是同一件事的两面,
|
||||
@@ -517,8 +573,6 @@ shadow root 会继承。
|
||||
在窗口最后一章时下标是 39,拿它和窗口长度 40 比会得出「已经是最后一章」,
|
||||
而后面还有八十章。窗口长度是传输细节,不该决定书在哪里结束。
|
||||
|
||||
### 阅读位置格式
|
||||
|
||||
### 书不能联网
|
||||
|
||||
加载时把章节里所有本地引用改写成内部 `reader-res:` 协议,渲染层从内存资源表解出;
|
||||
@@ -570,6 +624,17 @@ GB18030 再试 Big5,而是两种都解一遍再挑更干净的那个。原因
|
||||
更不能当标题。两条都有测试固定住。推断不出章节时按长度分段:
|
||||
一本五千章的网文如果变成一整节,分页会直接卡死。
|
||||
|
||||
网页残留:从网站另存的小说常把 `<div>`、`<br/>`、` ` 留在 TXT 里。转义后这些标签会被
|
||||
当成文字画出来,整章还会连成一段。所以在推断段落和章节标题**之前**先过
|
||||
`cleanTextMarkup()`(`web/src/formats/segments.ts`):块级标签变换行、行内标签去掉、常见实体解码、
|
||||
只有空白的行算空行。只认真实的标签名——正文里的 `a<b`、`<书名>` 不动,仍按文字转义。
|
||||
|
||||
书源(`format: 'chapters'`)的 `text/html` 章节是抓下来的网页片段,同样没有作者排版。
|
||||
**清洗由插件按书源声明的类型决定**(文本源整理成段落,图片/漫画源原样保留);
|
||||
阅读器只负责排版:只含文字和换行的章节(`isTextOnlyMarkup`)在读取时用 `htmlToText()`
|
||||
读回成字符、走上面同一套 TXT 分段和缩进,含图片、表格、媒体的章节保留标记,EPUB 不进这条路径。
|
||||
放在**读取时**而不是写入时,是为了让服务端早先缓存的章节也被修好。细则见 `ui.md` 的 TXT 排版一节。
|
||||
|
||||
### 漫画的页序
|
||||
|
||||
按自然序排(`1, 2, 10` 而不是 `1, 10, 2`)。排错不是观感问题,是故事被静默打乱。
|
||||
@@ -652,7 +717,7 @@ Android 上经典的「页面自己往边上飘」bug。
|
||||
| --- | --- | --- |
|
||||
| `native` | Android 外壳的 `TextToSpeech` | 有 `speech` 接口的外壳(版本 ≥ 3) |
|
||||
| `system` | WebView / 浏览器的 `speechSynthesis` | 默认。免费、离线、无往返 |
|
||||
| `http` | 服务端的 `TTS_URL`,经 `/api/v1/tts` 代理 | 前两者都不可用时 |
|
||||
| `http` | 服务端配置的 HTTP 朗读服务(系统设置 → 服务配置;`TTS_URL` 环境变量只作初始值),经 `/api/v1/tts` 代理 | 前两者都不可用时 |
|
||||
|
||||
`auto` 的优先级是 native → system → http,理由不是「谁更好」而是**谁知道得更多**:
|
||||
|
||||
@@ -660,7 +725,7 @@ Android 上经典的「页面自己往边上飘」bug。
|
||||
手机系统装好的中文语音经常不在 WebView 的 `getVoices()` 列表里,
|
||||
于是读者听到英文或听不到声音,而 Web 层拿到的列表本身就是缺的,修不了。
|
||||
这是「朗读没有中文声音」最常见的原因,唯一的解法是直接问平台。
|
||||
- http 排最后,因为它的每一句都要一次请求,而且它需要一个配了 `TTS_URL` 的服务端。
|
||||
- http 排最后,因为它的每一句都要一次请求,而且它需要一个配了 HTTP 朗读服务的服务端。
|
||||
|
||||
三者的**暂停语义真的不同**,所以它们不是同一个基类:
|
||||
|
||||
@@ -768,3 +833,29 @@ Android WebView 是真实会发生的)才推进,不是为了给朗读计时
|
||||
(注意:`check-shell.sh` 里新增的渲染模式词表校验,以及 `colors.xml` 里
|
||||
`--` 在 XML 注释中非法、Kotlin 侧 `Uint8Array` 不存在这两处,
|
||||
都是**第一次真正编译 Android 壳**时才暴露的。写不能编译的代码等于没写。)
|
||||
|
||||
---
|
||||
|
||||
## 10. 书源与插件
|
||||
|
||||
书源是「书库之外的书从哪来」:本地书库、OPDS 目录和进程插件(`server/src/sources/`)。
|
||||
宿主 `SourceHost`(`server/src/services/source-host.ts`)持有注册表、来源实例的生命周期和
|
||||
凭据边界;路由在 `http/routes/sources.ts`,接口形状见 [`api.md`](api.md)。
|
||||
|
||||
- **插件是进程,不是模块。** `ProcessPlugin` 以子进程运行受信任的 Node 插件,按行收发
|
||||
JSON-RPC(stdio),有超时和单条消息大小上限,返回值经 `protocol.ts` 解码后才进入宿主。
|
||||
这是**故障隔离而不是沙箱**:插件有服务进程同一个系统用户的权限,所以安装是管理员操作,
|
||||
安装包必须留在 `DATA_DIR/plugins` 里(`PLUGIN_PATH_ESCAPE`)。一个插件崩了、卡了,
|
||||
丢的是这一次调用,不是整个服务端。示例在 `examples/plugins/demo-chapters`。
|
||||
- **凭据加密存库。** 来源凭据用从签名密钥派生的密钥加密,接口只回报「有没有」,不回原文。
|
||||
- **搜索是一条流,失败按来源报告。** `POST /sources/:id/search` 的响应是
|
||||
`text/event-stream`(`http/search-stream.ts`):同一个请求里依次推出每一页结果,宿主按
|
||||
`ref` 去重、按 `resultLimit` 截断、校验游标不重复和批次进度不倒退。一个来源实例可能聚合了
|
||||
很多书源(例如书源插件),其中几个超时或报错不应让整次搜索失败,所以每一页可以带
|
||||
`errors[]`(`source` + `code` + `message`)。每一块只带**自上一块以来**的失败,客户端
|
||||
(`SourceCatalogPage` 的 `mergePages`)必须按 `source:code` 累积,而不是用最新一块替换——
|
||||
否则读者只看得到最后一个出错的书源。同一用户、同一来源、同一 `sessionId` 的新连接会先中止旧连接,
|
||||
`…/search/cancel` 显式结束会话。
|
||||
- **书源的书也是书。** 取得(acquire)一本书源书时,`publications/chapters.ts` 把它写进同一张
|
||||
`books` 表(`format: 'chapters'`)并缓存章节,书行仍只由 `ShelfService` 组装(§5 那条规则);
|
||||
章节的清洗与排版边界见 §8「TXT 的编码与章节」。
|
||||
@@ -1,19 +1,21 @@
|
||||
# 业务配置
|
||||
|
||||
管理员在「系统设置 → 服务配置」编辑实例业务配置,其中包含 TMDB、MusicBrainz 等服务分类。普通用户无法读取或修改服务凭据。
|
||||
管理员在「我的 → 管理 → 系统设置 → 服务配置」编辑实例业务配置,通过「配置分类」切换各分类。普通用户无法读取或修改服务凭据。
|
||||
|
||||
| 分类 | 可修改内容 |
|
||||
| --- | --- |
|
||||
| AI 功能 | 启用 AI、AI 扫描与 AI 总结;OpenAI 兼容接口地址、API Key、模型(填写地址和密钥后可获取模型列表)、扫描与总结 Prompt、扫描批次大小、总结最大输入字符数 |
|
||||
| 媒体播放 | OpenList 自动(直连优先)、浏览器直连、服务器代理;默认自动 |
|
||||
| TMDB | 启用、读取令牌、API Key、元数据语言,令牌优先于 API Key |
|
||||
| MusicBrainz | 启用、应用标识与联系地址,例如 `Reader/0.1 (mailto:admin@example.com)` |
|
||||
| HTTP 朗读 | 启用、合成接口、Bearer 令牌、音色列表接口、超时、缓存上限,支持检测连接、获取音色、试听和清理缓存 |
|
||||
| 扫描 | 阅读书库检查间隔(0 关闭)、同时扫描的媒体库数、远端文件处理并发数 |
|
||||
| 扫描设置 | 阅读书库自动检查间隔(秒,默认 60,0 关闭)、同时扫描的媒体库数、远端文件处理并发数;影音库自动扫描新文件(每小时检查)、扫描后刮削封面与简介、读取内嵌字幕与歌词、扫描隐藏文件夹 |
|
||||
| 刮削高级设置 | 请求超时、失败重试次数;MusicBrainz 的服务商限流仍强制执行 |
|
||||
| 登录与服务访问 | 对外地址、允许的浏览器来源、访问令牌与登录续期有效期 |
|
||||
| WebDAV 备份 | HTTPS 目录、账号、密码、上传上限、连接测试及已验证离线备份上传 |
|
||||
| 书库 | 允许成员上传书籍(默认开启)。开启时成员只能在书库页新增支持格式的书籍文件,不解包压缩包、不覆盖已有文件;关闭后成员看不到上传按钮。改名、移动、删除和新建目录始终仅限管理员 |
|
||||
|
||||
注册策略仍在「注册与邀请」管理;OpenList、媒体库目录及权限沿用原管理页面。个人音色、语速和阅读样式不属于实例配置。
|
||||
注册策略在同一面板的「注册与邀请」管理;OpenList、媒体库目录及权限在「我的 → 媒体库」管理。影音扫描的四个开关同样显示在媒体库管理页,两处修改的是同一份配置。个人音色、语速和阅读样式不属于实例配置。
|
||||
|
||||
## 保存与生效
|
||||
|
||||
@@ -27,7 +29,7 @@
|
||||
|
||||
## 旧配置迁移
|
||||
|
||||
升级首次初始化每个分类时,导入原有 `MEDIA_TMDB_*`、`MEDIA_MUSICBRAINZ_USER_AGENT`、`TTS_*`、`PUBLIC_URL`、`CORS_ORIGINS`、登录有效期及 `WEBDAV_*` 环境变量。原 `SCAN_INTERVAL` 与 `WATCH_INTERVAL` 取启用值中的较小间隔,两者都关闭则关闭自动检查。已有注册策略优先于 `ALLOW_REGISTRATION`。
|
||||
升级首次初始化每个分类时,导入原有 `AI_BASE_URL`/`AI_API_KEY`/`AI_MODEL`、`MEDIA_TMDB_*`、`MEDIA_MUSICBRAINZ_USER_AGENT`、`TTS_*`、`PUBLIC_URL`、`CORS_ORIGINS`、登录有效期及 `WEBDAV_*` 环境变量。原 `SCAN_INTERVAL` 与 `WATCH_INTERVAL` 取启用值中的较小间隔,两者都关闭则关闭自动检查。已有注册策略优先于 `ALLOW_REGISTRATION`。
|
||||
|
||||
完成初始化后,环境变量不再覆盖已保存配置,清空凭据也不会让旧变量重新生效。部署端可移除这些业务环境变量。监听端口、DATA_DIR、BOOKS_DIR、WEB_DIR、签名密钥、日志级别和外部程序路径仍是部署参数。
|
||||
|
||||
|
||||
+10
-10
@@ -15,13 +15,13 @@ Read-only books do not prevent account or progress updates in the data directory
|
||||
|
||||
## Business settings in the UI
|
||||
|
||||
Administrators configure HTTP speech, scanning, login lifetimes, public URL, browser origins, TMDB, MusicBrainz and WebDAV under System settings → Service configuration. Registration remains under Registration and invitations. Values persist in reader.db and apply without restarting.
|
||||
Administrators configure AI features, media playback mode, HTTP speech, scanning, scraping, login lifetimes, public URL, browser origins, TMDB, MusicBrainz and WebDAV under Me → System settings → Service configuration (我的 → 系统设置 → 服务配置). Registration is set in the same panel under Registration and invitations (注册与邀请). Values persist in reader.db and apply without restarting.
|
||||
|
||||
On first upgrade, legacy business environment variables are imported once. Existing database values, including cleared credentials, always win. See [business settings (中文)](business-settings.zh-CN.md).
|
||||
|
||||
## Deployment environment variables
|
||||
|
||||
These are defaults when running the server directly. The Docker image additionally sets `DATA_DIR=/data` and `WEB_DIR=/app/web`, with `BOOKS_DIR=/books`.
|
||||
These are defaults when running the server directly. The Docker image additionally sets `DATA_DIR=/data`, `WEB_DIR=/app/web`, `BOOKS_DIR=/books`, and `MEDIA_FFPROBE_PATH=/usr/bin/ffprobe`.
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
@@ -31,8 +31,8 @@ These are defaults when running the server directly. The Docker image additional
|
||||
| `PORT` | `5888` | Listen port |
|
||||
| `READER_TOKEN_SECRET` | Generated | Signing secret, at least 16 characters; otherwise stored in `DATA_DIR/token.secret` |
|
||||
| `LOG_LEVEL` | `info` | Logging level |
|
||||
| `MEDIA_FFPROBE_PATH` | `ffprobe` | Local media probe executable |
|
||||
| `MEDIA_FFMPEG_PATH` | `ffmpeg` | Embedded artwork and subtitle extraction |
|
||||
| `MEDIA_FFPROBE_PATH` | `ffprobe` | Local media probe executable; without it media is still indexed, but duration, tags, and chapters may be incomplete |
|
||||
| `MEDIA_FFMPEG_PATH` | `ffmpeg` | Embedded artwork and subtitle extraction; no transcoding |
|
||||
| `WEB_DIR` | `web` under the current working directory | Built Web client directory |
|
||||
|
||||
Run `docker compose up -d` after changing environment variables. Keep the signing secret stable to preserve sessions. Accounts, progress, and notes cannot be recovered by rescanning book files.
|
||||
@@ -49,7 +49,7 @@ reader.example.com {
|
||||
}
|
||||
```
|
||||
|
||||
Set the public URL to `https://reader.example.com` in Login and service access. If Caddy also runs in a container, use reader's service address on the container network instead.
|
||||
Set the public URL to `https://reader.example.com` in Me → System settings → Service configuration → Login and service access (登录与服务访问). If Caddy also runs in a container, use reader's service address on the container network instead.
|
||||
|
||||
For separately hosted frontends, set allowed browser origins in Login and service access to the Web client's actual origin. Source search uses Streamable HTTP: proxies must forward `text/event-stream` incrementally rather than buffer the entire response. A VPN or tunnel can provide access when you have no public IP.
|
||||
|
||||
@@ -57,7 +57,7 @@ For separately hosted frontends, set allowed browser origins in Login and servic
|
||||
|
||||
Use Android system speech or browser `speechSynthesis` when available. To enable an HTTP speech service, configure a compatible upstream in the administrator UI:
|
||||
|
||||
Configure the synthesis URL, token, voices URL, timeout and cache limit in System settings → Service configuration → HTTP speech. Test the connection and preview audio before saving.
|
||||
Configure the synthesis URL, token, voices URL, timeout and cache limit in Me → System settings → Service configuration → HTTP speech (HTTP 朗读). Test the connection and preview audio before saving.
|
||||
|
||||
reader proxies speech requests and does not include a synthesis model. The upstream must implement the expected request protocol; a service's name alone does not establish compatibility. See the [TTS API (中文)](api.md). The address must be reachable from the reader container; `localhost` inside a container refers to that container.
|
||||
|
||||
@@ -76,19 +76,19 @@ Setting `READER_TOKEN_SECRET` only avoids writing the secret file. It does not r
|
||||
|
||||
### File management returns `READ_ONLY_MOUNT`
|
||||
|
||||
Check whether the mount uses `:ro`. After switching to `:rw` and recreating the container, verify the server process can write the target directory. Uploads are available only on the administrator file-management page.
|
||||
Check whether the mount uses `:ro`. After switching to `:rw` and recreating the container, verify the server process can write the target directory. Administrators upload from the file-management page and members upload book files from the library page; renaming, moving, deleting and creating directories are administrator-only.
|
||||
|
||||
### The UI opens but books are missing
|
||||
|
||||
Check that the mounted files are readable and supported, then wait for scanning or trigger a scan as an administrator. The scanner skips directories such as `.git`, `@eaDir`, and `#recycle` and does not follow symlinks. Use the library file page to inspect the actual directory.
|
||||
Check that the mounted files are readable and supported, then wait for the next automatic check (every 60 seconds by default, see Scan settings); administrators can also call `POST /api/v1/library/scan` to scan immediately. The scanner skips directories such as `.git`, `@eaDir`, and `#recycle` and does not follow symlinks. Use the library file page to inspect the actual directory.
|
||||
|
||||
## Media libraries
|
||||
|
||||
Mount local movie, series, music, and audiobook directories separately (read-only is recommended), then enter their container paths in media settings. For a native server, use absolute local paths. Do not use DATA_DIR as a media root. Users see authorized libraries; administrators manage scans, matching, and metadata.
|
||||
Mount local movie, series, music, and audiobook directories separately (read-only is recommended), then enter their container paths under Me → Media libraries (我的 → 媒体库). For a native server, use absolute local paths. Do not use DATA_DIR as a media root. Users see authorized libraries; administrators manage scans, matching, and metadata.
|
||||
|
||||
The official image includes ffprobe and ffmpeg. For native runs, put them on PATH or set MEDIA_FFPROBE_PATH / MEDIA_FFMPEG_PATH to executable paths. They probe metadata and extract embedded resources; they do not enable transcoding.
|
||||
|
||||
Configure TMDB credentials and MusicBrainz contact information under System settings → Service configuration. Changes apply without restarting. Local tags and NFO work without online credentials.
|
||||
Configure TMDB credentials and MusicBrainz contact information under Me → System settings → Service configuration. Changes apply without restarting. Local tags and NFO work without online credentials.
|
||||
|
||||
Configure OpenList in the library creation form; no local mount is needed. Reader must reach OpenList and its download upstreams. Remote embedded metadata is not read, and some drivers require OpenList Web proxy. See [OpenList setup (中文)](media-openlist.md).
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## 目录与权限
|
||||
|
||||
- `BOOKS_DIR` 是用户书籍目录。`:ro` 用于只读部署;`:rw` 配合文件系统写权限启用管理员文件管理。
|
||||
- `BOOKS_DIR` 是用户书籍目录。`:ro` 用于只读部署;`:rw` 配合文件系统写权限启用上传和管理员文件管理(成员只能上传书籍文件,可在业务设置「书库」中关闭)。
|
||||
- `DATA_DIR` 保存 SQLite、账号、阅读记录、密钥、封面、扫描状态、上传暂存、下载文件与影音资料,必须始终可写。
|
||||
- `DATA_DIR` 不能与 `BOOKS_DIR` 相同或位于其内部,启动时会拒绝该配置。
|
||||
- 镜像入口脚本尝试修正数据目录本身的属主,再以 `reader` 用户运行。它不会替你修改书籍目录的属主,也不会递归修复已有数据文件的权限。
|
||||
@@ -15,13 +15,13 @@
|
||||
|
||||
## 页面业务配置
|
||||
|
||||
管理员在「系统设置 → 服务配置」中管理 HTTP 朗读、扫描、登录有效期、对外地址、浏览器来源、TMDB、MusicBrainz 及 WebDAV 备份目标,注册策略沿用「注册与邀请」。配置写入 reader.db,保存后无需重启。详见[业务配置](business-settings.zh-CN.md)。
|
||||
管理员在「我的 → 系统设置 → 服务配置」中管理 AI 功能、媒体播放方式、HTTP 朗读、扫描、刮削、登录有效期、对外地址、浏览器来源、TMDB、MusicBrainz 及 WebDAV 备份目标,注册策略在同一面板的「注册与邀请」中设置。配置写入 reader.db,保存后无需重启。详见[业务配置](business-settings.zh-CN.md)。
|
||||
|
||||
升级首次启动会导入旧业务环境变量;数据库存在配置后不再读取这些环境变量,清空字段也不会恢复旧值。
|
||||
|
||||
## 部署环境变量
|
||||
|
||||
以下是直接运行服务端时的默认值。Docker 镜像另设 `DATA_DIR=/data`、`WEB_DIR=/app/web`,`BOOKS_DIR=/books`。
|
||||
以下是直接运行服务端时的默认值。Docker 镜像另设 `DATA_DIR=/data`、`WEB_DIR=/app/web`、`BOOKS_DIR=/books` 和 `MEDIA_FFPROBE_PATH=/usr/bin/ffprobe`。
|
||||
|
||||
| 变量 | 默认值 | 用途 |
|
||||
| --- | --- | --- |
|
||||
@@ -49,13 +49,13 @@ reader.example.com {
|
||||
}
|
||||
```
|
||||
|
||||
在「登录与服务访问」中设置对外地址为 `https://reader.example.com`。如果 Caddy 也在容器中,代理地址应改为容器网络中 reader 的服务地址。
|
||||
在「我的 → 系统设置 → 服务配置 → 登录与服务访问」中设置对外服务地址为 `https://reader.example.com`。如果 Caddy 也在容器中,代理地址应改为容器网络中 reader 的服务地址。
|
||||
|
||||
前后端分开部署时,在「登录与服务访问」中设置允许的浏览器来源为 Web 客户端的实际来源。书源搜索采用 Streamable HTTP,代理需要持续转发 `text/event-stream`;不要把搜索响应缓冲到完成后才返回。没有公网 IP 时也可用 VPN 或隧道连接服务。
|
||||
|
||||
## HTTP 朗读
|
||||
|
||||
优先使用 Android 系统语音或浏览器 `speechSynthesis`。需要 HTTP 语音时,进入「系统设置 → 服务配置 → HTTP 朗读」,填写合成接口、认证令牌和可选音色列表地址,启用后检测连接、试听并保存。请求超时和音频缓存上限也在此设置。
|
||||
优先使用 Android 系统语音或浏览器 `speechSynthesis`。需要 HTTP 语音时,进入「我的 → 系统设置 → 服务配置 → HTTP 朗读」,填写合成接口、认证令牌和可选音色列表地址,启用后检测连接、试听并保存。请求超时和音频缓存上限也在此设置。
|
||||
|
||||
reader 代理语音请求,不内置合成模型。上游接口需兼容项目的请求协议;不能仅凭服务名称判断是否兼容,详见 [TTS API](api.md)。地址必须从 reader 容器内可达,容器内的 `localhost` 指容器自身。
|
||||
|
||||
@@ -74,11 +74,11 @@ reader 代理语音请求,不内置合成模型。上游接口需兼容项目
|
||||
|
||||
### 文件管理返回 `READ_ONLY_MOUNT`
|
||||
|
||||
检查卷是否为 `:ro`。改用 `:rw` 并重新创建容器后,还需确认服务进程对目标目录有写权限。上传仅在管理员文件管理页面提供。
|
||||
检查卷是否为 `:ro`。改用 `:rw` 并重新创建容器后,还需确认服务进程对目标目录有写权限。管理员在文件管理页上传,普通成员在书库页上传书籍;改名、移动、删除和新建目录仅限管理员。
|
||||
|
||||
### 页面打开但看不到书
|
||||
|
||||
确认挂载目录中的文件对服务进程可读,格式受支持,并等待扫描完成或由管理员手动扫描。扫描器跳过 `.git`、`@eaDir`、`#recycle` 等目录,不跟随符号链接;可在书库文件页核对实际目录。
|
||||
确认挂载目录中的文件对服务进程可读,格式受支持,并等待下一次自动检查(默认 60 秒,见「扫描设置」);管理员也可调用 `POST /api/v1/library/scan` 立即扫描。扫描器跳过 `.git`、`@eaDir`、`#recycle` 等目录,不跟随符号链接;可在书库文件页核对实际目录。
|
||||
|
||||
## 影音媒体库
|
||||
|
||||
@@ -86,7 +86,7 @@ reader 代理语音请求,不内置合成模型。上游接口需兼容项目
|
||||
|
||||
官方镜像包含 ffprobe 和 ffmpeg。本机启动时将二者加入 PATH,或设置 MEDIA_FFPROBE_PATH / MEDIA_FFMPEG_PATH 为可执行文件路径。ffprobe 用于时长、编码、标签和章节,ffmpeg 用于内嵌资源提取,均不提供转码。
|
||||
|
||||
在线匹配在书架右上角「系统设置 → 服务配置」的 TMDB 和 MusicBrainz 分类中配置令牌、API Key、应用标识与联系地址。可测试连接、调整资料语言并保存,无需重启。本地 NFO 和标签读取不依赖在线凭据。
|
||||
在线匹配在「我的 → 系统设置 → 服务配置」的 TMDB 和 MusicBrainz 分类中配置令牌、API Key、应用标识与联系地址。可测试连接、调整资料语言并保存,无需重启。本地 NFO 和标签读取不依赖在线凭据。
|
||||
|
||||
OpenList 连接信息在建库界面设置,无需本地挂载;服务端必须能访问 OpenList 和其下载源。远端内嵌资料暂不读取,某些驱动需要 OpenList Web 代理。配置与限制见 [OpenList 接入](media-openlist.md)。
|
||||
|
||||
|
||||
+55
-10
@@ -4,7 +4,7 @@
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js 24 is recommended to match the current container and CI. The server uses built-in `node:sqlite`.
|
||||
- Node.js 22 or later (`engines` in `server/package.json`); 24 is recommended to match the current container and CI. The server uses built-in `node:sqlite` and runs TypeScript directly through `tsx`.
|
||||
- npm and Git. Server and Web have separate package manifests and lockfiles; there is no root-level npm script.
|
||||
- Chromium for UI reviews.
|
||||
- For Android: JDK 17+, Android SDK 35, and Build Tools 35.0.1. The Web asset script requires a POSIX shell; Windows users can use Git Bash or WSL.
|
||||
@@ -29,7 +29,7 @@ Linux CI can use `npx playwright install --with-deps chromium` to install system
|
||||
|
||||
## Start development servers
|
||||
|
||||
Open two terminals at the repository root. Use a test library and keep data outside it.
|
||||
Open two terminals at the repository root. Use a test library and keep data outside it (the server refuses to start when `DATA_DIR` is inside `BOOKS_DIR`).
|
||||
|
||||
Linux/macOS:
|
||||
|
||||
@@ -69,9 +69,43 @@ npm test --prefix web
|
||||
npm run build --prefix web
|
||||
```
|
||||
|
||||
Web tests use both Vitest and Node's test runner. UI reviews use real Chromium; prepare it as described above. Historical test counts are not a substitute for current results.
|
||||
Historical test counts are not a substitute for current results.
|
||||
|
||||
For UI changes, run `npm run ui:review --prefix web`; source workflows have `npm run ui:sources --prefix web`. The review scripts live under `web/tools/ui-review/` and write screenshots and reports to the Git-ignored `artifacts/ui-review/` directory. Do not commit one-off screenshots under `docs/`.
|
||||
Running a single test:
|
||||
|
||||
```sh
|
||||
# Server (node:test); add --test-name-pattern="..." to filter
|
||||
cd server && npx tsx --test test/media-api.test.ts
|
||||
|
||||
# Most Web tests use Vitest
|
||||
cd web && npx vitest run test/foo.test.ts
|
||||
# The few files written for node:test (api, asset-url, paginator, window, windowed-toc)
|
||||
cd web && npx tsx --test test/paginator.test.ts
|
||||
```
|
||||
|
||||
The Web package has two test runners: `npm test` runs Vitest, then runs the node:test files above with `tsx --test`; those files are excluded from Vitest in `web/vite.config.ts`. When adding a node:test file, add it to both the `test` script and the Vitest `exclude` list. The Web suite imports server code, so run `npm ci` for both packages first.
|
||||
|
||||
### UI review
|
||||
|
||||
UI reviews render in real Chromium over CDP, then take screenshots and measurements; prepare Chromium as described above. The scripts live under `web/tools/ui-review/` and write to the Git-ignored `artifacts/ui-review/` directory. Do not commit one-off screenshots under `docs/`.
|
||||
|
||||
| Command | What it does | Output |
|
||||
| --- | --- | --- |
|
||||
| `npm run ui:review --prefix web` | Builds, then reviews the app screens | `artifacts/ui-review/react-app/`, result in `verification.json` |
|
||||
| `npm run ui:review:only --prefix web` | Same, reusing the existing `web/dist` (used by CI) | Same |
|
||||
| `npm run ui:review:reader --prefix web` | Reader scenes; does not build, run `npm run build` first | `artifacts/ui-review/reader/` |
|
||||
| `npm run ui:sources --prefix web` | Builds, then runs the source workflow; needs server dependencies installed | `artifacts/ui-review/sources/` |
|
||||
|
||||
Set `UI_REVIEW_DIR` to change the output directory. Run a review and look at the screenshots after layout changes. Reviews do not emulate safe areas; check edge-touching bars with CDP `Emulation.setSafeAreaInsetsOverride`.
|
||||
|
||||
### Icons
|
||||
|
||||
To add an icon, add its path to `web/tools/icons/paths.mjs` (use this project's own English names; `test/icons.test.ts` rejects names borrowed from other icon sets), then run `npm run icons --prefix web`.
|
||||
|
||||
## CI and releases
|
||||
|
||||
- GitHub Actions `.github/workflows/ci.yml` runs on every pull request and on pushes to every branch. Server and Web each run install → typecheck → test → build; a UI review job reuses the Web build and keeps screenshots as artifacts; the Android job runs `check-shell.sh`, verifies the packed Web assets, and builds a debug APK.
|
||||
- `.github/workflows/release.yml` runs only for `v*` tags or a manual run. It runs CI first, then builds the image on native amd64 and arm64 runners and publishes it to Docker Hub `hectorqin/reader-pro`: `x.y.z`, `x.y`, `latest`, and `sha-*` for tags; the branch name and `sha-*` for manual runs. It needs the `DOCKER_USERNAME` and `DOCKER_PASSWORD` repository secrets. `main` is the long-lived development branch; pushing to it does not release.
|
||||
|
||||
## Build and run
|
||||
|
||||
@@ -81,7 +115,7 @@ For UI changes, run `npm run ui:review --prefix web`; source workflows have `npm
|
||||
docker build -f server/Dockerfile -t reader:local .
|
||||
```
|
||||
|
||||
Use the repository root as the build context. The image builds both the Web client and server. Set the Compose image to `reader:local`, or use its commented `build` configuration.
|
||||
Use the repository root as the build context. The image builds both the Web client and server. To deploy from source, run `docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build`, and keep both `-f` options for later management commands.
|
||||
|
||||
### Without a container
|
||||
|
||||
@@ -105,22 +139,33 @@ cd android
|
||||
|
||||
Use `gradlew.bat` from a native Windows terminal. The output is `android/app/build/outputs/apk/debug/app-debug.apk`. Add `-PdefaultServerUrl=http://nas.local:5888` to preset the server address; otherwise users enter it and sign in. Building an APK does not validate reading on a physical device.
|
||||
|
||||
Checks that need no JDK:
|
||||
|
||||
```sh
|
||||
sh android/scripts/check-shell.sh # packages, imports, JS bridge contract, render-mode vocabulary
|
||||
sh android/scripts/verify-web-assets.sh # after build-web-assets.sh: the Web assets will be packed into the APK
|
||||
```
|
||||
|
||||
`check-shell.sh` counts only `@JavascriptInterface` methods as bridge methods and requires each to be declared in `web/src/android-bridge.d.ts` (method syntax; `name?(): T` for optional ones). See [android/README.md (中文)](../android/README.md) for the shell and [android/contract.md (中文)](../android/contract.md) for the contract.
|
||||
|
||||
## Code map
|
||||
|
||||
| Directory | Responsibility |
|
||||
| --- | --- |
|
||||
| `server/src/config`, `db` | Runtime configuration, SQLite schema and access |
|
||||
| `server/src/config`, `db`, `lib` | Runtime configuration, SQLite schema and access, path containment (`lib/paths.ts`) |
|
||||
| `server/src/indexer` | File formats, scanning, identity, and metadata |
|
||||
| `server/src/services`, `http` | Business services, API, authentication, and resources |
|
||||
| `server/src/media` | Media catalog, scans, metadata, and playback |
|
||||
| `web/src/features/media` | Media pages, components, services, themes, and players |
|
||||
| `server/src/sources` | Local library and OPDS integration |
|
||||
| `web/src/app` | Runtime wiring, providers, hash routing, and lazy pages (`app/router/lazy-pages.tsx`) |
|
||||
| `web/src/features` | Pages and components by domain; `features/media` holds media pages, services, and players |
|
||||
| `web/src/shared` | Cross-domain queries, stores, theme, and UI |
|
||||
| `server/src/sources` | Local library, OPDS, and process-based extension plugins |
|
||||
| `web/src/formats` | EPUB, TXT, comics, and other format handling |
|
||||
| `web/src/ui`, `styles` | Shared interactions, imperative reading stage, and reader styles |
|
||||
| `web/src/core`, `store` | Platform capabilities, synchronization, and offline storage |
|
||||
| `android/app/src/main/java/cool/cnb/reader` | Android Activity, WebView, native image and speech bridges |
|
||||
| `android/app/src/main/java/com/htmake/readerpro` | Android Activity, WebView, native image and speech bridges, Media3 playback |
|
||||
|
||||
The application UI uses React 19 and React Router. React Query owns server data and Zustand owns shared client state; the imperative reading stage handles book layout and measurement. Mantine provides base interaction components, Lucide React provides icons, and Shadow DOM isolates book styles. Both the server and Android consume the Web build. See [architecture (中文)](architecture.md) and [UI guidelines (中文)](ui.md) for details.
|
||||
The application UI uses React 19 and React Router (hash routing only). React Query owns server data and Zustand owns shared client state; the imperative reading stage handles book layout and measurement. Base interaction components are the project's own (`web/src/ui/toolkit.tsx`, `sheet.tsx`, `action-menu.tsx`, and others; Mantine has been removed), icons come from Lucide React and the project's own icon font, and Shadow DOM isolates book styles. The Web build has a fixed `assets/client.js` entry and one stylesheet, with rarely used screens split into lazy chunks; the same build is served by the server and bundled into Android. See [architecture (中文)](architecture.md) and [UI guidelines (中文)](ui.md) for details.
|
||||
|
||||
## Contributing workflow
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
## 环境
|
||||
|
||||
- 推荐 Node.js 24,与当前容器和 CI 保持一致;服务端使用内置 `node:sqlite`。
|
||||
- Node.js 22 及以上(`server/package.json` 的 `engines`),推荐 24,与当前容器和 CI 保持一致;服务端使用内置 `node:sqlite`,TypeScript 通过 `tsx` 直接运行。
|
||||
- npm、Git;服务端和 Web 各有独立 `package.json` 与锁文件,仓库根目录没有统一 npm 脚本。
|
||||
- UI 评审需要 Chromium。
|
||||
- 构建 Android 另需 JDK 17+、Android SDK 35、Build Tools 35.0.1;Web 资源构建脚本需要 POSIX shell(Windows 可使用 Git Bash 或 WSL)。
|
||||
@@ -29,7 +29,7 @@ Linux CI 可使用 `npx playwright install --with-deps chromium` 安装所需系
|
||||
|
||||
## 启动开发服务
|
||||
|
||||
在仓库根目录开两个终端。使用测试书库,不要把数据目录放进书库里。
|
||||
在仓库根目录开两个终端。使用测试书库,不要把数据目录放进书库里(`DATA_DIR` 位于 `BOOKS_DIR` 内时服务端拒绝启动)。
|
||||
|
||||
Linux/macOS:
|
||||
|
||||
@@ -69,9 +69,43 @@ npm test --prefix web
|
||||
npm run build --prefix web
|
||||
```
|
||||
|
||||
Web 测试同时包含 Vitest 和 Node 测试。UI 评审使用真实 Chromium;浏览器缺失时按上面的步骤准备环境。不要把历史测试数量当作当前验证结果。
|
||||
不要把历史测试数量当作当前验证结果。
|
||||
|
||||
UI 改动可运行 `npm run ui:review --prefix web`,书源流程可运行 `npm run ui:sources --prefix web`。评审脚本位于 `web/tools/ui-review/`,会把截图和报告写入被 Git 忽略的 `artifacts/ui-review/`;不要把一次运行的截图提交到 `docs/`。
|
||||
运行单个测试:
|
||||
|
||||
```sh
|
||||
# 服务端(node:test),可加 --test-name-pattern="..." 过滤
|
||||
cd server && npx tsx --test test/media-api.test.ts
|
||||
|
||||
# Web 大多数测试使用 Vitest
|
||||
cd web && npx vitest run test/foo.test.ts
|
||||
# 少数为 node:test 编写的文件(api、asset-url、paginator、window、windowed-toc)
|
||||
cd web && npx tsx --test test/paginator.test.ts
|
||||
```
|
||||
|
||||
Web 有两套测试运行器:`npm test` 先跑 Vitest,再用 `tsx --test` 跑上述 node:test 文件;这些文件在 `web/vite.config.ts` 中被 Vitest 排除。新增 node:test 文件时,要同时加入 `test` 脚本和 Vitest 的 `exclude` 列表。Web 测试会引用服务端代码,运行前两端都需要 `npm ci`。
|
||||
|
||||
### UI 评审
|
||||
|
||||
UI 评审在真实 Chromium 中通过 CDP 截图并测量;浏览器缺失时按上面的步骤准备环境。评审脚本位于 `web/tools/ui-review/`,截图和报告写入被 Git 忽略的 `artifacts/ui-review/`;不要把一次运行的截图提交到 `docs/`。
|
||||
|
||||
| 命令 | 内容 | 输出 |
|
||||
| --- | --- | --- |
|
||||
| `npm run ui:review --prefix web` | 先构建,再评审应用各屏 | `artifacts/ui-review/react-app/`,结果在 `verification.json` |
|
||||
| `npm run ui:review:only --prefix web` | 同上,但复用已有 `web/dist`(CI 使用) | 同上 |
|
||||
| `npm run ui:review:reader --prefix web` | 阅读器场景;不自动构建,需先 `npm run build` | `artifacts/ui-review/reader/` |
|
||||
| `npm run ui:sources --prefix web` | 先构建,再走书源流程;需已安装服务端依赖 | `artifacts/ui-review/sources/` |
|
||||
|
||||
可用 `UI_REVIEW_DIR` 改变输出目录。布局改动后运行评审并查看截图。评审不模拟安全区,贴边栏位需另用 CDP `Emulation.setSafeAreaInsetsOverride` 检查。
|
||||
|
||||
### 图标
|
||||
|
||||
新增图标时把路径加入 `web/tools/icons/paths.mjs`(使用本项目自己的英文名,`test/icons.test.ts` 会拒绝借用其他图标集的名称),然后运行 `npm run icons --prefix web`。
|
||||
|
||||
## 持续集成与发布
|
||||
|
||||
- GitHub Actions `.github/workflows/ci.yml`:每个 PR 和每个分支的 push 都会运行。服务端与 Web 分别执行 install → typecheck → test → build;另有 UI 评审(复用 Web 产物,截图作为制品保留),以及 Android 的 `check-shell.sh`、Web 资源打包校验和 debug APK。
|
||||
- `.github/workflows/release.yml`:只在推送 `v*` 标签或手动运行时触发,先跑 CI,再在 amd64 与 arm64 原生 runner 上构建镜像并发布到 Docker Hub `hectorqin/reader-pro`。标签发布 `x.y.z`、`x.y`、`latest` 与 `sha-*`;手动运行发布分支名与 `sha-*`。需要仓库机密 `DOCKER_USERNAME`、`DOCKER_PASSWORD`。`main` 是长期开发分支,push 到 `main` 不会发布。
|
||||
|
||||
## 构建与运行
|
||||
|
||||
@@ -105,22 +139,33 @@ cd android
|
||||
|
||||
Windows 原生终端使用 `gradlew.bat`。产物为 `android/app/build/outputs/apk/debug/app-debug.apk`。可通过 `-PdefaultServerUrl=http://nas.local:5888` 预设地址;未预设时由用户填写服务地址并登录。编译 APK 不等于完成真机阅读验证。
|
||||
|
||||
不需要 JDK 的检查:
|
||||
|
||||
```sh
|
||||
sh android/scripts/check-shell.sh # 包名、导入、JS 桥接契约、渲染模式词表
|
||||
sh android/scripts/verify-web-assets.sh # 在 build-web-assets.sh 之后,确认 Web 资源会被打进 APK
|
||||
```
|
||||
|
||||
`check-shell.sh` 只把带 `@JavascriptInterface` 的方法算作桥接方法,并要求每个都在 `web/src/android-bridge.d.ts` 中声明(方法语法,可选方法写作 `name?(): T`)。外壳说明见 [android/README.md](../android/README.md),契约见 [android/contract.md](../android/contract.md)。
|
||||
|
||||
## 代码导览
|
||||
|
||||
| 目录 | 责任 |
|
||||
| --- | --- |
|
||||
| `server/src/config`、`db` | 运行时配置、SQLite schema 和访问层 |
|
||||
| `server/src/config`、`db`、`lib` | 运行时配置、SQLite schema 和访问层、路径约束(`lib/paths.ts`) |
|
||||
| `server/src/indexer` | 文件格式、扫描、身份与元数据 |
|
||||
| `server/src/services`、`http` | 业务服务、API、鉴权和资源交付 |
|
||||
| `server/src/media` | 影音目录、扫描、资料与播放服务 |
|
||||
| `web/src/features/media` | 影音页面、组件、服务、主题与播放器 |
|
||||
| `server/src/sources` | 本地书库与 OPDS 接入 |
|
||||
| `web/src/app` | 运行时装配、Provider、哈希路由与懒加载页面(`app/router/lazy-pages.tsx`) |
|
||||
| `web/src/features` | 按领域划分的页面与组件;`features/media` 为影音页面、服务与播放器 |
|
||||
| `web/src/shared` | 跨领域的查询、状态、主题与 UI |
|
||||
| `server/src/sources` | 本地书库、OPDS 与进程型扩展插件 |
|
||||
| `web/src/formats` | EPUB、TXT、漫画等格式处理 |
|
||||
| `web/src/ui`、`styles` | 共享交互、命令式阅读舞台和阅读样式 |
|
||||
| `web/src/core`、`store` | 平台能力、同步、离线存储 |
|
||||
| `android/app/src/main/java/cool/cnb/reader` | Android Activity、WebView、原生图片和语音桥接 |
|
||||
| `android/app/src/main/java/com/htmake/readerpro` | Android Activity、WebView、原生图片、语音桥接与 Media3 播放 |
|
||||
|
||||
应用界面使用 React 19 和 React Router,服务端数据使用 React Query,共享客户端状态使用 Zustand;阅读内容布局与测量由命令式阅读舞台管理。基础交互组件使用 Mantine,图标使用 Lucide React,书籍样式通过 Shadow DOM 隔离。Web 产物由服务端和 Android 共同使用。详细取舍见[架构文档](architecture.md)和[界面规范](ui.md)。
|
||||
应用界面使用 React 19 和 React Router(仅哈希路由),服务端数据使用 React Query,共享客户端状态使用 Zustand;阅读内容布局与测量由命令式阅读舞台管理。基础交互组件为项目自有组件(`web/src/ui/toolkit.tsx`、`sheet.tsx`、`action-menu.tsx` 等,Mantine 已移除),图标使用 Lucide React 和项目自有图标字体,书籍样式通过 Shadow DOM 隔离。Web 产物固定入口为 `assets/client.js`、单一样式表,不常用的页面拆为懒加载分块;同一产物由服务端和 Android 共同使用。详细取舍见[架构文档](architecture.md)和[界面规范](ui.md)。
|
||||
|
||||
## 贡献流程
|
||||
|
||||
|
||||
@@ -10,9 +10,11 @@
|
||||
| 路由 | React Router 7 `createHashRouter` | Hash URL、嵌套路由、Outlet、参数、错误边界和导航 |
|
||||
| 服务端状态 | `@tanstack/react-query` | 查询缓存、去重、mutation、失效和预取 |
|
||||
| 客户端共享状态 | Zustand | 认证、设置、同步和播放状态 |
|
||||
| 基础组件 | Mantine | 主题、表单、菜单、通知、加载和管理界面 |
|
||||
| 图标 | `lucide-react` | React 图标组件 |
|
||||
| 构建 | Vite + TypeScript | H5 与 Android WebView 共用的相对资源单 Bundle |
|
||||
| 基础组件 | 项目自有组件(`ui/toolkit.tsx`、`ui/sheet.tsx`、`ui/action-menu.tsx`、`ui/dialog.tsx`、`ui/pager.tsx`) | 按钮、表单行、提示、弹层、「⋯」菜单和分页;Mantine 已移除 |
|
||||
| 全局提示 | `ui/notifications.ts`(Notyf 封装)、`ui/floating-notice.tsx` | 全局消息与单个操作的状态提示 |
|
||||
| 主题 | `shared/theme/` + `styles/theme-tokens.css` | V3 语义颜色令牌;字号、圆角等按角色命名的令牌 |
|
||||
| 图标 | `lucide-react` 与项目图标字体(`ui/icon.tsx`) | 图标组件 |
|
||||
| 构建 | Vite + TypeScript | H5 与 Android WebView 共用:相对资源(`base: './'`)、固定入口 `assets/client.js`、单一样式表,少用的屏幕拆成懒加载块 |
|
||||
|
||||
继续使用 Hash 路由是因为同一份产物同时由 H5 服务和 Android WebView 加载;两个宿主都不能为任意路径提供客户端回退。新的链接应使用 `#/...` 形式。
|
||||
|
||||
@@ -24,7 +26,7 @@
|
||||
- 恢复本地会话、服务地址、离线作用域和同步状态;
|
||||
- 暴露 flush、登录状态、阅读器注册和设置更新等宿主能力。
|
||||
|
||||
`AppProviders` 提供 `RuntimeContext`、`QueryClientProvider`、`MantineProvider` 和全局通知。Provider 生命周期负责启动/停止播放服务、连接会话与同步监听、让 Mantine 跟随主题明暗,并在页面隐藏或离开时刷新进度、离线数据和播放器状态,重新联网时续期会话。
|
||||
`AppProviders` 只提供 `RuntimeContext` 和 `QueryClientProvider`。Provider 生命周期负责启动/停止播放服务、连接会话与同步监听,并在页面隐藏或离开时刷新进度、离线数据和播放器状态,重新联网时续期会话。全局提示不需要 Provider:`ui/notifications.ts` 按需创建唯一的提示宿主。
|
||||
|
||||
## 目录边界
|
||||
|
||||
@@ -32,20 +34,22 @@
|
||||
|
||||
```text
|
||||
web/src/
|
||||
app/ # AppShell、Provider、运行时和 React Router
|
||||
app/ # AppShell、AppNav、Provider、运行时和 React Router(含 lazy-pages)
|
||||
features/
|
||||
account/pages/ # 我的(设置入口)
|
||||
auth/pages/ # 登录
|
||||
home/pages/ # 首页
|
||||
library/pages/ # 书库浏览和文件管理
|
||||
media/ # 影音 API、页面、组件、查询、变更、服务、状态和样式
|
||||
reader/pages/ # 阅读路由页面
|
||||
settings/pages/ # 用户与服务设置
|
||||
search/pages/ # 书籍与影音的全局搜索
|
||||
shelf/pages/,queries/ # 书架
|
||||
sources/hooks/,pages/ # 书源及扩展页面
|
||||
shared/query/,stores/,ui/ # 跨领域查询、状态和 UI 边界
|
||||
sources/components/,hooks/,pages/ # 书源、插件管理及扩展页面
|
||||
shared/query/,stores/,theme/,ui/ # 跨领域查询、状态、主题和交互 hook(如 use-swipe-tabs)
|
||||
api/, core/, formats/, net/ # API、平台、格式和网络基础能力
|
||||
render/ # 分页、PDF、文本、朗读等渲染服务
|
||||
store/ # 离线、出版物、设备设置存储
|
||||
ui/ # 阅读器命令式舞台及可复用交互组件
|
||||
ui/ # 阅读器命令式舞台及可复用组件(toolkit、sheet、action-menu 等)
|
||||
styles/ # 应用和阅读器全局样式
|
||||
```
|
||||
|
||||
@@ -53,11 +57,14 @@ web/src/
|
||||
|
||||
## 路由与兼容
|
||||
|
||||
路由定义集中在 `web/src/app/router/routes.tsx`,根级 `AppShell` 先经过 `AuthBoundary`,认证后渲染页面 Outlet。影音由 `MediaLayout` 提供频道上下文和公共频道入口,具体页面继续按路由拆分;主题由 `AuthenticatedShell` 统一应用。
|
||||
路由定义集中在 `web/src/app/router/routes.tsx`,根级 `AppShell` 先经过 `AuthBoundary`,认证后由 `AuthenticatedShell` 渲染页面 Outlet 和唯一的导航 `AppNav`(手机底部标签栏、宽屏侧栏;阅读器和播放器隐藏导航,二级页面在手机上用返回箭头代替标签栏)。`MediaLayout` 现在只是 `/media` 与 `/me` 两组嵌套路由的 Outlet,主题和导航都由 `AuthenticatedShell` 统一负责。
|
||||
|
||||
阅读器和管理员屏幕(媒体库管理、扫描与刮削、条目管理子页、书源编辑、凭据、扩展页)通过 `web/src/app/router/lazy-pages.tsx` 拆成懒加载块:每个页面包成带 `Suspense` 的同名组件,路由表照常引用。新增少用的屏幕时加到这里,不要在页面中临时 `import()`。
|
||||
|
||||
保留的兼容入口是有意的:
|
||||
|
||||
- `/settings` 重定向到 `/shelf`,兼容旧书架设置深链接;
|
||||
- `/settings` 重定向到 `/shelf`,兼容旧书架设置深链接;用户设置在「我的」(`/me`、`/me/:panel`);
|
||||
- 影音设置页已移除:`/media/:channel/settings` 与 `/media/:channel/settings/:panel` 重定向到「我的」,媒体库管理(`.../settings/libraries`)和扫描与刮削(`.../settings/tasks`)保留;
|
||||
- `/sources/search` 仍打开书源中心;
|
||||
- `/sources/:sourceId/:pageId` 与新的 `/sources/:sourceId/pages/:pageId` 都可打开扩展页;
|
||||
- `/media`、`/media/music` 和 `/media/audiobook` 重定向到对应频道默认页,未知路径回到 `/media/video`。
|
||||
@@ -74,11 +81,13 @@ web/src/
|
||||
|
||||
播放器是应用级资源。`AppProviders` 把 `runtime.player.element` 挂到 `document.body`,调用 `runtime.playback.start()` 建立 `PlaybackService` 对 `MediaPlayer` 的监听;路由切换只卸载控制界面,不销毁播放会话。`AuthenticatedShell` 根据当前 URL 控制播放器可见性,并处理播放器发出的 `open-controls` 事件,将用户带到带有 `item` 和 `part` 查询参数的播放器路由。Provider 卸载时停止监听并移除播放器节点。
|
||||
|
||||
应用主题由 `AuthenticatedShell` 创建 `ThemeController`(`web/src/shared/theme/theme.ts`),作用域来自 `runtime.mediaApi.preferenceScope()`。控制器只在 `<html>` 上写 `data-theme` 与 `data-mode`,组件只读 `web/src/styles/theme-tokens.css` 的 `--color-*` 令牌;响应系统深浅色、同页保存和存储变化,退出登录时清理属性和监听器。阅读器的黑白灰主题只写在阅读器容器上。
|
||||
应用主题由 `AuthenticatedShell` 创建 `ThemeController`(`web/src/shared/theme/theme.ts`),作用域来自 `runtime.mediaApi.preferenceScope()`。控制器只在 `<html>` 上写 `data-theme`(warm、blue、green)与 `data-mode`(light、dark),组件只读 `web/src/styles/theme-tokens.css` 的 `--color-*` 令牌(`--reader-*` 和影音别名映射到它们);响应系统深浅色、同页保存和存储变化,退出登录时清理属性和监听器。阅读器的黑白灰主题只写在阅读器容器上。主题偏好通过 `readThemePreference`、`saveThemePreference`、`resolveAppTheme`(React 中用 `shared/theme/use-theme.ts` 的 `useThemeState`)读写,不直接访问存储。
|
||||
|
||||
## 后续改动约定
|
||||
|
||||
- 新页面放入对应的 `features/<domain>/pages`,通过 `routes.tsx` 接入;
|
||||
- 新页面放入对应的 `features/<domain>/pages`,通过 `routes.tsx` 接入;少用的屏幕经 `lazy-pages.tsx` 懒加载;
|
||||
- 弹层一律用 `ui/sheet.tsx` 的 `Sheet`/`ConfirmSheet`,「⋯」菜单一律用 `ui/action-menu.tsx`,不引入第三方组件库;
|
||||
- 新的颜色、间距、字号、圆角、时长写成令牌,不写字面量(见 [`ui.md`](ui.md) §2);
|
||||
- 领域组件、查询、变更、服务和样式留在各自 feature 内,跨领域依赖经过 `shared` 或 `app` 边界;
|
||||
- 需要长生命周期的资源必须由 Provider 或明确的 service 管理,并在清理阶段释放;
|
||||
- 阅读器正文继续通过 `ReaderScreen` 托管,不把命令式舞台改写为普通 React 子树;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
This guide takes you through Docker deployment, account registration, and opening your first book. To run from source, use the [development guide](development.en.md).
|
||||
|
||||
This guide describes the current Node implementation. CI publishes `main` (development) and short commit tags, not `latest`; availability depends on a successful build. Pin a published commit tag for repeatable deployments. Legacy Java images and `/storage` data are not directly compatible with this version.
|
||||
This guide describes the current Node implementation. Release images are published to Docker Hub as `hectorqin/reader-pro` (amd64 and arm64) with `x.y.z`, `x.y`, `latest`, and `sha-<short>` tags, only when a `v*` version tag is pushed or a release is run manually; `main` is the long-lived development branch and pushing to it does not publish to Docker Hub. The bundled Compose file defaults to `hectorqin/reader-pro:latest`; pin a release version (`READER_IMAGE=hectorqin/reader-pro:x.y.z`) for long-term use. Legacy Java images and `/storage` data are not directly compatible with this version.
|
||||
|
||||
## 1. Prepare your environment
|
||||
|
||||
@@ -17,7 +17,7 @@ The container serves both the API and Web UI. No separate frontend deployment is
|
||||
## 2. Get the deployment configuration
|
||||
|
||||
```sh
|
||||
git clone https://cnb.cool/hectorqin/reader.git
|
||||
git clone https://github.com/hectorqin/reader.git
|
||||
cd reader
|
||||
```
|
||||
|
||||
@@ -41,7 +41,7 @@ For example, use `/srv/books:/books:ro` on Linux, or `"D:/books:/books:ro"` with
|
||||
### Choose read-only or writable books
|
||||
|
||||
- **Browse and read only**: keep `:ro`. Add books through the host or NAS and let reader scan them.
|
||||
- **Manage files in the browser**: use `:rw` and give the server process write permission. Administrators can upload, rename, move, delete, and create directories in the library file manager.
|
||||
- **Manage files in the browser**: use `:rw` and give the server process write permission. Administrators can upload, rename, move, delete, and create directories in the library file manager; members can only upload book files from the library page (an administrator can turn this off under Me → System settings → Service configuration → 书库).
|
||||
|
||||
A read-only book mount still allows shelves, progress, and notes to be saved in `/data`. See [configuration](configuration.en.md) for permissions and mount details.
|
||||
|
||||
@@ -59,7 +59,7 @@ Open `http://localhost:5888`, or `http://<server-ip>:5888` from another device.
|
||||
2. Wait for the initial scan, then open a book from the shelf or library.
|
||||
3. Create accounts for other members. Public registration is disabled by default after the first account.
|
||||
|
||||
To allow later self-registration, set `ALLOW_REGISTRATION: "true"` in the Compose `environment` section and run `docker compose up -d` again.
|
||||
To allow later self-registration, an administrator switches Me → System settings → Registration and invitations (我的 → 系统设置 → 注册与邀请) to open or invite-only registration. `ALLOW_REGISTRATION: "true"` in Compose only seeds the registration policy on the first start; afterwards the setting in the UI wins.
|
||||
|
||||
The health endpoint is `/api/v1/health`. For startup failures, missing books, or write errors, see [troubleshooting](configuration.en.md#troubleshooting).
|
||||
|
||||
@@ -69,7 +69,7 @@ If the image is unavailable, build the checked-out source:
|
||||
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
|
||||
```
|
||||
|
||||
This still requires access to the Node base image and npm dependencies. Keep both `-f` arguments for subsequent management commands. For published images, set `READER_IMAGE=cnb.cool/hectorqin/reader:<published-commit-tag>` in `.env` to pin a version.
|
||||
This still requires access to the Node base image and npm dependencies. Keep both `-f` arguments for subsequent management commands. To pin a release, set `READER_IMAGE=hectorqin/reader-pro:<version>` (for example `0.1.0`) in `.env`.
|
||||
|
||||
### Without Compose
|
||||
|
||||
@@ -81,7 +81,7 @@ docker run -d --name reader --restart unless-stopped \
|
||||
-p 5888:5888 \
|
||||
-v /path/to/your/books:/books:ro \
|
||||
-v "$(pwd)/data:/data" \
|
||||
cnb.cool/hectorqin/reader:main
|
||||
hectorqin/reader-pro:latest
|
||||
```
|
||||
|
||||
On Windows, the Compose setup above avoids shell-specific path and line-continuation syntax.
|
||||
@@ -90,13 +90,13 @@ On Windows, the Compose setup above avoids shell-specific path and line-continua
|
||||
|
||||
### Local books
|
||||
|
||||
Place EPUB, TXT, PDF, CBZ/ZIP, or comic image directories in your mounted library. By default, the server runs scheduled scans every 30 minutes and checks for changes every 60 seconds. Administrators can trigger a scan manually. With a writable mount, uploads are available in the administrator file manager.
|
||||
Place EPUB, TXT, PDF, CBZ/ZIP, or comic image directories in your mounted library. By default, the server checks the library for changes every 60 seconds; administrators can change the interval (0 disables it) under Me → System settings → Service configuration → Scan settings (扫描设置). The server also scans once at startup. With a writable mount, administrators upload from the file manager and members use 上传书籍 (Upload books) at the top of the library page (book formats only; no unpacking, no overwriting).
|
||||
|
||||
The shelf contains readable books; the library lets you browse the disk directory. Removing a book from a shelf is different from deleting its file. See the [user guide (中文)](user-guide.zh-CN.md).
|
||||
|
||||
### OPDS
|
||||
|
||||
As an administrator, open Sources (书源) from the shelf, add an OPDS source, and configure its server URL. If authentication is required, set credentials for your current account. Open the source to browse or search its catalog and acquire books for your shelf.
|
||||
Open Sources (书源) from the shelf. As an administrator, choose Add source (添加来源) on the Source management (书源管理) tab, add an OPDS source, and configure its server URL. If authentication is required, set credentials for your current account. Open the source to browse or search its catalog and acquire books for your shelf.
|
||||
|
||||
## 5. Add media libraries
|
||||
|
||||
@@ -108,7 +108,7 @@ Mount local media separately by adding the directories you need to Compose volum
|
||||
- /path/to/your/audiobooks:/media/audiobook:ro
|
||||
```
|
||||
|
||||
Run `docker compose up -d`, open the video page (`#/media/video`), and use More actions → Media settings → Media libraries. Choose video, music, or audiobooks, set a container path such as `/media/video`, and choose access permissions. Creation starts a scan; inspect Scan and scrape if it fails.
|
||||
Run `docker compose up -d`, then open Me → Media libraries (我的 → 媒体库), or More actions → Media library management (更多操作 → 媒体库管理) on the video page. Choose video, music, or audiobooks, set a container path such as `/media/video`, and choose access permissions. Creation starts a scan; inspect Scan and scrape (扫描与刮削) if it fails.
|
||||
|
||||
When running directly on Windows, use an absolute local path such as `D:\Media\Music`. With Docker Desktop, mount the directory and enter its container path instead.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
本文带你完成 Docker 部署、注册账号和读取第一本书。直接从源码运行请看[开发指南](development.zh-CN.md)。
|
||||
|
||||
本指南对应当前 Node 版工作区。CI 发布 `main`(开发分支)和提交短哈希标签,没有发布 `latest` 的配置;标签可用性以对应构建成功为准。部署后核对版本,长期使用建议固定提交标签。旧 Java 版镜像、`/storage` 数据不与新版直接兼容。
|
||||
本指南对应当前 Node 版工作区。正式版本镜像发布到 Docker Hub `hectorqin/reader-pro`(amd64 与 arm64),标签为 `x.y.z`、`x.y`、`latest` 和 `sha-<短哈希>`,只在推送 `v*` 版本标签或手动触发发布时生成;`main` 是长期开发分支,推送不会发布 Docker Hub 镜像。仓库自带的 Compose 文件默认使用 `hectorqin/reader-pro:latest`;长期使用建议固定正式版本号(`READER_IMAGE=hectorqin/reader-pro:x.y.z`)。旧 Java 版镜像、`/storage` 数据不与新版直接兼容。
|
||||
|
||||
## 1. 准备环境
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
## 2. 获取部署配置
|
||||
|
||||
```sh
|
||||
git clone https://cnb.cool/hectorqin/reader.git
|
||||
git clone https://github.com/hectorqin/reader.git
|
||||
cd reader
|
||||
```
|
||||
|
||||
@@ -41,7 +41,7 @@ volumes:
|
||||
### 选择只读或可写书库
|
||||
|
||||
- **只浏览和阅读**:保留 `:ro`。通过宿主机或 NAS 放入新书,等待扫描。
|
||||
- **在网页管理文件**:改为 `:rw`,并确保服务进程对该目录有写权限。管理员可以在书库文件管理页上传、改名、移动、删除和新建目录。
|
||||
- **在网页管理文件**:改为 `:rw`,并确保服务进程对该目录有写权限。管理员可以在书库文件管理页上传、改名、移动、删除和新建目录;普通成员只能在书库页上传书籍文件(可在「我的 → 系统设置 → 服务配置 → 书库」关闭)。
|
||||
|
||||
只读书库不会妨碍进度、笔记或书架保存,这些数据写入 `/data`。挂载和权限的详细说明见[部署配置](configuration.zh-CN.md)。
|
||||
|
||||
@@ -59,7 +59,7 @@ docker compose logs --tail=100 reader
|
||||
2. 等待初次扫描,在书架或书库中打开一本书。
|
||||
3. 为家人或其他成员创建账号。首个账号创建后默认关闭公开注册。
|
||||
|
||||
如需开放后续注册,在 Compose 的 `environment` 中设置 `ALLOW_REGISTRATION: "true"`,然后执行 `docker compose up -d`。
|
||||
如需开放后续注册,由管理员在「我的 → 系统设置 → 注册与邀请」中改为开放注册或仅邀请码注册。Compose 中的 `ALLOW_REGISTRATION: "true"` 只在首次启动初始化注册策略时生效,之后以页面设置为准。
|
||||
|
||||
健康检查地址为 `/api/v1/health`。若启动失败、目录为空或无法写入,请看[故障排查](configuration.zh-CN.md#故障排查)。
|
||||
|
||||
@@ -69,7 +69,7 @@ docker compose logs --tail=100 reader
|
||||
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
|
||||
```
|
||||
|
||||
此方式仍需能下载 Node 基础镜像和 npm 依赖。后续管理该部署时继续使用同样的两个 `-f` 参数。发布镜像部署可在 `.env` 设置 `READER_IMAGE=cnb.cool/hectorqin/reader:<已发布的提交标签>` 固定版本。
|
||||
此方式仍需能下载 Node 基础镜像和 npm 依赖。后续管理该部署时继续使用同样的两个 `-f` 参数。使用正式版本时,在 `.env` 设置 `READER_IMAGE=hectorqin/reader-pro:<版本号>`(例如 `0.1.0`)固定版本。
|
||||
|
||||
### 不使用 Compose
|
||||
|
||||
@@ -81,7 +81,7 @@ docker run -d --name reader --restart unless-stopped \
|
||||
-p 5888:5888 \
|
||||
-v /path/to/your/books:/books:ro \
|
||||
-v "$(pwd)/data:/data" \
|
||||
cnb.cool/hectorqin/reader:main
|
||||
hectorqin/reader-pro:latest
|
||||
```
|
||||
|
||||
Windows 推荐使用上面的 Compose 配置,避免不同 shell 的路径和换行语法差异。
|
||||
@@ -90,13 +90,13 @@ Windows 推荐使用上面的 Compose 配置,避免不同 shell 的路径和
|
||||
|
||||
### 本地书籍
|
||||
|
||||
把 EPUB、TXT、PDF、CBZ/ZIP 或漫画图片目录放到挂载的书库中。默认每 30 分钟定时扫描,每 60 秒检查变化;管理员也可手动触发扫描。可写挂载下,上传入口位于管理员文件管理页。
|
||||
把 EPUB、TXT、PDF、CBZ/ZIP 或漫画图片目录放到挂载的书库中。默认每 60 秒自动检查书库变化,间隔可在「我的 → 系统设置 → 服务配置 → 扫描设置」调整(0 关闭);服务启动时也会扫描一次。可写挂载下,管理员在文件管理页上传,普通成员在书库页头部的「上传书籍」上传(只收书籍格式,不解包、不覆盖)。
|
||||
|
||||
书架显示可阅读的书籍;书库按磁盘目录浏览。从书架移除书籍与删除磁盘文件是两种不同操作。详见[使用手册](user-guide.zh-CN.md)。
|
||||
|
||||
### OPDS
|
||||
|
||||
管理员从书架的「书源」入口进入书源中心,添加 OPDS 来源并设置服务地址。需要认证时填写当前账号的来源凭据。打开来源后可浏览或搜索目录,获取书籍并加入书架。
|
||||
从书架的「书源」入口进入书源中心,管理员在「书源管理」中点「添加来源」,添加 OPDS 来源并设置服务地址。需要认证时填写当前账号的来源凭据。打开来源后可浏览或搜索目录,获取书籍并加入书架。
|
||||
|
||||
## 5. 添加影音媒体库
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 影音模块架构
|
||||
|
||||
影音覆盖电影、剧集、音乐和有声书,使用独立 React 路由、主题和服务线程。首页进入影视频道,阅读通过书架入口访问;阅读正文引擎由 React 页面托管生命周期。
|
||||
影音覆盖电影、剧集、音乐和有声书,在统一应用外壳(首页、书架、影音、我的)中使用 `#/media/...` 路由,与阅读共用 V3 主题(`shared/theme`);服务端在独立 Worker 线程中运行。首页(`#/`)汇总继续阅读与继续观看/收听,阅读正文引擎由 React 页面托管生命周期。
|
||||
|
||||
## 数据与服务边界
|
||||
|
||||
@@ -20,13 +20,13 @@
|
||||
|
||||
本地库使用 ffprobe 探测标签、编码、时长和章节,并读取同目录 NFO、封面、歌词、字幕。在线资料由 TMDB 和 MusicBrainz 适配器提供;用户审阅候选后确认覆盖,原文件不写回。
|
||||
|
||||
OpenList 使用 fs/list 与 fs/get,只读扫描,按需解析临时下载地址,由 Reader 授权接口代理 Range。令牌不交给前端或下载源;远程内嵌资料暂不探测。具体配置与驱动限制见[OpenList](media-openlist.md)。
|
||||
OpenList 使用 fs/list 与 fs/get,只读扫描,按需解析临时下载地址。播放方式由服务配置「媒体播放」决定:自动(默认)和直连模式在流票据鉴权后以 307 跳转让浏览器直连临时地址,失败或需要时由 Reader 授权接口代理 Range;代理模式始终经服务端转发。令牌不交给前端或下载源;远程内嵌资料暂不探测。具体配置与驱动限制见[OpenList](media-openlist.md)。
|
||||
|
||||
Web 音频使用浏览器媒体能力,视频采用 DPlayer;Android 使用 Media3。播放会话、流票据、进度并发和即时撤权由服务端控制。后台授权与进程恢复见[后台播放](media-background-auth.md)。不提供转码、TVBox/JAR、云盘登录或影音离线下载。
|
||||
|
||||
## 代码与维护入口
|
||||
|
||||
- 服务端:[media 模块](../server/src/media/)、[HTTP 路由](../server/src/http/routes/media.ts)。
|
||||
- 服务端:[media 模块](../server/src/media/)、[HTTP 路由](../server/src/http/routes/media.ts)(在影音 Worker 中注册)、[主线程转发](../server/src/http/routes/media-proxy.ts)。
|
||||
- 前端:[media feature 模块](../web/src/features/media/)、[React 页面路由](../web/src/app/router/routes.tsx)。
|
||||
- [使用说明](media-user-guide.zh-CN.md)、[导航约定](media-navigation.md)、[验证方式](media-validation.md)、[性能测量](media-catalog-performance.md)。
|
||||
- [冻结的 v2 原型](prototypes/media/v2-review/index.html)是布局参考;实际功能、地址和主题生命周期以源码及当前说明为准。浏览器评审截图和报告统一写入被忽略的 `artifacts/media`,不再提交到 `docs/`。
|
||||
- [冻结的 v2 原型](prototypes/media/v2-review/index.html)是布局参考;实际功能、地址和主题生命周期以源码及当前说明为准。当前设计基准是 [V3 统一原型](prototypes/Reader%20V3%20·%20统一设计系统与完整原型.html)。浏览器评审截图和报告写入被忽略的 `artifacts/media`(影音评审工具)或 `artifacts/ui-review`(`npm run ui:review`),不提交到 `docs/`。
|
||||
+13
-10
@@ -1,10 +1,12 @@
|
||||
# 影音导航与路由
|
||||
|
||||
## 顶部栏
|
||||
## 应用导航与频道切换
|
||||
|
||||
手机首页点击频道标题或切换图标显示阅读、影视、音乐、有声书菜单;无底部频道 tab。点击外部、选择频道或 Escape 关闭菜单。桌面首页使用顶部频道链接,下方为频道内分类。
|
||||
全应用只有一套导航:手机为底部标签栏,701px 起为左侧边栏,包含首页(#/)、书架(#/shelf)、影音(#/media/video)和我的(#/me)。宽屏侧边栏在「影音」下列出影视、音乐、有声书三个频道;手机在频道分类页顶部用影视/音乐/有声书三段切换频道(旧的频道菜单和频道导轨已移除)。
|
||||
|
||||
二级页显示返回、页面标题和当前页面的操作,不显示频道切换。设置、收藏、资源详情、播放器各自拥有路由。媒体库筛选位于列表工具栏左侧,版本、排序等选择器使用统一主题弹层。阅读界面和书架设置维持原有结构。
|
||||
频道下方是分类标签:影视(首页、电影、剧集)、音乐(专辑、歌手、曲目)、有声书(有声书、演播者)。手机上左右滑动切换相邻分类,越过频道最后一个分类即进入下一频道;同频道内保留 library 和 sort 参数,跨频道重置。频道尚无媒体库时滑动按整个频道切换。分类页右上角 ⋯ 菜单提供收藏、历史、队列和媒体库管理,工具栏提供文件夹入口。
|
||||
|
||||
二级页显示返回、页面标题和当前页面的操作,不显示频道切换;手机上二级页以自己的返回栏替代底部标签栏,宽屏侧边栏保留。播放器与阅读页隐藏应用导航。设置、收藏、资源详情、播放器各自拥有路由。媒体库筛选位于列表工具栏左侧,版本、排序等选择器使用统一主题弹层。阅读界面和书架设置维持原有结构。
|
||||
|
||||
## 路由表
|
||||
|
||||
@@ -16,21 +18,22 @@ channel 表示 video、music 或 audiobook;itemId、part、libraryId 均为真
|
||||
| 影视分类 | #/media/video/movies、#/media/video/series |
|
||||
| 音乐分类 | #/media/music/albums、#/media/music/artists、#/media/music/tracks |
|
||||
| 有声书分类 | #/media/audiobook/books、#/media/audiobook/narrators |
|
||||
| 收藏 | #/media/favorites,scope 和 offset 保存筛选、分页 |
|
||||
| 首页、我的 | #/;#/me |
|
||||
| 收藏 | #/media/favorites,scope 和 offset 保存筛选、分页;#/media/:channel/favorites 预选该频道 |
|
||||
| 搜索 | #/media/search?q=…&scope=… |
|
||||
| 历史、待播队列 | #/media/:channel/history;#/media/queue |
|
||||
| 设置 | #/media/:channel/settings,以及 theme、browse、playback、plugins、account 子页 |
|
||||
| 历史、待播队列 | #/media/:channel/history;#/media/queue、#/media/:channel/queue |
|
||||
| 偏好设置(我的) | #/me/theme、#/me/browse、#/me/playback、#/me/account |
|
||||
| 媒体库管理 | #/media/:channel/settings/libraries |
|
||||
| 新建、编辑、权限 | #/media/:channel/settings/libraries/new、#/media/:channel/settings/libraries/:libraryId/edit、#/media/:channel/settings/libraries/:libraryId/permissions |
|
||||
| 扫描与刮削 | #/media/:channel/settings/tasks |
|
||||
| 扫描与刮削 | #/media/:channel/settings/tasks?tab=scan\|ai\|scrape&library=… |
|
||||
| 作品详情 | #/media/:channel/items/:itemId |
|
||||
| 资料、匹配、章节 | #/media/:channel/items/:itemId/metadata、match、chapters |
|
||||
| 版本、结构管理 | #/media/:channel/items/:itemId/editions/:editionId;#/media/:channel/items/:itemId/structure |
|
||||
| 播放 | #/media/:channel/player?item=…&part=… |
|
||||
| 歌词、播放队列、播放章节 | #/media/:channel/player/lyrics、#/media/:channel/player/queue、#/media/:channel/player/chapters |
|
||||
| 文件夹、文件 | #/media/:channel/folders/:libraryId?path=…&offset=…;文件继续使用该路由并追加 asset=… |
|
||||
| 文件夹、文件 | #/media/:channel/folders/:libraryId?path=…&offset=…(libraryId 可省略);文件继续使用该路由并追加 asset=… |
|
||||
| 演播者、演播作品 | #/media/audiobook/narrators/:narrator,以及 /media/audiobook/narrators/:narrator/works/:workId |
|
||||
|
||||
路由表之外保留少量旧深链接兼容:`/media`、`/media/music` 和 `/media/audiobook` 会进入对应频道默认页;未知媒体路径回到 `/media/video`。书源与系统旧入口的兼容规则见[React 架构说明](frontend-react-migration.md)。返回按实际浏览器历史和保存的来源状态处理;不为每个页面生成 return 查询参数。播放器的 item、part 查询参数保存作品及播放部分,可刷新后重新校验授权并恢复页面。
|
||||
路由表之外保留少量旧深链接兼容:`/media`、`/media/music` 和 `/media/audiobook` 会进入对应频道默认页;已移除的影音设置 `/media/:channel/settings` 及其 `/:panel` 子页跳转到 `/me` 或对应的 `/me/:panel`;未知路径回到 `/media/video`。书源与系统旧入口的兼容规则见[React 架构说明](frontend-react-migration.md)。返回按实际浏览器历史和保存的来源状态处理;不为每个页面生成 return 查询参数。播放器的 item、part 查询参数保存作品及播放部分,可刷新后重新校验授权并恢复页面。
|
||||
|
||||
协议实现以[React 页面路由](../web/src/app/router/routes.tsx)及[影音 feature](../web/src/features/media/)为准。冻结 v2 原型同步这些页面路径;其中作品 ID、查询参数和状态参数仍只用于设计演示。
|
||||
协议实现以[React 页面路由](../web/src/app/router/routes.tsx)及[影音 feature](../web/src/features/media/)为准。冻结 v2 原型仍使用已移除的 `/media/:channel/settings/*` 设置页路径,作品 ID、查询参数和状态参数只用于设计演示,不作为路由依据。
|
||||
@@ -1,6 +1,6 @@
|
||||
# 影音使用说明
|
||||
|
||||
影音支持电影、剧集、音乐和有声书。打开站点默认进入影视页(`#/media/video`);也可从书架右上角的「影音」入口进入或直接访问影音链接。通过频道导航切换频道,「阅读」返回书架,阅读界面与书架设置保持原位。
|
||||
影音支持电影、剧集、音乐和有声书。打开站点进入首页(`#/`),汇总继续阅读、继续观看/收听与书架;应用导航(手机为底部标签栏,宽屏为左侧边栏)中的「影音」进入影视频道(`#/media/video`),「书架」返回阅读。手机在频道页顶部用影视/音乐/有声书分段切换频道,并可左右滑动切换分类;宽屏在侧边栏「影音」下切换频道。导航细节见[导航约定](media-navigation.md)。
|
||||
|
||||
## 新建与管理媒体库
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
扫描只读源文件,不移动、改名或写回标签。目录或远端读取失败会保留已发布目录和资料。缺失文件可在详情查看状态;恢复文件后重扫。不同播放版本需要明确选择,不能按文件名推断为同一版。
|
||||
|
||||
媒体库管理页的每个媒体库卡片显示最近一次扫描:完成时间、进行中的进度(已处理百分比)和“取消”,或失败原因与“重试”“查看详情”。右侧“扫描与元数据”是全部媒体库共用的四个开关,也出现在系统设置的“扫描设置”中:
|
||||
媒体库管理页顶部有“扫描与刮削”和“新建媒体库”,列表显示全部媒体库(与从哪个频道进入无关)。每个媒体库卡片显示缺失文件数和最近一次扫描:完成时间、进行中的进度(已处理百分比)和“取消”,或失败原因与“重试”“查看详情”;从未扫描的库显示“开始扫描”。卡片的 ⋯ 菜单提供编辑媒体库、访问权限和扫描记录。“扫描与元数据”是全部媒体库共用的四个开关,也出现在「我的 → 系统设置 → 服务配置」的“扫描设置”中:
|
||||
|
||||
- 自动扫描新文件(默认开):每小时检查一次所有媒体库,未变化的文件沿用上次的技术信息。
|
||||
- 刮削封面与简介(默认关):扫描完成后,用已配置的 TMDB 或 MusicBrainz 为还没有在线资料、也从未刮削过的作品发起一次批量刮削;未匹配的作品不会每次重试。
|
||||
@@ -44,26 +44,26 @@
|
||||
|
||||
## 按目录配置识别与重新识别
|
||||
|
||||
管理员进入影视「文件夹」,在目标目录点击「识别规则与预览」。本地和 OpenList 库使用同一套规则:
|
||||
管理员在影视频道工具栏点「打开文件夹」,进入目标目录后从右上角 ⋯ 菜单选择「识别规则与预览」(仅影视库)。本地和 OpenList 库使用同一套规则:
|
||||
|
||||
- **继承上级**:删除当前目录的独立规则,使用最近的上级规则。
|
||||
- **自动识别**:显式恢复自动判断,也可覆盖上级“忽略”规则。
|
||||
- **电影目录**:强制按电影处理。
|
||||
- **剧集目录**:指定剧名、可选默认季号;文件中明确的季号优先于默认季号。
|
||||
- **指定季目录**:明确当前目录的季号,可继承上级剧目录的名称。
|
||||
- **剧集目录**:指定剧名、可选默认季/版本名称;文件中明确的季号优先于默认值。
|
||||
- **指定季目录**:必须填写当前目录的季/版本名称,可以是季号,也可以是“特别版”“4K版”“OVA”这类命名分组;剧名可继承上级剧目录。
|
||||
- **忽略目录**:跳过新文件的识别,已有作品保留;仍存在的文件不会仅因被忽略而标记缺失。
|
||||
|
||||
规则包含子目录,最近的独立规则优先。可选年份覆盖自动年份;“移除文件名开头的排列编号”适合 001. 片名 等命名,默认关闭,启用后务必核对年份片名。目录规则不提供 TMDB ID 强制绑定,在线匹配继续通过作品的元数据匹配入口确认。
|
||||
规则包含子目录,最近的独立规则优先。除忽略外,还可填写“目录内文件名解析正则”,对当前目录及子目录的所有文件统一应用,支持 name、year、season、episode、order、edition 等具名组。可选年份覆盖自动年份;“移除文件名开头的排列编号”适合 001. 片名 等命名,默认关闭,启用后务必核对年份片名。目录规则不提供 TMDB ID 强制绑定,在线匹配继续通过作品的元数据匹配入口确认。
|
||||
|
||||
保存规则只影响后续扫描。已有作品通过“保存并预览重新识别”调整:单次最多 500 个可用资源,超过时进入子目录分批操作;低把握结果和多个文件合为同一集默认不勾选。人工整理、人工资料、已确认在线资料、共享或已有多版本作品保留,目标剧集已人工维护时同样不自动改动。
|
||||
|
||||
确认应用后不改源文件,保留资源、版本、播放片段 ID、播放进度和队列。同集版本合并时收藏合并保留。普通重扫不直接改变已有作品归属;预览有效期 15 分钟,规则或目录数据库发生变化需重新预览。应用使用预览时读取的 NFO 快照;若期间编辑过 NFO,请重新生成预览。扫描或批量刮削期间不可保存规则和应用结果。
|
||||
|
||||
识别采用可解释规则与目录/NFO 证据,当前不内置模型。复杂命名可先用目录规则校正,再通过在线候选人工确认资料。
|
||||
扫描识别采用可解释规则与目录/NFO 证据,不调用模型。复杂命名可先用目录规则校正,再通过在线候选人工确认资料。「扫描与刮削」中的 AI 扫描需管理员在服务配置的「AI 功能」中启用,按批次作为可追踪任务运行并记录模型给出的分类结果,供查看;它不会改变已有作品的识别结果。
|
||||
|
||||
## 按目录清理失效资源
|
||||
|
||||
管理员打开频道的「文件夹」,选择媒体库并进入目标目录,点击「清理失效资源」。范围包含当前目录及所有子目录;在库内根目录操作则覆盖整个库。
|
||||
管理员打开频道的「文件夹」,选择媒体库并进入目标目录,从右上角 ⋯ 菜单选择「清理失效资源」。范围包含当前目录及所有子目录;在库内根目录操作则覆盖整个库。
|
||||
|
||||
先成功完成媒体库扫描,再预览清理数量。仅清理扫描已标记缺失的文件记录,不处理仅因编码不支持、探测失败、鉴权或网络错误而暂时无法播放的资源。扫描或批量刮削中不能清理;最近扫描失败或取消时需先重扫成功。
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
|
||||
本地库优先读取文件标签、NFO 和同目录封面;ffprobe 用于编码、时长及章节探测,ffmpeg 用于部分内嵌资源提取,两者不等于转码功能。OpenList 支持外置 NFO、封面、歌词和字幕,暂不读取远端内嵌标签、封面及章节。
|
||||
|
||||
管理员从频道导航切换到「阅读」进入书架,打开右上角「系统设置 → 服务配置」,在 TMDB 和 MusicBrainz 分类中配置令牌、API Key 或应用标识,保存后生效。管理员在作品菜单中匹配资料,审阅候选后确认;也可从「我的 → 扫描与刮削」发起批量任务并重试未完成项。来源网络、凭据或封面下载失败会单独提示。
|
||||
管理员打开「我的 → 系统设置 → 服务配置」,在 TMDB 和 MusicBrainz 分类中配置令牌、API Key 或应用标识,保存后生效。管理员在作品菜单中匹配资料,审阅候选后确认;也可从「我的 → 扫描与刮削」的“刮削”标签发起批量任务并重试未完成项。来源网络、凭据或封面下载失败会单独提示。
|
||||
|
||||
详情的资源信息菜单可查看文件与技术信息、管理版本。手工资料、作品归属、版本拆分或合并会在重扫时保留,不修改原文件。移动版本不转移收藏,也不复制原作品的人工资料;更改剧集、季和单集关系后需重新确认受影响的在线匹配。
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
|
||||
## 播放与恢复
|
||||
|
||||
视频由 DPlayer 提供内置控件;音频页面提供封面、进度、队列、歌词、倍速和睡眠定时。关闭迷你播放器停止当前播放。章节和曲目按当前版本的真实顺序处理,失效资源不会悄悄切到其他版本。
|
||||
视频由 DPlayer 提供内置控件,播放页工具栏另有选集、字幕与音轨(字幕、音轨、版本)、全屏(手机上尝试锁定横屏)和更多选项;音频页面提供封面、进度、队列、歌词、倍速、睡眠定时和播放模式(顺序播放、随机、单曲循环;播放器不支持时按钮置灰)。关闭迷你播放器停止当前播放。章节和曲目按当前版本的真实顺序处理,失效资源不会悄悄切到其他版本。
|
||||
|
||||
播放器地址包含作品 ID 和播放部分 ID,刷新后重新读取会话与授权。服务器保存播放进度;权限被撤销、资源失效或会话冲突时需要按页面提示处理。浏览器自动播放限制可能要求再次点击播放。
|
||||
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
|
||||
## 自动测试与构建
|
||||
|
||||
分别在 web 和 server 目录执行 npm test、npm run build。依赖 ffmpeg/ffprobe 的真实媒体测试需显式配置相应工具;跳过的测试不计为通过。CI 配置见[.cnb.yml](../.cnb.yml)。
|
||||
分别在 web 和 server 目录执行 npm run typecheck、npm test、npm run build。依赖 ffmpeg/ffprobe 的真实媒体测试需显式配置相应工具;跳过的测试不计为通过。CI 配置见 [GitHub Actions](../.github/workflows/ci.yml)。
|
||||
|
||||
## 浏览器与原型
|
||||
|
||||
先构建 web,再从 web 目录运行:
|
||||
|
||||
- `npm run ui:review:only`:启动真实服务端媒体夹具,使用生产 React bundle 在 390/1120px 检查书架、书库、影音频道、详情、收藏、播放器、设置和权限页面;同时覆盖搜索、收藏筛选分页、收藏写回、播放会话恢复、媒体库增改和普通用户权限边界。报告写入忽略的 `artifacts/ui-review/react-app/verification.json`,以报告中的 `passed`、`screenshots`、页面错误和横向溢出结果为准,不把历史截图数量写入文档。
|
||||
- `npm run ui:review:only`(`npm run ui:review` 会先构建再运行):启动真实服务端媒体夹具,使用生产 React bundle 在 390/1120px 检查书架、书库、我的、影音频道、详情、收藏、播放器、设置和权限页面;同时覆盖搜索、收藏筛选分页、收藏写回、播放会话恢复、媒体库增改和普通用户权限边界。报告写入忽略的 `artifacts/ui-review/react-app/verification.json`,以报告中的 `passed`、`screenshots`、页面错误和横向溢出结果为准,不把历史截图数量写入文档。
|
||||
|
||||
- node tools/media-desktop-openlist-review.cjs:桌面分类、选择器、收藏、OpenList 表单及视口边界。
|
||||
- node tools/media-openlist-e2e.cjs:生产页面、真实 Reader 服务、临时数据库,以及本机模拟 OpenList 的建库、扫描、播放和凭据维护闭环。
|
||||
@@ -18,7 +18,7 @@
|
||||
- node tools/media-v2-review.cjs:读取 MEDIA_REVIEW_SAMPLE_PACK 指定的[验收媒体包](media-acceptance-pack.md),进行较完整页面对照。
|
||||
- node tools/media-playback-review.cjs:实际音视频播放、刷新恢复、歌词队列切换与视频控件检查。
|
||||
|
||||
通过 PROTOTYPE_CHROMIUM 指定 Chromium。生成结果写入忽略的 artifacts/media 目录,截图不自动入库。提交中只保留明确选定的最新参考资料;本轮参考见[原型入口](prototypes/media/README.md)。
|
||||
上述 node tools/media-*.cjs 工具通过 PROTOTYPE_CHROMIUM 指定 Chromium,`ui:review` 使用 CHROME_PATH。生成结果写入忽略的 artifacts/media 目录,截图不自动入库。提交中只保留明确选定的最新参考资料;本轮参考见[原型入口](prototypes/media/README.md)。
|
||||
|
||||
原型自身检查:node docs/prototypes/media/v2-review/verify.cjs(从仓库根目录)。原型检查、布局断言和生产功能检查需要分别解释,截图数量不代表逐张视觉验收。
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
## 在其他阅读器中打开 Reader 书架
|
||||
|
||||
1. 登录 Reader,打开“书架设置 → 连接外部阅读器”。
|
||||
1. 登录 Reader,打开“书架设置 → 连接外部阅读器”(书架页右上角的书架设置,或「我的 → 书架显示」)。
|
||||
2. 填写客户端名称并创建 OPDS 凭据。复制目录地址、OPDS 用户名和密码到支持 OPDS 1.2 / HTTP Basic 的阅读器。
|
||||
3. 密码只显示一次,有效期一年。丢失后撤销并创建新的;不使用 Reader 登录密码。
|
||||
|
||||
目录地址通常为 `https://你的域名/opds`。反代含路径前缀时,将 `PUBLIC_URL` 配置为完整外部地址,并确保反代转发 OPDS 路由与 Authorization 头。对外部署须使用 HTTPS。
|
||||
目录地址通常为 `https://你的域名/opds`。反代含路径前缀时,在「我的 → 系统设置 → 服务配置 → 登录与服务访问」将对外服务地址设为完整外部地址(旧 `PUBLIC_URL` 环境变量只在首次初始化时导入),并确保反代转发 OPDS 路由与 Authorization 头。对外部署须使用 HTTPS。
|
||||
|
||||
首版提供当前账号未隐藏书架中的文件书籍、搜索、每页 50 本、封面与支持 Range 的原文件下载。章节书和目录漫画暂不发布。目录和下载请求都重新验证客户端凭据及账号状态,并设为 private/no-store。撤销或禁用账号后,新请求立即失效;已经下载到外部设备的文件无法远程删除。
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
## 上传一致性备份到 WebDAV
|
||||
|
||||
管理员可从「系统设置 → 服务配置 → WebDAV 备份」或 CLI 上传已完成的备份目录;不直接同步运行中的 DATA_DIR,也不是 Legado 进度互通。先按 [备份指南](backup.zh-CN.md) 停止全部相关服务进程、创建并验证备份。上传阶段可重新启动 Reader,备份目录保持不变。
|
||||
管理员可从「我的 → 系统设置 → 服务配置 → WebDAV 备份」或 CLI 上传已完成的备份目录;不直接同步运行中的 DATA_DIR,也不是 Legado 进度互通。先按 [备份指南](backup.zh-CN.md) 停止全部相关服务进程、创建并验证备份。上传阶段可重新启动 Reader,备份目录保持不变。
|
||||
|
||||
在编译后的服务端目录运行:
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
1. 进度可靠性:后台/隐藏事件经过 App.flush,先等待阅读器待保存位置和本地写队列,再触发同步;打开书籍比较本地和服务端时间,保留较新的记录。服务端查询最长等待 5 秒,不可达时回退本地。存储写入错误向调用者报告,后续写入可重试,阅读器显示保存失败。
|
||||
2. 数据保护:服务端离线 backup/verify/restore 命令,完整 DATA_DIR 文件校验、SQLite 完整性检查、恢复到新目录、拒绝覆盖与路径重叠。未完成目录和备份目录不能直接启动服务。操作说明见 [备份恢复](backup.zh-CN.md)。
|
||||
3. 部署接入:按当前 CI 标签规则把 latest 改为 main,可通过 READER_IMAGE 固定版本;添加源码构建 Compose 覆盖文件;限制容器日志为 3 × 10 MiB;补充新旧版数据兼容范围。
|
||||
3. 部署接入:按当前 CI 标签规则把 latest 改为 main,可通过 READER_IMAGE 固定版本(后注:此后新增 GitHub Actions `release.yml`,`v*` 标签会向 Docker Hub `hectorqin/reader-pro` 发布 `latest` 等标签,见[开发指南](development.zh-CN.md));添加源码构建 Compose 覆盖文件;限制容器日志为 3 × 10 MiB;补充新旧版数据兼容范围。
|
||||
|
||||
## 验证方式
|
||||
|
||||
|
||||
@@ -46,6 +46,8 @@ Reader V3 内置 4 个主题,每个主题含浅色和深色两套取值,共
|
||||
|
||||
新增 brand-text 和 ch-\*-text 的原因:浅色主题的 brand 和频道色已改为柔和的浅色,直接当文字用对比度不够,所以文字和图标改用更深的同色相取值。
|
||||
|
||||
危险色(第 4 节第 6 条的 danger)不属于主题:实现里是 `--color-danger` 和 `--color-danger-soft`,只分浅色和深色两套取值,不随主题变(`web/src/styles/theme-tokens.css`)。
|
||||
|
||||
## 3. 主题取值
|
||||
|
||||
### 3.1 暖陶
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# 影音设计参考
|
||||
|
||||
本目录只保留当前仍使用的设计基准。早期原型、反复覆盖的截图、失效的构建哈希和逐轮进度记录已移出仓库。
|
||||
本目录只保留影音的历史设计基准。早期原型、反复覆盖的截图、失效的构建哈希和逐轮进度记录已移出仓库。
|
||||
|
||||
- [冻结 v2 高保真原型](v2-review/index.html):已采用的布局基准,包含桌面和手机、主题及状态。原型中的作品 ID、查询参数和状态参数是演示数据;页面路径按当前 React 路由与 DPlayer 播放器同步。
|
||||
- 当前设计基准是上一级的 [Reader V3 统一原型](../Reader%20V3%20·%20统一设计系统与完整原型.html) 与 [V3 主题语义令牌](../Reader%20V3%20主题语义令牌.md):统一导航(首页、书架、影音、我的)、手机频道分段切换、我的作为设置中心、媒体库管理与扫描与元数据等页面以它为准。
|
||||
- [冻结 v2 高保真原型](v2-review/index.html):早期采用的影音布局参考,包含桌面和手机、主题及状态。原型中的作品 ID、查询参数和状态参数是演示数据;其中的 `/media/:channel/settings/*` 设置页和频道菜单已被 V3 导航与「我的」取代,路由以[导航说明](../../media-navigation.md)为准。
|
||||
|
||||
后续验收结果生成到被 Git 忽略的 `artifacts/media`,需要长期保留的截图应人工挑选;不要提交整批重复截图。运行方式见[验证说明](../../media-validation.md)。
|
||||
@@ -1,6 +1,6 @@
|
||||
# 影音 v2 设计基准
|
||||
|
||||
[打开高保真交互原型](index.html)。这是已采用的布局参考,不再是待确认提案。单文件包含 42 个页面和状态、手机/桌面尺寸与六种主题,可直接在浏览器打开;页面路径按当前 React 路由同步。
|
||||
[打开高保真交互原型](index.html)。这是冻结的早期布局参考,已由 [Reader V3 统一原型](../../Reader%20V3%20·%20统一设计系统与完整原型.html) 取代为当前设计基准。单文件包含 42 个页面和状态、手机/桌面尺寸与六种主题,可直接在浏览器打开;其中 `/media/:channel/settings` 及 theme、browse、playback、plugins、account 子页和频道菜单已不再存在(设置改在 `#/me`),页面路径以[导航说明](../../../media-navigation.md)为准。
|
||||
|
||||
作品、封面、进度、候选和账号均为示例;不调用真实服务,不播放媒体,不执行文件操作。冻结文件保留评审时的示例内容;作品详情使用 `/media/:channel/items/:itemId`,播放器使用 `/media/:channel/player` 及其歌词、队列、章节子面板,收藏等公共入口以[导航说明](../../../media-navigation.md)为准。视频控件由 DPlayer 提供,原型只表达布局和交互层级。
|
||||
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
- 使用 HTTPS 访问;本机 `localhost` / `127.0.0.1` 可使用 HTTP。通过局域网 IP 的普通 HTTP 访问仍能在线阅读,但浏览器不会启用 PWA 离线缓存。
|
||||
- Chrome / Edge:通过地址栏安装图标或浏览器菜单中的安装应用功能安装。
|
||||
- iPhone / iPad:在 Safari 中选择“分享 → 添加到主屏幕”。
|
||||
- 首次联网打开后,等待页面资源缓存完成,再离线使用。书籍正文需要在书架中提前下载;页面缓存不会自动下载整个书库。
|
||||
- 首次联网打开后,等待页面资源缓存完成,再离线使用。书籍正文需要在阅读器「工具 → 离线缓存」中提前下载;页面缓存不会自动下载整个书库。
|
||||
- 离线时保留原有登录状态即可打开已缓存书架和已下载书籍。首次登录、未下载内容和需要服务器的功能仍需联网。
|
||||
- 新版本会在访问、回到页面、恢复联网时检查,持续打开的页面每小时检查一次。下载完成后显示“立即更新”,点击会先保存本地阅读进度,再启用新版并刷新当前页面,无需重启 Chrome 或关闭其他窗口。保存失败时保留页面并允许重试。其他已打开的页面不会被强制刷新。
|
||||
- 首次从没有更新提示的旧版本升级,仍需关闭该站点的所有页面及独立应用窗口后重新打开一次,之后即可使用“立即更新”。
|
||||
|
||||
+69
-51
@@ -18,7 +18,8 @@
|
||||
|
||||
## 2. 设计令牌
|
||||
|
||||
全部定义在 `web/src/styles/reader.css` 的 `:root` 里。**界面层不应出现字面量**。
|
||||
间距、字号、动效、焦点环、圆角、安全区定义在 `web/src/styles/reader.css` 的 `:root` 里;
|
||||
颜色令牌在 `web/src/styles/theme-tokens.css`(§6)。**界面层不应出现字面量**。
|
||||
|
||||
### 2.1 间距:四档
|
||||
|
||||
@@ -179,7 +180,7 @@ Android 壳在原生层处理系统栏(`MainActivity.applyInsets`),WebView
|
||||
`SwitchRow` 用按钮而不是复选框:整行是 44px 的命中区,复选框会把命中区缩到
|
||||
16px 的方块上,拇指按不中。
|
||||
|
||||
底线是 28px,由 `npm run ui:review` 逐屏量过(§8):小于这个数的控件在
|
||||
底线是 28px,由 `npm run ui:review` 逐屏量过(§7):小于这个数的控件在
|
||||
拇指上不是「偏小」,而是需要瞄准。
|
||||
|
||||
在 `(pointer: coarse)` 设备上,外观必须小的控件用透明的 `::before` 把命中区扩到
|
||||
@@ -197,8 +198,9 @@ Android 壳在原生层处理系统栏(`MainActivity.applyInsets`),WebView
|
||||
一条是挂载目录本身。所以它们是两个路由:
|
||||
|
||||
```
|
||||
#/shelf[/<页>] 书架
|
||||
#/library[/files][/<路径>][/<页>][?q=<搜索>] 书库(两半,见 §3.4.1)
|
||||
#/shelf[?page=<页>&sort=…&q=…] 书架
|
||||
#/library[?path=<路径>&page=<页>&q=<搜索>] 书库浏览页(见 §3.4.1)
|
||||
#/library/files[?path=<路径>&page=<页>] 书库文件管理页
|
||||
```
|
||||
|
||||
做成同一个屏幕的两个标签只会更省事一点点,而会让三件事同时变坏:
|
||||
@@ -206,7 +208,7 @@ Android 壳在原生层处理系统栏(`MainActivity.applyInsets`),WebView
|
||||
- **返回键。** 标签切换会进同一段历史轨迹,于是 Back 变成「退回上一个标签」而不是
|
||||
离开这个屏幕。书库的面包屑本来是「走进去的文件夹用面包屑退,不用返回键退」,
|
||||
加一个标签进去,Back 就得同时扮演两个角色。
|
||||
- **链接。** `#/library/科幻` 是能给别人的;「书架的第 3 页」也是(`#/shelf/3`)。
|
||||
- **链接。** `#/library?path=科幻` 是能给别人的;「书架的第 3 页」也是(`#/shelf?page=3`)。
|
||||
- **一屏一个标题。** 参照稿里两个屏幕各有自己的标题与右侧入口,而标签栏要占一行。
|
||||
|
||||
#### 3.4.1 书库自己是两个屏幕(不是一个屏幕的两个页面)
|
||||
@@ -226,14 +228,14 @@ Android 壳在原生层处理系统栏(`MainActivity.applyInsets`),WebView
|
||||
| 一条是什么 | 一本书(书名、封面、作者) | 一个文件(文件名、大小、扫描标记) |
|
||||
| 布局 | 商城:搜索框在最上,封面网格铺满窗口 | 清点:面包屑 + 文件行 + 批量条 |
|
||||
| 一页多少 | 60 本 | 200 条 |
|
||||
| 谁能用 | 任何登录用户 | 只有管理员 |
|
||||
| 谁能用 | 任何登录用户(成员可在这里上传书籍) | 只有管理员 |
|
||||
|
||||
做成一个屏幕的两个页面会同时逼出三句假话:
|
||||
|
||||
- **一个头部服务两个列表。** 浏览页的头部要有搜索框,文件页的头部要有面包屑、
|
||||
上传和新建;共用之后两边都得让步,让步的产物就是上一版里那个既不像商城也不像
|
||||
文件管理器的头部。
|
||||
- **一条可达性规则。** `#/library/科幻` 打开的是封面,而文件管理器离它只有一次
|
||||
- **一条可达性规则。** `#/library?path=科幻` 打开的是封面,而文件管理器离它只有一次
|
||||
「页签」点击——也就是**每一个能发给别人的链接**都通向一个可以删书的页面。
|
||||
- **一个页码。** 两边分页的列表和每页条数都不同,切换必须重置页码,于是「第 4 页」
|
||||
按哪个页面在看会是两个意思。
|
||||
@@ -241,13 +243,16 @@ Android 壳在原生层处理系统栏(`MainActivity.applyInsets`),WebView
|
||||
**所以是两个路由:**
|
||||
|
||||
```
|
||||
#/library[/files][/<路径>][/<页>][?q=<搜索>]
|
||||
#/library[?path=<路径>&page=<页>&q=<搜索>]
|
||||
#/library/files[?path=<路径>&page=<页>]
|
||||
```
|
||||
|
||||
- **`#/library/科幻`** 是浏览页。没有 `files` 段,因为这是**读者**要的页面,也是
|
||||
别人发给你的那个链接。搜索是 `?q=`:它是自由文本、可以没有、并且落在路径**之外**
|
||||
(`#/library/books` 是浏览页的旧别名。)
|
||||
|
||||
- **`#/library?path=科幻`** 是浏览页。没有 `files` 段,因为这是**读者**要的页面,也是
|
||||
别人发给你的那个链接。搜索是 `?q=`:它是自由文本、可以没有、并且和路径是**两个**参数
|
||||
——这样文件夹里叫什么都无所谓。
|
||||
- **`#/library/files/科幻`** 是文件管理页。只有管理员进得来,而这是**服务端**的规矩
|
||||
- **`#/library/files?path=科幻`** 是文件管理页。只有管理员进得来,而这是**服务端**的规矩
|
||||
(每一行都写磁盘);客户端这一层是「不给他建那个页面」,见 §3.4.4。
|
||||
|
||||
两个路由的代价是:切换从「页签」变成**导航**,于是 Back 回到你刚才在的那一半。
|
||||
@@ -354,11 +359,18 @@ Android 壳在原生层处理系统栏(`MainActivity.applyInsets`),WebView
|
||||
- **入口。** 书架的「书库」按钮打开的是**浏览页**——那是一个读者的按钮。文件管理页
|
||||
的入口只在浏览页的头部,写着「文件管理」,是一个**带文字的**控件而不是一个裸图标:
|
||||
读者需要知道按下去会离开书、进入一个能删东西的页面。
|
||||
- **路由。** 判定在 `App.render`,不在按钮上。URL 是**用户输入**——它从书签、从聊天
|
||||
消息、从手写的链接过来——藏起一个按钮只是对读者的**提示**,`render` 里那一句才是
|
||||
**检查**。不是管理员却打开 `#/library/files/科幻`,落到的是**同一个文件夹的浏览页**,
|
||||
- **路由。** 判定在路由组件 `LibraryFilesPage`,不在按钮上。URL 是**用户输入**——它从书签、从聊天
|
||||
消息、从手写的链接过来——藏起一个按钮只是对读者的**提示**,路由组件里那一句才是
|
||||
**检查**。不是管理员却打开 `#/library/files?path=科幻`,落到的是**同一个文件夹的浏览页**,
|
||||
而不是一个错误页:书库是允许他去的地方,链接说的那个文件夹就是链接真正要说的东西。
|
||||
|
||||
- **成员的上传。** 成员没有文件管理入口;业务设置「书库 → 允许成员上传书籍」开启时(默认开启,
|
||||
客户端从 `GET /library/upload` 的 `allowed` 得知),浏览页头部的位置换成「上传书籍」:传到当前
|
||||
文件夹,重名保留两份(`rename`)。服务端只收书籍格式、不解包压缩包、不允许覆盖
|
||||
(见 [API](api.md) `POST /library/upload`),跳过的文件在结果提示里说明原因。上传只**新增**
|
||||
书籍,不改动已有文件,所以它不算文件管理。设置关闭时头部没有这个按钮,空书库的提示改为
|
||||
「书库的书由管理员添加。」
|
||||
|
||||
角色在进应用时从 `GET /api/v1/auth/me` 读一次,**没读到之前一律按非管理员算**:
|
||||
「还没问」不能是一段「成员也能打开删文件那个页面」的时间窗。后端仍然是每一行的
|
||||
权限判定(那个页面上的每个端点都会拒绝),客户端这一层只保证不给他建那个页面。
|
||||
@@ -370,7 +382,7 @@ Android 壳在原生层处理系统栏(`MainActivity.applyInsets`),WebView
|
||||
书架原来是在滚到底时追加一批(`loadMore`)。它有两个问题,而且都不是「体验」问题:
|
||||
列表没有读者能看见的长度,位置也回不去——关掉再打开从第一屏开始,因为「我在第三屏」
|
||||
只存在于一个滚动偏移里,而重绘会把它扔掉。两千本书是一个**地方**,地方需要有位置。
|
||||
`#/shelf/3` 就是一个位置:可以返回、可以前进、可以收藏、可以发给别人。
|
||||
`#/shelf?page=3` 就是一个位置:可以返回、可以前进、可以收藏、可以发给别人。
|
||||
|
||||
页数是**服务端算的**,页是**服务端切的**:
|
||||
|
||||
@@ -385,8 +397,8 @@ Android 壳在原生层处理系统栏(`MainActivity.applyInsets`),WebView
|
||||
一张空网格、上面一个页码,没有任何东西告诉他列表其实没问题。夹完再问一次最后一页,
|
||||
并且把 URL 一起改写,否则 Back 会回到那个被赶走的空页。
|
||||
|
||||
**翻页是导航,不是本地改数字。** 屏幕不自己改页码:它上报意图,shell 写 URL,
|
||||
URL 再回落到 `showPage(page)`。这一趟往返就是页能活过 Back、Forward 和刷新的全部原因。
|
||||
**翻页是导航,不是本地改数字。** 屏幕不自己改页码:它把页码写进 URL 查询参数
|
||||
(`setSearchParams`),再从 URL 读回页码去请求。这一趟往返就是页能活过 Back、Forward 和刷新的全部原因。
|
||||
翻页器也照此画:`aria-current="page"` 标当前页,两端禁用而不隐藏,页码窗口是
|
||||
「首、尾、以及当前页的邻居」——50 页在手机上是一排需要**读**而不是**按**的按钮。
|
||||
|
||||
@@ -423,11 +435,11 @@ URL 再回落到 `showPage(page)`。这一趟往返就是页能活过 Back、For
|
||||
书架是每个读者每天打开四十次的那一屏,所以方向落在**封面自己**上
|
||||
(#40:「补全完善书架的加入、移除逻辑」):
|
||||
|
||||
- **每张封面右上角一个「⋯」**,点开是这一本书的动作。它到现在只有一个条目
|
||||
(「从书架拿掉」),仍然是菜单:入口才是「这个方向存在」的证据,而在每张封面上
|
||||
直接画一个「从书架拿掉」按钮,是在读者想打开书的那根手指下面放一个破坏性控件。
|
||||
- **每张封面右上角一个「⋯」**,点开是这一本书的动作:标记想读/在读/已读、清除标记,
|
||||
以及「从书架拿掉」(§10)。即使只有「从书架拿掉」一条它也该是菜单:入口才是「这个方向存在」
|
||||
的证据,而在每张封面上直接画一个「从书架拿掉」按钮,是在读者想打开书的那根手指下面放一个破坏性控件。
|
||||
- **它是图标按钮,不是滑动、也不是长按。** 书架是纵向滚动的,横向手势跟它所在的
|
||||
滚动抢事件;长按没有任何可供性——同一条理由文件行已经在用(§3.4.3)。
|
||||
滚动抢事件;长按没有任何可供性——文件管理页的行也是同一条理由,用的是 `⋯`。
|
||||
- **先问,再写。** `remove` 是两个方向里唯一拿走东西的那个,也是读者最容易误读
|
||||
后果的那个。确认框里写的是**读者真正在问的那句话**:磁盘上的文件一个都不会动。
|
||||
- **写入之后重读这一页,而不是本地删掉那张卡片。** 列表是这次动作的答案,
|
||||
@@ -490,7 +502,8 @@ URL 再回落到 `showPage(page)`。这一趟往返就是页能活过 Back、For
|
||||
服务端返回的文案均按纯文本显示,不执行 HTML。
|
||||
|
||||
容器和消息正文不拦截点击,关闭按钮可以操作,避免提示挡住阅读按钮。原生弹窗打开时,同一个容器移入弹窗的顶层,
|
||||
关闭后回到 `body`;消息不抢焦点。提示出现、更新和关闭不得改变内容区尺寸或滚动位置,
|
||||
关闭后回到 `body`;消息不抢焦点。所以在 Sheet 里触发的操作(例如书源结果的「加入书架」)也用 Notyf 报结果,
|
||||
不在 Sheet 背后的页面上写内联提示——那样读者看不见。提示出现、更新和关闭不得改变内容区尺寸或滚动位置,
|
||||
这些行为在手机和桌面 Chromium 中验证。数量摘要、搜索统计和操作说明继续作为页面内容展示。
|
||||
|
||||
### 3.7 弹层:只有一种 Sheet
|
||||
@@ -501,7 +514,7 @@ SweetAlert、Mantine `Modal`、自绘遮罩或角落浮动卡片。
|
||||
|
||||
| 视口 | 形态 |
|
||||
| --- | --- |
|
||||
| ≤700px | 底部 Sheet:圆角顶边、把手,高度随内容,最高到安全区下方;阅读器内最高 62% |
|
||||
| ≤700px | 底部 Sheet:圆角顶边、把手。普通 Sheet 固定高 `min(80dvh, 视口减顶部安全区)`,内容在 Sheet 内部滚动;`compact` 高度随内容(到上限后同样内部滚动);阅读器内固定 `min(62dvh, …)`、最高 36rem。独立窗口 PWA(`data-host="pwa"`)里普通 Sheet 从视口 20% 处铺到底 |
|
||||
| ≥701px | 右侧抽屉:距边 1rem,宽 30rem;`size="wide"` 宽 42rem 给复杂表单 |
|
||||
| ≥701px,`size="compact"` | 确认、单行输入、短选择列表:跟手,出现在触发它的点按位置或聚焦控件的下方(放不下则上方),宽 24rem,始终留在窗口内;找不到触发点时回到右上角 |
|
||||
|
||||
@@ -510,7 +523,7 @@ SweetAlert、Mantine `Modal`、自绘遮罩或角落浮动卡片。
|
||||
- 就地渲染、不走 portal,所以继承打开它的页面主题(阅读器黑白灰、影音别名)。
|
||||
- 调用方的 `className` 只管**内容**样式,不得设置位置、尺寸、圆角、背景遮罩或标题栏。
|
||||
- 确认框焦点默认在「取消」;破坏性操作传 `danger`,按钮用危险色。
|
||||
- `⋯` 操作菜单和单选筛选(收藏类型等)用 `web/src/ui/action-menu.tsx` 的 `ActionMenu`:从触发按钮下方弹出的下拉菜单(手机和桌面一致),没有标题栏和遮罩,选中、Esc、Tab 或点外部即关闭,支持方向键;样式在 `web/src/styles/action-menu.css`。需要以问题形式出现的单选(书架的选择对话框)用 `ActionSheet`,即 compact Sheet 里的同一列菜单项。动作是数据(`label`、`onSelect`、`danger`、`checked`),菜单先关再执行动作,所以动作可以再开一个 Sheet 或跳转。不再使用 Mantine `Menu` 或 `<details>` 下拉。
|
||||
- `⋯` 操作菜单和单选筛选(收藏类型等)用 `web/src/ui/action-menu.tsx` 的 `ActionMenu`:从触发按钮下方弹出的下拉菜单(手机和桌面一致),没有标题栏和遮罩,选中、Esc、Tab 或点外部即关闭,支持方向键;样式在 `web/src/styles/action-menu.css`。需要以问题形式出现的单选(书架的选择对话框)用 `ActionSheet`,即 compact Sheet 里的同一列菜单项。动作是数据(`label`、`onSelect`、`danger`、`checked`、`description`——条目下的一行说明,经 `aria-describedby` 读出),菜单先关再执行动作,所以动作可以再开一个 Sheet 或跳转。不再使用 Mantine `Menu` 或 `<details>` 下拉。
|
||||
- 锚定在控件上的只有 `ActionMenu` 下拉、选择框(`MediaSelect`、`SearchableSelect`)和正文选区工具条,它们不是 Sheet。
|
||||
|
||||
以下是阅读器面板最初改成底部半屏的理由,仍然适用于手机形态:
|
||||
@@ -529,10 +542,11 @@ SweetAlert、Mantine `Modal`、自绘遮罩或角落浮动卡片。
|
||||
- **把手**:从底部升起的面板被理解为可拖拽的。拖动本身没有实现——把手是
|
||||
*可供性*,遮罩和 ✕ 是两个真实的出口。画一个做不到的把手比不画更糟,所以
|
||||
它只是视觉线索,真正移动的是面板自己的滚动。
|
||||
- **高度**:随内容,阅读器内最高 `min(62dvh, 36rem)`。一千二百章的目录会撑到上限,
|
||||
十一行的设置不占比它需要更多的空间。
|
||||
- **高度**:手机上是固定高度,不随内容:普通 Sheet `min(80dvh, …)`,阅读器内 `min(62dvh, …)`
|
||||
且不超过 36rem。随内容的高度在部分移动 WebView 里会让 Sheet 内部滚动失效(一千二百章的
|
||||
目录滚不动),所以只有 `compact`(确认、单行输入、短列表)仍按内容定高。
|
||||
|
||||
面板打开时**它拥有点击**:旁边露出的那一条正文不翻页(§7 的优先级)。
|
||||
面板打开时**它拥有点击**:旁边露出的那一条正文不翻页(§3.8 的优先级)。
|
||||
|
||||
### 3.8 事件层级
|
||||
|
||||
@@ -558,8 +572,9 @@ SweetAlert、Mantine `Modal`、自绘遮罩或角落浮动卡片。
|
||||
分类标签,逐格滑看起来像没反应,所以改为按**频道**走(`MediaPageFrame emptyChannel`)。
|
||||
频道由路径决定(`useMediaChannel`),静态路由(`audiobook/narrators`)也一样;
|
||||
「演播者」是有声书的第二个分类,和「有声书」同级,保留底部导航栏。
|
||||
书架的「全部 · 在读 · 想读 · 已读」用同一个 `useSwipeTabs` 横滑切换。
|
||||
|
||||
有两个坑是文档级别的,改这块之前必须先知道:
|
||||
有三个坑是文档级别的,改这块之前必须先知道:
|
||||
|
||||
- **必须用 `composedPath()`,不能用 `event.target`。** 书的正文在 shadow root
|
||||
里,而 shadow 边界会**重定向**事件:点正文里的 `<p>`,监听器看到的 target 是
|
||||
@@ -726,9 +741,9 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
|
||||
- 亮度是 `mix-blend-mode: multiply` 的覆盖层,不是调色板。换主题应该保留
|
||||
纸与字的对比关系;直接改颜色会把文字一起压暗——那正是「亮度」滑杆最常见的
|
||||
失败方式。
|
||||
- 新增颜色一律走 `:root` 变量。样式表里现存三个状态色
|
||||
(`#4a8f5f`/`#c08a2e`/`#b6453a`)和一处危险色(`#c0392b`)是**已知例外**,
|
||||
它们是语义色不是主题色,但应当在下次改动时提升为令牌。
|
||||
- 新增颜色一律走令牌。危险色已经是令牌:`--color-danger` / `--color-danger-soft`
|
||||
(`theme-tokens.css`,只分浅色和深色,不随主题变)。`reader.css` 里还剩两条规则直接写
|
||||
`#c0392b`,是**已知例外**,应当在下次改动时换成 `--color-danger`。
|
||||
|
||||
---
|
||||
|
||||
@@ -745,7 +760,7 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
|
||||
- `npm run ui:review:reader --prefix web`:阅读器舞台、面板、翻页和窄屏场景,输出到 `artifacts/ui-review/reader/`;
|
||||
- `npm run ui:sources --prefix web`:书源页面与扩展流程,输出到 `artifacts/ui-review/sources/`。
|
||||
|
||||
这些脚本只生成临时评审产物,不要把截图或报告提交到 `docs/`。CI 的 `client-ui-review` 只在界面代码、界面规范或评审工具变化时运行。
|
||||
这些脚本只生成临时评审产物,不要把截图或报告提交到 `docs/`。CI(`.github/workflows/ci.yml`)的 `ui-review` 任务在每次 CI 都对 Web 任务构建出的 bundle 运行 `ui:review:only`,并把 `artifacts/ui-review/react-app/` 上传为构建产物。
|
||||
|
||||
---
|
||||
|
||||
@@ -754,8 +769,8 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
|
||||
1. 新颜色 / 新间距 / 新时长 → 先看 `:root` 里有没有,没有就**加令牌**,
|
||||
不要在规则里写字面量。
|
||||
2. 新图标 → 加一条路径到 `tools/icons/paths.mjs`,**键名用这个项目自己的英语词**
|
||||
(§1.2:不许借别的图标集的名字,`test/icons.test.ts` 会拒),跑 `npm run icons`,
|
||||
把名字加进那张词汇表,然后用 `IconButton` 引用名字。
|
||||
(不许借别的图标集的名字,`test/icons.test.ts` 会拒),跑 `npm run icons`,
|
||||
在 `web/src/ui/icon.tsx` 里把名字映射到 Lucide 图标,然后用 `IconButton` 引用名字。
|
||||
3. 新控件 → 确认它出现在 §2.4 的焦点选择器列表里。
|
||||
4. 新动画 → 用 `--motion-duration` 和 `--motion-ease`,不要自己写时长。
|
||||
5. 有状态变化的控件 → 对照 §5 那张表,确认用的是对的那一种。
|
||||
@@ -764,19 +779,16 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
|
||||
7. 图标 + 文字并排 → 用 `IconTextButton`,不要自己拼一个 `<button>` 加一个
|
||||
`<span class="icon">`;图标加文字正是最容易歪的组合,而它每次歪的方式都
|
||||
一样(字形落到文字基线上)。
|
||||
8. 动图标相关的东西 → 先读 §1.4。图标对齐是字体度量和 CSS 两半一起决定的,
|
||||
只改一半会得到一个更小的偏移,而不是没有偏移。
|
||||
9. **动布局 → 跑 `npm run ui:review`,并且看截图。** 断言只覆盖已经量过的那些
|
||||
8. **动布局 → 跑 `npm run ui:review`,并且看截图。** 断言只覆盖已经量过的那些
|
||||
规则;新缺陷的第一道防线是眼睛。
|
||||
- 给绝对定位的字形居中时,量**墨迹**(`Range.getBoundingClientRect()`),
|
||||
不要量盒子——盒子居中而墨迹不居中是 §1.4 那个缺陷的第三次出现,每次
|
||||
都能活过一轮「盒子看起来在中间」的检查。
|
||||
10. **加一条能一直看着的断言**,如果这条规则是能量出来的。下一个人不会看到
|
||||
不要量盒子——盒子居中而墨迹不居中的缺陷能活过一轮「盒子看起来在中间」的检查。
|
||||
9. **加一条能一直看着的断言**,如果这条规则是能量出来的。下一个人不会看到
|
||||
这次截图。
|
||||
11. **加一个列表页 → 页数由服务端给,页由服务端切,页是 URL 的一部分**(§3.5)。
|
||||
10. **加一个列表页 → 页数由服务端给,页由服务端切,页是 URL 的一部分**(§3.5)。
|
||||
在客户端切好一页再丢掉、或把页码只放在组件状态里,都会在 Back、Forward 和
|
||||
刷新这三件事上分别坏一次,而这三件事没有一条是功能测试会先撞上的。
|
||||
12. **动贴边的条(页头、底栏、侧栏、浮在底部的提示)→ 按 §3.1 用模拟安全区看一遍**
|
||||
11. **动贴边的条(页头、底栏、侧栏、浮在底部的提示)→ 按 §3.1 用模拟安全区看一遍**
|
||||
竖屏和横屏。`ui:review` 不模拟安全区,刘海下的按钮在它的截图里是看不出来的。
|
||||
|
||||
---
|
||||
@@ -785,25 +797,28 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
|
||||
|
||||
| 路径 | 内容 |
|
||||
| --- | --- |
|
||||
| `web/src/ui/icon.tsx` | `Icon` 组件 |
|
||||
| `web/src/ui/icon-names.ts` | 码位表(生成) |
|
||||
| `web/src/styles/reader-icons.ttf` | 字体(生成) |
|
||||
| `web/src/ui/icon.tsx` | `Icon` 组件:图标名到 Lucide SVG 的映射 |
|
||||
| `web/src/ui/icon-names.ts` | 码位表(生成),`IconName` 的来源 |
|
||||
| `web/src/styles/reader-icons.ttf` | 字体(生成,界面不再加载) |
|
||||
| `web/tools/icons/` | 生成器 |
|
||||
| `web/test/icons.test.ts` | 字体度量、字形包围盒、码位表、**没有借来的名字**、词汇表与 `.icon` 规则的契约 |
|
||||
| `web/src/ui/toolkit.tsx` | `IconButton` / `Button` / `Segmented` / `SwitchRow` / `Scrim` |
|
||||
| `web/src/ui/toolkit.tsx` | `IconButton` / `IconTextButton` / `Button` / `Segmented` / `Callout` / `SwitchRow` / `SectionTitle` |
|
||||
| `web/src/ui/action-menu.tsx` / `web/src/styles/action-menu.css` | 每个 `⋯` 菜单的 `ActionMenu` 下拉,以及 `ActionSheet`(§3.7) |
|
||||
| `web/src/ui/cover-tint.ts` | 无封面占位的色相(§2.7) |
|
||||
| `web/src/ui/book-grid-skeleton.tsx` | 书架与书库加载时的封面网格骨架 |
|
||||
| `web/src/features/shelf/pages/ShelfPage.tsx` | 书架;排序、分页器与书库入口 |
|
||||
| `web/src/features/library/pages/LibraryPage.tsx` | 书库浏览、书籍列表和文件管理路由;搜索、面包屑、分页器、上传后重读 |
|
||||
| `web/src/ui/shelf-membership.ts` | 两个方向的措辞与批量报告那句话的唯一来源(§3.5.2) |
|
||||
| `web/src/ui/shelf-order.ts` | 排序的**唯一**定义:方向、客户端/服务端的键怎么翻译、阅读时间怎么排 |
|
||||
| `web/src/ui/sheet.tsx` / `web/src/styles/sheet.css` | 唯一的弹层 `Sheet` 与 `ConfirmSheet`(§3.7) |
|
||||
| `web/src/ui/dialog.tsx` | 提问(文本框/确认/选择),基于 `Sheet`,书架与文件管理共用 |
|
||||
| `web/src/ui/pager.tsx` | 翻页器,书架与书库共用(§3.5) |
|
||||
| `web/test/shelf-order.test.ts` | 书架排序(默认项、`recent` 是客户端排)、排序方向和稳定性 |
|
||||
| `web/src/ui/pager.tsx` | 翻页器组件(§3.5);书架和书库目前各自画「上一页 / 下一页」,没有用它 |
|
||||
| `web/test/shelf-order.test.ts` | 排序的方向、客户端/服务端的键、阅读时间排序与并列时的稳定 |
|
||||
| `web/test/react-router-routes.test.tsx` | React Router 的页面、影音嵌套路由和权限边界 |
|
||||
| `web/test/media-library-list.test.tsx` | 媒体库列表、分页和空状态 |
|
||||
| `web/test/media-library-editor.test.tsx` | 媒体库编辑、权限和保存后的重读 |
|
||||
| `web/src/ui/gestures.ts` | 手势与事件层级(§3.6) |
|
||||
| `web/src/ui/gestures.ts` | 手势与事件层级(§3.8) |
|
||||
| `web/src/shared/ui/use-swipe-tabs.ts` | 书架与影音分类的横滑切换(§3.8) |
|
||||
| `web/src/ui/reader-chrome.tsx` | 阅读页的顶栏/页脚/面板/朗读条/沉浸式按钮列 |
|
||||
| `web/src/ui/shadow.ts` | 书内容 shadow root;含纯文本排版表(§3.9) |
|
||||
| `web/src/formats/segments.ts` | 纯文本分段(「一段是什么」的唯一定义,§3.9) |
|
||||
@@ -813,9 +828,12 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
|
||||
| `web/tools/ui-review/` | UI 评审:截图 + 尺寸断言(§7) |
|
||||
| `artifacts/ui-review/react-app/` | 当前 React 评审截图与 `verification.json`(生成,不提交) |
|
||||
| `web/src/styles/reader.css` | 令牌、组件样式、交互状态、`prefers-reduced-motion` |
|
||||
| `web/src/styles/theme-tokens.css` | V3.1 颜色语义令牌、四个主题、`--reader-*` 别名、阅读纸色(§6) |
|
||||
| `web/test/design-tokens.test.ts` | 圆角与 24px 以下字号必须用令牌、`:hover` 必须在悬停守卫里(§2) |
|
||||
| `web/test/theme-tokens.test.ts` | 主题取值与 `theme.ts` 一致、对比度(§6) |
|
||||
|
||||
|
||||
## 9. V3 外壳与导航
|
||||
## 10. V3 外壳与导航
|
||||
|
||||
- 一个导航:手机底部四个标签(首页、书架、影音、我的),701px 以上改为左侧栏,影音下列出影视、音乐、有声书三个频道。取代旧的媒体频道栏。
|
||||
- 导航按页面类型显示:顶层页面显示;详情、设置、书库文件等二级页面在手机上由自己的返回箭头取代底栏,桌面端侧栏保留;阅读器和播放页不显示。规则在 `web/src/app/AppNav.tsx` 的 `navMode`。
|
||||
@@ -823,7 +841,7 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
|
||||
- 频道色(阅读、影视、音乐、有声书)是每个主题自己的 `--color-ch-*`(填充)、`-soft`(卡片底)和 `-text`(文字),由 `theme-tokens.test.ts` 守住对比度。
|
||||
- 首页显示在读的书、未看完的影视和有声书、书架一行;我的页只做入口,不拥有任何设置。
|
||||
- “跟随系统”= 浅色时用的主题加深色时用的主题(默认都是暖陶),在外观与主题页指定;关掉跟随系统时保留屏幕上当前的主题与明暗,再选浅色或深色。读取与保存只走 `web/src/shared/theme/theme.ts` 的 `readThemePreference` / `saveThemePreference`;判断当前主题一律用 `resolveAppTheme` / `resolveReadingTheme`,React 里用 `useThemeState`,不要自己读存储。
|
||||
- 搜索:桌面侧栏有“搜索书名、片名、歌曲、有声书”,打开 `#/search?q=`,同时列出书库里的书和有权访问的影音,各自链接到完整的书库搜索和影音搜索。侧栏账号下方显示同步引擎对服务器的判断:已连接、正在同步、离线或同步出错。
|
||||
- 搜索:桌面侧栏的“搜索书籍和影音”打开搜索页 `#/search?q=`(页内输入框写“搜索书名、片名、歌曲、有声书”),同时列出书库里的书和有权访问的影音,各自链接到完整的书库搜索和影音搜索。侧栏账号下方显示同步引擎对服务器的判断:已连接、正在同步、离线或同步出错。
|
||||
- 书架:搜索框下是“全部 / 在读 / 想读 / 已读”和“分组”(按书的系列或标签筛选),都写进 URL(`?status=`、`?series=`、`?tag=`)。封面左上角标出在读、想读、已读;“⋯”菜单可标记想读、在读、已读或清除标记,并保留“从书架拿掉”。状态以手动标记为准,没有标记时按进度推断。
|
||||
- 视频播放:字幕、音轨、版本是同一个“字幕与音轨”抽屉的三个分页。字幕页列出关闭、外挂(服务器上的 srt/ass/vtt)、内嵌,可“添加外挂字幕”(本机文件在浏览器里转换为 WebVTT,仅本次播放)、以 0.5 秒调整字幕延迟(外挂与本机字幕),并选择字号(小/标准/大,按设备保存)。工具栏的“全屏”进入横屏全屏:选集、倍速、适应屏幕(铺满裁边)、字幕和锁定浮在画面上,随 DPlayer 控制条一起淡出;锁定后点击不再作用于播放器,只留解锁按钮。这些只在网页播放器提供,Android 播放器用自己的菜单。
|
||||
- 媒体库管理:列表上方是媒体库数量和两个常驻操作“扫描与刮削”“新建媒体库”(没有媒体库时由空状态给出“新建媒体库”)。每个媒体库卡片用频道色图标区分类型,受限库标“指定用户”,⋯ 菜单是编辑、访问权限、扫描记录;底部是缺失文件数(有缺失时用警示色)和最近一次扫描——完成时间和“重新扫描”(从未扫描时为“开始扫描”)、进行中的进度(“已处理 62%”)和“取消”、失败原因和“重试”“查看详情”。右侧“扫描与元数据”是服务器业务配置里的四个开关,逐项保存;旧服务器没有这些开关时卡片占满宽度。样式在 `web/src/features/media/styles/manager.css`。
|
||||
|
||||
@@ -6,13 +6,14 @@
|
||||
|
||||
## 书架与书库
|
||||
|
||||
- **书架**是当前账号的阅读列表,可搜索、排序、调整密度并查看阅读进度。
|
||||
- **书架**是当前账号的阅读列表,可筛选、排序、调整密度并查看阅读进度。顶部可按「全部 / 在读 / 想读 / 已读」筛选(手机上可左右滑动切换),「分组」按系列或标签筛选。每本书的「⋯」菜单可标记想读、在读、已读,清除标记或从书架拿掉;未手动标记时按进度判断在读或已读。
|
||||
- **书库**按挂载目录浏览本地书籍,可将书加入书架。
|
||||
- **文件管理**供管理员检查磁盘目录。挂载可写且目录权限允许时,可上传、改名、移动、删除和新建目录。
|
||||
- **文件管理**仅供管理员检查磁盘目录。挂载可写且目录权限允许时,可上传、改名、移动、删除和新建目录。
|
||||
- **上传书籍**:普通成员没有文件管理;管理员在「我的 → 系统设置 → 服务配置 → 书库」开启「允许成员上传书籍」(默认开启)时,成员可在书库浏览页头部点「上传书籍」,把书传到当前文件夹。成员只能新增书籍文件:不支持的格式会被跳过,`.zip/.cbz` 按漫画压缩包原样保存不解包,重名时保留两份,不能覆盖已有文件。
|
||||
|
||||
从书架移除只改变该账号的书架记录,不删除书文件。文件管理中的删除会修改磁盘,请按界面确认提示操作。书籍的改名或移动按已有身份更新路径,保留阅读记录。
|
||||
|
||||
上传 `.zip/.cbz` 时会作为合集解包,保留必要的目录层级。单纯想把 CBZ 作为书籍扫描时,也可以直接把文件放到宿主机书库目录。重名上传支持保留两份、跳过、覆盖或拒绝整批,具体错误语义见 [上传 API](api.md)。
|
||||
管理员上传 `.zip/.cbz` 时会作为合集解包,保留必要的目录层级。单纯想把 CBZ 作为书籍扫描时,也可以直接把文件放到宿主机书库目录。重名上传支持保留两份、跳过、覆盖或拒绝整批,具体错误语义见 [上传 API](api.md)。
|
||||
|
||||
## 支持格式
|
||||
|
||||
@@ -28,7 +29,7 @@
|
||||
|
||||
## 扫描与元数据
|
||||
|
||||
默认每 30 分钟定时扫描、每 60 秒检查变化,管理员可以手动触发扫描。扫描先比较大小和修改时间,再按需计算内容哈希。`.git`、`@eaDir`、`#recycle` 等目录被跳过,符号链接不跟随。
|
||||
默认每 60 秒自动检查书库变化,服务启动时也扫描一次;管理员可在「我的 → 系统设置 → 服务配置 → 扫描设置」调整间隔(0 关闭)。扫描先比较大小和修改时间,再按需计算内容哈希。`.git`、`@eaDir`、`#recycle` 等目录被跳过,符号链接不跟随。
|
||||
|
||||
图片目录会结合图片、卷目录和压缩包结构识别。目录中包含其他书籍文件时,不应将这些书吞并为一个漫画目录;目录识别和分页资源规则见[架构](architecture.md)及 [API](api.md)。
|
||||
|
||||
@@ -47,13 +48,14 @@
|
||||
| 分页 / 滚动 | 按屏翻页或连续阅读 |
|
||||
| 字号、行距、页边距、对齐 | 调整正文,部分设置可选择沿用原书 |
|
||||
| 字体 | 在原字体栈之前加入选定字体 |
|
||||
| 纸色 | 跟随主题,或选择白纸、暖黄、浅绿、灰调、夜间;可设置应用为深色时换成主题的深色 |
|
||||
| 主题 | 跟随应用或黑白灰;明暗可跟随系统、浅色或深色 |
|
||||
| 亮度 | 界面叠加调暗,不等同于修改设备系统亮度 |
|
||||
| 图片适配 | 固定版式内容完整显示或适宽 |
|
||||
| 翻页方向 | 漫画等固定版式内容支持左右方向切换 |
|
||||
| TXT 编码 | 自动识别异常时手动选择 |
|
||||
|
||||
点击正文中央可切换工具栏,左右区域翻页;也可使用目录跳转章节。工具栏、目录和设置面板覆盖正文,避免显隐时重新挤压正文布局。电脑端支持方向键、PageUp/PageDown;Android 可通过外壳处理音量翻页。
|
||||
点击正文中央可切换工具栏,左右区域翻页(可在设置中改为左进右退);手机上分页模式可左右滑动翻页,页面跟随手指,快速划动或拖过约 30% 宽度后松手即翻页。也可使用目录跳转章节。工具栏、目录和设置面板覆盖正文,避免显隐时重新挤压正文布局。电脑端支持方向键、PageUp/PageDown;Android 可通过外壳处理音量翻页。
|
||||
|
||||
EPUB 章节按需加载,长书的目录与传输窗口分开处理,跨窗口跳转会先取相应章节。图标为 Lucide SVG,界面布局与交互规范见 [UI 文档](ui.md)。
|
||||
|
||||
@@ -74,7 +76,7 @@ EPUB 章节按需加载,长书的目录与传输窗口分开处理,跨窗口
|
||||
|
||||
## 影音入口
|
||||
|
||||
打开站点默认进入影视页(`#/media/video`),也可从书架右上角「影音」入口进入。通过频道导航切换电影、剧集、音乐和有声书,选择「阅读」返回书架。影音拥有独立设置、主题、收藏与播放页面,不改变阅读器和书架设置。
|
||||
打开站点默认进入「首页」,可继续最近的阅读与播放。底部(宽屏为左侧)导航有首页、书架、影音、我的四个标签;宽屏侧栏的搜索框可同时搜索书籍和影音。「影音」内通过频道切换影视、音乐和有声书,手机上可左右滑动切换分类。阅读与影音共用「我的 → 外观与主题」中的主题;影音浏览密度与播放偏好在「我的 → 影音浏览」「我的 → 播放」中设置。
|
||||
|
||||
管理员可添加本地目录或 OpenList 媒体库,配置权限、扫描与刮削;用户浏览已授权的内容并播放。建库、歌词、字幕、队列、倍速及睡眠定时见[影音使用说明](media-user-guide.zh-CN.md)。
|
||||
|
||||
|
||||
Reference in new issue
Block a user