Compare commits

...
Author SHA1 Message Date
saopig1andClaude Fable 5 846ee30bce fix(qq): pin the QQ Music API sidecar to qqMusicApiPort
The embedded QQ Music API sidecar could bind a different port than the one
the client base URL (getQQMusicBaseUrl) targets. The upstream
@sansenjian/qq-music-api package derives its default port from
process.env.PORT (falling back to 3200) and, in some historical versions,
auto-started that server as an import side effect. When an old build listened
on 3300 while the client requested 3200 (issue #122), fetching the QQ login QR
failed with ECONNREFUSED on 127.0.0.1:3200, so the QR never showed and login /
cookie persistence silently broke.

Align process.env.PORT with the configured qqMusicApiPort for the duration of
the import (restoring the previous value afterwards so nothing else in the
process is affected), reuse an already-listening instance instead of racing a
second listen, and log the port actually bound (read from the socket) so any
mismatch is visible in the logs.

- src/music/api-server.ts: PORT alignment + reuse-on-auto-start + bound-port log
- src/music/api-server.test.ts: regression coverage that the sidecar follows
  qqMusicPort (not an injected PORT) and restores PORT afterwards
- README.md: QQ login FAQ clarifies the sidecar and client share qqMusicApiPort
  and points stale-latest-image users (who saw 3300) at re-pulling the image

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 00:24:37 +08:00
saopig1andClaude Fable 5 051171b019 chore(claude): allow pushes to main/master, keep force-push deny
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 00:57:33 +08:00
saopig1andClaude Fable 5 750ad9b1cc 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>
2026-07-14 00:54:34 +08:00
TIANYAO ZHANG 3841fa80d3 Update README.md 2026-07-14 00:20:48 +08:00
15 changed files with 377 additions and 115 deletions

No files matched your search

-2
View File
@@ -15,8 +15,6 @@
"mcp__scheduled-tasks__*" "mcp__scheduled-tasks__*"
], ],
"deny": [ "deny": [
"Bash(git push * main)",
"Bash(git push * master)",
"Bash(git push --force *)", "Bash(git push --force *)",
"Bash(rm -rf /)" "Bash(rm -rf /)"
] ]
+67 -42
View File
@@ -5,7 +5,7 @@
<h1 align="center">TSMusicBot</h1> <h1 align="center">TSMusicBot</h1>
<p align="center"> <p align="center">
<strong>TeamSpeak 音乐机器人</strong> — 自建 <strong>Jellyfin</strong> 音乐库为主音源,网易云 / QQ / 酷狗 / 哔哩哔哩 / YouTube / Spotify 可选启用,YesPlayMusic 风格 WebUI 控制面板 <strong>TeamSpeak 音乐机器人</strong> — 网易云音乐 + QQ 音乐 + 酷狗音乐 + 哔哩哔哩 + YouTube(可选),Jellyfin / Spotify 可选启用,YesPlayMusic 风格 WebUI 控制面板
</p> </p>
<p align="center"> <p align="center">
@@ -15,16 +15,16 @@
<img src="https://img.shields.io/badge/许可证-MIT-green" /> <img src="https://img.shields.io/badge/许可证-MIT-green" />
<img src="https://img.shields.io/badge/FFmpeg-已内置-orange?logo=ffmpeg" /> <img src="https://img.shields.io/badge/FFmpeg-已内置-orange?logo=ffmpeg" />
<img src="https://img.shields.io/badge/Docker-支持-2496ED?logo=docker&logoColor=white" /> <img src="https://img.shields.io/badge/Docker-支持-2496ED?logo=docker&logoColor=white" />
<img src="https://img.shields.io/badge/Jellyfin-主音源-aa5cc3?logo=jellyfin&logoColor=white" /> <img src="https://img.shields.io/badge/酷狗音乐-支持-2ca2f9" />
<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/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/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/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/TS3-支持-2580C3?logo=teamspeak&logoColor=white" />
<img src="https://img.shields.io/badge/TS6-支持-2580C3?logo=teamspeak&logoColor=white" /> <img src="https://img.shields.io/badge/TS6-支持-2580C3?logo=teamspeak&logoColor=white" />
</p> </p>
> 本项目 fork 自 [ZHANGTIANYAO1/teamspeak-music-bot](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot)(MIT 许可),在其基础上将自建 Jellyfin 服务器改造为默认主音源;原有在线音源全部保留,可按需在配置中重新启用。 > 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 音乐库** 一键开启;原有在线音源保持默认启用,行为不变。
## 功能特性 ## 功能特性
@@ -34,8 +34,8 @@
- **本地音频上传播放** — 在搜索页拖拽或选择本地音频上传,上传后可直接播放 / 下一首播放 / 加入队列;管理员可在 设置 → 行为设置 开关此功能,播放结束或停止/清空/替换队列时会清理服务端接收的本地文件 - **本地音频上传播放** — 在搜索页拖拽或选择本地音频上传,上传后可直接播放 / 下一首播放 / 加入队列;管理员可在 设置 → 行为设置 开关此功能,播放结束或停止/清空/替换队列时会清理服务端接收的本地文件
- **专属链接(单机器人锁定)** — 通过 `/bot/<id>` 专属链接打开 WebUI 时锁定到单个机器人,刷新后保持,适合把某台机器人的控制页分享给特定用户 - **专属链接(单机器人锁定)** — 通过 `/bot/<id>` 专属链接打开 WebUI 时锁定到单个机器人,刷新后保持,适合把某台机器人的控制页分享给特定用户
- **频道无人时自动暂停** — 机器人所在频道没有其他人时自动暂停播放,有人加入后自动恢复(**默认关闭**,可在设置中开启) - **频道无人时自动暂停** — 机器人所在频道没有其他人时自动暂停播放,有人加入后自动恢复(**默认关闭**,可在设置中开启)
- **Jellyfin 主音源** — 连接自建 [Jellyfin](https://jellyfin.org/) 服务器作为默认音源:搜索(歌曲 / 专辑 / 歌单)、懒解析直传播放、同步歌词、收藏 Instant Mix 电台(`!fm`)、首页「最近添加 / 播放最多 / 收藏 / 流派」,并把播放进度回报给 Jellyfin(PlayCount / 播放状态)。详见 [Jellyfin 音源(主音源)](#jellyfin-音源主音源) - **Jellyfin 音源(可选)** — 连接自建 [Jellyfin](https://jellyfin.org/) 服务器作为额外音源:搜索(歌曲 / 专辑 / 歌单)、懒解析直传播放、同步歌词、收藏 Instant Mix 电台(`!fm -j`)、首页「最近添加 / 播放最多 / 收藏 / 流派」,并把播放进度回报给 Jellyfin(PlayCount / 播放状态)。**默认关闭**,在 设置 → Jellyfin 音乐库 一键开启。详见 [可选:Jellyfin 音源](#可选jellyfin-音源)
- **多平台音源(enabledProviders 门控)** — 网易云音乐 / QQ 音乐 / 酷狗音乐 / 哔哩哔哩 / YouTube(yt-dlp)代码全部保留但**默认停用**,在 `config.json` 的 `enabledProviders` 中列出即可重新启用;**Spotify(实验性)** 由独立开关控制(需 Premium + 自建开发者应用,默认关闭,详见 [Spotify 音源(实验性)](#spotify-音源实验性))。统一搜索(歌曲 / 歌单 / 专辑均支持翻页「加载更多」),结果标注来源,禁用音源不出现在搜索栏 - **多平台音源(enabledProviders 门控)** — 网易云音乐 / QQ 音乐 / 酷狗音乐 / 哔哩哔哩 / YouTube(yt-dlp,需安装)**默认启用**,可在 `config.json` 的 `enabledProviders` 中逐个停用;Jellyfin 为可选音源(见上),**Spotify(实验性)** 由独立开关控制(需 Premium + 自建开发者应用,默认关闭,详见 [Spotify 音源(实验性)](#spotify-音源实验性))。统一搜索(歌曲 / 歌单 / 专辑均支持翻页「加载更多」),结果标注来源,禁用音源不出现在搜索栏
- **真实客户端协议 (TS3/TS6 双协议)** — 机器人在 TeamSpeak 中可见(非 ServerQuery 隐身模式),自动检测并适配 TS3 和 TS6 服务器,支持 TS6 HTTP Query API - **真实客户端协议 (TS3/TS6 双协议)** — 机器人在 TeamSpeak 中可见(非 ServerQuery 隐身模式),自动检测并适配 TS3 和 TS6 服务器,支持 TS6 HTTP Query API
- **YesPlayMusic 风格 WebUI** — 精美界面,支持深色/浅色主题切换 - **YesPlayMusic 风格 WebUI** — 精美界面,支持深色/浅色主题切换
- **完整播放控制** — 播放/暂停/上一首/下一首/进度跳转/音量调节 - **完整播放控制** — 播放/暂停/上一首/下一首/进度跳转/音量调节
@@ -167,6 +167,16 @@ sudo ./scripts/install.sh
> >
> **如何判断是否需要迁移**:如果你是全新安装,或者你的机器人数据库中 `identity` 字段已经是空的,则**无需任何操作**。完成上述步骤后,按下面对应的系统升级步骤执行即可。 > **如何判断是否需要迁移**:如果你是全新安装,或者你的机器人数据库中 `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 鉴权**。从无鉴权旧版本升级后,**WebUI 必须先创建管理员账号才能使用**。所有 `/api/*` 端点(除少量公共白名单)和 `/ws` 现在都需要登录。 本次更新引入了**强制 WebUI 鉴权**。从无鉴权旧版本升级后,**WebUI 必须先创建管理员账号才能使用**。所有 `/api/*` 端点(除少量公共白名单)和 `/ws` 现在都需要登录。
@@ -307,14 +317,15 @@ sudo systemctl start tsmusicbot
- 机器人昵称 - 机器人昵称
- 可选:服务器密码、默认频道 - 可选:服务器密码、默认频道
3. 在 **设置 → 音乐账号** 扫码登录网易云 / QQ 音乐 / 酷狗音乐 / B 站账号(可选,登录后可播放 VIP 歌曲、获取每日推荐 / 我的歌单等) 3. 在 **设置 → 音乐账号** 扫码登录网易云 / QQ 音乐 / 酷狗音乐 / B 站账号(可选,登录后可播放 VIP 歌曲、获取每日推荐 / 我的歌单等)
4. 在 **设置 → 用户管理**(仅管理员可见)按需添加成员。成员默认可控制播放但无法管理其他用户;管理员还可为每个成员单独配置**能力**(播放控制 / 队列 / 机器人管理 / 平台登录 / 音质)和**可操作的机器人白名单**,未授权的机器人对该成员不可见、不可控 4. (可选)在 **设置 → Jellyfin 音乐库** 连接自建 Jellyfin 服务器并打开「启用 Jellyfin 音源」(安装向导第 3 步保存连接时会自动启用;详见 [可选:Jellyfin 音源](#可选jellyfin-音源))
5. 在 **设置 → 用户管理**(仅管理员可见)按需添加成员。成员默认可控制播放但无法管理其他用户;管理员还可为每个成员单独配置**能力**(播放控制 / 队列 / 机器人管理 / 平台登录 / 音质)和**可操作的机器人白名单**,未授权的机器人对该成员不可见、不可控
### WebUI 页面说明 ### WebUI 页面说明
| 页面 | 功能 | | 页面 | 功能 |
|------|------| |------|------|
| **首页** | 推荐歌单、每日推荐、私人FM(网易云 / QQ 雷达 / 酷狗电台)、我的歌单、收藏的歌单(各源带标签切换) | | **首页** | 推荐歌单、每日推荐、私人FM(网易云 / QQ 雷达 / 酷狗电台)、我的歌单、收藏的歌单(各源带标签切换);启用 Jellyfin 后另有「Jellyfin 电台 / 最近添加 / 播放最多 / 收藏 / 流派」区块 |
| **搜索** | 四平台统一搜索,结果标注网易云/QQ/酷狗/B站来源,可一键收藏歌单 | | **搜索** | 跨音源统一搜索(仅显示已启用的音源;启用 Jellyfin 后其结果排最前),结果标注来源,可一键收藏歌单 |
| **歌单** | 查看歌单详情,播放全部(根据当前播放模式选择首歌),一键收藏 | | **歌单** | 查看歌单详情,播放全部(根据当前播放模式选择首歌),一键收藏 |
| **歌词** | 全屏歌词页,实时同步滚动,模糊专辑封面背景 | | **歌词** | 全屏歌词页,实时同步滚动,模糊专辑封面背景 |
| **历史** | 播放历史记录 | | **历史** | 播放历史记录 |
@@ -326,9 +337,9 @@ sudo systemctl start tsmusicbot
| 命令 | 说明 | | 命令 | 说明 |
|------|------| |------|------|
| `!play <歌名>` | 搜索并播放(取最热门的匹配项;默认音源为 Jellyfin,除非已停用) | | `!play <歌名>` | 搜索并播放(取最热门的匹配项;默认音源为网易云) |
| `!play -j <歌名>` | 显式从 Jellyfin 搜索 | | `!play -n <歌名>` | 显式从网易云音乐搜索(默认音源即网易云,通常可省略) |
| `!play -n <歌名>` | 从网易云音乐搜索(默认音源不再是网易云,需显式 `-n`) | | `!play -j <歌名>` | 从 Jellyfin 搜索(需先启用 Jellyfin 音源) |
| `!play -q <歌名>` | 从 QQ 音乐搜索 | | `!play -q <歌名>` | 从 QQ 音乐搜索 |
| `!play -k <歌名>` | 从酷狗音乐搜索 | | `!play -k <歌名>` | 从酷狗音乐搜索 |
| `!play -b <关键词>` | 从哔哩哔哩搜索视频并播放音频 | | `!play -b <关键词>` | 从哔哩哔哩搜索视频并播放音频 |
@@ -348,8 +359,8 @@ sudo systemctl start tsmusicbot
| `!playlist -q <歌单名>` | 从 QQ 音乐搜索并加载歌单 | | `!playlist -q <歌单名>` | 从 QQ 音乐搜索并加载歌单 |
| `!album <专辑名或ID>` | 加载专辑(支持名称搜索 / 数字 ID / Jellyfin GUID) | | `!album <专辑名或ID>` | 加载专辑(支持名称搜索 / 数字 ID / Jellyfin GUID) |
| `!artist <歌手名>` | 按歌手循环播放(支持 `-j`/`-n`/`-q`/`-k`/`-b`/`-y`) | | `!artist <歌手名>` | 按歌手循环播放(支持 `-j`/`-n`/`-q`/`-k`/`-b`/`-y`) |
| `!fm` | 私人 FM(默认 Jellyfin:从收藏出发的 Instant Mix 电台,自动续播) | | `!fm` | 私人 FM(默认网易云,自动续播) |
| `!fm -n` | 网易云私人 FM(自动续播) | | `!fm -j` | Jellyfin 电台:从收藏出发的 Instant Mix(需启用 Jellyfin,自动续播) |
| `!fm -q` | QQ 音乐雷达 / 猜你喜欢 FM(自动续播) | | `!fm -q` | QQ 音乐雷达 / 猜你喜欢 FM(自动续播) |
| `!fm -k` | 酷狗私人电台 / 个性化推荐 FM(自动续播) | | `!fm -k` | 酷狗私人电台 / 个性化推荐 FM(自动续播) |
| `!lyrics` | 显示当前完整歌词(自动分多条消息发送,不再只显示开头几行) | | `!lyrics` | 显示当前完整歌词(自动分多条消息发送,不再只显示开头几行) |
@@ -383,14 +394,7 @@ sudo systemctl start tsmusicbot
### 音质等级 ### 音质等级
**Jellyfin(主音源)** **在线音源(网易云等)**
| 等级 | 说明 |
|------|------|
| **原始直传(direct)** | **默认**:原始文件不转码直传(机器人本地统一转 Opus,此档即最高音质) |
| 320kbps / 192kbps / 128kbps | 由 Jellyfin 服务器转码后传输,适合公网带宽有限的自建服务器 |
**在线音源(网易云等,启用后可选)**
| 等级 | 码率 | 格式 | 说明 | | 等级 | 码率 | 格式 | 说明 |
|------|------|------|------| |------|------|------|------|
@@ -401,6 +405,13 @@ sudo systemctl start tsmusicbot
| Hi-Res | ~1500kbps | FLAC | 需要 VIP | | Hi-Res | ~1500kbps | FLAC | 需要 VIP |
| 超清母带 | ~4000kbps | FLAC | 需要黑胶 VIP | | 超清母带 | ~4000kbps | FLAC | 需要黑胶 VIP |
**Jellyfin(启用后)**
| 等级 | 说明 |
|------|------|
| **原始直传(direct)** | **默认**:原始文件不转码直传(机器人本地统一转 Opus,此档即最高音质) |
| 320kbps / 192kbps / 128kbps | 由 Jellyfin 服务器转码后传输,适合公网带宽有限的自建服务器 |
在设置页面选择音质,立即生效(影响后续播放的歌曲)。 在设置页面选择音质,立即生效(影响后续播放的歌曲)。
## 项目架构 ## 项目架构
@@ -424,6 +435,7 @@ teamspeak-music-bot/
│ │ └── database.ts # SQLite 数据库(播放历史、实例、收藏、权限持久化) │ │ └── database.ts # SQLite 数据库(播放历史、实例、收藏、权限持久化)
│ ├── music/ # 音源服务 │ ├── music/ # 音源服务
│ │ ├── provider.ts # 统一 MusicProvider 接口 │ │ ├── provider.ts # 统一 MusicProvider 接口
│ │ ├── jellyfin.ts # Jellyfin 适配器(可选音源,直连 REST API)
│ │ ├── netease.ts # 网易云音乐适配器 │ │ ├── netease.ts # 网易云音乐适配器
│ │ ├── qq.ts # QQ 音乐适配器 │ │ ├── qq.ts # QQ 音乐适配器
│ │ ├── bilibili.ts # 哔哩哔哩适配器(视频音频提取) │ │ ├── bilibili.ts # 哔哩哔哩适配器(视频音频提取)
@@ -477,6 +489,7 @@ teamspeak-music-bot/
| **数据库** | better-sqlite3 (SQLite) | | **数据库** | better-sqlite3 (SQLite) |
| **音频处理** | FFmpeg (ffmpeg-static 内置), @discordjs/opus | | **音频处理** | FFmpeg (ffmpeg-static 内置), @discordjs/opus |
| **TS 协议** | @honeybbq/teamspeak-client(完整客户端协议)+ 自研 TS6 协议适配层 | | **TS 协议** | @honeybbq/teamspeak-client(完整客户端协议)+ 自研 TS6 协议适配层 |
| **Jellyfin** | Jellyfin REST API(可选音源,直连,无额外 npm 依赖) |
| **网易云 API** | NeteaseCloudMusicApi | | **网易云 API** | NeteaseCloudMusicApi |
| **QQ 音乐 API** | @sansenjian/qq-music-api(锁定 `~2.4.0`,需 Node ≥ 20.17) | | **QQ 音乐 API** | @sansenjian/qq-music-api(锁定 `~2.4.0`,需 Node ≥ 20.17) |
| **哔哩哔哩** | BiliBili Web API(搜索、DASH 音频流、QR 登录) | | **哔哩哔哩** | BiliBili Web API(搜索、DASH 音频流、QR 登录) |
@@ -486,17 +499,17 @@ teamspeak-music-bot/
| **图标** | @iconify/vue | | **图标** | @iconify/vue |
| **日志** | pino | | **日志** | pino |
## Jellyfin 音源(主音源) ## 可选:Jellyfin 音源
本 fork 把自建 [Jellyfin](https://jellyfin.org/) 媒体服务器作为**默认且主要的音源**:机器人直接播放你自己音乐库里的文件,不依赖任何在线平台的可用性 / 版权 / 登录状态。 本项目可将自建 [Jellyfin](https://jellyfin.org/) 媒体服务器作为**额外音源**:机器人直接播放你自己音乐库里的文件,不依赖任何在线平台的可用性 / 版权 / 登录状态。该音源**默认关闭**,需要手动启用。
### 连接配置 ### 启用与连接配置
三种方式任选: 三种方式任选:
1. **首次安装向导** — 第 3 步即 Jellyfin 连接卡(可跳过,稍后配置)。 1. **首次安装向导** — 第 3 步即 Jellyfin 连接卡(可跳过,稍后配置);填写并「保存并继续」会**自动启用**该音源。
2. **WebUI** — 设置 → Jellyfin 音乐库:填写服务器地址、选择认证方式、「测试连接」验证后保存,**保存即时生效,无需重启**。 2. **WebUI** — 设置 → Jellyfin 音乐库(可选):打开「**启用 Jellyfin 音源**」开关,填写服务器地址、选择认证方式、「测试连接」验证后保存,**保存即时生效,无需重启**。
3. **config.json** — 手动编辑 `jellyfin` 配置块后重启。 3. **config.json** — 手动编辑 `jellyfin` 配置块,并把 `"jellyfin"` 加入 `enabledProviders`,然后重启。
两种认证方式: 两种认证方式:
@@ -516,7 +529,7 @@ teamspeak-music-bot/
"apiKey": "", // apikey 模式填写 "apiKey": "", // apikey 模式填写
"userId": "" // apikey 模式填写 "userId": "" // apikey 模式填写
}, },
"enabledProviders": ["jellyfin"] "enabledProviders": ["netease", "qq", "bilibili", "youtube", "kugou", "jellyfin"]
} }
``` ```
@@ -527,20 +540,21 @@ teamspeak-music-bot/
- **搜索** — 歌曲 / 专辑 / 歌单,支持翻页「加载更多」;WebUI 统一搜索中 Jellyfin 结果排最前 - **搜索** — 歌曲 / 专辑 / 歌单,支持翻页「加载更多」;WebUI 统一搜索中 Jellyfin 结果排最前
- **播放** — 懒解析播放地址;默认**原始直传**(不经 Jellyfin 转码),也可选 320/192/128kbps 服务器转码档(设置 → 音质设置) - **播放** — 懒解析播放地址;默认**原始直传**(不经 Jellyfin 转码),也可选 320/192/128kbps 服务器转码档(设置 → 音质设置)
- **歌词** — 读取 Jellyfin 的歌词接口(内嵌或 .lrc),时间轴同步滚动,`!lyrics` 可用 - **歌词** — 读取 Jellyfin 的歌词接口(内嵌或 .lrc),时间轴同步滚动,`!lyrics` 可用
- **电台 / FM**(`!fm` 或首页「Jellyfin 电台」卡片)— 随机取一首**收藏**做种子生成 Instant Mix 歌曲流;没有收藏则回退到最近播放、再回退随机曲目 - **电台 / FM**(`!fm -j` 或首页「Jellyfin 电台」卡片)— 随机取一首**收藏**做种子生成 Instant Mix 歌曲流;没有收藏则回退到最近播放、再回退随机曲目
- **首页区块** — 最近添加(专辑)/ 播放最多 / Jellyfin 收藏 / 我的歌单 / 流派(点流派芯片即播放该流派) - **首页区块** — 最近添加(专辑)/ 播放最多 / Jellyfin 收藏 / 我的歌单 / 流派(点流派芯片即播放该流派)
- **播放上报** — 播放开始 / 进度(约 10s 一次)/ 停止会回报给 Jellyfin(`Sessions/Playing` 系列接口),你的 Jellyfin 播放统计(PlayCount、最近播放)保持准确;上报失败不影响播放 - **播放上报** — 播放开始 / 进度(约 10s 一次)/ 停止会回报给 Jellyfin(`Sessions/Playing` 系列接口),你的 Jellyfin 播放统计(PlayCount、最近播放)保持准确;上报失败不影响播放
- **聊天命令** — 默认音源即 Jellyfin:`!play <歌名>`、`!playlist <歌单名或GUID>`、`!album <专辑名或GUID>`、`!artist <歌手>`、`!fm` 开箱即用 - **聊天命令** — 启用后用 `-j` 标志:`!play -j <歌名>`、`!fm -j`、`!artist -j <歌手>`;`!playlist` / `!album` / `!play id:` 可直接粘贴 Jellyfin GUID。若把在线音源全部停用、只保留 Jellyfin,不带标志的命令会自动以 Jellyfin 为默认音源
### enabledProviders:音源开关 ### enabledProviders:音源开关
`config.json` 的 `enabledProviders` 数组决定哪些音源可用(默认 `["jellyfin"]`): `config.json` 的 `enabledProviders` 数组决定哪些音源可用(默认 `["netease", "qq", "bilibili", "youtube", "kugou"]`,即在线音源全开、Jellyfin 关闭):
- 可选值:`jellyfin`、`netease`、`qq`、`bilibili`、`youtube`、`kugou`(`local` 由 `localAudioEnabled` 控制,`spotify` 由 `spotify.enabled` 控制) - 可选值:`jellyfin`、`netease`、`qq`、`bilibili`、`youtube`、`kugou`(`local` 由 `localAudioEnabled` 控制,`spotify` 由 `spotify.enabled` 控制)
- 未列出的音源:聊天命令返回「音源未启用」、REST 返回 400、WebUI 搜索栏 / 登录卡 / FM 卡片自动隐藏 - 未列出的音源:聊天命令返回「音源未启用」、REST 返回 400、WebUI 搜索栏 / 登录卡 / FM 卡片自动隐藏
- 不带平台标志的命令走**固定优先级中第一个已启用的音源**:网易云 → QQ → 酷狗 → Jellyfin → B站 → YouTube(默认配置下即网易云)
- 网易云 / QQ 停用时,其内嵌 API 服务(端口 3001 / 3200)**不会启动** - 网易云 / QQ 停用时,其内嵌 API 服务(端口 3001 / 3200)**不会启动**
- 示例(Jellyfin 为主 + 保留网易云备用):`"enabledProviders": ["jellyfin", "netease"]` - 示例(Jellyfin 为主、只留网易云备用):`"enabledProviders": ["jellyfin", "netease"]`(此时默认音源仍为网易云,点歌用 `-j` 或停用网易云);示例(纯 Jellyfin):`"enabledProviders": ["jellyfin"]`
- 注意:重新启用网易云 / QQ 的内嵌 API 服务需要重启机器人;其余音源改动即时生效 - 注意:重新启用网易云 / QQ 的内嵌 API 服务需要重启机器人;其余音源改动即时生效(WebUI 的 Jellyfin 开关即改此列表)
## 可选:YouTube 音源 ## 可选:YouTube 音源
@@ -774,10 +788,13 @@ A:使用 `!move <频道名>` 命令,或在设置页面创建机器人时指
A:可以。在设置页面创建多个实例,分别连接不同的 TS 服务器或频道。 A:可以。在设置页面创建多个实例,分别连接不同的 TS 服务器或频道。
**Q:端口 3200 被占用?** **Q:端口 3200 被占用?**
A:QQ 音乐 API 启动时自动监听 3200 端口。如果之前的进程还在运行,程序会自动复用。如需重启可手动结束 `node` 进程。 A:QQ 音乐 API 启动时会监听 `config.json` 里的 `qqMusicApiPort`(默认 **3200**),客户端也用同一个端口发请求,二者始终一致。如果之前的进程还在运行,程序会自动复用。如需改端口,改 `qqMusicApiPort` 后重启即可;如需重启可手动结束 `node` 进程。
**Q:日志里 `baseURL` 是 3200,但 QQ API 实际监听在 3300?(二维码不弹)**
A:这是**旧版本**(或过期的 `latest` Docker 镜像)才有的问题:早期实现用的上游包默认端口是 3300,而客户端 `baseURL` 已经是 3200,两边对不上,取二维码时就 `ECONNREFUSED 127.0.0.1:3200`。当前版本已把内嵌 QQ 音乐 API **强制绑定到 `qqMusicApiPort`(默认 3200)**,并在启动前把上游包读取的 `PORT` 环境变量对齐到该端口,二者不可能再错位。修复方法:**拉取最新镜像并重启**(`docker compose pull && docker compose up -d`),或用 `npm ci && npm run build` 更新到最新代码。启动后可在日志里确认那行 `QQ Music API started`,其 `port` 字段就是实际监听端口。
**Q:QQ 音乐二维码不弹 / 扫码登录失败 / cookie 无法使用?** **Q:QQ 音乐二维码不弹 / 扫码登录失败 / cookie 无法使用?**
A:通常是内置的 QQ 音乐 API 服务没起来——它一旦没监听 3200 端口,机器人去取二维码就会拿到 `ECONNREFUSED 127.0.0.1:3200`,于是二维码不显示,登录和 cookie 也全失效。先看日志里 QQ API 的启动报错: A:通常是内置的 QQ 音乐 API 服务没起来——它一旦没监听 `qqMusicApiPort`(默认 3200)端口,机器人去取二维码就会拿到 `ECONNREFUSED 127.0.0.1:3200`,于是二维码不显示,登录和 cookie 也全失效。先看日志里 QQ API 的启动报错:
- 报 `ERR_REQUIRE_ESM`:装到了不兼容的 `@sansenjian/qq-music-api` 版本。本项目把它锁在 **`~2.4.0`**(需要 **Node ≥ 20.17 / 22.9**);务必用 `npm ci` 或 `npm install` 让版本与锁文件一致,**不要**手动 `npm update` 把它升级或降级到不兼容的中间版本(2.3.0/2.3.1 是纯 ESM、会触发此错)。 - 报 `ERR_REQUIRE_ESM`:装到了不兼容的 `@sansenjian/qq-music-api` 版本。本项目把它锁在 **`~2.4.0`**(需要 **Node ≥ 20.17 / 22.9**);务必用 `npm ci` 或 `npm install` 让版本与锁文件一致,**不要**手动 `npm update` 把它升级或降级到不兼容的中间版本(2.3.0/2.3.1 是纯 ESM、会触发此错)。
- 报 Node 版本不满足:升级 Node 到 ≥ 20.17,或将该依赖降到 `~2.2.10`(无此 Node 要求)后重装。 - 报 Node 版本不满足:升级 Node 到 ≥ 20.17,或将该依赖降到 `~2.2.10`(无此 Node 要求)后重装。
修好版本后重新 `npm install && npm run build` 并重启即可。 修好版本后重新 `npm install && npm run build` 并重启即可。
@@ -837,7 +854,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)。这里只列出重要变更和面向用户的破坏性改动。 > 完整历史请查看 [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** **功能增强:Spotify 音源(实验性)/ 搜索结果翻页 / 细粒度权限 / 本地收藏 / 本地音频上传 / 专属链接 / 自动暂停 / QQ 雷达 FM**
@@ -948,8 +975,8 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
| 项目 | 说明 | | 项目 | 说明 |
|------|------| |------|------|
| [ZHANGTIANYAO1/teamspeak-music-bot](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot) | **本项目的上游**(MIT 许可)——完整的机器人框架、在线音源与 WebUI 均来自该项目,本 fork 在其之上加入 Jellyfin 主音源 | | [Jellyfin](https://github.com/jellyfin/jellyfin) | 自由软件媒体服务器(本项目的可选自建音源) |
| [Jellyfin](https://github.com/jellyfin/jellyfin) | 自由软件媒体服务器(本 fork 的主音源) | | [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 补丁) | | [yichen11818/NeteaseTSBot](https://github.com/yichen11818/NeteaseTSBot) | TS6 协议兼容参考(vendored tsproto 补丁) |
| [Splamy/TS3AudioBot](https://github.com/Splamy/TS3AudioBot) | 优秀的 TeamSpeak 音频机器人框架 | | [Splamy/TS3AudioBot](https://github.com/Splamy/TS3AudioBot) | 优秀的 TeamSpeak 音频机器人框架 |
| [TS3AudioBot-BiliBiliPlugin](https://github.com/xxmod/TS3AudioBot-BiliBiliPlugin) | 提供插件开发参考 | | [TS3AudioBot-BiliBiliPlugin](https://github.com/xxmod/TS3AudioBot-BiliBiliPlugin) | 提供插件开发参考 |
@@ -967,5 +994,3 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
## 开源许可 ## 开源许可
[MIT](LICENSE) [MIT](LICENSE)
本项目 fork 自 [ZHANGTIANYAO1/teamspeak-music-bot](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot),上游同样以 MIT 许可发布,版权归其原作者所有;本 fork 的修改部分亦以 MIT 许可发布。
+3 -4
View File
@@ -731,8 +731,8 @@ export class BotInstance extends EventEmitter {
} }
/** Chat-command source flags. No flag → the configured default platform /** Chat-command source flags. No flag → the configured default platform
* (jellyfin unless disabled). Netease is no longer the implicit default, * (netease in the default config; otherwise the first enabled source by
* so it gets an explicit -n flag. */ * fixed priority — see defaultPlatform()). */
private static readonly FLAG_PLATFORMS: ReadonlyArray<[string, Platform]> = [ private static readonly FLAG_PLATFORMS: ReadonlyArray<[string, Platform]> = [
["b", "bilibili"], ["b", "bilibili"],
["q", "qq"], ["q", "qq"],
@@ -1451,7 +1451,6 @@ export class BotInstance extends EventEmitter {
return [ return [
"TSMusicBot Commands:", "TSMusicBot Commands:",
`${p}play <song> — Search and play (default source: ${def})`, `${p}play <song> — Search and play (default source: ${def})`,
`${p}play -j <song> — Search from Jellyfin`,
...(flagHelp ? [` Source flags: ${flagHelp}`] : []), ...(flagHelp ? [` Source flags: ${flagHelp}`] : []),
`${p}search <name> — List top matches to pick a specific (same-name) song`, `${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`, `${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}mode <seq|loop|random|rloop> — Play mode`,
`${p}playlist <name or id> — Load playlist by name or ID`, `${p}playlist <name or id> — Load playlist by name or ID`,
`${p}album <name or id> — Load album`, `${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}artist <name> — Play songs by artist (loop)`,
`${p}vote — Vote to skip`, `${p}vote — Vote to skip`,
`${p}lyrics — Show lyrics`, `${p}lyrics — Show lyrics`,
+45 -1
View File
@@ -10,7 +10,7 @@ import {
renameSync, renameSync,
} from "node:fs"; } from "node:fs";
import { tmpdir } from "node:os"; 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 // 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, // and transient-read-error paths can be observed/forced. Everything else (mkdtemp,
@@ -48,6 +48,50 @@ describe("config", () => {
expect(config).toEqual(getDefaultConfig()); 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", () => { it("creates config file on save", () => {
const dir = makeTmpDir(); const dir = makeTmpDir();
const path = join(dir, "sub", "config.json"); const path = join(dir, "sub", "config.json");
+14 -11
View File
@@ -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: * The default platform for !play/!add/!playlist/!album and all REST/WebUI calls:
* jellyfin when enabled, otherwise the first enabled provider in a fixed * the first enabled provider in a fixed priority order (netease with the default
* priority order. Falls back to "netease" when nothing is enabled so callers * config; jellyfin ranks after the online music platforms because it is an
* always get a provider — the enabled-gate then produces the friendly error. * 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 { export function defaultPlatform(config: BotConfig): GateableProvider {
if (config.enabledProviders.includes("jellyfin")) return "jellyfin"; for (const p of ["netease", "qq", "kugou", "jellyfin", "bilibili", "youtube"] as const) {
for (const p of ["netease", "qq", "kugou", "bilibili", "youtube"] as const) {
if (config.enabledProviders.includes(p)) return p; if (config.enabledProviders.includes(p)) return p;
} }
return "netease"; return "netease";
@@ -102,9 +104,10 @@ export interface BotConfig {
jellyfin: JellyfinConfig; jellyfin: JellyfinConfig;
/** /**
* Which gateable providers are active (see GATEABLE_PROVIDERS). Default is * Which gateable providers are active (see GATEABLE_PROVIDERS). Default is
* jellyfin-only: the legacy NetEase/QQ/Bilibili/YouTube/Kugou sources keep * the online sources (NetEase/QQ/Bilibili/YouTube/Kugou); jellyfin is an
* compiling but stay disabled — their embedded sidecar API servers must not * opt-in extra that must be listed here (Settings → Jellyfin 音乐库 toggles
* start (or bind ports 3001/3200) unless listed here. * 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[]; enabledProviders: GateableProvider[];
} }
@@ -159,7 +162,7 @@ export function getDefaultConfig(): BotConfig {
apiKey: "", apiKey: "",
userId: "", 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 // enabledProviders → known providers only; a non-array falls back to the
// default (["jellyfin"]). An explicitly-empty array is respected (operator // default (online sources, jellyfin off). An explicitly-empty array is
// chose to disable every gateable source). // respected (operator chose to disable every gateable source).
const enabledProviders = Array.isArray(partial.enabledProviders) const enabledProviders = Array.isArray(partial.enabledProviders)
? partial.enabledProviders.filter((p): p is GateableProvider => ? partial.enabledProviders.filter((p): p is GateableProvider =>
(GATEABLE_PROVIDERS as readonly string[]).includes(p as string), (GATEABLE_PROVIDERS as readonly string[]).includes(p as string),
+2 -2
View File
@@ -56,8 +56,8 @@ async function main() {
{ {
neteasePort: config.neteaseApiPort, neteasePort: config.neteaseApiPort,
qqMusicPort: config.qqMusicApiPort, qqMusicPort: config.qqMusicApiPort,
// Provider gating: with the default (jellyfin-only) config, neither // Provider gating: a sidecar only starts (and binds 3001/3200) when its
// sidecar starts and ports 3001/3200 are never bound. // source is listed in enabledProviders — both are on in the default config.
neteaseEnabled: isProviderEnabled(config, "netease"), neteaseEnabled: isProviderEnabled(config, "netease"),
qqEnabled: isProviderEnabled(config, "qq"), qqEnabled: isProviderEnabled(config, "qq"),
}, },
+105 -2
View File
@@ -1,5 +1,34 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { describeQqApiStartupError } from "./api-server.js"; import { createApiServerManager, describeQqApiStartupError } from "./api-server.js";
import type { Logger } from "../logger.js";
// Record every listen() the QQ sidecar makes so we can assert it is always
// pinned to the configured port (regression coverage for issue #122).
const mockState = vi.hoisted(() => ({
listenCalls: [] as Array<{ port: number; host: string }>,
}));
vi.mock("@sansenjian/qq-music-api", () => {
const app = {
listen(port: number, host: string, cb?: () => void) {
mockState.listenCalls.push({ port, host });
const server = {
address: () => ({ port, address: host, family: "IPv4" as const }),
on() {
return server;
},
close(done?: () => void) {
done?.();
},
};
// Real net/Koa fire the listening callback on a later tick, after the
// caller has captured the returned server handle.
if (cb) setImmediate(cb);
return server;
},
};
return { default: app };
});
describe("describeQqApiStartupError", () => { describe("describeQqApiStartupError", () => {
it("flags ERR_REQUIRE_ESM by error code with version-pin guidance", () => { it("flags ERR_REQUIRE_ESM by error code with version-pin guidance", () => {
@@ -28,3 +57,77 @@ describe("describeQqApiStartupError", () => {
expect(describeQqApiStartupError(null)).toBeNull(); expect(describeQqApiStartupError(null)).toBeNull();
}); });
}); });
// Regression coverage for issue #122: the QQ Music API sidecar must listen on
// the same port the client base URL targets (config.qqMusicApiPort). A stale
// build once bound 3300 while the client requested 3200, silently breaking the
// QQ login QR / search flow with ECONNREFUSED on 127.0.0.1:3200.
describe("createApiServerManager — QQ sidecar port binding", () => {
const noopLogger = {
info() {},
warn() {},
error() {},
debug() {},
trace() {},
fatal() {},
} as unknown as Logger;
beforeEach(() => {
mockState.listenCalls = [];
});
it("listens on the configured qqMusicPort and exposes a matching base URL", async () => {
const port = 39217; // uncommon port to avoid clashing with a real instance
const manager = createApiServerManager(
{ neteasePort: 39218, qqMusicPort: port, neteaseEnabled: false, qqEnabled: true },
noopLogger
);
await manager.start();
manager.stop();
expect(manager.getQQMusicBaseUrl()).toBe(`http://127.0.0.1:${port}`);
expect(mockState.listenCalls).toEqual([{ port, host: "127.0.0.1" }]);
});
it("follows qqMusicPort — not an injected PORT — and restores PORT afterwards", async () => {
const port = 39219;
const previous = process.env.PORT;
// Simulate a hosting platform / compose file injecting a stray PORT that
// must NOT leak into the QQ sidecar's chosen port.
process.env.PORT = "39999";
const manager = createApiServerManager(
{ neteasePort: 39220, qqMusicPort: port, neteaseEnabled: false, qqEnabled: true },
noopLogger
);
try {
await manager.start();
// The sidecar follows qqMusicPort, never the injected PORT.
expect(mockState.listenCalls).toEqual([{ port, host: "127.0.0.1" }]);
// The injected PORT is restored so nothing else in the process is affected.
expect(process.env.PORT).toBe("39999");
} finally {
manager.stop();
if (previous === undefined) delete process.env.PORT;
else process.env.PORT = previous;
}
});
it("leaves an absent PORT env unset after importing the sidecar", async () => {
const port = 39221;
const previous = process.env.PORT;
delete process.env.PORT;
const manager = createApiServerManager(
{ neteasePort: 39222, qqMusicPort: port, neteaseEnabled: false, qqEnabled: true },
noopLogger
);
try {
await manager.start();
// Was unset before importing — must be unset again, no leaked override.
expect(process.env.PORT).toBeUndefined();
} finally {
manager.stop();
if (previous === undefined) delete process.env.PORT;
else process.env.PORT = previous;
}
});
});
+46 -10
View File
@@ -114,7 +114,25 @@ export function createApiServerManager(
"QQ Music API port already in use — reusing existing instance" "QQ Music API port already in use — reusing existing instance"
); );
} else { } else {
const qqModule = (await import("@sansenjian/qq-music-api")) as any; // Pin the upstream server to the configured port before importing.
// The package derives its default port from process.env.PORT (falling
// back to 3200) and, in some historical versions, auto-started that
// server as an import side effect. Aligning PORT with qqMusicApiPort
// guarantees the sidecar can never bind a different port than the one
// the client base URL (getQQMusicBaseUrl) targets — the root cause of
// issue #122, where an old build listened on 3300 while the client
// requested 3200. Restore the previous value right after import so we
// never leak the override into the rest of the process (e.g. the web
// server or the NetEase sidecar, which also read PORT as a fallback).
const prevPortEnv = process.env.PORT;
process.env.PORT = String(options.qqMusicPort);
let qqModule: any;
try {
qqModule = (await import("@sansenjian/qq-music-api")) as any;
} finally {
if (prevPortEnv === undefined) delete process.env.PORT;
else process.env.PORT = prevPortEnv;
}
// The module's export structure varies between versions: // The module's export structure varies between versions:
// 2.2.11+: default → Koa app (has .listen) // 2.2.11+: default → Koa app (has .listen)
// 2.2.10: default → wrapper object whose .default is the Koa app // 2.2.10: default → wrapper object whose .default is the Koa app
@@ -124,16 +142,34 @@ export function createApiServerManager(
? candidate ? candidate
: candidate.default ?? null; : candidate.default ?? null;
if (koaApp && typeof koaApp.listen === "function") { if (koaApp && typeof koaApp.listen === "function") {
qqMusicServer = await new Promise<Server>((resolve, reject) => { // A version that auto-started on import has already bound the
const srv = koaApp.listen(options.qqMusicPort, "127.0.0.1", () => // configured port (thanks to the PORT alignment above); reuse it
resolve(srv) // rather than racing a second listen that would fail EADDRINUSE.
const stillFree = await isPortFree(options.qqMusicPort);
if (!stillFree) {
logger.info(
{ port: options.qqMusicPort },
"QQ Music API already listening on the configured port (auto-started on import) — reusing embedded instance"
); );
srv.on("error", reject); } else {
}); qqMusicServer = await new Promise<Server>((resolve, reject) => {
logger.info( const srv = koaApp.listen(options.qqMusicPort, "127.0.0.1", () =>
{ port: options.qqMusicPort }, resolve(srv)
"QQ Music API started" );
); srv.on("error", reject);
});
// Log the port actually bound (read from the socket) rather than
// the requested one, so operators can spot a mismatch in the logs.
const addr = qqMusicServer.address();
const boundPort =
addr && typeof addr === "object" && addr !== null
? addr.port
: options.qqMusicPort;
logger.info(
{ port: boundPort },
"QQ Music API started"
);
}
} else { } else {
logger.warn("QQ Music API module does not expose a Koa app"); logger.warn("QQ Music API module does not expose a Koa app");
} }
+2 -1
View File
@@ -612,7 +612,8 @@ describe("bot router /settings jellyfin block + enabledProviders", () => {
hasPassword: true, hasPassword: true,
hasApiKey: false, 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 () => { it("POST /settings merges jellyfin, keeps stored secrets on blank, hot-configures", async () => {
+26 -21
View File
@@ -82,44 +82,51 @@ describe("music router provider gating (enabledProviders) + jellyfin endpoints",
return { app, netease, jellyfin }; 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 { app, netease, jellyfin } = mount(getDefaultConfig());
const res = await request(app).get("/api/music/search?q=hello"); const res = await request(app).get("/api/music/search?q=hello");
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(jellyfin.search).toHaveBeenCalledWith("hello", 20, 0); expect(netease.search).toHaveBeenCalledWith("hello", 20, 0);
expect(netease.search).not.toHaveBeenCalled(); expect(jellyfin.search).not.toHaveBeenCalled();
}); });
it("rejects a disabled platform with 400 without calling its provider", async () => { it("rejects a disabled platform with 400 without calling its provider", async () => {
// Default config enables jellyfin only. // Default config leaves jellyfin (opt-in) disabled.
const { app, netease } = mount(getDefaultConfig()); const { app, jellyfin } = mount(getDefaultConfig());
const res = await request(app).get("/api/music/search?q=hello&platform=netease"); const res = await request(app).get("/api/music/search?q=hello&platform=jellyfin");
expect(res.status).toBe(400); 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(); const config = getDefaultConfig();
config.enabledProviders = ["jellyfin", "netease"]; config.enabledProviders = [...config.enabledProviders, "jellyfin"];
const { app, netease } = mount(config); const { app, jellyfin } = mount(config);
const res = await request(app).get("/api/music/search?q=hello&platform=netease"); const res = await request(app).get("/api/music/search?q=hello&platform=jellyfin");
expect(res.status).toBe(200); 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 () => { it("GET /providers reports enabled sources and the default platform", async () => {
const { app } = mount(getDefaultConfig()); const { app } = mount(getDefaultConfig());
const res = await request(app).get("/api/music/providers"); const res = await request(app).get("/api/music/providers");
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body.default).toBe("jellyfin"); expect(res.body.default).toBe("netease");
expect(res.body.enabled).toContain("jellyfin"); expect(res.body.enabled).toContain("netease");
expect(res.body.enabled).toContain("local"); // localAudioEnabled defaults on 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 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 () => { 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"); const res = await request(app).get("/api/music/jellyfin/latest-albums?limit=5");
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect( expect(
@@ -128,16 +135,14 @@ describe("music router provider gating (enabledProviders) + jellyfin endpoints",
expect(res.body.albums).toHaveLength(1); expect(res.body.albums).toHaveLength(1);
}); });
it("GET /jellyfin/latest-albums is 400 when jellyfin is disabled", async () => { it("GET /jellyfin/latest-albums is 400 when jellyfin is disabled (the default)", async () => {
const config = getDefaultConfig(); const { app } = mount(getDefaultConfig());
config.enabledProviders = ["netease"];
const { app } = mount(config);
const res = await request(app).get("/api/music/jellyfin/latest-albums"); const res = await request(app).get("/api/music/jellyfin/latest-albums");
expect(res.status).toBe(400); expect(res.status).toBe(400);
}); });
it("GET /jellyfin/favorites denies unauthenticated/guest access", async () => { 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"); const res = await request(app).get("/api/music/jellyfin/favorites");
expect(res.status).toBe(401); expect(res.status).toBe(401);
}); });
+6 -4
View File
@@ -37,9 +37,10 @@ export function createMusicRouter(
/** /**
* Provider gating for user-supplied platform params. No platform → the * Provider gating for user-supplied platform params. No platform → the
* configured default (jellyfin unless disabled). A disabled platform gets a * configured default (see defaultPlatform(); netease in the default config).
* friendly 400 and null back — the handler must return immediately. * A disabled platform gets a friendly 400 and null back — the handler must
* Without a config (unit-test routers), everything stays enabled. * return immediately. Without a config (unit-test routers), everything
* stays enabled.
*/ */
function resolveProvider(platform: unknown, res: Response): MusicProvider | null { function resolveProvider(platform: unknown, res: Response): MusicProvider | null {
const requested = typeof platform === "string" && platform ? platform : undefined; 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 // view would only yield results that get skipped. Spotify search remains
// available from its own tab via /search?platform=spotify. // available from its own tab via /search?platform=spotify.
// Provider gating (#enabledProviders): disabled sources are skipped, not // 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 enabled = (p: string) => !config || isProviderEnabled(config, p);
const none = { songs: [], albums: [], playlists: [] }; const none = { songs: [], albums: [], playlists: [] };
const [jellyfinResult, neteaseResult, qqResult, bilibiliResult, localResult, kugouResult] = await Promise.allSettled([ const [jellyfinResult, neteaseResult, qqResult, bilibiliResult, localResult, kugouResult] = await Promise.allSettled([
+3 -3
View File
@@ -113,14 +113,14 @@ export const usePlayerStore = defineStore('player', {
// Which sources the server has enabled (GET /api/music/providers) and the // Which sources the server has enabled (GET /api/music/providers) and the
// configured default. Empty until fetched — sections stay hidden briefly. // configured default. Empty until fetched — sections stay hidden briefly.
enabledProviders: [] as string[], enabledProviders: [] as string[],
defaultSource: 'jellyfin' as string, defaultSource: 'netease' as string,
// Home page cache, split by source // Home page cache, split by source
recommendPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[], kugou: [] as PlaylistItem[], spotify: [] as PlaylistItem[] }, 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[] }, 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[] }, userPlaylists: { jellyfin: [] as PlaylistItem[], netease: [] as PlaylistItem[], qq: [] as PlaylistItem[], kugou: [] as PlaylistItem[], spotify: [] as PlaylistItem[] },
bilibiliPopular: [] as Song[], bilibiliPopular: [] as Song[],
// Jellyfin home sections (primary source) // Jellyfin home sections (shown only when the optional source is enabled)
jellyfinLatestAlbums: [] as AlbumItem[], jellyfinLatestAlbums: [] as AlbumItem[],
jellyfinMostPlayed: [] as Song[], jellyfinMostPlayed: [] as Song[],
jellyfinFavorites: [] as Song[], jellyfinFavorites: [] as Song[],
@@ -173,7 +173,7 @@ export const usePlayerStore = defineStore('player', {
const maxDuration = this.activeBot.currentSong.duration || Infinity; const maxDuration = this.activeBot.currentSong.duration || Infinity;
return interpolateElapsed(timing, this.isPaused, maxDuration); 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[] { availableSources(): Source[] {
const s: Source[] = []; const s: Source[] = [];
if (this.authStatus.jellyfin) s.push('jellyfin'); if (this.authStatus.jellyfin) s.push('jellyfin');
+3 -3
View File
@@ -57,7 +57,7 @@
<template v-else-if="allSongs.length || allAlbums.length || allPlaylists.length"> <template v-else-if="allSongs.length || allAlbums.length || allPlaylists.length">
<!-- Only enabled sources are offered (enabledProviders gate); Jellyfin <!-- Only enabled sources are offered (enabledProviders gate); Jellyfin
— the primary source — comes first. --> (opt-in) comes first when enabled. -->
<div class="source-bar"> <div class="source-bar">
<button <button
v-if="sourceEnabled('jellyfin')" v-if="sourceEnabled('jellyfin')"
@@ -218,7 +218,7 @@ function loadSource(): SearchSource {
const stored = localStorage.getItem(SOURCE_STORAGE_KEY); const stored = localStorage.getItem(SOURCE_STORAGE_KEY);
if (SEARCH_SOURCES.includes(stored as SearchSource)) return stored as SearchSource; if (SEARCH_SOURCES.includes(stored as SearchSource)) return stored as SearchSource;
} catch { /* localStorage blocked */ } } catch { /* localStorage blocked */ }
return 'jellyfin'; return 'netease';
} }
type TabType = 'songs' | 'albums' | 'playlists'; type TabType = 'songs' | 'albums' | 'playlists';
@@ -272,7 +272,7 @@ function fixupSelectedSource() {
const def = store.defaultSource as SearchSource; const def = store.defaultSource as SearchSource;
selectedSource.value = SEARCH_SOURCES.includes(def) && sourceEnabled(def) selectedSource.value = SEARCH_SOURCES.includes(def) && sourceEnabled(def)
? def ? def
: SEARCH_SOURCES.find((s) => s !== 'local' && sourceEnabled(s)) ?? 'jellyfin'; : SEARCH_SOURCES.find((s) => s !== 'local' && sourceEnabled(s)) ?? 'netease';
} }
// ---- 分页 / 加载更多 ---- // ---- 分页 / 加载更多 ----
+39 -5
View File
@@ -165,10 +165,13 @@
</div> </div>
</section> </section>
<!-- Jellyfin — the primary music source. Admin-configured connection <!-- Jellyfin — optional self-hosted music source. Admin-configured
(server URL + credentials), no QR flow. --> connection (server URL + credentials), no QR flow. The card stays
<section v-if="can('platform.auth') && providerOn('jellyfin')" class="settings-section"> visible even while the source is disabled: the enable toggle below is
<h2 class="section-title">Jellyfin 音乐库</h2> 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-card">
<div class="account-header"> <div class="account-header">
@@ -181,6 +184,15 @@
</div> </div>
</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"> <div class="form-group">
<label>服务器地址</label> <label>服务器地址</label>
<input v-model="jellyfinForm.serverUrl" class="input" autocomplete="off" placeholder="https://jellyfin.example.com" /> <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 jellyfinTesting = ref(false);
const jellyfinMessage = ref(''); const jellyfinMessage = ref('');
const jellyfinMessageTone = ref<'ok' | 'warn'>('ok'); 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 // Populate from the masked GET /api/bot/settings response — secrets never
// round-trip, only hasPassword/hasApiKey. // round-trip, only hasPassword/hasApiKey.
@@ -1183,8 +1201,22 @@ async function saveJellyfin() {
jellyfinSaving.value = true; jellyfinSaving.value = true;
jellyfinMessage.value = ''; jellyfinMessage.value = '';
try { 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); 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'; jellyfinMessageTone.value = 'ok';
jellyfinMessage.value = '已保存'; jellyfinMessage.value = '已保存';
await checkAuthStatus(); await checkAuthStatus();
@@ -1471,6 +1503,8 @@ async function loadIdleTimeout() {
applyJellyfinConfig(res.data.jellyfin); applyJellyfinConfig(res.data.jellyfin);
if (Array.isArray(res.data.enabledProviders)) { if (Array.isArray(res.data.enabledProviders)) {
enabledProviders.value = res.data.enabledProviders; enabledProviders.value = res.data.enabledProviders;
jellyfinEnabledForm.value = res.data.enabledProviders.includes('jellyfin');
enabledProvidersLoaded.value = true;
} }
} catch { /* ignore */ } } catch { /* ignore */ }
} }
+16 -4
View File
@@ -61,7 +61,7 @@
<div v-if="currentStep === 2" class="step-content"> <div v-if="currentStep === 2" class="step-content">
<h2>连接 Jellyfin (可选)</h2> <h2>连接 Jellyfin (可选)</h2>
<p class="subtitle">连接自建 Jellyfin 服务器作为主音源;也可稍后在「设置」中配置</p> <p class="subtitle">连接自建 Jellyfin 服务器作为额外音源;保存后自动启用,也可稍后在「设置」中配置</p>
<div class="form-group"> <div class="form-group">
<label>服务器地址</label> <label>服务器地址</label>
<input v-model="jellyfin.serverUrl" placeholder="https://jellyfin.example.com" class="input" /> <input v-model="jellyfin.serverUrl" placeholder="https://jellyfin.example.com" class="input" />
@@ -130,8 +130,8 @@ const nickname = ref('MusicBot');
const defaultChannel = ref(''); const defaultChannel = ref('');
const channelId = ref(''); const channelId = ref('');
// Jellyfin — the primary music source. Optional: skipping leaves it // Jellyfin — optional self-hosted music source. Skipping leaves it
// configurable later in Settings. // configurable later in Settings; saving here also enables it.
const jellyfin = reactive({ const jellyfin = reactive({
serverUrl: '', serverUrl: '',
authMode: 'userpass' as 'userpass' | 'apikey', authMode: 'userpass' as 'userpass' | 'apikey',
@@ -182,7 +182,19 @@ async function testJellyfin() {
async function saveJellyfinAndNext() { async function saveJellyfinAndNext() {
jellyfinSaving.value = true; jellyfinSaving.value = true;
try { 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; currentStep.value = 3;
} catch { } catch {
jellyfinTestOk.value = false; jellyfinTestOk.value = false;