mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +08:00
feat: make Jellyfin an optional source instead of the default
Revert the jellyfin-only default introduced by PR #123 so upgrading users keep their online sources; Jellyfin becomes opt-in: - default enabledProviders is now the online set (netease/qq/bilibili/ youtube/kugou); defaultPlatform() uses a fixed priority order (netease -> qq -> kugou -> jellyfin -> bilibili -> youtube) instead of jellyfin-first, so chat/REST/WebUI default to netease again - Settings: Jellyfin card is always visible with a new enable toggle (its enabled bit is enabledProviders membership); guards against clobbering other providers before the list loads - Setup wizard: saving the Jellyfin step auto-enables the source when a server URL was entered - Search/player store fallbacks flip from jellyfin to netease; !help no longer hardcodes Jellyfin lines - tests: update default-platform assertions, add coverage for the new default set, legacy configs without enabledProviders, priority order, and explicit jellyfin-only configs - README: reframe Jellyfin as optional (badges, command table, quality tiers, dedicated section, changelog), document the enabledProviders default and the v1.10.0 jellyfin-only window fix, credit @ItsEricRao Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3841fa80d3
commit
750ad9b1cc
12 files changed
+270
-72
No files matched your search
@@ -5,7 +5,7 @@
|
||||
<h1 align="center">TSMusicBot</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>TeamSpeak 音乐机器人</strong> — 网易云音乐 + QQ 音乐 + 酷狗音乐 + 哔哩哔哩 + YouTube(可选),YesPlayMusic 风格 WebUI 控制面板
|
||||
<strong>TeamSpeak 音乐机器人</strong> — 网易云音乐 + QQ 音乐 + 酷狗音乐 + 哔哩哔哩 + YouTube(可选),Jellyfin / Spotify 可选启用,YesPlayMusic 风格 WebUI 控制面板
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -17,12 +17,15 @@
|
||||
<img src="https://img.shields.io/badge/Docker-支持-2496ED?logo=docker&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/酷狗音乐-支持-2ca2f9" />
|
||||
<img src="https://img.shields.io/badge/BiliBili-支持-00a1d6?logo=bilibili&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/Jellyfin-可选-aa5cc3?logo=jellyfin&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/YouTube-可选-FF0000?logo=youtube&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/Spotify-可选-1DB954?logo=spotify&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/TS3-支持-2580C3?logo=teamspeak&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/TS6-支持-2580C3?logo=teamspeak&logoColor=white" />
|
||||
</p>
|
||||
|
||||
> 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/<id>` 专属链接打开 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>` | 按歌曲 id 播放精确的某首歌(也支持直接粘贴网易云 / QQ / B站 歌曲链接) |
|
||||
| `!play id:<id>` | 按歌曲 id 播放精确的某首歌(也支持直接粘贴网易云 / QQ / B站 歌曲链接;Jellyfin 曲目用 GUID ItemId) |
|
||||
| `!add <歌名>` | 添加到播放队列(同样支持 `#序号` / `id:<id>` / 链接) |
|
||||
| `!pause` / `!resume` | 暂停 / 恢复播放 |
|
||||
| `!next` / `!prev` | 下一首 / 上一首 |
|
||||
@@ -338,11 +355,12 @@ sudo systemctl start tsmusicbot
|
||||
| `!queue` | 查看播放队列 |
|
||||
| `!remove <位置>` | 从队列中删除指定位置的歌曲(位置从 1 开始,见 `!queue`) |
|
||||
| `!mode <seq\|loop\|random\|rloop>` | 切换播放模式 |
|
||||
| `!playlist <歌单名或ID>` | 加载歌单(支持名称模糊搜索和 ID) |
|
||||
| `!playlist <歌单名或ID>` | 加载歌单(支持名称模糊搜索和 ID;Jellyfin 歌单 GUID 也可直接粘贴) |
|
||||
| `!playlist -q <歌单名>` | 从 QQ 音乐搜索并加载歌单 |
|
||||
| `!album <ID>` | 加载专辑 |
|
||||
| `!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) | 提供插件开发参考 |
|
||||
|
||||
+3
-4
@@ -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 <song> — Search and play (default source: ${def})`,
|
||||
`${p}play -j <song> — Search from Jellyfin`,
|
||||
...(flagHelp ? [` Source flags: ${flagHelp}`] : []),
|
||||
`${p}search <name> — 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 <seq|loop|random|rloop> — Play mode`,
|
||||
`${p}playlist <name or id> — Load playlist by name or ID`,
|
||||
`${p}album <name or id> — Load album`,
|
||||
`${p}fm — Personal FM (Jellyfin: 收藏电台 Instant Mix)`,
|
||||
`${p}fm — Personal FM (default source: ${def}; source flags work too)`,
|
||||
`${p}artist <name> — Play songs by artist (loop)`,
|
||||
`${p}vote — Vote to skip`,
|
||||
`${p}lyrics — Show lyrics`,
|
||||
|
||||
+45
-1
@@ -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");
|
||||
|
||||
+14
-11
@@ -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),
|
||||
|
||||
+2
-2
@@ -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"),
|
||||
},
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
+26
-21
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
|
||||
<template v-else-if="allSongs.length || allAlbums.length || allPlaylists.length">
|
||||
<!-- Only enabled sources are offered (enabledProviders gate); Jellyfin
|
||||
— the primary source — comes first. -->
|
||||
(opt-in) comes first when enabled. -->
|
||||
<div class="source-bar">
|
||||
<button
|
||||
v-if="sourceEnabled('jellyfin')"
|
||||
@@ -218,7 +218,7 @@ function loadSource(): SearchSource {
|
||||
const stored = localStorage.getItem(SOURCE_STORAGE_KEY);
|
||||
if (SEARCH_SOURCES.includes(stored as SearchSource)) return stored as SearchSource;
|
||||
} catch { /* localStorage blocked */ }
|
||||
return 'jellyfin';
|
||||
return 'netease';
|
||||
}
|
||||
|
||||
type TabType = 'songs' | 'albums' | 'playlists';
|
||||
@@ -272,7 +272,7 @@ function fixupSelectedSource() {
|
||||
const def = store.defaultSource as SearchSource;
|
||||
selectedSource.value = SEARCH_SOURCES.includes(def) && sourceEnabled(def)
|
||||
? def
|
||||
: SEARCH_SOURCES.find((s) => s !== 'local' && sourceEnabled(s)) ?? 'jellyfin';
|
||||
: SEARCH_SOURCES.find((s) => s !== 'local' && sourceEnabled(s)) ?? 'netease';
|
||||
}
|
||||
|
||||
// ---- 分页 / 加载更多 ----
|
||||
|
||||
@@ -165,10 +165,13 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Jellyfin — the primary music source. Admin-configured connection
|
||||
(server URL + credentials), no QR flow. -->
|
||||
<section v-if="can('platform.auth') && providerOn('jellyfin')" class="settings-section">
|
||||
<h2 class="section-title">Jellyfin 音乐库</h2>
|
||||
<!-- Jellyfin — optional self-hosted music source. Admin-configured
|
||||
connection (server URL + credentials), no QR flow. The card stays
|
||||
visible even while the source is disabled: the enable toggle below is
|
||||
the only UI that flips its enabledProviders membership, so hiding the
|
||||
card would leave no way to turn Jellyfin on. -->
|
||||
<section v-if="can('platform.auth')" class="settings-section">
|
||||
<h2 class="section-title">Jellyfin 音乐库(可选)</h2>
|
||||
|
||||
<div class="account-card">
|
||||
<div class="account-header">
|
||||
@@ -181,6 +184,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Enable (opt-in source: presence in enabledProviders) -->
|
||||
<label class="profile-toggle behavior-toggle spotify-toggle">
|
||||
<div class="profile-toggle-text">
|
||||
<div class="profile-toggle-label">启用 Jellyfin 音源</div>
|
||||
<div class="profile-toggle-hint">开启后 Jellyfin 出现在搜索、首页与聊天命令(!play -j)的音源列表中。默认关闭,点击下方「保存」后生效。</div>
|
||||
</div>
|
||||
<input v-model="jellyfinEnabledForm" type="checkbox" class="profile-toggle-switch" />
|
||||
</label>
|
||||
|
||||
<div class="form-group">
|
||||
<label>服务器地址</label>
|
||||
<input v-model="jellyfinForm.serverUrl" class="input" autocomplete="off" placeholder="https://jellyfin.example.com" />
|
||||
@@ -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<string, unknown> = { 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 */ }
|
||||
}
|
||||
|
||||
+16
-4
@@ -61,7 +61,7 @@
|
||||
|
||||
<div v-if="currentStep === 2" class="step-content">
|
||||
<h2>连接 Jellyfin (可选)</h2>
|
||||
<p class="subtitle">连接自建 Jellyfin 服务器作为主音源;也可稍后在「设置」中配置</p>
|
||||
<p class="subtitle">连接自建 Jellyfin 服务器作为额外音源;保存后自动启用,也可稍后在「设置」中配置</p>
|
||||
<div class="form-group">
|
||||
<label>服务器地址</label>
|
||||
<input v-model="jellyfin.serverUrl" placeholder="https://jellyfin.example.com" class="input" />
|
||||
@@ -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<string, unknown> = { 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;
|
||||
|
||||
Reference in new issue
Block a user