+> v1.10.0 新增**可选**的 [Jellyfin](https://jellyfin.org/) 音源(由 [@ItsEricRao](https://github.com/ItsEricRao) 在 [PR #123](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/123) 中贡献):连接自建 Jellyfin 服务器直接播放你自己的音乐库。默认关闭,在 **设置 → Jellyfin 音乐库** 一键开启;原有在线音源保持默认启用,行为不变。
+
## 功能特性
- **WebUI 鉴权与细粒度权限(必选)** — 用户名 + 密码登录,多用户、两种角色(管理员 / 成员);成员可进一步配置**细粒度能力**(播放控制 / 队列管理 / 机器人管理 / 平台登录 / 音质)和**按机器人授权白名单**,所有变更操作由后端逐请求强制校验。bcrypt 加密、HttpOnly 会话 Cookie,CSRF 防护,WebSocket 同样鉴权。首次访问引导创建管理员。从无鉴权旧版本升级时请参阅 [更新升级](#更新升级) 章节
@@ -31,7 +34,8 @@
- **本地音频上传播放** — 在搜索页拖拽或选择本地音频上传,上传后可直接播放 / 下一首播放 / 加入队列;管理员可在 设置 → 行为设置 开关此功能,播放结束或停止/清空/替换队列时会清理服务端接收的本地文件
- **专属链接(单机器人锁定)** — 通过 `/bot/` 专属链接打开 WebUI 时锁定到单个机器人,刷新后保持,适合把某台机器人的控制页分享给特定用户
- **频道无人时自动暂停** — 机器人所在频道没有其他人时自动暂停播放,有人加入后自动恢复(**默认关闭**,可在设置中开启)
-- **多平台音源** — 网易云音乐 + QQ 音乐 + 酷狗音乐 + 哔哩哔哩(默认内置),YouTube 可选启用(通过 yt-dlp),**Spotify(实验性)** 可选启用(需 Premium + 自建开发者应用,默认关闭,详见 [Spotify 音源(实验性)](#spotify-音源实验性)),统一搜索(歌曲 / 歌单 / 专辑均支持翻页「加载更多」),结果标注来源
+- **Jellyfin 音源(可选)** — 连接自建 [Jellyfin](https://jellyfin.org/) 服务器作为额外音源:搜索(歌曲 / 专辑 / 歌单)、懒解析直传播放、同步歌词、收藏 Instant Mix 电台(`!fm -j`)、首页「最近添加 / 播放最多 / 收藏 / 流派」,并把播放进度回报给 Jellyfin(PlayCount / 播放状态)。**默认关闭**,在 设置 → Jellyfin 音乐库 一键开启。详见 [可选:Jellyfin 音源](#可选jellyfin-音源)
+- **多平台音源(enabledProviders 门控)** — 网易云音乐 / QQ 音乐 / 酷狗音乐 / 哔哩哔哩 / YouTube(yt-dlp,需安装)**默认启用**,可在 `config.json` 的 `enabledProviders` 中逐个停用;Jellyfin 为可选音源(见上),**Spotify(实验性)** 由独立开关控制(需 Premium + 自建开发者应用,默认关闭,详见 [Spotify 音源(实验性)](#spotify-音源实验性))。统一搜索(歌曲 / 歌单 / 专辑均支持翻页「加载更多」),结果标注来源,禁用音源不出现在搜索栏
- **真实客户端协议 (TS3/TS6 双协议)** — 机器人在 TeamSpeak 中可见(非 ServerQuery 隐身模式),自动检测并适配 TS3 和 TS6 服务器,支持 TS6 HTTP Query API
- **YesPlayMusic 风格 WebUI** — 精美界面,支持深色/浅色主题切换
- **完整播放控制** — 播放/暂停/上一首/下一首/进度跳转/音量调节
@@ -163,6 +167,16 @@ sudo ./scripts/install.sh
>
> **如何判断是否需要迁移**:如果你是全新安装,或者你的机器人数据库中 `identity` 字段已经是空的,则**无需任何操作**。完成上述步骤后,按下面对应的系统升级步骤执行即可。
+### 关于 enabledProviders 音源开关(v1.10.0 起)
+
+v1.10.0([PR #123](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/123))引入 `enabledProviders` 音源开关与可选的 Jellyfin 音源。当前默认值为 `["netease", "qq", "bilibili", "youtube", "kugou"]`——**与旧版行为一致**,从更早版本升级**无需任何操作**,在线音源照常可用;Jellyfin 需要手动开启(详见 [可选:Jellyfin 音源](#可选jellyfin-音源))。
+
+> ⚠️ **仅影响短暂运行过 v1.10.0 初版的用户**:该版本曾把默认音源设为 Jellyfin-only。如果你在那段时间保存过设置,`data/config.json` 中可能被写入了 `"enabledProviders": ["jellyfin"]`,升级后在线音源会保持停用。修复方法:把在线音源加回列表(或直接删除该字段以使用默认值),重启机器人(网易云 / QQ 的内嵌 API 服务需要重启才会启动):
+>
+> ```json
+> "enabledProviders": ["netease", "qq", "bilibili", "youtube", "kugou", "jellyfin"]
+> ```
+
### 从 WebUI 无鉴权版本升级(重要)
本次更新引入了**强制 WebUI 鉴权**。从无鉴权旧版本升级后,**WebUI 必须先创建管理员账号才能使用**。所有 `/api/*` 端点(除少量公共白名单)和 `/ws` 现在都需要登录。
@@ -303,14 +317,15 @@ sudo systemctl start tsmusicbot
- 机器人昵称
- 可选:服务器密码、默认频道
3. 在 **设置 → 音乐账号** 扫码登录网易云 / QQ 音乐 / 酷狗音乐 / B 站账号(可选,登录后可播放 VIP 歌曲、获取每日推荐 / 我的歌单等)
-4. 在 **设置 → 用户管理**(仅管理员可见)按需添加成员。成员默认可控制播放但无法管理其他用户;管理员还可为每个成员单独配置**能力**(播放控制 / 队列 / 机器人管理 / 平台登录 / 音质)和**可操作的机器人白名单**,未授权的机器人对该成员不可见、不可控
+4. (可选)在 **设置 → Jellyfin 音乐库** 连接自建 Jellyfin 服务器并打开「启用 Jellyfin 音源」(安装向导第 3 步保存连接时会自动启用;详见 [可选:Jellyfin 音源](#可选jellyfin-音源))
+5. 在 **设置 → 用户管理**(仅管理员可见)按需添加成员。成员默认可控制播放但无法管理其他用户;管理员还可为每个成员单独配置**能力**(播放控制 / 队列 / 机器人管理 / 平台登录 / 音质)和**可操作的机器人白名单**,未授权的机器人对该成员不可见、不可控
### WebUI 页面说明
| 页面 | 功能 |
|------|------|
-| **首页** | 推荐歌单、每日推荐、私人FM(网易云 / QQ 雷达 / 酷狗电台)、我的歌单、收藏的歌单(各源带标签切换) |
-| **搜索** | 四平台统一搜索,结果标注网易云/QQ/酷狗/B站来源,可一键收藏歌单 |
+| **首页** | 推荐歌单、每日推荐、私人FM(网易云 / QQ 雷达 / 酷狗电台)、我的歌单、收藏的歌单(各源带标签切换);启用 Jellyfin 后另有「Jellyfin 电台 / 最近添加 / 播放最多 / 收藏 / 流派」区块 |
+| **搜索** | 跨音源统一搜索(仅显示已启用的音源;启用 Jellyfin 后其结果排最前),结果标注来源,可一键收藏歌单 |
| **歌单** | 查看歌单详情,播放全部(根据当前播放模式选择首歌),一键收藏 |
| **歌词** | 全屏歌词页,实时同步滚动,模糊专辑封面背景 |
| **历史** | 播放历史记录 |
@@ -322,14 +337,16 @@ sudo systemctl start tsmusicbot
| 命令 | 说明 |
|------|------|
-| `!play <歌名>` | 搜索并播放(取最热门的匹配项) |
+| `!play <歌名>` | 搜索并播放(取最热门的匹配项;默认音源为网易云) |
+| `!play -n <歌名>` | 显式从网易云音乐搜索(默认音源即网易云,通常可省略) |
+| `!play -j <歌名>` | 从 Jellyfin 搜索(需先启用 Jellyfin 音源) |
| `!play -q <歌名>` | 从 QQ 音乐搜索 |
| `!play -k <歌名>` | 从酷狗音乐搜索 |
| `!play -b <关键词>` | 从哔哩哔哩搜索视频并播放音频 |
| `!play -y <关键词>` | 从 YouTube 搜索并播放(需要安装 [yt-dlp](#可选youtube-音源))|
-| `!search <歌名> [-q\|-k\|-b\|-y]` | 列出前若干个匹配结果(含序号与 id),用于挑选同名歌曲;可加平台标志切换音源 |
+| `!search <歌名> [-j\|-n\|-q\|-k\|-b\|-y]` | 列出前若干个匹配结果(含序号与 id),用于挑选同名歌曲;可加平台标志切换音源 |
| `!play #<序号>` | 播放上一次 `!search` 结果中的第 N 项(区分同名歌曲) |
-| `!play id:` | 按歌曲 id 播放精确的某首歌(也支持直接粘贴网易云 / QQ / B站 歌曲链接) |
+| `!play id:` | 按歌曲 id 播放精确的某首歌(也支持直接粘贴网易云 / QQ / B站 歌曲链接;Jellyfin 曲目用 GUID ItemId) |
| `!add <歌名>` | 添加到播放队列(同样支持 `#序号` / `id:` / 链接) |
| `!pause` / `!resume` | 暂停 / 恢复播放 |
| `!next` / `!prev` | 下一首 / 上一首 |
@@ -338,11 +355,12 @@ sudo systemctl start tsmusicbot
| `!queue` | 查看播放队列 |
| `!remove <位置>` | 从队列中删除指定位置的歌曲(位置从 1 开始,见 `!queue`) |
| `!mode ` | 切换播放模式 |
-| `!playlist <歌单名或ID>` | 加载歌单(支持名称模糊搜索和 ID) |
+| `!playlist <歌单名或ID>` | 加载歌单(支持名称模糊搜索和 ID;Jellyfin 歌单 GUID 也可直接粘贴) |
| `!playlist -q <歌单名>` | 从 QQ 音乐搜索并加载歌单 |
-| `!album ` | 加载专辑 |
-| `!artist <歌手名>` | 按歌手循环播放(支持 `-q`/`-k`/`-b`/`-y`) |
-| `!fm` | 私人 FM(网易云,自动续播) |
+| `!album <专辑名或ID>` | 加载专辑(支持名称搜索 / 数字 ID / Jellyfin GUID) |
+| `!artist <歌手名>` | 按歌手循环播放(支持 `-j`/`-n`/`-q`/`-k`/`-b`/`-y`) |
+| `!fm` | 私人 FM(默认网易云,自动续播) |
+| `!fm -j` | Jellyfin 电台:从收藏出发的 Instant Mix(需启用 Jellyfin,自动续播) |
| `!fm -q` | QQ 音乐雷达 / 猜你喜欢 FM(自动续播) |
| `!fm -k` | 酷狗私人电台 / 个性化推荐 FM(自动续播) |
| `!lyrics` | 显示当前完整歌词(自动分多条消息发送,不再只显示开头几行) |
@@ -376,6 +394,8 @@ sudo systemctl start tsmusicbot
### 音质等级
+**在线音源(网易云等)**
+
| 等级 | 码率 | 格式 | 说明 |
|------|------|------|------|
| 标准 | 128kbps | MP3 | 免费可用 |
@@ -385,6 +405,13 @@ sudo systemctl start tsmusicbot
| Hi-Res | ~1500kbps | FLAC | 需要 VIP |
| 超清母带 | ~4000kbps | FLAC | 需要黑胶 VIP |
+**Jellyfin(启用后)**
+
+| 等级 | 说明 |
+|------|------|
+| **原始直传(direct)** | **默认**:原始文件不转码直传(机器人本地统一转 Opus,此档即最高音质) |
+| 320kbps / 192kbps / 128kbps | 由 Jellyfin 服务器转码后传输,适合公网带宽有限的自建服务器 |
+
在设置页面选择音质,立即生效(影响后续播放的歌曲)。
## 项目架构
@@ -408,6 +435,7 @@ teamspeak-music-bot/
│ │ └── database.ts # SQLite 数据库(播放历史、实例、收藏、权限持久化)
│ ├── music/ # 音源服务
│ │ ├── provider.ts # 统一 MusicProvider 接口
+│ │ ├── jellyfin.ts # Jellyfin 适配器(可选音源,直连 REST API)
│ │ ├── netease.ts # 网易云音乐适配器
│ │ ├── qq.ts # QQ 音乐适配器
│ │ ├── bilibili.ts # 哔哩哔哩适配器(视频音频提取)
@@ -461,6 +489,7 @@ teamspeak-music-bot/
| **数据库** | better-sqlite3 (SQLite) |
| **音频处理** | FFmpeg (ffmpeg-static 内置), @discordjs/opus |
| **TS 协议** | @honeybbq/teamspeak-client(完整客户端协议)+ 自研 TS6 协议适配层 |
+| **Jellyfin** | Jellyfin REST API(可选音源,直连,无额外 npm 依赖) |
| **网易云 API** | NeteaseCloudMusicApi |
| **QQ 音乐 API** | @sansenjian/qq-music-api(锁定 `~2.4.0`,需 Node ≥ 20.17) |
| **哔哩哔哩** | BiliBili Web API(搜索、DASH 音频流、QR 登录) |
@@ -470,6 +499,63 @@ teamspeak-music-bot/
| **图标** | @iconify/vue |
| **日志** | pino |
+## 可选:Jellyfin 音源
+
+本项目可将自建 [Jellyfin](https://jellyfin.org/) 媒体服务器作为**额外音源**:机器人直接播放你自己音乐库里的文件,不依赖任何在线平台的可用性 / 版权 / 登录状态。该音源**默认关闭**,需要手动启用。
+
+### 启用与连接配置
+
+三种方式任选:
+
+1. **首次安装向导** — 第 3 步即 Jellyfin 连接卡(可跳过,稍后配置);填写并「保存并继续」会**自动启用**该音源。
+2. **WebUI** — 设置 → Jellyfin 音乐库(可选):打开「**启用 Jellyfin 音源**」开关,填写服务器地址、选择认证方式、「测试连接」验证后保存,**保存即时生效,无需重启**。
+3. **config.json** — 手动编辑 `jellyfin` 配置块,并把 `"jellyfin"` 加入 `enabledProviders`,然后重启。
+
+两种认证方式:
+
+| 模式 | 填写内容 | 说明 |
+|------|---------|------|
+| **账号密码**(默认) | `username` + `password` | 以该用户身份登录(`AuthenticateByName`),token 自动持久化、失效自动重登 |
+| **API Key** | `apiKey` + `userId` | 使用管理后台生成的 API Key;`userId` 决定使用谁的音乐库 / 收藏 / 歌单 |
+
+```jsonc
+// config.json 片段
+{
+ "jellyfin": {
+ "serverUrl": "https://jellyfin.example.com",
+ "authMode": "userpass", // 或 "apikey"
+ "username": "music",
+ "password": "······",
+ "apiKey": "", // apikey 模式填写
+ "userId": "" // apikey 模式填写
+ },
+ "enabledProviders": ["netease", "qq", "bilibili", "youtube", "kugou", "jellyfin"]
+}
+```
+
+> 密码 / API Key 在 WebUI 中**只写不回显**;表单留空表示保持已保存的值不变。
+
+### 功能
+
+- **搜索** — 歌曲 / 专辑 / 歌单,支持翻页「加载更多」;WebUI 统一搜索中 Jellyfin 结果排最前
+- **播放** — 懒解析播放地址;默认**原始直传**(不经 Jellyfin 转码),也可选 320/192/128kbps 服务器转码档(设置 → 音质设置)
+- **歌词** — 读取 Jellyfin 的歌词接口(内嵌或 .lrc),时间轴同步滚动,`!lyrics` 可用
+- **电台 / FM**(`!fm -j` 或首页「Jellyfin 电台」卡片)— 随机取一首**收藏**做种子生成 Instant Mix 歌曲流;没有收藏则回退到最近播放、再回退随机曲目
+- **首页区块** — 最近添加(专辑)/ 播放最多 / Jellyfin 收藏 / 我的歌单 / 流派(点流派芯片即播放该流派)
+- **播放上报** — 播放开始 / 进度(约 10s 一次)/ 停止会回报给 Jellyfin(`Sessions/Playing` 系列接口),你的 Jellyfin 播放统计(PlayCount、最近播放)保持准确;上报失败不影响播放
+- **聊天命令** — 启用后用 `-j` 标志:`!play -j <歌名>`、`!fm -j`、`!artist -j <歌手>`;`!playlist` / `!album` / `!play id:` 可直接粘贴 Jellyfin GUID。若把在线音源全部停用、只保留 Jellyfin,不带标志的命令会自动以 Jellyfin 为默认音源
+
+### enabledProviders:音源开关
+
+`config.json` 的 `enabledProviders` 数组决定哪些音源可用(默认 `["netease", "qq", "bilibili", "youtube", "kugou"]`,即在线音源全开、Jellyfin 关闭):
+
+- 可选值:`jellyfin`、`netease`、`qq`、`bilibili`、`youtube`、`kugou`(`local` 由 `localAudioEnabled` 控制,`spotify` 由 `spotify.enabled` 控制)
+- 未列出的音源:聊天命令返回「音源未启用」、REST 返回 400、WebUI 搜索栏 / 登录卡 / FM 卡片自动隐藏
+- 不带平台标志的命令走**固定优先级中第一个已启用的音源**:网易云 → QQ → 酷狗 → Jellyfin → B站 → YouTube(默认配置下即网易云)
+- 网易云 / QQ 停用时,其内嵌 API 服务(端口 3001 / 3200)**不会启动**
+- 示例(Jellyfin 为主、只留网易云备用):`"enabledProviders": ["jellyfin", "netease"]`(此时默认音源仍为网易云,点歌用 `-j` 或停用网易云);示例(纯 Jellyfin):`"enabledProviders": ["jellyfin"]`
+- 注意:重新启用网易云 / QQ 的内嵌 API 服务需要重启机器人;其余音源改动即时生效(WebUI 的 Jellyfin 开关即改此列表)
+
## 可选:YouTube 音源
YouTube 是**可选**的音源,默认**未启用**,需要安装 [yt-dlp](https://github.com/yt-dlp/yt-dlp) 才能使用。启用后可通过聊天命令 `!play -y <关键词>` 或 WebUI 的 YouTube 平台选项搜索/播放 YouTube 视频的音频流。
@@ -765,7 +851,17 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
> 完整历史请查看 [git log](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/commits/main) 或 [Releases](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/releases)。这里只列出重要变更和面向用户的破坏性改动。
-### 最新版本
+### 最新版本 — Jellyfin 可选音源
+
+**Jellyfin 集成([PR #123](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/123),由 [@ItsEricRao](https://github.com/ItsEricRao) 贡献;随后调整为可选音源)**
+
+- **Jellyfin 音源(可选,默认关闭)**:连接自建 [Jellyfin](https://jellyfin.org/) 服务器作为额外音源——搜索(歌曲 / 专辑 / 歌单,支持翻页)、懒解析直传播放、同步歌词、收藏 Instant Mix 电台(`!fm -j`)、首页「最近添加 / 播放最多 / 收藏 / 流派」区块、播放进度回报(PlayCount / 播放状态)。账号密码或 API Key 两种认证,在 设置 → Jellyfin 音乐库 打开「启用 Jellyfin 音源」即可,保存即时生效。详见 [可选:Jellyfin 音源](#可选jellyfin-音源)。
+- **enabledProviders 音源开关**:`config.json` 新增 `enabledProviders` 字段,默认 `["netease", "qq", "bilibili", "youtube", "kugou"]`——在线音源保持默认启用,**从旧版本升级无行为变化**;列表外的音源在聊天命令 / REST / WebUI 中一律不可用,网易云 / QQ 停用时其内嵌 API 服务(端口 3001 / 3200)不再启动。
+- **新增 `-j`(Jellyfin)与 `-n`(网易云)平台标志**;不带标志的 `!play` / `!search` / `!fm` 等走固定优先级中第一个已启用的音源(默认配置下即网易云,行为与旧版一致)。
+- ⚠️ **v1.10.0 初版曾短暂把默认音源设为 Jellyfin-only,现已回退**。若你在该版本保存过设置导致 `config.json` 中为 `"enabledProviders": ["jellyfin"]`,请手动把在线音源加回(详见 [更新升级](#关于-enabledproviders-音源开关v1100-起))。
+- **QQ 按 ID 播放空歌名修复**([PR #124](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/124),感谢 [@Slldyd2077](https://github.com/Slldyd2077)):按 ID 播放 QQ 歌曲时回填歌曲元数据,TS 端不再显示空歌名。
+
+### v1.9.0 及更早
**功能增强:Spotify 音源(实验性)/ 搜索结果翻页 / 细粒度权限 / 本地收藏 / 本地音频上传 / 专属链接 / 自动暂停 / QQ 雷达 FM**
@@ -876,6 +972,8 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
| 项目 | 说明 |
|------|------|
+| [Jellyfin](https://github.com/jellyfin/jellyfin) | 自由软件媒体服务器(本项目的可选自建音源) |
+| [ItsEricRao](https://github.com/ItsEricRao) | Jellyfin 音源集成贡献者([PR #123](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/123)) |
| [yichen11818/NeteaseTSBot](https://github.com/yichen11818/NeteaseTSBot) | TS6 协议兼容参考(vendored tsproto 补丁) |
| [Splamy/TS3AudioBot](https://github.com/Splamy/TS3AudioBot) | 优秀的 TeamSpeak 音频机器人框架 |
| [TS3AudioBot-BiliBiliPlugin](https://github.com/xxmod/TS3AudioBot-BiliBiliPlugin) | 提供插件开发参考 |
diff --git a/src/bot/instance.ts b/src/bot/instance.ts
index 00d3711..baaf8f1 100755
--- a/src/bot/instance.ts
+++ b/src/bot/instance.ts
@@ -731,8 +731,8 @@ export class BotInstance extends EventEmitter {
}
/** Chat-command source flags. No flag → the configured default platform
- * (jellyfin unless disabled). Netease is no longer the implicit default,
- * so it gets an explicit -n flag. */
+ * (netease in the default config; otherwise the first enabled source by
+ * fixed priority — see defaultPlatform()). */
private static readonly FLAG_PLATFORMS: ReadonlyArray<[string, Platform]> = [
["b", "bilibili"],
["q", "qq"],
@@ -1451,7 +1451,6 @@ export class BotInstance extends EventEmitter {
return [
"TSMusicBot Commands:",
`${p}play — Search and play (default source: ${def})`,
- `${p}play -j — Search from Jellyfin`,
...(flagHelp ? [` Source flags: ${flagHelp}`] : []),
`${p}search — List top matches to pick a specific (same-name) song`,
`${p}play #N — Play the Nth result of the last ${p}search`,
@@ -1467,7 +1466,7 @@ export class BotInstance extends EventEmitter {
`${p}mode — Play mode`,
`${p}playlist — Load playlist by name or ID`,
`${p}album — Load album`,
- `${p}fm — Personal FM (Jellyfin: 收藏电台 Instant Mix)`,
+ `${p}fm — Personal FM (default source: ${def}; source flags work too)`,
`${p}artist — Play songs by artist (loop)`,
`${p}vote — Vote to skip`,
`${p}lyrics — Show lyrics`,
diff --git a/src/data/config.test.ts b/src/data/config.test.ts
index afac5ba..7fbce19 100644
--- a/src/data/config.test.ts
+++ b/src/data/config.test.ts
@@ -10,7 +10,7 @@ import {
renameSync,
} from "node:fs";
import { tmpdir } from "node:os";
-import { getDefaultConfig, loadConfig, saveConfig, migrateLegacyConfig } from "./config.js";
+import { getDefaultConfig, loadConfig, saveConfig, migrateLegacyConfig, defaultPlatform } from "./config.js";
// Wrap the fs functions config.ts uses in call-through spies so the atomic-write
// and transient-read-error paths can be observed/forced. Everything else (mkdtemp,
@@ -48,6 +48,50 @@ describe("config", () => {
expect(config).toEqual(getDefaultConfig());
});
+ it("defaults to the online sources with jellyfin as opt-in (disabled)", () => {
+ const config = getDefaultConfig();
+ expect(config.enabledProviders).toEqual(["netease", "qq", "bilibili", "youtube", "kugou"]);
+ expect(config.enabledProviders).not.toContain("jellyfin");
+ });
+
+ it("keeps pre-gating behavior for legacy configs without enabledProviders", () => {
+ const dir = makeTmpDir();
+ const path = join(dir, "config.json");
+ // A config written before enabledProviders existed: no such field.
+ writeFileSync(path, JSON.stringify({ webPort: 4000 }));
+ const config = loadConfig(path);
+ expect(config.enabledProviders).toEqual(["netease", "qq", "bilibili", "youtube", "kugou"]);
+ expect(defaultPlatform(config)).toBe("netease");
+ });
+
+ it("defaultPlatform follows the fixed priority order", () => {
+ const config = getDefaultConfig();
+ expect(defaultPlatform(config)).toBe("netease");
+
+ // Jellyfin ranks after the online music platforms…
+ config.enabledProviders = ["netease", "jellyfin"];
+ expect(defaultPlatform(config)).toBe("netease");
+ // …but ahead of the video sites…
+ config.enabledProviders = ["bilibili", "jellyfin", "youtube"];
+ expect(defaultPlatform(config)).toBe("jellyfin");
+ // …and is the default when it is the only enabled source.
+ config.enabledProviders = ["jellyfin"];
+ expect(defaultPlatform(config)).toBe("jellyfin");
+ // Nothing enabled → netease fallback (the gate then reports it disabled).
+ config.enabledProviders = [];
+ expect(defaultPlatform(config)).toBe("netease");
+ });
+
+ it("respects an explicit jellyfin-only enabledProviders from disk", () => {
+ const dir = makeTmpDir();
+ const path = join(dir, "config.json");
+ // e.g. a config persisted by the short-lived jellyfin-by-default builds.
+ writeFileSync(path, JSON.stringify({ enabledProviders: ["jellyfin"] }));
+ const config = loadConfig(path);
+ expect(config.enabledProviders).toEqual(["jellyfin"]);
+ expect(defaultPlatform(config)).toBe("jellyfin");
+ });
+
it("creates config file on save", () => {
const dir = makeTmpDir();
const path = join(dir, "sub", "config.json");
diff --git a/src/data/config.ts b/src/data/config.ts
index d477243..b5b515f 100755
--- a/src/data/config.ts
+++ b/src/data/config.ts
@@ -62,13 +62,15 @@ export function isProviderEnabled(config: BotConfig, platform: string): boolean
/**
* The default platform for !play/!add/!playlist/!album and all REST/WebUI calls:
- * jellyfin when enabled, otherwise the first enabled provider in a fixed
- * priority order. Falls back to "netease" when nothing is enabled so callers
- * always get a provider — the enabled-gate then produces the friendly error.
+ * the first enabled provider in a fixed priority order (netease with the default
+ * config; jellyfin ranks after the online music platforms because it is an
+ * opt-in source, but ahead of the video sites for users who run it as their
+ * only music library). Falls back to "netease" when nothing is enabled so
+ * callers always get a provider — the enabled-gate then produces the friendly
+ * error.
*/
export function defaultPlatform(config: BotConfig): GateableProvider {
- if (config.enabledProviders.includes("jellyfin")) return "jellyfin";
- for (const p of ["netease", "qq", "kugou", "bilibili", "youtube"] as const) {
+ for (const p of ["netease", "qq", "kugou", "jellyfin", "bilibili", "youtube"] as const) {
if (config.enabledProviders.includes(p)) return p;
}
return "netease";
@@ -102,9 +104,10 @@ export interface BotConfig {
jellyfin: JellyfinConfig;
/**
* Which gateable providers are active (see GATEABLE_PROVIDERS). Default is
- * jellyfin-only: the legacy NetEase/QQ/Bilibili/YouTube/Kugou sources keep
- * compiling but stay disabled — their embedded sidecar API servers must not
- * start (or bind ports 3001/3200) unless listed here.
+ * the online sources (NetEase/QQ/Bilibili/YouTube/Kugou); jellyfin is an
+ * opt-in extra that must be listed here (Settings → Jellyfin 音乐库 toggles
+ * it). Sources not listed stay disabled — the NetEase/QQ embedded sidecar
+ * API servers must not start (or bind ports 3001/3200) unless enabled.
*/
enabledProviders: GateableProvider[];
}
@@ -159,7 +162,7 @@ export function getDefaultConfig(): BotConfig {
apiKey: "",
userId: "",
},
- enabledProviders: ["jellyfin"],
+ enabledProviders: ["netease", "qq", "bilibili", "youtube", "kugou"],
};
}
@@ -306,8 +309,8 @@ export function loadConfig(path: string): BotConfig {
};
// enabledProviders → known providers only; a non-array falls back to the
- // default (["jellyfin"]). An explicitly-empty array is respected (operator
- // chose to disable every gateable source).
+ // default (online sources, jellyfin off). An explicitly-empty array is
+ // respected (operator chose to disable every gateable source).
const enabledProviders = Array.isArray(partial.enabledProviders)
? partial.enabledProviders.filter((p): p is GateableProvider =>
(GATEABLE_PROVIDERS as readonly string[]).includes(p as string),
diff --git a/src/index.ts b/src/index.ts
index 9fbd974..008a318 100755
--- a/src/index.ts
+++ b/src/index.ts
@@ -56,8 +56,8 @@ async function main() {
{
neteasePort: config.neteaseApiPort,
qqMusicPort: config.qqMusicApiPort,
- // Provider gating: with the default (jellyfin-only) config, neither
- // sidecar starts and ports 3001/3200 are never bound.
+ // Provider gating: a sidecar only starts (and binds 3001/3200) when its
+ // source is listed in enabledProviders — both are on in the default config.
neteaseEnabled: isProviderEnabled(config, "netease"),
qqEnabled: isProviderEnabled(config, "qq"),
},
diff --git a/src/web/api/bot.test.ts b/src/web/api/bot.test.ts
index b65e61d..eb174a6 100644
--- a/src/web/api/bot.test.ts
+++ b/src/web/api/bot.test.ts
@@ -612,7 +612,8 @@ describe("bot router /settings jellyfin block + enabledProviders", () => {
hasPassword: true,
hasApiKey: false,
});
- expect(res.body.enabledProviders).toEqual(["jellyfin"]);
+ // Default: online sources on, jellyfin opt-in (not listed).
+ expect(res.body.enabledProviders).toEqual(["netease", "qq", "bilibili", "youtube", "kugou"]);
});
it("POST /settings merges jellyfin, keeps stored secrets on blank, hot-configures", async () => {
diff --git a/src/web/api/music.test.ts b/src/web/api/music.test.ts
index 8d4fc1c..451dca1 100644
--- a/src/web/api/music.test.ts
+++ b/src/web/api/music.test.ts
@@ -82,44 +82,51 @@ describe("music router provider gating (enabledProviders) + jellyfin endpoints",
return { app, netease, jellyfin };
}
- it("routes a platform-less /search to the default platform (jellyfin)", async () => {
+ it("routes a platform-less /search to the default platform (netease)", async () => {
const { app, netease, jellyfin } = mount(getDefaultConfig());
const res = await request(app).get("/api/music/search?q=hello");
expect(res.status).toBe(200);
- expect(jellyfin.search).toHaveBeenCalledWith("hello", 20, 0);
- expect(netease.search).not.toHaveBeenCalled();
+ expect(netease.search).toHaveBeenCalledWith("hello", 20, 0);
+ expect(jellyfin.search).not.toHaveBeenCalled();
});
it("rejects a disabled platform with 400 without calling its provider", async () => {
- // Default config enables jellyfin only.
- const { app, netease } = mount(getDefaultConfig());
- const res = await request(app).get("/api/music/search?q=hello&platform=netease");
+ // Default config leaves jellyfin (opt-in) disabled.
+ const { app, jellyfin } = mount(getDefaultConfig());
+ const res = await request(app).get("/api/music/search?q=hello&platform=jellyfin");
expect(res.status).toBe(400);
- expect(netease.search).not.toHaveBeenCalled();
+ expect(jellyfin.search).not.toHaveBeenCalled();
});
- it("allows an explicitly re-enabled legacy platform", async () => {
+ it("allows an explicitly enabled jellyfin platform", async () => {
const config = getDefaultConfig();
- config.enabledProviders = ["jellyfin", "netease"];
- const { app, netease } = mount(config);
- const res = await request(app).get("/api/music/search?q=hello&platform=netease");
+ config.enabledProviders = [...config.enabledProviders, "jellyfin"];
+ const { app, jellyfin } = mount(config);
+ const res = await request(app).get("/api/music/search?q=hello&platform=jellyfin");
expect(res.status).toBe(200);
- expect(netease.search).toHaveBeenCalledWith("hello", 20, 0);
+ expect(jellyfin.search).toHaveBeenCalledWith("hello", 20, 0);
});
it("GET /providers reports enabled sources and the default platform", async () => {
const { app } = mount(getDefaultConfig());
const res = await request(app).get("/api/music/providers");
expect(res.status).toBe(200);
- expect(res.body.default).toBe("jellyfin");
- expect(res.body.enabled).toContain("jellyfin");
+ expect(res.body.default).toBe("netease");
+ expect(res.body.enabled).toContain("netease");
expect(res.body.enabled).toContain("local"); // localAudioEnabled defaults on
- expect(res.body.enabled).not.toContain("netease");
+ expect(res.body.enabled).not.toContain("jellyfin"); // opt-in, off by default
expect(res.body.enabled).not.toContain("spotify"); // spotify.enabled defaults off
});
+ /** Default config plus the opt-in jellyfin source enabled. */
+ function configWithJellyfin() {
+ const config = getDefaultConfig();
+ config.enabledProviders = [...config.enabledProviders, "jellyfin"];
+ return config;
+ }
+
it("GET /jellyfin/latest-albums returns provider data", async () => {
- const { app, jellyfin } = mount(getDefaultConfig());
+ const { app, jellyfin } = mount(configWithJellyfin());
const res = await request(app).get("/api/music/jellyfin/latest-albums?limit=5");
expect(res.status).toBe(200);
expect(
@@ -128,16 +135,14 @@ describe("music router provider gating (enabledProviders) + jellyfin endpoints",
expect(res.body.albums).toHaveLength(1);
});
- it("GET /jellyfin/latest-albums is 400 when jellyfin is disabled", async () => {
- const config = getDefaultConfig();
- config.enabledProviders = ["netease"];
- const { app } = mount(config);
+ it("GET /jellyfin/latest-albums is 400 when jellyfin is disabled (the default)", async () => {
+ const { app } = mount(getDefaultConfig());
const res = await request(app).get("/api/music/jellyfin/latest-albums");
expect(res.status).toBe(400);
});
it("GET /jellyfin/favorites denies unauthenticated/guest access", async () => {
- const { app } = mount(getDefaultConfig());
+ const { app } = mount(configWithJellyfin());
const res = await request(app).get("/api/music/jellyfin/favorites");
expect(res.status).toBe(401);
});
diff --git a/src/web/api/music.ts b/src/web/api/music.ts
index 7e50ebe..2f8363a 100644
--- a/src/web/api/music.ts
+++ b/src/web/api/music.ts
@@ -37,9 +37,10 @@ export function createMusicRouter(
/**
* Provider gating for user-supplied platform params. No platform → the
- * configured default (jellyfin unless disabled). A disabled platform gets a
- * friendly 400 and null back — the handler must return immediately.
- * Without a config (unit-test routers), everything stays enabled.
+ * configured default (see defaultPlatform(); netease in the default config).
+ * A disabled platform gets a friendly 400 and null back — the handler must
+ * return immediately. Without a config (unit-test routers), everything
+ * stays enabled.
*/
function resolveProvider(platform: unknown, res: Response): MusicProvider | null {
const requested = typeof platform === "string" && platform ? platform : undefined;
@@ -144,7 +145,8 @@ export function createMusicRouter(
// view would only yield results that get skipped. Spotify search remains
// available from its own tab via /search?platform=spotify.
// Provider gating (#enabledProviders): disabled sources are skipped, not
- // searched. Jellyfin — the primary source — leads the merged results.
+ // searched. Jellyfin (an opt-in source) leads the merged results when
+ // enabled — a self-hosted library match is almost always the wanted one.
const enabled = (p: string) => !config || isProviderEnabled(config, p);
const none = { songs: [], albums: [], playlists: [] };
const [jellyfinResult, neteaseResult, qqResult, bilibiliResult, localResult, kugouResult] = await Promise.allSettled([
diff --git a/web/src/stores/player.ts b/web/src/stores/player.ts
index c0e5677..3970199 100644
--- a/web/src/stores/player.ts
+++ b/web/src/stores/player.ts
@@ -113,14 +113,14 @@ export const usePlayerStore = defineStore('player', {
// Which sources the server has enabled (GET /api/music/providers) and the
// configured default. Empty until fetched — sections stay hidden briefly.
enabledProviders: [] as string[],
- defaultSource: 'jellyfin' as string,
+ defaultSource: 'netease' as string,
// Home page cache, split by source
recommendPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[], kugou: [] as PlaylistItem[], spotify: [] as PlaylistItem[] },
dailySongs: { netease: [] as Song[], qq: [] as Song[], kugou: [] as Song[], spotify: [] as Song[] },
userPlaylists: { jellyfin: [] as PlaylistItem[], netease: [] as PlaylistItem[], qq: [] as PlaylistItem[], kugou: [] as PlaylistItem[], spotify: [] as PlaylistItem[] },
bilibiliPopular: [] as Song[],
- // Jellyfin home sections (primary source)
+ // Jellyfin home sections (shown only when the optional source is enabled)
jellyfinLatestAlbums: [] as AlbumItem[],
jellyfinMostPlayed: [] as Song[],
jellyfinFavorites: [] as Song[],
@@ -173,7 +173,7 @@ export const usePlayerStore = defineStore('player', {
const maxDuration = this.activeBot.currentSong.duration || Infinity;
return interpolateElapsed(timing, this.isPaused, maxDuration);
},
- /** Sources that are currently logged in. Jellyfin (the primary source) leads. */
+ /** Sources that are currently logged in. Jellyfin (when enabled) leads. */
availableSources(): Source[] {
const s: Source[] = [];
if (this.authStatus.jellyfin) s.push('jellyfin');
diff --git a/web/src/views/Search.vue b/web/src/views/Search.vue
index a4111ec..3792256 100644
--- a/web/src/views/Search.vue
+++ b/web/src/views/Search.vue
@@ -57,7 +57,7 @@
+ (opt-in) comes first when enabled. -->
-
-
-
Jellyfin 音乐库
+
+
+
Jellyfin 音乐库(可选)
@@ -181,6 +184,15 @@
+
+
+
@@ -1164,6 +1176,12 @@ const jellyfinSaving = ref(false);
const jellyfinTesting = ref(false);
const jellyfinMessage = ref('');
const jellyfinMessageTone = ref<'ok' | 'warn'>('ok');
+// Jellyfin is opt-in: its "enabled" bit is membership in enabledProviders
+// (unlike Spotify's dedicated spotify.enabled flag). Only trust the toggle
+// once the real list has loaded, so an early save can't clobber the other
+// providers with the empty placeholder list.
+const jellyfinEnabledForm = ref(false);
+const enabledProvidersLoaded = ref(false);
// Populate from the masked GET /api/bot/settings response — secrets never
// round-trip, only hasPassword/hasApiKey.
@@ -1183,8 +1201,22 @@ async function saveJellyfin() {
jellyfinSaving.value = true;
jellyfinMessage.value = '';
try {
- const res = await axios.post('/api/bot/settings', { jellyfin: { ...jellyfinForm } });
+ // enabledProviders is a full-replace field, so only send it when the
+ // current list is known — otherwise we'd wipe the other sources.
+ const payload: Record = { jellyfin: { ...jellyfinForm } };
+ if (enabledProvidersLoaded.value) {
+ const others = enabledProviders.value.filter((p) => p !== 'jellyfin');
+ payload.enabledProviders = jellyfinEnabledForm.value ? [...others, 'jellyfin'] : others;
+ }
+ const res = await axios.post('/api/bot/settings', payload);
applyJellyfinConfig(res.data?.jellyfin);
+ if (Array.isArray(res.data?.enabledProviders)) {
+ enabledProviders.value = res.data.enabledProviders;
+ jellyfinEnabledForm.value = res.data.enabledProviders.includes('jellyfin');
+ enabledProvidersLoaded.value = true;
+ // Search bar / home sections / FM cards react without a reload.
+ store.fetchProviders();
+ }
jellyfinMessageTone.value = 'ok';
jellyfinMessage.value = '已保存';
await checkAuthStatus();
@@ -1471,6 +1503,8 @@ async function loadIdleTimeout() {
applyJellyfinConfig(res.data.jellyfin);
if (Array.isArray(res.data.enabledProviders)) {
enabledProviders.value = res.data.enabledProviders;
+ jellyfinEnabledForm.value = res.data.enabledProviders.includes('jellyfin');
+ enabledProvidersLoaded.value = true;
}
} catch { /* ignore */ }
}
diff --git a/web/src/views/Setup.vue b/web/src/views/Setup.vue
index c762f4e..9588b7e 100644
--- a/web/src/views/Setup.vue
+++ b/web/src/views/Setup.vue
@@ -61,7 +61,7 @@
连接 Jellyfin (可选)
-
连接自建 Jellyfin 服务器作为主音源;也可稍后在「设置」中配置
+
连接自建 Jellyfin 服务器作为额外音源;保存后自动启用,也可稍后在「设置」中配置
@@ -130,8 +130,8 @@ const nickname = ref('MusicBot');
const defaultChannel = ref('');
const channelId = ref('');
-// Jellyfin — the primary music source. Optional: skipping leaves it
-// configurable later in Settings.
+// Jellyfin — optional self-hosted music source. Skipping leaves it
+// configurable later in Settings; saving here also enables it.
const jellyfin = reactive({
serverUrl: '',
authMode: 'userpass' as 'userpass' | 'apikey',
@@ -182,7 +182,19 @@ async function testJellyfin() {
async function saveJellyfinAndNext() {
jellyfinSaving.value = true;
try {
- await axios.post('/api/bot/settings', { jellyfin: { ...jellyfin } });
+ // Jellyfin is opt-in (not in the default enabledProviders), so completing
+ // this step also enables the source — a configured-but-dark Jellyfin would
+ // be baffling. enabledProviders is full-replace: fetch the current list
+ // and append. Only do this when a server URL was actually entered.
+ const payload: Record = { jellyfin: { ...jellyfin } };
+ if (jellyfin.serverUrl.trim()) {
+ const cur = await axios.get('/api/bot/settings');
+ const ep: unknown = cur.data?.enabledProviders;
+ if (Array.isArray(ep) && !ep.includes('jellyfin')) {
+ payload.enabledProviders = [...ep, 'jellyfin'];
+ }
+ }
+ await axios.post('/api/bot/settings', payload);
currentStep.value = 3;
} catch {
jellyfinTestOk.value = false;