feat(media): add global and per-user controls with reading-only mode

This commit is contained in:
hectorqin committed 2026-11-07 12:09:26 +00:00
1 parent 2db8464e17
commit eeb508f880
58 files changed
+892 -114

No files matched your search

+1
View File
@@ -45,6 +45,7 @@
| 部署服务、添加书籍与媒体 | [入门指南](docs/getting-started.zh-CN.md) |
| 使用书架、阅读器、OPDS 和朗读 | [阅读手册](docs/user-guide.zh-CN.md) |
| 建媒体库、刮削、看视频或听音乐与有声书 | [影音手册](docs/media-user-guide.zh-CN.md) |
| 全站或按用户关闭影音,启用纯阅读模式 | [功能模块开关](docs/media-features.md) |
| 对接 OpenList / NAS / 网盘挂载 | [OpenList 接入](docs/media-openlist.md) |
| 配置环境变量、HTTPS、媒体探测或 TTS | [部署配置](docs/configuration.zh-CN.md) |
| 备份账号、阅读与影音数据 | [备份恢复](docs/backup.zh-CN.md) |
+1
View File
@@ -12,6 +12,7 @@ The project README introduces reader. Use the guides below for installation, eve
| 安装、首次登录、添加书籍、更新 / Installation and first steps | [入门指南](getting-started.zh-CN.md) | [Getting started](getting-started.en.md) |
| 书架、阅读、朗读、来源和离线 / Shelves, reading, TTS, sources, offline | [使用手册](user-guide.zh-CN.md) | Chinese only |
| 电影、剧集、音乐与有声书 / Movies, series, music and audiobooks | [影音手册](media-user-guide.zh-CN.md) | Chinese only |
| 全站与按用户关闭影音 / Global and per-user media controls | [纯阅读模式](media-features.md) | Chinese only |
| 远端媒体目录 / 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) |
+14 -2
View File
@@ -21,11 +21,14 @@
```json
{ "name": "reader", "apiVersion": 1, "registrationOpen": false,
"registrationMode": "closed", "invitationRequired": false, "userCount": 3 }
"registrationMode": "closed", "invitationRequired": false, "userCount": 3,
"features": { "media": true } }
```
客户端用它区分「全新实例」(可以注册)和「已被占用」(需要管理员开账号)。
`features.media` 表示全站影音开关,不代表当前账号的有效权限。响应禁止缓存。
`apiVersion` 是**能力版本**,与包的版本号无关:只有在字段语义变化、客户端必须改行为时
才动它。加字段、加端点都不动——这正是「只做加法」能成立的原因。
@@ -108,6 +111,8 @@
→ `{ "user": { ... } }`
用户对象包含个人开关 `mediaEnabled`、有效权限 `features.media`(全站与个人开关同时开启)和影音生命周期 `mediaState`(`disabled|stopping|starting|ready|failed|closed`)。响应禁止缓存;管理员没有绕过影音开关的特权,但仍可管理服务配置和用户权限。
### `POST /auth/password`
`{ "currentPassword": "...", "newPassword": "..." }` → `{ "ok": true }`
@@ -933,6 +938,8 @@ Reader 对外提供的只读 OPDS 服务(`/opds`,Basic 认证;客户端凭
## 影音
全站或当前用户关闭影音时,普通影音接口返回 `403 MEDIA_DISABLED`。播放票据和后台播放授权每次使用检查最新权限,失效时沿用原有票据错误响应;已建立的服务器影音流每秒复核权限。第三方重定向直链无法由 Reader 断流。详见[影音模块开关](media-features.md)。
接口前缀为 /api/v1/media,沿用账号令牌。用户只能访问已授权媒体库;建库、权限、扫描、刮削、识别规则、作品 / 版本整理和人工资料编辑需要管理员权限。准备中返回 503 MEDIA_STARTING,启动失败返回 503 MEDIA_UNAVAILABLE。阅读健康检查不能替代影音就绪检查。
影音运行在独立 worker 中,阅读进程只做转发:同时在途请求超过 64 个返回 503 MEDIA_BUSY;转发中断返回 503 MEDIA_REQUEST_UNCERTAIN(写操作请重新查询结果确认);目录查询期间账号或可访问媒体库发生变化返回 503 MEDIA_ACCESS_CHANGED。
@@ -1010,12 +1017,16 @@ kind 为 video、music 或 audiobook;OpenList 建库和凭据更新见[OpenLis
返回 `201 { "user": {...} }`。`role` 缺省为 `member`。不受注册策略限制——管理员本就该能加家庭成员。
可选布尔字段 `mediaEnabled` 覆盖新用户的影音权限;省略时采用 `modules.newUserMediaEnabled`,公开注册也采用该默认值。用户列表及创建响应均返回 `mediaEnabled`。
### `PATCH /admin/users/:id`
`{ "disabled": true }` 或 `{ "role": "admin" }` → 更新后的完整列表 `{ "users": [...] }`。用户不存在返回 `404`。
不能停用自己或移除自己的管理员角色(`400 SELF_LOCKOUT`),并须保留至少一名启用的管理员(`400 LAST_ADMIN`)。停用立即撤销旧会话,重新启用仍需重新登录。
也接受 `{ "mediaEnabled": false }`,只撤销影音权限,不停用账号或撤销阅读会话。允许管理员修改自己的影音权限,全站关闭时也可预配置。
### `POST /admin/users/:id/password`
`{ "password": "至少8位的新密码" }` → `{ "ok": true }`。重置其他用户的密码,同时撤销其访问令牌和续期令牌。本人请使用 `/auth/password`,对自己调用返回 `400 SELF_LOCKOUT`。
@@ -1183,7 +1194,8 @@ All `/api/v1/admin/settings` routes require an administrator bearer token. Respo
- `GET /api/v1/admin/settings` returns `{groups:[{group,label,fields,values,secrets,revision}]}`. Secret values are omitted; `secrets` contains presence flags only.
- `PATCH /api/v1/admin/settings/:group` accepts `{values,revision}`. Omitted fields retain their values; an explicit empty secret clears it. Stale revisions return `409 SETTINGS_CONFLICT`. Unknown fields and invalid values return 400.
- `PATCH` returns the updated group view. For `access`, a request carrying an `Origin` header is rejected with 400 if the new CORS origin list is non-empty and does not include that origin.
- Groups: `ai`, `playback`, `tmdb`, `musicbrainz`, `tts`, `scanning`, `scraping`, `access`, `webdav`, `library` (`memberUpload`, default `true`: whether members may upload books). Unknown groups return 400. Registration continues using its existing API.
- Groups: `modules`, `ai`, `playback`, `tmdb`, `musicbrainz`, `tts`, `scanning`, `scraping`, `access`, `webdav`, `library` (`memberUpload`, default `true`: whether members may upload books). Unknown groups return 400. Registration continues using its existing API.
- `modules` contains `mediaEnabled` and `newUserMediaEnabled` (both default `true`). The first controls media instance-wide; the second applies only to subsequently created users. Disabling media stops its worker and background tasks without deleting media data. Instances sharing `reader.db` share these settings.
- `POST /api/v1/admin/settings/:group/test` accepts `{values}` for TMDB, MusicBrainz, TTS or WebDAV (other groups return 400). It tests the draft without saving it and returns `{ok,elapsedMs}`; TTS additionally returns `bytes` and `voices`. A test already running for the same group returns `409 SETTINGS_TEST_BUSY`; an upstream failure returns `502 SETTINGS_TEST_FAILED`.
- `POST /api/v1/admin/settings/tts/preview` accepts `{values,voice?}` and returns sample audio. `POST /api/v1/admin/settings/tts/clear-cache` clears cached audio and returns `{ok:true}`.
- `POST /api/v1/admin/settings/webdav/upload` accepts `{directory,name}` and uploads a verified offline backup using saved WebDAV settings. It never creates an online snapshot or overwrites a remote backup. A concurrent upload returns `409 BACKUP_BUSY`; any upload failure returns 400.
+3
View File
@@ -2,8 +2,11 @@
管理员在「我的 → 管理 → 服务配置」编辑实例业务配置,通过「配置分类」切换各分类。普通用户无法读取或修改服务凭据。
每个用户的影音权限在「我的 → 用户管理」中设置,包括管理员自己的权限;全站关闭时仍可管理。总开关与个人开关同时开启才可使用影音。生命周期、共享数据库与播放直链边界见[影音模块开关与纯阅读模式](media-features.md)。
| 分类 | 可修改内容 |
| --- | --- |
| 功能模块 | 全站启用影音、新用户默认启用影音,均默认开启;全站关闭进入纯阅读模式,保留影音数据 |
| AI 功能 | 启用 AI、AI 扫描与 AI 总结;OpenAI 兼容接口地址、API Key、模型(填写地址和密钥后可获取模型列表)、扫描与总结 Prompt、扫描批次大小、总结最大输入字符数 |
| 媒体播放 | OpenList 自动(直连优先)、浏览器直连、服务器代理;默认自动 |
| TMDB | 启用、读取令牌、API Key、元数据语言,令牌优先于 API Key |
+2
View File
@@ -15,6 +15,8 @@ Read-only books do not prevent account or progress updates in the data directory
## Business settings in the UI
Me → Service configuration → Feature modules controls media globally and the default media permission for new users (both enabled by default). Me → User management controls each account, including administrators. Both switches must allow media. These are database settings, not environment variables; instances sharing the core database share the global switch. Disabling media preserves its data and leaves reading and book TTS available. See [media feature controls (中文)](media-features.md).
Administrators configure AI features, media playback mode, HTTP speech, scanning, scraping, login lifetimes, public URL, browser origins, TMDB, MusicBrainz and WebDAV under Me → Service configuration (我的 → 服务配置). Registration is set under Me → 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).
+2
View File
@@ -15,6 +15,8 @@
## 页面业务配置
「我的 → 服务配置 → 功能模块」提供全站影音开关与新用户默认权限;「我的 → 用户管理」提供每个账号的影音开关。这些是数据库业务设置,不是环境变量;共享核心数据库的实例共用总开关。关闭不删除影音数据,也不影响阅读和书籍朗读。详见[纯阅读模式](media-features.md)。
管理员在「我的 → 服务配置」中管理 AI 功能、媒体播放方式、HTTP 朗读、扫描、刮削、登录有效期、对外地址、浏览器来源、TMDB、MusicBrainz 及 WebDAV 备份目标,注册策略在「我的 → 注册与邀请」中设置。配置写入 reader.db,保存后无需重启。详见[业务配置](business-settings.zh-CN.md)。
升级首次启动会导入旧业务环境变量;数据库存在配置后不再读取这些环境变量,清空字段也不会恢复旧值。
+2
View File
@@ -169,6 +169,8 @@ sh android/scripts/verify-web-assets.sh # 在 build-web-assets.sh 之后,确
## 贡献流程
影音开关的隔离浏览器回归入口为 `npm --prefix web run ui:review:features`(在仓库根目录运行),使用生产构建与临时数据,不需要连接已部署实例。覆盖范围与产物见[影音验证](media-validation.md)。
1. 描述问题、复现步骤和期望行为;日志、书源及示例请移除凭据。
2. 将修改限制在相关模块,参考已有错误语义和测试方式。
3. 为行为变更补充适当的回归验证;纯文档修改检查链接、命令和示例即可。
+32
View File
@@ -0,0 +1,32 @@
# 影音模块开关与纯阅读模式
管理员可以在网页中即时控制全站影音及各用户的影音权限,无需重启服务。
## 全站开关
进入「我的 → 服务配置」,在「配置分类」选择「功能模块」:
- **启用影音模块**:默认开启。关闭前需要确认;关闭后所有账号(包括管理员)进入纯阅读模式,停止影音播放、扫描和刮削任务,退出影音 worker。下次启动也不会打开影音数据库或启动影音 worker。重新开启后显示启动状态;失败时保留阅读功能,可以关闭再开启以重试。
- **新用户默认启用影音**:默认开启,仅用于之后注册或由管理员创建的账号,不修改已有用户。管理员创建账号时可以单独覆盖该默认值。
关闭不会删除媒体文件、影音数据库、封面、收藏和播放记录。重新开启仍使用原数据;被停止的扫描或刮削任务不会自动恢复,可在任务页面手动重试。正常的周期自动扫描仍遵循原有扫描设置。
## 用户开关
进入「我的 → 用户管理」,每个账号都有「启用影音 / 关闭影音」操作,管理员也可以修改自己的影音权限。全站关闭时仍可配置各账号权限,重新开启全站开关后生效。
影音访问必须同时满足:全站开启、当前账号开启、原有媒体库权限允许。管理员没有绕过总开关或用户开关的特权,但始终能访问用户管理和服务配置以恢复权限。
用户关闭影音后,不再显示影音导航、首页推荐、搜索结果和播放偏好;时长统计仅展示阅读。旧影音链接显示未启用提示。PDF、漫画、书籍、书源、OPDS 和书籍朗读 TTS 不受影响。
## 生效与边界
- 服务端每次请求重新检查权限,旧登录 token 不会绕过新配置,也不需要退出登录。播放票据和后台播放授权同样检查最新账号权限。
- 已建立的服务器影音流每秒检查一次权限,撤权后断开;其他用户不受影响。全站关闭时安全停止影音后台任务,超出 worker 关闭期限时终止 worker,由原有任务恢复逻辑标记中断。
- 当前管理页面保存后立即刷新能力;其他在线页面最多约 15 秒刷新一次,窗口重新获得焦点或联网时也会刷新。页面更新速度不影响服务端拒绝新请求。
- 多个服务实例共享核心数据库时,影音生命周期每两秒检查一次全站配置;总开关对共享该数据库的实例共同生效,不是某个容器的独立设置。
- 浏览器已缓存的数据无法收回;已经重定向到第三方存储的播放直链不受 Reader 控制,撤权无法立即切断该第三方连接。需要严格即时阻断时应使用服务器代理播放,并自行限制第三方直链的有效期。
配置保存在核心数据库 `business_settings` 的 `modules` 分类,用户权限在 `users.media_enabled` 中,均不依赖影音 worker。升级时已有用户默认保留影音权限。`/api/v1/instance` 返回全站 `features.media`;`/api/v1/auth/me` 的用户对象返回 `mediaEnabled`、有效 `features.media` 以及 `mediaState`。关闭时影音普通接口返回 `403 / MEDIA_DISABLED`,失效的播放票据仍沿用原有票据错误响应。
能力信息随当前登录会话在设备保存,离线恢复沿用上次已确认的权限;切换服务器或账号后会重新确认,不使用前一账号的能力信息。
+5 -1
View File
@@ -4,8 +4,12 @@
## 数据与服务边界
全站 `business_settings.modules.mediaEnabled` 和个人 `users.media_enabled` 保存在核心数据库,不依赖影音 worker。升级保留已有用户影音权限;新用户遵循 `newUserMediaEnabled`。总开关关闭时启动不会打开 `media.db` 或启动 worker,运行中可串行停止、重新启动;生命周期每两秒复核共享数据库设置,关闭超时十秒终止 worker。自动任务停止后不再派发新任务,重新开启不会自动恢复已中断任务。
普通接口、播放票据、后台播放授权均重新读取核心账号权限,已有代理流每秒复核;前端按有效能力隐藏入口、停止播放器并清理影音查询缓存,纯阅读首次打开不初始化播放器。完整行为及第三方直链限制见[模块开关](media-features.md)。
- 阅读与账号数据保存在 reader.db,影音目录、版本、章节、收藏、播放进度和配置保存在 media.db。两库及签名密钥必须成对备份和恢复。
- 主服务先开放阅读,再异步启动影音 Worker。准备中返回 MEDIA_STARTING,失败返回 MEDIA_UNAVAILABLE;不会把影音启动或扫描任务排入阅读请求队列。
- 主服务先开放阅读,仅在全站开启影音时异步启动影音 Worker。关闭时返回 MEDIA_DISABLED,准备中返回 MEDIA_STARTING,失败返回 MEDIA_UNAVAILABLE;不会把影音启动或扫描任务排入阅读请求队列。
- MediaLibraries 管理媒体库权限与接入配置;MediaStorage 隔离本地目录和 OpenList。媒体资源引用不是外部下载 URL。
- MediaScanner 收集资源与资料,发布阶段通过独立事务原子更新目录,保留稳定身份、人工整理及已存在的进度。目录或远端旁车读取失败不发布不完整快照。
- 查询线程、发布线程及账号权限投影的边界见[写入隔离](media-write-isolation.md)。
+2
View File
@@ -2,6 +2,8 @@
## 应用导航与频道切换
以下影音入口仅在全站和当前用户均启用影音时显示。纯阅读模式仅保留首页、书架、我的三个导航,不显示影音频道、推荐、搜索结果或影音管理入口;偏好设置仅保留外观与主题、书架两个标签。旧影音链接显示未启用提示,不挂载影音内容。管理员始终可以访问服务配置和用户管理;`/me` 不受影音路由守卫限制。开关与更新时机见[功能模块说明](media-features.md)。
全应用只有一套导航:手机为底部标签栏,701px 起为左侧边栏,包含首页(#/)、书架(#/shelf)、影音(#/media/video)和我的(#/me)。宽屏侧边栏在「影音」下列出影视、音乐、有声书三个频道;手机在频道分类页顶部用影视/音乐/有声书三段切换频道(旧的频道菜单和频道导轨已移除)。
频道下方是分类标签:影视(首页、电影、剧集)、音乐(专辑、歌手、曲目)、有声书(有声书、演播者)。手机上左右滑动切换相邻分类,越过频道最后一个分类即进入下一频道;同频道内保留 library 和 sort 参数,跨频道重置。频道尚无媒体库时滑动按整个频道切换。分类页右上角 ⋯ 菜单提供收藏、历史、队列,管理员另有媒体库管理(身份确认前按成员处理);演播者目录页也显示同一排分类标签。工具栏提供文件夹入口。
+2
View File
@@ -4,6 +4,8 @@
## 新建与管理媒体库
管理员可在「我的 → 服务配置 → 功能模块」关闭全站影音进入纯阅读模式,也可在「用户管理」逐个启用或关闭账号的影音权限。数据保留、重新开启可恢复;书籍朗读不受影响。详见[影音模块开关](media-features.md)。
管理员从「我的 → 媒体库」(或影视页「更多操作 → 媒体库管理」)进入,点「新建媒体库」,选择内容类型、接入方式和访问范围。普通用户只看到已获授权的媒体库。
- **服务器目录**:填写 Reader 服务端能读取的路径。Docker 部署填写容器内路径,例如将宿主目录只读挂载到 /media/music 后,填 /media/music。
+2
View File
@@ -8,6 +8,8 @@
## 浏览器与原型
从仓库根目录执行 `npm --prefix web run ui:review:features`,构建前后端后使用临时数据库和书籍目录验证功能开关:管理员全站/个人权限、自身权限恢复、worker 重启、纯阅读旧链接与设置/搜索/统计、无影音请求和播放器、跨窗口刷新以及 390/1120px 布局。截图位于忽略的 `artifacts/ui-review/media-features/`。请勿在共享真实数据库的部署实例中切换开关做验证。
先构建 web,再从 web 目录运行:
- `npm run ui:review:only`(`npm run ui:review` 会先构建再运行):启动真实服务端媒体夹具,使用生产 React bundle 在 390/1120px 检查书架、书库、我的、影音频道、详情、收藏、播放器、设置和权限页面;同时覆盖搜索、收藏筛选分页、收藏写回、播放会话恢复、媒体库增改和普通用户权限边界。报告写入忽略的 `artifacts/ui-review/react-app/verification.json`,以报告中的 `passed`、`screenshots`、页面错误和横向溢出结果为准,不把历史截图数量写入文档。
+1
View File
@@ -857,6 +857,7 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
- 导航按页面类型显示:顶层页面显示;详情、设置、书库文件等二级页面在手机上由自己的返回箭头取代底栏,桌面端侧栏保留;阅读器和播放页不显示。规则在 `web/src/app/AppNav.tsx` 的 `navMode`。
- 主题在应用壳层统一应用(`ThemeController` 写 `<html data-theme data-mode>`)。登录页在应用主题之前显示,使用暖陶并跟随设备深浅色。书架、书库、书源、首页、阅读器的 `--reader-*` 是 `--color-*` 的别名(`theme-tokens.css`),影音页的 `--text`、`--media-*` 别名在 `presentation.css` 顶部。
- 频道色(阅读、影视、音乐、有声书)是每个主题自己的 `--color-ch-*`(填充)、`-soft`(卡片底)和 `-text`(文字),由 `theme-tokens.test.ts` 守住对比度。
- 纯阅读模式:全站或当前用户关闭影音时,导航仅保留首页、书架、我的;首页、搜索、偏好、统计与管理入口均移除影音部分,不创建播放器或请求影音接口。管理员的服务配置与用户管理不受限制,旧影音链接显示未启用提示。以下影音交互仅适用于有效影音权限开启时,详见[模块开关](media-features.md)。
- 首页:「继续」一行把在读的书、未看完的影视和有声书画成同一种卡片(封面+作品名+集/章+进度,单集显示所属剧集名),最多两本书,按最近时间排序;然后是书架一行,最后是「看点什么」「听本书」「听张专辑」三行影音,各自可在「最近添加」(`browse?sort=added`,按作品及其分集/曲目最新文件的修改时间)与「随便看看」(`sort=random`,可换一批)之间切换,选择记在本机;有内容才出现。我的页只做入口,不拥有任何设置。
- 我的页的每一行都打开一个页面,不在原地弹 sheet(唯一例外是退出登录的确认)。个人偏好只有一行「偏好设置」(`#/me/settings`):外观与主题、书架、影音浏览、播放是同一页的四个标签,一次只显示一个,`?section=` 选中标签(缺省为外观与主题),所有控件改动即时生效,不设保存按钮。书架页齿轮弹出的书架设置是这一分区的快捷入口,两处共用 `ShelfSettingsFields`。管理员的用户管理、注册与邀请、服务配置各是一页(`#/me/admin/*`),与媒体库、扫描与刮削并列;外部阅读器(OPDS)在账号分组(`#/me/opds`)。
- “跟随系统”= 浅色时用的主题加深色时用的主题(默认都是暖陶),在外观与主题页指定;关掉跟随系统时保留屏幕上当前的主题与明暗,再选浅色或深色。读取与保存只走 `web/src/shared/theme/theme.ts` 的 `readThemePreference` / `saveThemePreference`;判断当前主题一律用 `resolveAppTheme` / `resolveReadingTheme`,React 里用 `useThemeState`,不要自己读存储。
+1
View File
@@ -16,6 +16,7 @@
*/
export const MIGRATIONS_SQL = `
ALTER TABLE users ADD COLUMN auth_version INTEGER NOT NULL DEFAULT 0;
ALTER TABLE users ADD COLUMN media_enabled INTEGER NOT NULL DEFAULT 1 CHECK(media_enabled IN (0,1));
ALTER TABLE source_instances ADD COLUMN is_default INTEGER NOT NULL DEFAULT 0;
CREATE UNIQUE INDEX IF NOT EXISTS source_single_default ON source_instances(is_default) WHERE is_default = 1;
ALTER TABLE book_files ADD COLUMN parse_version INTEGER NOT NULL DEFAULT 0;
+4
View File
@@ -13,6 +13,7 @@ import { registerSourceRoutes } from './routes/sources.ts';
import { registerOpdsRoutes } from './routes/opds.ts';
import { registerMediaRoutes } from './routes/media.ts';
import {registerMediaProxy} from './routes/media-proxy.ts';
import {AppError} from '../lib/errors.ts';
import type {MediaService} from '../media/start-runtime.ts';
import { isOriginAllowed, resolveCorsOrigin } from './cors.ts';
import {BusinessSettings} from '../services/business-settings.ts';
@@ -67,6 +68,9 @@ export function buildApp(ctx: AppContext,mediaRuntime?:MediaService): FastifyIns
// than fixed, otherwise a shared cache will serve one origin's response to
// another.
reply.header('vary', 'origin');
if(request.method!=='OPTIONS'&&/^\/api\/v1\/media(?:\/|\?|$)/.test(request.raw.url??'')&&!ctx.settings!.read('modules').mediaEnabled){
throw new AppError(403,'MEDIA_DISABLED','影音模块已关闭');
}
});
app.options('/*', async (_request, reply) => reply.status(204).send());
+1
View File
@@ -15,6 +15,7 @@ export interface AppContext {
config: AppConfig;
settings?:BusinessSettings;
settingsChanged?:(group:string)=>void;
mediaState?:()=>string;
db: Db;
scanner: Scanner;
users: UserService;
+8 -3
View File
@@ -72,8 +72,11 @@ export function registerAuthRoutes(app: FastifyInstance, ctx: AppContext): void
return { ok: true };
});
app.get('/api/v1/auth/me', { preHandler: authenticateFn }, async (request) => {
return { user: currentUser(request) };
app.get('/api/v1/auth/me', { preHandler: authenticateFn }, async (request,reply) => {
reply.header('cache-control','private, no-store');
const user=currentUser(request);
const enabled=ctx.settings!.read('modules').mediaEnabled;
return { user:{...user,features:{media:enabled&&user.mediaEnabled},mediaState:ctx.mediaState?.()??(enabled?'ready':'disabled')} };
});
app.post('/api/v1/auth/password', { preHandler: authenticateFn }, async (request) => {
@@ -84,12 +87,14 @@ export function registerAuthRoutes(app: FastifyInstance, ctx: AppContext): void
return { ok: true };
});
app.get('/api/v1/instance', async () => {
app.get('/api/v1/instance', async (_request,reply) => {
reply.header('cache-control','no-store');
// Advertised so a client can tell a fresh instance from a claimed one
// without attempting registration.
return {
name: 'reader',
apiVersion: 1,
features:{media:ctx.settings!.read('modules').mediaEnabled},
registrationOpen: ctx.users.canRegisterPublicly(),
registrationMode: ctx.users.count() === 0 ? 'open' : registration.mode(),
invitationRequired: ctx.users.count() > 0 && registration.mode() === 'invite',
+6 -2
View File
@@ -802,12 +802,14 @@ export function registerLibraryRoutes(app: FastifyInstance, ctx: AppContext): vo
app.post('/api/v1/admin/users', { preHandler: auth }, async (request, reply) => {
requireAdmin(request);
const body = (request.body ?? {}) as { username?: string; password?: string; displayName?: string; role?: 'admin' | 'member' };
const body = (request.body ?? {}) as { username?: string; password?: string; displayName?: string; role?: 'admin' | 'member'; mediaEnabled?:boolean };
if (typeof body.username!=='string' || typeof body.password!=='string' || !body.username || !body.password || body.username.length>100 || body.password.length>1024 || (body.displayName!==undefined && (typeof body.displayName!=='string' || body.displayName.length>100))) throw badRequest('请填写有效的用户名、密码和显示名');
if (body.role!==undefined && body.role!=='admin' && body.role!=='member') throw badRequest('无效的用户角色');
if (body.mediaEnabled!==undefined && typeof body.mediaEnabled!=='boolean') throw badRequest('无效的影音权限');
const user = await ctx.users.createAsAdmin({
username: body.username,
password: body.password,
mediaEnabled: body.mediaEnabled,
...(body.displayName !== undefined ? { displayName: body.displayName } : {}),
...(body.role !== undefined ? { role: body.role } : {}),
});
@@ -819,15 +821,17 @@ export function registerLibraryRoutes(app: FastifyInstance, ctx: AppContext): vo
const actor = currentUser(request);
requireAdmin(request);
const { id } = request.params as { id: string };
const body = (request.body ?? {}) as { disabled?: boolean; role?: 'admin' | 'member' };
const body = (request.body ?? {}) as { disabled?: boolean; role?: 'admin' | 'member'; mediaEnabled?:boolean };
const target = ctx.users.byId(id);
if (!target) throw notFound('用户不存在');
if (body.disabled!==undefined && typeof body.disabled!=='boolean') throw badRequest('无效的账号状态');
if (body.mediaEnabled!==undefined && typeof body.mediaEnabled!=='boolean') throw badRequest('无效的影音权限');
if (body.role!==undefined && body.role!=='admin' && body.role!=='member') throw badRequest('无效的用户角色');
if (id === actor.id && (body.disabled || body.role==='member')) throw badRequest('不能停用自己或移除自己的管理员权限', 'SELF_LOCKOUT');
if (target.role==='admin' && !target.disabled && (body.disabled || body.role==='member') && ctx.db.get<{n:number}>("SELECT count(*) AS n FROM users WHERE role='admin' AND disabled=0")!.n<=1) throw badRequest('必须保留至少一名启用的管理员','LAST_ADMIN');
if (body.disabled !== undefined) ctx.users.setDisabled(id, body.disabled);
if (body.role !== undefined) ctx.users.setRole(id, body.role);
if (body.mediaEnabled !== undefined) ctx.users.setMediaEnabled(id, body.mediaEnabled);
return { users: ctx.users.list() };
});
+31 -1
View File
@@ -34,6 +34,7 @@ import type { Db } from '../../db/index.ts';
import {BusinessSettingsReader} from '../../services/business-settings.ts';
import {configuredProviders} from '../../media/configured-providers.ts';
import {AiService} from '../../services/ai.ts';
import {Readable} from 'node:stream';
export type MediaRouteContext=Pick<AppContext,'config'> & {db:MediaDatabase & Pick<Db,'prepare'>;users:Pick<AppContext['users'],'byId'>};
@@ -42,10 +43,38 @@ export function registerMediaRoutes(app:FastifyInstance,ctx:MediaRouteContext,op
const accounts=new DatabaseMediaAccounts(ctx.db);
const references=database!==ctx.db?new MediaAccountReferences(database,accounts):undefined;
const settings=new BusinessSettingsReader(ctx.db);
app.addHook('onRequest',async request=>{
if(/^\/api\/v1\/media(?:\/|\?|$)/.test(request.raw.url??'')&&!settings.read('modules').mediaEnabled)throw new AppError(403,'MEDIA_DISABLED','影音模块已关闭');
});
const ai=new AiService(database,()=>settings.read('ai'),app.log);
ai.recoverJobs();
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,()=>settings.read('playback').mode);
const streams=new Map<Readable,()=>void>();
app.addHook('onSend',async(request,reply,payload)=>{
if(!(payload instanceof Readable)||!request.url.startsWith('/api/v1/media/'))return payload;
const streamMatch=/^\/api\/v1\/media\/streams\/([^/?]+)/.exec(request.url);
const validate=()=>{
if(!settings.read('modules').mediaEnabled)throw new AppError(403,'MEDIA_DISABLED','影音模块已关闭');
if(streamMatch){
const ticket=new URL(request.url,'http://localhost').searchParams.get('ticket')??'';
playback.authorizeStream(decodeURIComponent(streamMatch[1]!),ticket);
}else if(request.currentUser){
const account=accounts.get(request.currentUser.id);
if(!account||account.disabled||account.media_enabled===0)throw new AppError(403,'MEDIA_DISABLED','此账号未启用影音');
}
};
try{validate();}catch(error){payload.destroy();throw error;}
streams.set(payload,validate);
const remove=()=>{streams.delete(payload);};
payload.once('close',remove);reply.raw.once('close',remove);
return payload;
});
const streamTimer=setInterval(()=>{
for(const [stream,validate] of streams){try{validate();}catch{streams.delete(stream);stream.destroy();}}
},1000);
streamTimer.unref();
app.addHook('preClose',async()=>{clearInterval(streamTimer);for(const stream of streams.keys())stream.destroy();streams.clear();});
const background=new MediaBackgroundGrants(database,libraries,accounts);
const userState=new MediaUserState(database,libraries,scanner.catalog);
const scraping=new MediaScraping(database,scanner.catalog,options.metadataProviders??configuredProviders(settings));
@@ -63,6 +92,7 @@ export function registerMediaRoutes(app:FastifyInstance,ctx:MediaRouteContext,op
const authenticateAccount=authenticate(ctx);
const auth=async(request:import('fastify').FastifyRequest,reply:import('fastify').FastifyReply)=>{
await authenticateAccount(request,reply);
if(!currentUser(request).mediaEnabled)throw new AppError(403,'MEDIA_DISABLED','此账号未启用影音');
if(request.method!=='GET'&&request.method!=='HEAD')references?.ensure(currentUser(request).id);
};
const databasePath=database.all<{name:string;file:string}>('PRAGMA database_list').find(database=>database.name==='main')?.file;
@@ -139,7 +169,7 @@ export function registerMediaRoutes(app:FastifyInstance,ctx:MediaRouteContext,op
preHandler:scrapeAdmin,schema:{body:{type:'object',additionalProperties:false,required:['candidateId'],properties:{candidateId:{type:'string',minLength:1,maxLength:100}}}},
},async request=>scraping.confirm(currentUser(request),request.params.id,request.body.candidateId));
app.delete<{Params:{id:string}}>('/api/v1/media/items/:id/match',{preHandler:scrapeAdmin},async request=>scraping.clear(currentUser(request),request.params.id));
app.addHook('onClose',async()=>{automation.stop();await ai.close();await catalogReader?.close();await scrapeJobs.close();await scanner.close();});
app.addHook('preClose',async()=>{automation.stop();await Promise.all([ai.close(),catalogReader?.close(),scrapeJobs.close(),scanner.close()]);});
const pagination={type:'object',properties:{offset:{type:'integer',minimum:0,default:0},limit:{type:'integer',minimum:1,maximum:200,default:100}}};
app.get('/api/v1/media/libraries',{preHandler:auth},async request=>({items:libraries.list(currentUser(request))}));
app.get('/api/v1/media/library-summaries',{preHandler:scrapeAdmin},async(request,reply)=>{reply.header('cache-control','private, no-store');return queryCatalog(request,reply,{method:'librarySummaries',args:[currentUser(request)]});});
+1 -1
View File
@@ -21,7 +21,7 @@ export function registerOpdsRoutes(app: FastifyInstance, ctx: AppContext): void
try {
const id = credentials.authenticate(request.headers.authorization);
const user = ctx.users.byId(id)!;
request.currentUser = { id, username: user.username, displayName: user.display_name, role: user.role, createdAt: user.created_at };
request.currentUser = { id, username: user.username, displayName: user.display_name, role: user.role, createdAt: user.created_at, mediaEnabled:user.media_enabled!==0 };
} catch (error) { reply.header('www-authenticate', 'Basic realm="Reader OPDS", charset="UTF-8"'); throw error; }
};
app.addHook('onSend', async (request, reply, payload) => {
+2
View File
@@ -38,6 +38,7 @@ async function main(): Promise<void> {
app.log.error({err:error},'media service unavailable; reading remains available');
});
const app = buildApp(ctx,mediaRuntime);
ctx.mediaState=mediaRuntime.state;
ctx.log = app.log;
// Code that runs outside a request (the scanner, the format parsers) has no
// request to borrow a logger from, so it gets the app's.
@@ -61,6 +62,7 @@ async function main(): Promise<void> {
let scheduler = startSchedulers(ctx, app.log);
ctx.settingsChanged=group=>{
if(group==='modules'){void mediaRuntime.setEnabled(ctx.settings!.read('modules').mediaEnabled);return;}
if(group!=='scanning')return;
if(scheduler.scanTimer)clearInterval(scheduler.scanTimer);
scheduler=startSchedulers(ctx,app.log,false);
+5 -1
View File
@@ -1,11 +1,13 @@
import type { MediaDatabase } from './read-database.ts';
import {badRequest} from '../lib/errors.ts';
import {BusinessSettingsReader} from '../services/business-settings.ts';
export interface MediaAccount {
id: string;
role: 'admin' | 'member';
disabled: number;
auth_version: number;
media_enabled?: number;
}
/** Resolve current authority on every use; media storage is not an account cache. */
@@ -14,7 +16,9 @@ export interface MediaAccounts { get(id: string): MediaAccount | undefined }
export class DatabaseMediaAccounts implements MediaAccounts {
constructor(private readonly db: Pick<MediaDatabase, 'get'>) {}
get(id: string): MediaAccount | undefined {
return this.db.get<MediaAccount>('SELECT id,role,disabled,auth_version FROM users WHERE id=?', id);
const account=this.db.get<MediaAccount>('SELECT id,role,disabled,auth_version,media_enabled FROM users WHERE id=?', id);
if(account&&(account.media_enabled===0||!new BusinessSettingsReader(this.db).read('modules').mediaEnabled))return {...account,media_enabled:0};
return account;
}
}
+5 -4
View File
@@ -23,6 +23,7 @@ interface Log { info(data: object, message: string): void; warn(data: object, me
*/
export class MediaAutomation {
private timer: ReturnType<typeof setInterval> | undefined;
private stopped=false;
constructor(
private readonly accounts: Pick<MediaDatabase, 'get'>,
private readonly db: MediaDatabase,
@@ -38,16 +39,16 @@ export class MediaAutomation {
if (interval > 0) { this.timer = setInterval(() => this.scanAll(), interval); this.timer.unref?.(); }
}
stop() { if (this.timer) clearInterval(this.timer); this.timer = undefined; }
stop() { this.stopped=true;if (this.timer) clearInterval(this.timer); this.timer = undefined; }
private systemActor(): MediaActor | null {
const row = this.accounts.get<{ id: string }>("SELECT id FROM users WHERE role='admin' AND disabled=0 ORDER BY created_at,id LIMIT 1");
const row = this.accounts.get<{ id: string }>("SELECT id FROM users WHERE role='admin' AND disabled=0 AND media_enabled=1 ORDER BY created_at,id LIMIT 1");
return row ? { id: row.id, role: 'admin' } : null;
}
/** Starts a scan of every library not already scanning. Returns the libraries started. */
scanAll(): string[] {
if (!this.settings().mediaAutoScan) return [];
if (this.stopped||!this.settings().mediaAutoScan) return [];
const actor = this.systemActor();
if (!actor) return [];
const started: string[] = [];
@@ -71,7 +72,7 @@ export class MediaAutomation {
/** Returns the started scrape job's id, or null when there was nothing to do. */
afterScan(event: ScanFinished): string | null {
if (event.state !== 'complete' || !this.settings().mediaScrape) return null;
if (this.stopped||event.state !== 'complete' || !this.settings().mediaScrape) return null;
const actor: MediaActor = event.actor.role === 'admin' ? event.actor : this.systemActor() ?? event.actor;
try {
const library = this.libraries.get(actor, event.libraryId);
+2 -2
View File
@@ -26,7 +26,7 @@ export class MediaBackgroundGrants {
const parts=[...new Set(partIds)],now=Date.now();
return this.db.transaction(()=>{
const user=this.accounts.get(actor.id);
if(!user||user.disabled)throw unauthorized();
if(!user||user.disabled||user.media_enabled===0)throw unauthorized();
for(const id of parts)this.part(actor,id);
this.db.run('DELETE FROM media_playback_sessions WHERE id IN (SELECT session_id FROM media_background_sessions WHERE expires_at<=?)',now);
this.db.run('DELETE FROM media_background_grants WHERE expires_at<=? OR revoked=1',now);
@@ -42,7 +42,7 @@ export class MediaBackgroundGrants {
const grant=this.db.get<Grant>('SELECT * FROM media_background_grants WHERE token_hash=?',this.hash(token));
if(!grant||grant.revoked||grant.expires_at<=Date.now())throw unauthorized('background authorization expired','MEDIA_BACKGROUND_INVALID');
const user=this.accounts.get(grant.user_id);
if(!user||user.disabled||user.auth_version!==grant.auth_version)throw unauthorized('background authorization revoked','MEDIA_BACKGROUND_INVALID');
if(!user||user.disabled||user.media_enabled===0||user.auth_version!==grant.auth_version)throw unauthorized('background authorization revoked','MEDIA_BACKGROUND_INVALID');
return {grant,actor:{id:user.id,role:user.role}};
}
authorizePart(token:string,partId:string){
+3 -3
View File
@@ -76,7 +76,7 @@ export class MediaPlayback {
const part=this.part(actor,partId);
if(!part.available)throw notFound('media resource is missing','MEDIA_MISSING');
const user=this.accounts.get(actor.id);
if(!user||user.disabled)throw unauthorized();
if(!user||user.disabled||user.media_enabled===0)throw unauthorized();
const id=randomUUID(),token=randomBytes(32).toString('base64url'),now=Date.now(),expiresAt=now+SESSION_TTL;
const old=this.progress(actor,partId);
const position=old.completed?part.start_seconds:Math.max(part.start_seconds,Math.min(old.position,part.end_seconds??Number.MAX_SAFE_INTEGER));
@@ -116,7 +116,7 @@ export class MediaPlayback {
const session=this.db.get<SessionRow>('SELECT * FROM media_playback_sessions WHERE id=? AND user_id=?',id,actor.id);
if(!session||session.expires_at+RENEWAL_GRACE<=now)throw notFound('playback session expired','MEDIA_SESSION_EXPIRED');
const user=this.accounts.get(actor.id);
if(!user||user.disabled||user.auth_version!==session.auth_version)throw unauthorized('media session revoked','MEDIA_TICKET');
if(!user||user.disabled||user.media_enabled===0||user.auth_version!==session.auth_version)throw unauthorized('media session revoked','MEDIA_TICKET');
const part=this.part(actor,session.part_id);
if(!part.available)throw notFound('media resource is missing','MEDIA_MISSING');
const progress=this.db.get<ProgressRow>('SELECT * FROM media_progress WHERE user_id=? AND part_id=?',actor.id,part.id);
@@ -133,7 +133,7 @@ export class MediaPlayback {
const background=this.db.get<{expires_at:number}>('SELECT expires_at FROM media_background_sessions WHERE session_id=?',id);
if(background&&background.expires_at<=Date.now())throw unauthorized('background authorization expired','MEDIA_TICKET');
const user=this.accounts.get(session.user_id);
if(!user||user.disabled||user.auth_version!==session.auth_version)throw unauthorized('media session revoked','MEDIA_TICKET');
if(!user||user.disabled||user.media_enabled===0||user.auth_version!==session.auth_version)throw unauthorized('media session revoked','MEDIA_TICKET');
const actor:MediaActor={id:user.id,role:user.role};const part=this.part(actor,session.part_id);
if(!part.available)throw notFound('media resource is missing','MEDIA_MISSING');
return {actor,part};
+1 -1
View File
@@ -35,7 +35,7 @@ app.addHook('onRequest',async(request,reply)=>{
registerErrorHandler(app);
registerMediaRoutes(app,{config,db:core,users:{byId:id=>core.get<UserRow>(
// Authentication needs account status and public profile, never password hashes.
"SELECT id,username,display_name,role,disabled,created_at,updated_at,auth_version,'' AS password_hash FROM users WHERE id=?",id)}},{database:media});
"SELECT id,username,display_name,role,disabled,created_at,updated_at,auth_version,media_enabled,'' AS password_hash FROM users WHERE id=?",id)}},{database:media});
let closing=false;
const cleanup=new MediaAccountCleanup(media,new DatabaseMediaAccounts(core));
let cleanupTimer:ReturnType<typeof setInterval>|undefined;
+1 -1
View File
@@ -48,7 +48,7 @@ export class MediaRuntime {
return fetch(url,{...options,headers,redirect:'manual'});
}
async close():Promise<void>{
if(this.closed)return;
if(this.closed)return this.exited;
this.closed=true;this.worker.postMessage({type:'close'});
const timer=setTimeout(()=>{void this.worker.terminate();},10000);
try{await this.exited;}finally{clearTimeout(timer);}
+48 -15
View File
@@ -3,32 +3,65 @@ import type {Db} from '../db/index.ts';
import type {AppConfig} from '../config/index.ts';
import {MediaRuntime} from './runtime.ts';
import {AppError} from '../lib/errors.ts';
import {BusinessSettingsReader} from '../services/business-settings.ts';
export type MediaState='disabled'|'stopping'|'starting'|'ready'|'failed'|'closed';
export type MediaService=Pick<MediaRuntime,'request'|'close'>;
/** Listen for reading first, then start media without queuing public requests. */
export function createMediaService(db:Db,config:AppConfig,onError:(error:unknown)=>void):MediaService & {start:()=>void}{
export function createMediaService(db:Db,config:AppConfig,onError:(error:unknown)=>void):MediaService & {start:()=>void;setEnabled:(enabled:boolean)=>Promise<void>;state:()=>MediaState}{
let runtime:MediaRuntime|undefined;
let state:'pending'|'starting'|'ready'|'failed'|'closed'='pending';
return {
start(){
if(state!=='pending')return;
state='starting';
let state:MediaState='disabled',enabled=false,closed=false,started=false;
let generation=0,transition=Promise.resolve();
let timer:ReturnType<typeof setInterval>|undefined;
const settings=new BusinessSettingsReader(db);
const setEnabled=(next:boolean):Promise<void>=>{
if(closed)return transition;
if(next===enabled&&state!=='failed')return transition;
enabled=next;
const revision=++generation;
state=next?'starting':runtime?'stopping':'disabled';
if(!next)void runtime?.close();
transition=transition.then(async()=>{
if(runtime){const previous=runtime;runtime=undefined;await previous.close();}
if(closed||revision!==generation)return;
if(!enabled){state='disabled';return;}
const current=createRuntime(db,config);
runtime=current;
try{
runtime=createRuntime(db,config);
void runtime.start().then(()=>{if(state==='starting')state='ready';},error=>{
if(state==='closed')return;
state='failed';onError(error);
});
}catch(error){state='failed';onError(error);}
await current.start();
if(!closed&&revision===generation)state='ready';
}catch(error){
await current.close();
if(runtime===current)runtime=undefined;
throw error;
}
}).catch(error=>{if(!closed&&revision===generation){state='failed';onError(error);}});
return transition;
};
const service={
state:()=>state,
setEnabled,
start(){
if(started||closed)return;
started=true;
void setEnabled(settings.read('modules').mediaEnabled);
timer=setInterval(()=>{const next=settings.read('modules').mediaEnabled;if(next!==enabled)void setEnabled(next);},2000);
timer.unref();
},
async request(path,options){
if(state==='pending'||state==='starting')throw new AppError(503,'MEDIA_STARTING','影音服务正在准备,请稍后重试');
async request(path:string,options?:RequestInit){
if(closed)throw new AppError(503,'MEDIA_UNAVAILABLE','影音服务不可用');
if(!settings.read('modules').mediaEnabled||!enabled)throw new AppError(403,'MEDIA_DISABLED','影音模块已关闭');
if(state==='starting')throw new AppError(503,'MEDIA_STARTING','影音服务正在准备,请稍后重试');
if(state!=='ready'||!runtime)throw new AppError(503,'MEDIA_UNAVAILABLE','影音服务不可用,请管理员检查服务日志');
return runtime.request(path,options);
},
async close(){state='closed';await runtime?.close();},
async close(){
closed=true;enabled=false;generation++;state='closed';clearInterval(timer);
await runtime?.close();await transition;await runtime?.close();
},
};
return service;
}
function createRuntime(db:Db,config:AppConfig):MediaRuntime{
+5
View File
@@ -3,6 +3,7 @@ import type { AppConfig } from '../config/index.ts';
import { badRequest, conflict } from '../lib/errors.ts';
export interface BusinessValues {
modules:{mediaEnabled:boolean;newUserMediaEnabled:boolean};
ai:{enabled:boolean;scanEnabled:boolean;summaryEnabled:boolean;baseUrl:string;apiKey:string;model:string;scanPrompt:string;summaryPrompt:string;batchSize:number;maxInputChars:number};
playback:{mode:'auto'|'direct'|'proxy'};
tmdb:{enabled:boolean;token:string;apiKey:string;language:string};
@@ -17,6 +18,10 @@ export interface BusinessValues {
export type SettingsGroup=keyof BusinessValues;
type Field={key:string;label:string;type:'text'|'textarea'|'password'|'number'|'checkbox'|'url'|'select';default:string|number|boolean;min?:number;max?:number;help?:string;options?:Array<{value:string;label:string}>};
export const SETTINGS_SCHEMA:Record<SettingsGroup,{label:string;fields:Field[]}>= {
modules:{label:'功能模块',fields:[
{key:'mediaEnabled',label:'启用影音模块',type:'checkbox',default:true,help:'关闭后全站进入纯阅读模式,停止影音播放和后台任务,保留媒体库与播放记录;不影响书籍朗读'},
{key:'newUserMediaEnabled',label:'新用户默认启用影音',type:'checkbox',default:true,help:'仅影响之后创建的账号,不修改已有用户的影音权限'},
]},
ai:{label:'AI 功能',fields:[
{key:'enabled',label:'启用 AI',type:'checkbox',default:false},
{key:'scanEnabled',label:'启用 AI 扫描',type:'checkbox',default:false},
+14 -3
View File
@@ -5,6 +5,7 @@ import { hashPassword, verifyPassword } from './passwords.ts';
import { hashToken, newRefreshToken, signAccessToken } from './tokens.ts';
import { conflict, forbidden, notFound, unauthorized } from '../lib/errors.ts';
import { RegistrationService } from './registration.ts';
import { BusinessSettingsReader } from './business-settings.ts';
export type Role = 'admin' | 'member';
@@ -18,6 +19,7 @@ export interface UserRow {
created_at: number;
updated_at: number;
auth_version?: number;
media_enabled?: number;
}
export interface PublicUser {
@@ -26,6 +28,7 @@ export interface PublicUser {
displayName: string;
role: Role;
createdAt: number;
mediaEnabled: boolean;
}
export function toPublicUser(row: UserRow): PublicUser {
@@ -35,6 +38,7 @@ export function toPublicUser(row: UserRow): PublicUser {
displayName: row.display_name || row.username,
role: row.role,
createdAt: row.created_at,
mediaEnabled: row.media_enabled !== 0,
};
}
@@ -74,6 +78,7 @@ export class UserService {
password: string;
displayName?: string;
role?: Role;
mediaEnabled?: boolean;
}, admission?: () => void): Promise<PublicUser> {
const username = input.username.trim();
if (username.length < 3) throw conflict('username must be at least 3 characters', 'USERNAME_TOO_SHORT');
@@ -94,13 +99,14 @@ export class UserService {
// The first user to register owns the instance.
role: input.role ?? (this.count() === 0 ? 'admin' : 'member'),
disabled: 0,
media_enabled: (input.mediaEnabled ?? new BusinessSettingsReader(this.db).read('modules').newUserMediaEnabled) ? 1 : 0,
created_at: now,
updated_at: now,
};
this.db.run(
`INSERT INTO users (id, username, display_name, password_hash, role, disabled, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?)`,
row.id, row.username, row.display_name, row.password_hash, row.role, row.disabled, row.created_at, row.updated_at,
`INSERT INTO users (id, username, display_name, password_hash, role, disabled, created_at, updated_at, media_enabled)
VALUES (?,?,?,?,?,?,?,?,?)`,
row.id, row.username, row.display_name, row.password_hash, row.role, row.disabled, row.created_at, row.updated_at, row.media_enabled!,
);
// A new account must see the whole shared library straight away. Without
// this, a book scanned before the account existed would stay invisible
@@ -125,6 +131,7 @@ export class UserService {
password: string;
displayName?: string;
role?: Role;
mediaEnabled?: boolean;
}): Promise<PublicUser> {
const requestedRole = input.role ?? 'member';
return this.create({ ...input, role: requestedRole });
@@ -237,6 +244,10 @@ export class UserService {
this.db.run('UPDATE users SET role = ?, updated_at = ? WHERE id = ?', role, Date.now(), userId);
}
setMediaEnabled(userId: string, enabled: boolean): void {
this.db.run('UPDATE users SET media_enabled = ?, updated_at = ? WHERE id = ?', enabled ? 1 : 0, Date.now(), userId);
}
async resetPassword(userId: string, password: string): Promise<void> {
if (!this.byId(userId)) throw notFound('用户不存在');
if (password.length < 8 || password.length > 1024) throw conflict('密码须为 8–1024 位','PASSWORD_TOO_SHORT');
+4
View File
@@ -60,6 +60,10 @@ test('automation scans every idle library, and after a scan scrapes only never-a
assert.equal(automation.afterScan({ actor: admin, libraryId: library.id, jobId: 'x', state: 'complete' }), null);
settings.mediaAutoScan = false;
assert.deepEqual(automation.scanAll(), []);
settings.mediaAutoScan=true;
automation.stop();
assert.deepEqual(automation.scanAll(),[]);
assert.equal(automation.afterScan({actor:admin,libraryId:library.id,jobId:'stopped',state:'complete'}),null);
});
test('embedded subtitle streams can be turned off, and the new scanning settings have defaults', async () => {
+124
View File
@@ -0,0 +1,124 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {mkdtemp,rm,access} from 'node:fs/promises';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
import {Db} from '../src/db/index.ts';
import type {AppConfig} from '../src/config/index.ts';
import type {AppContext} from '../src/http/context.ts';
import {buildApp} from '../src/http/app.ts';
import {UserService} from '../src/services/users.ts';
import {BusinessSettings} from '../src/services/business-settings.ts';
import {signAccessToken} from '../src/services/tokens.ts';
import {createMediaService} from '../src/media/start-runtime.ts';
import {authenticate} from '../src/http/auth.ts';
import {Readable} from 'node:stream';
import {setTimeout as delay} from 'node:timers/promises';
function config(root:string):AppConfig {
return {booksDir:root,dataDir:root,host:'127.0.0.1',port:0,jwtSecret:'media-features-test-secret',accessTokenTtl:604800,refreshTokenTtl:31536000,scanInterval:0,watchInterval:0,logLevel:'silent',publicUrl:'',corsOrigins:[],webDir:join(root,'web')};
}
test('administrators control global and per-user media without revoking reading sessions',async context=>{
const root=await mkdtemp(join(tmpdir(),'media-features-')),db=new Db(join(root,'reader.db')),cfg=config(root);
const users=new UserService(db,cfg);
const ctx={config:cfg,db,users} as AppContext;
for(const role of ['admin','member'])db.run('INSERT INTO users(id,username,password_hash,role,created_at,updated_at) VALUES(?,?,?,?,0,0)',role,role,'unused',role);
const app=buildApp(ctx);await app.ready();
context.after(async()=>{await app.close();db.close();await rm(root,{recursive:true,force:true});});
const admin={authorization:'Bearer '+signAccessToken(cfg,{id:'admin',role:'admin'}).token};
const member={authorization:'Bearer '+signAccessToken(cfg,{id:'member',role:'member'}).token};
const save=(values:object,headers=admin)=>app.inject({method:'PATCH',url:'/api/v1/admin/settings/modules',headers,payload:{revision:ctx.settings!.view('modules').revision,values}});
const permission=(id:string,mediaEnabled:unknown,headers=admin)=>app.inject({method:'PATCH',url:'/api/v1/admin/users/'+id,headers,payload:{mediaEnabled}});
assert.deepEqual(ctx.settings!.read('modules'),{mediaEnabled:true,newUserMediaEnabled:true});
assert.equal((await app.inject({url:'/api/v1/media/libraries',headers:member})).statusCode,200);
assert.equal((await permission('member',false,member)).statusCode,403);
assert.equal((await save({mediaEnabled:false},member)).statusCode,403);
assert.equal((await permission('member','false')).statusCode,400);
assert.equal((await permission('member',false)).statusCode,200);
assert.equal((await app.inject({url:'/api/v1/media/libraries',headers:member})).json().error.code,'MEDIA_DISABLED');
const me=await app.inject({url:'/api/v1/auth/me',headers:member});
assert.equal(me.statusCode,200);
assert.equal(me.json().user.mediaEnabled,false);
assert.equal(me.json().user.features.media,false);
assert.equal((await app.inject({url:'/api/v1/opds/credentials',headers:member})).statusCode,200);
assert.equal((await app.inject({url:'/api/v1/media/libraries',headers:admin})).statusCode,200);
assert.equal((await permission('admin',false)).statusCode,200);
assert.equal((await app.inject({url:'/api/v1/admin/settings',headers:admin})).statusCode,200);
assert.equal((await permission('admin',true)).statusCode,200);
assert.equal((await save({mediaEnabled:false,newUserMediaEnabled:false})).statusCode,200);
assert.equal((await app.inject({url:'/api/v1/instance'})).json().features.media,false);
for(const path of ['libraries','streams/old?ticket=old','background/playback/old/subtitles','artwork/old','missing-route']){
const response=await app.inject({url:'/api/v1/media/'+path,headers:admin});
assert.equal(response.statusCode,403,path);assert.equal(response.json().error.code,'MEDIA_DISABLED');
}
assert.equal((await app.inject({url:'/api/v1/auth/me',headers:admin})).statusCode,200);
const created=await users.create({username:'new-member',password:'password123'});
assert.equal(created.mediaEnabled,false);
assert.equal(users.byId('admin')!.media_enabled,1);
assert.equal((await save({mediaEnabled:true})).statusCode,200);
assert.equal((await app.inject({url:'/api/v1/media/libraries',headers:admin})).statusCode,200);
assert.equal((await app.inject({url:'/api/v1/media/libraries',headers:member})).statusCode,403);
assert.equal((await permission('member',true)).statusCode,200);
assert.equal((await app.inject({url:'/api/v1/media/libraries',headers:member})).statusCode,200);
assert.equal(users.byId('member')!.auth_version,0);
});
test('disabled media does not open its database; toggles serialize worker shutdown and preserve libraries',async context=>{
const root=await mkdtemp(join(tmpdir(),'media-toggle-')),cfg=config(root),db=new Db(join(root,'reader.db'));
const settings=new BusinessSettings(db,cfg),errors:unknown[]=[];
const save=(enabled:boolean)=>settings.save('modules',{mediaEnabled:enabled},settings.view('modules').revision);
save(false);
db.run("INSERT INTO users(id,username,password_hash,role,created_at,updated_at) VALUES('admin','admin','unused','admin',0,0)");
const service=createMediaService(db,cfg,error=>errors.push(error));
context.after(async()=>{await service.close();db.close();await rm(root,{recursive:true,force:true});});
service.start();
assert.equal(service.state(),'disabled');
await assert.rejects(access(join(root,'media.db')));
await assert.rejects(service.request('/api/v1/media/libraries'),{code:'MEDIA_DISABLED'});
save(true);await service.setEnabled(true);
assert.equal(service.state(),'ready');
const headers={authorization:'Bearer '+signAccessToken(cfg,{id:'admin',role:'admin'}).token,'content-type':'application/json'};
const response=await service.request('/api/v1/media/libraries',{method:'POST',headers,body:JSON.stringify({name:'Movies',kind:'video',root,access:'all'})});
assert.equal(response.status,201,await response.clone().text());
const library=await response.json();
save(false);await service.setEnabled(false);
assert.equal(service.state(),'disabled');
assert.ok(await access(join(root,'media.db')).then(()=>true));
save(true);const starting=service.setEnabled(true);
save(false);const stopping=service.setEnabled(false);
save(true);const restarted=service.setEnabled(true);
await Promise.all([starting,stopping,restarted]);
assert.equal(service.state(),'ready');
const result=await service.request('/api/v1/media/libraries',{headers});
assert.equal(result.status,200);assert.equal((await result.json()).items[0].id,library.id);
assert.deepEqual(errors,[]);
save(false);await service.setEnabled(false);
await service.close();
assert.equal(service.state(),'closed');
});
test('revoking media interrupts an active stream without stopping another user',async context=>{
const root=await mkdtemp(join(tmpdir(),'media-stream-revoke-')),cfg=config(root),db=new Db(':memory:');
const users=new UserService(db,cfg),ctx={config:cfg,db,users} as AppContext;
for(const role of ['admin','member'])db.run('INSERT INTO users(id,username,password_hash,role,created_at,updated_at) VALUES(?,?,?,?,0,0)',role,role,'unused',role);
const app=buildApp(ctx);
app.get('/api/v1/media/test-stream',{preHandler:authenticate(ctx)},async(_request,reply)=>reply.type('application/octet-stream').send(Readable.from((async function*(){while(true){await delay(25);yield Buffer.alloc(1024);}})())));
const origin=await app.listen({host:'127.0.0.1',port:0});
context.after(async()=>{await app.close();db.close();await rm(root,{recursive:true,force:true});});
const stream=async(id:string)=>{
const response=await fetch(origin+'/api/v1/media/test-stream',{headers:{authorization:'Bearer '+signAccessToken(cfg,{id,role:id==='admin'?'admin':'member'}).token},signal:AbortSignal.timeout(6000)});
assert.equal(response.status,200);
const reader=response.body!.getReader();assert.equal((await reader.read()).done,false);return reader;
};
const admin=await stream('admin'),member=await stream('member');
let started=Date.now();
users.setMediaEnabled('member',false);
await assert.rejects(async()=>{while(!(await member.read()).done){};throw new Error('stream ended without revocation');},/terminated|aborted/i);
assert.ok(Date.now()-started<3000,'revocation must close the stream before the client timeout');
assert.equal((await admin.read()).done,false);
started=Date.now();
ctx.settings!.save('modules',{mediaEnabled:false},ctx.settings!.view('modules').revision);
await assert.rejects(async()=>{while(!(await admin.read()).done){};throw new Error('stream ended without revocation');},/terminated|aborted/i);
assert.ok(Date.now()-started<3000,'global disable must close the stream before the client timeout');
});
+9
View File
@@ -50,6 +50,15 @@ test('media runtime serves scan/catalog/playback HTTP in its worker and reads li
assert.equal(invalidRange.status,416);assert.equal(invalidRange.headers.get('content-range'),'bytes */12');
const noAuth=await fetch(publicOrigin+'/api/v1/media/libraries',{headers:{'x-reader-media-internal':'forged'}});
assert.equal(noAuth.status,401);
const grant=await call('background-grants','POST',{partIds:[detail.editions[0].parts[0].id]});
core.run("UPDATE users SET media_enabled=0 WHERE id='admin'");
const denied=await runtime.request('/api/v1/media/libraries',{headers:{authorization:'Bearer '+token}});
assert.equal(denied.status,403);assert.equal((await denied.json()).error.code,'MEDIA_DISABLED');
assert.equal((await fetch(publicOrigin+playback.streamUrl)).status,401);
const backgroundDenied=await runtime.request('/api/v1/media/background/playback',{method:'POST',headers:{'content-type':'application/json','x-media-background':grant.token},body:JSON.stringify({partId:detail.editions[0].parts[0].id})});
assert.equal(backgroundDenied.status,401);
core.run("UPDATE users SET media_enabled=1 WHERE id='admin'");
assert.equal((await runtime.request('/api/v1/media/libraries',{headers:{authorization:'Bearer '+token}})).status,200);
assert.equal(core.get("SELECT name FROM sqlite_master WHERE name='media_assets'"),undefined);
core.run("UPDATE users SET disabled=1 WHERE id='admin'");
assert.equal((await runtime.request('/api/v1/media/libraries',{headers:{authorization:'Bearer '+token}})).status,403);
+1
View File
@@ -15,6 +15,7 @@
"ui:review:only": "node tools/ui-review/react-app.mjs",
"ui:review:reader": "node tools/ui-review/run.mjs",
"ui:review:pdf": "npm run build && node tools/ui-review/pdf-layout.mjs",
"ui:review:features": "npm run build && npm --prefix ../server run build && node tools/ui-review/media-features.mjs",
"ui:sources": "npm run build && node --import ../server/node_modules/tsx/dist/loader.mjs tools/ui-review/host-source-flow.mjs"
},
"dependencies": {
+12 -2
View File
@@ -241,13 +241,21 @@ export class ReaderApi {
}
async me(): Promise<User> {
const generation=this.sessionGeneration;
const result = await this.get<{ user: User }>('/api/v1/auth/me');
if(generation===this.sessionGeneration&&this.session?.user.id===result.user.id){
const session={...this.session,user:result.user};
this.session=session;
const write=this.sessionWrites.catch(()=>undefined).then(()=>generation===this.sessionGeneration?this.sessions.save(session):undefined);
this.sessionWrites=write;
await write;
}
return result.user;
}
async adminUsers(): Promise<{users:import('./types.ts').ManagedUser[]}> { return this.get('/api/v1/admin/users'); }
async adminCreateUser(input:{username:string;password:string;displayName:string;role:'admin'|'member'}): Promise<unknown> { return this.call('/api/v1/admin/users','POST',input); }
async adminUpdateUser(id:string,input:{disabled?:boolean;role?:'admin'|'member'}): Promise<unknown> { return this.call('/api/v1/admin/users/'+encodeURIComponent(id),'PATCH',input); }
async adminCreateUser(input:{username:string;password:string;displayName:string;role:'admin'|'member';mediaEnabled?:boolean}): Promise<unknown> { return this.call('/api/v1/admin/users','POST',input); }
async adminUpdateUser(id:string,input:{disabled?:boolean;role?:'admin'|'member';mediaEnabled?:boolean}): Promise<unknown> { return this.call('/api/v1/admin/users/'+encodeURIComponent(id),'PATCH',input); }
async adminResetPassword(id:string,password:string): Promise<unknown> { return this.call('/api/v1/admin/users/'+encodeURIComponent(id)+'/password','POST',{password}); }
async registrationSettings(): Promise<import('./types.ts').RegistrationSettings> { return this.get('/api/v1/admin/registration'); }
async setRegistrationMode(mode:import('./types.ts').RegistrationSettings['mode']): Promise<unknown> { return this.call('/api/v1/admin/registration','PATCH',{mode}); }
@@ -949,6 +957,8 @@ export class ReaderApi {
const session = response.json as Session;
if (!session?.accessToken) throw new ApiError('unauthorized', 'refresh returned no session');
if (session.user.id !== this.session?.user.id) throw new ApiError('unauthorized', 'refresh returned another account');
const previous=this.session?.user;
if(previous?.features)session.user={...session.user,features:previous.features,...(previous.mediaState?{mediaState:previous.mediaState}:{})};
await this.setSession(session, true);
this.assertSession(generation);
return session;
+4
View File
@@ -21,9 +21,13 @@ export interface User {
displayName: string;
role: 'admin' | 'member';
createdAt: number;
mediaEnabled?: boolean;
features?: {media:boolean};
mediaState?: 'disabled'|'stopping'|'starting'|'ready'|'failed'|'closed';
}
export interface InstanceInfo {
features?: {media:boolean};
registrationMode?: 'closed' | 'open' | 'invite';
invitationRequired?: boolean;
name: string;
+12 -6
View File
@@ -1,11 +1,12 @@
import { useEffect, type ReactNode } from 'react';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../shared/stores/auth.store.ts';
import { useRuntime } from './providers/runtime-context.tsx';
import { LoginPage } from '../features/auth/pages/LoginPage.tsx';
import { AppNav, navMode } from './AppNav.tsx';
import { playerPath } from '../features/media/stores/playback.store.ts';
import { ThemeController } from '../shared/theme/theme.ts';
import { useMediaEnabled } from '../shared/stores/features.ts';
export function AppShell() {
return <AuthBoundary><AuthenticatedShell /></AuthBoundary>;
@@ -15,15 +16,17 @@ function AuthenticatedShell() {
const runtime = useRuntime();
const location = useLocation();
const navigate = useNavigate();
const media=useMediaEnabled();
useEffect(() => {
if(!media)return;
const openControls = () => {
navigate(playerPath(runtime.playback.snapshot()));
};
runtime.player.addEventListener('open-controls', openControls);
return () => runtime.player.removeEventListener('open-controls', openControls);
}, [runtime, navigate]);
useEffect(() => runtime.player.setVisible(location.pathname.startsWith('/media/')), [runtime, location.pathname]);
const mode = navMode(location.pathname);
}, [runtime, navigate,media]);
useEffect(() => {if(media)runtime.player.setVisible(location.pathname.startsWith('/media/'));}, [runtime, location.pathname,media]);
const mode = !media&&location.pathname.startsWith('/media')?'push':navMode(location.pathname);
// One app theme for every page; the reader may wear its own reading theme on top.
useEffect(() => {
const theme = new ThemeController(runtime.mediaApi.preferenceScope());
@@ -46,5 +49,8 @@ export function AuthBoundary({ children }: { children?: ReactNode }) {
}
/** Media routes: the page itself. Theme and navigation belong to AuthenticatedShell. */
export function MediaLayout() { return <Outlet />; }
export function MediaLayout() {
const enabled=useMediaEnabled();
if(!enabled)return <section className="media-screen media-page" role="status"><h1>影音模块未启用</h1><p>请联系管理员检查全站开关及你的影音权限。</p><Link to="/">返回首页</Link></section>;
return <Outlet />;
}
+4 -2
View File
@@ -6,6 +6,7 @@ import { useAuthStore } from '../shared/stores/auth.store.ts';
import { useSyncStore } from '../shared/stores/sync.store.ts';
import type { SyncStatus } from '../core/sync.ts';
import { useRuntime } from './providers/runtime-context.tsx';
import { useMediaEnabled } from '../shared/stores/features.ts';
/**
* hidden: the book and the player own the whole screen.
@@ -41,6 +42,7 @@ const destinations: Destination[] = [
export function AppNav({ mode }: { mode: Exclude<NavMode, 'hidden'> }) {
const { pathname } = useLocation();
const runtime = useRuntime();
const media=useMediaEnabled();
const user = useAuthStore(state => state.verifiedUser ?? state.session?.user ?? null);
const channel = /^\/media\/(video|music|audiobook)/.exec(pathname)?.[1];
const name = user?.displayName || user?.username || '';
@@ -54,10 +56,10 @@ export function AppNav({ mode }: { mode: Exclude<NavMode, 'hidden'> }) {
{/* Desktop only: one search across books and media (the page is /search). */}
<form className="app-nav-search" role="search" onSubmit={search}>
<Icon name="search" size={16} />
<input type="search" aria-label="搜索书籍和影音" placeholder="搜索书籍和影音" value={query} maxLength={200} onInput={event => setQuery(event.currentTarget.value)} />
<input type="search" aria-label={media?'搜索书籍和影音':'搜索书籍'} placeholder={media?'搜索书籍和影音':'搜索书籍'} value={query} maxLength={200} onInput={event => setQuery(event.currentTarget.value)} />
</form>
<ul className="app-nav-list">
{destinations.map(({ to, label, icon, current }) => {
{destinations.filter(item=>media||item.to!=='/media/video').map(({ to, label, icon, current }) => {
const active = current(pathname);
return <li key={to}>
<Link className="app-nav-item" to={to} aria-current={active ? 'page' : undefined}>
+4 -1
View File
@@ -2,14 +2,17 @@ import { useEffect, type ReactNode } from 'react';
import { QueryClientProvider } from '@tanstack/react-query';
import type { AppRuntime } from '../runtime.ts';
import { RuntimeContext } from './runtime-context.tsx';
import { useMediaEnabled } from '../../shared/stores/features.ts';
export function AppProviders({ runtime, children }: { runtime: AppRuntime; children: ReactNode }) {
const media=useMediaEnabled();
useEffect(() => {
if(!media)return;
const host = runtime.player.element;
document.body.append(host);
runtime.playback.start();
return () => { runtime.playback.stop(); host.remove(); };
}, [runtime]);
}, [runtime,media]);
useEffect(() => runtime.connect(), [runtime]);
useEffect(() => {
const visibility = () => {
+2 -4
View File
@@ -1,4 +1,4 @@
import { Navigate, type RouteObject } from 'react-router-dom';
import { Navigate, Outlet, type RouteObject } from 'react-router-dom';
import { AppShell, MediaLayout } from '../App.tsx';
import { MediaCatalogPage } from '../../features/media/pages/MediaCatalogPage.tsx';
import { MediaSearchPage } from '../../features/media/pages/MediaSearchPage.tsx';
@@ -55,11 +55,10 @@ export const routes = [{
// 我的 (/me).
{ path: 'settings', element: <Navigate to="/shelf" replace /> },
{ path: 'book/:bookId', element: <ReaderPage /> },
// 我的 shares MediaLayout so it follows the chosen theme and keeps the channel rail.
// Every row of it opens a page under /me, so back from any of them returns to 我的:
// all personal preferences are sections of one page (/me/settings), administration
// is one page per job (/me/admin/*). Old panel links (/me/theme, …) redirect.
{ path: 'me', element: <MediaLayout />, children: [
{ path: 'me', element: <Outlet />, children: [
{ index: true, element: <MinePage /> },
{ path: 'settings', element: <SettingsPage /> },
{ path: 'stats', element: <StatsPage /> },
@@ -109,4 +108,3 @@ export const routes = [{
}] satisfies RouteObject[];
+52 -11
View File
@@ -14,6 +14,7 @@ import { useAuthStore } from '../shared/stores/auth.store.ts';
import { useSettingsStore } from '../shared/stores/settings.store.ts';
import { useSyncStore } from '../shared/stores/sync.store.ts';
import { PlaybackService } from '../features/media/services/PlaybackService.ts';
import { mediaEnabled } from '../shared/stores/features.ts';
export function validServerUrl(value?: string): string {
if (!value?.trim()) return '';
@@ -48,8 +49,10 @@ export async function createAppRuntime(defaultServerUrl = '') {
};
api = new ReaderApi(platform, sessions);
const mediaApi = new MediaApi(api);
const player = new MediaPlayer(mediaApi);
const playback = new PlaybackService(player, mediaApi);
let player:MediaPlayer|undefined;
let playback:PlaybackService|undefined;
const getPlayer=()=>player??=new MediaPlayer(mediaApi);
const getPlayback=()=>playback??=new PlaybackService(getPlayer(),mediaApi);
const settings = new SettingsStore(platform.kv);
const offline = new OfflineStore(platform.kv);
const sync = new SyncEngine(api, offline, platform);
@@ -73,19 +76,46 @@ export async function createAppRuntime(defaultServerUrl = '') {
}
let currentBook: Book | null = null;
let readerFlush: (() => Promise<void>) | null = null;
let capabilityRevision=0;
let mediaAllowed=mediaEnabled(useAuthStore.getState().verifiedUser??useAuthStore.getState().session?.user);
let refreshing:{scope:string;promise:Promise<void>}|undefined;
function refreshCapabilities():Promise<void>{
const scope=JSON.stringify([api.baseUrl,api.currentSession()?.user.id]);
if(refreshing?.scope===scope)return refreshing.promise;
const promise=readCapabilities().finally(()=>{if(refreshing?.promise===promise)refreshing=undefined;});
refreshing={scope,promise};
return promise;
}
async function readCapabilities(){
const session=api.currentSession(),server=api.baseUrl;
if(!session)return;
const revision=++capabilityRevision;
const user=await api.me();
if(revision!==capabilityRevision||api.baseUrl!==server||api.currentSession()?.user.id!==session.user.id)return;
useAuthStore.getState().verify(user);
if(!mediaEnabled(user)){
playback?.stop();
if(mediaAllowed)await player?.stop().catch(()=>undefined);
const predicate=(query:{queryKey:readonly unknown[]})=>query.queryKey.includes('media');
await queryClient.cancelQueries({predicate});
queryClient.removeQueries({predicate});
}
mediaAllowed=mediaEnabled(user);
}
return {
api, mediaApi, player, playback, settings, offline, sync, queryClient, platform,
api, mediaApi, get player(){return getPlayer();}, get playback(){return getPlayback();}, settings, offline, sync, queryClient, platform,
refreshCapabilities,
pageHost: bridge ? androidPageHost(bridge) : null,
speechBridge: bridge ? androidSpeechBridge(bridge) : null,
async authenticated() {
const session = api.currentSession();
useAuthStore.getState().setSession(session);
useAuthStore.getState().setSession(session?{...session,user:{...session.user,features:{media:false}}}:null);
if (!session) return;
await offline.setScope(publicationScope(api.baseUrl, session.user.id));
useAuthStore.getState().verify(await api.me());
await refreshCapabilities();
queryClient.clear();
sync.start();
player.reconnectNative();
if(mediaEnabled(useAuthStore.getState().verifiedUser))getPlayer().reconnectNative();
},
async updateSettings(patch: Partial<AppSettings>) {
await settings.update(patch);
@@ -99,20 +129,31 @@ export async function createAppRuntime(defaultServerUrl = '') {
syncStatus: () => sync.status(),
settingsSnapshot: () => settings.load(),
async saveBeforeUpdate() {
await readerFlush?.(); await offline.flush(); await player.flush();
await readerFlush?.(); await offline.flush(); await player?.flush();
},
async flush() {
await Promise.allSettled([player.flush(), (async () => { await readerFlush?.(); await offline.flush(); await sync.flush(); })()]);
await Promise.allSettled([player?.flush(), (async () => { await readerFlush?.(); await offline.flush(); await sync.flush(); })()]);
},
connect() {
let scope=JSON.stringify([api.baseUrl,api.currentSession()?.user.id]);
const stopSession = api.onSessionChange(session => {
useAuthStore.getState().setSession(session);
const nextScope=JSON.stringify([api.baseUrl,session?.user.id]);
const verified=scope===nextScope?useAuthStore.getState().verifiedUser:null;
if(scope!==nextScope){capabilityRevision++;mediaAllowed=false;playback?.stop();void player?.stop().catch(()=>undefined);}
scope=nextScope;
useAuthStore.getState().setSession(session?{...session,user:{...session.user,features:{media:false}}}:null);
if(verified)useAuthStore.getState().verify(verified);
queryClient.clear();
if (!session) { sync.stop(); void player.stop(); }
if (!session) { sync.stop(); void player?.stop().catch(()=>undefined); }
else void refreshCapabilities().catch(()=>undefined);
});
const refresh=()=>{void refreshCapabilities().catch(()=>undefined);};
const timer=setInterval(refresh,15000);
window.addEventListener('focus',refresh);
window.addEventListener('online',refresh);
const stopSync = sync.onStatus(useSyncStore.getState().update);
useSyncStore.getState().update(sync.status());
return () => { stopSession(); stopSync(); };
return () => { capabilityRevision++;clearInterval(timer);window.removeEventListener('focus',refresh);window.removeEventListener('online',refresh);stopSession(); stopSync(); };
},
};
}
@@ -5,6 +5,7 @@ import { useAuthStore } from '../../../shared/stores/auth.store.ts';
import { RegistrationManagement, UserManagement } from '../../../ui/system-settings.tsx';
import { BusinessSettings } from '../../../ui/business-settings.tsx';
import type { ReactNode } from 'react';
import { useMediaEnabled } from '../../../shared/stores/features.ts';
/**
* 我的 › 管理: one page per job (用户管理, 注册与邀请, 服务配置), like 媒体库 and
@@ -18,8 +19,8 @@ function AdminFrame({ title, children }: { title: string; children: ReactNode })
}
export function AdminUsersPage() {
const { api } = useRuntime();
return <AdminFrame title="用户管理"><UserManagement api={api} /></AdminFrame>;
const runtime = useRuntime();
return <AdminFrame title="用户管理"><UserManagement api={runtime.api} onSaved={()=>void runtime.refreshCapabilities().catch(()=>undefined)} /></AdminFrame>;
}
export function AdminRegistrationPage() {
@@ -28,6 +29,9 @@ export function AdminRegistrationPage() {
}
export function AdminServicesPage() {
const { api } = useRuntime();
return <AdminFrame title="服务配置"><div className="user-management"><BusinessSettings api={api} /></div></AdminFrame>;
const runtime = useRuntime();
const media=useMediaEnabled();
const state=useAuthStore(store=>(store.verifiedUser??store.session?.user)?.mediaState);
const labels={disabled:'已关闭',stopping:'正在停止',starting:'正在启动',ready:'已就绪',failed:'启动失败,请检查服务日志',closed:'已停止'};
return <AdminFrame title="服务配置"><div className="user-management"><p role="status">影音服务:{state?labels[state]:'状态待确认'}</p><BusinessSettings api={runtime.api} showMedia={media} onSaved={()=>void runtime.refreshCapabilities().catch(()=>undefined)} /></div></AdminFrame>;
}
+6 -4
View File
@@ -10,6 +10,7 @@ import { useSyncStore } from '../../../shared/stores/sync.store.ts';
import { connectionLabel } from '../../../app/AppNav.tsx';
import '../../home/styles/home.css';
import '../styles/mine.css';
import { useMediaEnabled } from '../../../shared/stores/features.ts';
function Row({ icon, label, value, to, state, onClick, danger = false }: { icon: IconName; label: string; value?: string; to?: string; state?: unknown; onClick?: () => void; danger?: boolean }) {
const body = <>
@@ -43,6 +44,7 @@ function serverHost(baseUrl: string): string {
* Signing out is the one exception: a confirmation, not a place.
*/
export function MinePage() {
const media=useMediaEnabled();
const runtime = useRuntime();
const user = useAuthStore(state => state.verifiedUser ?? state.session?.user ?? null);
const admin = user?.role === 'admin';
@@ -70,14 +72,14 @@ export function MinePage() {
<Group title="内容" order={1}>
<Row icon="library" label="书库" to="/library" />
<Row icon="feed" label="书源" to="/sources" />
<Row icon="clock" label="时长统计" value="阅读、观影、听书" to="/me/stats" />
{admin && <Row icon="video" label="媒体库" to="/media/video/settings/libraries" state={{ returnTo: '/me' }} />}
<Row icon="clock" label="时长统计" value={media?'阅读、观影、听书':'阅读'} to="/me/stats" />
{admin && media && <Row icon="video" label="媒体库" to="/media/video/settings/libraries" state={{ returnTo: '/me' }} />}
</Group>
{admin && <Group title="管理" order={3}>
<Row icon="users" label="用户管理" to="/me/admin/users" />
<Row icon="invite" label="注册与邀请" to="/me/admin/registration" />
<Row icon="server" label="服务配置" value="朗读、AI、刮削、备份" to="/me/admin/services" />
<Row icon="refresh" label="扫描与刮削" to="/media/video/settings/tasks" state={{ returnTo: '/me' }} />
<Row icon="server" label="服务配置" value={media?'功能模块、朗读、AI、刮削、备份':'功能模块、朗读、AI、备份'} to="/me/admin/services" />
{media&&<Row icon="refresh" label="扫描与刮削" to="/media/video/settings/tasks" state={{ returnTo: '/me' }} />}
</Group>}
</div>
<div className="mine-column">
@@ -9,6 +9,7 @@ import { ShelfSettingsFields } from '../../../ui/shelf-settings.tsx';
import { Icon } from '../../../ui/icon.tsx';
import { playerPath } from '../../media/stores/playback.store.ts';
import '../styles/mine.css';
import { useMediaEnabled } from '../../../shared/stores/features.ts';
export const SETTINGS_SECTIONS = [
{ id: 'theme', title: '外观与主题' },
@@ -34,13 +35,15 @@ function isSection(value: string | null | undefined): value is SettingsSection {
* not a second setting.
*/
export function SettingsPage() {
const media=useMediaEnabled();
const sections=SETTINGS_SECTIONS.filter(section=>media||section.id==='theme'||section.id==='shelf');
const runtime = useRuntime();
const navigate = useNavigate();
const settings = useSettingsStore(state => state.settings);
const scope = runtime.mediaApi.preferenceScope();
const [params, setParams] = useSearchParams();
const requested = params.get('section');
const active: SettingsSection = isSection(requested) ? requested : 'theme';
const active: SettingsSection = isSection(requested)&&sections.some(section=>section.id===requested) ? requested : 'theme';
const select = (id: SettingsSection, focus = false) => {
setParams(id === 'theme' ? {} : { section: id }, { replace: true });
if (focus) document.getElementById(`settings-tab-${id}`)?.focus();
@@ -50,18 +53,18 @@ export function SettingsPage() {
return <MediaPageFrame className="media-secondary-page settings-page" title="偏好设置" backTo="/me" backLabel="返回我的">
<div className="settings-body">
<div className="settings-tabs" role="tablist" aria-label="设置分类">
{SETTINGS_SECTIONS.map((section, index) => <button type="button" role="tab" key={section.id} id={`settings-tab-${section.id}`}
{sections.map((section, index) => <button type="button" role="tab" key={section.id} id={`settings-tab-${section.id}`}
aria-selected={active === section.id} aria-controls="settings-panel" tabIndex={active === section.id ? 0 : -1}
onClick={() => select(section.id)}
onKeyDown={event => {
// Arrow keys move between tabs, one tab stop for the row (WAI-ARIA tabs).
const last = SETTINGS_SECTIONS.length - 1;
const last = sections.length - 1;
const target = event.key === 'ArrowRight' ? (index === last ? 0 : index + 1)
: event.key === 'ArrowLeft' ? (index === 0 ? last : index - 1)
: event.key === 'Home' ? 0 : event.key === 'End' ? last : -1;
if (target < 0) return;
event.preventDefault();
select(SETTINGS_SECTIONS[target]!.id, true);
select(sections[target]!.id, true);
}}>{section.title}</button>)}
</div>
+7 -5
View File
@@ -15,6 +15,7 @@ import { useMediaPreferences } from '../../media/hooks/use-media-preferences.ts'
import type { SearchItem } from '../../media/api/media-api.ts';
import type { ContinueReadingItem } from '../../../api/types.ts';
import '../styles/home.css';
import { useMediaEnabled } from '../../../shared/stores/features.ts';
export function greeting(hour: number): string {
return hour < 6 ? '夜深了' : hour < 12 ? '早上好' : hour < 18 ? '下午好' : '晚上好';
@@ -131,6 +132,7 @@ function Discover({ channel, kind, heading, to }: { channel: 'video' | 'audioboo
/** 首页: where you left off across reading and media, the shelf, then something to pick up. */
export function HomePage() {
const runtime = useRuntime();
const media=useMediaEnabled();
const user = useAuthStore(state => state.verifiedUser ?? state.session?.user ?? null);
const reading = useQuery({
queryKey: ['home', 'reading', runtime.api.baseUrl, user?.id],
@@ -139,11 +141,11 @@ export function HomePage() {
const shelf = useShelf(1, 'recent');
// 首页显示最近续播 (偏好设置 › 影音浏览) off: no video/audiobook continue cards; books are not media.
const { showContinue } = useMediaPreferences();
const video = useQuery({ ...historyQuery(runtime, 'video', new URLSearchParams()), enabled: showContinue });
const audio = useQuery({ ...historyQuery(runtime, 'audiobook', new URLSearchParams()), enabled: showContinue });
const video = useQuery({ ...historyQuery(runtime, 'video', new URLSearchParams()), enabled: media&&showContinue });
const audio = useQuery({ ...historyQuery(runtime, 'audiobook', new URLSearchParams()), enabled: media&&showContinue });
const unfinished = (items?: Activity[]) => items?.find(item => !item.completed);
const readingBooks = reading.data ?? [], book = readingBooks[0];
const videoActivity = showContinue ? unfinished(video.data?.items) : undefined, audioActivity = showContinue ? unfinished(audio.data?.items) : undefined;
const videoActivity = media&&showContinue ? unfinished(video.data?.items) : undefined, audioActivity = media&&showContinue ? unfinished(audio.data?.items) : undefined;
const books = (shelf.data?.items ?? []).slice(0, 8);
const name = user?.displayName || user?.username || '';
@@ -171,8 +173,8 @@ export function HomePage() {
<div className="home-section"><h2>我的书架</h2><Link to="/shelf">全部<Icon name="chevron-right" size={16} /></Link></div>
<div className="home-shelf-row">{books.map(item => <BookCard key={item.id} book={item} showAuthor={false} showProgress />)}</div>
</section>}
<Discover channel="video" kind="video" heading="看点什么" to="/media/video" />
{media&&<><Discover channel="video" kind="video" heading="看点什么" to="/media/video" />
<Discover channel="audiobook" kind="audiobook" heading="听本书" to="/media/audiobook" />
<Discover channel="music" kind="album" heading="听张专辑" to="/media/music/albums" />
<Discover channel="music" kind="album" heading="听张专辑" to="/media/music/albums" /></>}
</div>;
}
+7 -5
View File
@@ -10,6 +10,7 @@ import { MediaPageFrame } from '../../media/components/MediaPageFrame.tsx';
import type { MediaChannel } from '../../media/api/media-api.ts';
import '../../home/styles/home.css';
import '../styles/search.css';
import { useMediaEnabled } from '../../../shared/stores/features.ts';
const channelLabels: Record<MediaChannel, string> = { video: '影视', music: '音乐', audiobook: '有声书' };
const BOOK_LIMIT = 12, MEDIA_LIMIT = 8;
@@ -21,6 +22,7 @@ const BOOK_LIMIT = 12, MEDIA_LIMIT = 8;
*/
export function SearchPage() {
const runtime = useRuntime();
const mediaEnabled=useMediaEnabled();
const navigate = useNavigate();
const [params, setParams] = useSearchParams();
const query = (params.get('q') ?? '').trim();
@@ -36,17 +38,17 @@ export function SearchPage() {
const media = useQuery({
queryKey: ['search', 'media', runtime.api.baseUrl, user, query],
queryFn: ({ signal }) => runtime.mediaApi.search(query, 'all', 0, signal),
enabled: query.length > 0,
enabled: mediaEnabled&&query.length > 0,
});
const submit = (event: FormEvent) => { event.preventDefault(); const q = input.trim(); setParams(q ? { q } : {}); };
const mediaItems = (media.data?.items ?? []).slice(0, MEDIA_LIMIT);
return <MediaPageFrame className="media-secondary-page search-page" title="搜索" backTo="/" backLabel="返回首页">
<form className="search-page-form" role="search" onSubmit={submit}>
<Icon name="search" size={18} />
<input type="search" name="q" aria-label="搜索全部内容" placeholder="搜索书名、片名、歌曲、有声书" value={input} maxLength={200} onInput={event => setInput(event.currentTarget.value)} />
<input type="search" name="q" aria-label="搜索全部内容" placeholder={mediaEnabled?'搜索书名、片名、歌曲、有声书':'搜索书名、作者'} value={input} maxLength={200} onInput={event => setInput(event.currentTarget.value)} />
<button type="submit" className="media-primary" disabled={!input.trim()}>搜索</button>
</form>
{!query ? <p className="search-page-hint">输入名称,同时搜索书库和有权访问的影音库。</p> : <>
{!query ? <p className="search-page-hint">{mediaEnabled?'输入名称,同时搜索书库和有权访问的影音库。':'输入名称搜索书库。'}</p> : <>
<section className="search-page-section" aria-label="书籍搜索结果">
<header><h2>书籍{books.data ? <small>{books.data.total}</small> : null}</h2>
{books.data && books.data.total > BOOK_LIMIT && <Link to={`/library?q=${encodeURIComponent(query)}`}>在书库查看全部<Icon name="chevron-right" size={16} /></Link>}</header>
@@ -57,7 +59,7 @@ export function SearchPage() {
{books.data.items.map(book => <BookCard key={book.id} book={book} showAuthor={settings.shelfShowAuthor} showProgress={false} />)}
</div>}
</section>
<section className="search-page-section" aria-label="影音搜索结果">
{mediaEnabled&&<section className="search-page-section" aria-label="影音搜索结果">
<header><h2>影音{media.data ? <small>{media.data.total}</small> : null}</h2>
{media.data && media.data.total > MEDIA_LIMIT && <Link to={`/media/search?q=${encodeURIComponent(query)}&scope=all`}>查看全部影音结果<Icon name="chevron-right" size={16} /></Link>}</header>
{media.isPending ? <p role="status">正在搜索影音…</p>
@@ -69,7 +71,7 @@ export function SearchPage() {
<span className="media-search-copy"><strong title={item.title}>{item.title}</strong><small>{channelLabels[item.channel]} · {item.libraryName}</small></span>
<Icon name="chevron-right" size={18} />
</button>)}</div>}
</section>
</section>}
</>}
</MediaPageFrame>;
}
+19 -14
View File
@@ -3,6 +3,7 @@ import { Link, useSearchParams } from 'react-router-dom';
import { useMemo, useState } from 'react';
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
import { useAuthStore } from '../../../shared/stores/auth.store.ts';
import { useMediaEnabled } from '../../../shared/stores/features.ts';
import { MediaPageFrame } from '../../media/components/MediaPageFrame.tsx';
import { IconButton, Segmented } from '../../../ui/toolkit.tsx';
import { Icon } from '../../../ui/icon.tsx';
@@ -27,12 +28,14 @@ const GROUPS: Array<{ value: TimeStatsGroup; label: string }> = [
*/
export function StatsPage() {
const runtime = useRuntime();
const enabled=useMediaEnabled();
const kinds=STAT_KINDS.filter(item=>enabled||item.kind==='reading');
const user = useAuthStore(state => state.verifiedUser ?? state.session?.user ?? null);
const [params, setParams] = useSearchParams();
const group = (['day', 'month', 'year'].includes(params.get('g') ?? '') ? params.get('g') : 'day') as TimeStatsGroup;
const anchor = params.get('at') ?? '';
const selected = params.get('sel') ?? '';
const kind = (STAT_KINDS.some(item => item.kind === params.get('kind')) ? params.get('kind') : '') as StatKind | '';
const kind = (kinds.some(item => item.kind === params.get('kind')) ? params.get('kind') : '') as StatKind | '';
const set = (patch: Record<string, string>) => {
const next = new URLSearchParams(params);
for (const [key, value] of Object.entries(patch)) { if (value) next.set(key, value); else next.delete(key); }
@@ -48,12 +51,12 @@ export function StatsPage() {
const worksQuery: TimeStatsQuery = pick ? { from: pick.from, to: pick.to, tz, group } : chartQuery;
const reading = useQuery({ queryKey: ['stats', 'reading', chartQuery], queryFn: ({ signal }) => runtime.api.readingStats(chartQuery, { signal }) });
const media = useQuery({ queryKey: ['stats', 'media', chartQuery], queryFn: ({ signal }) => runtime.mediaApi.timeStats(chartQuery, signal), retry: false });
const media = useQuery({ queryKey: ['stats', 'media', chartQuery], queryFn: ({ signal }) => runtime.mediaApi.timeStats(chartQuery, signal), enabled, retry: false });
const readingWorks = useQuery({ queryKey: ['stats', 'reading', worksQuery], queryFn: ({ signal }) => runtime.api.readingStats(worksQuery, { signal }), enabled: !!pick });
const mediaWorks = useQuery({ queryKey: ['stats', 'media', worksQuery], queryFn: ({ signal }) => runtime.mediaApi.timeStats(worksQuery, signal), enabled: !!pick, retry: false });
const mediaWorks = useQuery({ queryKey: ['stats', 'media', worksQuery], queryFn: ({ signal }) => runtime.mediaApi.timeStats(worksQuery, signal), enabled: enabled&&!!pick, retry: false });
const series = mergeSeries(periods, reading.data, media.data);
const works = mergeWorks(pick ? readingWorks.data : reading.data, pick ? mediaWorks.data : media.data).filter(work => !kind || work.kind === kind);
const series = mergeSeries(periods, reading.data, enabled?media.data:undefined);
const works = mergeWorks(pick ? readingWorks.data : reading.data, enabled?(pick ? mediaWorks.data : media.data):undefined).filter(work => !kind || work.kind === kind);
const totals = Object.fromEntries(STAT_KINDS.map(item => [item.kind, series.reduce((sum, point) => sum + point.byKind[item.kind], 0)])) as Record<StatKind, number>;
const shown = (point: { byKind: Record<StatKind, number> }) => kind ? point.byKind[kind] : STAT_KINDS.reduce((sum, item) => sum + point.byKind[item.kind], 0);
const max = Math.max(1, ...series.map(shown));
@@ -72,11 +75,11 @@ export function StatsPage() {
</div>
<div className="stats-kinds" role="group" aria-label="类型">
<button type="button" aria-pressed={!kind} onClick={() => set({ kind: '' })}><span>全部</span><strong>{formatDuration(STAT_KINDS.reduce((sum, item) => sum + totals[item.kind], 0))}</strong></button>
{STAT_KINDS.map(item => <button type="button" key={item.kind} aria-pressed={kind === item.kind} onClick={() => set({ kind: kind === item.kind ? '' : item.kind })}>
{kinds.map(item => <button type="button" key={item.kind} aria-pressed={kind === item.kind} onClick={() => set({ kind: kind === item.kind ? '' : item.kind })}>
<span><i className="stats-swatch" data-kind={item.kind} aria-hidden="true" />{item.label}</span><strong>{formatDuration(totals[item.kind])}</strong>
</button>)}
</div>
{media.error && <p className="stats-note" role="status">影音时长暂时无法读取{isStarting(media.error) ? '(影音服务正在启动)' : ''},下面只包含阅读时长。</p>}
{enabled&&media.error && <p className="stats-note" role="status">影音时长暂时无法读取{isStarting(media.error) ? '(影音服务正在启动)' : ''},下面只包含阅读时长。</p>}
{reading.error && <p className="stats-note" role="alert">阅读时长读取失败。<button type="button" className="button" onClick={() => void reading.refetch()}>重试</button></p>}
<section className="stats-chart" aria-label={`${range.label}时长`}>
@@ -103,8 +106,8 @@ export function StatsPage() {
<details className="stats-table">
<summary>表格</summary>
<table>
<thead><tr><th scope="col">{group === 'day' ? '日期' : group === 'month' ? '月份' : '年份'}</th>{STAT_KINDS.map(item => <th scope="col" key={item.kind}>{item.label}</th>)}</tr></thead>
<tbody>{series.filter(point => shown(point) > 0).map(point => <tr key={point.key}><th scope="row">{point.label}</th>{STAT_KINDS.map(item => <td key={item.kind}>{point.byKind[item.kind] ? formatDuration(point.byKind[item.kind]) : '—'}</td>)}</tr>)}</tbody>
<thead><tr><th scope="col">{group === 'day' ? '日期' : group === 'month' ? '月份' : '年份'}</th>{kinds.map(item => <th scope="col" key={item.kind}>{item.label}</th>)}</tr></thead>
<tbody>{series.filter(point => shown(point) > 0).map(point => <tr key={point.key}><th scope="row">{point.label}</th>{kinds.map(item => <td key={item.kind}>{point.byKind[item.kind] ? formatDuration(point.byKind[item.kind]) : '—'}</td>)}</tr>)}</tbody>
</table>
</details>
</section>
@@ -131,21 +134,23 @@ export function StatsPage() {
/** Administrators: each account's time in the range shown, by kind. */
function AdminOverview({ query, label }: { query: TimeStatsQuery; label: string }) {
const runtime = useRuntime();
const enabled=useMediaEnabled();
const kinds=STAT_KINDS.filter(item=>enabled||item.kind==='reading');
const reading = useQuery({ queryKey: ['stats', 'users', 'reading', query], queryFn: ({ signal }) => runtime.api.readingStatsUsers(query, { signal }) });
const media = useQuery({ queryKey: ['stats', 'users', 'media', query], queryFn: ({ signal }) => runtime.mediaApi.statsUsers(query, signal), retry: false });
const media = useQuery({ queryKey: ['stats', 'users', 'media', query], queryFn: ({ signal }) => runtime.mediaApi.statsUsers(query, signal), enabled, retry: false });
const rows = (reading.data ?? []).map(user => {
const byKind: Record<StatKind, number> = { reading: user.seconds, video: 0, audiobook: 0, music: 0 };
for (const row of media.data ?? []) if (row.userId === user.userId) byKind[row.channel] += row.seconds;
for (const row of (enabled?media.data:undefined) ?? []) if (row.userId === user.userId) byKind[row.channel] += row.seconds;
return { ...user, byKind, total: STAT_KINDS.reduce((sum, item) => sum + byKind[item.kind], 0) };
}).sort((a, b) => b.total - a.total);
return <section className="stats-admin" aria-label="全部用户">
<h2>全部用户 · {label}</h2>
{reading.isPending ? <p className="stats-empty">正在读取…</p> : <div className="stats-admin-scroll"><table>
<thead><tr><th scope="col">用户</th><th scope="col">合计</th>{STAT_KINDS.map(item => <th scope="col" key={item.kind}>{item.label}</th>)}</tr></thead>
<thead><tr><th scope="col">用户</th><th scope="col">合计</th>{kinds.map(item => <th scope="col" key={item.kind}>{item.label}</th>)}</tr></thead>
<tbody>{rows.map(row => <tr key={row.userId}><th scope="row">{row.displayName || row.username}</th><td>{formatDuration(row.total)}</td>
{STAT_KINDS.map(item => <td key={item.kind}>{row.byKind[item.kind] ? formatDuration(row.byKind[item.kind]) : '—'}</td>)}</tr>)}</tbody>
{kinds.map(item => <td key={item.kind}>{row.byKind[item.kind] ? formatDuration(row.byKind[item.kind]) : '—'}</td>)}</tr>)}</tbody>
</table></div>}
{media.error && <p className="stats-note">影音时长暂时无法读取,表中只有阅读时长。</p>}
{enabled&&media.error && <p className="stats-note">影音时长暂时无法读取,表中只有阅读时长。</p>}
</section>;
}
+10
View File
@@ -0,0 +1,10 @@
import type { User } from '../../api/types.ts';
import { useAuthStore } from './auth.store.ts';
export function mediaEnabled(user:User|null|undefined):boolean {
return !!user&&user.mediaEnabled!==false&&(user.features?.media??true);
}
export function useMediaEnabled():boolean {
return useAuthStore(state=>mediaEnabled(state.verifiedUser??state.session?.user));
}
+14 -8
View File
@@ -13,12 +13,13 @@ 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'|'textarea'|'password'|'number'|'checkbox'|'url'|'select';min?:number;max?:number;help?:string;options?:Array<{value:string;label:string}>}>;
}
export function BusinessSettings({api,group,onSaved,selectControl}:{api:SettingsTransport;group?:string;onSaved?:()=>void;selectControl?:any}){
export function BusinessSettings({api,group,onSaved,selectControl,showMedia=true}:{api:SettingsTransport;group?:string;onSaved?:()=>void;selectControl?:any;showMedia?:boolean}){
const Select=selectControl??'select';
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 [models,setModels]=useState<string[]>([]);
const [confirmMedia,setConfirmMedia]=useState(false);
const [directory,setDirectory]=useState(''),[name,setName]=useState('reader-backup.zip'),[clear,setClear]=useState(false);
useEffect(()=>{
const controller=new AbortController();setBusy(true);setError('');
@@ -27,20 +28,24 @@ export function BusinessSettings({api,group,onSaved,selectControl}:{api:Settings
},[api,retry]);
useEffect(()=>{if(group)setSelected(group);},[group]);
const current=groups.find(item=>item.group===selected);
const visibleGroups=groups.filter(item=>showMedia||!['playback','tmdb','musicbrainz','scraping'].includes(item.group));
useEffect(()=>{if(!group&&!showMedia&&['playback','tmdb','musicbrainz','scraping'].includes(selected))setSelected('modules');},[group,showMedia,selected]);
useLayoutEffect(()=>{setValues(current?.values||{});setVoices([]);setVoice('');setAudio('');setModels([]);},[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);}}
const save=()=>void work(async()=>{
if(!current)return;
const result=await api.businessSettingsRequest<SettingsView>(selected,'PATCH',{values,revision:current.revision});
setGroups(items=>items.map(item=>item.group===selected?result:item));setMessage('配置已保存');onSaved?.();
});
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:ChangeEvent<HTMLSelectElement>)=>setSelected(event.currentTarget.value)}>{groups.map(item=><option key={item.group} value={item.group}>{item.label}</option>)}</Select></label>}
{!group&&<label className="user-manager-mode">配置分类<Select aria-label="配置分类" value={selected} disabled={busy} onChange={(event:ChangeEvent<HTMLSelectElement>)=>setSelected(event.currentTarget.value)}>{visibleGroups.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}
<p className="muted">{selected==='modules'?'保存后立即生效,无需重启。关闭影音将停止播放及后台任务,保留全部影音数据。':'保存后生效,无需重启。进行中的任务保留原配置,新任务使用更新后的配置。'}</p>
<form className="user-manager-form" onSubmit={event=>{event.preventDefault();if(selected==='modules'&&current.values.mediaEnabled&&!values.mediaEnabled)setConfirmMedia(true);else save();}}>
{current.fields.filter(field=>showMedia||!(selected==='scanning'&&(/^(media|libraries$|files$)/.test(field.key)))&&!(selected==='ai'&&['scanEnabled','scanPrompt','batchSize'].includes(field.key))).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})}/>:
selected==='ai'&&field.key==='model'?<Select aria-label={field.label} value={String(values.model||'')} disabled={busy} onChange={(event:ChangeEvent<HTMLSelectElement>)=>setValues({...values,model:event.currentTarget.value})}><option value="">请获取并选择模型</option>{Array.from(new Set([String(values.model||''),...models])).filter(Boolean).map(model=><option key={model} value={model}>{model}</option>)}</Select>:
field.type==='select'?<Select aria-label={field.label} value={String(values[field.key]??'')} disabled={busy} onChange={(event:ChangeEvent<HTMLSelectElement>)=>setValues({...values,[field.key]:event.currentTarget.value})}>{field.options?.map(option=><option key={option.value} value={option.value}>{option.label}</option>)}</Select>:
@@ -57,6 +62,7 @@ export function BusinessSettings({api,group,onSaved,selectControl}:{api:Settings
<button type="button" className="button" disabled={busy} onClick={()=>setRetry(value=>value+1)}>重新加载</button>
</div>
</form>
{confirmMedia&&<ConfirmSheet danger title="关闭影音模块" text="关闭后所有用户将进入纯阅读模式,正在播放、扫描和刮削的任务将停止。媒体库、收藏与播放记录会保留,书籍阅读和朗读不受影响。" onCancel={()=>setConfirmMedia(false)} onConfirm={()=>{setConfirmMedia(false);save();}}/>}
{selected==='ai'&&models.length>0&&<p className="muted">可用模型:{models.join('、')}</p>}
{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>
+15 -2
View File
@@ -29,20 +29,31 @@ function useAdminWork<T>(load: () => Promise<T>) {
return { data, busy, work, confirm: setConfirmation, notice };
}
export function UserManagement({ api }: { api: ReaderApi }) {
export function UserManagement({ api,onSaved }: { api: ReaderApi;onSaved?:()=>void }) {
const { data, busy, work, confirm, notice } = useAdminWork(async () => (await api.adminUsers()).users);
const users: ManagedUser[] = data ?? [];
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), [query, setQuery] = useState('');
const self = api.currentSession()?.user.id;
const [globalMedia,setGlobalMedia]=useState(true),[defaultMedia,setDefaultMedia]=useState(true),[mediaEnabled,setMediaEnabled]=useState<boolean|undefined>();
useEffect(()=>{
let active=true;
if(api.businessSettingsRequest)void api.businessSettingsRequest<{groups:Array<{group:string;values:Record<string,boolean>}>}>().then(result=>{
const modules=result.groups.find(group=>group.group==='modules');
if(active&&modules){setGlobalMedia(modules.values.mediaEnabled!==false);setDefaultMedia(modules.values.newUserMediaEnabled!==false);}
}).catch(()=>undefined);
return()=>{active=false;};
},[api]);
return <div className="user-management">
{notice}
{!globalMedia&&<p role="status">全站影音已关闭;用户影音权限将在重新开启后生效。</p>}
<div className="user-manager-actions"><input aria-label="筛选用户" placeholder="搜索用户名或显示名" value={query} onInput={e => setQuery(e.currentTarget.value)} /><button className="button primary" disabled={busy} onClick={() => { setForm(!form); setReset(null); setPassword(''); }}>新增用户</button></div>
{form && <form className="user-manager-form" onSubmit={e => { e.preventDefault(); void work(async () => { await api.adminCreateUser({ username: username.trim(), password, displayName: displayName.trim(), role }); setForm(false); setUsername(''); setPassword(''); setDisplayName(''); setRole('member'); }, '用户已创建'); }}>
{form && <form className="user-manager-form" onSubmit={e => { e.preventDefault(); void work(async () => { await api.adminCreateUser({ username: username.trim(), password, displayName: displayName.trim(), role,...(mediaEnabled===undefined?{}:{mediaEnabled}) }); setForm(false); setUsername(''); setPassword(''); setDisplayName(''); setRole('member');setMediaEnabled(undefined); }, '用户已创建'); }}>
<label>用户名<input required minLength={3} maxLength={100} value={username} onInput={e => setUsername(e.currentTarget.value)} /></label>
<label>初始密码<input required type="password" autoComplete="new-password" minLength={8} maxLength={1024} value={password} onInput={e => setPassword(e.currentTarget.value)} /></label>
<label>显示名<input maxLength={100} value={displayName} onInput={e => setDisplayName(e.currentTarget.value)} /></label>
<label>账号角色<select value={role} onChange={e => setRole(e.currentTarget.value as 'admin' | 'member')}><option value="member">普通用户</option><option value="admin">管理员</option></select></label>
<label className="business-setting-toggle">允许使用影音<input type="checkbox" checked={mediaEnabled??defaultMedia} onChange={event=>setMediaEnabled(event.currentTarget.checked)}/></label>
<button className="button primary" disabled={busy}>创建账号</button>
</form>}
{reset && <form className="user-manager-form" onSubmit={e => { e.preventDefault(); void work(async () => { await api.adminResetPassword(reset.id, password); setReset(null); setPassword(''); }, '密码已重置,旧会话已失效'); }}><p>重置 {reset.username} 的密码,所有旧会话将立即失效。</p><label>新密码<input type="password" required minLength={8} maxLength={1024} autoComplete="new-password" value={password} onInput={e => setPassword(e.currentTarget.value)} /></label><button className="button primary" disabled={busy}>确认重置密码</button><button type="button" className="button" disabled={busy} onClick={() => { setReset(null); setPassword(''); }}>取消重置</button></form>}
@@ -50,6 +61,8 @@ export function UserManagement({ api }: { api: ReaderApi }) {
<ul className="user-manager-list">{users.filter(user => (user.username + ' ' + user.displayName).toLowerCase().includes(query.toLowerCase())).map(user => <li key={user.id}>
<div><strong>{user.displayName}</strong><span className="muted"> @{user.username}{user.id === self ? ' · 当前账号' : ''}</span></div>
<p>{user.role === 'admin' ? '管理员' : '普通用户'} · {user.disabled ? '已停用' : '正常'}</p>
<p>影音:{user.mediaEnabled===false?'未启用':'已启用'}{!globalMedia?'(全站已关闭)':''}</p>
<button className="button" disabled={busy} onClick={()=>confirm({text:`${user.mediaEnabled===false?'允许':'禁止'} ${user.username} 使用影音?${user.mediaEnabled===false?'原有媒体库权限仍然有效。':'播放将停止,影音数据保留,阅读不受影响。'}`,run:async()=>{await api.adminUpdateUser(user.id,{mediaEnabled:user.mediaEnabled===false});onSaved?.();}})}>{user.mediaEnabled===false?'启用影音':'关闭影音'}</button>
{user.id !== self && <div className="user-manager-actions">
<button className="button" disabled={busy} onClick={() => confirm({ text: `确定${user.disabled ? '启用' : '停用'} ${user.username}?${user.disabled ? '' : '旧会话将立即失效。'}`, run: () => api.adminUpdateUser(user.id, { disabled: !user.disabled }) })}>{user.disabled ? '启用' : '停用'}</button>
<button className="button" disabled={busy} onClick={() => confirm({ text: `将 ${user.username} ${user.role === 'admin' ? '改为普通用户' : '设为管理员(可管理全部用户及实例设置)'}?`, run: () => api.adminUpdateUser(user.id, { role: user.role === 'admin' ? 'member' : 'admin' }) })}>{user.role === 'admin' ? '移除管理员' : '设为管理员'}</button>
+14
View File
@@ -32,3 +32,17 @@ it('tests unsaved values without saving and keeps inputs after a rejected save',
await click('保存配置');await vi.waitFor(()=>expect(document.querySelector('[role=alert]')?.textContent).toContain('配置已被其他管理员修改'));
expect(input.value).toBe('new-secret');
});
it('requires confirmation before disabling media and saves only after confirmation',async()=>{
const modules={group:'modules',label:'功能模块',revision:1,values:{mediaEnabled:true,newUserMediaEnabled:true},secrets:{},fields:[{key:'mediaEnabled',label:'启用影音模块',type:'checkbox'},{key:'newUserMediaEnabled',label:'新用户默认启用影音',type:'checkbox'}]};
const request=vi.fn().mockImplementation(async(_path,method,body)=>method==='PATCH'?{...modules,revision:2,values:body.values}:{groups:[modules]});
const saved=vi.fn();
await act(async()=>render(<BusinessSettings api={{businessSettingsRequest:request,businessTtsPreview:vi.fn()}} group="modules" onSaved={saved}/>,root));
await act(async()=>root.querySelector<HTMLInputElement>('[type=checkbox]')!.click());
await click('保存配置');
expect(request.mock.calls.filter(call=>call[1]==='PATCH')).toHaveLength(0);
expect(document.querySelector('[role=alertdialog]')?.textContent).toContain('关闭影音模块');
await act(async()=>[...document.querySelectorAll<HTMLButtonElement>('[role=alertdialog] button')].find(button=>button.textContent==='确认')!.click());
expect(request).toHaveBeenCalledWith('modules','PATCH',{values:{mediaEnabled:false,newUserMediaEnabled:true},revision:1});
expect(saved).toHaveBeenCalledOnce();
});
+8
View File
@@ -51,6 +51,14 @@ const settle = () => act(async () => { await new Promise(resolve => setTimeout(r
afterEach(() => { act(() => render(null, root)); useAuthStore.setState({ session: null, verifiedUser: null }); localStorage.clear(); });
describe('首页', () => {
it('pure reading never requests media history or discovery',async()=>{
useAuthStore.setState({verifiedUser:{...user,role:'member',features:{media:false}}});
const runtime=mount();await settle();
expect(runtime.mediaApi.request).not.toHaveBeenCalled();
expect(runtime.mediaApi.detail).not.toHaveBeenCalled();
expect(root.querySelectorAll('a[href^="/media/"]')).toHaveLength(0);
expect(root.textContent).toContain('我的书架');
});
it('greets by time of day', () => {
expect([2, 8, 14, 21].map(greeting)).toEqual(['夜深了', '早上好', '下午好', '晚上好']);
});
+21
View File
@@ -0,0 +1,21 @@
import {expect,it,vi} from 'vitest';
import {ReaderApi} from '../src/api/client.ts';
import {FakeTransport,makePlatform} from './helpers/env.ts';
it('persists effective capabilities for offline restore and preserves them through token renewal',async()=>{
const transport=new FakeTransport();
const user={id:'reader',username:'reader',displayName:'Reader',role:'member' as const,createdAt:0,mediaEnabled:true};
const session={user,accessToken:'access',refreshToken:'refresh',accessTokenExpiresAt:9e15,refreshTokenExpiresAt:9e15};
const save=vi.fn(async(_value:unknown)=>undefined);
const api=new ReaderApi(makePlatform(transport),{load:async()=>session,save,clear:async()=>undefined});
await api.restore();
transport.respondWith(()=>({status:200,headers:{},json:{user:{...user,features:{media:false},mediaState:'disabled'}}}));
expect((await api.me()).features?.media).toBe(false);
expect(api.currentSession()?.user.features?.media).toBe(false);
expect(save.mock.calls[0]?.[0]).toMatchObject({user:{features:{media:false}}});
api.currentSession()!.accessTokenExpiresAt=0;
transport.respondWith(()=>({status:200,headers:{},json:{...session,accessToken:'renewed',accessTokenExpiresAt:9e15}}));
await api.renewSessionIfNeeded();
expect(api.currentSession()?.accessToken).toBe('renewed');
expect(api.currentSession()?.user.features?.media).toBe(false);
});
@@ -0,0 +1,85 @@
// @vitest-environment jsdom
import {afterEach,expect,it,vi} from 'vitest';
import type {Platform,HttpResponse} from '../src/core/platform.ts';
import {FakeTransport,makePlatform} from './helpers/env.ts';
import {createAppRuntime} from '../src/app/runtime.ts';
import {useAuthStore} from '../src/shared/stores/auth.store.ts';
const fixture=vi.hoisted(()=>({platform:null as Platform|null,created:0,stopped:vi.fn()}));
vi.mock('../src/core/web-platform.ts',()=>({createWebPlatform:async()=>fixture.platform}));
vi.mock('../src/core/sync.ts',()=>({SyncEngine:class{
start(){} stop(){} async flush(){} onStatus(){return ()=>{};}
status(){return {state:'idle',lastSyncAt:null,pending:false,message:''};}
}}));
vi.mock('../src/features/media/services/player.ts',()=>({MediaPlayer:class extends EventTarget{
active=true;
constructor(){super();fixture.created++;}
async stop(){this.active=false;fixture.stopped();}
async flush(){} reconnectNative(){}
}}));
afterEach(()=>{fixture.created=0;fixture.stopped.mockClear();useAuthStore.setState({session:null,verifiedUser:null});});
async function setup(enabled=false){
const transport=new FakeTransport(),platform=makePlatform(transport);
fixture.platform=platform;
const user={id:'reader',username:'reader',displayName:'Reader',role:'member' as const,createdAt:0,mediaEnabled:true,features:{media:enabled}};
const session={user,accessToken:'access',refreshToken:'refresh',accessTokenExpiresAt:9e15,refreshTokenExpiresAt:9e15};
await platform.kv.set('reader.session.v1',JSON.stringify(session));
transport.json({user});
const runtime=await createAppRuntime('https://reader.example');
return {runtime,transport,user,session};
}
it('pure-reading startup, page flush and capability polling do not initialize a player',async()=>{
const {runtime}=await setup();
const disconnect=runtime.connect();
try{
await runtime.flush();await runtime.saveBeforeUpdate();await runtime.refreshCapabilities();
expect(fixture.created).toBe(0);
expect(useAuthStore.getState().verifiedUser?.features?.media).toBe(false);
}finally{disconnect();runtime.queryClient.clear();}
});
it('revocation stops an existing player once and cancels media caches',async()=>{
const {runtime,transport,user}=await setup(true);
void runtime.player;
runtime.queryClient.setQueryData(['media','scope','history'],{private:true});
transport.json({user:{...user,features:{media:false}}});
await runtime.refreshCapabilities();await runtime.refreshCapabilities();
expect(fixture.created).toBe(1);expect(fixture.stopped).toHaveBeenCalledOnce();
expect(runtime.queryClient.getQueryData(['media','scope','history'])).toBeUndefined();
expect(useAuthStore.getState().verifiedUser?.features?.media).toBe(false);
runtime.queryClient.clear();
});
it('concurrent capability refreshes share one request',async()=>{
const {runtime,transport,user}=await setup();
let finish!:(response:HttpResponse)=>void;
transport.respondWith(()=>new Promise(resolve=>{finish=resolve;}));
const requests=transport.requests.length;
const first=runtime.refreshCapabilities(),second=runtime.refreshCapabilities();
await vi.waitFor(()=>expect(transport.requests.length).toBe(requests+1));
finish({status:200,headers:{},json:{user}});
await Promise.all([first,second]);runtime.queryClient.clear();
});
it('a late capability response from the previous account cannot enable media on the new account',async()=>{
const {runtime,transport,user,session}=await setup();
const disconnect=runtime.connect();
try{
let finish!:(response:HttpResponse)=>void;
transport.respondWith(()=>new Promise(resolve=>{finish=resolve;}));
const old=runtime.refreshCapabilities();
await vi.waitFor(()=>expect(finish).toBeTypeOf('function'));
const nextUser={...user,id:'other',features:{media:false}};
transport.respondWith(request=>({status:200,headers:{},json:request.url.endsWith('/login')?{...session,user:nextUser}:{user:nextUser}}));
await runtime.api.login('other','password123');
await runtime.refreshCapabilities();
finish({status:200,headers:{},json:{user:{...user,features:{media:true}}}});
await old.catch(()=>undefined);
expect(useAuthStore.getState().verifiedUser?.id).toBe('other');
expect(useAuthStore.getState().verifiedUser?.features?.media).toBe(false);
expect(fixture.created).toBe(0);
}finally{disconnect();runtime.queryClient.clear();}
});
+89
View File
@@ -0,0 +1,89 @@
// @vitest-environment jsdom
import {act} from 'react';
import {afterEach,beforeEach,expect,it,vi} from 'vitest';
import {MemoryRouter,Route,Routes,createMemoryRouter,RouterProvider} from 'react-router-dom';
import {QueryClient,QueryClientProvider} from '@tanstack/react-query';
import {render} from '../src/shared/ui/render-root.ts';
import {RuntimeContext} from '../src/app/providers/runtime-context.tsx';
import {useAuthStore} from '../src/shared/stores/auth.store.ts';
import {AppNav} from '../src/app/AppNav.tsx';
import {MediaLayout} from '../src/app/App.tsx';
import {SearchPage} from '../src/features/search/pages/SearchPage.tsx';
import {StatsPage} from '../src/features/stats/pages/StatsPage.tsx';
import {UserManagement} from '../src/ui/system-settings.tsx';
import {dismissAllNotices} from '../src/ui/notifications.ts';
import {routes} from '../src/app/router/routes.tsx';
const root=document.createElement('div');document.body.append(root);
const user={id:'reader',username:'reader',displayName:'Reader',role:'admin' as const,createdAt:0,mediaEnabled:false,features:{media:false}};
let client:QueryClient;
const runtime={
api:{baseUrl:'https://reader.example',currentSession:()=>({user}),listBooks:vi.fn(async()=>({items:[],total:0})),readingStats:vi.fn(async()=>({series:[],works:[]})),readingStatsUsers:vi.fn(async()=>[])},
mediaApi:{preferenceScope:()=> 'features-test',search:vi.fn(),timeStats:vi.fn(),statsUsers:vi.fn()},
} as any;
const paint=(element:React.ReactNode,path='/')=>act(async()=>render(<RuntimeContext value={runtime}><QueryClientProvider client={client}><MemoryRouter initialEntries={[path]}>{element}</MemoryRouter></QueryClientProvider></RuntimeContext>,root));
beforeEach(()=>{client=new QueryClient({defaultOptions:{queries:{retry:false}}});useAuthStore.setState({session:{user,accessToken:'access',refreshToken:'refresh',accessTokenExpiresAt:9e15,refreshTokenExpiresAt:9e15},verifiedUser:user});});
afterEach(()=>{act(()=>render(null,root));client.clear();vi.clearAllMocks();useAuthStore.setState({session:null,verifiedUser:null});dismissAllNotices();});
it('pure reading navigation has three destinations and book-only search',async()=>{
await paint(<AppNav mode="top"/>);
expect([...root.querySelectorAll('.app-nav-list > li > a')].map(link=>link.textContent)).toEqual(['首页','书架','我的']);
expect(root.querySelector('input[aria-label="搜索书籍"]')).not.toBeNull();
expect(root.querySelector('[aria-label="影音频道"]')).toBeNull();
});
it('old media deep links cannot mount their children',async()=>{
const mounted=vi.fn();
function Child(){mounted();return <div>Forbidden media</div>;}
await paint(<Routes><Route path="/media" element={<MediaLayout/>}><Route path="video" element={<Child/>}/></Route></Routes>,'/media/video');
expect(mounted).not.toHaveBeenCalled();
expect(root.textContent).toContain('影音模块未启用');
expect(root.querySelector('a')?.getAttribute('href')).toBe('/');
});
it('actual application routes keep account and administrator recovery links accessible in pure reading',async()=>{
const router=createMemoryRouter(routes,{initialEntries:['/me']});
try{
await act(async()=>render(<RuntimeContext value={runtime}><QueryClientProvider client={client}><RouterProvider router={router}/></QueryClientProvider></RuntimeContext>,root));
expect(root.querySelector('a[href="/me/admin/users"]')).not.toBeNull();
expect(root.querySelector('a[href="/me/admin/services"]')).not.toBeNull();
expect(root.querySelector('a[href="/me/account"]')).not.toBeNull();
expect(root.textContent).not.toContain('影音模块未启用');
}finally{router.dispose();}
});
it('search does not fetch or show media even if its cache still contains results',async()=>{
client.setQueryData(['search','media',runtime.api.baseUrl,user.id,'test'],{items:[{id:'old',title:'Private media'}],total:1});
await paint(<SearchPage/>,'/search?q=test');
expect(runtime.api.listBooks).toHaveBeenCalled();
expect(runtime.mediaApi.search).not.toHaveBeenCalled();
expect(root.querySelector('[aria-label="影音搜索结果"]')).toBeNull();
expect(root.querySelector('input')?.placeholder).toBe('搜索书名、作者');
expect(root.textContent).not.toContain('Private media');
});
it('statistics keep reading but skip media and ignore an old video filter',async()=>{
await paint(<StatsPage/>,'/me/stats?kind=video');
expect(runtime.api.readingStats).toHaveBeenCalled();
expect(runtime.mediaApi.timeStats).not.toHaveBeenCalled();
expect(runtime.mediaApi.statsUsers).not.toHaveBeenCalled();
expect(root.querySelector('[aria-label="类型"]')?.textContent).not.toContain('观影');
});
it('administrators can change their own media permission while the global switch is off',async()=>{
const onSaved=vi.fn();
let enabled=true;
const api={currentSession:()=>({user}),adminUsers:vi.fn(async()=>({users:[{...user,mediaEnabled:enabled,disabled:false}]})),businessSettingsRequest:vi.fn(async()=>({groups:[{group:'modules',values:{mediaEnabled:false,newUserMediaEnabled:false}}]})),adminUpdateUser:vi.fn(async(_id,body)=>{enabled=body.mediaEnabled;})} as any;
await paint(<UserManagement api={api} onSaved={onSaved}/>);
expect(root.textContent).toContain('全站影音已关闭');
await act(async()=>[...root.querySelectorAll('button')].find(button=>button.textContent==='新增用户')!.click());
expect(root.querySelector<HTMLInputElement>('[type=checkbox]')?.checked).toBe(false);
await act(async()=>[...root.querySelectorAll('button')].find(button=>button.textContent==='新增用户')!.click());
await act(async()=>[...root.querySelectorAll('button')].find(button=>button.textContent==='关闭影音')!.click());
await act(async()=>[...document.querySelectorAll<HTMLButtonElement>('[role=alertdialog] button')].find(button=>button.textContent==='确认')!.click());
expect(api.adminUpdateUser).toHaveBeenCalledWith(user.id,{mediaEnabled:false});
expect(onSaved).toHaveBeenCalledOnce();
expect(root.textContent).toContain('影音:未启用');
expect(root.textContent).toContain('启用影音');
});
+9
View File
@@ -33,6 +33,15 @@ afterEach(() => {
});
describe('我的', () => {
it('pure reading hides media management but keeps administrator recovery controls',()=>{
useAuthStore.setState({verifiedUser:{...user('admin'),features:{media:false}}});
paint();
const links=[...root.querySelectorAll<HTMLAnchorElement>('a')];
expect(links.some(link=>link.getAttribute('href')?.startsWith('/media/'))).toBe(false);
expect(links.some(link=>link.getAttribute('href')==='/me/admin/users')).toBe(true);
expect(links.some(link=>link.getAttribute('href')==='/me/admin/services')).toBe(true);
expect(root.textContent).not.toContain('观影');
});
it('shows who is signed in, which server, and opens every row as a page', () => {
useAuthStore.setState({ verifiedUser: user('member') });
paint();
+12 -2
View File
@@ -1,11 +1,12 @@
// @vitest-environment jsdom
import { act } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import { render } from '../src/shared/ui/render-root.ts';
import { LegacySettingsRedirect, SettingsPage } from '../src/features/account/pages/SettingsPage.tsx';
import { RuntimeContext } from '../src/app/providers/runtime-context.tsx';
import { readMediaPreferences } from '../src/features/media/services/preferences.ts';
import { useAuthStore } from '../src/shared/stores/auth.store.ts';
const root = document.createElement('div');
document.body.append(root);
@@ -32,12 +33,21 @@ const paint = (entry: string) => act(() => render(
));
const where = () => root.querySelector('[data-where]')?.getAttribute('data-where');
afterEach(() => { act(() => render(null, root)); localStorage.clear(); updateSettings.mockClear(); });
beforeEach(()=>useAuthStore.setState({verifiedUser:{id:'settings-user',username:'reader',displayName:'Reader',role:'member',createdAt:0}}));
afterEach(() => { act(() => render(null, root));useAuthStore.setState({session:null,verifiedUser:null});localStorage.clear(); updateSettings.mockClear(); });
describe('偏好设置', () => {
const tabs = () => [...root.querySelectorAll<HTMLButtonElement>('[role=tab]')];
const selected = () => tabs().filter(tab => tab.getAttribute('aria-selected') === 'true').map(tab => tab.textContent);
it('pure reading hides media tabs and rejects an old playback tab selection',()=>{
useAuthStore.setState({verifiedUser:{id:'reader',username:'reader',displayName:'Reader',role:'admin',createdAt:0,features:{media:false}}});
paint('/me/settings?section=playback');
expect(tabs().map(tab=>tab.textContent)).toEqual(['外观与主题','书架']);
expect(selected()).toEqual(['外观与主题']);
expect(root.querySelector('.playback-settings')).toBeNull();
});
it('shows the four kinds of preference as tabs, one panel at a time', () => {
paint('/me/settings');
expect(tabs().map(tab => tab.textContent)).toEqual(['外观与主题', '书架', '影音浏览', '播放']);
+126
View File
@@ -0,0 +1,126 @@
import assert from 'node:assert/strict';
import {mkdtemp,mkdir,rm} from 'node:fs/promises';
import {tmpdir} from 'node:os';
import {join,resolve} from 'node:path';
import {spawn} from 'node:child_process';
import {createInterface} from 'node:readline';
import {setTimeout as delay} from 'node:timers/promises';
import {fileURLToPath} from 'node:url';
import {chromium} from 'playwright';
const repo=resolve(fileURLToPath(new URL('../../..',import.meta.url)));
const root=await mkdtemp(join(tmpdir(),'reader-media-features-'));
const output=join(repo,'artifacts/ui-review/media-features');
await mkdir(join(root,'books'));await mkdir(output,{recursive:true});
const child=spawn(process.execPath,[join(repo,'server/dist/main.js')],{cwd:repo,stdio:['ignore','pipe','pipe'],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:'0',LOG_LEVEL:'info',READER_TOKEN_SECRET:'media-features-browser-fixture',SCAN_INTERVAL:'0',WATCH_INTERVAL:'0'}});
const exited=new Promise(resolveExit=>child.once('exit',resolveExit));
let logs='';child.stderr.on('data',chunk=>{logs+=chunk;});
const lines=createInterface({input:child.stdout});
let browser;
try{
const origin=await new Promise((resolveOrigin,reject)=>{
const timer=setTimeout(()=>reject(new Error('fixture start timeout '+logs)),30000);
lines.on('line',line=>{
logs+=line+'\n';
try{const match=JSON.parse(line).msg?.match(/Server listening at (http:\/\/\S+)/);if(match){clearTimeout(timer);resolveOrigin(match[1]);}}catch{}
});
child.once('error',error=>{clearTimeout(timer);reject(error);});
child.once('exit',()=>{clearTimeout(timer);reject(new Error('fixture exited '+logs));});
});
const call=async(path,method='GET',body,token)=>{
const response=await fetch(origin+'/api/v1/'+path,{method,headers:{...(token?{authorization:'Bearer '+token}:{}),...(body?{'content-type':'application/json'}:{})},...(body?{body:JSON.stringify(body)}:{})});
const result=await response.json();assert.ok(response.ok,JSON.stringify(result));return result;
};
const {session}=await call('auth/register','POST',{username:'admin',password:'password123'});
const token=session.accessToken;
const {user:member}=await call('admin/users','POST',{username:'member',password:'password123',displayName:'Member',role:'member'},token);
const waitState=async state=>{
for(let attempt=0;attempt<100;attempt++){
if((await call('auth/me','GET',undefined,token)).user.mediaState===state)return;
await delay(100);
}
throw new Error('media state did not become '+state);
};
await waitState('ready');
browser=await chromium.launch({headless:true,args:['--no-sandbox']});
const admin=await browser.newPage({viewport:{width:1120,height:900}});
const errors=[];
admin.on('pageerror',error=>errors.push(error.message));
const login=async(page,name)=>{
await page.goto(origin);
await page.locator('#login-username').fill(name);
await page.locator('#login-password').fill('password123');
await page.getByRole('button',{name:'登录',exact:true}).click();
await page.locator('.app-nav').waitFor();
};
await login(admin,'admin');
const toggleUser=async(name,enabled)=>{
await admin.goto(origin+'/#/me/admin/users');
const card=admin.locator('.user-manager-list > li').filter({hasText:'@'+name});
await card.getByRole('button',{name:enabled?'启用影音':'关闭影音',exact:true}).click();
await admin.getByRole('alertdialog').getByRole('button',{name:'确认',exact:true}).click();
await card.getByRole('button',{name:enabled?'关闭影音':'启用影音',exact:true}).waitFor();
};
await toggleUser('member',false);
assert.equal((await call('auth/me','GET',undefined,token)).user.features.media,true);
const memberContext=await browser.newContext({viewport:{width:390,height:844}});
const memberPage=await memberContext.newPage();
memberPage.on('pageerror',error=>errors.push(error.message));
const mediaRequests=[];
memberPage.on('request',request=>{if(new URL(request.url()).pathname.startsWith('/api/v1/media/'))mediaRequests.push(request.url());});
await login(memberPage,'member');
assert.equal(await memberPage.locator('.app-nav-list > li').count(),3);
assert.equal(await memberPage.locator('.media-player').count(),0);
for(const route of ['/search?q=fixture','/me/settings?section=playback','/me/stats','/media/video']){
await memberPage.goto(origin+'/#'+route);
await delay(250);
assert.equal(await memberPage.locator('body').evaluate(element=>element.scrollWidth>innerWidth),false,route);
}
await memberPage.getByRole('heading',{name:'影音模块未启用'}).waitFor();
assert.equal(mediaRequests.length,0);
const memberLogin=await call('auth/login','POST',{username:'member',password:'password123'});
const denied=await fetch(origin+'/api/v1/media/libraries',{headers:{authorization:'Bearer '+memberLogin.accessToken}});
assert.equal(denied.status,403);
assert.equal((await denied.json()).error.code,'MEDIA_DISABLED');
await toggleUser('admin',false);
const toggleGlobal=async enabled=>{
await admin.goto(origin+'/#/me/admin/services');
await admin.getByLabel('配置分类',{exact:true}).selectOption('modules');
await admin.getByRole('checkbox',{name:'启用影音模块'}).setChecked(enabled);
await admin.getByRole('button',{name:'保存配置',exact:true}).click();
if(!enabled)await admin.getByRole('alertdialog').getByRole('button',{name:'确认',exact:true}).click();
await waitState(enabled?'ready':'disabled');
};
await toggleGlobal(false);
await admin.reload();
await admin.getByText('影音服务:已关闭',{exact:true}).waitFor();
await admin.getByLabel('配置分类',{exact:true}).selectOption('modules');
assert.equal(await admin.locator('.app-nav-list > li').count(),3);
assert.equal(await admin.locator('.media-player').count(),0);
for(const width of [390,1120]){
await admin.setViewportSize({width,height:900});
assert.equal(await admin.locator('body').evaluate(element=>element.scrollWidth>innerWidth),false);
await admin.screenshot({path:join(output,`pure-reading-${width}.png`),fullPage:true});
}
await toggleGlobal(true);
assert.equal((await call('auth/me','GET',undefined,token)).user.features.media,false);
await toggleUser('admin',true);
await admin.locator('.app-nav-list > li').nth(3).waitFor();
await toggleUser('member',true);
await memberPage.evaluate(()=>window.dispatchEvent(new Event('focus')));
await memberPage.locator('.app-nav-list > li').nth(3).waitFor();
assert.equal((await call('admin/users','GET',undefined,token)).users.find(user=>user.id===member.id).mediaEnabled,true);
assert.deepEqual(errors,[]);
console.log('PASS: administrator global/user switches, self-recovery, worker restart, pure-reading routes, no media requests/player, responsive layouts, and cross-window permission refresh');
}catch(error){
if(browser)for(const context of browser.contexts())for(const page of context.pages()){
console.error('PAGE',page.url(),(await page.locator('body').innerText()).slice(0,3000));
await page.screenshot({path:join(output,`failure-${browser.contexts().indexOf(context)}.png`),fullPage:true});
}
throw error;
}finally{
await browser?.close();lines.close();child.kill('SIGTERM');
await Promise.race([exited,delay(10000)]);
if(child.exitCode===null){child.kill('SIGKILL');await exited;}
await rm(root,{recursive:true,force:true});
}