diff --git a/README.md b/README.md index acb23e9..01620b2 100644 --- a/README.md +++ b/README.md @@ -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) | diff --git a/docs/README.md b/docs/README.md index b07ee67..487c400 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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) | diff --git a/docs/api.md b/docs/api.md index 1e93b9f..09978a6 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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. diff --git a/docs/business-settings.zh-CN.md b/docs/business-settings.zh-CN.md index 53c1525..8f4fc4c 100644 --- a/docs/business-settings.zh-CN.md +++ b/docs/business-settings.zh-CN.md @@ -2,8 +2,11 @@ 管理员在「我的 → 管理 → 服务配置」编辑实例业务配置,通过「配置分类」切换各分类。普通用户无法读取或修改服务凭据。 +每个用户的影音权限在「我的 → 用户管理」中设置,包括管理员自己的权限;全站关闭时仍可管理。总开关与个人开关同时开启才可使用影音。生命周期、共享数据库与播放直链边界见[影音模块开关与纯阅读模式](media-features.md)。 + | 分类 | 可修改内容 | | --- | --- | +| 功能模块 | 全站启用影音、新用户默认启用影音,均默认开启;全站关闭进入纯阅读模式,保留影音数据 | | AI 功能 | 启用 AI、AI 扫描与 AI 总结;OpenAI 兼容接口地址、API Key、模型(填写地址和密钥后可获取模型列表)、扫描与总结 Prompt、扫描批次大小、总结最大输入字符数 | | 媒体播放 | OpenList 自动(直连优先)、浏览器直连、服务器代理;默认自动 | | TMDB | 启用、读取令牌、API Key、元数据语言,令牌优先于 API Key | diff --git a/docs/configuration.en.md b/docs/configuration.en.md index f2ae3a2..b1fd8fa 100644 --- a/docs/configuration.en.md +++ b/docs/configuration.en.md @@ -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). diff --git a/docs/configuration.zh-CN.md b/docs/configuration.zh-CN.md index 7ae5742..23b22dc 100644 --- a/docs/configuration.zh-CN.md +++ b/docs/configuration.zh-CN.md @@ -15,6 +15,8 @@ ## 页面业务配置 +「我的 → 服务配置 → 功能模块」提供全站影音开关与新用户默认权限;「我的 → 用户管理」提供每个账号的影音开关。这些是数据库业务设置,不是环境变量;共享核心数据库的实例共用总开关。关闭不删除影音数据,也不影响阅读和书籍朗读。详见[纯阅读模式](media-features.md)。 + 管理员在「我的 → 服务配置」中管理 AI 功能、媒体播放方式、HTTP 朗读、扫描、刮削、登录有效期、对外地址、浏览器来源、TMDB、MusicBrainz 及 WebDAV 备份目标,注册策略在「我的 → 注册与邀请」中设置。配置写入 reader.db,保存后无需重启。详见[业务配置](business-settings.zh-CN.md)。 升级首次启动会导入旧业务环境变量;数据库存在配置后不再读取这些环境变量,清空字段也不会恢复旧值。 diff --git a/docs/development.zh-CN.md b/docs/development.zh-CN.md index 03bf32e..05efe1d 100644 --- a/docs/development.zh-CN.md +++ b/docs/development.zh-CN.md @@ -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. 为行为变更补充适当的回归验证;纯文档修改检查链接、命令和示例即可。 diff --git a/docs/media-features.md b/docs/media-features.md new file mode 100644 index 0000000..5dcca2f --- /dev/null +++ b/docs/media-features.md @@ -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`,失效的播放票据仍沿用原有票据错误响应。 + +能力信息随当前登录会话在设备保存,离线恢复沿用上次已确认的权限;切换服务器或账号后会重新确认,不使用前一账号的能力信息。 diff --git a/docs/media-implementation.md b/docs/media-implementation.md index 1edbee5..b8c2650 100644 --- a/docs/media-implementation.md +++ b/docs/media-implementation.md @@ -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)。 diff --git a/docs/media-navigation.md b/docs/media-navigation.md index 8b10e68..6a9ed1e 100644 --- a/docs/media-navigation.md +++ b/docs/media-navigation.md @@ -2,6 +2,8 @@ ## 应用导航与频道切换 +以下影音入口仅在全站和当前用户均启用影音时显示。纯阅读模式仅保留首页、书架、我的三个导航,不显示影音频道、推荐、搜索结果或影音管理入口;偏好设置仅保留外观与主题、书架两个标签。旧影音链接显示未启用提示,不挂载影音内容。管理员始终可以访问服务配置和用户管理;`/me` 不受影音路由守卫限制。开关与更新时机见[功能模块说明](media-features.md)。 + 全应用只有一套导航:手机为底部标签栏,701px 起为左侧边栏,包含首页(#/)、书架(#/shelf)、影音(#/media/video)和我的(#/me)。宽屏侧边栏在「影音」下列出影视、音乐、有声书三个频道;手机在频道分类页顶部用影视/音乐/有声书三段切换频道(旧的频道菜单和频道导轨已移除)。 频道下方是分类标签:影视(首页、电影、剧集)、音乐(专辑、歌手、曲目)、有声书(有声书、演播者)。手机上左右滑动切换相邻分类,越过频道最后一个分类即进入下一频道;同频道内保留 library 和 sort 参数,跨频道重置。频道尚无媒体库时滑动按整个频道切换。分类页右上角 ⋯ 菜单提供收藏、历史、队列,管理员另有媒体库管理(身份确认前按成员处理);演播者目录页也显示同一排分类标签。工具栏提供文件夹入口。 diff --git a/docs/media-user-guide.zh-CN.md b/docs/media-user-guide.zh-CN.md index 71df38f..cebf3e7 100644 --- a/docs/media-user-guide.zh-CN.md +++ b/docs/media-user-guide.zh-CN.md @@ -4,6 +4,8 @@ ## 新建与管理媒体库 +管理员可在「我的 → 服务配置 → 功能模块」关闭全站影音进入纯阅读模式,也可在「用户管理」逐个启用或关闭账号的影音权限。数据保留、重新开启可恢复;书籍朗读不受影响。详见[影音模块开关](media-features.md)。 + 管理员从「我的 → 媒体库」(或影视页「更多操作 → 媒体库管理」)进入,点「新建媒体库」,选择内容类型、接入方式和访问范围。普通用户只看到已获授权的媒体库。 - **服务器目录**:填写 Reader 服务端能读取的路径。Docker 部署填写容器内路径,例如将宿主目录只读挂载到 /media/music 后,填 /media/music。 diff --git a/docs/media-validation.md b/docs/media-validation.md index 485014d..9caeca7 100644 --- a/docs/media-validation.md +++ b/docs/media-validation.md @@ -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`、页面错误和横向溢出结果为准,不把历史截图数量写入文档。 diff --git a/docs/ui.md b/docs/ui.md index 08c5455..665fb72 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -857,6 +857,7 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「 - 导航按页面类型显示:顶层页面显示;详情、设置、书库文件等二级页面在手机上由自己的返回箭头取代底栏,桌面端侧栏保留;阅读器和播放页不显示。规则在 `web/src/app/AppNav.tsx` 的 `navMode`。 - 主题在应用壳层统一应用(`ThemeController` 写 ``)。登录页在应用主题之前显示,使用暖陶并跟随设备深浅色。书架、书库、书源、首页、阅读器的 `--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`,不要自己读存储。 diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 3919b3a..8a47c9d 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -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; diff --git a/server/src/http/app.ts b/server/src/http/app.ts index 182a434..1541ae6 100644 --- a/server/src/http/app.ts +++ b/server/src/http/app.ts @@ -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()); diff --git a/server/src/http/context.ts b/server/src/http/context.ts index dce8168..5529e1c 100644 --- a/server/src/http/context.ts +++ b/server/src/http/context.ts @@ -15,6 +15,7 @@ export interface AppContext { config: AppConfig; settings?:BusinessSettings; settingsChanged?:(group:string)=>void; + mediaState?:()=>string; db: Db; scanner: Scanner; users: UserService; diff --git a/server/src/http/routes/auth.ts b/server/src/http/routes/auth.ts index a5333c6..41ed9e1 100644 --- a/server/src/http/routes/auth.ts +++ b/server/src/http/routes/auth.ts @@ -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', diff --git a/server/src/http/routes/library.ts b/server/src/http/routes/library.ts index 5297c49..845e96a 100644 --- a/server/src/http/routes/library.ts +++ b/server/src/http/routes/library.ts @@ -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() }; }); diff --git a/server/src/http/routes/media.ts b/server/src/http/routes/media.ts index fb5a80e..c00fd6b 100644 --- a/server/src/http/routes/media.ts +++ b/server/src/http/routes/media.ts @@ -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 & {db:MediaDatabase & Pick;users:Pick}; @@ -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 Mapvoid>(); + 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)]});}); diff --git a/server/src/http/routes/opds.ts b/server/src/http/routes/opds.ts index fc2410c..2138180 100644 --- a/server/src/http/routes/opds.ts +++ b/server/src/http/routes/opds.ts @@ -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) => { diff --git a/server/src/main.ts b/server/src/main.ts index c4d9f87..1aeebe1 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -38,6 +38,7 @@ async function main(): Promise { 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 { 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); diff --git a/server/src/media/accounts.ts b/server/src/media/accounts.ts index fbd68a6..c501bd9 100644 --- a/server/src/media/accounts.ts +++ b/server/src/media/accounts.ts @@ -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) {} get(id: string): MediaAccount | undefined { - return this.db.get('SELECT id,role,disabled,auth_version FROM users WHERE id=?', id); + const account=this.db.get('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; } } diff --git a/server/src/media/automation.ts b/server/src/media/automation.ts index 9402ab2..2058b8a 100644 --- a/server/src/media/automation.ts +++ b/server/src/media/automation.ts @@ -23,6 +23,7 @@ interface Log { info(data: object, message: string): void; warn(data: object, me */ export class MediaAutomation { private timer: ReturnType | undefined; + private stopped=false; constructor( private readonly accounts: Pick, 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); diff --git a/server/src/media/background-grants.ts b/server/src/media/background-grants.ts index ecf3779..88459d0 100644 --- a/server/src/media/background-grants.ts +++ b/server/src/media/background-grants.ts @@ -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('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){ diff --git a/server/src/media/playback.ts b/server/src/media/playback.ts index da24113..425636c 100644 --- a/server/src/media/playback.ts +++ b/server/src/media/playback.ts @@ -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('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('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}; diff --git a/server/src/media/runtime-worker.ts b/server/src/media/runtime-worker.ts index 14b90b9..0f3a7ae 100644 --- a/server/src/media/runtime-worker.ts +++ b/server/src/media/runtime-worker.ts @@ -35,7 +35,7 @@ app.addHook('onRequest',async(request,reply)=>{ registerErrorHandler(app); registerMediaRoutes(app,{config,db:core,users:{byId:id=>core.get( // 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|undefined; diff --git a/server/src/media/runtime.ts b/server/src/media/runtime.ts index 1c3d03b..dde1ba9 100644 --- a/server/src/media/runtime.ts +++ b/server/src/media/runtime.ts @@ -48,7 +48,7 @@ export class MediaRuntime { return fetch(url,{...options,headers,redirect:'manual'}); } async close():Promise{ - 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);} diff --git a/server/src/media/start-runtime.ts b/server/src/media/start-runtime.ts index 245cd61..78fa349 100644 --- a/server/src/media/start-runtime.ts +++ b/server/src/media/start-runtime.ts @@ -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; /** 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;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|undefined; + const settings=new BusinessSettingsReader(db); + const setEnabled=(next:boolean):Promise=>{ + 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{ diff --git a/server/src/services/business-settings.ts b/server/src/services/business-settings.ts index 2ecb16f..79587ad 100644 --- a/server/src/services/business-settings.ts +++ b/server/src/services/business-settings.ts @@ -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= { + 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}, diff --git a/server/src/services/users.ts b/server/src/services/users.ts index ecdc4f0..38382b7 100644 --- a/server/src/services/users.ts +++ b/server/src/services/users.ts @@ -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 { 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 { 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 { if (!this.byId(userId)) throw notFound('用户不存在'); if (password.length < 8 || password.length > 1024) throw conflict('密码须为 8–1024 位','PASSWORD_TOO_SHORT'); diff --git a/server/test/media-automation.test.ts b/server/test/media-automation.test.ts index 45e0fd9..ef13d7c 100644 --- a/server/test/media-automation.test.ts +++ b/server/test/media-automation.test.ts @@ -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 () => { diff --git a/server/test/media-features.test.ts b/server/test/media-features.test.ts new file mode 100644 index 0000000..e363db8 --- /dev/null +++ b/server/test/media-features.test.ts @@ -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'); +}); diff --git a/server/test/media-runtime.test.ts b/server/test/media-runtime.test.ts index 568f6e3..4a8d33e 100644 --- a/server/test/media-runtime.test.ts +++ b/server/test/media-runtime.test.ts @@ -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); diff --git a/web/package.json b/web/package.json index 49316b6..cb3c853 100644 --- a/web/package.json +++ b/web/package.json @@ -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": { diff --git a/web/src/api/client.ts b/web/src/api/client.ts index d84997b..0f07a5c 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -241,13 +241,21 @@ export class ReaderApi { } async me(): Promise { + 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 { return this.call('/api/v1/admin/users','POST',input); } - async adminUpdateUser(id:string,input:{disabled?:boolean;role?:'admin'|'member'}): Promise { 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 { return this.call('/api/v1/admin/users','POST',input); } + async adminUpdateUser(id:string,input:{disabled?:boolean;role?:'admin'|'member';mediaEnabled?:boolean}): Promise { return this.call('/api/v1/admin/users/'+encodeURIComponent(id),'PATCH',input); } async adminResetPassword(id:string,password:string): Promise { return this.call('/api/v1/admin/users/'+encodeURIComponent(id)+'/password','POST',{password}); } async registrationSettings(): Promise { return this.get('/api/v1/admin/registration'); } async setRegistrationMode(mode:import('./types.ts').RegistrationSettings['mode']): Promise { 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; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 9009252..ebf5ac2 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -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; diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx index b0c3865..40e5eac 100644 --- a/web/src/app/App.tsx +++ b/web/src/app/App.tsx @@ -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 ; @@ -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 ; } - +export function MediaLayout() { + const enabled=useMediaEnabled(); + if(!enabled)return

影音模块未启用

请联系管理员检查全站开关及你的影音权限。

返回首页
; + return ; +} diff --git a/web/src/app/AppNav.tsx b/web/src/app/AppNav.tsx index 8e232d1..1f5ae1e 100644 --- a/web/src/app/AppNav.tsx +++ b/web/src/app/AppNav.tsx @@ -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 }) { 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 }) { {/* Desktop only: one search across books and media (the page is /search). */}
- setQuery(event.currentTarget.value)} /> + setQuery(event.currentTarget.value)} />
    - {destinations.map(({ to, label, icon, current }) => { + {destinations.filter(item=>media||item.to!=='/media/video').map(({ to, label, icon, current }) => { const active = current(pathname); return
  • diff --git a/web/src/app/providers/AppProviders.tsx b/web/src/app/providers/AppProviders.tsx index 0755645..3d58653 100644 --- a/web/src/app/providers/AppProviders.tsx +++ b/web/src/app/providers/AppProviders.tsx @@ -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 = () => { diff --git a/web/src/app/router/routes.tsx b/web/src/app/router/routes.tsx index d6558b7..82eed49 100644 --- a/web/src/app/router/routes.tsx +++ b/web/src/app/router/routes.tsx @@ -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: }, { path: 'book/:bookId', element: }, - // 我的 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: , children: [ + { path: 'me', element: , children: [ { index: true, element: }, { path: 'settings', element: }, { path: 'stats', element: }, @@ -109,4 +108,3 @@ export const routes = [{ }] satisfies RouteObject[]; - diff --git a/web/src/app/runtime.ts b/web/src/app/runtime.ts index 62f3fb7..a1c7c62 100644 --- a/web/src/app/runtime.ts +++ b/web/src/app/runtime.ts @@ -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) | null = null; + let capabilityRevision=0; + let mediaAllowed=mediaEnabled(useAuthStore.getState().verifiedUser??useAuthStore.getState().session?.user); + let refreshing:{scope:string;promise:Promise}|undefined; + function refreshCapabilities():Promise{ + 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) { 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(); }; }, }; } diff --git a/web/src/features/account/pages/AdminPages.tsx b/web/src/features/account/pages/AdminPages.tsx index 2de6382..792b292 100644 --- a/web/src/features/account/pages/AdminPages.tsx +++ b/web/src/features/account/pages/AdminPages.tsx @@ -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 ; + const runtime = useRuntime(); + return void runtime.refreshCapabilities().catch(()=>undefined)} />; } export function AdminRegistrationPage() { @@ -28,6 +29,9 @@ export function AdminRegistrationPage() { } export function AdminServicesPage() { - const { api } = useRuntime(); - return
    ; + 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

    影音服务:{state?labels[state]:'状态待确认'}

    void runtime.refreshCapabilities().catch(()=>undefined)} />
    ; } diff --git a/web/src/features/account/pages/MinePage.tsx b/web/src/features/account/pages/MinePage.tsx index e34c4dc..11922a9 100644 --- a/web/src/features/account/pages/MinePage.tsx +++ b/web/src/features/account/pages/MinePage.tsx @@ -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() { - - {admin && } + + {admin && media && } {admin && - - + + {media&&} }
    diff --git a/web/src/features/account/pages/SettingsPage.tsx b/web/src/features/account/pages/SettingsPage.tsx index 6848548..08c1015 100644 --- a/web/src/features/account/pages/SettingsPage.tsx +++ b/web/src/features/account/pages/SettingsPage.tsx @@ -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)&§ions.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
    - {SETTINGS_SECTIONS.map((section, index) => )}
    diff --git a/web/src/features/home/pages/HomePage.tsx b/web/src/features/home/pages/HomePage.tsx index a3b9ddd..08823bd 100644 --- a/web/src/features/home/pages/HomePage.tsx +++ b/web/src/features/home/pages/HomePage.tsx @@ -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() {

    我的书架

    全部
    {books.map(item => )}
    } - + {media&&<> - + }
    ; } diff --git a/web/src/features/search/pages/SearchPage.tsx b/web/src/features/search/pages/SearchPage.tsx index a194a79..6ad1941 100644 --- a/web/src/features/search/pages/SearchPage.tsx +++ b/web/src/features/search/pages/SearchPage.tsx @@ -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 = { 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
    - setInput(event.currentTarget.value)} /> + setInput(event.currentTarget.value)} /> - {!query ?

    输入名称,同时搜索书库和有权访问的影音库。

    : <> + {!query ?

    {mediaEnabled?'输入名称,同时搜索书库和有权访问的影音库。':'输入名称搜索书库。'}

    : <>

    书籍{books.data ? {books.data.total} : null}

    {books.data && books.data.total > BOOK_LIMIT && 在书库查看全部}
    @@ -57,7 +59,7 @@ export function SearchPage() { {books.data.items.map(book => )}
    } -
    + {mediaEnabled&&

    影音{media.data ? {media.data.total} : null}

    {media.data && media.data.total > MEDIA_LIMIT && 查看全部影音结果}
    {media.isPending ?

    正在搜索影音…

    @@ -69,7 +71,7 @@ export function SearchPage() { {item.title}{channelLabels[item.channel]} · {item.libraryName} )}} -
    +
    } } ; } diff --git a/web/src/features/stats/pages/StatsPage.tsx b/web/src/features/stats/pages/StatsPage.tsx index 16bba98..489ca08 100644 --- a/web/src/features/stats/pages/StatsPage.tsx +++ b/web/src/features/stats/pages/StatsPage.tsx @@ -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) => { 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; const shown = (point: { byKind: Record }) => 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() {
    - {STAT_KINDS.map(item =>