feat(settings): manage business configuration from admin pages

This commit is contained in:
hectorqin committed 2026-09-28 12:38:54 +08:00
1 parent 996f0c555e
commit ff976a4cf6
41 files changed
+731 -192

No files matched your search

+1 -30
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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 }.
+39
View File
@@ -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
View File
@@ -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
View File
@@ -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)。
+1 -1
View File
@@ -17,7 +17,7 @@
本地库优先读取文件标签、NFO 和同目录封面;ffprobe 用于编码、时长及章节探测,ffmpeg 用于部分内嵌资源提取,两者不等于转码功能。OpenList 支持外置 NFO、封面、歌词和字幕,暂不读取远端内嵌标签、封面及章节。
服务器可配置 MEDIA_TMDB_TOKEN 或 MEDIA_TMDB_API_KEY(同时配置时优先令牌),以及 MEDIA_MUSICBRAINZ_USER_AGENT。管理员在作品菜单中匹配资料,审阅候选后确认;也可在「扫描与刮削」中发起批量任务并重试未完成项。来源网络、凭据或封面下载失败会单独提示。
在「影音设置 → 元数据刮削」中配置 TMDB 令牌或 API Key(同时填写时优先令牌),以及 MusicBrainz 应用标识与联系地址,保存后生效。管理员在作品菜单中匹配资料,审阅候选后确认;也可在「扫描与刮削」中发起批量任务并重试未完成项。来源网络、凭据或封面下载失败会单独提示。
详情的资源信息菜单可查看文件与技术信息、管理版本。手工资料、作品归属、版本拆分或合并会在重扫时保留,不修改原文件。移动版本不转移收藏,也不复制原作品的人工资料;更改剧集、季和单集关系后需重新确认受影响的在线匹配。
+4 -7
View File
@@ -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 存储及受控下载权限。
+3
View File
@@ -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'))
+6
View File
@@ -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);
+3
View File
@@ -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');}
});
}
+5 -2
View File
@@ -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);
+8 -8
View File
@@ -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);
+3 -3
View File
@@ -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
View File
@@ -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 };
}
+8 -5
View File
@@ -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;
+17
View File
@@ -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:'')];
}
+12 -13
View File
@@ -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';
}
+6 -6
View File
@@ -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=[];
+2 -1
View File
@@ -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);
+5 -3
View File
@@ -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)`);
+151
View File
@@ -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];
}
+4 -2
View File
@@ -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
View File
@@ -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'));
}
+76
View File
@@ -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;}
});
+5 -1
View File
@@ -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; }
});
+9
View File
@@ -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;
+2
View File
@@ -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 -2
View File
@@ -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>}
+2
View File
@@ -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 } : {}),
+2 -1
View File
@@ -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)));
}
/**
+19
View File
@@ -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; }
+67
View File
@@ -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'&&current.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>;
}
+1 -1
View File
@@ -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>
);
}
+5 -7
View File
@@ -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 -3
View File
@@ -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>
+33
View File
@@ -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');
});
+3 -2
View File
@@ -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);
});
+10
View File
@@ -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' });
+46
View File
@@ -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;});