mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-10 21:11:08 +08:00
feat(settings): manage business configuration from admin pages
This commit is contained in:
1 parent
996f0c555e
commit
ff976a4cf6
41 files changed
+731
-192
No files matched your search
+1
-30
@@ -25,41 +25,12 @@ services:
|
||||
ports:
|
||||
- "5888:5888"
|
||||
environment:
|
||||
# Polling intervals in seconds. Set to 0 to disable (a manual scan is
|
||||
# still available from the admin API).
|
||||
SCAN_INTERVAL: "1800"
|
||||
WATCH_INTERVAL: "60"
|
||||
# Business settings are managed in the administrator UI and persisted in /data.
|
||||
LOG_LEVEL: "info"
|
||||
# Optional online media metadata providers. Configure on the host; do not
|
||||
# commit credentials. Local NFO/tags work without either provider.
|
||||
# MEDIA_TMDB_TOKEN: "${MEDIA_TMDB_TOKEN}"
|
||||
# MEDIA_TMDB_API_KEY: "${MEDIA_TMDB_API_KEY}"
|
||||
# MEDIA_MUSICBRAINZ_USER_AGENT: "Reader/1.0 (your-contact@example.com)"
|
||||
# Set this to the address you actually reach the server on, so download
|
||||
# links work behind a reverse proxy.
|
||||
# PUBLIC_URL: "https://reader.example.com"
|
||||
# Uncomment to allow family members to self-register instead of the admin
|
||||
# creating their accounts:
|
||||
# ALLOW_REGISTRATION: "true"
|
||||
# Origins allowed to call the API from a browser, comma separated.
|
||||
# Empty (the default) reflects whatever origin asks. Fine for a LAN: the
|
||||
# client does not know the address it will be reached on, and there are no
|
||||
# cookies in the design. Set it if the instance is on the public internet:
|
||||
# CORS_ORIGINS: "https://reader.example.com"
|
||||
# Directory holding the built H5 client, served at `/`. Already set in the
|
||||
# image; only override if you build the client separately.
|
||||
# WEB_DIR: "/app/web"
|
||||
|
||||
# --- HTTP speech, for clients that have no engine of their own ---
|
||||
# The server only *proxies*; it does not bundle a synthesizer, because
|
||||
# that would multiply the image size and a NAS's CPU budget. Point this at
|
||||
# any service that accepts the classic `?text=...&speed=...` shape
|
||||
# (edge-tts, a local Piper wrapper, a commercial API with a shim).
|
||||
#
|
||||
# TTS_URL: "http://host.docker.internal:5002/tts"
|
||||
# TTS_TOKEN: "" # bearer token, if the upstream wants one
|
||||
# TTS_VOICES_URL: "" # defaults to <TTS_URL>/voices
|
||||
# TTS_CACHE_BYTES: "268435456" # audio cache cap in /data; 0 disables
|
||||
volumes:
|
||||
# --- your books ---
|
||||
# Use :ro for a browse-only deployment. Use :rw if an administrator
|
||||
|
||||
@@ -15,6 +15,7 @@ The project README introduces reader. Use the guides below for installation, eve
|
||||
| 远端媒体目录 / Remote media directories | [OpenList 接入](media-openlist.md) | Chinese only |
|
||||
| 安装到桌面、离线启动和版本更新 / PWA installation, offline startup and updates | [PWA 指南](pwa.zh-CN.md) | Chinese only |
|
||||
| 环境变量、目录权限、HTTPS、排错 / Settings, permissions, HTTPS, troubleshooting | [部署配置](configuration.zh-CN.md) | [Configuration](configuration.en.md) |
|
||||
| 页面业务配置、服务连接与迁移 / Business settings, service connections and migration | [业务配置](business-settings.zh-CN.md) | Chinese only |
|
||||
| 数据备份、校验与恢复 / Data backup, verification and restore | [备份恢复](backup.zh-CN.md) | Chinese only |
|
||||
| 本地开发、验证、镜像与 Android 构建 / Development, checks, Docker and Android builds | [开发指南](development.zh-CN.md) | [Development](development.en.md) |
|
||||
|
||||
|
||||
+27
-17
@@ -72,7 +72,7 @@
|
||||
```
|
||||
|
||||
- 首个账号恒可注册,且自动成为 `admin`
|
||||
- 之后按管理员设置的注册策略开放;未设置时兼容 `ALLOW_REGISTRATION`。
|
||||
- 之后按管理员设置的注册策略开放;首次初始化时从旧 `ALLOW_REGISTRATION` 导入,随后以数据库配置为准。
|
||||
- 邀请模式需要提交 `inviteCode`,无效、过期、停用或用尽的邀请码返回 `400 INVITE_INVALID`,未填写返回 `400 INVITE_REQUIRED`。普通注册只创建普通用户。
|
||||
- 关闭时返回 `400 REGISTRATION_DISABLED`
|
||||
|
||||
@@ -634,7 +634,7 @@ manifest 的每个条目还带 `ref`,即这个文件对应的**资源引用**
|
||||
|
||||
## 朗读(HTTP TTS)
|
||||
|
||||
这两条路由只在服务端配置了 `TTS_URL` 时才有意义,并且分工明确:
|
||||
这两条路由只在服务端配置了 HTTP 朗读合成接口 时才有意义,并且分工明确:
|
||||
一条回答「这里能用什么」,一条真正合成音频。
|
||||
|
||||
### `GET /tts/voices`
|
||||
@@ -643,10 +643,10 @@ manifest 的每个条目还带 `ref`,即这个文件对应的**资源引用**
|
||||
|
||||
→ `{ "http": false, "formats": ["audio/mpeg", ...], "maxLength": 800, "voices": [] }`
|
||||
|
||||
- 未配置 `TTS_URL` 时返回 `http: false` 而**不是 404**:客户端问的是「这里能用什么引擎」,
|
||||
不是「这个接口存在吗」。一个 `TTS_URL` 没配的实例是完全正常的实例。
|
||||
- 未配置 HTTP 朗读合成接口 时返回 `http: false` 而**不是 404**:客户端问的是「这里能用什么引擎」,
|
||||
不是「这个接口存在吗」。一个 HTTP 朗读合成接口 没配的实例是完全正常的实例。
|
||||
- `maxLength` 无论开关都返回:客户端会按它切句,这个决定发生在选引擎之前。
|
||||
- 配置了之后 `voices` 来自 `TTS_VOICES_URL`(或约定路径 `<TTS_URL>/voices`)。
|
||||
- 配置了之后 `voices` 来自 音色列表接口(或约定路径 `<合成接口>/voices`)。
|
||||
|
||||
### `GET /tts?text=<文本>&voice=<可选>&speed=<可选>&format=<可选>`
|
||||
|
||||
@@ -658,23 +658,22 @@ manifest 的每个条目还带 `ref`,即这个文件对应的**资源引用**
|
||||
- `speed` 夹在 0.25–4;`text` 上限 800 字,超了返回 `TEXT_TOO_LONG`,
|
||||
**不会转发给上游**。
|
||||
- 上游返回非 `audio/*` 时返回 `TTS_UPSTREAM` 而不是把 HTML 当音频流出去:
|
||||
一个配错的 `TTS_URL`(指到一个网页)会以 200 返回 HTML,塞进 `<audio>` 里
|
||||
一个配错的 HTTP 朗读合成接口(指到一个网页)会以 200 返回 HTML,塞进 `<audio>` 里
|
||||
既没有声音也没有事件——这是最难排查的一种「成功」。
|
||||
- 这是唯一接受**查询串令牌**的接口之一(另一个是 `/books/:id/assets`、`/cover`、`/content`):
|
||||
`<audio src>` 无法携带 `Authorization` 头。因此它被精确地限制为 `GET /tts`,
|
||||
能力查询 `/tts/voices` 不支持查询串令牌。
|
||||
- 响应带 `cache-control: private, max-age=604800, immutable`:
|
||||
同一句话的音频永远相同,第二次播放不应该再到服务端。
|
||||
- 响应带 `cache-control: private, no-store`,避免服务配置更新后继续使用旧音频;服务端按上游配置缓存合成结果。
|
||||
|
||||
相关环境变量:
|
||||
在「系统设置 → 服务配置 → HTTP 朗读」修改以下配置:
|
||||
|
||||
| 变量 | 说明 |
|
||||
| 配置项 | 说明 |
|
||||
| --- | --- |
|
||||
| `TTS_URL` | 上游合成服务的地址,例如 `http://127.0.0.1:5002/tts`。不设则关闭 HTTP 朗读 |
|
||||
| `TTS_TOKEN` | 以 `Authorization: Bearer` 转发给上游 |
|
||||
| `TTS_VOICES_URL` | 语音列表地址;默认 `<TTS_URL>/voices` |
|
||||
| `TTS_TIMEOUT_MS` | 单次合成超时,默认 20000 |
|
||||
| `TTS_CACHE_BYTES` | 音频磁盘缓存上限(`DATA_DIR/tts-cache`),默认 256MiB;0 关闭缓存 |
|
||||
| HTTP 朗读合成接口 | 上游合成服务的地址,例如 `http://127.0.0.1:5002/tts`。不设则关闭 HTTP 朗读 |
|
||||
| 认证令牌 | 以 `Authorization: Bearer` 转发给上游 |
|
||||
| 音色列表接口 | 语音列表地址;默认 `<合成接口>/voices` |
|
||||
| 请求超时(毫秒) | 单次合成超时,默认 20000 |
|
||||
| 音频缓存上限(字节) | 音频磁盘缓存上限(`DATA_DIR/tts-cache`),默认 256MiB;0 关闭缓存 |
|
||||
|
||||
## 静态客户端
|
||||
|
||||
@@ -888,7 +887,7 @@ kind 为 video、music 或 audiobook;OpenList 建库和凭据更新见[OpenLis
|
||||
"displayName": "家人", "role": "member" }
|
||||
```
|
||||
|
||||
不受 `ALLOW_REGISTRATION` 限制——管理员本就该能加家庭成员。
|
||||
不受注册策略限制——管理员本就该能加家庭成员。
|
||||
|
||||
### `PATCH /admin/users/:id`
|
||||
|
||||
@@ -956,7 +955,7 @@ GET 返回 `{ "mode": "closed", "invites": [...] }`;PATCH 接受 `{ "mode": "c
|
||||
| `FILE_MISSING` | 404 | 文件已从磁盘消失 |
|
||||
| `USERNAME_TAKEN` | 409 | 用户名已占用 |
|
||||
| `PASSWORD_TOO_SHORT` | 409 | 口令少于 8 位 |
|
||||
| `TTS_DISABLED` | 400 | 该实例没有配置 `TTS_URL`,无法使用 HTTP 朗读 |
|
||||
| `TTS_DISABLED` | 400 | 该实例没有配置 HTTP 朗读合成接口,无法使用 HTTP 朗读 |
|
||||
| `TTS_UPSTREAM` | 400 | 上游合成服务不可达、超时、报错,或返回的不是音频 |
|
||||
| `TEXT_TOO_LONG` | 400 | 单条语句超过 800 字,请客户端先切句 |
|
||||
| `INVALID_SOURCE_CONFIG` | 400 | 来源配置不符合内置来源要求 |
|
||||
@@ -1000,6 +999,17 @@ GET 返回 `{ "mode": "closed", "invites": [...] }`;PATCH 接受 `{ "mode": "c
|
||||
|
||||
## Personal reading overrides and TTS diagnostics
|
||||
|
||||
### Administrator business settings
|
||||
|
||||
All `/api/v1/admin/settings` routes require an administrator bearer token. Responses are not cacheable. Configuration is persisted in `reader.db` and applied without restarting.
|
||||
|
||||
- `GET /api/v1/admin/settings` returns `{groups:[{group,label,fields,values,secrets,revision}]}`. Secret values are omitted; `secrets` contains presence flags only.
|
||||
- `PATCH /api/v1/admin/settings/:group` accepts `{values,revision}`. Omitted fields retain their values; an explicit empty secret clears it. Stale revisions return `409 SETTINGS_CONFLICT`. Unknown fields and invalid values return 400.
|
||||
- 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.
|
||||
|
||||
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 }.
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# 业务配置
|
||||
|
||||
管理员在「系统设置 → 服务配置」编辑实例业务配置;「影音设置 → 元数据刮削」同时提供 TMDB、MusicBrainz 的直接入口。普通用户无法读取或修改服务凭据。
|
||||
|
||||
| 分类 | 可修改内容 |
|
||||
| --- | --- |
|
||||
| TMDB | 启用、读取令牌、API Key、元数据语言,令牌优先于 API Key |
|
||||
| MusicBrainz | 启用、应用标识与联系地址,例如 `Reader/0.1 (mailto:admin@example.com)` |
|
||||
| HTTP 朗读 | 启用、合成接口、Bearer 令牌、音色列表接口、超时、缓存上限,支持检测连接、获取音色、试听和清理缓存 |
|
||||
| 扫描 | 阅读书库检查间隔(0 关闭)、同时扫描的媒体库数、远端文件处理并发数 |
|
||||
| 刮削高级设置 | 请求超时、失败重试次数;MusicBrainz 的服务商限流仍强制执行 |
|
||||
| 登录与服务访问 | 对外地址、允许的浏览器来源、访问令牌与登录续期有效期 |
|
||||
| WebDAV 备份 | HTTPS 目录、账号、密码、上传上限、连接测试及已验证离线备份上传 |
|
||||
|
||||
注册策略仍在「注册与邀请」管理;OpenList、媒体库目录及权限沿用原管理页面。个人音色、语速和阅读样式不属于实例配置。
|
||||
|
||||
## 保存与生效
|
||||
|
||||
- 配置存入 `reader.db`,随完整 DATA_DIR 备份与恢复。备份含凭据,需限制访问权限。
|
||||
- 凭据不回显。未编辑时保留,填写时替换,点击清除后保存会删除;关闭功能可以保留凭据。
|
||||
- 保存后新请求使用新配置;已开始的朗读请求、扫描文件处理及批量刮削使用原配置。媒体库同时运行数量的调整在队列调度时应用。
|
||||
- 阅读自动扫描合并为一个定时器;保存后重新计时,不重复触发两套相同扫描。启动时仍进行一次书库检查。
|
||||
- 登录有效期调整只影响随后签发的令牌,不修改已经签发的令牌。OPDS 的对外地址立即更新。
|
||||
- 连接测试与试听使用当前表单,成功不代表已经保存。重新加载会丢弃未保存编辑。
|
||||
- 同一分类使用版本号防止覆盖其他管理员的修改;遇到冲突需重新加载后编辑。
|
||||
|
||||
## 旧配置迁移
|
||||
|
||||
升级首次初始化每个分类时,导入原有 `MEDIA_TMDB_*`、`MEDIA_MUSICBRAINZ_USER_AGENT`、`TTS_*`、`PUBLIC_URL`、`CORS_ORIGINS`、登录有效期及 `WEBDAV_*` 环境变量。原 `SCAN_INTERVAL` 与 `WATCH_INTERVAL` 取启用值中的较小间隔,两者都关闭则关闭自动检查。已有注册策略优先于 `ALLOW_REGISTRATION`。
|
||||
|
||||
完成初始化后,环境变量不再覆盖已保存配置,清空凭据也不会让旧变量重新生效。部署端可移除这些业务环境变量。监听端口、DATA_DIR、BOOKS_DIR、WEB_DIR、签名密钥、日志级别和外部程序路径仍是部署参数。
|
||||
|
||||
## WebDAV 备份
|
||||
|
||||
这里配置备份目的地,不提供在线数据库快照。先按[离线备份指南](backup.zh-CN.md)停服制作完整、已验证的备份,再启动服务,从 WebDAV 页输入服务器可访问的备份目录及 `.zip` 文件名上传。容器中只能访问已挂载的目录。
|
||||
|
||||
上传先验证本地清单,临时上传后校验远端内容,再发布最终文件;不会覆盖同名备份。连接测试使用 `PROPFIND` 检查目录访问,不保证上传配额或写权限。
|
||||
|
||||
命令行仍可用 `node dist/maintenance/webdav-cli.js <备份目录> <名称.zip>`,读取 DATA_DIR 对应实例已保存的 WebDAV 配置,不再从环境变量读取目的地凭据。
|
||||
+12
-27
@@ -13,7 +13,13 @@ For a first installation, follow [getting started](getting-started.en.md). This
|
||||
|
||||
Read-only books do not prevent account or progress updates in the data directory. To enable uploads, check host ACLs and directory permissions as well as changing the mount to `:rw`.
|
||||
|
||||
## Environment variables
|
||||
## Business settings in the UI
|
||||
|
||||
Administrators configure HTTP speech, scanning, login lifetimes, public URL, browser origins and WebDAV under System settings → Service configuration. TMDB and MusicBrainz are also editable in Media settings → Metadata providers. Registration remains 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`.
|
||||
|
||||
@@ -23,26 +29,11 @@ These are defaults when running the server directly. The Docker image additional
|
||||
| `DATA_DIR` | `data` under the current working directory | Server state and caches |
|
||||
| `HOST` | `0.0.0.0` | Listen address |
|
||||
| `PORT` | `5888` | Listen port |
|
||||
| `PUBLIC_URL` | Empty | Public-facing URL; set when using a reverse proxy |
|
||||
| `SCAN_INTERVAL` | `1800` | Scheduled scan interval in seconds; `0` disables it |
|
||||
| `WATCH_INTERVAL` | `60` | Change-check interval in seconds; `0` disables it |
|
||||
| `ALLOW_REGISTRATION` | `false` | Default registration policy; saved admin settings take precedence (closed, open, or invite-only) |
|
||||
| `ACCESS_TOKEN_TTL` | `604800` | Access-token lifetime in seconds (7 days); renewed near expiry on requests or returning to the foreground |
|
||||
| `REFRESH_TOKEN_TTL` | `31536000` | Refresh-token lifetime in seconds |
|
||||
| `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_TMDB_TOKEN` | Empty | TMDB read token; preferred when both credentials are configured |
|
||||
| `MEDIA_TMDB_API_KEY` | Empty | TMDB API key; no online matching unless token or key is set |
|
||||
| `MEDIA_MUSICBRAINZ_USER_AGENT` | Empty | MusicBrainz application/version/contact identifier; empty disables matching |
|
||||
| `CORS_ORIGINS` | Empty | Comma-separated browser origins; empty reflects the requesting origin |
|
||||
| `WEB_DIR` | `web` under the current working directory | Built Web client directory |
|
||||
| `TTS_URL` | Empty | HTTP speech upstream; empty disables HTTP speech |
|
||||
| `TTS_TOKEN` | Empty | Upstream Bearer token |
|
||||
| `TTS_VOICES_URL` | `<TTS_URL>/voices` | Upstream voice list |
|
||||
| `TTS_TIMEOUT_MS` | `20000` | Speech request timeout in milliseconds |
|
||||
| `TTS_CACHE_BYTES` | `268435456` | Byte limit for `DATA_DIR/tts-cache`; `0` disables caching |
|
||||
|
||||
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.
|
||||
|
||||
@@ -58,21 +49,15 @@ reader.example.com {
|
||||
}
|
||||
```
|
||||
|
||||
Set `PUBLIC_URL=https://reader.example.com`. 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 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 `CORS_ORIGINS` 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.
|
||||
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.
|
||||
|
||||
## HTTP text-to-speech
|
||||
|
||||
Use Android system speech or browser `speechSynthesis` when available. To enable an HTTP speech service, configure a compatible upstream in Compose:
|
||||
Use Android system speech or browser `speechSynthesis` when available. To enable an HTTP speech service, configure a compatible upstream in the administrator UI:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
TTS_URL: "http://your-tts-host:5002/tts"
|
||||
# TTS_TOKEN: "your-token"
|
||||
# TTS_VOICES_URL: "http://your-tts-host:5002/voices"
|
||||
TTS_CACHE_BYTES: "268435456"
|
||||
```
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -103,7 +88,7 @@ Mount local movie, series, music, and audiobook directories separately (read-onl
|
||||
|
||||
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.
|
||||
|
||||
Set MEDIA_TMDB_TOKEN or MEDIA_TMDB_API_KEY for online video matching, and MEDIA_MUSICBRAINZ_USER_AGENT for music metadata. Keep credentials in the host environment or an untracked .env, and pass them through Compose environment entries. A .env entry alone does not inject the value into the container. Local tags and NFO work without online credentials.
|
||||
Configure TMDB credentials and MusicBrainz contact information in Media settings → Metadata providers. 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).
|
||||
|
||||
|
||||
+11
-28
@@ -13,7 +13,13 @@
|
||||
|
||||
书库只读不影响账号和阅读进度写入数据目录。需要上传时,除修改 `:rw` 外,还要检查宿主机 ACL 或目录权限。
|
||||
|
||||
## 环境变量
|
||||
## 页面业务配置
|
||||
|
||||
管理员在「系统设置 → 服务配置」中管理 HTTP 朗读、扫描、登录有效期、对外地址、浏览器来源及 WebDAV 备份目标。TMDB 与 MusicBrainz 也可在「影音设置 → 元数据刮削」编辑,注册策略沿用「注册与邀请」。配置写入 reader.db,保存后无需重启。详见[业务配置](business-settings.zh-CN.md)。
|
||||
|
||||
升级首次启动会导入旧业务环境变量;数据库存在配置后不再读取这些环境变量,清空字段也不会恢复旧值。
|
||||
|
||||
## 部署环境变量
|
||||
|
||||
以下是直接运行服务端时的默认值。Docker 镜像另设 `DATA_DIR=/data`、`WEB_DIR=/app/web`,`BOOKS_DIR=/books`。
|
||||
|
||||
@@ -23,26 +29,11 @@
|
||||
| `DATA_DIR` | 当前工作目录下的 `data` | 服务端状态和缓存目录 |
|
||||
| `HOST` | `0.0.0.0` | 监听地址 |
|
||||
| `PORT` | `5888` | 监听端口 |
|
||||
| `PUBLIC_URL` | 空 | 对外访问地址,反向代理后设置 |
|
||||
| `SCAN_INTERVAL` | `1800` | 定时扫描间隔,秒;`0` 关闭 |
|
||||
| `WATCH_INTERVAL` | `60` | 变化检查间隔,秒;`0` 关闭 |
|
||||
| `ALLOW_REGISTRATION` | `false` | 首个账号之后的默认注册策略;管理员在「系统设置 → 注册与邀请」保存的策略优先,支持关闭、开放、邀请码注册 |
|
||||
| `ACCESS_TOKEN_TTL` | `604800` | 访问令牌有效期,秒;默认 7 天,客户端在临近过期请求或返回前台时自动续期 |
|
||||
| `REFRESH_TOKEN_TTL` | `31536000` | 刷新令牌有效期,秒 |
|
||||
| `READER_TOKEN_SECRET` | 自动生成 | 至少 16 字符的签名密钥;默认保存在 `DATA_DIR/token.secret` |
|
||||
| `LOG_LEVEL` | `info` | 日志级别 |
|
||||
| `MEDIA_FFPROBE_PATH` | `ffprobe` | 影音技术信息读取程序;缺失时仍可扫描入库,时长、标签及章节信息可能不完整 |
|
||||
| `MEDIA_TMDB_TOKEN` | 空 | TMDB 读取令牌;与 API Key 同时配置时优先使用 |
|
||||
| `MEDIA_TMDB_API_KEY` | 空 | TMDB API Key;令牌与 Key 均为空时关闭在线影视匹配 |
|
||||
| `MEDIA_FFMPEG_PATH` | `ffmpeg` | 内嵌封面与字幕提取程序,不提供转码 |
|
||||
| `MEDIA_MUSICBRAINZ_USER_AGENT` | 空 | MusicBrainz 请求标识,填写应用名称、版本及有效联系地址,例如 `Reader/0.1 (mailto:admin@example.com)`;空值禁用 MusicBrainz 在线匹配 |
|
||||
| `CORS_ORIGINS` | 空 | 允许的浏览器来源,逗号分隔;空值反射请求来源 |
|
||||
| `WEB_DIR` | 当前工作目录下的 `web` | 已构建 Web 客户端目录 |
|
||||
| `TTS_URL` | 空 | HTTP 语音上游地址,空值关闭 HTTP 朗读 |
|
||||
| `TTS_TOKEN` | 空 | 上游 Bearer 令牌 |
|
||||
| `TTS_VOICES_URL` | `<TTS_URL>/voices` | 上游语音列表 |
|
||||
| `TTS_TIMEOUT_MS` | `20000` | 单次语音请求超时,毫秒 |
|
||||
| `TTS_CACHE_BYTES` | `268435456` | `DATA_DIR/tts-cache` 缓存上限,字节;`0` 关闭 |
|
||||
|
||||
修改 Compose 环境变量后执行 `docker compose up -d`。签名密钥需要稳定保存,否则已有会话会失效。数据目录中的账号、进度和笔记不能靠重新扫描书籍恢复。
|
||||
|
||||
@@ -58,21 +49,13 @@ reader.example.com {
|
||||
}
|
||||
```
|
||||
|
||||
设置 `PUBLIC_URL=https://reader.example.com`。如果 Caddy 也在容器中,代理地址应改为容器网络中 reader 的服务地址。
|
||||
在「登录与服务访问」中设置对外地址为 `https://reader.example.com`。如果 Caddy 也在容器中,代理地址应改为容器网络中 reader 的服务地址。
|
||||
|
||||
前后端分开部署时,将 `CORS_ORIGINS` 设置为 Web 客户端的实际来源。书源搜索采用 Streamable HTTP,代理需要持续转发 `text/event-stream`;不要把搜索响应缓冲到完成后才返回。没有公网 IP 时也可用 VPN 或隧道连接服务。
|
||||
前后端分开部署时,在「登录与服务访问」中设置允许的浏览器来源为 Web 客户端的实际来源。书源搜索采用 Streamable HTTP,代理需要持续转发 `text/event-stream`;不要把搜索响应缓冲到完成后才返回。没有公网 IP 时也可用 VPN 或隧道连接服务。
|
||||
|
||||
## HTTP 朗读
|
||||
|
||||
优先使用 Android 系统语音或浏览器 `speechSynthesis`。需要 HTTP 语音时,在 Compose 中配置兼容上游:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
TTS_URL: "http://your-tts-host:5002/tts"
|
||||
# TTS_TOKEN: "your-token"
|
||||
# TTS_VOICES_URL: "http://your-tts-host:5002/voices"
|
||||
TTS_CACHE_BYTES: "268435456"
|
||||
```
|
||||
优先使用 Android 系统语音或浏览器 `speechSynthesis`。需要 HTTP 语音时,进入「系统设置 → 服务配置 → HTTP 朗读」,填写合成接口、认证令牌和可选音色列表地址,启用后检测连接、试听并保存。请求超时和音频缓存上限也在此设置。
|
||||
|
||||
reader 代理语音请求,不内置合成模型。上游接口需兼容项目的请求协议;不能仅凭服务名称判断是否兼容,详见 [TTS API](api.md)。地址必须从 reader 容器内可达,容器内的 `localhost` 指容器自身。
|
||||
|
||||
@@ -103,7 +86,7 @@ reader 代理语音请求,不内置合成模型。上游接口需兼容项目
|
||||
|
||||
官方镜像包含 ffprobe 和 ffmpeg。本机启动时将二者加入 PATH,或设置 MEDIA_FFPROBE_PATH / MEDIA_FFMPEG_PATH 为可执行文件路径。ffprobe 用于时长、编码、标签和章节,ffmpeg 用于内嵌资源提取,均不提供转码。
|
||||
|
||||
在线匹配可配置 MEDIA_TMDB_TOKEN 或 MEDIA_TMDB_API_KEY,音乐资料可配置 MEDIA_MUSICBRAINZ_USER_AGENT。仅在宿主环境或未提交的 .env 中保存凭据,并在 Compose 的 environment 中传入;只有 .env 值、未声明 environment 时不会自动传进容器。本地 NFO 和标签读取不依赖在线凭据。
|
||||
在线匹配在「影音设置 → 元数据刮削」配置 TMDB 令牌或 API Key,以及 MusicBrainz 应用标识与联系地址。可测试连接、调整资料语言并保存,无需重启。本地 NFO 和标签读取不依赖在线凭据。
|
||||
|
||||
OpenList 连接信息在建库界面设置,无需本地挂载;服务端必须能访问 OpenList 和其下载源。远端内嵌资料暂不读取,某些驱动需要 OpenList Web 代理。配置与限制见 [OpenList 接入](media-openlist.md)。
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
本地库优先读取文件标签、NFO 和同目录封面;ffprobe 用于编码、时长及章节探测,ffmpeg 用于部分内嵌资源提取,两者不等于转码功能。OpenList 支持外置 NFO、封面、歌词和字幕,暂不读取远端内嵌标签、封面及章节。
|
||||
|
||||
服务器可配置 MEDIA_TMDB_TOKEN 或 MEDIA_TMDB_API_KEY(同时配置时优先令牌),以及 MEDIA_MUSICBRAINZ_USER_AGENT。管理员在作品菜单中匹配资料,审阅候选后确认;也可在「扫描与刮削」中发起批量任务并重试未完成项。来源网络、凭据或封面下载失败会单独提示。
|
||||
在「影音设置 → 元数据刮削」中配置 TMDB 令牌或 API Key(同时填写时优先令牌),以及 MusicBrainz 应用标识与联系地址,保存后生效。管理员在作品菜单中匹配资料,审阅候选后确认;也可在「扫描与刮削」中发起批量任务并重试未完成项。来源网络、凭据或封面下载失败会单独提示。
|
||||
|
||||
详情的资源信息菜单可查看文件与技术信息、管理版本。手工资料、作品归属、版本拆分或合并会在重扫时保留,不修改原文件。移动版本不转移收藏,也不复制原作品的人工资料;更改剧集、季和单集关系后需重新确认受影响的在线匹配。
|
||||
|
||||
|
||||
@@ -24,22 +24,19 @@
|
||||
|
||||
## 上传一致性备份到 WebDAV
|
||||
|
||||
这是管理员 CLI,上传已完成的备份目录;不直接同步运行中的 DATA_DIR,也不是 Legado 进度互通。先按 [备份指南](backup.zh-CN.md) 停止全部相关服务进程、创建并验证备份。上传阶段可重新启动 Reader,备份目录保持不变。
|
||||
管理员可从「系统设置 → 服务配置 → WebDAV 备份」或 CLI 上传已完成的备份目录;不直接同步运行中的 DATA_DIR,也不是 Legado 进度互通。先按 [备份指南](backup.zh-CN.md) 停止全部相关服务进程、创建并验证备份。上传阶段可重新启动 Reader,备份目录保持不变。
|
||||
|
||||
在编译后的服务端目录运行:
|
||||
|
||||
```sh
|
||||
# 从安全的环境/秘密管理器注入,不要把密码写进仓库或 URL
|
||||
export WEBDAV_URL='https://dav.example.com/reader-backups/'
|
||||
export WEBDAV_USERNAME='backup-user'
|
||||
# WEBDAV_PASSWORD 由运行环境注入
|
||||
# 先在系统设置中保存 WebDAV 目标、账号和密码;DATA_DIR 指向运行实例的数据目录
|
||||
node dist/maintenance/webdav-cli.js /backups/reader-20260924 reader-20260924.zip
|
||||
```
|
||||
|
||||
目标 WebDAV collection 必须已存在,账号需有 HEAD、PUT、GET、MOVE、DELETE 权限。仅接受 HTTPS(loopback 测试允许 HTTP),不跟随重定向转发凭据。原始文件及最终 ZIP 默认限 2 GiB,可通过 `WEBDAV_MAX_BYTES` 设置;ZIP32 上限小于 4 GiB,最多 60000 条目录记录。打包使用本地临时磁盘,服务端需有足够空间。
|
||||
目标 WebDAV collection 必须已存在,账号需有 HEAD、PUT、GET、MOVE、DELETE 权限。仅接受 HTTPS(loopback 测试允许 HTTP),不跟随重定向转发凭据。原始文件及最终 ZIP 默认限 2 GiB,可在 WebDAV 配置页设置上传上限;ZIP32 上限小于 4 GiB,最多 60000 条目录记录。打包使用本地临时磁盘,服务端需有足够空间。
|
||||
|
||||
流程为:验证目录 → 流式 ZIP 打包 → 再次验证目录 → 检查同名目标 → 上传随机临时文件 → 下载并核对大小/SHA-256 → `MOVE` 且 `Overwrite: F` 发布。暂时性 PUT 错误最多尝试三次;身份验证失败、校验失败或最终 MOVE 错误不会强行覆盖。最终发布结果不确定时须核对远端,不自动重试 MOVE。
|
||||
|
||||
取消/失败会尝试删除此次随机临时文件;网络持续故障时可能残留 `.reader-upload-*.zip`,管理员核对后清理。首版不自动删除历史备份、不设定计划任务。密码仅存在调用进程环境,不写入配置和日志。
|
||||
取消/失败会尝试删除此次随机临时文件;网络持续故障时可能残留 `.reader-upload-*.zip`,管理员核对后清理。首版不自动删除历史备份、不设定计划任务。密码保存在实例数据库中,不回显至页面或写入日志;备份包含这些凭据,应限制访问权限。
|
||||
|
||||
恢复时,下载并在新的空目录解压 ZIP(Linux 上保留权限和内部符号链接),运行 `maintenance/cli.js verify`,通过后按备份指南恢复到新的 DATA_DIR。请使用可信工具解压;备份包含账号数据和 token.secret,应使用私有 WebDAV 存储及受控下载权限。
|
||||
@@ -38,6 +38,9 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS business_settings (
|
||||
name TEXT PRIMARY KEY,value TEXT NOT NULL,revision INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS registration_settings (
|
||||
id INTEGER PRIMARY KEY CHECK(id = 1),
|
||||
mode TEXT NOT NULL CHECK(mode IN ('closed','open','invite'))
|
||||
|
||||
@@ -14,8 +14,13 @@ import { registerMediaRoutes } from './routes/media.ts';
|
||||
import {registerMediaProxy} from './routes/media-proxy.ts';
|
||||
import type {MediaService} from '../media/start-runtime.ts';
|
||||
import { isOriginAllowed, resolveCorsOrigin } from './cors.ts';
|
||||
import {BusinessSettings} from '../services/business-settings.ts';
|
||||
import {TtsService} from '../services/tts.ts';
|
||||
import {registerBusinessSettingsRoutes} from './routes/business-settings.ts';
|
||||
|
||||
export function buildApp(ctx: AppContext,mediaRuntime?:MediaService): FastifyInstance {
|
||||
ctx.settings??=new BusinessSettings(ctx.db,ctx.config);
|
||||
ctx.tts=new TtsService(ctx.config,ctx.settings.read('tts'));
|
||||
const app = Fastify({
|
||||
logger: { level: ctx.config.logLevel },
|
||||
// Book bodies are streamed straight from disk, so a JSON limit this small is
|
||||
@@ -64,6 +69,7 @@ export function buildApp(ctx: AppContext,mediaRuntime?:MediaService): FastifyIns
|
||||
app.options('/*', async (_request, reply) => reply.status(204).send());
|
||||
|
||||
registerAuthRoutes(app, ctx);
|
||||
registerBusinessSettingsRoutes(app,ctx);
|
||||
registerLibraryRoutes(app, ctx);
|
||||
registerSyncRoutes(app, ctx);
|
||||
registerTtsRoutes(app, ctx);
|
||||
|
||||
@@ -9,9 +9,12 @@ import type { TtsService } from '../services/tts.ts';
|
||||
import type { BrowseService } from '../services/browse.ts';
|
||||
import type { UploadService } from '../services/uploads.ts';
|
||||
import type { SourceHost } from '../services/source-host.ts';
|
||||
import type { BusinessSettings } from '../services/business-settings.ts';
|
||||
|
||||
export interface AppContext {
|
||||
config: AppConfig;
|
||||
settings?:BusinessSettings;
|
||||
settingsChanged?:(group:string)=>void;
|
||||
db: Db;
|
||||
scanner: Scanner;
|
||||
users: UserService;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import type {FastifyInstance} from 'fastify';
|
||||
import type {AppContext} from '../context.ts';
|
||||
import {authenticate,requireAdmin} from '../auth.ts';
|
||||
import {badRequest,AppError} from '../../lib/errors.ts';
|
||||
import {SETTINGS_SCHEMA,type SettingsGroup} from '../../services/business-settings.ts';
|
||||
import {TtsService} from '../../services/tts.ts';
|
||||
import {MetadataHttp,TmdbProvider,MusicBrainzProvider} from '../../media/metadata-providers.ts';
|
||||
import {uploadBackup} from '../../maintenance/webdav.ts';
|
||||
|
||||
export function registerBusinessSettingsRoutes(app:FastifyInstance,ctx:AppContext){
|
||||
const settings=ctx.settings!,auth=authenticate(ctx),active=new Set<string>();
|
||||
const guard=async(request:Parameters<typeof auth>[0],reply:Parameters<typeof auth>[1])=>{await auth(request,reply);requireAdmin(request);reply.header('cache-control','no-store');};
|
||||
const group=(params:unknown)=>{
|
||||
const name=(params as {group?:string})?.group;
|
||||
if(!name||!Object.hasOwn(SETTINGS_SCHEMA,name))throw badRequest('未知配置分类');return name as SettingsGroup;
|
||||
};
|
||||
app.get('/api/v1/admin/settings',{preHandler:guard},async()=>({groups:(Object.keys(SETTINGS_SCHEMA) as SettingsGroup[]).map(name=>settings.view(name))}));
|
||||
app.patch('/api/v1/admin/settings/:group',{preHandler:guard},async request=>{
|
||||
const name=group(request.params),body=request.body as {values?:unknown;revision?:unknown};
|
||||
if(!body)throw badRequest('缺少配置');
|
||||
// Do not let a CORS edit lock this browser out of administering the instance.
|
||||
if(name==='access'&&request.headers.origin){
|
||||
const next=settings.candidate(name,body.values),origins=next.corsOrigins.split(',').map(value=>value.trim()).filter(Boolean);
|
||||
if(origins.length&&!origins.includes(request.headers.origin))throw badRequest('允许的来源必须包含当前管理页面的来源');
|
||||
}
|
||||
const result=settings.save(name,body.values,body.revision);
|
||||
settings.apply(ctx.config);
|
||||
if(name==='tts')ctx.tts=new TtsService(ctx.config,settings.read('tts'));
|
||||
ctx.settingsChanged?.(name);
|
||||
app.log.info({group:name,actor:request.currentUser!.id,revision:result.revision},'business settings updated');
|
||||
return result;
|
||||
});
|
||||
app.post('/api/v1/admin/settings/:group/test',{preHandler:guard},async request=>{
|
||||
const name=group(request.params),input=(request.body as {values?:unknown})?.values??{};
|
||||
if(active.has(name))throw new AppError(409,'SETTINGS_TEST_BUSY','此配置正在检测,请稍候');
|
||||
active.add(name);const started=Date.now();
|
||||
try{
|
||||
if(name==='tmdb'){
|
||||
const value=settings.candidate('tmdb',input),http=new MetadataHttp(fetch,settings.read('scraping'));
|
||||
await new TmdbProvider(http,value.token,value.apiKey,value.language).search('movie','test');
|
||||
}else if(name==='musicbrainz'){
|
||||
const value=settings.candidate('musicbrainz',input);
|
||||
await new MusicBrainzProvider(new MetadataHttp(fetch,settings.read('scraping')),value.userAgent).search('artist','Mozart');
|
||||
}else if(name==='tts'){
|
||||
const service=new TtsService(ctx.config,settings.candidate('tts',input));
|
||||
const audio=await service.synthesize({text:'这是阅读器的朗读连接测试。'});
|
||||
return {ok:true,elapsedMs:Date.now()-started,bytes:audio.bytes.length,voices:await service.voices()};
|
||||
}else if(name==='webdav'){
|
||||
const value=settings.candidate('webdav',input);
|
||||
if(!value.url||!value.username||!value.password)throw badRequest('请填写备份地址、账号和密码');
|
||||
const response=await fetch(value.url,{method:'PROPFIND',redirect:'error',headers:{Depth:'0',Authorization:'Basic '+Buffer.from(value.username+':'+value.password).toString('base64')},signal:AbortSignal.timeout(15000)});
|
||||
await response.body?.cancel();if(!response.ok)throw badRequest(`WebDAV 连接失败(HTTP ${response.status})`);
|
||||
}else throw badRequest('此分类不需要连接测试');
|
||||
return {ok:true,elapsedMs:Date.now()-started};
|
||||
}catch(error){
|
||||
if(error instanceof AppError)throw error;
|
||||
throw new AppError(502,'SETTINGS_TEST_FAILED','连接测试失败,请检查服务地址、凭据及网络');
|
||||
}finally{active.delete(name);}
|
||||
});
|
||||
app.post('/api/v1/admin/settings/tts/preview',{preHandler:guard},async(request,reply)=>{
|
||||
const body=request.body as {values?:unknown;voice?:string};
|
||||
if(body?.voice!==undefined&&(typeof body.voice!=='string'||body.voice.length>120))throw badRequest('无效的音色');
|
||||
const service=new TtsService(ctx.config,settings.candidate('tts',body?.values??{}));
|
||||
const audio=await service.synthesize({text:'这是阅读器朗读试听。愿你享受阅读的时光。',voice:body?.voice});
|
||||
return reply.type(audio.contentType).send(Buffer.from(audio.bytes));
|
||||
});
|
||||
app.post('/api/v1/admin/settings/tts/clear-cache',{preHandler:guard},async()=>{
|
||||
// Cache cleanup also works when HTTP speech has been disabled.
|
||||
const value=settings.read('tts');new TtsService(ctx.config,{...value,enabled:true,url:value.url||'http://localhost'}).clearCache();
|
||||
return {ok:true};
|
||||
});
|
||||
app.post('/api/v1/admin/settings/webdav/upload',{preHandler:guard},async request=>{
|
||||
const body=request.body as {directory?:unknown;name?:unknown};
|
||||
if(typeof body?.directory!=='string'||!body.directory||body.directory.length>4096||typeof body?.name!=='string')throw badRequest('请输入已验证备份目录和远端文件名');
|
||||
if(active.has('upload'))throw new AppError(409,'BACKUP_BUSY','已有备份正在上传');
|
||||
active.add('upload');
|
||||
try{return await uploadBackup(body.directory,{...settings.read('webdav'),name:body.name});}
|
||||
catch{throw badRequest('备份上传失败,请检查备份完整性、目标权限、配额及同名文件;不会覆盖已有备份');}
|
||||
finally{active.delete('upload');}
|
||||
});
|
||||
}
|
||||
@@ -25,6 +25,8 @@ import { MediaBackgroundGrants } from '../../media/background-grants.ts';
|
||||
import { DatabaseMediaAccounts, MediaAccountReferences } from '../../media/accounts.ts';
|
||||
import type { MediaDatabase } from '../../media/read-database.ts';
|
||||
import type { Db } from '../../db/index.ts';
|
||||
import {BusinessSettingsReader} from '../../services/business-settings.ts';
|
||||
import {configuredProviders} from '../../media/configured-providers.ts';
|
||||
|
||||
export type MediaRouteContext=Pick<AppContext,'config'> & {db:MediaDatabase & Pick<Db,'prepare'>;users:Pick<AppContext['users'],'byId'>};
|
||||
|
||||
@@ -32,11 +34,12 @@ export function registerMediaRoutes(app:FastifyInstance,ctx:MediaRouteContext,op
|
||||
const database=options.database||ctx.db;
|
||||
const accounts=new DatabaseMediaAccounts(ctx.db);
|
||||
const references=database!==ctx.db?new MediaAccountReferences(database,accounts):undefined;
|
||||
const libraries=new MediaLibraries(database,true,accounts,references),scanner=new MediaScanner(database,libraries,undefined,app.log);
|
||||
const settings=new BusinessSettingsReader(ctx.db);
|
||||
const libraries=new MediaLibraries(database,true,accounts,references),scanner=new MediaScanner(database,libraries,undefined,app.log,()=>settings.read('scanning'));
|
||||
const playback=new MediaPlayback(database,libraries,accounts);
|
||||
const background=new MediaBackgroundGrants(database,libraries,accounts);
|
||||
const userState=new MediaUserState(database,libraries,scanner.catalog);
|
||||
const scraping=new MediaScraping(database,scanner.catalog,options.metadataProviders);
|
||||
const scraping=new MediaScraping(database,scanner.catalog,options.metadataProviders??configuredProviders(settings));
|
||||
const hierarchy=new MediaHierarchy(database,scanner.catalog);
|
||||
const scrapeJobs=new MediaScrapeJobs(database,scanner.catalog,scraping,accounts);
|
||||
const artwork=new MediaArtwork(database,libraries);
|
||||
|
||||
@@ -16,7 +16,7 @@ const feedType = 'application/atom+xml;profile=opds-catalog;kind=acquisition';
|
||||
export function registerOpdsRoutes(app: FastifyInstance, ctx: AppContext): void {
|
||||
const credentials = new OpdsCredentials(ctx.db), auth = authenticate(ctx);
|
||||
const formats = allFileHandlers().map(handler => handler.format);
|
||||
const root = `${ctx.config.publicUrl.replace(/\/$/, '')}/opds`;
|
||||
const root = ()=>`${ctx.config.publicUrl.replace(/\/$/, '')}/opds`;
|
||||
const access = async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
try {
|
||||
const id = credentials.authenticate(request.headers.authorization);
|
||||
@@ -30,15 +30,15 @@ export function registerOpdsRoutes(app: FastifyInstance, ctx: AppContext): void
|
||||
}
|
||||
return payload;
|
||||
});
|
||||
app.get('/api/v1/opds/credentials', { preHandler: auth }, async request => ({ credentials: credentials.list(currentUser(request).id), catalogUrl: root }));
|
||||
app.get('/api/v1/opds/credentials', { preHandler: auth }, async request => ({ credentials: credentials.list(currentUser(request).id), catalogUrl: root() }));
|
||||
app.post('/api/v1/opds/credentials', { preHandler: auth }, async (request, reply) => {
|
||||
reply.code(201);
|
||||
return { ...credentials.create(currentUser(request).id, (request.body as { name?: unknown } | null)?.name), catalogUrl: root };
|
||||
return { ...credentials.create(currentUser(request).id, (request.body as { name?: unknown } | null)?.name), catalogUrl: root() };
|
||||
});
|
||||
app.delete('/api/v1/opds/credentials/:id', { preHandler: auth }, async request => {
|
||||
credentials.revoke(currentUser(request).id, (request.params as { id: string }).id); return { ok: true };
|
||||
});
|
||||
app.get('/opds/', { preHandler: access }, async (request, reply) => reply.redirect(root + (request.url.includes('?') ? request.url.slice(request.url.indexOf('?')) : '')));
|
||||
app.get('/opds/', { preHandler: access }, async (request, reply) => reply.redirect(root() + (request.url.includes('?') ? request.url.slice(request.url.indexOf('?')) : '')));
|
||||
app.get('/opds', { preHandler: access }, async (request, reply) => {
|
||||
const query = request.query as { page?: unknown; search?: unknown };
|
||||
const page = query.page === undefined ? 1 : Number(query.page);
|
||||
@@ -47,9 +47,9 @@ export function registerOpdsRoutes(app: FastifyInstance, ctx: AppContext): void
|
||||
const search = query.search as string | undefined;
|
||||
const result = ctx.shelf.list(currentUser(request).id, { page, pageSize: 50, fileFormats: formats, sort: 'title', ...(search ? { search } : {}) });
|
||||
const link = (rel: string, href: string, type = feedType) => `<link rel="${rel}" href="${xml(href)}" ${type ? `type="${type}"` : ''}/>`;
|
||||
const pageUrl = (number: number) => root + '?' + new URLSearchParams({ page: String(number), ...(search ? { search } : {}) });
|
||||
const pageUrl = (number: number) => root() + '?' + new URLSearchParams({ page: String(number), ...(search ? { search } : {}) });
|
||||
const entries = result.items.map(book => {
|
||||
const href = `${root}/books/${encodeURIComponent(book.id)}`;
|
||||
const href = `${root()}/books/${encodeURIComponent(book.id)}`;
|
||||
const file = new FilePublications(ctx.db, ctx.config).context(book.id);
|
||||
return `<entry><id>urn:reader:book:${xml(book.id)}</id><title>${xml(book.title)}</title><updated>${new Date(book.updatedAt).toISOString()}</updated>
|
||||
<author><name>${xml(book.author)}</name></author><summary type="text">${xml(book.description)}</summary>
|
||||
@@ -58,12 +58,12 @@ export function registerOpdsRoutes(app: FastifyInstance, ctx: AppContext): void
|
||||
}).join('');
|
||||
return reply.type(feedType).send(`<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xmlns:opensearch="http://a9.com/-/spec/opensearch/1.1/">
|
||||
<id>urn:reader:opds:${xml(currentUser(request).id)}</id><title>Reader 个人书架</title><updated>${new Date().toISOString()}</updated>
|
||||
${link('self', pageUrl(page))}${link('start', root)}${link('search', root + '/search.xml', 'application/opensearchdescription+xml')}
|
||||
${link('self', pageUrl(page))}${link('start', root())}${link('search', root() + '/search.xml', 'application/opensearchdescription+xml')}
|
||||
${page > 1 ? link('previous', pageUrl(page - 1)) : ''}${page * result.pageSize < result.total ? link('next', pageUrl(page + 1)) : ''}
|
||||
<opensearch:totalResults>${result.total}</opensearch:totalResults><opensearch:startIndex>${(page - 1) * result.pageSize + 1}</opensearch:startIndex><opensearch:itemsPerPage>${result.pageSize}</opensearch:itemsPerPage>${entries}</feed>`);
|
||||
});
|
||||
app.get('/opds/search.xml', { preHandler: access }, async (_request, reply) => reply.type('application/opensearchdescription+xml').send(
|
||||
`<?xml version="1.0" encoding="utf-8"?><OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/"><ShortName>Reader</ShortName><Description>搜索个人书架</Description><Url type="${feedType}" template="${xml(root + '?search={searchTerms}')}"/><InputEncoding>UTF-8</InputEncoding></OpenSearchDescription>`,
|
||||
`<?xml version="1.0" encoding="utf-8"?><OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/"><ShortName>Reader</ShortName><Description>搜索个人书架</Description><Url type="${feedType}" template="${xml(root() + '?search={searchTerms}')}"/><InputEncoding>UTF-8</InputEncoding></OpenSearchDescription>`,
|
||||
));
|
||||
app.get('/opds/books/:id/content', { preHandler: access }, async (request, reply) => {
|
||||
const book = ctx.shelf.get(currentUser(request).id, (request.params as { id: string }).id);
|
||||
|
||||
@@ -64,9 +64,9 @@ export function registerTtsRoutes(app: FastifyInstance, ctx: AppContext): void {
|
||||
|
||||
reply.header('content-type', audio.contentType);
|
||||
reply.header('content-length', String(audio.bytes.byteLength));
|
||||
// Immutable per (text, voice, speed): the same sentence will always be the
|
||||
// same audio, so a second play of it should not reach the server at all.
|
||||
reply.header('cache-control', 'private, max-age=604800, immutable');
|
||||
// The administrator can replace the upstream while the audio URL stays the
|
||||
// same. Reuse the server cache, which includes the upstream configuration.
|
||||
reply.header('cache-control', 'private, no-store');
|
||||
reply.header('x-tts-cache', audio.cached ? 'hit' : 'miss');
|
||||
return reply.send(Buffer.from(audio.bytes));
|
||||
});
|
||||
|
||||
+13
-10
@@ -53,13 +53,18 @@ async function main(): Promise<void> {
|
||||
ctx.users = new UserService(db, config);
|
||||
ctx.shelf = new ShelfService(db);
|
||||
ctx.sync = new SyncService(db, ctx.shelf);
|
||||
ctx.tts = new TtsService(config);
|
||||
ctx.tts = new TtsService(config,ctx.settings!.read('tts'));
|
||||
ctx.browse = new BrowseService(db, config, ctx.shelf);
|
||||
ctx.uploads = new UploadService(db, config, ctx.browse, ctx.scanner);
|
||||
|
||||
app.log.info({ booksDir: config.booksDir, dataDir: config.dataDir }, 'starting reader server');
|
||||
|
||||
const scheduler = startSchedulers(ctx, app.log);
|
||||
let scheduler = startSchedulers(ctx, app.log);
|
||||
ctx.settingsChanged=group=>{
|
||||
if(group!=='scanning')return;
|
||||
if(scheduler.scanTimer)clearInterval(scheduler.scanTimer);
|
||||
scheduler=startSchedulers(ctx,app.log,false);
|
||||
};
|
||||
|
||||
const shutdown = async (signal: string): Promise<void> => {
|
||||
app.log.info({ signal }, 'shutting down');
|
||||
@@ -80,7 +85,7 @@ async function main(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function startSchedulers(ctx: AppContext, log: AppContext['log']): Schedulers {
|
||||
function startSchedulers(ctx: AppContext, log: AppContext['log'],initial=true): Schedulers {
|
||||
const runScan = async (): Promise<void> => {
|
||||
try {
|
||||
await ctx.scanner.scan();
|
||||
@@ -90,19 +95,17 @@ function startSchedulers(ctx: AppContext, log: AppContext['log']): Schedulers {
|
||||
};
|
||||
|
||||
// A scan at boot makes the library available immediately after a restart.
|
||||
void runScan();
|
||||
if(initial)void runScan();
|
||||
|
||||
// The watch poller is the same code path as the interval scan; the mtime+size
|
||||
// fast path makes it cheap enough to run every minute (see Scanner.indexFile).
|
||||
const scanTimer = ctx.config.scanInterval > 0
|
||||
? setInterval(() => void runScan(), ctx.config.scanInterval * 1000)
|
||||
const interval=ctx.settings!.read('scanning').interval;
|
||||
const scanTimer = interval > 0
|
||||
? setInterval(() => void runScan(), interval * 1000)
|
||||
: undefined;
|
||||
scanTimer?.unref();
|
||||
|
||||
const watchTimer = ctx.config.watchInterval > 0
|
||||
? setInterval(() => void runScan(), ctx.config.watchInterval * 1000)
|
||||
: undefined;
|
||||
watchTimer?.unref();
|
||||
const watchTimer = undefined;
|
||||
|
||||
return { scanTimer, watchTimer };
|
||||
}
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { uploadBackup } from './webdav.ts';
|
||||
import {join} from 'node:path';
|
||||
import {loadConfig} from '../config/index.ts';
|
||||
import {MediaReadDatabase} from '../media/read-database.ts';
|
||||
import {BusinessSettingsReader} from '../services/business-settings.ts';
|
||||
|
||||
const controller = new AbortController();
|
||||
process.once('SIGINT', () => controller.abort());
|
||||
process.once('SIGTERM', () => controller.abort());
|
||||
const [directory, name, ...extra] = process.argv.slice(2);
|
||||
if (!directory || !name || extra.length) {
|
||||
console.error('用法:node dist/maintenance/webdav-cli.js <已验证备份目录> <远端名称.zip>\n环境变量:WEBDAV_URL、WEBDAV_USERNAME、WEBDAV_PASSWORD;可选 WEBDAV_MAX_BYTES。');
|
||||
console.error('用法:node dist/maintenance/webdav-cli.js <已验证备份目录> <远端名称.zip>\n使用 DATA_DIR 对应实例在系统设置中保存的 WebDAV 配置。');
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
uploadBackup(directory, { name, url: process.env.WEBDAV_URL ?? '', username: process.env.WEBDAV_USERNAME ?? '',
|
||||
password: process.env.WEBDAV_PASSWORD ?? '', signal: controller.signal,
|
||||
...(process.env.WEBDAV_MAX_BYTES ? { maxBytes: Number(process.env.WEBDAV_MAX_BYTES) } : {}),
|
||||
}).then(result => console.log(JSON.stringify(result, null, 2))).catch(() => {
|
||||
const db=new MediaReadDatabase(join(loadConfig().dataDir,'reader.db'));
|
||||
const settings=new BusinessSettingsReader(db).read('webdav');db.close();
|
||||
uploadBackup(directory, { name,...settings,signal:controller.signal }).then(result => console.log(JSON.stringify(result, null, 2))).catch(() => {
|
||||
// Network exceptions may contain destination URLs; keep credentials and URLs out of logs.
|
||||
console.error(controller.signal.aborted ? '备份上传已取消' : '备份上传失败,请检查备份校验、远端权限、配额、同名文件及 WebDAV 日志。');
|
||||
process.exitCode = 1;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type {BusinessSettingsReader,BusinessValues} from '../services/business-settings.ts';
|
||||
import {MetadataHttp,TmdbProvider,MusicBrainzProvider,type MetadataProvider} from './metadata-providers.ts';
|
||||
|
||||
export function configuredProviders(settings:BusinessSettingsReader){
|
||||
let key='',providers:MetadataProvider[]=[];
|
||||
const limiter={next:0};
|
||||
return ()=>{
|
||||
const tmdb=settings.read('tmdb'),musicbrainz=settings.read('musicbrainz'),options=settings.read('scraping');
|
||||
const next=JSON.stringify([tmdb,musicbrainz,options]);
|
||||
if(next!==key){key=next;providers=createProviders(tmdb,musicbrainz,new MetadataHttp(fetch,options,limiter));}
|
||||
return providers;
|
||||
};
|
||||
}
|
||||
export function createProviders(tmdb:BusinessValues['tmdb'],musicbrainz:BusinessValues['musicbrainz'],http:MetadataHttp){
|
||||
return [new TmdbProvider(http,tmdb.enabled?tmdb.token:'',tmdb.enabled?tmdb.apiKey:'',tmdb.language),
|
||||
new MusicBrainzProvider(http,musicbrainz.enabled?musicbrainz.userAgent:'')];
|
||||
}
|
||||
@@ -35,25 +35,24 @@ const unavailable = () => new AppError(502, 'MEDIA_PROVIDER_UNAVAILABLE', '刮
|
||||
|
||||
/** Only provider-owned fixed origins are accepted; never follow redirects with credentials. */
|
||||
export class MetadataHttp {
|
||||
private musicBrainzNext = 0;
|
||||
constructor(private readonly fetcher: typeof fetch = fetch) {}
|
||||
constructor(private readonly fetcher: typeof fetch = fetch,private readonly options={timeoutMs:12000,retries:2},private readonly limiter={next:0}) {}
|
||||
|
||||
async json(url: URL, headers: Record<string, string>, signal?:AbortSignal): Promise<JsonObject> {
|
||||
if (!['https://api.themoviedb.org', 'https://musicbrainz.org'].includes(url.origin)) throw badRequest('invalid provider origin');
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
for (let attempt = 0; attempt <= this.options.retries; attempt++) {
|
||||
signal?.throwIfAborted();
|
||||
if (url.hostname === 'musicbrainz.org') {
|
||||
// Reserve before awaiting so concurrent callers obey MusicBrainz's one-request/second limit.
|
||||
const now = Date.now(), scheduled = Math.max(now, this.musicBrainzNext);
|
||||
const now = Date.now(), scheduled = Math.max(now, this.limiter.next);
|
||||
if (scheduled - now > 10_000) throw new AppError(429, 'MEDIA_PROVIDER_BUSY', '刮削请求较多,请稍后重试');
|
||||
this.musicBrainzNext = scheduled + 1100;
|
||||
this.limiter.next = scheduled + 1100;
|
||||
if (scheduled > now) await delay(scheduled - now,undefined,{signal});
|
||||
}
|
||||
try {
|
||||
const response = await this.fetcher(url, { headers, redirect: 'error', signal: signal?AbortSignal.any([signal,AbortSignal.timeout(12_000)]):AbortSignal.timeout(12_000) });
|
||||
const response = await this.fetcher(url, { headers, redirect: 'error', signal: signal?AbortSignal.any([signal,AbortSignal.timeout(this.options.timeoutMs)]):AbortSignal.timeout(this.options.timeoutMs) });
|
||||
if (response.status === 429 || response.status >= 500) {
|
||||
await response.body?.cancel();
|
||||
if (attempt === 2) throw unavailable();
|
||||
if (attempt === this.options.retries) throw unavailable();
|
||||
const retry = Number(response.headers.get('retry-after'));
|
||||
if (Number.isFinite(retry) && retry > 5) throw unavailable();
|
||||
await delay(Math.max((attempt + 1) * 500, Number.isFinite(retry) ? retry * 1000 : 0),undefined,{signal});
|
||||
@@ -83,7 +82,7 @@ export class MetadataHttp {
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted();
|
||||
if (error instanceof AppError) throw error;
|
||||
if (attempt === 2) throw unavailable();
|
||||
if (attempt === this.options.retries) throw unavailable();
|
||||
await delay((attempt + 1) * 500,undefined,{signal});
|
||||
}
|
||||
}
|
||||
@@ -96,15 +95,15 @@ export class TmdbProvider implements MetadataProvider {
|
||||
readonly label = 'TMDB';
|
||||
readonly kinds: MediaItemKind[] = ['movie', 'series', 'season', 'episode'];
|
||||
get configured() { return !!(this.token || this.apiKey); }
|
||||
constructor(private readonly http: MetadataHttp, private readonly token = process.env.MEDIA_TMDB_TOKEN || '', private readonly apiKey = process.env.MEDIA_TMDB_API_KEY || '') {}
|
||||
constructor(private readonly http: MetadataHttp, private readonly token = '', private readonly apiKey = '',private readonly language='zh-CN') {}
|
||||
private endpoint(kind: MediaItemKind): string {
|
||||
if (!this.configured) throw badRequest('请先配置 MEDIA_TMDB_TOKEN 或 MEDIA_TMDB_API_KEY', 'MEDIA_PROVIDER_NOT_CONFIGURED');
|
||||
if (!this.configured) throw badRequest('请先在影音设置中配置 TMDB', 'MEDIA_PROVIDER_NOT_CONFIGURED');
|
||||
if (!this.kinds.includes(kind)) throw badRequest('TMDB 不支持此条目类型');
|
||||
return kind === 'movie' ? 'movie' : 'tv';
|
||||
}
|
||||
private request(path: string, query?: string, signal?:AbortSignal) {
|
||||
const url = new URL('https://api.themoviedb.org/3/' + path);
|
||||
url.searchParams.set('language', 'zh-CN');
|
||||
url.searchParams.set('language', this.language);
|
||||
if (query) url.searchParams.set('query', query);
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
if (this.token) headers.Authorization = 'Bearer ' + this.token;
|
||||
@@ -150,9 +149,9 @@ export class MusicBrainzProvider implements MetadataProvider {
|
||||
readonly label = 'MusicBrainz';
|
||||
readonly kinds: MediaItemKind[] = ['artist', 'album', 'track', 'audiobook'];
|
||||
get configured() { return !!this.userAgent; }
|
||||
constructor(private readonly http: MetadataHttp, private readonly userAgent = process.env.MEDIA_MUSICBRAINZ_USER_AGENT || '') {}
|
||||
constructor(private readonly http: MetadataHttp, private readonly userAgent = '') {}
|
||||
private entity(kind: MediaItemKind): string {
|
||||
if (!this.configured) throw badRequest('请先配置包含联系地址的 MEDIA_MUSICBRAINZ_USER_AGENT', 'MEDIA_PROVIDER_NOT_CONFIGURED');
|
||||
if (!this.configured) throw badRequest('请先在影音设置中配置 MusicBrainz 联系信息', 'MEDIA_PROVIDER_NOT_CONFIGURED');
|
||||
if (!this.kinds.includes(kind)) throw badRequest('MusicBrainz 不支持此条目类型');
|
||||
return kind === 'artist' ? 'artist' : kind === 'track' ? 'recording' : 'release-group';
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ export class MediaScanner {
|
||||
private running=0;
|
||||
private pending:Array<()=>void>=[];
|
||||
private readonly traces=new Map<string,ScanTrace>();
|
||||
constructor(db:MediaDatabase & Pick<Db,'prepare'>,private readonly libraries:MediaLibraries,private readonly probe:MediaProbe=probeMedia,private readonly logger?:ScanLogger) {
|
||||
constructor(db:MediaDatabase & Pick<Db,'prepare'>,private readonly libraries:MediaLibraries,private readonly probe:MediaProbe=probeMedia,private readonly logger?:ScanLogger,private readonly settings=()=>({libraries:2,files:4})) {
|
||||
this.db=new MediaStatementDatabase(db);
|
||||
db.transaction(()=>{
|
||||
db.run(`CREATE TABLE IF NOT EXISTS media_assets (
|
||||
@@ -67,13 +67,13 @@ export class MediaScanner {
|
||||
if(actor.role!=='admin')throw forbidden('admin role required','ADMIN_REQUIRED');
|
||||
this.libraries.get(actor,libraryId);
|
||||
if(this.active.has(libraryId))throw conflict('media scan already running','SCAN_RUNNING');
|
||||
const id=randomUUID(),controller=new AbortController();
|
||||
const id=randomUUID(),controller=new AbortController(),options=this.settings();
|
||||
this.db.run("INSERT INTO media_scan_jobs(id,library_id,state,started_at) VALUES(?,?,'queued',?)",id,libraryId,Date.now());
|
||||
let finish!:()=>void;const done=new Promise<void>(resolve=>{finish=resolve;});
|
||||
const execute=()=>{
|
||||
entry.cancelQueued=undefined;this.running++;
|
||||
this.db.run("UPDATE media_scan_jobs SET state='running' WHERE id=?",id);
|
||||
void this.scan(actor,libraryId,id,controller.signal).finally(()=>{this.active.delete(libraryId);this.running--;finish();this.drain();});
|
||||
void this.scan(actor,libraryId,id,controller.signal,options.files).finally(()=>{this.active.delete(libraryId);this.running--;finish();this.drain();});
|
||||
};
|
||||
const entry:{controller:AbortController;done:Promise<void>;cancelQueued:(()=>void)|undefined}={controller,done,cancelQueued:()=>{
|
||||
this.pending=this.pending.filter(task=>task!==execute);this.active.delete(libraryId);
|
||||
@@ -83,7 +83,7 @@ export class MediaScanner {
|
||||
return this.job(actor,id);
|
||||
}
|
||||
|
||||
private drain(){while(this.running<2&&this.pending.length)this.pending.shift()!();}
|
||||
private drain(){while(this.running<this.settings().libraries&&this.pending.length)this.pending.shift()!();}
|
||||
startAll(actor:MediaActor){
|
||||
if(actor.role!=='admin')throw forbidden('admin role required','ADMIN_REQUIRED');
|
||||
const items:MediaScanJob[]=[],skipped:string[]=[];
|
||||
@@ -142,7 +142,7 @@ export class MediaScanner {
|
||||
probe:{status:row.probe_status,info:row.technical_json?JSON.parse(row.technical_json):null}};
|
||||
}
|
||||
|
||||
private async scan(actor:MediaActor,libraryId:string,jobId:string,signal:AbortSignal):Promise<void> {
|
||||
private async scan(actor:MediaActor,libraryId:string,jobId:string,signal:AbortSignal,files=4):Promise<void> {
|
||||
const trace=new ScanTrace(jobId,libraryId,this.logger);this.traces.set(jobId,trace);trace.note('开始扫描');
|
||||
const heartbeat=setInterval(()=>{
|
||||
trace.note('扫描进度');
|
||||
@@ -180,7 +180,7 @@ export class MediaScanner {
|
||||
};
|
||||
// Remote sidecar reads are I/O bound. Keep local ffprobe sequential and drain
|
||||
// every batch before failure/cleanup so no worker writes after staging is removed.
|
||||
const concurrency=storage.filePath?1:4,iterator=storage.list(signal)[Symbol.asyncIterator]();
|
||||
const concurrency=storage.filePath?1:files,iterator=storage.list(signal)[Symbol.asyncIterator]();
|
||||
let batch:StorageEntry[]=[];
|
||||
const flush=async()=>{
|
||||
const results=await Promise.allSettled(batch.map(inspect));batch=[];
|
||||
|
||||
@@ -79,13 +79,14 @@ export class MediaScrapeJobs {
|
||||
async wait(id:string){await this.active.get(id)?.done;}
|
||||
async close(){const jobs=[...this.active.values()];jobs.forEach(job=>job.controller.abort());await Promise.all(jobs.map(job=>job.done));}
|
||||
private async run(actor:MediaActor,id:string,provider:string,items:string[],signal:AbortSignal){
|
||||
const scraping=this.scraping.snapshot();
|
||||
try{
|
||||
for(const item of items){
|
||||
signal.throwIfAborted();
|
||||
const user=this.accounts.get(actor.id);
|
||||
if(!user||user.disabled||user.role!=='admin')throw new Error('actor-unavailable');
|
||||
this.db.run("UPDATE media_scrape_job_items SET state='running' WHERE job_id=? AND item_id=?",id,item);
|
||||
try{const result=await this.scraping.autoMatch(actor,item,provider,signal,()=>{const current=this.accounts.get(actor.id);if(!current||current.disabled||current.role!=='admin')throw new Error('actor-unavailable');});signal.throwIfAborted();this.db.run('UPDATE media_scrape_job_items SET state=? WHERE job_id=? AND item_id=?',result.status,id,item);}
|
||||
try{const result=await scraping.autoMatch(actor,item,provider,signal,()=>{const current=this.accounts.get(actor.id);if(!current||current.disabled||current.role!=='admin')throw new Error('actor-unavailable');});signal.throwIfAborted();this.db.run('UPDATE media_scrape_job_items SET state=? WHERE job_id=? AND item_id=?',result.status,id,item);}
|
||||
catch(error){if(signal.aborted)throw error;this.db.run("UPDATE media_scrape_job_items SET state='failed',error=? WHERE job_id=? AND item_id=?",'匹配失败,请重新审阅或重试',id,item);}
|
||||
}
|
||||
this.db.run("UPDATE media_scrape_jobs SET state='complete' WHERE id=?",id);
|
||||
|
||||
@@ -12,10 +12,12 @@ interface CandidateRow { id: string; item_id: string; actor_id: string; provider
|
||||
|
||||
/** Search stores reviewable candidates. Only a confirmed, item-bound candidate may publish metadata. */
|
||||
export class MediaScraping {
|
||||
private readonly providers: MetadataProvider[];
|
||||
constructor(private readonly db:MediaDatabase, private readonly catalog: MediaCatalog, providers?: MetadataProvider[]) {
|
||||
private readonly source:MetadataProvider[]|(()=>MetadataProvider[]);
|
||||
private get providers(){return typeof this.source==='function'?this.source():this.source;}
|
||||
snapshot(){return new MediaScraping(this.db,this.catalog,this.providers);}
|
||||
constructor(private readonly db:MediaDatabase, private readonly catalog: MediaCatalog, providers?: MetadataProvider[]|(()=>MetadataProvider[])) {
|
||||
const http = new MetadataHttp();
|
||||
this.providers = providers ?? [new TmdbProvider(http), new MusicBrainzProvider(http)];
|
||||
this.source = providers ?? [new TmdbProvider(http), new MusicBrainzProvider(http)];
|
||||
db.run(`CREATE TABLE IF NOT EXISTS media_scrape_candidates (
|
||||
id TEXT PRIMARY KEY,item_id TEXT NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
|
||||
actor_id TEXT NOT NULL,provider TEXT NOT NULL,external_id TEXT NOT NULL,expires_at INTEGER NOT NULL)`);
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { Db } from '../db/index.ts';
|
||||
import type { AppConfig } from '../config/index.ts';
|
||||
import { badRequest, conflict } from '../lib/errors.ts';
|
||||
|
||||
export interface BusinessValues {
|
||||
tmdb:{enabled:boolean;token:string;apiKey:string;language:string};
|
||||
musicbrainz:{enabled:boolean;userAgent:string};
|
||||
tts:{enabled:boolean;url:string;token:string;voicesUrl:string;timeoutMs:number;cacheMaxBytes:number};
|
||||
scanning:{interval:number;libraries:number;files:number};
|
||||
scraping:{timeoutMs:number;retries:number};
|
||||
access:{publicUrl:string;corsOrigins:string;accessTokenTtl:number;refreshTokenTtl:number};
|
||||
webdav:{url:string;username:string;password:string;maxBytes:number};
|
||||
}
|
||||
export type SettingsGroup=keyof BusinessValues;
|
||||
type Field={key:string;label:string;type:'text'|'password'|'number'|'checkbox'|'url';default:string|number|boolean;min?:number;max?:number;help?:string};
|
||||
export const SETTINGS_SCHEMA:Record<SettingsGroup,{label:string;fields:Field[]}>= {
|
||||
tmdb:{label:'TMDB',fields:[
|
||||
{key:'enabled',label:'启用 TMDB',type:'checkbox',default:false},
|
||||
{key:'token',label:'读取令牌',type:'password',default:''},
|
||||
{key:'apiKey',label:'API Key(未填令牌时使用)',type:'password',default:''},
|
||||
{key:'language',label:'资料语言',type:'text',default:'zh-CN',help:'例如 zh-CN、zh-TW、en-US'},
|
||||
]},
|
||||
musicbrainz:{label:'MusicBrainz',fields:[
|
||||
{key:'enabled',label:'启用 MusicBrainz',type:'checkbox',default:false},
|
||||
{key:'userAgent',label:'应用标识与联系地址',type:'text',default:'',help:'例如 Reader/0.1 (mailto:admin@example.com)'},
|
||||
]},
|
||||
tts:{label:'HTTP 朗读',fields:[
|
||||
{key:'enabled',label:'启用 HTTP 朗读',type:'checkbox',default:false},
|
||||
{key:'url',label:'语音合成接口',type:'url',default:'',help:'兼容 text、voice、speed 查询参数;地址须能从服务器访问'},
|
||||
{key:'token',label:'认证令牌',type:'password',default:''},
|
||||
{key:'voicesUrl',label:'音色列表接口(可选)',type:'url',default:''},
|
||||
{key:'timeoutMs',label:'请求超时(毫秒)',type:'number',default:20000,min:100,max:120000},
|
||||
{key:'cacheMaxBytes',label:'音频缓存上限(字节,0 关闭)',type:'number',default:268435456,min:0,max:10737418240},
|
||||
]},
|
||||
scanning:{label:'扫描设置',fields:[
|
||||
{key:'interval',label:'阅读书库自动检查间隔(秒,0 关闭)',type:'number',default:60,min:0,max:604800,help:'统一原定时扫描与变化检查;保存后重新计时'},
|
||||
{key:'libraries',label:'同时扫描的媒体库数',type:'number',default:2,min:1,max:4},
|
||||
{key:'files',label:'远端文件处理并发数',type:'number',default:4,min:1,max:8},
|
||||
]},
|
||||
scraping:{label:'刮削高级设置',fields:[
|
||||
{key:'timeoutMs',label:'请求超时(毫秒)',type:'number',default:12000,min:1000,max:120000},
|
||||
{key:'retries',label:'失败重试次数',type:'number',default:2,min:0,max:3},
|
||||
]},
|
||||
access:{label:'登录与服务访问',fields:[
|
||||
{key:'publicUrl',label:'对外服务地址',type:'url',default:''},
|
||||
{key:'corsOrigins',label:'允许的浏览器来源',type:'text',default:'',help:'多个来源以逗号分隔;留空允许任意来源'},
|
||||
{key:'accessTokenTtl',label:'访问令牌有效期(秒)',type:'number',default:604800,min:300,max:31536000},
|
||||
{key:'refreshTokenTtl',label:'登录续期有效期(秒)',type:'number',default:31536000,min:3600,max:157680000},
|
||||
]},
|
||||
webdav:{label:'WebDAV 备份',fields:[
|
||||
{key:'url',label:'备份目录地址',type:'url',default:'',help:'使用 HTTPS;仅本机调试允许 HTTP'},
|
||||
{key:'username',label:'账号',type:'text',default:''},
|
||||
{key:'password',label:'密码',type:'password',default:''},
|
||||
{key:'maxBytes',label:'单次上传上限(字节)',type:'number',default:2147483648,min:1,max:4294967295},
|
||||
]},
|
||||
};
|
||||
export function settingsDefaults<K extends SettingsGroup>(group:K):BusinessValues[K]{
|
||||
return Object.fromEntries(SETTINGS_SCHEMA[group].fields.map(field=>[field.key,field.default])) as BusinessValues[K];
|
||||
}
|
||||
export class BusinessSettingsReader {
|
||||
constructor(protected readonly db:Pick<Db,'get'>){}
|
||||
read<K extends SettingsGroup>(group:K):BusinessValues[K]{
|
||||
const row=this.db.get<{value:string}>('SELECT value FROM business_settings WHERE name=?',group);
|
||||
return {...settingsDefaults(group),...(row?JSON.parse(row.value):{})};
|
||||
}
|
||||
}
|
||||
export class BusinessSettings extends BusinessSettingsReader {
|
||||
constructor(private readonly database:Db,config:AppConfig){
|
||||
super(database);
|
||||
database.transaction(()=>{
|
||||
for(const group of Object.keys(SETTINGS_SCHEMA) as SettingsGroup[]){
|
||||
if(!database.get('SELECT name FROM business_settings WHERE name=?',group)){
|
||||
const initial=legacySettings(group,config);
|
||||
database.run('INSERT INTO business_settings(name,value,revision) VALUES(?,?,1)',group,JSON.stringify(initial));
|
||||
}
|
||||
}
|
||||
});
|
||||
this.apply(config);
|
||||
}
|
||||
apply(config:AppConfig){
|
||||
const access=this.read('access');
|
||||
Object.assign(config,{publicUrl:access.publicUrl,corsOrigins:access.corsOrigins.split(',').map(value=>value.trim()).filter(Boolean),accessTokenTtl:access.accessTokenTtl,refreshTokenTtl:access.refreshTokenTtl});
|
||||
}
|
||||
view(group:SettingsGroup){
|
||||
const values={...this.read(group)} as Record<string,unknown>,secrets:Record<string,boolean>={};
|
||||
for(const field of SETTINGS_SCHEMA[group].fields)if(field.type==='password'){secrets[field.key]=!!values[field.key];delete values[field.key];}
|
||||
return {group,...SETTINGS_SCHEMA[group],values,secrets,revision:this.database.get<{revision:number}>('SELECT revision FROM business_settings WHERE name=?',group)!.revision};
|
||||
}
|
||||
candidate<K extends SettingsGroup>(group:K,input:unknown):BusinessValues[K]{
|
||||
if(!input||typeof input!=='object'||Array.isArray(input))throw badRequest('配置必须是对象');
|
||||
const patch=input as Record<string,unknown>,values={...this.read(group)} as Record<string,unknown>;
|
||||
for(const [key,value] of Object.entries(patch)){
|
||||
const field=SETTINGS_SCHEMA[group].fields.find(field=>field.key===key);
|
||||
if(!field)throw badRequest('未知配置项');
|
||||
if(field.type==='checkbox'){if(typeof value!=='boolean')throw badRequest(`${field.label}格式无效`);}
|
||||
else if(field.type==='number'){if(typeof value!=='number'||!Number.isSafeInteger(value)||value<field.min!||value>field.max!)throw badRequest(`${field.label}须在 ${field.min}–${field.max} 之间`);}
|
||||
else if(typeof value!=='string'||value.length>4096||/[\u0000-\u001f\u007f]/.test(value))throw badRequest(`${field.label}格式无效`);
|
||||
values[key]=typeof value==='string'&&field.type!=='password'?value.trim():value;
|
||||
if(field.type==='url'&&values[key])validateUrl(String(values[key]));
|
||||
}
|
||||
if(group==='tmdb'){
|
||||
if(values.enabled&&!values.token&&!values.apiKey)throw badRequest('启用 TMDB 前请填写令牌或 API Key');
|
||||
if(!/^[a-z]{2,3}(?:-[a-zA-Z]{2,4})?$/.test(String(values.language)))throw badRequest('无效的资料语言');
|
||||
}
|
||||
if(group==='musicbrainz'&&values.enabled&&!/\S+\/\S+.*(?:@|https?:\/\/)/.test(String(values.userAgent)))throw badRequest('请填写应用名称/版本及有效联系地址');
|
||||
if(group==='tts'&&values.enabled&&!values.url)throw badRequest('启用 HTTP 朗读前请填写接口地址');
|
||||
if(group==='access'){
|
||||
for(const origin of String(values.corsOrigins).split(',').map(value=>value.trim()).filter(Boolean))if(origin!=='null'&&validateUrl(origin).origin!==origin)throw badRequest('浏览器来源须为协议与域名(可含端口),不能包含路径');
|
||||
if(Number(values.refreshTokenTtl)<Number(values.accessTokenTtl))throw badRequest('登录续期有效期不能小于访问令牌有效期');
|
||||
}
|
||||
if(group==='webdav'&&values.url){
|
||||
const url=validateUrl(String(values.url));
|
||||
if(url.search||url.hash||(url.protocol!=='https:'&&!['127.0.0.1','[::1]','localhost'].includes(url.hostname)))throw badRequest('WebDAV 必须使用 HTTPS 目录地址,且不能包含查询参数');
|
||||
if(String(values.username).includes(':'))throw badRequest('WebDAV 账号不能包含冒号');
|
||||
}
|
||||
return values as unknown as BusinessValues[K];
|
||||
}
|
||||
save(group:SettingsGroup,input:unknown,revision:unknown){
|
||||
return this.database.transaction(()=>{
|
||||
const current=this.view(group);
|
||||
if(revision!==current.revision)throw conflict('配置已被其他管理员修改,请重新加载后保存','SETTINGS_CONFLICT');
|
||||
const values=this.candidate(group,input);
|
||||
this.database.run('UPDATE business_settings SET value=?,revision=revision+1 WHERE name=?',JSON.stringify(values),group);
|
||||
return this.view(group);
|
||||
});
|
||||
}
|
||||
}
|
||||
function validateUrl(value:string){
|
||||
let url:URL;try{url=new URL(value);}catch{throw badRequest('请输入有效的 HTTP 或 HTTPS 地址');}
|
||||
if(!['http:','https:'].includes(url.protocol)||url.username||url.password||url.hash)throw badRequest('地址不能包含账号、密码、片段或其他协议');
|
||||
return url;
|
||||
}
|
||||
/** Called only for missing database rows: clearing a page field never revives env values. */
|
||||
function legacySettings<K extends SettingsGroup>(group:K,config:AppConfig):BusinessValues[K]{
|
||||
const env=process.env,text=(name:string)=>env[name]?.trim()||'';
|
||||
const integer=(name:string,fallback:number)=>{const value=Number(env[name]);return env[name]&&Number.isSafeInteger(value)&&value>=0?value:fallback;};
|
||||
const intervals=[config.scanInterval,config.watchInterval].filter(value=>value>0);
|
||||
const legacy:Partial<BusinessValues>={
|
||||
tmdb:{enabled:!!(text('MEDIA_TMDB_TOKEN')||text('MEDIA_TMDB_API_KEY')),token:text('MEDIA_TMDB_TOKEN'),apiKey:text('MEDIA_TMDB_API_KEY'),language:'zh-CN'},
|
||||
musicbrainz:{enabled:!!text('MEDIA_MUSICBRAINZ_USER_AGENT'),userAgent:text('MEDIA_MUSICBRAINZ_USER_AGENT')},
|
||||
tts:{enabled:!!text('TTS_URL'),url:text('TTS_URL'),token:text('TTS_TOKEN'),voicesUrl:text('TTS_VOICES_URL'),timeoutMs:integer('TTS_TIMEOUT_MS',20000),cacheMaxBytes:integer('TTS_CACHE_BYTES',268435456)},
|
||||
scanning:{interval:intervals.length?Math.min(...intervals):0,libraries:2,files:4},
|
||||
access:{publicUrl:config.publicUrl,corsOrigins:config.corsOrigins.join(','),accessTokenTtl:config.accessTokenTtl,refreshTokenTtl:config.refreshTokenTtl},
|
||||
webdav:{url:text('WEBDAV_URL'),username:text('WEBDAV_USERNAME'),password:text('WEBDAV_PASSWORD'),maxBytes:integer('WEBDAV_MAX_BYTES',2147483648)},
|
||||
};
|
||||
const result=(legacy[group]??settingsDefaults(group)) as unknown as Record<string,unknown>;
|
||||
for(const field of SETTINGS_SCHEMA[group].fields){
|
||||
if(field.type==='number'&&(!Number.isSafeInteger(result[field.key])||Number(result[field.key])<field.min!||Number(result[field.key])>field.max!))result[field.key]=field.default;
|
||||
}
|
||||
return result as unknown as BusinessValues[K];
|
||||
}
|
||||
@@ -6,10 +6,12 @@ export type RegistrationMode = 'closed' | 'open' | 'invite';
|
||||
const digest = (code: string) => createHash('sha256').update(code.trim()).digest('hex');
|
||||
|
||||
export class RegistrationService {
|
||||
constructor(private readonly db: Db) {}
|
||||
constructor(private readonly db: Db) {
|
||||
db.run('INSERT OR IGNORE INTO registration_settings(id,mode) VALUES(1,?)',process.env.ALLOW_REGISTRATION==='true'?'open':'closed');
|
||||
}
|
||||
mode(): RegistrationMode {
|
||||
return this.db.get<{mode:RegistrationMode}>('SELECT mode FROM registration_settings WHERE id = 1')?.mode
|
||||
?? (process.env.ALLOW_REGISTRATION === 'true' ? 'open' : 'closed');
|
||||
?? 'closed';
|
||||
}
|
||||
setMode(mode: unknown): void {
|
||||
if (mode !== 'closed' && mode !== 'open' && mode !== 'invite') throw badRequest('无效的注册模式');
|
||||
|
||||
+21
-12
@@ -13,6 +13,7 @@ import {
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import {settingsDefaults,type BusinessValues} from './business-settings.ts';
|
||||
|
||||
/**
|
||||
* Server-side speech synthesis, for a client that has no engine of its own.
|
||||
@@ -45,6 +46,7 @@ const MAX_TEXT_LENGTH = 800;
|
||||
|
||||
/** What the client is told this instance supports. */
|
||||
export interface TtsCapabilities {
|
||||
timeoutMs:number;
|
||||
/** True when `TTS_URL` is configured, i.e. this instance can synthesise. */
|
||||
http: boolean;
|
||||
/** Content types the proxy may answer with, for the client's own checks. */
|
||||
@@ -86,19 +88,19 @@ export class TtsService {
|
||||
private cacheBytes = 0;
|
||||
private cacheFiles: string[] = [];
|
||||
|
||||
constructor(config: AppConfig) {
|
||||
const url = (process.env.TTS_URL ?? '').trim().replace(/\/+$/, '');
|
||||
this.config = url
|
||||
constructor(config: AppConfig,input:BusinessValues['tts']=settingsDefaults('tts')) {
|
||||
const url = input.url.trim().replace(/\/+$/, '');
|
||||
this.config = input.enabled&&url
|
||||
? {
|
||||
url,
|
||||
token: (process.env.TTS_TOKEN ?? '').trim(),
|
||||
voicesUrl: (process.env.TTS_VOICES_URL ?? '').trim(),
|
||||
timeoutMs: Number.parseInt(process.env.TTS_TIMEOUT_MS ?? '20000', 10) || 20_000,
|
||||
token: input.token,
|
||||
voicesUrl: input.voicesUrl,
|
||||
timeoutMs: input.timeoutMs,
|
||||
cacheDir: join(config.dataDir, 'tts-cache'),
|
||||
cacheMaxBytes: Number.parseInt(process.env.TTS_CACHE_BYTES ?? String(256 * 1024 * 1024), 10) || 0,
|
||||
cacheMaxBytes: input.cacheMaxBytes,
|
||||
}
|
||||
: null;
|
||||
if (this.config) this.loadCacheIndex();
|
||||
if (this.config) {this.loadCacheIndex();if(this.config.cacheMaxBytes>0)this.evict();}
|
||||
}
|
||||
|
||||
/** Whether this instance can synthesise at all. */
|
||||
@@ -108,6 +110,7 @@ export class TtsService {
|
||||
|
||||
capabilities(): TtsCapabilities {
|
||||
return {
|
||||
timeoutMs:this.config?.timeoutMs??20000,
|
||||
http: this.enabled,
|
||||
formats: ['audio/mpeg', 'audio/wav', 'audio/ogg', 'audio/mp4'],
|
||||
maxLength: MAX_TEXT_LENGTH,
|
||||
@@ -130,7 +133,7 @@ export class TtsService {
|
||||
}): Promise<{ bytes: Uint8Array; contentType: string; cached: boolean }> {
|
||||
const config = this.config;
|
||||
if (!config) {
|
||||
throw badRequest('this instance has no HTTP TTS engine configured (set TTS_URL)', 'TTS_DISABLED');
|
||||
throw badRequest('请管理员在系统设置中配置 HTTP 朗读', 'TTS_DISABLED');
|
||||
}
|
||||
const text = (input.text ?? '').trim();
|
||||
if (!text) throw badRequest('text is required');
|
||||
@@ -142,7 +145,7 @@ export class TtsService {
|
||||
const speed = input.speed === undefined ? 1 : clamp(input.speed, 0.25, 4);
|
||||
const voice = (input.voice ?? '').slice(0, 120);
|
||||
|
||||
const key = hashKey({ text, voice, speed, format: input.format ?? '', url: config.url });
|
||||
const key = hashKey({ text, voice, speed, format: input.format ?? '', url: config.url,credential:hashKey({token:config.token}) });
|
||||
const cached = this.readCache(key);
|
||||
if (cached) return { ...cached, cached: true };
|
||||
|
||||
@@ -247,7 +250,7 @@ export class TtsService {
|
||||
|
||||
private readCache(key: string): { bytes: Uint8Array; contentType: string } | null {
|
||||
const config = this.config;
|
||||
if (!config) return null;
|
||||
if (!config||config.cacheMaxBytes<=0) return null;
|
||||
const meta = join(config.cacheDir, `${key}.meta`);
|
||||
const data = join(config.cacheDir, `${key}.bin`);
|
||||
try {
|
||||
@@ -303,6 +306,12 @@ export class TtsService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clearCache():void {
|
||||
if(!this.config)return;
|
||||
for(const name of readdirSync(this.config.cacheDir))if(/^[a-f0-9]{32}\.(bin|meta)$/.test(name))unlinkSync(join(this.config.cacheDir,name));
|
||||
this.loadCacheIndex();
|
||||
}
|
||||
}
|
||||
|
||||
function normaliseVoice(row: unknown): TtsVoice | null {
|
||||
@@ -329,5 +338,5 @@ function clamp(value: number, min: number, max: number): number {
|
||||
|
||||
/** Wired in `main.ts`; kept out of `AppContext` so tests can build one freely. */
|
||||
export function createTtsService(ctx: AppContext): TtsService {
|
||||
return new TtsService(ctx.config);
|
||||
return new TtsService(ctx.config,ctx.settings?.read('tts'));
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {mkdtemp,rm} from 'node:fs/promises';
|
||||
import {join} from 'node:path';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {createServer} from 'node:http';
|
||||
import {Db} from '../src/db/index.ts';
|
||||
import type {AppConfig} from '../src/config/index.ts';
|
||||
import type {AppContext} from '../src/http/context.ts';
|
||||
import {BusinessSettings,BusinessSettingsReader} from '../src/services/business-settings.ts';
|
||||
import {UserService} from '../src/services/users.ts';
|
||||
import {buildApp} from '../src/http/app.ts';
|
||||
import {signAccessToken} from '../src/services/tokens.ts';
|
||||
import {configuredProviders} from '../src/media/configured-providers.ts';
|
||||
import {MediaReadDatabase} from '../src/media/read-database.ts';
|
||||
import {RegistrationService} from '../src/services/registration.ts';
|
||||
|
||||
function config(root:string):AppConfig{return {booksDir:root,dataDir:root,host:'127.0.0.1',port:0,jwtSecret:'settings-test-secret-at-least-16',accessTokenTtl:604800,refreshTokenTtl:31536000,scanInterval:1800,watchInterval:60,logLevel:'silent',publicUrl:'',corsOrigins:[],webDir:join(root,'web')};}
|
||||
|
||||
test('settings migrate once, redact secrets, reject stale edits and are visible to read-only media connections',async t=>{
|
||||
const root=await mkdtemp(join(tmpdir(),'business-settings-')),path=join(root,'reader.db');
|
||||
t.after(()=>rm(root,{recursive:true,force:true}));
|
||||
const old=process.env.MEDIA_TMDB_TOKEN;process.env.MEDIA_TMDB_TOKEN='legacy-secret';
|
||||
t.after(()=>{if(old===undefined)delete process.env.MEDIA_TMDB_TOKEN;else process.env.MEDIA_TMDB_TOKEN=old;});
|
||||
const db=new Db(path),settings=new BusinessSettings(db,config(root)),view=settings.view('tmdb');
|
||||
assert.equal(settings.read('tmdb').token,'legacy-secret');assert.equal(view.secrets.token,true);
|
||||
assert.ok(!JSON.stringify(view).includes('legacy-secret'));assert.equal(view.values.token,undefined);
|
||||
const remote=new MediaReadDatabase(path),reader=new BusinessSettingsReader(remote),providers=configuredProviders(reader);
|
||||
const running=providers();assert.equal(running[0]!.configured,true);
|
||||
settings.save('tmdb',{enabled:false,token:'',apiKey:''},view.revision);
|
||||
assert.equal(providers()[0]!.configured,false);assert.equal(running[0]!.configured,true,'existing job keeps its provider snapshot');
|
||||
assert.throws(()=>settings.save('tmdb',{enabled:true},view.revision),{code:'SETTINGS_CONFLICT'});
|
||||
assert.throws(()=>settings.save('scanning',{files:999},settings.view('scanning').revision),{statusCode:400});
|
||||
assert.equal(settings.read('scanning').interval,60);
|
||||
remote.close();db.close();
|
||||
const reopened=new Db(path);try{const next=new BusinessSettings(reopened,config(root));assert.equal(next.read('tmdb').token,'');assert.equal(next.read('tmdb').enabled,false);}finally{reopened.close();}
|
||||
});
|
||||
|
||||
test('admin API changes HTTP speech and OPDS live, preserves secrets, and blocks member writes',async t=>{
|
||||
const root=await mkdtemp(join(tmpdir(),'business-api-')),db=new Db(':memory:'),cfg=config(root);
|
||||
let authHeader='',calls=0;
|
||||
const upstream=createServer((req,res)=>{
|
||||
authHeader=String(req.headers.authorization||'');calls++;
|
||||
if(req.url?.startsWith('/voices')){res.setHeader('content-type','application/json');res.end('[{"id":"zh","name":"中文"}]');}
|
||||
else{res.setHeader('content-type','audio/mpeg');res.end('audio');}
|
||||
});
|
||||
await new Promise<void>(resolve=>upstream.listen(0,'127.0.0.1',resolve));
|
||||
const origin='http://127.0.0.1:'+(upstream.address() as {port:number}).port;
|
||||
const ctx={config:cfg,db,users:new UserService(db,cfg)} as AppContext;
|
||||
for(const [id,role] of [['admin','admin'],['member','member']])db.run('INSERT INTO users(id,username,display_name,password_hash,role,created_at,updated_at) VALUES(?,?,?,?,?,0,0)',id!,id!,id!,'unused',role!);
|
||||
const app=buildApp(ctx);await app.ready();
|
||||
t.after(async()=>{await app.close();db.close();upstream.closeAllConnections();await new Promise<void>(resolve=>upstream.close(()=>resolve()));await rm(root,{recursive:true,force:true});});
|
||||
const admin={authorization:'Bearer '+signAccessToken(cfg,{id:'admin',role:'admin'}).token},member={authorization:'Bearer '+signAccessToken(cfg,{id:'member',role:'member'}).token};
|
||||
assert.equal((await app.inject({url:'/api/v1/admin/settings'})).statusCode,401);
|
||||
assert.equal((await app.inject({url:'/api/v1/admin/settings',headers:member})).statusCode,403);
|
||||
assert.equal((await app.inject({method:'PATCH',url:'/api/v1/admin/settings/tts',headers:member,payload:{}})).statusCode,403);
|
||||
const save=async(group:string,values:object,headers:Record<string,string>=admin)=>app.inject({method:'PATCH',url:'/api/v1/admin/settings/'+group,headers,payload:{values,revision:ctx.settings!.view(group as 'tts').revision}});
|
||||
const saved=await save('tts',{enabled:true,url:origin+'/tts',voicesUrl:origin+'/voices',token:'private-token',cacheMaxBytes:0});
|
||||
assert.equal(saved.statusCode,200,saved.body);assert.ok(!saved.body.includes('private-token'));
|
||||
const voices=await app.inject({url:'/api/v1/tts/voices',headers:admin});assert.equal(voices.json().http,true);assert.equal(voices.json().voices[0].id,'zh');
|
||||
const audio=await app.inject({method:'POST',url:'/api/v1/tts/test',headers:admin,payload:{}});assert.equal(audio.statusCode,200,audio.body);assert.equal(authHeader,'Bearer private-token');
|
||||
const count=calls;await app.inject({method:'POST',url:'/api/v1/tts/test',headers:admin,payload:{}});assert.ok(calls>count,'cache disabled really bypasses existing audio');
|
||||
await save('tts',{timeoutMs:1000});assert.equal(ctx.settings!.read('tts').token,'private-token');
|
||||
assert.equal((await save('tts',{enabled:false,token:''})).statusCode,200);
|
||||
assert.equal((await app.inject({url:'/api/v1/tts/voices',headers:admin})).json().http,false);
|
||||
assert.equal((await save('access',{publicUrl:'https://books.example'})).statusCode,200);
|
||||
assert.equal((await app.inject({url:'/api/v1/opds/credentials',headers:admin})).json().catalogUrl,'https://books.example/opds');
|
||||
const locked=await save('access',{corsOrigins:'https://other.example'},{...admin,origin:'https://books.example'});
|
||||
assert.equal(locked.statusCode,400);assert.equal(ctx.config.corsOrigins.length,0);
|
||||
});
|
||||
|
||||
test('registration stores legacy default once instead of falling back after later env changes',()=>{
|
||||
const db=new Db(':memory:'),old=process.env.ALLOW_REGISTRATION;
|
||||
try{process.env.ALLOW_REGISTRATION='true';const service=new RegistrationService(db);assert.equal(service.mode(),'open');service.setMode('closed');assert.equal(new RegistrationService(db).mode(),'closed');}
|
||||
finally{db.close();if(old===undefined)delete process.env.ALLOW_REGISTRATION;else process.env.ALLOW_REGISTRATION=old;}
|
||||
});
|
||||
@@ -32,6 +32,7 @@ import { UserService } from '../src/services/users.ts';
|
||||
import { ShelfService } from '../src/services/shelf.ts';
|
||||
import { SyncService } from '../src/services/sync.ts';
|
||||
import { TtsService } from '../src/services/tts.ts';
|
||||
import {BusinessSettings} from '../src/services/business-settings.ts';
|
||||
import { BrowseService } from '../src/services/browse.ts';
|
||||
import { buildApp } from '../src/http/app.ts';
|
||||
import type { AppContext } from '../src/http/context.ts';
|
||||
@@ -91,6 +92,9 @@ function startUpstream(): Promise<void> {
|
||||
async function buildInstance(): Promise<void> {
|
||||
const config = loadConfig();
|
||||
const db = openDatabase(config);
|
||||
// Each case supplies its desired fixture explicitly; production imports env only once.
|
||||
const settings=new BusinessSettings(db,config);
|
||||
settings.save('tts',{enabled:!!process.env.TTS_URL,url:process.env.TTS_URL||'',voicesUrl:process.env.TTS_VOICES_URL||'',cacheMaxBytes:0},settings.view('tts').revision);
|
||||
ctx = {
|
||||
config,
|
||||
db,
|
||||
@@ -294,7 +298,7 @@ test('diagnostic endpoint synthesizes audio and requires header authentication',
|
||||
for (const [behaviour,code] of [['empty','TTS_EMPTY'],['auth','TTS_AUTH'],['slow','TTS_TIMEOUT']] as const) {
|
||||
test('diagnosis classifies ' + behaviour, async () => {
|
||||
upstreamBehaviour = behaviour;
|
||||
if (behaviour === 'slow') { process.env.TTS_TIMEOUT_MS = '30'; ctx.tts = new TtsService(ctx.config); }
|
||||
if (behaviour === 'slow') { ctx.tts = new TtsService(ctx.config,{...ctx.settings!.read('tts'),timeoutMs:30}); }
|
||||
try { const result = await app.inject({method:'POST',url:'/api/v1/tts/test',headers:{authorization:'Bearer '+token},payload:{}}); assert.equal(result.statusCode,400,result.body); assert.equal(result.json().error.code,code); }
|
||||
finally { delete process.env.TTS_TIMEOUT_MS; }
|
||||
});
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface SessionStore {
|
||||
* engines may I offer here", not "does this endpoint exist".
|
||||
*/
|
||||
export interface TtsCapabilities {
|
||||
timeoutMs?:number;
|
||||
http: boolean;
|
||||
formats: string[];
|
||||
maxLength: number;
|
||||
@@ -707,6 +708,14 @@ export class ReaderApi {
|
||||
return this.call<T>(path, method, body, options);
|
||||
}
|
||||
|
||||
businessSettingsRequest<T>(path='',method='GET',body?:unknown,signal?:AbortSignal):Promise<T>{
|
||||
return this.call<T>('/api/v1/admin/settings'+(path?'/'+path:''),method,body,signal?{signal}:{});
|
||||
}
|
||||
async businessTtsPreview(values:Record<string,unknown>,voice:string):Promise<Blob>{
|
||||
const result=await this.request('/api/v1/admin/settings/tts/preview','POST',{values,voice},{binary:true});
|
||||
return new Blob([new Uint8Array(result.bytes??[]).buffer],{type:result.headers['content-type']||'audio/mpeg'});
|
||||
}
|
||||
|
||||
private async get<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const response = await this.request(path, 'GET', undefined, options);
|
||||
return response.json as T;
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface MediaAccount {username:string;displayName:string;role:'admin'|'
|
||||
|
||||
export class MediaApi {
|
||||
constructor(private readonly reader:ReaderApi) {}
|
||||
businessSettingsRequest<T>(path='',method='GET',body?:unknown,signal?:AbortSignal){return this.reader.businessSettingsRequest<T>(path,method,body,signal);}
|
||||
businessTtsPreview(values:Record<string,unknown>,voice:string){return this.reader.businessTtsPreview(values,voice);}
|
||||
preferenceScope(){return JSON.stringify([this.reader.baseUrl,this.reader.currentSession()?.user.id??'anonymous']);}
|
||||
accountInfo():MediaAccount|null {const user=this.reader.currentSession()?.user;return user?{username:user.username,displayName:user.displayName,role:user.role,server:this.reader.baseUrl}:null;}
|
||||
request<T>(path:string,method='GET',body?:unknown,signal?:AbortSignal):Promise<T> {
|
||||
|
||||
@@ -3,9 +3,10 @@ import {ChevronRight,FileText,Search} from 'lucide-preact';
|
||||
import type {MediaApi} from './api.ts';
|
||||
import {MediaLoading} from './loading.tsx';
|
||||
import {MediaScreenError} from './screen-error.tsx';
|
||||
import {BusinessSettings} from '../ui/business-settings.tsx';
|
||||
|
||||
interface Provider {id:string;label:string;kinds:string[];configured:boolean}
|
||||
const kinds:Record<string,string>={movie:'电影',series:'剧集',season:'季',episode:'单集',album:'专辑',track:'曲目',audiobook:'有声书'};
|
||||
const kinds:Record<string,string>={movie:'电影',series:'剧集',season:'季',episode:'单集',artist:'艺人',album:'专辑',track:'曲目',audiobook:'有声书'};
|
||||
export function MediaProviderSettings({api}:{api:MediaApi}){
|
||||
const [providers,setProviders]=useState<Provider[]>([]),[loading,setLoading]=useState(true),[error,setError]=useState(''),[retry,setRetry]=useState(0),[selected,setSelected]=useState('');
|
||||
const [cause,setCause]=useState<unknown>();
|
||||
@@ -17,7 +18,7 @@ export function MediaProviderSettings({api}:{api:MediaApi}){
|
||||
{!loading&&!error&&providers.map(value=><button key={value.id} className="media-provider-row" aria-expanded={selected===value.id} onClick={()=>setSelected(selected===value.id?'':value.id)}><Search size={20} aria-hidden="true"/><span><strong>{value.label}</strong><small>{value.kinds.map(kind=>kinds[kind]||kind).join('、')}</small></span><small className="media-status-badge" data-configured={value.configured}>{value.configured?'已配置':'未配置'}</small><ChevronRight size={16} aria-hidden="true"/></button>)}
|
||||
{!loading&&!error&&!providers.length&&<p>服务器尚未提供在线元数据来源。</p>}
|
||||
{provider&&!loading&&!error&&<section className="media-provider-note" aria-label={provider.label+'配置说明'}><h3>{provider.label}</h3><p>{provider.configured?'服务器已配置此来源,可以从作品详情搜索候选,或从扫描与刮削页面发起匹配。':'此来源尚未配置,配置完成后才能搜索和匹配元数据。'}</p>
|
||||
{provider.id==='tmdb'?<p>在服务器启动环境设置 <code>MEDIA_TMDB_TOKEN</code> 或 <code>MEDIA_TMDB_API_KEY</code>,重启服务后生效。凭据由服务器保存,此处不显示凭据内容。</p>:provider.id==='musicbrainz'?<p>在服务器启动环境设置包含应用名称和联系地址的 <code>MEDIA_MUSICBRAINZ_USER_AGENT</code>,重启服务后生效。使用专辑、艺人与曲目标签搜索,低置信度结果需要人工确认。</p>:<p>配置方式由服务器提供此来源的适配器决定。</p>}
|
||||
{['tmdb','musicbrainz'].includes(provider.id)?<BusinessSettings api={api} group={provider.id} onSaved={()=>setRetry(value=>value+1)}/>:<p>配置方式由服务器提供此来源的适配器决定。</p>}
|
||||
<button onClick={()=>{setSelected('');setRetry(value=>value+1);}}>刷新配置状态</button></section>}
|
||||
<button className="media-provider-row" aria-expanded={selected==='local'} onClick={()=>setSelected(selected==='local'?'':'local')}><FileText size={20} aria-hidden="true"/><span><strong>本地资料</strong><small>NFO、内嵌标签、同目录图片</small></span><small className="media-status-badge">内置</small><ChevronRight size={16} aria-hidden="true"/></button>
|
||||
{selected==='local'&&<p className="media-provider-note">扫描时读取本地资料,人工覆盖优先于资料源。在线匹配不会修改原始媒体文件。</p>}
|
||||
|
||||
@@ -115,6 +115,7 @@ export const SPEECH_ENGINE_LABELS: Record<Exclude<SpeechEngineKind, 'auto'>, str
|
||||
};
|
||||
|
||||
export interface SpeechFactoryOptions {
|
||||
httpTimeoutMs?:()=>number;
|
||||
kind: Exclude<SpeechEngineKind, 'auto'>;
|
||||
baseUrl: string;
|
||||
accessToken: () => string | null;
|
||||
@@ -154,6 +155,7 @@ export function createSpeechEngine(options: SpeechFactoryOptions): SpeechEngine
|
||||
});
|
||||
case 'http': {
|
||||
const engine = new HttpTtsEngine({
|
||||
...(options.httpTimeoutMs?{requestTimeoutMs:options.httpTimeoutMs}:{}),
|
||||
baseUrl: options.baseUrl,
|
||||
accessToken: options.accessToken,
|
||||
...(options.onError ? { onError: options.onError } : {}),
|
||||
|
||||
@@ -52,6 +52,7 @@ export interface HttpTtsCapabilities {
|
||||
}
|
||||
|
||||
export interface HttpTtsOptions {
|
||||
requestTimeoutMs?:()=>number;
|
||||
/** Absolute URL of the server, e.g. `http://192.168.1.10:8080`. */
|
||||
baseUrl: string;
|
||||
/** Bearer token; sent as a query parameter, see the note above. */
|
||||
@@ -339,7 +340,7 @@ export class HttpTtsEngine {
|
||||
this.timeout = null;
|
||||
if (generation !== this.generation) return;
|
||||
if (this.state === 'playing') this.speakNext();
|
||||
}, REQUEST_TIMEOUT_MS);
|
||||
}, Math.max(1000,Math.min(125000,this.options.requestTimeoutMs?.()??REQUEST_TIMEOUT_MS)));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
.business-settings { min-width: 0; }
|
||||
.business-settings .business-setting-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-height: 44px;
|
||||
}
|
||||
.business-settings .business-setting-toggle input[type=checkbox] {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
min-height: 22px;
|
||||
margin: 0;
|
||||
flex: 0 0 22px;
|
||||
accent-color: var(--reader-accent);
|
||||
}
|
||||
.business-settings .business-secret-clear { justify-self: start; font-size: 12px; }
|
||||
.business-settings audio { width: 100%; }
|
||||
.business-settings .muted { line-height: 1.6; }
|
||||
@@ -0,0 +1,67 @@
|
||||
import {useEffect,useState} from './vendor/preact.ts';
|
||||
import {FloatingNotice} from './floating-notice.tsx';
|
||||
import {FloatingConfirm} from './floating-confirm.tsx';
|
||||
import '../styles/business-settings.css';
|
||||
|
||||
export interface SettingsTransport {
|
||||
businessSettingsRequest<T>(path?:string,method?:string,body?:unknown,signal?:AbortSignal):Promise<T>;
|
||||
businessTtsPreview(values:Record<string,unknown>,voice:string):Promise<Blob>;
|
||||
}
|
||||
interface SettingsView {
|
||||
group:string;label:string;revision:number;values:Record<string,string|number|boolean>;secrets:Record<string,boolean>;
|
||||
fields:Array<{key:string;label:string;type:'text'|'password'|'number'|'checkbox'|'url';min?:number;max?:number;help?:string}>;
|
||||
}
|
||||
export function BusinessSettings({api,group,onSaved}:{api:SettingsTransport;group?:string;onSaved?:()=>void}){
|
||||
const [groups,setGroups]=useState<SettingsView[]>([]),[selected,setSelected]=useState(group||'tts');
|
||||
const [values,setValues]=useState<Record<string,string|number|boolean>>({}),[busy,setBusy]=useState(false),[error,setError]=useState(''),[message,setMessage]=useState(''),[retry,setRetry]=useState(0);
|
||||
const [voices,setVoices]=useState<Array<{id:string;name:string}>>([]),[voice,setVoice]=useState(''),[audio,setAudio]=useState('');
|
||||
const [directory,setDirectory]=useState(''),[name,setName]=useState('reader-backup.zip'),[clear,setClear]=useState(false);
|
||||
useEffect(()=>{
|
||||
const controller=new AbortController();setBusy(true);setError('');
|
||||
void api.businessSettingsRequest<{groups:SettingsView[]}>('','GET',undefined,controller.signal).then(result=>{if(!controller.signal.aborted)setGroups(result.groups);}).catch(error=>{if(!controller.signal.aborted)setError(error instanceof Error?error.message:'读取配置失败');}).finally(()=>{if(!controller.signal.aborted)setBusy(false);});
|
||||
return ()=>controller.abort();
|
||||
},[api,retry]);
|
||||
useEffect(()=>{if(group)setSelected(group);},[group]);
|
||||
const current=groups.find(item=>item.group===selected);
|
||||
useEffect(()=>{setValues(current?.values||{});setVoices([]);setVoice('');setAudio('');},[current]);
|
||||
useEffect(()=>()=>{if(audio)URL.revokeObjectURL(audio);},[audio]);
|
||||
async function work(action:()=>Promise<void>){if(busy)return;setBusy(true);setError('');setMessage('');try{await action();}catch(error){setError(error instanceof Error?error.message:'操作失败');}finally{setBusy(false);}}
|
||||
return <section className="business-settings" aria-label={current?.label||'业务配置'}>
|
||||
<FloatingNotice message={busy?'正在处理…':error||message} busy={busy} error={!!error} kind="success"/>
|
||||
{!group&&<label className="user-manager-mode">配置分类<select aria-label="配置分类" value={selected} disabled={busy} onChange={event=>setSelected(event.currentTarget.value)}>{groups.map(item=><option key={item.group} value={item.group}>{item.label}</option>)}</select></label>}
|
||||
{!current&&!busy&&<button onClick={()=>setRetry(value=>value+1)}>重新读取配置</button>}
|
||||
{current&&<>
|
||||
<p className="muted">保存后生效,无需重启。进行中的任务保留原配置,新任务使用更新后的配置。</p>
|
||||
<form className="user-manager-form" onSubmit={event=>{event.preventDefault();void work(async()=>{
|
||||
const result=await api.businessSettingsRequest<SettingsView>(selected,'PATCH',{values,revision:current.revision});
|
||||
setGroups(items=>items.map(item=>item.group===selected?result:item));setMessage('配置已保存');onSaved?.();
|
||||
});}}>
|
||||
{current.fields.map(field=><label key={field.key} className={field.type==='checkbox'?'business-setting-toggle':undefined}>{field.label}
|
||||
{field.type==='checkbox'?<input type="checkbox" checked={!!values[field.key]} disabled={busy} onChange={event=>setValues({...values,[field.key]:event.currentTarget.checked})}/>:
|
||||
<input aria-label={field.label} type={field.type} value={String(values[field.key]??'')} disabled={busy} min={field.min} max={field.max} step={field.type==='number'?1:undefined} maxLength={4096} autoComplete={field.type==='password'?'new-password':'off'} placeholder={field.type==='password'&¤t.secrets[field.key]?'已保存,留空不修改':undefined} onInput={event=>setValues({...values,[field.key]:field.type==='number'?Number(event.currentTarget.value):event.currentTarget.value})}/>}
|
||||
{field.help&&<small className="muted">{field.help}</small>}
|
||||
{field.type==='password'&&<><small className="muted">{values[field.key]===''?'保存时将清除凭据':current.secrets[field.key]?'已有凭据,仅替换时填写':'尚未配置凭据'}</small><button className="button business-secret-clear" type="button" disabled={busy} onClick={()=>setValues({...values,[field.key]:''})}>清除{field.label}</button></>}
|
||||
</label>)}
|
||||
<div className="user-manager-actions"><button className="button primary" disabled={busy}>保存配置</button>
|
||||
{['tmdb','musicbrainz','tts','webdav'].includes(selected)&&<button type="button" className="button" disabled={busy} onClick={()=>void work(async()=>{
|
||||
const result=await api.businessSettingsRequest<{elapsedMs:number;voices?:Array<{id:string;name:string}>}>(selected+'/test','POST',{values});setVoices(result.voices||[]);setMessage(`连接成功,耗时 ${result.elapsedMs} 毫秒(尚未保存表单)`);
|
||||
})}>{selected==='tts'?'检测连接并获取音色':'测试连接'}</button>}
|
||||
<button type="button" className="button" disabled={busy} onClick={()=>setRetry(value=>value+1)}>重新加载</button>
|
||||
</div>
|
||||
</form>
|
||||
{selected==='tts'&&<div className="user-manager-form"><label>试听音色<input list="business-tts-voices" value={voice} onInput={event=>setVoice(event.currentTarget.value)}/><datalist id="business-tts-voices">{voices.map(item=><option key={item.id} value={item.id}>{item.name}</option>)}</datalist></label>
|
||||
<button className="button" disabled={busy} onClick={()=>void work(async()=>{setAudio(URL.createObjectURL(await api.businessTtsPreview(values,voice)));setMessage('试听音频已就绪');})}>生成试听音频</button>
|
||||
{audio&&<audio controls src={audio} aria-label="HTTP 朗读试听"/>}
|
||||
<button className="button" disabled={busy} onClick={()=>setClear(true)}>清理音频缓存</button>
|
||||
</div>}
|
||||
{clear&&<FloatingConfirm text="清理服务器音频缓存?下次朗读将重新合成。" onCancel={()=>setClear(false)} onConfirm={()=>{setClear(false);void work(async()=>{await api.businessSettingsRequest('tts/clear-cache','POST');setMessage('音频缓存已清理');});}}/>}
|
||||
{selected==='webdav'&&<details><summary>上传已验证的离线备份</summary><p>先停服制作完整备份,再启动服务上传。这里不会在线复制运行中的数据库。目录须能从服务器内访问;上传使用已保存的 WebDAV 配置。</p>
|
||||
<form className="user-manager-form" onSubmit={event=>{event.preventDefault();void work(async()=>{await api.businessSettingsRequest('webdav/upload','POST',{directory,name});setMessage('备份已上传并验证');});}}>
|
||||
<label>服务器上的备份目录<input required value={directory} onInput={event=>setDirectory(event.currentTarget.value)}/></label>
|
||||
<label>远端文件名(.zip)<input required pattern="[a-zA-Z0-9][a-zA-Z0-9._-]*\.zip" value={name} onInput={event=>setName(event.currentTarget.value)}/></label>
|
||||
<button className="button" disabled={busy}>验证并上传备份</button>
|
||||
</form>
|
||||
</details>}
|
||||
</>}
|
||||
</section>;
|
||||
}
|
||||
@@ -610,7 +610,7 @@ function SpeechSettings({ state, handlers }: { state: ChromeState; handlers: Chr
|
||||
if (state.speechUnavailable) {
|
||||
return (
|
||||
<div className="notice">
|
||||
当前环境没有可用的朗读引擎:浏览器不支持语音合成,且服务端未配置 TTS_URL。在服务端设置 TTS_URL 后即可使用 HTTP 朗读。
|
||||
当前环境没有可用的朗读引擎。请管理员在「系统设置 → 服务配置 → HTTP 朗读」配置语音服务,或启用设备的系统语音。
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -154,8 +154,8 @@ export class ReaderScreen {
|
||||
private lastSpeechQueue: SpokenChunk[] = [];
|
||||
/** What the server said it can synthesise. */
|
||||
private httpTtsAvailable = false;
|
||||
private httpTtsTimeoutMs=20000;
|
||||
/** Probed once per screen; a server's capabilities do not change mid-book. */
|
||||
private httpProbed = false;
|
||||
/** Voices the current engine reports, for the panel's select. */
|
||||
private voices: Array<{ id: string; name: string; lang: string; default: boolean }> = [];
|
||||
|
||||
@@ -1309,12 +1309,8 @@ export class ReaderScreen {
|
||||
// Re-read the voice list on every open: a Bluetooth headset paired while the
|
||||
// book was open adds a voice, and Chrome only reports it asynchronously.
|
||||
this.refreshVoices();
|
||||
// The server's capability answer is cached after the first probe, so this is
|
||||
// a no-op on every subsequent open.
|
||||
if (!this.httpProbed) {
|
||||
this.httpProbed = true;
|
||||
void this.probeHttpTts();
|
||||
}
|
||||
// Administrators can change HTTP speech without restarting this reader.
|
||||
void this.probeHttpTts();
|
||||
}
|
||||
|
||||
private refreshVoices(): void {
|
||||
@@ -1335,6 +1331,7 @@ export class ReaderScreen {
|
||||
try {
|
||||
const capabilities = await this.options.api.ttsCapabilities();
|
||||
this.httpTtsAvailable = capabilities?.http === true;
|
||||
this.httpTtsTimeoutMs=(capabilities?.timeoutMs??20000)+5000;
|
||||
} catch {
|
||||
this.httpTtsAvailable = false;
|
||||
}
|
||||
@@ -1711,6 +1708,7 @@ export class ReaderScreen {
|
||||
if (!kind) return null;
|
||||
|
||||
const engine = createSpeechEngine({
|
||||
httpTimeoutMs:()=>this.httpTtsTimeoutMs,
|
||||
kind,
|
||||
baseUrl: this.options.api.baseUrl,
|
||||
accessToken: () => this.options.api.currentSession()?.accessToken ?? null,
|
||||
|
||||
@@ -4,10 +4,11 @@ import type { ManagedUser, RegistrationSettings } from '../api/types.ts';
|
||||
import { Modal } from './modal.tsx';
|
||||
import { FloatingConfirm } from './floating-confirm.tsx';
|
||||
import { FloatingNotice } from './floating-notice.tsx';
|
||||
import {BusinessSettings} from './business-settings.tsx';
|
||||
|
||||
export function SystemSettings({api,onClose}:{api:ReaderApi;onClose():void}) {
|
||||
const [users,setUsers]=useState<ManagedUser[]>([]),[settings,setSettings]=useState<RegistrationSettings|null>(null);
|
||||
const [tab,setTab]=useState<'users'|'registration'>('users'),[busy,setBusy]=useState(false),[error,setError]=useState(''),[message,setMessage]=useState('');
|
||||
const [tab,setTab]=useState<'users'|'registration'|'business'>('users'),[busy,setBusy]=useState(false),[error,setError]=useState(''),[message,setMessage]=useState('');
|
||||
const [form,setForm]=useState(false),[username,setUsername]=useState(''),[password,setPassword]=useState(''),[displayName,setDisplayName]=useState(''),[role,setRole]=useState<'admin'|'member'>('member');
|
||||
const [reset,setReset]=useState<ManagedUser|null>(null),[confirmation,setConfirmation]=useState<{text:string;run:()=>Promise<unknown>}|null>(null);
|
||||
const [label,setLabel]=useState(''),[maxUses,setMaxUses]=useState(1),[days,setDays]=useState(7),[code,setCode]=useState('');
|
||||
@@ -17,7 +18,7 @@ export function SystemSettings({api,onClose}:{api:ReaderApi;onClose():void}) {
|
||||
useEffect(()=>{void work(async()=>{},'');},[]);
|
||||
const self=api.currentSession()?.user.id;
|
||||
return <Modal title="系统设置" busy={busy} onClose={onClose}><div className="user-management source-modal-content">
|
||||
<nav className="extension-tabs" aria-label="系统设置分类"><button aria-pressed={tab==='users'} onClick={()=>setTab('users')}>用户管理</button><button aria-pressed={tab==='registration'} onClick={()=>setTab('registration')}>注册与邀请</button></nav>
|
||||
<nav className="extension-tabs" aria-label="系统设置分类"><button aria-pressed={tab==='users'} onClick={()=>setTab('users')}>用户管理</button><button aria-pressed={tab==='registration'} onClick={()=>setTab('registration')}>注册与邀请</button><button aria-pressed={tab==='business'} onClick={()=>setTab('business')}>服务配置</button></nav>
|
||||
<FloatingNotice message={error || message} error={!!error} kind="success" />
|
||||
{confirmation && <FloatingConfirm text={confirmation.text} onCancel={()=>setConfirmation(null)} onConfirm={()=>{setConfirmation(null);void work(confirmation.run);}} />}
|
||||
{tab==='users' ? <>
|
||||
@@ -35,7 +36,7 @@ export function SystemSettings({api,onClose}:{api:ReaderApi;onClose():void}) {
|
||||
<p>{user.role==='admin'?'管理员':'普通用户'} · {user.disabled?'已停用':'正常'}</p>
|
||||
{user.id!==self && <div className="user-manager-actions"><button className="button" disabled={busy} onClick={()=>setConfirmation({text:`确定${user.disabled?'启用':'停用'} ${user.username}?${user.disabled?'':'旧会话将立即失效。'}`,run:()=>api.adminUpdateUser(user.id,{disabled:!user.disabled})})}>{user.disabled?'启用':'停用'}</button><button className="button" disabled={busy} onClick={()=>setConfirmation({text:`将 ${user.username} ${user.role==='admin'?'改为普通用户':'设为管理员(可管理全部用户及实例设置)'}?`,run:()=>api.adminUpdateUser(user.id,{role:user.role==='admin'?'member':'admin'})})}>{user.role==='admin'?'移除管理员':'设为管理员'}</button><button className="button" disabled={busy} onClick={()=>{setReset(user);setForm(false);setPassword('');}}>重置密码</button></div>}
|
||||
</li>)}</ul>
|
||||
</> : settings && <>
|
||||
</> : tab==='business'?<BusinessSettings api={api}/>:settings && <>
|
||||
<label className="user-manager-mode">注册方式<select aria-label="注册方式" value={settings.mode} disabled={busy} onChange={e=>{const mode=e.currentTarget.value as RegistrationSettings['mode'];setConfirmation({text:`将注册方式修改为${mode==='closed'?'关闭注册':mode==='open'?'开放注册(任何访问者都可创建账号)':'仅邀请码注册'}?`,run:()=>api.setRegistrationMode(mode)});}}><option value="closed">关闭注册</option><option value="open">开放注册</option><option value="invite">仅邀请码注册</option></select></label>
|
||||
<p className="muted">关闭注册时仍可由管理员创建账号。设置立即生效并在重启后保留。</p>
|
||||
<details><summary>创建邀请码</summary><form className="user-manager-form" onSubmit={e=>{e.preventDefault();void work(async()=>{setCode((await api.createInvite({label,maxUses,days})).code);},'邀请码已创建');}}><label>备注<input maxLength={80} value={label} onInput={e=>setLabel(e.currentTarget.value)} /></label><label>可用次数<input type="number" required min={1} max={1000} value={maxUses} onInput={e=>setMaxUses(Number(e.currentTarget.value))} /></label><label>有效天数<input type="number" required min={1} max={365} value={days} onInput={e=>setDays(Number(e.currentTarget.value))} /></label><button className="button primary" disabled={busy}>生成邀请码</button></form></details>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach,expect,it,vi} from 'vitest';
|
||||
import {act} from 'preact/test-utils';
|
||||
import {render} from '../src/ui/vendor/preact.ts';
|
||||
import {BusinessSettings} from '../src/ui/business-settings.tsx';
|
||||
import {dismissAllNotices} from '../src/ui/notifications.ts';
|
||||
const root=document.createElement('div');document.body.append(root);
|
||||
afterEach(()=>{act(()=>render(null,root));dismissAllNotices();});
|
||||
const view={group:'tmdb',label:'TMDB',revision:3,values:{enabled:true,language:'zh-CN'},secrets:{token:true},fields:[{key:'enabled',label:'启用 TMDB',type:'checkbox'},{key:'token',label:'读取令牌',type:'password'},{key:'language',label:'资料语言',type:'text'}]};
|
||||
const click=async(text:string)=>act(async()=>[...root.querySelectorAll('button')].find(button=>button.textContent===text)!.click());
|
||||
it('preserves stored credentials until explicitly cleared and submits a revision',async()=>{
|
||||
const request=vi.fn().mockImplementation(async(_path,method)=>method==='PATCH'?{...view,revision:4}:{groups:[view]});
|
||||
const api={businessSettingsRequest:request,businessTtsPreview:vi.fn()};
|
||||
await act(async()=>render(<BusinessSettings api={api} group="tmdb"/>,root));
|
||||
await vi.waitFor(()=>expect(root.querySelector<HTMLInputElement>('[type=password]')?.placeholder).toBe('已保存,留空不修改'));
|
||||
await click('保存配置');
|
||||
expect(request).toHaveBeenCalledWith('tmdb','PATCH',{values:view.values,revision:3});
|
||||
await vi.waitFor(()=>expect(root.querySelector('button')?.disabled).toBe(false));
|
||||
await click('清除读取令牌');await click('保存配置');
|
||||
expect(request).toHaveBeenCalledWith('tmdb','PATCH',{values:{...view.values,token:''},revision:4});
|
||||
});
|
||||
it('tests unsaved values without saving and keeps inputs after a rejected save',async()=>{
|
||||
const request=vi.fn().mockImplementation(async(path,method)=>method==='PATCH'?Promise.reject(Error('配置已被其他管理员修改')):path?.endsWith('/test')?{elapsedMs:20}:{groups:[view]});
|
||||
const api={businessSettingsRequest:request,businessTtsPreview:vi.fn()};
|
||||
await act(async()=>render(<BusinessSettings api={api} group="tmdb"/>,root));
|
||||
await vi.waitFor(()=>expect(root.querySelector('[aria-label="读取令牌"]')).not.toBeNull());
|
||||
const input=root.querySelector<HTMLInputElement>('[aria-label="读取令牌"]')!;
|
||||
act(()=>{input.value='new-secret';input.dispatchEvent(new Event('input',{bubbles:true}));});
|
||||
await click('测试连接');expect(request).toHaveBeenCalledWith('tmdb/test','POST',{values:{...view.values,token:'new-secret'}});
|
||||
expect(request.mock.calls.some(call=>call[1]==='PATCH')).toBe(false);
|
||||
await click('保存配置');await vi.waitFor(()=>expect(document.querySelector('[role=alert]')?.textContent).toContain('配置已被其他管理员修改'));
|
||||
expect(input.value).toBe('new-secret');
|
||||
});
|
||||
@@ -8,12 +8,13 @@ const root=document.createElement('div');document.body.append(root);
|
||||
afterEach(()=>act(()=>render(null,root)));
|
||||
it('retries source status and shows only capabilities reported by the server',async()=>{
|
||||
const request=vi.fn().mockRejectedValueOnce(new Error('网络中断')).mockResolvedValue({items:[{id:'tmdb',label:'TMDB',kinds:['movie','series'],configured:false},{id:'musicbrainz',label:'MusicBrainz',kinds:['album','track'],configured:true}]});
|
||||
await act(async()=>render(<MediaProviderSettings api={{request} as unknown as MediaApi}/>,root));
|
||||
const businessSettingsRequest=vi.fn().mockResolvedValue({groups:[{group:'tmdb',label:'TMDB',revision:1,values:{enabled:false},secrets:{token:false},fields:[{key:'token',label:'读取令牌',type:'password'}]}]});
|
||||
await act(async()=>render(<MediaProviderSettings api={{request,businessSettingsRequest} as unknown as MediaApi}/>,root));
|
||||
await vi.waitFor(()=>expect(root.querySelector('[role=alert]')?.textContent).toContain('网络中断'));
|
||||
await act(async()=>Array.from(root.querySelectorAll('button')).find(b=>b.textContent==='重试读取来源')!.click());
|
||||
await vi.waitFor(()=>expect(root.querySelectorAll('.media-provider-row')).toHaveLength(3));
|
||||
const rows=root.querySelectorAll('.media-provider-row');expect(rows).toHaveLength(3);expect(rows[0]!.textContent).toContain('未配置');expect(rows[1]!.textContent).toContain('已配置');
|
||||
await act(async()=>(rows[0] as HTMLButtonElement).click());expect(root.textContent).toContain('MEDIA_TMDB_API_KEY');expect(root.querySelector('input[type=password]')).toBeNull();
|
||||
await act(async()=>(rows[0] as HTMLButtonElement).click());await vi.waitFor(()=>expect(root.querySelector('input[type=password]')).not.toBeNull());expect(root.textContent).not.toContain('MEDIA_TMDB_API_KEY');expect(root.textContent).toContain('保存配置');
|
||||
expect(request.mock.calls.every(call=>call[0]==='metadata/providers'&&call[1]==='GET')).toBe(true);
|
||||
expect(Array.from(root.querySelectorAll('button')).some(button=>button.textContent?.includes('TVBox'))).toBe(false);
|
||||
});
|
||||
|
||||
@@ -101,6 +101,16 @@ function makeEngine(options: {
|
||||
return { engine, audios, spoken, errors };
|
||||
}
|
||||
|
||||
it('uses the current configured waiting limit for each sentence',async()=>{
|
||||
let timeout=65000;
|
||||
const schedule=vi.fn(()=>0 as unknown as ReturnType<typeof setTimeout>);
|
||||
const engine=new HttpTtsEngine({baseUrl:'http://nas.local',accessToken:()=>null,requestTimeoutMs:()=>timeout,
|
||||
createAudio:()=>new FakeAudio() as unknown as HTMLAudioElement,setTimeout:schedule,clearTimeout:()=>{}});
|
||||
engine.setQueueLoader(async()=>({chunks:[{text:'试听。'}],startIndex:0}));
|
||||
await engine.play(0);expect(schedule).toHaveBeenLastCalledWith(expect.any(Function),65000);
|
||||
engine.stop();timeout=35000;await engine.play(0);expect(schedule).toHaveBeenLastCalledWith(expect.any(Function),35000);engine.stop();
|
||||
});
|
||||
|
||||
describe('HttpTtsEngine', () => {
|
||||
it('asks the server one sentence at a time, with the token in the query', async () => {
|
||||
const { engine, audios } = makeEngine({ chunks: [['第一句。', '第二句。']], token: 'tok-123' });
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
const {chromium}=require('playwright');
|
||||
const {spawn}=require('node:child_process');
|
||||
const {createServer}=require('node:http');
|
||||
const {mkdtemp,mkdir,rm}=require('node:fs/promises');
|
||||
const {tmpdir}=require('node:os');
|
||||
const {join,resolve,relative,isAbsolute}=require('node:path');
|
||||
const {setTimeout:delay}=require('node:timers/promises');
|
||||
const assert=require('node:assert/strict');
|
||||
|
||||
(async()=>{
|
||||
const repo=resolve(__dirname,'../..'),root=await mkdtemp(join(tmpdir(),'reader-settings-ui-'));
|
||||
await mkdir(join(root,'books'));
|
||||
const wav=Buffer.alloc(2044);wav.write('RIFF');wav.writeUInt32LE(2036,4);wav.write('WAVEfmt ',8);wav.writeUInt32LE(16,16);wav.writeUInt16LE(1,20);wav.writeUInt16LE(1,22);wav.writeUInt32LE(8000,24);wav.writeUInt32LE(16000,28);wav.writeUInt16LE(2,32);wav.writeUInt16LE(16,34);wav.write('data',36);wav.writeUInt32LE(2000,40);
|
||||
const upstream=createServer((req,res)=>{if(req.url.startsWith('/voices')){res.setHeader('content-type','application/json');res.end('[{"id":"sample","name":"测试音色"}]');}else{res.setHeader('content-type','audio/wav');res.end(wav);}});
|
||||
await new Promise(resolve=>upstream.listen(0,'127.0.0.1',resolve));
|
||||
const tts='http://127.0.0.1:'+upstream.address().port;
|
||||
const socket=createServer();await new Promise(resolve=>socket.listen(0,'127.0.0.1',resolve));const port=socket.address().port;await new Promise(resolve=>socket.close(resolve));
|
||||
const origin='http://127.0.0.1:'+port;
|
||||
const server=spawn(process.execPath,[join(repo,'server/dist/main.js')],{cwd:repo,windowsHide:true,stdio:'ignore',env:{...process.env,BOOKS_DIR:join(root,'books'),DATA_DIR:join(root,'data'),WEB_DIR:join(repo,'web/dist'),HOST:'127.0.0.1',PORT:String(port),LOG_LEVEL:'silent',SCAN_INTERVAL:'0',WATCH_INTERVAL:'0'}});
|
||||
let browser;
|
||||
try{
|
||||
for(let i=0;;i++){try{if((await fetch(origin+'/api/v1/health')).ok)break;}catch{}assert.ok(i<100&&server.exitCode===null,'fixture starts');await delay(100);}
|
||||
const result=await fetch(origin+'/api/v1/auth/register',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({username:'settings-admin',password:'settings-test-password'})});assert.equal(result.status,201);
|
||||
const token=(await result.json()).session.accessToken,headers={authorization:'Bearer '+token,'content-type':'application/json'};
|
||||
browser=await chromium.launch({headless:true});const page=await browser.newPage({viewport:{width:390,height:844}}),errors=[];page.on('pageerror',error=>errors.push(error.message));
|
||||
await page.goto(origin);await page.locator('#login-username').fill('settings-admin');await page.locator('#login-password').fill('settings-test-password');await page.locator('form button[type=submit]').click();
|
||||
async function open(){await page.getByRole('button',{name:'系统设置',exact:true}).click();await page.getByRole('button',{name:'服务配置',exact:true}).click();await page.getByLabel('配置分类').selectOption('tts');}
|
||||
await open();await page.getByLabel('启用 HTTP 朗读',{exact:true}).check();await page.getByLabel('语音合成接口',{exact:true}).fill(tts+'/tts');await page.getByLabel('音色列表接口(可选)',{exact:true}).fill(tts+'/voices');
|
||||
await page.getByRole('button',{name:'检测连接并获取音色',exact:true}).click();await page.locator('#business-tts-voices option').waitFor({state:'attached'});
|
||||
await page.getByRole('button',{name:'生成试听音频',exact:true}).click();await page.locator('audio[aria-label="HTTP 朗读试听"]').waitFor();
|
||||
const saved=page.waitForResponse(response=>response.url().endsWith('/admin/settings/tts')&&response.request().method()==='PATCH');await page.getByRole('button',{name:'保存配置',exact:true}).click();assert.equal((await saved).status(),200);
|
||||
assert.equal((await (await fetch(origin+'/api/v1/tts/voices',{headers})).json()).http,true);
|
||||
await page.reload();await open();assert.equal(await page.getByLabel('语音合成接口',{exact:true}).inputValue(),tts+'/tts');
|
||||
const overflow=await page.locator('.business-settings').evaluate(element=>element.scrollWidth>element.clientWidth+2);assert.equal(overflow,false,'mobile settings do not overflow');
|
||||
const artifacts=join(repo,'artifacts/business-settings');await mkdir(artifacts,{recursive:true});await page.screenshot({path:join(artifacts,'http-tts-mobile.png')});
|
||||
await page.getByLabel('配置分类').selectOption('tmdb');await page.getByLabel('启用 TMDB',{exact:true}).check();await page.getByLabel('读取令牌',{exact:true}).fill('test-token');
|
||||
const tmdbSaved=page.waitForResponse(response=>response.url().endsWith('/admin/settings/tmdb')&&response.request().method()==='PATCH');await page.getByRole('button',{name:'保存配置',exact:true}).click();assert.equal((await tmdbSaved).status(),200);
|
||||
const provider=await (await fetch(origin+'/api/v1/media/metadata/providers',{headers})).json();assert.equal(provider.items.find(item=>item.id==='tmdb').configured,true,'isolated media worker sees configuration without restarting');
|
||||
assert.equal(await page.getByLabel('读取令牌',{exact:true}).inputValue(),'');
|
||||
await page.screenshot({path:join(artifacts,'tmdb-mobile.png')});assert.deepEqual(errors,[]);
|
||||
console.log('PASS: mobile settings, draft TTS test and audio, save/reload, secret redaction, and live media worker configuration');
|
||||
}finally{
|
||||
await browser?.close();server.kill();await new Promise(resolve=>server.exitCode!==null?resolve():server.once('exit',resolve));upstream.closeAllConnections();await new Promise(resolve=>upstream.close(resolve));
|
||||
const rel=relative(resolve(tmpdir()),resolve(root));if(!rel||rel.startsWith('..')||isAbsolute(rel))throw Error('Unsafe fixture path');await rm(root,{recursive:true,force:true});
|
||||
}
|
||||
})().catch(error=>{console.error(error);process.exitCode=1;});
|
||||
Reference in new issue
Block a user