Compare commits

...
Author SHA1 Message Date
saopig1 75497cf0f7 Merge remote-tracking branch 'origin/main' into feat/issue-126-default-source
# Conflicts:
#	src/data/config.ts
2026-07-17 11:05:05 +08:00
TIANYAO ZHANG 72682f4308 Merge pull request #130 from ZHANGTIANYAO1/feat/issue-125-persist-settings
feat: persist volume, play mode and audio quality across restarts
2026-07-17 11:03:22 +08:00
TIANYAO ZHANG f1585b010d Merge pull request #134 from ZHANGTIANYAO1/fix/issue-128-noindex-webui
feat(web): keep deployed WebUI out of search-engine indexes
2026-07-17 11:02:49 +08:00
TIANYAO ZHANG 1f88220d01 Merge pull request #129 from ZHANGTIANYAO1/fix/issue-122-qq-api-port
fix(qq): pin QQ Music API sidecar to configured qqMusicApiPort
2026-07-17 11:02:46 +08:00
TIANYAO ZHANG 75e3b1c722 Merge pull request #132 from ZHANGTIANYAO1/chore/issue-127-gitignore-claude
chore: add .claude/ to gitignore and untrack committed settings
2026-07-17 11:02:43 +08:00
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 fbd94c424b feat: persist volume, play mode and audio quality across restarts
Runtime playback settings were kept only in memory (AudioPlayer.volume,
PlayQueue.mode, each provider's quality field), so restarting the bot reset
them to defaults and users had to re-tune volume and quality every time (#125).

Persist and restore them via the repo's existing storage:
- Volume and play mode are per-bot, stored on new bot_instances columns
  (volume, play_mode) with a schema migration; restored when the instance is
  (re)built, written by cmdVol / cmdMode which every entry point (chat command,
  WebUI, REST) funnels through. Volume and mode are written independently so a
  transient !fm/!artist mode switch never overwrites the user's saved !mode.
- Per-provider audio quality is global (shared providers), stored in a new
  config.json `audioQuality` block; applied to the providers at startup and
  re-snapshotted on POST /api/music/quality.

Queue, current song, progress and FM/artist sessions stay ephemeral.

Adds tests for config sanitize/round-trip, DB player-settings + migration,
cmdVol/cmdMode persistence + construction-time restore, and quality persistence
through the REST endpoint. Documents the behavior in the README.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 00:09:47 +08:00
saopig1andClaude Fable 5 987513a5f6 feat: add configurable default music source (#126)
Make the default playback source a user-configurable setting so servers
that mostly play e.g. Bilibili no longer need to type `-b` on every
`!play`. Previously defaultPlatform() always picked the first enabled
provider by a fixed priority order, with no way to override it.

- config: add optional `defaultPlatform: GateableProvider | null`.
  loadConfig sanitizes it — kept only when it names a known provider that
  is also currently enabled, else null. defaultPlatform() returns the
  preference when enabled, otherwise falls back to the fixed priority order.
- POST /api/bot/settings accepts `defaultPlatform` (validated against the
  possibly-updated enabledProviders; null/"" clears it), and reconciles a
  stored default that a new enabledProviders list no longer allows. GET and
  POST responses expose the field.
- WebUI: new "默认音源" section with a source picker; saving refreshes the
  store's default source so it takes effect immediately without a restart.
- Tests: extend config defaultPlatform priority tests and add coverage for
  the settings endpoint and /providers routing.
- README: document `defaultPlatform` in the enabledProviders section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 23:43:39 +08:00
saopig1andClaude Fable 5 ea0f7c17b5 feat(web): keep deployed WebUI out of search-engine indexes
Searching "TsmusicBot" surfaced many deployed instances' WebUI URLs,
letting strangers walk into other people's control pages (issue #128).
Add defence-in-depth so crawlers stop indexing public deployments:

- send `X-Robots-Tag: noindex, nofollow` on every Express response
- serve `/robots.txt` with `User-agent: * / Disallow: /`
- add `<meta name="robots" content="noindex, nofollow">` to index.html,
  which also covers the /bot/<id> dedicated-link pages (same SPA shell)

These layers only prevent indexing; real protection stays with WebUI
auth and the reverse proxy. Document this in the README security section
and warn users not to post their WebUI link on public pages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 23:31:31 +08:00
saopig1andClaude Fable 5 4493269479 chore: add .claude/ to gitignore and untrack it
The .claude/ directory holds local Claude Code settings that should
not be version-controlled. Add it to .gitignore and remove the
already-committed settings from the index (files kept on disk).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 23:27:01 +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
24 changed files with 1369 additions and 171 deletions

No files matched your search

-24
View File
@@ -1,24 +0,0 @@
{
"permissions": {
"allow": [
"Read",
"Edit",
"Write",
"Glob",
"Grep",
"Bash(*)",
"WebFetch(*)",
"WebSearch(*)",
"Agent(*)",
"mcp__Claude_Preview__*",
"mcp__Claude_in_Chrome__*",
"mcp__scheduled-tasks__*"
],
"deny": [
"Bash(git push * main)",
"Bash(git push * master)",
"Bash(git push --force *)",
"Bash(rm -rf /)"
]
}
}
-26
View File
@@ -1,26 +0,0 @@
{
"permissions": {
"allow": [
"Read",
"Edit",
"Write",
"Glob",
"Grep",
"Bash(*)",
"WebFetch(*)",
"WebSearch(*)",
"Agent(*)",
"mcp__Claude_Preview__*",
"mcp__Claude_in_Chrome__*",
"mcp__scheduled-tasks__*",
"Bash(npx vitest:*)",
"Bash(cp \"C:\\\\Users\\\\saopig1\\\\.claude\\\\projects\\\\C--Users-saopig1-Music-teamspeak-music-bot\\\\b5a64d6f-051e-4b87-966c-ece97d2b879b\\\\tool-results\\\\webfetch-1776569008470-exv7qe.bin\" /tmp/design.gz)",
"Bash(gunzip -f /tmp/design.gz)",
"Read(//tmp/**)"
],
"deny": [
"Bash(git push --force *)",
"Bash(rm -rf /)"
]
}
}
+1
View File
@@ -7,6 +7,7 @@ config.json
cookies/
.superpowers/
.worktrees/
.claude/
setup.log
/bin/
scripts/navbar_bigger.png
+83 -42
View File
@@ -5,7 +5,7 @@
<h1 align="center">TSMusicBot</h1>
<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 align="center">
@@ -15,16 +15,16 @@
<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/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/BiliBili-可选-00a1d6?logo=bilibili&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>
> 本项目 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 时锁定到单个机器人,刷新后保持,适合把某台机器人的控制页分享给特定用户
- **频道无人时自动暂停** — 机器人所在频道没有其他人时自动暂停播放,有人加入后自动恢复(**默认关闭**,可在设置中开启)
- **Jellyfin 主音源** — 连接自建 [Jellyfin](https://jellyfin.org/) 服务器作为默认音源:搜索(歌曲 / 专辑 / 歌单)、懒解析直传播放、同步歌词、收藏 Instant Mix 电台(`!fm`)、首页「最近添加 / 播放最多 / 收藏 / 流派」,并把播放进度回报给 Jellyfin(PlayCount / 播放状态)。详见 [Jellyfin 音源(主音源)](#jellyfin-音源主音源)
- **多平台音源(enabledProviders 门控)** — 网易云音乐 / QQ 音乐 / 酷狗音乐 / 哔哩哔哩 / YouTube(yt-dlp)代码全部保留但**默认停用**,在 `config.json` 的 `enabledProviders` 中列出即可重新启用;**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** — 精美界面,支持深色/浅色主题切换
- **完整播放控制** — 播放/暂停/上一首/下一首/进度跳转/音量调节
@@ -167,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` 现在都需要登录。
@@ -307,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 后其结果排最前),结果标注来源,可一键收藏歌单 |
| **歌单** | 查看歌单详情,播放全部(根据当前播放模式选择首歌),一键收藏 |
| **歌词** | 全屏歌词页,实时同步滚动,模糊专辑封面背景 |
| **历史** | 播放历史记录 |
@@ -326,9 +337,9 @@ sudo systemctl start tsmusicbot
| 命令 | 说明 |
|------|------|
| `!play <歌名>` | 搜索并播放(取最热门的匹配项;默认音源为 Jellyfin,除非已停用) |
| `!play -j <歌名>` | 显式从 Jellyfin 搜索 |
| `!play -n <歌名>` | 从网易云音乐搜索(默认音源不再是网易云,需显式 `-n`) |
| `!play <歌名>` | 搜索并播放(取最热门的匹配项;默认音源为网易云) |
| `!play -n <歌名>` | 显式从网易云音乐搜索(默认音源即网易云,通常可省略) |
| `!play -j <歌名>` | 从 Jellyfin 搜索(需先启用 Jellyfin 音源) |
| `!play -q <歌名>` | 从 QQ 音乐搜索 |
| `!play -k <歌名>` | 从酷狗音乐搜索 |
| `!play -b <关键词>` | 从哔哩哔哩搜索视频并播放音频 |
@@ -348,8 +359,8 @@ sudo systemctl start tsmusicbot
| `!playlist -q <歌单名>` | 从 QQ 音乐搜索并加载歌单 |
| `!album <专辑名或ID>` | 加载专辑(支持名称搜索 / 数字 ID / Jellyfin GUID) |
| `!artist <歌手名>` | 按歌手循环播放(支持 `-j`/`-n`/`-q`/`-k`/`-b`/`-y`) |
| `!fm` | 私人 FM(默认 Jellyfin:从收藏出发的 Instant Mix 电台,自动续播) |
| `!fm -n` | 网易云私人 FM(自动续播) |
| `!fm` | 私人 FM(默认网易云,自动续播) |
| `!fm -j` | Jellyfin 电台:从收藏出发的 Instant Mix(需启用 Jellyfin,自动续播) |
| `!fm -q` | QQ 音乐雷达 / 猜你喜欢 FM(自动续播) |
| `!fm -k` | 酷狗私人电台 / 个性化推荐 FM(自动续播) |
| `!lyrics` | 显示当前完整歌词(自动分多条消息发送,不再只显示开头几行) |
@@ -383,14 +394,7 @@ sudo systemctl start tsmusicbot
### 音质等级
**Jellyfin(主音源)**
| 等级 | 说明 |
|------|------|
| **原始直传(direct)** | **默认**:原始文件不转码直传(机器人本地统一转 Opus,此档即最高音质) |
| 320kbps / 192kbps / 128kbps | 由 Jellyfin 服务器转码后传输,适合公网带宽有限的自建服务器 |
**在线音源(网易云等,启用后可选)**
**在线音源(网易云等)**
| 等级 | 码率 | 格式 | 说明 |
|------|------|------|------|
@@ -401,8 +405,29 @@ sudo systemctl start tsmusicbot
| Hi-Res | ~1500kbps | FLAC | 需要 VIP |
| 超清母带 | ~4000kbps | FLAC | 需要黑胶 VIP |
**Jellyfin(启用后)**
| 等级 | 说明 |
|------|------|
| **原始直传(direct)** | **默认**:原始文件不转码直传(机器人本地统一转 Opus,此档即最高音质) |
| 320kbps / 192kbps / 128kbps | 由 Jellyfin 服务器转码后传输,适合公网带宽有限的自建服务器 |
在设置页面选择音质,立即生效(影响后续播放的歌曲)。
> **重启后保留(#125)**:音质选择会持久化到 `data/config.json`(每个平台各自记录),重启机器人后自动恢复,无需每次手动重设。
### 重启后保留的播放设置
以下运行时设置在改动时自动落盘,重启机器人后自动恢复,不再回到默认值:
| 设置 | 作用范围 | 存储位置 |
|------|----------|----------|
| **播放音量**(`!vol` / WebUI 音量条 / REST `/volume`) | 每个机器人独立 | 数据库 `bot_instances.volume` |
| **播放模式**(`!mode` / WebUI / REST `/mode`:顺序 / 列表循环 / 随机 / 随机循环) | 每个机器人独立 | 数据库 `bot_instances.play_mode` |
| **音质**(各平台,WebUI 设置页 / REST `/quality`) | 全局(各平台各自记录) | `data/config.json` 的 `audioQuality` |
聊天命令、WebUI、REST API 三种入口的改动都会被持久化。播放队列、当前歌曲、进度、`!fm` / `!artist` 等临时播放状态仍为一次性状态,重启后不保留(`!fm` / `!artist` 内部临时切换的随机 / 循环也**不会**覆盖你用 `!mode` 显式保存的偏好)。
## 项目架构
```
@@ -424,6 +449,7 @@ teamspeak-music-bot/
│ │ └── database.ts # SQLite 数据库(播放历史、实例、收藏、权限持久化)
│ ├── music/ # 音源服务
│ │ ├── provider.ts # 统一 MusicProvider 接口
│ │ ├── jellyfin.ts # Jellyfin 适配器(可选音源,直连 REST API)
│ │ ├── netease.ts # 网易云音乐适配器
│ │ ├── qq.ts # QQ 音乐适配器
│ │ ├── bilibili.ts # 哔哩哔哩适配器(视频音频提取)
@@ -477,6 +503,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 登录) |
@@ -486,17 +513,17 @@ teamspeak-music-bot/
| **图标** | @iconify/vue |
| **日志** | pino |
## Jellyfin 音源(主音源)
## 可选:Jellyfin 音源
本 fork 把自建 [Jellyfin](https://jellyfin.org/) 媒体服务器作为**默认且主要的音源**:机器人直接播放你自己音乐库里的文件,不依赖任何在线平台的可用性 / 版权 / 登录状态。
本项目可将自建 [Jellyfin](https://jellyfin.org/) 媒体服务器作为**额外音源**:机器人直接播放你自己音乐库里的文件,不依赖任何在线平台的可用性 / 版权 / 登录状态。该音源**默认关闭**,需要手动启用。
### 连接配置
### 启用与连接配置
三种方式任选:
1. **首次安装向导** — 第 3 步即 Jellyfin 连接卡(可跳过,稍后配置)。
2. **WebUI** — 设置 → Jellyfin 音乐库:填写服务器地址、选择认证方式、「测试连接」验证后保存,**保存即时生效,无需重启**。
3. **config.json** — 手动编辑 `jellyfin` 配置块后重启。
1. **首次安装向导** — 第 3 步即 Jellyfin 连接卡(可跳过,稍后配置);填写并「保存并继续」会**自动启用**该音源。
2. **WebUI** — 设置 → Jellyfin 音乐库(可选):打开「**启用 Jellyfin 音源**」开关,填写服务器地址、选择认证方式、「测试连接」验证后保存,**保存即时生效,无需重启**。
3. **config.json** — 手动编辑 `jellyfin` 配置块,并把 `"jellyfin"` 加入 `enabledProviders`,然后重启。
两种认证方式:
@@ -516,7 +543,7 @@ teamspeak-music-bot/
"apiKey": "", // apikey 模式填写
"userId": "" // apikey 模式填写
},
"enabledProviders": ["jellyfin"]
"enabledProviders": ["netease", "qq", "bilibili", "youtube", "kugou", "jellyfin"]
}
```
@@ -527,20 +554,22 @@ teamspeak-music-bot/
- **搜索** — 歌曲 / 专辑 / 歌单,支持翻页「加载更多」;WebUI 统一搜索中 Jellyfin 结果排最前
- **播放** — 懒解析播放地址;默认**原始直传**(不经 Jellyfin 转码),也可选 320/192/128kbps 服务器转码档(设置 → 音质设置)
- **歌词** — 读取 Jellyfin 的歌词接口(内嵌或 .lrc),时间轴同步滚动,`!lyrics` 可用
- **电台 / FM**(`!fm` 或首页「Jellyfin 电台」卡片)— 随机取一首**收藏**做种子生成 Instant Mix 歌曲流;没有收藏则回退到最近播放、再回退随机曲目
- **电台 / FM**(`!fm -j` 或首页「Jellyfin 电台」卡片)— 随机取一首**收藏**做种子生成 Instant Mix 歌曲流;没有收藏则回退到最近播放、再回退随机曲目
- **首页区块** — 最近添加(专辑)/ 播放最多 / Jellyfin 收藏 / 我的歌单 / 流派(点流派芯片即播放该流派)
- **播放上报** — 播放开始 / 进度(约 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:音源开关
`config.json` 的 `enabledProviders` 数组决定哪些音源可用(默认 `["jellyfin"]`):
`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(默认配置下即网易云)
- **自定义默认音源(`defaultPlatform`)** — 想让不带标志的 `!play 歌名` 直接用某个音源(例如常听哔哩哔哩,免去每次加 `-b`),可在 设置 → 默认音源 里选择,或在 `config.json` 中设置 `"defaultPlatform": "bilibili"`。取值须是 `enabledProviders` 里已启用的音源,否则被忽略(回退到上面的固定优先级);留空 / `null` / 删除该字段即恢复固定优先级。WebUI 保存后即时生效,无需重启
- 网易云 / QQ 停用时,其内嵌 API 服务(端口 3001 / 3200)**不会启动**
- 示例(Jellyfin 为主 + 保留网易云备用):`"enabledProviders": ["jellyfin", "netease"]`
- 注意:重新启用网易云 / QQ 的内嵌 API 服务需要重启机器人;其余音源改动即时生效
- 示例(Jellyfin 为主、只留网易云备用):`"enabledProviders": ["jellyfin", "netease"]`(默认音源仍为网易云,点歌用 `-j`、停用网易云,或直接把 `defaultPlatform` 设为 `"jellyfin"`);示例(纯 Jellyfin):`"enabledProviders": ["jellyfin"]`
- 注意:重新启用网易云 / QQ 的内嵌 API 服务需要重启机器人;其余音源改动即时生效(WebUI 的 Jellyfin 开关即改此列表)
## 可选:YouTube 音源
@@ -774,10 +803,13 @@ A:使用 `!move <频道名>` 命令,或在设置页面创建机器人时指
A:可以。在设置页面创建多个实例,分别连接不同的 TS 服务器或频道。
**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 无法使用?**
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、会触发此错)。
- 报 Node 版本不满足:升级 Node 到 ≥ 20.17,或将该依赖降到 `~2.2.10`(无此 Node 要求)后重装。
修好版本后重新 `npm install && npm run build` 并重启即可。
@@ -837,7 +869,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**
@@ -867,6 +909,7 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
- **会话存储**:服务端 SQLite 表 `sessions`,存储 sha256(token);浏览器只持有原始 token cookie。7 天 TTL,每小时滚动续期。同账号最多 10 个并发会话(超出剔除最旧)。
- **登录限流**:每 IP 每分钟 5 次 `/login` + 3 次 `/setup`,命中返回 429 + `Retry-After`。
- **CSRF & 安全头**:所有 mutating 请求强制 `Origin`/`Referer` 同源;响应携带 `X-Frame-Options: DENY` 和 `Content-Security-Policy: frame-ancestors 'none'`(防点击劫持)。
- **搜索引擎隐身(防止实例被收录,issue #128)**:为避免部署实例的 WebUI 被搜索引擎收录、被陌生人搜到控制页,采用纵深防御——所有响应携带 `X-Robots-Tag: noindex, nofollow`,`/robots.txt` 返回 `User-agent: * / Disallow: /`,`index.html` 内置 `<meta name="robots" content="noindex, nofollow">`(专属链接 `/bot/<id>` 等所有页面同样覆盖)。这些只阻止「被索引」,不是访问控制——**请不要把自己的 WebUI 链接发到公开网页 / 论坛 / 聊天群**,真正的防护来自登录鉴权与反向代理。
- **配置变更**:反向代理部署务必 `"trustProxy": true`(详见 [反向代理部署注意事项](#反向代理部署注意事项))。`config.adminGroups` 现已启用,用于限制管理类聊天命令只能由指定 TeamSpeak 服务器组运行(为空 = 不限制,详见 [TeamSpeak 命令权限](#teamspeak-命令权限管理类命令限制));`config.adminPassword` 仍为旧版预留字段,保留以兼容旧 `config.json`,当前未使用。
### v0.x — Bot Profile 自动更新与协议层升级
@@ -948,8 +991,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) | 自由软件媒体服务器(本 fork 的主音源) |
| [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) | 提供插件开发参考 |
@@ -967,5 +1010,3 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
## 开源许可
[MIT](LICENSE)
本项目 fork 自 [ZHANGTIANYAO1/teamspeak-music-bot](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot),上游同样以 MIT 许可发布,版权归其原作者所有;本 fork 的修改部分亦以 MIT 许可发布。
+134
View File
@@ -6,6 +6,7 @@ import type { SpotifyController } from "../music/spotify/controller.js";
import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js";
import type { MusicProvider } from "../music/provider.js";
import type { BotDatabase } from "../data/database.js";
import { createDatabase } from "../data/database.js";
import type { AvatarStore } from "../data/avatars.js";
import type { BotConfig } from "../data/config.js";
@@ -832,6 +833,9 @@ describe("BotInstance — spotifyOAuth threading to the controller factory (C3.1
const database = {
getProfileConfig: () => ({}),
getCustomAvatarPath: () => null,
getPlayerSettings: () => ({ volume: 75, playMode: "seq" }),
saveVolume: () => {},
savePlayMode: () => {},
} as unknown as BotDatabase;
const options: BotInstanceOptions = {
id: "bot-oauth-test",
@@ -912,6 +916,136 @@ describe("spotifyPortsForBotId — per-bot go-librespot ports (Fix 3)", () => {
});
});
// --- Persisting volume + play mode across restarts (#125) ------------------
const cmdVol = (BotInstance.prototype as any).cmdVol as (this: unknown, cmd: any) => string;
const cmdMode = (BotInstance.prototype as any).cmdMode as (this: unknown, cmd: any) => string;
describe("BotInstance.cmdVol — persistence (#125)", () => {
function makeVolCtx() {
let stored = 75;
return {
id: "bot1",
player: {
setVolume: vi.fn((v: number) => { stored = v; }),
getVolume: vi.fn(() => stored),
},
database: { saveVolume: vi.fn() },
logger: { warn: vi.fn() },
emit: vi.fn(),
// The real private persist helper lives on the prototype; wire it so the
// test exercises the shipped persistence path end-to-end.
persistVolume: (BotInstance.prototype as any).persistVolume,
} as any;
}
it("saves the new volume via database.saveVolume (covers chat !vol AND the REST endpoint)", () => {
const ctx = makeVolCtx();
const res = cmdVol.call(ctx, { args: "40" });
expect(res).toBe("Volume set to 40%");
expect(ctx.player.setVolume).toHaveBeenCalledWith(40);
expect(ctx.database.saveVolume).toHaveBeenCalledWith("bot1", 40);
expect(ctx.emit).toHaveBeenCalledWith("stateChange");
});
it("does not persist an out-of-range volume", () => {
const ctx = makeVolCtx();
const res = cmdVol.call(ctx, { args: "999" });
expect(res).toBe("Usage: !vol <0-100>");
expect(ctx.player.setVolume).not.toHaveBeenCalled();
expect(ctx.database.saveVolume).not.toHaveBeenCalled();
});
it("swallows a database error so the volume change still succeeds", () => {
const ctx = makeVolCtx();
ctx.database.saveVolume = vi.fn(() => { throw new Error("disk full"); });
const res = cmdVol.call(ctx, { args: "50" });
expect(res).toBe("Volume set to 50%");
expect(ctx.player.setVolume).toHaveBeenCalledWith(50);
expect(ctx.logger.warn).toHaveBeenCalled();
});
});
describe("BotInstance.cmdMode — persistence (#125)", () => {
function makeModeCtx() {
let mode = "seq";
return {
id: "bot1",
queue: {
setMode: vi.fn((m: string) => { mode = m; }),
getMode: vi.fn(() => mode),
},
database: { savePlayMode: vi.fn() },
logger: { warn: vi.fn() },
emit: vi.fn(),
persistPlayMode: (BotInstance.prototype as any).persistPlayMode,
} as any;
}
it("saves the new play mode via database.savePlayMode", () => {
const ctx = makeModeCtx();
const res = cmdMode.call(ctx, { args: "rloop" });
expect(res).toBe("Play mode set to: rloop");
expect(ctx.queue.setMode).toHaveBeenCalledWith("rloop");
expect(ctx.database.savePlayMode).toHaveBeenCalledWith("bot1", "rloop");
expect(ctx.emit).toHaveBeenCalledWith("stateChange");
});
it("does not persist an unknown mode", () => {
const ctx = makeModeCtx();
const res = cmdMode.call(ctx, { args: "bogus" });
expect(res).toBe("Usage: !mode <seq|loop|random|rloop>");
expect(ctx.queue.setMode).not.toHaveBeenCalled();
expect(ctx.database.savePlayMode).not.toHaveBeenCalled();
});
});
describe("BotInstance — restores persisted player settings on construction (#125)", () => {
const provider = { platform: "netease" } as unknown as MusicProvider;
function makeOptions(id: string, database: BotDatabase): BotInstanceOptions {
const logger: any = { info() {}, warn() {}, error() {}, debug() {}, child() { return logger; } };
return {
id,
name: "RestoreBot",
tsOptions: { host: "localhost", port: 9987, queryPort: 10011, nickname: "RestoreBot" } as any,
neteaseProvider: provider,
qqProvider: provider,
bilibiliProvider: provider,
youtubeProvider: provider,
database,
config: { spotify: {} } as unknown as BotConfig,
logger,
avatarStore: { read: () => null } as unknown as AvatarStore,
spotifyControllerFactory: () => ({ on: () => {} } as unknown as SpotifyController),
};
}
it("applies the saved volume + play mode from the database", () => {
const db = createDatabase(":memory:");
db.saveBotInstance({
id: "bot-restore", name: "B", serverAddress: "x", serverPort: 9987, nickname: "n",
defaultChannel: "", channelId: "", channelPassword: "", autoStart: false,
serverProtocol: "", ts6ApiKey: "", serverPassword: "",
});
db.saveVolume("bot-restore", 33);
db.savePlayMode("bot-restore", "loop");
const bot = new BotInstance(makeOptions("bot-restore", db));
const status = bot.getStatus();
expect(status.volume).toBe(33);
expect(status.playMode).toBe("loop");
db.close();
});
it("falls back to defaults for a bot with no saved settings", () => {
const db = createDatabase(":memory:");
const bot = new BotInstance(makeOptions("brand-new", db));
const status = bot.getStatus();
expect(status.volume).toBe(75);
expect(status.playMode).toBe("seq");
db.close();
});
});
describe("BotInstance.handleTextMessage — response chunking (#116)", () => {
it("splits a long command response into multiple sends, each under the byte cap", async () => {
const ctx = makeGateCtx({ adminGroups: [] });
+55 -11
View File
@@ -39,6 +39,15 @@ import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js";
/** Reply sent when a non-admin invokes an admin-only chat command. */
export const COMMAND_DENIED_MESSAGE = "⛔ 需要管理员权限(该命令仅限管理员服务器组)";
/** Maps the persisted / command-line play-mode string to the PlayMode enum.
* Shared by the !mode command and the restart-restore path (#125). */
const PLAY_MODE_BY_VALUE: Record<string, PlayMode> = {
seq: PlayMode.Sequential,
loop: PlayMode.Loop,
random: PlayMode.Random,
rloop: PlayMode.RandomLoop,
};
/** Fallback message when Spotify audio can't be served (backend unavailable
* OR a per-track playTrack failure against a dead/failed sidecar). */
const SPOTIFY_UNAVAILABLE_MESSAGE =
@@ -188,6 +197,19 @@ export class BotInstance extends EventEmitter {
this.player = new AudioPlayer(this.logger);
this.queue = new PlayQueue();
// Restore persisted per-bot player settings (#125): volume + play mode
// survive restarts. getPlayerSettings returns validated values (the in-memory
// defaults when the row/column is absent), so this is a harmless no-op for a
// brand-new bot and reproduces the saved state for an existing one.
try {
const settings = this.database.getPlayerSettings(this.id);
this.player.setVolume(settings.volume);
const restoredMode = PLAY_MODE_BY_VALUE[settings.playMode];
if (restoredMode) this.queue.setMode(restoredMode);
} catch (err) {
this.logger.warn({ err }, "Failed to restore player settings — using defaults");
}
// Structural typing (like localProvider.sweepUnreferenced): only the real
// JellyfinProvider exposes createPlaybackReporter, so the netease fallback
// provider simply leaves reporting off.
@@ -731,8 +753,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"],
@@ -1136,10 +1158,36 @@ export class BotInstance extends EventEmitter {
const vol = parseInt(cmd.args, 10);
if (isNaN(vol) || vol < 0 || vol > 100) return "Usage: !vol <0-100>";
this.player.setVolume(vol);
// Persist so the volume survives a restart (#125). Both the chat !vol command
// and the WebUI/REST volume endpoint funnel through here, so one write covers
// every entry point. Only volume is written — play mode is saved independently.
this.persistVolume();
this.emit("stateChange");
return `Volume set to ${vol}%`;
}
/** Persist the current volume (#125). Best-effort: a DB error must never break
* the volume change itself. */
private persistVolume(): void {
try {
this.database.saveVolume(this.id, this.player.getVolume());
} catch (err) {
this.logger.warn({ err }, "Failed to persist volume");
}
}
/** Persist the current play mode (#125). Best-effort, mirrors persistVolume.
* Called ONLY from the explicit !mode command — NOT from FM/artist mode, whose
* Random/Loop switch is a transient side effect that must not overwrite the
* user's saved preference. */
private persistPlayMode(): void {
try {
this.database.savePlayMode(this.id, this.queue.getMode());
} catch (err) {
this.logger.warn({ err }, "Failed to persist play mode");
}
}
private cmdNow(): string {
const song = this.queue.current();
if (!song) return "Nothing is playing";
@@ -1205,15 +1253,12 @@ export class BotInstance extends EventEmitter {
}
private cmdMode(cmd: ParsedCommand): string {
const modeMap: Record<string, PlayMode> = {
seq: PlayMode.Sequential,
loop: PlayMode.Loop,
random: PlayMode.Random,
rloop: PlayMode.RandomLoop,
};
const mode = modeMap[cmd.args];
const mode = PLAY_MODE_BY_VALUE[cmd.args];
if (mode === undefined) return "Usage: !mode <seq|loop|random|rloop>";
this.queue.setMode(mode);
// Persist so the play mode survives a restart (#125). The chat !mode command
// and the WebUI/REST mode endpoint both funnel through here.
this.persistPlayMode();
this.emit("stateChange");
return `Play mode set to: ${cmd.args}`;
}
@@ -1451,7 +1496,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 +1511,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`,
+154 -1
View File
@@ -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,159 @@ 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");
});
// --- #126: an explicit operator default source ---
it("defaultPlatform is null by default (follow the priority order)", () => {
expect(getDefaultConfig().defaultPlatform).toBeNull();
});
it("defaultPlatform() honors an explicit, enabled preference over the priority order", () => {
const config = getDefaultConfig();
// Priority would pick netease; a Bilibili-loving server sets B站 instead (#126).
config.defaultPlatform = "bilibili";
expect(defaultPlatform(config)).toBe("bilibili");
});
it("defaultPlatform() ignores a preference whose source is not enabled", () => {
const config = getDefaultConfig();
config.defaultPlatform = "jellyfin"; // opt-in, not enabled in the default config
// Falls back to the fixed priority order (netease)…
expect(defaultPlatform(config)).toBe("netease");
// …until the preferred source is actually enabled.
config.enabledProviders = [...config.enabledProviders, "jellyfin"];
expect(defaultPlatform(config)).toBe("jellyfin");
});
it("loadConfig keeps a valid, enabled defaultPlatform", () => {
const dir = makeTmpDir();
const path = join(dir, "config.json");
writeFileSync(path, JSON.stringify({ defaultPlatform: "bilibili" }));
const config = loadConfig(path);
expect(config.defaultPlatform).toBe("bilibili");
expect(defaultPlatform(config)).toBe("bilibili");
});
it("loadConfig nulls a defaultPlatform that is unknown, disabled, or the wrong type", () => {
const dir = makeTmpDir();
// Unknown provider name.
const p1 = join(dir, "c1.json");
writeFileSync(p1, JSON.stringify({ defaultPlatform: "bogus" }));
expect(loadConfig(p1).defaultPlatform).toBeNull();
// Known provider, but not in enabledProviders.
const p2 = join(dir, "c2.json");
writeFileSync(p2, JSON.stringify({ enabledProviders: ["netease"], defaultPlatform: "bilibili" }));
expect(loadConfig(p2).defaultPlatform).toBeNull();
// Wrong type.
const p3 = join(dir, "c3.json");
writeFileSync(p3, JSON.stringify({ defaultPlatform: 42 }));
expect(loadConfig(p3).defaultPlatform).toBeNull();
});
it("round-trips defaultPlatform through save/load", () => {
const dir = makeTmpDir();
const path = join(dir, "config.json");
saveConfig(path, { ...getDefaultConfig(), defaultPlatform: "qq" });
expect(loadConfig(path).defaultPlatform).toBe("qq");
});
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");
});
// ── audioQuality persistence (#125) ─────────────────────────────────────
it("defaults audioQuality to each provider's in-memory default", () => {
const config = getDefaultConfig();
expect(config.audioQuality).toEqual({
netease: "exhigh",
qq: "exhigh",
bilibili: "high",
kugou: "128",
jellyfin: "direct",
});
});
it("fills audioQuality defaults for a legacy config without the field", () => {
const dir = makeTmpDir();
const path = join(dir, "config.json");
writeFileSync(path, JSON.stringify({ webPort: 4000 }));
const config = loadConfig(path);
expect(config.audioQuality).toEqual(getDefaultConfig().audioQuality);
});
it("round-trips a saved audioQuality through save/load", () => {
const dir = makeTmpDir();
const path = join(dir, "config.json");
const config = getDefaultConfig();
config.audioQuality = {
netease: "lossless",
qq: "flac",
bilibili: "high",
kugou: "flac",
jellyfin: "320",
};
saveConfig(path, config);
const loaded = loadConfig(path);
expect(loaded.audioQuality).toEqual(config.audioQuality);
});
it("coerces missing / non-string audioQuality fields to defaults", () => {
const dir = makeTmpDir();
const path = join(dir, "config.json");
// netease valid, qq blank, bilibili wrong type, kugou missing, jellyfin valid.
writeFileSync(
path,
JSON.stringify({ audioQuality: { netease: "lossless", qq: " ", bilibili: 320, jellyfin: "192" } }),
);
const config = loadConfig(path);
expect(config.audioQuality).toEqual({
netease: "lossless",
qq: "exhigh", // blank → default
bilibili: "high", // non-string → default
kugou: "128", // missing → default
jellyfin: "192",
});
});
it("creates config file on save", () => {
const dir = makeTmpDir();
const path = join(dir, "sub", "config.json");
+88 -12
View File
@@ -38,6 +38,22 @@ export interface JellyfinConfig {
userId: string;
}
/**
* Per-provider audio quality (音质), persisted so a restart keeps the user's
* choice instead of resetting each provider to its in-memory default (#125).
* The values are the same strings the WebUI/REST `POST /api/music/quality`
* endpoint sends and each provider's setQuality() accepts; on startup they are
* replayed onto the (shared, process-wide) providers. Providers ignore/normalize
* unknown values, so a stale/hand-edited entry can never break playback.
*/
export interface AudioQualityConfig {
netease: string;
qq: string;
bilibili: string;
kugou: string;
jellyfin: string;
}
/**
* Providers gated by `enabledProviders`. Not listed here:
* - "local" → governed by the existing `localAudioEnabled` flag
@@ -61,14 +77,25 @@ 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 default platform for !play/!add/!playlist/!album and all REST/WebUI calls.
*
* An explicit user preference (`config.defaultPlatform`) wins whenever it points
* at a source that is currently enabled — this lets e.g. a Bilibili-loving server
* set B站 as the default so `!play <歌名>` needs no `-b` flag (issue #126). The
* enabled-guard here matters at runtime too: if the operator later disables the
* preferred source, we must fall through instead of returning a dead default.
*
* With no (usable) preference we fall back to 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) {
const pref = config.defaultPlatform;
if (pref && config.enabledProviders.includes(pref)) return pref;
for (const p of ["netease", "qq", "kugou", "jellyfin", "bilibili", "youtube"] as const) {
if (config.enabledProviders.includes(p)) return p;
}
return "netease";
@@ -100,13 +127,24 @@ export interface BotConfig {
guestMode: GuestModeConfig;
spotify: SpotifyConfig;
jellyfin: JellyfinConfig;
/** Persisted per-provider audio quality (音质), restored on startup (#125). */
audioQuality: AudioQualityConfig;
/**
* 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[];
/**
* Optional operator-chosen default source for commands/REST/WebUI calls that
* omit a platform (issue #126). When set to an enabled gateable provider it
* overrides the fixed priority order in defaultPlatform(); `null` (the default)
* keeps that priority order. loadConfig cleans stale/unknown/disabled values
* back to null.
*/
defaultPlatform: GateableProvider | null;
}
export function getDefaultConfig(): BotConfig {
@@ -159,7 +197,17 @@ export function getDefaultConfig(): BotConfig {
apiKey: "",
userId: "",
},
enabledProviders: ["jellyfin"],
// Mirrors each provider's own in-memory default quality; overwritten on
// startup once the user has changed a quality (persisted via #125).
audioQuality: {
netease: "exhigh",
qq: "exhigh",
bilibili: "high",
kugou: "128",
jellyfin: "direct",
},
enabledProviders: ["netease", "qq", "bilibili", "youtube", "kugou"],
defaultPlatform: null,
};
}
@@ -306,14 +354,40 @@ 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),
)
: defaults.enabledProviders;
// defaultPlatform → an explicit operator default (issue #126). Keep it only
// when it names a KNOWN gateable provider that is ALSO currently enabled;
// anything else (unknown value, disabled source, wrong type, missing) becomes
// null so defaultPlatform() falls back to the fixed priority order.
const rawDefault = partial.defaultPlatform;
const defaultPlatformPref: GateableProvider | null =
typeof rawDefault === "string" &&
(GATEABLE_PROVIDERS as readonly string[]).includes(rawDefault) &&
enabledProviders.includes(rawDefault as GateableProvider)
? (rawDefault as GateableProvider)
: null;
// audioQuality → per-provider strings; each field falls back to its default
// when missing/blank/non-string (a hand-edited/legacy config must never smuggle
// a non-string past the gate — the value is fed straight to provider.setQuality).
const partialAq = (partial.audioQuality ?? {}) as Partial<AudioQualityConfig>;
const coerceQuality = (v: unknown, fallback: string): string =>
typeof v === "string" && v.trim() ? v : fallback;
const audioQuality: AudioQualityConfig = {
netease: coerceQuality(partialAq.netease, defaults.audioQuality.netease),
qq: coerceQuality(partialAq.qq, defaults.audioQuality.qq),
bilibili: coerceQuality(partialAq.bilibili, defaults.audioQuality.bilibili),
kugou: coerceQuality(partialAq.kugou, defaults.audioQuality.kugou),
jellyfin: coerceQuality(partialAq.jellyfin, defaults.audioQuality.jellyfin),
};
return {
...defaults,
...partial,
@@ -321,7 +395,9 @@ export function loadConfig(path: string): BotConfig {
guestMode: gm,
spotify,
jellyfin,
audioQuality,
enabledProviders,
defaultPlatform: defaultPlatformPref,
};
}
}
+86
View File
@@ -131,6 +131,92 @@ describe("database", () => {
expect(botDb.deleteBotInstance("nonexistent")).toBe(false);
});
it("persists and restores per-bot player settings (volume + play mode) (#125)", () => {
const inst = {
id: "bot-ps",
name: "B",
serverAddress: "x",
serverPort: 9987,
nickname: "n",
defaultChannel: "",
channelId: "",
channelPassword: "",
autoStart: false,
serverProtocol: "",
ts6ApiKey: "",
serverPassword: "",
};
botDb.saveBotInstance(inst);
// Fresh row → in-memory defaults.
expect(botDb.getPlayerSettings("bot-ps")).toEqual({ volume: 75, playMode: "seq" });
// Volume and play mode persist independently.
botDb.saveVolume("bot-ps", 42);
expect(botDb.getPlayerSettings("bot-ps")).toEqual({ volume: 42, playMode: "seq" });
botDb.savePlayMode("bot-ps", "rloop");
expect(botDb.getPlayerSettings("bot-ps")).toEqual({ volume: 42, playMode: "rloop" });
// A later saveBotInstance upsert (e.g. autoStart toggle) must NOT reset them.
botDb.saveBotInstance({ ...inst, autoStart: true });
expect(botDb.getPlayerSettings("bot-ps")).toEqual({ volume: 42, playMode: "rloop" });
});
it("defaults player settings for an unknown bot and validates inputs (#125)", () => {
// No row → defaults.
expect(botDb.getPlayerSettings("does-not-exist")).toEqual({ volume: 75, playMode: "seq" });
botDb.saveBotInstance({
id: "bot-v",
name: "B",
serverAddress: "x",
serverPort: 9987,
nickname: "n",
defaultChannel: "",
channelId: "",
channelPassword: "",
autoStart: false,
serverProtocol: "",
ts6ApiKey: "",
serverPassword: "",
});
// Out-of-range volume is clamped; an unknown play mode is ignored (not stored).
botDb.saveVolume("bot-v", 250);
expect(botDb.getPlayerSettings("bot-v").volume).toBe(100);
botDb.saveVolume("bot-v", -10);
expect(botDb.getPlayerSettings("bot-v").volume).toBe(0);
botDb.savePlayMode("bot-v", "bogus");
expect(botDb.getPlayerSettings("bot-v").playMode).toBe("seq");
});
it("migrates volume + play_mode columns onto a legacy bot_instances table (#125)", () => {
const dir = mkdtempSync(join(tmpdir(), "tsmb-mig-"));
const p = join(dir, "legacy.db");
// Build a minimal pre-#125 bot_instances table (no volume/play_mode columns).
const legacy = createDatabase(p);
legacy.db.exec("DROP TABLE bot_instances");
legacy.db.exec(`CREATE TABLE bot_instances (
id TEXT PRIMARY KEY, name TEXT NOT NULL, serverAddress TEXT NOT NULL,
serverPort INTEGER NOT NULL, nickname TEXT NOT NULL, defaultChannel TEXT NOT NULL,
channelId TEXT NOT NULL DEFAULT '', channelPassword TEXT NOT NULL,
autoStart INTEGER NOT NULL DEFAULT 0, serverProtocol TEXT NOT NULL DEFAULT '',
ts6ApiKey TEXT NOT NULL DEFAULT '', serverPassword TEXT NOT NULL DEFAULT '', identity TEXT
)`);
legacy.db
.prepare("INSERT INTO bot_instances (id, name, serverAddress, serverPort, nickname, defaultChannel, channelPassword) VALUES (?, 'B', 'x', 9987, 'n', '', '')")
.run("legacy-bot");
legacy.close();
// Reopen → migrateSchema adds the columns; the old row gets the defaults.
const reopened = createDatabase(p);
const cols = (reopened.db.prepare("PRAGMA table_info(bot_instances)").all() as Array<{ name: string }>).map((c) => c.name);
expect(cols).toContain("volume");
expect(cols).toContain("play_mode");
expect(reopened.getPlayerSettings("legacy-bot")).toEqual({ volume: 75, playMode: "seq" });
reopened.close();
rmSync(dir, { recursive: true, force: true });
});
it("persists and clears customAvatarPath on a bot instance", () => {
const inst = {
id: "bot-1",
+70
View File
@@ -55,6 +55,26 @@ export const DEFAULT_PROFILE_CONFIG: ProfileConfig = {
nowPlayingMsgEnabled: true,
};
/**
* Per-bot player settings persisted across restarts (#125): the playback volume
* and play mode. These reset to defaults on process restart when kept only in
* memory (AudioPlayer/PlayQueue), so they are stored on the bot_instances row —
* exactly like the per-bot profile flags — and restored when the bot is (re)built.
*/
export interface PlayerSettings {
/** 0-100. */
volume: number;
/** PlayMode string: "seq" | "loop" | "random" | "rloop". */
playMode: string;
}
const PLAY_MODES = new Set(["seq", "loop", "random", "rloop"]);
export const DEFAULT_PLAYER_SETTINGS: PlayerSettings = {
volume: 75,
playMode: "seq",
};
export interface FavoritePlaylist {
id: number;
userId: string;
@@ -75,6 +95,9 @@ export interface BotDatabase {
deleteBotInstance(id: string): boolean;
getProfileConfig(botId: string): ProfileConfig;
saveProfileConfig(botId: string, config: ProfileConfig): void;
getPlayerSettings(botId: string): PlayerSettings;
saveVolume(botId: string, volume: number): void;
savePlayMode(botId: string, playMode: string): void;
getCustomAvatarPath(botId: string): string | null;
setCustomAvatarPath(botId: string, path: string | null): void;
addFavorite(userId: string, playlist: { platform: string; playlistId: string; name: string; coverUrl: string; songCount: number }): void;
@@ -119,6 +142,15 @@ function migrateSchema(db: Database.Database): void {
if (!names.includes("custom_avatar_path")) {
db.exec("ALTER TABLE bot_instances ADD COLUMN custom_avatar_path TEXT");
}
// Per-bot persisted player settings (#125): volume + play mode. Defaults match
// AudioPlayer/PlayQueue's in-memory defaults so pre-existing rows keep behaving
// exactly as before until the user changes them.
if (!names.includes("volume")) {
db.exec("ALTER TABLE bot_instances ADD COLUMN volume INTEGER NOT NULL DEFAULT 75");
}
if (!names.includes("play_mode")) {
db.exec("ALTER TABLE bot_instances ADD COLUMN play_mode TEXT NOT NULL DEFAULT 'seq'");
}
const userColumns = db.prepare("PRAGMA table_info(users)").all() as Array<{ name: string }>;
const userColNames = userColumns.map((c) => c.name);
@@ -161,6 +193,8 @@ function initTables(db: Database.Database): void {
serverProtocol TEXT NOT NULL DEFAULT '',
ts6ApiKey TEXT NOT NULL DEFAULT '',
serverPassword TEXT NOT NULL DEFAULT '',
volume INTEGER NOT NULL DEFAULT 75,
play_mode TEXT NOT NULL DEFAULT 'seq',
identity TEXT
);
@@ -321,6 +355,12 @@ export function createDatabase(dbPath: string): BotDatabase {
WHERE id = @id
`);
const selectPlayerSettings = db.prepare(
`SELECT volume, play_mode FROM bot_instances WHERE id = ?`,
);
const updateVolume = db.prepare(`UPDATE bot_instances SET volume = ? WHERE id = ?`);
const updatePlayMode = db.prepare(`UPDATE bot_instances SET play_mode = ? WHERE id = ?`);
const selectCustomAvatar = db.prepare(`SELECT custom_avatar_path FROM bot_instances WHERE id = ?`);
const updateCustomAvatar = db.prepare(`UPDATE bot_instances SET custom_avatar_path = ? WHERE id = ?`);
@@ -406,6 +446,36 @@ export function createDatabase(dbPath: string): BotDatabase {
});
},
getPlayerSettings(botId) {
const row = selectPlayerSettings.get(botId) as
| { volume: number | null; play_mode: string | null }
| undefined;
if (!row) return { ...DEFAULT_PLAYER_SETTINGS };
// Coerce/validate: clamp volume to 0-100 and fall back to defaults for any
// NULL / out-of-range / unknown value (a hand-edited DB must never feed a
// bad value into AudioPlayer.setVolume / PlayQueue.setMode).
const rawVol = typeof row.volume === "number" ? row.volume : DEFAULT_PLAYER_SETTINGS.volume;
const volume = Number.isFinite(rawVol)
? Math.max(0, Math.min(100, Math.round(rawVol)))
: DEFAULT_PLAYER_SETTINGS.volume;
const playMode =
typeof row.play_mode === "string" && PLAY_MODES.has(row.play_mode)
? row.play_mode
: DEFAULT_PLAYER_SETTINGS.playMode;
return { volume, playMode };
},
saveVolume(botId, volume) {
const clamped = Math.max(0, Math.min(100, Math.round(volume)));
updateVolume.run(clamped, botId);
},
savePlayMode(botId, playMode) {
// Persist only recognized modes so a bad value can never poison the row.
if (!PLAY_MODES.has(playMode)) return;
updatePlayMode.run(playMode, botId);
},
getCustomAvatarPath(botId) {
const row = selectCustomAvatar.get(botId) as { custom_avatar_path: string | null } | undefined;
return row?.custom_avatar_path ?? null;
+11 -2
View File
@@ -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"),
},
@@ -100,6 +100,15 @@ async function main() {
if (jellyfinAuth) jellyfinProvider.setCookie(jellyfinAuth);
jellyfinProvider.setPersist((serialized) => cookieStore.save("jellyfin", serialized));
// Restore the persisted per-provider audio quality (#125) onto the shared,
// process-wide providers so a restart keeps the user's choice. setQuality()
// normalizes/ignores unknown values, so a stale entry can never break playback.
neteaseProvider.setQuality(config.audioQuality.netease);
qqProvider.setQuality(config.audioQuality.qq);
bilibiliProvider.setQuality(config.audioQuality.bilibili);
kugouProvider.setQuality(config.audioQuality.kugou);
jellyfinProvider.setQuality(config.audioQuality.jellyfin);
const permissions = createPermissionStore(db.db);
// Single process-wide Spotify authorization (one Premium account for Stage 3).
+105 -2
View File
@@ -1,5 +1,34 @@
import { describe, it, expect } from "vitest";
import { describeQqApiStartupError } from "./api-server.js";
import { describe, it, expect, vi, beforeEach } from "vitest";
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", () => {
it("flags ERR_REQUIRE_ESM by error code with version-pin guidance", () => {
@@ -28,3 +57,77 @@ describe("describeQqApiStartupError", () => {
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;
}
});
});
+38 -2
View File
@@ -114,7 +114,25 @@ export function createApiServerManager(
"QQ Music API port already in use — reusing existing instance"
);
} 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:
// 2.2.11+: default → Koa app (has .listen)
// 2.2.10: default → wrapper object whose .default is the Koa app
@@ -124,16 +142,34 @@ export function createApiServerManager(
? candidate
: candidate.default ?? null;
if (koaApp && typeof koaApp.listen === "function") {
// A version that auto-started on import has already bound the
// configured port (thanks to the PORT alignment above); reuse it
// 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"
);
} else {
qqMusicServer = await new Promise<Server>((resolve, reject) => {
const srv = koaApp.listen(options.qqMusicPort, "127.0.0.1", () =>
resolve(srv)
);
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: options.qqMusicPort },
{ port: boundPort },
"QQ Music API started"
);
}
} else {
logger.warn("QQ Music API module does not expose a Koa app");
}
+52 -1
View File
@@ -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 () => {
@@ -646,4 +647,54 @@ describe("bot router /settings jellyfin block + enabledProviders", () => {
// No jellyfin block in the request → no reconfigure call.
expect(configureCalls).toHaveLength(0);
});
// --- #126: operator-chosen default source ---
it("GET /settings exposes defaultPlatform (null by default)", async () => {
const res = await request(mountBot()).get("/api/bot/settings");
expect(res.status).toBe(200);
expect(res.body.defaultPlatform).toBeNull();
});
it("POST /settings sets an enabled defaultPlatform and persists it", async () => {
const res = await request(mountBot()).post("/api/bot/settings").send({
defaultPlatform: "bilibili",
});
expect(res.status).toBe(200);
expect(res.body.defaultPlatform).toBe("bilibili");
expect(config.defaultPlatform).toBe("bilibili");
const onDisk = JSON.parse(readFileSync(configPath, "utf-8"));
expect(onDisk.defaultPlatform).toBe("bilibili");
});
it("POST /settings ignores an unknown or disabled defaultPlatform", async () => {
const app = mountBot();
// jellyfin is opt-in and not enabled in the default config → rejected.
await request(app).post("/api/bot/settings").send({ defaultPlatform: "jellyfin" });
expect(config.defaultPlatform).toBeNull();
// Unknown value → rejected.
await request(app).post("/api/bot/settings").send({ defaultPlatform: "bogus" });
expect(config.defaultPlatform).toBeNull();
});
it("POST /settings clears defaultPlatform with null", async () => {
const app = mountBot();
await request(app).post("/api/bot/settings").send({ defaultPlatform: "qq" });
expect(config.defaultPlatform).toBe("qq");
const res = await request(app).post("/api/bot/settings").send({ defaultPlatform: null });
expect(res.body.defaultPlatform).toBeNull();
expect(config.defaultPlatform).toBeNull();
});
it("POST /settings drops a default whose source gets disabled in the same request", async () => {
const app = mountBot();
await request(app).post("/api/bot/settings").send({ defaultPlatform: "qq" });
expect(config.defaultPlatform).toBe("qq");
// Disabling qq via enabledProviders clears the now-invalid default.
const res = await request(app).post("/api/bot/settings").send({
enabledProviders: ["netease", "bilibili"],
});
expect(res.body.defaultPlatform).toBeNull();
expect(config.defaultPlatform).toBeNull();
});
});
+25
View File
@@ -77,6 +77,7 @@ export function createBotRouter(
spotify: maskedSpotify(),
jellyfin: maskedJellyfin(),
enabledProviders: config.enabledProviders,
defaultPlatform: config.defaultPlatform,
});
});
@@ -175,6 +176,29 @@ export function createBotRouter(
);
}
// defaultPlatform (issue #126): the operator-chosen default source for
// platform-less commands/REST/WebUI calls. Reconciled AFTER enabledProviders
// so both are validated against the same (possibly updated) enabled list:
// 1) Drop a stored default that the new enabledProviders no longer allows,
// keeping the persisted config consistent with loadConfig's invariant.
// 2) Apply an explicit change — `null`/`""` clears it (back to priority
// order); a known+enabled provider sets it; anything else is ignored.
if (config.defaultPlatform && !config.enabledProviders.includes(config.defaultPlatform)) {
config.defaultPlatform = null;
}
if ("defaultPlatform" in req.body) {
const dp = req.body.defaultPlatform;
if (dp === null || dp === "") {
config.defaultPlatform = null;
} else if (
typeof dp === "string" &&
(GATEABLE_PROVIDERS as readonly string[]).includes(dp) &&
config.enabledProviders.includes(dp as GateableProvider)
) {
config.defaultPlatform = dp as GateableProvider;
}
}
saveConfig(configPath, config);
// Hot-apply the (possibly re-pointed) Jellyfin connection to the live
@@ -219,6 +243,7 @@ export function createBotRouter(
spotify: maskedSpotify(),
jellyfin: maskedJellyfin(),
enabledProviders: config.enabledProviders,
defaultPlatform: config.defaultPlatform,
});
});
+176 -23
View File
@@ -1,9 +1,19 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import express from "express";
import cookieParser from "cookie-parser";
import request from "supertest";
import pino from "pino";
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { MusicProvider, SearchResult } from "../../music/provider.js";
import { getDefaultConfig, type BotConfig } from "../../data/config.js";
import { getDefaultConfig, loadConfig, type BotConfig } from "../../data/config.js";
import { createDatabase, type BotDatabase } from "../../data/database.js";
import { createUserStore } from "../../data/users.js";
import { createSessionStore } from "../../data/sessions.js";
import { createPermissionStore } from "../../data/permissions.js";
import { createRequireAuth } from "../middleware/requireAuth.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
import { createMusicRouter } from "./music.js";
const empty: SearchResult = { songs: [], albums: [], playlists: [] };
@@ -82,44 +92,70 @@ 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
});
it("GET /providers reports a configured defaultPlatform override (#126)", async () => {
const config = getDefaultConfig();
config.defaultPlatform = "qq"; // operator prefers QQ over the priority order
const { app } = mount(config);
const res = await request(app).get("/api/music/providers");
expect(res.status).toBe(200);
expect(res.body.default).toBe("qq");
});
it("routes a platform-less /search to the configured defaultPlatform (#126)", async () => {
const config = getDefaultConfig();
config.defaultPlatform = "bilibili";
const { app, netease } = mount(config);
const res = await request(app).get("/api/music/search?q=hello");
expect(res.status).toBe(200);
// Default is now bilibili, so the netease provider must NOT be hit.
expect(netease.search).not.toHaveBeenCalled();
});
/** 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,17 +164,134 @@ 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);
});
});
describe("music router POST /quality — persistence (#125)", () => {
let tmpDir: string;
let configPath: string;
let config: BotConfig;
let botDb: BotDatabase;
let app: express.Express;
let cookie: string;
let providers: Record<string, MusicProvider>;
/** A provider whose in-memory quality is settable and readable, like the real
* ones. */
function qualityProvider(platform: MusicProvider["platform"], initial: string): MusicProvider {
let q = initial;
return {
platform,
search: vi.fn().mockResolvedValue(empty),
getQuality: vi.fn(() => q),
setQuality: vi.fn((v: string) => { q = v; }),
} as unknown as MusicProvider;
}
/** Jellyfin only accepts its own tiers (mirrors the real provider), so a
* broadcast of a foreign value is ignored — proving the snapshot captures each
* provider's ACTUAL post-apply state, not just the request value. */
function jellyfinQualityProvider(): MusicProvider {
let q = "direct";
const tiers = new Set(["direct", "320", "192", "128"]);
return {
platform: "jellyfin",
search: vi.fn().mockResolvedValue(empty),
getQuality: vi.fn(() => q),
setQuality: vi.fn((v: string) => { if (tiers.has(v)) q = v; }),
} as unknown as MusicProvider;
}
beforeEach(async () => {
botDb = createDatabase(":memory:");
const users = createUserStore(botDb.db);
const sessions = createSessionStore(botDb.db);
const admin = await users.createUser("admin", "pw-admin", "admin");
cookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(admin.id).token}`;
tmpDir = mkdtempSync(join(tmpdir(), "musicquality-"));
configPath = join(tmpDir, "config.json");
config = getDefaultConfig();
providers = {
netease: qualityProvider("netease", "exhigh"),
qq: qualityProvider("qq", "exhigh"),
bilibili: qualityProvider("bilibili", "high"),
kugou: qualityProvider("kugou", "128"),
jellyfin: jellyfinQualityProvider(),
};
app = express();
app.use(express.json());
app.use(cookieParser());
app.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db), () => getDefaultConfig().guestMode));
app.use(
"/api/music",
createMusicRouter(
providers.netease, providers.qq, providers.bilibili, pino({ level: "silent" }),
undefined, config, providers.kugou, undefined, providers.jellyfin, configPath,
),
);
});
afterEach(() => {
botDb.close();
rmSync(tmpDir, { recursive: true, force: true });
});
it("persists a platform-specific quality change to config.json", async () => {
const res = await request(app)
.post("/api/music/quality")
.set("Cookie", cookie)
.send({ platform: "netease", quality: "lossless" });
expect(res.status).toBe(200);
expect(providers.netease.setQuality).toHaveBeenCalledWith("lossless");
// in-memory config mutated
expect(config.audioQuality.netease).toBe("lossless");
// written to disk + reload reflects it (survives a restart)
const onDisk = JSON.parse(readFileSync(configPath, "utf-8"));
expect(onDisk.audioQuality.netease).toBe("lossless");
expect(loadConfig(configPath).audioQuality.netease).toBe("lossless");
});
it("snapshots each provider's post-apply quality on a broadcast change", async () => {
const res = await request(app)
.post("/api/music/quality")
.set("Cookie", cookie)
.send({ quality: "320" });
expect(res.status).toBe(200);
// Broadcast reached every provider…
expect(providers.netease.setQuality).toHaveBeenCalledWith("320");
expect(providers.jellyfin.setQuality).toHaveBeenCalledWith("320");
// …and the snapshot reflects what each one actually accepted. Jellyfin's
// "320" is a valid tier here, so it takes; a foreign value would be ignored.
expect(config.audioQuality).toEqual({
netease: "320",
qq: "320",
bilibili: "320",
kugou: "320",
jellyfin: "320",
});
});
it("ignores foreign broadcast values that a provider rejects (jellyfin)", async () => {
const res = await request(app)
.post("/api/music/quality")
.set("Cookie", cookie)
.send({ quality: "lossless" });
expect(res.status).toBe(200);
// jellyfin rejects the NetEase-style value → stays at its default tier.
expect(config.audioQuality.jellyfin).toBe("direct");
expect(config.audioQuality.netease).toBe("lossless");
});
});
+31 -6
View File
@@ -2,7 +2,7 @@ import express, { Router, type Response } from "express";
import type { MusicProvider, Song, Album } from "../../music/provider.js";
import { YouTubeProvider } from "../../music/youtube.js";
import type { Logger } from "../../logger.js";
import { isProviderEnabled, defaultPlatform, type BotConfig } from "../../data/config.js";
import { isProviderEnabled, defaultPlatform, saveConfig, type BotConfig } from "../../data/config.js";
import { requirePermission } from "../middleware/requirePermission.js";
import { requireNotGuest } from "../middleware/requireNotGuest.js";
import { authorize } from "../middleware/authorize.js";
@@ -16,7 +16,10 @@ export function createMusicRouter(
config?: BotConfig,
kugouProvider?: MusicProvider,
spotifyProvider?: MusicProvider,
jellyfinProvider?: MusicProvider
jellyfinProvider?: MusicProvider,
// When set (alongside config), a quality change is persisted to config.json so
// it survives a restart (#125). Omitted by unit-test routers → no persistence.
configPath?: string,
): Router {
const router = Router();
const youtubeProvider: MusicProvider = new YouTubeProvider();
@@ -37,9 +40,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 +148,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([
@@ -478,6 +483,26 @@ export function createMusicRouter(
if ((!platform || platform === "jellyfin") && jellyfinProvider) {
jellyfinProvider.setQuality(quality);
}
// Persist the (post-apply) per-provider quality so it survives a restart
// (#125). Snapshotting each provider's getQuality() AFTER setQuality captures
// exactly what each one accepted (jellyfin ignores foreign tiers, kugou maps
// aliases), so replaying these on startup reproduces this state faithfully.
if (config && configPath) {
config.audioQuality = {
netease: neteaseProvider.getQuality(),
qq: qqProvider.getQuality(),
bilibili: bilibiliProvider.getQuality(),
kugou: kugouProvider?.getQuality() ?? config.audioQuality.kugou,
jellyfin: jellyfinProvider?.getQuality() ?? config.audioQuality.jellyfin,
};
try {
saveConfig(configPath, config);
} catch (err) {
logger.warn({ err }, "Failed to persist audio quality");
}
}
logger.info({ quality, platform }, "Audio quality changed");
res.json({ success: true, quality });
});
+86
View File
@@ -0,0 +1,86 @@
import { describe, it, expect } from "vitest";
import express from "express";
import request from "supertest";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
/**
* Search-engine hardening for issue #128: searching "TsmusicBot" surfaced a
* large number of deployed instances' WebUI URLs, letting strangers walk into
* other people's control pages. The fix is defence in depth — none of these
* layers is authentication (that's handled elsewhere), they just keep the
* public URL out of crawler indexes:
*
* 1. `X-Robots-Tag: noindex, nofollow` on EVERY response;
* 2. `GET /robots.txt` → `User-agent: * / Disallow: /`;
* 3. `<meta name="robots" content="noindex, nofollow">` in web/index.html.
*
* The header middleware and the /robots.txt route both live at the top of
* `createWebServer` in `server.ts`; this test asserts the exact behaviour we
* expect from them in isolation (the wiring inside server.ts is verified by
* code review / git diff, matching security-headers.test.ts).
*/
describe("search-engine hardening (issue #128 noindex)", () => {
function buildApp() {
const app = express();
// Mirrors the security-headers middleware in server.ts.
app.use((_req, res, next) => {
res.setHeader("X-Frame-Options", "DENY");
res.setHeader("Content-Security-Policy", "frame-ancestors 'none'");
res.setHeader("X-Robots-Tag", "noindex, nofollow");
next();
});
// Mirrors the public /robots.txt route in server.ts.
app.get("/robots.txt", (_req, res) => {
res.type("text/plain").send("User-agent: *\nDisallow: /\n");
});
app.get("/", (_req, res) => res.json({ ok: true }));
app.post("/api/session/login", (_req, res) => res.json({ ok: true }));
return app;
}
it("sets X-Robots-Tag: noindex, nofollow on GET responses", async () => {
const res = await request(buildApp()).get("/");
expect(res.status).toBe(200);
expect(res.headers["x-robots-tag"]).toBe("noindex, nofollow");
});
it("sets X-Robots-Tag on POST (API) responses too", async () => {
const res = await request(buildApp()).post("/api/session/login");
expect(res.headers["x-robots-tag"]).toBe("noindex, nofollow");
});
it("serves /robots.txt disallowing all crawlers", async () => {
const res = await request(buildApp()).get("/robots.txt");
expect(res.status).toBe(200);
expect(res.headers["content-type"]).toMatch(/text\/plain/);
expect(res.text).toContain("User-agent: *");
expect(res.text).toContain("Disallow: /");
});
it("still tags the /robots.txt response itself as noindex", async () => {
const res = await request(buildApp()).get("/robots.txt");
expect(res.headers["x-robots-tag"]).toBe("noindex, nofollow");
});
});
describe("frontend robots meta tag (issue #128 noindex)", () => {
const indexHtmlPath = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../web/index.html"
);
const html = fs.readFileSync(indexHtmlPath, "utf-8");
const robotsMeta = html.match(
/<meta\s+name=["']robots["']\s+content=["']([^"']+)["']\s*\/?>/i
);
it("declares a robots meta tag", () => {
expect(robotsMeta).not.toBeNull();
});
it("marks the SPA shell noindex, nofollow (covers /bot/<id> dedicated links)", () => {
expect(robotsMeta?.[1]).toBe("noindex, nofollow");
});
});
+18 -4
View File
@@ -77,12 +77,19 @@ export function createWebServer(options: WebServerOptions): WebServer {
app.set("trust proxy", true);
}
// Security headers: prevent the WebUI from being embedded in a third-party
// iframe (clickjacking defence). CSP frame-ancestors is the modern equivalent
// of X-Frame-Options; both are set for compatibility across browsers.
// Security headers:
// • X-Frame-Options / CSP frame-ancestors — prevent the WebUI from being
// embedded in a third-party iframe (clickjacking defence). CSP
// frame-ancestors is the modern equivalent of X-Frame-Options; both are
// set for compatibility across browsers.
// • X-Robots-Tag — keep deployed instances out of search-engine indexes
// (issue #128: searching "TsmusicBot" surfaced strangers' WebUI URLs).
// Set on EVERY response so JSON/API responses and the SPA shell are all
// covered; complements /robots.txt and the <meta name="robots"> tag.
app.use((_req, res, next) => {
res.setHeader("X-Frame-Options", "DENY");
res.setHeader("Content-Security-Policy", "frame-ancestors 'none'");
res.setHeader("X-Robots-Tag", "noindex, nofollow");
next();
});
@@ -95,6 +102,13 @@ export function createWebServer(options: WebServerOptions): WebServer {
const permissions = createPermissionStore(options.database.db);
// ─── Public routes (no auth, no CSRF) ───────────────────────────────────
// Disallow every crawler (issue #128). Declared before the static SPA
// fallback so this wins over index.html for /robots.txt. Belt-and-braces
// with the X-Robots-Tag header above and the <meta name="robots"> tag.
app.get("/robots.txt", (_req, res) => {
res.type("text/plain").send("User-agent: *\nDisallow: /\n");
});
app.get("/api/health", (_req, res) => {
res.json({ status: "ok", version: "0.1.0" });
});
@@ -151,7 +165,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
);
app.use(
"/api/music",
createMusicRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.localProvider, options.config, options.kugouProvider, options.spotifyProvider, options.jellyfinProvider)
createMusicRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.localProvider, options.config, options.kugouProvider, options.spotifyProvider, options.jellyfinProvider, options.configPath)
);
app.use("/api/player", createPlayerRouter(
options.botManager, logger, options.database,
+6
View File
@@ -3,6 +3,12 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Keep deployed instances out of search-engine indexes (issue #128:
searching "TsmusicBot" surfaced strangers' WebUI URLs). Defence in depth
alongside the server's X-Robots-Tag header and /robots.txt. Applies to
the SPA shell and every in-app route (incl. /bot/<id> dedicated links),
since they all share this single index.html. -->
<meta name="robots" content="noindex, nofollow">
<!-- Bilibili / NetEase / QQ image CDNs reject requests whose Referer is not on
their whitelist, so we must not leak a Referer cross-origin. "same-origin"
does exactly that: full Referer for our own requests, none for cross-origin
+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
// 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');
+3 -3
View File
@@ -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';
}
// ---- 分页 / 加载更多 ----
+128 -5
View File
@@ -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" />
@@ -543,6 +555,36 @@
</div>
</section>
<!-- Default music source (issue #126): the source used by chat commands
(!play/!add …) and the WebUI when no platform flag is given. Saving is a
global bot setting, so gate on bot.manage like the other behavior rows. -->
<section v-if="can('bot.manage')" class="settings-section">
<h2 class="section-title">默认音源</h2>
<p class="profile-section-hint">
设置不带音源参数时(如聊天里的 <code>!play 歌名</code> 或网页搜索)默认使用的音源。
例如把默认音源设为「哔哩哔哩」后,点播 B 站音乐就不用每次都加 <code>-b</code>。
选择「自动」则按内置优先级挑选第一个已启用的音源(网易云 → QQ → 酷狗 → Jellyfin → 哔哩哔哩 → YouTube)。
</p>
<div class="setting-row">
<div class="setting-label">
<Icon icon="mdi:music-box-multiple-outline" class="setting-icon" />
默认音源
</div>
<div class="prefix-input-wrap">
<select v-model="defaultPlatformForm" class="input input-sm default-source-select">
<option value="">自动(按优先级)</option>
<option v-for="opt in defaultSourceOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<button class="btn-primary" :disabled="defaultSourceSaving" @click="saveDefaultSource">
{{ defaultSourceSaving ? '保存中…' : '保存' }}
</button>
</div>
</div>
<p v-if="defaultSourceMessage" class="spotify-message" :class="`tone-${defaultSourceMessageTone}`">{{ defaultSourceMessage }}</p>
</section>
<!-- Spotify (Connect) playback via librespot — requires platform.auth -->
<section v-if="can('platform.auth')" class="settings-section">
<h2 class="section-title">Spotify 播放(实验性)</h2>
@@ -1149,6 +1191,51 @@ function providerOn(p: string): boolean {
return enabledProviders.value.length === 0 || enabledProviders.value.includes(p);
}
// --- Default music source (issue #126) ---
// Chinese labels for the gateable providers, shown in the default-source select.
const PROVIDER_LABELS: Record<string, string> = {
netease: '网易云音乐',
qq: 'QQ音乐',
kugou: '酷狗音乐',
bilibili: '哔哩哔哩',
youtube: 'YouTube',
jellyfin: 'Jellyfin',
};
// Empty string = "auto" (follow the fixed priority order); persisted as null.
const defaultPlatformForm = ref('');
const defaultSourceSaving = ref(false);
const defaultSourceMessage = ref('');
const defaultSourceMessageTone = ref<'ok' | 'warn'>('ok');
// Only currently-enabled sources can be picked as the default.
const defaultSourceOptions = computed(() =>
enabledProviders.value
.filter((p) => p in PROVIDER_LABELS)
.map((p) => ({ value: p, label: PROVIDER_LABELS[p] })),
);
async function saveDefaultSource() {
defaultSourceSaving.value = true;
defaultSourceMessage.value = '';
try {
const res = await axios.post('/api/bot/settings', {
defaultPlatform: defaultPlatformForm.value || null,
});
defaultPlatformForm.value = res.data?.defaultPlatform ?? '';
// Push the new default across the app immediately (search bar / play calls
// read store.defaultSource, refreshed via GET /api/music/providers).
await store.fetchProviders();
defaultSourceMessageTone.value = 'ok';
defaultSourceMessage.value = '已保存';
} catch (err: any) {
defaultSourceMessageTone.value = 'warn';
defaultSourceMessage.value = err?.response?.status === 403
? '没有权限修改设置(需要 bot.manage)'
: '保存失败,请稍后重试';
} finally {
defaultSourceSaving.value = false;
}
}
// --- Jellyfin connection (admin-configured; password/apiKey are write-only) ---
const jellyfinForm = reactive({
serverUrl: '',
@@ -1164,6 +1251,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 +1276,27 @@ 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();
}
// Disabling a source can clear a default that pointed at it (backend
// reconciles enabledProviders → defaultPlatform); keep the select in sync.
if (res.data && 'defaultPlatform' in res.data) {
defaultPlatformForm.value = res.data.defaultPlatform ?? '';
}
jellyfinMessageTone.value = 'ok';
jellyfinMessage.value = '已保存';
await checkAuthStatus();
@@ -1471,7 +1583,11 @@ 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;
}
// null (unset) → "" so the select shows "自动(按优先级)".
defaultPlatformForm.value = res.data.defaultPlatform ?? '';
} catch { /* ignore */ }
}
@@ -2414,6 +2530,13 @@ onUnmounted(() => {
.input-sm { max-width: 80px; }
// The default-source picker holds full source names ("网易云音乐"), so it needs
// more room than the 80px .input-sm cap.
.default-source-select {
max-width: none;
flex: 0 0 160px;
}
.textarea {
width: 100%;
padding: 10px 14px;
+16 -4
View File
@@ -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;