diff --git a/CLAUDE.md b/CLAUDE.md index 97a93c2..7bb0a11 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/README.en.md b/README.en.md index 1f56b56..ea576de 100644 --- a/README.en.md +++ b/README.en.md @@ -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://: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://: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 diff --git a/README.md b/README.md index 8b11608..acb23e9 100644 --- a/README.md +++ b/README.md @@ -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)。 ## 文档 diff --git a/android/README.md b/android/README.md index ce57775..091c675 100644 --- a/android/README.md +++ b/android/README.md @@ -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=` 增量拉取。服务端是唯一真相源, -冲突是 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 不在本目录范围内。 diff --git a/android/contract.md b/android/contract.md index 955ac3a..ebc6823 100644 --- a/android/contract.md +++ b/android/contract.md @@ -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: , 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= + → 单个文件;path 只能取 manifest 列出的值,其他值得不到别的文件 ``` -目录书(漫画目录)没有单一文件,`/content` 会返回 `400 DIRECTORY_BOOK`。 -这类书要缓存的是 manifest 里 `files` 列出的**每一页**: - -``` -GET /api/v1/books/:id/file?path= - → 200,一张图;ETag 按文件算 -``` - -`path` 只能取 `files` 里出现过的值。服务端只按这份契约匹配,不会用 query 拼路径, -所以乱填只会拿到 404,不会拿到别的书的东西。 - -`/data` 里的书架元数据由客户端自己缓存(`GET /api/v1/books` 的响应)。 -服务端离线时,外壳读本地缓存渲染书架,并静默降级 —— 设计文档 §8.2 -要求「客户端必须优雅处理服务端不可达」,这条不能靠报错弹窗实现。 +服务端不可达时客户端读本地缓存并静默降级,不能靠报错弹窗。 diff --git a/docs/api.md b/docs/api.md index 57e8350..442d008 100644 --- a/docs/api.md +++ b/docs/api.md @@ -2,10 +2,10 @@ 基础路径 `/api/v1`。除标注「公开」外都需要 `Authorization: Bearer `。 -错误响应统一格式: +错误响应统一格式(请求体 / 查询参数校验失败时 `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=` @@ -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,塞进 `