Compare commits

...
Author SHA1 Message Date
saopig1 c8dacebc45 Merge remote-tracking branch 'origin/main' into feat/issue-119-saved-playlists
# Conflicts:
#	src/data/config.ts
2026-07-17 11:09:04 +08:00
TIANYAO ZHANG 880a9c084e Merge pull request #131 from ZHANGTIANYAO1/feat/issue-126-default-source
feat: add configurable default music source (#126)
2026-07-17 11:05:16 +08:00
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 0c7eb677b8 docs(readme): document save/load queues + restart resume + playKeepsQueue
- Commands table: !save / !load [-a] / !queues (with the feature-disabled note).
- Features list + WebUI pages + 行为设置 mention the two toggles and 已存队列 page.
- Changelog entry with the honest caveats: restart resumes the current track
  from its start (no seek memory); Spotify auto-resume is best-effort.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 01:29:06 +08:00
saopig1andClaude Fable 5 0b10f9553c feat(#119): saved queues web UI + behavior-settings toggles
- Settings → 行为设置: two new toggles (保存/加载播放清单, 单曲直接播放不清空队列)
  that round-trip savedQueuesEnabled / playKeepsQueue and keep the nav gate in sync.
- New "已存队列" page (/saved-queues): save the current queue (with a 共享 option),
  load (replace) / append / delete saved queues; renders a "feature disabled"
  state on 403 so it degrades gracefully when the flag is off.
- Nav entry gated on the savedQueuesEnabled store flag (hidden for guests).
- useSavedQueues API composable + a pure, unit-tested list/ownership helper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 01:29:00 +08:00
saopig1andClaude Fable 5 69a2e8c264 feat(#119): save/load queues + live-queue persistence + playKeepsQueue (backend)
Add three default-off capabilities that stop the play queue from being lost,
all gated behind admin/independent toggles so existing behavior is unchanged
until an operator opts in:

- Named save/load of queues (Feature 1): new saved_queues table (per-user +
  reserved __shared__ owner, capped at 50 queues / 1000 songs, JSON song blob
  that degrades to empty on corruption); /api/saved-queues router (list/save/
  load/delete with ownership 404s, inert 403 when disabled); chat commands
  !save / !load [-a] / !queues; BotInstance.loadSavedQueue (replace/append).
- Auto-restore live queue across restart (Feature 2): PlayQueue.snapshot/restore,
  queue_state table (one row per bot), a debounced snapshot writer driven off
  stateChange, and restore+resume on connect. Cancels the pending snapshot on
  disconnect so a stale write can't wipe the row a restart must restore.
- playKeepsQueue (Feature 3): BotInstance.playSingleSong funnels chat !play and
  the web /play-song route through one place; when enabled a single-song play
  inserts-after-current and jumps instead of clearing the queue.

Config gains savedQueuesEnabled + playKeepsQueue (both default false, strict-
coerced on load like spotify.enabled); the settings API round-trips them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 01:28:48 +08:00
saopig1andClaude Opus 4.8 b8ec50c7f7 docs(plan): implementation plan for save/load playlists + queue persistence (#119)
13-task TDD plan across 5 stages: config gates, playKeepsQueue seam,
named save/load (DB + API + chat + web), live-queue snapshot/restore,
and docs. Each task independently testable; all behaviors default off.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 00:58:19 +08:00
saopig1andClaude Opus 4.8 f74b55ddbc docs(spec): design for save/load playlists + queue persistence (#119)
Design doc for issue #119: named per-user/shared save/load (chat + web),
auto-restore-and-resume of the live queue across restart (both behind an
admin-controlled savedQueuesEnabled flag, default off), and an independent
playKeepsQueue toggle so single-song !play inserts-and-plays instead of
clearing the queue. All toggles default off — no behavior change until opted in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 00:58:19 +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
TIANYAO ZHANG 04ceac5a97 Merge pull request #123 from ItsEricRao/main
Jellyfin Integration. Assisted by Claude Fable 5.
2026-07-14 00:18:29 +08:00
TIANYAO ZHANG ada292574f Merge pull request #124 from Slldyd2077/fix/qq-song-detail-empty-name
fix(qq): 按 ID 播放时回填歌曲元数据,修复 TS 显示空歌名
2026-07-14 00:17:08 +08:00
Claude CodeandClaude Opus 4.8 15190413b2 fix(qq): 按 ID 播放时回填歌曲元数据,修复 TS 显示空歌名
用 id: / play-by-id 播放 QQ 音乐时,getSongDetail 依赖的 /getSongInfo
端点已被上游废弃(返回 code 500001),会 fall through 到一个 name 为空
的兜底 stub。空歌名一路传到 currentSong,导致 TS 昵称 / 正在播放消息
显示成「♪ 正在播放:  -  []」。

修复:在返回空 stub 前新增 fetchSongDetailViaMusicu(),改用与搜索同源
且可用的 u.y.qq.com/cgi-bin/musicu.fcg(music.pf_song_detail_svr /
get_song_detail_yqq)按 mid 拉取真实 track_info(歌名/歌手/专辑/时长/
封面),无需登录;仅当它也失败时才退回空 stub。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 10:35:54 +08:00
itsericrao f637ba0191 Jellyfin Integration. Assisted by Claude Fable 5. 2026-07-06 23:07:20 +08:00
b9767a7471 Merge PR #121: show requester names in play history
Merges feature/play-history-requester (@Fa1nttt) into main.

The PR records the WebUI/TeamSpeak requester on queued songs and persists it
to play history (schema migration for requestedBy), rendering it as a badge in
SongCard (gray for 游客/guest).

Conflicts (frontend platform union) resolved to keep both 'spotify' (from #118)
and the new requestedBy/playedAt fields.

Integration fix: the Spotify playback branch in resolveAndPlay (added by #118,
which did not exist on the PR's base) also records play history — added
`requestedBy: song.requestedBy` there so Spotify tracks carry attribution too,
matching the non-Spotify path.

Verified on the merged tree: tsc --noEmit clean, full suite 1309/1309, web build clean.

Co-Authored-By: Fa1nttt <noreply@github.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 23:17:16 +08:00
saopig1andClaude Opus 4.8 47b29358ae docs(readme): add Spotify badge to the badge bar, marked 可选 like YouTube
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 23:08:16 +08:00
Fa1nttt cb66d77e9c feat: show requester names in play history 2026-07-04 22:32:21 +08:00
saopig1andClaude Opus 4.8 9f5e27a277 docs(readme): document Spotify source, search pagination, and full !lyrics
Reflect the two merged PRs in the README:
- Multi-source bullet + changelog: Spotify (#112, experimental/opt-in) and
  per-source search pagination "加载更多" (#115).
- !lyrics command now shows full lyrics chunked into multiple messages (#116).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 15:18:34 +08:00
saopig1 486c3a0a69 Merge PR #120: full !lyrics output (#116) + web search pagination (#115)
# Conflicts:
#	src/bot/instance.test.ts
2026-07-04 15:12:26 +08:00
saopig1 8359c56dc7 Merge PR #118: optional Spotify audio source (hybrid go-librespot/Rust librespot) (#112) 2026-07-04 15:08:50 +08:00
saopig1andClaude Opus 4.8 a97e72ef30 feat(search): per-source load-more pagination in Search.vue (#115 frontend)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 14:42:07 +08:00
saopig1andClaude Opus 4.8 a1cc0b8574 feat(search): server-side offset pagination through providers + /search route (#115 backend)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 14:36:54 +08:00
saopig1andClaude Opus 4.8 81b8953d52 fix(lyrics): send full lyrics chunked under TeamSpeak message cap (#116)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 14:26:51 +08:00
67 changed files with 8420 additions and 311 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 /)"
]
}
}
+2
View File
@@ -7,5 +7,7 @@ config.json
cookies/ cookies/
.superpowers/ .superpowers/
.worktrees/ .worktrees/
.claude/
setup.log
/bin/ /bin/
scripts/navbar_bigger.png scripts/navbar_bigger.png
+155 -18
View File
@@ -5,7 +5,7 @@
<h1 align="center">TSMusicBot</h1> <h1 align="center">TSMusicBot</h1>
<p align="center"> <p align="center">
<strong>TeamSpeak 音乐机器人</strong> — 网易云音乐 + QQ 音乐 + 酷狗音乐 + 哔哩哔哩 + YouTube(可选),YesPlayMusic 风格 WebUI 控制面板 <strong>TeamSpeak 音乐机器人</strong> — 网易云音乐 + QQ 音乐 + 酷狗音乐 + 哔哩哔哩 + YouTube(可选),Jellyfin / Spotify 可选启用,YesPlayMusic 风格 WebUI 控制面板
</p> </p>
<p align="center"> <p align="center">
@@ -17,20 +17,26 @@
<img src="https://img.shields.io/badge/Docker-支持-2496ED?logo=docker&logoColor=white" /> <img src="https://img.shields.io/badge/Docker-支持-2496ED?logo=docker&logoColor=white" />
<img src="https://img.shields.io/badge/酷狗音乐-支持-2ca2f9" /> <img src="https://img.shields.io/badge/酷狗音乐-支持-2ca2f9" />
<img src="https://img.shields.io/badge/BiliBili-支持-00a1d6?logo=bilibili&logoColor=white" /> <img src="https://img.shields.io/badge/BiliBili-支持-00a1d6?logo=bilibili&logoColor=white" />
<img src="https://img.shields.io/badge/Jellyfin-可选-aa5cc3?logo=jellyfin&logoColor=white" />
<img src="https://img.shields.io/badge/YouTube-可选-FF0000?logo=youtube&logoColor=white" /> <img src="https://img.shields.io/badge/YouTube-可选-FF0000?logo=youtube&logoColor=white" />
<img src="https://img.shields.io/badge/Spotify-可选-1DB954?logo=spotify&logoColor=white" />
<img src="https://img.shields.io/badge/TS3-支持-2580C3?logo=teamspeak&logoColor=white" /> <img src="https://img.shields.io/badge/TS3-支持-2580C3?logo=teamspeak&logoColor=white" />
<img src="https://img.shields.io/badge/TS6-支持-2580C3?logo=teamspeak&logoColor=white" /> <img src="https://img.shields.io/badge/TS6-支持-2580C3?logo=teamspeak&logoColor=white" />
</p> </p>
> v1.10.0 新增**可选**的 [Jellyfin](https://jellyfin.org/) 音源(由 [@ItsEricRao](https://github.com/ItsEricRao) 在 [PR #123](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/123) 中贡献):连接自建 Jellyfin 服务器直接播放你自己的音乐库。默认关闭,在 **设置 → Jellyfin 音乐库** 一键开启;原有在线音源保持默认启用,行为不变。
## 功能特性 ## 功能特性
- **WebUI 鉴权与细粒度权限(必选)** — 用户名 + 密码登录,多用户、两种角色(管理员 / 成员);成员可进一步配置**细粒度能力**(播放控制 / 队列管理 / 机器人管理 / 平台登录 / 音质)和**按机器人授权白名单**,所有变更操作由后端逐请求强制校验。bcrypt 加密、HttpOnly 会话 Cookie,CSRF 防护,WebSocket 同样鉴权。首次访问引导创建管理员。从无鉴权旧版本升级时请参阅 [更新升级](#更新升级) 章节 - **WebUI 鉴权与细粒度权限(必选)** — 用户名 + 密码登录,多用户、两种角色(管理员 / 成员);成员可进一步配置**细粒度能力**(播放控制 / 队列管理 / 机器人管理 / 平台登录 / 音质)和**按机器人授权白名单**,所有变更操作由后端逐请求强制校验。bcrypt 加密、HttpOnly 会话 Cookie,CSRF 防护,WebSocket 同样鉴权。首次访问引导创建管理员。从无鉴权旧版本升级时请参阅 [更新升级](#更新升级) 章节
- **游客模式(免登录点歌,默认关闭)** — 管理员可选择允许访客**无需账号密码**进入 WebUI 点歌,并逐项配置游客权限(8 个开关,默认仅「添加到队列末尾」开启)与可控机器人白名单;游客无法查看 / 修改任何设置、管理机器人或访问用户管理。开启后登录页出现 **「以游客身份进入」**。详见下文 **「游客模式 / Guest mode」** 小节 - **游客模式(免登录点歌,默认关闭)** — 管理员可选择允许访客**无需账号密码**进入 WebUI 点歌,并逐项配置游客权限(8 个开关,默认仅「添加到队列末尾」开启)与可控机器人白名单;游客无法查看 / 修改任何设置、管理机器人或访问用户管理。开启后登录页出现 **「以游客身份进入」**。详见下文 **「游客模式 / Guest mode」** 小节
- **本地收藏歌单** — 在首页 / 搜索 / 歌单页一键收藏,收藏内容按用户存储,登录后跨设备同步 - **本地收藏歌单** — 在首页 / 搜索 / 歌单页一键收藏,收藏内容按用户存储,登录后跨设备同步
- **保存/加载播放清单 + 重启后自动恢复队列(可选,默认关闭)** — 管理员在 设置 → 行为设置 开启后,可在网页「已存队列」页或聊天命令(`!save` / `!load` / `!queues`)把当前队列保存为清单,随时**替换**加载或**追加**到队列末尾;同时机器人重启后会自动恢复并继续播放上次的队列。网页保存可选「共享」,聊天保存进入共享清单。**说明**:重启只能从当前曲目的开头恢复(不记忆播放进度);Spotify 自动恢复为尽力而为(依赖 sidecar 可用)。详见 [使用说明](#使用说明)
- **本地音频上传播放** — 在搜索页拖拽或选择本地音频上传,上传后可直接播放 / 下一首播放 / 加入队列;管理员可在 设置 → 行为设置 开关此功能,播放结束或停止/清空/替换队列时会清理服务端接收的本地文件 - **本地音频上传播放** — 在搜索页拖拽或选择本地音频上传,上传后可直接播放 / 下一首播放 / 加入队列;管理员可在 设置 → 行为设置 开关此功能,播放结束或停止/清空/替换队列时会清理服务端接收的本地文件
- **专属链接(单机器人锁定)** — 通过 `/bot/<id>` 专属链接打开 WebUI 时锁定到单个机器人,刷新后保持,适合把某台机器人的控制页分享给特定用户 - **专属链接(单机器人锁定)** — 通过 `/bot/<id>` 专属链接打开 WebUI 时锁定到单个机器人,刷新后保持,适合把某台机器人的控制页分享给特定用户
- **频道无人时自动暂停** — 机器人所在频道没有其他人时自动暂停播放,有人加入后自动恢复(**默认关闭**,可在设置中开启) - **频道无人时自动暂停** — 机器人所在频道没有其他人时自动暂停播放,有人加入后自动恢复(**默认关闭**,可在设置中开启)
- **多平台音源** — 网易云音乐 + QQ 音乐 + 酷狗音乐 + 哔哩哔哩(默认内置),YouTube 可选启用(通过 yt-dlp),**Spotify(实验性)** 可选启用(需 Premium + 自建开发者应用,默认关闭,详见 [Spotify 音源(实验性)](#spotify-音源实验性)),统一搜索,结果标注来源 - **Jellyfin 音源(可选)** — 连接自建 [Jellyfin](https://jellyfin.org/) 服务器作为额外音源:搜索(歌曲 / 专辑 / 歌单)、懒解析直传播放、同步歌词、收藏 Instant Mix 电台(`!fm -j`)、首页「最近添加 / 播放最多 / 收藏 / 流派」,并把播放进度回报给 Jellyfin(PlayCount / 播放状态)。**默认关闭**,在 设置 → Jellyfin 音乐库 一键开启。详见 [可选:Jellyfin 音源](#可选jellyfin-音源)
- **多平台音源(enabledProviders 门控)** — 网易云音乐 / QQ 音乐 / 酷狗音乐 / 哔哩哔哩 / YouTube(yt-dlp,需安装)**默认启用**,可在 `config.json` 的 `enabledProviders` 中逐个停用;Jellyfin 为可选音源(见上),**Spotify(实验性)** 由独立开关控制(需 Premium + 自建开发者应用,默认关闭,详见 [Spotify 音源(实验性)](#spotify-音源实验性))。统一搜索(歌曲 / 歌单 / 专辑均支持翻页「加载更多」),结果标注来源,禁用音源不出现在搜索栏
- **真实客户端协议 (TS3/TS6 双协议)** — 机器人在 TeamSpeak 中可见(非 ServerQuery 隐身模式),自动检测并适配 TS3 和 TS6 服务器,支持 TS6 HTTP Query API - **真实客户端协议 (TS3/TS6 双协议)** — 机器人在 TeamSpeak 中可见(非 ServerQuery 隐身模式),自动检测并适配 TS3 和 TS6 服务器,支持 TS6 HTTP Query API
- **YesPlayMusic 风格 WebUI** — 精美界面,支持深色/浅色主题切换 - **YesPlayMusic 风格 WebUI** — 精美界面,支持深色/浅色主题切换
- **完整播放控制** — 播放/暂停/上一首/下一首/进度跳转/音量调节 - **完整播放控制** — 播放/暂停/上一首/下一首/进度跳转/音量调节
@@ -162,6 +168,16 @@ sudo ./scripts/install.sh
> >
> **如何判断是否需要迁移**:如果你是全新安装,或者你的机器人数据库中 `identity` 字段已经是空的,则**无需任何操作**。完成上述步骤后,按下面对应的系统升级步骤执行即可。 > **如何判断是否需要迁移**:如果你是全新安装,或者你的机器人数据库中 `identity` 字段已经是空的,则**无需任何操作**。完成上述步骤后,按下面对应的系统升级步骤执行即可。
### 关于 enabledProviders 音源开关(v1.10.0 起)
v1.10.0([PR #123](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/123))引入 `enabledProviders` 音源开关与可选的 Jellyfin 音源。当前默认值为 `["netease", "qq", "bilibili", "youtube", "kugou"]`——**与旧版行为一致**,从更早版本升级**无需任何操作**,在线音源照常可用;Jellyfin 需要手动开启(详见 [可选:Jellyfin 音源](#可选jellyfin-音源))。
> ⚠️ **仅影响短暂运行过 v1.10.0 初版的用户**:该版本曾把默认音源设为 Jellyfin-only。如果你在那段时间保存过设置,`data/config.json` 中可能被写入了 `"enabledProviders": ["jellyfin"]`,升级后在线音源会保持停用。修复方法:把在线音源加回列表(或直接删除该字段以使用默认值),重启机器人(网易云 / QQ 的内嵌 API 服务需要重启才会启动):
>
> ```json
> "enabledProviders": ["netease", "qq", "bilibili", "youtube", "kugou", "jellyfin"]
> ```
### 从 WebUI 无鉴权版本升级(重要) ### 从 WebUI 无鉴权版本升级(重要)
本次更新引入了**强制 WebUI 鉴权**。从无鉴权旧版本升级后,**WebUI 必须先创建管理员账号才能使用**。所有 `/api/*` 端点(除少量公共白名单)和 `/ws` 现在都需要登录。 本次更新引入了**强制 WebUI 鉴权**。从无鉴权旧版本升级后,**WebUI 必须先创建管理员账号才能使用**。所有 `/api/*` 端点(除少量公共白名单)和 `/ws` 现在都需要登录。
@@ -302,18 +318,20 @@ sudo systemctl start tsmusicbot
- 机器人昵称 - 机器人昵称
- 可选:服务器密码、默认频道 - 可选:服务器密码、默认频道
3. 在 **设置 → 音乐账号** 扫码登录网易云 / QQ 音乐 / 酷狗音乐 / B 站账号(可选,登录后可播放 VIP 歌曲、获取每日推荐 / 我的歌单等) 3. 在 **设置 → 音乐账号** 扫码登录网易云 / QQ 音乐 / 酷狗音乐 / B 站账号(可选,登录后可播放 VIP 歌曲、获取每日推荐 / 我的歌单等)
4. 在 **设置 → 用户管理**(仅管理员可见)按需添加成员。成员默认可控制播放但无法管理其他用户;管理员还可为每个成员单独配置**能力**(播放控制 / 队列 / 机器人管理 / 平台登录 / 音质)和**可操作的机器人白名单**,未授权的机器人对该成员不可见、不可控 4. (可选)在 **设置 → Jellyfin 音乐库** 连接自建 Jellyfin 服务器并打开「启用 Jellyfin 音源」(安装向导第 3 步保存连接时会自动启用;详见 [可选:Jellyfin 音源](#可选jellyfin-音源))
5. 在 **设置 → 用户管理**(仅管理员可见)按需添加成员。成员默认可控制播放但无法管理其他用户;管理员还可为每个成员单独配置**能力**(播放控制 / 队列 / 机器人管理 / 平台登录 / 音质)和**可操作的机器人白名单**,未授权的机器人对该成员不可见、不可控
### WebUI 页面说明 ### WebUI 页面说明
| 页面 | 功能 | | 页面 | 功能 |
|------|------| |------|------|
| **首页** | 推荐歌单、每日推荐、私人FM(网易云 / QQ 雷达 / 酷狗电台)、我的歌单、收藏的歌单(各源带标签切换) | | **首页** | 推荐歌单、每日推荐、私人FM(网易云 / QQ 雷达 / 酷狗电台)、我的歌单、收藏的歌单(各源带标签切换);启用 Jellyfin 后另有「Jellyfin 电台 / 最近添加 / 播放最多 / 收藏 / 流派」区块 |
| **搜索** | 四平台统一搜索,结果标注网易云/QQ/酷狗/B站来源,可一键收藏歌单 | | **搜索** | 跨音源统一搜索(仅显示已启用的音源;启用 Jellyfin 后其结果排最前),结果标注来源,可一键收藏歌单 |
| **歌单** | 查看歌单详情,播放全部(根据当前播放模式选择首歌),一键收藏 | | **歌单** | 查看歌单详情,播放全部(根据当前播放模式选择首歌),一键收藏 |
| **歌词** | 全屏歌词页,实时同步滚动,模糊专辑封面背景 | | **歌词** | 全屏歌词页,实时同步滚动,模糊专辑封面背景 |
| **历史** | 播放历史记录 | | **历史** | 播放历史记录 |
| **设置** | 账户(修改自己密码) / 主题切换 / 机器人管理 / 行为设置(空闲超时、频道无人自动暂停) / 多平台账号登录(网易云 / QQ / 酷狗 / B站) / 音质选择 / 命令前缀 / 用户管理(仅管理员,含成员能力与机器人白名单)/ 操作审计(仅管理员) | | **已存队列** | 保存当前队列为清单、加载(替换)/ 追加 / 删除已保存清单(仅在管理员开启「保存/加载播放清单」后出现) |
| **设置** | 账户(修改自己密码) / 主题切换 / 机器人管理 / 行为设置(空闲超时、频道无人自动暂停、保存/加载播放清单、单曲直接播放不清空队列) / 多平台账号登录(网易云 / QQ / 酷狗 / B站) / 音质选择 / 命令前缀 / 用户管理(仅管理员,含成员能力与机器人白名单)/ 操作审计(仅管理员) |
### TeamSpeak 文字命令 ### TeamSpeak 文字命令
@@ -321,14 +339,16 @@ sudo systemctl start tsmusicbot
| 命令 | 说明 | | 命令 | 说明 |
|------|------| |------|------|
| `!play <歌名>` | 搜索并播放(取最热门的匹配项) | | `!play <歌名>` | 搜索并播放(取最热门的匹配项;默认音源为网易云) |
| `!play -n <歌名>` | 显式从网易云音乐搜索(默认音源即网易云,通常可省略) |
| `!play -j <歌名>` | 从 Jellyfin 搜索(需先启用 Jellyfin 音源) |
| `!play -q <歌名>` | 从 QQ 音乐搜索 | | `!play -q <歌名>` | 从 QQ 音乐搜索 |
| `!play -k <歌名>` | 从酷狗音乐搜索 | | `!play -k <歌名>` | 从酷狗音乐搜索 |
| `!play -b <关键词>` | 从哔哩哔哩搜索视频并播放音频 | | `!play -b <关键词>` | 从哔哩哔哩搜索视频并播放音频 |
| `!play -y <关键词>` | 从 YouTube 搜索并播放(需要安装 [yt-dlp](#可选youtube-音源))| | `!play -y <关键词>` | 从 YouTube 搜索并播放(需要安装 [yt-dlp](#可选youtube-音源))|
| `!search <歌名> [-q\|-k\|-b\|-y]` | 列出前若干个匹配结果(含序号与 id),用于挑选同名歌曲;可加平台标志切换音源 | | `!search <歌名> [-j\|-n\|-q\|-k\|-b\|-y]` | 列出前若干个匹配结果(含序号与 id),用于挑选同名歌曲;可加平台标志切换音源 |
| `!play #<序号>` | 播放上一次 `!search` 结果中的第 N 项(区分同名歌曲) | | `!play #<序号>` | 播放上一次 `!search` 结果中的第 N 项(区分同名歌曲) |
| `!play id:<id>` | 按歌曲 id 播放精确的某首歌(也支持直接粘贴网易云 / QQ / B站 歌曲链接) | | `!play id:<id>` | 按歌曲 id 播放精确的某首歌(也支持直接粘贴网易云 / QQ / B站 歌曲链接;Jellyfin 曲目用 GUID ItemId) |
| `!add <歌名>` | 添加到播放队列(同样支持 `#序号` / `id:<id>` / 链接) | | `!add <歌名>` | 添加到播放队列(同样支持 `#序号` / `id:<id>` / 链接) |
| `!pause` / `!resume` | 暂停 / 恢复播放 | | `!pause` / `!resume` | 暂停 / 恢复播放 |
| `!next` / `!prev` | 下一首 / 上一首 | | `!next` / `!prev` | 下一首 / 上一首 |
@@ -337,20 +357,26 @@ sudo systemctl start tsmusicbot
| `!queue` | 查看播放队列 | | `!queue` | 查看播放队列 |
| `!remove <位置>` | 从队列中删除指定位置的歌曲(位置从 1 开始,见 `!queue`) | | `!remove <位置>` | 从队列中删除指定位置的歌曲(位置从 1 开始,见 `!queue`) |
| `!mode <seq\|loop\|random\|rloop>` | 切换播放模式 | | `!mode <seq\|loop\|random\|rloop>` | 切换播放模式 |
| `!playlist <歌单名或ID>` | 加载歌单(支持名称模糊搜索和 ID) | | `!playlist <歌单名或ID>` | 加载歌单(支持名称模糊搜索和 ID;Jellyfin 歌单 GUID 也可直接粘贴) |
| `!playlist -q <歌单名>` | 从 QQ 音乐搜索并加载歌单 | | `!playlist -q <歌单名>` | 从 QQ 音乐搜索并加载歌单 |
| `!album <ID>` | 加载专辑 | | `!album <专辑名或ID>` | 加载专辑(支持名称搜索 / 数字 ID / Jellyfin GUID) |
| `!artist <歌手名>` | 按歌手循环播放(支持 `-q`/`-k`/`-b`/`-y`) | | `!artist <歌手名>` | 按歌手循环播放(支持 `-j`/`-n`/`-q`/`-k`/`-b`/`-y`) |
| `!fm` | 私人 FM(网易云,自动续播) | | `!fm` | 私人 FM(默认网易云,自动续播) |
| `!fm -j` | Jellyfin 电台:从收藏出发的 Instant Mix(需启用 Jellyfin,自动续播) |
| `!fm -q` | QQ 音乐雷达 / 猜你喜欢 FM(自动续播) | | `!fm -q` | QQ 音乐雷达 / 猜你喜欢 FM(自动续播) |
| `!fm -k` | 酷狗私人电台 / 个性化推荐 FM(自动续播) | | `!fm -k` | 酷狗私人电台 / 个性化推荐 FM(自动续播) |
| `!lyrics` | 显示当前歌词 | | `!lyrics` | 显示当前完整歌词(自动分多条消息发送,不再只显示开头几行) |
| `!now` | 当前播放信息 | | `!now` | 当前播放信息 |
| `!vote` | 投票跳过当前歌曲 | | `!vote` | 投票跳过当前歌曲 |
| `!move <频道名>` | 移动到指定频道 | | `!move <频道名>` | 移动到指定频道 |
| `!save <名称>` | 保存当前队列为一份已保存清单(需启用「保存/加载播放清单」,聊天保存进入共享清单) |
| `!load [-a] <名称>` | 加载已保存清单(默认替换当前队列并播放;加 `-a` 追加到队列末尾) |
| `!queues` | 列出已保存(共享)清单 |
| `!help` | 显示帮助信息 | | `!help` | 显示帮助信息 |
> 命令前缀默认为 `!`,可在设置页面修改。支持别名:`!p` = `!play`,`!s` = `!skip`,`!n` = `!next` > 命令前缀默认为 `!`,可在设置页面修改。支持别名:`!p` = `!play`,`!s` = `!skip`,`!n` = `!next`
>
> `!save` / `!load` / `!queues` 仅在管理员开启「保存/加载播放清单」后可用(默认关闭),未启用时回复「此功能未启用」。
### TeamSpeak 命令权限(管理类命令限制) ### TeamSpeak 命令权限(管理类命令限制)
@@ -375,6 +401,8 @@ sudo systemctl start tsmusicbot
### 音质等级 ### 音质等级
**在线音源(网易云等)**
| 等级 | 码率 | 格式 | 说明 | | 等级 | 码率 | 格式 | 说明 |
|------|------|------|------| |------|------|------|------|
| 标准 | 128kbps | MP3 | 免费可用 | | 标准 | 128kbps | MP3 | 免费可用 |
@@ -384,8 +412,29 @@ sudo systemctl start tsmusicbot
| Hi-Res | ~1500kbps | FLAC | 需要 VIP | | Hi-Res | ~1500kbps | FLAC | 需要 VIP |
| 超清母带 | ~4000kbps | FLAC | 需要黑胶 VIP | | 超清母带 | ~4000kbps | FLAC | 需要黑胶 VIP |
**Jellyfin(启用后)**
| 等级 | 说明 |
|------|------|
| **原始直传(direct)** | **默认**:原始文件不转码直传(机器人本地统一转 Opus,此档即最高音质) |
| 320kbps / 192kbps / 128kbps | 由 Jellyfin 服务器转码后传输,适合公网带宽有限的自建服务器 |
在设置页面选择音质,立即生效(影响后续播放的歌曲)。 在设置页面选择音质,立即生效(影响后续播放的歌曲)。
> **重启后保留(#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` 显式保存的偏好)。
## 项目架构 ## 项目架构
``` ```
@@ -407,6 +456,7 @@ teamspeak-music-bot/
│ │ └── database.ts # SQLite 数据库(播放历史、实例、收藏、权限持久化) │ │ └── database.ts # SQLite 数据库(播放历史、实例、收藏、权限持久化)
│ ├── music/ # 音源服务 │ ├── music/ # 音源服务
│ │ ├── provider.ts # 统一 MusicProvider 接口 │ │ ├── provider.ts # 统一 MusicProvider 接口
│ │ ├── jellyfin.ts # Jellyfin 适配器(可选音源,直连 REST API)
│ │ ├── netease.ts # 网易云音乐适配器 │ │ ├── netease.ts # 网易云音乐适配器
│ │ ├── qq.ts # QQ 音乐适配器 │ │ ├── qq.ts # QQ 音乐适配器
│ │ ├── bilibili.ts # 哔哩哔哩适配器(视频音频提取) │ │ ├── bilibili.ts # 哔哩哔哩适配器(视频音频提取)
@@ -460,6 +510,7 @@ teamspeak-music-bot/
| **数据库** | better-sqlite3 (SQLite) | | **数据库** | better-sqlite3 (SQLite) |
| **音频处理** | FFmpeg (ffmpeg-static 内置), @discordjs/opus | | **音频处理** | FFmpeg (ffmpeg-static 内置), @discordjs/opus |
| **TS 协议** | @honeybbq/teamspeak-client(完整客户端协议)+ 自研 TS6 协议适配层 | | **TS 协议** | @honeybbq/teamspeak-client(完整客户端协议)+ 自研 TS6 协议适配层 |
| **Jellyfin** | Jellyfin REST API(可选音源,直连,无额外 npm 依赖) |
| **网易云 API** | NeteaseCloudMusicApi | | **网易云 API** | NeteaseCloudMusicApi |
| **QQ 音乐 API** | @sansenjian/qq-music-api(锁定 `~2.4.0`,需 Node ≥ 20.17) | | **QQ 音乐 API** | @sansenjian/qq-music-api(锁定 `~2.4.0`,需 Node ≥ 20.17) |
| **哔哩哔哩** | BiliBili Web API(搜索、DASH 音频流、QR 登录) | | **哔哩哔哩** | BiliBili Web API(搜索、DASH 音频流、QR 登录) |
@@ -469,6 +520,64 @@ teamspeak-music-bot/
| **图标** | @iconify/vue | | **图标** | @iconify/vue |
| **日志** | pino | | **日志** | pino |
## 可选:Jellyfin 音源
本项目可将自建 [Jellyfin](https://jellyfin.org/) 媒体服务器作为**额外音源**:机器人直接播放你自己音乐库里的文件,不依赖任何在线平台的可用性 / 版权 / 登录状态。该音源**默认关闭**,需要手动启用。
### 启用与连接配置
三种方式任选:
1. **首次安装向导** — 第 3 步即 Jellyfin 连接卡(可跳过,稍后配置);填写并「保存并继续」会**自动启用**该音源。
2. **WebUI** — 设置 → Jellyfin 音乐库(可选):打开「**启用 Jellyfin 音源**」开关,填写服务器地址、选择认证方式、「测试连接」验证后保存,**保存即时生效,无需重启**。
3. **config.json** — 手动编辑 `jellyfin` 配置块,并把 `"jellyfin"` 加入 `enabledProviders`,然后重启。
两种认证方式:
| 模式 | 填写内容 | 说明 |
|------|---------|------|
| **账号密码**(默认) | `username` + `password` | 以该用户身份登录(`AuthenticateByName`),token 自动持久化、失效自动重登 |
| **API Key** | `apiKey` + `userId` | 使用管理后台生成的 API Key;`userId` 决定使用谁的音乐库 / 收藏 / 歌单 |
```jsonc
// config.json 片段
{
"jellyfin": {
"serverUrl": "https://jellyfin.example.com",
"authMode": "userpass", // 或 "apikey"
"username": "music",
"password": "······",
"apiKey": "", // apikey 模式填写
"userId": "" // apikey 模式填写
},
"enabledProviders": ["netease", "qq", "bilibili", "youtube", "kugou", "jellyfin"]
}
```
> 密码 / API Key 在 WebUI 中**只写不回显**;表单留空表示保持已保存的值不变。
### 功能
- **搜索** — 歌曲 / 专辑 / 歌单,支持翻页「加载更多」;WebUI 统一搜索中 Jellyfin 结果排最前
- **播放** — 懒解析播放地址;默认**原始直传**(不经 Jellyfin 转码),也可选 320/192/128kbps 服务器转码档(设置 → 音质设置)
- **歌词** — 读取 Jellyfin 的歌词接口(内嵌或 .lrc),时间轴同步滚动,`!lyrics` 可用
- **电台 / FM**(`!fm -j` 或首页「Jellyfin 电台」卡片)— 随机取一首**收藏**做种子生成 Instant Mix 歌曲流;没有收藏则回退到最近播放、再回退随机曲目
- **首页区块** — 最近添加(专辑)/ 播放最多 / Jellyfin 收藏 / 我的歌单 / 流派(点流派芯片即播放该流派)
- **播放上报** — 播放开始 / 进度(约 10s 一次)/ 停止会回报给 Jellyfin(`Sessions/Playing` 系列接口),你的 Jellyfin 播放统计(PlayCount、最近播放)保持准确;上报失败不影响播放
- **聊天命令** — 启用后用 `-j` 标志:`!play -j <歌名>`、`!fm -j`、`!artist -j <歌手>`;`!playlist` / `!album` / `!play id:` 可直接粘贴 Jellyfin GUID。若把在线音源全部停用、只保留 Jellyfin,不带标志的命令会自动以 Jellyfin 为默认音源
### enabledProviders:音源开关
`config.json` 的 `enabledProviders` 数组决定哪些音源可用(默认 `["netease", "qq", "bilibili", "youtube", "kugou"]`,即在线音源全开、Jellyfin 关闭):
- 可选值:`jellyfin`、`netease`、`qq`、`bilibili`、`youtube`、`kugou`(`local` 由 `localAudioEnabled` 控制,`spotify` 由 `spotify.enabled` 控制)
- 未列出的音源:聊天命令返回「音源未启用」、REST 返回 400、WebUI 搜索栏 / 登录卡 / FM 卡片自动隐藏
- 不带平台标志的命令默认走**固定优先级中第一个已启用的音源**:网易云 → QQ → 酷狗 → Jellyfin → B站 → YouTube(默认配置下即网易云)
- **自定义默认音源(`defaultPlatform`)** — 想让不带标志的 `!play 歌名` 直接用某个音源(例如常听哔哩哔哩,免去每次加 `-b`),可在 设置 → 默认音源 里选择,或在 `config.json` 中设置 `"defaultPlatform": "bilibili"`。取值须是 `enabledProviders` 里已启用的音源,否则被忽略(回退到上面的固定优先级);留空 / `null` / 删除该字段即恢复固定优先级。WebUI 保存后即时生效,无需重启
- 网易云 / QQ 停用时,其内嵌 API 服务(端口 3001 / 3200)**不会启动**
- 示例(Jellyfin 为主、只留网易云备用):`"enabledProviders": ["jellyfin", "netease"]`(默认音源仍为网易云,点歌用 `-j`、停用网易云,或直接把 `defaultPlatform` 设为 `"jellyfin"`);示例(纯 Jellyfin):`"enabledProviders": ["jellyfin"]`
- 注意:重新启用网易云 / QQ 的内嵌 API 服务需要重启机器人;其余音源改动即时生效(WebUI 的 Jellyfin 开关即改此列表)
## 可选:YouTube 音源 ## 可选:YouTube 音源
YouTube 是**可选**的音源,默认**未启用**,需要安装 [yt-dlp](https://github.com/yt-dlp/yt-dlp) 才能使用。启用后可通过聊天命令 `!play -y <关键词>` 或 WebUI 的 YouTube 平台选项搜索/播放 YouTube 视频的音频流。 YouTube 是**可选**的音源,默认**未启用**,需要安装 [yt-dlp](https://github.com/yt-dlp/yt-dlp) 才能使用。启用后可通过聊天命令 `!play -y <关键词>` 或 WebUI 的 YouTube 平台选项搜索/播放 YouTube 视频的音频流。
@@ -701,10 +810,13 @@ A:使用 `!move <频道名>` 命令,或在设置页面创建机器人时指
A:可以。在设置页面创建多个实例,分别连接不同的 TS 服务器或频道。 A:可以。在设置页面创建多个实例,分别连接不同的 TS 服务器或频道。
**Q:端口 3200 被占用?** **Q:端口 3200 被占用?**
A:QQ 音乐 API 启动时自动监听 3200 端口。如果之前的进程还在运行,程序会自动复用。如需重启可手动结束 `node` 进程。 A:QQ 音乐 API 启动时会监听 `config.json` 里的 `qqMusicApiPort`(默认 **3200**),客户端也用同一个端口发请求,二者始终一致。如果之前的进程还在运行,程序会自动复用。如需改端口,改 `qqMusicApiPort` 后重启即可;如需重启可手动结束 `node` 进程。
**Q:日志里 `baseURL` 是 3200,但 QQ API 实际监听在 3300?(二维码不弹)**
A:这是**旧版本**(或过期的 `latest` Docker 镜像)才有的问题:早期实现用的上游包默认端口是 3300,而客户端 `baseURL` 已经是 3200,两边对不上,取二维码时就 `ECONNREFUSED 127.0.0.1:3200`。当前版本已把内嵌 QQ 音乐 API **强制绑定到 `qqMusicApiPort`(默认 3200)**,并在启动前把上游包读取的 `PORT` 环境变量对齐到该端口,二者不可能再错位。修复方法:**拉取最新镜像并重启**(`docker compose pull && docker compose up -d`),或用 `npm ci && npm run build` 更新到最新代码。启动后可在日志里确认那行 `QQ Music API started`,其 `port` 字段就是实际监听端口。
**Q:QQ 音乐二维码不弹 / 扫码登录失败 / cookie 无法使用?** **Q:QQ 音乐二维码不弹 / 扫码登录失败 / cookie 无法使用?**
A:通常是内置的 QQ 音乐 API 服务没起来——它一旦没监听 3200 端口,机器人去取二维码就会拿到 `ECONNREFUSED 127.0.0.1:3200`,于是二维码不显示,登录和 cookie 也全失效。先看日志里 QQ API 的启动报错: A:通常是内置的 QQ 音乐 API 服务没起来——它一旦没监听 `qqMusicApiPort`(默认 3200)端口,机器人去取二维码就会拿到 `ECONNREFUSED 127.0.0.1:3200`,于是二维码不显示,登录和 cookie 也全失效。先看日志里 QQ API 的启动报错:
- 报 `ERR_REQUIRE_ESM`:装到了不兼容的 `@sansenjian/qq-music-api` 版本。本项目把它锁在 **`~2.4.0`**(需要 **Node ≥ 20.17 / 22.9**);务必用 `npm ci` 或 `npm install` 让版本与锁文件一致,**不要**手动 `npm update` 把它升级或降级到不兼容的中间版本(2.3.0/2.3.1 是纯 ESM、会触发此错)。 - 报 `ERR_REQUIRE_ESM`:装到了不兼容的 `@sansenjian/qq-music-api` 版本。本项目把它锁在 **`~2.4.0`**(需要 **Node ≥ 20.17 / 22.9**);务必用 `npm ci` 或 `npm install` 让版本与锁文件一致,**不要**手动 `npm update` 把它升级或降级到不兼容的中间版本(2.3.0/2.3.1 是纯 ESM、会触发此错)。
- 报 Node 版本不满足:升级 Node 到 ≥ 20.17,或将该依赖降到 `~2.2.10`(无此 Node 要求)后重装。 - 报 Node 版本不满足:升级 Node 到 ≥ 20.17,或将该依赖降到 `~2.2.10`(无此 Node 要求)后重装。
修好版本后重新 `npm install && npm run build` 并重启即可。 修好版本后重新 `npm install && npm run build` 并重启即可。
@@ -764,9 +876,28 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
> 完整历史请查看 [git log](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/commits/main) 或 [Releases](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/releases)。这里只列出重要变更和面向用户的破坏性改动。 > 完整历史请查看 [git log](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/commits/main) 或 [Releases](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/releases)。这里只列出重要变更和面向用户的破坏性改动。
### 最新版本 ### 最新版本 — 保存/加载播放清单 + 重启恢复队列
**功能增强:细粒度权限 / 本地收藏 / 本地音频上传 / 专属链接 / 自动暂停 / QQ 雷达 FM** **保存/加载播放清单 + 队列持久化([#119](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/119))——三项开关均默认关闭,升级无行为变化**
- **保存/加载播放清单(`savedQueuesEnabled`,默认关闭,管理员开关)**:开启后,可在网页「已存队列」页或聊天命令保存当前队列为清单、随时**替换**加载或**追加**到队列末尾。网页保存可选「共享」(否则私有到当前用户);聊天命令始终进入共享清单。新增聊天命令 `!save <名称>` / `!load [-a] <名称>` / `!queues`(未启用时回复「此功能未启用」)。上限:每个所有者 ≤ 50 份清单,每份 ≤ 1000 首。
- **重启后自动恢复并继续播放队列**(同由 `savedQueuesEnabled` 门控):机器人连接后会恢复上次的队列并继续播放。**说明**:只能从当前曲目的**开头**恢复(不记忆播放进度,链接重新解析);**Spotify 恢复为尽力而为**(依赖 sidecar 重新可用),其他音源可靠。
- **单曲直接播放不清空队列(`playKeepsQueue`,默认关闭,独立开关)**:开启后,直接播放单曲会插入到当前歌曲之后并立即播放、播完继续原队列,而不是清空整个队列。仅影响单曲的「直接播放」;歌单 / 专辑 / 电台仍会替换队列。
- 三项均在 设置 → 行为设置 中开关,保存即时生效,无需重启。
### 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**
- **细粒度账号权限**(叠加在 admin / member 之上):管理员可为每个成员勾选 5 项能力(`player.control` / `player.queue` / `bot.manage` / `platform.auth` / `quality`)和按机器人授权白名单;所有变更路由由后端 `requirePermission` / `requireBotAccess` 中间件逐请求强制校验,未授权返回 403,未授权的机器人对成员不可见(列表过滤,无 403-vs-404 枚举泄漏)。已有成员经一次性迁移获得全部能力,新成员默认基础能力。 - **细粒度账号权限**(叠加在 admin / member 之上):管理员可为每个成员勾选 5 项能力(`player.control` / `player.queue` / `bot.manage` / `platform.auth` / `quality`)和按机器人授权白名单;所有变更路由由后端 `requirePermission` / `requireBotAccess` 中间件逐请求强制校验,未授权返回 403,未授权的机器人对成员不可见(列表过滤,无 403-vs-404 枚举泄漏)。已有成员经一次性迁移获得全部能力,新成员默认基础能力。
- **本地收藏歌单**:按用户存储的收藏(`favorite_playlists` 表 + `/api/favorites`),首页 / 搜索 / 歌单页一键收藏,跨设备同步。 - **本地收藏歌单**:按用户存储的收藏(`favorite_playlists` 表 + `/api/favorites`),首页 / 搜索 / 歌单页一键收藏,跨设备同步。
@@ -774,9 +905,12 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
- **专属链接(单机器人锁定)**:`/bot/<id>` 打开时锁定到单台机器人,`?bot=<id>` 随刷新保持;与权限白名单组合,机器人下拉只显示"作用域 ∩ 可控"的机器人。 - **专属链接(单机器人锁定)**:`/bot/<id>` 打开时锁定到单台机器人,`?bot=<id>` 随刷新保持;与权限白名单组合,机器人下拉只显示"作用域 ∩ 可控"的机器人。
- **频道无人时自动暂停**:机器人所在频道清空时暂停、有人加入时恢复(区分用户手动暂停,不会误恢复);可在 设置 → 行为设置 开关(默认关闭)。占用检测在 `clientlist` 查询失败时按"未知"处理而非"无人",避免有人在听时被误暂停。 - **频道无人时自动暂停**:机器人所在频道清空时暂停、有人加入时恢复(区分用户手动暂停,不会误恢复);可在 设置 → 行为设置 开关(默认关闭)。占用检测在 `clientlist` 查询失败时按"未知"处理而非"无人",避免有人在听时被误暂停。
- **QQ 音乐雷达 / 私人 FM**:`!fm -q` 或 WebUI 启动 QQ 雷达推荐流(失败回退"猜你喜欢"),FM 自动续播现支持任意平台。 - **QQ 音乐雷达 / 私人 FM**:`!fm -q` 或 WebUI 启动 QQ 雷达推荐流(失败回退"猜你喜欢"),FM 自动续播现支持任意平台。
- **#112 Spotify 音源(实验性)**:新增 Spotify 作为可选音源,默认关闭、需 Premium + 自建开发者应用(PKCE,无需 Client Secret)。采用混合 librespot 后端(Linux/Docker 用 go-librespot,Windows 用 Rust librespot),元数据走 Spotify Web API,可与现有音源混排入队。为 ToS 灰色地带的实验特性,详见 [Spotify 音源(实验性)](#spotify-音源实验性)。
- **#115 搜索结果翻页**:WebUI 搜索现按来源、按分类(歌曲 / 歌单 / 专辑)提供「加载更多」,服务端新增 `offset` 分页,不再固定只返回首页 20 首 / 10 个歌单 / 10 张专辑。
**Bug 修复** **Bug 修复**
- **#116 `!lyrics` 只显示开头几行**:聊天命令曾把歌词截断为前 10 行,且长消息未分片会触及 TeamSpeak 单条约 1 KB 上限。现发送完整歌词,并按 UTF-8 字节安全地分割成多条消息(长回复通用分片,不再截断)。
- **#86 config.json 未在首次运行生成**:配置文件改放到持久化的 `data/config.json`(旧版写在项目根目录,不在 Docker 卷内,导致重启丢失、手动编辑不生效);升级时自动把根目录旧配置迁移到 `data/` 并保留你的设置。 - **#86 config.json 未在首次运行生成**:配置文件改放到持久化的 `data/config.json`(旧版写在项目根目录,不在 Docker 卷内,导致重启丢失、手动编辑不生效);升级时自动把根目录旧配置迁移到 `data/` 并保留你的设置。
- **#89 B站长音频约 16 分钟被暂停且无法继续**:ffmpeg 增加 `-reconnect_at_eof`(B站 CDN 会在 token/会话到期时提前关闭连接造成 EOF),并新增"远离结尾的卡死看门狗"——彻底卡死的流会自动推进到下一首而不是永久静音。 - **#89 B站长音频约 16 分钟被暂停且无法继续**:ffmpeg 增加 `-reconnect_at_eof`(B站 CDN 会在 token/会话到期时提前关闭连接造成 EOF),并新增"远离结尾的卡死看门狗"——彻底卡死的流会自动推进到下一首而不是永久静音。
- **#84 音量曲线不顺滑**:0–100 改为连续单调曲线 `0.2x + 0.8x^8`(消除 80–99 的"死区"与 100 处的突跳,满响度仍保留在 100)。 - **#84 音量曲线不顺滑**:0–100 改为连续单调曲线 `0.2x + 0.8x^8`(消除 80–99 的"死区"与 100 处的突跳,满响度仍保留在 100)。
@@ -791,6 +925,7 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
- **会话存储**:服务端 SQLite 表 `sessions`,存储 sha256(token);浏览器只持有原始 token cookie。7 天 TTL,每小时滚动续期。同账号最多 10 个并发会话(超出剔除最旧)。 - **会话存储**:服务端 SQLite 表 `sessions`,存储 sha256(token);浏览器只持有原始 token cookie。7 天 TTL,每小时滚动续期。同账号最多 10 个并发会话(超出剔除最旧)。
- **登录限流**:每 IP 每分钟 5 次 `/login` + 3 次 `/setup`,命中返回 429 + `Retry-After`。 - **登录限流**:每 IP 每分钟 5 次 `/login` + 3 次 `/setup`,命中返回 429 + `Retry-After`。
- **CSRF & 安全头**:所有 mutating 请求强制 `Origin`/`Referer` 同源;响应携带 `X-Frame-Options: DENY` 和 `Content-Security-Policy: frame-ancestors 'none'`(防点击劫持)。 - **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`,当前未使用。 - **配置变更**:反向代理部署务必 `"trustProxy": true`(详见 [反向代理部署注意事项](#反向代理部署注意事项))。`config.adminGroups` 现已启用,用于限制管理类聊天命令只能由指定 TeamSpeak 服务器组运行(为空 = 不限制,详见 [TeamSpeak 命令权限](#teamspeak-命令权限管理类命令限制));`config.adminPassword` 仍为旧版预留字段,保留以兼容旧 `config.json`,当前未使用。
### v0.x — Bot Profile 自动更新与协议层升级 ### v0.x — Bot Profile 自动更新与协议层升级
@@ -872,6 +1007,8 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
| 项目 | 说明 | | 项目 | 说明 |
|------|------| |------|------|
| [Jellyfin](https://github.com/jellyfin/jellyfin) | 自由软件媒体服务器(本项目的可选自建音源) |
| [ItsEricRao](https://github.com/ItsEricRao) | Jellyfin 音源集成贡献者([PR #123](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/123)) |
| [yichen11818/NeteaseTSBot](https://github.com/yichen11818/NeteaseTSBot) | TS6 协议兼容参考(vendored tsproto 补丁) | | [yichen11818/NeteaseTSBot](https://github.com/yichen11818/NeteaseTSBot) | TS6 协议兼容参考(vendored tsproto 补丁) |
| [Splamy/TS3AudioBot](https://github.com/Splamy/TS3AudioBot) | 优秀的 TeamSpeak 音频机器人框架 | | [Splamy/TS3AudioBot](https://github.com/Splamy/TS3AudioBot) | 优秀的 TeamSpeak 音频机器人框架 |
| [TS3AudioBot-BiliBiliPlugin](https://github.com/xxmod/TS3AudioBot-BiliBiliPlugin) | 提供插件开发参考 | | [TS3AudioBot-BiliBiliPlugin](https://github.com/xxmod/TS3AudioBot-BiliBiliPlugin) | 提供插件开发参考 |
@@ -0,0 +1,53 @@
# Jellyfin 音源(主音源化)实施计划
目标:自建 Jellyfin 服务器成为默认且主要的音源;现有 NetEase / QQ / Bilibili / YouTube / Kugou
继续编译但默认停用(`enabledProviders` 配置门控,本次不删除)。约束:`src/audio/*`、
`src/ts-protocol/*` 不改(唯一例外:`queue.ts` 中 `QueuedSong.platform` 联合类型加一个成员,
纯类型加宽,无任何逻辑改动,否则无法编译)。
## 触点地图
后端:
- `src/music/provider.ts` — platform 联合类型加 `"jellyfin"`(Song/Playlist/Album/MusicProvider)
- `src/audio/queue.ts` — 同上(仅类型,一个词)
- `src/data/database.ts` — PlayHistoryRecord.platform 加 `"jellyfin"`
- `src/music/auth.ts` — CookieStore 平台加 `"jellyfin"`(token JSON 与 cookie 同路径持久化)
- `src/data/config.ts` — 新增 `JellyfinConfig`(serverUrl / authMode / username / password /
apiKey / userId)+ `enabledProviders`(默认 `["jellyfin"]`)+ 载入清洗 + `isProviderEnabled` /
`defaultPlatform` 助手。spotify 仍由 `spotify.enabled` 门控、local 仍由 `localAudioEnabled` 门控
- `src/music/jellyfin.ts` — 新 Provider:认证(userpass=AuthenticateByName + MediaBrowser 头,
401 重登一次;apikey=X-Emby-Token)、搜索(Audio/MusicAlbum/Playlist, StartIndex 翻页)、
懒解析播放 URL(direct=`/Audio/{id}/stream?static=true`;320/192/128=`/Audio/{id}/universal`
转码)、歌单/专辑/歌词(ticks→秒,404=无歌词)、收藏→InstantMix 电台链(收藏→最近播放→随机曲目)、
最近添加/最多播放/流派、播放上报(Sessions/Playing[/Progress|/Stopped],全部吞错)、
音质档(direct 原始直传 / 320k / 192k / 128k)
- `src/music/api-server.ts` — 按 provider 开关决定是否绑定 3001/3200
- `src/index.ts` — 构造 JellyfinProvider、加载持久化 token、穿线
- `src/bot/manager.ts` — 穿线 jellyfinProvider(与 spotifyProvider 同模式)
- `src/bot/instance.ts` — jellyfin 分支(getProviderFor / getProvider,新 flag `-j`/`-n`)、
默认平台=jellyfin、停用音源友好报错、歌单/专辑命令识别 GUID、播放上报挂钩、帮助文本
- `src/web/api/music.ts` — provider 选择 + 门控、`/providers`、jellyfin 首页数据端点、音质路由
- `src/web/api/auth.ts` — jellyfin 状态 + 测试连接(/System/Info);无 QR
- `src/web/api/player.ts` — 平台白名单 + platformFlag(netease 显式 `-n`)
- `src/web/api/bot.ts` — settings 读写 jellyfin 配置块(密码/APIKey 写入不回显)+ 热更新 provider
前端(Vue 3,保持 YesPlayMusic 风格;新增文案 zh+en):
- `stores/player.ts` — Source 加 jellyfin、enabledProviders 状态、jellyfin 首页数据
- `views/Search.vue` — 音源条只显示启用的 provider、Jellyfin 徽标
- `views/Home.vue` — 最近添加 / 播放最多 / 我的歌单 / 收藏 / 流派 区块 + jellyfin FM 卡片
- `views/Settings.vue` — Jellyfin 连接卡(地址/认证模式/凭据/测试连接)、隐藏停用 provider 的
登录卡、jellyfin 音质档
- `views/Setup.vue` — 向导第 3 步换成 Jellyfin 连接卡
- `views/Playlist.vue` 等 — ID 均按 string 处理,审计通过(GUID 无数字假设)
文档:README 增加「Jellyfin 音源」章节 + fork 出处与 MIT 归属(上游
ZHANGTIANYAO1/teamspeak-music-bot)。
## 阶段与验证
1. 类型 + 配置 → `npx tsc --noEmit`
2. JellyfinProvider + 单测 → tsc + vitest
3. 门控 + bot 穿线 → tsc + vitest
4. REST → tsc + vitest
5. WebUI → `cd web && npm run build`
6. README + 全量构建/测试
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,227 @@
# Save/Load Playlists + Queue Persistence — Design
**Issue:** [#119](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/119) — 加入保存和加载播放清单功能
**Date:** 2026-07-06
**Status:** Approved (design), pending implementation plan
## Problem
The play queue lives only in memory (`PlayQueue` inside each `BotInstance`). It is lost in two situations the user calls out:
1. **On restart** — the process stops, the in-memory queue is gone.
2. **On "直接播放"** — `!play <song>` (and the WebUI "play now" path) call `queue.clear()`, wiping the queue to play one song.
The user wants to stop losing the queue. Two related-but-separate capabilities were agreed:
- **Named save/load** of queues (manual), plus **auto-restore** of the live queue across restarts.
- An **independent** option to make single-song immediate-play *not* clear the queue.
Everything ships **behind admin/independent toggles that default OFF**, so existing behavior is unchanged until an operator opts in.
## Scope & agreed decisions
| Decision | Choice |
| --- | --- |
| Core behavior | **Both** — named save/load **and** auto-restore live queue across restart |
| Saved-queue ownership | **Per-user** (like `favorite_playlists`), plus a reserved `__shared__` owner for chat + opt-in sharing |
| Trigger surface | **Web + chat** commands |
| Auto-restore on restart | **Restore and resume playing** (gated by `savedQueuesEnabled`) |
| Load semantics | **Replace (default) + append option** (`-a` flag / WebUI Append button) |
| Feature gate | `savedQueuesEnabled` — **default false, admin-controlled** |
| Single-play clear | Independent `playKeepsQueue` toggle — **default false** |
### Out of scope (YAGNI)
- Renaming a saved queue (delete + re-save instead).
- Mid-track resume on restart (resume from the current track's **start**; URLs are re-resolved).
- Normalized per-song storage (songs stored as a JSON blob).
- Sharing granularity beyond "private to me" vs "shared" (one boolean).
## Storage approach
A saved queue is an ordered list of songs that is only ever saved and loaded **whole** — never queried song-by-song. So songs are stored as a **JSON `TEXT` blob**, not a normalized child table. Each stored song is a `QueuedSong` **without `url`** (URLs are resolved lazily at play time, exactly as today). This mirrors how `QueuedSong` already flows and keeps the schema to a single row per saved queue.
---
## Feature 1 — Named save/load (`savedQueuesEnabled`)
### Data model
New table:
```sql
CREATE TABLE IF NOT EXISTS saved_queues (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ownerId TEXT NOT NULL, -- WebUI user id, or the reserved SHARED owner
name TEXT NOT NULL,
songs TEXT NOT NULL, -- JSON array of stored songs (QueuedSong minus url)
songCount INTEGER NOT NULL DEFAULT 0,
createdAt TEXT NOT NULL DEFAULT (datetime('now')),
updatedAt TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(ownerId, name)
);
CREATE INDEX IF NOT EXISTS idx_saved_queues_ownerId ON saved_queues(ownerId);
```
- **`ownerId`** is either a real user id **or** a reserved constant `SHARED_QUEUE_OWNER = "__shared__"` (a value that can never collide with a real user id).
- **Ownership rule (reconciles per-user with chat):**
- **WebUI save** has a **"共享 (shared)" checkbox.** Off → `ownerId = req.user.id` (private to you). On → `ownerId = SHARED_QUEUE_OWNER`.
- **Chat `!save`** always writes `ownerId = SHARED_QUEUE_OWNER` (TeamSpeak users have no WebUI account).
- **WebUI list** shows **your own + shared** (labeled). **Chat `!queues`/`!load`** see **shared only**.
- **Overwrite:** `UNIQUE(ownerId, name)` → save is an **upsert** (same owner+name replaces `songs`, `songCount`, `updatedAt`).
- **Caps** (reject with a clear message): ≤ **50** saved queues per owner; ≤ **1000** songs per saved queue.
### DB methods (added to `BotDatabase`)
```ts
saveQueue(ownerId: string, name: string, songs: StoredSong[]): SavedQueue; // upsert
listSavedQueues(ownerId: string, includeShared: boolean): SavedQueueMeta[]; // meta only (no songs blob)
getSavedQueue(id: number): SavedQueue | null; // full, with songs
deleteSavedQueue(id: number): boolean;
```
- `StoredSong` = `Omit<QueuedSong, "url">`.
- `SavedQueueMeta` = row without the `songs` blob (id, ownerId, name, songCount, timestamps) — keeps list responses light.
- JSON (de)serialize at the DB boundary; parse failures on a corrupt blob degrade to an empty song list (never throw into a route).
### Web API — `src/web/api/saved-queues.ts`
All routes require auth + `player.queue` capability, and **404/403 when `savedQueuesEnabled` is false** (feature inert).
| Method / path | Behavior |
| --- | --- |
| `GET /api/saved-queues` | list current user's own + shared (meta only) |
| `POST /api/saved-queues` | body `{ botId, name, shared? }` → snapshot that bot's **current queue** songs, upsert |
| `POST /api/saved-queues/:id/load` | body `{ botId, mode: "replace"\|"append" }` → load into that bot |
| `DELETE /api/saved-queues/:id` | delete (only own or shared; not another user's private) |
Ownership check on load/delete: allow if `ownerId === req.user.id` or `ownerId === SHARED_QUEUE_OWNER`; else 404 (no existence leak, matching the favorites pattern).
### Chat commands (new, in `BotInstance.executeCommand`)
- `!save <名称>` — save current queue → shared bucket.
- `!load <名称>` — replace queue with a saved (shared) queue and play.
- `!load -a <名称>` — append a saved (shared) queue to the end.
- `!queues` — list shared saved queues (names + counts).
All four reply **"此功能未启用"** when `savedQueuesEnabled` is false. Added to help text and the command table.
### Load semantics (shared by web + chat)
- **replace** — `queue.clear()`, add all stored songs, `queue.play()` + `resolveAndPlay(first)` (same shape as `cmdPlaylist`). Exits FM mode.
- **append** — add all stored songs to the end; if idle, start the first newly-added one; never interrupts a playing track.
- Loaded songs are re-tagged with a `requestedBy` of the loader (WebUI username / `游客` / chat `invokerName`) so play-history attribution stays correct (integrates with #121).
### WebUI
A new **"已保存队列 / Saved Queues"** page (nav entry visible only when `savedQueuesEnabled`):
- **Save current queue**: name input + **共享** checkbox → `POST`.
- Per entry (reusing `SongCard`/list styles): **Load** (replace), **Append**, **Delete**, showing name / song count / shared badge / owner.
- Store/composable follows the existing `favorites` pattern.
---
## Feature 2 — Auto-restore live queue across restart (`savedQueuesEnabled`)
Gated by the **same** `savedQueuesEnabled` flag (part of the "saved queues" feature).
### Data model
One row per bot (the live snapshot, continuously overwritten):
```sql
CREATE TABLE IF NOT EXISTS queue_state (
botId TEXT PRIMARY KEY,
songs TEXT NOT NULL, -- JSON array of StoredSong
currentIndex INTEGER NOT NULL,
mode TEXT NOT NULL, -- PlayMode value
isFmMode INTEGER NOT NULL DEFAULT 0,
fmPlatform TEXT NOT NULL DEFAULT '',
updatedAt TEXT NOT NULL DEFAULT (datetime('now'))
);
```
DB methods: `saveQueueState(state)` (upsert), `getQueueState(botId)`, `clearQueueState(botId)`.
### Snapshot (write path)
- Add `PlayQueue.snapshot(): QueueSnapshot` and `PlayQueue.restore(snapshot)`.
- `snapshot` captures `songs` (minus url), `currentIndex`, `mode`.
- `restore` rebuilds `songs`, `currentIndex`, `mode`, and resets the derived `playedIndices`/`history`/`forwardStack` to a clean, consistent state for the restored index.
- `BotInstance` writes the snapshot **debounced (~1 s)** on `stateChange` (queue mutations, track changes, and mode changes already emit `stateChange`). FM mode + fm platform captured alongside.
- When the queue becomes empty (`clear()` with nothing re-added), the row is cleared via `clearQueueState`.
### Restore (read path — "resume and play")
When a bot reaches **connected/ready** (the same lifecycle point `autoStart` uses):
1. If `savedQueuesEnabled` and a `queue_state` row exists → `PlayQueue.restore(...)`, restore FM mode/provider.
2. If there was a current track → `resolveAndPlay(current)` (re-resolves URL, plays **from the track's start**).
**Honest caveats (documented in README + spec):**
- Resumes the **current track from its start**, not the exact millisecond (URLs are re-resolved; no persisted elapsed/seek).
- **Spotify** auto-resume is **best-effort** — it depends on the sidecar/controller being back up; non-Spotify sources are reliable.
- Resume only happens for bots that reach the connected state (auto-started, or on next manual start).
---
## Feature 3 — `playKeepsQueue` (independent single-play toggle)
**Independent** config flag, **not** gated by `savedQueuesEnabled`. Default false → today's behavior.
Affects **single-song immediate play only**: chat `!play <song | #N | id:<id> | URL>` and the WebUI play-now / play-by-id path.
| `playKeepsQueue` | `!play <song>` behavior |
| --- | --- |
| `false` (default) | `queue.clear()` → play only that song (today) |
| `true` | `addNext(song)` (insert after current) → `playAt(insertedAt)` (jump & play now) → `resolveAndPlay`; queue **kept**; on track-end, `next()` continues the queue |
- Reuses existing `PlayQueue.addNext` + `playAt` — **no new queue logic.**
- **Not** applied to collection loads — `!playlist` / `!album` / `!artist` / `!fm` still replace the queue (loading a collection is meant to replace; the request is about 单曲/single songs).
- **Empty queue** → equivalent to a normal play (nothing to preserve).
- **FM mode** → `!play` still exits FM (manual takeover), but existing queued songs are preserved and continue after the single song (auto-refill stops because FM is off). Documented.
---
## Config
Add to `BotConfig` (in `src/data/config.ts`), both **default false**, both sanitized on load exactly like `localAudioEnabled` / `autoPauseOnEmpty` (so a hand-edited / legacy / corrupt `config.json` can never silently enable them):
```ts
savedQueuesEnabled: boolean; // default false — gates Features 1 & 2 (admin-controlled)
playKeepsQueue: boolean; // default false — independent (Feature 3)
```
- Set via **Settings → 行为设置** (the existing admin behavior-settings surface, written through `POST /api/bot/settings`).
- When `savedQueuesEnabled` is false: chat save/load/queues reply "此功能未启用"; `/api/saved-queues/*` return 403/404; the WebUI page/nav is hidden; no snapshotting; no auto-restore.
## Error handling & edge cases
- Corrupt `songs` JSON blob → treated as empty list; never throws into a route or the restore path.
- Save with a duplicate name → upsert (overwrite), not an error.
- Load/delete of a non-owned private queue → 404.
- Caps exceeded → 4xx with a clear message (web) / friendly reply (chat).
- Snapshot writes are best-effort and debounced; a DB write failure logs and never interrupts playback.
- Restore of a Spotify-containing queue → best-effort per source; failures skip to next (existing `resolveAndPlay` skip behavior).
## Testing (TDD)
- **DB:** `saveQueue` upsert + caps; `listSavedQueues` own vs shared; `getSavedQueue`/`deleteSavedQueue`; ownership; `queue_state` upsert/get/clear; JSON round-trip + corrupt-blob degradation.
- **PlayQueue:** `snapshot`/`restore` round-trip (songs, index, mode; derived state consistent).
- **BotInstance:** `!save`/`!load`/`!load -a`/`!queues`; feature-disabled replies; snapshot-on-stateChange (debounced); resume-on-ready; `playKeepsQueue` insert-and-jump vs clear; collections still replace; FM interaction.
- **Web API:** auth + capability + feature-gate (403/404); save (own/shared); load replace/append; delete ownership; caps.
- **Config:** defaults false; load sanitization (legacy/corrupt/non-boolean → false).
- **Frontend:** Saved Queues page (save w/ shared toggle, load, append, delete, hidden when disabled); store/composable.
- Then: full suite (`npx vitest run --no-file-parallelism`) + `npx tsc --noEmit` + `cd web && npm run build`.
## Rollout / staging
Implement in stages (each independently valuable, all default-off):
1. **Config + gates** — `savedQueuesEnabled`, `playKeepsQueue`, sanitization, Settings UI.
2. **Feature 3** — `playKeepsQueue` single-play behavior (small, self-contained).
3. **Feature 1** — named save/load (DB → API → chat → WebUI page).
4. **Feature 2** — live-queue snapshot + resume-on-restart.
5. **Docs** — README (commands, toggles, caveats).
+8
View File
@@ -47,5 +47,13 @@
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^6.0.2", "typescript": "^6.0.2",
"vitest": "^4.1.2" "vitest": "^4.1.2"
},
"allowScripts": {
"@discordjs/opus@0.10.0": true,
"better-sqlite3@12.11.1": true,
"cpu-features@0.0.10": true,
"esbuild@0.28.1": true,
"ffmpeg-static@5.3.0": true,
"ssh2@1.17.0": true
} }
} }
+41
View File
@@ -564,4 +564,45 @@ describe("PlayQueue", () => {
} }
}); });
}); });
describe("snapshot / restore (#119)", () => {
it("round-trips songs, index, and mode; strips url", () => {
const q = new PlayQueue();
q.add(makeSong("A"));
q.add(makeSong("B"));
q.setMode(PlayMode.Loop);
q.play();
q.next(); // current = index 1
const snap = q.snapshot();
expect(snap.currentIndex).toBe(1);
expect(snap.mode).toBe(PlayMode.Loop);
expect((snap.songs[0] as QueuedSong).url).toBeUndefined();
expect(snap.songs.map((s) => s.id)).toEqual(["A", "B"]);
const q2 = new PlayQueue();
q2.restore(snap);
expect(q2.list().map((s) => s.id)).toEqual(["A", "B"]);
expect(q2.getCurrentIndex()).toBe(1);
expect(q2.getMode()).toBe(PlayMode.Loop);
expect(q2.current()?.id).toBe("B");
});
it("preserves requestedBy through a snapshot", () => {
const q = new PlayQueue();
q.add({ ...makeSong("A"), requestedBy: "alice" });
q.play();
const q2 = new PlayQueue();
q2.restore(q.snapshot());
expect(q2.current()?.requestedBy).toBe("alice");
});
it("degrades an out-of-range index to -1 (nothing current)", () => {
const q = new PlayQueue();
const { url: _url, ...noUrl } = makeSong("A");
q.restore({ songs: [noUrl], currentIndex: 5, mode: PlayMode.Sequential });
expect(q.getCurrentIndex()).toBe(-1);
expect(q.current()).toBeNull();
expect(q.list().map((s) => s.id)).toEqual(["A"]);
});
});
}); });
+42 -1
View File
@@ -10,10 +10,23 @@ export interface QueuedSong {
name: string; name: string;
artist: string; artist: string;
album: string; album: string;
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify"; platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify" | "jellyfin";
url?: string; // resolved lazily at play time url?: string; // resolved lazily at play time
coverUrl: string; coverUrl: string;
duration: number; // seconds duration: number; // seconds
requestedBy?: string;
}
/**
* A persistable view of a queue: its songs (minus the lazily-resolved `url`),
* the current index, and the play mode. Used to snapshot/restore the live queue
* across restarts (issue #119). Derived state (playedIndices/history/forward
* stack) is intentionally NOT captured — restore() rebuilds it consistently.
*/
export interface QueueSnapshot {
songs: Omit<QueuedSong, "url">[];
currentIndex: number;
mode: PlayMode;
} }
export class PlayQueue { export class PlayQueue {
@@ -272,4 +285,32 @@ export class PlayQueue {
unplayedCount(): number { unplayedCount(): number {
return this.songs.length - this.playedIndices.size; return this.songs.length - this.playedIndices.size;
} }
/**
* Capture the queue as a persistable snapshot (songs minus `url`, current
* index, mode). Songs keep their `requestedBy` so restored play-history
* attribution stays correct. See restore().
*/
snapshot(): QueueSnapshot {
return {
songs: this.songs.map(({ url: _url, ...s }) => s),
currentIndex: this.currentIndex,
mode: this.mode,
};
}
/**
* Replace the queue contents from a snapshot. Rebuilds the derived
* playedIndices/history/forwardStack to a clean, consistent state for the
* restored index (an out-of-range index degrades to -1 = "nothing current").
*/
restore(s: QueueSnapshot): void {
this.songs = s.songs.map((song) => ({ ...song }));
this.mode = s.mode;
this.currentIndex =
s.currentIndex >= 0 && s.currentIndex < this.songs.length ? s.currentIndex : -1;
this.playedIndices = new Set(this.currentIndex >= 0 ? [this.currentIndex] : []);
this.history = [];
this.forwardStack = [];
}
} }
+476
View File
@@ -1,6 +1,9 @@
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { BotInstance, COMMAND_DENIED_MESSAGE, spotifyPortsForBotId } from "./instance.js"; import { BotInstance, COMMAND_DENIED_MESSAGE, spotifyPortsForBotId } from "./instance.js";
import type { BotInstanceOptions } from "./instance.js"; import type { BotInstanceOptions } from "./instance.js";
import { PlayQueue, PlayMode } from "../audio/queue.js";
import { createDatabase, SHARED_QUEUE_OWNER } from "../data/database.js";
import { parseCommand } from "./commands.js";
import type { TS3TextMessage } from "../ts-protocol/client.js"; import type { TS3TextMessage } from "../ts-protocol/client.js";
import type { SpotifyController } from "../music/spotify/controller.js"; import type { SpotifyController } from "../music/spotify/controller.js";
import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js"; import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js";
@@ -832,6 +835,9 @@ describe("BotInstance — spotifyOAuth threading to the controller factory (C3.1
const database = { const database = {
getProfileConfig: () => ({}), getProfileConfig: () => ({}),
getCustomAvatarPath: () => null, getCustomAvatarPath: () => null,
getPlayerSettings: () => ({ volume: 75, playMode: "seq" }),
saveVolume: () => {},
savePlayMode: () => {},
} as unknown as BotDatabase; } as unknown as BotDatabase;
const options: BotInstanceOptions = { const options: BotInstanceOptions = {
id: "bot-oauth-test", id: "bot-oauth-test",
@@ -911,3 +917,473 @@ 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: [] });
const longResponse = Array.from(
{ length: 200 },
(_, i) => `歌词 line number ${i} with some content`,
).join("\n");
ctx.executeCommand = vi.fn(async () => longResponse);
await handleTextMessage.call(ctx, makeMsg("!lyrics"));
const calls = ctx.tsClient.sendTextMessage.mock.calls;
expect(calls.length).toBeGreaterThan(1);
for (const [chunk] of calls) {
expect(Buffer.byteLength(chunk as string, "utf8")).toBeLessThanOrEqual(900);
}
});
it("sends a short command response as a single message", async () => {
const ctx = makeGateCtx({ adminGroups: [] });
ctx.executeCommand = vi.fn(async () => "short reply");
await handleTextMessage.call(ctx, makeMsg("!lyrics"));
expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledTimes(1);
expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledWith("short reply");
});
});
const cmdLyrics = (BotInstance.prototype as any).cmdLyrics as (
this: unknown,
) => Promise<string>;
describe("BotInstance.cmdLyrics — full lyrics (#116)", () => {
it("returns ALL lyric lines, not just the first 10", async () => {
const lyricLines = Array.from({ length: 30 }, (_, i) => ({
time: i,
text: `lyric line ${i}`,
}));
const ctx: any = {
queue: { current: () => ({ id: "s1", name: "Song", platform: "netease" }) },
getProviderFor: () => ({ getLyrics: vi.fn(async () => lyricLines) }),
};
const out = await cmdLyrics.call(ctx);
for (const l of lyricLines) {
expect(out).toContain(l.text);
}
expect(out.startsWith("Lyrics for Song:")).toBe(true);
});
it("returns 'No lyrics available' when the provider has none", async () => {
const ctx: any = {
queue: { current: () => ({ id: "s1", name: "Song", platform: "netease" }) },
getProviderFor: () => ({ getLyrics: vi.fn(async () => []) }),
};
expect(await cmdLyrics.call(ctx)).toBe("No lyrics available");
});
});
// ─── Saved queues + live-queue persistence + playKeepsQueue (#119) ─────────
// All exercise the ACTUAL shipped methods via their prototype, bound to a
// minimal ctx — the same lightweight pattern as the cmd* tests above.
const playSingleSong = BotInstance.prototype.playSingleSong as (
this: unknown,
song: unknown,
requesterName?: string,
) => Promise<boolean>;
const loadSavedQueue = BotInstance.prototype.loadSavedQueue as (
this: unknown,
songs: unknown[],
mode: "replace" | "append",
requesterName?: string,
) => Promise<void>;
const cmdSaveQueue = (BotInstance.prototype as any).cmdSaveQueue as (this: unknown, cmd: any) => string;
const cmdLoadQueue = (BotInstance.prototype as any).cmdLoadQueue as (this: unknown, cmd: any) => Promise<string>;
const cmdListQueues = (BotInstance.prototype as any).cmdListQueues as (this: unknown) => string;
const persistQueueSnapshot = (BotInstance.prototype as any).persistQueueSnapshot as (this: unknown) => void;
const scheduleQueueSnapshot = (BotInstance.prototype as any).scheduleQueueSnapshot as (this: unknown) => void;
const restoreQueueFromSnapshot = (BotInstance.prototype as any).restoreQueueFromSnapshot as (this: unknown) => Promise<void>;
const withRequester = (BotInstance.prototype as any).withRequester;
const isSameSong = (BotInstance.prototype as any).isSameSong;
const savedQueuesGuard = (BotInstance.prototype as any).savedQueuesGuard;
function song119(id: string) {
return { id, name: id, artist: "", album: "", platform: "netease" as const, coverUrl: "", duration: 1 };
}
function makePlayer119() {
let state: "idle" | "playing" | "paused" = "idle";
return {
stop: vi.fn(() => { state = "idle"; }),
resetFailures: vi.fn(),
getState: vi.fn(() => state),
_play: () => { state = "playing"; },
};
}
describe("BotInstance.playSingleSong / playKeepsQueue (#119)", () => {
function makeCtx(playKeepsQueue: boolean) {
const queue = new PlayQueue();
return {
config: { playKeepsQueue },
queue,
player: makePlayer119(),
withRequester,
isSameSong,
disableFmMode: vi.fn(),
sweepLocalAudio: vi.fn(),
resolveAndPlay: vi.fn(async () => true),
} as any;
}
it("clears the queue when playKeepsQueue is false (default)", async () => {
const ctx = makeCtx(false);
ctx.queue.add(song119("a"));
ctx.queue.play();
const ok = await playSingleSong.call(ctx, song119("b"), "alice");
expect(ok).toBe(true);
expect(ctx.queue.list().map((s: any) => s.id)).toEqual(["b"]);
expect(ctx.queue.current()?.id).toBe("b");
expect(ctx.sweepLocalAudio).toHaveBeenCalled();
});
it("inserts-after-current and keeps the queue when playKeepsQueue is true", async () => {
const ctx = makeCtx(true);
ctx.queue.add(song119("a"));
ctx.queue.add(song119("c"));
ctx.queue.play(); // current = a (index 0)
await playSingleSong.call(ctx, song119("b"), "alice");
expect(ctx.queue.list().map((s: any) => s.id)).toEqual(["a", "b", "c"]);
expect(ctx.queue.current()?.id).toBe("b");
expect(ctx.queue.current()?.requestedBy).toBe("alice");
// Keep-queue mode must not sweep local uploads (nothing was released).
expect(ctx.sweepLocalAudio).not.toHaveBeenCalled();
});
it("falls back to clear-and-play when playKeepsQueue is true but the queue is empty", async () => {
const ctx = makeCtx(true);
await playSingleSong.call(ctx, song119("b"), "alice");
expect(ctx.queue.list().map((s: any) => s.id)).toEqual(["b"]);
expect(ctx.queue.current()?.id).toBe("b");
});
});
describe("BotInstance.loadSavedQueue (#119)", () => {
function makeCtx() {
const player = makePlayer119();
return {
queue: new PlayQueue(),
player,
withRequester,
disableFmMode: vi.fn(),
sweepLocalAudio: vi.fn(),
resolveAndPlay: vi.fn(async () => { player._play(); return true; }),
emit: vi.fn(),
} as any;
}
it("replace clears + plays from the first track", async () => {
const ctx = makeCtx();
ctx.queue.add(song119("old"));
ctx.queue.play();
await loadSavedQueue.call(ctx, [song119("a"), song119("b")], "replace", "bob");
expect(ctx.queue.list().map((s: any) => s.id)).toEqual(["a", "b"]);
expect(ctx.queue.current()?.id).toBe("a");
expect(ctx.queue.current()?.requestedBy).toBe("bob");
expect(ctx.disableFmMode).toHaveBeenCalled();
expect(ctx.resolveAndPlay).toHaveBeenCalled();
expect(ctx.emit).toHaveBeenCalledWith("stateChange");
});
it("append adds to the end and starts playing only when idle", async () => {
const ctx = makeCtx();
// Idle bot with an existing (not playing) queue entry.
ctx.queue.add(song119("x"));
await loadSavedQueue.call(ctx, [song119("a"), song119("b")], "append");
expect(ctx.queue.list().map((s: any) => s.id)).toEqual(["x", "a", "b"]);
// wasIdle → start the first appended song (index 1).
expect(ctx.queue.current()?.id).toBe("a");
expect(ctx.resolveAndPlay).toHaveBeenCalledTimes(1);
});
it("append does not interrupt a playing track", async () => {
const ctx = makeCtx();
ctx.player._play(); // player is 'playing'
ctx.queue.add(song119("x"));
ctx.queue.play(); // current = x
await loadSavedQueue.call(ctx, [song119("a")], "append");
expect(ctx.queue.list().map((s: any) => s.id)).toEqual(["x", "a"]);
expect(ctx.queue.current()?.id).toBe("x");
expect(ctx.resolveAndPlay).not.toHaveBeenCalled();
});
});
describe("BotInstance chat save/load/queues (#119)", () => {
function makeCtx(enabled: boolean, db = createDatabase(":memory:")) {
const queue = new PlayQueue();
return {
config: { savedQueuesEnabled: enabled, commandPrefix: "!" },
queue,
database: db,
savedQueuesGuard,
loadSavedQueue: vi.fn(async () => {}),
} as any;
}
it("replies 此功能未启用 when the feature is disabled", () => {
const ctx = makeCtx(false);
expect(cmdSaveQueue.call(ctx, parseCommand("!save night", "!")!)).toBe("此功能未启用");
expect(cmdListQueues.call(ctx)).toBe("此功能未启用");
});
it("refuses saving an empty queue", () => {
const ctx = makeCtx(true);
expect(cmdSaveQueue.call(ctx, parseCommand("!save night", "!")!)).toBe("队列为空,无法保存");
});
it("saves the current queue to the shared bucket and lists it", () => {
const ctx = makeCtx(true);
ctx.queue.add(song119("a"));
ctx.queue.add(song119("b"));
const reply = cmdSaveQueue.call(ctx, parseCommand("!save night", "!")!);
expect(reply).toContain("已保存队列");
expect(ctx.database.listSavedQueues(SHARED_QUEUE_OWNER, false).map((x: any) => x.name)).toContain("night");
expect(cmdListQueues.call(ctx)).toContain("night");
});
it("loads a saved queue by name (replace by default, -a appends)", async () => {
const db = createDatabase(":memory:");
const ctx = makeCtx(true, db);
db.saveQueue(SHARED_QUEUE_OWNER, "night", [song119("a")]);
const rep = await cmdLoadQueue.call(ctx, parseCommand("!load night", "!")!);
expect(rep).toContain("已加载");
expect(ctx.loadSavedQueue).toHaveBeenCalledWith(expect.any(Array), "replace");
const repA = await cmdLoadQueue.call(ctx, parseCommand("!load -a night", "!")!);
expect(repA).toContain("已追加");
expect(ctx.loadSavedQueue).toHaveBeenLastCalledWith(expect.any(Array), "append");
});
it("reports a missing saved queue", async () => {
const ctx = makeCtx(true);
expect(await cmdLoadQueue.call(ctx, parseCommand("!load nope", "!")!)).toContain("找不到");
});
});
describe("BotInstance live-queue persistence (#119)", () => {
function makeCtx(enabled: boolean, db = createDatabase(":memory:")) {
return {
id: "bot1",
config: { savedQueuesEnabled: enabled },
queue: new PlayQueue(),
database: db,
isFmMode: false,
fmProvider: null,
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
player: makePlayer119(),
resolveAndPlay: vi.fn(async () => true),
getProviderFor: vi.fn(() => ({ platform: "netease" })),
} as any;
}
it("persists a snapshot when enabled", () => {
const ctx = makeCtx(true);
ctx.queue.add(song119("a"));
ctx.queue.play();
persistQueueSnapshot.call(ctx);
const st = ctx.database.getQueueState("bot1")!;
expect(st.songs.map((s: any) => s.id)).toEqual(["a"]);
expect(st.currentIndex).toBe(0);
});
it("does NOT persist when the feature is disabled", () => {
const ctx = makeCtx(false);
ctx.queue.add(song119("a"));
ctx.queue.play();
persistQueueSnapshot.call(ctx);
expect(ctx.database.getQueueState("bot1")).toBeNull();
});
it("clears the persisted row when the queue is empty", () => {
const db = createDatabase(":memory:");
db.saveQueueState({ botId: "bot1", songs: [song119("a")], currentIndex: 0, mode: "seq", isFmMode: false, fmPlatform: "" });
const ctx = makeCtx(true, db);
persistQueueSnapshot.call(ctx); // queue is empty
expect(db.getQueueState("bot1")).toBeNull();
});
it("restores and resumes the current track on restore", async () => {
const db = createDatabase(":memory:");
db.saveQueueState({ botId: "bot1", songs: [song119("a"), song119("b")], currentIndex: 1, mode: "loop", isFmMode: false, fmPlatform: "" });
const ctx = makeCtx(true, db);
await restoreQueueFromSnapshot.call(ctx);
expect(ctx.queue.list().map((s: any) => s.id)).toEqual(["a", "b"]);
expect(ctx.queue.getCurrentIndex()).toBe(1);
expect(ctx.queue.getMode()).toBe(PlayMode.Loop);
expect(ctx.resolveAndPlay).toHaveBeenCalledTimes(1);
});
it("restores FM mode + provider from the snapshot", async () => {
const db = createDatabase(":memory:");
db.saveQueueState({ botId: "bot1", songs: [song119("a")], currentIndex: 0, mode: "random", isFmMode: true, fmPlatform: "qq" });
const ctx = makeCtx(true, db);
await restoreQueueFromSnapshot.call(ctx);
expect(ctx.isFmMode).toBe(true);
expect(ctx.getProviderFor).toHaveBeenCalledWith("qq");
});
it("does nothing when the feature is disabled", async () => {
const db = createDatabase(":memory:");
db.saveQueueState({ botId: "bot1", songs: [song119("a")], currentIndex: 0, mode: "seq", isFmMode: false, fmPlatform: "" });
const ctx = makeCtx(false, db);
await restoreQueueFromSnapshot.call(ctx);
expect(ctx.queue.list()).toEqual([]);
expect(ctx.resolveAndPlay).not.toHaveBeenCalled();
});
it("a cancelled snapshot timer does not wipe persisted state (disconnect race)", () => {
vi.useFakeTimers();
try {
const db = createDatabase(":memory:");
db.saveQueueState({ botId: "bot1", songs: [song119("a")], currentIndex: 0, mode: "seq", isFmMode: false, fmPlatform: "" });
const ctx = makeCtx(true, db);
ctx.queue.add(song119("a"));
ctx.queue.play();
// Debounced snapshot scheduled, then a disconnect clears the queue and
// cancels the pending timer — the persisted row must survive for restore.
scheduleQueueSnapshot.call(ctx);
ctx.queue.clear();
if (ctx.snapshotTimer) clearTimeout(ctx.snapshotTimer);
vi.advanceTimersByTime(3000);
expect(db.getQueueState("bot1")).not.toBeNull();
} finally {
vi.useRealTimers();
}
});
});
+458 -65
View File
@@ -6,16 +6,23 @@ import {
} from "../ts-protocol/client.js"; } from "../ts-protocol/client.js";
import { AudioPlayer } from "../audio/player.js"; import { AudioPlayer } from "../audio/player.js";
import { PlayQueue, PlayMode, type QueuedSong } from "../audio/queue.js"; import { PlayQueue, PlayMode, type QueuedSong } from "../audio/queue.js";
import type { MusicProvider, Song } from "../music/provider.js"; import type { MusicProvider, Platform, Song } from "../music/provider.js";
import { import {
parseCommand, parseCommand,
canRunCommand, canRunCommand,
type ParsedCommand, type ParsedCommand,
} from "./commands.js"; } from "./commands.js";
import { parseSongRef, parseSelectionIndex } from "./song-ref.js"; import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
import { splitTextIntoChunks } from "./text-chunk.js";
import type { Logger } from "../logger.js"; import type { Logger } from "../logger.js";
import type { BotDatabase, ProfileConfig } from "../data/database.js"; import { SHARED_QUEUE_OWNER, type BotDatabase, type ProfileConfig, type StoredSong } from "../data/database.js";
import type { BotConfig, SpotifyConfig } from "../data/config.js"; import {
isProviderEnabled,
defaultPlatform,
type BotConfig,
type SpotifyConfig,
} from "../data/config.js";
import type { JellyfinPlaybackReporter } from "../music/jellyfin.js";
import { BotProfileManager } from "./profile.js"; import { BotProfileManager } from "./profile.js";
import type { AvatarStore } from "../data/avatars.js"; import type { AvatarStore } from "../data/avatars.js";
import { import {
@@ -32,6 +39,15 @@ import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js";
/** Reply sent when a non-admin invokes an admin-only chat command. */ /** Reply sent when a non-admin invokes an admin-only chat command. */
export const COMMAND_DENIED_MESSAGE = "⛔ 需要管理员权限(该命令仅限管理员服务器组)"; 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 /** Fallback message when Spotify audio can't be served (backend unavailable
* OR a per-track playTrack failure against a dead/failed sidecar). */ * OR a per-track playTrack failure against a dead/failed sidecar). */
const SPOTIFY_UNAVAILABLE_MESSAGE = const SPOTIFY_UNAVAILABLE_MESSAGE =
@@ -79,6 +95,7 @@ export interface BotInstanceOptions {
localProvider?: MusicProvider; localProvider?: MusicProvider;
kugouProvider?: MusicProvider; kugouProvider?: MusicProvider;
spotifyProvider?: MusicProvider; spotifyProvider?: MusicProvider;
jellyfinProvider?: MusicProvider;
database: BotDatabase; database: BotDatabase;
config: BotConfig; config: BotConfig;
logger: Logger; logger: Logger;
@@ -131,6 +148,7 @@ export class BotInstance extends EventEmitter {
private localProvider: MusicProvider; private localProvider: MusicProvider;
private kugouProvider: MusicProvider; private kugouProvider: MusicProvider;
private spotifyProvider: MusicProvider; private spotifyProvider: MusicProvider;
private jellyfinProvider: MusicProvider;
private database: BotDatabase; private database: BotDatabase;
private config: BotConfig; private config: BotConfig;
private logger: Logger; private logger: Logger;
@@ -148,11 +166,17 @@ export class BotInstance extends EventEmitter {
private profileManager: BotProfileManager; private profileManager: BotProfileManager;
private isFmMode = false; private isFmMode = false;
private fmProvider: MusicProvider | null = null; private fmProvider: MusicProvider | null = null;
private fmRequesterName: string | undefined;
/** Results of the most recent !search, for "#N" selection (issue #90). */ /** Results of the most recent !search, for "#N" selection (issue #90). */
private lastSearchResults: Song[] = []; private lastSearchResults: Song[] = [];
/** 当前曲实际播放时长(试听片段秒数或完整 duration);resolveAndPlay 赋值。 */ /** 当前曲实际播放时长(试听片段秒数或完整 duration);resolveAndPlay 赋值。 */
private effectiveDuration: number | undefined; private effectiveDuration: number | undefined;
private playGate: Promise<unknown> = Promise.resolve(); private playGate: Promise<unknown> = Promise.resolve();
/** Per-bot Jellyfin playback-report session (start / ~10s progress / stop).
* null when the wired provider has no reporting capability. */
private jellyfinReporter: JellyfinPlaybackReporter | null = null;
/** Debounce handle for the live-queue snapshot writer (Feature 2, #119). */
private snapshotTimer: ReturnType<typeof setTimeout> | null = null;
constructor(options: BotInstanceOptions) { constructor(options: BotInstanceOptions) {
super(); super();
@@ -165,6 +189,7 @@ export class BotInstance extends EventEmitter {
this.localProvider = options.localProvider ?? options.neteaseProvider; this.localProvider = options.localProvider ?? options.neteaseProvider;
this.kugouProvider = options.kugouProvider ?? options.neteaseProvider; this.kugouProvider = options.kugouProvider ?? options.neteaseProvider;
this.spotifyProvider = options.spotifyProvider ?? options.neteaseProvider; this.spotifyProvider = options.spotifyProvider ?? options.neteaseProvider;
this.jellyfinProvider = options.jellyfinProvider ?? options.neteaseProvider;
this.database = options.database; this.database = options.database;
this.config = options.config; this.config = options.config;
this.logger = options.logger.child({ botId: this.id }); this.logger = options.logger.child({ botId: this.id });
@@ -174,6 +199,27 @@ export class BotInstance extends EventEmitter {
this.player = new AudioPlayer(this.logger); this.player = new AudioPlayer(this.logger);
this.queue = new PlayQueue(); 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.
const jfReportable = this.jellyfinProvider as MusicProvider & {
createPlaybackReporter?: () => JellyfinPlaybackReporter;
};
this.jellyfinReporter = jfReportable.createPlaybackReporter?.() ?? null;
// One long-lived Spotify sidecar controller per bot. Construction is // One long-lived Spotify sidecar controller per bot. Construction is
// cheap and side-effect-free — nothing spawns until ensureStarted(). // cheap and side-effect-free — nothing spawns until ensureStarted().
const spotifyBase = const spotifyBase =
@@ -224,6 +270,11 @@ export class BotInstance extends EventEmitter {
this.setupPlayerEvents(); this.setupPlayerEvents();
this.setupTsEvents(); this.setupTsEvents();
// Feature 2 (#119): persist a debounced snapshot of the live queue whenever
// it changes, so it can be restored + resumed after a restart. Inert unless
// config.savedQueuesEnabled is on (checked inside the scheduler).
this.on("stateChange", () => this.scheduleQueueSnapshot());
} }
private setupPlayerEvents(): void { private setupPlayerEvents(): void {
@@ -307,9 +358,19 @@ export class BotInstance extends EventEmitter {
// this.connected was never flipped to true. Previously this handler // this.connected was never flipped to true. Previously this handler
// short-circuited on !this.connected, leaving player stuck as "playing". // short-circuited on !this.connected, leaving player stuck as "playing".
this.connected = false; this.connected = false;
// Cancel any pending live-queue snapshot BEFORE clearing the queue: a
// debounced snapshot firing after clear() would persist an empty queue
// (clearQueueState), wiping the state we want to restore on reconnect —
// and since a manual stop→start reuses the same botId, that would clobber
// the new instance's restored row (#119).
if (this.snapshotTimer) {
clearTimeout(this.snapshotTimer);
this.snapshotTimer = null;
}
this.spotifyController.stop(); this.spotifyController.stop();
this.currentSourceIsSpotify = false; this.currentSourceIsSpotify = false;
this.player.stop(); this.player.stop();
this.jellyfinReporter?.onStop();
this.queue.clear(); this.queue.clear();
this.sweepLocalAudio("disconnected"); this.sweepLocalAudio("disconnected");
// A lifecycle change must not leave a stale auto-resume armed. // A lifecycle change must not leave a stale auto-resume armed.
@@ -325,6 +386,7 @@ export class BotInstance extends EventEmitter {
// Fresh connection — clear any stale auto-pause flag from a prior session. // Fresh connection — clear any stale auto-pause flag from a prior session.
this.autoPaused = false; this.autoPaused = false;
this._startIdlePoller(); this._startIdlePoller();
this._startJellyfinReportPoller();
}); });
// React near-instantly to channel membership changes. The 30s idle // React near-instantly to channel membership changes. The 30s idle
@@ -388,13 +450,26 @@ export class BotInstance extends EventEmitter {
this.connected = true; this.connected = true;
this.profileManager.onConnect(); this.profileManager.onConnect();
this.emit("connected"); this.emit("connected");
// Feature 2 (#119): restore + resume the live queue persisted before the
// last shutdown. Best-effort and gated on savedQueuesEnabled; runs after
// the bot is fully connected so resolveAndPlay can actually push audio.
void this.restoreQueueFromSnapshot();
} }
disconnect(): void { disconnect(): void {
this._cancelIdleTimer(); this._cancelIdleTimer();
// Cancel any pending live-queue snapshot before clearing so it can't fire
// afterwards and persist an empty queue over the state we keep for restore
// (#119). The disconnected handler cancels too, but do it here as well for
// the path where tsClient.disconnect() doesn't re-emit "disconnected".
if (this.snapshotTimer) {
clearTimeout(this.snapshotTimer);
this.snapshotTimer = null;
}
this.spotifyController.stop(); this.spotifyController.stop();
this.currentSourceIsSpotify = false; this.currentSourceIsSpotify = false;
this.player.stop(); this.player.stop();
this.jellyfinReporter?.onStop();
this.queue.clear(); this.queue.clear();
this.sweepLocalAudio("disconnected"); this.sweepLocalAudio("disconnected");
this.connected = false; this.connected = false;
@@ -473,6 +548,29 @@ export class BotInstance extends EventEmitter {
} }
} }
/**
* ~10s Jellyfin progress reporting, following the _startIdlePoller pattern:
* a self-rescheduling timeout guarded by this.connected, so it needs no
* explicit teardown. When the current track is not (or no longer) a jellyfin
* item, onStop() idempotently closes any open report session.
*/
private _startJellyfinReportPoller(): void {
if (!this.jellyfinReporter) return;
const tick = () => {
if (!this.connected) return;
const reporter = this.jellyfinReporter!;
const current = this.queue.current();
const state = this.player.getState();
if (current?.platform === "jellyfin" && (state === "playing" || state === "paused")) {
reporter.onTick(current.id, this.player.getElapsed(), state === "paused");
} else {
reporter.onStop();
}
setTimeout(tick, 10_000);
};
setTimeout(tick, 10_000);
}
private _scheduleIdleCheck(): void { private _scheduleIdleCheck(): void {
if (this.idleTimer !== null) return; // 已经在倒计时,不重复创建 if (this.idleTimer !== null) return; // 已经在倒计时,不重复创建
const minutes = this.config.idleTimeoutMinutes ?? 0; const minutes = this.config.idleTimeoutMinutes ?? 0;
@@ -520,7 +618,11 @@ export class BotInstance extends EventEmitter {
try { try {
const response = await this.executeCommand(parsed, msg); const response = await this.executeCommand(parsed, msg);
if (response) { if (response) {
await this.tsClient.sendTextMessage(response); // A single long reply (e.g. full lyrics) would exceed TeamSpeak's
// per-message byte cap, so split it and send the chunks in order.
for (const chunk of splitTextIntoChunks(response)) {
await this.tsClient.sendTextMessage(chunk);
}
} }
} catch (err) { } catch (err) {
this.logger.error({ err, command: parsed.name }, "Command execution error"); this.logger.error({ err, command: parsed.name }, "Command execution error");
@@ -571,7 +673,8 @@ export class BotInstance extends EventEmitter {
async executeCommand( async executeCommand(
cmd: ParsedCommand, cmd: ParsedCommand,
msg?: TS3TextMessage msg?: TS3TextMessage,
requesterName = this.requesterNameFromMessage(msg),
): Promise<string | null> { ): Promise<string | null> {
// Reject commands that would push audio when the bot isn't connected: // Reject commands that would push audio when the bot isn't connected:
// otherwise ffmpeg spawns and voice goes to a half-initialized or // otherwise ffmpeg spawns and voice goes to a half-initialized or
@@ -599,12 +702,12 @@ export class BotInstance extends EventEmitter {
case "find": case "find":
return this.cmdSearch(cmd); return this.cmdSearch(cmd);
case "play": case "play":
return this.cmdPlay(cmd); return this.cmdPlay(cmd, requesterName);
case "add": case "add":
return this.cmdAdd(cmd); return this.cmdAdd(cmd, requesterName);
case "playnext": case "playnext":
case "pn": case "pn":
return this.cmdPlayNext(cmd); return this.cmdPlayNext(cmd, requesterName);
case "pause": case "pause":
return this.cmdPause(); return this.cmdPause();
case "resume": case "resume":
@@ -630,13 +733,13 @@ export class BotInstance extends EventEmitter {
case "mode": case "mode":
return this.cmdMode(cmd); return this.cmdMode(cmd);
case "playlist": case "playlist":
return this.cmdPlaylist(cmd); return this.cmdPlaylist(cmd, requesterName);
case "album": case "album":
return this.cmdAlbum(cmd); return this.cmdAlbum(cmd, requesterName);
case "fm": case "fm":
return this.cmdFm(cmd); return this.cmdFm(cmd, requesterName);
case "artist": case "artist":
return this.cmdArtist(cmd); return this.cmdArtist(cmd, requesterName);
case "vote": case "vote":
return this.cmdVote(msg); return this.cmdVote(msg);
case "lyrics": case "lyrics":
@@ -645,6 +748,12 @@ export class BotInstance extends EventEmitter {
return this.cmdMove(cmd); return this.cmdMove(cmd);
case "follow": case "follow":
return this.cmdFollow(msg); return this.cmdFollow(msg);
case "save":
return this.cmdSaveQueue(cmd);
case "load":
return this.cmdLoadQueue(cmd);
case "queues":
return this.cmdListQueues();
case "help": case "help":
return this.cmdHelp(); return this.cmdHelp();
default: default:
@@ -652,27 +761,67 @@ export class BotInstance extends EventEmitter {
} }
} }
getProviderFor(platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify"): MusicProvider { getProviderFor(platform: Platform): MusicProvider {
if (platform === "bilibili") return this.bilibiliProvider; if (platform === "bilibili") return this.bilibiliProvider;
if (platform === "youtube") return this.youtubeProvider; if (platform === "youtube") return this.youtubeProvider;
if (platform === "local") return this.localProvider; if (platform === "local") return this.localProvider;
if (platform === "kugou") return this.kugouProvider; if (platform === "kugou") return this.kugouProvider;
if (platform === "spotify") return this.spotifyProvider; if (platform === "spotify") return this.spotifyProvider;
if (platform === "jellyfin") return this.jellyfinProvider;
return platform === "qq" ? this.qqProvider : this.neteaseProvider; return platform === "qq" ? this.qqProvider : this.neteaseProvider;
} }
/** Friendly gate for user-selected platforms (flags / URLs / REST params). */
assertProviderEnabled(platform: Platform): void {
if (!isProviderEnabled(this.config, platform)) {
throw new Error(
`音源未启用:${platform}(provider disabled — 需在配置 enabledProviders 中开启)`,
);
}
}
private disableFmMode(): void { private disableFmMode(): void {
this.isFmMode = false; this.isFmMode = false;
this.fmProvider = null; this.fmProvider = null;
this.fmRequesterName = undefined;
} }
/** Chat-command source flags. No flag → the configured default platform
* (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"],
["y", "youtube"],
["k", "kugou"],
["s", "spotify"],
["n", "netease"],
["j", "jellyfin"],
];
private getProvider(flags: Set<string>): MusicProvider { private getProvider(flags: Set<string>): MusicProvider {
if (flags.has("b")) return this.bilibiliProvider; for (const [flag, platform] of BotInstance.FLAG_PLATFORMS) {
if (flags.has("q")) return this.qqProvider; if (flags.has(flag)) {
if (flags.has("y")) return this.youtubeProvider; this.assertProviderEnabled(platform);
if (flags.has("k")) return this.kugouProvider; return this.getProviderFor(platform);
if (flags.has("s")) return this.spotifyProvider; }
return this.neteaseProvider; }
const def = defaultPlatform(this.config);
this.assertProviderEnabled(def);
return this.getProviderFor(def);
}
private requesterNameFromMessage(msg?: TS3TextMessage): string | undefined {
const name = msg?.invokerName?.trim();
return name || undefined;
}
private withRequester<T extends Song | QueuedSong>(
song: T,
requesterName?: string,
): T & { requestedBy?: string } {
const requestedBy = requesterName?.trim();
return requestedBy ? { ...song, requestedBy } : { ...song };
} }
/** Resolve URL for a song and start playing it. Skips to next if URL fails. */ /** Resolve URL for a song and start playing it. Skips to next if URL fails. */
@@ -772,6 +921,7 @@ export class BotInstance extends EventEmitter {
this.player.resume(); this.player.resume();
} }
this.currentSourceIsSpotify = true; this.currentSourceIsSpotify = true;
this.jellyfinReporter?.onStop();
song.url = result.url; song.url = result.url;
// No trial clip for Spotify — full-track duration only (the near-end // No trial clip for Spotify — full-track duration only (the near-end
// stall watchdog is disabled for the external stream anyway). // stall watchdog is disabled for the external stream anyway).
@@ -785,6 +935,7 @@ export class BotInstance extends EventEmitter {
album: song.album, album: song.album,
platform: song.platform, platform: song.platform,
coverUrl: song.coverUrl, coverUrl: song.coverUrl,
requestedBy: song.requestedBy,
}); });
await this.syncProfileToSong(song); await this.syncProfileToSong(song);
this.emit("stateChange"); this.emit("stateChange");
@@ -801,6 +952,11 @@ export class BotInstance extends EventEmitter {
// 试听片段用试听时长(让 player nearEnd 正确触发自动切歌);完整曲回退 song.duration // 试听片段用试听时长(让 player nearEnd 正确触发自动切歌);完整曲回退 song.duration
this.effectiveDuration = result.trialDuration ?? song.duration; this.effectiveDuration = result.trialDuration ?? song.duration;
this.player.play(result.url, 0, this.effectiveDuration); this.player.play(result.url, 0, this.effectiveDuration);
// Jellyfin playback reporting: open a session for jellyfin tracks (the
// reporter closes the previous one itself); close any open session when
// playback moves to another source. Fire-and-forget — never blocks play.
if (song.platform === "jellyfin") this.jellyfinReporter?.onTrackStart(song.id);
else this.jellyfinReporter?.onStop();
// Fresh playback (re)start — clear auto-pause so a later occupancy // Fresh playback (re)start — clear auto-pause so a later occupancy
// change won't try to "resume" a track the user already restarted. // change won't try to "resume" a track the user already restarted.
this.autoPaused = false; this.autoPaused = false;
@@ -812,6 +968,7 @@ export class BotInstance extends EventEmitter {
album: song.album, album: song.album,
platform: song.platform, platform: song.platform,
coverUrl: song.coverUrl, coverUrl: song.coverUrl,
requestedBy: song.requestedBy,
}); });
// Keep TeamSpeak-side profile updates on the same path for play/next/FM. // Keep TeamSpeak-side profile updates on the same path for play/next/FM.
await this.syncProfileToSong(song); await this.syncProfileToSong(song);
@@ -855,6 +1012,7 @@ export class BotInstance extends EventEmitter {
// 2) id:/URL — fetch that exact song. // 2) id:/URL — fetch that exact song.
const ref = parseSongRef(args); const ref = parseSongRef(args);
if (ref) { if (ref) {
if (ref.platform) this.assertProviderEnabled(ref.platform);
const provider = ref.platform ? this.getProviderFor(ref.platform) : this.getProvider(cmd.flags); const provider = ref.platform ? this.getProviderFor(ref.platform) : this.getProvider(cmd.flags);
const song = await provider.getSongDetail(ref.id); const song = await provider.getSongDetail(ref.id);
if (!song) return { error: `No song found for ${ref.platform ?? provider.platform} id: ${ref.id}` }; if (!song) return { error: `No song found for ${ref.platform ?? provider.platform} id: ${ref.id}` };
@@ -884,39 +1042,108 @@ export class BotInstance extends EventEmitter {
].join("\n"); ].join("\n");
} }
private async cmdPlay(cmd: ParsedCommand): Promise<string> { private async cmdPlay(cmd: ParsedCommand, requesterName?: string): Promise<string> {
if (!cmd.args) return `Usage: ${this.config.commandPrefix}play <song name | #N | id:<id> | URL>`; if (!cmd.args) return `Usage: ${this.config.commandPrefix}play <song name | #N | id:<id> | URL>`;
const { song, error } = await this.resolvePlayQuery(cmd); const { song, error } = await this.resolvePlayQuery(cmd);
if (error) return error; if (error) return error;
const song0 = song!; const song0 = song!;
const ok = await this.playSingleSong(song0, requesterName);
if (!ok) return `Cannot play: ${song0.name}`;
return `Now playing: ${song0.name} - ${song0.artist}`;
}
/**
* Play a single resolved song immediately, honoring config.playKeepsQueue:
* - false (default): clear the queue and play only this song — today's
* behavior. The prior track is stopped and released local uploads swept.
* - true (and the queue is non-empty): insert the song after the current
* track and jump to it (reusing addNext + playAt — no new queue logic), so
* the rest of the queue survives and continues after it. FM auto-refill is
* stopped (manual takeover), but existing queued songs are preserved.
*
* Shared by chat !play and the web /play-song route so the toggle decision
* lives in exactly one place (#119). Returns true if a track started playing.
*/
async playSingleSong(song: QueuedSong, requesterName?: string): Promise<boolean> {
const s = this.withRequester(song, requesterName);
if (this.config.playKeepsQueue && !this.queue.isEmpty()) {
const insertedAt =
this.queue.getCurrentIndex() < 0
? this.queue.size()
: this.queue.getCurrentIndex() + 1;
this.player.stop();
this.disableFmMode();
this.queue.addNext(s);
this.queue.playAt(insertedAt);
this.player.resetFailures();
// No sweep here: the queue is kept, so no local uploads were released.
return this.resolveAndPlay(this.queue.current()!);
}
// Legacy replace behavior (default).
const previous = this.queue.current(); const previous = this.queue.current();
if (previous && !this.isSameSong(previous, song0)) { if (previous && !this.isSameSong(previous, s)) {
this.player.stop(); this.player.stop();
} }
this.queue.clear(); this.queue.clear();
this.disableFmMode(); this.disableFmMode();
this.queue.add({ ...song0 }); this.queue.add(s);
this.queue.play(); this.queue.play();
// Reset failure counter on user-initiated play // Reset failure counter on user-initiated play
this.player.resetFailures(); this.player.resetFailures();
const ok = await this.resolveAndPlay(this.queue.current()!); const ok = await this.resolveAndPlay(this.queue.current()!);
// Sweep AFTER the new song is queued+resolved: the replaced songs are no // Sweep AFTER the new song is queued+resolved: the replaced songs are no
// longer referenced (and get deleted), but song0 — if it is the same local // longer referenced (and get deleted), but the song — if it is the same
// upload that was already playing — stays referenced and is preserved. // local upload that was already playing — stays referenced and is preserved.
this.sweepLocalAudio("replaced"); this.sweepLocalAudio("replaced");
if (!ok) return `Cannot play: ${song0.name}`; return ok;
return `Now playing: ${song0.name} - ${song0.artist}`;
} }
private async cmdAdd(cmd: ParsedCommand): Promise<string> { /**
* Load a saved song list into this bot's queue (#119). `replace` clears +
* plays from the first track (exits FM, like a fresh collection load);
* `append` adds to the end and only starts playing if the bot was idle
* (never interrupts a playing track). Loaded songs are re-tagged with the
* loader's name so play-history attribution stays correct.
*/
async loadSavedQueue(
songs: StoredSong[],
mode: "replace" | "append",
requesterName?: string,
): Promise<void> {
const tagged = songs.map((s) =>
this.withRequester({ ...(s as QueuedSong) }, requesterName),
);
if (mode === "replace") {
this.player.stop();
this.queue.clear();
this.disableFmMode();
for (const s of tagged) this.queue.add(s);
this.sweepLocalAudio("queue_replaced");
const first = this.queue.play();
this.player.resetFailures();
if (first) await this.resolveAndPlay(first);
} else {
const wasIdle = this.player.getState() === "idle";
const startAt = this.queue.size();
for (const s of tagged) this.queue.add(s);
if (wasIdle && this.queue.size() > startAt) {
this.queue.playAt(startAt);
this.player.resetFailures();
await this.resolveAndPlay(this.queue.current()!);
}
}
this.emit("stateChange");
}
private async cmdAdd(cmd: ParsedCommand, requesterName?: string): Promise<string> {
if (!cmd.args) return `Usage: ${this.config.commandPrefix}add <song name | #N | id:<id> | URL>`; if (!cmd.args) return `Usage: ${this.config.commandPrefix}add <song name | #N | id:<id> | URL>`;
const { song, error } = await this.resolvePlayQuery(cmd); const { song, error } = await this.resolvePlayQuery(cmd);
if (error) return error; if (error) return error;
const s = song!; const s = song!;
const wasIdle = this.player.getState() === "idle"; const wasIdle = this.player.getState() === "idle";
this.queue.add({ ...s }); this.queue.add(this.withRequester(s, requesterName));
// If nothing was playing, start this newly-added song immediately. // If nothing was playing, start this newly-added song immediately.
// Matches /api/player/:id/add-by-id behavior so both add paths feel // Matches /api/player/:id/add-by-id behavior so both add paths feel
@@ -933,7 +1160,7 @@ export class BotInstance extends EventEmitter {
return `Added to queue: ${s.name} - ${s.artist} (position ${this.queue.size()})`; return `Added to queue: ${s.name} - ${s.artist} (position ${this.queue.size()})`;
} }
private async cmdPlayNext(cmd: ParsedCommand): Promise<string> { private async cmdPlayNext(cmd: ParsedCommand, requesterName?: string): Promise<string> {
if (!cmd.args) return `Usage: ${this.config.commandPrefix}playnext <song name | #N | id:<id> | URL>`; if (!cmd.args) return `Usage: ${this.config.commandPrefix}playnext <song name | #N | id:<id> | URL>`;
const { song, error } = await this.resolvePlayQuery(cmd); const { song, error } = await this.resolvePlayQuery(cmd);
if (error) return error; if (error) return error;
@@ -949,7 +1176,7 @@ export class BotInstance extends EventEmitter {
this.queue.getCurrentIndex() < 0 this.queue.getCurrentIndex() < 0
? this.queue.size() ? this.queue.size()
: this.queue.getCurrentIndex() + 1; : this.queue.getCurrentIndex() + 1;
this.queue.addNext({ ...s }); this.queue.addNext(this.withRequester(s, requesterName));
if (wasIdle) { if (wasIdle) {
this.queue.playAt(insertedAt); this.queue.playAt(insertedAt);
@@ -996,6 +1223,7 @@ export class BotInstance extends EventEmitter {
} }
this.currentSourceIsSpotify = false; this.currentSourceIsSpotify = false;
this.player.stop(); this.player.stop();
this.jellyfinReporter?.onStop();
this.autoPaused = false; this.autoPaused = false;
this.queue.clear(); this.queue.clear();
this.sweepLocalAudio("stopped"); this.sweepLocalAudio("stopped");
@@ -1033,10 +1261,36 @@ export class BotInstance extends EventEmitter {
const vol = parseInt(cmd.args, 10); const vol = parseInt(cmd.args, 10);
if (isNaN(vol) || vol < 0 || vol > 100) return "Usage: !vol <0-100>"; if (isNaN(vol) || vol < 0 || vol > 100) return "Usage: !vol <0-100>";
this.player.setVolume(vol); 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"); this.emit("stateChange");
return `Volume set to ${vol}%`; 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 { private cmdNow(): string {
const song = this.queue.current(); const song = this.queue.current();
if (!song) return "Nothing is playing"; if (!song) return "Nothing is playing";
@@ -1058,6 +1312,7 @@ export class BotInstance extends EventEmitter {
this.spotifyController.stop(); this.spotifyController.stop();
this.currentSourceIsSpotify = false; this.currentSourceIsSpotify = false;
this.player.stop(); this.player.stop();
this.jellyfinReporter?.onStop();
this.queue.clear(); this.queue.clear();
this.sweepLocalAudio("queue_cleared"); this.sweepLocalAudio("queue_cleared");
this.disableFmMode(); this.disableFmMode();
@@ -1101,31 +1356,28 @@ export class BotInstance extends EventEmitter {
} }
private cmdMode(cmd: ParsedCommand): string { private cmdMode(cmd: ParsedCommand): string {
const modeMap: Record<string, PlayMode> = { const mode = PLAY_MODE_BY_VALUE[cmd.args];
seq: PlayMode.Sequential,
loop: PlayMode.Loop,
random: PlayMode.Random,
rloop: PlayMode.RandomLoop,
};
const mode = modeMap[cmd.args];
if (mode === undefined) return "Usage: !mode <seq|loop|random|rloop>"; if (mode === undefined) return "Usage: !mode <seq|loop|random|rloop>";
this.queue.setMode(mode); 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"); this.emit("stateChange");
return `Play mode set to: ${cmd.args}`; return `Play mode set to: ${cmd.args}`;
} }
private async cmdPlaylist(cmd: ParsedCommand): Promise<string> { private async cmdPlaylist(cmd: ParsedCommand, requesterName?: string): Promise<string> {
if (!cmd.args) return "Usage: !playlist <playlist name or ID>"; if (!cmd.args) return "Usage: !playlist <playlist name or ID>";
const provider = this.getProvider(cmd.flags); const provider = this.getProvider(cmd.flags);
// Determine if input is a numeric ID or a name search // Determine if input is a direct ID (numeric / Jellyfin GUID) or a name search
const id = this.extractId(cmd.args); const id = this.extractId(cmd.args);
const isNumericId = /^\d+$/.test(cmd.args.trim()); const isDirectId = this.looksLikeCollectionId(cmd.args);
let playlistId: string; let playlistId: string;
if (isNumericId || id !== cmd.args) { if (isDirectId || id !== cmd.args) {
// Input is a numeric ID or URL containing an ID — use existing logic // Input is a direct ID or URL containing an ID — use existing logic
playlistId = id; playlistId = id;
} else { } else {
// Name-based search // Name-based search
@@ -1159,7 +1411,7 @@ export class BotInstance extends EventEmitter {
this.queue.clear(); this.queue.clear();
this.disableFmMode(); this.disableFmMode();
for (const song of songs) { for (const song of songs) {
this.queue.add({ ...song, platform: provider.platform }); this.queue.add(this.withRequester({ ...song, platform: provider.platform }, requesterName));
} }
const first = this.queue.play(); const first = this.queue.play();
if (first) await this.resolveAndPlay(first); if (first) await this.resolveAndPlay(first);
@@ -1168,17 +1420,17 @@ export class BotInstance extends EventEmitter {
return `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`; return `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`;
} }
private async cmdAlbum(cmd: ParsedCommand): Promise<string> { private async cmdAlbum(cmd: ParsedCommand, requesterName?: string): Promise<string> {
if (!cmd.args) return "Usage: !album <album name or ID>"; if (!cmd.args) return "Usage: !album <album name or ID>";
const provider = this.getProvider(cmd.flags); const provider = this.getProvider(cmd.flags);
const id = this.extractId(cmd.args); const id = this.extractId(cmd.args);
const isNumericId = /^\d+$/.test(cmd.args.trim()); const isDirectId = this.looksLikeCollectionId(cmd.args);
let albumId: string; let albumId: string;
if (isNumericId || id !== cmd.args) { if (isDirectId || id !== cmd.args) {
// Input is a numeric ID or URL containing an ID — use directly // Input is a direct ID (numeric / Jellyfin GUID) or URL containing an ID — use directly
albumId = id; albumId = id;
} else { } else {
// Name-based search // Name-based search
@@ -1196,7 +1448,7 @@ export class BotInstance extends EventEmitter {
this.queue.clear(); this.queue.clear();
this.disableFmMode(); this.disableFmMode();
for (const song of songs) { for (const song of songs) {
this.queue.add({ ...song, platform: provider.platform }); this.queue.add(this.withRequester({ ...song, platform: provider.platform }, requesterName));
} }
const first = this.queue.play(); const first = this.queue.play();
if (first) await this.resolveAndPlay(first); if (first) await this.resolveAndPlay(first);
@@ -1205,11 +1457,11 @@ export class BotInstance extends EventEmitter {
return `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`; return `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`;
} }
private async cmdFm(cmd: ParsedCommand): Promise<string> { private async cmdFm(cmd: ParsedCommand, requesterName?: string): Promise<string> {
return this.startFm(this.getProvider(cmd.flags)); return this.startFm(this.getProvider(cmd.flags), requesterName);
} }
async startFm(provider: MusicProvider = this.neteaseProvider): Promise<string> { async startFm(provider: MusicProvider = this.neteaseProvider, requesterName?: string): Promise<string> {
// Match the !fm chat-command guard: refuse before mutating the queue when // Match the !fm chat-command guard: refuse before mutating the queue when
// offline, so the web /fm route can't wipe the queue + flip into FM mode // offline, so the web /fm route can't wipe the queue + flip into FM mode
// while nothing can actually play. // while nothing can actually play.
@@ -1226,11 +1478,12 @@ export class BotInstance extends EventEmitter {
this.player.stop(); this.player.stop();
this.queue.clear(); this.queue.clear();
for (const song of songs) { for (const song of songs) {
this.queue.add({ ...song, platform: provider.platform }); this.queue.add(this.withRequester({ ...song, platform: provider.platform }, requesterName));
} }
this.queue.setMode(PlayMode.Random); this.queue.setMode(PlayMode.Random);
this.isFmMode = true; this.isFmMode = true;
this.fmProvider = provider; this.fmProvider = provider;
this.fmRequesterName = requesterName?.trim() || undefined;
this.player.resetFailures(); this.player.resetFailures();
const first = this.queue.play(); const first = this.queue.play();
@@ -1241,7 +1494,7 @@ export class BotInstance extends EventEmitter {
return `${label} started: ${first?.name ?? "unknown"} - ${first?.artist ?? ""}`; return `${label} started: ${first?.name ?? "unknown"} - ${first?.artist ?? ""}`;
} }
private async cmdArtist(cmd: ParsedCommand): Promise<string> { private async cmdArtist(cmd: ParsedCommand, requesterName?: string): Promise<string> {
if (!cmd.args) return "Usage: !artist <artist name>"; if (!cmd.args) return "Usage: !artist <artist name>";
const provider = this.getProvider(cmd.flags); const provider = this.getProvider(cmd.flags);
const result = await provider.search(cmd.args, 50); const result = await provider.search(cmd.args, 50);
@@ -1262,7 +1515,7 @@ export class BotInstance extends EventEmitter {
this.queue.clear(); this.queue.clear();
this.disableFmMode(); this.disableFmMode();
for (const song of filtered) { for (const song of filtered) {
this.queue.add({ ...song, platform: provider.platform }); this.queue.add(this.withRequester({ ...song, platform: provider.platform }, requesterName));
} }
this.queue.setMode(PlayMode.Loop); this.queue.setMode(PlayMode.Loop);
this.player.resetFailures(); this.player.resetFailures();
@@ -1281,7 +1534,7 @@ export class BotInstance extends EventEmitter {
const songs = await provider.getPersonalFm(); const songs = await provider.getPersonalFm();
if (songs.length === 0) return; if (songs.length === 0) return;
for (const song of songs) { for (const song of songs) {
this.queue.add({ ...song, platform: provider.platform }); this.queue.add(this.withRequester({ ...song, platform: provider.platform }, this.fmRequesterName));
} }
this.logger.debug({ count: songs.length, platform: provider.platform }, "FM queue refilled"); this.logger.debug({ count: songs.length, platform: provider.platform }, "FM queue refilled");
} catch (err) { } catch (err) {
@@ -1316,7 +1569,10 @@ export class BotInstance extends EventEmitter {
const provider = this.getProviderFor(song.platform); const provider = this.getProviderFor(song.platform);
const lyrics = await provider.getLyrics(song.id); const lyrics = await provider.getLyrics(song.id);
if (lyrics.length === 0) return "No lyrics available"; if (lyrics.length === 0) return "No lyrics available";
const lines = lyrics.slice(0, 10).map((l) => l.text); // Include the FULL lyrics (the send path chunks them under the message
// cap). Cap only to avoid pathological spam — far above any normal song.
const MAX_LYRIC_LINES = 200;
const lines = lyrics.slice(0, MAX_LYRIC_LINES).map((l) => l.text);
return `Lyrics for ${song.name}:\n${lines.join("\n")}`; return `Lyrics for ${song.name}:\n${lines.join("\n")}`;
} }
@@ -1331,17 +1587,138 @@ export class BotInstance extends EventEmitter {
return "Following you to your channel"; return "Following you to your channel";
} }
// ─── Saved queues (chat side, #119) ──────────────────────────────────────
// TeamSpeak users have no WebUI account, so chat save/load always uses the
// reserved SHARED_QUEUE_OWNER bucket. All three commands are inert (reply
// "此功能未启用") unless the admin enabled savedQueuesEnabled.
private savedQueuesGuard(): string | null {
return this.config.savedQueuesEnabled ? null : "此功能未启用";
}
private cmdSaveQueue(cmd: ParsedCommand): string {
const off = this.savedQueuesGuard();
if (off) return off;
const name = cmd.args.trim();
if (!name) return `Usage: ${this.config.commandPrefix}save <名称>`;
const songs = this.queue.list();
if (songs.length === 0) return "队列为空,无法保存";
try {
const saved = this.database.saveQueue(SHARED_QUEUE_OWNER, name, songs);
return `已保存队列「${name}」(${saved.songCount} 首)`;
} catch (err) {
return `保存失败:${(err as Error).message}`;
}
}
private async cmdLoadQueue(cmd: ParsedCommand): Promise<string> {
const off = this.savedQueuesGuard();
if (off) return off;
const name = cmd.args.trim();
if (!name) return `Usage: ${this.config.commandPrefix}load [-a] <名称>`;
const meta = this.database
.listSavedQueues(SHARED_QUEUE_OWNER, false)
.find((q) => q.name === name);
const full = meta ? this.database.getSavedQueue(meta.id) : null;
if (!full) return `找不到已保存队列「${name}」`;
const mode = cmd.flags.has("a") ? "append" : "replace";
await this.loadSavedQueue(full.songs, mode);
return mode === "append"
? `已追加「${name}」(${full.songs.length} 首)到队列`
: `已加载「${name}」(${full.songs.length} 首)`;
}
private cmdListQueues(): string {
const off = this.savedQueuesGuard();
if (off) return off;
const list = this.database.listSavedQueues(SHARED_QUEUE_OWNER, false);
if (list.length === 0) return "还没有已保存的队列";
return ["已保存队列:", ...list.map((q) => `• ${q.name}(${q.songCount} 首)`)].join("\n");
}
// ─── Live-queue persistence (Feature 2, #119) ────────────────────────────
/** Synchronous snapshot writer. Persists the live queue (or clears the row
* when empty). Best-effort — a DB failure logs and never interrupts play. */
private persistQueueSnapshot(): void {
if (!this.config.savedQueuesEnabled) return;
try {
const snap = this.queue.snapshot();
if (snap.songs.length === 0) {
this.database.clearQueueState(this.id);
return;
}
this.database.saveQueueState({
botId: this.id,
songs: snap.songs,
currentIndex: snap.currentIndex,
mode: snap.mode,
isFmMode: this.isFmMode,
fmPlatform: this.isFmMode && this.fmProvider ? this.fmProvider.platform : "",
});
} catch (err) {
this.logger.warn({ err }, "queue snapshot persist failed");
}
}
/** Debounce the snapshot writer (~1s) off the stateChange firehose. */
private scheduleQueueSnapshot(): void {
if (!this.config.savedQueuesEnabled) return;
if (this.snapshotTimer) clearTimeout(this.snapshotTimer);
this.snapshotTimer = setTimeout(() => this.persistQueueSnapshot(), 1000);
// Don't keep the event loop alive just for a pending snapshot.
this.snapshotTimer.unref?.();
}
/** Restore + resume the live queue after (re)connect. Best-effort: resumes
* the current track from its START (URLs are re-resolved; no persisted
* elapsed). Spotify resume depends on the sidecar being available. */
private async restoreQueueFromSnapshot(): Promise<void> {
if (!this.config.savedQueuesEnabled) return;
let st;
try {
st = this.database.getQueueState(this.id);
} catch (err) {
this.logger.warn({ err }, "queue snapshot restore failed to read state");
return;
}
if (!st || st.songs.length === 0) return;
this.queue.restore({
songs: st.songs,
currentIndex: st.currentIndex,
mode: st.mode as PlayMode,
});
if (st.isFmMode && st.fmPlatform) {
this.isFmMode = true;
this.fmProvider = this.getProviderFor(st.fmPlatform as Platform);
}
const current = this.queue.current();
if (current) {
this.player.resetFailures();
await this.resolveAndPlay(current);
}
this.logger.info(
{ count: st.songs.length, index: st.currentIndex },
"Restored live queue from snapshot",
);
}
private cmdHelp(): string { private cmdHelp(): string {
const p = this.config.commandPrefix; const p = this.config.commandPrefix;
const def = defaultPlatform(this.config);
// Only advertise source flags whose provider is actually enabled.
const flagHelp = BotInstance.FLAG_PLATFORMS.filter(([, platform]) =>
isProviderEnabled(this.config, platform),
)
.map(([flag, platform]) => `-${flag}=${platform}`)
.join(" ");
return [ return [
"TSMusicBot Commands:", "TSMusicBot Commands:",
`${p}play <song> — Search and play (most popular match)`, `${p}play <song> — Search and play (default source: ${def})`,
`${p}play -q <song> — Search from QQ Music`, ...(flagHelp ? [` Source flags: ${flagHelp}`] : []),
`${p}play -b <song> — Search from BiliBili`,
`${p}play -y <song> — Search from YouTube (yt-dlp)`,
`${p}search <name> — List top matches to pick a specific (same-name) song`, `${p}search <name> — List top matches to pick a specific (same-name) song`,
`${p}play #N — Play the Nth result of the last ${p}search`, `${p}play #N — Play the Nth result of the last ${p}search`,
`${p}play id:<id> — Play an exact song by id / URL (NetEase·QQ·BiliBili)`, `${p}play id:<id> — Play an exact song by id / URL`,
`${p}add <song> — Add to queue (also accepts #N / id: / URL)`, `${p}add <song> — Add to queue (also accepts #N / id: / URL)`,
`${p}playnext <song> — Insert as next song (alias: ${p}pn)`, `${p}playnext <song> — Insert as next song (alias: ${p}pn)`,
`${p}pause/resume — Pause/resume`, `${p}pause/resume — Pause/resume`,
@@ -1352,11 +1729,16 @@ export class BotInstance extends EventEmitter {
`${p}remove <pos> — Remove song at position (see ${p}queue)`, `${p}remove <pos> — Remove song at position (see ${p}queue)`,
`${p}mode <seq|loop|random|rloop> — Play mode`, `${p}mode <seq|loop|random|rloop> — Play mode`,
`${p}playlist <name or id> — Load playlist by name or ID`, `${p}playlist <name or id> — Load playlist by name or ID`,
`${p}playlist -q <name or id> — Load playlist from QQ Music`, `${p}album <name or id> — Load album`,
`${p}album <id> — Load album`, `${p}fm — Personal FM (default source: ${def}; source flags work too)`,
`${p}fm — Personal FM (NetEase)`,
`${p}artist <name> — Play songs by artist (loop)`, `${p}artist <name> — Play songs by artist (loop)`,
`${p}artist -q <name> — Artist loop from QQ Music`, ...(this.config.savedQueuesEnabled
? [
`${p}save <名称> — Save current queue`,
`${p}load [-a] <名称> — Load a saved queue (-a appends)`,
`${p}queues — List saved queues`,
]
: []),
`${p}vote — Vote to skip`, `${p}vote — Vote to skip`,
`${p}lyrics — Show lyrics`, `${p}lyrics — Show lyrics`,
`${p}now — Current song info`, `${p}now — Current song info`,
@@ -1444,6 +1826,17 @@ export class BotInstance extends EventEmitter {
return input; return input;
} }
/** Direct collection ids: numeric (NetEase/QQ) or Jellyfin GUID ItemIds
* (32 hex chars, optionally dashed) — never treat those as name searches. */
private looksLikeCollectionId(raw: string): boolean {
const t = raw.trim();
return (
/^\d+$/.test(t) ||
/^[0-9a-fA-F]{32}$/.test(t) ||
/^[0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$/.test(t)
);
}
/** Serialize queue-mutation + play sequences so concurrent requests can't /** Serialize queue-mutation + play sequences so concurrent requests can't
* interleave (audible track must match queue.currentIndex). */ * interleave (audible track must match queue.currentIndex). */
runExclusive<T>(fn: () => Promise<T>): Promise<T> { runExclusive<T>(fn: () => Promise<T>): Promise<T> {
+7 -1
View File
@@ -79,6 +79,7 @@ export class BotManager extends EventEmitter {
private localProvider: MusicProvider; private localProvider: MusicProvider;
private kugouProvider: MusicProvider; private kugouProvider: MusicProvider;
private spotifyProvider: MusicProvider; private spotifyProvider: MusicProvider;
private jellyfinProvider: MusicProvider;
private spotifyDataDir: string; private spotifyDataDir: string;
private readonly spotifyOAuth?: SpotifyOAuth; private readonly spotifyOAuth?: SpotifyOAuth;
private database: BotDatabase; private database: BotDatabase;
@@ -102,7 +103,8 @@ export class BotManager extends EventEmitter {
kugouProvider?: MusicProvider, kugouProvider?: MusicProvider,
spotifyProvider?: MusicProvider, spotifyProvider?: MusicProvider,
spotifyDataDir?: string, spotifyDataDir?: string,
spotifyOAuth?: SpotifyOAuth spotifyOAuth?: SpotifyOAuth,
jellyfinProvider?: MusicProvider
) { ) {
super(); super();
this.neteaseProvider = neteaseProvider; this.neteaseProvider = neteaseProvider;
@@ -112,6 +114,7 @@ export class BotManager extends EventEmitter {
this.localProvider = localProvider ?? neteaseProvider; this.localProvider = localProvider ?? neteaseProvider;
this.kugouProvider = kugouProvider ?? neteaseProvider; this.kugouProvider = kugouProvider ?? neteaseProvider;
this.spotifyProvider = spotifyProvider ?? neteaseProvider; this.spotifyProvider = spotifyProvider ?? neteaseProvider;
this.jellyfinProvider = jellyfinProvider ?? neteaseProvider;
this.spotifyDataDir = spotifyDataDir ?? path.join(process.cwd(), "data", "spotify"); this.spotifyDataDir = spotifyDataDir ?? path.join(process.cwd(), "data", "spotify");
this.spotifyOAuth = spotifyOAuth; this.spotifyOAuth = spotifyOAuth;
// Let the local provider see which uploads are still referenced by any // Let the local provider see which uploads are still referenced by any
@@ -153,6 +156,7 @@ export class BotManager extends EventEmitter {
localProvider: this.localProvider, localProvider: this.localProvider,
kugouProvider: this.kugouProvider, kugouProvider: this.kugouProvider,
spotifyProvider: this.spotifyProvider, spotifyProvider: this.spotifyProvider,
jellyfinProvider: this.jellyfinProvider,
database: this.database, database: this.database,
config: this.config, config: this.config,
logger: this.logger, logger: this.logger,
@@ -296,6 +300,7 @@ export class BotManager extends EventEmitter {
localProvider: this.localProvider, localProvider: this.localProvider,
kugouProvider: this.kugouProvider, kugouProvider: this.kugouProvider,
spotifyProvider: this.spotifyProvider, spotifyProvider: this.spotifyProvider,
jellyfinProvider: this.jellyfinProvider,
database: this.database, database: this.database,
config: this.config, config: this.config,
logger: this.logger, logger: this.logger,
@@ -353,6 +358,7 @@ export class BotManager extends EventEmitter {
localProvider: this.localProvider, localProvider: this.localProvider,
kugouProvider: this.kugouProvider, kugouProvider: this.kugouProvider,
spotifyProvider: this.spotifyProvider, spotifyProvider: this.spotifyProvider,
jellyfinProvider: this.jellyfinProvider,
database: this.database, database: this.database,
config: this.config, config: this.config,
logger: this.logger, logger: this.logger,
+67
View File
@@ -0,0 +1,67 @@
import { describe, it, expect } from "vitest";
import { splitTextIntoChunks } from "./text-chunk.js";
const bytes = (s: string) => Buffer.byteLength(s, "utf8");
describe("splitTextIntoChunks", () => {
it("returns a single chunk for a short string", () => {
const chunks = splitTextIntoChunks("hello world", 900);
expect(chunks).toEqual(["hello world"]);
});
it("splits a multi-line string longer than maxBytes into multiple chunks on line boundaries", () => {
const lines = Array.from({ length: 50 }, (_, i) => `line number ${i}`);
const text = lines.join("\n");
const chunks = splitTextIntoChunks(text, 60);
expect(chunks.length).toBeGreaterThan(1);
for (const c of chunks) {
expect(bytes(c)).toBeLessThanOrEqual(60);
}
// No hard-split of any line occurred, so rejoining with "\n" is lossless.
expect(chunks.join("\n")).toBe(text);
});
it("bounds by BYTES not chars: multibyte (Chinese) content stays under the cap", () => {
// Each Chinese char is 3 bytes in UTF-8. 40 chars/line = 120 bytes/line.
const lines = Array.from({ length: 10 }, () => "歌词".repeat(20));
const text = lines.join("\n");
const chunks = splitTextIntoChunks(text, 150);
expect(chunks.length).toBeGreaterThan(1);
for (const c of chunks) {
expect(bytes(c)).toBeLessThanOrEqual(150);
}
expect(chunks.join("\n")).toBe(text);
});
it("hard-splits a single over-long line so no chunk exceeds the cap", () => {
const longLine = "a".repeat(500);
const chunks = splitTextIntoChunks(longLine, 100);
expect(chunks.length).toBeGreaterThan(1);
for (const c of chunks) {
expect(bytes(c)).toBeLessThanOrEqual(100);
}
// Content is preserved (hard-split introduces split points, not \n).
expect(chunks.join("")).toBe(longLine);
});
it("never splits a multibyte character across a hard-split boundary", () => {
// 200 Chinese chars = 600 bytes on ONE line, cap 40 bytes.
const longLine = "歌".repeat(200);
const chunks = splitTextIntoChunks(longLine, 40);
for (const c of chunks) {
expect(bytes(c)).toBeLessThanOrEqual(40);
// A clean re-decode: every chunk is valid UTF-8 with no replacement char.
expect(c.includes("�")).toBe(false);
}
expect(chunks.join("")).toBe(longLine);
});
it("preserves blank lines within a single chunk", () => {
const text = "a\n\nb";
expect(splitTextIntoChunks(text, 900)).toEqual([text]);
});
});
+74
View File
@@ -0,0 +1,74 @@
/**
* Split `text` into chunks whose UTF-8 byte length never exceeds `maxBytes`.
*
* TeamSpeak enforces a per-message byte cap (~1024 bytes), and the send path
* does no chunking, so a long single reply (e.g. full song lyrics) would be
* truncated or rejected. This packs whole lines greedily, breaking BETWEEN
* lines. When a single line is itself longer than `maxBytes`, it is hard-split
* on UTF-8 character boundaries so no chunk ever exceeds the cap and no
* multibyte character is ever cut in half.
*
* Content is preserved on rejoin, modulo the split points: chunks split only on
* newline boundaries rejoin losslessly with `chunks.join("\n")`; a hard-split
* long line rejoins with `chunks.join("")`.
*
* @param text The full message text.
* @param maxBytes Max UTF-8 bytes per chunk (default 900 — under TS's ~1024 cap
* with headroom for protocol framing/escaping).
*/
export function splitTextIntoChunks(text: string, maxBytes = 900): string[] {
const chunks: string[] = [];
let current = "";
const flush = (): void => {
if (current !== "") {
chunks.push(current);
current = "";
}
};
for (const rawLine of text.split("\n")) {
const pieces =
Buffer.byteLength(rawLine, "utf8") > maxBytes
? hardSplitByBytes(rawLine, maxBytes)
: [rawLine];
for (const piece of pieces) {
const candidate = current === "" ? piece : `${current}\n${piece}`;
if (Buffer.byteLength(candidate, "utf8") <= maxBytes) {
current = candidate;
} else {
// current is guaranteed non-empty here: pieces never exceed maxBytes,
// so an empty `current` always accepts the next piece above.
flush();
current = piece;
}
}
}
flush();
return chunks;
}
/**
* Break a single line into pieces each ≤ `maxBytes` UTF-8 bytes, never cutting
* a character (iterates code points, so surrogate pairs stay intact).
*/
function hardSplitByBytes(line: string, maxBytes: number): string[] {
const pieces: string[] = [];
let current = "";
let currentBytes = 0;
for (const ch of line) {
const chBytes = Buffer.byteLength(ch, "utf8");
if (currentBytes + chBytes > maxBytes && current !== "") {
pieces.push(current);
current = "";
currentBytes = 0;
}
current += ch;
currentBytes += chBytes;
}
if (current !== "") pieces.push(current);
return pieces;
}
+178 -1
View File
@@ -10,7 +10,7 @@ import {
renameSync, renameSync,
} from "node:fs"; } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { getDefaultConfig, loadConfig, saveConfig, migrateLegacyConfig } from "./config.js"; import { getDefaultConfig, loadConfig, saveConfig, migrateLegacyConfig, defaultPlatform } from "./config.js";
// Wrap the fs functions config.ts uses in call-through spies so the atomic-write // Wrap the fs functions config.ts uses in call-through spies so the atomic-write
// and transient-read-error paths can be observed/forced. Everything else (mkdtemp, // and transient-read-error paths can be observed/forced. Everything else (mkdtemp,
@@ -48,6 +48,159 @@ describe("config", () => {
expect(config).toEqual(getDefaultConfig()); expect(config).toEqual(getDefaultConfig());
}); });
it("defaults to the online sources with jellyfin as opt-in (disabled)", () => {
const config = getDefaultConfig();
expect(config.enabledProviders).toEqual(["netease", "qq", "bilibili", "youtube", "kugou"]);
expect(config.enabledProviders).not.toContain("jellyfin");
});
it("keeps pre-gating behavior for legacy configs without enabledProviders", () => {
const dir = makeTmpDir();
const path = join(dir, "config.json");
// A config written before enabledProviders existed: no such field.
writeFileSync(path, JSON.stringify({ webPort: 4000 }));
const config = loadConfig(path);
expect(config.enabledProviders).toEqual(["netease", "qq", "bilibili", "youtube", "kugou"]);
expect(defaultPlatform(config)).toBe("netease");
});
it("defaultPlatform follows the fixed priority order", () => {
const config = getDefaultConfig();
expect(defaultPlatform(config)).toBe("netease");
// Jellyfin ranks after the online music platforms…
config.enabledProviders = ["netease", "jellyfin"];
expect(defaultPlatform(config)).toBe("netease");
// …but ahead of the video sites…
config.enabledProviders = ["bilibili", "jellyfin", "youtube"];
expect(defaultPlatform(config)).toBe("jellyfin");
// …and is the default when it is the only enabled source.
config.enabledProviders = ["jellyfin"];
expect(defaultPlatform(config)).toBe("jellyfin");
// Nothing enabled → netease fallback (the gate then reports it disabled).
config.enabledProviders = [];
expect(defaultPlatform(config)).toBe("netease");
});
// --- #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", () => { it("creates config file on save", () => {
const dir = makeTmpDir(); const dir = makeTmpDir();
const path = join(dir, "sub", "config.json"); const path = join(dir, "sub", "config.json");
@@ -450,4 +603,28 @@ describe("loadConfig error handling", () => {
expect(readFileSync(join(dir, backups[0]), "utf-8")).toBe(content); expect(readFileSync(join(dir, backups[0]), "utf-8")).toBe(content);
} }
}); });
it("defaults savedQueuesEnabled and playKeepsQueue to false", () => {
const c = getDefaultConfig();
expect(c.savedQueuesEnabled).toBe(false);
expect(c.playKeepsQueue).toBe(false);
});
it("coerces non-boolean savedQueues/playKeepsQueue values to false on load", () => {
const dir = makeTmpDir();
const path = join(dir, "config.json");
writeFileSync(path, JSON.stringify({ savedQueuesEnabled: "yes", playKeepsQueue: 1 }));
const c = loadConfig(path);
expect(c.savedQueuesEnabled).toBe(false);
expect(c.playKeepsQueue).toBe(false);
});
it("preserves savedQueues/playKeepsQueue true when explicitly enabled", () => {
const dir = makeTmpDir();
const path = join(dir, "config.json");
writeFileSync(path, JSON.stringify({ savedQueuesEnabled: true, playKeepsQueue: true }));
const c = loadConfig(path);
expect(c.savedQueuesEnabled).toBe(true);
expect(c.playKeepsQueue).toBe(true);
});
}); });
+194
View File
@@ -26,6 +26,81 @@ export interface SpotifyConfig {
bitrate: number; bitrate: number;
} }
export interface JellyfinConfig {
/** Base URL of the Jellyfin server, e.g. "https://jellyfin.example.com". */
serverUrl: string;
authMode: "userpass" | "apikey";
// userpass mode
username: string;
password: string;
// apikey mode: admin API key + the user whose library/favorites/playlists are used
apiKey: string;
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
* - "spotify" → governed by the existing `spotify.enabled` flag
*/
export const GATEABLE_PROVIDERS = [
"jellyfin",
"netease",
"qq",
"bilibili",
"youtube",
"kugou",
] as const;
export type GateableProvider = (typeof GATEABLE_PROVIDERS)[number];
/** Whether a platform may be used for search/playback under the current config. */
export function isProviderEnabled(config: BotConfig, platform: string): boolean {
if (platform === "local") return config.localAudioEnabled !== false;
if (platform === "spotify") return config.spotify.enabled;
return config.enabledProviders.includes(platform as GateableProvider);
}
/**
* 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 {
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";
}
export interface BotConfig { export interface BotConfig {
webPort: number; webPort: number;
locale: "zh" | "en"; locale: "zh" | "en";
@@ -41,6 +116,19 @@ export interface BotConfig {
idleTimeoutMinutes: number; idleTimeoutMinutes: number;
/** Enable uploading and playback of server-stored local audio files. */ /** Enable uploading and playback of server-stored local audio files. */
localAudioEnabled: boolean; localAudioEnabled: boolean;
/**
* Enable named save/load of queues (chat + web) AND auto-restore of the live
* queue across a restart. Admin-controlled; default false so nothing is
* persisted/restored until an operator opts in.
*/
savedQueuesEnabled: boolean;
/**
* When true, a single-song immediate !play (chat) / play-song (web) inserts
* after the current track and jumps to it instead of clearing the queue, so
* the rest of the queue survives and continues afterwards. Default false
* keeps today's clear-and-play behavior.
*/
playKeepsQueue: boolean;
// Public base URL used when generating share links (e.g. the bot专属链接). // Public base URL used when generating share links (e.g. the bot专属链接).
// Leave empty to use the browser's current origin. Example: // Leave empty to use the browser's current origin. Example:
// "https://music.example.com" or "http://1.2.3.4:3000" // "https://music.example.com" or "http://1.2.3.4:3000"
@@ -51,6 +139,25 @@ export interface BotConfig {
trustProxy: boolean; trustProxy: boolean;
guestMode: GuestModeConfig; guestMode: GuestModeConfig;
spotify: SpotifyConfig; 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
* 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 { export function getDefaultConfig(): BotConfig {
@@ -71,6 +178,8 @@ export function getDefaultConfig(): BotConfig {
autoPauseOnEmpty: false, autoPauseOnEmpty: false,
idleTimeoutMinutes: 0, idleTimeoutMinutes: 0,
localAudioEnabled: true, localAudioEnabled: true,
savedQueuesEnabled: false,
playKeepsQueue: false,
publicUrl: "", publicUrl: "",
trustProxy: false, trustProxy: false,
guestMode: { guestMode: {
@@ -95,6 +204,25 @@ export function getDefaultConfig(): BotConfig {
deviceName: "TSMusicBot", deviceName: "TSMusicBot",
bitrate: 320, bitrate: 320,
}, },
jellyfin: {
serverUrl: "",
authMode: "userpass",
username: "",
password: "",
apiKey: "",
userId: "",
},
// 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,
}; };
} }
@@ -221,12 +349,78 @@ export function loadConfig(path: string): BotConfig {
: defaults.spotify.bitrate, : defaults.spotify.bitrate,
}; };
// Sanitize the jellyfin block on load, mirroring the spotify handling: a
// hand-edited/legacy config.json must never smuggle wrong shapes past the
// gate. Unknown/invalid sub-fields fall back to defaults.
const partialJf = (partial.jellyfin ?? {}) as Partial<JellyfinConfig>;
const jellyfin: JellyfinConfig = {
serverUrl:
typeof partialJf.serverUrl === "string"
? partialJf.serverUrl.trim().replace(/\/+$/, "")
: defaults.jellyfin.serverUrl,
authMode:
partialJf.authMode === "apikey" ? "apikey" : defaults.jellyfin.authMode,
username:
typeof partialJf.username === "string" ? partialJf.username : defaults.jellyfin.username,
password:
typeof partialJf.password === "string" ? partialJf.password : defaults.jellyfin.password,
apiKey: typeof partialJf.apiKey === "string" ? partialJf.apiKey : defaults.jellyfin.apiKey,
userId: typeof partialJf.userId === "string" ? partialJf.userId : defaults.jellyfin.userId,
};
// enabledProviders → known providers only; a non-array falls back to the
// 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;
// Strict-coerce the two feature flags exactly like spotify.enabled so a
// hand-edited / legacy / corrupt config.json can never silently enable
// them (`"yes"`, `1`, `null` → false; only a literal `true` enables).
const savedQueuesEnabled = partial.savedQueuesEnabled === true;
const playKeepsQueue = partial.playKeepsQueue === true;
// 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 { return {
...defaults, ...defaults,
...partial, ...partial,
adminGroups, adminGroups,
guestMode: gm, guestMode: gm,
spotify, spotify,
jellyfin,
audioQuality,
enabledProviders,
savedQueuesEnabled,
playKeepsQueue,
defaultPlatform: defaultPlatformPref,
}; };
} }
} }
+169 -1
View File
@@ -2,7 +2,7 @@ import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createDatabase, type BotDatabase, type BotInstance, type PlayHistoryEntry } from "./database.js"; import { createDatabase, SHARED_QUEUE_OWNER, type BotDatabase, type BotInstance, type PlayHistoryEntry } from "./database.js";
import { createUserStore, GUEST_USER_ID } from "./users.js"; import { createUserStore, GUEST_USER_ID } from "./users.js";
describe("database", () => { describe("database", () => {
@@ -60,6 +60,7 @@ describe("database", () => {
album: "Test Album", album: "Test Album",
platform: "netease", platform: "netease",
coverUrl: "https://example.com/cover.jpg", coverUrl: "https://example.com/cover.jpg",
requestedBy: "alice",
}); });
botDb.addPlayHistory({ botDb.addPlayHistory({
@@ -76,6 +77,7 @@ describe("database", () => {
expect(history).toHaveLength(2); expect(history).toHaveLength(2);
expect(history[0].songName).toBe("Another Song"); expect(history[0].songName).toBe("Another Song");
expect(history[1].songName).toBe("Test Song"); expect(history[1].songName).toBe("Test Song");
expect(history[1].requestedBy).toBe("alice");
}); });
it("saves and loads bot instances", () => { it("saves and loads bot instances", () => {
@@ -129,6 +131,92 @@ describe("database", () => {
expect(botDb.deleteBotInstance("nonexistent")).toBe(false); 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", () => { it("persists and clears customAvatarPath on a bot instance", () => {
const inst = { const inst = {
id: "bot-1", id: "bot-1",
@@ -151,6 +239,86 @@ describe("database", () => {
botDb.setCustomAvatarPath("bot-1", null); botDb.setCustomAvatarPath("bot-1", null);
expect(botDb.getCustomAvatarPath("bot-1")).toBeNull(); expect(botDb.getCustomAvatarPath("bot-1")).toBeNull();
}); });
const sq = (id: string) => ({
id,
name: id,
artist: "",
album: "",
platform: "netease" as const,
coverUrl: "",
duration: 1,
});
describe("saved_queues", () => {
it("upserts by (ownerId, name) and returns songs", () => {
botDb.saveQueue("u1", "night", [sq("a"), sq("b")]);
const again = botDb.saveQueue("u1", "night", [sq("c")]); // overwrite
expect(again.songCount).toBe(1);
expect(botDb.listSavedQueues("u1", false)).toHaveLength(1);
const full = botDb.getSavedQueue(again.id)!;
expect(full.songs.map((s) => s.id)).toEqual(["c"]);
});
it("strips url before persisting", () => {
const saved = botDb.saveQueue("u1", "x", [
{ ...sq("a"), url: "http://example.com/a.mp3" } as never,
]);
const full = botDb.getSavedQueue(saved.id)!;
expect((full.songs[0] as { url?: string }).url).toBeUndefined();
});
it("lists own + shared when includeShared, own-only otherwise", () => {
botDb.saveQueue("u1", "mine", [sq("a")]);
botDb.saveQueue(SHARED_QUEUE_OWNER, "party", [sq("b")]);
expect(botDb.listSavedQueues("u1", false).map((q) => q.name)).toEqual(["mine"]);
expect(
botDb.listSavedQueues("u1", true).map((q) => q.name).sort(),
).toEqual(["mine", "party"]);
});
it("caps songs at 1000 and queues at 50", () => {
expect(() =>
botDb.saveQueue("u1", "big", Array.from({ length: 1001 }, (_, i) => sq("s" + i))),
).toThrow(/1000/);
for (let i = 0; i < 50; i++) botDb.saveQueue("u1", "q" + i, [sq("a")]);
expect(() => botDb.saveQueue("u1", "q50", [sq("a")])).toThrow(/50/);
// Overwriting an existing name is always allowed despite the cap.
expect(() => botDb.saveQueue("u1", "q0", [sq("z")])).not.toThrow();
});
it("deletes and degrades a corrupt blob to empty", () => {
const q = botDb.saveQueue("u1", "x", [sq("a")]);
botDb.db.prepare("UPDATE saved_queues SET songs='not json' WHERE id=?").run(q.id);
expect(botDb.getSavedQueue(q.id)!.songs).toEqual([]);
expect(botDb.deleteSavedQueue(q.id)).toBe(true);
expect(botDb.getSavedQueue(q.id)).toBeNull();
expect(botDb.deleteSavedQueue(q.id)).toBe(false); // already gone
});
});
describe("queue_state", () => {
it("upserts, reads back, and clears per bot", () => {
botDb.saveQueueState({ botId: "b1", songs: [sq("a")], currentIndex: 0, mode: "loop", isFmMode: true, fmPlatform: "netease" });
botDb.saveQueueState({ botId: "b1", songs: [sq("a"), sq("b")], currentIndex: 1, mode: "seq", isFmMode: false, fmPlatform: "" });
const st = botDb.getQueueState("b1")!;
expect(st.songs.map((s) => s.id)).toEqual(["a", "b"]);
expect(st.currentIndex).toBe(1);
expect(st.mode).toBe("seq");
expect(st.isFmMode).toBe(false);
botDb.clearQueueState("b1");
expect(botDb.getQueueState("b1")).toBeNull();
});
it("round-trips FM flags and degrades a corrupt blob", () => {
botDb.saveQueueState({ botId: "b2", songs: [sq("a")], currentIndex: 0, mode: "random", isFmMode: true, fmPlatform: "qq" });
const st = botDb.getQueueState("b2")!;
expect(st.isFmMode).toBe(true);
expect(st.fmPlatform).toBe("qq");
botDb.db.prepare("UPDATE queue_state SET songs='{' WHERE botId=?").run("b2");
expect(botDb.getQueueState("b2")!.songs).toEqual([]);
});
});
}); });
describe("guest principal migration", () => { describe("guest principal migration", () => {
+293 -4
View File
@@ -1,6 +1,46 @@
import Database from "better-sqlite3"; import Database from "better-sqlite3";
import { CAPABILITIES, BOTS_ALL } from "./permissions.js"; import { CAPABILITIES, BOTS_ALL } from "./permissions.js";
import { GUEST_USER_ID, GUEST_USERNAME } from "./users.js"; import { GUEST_USER_ID, GUEST_USERNAME } from "./users.js";
import type { QueuedSong } from "../audio/queue.js";
/**
* Reserved owner id for chat-saved / opt-in-shared queues. A `__`-bracketed
* literal can never collide with a real WebUI user id (UUIDs), so it cleanly
* partitions "shared" saved queues from per-user private ones (issue #119).
*/
export const SHARED_QUEUE_OWNER = "__shared__";
/** Cap per owner (private user OR the shared bucket). */
export const MAX_SAVED_QUEUES = 50;
/** Cap per saved queue / persisted live-queue snapshot. */
export const MAX_QUEUE_SONGS = 1000;
/** A stored song is a QueuedSong minus the lazily-resolved `url`. */
export type StoredSong = Omit<QueuedSong, "url">;
/** Saved-queue row without the (potentially large) songs blob — for list views. */
export interface SavedQueueMeta {
id: number;
ownerId: string;
name: string;
songCount: number;
createdAt: string;
updatedAt: string;
}
/** Full saved queue, including its songs. */
export interface SavedQueue extends SavedQueueMeta {
songs: StoredSong[];
}
/** One-row-per-bot persisted live-queue state (Feature 2, auto-restore). */
export interface QueueStateRow {
botId: string;
songs: StoredSong[];
currentIndex: number;
mode: string;
isFmMode: boolean;
fmPlatform: string;
}
export interface PlayHistoryEntry { export interface PlayHistoryEntry {
botId: string; botId: string;
@@ -8,8 +48,9 @@ export interface PlayHistoryEntry {
songName: string; songName: string;
artist: string; artist: string;
album: string; album: string;
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify"; platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify" | "jellyfin";
coverUrl: string; coverUrl: string;
requestedBy?: string;
} }
export interface PlayHistoryRecord extends PlayHistoryEntry { export interface PlayHistoryRecord extends PlayHistoryEntry {
@@ -54,6 +95,26 @@ export const DEFAULT_PROFILE_CONFIG: ProfileConfig = {
nowPlayingMsgEnabled: true, 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 { export interface FavoritePlaylist {
id: number; id: number;
userId: string; userId: string;
@@ -74,12 +135,24 @@ export interface BotDatabase {
deleteBotInstance(id: string): boolean; deleteBotInstance(id: string): boolean;
getProfileConfig(botId: string): ProfileConfig; getProfileConfig(botId: string): ProfileConfig;
saveProfileConfig(botId: string, config: ProfileConfig): void; 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; getCustomAvatarPath(botId: string): string | null;
setCustomAvatarPath(botId: string, path: string | null): void; setCustomAvatarPath(botId: string, path: string | null): void;
addFavorite(userId: string, playlist: { platform: string; playlistId: string; name: string; coverUrl: string; songCount: number }): void; addFavorite(userId: string, playlist: { platform: string; playlistId: string; name: string; coverUrl: string; songCount: number }): void;
removeFavorite(userId: string, playlistId: string, platform: string): boolean; removeFavorite(userId: string, playlistId: string, platform: string): boolean;
getFavorites(userId: string): FavoritePlaylist[]; getFavorites(userId: string): FavoritePlaylist[];
isFavorited(userId: string, playlistId: string, platform: string): boolean; isFavorited(userId: string, playlistId: string, platform: string): boolean;
// Saved queues (Feature 1) — upsert by (ownerId, name), capped.
saveQueue(ownerId: string, name: string, songs: StoredSong[]): SavedQueue;
listSavedQueues(ownerId: string, includeShared: boolean): SavedQueueMeta[];
getSavedQueue(id: number): SavedQueue | null;
deleteSavedQueue(id: number): boolean;
// Live-queue persistence (Feature 2) — one row per bot.
saveQueueState(state: QueueStateRow): void;
getQueueState(botId: string): QueueStateRow | null;
clearQueueState(botId: string): void;
close(): void; close(): void;
} }
@@ -118,12 +191,27 @@ function migrateSchema(db: Database.Database): void {
if (!names.includes("custom_avatar_path")) { if (!names.includes("custom_avatar_path")) {
db.exec("ALTER TABLE bot_instances ADD COLUMN custom_avatar_path TEXT"); 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 userColumns = db.prepare("PRAGMA table_info(users)").all() as Array<{ name: string }>;
const userColNames = userColumns.map((c) => c.name); const userColNames = userColumns.map((c) => c.name);
if (!userColNames.includes("role")) { if (!userColNames.includes("role")) {
db.exec("ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'"); db.exec("ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'");
} }
const historyColumns = db.prepare("PRAGMA table_info(play_history)").all() as Array<{ name: string }>;
const historyColNames = historyColumns.map((c) => c.name);
if (!historyColNames.includes("requestedBy")) {
db.exec("ALTER TABLE play_history ADD COLUMN requestedBy TEXT NOT NULL DEFAULT ''");
}
} }
function initTables(db: Database.Database): void { function initTables(db: Database.Database): void {
@@ -137,6 +225,7 @@ function initTables(db: Database.Database): void {
album TEXT NOT NULL, album TEXT NOT NULL,
platform TEXT NOT NULL, platform TEXT NOT NULL,
coverUrl TEXT NOT NULL, coverUrl TEXT NOT NULL,
requestedBy TEXT NOT NULL DEFAULT '',
playedAt TEXT NOT NULL DEFAULT (datetime('now')) playedAt TEXT NOT NULL DEFAULT (datetime('now'))
); );
@@ -153,6 +242,8 @@ function initTables(db: Database.Database): void {
serverProtocol TEXT NOT NULL DEFAULT '', serverProtocol TEXT NOT NULL DEFAULT '',
ts6ApiKey TEXT NOT NULL DEFAULT '', ts6ApiKey TEXT NOT NULL DEFAULT '',
serverPassword TEXT NOT NULL DEFAULT '', serverPassword TEXT NOT NULL DEFAULT '',
volume INTEGER NOT NULL DEFAULT 75,
play_mode TEXT NOT NULL DEFAULT 'seq',
identity TEXT identity TEXT
); );
@@ -215,6 +306,28 @@ function initTables(db: Database.Database): void {
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
); );
CREATE INDEX IF NOT EXISTS idx_user_bot_access_userId ON user_bot_access(userId); CREATE INDEX IF NOT EXISTS idx_user_bot_access_userId ON user_bot_access(userId);
CREATE TABLE IF NOT EXISTS saved_queues (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ownerId TEXT NOT NULL,
name TEXT NOT NULL,
songs TEXT NOT NULL,
songCount INTEGER NOT NULL DEFAULT 0,
createdAt TEXT NOT NULL DEFAULT (datetime('now')),
updatedAt TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(ownerId, name)
);
CREATE INDEX IF NOT EXISTS idx_saved_queues_ownerId ON saved_queues(ownerId);
CREATE TABLE IF NOT EXISTS queue_state (
botId TEXT PRIMARY KEY,
songs TEXT NOT NULL,
currentIndex INTEGER NOT NULL,
mode TEXT NOT NULL,
isFmMode INTEGER NOT NULL DEFAULT 0,
fmPlatform TEXT NOT NULL DEFAULT '',
updatedAt TEXT NOT NULL DEFAULT (datetime('now'))
);
`); `);
} }
@@ -265,8 +378,8 @@ export function createDatabase(dbPath: string): BotDatabase {
ensureGuestUser(db); ensureGuestUser(db);
const insertHistory = db.prepare(` const insertHistory = db.prepare(`
INSERT INTO play_history (botId, songId, songName, artist, album, platform, coverUrl) INSERT INTO play_history (botId, songId, songName, artist, album, platform, coverUrl, requestedBy)
VALUES (@botId, @songId, @songName, @artist, @album, @platform, @coverUrl) VALUES (@botId, @songId, @songName, @artist, @album, @platform, @coverUrl, @requestedBy)
`); `);
const selectHistory = db.prepare(` const selectHistory = db.prepare(`
@@ -313,6 +426,12 @@ export function createDatabase(dbPath: string): BotDatabase {
WHERE id = @id 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 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 = ?`); const updateCustomAvatar = db.prepare(`UPDATE bot_instances SET custom_avatar_path = ? WHERE id = ?`);
@@ -334,11 +453,72 @@ export function createDatabase(dbPath: string): BotDatabase {
SELECT 1 FROM favorite_playlists WHERE userId = ? AND playlistId = ? AND platform = ? SELECT 1 FROM favorite_playlists WHERE userId = ? AND playlistId = ? AND platform = ?
`); `);
// A corrupt/hand-edited songs blob must never throw into a route or the
// restore path — degrade to an empty list instead.
const parseSongs = (raw: string): StoredSong[] => {
try {
const v = JSON.parse(raw);
return Array.isArray(v) ? (v as StoredSong[]) : [];
} catch {
return [];
}
};
const rowToSavedMeta = (r: {
id: number; ownerId: string; name: string; songCount: number; createdAt: string; updatedAt: string;
}): SavedQueueMeta => ({
id: r.id,
ownerId: r.ownerId,
name: r.name,
songCount: r.songCount,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
});
const upsertSavedQueue = db.prepare(`
INSERT INTO saved_queues (ownerId, name, songs, songCount)
VALUES (@ownerId, @name, @songs, @songCount)
ON CONFLICT(ownerId, name) DO UPDATE SET
songs = excluded.songs,
songCount = excluded.songCount,
updatedAt = datetime('now')
`);
const selectSavedQueueByOwnerName = db.prepare(
"SELECT * FROM saved_queues WHERE ownerId = ? AND name = ?",
);
const selectSavedQueueIdByOwnerName = db.prepare(
"SELECT id FROM saved_queues WHERE ownerId = ? AND name = ?",
);
const countSavedQueues = db.prepare(
"SELECT COUNT(*) AS c FROM saved_queues WHERE ownerId = ?",
);
const listSavedQueuesOwn = db.prepare(
"SELECT id, ownerId, name, songCount, createdAt, updatedAt FROM saved_queues WHERE ownerId = ? ORDER BY updatedAt DESC",
);
const listSavedQueuesShared = db.prepare(
"SELECT id, ownerId, name, songCount, createdAt, updatedAt FROM saved_queues WHERE ownerId = ? OR ownerId = ? ORDER BY updatedAt DESC",
);
const selectSavedQueueById = db.prepare("SELECT * FROM saved_queues WHERE id = ?");
const deleteSavedQueueById = db.prepare("DELETE FROM saved_queues WHERE id = ?");
const upsertQueueState = db.prepare(`
INSERT INTO queue_state (botId, songs, currentIndex, mode, isFmMode, fmPlatform, updatedAt)
VALUES (@botId, @songs, @currentIndex, @mode, @isFmMode, @fmPlatform, datetime('now'))
ON CONFLICT(botId) DO UPDATE SET
songs = excluded.songs,
currentIndex = excluded.currentIndex,
mode = excluded.mode,
isFmMode = excluded.isFmMode,
fmPlatform = excluded.fmPlatform,
updatedAt = datetime('now')
`);
const selectQueueState = db.prepare("SELECT * FROM queue_state WHERE botId = ?");
const deleteQueueState = db.prepare("DELETE FROM queue_state WHERE botId = ?");
return { return {
db, db,
addPlayHistory(record) { addPlayHistory(record) {
insertHistory.run(record); insertHistory.run({ ...record, requestedBy: record.requestedBy ?? "" });
}, },
getPlayHistory(botId, limit) { getPlayHistory(botId, limit) {
@@ -398,6 +578,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) { getCustomAvatarPath(botId) {
const row = selectCustomAvatar.get(botId) as { custom_avatar_path: string | null } | undefined; const row = selectCustomAvatar.get(botId) as { custom_avatar_path: string | null } | undefined;
return row?.custom_avatar_path ?? null; return row?.custom_avatar_path ?? null;
@@ -424,6 +634,85 @@ export function createDatabase(dbPath: string): BotDatabase {
return row !== undefined; return row !== undefined;
}, },
saveQueue(ownerId, name, songs) {
if (songs.length > MAX_QUEUE_SONGS) {
throw new Error(`保存失败:歌曲数量超过上限 ${MAX_QUEUE_SONGS}`);
}
// Strip any lazily-resolved url before persisting.
const stripped: StoredSong[] = songs.map((s) => {
const { url: _url, ...rest } = s as QueuedSong;
return rest;
});
// Enforce the per-owner cap only for a NEW name (an overwrite of an
// existing saved queue must always be allowed).
const existing = selectSavedQueueIdByOwnerName.get(ownerId, name) as
| { id: number }
| undefined;
if (!existing) {
const { c } = countSavedQueues.get(ownerId) as { c: number };
if (c >= MAX_SAVED_QUEUES) {
throw new Error(`保存失败:已保存队列数量超过上限 ${MAX_SAVED_QUEUES}`);
}
}
upsertSavedQueue.run({
ownerId,
name,
songs: JSON.stringify(stripped),
songCount: stripped.length,
});
const row = selectSavedQueueByOwnerName.get(ownerId, name) as SavedQueueMeta;
return { ...rowToSavedMeta(row), songs: stripped };
},
listSavedQueues(ownerId, includeShared) {
const rows = includeShared
? (listSavedQueuesShared.all(ownerId, SHARED_QUEUE_OWNER) as SavedQueueMeta[])
: (listSavedQueuesOwn.all(ownerId) as SavedQueueMeta[]);
return rows.map(rowToSavedMeta);
},
getSavedQueue(id) {
const row = selectSavedQueueById.get(id) as
| (SavedQueueMeta & { songs: string })
| undefined;
if (!row) return null;
return { ...rowToSavedMeta(row), songs: parseSongs(row.songs) };
},
deleteSavedQueue(id) {
return deleteSavedQueueById.run(id).changes > 0;
},
saveQueueState(state) {
upsertQueueState.run({
botId: state.botId,
songs: JSON.stringify(state.songs),
currentIndex: state.currentIndex,
mode: state.mode,
isFmMode: state.isFmMode ? 1 : 0,
fmPlatform: state.fmPlatform,
});
},
getQueueState(botId) {
const r = selectQueueState.get(botId) as
| { botId: string; songs: string; currentIndex: number; mode: string; isFmMode: number; fmPlatform: string }
| undefined;
if (!r) return null;
return {
botId: r.botId,
songs: parseSongs(r.songs),
currentIndex: r.currentIndex,
mode: r.mode,
isFmMode: r.isFmMode === 1,
fmPlatform: r.fmPlatform,
};
},
clearQueueState(botId) {
deleteQueueState.run(botId);
},
close() { close() {
db.close(); db.close();
}, },
+32 -3
View File
@@ -1,6 +1,6 @@
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { loadConfig, saveConfig, migrateLegacyConfig } from "./data/config.js"; import { loadConfig, saveConfig, migrateLegacyConfig, isProviderEnabled } from "./data/config.js";
import { createDatabase } from "./data/database.js"; import { createDatabase } from "./data/database.js";
import { createLogger } from "./logger.js"; import { createLogger } from "./logger.js";
import { createApiServerManager } from "./music/api-server.js"; import { createApiServerManager } from "./music/api-server.js";
@@ -9,6 +9,7 @@ import { QQMusicProvider } from "./music/qq.js";
import { BiliBiliProvider } from "./music/bilibili.js"; import { BiliBiliProvider } from "./music/bilibili.js";
import { LocalMusicProvider } from "./music/local.js"; import { LocalMusicProvider } from "./music/local.js";
import { KugouProvider } from "./music/kugou.js"; import { KugouProvider } from "./music/kugou.js";
import { JellyfinProvider } from "./music/jellyfin.js";
import { SpotifyProvider } from "./music/spotify/provider.js"; import { SpotifyProvider } from "./music/spotify/provider.js";
import { SpotifyOAuth, createFileOAuthTokenStore } from "./music/spotify/spotify-oauth.js"; import { SpotifyOAuth, createFileOAuthTokenStore } from "./music/spotify/spotify-oauth.js";
import { createCookieStore } from "./music/auth.js"; import { createCookieStore } from "./music/auth.js";
@@ -52,7 +53,14 @@ async function main() {
const db = createDatabase(DB_PATH); const db = createDatabase(DB_PATH);
const apiServer = createApiServerManager( const apiServer = createApiServerManager(
{ neteasePort: config.neteaseApiPort, qqMusicPort: config.qqMusicApiPort }, {
neteasePort: config.neteaseApiPort,
qqMusicPort: config.qqMusicApiPort,
// 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"),
},
logger logger
); );
await apiServer.start(); await apiServer.start();
@@ -82,6 +90,25 @@ async function main() {
const kugouCookie = cookieStore.load("kugou"); const kugouCookie = cookieStore.load("kugou");
if (kugouCookie) kugouProvider.setCookie(kugouCookie); if (kugouCookie) kugouProvider.setCookie(kugouCookie);
// Jellyfin: admin-configured connection (no QR). The persisted blob carries
// AccessToken + User.Id + DeviceId and lives alongside the other platform
// cookies; a Jellyfin server that is unreachable at boot must not block
// startup — the provider authenticates lazily on first use.
const jellyfinProvider = new JellyfinProvider(logger);
jellyfinProvider.configure(config.jellyfin);
const jellyfinAuth = cookieStore.load("jellyfin");
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); const permissions = createPermissionStore(db.db);
// Single process-wide Spotify authorization (one Premium account for Stage 3). // Single process-wide Spotify authorization (one Premium account for Stage 3).
@@ -114,7 +141,8 @@ async function main() {
kugouProvider, kugouProvider,
spotifyProvider, spotifyProvider,
SPOTIFY_DATA_DIR, SPOTIFY_DATA_DIR,
spotifyOAuth spotifyOAuth,
jellyfinProvider
); );
await botManager.loadSavedBots(); await botManager.loadSavedBots();
@@ -127,6 +155,7 @@ async function main() {
localProvider, localProvider,
kugouProvider, kugouProvider,
spotifyProvider, spotifyProvider,
jellyfinProvider,
database: db, database: db,
avatarStore, avatarStore,
config, config,
+105 -2
View File
@@ -1,5 +1,34 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { describeQqApiStartupError } from "./api-server.js"; import { createApiServerManager, describeQqApiStartupError } from "./api-server.js";
import type { Logger } from "../logger.js";
// Record every listen() the QQ sidecar makes so we can assert it is always
// pinned to the configured port (regression coverage for issue #122).
const mockState = vi.hoisted(() => ({
listenCalls: [] as Array<{ port: number; host: string }>,
}));
vi.mock("@sansenjian/qq-music-api", () => {
const app = {
listen(port: number, host: string, cb?: () => void) {
mockState.listenCalls.push({ port, host });
const server = {
address: () => ({ port, address: host, family: "IPv4" as const }),
on() {
return server;
},
close(done?: () => void) {
done?.();
},
};
// Real net/Koa fire the listening callback on a later tick, after the
// caller has captured the returned server handle.
if (cb) setImmediate(cb);
return server;
},
};
return { default: app };
});
describe("describeQqApiStartupError", () => { describe("describeQqApiStartupError", () => {
it("flags ERR_REQUIRE_ESM by error code with version-pin guidance", () => { it("flags ERR_REQUIRE_ESM by error code with version-pin guidance", () => {
@@ -28,3 +57,77 @@ describe("describeQqApiStartupError", () => {
expect(describeQqApiStartupError(null)).toBeNull(); expect(describeQqApiStartupError(null)).toBeNull();
}); });
}); });
// Regression coverage for issue #122: the QQ Music API sidecar must listen on
// the same port the client base URL targets (config.qqMusicApiPort). A stale
// build once bound 3300 while the client requested 3200, silently breaking the
// QQ login QR / search flow with ECONNREFUSED on 127.0.0.1:3200.
describe("createApiServerManager — QQ sidecar port binding", () => {
const noopLogger = {
info() {},
warn() {},
error() {},
debug() {},
trace() {},
fatal() {},
} as unknown as Logger;
beforeEach(() => {
mockState.listenCalls = [];
});
it("listens on the configured qqMusicPort and exposes a matching base URL", async () => {
const port = 39217; // uncommon port to avoid clashing with a real instance
const manager = createApiServerManager(
{ neteasePort: 39218, qqMusicPort: port, neteaseEnabled: false, qqEnabled: true },
noopLogger
);
await manager.start();
manager.stop();
expect(manager.getQQMusicBaseUrl()).toBe(`http://127.0.0.1:${port}`);
expect(mockState.listenCalls).toEqual([{ port, host: "127.0.0.1" }]);
});
it("follows qqMusicPort — not an injected PORT — and restores PORT afterwards", async () => {
const port = 39219;
const previous = process.env.PORT;
// Simulate a hosting platform / compose file injecting a stray PORT that
// must NOT leak into the QQ sidecar's chosen port.
process.env.PORT = "39999";
const manager = createApiServerManager(
{ neteasePort: 39220, qqMusicPort: port, neteaseEnabled: false, qqEnabled: true },
noopLogger
);
try {
await manager.start();
// The sidecar follows qqMusicPort, never the injected PORT.
expect(mockState.listenCalls).toEqual([{ port, host: "127.0.0.1" }]);
// The injected PORT is restored so nothing else in the process is affected.
expect(process.env.PORT).toBe("39999");
} finally {
manager.stop();
if (previous === undefined) delete process.env.PORT;
else process.env.PORT = previous;
}
});
it("leaves an absent PORT env unset after importing the sidecar", async () => {
const port = 39221;
const previous = process.env.PORT;
delete process.env.PORT;
const manager = createApiServerManager(
{ neteasePort: 39222, qqMusicPort: port, neteaseEnabled: false, qqEnabled: true },
noopLogger
);
try {
await manager.start();
// Was unset before importing — must be unset again, no leaked override.
expect(process.env.PORT).toBeUndefined();
} finally {
manager.stop();
if (previous === undefined) delete process.env.PORT;
else process.env.PORT = previous;
}
});
});
+51 -2
View File
@@ -5,6 +5,10 @@ import type { Server } from "node:http";
export interface ApiServerOptions { export interface ApiServerOptions {
neteasePort: number; neteasePort: number;
qqMusicPort: number; qqMusicPort: number;
/** Provider gating (#enabledProviders): when false, the corresponding
* embedded sidecar API server is never started and its port never bound. */
neteaseEnabled?: boolean;
qqEnabled?: boolean;
} }
export interface ApiServerManager { export interface ApiServerManager {
@@ -65,9 +69,16 @@ export function createApiServerManager(
return { return {
async start(): Promise<void> { async start(): Promise<void> {
// Provider gating: with the jellyfin-only default config neither legacy
// sidecar starts, so ports 3001/3200 are never opened.
if (options.neteaseEnabled === false && options.qqEnabled === false) {
logger.info("NetEase/QQ providers disabled — embedded music API servers not started");
return;
}
logger.info("Starting embedded music API servers..."); logger.info("Starting embedded music API servers...");
// Start NetEase Cloud Music API // Start NetEase Cloud Music API
if (options.neteaseEnabled !== false) {
try { try {
const portFree = await isPortFree(options.neteasePort); const portFree = await isPortFree(options.neteasePort);
if (!portFree) { if (!portFree) {
@@ -88,11 +99,13 @@ export function createApiServerManager(
} catch (err) { } catch (err) {
logger.error({ err }, "Failed to start NetEase Cloud Music API"); logger.error({ err }, "Failed to start NetEase Cloud Music API");
} }
}
// Start QQ Music API. Older versions auto-started on import; the // Start QQ Music API. Older versions auto-started on import; the
// current fork (2.2.11+) only listens when run as `require.main`, // current fork (2.2.11+) only listens when run as `require.main`,
// so we explicitly call .listen() on the imported Koa app and keep // so we explicitly call .listen() on the imported Koa app and keep
// the server handle for clean shutdown. // the server handle for clean shutdown.
if (options.qqEnabled === false) return;
try { try {
const portFree = await isPortFree(options.qqMusicPort); const portFree = await isPortFree(options.qqMusicPort);
if (!portFree) { if (!portFree) {
@@ -101,7 +114,25 @@ export function createApiServerManager(
"QQ Music API port already in use — reusing existing instance" "QQ Music API port already in use — reusing existing instance"
); );
} else { } else {
const qqModule = (await import("@sansenjian/qq-music-api")) as any; // Pin the upstream server to the configured port before importing.
// The package derives its default port from process.env.PORT (falling
// back to 3200) and, in some historical versions, auto-started that
// server as an import side effect. Aligning PORT with qqMusicApiPort
// guarantees the sidecar can never bind a different port than the one
// the client base URL (getQQMusicBaseUrl) targets — the root cause of
// issue #122, where an old build listened on 3300 while the client
// requested 3200. Restore the previous value right after import so we
// never leak the override into the rest of the process (e.g. the web
// server or the NetEase sidecar, which also read PORT as a fallback).
const prevPortEnv = process.env.PORT;
process.env.PORT = String(options.qqMusicPort);
let qqModule: any;
try {
qqModule = (await import("@sansenjian/qq-music-api")) as any;
} finally {
if (prevPortEnv === undefined) delete process.env.PORT;
else process.env.PORT = prevPortEnv;
}
// The module's export structure varies between versions: // The module's export structure varies between versions:
// 2.2.11+: default → Koa app (has .listen) // 2.2.11+: default → Koa app (has .listen)
// 2.2.10: default → wrapper object whose .default is the Koa app // 2.2.10: default → wrapper object whose .default is the Koa app
@@ -111,16 +142,34 @@ export function createApiServerManager(
? candidate ? candidate
: candidate.default ?? null; : candidate.default ?? null;
if (koaApp && typeof koaApp.listen === "function") { if (koaApp && typeof koaApp.listen === "function") {
// 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) => { qqMusicServer = await new Promise<Server>((resolve, reject) => {
const srv = koaApp.listen(options.qqMusicPort, "127.0.0.1", () => const srv = koaApp.listen(options.qqMusicPort, "127.0.0.1", () =>
resolve(srv) resolve(srv)
); );
srv.on("error", reject); 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( logger.info(
{ port: options.qqMusicPort }, { port: boundPort },
"QQ Music API started" "QQ Music API started"
); );
}
} else { } else {
logger.warn("QQ Music API module does not expose a Koa app"); logger.warn("QQ Music API module does not expose a Koa app");
} }
+8 -4
View File
@@ -1,9 +1,13 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
/** Platforms with a persisted credential blob. For jellyfin the "cookie" is a
* JSON string carrying the access token / userId / deviceId (see jellyfin.ts). */
type CookiePlatform = "netease" | "qq" | "bilibili" | "kugou" | "jellyfin";
export interface CookieStore { export interface CookieStore {
save(platform: "netease" | "qq" | "bilibili" | "kugou", cookie: string): void; save(platform: CookiePlatform, cookie: string): void;
load(platform: "netease" | "qq" | "bilibili" | "kugou"): string; load(platform: CookiePlatform): string;
} }
export function createCookieStore(cookieDir: string): CookieStore { export function createCookieStore(cookieDir: string): CookieStore {
@@ -12,7 +16,7 @@ export function createCookieStore(cookieDir: string): CookieStore {
} }
return { return {
save(platform: "netease" | "qq" | "bilibili" | "kugou", cookie: string): void { save(platform: CookiePlatform, cookie: string): void {
const filePath = path.join(cookieDir, `${platform}.json`); const filePath = path.join(cookieDir, `${platform}.json`);
fs.writeFileSync( fs.writeFileSync(
filePath, filePath,
@@ -21,7 +25,7 @@ export function createCookieStore(cookieDir: string): CookieStore {
); );
}, },
load(platform: "netease" | "qq" | "bilibili" | "kugou"): string { load(platform: CookiePlatform): string {
const filePath = path.join(cookieDir, `${platform}.json`); const filePath = path.join(cookieDir, `${platform}.json`);
if (!fs.existsSync(filePath)) return ""; if (!fs.existsSync(filePath)) return "";
try { try {
+39
View File
@@ -0,0 +1,39 @@
import { describe, it, expect, vi } from "vitest";
import { BiliBiliProvider } from "./bilibili.js";
describe("BiliBiliProvider.search pagination", () => {
function mockProvider() {
const p = new BiliBiliProvider();
const get = vi.fn().mockResolvedValue({ data: { data: { result: [] } } });
// Short-circuit the buvid + wbi bootstrap so search only issues the
// /search/type request we want to inspect.
(p as any).buvidInitialized = true;
(p as any).wbiMixinKey = "0".repeat(32);
(p as any).wbiKeyFetchedAt = Date.now();
(p as any).api = { get };
return { p, get };
}
function searchParams(get: ReturnType<typeof vi.fn>) {
const call = get.mock.calls.find(
(c: any[]) => c[0] === "/x/web-interface/wbi/search/type"
);
expect(call, "expected a /search/type call").toBeTruthy();
// signWbi stringifies every value.
return call![1].params as Record<string, string>;
}
it("adds page (offset/limit+1) alongside page_size", async () => {
const { p, get } = mockProvider();
await p.search("hello", 20, 20); // page 2
const params = searchParams(get);
expect(params.page).toBe("2");
expect(params.page_size).toBe("20");
});
it("defaults offset to 0 → page 1 (backward compatible)", async () => {
const { p, get } = mockProvider();
await p.search("hello", 20);
expect(searchParams(get).page).toBe("1");
});
});
+5 -1
View File
@@ -147,12 +147,16 @@ export class BiliBiliProvider implements MusicProvider {
return fixed; return fixed;
} }
async search(query: string, limit = 20): Promise<SearchResult> { async search(query: string, limit = 20, offset = 0): Promise<SearchResult> {
await this.ensureBuvidCookie(); await this.ensureBuvidCookie();
await this.ensureWbiKeys(); await this.ensureWbiKeys();
// /search/type is page-based; the web pages in limit-aligned steps so
// offset is a multiple of page_size.
const page = Math.floor(offset / limit) + 1;
const signed = this.signWbi({ const signed = this.signWbi({
search_type: "video", search_type: "video",
keyword: query, keyword: query,
page,
page_size: limit, page_size: limit,
}); });
const res = await this.api.get("/x/web-interface/wbi/search/type", { const res = await this.api.get("/x/web-interface/wbi/search/type", {
+386
View File
@@ -0,0 +1,386 @@
import { describe, it, expect, vi } from "vitest";
import {
ticksToSeconds,
TICKS_PER_SECOND,
buildCoverUrl,
buildStreamUrl,
buildUniversalUrl,
mapJellyfinSong,
mapJellyfinAlbum,
mapJellyfinPlaylist,
mapJellyfinLyrics,
describeJellyfinError,
JELLYFIN_QUALITY_LEVELS,
JellyfinProvider,
} from "./jellyfin.js";
import type { JellyfinConfig } from "../data/config.js";
function cfg(partial: Partial<JellyfinConfig> = {}): JellyfinConfig {
return {
serverUrl: "https://jf.example.com",
authMode: "apikey",
username: "",
password: "",
apiKey: "KEY",
userId: "user-1",
...partial,
};
}
/** Provider with a mocked axios instance; returns the request spy. */
function mockProvider(config = cfg(), responder?: (req: any) => any) {
const p = new JellyfinProvider();
p.configure(config);
const request = vi.fn(async (req: any) => ({
data: responder ? responder(req) : { Items: [] },
}));
(p as any).api = { request, post: vi.fn() };
return { p, request };
}
describe("Jellyfin mapping helpers", () => {
it("converts ticks to seconds (1 tick = 100ns)", () => {
expect(ticksToSeconds(TICKS_PER_SECOND)).toBe(1);
expect(ticksToSeconds(2_275_000_000)).toBeCloseTo(227.5, 3);
expect(ticksToSeconds(undefined)).toBe(0);
expect(ticksToSeconds(NaN)).toBe(0);
});
it("maps a Jellyfin audio item to Song (GUID id stays a string)", () => {
const song = mapJellyfinSong(
{
Id: "3fa85f6457174562b3fc2c963f66afa6",
Name: "Track",
Artists: ["A", "B"],
AlbumArtist: "A",
Album: "The Album",
RunTimeTicks: 1_855_000_000,
},
"https://jf/x.jpg",
);
expect(song).toEqual({
id: "3fa85f6457174562b3fc2c963f66afa6",
name: "Track",
artist: "A / B",
album: "The Album",
duration: 186, // 185.5s rounded
coverUrl: "https://jf/x.jpg",
platform: "jellyfin",
});
});
it("falls back to AlbumArtist when Artists is empty", () => {
const song = mapJellyfinSong({ Id: "1", Artists: [], AlbumArtist: "Solo" }, "");
expect(song.artist).toBe("Solo");
});
it("maps albums and playlists with ChildCount", () => {
const album = mapJellyfinAlbum(
{ Id: "a1", Name: "LP", AlbumArtist: "X", ChildCount: 10 },
"c",
);
expect(album).toMatchObject({ id: "a1", artist: "X", songCount: 10, platform: "jellyfin" });
const pl = mapJellyfinPlaylist({ Id: "p1", Name: "Mix", ChildCount: 7 }, "c");
expect(pl).toMatchObject({ id: "p1", songCount: 7, platform: "jellyfin" });
});
it("cover URL prefers the item's Primary image, falls back to the album's", () => {
const own = buildCoverUrl("https://jf", "K", {
Id: "i1",
ImageTags: { Primary: "tag1" },
AlbumId: "a1",
AlbumPrimaryImageTag: "tag2",
});
expect(own).toBe("https://jf/Items/i1/Images/Primary?maxWidth=512&tag=tag1&api_key=K");
const album = buildCoverUrl("https://jf", "K", {
Id: "i1",
AlbumId: "a1",
AlbumPrimaryImageTag: "tag2",
});
expect(album).toBe("https://jf/Items/a1/Images/Primary?maxWidth=512&tag=tag2&api_key=K");
expect(buildCoverUrl("https://jf", "K", { Id: "i1" })).toBe("");
});
it("builds direct and transcoded stream URLs", () => {
expect(buildStreamUrl("https://jf", "K", "i1")).toBe(
"https://jf/Audio/i1/stream?static=true&api_key=K",
);
const url = buildUniversalUrl("https://jf", {
apiKey: "K",
userId: "u",
deviceId: "d",
itemId: "i1",
kbps: 320,
});
expect(url).toContain("https://jf/Audio/i1/universal?");
expect(url).toContain("maxStreamingBitrate=320000");
expect(url).toContain("transcodingContainer=mp3");
expect(url).toContain("transcodingProtocol=http");
// The container list must be URL-encoded ("|" → %7C)
expect(url).toContain("container=mp3%2Caac%2Cm4a%7Caac%2Cflac%2Cwebma%2Cwebm%2Cwav%2Cogg");
});
it("maps lyrics with tick offsets; entries without Start collapse to 0; empty text dropped", () => {
const lines = mapJellyfinLyrics({
Lyrics: [
{ Text: "line two", Start: 125_000_000 },
{ Text: "line one", Start: 5_000_000 },
{ Text: " ", Start: 1 },
{ Text: "untimed" },
],
});
expect(lines).toEqual([
{ time: 0, text: "untimed" },
{ time: 0.5, text: "line one" },
{ time: 12.5, text: "line two" },
]);
});
it("mapJellyfinLyrics tolerates junk payloads", () => {
expect(mapJellyfinLyrics(null)).toEqual([]);
expect(mapJellyfinLyrics({})).toEqual([]);
expect(mapJellyfinLyrics({ Lyrics: "nope" })).toEqual([]);
});
it("describes common connection errors in a friendly way", () => {
expect(describeJellyfinError({ response: { status: 401 } })).toContain("401");
expect(describeJellyfinError({ code: "ECONNREFUSED" })).toContain("无法连接");
expect(describeJellyfinError(new Error("boom"))).toBe("boom");
});
});
describe("JellyfinProvider", () => {
it("quality: defaults to direct, ignores foreign (NetEase) values", () => {
const p = new JellyfinProvider();
expect(p.getQuality()).toBe("direct");
p.setQuality("exhigh"); // NetEase value from the legacy platform-less broadcast
expect(p.getQuality()).toBe("direct");
p.setQuality("320");
expect(p.getQuality()).toBe("320");
expect(JELLYFIN_QUALITY_LEVELS[0].value).toBe("direct");
});
it("getSongUrl: direct tier → static stream; 320 tier → universal transcode", async () => {
const { p } = mockProvider();
p.setCookie(JSON.stringify({ deviceId: "dev-1" }));
const direct = await p.getSongUrl("item1");
expect(direct?.url).toBe("https://jf.example.com/Audio/item1/stream?static=true&api_key=KEY");
p.setQuality("320");
const transcoded = await p.getSongUrl("item1");
expect(transcoded?.url).toContain("/Audio/item1/universal?");
expect(transcoded?.url).toContain("maxStreamingBitrate=320000");
expect(transcoded?.url).toContain("api_key=KEY");
});
it("search hits /Items for Audio, MusicAlbum and Playlist with paging", async () => {
const { p, request } = mockProvider();
await p.search("mozart", 30, 60);
const types = request.mock.calls.map((c) => c[0].params?.IncludeItemTypes);
expect(types).toContain("Audio");
expect(types).toContain("MusicAlbum");
expect(types).toContain("Playlist");
for (const call of request.mock.calls) {
expect(call[0].params.Limit).toBe(30);
expect(call[0].params.StartIndex).toBe(60);
expect(call[0].params.userId).toBe("user-1");
expect(call[0].headers["X-Emby-Token"]).toBe("KEY");
}
});
it("userpass: authenticates once via AuthenticateByName, persists token, retries once on 401", async () => {
const p = new JellyfinProvider();
p.configure(cfg({ authMode: "userpass", username: "eric", password: "pw", apiKey: "", userId: "" }));
const persisted: string[] = [];
p.setPersist((s) => persisted.push(s));
let tokenCounter = 0;
const post = vi.fn(async (..._args: any[]) => ({
data: { AccessToken: `tok-${++tokenCounter}`, User: { Id: "u9" } },
}));
// First data request 401s (expired token), the retry succeeds.
let dataCalls = 0;
const request = vi.fn(async (req: any) => {
dataCalls++;
if (dataCalls === 1) {
const err: any = new Error("Unauthorized");
err.response = { status: 401 };
throw err;
}
return { data: { Items: [{ Id: "s1", Name: "N" }] }, config: req };
});
(p as any).api = { request, post };
const songs = await p.getPlaylistSongs("pl1");
expect(songs).toHaveLength(1);
// login ran twice: initial auth + re-auth after the 401
expect(post).toHaveBeenCalledTimes(2);
const authHeader = post.mock.calls[0][2].headers.Authorization as string;
expect(authHeader).toContain('MediaBrowser Client="TSMusicBot"');
expect(authHeader).toContain("DeviceId=");
expect(post.mock.calls[0][1]).toEqual({ Username: "eric", Pw: "pw" });
// token + userId + deviceId persisted like cookies
const last = JSON.parse(persisted[persisted.length - 1]);
expect(last.accessToken).toBe("tok-2");
expect(last.userId).toBe("u9");
expect(last.deviceId).toBeTruthy();
});
it("userpass: surfaces the error when the retry also 401s (single retry only)", async () => {
const p = new JellyfinProvider();
p.configure(cfg({ authMode: "userpass", username: "eric", password: "bad", apiKey: "" }));
const post = vi.fn(async () => ({ data: { AccessToken: "t", User: { Id: "u" } } }));
const err: any = new Error("Unauthorized");
err.response = { status: 401 };
const request = vi.fn(async () => {
throw err;
});
(p as any).api = { request, post };
await expect(p.getPlaylistSongs("pl1")).rejects.toThrow("Unauthorized");
expect(request).toHaveBeenCalledTimes(2); // original + exactly one retry
});
it("setCookie/getCookie round-trip restores token, userId and deviceId", () => {
const p = new JellyfinProvider();
p.setCookie(JSON.stringify({ accessToken: "T", userId: "U", deviceId: "D" }));
expect(JSON.parse(p.getCookie())).toEqual({ accessToken: "T", userId: "U", deviceId: "D" });
p.setCookie("not json"); // junk must not corrupt state
expect(JSON.parse(p.getCookie())).toEqual({ accessToken: "T", userId: "U", deviceId: "D" });
});
it("getLyrics: 404 means no lyrics (empty), other errors surface", async () => {
const notFound: any = new Error("nf");
notFound.response = { status: 404 };
const { p } = mockProvider(cfg(), () => {
throw notFound;
});
expect(await p.getLyrics("i1")).toEqual([]);
const boom: any = new Error("server down");
boom.response = { status: 500 };
const { p: p2 } = mockProvider(cfg(), () => {
throw boom;
});
await expect(p2.getLyrics("i1")).rejects.toThrow("server down");
});
it("getAuthStatus reflects /System/Info reachability; unconfigured = logged out", async () => {
const blank = new JellyfinProvider();
expect(await blank.getAuthStatus()).toEqual({ loggedIn: false });
const { p } = mockProvider(cfg(), () => ({ ServerName: "NAS" }));
expect(await p.getAuthStatus()).toEqual({ loggedIn: true, nickname: "API Key@NAS" });
const { p: down } = mockProvider(cfg(), () => {
throw new Error("ECONNREFUSED");
});
expect(await down.getAuthStatus()).toEqual({ loggedIn: false });
});
it("getPersonalFm seeds InstantMix from a random favorite", async () => {
const { p, request } = mockProvider(cfg(), (req: any) => {
if (req.url.includes("/InstantMix")) {
return { Items: [{ Id: "m1", Name: "Mixed" }] };
}
if (req.params?.Filters === "IsFavorite") {
return { Items: [{ Id: "fav1", Name: "Fav" }] };
}
return { Items: [] };
});
const songs = await p.getPersonalFm();
expect(songs.map((s) => s.id)).toEqual(["m1"]);
const mixCall = request.mock.calls.find((c) => c[0].url.includes("/InstantMix"));
expect(mixCall![0].url).toContain("/Items/fav1/InstantMix");
});
it("getPersonalFm falls back: favorites → recently played → random", async () => {
const { p, request } = mockProvider(cfg(), (req: any) => {
if (req.url.includes("/InstantMix")) return { Items: [{ Id: "m2" }] };
if (req.params?.Filters === "IsFavorite") return { Items: [] };
if (req.params?.Filters === "IsPlayed") return { Items: [{ Id: "recent1" }] };
return { Items: [] };
});
const songs = await p.getPersonalFm();
expect(songs.map((s) => s.id)).toEqual(["m2"]);
const mixCall = request.mock.calls.find((c) => c[0].url.includes("/InstantMix"));
expect(mixCall![0].url).toContain("/Items/recent1/InstantMix");
});
it("playback reporter posts start/progress/stopped with PositionTicks", async () => {
const bodies: { url: string; data: any }[] = [];
const { p } = mockProvider(cfg(), () => ({}));
((p as any).api.request as ReturnType<typeof vi.fn>).mockImplementation(
async (req: any) => {
bodies.push({ url: req.url, data: req.data });
return { data: {} };
},
);
const reporter = p.createPlaybackReporter();
reporter.onTrackStart("song1");
reporter.onTick("song1", 90.5, false);
reporter.onStop();
await vi.waitFor(() => expect(bodies).toHaveLength(3));
expect(bodies[0].url).toContain("/Sessions/Playing");
expect(bodies[0].data.ItemId).toBe("song1");
const sessionId = bodies[0].data.PlaySessionId;
expect(sessionId).toBeTruthy();
expect(bodies[1].url).toContain("/Sessions/Playing/Progress");
expect(bodies[1].data.PositionTicks).toBe(905_000_000);
expect(bodies[1].data.PlaySessionId).toBe(sessionId);
// Stop carries the LAST reported position so a natural track end counts as played.
expect(bodies[2].url).toContain("/Sessions/Playing/Stopped");
expect(bodies[2].data.PositionTicks).toBe(905_000_000);
});
it("reporter: a new track start closes the previous session; failures are swallowed", async () => {
const bodies: { url: string; data: any }[] = [];
const { p } = mockProvider(cfg(), () => ({}));
((p as any).api.request as ReturnType<typeof vi.fn>).mockImplementation(
async (req: any) => {
bodies.push({ url: req.url, data: req.data });
if (req.url.includes("/Sessions")) throw new Error("reporting endpoint down");
return { data: {} };
},
);
const reporter = p.createPlaybackReporter();
reporter.onTrackStart("a");
reporter.onTick("a", 200, false);
reporter.onTrackStart("b"); // must post Stopped for "a" at pos 200, then Playing for "b"
reporter.onStop();
await vi.waitFor(() => expect(bodies.length).toBeGreaterThanOrEqual(5));
const stopped = bodies.filter((b) => b.url.includes("/Stopped"));
expect(stopped[0].data.ItemId).toBe("a");
expect(stopped[0].data.PositionTicks).toBe(2_000_000_000);
expect(stopped[1].data.ItemId).toBe("b");
// ticks for stale items are ignored
reporter.onTick("a", 999, false);
expect(bodies.filter((b) => b.url.includes("/Progress"))).toHaveLength(1);
});
it("testConnection reports server info on success and friendly errors on failure", async () => {
const { p } = mockProvider(cfg(), () => ({ ServerName: "NAS", Version: "10.9.11" }));
expect(await p.testConnection()).toEqual({ ok: true, serverName: "NAS", version: "10.9.11" });
const bad = new JellyfinProvider();
bad.configure(cfg({ apiKey: "", userId: "" }));
const res = await bad.testConnection();
expect(res.ok).toBe(false);
expect(res.error).toContain("apiKey");
});
it("configure drops the cached token when credentials change, keeps deviceId", () => {
const p = new JellyfinProvider();
p.configure(cfg());
p.setCookie(JSON.stringify({ accessToken: "T", userId: "U", deviceId: "D" }));
p.configure(cfg({ serverUrl: "https://other.example.com" }));
const state = JSON.parse(p.getCookie());
expect(state.accessToken).toBe("");
expect(state.deviceId).toBe("D");
});
});
+741
View File
@@ -0,0 +1,741 @@
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import crypto from "node:crypto";
import axios, { type AxiosError, type AxiosInstance } from "axios";
import type { JellyfinConfig } from "../data/config.js";
import type { Logger } from "../logger.js";
import type {
MusicProvider,
Song,
SongUrlResult,
Playlist,
PlaylistDetail,
LyricLine,
SearchResult,
QrCodeResult,
AuthStatus,
Album,
} from "./provider.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
/** 1 tick = 100 ns → 10,000,000 ticks = 1 second (Jellyfin RunTimeTicks / lyric Start). */
export const TICKS_PER_SECOND = 10_000_000;
export function ticksToSeconds(ticks: number | null | undefined): number {
if (typeof ticks !== "number" || !Number.isFinite(ticks)) return 0;
return ticks / TICKS_PER_SECOND;
}
const CLIENT_NAME = "TSMusicBot";
/** Best-effort package version for the MediaBrowser auth header. */
const PKG_VERSION = (() => {
try {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "..", "package.json"), "utf-8"),
) as { version?: string };
return pkg.version ?? "0.0.0";
} catch {
return "0.0.0";
}
})();
// Jellyfin quality tiers replace the NetEase labels for this source. "direct"
// (default) streams the original file — the bot re-encodes to Opus anyway, so
// this is max quality. The transcode tiers exist for remote/low-bandwidth
// Jellyfin servers.
export const JELLYFIN_QUALITY_LEVELS = [
{ value: "direct", label: "原始直传 Direct", bitrate: 0 },
{ value: "320", label: "320kbps 转码", bitrate: 320 },
{ value: "192", label: "192kbps 转码", bitrate: 192 },
{ value: "128", label: "128kbps 转码", bitrate: 128 },
] as const;
/** Subset of Jellyfin's BaseItemDto that this provider consumes. */
export interface JellyfinItem {
Id: string;
Name?: string;
Artists?: string[];
AlbumArtist?: string;
Album?: string;
AlbumId?: string;
AlbumPrimaryImageTag?: string;
RunTimeTicks?: number;
ImageTags?: { Primary?: string };
ChildCount?: number;
SongCount?: number;
Overview?: string;
}
/**
* Primary cover art URL for an item; falls back to the parent album's primary
* image (AlbumPrimaryImageTag) and finally to "" (the UI renders a placeholder
* for missing art). The api_key query param authenticates browser <img> loads
* and the TS-avatar download alike.
*/
export function buildCoverUrl(baseUrl: string, apiKey: string, item: JellyfinItem): string {
if (!baseUrl) return "";
const key = apiKey ? `&api_key=${encodeURIComponent(apiKey)}` : "";
if (item.ImageTags?.Primary) {
return `${baseUrl}/Items/${item.Id}/Images/Primary?maxWidth=512&tag=${item.ImageTags.Primary}${key}`;
}
if (item.AlbumId && item.AlbumPrimaryImageTag) {
return `${baseUrl}/Items/${item.AlbumId}/Images/Primary?maxWidth=512&tag=${item.AlbumPrimaryImageTag}${key}`;
}
return "";
}
/** Direct (untranscoded) stream URL — the default playback path. */
export function buildStreamUrl(baseUrl: string, apiKey: string, itemId: string): string {
const params = new URLSearchParams({ static: "true", api_key: apiKey });
return `${baseUrl}/Audio/${itemId}/stream?${params.toString()}`;
}
/** Server-side transcode URL for the 320/192/128 kbps tiers. */
export function buildUniversalUrl(
baseUrl: string,
o: { apiKey: string; userId: string; deviceId: string; itemId: string; kbps: number },
): string {
const params = new URLSearchParams({
userId: o.userId,
deviceId: o.deviceId,
api_key: o.apiKey,
maxStreamingBitrate: String(o.kbps * 1000),
container: "mp3,aac,m4a|aac,flac,webma,webm,wav,ogg",
transcodingContainer: "mp3",
transcodingProtocol: "http",
});
return `${baseUrl}/Audio/${o.itemId}/universal?${params.toString()}`;
}
export function mapJellyfinSong(item: JellyfinItem, coverUrl: string): Song {
return {
id: String(item.Id),
name: item.Name ?? "",
artist: (item.Artists?.length ? item.Artists : [item.AlbumArtist ?? ""])
.filter(Boolean)
.join(" / "),
album: item.Album ?? "",
duration: Math.round(ticksToSeconds(item.RunTimeTicks)),
coverUrl,
platform: "jellyfin",
};
}
export function mapJellyfinAlbum(item: JellyfinItem, coverUrl: string): Album {
return {
id: String(item.Id),
name: item.Name ?? "",
artist: item.AlbumArtist ?? (item.Artists ?? []).join(" / "),
coverUrl,
songCount: item.ChildCount ?? item.SongCount ?? 0,
platform: "jellyfin",
};
}
export function mapJellyfinPlaylist(item: JellyfinItem, coverUrl: string): Playlist {
return {
id: String(item.Id),
name: item.Name ?? "",
coverUrl,
songCount: item.ChildCount ?? item.SongCount ?? 0,
platform: "jellyfin",
};
}
/**
* Map `GET /Audio/{id}/Lyrics` → the bot's synced-lyric format. `Start` is in
* ticks; entries without Start (plain-text lyrics) collapse to time 0 so the
* lyric view still renders them, just without sync. Translation stays empty —
* Jellyfin has no translated-lyrics concept.
*/
export function mapJellyfinLyrics(payload: unknown): LyricLine[] {
const lyrics = (payload as { Lyrics?: { Text?: string; Start?: number }[] })?.Lyrics;
if (!Array.isArray(lyrics)) return [];
return lyrics
.filter((l) => typeof l?.Text === "string" && l.Text.trim() !== "")
.map((l) => ({ time: ticksToSeconds(l.Start), text: l.Text!.trim() }))
.sort((a, b) => a.time - b.time);
}
/** Operator-friendly connection/auth error description (test-connection UI + logs). */
export function describeJellyfinError(err: unknown): string {
const e = err as AxiosError;
if (e?.response) {
const s = e.response.status;
if (s === 401) return "认证失败:用户名/密码或 API Key 不正确 (401 Unauthorized)";
if (s === 403) return "该账号没有访问权限 (403 Forbidden)";
if (s === 404) return "接口不存在——请确认地址指向 Jellyfin 根路径 (404 Not Found)";
return `Jellyfin 返回 HTTP ${s}`;
}
const code = (e as { code?: string })?.code;
if (code === "ECONNREFUSED") return "无法连接 Jellyfin 服务器 (connection refused)";
if (code === "ENOTFOUND" || code === "EAI_AGAIN") return "无法解析服务器地址 (DNS lookup failed)";
if (code === "ETIMEDOUT" || code === "ECONNABORTED") return "连接 Jellyfin 超时 (timeout)";
return (err as Error)?.message ?? String(err);
}
/**
* Per-bot playback reporting handle. One playback session (PlaySessionId) per
* track start; the previous session is closed with its last known position so
* Jellyfin's played/PlayCount bookkeeping sees natural track ends as ~complete
* plays and mid-track skips as partial ones. Every call is fire-and-forget —
* reporting must never affect playback.
*/
export interface JellyfinPlaybackReporter {
onTrackStart(itemId: string): void;
onTick(itemId: string, positionSec: number, paused: boolean): void;
onStop(): void;
}
interface PersistedAuth {
accessToken?: string;
userId?: string;
deviceId?: string;
}
function emptyConfig(): JellyfinConfig {
return { serverUrl: "", authMode: "userpass", username: "", password: "", apiKey: "", userId: "" };
}
export class JellyfinProvider implements MusicProvider {
readonly platform = "jellyfin" as const;
private api: AxiosInstance;
private cfg: JellyfinConfig = emptyConfig();
private token = "";
private userId = "";
private deviceId = "";
private quality: string = "direct";
private loginPromise: Promise<void> | null = null;
private persistFn: ((serialized: string) => void) | null = null;
private logger: Logger | null = null;
constructor(logger?: Logger) {
this.api = axios.create({ timeout: 10000 });
this.logger = logger ?? null;
}
/**
* Apply (or re-apply, on settings save) the admin-configured connection.
* Changing any credential-relevant field drops the cached token so the next
* request re-authenticates; the stable deviceId survives reconfiguration.
*/
configure(cfg: JellyfinConfig): void {
const prev = this.cfg;
this.cfg = { ...cfg, serverUrl: (cfg.serverUrl ?? "").trim().replace(/\/+$/, "") };
const credsChanged =
prev.serverUrl !== this.cfg.serverUrl ||
prev.authMode !== this.cfg.authMode ||
prev.username !== this.cfg.username ||
prev.password !== this.cfg.password ||
prev.apiKey !== this.cfg.apiKey ||
prev.userId !== this.cfg.userId;
if (credsChanged) {
this.token = "";
this.userId = "";
}
}
isConfigured(): boolean {
return this.cfg.serverUrl.length > 0;
}
/** Wire the on-disk persistence used after a successful login (cookie store). */
setPersist(fn: (serialized: string) => void): void {
this.persistFn = fn;
}
setQuality(quality: string): void {
// Ignore foreign values: the legacy platform-less POST /api/music/quality
// broadcasts NetEase levels to every provider, which must not clobber the
// jellyfin default ("direct").
if (JELLYFIN_QUALITY_LEVELS.some((l) => l.value === quality)) {
this.quality = quality;
}
}
getQuality(): string {
return this.quality;
}
// --- Auth ---
private authHeader(): string {
return (
`MediaBrowser Client="${CLIENT_NAME}", Device="${CLIENT_NAME}", ` +
`DeviceId="${this.deviceId}", Version="${PKG_VERSION}"`
);
}
private ensureDeviceId(): void {
if (!this.deviceId) {
this.deviceId = crypto.randomUUID();
this.persistState();
}
}
private persistState(): void {
try {
this.persistFn?.(this.getCookie());
} catch (err) {
this.logger?.warn({ err }, "Failed to persist Jellyfin auth state");
}
}
private async login(): Promise<void> {
this.ensureDeviceId();
const res = await this.api.post(
`${this.cfg.serverUrl}/Users/AuthenticateByName`,
{ Username: this.cfg.username, Pw: this.cfg.password },
{ headers: { Authorization: this.authHeader() } },
);
const token = res.data?.AccessToken as string | undefined;
const userId = res.data?.User?.Id as string | undefined;
if (!token || !userId) {
throw new Error("Jellyfin 登录响应缺少 AccessToken/User.Id");
}
this.token = token;
this.userId = userId;
this.persistState();
this.logger?.info({ userId }, "Jellyfin authenticated");
}
private async ensureAuth(): Promise<void> {
if (!this.cfg.serverUrl) {
throw new Error("Jellyfin 未配置服务器地址 (server URL not configured)");
}
if (this.cfg.authMode === "apikey") {
if (!this.cfg.apiKey || !this.cfg.userId) {
throw new Error("Jellyfin API Key 模式需要 apiKey 和 userId");
}
this.token = this.cfg.apiKey;
this.userId = this.cfg.userId;
this.ensureDeviceId();
return;
}
if (this.token && this.userId) return;
if (!this.cfg.username) {
throw new Error("Jellyfin 未配置用户名/密码 (username not configured)");
}
if (!this.loginPromise) {
this.loginPromise = this.login().finally(() => {
this.loginPromise = null;
});
}
return this.loginPromise;
}
/** Authenticated request with a single re-auth retry on 401 (userpass mode). */
private async request<T = unknown>(
method: "get" | "post",
path: string,
opts: { params?: Record<string, unknown>; data?: unknown; retried?: boolean } = {},
): Promise<T> {
await this.ensureAuth();
// Callers snapshot `userId: this.userId` while building params, which is
// still empty before the first login. ensureAuth() has populated it by
// now, so backfill the stale-empty snapshot instead of sending userId="".
const params = opts.params ? { ...opts.params } : undefined;
if (params && "userId" in params && !params.userId) params.userId = this.userId;
try {
const res = await this.api.request<T>({
method,
url: this.cfg.serverUrl + path,
params,
data: opts.data,
headers: { "X-Emby-Token": this.token },
});
return res.data;
} catch (err) {
const status = (err as AxiosError).response?.status;
if (status === 401 && this.cfg.authMode === "userpass" && !opts.retried) {
this.token = "";
this.userId = "";
return this.request(method, path, { ...opts, retried: true });
}
throw err;
}
}
// --- MusicProvider ---
private coverFor(item: JellyfinItem): string {
return buildCoverUrl(this.cfg.serverUrl, this.token, item);
}
private mapSongs(items: JellyfinItem[] | undefined | null): Song[] {
return (items ?? []).map((i) => mapJellyfinSong(i, this.coverFor(i)));
}
async search(query: string, limit = 20, offset = 0): Promise<SearchResult> {
const common = {
searchTerm: query,
Recursive: true,
Limit: limit,
StartIndex: offset,
userId: this.userId,
};
const [songRes, albumRes, playlistRes] = await Promise.all([
this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
params: { ...common, IncludeItemTypes: "Audio" },
}),
this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
params: { ...common, IncludeItemTypes: "MusicAlbum", Fields: "ChildCount" },
}),
this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
params: { ...common, IncludeItemTypes: "Playlist", Fields: "ChildCount" },
}),
]);
return {
songs: this.mapSongs(songRes?.Items),
albums: (albumRes?.Items ?? []).map((i) => mapJellyfinAlbum(i, this.coverFor(i))),
playlists: (playlistRes?.Items ?? []).map((i) => mapJellyfinPlaylist(i, this.coverFor(i))),
};
}
async getSongUrl(songId: string, quality?: string): Promise<SongUrlResult | null> {
await this.ensureAuth();
const level = quality ?? this.quality;
const tier = JELLYFIN_QUALITY_LEVELS.find((l) => l.value === level);
if (!tier || tier.value === "direct") {
return { url: buildStreamUrl(this.cfg.serverUrl, this.token, songId) };
}
return {
url: buildUniversalUrl(this.cfg.serverUrl, {
apiKey: this.token,
userId: this.userId,
deviceId: this.deviceId,
itemId: songId,
kbps: tier.bitrate,
}),
};
}
async getSongDetail(songId: string): Promise<Song | null> {
const res = await this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
params: { ids: songId, userId: this.userId },
});
const item = res?.Items?.[0];
return item ? mapJellyfinSong(item, this.coverFor(item)) : null;
}
async getPlaylistSongs(playlistId: string): Promise<Song[]> {
const res = await this.request<{ Items?: JellyfinItem[] }>(
"get",
`/Playlists/${playlistId}/Items`,
{ params: { userId: this.userId } },
);
return this.mapSongs(res?.Items);
}
async getAlbumSongs(albumId: string): Promise<Song[]> {
const res = await this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
params: {
parentId: albumId,
sortBy: "ParentIndexNumber,IndexNumber",
userId: this.userId,
},
});
return this.mapSongs(res?.Items);
}
async getRecommendPlaylists(): Promise<Playlist[]> {
return this.getUserPlaylists();
}
async getUserPlaylists(): Promise<Playlist[]> {
const res = await this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
params: {
IncludeItemTypes: "Playlist",
Recursive: true,
Fields: "ChildCount",
SortBy: "SortName",
userId: this.userId,
},
});
return (res?.Items ?? []).map((i) => mapJellyfinPlaylist(i, this.coverFor(i)));
}
async getPlaylistDetail(playlistId: string): Promise<PlaylistDetail | null> {
const res = await this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
params: { ids: playlistId, Fields: "ChildCount,Overview", userId: this.userId },
});
const item = res?.Items?.[0];
if (!item) return null;
return {
id: String(item.Id),
name: item.Name ?? "",
description: item.Overview ?? "",
coverUrl: this.coverFor(item),
songCount: item.ChildCount ?? 0,
};
}
async getLyrics(songId: string): Promise<LyricLine[]> {
try {
const res = await this.request<unknown>("get", `/Audio/${songId}/Lyrics`);
return mapJellyfinLyrics(res);
} catch (err) {
// 404 = the track simply has no lyrics — not an error.
if ((err as AxiosError).response?.status === 404) return [];
throw err;
}
}
// No QR-code login concept — connection is global and admin-configured.
async getQrCode(): Promise<QrCodeResult> {
return { qrUrl: "", key: "" };
}
async checkQrCodeStatus(): Promise<"waiting" | "scanned" | "confirmed" | "expired"> {
return "expired";
}
setCookie(cookie: string): void {
try {
const parsed = JSON.parse(cookie) as PersistedAuth;
if (parsed && typeof parsed === "object") {
if (typeof parsed.accessToken === "string") this.token = parsed.accessToken;
if (typeof parsed.userId === "string") this.userId = parsed.userId;
if (typeof parsed.deviceId === "string") this.deviceId = parsed.deviceId;
}
} catch {
// Not the JSON blob we persist — ignore rather than corrupt auth state.
}
}
getCookie(): string {
return JSON.stringify({
accessToken: this.token,
userId: this.userId,
deviceId: this.deviceId,
});
}
async getAuthStatus(): Promise<AuthStatus> {
if (!this.cfg.serverUrl) return { loggedIn: false };
try {
const info = await this.request<{ ServerName?: string }>("get", "/System/Info");
const who = this.cfg.authMode === "apikey" ? "API Key" : this.cfg.username;
return { loggedIn: true, nickname: `${who}@${info?.ServerName ?? "Jellyfin"}` };
} catch (err) {
this.logger?.debug({ err: describeJellyfinError(err) }, "Jellyfin auth status check failed");
return { loggedIn: false };
}
}
/**
* Authenticated round-trip to /System/Info for the Settings "test connection"
* button. `candidate` (when given) tests form values in a throwaway instance
* so a failed test never disturbs the live token state.
*/
async testConnection(
candidate?: JellyfinConfig,
): Promise<{ ok: boolean; serverName?: string; version?: string; error?: string }> {
const probe = candidate ? new JellyfinProvider() : this;
if (candidate) probe.configure(candidate);
try {
const info = await probe.request<{ ServerName?: string; Version?: string }>(
"get",
"/System/Info",
);
return { ok: true, serverName: info?.ServerName, version: info?.Version };
} catch (err) {
return { ok: false, error: describeJellyfinError(err) };
}
}
/**
* Personal FM = Instant Mix seeded from a random favorite, falling back to a
* random recently-played track, then a random library track. A final safety
* net returns 50 random tracks when Instant Mix itself yields nothing.
*/
async getPersonalFm(): Promise<Song[]> {
const seed = await this.pickFmSeed();
if (seed) {
const mix = await this.request<{ Items?: JellyfinItem[] }>(
"get",
`/Items/${seed}/InstantMix`,
{ params: { userId: this.userId, limit: 50 } },
);
const songs = this.mapSongs(mix?.Items);
if (songs.length > 0) return songs;
}
const random = await this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
params: {
IncludeItemTypes: "Audio",
Recursive: true,
SortBy: "Random",
Limit: 50,
userId: this.userId,
},
});
return this.mapSongs(random?.Items);
}
private async pickFmSeed(): Promise<string | null> {
const favorite = await this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
params: {
Filters: "IsFavorite",
IncludeItemTypes: "Audio",
Recursive: true,
SortBy: "Random",
Limit: 1,
userId: this.userId,
},
});
if (favorite?.Items?.[0]?.Id) return favorite.Items[0].Id;
const played = await this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
params: {
Filters: "IsPlayed",
IncludeItemTypes: "Audio",
Recursive: true,
SortBy: "DatePlayed",
SortOrder: "Descending",
Limit: 20,
userId: this.userId,
},
});
const playedItems = played?.Items ?? [];
if (playedItems.length > 0) {
return playedItems[Math.floor(Math.random() * playedItems.length)].Id;
}
const random = await this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
params: {
IncludeItemTypes: "Audio",
Recursive: true,
SortBy: "Random",
Limit: 1,
userId: this.userId,
},
});
return random?.Items?.[0]?.Id ?? null;
}
// --- Home sections ---
async getLatestAlbums(limit = 12): Promise<Album[]> {
await this.ensureAuth();
const items = await this.request<JellyfinItem[]>(
"get",
`/Users/${this.userId}/Items/Latest`,
{ params: { IncludeItemTypes: "MusicAlbum", Limit: limit, Fields: "ChildCount" } },
);
return (Array.isArray(items) ? items : []).map((i) =>
mapJellyfinAlbum(i, this.coverFor(i)),
);
}
async getMostPlayed(limit = 12): Promise<Song[]> {
const res = await this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
params: {
SortBy: "PlayCount",
SortOrder: "Descending",
IncludeItemTypes: "Audio",
Recursive: true,
Filters: "IsPlayed",
Limit: limit,
userId: this.userId,
},
});
return this.mapSongs(res?.Items);
}
async getFavoriteSongs(limit = 100): Promise<Song[]> {
const res = await this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
params: {
Filters: "IsFavorite",
IncludeItemTypes: "Audio",
Recursive: true,
SortBy: "SortName",
Limit: limit,
userId: this.userId,
},
});
return this.mapSongs(res?.Items);
}
async getGenres(limit = 30): Promise<{ id: string; name: string }[]> {
const res = await this.request<{ Items?: JellyfinItem[] }>("get", "/MusicGenres", {
params: { userId: this.userId, Limit: limit, SortBy: "SortName" },
});
return (res?.Items ?? []).map((i) => ({ id: String(i.Id), name: i.Name ?? "" }));
}
async getGenreSongs(genreId: string, limit = 100): Promise<Song[]> {
const res = await this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
params: {
GenreIds: genreId,
IncludeItemTypes: "Audio",
Recursive: true,
SortBy: "AlbumArtist,Album,SortName",
Limit: limit,
userId: this.userId,
},
});
return this.mapSongs(res?.Items);
}
// --- Playback reporting (Sessions API) ---
private async report(path: string, body: Record<string, unknown>): Promise<void> {
try {
await this.request("post", path, { data: body });
} catch (err) {
// Reporting must never affect playback — log and swallow.
this.logger?.debug(
{ err: describeJellyfinError(err), path },
"Jellyfin playback report failed",
);
}
}
createPlaybackReporter(): JellyfinPlaybackReporter {
// One session per track start; remember the last reported position so the
// implicit stop on track change carries a sane PositionTicks (a natural
// track end reports ~full duration → Jellyfin counts the play).
let current: { itemId: string; sessionId: string; lastPosSec: number } | null = null;
const report = this.report.bind(this);
return {
onTrackStart(itemId: string): void {
if (current) {
void report("/Sessions/Playing/Stopped", {
ItemId: current.itemId,
PlaySessionId: current.sessionId,
PositionTicks: Math.round(current.lastPosSec * TICKS_PER_SECOND),
});
}
current = { itemId, sessionId: crypto.randomUUID(), lastPosSec: 0 };
void report("/Sessions/Playing", {
ItemId: itemId,
PlaySessionId: current.sessionId,
PositionTicks: 0,
CanSeek: true,
});
},
onTick(itemId: string, positionSec: number, paused: boolean): void {
if (!current || current.itemId !== itemId) return;
current.lastPosSec = positionSec;
void report("/Sessions/Playing/Progress", {
ItemId: itemId,
PlaySessionId: current.sessionId,
PositionTicks: Math.round(positionSec * TICKS_PER_SECOND),
IsPaused: paused,
});
},
onStop(): void {
if (!current) return;
void report("/Sessions/Playing/Stopped", {
ItemId: current.itemId,
PlaySessionId: current.sessionId,
PositionTicks: Math.round(current.lastPosSec * TICKS_PER_SECOND),
});
current = null;
},
};
}
}
+31 -2
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { mapKugouSong, mapKugouSongs, mapKugouAlbums, mapKugouPlaylist, mapKugouPlaylists, krcToLrc } from "./kugou.js"; import { mapKugouSong, mapKugouSongs, mapKugouAlbums, mapKugouPlaylist, mapKugouPlaylists, krcToLrc, KugouProvider } from "./kugou.js";
import { parseLyrics } from "./netease.js"; import { parseLyrics } from "./netease.js";
describe("mapKugouSongs", () => { describe("mapKugouSongs", () => {
@@ -193,3 +193,32 @@ describe("mapKugouPlaylists", () => {
expect(mapKugouPlaylists(undefined)).toEqual([]); expect(mapKugouPlaylists(undefined)).toEqual([]);
}); });
}); });
describe("KugouProvider.search pagination", () => {
function mockProvider() {
const p = new KugouProvider();
const get = vi.fn().mockResolvedValue({ data: { data: { info: [] } } });
(p as any).mobileHttp = { get };
return { p, get };
}
function searchParams(get: ReturnType<typeof vi.fn>) {
const call = get.mock.calls[0];
expect(call, "expected a mobile search call").toBeTruthy();
return call[1].params as Record<string, unknown>;
}
it("sets page to offset/limit+1 and keeps pagesize=limit", async () => {
const { p, get } = mockProvider();
await p.search("hello", 20, 20); // page 2
const params = searchParams(get);
expect(params.page).toBe(2);
expect(params.pagesize).toBe(20);
});
it("defaults offset to 0 → page 1 (backward compatible)", async () => {
const { p, get } = mockProvider();
await p.search("hello", 20);
expect(searchParams(get).page).toBe(1);
});
});
+5 -2
View File
@@ -616,12 +616,15 @@ export class KugouProvider implements MusicProvider {
} }
// --- Search (verified live via the unsigned mobile endpoint) --------------- // --- Search (verified live via the unsigned mobile endpoint) ---------------
async search(query: string, limit = 20): Promise<SearchResult> { async search(query: string, limit = 20, offset = 0): Promise<SearchResult> {
const q = query.trim(); const q = query.trim();
if (!q) return { songs: [], playlists: [], albums: [] }; if (!q) return { songs: [], playlists: [], albums: [] };
try { try {
// Songs only. `page` is the 1-based cursor; the web pages in limit-aligned
// steps so offset is a multiple of pagesize.
const page = Math.floor(offset / limit) + 1;
const res = await this.mobileHttp.get("http://mobilecdn.kugou.com/api/v3/search/song", { const res = await this.mobileHttp.get("http://mobilecdn.kugou.com/api/v3/search/song", {
params: { format: "json", keyword: q, page: 1, pagesize: limit, showtype: 1 }, params: { format: "json", keyword: q, page, pagesize: limit, showtype: 1 },
}); });
const info = res.data?.data?.info as KugouRawSong[] | undefined; const info = res.data?.data?.info as KugouRawSong[] | undefined;
return { songs: mapKugouSongs(info), playlists: [], albums: [] }; return { songs: mapKugouSongs(info), playlists: [], albums: [] };
+14
View File
@@ -205,6 +205,20 @@ describe("LocalMusicProvider quota", () => {
}); });
}); });
describe("LocalMusicProvider search pagination", () => {
it("slices [offset, offset+limit) instead of the first page", async () => {
const recs = ["a", "b", "c", "d"].map((id) => makeRecord(id));
seed(recs); // newest-first order preserved: a, b, c, d
const p = new LocalMusicProvider(dir);
const page1 = await p.search("", 2); // offset defaults to 0
expect(page1.songs.map((s) => s.id)).toEqual(["a", "b"]);
const page2 = await p.search("", 2, 2);
expect(page2.songs.map((s) => s.id)).toEqual(["c", "d"]);
});
});
describe("LocalMusicProvider filename handling", () => { describe("LocalMusicProvider filename handling", () => {
it("accepts a long filename without dropping its extension", async () => { it("accepts a long filename without dropping its extension", async () => {
const p = new LocalMusicProvider(dir); const p = new LocalMusicProvider(dir);
+2 -2
View File
@@ -214,12 +214,12 @@ export class LocalMusicProvider implements MusicProvider {
return song; return song;
} }
async search(query: string, limit = 20): Promise<SearchResult> { async search(query: string, limit = 20, offset = 0): Promise<SearchResult> {
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
const songs = this.records const songs = this.records
.filter((r) => existsSync(r.filePath)) .filter((r) => existsSync(r.filePath))
.filter((r) => !q || `${r.name} ${r.artist} ${r.album} ${r.originalName}`.toLowerCase().includes(q)) .filter((r) => !q || `${r.name} ${r.artist} ${r.album} ${r.originalName}`.toLowerCase().includes(q))
.slice(0, limit) .slice(offset, offset + limit)
.map((r) => this.toSong(r)); .map((r) => this.toSong(r));
return { songs, playlists: [], albums: [] }; return { songs, playlists: [], albums: [] };
} }
+48 -2
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { parseLyrics, mapNeteaseAlbums, mapNeteaseSongs, parseNeteaseTrial } from "./netease.js"; import { parseLyrics, mapNeteaseAlbums, mapNeteaseSongs, parseNeteaseTrial, NeteaseProvider } from "./netease.js";
describe("NetEase adapter", () => { describe("NetEase adapter", () => {
it("parses LRC format lyrics", () => { it("parses LRC format lyrics", () => {
@@ -93,3 +93,49 @@ describe("NetEase adapter", () => {
expect(parseNeteaseTrial({ freeTrialInfo: { start: 0, end: 0 } })).toBeUndefined(); expect(parseNeteaseTrial({ freeTrialInfo: { start: 0, end: 0 } })).toBeUndefined();
}); });
}); });
describe("NeteaseProvider.search pagination", () => {
function mockProvider() {
const p = new NeteaseProvider("http://x");
const get = vi.fn().mockResolvedValue({
data: { result: { songs: [], playlists: [], albums: [] } },
});
(p as any).api = { get };
return { p, get };
}
/** Find the /cloudsearch call whose params.type matches. */
function callByType(get: ReturnType<typeof vi.fn>, type: number) {
const call = get.mock.calls.find((c: any[]) => c[1]?.params?.type === type);
expect(call, `expected a /cloudsearch call with type=${type}`).toBeTruthy();
return call![1].params as Record<string, unknown>;
}
it("forwards offset for songs and uses real limit+offset for playlists/albums", async () => {
const { p, get } = mockProvider();
await p.search("hello", 20, 20);
// songs (type 1): offset forwarded, limit unchanged
const songs = callByType(get, 1);
expect(songs.limit).toBe(20);
expect(songs.offset).toBe(20);
// playlists (type 1000): limit-driven (NOT hardcoded 10) + offset
const playlists = callByType(get, 1000);
expect(playlists.limit).toBe(20);
expect(playlists.offset).toBe(20);
// albums (type 10): limit-driven (NOT hardcoded 10) + offset
const albums = callByType(get, 10);
expect(albums.limit).toBe(20);
expect(albums.offset).toBe(20);
});
it("defaults offset to 0 (backward compatible)", async () => {
const { p, get } = mockProvider();
await p.search("hello", 20);
expect(callByType(get, 1).offset).toBe(0);
expect(callByType(get, 1000).offset).toBe(0);
expect(callByType(get, 10).offset).toBe(0);
});
});
+8 -4
View File
@@ -131,21 +131,25 @@ export class NeteaseProvider implements MusicProvider {
return this.cookie ? { cookie: this.cookie } : {}; return this.cookie ? { cookie: this.cookie } : {};
} }
async search(query: string, limit = 20): Promise<SearchResult> { async search(query: string, limit = 20, offset = 0): Promise<SearchResult> {
// /cloudsearch supports offset for every type. Songs, playlists (type 1000)
// and albums (type 10) are all limit/offset-driven so the web can page past
// the first page (playlists/albums were previously hardcoded to limit: 10).
const [songRes, playlistRes, albumRes] = await Promise.all([ const [songRes, playlistRes, albumRes] = await Promise.all([
this.api.get("/cloudsearch", { this.api.get("/cloudsearch", {
params: { keywords: query, type: 1, limit, ...this.cookieParams }, params: { keywords: query, type: 1, limit, offset, ...this.cookieParams },
}), }),
this.api.get("/cloudsearch", { this.api.get("/cloudsearch", {
params: { params: {
keywords: query, keywords: query,
type: 1000, type: 1000,
limit: 10, limit,
offset,
...this.cookieParams, ...this.cookieParams,
}, },
}), }),
this.api.get("/cloudsearch", { this.api.get("/cloudsearch", {
params: { keywords: query, type: 10, limit: 10, ...this.cookieParams }, params: { keywords: query, type: 10, limit, offset, ...this.cookieParams },
}), }),
]); ]);
+17 -5
View File
@@ -1,3 +1,15 @@
/** All music source platforms. Jellyfin ItemIds are GUID strings — never
* assume numeric ids when handling a generic Platform. */
export type Platform =
| "netease"
| "qq"
| "bilibili"
| "youtube"
| "local"
| "kugou"
| "spotify"
| "jellyfin";
export interface Song { export interface Song {
id: string; id: string;
name: string; name: string;
@@ -5,7 +17,7 @@ export interface Song {
album: string; album: string;
duration: number; // seconds duration: number; // seconds
coverUrl: string; coverUrl: string;
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify"; platform: Platform;
/** VIP / copyright-restricted: non-VIP users can only play a trial fragment /** VIP / copyright-restricted: non-VIP users can only play a trial fragment
* (NetEase fee=1 VIP / fee=4 album-only, or QQ pay.payplay/paytrackprice=1). */ * (NetEase fee=1 VIP / fee=4 album-only, or QQ pay.payplay/paytrackprice=1). */
vip?: boolean; vip?: boolean;
@@ -27,7 +39,7 @@ export interface Playlist {
name: string; name: string;
coverUrl: string; coverUrl: string;
songCount: number; songCount: number;
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify"; platform: Platform;
} }
export interface PlaylistDetail { export interface PlaylistDetail {
@@ -44,7 +56,7 @@ export interface Album {
artist: string; artist: string;
coverUrl: string; coverUrl: string;
songCount: number; songCount: number;
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify"; platform: Platform;
} }
export interface LyricLine { export interface LyricLine {
@@ -72,9 +84,9 @@ export interface AuthStatus {
} }
export interface MusicProvider { export interface MusicProvider {
readonly platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify"; readonly platform: Platform;
search(query: string, limit?: number): Promise<SearchResult>; search(query: string, limit?: number, offset?: number): Promise<SearchResult>;
getSongUrl(songId: string, quality?: string): Promise<SongUrlResult | null>; getSongUrl(songId: string, quality?: string): Promise<SongUrlResult | null>;
setQuality(quality: string): void; setQuality(quality: string): void;
getQuality(): string; getQuality(): string;
+82 -2
View File
@@ -1,5 +1,14 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { mapQqAlbums, mapQqSongs, parseQqTrial } from "./qq.js";
// All axios.create(...) instances in qq.ts (qqMusicuApi / qqSearchApi / qqFavApi
// and the per-instance api) share this single mock so the search test can
// inspect the outgoing params/body regardless of which client issued them.
const { mockGet, mockPost } = vi.hoisted(() => ({ mockGet: vi.fn(), mockPost: vi.fn() }));
vi.mock("axios", () => ({
default: { create: () => ({ get: mockGet, post: mockPost }) },
}));
import { mapQqAlbums, mapQqSongs, parseQqTrial, QQMusicProvider } from "./qq.js";
describe("QQ adapter", () => { describe("QQ adapter", () => {
it("mapQqSongs maps QQMusicApi-style song entries", () => { it("mapQqSongs maps QQMusicApi-style song entries", () => {
@@ -92,3 +101,74 @@ describe("QQ adapter", () => {
expect(out[0].id).toBe(""); expect(out[0].id).toBe("");
}); });
}); });
describe("QQMusicProvider.search pagination", () => {
beforeEach(() => {
mockGet.mockReset();
mockPost.mockReset();
});
/** musicu.fcg returns one song → primary path succeeds. */
function musicuOk() {
mockGet.mockImplementation(async (url: string) => {
if (url === "/cgi-bin/musicu.fcg") {
return {
data: {
req_0: { data: { body: { song: { list: [{ mid: "m1", name: "S", singer: [], album: {}, interval: 100 }] } } } },
req_album: { data: { body: { album: { list: [] } } } },
req_playlist: { data: { body: { songlist: { list: [] } } } },
},
};
}
return { data: {} };
});
}
function musicuReqData() {
const call = mockGet.mock.calls.find((c: any[]) => c[0] === "/cgi-bin/musicu.fcg");
expect(call, "expected a musicu.fcg call").toBeTruthy();
return JSON.parse(call![1].params.data);
}
it("adds page_num (offset/limit+1) and limit-driven num_per_page for songs/albums/playlists", async () => {
musicuOk();
const p = new QQMusicProvider("http://x");
await p.search("hello", 20, 20); // page 2
const d = musicuReqData();
expect(d.req_0.param.page_num).toBe(2);
expect(d.req_0.param.num_per_page).toBe(20);
// Albums/playlists: num_per_page must be limit-driven (NOT hardcoded 10).
expect(d.req_album.param.page_num).toBe(2);
expect(d.req_album.param.num_per_page).toBe(20);
expect(d.req_playlist.param.page_num).toBe(2);
expect(d.req_playlist.param.num_per_page).toBe(20);
});
it("defaults offset to 0 → page_num 1 (backward compatible)", async () => {
musicuOk();
const p = new QQMusicProvider("http://x");
await p.search("hello", 20);
const d = musicuReqData();
expect(d.req_0.param.page_num).toBe(1);
});
it("fallback client_search_cp sets p to the page cursor", async () => {
// musicu returns no songs → primary returns null → fallback runs.
mockGet.mockImplementation(async (url: string) => {
if (url === "/cgi-bin/musicu.fcg") {
return { data: { req_0: { data: { body: { song: { list: [] } } } } } };
}
// client_search_cp
return { data: { data: { song: { list: [] }, album: { list: [] } } } };
});
const p = new QQMusicProvider("http://x");
await p.search("hello", 20, 20); // page 2
const songCall = mockGet.mock.calls.find(
(c: any[]) => c[0] === "/soso/fcgi-bin/client_search_cp" && c[1]?.params?.type === 0
);
expect(songCall, "expected a client_search_cp song call").toBeTruthy();
expect(songCall![1].params.p).toBe(2);
});
});
+67 -11
View File
@@ -149,16 +149,16 @@ export class QQMusicProvider implements MusicProvider {
}; };
} }
async search(query: string, limit = 20): Promise<SearchResult> { async search(query: string, limit = 20, offset = 0): Promise<SearchResult> {
// Primary: u.y.qq.com/cgi-bin/musicu.fcg — supports songs + albums + // Primary: u.y.qq.com/cgi-bin/musicu.fcg — supports songs + albums +
// playlists. Fixed per https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/61 // playlists. Fixed per https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/61
// (removed searchid, num_per_page >= 10, corrected search_type values). // (removed searchid, num_per_page >= 10, corrected search_type values).
const primary = await this.searchViaMusicuFcg(query, limit); const primary = await this.searchViaMusicuFcg(query, limit, offset);
if (primary) return primary; if (primary) return primary;
// Fallback: c.y.qq.com/soso/fcgi-bin/client_search_cp (song + album, // Fallback: c.y.qq.com/soso/fcgi-bin/client_search_cp (song + album,
// no playlist support). Kept as redundancy. // no playlist support). Kept as redundancy.
return this.searchViaClientSearchCp(query, limit); return this.searchViaClientSearchCp(query, limit, offset);
} }
/** Primary search via u.y.qq.com/cgi-bin/musicu.fcg. /** Primary search via u.y.qq.com/cgi-bin/musicu.fcg.
@@ -169,25 +169,31 @@ export class QQMusicProvider implements MusicProvider {
* 3. `search_type: 2` for albums, `3` for playlists (8 was "user"). */ * 3. `search_type: 2` for albums, `3` for playlists (8 was "user"). */
private async searchViaMusicuFcg( private async searchViaMusicuFcg(
query: string, query: string,
limit: number limit: number,
offset = 0
): Promise<SearchResult | null> { ): Promise<SearchResult | null> {
try { try {
// num_per_page must stay >= 10 (lower values return empty). It is now
// limit-driven for ALL three lists (albums/playlists were hardcoded to
// 10). page_num is the offset cursor; the web always requests in
// limit-aligned pages so offset is a multiple of limit.
const numPerPage = Math.max(10, Math.min(limit, 50)); const numPerPage = Math.max(10, Math.min(limit, 50));
const pageNum = Math.floor(offset / limit) + 1;
const reqData = JSON.stringify({ const reqData = JSON.stringify({
req_0: { req_0: {
module: "music.search.SearchCgiService", module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop", method: "DoSearchForQQMusicDesktop",
param: { query, num_per_page: numPerPage, search_type: 0 }, param: { query, num_per_page: numPerPage, page_num: pageNum, search_type: 0 },
}, },
req_album: { req_album: {
module: "music.search.SearchCgiService", module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop", method: "DoSearchForQQMusicDesktop",
param: { query, num_per_page: 10, search_type: 2 }, param: { query, num_per_page: numPerPage, page_num: pageNum, search_type: 2 },
}, },
req_playlist: { req_playlist: {
module: "music.search.SearchCgiService", module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop", method: "DoSearchForQQMusicDesktop",
param: { query, num_per_page: 10, search_type: 3 }, param: { query, num_per_page: numPerPage, page_num: pageNum, search_type: 3 },
}, },
}); });
const res = await qqMusicuApi.get("/cgi-bin/musicu.fcg", { const res = await qqMusicuApi.get("/cgi-bin/musicu.fcg", {
@@ -221,12 +227,16 @@ export class QQMusicProvider implements MusicProvider {
/** Fallback search via c.y.qq.com/soso/fcgi-bin/client_search_cp */ /** Fallback search via c.y.qq.com/soso/fcgi-bin/client_search_cp */
private async searchViaClientSearchCp( private async searchViaClientSearchCp(
query: string, query: string,
limit: number limit: number,
offset = 0
): Promise<SearchResult> { ): Promise<SearchResult> {
// `p` is the 1-based page cursor. The web pages in limit-aligned steps so
// offset is a multiple of limit.
const page = Math.floor(offset / limit) + 1;
const songParams = { const songParams = {
w: query, w: query,
format: "json", format: "json",
p: 1, p: page,
n: Math.min(limit, 50), n: Math.min(limit, 50),
type: 0, type: 0,
cr: 1, cr: 1,
@@ -234,7 +244,7 @@ export class QQMusicProvider implements MusicProvider {
const albumParams = { const albumParams = {
w: query, w: query,
format: "json", format: "json",
p: 1, p: page,
n: 5, n: 5,
t: 8, t: 8,
cr: 1, cr: 1,
@@ -354,8 +364,18 @@ export class QQMusicProvider implements MusicProvider {
}; };
} }
} catch { } catch {
// fall through to stub // fall through to musicu.fcg fallback
} }
// Fallback: fetch metadata via u.y.qq.com/cgi-bin/musicu.fcg. The bundled
// library's /getSongInfo route is broken (upstream code 500001), so the
// primary try above almost always falls through here for QQ. Without this,
// play-by-id songs carry an empty name → the TS nickname / now-playing
// message renders as "♪ 正在播放: - []". This endpoint (same host/shape
// as search) reliably returns full track_info without requiring login.
const detail = await this.fetchSongDetailViaMusicu(songId);
if (detail) return detail;
// Minimal stub — resolveAndPlay only needs id + platform to fetch a // Minimal stub — resolveAndPlay only needs id + platform to fetch a
// play URL. Name/artist/album will be empty in play history, but the // play URL. Name/artist/album will be empty in play history, but the
// song will actually play, which is the important part. // song will actually play, which is the important part.
@@ -370,6 +390,42 @@ export class QQMusicProvider implements MusicProvider {
}; };
} }
/** Fetch a single song's metadata via u.y.qq.com/cgi-bin/musicu.fcg
* (module music.pf_song_detail_svr / get_song_detail_yqq). This mirrors the
* search path's host + request shape and, unlike the library's /getSongInfo,
* actually works against QQ's current API. Returns null on any failure so the
* caller can fall back to the minimal stub. */
private async fetchSongDetailViaMusicu(songId: string): Promise<Song | null> {
try {
const reqData = JSON.stringify({
req_0: {
module: "music.pf_song_detail_svr",
method: "get_song_detail_yqq",
param: { song_mid: songId, song_type: 0 },
},
});
const res = await qqMusicuApi.get("/cgi-bin/musicu.fcg", {
params: { format: "json", data: reqData },
});
const t = res.data?.req_0?.data?.track_info;
if (!t) return null;
const albumMid = t.album?.mid ?? t.album?.pmid ?? "";
return {
id: String(t.mid ?? t.id ?? songId),
name: t.name ?? t.title ?? "",
artist: (t.singer ?? []).map((a: any) => a.name ?? a.title ?? "").filter(Boolean).join(" / "),
album: t.album?.name ?? t.album?.title ?? "",
duration: t.interval ?? 0,
coverUrl: albumMid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${albumMid}.jpg`
: "",
platform: "qq",
};
} catch {
return null;
}
}
async getPlaylistSongs(playlistId: string): Promise<Song[]> { async getPlaylistSongs(playlistId: string): Promise<Song[]> {
const res = await this.api.get("/getSongListDetail", { const res = await this.api.get("/getSongListDetail", {
params: { disstid: playlistId, ...this.cookieParams }, params: { disstid: playlistId, ...this.cookieParams },
+9 -3
View File
@@ -115,17 +115,23 @@ export class YouTubeProvider implements MusicProvider {
readonly platform = "youtube" as const; readonly platform = "youtube" as const;
private quality = "bestaudio"; private quality = "bestaudio";
async search(query: string, limit = 5): Promise<SearchResult> { async search(query: string, limit = 5, offset = 0): Promise<SearchResult> {
try { try {
// yt-dlp's `ytsearchN` has no offset cursor — it always returns the first
// N results. Best-effort paginate by fetching offset+limit and slicing
// locally. (offset 0 → identical to before.)
const total = offset + limit;
const raw = await runYtDlp([ const raw = await runYtDlp([
`ytsearch${limit}:${query}`, `ytsearch${total}:${query}`,
"--dump-json", "--dump-json",
"--flat-playlist", "--flat-playlist",
"--no-warnings", "--no-warnings",
"--quiet", "--quiet",
]); ]);
const lines = raw.trim().split("\n").filter(Boolean); const lines = raw.trim().split("\n").filter(Boolean);
const songs: Song[] = lines.map((line) => { const songs: Song[] = lines
.slice(offset, offset + limit)
.map((line) => {
const entry = JSON.parse(line) as YtDlpEntry; const entry = JSON.parse(line) as YtDlpEntry;
return entryToSong(entry); return entryToSong(entry);
}); });
+74
View File
@@ -0,0 +1,74 @@
import { describe, it, expect, vi } from "vitest";
import express from "express";
import request from "supertest";
import pino from "pino";
import type { MusicProvider } from "../../music/provider.js";
import { getDefaultConfig, type JellyfinConfig } from "../../data/config.js";
import { createAuthRouter } from "./auth.js";
function fakeProvider(platform: MusicProvider["platform"]): MusicProvider {
return { platform } as unknown as MusicProvider;
}
describe("auth router POST /jellyfin/test", () => {
function mount(
stored: Partial<JellyfinConfig>,
user: unknown = { role: "admin" },
) {
const config = getDefaultConfig();
Object.assign(config.jellyfin, stored);
const testConnection = vi.fn().mockResolvedValue({ ok: true, serverName: "JF" });
const jellyfin = { platform: "jellyfin", testConnection } as unknown as MusicProvider;
const app = express();
app.use(express.json());
app.use((req, _res, next) => { (req as { user?: unknown }).user = user; next(); });
app.use(
"/api/auth",
createAuthRouter(
fakeProvider("netease"), fakeProvider("qq"), fakeProvider("bilibili"),
pino({ level: "silent" }), undefined, undefined, undefined, jellyfin, config,
),
);
return { app, testConnection };
}
it("fills empty credential fields from the stored config (masked password case)", async () => {
const { app, testConnection } = mount({
serverUrl: "https://old.example.com",
username: "bob",
password: "stored-pw",
});
const res = await request(app)
.post("/api/auth/jellyfin/test")
.send({ serverUrl: "https://new.example.com", username: "bob", password: "" });
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
expect(testConnection).toHaveBeenCalledWith(
expect.objectContaining({
serverUrl: "https://new.example.com",
username: "bob",
password: "stored-pw",
}),
);
});
it("passes freshly entered credentials through", async () => {
const { app, testConnection } = mount({});
await request(app).post("/api/auth/jellyfin/test").send({
serverUrl: "https://jf.example.com",
authMode: "apikey",
apiKey: "key123",
userId: "u1",
});
expect(testConnection).toHaveBeenCalledWith(
expect.objectContaining({ authMode: "apikey", apiKey: "key123", userId: "u1" }),
);
});
it("is 403 for a member lacking platform.auth", async () => {
const { app, testConnection } = mount({}, { role: "member", capabilities: new Set([]) });
const res = await request(app).post("/api/auth/jellyfin/test").send({});
expect(res.status).toBe(403);
expect(testConnection).not.toHaveBeenCalled();
});
});
+52 -1
View File
@@ -3,6 +3,7 @@ import type { MusicProvider } from "../../music/provider.js";
import { YouTubeProvider } from "../../music/youtube.js"; import { YouTubeProvider } from "../../music/youtube.js";
import type { CookieStore } from "../../music/auth.js"; import type { CookieStore } from "../../music/auth.js";
import type { Logger } from "../../logger.js"; import type { Logger } from "../../logger.js";
import type { BotConfig, JellyfinConfig } from "../../data/config.js";
import { requirePermission } from "../middleware/requirePermission.js"; import { requirePermission } from "../middleware/requirePermission.js";
import { requireNotGuest } from "../middleware/requireNotGuest.js"; import { requireNotGuest } from "../middleware/requireNotGuest.js";
@@ -13,7 +14,9 @@ export function createAuthRouter(
logger: Logger, logger: Logger,
cookieStore?: CookieStore, cookieStore?: CookieStore,
kugouProvider?: MusicProvider, kugouProvider?: MusicProvider,
spotifyProvider?: MusicProvider spotifyProvider?: MusicProvider,
jellyfinProvider?: MusicProvider,
config?: BotConfig
): Router { ): Router {
const router = Router(); const router = Router();
// YouTube is auth-less; we only use this instance so /auth/status can // YouTube is auth-less; we only use this instance so /auth/status can
@@ -25,6 +28,7 @@ export function createAuthRouter(
if (platform === "youtube") return youtubeProvider; if (platform === "youtube") return youtubeProvider;
if (platform === "kugou" && kugouProvider) return kugouProvider; if (platform === "kugou" && kugouProvider) return kugouProvider;
if (platform === "spotify" && spotifyProvider) return spotifyProvider; if (platform === "spotify" && spotifyProvider) return spotifyProvider;
if (platform === "jellyfin" && jellyfinProvider) return jellyfinProvider;
return platform === "qq" ? qqProvider : neteaseProvider; return platform === "qq" ? qqProvider : neteaseProvider;
} }
@@ -84,6 +88,45 @@ export function createAuthRouter(
} }
}); });
// Jellyfin has no QR/cookie flow — the connection is admin-configured. This
// round-trips /System/Info so Settings can verify form values BEFORE saving.
// Empty/missing credential fields fall back to the stored config, so a
// masked (not re-entered) password still tests the live setup.
router.post("/jellyfin/test", requirePermission("platform.auth"), async (req, res) => {
const testable = jellyfinProvider as
| (MusicProvider & {
testConnection?: (
candidate?: JellyfinConfig,
) => Promise<{ ok: boolean; serverName?: string; version?: string; error?: string }>;
})
| undefined;
if (!testable?.testConnection) {
res.status(501).json({ error: "Jellyfin provider not available" });
return;
}
try {
const body = (req.body ?? {}) as Partial<JellyfinConfig>;
const stored = config?.jellyfin;
const str = (v: unknown, fallback: string) =>
typeof v === "string" && v.trim() !== "" ? v.trim() : fallback;
const candidate: JellyfinConfig = {
serverUrl: str(body.serverUrl, stored?.serverUrl ?? ""),
authMode:
body.authMode === "apikey" || body.authMode === "userpass"
? body.authMode
: stored?.authMode ?? "userpass",
username: str(body.username, stored?.username ?? ""),
password: str(body.password, stored?.password ?? ""),
apiKey: str(body.apiKey, stored?.apiKey ?? ""),
userId: str(body.userId, stored?.userId ?? ""),
};
res.json(await testable.testConnection(candidate));
} catch (err) {
logger.error({ err }, "Jellyfin test connection failed");
res.status(500).json({ error: (err as Error).message });
}
});
router.post("/sms/send", requirePermission("platform.auth"), async (req, res) => { router.post("/sms/send", requirePermission("platform.auth"), async (req, res) => {
try { try {
const { phone } = req.body; const { phone } = req.body;
@@ -139,6 +182,14 @@ export function createAuthRouter(
.json({ error: "YouTube does not use cookies (uses yt-dlp binary)" }); .json({ error: "YouTube does not use cookies (uses yt-dlp binary)" });
return; return;
} }
// Jellyfin auth is server-configured (Settings → connection card), not
// cookie-based; falling through would clobber the NetEase cookie entry.
if (platform === "jellyfin") {
res
.status(400)
.json({ error: "Jellyfin 通过 Settings 配置连接,不支持手动 Cookie" });
return;
}
const provider = getProvider(platform); const provider = getProvider(platform);
provider.setCookie(cookie); provider.setCookie(cookie);
const plat = platform === "bilibili" ? "bilibili" as const const plat = platform === "bilibili" ? "bilibili" as const
+170 -1
View File
@@ -13,7 +13,7 @@ import { createAvatarStore } from "../../data/avatars.js";
import { createRequireAuth } from "../middleware/requireAuth.js"; import { createRequireAuth } from "../middleware/requireAuth.js";
import { createPermissionStore } from "../../data/permissions.js"; import { createPermissionStore } from "../../data/permissions.js";
import { createBotRouter } from "./bot.js"; import { createBotRouter } from "./bot.js";
import { getDefaultConfig, type BotConfig } from "../../data/config.js"; import { getDefaultConfig, type BotConfig, type JellyfinConfig } from "../../data/config.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js"; import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
import type { BotManager } from "../../bot/manager.js"; import type { BotManager } from "../../bot/manager.js";
@@ -325,6 +325,38 @@ describe("bot router /settings", () => {
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(config.spotify).toEqual(before); expect(config.spotify).toEqual(before);
}); });
it("GET /settings echoes savedQueuesEnabled + playKeepsQueue (default false)", async () => {
const res = await request(app).get("/api/bot/settings").set("Cookie", cookie);
expect(res.status).toBe(200);
expect(res.body.savedQueuesEnabled).toBe(false);
expect(res.body.playKeepsQueue).toBe(false);
});
it("POST /settings persists savedQueuesEnabled and playKeepsQueue", async () => {
const res = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ savedQueuesEnabled: true, playKeepsQueue: true });
expect(res.status).toBe(200);
expect(res.body.savedQueuesEnabled).toBe(true);
expect(res.body.playKeepsQueue).toBe(true);
expect(config.savedQueuesEnabled).toBe(true);
expect(config.playKeepsQueue).toBe(true);
const get = await request(app).get("/api/bot/settings").set("Cookie", cookie);
expect(get.body.savedQueuesEnabled).toBe(true);
expect(get.body.playKeepsQueue).toBe(true);
});
it("POST /settings ignores non-boolean savedQueuesEnabled without 400", async () => {
const res = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ savedQueuesEnabled: "nope" });
expect(res.status).toBe(200);
expect(config.savedQueuesEnabled).toBe(false); // unchanged from default
});
}); });
// Whole-branch I2: saving a Client ID in Settings must re-configure the single // Whole-branch I2: saving a Client ID in Settings must re-configure the single
@@ -561,3 +593,140 @@ describe("bot router /settings guest-mode gating + persistence", () => {
expect(config.spotify.clientId).toBe(""); expect(config.spotify.clientId).toBe("");
}); });
}); });
describe("bot router /settings jellyfin block + enabledProviders", () => {
let tmpDir: string;
let configPath: string;
let config: BotConfig;
let botDb: BotDatabase;
let configureCalls: JellyfinConfig[];
beforeEach(() => {
botDb = createDatabase(":memory:");
tmpDir = mkdtempSync(join(tmpdir(), "botsettings-jf-"));
configPath = join(tmpDir, "config.json");
config = getDefaultConfig();
configureCalls = [];
});
afterEach(() => {
botDb.close();
rmSync(tmpDir, { recursive: true, force: true });
});
function mountBot(): express.Express {
const fakeManager = { getAllBots: () => [] } as unknown as BotManager;
const avatarStore = createAvatarStore(tmpDir);
const app = express();
app.use(express.json());
app.use((req, _res, next) => { (req as { user?: unknown }).user = { role: "admin" }; next(); });
app.use(
"/api/bot",
createBotRouter(
fakeManager, config, configPath, pino({ level: "silent" }), botDb, avatarStore,
undefined, undefined, undefined,
{ configure: (cfg: JellyfinConfig) => configureCalls.push({ ...cfg }) },
),
);
return app;
}
it("GET /settings exposes a masked jellyfin block (no password/apiKey echo)", async () => {
config.jellyfin.serverUrl = "https://jf.example.com";
config.jellyfin.password = "secret";
const res = await request(mountBot()).get("/api/bot/settings");
expect(res.status).toBe(200);
expect(res.body.jellyfin).toEqual({
serverUrl: "https://jf.example.com",
authMode: "userpass",
username: "",
userId: "",
hasPassword: true,
hasApiKey: false,
});
// 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 () => {
config.jellyfin.password = "stored-pw";
const app = mountBot();
const res = await request(app).post("/api/bot/settings").send({
jellyfin: { serverUrl: "https://jf.example.com///", username: "bob", password: "" },
});
expect(res.status).toBe(200);
// Trailing slashes normalized; blank password kept the stored one.
expect(config.jellyfin.serverUrl).toBe("https://jf.example.com");
expect(config.jellyfin.username).toBe("bob");
expect(config.jellyfin.password).toBe("stored-pw");
expect(res.body.jellyfin.hasPassword).toBe(true);
// Live provider re-configured with the post-merge values.
expect(configureCalls).toHaveLength(1);
expect(configureCalls[0].serverUrl).toBe("https://jf.example.com");
expect(configureCalls[0].password).toBe("stored-pw");
// Persisted to disk.
const onDisk = JSON.parse(readFileSync(configPath, "utf-8"));
expect(onDisk.jellyfin.username).toBe("bob");
});
it("POST /settings filters enabledProviders to known providers", async () => {
const res = await request(mountBot()).post("/api/bot/settings").send({
enabledProviders: ["jellyfin", "netease", "bogus", 42],
});
expect(res.status).toBe(200);
expect(res.body.enabledProviders).toEqual(["jellyfin", "netease"]);
expect(config.enabledProviders).toEqual(["jellyfin", "netease"]);
// 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();
});
});
+94 -2
View File
@@ -1,7 +1,7 @@
import { Router } from "express"; import { Router } from "express";
import type { BotManager } from "../../bot/manager.js"; import type { BotManager } from "../../bot/manager.js";
import type { BotConfig, GuestModeConfig, SpotifyConfig } from "../../data/config.js"; import type { BotConfig, GuestModeConfig, SpotifyConfig, JellyfinConfig, GateableProvider } from "../../data/config.js";
import { saveConfig } from "../../data/config.js"; import { saveConfig, GATEABLE_PROVIDERS } from "../../data/config.js";
import type { Logger } from "../../logger.js"; import type { Logger } from "../../logger.js";
import type { BotDatabase } from "../../data/database.js"; import type { BotDatabase } from "../../data/database.js";
import type { AvatarStore } from "../../data/avatars.js"; import type { AvatarStore } from "../../data/avatars.js";
@@ -25,6 +25,11 @@ export function createBotRouter(
// Settings must push creds here too, or search stays empty until a restart. // Settings must push creds here too, or search stays empty until a restart.
// Structural type = SpotifyProvider.setCreds. // Structural type = SpotifyProvider.setCreds.
spotifyProvider?: { setCreds(clientId: string, clientSecret: string): void }, spotifyProvider?: { setCreds(clientId: string, clientSecret: string): void },
// The live JellyfinProvider, so a Settings save re-points the connection
// without a restart (configure() drops the cached token only when
// credential-relevant fields actually changed). Structural type =
// JellyfinProvider.configure.
jellyfinProvider?: { configure(cfg: JellyfinConfig): void },
): Router { ): Router {
const router = Router(); const router = Router();
@@ -39,6 +44,17 @@ export function createBotRouter(
hasClientSecret: config.spotify.clientSecret.length > 0, hasClientSecret: config.spotify.clientSecret.length > 0,
}); });
// Masked jellyfin view: password and apiKey are write-only — only their
// presence is reported (mirrors maskedSpotify's clientSecret handling).
const maskedJellyfin = () => ({
serverUrl: config.jellyfin.serverUrl,
authMode: config.jellyfin.authMode,
username: config.jellyfin.username,
userId: config.jellyfin.userId,
hasPassword: config.jellyfin.password.length > 0,
hasApiKey: config.jellyfin.apiKey.length > 0,
});
router.get("/", (req, res) => { router.get("/", (req, res) => {
const all = botManager.getAllBots().map((b) => b.getStatus()); const all = botManager.getAllBots().map((b) => b.getStatus());
const u = req.user!; const u = req.user!;
@@ -56,9 +72,14 @@ export function createBotRouter(
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0, idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
autoPauseOnEmpty: config.autoPauseOnEmpty, autoPauseOnEmpty: config.autoPauseOnEmpty,
localAudioEnabled: config.localAudioEnabled, localAudioEnabled: config.localAudioEnabled,
savedQueuesEnabled: config.savedQueuesEnabled,
playKeepsQueue: config.playKeepsQueue,
adminGroups: config.adminGroups ?? [], adminGroups: config.adminGroups ?? [],
guestMode: config.guestMode, guestMode: config.guestMode,
spotify: maskedSpotify(), spotify: maskedSpotify(),
jellyfin: maskedJellyfin(),
enabledProviders: config.enabledProviders,
defaultPlatform: config.defaultPlatform,
}); });
}); });
@@ -80,6 +101,16 @@ export function createBotRouter(
if (hasAutoPause) config.autoPauseOnEmpty = autoPauseOnEmpty; if (hasAutoPause) config.autoPauseOnEmpty = autoPauseOnEmpty;
if (hasLocalAudioEnabled) config.localAudioEnabled = localAudioEnabled; if (hasLocalAudioEnabled) config.localAudioEnabled = localAudioEnabled;
// Saved-queues + play-keeps-queue toggles (default off). Both read live from
// config by BotInstance / the saved-queues router, so no per-bot push needed;
// only a literal boolean mutates the stored value (junk is ignored).
if (typeof req.body.savedQueuesEnabled === "boolean") {
config.savedQueuesEnabled = req.body.savedQueuesEnabled;
}
if (typeof req.body.playKeepsQueue === "boolean") {
config.playKeepsQueue = req.body.playKeepsQueue;
}
const hasGuestMode = guestMode !== undefined && guestMode !== null && typeof guestMode === "object"; const hasGuestMode = guestMode !== undefined && guestMode !== null && typeof guestMode === "object";
if (hasGuestMode) { if (hasGuestMode) {
const gm = config.guestMode; const gm = config.guestMode;
@@ -130,8 +161,64 @@ export function createBotRouter(
} }
} }
// Partial-merge the jellyfin block (same contract as spotify): invalid
// sub-fields are ignored; password/apiKey are write-only and only stored
// when non-empty so a blank (masked) field never wipes them.
const jf = req.body?.jellyfin;
if (jf && typeof jf === "object") {
const t = config.jellyfin;
if (typeof jf.serverUrl === "string") {
t.serverUrl = jf.serverUrl.trim().replace(/\/+$/, "");
}
if (jf.authMode === "userpass" || jf.authMode === "apikey") t.authMode = jf.authMode;
if (typeof jf.username === "string") t.username = jf.username;
if (typeof jf.password === "string" && jf.password.length > 0) t.password = jf.password;
if (typeof jf.apiKey === "string" && jf.apiKey.length > 0) t.apiKey = jf.apiKey;
if (typeof jf.userId === "string") t.userId = jf.userId;
}
// enabledProviders: full replace, known providers only (mirrors loadConfig).
// An empty array is a valid "all gateable sources off". NOTE: the NetEase/QQ
// sidecar API servers are only started at boot, so newly enabling those two
// still needs a restart; jellyfin and the rest take effect immediately.
const ep = req.body?.enabledProviders;
if (Array.isArray(ep)) {
config.enabledProviders = ep.filter((p: unknown): p is GateableProvider =>
(GATEABLE_PROVIDERS as readonly string[]).includes(p as string),
);
}
// 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); saveConfig(configPath, config);
// Hot-apply the (possibly re-pointed) Jellyfin connection to the live
// provider — a Settings save must work without a restart.
if (jf && typeof jf === "object") {
jellyfinProvider?.configure(config.jellyfin);
}
// I2: only when the spotify block was present, push the (possibly UI-entered) // I2: only when the spotify block was present, push the (possibly UI-entered)
// Client ID into the live process-wide OAuth so Connect works without a // Client ID into the live process-wide OAuth so Connect works without a
// restart. Empty clientId => undefined redirect => configure() disables OAuth. // restart. Empty clientId => undefined redirect => configure() disables OAuth.
@@ -163,9 +250,14 @@ export function createBotRouter(
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0, idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
autoPauseOnEmpty: config.autoPauseOnEmpty, autoPauseOnEmpty: config.autoPauseOnEmpty,
localAudioEnabled: config.localAudioEnabled, localAudioEnabled: config.localAudioEnabled,
savedQueuesEnabled: config.savedQueuesEnabled,
playKeepsQueue: config.playKeepsQueue,
adminGroups: config.adminGroups ?? [], adminGroups: config.adminGroups ?? [],
guestMode: config.guestMode, guestMode: config.guestMode,
spotify: maskedSpotify(), spotify: maskedSpotify(),
jellyfin: maskedJellyfin(),
enabledProviders: config.enabledProviders,
defaultPlatform: config.defaultPlatform,
}); });
}); });
+297
View File
@@ -0,0 +1,297 @@
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, 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: [] };
function fakeProvider(platform: MusicProvider["platform"]): MusicProvider {
return {
platform,
search: vi.fn().mockResolvedValue(empty),
} as unknown as MusicProvider;
}
describe("music router GET /search offset pagination", () => {
let app: express.Express;
let netease: MusicProvider;
beforeEach(() => {
netease = fakeProvider("netease");
const router = createMusicRouter(
netease,
fakeProvider("qq"),
fakeProvider("bilibili"),
pino({ level: "silent" })
);
app = express();
app.use("/api/music", router);
});
it("parses offset and passes it as the 3rd arg to provider.search", async () => {
const res = await request(app).get("/api/music/search?q=hello&limit=20&offset=20");
expect(res.status).toBe(200);
expect(netease.search).toHaveBeenCalledWith("hello", 20, 20);
});
it("defaults a missing offset to 0", async () => {
const res = await request(app).get("/api/music/search?q=hello&limit=20");
expect(res.status).toBe(200);
expect(netease.search).toHaveBeenCalledWith("hello", 20, 0);
});
it("clamps a negative offset to 0", async () => {
const res = await request(app).get("/api/music/search?q=hello&limit=20&offset=-5");
expect(res.status).toBe(200);
expect(netease.search).toHaveBeenCalledWith("hello", 20, 0);
});
});
describe("music router provider gating (enabledProviders) + jellyfin endpoints", () => {
function jellyfinFake(): MusicProvider {
return {
platform: "jellyfin",
search: vi.fn().mockResolvedValue(empty),
getQuality: vi.fn().mockReturnValue("direct"),
getLatestAlbums: vi
.fn()
.mockResolvedValue([{ id: "a1", name: "Album", platform: "jellyfin" }]),
getFavoriteSongs: vi.fn().mockResolvedValue([]),
} as unknown as MusicProvider;
}
function mount(config: BotConfig) {
const netease = fakeProvider("netease");
const jellyfin = jellyfinFake();
const router = createMusicRouter(
netease,
fakeProvider("qq"),
fakeProvider("bilibili"),
pino({ level: "silent" }),
undefined,
config,
fakeProvider("kugou"),
fakeProvider("spotify"),
jellyfin,
);
const app = express();
app.use("/api/music", router);
return { app, netease, jellyfin };
}
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(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 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(jellyfin.search).not.toHaveBeenCalled();
});
it("allows an explicitly enabled jellyfin platform", async () => {
const config = getDefaultConfig();
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(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("netease");
expect(res.body.enabled).toContain("netease");
expect(res.body.enabled).toContain("local"); // localAudioEnabled defaults on
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(configWithJellyfin());
const res = await request(app).get("/api/music/jellyfin/latest-albums?limit=5");
expect(res.status).toBe(200);
expect(
(jellyfin as unknown as { getLatestAlbums: ReturnType<typeof vi.fn> }).getLatestAlbums,
).toHaveBeenCalledWith(5);
expect(res.body.albums).toHaveLength(1);
});
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(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");
});
});
+201 -22
View File
@@ -1,8 +1,8 @@
import express, { Router } from "express"; import express, { Router, type Response } from "express";
import type { MusicProvider } from "../../music/provider.js"; import type { MusicProvider, Song, Album } from "../../music/provider.js";
import { YouTubeProvider } from "../../music/youtube.js"; import { YouTubeProvider } from "../../music/youtube.js";
import type { Logger } from "../../logger.js"; import type { Logger } from "../../logger.js";
import type { BotConfig } from "../../data/config.js"; import { isProviderEnabled, defaultPlatform, saveConfig, type BotConfig } from "../../data/config.js";
import { requirePermission } from "../middleware/requirePermission.js"; import { requirePermission } from "../middleware/requirePermission.js";
import { requireNotGuest } from "../middleware/requireNotGuest.js"; import { requireNotGuest } from "../middleware/requireNotGuest.js";
import { authorize } from "../middleware/authorize.js"; import { authorize } from "../middleware/authorize.js";
@@ -15,7 +15,11 @@ export function createMusicRouter(
localProvider?: MusicProvider, localProvider?: MusicProvider,
config?: BotConfig, config?: BotConfig,
kugouProvider?: MusicProvider, kugouProvider?: MusicProvider,
spotifyProvider?: MusicProvider spotifyProvider?: 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 { ): Router {
const router = Router(); const router = Router();
const youtubeProvider: MusicProvider = new YouTubeProvider(); const youtubeProvider: MusicProvider = new YouTubeProvider();
@@ -30,9 +34,27 @@ export function createMusicRouter(
if (platform === "local" && localProvider) return localProvider; if (platform === "local" && localProvider) return localProvider;
if (platform === "kugou" && kugouProvider) return kugouProvider; if (platform === "kugou" && kugouProvider) return kugouProvider;
if (platform === "spotify" && spotifyProvider) return spotifyProvider; if (platform === "spotify" && spotifyProvider) return spotifyProvider;
if (platform === "jellyfin" && jellyfinProvider) return jellyfinProvider;
return platform === "qq" ? qqProvider : neteaseProvider; return platform === "qq" ? qqProvider : neteaseProvider;
} }
/**
* Provider gating for user-supplied platform params. No platform → the
* 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;
const target = requested ?? (config ? defaultPlatform(config) : "netease");
if (config && !isProviderEnabled(config, target)) {
res.status(400).json({ error: `音源未启用:${target} (provider disabled)` });
return null;
}
return getProvider(target);
}
router.post( router.post(
"/local/upload", "/local/upload",
authorize({ capability: "player.queue", guestFlag: "addToQueue" }), authorize({ capability: "player.queue", guestFlag: "addToQueue" }),
@@ -86,7 +108,7 @@ export function createMusicRouter(
router.get("/search", async (req, res) => { router.get("/search", async (req, res) => {
try { try {
const { q, platform, limit } = req.query; const { q, platform, limit, offset } = req.query;
if (!q) { if (!q) {
res.status(400).json({ error: "q (query) is required" }); res.status(400).json({ error: "q (query) is required" });
return; return;
@@ -95,10 +117,15 @@ export function createMusicRouter(
res.json({ songs: [], playlists: [], albums: [] }); res.json({ songs: [], playlists: [], albums: [] });
return; return;
} }
const provider = getProvider(platform as string); const provider = resolveProvider(platform, res);
if (!provider) return;
// Server-side pagination: offset lets the web load past the first page.
// Clamp to >= 0 so a bad/negative value falls back to the first page.
const parsedOffset = Math.max(0, parseInt(offset as string) || 0);
const result = await provider.search( const result = await provider.search(
q as string, q as string,
parseInt(limit as string) || 20 parseInt(limit as string) || 20,
parsedOffset
); );
res.json(result); res.json(result);
} catch (err) { } catch (err) {
@@ -120,15 +147,22 @@ export function createMusicRouter(
// backend lands (Stage 2/3), so surfacing them in the default all-sources // backend lands (Stage 2/3), so surfacing them in the default all-sources
// view would only yield results that get skipped. Spotify search remains // view would only yield results that get skipped. Spotify search remains
// available from its own tab via /search?platform=spotify. // available from its own tab via /search?platform=spotify.
const [neteaseResult, qqResult, bilibiliResult, localResult, kugouResult] = await Promise.allSettled([ // Provider gating (#enabledProviders): disabled sources are skipped, not
neteaseProvider.search(q as string, parsedLimit), // searched. Jellyfin (an opt-in source) leads the merged results when
qqProvider.search(q as string, parsedLimit), // enabled — a self-hosted library match is almost always the wanted one.
bilibiliProvider.search(q as string, parsedLimit), const enabled = (p: string) => !config || isProviderEnabled(config, p);
localProvider && isLocalAudioEnabled() ? localProvider.search(q as string, parsedLimit) : Promise.resolve({ songs: [], albums: [], playlists: [] }), const none = { songs: [], albums: [], playlists: [] };
kugouProvider ? kugouProvider.search(q as string, parsedLimit) : Promise.resolve({ songs: [], albums: [], playlists: [] }), const [jellyfinResult, neteaseResult, qqResult, bilibiliResult, localResult, kugouResult] = await Promise.allSettled([
jellyfinProvider && enabled("jellyfin") ? jellyfinProvider.search(q as string, parsedLimit) : Promise.resolve(none),
enabled("netease") ? neteaseProvider.search(q as string, parsedLimit) : Promise.resolve(none),
enabled("qq") ? qqProvider.search(q as string, parsedLimit) : Promise.resolve(none),
enabled("bilibili") ? bilibiliProvider.search(q as string, parsedLimit) : Promise.resolve(none),
localProvider && isLocalAudioEnabled() ? localProvider.search(q as string, parsedLimit) : Promise.resolve(none),
kugouProvider && enabled("kugou") ? kugouProvider.search(q as string, parsedLimit) : Promise.resolve(none),
]); ]);
const songs = [ const songs = [
...(jellyfinResult.status === "fulfilled" ? jellyfinResult.value.songs : []),
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.songs : []), ...(neteaseResult.status === "fulfilled" ? neteaseResult.value.songs : []),
...(qqResult.status === "fulfilled" ? qqResult.value.songs : []), ...(qqResult.status === "fulfilled" ? qqResult.value.songs : []),
...(bilibiliResult.status === "fulfilled" ? bilibiliResult.value.songs : []), ...(bilibiliResult.status === "fulfilled" ? bilibiliResult.value.songs : []),
@@ -136,10 +170,12 @@ export function createMusicRouter(
...(kugouResult.status === "fulfilled" ? kugouResult.value.songs : []), ...(kugouResult.status === "fulfilled" ? kugouResult.value.songs : []),
]; ];
const albums = [ const albums = [
...(jellyfinResult.status === "fulfilled" ? jellyfinResult.value.albums : []),
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.albums : []), ...(neteaseResult.status === "fulfilled" ? neteaseResult.value.albums : []),
...(qqResult.status === "fulfilled" ? qqResult.value.albums : []), ...(qqResult.status === "fulfilled" ? qqResult.value.albums : []),
]; ];
const playlists = [ const playlists = [
...(jellyfinResult.status === "fulfilled" ? jellyfinResult.value.playlists : []),
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.playlists : []), ...(neteaseResult.status === "fulfilled" ? neteaseResult.value.playlists : []),
...(qqResult.status === "fulfilled" ? qqResult.value.playlists : []), ...(qqResult.status === "fulfilled" ? qqResult.value.playlists : []),
]; ];
@@ -157,7 +193,8 @@ export function createMusicRouter(
res.status(403).json({ error: "本地音频播放已关闭" }); res.status(403).json({ error: "本地音频播放已关闭" });
return; return;
} }
const provider = getProvider(req.query.platform as string); const provider = resolveProvider(req.query.platform, res);
if (!provider) return;
const song = await provider.getSongDetail(req.params.id); const song = await provider.getSongDetail(req.params.id);
if (!song) { if (!song) {
res.status(404).json({ error: "Song not found" }); res.status(404).json({ error: "Song not found" });
@@ -171,7 +208,8 @@ export function createMusicRouter(
router.get("/playlist/:id", async (req, res) => { router.get("/playlist/:id", async (req, res) => {
try { try {
const provider = getProvider(req.query.platform as string); const provider = resolveProvider(req.query.platform, res);
if (!provider) return;
const songs = await provider.getPlaylistSongs(req.params.id); const songs = await provider.getPlaylistSongs(req.params.id);
res.json({ songs }); res.json({ songs });
} catch (err) { } catch (err) {
@@ -181,7 +219,8 @@ export function createMusicRouter(
router.get("/recommend/playlists", async (req, res) => { router.get("/recommend/playlists", async (req, res) => {
try { try {
const provider = getProvider(req.query.platform as string); const provider = resolveProvider(req.query.platform, res);
if (!provider) return;
const playlists = await provider.getRecommendPlaylists(); const playlists = await provider.getRecommendPlaylists();
res.json({ playlists }); res.json({ playlists });
} catch (err) { } catch (err) {
@@ -191,7 +230,8 @@ export function createMusicRouter(
router.get("/album/:id", async (req, res) => { router.get("/album/:id", async (req, res) => {
try { try {
const provider = getProvider(req.query.platform as string); const provider = resolveProvider(req.query.platform, res);
if (!provider) return;
const songs = await provider.getAlbumSongs(req.params.id); const songs = await provider.getAlbumSongs(req.params.id);
res.json({ songs }); res.json({ songs });
} catch (err) { } catch (err) {
@@ -201,7 +241,8 @@ export function createMusicRouter(
router.get("/lyrics/:id", async (req, res) => { router.get("/lyrics/:id", async (req, res) => {
try { try {
const provider = getProvider(req.query.platform as string); const provider = resolveProvider(req.query.platform, res);
if (!provider) return;
const lyrics = await provider.getLyrics(req.params.id); const lyrics = await provider.getLyrics(req.params.id);
res.json({ lyrics }); res.json({ lyrics });
} catch (err) { } catch (err) {
@@ -211,7 +252,8 @@ export function createMusicRouter(
router.get("/recommend/songs", requireNotGuest, async (req, res) => { router.get("/recommend/songs", requireNotGuest, async (req, res) => {
try { try {
const provider = getProvider(req.query.platform as string); const provider = resolveProvider(req.query.platform, res);
if (!provider) return;
if (!provider.getDailyRecommendSongs) { if (!provider.getDailyRecommendSongs) {
res.status(501).json({ error: "Not supported by this provider" }); res.status(501).json({ error: "Not supported by this provider" });
return; return;
@@ -226,7 +268,8 @@ export function createMusicRouter(
router.get("/personal/fm", requireNotGuest, async (req, res) => { router.get("/personal/fm", requireNotGuest, async (req, res) => {
try { try {
const provider = getProvider(req.query.platform as string); const provider = resolveProvider(req.query.platform, res);
if (!provider) return;
if (!provider.getPersonalFm) { if (!provider.getPersonalFm) {
res.status(501).json({ error: "Not supported by this provider" }); res.status(501).json({ error: "Not supported by this provider" });
return; return;
@@ -241,7 +284,8 @@ export function createMusicRouter(
router.get("/user/playlists", requireNotGuest, async (req, res) => { router.get("/user/playlists", requireNotGuest, async (req, res) => {
try { try {
const provider = getProvider(req.query.platform as string); const provider = resolveProvider(req.query.platform, res);
if (!provider) return;
if (!provider.getUserPlaylists) { if (!provider.getUserPlaylists) {
res.status(501).json({ error: "Not supported by this provider" }); res.status(501).json({ error: "Not supported by this provider" });
return; return;
@@ -256,7 +300,8 @@ export function createMusicRouter(
router.get("/playlist/:id/detail", async (req, res) => { router.get("/playlist/:id/detail", async (req, res) => {
try { try {
const provider = getProvider(req.query.platform as string); const provider = resolveProvider(req.query.platform, res);
if (!provider) return;
if (!provider.getPlaylistDetail) { if (!provider.getPlaylistDetail) {
res.status(501).json({ error: "Not supported by this provider" }); res.status(501).json({ error: "Not supported by this provider" });
return; return;
@@ -290,6 +335,114 @@ export function createMusicRouter(
} }
}); });
// Enabled sources + default platform, for the web UI (source tabs, default
// search/playback source). Without a config (unit-test routers) everything
// reports enabled with the legacy netease default.
router.get("/providers", (_req, res) => {
const ALL_PLATFORMS = [
"jellyfin",
"netease",
"qq",
"bilibili",
"youtube",
"kugou",
"spotify",
"local",
];
res.json({
enabled: config ? ALL_PLATFORMS.filter((p) => isProviderEnabled(config, p)) : ALL_PLATFORMS,
default: config ? defaultPlatform(config) : "netease",
});
});
// ─── Jellyfin home-page data ───────────────────────────────────────────
// The concrete JellyfinProvider surface these endpoints consume; the router
// only knows the MusicProvider interface, so narrow structurally (same
// pattern as localProvider.uploadAudio above).
type JellyfinHomeProvider = MusicProvider & {
getLatestAlbums?: (limit?: number) => Promise<Album[]>;
getMostPlayed?: (limit?: number) => Promise<Song[]>;
getFavoriteSongs?: (limit?: number) => Promise<Song[]>;
getGenres?: (limit?: number) => Promise<{ id: string; name: string }[]>;
getGenreSongs?: (genreId: string, limit?: number) => Promise<Song[]>;
};
const jellyfinHome = jellyfinProvider as JellyfinHomeProvider | undefined;
/** 501 when no provider is wired, 400 when the source is disabled. */
function jellyfinOrReject(res: Response): JellyfinHomeProvider | null {
if (!jellyfinHome) {
res.status(501).json({ error: "Jellyfin provider not configured" });
return null;
}
if (config && !isProviderEnabled(config, "jellyfin")) {
res.status(400).json({ error: "音源未启用:jellyfin (provider disabled)" });
return null;
}
return jellyfinHome;
}
router.get("/jellyfin/latest-albums", async (req, res) => {
try {
const p = jellyfinOrReject(res);
if (!p) return;
const limit = parseInt(req.query.limit as string) || 12;
res.json({ albums: (await p.getLatestAlbums?.(limit)) ?? [] });
} catch (err) {
logger.error({ err }, "Jellyfin latest albums failed");
res.status(500).json({ error: (err as Error).message });
}
});
router.get("/jellyfin/most-played", async (req, res) => {
try {
const p = jellyfinOrReject(res);
if (!p) return;
const limit = parseInt(req.query.limit as string) || 12;
res.json({ songs: (await p.getMostPlayed?.(limit)) ?? [] });
} catch (err) {
logger.error({ err }, "Jellyfin most played failed");
res.status(500).json({ error: (err as Error).message });
}
});
// Favorites are account-level data — guests don't get them (matches the
// requireNotGuest gate on /user/playlists).
router.get("/jellyfin/favorites", requireNotGuest, async (req, res) => {
try {
const p = jellyfinOrReject(res);
if (!p) return;
const limit = parseInt(req.query.limit as string) || 100;
res.json({ songs: (await p.getFavoriteSongs?.(limit)) ?? [] });
} catch (err) {
logger.error({ err }, "Jellyfin favorites failed");
res.status(500).json({ error: (err as Error).message });
}
});
router.get("/jellyfin/genres", async (req, res) => {
try {
const p = jellyfinOrReject(res);
if (!p) return;
const limit = parseInt(req.query.limit as string) || 30;
res.json({ genres: (await p.getGenres?.(limit)) ?? [] });
} catch (err) {
logger.error({ err }, "Jellyfin genres failed");
res.status(500).json({ error: (err as Error).message });
}
});
router.get("/jellyfin/genre/:id/songs", async (req, res) => {
try {
const p = jellyfinOrReject(res);
if (!p) return;
const limit = parseInt(req.query.limit as string) || 100;
res.json({ songs: (await p.getGenreSongs?.(req.params.id, limit)) ?? [] });
} catch (err) {
logger.error({ err }, "Jellyfin genre songs failed");
res.status(500).json({ error: (err as Error).message });
}
});
// Get current quality // Get current quality
router.get("/quality", requireNotGuest, (_req, res) => { router.get("/quality", requireNotGuest, (_req, res) => {
res.json({ res.json({
@@ -299,6 +452,7 @@ export function createMusicRouter(
local: localProvider?.getQuality() ?? "original", local: localProvider?.getQuality() ?? "original",
kugou: kugouProvider?.getQuality() ?? "128", kugou: kugouProvider?.getQuality() ?? "128",
spotify: spotifyProvider?.getQuality() ?? "320", spotify: spotifyProvider?.getQuality() ?? "320",
jellyfin: jellyfinProvider?.getQuality() ?? "direct",
}); });
}); });
@@ -324,6 +478,31 @@ export function createMusicRouter(
if ((!platform || platform === "spotify") && spotifyProvider) { if ((!platform || platform === "spotify") && spotifyProvider) {
spotifyProvider.setQuality(quality); spotifyProvider.setQuality(quality);
} }
// Safe in the platform-less broadcast: JellyfinProvider.setQuality ignores
// values outside its own tier list (direct/320/192/128).
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"); logger.info({ quality, platform }, "Audio quality changed");
res.json({ success: true, quality }); res.json({ success: true, quality });
}); });
+39 -32
View File
@@ -40,6 +40,10 @@ export function createPlayerRouter(
if (platform === "qq") return "-q"; if (platform === "qq") return "-q";
if (platform === "youtube") return "-y"; if (platform === "youtube") return "-y";
if (platform === "kugou") return "-k"; if (platform === "kugou") return "-k";
if (platform === "jellyfin") return "-j";
// The flag-less default is now the configured default platform (jellyfin
// unless disabled), so netease needs an explicit flag.
if (platform === "netease") return "-n";
return ""; return "";
}; };
@@ -53,6 +57,11 @@ export function createPlayerRouter(
res.status(403).json({ error: "本地音频播放已关闭" }); res.status(403).json({ error: "本地音频播放已关闭" });
} }
function requesterName(req: any): string {
const name = req.user?.username;
return typeof name === "string" && name.trim() ? name.trim() : "游客";
}
router.post("/:botId/play", authorize({ capability: "player.control" }), async (req, res) => { router.post("/:botId/play", authorize({ capability: "player.control" }), async (req, res) => {
try { try {
const bot = (req as any).bot; const bot = (req as any).bot;
@@ -66,7 +75,7 @@ export function createPlayerRouter(
res.status(400).json({ error: "Invalid command" }); res.status(400).json({ error: "Invalid command" });
return; return;
} }
const response = await bot.executeCommand(cmd); const response = await bot.executeCommand(cmd, undefined, requesterName(req));
res.json({ message: response }); res.json({ message: response });
} catch (err) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); res.status(500).json({ error: (err as Error).message });
@@ -82,7 +91,7 @@ export function createPlayerRouter(
res.status(400).json({ error: "Invalid command" }); res.status(400).json({ error: "Invalid command" });
return; return;
} }
const response = await bot.executeCommand(cmd); const response = await bot.executeCommand(cmd, undefined, requesterName(req));
res.json({ message: response }); res.json({ message: response });
} catch (err) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); res.status(500).json({ error: (err as Error).message });
@@ -116,11 +125,11 @@ export function createPlayerRouter(
return; return;
} }
const provider = bot.getProviderFor( const provider = bot.getProviderFor(
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou" platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou" || platform === "jellyfin"
? platform ? platform
: "netease" : "netease"
); );
const message = await bot.startFm(provider); const message = await bot.startFm(provider, requesterName(req));
res.json({ res.json({
ok: ok:
!message.startsWith("No FM songs") && !message.startsWith("No FM songs") &&
@@ -271,7 +280,7 @@ export function createPlayerRouter(
`!playlist ${platformFlag(platform)} ${playlistId}`.trim(), `!playlist ${platformFlag(platform)} ${playlistId}`.trim(),
"!" "!"
)!; )!;
const response = await bot.executeCommand(cmd); const response = await bot.executeCommand(cmd, undefined, requesterName(req));
res.json({ message: response }); res.json({ message: response });
} catch (err) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); res.status(500).json({ error: (err as Error).message });
@@ -291,7 +300,7 @@ export function createPlayerRouter(
// Use the bot's own provider lookup — it already knows about youtube, // Use the bot's own provider lookup — it already knows about youtube,
// which the router's constructor params did not. // which the router's constructor params did not.
const provider = bot.getProviderFor( const provider = bot.getProviderFor(
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou" platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou" || platform === "jellyfin"
? platform ? platform
: "netease" : "netease"
); );
@@ -332,7 +341,7 @@ export function createPlayerRouter(
const queue = bot.getQueueManager(); const queue = bot.getQueueManager();
queue.clear(); queue.clear();
for (const song of queueable) { for (const song of queueable) {
queue.add({ ...song, platform: provider.platform }); queue.add({ ...song, platform: provider.platform, requestedBy: requesterName(req) });
} }
// Sweep AFTER the queue is rebuilt: the previous queue's local uploads are // Sweep AFTER the queue is rebuilt: the previous queue's local uploads are
// released and deleted, but an empty/failed playlist (early return above) // released and deleted, but an empty/failed playlist (early return above)
@@ -384,7 +393,7 @@ export function createPlayerRouter(
return; return;
} }
const provider = bot.getProviderFor( const provider = bot.getProviderFor(
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou" platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou" || platform === "jellyfin"
? platform ? platform
: "netease" : "netease"
); );
@@ -418,7 +427,7 @@ export function createPlayerRouter(
const queue = bot.getQueueManager(); const queue = bot.getQueueManager();
queue.clear(); queue.clear();
for (const song of queueable) { for (const song of queueable) {
queue.add({ ...song, platform: provider.platform }); queue.add({ ...song, platform: provider.platform, requestedBy: requesterName(req) });
} }
// Sweep AFTER the queue is rebuilt (see play-playlist). // Sweep AFTER the queue is rebuilt (see play-playlist).
bot.cleanupQueuedLocalSongs?.("queue_replaced"); bot.cleanupQueuedLocalSongs?.("queue_replaced");
@@ -452,7 +461,11 @@ export function createPlayerRouter(
} }
}); });
// Play a single song by ID — resolves URL on demand // Play a single song by ID — resolves URL on demand. Funnels through
// bot.playSingleSong so the config.playKeepsQueue decision (clear-and-play vs
// insert-and-jump, keeping the queue) lives in one place shared with chat
// !play. Serialized via runExclusive like /play-now-song so concurrent
// requests can't interleave the queue mutation + playback (#119).
router.post("/:botId/play-song", authorize({ capability: "player.control" }), async (req, res) => { router.post("/:botId/play-song", authorize({ capability: "player.control" }), async (req, res) => {
try { try {
const bot = (req as any).bot; const bot = (req as any).bot;
@@ -465,23 +478,16 @@ export function createPlayerRouter(
rejectDisabledLocalAudio(res); rejectDisabledLocalAudio(res);
return; return;
} }
const queue = bot.getQueueManager(); // The cleanupQueuedLocalSongs sweep now lives inside playSingleSong's
bot.getPlayer().stop(); // clear branch — do NOT also call it here, or it would delete retained
queue.clear(); // local uploads in keep-queue mode.
queue.add(song); const body = await bot.runExclusive(async () => {
queue.play(); const ok = await bot.playSingleSong({ ...song }, requesterName(req));
return ok
bot.getPlayer().resetFailures(); ? { ok: true, message: `正在播放:${song.name || 'Unknown'} - ${song.artist || 'Unknown'}` }
const ok = await bot.resolveAndPlay(queue.current()!); : { ok: false, message: `无法播放「${song.name || song.id}」(区域/版权限制)` };
// Sweep AFTER the new song is queued+resolved, so replaying a local song });
// that was still in the queue doesn't delete the file we're about to play. res.json(body);
bot.cleanupQueuedLocalSongs?.("queue_replaced");
if (!ok) {
res.json({ ok: false, message: `无法播放「${song.name || song.id}」(区域/版权限制)` });
return;
}
res.json({ ok: true, message: `正在播放:${song.name || 'Unknown'} - ${song.artist || 'Unknown'}` });
} catch (err) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); res.status(500).json({ error: (err as Error).message });
} }
@@ -513,7 +519,7 @@ export function createPlayerRouter(
// after natural track end without queue.clear()). // after natural track end without queue.clear()).
const insertedAt = const insertedAt =
queue.getCurrentIndex() < 0 ? queue.size() : queue.getCurrentIndex() + 1; queue.getCurrentIndex() < 0 ? queue.size() : queue.getCurrentIndex() + 1;
queue.addNext(song); queue.addNext({ ...song, requestedBy: requesterName(req) });
if (wasIdle) { if (wasIdle) {
// Promote the just-added song to current and start it. // Promote the just-added song to current and start it.
@@ -555,7 +561,7 @@ export function createPlayerRouter(
const queue = bot.getQueueManager(); const queue = bot.getQueueManager();
const insertedAt = const insertedAt =
queue.getCurrentIndex() < 0 ? queue.size() : queue.getCurrentIndex() + 1; queue.getCurrentIndex() < 0 ? queue.size() : queue.getCurrentIndex() + 1;
queue.addNext(song); queue.addNext({ ...song, requestedBy: requesterName(req) });
queue.playAt(insertedAt); queue.playAt(insertedAt);
bot.getPlayer().resetFailures(); bot.getPlayer().resetFailures();
const ok = await bot.resolveAndPlay(queue.current()!); const ok = await bot.resolveAndPlay(queue.current()!);
@@ -587,7 +593,7 @@ export function createPlayerRouter(
const body = await bot.runExclusive(async () => { const body = await bot.runExclusive(async () => {
const queue = bot.getQueueManager(); const queue = bot.getQueueManager();
const wasIdle = bot.getPlayer().getState() === "idle"; const wasIdle = bot.getPlayer().getState() === "idle";
queue.add(song); queue.add({ ...song, requestedBy: requesterName(req) });
// If nothing was playing, start this newly-added song immediately. // If nothing was playing, start this newly-added song immediately.
if (wasIdle) { if (wasIdle) {
@@ -615,7 +621,7 @@ export function createPlayerRouter(
return; return;
} }
const provider = bot.getProviderFor( const provider = bot.getProviderFor(
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou" platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou" || platform === "jellyfin"
? platform ? platform
: "netease" : "netease"
); );
@@ -627,7 +633,7 @@ export function createPlayerRouter(
} }
const queue = bot.getQueueManager(); const queue = bot.getQueueManager();
queue.add({ ...song, platform: provider.platform }); queue.add({ ...song, platform: provider.platform, requestedBy: requesterName(req) });
// If nothing is playing, start the first song // If nothing is playing, start the first song
if (bot.getPlayer().getState() === "idle") { if (bot.getPlayer().getState() === "idle") {
@@ -678,6 +684,7 @@ export function createPlayerRouter(
coverUrl: r.coverUrl, coverUrl: r.coverUrl,
platform: r.platform, platform: r.platform,
playedAt: r.playedAt, playedAt: r.playedAt,
requestedBy: r.requestedBy,
})); }));
res.json({ history }); res.json({ history });
}); });
+119
View File
@@ -0,0 +1,119 @@
import { describe, it, expect } from "vitest";
import express from "express";
import request from "supertest";
import pino from "pino";
import { createDatabase, SHARED_QUEUE_OWNER, type BotDatabase } from "../../data/database.js";
import type { BotManager } from "../../bot/manager.js";
import { createSavedQueuesRouter } from "./saved-queues.js";
const song = (id: string) => ({
id,
name: id,
artist: "",
album: "",
platform: "netease" as const,
coverUrl: "",
duration: 1,
});
function mount(enabled: boolean, opts: { queue?: unknown[] } = {}) {
const db = createDatabase(":memory:");
const loads: Array<{ songs: unknown[]; mode: string; by?: string }> = [];
const bot = {
getQueueManager: () => ({ list: () => opts.queue ?? [song("a"), song("b")] }),
loadSavedQueue: async (songs: unknown[], mode: string, by?: string) => {
loads.push({ songs, mode, by });
},
};
const botManager = { getBot: (_id: string) => bot } as unknown as BotManager;
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as unknown as { user: unknown }).user = { id: "u1", username: "alice", role: "member" };
next();
});
app.use(
"/api/saved-queues",
createSavedQueuesRouter(db, botManager, () => enabled, pino({ level: "silent" })),
);
return { app, db, loads };
}
describe("saved-queues router", () => {
it("403s every route when the feature is disabled", async () => {
const { app } = mount(false);
expect((await request(app).get("/api/saved-queues")).status).toBe(403);
expect((await request(app).post("/api/saved-queues").send({ botId: "b", name: "x" })).status).toBe(403);
expect((await request(app).post("/api/saved-queues/1/load").send({ botId: "b" })).status).toBe(403);
expect((await request(app).delete("/api/saved-queues/1")).status).toBe(403);
});
it("saves the current queue (private) and lists it back", async () => {
const { app } = mount(true);
const save = await request(app).post("/api/saved-queues").send({ botId: "b", name: "night" });
expect(save.status).toBe(200);
expect(save.body.queue.name).toBe("night");
expect(save.body.queue.songCount).toBe(2);
expect(save.body.queue.ownerId).toBe("u1");
const list = await request(app).get("/api/saved-queues");
expect(list.status).toBe(200);
expect(list.body.queues.map((q: { name: string }) => q.name)).toContain("night");
});
it("saves to the shared bucket when shared:true", async () => {
const { app, db } = mount(true);
const save = await request(app).post("/api/saved-queues").send({ botId: "b", name: "party", shared: true });
expect(save.status).toBe(200);
expect(save.body.queue.ownerId).toBe(SHARED_QUEUE_OWNER);
expect(db.listSavedQueues(SHARED_QUEUE_OWNER, false).map((q) => q.name)).toEqual(["party"]);
});
it("rejects saving an empty queue", async () => {
const { app } = mount(true, { queue: [] });
const save = await request(app).post("/api/saved-queues").send({ botId: "b", name: "empty" });
expect(save.status).toBe(400);
});
it("requires botId and name", async () => {
const { app } = mount(true);
expect((await request(app).post("/api/saved-queues").send({ name: "x" })).status).toBe(400);
expect((await request(app).post("/api/saved-queues").send({ botId: "b" })).status).toBe(400);
});
it("loads a shared queue (replace by default) into the bot", async () => {
const { app, db, loads } = mount(true);
const saved = db.saveQueue(SHARED_QUEUE_OWNER, "party", [song("a"), song("b")]);
const load = await request(app).post(`/api/saved-queues/${saved.id}/load`).send({ botId: "b" });
expect(load.status).toBe(200);
expect(load.body).toMatchObject({ ok: true, loaded: 2, mode: "replace" });
expect(loads).toHaveLength(1);
expect(loads[0].mode).toBe("replace");
expect(loads[0].by).toBe("alice");
});
it("loads in append mode when requested", async () => {
const { app, db, loads } = mount(true);
const saved = db.saveQueue("u1", "mine", [song("a")]);
const load = await request(app).post(`/api/saved-queues/${saved.id}/load`).send({ botId: "b", mode: "append" });
expect(load.status).toBe(200);
expect(loads[0].mode).toBe("append");
});
it("404s loading another user's private queue (no existence leak)", async () => {
const { app, db } = mount(true);
db.saveQueue("someoneElse", "private", [song("z")]);
const other = db.listSavedQueues("someoneElse", false)[0];
const load = await request(app).post(`/api/saved-queues/${other.id}/load`).send({ botId: "b", mode: "replace" });
expect(load.status).toBe(404);
});
it("deletes an own queue but 404s another user's private one", async () => {
const { app, db } = mount(true);
const mine = db.saveQueue("u1", "mine", [song("a")]);
const theirs = db.saveQueue("someoneElse", "private", [song("z")]);
expect((await request(app).delete(`/api/saved-queues/${theirs.id}`)).status).toBe(404);
expect((await request(app).delete(`/api/saved-queues/${mine.id}`)).status).toBe(200);
expect(db.getSavedQueue(mine.id)).toBeNull();
});
});
+122
View File
@@ -0,0 +1,122 @@
import { Router } from "express";
import type { BotDatabase } from "../../data/database.js";
import { SHARED_QUEUE_OWNER } from "../../data/database.js";
import type { BotManager } from "../../bot/manager.js";
import type { Logger } from "../../logger.js";
/**
* The /api/saved-queues router (Feature 1, #119). Named save/load of queues,
* per-user with a reserved shared bucket. Every route is inert (403) unless
* savedQueuesEnabled is on, so the feature is fully gated behind the admin flag.
*
* Ownership model:
* - WebUI save with `shared:true` → SHARED_QUEUE_OWNER; otherwise the caller's
* own user id (private to them).
* - list returns the caller's own queues + shared ones.
* - load/delete are allowed only for the caller's own queues or shared ones;
* another user's private queue 404s (no existence leak, matching favorites).
*/
export function createSavedQueuesRouter(
database: BotDatabase,
botManager: BotManager,
isEnabled: () => boolean,
logger: Logger,
): Router {
const router = Router();
// Feature gate — inert (403) when savedQueuesEnabled is false.
router.use((_req, res, next) => {
if (!isEnabled()) {
res.status(403).json({ error: "此功能未启用" });
return;
}
next();
});
// GET / — the caller's own + shared saved queues (meta only, no songs blob).
router.get("/", (req, res) => {
const userId = req.user!.id;
res.json({ queues: database.listSavedQueues(userId, true) });
});
// POST / — snapshot a bot's CURRENT queue and upsert it.
// body: { botId, name, shared? }
router.post("/", (req, res) => {
const userId = req.user!.id;
const { botId, name, shared } = req.body ?? {};
if (typeof name !== "string" || !name.trim() || typeof botId !== "string" || !botId) {
res.status(400).json({ error: "botId and name are required" });
return;
}
const bot = botManager.getBot(botId);
if (!bot) {
res.status(404).json({ error: "bot not found" });
return;
}
const songs = bot.getQueueManager().list();
if (songs.length === 0) {
res.status(400).json({ error: "队列为空,无法保存" });
return;
}
const ownerId = shared === true ? SHARED_QUEUE_OWNER : userId;
try {
const saved = database.saveQueue(ownerId, name.trim(), songs);
logger.info({ userId, ownerId, name: saved.name, count: saved.songCount }, "saved queue upserted");
res.json({
queue: {
id: saved.id,
ownerId: saved.ownerId,
name: saved.name,
songCount: saved.songCount,
},
});
} catch (err) {
res.status(400).json({ error: (err as Error).message });
}
});
// POST /:id/load — load a saved queue into a bot. body: { botId, mode }
router.post("/:id/load", async (req, res) => {
const userId = req.user!.id;
const username = req.user!.username;
const id = parseInt(req.params.id, 10);
const { botId, mode } = req.body ?? {};
if (Number.isNaN(id) || typeof botId !== "string" || !botId) {
res.status(400).json({ error: "invalid id/botId" });
return;
}
const sq = database.getSavedQueue(id);
if (!sq || (sq.ownerId !== userId && sq.ownerId !== SHARED_QUEUE_OWNER)) {
res.status(404).json({ error: "not found" });
return;
}
const bot = botManager.getBot(botId);
if (!bot) {
res.status(404).json({ error: "bot not found" });
return;
}
const loadMode = mode === "append" ? "append" : "replace";
await bot.loadSavedQueue(sq.songs, loadMode, username || "游客");
res.json({ ok: true, loaded: sq.songs.length, mode: loadMode });
});
// DELETE /:id — delete a saved queue (own or shared only).
router.delete("/:id", (req, res) => {
const userId = req.user!.id;
const id = parseInt(req.params.id, 10);
if (Number.isNaN(id)) {
res.status(400).json({ error: "invalid id" });
return;
}
const sq = database.getSavedQueue(id);
if (!sq || (sq.ownerId !== userId && sq.ownerId !== SHARED_QUEUE_OWNER)) {
res.status(404).json({ error: "not found" });
return;
}
database.deleteSavedQueue(id);
logger.info({ userId, id }, "saved queue deleted");
res.json({ ok: true });
});
return router;
}
+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");
});
});
+38 -5
View File
@@ -19,9 +19,11 @@ import { createUsersRouter } from "./api/users.js";
import { createAuditStore } from "../data/audit.js"; import { createAuditStore } from "../data/audit.js";
import { createAuditRouter } from "./api/audit.js"; import { createAuditRouter } from "./api/audit.js";
import { createFavoritesRouter } from "./api/favorites.js"; import { createFavoritesRouter } from "./api/favorites.js";
import { createSavedQueuesRouter } from "./api/saved-queues.js";
import { createSpotifyRouter } from "./api/spotify.js"; import { createSpotifyRouter } from "./api/spotify.js";
import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js"; import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js";
import type { SpotifyProvider } from "../music/spotify/provider.js"; import type { SpotifyProvider } from "../music/spotify/provider.js";
import type { JellyfinProvider } from "../music/jellyfin.js";
import { resolveSpotifyBackendKind } from "../music/spotify/backend-select.js"; import { resolveSpotifyBackendKind } from "../music/spotify/backend-select.js";
import { import {
isGoLibrespotPresent, isGoLibrespotPresent,
@@ -49,6 +51,7 @@ export interface WebServerOptions {
localProvider: MusicProvider; localProvider: MusicProvider;
kugouProvider: MusicProvider; kugouProvider: MusicProvider;
spotifyProvider: MusicProvider; spotifyProvider: MusicProvider;
jellyfinProvider: MusicProvider;
database: BotDatabase; database: BotDatabase;
config: BotConfig; config: BotConfig;
configPath: string; configPath: string;
@@ -75,12 +78,19 @@ export function createWebServer(options: WebServerOptions): WebServer {
app.set("trust proxy", true); app.set("trust proxy", true);
} }
// Security headers: prevent the WebUI from being embedded in a third-party // Security headers:
// iframe (clickjacking defence). CSP frame-ancestors is the modern equivalent // • X-Frame-Options / CSP frame-ancestors — prevent the WebUI from being
// of X-Frame-Options; both are set for compatibility across browsers. // 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) => { app.use((_req, res, next) => {
res.setHeader("X-Frame-Options", "DENY"); res.setHeader("X-Frame-Options", "DENY");
res.setHeader("Content-Security-Policy", "frame-ancestors 'none'"); res.setHeader("Content-Security-Policy", "frame-ancestors 'none'");
res.setHeader("X-Robots-Tag", "noindex, nofollow");
next(); next();
}); });
@@ -93,6 +103,13 @@ export function createWebServer(options: WebServerOptions): WebServer {
const permissions = createPermissionStore(options.database.db); const permissions = createPermissionStore(options.database.db);
// ─── Public routes (no auth, no CSRF) ─────────────────────────────────── // ─── 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) => { app.get("/api/health", (_req, res) => {
res.json({ status: "ok", version: "0.1.0" }); res.json({ status: "ok", version: "0.1.0" });
}); });
@@ -141,11 +158,15 @@ export function createWebServer(options: WebServerOptions): WebServer {
// runtime object is a SpotifyProvider (see index.ts); WebServerOptions types // runtime object is a SpotifyProvider (see index.ts); WebServerOptions types
// it as the wider MusicProvider, so narrow it here for the setCreds contract. // it as the wider MusicProvider, so narrow it here for the setCreds contract.
options.spotifyProvider as SpotifyProvider, options.spotifyProvider as SpotifyProvider,
// Same live-reconfigure contract for Jellyfin: a Settings save re-points
// the connection without a restart. Runtime object is a JellyfinProvider
// (see index.ts); WebServerOptions types it as the wider MusicProvider.
options.jellyfinProvider as JellyfinProvider,
) )
); );
app.use( app.use(
"/api/music", "/api/music",
createMusicRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.localProvider, options.config, options.kugouProvider, options.spotifyProvider) 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( app.use("/api/player", createPlayerRouter(
options.botManager, logger, options.database, options.botManager, logger, options.database,
@@ -153,7 +174,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
)); ));
app.use( app.use(
"/api/auth", "/api/auth",
createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore, options.kugouProvider, options.spotifyProvider) createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore, options.kugouProvider, options.spotifyProvider, options.jellyfinProvider, options.config)
); );
if (options.spotifyOAuth) { if (options.spotifyOAuth) {
app.use( app.use(
@@ -182,6 +203,18 @@ export function createWebServer(options: WebServerOptions): WebServer {
); );
} }
app.use("/api/favorites", requireNotGuest, createFavoritesRouter(options.database, logger)); app.use("/api/favorites", requireNotGuest, createFavoritesRouter(options.database, logger));
// Saved queues (Feature 1, #119). Members + admins only (requireNotGuest);
// the router itself 403s every route unless savedQueuesEnabled is on.
app.use(
"/api/saved-queues",
requireNotGuest,
createSavedQueuesRouter(
options.database,
options.botManager,
() => options.config.savedQueuesEnabled,
logger,
),
);
// admin-only routes // admin-only routes
app.use("/api/users", requireAdmin, createUsersRouter(users, sessions, audit, logger, permissions)); app.use("/api/users", requireAdmin, createUsersRouter(users, sessions, audit, logger, permissions));
+6
View File
@@ -3,6 +3,12 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <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 <!-- 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" 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 does exactly that: full Referer for our own requests, none for cross-origin
+3
View File
@@ -166,6 +166,9 @@ onMounted(async () => {
// Search or Playlist render hearts correctly without first visiting Home. // Search or Playlist render hearts correctly without first visiting Home.
// (fire-and-forget; fetchFavorites swallows the 401 when not yet logged in.) // (fire-and-forget; fetchFavorites swallows the 401 when not yet logged in.)
playerStore.fetchFavorites(); playerStore.fetchFavorites();
// Non-critical: reads savedQueuesEnabled so the nav entry can show/hide.
// Guests get a 403 (swallowed) → the entry stays hidden for them.
if (!session.isGuest.value) playerStore.fetchBotSettings();
syncTimer = setInterval(() => playerStore.syncElapsed(), 3000); syncTimer = setInterval(() => playerStore.syncElapsed(), 3000);
mobileRaf = requestAnimationFrame(updateMobileProgress); mobileRaf = requestAnimationFrame(updateMobileProgress);
// Reconcile the dedicated-link scope only after the bot list is known: the // Reconcile the dedicated-link scope only after the bot list is known: the
+6
View File
@@ -7,6 +7,12 @@
<RouterLink to="/search" class="nav-link" active-class="active">搜索</RouterLink> <RouterLink to="/search" class="nav-link" active-class="active">搜索</RouterLink>
<RouterLink to="/library" class="nav-link" active-class="active">音乐库</RouterLink> <RouterLink to="/library" class="nav-link" active-class="active">音乐库</RouterLink>
<RouterLink to="/history" class="nav-link" active-class="active">播放历史</RouterLink> <RouterLink to="/history" class="nav-link" active-class="active">播放历史</RouterLink>
<RouterLink
v-if="store.savedQueuesEnabled && !session.isGuest.value"
to="/saved-queues"
class="nav-link"
active-class="active"
>已存队列</RouterLink>
</div> </div>
<div class="nav-right"> <div class="nav-right">
+32 -2
View File
@@ -7,8 +7,13 @@
<span class="song-name">{{ song.name }}</span> <span class="song-name">{{ song.name }}</span>
<span <span
class="platform-badge" class="platform-badge"
:class="song.platform === 'bilibili' ? 'badge-bilibili' : song.platform === 'qq' ? 'badge-qq' : song.platform === 'youtube' ? 'badge-youtube' : song.platform === 'local' ? 'badge-local' : song.platform === 'kugou' ? 'badge-kugou' : song.platform === 'spotify' ? 'badge-spotify' : 'badge-netease'" :class="song.platform === 'bilibili' ? 'badge-bilibili' : song.platform === 'qq' ? 'badge-qq' : song.platform === 'youtube' ? 'badge-youtube' : song.platform === 'local' ? 'badge-local' : song.platform === 'kugou' ? 'badge-kugou' : song.platform === 'spotify' ? 'badge-spotify' : song.platform === 'jellyfin' ? 'badge-jellyfin' : 'badge-netease'"
>{{ song.platform === 'bilibili' ? 'B站' : song.platform === 'qq' ? 'QQ' : song.platform === 'youtube' ? 'YouTube' : song.platform === 'local' ? '本地' : song.platform === 'kugou' ? '酷狗' : song.platform === 'spotify' ? 'Spotify' : '网易云' }}</span> >{{ song.platform === 'bilibili' ? 'B站' : song.platform === 'qq' ? 'QQ' : song.platform === 'youtube' ? 'YouTube' : song.platform === 'local' ? '本地' : song.platform === 'kugou' ? '酷狗' : song.platform === 'spotify' ? 'Spotify' : song.platform === 'jellyfin' ? 'Jellyfin' : '网易云' }}</span>
<span
v-if="song.requestedBy"
class="requester-badge"
:class="{ 'requester-badge-guest': song.requestedBy === '游客' }"
>{{ song.requestedBy }}</span>
</div> </div>
<div class="song-artist">{{ song.artist }}</div> <div class="song-artist">{{ song.artist }}</div>
</div> </div>
@@ -115,6 +120,26 @@ function formatDuration(seconds: number): string {
line-height: 1.4; line-height: 1.4;
} }
.requester-badge {
flex-shrink: 0;
max-width: 96px;
font-size: var(--fs-micro);
font-weight: var(--fw-semi);
padding: 1px 5px;
border-radius: var(--radius-xs);
line-height: 1.4;
background: var(--color-online-15);
color: var(--color-online);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.requester-badge-guest {
background: rgba(156, 163, 175, 0.18);
color: rgba(156, 163, 175, 0.95);
}
.badge-netease { .badge-netease {
background: var(--brand-netease-15); background: var(--brand-netease-15);
color: var(--brand-netease); color: var(--brand-netease);
@@ -150,6 +175,11 @@ function formatDuration(seconds: number): string {
color: var(--brand-spotify); color: var(--brand-spotify);
} }
.badge-jellyfin {
background: var(--brand-jellyfin-12);
color: var(--brand-jellyfin);
}
.song-artist { .song-artist {
font-size: 12px; font-size: 12px;
color: var(--text-secondary); color: var(--text-secondary);
+1
View File
@@ -22,6 +22,7 @@
import type { Source } from '../stores/player.js'; import type { Source } from '../stores/player.js';
const LABELS: Record<Source, string> = { const LABELS: Record<Source, string> = {
jellyfin: 'Jellyfin',
netease: '网易云', netease: '网易云',
qq: 'QQ', qq: 'QQ',
kugou: '酷狗', kugou: '酷狗',
+34
View File
@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest';
import { isShared, sortQueues, SHARED_OWNER, type SavedQueueMeta } from './savedQueues';
const meta = (over: Partial<SavedQueueMeta>): SavedQueueMeta => ({
id: 1,
ownerId: 'u1',
name: 'q',
songCount: 0,
createdAt: '2026-01-01',
updatedAt: '2026-01-01',
...over,
});
describe('savedQueues helper', () => {
it('flags the shared owner', () => {
expect(isShared({ ownerId: SHARED_OWNER })).toBe(true);
expect(isShared({ ownerId: 'u1' })).toBe(false);
});
it('sorts by updatedAt descending (newest first)', () => {
const out = sortQueues([
meta({ id: 1, updatedAt: '2026-01-01' }),
meta({ id: 2, updatedAt: '2026-02-01' }),
meta({ id: 3, updatedAt: '2026-01-15' }),
]);
expect(out.map((q) => q.id)).toEqual([2, 3, 1]);
});
it('does not mutate the input array', () => {
const input = [meta({ id: 1, updatedAt: '2026-01-01' }), meta({ id: 2, updatedAt: '2026-02-01' })];
sortQueues(input);
expect(input.map((q) => q.id)).toEqual([1, 2]);
});
});
+27
View File
@@ -0,0 +1,27 @@
// Pure, framework-free helpers for the Saved Queues feature (#119). Kept
// separate from the API composable so the list/ownership logic is unit-testable
// without a DOM or axios.
export interface SavedQueueMeta {
id: number;
ownerId: string;
name: string;
songCount: number;
createdAt: string;
updatedAt: string;
}
/** Must match SHARED_QUEUE_OWNER in src/data/database.ts. */
export const SHARED_OWNER = '__shared__';
/** Whether a saved queue lives in the shared bucket (vs. private to a user). */
export function isShared(q: Pick<SavedQueueMeta, 'ownerId'>): boolean {
return q.ownerId === SHARED_OWNER;
}
/** Newest-updated first; stable for equal timestamps. */
export function sortQueues<T extends { updatedAt: string }>(qs: T[]): T[] {
return [...qs].sort((a, b) =>
a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : 0,
);
}
+52
View File
@@ -0,0 +1,52 @@
import { ref } from 'vue';
import axios from 'axios';
import { sortQueues, type SavedQueueMeta } from './savedQueues.js';
/**
* API composable for the Saved Queues feature (#119). Wraps /api/saved-queues.
* `disabled` becomes true when the server returns 403 (savedQueuesEnabled off),
* so the page can render a "feature disabled" state instead of an error.
*/
export function useSavedQueues() {
const queues = ref<SavedQueueMeta[]>([]);
const loading = ref(false);
const disabled = ref(false);
const error = ref<string | null>(null);
async function list(): Promise<void> {
loading.value = true;
error.value = null;
try {
const res = await axios.get('/api/saved-queues');
queues.value = sortQueues((res.data?.queues ?? []) as SavedQueueMeta[]);
disabled.value = false;
} catch (e: unknown) {
const status = (e as { response?: { status?: number } })?.response?.status;
if (status === 403) {
disabled.value = true;
queues.value = [];
} else {
error.value = '加载已保存队列失败';
}
} finally {
loading.value = false;
}
}
async function save(botId: string, name: string, shared: boolean): Promise<void> {
await axios.post('/api/saved-queues', { botId, name, shared });
await list();
}
async function load(id: number, botId: string, mode: 'replace' | 'append'): Promise<{ loaded: number; mode: string }> {
const res = await axios.post(`/api/saved-queues/${id}/load`, { botId, mode });
return res.data;
}
async function remove(id: number): Promise<void> {
await axios.delete(`/api/saved-queues/${id}`);
await list();
}
return { queues, loading, disabled, error, list, save, load, remove };
}
+1
View File
@@ -22,6 +22,7 @@ const router = createRouter({
}, },
{ path: '/lyrics', name: 'lyrics', component: () => import('../views/Lyrics.vue') }, { path: '/lyrics', name: 'lyrics', component: () => import('../views/Lyrics.vue') },
{ path: '/history', name: 'history', component: () => import('../views/History.vue') }, { path: '/history', name: 'history', component: () => import('../views/History.vue') },
{ path: '/saved-queues', name: 'saved-queues', component: () => import('../views/SavedQueues.vue') },
{ path: '/settings', name: 'settings', component: () => import('../views/Settings.vue') }, { path: '/settings', name: 'settings', component: () => import('../views/Settings.vue') },
{ path: '/setup', name: 'setup', component: () => import('../views/Setup.vue') }, { path: '/setup', name: 'setup', component: () => import('../views/Setup.vue') },
{ path: '/bot/:id', name: 'bot', component: () => import('../views/BotRedirect.vue') }, { path: '/bot/:id', name: 'bot', component: () => import('../views/BotRedirect.vue') },
+151 -18
View File
@@ -10,10 +10,21 @@ export interface Song {
album: string; album: string;
duration: number; duration: number;
coverUrl: string; coverUrl: string;
platform: 'netease' | 'qq' | 'bilibili' | 'youtube' | 'local' | 'kugou' | 'spotify'; platform: 'netease' | 'qq' | 'bilibili' | 'youtube' | 'local' | 'kugou' | 'spotify' | 'jellyfin';
requestedBy?: string;
playedAt?: string;
} }
export type Source = 'netease' | 'qq' | 'kugou' | 'spotify'; export type Source = 'jellyfin' | 'netease' | 'qq' | 'kugou' | 'spotify';
export interface AlbumItem {
id: string;
name: string;
artist: string;
coverUrl: string;
songCount: number;
platform: string;
}
export interface BotStatus { export interface BotStatus {
id: string; id: string;
@@ -99,12 +110,26 @@ export const usePlayerStore = defineStore('player', {
timings: {} as Record<string, TimingState>, timings: {} as Record<string, TimingState>,
theme: 'dark' as 'dark' | 'light', theme: 'dark' as 'dark' | 'light',
// 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: 'netease' as string,
// Whether the admin enabled save/load queues (#119). Gates the nav entry
// and the /saved-queues page. Fetched from /api/bot/settings (non-guest).
savedQueuesEnabled: false,
// Home page cache, split by source // Home page cache, split by source
recommendPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[], kugou: [] as PlaylistItem[], spotify: [] as PlaylistItem[] }, recommendPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[], kugou: [] as PlaylistItem[], spotify: [] as PlaylistItem[] },
dailySongs: { netease: [] as Song[], qq: [] as Song[], kugou: [] as Song[], spotify: [] as Song[] }, dailySongs: { netease: [] as Song[], qq: [] as Song[], kugou: [] as Song[], spotify: [] as Song[] },
userPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[], kugou: [] as PlaylistItem[], spotify: [] as PlaylistItem[] }, userPlaylists: { jellyfin: [] as PlaylistItem[], netease: [] as PlaylistItem[], qq: [] as PlaylistItem[], kugou: [] as PlaylistItem[], spotify: [] as PlaylistItem[] },
bilibiliPopular: [] as Song[], bilibiliPopular: [] as Song[],
authStatus: { netease: false, qq: false, kugou: false, spotify: false }, // Jellyfin home sections (shown only when the optional source is enabled)
jellyfinLatestAlbums: [] as AlbumItem[],
jellyfinMostPlayed: [] as Song[],
jellyfinFavorites: [] as Song[],
jellyfinGenres: [] as { id: string; name: string }[],
authStatus: { jellyfin: false, netease: false, qq: false, kugou: false, spotify: false },
lastFetchTime: 0, lastFetchTime: 0,
// Favorited playlists (fetched from server, isolated per WebUI user) // Favorited playlists (fetched from server, isolated per WebUI user)
@@ -152,15 +177,20 @@ export const usePlayerStore = defineStore('player', {
const maxDuration = this.activeBot.currentSong.duration || Infinity; const maxDuration = this.activeBot.currentSong.duration || Infinity;
return interpolateElapsed(timing, this.isPaused, maxDuration); return interpolateElapsed(timing, this.isPaused, maxDuration);
}, },
/** Sources that are currently logged in. Order: netease before qq. */ /** Sources that are currently logged in. Jellyfin (when enabled) leads. */
availableSources(): Source[] { availableSources(): Source[] {
const s: Source[] = []; const s: Source[] = [];
if (this.authStatus.jellyfin) s.push('jellyfin');
if (this.authStatus.netease) s.push('netease'); if (this.authStatus.netease) s.push('netease');
if (this.authStatus.qq) s.push('qq'); if (this.authStatus.qq) s.push('qq');
if (this.authStatus.kugou) s.push('kugou'); if (this.authStatus.kugou) s.push('kugou');
if (this.authStatus.spotify) s.push('spotify'); if (this.authStatus.spotify) s.push('spotify');
return s; return s;
}, },
/** Whether a source is enabled server-side (enabledProviders gate). */
sourceEnabled(): (platform: string) => boolean {
return (platform: string) => this.enabledProviders.includes(platform);
},
}, },
actions: { actions: {
@@ -569,27 +599,62 @@ export const usePlayerStore = defineStore('player', {
return this.favoritedPlaylists.some((f) => f.playlistId === playlistId && f.platform === platform); return this.favoritedPlaylists.some((f) => f.playlistId === playlistId && f.platform === platform);
}, },
/** Load global bot settings we care about client-side (currently just the
* saved-queues gate). Guests get a 403 here, which we swallow so the nav
* entry simply stays hidden for them. */
async fetchBotSettings() {
try {
const res = await axios.get('/api/bot/settings');
this.savedQueuesEnabled = res.data?.savedQueuesEnabled === true;
} catch {
// not critical (guests 403; keep previous value otherwise)
}
},
/** Load the server-side source gate (enabledProviders + default). */
async fetchProviders() {
try {
const res = await axios.get('/api/music/providers');
this.enabledProviders = res.data.enabled ?? [];
if (typeof res.data.default === 'string') this.defaultSource = res.data.default;
} catch {
// keep previous values (backend briefly unavailable)
}
},
async fetchHomeData() { async fetchHomeData() {
// The provider gate decides which sources to even ask about — disabled
// sources are skipped entirely (their sidecars may not be running).
await this.fetchProviders();
const has = (p: string) => this.enabledProviders.includes(p);
const skip = Promise.reject(new Error('provider disabled'));
// Pre-rejected sentinel: mark handled so it never surfaces as unhandled.
skip.catch(() => {});
// Always check auth status first — if it changed since the cached // Always check auth status first — if it changed since the cached
// fetch (e.g., user logged in/out as a different account), the // fetch (e.g., user logged in/out as a different account), the
// cached playlists belong to a different user and we MUST refetch. // cached playlists belong to a different user and we MUST refetch.
const [neAuthRes, qqAuthRes, kugouAuthRes, spAuthRes] = await Promise.allSettled([ const [jfAuthRes, neAuthRes, qqAuthRes, kugouAuthRes, spAuthRes] = await Promise.allSettled([
axios.get('/api/auth/status', { params: { platform: 'netease' } }), has('jellyfin') ? axios.get('/api/auth/status', { params: { platform: 'jellyfin' } }) : skip,
axios.get('/api/auth/status', { params: { platform: 'qq' } }), has('netease') ? axios.get('/api/auth/status', { params: { platform: 'netease' } }) : skip,
axios.get('/api/auth/status', { params: { platform: 'kugou' } }), has('qq') ? axios.get('/api/auth/status', { params: { platform: 'qq' } }) : skip,
axios.get('/api/auth/status', { params: { platform: 'spotify' } }), has('kugou') ? axios.get('/api/auth/status', { params: { platform: 'kugou' } }) : skip,
has('spotify') ? axios.get('/api/auth/status', { params: { platform: 'spotify' } }) : skip,
]); ]);
const newAuth = { const newAuth = {
jellyfin: jfAuthRes.status === 'fulfilled' && !!jfAuthRes.value.data?.loggedIn,
netease: neAuthRes.status === 'fulfilled' && !!neAuthRes.value.data?.loggedIn, netease: neAuthRes.status === 'fulfilled' && !!neAuthRes.value.data?.loggedIn,
qq: qqAuthRes.status === 'fulfilled' && !!qqAuthRes.value.data?.loggedIn, qq: qqAuthRes.status === 'fulfilled' && !!qqAuthRes.value.data?.loggedIn,
kugou: kugouAuthRes.status === 'fulfilled' && !!kugouAuthRes.value.data?.loggedIn, kugou: kugouAuthRes.status === 'fulfilled' && !!kugouAuthRes.value.data?.loggedIn,
spotify: spAuthRes.status === 'fulfilled' && !!spAuthRes.value.data?.loggedIn, spotify: spAuthRes.status === 'fulfilled' && !!spAuthRes.value.data?.loggedIn,
}; };
const authChanged = const authChanged =
newAuth.jellyfin !== this.authStatus.jellyfin ||
newAuth.netease !== this.authStatus.netease || newAuth.netease !== this.authStatus.netease ||
newAuth.qq !== this.authStatus.qq || newAuth.qq !== this.authStatus.qq ||
newAuth.kugou !== this.authStatus.kugou || newAuth.kugou !== this.authStatus.kugou ||
newAuth.spotify !== this.authStatus.spotify; newAuth.spotify !== this.authStatus.spotify;
this.authStatus.jellyfin = newAuth.jellyfin;
this.authStatus.netease = newAuth.netease; this.authStatus.netease = newAuth.netease;
this.authStatus.qq = newAuth.qq; this.authStatus.qq = newAuth.qq;
this.authStatus.kugou = newAuth.kugou; this.authStatus.kugou = newAuth.kugou;
@@ -610,17 +675,23 @@ export const usePlayerStore = defineStore('player', {
// 2. NetEase data: recommend playlists work anonymously; daily/user // 2. NetEase data: recommend playlists work anonymously; daily/user
// playlists need login but Promise.allSettled isolates failures. // playlists need login but Promise.allSettled isolates failures.
const neteasePromises = [ const emptyPlaylists = { data: { playlists: [] } };
const emptySongs = { data: { songs: [] } };
const neteasePromises = has('netease')
? [
axios.get('/api/music/recommend/playlists', { params: { platform: 'netease' } }), axios.get('/api/music/recommend/playlists', { params: { platform: 'netease' } }),
axios.get('/api/music/recommend/songs', { params: { platform: 'netease' } }), axios.get('/api/music/recommend/songs', { params: { platform: 'netease' } }),
axios.get('/api/music/user/playlists', { params: { platform: 'netease' } }), axios.get('/api/music/user/playlists', { params: { platform: 'netease' } }),
]
: [
Promise.resolve(emptyPlaylists),
Promise.resolve(emptySongs),
Promise.resolve(emptyPlaylists),
]; ];
// 3. QQ data: only fetch when QQ is logged in. When not logged in, // 3. QQ data: only fetch when QQ is logged in. When not logged in,
// resolve to empty payloads so the same indexed handling works. // resolve to empty payloads so the same indexed handling works.
const emptyPlaylists = { data: { playlists: [] } }; const qqPromises = has('qq') && this.authStatus.qq
const emptySongs = { data: { songs: [] } };
const qqPromises = this.authStatus.qq
? [ ? [
axios.get('/api/music/recommend/playlists', { params: { platform: 'qq' } }), axios.get('/api/music/recommend/playlists', { params: { platform: 'qq' } }),
axios.get('/api/music/recommend/songs', { params: { platform: 'qq' } }), axios.get('/api/music/recommend/songs', { params: { platform: 'qq' } }),
@@ -634,7 +705,7 @@ export const usePlayerStore = defineStore('player', {
// 4. Kugou data: every section (incl. recommend playlists) needs login, // 4. Kugou data: every section (incl. recommend playlists) needs login,
// so gate all three on auth like QQ. // so gate all three on auth like QQ.
const kugouPromises = this.authStatus.kugou const kugouPromises = has('kugou') && this.authStatus.kugou
? [ ? [
axios.get('/api/music/recommend/playlists', { params: { platform: 'kugou' } }), axios.get('/api/music/recommend/playlists', { params: { platform: 'kugou' } }),
axios.get('/api/music/recommend/songs', { params: { platform: 'kugou' } }), axios.get('/api/music/recommend/songs', { params: { platform: 'kugou' } }),
@@ -646,16 +717,49 @@ export const usePlayerStore = defineStore('player', {
Promise.resolve(emptyPlaylists), Promise.resolve(emptyPlaylists),
]; ];
const biliPromise = axios.get('/api/music/bilibili/popular?limit=12'); const biliPromise = has('bilibili')
? axios.get('/api/music/bilibili/popular?limit=12')
: Promise.resolve(emptySongs);
// 5. Jellyfin home sections. Favorites are guest-blocked server-side;
// allSettled turns that 403 into an empty section.
const jellyfinPromises = has('jellyfin')
? [
axios.get('/api/music/jellyfin/latest-albums', { params: { limit: 12 } }),
axios.get('/api/music/jellyfin/most-played', { params: { limit: 12 } }),
axios.get('/api/music/jellyfin/favorites', { params: { limit: 12 } }),
axios.get('/api/music/jellyfin/genres'),
axios.get('/api/music/user/playlists', { params: { platform: 'jellyfin' } }),
]
: [
Promise.resolve({ data: { albums: [] } }),
Promise.resolve(emptySongs),
Promise.resolve(emptySongs),
Promise.resolve({ data: { genres: [] } }),
Promise.resolve(emptyPlaylists),
];
const results = await Promise.allSettled([ const results = await Promise.allSettled([
...neteasePromises, ...neteasePromises,
...qqPromises, ...qqPromises,
...kugouPromises, ...kugouPromises,
biliPromise, biliPromise,
...jellyfinPromises,
]); ]);
const [neRecPL, neDaily, neUserPL, qqRecPL, qqDaily, qqUserPL, kgRecPL, kgDaily, kgUserPL, bili] = results; const [neRecPL, neDaily, neUserPL, qqRecPL, qqDaily, qqUserPL, kgRecPL, kgDaily, kgUserPL, bili,
jfLatest, jfMost, jfFav, jfGenres, jfUserPL] = results;
this.jellyfinLatestAlbums =
jfLatest.status === 'fulfilled' ? (jfLatest.value.data.albums ?? []) : [];
this.jellyfinMostPlayed =
jfMost.status === 'fulfilled' ? (jfMost.value.data.songs ?? []) : [];
this.jellyfinFavorites =
jfFav.status === 'fulfilled' ? (jfFav.value.data.songs ?? []) : [];
this.jellyfinGenres =
jfGenres.status === 'fulfilled' ? (jfGenres.value.data.genres ?? []) : [];
this.userPlaylists.jellyfin =
jfUserPL.status === 'fulfilled' ? (jfUserPL.value.data.playlists ?? []) : [];
this.recommendPlaylists.netease = this.recommendPlaylists.netease =
neRecPL.status === 'fulfilled' ? (neRecPL.value.data.playlists ?? []) : []; neRecPL.status === 'fulfilled' ? (neRecPL.value.data.playlists ?? []) : [];
@@ -684,8 +788,10 @@ export const usePlayerStore = defineStore('player', {
// Only mark as fetched if at least the auth-status calls succeeded — // Only mark as fetched if at least the auth-status calls succeeded —
// a fully failed fetch (network blip / server down) should NOT be // a fully failed fetch (network blip / server down) should NOT be
// cached for 5 minutes, otherwise the user has to hard-reload to // cached for 5 minutes, otherwise the user has to hard-reload to
// recover when connectivity returns. // recover when connectivity returns. (Disabled providers' checks are
// pre-rejected sentinels, so only enabled ones can vouch here.)
const authOk = const authOk =
jfAuthRes.status === 'fulfilled' ||
neAuthRes.status === 'fulfilled' || neAuthRes.status === 'fulfilled' ||
qqAuthRes.status === 'fulfilled' || qqAuthRes.status === 'fulfilled' ||
kugouAuthRes.status === 'fulfilled'; kugouAuthRes.status === 'fulfilled';
@@ -693,5 +799,32 @@ export const usePlayerStore = defineStore('player', {
this.lastFetchTime = Date.now(); this.lastFetchTime = Date.now();
} }
}, },
/**
* Play a whole Jellyfin genre: fetch its tracks, start the first, queue
* the rest. There is no bulk-queue endpoint, so the remainder is appended
* sequentially — capped at 30 tracks to keep the request burst small.
*/
async playJellyfinGenre(genreId: string) {
if (!this.activeBotId) return;
try {
const res = await axios.get(`/api/music/jellyfin/genre/${genreId}/songs`, {
params: { limit: 30 },
});
const songs: Song[] = res.data.songs ?? [];
if (songs.length === 0) {
this.notify('该流派下没有曲目', 'info');
return;
}
await this.playSong(songs[0]);
for (const song of songs.slice(1)) {
await this.addSong(song);
}
this.notify(`已加入 ${songs.length} 首`, 'info');
this.fetchQueue();
} catch {
this.notify('播放流派失败', 'error');
}
},
}, },
}); });
+1 -1
View File
@@ -28,7 +28,7 @@ function readAll(): Partial<Record<TabKey, Source>> {
export function loadTabSource(key: TabKey, fallback: Source = 'netease'): Source { export function loadTabSource(key: TabKey, fallback: Source = 'netease'): Source {
const all = readAll(); const all = readAll();
const v = all[key]; const v = all[key];
return v === 'netease' || v === 'qq' || v === 'kugou' || v === 'spotify' ? v : fallback; return v === 'jellyfin' || v === 'netease' || v === 'qq' || v === 'kugou' || v === 'spotify' ? v : fallback;
} }
export function saveTabSource(key: TabKey, value: Source): void { export function saveTabSource(key: TabKey, value: Source): void {
+2
View File
@@ -84,6 +84,8 @@
--brand-kugou-12: rgba(44, 162, 249, 0.12); --brand-kugou-12: rgba(44, 162, 249, 0.12);
--brand-spotify: #1DB954; --brand-spotify: #1DB954;
--brand-spotify-12: rgba(29, 185, 84, 0.12); --brand-spotify-12: rgba(29, 185, 84, 0.12);
--brand-jellyfin: #aa5cc3;
--brand-jellyfin-12: rgba(170, 92, 195, 0.12);
} }
// Dark theme (default) // Dark theme (default)
+144 -18
View File
@@ -18,10 +18,20 @@
</div> </div>
</section> </section>
<!-- 私人FM --> <!-- 私人FM(音源按 enabledProviders 门控;Jellyfin 电台优先) -->
<section class="section"> <section v-if="fmCardCount > 0" class="section">
<h2 class="section-title">私人FM</h2> <h2 class="section-title">私人FM</h2>
<div class="fm-card hover-scale" @click="playFm('netease')"> <div v-if="enabled('jellyfin')" class="fm-card hover-scale" @click="playFm('jellyfin')">
<div class="fm-icon-wrapper jellyfin">
<Icon icon="mdi:jellyfish" class="fm-icon" />
</div>
<div class="fm-info">
<div class="fm-title">Jellyfin 电台</div>
<div class="fm-desc">从收藏出发的 Instant Mix 歌曲流</div>
</div>
<Icon icon="mdi:play-circle" class="fm-play-icon" />
</div>
<div v-if="enabled('netease')" class="fm-card hover-scale" @click="playFm('netease')">
<div class="fm-icon-wrapper"> <div class="fm-icon-wrapper">
<Icon icon="mdi:radio" class="fm-icon" /> <Icon icon="mdi:radio" class="fm-icon" />
</div> </div>
@@ -31,7 +41,7 @@
</div> </div>
<Icon icon="mdi:play-circle" class="fm-play-icon" /> <Icon icon="mdi:play-circle" class="fm-play-icon" />
</div> </div>
<div v-if="store.authStatus.qq" class="fm-card hover-scale" @click="playFm('qq')"> <div v-if="enabled('qq') && store.authStatus.qq" class="fm-card hover-scale" @click="playFm('qq')">
<div class="fm-icon-wrapper qq"> <div class="fm-icon-wrapper qq">
<Icon icon="mdi:radar" class="fm-icon" /> <Icon icon="mdi:radar" class="fm-icon" />
</div> </div>
@@ -41,7 +51,7 @@
</div> </div>
<Icon icon="mdi:play-circle" class="fm-play-icon" /> <Icon icon="mdi:play-circle" class="fm-play-icon" />
</div> </div>
<div v-if="store.authStatus.kugou" class="fm-card hover-scale" @click="playFm('kugou')"> <div v-if="enabled('kugou') && store.authStatus.kugou" class="fm-card hover-scale" @click="playFm('kugou')">
<div class="fm-icon-wrapper kugou"> <div class="fm-icon-wrapper kugou">
<Icon icon="mdi:radio-tower" class="fm-icon" /> <Icon icon="mdi:radio-tower" class="fm-icon" />
</div> </div>
@@ -53,6 +63,76 @@
</div> </div>
</section> </section>
<!-- Jellyfin: 最近添加 -->
<section class="section" v-if="store.jellyfinLatestAlbums.length > 0">
<h2 class="section-title">最近添加</h2>
<div class="playlist-grid">
<RouterLink
v-for="al in store.jellyfinLatestAlbums"
:key="al.id"
:to="`/album/${al.id}?platform=jellyfin`"
class="playlist-card hover-scale"
>
<CoverArt :url="al.coverUrl" :size="160" :radius="10" :show-shadow="true" />
<div class="playlist-name">{{ al.name }}</div>
<div class="playlist-count">{{ al.artist }}</div>
</RouterLink>
</div>
</section>
<!-- Jellyfin: 播放最多 -->
<section class="section" v-if="store.jellyfinMostPlayed.length > 0">
<h2 class="section-title">播放最多</h2>
<div class="daily-grid">
<div
v-for="song in store.jellyfinMostPlayed"
:key="song.id"
class="daily-card hover-scale"
@click="store.playSong(song)"
>
<CoverArt :url="song.coverUrl" :size="120" :radius="10" :show-shadow="true" />
<div class="daily-name">{{ song.name }}</div>
<div class="daily-artist">{{ song.artist }}</div>
</div>
</div>
</section>
<!-- Jellyfin: 收藏曲目 -->
<section class="section" v-if="store.jellyfinFavorites.length > 0">
<h2 class="section-title">
<Icon icon="mdi:star" style="color: var(--brand-jellyfin)" />
Jellyfin 收藏
</h2>
<div class="daily-grid">
<div
v-for="song in store.jellyfinFavorites"
:key="song.id"
class="daily-card hover-scale"
@click="store.playSong(song)"
>
<CoverArt :url="song.coverUrl" :size="120" :radius="10" :show-shadow="true" />
<div class="daily-name">{{ song.name }}</div>
<div class="daily-artist">{{ song.artist }}</div>
</div>
</div>
</section>
<!-- Jellyfin: 流派 -->
<section class="section" v-if="store.jellyfinGenres.length > 0">
<h2 class="section-title">流派</h2>
<div class="genre-chips">
<button
v-for="g in store.jellyfinGenres"
:key="g.id"
class="genre-chip"
@click="store.playJellyfinGenre(g.id)"
>
<Icon icon="mdi:music-note" />
{{ g.name }}
</button>
</div>
</section>
<!-- 每日推荐 --> <!-- 每日推荐 -->
<section class="section" v-if="dailyAvailable.length > 0"> <section class="section" v-if="dailyAvailable.length > 0">
<h2 class="section-title"> <h2 class="section-title">
@@ -177,16 +257,31 @@ const store = usePlayerStore();
const USER_PLAYLIST_LIMIT = 20; const USER_PLAYLIST_LIMIT = 20;
const userPlaylistsExpanded = ref(false); const userPlaylistsExpanded = ref(false);
// Available sources per section. // Server-side source gate (enabledProviders).
// Recommend playlists work anonymously on netease, so it's always available; const enabled = (p: string) => store.enabledProviders.includes(p);
const fmCardCount = computed(() =>
['jellyfin', 'netease'].filter(enabled).length +
(enabled('qq') && store.authStatus.qq ? 1 : 0) +
(enabled('kugou') && store.authStatus.kugou ? 1 : 0),
);
// Available sources per section. Jellyfin has no daily/recommend concept —
// it gets its own home sections instead — so those two tab bars only ever
// carry the legacy sources (which is also what their store maps are keyed by).
type LegacySource = Exclude<Source, 'jellyfin'>;
// Recommend playlists work anonymously on netease (when enabled);
// QQ requires login. This intentionally differs from dailyAvailable/userAvailable. // QQ requires login. This intentionally differs from dailyAvailable/userAvailable.
const recommendAvailable = computed<Source[]>(() => { const recommendAvailable = computed<LegacySource[]>(() => {
const s: Source[] = ['netease']; const s: LegacySource[] = [];
if (store.authStatus.qq) s.push('qq'); if (enabled('netease')) s.push('netease');
if (store.authStatus.kugou) s.push('kugou'); if (enabled('qq') && store.authStatus.qq) s.push('qq');
if (enabled('kugou') && store.authStatus.kugou) s.push('kugou');
return s; return s;
}); });
const dailyAvailable = computed<Source[]>(() => store.availableSources); const dailyAvailable = computed<LegacySource[]>(() =>
store.availableSources.filter((s): s is LegacySource => s !== 'jellyfin'),
);
const userAvailable = computed<Source[]>(() => store.availableSources); const userAvailable = computed<Source[]>(() => store.availableSources);
// Persisted active source per section. // Persisted active source per section.
@@ -199,14 +294,14 @@ watch(dailySource, (v) => saveTabSource('home.daily', v));
watch(userSource, (v) => saveTabSource('home.user', v)); watch(userSource, (v) => saveTabSource('home.user', v));
// Fallback when persisted source is no longer available. // Fallback when persisted source is no longer available.
const recommendSourceSafe = computed<Source>(() => const recommendSourceSafe = computed<LegacySource>(() =>
recommendAvailable.value.includes(recommendSource.value) (recommendAvailable.value as Source[]).includes(recommendSource.value)
? recommendSource.value ? (recommendSource.value as LegacySource)
: recommendAvailable.value[0] ?? 'netease' : recommendAvailable.value[0] ?? 'netease'
); );
const dailySourceSafe = computed<Source>(() => const dailySourceSafe = computed<LegacySource>(() =>
dailyAvailable.value.includes(dailySource.value) (dailyAvailable.value as Source[]).includes(dailySource.value)
? dailySource.value ? (dailySource.value as LegacySource)
: dailyAvailable.value[0] ?? 'netease' : dailyAvailable.value[0] ?? 'netease'
); );
const userSourceSafe = computed<Source>(() => const userSourceSafe = computed<Source>(() =>
@@ -373,6 +468,11 @@ onMounted(() => {
&.kugou { &.kugou {
background: linear-gradient(135deg, var(--brand-kugou), #1d7fd1); background: linear-gradient(135deg, var(--brand-kugou), #1d7fd1);
} }
// Jellyfin's brand gradient (purple → blue).
&.jellyfin {
background: linear-gradient(135deg, var(--brand-jellyfin), #00a4dc);
}
} }
.fm-icon { .fm-icon {
@@ -472,4 +572,30 @@ onMounted(() => {
color: var(--text-tertiary); color: var(--text-tertiary);
margin-top: 2px; margin-top: 2px;
} }
// Jellyfin 流派
.genre-chips {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.genre-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
background: var(--bg-card);
border-radius: 999px;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
&:hover {
color: var(--brand-jellyfin);
background: var(--brand-jellyfin-12);
}
}
</style> </style>
+301
View File
@@ -0,0 +1,301 @@
<template>
<div class="saved-queues-page">
<button class="back-btn" @click="$router.back()">
<Icon icon="mdi:arrow-left" />
返回
</button>
<h1 class="page-title">已保存队列</h1>
<div v-if="disabled" class="empty">
此功能未启用。请在「设置 → 行为设置」中开启「保存/加载播放清单」。
</div>
<template v-else>
<!-- Save current queue -->
<section class="save-card">
<div class="save-title">保存当前队列</div>
<div class="save-hint">
将机器人 <strong>{{ activeBotName }}</strong> 当前的播放队列保存为一份清单,稍后可加载或追加。
</div>
<div class="save-row">
<input
v-model="newName"
class="input"
type="text"
placeholder="清单名称"
maxlength="80"
@keyup.enter="onSave"
/>
<label class="shared-check">
<input v-model="newShared" type="checkbox" />
共享
</label>
<button class="btn-primary" :disabled="!canSave || saving" @click="onSave">
{{ saving ? '保存中…' : '保存' }}
</button>
</div>
</section>
<div v-if="loading" class="loading">加载中...</div>
<div v-else-if="queues.length === 0" class="empty">还没有已保存的队列</div>
<div v-else class="queue-list">
<div v-for="q in queues" :key="q.id" class="queue-row">
<div class="queue-info">
<span class="queue-name">{{ q.name }}</span>
<span v-if="isShared(q)" class="badge shared">共享</span>
<span v-else class="badge private">私有</span>
<span class="queue-count">{{ q.songCount }} 首</span>
</div>
<div class="queue-actions">
<button class="btn-sm primary" :disabled="!activeBotId || busyId === q.id" @click="onLoad(q, 'replace')">
加载
</button>
<button class="btn-sm" :disabled="!activeBotId || busyId === q.id" @click="onLoad(q, 'append')">
追加
</button>
<button class="btn-sm danger" :disabled="busyId === q.id" @click="onDelete(q)">
删除
</button>
</div>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { Icon } from '@iconify/vue';
import { usePlayerStore } from '../stores/player.js';
import { useSavedQueues } from '../composables/useSavedQueues.js';
import { isShared, type SavedQueueMeta } from '../composables/savedQueues.js';
const store = usePlayerStore();
const { queues, loading, disabled, list, save, load, remove } = useSavedQueues();
const newName = ref('');
const newShared = ref(false);
const saving = ref(false);
const busyId = ref<number | null>(null);
const activeBotId = computed(() => store.activeBotId);
const activeBotName = computed(() => store.activeBot?.name ?? '(未选择)');
const canSave = computed(() => !!activeBotId.value && newName.value.trim().length > 0);
async function onSave() {
if (!canSave.value || saving.value) return;
saving.value = true;
try {
await save(activeBotId.value!, newName.value.trim(), newShared.value);
store.notify(`已保存「${newName.value.trim()}」`, 'info');
newName.value = '';
newShared.value = false;
} catch (e: unknown) {
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error;
store.notify(msg || '保存失败', 'error');
} finally {
saving.value = false;
}
}
async function onLoad(q: SavedQueueMeta, mode: 'replace' | 'append') {
if (!activeBotId.value) return;
busyId.value = q.id;
try {
const res = await load(q.id, activeBotId.value, mode);
store.notify(
mode === 'append' ? `已追加「${q.name}」(${res.loaded} 首)` : `已加载「${q.name}」(${res.loaded} 首)`,
'info',
);
store.fetchQueue();
} catch {
store.notify('加载失败', 'error');
} finally {
busyId.value = null;
}
}
async function onDelete(q: SavedQueueMeta) {
busyId.value = q.id;
try {
await remove(q.id);
store.notify(`已删除「${q.name}」`, 'info');
} catch {
store.notify('删除失败', 'error');
} finally {
busyId.value = null;
}
}
onMounted(async () => {
if (!store.activeBotId) {
await store.fetchBots();
}
await list();
});
</script>
<style lang="scss" scoped>
.back-btn {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
opacity: 0.7;
margin-bottom: 16px;
transition: opacity var(--transition-fast);
&:hover { opacity: 1; }
}
.page-title {
font-size: 28px;
font-weight: 800;
margin-bottom: 24px;
}
.save-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 18px;
margin-bottom: 24px;
}
.save-title {
font-size: 16px;
font-weight: 700;
margin-bottom: 6px;
}
.save-hint {
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 14px;
}
.save-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.input {
flex: 1;
min-width: 180px;
padding: 10px 12px;
font-size: 14px;
background: var(--hover-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
color: inherit;
&:focus { outline: none; border-color: var(--color-primary); }
}
.shared-check {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-secondary);
white-space: nowrap;
cursor: pointer;
}
.btn-primary {
padding: 10px 18px;
font-size: 14px;
font-weight: 600;
border-radius: var(--radius-sm);
background: var(--color-primary);
color: #fff;
border: 1px solid var(--color-primary);
cursor: pointer;
transition: filter var(--transition-fast);
&:hover:not(:disabled) { filter: brightness(1.08); }
&:disabled { opacity: 0.5; cursor: not-allowed; }
}
.queue-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.queue-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
flex-wrap: wrap;
}
.queue-info {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.queue-name {
font-size: 15px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.queue-count {
font-size: 12px;
color: var(--text-tertiary);
}
.badge {
font-size: 10px;
font-weight: 700;
padding: 2px 6px;
border-radius: 4px;
flex-shrink: 0;
&.shared { background: var(--color-primary-15); color: var(--color-primary); }
&.private { background: var(--hover-bg); color: var(--text-secondary); }
}
.queue-actions {
display: flex;
gap: 6px;
}
.btn-sm {
padding: 6px 12px;
font-size: 12px;
font-weight: 600;
border-radius: var(--radius-sm);
background: var(--hover-bg);
border: 1px solid var(--border-color);
color: var(--text-primary);
cursor: pointer;
transition: all var(--transition-fast);
&:hover:not(:disabled) { background: var(--bg-secondary); border-color: var(--color-primary); }
&:disabled { opacity: 0.5; cursor: not-allowed; }
&.primary { background: var(--color-primary); border-color: var(--color-primary); color: #fff; }
&.danger { color: #ef4444; }
}
.loading {
text-align: center;
padding: 60px;
color: var(--text-secondary);
}
.empty {
text-align: center;
padding: 60px 20px;
color: var(--text-tertiary);
font-size: 14px;
}
</style>
+176 -4
View File
@@ -56,23 +56,35 @@
<div v-if="loading" class="loading">搜索中...</div> <div v-if="loading" class="loading">搜索中...</div>
<template v-else-if="allSongs.length || allAlbums.length || allPlaylists.length"> <template v-else-if="allSongs.length || allAlbums.length || allPlaylists.length">
<!-- Only enabled sources are offered (enabledProviders gate); Jellyfin
(opt-in) comes first when enabled. -->
<div class="source-bar"> <div class="source-bar">
<button <button
v-if="sourceEnabled('jellyfin')"
class="source-btn"
:class="{ active: selectedSource === 'jellyfin' }"
@click="selectedSource = 'jellyfin'"
>Jellyfin</button>
<button
v-if="sourceEnabled('netease')"
class="source-btn" class="source-btn"
:class="{ active: selectedSource === 'netease' }" :class="{ active: selectedSource === 'netease' }"
@click="selectedSource = 'netease'" @click="selectedSource = 'netease'"
>网易云</button> >网易云</button>
<button <button
v-if="sourceEnabled('qq')"
class="source-btn" class="source-btn"
:class="{ active: selectedSource === 'qq' }" :class="{ active: selectedSource === 'qq' }"
@click="selectedSource = 'qq'" @click="selectedSource = 'qq'"
>QQ</button> >QQ</button>
<button <button
v-if="sourceEnabled('bilibili')"
class="source-btn" class="source-btn"
:class="{ active: selectedSource === 'bilibili' }" :class="{ active: selectedSource === 'bilibili' }"
@click="selectedSource = 'bilibili'" @click="selectedSource = 'bilibili'"
>B站</button> >B站</button>
<button <button
v-if="sourceEnabled('kugou')"
class="source-btn" class="source-btn"
:class="{ active: selectedSource === 'kugou' }" :class="{ active: selectedSource === 'kugou' }"
@click="selectedSource = 'kugou'" @click="selectedSource = 'kugou'"
@@ -165,6 +177,13 @@
@add="store.addSong(song)" @add="store.addSong(song)"
/> />
</section> </section>
<div v-if="showLoadMore" class="load-more-wrap">
<button class="load-more-btn" :disabled="currentLoadingMore" @click="loadMore">
<Icon v-if="currentLoadingMore" icon="mdi:loading" class="spin" />
{{ currentLoadingMore ? '加载中...' : '加载更多' }}
</button>
</div>
</template> </template>
<div v-else-if="searched" class="empty">未找到相关结果</div> <div v-else-if="searched" class="empty">未找到相关结果</div>
@@ -180,6 +199,9 @@ import { usePlayerStore } from '../stores/player.js';
import type { Song } from '../stores/player.js'; import type { Song } from '../stores/player.js';
import SongCard from '../components/SongCard.vue'; import SongCard from '../components/SongCard.vue';
import CoverArt from '../components/CoverArt.vue'; import CoverArt from '../components/CoverArt.vue';
import { mergeDedup, hasMore, nextOffset } from './searchPagination.js';
const PAGE_SIZE = 20;
const store = usePlayerStore(); const store = usePlayerStore();
const route = useRoute(); const route = useRoute();
@@ -187,18 +209,22 @@ const router = useRouter();
const SOURCE_STORAGE_KEY = 'search-source'; const SOURCE_STORAGE_KEY = 'search-source';
type SearchSource = 'netease' | 'qq' | 'bilibili' | 'local' | 'kugou'; type SearchSource = 'jellyfin' | 'netease' | 'qq' | 'bilibili' | 'local' | 'kugou';
const SEARCH_SOURCES: SearchSource[] = ['jellyfin', 'netease', 'qq', 'bilibili', 'local', 'kugou'];
function loadSource(): SearchSource { function loadSource(): SearchSource {
try { try {
const stored = localStorage.getItem(SOURCE_STORAGE_KEY); const stored = localStorage.getItem(SOURCE_STORAGE_KEY);
if (stored === 'netease' || stored === 'qq' || stored === 'bilibili' || stored === 'local' || stored === 'kugou') return stored; if (SEARCH_SOURCES.includes(stored as SearchSource)) return stored as SearchSource;
} catch { /* localStorage blocked */ } } catch { /* localStorage blocked */ }
return 'netease'; return 'netease';
} }
type TabType = 'songs' | 'albums' | 'playlists';
const query = ref((route.query.q as string) || ''); const query = ref((route.query.q as string) || '');
const activeTab = ref<'songs' | 'albums' | 'playlists'>('songs'); const activeTab = ref<TabType>('songs');
const selectedSource = ref<SearchSource>(loadSource()); const selectedSource = ref<SearchSource>(loadSource());
interface Album { id: string; name: string; artist: string; coverUrl: string; songCount?: number; platform: string; } interface Album { id: string; name: string; artist: string; coverUrl: string; songCount?: number; platform: string; }
@@ -207,6 +233,9 @@ interface Playlist { id: string; name: string; coverUrl: string; songCount?: num
const allSongs = ref<Song[]>([]); const allSongs = ref<Song[]>([]);
const allAlbums = ref<Album[]>([]); const allAlbums = ref<Album[]>([]);
const allPlaylists = ref<Playlist[]>([]); const allPlaylists = ref<Playlist[]>([]);
// "加载更多" 分页状态:hasMore 按 (类型, 音源) 记录,loadingMore 按类型记录。
const hasMoreMap = ref<Record<string, boolean>>({});
const loadingMore = ref<Record<TabType, boolean>>({ songs: false, albums: false, playlists: false });
const loading = ref(false); const loading = ref(false);
const searched = ref(false); const searched = ref(false);
const uploading = ref(false); const uploading = ref(false);
@@ -230,6 +259,97 @@ const filteredPlaylists = computed(() =>
const hasLocalSongs = computed(() => localAudioEnabled.value && allSongs.value.some((s) => s.platform === 'local')); const hasLocalSongs = computed(() => localAudioEnabled.value && allSongs.value.some((s) => s.platform === 'local'));
// Server-side source gate (enabledProviders). Until /providers loads, the
// store list is empty — treat that as "everything shown" so a slow request
// doesn't blank the bar.
function sourceEnabled(p: string): boolean {
return store.enabledProviders.length === 0 || store.enabledProviders.includes(p);
}
/** Snap a disabled/stale selection to the configured default (or first enabled). */
function fixupSelectedSource() {
if (selectedSource.value === 'local' || sourceEnabled(selectedSource.value)) return;
const def = store.defaultSource as SearchSource;
selectedSource.value = SEARCH_SOURCES.includes(def) && sourceEnabled(def)
? def
: SEARCH_SOURCES.find((s) => s !== 'local' && sourceEnabled(s)) ?? 'netease';
}
// ---- 分页 / 加载更多 ----
function pageKey(type: TabType, source: string): string {
return `${type}:${source}`;
}
const currentItems = computed(() => {
if (activeTab.value === 'albums') return filteredAlbums.value;
if (activeTab.value === 'playlists') return filteredPlaylists.value;
return filteredSongs.value;
});
const currentLoadingMore = computed(() => loadingMore.value[activeTab.value]);
const currentHasMore = computed(
() => hasMoreMap.value[pageKey(activeTab.value, selectedSource.value)] ?? false
);
// 有结果、还有下一页时才显示按钮;加载中时按钮保留但禁用并显示 spinner。
const showLoadMore = computed(() => currentItems.value.length > 0 && currentHasMore.value);
function resetPagination() {
hasMoreMap.value = {};
loadingMore.value = { songs: false, albums: false, playlists: false };
}
// 记录某个 (类型, 音源) 是否还有更多:返回条数 === PAGE_SIZE 视为还有下一页。
function setHasMore(type: TabType, source: string, returnedCount: number) {
hasMoreMap.value = {
...hasMoreMap.value,
[pageKey(type, source)]: hasMore(returnedCount, PAGE_SIZE),
};
}
// 初始 /search/all 返回的是各音源合并的首页,按音源分组统计每种类型的条数。
function recordInitialHasMore(items: { platform: string }[], type: TabType) {
const counts: Record<string, number> = {};
for (const it of items) counts[it.platform] = (counts[it.platform] ?? 0) + 1;
const next = { ...hasMoreMap.value };
for (const [source, count] of Object.entries(counts)) {
next[pageKey(type, source)] = hasMore(count, PAGE_SIZE);
}
hasMoreMap.value = next;
}
async function loadMore() {
const type = activeTab.value;
const source = selectedSource.value;
if (loadingMore.value[type]) return;
if (!currentHasMore.value) return;
const offset = nextOffset(currentItems.value.length, PAGE_SIZE);
loadingMore.value = { ...loadingMore.value, [type]: true };
try {
const res = await axios.get('/api/music/search', {
params: { q: query.value, platform: source, limit: PAGE_SIZE, offset },
});
if (type === 'albums') {
const incoming = (res.data.albums ?? []) as Album[];
allAlbums.value = mergeDedup(allAlbums.value, incoming);
setHasMore(type, source, incoming.length);
} else if (type === 'playlists') {
const incoming = (res.data.playlists ?? []) as Playlist[];
allPlaylists.value = mergeDedup(allPlaylists.value, incoming);
setHasMore(type, source, incoming.length);
} else {
const incoming = (res.data.songs ?? []) as Song[];
allSongs.value = mergeDedup(allSongs.value, incoming);
setHasMore(type, source, incoming.length);
}
} catch {
// 保留 hasMore 现状,允许用户重试。
} finally {
loadingMore.value = { ...loadingMore.value, [type]: false };
}
}
// Persist source preference // Persist source preference
watch(selectedSource, (src) => { watch(selectedSource, (src) => {
try { localStorage.setItem(SOURCE_STORAGE_KEY, src); } catch { /* ignore */ } try { localStorage.setItem(SOURCE_STORAGE_KEY, src); } catch { /* ignore */ }
@@ -266,12 +386,16 @@ async function doSearch() {
loading.value = true; loading.value = true;
searched.value = true; searched.value = true;
activeTab.value = 'songs'; activeTab.value = 'songs';
resetPagination();
router.replace({ query: { q: query.value } }); router.replace({ query: { q: query.value } });
try { try {
const res = await axios.get('/api/music/search/all', { params: { q: query.value } }); const res = await axios.get('/api/music/search/all', { params: { q: query.value } });
allSongs.value = res.data.songs ?? []; allSongs.value = res.data.songs ?? [];
allAlbums.value = res.data.albums ?? []; allAlbums.value = res.data.albums ?? [];
allPlaylists.value = res.data.playlists ?? []; allPlaylists.value = res.data.playlists ?? [];
recordInitialHasMore(allSongs.value, 'songs');
recordInitialHasMore(allAlbums.value, 'albums');
recordInitialHasMore(allPlaylists.value, 'playlists');
} catch { } catch {
allSongs.value = []; allAlbums.value = []; allPlaylists.value = []; allSongs.value = []; allAlbums.value = []; allPlaylists.value = [];
} finally { } finally {
@@ -357,6 +481,7 @@ function badgeLabel(platform: string): string {
if (platform === 'youtube') return 'YouTube'; if (platform === 'youtube') return 'YouTube';
if (platform === 'local') return '本地'; if (platform === 'local') return '本地';
if (platform === 'kugou') return '酷狗'; if (platform === 'kugou') return '酷狗';
if (platform === 'jellyfin') return 'Jellyfin';
return '网易云'; return '网易云';
} }
@@ -366,6 +491,7 @@ function badgeClass(platform: string): string {
if (platform === 'youtube') return 'badge-youtube'; if (platform === 'youtube') return 'badge-youtube';
if (platform === 'local') return 'badge-local'; if (platform === 'local') return 'badge-local';
if (platform === 'kugou') return 'badge-kugou'; if (platform === 'kugou') return 'badge-kugou';
if (platform === 'jellyfin') return 'badge-jellyfin';
return 'badge-netease'; return 'badge-netease';
} }
@@ -381,9 +507,11 @@ async function loadLocalAudioSetting() {
} }
} }
onMounted(() => { onMounted(async () => {
loadLocalAudioSetting(); loadLocalAudioSetting();
if (query.value) doSearch(); if (query.value) doSearch();
await store.fetchProviders();
fixupSelectedSource();
}); });
</script> </script>
@@ -601,6 +729,45 @@ onMounted(() => {
.result-section { .result-section {
margin-bottom: 32px; margin-bottom: 32px;
} }
.load-more-wrap {
display: flex;
justify-content: center;
margin: 8px 0 32px;
}
.load-more-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 9px 28px;
border-radius: var(--radius-md);
font-size: 14px;
font-family: inherit;
font-weight: var(--fw-semi);
color: var(--text-secondary);
background: var(--bg-card);
cursor: pointer;
transition: color var(--transition-fast), background var(--transition-fast);
&:hover:not(:disabled) {
color: var(--color-primary);
background: rgba(51, 94, 234, 0.12);
}
&:disabled {
cursor: not-allowed;
opacity: 0.7;
}
.spin {
animation: load-more-spin 0.8s linear infinite;
}
}
@keyframes load-more-spin {
to { transform: rotate(360deg); }
}
.card-grid { .card-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
@@ -657,6 +824,11 @@ onMounted(() => {
color: var(--brand-kugou); color: var(--brand-kugou);
} }
.badge-jellyfin {
background: var(--brand-jellyfin-12);
color: var(--brand-jellyfin);
}
.fav-badge { .fav-badge {
position: absolute; position: absolute;
top: 8px; top: 8px;
+408 -14
View File
@@ -165,12 +165,118 @@
</div> </div>
</section> </section>
<!-- Music Account - QR Code Login (platform auth) requires platform.auth --> <!-- 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"> <section v-if="can('platform.auth')" class="settings-section">
<h2 class="section-title">Jellyfin 音乐库(可选)</h2>
<div class="account-card">
<div class="account-header">
<Icon icon="mdi:jellyfish" class="account-icon jellyfin-icon" />
<div class="account-info">
<div class="account-name">Jellyfin</div>
<div class="account-status" :class="{ logged: jellyfinAuth.loggedIn }">
{{ jellyfinAuth.loggedIn ? `已连接: ${jellyfinAuth.nickname}` : '未连接' }}
</div>
</div>
</div>
<!-- Enable (opt-in source: presence in enabledProviders) -->
<label class="profile-toggle behavior-toggle spotify-toggle">
<div class="profile-toggle-text">
<div class="profile-toggle-label">启用 Jellyfin 音源</div>
<div class="profile-toggle-hint">开启后 Jellyfin 出现在搜索、首页与聊天命令(!play -j)的音源列表中。默认关闭,点击下方「保存」后生效。</div>
</div>
<input v-model="jellyfinEnabledForm" type="checkbox" class="profile-toggle-switch" />
</label>
<div class="form-group">
<label>服务器地址</label>
<input v-model="jellyfinForm.serverUrl" class="input" autocomplete="off" placeholder="https://jellyfin.example.com" />
</div>
<div class="setting-row spotify-row">
<div class="setting-label">认证方式</div>
<div class="spotify-btn-group">
<button
class="spotify-choice"
:class="{ active: jellyfinForm.authMode === 'userpass' }"
@click="jellyfinForm.authMode = 'userpass'"
>账号密码</button>
<button
class="spotify-choice"
:class="{ active: jellyfinForm.authMode === 'apikey' }"
@click="jellyfinForm.authMode = 'apikey'"
>API Key</button>
</div>
</div>
<template v-if="jellyfinForm.authMode === 'userpass'">
<div class="form-group">
<label>用户名</label>
<input v-model="jellyfinForm.username" class="input" autocomplete="off" placeholder="Jellyfin 用户名" />
</div>
<div class="form-group">
<label>
密码
<span class="spotify-secret-hint" :class="{ set: jellyfinHasPassword }">
{{ jellyfinHasPassword ? '已设置' : '未设置' }}
</span>
</label>
<input
v-model="jellyfinForm.password"
class="input"
type="password"
autocomplete="off"
placeholder="留空表示不修改已保存的密码"
/>
</div>
</template>
<template v-else>
<div class="form-group">
<label>
API Key
<span class="spotify-secret-hint" :class="{ set: jellyfinHasApiKey }">
{{ jellyfinHasApiKey ? '已设置' : '未设置' }}
</span>
</label>
<input
v-model="jellyfinForm.apiKey"
class="input"
type="password"
autocomplete="off"
placeholder="留空表示不修改已保存的 API Key"
/>
</div>
<div class="form-group">
<label>用户 ID</label>
<input v-model="jellyfinForm.userId" class="input" autocomplete="off" placeholder="该 Key 对应使用的 Jellyfin 用户 ID" />
</div>
</template>
<div class="spotify-actions">
<button class="btn-secondary" :disabled="jellyfinTesting" @click="testJellyfin">
{{ jellyfinTesting ? '测试中…' : '测试连接' }}
</button>
<button class="btn-primary" :disabled="jellyfinSaving" @click="saveJellyfin">
{{ jellyfinSaving ? '保存中…' : '保存' }}
</button>
</div>
<p v-if="jellyfinMessage" class="spotify-message" :class="`tone-${jellyfinMessageTone}`">{{ jellyfinMessage }}</p>
</div>
</section>
<!-- Music Account - QR Code Login (platform auth) requires platform.auth.
Cards for sources disabled via enabledProviders are hidden. -->
<section v-if="can('platform.auth') && (providerOn('netease') || providerOn('qq') || providerOn('bilibili') || providerOn('kugou'))" class="settings-section">
<h2 class="section-title">音乐账号</h2> <h2 class="section-title">音乐账号</h2>
<!-- NetEase --> <!-- NetEase -->
<div class="account-card"> <div v-if="providerOn('netease')" class="account-card">
<div class="account-header"> <div class="account-header">
<Icon icon="mdi:cloud-outline" class="account-icon" /> <Icon icon="mdi:cloud-outline" class="account-icon" />
<div class="account-info"> <div class="account-info">
@@ -240,7 +346,7 @@
</div> </div>
<!-- QQ Music --> <!-- QQ Music -->
<div class="account-card"> <div v-if="providerOn('qq')" class="account-card">
<div class="account-header"> <div class="account-header">
<Icon icon="mdi:music-circle-outline" class="account-icon" /> <Icon icon="mdi:music-circle-outline" class="account-icon" />
<div class="account-info"> <div class="account-info">
@@ -309,7 +415,7 @@
</div> </div>
</div> </div>
<!-- BiliBili --> <!-- BiliBili -->
<div class="account-card"> <div v-if="providerOn('bilibili')" class="account-card">
<div class="account-header"> <div class="account-header">
<Icon icon="mdi:video-outline" class="account-icon bilibili-icon" /> <Icon icon="mdi:video-outline" class="account-icon bilibili-icon" />
<div class="account-info"> <div class="account-info">
@@ -379,7 +485,7 @@
</div> </div>
<!-- Kugou --> <!-- Kugou -->
<div class="account-card"> <div v-if="providerOn('kugou')" class="account-card">
<div class="account-header"> <div class="account-header">
<Icon icon="mdi:music-circle-outline" class="account-icon kugou-icon" /> <Icon icon="mdi:music-circle-outline" class="account-icon kugou-icon" />
<div class="account-info"> <div class="account-info">
@@ -449,6 +555,36 @@
</div> </div>
</section> </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 --> <!-- Spotify (Connect) playback via librespot — requires platform.auth -->
<section v-if="can('platform.auth')" class="settings-section"> <section v-if="can('platform.auth')" class="settings-section">
<h2 class="section-title">Spotify 播放(实验性)</h2> <h2 class="section-title">Spotify 播放(实验性)</h2>
@@ -552,7 +688,25 @@
<!-- Audio Quality requires quality --> <!-- Audio Quality requires quality -->
<section v-if="can('quality')" class="settings-section"> <section v-if="can('quality')" class="settings-section">
<h2 class="section-title">音质设置</h2> <h2 class="section-title">音质设置</h2>
<div class="setting-row"> <div v-if="providerOn('jellyfin')" class="setting-row">
<div class="setting-label">
<Icon icon="mdi:jellyfish" class="setting-icon" />
Jellyfin 音质
</div>
<div class="quality-options">
<button
v-for="q in jellyfinQualityLevels"
:key="q.value"
class="quality-btn"
:class="{ active: jellyfinQuality === q.value }"
@click="setJellyfinQuality(q.value)"
>
<div class="quality-name">{{ q.label }}</div>
<div class="quality-desc">{{ q.desc }}</div>
</button>
</div>
</div>
<div v-if="providerOn('netease') || providerOn('qq') || providerOn('kugou') || providerOn('bilibili')" class="setting-row">
<div class="setting-label"> <div class="setting-label">
<Icon icon="mdi:music-note-eighth" class="setting-icon" /> <Icon icon="mdi:music-note-eighth" class="setting-icon" />
音源质量 音源质量
@@ -636,6 +790,32 @@
@change="saveLocalAudioEnabled" @change="saveLocalAudioEnabled"
/> />
</label> </label>
<label class="profile-toggle behavior-toggle">
<div class="profile-toggle-text">
<div class="profile-toggle-label">保存/加载播放清单(含重启后自动恢复队列)</div>
<div class="profile-toggle-hint">开启后可在网页与聊天命令(!save / !load / !queues)保存和加载播放清单,并在机器人重启后自动恢复并继续播放上次的队列。重启只能从当前曲目的开头恢复(不记忆进度);Spotify 恢复为尽力而为。默认关闭。</div>
</div>
<input
v-model="savedQueuesEnabled"
type="checkbox"
class="profile-toggle-switch"
@change="saveSavedQueuesEnabled"
/>
</label>
<label class="profile-toggle behavior-toggle">
<div class="profile-toggle-text">
<div class="profile-toggle-label">直接播放单曲时不清空队列</div>
<div class="profile-toggle-hint">开启后,直接播放单曲会插入到当前歌曲之后并立即播放,播完继续原队列,而不是清空整个队列。仅影响单曲的「直接播放」;歌单/专辑/电台仍会替换队列。默认关闭。</div>
</div>
<input
v-model="playKeepsQueue"
type="checkbox"
class="profile-toggle-switch"
@change="savePlayKeepsQueue"
/>
</label>
</section> </section>
<!-- Guest Mode (admin only) --> <!-- Guest Mode (admin only) -->
@@ -984,10 +1164,21 @@ const qualityLevels = [
{ value: 'jymaster', label: '超清母带', desc: '最高质量' }, { value: 'jymaster', label: '超清母带', desc: '最高质量' },
]; ];
// Jellyfin quality tiers (direct = untranscoded original; the rest are
// server-side transcodes for low-bandwidth Jellyfin links).
const jellyfinQuality = ref('direct');
const jellyfinQualityLevels = [
{ value: 'direct', label: '原始直传', desc: 'Direct(无转码)' },
{ value: '320', label: '320kbps', desc: '服务器转码' },
{ value: '192', label: '192kbps', desc: '服务器转码' },
{ value: '128', label: '128kbps', desc: '服务器转码' },
];
async function loadQuality() { async function loadQuality() {
try { try {
const res = await axios.get('/api/music/quality'); const res = await axios.get('/api/music/quality');
currentQuality.value = res.data.netease || 'exhigh'; currentQuality.value = res.data.netease || 'exhigh';
jellyfinQuality.value = res.data.jellyfin || 'direct';
} catch { /* ignore */ } } catch { /* ignore */ }
} }
@@ -998,6 +1189,13 @@ async function setQuality(q: string) {
} catch { /* ignore */ } } catch { /* ignore */ }
} }
async function setJellyfinQuality(q: string) {
jellyfinQuality.value = q;
try {
await axios.post('/api/music/quality', { quality: q, platform: 'jellyfin' });
} catch { /* ignore */ }
}
// Login mode: 'qr' | 'cookie' | null // Login mode: 'qr' | 'cookie' | null
const neteaseLoginMode = ref<'qr' | 'cookie' | null>(null); const neteaseLoginMode = ref<'qr' | 'cookie' | null>(null);
const qqLoginMode = ref<'qr' | 'cookie' | null>(null); const qqLoginMode = ref<'qr' | 'cookie' | null>(null);
@@ -1009,6 +1207,158 @@ const neteaseAuth = reactive({ loggedIn: false, nickname: '', avatarUrl: '' });
const qqAuth = reactive({ loggedIn: false, nickname: '', avatarUrl: '' }); const qqAuth = reactive({ loggedIn: false, nickname: '', avatarUrl: '' });
const bilibiliAuth = reactive({ loggedIn: false, nickname: '', avatarUrl: '' }); const bilibiliAuth = reactive({ loggedIn: false, nickname: '', avatarUrl: '' });
const kugouAuth = reactive({ loggedIn: false, nickname: '', avatarUrl: '' }); const kugouAuth = reactive({ loggedIn: false, nickname: '', avatarUrl: '' });
const jellyfinAuth = reactive({ loggedIn: false, nickname: '' });
// Server-side source gate (enabledProviders): cards for disabled sources are
// hidden. Empty until GET /api/bot/settings responds — treat as all-on so a
// slow load doesn't blank the page.
const enabledProviders = ref<string[]>([]);
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: '',
authMode: 'userpass' as 'userpass' | 'apikey',
username: '',
password: '',
apiKey: '',
userId: '',
});
const jellyfinHasPassword = ref(false);
const jellyfinHasApiKey = ref(false);
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.
function applyJellyfinConfig(jf: any) {
if (!jf || typeof jf !== 'object') return;
jellyfinForm.serverUrl = typeof jf.serverUrl === 'string' ? jf.serverUrl : '';
if (jf.authMode === 'userpass' || jf.authMode === 'apikey') jellyfinForm.authMode = jf.authMode;
jellyfinForm.username = typeof jf.username === 'string' ? jf.username : '';
jellyfinForm.userId = typeof jf.userId === 'string' ? jf.userId : '';
jellyfinForm.password = ''; // blank on load; blank on save = unchanged
jellyfinForm.apiKey = '';
jellyfinHasPassword.value = Boolean(jf.hasPassword);
jellyfinHasApiKey.value = Boolean(jf.hasApiKey);
}
async function saveJellyfin() {
jellyfinSaving.value = true;
jellyfinMessage.value = '';
try {
// 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();
} catch (err: any) {
jellyfinMessageTone.value = 'warn';
jellyfinMessage.value = err?.response?.status === 403
? '没有权限修改设置(需要 bot.manage)'
: '保存失败,请稍后重试';
} finally {
jellyfinSaving.value = false;
}
}
// Round-trips /System/Info with the CURRENT form values (empty secret fields
// fall back to the stored ones server-side), without touching saved config.
async function testJellyfin() {
jellyfinTesting.value = true;
jellyfinMessage.value = '';
try {
const res = await axios.post('/api/auth/jellyfin/test', { ...jellyfinForm });
if (res.data?.ok) {
jellyfinMessageTone.value = 'ok';
jellyfinMessage.value = `连接成功:${res.data.serverName ?? 'Jellyfin'} ${res.data.version ?? ''}`;
} else {
jellyfinMessageTone.value = 'warn';
jellyfinMessage.value = `连接失败:${res.data?.error ?? '未知错误'}`;
}
} catch (err: any) {
jellyfinMessageTone.value = 'warn';
jellyfinMessage.value = err?.response?.status === 403
? '没有权限执行平台登录(需要 platform.auth)'
: '测试请求失败,请稍后重试';
} finally {
jellyfinTesting.value = false;
}
}
// QR state // QR state
interface QrState { interface QrState {
@@ -1039,19 +1389,22 @@ function getQrState(platform: string): QrState {
} }
async function checkAuthStatus() { async function checkAuthStatus() {
try { // allSettled: one slow/unconfigured platform must not hide the others'
const [nRes, qRes, bRes, kRes] = await Promise.all([ // status (jellyfin probes its server with a real HTTP round-trip).
const [nRes, qRes, bRes, kRes, jRes] = await Promise.allSettled([
axios.get('/api/auth/status', { params: { platform: 'netease' } }), axios.get('/api/auth/status', { params: { platform: 'netease' } }),
axios.get('/api/auth/status', { params: { platform: 'qq' } }), axios.get('/api/auth/status', { params: { platform: 'qq' } }),
axios.get('/api/auth/status', { params: { platform: 'bilibili' } }), axios.get('/api/auth/status', { params: { platform: 'bilibili' } }),
axios.get('/api/auth/status', { params: { platform: 'kugou' } }), axios.get('/api/auth/status', { params: { platform: 'kugou' } }),
axios.get('/api/auth/status', { params: { platform: 'jellyfin' } }),
]); ]);
Object.assign(neteaseAuth, nRes.data); if (nRes.status === 'fulfilled') Object.assign(neteaseAuth, nRes.value.data);
Object.assign(qqAuth, qRes.data); if (qRes.status === 'fulfilled') Object.assign(qqAuth, qRes.value.data);
Object.assign(bilibiliAuth, bRes.data); if (bRes.status === 'fulfilled') Object.assign(bilibiliAuth, bRes.value.data);
Object.assign(kugouAuth, kRes.data); if (kRes.status === 'fulfilled') Object.assign(kugouAuth, kRes.value.data);
} catch { if (jRes.status === 'fulfilled') {
// API not ready jellyfinAuth.loggedIn = Boolean(jRes.value.data?.loggedIn);
jellyfinAuth.nickname = jRes.value.data?.nickname ?? '';
} }
} }
@@ -1243,6 +1596,9 @@ const idleTimeout = ref(0);
// Defaults OFF to match the backend default (config.ts getDefaultConfig). // Defaults OFF to match the backend default (config.ts getDefaultConfig).
const autoPauseOnEmpty = ref(false); const autoPauseOnEmpty = ref(false);
const localAudioEnabled = ref(true); const localAudioEnabled = ref(true);
// Saved-queues + play-keeps-queue toggles (#119), both default OFF.
const savedQueuesEnabled = ref(false);
const playKeepsQueue = ref(false);
async function loadIdleTimeout() { async function loadIdleTimeout() {
try { try {
@@ -1250,9 +1606,20 @@ async function loadIdleTimeout() {
idleTimeout.value = res.data.idleTimeoutMinutes ?? 0; idleTimeout.value = res.data.idleTimeoutMinutes ?? 0;
autoPauseOnEmpty.value = res.data.autoPauseOnEmpty ?? false; autoPauseOnEmpty.value = res.data.autoPauseOnEmpty ?? false;
localAudioEnabled.value = res.data.localAudioEnabled ?? true; localAudioEnabled.value = res.data.localAudioEnabled ?? true;
savedQueuesEnabled.value = res.data.savedQueuesEnabled ?? false;
playKeepsQueue.value = res.data.playKeepsQueue ?? false;
store.savedQueuesEnabled = savedQueuesEnabled.value;
applyGuestModeFromServer(res.data.guestMode); applyGuestModeFromServer(res.data.guestMode);
applyAdminGroupsFromServer(res.data.adminGroups); applyAdminGroupsFromServer(res.data.adminGroups);
applySpotifyConfig(res.data.spotify); applySpotifyConfig(res.data.spotify);
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 */ } } catch { /* ignore */ }
} }
@@ -1275,6 +1642,22 @@ async function saveLocalAudioEnabled() {
} catch { /* ignore */ } } catch { /* ignore */ }
} }
async function saveSavedQueuesEnabled() {
try {
const res = await axios.post('/api/bot/settings', { savedQueuesEnabled: savedQueuesEnabled.value });
savedQueuesEnabled.value = res.data.savedQueuesEnabled ?? savedQueuesEnabled.value;
// Keep the shared store flag (nav gate) in sync without a reload.
store.savedQueuesEnabled = savedQueuesEnabled.value;
} catch { /* ignore */ }
}
async function savePlayKeepsQueue() {
try {
const res = await axios.post('/api/bot/settings', { playKeepsQueue: playKeepsQueue.value });
playKeepsQueue.value = res.data.playKeepsQueue ?? playKeepsQueue.value;
} catch { /* ignore */ }
}
// --- Spotify (Connect) settings (§8) --- // --- Spotify (Connect) settings (§8) ---
// NOTE: This card's status comes ONLY from /api/spotify/status (playback OAuth // NOTE: This card's status comes ONLY from /api/spotify/status (playback OAuth
// `authorized`). It is deliberately NOT wired to the player store's // `authorized`). It is deliberately NOT wired to the player store's
@@ -1989,6 +2372,10 @@ onUnmounted(() => {
&.kugou-icon { &.kugou-icon {
color: var(--brand-kugou); color: var(--brand-kugou);
} }
&.jellyfin-icon {
color: var(--brand-jellyfin);
}
} }
.account-name { .account-name {
@@ -2191,6 +2578,13 @@ onUnmounted(() => {
.input-sm { max-width: 80px; } .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 { .textarea {
width: 100%; width: 100%;
padding: 10px 14px; padding: 10px 14px;
+107 -5
View File
@@ -60,12 +60,49 @@
</div> </div>
<div v-if="currentStep === 2" class="step-content"> <div v-if="currentStep === 2" class="step-content">
<h2>登录音乐账号 (可选)</h2> <h2>连接 Jellyfin (可选)</h2>
<p class="subtitle">登录后可播放 VIP/付费歌曲,跳过则只能播放免费歌曲</p> <p class="subtitle">连接自建 Jellyfin 服务器作为额外音源;保存后自动启用,也可稍后在「设置」中配置</p>
<div class="form-group">
<label>服务器地址</label>
<input v-model="jellyfin.serverUrl" placeholder="https://jellyfin.example.com" class="input" />
</div>
<div class="form-group">
<label>认证方式</label>
<select v-model="jellyfin.authMode" class="input">
<option value="userpass">账号密码</option>
<option value="apikey">API Key</option>
</select>
</div>
<template v-if="jellyfin.authMode === 'userpass'">
<div class="form-group">
<label>用户名</label>
<input v-model="jellyfin.username" placeholder="Jellyfin 用户名" class="input" />
</div>
<div class="form-group">
<label>密码</label>
<input v-model="jellyfin.password" type="password" placeholder="Jellyfin 密码" class="input" />
</div>
</template>
<template v-else>
<div class="form-group">
<label>API Key</label>
<input v-model="jellyfin.apiKey" type="password" placeholder="Jellyfin API Key" class="input" />
</div>
<div class="form-group">
<label>用户 ID</label>
<input v-model="jellyfin.userId" placeholder="该 Key 使用的用户 ID" class="input" />
</div>
</template>
<p v-if="jellyfinTestMessage" class="test-message" :class="{ ok: jellyfinTestOk }">{{ jellyfinTestMessage }}</p>
<div class="btn-row"> <div class="btn-row">
<button class="btn-secondary" @click="currentStep = 1">上一步</button> <button class="btn-secondary" @click="currentStep = 1">上一步</button>
<button class="btn-secondary" @click="currentStep = 3">跳过</button> <button class="btn-secondary" @click="currentStep = 3">跳过</button>
<button class="btn-primary" @click="currentStep = 3">完成登录</button> <button class="btn-secondary" :disabled="jellyfinTesting" @click="testJellyfin">
{{ jellyfinTesting ? '测试中…' : '测试连接' }}
</button>
<button class="btn-primary" :disabled="jellyfinSaving" @click="saveJellyfinAndNext">
{{ jellyfinSaving ? '保存中…' : '保存并继续' }}
</button>
</div> </div>
</div> </div>
@@ -78,11 +115,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref, reactive } from 'vue';
import axios from 'axios'; import axios from 'axios';
const currentStep = ref(0); const currentStep = ref(0);
const stepLabels = ['欢迎', 'TS 服务器', '音乐账号', '完成']; const stepLabels = ['欢迎', 'TS 服务器', 'Jellyfin', '完成'];
const adminPassword = ref(''); const adminPassword = ref('');
const locale = ref('zh'); const locale = ref('zh');
@@ -93,6 +130,21 @@ const nickname = ref('MusicBot');
const defaultChannel = ref(''); const defaultChannel = ref('');
const channelId = ref(''); const channelId = ref('');
// 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',
username: '',
password: '',
apiKey: '',
userId: '',
});
const jellyfinTesting = ref(false);
const jellyfinSaving = ref(false);
const jellyfinTestOk = ref(false);
const jellyfinTestMessage = ref('');
async function createBotAndNext() { async function createBotAndNext() {
try { try {
await axios.post('/api/bot', { await axios.post('/api/bot', {
@@ -109,6 +161,48 @@ async function createBotAndNext() {
alert('Failed to create bot: ' + (err as Error).message); alert('Failed to create bot: ' + (err as Error).message);
} }
} }
async function testJellyfin() {
jellyfinTesting.value = true;
jellyfinTestMessage.value = '';
try {
const res = await axios.post('/api/auth/jellyfin/test', { ...jellyfin });
jellyfinTestOk.value = Boolean(res.data?.ok);
jellyfinTestMessage.value = res.data?.ok
? `连接成功:${res.data.serverName ?? 'Jellyfin'} ${res.data.version ?? ''}`
: `连接失败:${res.data?.error ?? '未知错误'}`;
} catch {
jellyfinTestOk.value = false;
jellyfinTestMessage.value = '测试请求失败,请检查地址后重试';
} finally {
jellyfinTesting.value = false;
}
}
async function saveJellyfinAndNext() {
jellyfinSaving.value = true;
try {
// 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;
jellyfinTestMessage.value = '保存失败,请稍后在「设置」中重试';
} finally {
jellyfinSaving.value = false;
}
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -232,6 +326,14 @@ async function createBotAndNext() {
margin-top: 32px; margin-top: 32px;
} }
.test-message {
font-size: 13px;
color: #e67e22;
margin-top: -6px;
&.ok { color: var(--color-online, #22c55e); }
}
.done-step { .done-step {
text-align: center; text-align: center;
padding-top: 60px; padding-top: 60px;
+93
View File
@@ -0,0 +1,93 @@
import { describe, it, expect } from "vitest";
import { itemKey, mergeDedup, hasMore, nextOffset, type Keyed } from "./searchPagination.js";
const item = (platform: string, id: string): Keyed & { label: string } => ({
platform,
id,
label: `${platform}:${id}`,
});
describe("searchPagination helpers (#115)", () => {
describe("itemKey", () => {
it("builds a `${platform}:${id}` key", () => {
expect(itemKey({ platform: "netease", id: "42" })).toBe("netease:42");
});
it("distinguishes same id across platforms", () => {
expect(itemKey({ platform: "qq", id: "1" })).not.toBe(itemKey({ platform: "netease", id: "1" }));
});
});
describe("mergeDedup", () => {
it("appends incoming items, existing first, order preserved", () => {
const existing = [item("netease", "1"), item("netease", "2")];
const incoming = [item("netease", "3"), item("netease", "4")];
expect(mergeDedup(existing, incoming).map((x) => x.id)).toEqual(["1", "2", "3", "4"]);
});
it("drops incoming items already present in existing", () => {
const existing = [item("netease", "1"), item("netease", "2")];
const incoming = [item("netease", "2"), item("netease", "3")];
expect(mergeDedup(existing, incoming).map((x) => x.id)).toEqual(["1", "2", "3"]);
});
it("drops duplicates within the incoming batch", () => {
const existing = [item("netease", "1")];
const incoming = [item("netease", "2"), item("netease", "2"), item("netease", "3")];
expect(mergeDedup(existing, incoming).map((x) => x.id)).toEqual(["1", "2", "3"]);
});
it("treats same id on different platforms as distinct", () => {
const existing = [item("netease", "1")];
const incoming = [item("qq", "1")];
const merged = mergeDedup(existing, incoming);
expect(merged.map(itemKey)).toEqual(["netease:1", "qq:1"]);
});
it("does not mutate the existing array", () => {
const existing = [item("netease", "1")];
const before = existing.slice();
mergeDedup(existing, [item("netease", "2")]);
expect(existing).toEqual(before);
});
it("handles empty incoming", () => {
const existing = [item("netease", "1")];
expect(mergeDedup(existing, []).map((x) => x.id)).toEqual(["1"]);
});
});
describe("hasMore", () => {
it("is true when a full page came back", () => {
expect(hasMore(20, 20)).toBe(true);
});
it("is false when a short page came back", () => {
expect(hasMore(7, 20)).toBe(false);
});
it("is false when nothing came back", () => {
expect(hasMore(0, 20)).toBe(false);
});
});
describe("nextOffset", () => {
it("returns the page-aligned offset for a full first page", () => {
expect(nextOffset(20, 20)).toBe(20);
});
it("returns 0 when nothing is shown yet", () => {
expect(nextOffset(0, 20)).toBe(0);
});
it("rounds up to the next page boundary after dedup drops items", () => {
// page1 (20) + page2 minus 5 dupes -> 35 shown, next page cursor is 40.
expect(nextOffset(35, 20)).toBe(40);
});
it("stays aligned across multiple full pages", () => {
expect(nextOffset(40, 20)).toBe(40);
expect(nextOffset(60, 20)).toBe(60);
});
});
});
+46
View File
@@ -0,0 +1,46 @@
// Pure pagination helpers for Search.vue "加载更多" (load-more) per source + tab.
// Kept framework-free so root vitest can unit-cover the logic (see searchPagination.test.ts).
/** Minimal shape shared by songs / albums / playlists: needs a stable dedup key. */
export interface Keyed {
id: string;
platform: string;
}
/** Stable dedup key for a result item: `${platform}:${id}`. */
export function itemKey(item: Keyed): string {
return `${item.platform}:${item.id}`;
}
/**
* Merge `incoming` into `existing`, deduped by `${platform}:${id}`.
* Order is preserved with existing items first; incoming items already present
* (or duplicated within the incoming batch) are dropped.
*/
export function mergeDedup<T extends Keyed>(existing: T[], incoming: T[]): T[] {
const seen = new Set<string>(existing.map(itemKey));
const result = existing.slice();
for (const item of incoming) {
const key = itemKey(item);
if (seen.has(key)) continue;
seen.add(key);
result.push(item);
}
return result;
}
/**
* Whether another page might exist: a full page (=== pageSize) means keep the
* button; a short/empty page (< pageSize) means the source is exhausted.
*/
export function hasMore(returnedCount: number, pageSize: number): boolean {
return returnedCount >= pageSize;
}
/**
* Offset for the next page request. Offsets are page-aligned, so this is simply
* the number of items already shown for that source+type.
*/
export function nextOffset(currentCountForSource: number, pageSize: number): number {
return Math.ceil(currentCountForSource / pageSize) * pageSize;
}